Skip to content
47 changes: 47 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -440,6 +440,53 @@ for all of them; `AsyncSonilo` exposes the same shape with `asave`/`asave_all`.
Use `submit()` instead of `generate()` to get a `task_id` back immediately and
poll it yourself with `client.tasks.wait(task_id, parser=parse_dubbing_result)`.

### Your own script per language

Pass `subtitles` to say exactly what each language should say: a map of
language code to an `.srt`/`.vtt` file path or an https URL. These are
**target-language** scripts — one per language being dubbed into, carrying the
lines you want spoken — not source-language transcripts. The set of languages
must match `languages` exactly; the server checks that, and a mismatch is a
`422` before anything is charged. Each uploaded script is at most 1 MiB.

Add `export_srt=True` (it requires `subtitles`) to get a re-timed `.srt` per
language back: after the dub is delivered, each language's final audio is
force-aligned against its script and the lines come back verbatim with new
timings.

```python
result = client.dubbing.generate(
video_url="https://example.com/clip.mp4",
languages=["es", "fr"],
subtitles={"es": "spanish.srt", "fr": "https://example.com/french.vtt"},
export_srt=True,
timeout=7200,
)
result.save_all("./dubbed")
result.save_all_subtitles("./dubbed")
for language, report in result.subtitle_export.items():
loss = report.get("alignment_loss")
# The loss may arrive as a string; float() reads either shape.
print(language, report.get("status"), float(loss) if loss is not None else "n/a")
```

`subtitles` is a language → `.srt`-URL map alongside `outputs`, with
`save_subtitle(language, path)` and `save_all_subtitles(dir)` mirroring
`save`/`save_all` (`asave_subtitle`/`asave_all_subtitles` on `AsyncSonilo`).
`submit()` returns a `DubbingTask` — an `SfxTask` plus the same
`subtitle_preflight` map, carried on the 202 itself. That check is free and
runs before anything is charged, so a language whose status is
`review_required` (the pipeline changed lines in your script) is worth reading
there rather than after the billed dub finishes.

`subtitle_preflight` reports what the check made of each script before
anything was charged, and `subtitle_export` reports `status`
(`exported`, `exported_review_required` or `blocked`), `alignment_loss`,
`issues` and `error` per language. A blocked export does **not** fail the
task: every video is still delivered and `subtitles` simply lacks that
language. Values inside both reports may arrive as strings rather than
numbers, so read them defensively.

## Video analysis

`client.video_analysis` analyzes a video and returns a **creative brief** for
Expand Down
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.16.0"
version = "0.17.0"
description = "Official Python client for the Sonilo API"
readme = "README.md"
license = "MIT"
Expand Down
14 changes: 14 additions & 0 deletions sonilo-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,20 @@ command that produces no media file — nothing is generated:
original picture matters more than matching lip movement.
- `--output` is a filename template, not a single destination: a dubbing task returns one video
per language, so `--output clip.mp4` writes `clip.es.mp4`, `clip.fr.mp4`, etc.
- `--ducking` ducks the background music/effects bed under the dubbed voice while it speaks;
off by default, so the bed otherwise stays at a static level. `--no-ducking` states that
default explicitly.
- `--subtitle <language>=<path-or-url>` gives one language the script to speak, as an `.srt`/`.vtt`
file or an https URL. Repeat it once per language; the set must match `--languages` exactly.
The scripts are in the **target** language, not the source's:

sonilo dubbing --video clip.mp4 --languages es,fr \
--subtitle es=spanish.srt --subtitle fr=https://example.com/french.vtt --export-srt

- `--export-srt` (requires `--subtitle`) returns a re-timed `.srt` per language, aligned to the
delivered audio with your lines kept verbatim. Each one is written beside its video
(`clip.es.mp4` -> `clip.es.srt`), and one status line per language is printed. A language whose
export is blocked still gets its video — only the `.srt` is missing.
- Billing is per language, and dubbing has **no free trial runs** — see [Free trial](#free-trial)
below.
- `--timeout` defaults to 7200 seconds, matching the backend's own ceiling for a dubbing job
Expand Down
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.15.0"
version = "0.16.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.16.0,<0.17"]
dependencies = ["sonilo>=0.17.0,<0.18"]
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.14.3"
__version__ = "0.16.0"

__all__ = ["__version__"]
88 changes: 88 additions & 0 deletions sonilo-cli/src/sonilo_cli/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -624,27 +624,92 @@ def _language_path(out: str, language: str) -> str:
return str(base.with_name(f"{base.stem}.{language}{base.suffix or '.mp4'}"))


def _subtitles(values: Optional[List[str]]) -> Optional[Dict[str, str]]:
"""Turn repeated `--subtitle <lang>=<path-or-url>` values into the map the
SDK takes. The `=` is split once only, so a Windows path or a URL with its
own `=` survives. Neither the codes nor the language set are checked here —
the server owns both rules and names what it refused."""
if not values:
return None
subtitles: Dict[str, str] = {}
for value in values:
language, _, source = value.partition("=")
language = language.strip()
if not language or not source:
_fail(
f"--subtitle needs <language>=<path-or-url>, got {value!r} "
"(e.g. --subtitle es=spanish.srt)"
)
if language in subtitles:
_fail(f"--subtitle {language} given twice; one script per language")
subtitles[language] = source
return subtitles


def _export_line(language: str, report: Dict[str, Any]) -> str:
"""One status line per language. `alignment_loss` arrives as a float or as
a string — the pipeline stores numbers as strings — so it is formatted
through float() and simply omitted when it is neither."""
status = report.get("status") or "unknown"
line = f"Subtitle {language}: {status}"
try:
line += f" (alignment loss {float(report['alignment_loss']):.3f})"
except (KeyError, TypeError, ValueError):
pass
error = report.get("error")
if error:
line += f" — {error}"
return line


def cmd_dubbing(client: Sonilo, args: argparse.Namespace) -> None:
out = args.output if args.output is not None else "output.mp4"
languages = None
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")
subtitles = _subtitles(args.subtitle)
if args.export_srt and subtitles is None:
_fail("--export-srt needs --subtitle <language>=<path-or-url> for each language")
# Both files are derived from the same template, and the subtitle is
# written second: an .srt template would have clip.es.srt overwrite the
# video that had just been saved to clip.es.srt, reporting both writes as
# successes. Refuse it here rather than destroy the deliverable.
if args.export_srt and Path(out).suffix.lower() == ".srt":
_fail(
f"--output {out} ends in .srt, which --export-srt would overwrite with "
"the subtitle — name the video (e.g. --output clip.mp4) and the .srt "
"is written beside it"
)
result = client.dubbing.generate(
video=args.video,
video_url=args.video_url,
languages=languages,
ducking=_ducking(args),
# Only sent when the flag is present, so the server keeps owning the
# default (lip sync on).
lipsync=False if args.no_lipsync else None,
subtitles=subtitles,
export_srt=True if args.export_srt else None,
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)
if not args.export_srt:
return
# The .srt lands beside its video (clip.es.mp4 -> clip.es.srt). A blocked
# export still delivers the video, so every requested language gets a
# status line whether or not a file came back with it.
for language in sorted(result.subtitle_export or result.subtitles):
if language in result.subtitles:
srt = Path(_language_path(out, language)).with_suffix(".srt")
path = result.save_subtitle(language, srt)
_wrote(path, path.stat().st_size)
print(_export_line(language, result.subtitle_export.get(language, {})))


def _identity(body: Any) -> Any:
Expand Down Expand Up @@ -1023,6 +1088,29 @@ def build_parser() -> argparse.ArgumentParser:
"or when preserving the exact original picture matters more than "
"matching lip movement.",
)
p_dub.add_argument(
"--ducking", dest="ducking", action="store_true",
help="Duck the background music/effects bed under the dubbed voice while "
"it speaks, instead of keeping it at a static level. Off by default.",
)
p_dub.add_argument(
"--no-ducking", dest="no_ducking", action="store_true",
help="Explicit opt-out. Same as the default; kept so existing "
"scripts keep working.",
)
p_dub.add_argument(
"--subtitle", dest="subtitle", action="append", default=None,
metavar="LANG=SOURCE",
help="Script to speak in one target language, as <language>=<path> or "
"<language>=<https URL>. Repeat once per language. Scripts are "
".srt or .vtt in the TARGET language, not source transcripts.",
)
p_dub.add_argument(
"--export-srt", dest="export_srt", action="store_true",
help="Return a re-timed .srt per language, aligned to the delivered "
"audio and keeping your lines verbatim. Written beside each video "
"(clip.es.mp4 -> clip.es.srt). Requires --subtitle.",
)
p_dub.add_argument(
"--output", default=None,
help="Filename template; one file is written per language with the code "
Expand Down
Loading
Loading