Files
VoiceStudio/backend/api/routers/dub_export.py
T
65fc5245dc feat(dub): timeline segment editor — drag, snap-to-onset, keyboard a11y (#280) (#348)
* feat(dub): full-track speech-onset detection + GET /dub/onsets/{job_id} (#280)

detect_speech_onsets() lists every speech rise across the track (frame RMS,
adaptive threshold, 150ms hysteresis) — powers the timeline editor's
snap-to-onset ticks. Route prefers the Demucs vocals stem, falls back to the
mix, and caches onsets.json per job (mtime-invalidated).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(dub): timeline editor math core — windowing, snap, clamp, fingerprint-safe commit (#280)

Pure helpers for the segment track: binary-search windowing, snapTime with
deterministic ties, neighbour/min-duration clamps with Alt-overlap (<=200ms),
commitMoveResize with fingerprint parity (move touches only start/end; resize
sets speed exactly like the old Regions handler and DELETES the key at 1.0 so
_canon_value's missing-vs-1.0 hashing can't mark untouched segments stale),
and overlap detection.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(dub): SegmentTrack editing lane replaces the Regions plugin (#280)

Custom DOM segment boxes (6px edge handles, body-drag move, speaker colors,
stale/fresh tint, hatched overlap warning) virtualized by time over a single
{pxPerSec, scrollLeft} alignment source read off WaveSurfer's wrapper.
Snap-to-onset ticks on a viewport-sized canvas light up in snap range;
Ctrl/Cmd-wheel zooms centered on the cursor; double-click plays the slot via
playRange (timeupdate watcher pauses at slot end). Roving-tabindex listbox
keyboard model (arrows / Enter / Shift / Alt / Delete / S) with polite
aria-live announcements. WebKit fallback keeps a self-scrolling lane at a
fixed px/sec. timeline.* strings translated in all 21 locales.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(dub): wire timeline editor — per-gesture undo, id fix, table selection sync (#280)

segmentMoveResize() pushes undo ONCE per gesture (drag commits on pointerup;
keyboard nudges coalesce per focus session) and matches by String(id) — the
old parseInt('seg-3_a') path edited the wrong segment after a split. Commits
go through commitMoveResize for fingerprint parity, and the existing
recomputeIncremental effect picks up every commit. Clicking a timeline box
scrolls + highlights its row in DubSegmentTable; 'preview dub here' parks
the player at the slot start, then synthesizes the line.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(dub): inline the onsets-cache containment guard — CodeQL can't track helpers

Same lesson as #328/#329: the realpath+startswith sanitizer must sit at
the sink, not behind a function return. Unused helper removed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 16:21:07 +05:30

1164 lines
47 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import os
import io
import re
import time
import uuid
import asyncio
import logging
from typing import Optional
from fastapi import APIRouter, HTTPException, Query, Response
from fastapi.responses import FileResponse, StreamingResponse
from core.config import DUB_DIR, dub_seg_path
from core.tasks import task_manager
from api.routers.dub_core import _get_job
from services.ffmpeg_utils import find_ffmpeg, run_ffmpeg
router = APIRouter()
logger = logging.getLogger("omnivoice.api")
def _unique_stamp() -> str:
"""Return a short unique suffix like '20260415T142301-ab12cd34' for export files."""
return f"{time.strftime('%Y%m%dT%H%M%S')}-{uuid.uuid4().hex[:8]}"
_SAFE_LANG = re.compile(r"^[A-Za-z0-9_-]{1,32}$")
def _native_save(source: str, destination: str, display_name: str, media_type: str):
"""Copy a generated export file to a user-chosen destination and return JSON."""
import shutil
dest = os.path.expanduser(destination)
# Reject traversal against the user's home dir — Tauri save dialog returns abs path.
if not os.path.isabs(dest):
raise HTTPException(status_code=400, detail="save_path must be absolute")
try:
os.makedirs(os.path.dirname(dest) or ".", exist_ok=True)
shutil.copy2(source, dest)
except PermissionError as e:
raise HTTPException(status_code=403, detail=f"Permission denied: {e}")
except OSError as e:
raise HTTPException(status_code=500, detail=f"Copy failed: {e}")
if not os.path.exists(dest) or os.path.getsize(dest) == 0:
raise HTTPException(status_code=500, detail="Copy produced empty file at destination")
logger.info("Native save wrote %s (%d bytes)", dest, os.path.getsize(dest))
return {
"saved": True,
"path": dest,
"size": os.path.getsize(dest),
"media_type": media_type,
"display_name": display_name,
}
@router.get("/tasks/stream/{task_id}")
async def stream_task(task_id: str, after_seq: int = 0):
"""Universal Server-Sent Event stream for background tasks.
`?after_seq=N` enables resumption: on reconnect, the client replays
persisted events with seq > N, then (if the job is still live) attaches
to the in-memory listener for live updates. After a server restart the
in-memory task is gone but the persisted tail + final `jobs.status` are
still readable, so a mid-stream reload still sees the final state.
"""
from core import job_store
job_row = job_store.get(task_id)
live = task_manager.active_tasks.get(task_id)
if not live and not job_row:
raise HTTPException(
status_code=404,
detail="No such task. It may have been cleaned up or was never created.",
)
async def _reader():
# 1) Replay any persisted events after the client's last-seen seq.
try:
persisted = job_store.events_since(task_id, after_seq=after_seq)
except Exception:
persisted = []
for evt in persisted:
yield evt["payload"]
# 2) If the job has finished (whether in-memory or persisted-only), done.
if not live:
return
if live["status"] in ("done", "failed", "cancelled"):
return
# 3) Attach to the in-memory listener for live updates.
q = asyncio.Queue()
await task_manager.add_listener(task_id, q)
try:
while True:
evt = await q.get()
if evt is None:
break
yield evt
finally:
await task_manager.remove_listener(task_id, q)
return StreamingResponse(_reader(), media_type="text/event-stream")
@router.get("/jobs")
async def list_jobs(status: str | None = None, project_id: str | None = None, limit: int = 100):
"""List persisted jobs, newest first.
`status=active` → running + pending (what the batch-queue UI wants).
`status=failed|done|cancelled|pending|running` → exact match.
`project_id=...` → scope to one project.
"""
from core import job_store
limit = max(1, min(500, int(limit)))
return job_store.list_jobs(status=status, project_id=project_id, limit=limit)
@router.get("/jobs/{job_id}")
async def get_job(job_id: str):
from core import job_store
row = job_store.get(job_id)
if not row:
raise HTTPException(
status_code=404,
detail="No such job. It may have been cleaned up or never created.",
)
return row
@router.get("/jobs/{job_id}/events")
async def list_job_events(job_id: str, after_seq: int = 0, limit: int = 500):
"""Persisted SSE tail. Strict ascending seq so the client can stitch
it onto a live feed (which starts above the last returned seq).
"""
from core import job_store
row = job_store.get(job_id)
if not row:
raise HTTPException(
status_code=404,
detail="No job with that id. It may have expired, been deleted, or the server restarted before it was persisted — check the dub history in the sidebar.",
)
limit = max(1, min(2000, int(limit)))
return {
"job": row,
"events": job_store.events_since(job_id, after_seq=after_seq, limit=limit),
}
@router.post("/tasks/cancel/{task_id}")
async def cancel_task(task_id: str):
"""Cancel a running background task (e.g. dub generation)."""
ok = task_manager.cancel_task(task_id)
if not ok:
raise HTTPException(status_code=404, detail="Task not found")
return {"cancelled": True, "task_id": task_id}
@router.get("/dub/tracks/{job_id}")
async def dub_list_tracks(job_id: str):
job = _get_job(job_id)
if not job:
raise HTTPException(status_code=404, detail="Job not found")
return {"tracks": job.get("dubbed_tracks", {})}
def _write_burn_srt(job: dict, exports_dir: str, stamp: str, dual: bool) -> str | None:
"""Build a temp SRT from job segments for use with ffmpeg's subtitles filter.
Returned path is already ffmpeg-filter-safe (plain ASCII basename under exports_dir).
Returns None if there are no segments to render.
"""
segments = job.get("segments", [])
if not segments:
return None
lines = []
for i, seg in enumerate(segments):
lines.append(str(i + 1))
lines.append(f"{_format_srt_time(seg['start'])} --> {_format_srt_time(seg['end'])}")
lines.append(_pick_subtitle_text(seg, dual))
lines.append("")
sub_path = os.path.join(exports_dir, f"burn_subs_{stamp}.srt")
with open(sub_path, "w", encoding="utf-8") as f:
f.write("\n".join(lines))
return sub_path
def _ffmpeg_filter_escape(path: str) -> str:
"""Escape a path for use inside an ffmpeg filter value (subtitles=...).
ffmpeg's filter parser treats `:` as an option separator and `\\`, `'` specially.
Backslashes first, then colons, then single quotes.
"""
return path.replace("\\", "\\\\").replace(":", "\\:").replace("'", "\\'")
def _build_video_stretch_filter_graph(
plan: list[dict], orig_dur: float, video_input_idx: int = 0,
in_label: str | None = None,
) -> tuple[str, str]:
"""Build an ffmpeg filter_complex graph that stretches the source video
per-segment so each segment's visual duration matches a dub audio layout.
`plan` is a list of {orig_start, orig_end, new_start, new_end, stretch_ratio}
in original-time order (as persisted by dub_generate for stretch_video
mode). Gaps between plan entries — and the pre-roll / tail — are passed
through at 1.0× rate so silence and B-roll don't get squashed.
`in_label` overrides the input stream reference; e.g. pass "[vsub]" when
a subtitles filter has already written to that label. Defaults to
`[{video_input_idx}:v]` for direct source-stream consumption.
Returns (filter_graph, output_label). Output label is "[vstretched]" when
chunks were emitted, or the original input label when the plan was empty
(caller should fall back to stream-copy in that case).
"""
# Empty plan = no stretch — caller should stream-copy the video. Return
# early so we don't synthesise a degenerate "stretch whole video at 1.0×"
# graph that would force a needless re-encode.
if not plan:
return "", in_label or f"[{video_input_idx}:v]"
chunks: list[tuple[float, float, float]] = [] # (a, b, ratio)
cursor = 0.0
for entry in plan:
a = float(entry["orig_start"])
b = float(entry["orig_end"])
if a > cursor + 1e-3:
chunks.append((cursor, a, 1.0)) # gap or pre-roll at native rate
ratio = float(entry["stretch_ratio"])
if b > a:
chunks.append((a, b, ratio))
cursor = max(cursor, b)
if orig_dur > cursor + 1e-3:
chunks.append((cursor, orig_dur, 1.0)) # tail at native rate
chunks = [(a, b, r) for (a, b, r) in chunks if b > a]
if not chunks:
return "", in_label or f"[{video_input_idx}:v]"
src = in_label or f"[{video_input_idx}:v]"
parts: list[str] = []
labels: list[str] = []
# `split` lets us tap the same source stream once per chunk without re-
# decoding. setpts={ratio}*PTS slows down (ratio > 1) or speeds up
# (ratio < 1) each chunk; PTS-STARTPTS first to normalise the timestamp
# base after the trim.
split_labels = [f"[vsplit{idx}]" for idx in range(len(chunks))]
parts.append(f"{src}split={len(chunks)}{''.join(split_labels)}")
for idx, ((a, b, ratio), split_lbl) in enumerate(zip(chunks, split_labels)):
out_label = f"[vstr{idx}]"
labels.append(out_label)
parts.append(
f"{split_lbl}trim=start={a:.4f}:end={b:.4f},"
f"setpts=PTS-STARTPTS,setpts={ratio:.6f}*PTS{out_label}"
)
parts.append("".join(labels) + f"concat=n={len(chunks)}:v=1:a=0[vstretched]")
return ";".join(parts), "[vstretched]"
def _video_stretch_plan_for(job: dict, lang_code: str) -> dict | None:
"""Return the persisted stretch plan + total durations for `lang_code`,
or None if this job didn't use stretch_video mode (or no plan exists).
"""
if (job.get("timing_strategy") or "").lower() != "stretch_video":
return None
plans = job.get("video_stretch_plans") or {}
entry = plans.get(lang_code)
if not entry or not entry.get("plan"):
return None
return entry
#: Audio export formats → ffmpeg codec args. Unknown formats fall back to
#: AAC/m4a so a bad request can never produce a broken command.
_AUDIO_FORMAT_CODECS: dict[str, list[str]] = {
"wav": ["-c:a", "pcm_s16le"],
"m4a": ["-c:a", "aac", "-b:a", "192k"],
"mp3": ["-c:a", "libmp3lame", "-q:a", "2"],
"flac": ["-c:a", "flac"],
}
def _build_audio_export_cmd(
ffmpeg: str,
track_path: str,
bg_path: Optional[str],
out_path: str,
fmt: str,
) -> list[str]:
"""Build the ffmpeg command for an audio-only dub export (#119).
No video input, stream-map, or codec — just the dubbed track, optionally
mixed with the separated background (``no_vocals``), written to ``out_path``
in the requested ``fmt``. Unknown formats fall back to AAC.
"""
codec = _AUDIO_FORMAT_CODECS.get((fmt or "").lower(), _AUDIO_FORMAT_CODECS["m4a"])
cmd = [ffmpeg, "-y", "-i", track_path]
if bg_path:
# Mix the dubbed voice over the original background bed (same weights
# as the video mux path) so ambience/music is preserved.
cmd += ["-i", bg_path, "-filter_complex",
"[0:a][1:a]amix=inputs=2:duration=longest:dropout_transition=2:weights=1.2 0.8[aout]",
"-map", "[aout]"]
cmd += codec
cmd.append(out_path)
return cmd
@router.get("/dub/download/{job_id}")
@router.get("/dub/download/{job_id}/{filename}")
async def dub_download(
job_id: str,
preserve_bg: bool = Query(True, description="Mix background noise into dubbed tracks"),
default_track: str = Query("original"),
include_tracks: str = Query("", description="Comma-separated list of tracks to include (e.g. 'original,de,es'). Empty = include all."),
save_path: str = Query("", description="Absolute destination path. If set, mux output is copied there and JSON returned instead of FileResponse."),
burn_subs: bool = Query(False, description="Burn subtitles into the video stream (forces re-encode). Uses dual-subtitle layout when dual=1."),
dual: bool = Query(False, description="When burn_subs=1, render translated on top of italicised original."),
out_format: str = Query("m4a", description="Audio-only jobs (#119): output container — wav, m4a, mp3, or flac. Ignored for video jobs."),
):
job = _get_job(job_id)
if not job:
raise HTTPException(status_code=404, detail="Job not found")
tracks = job.get("dubbed_tracks", {})
if not tracks:
raise HTTPException(status_code=400, detail="No dubbed tracks generated yet")
include_set = set(t.strip() for t in include_tracks.split(",") if t.strip()) if include_tracks else None
include_original = include_set is None or "original" in include_set
if include_set:
filtered_tracks = {k: v for k, v in tracks.items() if k in include_set}
else:
filtered_tracks = dict(tracks)
if not filtered_tracks and not include_original:
raise HTTPException(status_code=400, detail="No tracks selected for export")
video_path = job["video_path"]
stamp = _unique_stamp()
exports_dir = os.path.join(DUB_DIR, job_id, "exports")
os.makedirs(exports_dir, exist_ok=True)
output_path = os.path.join(exports_dir, f"dubbed_video_{stamp}.mp4")
ffmpeg = find_ffmpeg()
# ── Audio-only dubbing (#119) ─────────────────────────────────────────
# No source video to mux into — export the dubbed track (optionally mixed
# with the separated background) straight to an audio container.
if (job.get("input_type") or "video").lower() == "audio":
if default_track and default_track != "original" and default_track in filtered_tracks:
lang_code, track_info = default_track, filtered_tracks[default_track]
elif filtered_tracks:
lang_code, track_info = next(iter(filtered_tracks.items()))
else:
raise HTTPException(status_code=400, detail="No dubbed track selected for audio export")
fmt = (out_format or "m4a").lower()
if fmt not in _AUDIO_FORMAT_CODECS:
fmt = "m4a"
# lang_code is already constrained to an existing track key, but
# allowlist-sanitize it before it reaches the output path so a path
# component can never carry separators/traversal (same pattern as
# safe_name below).
safe_lang = "".join(c for c in lang_code if c.isalnum() or c in "-_") or "track"
out_path = os.path.join(exports_dir, f"dubbed_audio_{safe_lang}_{stamp}.{fmt}")
bg = job.get("no_vocals_path") if preserve_bg else None
bg = bg if (bg and os.path.exists(bg)) else None
cmd = _build_audio_export_cmd(ffmpeg, track_info["path"], bg, out_path, fmt)
try:
rc, _, stderr = await run_ffmpeg(cmd, timeout=1800.0)
if rc != 0:
raise Exception(stderr.decode(errors="replace") if stderr else "ffmpeg audio export non-zero")
except asyncio.TimeoutError:
raise HTTPException(status_code=504, detail="ffmpeg audio export timed out")
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"ffmpeg failed to export dubbed audio: {e}. Verify ffmpeg is installed (`ffmpeg -version`) and the dubbed track exists.",
)
if not os.path.exists(out_path) or os.path.getsize(out_path) == 0:
raise HTTPException(status_code=500, detail="ffmpeg audio export produced no output file")
logger.info("Dub audio export wrote %s (%d bytes)", out_path, os.path.getsize(out_path))
base_name = os.path.splitext(job.get("filename", "output"))[0]
safe_name = "".join(c for c in base_name if c.isalnum() or c in "-_ ").strip() or "output"
dl_name = f"dubbed_{safe_name}_{safe_lang}_{stamp}.{fmt}"
media_type = _MEDIA_TYPES.get(f".{fmt}", "audio/mp4")
if save_path:
return _native_save(out_path, save_path, dl_name, media_type=media_type)
return FileResponse(
out_path, media_type=media_type,
headers={"Content-Disposition": f'attachment; filename="{dl_name}"'},
)
# Determine whether this export should drive video through a per-segment
# stretch graph (Mode B). Stretch is keyed off the default_track's plan
# because the video can only physically follow one timeline at a time.
# If multiple dub tracks are included and they were generated under
# stretch_video, only the default_track is visually in sync — other
# tracks share the same (stretched) video. Single-track export is the
# supported common case.
stretch_entry = _video_stretch_plan_for(job, default_track) if default_track and default_track != "original" else None
# Subtitle burn under stretch_video would render cues at the original
# timestamps onto a re-timed video — they'd drift. Skip the burn pass
# in that combo and log; the user can still export the SRT/VTT
# separately and the new-layout timing lives there.
if stretch_entry and burn_subs:
logger.warning(
"stretch_video + burn_subs is not supported in one pass; "
"skipping subtitle burn for job %s. Export the SRT/VTT separately.",
job_id,
)
burn_subs = False
sub_path = _write_burn_srt(job, exports_dir, stamp, dual) if burn_subs else None
cmd = [ffmpeg, "-i", video_path]
input_idx = 1
bg_audio = job.get("no_vocals_path") if preserve_bg else None
bg_idx = None
if bg_audio and os.path.exists(bg_audio) and filtered_tracks:
cmd += ["-i", bg_audio]
bg_idx = input_idx
input_idx += 1
tracks_to_process = []
for lang_code, track_info in filtered_tracks.items():
cmd += ["-i", track_info["path"]]
tracks_to_process.append({"lang_code": lang_code, "idx": input_idx, "info": track_info})
input_idx += 1
filter_parts: list[str] = []
video_map = "0:v:0"
if sub_path:
esc = _ffmpeg_filter_escape(sub_path)
filter_parts.append(f"[0:v]subtitles='{esc}'[vsub]")
video_map = "[vsub]"
if stretch_entry:
orig_dur = float(stretch_entry.get("orig_duration") or job.get("duration") or 0.0)
graph, vlabel = _build_video_stretch_filter_graph(
stretch_entry["plan"], orig_dur, video_input_idx=0,
in_label=video_map if video_map != "0:v:0" else None,
)
if graph:
filter_parts.append(graph)
video_map = vlabel
cmd += ["-map", video_map]
if include_original:
cmd += ["-map", "0:a:0"]
if bg_idx is not None:
for i, t in enumerate(tracks_to_process):
out_label = f"[aout{i}]"
filter_parts.append(f"[{bg_idx}:a][{t['idx']}:a]amix=inputs=2:duration=longest:dropout_transition=2:weights=0.8 1.2{out_label}")
t["out_label"] = out_label
for t in tracks_to_process:
cmd += ["-map", t["out_label"]]
else:
for t in tracks_to_process:
cmd += ["-map", f"{t['idx']}:a:0"]
if filter_parts:
cmd += ["-filter_complex", ";".join(filter_parts)]
# Burning subs or per-segment video stretch both force a real video
# re-encode; stream-copy is only viable when nothing touches the video
# filter chain.
if sub_path or stretch_entry:
cmd += ["-c:v", "libx264", "-preset", "medium", "-crf", "20", "-pix_fmt", "yuv420p"]
else:
cmd += ["-c:v", "copy"]
cmd += ["-c:a", "aac", "-b:a", "192k"]
audio_stream_idx = 0
if include_original:
cmd += [f"-metadata:s:a:{audio_stream_idx}", "language=und", f"-metadata:s:a:{audio_stream_idx}", "title=Original"]
audio_stream_idx += 1
for t in tracks_to_process:
cmd += [
f"-metadata:s:a:{audio_stream_idx}", f"language={t['lang_code']}",
f"-metadata:s:a:{audio_stream_idx}", f"title={t['info']['language']}"
]
t["stream_idx"] = audio_stream_idx
audio_stream_idx += 1
total_audio = (1 if include_original else 0) + len(tracks_to_process)
for i in range(total_audio):
cmd += [f"-disposition:a:{i}", "0"]
if default_track == "original" and include_original:
cmd += ["-disposition:a:0", "default"]
else:
target_idx = 0
for t in tracks_to_process:
if t['lang_code'] == default_track:
target_idx = t["stream_idx"]
break
cmd += [f"-disposition:a:{target_idx}", "default"]
# In stretch_video mode the video and audio durations should match
# within sub-frame precision, but `-shortest` can still cut off the
# trailing frame; let ffmpeg keep both streams. Otherwise keep the
# legacy `-shortest` so a slightly-overrunning track doesn't extend
# the mux past the video.
if not stretch_entry:
cmd += ["-shortest"]
cmd += [output_path, "-y"]
try:
rc, _, stderr = await run_ffmpeg(cmd, timeout=1800.0)
if rc != 0:
raise Exception(stderr.decode(errors="replace") if stderr else "ffmpeg mux non-zero")
except asyncio.TimeoutError:
raise HTTPException(status_code=504, detail="ffmpeg mux timed out")
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"ffmpeg failed to combine video + dubbed audio: {e}. Verify ffmpeg is installed (`ffmpeg -version`), and check that every dubbed track file exists in the job folder.",
)
if not os.path.exists(output_path) or os.path.getsize(output_path) == 0:
raise HTTPException(status_code=500, detail="ffmpeg mux produced no output file")
logger.info("Dub mux wrote %s (%d bytes)", output_path, os.path.getsize(output_path))
base_name = os.path.splitext(job.get('filename', 'output'))[0]
safe_name = ''.join(c for c in base_name if c.isalnum() or c in '-_ ').strip() or 'output'
dl_name = f"dubbed_{safe_name}_{stamp}.mp4"
if save_path:
return _native_save(output_path, save_path, dl_name, media_type="video/mp4")
return FileResponse(
output_path, media_type="video/mp4",
headers={"Content-Disposition": f'attachment; filename="{dl_name}"'},
)
_MEDIA_TYPES = {
".mp4": "video/mp4",
".m4v": "video/mp4",
".mov": "video/quicktime",
".webm": "video/webm",
".mkv": "video/x-matroska",
".m4a": "audio/mp4",
".mp3": "audio/mpeg",
".wav": "audio/wav",
".flac": "audio/flac",
".ogg": "audio/ogg",
}
@router.get("/dub/media/{job_id}")
async def dub_get_media(job_id: str):
job = _get_job(job_id)
if not job:
raise HTTPException(status_code=404, detail="Job not found")
video_path = job["video_path"]
if not os.path.exists(video_path):
raise HTTPException(status_code=404, detail="Media file not found")
# Pass an explicit media_type. Without this Starlette falls back to
# mimetypes.guess_type, which on some platforms returns the wrong
# MIME (e.g. "application/octet-stream" for .mkv), and the Tauri
# WebView then refuses to render the <video> element — leaving a
# silent black box. Default to video/mp4 because the ingest pipeline
# remuxes URL downloads to mp4 (dub_pipeline.yt_download_sync).
ext = os.path.splitext(video_path)[1].lower()
return FileResponse(video_path, media_type=_MEDIA_TYPES.get(ext, "video/mp4"))
# One mux at a time per preview file. Without this, two overlapping requests
# (e.g. the <video> element remounting right after a re-dub) both ran ffmpeg
# against the same output path, and the mtime check below saw the half-written
# file as a valid cache — serving a truncated MP4 that left the player stuck
# loading forever (#281).
_preview_mux_locks: dict[str, asyncio.Lock] = {}
def _preview_lock(path: str) -> asyncio.Lock:
lock = _preview_mux_locks.get(path)
if lock is None:
lock = _preview_mux_locks.setdefault(path, asyncio.Lock())
return lock
@router.get("/dub/preview-video/{job_id}")
async def dub_preview_video(
job_id: str,
lang: str = Query(..., description="Language code of the dubbed track to mux in"),
preserve_bg: bool = Query(True),
):
"""Return an inline-playable MP4 with the chosen dubbed track as sole audio.
Caches per lang+preserve_bg combination under exports/preview_{lang}_{bg}.mp4.
Cache is invalidated when the underlying dubbed track mtime is newer than the cache.
"""
job = _get_job(job_id)
if not job:
raise HTTPException(status_code=404, detail="Job not found")
tracks = job.get("dubbed_tracks", {})
track_info = tracks.get(lang)
if not track_info:
raise HTTPException(status_code=404, detail=f"No dubbed track for lang={lang}")
track_path = track_info.get("path")
if not track_path or not os.path.exists(track_path):
raise HTTPException(status_code=404, detail="Dubbed track file missing")
video_path = job.get("video_path")
if not video_path or not os.path.exists(video_path):
raise HTTPException(status_code=404, detail="Source video missing")
bg_audio = job.get("no_vocals_path") if preserve_bg else None
has_bg = bool(bg_audio and os.path.exists(bg_audio))
if not _SAFE_LANG.match(lang):
raise HTTPException(status_code=400, detail="Invalid lang")
# realpath-normalised + containment-checked inline BEFORE any filesystem
# access so the guard dominates every sink (the file's established
# pattern — see dub_preview_segment; CodeQL does not track the guard
# through a helper's return value).
_base = os.path.realpath(DUB_DIR)
exports_dir = os.path.realpath(os.path.join(_base, job_id, "exports"))
if not exports_dir.startswith(_base + os.sep):
raise HTTPException(status_code=400, detail="Invalid job id")
os.makedirs(exports_dir, exist_ok=True)
bg_suffix = "bg" if (preserve_bg and has_bg) else "nobg"
preview_path = os.path.realpath(
os.path.join(exports_dir, f"preview_{lang}_{bg_suffix}.mp4")
)
if not preview_path.startswith(_base + os.sep):
raise HTTPException(status_code=400, detail="Invalid path")
track_mtime = os.path.getmtime(track_path)
def _cache_ok() -> bool:
return (
os.path.exists(preview_path)
and os.path.getsize(preview_path) > 0
and os.path.getmtime(preview_path) >= track_mtime
)
async def _mux_preview():
# Mux into a temp file and os.replace() into place so a concurrent
# reader never sees a partially-written preview (#281: video stuck
# loading forever after a re-dub).
mux_path = preview_path + ".tmp.mp4"
ffmpeg = find_ffmpeg()
stretch_entry = _video_stretch_plan_for(job, lang)
cmd = [ffmpeg, "-i", video_path]
input_idx = 1
if preserve_bg and has_bg:
cmd += ["-i", bg_audio]
bg_idx = input_idx
input_idx += 1
else:
bg_idx = None
cmd += ["-i", track_path]
track_idx = input_idx
# Build filter graph. In stretch_video mode we splice the source
# video into per-segment chunks, setpts each to match the dub audio
# layout, and concat them — so audio plays at natural rate and the
# visuals follow. Otherwise we stream-copy video for speed.
filter_parts: list[str] = []
video_map = "0:v:0"
if stretch_entry:
orig_dur = float(stretch_entry.get("orig_duration") or job.get("duration") or 0.0)
graph, vlabel = _build_video_stretch_filter_graph(
stretch_entry["plan"], orig_dur, video_input_idx=0,
)
if graph:
filter_parts.append(graph)
video_map = vlabel
if bg_idx is not None:
filter_parts.append(
f"[{bg_idx}:a][{track_idx}:a]amix=inputs=2:duration=longest:dropout_transition=2:weights=0.8 1.2[aout]"
)
cmd += ["-map", video_map]
if bg_idx is not None:
cmd += ["-map", "[aout]"]
else:
cmd += ["-map", f"{track_idx}:a:0"]
if filter_parts:
cmd += ["-filter_complex", ";".join(filter_parts)]
# Stretch path needs a real encode; stream-copy otherwise.
if stretch_entry:
cmd += ["-c:v", "libx264", "-preset", "medium", "-crf", "20", "-pix_fmt", "yuv420p"]
else:
cmd += ["-c:v", "copy"]
cmd += ["-c:a", "aac", "-b:a", "192k"]
# `-shortest` would cut the stretched video at the (slightly different)
# audio length and lose the trailing frame; only use it on the copy path.
if not stretch_entry:
cmd += ["-shortest"]
cmd += [mux_path, "-y"]
def _discard_tmp():
try:
os.remove(mux_path)
except OSError:
pass
try:
rc, _, stderr = await run_ffmpeg(cmd, timeout=900.0)
if rc != 0:
raise Exception(stderr.decode(errors="replace") if stderr else "ffmpeg mux non-zero")
if not os.path.exists(mux_path) or os.path.getsize(mux_path) == 0:
raise Exception("preview mux produced empty file")
except asyncio.TimeoutError:
_discard_tmp()
raise HTTPException(status_code=504, detail="preview mux timed out")
except Exception as e:
_discard_tmp()
raise HTTPException(
status_code=500,
detail=f"ffmpeg failed to build the preview stream: {str(e)[:300]}. This usually means the source video can't be re-encoded on the fly — try downloading the MP4 instead.",
)
os.replace(mux_path, preview_path)
async with _preview_lock(preview_path):
if not _cache_ok():
await _mux_preview()
# no-store: the URL is stable across re-dubs, so any HTTP-level caching
# in the WebView would keep showing the previous dub after a re-generate
# (#281: "edits don't change the result").
return FileResponse(
preview_path,
media_type="video/mp4",
headers={"Cache-Control": "no-store"},
)
def _compute_onsets_sync(src_path: str) -> list[float]:
"""Blocking part of onset analysis — runs in a worker thread."""
import soundfile as sf
from services.onset_align import detect_speech_onsets
audio, sr = sf.read(src_path, dtype="float32")
return detect_speech_onsets(audio, sr)
@router.get("/dub/onsets/{job_id}")
async def dub_get_onsets(job_id: str):
"""Speech-onset times for the timeline editor's snap-to-onset ticks (#280).
Prefers the Demucs-isolated vocals track (clean speech energy); falls
back to the mixed audio. Computed once per job and cached as
``onsets.json`` in the job directory; recomputed if the source audio is
newer than the cache (e.g. re-ingest into the same job dir).
"""
import json
job = _get_job(job_id)
if not job:
raise HTTPException(status_code=404, detail="Job not found")
vocals = job.get("vocals_path")
mix = job.get("audio_path")
if vocals and os.path.exists(vocals):
src_path, source = vocals, "vocals"
elif mix and os.path.exists(mix):
src_path, source = mix, "mix"
else:
raise HTTPException(status_code=404, detail="No audio track available for onset analysis")
# Containment inlined (not via _safe_job_path): CodeQL can't track the
# sanitizer through a helper's return — the file's established idiom.
base = os.path.realpath(DUB_DIR)
cache_path = os.path.realpath(os.path.join(base, job_id, "onsets.json"))
if not cache_path.startswith(base + os.sep):
raise HTTPException(status_code=400, detail="Invalid job id")
try:
if (
os.path.exists(cache_path)
and os.path.getmtime(cache_path) >= os.path.getmtime(src_path)
):
with open(cache_path, "r", encoding="utf-8") as f:
cached = json.load(f)
if isinstance(cached, dict) and isinstance(cached.get("onsets"), list):
return cached
except (OSError, ValueError):
pass # unreadable/corrupt cache → recompute below
try:
onsets = await asyncio.to_thread(_compute_onsets_sync, src_path)
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"Onset analysis failed: {str(e)[:200]}",
)
payload = {"onsets": onsets, "source": source}
try:
os.makedirs(os.path.dirname(cache_path), exist_ok=True)
tmp_path = cache_path + ".tmp"
with open(tmp_path, "w", encoding="utf-8") as f:
json.dump(payload, f)
os.replace(tmp_path, cache_path)
except OSError as e:
logger.warning("onsets cache write failed for %s: %s", job_id, e)
return payload
@router.get("/dub/thumb/{job_id}")
async def dub_get_thumb(job_id: str):
"""Serve the extracted dub video thumbnail (jpg). 404 if not generated."""
job = _get_job(job_id)
if not job:
raise HTTPException(status_code=404, detail="Job not found")
# Resolve under DUB_DIR to prevent traversal.
thumb = os.path.join(DUB_DIR, job_id, "thumb.jpg")
if not os.path.exists(thumb):
raise HTTPException(status_code=404, detail="Thumbnail not available")
return FileResponse(thumb, media_type="image/jpeg", headers={"Cache-Control": "public, max-age=3600"})
@router.get("/dub/audio/{job_id}")
async def dub_get_audio(job_id: str):
job = _get_job(job_id)
if not job:
raise HTTPException(status_code=404, detail="Job not found")
audio = job.get("audio_path")
if not audio or not os.path.exists(audio):
raise HTTPException(status_code=404, detail="Audio file not found")
return FileResponse(audio, media_type="audio/wav")
@router.get("/dub/preview/{job_id}/{segment_index}")
async def dub_preview_segment(job_id: str, segment_index: int):
job = _get_job(job_id)
if not job:
raise HTTPException(status_code=404, detail="Job not found")
# Resolve the stable-id-named WAV via the render manifest; fall back to the
# legacy index name for jobs rendered before id-based naming (#185). Each
# candidate is realpath-normalised and containment-checked BEFORE any
# filesystem access, so the guard dominates every path sink.
order = job.get("seg_order") or []
seg_id = order[segment_index] if 0 <= segment_index < len(order) else segment_index
base = os.path.realpath(DUB_DIR)
seg_path = None
for _sid in (seg_id, segment_index):
cand = os.path.realpath(dub_seg_path(job_id, _sid))
if cand.startswith(base + os.sep) and os.path.exists(cand):
seg_path = cand
break
if not seg_path:
raise HTTPException(status_code=404, detail="Segment not generated yet")
return FileResponse(seg_path, media_type="audio/wav")
@router.get("/dub/download-audio/{job_id}")
@router.get("/dub/download-audio/{job_id}/{filename}")
async def dub_download_audio(job_id: str, lang: str = Query(None), preserve_bg: bool = Query(True), save_path: str = Query("")):
job = _get_job(job_id)
if not job:
raise HTTPException(status_code=404, detail="Job not found")
tracks = job.get("dubbed_tracks", {})
if lang and lang in tracks:
wav_path = tracks[lang]["path"]
elif tracks:
wav_path = list(tracks.values())[0]["path"]
else:
raise HTTPException(status_code=400, detail="No dubbed audio track generated yet")
if not os.path.exists(wav_path):
raise HTTPException(status_code=404, detail="Audio file not found")
lang_label = lang or list(tracks.keys())[0]
stamp = _unique_stamp()
exports_dir = os.path.join(DUB_DIR, job_id, "exports")
os.makedirs(exports_dir, exist_ok=True)
bg_audio = job.get("no_vocals_path") if preserve_bg else None
if bg_audio and os.path.exists(bg_audio):
ffmpeg = find_ffmpeg()
final_audio_path = os.path.join(exports_dir, f"mixed_dub_{lang_label}_{stamp}.wav")
cmd = [
ffmpeg, "-i", bg_audio, "-i", wav_path,
"-filter_complex", "[0:a][1:a]amix=inputs=2:duration=longest:dropout_transition=2:weights=0.8 1.2[aout]",
"-map", "[aout]", "-c:a", "pcm_s16le", "-y", final_audio_path
]
try:
rc, _, stderr = await run_ffmpeg(cmd, timeout=900.0)
if rc != 0:
raise Exception(stderr.decode(errors="replace") if stderr else "ffmpeg mix non-zero")
if not os.path.exists(final_audio_path) or os.path.getsize(final_audio_path) == 0:
raise Exception("ffmpeg mix produced no output file")
wav_path = final_audio_path
logger.info("Dub audio mix wrote %s (%d bytes)", final_audio_path, os.path.getsize(final_audio_path))
except Exception as e:
logger.error(f"Failed to mix audio: {str(e)}")
base_name = os.path.splitext(job.get('filename', 'audio'))[0]
safe_name = ''.join(c for c in base_name if c.isalnum() or c in '-_ ').strip() or 'audio'
dl_name = f"dubbed_audio_{lang_label}_{safe_name}_{stamp}.wav"
if save_path:
return _native_save(wav_path, save_path, dl_name, media_type="audio/wav")
return FileResponse(
wav_path, media_type="audio/wav",
headers={"Content-Disposition": f'attachment; filename="{dl_name}"'},
)
def _format_srt_time(seconds):
h = int(seconds // 3600)
m = int((seconds % 3600) // 60)
s = int(seconds % 60)
ms = int((seconds % 1) * 1000)
return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}"
def _pick_subtitle_text(seg: dict, dual: bool) -> str:
"""One line per subtitle cue, unless dual=true and an original exists.
Dual layout stacks translated text on top of the (italicised) original, the
way Netflix / language-learning apps present them:
Das Spiel wirklich zu verändern.
<i>Actually change the game.</i>
"""
translated = (seg.get("text") or "").strip()
original = (seg.get("text_original") or "").strip()
if not dual or not original or original == translated:
return translated or original
return f"{translated}\n<i>{original}</i>"
# Subtitles deliberately have no ?save_path= variant: they're small text
# bodies, so the Tauri side fetches them raw and writes the file itself via
# the save_text_file command — the OS save dialog is the write authorization
# (#309). The frontend's JSON-envelope save flow stays for binary exports.
@router.get("/dub/srt/{job_id}")
@router.get("/dub/srt/{job_id}/{filename}")
async def dub_export_srt(job_id: str, dual: bool = False):
job = _get_job(job_id)
if not job:
raise HTTPException(status_code=404, detail="Job not found")
segments = job.get("segments", [])
if not segments:
raise HTTPException(status_code=400, detail="No transcript segments available")
srt_lines = []
for i, seg in enumerate(segments):
start_ts = _format_srt_time(seg["start"])
end_ts = _format_srt_time(seg["end"])
srt_lines.append(f"{i + 1}")
srt_lines.append(f"{start_ts} --> {end_ts}")
srt_lines.append(_pick_subtitle_text(seg, dual))
srt_lines.append("")
srt_content = "\n".join(srt_lines)
base_name = os.path.splitext(job.get('filename', 'video'))[0]
suffix = "_dual" if dual else ""
dl_name = f"subtitles_{base_name}{suffix}.srt"
return Response(
content=srt_content,
media_type="text/plain",
headers={"Content-Disposition": f'attachment; filename="{dl_name}"'},
)
def _format_vtt_time(seconds):
h = int(seconds // 3600)
m = int((seconds % 3600) // 60)
s = int(seconds % 60)
ms = int((seconds % 1) * 1000)
return f"{h:02d}:{m:02d}:{s:02d}.{ms:03d}"
@router.get("/dub/vtt/{job_id}")
@router.get("/dub/vtt/{job_id}/{filename}")
async def dub_export_vtt(job_id: str, dual: bool = False):
job = _get_job(job_id)
if not job:
raise HTTPException(status_code=404, detail="Job not found")
segments = job.get("segments", [])
if not segments:
raise HTTPException(status_code=400, detail="No transcript segments available")
vtt_lines = ["WEBVTT", ""]
for i, seg in enumerate(segments):
start_ts = _format_vtt_time(seg["start"])
end_ts = _format_vtt_time(seg["end"])
vtt_lines.append(str(i + 1))
vtt_lines.append(f"{start_ts} --> {end_ts}")
vtt_lines.append(_pick_subtitle_text(seg, dual))
vtt_lines.append("")
vtt_content = "\n".join(vtt_lines)
base_name = os.path.splitext(job.get('filename', 'video'))[0]
suffix = "_dual" if dual else ""
dl_name = f"subtitles_{base_name}{suffix}.vtt"
return Response(
content=vtt_content,
media_type="text/vtt",
headers={"Content-Disposition": f'attachment; filename="{dl_name}"'},
)
@router.get("/dub/export-segments/{job_id}")
async def dub_export_segments_zip(job_id: str):
import zipfile
job = _get_job(job_id)
if not job:
raise HTTPException(status_code=404, detail="Job not found")
segments = job.get("segments", [])
if not segments:
raise HTTPException(status_code=400, detail="No segments available")
zip_buffer = io.BytesIO()
order = job.get("seg_order") or []
base = os.path.realpath(DUB_DIR)
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
for i, seg in enumerate(segments):
seg_id = order[i] if i < len(order) else i
# realpath + containment guard before any filesystem access.
seg_path = None
for _sid in (seg_id, i):
cand = os.path.realpath(dub_seg_path(job_id, _sid))
if cand.startswith(base + os.sep) and os.path.exists(cand):
seg_path = cand
break
if seg_path:
speaker = seg.get("speaker_id", "Speaker1").replace(" ", "")
start_str = f"{seg['start']:.2f}"
end_str = f"{seg['end']:.2f}"
arc_name = f"{i+1:03d}_{start_str}-{end_str}_{speaker}.wav"
zf.write(seg_path, arc_name)
zip_buffer.seek(0)
base_name = os.path.splitext(job.get('filename', 'video'))[0]
safe_name = ''.join(c for c in base_name if c.isalnum() or c in '-_ ').strip() or 'segments'
return Response(
content=zip_buffer.read(),
media_type="application/zip",
headers={"Content-Disposition": f'attachment; filename="segments_{safe_name}.zip"'},
)
@router.get("/dub/download-mp3/{job_id}")
@router.get("/dub/download-mp3/{job_id}/{filename}")
async def dub_download_mp3(job_id: str, lang: str = Query(None), preserve_bg: bool = Query(True), save_path: str = Query(""), bitrate: str = Query("192k")):
job = _get_job(job_id)
if not job:
raise HTTPException(status_code=404, detail="Job not found")
tracks = job.get("dubbed_tracks", {})
if lang and lang in tracks:
wav_path = tracks[lang]["path"]
elif tracks:
wav_path = list(tracks.values())[0]["path"]
else:
raise HTTPException(status_code=400, detail="No dubbed audio track generated yet")
if not os.path.exists(wav_path):
raise HTTPException(status_code=404, detail="Audio file not found")
lang_label = lang or list(tracks.keys())[0]
ffmpeg = find_ffmpeg()
stamp = _unique_stamp()
exports_dir = os.path.join(DUB_DIR, job_id, "exports")
os.makedirs(exports_dir, exist_ok=True)
source_path = wav_path
bg_audio = job.get("no_vocals_path") if preserve_bg else None
if bg_audio and os.path.exists(bg_audio):
mixed_path = os.path.join(exports_dir, f"mixed_mp3_{lang_label}_{stamp}.wav")
cmd_mix = [
ffmpeg, "-i", bg_audio, "-i", wav_path,
"-filter_complex", "[0:a][1:a]amix=inputs=2:duration=longest:dropout_transition=2:weights=0.8 1.2[aout]",
"-map", "[aout]", "-c:a", "pcm_s16le", "-y", mixed_path
]
try:
rc, _, _ = await run_ffmpeg(cmd_mix, timeout=900.0)
if rc == 0 and os.path.exists(mixed_path) and os.path.getsize(mixed_path) > 0:
source_path = mixed_path
except Exception as e:
logger.error(f"Failed to mix audio for MP3: {e}")
mp3_path = os.path.join(exports_dir, f"dubbed_{lang_label}_{stamp}.mp3")
# Accept '128', '192k' etc. — normalize to ffmpeg's 'Nk' form and clamp
# to a sensible range so a malformed value can't stall encoding.
_br = str(bitrate or "192k").lower().rstrip("k") or "192"
try:
_br_int = max(64, min(int(_br), 320))
except ValueError:
_br_int = 192
br_arg = f"{_br_int}k"
cmd = [ffmpeg, "-i", source_path, "-codec:a", "libmp3lame", "-b:a", br_arg, "-y", mp3_path]
try:
rc, _, stderr = await run_ffmpeg(cmd, timeout=600.0)
if rc != 0:
raise Exception(stderr.decode(errors="replace") if stderr else "MP3 encode non-zero")
except asyncio.TimeoutError:
raise HTTPException(status_code=504, detail="MP3 encoding timed out")
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"ffmpeg couldn't encode MP3: {e}. Check that libmp3lame is compiled into your ffmpeg build (`ffmpeg -codecs | grep mp3`) — reinstall via homebrew if it's missing.",
)
if not os.path.exists(mp3_path) or os.path.getsize(mp3_path) == 0:
raise HTTPException(status_code=500, detail="MP3 encoding produced no output file")
logger.info("Dub MP3 encoded %s (%d bytes)", mp3_path, os.path.getsize(mp3_path))
base_name = os.path.splitext(job.get('filename', 'audio'))[0]
safe_name = ''.join(c for c in base_name if c.isalnum() or c in '-_ ').strip() or 'audio'
dl_name = f"dubbed_{lang_label}_{safe_name}_{stamp}.mp3"
if save_path:
return _native_save(mp3_path, save_path, dl_name, media_type="audio/mpeg")
return FileResponse(
mp3_path, media_type="audio/mpeg",
headers={"Content-Disposition": f'attachment; filename="{dl_name}"'},
)
@router.get("/dub/export-stems/{job_id}")
async def dub_export_stems(job_id: str, lang: str = Query(None)):
import zipfile
job = _get_job(job_id)
if not job:
raise HTTPException(status_code=404, detail="Job not found")
tracks = job.get("dubbed_tracks", {})
if not tracks:
raise HTTPException(status_code=400, detail="No dubbed tracks generated yet")
if lang and lang in tracks:
vocals_path = tracks[lang]["path"]
lang_label = lang
elif tracks:
first_key = list(tracks.keys())[0]
vocals_path = tracks[first_key]["path"]
lang_label = first_key
else:
raise HTTPException(status_code=400, detail="No dubbed audio track")
bg_path = job.get("no_vocals_path")
zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
if os.path.exists(vocals_path):
zf.write(vocals_path, f"vocals_dubbed_{lang_label}.wav")
if bg_path and os.path.exists(bg_path):
zf.write(bg_path, "background_original.wav")
zip_buffer.seek(0)
base_name = os.path.splitext(job.get('filename', 'video'))[0]
safe_name = ''.join(c for c in base_name if c.isalnum() or c in '-_ ').strip() or 'stems'
return Response(
content=zip_buffer.read(),
media_type="application/zip",
headers={"Content-Disposition": f'attachment; filename="stems_{safe_name}.zip"'},
)