YTShortMakerArchx commited on
Commit
6bfa8f1
Β·
verified Β·
1 Parent(s): 525e858

Update main.py

Browse files
Files changed (1) hide show
  1. main.py +159 -107
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 = 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,12 +55,12 @@ CACHE_TIMEOUT = 300
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() # ordered list of waiting job_ids
62
- _RUNNING_JOBS: set = set() # job_ids currently encoding
63
- _QUEUE_LOCK: asyncio.Lock = None # created at startup
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"πŸ†• New client {rec['token'][:8]}…")
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"♻️ Restored client {token[:8]}… from header")
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.0.0",
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 = 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,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 / 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,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 = 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,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": 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,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 = JOBS.get(job_id)
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 = i + 1
478
  JOBS[jid]["queue_position"] = pos
479
- JOBS[jid]["message"] = queue_eta(pos)["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"πŸ”ͺ Killed FFmpeg for job {job_id}")
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: asyncio.subprocess.Process,
502
- job_id: str,
503
  total_duration: float,
504
  progress_start: int = 50,
505
  progress_end: int = 95,
506
  ):
507
  frame = fps = speed = 0
508
- out_time_s = 0.0
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}Γ— speed)"
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"πŸš€ Job {job_id}: starting single-pass encode")
578
  update_job(job_id, status="processing", progress=10,
579
- message="Downloading background video…", queue_position=None)
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–180 s)")
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 = video_dur - audio_dur
600
- start = random.uniform(0, max_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 (input-side seek)")
607
  else:
608
- loop_n = int(audio_dur / video_dur) + 2
609
  bg_input_args = [
610
  "-stream_loop", str(loop_n),
611
  "-i", str(bg_path),
612
  ]
613
- logger.info(f"Job {job_id}: loop Γ—{loop_n}")
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", "libx264",
637
- "-preset", "medium",
638
- "-crf", "16",
639
- "-minrate", "8M",
640
- "-maxrate", "16M",
641
- "-bufsize", "16M",
642
  "-profile:v", "high",
643
  "-level:v", "4.2",
644
  "-pix_fmt", "yuv420p",
645
- "-c:a", "aac",
646
- "-b:a", "192k",
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… (single-pass)", total_duration=audio_dur)
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"βœ… Job {job_id}: {size_mb:.2f} MB")
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 β€’ CRF 16 β€’ medium β€’ 8–16 Mbps",
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.0.0",
727
  "queue": {
728
- "max_concurrent": MAX_CONCURRENT,
729
  "avg_job_duration_s": AVG_JOB_DURATION_S,
730
  },
731
  "endpoints": {
732
- "register": "GET /register β€” get or create your client token",
733
- "generate": "POST /generate β€” submit a job",
734
- "cancel": "DELETE /job/{id} β€” cancel job (called by controller)",
735
- "job_status": "GET /job/{id} β€” poll status + queue position",
736
- "queue": "GET /queue β€” overall queue snapshot",
737
- "download": "GET /download/{id}",
 
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": running,
781
- "waiting": waiting,
782
- "total_active": running + waiting,
783
- "avg_job_s": AVG_JOB_DURATION_S,
784
- "estimated_wait_s": waiting * AVG_JOB_DURATION_S if running == 0 else
785
- (waiting + 1) * AVG_JOB_DURATION_S,
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: GenerateRequest,
803
  background_tasks: BackgroundTasks,
804
- http_req: Request,
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
- audio_size = await save_base64_audio(req.audio_base64, audio_path)
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…", "wait_low_s": 0, "wait_high_s": 30}
870
  else:
871
  eta = queue_eta(position, req.duration)
872
 
873
  return JSONResponse(
874
  {
875
- "job_id": job_id,
876
- "client_token": token,
877
- "status": "queued",
878
- "queue_position": position if position > 0 else 1,
879
- "queue": eta,
880
- "check_status": f"/job/{job_id}",
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": "no-cache",
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"πŸ—‘ Deleted {job_id}")
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"πŸ›‘ Job {job_id} cancelled via controller request")
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.0.0")
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–16 Mbps Β· AAC 192k")
1021
- logger.info("Speed opts : single-pass trim Β· ass-first filter Β· threads=auto")
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"βœ… {len(vids)} videos cached")
1029
  except Exception as e:
1030
- logger.warning(f"⚠️ Video cache warm-up: {e}")
1031
 
1032
  cleanup_old_jobs()
1033
- logger.info("πŸš€ Ready")
 
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():