From 935b38a9620f8cd8422eba80f88d9ee359880dc2 Mon Sep 17 00:00:00 2001 From: Palash Debnath Date: Fri, 26 Jun 2026 15:16:45 +0530 Subject: [PATCH] fix(dub): pass OmniVoice's ffmpeg to yt-dlp so URL merge works off PATH (#712) (#716) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dubbing a video URL on Windows (v0.3.8) failed with 'You have requested merging of multiple formats but ffmpeg is not installed.' The download format selector pulls separate video+audio streams, so yt-dlp muxes them via ffmpeg (merge_output_format=mp4) — but yt-dlp only checks PATH, while OmniVoice's ffmpeg is typically a bundled Tauri sidecar / imageio-ffmpeg binary that isn't on PATH. yt_download_sync now sets ydl_opts['ffmpeg_location'] = find_ffmpeg() (the same resolver the rest of the dub pipeline uses) when ffmpeg is resolvable; if it isn't, the key is omitted so yt-dlp falls back to PATH as before (no regression). Tests assert the location is passed when resolved and omitted when not. Co-authored-by: mergetest Co-authored-by: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 5 ++ backend/services/dub_pipeline.py | 9 ++++ tests/test_dub_download_ffmpeg_location.py | 61 ++++++++++++++++++++++ 3 files changed, 75 insertions(+) create mode 100644 tests/test_dub_download_ffmpeg_location.py diff --git a/CHANGELOG.md b/CHANGELOG.md index d05b3826..681f7607 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -124,6 +124,11 @@ across dub, generate, and design (a corrupt-binary failure no longer poses as "get a free token" link. (#657, #669) ### Fixed +- **Dubbing a video URL no longer fails with "ffmpeg is not installed."** yt-dlp + downloads video and audio as separate streams and muxes them with ffmpeg, but + it only looked on PATH — so on Windows (where OmniVoice's ffmpeg is a bundled + sidecar / `imageio-ffmpeg` binary off PATH) the merge aborted before the dub + could start. yt-dlp is now pointed at the same ffmpeg OmniVoice resolves. (#712) - **A synth that succeeded no longer 500s because of a history-logging hiccup.** If the local database somehow missed schema init, recording the clip to generation history failed with *"no such table: generation_history"* and diff --git a/backend/services/dub_pipeline.py b/backend/services/dub_pipeline.py index 7edef069..b69da1c0 100644 --- a/backend/services/dub_pipeline.py +++ b/backend/services/dub_pipeline.py @@ -551,6 +551,15 @@ def yt_download_sync( "extractor_retries": 5, "skip_unavailable_fragments": True, } + # #712: the format selector above pulls separate video+audio streams, so + # yt-dlp muxes them via ffmpeg (merge_output_format=mp4). yt-dlp only looks + # for ffmpeg on PATH and aborts with "you have requested merging of multiple + # formats but ffmpeg is not installed" — but OmniVoice's ffmpeg is often a + # bundled Tauri sidecar / imageio-ffmpeg binary that isn't on PATH (common on + # Windows). Point yt-dlp at the exact ffmpeg we resolve so the merge works. + _ffmpeg_bin = find_ffmpeg() + if _ffmpeg_bin: + ydl_opts["ffmpeg_location"] = _ffmpeg_bin if progress_hook is not None: ydl_opts["progress_hooks"] = [progress_hook] diff --git a/tests/test_dub_download_ffmpeg_location.py b/tests/test_dub_download_ffmpeg_location.py new file mode 100644 index 00000000..b18c5e02 --- /dev/null +++ b/tests/test_dub_download_ffmpeg_location.py @@ -0,0 +1,61 @@ +"""yt-dlp must merge video+audio with OmniVoice's ffmpeg, not just PATH (#712). + +The download format selector pulls separate streams, so yt-dlp muxes them via +ffmpeg. yt-dlp only checks PATH and aborts ("you have requested merging of +multiple formats but ffmpeg is not installed") when ffmpeg is a bundled sidecar +/ imageio binary off PATH (common on Windows). yt_download_sync must pass the +resolved ffmpeg as `ffmpeg_location`. +""" +import os +import sys + +import pytest + +sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "backend")) + +from services import dub_pipeline # noqa: E402 + + +class _FakeYDL: + captured: dict = {} + + def __init__(self, opts): + type(self).captured = dict(opts) + + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + def extract_info(self, url, download=True): + raise RuntimeError("stop after capturing opts") + + def prepare_filename(self, info): + return "unused" + + +def test_download_passes_resolved_ffmpeg_location(tmp_path, monkeypatch): + import yt_dlp + + monkeypatch.setattr(dub_pipeline, "find_ffmpeg", lambda: "/opt/omnivoice/ffmpeg") + monkeypatch.setattr(yt_dlp, "YoutubeDL", _FakeYDL) + with pytest.raises(Exception): + dub_pipeline.yt_download_sync("https://youtu.be/abc", str(tmp_path)) + + assert _FakeYDL.captured.get("ffmpeg_location") == "/opt/omnivoice/ffmpeg", ( + "yt-dlp must merge formats with OmniVoice's resolved ffmpeg, not just PATH (#712)" + ) + + +def test_download_omits_ffmpeg_location_when_unresolved(tmp_path, monkeypatch): + # If we can't resolve ffmpeg, don't pin a bogus location — let yt-dlp try + # PATH as before (no regression for users who have ffmpeg on PATH). + import yt_dlp + + monkeypatch.setattr(dub_pipeline, "find_ffmpeg", lambda: None) + monkeypatch.setattr(yt_dlp, "YoutubeDL", _FakeYDL) + with pytest.raises(Exception): + dub_pipeline.yt_download_sync("https://youtu.be/abc", str(tmp_path)) + + assert "ffmpeg_location" not in _FakeYDL.captured