HeshamHaroon Claude Sonnet 4.6 commited on
Commit
ca6ab3f
·
1 Parent(s): 8476b02

feat: pipeline orchestrator with split GPU allocation

Browse files

Adds pipeline_runner.py which orchestrates all 9 Mazinger dubbing stages.
GPU-heavy work is split across two @spaces.GPU(duration=120) functions
(_gpu_transcribe, _gpu_synthesize) to respect ZeroGPU's 120s per-request
hard cap. LLM calls use _call_with_retry with 5/15/45 s exponential
backoff on 429 errors. Audio duration is gated at 300 s via soundfile.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Files changed (1) hide show
  1. pipeline_runner.py +611 -0
pipeline_runner.py ADDED
@@ -0,0 +1,611 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ pipeline_runner.py — Mazinger Dubber pipeline orchestrator for HF ZeroGPU Spaces.
3
+
4
+ Orchestrates all 9 dubbing stages. GPU-heavy stages are split into two separate
5
+ @spaces.GPU-decorated functions to stay within the 120s-per-request hard cap.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import shutil
12
+ import tempfile
13
+ import time
14
+ from typing import Any, Generator
15
+
16
+ import spaces # provided by HF ZeroGPU runtime
17
+
18
+ # ---------------------------------------------------------------------------
19
+ # Constants
20
+ # ---------------------------------------------------------------------------
21
+
22
+ HF_TOKEN: str = os.environ.get("HF_TOKEN", "")
23
+ LLM_BASE_URL: str = "https://router.huggingface.co/v1"
24
+ LLM_MODEL_TEXT: str = "Qwen/Qwen2.5-72B-Instruct"
25
+ LLM_MODEL_VISION: str = "Qwen/Qwen2.5-VL-7B-Instruct"
26
+ BASE_DIR: str = "/tmp/mazinger_output"
27
+
28
+ STAGE_NAMES: list[str] = [
29
+ "Download", # 1
30
+ "Transcribe", # 2
31
+ "Thumbnails", # 3
32
+ "Describe", # 4
33
+ "Translate", # 5
34
+ "Resegment", # 6
35
+ "Synthesize", # 7
36
+ "Assemble", # 8
37
+ "Subtitle", # 9
38
+ ]
39
+
40
+ # ---------------------------------------------------------------------------
41
+ # Mazinger imports (only available on HF Spaces where the package is installed)
42
+ # ---------------------------------------------------------------------------
43
+
44
+ from mazinger import ProjectPaths, LLMUsageTracker # noqa: E402
45
+ from mazinger.llm import build_client # noqa: E402
46
+ from mazinger import ( # noqa: E402
47
+ download,
48
+ transcribe,
49
+ thumbnails,
50
+ describe,
51
+ translate,
52
+ resegment,
53
+ tts,
54
+ assemble,
55
+ subtitle,
56
+ )
57
+ from mazinger.subtitle import SubtitleStyle, download_google_font # noqa: E402
58
+ from mazinger.srt import parse as parse_srt # noqa: E402
59
+
60
+ # ---------------------------------------------------------------------------
61
+ # Helper: build LLM client
62
+ # ---------------------------------------------------------------------------
63
+
64
+
65
+ def _make_client(model: str = LLM_MODEL_TEXT):
66
+ """Return an OpenAI-compatible client pointed at HF Inference Router."""
67
+ return build_client(api_key=HF_TOKEN, base_url=LLM_BASE_URL)
68
+
69
+
70
+ # ---------------------------------------------------------------------------
71
+ # Helper: call with exponential backoff on 429
72
+ # ---------------------------------------------------------------------------
73
+
74
+
75
+ def _call_with_retry(fn, *args, max_retries: int = 3, **kwargs):
76
+ """
77
+ Call *fn* with *args*/*kwargs*, retrying up to *max_retries* times on
78
+ HTTP 429 (rate-limit) errors with exponential backoff (5 s / 15 s / 45 s).
79
+ """
80
+ delays = [5, 15, 45]
81
+ last_exc: Exception | None = None
82
+
83
+ for attempt in range(max_retries + 1):
84
+ try:
85
+ return fn(*args, **kwargs)
86
+ except Exception as exc:
87
+ # Detect rate-limit errors by status code attribute or message text
88
+ is_rate_limit = (
89
+ getattr(exc, "status_code", None) == 429
90
+ or "429" in str(exc)
91
+ or "rate limit" in str(exc).lower()
92
+ or "too many requests" in str(exc).lower()
93
+ )
94
+ if is_rate_limit and attempt < max_retries:
95
+ wait = delays[attempt]
96
+ print(
97
+ f"[retry] 429 rate-limit hit — waiting {wait}s "
98
+ f"(attempt {attempt + 1}/{max_retries})"
99
+ )
100
+ time.sleep(wait)
101
+ last_exc = exc
102
+ continue
103
+ raise
104
+
105
+ # Should never reach here, but satisfy the type checker
106
+ raise last_exc # type: ignore[misc]
107
+
108
+
109
+ # ---------------------------------------------------------------------------
110
+ # Helper: audio duration guard
111
+ # ---------------------------------------------------------------------------
112
+
113
+
114
+ def _check_audio_duration(audio_path: str, max_seconds: float = 300.0) -> float:
115
+ """
116
+ Return audio duration in seconds.
117
+ Raises ValueError if the file exceeds *max_seconds*.
118
+ Requires the `soundfile` package (included in mazinger[all-qwen]).
119
+ """
120
+ import soundfile as sf # lazy import — only needed here
121
+
122
+ info = sf.info(audio_path)
123
+ duration: float = info.duration
124
+ if duration > max_seconds:
125
+ raise ValueError(
126
+ f"Audio is {duration:.1f}s — exceeds the {max_seconds:.0f}s limit. "
127
+ "Please trim your video before uploading."
128
+ )
129
+ return duration
130
+
131
+
132
+ # ---------------------------------------------------------------------------
133
+ # GPU Stage 1 — Transcription (120 s allocation)
134
+ # ---------------------------------------------------------------------------
135
+
136
+
137
+ @spaces.GPU(duration=120)
138
+ def _gpu_transcribe(
139
+ audio_path: str,
140
+ output_path: str,
141
+ method: str = "whisperx",
142
+ model: str | None = None,
143
+ language: str | None = None,
144
+ ) -> str:
145
+ """
146
+ Run WhisperX (or the requested STT method) on *audio_path* with CUDA.
147
+
148
+ Returns the path to the written SRT file (*output_path*).
149
+ First invocation may be slow due to model weight downloads.
150
+ """
151
+ transcribe.transcribe(
152
+ audio_path=audio_path,
153
+ output_path=output_path,
154
+ method=method,
155
+ model=model,
156
+ language=language,
157
+ device="cuda",
158
+ )
159
+ return output_path
160
+
161
+
162
+ # ---------------------------------------------------------------------------
163
+ # GPU Stage 2 — TTS Synthesis (120 s allocation)
164
+ # ---------------------------------------------------------------------------
165
+
166
+
167
+ @spaces.GPU(duration=120)
168
+ def _gpu_synthesize(
169
+ tts_model_name: str,
170
+ voice_sample: str | None,
171
+ voice_script: str | None,
172
+ voice_theme: str | None,
173
+ clone_profile: str | None,
174
+ srt_entries: list[dict],
175
+ output_dir: str,
176
+ target_language: str,
177
+ ) -> str:
178
+ """
179
+ Load the TTS model, resolve a voice prompt via one of the supported modes,
180
+ then synthesize all SRT segments into *output_dir*.
181
+
182
+ Voice-prompt priority:
183
+ 1. clone_profile → load a pre-built voice profile
184
+ 2. voice_sample + voice_script → create a voice prompt from recorded audio
185
+ 3. voice_theme → load a named built-in theme
186
+ 4. fallback → built-in "narrator-m" voice
187
+
188
+ Returns the output directory path containing rendered audio segments.
189
+ """
190
+ # Load TTS model onto GPU
191
+ tts_model = tts.load_model(tts_model_name)
192
+
193
+ # Resolve voice prompt
194
+ if clone_profile:
195
+ voice_prompt = tts.load_profile(clone_profile)
196
+ elif voice_sample and voice_script:
197
+ voice_prompt = tts.create_voice_prompt(
198
+ audio_path=voice_sample,
199
+ transcript=voice_script,
200
+ )
201
+ elif voice_theme:
202
+ voice_prompt = tts.load_theme(voice_theme)
203
+ else:
204
+ voice_prompt = tts.load_theme("narrator-m")
205
+
206
+ # Synthesize all segments
207
+ tts.synthesize_segments(
208
+ model=tts_model,
209
+ voice_prompt=voice_prompt,
210
+ srt_entries=srt_entries,
211
+ output_dir=output_dir,
212
+ language=target_language,
213
+ )
214
+
215
+ return output_dir
216
+
217
+
218
+ # ---------------------------------------------------------------------------
219
+ # Main pipeline generator
220
+ # ---------------------------------------------------------------------------
221
+
222
+
223
+ def run_pipeline(
224
+ source: str,
225
+ target_language: str,
226
+ voice_mode: str,
227
+ voice_theme: str | None = None,
228
+ voice_profile: str | None = None,
229
+ voice_sample: str | None = None,
230
+ voice_script: str | None = None,
231
+ output_type: str = "video",
232
+ embed_subtitles: bool = True,
233
+ subtitle_font: str = "Cairo",
234
+ subtitle_font_size: int = 28,
235
+ ) -> Generator[tuple[int, str, dict[str, Any]], None, None]:
236
+ """
237
+ Orchestrate all 9 dubbing stages for *source* (URL or local path).
238
+
239
+ Yields ``(stage_index, log_message, result_dict)`` tuples at each stage.
240
+ Stage indices are 1-based; a final yield with ``stage_index=9`` (all done)
241
+ is emitted after stage 9 completes.
242
+
243
+ Args:
244
+ source: YouTube/local video URL or absolute file path.
245
+ target_language: BCP-47 language code for the dubbed output (e.g. "ar").
246
+ voice_mode: One of "theme", "profile", "clone", or "default".
247
+ voice_theme: Named built-in voice theme (used when voice_mode="theme").
248
+ voice_profile: Pre-built clone profile name (used when voice_mode="profile").
249
+ voice_sample: Path to speaker audio sample (used when voice_mode="clone").
250
+ voice_script: Transcript of *voice_sample* (used when voice_mode="clone").
251
+ output_type: "video" or "audio_only".
252
+ embed_subtitles: Burn translated subtitles into output video.
253
+ subtitle_font: Google Font name for burned subtitles.
254
+ subtitle_font_size: Point size for burned subtitles.
255
+ """
256
+ os.makedirs(BASE_DIR, exist_ok=True)
257
+ project_dir = tempfile.mkdtemp(dir=BASE_DIR, prefix="run_")
258
+ paths = ProjectPaths(project_dir)
259
+ tracker = LLMUsageTracker()
260
+
261
+ # Temporary directory that survives across ZeroGPU ephemeral boundaries
262
+ result_tmp = tempfile.mkdtemp(prefix="mazinger_result_")
263
+
264
+ # -----------------------------------------------------------------------
265
+ # Stage 1 — Download
266
+ # -----------------------------------------------------------------------
267
+ stage = 1
268
+ yield stage, f"[{STAGE_NAMES[stage - 1]}] Starting download: {source}", {}
269
+
270
+ try:
271
+ if source.startswith("http://") or source.startswith("https://"):
272
+ dl_result = download.download_url(source, output_dir=paths.media_dir)
273
+ else:
274
+ dl_result = download.ingest_local(source, output_dir=paths.media_dir)
275
+ except Exception as exc:
276
+ yield stage, f"[{STAGE_NAMES[stage - 1]}] FAILED: {exc}", {"error": str(exc)}
277
+ return
278
+
279
+ audio_path: str = dl_result.get("audio_path", "")
280
+ video_path: str | None = dl_result.get("video_path")
281
+
282
+ # Duration guard
283
+ try:
284
+ duration = _check_audio_duration(audio_path, max_seconds=300.0)
285
+ except ValueError as exc:
286
+ yield stage, f"[{STAGE_NAMES[stage - 1]}] REJECTED: {exc}", {"error": str(exc)}
287
+ return
288
+
289
+ yield (
290
+ stage,
291
+ f"[{STAGE_NAMES[stage - 1]}] Done — {duration:.1f}s of audio.",
292
+ {"audio_path": audio_path, "video_path": video_path, "duration": duration},
293
+ )
294
+
295
+ # -----------------------------------------------------------------------
296
+ # Stage 2 — Transcribe (GPU)
297
+ # -----------------------------------------------------------------------
298
+ stage = 2
299
+ srt_path: str = paths.srt_raw
300
+
301
+ yield (
302
+ stage,
303
+ f"[{STAGE_NAMES[stage - 1]}] Transcribing with WhisperX "
304
+ "(first run may take a minute while model weights download)…",
305
+ {},
306
+ )
307
+
308
+ try:
309
+ _gpu_transcribe(
310
+ audio_path=audio_path,
311
+ output_path=srt_path,
312
+ method="whisperx",
313
+ )
314
+ except TimeoutError as exc:
315
+ yield (
316
+ stage,
317
+ f"[{STAGE_NAMES[stage - 1]}] GPU timeout — try a shorter clip. {exc}",
318
+ {"error": str(exc)},
319
+ )
320
+ return
321
+ except Exception as exc:
322
+ yield stage, f"[{STAGE_NAMES[stage - 1]}] FAILED: {exc}", {"error": str(exc)}
323
+ return
324
+
325
+ yield (
326
+ stage,
327
+ f"[{STAGE_NAMES[stage - 1]}] Done — SRT written to {srt_path}.",
328
+ {"srt_path": srt_path},
329
+ )
330
+
331
+ # -----------------------------------------------------------------------
332
+ # Stage 3 — Thumbnails
333
+ # -----------------------------------------------------------------------
334
+ stage = 3
335
+ thumb_paths: list[str] = []
336
+
337
+ yield stage, f"[{STAGE_NAMES[stage - 1]}] Extracting keyframes…", {}
338
+
339
+ if video_path and os.path.exists(video_path):
340
+ try:
341
+ timestamps = thumbnails.select_timestamps(video_path)
342
+ thumb_paths = thumbnails.extract_frames(
343
+ video_path=video_path,
344
+ timestamps=timestamps,
345
+ output_dir=paths.thumbs_dir,
346
+ )
347
+ except Exception as exc:
348
+ # Non-fatal — describe stage will be skipped gracefully
349
+ yield (
350
+ stage,
351
+ f"[{STAGE_NAMES[stage - 1]}] WARNING: could not extract frames — {exc}",
352
+ {"warning": str(exc)},
353
+ )
354
+ else:
355
+ yield (
356
+ stage,
357
+ f"[{STAGE_NAMES[stage - 1]}] No video file available — skipping frame extraction.",
358
+ {},
359
+ )
360
+
361
+ yield (
362
+ stage,
363
+ f"[{STAGE_NAMES[stage - 1]}] Done — {len(thumb_paths)} keyframe(s) extracted.",
364
+ {"thumb_paths": thumb_paths},
365
+ )
366
+
367
+ # -----------------------------------------------------------------------
368
+ # Stage 4 — Describe (vision LLM)
369
+ # -----------------------------------------------------------------------
370
+ stage = 4
371
+ scene_description: str = ""
372
+
373
+ yield stage, f"[{STAGE_NAMES[stage - 1]}] Describing scene content…", {}
374
+
375
+ if thumb_paths:
376
+ vision_client = _make_client(LLM_MODEL_VISION)
377
+ try:
378
+ scene_description = _call_with_retry(
379
+ describe.describe_content,
380
+ client=vision_client,
381
+ model=LLM_MODEL_VISION,
382
+ image_paths=thumb_paths,
383
+ tracker=tracker,
384
+ )
385
+ except Exception as exc:
386
+ yield (
387
+ stage,
388
+ f"[{STAGE_NAMES[stage - 1]}] WARNING: description failed — {exc}",
389
+ {"warning": str(exc)},
390
+ )
391
+ else:
392
+ yield (
393
+ stage,
394
+ f"[{STAGE_NAMES[stage - 1]}] No thumbnails — skipping visual description.",
395
+ {},
396
+ )
397
+
398
+ yield (
399
+ stage,
400
+ f"[{STAGE_NAMES[stage - 1]}] Done.",
401
+ {"scene_description": scene_description},
402
+ )
403
+
404
+ # -----------------------------------------------------------------------
405
+ # Stage 5 — Translate
406
+ # -----------------------------------------------------------------------
407
+ stage = 5
408
+ srt_translated_path: str = paths.srt_translated
409
+
410
+ yield (
411
+ stage,
412
+ f"[{STAGE_NAMES[stage - 1]}] Translating subtitles to {target_language}…",
413
+ {},
414
+ )
415
+
416
+ text_client = _make_client(LLM_MODEL_TEXT)
417
+
418
+ try:
419
+ _call_with_retry(
420
+ translate.translate_srt,
421
+ client=text_client,
422
+ model=LLM_MODEL_TEXT,
423
+ srt_path=srt_path,
424
+ output_path=srt_translated_path,
425
+ target_language=target_language,
426
+ scene_description=scene_description,
427
+ tracker=tracker,
428
+ )
429
+ except Exception as exc:
430
+ yield stage, f"[{STAGE_NAMES[stage - 1]}] FAILED: {exc}", {"error": str(exc)}
431
+ return
432
+
433
+ yield (
434
+ stage,
435
+ f"[{STAGE_NAMES[stage - 1]}] Done — translated SRT at {srt_translated_path}.",
436
+ {"srt_translated_path": srt_translated_path},
437
+ )
438
+
439
+ # -----------------------------------------------------------------------
440
+ # Stage 6 — Resegment
441
+ # -----------------------------------------------------------------------
442
+ stage = 6
443
+ srt_resegmented_path: str = paths.srt_resegmented
444
+
445
+ yield stage, f"[{STAGE_NAMES[stage - 1]}] Resegmenting for natural TTS phrasing…", {}
446
+
447
+ try:
448
+ _call_with_retry(
449
+ resegment.resegment_srt,
450
+ client=text_client,
451
+ model=LLM_MODEL_TEXT,
452
+ srt_path=srt_translated_path,
453
+ output_path=srt_resegmented_path,
454
+ target_language=target_language,
455
+ tracker=tracker,
456
+ )
457
+ except Exception as exc:
458
+ yield stage, f"[{STAGE_NAMES[stage - 1]}] FAILED: {exc}", {"error": str(exc)}
459
+ return
460
+
461
+ yield (
462
+ stage,
463
+ f"[{STAGE_NAMES[stage - 1]}] Done — resegmented SRT at {srt_resegmented_path}.",
464
+ {"srt_resegmented_path": srt_resegmented_path},
465
+ )
466
+
467
+ # -----------------------------------------------------------------------
468
+ # Stage 7 — Synthesize (GPU)
469
+ # -----------------------------------------------------------------------
470
+ stage = 7
471
+ segments_dir: str = paths.segments_dir
472
+ os.makedirs(segments_dir, exist_ok=True)
473
+
474
+ yield (
475
+ stage,
476
+ f"[{STAGE_NAMES[stage - 1]}] Synthesizing dubbed audio "
477
+ "(first run may take a minute while TTS model downloads)…",
478
+ {},
479
+ )
480
+
481
+ # Parse SRT into entry dicts for the TTS stage
482
+ srt_entries = parse_srt(srt_resegmented_path)
483
+
484
+ # Resolve voice-mode arguments
485
+ _voice_theme = voice_theme if voice_mode == "theme" else None
486
+ _clone_profile = voice_profile if voice_mode == "profile" else None
487
+ _voice_sample = voice_sample if voice_mode == "clone" else None
488
+ _voice_script = voice_script if voice_mode == "clone" else None
489
+
490
+ try:
491
+ _gpu_synthesize(
492
+ tts_model_name="Qwen/Qwen3-TTS",
493
+ voice_sample=_voice_sample,
494
+ voice_script=_voice_script,
495
+ voice_theme=_voice_theme,
496
+ clone_profile=_clone_profile,
497
+ srt_entries=srt_entries,
498
+ output_dir=segments_dir,
499
+ target_language=target_language,
500
+ )
501
+ except TimeoutError as exc:
502
+ yield (
503
+ stage,
504
+ f"[{STAGE_NAMES[stage - 1]}] GPU timeout — try a shorter clip. {exc}",
505
+ {"error": str(exc)},
506
+ )
507
+ return
508
+ except Exception as exc:
509
+ yield stage, f"[{STAGE_NAMES[stage - 1]}] FAILED: {exc}", {"error": str(exc)}
510
+ return
511
+
512
+ yield (
513
+ stage,
514
+ f"[{STAGE_NAMES[stage - 1]}] Done — audio segments in {segments_dir}.",
515
+ {"segments_dir": segments_dir},
516
+ )
517
+
518
+ # -----------------------------------------------------------------------
519
+ # Stage 8 — Assemble
520
+ # -----------------------------------------------------------------------
521
+ stage = 8
522
+ assembled_path: str = paths.assembled_audio
523
+
524
+ yield stage, f"[{STAGE_NAMES[stage - 1]}] Assembling dubbed timeline…", {}
525
+
526
+ try:
527
+ assemble.assemble_timeline(
528
+ srt_entries=srt_entries,
529
+ segments_dir=segments_dir,
530
+ original_audio_path=audio_path,
531
+ output_path=assembled_path,
532
+ )
533
+ assembled_path = assemble.post_process(assembled_path)
534
+ except Exception as exc:
535
+ yield stage, f"[{STAGE_NAMES[stage - 1]}] FAILED: {exc}", {"error": str(exc)}
536
+ return
537
+
538
+ yield (
539
+ stage,
540
+ f"[{STAGE_NAMES[stage - 1]}] Done — assembled audio at {assembled_path}.",
541
+ {"assembled_path": assembled_path},
542
+ )
543
+
544
+ # -----------------------------------------------------------------------
545
+ # Stage 9 — Subtitle / Mux
546
+ # -----------------------------------------------------------------------
547
+ stage = 9
548
+ final_path: str
549
+
550
+ yield stage, f"[{STAGE_NAMES[stage - 1]}] Finalising output…", {}
551
+
552
+ try:
553
+ if embed_subtitles and video_path and os.path.exists(video_path):
554
+ # Download the requested Google Font for burned subtitles
555
+ font_path: str | None = None
556
+ try:
557
+ font_path = download_google_font(subtitle_font, output_dir=paths.fonts_dir)
558
+ except Exception:
559
+ font_path = None # fall back to FFmpeg default font
560
+
561
+ style = SubtitleStyle(
562
+ font_path=font_path,
563
+ font_size=subtitle_font_size,
564
+ )
565
+ final_path = subtitle.burn_subtitles(
566
+ video_path=video_path,
567
+ audio_path=assembled_path,
568
+ srt_path=srt_resegmented_path,
569
+ output_path=paths.final_video,
570
+ style=style,
571
+ )
572
+ else:
573
+ final_path = subtitle.mux_video(
574
+ video_path=video_path,
575
+ audio_path=assembled_path,
576
+ output_path=paths.final_video,
577
+ )
578
+ except Exception as exc:
579
+ yield stage, f"[{STAGE_NAMES[stage - 1]}] FAILED: {exc}", {"error": str(exc)}
580
+ return
581
+
582
+ # Copy results to the persistent temp directory (ZeroGPU ephemeral-safe)
583
+ result_video = os.path.join(result_tmp, os.path.basename(final_path))
584
+ result_srt = os.path.join(result_tmp, os.path.basename(srt_resegmented_path))
585
+
586
+ shutil.copy2(final_path, result_video)
587
+ shutil.copy2(srt_resegmented_path, result_srt)
588
+
589
+ yield (
590
+ stage,
591
+ f"[{STAGE_NAMES[stage - 1]}] Done — final output at {result_video}.",
592
+ {
593
+ "final_path": result_video,
594
+ "srt_path": result_srt,
595
+ "usage": tracker.summary(),
596
+ },
597
+ )
598
+
599
+ # -----------------------------------------------------------------------
600
+ # Final sentinel yield — all 9 stages complete
601
+ # -----------------------------------------------------------------------
602
+ yield (
603
+ 9,
604
+ "Pipeline complete.",
605
+ {
606
+ "final_path": result_video,
607
+ "srt_path": result_srt,
608
+ "usage": tracker.summary(),
609
+ "done": True,
610
+ },
611
+ )