Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 94 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,44 @@ 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).

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: 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

with Sonilo() as client:
result = client.dubbing.generate(
video_url="https://example.com/clip.mp4",
languages=["es", "fr"],
timeout=7200,
)
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
Expand Down Expand Up @@ -248,27 +286,54 @@ 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.

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
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`
Expand All @@ -278,3 +343,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.
5 changes: 4 additions & 1 deletion context7.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@
"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.",
"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."
]
}
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "sonilo"
version = "0.5.1"
version = "0.7.0"
description = "Official Python client for the Sonilo API"
readme = "README.md"
license = "MIT"
Expand Down
39 changes: 36 additions & 3 deletions sonilo-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <task-id>
sonilo tasks wait <task-id> --poll-interval 2 --timeout 600

Expand Down Expand Up @@ -57,15 +59,46 @@ 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.
- `--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 <task-id>`.

## 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.

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. `sonilo account`
shows the services available to your key.
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.
4 changes: 2 additions & 2 deletions sonilo-cli/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@ build-backend = "hatchling.build"

[project]
name = "sonilo-cli"
version = "0.1.1"
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.5,<0.6"]
dependencies = ["sonilo>=0.7.0,<0.8"]
keywords = ["sonilo", "cli", "music", "sfx", "text-to-music", "video-to-music", "ai"]

[project.urls]
Expand Down
2 changes: 1 addition & 1 deletion sonilo-cli/src/sonilo_cli/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
__version__ = "0.1.1"
__version__ = "0.3.0"

__all__ = ["__version__"]
94 changes: 91 additions & 3 deletions sonilo-cli/src/sonilo_cli/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -160,6 +185,42 @@ def cmd_video_to_video_sound(client: Sonilo, args: argparse.Namespace) -> None:
_run_sound(client, args, client.video_to_video_sound, "mp4")


# 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:
"""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

Expand Down Expand Up @@ -303,6 +364,28 @@ 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: 7200, matching the "
"backend's own ceiling for a dubbing job. A timed-out "
"task is still running — resume it with `sonilo tasks wait <task-id>`.",
)
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="<get|wait>")
Expand Down Expand Up @@ -333,6 +416,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:
Expand Down
Loading
Loading