Merge pull request #1672 from debpalash/fix/dub-srt-voices-1660
fix(dub): preserve workflow state and browser compatibility
This commit is contained in:
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
@@ -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
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1083,6 +1083,9 @@
|
||||
"pipeline": "مسار الدبلجة",
|
||||
"preview_language": "لغة المعاينة",
|
||||
"target_language": "الدبلجة إلى",
|
||||
"source_language": "لغة المصدر",
|
||||
"retry_failed": "إعادة محاولة {{count}} فاشلة",
|
||||
"skip_failed": "تخطي الفاشلة",
|
||||
"transcript_after_extract": "يظهر النص بعد الاستخراج.",
|
||||
"qc_btn": "التحقق من توقيت الدبلجة (فحص المرور الثاني)",
|
||||
"verify": "تحقق",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1081,6 +1081,9 @@
|
||||
"pipeline": "डबिंग पाइपलाइन",
|
||||
"preview_language": "पूर्वावलोकन भाषा",
|
||||
"target_language": "इसमें डब करें",
|
||||
"source_language": "स्रोत भाषा",
|
||||
"retry_failed": "{{count}} विफल का पुनः प्रयास करें",
|
||||
"skip_failed": "विफल को छोड़ें",
|
||||
"transcript_after_extract": "निकालने के बाद प्रतिलिपि दिखाई देगी।",
|
||||
"qc_btn": "डब टाइमिंग सत्यापित करें (सेकंड-पास चेक)",
|
||||
"verify": "सत्यापित करें",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1083,6 +1083,9 @@
|
||||
"pipeline": "ダビングパイプライン",
|
||||
"preview_language": "プレビュー言語",
|
||||
"target_language": "吹き替え先",
|
||||
"source_language": "ソース言語",
|
||||
"retry_failed": "失敗した{{count}}件を再試行",
|
||||
"skip_failed": "失敗をスキップ",
|
||||
"transcript_after_extract": "文字起こしは抽出後に表示されます。",
|
||||
"qc_btn": "ダビングタイミングの検証(2パス目チェック)",
|
||||
"verify": "検証",
|
||||
|
||||
@@ -1083,6 +1083,9 @@
|
||||
"pipeline": "더빙 파이프라인",
|
||||
"preview_language": "미리보기 언어",
|
||||
"target_language": "더빙할 언어",
|
||||
"source_language": "원본 언어",
|
||||
"retry_failed": "실패한 {{count}}개 다시 시도",
|
||||
"skip_failed": "실패 항목 건너뛰기",
|
||||
"transcript_after_extract": "추출이 끝나면 대본이 표시됩니다.",
|
||||
"qc_btn": "더빙 타이밍 확인(두 번째 통과 확인)",
|
||||
"verify": "확인",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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ź",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1081,6 +1081,9 @@
|
||||
"pipeline": "Конвейер дубляжа",
|
||||
"preview_language": "Язык предпросмотра",
|
||||
"target_language": "Дублировать на",
|
||||
"source_language": "Исходный язык",
|
||||
"retry_failed": "Повторить {{count}} неудачных",
|
||||
"skip_failed": "Пропустить неудачные",
|
||||
"transcript_after_extract": "Стенограмма появится после извлечения.",
|
||||
"qc_btn": "Проверка синхронизации перезаписи (вторая проверка)",
|
||||
"verify": "Проверить",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1083,6 +1083,9 @@
|
||||
"pipeline": "ไปป์ไลน์การพากย์",
|
||||
"preview_language": "ภาษาตัวอย่าง",
|
||||
"target_language": "พากย์เป็น",
|
||||
"source_language": "ภาษาต้นฉบับ",
|
||||
"retry_failed": "ลองใหม่ {{count}} รายการที่ล้มเหลว",
|
||||
"skip_failed": "ข้ามรายการที่ล้มเหลว",
|
||||
"transcript_after_extract": "ทรานสคริปต์จะปรากฏหลังการแยกเสียง",
|
||||
"qc_btn": "ตรวจสอบระยะเวลาการพากย์ (ตรวจสอบรอบที่สอง)",
|
||||
"verify": "ตรวจสอบ",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1083,6 +1083,9 @@
|
||||
"pipeline": "Конвеєр дубляжу",
|
||||
"preview_language": "Мова попереднього перегляду",
|
||||
"target_language": "Дублювати мовою",
|
||||
"source_language": "Мова оригіналу",
|
||||
"retry_failed": "Повторити {{count}} невдалих",
|
||||
"skip_failed": "Пропустити невдалі",
|
||||
"transcript_after_extract": "Стенограма з’явиться після вилучення.",
|
||||
"qc_btn": "Перевірте синхронізацію дубляжу (перевірка другого проходу)",
|
||||
"verify": "Перевірити",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1042,6 +1042,9 @@
|
||||
"pipeline": "配音流程",
|
||||
"preview_language": "预览语言",
|
||||
"target_language": "配音为",
|
||||
"source_language": "源语言",
|
||||
"retry_failed": "重试 {{count}} 个失败项",
|
||||
"skip_failed": "跳过失败项",
|
||||
"transcript_after_extract": "提取完成后将显示转录文本。",
|
||||
"qc_btn": "验证配音时序(第二遍检查)",
|
||||
"verify": "验证",
|
||||
|
||||
@@ -1083,6 +1083,9 @@
|
||||
"pipeline": "配音流程",
|
||||
"preview_language": "預覽語言",
|
||||
"target_language": "配音成",
|
||||
"source_language": "來源語言",
|
||||
"retry_failed": "重試 {{count}} 個失敗項目",
|
||||
"skip_failed": "跳過失敗項目",
|
||||
"transcript_after_extract": "擷取完成後將顯示逐字稿。",
|
||||
"qc_btn": "驗證配音時序(第二遍檢視)",
|
||||
"verify": "驗證",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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."}
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
@@ -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])
|
||||
|
||||
Reference in New Issue
Block a user