Merge pull request #1672 from debpalash/fix/dub-srt-voices-1660

fix(dub): preserve workflow state and browser compatibility
This commit is contained in:
Palash Debnath
2026-08-27 22:12:52 +05:30
committed by GitHub
43 changed files with 1186 additions and 42 deletions
+4
View File
@@ -11,6 +11,10 @@ the frozen-backend fallback mirror it for their toolchains.
**Highlights**
- VoiceStudio now acts as a local speech platform: other apps can trigger its native dictation or connect through versioned HTTP, WebSocket, JSON-RPC, CLI, and MCP transports (#1646)
- Uploaded dubbing videos are normalized to browser-safe H.264/AAC before preview, preventing valid VP9, AV1, or Opus media from failing with “no supported sources” (#1644)
- Dubbing now separates spoken and target languages, preserves translations through segment cleanup, and lets failed translations be retried or skipped without restarting the batch (#1654) — thanks @Number16BusShelter!
- Importing replacement SRT subtitles now keeps each cue bound to the best-overlapping source speaker and clone instead of resetting every line to a random default voice (#1660) — thanks @invio-a11y!
- Uploading a Dub preview no longer blocks every backend request while ffmpeg extracts its audio (#1667) — thanks @tfreyd!
- Docker quick starts now require the administrator key needed through container NAT instead of starting a UI whose protected actions return 403 (#1651) — thanks @wd357dui!
- WSL2 AMD containers now use the `/dev/dxg` ROCDXG bridge with actionable GPU diagnostics instead of silently falling back to CPU (#1655) — thanks @wd357dui!
- Ad-hoc voice-clone references now stay alive until cancelled or timed-out GPU work actually stops reading them, so prompt caching can finish instead of failing on a deleted temp file (#1668) — thanks @tfreyd!
+158 -11
View File
@@ -134,6 +134,82 @@ _save_job = dub_pipeline.save_job
# paste (or a mis-aimed binary) burn CPU in the parser.
_MAX_SUBTITLE_PASTE_CHARS = 2_000_000
_SRT_REPLACED_FIELDS = {
"id",
"start",
"end",
"text",
"text_original",
"translations",
"translate_error",
"translate_degraded",
}
def _best_overlapping_segment(cue: dict, existing: list[dict]) -> dict | None:
"""Return the prior segment with the strongest temporal overlap."""
cue_start = float(cue.get("start") or 0.0)
cue_end = float(cue.get("end") or cue_start)
cue_mid = (cue_start + cue_end) / 2.0
best = None
best_key = None
for index, segment in enumerate(existing):
start = float(segment.get("start") or 0.0)
end = float(segment.get("end") or start)
overlap = min(cue_end, end) - max(cue_start, start)
if overlap <= 0:
continue
midpoint_distance = abs(cue_mid - ((start + end) / 2.0))
key = (overlap, -midpoint_distance, -index)
if best_key is None or key > best_key:
best = segment
best_key = key
return best
def _carry_srt_voice_metadata(
cues: list[dict],
existing: list[dict],
segment_clones: dict | None,
speaker_clones: dict | None = None,
) -> tuple[list[dict], dict]:
"""Replace subtitle content while retaining the source cast assignment."""
source_clones = dict(segment_clones or {})
source_speaker_clones = dict(speaker_clones or {})
# Replacement cues get new positional ids. Starting from the old map would
# let an unmatched cue whose new id happens to equal an old id inherit an
# unrelated reference. Only explicitly overlap-matched references survive.
clones = {}
merged_segments = []
for new_id, cue in enumerate(cues):
prior = _best_overlapping_segment(cue, existing)
metadata = {
key: value
for key, value in (prior or {}).items()
if key not in _SRT_REPLACED_FIELDS
}
merged = {
**metadata,
"id": new_id,
"start": cue.get("start", 0.0),
"end": cue.get("end", 0.0),
"text": cue.get("text", ""),
"text_original": cue.get("text", ""),
}
if not merged.get("speaker_id"):
merged["speaker_id"] = cue.get("speaker_id") or "Speaker 1"
if prior is not None:
prior_id = str(prior.get("id", ""))
clone = source_clones.get(prior_id)
if clone is None:
clone = source_speaker_clones.get(prior.get("speaker_id"))
if clone is not None:
clones[str(new_id)] = clone
if merged.get("profile_id") == f"auto-seg:{prior_id}":
merged["profile_id"] = f"auto-seg:{new_id}"
merged_segments.append(merged)
return merged_segments, clones
@router.post("/dub/parse-subtitle-text")
def dub_parse_subtitle_text(req: ParseSubtitleTextRequest):
@@ -234,7 +310,32 @@ async def dub_import_srt(job_id: str, file: UploadFile = File(...)):
else:
segments = result.segments
prior_segments = [
segment for segment in (job.get("segments") or []) if isinstance(segment, dict)
]
segments, segment_clones = _carry_srt_voice_metadata(
segments,
prior_segments,
job.get("segment_clones"),
job.get("speaker_clones"),
)
job["segments"] = segments
job["segment_clones"] = segment_clones
# A pooled speaker clone is keyed only by a display label. Replacement
# cues can reuse that label without overlapping the original speaker, so
# retain matched pooled references as segment-specific clones above and
# drop the global map before rebuilding the cast.
job["speaker_clones"] = {}
if segment_clones:
from services.speaker_clone import build_cast_sources
job["cast_sources"] = build_cast_sources(
segments,
None,
segment_clones,
)
else:
job.pop("cast_sources", None)
# `source_lang` stays whatever the user (or the upload step) set; we
# don't try to language-detect off the cue text — that's noisy and the
# user usually knows what their .srt is.
@@ -351,12 +452,13 @@ async def preview_upload(video: UploadFile = File(...)):
safe_name = f"{uuid.uuid4().hex[:12]}"
vid_path = os.path.join(PREVIEW_DIR, f"{safe_name}{ext}")
wav_path = os.path.join(PREVIEW_DIR, f"{safe_name}.wav")
with open(vid_path, "wb") as f:
f.write(await video.read())
has_audio = False
if ext not in [".wav", ".mp3", ".m4a", ".aac"]:
payload = await video.read()
def _write_and_extract() -> bool:
with open(vid_path, "wb") as f:
f.write(payload)
if ext in {".wav", ".mp3", ".m4a", ".aac"}:
return False
try:
ffmpeg_cmd = [
find_ffmpeg(), "-y", "-i", vid_path,
@@ -368,10 +470,16 @@ async def preview_upload(video: UploadFile = File(...)):
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
timeout=300,
)
has_audio = True
return True
except Exception as e:
logger.warning("FFmpeg extraction failed: %s", log_safe(e))
pass
return False
# File writes and ffmpeg are blocking operations. Keep them on the bounded
# CPU pool so a large preview cannot stall unrelated API requests (#1667).
has_audio = await asyncio.get_running_loop().run_in_executor(
_cpu_pool, _write_and_extract
)
return {
"url": f"/preview/{safe_name}{ext}",
@@ -410,12 +518,38 @@ _ingest_gen = dub_pipeline.ingest_pipeline
#: container so a mislabelled video can't slip past the video-skipping branch.
_AUDIO_EXTS = {".wav", ".mp3", ".m4a", ".aac", ".flac", ".ogg", ".opus", ".wma"}
# Source-language choices exposed by the first-party dub UI. Keeping this an
# allow-list rejects language names and private-use BCP-47 tags before they are
# persisted as ASR overrides. Values are normalized to lowercase below.
_DUB_SOURCE_LANG_CODES = frozenset({
"af", "sq", "am", "ar", "hy", "az", "eu", "be", "bn", "bs", "bg",
"my", "ca", "cmn-hans", "cmn-hant", "hr", "cs", "da", "nl", "en",
"et", "fi", "fr", "gl", "ka", "de", "el", "gu", "ht", "ha", "haw",
"he", "hi", "hu", "is", "id", "it", "ja", "jw", "kn", "kk", "km",
"ko", "ku", "ky", "lo", "la", "lv", "lt", "mk", "ms", "ml", "mt",
"mi", "mr", "mn", "ne", "no", "ps", "fa", "pl", "pt", "pa", "ro",
"ru", "sm", "gd", "sr", "sn", "sd", "si", "sk", "sl", "so", "es",
"su", "sw", "sv", "tg", "ta", "te", "th", "tr", "uk", "ur", "uz",
"vi", "cy", "xh", "yi", "yo", "zu",
})
def _source_lang_override(value: str | None) -> str | None:
"""Normalize a user-selected source language; auto/und means detect."""
code = (value or "").strip().lower()
if code in {"", "auto", "und"}:
return None
if code not in _DUB_SOURCE_LANG_CODES:
raise HTTPException(status_code=400, detail="Invalid source language code")
return code
@router.post("/dub/upload")
async def dub_upload(
video: UploadFile = File(...),
job_id: Optional[str] = Form(None),
input_type: str = Form("video"),
source_lang: Optional[str] = Form(None),
):
"""Accept a media upload, write to disk, queue background prep task.
@@ -445,6 +579,7 @@ async def dub_upload(
detail=f"Audio-only dubbing needs an audio file ({', '.join(sorted(_AUDIO_EXTS))}); got '{ext or 'no extension'}'.",
)
source_lang_override = _source_lang_override(source_lang)
os.makedirs(job_dir, exist_ok=True)
video_path = os.path.join(job_dir, f"original{ext}")
@@ -456,7 +591,13 @@ async def dub_upload(
await task_manager.add_task(
task_id, "prep",
_ingest_gen, job_id, job_dir,
{"kind": "file", "path": video_path, "input_type": input_type}, filename,
{
"kind": "file",
"path": video_path,
"input_type": input_type,
"source_lang": source_lang_override,
},
filename,
)
return JSONResponse(
status_code=202,
@@ -478,6 +619,7 @@ async def dub_ingest_url(req: DubIngestUrlRequest, request: Request):
status_code=400,
detail="URL must start with http:// or https://. Paste a full video link (e.g. https://youtube.com/watch?v=…) or drop a local file instead.",
)
source_lang_override = _source_lang_override(req.source_lang)
try:
import yt_dlp # noqa: F401
@@ -513,6 +655,7 @@ async def dub_ingest_url(req: DubIngestUrlRequest, request: Request):
"fetch_subs": bool(req.fetch_subs),
"sub_langs": req.sub_langs or None,
"cookie_file": cookie_path,
"source_lang": source_lang_override,
}
try:
await task_manager.add_task(
@@ -1669,7 +1812,9 @@ async def dub_transcribe_stream(
except Exception as e:
logger.warning("speaker_clone extraction skipped: %s", e)
job["source_lang"] = ((detected_lang or "en").split("_")[0][:2] or "en").lower()
job["source_lang"] = job.get("source_lang_override") or (
(detected_lang or "en").split("_")[0][:2] or "en"
).lower()
job["full_transcript"] = " ".join(s.get("text", "") for s in final_segs)
_save_job(job_id, job)
@@ -1866,7 +2011,9 @@ async def dub_transcribe(job_id: str, num_speakers: Optional[int] = None):
except Exception as e:
logger.warning("Failed to unload ASR backend: %s", e)
job["source_lang"] = (detected_lang or "en").split("_")[0][:2].lower()
job["source_lang"] = job.get("source_lang_override") or (
(detected_lang or "en").split("_")[0][:2] or "en"
).lower()
scene_cuts = job.get("scene_cuts") or []
segments = segment_transcript(result, duration=job.get("duration", 0.0), scene_cuts=scene_cuts)
+1
View File
@@ -190,6 +190,7 @@ class ParseSubtitleTextRequest(BaseModel):
class DubIngestUrlRequest(BaseModel):
url: str
job_id: Optional[str] = None
source_lang: Optional[str] = None
# When true and the URL is a caption-bearing host (YouTube, Vimeo, TED…),
# ask yt-dlp to also download the original-language + any additional
# sub_langs as VTT. The UI uses this to seed a transcript without running
+77
View File
@@ -742,6 +742,74 @@ def _ensure_browser_playable_mp4(video_path: str) -> str:
return video_path
async def _ensure_browser_playable_mp4_for_job(job_id: str, video_path: str) -> str:
"""Normalize an upload through the job's cancellable process registry."""
is_mp4 = video_path.lower().endswith(".mp4")
vcodec, acodec = await asyncio.to_thread(_probe_codecs, video_path)
if is_mp4 and vcodec in _BROWSER_VIDEO_CODECS and acodec in _BROWSER_AUDIO_CODECS:
return video_path
target = os.path.splitext(video_path)[0] + ".mp4"
if target == video_path:
target = os.path.splitext(video_path)[0] + ".browser.mp4"
run_proc = run_proc_factory(job_id)
ffmpeg_bin = find_ffmpeg()
async def attempt(cmd: list[str]) -> int:
try:
proc, _stdout, _stderr = await run_proc(cmd, timeout=1800.0)
return proc.returncode
except asyncio.CancelledError:
raise
except Exception as exc:
logger.warning(
"Browser-media normalization process failed for %s: %s",
log_safe(video_path),
log_safe(exc),
)
return 1
rc = 1
if not is_mp4:
rc = await attempt(
[
ffmpeg_bin, "-y", "-i", video_path,
"-c:v", "copy", "-c:a", "copy",
"-movflags", "+faststart", target,
]
)
if rc == 0 and os.path.exists(target):
target_vcodec, target_acodec = await asyncio.to_thread(_probe_codecs, target)
if (
target_vcodec not in _BROWSER_VIDEO_CODECS
or target_acodec not in _BROWSER_AUDIO_CODECS
):
rc = 1
else:
rc = 1
if rc != 0:
rc = await attempt(
[
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,
]
)
if rc == 0 and os.path.exists(target) and target != video_path:
try:
os.remove(video_path)
except OSError:
pass # Best effort: the normalized target is already complete.
return target
logger.warning(
"Could not transcode %s to browser-playable mp4 — the in-app "
"video player may render this file as a black box.",
log_safe(video_path),
)
return video_path
# Bounded retry for transient download failures (#579/#598). yt-dlp's own
# `retries`/`fragment_retries` cover per-fragment HTTP flakes, but a broken
# pipe ([Errno 32]) raised while the write side of a pipe closes mid-stream
@@ -1257,6 +1325,13 @@ async def ingest_pipeline(
except Exception:
dur = 0.0
# URL downloads already pass through this guard in yt_download_sync.
# Uploaded videos did not, so a valid VP9/AV1/Opus upload could be
# processed successfully but remain undecodable by the in-app WebView.
# Codec probing/transcoding is blocking; keep it off the event loop.
if source.get("kind") != "url" and input_type != "audio":
video_path = await _ensure_browser_playable_mp4_for_job(job_id, video_path)
# 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)
@@ -1295,6 +1370,7 @@ async def ingest_pipeline(
"scene_cuts": scene_cuts,
"youtube_subs": youtube_subs_by_lang or None,
"input_type": input_type,
"source_lang_override": source.get("source_lang"),
}
if not put_and_save_job(
job_id, full_job, filename=filename, duration=dur, content_hash=content_hash,
@@ -1323,6 +1399,7 @@ async def ingest_pipeline(
"scene_cuts": [],
"youtube_subs": youtube_subs_by_lang or None,
"input_type": input_type,
"source_lang_override": source.get("source_lang"),
}
if not put_and_save_job(
job_id, partial, filename=filename, duration=dur, content_hash=content_hash,
+50
View File
@@ -79,6 +79,7 @@ class Segment:
text: str
speaker_id: str = "Speaker 1"
id: str = field(default_factory=lambda: str(uuid.uuid4())[:8])
extra: dict = field(default_factory=dict)
@property
def duration(self) -> float:
@@ -90,6 +91,7 @@ class Segment:
def to_dict(self) -> dict:
return {
**self.extra,
"id": self.id,
"start": round(self.start, 2),
"end": round(self.end, 2),
@@ -98,6 +100,46 @@ class Segment:
}
def _merge_segment_extra(target: Segment, incoming: Segment, *, prepend: bool) -> None:
"""Preserve editor metadata when cleanup folds ``incoming`` into ``target``."""
for key, value in incoming.extra.items():
target.extra.setdefault(key, value)
def joined(left: object, right: object) -> str:
return _clean(f"{left or ''} {right or ''}")
target_original = target.extra.get("text_original")
incoming_original = incoming.extra.get("text_original")
if target_original is not None or incoming_original is not None:
target.extra["text_original"] = (
joined(incoming_original, target_original)
if prepend
else joined(target_original, incoming_original)
)
raw_target_translations = target.extra.get("translations")
raw_incoming_translations = incoming.extra.get("translations")
target_translations = raw_target_translations if isinstance(raw_target_translations, dict) else {}
incoming_translations = (
raw_incoming_translations if isinstance(raw_incoming_translations, dict) else {}
)
if target_translations or incoming_translations:
merged = {}
languages = {
*target_translations.keys(),
*incoming_translations.keys(),
}
for language in languages:
target_text = target_translations.get(language)
incoming_text = incoming_translations.get(language)
merged[language] = (
joined(incoming_text, target_text)
if prepend
else joined(target_text, incoming_text)
)
target.extra["translations"] = merged
def _clean(text: str) -> str:
return _WS.sub(" ", (text or "").strip())
@@ -317,12 +359,14 @@ def _merge_short(segments: List[Segment]) -> List[Segment]:
i += 1
continue
if target is prev:
_merge_segment_extra(prev, s, prepend=False)
prev.text = _clean(prev.text + " " + s.text)
prev.end = max(prev.end, s.end)
segments.pop(i)
did_merge = True
continue
if target is nxt:
_merge_segment_extra(nxt, s, prepend=True)
nxt.text = _clean(s.text + " " + nxt.text)
nxt.start = min(nxt.start, s.start)
segments.pop(i)
@@ -360,6 +404,7 @@ def _stitch_adjacent_shorts(segments: List[Segment]) -> List[Segment]:
and b.duration <= STITCH_DUR
and combined_dur <= MAX_DUR
):
_merge_segment_extra(a, b, prepend=False)
a.text = _clean(a.text + " " + b.text)
a.end = b.end
segments.pop(i + 1)
@@ -386,6 +431,11 @@ def clean_up_segments(segments: List[dict]) -> List[dict]:
text=_clean(str(s.get("text", ""))),
speaker_id=str(s.get("speaker_id") or "Speaker 1"),
id=str(s.get("id") or uuid.uuid4().hex[:8]),
extra={
key: value
for key, value in s.items()
if key not in {"id", "start", "end", "text", "speaker_id"}
},
))
except (TypeError, ValueError):
continue
+37
View File
@@ -0,0 +1,37 @@
from __future__ import annotations
import asyncio
import io
import threading
import pytest
from fastapi import UploadFile
@pytest.mark.asyncio
async def test_preview_ffmpeg_does_not_block_event_loop(monkeypatch, tmp_path):
from api.routers import dub_core
loop = asyncio.get_running_loop()
started = asyncio.Event()
release = threading.Event()
def slow_ffmpeg(*_args, **_kwargs):
loop.call_soon_threadsafe(started.set)
assert release.wait(timeout=2)
monkeypatch.setattr(dub_core, "PREVIEW_DIR", str(tmp_path))
monkeypatch.setattr(dub_core, "find_ffmpeg", lambda: "ffmpeg")
monkeypatch.setattr(dub_core.subprocess, "run", slow_ffmpeg)
upload = UploadFile(filename="preview.mp4", file=io.BytesIO(b"video"))
before = loop.time()
task = asyncio.create_task(dub_core.preview_upload(upload))
try:
await asyncio.wait_for(started.wait(), timeout=1)
assert loop.time() - before < 0.5
finally:
release.set()
result = await task
assert result["audioUrl"].endswith(".wav")
+6
View File
@@ -486,6 +486,8 @@ function App() {
const setDubLang = useAppStore((s) => s.setDubLang);
const dubLangCode = useAppStore((s) => s.dubLangCode);
const setDubLangCode = useAppStore((s) => s.setDubLangCode);
const dubSourceLangCode = useAppStore((s) => s.dubSourceLangCode);
const setDubSourceLangCode = useAppStore((s) => s.setDubSourceLangCode);
const dubDialect = useAppStore((s) => s.dubDialect);
const setDubDialect = useAppStore((s) => s.setDubDialect);
const dubInstruct = useAppStore((s) => s.dubInstruct);
@@ -982,6 +984,7 @@ function App() {
setDubTracks([]);
setDubProgress({ current: 0, total: 0, text: '' });
setDubTranscript('');
setDubSourceLangCode('auto');
setShowTranscript(false);
setPreviewAudios({});
setDubLocalBlobUrl((prev) => {
@@ -1011,6 +1014,7 @@ function App() {
dubSegments,
dubLang,
dubLangCode,
dubSourceLangCode,
dubDialect,
dubInstruct,
dubTracks,
@@ -1062,6 +1066,7 @@ function App() {
);
setDubLang(s.dubLang || 'Auto');
setDubLangCode(s.dubLangCode || 'en');
setDubSourceLangCode(s.dubSourceLangCode || 'auto');
setDubDialect(s.dubDialect || '');
setDubInstruct(s.dubInstruct || '');
setDubTracks(s.dubTracks || []);
@@ -1144,6 +1149,7 @@ function App() {
// restores existing rows correctly without a migration.
setDubLang(item.language || job.language || 'Auto');
setDubLangCode(item.language_code || job.language_code || 'und');
setDubSourceLangCode(job.source_lang_override || job.source_lang || 'auto');
setDubTracks(Object.keys(job.dubbed_tracks || {}));
setDubStep(Object.keys(job.dubbed_tracks || {}).length > 0 ? 'done' : 'editing');
// Phase 4.5 seg_hashes are written per successful segment by
+10 -2
View File
@@ -4,12 +4,17 @@ import type { DubHistoryResponse, DubTranslateResponse } from './types';
export async function dubUpload(
file: File | Blob,
jobId: string,
{ signal, inputType = 'video' }: { signal?: AbortSignal; inputType?: 'video' | 'audio' } = {},
{
signal,
inputType = 'video',
sourceLang,
}: { signal?: AbortSignal; inputType?: 'video' | 'audio'; sourceLang?: string } = {},
): Promise<unknown> {
const fd = new FormData();
fd.append('video', file);
fd.append('job_id', jobId);
fd.append('input_type', inputType); // #119: audio-only dubbing
if (sourceLang && sourceLang !== 'auto') fd.append('source_lang', sourceLang);
return apiPost('/dub/upload', fd, { signal });
}
@@ -21,6 +26,8 @@ export interface IngestUrlOptions {
subLangs?: string[];
/** Explicit cookies.txt export used only for this import. */
cookieFile?: File;
/** Explicit spoken language, or auto/undefined to use ASR detection. */
sourceLang?: string;
}
export const DUB_COOKIE_TRANSPORT_ERROR = 'DUB_COOKIE_TRANSPORT';
@@ -46,7 +53,7 @@ export async function dubIngestUrl(
jobId: string,
opts: IngestUrlOptions = {},
): Promise<unknown> {
const { signal, fetchSubs, subLangs, cookieFile } = opts;
const { signal, fetchSubs, subLangs, cookieFile, sourceLang } = opts;
if (cookieFile && !_cookieTransportAllowed(API)) {
throw cookieSelectionError(DUB_COOKIE_TRANSPORT_ERROR);
}
@@ -62,6 +69,7 @@ export async function dubIngestUrl(
fetch_subs: fetchSubs || undefined,
sub_langs: subLangs && subLangs.length ? subLangs : undefined,
cookie_file: cookieText,
source_lang: sourceLang && sourceLang !== 'auto' ? sourceLang : undefined,
},
{ signal },
);
@@ -117,6 +117,7 @@ export default function DubLeftColumn({
// classifies as impossible to fit (default OFF needs an LLM).
const condenseSuggest = useAppStore((s) => s.condenseSuggest);
const setCondenseSuggest = useAppStore((s) => s.setCondenseSuggest);
const failedTranslationCount = dubSegments.filter((segment) => segment.translate_error).length;
// Frozen-build (packaged/signed, read-only site-packages) escape-hatch
// popover: pip install is impossible, so we surface the copyable command +
// a one-click switch to the always-bundled Argos engine + a docs deeplink.
@@ -803,6 +804,34 @@ export default function DubLeftColumn({
</div>
</div>
<div className="flex justify-end gap-[6px] flex-wrap">
{failedTranslationCount > 0 && (
<>
<Button
variant="subtle"
size="sm"
onClick={() => handleTranslateAll({ retryFailed: true })}
disabled={isTranslating}
>
{t('dub.retry_failed', { count: failedTranslationCount })}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() =>
editSegments(
dubSegments.map((segment) =>
segment.translate_error
? { ...segment, translate_error: undefined, translation_skipped: true }
: segment,
),
)
}
disabled={isTranslating}
>
{t('dub.skip_failed')}
</Button>
</>
)}
<Button
variant="subtle"
size="sm"
@@ -95,6 +95,8 @@ export default function IdleSkeleton({
youtubeCookieFile,
setYoutubeCookieFile,
dubLangCode,
dubSourceLangCode,
setDubSourceLangCode,
setDubLangCode,
setDubLang,
landingAdvOpen,
@@ -470,6 +472,24 @@ export default function IdleSkeleton({
(speakers, style) hides behind Advanced ElevenLabs-style
flow, VoiceStudio chrome. The pick pre-seeds the editor. */}
<div className="flex items-center justify-between gap-[10px] mt-[10px] px-[10px] py-[8px] [border:1px_solid_var(--chrome-border)] rounded-[10px] bg-[var(--chrome-hover-bg)]">
<label className="dub-landing-opts__lang inline-flex items-center gap-[7px] min-w-0 text-[var(--chrome-fg-muted)]">
<Globe size={13} />
<span className="text-[0.72rem] font-medium whitespace-nowrap">
{t('dub.source_language')}
</span>
<select
className="input-base text-[0.65rem]"
value={dubSourceLangCode}
onChange={(event) => setDubSourceLangCode(event.target.value)}
>
<option value="auto">{t('bootstrap.auto_detect')}</option>
{LANG_CODES.map((language) => (
<option key={language.code} value={language.code}>
{language.label} {language.code}
</option>
))}
</select>
</label>
<label className="dub-landing-opts__lang inline-flex items-center gap-[7px] min-w-0 text-[var(--chrome-fg-muted)]">
<Globe size={13} />
<span className="text-[0.72rem] font-medium whitespace-nowrap">
+35 -3
View File
@@ -69,6 +69,7 @@ export default function useDubWorkflow({
const setDubSegments = useAppStore((s) => s.setDubSegments);
const dubLang = useAppStore((s) => s.dubLang);
const dubLangCode = useAppStore((s) => s.dubLangCode);
const dubSourceLangCode = useAppStore((s) => s.dubSourceLangCode);
const dubInstruct = useAppStore((s) => s.dubInstruct);
const setDubFilename = useAppStore((s) => s.setDubFilename);
const setDubDuration = useAppStore((s) => s.setDubDuration);
@@ -546,7 +547,11 @@ export default function useDubWorkflow({
{ cancellable: true, homeMode: 'dub' },
);
try {
const data = await dubUpload(dubVideoFile, clientJobId, { signal: ctrl.signal, inputType });
const data = await dubUpload(dubVideoFile, clientJobId, {
signal: ctrl.signal,
inputType,
sourceLang: dubSourceLangCode,
});
setDubJobId(data.job_id);
if (data.filename) setDubFilename(data.filename);
setDubTaskId(data.task_id);
@@ -610,6 +615,7 @@ export default function useDubWorkflow({
loadProfiles,
_resetStaleDubSession,
_showMissingAsr,
dubSourceLangCode,
],
);
@@ -645,6 +651,7 @@ export default function useDubWorkflow({
fetchSubs: !!opts.fetchSubs,
subLangs: opts.subLangs,
cookieFile: opts.cookieFile,
sourceLang: dubSourceLangCode,
});
setDubJobId(data.job_id);
setDubTaskId(data.task_id);
@@ -713,6 +720,7 @@ export default function useDubWorkflow({
loadProfiles,
_resetStaleDubSession,
_showMissingAsr,
dubSourceLangCode,
],
);
@@ -848,11 +856,25 @@ export default function useDubWorkflow({
// that language rather than rendering a wrong-language track.
const handleTranslateAll = useCallback(
async (langOverride) => {
const options =
langOverride && typeof langOverride === 'object' && !('preventDefault' in langOverride)
? langOverride
: {};
const targetLang =
typeof langOverride === 'string' && langOverride ? langOverride : dubLangCode;
typeof langOverride === 'string' && langOverride
? langOverride
: options.langOverride || dubLangCode;
// Snapshot segments at call time: inside the multi-language loop the
// click-time closure is stale after the previous pick's translate pass.
const segs = useAppStore.getState().dubSegments;
const allSegments = useAppStore.getState().dubSegments;
const retryFailed = !!options.retryFailed;
const segs = retryFailed
? allSegments.filter(
(segment) =>
segment.translate_errors?.[targetLang] ||
(!segment.translate_errors && segment.translate_error),
)
: allSegments;
if (!segs.length || !targetLang) return false;
setIsTranslating(true);
// Root cause of the "sticky TRANSLATION FAILED banner": a new translate
@@ -876,6 +898,8 @@ export default function useDubWorkflow({
end: s.end != null ? s.end : undefined,
})),
target_lang: targetLang,
source_lang:
dubSourceLangCode && dubSourceLangCode !== 'auto' ? dubSourceLangCode : undefined,
provider: translateProvider,
quality: translateQuality,
// Lets the backend resolve the ASR-detected source language AND
@@ -914,6 +938,12 @@ export default function useDubWorkflow({
const hit = translatedMap[s.id];
if (!hit) return s;
const gotText = !!(hit.text && hit.text.trim());
const translateErrors = { ...s.translate_errors };
if (!s.translate_errors && s.translate_error) {
translateErrors[targetLang] = s.translate_error;
}
if (hit.error) translateErrors[targetLang] = hit.error;
else delete translateErrors[targetLang];
return {
...s,
text: gotText ? hit.text : s.text,
@@ -924,6 +954,7 @@ export default function useDubWorkflow({
...(gotText ? { translations: { ...s.translations, [targetLang]: hit.text } } : {}),
...(gotText ? { merge_parts: undefined } : {}),
translate_error: hit.error || undefined,
translate_errors: Object.keys(translateErrors).length ? translateErrors : undefined,
translate_degraded: hit.degraded || undefined,
translate_literal: hit.literal || undefined,
translate_critique: hit.critique || undefined,
@@ -1006,6 +1037,7 @@ export default function useDubWorkflow({
},
[
dubLangCode,
dubSourceLangCode,
dubDialect,
dubJobId,
translateProvider,
+3
View File
@@ -1083,6 +1083,9 @@
"pipeline": "مسار الدبلجة",
"preview_language": "لغة المعاينة",
"target_language": "الدبلجة إلى",
"source_language": "لغة المصدر",
"retry_failed": "إعادة محاولة {{count}} فاشلة",
"skip_failed": "تخطي الفاشلة",
"transcript_after_extract": "يظهر النص بعد الاستخراج.",
"qc_btn": "التحقق من توقيت الدبلجة (فحص المرور الثاني)",
"verify": "تحقق",
+3
View File
@@ -1081,6 +1081,9 @@
"pipeline": "Dubbing-Pipeline",
"preview_language": "Vorschausprache",
"target_language": "Dubben in",
"source_language": "Ausgangssprache",
"retry_failed": "{{count}} fehlgeschlagene erneut versuchen",
"skip_failed": "Fehlgeschlagene überspringen",
"transcript_after_extract": "Das Transkript erscheint nach der Extraktion.",
"qc_btn": "Überprüfen des Dub-Timings (Second-Pass-Check)",
"verify": "Prüfen",
+3
View File
@@ -1361,6 +1361,9 @@
"pipeline": "Dubbing pipeline",
"preview_language": "Preview language",
"target_language": "Dub into",
"source_language": "Spoken language",
"retry_failed": "Retry {{count}} failed",
"skip_failed": "Skip failed",
"transcript_after_extract": "Transcript appears after extraction.",
"paste_translation_btn": "Paste Translation",
"paste_translation_title": "Paste a translation",
+3
View File
@@ -1081,6 +1081,9 @@
"pipeline": "Flujo de doblaje",
"preview_language": "Idioma de vista previa",
"target_language": "Doblar a",
"source_language": "Idioma de origen",
"retry_failed": "Reintentar {{count}} fallidas",
"skip_failed": "Omitir fallidas",
"transcript_after_extract": "La transcripción aparecerá tras la extracción.",
"qc_btn": "Verificar el tiempo de doblaje (verificación de segundo paso)",
"verify": "Verificar",
+3
View File
@@ -1081,6 +1081,9 @@
"pipeline": "Pipeline de doublage",
"preview_language": "Langue d'aperçu",
"target_language": "Doubler en",
"source_language": "Langue source",
"retry_failed": "Réessayer {{count}} échecs",
"skip_failed": "Ignorer les échecs",
"transcript_after_extract": "La transcription apparaît après l'extraction.",
"qc_btn": "Vérifier le timing du doublage (vérification du deuxième passage)",
"verify": "Vérifier",
+3
View File
@@ -1081,6 +1081,9 @@
"pipeline": "डबिंग पाइपलाइन",
"preview_language": "पूर्वावलोकन भाषा",
"target_language": "इसमें डब करें",
"source_language": "स्रोत भाषा",
"retry_failed": "{{count}} विफल का पुनः प्रयास करें",
"skip_failed": "विफल को छोड़ें",
"transcript_after_extract": "निकालने के बाद प्रतिलिपि दिखाई देगी।",
"qc_btn": "डब टाइमिंग सत्यापित करें (सेकंड-पास चेक)",
"verify": "सत्यापित करें",
+3
View File
@@ -1083,6 +1083,9 @@
"pipeline": "Alur sulih suara",
"preview_language": "Bahasa pratinjau",
"target_language": "Sulih suara ke",
"source_language": "Bahasa sumber",
"retry_failed": "Coba lagi {{count}} yang gagal",
"skip_failed": "Lewati yang gagal",
"transcript_after_extract": "Transkrip muncul setelah ekstraksi.",
"qc_btn": "Verifikasi waktu sulih suara (pemeriksaan jalur kedua)",
"verify": "Verifikasi",
+3
View File
@@ -1081,6 +1081,9 @@
"pipeline": "Pipeline di doppiaggio",
"preview_language": "Lingua di anteprima",
"target_language": "Doppia in",
"source_language": "Lingua di origine",
"retry_failed": "Riprova {{count}} non riuscite",
"skip_failed": "Salta non riuscite",
"transcript_after_extract": "La trascrizione appare dopo l'estrazione.",
"qc_btn": "Verifica i tempi di duplicazione (controllo del secondo passaggio)",
"verify": "Verifica",
+3
View File
@@ -1083,6 +1083,9 @@
"pipeline": "ダビングパイプライン",
"preview_language": "プレビュー言語",
"target_language": "吹き替え先",
"source_language": "ソース言語",
"retry_failed": "失敗した{{count}}件を再試行",
"skip_failed": "失敗をスキップ",
"transcript_after_extract": "文字起こしは抽出後に表示されます。",
"qc_btn": "ダビングタイミングの検証(2パス目チェック)",
"verify": "検証",
+3
View File
@@ -1083,6 +1083,9 @@
"pipeline": "더빙 파이프라인",
"preview_language": "미리보기 언어",
"target_language": "더빙할 언어",
"source_language": "원본 언어",
"retry_failed": "실패한 {{count}}개 다시 시도",
"skip_failed": "실패 항목 건너뛰기",
"transcript_after_extract": "추출이 끝나면 대본이 표시됩니다.",
"qc_btn": "더빙 타이밍 확인(두 번째 통과 확인)",
"verify": "확인",
+3
View File
@@ -1081,6 +1081,9 @@
"pipeline": "Dubbing-pipeline",
"preview_language": "Voorbeeldtaal",
"target_language": "Dubben naar",
"source_language": "Brontaal",
"retry_failed": "{{count}} mislukte opnieuw proberen",
"skip_failed": "Mislukte overslaan",
"transcript_after_extract": "Het afschrift verschijnt na extractie.",
"qc_btn": "Controleer de dub-timing (tweede-doorgangscontrole)",
"verify": "Controleren",
+3
View File
@@ -1081,6 +1081,9 @@
"pipeline": "Proces dubbingu",
"preview_language": "Język podglądu",
"target_language": "Dubbinguj na",
"source_language": "Język źródłowy",
"retry_failed": "Ponów {{count}} nieudanych",
"skip_failed": "Pomiń nieudane",
"transcript_after_extract": "Transkrypcja pojawi się po ekstrakcji.",
"qc_btn": "Sprawdź czas dubowania (kontrola drugiego przejścia)",
"verify": "Sprawdź",
+3
View File
@@ -1081,6 +1081,9 @@
"pipeline": "Pipeline de dublagem",
"preview_language": "Idioma da visualização",
"target_language": "Dublar para",
"source_language": "Idioma de origem",
"retry_failed": "Tentar novamente {{count}} falhas",
"skip_failed": "Ignorar falhas",
"transcript_after_extract": "A transcrição aparece após a extração.",
"qc_btn": "Verifique o tempo de dublagem (verificação de segunda passagem)",
"verify": "Verificar",
+3
View File
@@ -1081,6 +1081,9 @@
"pipeline": "Конвейер дубляжа",
"preview_language": "Язык предпросмотра",
"target_language": "Дублировать на",
"source_language": "Исходный язык",
"retry_failed": "Повторить {{count}} неудачных",
"skip_failed": "Пропустить неудачные",
"transcript_after_extract": "Стенограмма появится после извлечения.",
"qc_btn": "Проверка синхронизации перезаписи (вторая проверка)",
"verify": "Проверить",
+3
View File
@@ -1083,6 +1083,9 @@
"pipeline": "Dubbningspipeline",
"preview_language": "Förhandsgranskningsspråk",
"target_language": "Dubba till",
"source_language": "Källspråk",
"retry_failed": "Försök igen med {{count}} misslyckade",
"skip_failed": "Hoppa över misslyckade",
"transcript_after_extract": "Avskriften visas efter extraktionen.",
"qc_btn": "Verifiera dubbningstid (second-pass check)",
"verify": "Verifiera",
+3
View File
@@ -1083,6 +1083,9 @@
"pipeline": "ไปป์ไลน์การพากย์",
"preview_language": "ภาษาตัวอย่าง",
"target_language": "พากย์เป็น",
"source_language": "ภาษาต้นฉบับ",
"retry_failed": "ลองใหม่ {{count}} รายการที่ล้มเหลว",
"skip_failed": "ข้ามรายการที่ล้มเหลว",
"transcript_after_extract": "ทรานสคริปต์จะปรากฏหลังการแยกเสียง",
"qc_btn": "ตรวจสอบระยะเวลาการพากย์ (ตรวจสอบรอบที่สอง)",
"verify": "ตรวจสอบ",
+3
View File
@@ -1083,6 +1083,9 @@
"pipeline": "Dublaj hattı",
"preview_language": "Önizleme dili",
"target_language": "Dublaj dili",
"source_language": "Kaynak dil",
"retry_failed": "Başarısız {{count}} öğeyi yeniden dene",
"skip_failed": "Başarısızları atla",
"transcript_after_extract": "Transkript, çıkarma işleminden sonra görünür.",
"qc_btn": "Dub zamanlamasını doğrulayın (ikinci geçiş kontrolü)",
"verify": "Doğrula",
+3
View File
@@ -1083,6 +1083,9 @@
"pipeline": "Конвеєр дубляжу",
"preview_language": "Мова попереднього перегляду",
"target_language": "Дублювати мовою",
"source_language": "Мова оригіналу",
"retry_failed": "Повторити {{count}} невдалих",
"skip_failed": "Пропустити невдалі",
"transcript_after_extract": "Стенограма з’явиться після вилучення.",
"qc_btn": "Перевірте синхронізацію дубляжу (перевірка другого проходу)",
"verify": "Перевірити",
+3
View File
@@ -1083,6 +1083,9 @@
"pipeline": "Quy trình lồng tiếng",
"preview_language": "Ngôn ngữ xem trước",
"target_language": "Lồng tiếng sang",
"source_language": "Ngôn ngữ nguồn",
"retry_failed": "Thử lại {{count}} mục lỗi",
"skip_failed": "Bỏ qua mục lỗi",
"transcript_after_extract": "Bản chép lời sẽ xuất hiện sau khi trích xuất.",
"qc_btn": "Xác minh thời gian lồng tiếng (kiểm tra lần thứ hai)",
"verify": "Xác minh",
+3
View File
@@ -1042,6 +1042,9 @@
"pipeline": "配音流程",
"preview_language": "预览语言",
"target_language": "配音为",
"source_language": "源语言",
"retry_failed": "重试 {{count}} 个失败项",
"skip_failed": "跳过失败项",
"transcript_after_extract": "提取完成后将显示转录文本。",
"qc_btn": "验证配音时序(第二遍检查)",
"verify": "验证",
+3
View File
@@ -1083,6 +1083,9 @@
"pipeline": "配音流程",
"preview_language": "預覽語言",
"target_language": "配音成",
"source_language": "來源語言",
"retry_failed": "重試 {{count}} 個失敗項目",
"skip_failed": "跳過失敗項目",
"transcript_after_extract": "擷取完成後將顯示逐字稿。",
"qc_btn": "驗證配音時序(第二遍檢視)",
"verify": "驗證",
+28 -21
View File
@@ -95,6 +95,8 @@ export default function DubTab(props) {
const dubLang = useAppStore((s) => s.dubLang);
const setDubLang = useAppStore((s) => s.setDubLang);
const dubLangCode = useAppStore((s) => s.dubLangCode);
const dubSourceLangCode = useAppStore((s) => s.dubSourceLangCode);
const setDubSourceLangCode = useAppStore((s) => s.setDubSourceLangCode);
// User-driven language switches go through switchDubLangCode (P1.2): it
// swaps segment text through the per-language `translations` map instead
// of leaving the previous language's text on screen (and previously,
@@ -320,28 +322,31 @@ export default function DubTab(props) {
// while still restoring the primary target in the editor afterwards. Each
// translation lands in segments[].translations[code], so Generate can
// reuse the complete maps without retranslating or losing another language.
const onTranslateClick = useCallback(async () => {
if (!multiLangMode) return handleTranslateAll();
if (multiBatchRunningRef.current) return false;
multiBatchRunningRef.current = true;
setMultiBatchBusy(true);
const { lang: primaryLanguage, code: primaryCode } = primaryTargetRef.current;
let allOk = true;
try {
for (const target of batchTargets) {
setDubLang(target.lang);
switchDubLangCode(target.code);
const ok = await handleTranslateAll(target.code);
if (!ok) allOk = false;
const onTranslateClick = useCallback(
async (options = {}) => {
if (!multiLangMode) return handleTranslateAll(options);
if (multiBatchRunningRef.current) return false;
multiBatchRunningRef.current = true;
setMultiBatchBusy(true);
const { lang: primaryLanguage, code: primaryCode } = primaryTargetRef.current;
let allOk = true;
try {
for (const target of batchTargets) {
setDubLang(target.lang);
switchDubLangCode(target.code);
const ok = await handleTranslateAll({ ...options, langOverride: target.code });
if (!ok) allOk = false;
}
} finally {
setDubLang(primaryLanguage);
switchDubLangCode(primaryCode);
multiBatchRunningRef.current = false;
setMultiBatchBusy(false);
}
} finally {
setDubLang(primaryLanguage);
switchDubLangCode(primaryCode);
multiBatchRunningRef.current = false;
setMultiBatchBusy(false);
}
return allOk;
}, [multiLangMode, batchTargets, handleTranslateAll, setDubLang, switchDubLangCode]);
return allOk;
},
[multiLangMode, batchTargets, handleTranslateAll, setDubLang, switchDubLangCode],
);
// Live ETA while generating elapsed ticks each second; remaining is
// extrapolated from the current/total rate so it's only meaningful once
@@ -682,6 +687,8 @@ export default function DubTab(props) {
youtubeCookieFile={youtubeCookieFile}
setYoutubeCookieFile={setYoutubeCookieFile}
dubLangCode={dubLangCode}
dubSourceLangCode={dubSourceLangCode}
setDubSourceLangCode={setDubSourceLangCode}
setDubLangCode={switchDubLangCode}
setDubLang={setDubLang}
landingAdvOpen={landingAdvOpen}
+5
View File
@@ -99,6 +99,7 @@ export interface DubSlice {
// ── Language / translate ──────────────────────────────────────────────
dubLang: string;
dubLangCode: string;
dubSourceLangCode: string;
// Optional speaker-count hint passed to pyannote diarization (#274). null =
// let pyannote auto-detect; a positive int forces that many speakers when
@@ -169,6 +170,7 @@ export interface DubSlice {
bumpDubGenNonce: () => void;
setDubLang: (v: Updater<string>) => void;
setDubLangCode: (v: Updater<string>) => void;
setDubSourceLangCode: (v: Updater<string>) => void;
/**
* User-driven target-language switch (P1.2). Unlike the plain setter it
* also remaps segment text through the per-language `translations` store:
@@ -216,6 +218,7 @@ const INITIAL: Omit<
| 'bumpDubGenNonce'
| 'setDubLang'
| 'setDubLangCode'
| 'setDubSourceLangCode'
| 'switchDubLangCode'
| 'setDubNumSpeakers'
| 'setDubDialect'
@@ -250,6 +253,7 @@ const INITIAL: Omit<
dubGenNonce: 0,
dubLang: 'Auto',
dubLangCode: 'en',
dubSourceLangCode: 'auto',
dubNumSpeakers: null,
dubDialect: '',
multiLangMode: false,
@@ -288,6 +292,7 @@ export const createDubSlice: StateCreator<DubSlice, [], [], DubSlice> = (set, ge
bumpDubGenNonce: () => set(() => ({ dubGenNonce: Date.now() })),
setDubLang: (v) => set((s) => ({ dubLang: resolve(v, s.dubLang) })),
setDubLangCode: (v) => set((s) => ({ dubLangCode: resolve(v, s.dubLangCode) })),
setDubSourceLangCode: (v) => set((s) => ({ dubSourceLangCode: resolve(v, s.dubSourceLangCode) })),
switchDubLangCode: (code) =>
set((s) => {
const prev = s.dubLangCode;
@@ -117,6 +117,40 @@ describe('Dubbing missing-ASR recovery', () => {
expect(useAppStore.getState().dubJobId).not.toBe('job-kept-for-retry');
});
it('keeps auto detection selected across two consecutive jobs', async () => {
let uploadNumber = 0;
dubApi.dubUpload.mockImplementation(async () => {
uploadNumber += 1;
return { job_id: `job-${uploadNumber}`, task_id: `prep-${uploadNumber}` };
});
const { result } = renderWorkflow();
const runUpload = async (detectedLanguage) => {
const streamStart = streams.length;
let upload;
act(() => {
upload = result.current.handleDubUpload(
new File(['video'], `job-${uploadNumber + 1}.mp4`, { type: 'video/mp4' }),
);
});
await waitFor(() => expect(streams).toHaveLength(streamStart + 1));
streams[streamStart].emit('message', { type: 'ready' });
await waitFor(() => expect(streams).toHaveLength(streamStart + 2));
streams[streamStart + 1].emit('final', {
segments: [{ id: '1', text: 'hello' }],
source_lang: detectedLanguage,
});
streams[streamStart + 1].emit('done');
await act(async () => upload);
};
await runUpload('es');
await runUpload('de');
expect(dubApi.dubUpload.mock.calls.map((call) => call[2].sourceLang)).toEqual(['auto', 'auto']);
expect(useAppStore.getState().dubSourceLangCode).toBe('auto');
});
it('keeps the job, installs inline, then automatically retranscribes it', async () => {
const { result } = renderWorkflow();
let firstAttempt;
@@ -14,14 +14,19 @@ import { useAppStore } from '../store';
// and on a failed translate: skip that pick's generate, keep going, report
// the skipped languages in a final toast.
const captured = vi.hoisted(() => ({ header: [] }));
const captured = vi.hoisted(() => ({ header: [], left: [] }));
vi.mock('../components/dub/DubHeader', () => ({
default: (props) => {
captured.header.push(props);
return null;
},
}));
vi.mock('../components/dub/DubLeftColumn', () => ({ default: () => null }));
vi.mock('../components/dub/DubLeftColumn', () => ({
default: (props) => {
captured.left.push(props);
return null;
},
}));
vi.mock('../components/dub/DubRightColumn', () => ({ default: () => null }));
vi.mock('../components/dub/DubFooter', () => ({ default: () => null }));
vi.mock('../components/dub/DubPipelineStepper', () => ({ default: () => null }));
@@ -101,6 +106,7 @@ const PICKS = [
{ lang: 'French', code: 'fr' },
{ lang: 'German', code: 'de' },
];
const EXPECTED_CODES = ['bn', ...PICKS.map((pick) => pick.code)];
/** Render DubTab in multi-lang mode and return { onGenerateClick, calls, mocks }. */
function setup({
@@ -110,7 +116,8 @@ function setup({
segments,
} = {}) {
const calls = [];
const handleTranslateAll = vi.fn(async (code) => {
const handleTranslateAll = vi.fn(async (arg) => {
const code = typeof arg === 'string' ? arg : arg?.langOverride;
calls.push(`translate:${code}`);
const ok = translateOk(code);
if (ok) {
@@ -155,6 +162,19 @@ describe('DubTab — multi-language generate translates each language first (P1.
beforeEach(() => {
useAppStore.setState(baseState, true);
captured.header.length = 0;
captured.left.length = 0;
});
it('forwards retry-only options through every language in the wrapper', async () => {
const { handleTranslateAll } = setup();
await act(async () => {
await captured.left.at(-1).handleTranslateAll({ retryFailed: true });
});
expect(handleTranslateAll.mock.calls.map(([options]) => options)).toEqual(
EXPECTED_CODES.map((langOverride) => ({ retryFailed: true, langOverride })),
);
});
afterEach(() => {
vi.restoreAllMocks();
+10 -2
View File
@@ -97,11 +97,11 @@ describe('project payload — save/load round-trip (restoreProjectExtras)', () =
});
describe('App.jsx wiring guard (raw source — keeps the util honest)', () => {
it('saveProject persists the three fields in statePayload.state', () => {
it('saveProject persists multi-language and source-language fields', () => {
const start = appSrc.indexOf('const statePayload');
expect(start).toBeGreaterThan(-1);
const block = appSrc.slice(start, appSrc.indexOf('apiSaveProject', start));
for (const key of ['multiLangMode', 'multiLangs', 'exportTracks']) {
for (const key of ['multiLangMode', 'multiLangs', 'exportTracks', 'dubSourceLangCode']) {
expect(block, `statePayload.state must include ${key}`).toContain(key);
}
});
@@ -113,5 +113,13 @@ describe('App.jsx wiring guard (raw source — keeps the util honest)', () => {
expect(block).toContain('restoreProjectExtras');
expect(block).toContain('setMultiLangMode');
expect(block).toContain('setMultiLangs');
expect(block).toContain("setDubSourceLangCode(s.dubSourceLangCode || 'auto')");
});
it('resetDub clears an auto-detected language before the next upload', () => {
const start = appSrc.indexOf('const resetDub');
expect(start).toBeGreaterThan(-1);
const block = appSrc.slice(start, appSrc.indexOf('// ═══ STUDIO PROJECT CRUD', start));
expect(block).toContain("setDubSourceLangCode('auto')");
});
});
@@ -104,6 +104,119 @@ describe('handleTranslateAll(langOverride) — multi-language target override',
expect(dubApi.dubTranslate.mock.calls[0][0].target_lang).toBe('es');
});
it('sends the explicitly selected spoken language', async () => {
useAppStore.setState({ dubSourceLangCode: 'fr' });
dubApi.dubTranslate.mockResolvedValue({
translated: [{ id: '1', text: 'hola' }],
target_lang: 'es',
});
const { result } = renderWorkflow();
await act(async () => {
await result.current.handleTranslateAll();
});
expect(dubApi.dubTranslate.mock.calls[0][0].source_lang).toBe('fr');
});
it('retries only segments carrying a translation error', async () => {
useAppStore.setState({
dubSegments: [
{ id: '1', text: 'ok', text_original: 'ok', start: 0, end: 1 },
{
id: '2',
text: 'failed',
text_original: 'failed',
start: 1,
end: 2,
translate_error: 'offline',
},
],
});
dubApi.dubTranslate.mockResolvedValue({
translated: [{ id: '2', text: 'recuperado' }],
target_lang: 'es',
});
const { result } = renderWorkflow();
await act(async () => {
await result.current.handleTranslateAll({ retryFailed: true });
});
expect(dubApi.dubTranslate.mock.calls[0][0].segments.map((segment) => segment.id)).toEqual([
'2',
]);
expect(useAppStore.getState().dubSegments[1].translate_error).toBeUndefined();
});
it('retries and clears failures by target language', async () => {
useAppStore.setState({
dubSegments: [
{
id: '1',
text: 'failed twice',
text_original: 'failed twice',
start: 0,
end: 1,
},
{
id: '2',
text: 'failed in French',
text_original: 'failed in French',
start: 1,
end: 2,
},
],
});
dubApi.dubTranslate
.mockResolvedValueOnce({
translated: [
{ id: '1', text: '', error: 'es offline' },
{ id: '2', text: 'es:2' },
],
target_lang: 'es',
})
.mockResolvedValueOnce({
translated: [
{ id: '1', text: '', error: 'fr offline' },
{ id: '2', text: '', error: 'fr offline' },
],
target_lang: 'fr',
});
const { result } = renderWorkflow();
await act(async () => {
await result.current.handleTranslateAll('es');
await result.current.handleTranslateAll('fr');
});
expect(useAppStore.getState().dubSegments.map((segment) => segment.translate_errors)).toEqual([
{ es: 'es offline', fr: 'fr offline' },
{ fr: 'fr offline' },
]);
dubApi.dubTranslate.mockImplementation(async ({ segments, target_lang: targetLang }) => ({
translated: segments.map(({ id }) => ({ id, text: `${targetLang}:${id}` })),
target_lang: targetLang,
}));
await act(async () => {
await result.current.handleTranslateAll({ retryFailed: true, langOverride: 'es' });
});
expect(dubApi.dubTranslate.mock.calls[2][0].segments.map(({ id }) => id)).toEqual(['1']);
expect(useAppStore.getState().dubSegments[0].translate_errors).toEqual({
fr: 'fr offline',
});
await act(async () => {
await result.current.handleTranslateAll({ retryFailed: true, langOverride: 'fr' });
});
expect(dubApi.dubTranslate.mock.calls[3][0].segments.map(({ id }) => id)).toEqual(['1', '2']);
expect(useAppStore.getState().dubSegments.map((segment) => segment.translate_errors)).toEqual([
undefined,
undefined,
]);
});
it('request failure resolves false and surfaces the existing error banner', async () => {
dubApi.dubTranslate.mockRejectedValue(new Error('engine down'));
const { result } = renderWorkflow();
+177
View File
@@ -0,0 +1,177 @@
"""Browser-safe media normalization for local dubbing uploads (#1643/#1644)."""
from __future__ import annotations
import asyncio
from types import SimpleNamespace
import pytest
from services import dub_pipeline as dp
def _run_local_ingest(tmp_path, monkeypatch, *, input_type="video"):
source_path = tmp_path / ("source.wav" if input_type == "audio" else "source.mp4")
source_path.write_bytes(b"source")
normalized_path = tmp_path / "source.browser.mp4"
normalized_path.write_bytes(b"normalized")
normalized = []
saved = []
async def ensure(job_id, path):
assert job_id == "browser_media"
normalized.append(path)
return str(normalized_path)
def factory(_job_id):
async def run_proc(cmd, **_kwargs):
output = next((str(arg) for arg in cmd if str(arg).endswith(".wav")), None)
if output:
with open(output, "wb") as handle:
handle.write(b"RIFF")
return SimpleNamespace(returncode=0), b"", b""
return run_proc
monkeypatch.setattr(dp, "_ensure_browser_playable_mp4_for_job", ensure)
monkeypatch.setattr(dp, "run_proc_factory", factory)
monkeypatch.setattr(dp.sf, "info", lambda _path: SimpleNamespace(frames=16000, samplerate=16000))
monkeypatch.setattr(dp, "compute_file_hash", lambda _path: "content-hash")
monkeypatch.setattr(
dp,
"find_cached_job",
lambda *_args: {
"job_id": "cached",
"vocals_path": None,
"no_vocals_path": None,
"thumb_path": None,
"scene_cuts": [],
},
)
monkeypatch.setattr(
dp,
"put_and_save_job",
lambda _job_id, job, **_kwargs: saved.append(job.copy()) or True,
)
async def drain():
return [
event
async for event in dp.ingest_pipeline(
"browser_media",
str(tmp_path),
{"kind": "file", "path": str(source_path), "input_type": input_type},
)
]
asyncio.run(drain())
return source_path, normalized_path, normalized, saved
def test_local_video_is_normalized_before_job_is_persisted(tmp_path, monkeypatch):
source, normalized_path, normalized, saved = _run_local_ingest(tmp_path, monkeypatch)
assert normalized == [str(source)]
assert saved[-1]["video_path"] == str(normalized_path)
def test_audio_only_ingest_does_not_attempt_video_transcode(tmp_path, monkeypatch):
source, _normalized_path, normalized, saved = _run_local_ingest(
tmp_path, monkeypatch, input_type="audio"
)
assert normalized == []
assert saved[-1]["video_path"] == str(source)
def test_upload_normalization_propagates_cancellation_to_registered_process(tmp_path, monkeypatch):
source = tmp_path / "source.mp4"
source.write_bytes(b"source")
started = asyncio.Event()
cleaned = asyncio.Event()
seen = {}
monkeypatch.setattr(dp, "_probe_codecs", lambda _path: ("vp9", "opus"))
monkeypatch.setattr(dp, "find_ffmpeg", lambda: "ffmpeg")
def factory(job_id):
seen["job_id"] = job_id
async def run_proc(_cmd, *, timeout):
seen["timeout"] = timeout
started.set()
try:
await asyncio.Event().wait()
finally:
cleaned.set()
return run_proc
monkeypatch.setattr(dp, "run_proc_factory", factory)
async def cancel_normalization():
task = asyncio.create_task(
dp._ensure_browser_playable_mp4_for_job("cancel-job", str(source))
)
await asyncio.wait_for(started.wait(), timeout=1)
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
else:
raise AssertionError("normalization did not propagate cancellation")
await asyncio.wait_for(cleaned.wait(), timeout=1)
asyncio.run(cancel_normalization())
assert seen == {"job_id": "cancel-job", "timeout": 1800.0}
@pytest.mark.parametrize("failure", [RuntimeError("spawn failed"), asyncio.TimeoutError()])
def test_upload_normalization_failure_keeps_the_original_media(tmp_path, monkeypatch, failure):
source = tmp_path / "source.mp4"
source.write_bytes(b"source")
monkeypatch.setattr(dp, "_probe_codecs", lambda _path: ("vp9", "opus"))
monkeypatch.setattr(dp, "find_ffmpeg", lambda: "ffmpeg")
def factory(_job_id):
async def run_proc(_cmd, *, timeout):
assert timeout == 1800.0
raise failure
return run_proc
monkeypatch.setattr(dp, "run_proc_factory", factory)
result = asyncio.run(dp._ensure_browser_playable_mp4_for_job("failed-job", str(source)))
assert result == str(source)
assert source.exists()
def test_successful_remux_with_unsupported_codecs_is_transcoded(tmp_path, monkeypatch):
source = tmp_path / "source.webm"
source.write_bytes(b"source")
target = tmp_path / "source.mp4"
commands = []
monkeypatch.setattr(dp, "_probe_codecs", lambda _path: ("vp9", "opus"))
monkeypatch.setattr(dp, "find_ffmpeg", lambda: "ffmpeg")
def factory(_job_id):
async def run_proc(cmd, *, timeout):
assert timeout == 1800.0
commands.append(cmd)
target.write_bytes(b"normalized")
return SimpleNamespace(returncode=0), b"", b""
return run_proc
monkeypatch.setattr(dp, "run_proc_factory", factory)
result = asyncio.run(dp._ensure_browser_playable_mp4_for_job("codec-job", str(source)))
assert result == str(target)
assert [cmd[cmd.index("-c:v") + 1] for cmd in commands] == ["copy", "libx264"]
assert [cmd[cmd.index("-c:a") + 1] for cmd in commands] == ["copy", "aac"]
assert not source.exists()
@@ -0,0 +1,64 @@
from services.segmentation import clean_up_segments
def test_cleanup_preserves_editor_metadata_and_combines_translations():
segments = [
{
"id": "a",
"start": 0.0,
"end": 2.0,
"text": "Hola, gran mundo.",
"text_original": "Hello, big world.",
"speaker_id": "Speaker 1",
"profile_id": "voice-a",
"translations": {"es": "Hola, gran mundo.", "fr": "Bonjour le monde."},
},
{
"id": "b",
"start": 2.1,
"end": 2.4,
"text": "Otra vez.",
"text_original": "Again.",
"speaker_id": "Speaker 1",
"profile_id": "voice-b",
"translations": {"es": "Otra vez.", "fr": "Encore."},
"translate_error": "retry me",
},
]
cleaned = clean_up_segments(segments)
assert len(cleaned) == 1
assert cleaned[0]["text"] == "Hola, gran mundo. Otra vez."
assert cleaned[0]["text_original"] == "Hello, big world. Again."
assert cleaned[0]["translations"] == {
"es": "Hola, gran mundo. Otra vez.",
"fr": "Bonjour le monde. Encore.",
}
assert cleaned[0]["profile_id"] == "voice-a"
assert cleaned[0]["translate_error"] == "retry me"
def test_cleanup_ignores_legacy_non_mapping_translations():
segments = [
{
"id": "a",
"start": 0.0,
"end": 2.0,
"text": "Hello.",
"speaker_id": "Speaker 1",
"translations": "legacy-corrupt-value",
},
{
"id": "b",
"start": 2.1,
"end": 2.4,
"text": "Again.",
"speaker_id": "Speaker 1",
"translations": {"es": "Otra vez."},
},
]
cleaned = clean_up_segments(segments)
assert cleaned[0]["translations"] == {"es": "Otra vez."}
+35
View File
@@ -292,3 +292,38 @@ class TestAudioOnlyDubbing:
)
assert r.status_code == 400
assert "audio file" in r.json()["detail"].lower()
def test_ingest_requests_reject_unregistered_source_languages(self, app_client):
client, _dc, _dx, _tmp = app_client
form = client.post(
"/dub/upload",
files={"video": ("clip.mp4", b"video", "video/mp4")},
data={"source_lang": "english"},
)
json_response = client.post(
"/dub/ingest-url",
json={"url": "https://example.com/video", "source_lang": "x-123"},
)
assert form.status_code == 400
assert json_response.status_code == 400
assert form.json()["detail"] == "Invalid source language code"
assert json_response.json()["detail"] == "Invalid source language code"
def test_upload_accepts_a_registered_source_language(self, app_client, monkeypatch):
client, dc, _dx, _tmp = app_client
queued = []
async def add_task(*args):
queued.append(args)
monkeypatch.setattr(dc.task_manager, "add_task", add_task)
response = client.post(
"/dub/upload",
files={"video": ("clip.mp4", b"video", "video/mp4")},
data={"source_lang": "FR"},
)
assert response.status_code == 202
assert queued[0][5]["source_lang"] == "fr"
+191
View File
@@ -0,0 +1,191 @@
from __future__ import annotations
import asyncio
import io
from fastapi import UploadFile
def test_srt_cues_inherit_best_overlap_voice_metadata():
from api.routers.dub_core import _carry_srt_voice_metadata
existing = [
{
"id": "left",
"start": 0.0,
"end": 2.0,
"text": "old one",
"speaker_id": "Speaker 1",
"profile_id": "auto:speaker_1",
"speed": 1.1,
"translations": {"fr": "stale"},
},
{
"id": "right",
"start": 2.0,
"end": 5.0,
"text": "old two",
"speaker_id": "Speaker 2",
"profile_id": "auto-seg:right",
"effect_preset": "radio",
},
]
cues = [
{"id": 0, "start": 0.2, "end": 1.8, "text": "new one", "speaker_id": "Speaker 1"},
{"id": 1, "start": 1.8, "end": 4.8, "text": "new two", "speaker_id": "Speaker 1"},
]
clone = {"ref_audio": "/job/right.wav", "duration": 2.8}
merged, clones = _carry_srt_voice_metadata(
cues,
existing,
{"right": clone},
)
assert merged[0]["speaker_id"] == "Speaker 1"
assert merged[0]["profile_id"] == "auto:speaker_1"
assert merged[0]["speed"] == 1.1
assert "translations" not in merged[0]
assert merged[1]["speaker_id"] == "Speaker 2"
assert merged[1]["profile_id"] == "auto-seg:1"
assert merged[1]["effect_preset"] == "radio"
assert merged[1]["text_original"] == "new two"
assert clones["1"] is clone
def test_unmatched_replacement_cue_does_not_inherit_colliding_old_clone():
from api.routers.dub_core import _carry_srt_voice_metadata
clone = {"ref_audio": "/job/unrelated.wav", "duration": 1.0}
merged, clones = _carry_srt_voice_metadata(
[{"id": 0, "start": 10.0, "end": 11.0, "text": "new"}],
[{"id": "0", "start": 0.0, "end": 1.0, "text": "removed"}],
{"0": clone},
)
assert merged[0]["speaker_id"] == "Speaker 1"
assert clones == {}
def test_import_srt_rekeys_clone_refs_and_rebuilds_cast(monkeypatch):
from api.routers import dub_core
job_id = "srt-cast"
clone = {
"ref_audio": "/job/speaker-two.wav",
"ref_text": "source",
"duration": 3.0,
}
job = {
"duration": 6.0,
"segments": [
{
"id": "old-1",
"start": 0.0,
"end": 2.0,
"text": "old one",
"speaker_id": "Speaker 1",
"profile_id": "auto:speaker_1",
},
{
"id": "old-2",
"start": 2.0,
"end": 5.0,
"text": "old two",
"speaker_id": "Speaker 2",
"profile_id": "auto:speaker_2",
},
],
"segment_clones": {"old-2": clone},
}
dub_core._dub_jobs[job_id] = job
monkeypatch.setattr(dub_core, "_save_job", lambda *_args: None)
upload = UploadFile(
filename="replacement.srt",
file=io.BytesIO(
b"1\n00:00:00,000 --> 00:00:02,000\nFirst line\n\n"
b"2\n00:00:02,000 --> 00:00:05,000\nSecond line\n"
),
)
try:
result = asyncio.run(dub_core.dub_import_srt(job_id, upload))
finally:
dub_core._dub_jobs.pop(job_id, None)
assert [segment["speaker_id"] for segment in result["segments"]] == [
"Speaker 1",
"Speaker 2",
]
assert [segment["profile_id"] for segment in result["segments"]] == [
"auto:speaker_1",
"auto:speaker_2",
]
assert job["segment_clones"]["1"] is clone
assert job["cast_sources"]["Speaker 2"]["kind"] == "segment"
def test_import_srt_clears_stale_clone_and_cast_maps(monkeypatch):
from api.routers import dub_core
job_id = "srt-clear-cast"
job = {
"duration": 20.0,
"segments": [{"id": "0", "start": 0.0, "end": 1.0, "text": "old"}],
"segment_clones": {"0": {"ref_audio": "/job/unrelated.wav"}},
"speaker_clones": {"Speaker 1": {"ref_audio": "/job/stale-speaker.wav"}},
"cast_sources": {"Speaker 1": {"kind": "segment", "ref_audio": "/job/unrelated.wav"}},
}
dub_core._dub_jobs[job_id] = job
monkeypatch.setattr(dub_core, "_save_job", lambda *_args: None)
upload = UploadFile(
filename="replacement.srt",
file=io.BytesIO(b"1\n00:00:10,000 --> 00:00:11,000\nNew line\n"),
)
try:
asyncio.run(dub_core.dub_import_srt(job_id, upload))
finally:
dub_core._dub_jobs.pop(job_id, None)
assert job["segment_clones"] == {}
assert job["speaker_clones"] == {}
assert "cast_sources" not in job
def test_import_srt_scopes_matched_speaker_clone_to_the_matched_cue(monkeypatch):
from api.routers import dub_core
job_id = "srt-scope-speaker-clone"
speaker_clone = {"ref_audio": "/job/speaker-one.wav", "duration": 2.0}
job = {
"duration": 20.0,
"segments": [
{
"id": "old",
"start": 0.0,
"end": 2.0,
"text": "old",
"speaker_id": "Speaker 1",
"profile_id": "auto:speaker_1",
}
],
"speaker_clones": {"Speaker 1": speaker_clone},
"cast_sources": {"Speaker 1": {"kind": "speaker"}},
}
dub_core._dub_jobs[job_id] = job
monkeypatch.setattr(dub_core, "_save_job", lambda *_args: None)
upload = UploadFile(
filename="replacement.srt",
file=io.BytesIO(
b"1\n00:00:00,000 --> 00:00:02,000\nMatched\n\n"
b"2\n00:00:10,000 --> 00:00:11,000\nUnmatched\n"
),
)
try:
asyncio.run(dub_core.dub_import_srt(job_id, upload))
finally:
dub_core._dub_jobs.pop(job_id, None)
assert job["speaker_clones"] == {}
assert job["segment_clones"] == {"0": speaker_clone}
assert "1" not in job["segment_clones"]
assert job["cast_sources"]["Speaker 1"]["kind"] == "segment"
+16
View File
@@ -763,6 +763,22 @@ class TestTranscribeRoute:
# In-memory job was updated.
assert dc._dub_jobs[job_id]["source_lang"] == "es"
def test_selected_source_lang_overrides_detection(self, app_client):
client, dc, tmp = app_client
job_id = _seed_job(dc, tmp, duration=18.0)
dc._dub_jobs[job_id]["source_lang_override"] = "fr"
fixture = _load_fixture("whisper_screenshot.json")
fixture["language"] = "es_ES"
with patch("mlx_whisper.transcribe", return_value=fixture), patch(
"torch.backends.mps.is_available", return_value=True
):
res = client.post(f"/dub/transcribe/{job_id}")
assert res.status_code == 200
assert res.json()["source_lang"] == "fr"
assert dc._dub_jobs[job_id]["source_lang"] == "fr"
def test_scene_cuts_applied_when_viable(self, app_client):
client, dc, tmp = app_client
job_id = _seed_job(dc, tmp, duration=14.0, scene_cuts=[5.5])