From cae81806cc052de61cc0096fd58cb4b0ea0d49e0 Mon Sep 17 00:00:00 2001 From: Spencer Qian Date: Fri, 24 Jul 2026 12:17:02 -0700 Subject: [PATCH 01/10] feat: add build_dubbing_parts for POST /v1/dubbing --- src/sonilo/_requests.py | 43 +++++++++++++++++++++++++++++++++++++++++ tests/test_requests.py | 42 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+) diff --git a/src/sonilo/_requests.py b/src/sonilo/_requests.py index cf92b4e..fa7542b 100644 --- a/src/sonilo/_requests.py +++ b/src/sonilo/_requests.py @@ -83,6 +83,49 @@ def build_v2m_parts( return data, files, opened +def build_dubbing_parts( + video: Any, + video_url: Optional[str], + languages: Optional[List[str]], +) -> Tuple[Dict[str, str], Optional[Dict[str, tuple]], bool]: + """Build the multipart parts for POST /v1/dubbing. + + `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. + """ + if (video is None) == (video_url is None): + raise SoniloError("Provide exactly one of video or video_url") + + # Assemble data dict completely before opening any files + data: Dict[str, str] = {} + if video_url is not None: + if not video_url.lower().startswith("https://"): + raise SoniloError( + "video_url must use https — the dubbing pipeline requires an https URL" + ) + data["video_url"] = video_url + if languages is not None: + data["languages"] = json.dumps(languages) + + # 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 + + def _resolve_music_mode( mode: Optional[str], isolate_vocals: Optional[bool], diff --git a/tests/test_requests.py b/tests/test_requests.py index 398eb18..103bc6c 100644 --- a/tests/test_requests.py +++ b/tests/test_requests.py @@ -4,6 +4,7 @@ import pytest from sonilo._requests import ( + build_dubbing_parts, build_t2m_data, build_v2m_async_parts, build_v2m_parts, @@ -120,3 +121,44 @@ def test_v2v_sfx_parts_serializes_segments(): None, "https://x/v.mp4", None, [{"start": 0, "end": 2, "prompt": "boom"}] ) assert json.loads(data["segments"]) == [{"start": 0, "end": 2, "prompt": "boom"}] + + +def test_build_dubbing_parts_encodes_languages_as_a_json_array(): + data, files, opened = build_dubbing_parts( + None, "https://x/v.mp4", ["es", "fr"] + ) + assert files is None and opened is False + assert data["video_url"] == "https://x/v.mp4" + assert json.loads(data["languages"]) == ["es", "fr"] + + +def test_build_dubbing_parts_omits_languages_when_unset(): + data, _, _ = build_dubbing_parts(None, "https://x/v.mp4", None) + assert data == {"video_url": "https://x/v.mp4"} + + +def test_build_dubbing_parts_requires_exactly_one_input(): + with pytest.raises(SoniloError): + build_dubbing_parts(None, None, None) + with pytest.raises(SoniloError): + build_dubbing_parts(b"bytes", "https://x/v.mp4", None) + + +def test_build_dubbing_parts_rejects_a_non_https_url(): + with pytest.raises(SoniloError): + build_dubbing_parts(None, "http://x/v.mp4", None) + + +def test_build_dubbing_parts_uploads_bytes_as_the_video_part(): + data, files, opened = 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 + + +def test_build_dubbing_parts_passes_unknown_codes_through(): + # The backend owns the supported-language list; a client-side allowlist + # would make this SDK reject codes added server-side later. + data, _, _ = build_dubbing_parts(None, "https://x/v.mp4", ["xx"]) + assert json.loads(data["languages"]) == ["xx"] From 0d5e23d08e6c0bcac96ff6f857319b853236836e Mon Sep 17 00:00:00 2001 From: Spencer Qian Date: Fri, 24 Jul 2026 12:22:21 -0700 Subject: [PATCH 02/10] feat: add DubbingResult and parse_dubbing_result --- src/sonilo/resources/tasks.py | 30 ++++++++++ src/sonilo/types.py | 102 +++++++++++++++++++++++++++++++++- tests/test_dubbing.py | 79 ++++++++++++++++++++++++++ 3 files changed, 210 insertions(+), 1 deletion(-) create mode 100644 tests/test_dubbing.py diff --git a/src/sonilo/resources/tasks.py b/src/sonilo/resources/tasks.py index c0364d2..7762d91 100644 --- a/src/sonilo/resources/tasks.py +++ b/src/sonilo/resources/tasks.py @@ -7,6 +7,7 @@ from sonilo.errors import SoniloError, TaskFailedError, TaskTimeoutError from sonilo.types import ( + DubbingResult, MusicAudioMedia, MusicResult, MusicTitle, @@ -167,6 +168,35 @@ def parse_sound_result(body: Dict[str, Any]) -> "SoundResult": raise SoniloError(f"Malformed task response: missing {e.args[0]!r}") from e +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. + """ + 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, + duration_seconds=body.get("duration_seconds"), + cost=body.get("cost"), + error=body.get("error"), + refunded=body.get("refunded"), + ) + except KeyError as e: + raise SoniloError(f"Malformed task response: missing {e.args[0]!r}") from e + + def parse_sfx_task(body: Dict[str, Any]) -> SfxTask: """Map a submission ack to SfxTask.""" try: diff --git a/src/sonilo/types.py b/src/sonilo/types.py index 24c64c3..3cc1c42 100644 --- a/src/sonilo/types.py +++ b/src/sonilo/types.py @@ -1,7 +1,7 @@ from __future__ import annotations import httpx -from dataclasses import dataclass +from dataclasses import dataclass, field from pathlib import Path from typing import Any, Dict, List, Optional, Union @@ -361,3 +361,103 @@ async def asave_stem( ) -> Path: """Async variant of save_stem().""" return await _adownload_to(self._stem(which), path, timeout) + + +@dataclass +class DubbingResult: + """State of a dubbing task (`tasks.get`) or its final result + (`wait`/`generate`). + + Unlike every other endpoint's result there is no single artifact: a dubbing + task renders one video per requested language, so `outputs` is a map of + 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. + """ + + task_id: str + status: str + type: Optional[str] = None + outputs: Dict[str, str] = field(default_factory=dict) + duration_seconds: Optional[float] = None + cost: Optional[float] = None + error: Optional[Dict[str, Any]] = None + refunded: Optional[bool] = None + + def _url(self, language: str) -> str: + if language not in self.outputs: + available = ", ".join(sorted(self.outputs)) or "none" + raise SoniloError( + f"No output for language {language!r} on this result " + f"(status={self.status}; available: {available})" + ) + return self.outputs[language] + + def save( + self, + language: str, + path: Union[str, Path], + *, + timeout: float = DOWNLOAD_TIMEOUT, + ) -> Path: + """Download one language's dubbed video to `path` and return it. The + URL is presigned — no API key is sent.""" + response = httpx.get(self._url(language), follow_redirects=True, timeout=timeout) + if response.status_code >= 400: + raise SoniloError(f"Download failed: HTTP {response.status_code}") + p = Path(path) + p.write_bytes(response.content) + return p + + async def asave( + self, + language: str, + path: Union[str, Path], + *, + timeout: float = DOWNLOAD_TIMEOUT, + ) -> Path: + """Async variant of save().""" + url = self._url(language) + async with httpx.AsyncClient(follow_redirects=True, timeout=timeout) as http: + response = await http.get(url) + if response.status_code >= 400: + raise SoniloError(f"Download failed: HTTP {response.status_code}") + p = Path(path) + p.write_bytes(response.content) + return p + + def save_all( + self, + directory: Union[str, Path], + *, + prefix: str = "dubbed", + timeout: float = DOWNLOAD_TIMEOUT, + ) -> Dict[str, Path]: + """Download every language into `directory` as + `{prefix}.{language}.mp4`, returning the language → path map. The + directory is created if it does not exist.""" + target = Path(directory) + target.mkdir(parents=True, exist_ok=True) + return { + language: self.save( + language, target / f"{prefix}.{language}.mp4", timeout=timeout + ) + for language in sorted(self.outputs) + } + + async def asave_all( + self, + directory: Union[str, Path], + *, + prefix: str = "dubbed", + timeout: float = DOWNLOAD_TIMEOUT, + ) -> Dict[str, Path]: + """Async variant of save_all().""" + target = Path(directory) + target.mkdir(parents=True, exist_ok=True) + return { + language: await self.asave( + language, target / f"{prefix}.{language}.mp4", timeout=timeout + ) + for language in sorted(self.outputs) + } diff --git a/tests/test_dubbing.py b/tests/test_dubbing.py new file mode 100644 index 0000000..8653022 --- /dev/null +++ b/tests/test_dubbing.py @@ -0,0 +1,79 @@ +import httpx +import pytest +import respx + +from sonilo.errors import SoniloError +from sonilo.resources.tasks import parse_dubbing_result + +SUCCESS_BODY = { + "task_id": "db1", + "type": "dubbing", + "status": "succeeded", + "outputs": {"es": "https://r2/es.mp4", "fr": "https://r2/fr.mp4"}, + "duration_seconds": 12.5, + "cost": 0.36, +} + + +def test_parse_dubbing_result_reads_the_outputs_map(): + result = parse_dubbing_result(SUCCESS_BODY) + assert result.task_id == "db1" + assert result.status == "succeeded" + assert result.type == "dubbing" + assert result.outputs == { + "es": "https://r2/es.mp4", + "fr": "https://r2/fr.mp4", + } + assert result.duration_seconds == 12.5 + assert result.cost == 0.36 + + +def test_parse_dubbing_result_defaults_outputs_to_empty(): + result = parse_dubbing_result({"task_id": "db1", "status": "processing"}) + assert result.outputs == {} + + +def test_parse_dubbing_result_rejects_a_body_without_a_task_id(): + with pytest.raises(SoniloError): + parse_dubbing_result({"status": "succeeded"}) + + +@respx.mock +def test_save_downloads_one_language(tmp_path): + respx.get("https://r2/es.mp4").mock( + return_value=httpx.Response(200, content=b"es-bytes") + ) + result = parse_dubbing_result(SUCCESS_BODY) + path = result.save("es", tmp_path / "clip.es.mp4") + assert path.read_bytes() == b"es-bytes" + + +def test_save_rejects_a_language_the_task_did_not_produce(tmp_path): + result = parse_dubbing_result(SUCCESS_BODY) + with pytest.raises(SoniloError): + result.save("de", tmp_path / "clip.de.mp4") + + +@respx.mock +def test_save_all_writes_one_file_per_language(tmp_path): + respx.get("https://r2/es.mp4").mock( + return_value=httpx.Response(200, content=b"es-bytes") + ) + respx.get("https://r2/fr.mp4").mock( + return_value=httpx.Response(200, content=b"fr-bytes") + ) + result = parse_dubbing_result(SUCCESS_BODY) + paths = result.save_all(tmp_path / "out") + assert set(paths) == {"es", "fr"} + assert (tmp_path / "out" / "dubbed.es.mp4").read_bytes() == b"es-bytes" + assert (tmp_path / "out" / "dubbed.fr.mp4").read_bytes() == b"fr-bytes" + + +@respx.mock +async def test_asave_downloads_one_language(tmp_path): + respx.get("https://r2/fr.mp4").mock( + return_value=httpx.Response(200, content=b"fr-bytes") + ) + result = parse_dubbing_result(SUCCESS_BODY) + path = await result.asave("fr", tmp_path / "clip.fr.mp4") + assert path.read_bytes() == b"fr-bytes" From f9bbb4def24edc50c5ff3e9fc7b974649b4e36e2 Mon Sep 17 00:00:00 2001 From: Spencer Qian Date: Fri, 24 Jul 2026 12:26:38 -0700 Subject: [PATCH 03/10] refactor: delegate DubbingResult.save/asave to the shared download helpers Follows the SoundResult pattern of calling _download_to/_adownload_to instead of re-inlining the httpx request and status check. --- src/sonilo/types.py | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/src/sonilo/types.py b/src/sonilo/types.py index 3cc1c42..6a2970f 100644 --- a/src/sonilo/types.py +++ b/src/sonilo/types.py @@ -402,12 +402,7 @@ def save( ) -> Path: """Download one language's dubbed video to `path` and return it. The URL is presigned — no API key is sent.""" - response = httpx.get(self._url(language), follow_redirects=True, timeout=timeout) - if response.status_code >= 400: - raise SoniloError(f"Download failed: HTTP {response.status_code}") - p = Path(path) - p.write_bytes(response.content) - return p + return _download_to(self._url(language), path, timeout) async def asave( self, @@ -417,14 +412,7 @@ async def asave( timeout: float = DOWNLOAD_TIMEOUT, ) -> Path: """Async variant of save().""" - url = self._url(language) - async with httpx.AsyncClient(follow_redirects=True, timeout=timeout) as http: - response = await http.get(url) - if response.status_code >= 400: - raise SoniloError(f"Download failed: HTTP {response.status_code}") - p = Path(path) - p.write_bytes(response.content) - return p + return await _adownload_to(self._url(language), path, timeout) def save_all( self, From 4dfd384d055c3448e0b902cdb4a1e761b30014f5 Mon Sep 17 00:00:00 2001 From: Spencer Qian Date: Fri, 24 Jul 2026 12:31:57 -0700 Subject: [PATCH 04/10] feat: add client.dubbing for POST /v1/dubbing (sync and async) --- src/sonilo/__init__.py | 2 + src/sonilo/_async_client.py | 2 + src/sonilo/_client.py | 2 + src/sonilo/resources/dubbing.py | 95 +++++++++++++++++++++++++++++++++ tests/test_dubbing.py | 65 ++++++++++++++++++++++ 5 files changed, 166 insertions(+) create mode 100644 src/sonilo/resources/dubbing.py diff --git a/src/sonilo/__init__.py b/src/sonilo/__init__.py index 9114f40..75ea159 100644 --- a/src/sonilo/__init__.py +++ b/src/sonilo/__init__.py @@ -13,6 +13,7 @@ TaskTimeoutError, ) from sonilo.types import ( + DubbingResult, MusicAudioMedia, MusicResult, MusicTitle, @@ -31,6 +32,7 @@ "AsyncSonilo", "AuthenticationError", "BadRequestError", + "DubbingResult", "GenerationError", "MusicAudioMedia", "MusicResult", diff --git a/src/sonilo/_async_client.py b/src/sonilo/_async_client.py index 3dfdc58..7c98935 100644 --- a/src/sonilo/_async_client.py +++ b/src/sonilo/_async_client.py @@ -8,6 +8,7 @@ from sonilo._streaming import aiter_events from sonilo.errors import error_from_response from sonilo.resources.account import AsyncAccount +from sonilo.resources.dubbing import AsyncDubbing from sonilo.resources.tasks import AsyncTasks from sonilo.resources.text_to_music import AsyncTextToMusic from sonilo.resources.text_to_sfx import AsyncTextToSfx @@ -49,6 +50,7 @@ def __init__( self.video_to_video_sfx = AsyncVideoToVideoSfx(self) self.video_to_sound = AsyncVideoToSound(self) self.video_to_video_sound = AsyncVideoToVideoSound(self) + self.dubbing = AsyncDubbing(self) self.account = AsyncAccount(self) self.tasks = AsyncTasks(self) diff --git a/src/sonilo/_client.py b/src/sonilo/_client.py index 540ac7f..218e3d2 100644 --- a/src/sonilo/_client.py +++ b/src/sonilo/_client.py @@ -9,6 +9,7 @@ from sonilo._version import __version__ from sonilo.errors import SoniloError, error_from_response from sonilo.resources.account import Account +from sonilo.resources.dubbing import Dubbing from sonilo.resources.tasks import Tasks from sonilo.resources.text_to_music import TextToMusic from sonilo.resources.text_to_sfx import TextToSfx @@ -79,6 +80,7 @@ def __init__( self.video_to_video_sfx = VideoToVideoSfx(self) self.video_to_sound = VideoToSound(self) self.video_to_video_sound = VideoToVideoSound(self) + self.dubbing = Dubbing(self) self.account = Account(self) self.tasks = Tasks(self) diff --git a/src/sonilo/resources/dubbing.py b/src/sonilo/resources/dubbing.py new file mode 100644 index 0000000..d15076e --- /dev/null +++ b/src/sonilo/resources/dubbing.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, List, Optional + +from sonilo._requests import build_dubbing_parts +from sonilo.resources.tasks import ( + DEFAULT_POLL_INTERVAL, + DEFAULT_WAIT_TIMEOUT, + parse_dubbing_result, + parse_sfx_task, +) +from sonilo.types import DubbingResult, SfxTask + +if TYPE_CHECKING: + from sonilo._async_client import AsyncSonilo + from sonilo._client import Sonilo + +PATH = "/v1/dubbing" + + +class Dubbing: + """Dub one video into several target languages. Async only; the result + carries a language → dubbed-video-URL map under `outputs`.""" + + def __init__(self, client: "Sonilo") -> None: + self._client = client + + def submit( + self, + *, + video: Any = None, + video_url: Optional[str] = None, + languages: Optional[List[str]] = None, + ) -> SfxTask: + data, files, opened = build_dubbing_parts(video, video_url, languages) + close_after = files["video"][1] if files is not None and opened else None + return parse_sfx_task( + self._client._post_json(PATH, data=data, files=files, close_after=close_after) + ) + + def generate( + self, + *, + video: Any = None, + video_url: Optional[str] = None, + languages: Optional[List[str]] = None, + poll_interval: float = DEFAULT_POLL_INTERVAL, + timeout: float = DEFAULT_WAIT_TIMEOUT, + ) -> DubbingResult: + task = self.submit(video=video, video_url=video_url, languages=languages) + return self._client.tasks.wait( + task.task_id, + poll_interval=poll_interval, + timeout=timeout, + parser=parse_dubbing_result, + ) + + +class AsyncDubbing: + def __init__(self, client: "AsyncSonilo") -> None: + self._client = client + + async def submit( + self, + *, + video: Any = None, + video_url: Optional[str] = None, + languages: Optional[List[str]] = None, + ) -> SfxTask: + data, files, opened = build_dubbing_parts(video, video_url, languages) + close_after = files["video"][1] if files is not None and opened else None + return parse_sfx_task( + await self._client._post_json( + PATH, data=data, files=files, close_after=close_after + ) + ) + + async def generate( + self, + *, + video: Any = None, + video_url: Optional[str] = None, + languages: Optional[List[str]] = 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 + ) + return await self._client.tasks.wait( + task.task_id, + poll_interval=poll_interval, + timeout=timeout, + parser=parse_dubbing_result, + ) diff --git a/tests/test_dubbing.py b/tests/test_dubbing.py index 8653022..6956cce 100644 --- a/tests/test_dubbing.py +++ b/tests/test_dubbing.py @@ -1,3 +1,5 @@ +from urllib.parse import unquote_plus + import httpx import pytest import respx @@ -77,3 +79,66 @@ async def test_asave_downloads_one_language(tmp_path): result = parse_dubbing_result(SUCCESS_BODY) path = await result.asave("fr", tmp_path / "clip.fr.mp4") assert path.read_bytes() == b"fr-bytes" + + +from sonilo import AsyncSonilo, Sonilo + +ACK = {"task_id": "db1", "status": "processing"} + + +@respx.mock +def test_submit_posts_to_v1_dubbing(): + 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: + task = client.dubbing.submit( + video_url="https://x/v.mp4", languages=["es", "fr"] + ) + assert task.task_id == "db1" + # video_url-only submissions have no file part, so httpx sends this as + # application/x-www-form-urlencoded (not multipart) and percent-escapes + # the JSON array; unquote before checking for the raw JSON substring. + sent = unquote_plus(route.calls.last.request.content.decode()) + assert "video_url" in sent + assert '["es", "fr"]' in sent + + +@respx.mock +def test_generate_polls_to_a_dubbing_result(): + respx.post("https://api.sonilo.com/v1/dubbing").mock( + return_value=httpx.Response(202, json=ACK) + ) + respx.get("https://api.sonilo.com/v1/tasks/db1").mock( + return_value=httpx.Response(200, json=SUCCESS_BODY) + ) + with Sonilo(api_key="sk-test") as client: + result = client.dubbing.generate( + video_url="https://x/v.mp4", languages=["es", "fr"], poll_interval=0 + ) + assert result.outputs["es"] == "https://r2/es.mp4" + assert result.outputs["fr"] == "https://r2/fr.mp4" + + +@respx.mock +def test_submit_rejects_a_non_https_url_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="http://x/v.mp4") + assert not route.called + + +@respx.mock +async def test_async_generate_polls_to_a_dubbing_result(): + respx.post("https://api.sonilo.com/v1/dubbing").mock( + return_value=httpx.Response(202, json=ACK) + ) + respx.get("https://api.sonilo.com/v1/tasks/db1").mock( + return_value=httpx.Response(200, json=SUCCESS_BODY) + ) + async with AsyncSonilo(api_key="sk-test") as client: + result = await client.dubbing.generate( + video_url="https://x/v.mp4", poll_interval=0 + ) + assert result.outputs["fr"] == "https://r2/fr.mp4" From 86717e6a8ba1a0d5019a5a6c851f348b458b9daf Mon Sep 17 00:00:00 2001 From: Spencer Qian Date: Fri, 24 Jul 2026 12:39:05 -0700 Subject: [PATCH 05/10] feat(cli): add the dubbing command --- sonilo-cli/src/sonilo_cli/__main__.py | 56 ++++++++++++++++++++ sonilo-cli/tests/test_cli.py | 74 +++++++++++++++++++++++++++ 2 files changed, 130 insertions(+) diff --git a/sonilo-cli/src/sonilo_cli/__main__.py b/sonilo-cli/src/sonilo_cli/__main__.py index 2eb8b4a..90110fe 100644 --- a/sonilo-cli/src/sonilo_cli/__main__.py +++ b/sonilo-cli/src/sonilo_cli/__main__.py @@ -160,6 +160,41 @@ def cmd_video_to_video_sound(client: Sonilo, args: argparse.Namespace) -> None: _run_sound(client, args, client.video_to_video_sound, "mp4") +# The dubbing backend polls its pipeline for up to 7200s (2 hours), so the +# SDK's DEFAULT_WAIT_TIMEOUT of 600s would routinely abandon a job the user +# has already been charged for. Wait an hour by default; --timeout overrides. +DUBBING_WAIT_TIMEOUT = 3600.0 + + +def _language_path(out: str, language: str) -> str: + """Turn one --output value into a per-language path: `clip.mp4` + `es` + becomes `clip.es.mp4`. A dubbing task returns one video per language, so a + single literal destination cannot express the result. This is the same + transform _stem_path applies for --stem, so both flags read the same way.""" + base = Path(out) + return str(base.with_name(f"{base.stem}.{language}{base.suffix or '.mp4'}")) + + +def cmd_dubbing(client: Sonilo, args: argparse.Namespace) -> None: + out = args.output if args.output is not None else "output.mp4" + languages = None + if args.languages is not 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") + result = client.dubbing.generate( + video=args.video, + video_url=args.video_url, + languages=languages, + timeout=args.timeout, + ) + if not result.outputs: + _fail("task succeeded but returned no dubbed videos") + for language in sorted(result.outputs): + path = result.save(language, _language_path(out, language)) + _wrote(path, path.stat().st_size) + + def _identity(body: Any) -> Any: return body @@ -303,6 +338,27 @@ def build_parser() -> argparse.ArgumentParser: p_v2vsd.add_argument("--output", default=None, help="Where to save the combined video.") p_v2vsd.set_defaults(func=cmd_video_to_video_sound) + p_dub = sub.add_parser("dubbing", help="Dub a video into other languages") + _add_global(p_dub) + _add_video_source(p_dub) + p_dub.add_argument( + "--languages", default=None, + help="Comma-separated target languages. Default: zh_cn,es,fr. " + "Supported: en, zh_cn, ja, ko, pt, es, de, fr, it, ru", + ) + p_dub.add_argument( + "--output", default=None, + help="Filename template; one file is written per language with the code " + "inserted before the extension (clip.mp4 -> clip.es.mp4). " + "Default: output.mp4", + ) + p_dub.add_argument( + "--timeout", type=float, default=DUBBING_WAIT_TIMEOUT, + help="Give up waiting after this many seconds. Default: 3600. A timed-out " + "task is still running — resume it with `sonilo tasks wait `.", + ) + p_dub.set_defaults(func=cmd_dubbing) + p_tasks = sub.add_parser("tasks", help="Inspect async tasks") _add_global(p_tasks) tsub = p_tasks.add_subparsers(dest="tasks_command", metavar="") diff --git a/sonilo-cli/tests/test_cli.py b/sonilo-cli/tests/test_cli.py index f8e959c..4d8c9da 100644 --- a/sonilo-cli/tests/test_cli.py +++ b/sonilo-cli/tests/test_cli.py @@ -1,4 +1,5 @@ import json +from urllib.parse import unquote_plus import httpx import pytest @@ -382,3 +383,76 @@ def test_cli_identifies_itself_not_the_sdk(): assert client._http.headers["x-sonilo-client-version"] == sonilo_cli.__version__ finally: client.close() + + +DUBBING_BODY = { + "task_id": "db1", + "type": "dubbing", + "status": "succeeded", + "outputs": {"es": "https://r2/es.mp4", "fr": "https://r2/fr.mp4"}, +} + + +@respx.mock +def test_dubbing_writes_one_file_per_language(tmp_path): + 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=DUBBING_BODY) + ) + respx.get("https://r2/es.mp4").mock( + return_value=httpx.Response(200, content=b"es-bytes") + ) + respx.get("https://r2/fr.mp4").mock( + return_value=httpx.Response(200, content=b"fr-bytes") + ) + out = tmp_path / "clip.mp4" + run([ + "dubbing", + "--video-url", "https://x/v.mp4", + "--languages", "es,fr", + "--output", str(out), + ]) + assert (tmp_path / "clip.es.mp4").read_bytes() == b"es-bytes" + assert (tmp_path / "clip.fr.mp4").read_bytes() == b"fr-bytes" + + +@respx.mock +def test_dubbing_sends_languages_as_a_json_array(tmp_path): + 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") + ) + run([ + "dubbing", + "--video-url", "https://x/v.mp4", + "--languages", " es , fr ", + "--output", str(tmp_path / "clip.mp4"), + ]) + # With no file part the request body is form-urlencoded, so the JSON + # array arrives percent-encoded (spaces as `+`, quotes as `%22`, etc.). + # Decode before checking for the literal JSON array string. + body = unquote_plus(route.calls.last.request.content.decode()) + assert '["es", "fr"]' in body + + +def test_dubbing_requires_a_video_source(): + with pytest.raises(SystemExit) as exc: + main(["--api-key", "sk-test", "dubbing"]) + assert exc.value.code == 1 + + +@respx.mock +def test_dubbing_non_https_url_exits_1(capsys): + with pytest.raises(SystemExit) as exc: + main(["--api-key", "sk-test", "dubbing", "--video-url", "http://x/v.mp4"]) + assert exc.value.code == 1 From 56951d6f67bfc416ec5d683fd4fc90007676821f Mon Sep 17 00:00:00 2001 From: Spencer Qian Date: Fri, 24 Jul 2026 12:48:21 -0700 Subject: [PATCH 06/10] chore: bump sonilo to 0.6.0 and sonilo-cli to 0.2.0, document dubbing --- README.md | 38 +++++++++++++++++++++++++-- context7.json | 4 ++- pyproject.toml | 2 +- sonilo-cli/README.md | 23 +++++++++++++++- sonilo-cli/pyproject.toml | 4 +-- sonilo-cli/src/sonilo_cli/__init__.py | 2 +- src/sonilo/_version.py | 2 +- 7 files changed, 66 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 766cb2c..ec53572 100644 --- a/README.md +++ b/README.md @@ -179,6 +179,35 @@ poll it yourself with `client.tasks.wait(task_id, parser=parse_sound_result)`. `AsyncSonilo` exposes the same two resources with `await`-able `submit`/`generate` and `asave`/`asave_stem`. +## Dubbing + +`client.dubbing` dubs one video into one or more target languages in a single +async call. Pass exactly one of `video` / `video_url` (`video_url` must be +**https**), plus optional `languages` — it defaults server-side to +`["zh_cn", "es", "fr"]`; supported codes are `en, zh_cn, ja, ko, pt, es, de, +fr, it, ru`. Source videos may be at most 180 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). + +```python +from sonilo import Sonilo + +with Sonilo() as client: + result = client.dubbing.generate( + video_url="https://example.com/clip.mp4", + languages=["es", "fr"], + ) + for language, path in result.save_all("./dubbed").items(): + print(language, path) +``` + +`DubbingResult.outputs` is a language → dubbed-`.mp4`-URL map — there's no +single `output_url` since one call produces multiple videos. Use +`result.save(language, path)` to fetch just one language, or `save_all(dir)` +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)`. + ## Streaming ```python @@ -248,13 +277,18 @@ are presigned and expire; download promptly or re-fetch via `tasks.get`. ## Free trial -Accounts created through self-serve signup start with free runs on every -endpoint — no card required: +Accounts created through self-serve signup start with free runs on most +endpoints — no card required: | Free runs | Endpoints | | --- | --- | | 2 each | text-to-music, text-to-sfx, audio-ducking | | 1 each | video-to-music, video-to-sfx, video-to-video-music, video-to-video-sfx, video-to-sound, video-to-video-sound | +| 0 | dubbing | + +Dubbing bills `video duration × number of languages`, so a free run on it +would be worth far more than a free run on any other endpoint — it has no +free allowance and bills from the first call. Once an endpoint's free runs are used up, calls to it bill at the normal rate. diff --git a/context7.json b/context7.json index 552a3bb..da9d172 100644 --- a/context7.json +++ b/context7.json @@ -25,6 +25,8 @@ "Result media (.url) is a short-lived presigned URL, not the API's own domain — download it with the result's .save() helper; do not send the Authorization header to it.", "Catch AuthenticationError (401), PaymentRequiredError (402), RateLimitError (429) and TaskFailedError (status \"failed\") separately rather than one generic except — callers usually handle these differently. All extend SoniloError.", "video / video_url accept exactly one of the two, never both and never neither — validate before constructing a request.", - "Self-serve accounts start with free runs per endpoint (2 each for text-to-music, text-to-sfx, audio-ducking; 1 each for the video endpoints), then bill normally. A first call succeeding is not evidence that billing is set up." + "Self-serve accounts start with free runs per endpoint (2 each for text-to-music, text-to-sfx, audio-ducking; 1 each for other video endpoints; dubbing gets none), then bill normally. A first call succeeding is not evidence that billing is set up.", + "client.dubbing dubs one video into several languages in a single async call. languages is a list of codes (en, zh_cn, ja, ko, pt, es, de, fr, it, ru); omit it for the default [\"zh_cn\", \"es\", \"fr\"]. You are billed per language, with no free trial runs.", + "A DubbingResult has no audio/video/output_url. Its results live in result.outputs, a map of language code to dubbed .mp4 URL: use result.save(lang, path) or result.save_all(dir). dubbing's video_url must be https." ] } diff --git a/pyproject.toml b/pyproject.toml index fd4e058..2db2ec9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "sonilo" -version = "0.5.1" +version = "0.6.0" description = "Official Python client for the Sonilo API" readme = "README.md" license = "MIT" diff --git a/sonilo-cli/README.md b/sonilo-cli/README.md index 5f13e85..2c6cd74 100644 --- a/sonilo-cli/README.md +++ b/sonilo-cli/README.md @@ -25,6 +25,8 @@ or pass `--api-key sk-...` on any command. sonilo video-to-sound --video clip.mp4 \ --music-prompt "uplifting orchestral score" --sfx-prompt "match the on-screen action" sonilo video-to-video-sound --video clip.mp4 --music-prompt "tense synths" + sonilo dubbing --video-url https://example.com/clip.mp4 --languages es,fr --output dubbed.mp4 + # writes dubbed.es.mp4 and dubbed.fr.mp4 sonilo tasks get sonilo tasks wait --poll-interval 2 --timeout 600 @@ -57,15 +59,34 @@ they differ only in what comes back: `video-to-sound` writes the mixed **audio** music stem lands at `soundtrack.music.m4a`. `music_processed` exists only when `--preserve-speech` or ducking altered the music bed. +### Dubbing + +`dubbing` dubs a video into one or more target languages in a single async call: + + sonilo dubbing --video-url https://example.com/clip.mp4 --languages es,fr --output dubbed.mp4 + # writes dubbed.es.mp4 and dubbed.fr.mp4 + +- `--languages` is comma-separated; omit it to use the server default `zh_cn,es,fr`. Supported + codes: `en, zh_cn, ja, ko, pt, es, de, fr, it, ru`. +- Source videos may be at most 180 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. +- Billing is per language, and dubbing has **no free trial runs** — see [Free trial](#free-trial) + below. + ## Free trial -Accounts created through self-serve signup start with free runs on every endpoint — no card +Accounts created through self-serve signup start with free runs on most endpoints — no card required: | Free runs | Endpoints | | --- | --- | | 2 each | text-to-music, text-to-sfx, audio-ducking | | 1 each | video-to-music, video-to-sfx, video-to-video-music, video-to-video-sfx, video-to-sound, video-to-video-sound | +| 0 | dubbing | + +Dubbing bills `video duration × number of languages`, so a free run on it would be worth far more +than a free run on any other endpoint — it has no free allowance and bills from the first call. Once an endpoint's free runs are used up, calls to it bill at the normal rate. `sonilo account` shows the services available to your key. diff --git a/sonilo-cli/pyproject.toml b/sonilo-cli/pyproject.toml index f20eb40..7553752 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.1.1" +version = "0.2.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.5,<0.6"] +dependencies = ["sonilo>=0.6.0,<0.7"] 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 0dc5d6a..cf86e19 100644 --- a/sonilo-cli/src/sonilo_cli/__init__.py +++ b/sonilo-cli/src/sonilo_cli/__init__.py @@ -1,3 +1,3 @@ -__version__ = "0.1.1" +__version__ = "0.2.0" __all__ = ["__version__"] diff --git a/src/sonilo/_version.py b/src/sonilo/_version.py index dd9b22c..906d362 100644 --- a/src/sonilo/_version.py +++ b/src/sonilo/_version.py @@ -1 +1 @@ -__version__ = "0.5.1" +__version__ = "0.6.0" From ac5ca90a75bb8b904c20240c5ccfb21a9353b495 Mon Sep 17 00:00:00 2001 From: Spencer Qian Date: Fri, 24 Jul 2026 13:12:09 -0700 Subject: [PATCH 07/10] fix(dubbing): address code review findings - README: pass an explicit timeout=3600 in the dubbing sample and explain that the SDK's 600s default wait is far shorter than the dubbing pipeline can take, and that a client-side timeout does not cancel or refund the task. - sonilo-cli README: document the dubbing --timeout default and that a timed-out wait is resumable with `sonilo tasks wait `. - sonilo-cli tests: cover the omitted --languages path (no `languages` field sent) and assert the stderr text of the non-https exit-1 case. - tests/test_dubbing.py: move a stray mid-file import to the header. --- README.md | 8 ++++++++ sonilo-cli/README.md | 3 +++ sonilo-cli/tests/test_cli.py | 27 +++++++++++++++++++++++++++ tests/test_dubbing.py | 3 +-- 4 files changed, 39 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index ec53572..6f435fa 100644 --- a/README.md +++ b/README.md @@ -189,6 +189,13 @@ fr, it, ru`. Source videos may be at most 180 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). +The SDK's default wait is `DEFAULT_WAIT_TIMEOUT` (600 seconds), but the +dubbing pipeline can take much longer than that — especially with several +languages in one call. Pass a longer `timeout` explicitly, as below. Note +that a client-side timeout only stops *waiting*: it does not cancel the task +or refund what's already been billed, so for long jobs prefer `submit()` +plus your own `client.tasks.wait(...)` over `generate()`. + ```python from sonilo import Sonilo @@ -196,6 +203,7 @@ with Sonilo() as client: result = client.dubbing.generate( video_url="https://example.com/clip.mp4", languages=["es", "fr"], + timeout=3600, ) for language, path in result.save_all("./dubbed").items(): print(language, path) diff --git a/sonilo-cli/README.md b/sonilo-cli/README.md index 2c6cd74..42962d7 100644 --- a/sonilo-cli/README.md +++ b/sonilo-cli/README.md @@ -73,6 +73,9 @@ they differ only in what comes back: `video-to-sound` writes the mixed **audio** per language, so `--output clip.mp4` writes `clip.es.mp4`, `clip.fr.mp4`, etc. - Billing is per language, and dubbing has **no free trial runs** — see [Free trial](#free-trial) below. +- `--timeout` defaults to 3600 seconds (longer than other commands' default, since dubbing can run + well past the usual `tasks wait --timeout 600`). If the wait still times out, the task keeps + running server-side — resume watching it with `sonilo tasks wait `. ## Free trial diff --git a/sonilo-cli/tests/test_cli.py b/sonilo-cli/tests/test_cli.py index 4d8c9da..f2254a2 100644 --- a/sonilo-cli/tests/test_cli.py +++ b/sonilo-cli/tests/test_cli.py @@ -456,3 +456,30 @@ def test_dubbing_non_https_url_exits_1(capsys): with pytest.raises(SystemExit) as exc: main(["--api-key", "sk-test", "dubbing", "--video-url", "http://x/v.mp4"]) assert exc.value.code == 1 + assert "https" in capsys.readouterr().err + + +@respx.mock +def test_dubbing_without_languages_omits_the_field(tmp_path): + 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") + ) + run([ + "dubbing", + "--video-url", "https://x/v.mp4", + "--output", str(tmp_path / "clip.mp4"), + ]) + # Omitting --languages must not send a `languages` field at all — the + # server default (["zh_cn", "es", "fr"]) only applies when the field is + # absent. Sending `languages=[]` or the string "None" would silently + # override that default with something else. + assert b"languages" not in route.calls.last.request.content diff --git a/tests/test_dubbing.py b/tests/test_dubbing.py index 6956cce..b7f52ce 100644 --- a/tests/test_dubbing.py +++ b/tests/test_dubbing.py @@ -4,6 +4,7 @@ import pytest import respx +from sonilo import AsyncSonilo, Sonilo from sonilo.errors import SoniloError from sonilo.resources.tasks import parse_dubbing_result @@ -81,8 +82,6 @@ async def test_asave_downloads_one_language(tmp_path): assert path.read_bytes() == b"fr-bytes" -from sonilo import AsyncSonilo, Sonilo - ACK = {"task_id": "db1", "status": "processing"} From a149523538353d3f48bdb4624fe440ef2f4478b1 Mon Sep 17 00:00:00 2001 From: Spencer Qian Date: Fri, 24 Jul 2026 13:38:20 -0700 Subject: [PATCH 08/10] feat: surface the free trial before it runs out, and name it when it does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add TrialExhaustedError (a PaymentRequiredError subclass) for the 402 whose body carries code: "trial_exhausted", so callers can tell "you have never paid us" apart from a funded wallet that ran dry without matching on message text. Type account.services() as AccountServices/TrialQuota TypedDicts — the trial quota was reachable at runtime but invisible to type checkers and undocumented — and document the three 402 codes. sonilo account now prints a one-line trial summary on stderr (stdout stays pure JSON), and the CLI's error handler no longer drops the API's error code. Bumps sonilo to 0.7.0 and sonilo-cli to 0.3.0. --- README.md | 50 ++++++++++++++++++++++++- context7.json | 1 + pyproject.toml | 2 +- sonilo-cli/README.md | 12 +++++- sonilo-cli/pyproject.toml | 4 +- sonilo-cli/src/sonilo_cli/__init__.py | 2 +- sonilo-cli/src/sonilo_cli/__main__.py | 36 ++++++++++++++++-- sonilo-cli/tests/test_cli.py | 53 +++++++++++++++++++++++++++ src/sonilo/__init__.py | 6 +++ src/sonilo/_version.py | 2 +- src/sonilo/errors.py | 14 +++++++ src/sonilo/resources/account.py | 20 +++++++--- src/sonilo/types.py | 35 +++++++++++++++++- tests/test_errors.py | 38 +++++++++++++++++++ 14 files changed, 258 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 6f435fa..a8f8063 100644 --- a/README.md +++ b/README.md @@ -300,6 +300,11 @@ free allowance and bills from the first call. Once an endpoint's free runs are used up, calls to it bill at the normal rate. +The table above is the current default. Read the live numbers from +`account.services()` rather than hard-coding them — see [Account](#account) +below, and [Errors](#errors) for what a spent trial looks like at the call +site. + ## Account ```python @@ -307,10 +312,27 @@ client.account.services() client.account.usage(days=7) ``` +`services()["trial"]` reports the free-trial allowance per service, so an +integration can degrade gracefully *before* a call fails: + +```python +quota = client.account.services().get("trial", {}).get("text_to_music") +if quota and quota["remaining"] == 0: + # Prompt for a payment method instead of firing a call that will 402. + print(f"Free trial spent ({quota['used']}/{quota['granted']}).") +``` + +`trial` is present only for self-serve accounts, so always treat it as +optional; a service missing from the map has no trial allowance rather than +an unlimited one. `AccountServices` and `TrialQuota` are exported as +`TypedDict`s for type checking — the return value is a plain `dict` at +runtime. + ## Errors All errors extend `SoniloError`: `AuthenticationError` (401), -`PaymentRequiredError` (402), `RateLimitError` (429, `.retry_after`), +`PaymentRequiredError` (402), `TrialExhaustedError` (402, a subclass of +`PaymentRequiredError`), `RateLimitError` (429, `.retry_after`), `BadRequestError` (400/413/422, `.detail`), `APIError` (anything else), `GenerationError` for failures mid-stream, `TaskFailedError` (`.code`, `.task_id`, `.refunded`) for a failed SFX task, and `TaskTimeoutError` @@ -320,3 +342,29 @@ Every `APIError` also carries `.status_code`, `.body` (the parsed response), `.code` (the API's error code, e.g. `"rate_limit_exceeded"`), and `.errors` (the validation detail list on a 422), in addition to any subclass-specific attributes above. + +### The three 402s + +A `402` is not one condition. Branch on the class (or equivalently on +`.code`), never on the message text: + +```python +from sonilo import PaymentRequiredError, TrialExhaustedError + +try: + client.text_to_music.generate(prompt="lofi", duration=30) +except TrialExhaustedError: + # code: "trial_exhausted" — the free trial for this service is spent and + # the account has never been funded. Prompt for a payment method; a retry + # can never succeed. + ... +except PaymentRequiredError as exc: + # code: "insufficient_balance" — a funded wallet ran dry. Add balance and + # retry the same request. + # code: "payment_required" — anything else, e.g. a suspended account. + print(exc.code) +``` + +`TrialExhaustedError` subclasses `PaymentRequiredError`, so an existing +`except PaymentRequiredError` keeps catching every 402 — order the handlers +most-specific-first if you want to tell them apart. diff --git a/context7.json b/context7.json index da9d172..b92b45c 100644 --- a/context7.json +++ b/context7.json @@ -26,6 +26,7 @@ "Catch AuthenticationError (401), PaymentRequiredError (402), RateLimitError (429) and TaskFailedError (status \"failed\") separately rather than one generic except — callers usually handle these differently. All extend SoniloError.", "video / video_url accept exactly one of the two, never both and never neither — validate before constructing a request.", "Self-serve accounts start with free runs per endpoint (2 each for text-to-music, text-to-sfx, audio-ducking; 1 each for other video endpoints; dubbing gets none), then bill normally. A first call succeeding is not evidence that billing is set up.", + "Before a paid call, read client.account.services().get(\"trial\", {}) and degrade gracefully when a service's remaining is 0: that call raises TrialExhaustedError (402 trial_exhausted), which no retry fixes — ask for a payment method. trial may be absent.", "client.dubbing dubs one video into several languages in a single async call. languages is a list of codes (en, zh_cn, ja, ko, pt, es, de, fr, it, ru); omit it for the default [\"zh_cn\", \"es\", \"fr\"]. You are billed per language, with no free trial runs.", "A DubbingResult has no audio/video/output_url. Its results live in result.outputs, a map of language code to dubbed .mp4 URL: use result.save(lang, path) or result.save_all(dir). dubbing's video_url must be https." ] diff --git a/pyproject.toml b/pyproject.toml index 2db2ec9..3190109 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "sonilo" -version = "0.6.0" +version = "0.7.0" description = "Official Python client for the Sonilo API" readme = "README.md" license = "MIT" diff --git a/sonilo-cli/README.md b/sonilo-cli/README.md index 42962d7..65c680b 100644 --- a/sonilo-cli/README.md +++ b/sonilo-cli/README.md @@ -91,5 +91,13 @@ required: Dubbing bills `video duration × number of languages`, so a free run on it would be worth far more than a free run on any other endpoint — it has no free allowance and bills from the first call. -Once an endpoint's free runs are used up, calls to it bill at the normal rate. `sonilo account` -shows the services available to your key. +The table above is the current default. `sonilo account` prints the live numbers: the account JSON +goes to stdout, and when the account has a free-trial allowance one summary line goes to stderr: + + Free trial: text-to-music 1/2 left, video-to-music 0/1 left + +Because the summary is on stderr, `sonilo account | jq .trial` still sees clean JSON. + +Once an endpoint's free runs are used up, calls to it bill at the normal rate — or, if the account +has never been funded, fail with `HTTP 402: ... (trial_exhausted)` until a payment method is added. +That is the one 402 a retry can never fix. diff --git a/sonilo-cli/pyproject.toml b/sonilo-cli/pyproject.toml index 7553752..4982263 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.2.0" +version = "0.3.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.6.0,<0.7"] +dependencies = ["sonilo>=0.7.0,<0.8"] 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 cf86e19..ec754d1 100644 --- a/sonilo-cli/src/sonilo_cli/__init__.py +++ b/sonilo-cli/src/sonilo_cli/__init__.py @@ -1,3 +1,3 @@ -__version__ = "0.2.0" +__version__ = "0.3.0" __all__ = ["__version__"] diff --git a/sonilo-cli/src/sonilo_cli/__main__.py b/sonilo-cli/src/sonilo_cli/__main__.py index 90110fe..9aabf82 100644 --- a/sonilo-cli/src/sonilo_cli/__main__.py +++ b/sonilo-cli/src/sonilo_cli/__main__.py @@ -6,11 +6,11 @@ import sys import time from pathlib import Path -from typing import Any, List, NoReturn, Optional +from typing import Any, Dict, List, NoReturn, Optional from urllib.parse import urlparse from sonilo import Sonilo -from sonilo.errors import SoniloError +from sonilo.errors import APIError, SoniloError from sonilo_cli import __version__ @@ -48,8 +48,33 @@ def build_client(api_key: Optional[str]) -> Sonilo: return Sonilo(api_key=key, client_name="cli-python", client_version=__version__) +def format_trial_summary(trial: Optional[Dict[str, Any]]) -> Optional[str]: + """One-line human summary of the free-trial allowance, e.g. + "Free trial: text-to-music 1/2 left, video-to-music 0/1 left". + + Returns None when there is nothing to report — the `trial` field is + present only for self-serve accounts, and printing an empty "Free trial:" + label would read as a bug. + """ + if not trial: + return None + parts = [ + # Service keys are task_types (text_to_music); show them the way the + # endpoints and the error messages spell them (text-to-music). + f"{service.replace('_', '-')} {quota['remaining']}/{quota['granted']} left" + for service, quota in trial.items() + ] + return f"Free trial: {', '.join(parts)}" + + def cmd_account(client: Sonilo, args: argparse.Namespace) -> None: - _print_json(client.account.services()) + services = client.account.services() + _print_json(services) + # stdout stays pure JSON so `sonilo account | jq` keeps working; the + # human-readable summary goes to stderr. + summary = format_trial_summary(services.get("trial")) + if summary is not None: + sys.stderr.write(f"{summary}\n") def cmd_usage(client: Sonilo, args: argparse.Namespace) -> None: @@ -389,6 +414,11 @@ def main(argv: Optional[List[str]] = None) -> None: client = build_client(getattr(args, "api_key", None)) try: func(client, args) + except APIError as exc: + # Show the API's error code alongside the message: it is what the + # docs tell people to branch on, and "(trial_exhausted)" is the + # difference between "add a payment method" and "retry later". + _fail(f"{exc}{f' ({exc.code})' if exc.code else ''}") except SoniloError as exc: _fail(str(exc)) finally: diff --git a/sonilo-cli/tests/test_cli.py b/sonilo-cli/tests/test_cli.py index f2254a2..18f9be7 100644 --- a/sonilo-cli/tests/test_cli.py +++ b/sonilo-cli/tests/test_cli.py @@ -25,6 +25,59 @@ def test_account_prints_json(capsys): assert out == {"plan": "pro"} +@respx.mock +def test_account_prints_trial_summary_on_stderr(capsys): + services = { + "available_services": ["text_to_music", "video_to_music"], + "rpm_limit": 60, + "concurrency_limit": 5, + "discount_factor": 1.0, + "max_upload_size_mb": 300, + "trial": { + "text_to_music": {"granted": 2, "used": 1, "remaining": 1}, + "video_to_music": {"granted": 1, "used": 1, "remaining": 0}, + }, + } + respx.get(f"{BASE}/v1/account/services").mock( + return_value=httpx.Response(200, json=services) + ) + run(["account"]) + captured = capsys.readouterr() + # stdout stays parseable JSON; the summary is on stderr. + assert json.loads(captured.out) == services + assert captured.err.strip() == ( + "Free trial: text-to-music 1/2 left, video-to-music 0/1 left" + ) + + +@respx.mock +def test_account_prints_no_summary_without_trial(capsys): + respx.get(f"{BASE}/v1/account/services").mock( + return_value=httpx.Response(200, json={"available_services": []}) + ) + run(["account"]) + assert capsys.readouterr().err == "" + + +@respx.mock +def test_trial_exhausted_error_shows_the_code(capsys): + respx.get(f"{BASE}/v1/account/services").mock( + return_value=httpx.Response( + 402, + json={ + "code": "trial_exhausted", + "message": "You've used your 2 free trial calls for text-to-music.", + }, + ) + ) + with pytest.raises(SystemExit) as exc: + run(["account"]) + assert exc.value.code == 1 + err = capsys.readouterr().err + assert "free trial calls for text-to-music" in err + assert "(trial_exhausted)" in err + + @respx.mock def test_usage_passes_days(capsys): route = respx.get(f"{BASE}/v1/account/usage").mock( diff --git a/src/sonilo/__init__.py b/src/sonilo/__init__.py index 75ea159..1b07c55 100644 --- a/src/sonilo/__init__.py +++ b/src/sonilo/__init__.py @@ -11,8 +11,10 @@ SoniloError, TaskFailedError, TaskTimeoutError, + TrialExhaustedError, ) from sonilo.types import ( + AccountServices, DubbingResult, MusicAudioMedia, MusicResult, @@ -24,11 +26,13 @@ SfxTask, StreamEvent, Track, + TrialQuota, VideoResult, ) __all__ = [ "APIError", + "AccountServices", "AsyncSonilo", "AuthenticationError", "BadRequestError", @@ -50,6 +54,8 @@ "TaskFailedError", "TaskTimeoutError", "Track", + "TrialExhaustedError", + "TrialQuota", "VideoResult", "__version__", ] diff --git a/src/sonilo/_version.py b/src/sonilo/_version.py index 906d362..49e0fc1 100644 --- a/src/sonilo/_version.py +++ b/src/sonilo/_version.py @@ -1 +1 @@ -__version__ = "0.6.0" +__version__ = "0.7.0" diff --git a/src/sonilo/errors.py b/src/sonilo/errors.py index 1125b09..dcc3439 100644 --- a/src/sonilo/errors.py +++ b/src/sonilo/errors.py @@ -33,6 +33,16 @@ class PaymentRequiredError(APIError): pass +class TrialExhaustedError(PaymentRequiredError): + """The free trial for this service is spent and the account has never been + funded — ask for a payment method rather than retrying. + + A subclass of PaymentRequiredError, so code that already catches every 402 + keeps working; catch this one first to tell it apart from a funded wallet + that ran dry (``code == "insufficient_balance"``). + """ + + class BadRequestError(APIError): @property def detail(self) -> Optional[str]: @@ -117,6 +127,10 @@ def error_from_response(response: httpx.Response) -> APIError: if status == 401: return AuthenticationError(message, status, body) if status == 402: + # Branch on the API's `code`, never on the message text — the wording + # is product copy and changes; the code is the contract. + if isinstance(body, dict) and body.get("code") == "trial_exhausted": + return TrialExhaustedError(message, status, body) return PaymentRequiredError(message, status, body) if status == 429: raw = response.headers.get("retry-after") diff --git a/src/sonilo/resources/account.py b/src/sonilo/resources/account.py index bb42534..c9bec86 100644 --- a/src/sonilo/resources/account.py +++ b/src/sonilo/resources/account.py @@ -1,6 +1,8 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, Dict, Optional +from typing import TYPE_CHECKING, Any, Dict, Optional, cast + +from sonilo.types import AccountServices if TYPE_CHECKING: from sonilo._async_client import AsyncSonilo @@ -11,8 +13,13 @@ class Account: def __init__(self, client: "Sonilo") -> None: self._client = client - def services(self) -> Dict[str, Any]: - return self._client._get_json("/v1/account/services") + def services(self) -> AccountServices: + """Plan limits, available services and — for self-serve accounts — + the free-trial allowance under `trial`. Read + `trial[service]["remaining"]` before a generation call to degrade + gracefully instead of hitting a 402 `trial_exhausted`. Still a plain + dict at runtime; AccountServices is a typing aid, not a model.""" + return cast(AccountServices, self._client._get_json("/v1/account/services")) def usage(self, *, days: Optional[int] = None) -> Dict[str, Any]: params = {"days": days} if days is not None else None @@ -23,8 +30,11 @@ class AsyncAccount: def __init__(self, client: "AsyncSonilo") -> None: self._client = client - async def services(self) -> Dict[str, Any]: - return await self._client._get_json("/v1/account/services") + async def services(self) -> AccountServices: + """Async variant of Account.services().""" + return cast( + AccountServices, await self._client._get_json("/v1/account/services") + ) async def usage(self, *, days: Optional[int] = None) -> Dict[str, Any]: params = {"days": days} if days is not None else None diff --git a/src/sonilo/types.py b/src/sonilo/types.py index 6a2970f..21db947 100644 --- a/src/sonilo/types.py +++ b/src/sonilo/types.py @@ -3,7 +3,7 @@ import httpx from dataclasses import dataclass, field from pathlib import Path -from typing import Any, Dict, List, Optional, Union +from typing import Any, Dict, List, Optional, TypedDict, Union from sonilo.errors import SoniloError @@ -22,6 +22,39 @@ Segment) require `end`, must start at 0, and be contiguous; validated server-side.""" +class TrialQuota(TypedDict): + """One service's free-trial allowance. `remaining` is already floored at + 0, so it is safe to compare directly.""" + + granted: int + used: int + remaining: int + + +class _AccountServicesRequired(TypedDict): + """The always-present half of AccountServices. Split out because `trial` + is optional and Required/NotRequired only exist from Python 3.11.""" + + available_services: List[str] + rpm_limit: int + concurrency_limit: int + discount_factor: Union[float, str] + max_upload_size_mb: Optional[int] + + +class AccountServices(_AccountServicesRequired, total=False): + """Shape of `GET /v1/account/services` (`client.account.services()`). + + Still a plain dict at runtime — this is a typing aid, not a parsed model. + """ + + trial: Dict[str, TrialQuota] + """Free-trial allowance keyed by service (`granted` / `used` / + `remaining`). Present only for self-serve accounts, so always treat it as + possibly absent; a service missing from the map has no trial allowance + rather than an unlimited one.""" + + @dataclass class Track: audio: bytes diff --git a/tests/test_errors.py b/tests/test_errors.py index 2c1667a..cdd2d11 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -9,6 +9,7 @@ PaymentRequiredError, RateLimitError, SoniloError, + TrialExhaustedError, error_from_response, ) @@ -47,9 +48,46 @@ def test_402_maps_to_payment_required(): make_response(402, {"code": "payment_required", "message": "Insufficient balance"}) ) assert isinstance(err, PaymentRequiredError) + assert not isinstance(err, TrialExhaustedError) assert "Insufficient balance" in str(err) +def test_402_with_trial_exhausted_code_maps_to_trial_exhausted(): + err = error_from_response( + make_response( + 402, + { + "code": "trial_exhausted", + "message": ( + "You've used your 2 free trial calls for text-to-music. " + "Add a payment method to continue: " + "https://platform.sonilo.com/dashboard/billing" + ), + }, + ) + ) + assert isinstance(err, TrialExhaustedError) + assert err.code == "trial_exhausted" + # Still a PaymentRequiredError, so callers catching every 402 keep working. + assert isinstance(err, PaymentRequiredError) + assert "free trial calls for text-to-music" in str(err) + + +def test_402_with_insufficient_balance_code_is_not_trial_exhausted(): + err = error_from_response( + make_response( + 402, + { + "code": "insufficient_balance", + "message": "Insufficient balance: need 0.4320, have 0.1000", + }, + ) + ) + assert isinstance(err, PaymentRequiredError) + assert not isinstance(err, TrialExhaustedError) + assert err.code == "insufficient_balance" + + def test_404_maps_to_api_error_with_code(): err = error_from_response( make_response(404, {"code": "not_found", "message": "Task not found"}) From b7e51bdb841854c2698f324358e299c9d67da488 Mon Sep 17 00:00:00 2001 From: Spencer Qian Date: Fri, 24 Jul 2026 14:31:26 -0700 Subject: [PATCH 09/10] fix(dubbing): wait 7200s, matching the backend's own ceiling The dubbing backend polls its pipeline for up to 7200s. Giving up at 3600s still abandons jobs that are running fine, leaving a caller charged for videos they never receive. Match the backend exactly so the client gives up only once the backend has. --- README.md | 11 ++++++----- sonilo-cli/README.md | 7 ++++--- sonilo-cli/src/sonilo_cli/__main__.py | 12 +++++++----- 3 files changed, 17 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index a8f8063..794af3f 100644 --- a/README.md +++ b/README.md @@ -191,10 +191,11 @@ no free trial allowance — see [Free trial](#free-trial). The SDK's default wait is `DEFAULT_WAIT_TIMEOUT` (600 seconds), but the dubbing pipeline can take much longer than that — especially with several -languages in one call. Pass a longer `timeout` explicitly, as below. Note -that a client-side timeout only stops *waiting*: it does not cancel the task -or refund what's already been billed, so for long jobs prefer `submit()` -plus your own `client.tasks.wait(...)` over `generate()`. +languages in one call. Pass a longer `timeout` explicitly: 7200 seconds +matches the backend's own ceiling for a dubbing job, and is what the CLI +defaults to. Note that a client-side timeout only stops *waiting* — it does +not cancel the task or refund what's already been billed, so for long jobs +prefer `submit()` plus your own `client.tasks.wait(...)` over `generate()`. ```python from sonilo import Sonilo @@ -203,7 +204,7 @@ with Sonilo() as client: result = client.dubbing.generate( video_url="https://example.com/clip.mp4", languages=["es", "fr"], - timeout=3600, + timeout=7200, ) for language, path in result.save_all("./dubbed").items(): print(language, path) diff --git a/sonilo-cli/README.md b/sonilo-cli/README.md index 65c680b..f0eb305 100644 --- a/sonilo-cli/README.md +++ b/sonilo-cli/README.md @@ -73,9 +73,10 @@ they differ only in what comes back: `video-to-sound` writes the mixed **audio** per language, so `--output clip.mp4` writes `clip.es.mp4`, `clip.fr.mp4`, etc. - Billing is per language, and dubbing has **no free trial runs** — see [Free trial](#free-trial) below. -- `--timeout` defaults to 3600 seconds (longer than other commands' default, since dubbing can run - well past the usual `tasks wait --timeout 600`). If the wait still times out, the task keeps - running server-side — resume watching it with `sonilo tasks wait `. +- `--timeout` defaults to 7200 seconds, matching the backend's own ceiling for a dubbing job + (far longer than other commands' default, since dubbing can run well past the usual + `tasks wait --timeout 600`). If the wait still times out, the task keeps running + server-side — resume watching it with `sonilo tasks wait `. ## Free trial diff --git a/sonilo-cli/src/sonilo_cli/__main__.py b/sonilo-cli/src/sonilo_cli/__main__.py index 9aabf82..2142402 100644 --- a/sonilo-cli/src/sonilo_cli/__main__.py +++ b/sonilo-cli/src/sonilo_cli/__main__.py @@ -185,10 +185,11 @@ def cmd_video_to_video_sound(client: Sonilo, args: argparse.Namespace) -> None: _run_sound(client, args, client.video_to_video_sound, "mp4") -# The dubbing backend polls its pipeline for up to 7200s (2 hours), so the -# SDK's DEFAULT_WAIT_TIMEOUT of 600s would routinely abandon a job the user -# has already been charged for. Wait an hour by default; --timeout overrides. -DUBBING_WAIT_TIMEOUT = 3600.0 +# Matched to the dubbing backend's own ceiling: it polls its pipeline for up +# to 7200s (2 hours), so anything shorter abandons a job the user has already +# been charged for. The SDK's generic DEFAULT_WAIT_TIMEOUT of 600s is far too +# short for this endpoint. --timeout overrides. +DUBBING_WAIT_TIMEOUT = 7200.0 def _language_path(out: str, language: str) -> str: @@ -379,7 +380,8 @@ def build_parser() -> argparse.ArgumentParser: ) p_dub.add_argument( "--timeout", type=float, default=DUBBING_WAIT_TIMEOUT, - help="Give up waiting after this many seconds. Default: 3600. A timed-out " + help="Give up waiting after this many seconds. Default: 7200, matching the " + "backend's own ceiling for a dubbing job. A timed-out " "task is still running — resume it with `sonilo tasks wait `.", ) p_dub.set_defaults(func=cmd_dubbing) From e02a87d0820b24547397860dfc95b041abf2523b Mon Sep 17 00:00:00 2001 From: Spencer Qian Date: Fri, 24 Jul 2026 14:59:11 -0700 Subject: [PATCH 10/10] fix(video-kit): widen the sonilo range to <1.0 so the workspace resolves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sonilo-video-kit pinned sonilo<0.6, so every editable install of the workspace broke the moment sonilo went to 0.6.0 — CI could not even reach the test step. Widening to <1.0 matches what the JS video-kit already does (>=0.5.1 <1.0.0) and stops this recurring on the next minor bump. Bumped to 0.1.3 so the published package carries the working range too. --- sonilo-video-kit/pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sonilo-video-kit/pyproject.toml b/sonilo-video-kit/pyproject.toml index 2aaefaf..89aa594 100644 --- a/sonilo-video-kit/pyproject.toml +++ b/sonilo-video-kit/pyproject.toml @@ -4,13 +4,13 @@ build-backend = "hatchling.build" [project] name = "sonilo-video-kit" -version = "0.1.2" +version = "0.1.3" description = "Video helpers for the Sonilo API: generate a soundtrack and mix it into your video with ffmpeg" readme = "README.md" license = "MIT" requires-python = ">=3.9" authors = [{ name = "Sonilo AI" }] -dependencies = ["sonilo>=0.3,<0.6"] +dependencies = ["sonilo>=0.3,<1.0"] keywords = ["sonilo", "video", "music", "ffmpeg", "soundtrack", "ducking", "ai"] [project.urls]