From d0a361a28615967c300d35c8f7dac14910b04b93 Mon Sep 17 00:00:00 2001 From: Spencer Qian Date: Fri, 18 Sep 2026 21:27:03 -0700 Subject: [PATCH] dubbing: carry the free 15-second preview on DubbingResult; release sonilo 0.21.0 and sonilo-cli 0.20.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The API now gives every self-serve account one free video translation run as a 15-second preview: the first single-language call without scripts translates only the first 15 seconds, at no charge, and the task carries `trial_preview` (preview_seconds, source_duration_seconds, trimmed, languages, full_video_cost_usd, message). DubbingResult dropped that field, and both READMEs and context7 said dubbing had no free trial. - DubbingResult.trial_preview (dict or None), parsed whole; a non-object reads as "not a preview". - sonilo-cli: after the "Wrote …" line, print the preview's message so the 15 s clip is never mistaken for the whole video. - READMEs (free-trial tables + dubbing sections), context7 rule. - sonilo 0.21.0 (pyproject + _version), sonilo-cli 0.20.0 with its core pin widened to >=0.21.0,<0.22. --- README.md | 17 ++++++++++------- context7.json | 2 +- 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 | 8 ++++++++ src/sonilo/_version.py | 2 +- src/sonilo/resources/tasks.py | 7 +++++++ src/sonilo/types.py | 9 +++++++++ tests/test_dubbing.py | 14 ++++++++++++++ 11 files changed, 61 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 6e6fdf0..558f584 100644 --- a/README.md +++ b/README.md @@ -421,8 +421,8 @@ The background bed is rebuilt either way, so `ducking` behaves the same. 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). +costs three times as much as one. Dubbing's one free run is a 15-second +preview — 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 @@ -707,11 +707,14 @@ endpoints — no card required: | --- | --- | | 2 each | text-to-music, text-to-sfx, audio-ducking, video-analysis, proofread | | 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. +| 1, as a 15-second preview | dubbing | + +Dubbing bills `video duration × number of languages`, so its free run is a +preview rather than a full call: the first single-language call without +scripts translates only the first 15 seconds of the video, at no charge, and +the result's `trial_preview` says so and quotes what the whole video would +cost (`full_video_cost_usd`). Several languages, scripts, and every call +after that are billed. 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 9f2f27f..7686de1 100644 --- a/context7.json +++ b/context7.json @@ -30,7 +30,7 @@ "Self-serve accounts start with free runs per endpoint (2 each for text-to-music, text-to-sfx, audio-ducking, video-analysis, proofread; 1 each for other video endpoints; none for dubbing). A first call succeeding is not proof billing works.", "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 \u2014 ask for a payment method. trial may be absent.", "client.audio_ducking ducks an EXISTING music bed under an EXISTING voice track; nothing is generated. One of voice/voice_url, one of music/music_url. Voice may be audio or video (video returns a .mp4); music must be audio. Result: output_url, no stems.", - "client.dubbing dubs one video into many languages in one async call; languages: en, zh_cn, ja, ko, pt, pt_br, es, es_419, de, fr, it, ru, th, ar, tr, vi, id, ta, ml, kn, gu, pa_in, sd_in, hi; default zh_cn,es,fr. Billed per language, no free trial.", + "client.dubbing dubs one video into many languages in one call; languages: en, zh_cn, ja, ko, pt, pt_br, es, es_419, de, fr, it, ru, th, ar, tr, vi, id, ta, ml, kn, gu, pa_in, sd_in, hi; default zh_cn,es,fr. Billed per language; 1 free 15 s preview.", "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.", "client.video_analysis returns a creative BRIEF, not media: analyze() (not generate()), no save(). Music brief: result.segments (start/end/label/prompt) + result.variations[i].prompt; mode both (default) adds result.sfx_segments + result.sfx_prompt.", "Pass a video_analysis variation's prompt straight to video_to_music / video_to_sfx / video_to_sound as their prompt. One of video/video_url plus optional prompt, variants_num (1-5, billed per brief), mode (both/music/sfx, same price); max 480s, 10s floor.", diff --git a/pyproject.toml b/pyproject.toml index 2cc3412..5e031d5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "sonilo" -version = "0.20.0" +version = "0.21.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 5759123..f34cf2c 100644 --- a/sonilo-cli/README.md +++ b/sonilo-cli/README.md @@ -346,8 +346,8 @@ rendered. 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. +- Billing is per language. The one free run is a **15-second preview** — see + [Free trial](#free-trial) below; the preview's message is printed right under the `Wrote …` line. - `--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 @@ -362,10 +362,12 @@ required: | --- | --- | | 2 each | text-to-music, text-to-sfx, audio-ducking, video-analysis, proofread | | 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 | +| 1, as a 15-second preview | 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. +Dubbing bills `video duration × number of languages`, so its free run is a preview rather than a +full call: the first single-language call without scripts translates only the first 15 seconds +of the video, at no charge, and prints what the whole video would cost. Several languages, +scripts, and every call after that are billed. 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: diff --git a/sonilo-cli/pyproject.toml b/sonilo-cli/pyproject.toml index 233a692..d05d1f2 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.19.0" +version = "0.20.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.20.0,<0.21"] +dependencies = ["sonilo>=0.21.0,<0.22"] 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 dbd8a6b..28db132 100644 --- a/sonilo-cli/src/sonilo_cli/__init__.py +++ b/sonilo-cli/src/sonilo_cli/__init__.py @@ -1,3 +1,3 @@ -__version__ = "0.19.0" +__version__ = "0.20.0" __all__ = ["__version__"] diff --git a/sonilo-cli/src/sonilo_cli/__main__.py b/sonilo-cli/src/sonilo_cli/__main__.py index 5c666df..3c5ad48 100644 --- a/sonilo-cli/src/sonilo_cli/__main__.py +++ b/sonilo-cli/src/sonilo_cli/__main__.py @@ -717,6 +717,14 @@ 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 result.trial_preview: + # The free preview translated only the first 15 seconds. Say so right + # under the "Wrote …" line, so nobody mistakes the clip for the whole + # video. + print( + result.trial_preview.get("message") + or "Free preview: only the first 15 seconds of the video were translated." + ) if not args.export_srt: return # The .srt lands beside its video (clip.es.mp4 -> clip.es.srt). A blocked diff --git a/src/sonilo/_version.py b/src/sonilo/_version.py index 5f4bb0b..6a726d8 100644 --- a/src/sonilo/_version.py +++ b/src/sonilo/_version.py @@ -1 +1 @@ -__version__ = "0.20.0" +__version__ = "0.21.0" diff --git a/src/sonilo/resources/tasks.py b/src/sonilo/resources/tasks.py index 6a93f2a..5bd8808 100644 --- a/src/sonilo/resources/tasks.py +++ b/src/sonilo/resources/tasks.py @@ -279,6 +279,13 @@ def parse_dubbing_result(body: Dict[str, Any]) -> "DubbingResult": 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")), + # Passed through whole (its fields are server-owned, like the + # reports'); anything that is not an object reads as "not a + # preview" rather than raising on a paid run. + trial_preview=( + body["trial_preview"] + if isinstance(body.get("trial_preview"), dict) else None + ), 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 f1bf098..7f06b44 100644 --- a/src/sonilo/types.py +++ b/src/sonilo/types.py @@ -627,6 +627,14 @@ class DubbingResult: `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. + + `trial_preview` is present, in every task state, when this run was the + account's free preview: a self-serve account's first single-language call + without scripts translates only the first 15 seconds of the video, at no + charge. It carries `preview_seconds`, `source_duration_seconds`, + `trimmed`, `languages`, `full_video_cost_usd` (what translating the whole + video would cost) and a ready-made `message`. `duration_seconds` is then + the preview's length, not the source's. None on every paid run. """ task_id: str @@ -636,6 +644,7 @@ class DubbingResult: 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) + trial_preview: Optional[Dict[str, Any]] = None duration_seconds: Optional[float] = None cost: Optional[float] = None error: Optional[Dict[str, Any]] = None diff --git a/tests/test_dubbing.py b/tests/test_dubbing.py index a6b2b26..5133ad3 100644 --- a/tests/test_dubbing.py +++ b/tests/test_dubbing.py @@ -216,6 +216,20 @@ def test_parse_dubbing_result_defaults_the_subtitle_maps_to_empty(): assert result.subtitle_export == {} +def test_parse_dubbing_result_carries_the_free_preview(): + preview = { + "preview_seconds": 15, "source_duration_seconds": 60.0, "trimmed": True, + "languages": 1, "full_video_cost_usd": 3.49, "message": "Free preview: …", + } + result = parse_dubbing_result({**SUCCESS_BODY, "trial_preview": preview}) + assert result.trial_preview == preview + + +def test_parse_dubbing_result_has_no_preview_on_a_paid_run(): + assert parse_dubbing_result(SUCCESS_BODY).trial_preview is None + assert parse_dubbing_result({**SUCCESS_BODY, "trial_preview": "nope"}).trial_preview is None + + def test_parse_dubbing_result_drops_malformed_report_entries(): result = parse_dubbing_result({ "task_id": "db1", "status": "succeeded",