fix(dub): preserve original sound outside dialogue intervals

This commit is contained in:
Palash Debnath
2026-09-15 19:02:25 +05:30
parent 0a6ea976cb
commit 7758ccc500
56 changed files with 384 additions and 74 deletions
+2
View File
@@ -27,6 +27,8 @@ the frozen-backend fallback mirror it for their toolchains.
### Fixed
- Dubbing preserves original sound outside dialogue and mixes separated background only beneath replacement speech (#2129)
- Dubbing repairs missing speech caches, rejects incomplete output, avoids oversized speaker references, and fits full speech without early clipping (#2129)
- Workspace sidebars have a working right-edge resize handle, allow 40% more width, remember their size, and keep video controls inside the preview (#2129)
- Pressing Play while a video is loading starts playback when it is ready instead of reporting playback unavailable (#2129)
+47 -19
View File
@@ -38,6 +38,31 @@ router = APIRouter()
logger = logging.getLogger("omnivoice.api")
async def _preserved_background(job: dict, job_id: str, lang: str, *, prepare: bool = True) -> str:
"""All mixed preview/download paths share the same dialogue-only bed."""
from services.dub_background import surgical_background
bed = _optional_dub_artifact(job.get("no_vocals_path"), job_id)
source = _optional_dub_artifact(job.get("video_path"), job_id) or _optional_dub_artifact(job.get("audio_path"), job_id)
if not bed or not source:
raise HTTPException(status_code=409, detail={"code": "dub_background_unavailable", "message": "Original audio and background separation are required"})
track = (job.get("dubbed_tracks") or {}).get(lang) or {}
segments = track.get("source_segments") or job.get("segments") or []
if not segments:
raise HTTPException(status_code=409, detail={"code": "dub_background_unavailable", "message": "Dialogue timing is required"})
if not prepare:
return bed
strategy = track.get("timing_strategy") or job.get("timing_strategy")
plans = job.get("fit_plans" if strategy == "smart_fit" else "video_stretch_plans") or {}
entry = (plans.get(lang) or {}) if strategy in {"smart_fit", "stretch_video"} else {}
directory = os.path.join(_existing_job_dir_or_404(job_id), "exports")
os.makedirs(directory, exist_ok=True)
try:
return await surgical_background(source, bed, directory, segments, entry.get("plan") or [], float(entry.get("orig_duration") or job.get("duration") or 0))
except (ValueError, RuntimeError) as exc:
raise HTTPException(status_code=409, detail={"code": "dub_background_unavailable", "message": str(exc)}) from exc
def _unique_stamp() -> str:
"""Return a short unique suffix like '20260415T142301-ab12cd34' for export files."""
return f"{time.strftime('%Y%m%dT%H%M%S')}-{uuid.uuid4().hex[:8]}"
@@ -596,7 +621,7 @@ def _build_audio_export_cmd(
# Mix the dubbed voice over the original background bed (same weights
# as the video mux path) so ambience/music is preserved.
cmd += ["-i", bg_path, "-filter_complex",
bed_mix_filter("1:a", "0:a"),
bed_mix_filter("1:a", "0:a", bed_gain=1.0),
"-map", "[aout]"]
cmd += codec
cmd.append(out_path)
@@ -691,7 +716,7 @@ async def dub_download(
else:
output_name = f"dubbed_audio_{stamp}.m4a"
out_path = os.path.join(exports_dir, output_name)
bg = _optional_dub_artifact(job.get("no_vocals_path"), job_id) if preserve_bg else None
bg = await _preserved_background(job, job_id, lang_code) if preserve_bg else None
cmd = _build_audio_export_cmd(ffmpeg, track_info["path"], bg, out_path, fmt)
try:
rc, _, stderr = await run_ffmpeg(cmd, timeout=1800.0)
@@ -839,17 +864,16 @@ async def dub_download(
retimed_idx = input_idx
input_idx += 1
bg_audio = _optional_dub_artifact(job.get("no_vocals_path"), job_id) if preserve_bg else None
bg_idx = None
if bg_audio and filtered_tracks:
cmd += ["-i", bg_audio]
bg_idx = input_idx
input_idx += 1
tracks_to_process = []
for lang_code, track_info in filtered_tracks.items():
if preserve_bg:
bg_audio = await _preserved_background(job, job_id, lang_code)
cmd += ["-i", bg_audio]
bg_idx = input_idx
input_idx += 1
cmd += ["-i", track_info["path"]]
tracks_to_process.append({"lang_code": lang_code, "idx": input_idx, "info": track_info})
tracks_to_process.append({"lang_code": lang_code, "idx": input_idx, "bg_idx": bg_idx, "info": track_info})
input_idx += 1
filter_parts: list[str] = []
@@ -918,7 +942,7 @@ async def dub_download(
for i, t in enumerate(tracks_to_process):
tail = f",apad=whole_dur={apad_dur:.4f}" if apad_dur else ""
filter_parts.append(bed_mix_filter(
f"{bg_idx}:a", f"{t['idx']}:a", out=f"aout{i}", tail=tail, uniq=str(i),
f"{t['bg_idx']}:a", f"{t['idx']}:a", out=f"aout{i}", tail=tail, uniq=str(i), bed_gain=1.0,
))
t["out_label"] = f"[aout{i}]"
for t in tracks_to_process:
@@ -1119,7 +1143,7 @@ async def dub_preview_video(
video_path = _dub_artifact(job.get("video_path"), job_id, missing_detail="Source video missing")
bg_audio = _optional_dub_artifact(job.get("no_vocals_path"), job_id) if preserve_bg else None
bg_audio = await _preserved_background(job, job_id, lang, prepare=request.method != "HEAD") if preserve_bg else None
has_bg = bool(bg_audio)
# realpath-normalised + containment-checked inline BEFORE any filesystem
@@ -1131,7 +1155,7 @@ async def dub_preview_video(
if not exports_dir.startswith(_base + os.sep):
raise HTTPException(status_code=400, detail="Invalid job id")
os.makedirs(exports_dir, exist_ok=True)
bg_suffix = "bg" if (preserve_bg and has_bg) else "nobg"
bg_suffix = "surgical_v2_" + Path(bg_audio).stem if (preserve_bg and has_bg) else "nobg"
preview_path = os.path.realpath(
os.path.join(exports_dir, f"preview_v2_{lang}_{bg_suffix}.mp4")
)
@@ -1270,7 +1294,7 @@ async def dub_preview_video(
audio_map = f"{track_idx}:a:0"
if bg_idx is not None:
tail = f",apad=whole_dur={apad_dur:.4f}" if apad_dur else ""
filter_parts.append(bed_mix_filter(f"{bg_idx}:a", f"{track_idx}:a", tail=tail))
filter_parts.append(bed_mix_filter(f"{bg_idx}:a", f"{track_idx}:a", tail=tail, bed_gain=1.0))
audio_map = "[aout]"
elif apad_dur:
filter_parts.append(f"[{track_idx}:a]apad=whole_dur={apad_dur:.4f}[aout]")
@@ -1661,13 +1685,13 @@ async def dub_download_audio(
exports_dir = os.path.join(job_dir, "exports")
os.makedirs(exports_dir, exist_ok=True)
bg_audio = _optional_dub_artifact(job.get("no_vocals_path"), job_id) if preserve_bg else None
bg_audio = await _preserved_background(job, job_id, lang_label) if preserve_bg else None
if bg_audio:
ffmpeg = find_ffmpeg()
final_audio_path = os.path.join(exports_dir, f"mixed_dub_{stamp}.wav")
cmd = [
ffmpeg, "-i", bg_audio, "-i", wav_path,
"-filter_complex", bed_mix_filter("0:a", "1:a"),
"-filter_complex", bed_mix_filter("0:a", "1:a", bed_gain=1.0),
"-map", "[aout]", "-c:a", "pcm_s16le", "-y", final_audio_path
]
try:
@@ -1678,8 +1702,9 @@ async def dub_download_audio(
raise Exception("ffmpeg mix produced no output file")
wav_path = final_audio_path
logger.info("Dub audio mix completed")
except Exception:
except Exception as exc:
logger.exception("Failed to mix audio")
raise HTTPException(status_code=500, detail={"code": "dub_background_unavailable", "message": "Could not preserve background audio"}) from exc
base_name = os.path.splitext(job.get('filename', 'audio'))[0]
safe_name = ''.join(c for c in base_name if c.isalnum() or c in '-_ ').strip() or 'audio'
@@ -1947,20 +1972,23 @@ async def dub_download_mp3(
os.makedirs(exports_dir, exist_ok=True)
source_path = wav_path
bg_audio = _optional_dub_artifact(job.get("no_vocals_path"), job_id) if preserve_bg else None
bg_audio = await _preserved_background(job, job_id, lang_label) if preserve_bg else None
if bg_audio:
mixed_path = os.path.join(exports_dir, f"mixed_mp3_{stamp}.wav")
cmd_mix = [
ffmpeg, "-i", bg_audio, "-i", wav_path,
"-filter_complex", bed_mix_filter("0:a", "1:a"),
"-filter_complex", bed_mix_filter("0:a", "1:a", bed_gain=1.0),
"-map", "[aout]", "-c:a", "pcm_s16le", "-y", mixed_path
]
try:
rc, _, _ = await run_ffmpeg(cmd_mix, timeout=900.0)
if rc == 0 and os.path.exists(mixed_path) and os.path.getsize(mixed_path) > 0:
source_path = mixed_path
except Exception:
else:
raise RuntimeError("Background mixing failed")
except Exception as exc:
logger.exception("Failed to mix audio for MP3")
raise HTTPException(status_code=500, detail={"code": "dub_background_unavailable", "message": "Could not preserve background audio"}) from exc
mp3_path = os.path.join(exports_dir, f"dubbed_{stamp}.mp3")
# Accept '128', '192k' etc. — normalize to ffmpeg's 'Nk' form and clamp
+1
View File
@@ -1961,6 +1961,7 @@ async def dub_generate(job_id: str, req: DubRequest):
"language_code": lang_code,
"duration": round(track_dur, 4),
"timing_strategy": strategy,
"source_segments": [{"start": seg.start, "end": seg.end} for seg in req.segments],
}
# Persist the timing strategy + (for Mode B) the per-segment stretch
+130
View File
@@ -0,0 +1,130 @@
"""Dialogue-only replacement beds: original outside speech, separated bed inside."""
from __future__ import annotations
import asyncio
import hashlib
import json
import math
import os
import tempfile
from pathlib import Path
from services.ffmpeg_utils import find_ffmpeg, run_ffmpeg
from services.video_retime import expand_retime_chunks
RATE = 48000
FADE_S = .01
_locks: dict[str, asyncio.Lock] = {}
def dialogue_intervals(segments: list[dict]) -> list[tuple[float, float]]:
intervals = []
for row in segments:
a, b = float(row['start']), float(row['end'])
if not math.isfinite(a) or not math.isfinite(b) or a < 0 or b <= a:
raise ValueError('Invalid dialogue interval')
intervals.append((a, b))
merged: list[tuple[float, float]] = []
for a, b in sorted(intervals):
if merged and a <= merged[-1][1]:
merged[-1] = (merged[-1][0], max(b, merged[-1][1]))
else:
merged.append((a, b))
return merged
def splice_background(original: str, separated: str, output: str, intervals: list[tuple[float, float]]) -> None:
"""Stream in bounded memory; crossfades lie INSIDE dialogue intervals."""
import numpy as np
import soundfile as sf
with sf.SoundFile(original) as src, sf.SoundFile(separated) as bed:
if src.samplerate != bed.samplerate or src.channels != bed.channels:
raise ValueError('Background inputs must have matching sample format')
if bed.frames < src.frames - int(.1 * src.samplerate):
raise ValueError('Separated background is incomplete')
with sf.SoundFile(output, 'w', samplerate=src.samplerate, channels=src.channels, subtype='FLOAT') as out:
offset = 0
active = 0
while True:
wave = src.read(65536, dtype='float32', always_2d=True)
if not len(wave):
break
background = bed.read(len(wave), dtype='float32', always_2d=True)
if len(background) < len(wave):
background = np.pad(background, ((0, len(wave)-len(background)), (0, 0)))
times = np.arange(offset, offset + len(wave)) / src.samplerate
mask = np.zeros(len(wave), dtype='float32')
while active < len(intervals) and intervals[active][1] < times[0]:
active += 1
for a, b in intervals[active:]:
if a > times[-1]:
break
fade = min(FADE_S, (b-a)/2)
envelope = np.clip(np.minimum((times-a)/fade, (b-times)/fade), 0, 1)
mask = np.maximum(mask, envelope)
out.write(wave * (1-mask[:, None]) + background * mask[:, None])
offset += len(wave)
async def _checked(cmd: list[str]) -> None:
rc, _, error = await run_ffmpeg(cmd, timeout=1800.0)
if rc:
raise RuntimeError('Could not preserve original background audio: ' + str(error)[-500:])
async def surgical_background(source: str, separated: str, cache_dir: str, segments: list[dict], plan: list[dict], duration: float) -> str:
for chunk in plan:
ratio = float(chunk["stretch_ratio"])
if not math.isfinite(ratio) or ratio <= 0:
raise ValueError("Invalid background retiming ratio")
intervals = dialogue_intervals(segments)
if not intervals:
raise ValueError('Dialogue timing is required to preserve original background audio')
identity = [(p, os.stat(p).st_size, os.stat(p).st_mtime_ns) for p in (source, separated)]
key = hashlib.sha256(json.dumps([1, identity, intervals, plan, duration], sort_keys=True).encode()).hexdigest()[:24]
target = str(Path(cache_dir) / f'surgical_{key}.wav')
async with _locks.setdefault(target, asyncio.Lock()):
if os.path.isfile(target):
return target
ffmpeg = find_ffmpeg()
with tempfile.TemporaryDirectory(prefix='.surgical-', dir=cache_dir) as tmp:
original, bed, spliced = [str(Path(tmp)/name) for name in ('source.wav', 'bed.wav', 'spliced.wav')]
for inp, out in ((source, original), (separated, bed)):
await _checked([ffmpeg, '-y', '-i', inp, '-map', '0:a:0', '-vn', '-ar', str(RATE), '-ac', '2', '-c:a', 'pcm_f32le', out])
await asyncio.to_thread(splice_background, original, bed, spliced, intervals)
if plan and any(abs(float(p['stretch_ratio'])-1) > 1e-6 for p in plan):
chunks = expand_retime_chunks(plan, duration)
# Bound filter buffering for long projects; trim each batch's
# input before splitting it among the chunk filters.
batches = []
for batch_index in range(0, len(chunks), 16):
batch = chunks[batch_index:batch_index+16]
origin = batch[0][0]
filters = []
for i, (a, b, ratio) in enumerate(batch):
rate = 1 / ratio
tempos = []
while rate < .5:
tempos.append('atempo=0.5')
rate /= .5
while rate > 2:
tempos.append('atempo=2')
rate /= 2
tempos.append(f'atempo={rate:.9f}')
length = (b-a)*ratio
filters.append(f'[0:a]atrim=start={a-origin:.9f}:end={b-origin:.9f},asetpts=PTS-STARTPTS,' + ','.join(tempos) + f',apad,atrim=duration={length:.9f}[c{i}]')
filters.append(''.join(f'[c{i}]' for i in range(len(batch))) + f'concat=n={len(batch)}:v=0:a=1[out]')
script = Path(tmp)/'retime.txt'
script.write_text(';'.join(filters))
batch_name = f'batch{batch_index}.wav'
output = str(Path(tmp)/batch_name)
await _checked([ffmpeg, '-y', '-ss', str(origin), '-t', str(batch[-1][1]-origin), '-i', spliced, '-filter_complex_script', str(script), '-map', '[out]', '-c:a', 'pcm_f32le', output])
batches.append(batch_name)
listing = Path(tmp)/'concat.txt'
listing.write_text(''.join(f"file '{name}'\n" for name in batches))
retimed = str(Path(tmp)/'retimed.wav')
await _checked([ffmpeg, '-y', '-f', 'concat', '-safe', '1', '-i', str(listing), '-c:a', 'copy', retimed])
spliced = retimed
os.replace(spliced, target)
return target
+6 -5
View File
@@ -89,6 +89,7 @@ def bed_mix_filter(
duration: str = "longest",
tail: str = "",
uniq: str = "",
bed_gain: float = BED_GAIN,
) -> str:
"""One ffmpeg filter chain mixing `voice_in` over `bed_in` at original level.
@@ -110,22 +111,22 @@ def bed_mix_filter(
# Gains applied per input, amix reduced to a plain sum: levels are
# exact for the whole timeline, including after either stream ends.
return (
f"[{bed_in}]aresample={BED_MIX_SAMPLE_RATE},{stereo},volume={BED_GAIN:g}[{b}];"
f"[{bed_in}]aresample={BED_MIX_SAMPLE_RATE},{stereo},volume={bed_gain:g}[{b}];"
f"[{voice_in}]aresample={BED_MIX_SAMPLE_RATE},{stereo},volume={VOICE_GAIN:g}[{v}];"
f"[{b}][{v}]amix=inputs=2:duration={duration}:dropout_transition=2:"
f"normalize=0,alimiter=level=false:limit=0.98{tail}[{out}]"
f"normalize=0,alimiter=level=false:limit=0.98:latency=1{tail}[{out}]"
)
# Legacy ffmpeg (<5, no `normalize`): cancel amix's normalization with a
# compensating multiply. Exact while both streams run; if one ends early
# the tail is over-boosted into the limiter until the graph ends — a known
# quirk accepted only on old ffmpeg, where the alternative is no export.
total = BED_GAIN + VOICE_GAIN
total = bed_gain + VOICE_GAIN
return (
f"[{bed_in}]aresample={BED_MIX_SAMPLE_RATE},{stereo}[{b}];"
f"[{voice_in}]aresample={BED_MIX_SAMPLE_RATE},{stereo}[{v}];"
f"[{b}][{v}]amix=inputs=2:duration={duration}:dropout_transition=2:"
f"weights={BED_GAIN:g} {VOICE_GAIN:g},volume={total:g},"
f"alimiter=level=false:limit=0.98{tail}[{out}]"
f"weights={bed_gain:g} {VOICE_GAIN:g},volume={total:g},"
f"alimiter=level=false:limit=0.98:latency=1{tail}[{out}]"
)
+17
View File
@@ -223,3 +223,20 @@ retrying. Camera-cut segmentation uses nearby timed word boundaries when availab
and skips cuts inside speech that cannot be assigned safely. This improves phrase
timing; it does not promise phoneme-level lip sync or correct inaccurate source
transcripts automatically.
### Preserve sound outside dialogue
Background-preserving previews and audio/video exports keep the original stereo
sound outside dialogue intervals, including audience reactions, music and ambience.
Inside those intervals, they mix dubbed speech over the separated background, with
10 ms transitions contained within the dialogue boundaries. Each generated language
stores its source intervals; older tracks use their saved project intervals.
Retimed modes also retime this background to follow the video. Ordinary Strict Slot
and Concise modes keep the original background timeline.
Original media and a complete separated background are required. A missing or failed
background mix stops export rather than silently exporting speech alone. Explicit
speech-only export remains available. The preserved bed is cached locally and rebuilt
when source files, dialogue intervals or the language's retiming plan change.
Separation can still affect sounds overlapping dialogue; exact isolation from a
single mixed recording is not guaranteed. Correct dialogue boundaries matter.
@@ -452,7 +452,7 @@ export function DubPage() {
const revision = fingerprintRevision(session.fingerprintsByLang?.[track]);
return {
track,
path: `/dub/preview-video/${job}?lang=${encodeURIComponent(track)}${revision ? `&v=${revision}` : ''}`,
path: `/dub/preview-video/${job}?mix=surgical2&lang=${encodeURIComponent(track)}${revision ? `&v=${revision}` : ''}`,
};
})
: [],
@@ -469,7 +469,7 @@ export function DubPage() {
: apiPath(
preview === 'original'
? `/dub/media/${job}`
: `/dub/preview-video/${job}?lang=${encodeURIComponent(preview)}${previewRevision ? `&v=${previewRevision}` : ''}`,
: `/dub/preview-video/${job}?mix=surgical2&lang=${encodeURIComponent(preview)}${previewRevision ? `&v=${previewRevision}` : ''}`,
),
[job, preview, previewRevision, session.inputType],
);
@@ -2581,6 +2581,7 @@
},
"dubIntegrity": {
"missingSpeech": "الكلام مفقود أو غير قابل للقراءة. أعد توليد المقطع المتأثر قبل التصدير.",
"timingOverflow": "يتجاوز الكلام الوقت المخصص له. اختصر الترجمة أو اختر التوقيت الصارم أو تمديد الفيديو."
"timingOverflow": "يتجاوز الكلام الوقت المخصص له. اختصر الترجمة أو اختر التوقيت الصارم أو تمديد الفيديو.",
"backgroundUnavailable": "تعذّر الحفاظ على الصوت الأصلي. تحقّق من فصل الخلفية وتوقيت الحوار ثم أعد المحاولة، أو صدّر الكلام فقط بشكل صريح."
}
}
@@ -2573,6 +2573,7 @@
},
"dubIntegrity": {
"missingSpeech": "Sprache fehlt oder ist nicht lesbar. Erzeuge das betroffene Segment vor dem Export erneut.",
"timingOverflow": "Die Sprache überschreitet ihr Zeitfenster. Kürze die Übersetzung oder wähle ein festes Zeitfenster oder die Videostreckung."
"timingOverflow": "Die Sprache überschreitet ihr Zeitfenster. Kürze die Übersetzung oder wähle ein festes Zeitfenster oder die Videostreckung.",
"backgroundUnavailable": "Der Originalton konnte nicht erhalten werden. Prüfe Hintergrundtrennung und Dialogzeiten und versuche es erneut, oder exportiere ausdrücklich nur Sprache."
}
}
@@ -2573,6 +2573,7 @@
},
"dubIntegrity": {
"missingSpeech": "Speech is missing or unreadable. Regenerate the affected segment before exporting.",
"timingOverflow": "Speech exceeds its time slot. Shorten the translation or choose Strict Slot or Stretch Video."
"timingOverflow": "Speech exceeds its time slot. Shorten the translation or choose Strict Slot or Stretch Video.",
"backgroundUnavailable": "Original sound could not be preserved. Check background separation and dialogue timing, then retry, or explicitly export speech only."
}
}
@@ -2575,6 +2575,7 @@
},
"dubIntegrity": {
"missingSpeech": "Falta audio de voz o no se puede leer. Regenera el segmento afectado antes de exportar.",
"timingOverflow": "La voz supera su intervalo de tiempo. Acorta la traducción o elige un intervalo estricto o alargar el vídeo."
"timingOverflow": "La voz supera su intervalo de tiempo. Acorta la traducción o elige un intervalo estricto o alargar el vídeo.",
"backgroundUnavailable": "No se pudo conservar el sonido original. Revisa la separación del fondo y los tiempos del diálogo e inténtalo de nuevo, o exporta solo la voz explícitamente."
}
}
@@ -2575,6 +2575,7 @@
},
"dubIntegrity": {
"missingSpeech": "La parole est absente ou illisible. Régénérez le segment concerné avant lexportation.",
"timingOverflow": "La parole dépasse son créneau. Raccourcissez la traduction ou choisissez un créneau strict ou l’étirement vidéo."
"timingOverflow": "La parole dépasse son créneau. Raccourcissez la traduction ou choisissez un créneau strict ou l’étirement vidéo.",
"backgroundUnavailable": "Le son original na pas pu être préservé. Vérifiez la séparation du fond et les temps du dialogue, puis réessayez, ou exportez explicitement la voix seule."
}
}
@@ -2573,6 +2573,7 @@
},
"dubIntegrity": {
"missingSpeech": "बोली गायब है या पढ़ी नहीं जा सकती। निर्यात से पहले प्रभावित खंड दोबारा बनाएँ।",
"timingOverflow": "बोली अपने समय खंड से लंबी है। अनुवाद छोटा करें या सख्त समय खंड अथवा वीडियो खिंचाव चुनें।"
"timingOverflow": "बोली अपने समय खंड से लंबी है। अनुवाद छोटा करें या सख्त समय खंड अथवा वीडियो खिंचाव चुनें।",
"backgroundUnavailable": "मूल ध्वनि सुरक्षित नहीं रखी जा सकी। पृष्ठभूमि पृथक्करण और संवाद का समय जाँचकर फिर कोशिश करें, या केवल वाणी निर्यात करने का विकल्प चुनें।"
}
}
@@ -2573,6 +2573,7 @@
},
"dubIntegrity": {
"missingSpeech": "Audio ucapan hilang atau tidak dapat dibaca. Buat ulang segmen terkait sebelum mengekspor.",
"timingOverflow": "Ucapan melebihi jatah waktunya. Persingkat terjemahan atau pilih slot ketat atau rentangkan video."
"timingOverflow": "Ucapan melebihi jatah waktunya. Persingkat terjemahan atau pilih slot ketat atau rentangkan video.",
"backgroundUnavailable": "Suara asli tidak dapat dipertahankan. Periksa pemisahan latar dan waktu dialog, lalu coba lagi, atau pilih ekspor suara saja."
}
}
@@ -2575,6 +2575,7 @@
},
"dubIntegrity": {
"missingSpeech": "Il parlato manca o non è leggibile. Rigenera il segmento interessato prima di esportare.",
"timingOverflow": "Il parlato supera il tempo disponibile. Accorcia la traduzione oppure scegli uno slot rigoroso o estendi il video."
"timingOverflow": "Il parlato supera il tempo disponibile. Accorcia la traduzione oppure scegli uno slot rigoroso o estendi il video.",
"backgroundUnavailable": "Impossibile preservare il suono originale. Controlla la separazione del sottofondo e i tempi del dialogo, poi riprova, oppure esporta esplicitamente solo la voce."
}
}
@@ -2573,6 +2573,7 @@
},
"dubIntegrity": {
"missingSpeech": "音声がないか読み取れません。書き出す前に該当セグメントを再生成してください。",
"timingOverflow": "音声が割り当て時間を超えています。翻訳を短くするか、厳密な時間枠または動画の引き伸ばしを選んでください。"
"timingOverflow": "音声が割り当て時間を超えています。翻訳を短くするか、厳密な時間枠または動画の引き伸ばしを選んでください。",
"backgroundUnavailable": "元の音声を保持できませんでした。背景音の分離と台詞のタイミングを確認して再試行するか、音声のみの書き出しを選択してください。"
}
}
@@ -2573,6 +2573,7 @@
},
"dubIntegrity": {
"missingSpeech": "음성이 없거나 읽을 수 없습니다. 내보내기 전에 해당 구간을 다시 생성하세요.",
"timingOverflow": "음성이 할당된 시간을 초과합니다. 번역을 줄이거나 엄격한 시간 구간 또는 비디오 늘이기를 선택하세요."
"timingOverflow": "음성이 할당된 시간을 초과합니다. 번역을 줄이거나 엄격한 시간 구간 또는 비디오 늘이기를 선택하세요.",
"backgroundUnavailable": "원본 소리를 보존할 수 없습니다. 배경음 분리와 대사 타이밍을 확인한 후 다시 시도하거나 음성만 내보내기를 선택하세요."
}
}
@@ -2573,6 +2573,7 @@
},
"dubIntegrity": {
"missingSpeech": "Spraak ontbreekt of is onleesbaar. Genereer het betreffende segment opnieuw voordat je exporteert.",
"timingOverflow": "De spraak overschrijdt het tijdvak. Verkort de vertaling of kies een strikt tijdvak of het uitrekken van de video."
"timingOverflow": "De spraak overschrijdt het tijdvak. Verkort de vertaling of kies een strikt tijdvak of het uitrekken van de video.",
"backgroundUnavailable": "Het oorspronkelijke geluid kon niet behouden blijven. Controleer de achtergrondscheiding en dialoogtijden en probeer opnieuw, of kies expliciet voor alleen spraak exporteren."
}
}
@@ -2577,6 +2577,7 @@
},
"dubIntegrity": {
"missingSpeech": "Brak mowy lub nie można jej odczytać. Przed eksportem wygeneruj ponownie dany segment.",
"timingOverflow": "Mowa przekracza przydzielony czas. Skróć tłumaczenie albo wybierz ścisły przedział czasu lub wydłużenie filmu."
"timingOverflow": "Mowa przekracza przydzielony czas. Skróć tłumaczenie albo wybierz ścisły przedział czasu lub wydłużenie filmu.",
"backgroundUnavailable": "Nie udało się zachować oryginalnego dźwięku. Sprawdź oddzielenie tła i czasy dialogów, a następnie spróbuj ponownie lub wybierz eksport samej mowy."
}
}
@@ -2575,6 +2575,7 @@
},
"dubIntegrity": {
"missingSpeech": "A fala está ausente ou ilegível. Gere novamente o segmento afetado antes de exportar.",
"timingOverflow": "A fala excede o intervalo de tempo. Encurte a tradução ou escolha um intervalo estrito ou estique o vídeo."
"timingOverflow": "A fala excede o intervalo de tempo. Encurte a tradução ou escolha um intervalo estrito ou estique o vídeo.",
"backgroundUnavailable": "Não foi possível preservar o som original. Verifique a separação do fundo e os tempos do diálogo e tente novamente, ou escolha exportar apenas a fala."
}
}
@@ -2577,6 +2577,7 @@
},
"dubIntegrity": {
"missingSpeech": "Речь отсутствует или не читается. Перед экспортом заново сгенерируйте этот сегмент.",
"timingOverflow": "Речь выходит за отведённое время. Сократите перевод или выберите строгий интервал либо растяжение видео."
"timingOverflow": "Речь выходит за отведённое время. Сократите перевод или выберите строгий интервал либо растяжение видео.",
"backgroundUnavailable": "Не удалось сохранить исходный звук. Проверьте отделение фона и время реплик, затем повторите попытку или выберите экспорт только речи."
}
}
@@ -2573,6 +2573,7 @@
},
"dubIntegrity": {
"missingSpeech": "Tal saknas eller kan inte läsas. Generera det berörda segmentet på nytt före export.",
"timingOverflow": "Talet överskrider sin tidslucka. Korta översättningen eller välj strikt tidslucka eller sträck ut videon."
"timingOverflow": "Talet överskrider sin tidslucka. Korta översättningen eller välj strikt tidslucka eller sträck ut videon.",
"backgroundUnavailable": "Originalljudet kunde inte bevaras. Kontrollera bakgrundssepareringen och dialogens tider och försök igen, eller välj att endast exportera tal."
}
}
@@ -2573,6 +2573,7 @@
},
"dubIntegrity": {
"missingSpeech": "เสียงพูดหายไปหรืออ่านไม่ได้ สร้างช่วงที่มีปัญหาใหม่ก่อนส่งออก",
"timingOverflow": "เสียงพูดยาวเกินช่วงเวลาที่กำหนด ย่อคำแปลหรือเลือกช่วงเวลาแบบเคร่งครัดหรือยืดวิดีโอ"
"timingOverflow": "เสียงพูดยาวเกินช่วงเวลาที่กำหนด ย่อคำแปลหรือเลือกช่วงเวลาแบบเคร่งครัดหรือยืดวิดีโอ",
"backgroundUnavailable": "ไม่สามารถรักษาเสียงต้นฉบับได้ ตรวจสอบการแยกเสียงพื้นหลังและเวลาบทสนทนาแล้วลองอีกครั้ง หรือเลือกส่งออกเฉพาะเสียงพูด"
}
}
@@ -2573,6 +2573,7 @@
},
"dubIntegrity": {
"missingSpeech": "Konuşma eksik veya okunamıyor. Dışa aktarmadan önce ilgili bölümü yeniden oluşturun.",
"timingOverflow": "Konuşma ayrılan süreyi aşıyor. Çeviriyi kısaltın veya kesin zaman aralığını ya da videoyu uzatmayı seçin."
"timingOverflow": "Konuşma ayrılan süreyi aşıyor. Çeviriyi kısaltın veya kesin zaman aralığını ya da videoyu uzatmayı seçin.",
"backgroundUnavailable": "Özgün ses korunamadı. Arka plan ayrımını ve diyalog zamanlarını kontrol edip yeniden deneyin veya yalnızca konuşmayı dışa aktarmayı seçin."
}
}
@@ -2577,6 +2577,7 @@
},
"dubIntegrity": {
"missingSpeech": "Мовлення відсутнє або не читається. Перед експортом повторно згенеруйте цей сегмент.",
"timingOverflow": "Мовлення перевищує відведений час. Скоротіть переклад або виберіть строгий інтервал чи розтягнення відео."
"timingOverflow": "Мовлення перевищує відведений час. Скоротіть переклад або виберіть строгий інтервал чи розтягнення відео.",
"backgroundUnavailable": "Не вдалося зберегти оригінальний звук. Перевірте відокремлення фону й час реплік, потім повторіть спробу або виберіть експорт лише мовлення."
}
}
@@ -2573,6 +2573,7 @@
},
"dubIntegrity": {
"missingSpeech": "Lời nói bị thiếu hoặc không đọc được. Hãy tạo lại đoạn bị ảnh hưởng trước khi xuất.",
"timingOverflow": "Lời nói vượt quá thời gian được phân bổ. Hãy rút ngắn bản dịch hoặc chọn khung thời gian nghiêm ngặt hay kéo dài video."
"timingOverflow": "Lời nói vượt quá thời gian được phân bổ. Hãy rút ngắn bản dịch hoặc chọn khung thời gian nghiêm ngặt hay kéo dài video.",
"backgroundUnavailable": "Không thể giữ âm thanh gốc. Hãy kiểm tra việc tách âm nền và thời gian hội thoại rồi thử lại, hoặc chọn chỉ xuất giọng nói."
}
}
@@ -2577,6 +2577,7 @@
},
"dubIntegrity": {
"missingSpeech": "语音缺失或无法读取。请在导出前重新生成受影响的片段。",
"timingOverflow": "语音超出分配的时长。请缩短译文,或选择严格时间段或拉伸视频。"
"timingOverflow": "语音超出分配的时长。请缩短译文,或选择严格时间段或拉伸视频。",
"backgroundUnavailable": "无法保留原始声音。请检查背景音分离和对白时间后重试,或明确选择仅导出语音。"
}
}
@@ -2573,6 +2573,7 @@
},
"dubIntegrity": {
"missingSpeech": "語音缺失或無法讀取。請在匯出前重新產生受影響的片段。",
"timingOverflow": "語音超出分配的時長。請縮短譯文,或選擇嚴格時間區段或延展影片。"
"timingOverflow": "語音超出分配的時長。請縮短譯文,或選擇嚴格時間區段或延展影片。",
"backgroundUnavailable": "無法保留原始聲音。請檢查背景音分離和對白時間後重試,或明確選擇僅匯出語音。"
}
}
@@ -23,6 +23,13 @@ afterEach(() => {
});
describe('errorFromResponse', () => {
it('localizes background preservation errors and retains diagnostics', async () => {
const detail = { code: 'dub_background_unavailable', message: 'Raw diagnostic' };
const err = await errorFromResponse(new Response(JSON.stringify({ detail }), { status: 409 }));
expect(err.detail).not.toBe('Raw diagnostic');
expect(err.detail).not.toContain('Raw diagnostic');
expect(err.payload?.detail).toEqual(detail);
});
it('uses a string detail verbatim and keeps the payload', async () => {
const res = new Response(JSON.stringify({ detail: 'Unsupported instruct items' }), {
status: 400,
@@ -44,6 +44,8 @@ export function describeError(err: unknown): string {
}
function detailToString(detail: unknown): string {
if (detail && typeof detail === 'object' && 'code' in detail && detail.code === 'dub_background_unavailable')
return tr('dubIntegrity.backgroundUnavailable');
if (typeof detail === 'string') return detail;
if (detail == null) return '';
if (
+2 -1
View File
@@ -2835,6 +2835,7 @@
},
"dubIntegrity": {
"missingSpeech": "الكلام مفقود أو غير قابل للقراءة. أعد توليد المقطع المتأثر قبل التصدير.",
"timingOverflow": "يتجاوز الكلام الوقت المخصص له. اختصر الترجمة أو اختر التوقيت الصارم أو تمديد الفيديو."
"timingOverflow": "يتجاوز الكلام الوقت المخصص له. اختصر الترجمة أو اختر التوقيت الصارم أو تمديد الفيديو.",
"backgroundUnavailable": "تعذّر الحفاظ على الصوت الأصلي. تحقّق من فصل الخلفية وتوقيت الحوار ثم أعد المحاولة، أو صدّر الكلام فقط بشكل صريح."
}
}
+2 -1
View File
@@ -2835,6 +2835,7 @@
},
"dubIntegrity": {
"missingSpeech": "Sprache fehlt oder ist nicht lesbar. Erzeuge das betroffene Segment vor dem Export erneut.",
"timingOverflow": "Die Sprache überschreitet ihr Zeitfenster. Kürze die Übersetzung oder wähle ein festes Zeitfenster oder die Videostreckung."
"timingOverflow": "Die Sprache überschreitet ihr Zeitfenster. Kürze die Übersetzung oder wähle ein festes Zeitfenster oder die Videostreckung.",
"backgroundUnavailable": "Der Originalton konnte nicht erhalten werden. Prüfe Hintergrundtrennung und Dialogzeiten und versuche es erneut, oder exportiere ausdrücklich nur Sprache."
}
}
+2 -1
View File
@@ -3316,6 +3316,7 @@
},
"dubIntegrity": {
"missingSpeech": "Speech is missing or unreadable. Regenerate the affected segment before exporting.",
"timingOverflow": "Speech exceeds its time slot. Shorten the translation or choose Strict Slot or Stretch Video."
"timingOverflow": "Speech exceeds its time slot. Shorten the translation or choose Strict Slot or Stretch Video.",
"backgroundUnavailable": "Original sound could not be preserved. Check background separation and dialogue timing, then retry, or explicitly export speech only."
}
}
+2 -1
View File
@@ -2835,6 +2835,7 @@
},
"dubIntegrity": {
"missingSpeech": "Falta audio de voz o no se puede leer. Regenera el segmento afectado antes de exportar.",
"timingOverflow": "La voz supera su intervalo de tiempo. Acorta la traducción o elige un intervalo estricto o alargar el vídeo."
"timingOverflow": "La voz supera su intervalo de tiempo. Acorta la traducción o elige un intervalo estricto o alargar el vídeo.",
"backgroundUnavailable": "No se pudo conservar el sonido original. Revisa la separación del fondo y los tiempos del diálogo e inténtalo de nuevo, o exporta solo la voz explícitamente."
}
}
+2 -1
View File
@@ -2835,6 +2835,7 @@
},
"dubIntegrity": {
"missingSpeech": "La parole est absente ou illisible. Régénérez le segment concerné avant lexportation.",
"timingOverflow": "La parole dépasse son créneau. Raccourcissez la traduction ou choisissez un créneau strict ou l’étirement vidéo."
"timingOverflow": "La parole dépasse son créneau. Raccourcissez la traduction ou choisissez un créneau strict ou l’étirement vidéo.",
"backgroundUnavailable": "Le son original na pas pu être préservé. Vérifiez la séparation du fond et les temps du dialogue, puis réessayez, ou exportez explicitement la voix seule."
}
}
+2 -1
View File
@@ -2835,6 +2835,7 @@
},
"dubIntegrity": {
"missingSpeech": "बोली गायब है या पढ़ी नहीं जा सकती। निर्यात से पहले प्रभावित खंड दोबारा बनाएँ।",
"timingOverflow": "बोली अपने समय खंड से लंबी है। अनुवाद छोटा करें या सख्त समय खंड अथवा वीडियो खिंचाव चुनें।"
"timingOverflow": "बोली अपने समय खंड से लंबी है। अनुवाद छोटा करें या सख्त समय खंड अथवा वीडियो खिंचाव चुनें।",
"backgroundUnavailable": "मूल ध्वनि सुरक्षित नहीं रखी जा सकी। पृष्ठभूमि पृथक्करण और संवाद का समय जाँचकर फिर कोशिश करें, या केवल वाणी निर्यात करने का विकल्प चुनें।"
}
}
+2 -1
View File
@@ -2835,6 +2835,7 @@
},
"dubIntegrity": {
"missingSpeech": "Audio ucapan hilang atau tidak dapat dibaca. Buat ulang segmen terkait sebelum mengekspor.",
"timingOverflow": "Ucapan melebihi jatah waktunya. Persingkat terjemahan atau pilih slot ketat atau rentangkan video."
"timingOverflow": "Ucapan melebihi jatah waktunya. Persingkat terjemahan atau pilih slot ketat atau rentangkan video.",
"backgroundUnavailable": "Suara asli tidak dapat dipertahankan. Periksa pemisahan latar dan waktu dialog, lalu coba lagi, atau pilih ekspor suara saja."
}
}
+2 -1
View File
@@ -2835,6 +2835,7 @@
},
"dubIntegrity": {
"missingSpeech": "Il parlato manca o non è leggibile. Rigenera il segmento interessato prima di esportare.",
"timingOverflow": "Il parlato supera il tempo disponibile. Accorcia la traduzione oppure scegli uno slot rigoroso o estendi il video."
"timingOverflow": "Il parlato supera il tempo disponibile. Accorcia la traduzione oppure scegli uno slot rigoroso o estendi il video.",
"backgroundUnavailable": "Impossibile preservare il suono originale. Controlla la separazione del sottofondo e i tempi del dialogo, poi riprova, oppure esporta esplicitamente solo la voce."
}
}
+2 -1
View File
@@ -2835,6 +2835,7 @@
},
"dubIntegrity": {
"missingSpeech": "音声がないか読み取れません。書き出す前に該当セグメントを再生成してください。",
"timingOverflow": "音声が割り当て時間を超えています。翻訳を短くするか、厳密な時間枠または動画の引き伸ばしを選んでください。"
"timingOverflow": "音声が割り当て時間を超えています。翻訳を短くするか、厳密な時間枠または動画の引き伸ばしを選んでください。",
"backgroundUnavailable": "元の音声を保持できませんでした。背景音の分離と台詞のタイミングを確認して再試行するか、音声のみの書き出しを選択してください。"
}
}
+2 -1
View File
@@ -3316,6 +3316,7 @@
},
"dubIntegrity": {
"missingSpeech": "음성이 없거나 읽을 수 없습니다. 내보내기 전에 해당 구간을 다시 생성하세요.",
"timingOverflow": "음성이 할당된 시간을 초과합니다. 번역을 줄이거나 엄격한 시간 구간 또는 비디오 늘이기를 선택하세요."
"timingOverflow": "음성이 할당된 시간을 초과합니다. 번역을 줄이거나 엄격한 시간 구간 또는 비디오 늘이기를 선택하세요.",
"backgroundUnavailable": "원본 소리를 보존할 수 없습니다. 배경음 분리와 대사 타이밍을 확인한 후 다시 시도하거나 음성만 내보내기를 선택하세요."
}
}
+2 -1
View File
@@ -2835,6 +2835,7 @@
},
"dubIntegrity": {
"missingSpeech": "Spraak ontbreekt of is onleesbaar. Genereer het betreffende segment opnieuw voordat je exporteert.",
"timingOverflow": "De spraak overschrijdt het tijdvak. Verkort de vertaling of kies een strikt tijdvak of het uitrekken van de video."
"timingOverflow": "De spraak overschrijdt het tijdvak. Verkort de vertaling of kies een strikt tijdvak of het uitrekken van de video.",
"backgroundUnavailable": "Het oorspronkelijke geluid kon niet behouden blijven. Controleer de achtergrondscheiding en dialoogtijden en probeer opnieuw, of kies expliciet voor alleen spraak exporteren."
}
}
+2 -1
View File
@@ -2835,6 +2835,7 @@
},
"dubIntegrity": {
"missingSpeech": "Brak mowy lub nie można jej odczytać. Przed eksportem wygeneruj ponownie dany segment.",
"timingOverflow": "Mowa przekracza przydzielony czas. Skróć tłumaczenie albo wybierz ścisły przedział czasu lub wydłużenie filmu."
"timingOverflow": "Mowa przekracza przydzielony czas. Skróć tłumaczenie albo wybierz ścisły przedział czasu lub wydłużenie filmu.",
"backgroundUnavailable": "Nie udało się zachować oryginalnego dźwięku. Sprawdź oddzielenie tła i czasy dialogów, a następnie spróbuj ponownie lub wybierz eksport samej mowy."
}
}
+2 -1
View File
@@ -2835,6 +2835,7 @@
},
"dubIntegrity": {
"missingSpeech": "A fala está ausente ou ilegível. Gere novamente o segmento afetado antes de exportar.",
"timingOverflow": "A fala excede o intervalo de tempo. Encurte a tradução ou escolha um intervalo estrito ou estique o vídeo."
"timingOverflow": "A fala excede o intervalo de tempo. Encurte a tradução ou escolha um intervalo estrito ou estique o vídeo.",
"backgroundUnavailable": "Não foi possível preservar o som original. Verifique a separação do fundo e os tempos do diálogo e tente novamente, ou escolha exportar apenas a fala."
}
}
+2 -1
View File
@@ -2835,6 +2835,7 @@
},
"dubIntegrity": {
"missingSpeech": "Речь отсутствует или не читается. Перед экспортом заново сгенерируйте этот сегмент.",
"timingOverflow": "Речь выходит за отведённое время. Сократите перевод или выберите строгий интервал либо растяжение видео."
"timingOverflow": "Речь выходит за отведённое время. Сократите перевод или выберите строгий интервал либо растяжение видео.",
"backgroundUnavailable": "Не удалось сохранить исходный звук. Проверьте отделение фона и время реплик, затем повторите попытку или выберите экспорт только речи."
}
}
+2 -1
View File
@@ -2835,6 +2835,7 @@
},
"dubIntegrity": {
"missingSpeech": "Tal saknas eller kan inte läsas. Generera det berörda segmentet på nytt före export.",
"timingOverflow": "Talet överskrider sin tidslucka. Korta översättningen eller välj strikt tidslucka eller sträck ut videon."
"timingOverflow": "Talet överskrider sin tidslucka. Korta översättningen eller välj strikt tidslucka eller sträck ut videon.",
"backgroundUnavailable": "Originalljudet kunde inte bevaras. Kontrollera bakgrundssepareringen och dialogens tider och försök igen, eller välj att endast exportera tal."
}
}
+2 -1
View File
@@ -2835,6 +2835,7 @@
},
"dubIntegrity": {
"missingSpeech": "เสียงพูดหายไปหรืออ่านไม่ได้ สร้างช่วงที่มีปัญหาใหม่ก่อนส่งออก",
"timingOverflow": "เสียงพูดยาวเกินช่วงเวลาที่กำหนด ย่อคำแปลหรือเลือกช่วงเวลาแบบเคร่งครัดหรือยืดวิดีโอ"
"timingOverflow": "เสียงพูดยาวเกินช่วงเวลาที่กำหนด ย่อคำแปลหรือเลือกช่วงเวลาแบบเคร่งครัดหรือยืดวิดีโอ",
"backgroundUnavailable": "ไม่สามารถรักษาเสียงต้นฉบับได้ ตรวจสอบการแยกเสียงพื้นหลังและเวลาบทสนทนาแล้วลองอีกครั้ง หรือเลือกส่งออกเฉพาะเสียงพูด"
}
}
+2 -1
View File
@@ -2835,6 +2835,7 @@
},
"dubIntegrity": {
"missingSpeech": "Konuşma eksik veya okunamıyor. Dışa aktarmadan önce ilgili bölümü yeniden oluşturun.",
"timingOverflow": "Konuşma ayrılan süreyi aşıyor. Çeviriyi kısaltın veya kesin zaman aralığını ya da videoyu uzatmayı seçin."
"timingOverflow": "Konuşma ayrılan süreyi aşıyor. Çeviriyi kısaltın veya kesin zaman aralığını ya da videoyu uzatmayı seçin.",
"backgroundUnavailable": "Özgün ses korunamadı. Arka plan ayrımını ve diyalog zamanlarını kontrol edip yeniden deneyin veya yalnızca konuşmayı dışa aktarmayı seçin."
}
}
+2 -1
View File
@@ -2835,6 +2835,7 @@
},
"dubIntegrity": {
"missingSpeech": "Мовлення відсутнє або не читається. Перед експортом повторно згенеруйте цей сегмент.",
"timingOverflow": "Мовлення перевищує відведений час. Скоротіть переклад або виберіть строгий інтервал чи розтягнення відео."
"timingOverflow": "Мовлення перевищує відведений час. Скоротіть переклад або виберіть строгий інтервал чи розтягнення відео.",
"backgroundUnavailable": "Не вдалося зберегти оригінальний звук. Перевірте відокремлення фону й час реплік, потім повторіть спробу або виберіть експорт лише мовлення."
}
}
+2 -1
View File
@@ -2835,6 +2835,7 @@
},
"dubIntegrity": {
"missingSpeech": "Lời nói bị thiếu hoặc không đọc được. Hãy tạo lại đoạn bị ảnh hưởng trước khi xuất.",
"timingOverflow": "Lời nói vượt quá thời gian được phân bổ. Hãy rút ngắn bản dịch hoặc chọn khung thời gian nghiêm ngặt hay kéo dài video."
"timingOverflow": "Lời nói vượt quá thời gian được phân bổ. Hãy rút ngắn bản dịch hoặc chọn khung thời gian nghiêm ngặt hay kéo dài video.",
"backgroundUnavailable": "Không thể giữ âm thanh gốc. Hãy kiểm tra việc tách âm nền và thời gian hội thoại rồi thử lại, hoặc chọn chỉ xuất giọng nói."
}
}
+2 -1
View File
@@ -3316,6 +3316,7 @@
},
"dubIntegrity": {
"missingSpeech": "语音缺失或无法读取。请在导出前重新生成受影响的片段。",
"timingOverflow": "语音超出分配的时长。请缩短译文,或选择严格时间段或拉伸视频。"
"timingOverflow": "语音超出分配的时长。请缩短译文,或选择严格时间段或拉伸视频。",
"backgroundUnavailable": "无法保留原始声音。请检查背景音分离和对白时间后重试,或明确选择仅导出语音。"
}
}
+2 -1
View File
@@ -2835,6 +2835,7 @@
},
"dubIntegrity": {
"missingSpeech": "語音缺失或無法讀取。請在匯出前重新產生受影響的片段。",
"timingOverflow": "語音超出分配的時長。請縮短譯文,或選擇嚴格時間區段或延展影片。"
"timingOverflow": "語音超出分配的時長。請縮短譯文,或選擇嚴格時間區段或延展影片。",
"backgroundUnavailable": "無法保留原始聲音。請檢查背景音分離和對白時間後重試,或明確選擇僅匯出語音。"
}
}
+1 -1
View File
@@ -552,7 +552,7 @@ export default function DubTab(props) {
// previewMode is 'original' or a dubbed language code (multi-language switcher).
const previewIsDub = previewMode !== 'original' && hasDubbedTrack;
const videoSrc = previewIsDub
? `${API}/dub/preview-video/${dubJobId}?lang=${encodeURIComponent(previewMode)}&preserve_bg=${preserveBg ? 1 : 0}&v=${dubGenNonce}`
? `${API}/dub/preview-video/${dubJobId}?mix=surgical2&lang=${encodeURIComponent(previewMode)}&preserve_bg=${preserveBg ? 1 : 0}&v=${dubGenNonce}`
: `${API}/dub/media/${dubJobId}`;
// The video is the normal transport, but WaveSurfer can fall back to a
// companion audio element when a WebView decodes the picture without its
+7 -1
View File
@@ -5,6 +5,7 @@
// native save dialog instead; calling that dialog when no Tauri runtime is
// present throws "Cannot read properties of undefined (reading 'invoke')"
// (issue #256), so callers must guard on isTauri and route here otherwise.
import i18next from 'i18next';
import { apiFetch } from '../api/client';
/**
@@ -40,7 +41,12 @@ export async function browserDownload(url, fallbackName, deps = {}) {
const urlApi = deps.url ?? globalThis.URL;
const response = await _fetch(url);
if (!response.ok) throw new Error('Download failed');
if (!response.ok) {
const body = typeof response.json === 'function' ? await response.json().catch(() => null) : null;
if (body?.detail?.code === 'dub_background_unavailable')
throw new Error(i18next.t('dubIntegrity.backgroundUnavailable'));
throw new Error('Download failed');
}
const serverName = parseFilenameFromContentDisposition(
response.headers?.get?.('content-disposition'),
+1 -1
View File
@@ -78,7 +78,7 @@ def test_background_mix_preserves_bed_level_and_bandwidth(monkeypatch):
s = _flat(cmd)
assert f"aresample={fu.BED_MIX_SAMPLE_RATE}" in s, "bed bandwidth collapses to the 24kHz voice rate"
assert "normalize=0" in s, "amix normalization not disabled — bed level depends on stream lifetimes"
assert f"volume={fu.BED_GAIN:g}" in s and f"volume={fu.VOICE_GAIN:g}" in s
assert "volume=1[" in s and f"volume={fu.VOICE_GAIN:g}" in s
assert fu.BED_GAIN >= 0.85, "bed gain drifted away from 'almost like the original'"
assert "alimiter" in s # full-scale mixing needs the peak guard
+8 -3
View File
@@ -42,6 +42,11 @@ def app_client(tmp_path, monkeypatch):
import main as _main
importlib.reload(_main)
import services.dub_background as background
async def fake_background(source, separated, *args):
return separated
monkeypatch.setattr(background, "surgical_background", fake_background)
from fastapi.testclient import TestClient
with TestClient(_main.app) as client:
yield client, _dc, _dx, tmp_path
@@ -68,7 +73,7 @@ def _seed_job_with_tracks(dc, tmp_path: Path):
"no_vocals_path": str(bg_wav),
"duration": 1.0,
"filename": "clip.mp4",
"segments": [],
"segments": [{"start": 0.1, "end": 0.8}],
"dubbed_tracks": {"es": {"path": str(track_wav), "language": "Spanish", "language_code": "es"}},
"scene_cuts": [],
}
@@ -127,7 +132,7 @@ class TestDubExportUniqueness:
assert response.headers["accept-ranges"] == "bytes"
assert response.content == b""
run_ffmpeg.assert_not_called()
assert not (job_dir / "exports" / "preview_v2_es_bg.mp4").exists()
assert not (job_dir / "exports" / "preview_v2_es_surgical_v2_no_vocals.mp4").exists()
def test_preview_is_faststart_and_reuses_immutable_cache(self, app_client):
client, dc, dx, tmp = app_client
@@ -152,7 +157,7 @@ class TestDubExportUniqueness:
assert commands[0][commands[0].index("-movflags") + 1] == "+faststart"
assert first.headers["cache-control"] == "private, max-age=31536000, immutable"
assert first.headers["accept-ranges"] == "bytes"
assert (job_dir / "exports" / "preview_v2_es_bg.mp4").is_file()
assert (job_dir / "exports" / "preview_v2_es_surgical_v2_no_vocals.mp4").is_file()
def test_original_only_resolves_stale_default_before_retime(self, app_client):
client, dc, dx, tmp = app_client
+69
View File
@@ -0,0 +1,69 @@
"""Regression: separation must not erase audience reactions between dialogue."""
import asyncio
import numpy as np
import pytest
import soundfile as sf
from services.dub_background import dialogue_intervals, splice_background, surgical_background
def test_original_stereo_samples_survive_outside_dialogue(tmp_path):
sr = 48000
rng = np.random.default_rng(42)
original = rng.uniform(-.4, .4, (sr*3, 2)).astype('float32')
bed = np.full_like(original, .02)
src, bg, out = [str(tmp_path/p) for p in ('src.wav', 'bg.wav', 'out.wav')]
sf.write(src, original, sr, subtype='FLOAT')
sf.write(bg, bed, sr, subtype='FLOAT')
splice_background(src, bg, out, [(1, 2)])
mixed, _ = sf.read(out, dtype='float32')
np.testing.assert_array_equal(mixed[:sr], original[:sr])
np.testing.assert_array_equal(mixed[2*sr:], original[2*sr:])
np.testing.assert_array_equal(mixed[sr+480:2*sr-480], bed[sr+480:2*sr-480])
assert np.isfinite(mixed).all()
def test_overlap_is_one_replacement_region():
assert dialogue_intervals([{'start':2,'end':3},{'start':1,'end':2.5}]) == [(1,3)]
@pytest.mark.parametrize('end', [0, float('nan'), float('inf')])
def test_invalid_timing_is_rejected(end):
with pytest.raises(ValueError):
dialogue_intervals([{'start':0,'end':end}])
def test_incomplete_background_is_not_silently_padded(tmp_path):
src, bg, out = [str(tmp_path/p) for p in ('src.wav', 'bg.wav', 'out.wav')]
sf.write(src, np.ones((48000,2))*.1, 48000)
sf.write(bg, np.ones((100,2))*.02, 48000)
with pytest.raises(ValueError, match='incomplete'):
splice_background(src, bg, out, [(0,1)])
def test_real_ffmpeg_retime_and_cache_invalidation(tmp_path):
sr = 48000
src, bg = [str(tmp_path/p) for p in ('src.wav', 'bg.wav')]
wave = np.ones((sr*3,2), dtype='float32')*.1
sf.write(src, wave, sr, subtype='FLOAT')
sf.write(bg, wave*.2, sr, subtype='FLOAT')
async def run():
segments=[{'start':1,'end':2}]
plain=await surgical_background(src,bg,str(tmp_path),segments,[],3)
assert await surgical_background(src,bg,str(tmp_path),segments,[],3) == plain
changed=await surgical_background(src,bg,str(tmp_path),[{'start':.5,'end':2}],[],3)
assert changed != plain
retimed=await surgical_background(src,bg,str(tmp_path),segments,[{'orig_start':1,'orig_end':2,'stretch_ratio':2}],3)
assert sf.info(retimed).duration == pytest.approx(4, abs=.01)
values,_=sf.read(retimed)
assert values[int(3.5*sr),0] == pytest.approx(.1,abs=.001)
asyncio.run(run())
def test_missing_separation_blocks_preserved_export(monkeypatch):
import api.routers.dub_export as de
from fastapi import HTTPException
monkeypatch.setattr(de, '_optional_dub_artifact', lambda *_: None)
with pytest.raises(HTTPException) as error:
asyncio.run(de._preserved_background({}, 'job', 'bn'))
assert error.value.status_code == 409