* feat(dub): audio-only dubbing mode (#119) Add an audio→audio dubbing path: upload an audio file, get dubbed audio out, with no video processing. The transcribe → translate → TTS core is unchanged; only the video-coupled stages are skipped. Backend: - dub_core /dub/upload: new `input_type` form field ("video"|"audio"). Audio mode validates the upload is a known audio container (else 400) and threads input_type into the ingest source dict. - dub_pipeline ingest: for audio input, skip scene detection + thumbnail ffmpeg passes (still emits scene_done count=0 so the prep SSE contract the frontend waits on is unchanged); stores input_type on the job. - dub_export /dub/download: for audio jobs, branch to an audio-only export (_build_audio_export_cmd) — no video input/map/codec/subtitle pass. Outputs dubbed_audio_{lang}_{stamp}.{wav|m4a|mp3|flac} via `out_format` (default m4a), optionally mixed with the separated background. Unknown formats fall back to AAC. Frontend: - dubSlice: dubInputType state + setter (default 'video'). - DubTab: auto-select audio-only mode when an audio file is dropped/picked. - dub.ts/useDubWorkflow: pass input_type on upload. Tests (11): _build_audio_export_cmd format/mix matrix; end-to-end audio-only export produces an audio file (no video mux); unknown-format fallback; upload rejects a video extension in audio mode. Closes #119. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * harden(#119): allowlist-sanitize lang_code in audio export path The track id is already constrained to an existing track key, but allowlist-sanitize it before it reaches the output path (same pattern as the existing safe_name) so a path component can never carry separators — clears the CodeQL path-injection flag on the new audio-export branch. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * polish(#119): address Greptile P2s on audio-only dubbing - dub_pipeline: emit scene_start before scene_done(count=0) for audio so the prep SSE stage sequence is symmetric with the video path. - useDubWorkflow: 'Preparing audio…' pill for audio jobs (was always 'Preparing video…'). - DubTab: widen the drop-accept regex + file-input accept to the full supported audio set (aac/opus/wma) so it matches the input-type detection and the backend allowlist. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(#119): drop unused dubInputType read in DubTab (CodeQL) Only setDubInputType is used; the value read was dead. Clears the CodeQL unused-variable alert. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
53 lines
1.9 KiB
Python
53 lines
1.9 KiB
Python
"""#119 — audio-only dubbing export.
|
|
|
|
The audio-only branch of /dub/download builds a simple ffmpeg command that
|
|
muxes the dubbed track (optionally mixed with the separated background) into an
|
|
audio container — no video input, no video codec/stream-map/subtitle pass.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from api.routers.dub_export import _build_audio_export_cmd
|
|
|
|
|
|
def _flat(cmd):
|
|
return " ".join(cmd)
|
|
|
|
|
|
def test_wav_track_only_no_video():
|
|
cmd = _build_audio_export_cmd("ffmpeg", "/j/dubbed_de.wav", None, "/j/out.wav", "wav")
|
|
s = _flat(cmd)
|
|
# the dubbed track is the only input; never the source media / a video map
|
|
assert cmd.count("-i") == 1
|
|
assert "/j/dubbed_de.wav" in s
|
|
assert "-map" not in s or "0:v" not in s # no video stream mapping
|
|
assert "-c:v" not in s # no video codec
|
|
assert "pcm_s16le" in s # wav → PCM
|
|
assert cmd[-1] == "/j/out.wav"
|
|
|
|
|
|
def test_m4a_uses_aac():
|
|
cmd = _build_audio_export_cmd("ffmpeg", "/j/dubbed_de.wav", None, "/j/out.m4a", "m4a")
|
|
s = _flat(cmd)
|
|
assert "aac" in s
|
|
assert "-c:v" not in s
|
|
|
|
|
|
def test_mp3_uses_lame():
|
|
cmd = _build_audio_export_cmd("ffmpeg", "/j/dubbed_de.wav", None, "/j/out.mp3", "mp3")
|
|
assert "libmp3lame" in _flat(cmd)
|
|
|
|
|
|
def test_background_mix_adds_amix_and_second_input():
|
|
cmd = _build_audio_export_cmd("ffmpeg", "/j/dubbed_de.wav", "/j/no_vocals.wav", "/j/out.m4a", "m4a")
|
|
s = _flat(cmd)
|
|
assert cmd.count("-i") == 2 # track + background
|
|
assert "/j/no_vocals.wav" in s
|
|
assert "amix" in s # mixed, not just concatenated
|
|
assert "-filter_complex" in s
|
|
|
|
|
|
def test_unknown_format_falls_back_to_aac_m4a():
|
|
# Defensive: an unexpected format string must not produce a broken command.
|
|
cmd = _build_audio_export_cmd("ffmpeg", "/j/dubbed_de.wav", None, "/j/out.bin", "weird")
|
|
assert "aac" in _flat(cmd)
|