* feat(dub): Smart Fit phase B — per-segment video retime export, drift absorption, fitted subtitles Executes the video side of the Smart Fit plans persisted by Phase A (job["fit_plans"], #347) at export and preview time. Backend: - services/video_retime.py (new, clean-room): two-tier retime executor. ≤48 chunks → the proven single-pass split/trim/setpts/concat filter_complex; above → batches of 40 chunks rendered to intermediate slices (identical libx264 medium/crf20 params, keyframe at t=0) joined losslessly with the concat demuxer. Slices are CFR-resampled (fps=) because setpts leaves VFR-ish timestamps that broke tpad and drifted a frame per retimed chunk on ffmpeg 7.x. Temp slices cleaned on success AND failure/abort. - Drift absorption: fitted track longer than retimed video → freeze-frame tail (tpad=stop_mode=clone) predicted into the last slice / single-pass graph, with residual mux-side tpad; video longer → silence-pad the dub audio chain (apad=whole_dur). ±50 ms tolerance. - VFR guard: probe r_frame_rate vs avg_frame_rate; normalise with fps= before trim/setpts; probe failure degrades gracefully. - Plan resolution: _video_retime_plan_for spans legacy video_stretch_plans (byte-identical resolution + command construction) and fit_plans, gated on the track's own timing_strategy so stale plans never retime a track re-generated under another strategy. - Fitted subtitles: /dub/srt + /dub/vtt accept ?lang= and serve cue times from fitted_segments for Smart Fit tracks; _write_burn_srt does the same for burn-in. burn_subs+retime is now allowed for smart_fit (burn runs AFTER the retime graph); still rejected for legacy stretch_video. - /dub/preview-video resolves the same plan so in-app preview matches export. - Fallback ladder: batch encode failure/timeouts → un-retimed export with a structured core.failure warning (X-Dub-Export-Warning header + job["last_export_warning"]); concat join rejection → one single-pass retry while ≤96 chunks; abort → 409 + proc kill via run_ffmpeg job_id registration (/dub/abort reaches export encodes now) + temp cleanup. Frontend: - Export drawer passes ?lang= on subtitle exports and shows an i18n'd re-encode cost note (~0.5–2× video length on CPU) when a retiming strategy is active — translated in all 21 locales. Tests: tests/test_smart_fit_export.py — plan resolution, batch math, graph parity + new stages, fitted-cue SRT/VTT/burn selection, burn policy, VFR detection; ffmpeg-gated integration renders both executor tiers (batch size forced to 2) and the real /dub/download endpoint, ffprobing durations within ±50 ms across both pad branches. All existing dub export/subtitle/preview/timing tests pass unchanged. Refs docs/competitive-analysis.md Action 1 (dub-length fitting v2); completes Smart Fit (Phase A = #347). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(security): sanitize Smart Fit retime work paths at every sink (CodeQL py/path-injection) The job_id-derived retime work path (retimed_*.mp4 / preview_retimed_*.tmp.mp4) flowed unguarded from dub_export into prepare_smart_fit_video / render_retimed_video and their derived slice/concat paths and ffmpeg argv. Apply the repo's proven inline realpath+startswith containment pattern (helpers/commonpath are not recognized — see #309/#328/#329/#348): - dub_export.py: validate work_path against DUB_DIR at both construction sites (export + preview) and pass the validated realpath onward. - video_retime.py: make both entry points self-defending — realpath + DUB_DIR containment on out_path/work_path before any derivation, raising RetimeError(stage="plan") on escape; slices_dir/slice_path/list_path and RetimeDecision.file_path now all derive from the sanitized value. DUB_DIR is read via module attribute so test fixtures reloading core.config work. - ffmpeg_utils.py: document that all caller-assembled argv paths are realpath-validated upstream. - tests: sandbox DUB_DIR in the executor integration tests (tmp_path) so the new guard sees the test workspace. No behavior change for valid (server-built) paths — the guard only fires on traversal. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(smart-fit): patch DUB_DIR on video_retime's own config ref — survives suite-wide reload The retime guard reads video_retime._config.DUB_DIR at call time; the sandbox fixture patched a fresh 'import core.config' instead. Another test reloads core.config in the full suite, so the two module refs diverged — the patch missed and the guard rejected the test's tmp paths (green in isolation, red in CI's full run). Patch the exact ref the guard dereferences. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(dub): resolve DUB_DIR live at call time in retime guards — survive full-suite reload The path-containment guards bound DUB_DIR via a module-level 'from core import config as _config'. Other tests importlib.reload() core.config (sandboxing OMNIVOICE_DATA_DIR), after which the guard checked containment against a stale DUB_DIR while dub_export built the path under the reloaded one — every retime path then 'escaped the dub workspace' (green file-alone, red full-suite: the 5 integration failures CI hit). Re-import DUB_DIR locally in each guard so it always reads the current sys.modules value; simplify the sandbox fixture to patch the canonical module. Verified: full backend suite green on the Smart Fit tests (the 2 remaining settings_store failures are pre-existing on main, unrelated — local data-dir artifact). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(security): clear CodeQL alerts on Smart Fit export — job_id allowlist, proc-registry decouple - py/path-injection (8, video_retime.py): validate job_id with a strict inline regex allowlist (re.fullmatch [A-Za-z0-9_-]{1,64}) at the entry of dub_download and dub_preview_video, before it reaches any filesystem path or ffmpeg argv. The existing realpath containment guards stay as defense-in-depth; the regex barrier is the sanitizer CodeQL recognizes through the service-module call chain. - py/log-injection (4): newline-strip job_id inline at the logger calls in ffmpeg_utils.run_ffmpeg and the two retime-fallback logger.error sites in dub_export. - py/empty-except (3): best-effort cleanup os.remove handlers now log the OSError at debug instead of bare pass (video_retime + both dub_export mux finally blocks; _discard_tmp too for consistency). - py/cyclic-import (2): break the dub_pipeline <-> ffmpeg_utils cycle for real — the subprocess registry (register_proc/unregister_proc/ kill_job_procs/has_active_procs + state) moves to a new stdlib-only leaf module services/proc_registry.py. ffmpeg_utils now imports it at module top (no lazy import); dub_pipeline re-exports every name so dub_core aliases and tests keep working unchanged. No behavior change for valid inputs; invalid job ids now get a clean 400 instead of a 404/containment error. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(dub): address #350 review — cancelled-vs-failed retime, logged best-effort excepts, redacted probe logs, narrowed test assert - rc<0 (killed by user cancel) now raises RetimeError(stage='aborted') instead of reporting an ordinary render failure (CodeRabbit) - best-effort cleanup/QC-event excepts log at debug instead of bare pass (CodeQL empty-except x3) - probe failure logs use basename, not full user paths (CodeRabbit/CodeQL) - test_render_cleans_slices_on_failure asserts RetimeError, not Exception Rebuttals (no change needed, see PR comment): fitted-cue subtitles track the fitted AUDIO timeline which is correct even on retime fallback; the planner only emits stretch ratios >1 so the early-exit guard is a true no-op check; '\'' is ffmpeg's own utility quoting for concat lists; has_active_procs is an intentional re-export (noqa'd). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: mergetest <test@local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
904 lines
38 KiB
Python
904 lines
38 KiB
Python
"""
|
|
Dub-pipeline service — Phase 2.4 (ROADMAP.md).
|
|
|
|
Extracts the non-HTTP business logic out of the 889-line `dub_core.py` router
|
|
so the router can stay thin (HTTP concerns only) and this module can be
|
|
imported cleanly by other routers (dub_generate, dub_translate, dub_export)
|
|
and future callers (the Tools page, the headless CLI, tests).
|
|
|
|
What's here
|
|
-----------
|
|
* **Job state** — `_dub_jobs` in-memory dict + `get_job` / `save_job` that
|
|
hydrate from `dub_history.job_data` on cache miss.
|
|
* **Content-hash cache lookup** — `compute_file_hash`, `find_cached_job`.
|
|
* **Safe path resolution** — `safe_job_dir`.
|
|
* **Process lifecycle** — ffmpeg/demucs subprocess tracking + `kill_job_procs`
|
|
so `POST /dub/abort/{id}` can tear down in-flight work (implemented in
|
|
`services.proc_registry`, re-exported here for compatibility).
|
|
* **SSE helpers** — `sse_event`, `prep_event`.
|
|
|
|
What stays in the router
|
|
------------------------
|
|
The route decorators + request-body validation + response shaping. The big
|
|
ingest/transcribe generators still live there for now — they're tightly
|
|
coupled to FastAPI's `StreamingResponse`/async-generator contract, and
|
|
moving them is a follow-up.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import hashlib
|
|
import json
|
|
import logging
|
|
import os
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
import time
|
|
from typing import AsyncIterator, Optional
|
|
|
|
import soundfile as sf
|
|
|
|
from core.config import DUB_DIR
|
|
from fastapi import HTTPException
|
|
from services.ffmpeg_utils import find_ffmpeg, find_ffprobe, _get_semaphore, _spawn_with_retry
|
|
from services.model_manager import get_best_device
|
|
# Process lifecycle moved to its own leaf module so ffmpeg_utils can import
|
|
# it at module top (no dub_pipeline ↔ ffmpeg_utils cycle). Re-exported here —
|
|
# dub_core and tests still alias these names through this module.
|
|
from services.proc_registry import ( # noqa: F401 — re-exports
|
|
_active_procs,
|
|
_active_procs_lock,
|
|
has_active_procs,
|
|
kill_job_procs,
|
|
register_proc,
|
|
unregister_proc,
|
|
)
|
|
from core.db import db_conn
|
|
from core import event_bus
|
|
from core import failure
|
|
|
|
logger = logging.getLogger("omnivoice.dub_pipeline")
|
|
|
|
# ── Module-level state ──────────────────────────────────────────────────────
|
|
# These used to live in dub_core.py. The router now re-exports them for
|
|
# backward compat during the transition.
|
|
|
|
_dub_jobs: dict[str, dict] = {}
|
|
_dub_jobs_lock = threading.Lock()
|
|
|
|
_DUB_DIR_REAL = os.path.realpath(DUB_DIR)
|
|
_HASH_BUF_SIZE = 1 << 18 # 256 KB chunks for hashing
|
|
|
|
|
|
# ── Pure helpers ────────────────────────────────────────────────────────────
|
|
|
|
|
|
def compute_file_hash(path: str) -> str:
|
|
"""SHA-256 digest of a file, streamed in 256 KB chunks."""
|
|
h = hashlib.sha256()
|
|
with open(path, "rb") as f:
|
|
while True:
|
|
chunk = f.read(_HASH_BUF_SIZE)
|
|
if not chunk:
|
|
break
|
|
h.update(chunk)
|
|
return h.hexdigest()
|
|
|
|
|
|
def safe_job_dir(job_id: str) -> Optional[str]:
|
|
"""Resolve a job directory under DUB_DIR, rejecting traversal."""
|
|
if not job_id or "/" in job_id or "\\" in job_id or job_id in (".", ".."):
|
|
return None
|
|
candidate = os.path.realpath(os.path.join(DUB_DIR, job_id))
|
|
if not candidate.startswith(_DUB_DIR_REAL + os.sep):
|
|
return None
|
|
return candidate
|
|
|
|
|
|
def sse_event(event: str, payload) -> bytes:
|
|
"""Encode one Server-Sent Event frame. UTF-8 bytes, ready to yield."""
|
|
return f"event: {event}\ndata: {json.dumps(payload, ensure_ascii=False)}\n\n".encode("utf-8")
|
|
|
|
|
|
def prep_event(event_type: str, **fields) -> str:
|
|
"""Build a `data:` SSE line for the ingest pipeline (plain `data: {...}`)."""
|
|
return f"data: {json.dumps({'type': event_type, **fields})}\n\n"
|
|
|
|
|
|
# ── Content-hash cache lookup ───────────────────────────────────────────────
|
|
|
|
|
|
def find_cached_job(content_hash: str, exclude_job_id: str) -> Optional[dict]:
|
|
"""Find a previous job with the same content hash that has the heavy
|
|
artifacts (vocals/no-vocals/scene cuts) still on disk. Returns a dict
|
|
of paths + metadata the caller can shallow-copy into the new job, or
|
|
None if no usable cache exists.
|
|
"""
|
|
if not content_hash:
|
|
return None
|
|
with db_conn() as conn:
|
|
rows = conn.execute(
|
|
"SELECT id, job_data FROM dub_history WHERE content_hash=? AND id!=? ORDER BY created_at DESC LIMIT 5",
|
|
(content_hash, exclude_job_id),
|
|
).fetchall()
|
|
for row in rows:
|
|
try:
|
|
job = json.loads(row["job_data"])
|
|
except (json.JSONDecodeError, TypeError):
|
|
continue
|
|
cached_dir = safe_job_dir(row["id"])
|
|
if not cached_dir or not os.path.isdir(cached_dir):
|
|
continue
|
|
vocals = job.get("vocals_path") or os.path.join(cached_dir, "vocals.wav")
|
|
if not os.path.isfile(vocals):
|
|
continue
|
|
return {
|
|
"job_dir": cached_dir,
|
|
"job_id": row["id"],
|
|
"vocals_path": vocals,
|
|
"no_vocals_path": job.get("no_vocals_path"),
|
|
"scene_cuts": job.get("scene_cuts") or [],
|
|
"thumb_path": job.get("thumb_path"),
|
|
"duration": job.get("duration", 0.0),
|
|
}
|
|
return None
|
|
|
|
|
|
# ── Process lifecycle ───────────────────────────────────────────────────────
|
|
# register_proc / unregister_proc / kill_job_procs / has_active_procs live in
|
|
# services.proc_registry (imported + re-exported above).
|
|
|
|
|
|
# ── Job state (in-memory + SQLite fallback) ────────────────────────────────
|
|
|
|
|
|
def get_job(job_id: str) -> Optional[dict]:
|
|
"""Look up a job. Checks the in-memory cache first, then falls back to
|
|
`dub_history.job_data` so saved projects still resolve after restart.
|
|
"""
|
|
with _dub_jobs_lock:
|
|
if job_id in _dub_jobs:
|
|
return _dub_jobs[job_id]
|
|
with db_conn() as conn:
|
|
row = conn.execute("SELECT job_data FROM dub_history WHERE id=?", (job_id,)).fetchone()
|
|
if row and row["job_data"]:
|
|
try:
|
|
job = json.loads(row["job_data"])
|
|
with _dub_jobs_lock:
|
|
_dub_jobs[job_id] = job
|
|
return job
|
|
except json.JSONDecodeError as e:
|
|
# job_id arrives from request paths — strip newlines so a crafted
|
|
# id can't forge extra log lines (py/log-injection).
|
|
safe_id = str(job_id).replace("\r", "").replace("\n", "")
|
|
logger.error("Failed to decode dub_history.job_data for %s: %s", safe_id, e)
|
|
return None
|
|
|
|
|
|
def put_job(job_id: str, job: dict) -> None:
|
|
"""Insert / replace the in-memory job record. Does NOT persist."""
|
|
with _dub_jobs_lock:
|
|
_dub_jobs[job_id] = job
|
|
|
|
|
|
def save_job(job_id: str, job: dict, filename: str = "", duration: float = 0.0, content_hash: str = "") -> None:
|
|
"""Persist dub job state to SQLite so it survives restarts. Uses UPSERT
|
|
on `id` so repeated saves in a session keep the latest snapshot.
|
|
"""
|
|
try:
|
|
segments = job.get("segments") or []
|
|
tracks = list((job.get("dubbed_tracks") or {}).keys())
|
|
with db_conn() as conn:
|
|
conn.execute(
|
|
"""INSERT INTO dub_history
|
|
(id, filename, duration, segments_count, language, language_code, tracks, job_data, content_hash, created_at)
|
|
VALUES (?,?,?,?,?,?,?,?,?,?)
|
|
ON CONFLICT(id) DO UPDATE SET
|
|
filename=excluded.filename,
|
|
duration=excluded.duration,
|
|
segments_count=excluded.segments_count,
|
|
tracks=excluded.tracks,
|
|
job_data=excluded.job_data,
|
|
content_hash=CASE WHEN excluded.content_hash != '' THEN excluded.content_hash ELSE dub_history.content_hash END""",
|
|
(job_id, filename or job.get("filename", ""),
|
|
duration or job.get("duration", 0.0),
|
|
len(segments), job.get("language", ""), job.get("language_code", ""),
|
|
json.dumps(tracks), json.dumps(job, default=str), content_hash or "", time.time()),
|
|
)
|
|
except Exception as e:
|
|
logger.error("Failed to persist dub job %s: %s", job_id, e)
|
|
return
|
|
event_bus.emit("dub_history", {"action": "saved", "id": job_id})
|
|
|
|
|
|
# ── Ingest pipeline (download → extract → demucs → scene → thumb) ──────────
|
|
|
|
|
|
def run_proc_factory(job_id: str):
|
|
"""Return an async `run_proc` helper bound to this job_id.
|
|
|
|
Spawns subprocesses under the shared ffmpeg semaphore, tracks them so
|
|
`kill_job_procs(job_id)` can terminate them, raises HTTP 504 on timeout.
|
|
"""
|
|
async def run_proc(cmd, timeout: float = 900.0):
|
|
async with _get_semaphore():
|
|
p = await _spawn_with_retry(
|
|
cmd,
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.PIPE,
|
|
)
|
|
register_proc(job_id, p)
|
|
try:
|
|
try:
|
|
stdout, stderr = await asyncio.wait_for(p.communicate(), timeout=timeout)
|
|
except asyncio.TimeoutError:
|
|
try:
|
|
p.kill()
|
|
except ProcessLookupError:
|
|
pass
|
|
try:
|
|
await asyncio.wait_for(p.wait(), timeout=5.0)
|
|
except asyncio.TimeoutError:
|
|
pass
|
|
raise HTTPException(status_code=504, detail=f"subprocess timed out after {timeout}s")
|
|
return p, stdout, stderr
|
|
finally:
|
|
unregister_proc(job_id, p)
|
|
if p.returncode is None:
|
|
try:
|
|
p.kill()
|
|
except ProcessLookupError:
|
|
pass
|
|
try:
|
|
await asyncio.wait_for(p.wait(), timeout=5.0)
|
|
except asyncio.TimeoutError:
|
|
pass
|
|
return run_proc
|
|
|
|
|
|
async def run_proc_streaming_stderr(
|
|
job_id: str,
|
|
cmd: list[str],
|
|
*,
|
|
timeout: float = 1800.0,
|
|
) -> AsyncIterator[tuple]:
|
|
"""Spawn `cmd` and stream its stderr line-by-line as it runs.
|
|
|
|
Yields:
|
|
('stderr', line: str) — once per logical line (split on \\r or \\n,
|
|
so tqdm-style progress bars that overwrite the same line via
|
|
carriage return surface as separate lines)
|
|
('done', returncode: int, full_stderr: bytes) — exactly once when
|
|
the subprocess exits; this is always the final value.
|
|
|
|
Honors the same semaphore + register_proc/kill plumbing as run_proc.
|
|
"""
|
|
async with _get_semaphore():
|
|
p = await _spawn_with_retry(
|
|
cmd,
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.PIPE,
|
|
)
|
|
register_proc(job_id, p)
|
|
stderr_parts: list[bytes] = []
|
|
rc: int = -1
|
|
try:
|
|
buf = b""
|
|
start = time.monotonic()
|
|
while True:
|
|
if time.monotonic() - start > timeout:
|
|
try:
|
|
p.kill()
|
|
except ProcessLookupError:
|
|
pass
|
|
raise HTTPException(
|
|
status_code=504,
|
|
detail=f"subprocess timed out after {timeout}s",
|
|
)
|
|
try:
|
|
chunk = await asyncio.wait_for(p.stderr.read(256), timeout=1.0)
|
|
except asyncio.TimeoutError:
|
|
if p.returncode is not None:
|
|
break
|
|
continue
|
|
if not chunk:
|
|
break
|
|
stderr_parts.append(chunk)
|
|
buf += chunk
|
|
while True:
|
|
idx_r = buf.find(b"\r")
|
|
idx_n = buf.find(b"\n")
|
|
if idx_r < 0 and idx_n < 0:
|
|
break
|
|
if idx_r < 0:
|
|
idx = idx_n
|
|
elif idx_n < 0:
|
|
idx = idx_r
|
|
else:
|
|
idx = min(idx_r, idx_n)
|
|
line = buf[:idx].decode(errors="replace")
|
|
buf = buf[idx + 1:]
|
|
if line.strip():
|
|
yield ("stderr", line)
|
|
rc = await p.wait()
|
|
finally:
|
|
unregister_proc(job_id, p)
|
|
if p.returncode is None:
|
|
try:
|
|
p.kill()
|
|
except ProcessLookupError:
|
|
pass
|
|
try:
|
|
await asyncio.wait_for(p.wait(), timeout=5.0)
|
|
except asyncio.TimeoutError:
|
|
pass
|
|
try:
|
|
p.stdout.close()
|
|
except Exception:
|
|
pass
|
|
yield ("done", rc, b"".join(stderr_parts))
|
|
|
|
|
|
_BROWSER_VIDEO_CODECS = {"h264", "avc1"}
|
|
_BROWSER_AUDIO_CODECS = {"aac", "mp4a"}
|
|
|
|
|
|
def _probe_codecs(path: str) -> tuple[str, str]:
|
|
"""Return (video_codec, audio_codec) lowercased, or ('','') on probe error."""
|
|
ffprobe = find_ffprobe()
|
|
if not ffprobe:
|
|
return ("", "")
|
|
try:
|
|
out = subprocess.run(
|
|
[ffprobe, "-v", "error", "-select_streams", "v:0",
|
|
"-show_entries", "stream=codec_name", "-of", "csv=p=0", path],
|
|
capture_output=True, text=True, timeout=10,
|
|
)
|
|
vcodec = (out.stdout or "").strip().lower()
|
|
out = subprocess.run(
|
|
[ffprobe, "-v", "error", "-select_streams", "a:0",
|
|
"-show_entries", "stream=codec_name", "-of", "csv=p=0", path],
|
|
capture_output=True, text=True, timeout=10,
|
|
)
|
|
acodec = (out.stdout or "").strip().lower()
|
|
return vcodec, acodec
|
|
except Exception:
|
|
return ("", "")
|
|
|
|
|
|
def _ensure_browser_playable_mp4(video_path: str) -> str:
|
|
"""Guarantee `video_path` is an mp4 with h264 video + aac audio.
|
|
|
|
Three classes of fix the in-app `<video>` element needs:
|
|
1. `.webm`/`.mkv` containers — WKWebView refuses them outright.
|
|
2. `.mp4` with VP9 or AV1 video — WKWebView can't decode either
|
|
inside an mp4 container (Safari ships VP9 only in WebM).
|
|
3. `.mp4` with Opus audio — same story; mp4-with-opus is rare and
|
|
poorly supported across webviews.
|
|
|
|
Strategy: probe codecs; if extension is mp4 AND both codecs are in
|
|
the browser-safe set, leave alone. Otherwise transcode to h264+aac.
|
|
Returns the (possibly new) file path.
|
|
"""
|
|
ffmpeg_bin = find_ffmpeg()
|
|
is_mp4 = video_path.lower().endswith(".mp4")
|
|
if is_mp4:
|
|
vcodec, acodec = _probe_codecs(video_path)
|
|
if vcodec in _BROWSER_VIDEO_CODECS and acodec in _BROWSER_AUDIO_CODECS:
|
|
return video_path # already safe — fast path, no rewrite
|
|
# Need to rewrite. Try stream-copy first when the container is the
|
|
# only problem; fall back to full transcode for codec mismatches.
|
|
target = os.path.splitext(video_path)[0] + ".mp4"
|
|
if target == video_path:
|
|
target = os.path.splitext(video_path)[0] + ".browser.mp4"
|
|
# Stream-copy attempt — works when codecs are already h264+aac but
|
|
# the container is wrong (rare with our format selector but cheap to
|
|
# try). Skip straight to transcode if we already know codecs are bad.
|
|
rc = 1
|
|
if is_mp4:
|
|
# Codecs were probed and known-bad; skip the copy attempt.
|
|
pass
|
|
else:
|
|
rc = subprocess.run(
|
|
[ffmpeg_bin, "-y", "-i", video_path,
|
|
"-c:v", "copy", "-c:a", "copy",
|
|
"-movflags", "+faststart", target],
|
|
capture_output=True,
|
|
).returncode
|
|
if rc != 0 or not os.path.exists(target):
|
|
rc = 1
|
|
if rc != 0:
|
|
# Full transcode — h264 baseline-ish + aac is the safe combo.
|
|
rc = subprocess.run(
|
|
[ffmpeg_bin, "-y", "-i", video_path,
|
|
"-c:v", "libx264", "-preset", "veryfast", "-crf", "23",
|
|
"-pix_fmt", "yuv420p",
|
|
"-c:a", "aac", "-b:a", "192k",
|
|
"-movflags", "+faststart", target],
|
|
capture_output=True,
|
|
).returncode
|
|
if rc == 0 and os.path.exists(target) and target != video_path:
|
|
try:
|
|
os.remove(video_path)
|
|
except OSError:
|
|
pass
|
|
return target
|
|
if rc != 0:
|
|
logger.warning(
|
|
"Could not transcode %s to browser-playable mp4 — the in-app "
|
|
"video player may render this file as a black box.",
|
|
video_path,
|
|
)
|
|
return video_path
|
|
|
|
|
|
def yt_download_sync(
|
|
url: str,
|
|
job_dir: str,
|
|
*,
|
|
fetch_subs: bool = False,
|
|
sub_langs: list[str] | None = None,
|
|
progress_hook=None,
|
|
) -> tuple[str, str, list[str]]:
|
|
"""Blocking yt-dlp download into `job_dir`.
|
|
|
|
Returns (video_path, title, downloaded_sub_files).
|
|
|
|
Captions are downloaded in a separate, best-effort pass after the video
|
|
so subtitle failures (rate-limits, missing tracks) can never derail the
|
|
actual ingest. Defaults skip YouTube's auto-translated set — requesting
|
|
"all" expands into ~100 per-language variants per video and reliably
|
|
trips HTTP 429, and the downstream Translate step handles target
|
|
languages more reliably than YouTube's machine translations anyway.
|
|
Pass an explicit `sub_langs` list to override the default selection.
|
|
"""
|
|
import glob
|
|
import yt_dlp
|
|
outtmpl = os.path.join(job_dir, "original.%(ext)s")
|
|
ydl_opts: dict = {
|
|
"outtmpl": outtmpl,
|
|
# Prefer h264+aac streams so the merged mp4 is natively decodable
|
|
# by WKWebView/WebView2/Chromium. YouTube serves VP9+Opus as the
|
|
# default high-quality combo, which yt-dlp will happily mux into
|
|
# an mp4 container — but Safari/WKWebView refuses to decode VP9
|
|
# inside mp4, leaving a black <video> in the dub editor. Fall
|
|
# back to any combo only when no h264/aac variant exists, then
|
|
# the post-download codec probe below will transcode it.
|
|
"format": (
|
|
"bv*[vcodec^=avc1][ext=mp4]+ba[acodec^=mp4a][ext=m4a]/"
|
|
"bv*[vcodec^=avc1]+ba[acodec^=mp4a]/"
|
|
"b[vcodec^=avc1][acodec^=mp4a]/"
|
|
"bv*[vcodec^=avc1]+ba/"
|
|
"b[vcodec^=avc1]/"
|
|
"bv*+ba/b"
|
|
),
|
|
"merge_output_format": "mp4",
|
|
"noplaylist": True,
|
|
"quiet": True,
|
|
"no_warnings": True,
|
|
"restrictfilenames": True,
|
|
"socket_timeout": 30,
|
|
# Resilience against YouTube CDN flakes: a single empty fragment
|
|
# (commonly the very last one — "Did not get any data blocks")
|
|
# used to fail the whole ingest at 99% complete. Retry each
|
|
# fragment generously and tolerate one that never returns data;
|
|
# missing the last <0.5% of audio is acceptable for transcription.
|
|
"fragment_retries": 10,
|
|
"retries": 10,
|
|
"extractor_retries": 5,
|
|
"skip_unavailable_fragments": True,
|
|
}
|
|
if progress_hook is not None:
|
|
ydl_opts["progress_hooks"] = [progress_hook]
|
|
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
|
info = ydl.extract_info(url, download=True)
|
|
path = ydl.prepare_filename(info)
|
|
root, _ = os.path.splitext(path)
|
|
mp4 = root + ".mp4"
|
|
if os.path.exists(mp4):
|
|
video_path = mp4
|
|
else:
|
|
video_path = path
|
|
# Browser-playability guard: WKWebView (Tauri on macOS) refuses to
|
|
# decode VP9/AV1 video and Opus audio even when they're wrapped in an
|
|
# mp4 container, and refuses .webm/.mkv outright. We probe the actual
|
|
# codecs and transcode only when needed — `<video>` rendering is
|
|
# what we care about, not what extension yt-dlp ended up writing.
|
|
video_path = _ensure_browser_playable_mp4(video_path)
|
|
title = info.get("title") or os.path.basename(video_path)
|
|
|
|
sub_files: list[str] = []
|
|
if fetch_subs:
|
|
if sub_langs:
|
|
langs = list(sub_langs)
|
|
else:
|
|
orig = (info.get("language") or "").strip()
|
|
manual = list((info.get("subtitles") or {}).keys())
|
|
langs = sorted({*manual, *([orig] if orig else [])})
|
|
if not langs:
|
|
logger.info("No captions available on %s (skipping subtitle pass)", url)
|
|
else:
|
|
sub_opts = {
|
|
**ydl_opts,
|
|
"skip_download": True,
|
|
"writesubtitles": True,
|
|
"writeautomaticsub": True,
|
|
"subtitleslangs": langs,
|
|
"subtitlesformat": "vtt",
|
|
"extractor_args": {"youtube": {"skip": ["translated_subs"]}},
|
|
"ignoreerrors": True,
|
|
"extractor_retries": 5,
|
|
"sleep_interval_subtitles": 1,
|
|
}
|
|
try:
|
|
with yt_dlp.YoutubeDL(sub_opts) as ydl_sub:
|
|
ydl_sub.extract_info(url, download=True)
|
|
except Exception as e:
|
|
logger.warning(
|
|
"Subtitle download failed for %s (continuing with video): %s",
|
|
url, e,
|
|
)
|
|
base = os.path.splitext(video_path)[0]
|
|
sub_files = sorted(glob.glob(base + ".*.vtt"))
|
|
return video_path, title, sub_files
|
|
|
|
|
|
def parse_vtt_segments(vtt_path: str) -> list[dict]:
|
|
"""Very small WEBVTT parser → list of {start, end, text}.
|
|
|
|
Purpose: turn yt-dlp's downloaded caption track into the same segment
|
|
shape the dub pipeline's transcript step produces, so the UI can seed
|
|
its editor from YouTube captions instead of running Whisper. We don't
|
|
care about positioning cues or styling — just the timed text.
|
|
"""
|
|
try:
|
|
with open(vtt_path, "r", encoding="utf-8") as f:
|
|
raw = f.read()
|
|
except OSError:
|
|
return []
|
|
|
|
def _ts(s: str) -> float:
|
|
# Format: HH:MM:SS.mmm or MM:SS.mmm
|
|
parts = s.strip().split(":")
|
|
try:
|
|
parts = [float(p.replace(",", ".")) for p in parts]
|
|
except ValueError:
|
|
return 0.0
|
|
if len(parts) == 3:
|
|
h, m, sec = parts
|
|
elif len(parts) == 2:
|
|
h, m, sec = 0.0, parts[0], parts[1]
|
|
else:
|
|
return 0.0
|
|
return h * 3600.0 + m * 60.0 + sec
|
|
|
|
segments: list[dict] = []
|
|
blocks = raw.replace("\r\n", "\n").split("\n\n")
|
|
for block in blocks:
|
|
lines = [ln for ln in block.split("\n") if ln.strip() and not ln.startswith("WEBVTT") and not ln.startswith("NOTE")]
|
|
if not lines:
|
|
continue
|
|
# Skip numeric cue ID line if present
|
|
if "-->" not in lines[0] and len(lines) > 1:
|
|
lines = lines[1:]
|
|
if not lines or "-->" not in lines[0]:
|
|
continue
|
|
ts_line = lines[0]
|
|
try:
|
|
left, right = ts_line.split("-->")
|
|
# Drop any settings after the right timestamp ("00:01.000 align:left line:0%")
|
|
right = right.strip().split(" ")[0]
|
|
start = _ts(left)
|
|
end = _ts(right)
|
|
except Exception:
|
|
continue
|
|
text = " ".join(ln.strip() for ln in lines[1:]).strip()
|
|
# Strip inline styling like <c.colorE5E5E5>foo</c> or <00:00:01.200>
|
|
text = re.sub(r"<[^>]+>", "", text)
|
|
if text:
|
|
segments.append({"start": start, "end": end, "text": text})
|
|
return segments
|
|
|
|
|
|
async def ingest_pipeline(
|
|
job_id: str,
|
|
job_dir: str,
|
|
source: dict,
|
|
filename_hint: Optional[str] = None,
|
|
) -> AsyncIterator[str]:
|
|
"""Async generator: emit SSE events per processing stage.
|
|
|
|
Stages: download_start, download_done, extract_start, extract_done,
|
|
demucs_start, demucs_done, scene_start, scene_done, ready, error, cancelled.
|
|
"""
|
|
youtube_subs_by_lang: dict[str, list[dict]] = {}
|
|
# Audio-only jobs (#119) skip scene detection + thumbnailing below; the
|
|
# transcribe → translate → TTS core is identical.
|
|
input_type = (source.get("input_type") or "video").lower()
|
|
try:
|
|
if source.get("kind") == "url":
|
|
url = source["url"]
|
|
fetch_subs = bool(source.get("fetch_subs"))
|
|
sub_langs = source.get("sub_langs") or None
|
|
yield prep_event("download_start", url=url)
|
|
# Bridge yt-dlp's per-fragment progress callback (fires inside
|
|
# the worker thread) into the async generator via a threadsafe
|
|
# queue so the UI can render a real download bar.
|
|
loop = asyncio.get_running_loop()
|
|
dl_queue: asyncio.Queue = asyncio.Queue()
|
|
_last_pct = -1
|
|
|
|
def _yt_progress(d: dict) -> None:
|
|
nonlocal _last_pct
|
|
status = d.get("status")
|
|
if status == "downloading":
|
|
total = d.get("total_bytes") or d.get("total_bytes_estimate") or 0
|
|
cur = d.get("downloaded_bytes") or 0
|
|
if total > 0:
|
|
pct = max(0, min(100, int(cur * 100 / total)))
|
|
if pct != _last_pct:
|
|
_last_pct = pct
|
|
payload = {
|
|
"percent": pct,
|
|
"speed_bps": d.get("speed") or 0,
|
|
"eta_s": d.get("eta"),
|
|
}
|
|
loop.call_soon_threadsafe(dl_queue.put_nowait, payload)
|
|
|
|
dl_task = asyncio.create_task(asyncio.to_thread(
|
|
yt_download_sync, url, job_dir,
|
|
fetch_subs=fetch_subs, sub_langs=sub_langs,
|
|
progress_hook=_yt_progress,
|
|
))
|
|
try:
|
|
while not dl_task.done():
|
|
try:
|
|
payload = await asyncio.wait_for(dl_queue.get(), timeout=0.5)
|
|
yield prep_event("download_progress", **payload)
|
|
except asyncio.TimeoutError:
|
|
continue
|
|
# Drain any final queued events.
|
|
while not dl_queue.empty():
|
|
payload = dl_queue.get_nowait()
|
|
yield prep_event("download_progress", **payload)
|
|
video_path, title, sub_files = await dl_task
|
|
except Exception as e:
|
|
logger.exception("Download failed for job %s", job_id)
|
|
if not dl_task.done():
|
|
dl_task.cancel()
|
|
yield prep_event("error", **failure.build_failure(e, stage="download"))
|
|
shutil.rmtree(job_dir, ignore_errors=True)
|
|
return
|
|
filename = title or os.path.basename(video_path)
|
|
try:
|
|
size = os.path.getsize(video_path)
|
|
except OSError:
|
|
size = 0
|
|
# Parse each downloaded .<lang>.vtt into a segment list we can
|
|
# stash on the job. The router will merge this into the job dict
|
|
# after ingest completes so /dub/transcribe-stream (or a future
|
|
# "use-youtube-subs" toggle) can seed segments from it.
|
|
for sf_path in sub_files:
|
|
base = os.path.splitext(os.path.basename(sf_path))[0]
|
|
# "original.en" → lang "en"; fallback to the trailing token.
|
|
lang_tag = base.rsplit(".", 1)[-1] if "." in base else "und"
|
|
segs = parse_vtt_segments(sf_path)
|
|
if segs:
|
|
youtube_subs_by_lang[lang_tag] = segs
|
|
yield prep_event(
|
|
"download_done",
|
|
title=title, size=size, filename=filename,
|
|
youtube_subs=sorted(youtube_subs_by_lang.keys()),
|
|
)
|
|
else:
|
|
video_path = source["path"]
|
|
filename = filename_hint or os.path.basename(video_path)
|
|
|
|
audio_path = os.path.join(job_dir, "audio.wav")
|
|
ffmpeg = find_ffmpeg()
|
|
run_proc = run_proc_factory(job_id)
|
|
|
|
yield prep_event("extract_start")
|
|
try:
|
|
p, _, stderr = await run_proc([
|
|
ffmpeg, "-i", video_path, "-vn", "-acodec", "pcm_s16le",
|
|
"-ar", "16000", "-ac", "1", audio_path, "-y",
|
|
])
|
|
if p.returncode != 0:
|
|
msg = (stderr.decode(errors="replace") or f"ffmpeg returned exit code {p.returncode}").strip()[:500]
|
|
raise Exception(msg)
|
|
except asyncio.CancelledError:
|
|
raise
|
|
except Exception as e:
|
|
logger.exception("Extract failed for job %s", job_id)
|
|
yield prep_event("error", **failure.build_failure(e, stage="extract"))
|
|
return
|
|
|
|
try:
|
|
dur = float(sf.info(audio_path).frames) / float(sf.info(audio_path).samplerate)
|
|
except Exception:
|
|
dur = 0.0
|
|
|
|
# Content-hash cache: reuse artifacts from previous matching jobs.
|
|
content_hash = await asyncio.to_thread(compute_file_hash, audio_path)
|
|
cached = find_cached_job(content_hash, job_id)
|
|
if cached:
|
|
logger.info("Cache hit for job %s (hash %s) → reusing artifacts from %s",
|
|
job_id, content_hash[:12], cached["job_id"])
|
|
vocals_path = os.path.join(job_dir, "vocals.wav")
|
|
no_vocals_path = os.path.join(job_dir, "no_vocals.wav")
|
|
thumb_path = os.path.join(job_dir, "thumb.jpg")
|
|
|
|
if cached["vocals_path"] and os.path.isfile(cached["vocals_path"]):
|
|
shutil.copy2(cached["vocals_path"], vocals_path)
|
|
else:
|
|
vocals_path = audio_path
|
|
if cached["no_vocals_path"] and os.path.isfile(cached["no_vocals_path"]):
|
|
shutil.copy2(cached["no_vocals_path"], no_vocals_path)
|
|
else:
|
|
no_vocals_path = None
|
|
if cached["thumb_path"] and os.path.isfile(cached["thumb_path"]):
|
|
shutil.copy2(cached["thumb_path"], thumb_path)
|
|
else:
|
|
thumb_path = None
|
|
|
|
scene_cuts = cached["scene_cuts"] or []
|
|
|
|
full_job = {
|
|
"video_path": video_path,
|
|
"audio_path": audio_path,
|
|
"vocals_path": vocals_path,
|
|
"no_vocals_path": no_vocals_path,
|
|
"thumb_path": thumb_path if thumb_path and os.path.exists(thumb_path) else None,
|
|
"duration": dur,
|
|
"filename": filename,
|
|
"segments": None,
|
|
"dubbed_tracks": {},
|
|
"scene_cuts": scene_cuts,
|
|
"youtube_subs": youtube_subs_by_lang or None,
|
|
"input_type": input_type,
|
|
}
|
|
put_job(job_id, full_job)
|
|
save_job(job_id, full_job, filename, dur, content_hash)
|
|
yield prep_event("extract_done", job_id=job_id, duration=round(dur, 2), filename=filename)
|
|
yield prep_event("cached",
|
|
has_bg=bool(no_vocals_path and os.path.exists(no_vocals_path)),
|
|
scene_count=len(scene_cuts))
|
|
yield prep_event("ready", job_id=job_id, duration=round(dur, 2), filename=filename)
|
|
|
|
else:
|
|
# Full pipeline: demucs → scene → thumbnail.
|
|
partial = {
|
|
"video_path": video_path,
|
|
"audio_path": audio_path,
|
|
"vocals_path": audio_path, # fallback until demucs completes
|
|
"no_vocals_path": None,
|
|
"thumb_path": None,
|
|
"duration": dur,
|
|
"filename": filename,
|
|
"segments": None,
|
|
"dubbed_tracks": {},
|
|
"scene_cuts": [],
|
|
"youtube_subs": youtube_subs_by_lang or None,
|
|
"input_type": input_type,
|
|
}
|
|
put_job(job_id, partial)
|
|
save_job(job_id, partial, filename, dur, content_hash)
|
|
yield prep_event("extract_done", job_id=job_id, duration=round(dur, 2), filename=filename)
|
|
|
|
vocals_path = os.path.join(job_dir, "vocals.wav")
|
|
no_vocals_path = os.path.join(job_dir, "no_vocals.wav")
|
|
scene_cuts: list = []
|
|
|
|
yield prep_event("demucs_start")
|
|
try:
|
|
demucs_cmd = [sys.executable, "-m", "demucs.separate",
|
|
"--two-stems", "vocals", "-n", "htdemucs", "-d", get_best_device(),
|
|
audio_path, "-o", job_dir]
|
|
rc = -1
|
|
stderr_full = b""
|
|
last_pct = -1
|
|
# demucs writes a tqdm progress bar to stderr as
|
|
# " 42%|████ | …" — surface each new integer percent
|
|
# to the UI so the user sees the bar instead of a static
|
|
# spinner during the multi-minute separation step.
|
|
async for evt in run_proc_streaming_stderr(job_id, demucs_cmd, timeout=1800.0):
|
|
if evt[0] == "stderr":
|
|
m = re.search(r"(\d{1,3})%", evt[1])
|
|
if m:
|
|
pct = max(0, min(100, int(m.group(1))))
|
|
if pct != last_pct:
|
|
last_pct = pct
|
|
yield prep_event("demucs_progress", percent=pct)
|
|
elif evt[0] == "done":
|
|
rc, stderr_full = evt[1], evt[2]
|
|
if rc != 0:
|
|
raise Exception(stderr_full.decode(errors="replace")[:500])
|
|
demucs_out = os.path.join(job_dir, "htdemucs", "audio")
|
|
if os.path.exists(os.path.join(demucs_out, "vocals.wav")):
|
|
shutil.move(os.path.join(demucs_out, "vocals.wav"), vocals_path)
|
|
shutil.move(os.path.join(demucs_out, "no_vocals.wav"), no_vocals_path)
|
|
shutil.rmtree(os.path.join(job_dir, "htdemucs"), ignore_errors=True)
|
|
except asyncio.CancelledError:
|
|
raise
|
|
except Exception as e:
|
|
logger.warning("Demucs failed for %s, falling back to mixed audio: %s", job_id, e)
|
|
# plan-04: surface the degradation (job continues with mixed audio).
|
|
yield prep_event("warning", **failure.build_failure(e, stage="demucs", include_diagnostic=False))
|
|
vocals_path = audio_path
|
|
no_vocals_path = None
|
|
yield prep_event("demucs_done",
|
|
has_bg=bool(no_vocals_path and os.path.exists(no_vocals_path)))
|
|
|
|
# Audio-only jobs (#119) have no video to scan or thumbnail. Skip
|
|
# both ffmpeg passes but still emit scene_done (count=0) so the
|
|
# prep SSE contract the frontend waits on is unchanged.
|
|
thumb_path = os.path.join(job_dir, "thumb.jpg")
|
|
if input_type == "audio":
|
|
# No scenes/thumbnail in audio — emit the start/done pair anyway
|
|
# so the prep SSE stage sequence stays symmetric with the video
|
|
# path (the frontend's stage tracker expects both).
|
|
yield prep_event("scene_start")
|
|
yield prep_event("scene_done", count=0)
|
|
thumb_path = None
|
|
else:
|
|
yield prep_event("scene_start")
|
|
try:
|
|
p, _, stderr_scene = await run_proc([
|
|
ffmpeg, "-i", video_path, "-filter:v",
|
|
"select='gt(scene,0.3)',showinfo", "-f", "null", "-",
|
|
], timeout=600.0)
|
|
matches = re.finditer(r"pts_time:([\d\.]+)", stderr_scene.decode(errors="replace"))
|
|
scene_cuts = [float(m.group(1)) for m in matches]
|
|
except asyncio.CancelledError:
|
|
raise
|
|
except Exception as e:
|
|
logger.warning("Scene detection failed for %s: %s", job_id, e)
|
|
yield prep_event("warning", **failure.build_failure(e, stage="scene", include_diagnostic=False))
|
|
yield prep_event("scene_done", count=len(scene_cuts))
|
|
|
|
offset = max(0.5, min(1.5, dur * 0.1)) if dur else 1.0
|
|
try:
|
|
await run_proc([
|
|
ffmpeg, "-y", "-ss", f"{offset:.2f}", "-i", video_path,
|
|
"-vframes", "1", "-vf", "scale=320:-2",
|
|
"-q:v", "4", thumb_path,
|
|
], timeout=30.0)
|
|
except asyncio.CancelledError:
|
|
raise
|
|
except Exception as e:
|
|
logger.warning("Thumbnail extraction failed for %s: %s", job_id, e)
|
|
yield prep_event("warning", **failure.build_failure(e, stage="thumbnail", include_diagnostic=False))
|
|
|
|
_dub_jobs[job_id].update({
|
|
"vocals_path": vocals_path,
|
|
"no_vocals_path": no_vocals_path,
|
|
"thumb_path": thumb_path if (thumb_path and os.path.exists(thumb_path)) else None,
|
|
"scene_cuts": scene_cuts,
|
|
})
|
|
save_job(job_id, _dub_jobs[job_id], filename, dur, content_hash)
|
|
yield prep_event("ready", job_id=job_id, duration=round(dur, 2), filename=filename)
|
|
|
|
except asyncio.CancelledError:
|
|
logger.info("Dub prep cancelled for job %s; killing subprocesses and cleaning up", job_id)
|
|
kill_job_procs(job_id)
|
|
try:
|
|
shutil.rmtree(job_dir, ignore_errors=True)
|
|
finally:
|
|
_dub_jobs.pop(job_id, None)
|
|
yield prep_event("cancelled")
|
|
raise
|
|
except Exception as e:
|
|
# plan-04 (#131): no unhandled ingest failure may be silent. Log the
|
|
# real traceback and surface a structured, non-empty reason with stage
|
|
# context instead of letting it bubble up as a bare task error.
|
|
logger.exception("Ingest pipeline failed for job %s", job_id)
|
|
yield prep_event("error", **failure.build_failure(e, stage="ingest"))
|
|
return
|
|
finally:
|
|
with _active_procs_lock:
|
|
_active_procs.pop(job_id, None)
|