From 7e7d971e616c40dd29506a89c5801cd7b07672b3 Mon Sep 17 00:00:00 2001 From: Spencer Qian Date: Sun, 13 Sep 2026 22:43:59 -0700 Subject: [PATCH 1/7] feat: dubbing subtitles, export_srt and lipsync on the client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `POST /v1/dubbing` takes a target-language script per language as the repeatable multipart field `subtitles[]` (an https URL or an uploaded .srt/.vtt), and `export_srt` returns a re-timed .srt per language once the dub is delivered. `lipsync` has existed on the endpoint all along and never reached this SDK. `build_dubbing_parts` now returns a ready-made `close_after` as its third element instead of an `opened` bool, the shape `build_ducking_parts` already uses. One request can open the video plus one script per target language, and a bool cannot say how many handles that is — nor which of them this builder opened. Every handle is closed on the way out if a later open fails, and the caller closes the rest through the `close_after` seam `_post_json` already exposes, so the routine 422 from a rejected script set does not leak them. Client-side checks are limited to the two guaranteed 422s: a script path whose suffix is not .srt/.vtt, and `export_srt` with no scripts. The language set is not checked against `languages` — the server owns that rule, its message names the offending code, and a local copy would break a caller who relies on the server default without passing `languages` at all. `DubbingResult` gains `subtitles` (with save_subtitle / save_all_subtitles mirroring save / save_all), `subtitle_preflight` and `subtitle_export`. The two report maps are coerced but their values are left as they arrived: the pipeline stores numbers as strings, so `cue_count` may be "5" and `alignment_loss` "0.63", and guessing at a type here would only mislead. A blocked export does not fail the task, so `subtitles` can lack a language whose video was still delivered — save_all_subtitles iterates `subtitles`, not `outputs`. `lipsync` defaults ON server-side and every dubbing task ran that way before the field existed, so it is sent only when the caller passes it: absent has to keep meaning true. Claude-Session: https://claude.ai/code/session_01WWHfnbRaXRdAH2sjm8mApk --- src/sonilo/_requests.py | 108 ++++++++++++++++--- src/sonilo/resources/dubbing.py | 46 ++++++-- src/sonilo/resources/tasks.py | 40 +++++-- src/sonilo/types.py | 83 ++++++++++++++ tests/test_dubbing.py | 186 ++++++++++++++++++++++++++++++++ tests/test_requests.py | 116 +++++++++++++++++++- 6 files changed, 540 insertions(+), 39 deletions(-) diff --git a/src/sonilo/_requests.py b/src/sonilo/_requests.py index d36e58a..412b098 100644 --- a/src/sonilo/_requests.py +++ b/src/sonilo/_requests.py @@ -3,7 +3,7 @@ import json import mimetypes from pathlib import Path -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Dict, List, Optional, Tuple, Union from sonilo.errors import SoniloError from sonilo.types import Segment, SfxSegment @@ -169,30 +169,78 @@ def build_ducking_parts( return data, files or None, MultiClose(opened) if opened else None +SUBTITLE_SUFFIXES = (".srt", ".vtt") + + +def _is_subtitle_url(value: Any) -> bool: + """A subtitle value is a plain string: https:// means "fetch it", anything + else is a local path. Unambiguous, because a real path cannot start with + `https://` — the same rule `video` already follows for path strings.""" + return isinstance(value, str) and value.lower().startswith("https://") + + +def _subtitle_filename(value: Union[str, Path]) -> str: + """Validate one local subtitle path and return the basename to send. + + The suffix check is local because it is a guaranteed server-side 422: the + part's filename is what the backend uses to pick the parser. The 1 MiB cap + and the "one script per requested language" rule are deliberately NOT + checked here — the server owns both, and a copy here would drift.""" + path = Path(value) + if path.suffix.lower() not in SUBTITLE_SUFFIXES: + raise SoniloError( + f"Subtitle file {path.name!r} must be .srt or .vtt" + ) + return path.name or "subtitles.srt" + + def build_dubbing_parts( video: Any, video_url: Optional[str], languages: Optional[List[str]], ducking: Optional[bool] = None, -) -> Tuple[Dict[str, str], Optional[Dict[str, tuple]], bool]: + subtitles: Optional[Dict[str, Union[str, Path]]] = None, + export_srt: Optional[bool] = None, + lipsync: Optional[bool] = None, +) -> Tuple[Dict[str, str], Optional[Dict[str, tuple]], Optional[MultiClose]]: """Build the multipart parts for POST /v1/dubbing. + Like build_ducking_parts — and unlike the single-input builders — this + returns a ready-made `close_after` (or None) as its third element instead + of an `opened` bool: with `subtitles` there is no bound on how many local + files one request opens (one per target language, plus the video), and a + single bool cannot say which of them this builder opened. Every handle + opened here is closed on the way out if anything later fails, and the + caller closes the rest through `close_after` once the request is done. + `languages` travels as one opaque form field holding a JSON array string — that is the shape the backend parses. It is omitted entirely when unset so the server default (["zh_cn", "es", "fr"]) applies; an empty array would be rejected as a malformed payload instead. - The https check is local because it is a guaranteed server-side 422: the - dubbing pipeline fetches the source URL itself and requires https - specifically, unlike the fal-backed endpoints, which also accept plain - http. Language codes are deliberately NOT checked here — the backend owns - that list, and a hardcoded copy would make this SDK reject codes added - later. + `subtitles` maps each target language to the script to speak in it: an + https URL string, or a local .srt/.vtt path sent as the part + `subtitles[]`. The language set is NOT checked against + `languages` here — the server owns that rule, its message is better, and a + local copy would break a caller who relies on the server default without + passing `languages` at all. Language codes are deliberately NOT checked + either — the backend owns that list, and a hardcoded copy would make this + SDK reject codes added later. + + The https check on `video_url` is local because it is a guaranteed + server-side 422: the dubbing pipeline fetches the source URL itself and + requires https specifically, unlike the fal-backed endpoints, which also + accept plain http. """ if (video is None) == (video_url is None): raise SoniloError("Provide exactly one of video or video_url") + # A guaranteed 422 otherwise: there is nothing to align the audio against. + if export_srt and not subtitles: + raise SoniloError("export_srt requires subtitles (one script per target language)") - # Assemble data dict completely before opening any files + # Assemble data dict completely before opening any files. Subtitle paths + # are validated in this pass too, so a bad suffix on the last language + # cannot leak the handles the earlier ones opened. data: Dict[str, str] = {} if video_url is not None: if not video_url.lower().startswith("https://"): @@ -206,15 +254,41 @@ def build_dubbing_parts( # when unset so the server default applies. if ducking is not None: data["ducking"] = "true" if ducking else "false" + # lipsync is the opposite — default ON, and every dubbing task ran that way + # before the field existed — so absent MUST keep meaning true. Sending it + # only when the caller passed it is what preserves that. + if lipsync is not None: + data["lipsync"] = "true" if lipsync else "false" + if export_srt is not None: + data["export_srt"] = "true" if export_srt else "false" + local_subtitles: List[Tuple[str, str, Union[str, Path]]] = [] + for language, value in (subtitles or {}).items(): + if _is_subtitle_url(value): + data["subtitles[{}]".format(language)] = value # type: ignore[assignment] + else: + local_subtitles.append((language, _subtitle_filename(value), value)) + + # Now open files (only after data is fully assembled). If any open raises, + # everything opened before it is closed on the way out. + files: Dict[str, tuple] = {} + opened: List[Any] = [] + try: + if video is not None: + filename, fileobj, was_opened = normalize_video(video) + if was_opened: + opened.append(fileobj) + files["video"] = (filename, fileobj, "video/mp4") + for language, filename, source in local_subtitles: + fileobj = Path(source).open("rb") + opened.append(fileobj) + content_type = mimetypes.guess_type(filename)[0] or "application/octet-stream" + files["subtitles[{}]".format(language)] = (filename, fileobj, content_type) + except BaseException: + for fileobj in opened: + fileobj.close() + raise - # Now open files (only after data is fully assembled) - files: Optional[Dict[str, tuple]] = None - opened = False - if video is not None: - filename, fileobj, opened = normalize_video(video) - files = {"video": (filename, fileobj, "video/mp4")} - - return data, files, opened + return data, files or None, MultiClose(opened) if opened else None def build_video_analysis_parts( diff --git a/src/sonilo/resources/dubbing.py b/src/sonilo/resources/dubbing.py index d5b5adf..cd64635 100644 --- a/src/sonilo/resources/dubbing.py +++ b/src/sonilo/resources/dubbing.py @@ -1,6 +1,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, List, Optional +from pathlib import Path +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union from sonilo._requests import build_dubbing_parts from sonilo.resources.tasks import ( @@ -25,7 +26,18 @@ class Dubbing: `ducking` (default off, free) ducks the background music/effects bed under the dubbed voice while it speaks; when off the bed is kept at a constant level. Every endpoint's `ducking` defaults off, so this one is - no exception.""" + no exception. + + `lipsync` (default ON) re-renders the speaker's mouth to match the dubbed + audio. Pass False to keep the source's own frames, resolution and frame + rate and replace only the audio. + + `subtitles` maps each target language to the script to speak in it — an + https URL or a local .srt/.vtt path. These are TARGET-language scripts, + one per language being dubbed into, not source transcripts. With + `export_srt=True` each language's delivered audio is force-aligned against + its script and a re-timed .srt comes back under `DubbingResult.subtitles`, + keeping the lines verbatim.""" def __init__(self, client: "Sonilo") -> None: self._client = client @@ -37,9 +49,14 @@ def submit( video_url: Optional[str] = None, languages: Optional[List[str]] = None, ducking: Optional[bool] = None, + subtitles: Optional[Dict[str, Union[str, Path]]] = None, + export_srt: Optional[bool] = None, + lipsync: Optional[bool] = None, ) -> SfxTask: - data, files, opened = build_dubbing_parts(video, video_url, languages, ducking) - close_after = files["video"][1] if files is not None and opened else None + data, files, close_after = build_dubbing_parts( + video, video_url, languages, ducking, + subtitles=subtitles, export_srt=export_srt, lipsync=lipsync, + ) return parse_sfx_task( self._client._post_json(PATH, data=data, files=files, close_after=close_after) ) @@ -51,11 +68,15 @@ def generate( video_url: Optional[str] = None, languages: Optional[List[str]] = None, ducking: Optional[bool] = None, + subtitles: Optional[Dict[str, Union[str, Path]]] = None, + export_srt: Optional[bool] = None, + lipsync: Optional[bool] = None, poll_interval: float = DEFAULT_POLL_INTERVAL, timeout: float = DEFAULT_WAIT_TIMEOUT, ) -> DubbingResult: task = self.submit( - video=video, video_url=video_url, languages=languages, ducking=ducking + video=video, video_url=video_url, languages=languages, ducking=ducking, + subtitles=subtitles, export_srt=export_srt, lipsync=lipsync, ) return self._client.tasks.wait( task.task_id, @@ -76,9 +97,14 @@ async def submit( video_url: Optional[str] = None, languages: Optional[List[str]] = None, ducking: Optional[bool] = None, + subtitles: Optional[Dict[str, Union[str, Path]]] = None, + export_srt: Optional[bool] = None, + lipsync: Optional[bool] = None, ) -> SfxTask: - data, files, opened = build_dubbing_parts(video, video_url, languages, ducking) - close_after = files["video"][1] if files is not None and opened else None + data, files, close_after = build_dubbing_parts( + video, video_url, languages, ducking, + subtitles=subtitles, export_srt=export_srt, lipsync=lipsync, + ) return parse_sfx_task( await self._client._post_json( PATH, data=data, files=files, close_after=close_after @@ -92,11 +118,15 @@ async def generate( video_url: Optional[str] = None, languages: Optional[List[str]] = None, ducking: Optional[bool] = None, + subtitles: Optional[Dict[str, Union[str, Path]]] = None, + export_srt: Optional[bool] = None, + lipsync: Optional[bool] = None, poll_interval: float = DEFAULT_POLL_INTERVAL, timeout: float = DEFAULT_WAIT_TIMEOUT, ) -> DubbingResult: task = await self.submit( - video=video, video_url=video_url, languages=languages, ducking=ducking + video=video, video_url=video_url, languages=languages, ducking=ducking, + subtitles=subtitles, export_srt=export_srt, lipsync=lipsync, ) return await self._client.tasks.wait( task.task_id, diff --git a/src/sonilo/resources/tasks.py b/src/sonilo/resources/tasks.py index e2922ce..089f81e 100644 --- a/src/sonilo/resources/tasks.py +++ b/src/sonilo/resources/tasks.py @@ -236,26 +236,46 @@ def parse_sound_result(body: Dict[str, Any]) -> "SoundResult": raise SoniloError(f"Malformed task response: missing {e.args[0]!r}") from e +def _url_map_from(data: Any) -> Dict[str, str]: + """Coerce a language → URL map key-by-key rather than passing it through: + a non-dict or a non-string value from a backend change would otherwise + surface as a confusing AttributeError deep inside save(), long after the + parse.""" + if not isinstance(data, dict): + return {} + return {str(k): str(v) for k, v in data.items()} + + +def _report_map_from(data: Any) -> Dict[str, Dict[str, Any]]: + """Same defensiveness for the subtitle preflight/export reports, which are + maps of language → report object. Entry VALUES are left exactly as they + arrived — the pipeline's store returns numbers as strings, so `cue_count` + may be "5" and `alignment_loss` "0.63", and coercing them here would only + guess at which of the two a caller wanted. Malformed entries (anything + that is not an object) are dropped.""" + if not isinstance(data, dict): + return {} + return {str(k): v for k, v in data.items() if isinstance(v, dict)} + + def parse_dubbing_result(body: Dict[str, Any]) -> "DubbingResult": """Map a GET /v1/tasks/{id} body for a dubbing task to DubbingResult; unknown fields are ignored. - `outputs` is coerced key-by-key rather than passed through: a non-dict or - a non-string value from a backend change would otherwise surface as a - confusing AttributeError deep inside save(), long after the parse. + The four language-keyed maps are all coerced rather than passed through — + see _url_map_from / _report_map_from. `subtitles` and the two reports are + only present when the task was submitted with scripts, and any of them may + be missing a language whose export was blocked. """ - outputs = body.get("outputs") - coerced = ( - {str(k): str(v) for k, v in outputs.items()} - if isinstance(outputs, dict) - else {} - ) try: return DubbingResult( task_id=body["task_id"], status=body["status"], type=body.get("type"), - outputs=coerced, + outputs=_url_map_from(body.get("outputs")), + subtitles=_url_map_from(body.get("subtitles")), + subtitle_preflight=_report_map_from(body.get("subtitle_preflight")), + subtitle_export=_report_map_from(body.get("subtitle_export")), duration_seconds=body.get("duration_seconds"), cost=body.get("cost"), error=body.get("error"), diff --git a/src/sonilo/types.py b/src/sonilo/types.py index 50dd5e5..722e753 100644 --- a/src/sonilo/types.py +++ b/src/sonilo/types.py @@ -598,12 +598,26 @@ class DubbingResult: language code to presigned `.mp4` URL rather than an audio/video media object. `save` therefore takes the language to fetch, and `save_all` is the convenience for pulling every one of them down at once. + + When the task was submitted with scripts and `export_srt=True`, three more + maps come back, all keyed by language: `subtitles` (presigned re-timed + `.srt` URLs, with `save_subtitle`/`save_all_subtitles` mirroring + `save`/`save_all`), `subtitle_preflight` (what the check made of each + script before anything was charged) and `subtitle_export` (`status`, + `alignment_loss`, `issues`, `error`, `report_url` per language). A blocked + export does not fail the task — the videos are still delivered and + `subtitles` simply lacks that language. The report values come back as + JSON of whatever type the pipeline stored, so a count or a loss may be a + string rather than a number; read them defensively. """ task_id: str status: str type: Optional[str] = None outputs: Dict[str, str] = field(default_factory=dict) + subtitles: Dict[str, str] = field(default_factory=dict) + subtitle_preflight: Dict[str, Dict[str, Any]] = field(default_factory=dict) + subtitle_export: Dict[str, Dict[str, Any]] = field(default_factory=dict) duration_seconds: Optional[float] = None cost: Optional[float] = None error: Optional[Dict[str, Any]] = None @@ -675,6 +689,75 @@ async def asave_all( for language in sorted(self.outputs) } + def _subtitle_url(self, language: str) -> str: + if language not in self.subtitles: + available = ", ".join(sorted(self.subtitles)) or "none" + raise SoniloError( + f"No subtitle for language {language!r} on this result " + f"(status={self.status}; available: {available}). A language is " + "missing here when export_srt was not requested or when its " + f"export was blocked — see subtitle_export." + ) + return self.subtitles[language] + + def save_subtitle( + self, + language: str, + path: Union[str, Path], + *, + timeout: float = DOWNLOAD_TIMEOUT, + ) -> Path: + """Download one language's re-timed `.srt` to `path` and return it. The + URL is presigned — no API key is sent.""" + return _download_to(self._subtitle_url(language), path, timeout) + + async def asave_subtitle( + self, + language: str, + path: Union[str, Path], + *, + timeout: float = DOWNLOAD_TIMEOUT, + ) -> Path: + """Async variant of save_subtitle().""" + return await _adownload_to(self._subtitle_url(language), path, timeout) + + def save_all_subtitles( + self, + directory: Union[str, Path], + *, + prefix: str = "dubbed", + timeout: float = DOWNLOAD_TIMEOUT, + ) -> Dict[str, Path]: + """Download every returned subtitle into `directory` as + `{prefix}.{language}.srt`, returning the language → path map. Iterates + `subtitles`, not `outputs`: a language whose export was blocked still + has a video, so keying off the videos would raise on it.""" + target = Path(directory) + target.mkdir(parents=True, exist_ok=True) + return { + language: self.save_subtitle( + language, target / f"{prefix}.{language}.srt", timeout=timeout + ) + for language in sorted(self.subtitles) + } + + async def asave_all_subtitles( + self, + directory: Union[str, Path], + *, + prefix: str = "dubbed", + timeout: float = DOWNLOAD_TIMEOUT, + ) -> Dict[str, Path]: + """Async variant of save_all_subtitles().""" + target = Path(directory) + target.mkdir(parents=True, exist_ok=True) + return { + language: await self.asave_subtitle( + language, target / f"{prefix}.{language}.srt", timeout=timeout + ) + for language in sorted(self.subtitles) + } + @dataclass class AnalysisSegment: diff --git a/tests/test_dubbing.py b/tests/test_dubbing.py index ddd5706..840107d 100644 --- a/tests/test_dubbing.py +++ b/tests/test_dubbing.py @@ -1,3 +1,4 @@ +from pathlib import Path from urllib.parse import unquote_plus import httpx @@ -157,3 +158,188 @@ async def test_async_generate_polls_to_a_dubbing_result(): video_url="https://x/v.mp4", poll_interval=0 ) assert result.outputs["fr"] == "https://r2/fr.mp4" + + +# --- subtitles, export_srt and lipsync ------------------------------------- + +SUBTITLED_BODY = { + "task_id": "db1", + "type": "dubbing", + "status": "succeeded", + "outputs": {"es": "https://r2/es.mp4", "fr": "https://r2/fr.mp4"}, + # fr's export was blocked: it still has a video, just no subtitle. + "subtitles": {"es": "https://r2/es.srt"}, + # The pipeline's store returns numbers as strings, so a client that + # surfaces these has to tolerate both shapes — hence "5" next to 4. + "subtitle_preflight": { + "es": {"status": "ok", "cue_count": "5", "issues": [], "changes_count": 0}, + "fr": {"status": "ok", "cue_count": 4, "issues": [], "changes_count": 0}, + }, + "subtitle_export": { + "es": {"status": "exported", "alignment_loss": "0.6305176995017312"}, + "fr": {"status": "blocked", "error": "alignment failed"}, + }, +} + + +def test_parse_dubbing_result_reads_the_subtitle_maps(): + result = parse_dubbing_result(SUBTITLED_BODY) + assert result.subtitles == {"es": "https://r2/es.srt"} + assert result.subtitle_preflight["es"]["cue_count"] == "5" + assert result.subtitle_preflight["fr"]["cue_count"] == 4 + assert result.subtitle_export["fr"]["status"] == "blocked" + + +def test_parse_dubbing_result_defaults_the_subtitle_maps_to_empty(): + result = parse_dubbing_result(SUCCESS_BODY) + assert result.subtitles == {} + assert result.subtitle_preflight == {} + assert result.subtitle_export == {} + + +def test_parse_dubbing_result_drops_malformed_report_entries(): + result = parse_dubbing_result({ + "task_id": "db1", "status": "succeeded", + "subtitles": "nope", + "subtitle_export": {"es": "not-a-report", "fr": {"status": "exported"}}, + }) + assert result.subtitles == {} + assert result.subtitle_export == {"fr": {"status": "exported"}} + + +@respx.mock +def test_save_subtitle_downloads_one_language(tmp_path): + respx.get("https://r2/es.srt").mock( + return_value=httpx.Response(200, content=b"1\nhola\n") + ) + result = parse_dubbing_result(SUBTITLED_BODY) + path = result.save_subtitle("es", tmp_path / "clip.es.srt") + assert path.read_bytes() == b"1\nhola\n" + + +def test_save_subtitle_rejects_a_language_whose_export_was_blocked(tmp_path): + result = parse_dubbing_result(SUBTITLED_BODY) + with pytest.raises(SoniloError): + result.save_subtitle("fr", tmp_path / "clip.fr.srt") + + +@respx.mock +def test_save_all_subtitles_skips_languages_without_one(tmp_path): + respx.get("https://r2/es.srt").mock( + return_value=httpx.Response(200, content=b"1\nhola\n") + ) + result = parse_dubbing_result(SUBTITLED_BODY) + paths = result.save_all_subtitles(tmp_path / "out") + assert set(paths) == {"es"} + assert (tmp_path / "out" / "dubbed.es.srt").read_bytes() == b"1\nhola\n" + + +@respx.mock +async def test_asave_subtitle_downloads_one_language(tmp_path): + respx.get("https://r2/es.srt").mock( + return_value=httpx.Response(200, content=b"1\nhola\n") + ) + result = parse_dubbing_result(SUBTITLED_BODY) + path = await result.asave_subtitle("es", tmp_path / "clip.es.srt") + assert path.read_bytes() == b"1\nhola\n" + + +@respx.mock +def test_submit_sends_subtitle_urls_as_bracket_keyed_fields(): + route = respx.post("https://api.sonilo.com/v1/dubbing").mock( + return_value=httpx.Response(202, json=ACK) + ) + with Sonilo(api_key="sk-test") as client: + client.dubbing.submit( + video_url="https://x/v.mp4", + languages=["es", "fr"], + subtitles={"es": "https://x/es.srt", "fr": "https://x/fr.vtt"}, + export_srt=True, + ) + sent = unquote_plus(route.calls.last.request.content.decode()) + assert "subtitles[es]=https://x/es.srt" in sent + assert "subtitles[fr]=https://x/fr.vtt" in sent + assert "export_srt=true" in sent + + +@respx.mock +def test_submit_uploads_local_scripts_as_file_parts(tmp_path): + route = respx.post("https://api.sonilo.com/v1/dubbing").mock( + return_value=httpx.Response(202, json=ACK) + ) + script = tmp_path / "spanish.srt" + script.write_text("1\n00:00:00,000 --> 00:00:01,000\nhola\n") + with Sonilo(api_key="sk-test") as client: + client.dubbing.submit( + video_url="https://x/v.mp4", languages=["es"], + subtitles={"es": str(script)}, export_srt=True, + ) + body = route.calls.last.request.content.decode(errors="replace") + assert 'name="subtitles[es]"' in body + assert 'filename="spanish.srt"' in body + + +@respx.mock +def test_submit_closes_the_scripts_even_when_the_api_rejects_them(tmp_path, monkeypatch): + """The 422 path is the one that leaks: preflight rejects a script set + routinely, and those handles have to be closed all the same.""" + respx.post("https://api.sonilo.com/v1/dubbing").mock( + return_value=httpx.Response(422, json={"error": {"code": "SUBTITLE_PREFLIGHT_BLOCKED"}}) + ) + script = tmp_path / "spanish.srt" + script.write_text("1\n") + opened = [] + real_open = Path.open + + def spy(self, *args, **kwargs): + handle = real_open(self, *args, **kwargs) + opened.append(handle) + return handle + + monkeypatch.setattr(Path, "open", spy) + with Sonilo(api_key="sk-test") as client: + with pytest.raises(SoniloError): + client.dubbing.submit( + video_url="https://x/v.mp4", languages=["es"], + subtitles={"es": str(script)}, + ) + assert opened and all(handle.closed for handle in opened) + + +@respx.mock +def test_submit_rejects_export_srt_without_subtitles_before_sending(): + route = respx.post("https://api.sonilo.com/v1/dubbing") + with Sonilo(api_key="sk-test") as client: + with pytest.raises(SoniloError): + client.dubbing.submit(video_url="https://x/v.mp4", export_srt=True) + assert not route.called + + +@respx.mock +def test_submit_sends_lipsync_only_when_set(): + route = respx.post("https://api.sonilo.com/v1/dubbing").mock( + return_value=httpx.Response(202, json=ACK) + ) + with Sonilo(api_key="sk-test") as client: + client.dubbing.submit(video_url="https://x/v.mp4", lipsync=False) + client.dubbing.submit(video_url="https://x/v.mp4") + bodies = [unquote_plus(c.request.content.decode()) for c in route.calls] + assert "lipsync=false" in bodies[0] + # Unset → omitted, so the server default (on) applies. + assert "lipsync" not in bodies[1] + + +@respx.mock +async def test_async_submit_sends_the_subtitle_fields(): + route = respx.post("https://api.sonilo.com/v1/dubbing").mock( + return_value=httpx.Response(202, json=ACK) + ) + async with AsyncSonilo(api_key="sk-test") as client: + await client.dubbing.submit( + video_url="https://x/v.mp4", languages=["es"], + subtitles={"es": "https://x/es.srt"}, export_srt=True, lipsync=True, + ) + sent = unquote_plus(route.calls.last.request.content.decode()) + assert "subtitles[es]=https://x/es.srt" in sent + assert "export_srt=true" in sent + assert "lipsync=true" in sent diff --git a/tests/test_requests.py b/tests/test_requests.py index 7d8e0e0..50ca5f5 100644 --- a/tests/test_requests.py +++ b/tests/test_requests.py @@ -126,10 +126,12 @@ def test_v2v_sfx_parts_serializes_segments(): def test_build_dubbing_parts_encodes_languages_as_a_json_array(): - data, files, opened = build_dubbing_parts( + data, files, close_after = build_dubbing_parts( None, "https://x/v.mp4", ["es", "fr"] ) - assert files is None and opened is False + # Third element is a ready-made close_after (or None), not an `opened` + # bool: subtitles can open one file per language on top of the video. + assert files is None and close_after is None assert data["video_url"] == "https://x/v.mp4" assert json.loads(data["languages"]) == ["es", "fr"] @@ -152,11 +154,12 @@ def test_build_dubbing_parts_rejects_a_non_https_url(): def test_build_dubbing_parts_uploads_bytes_as_the_video_part(): - data, files, opened = build_dubbing_parts(b"bytes", None, ["ja"]) + data, files, close_after = build_dubbing_parts(b"bytes", None, ["ja"]) assert "video_url" not in data assert json.loads(data["languages"]) == ["ja"] assert files is not None and files["video"][1] == b"bytes" - assert opened is False + # Nothing was opened here — the caller handed us the bytes. + assert close_after is None def test_build_dubbing_parts_passes_unknown_codes_through(): @@ -226,3 +229,108 @@ def test_build_v2s_parts_forwards_variants_num(): def test_build_v2s_parts_omits_variants_num_when_unset(): data, _, _ = build_v2s_parts(None, "https://x/v.mp4", None, None, None, None, None) assert "variants_num" not in data + + +# --- dubbing subtitles ----------------------------------------------------- +# +# The builder returns a ready-made `close_after` here (see build_ducking_parts +# for the same shape): one request can open the video plus one script per +# target language, and a bool cannot say how many handles that is. + + +def test_build_dubbing_parts_sends_an_https_subtitle_as_a_text_field(): + data, files, close_after = build_dubbing_parts( + None, "https://x/v.mp4", ["es"], subtitles={"es": "https://x/es.srt"} + ) + assert data["subtitles[es]"] == "https://x/es.srt" + assert files is None and close_after is None + + +def test_build_dubbing_parts_uploads_a_local_subtitle_with_its_basename(tmp_path): + script = tmp_path / "spanish.srt" + script.write_text("1\n00:00:00,000 --> 00:00:01,000\nhola\n") + data, files, close_after = build_dubbing_parts( + None, "https://x/v.mp4", ["es"], subtitles={"es": str(script)} + ) + assert "subtitles[es]" not in data + assert files is not None + filename, fileobj, _ = files["subtitles[es]"] + assert filename == "spanish.srt" + assert not fileobj.closed + close_after.close() + assert fileobj.closed + + +def test_build_dubbing_parts_closes_the_video_and_every_script(tmp_path): + video = tmp_path / "clip.mp4" + video.write_bytes(b"mp4") + es = tmp_path / "es.vtt" + es.write_text("WEBVTT\n") + fr = tmp_path / "fr.srt" + fr.write_text("1\n") + _, files, close_after = build_dubbing_parts( + str(video), None, ["es", "fr"], subtitles={"es": es, "fr": str(fr)} + ) + handles = [files[key][1] for key in ("video", "subtitles[es]", "subtitles[fr]")] + assert all(not handle.closed for handle in handles) + close_after.close() + assert all(handle.closed for handle in handles) + + +def test_build_dubbing_parts_closes_handles_when_a_later_open_fails(tmp_path, monkeypatch): + """A leaked handle is invisible until the process runs out of descriptors, + so the error path is tested the only way it can be: by watching every open.""" + from pathlib import Path + + good = tmp_path / "es.srt" + good.write_text("1\n") + opened = [] + real_open = Path.open + + def spy(self, *args, **kwargs): + handle = real_open(self, *args, **kwargs) + opened.append(handle) + return handle + + monkeypatch.setattr(Path, "open", spy) + with pytest.raises(OSError): + build_dubbing_parts( + None, + "https://x/v.mp4", + ["es", "fr"], + subtitles={"es": str(good), "fr": str(tmp_path / "missing.srt")}, + ) + assert opened and all(handle.closed for handle in opened) + + +def test_build_dubbing_parts_rejects_a_subtitle_that_is_not_srt_or_vtt(tmp_path): + script = tmp_path / "es.txt" + script.write_text("hola") + with pytest.raises(SoniloError) as exc: + build_dubbing_parts(None, "https://x/v.mp4", ["es"], subtitles={"es": str(script)}) + assert "es.txt" in str(exc.value) + + +def test_build_dubbing_parts_rejects_export_srt_without_subtitles(): + with pytest.raises(SoniloError): + build_dubbing_parts(None, "https://x/v.mp4", ["es"], export_srt=True) + + +def test_build_dubbing_parts_sends_export_srt_with_subtitles(): + data, _, _ = build_dubbing_parts( + None, "https://x/v.mp4", ["es"], + subtitles={"es": "https://x/es.srt"}, export_srt=True, + ) + assert data["export_srt"] == "true" + + +def test_build_dubbing_parts_omits_lipsync_when_unset(): + # Absent must keep meaning true — that is what every dubbing task did + # before the field existed. + data, _, _ = build_dubbing_parts(None, "https://x/v.mp4", None) + assert "lipsync" not in data + + +def test_build_dubbing_parts_sends_lipsync_when_set(): + data, _, _ = build_dubbing_parts(None, "https://x/v.mp4", None, lipsync=False) + assert data["lipsync"] == "false" From 33279ac42d627f4253e25a263c254838d417f5c0 Mon Sep 17 00:00:00 2001 From: Spencer Qian Date: Sun, 13 Sep 2026 22:45:07 -0700 Subject: [PATCH 2/7] feat(cli): --subtitle and --export-srt on `sonilo dubbing` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `--subtitle =`, repeated once per target language, carries the script to speak in it; `--export-srt` asks for a re-timed .srt back. The `=` is split once only so a path or URL containing one survives. Neither the codes nor the language set are checked here — the server owns both rules and its message names what it refused; the SDK still refuses a suffix that is not .srt/.vtt, which is a guaranteed 422. Each .srt is written beside its video (clip.es.mp4 -> clip.es.srt) and every requested language gets a status line, including a language whose export was blocked: that still delivers the video, just without a script. The alignment loss is formatted through float() because the pipeline stores numbers as strings — "0.63" and 0.63 both have to print — and is omitted when it is neither. Claude-Session: https://claude.ai/code/session_01WWHfnbRaXRdAH2sjm8mApk --- sonilo-cli/src/sonilo_cli/__main__.py | 67 +++++++++++ sonilo-cli/tests/test_cli.py | 164 ++++++++++++++++++++++++++ 2 files changed, 231 insertions(+) diff --git a/sonilo-cli/src/sonilo_cli/__main__.py b/sonilo-cli/src/sonilo_cli/__main__.py index e42ff6b..35187d0 100644 --- a/sonilo-cli/src/sonilo_cli/__main__.py +++ b/sonilo-cli/src/sonilo_cli/__main__.py @@ -624,6 +624,44 @@ def _language_path(out: str, language: str) -> str: return str(base.with_name(f"{base.stem}.{language}{base.suffix or '.mp4'}")) +def _subtitles(values: Optional[List[str]]) -> Optional[Dict[str, str]]: + """Turn repeated `--subtitle =` values into the map the + SDK takes. The `=` is split once only, so a Windows path or a URL with its + own `=` survives. Neither the codes nor the language set are checked here — + the server owns both rules and names what it refused.""" + if not values: + return None + subtitles: Dict[str, str] = {} + for value in values: + language, _, source = value.partition("=") + language = language.strip() + if not language or not source: + _fail( + f"--subtitle needs =, got {value!r} " + "(e.g. --subtitle es=spanish.srt)" + ) + if language in subtitles: + _fail(f"--subtitle {language} given twice; one script per language") + subtitles[language] = source + return subtitles + + +def _export_line(language: str, report: Dict[str, Any]) -> str: + """One status line per language. `alignment_loss` arrives as a float or as + a string — the pipeline stores numbers as strings — so it is formatted + through float() and simply omitted when it is neither.""" + status = report.get("status") or "unknown" + line = f"Subtitle {language}: {status}" + try: + line += f" (alignment loss {float(report['alignment_loss']):.3f})" + except (KeyError, TypeError, ValueError): + pass + error = report.get("error") + if error: + line += f" — {error}" + return line + + def cmd_dubbing(client: Sonilo, args: argparse.Namespace) -> None: out = args.output if args.output is not None else "output.mp4" languages = None @@ -631,10 +669,15 @@ def cmd_dubbing(client: Sonilo, args: argparse.Namespace) -> None: languages = [code.strip() for code in args.languages.split(",") if code.strip()] if not languages: _fail("--languages needs at least one language code, e.g. --languages es,fr") + subtitles = _subtitles(args.subtitle) + if args.export_srt and subtitles is None: + _fail("--export-srt needs --subtitle = for each language") result = client.dubbing.generate( video=args.video, video_url=args.video_url, languages=languages, + subtitles=subtitles, + export_srt=True if args.export_srt else None, timeout=args.timeout, ) if not result.outputs: @@ -642,6 +685,17 @@ def cmd_dubbing(client: Sonilo, args: argparse.Namespace) -> None: for language in sorted(result.outputs): path = result.save(language, _language_path(out, language)) _wrote(path, path.stat().st_size) + if not args.export_srt: + return + # The .srt lands beside its video (clip.es.mp4 -> clip.es.srt). A blocked + # export still delivers the video, so every requested language gets a + # status line whether or not a file came back with it. + for language in sorted(result.subtitle_export or result.subtitles): + if language in result.subtitles: + srt = Path(_language_path(out, language)).with_suffix(".srt") + path = result.save_subtitle(language, srt) + _wrote(path, path.stat().st_size) + print(_export_line(language, result.subtitle_export.get(language, {}))) def _identity(body: Any) -> Any: @@ -1010,6 +1064,19 @@ def build_parser() -> argparse.ArgumentParser: "es_419 Latin American Spanish; plain pt and es stay " "unqualified, as does ar.", ) + p_dub.add_argument( + "--subtitle", dest="subtitle", action="append", default=None, + metavar="LANG=SOURCE", + help="Script to speak in one target language, as = or " + "=. Repeat once per language. Scripts are " + ".srt or .vtt in the TARGET language, not source transcripts.", + ) + p_dub.add_argument( + "--export-srt", dest="export_srt", action="store_true", + help="Return a re-timed .srt per language, aligned to the delivered " + "audio and keeping your lines verbatim. Written beside each video " + "(clip.es.mp4 -> clip.es.srt). Requires --subtitle.", + ) p_dub.add_argument( "--output", default=None, help="Filename template; one file is written per language with the code " diff --git a/sonilo-cli/tests/test_cli.py b/sonilo-cli/tests/test_cli.py index 49b6b6c..8b0b62e 100644 --- a/sonilo-cli/tests/test_cli.py +++ b/sonilo-cli/tests/test_cli.py @@ -720,6 +720,170 @@ def test_dubbing_without_languages_omits_the_field(tmp_path): assert b"languages" not in route.calls.last.request.content +# --- dubbing subtitles ---------------------------------------------------- + +SUBTITLED_BODY = { + "task_id": "db1", + "type": "dubbing", + "status": "succeeded", + "outputs": {"es": "https://r2/es.mp4", "fr": "https://r2/fr.mp4"}, + # fr's export was blocked: its video is still delivered, with no .srt. + "subtitles": {"es": "https://r2/es.srt"}, + "subtitle_export": { + # The pipeline stores numbers as strings, so the loss arrives as one. + "es": {"status": "exported", "alignment_loss": "0.6305176995017312"}, + "fr": {"status": "blocked", "error": "alignment failed"}, + }, +} + + +def _stub_subtitled_dubbing(): + route = respx.post(f"{BASE}/v1/dubbing").mock( + return_value=httpx.Response(202, json={"task_id": "db1", "status": "processing"}) + ) + respx.get(f"{BASE}/v1/tasks/db1").mock( + return_value=httpx.Response(200, json=SUBTITLED_BODY) + ) + for language in ("es", "fr"): + respx.get(f"https://r2/{language}.mp4").mock( + return_value=httpx.Response(200, content=f"{language}-bytes".encode()) + ) + respx.get("https://r2/es.srt").mock( + return_value=httpx.Response(200, content=b"1\nhola\n") + ) + return route + + +@respx.mock +def test_dubbing_sends_subtitle_urls(tmp_path): + route = _stub_subtitled_dubbing() + run([ + "dubbing", + "--video-url", "https://x/v.mp4", + "--languages", "es,fr", + "--subtitle", "es=https://x/es.srt", + "--subtitle", "fr=https://x/fr.vtt", + "--output", str(tmp_path / "clip.mp4"), + ]) + body = unquote_plus(route.calls.last.request.content.decode()) + assert "subtitles[es]=https://x/es.srt" in body + assert "subtitles[fr]=https://x/fr.vtt" in body + + +@respx.mock +def test_dubbing_uploads_local_subtitle_files(tmp_path): + route = _stub_subtitled_dubbing() + script = tmp_path / "spanish.srt" + script.write_text("1\n") + run([ + "dubbing", + "--video-url", "https://x/v.mp4", + "--languages", "es,fr", + "--subtitle", f"es={script}", + "--subtitle", "fr=https://x/fr.vtt", + "--output", str(tmp_path / "clip.mp4"), + ]) + body = route.calls.last.request.content.decode(errors="replace") + assert 'name="subtitles[es]"' in body + assert 'filename="spanish.srt"' in body + + +@respx.mock +def test_dubbing_export_srt_writes_the_srt_beside_each_video(tmp_path, capsys): + _stub_subtitled_dubbing() + run([ + "dubbing", + "--video-url", "https://x/v.mp4", + "--languages", "es,fr", + "--subtitle", "es=https://x/es.srt", + "--subtitle", "fr=https://x/fr.vtt", + "--export-srt", + "--output", str(tmp_path / "clip.mp4"), + ]) + assert (tmp_path / "clip.es.mp4").read_bytes() == b"es-bytes" + assert (tmp_path / "clip.es.srt").read_bytes() == b"1\nhola\n" + # fr's export was blocked, so there is a video but no .srt for it. + assert (tmp_path / "clip.fr.mp4").exists() + assert not (tmp_path / "clip.fr.srt").exists() + out = capsys.readouterr().out + assert "Subtitle es: exported (alignment loss 0.631)" in out + assert "Subtitle fr: blocked" in out + + +@respx.mock +def test_dubbing_export_srt_tolerates_a_numeric_alignment_loss(tmp_path, capsys): + respx.post(f"{BASE}/v1/dubbing").mock( + return_value=httpx.Response(202, json={"task_id": "db1", "status": "processing"}) + ) + respx.get(f"{BASE}/v1/tasks/db1").mock( + return_value=httpx.Response(200, json={ + "task_id": "db1", "status": "succeeded", + "outputs": {"es": "https://r2/es.mp4"}, + "subtitles": {"es": "https://r2/es.srt"}, + "subtitle_export": {"es": {"status": "exported", "alignment_loss": 0.25}}, + }) + ) + respx.get("https://r2/es.mp4").mock( + return_value=httpx.Response(200, content=b"es-bytes") + ) + respx.get("https://r2/es.srt").mock( + return_value=httpx.Response(200, content=b"1\nhola\n") + ) + run([ + "dubbing", + "--video-url", "https://x/v.mp4", + "--subtitle", "es=https://x/es.srt", + "--export-srt", + "--output", str(tmp_path / "clip.mp4"), + ]) + assert "Subtitle es: exported (alignment loss 0.250)" in capsys.readouterr().out + + +def test_dubbing_export_srt_without_a_subtitle_exits_1(capsys): + with pytest.raises(SystemExit) as exc: + main([ + "--api-key", "sk-test", "dubbing", + "--video-url", "https://x/v.mp4", "--export-srt", + ]) + assert exc.value.code == 1 + assert "--subtitle" in capsys.readouterr().err + + +def test_dubbing_rejects_a_subtitle_without_a_language(capsys): + with pytest.raises(SystemExit) as exc: + main([ + "--api-key", "sk-test", "dubbing", + "--video-url", "https://x/v.mp4", "--subtitle", "spanish.srt", + ]) + assert exc.value.code == 1 + assert "--subtitle" in capsys.readouterr().err + + +def test_dubbing_rejects_the_same_language_twice(capsys): + with pytest.raises(SystemExit) as exc: + main([ + "--api-key", "sk-test", "dubbing", "--video-url", "https://x/v.mp4", + "--subtitle", "es=a.srt", "--subtitle", "es=b.srt", + ]) + assert exc.value.code == 1 + assert "twice" in capsys.readouterr().err + + +@respx.mock +def test_dubbing_rejects_a_subtitle_that_is_not_srt_or_vtt(tmp_path, capsys): + route = respx.post(f"{BASE}/v1/dubbing") + script = tmp_path / "es.txt" + script.write_text("hola") + with pytest.raises(SystemExit) as exc: + main([ + "--api-key", "sk-test", "dubbing", + "--video-url", "https://x/v.mp4", "--subtitle", f"es={script}", + ]) + assert exc.value.code == 1 + assert "es.txt" in capsys.readouterr().err + assert not route.called + + # --- --segments ----------------------------------------------------------- # # The two shapes are not interchangeable: music segments are From 9b7746d68edc8c3f10423d9a3bf5b0f7f436c85d Mon Sep 17 00:00:00 2001 From: Spencer Qian Date: Sun, 13 Sep 2026 22:45:37 -0700 Subject: [PATCH 3/7] chore: sonilo 0.16.0, sonilo-cli 0.15.0, and widen the CLI's pin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New parameters on an existing endpoint, so a minor bump on both packages — and `__version__` is bumped alongside `pyproject.toml` in each, since it ships as the `x-sonilo-client-version` header and CI now fails on a one-sided bump (#39). The CLI's `sonilo>=0.15.0,<0.16` pin has to widen to `>=0.16.0,<0.17` in this same commit: it calls `dubbing.generate(subtitles=..., export_srt=...)`, which only exists from 0.16.0, and left alone the pin would make the editable install of both packages unresolvable. Claude-Session: https://claude.ai/code/session_01WWHfnbRaXRdAH2sjm8mApk --- pyproject.toml | 2 +- sonilo-cli/pyproject.toml | 4 ++-- sonilo-cli/src/sonilo_cli/__init__.py | 2 +- src/sonilo/_version.py | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 32dad54..9ecfbf1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "sonilo" -version = "0.15.4" +version = "0.16.0" description = "Official Python client for the Sonilo API" readme = "README.md" license = "MIT" diff --git a/sonilo-cli/pyproject.toml b/sonilo-cli/pyproject.toml index bb72fa1..9d4d27e 100644 --- a/sonilo-cli/pyproject.toml +++ b/sonilo-cli/pyproject.toml @@ -4,13 +4,13 @@ build-backend = "hatchling.build" [project] name = "sonilo-cli" -version = "0.14.3" +version = "0.15.0" description = "Command-line interface for the Sonilo API: generate music and sound effects from text or video" readme = "README.md" license = "MIT" requires-python = ">=3.9" authors = [{ name = "Sonilo AI" }] -dependencies = ["sonilo>=0.15.0,<0.16"] +dependencies = ["sonilo>=0.16.0,<0.17"] keywords = ["sonilo", "cli", "music", "sfx", "text-to-music", "video-to-music", "ai"] [project.urls] diff --git a/sonilo-cli/src/sonilo_cli/__init__.py b/sonilo-cli/src/sonilo_cli/__init__.py index f7c4098..4a19ad6 100644 --- a/sonilo-cli/src/sonilo_cli/__init__.py +++ b/sonilo-cli/src/sonilo_cli/__init__.py @@ -1,3 +1,3 @@ -__version__ = "0.14.3" +__version__ = "0.15.0" __all__ = ["__version__"] diff --git a/src/sonilo/_version.py b/src/sonilo/_version.py index 8782a8b..5a313cc 100644 --- a/src/sonilo/_version.py +++ b/src/sonilo/_version.py @@ -1 +1 @@ -__version__ = "0.15.4" +__version__ = "0.16.0" From 361e417bba1d6745b0f74a04446bdb96e1be25f7 Mon Sep 17 00:00:00 2001 From: Spencer Qian Date: Sun, 13 Sep 2026 22:47:01 -0700 Subject: [PATCH 4/7] docs: subtitles, export_srt and lipsync in both READMEs The dubbing section of each README gains the new parameters: the target- language scripts and what the server checks about them, the re-timed .srt `export_srt` returns, the three new maps on `DubbingResult` (including that a blocked export still delivers the video), and `lipsync`, which has been on the endpoint all along and was documented nowhere. Also f-strings the two `subtitles[]` field names, matching the rest of the module. Claude-Session: https://claude.ai/code/session_01WWHfnbRaXRdAH2sjm8mApk --- README.md | 43 ++++++++++++++++++++++++++++++++++++++++- sonilo-cli/README.md | 11 +++++++++++ src/sonilo/_requests.py | 4 ++-- 3 files changed, 55 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index ab4d08b..4ba7243 100644 --- a/README.md +++ b/README.md @@ -396,7 +396,9 @@ Portuguese and `es_419` Latin American Spanish; plain `pt` and `es` stay unqualified, as does `ar`). The optional `ducking` boolean (default off, free) ducks the background music/effects bed under the dubbed voice while it speaks; when off the bed is kept at a constant level. (Every endpoint's `ducking` defaults off, -so this one is no exception.) Source videos may be +so this one is no exception.) The optional `lipsync` boolean defaults **on**: +pass `lipsync=False` to keep the source's own frames, resolution and frame +rate and replace only the audio. Source videos may be at most 300 seconds long, and billing is per language: a 3-language call costs three times as much as one. Dubbing has no free trial allowance — see [Free trial](#free-trial). @@ -429,6 +431,45 @@ for all of them; `AsyncSonilo` exposes the same shape with `asave`/`asave_all`. Use `submit()` instead of `generate()` to get a `task_id` back immediately and poll it yourself with `client.tasks.wait(task_id, parser=parse_dubbing_result)`. +### Your own script per language + +Pass `subtitles` to say exactly what each language should say: a map of +language code to an `.srt`/`.vtt` file path or an https URL. These are +**target-language** scripts — one per language being dubbed into, carrying the +lines you want spoken — not source-language transcripts. The set of languages +must match `languages` exactly; the server checks that, and a mismatch is a +`422` before anything is charged. Each uploaded script is at most 1 MiB. + +Add `export_srt=True` (it requires `subtitles`) to get a re-timed `.srt` per +language back: after the dub is delivered, each language's final audio is +force-aligned against its script and the lines come back verbatim with new +timings. + +```python +result = client.dubbing.generate( + video_url="https://example.com/clip.mp4", + languages=["es", "fr"], + subtitles={"es": "spanish.srt", "fr": "https://example.com/french.vtt"}, + export_srt=True, + timeout=7200, +) +result.save_all("./dubbed") +result.save_all_subtitles("./dubbed") +for language, report in result.subtitle_export.items(): + print(language, report["status"], report.get("alignment_loss")) +``` + +`subtitles` is a language → `.srt`-URL map alongside `outputs`, with +`save_subtitle(language, path)` and `save_all_subtitles(dir)` mirroring +`save`/`save_all` (`asave_subtitle`/`asave_all_subtitles` on `AsyncSonilo`). +`subtitle_preflight` reports what the check made of each script before +anything was charged, and `subtitle_export` reports `status` +(`exported`, `exported_review_required` or `blocked`), `alignment_loss`, +`issues` and `error` per language. A blocked export does **not** fail the +task: every video is still delivered and `subtitles` simply lacks that +language. Values inside both reports may arrive as strings rather than +numbers, so read them defensively. + ## Video analysis `client.video_analysis` analyzes a video and returns a **creative brief** for diff --git a/sonilo-cli/README.md b/sonilo-cli/README.md index d0bef08..2cebaf4 100644 --- a/sonilo-cli/README.md +++ b/sonilo-cli/README.md @@ -279,6 +279,17 @@ command that produces no media file — nothing is generated: - Source videos may be at most 300 seconds long. - `--output` is a filename template, not a single destination: a dubbing task returns one video per language, so `--output clip.mp4` writes `clip.es.mp4`, `clip.fr.mp4`, etc. +- `--subtitle =` gives one language the script to speak, as an `.srt`/`.vtt` + file or an https URL. Repeat it once per language; the set must match `--languages` exactly. + The scripts are in the **target** language, not the source's: + + sonilo dubbing --video clip.mp4 --languages es,fr \ + --subtitle es=spanish.srt --subtitle fr=https://example.com/french.vtt --export-srt + +- `--export-srt` (requires `--subtitle`) returns a re-timed `.srt` per language, aligned to the + delivered audio with your lines kept verbatim. Each one is written beside its video + (`clip.es.mp4` -> `clip.es.srt`), and one status line per language is printed. A language whose + export is blocked still gets its video — only the `.srt` is missing. - Billing is per language, and dubbing has **no free trial runs** — see [Free trial](#free-trial) below. - `--timeout` defaults to 7200 seconds, matching the backend's own ceiling for a dubbing job diff --git a/src/sonilo/_requests.py b/src/sonilo/_requests.py index 412b098..6c34bf5 100644 --- a/src/sonilo/_requests.py +++ b/src/sonilo/_requests.py @@ -264,7 +264,7 @@ def build_dubbing_parts( local_subtitles: List[Tuple[str, str, Union[str, Path]]] = [] for language, value in (subtitles or {}).items(): if _is_subtitle_url(value): - data["subtitles[{}]".format(language)] = value # type: ignore[assignment] + data[f"subtitles[{language}]"] = value # type: ignore[assignment] else: local_subtitles.append((language, _subtitle_filename(value), value)) @@ -282,7 +282,7 @@ def build_dubbing_parts( fileobj = Path(source).open("rb") opened.append(fileobj) content_type = mimetypes.guess_type(filename)[0] or "application/octet-stream" - files["subtitles[{}]".format(language)] = (filename, fileobj, content_type) + files[f"subtitles[{language}]"] = (filename, fileobj, content_type) except BaseException: for fileobj in opened: fileobj.close() From 05fae4a1569a32b456aeef6ce642485c93b0411b Mon Sep 17 00:00:00 2001 From: Spencer Qian Date: Sun, 13 Sep 2026 22:49:25 -0700 Subject: [PATCH 5/7] feat(cli): --ducking and --no-lipsync on `sonilo dubbing` The JavaScript CLI's dubbing command has had --ducking since the ducking round and has just gained --no-lipsync; the two CLIs mirror each other, so this side was the one out of step. --ducking goes through the same `_ducking()` resolution the sound commands use, so --no-ducking keeps parsing as the explicit-default no-op it is elsewhere and passing both still exits 1. Only --no-lipsync exists, with no positive counterpart: lip sync is default-ON server-side, so the sole useful direction is turning it off, and an absent flag sends nothing rather than restating a default that could move. Claude-Session: https://claude.ai/code/session_01WWHfnbRaXRdAH2sjm8mApk --- sonilo-cli/README.md | 5 +++ sonilo-cli/src/sonilo_cli/__main__.py | 21 ++++++++++ sonilo-cli/tests/test_cli.py | 59 +++++++++++++++++++++++++++ 3 files changed, 85 insertions(+) diff --git a/sonilo-cli/README.md b/sonilo-cli/README.md index 2cebaf4..655d6ff 100644 --- a/sonilo-cli/README.md +++ b/sonilo-cli/README.md @@ -279,6 +279,11 @@ command that produces no media file — nothing is generated: - Source videos may be at most 300 seconds long. - `--output` is a filename template, not a single destination: a dubbing task returns one video per language, so `--output clip.mp4` writes `clip.es.mp4`, `clip.fr.mp4`, etc. +- `--ducking` ducks the background music/effects bed under the dubbed voice while it speaks; + off by default, so the bed otherwise stays at a static level. `--no-ducking` states that + default explicitly. +- `--no-lipsync` skips the mouth re-render: the deliverable keeps the source's own frames, + resolution and frame rate, and only the audio is replaced. Lip sync is on by default. - `--subtitle =` gives one language the script to speak, as an `.srt`/`.vtt` file or an https URL. Repeat it once per language; the set must match `--languages` exactly. The scripts are in the **target** language, not the source's: diff --git a/sonilo-cli/src/sonilo_cli/__main__.py b/sonilo-cli/src/sonilo_cli/__main__.py index 35187d0..afe3497 100644 --- a/sonilo-cli/src/sonilo_cli/__main__.py +++ b/sonilo-cli/src/sonilo_cli/__main__.py @@ -676,6 +676,11 @@ def cmd_dubbing(client: Sonilo, args: argparse.Namespace) -> None: video=args.video, video_url=args.video_url, languages=languages, + ducking=_ducking(args), + # lipsync is default-ON server-side, so the only direction worth a flag + # is turning it off — and an absent flag has to send nothing at all, + # never False, or it would restate a default that could move. + lipsync=False if args.no_lipsync else None, subtitles=subtitles, export_srt=True if args.export_srt else None, timeout=args.timeout, @@ -1064,6 +1069,22 @@ def build_parser() -> argparse.ArgumentParser: "es_419 Latin American Spanish; plain pt and es stay " "unqualified, as does ar.", ) + p_dub.add_argument( + "--ducking", dest="ducking", action="store_true", + help="Duck the background music/effects bed under the dubbed voice while " + "it speaks, instead of keeping it at a static level. Off by default.", + ) + p_dub.add_argument( + "--no-ducking", dest="no_ducking", action="store_true", + help="Explicit opt-out. Same as the default; kept so existing " + "scripts keep working.", + ) + p_dub.add_argument( + "--no-lipsync", dest="no_lipsync", action="store_true", + help="Skip the mouth re-render: the deliverable keeps the source's own " + "frames, resolution and frame rate, and only the audio is replaced. " + "On by default, so this is the only direction worth asking for.", + ) p_dub.add_argument( "--subtitle", dest="subtitle", action="append", default=None, metavar="LANG=SOURCE", diff --git a/sonilo-cli/tests/test_cli.py b/sonilo-cli/tests/test_cli.py index 8b0b62e..b1a2509 100644 --- a/sonilo-cli/tests/test_cli.py +++ b/sonilo-cli/tests/test_cli.py @@ -720,6 +720,65 @@ def test_dubbing_without_languages_omits_the_field(tmp_path): assert b"languages" not in route.calls.last.request.content +# --- dubbing ducking and lipsync ------------------------------------------ + + +def _stub_plain_dubbing(): + route = respx.post(f"{BASE}/v1/dubbing").mock( + return_value=httpx.Response(202, json={"task_id": "db1", "status": "processing"}) + ) + respx.get(f"{BASE}/v1/tasks/db1").mock( + return_value=httpx.Response(200, json={ + "task_id": "db1", "status": "succeeded", + "outputs": {"es": "https://r2/es.mp4"}, + }) + ) + respx.get("https://r2/es.mp4").mock( + return_value=httpx.Response(200, content=b"es-bytes") + ) + return route + + +@respx.mock +@pytest.mark.parametrize( + "flags, wire", + [ + # ducking is default-OFF server-side and lipsync default-ON, so each + # flag is only ever sent to change that default. + (["--ducking"], "ducking=true"), + (["--no-ducking"], "ducking=false"), + (["--no-lipsync"], "lipsync=false"), + ], +) +def test_dubbing_ducking_and_lipsync_flags(tmp_path, flags, wire): + route = _stub_plain_dubbing() + run([ + "dubbing", "--video-url", "https://x/v.mp4", + "--output", str(tmp_path / "clip.mp4"), + ] + flags) + assert wire in unquote_plus(route.calls.last.request.content.decode()) + + +@respx.mock +def test_dubbing_omits_ducking_and_lipsync_when_unset(tmp_path): + route = _stub_plain_dubbing() + run([ + "dubbing", "--video-url", "https://x/v.mp4", + "--output", str(tmp_path / "clip.mp4"), + ]) + # Absent must stay absent: the server defaults (ducking off, lipsync on) + # only apply when the field is not sent at all. + body = route.calls.last.request.content.decode() + assert "ducking=" not in body + assert "lipsync=" not in body + + +def test_dubbing_rejects_both_ducking_flags(tmp_path): + with pytest.raises(SystemExit): + run(["dubbing", "--video-url", "https://x/v.mp4", + "--output", str(tmp_path / "clip.mp4"), "--ducking", "--no-ducking"]) + + # --- dubbing subtitles ---------------------------------------------------- SUBTITLED_BODY = { From d5ef0036a6460bc39d64d3e79cdbf47ec594fed5 Mon Sep 17 00:00:00 2001 From: Spencer Qian Date: Sun, 13 Sep 2026 23:18:35 -0700 Subject: [PATCH 6/7] fix: return the 202's subtitle_preflight from dubbing submit() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `submit()` parsed the dubbing ack with the shared `parse_sfx_task`, which builds an `SfxTask` of `task_id` and `status` — so the whole per-language `subtitle_preflight` map was read off the wire and dropped. That map is the free, pre-charge check: a language whose status is `review_required` had lines changed in the script the caller submitted, and with `submit()` there was no way to see it before the billed dub finished. Adds `DubbingTask`, an additive subclass of `SfxTask` carrying the map, and `parse_dubbing_task` to fill it — coerced through the same `_report_map_from` the finished task's copy uses. `SfxTask` itself is untouched: it acks every other async endpoint, and the preflight is dubbing's alone. Mirrors the JavaScript SDK's `DubbingTask extends SfxTask`. Also drops the unreachable `or "subtitles.srt"` fallback in `_subtitle_filename` — an empty name has an empty suffix, so the extension check raises before it — and fixes the README example, which read `report["status"]` two lines under its own advice to read these defensively. Claude-Session: https://claude.ai/code/session_01WWHfnbRaXRdAH2sjm8mApk --- README.md | 10 +++- src/sonilo/__init__.py | 2 + src/sonilo/_requests.py | 10 ++-- src/sonilo/resources/dubbing.py | 12 ++--- src/sonilo/resources/tasks.py | 18 ++++++++ src/sonilo/types.py | 18 ++++++++ tests/test_dubbing.py | 82 ++++++++++++++++++++++++++++++++- 7 files changed, 139 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 4ba7243..b9af9ec 100644 --- a/README.md +++ b/README.md @@ -456,12 +456,20 @@ result = client.dubbing.generate( result.save_all("./dubbed") result.save_all_subtitles("./dubbed") for language, report in result.subtitle_export.items(): - print(language, report["status"], report.get("alignment_loss")) + loss = report.get("alignment_loss") + # The loss may arrive as a string; float() reads either shape. + print(language, report.get("status"), float(loss) if loss is not None else "n/a") ``` `subtitles` is a language → `.srt`-URL map alongside `outputs`, with `save_subtitle(language, path)` and `save_all_subtitles(dir)` mirroring `save`/`save_all` (`asave_subtitle`/`asave_all_subtitles` on `AsyncSonilo`). +`submit()` returns a `DubbingTask` — an `SfxTask` plus the same +`subtitle_preflight` map, carried on the 202 itself. That check is free and +runs before anything is charged, so a language whose status is +`review_required` (the pipeline changed lines in your script) is worth reading +there rather than after the billed dub finishes. + `subtitle_preflight` reports what the check made of each script before anything was charged, and `subtitle_export` reports `status` (`exported`, `exported_review_required` or `blocked`), `alignment_loss`, diff --git a/src/sonilo/__init__.py b/src/sonilo/__init__.py index 4f390c0..d6c055a 100644 --- a/src/sonilo/__init__.py +++ b/src/sonilo/__init__.py @@ -18,6 +18,7 @@ AnalysisSegment, AnalysisVariation, DubbingResult, + DubbingTask, MusicAudioMedia, MusicResult, MusicStems, @@ -43,6 +44,7 @@ "AuthenticationError", "BadRequestError", "DubbingResult", + "DubbingTask", "GenerationError", "MusicAudioMedia", "MusicResult", diff --git a/src/sonilo/_requests.py b/src/sonilo/_requests.py index 6c34bf5..9ce6743 100644 --- a/src/sonilo/_requests.py +++ b/src/sonilo/_requests.py @@ -183,15 +183,17 @@ def _subtitle_filename(value: Union[str, Path]) -> str: """Validate one local subtitle path and return the basename to send. The suffix check is local because it is a guaranteed server-side 422: the - part's filename is what the backend uses to pick the parser. The 1 MiB cap - and the "one script per requested language" rule are deliberately NOT - checked here — the server owns both, and a copy here would drift.""" + part's filename is what the backend uses to pick the parser. It also makes + the name safe to return unguarded — an empty name has an empty suffix, so + it never gets past the check. The 1 MiB cap and the "one script per + requested language" rule are deliberately NOT checked here — the server + owns both, and a copy here would drift.""" path = Path(value) if path.suffix.lower() not in SUBTITLE_SUFFIXES: raise SoniloError( f"Subtitle file {path.name!r} must be .srt or .vtt" ) - return path.name or "subtitles.srt" + return path.name def build_dubbing_parts( diff --git a/src/sonilo/resources/dubbing.py b/src/sonilo/resources/dubbing.py index cd64635..1cb690e 100644 --- a/src/sonilo/resources/dubbing.py +++ b/src/sonilo/resources/dubbing.py @@ -8,9 +8,9 @@ DEFAULT_POLL_INTERVAL, DEFAULT_WAIT_TIMEOUT, parse_dubbing_result, - parse_sfx_task, + parse_dubbing_task, ) -from sonilo.types import DubbingResult, SfxTask +from sonilo.types import DubbingResult, DubbingTask if TYPE_CHECKING: from sonilo._async_client import AsyncSonilo @@ -52,12 +52,12 @@ def submit( subtitles: Optional[Dict[str, Union[str, Path]]] = None, export_srt: Optional[bool] = None, lipsync: Optional[bool] = None, - ) -> SfxTask: + ) -> DubbingTask: data, files, close_after = build_dubbing_parts( video, video_url, languages, ducking, subtitles=subtitles, export_srt=export_srt, lipsync=lipsync, ) - return parse_sfx_task( + return parse_dubbing_task( self._client._post_json(PATH, data=data, files=files, close_after=close_after) ) @@ -100,12 +100,12 @@ async def submit( subtitles: Optional[Dict[str, Union[str, Path]]] = None, export_srt: Optional[bool] = None, lipsync: Optional[bool] = None, - ) -> SfxTask: + ) -> DubbingTask: data, files, close_after = build_dubbing_parts( video, video_url, languages, ducking, subtitles=subtitles, export_srt=export_srt, lipsync=lipsync, ) - return parse_sfx_task( + return parse_dubbing_task( await self._client._post_json( PATH, data=data, files=files, close_after=close_after ) diff --git a/src/sonilo/resources/tasks.py b/src/sonilo/resources/tasks.py index 089f81e..f1dfa23 100644 --- a/src/sonilo/resources/tasks.py +++ b/src/sonilo/resources/tasks.py @@ -10,6 +10,7 @@ AnalysisSegment, AnalysisVariation, DubbingResult, + DubbingTask, MusicAudioMedia, MusicResult, MusicStems, @@ -357,6 +358,23 @@ def parse_sfx_task(body: Dict[str, Any]) -> SfxTask: raise SoniloError(f"Malformed task response: missing {e.args[0]!r}") from e +def parse_dubbing_task(body: Dict[str, Any]) -> DubbingTask: + """Map a /v1/dubbing submission ack to DubbingTask. + + Same envelope as parse_sfx_task plus `subtitle_preflight`, which only the + dubbing ack carries and only when scripts were sent. It is coerced exactly + as the finished task's copy is — the ack is where a `review_required` + language can still be acted on, before the dub is billed.""" + try: + return DubbingTask( + task_id=body["task_id"], + status=body.get("status", "processing"), + subtitle_preflight=_report_map_from(body.get("subtitle_preflight")), + ) + except KeyError as e: + raise SoniloError(f"Malformed task response: missing {e.args[0]!r}") from e + + def _raise_if_failed(result: _PollableResult) -> None: if result.status == "failed": error = result.error if isinstance(result.error, dict) else {} diff --git a/src/sonilo/types.py b/src/sonilo/types.py index 722e753..129ba29 100644 --- a/src/sonilo/types.py +++ b/src/sonilo/types.py @@ -76,6 +76,24 @@ class SfxTask: status: str +@dataclass +class DubbingTask(SfxTask): + """Submission ack for /v1/dubbing. + + A separate type rather than two more fields on SfxTask, which every other + async endpoint returns: the 202 for a dubbing submission with scripts + carries `subtitle_preflight`, the free pre-charge check of each script, and + nothing else acks anything like it. + + Reading it matters most on `submit()`: a language whose status is + `review_required` had lines changed before anything was spoken, and a + caller who only ever looks at the finished task learns that after the dub + is billed. A submission without scripts simply leaves the map empty. + """ + + subtitle_preflight: Dict[str, Dict[str, Any]] = field(default_factory=dict) + + @dataclass class SfxMedia: """A generated file re-hosted on R2 behind a presigned URL.""" diff --git a/tests/test_dubbing.py b/tests/test_dubbing.py index 840107d..150bd4d 100644 --- a/tests/test_dubbing.py +++ b/tests/test_dubbing.py @@ -5,9 +5,10 @@ import pytest import respx -from sonilo import AsyncSonilo, Sonilo +from sonilo import AsyncSonilo, DubbingTask, Sonilo from sonilo.errors import SoniloError -from sonilo.resources.tasks import parse_dubbing_result +from sonilo.types import SfxTask +from sonilo.resources.tasks import parse_dubbing_result, parse_dubbing_task SUCCESS_BODY = { "task_id": "db1", @@ -343,3 +344,80 @@ async def test_async_submit_sends_the_subtitle_fields(): assert "subtitles[es]=https://x/es.srt" in sent assert "export_srt=true" in sent assert "lipsync=true" in sent + + +# --- the 202's preflight --------------------------------------------------- + +PREFLIGHT_ACK = { + "task_id": "db1", + "status": "processing", + "subtitle_preflight": { + # The pipeline stores numbers as strings, so cue_count arrives as one. + "es": {"status": "ok", "cue_count": "5", "issues": [], "changes_count": 0}, + "fr": {"status": "review_required", "cue_count": 4, "issues": ["line_shortened"], + "changes_count": 2}, + }, +} + + +@respx.mock +def test_submit_returns_the_preflight_from_the_ack(): + """The preflight is free and pre-charge: a review_required language is only + actionable here, before the dub is billed.""" + respx.post("https://api.sonilo.com/v1/dubbing").mock( + return_value=httpx.Response(202, json=PREFLIGHT_ACK) + ) + with Sonilo(api_key="sk-test") as client: + task = client.dubbing.submit( + video_url="https://x/v.mp4", languages=["es", "fr"], + subtitles={"es": "https://x/es.srt", "fr": "https://x/fr.srt"}, + ) + assert isinstance(task, DubbingTask) + assert task.task_id == "db1" and task.status == "processing" + assert task.subtitle_preflight["fr"]["status"] == "review_required" + assert task.subtitle_preflight["es"]["cue_count"] == "5" + + +@respx.mock +def test_submit_without_scripts_has_an_empty_preflight(): + respx.post("https://api.sonilo.com/v1/dubbing").mock( + return_value=httpx.Response(202, json=ACK) + ) + with Sonilo(api_key="sk-test") as client: + task = client.dubbing.submit(video_url="https://x/v.mp4") + assert task.subtitle_preflight == {} + + +def test_parse_dubbing_task_drops_malformed_preflight_entries(): + task = parse_dubbing_task({ + "task_id": "db1", + "subtitle_preflight": {"es": "not-a-report", "fr": {"status": "ok"}}, + }) + # Missing status defaults the same way the shared ack parser does. + assert task.status == "processing" + assert task.subtitle_preflight == {"fr": {"status": "ok"}} + + +def test_parse_dubbing_task_rejects_a_body_without_a_task_id(): + with pytest.raises(SoniloError): + parse_dubbing_task({"status": "processing"}) + + +def test_dubbing_task_extends_sfx_task_without_changing_it(): + """SfxTask acks every other async endpoint, so the preflight had to arrive + as a new type rather than two more fields on the shared one.""" + assert issubclass(DubbingTask, SfxTask) + assert not hasattr(SfxTask("t", "processing"), "subtitle_preflight") + + +@respx.mock +async def test_async_submit_returns_the_preflight_too(): + respx.post("https://api.sonilo.com/v1/dubbing").mock( + return_value=httpx.Response(202, json=PREFLIGHT_ACK) + ) + async with AsyncSonilo(api_key="sk-test") as client: + task = await client.dubbing.submit( + video_url="https://x/v.mp4", + subtitles={"es": "https://x/es.srt", "fr": "https://x/fr.srt"}, + ) + assert task.subtitle_preflight["fr"]["changes_count"] == 2 From b34386e624b8234ed36a80966cda2a16eb4cea86 Mon Sep 17 00:00:00 2001 From: Spencer Qian Date: Sun, 13 Sep 2026 23:18:43 -0700 Subject: [PATCH 7/7] fix(cli): refuse --output *.srt with --export-srt, and tighten three tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `sonilo dubbing --export-srt --output clip.srt` wrote the dubbed video to clip.es.srt and then wrote the subtitle over it, printing a "Wrote" line for each: both files come from the one template and the subtitle goes second, so the mp4 was silently truncated away. It is refused at parse time now, before anything runs, with a message naming the cause and the fix — matched case-insensitively, and the same refusal the JavaScript CLI makes. The three tests that asserted only an exit code plus a flag name were passing against `main` for the wrong reason: argparse's own "unrecognized arguments" error quotes the flag, so they would not have caught a regression that deleted the flags outright. Each now matches the message this CLI actually prints. Claude-Session: https://claude.ai/code/session_01WWHfnbRaXRdAH2sjm8mApk --- sonilo-cli/src/sonilo_cli/__main__.py | 10 +++++++++ sonilo-cli/tests/test_cli.py | 29 ++++++++++++++++++++++++--- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/sonilo-cli/src/sonilo_cli/__main__.py b/sonilo-cli/src/sonilo_cli/__main__.py index afe3497..eaa41e3 100644 --- a/sonilo-cli/src/sonilo_cli/__main__.py +++ b/sonilo-cli/src/sonilo_cli/__main__.py @@ -672,6 +672,16 @@ def cmd_dubbing(client: Sonilo, args: argparse.Namespace) -> None: subtitles = _subtitles(args.subtitle) if args.export_srt and subtitles is None: _fail("--export-srt needs --subtitle = for each language") + # Both files are derived from the same template, and the subtitle is + # written second: an .srt template would have clip.es.srt overwrite the + # video that had just been saved to clip.es.srt, reporting both writes as + # successes. Refuse it here rather than destroy the deliverable. + if args.export_srt and Path(out).suffix.lower() == ".srt": + _fail( + f"--output {out} ends in .srt, which --export-srt would overwrite with " + "the subtitle — name the video (e.g. --output clip.mp4) and the .srt " + "is written beside it" + ) result = client.dubbing.generate( video=args.video, video_url=args.video_url, diff --git a/sonilo-cli/tests/test_cli.py b/sonilo-cli/tests/test_cli.py index b1a2509..e1cb622 100644 --- a/sonilo-cli/tests/test_cli.py +++ b/sonilo-cli/tests/test_cli.py @@ -774,9 +774,12 @@ def test_dubbing_omits_ducking_and_lipsync_when_unset(tmp_path): def test_dubbing_rejects_both_ducking_flags(tmp_path): - with pytest.raises(SystemExit): + with pytest.raises(SystemExit) as exc: run(["dubbing", "--video-url", "https://x/v.mp4", "--output", str(tmp_path / "clip.mp4"), "--ducking", "--no-ducking"]) + # The helper's own message, not argparse's: matching only the exit would + # also pass against a build with neither flag defined. + assert "pass at most one of --ducking or --no-ducking" in str(exc.value) # --- dubbing subtitles ---------------------------------------------------- @@ -898,6 +901,21 @@ def test_dubbing_export_srt_tolerates_a_numeric_alignment_loss(tmp_path, capsys) assert "Subtitle es: exported (alignment loss 0.250)" in capsys.readouterr().out +def test_dubbing_export_srt_refuses_an_srt_output_template(capsys, tmp_path): + """Both files come from one template and the subtitle is written second, so + an .srt template would silently overwrite the dubbed video.""" + with pytest.raises(SystemExit) as exc: + main([ + "--api-key", "sk-test", "dubbing", "--video-url", "https://x/v.mp4", + "--subtitle", "es=https://x/es.srt", "--export-srt", + "--output", str(tmp_path / "clip.SRT"), + ]) + assert exc.value.code == 1 + err = capsys.readouterr().err + assert "--export-srt would overwrite" in err + assert not (tmp_path / "clip.es.SRT").exists() + + def test_dubbing_export_srt_without_a_subtitle_exits_1(capsys): with pytest.raises(SystemExit) as exc: main([ @@ -915,7 +933,12 @@ def test_dubbing_rejects_a_subtitle_without_a_language(capsys): "--video-url", "https://x/v.mp4", "--subtitle", "spanish.srt", ]) assert exc.value.code == 1 - assert "--subtitle" in capsys.readouterr().err + # Match our own message, not just the flag name: argparse's "unrecognized + # arguments" error quotes the flag too, so a laxer assertion would pass + # against a build where --subtitle does not exist at all. + err = capsys.readouterr().err + assert "--subtitle needs =" in err + assert "'spanish.srt'" in err def test_dubbing_rejects_the_same_language_twice(capsys): @@ -939,7 +962,7 @@ def test_dubbing_rejects_a_subtitle_that_is_not_srt_or_vtt(tmp_path, capsys): "--video-url", "https://x/v.mp4", "--subtitle", f"es={script}", ]) assert exc.value.code == 1 - assert "es.txt" in capsys.readouterr().err + assert "'es.txt' must be .srt or .vtt" in capsys.readouterr().err assert not route.called