fix(dub): preserve original sound outside dialogue intervals
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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}]"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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 l’exportation.",
|
||||
"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 n’a 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 (
|
||||
|
||||
@@ -2835,6 +2835,7 @@
|
||||
},
|
||||
"dubIntegrity": {
|
||||
"missingSpeech": "الكلام مفقود أو غير قابل للقراءة. أعد توليد المقطع المتأثر قبل التصدير.",
|
||||
"timingOverflow": "يتجاوز الكلام الوقت المخصص له. اختصر الترجمة أو اختر التوقيت الصارم أو تمديد الفيديو."
|
||||
"timingOverflow": "يتجاوز الكلام الوقت المخصص له. اختصر الترجمة أو اختر التوقيت الصارم أو تمديد الفيديو.",
|
||||
"backgroundUnavailable": "تعذّر الحفاظ على الصوت الأصلي. تحقّق من فصل الخلفية وتوقيت الحوار ثم أعد المحاولة، أو صدّر الكلام فقط بشكل صريح."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2835,6 +2835,7 @@
|
||||
},
|
||||
"dubIntegrity": {
|
||||
"missingSpeech": "La parole est absente ou illisible. Régénérez le segment concerné avant l’exportation.",
|
||||
"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 n’a 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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2835,6 +2835,7 @@
|
||||
},
|
||||
"dubIntegrity": {
|
||||
"missingSpeech": "बोली गायब है या पढ़ी नहीं जा सकती। निर्यात से पहले प्रभावित खंड दोबारा बनाएँ।",
|
||||
"timingOverflow": "बोली अपने समय खंड से लंबी है। अनुवाद छोटा करें या सख्त समय खंड अथवा वीडियो खिंचाव चुनें।"
|
||||
"timingOverflow": "बोली अपने समय खंड से लंबी है। अनुवाद छोटा करें या सख्त समय खंड अथवा वीडियो खिंचाव चुनें।",
|
||||
"backgroundUnavailable": "मूल ध्वनि सुरक्षित नहीं रखी जा सकी। पृष्ठभूमि पृथक्करण और संवाद का समय जाँचकर फिर कोशिश करें, या केवल वाणी निर्यात करने का विकल्प चुनें।"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2835,6 +2835,7 @@
|
||||
},
|
||||
"dubIntegrity": {
|
||||
"missingSpeech": "音声がないか読み取れません。書き出す前に該当セグメントを再生成してください。",
|
||||
"timingOverflow": "音声が割り当て時間を超えています。翻訳を短くするか、厳密な時間枠または動画の引き伸ばしを選んでください。"
|
||||
"timingOverflow": "音声が割り当て時間を超えています。翻訳を短くするか、厳密な時間枠または動画の引き伸ばしを選んでください。",
|
||||
"backgroundUnavailable": "元の音声を保持できませんでした。背景音の分離と台詞のタイミングを確認して再試行するか、音声のみの書き出しを選択してください。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3316,6 +3316,7 @@
|
||||
},
|
||||
"dubIntegrity": {
|
||||
"missingSpeech": "음성이 없거나 읽을 수 없습니다. 내보내기 전에 해당 구간을 다시 생성하세요.",
|
||||
"timingOverflow": "음성이 할당된 시간을 초과합니다. 번역을 줄이거나 엄격한 시간 구간 또는 비디오 늘이기를 선택하세요."
|
||||
"timingOverflow": "음성이 할당된 시간을 초과합니다. 번역을 줄이거나 엄격한 시간 구간 또는 비디오 늘이기를 선택하세요.",
|
||||
"backgroundUnavailable": "원본 소리를 보존할 수 없습니다. 배경음 분리와 대사 타이밍을 확인한 후 다시 시도하거나 음성만 내보내기를 선택하세요."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2835,6 +2835,7 @@
|
||||
},
|
||||
"dubIntegrity": {
|
||||
"missingSpeech": "Речь отсутствует или не читается. Перед экспортом заново сгенерируйте этот сегмент.",
|
||||
"timingOverflow": "Речь выходит за отведённое время. Сократите перевод или выберите строгий интервал либо растяжение видео."
|
||||
"timingOverflow": "Речь выходит за отведённое время. Сократите перевод или выберите строгий интервал либо растяжение видео.",
|
||||
"backgroundUnavailable": "Не удалось сохранить исходный звук. Проверьте отделение фона и время реплик, затем повторите попытку или выберите экспорт только речи."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2835,6 +2835,7 @@
|
||||
},
|
||||
"dubIntegrity": {
|
||||
"missingSpeech": "เสียงพูดหายไปหรืออ่านไม่ได้ สร้างช่วงที่มีปัญหาใหม่ก่อนส่งออก",
|
||||
"timingOverflow": "เสียงพูดยาวเกินช่วงเวลาที่กำหนด ย่อคำแปลหรือเลือกช่วงเวลาแบบเคร่งครัดหรือยืดวิดีโอ"
|
||||
"timingOverflow": "เสียงพูดยาวเกินช่วงเวลาที่กำหนด ย่อคำแปลหรือเลือกช่วงเวลาแบบเคร่งครัดหรือยืดวิดีโอ",
|
||||
"backgroundUnavailable": "ไม่สามารถรักษาเสียงต้นฉบับได้ ตรวจสอบการแยกเสียงพื้นหลังและเวลาบทสนทนาแล้วลองอีกครั้ง หรือเลือกส่งออกเฉพาะเสียงพูด"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2835,6 +2835,7 @@
|
||||
},
|
||||
"dubIntegrity": {
|
||||
"missingSpeech": "Мовлення відсутнє або не читається. Перед експортом повторно згенеруйте цей сегмент.",
|
||||
"timingOverflow": "Мовлення перевищує відведений час. Скоротіть переклад або виберіть строгий інтервал чи розтягнення відео."
|
||||
"timingOverflow": "Мовлення перевищує відведений час. Скоротіть переклад або виберіть строгий інтервал чи розтягнення відео.",
|
||||
"backgroundUnavailable": "Не вдалося зберегти оригінальний звук. Перевірте відокремлення фону й час реплік, потім повторіть спробу або виберіть експорт лише мовлення."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3316,6 +3316,7 @@
|
||||
},
|
||||
"dubIntegrity": {
|
||||
"missingSpeech": "语音缺失或无法读取。请在导出前重新生成受影响的片段。",
|
||||
"timingOverflow": "语音超出分配的时长。请缩短译文,或选择严格时间段或拉伸视频。"
|
||||
"timingOverflow": "语音超出分配的时长。请缩短译文,或选择严格时间段或拉伸视频。",
|
||||
"backgroundUnavailable": "无法保留原始声音。请检查背景音分离和对白时间后重试,或明确选择仅导出语音。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2835,6 +2835,7 @@
|
||||
},
|
||||
"dubIntegrity": {
|
||||
"missingSpeech": "語音缺失或無法讀取。請在匯出前重新產生受影響的片段。",
|
||||
"timingOverflow": "語音超出分配的時長。請縮短譯文,或選擇嚴格時間區段或延展影片。"
|
||||
"timingOverflow": "語音超出分配的時長。請縮短譯文,或選擇嚴格時間區段或延展影片。",
|
||||
"backgroundUnavailable": "無法保留原始聲音。請檢查背景音分離和對白時間後重試,或明確選擇僅匯出語音。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user