Update main.py
Browse files
main.py
CHANGED
|
@@ -41,12 +41,12 @@ for d in [AUDIO_DIR, OUTPUT_DIR, JOBS_DIR, CLIENT_DIR]:
|
|
| 41 |
|
| 42 |
# Rate limiting
|
| 43 |
RATE_LIMITS: Dict[str, list] = {}
|
| 44 |
-
GENERATE_LIMIT
|
| 45 |
-
STATUS_LIMIT
|
| 46 |
|
| 47 |
# Job timeout
|
| 48 |
-
JOBS: Dict[str, Dict]
|
| 49 |
-
JOB_TIMEOUT
|
| 50 |
|
| 51 |
# Video listing cache
|
| 52 |
VIDEO_CACHE: Dict[str, tuple] = {}
|
|
@@ -55,12 +55,12 @@ CACHE_TIMEOUT = 300
|
|
| 55 |
# =============================================================================
|
| 56 |
# QUEUE SYSTEM
|
| 57 |
# =============================================================================
|
| 58 |
-
MAX_CONCURRENT
|
| 59 |
-
AVG_JOB_DURATION_S
|
| 60 |
|
| 61 |
-
_JOB_QUEUE:
|
| 62 |
-
_RUNNING_JOBS: set
|
| 63 |
-
_QUEUE_LOCK:
|
| 64 |
|
| 65 |
# =============================================================================
|
| 66 |
# CLIENT ID SYSTEM
|
|
@@ -112,7 +112,7 @@ def _create_client(fingerprint: str) -> Dict:
|
|
| 112 |
"jobs": [],
|
| 113 |
}
|
| 114 |
_save_client(rec)
|
| 115 |
-
logger.info(f"
|
| 116 |
return rec
|
| 117 |
|
| 118 |
def _fingerprint(req: Request) -> str:
|
|
@@ -142,7 +142,7 @@ def resolve_client(req: Request) -> Dict:
|
|
| 142 |
"jobs": [],
|
| 143 |
}
|
| 144 |
_save_client(rec)
|
| 145 |
-
logger.info(f"
|
| 146 |
return rec
|
| 147 |
|
| 148 |
fp = _fingerprint(req)
|
|
@@ -168,7 +168,7 @@ logger = logging.getLogger(__name__)
|
|
| 168 |
# =============================================================================
|
| 169 |
app = FastAPI(
|
| 170 |
title="ArchNemix Shorts Generator API",
|
| 171 |
-
version="5.
|
| 172 |
docs_url="/docs",
|
| 173 |
redoc_url="/redoc",
|
| 174 |
)
|
|
@@ -195,8 +195,8 @@ app.add_middleware(
|
|
| 195 |
# MODELS
|
| 196 |
# =============================================================================
|
| 197 |
class GenerateRequest(BaseModel):
|
| 198 |
-
audio_base64: str
|
| 199 |
-
subtitles_ass: str
|
| 200 |
background: str
|
| 201 |
duration: Optional[float] = Field(None, ge=1, le=180)
|
| 202 |
request_id: Optional[str] = None
|
|
@@ -323,7 +323,7 @@ def _make_job(job_id: str, client_token: str) -> Dict:
|
|
| 323 |
"status": "queued",
|
| 324 |
"queue_position": None,
|
| 325 |
"progress": 0,
|
| 326 |
-
"message": "Waiting in queue
|
| 327 |
"created_at": datetime.now(),
|
| 328 |
"updated_at": datetime.now(),
|
| 329 |
"error": None,
|
|
@@ -376,10 +376,10 @@ def cleanup_old_jobs():
|
|
| 376 |
except ValueError:
|
| 377 |
pass
|
| 378 |
for p in [
|
| 379 |
-
JOBS_DIR
|
| 380 |
-
AUDIO_DIR
|
| 381 |
-
AUDIO_DIR
|
| 382 |
-
AUDIO_DIR
|
| 383 |
OUTPUT_DIR / f"{job_id}.mp4",
|
| 384 |
]:
|
| 385 |
try:
|
|
@@ -400,10 +400,10 @@ def queue_position(job_id: str) -> Optional[int]:
|
|
| 400 |
def queue_eta(position: int, job_duration: Optional[float] = None) -> Dict:
|
| 401 |
running_remaining = AVG_JOB_DURATION_S
|
| 402 |
for jid in _RUNNING_JOBS:
|
| 403 |
-
j
|
| 404 |
total = j.get("total_duration", 0) or AVG_JOB_DURATION_S
|
| 405 |
out = j.get("ffmpeg_out_time", 0) or 0
|
| 406 |
-
remaining
|
| 407 |
running_remaining = min(running_remaining, remaining)
|
| 408 |
|
| 409 |
queued_ahead = max(0, position - 1)
|
|
@@ -421,13 +421,13 @@ def queue_eta(position: int, job_duration: Optional[float] = None) -> Dict:
|
|
| 421 |
return f"{m}m {sec:02d}s" if sec else f"{m}m"
|
| 422 |
|
| 423 |
return {
|
| 424 |
-
"queue_position":
|
| 425 |
-
"jobs_ahead":
|
| 426 |
-
"wait_low_s":
|
| 427 |
-
"wait_high_s":
|
| 428 |
-
"wait_low_human":
|
| 429 |
-
"wait_high_human":
|
| 430 |
-
"total_with_own_s":
|
| 431 |
"message": (
|
| 432 |
f"Position {position} in queue β "
|
| 433 |
f"roughly {_fmt(low_s)}β{_fmt(high_s)} wait, "
|
|
@@ -441,12 +441,11 @@ async def _queue_worker():
|
|
| 441 |
|
| 442 |
if len(_RUNNING_JOBS) >= MAX_CONCURRENT:
|
| 443 |
continue
|
| 444 |
-
|
| 445 |
if not _JOB_QUEUE:
|
| 446 |
continue
|
| 447 |
|
| 448 |
job_id = _JOB_QUEUE[0]
|
| 449 |
-
job
|
| 450 |
if not job or job["status"] in ("failed", "completed"):
|
| 451 |
_JOB_QUEUE.popleft()
|
| 452 |
continue
|
|
@@ -474,9 +473,9 @@ async def _run_and_release(job_id, audio_path, subs_path, background, duration):
|
|
| 474 |
def _refresh_queue_positions():
|
| 475 |
for i, jid in enumerate(_JOB_QUEUE):
|
| 476 |
if jid in JOBS:
|
| 477 |
-
pos
|
| 478 |
JOBS[jid]["queue_position"] = pos
|
| 479 |
-
JOBS[jid]["message"]
|
| 480 |
_persist_job(jid)
|
| 481 |
|
| 482 |
# =============================================================================
|
|
@@ -489,7 +488,7 @@ def _kill_ffmpeg_for_job(job_id: str):
|
|
| 489 |
if proc and proc.returncode is None:
|
| 490 |
try:
|
| 491 |
proc.kill()
|
| 492 |
-
logger.info(f"
|
| 493 |
except Exception:
|
| 494 |
pass
|
| 495 |
|
|
@@ -498,27 +497,27 @@ def _parse_ffmpeg_kv(line: str) -> Dict:
|
|
| 498 |
return {parts[0].strip(): parts[1].strip()} if len(parts) == 2 else {}
|
| 499 |
|
| 500 |
async def _stream_ffmpeg_progress(
|
| 501 |
-
proc:
|
| 502 |
-
job_id:
|
| 503 |
total_duration: float,
|
| 504 |
progress_start: int = 50,
|
| 505 |
progress_end: int = 95,
|
| 506 |
):
|
| 507 |
frame = fps = speed = 0
|
| 508 |
-
out_time_s
|
| 509 |
last_update_time = time.time()
|
| 510 |
|
| 511 |
async for raw_line in proc.stderr:
|
| 512 |
line = raw_line.decode(errors="replace").strip()
|
| 513 |
if not line:
|
| 514 |
continue
|
| 515 |
-
|
| 516 |
kv = _parse_ffmpeg_kv(line)
|
| 517 |
if not kv:
|
| 518 |
continue
|
| 519 |
key, val = next(iter(kv.items()))
|
| 520 |
|
| 521 |
-
if key == "frame":
|
| 522 |
frame = int(val) if val.isdigit() else frame
|
| 523 |
elif key == "fps":
|
| 524 |
try: fps = float(val)
|
|
@@ -528,13 +527,13 @@ async def _stream_ffmpeg_progress(
|
|
| 528 |
except ValueError: pass
|
| 529 |
elif key == "out_time":
|
| 530 |
try:
|
| 531 |
-
parts = val.split(
|
| 532 |
if len(parts) == 3:
|
| 533 |
h, m, s = parts
|
| 534 |
out_time_s = int(h) * 3600 + int(m) * 60 + float(s)
|
| 535 |
except ValueError: pass
|
| 536 |
elif key == "speed":
|
| 537 |
-
try:
|
| 538 |
speed = float(val.replace("x", ""))
|
| 539 |
except ValueError: pass
|
| 540 |
elif key == "progress":
|
|
@@ -553,8 +552,8 @@ async def _stream_ffmpeg_progress(
|
|
| 553 |
total_duration=total_duration,
|
| 554 |
message=(
|
| 555 |
f"Encoding {out_time_s:.1f}s / {total_duration:.1f}s "
|
| 556 |
-
f"({speed:.1f}
|
| 557 |
-
if total_duration > 0 and speed > 0 else "Encoding
|
| 558 |
),
|
| 559 |
)
|
| 560 |
if val == "end":
|
|
@@ -574,9 +573,9 @@ async def process_video_task(
|
|
| 574 |
duration: Optional[float],
|
| 575 |
):
|
| 576 |
try:
|
| 577 |
-
logger.info(f"
|
| 578 |
update_job(job_id, status="processing", progress=10,
|
| 579 |
-
message="Downloading background video
|
| 580 |
|
| 581 |
try:
|
| 582 |
bg_path = await download_video_from_dataset(background, "minecraft")
|
|
@@ -584,33 +583,33 @@ async def process_video_task(
|
|
| 584 |
update_job(job_id, status="failed", error=str(e.detail))
|
| 585 |
return
|
| 586 |
|
| 587 |
-
update_job(job_id, progress=20, message="Analysing media
|
| 588 |
|
| 589 |
audio_dur = duration or get_media_duration(audio_path)
|
| 590 |
video_dur = get_media_duration(Path(bg_path))
|
| 591 |
|
| 592 |
if not (1 <= audio_dur <= 180):
|
| 593 |
-
update_job(job_id, status="failed", error="Audio duration out of range (1
|
| 594 |
return
|
| 595 |
|
| 596 |
logger.info(f"Job {job_id}: audio={audio_dur:.2f}s bg={video_dur:.2f}s")
|
| 597 |
|
| 598 |
if video_dur > audio_dur:
|
| 599 |
-
max_start
|
| 600 |
-
start
|
| 601 |
bg_input_args = [
|
| 602 |
"-ss", f"{start:.3f}",
|
| 603 |
"-t", f"{audio_dur:.3f}",
|
| 604 |
"-i", str(bg_path),
|
| 605 |
]
|
| 606 |
-
logger.info(f"Job {job_id}: trim start={start:.2f}s
|
| 607 |
else:
|
| 608 |
-
loop_n
|
| 609 |
bg_input_args = [
|
| 610 |
"-stream_loop", str(loop_n),
|
| 611 |
"-i", str(bg_path),
|
| 612 |
]
|
| 613 |
-
logger.info(f"Job {job_id}: loop
|
| 614 |
|
| 615 |
subs_str = str(subs_path).replace("\\", "/").replace(":", "\\:")
|
| 616 |
vf = (
|
|
@@ -633,17 +632,17 @@ async def process_video_task(
|
|
| 633 |
"-filter_complex", vf,
|
| 634 |
"-map", "[v]",
|
| 635 |
"-map", "1:a",
|
| 636 |
-
"-c:v",
|
| 637 |
-
"-preset",
|
| 638 |
-
"-crf",
|
| 639 |
-
"-minrate",
|
| 640 |
-
"-maxrate",
|
| 641 |
-
"-bufsize",
|
| 642 |
"-profile:v", "high",
|
| 643 |
"-level:v", "4.2",
|
| 644 |
"-pix_fmt", "yuv420p",
|
| 645 |
-
"-c:a",
|
| 646 |
-
"-b:a",
|
| 647 |
"-shortest",
|
| 648 |
"-movflags", "+faststart",
|
| 649 |
"-threads", "0",
|
|
@@ -652,11 +651,7 @@ async def process_video_task(
|
|
| 652 |
str(output_path),
|
| 653 |
]
|
| 654 |
|
| 655 |
-
update_job(job_id, progress=40, message="Encoding
|
| 656 |
-
logger.info(
|
| 657 |
-
f"Job {job_id}: CRF=16 preset=medium "
|
| 658 |
-
f"minrate=8M maxrate=16M bufsize=16M threads=auto"
|
| 659 |
-
)
|
| 660 |
|
| 661 |
timeout = max(480, int(audio_dur * 5) + 120)
|
| 662 |
|
|
@@ -695,7 +690,7 @@ async def process_video_task(
|
|
| 695 |
return
|
| 696 |
|
| 697 |
size_mb = output_path.stat().st_size / 1024 / 1024
|
| 698 |
-
logger.info(f"
|
| 699 |
|
| 700 |
for p in [audio_path, subs_path]:
|
| 701 |
try:
|
|
@@ -707,7 +702,7 @@ async def process_video_task(
|
|
| 707 |
job_id,
|
| 708 |
status="completed",
|
| 709 |
progress=100,
|
| 710 |
-
message=f"Done! {size_mb:.1f} MB
|
| 711 |
output_path=str(output_path),
|
| 712 |
)
|
| 713 |
|
|
@@ -715,6 +710,24 @@ async def process_video_task(
|
|
| 715 |
logger.exception(f"process_video_task unhandled for {job_id}")
|
| 716 |
update_job(job_id, status="failed", error="Unexpected server error")
|
| 717 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 718 |
# =============================================================================
|
| 719 |
# API ENDPOINTS
|
| 720 |
# =============================================================================
|
|
@@ -723,18 +736,19 @@ async def process_video_task(
|
|
| 723 |
async def root():
|
| 724 |
return {
|
| 725 |
"name": "ArchNemix Shorts Generator API",
|
| 726 |
-
"version": "5.
|
| 727 |
"queue": {
|
| 728 |
-
"max_concurrent":
|
| 729 |
"avg_job_duration_s": AVG_JOB_DURATION_S,
|
| 730 |
},
|
| 731 |
"endpoints": {
|
| 732 |
-
"register": "GET /register
|
| 733 |
-
"generate": "POST /generate
|
| 734 |
-
"cancel": "DELETE /job/{id}
|
| 735 |
-
"job_status": "GET /job/{id}
|
| 736 |
-
"queue": "GET /queue
|
| 737 |
-
"
|
|
|
|
| 738 |
"delete": "DELETE /video/{id}",
|
| 739 |
"videos": "GET /videos/{category}",
|
| 740 |
"health": "GET /health",
|
|
@@ -777,12 +791,12 @@ async def queue_status():
|
|
| 777 |
running = len(_RUNNING_JOBS)
|
| 778 |
waiting = len(_JOB_QUEUE)
|
| 779 |
return {
|
| 780 |
-
"running":
|
| 781 |
-
"waiting":
|
| 782 |
-
"total_active":
|
| 783 |
-
"avg_job_s":
|
| 784 |
-
"estimated_wait_s":
|
| 785 |
-
|
| 786 |
"message": (
|
| 787 |
"No jobs running β submit yours now!" if (running + waiting) == 0 else
|
| 788 |
f"{running} running, {waiting} in queue"
|
|
@@ -799,9 +813,9 @@ async def list_videos(category: str = "minecraft"):
|
|
| 799 |
|
| 800 |
@app.post("/generate")
|
| 801 |
async def generate_video(
|
| 802 |
-
req:
|
| 803 |
background_tasks: BackgroundTasks,
|
| 804 |
-
http_req:
|
| 805 |
):
|
| 806 |
if not validate_app_key(http_req):
|
| 807 |
raise HTTPException(403, "Invalid API key")
|
|
@@ -836,7 +850,7 @@ async def generate_video(
|
|
| 836 |
create_job(job_id, token)
|
| 837 |
|
| 838 |
try:
|
| 839 |
-
|
| 840 |
await save_subtitles(req.subtitles_ass, subs_path)
|
| 841 |
except ValueError as e:
|
| 842 |
update_job(job_id, status="failed", error=str(e))
|
|
@@ -856,28 +870,23 @@ async def generate_video(
|
|
| 856 |
client["jobs"] = client["jobs"][-50:]
|
| 857 |
_save_client(client)
|
| 858 |
|
| 859 |
-
position = len(_JOB_QUEUE) + 1
|
| 860 |
-
if _RUNNING_JOBS:
|
| 861 |
-
pass
|
| 862 |
-
else:
|
| 863 |
-
position = 0
|
| 864 |
-
|
| 865 |
_JOB_QUEUE.append(job_id)
|
| 866 |
_refresh_queue_positions()
|
| 867 |
|
| 868 |
if position == 0:
|
| 869 |
-
eta = {"message": "Starting very soon
|
| 870 |
else:
|
| 871 |
eta = queue_eta(position, req.duration)
|
| 872 |
|
| 873 |
return JSONResponse(
|
| 874 |
{
|
| 875 |
-
"job_id":
|
| 876 |
-
"client_token":
|
| 877 |
-
"status":
|
| 878 |
-
"queue_position":
|
| 879 |
-
"queue":
|
| 880 |
-
"check_status":
|
| 881 |
},
|
| 882 |
headers={"X-Client-ID": token},
|
| 883 |
)
|
|
@@ -922,30 +931,66 @@ async def job_status(job_id: str, http_req: Request):
|
|
| 922 |
},
|
| 923 |
}
|
| 924 |
|
|
|
|
| 925 |
if job["status"] == "completed" and job.get("output_path"):
|
| 926 |
resp["download_url"] = f"/download/{job_id}"
|
|
|
|
|
|
|
| 927 |
if job.get("error"):
|
| 928 |
resp["error"] = job["error"]
|
| 929 |
|
| 930 |
return JSONResponse(resp)
|
| 931 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 932 |
@app.get("/download/{job_id}")
|
| 933 |
-
async def download_video(job_id: str):
|
|
|
|
|
|
|
|
|
|
|
|
|
| 934 |
output_path = OUTPUT_DIR / f"{job_id}.mp4"
|
| 935 |
if not output_path.exists():
|
| 936 |
raise HTTPException(404, "Video not found or not ready")
|
| 937 |
if job_id in JOBS and JOBS[job_id]["status"] != "completed":
|
| 938 |
raise HTTPException(400, "Video not ready yet")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 939 |
return FileResponse(
|
| 940 |
output_path,
|
| 941 |
media_type="video/mp4",
|
| 942 |
filename=f"archnemix-short-{job_id[:8]}.mp4",
|
| 943 |
headers={
|
| 944 |
"Content-Disposition": f'attachment; filename="archnemix-short-{job_id[:8]}.mp4"',
|
| 945 |
-
"Cache-Control":
|
| 946 |
},
|
| 947 |
)
|
| 948 |
|
|
|
|
| 949 |
@app.delete("/video/{job_id}")
|
| 950 |
async def delete_video(job_id: str, http_req: Request):
|
| 951 |
if not validate_app_key(http_req):
|
|
@@ -957,7 +1002,7 @@ async def delete_video(job_id: str, http_req: Request):
|
|
| 957 |
try:
|
| 958 |
output_path.unlink()
|
| 959 |
removed = True
|
| 960 |
-
logger.info(f"
|
| 961 |
except Exception as e:
|
| 962 |
raise HTTPException(500, f"Delete failed: {e}")
|
| 963 |
|
|
@@ -968,6 +1013,7 @@ async def delete_video(job_id: str, http_req: Request):
|
|
| 968 |
|
| 969 |
return {"job_id": job_id, "deleted": removed}
|
| 970 |
|
|
|
|
| 971 |
@app.delete("/job/{job_id}")
|
| 972 |
async def cancel_job(job_id: str, http_req: Request):
|
| 973 |
"""Called by controller when frontend goes silent."""
|
|
@@ -988,12 +1034,17 @@ async def cancel_job(job_id: str, http_req: Request):
|
|
| 988 |
except ValueError:
|
| 989 |
pass
|
| 990 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 991 |
update_job(job_id, status="failed", error="Cancelled by controller β client disconnected")
|
| 992 |
_refresh_queue_positions()
|
| 993 |
|
| 994 |
-
logger.info(f"
|
| 995 |
return {"job_id": job_id, "cancelled": True}
|
| 996 |
|
|
|
|
| 997 |
@app.get("/debug/dataset")
|
| 998 |
async def debug_dataset():
|
| 999 |
try:
|
|
@@ -1013,24 +1064,25 @@ async def startup():
|
|
| 1013 |
_QUEUE_LOCK = asyncio.Lock()
|
| 1014 |
|
| 1015 |
logger.info("=" * 60)
|
| 1016 |
-
logger.info("ArchNemix Shorts Generator API v5.
|
| 1017 |
logger.info(f"Dataset : {HF_DATASET}")
|
| 1018 |
logger.info(f"Concurrent : {MAX_CONCURRENT}")
|
| 1019 |
-
logger.info(f"Avg job : {AVG_JOB_DURATION_S}s ({AVG_JOB_DURATION_S//60}m)")
|
| 1020 |
-
logger.info("Quality : CRF 16 Β· medium Β· 8
|
| 1021 |
-
logger.info("
|
| 1022 |
logger.info("=" * 60)
|
| 1023 |
|
| 1024 |
asyncio.create_task(_queue_worker())
|
| 1025 |
|
| 1026 |
try:
|
| 1027 |
vids = await list_videos_from_dataset("minecraft")
|
| 1028 |
-
logger.info(f"
|
| 1029 |
except Exception as e:
|
| 1030 |
-
logger.warning(f"
|
| 1031 |
|
| 1032 |
cleanup_old_jobs()
|
| 1033 |
-
logger.info("
|
|
|
|
| 1034 |
|
| 1035 |
@app.on_event("shutdown")
|
| 1036 |
async def shutdown():
|
|
|
|
| 41 |
|
| 42 |
# Rate limiting
|
| 43 |
RATE_LIMITS: Dict[str, list] = {}
|
| 44 |
+
GENERATE_LIMIT = 3 # per hour per client
|
| 45 |
+
STATUS_LIMIT = 1000 # per hour per job
|
| 46 |
|
| 47 |
# Job timeout
|
| 48 |
+
JOBS: Dict[str, Dict] = {}
|
| 49 |
+
JOB_TIMEOUT = 900 # 15 min hard cap
|
| 50 |
|
| 51 |
# Video listing cache
|
| 52 |
VIDEO_CACHE: Dict[str, tuple] = {}
|
|
|
|
| 55 |
# =============================================================================
|
| 56 |
# QUEUE SYSTEM
|
| 57 |
# =============================================================================
|
| 58 |
+
MAX_CONCURRENT = 1
|
| 59 |
+
AVG_JOB_DURATION_S = 360 # 6 minutes per job
|
| 60 |
|
| 61 |
+
_JOB_QUEUE: deque = deque()
|
| 62 |
+
_RUNNING_JOBS: set = set()
|
| 63 |
+
_QUEUE_LOCK: asyncio.Lock = None # created at startup
|
| 64 |
|
| 65 |
# =============================================================================
|
| 66 |
# CLIENT ID SYSTEM
|
|
|
|
| 112 |
"jobs": [],
|
| 113 |
}
|
| 114 |
_save_client(rec)
|
| 115 |
+
logger.info(f"New client {rec['token'][:8]}...")
|
| 116 |
return rec
|
| 117 |
|
| 118 |
def _fingerprint(req: Request) -> str:
|
|
|
|
| 142 |
"jobs": [],
|
| 143 |
}
|
| 144 |
_save_client(rec)
|
| 145 |
+
logger.info(f"Restored client {token[:8]}... from header")
|
| 146 |
return rec
|
| 147 |
|
| 148 |
fp = _fingerprint(req)
|
|
|
|
| 168 |
# =============================================================================
|
| 169 |
app = FastAPI(
|
| 170 |
title="ArchNemix Shorts Generator API",
|
| 171 |
+
version="5.1.0",
|
| 172 |
docs_url="/docs",
|
| 173 |
redoc_url="/redoc",
|
| 174 |
)
|
|
|
|
| 195 |
# MODELS
|
| 196 |
# =============================================================================
|
| 197 |
class GenerateRequest(BaseModel):
|
| 198 |
+
audio_base64: str = Field(..., min_length=10)
|
| 199 |
+
subtitles_ass: str = Field(..., min_length=10)
|
| 200 |
background: str
|
| 201 |
duration: Optional[float] = Field(None, ge=1, le=180)
|
| 202 |
request_id: Optional[str] = None
|
|
|
|
| 323 |
"status": "queued",
|
| 324 |
"queue_position": None,
|
| 325 |
"progress": 0,
|
| 326 |
+
"message": "Waiting in queue...",
|
| 327 |
"created_at": datetime.now(),
|
| 328 |
"updated_at": datetime.now(),
|
| 329 |
"error": None,
|
|
|
|
| 376 |
except ValueError:
|
| 377 |
pass
|
| 378 |
for p in [
|
| 379 |
+
JOBS_DIR / f"{job_id}.json",
|
| 380 |
+
AUDIO_DIR / f"{job_id}.mp3",
|
| 381 |
+
AUDIO_DIR / f"{job_id}.ass",
|
| 382 |
+
AUDIO_DIR / f"{job_id}_trimmed.mp4",
|
| 383 |
OUTPUT_DIR / f"{job_id}.mp4",
|
| 384 |
]:
|
| 385 |
try:
|
|
|
|
| 400 |
def queue_eta(position: int, job_duration: Optional[float] = None) -> Dict:
|
| 401 |
running_remaining = AVG_JOB_DURATION_S
|
| 402 |
for jid in _RUNNING_JOBS:
|
| 403 |
+
j = JOBS.get(jid, {})
|
| 404 |
total = j.get("total_duration", 0) or AVG_JOB_DURATION_S
|
| 405 |
out = j.get("ffmpeg_out_time", 0) or 0
|
| 406 |
+
remaining = max(0, total - out)
|
| 407 |
running_remaining = min(running_remaining, remaining)
|
| 408 |
|
| 409 |
queued_ahead = max(0, position - 1)
|
|
|
|
| 421 |
return f"{m}m {sec:02d}s" if sec else f"{m}m"
|
| 422 |
|
| 423 |
return {
|
| 424 |
+
"queue_position": position,
|
| 425 |
+
"jobs_ahead": position,
|
| 426 |
+
"wait_low_s": low_s,
|
| 427 |
+
"wait_high_s": high_s,
|
| 428 |
+
"wait_low_human": _fmt(low_s),
|
| 429 |
+
"wait_high_human": _fmt(high_s),
|
| 430 |
+
"total_with_own_s": int(total_s),
|
| 431 |
"message": (
|
| 432 |
f"Position {position} in queue β "
|
| 433 |
f"roughly {_fmt(low_s)}β{_fmt(high_s)} wait, "
|
|
|
|
| 441 |
|
| 442 |
if len(_RUNNING_JOBS) >= MAX_CONCURRENT:
|
| 443 |
continue
|
|
|
|
| 444 |
if not _JOB_QUEUE:
|
| 445 |
continue
|
| 446 |
|
| 447 |
job_id = _JOB_QUEUE[0]
|
| 448 |
+
job = JOBS.get(job_id)
|
| 449 |
if not job or job["status"] in ("failed", "completed"):
|
| 450 |
_JOB_QUEUE.popleft()
|
| 451 |
continue
|
|
|
|
| 473 |
def _refresh_queue_positions():
|
| 474 |
for i, jid in enumerate(_JOB_QUEUE):
|
| 475 |
if jid in JOBS:
|
| 476 |
+
pos = i + 1
|
| 477 |
JOBS[jid]["queue_position"] = pos
|
| 478 |
+
JOBS[jid]["message"] = queue_eta(pos)["message"]
|
| 479 |
_persist_job(jid)
|
| 480 |
|
| 481 |
# =============================================================================
|
|
|
|
| 488 |
if proc and proc.returncode is None:
|
| 489 |
try:
|
| 490 |
proc.kill()
|
| 491 |
+
logger.info(f"Killed FFmpeg for job {job_id}")
|
| 492 |
except Exception:
|
| 493 |
pass
|
| 494 |
|
|
|
|
| 497 |
return {parts[0].strip(): parts[1].strip()} if len(parts) == 2 else {}
|
| 498 |
|
| 499 |
async def _stream_ffmpeg_progress(
|
| 500 |
+
proc: asyncio.subprocess.Process,
|
| 501 |
+
job_id: str,
|
| 502 |
total_duration: float,
|
| 503 |
progress_start: int = 50,
|
| 504 |
progress_end: int = 95,
|
| 505 |
):
|
| 506 |
frame = fps = speed = 0
|
| 507 |
+
out_time_s = 0.0
|
| 508 |
last_update_time = time.time()
|
| 509 |
|
| 510 |
async for raw_line in proc.stderr:
|
| 511 |
line = raw_line.decode(errors="replace").strip()
|
| 512 |
if not line:
|
| 513 |
continue
|
| 514 |
+
|
| 515 |
kv = _parse_ffmpeg_kv(line)
|
| 516 |
if not kv:
|
| 517 |
continue
|
| 518 |
key, val = next(iter(kv.items()))
|
| 519 |
|
| 520 |
+
if key == "frame":
|
| 521 |
frame = int(val) if val.isdigit() else frame
|
| 522 |
elif key == "fps":
|
| 523 |
try: fps = float(val)
|
|
|
|
| 527 |
except ValueError: pass
|
| 528 |
elif key == "out_time":
|
| 529 |
try:
|
| 530 |
+
parts = val.split(":")
|
| 531 |
if len(parts) == 3:
|
| 532 |
h, m, s = parts
|
| 533 |
out_time_s = int(h) * 3600 + int(m) * 60 + float(s)
|
| 534 |
except ValueError: pass
|
| 535 |
elif key == "speed":
|
| 536 |
+
try:
|
| 537 |
speed = float(val.replace("x", ""))
|
| 538 |
except ValueError: pass
|
| 539 |
elif key == "progress":
|
|
|
|
| 552 |
total_duration=total_duration,
|
| 553 |
message=(
|
| 554 |
f"Encoding {out_time_s:.1f}s / {total_duration:.1f}s "
|
| 555 |
+
f"({speed:.1f}x speed)"
|
| 556 |
+
if total_duration > 0 and speed > 0 else "Encoding..."
|
| 557 |
),
|
| 558 |
)
|
| 559 |
if val == "end":
|
|
|
|
| 573 |
duration: Optional[float],
|
| 574 |
):
|
| 575 |
try:
|
| 576 |
+
logger.info(f"Job {job_id}: starting encode")
|
| 577 |
update_job(job_id, status="processing", progress=10,
|
| 578 |
+
message="Downloading background video...", queue_position=None)
|
| 579 |
|
| 580 |
try:
|
| 581 |
bg_path = await download_video_from_dataset(background, "minecraft")
|
|
|
|
| 583 |
update_job(job_id, status="failed", error=str(e.detail))
|
| 584 |
return
|
| 585 |
|
| 586 |
+
update_job(job_id, progress=20, message="Analysing media...")
|
| 587 |
|
| 588 |
audio_dur = duration or get_media_duration(audio_path)
|
| 589 |
video_dur = get_media_duration(Path(bg_path))
|
| 590 |
|
| 591 |
if not (1 <= audio_dur <= 180):
|
| 592 |
+
update_job(job_id, status="failed", error="Audio duration out of range (1-180 s)")
|
| 593 |
return
|
| 594 |
|
| 595 |
logger.info(f"Job {job_id}: audio={audio_dur:.2f}s bg={video_dur:.2f}s")
|
| 596 |
|
| 597 |
if video_dur > audio_dur:
|
| 598 |
+
max_start = video_dur - audio_dur
|
| 599 |
+
start = random.uniform(0, max_start)
|
| 600 |
bg_input_args = [
|
| 601 |
"-ss", f"{start:.3f}",
|
| 602 |
"-t", f"{audio_dur:.3f}",
|
| 603 |
"-i", str(bg_path),
|
| 604 |
]
|
| 605 |
+
logger.info(f"Job {job_id}: trim start={start:.2f}s")
|
| 606 |
else:
|
| 607 |
+
loop_n = int(audio_dur / video_dur) + 2
|
| 608 |
bg_input_args = [
|
| 609 |
"-stream_loop", str(loop_n),
|
| 610 |
"-i", str(bg_path),
|
| 611 |
]
|
| 612 |
+
logger.info(f"Job {job_id}: loop x{loop_n}")
|
| 613 |
|
| 614 |
subs_str = str(subs_path).replace("\\", "/").replace(":", "\\:")
|
| 615 |
vf = (
|
|
|
|
| 632 |
"-filter_complex", vf,
|
| 633 |
"-map", "[v]",
|
| 634 |
"-map", "1:a",
|
| 635 |
+
"-c:v", "libx264",
|
| 636 |
+
"-preset", "medium",
|
| 637 |
+
"-crf", "16",
|
| 638 |
+
"-minrate", "8M",
|
| 639 |
+
"-maxrate", "16M",
|
| 640 |
+
"-bufsize", "16M",
|
| 641 |
"-profile:v", "high",
|
| 642 |
"-level:v", "4.2",
|
| 643 |
"-pix_fmt", "yuv420p",
|
| 644 |
+
"-c:a", "aac",
|
| 645 |
+
"-b:a", "192k",
|
| 646 |
"-shortest",
|
| 647 |
"-movflags", "+faststart",
|
| 648 |
"-threads", "0",
|
|
|
|
| 651 |
str(output_path),
|
| 652 |
]
|
| 653 |
|
| 654 |
+
update_job(job_id, progress=40, message="Encoding... (single-pass)", total_duration=audio_dur)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 655 |
|
| 656 |
timeout = max(480, int(audio_dur * 5) + 120)
|
| 657 |
|
|
|
|
| 690 |
return
|
| 691 |
|
| 692 |
size_mb = output_path.stat().st_size / 1024 / 1024
|
| 693 |
+
logger.info(f"Job {job_id}: done {size_mb:.2f} MB")
|
| 694 |
|
| 695 |
for p in [audio_path, subs_path]:
|
| 696 |
try:
|
|
|
|
| 702 |
job_id,
|
| 703 |
status="completed",
|
| 704 |
progress=100,
|
| 705 |
+
message=f"Done! {size_mb:.1f} MB",
|
| 706 |
output_path=str(output_path),
|
| 707 |
)
|
| 708 |
|
|
|
|
| 710 |
logger.exception(f"process_video_task unhandled for {job_id}")
|
| 711 |
update_job(job_id, status="failed", error="Unexpected server error")
|
| 712 |
|
| 713 |
+
# =============================================================================
|
| 714 |
+
# FILE CLEANUP HELPER
|
| 715 |
+
# =============================================================================
|
| 716 |
+
def _delete_output_file(job_id: str, output_path: Path):
|
| 717 |
+
"""
|
| 718 |
+
Delete the video file after it has been sent to the user.
|
| 719 |
+
Called as a background task from /download so /tmp stays clean.
|
| 720 |
+
"""
|
| 721 |
+
try:
|
| 722 |
+
output_path.unlink(missing_ok=True)
|
| 723 |
+
if job_id in JOBS:
|
| 724 |
+
JOBS[job_id]["output_path"] = None
|
| 725 |
+
JOBS[job_id]["message"] = "Downloaded and cleaned up"
|
| 726 |
+
_persist_job(job_id)
|
| 727 |
+
logger.info(f"Cleaned up {job_id} after download")
|
| 728 |
+
except Exception as e:
|
| 729 |
+
logger.warning(f"Cleanup failed for {job_id}: {e}")
|
| 730 |
+
|
| 731 |
# =============================================================================
|
| 732 |
# API ENDPOINTS
|
| 733 |
# =============================================================================
|
|
|
|
| 736 |
async def root():
|
| 737 |
return {
|
| 738 |
"name": "ArchNemix Shorts Generator API",
|
| 739 |
+
"version": "5.1.0",
|
| 740 |
"queue": {
|
| 741 |
+
"max_concurrent": MAX_CONCURRENT,
|
| 742 |
"avg_job_duration_s": AVG_JOB_DURATION_S,
|
| 743 |
},
|
| 744 |
"endpoints": {
|
| 745 |
+
"register": "GET /register",
|
| 746 |
+
"generate": "POST /generate",
|
| 747 |
+
"cancel": "DELETE /job/{id}",
|
| 748 |
+
"job_status": "GET /job/{id}",
|
| 749 |
+
"queue": "GET /queue",
|
| 750 |
+
"stream": "GET /stream/{id} β inline video playback",
|
| 751 |
+
"download": "GET /download/{id} β file download (deletes after)",
|
| 752 |
"delete": "DELETE /video/{id}",
|
| 753 |
"videos": "GET /videos/{category}",
|
| 754 |
"health": "GET /health",
|
|
|
|
| 791 |
running = len(_RUNNING_JOBS)
|
| 792 |
waiting = len(_JOB_QUEUE)
|
| 793 |
return {
|
| 794 |
+
"running": running,
|
| 795 |
+
"waiting": waiting,
|
| 796 |
+
"total_active": running + waiting,
|
| 797 |
+
"avg_job_s": AVG_JOB_DURATION_S,
|
| 798 |
+
"estimated_wait_s": waiting * AVG_JOB_DURATION_S if running == 0 else
|
| 799 |
+
(waiting + 1) * AVG_JOB_DURATION_S,
|
| 800 |
"message": (
|
| 801 |
"No jobs running β submit yours now!" if (running + waiting) == 0 else
|
| 802 |
f"{running} running, {waiting} in queue"
|
|
|
|
| 813 |
|
| 814 |
@app.post("/generate")
|
| 815 |
async def generate_video(
|
| 816 |
+
req: GenerateRequest,
|
| 817 |
background_tasks: BackgroundTasks,
|
| 818 |
+
http_req: Request,
|
| 819 |
):
|
| 820 |
if not validate_app_key(http_req):
|
| 821 |
raise HTTPException(403, "Invalid API key")
|
|
|
|
| 850 |
create_job(job_id, token)
|
| 851 |
|
| 852 |
try:
|
| 853 |
+
await save_base64_audio(req.audio_base64, audio_path)
|
| 854 |
await save_subtitles(req.subtitles_ass, subs_path)
|
| 855 |
except ValueError as e:
|
| 856 |
update_job(job_id, status="failed", error=str(e))
|
|
|
|
| 870 |
client["jobs"] = client["jobs"][-50:]
|
| 871 |
_save_client(client)
|
| 872 |
|
| 873 |
+
position = len(_JOB_QUEUE) + 1 if _RUNNING_JOBS else 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 874 |
_JOB_QUEUE.append(job_id)
|
| 875 |
_refresh_queue_positions()
|
| 876 |
|
| 877 |
if position == 0:
|
| 878 |
+
eta = {"message": "Starting very soon...", "wait_low_s": 0, "wait_high_s": 30}
|
| 879 |
else:
|
| 880 |
eta = queue_eta(position, req.duration)
|
| 881 |
|
| 882 |
return JSONResponse(
|
| 883 |
{
|
| 884 |
+
"job_id": job_id,
|
| 885 |
+
"client_token": token,
|
| 886 |
+
"status": "queued",
|
| 887 |
+
"queue_position": position if position > 0 else 1,
|
| 888 |
+
"queue": eta,
|
| 889 |
+
"check_status": f"/job/{job_id}",
|
| 890 |
},
|
| 891 |
headers={"X-Client-ID": token},
|
| 892 |
)
|
|
|
|
| 931 |
},
|
| 932 |
}
|
| 933 |
|
| 934 |
+
# ββ Return both URLs when completed β controller stores these directly βββ
|
| 935 |
if job["status"] == "completed" and job.get("output_path"):
|
| 936 |
resp["download_url"] = f"/download/{job_id}"
|
| 937 |
+
resp["stream_url"] = f"/stream/{job_id}"
|
| 938 |
+
|
| 939 |
if job.get("error"):
|
| 940 |
resp["error"] = job["error"]
|
| 941 |
|
| 942 |
return JSONResponse(resp)
|
| 943 |
|
| 944 |
+
|
| 945 |
+
@app.get("/stream/{job_id}")
|
| 946 |
+
async def stream_video(job_id: str):
|
| 947 |
+
"""
|
| 948 |
+
Serve the video for inline browser playback.
|
| 949 |
+
Frontend uses this as <video src="..."> so user can watch immediately.
|
| 950 |
+
File is NOT deleted after streaming β user may rewatch or seek.
|
| 951 |
+
"""
|
| 952 |
+
output_path = OUTPUT_DIR / f"{job_id}.mp4"
|
| 953 |
+
if not output_path.exists():
|
| 954 |
+
raise HTTPException(404, "Video not found or not ready")
|
| 955 |
+
if job_id in JOBS and JOBS[job_id]["status"] != "completed":
|
| 956 |
+
raise HTTPException(400, "Video not ready yet")
|
| 957 |
+
|
| 958 |
+
return FileResponse(
|
| 959 |
+
output_path,
|
| 960 |
+
media_type="video/mp4",
|
| 961 |
+
headers={
|
| 962 |
+
"Cache-Control": "no-cache",
|
| 963 |
+
"Accept-Ranges": "bytes", # enables seeking in the browser player
|
| 964 |
+
},
|
| 965 |
+
)
|
| 966 |
+
|
| 967 |
+
|
| 968 |
@app.get("/download/{job_id}")
|
| 969 |
+
async def download_video(job_id: str, background_tasks: BackgroundTasks):
|
| 970 |
+
"""
|
| 971 |
+
Serve the video as a file download.
|
| 972 |
+
Deletes the file from /tmp after sending so disk stays clean.
|
| 973 |
+
"""
|
| 974 |
output_path = OUTPUT_DIR / f"{job_id}.mp4"
|
| 975 |
if not output_path.exists():
|
| 976 |
raise HTTPException(404, "Video not found or not ready")
|
| 977 |
if job_id in JOBS and JOBS[job_id]["status"] != "completed":
|
| 978 |
raise HTTPException(400, "Video not ready yet")
|
| 979 |
+
|
| 980 |
+
# Delete file from /tmp after response is sent
|
| 981 |
+
background_tasks.add_task(_delete_output_file, job_id, output_path)
|
| 982 |
+
|
| 983 |
return FileResponse(
|
| 984 |
output_path,
|
| 985 |
media_type="video/mp4",
|
| 986 |
filename=f"archnemix-short-{job_id[:8]}.mp4",
|
| 987 |
headers={
|
| 988 |
"Content-Disposition": f'attachment; filename="archnemix-short-{job_id[:8]}.mp4"',
|
| 989 |
+
"Cache-Control": "no-cache",
|
| 990 |
},
|
| 991 |
)
|
| 992 |
|
| 993 |
+
|
| 994 |
@app.delete("/video/{job_id}")
|
| 995 |
async def delete_video(job_id: str, http_req: Request):
|
| 996 |
if not validate_app_key(http_req):
|
|
|
|
| 1002 |
try:
|
| 1003 |
output_path.unlink()
|
| 1004 |
removed = True
|
| 1005 |
+
logger.info(f"Deleted {job_id}")
|
| 1006 |
except Exception as e:
|
| 1007 |
raise HTTPException(500, f"Delete failed: {e}")
|
| 1008 |
|
|
|
|
| 1013 |
|
| 1014 |
return {"job_id": job_id, "deleted": removed}
|
| 1015 |
|
| 1016 |
+
|
| 1017 |
@app.delete("/job/{job_id}")
|
| 1018 |
async def cancel_job(job_id: str, http_req: Request):
|
| 1019 |
"""Called by controller when frontend goes silent."""
|
|
|
|
| 1034 |
except ValueError:
|
| 1035 |
pass
|
| 1036 |
|
| 1037 |
+
# Clean up output file if it exists
|
| 1038 |
+
output_path = OUTPUT_DIR / f"{job_id}.mp4"
|
| 1039 |
+
output_path.unlink(missing_ok=True)
|
| 1040 |
+
|
| 1041 |
update_job(job_id, status="failed", error="Cancelled by controller β client disconnected")
|
| 1042 |
_refresh_queue_positions()
|
| 1043 |
|
| 1044 |
+
logger.info(f"Job {job_id} cancelled via controller request")
|
| 1045 |
return {"job_id": job_id, "cancelled": True}
|
| 1046 |
|
| 1047 |
+
|
| 1048 |
@app.get("/debug/dataset")
|
| 1049 |
async def debug_dataset():
|
| 1050 |
try:
|
|
|
|
| 1064 |
_QUEUE_LOCK = asyncio.Lock()
|
| 1065 |
|
| 1066 |
logger.info("=" * 60)
|
| 1067 |
+
logger.info("ArchNemix Shorts Generator API v5.1.0")
|
| 1068 |
logger.info(f"Dataset : {HF_DATASET}")
|
| 1069 |
logger.info(f"Concurrent : {MAX_CONCURRENT}")
|
| 1070 |
+
logger.info(f"Avg job : {AVG_JOB_DURATION_S}s ({AVG_JOB_DURATION_S // 60}m)")
|
| 1071 |
+
logger.info("Quality : CRF 16 Β· medium Β· 8-16 Mbps Β· AAC 192k")
|
| 1072 |
+
logger.info("Video path : stream from /tmp, delete on download")
|
| 1073 |
logger.info("=" * 60)
|
| 1074 |
|
| 1075 |
asyncio.create_task(_queue_worker())
|
| 1076 |
|
| 1077 |
try:
|
| 1078 |
vids = await list_videos_from_dataset("minecraft")
|
| 1079 |
+
logger.info(f"{len(vids)} videos cached")
|
| 1080 |
except Exception as e:
|
| 1081 |
+
logger.warning(f"Video cache warm-up: {e}")
|
| 1082 |
|
| 1083 |
cleanup_old_jobs()
|
| 1084 |
+
logger.info("Ready")
|
| 1085 |
+
|
| 1086 |
|
| 1087 |
@app.on_event("shutdown")
|
| 1088 |
async def shutdown():
|