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
88 changes: 87 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -499,6 +499,92 @@ 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.

## Proofread

`client.proofread` transcribes a video and translates the transcript into
editable subtitle files — one `.srt` per language plus the source-language
transcript. Nothing is dubbed and nothing is spoken: this is the step *before*
`client.dubbing`, so you can read and correct the wording before any voice is
rendered.

Pass exactly one of `video` / `video_url` (`video_url` must be **https**); the
video must have an audio track. Plus optional `languages` — the target
languages to translate into, the same codes `client.dubbing` takes (see
[Dubbing](#dubbing) for the list, and for what `pt_br`, `es_419`, `pa_in` and
`sd_in` mean), so a proofread script can go straight into a dub. Omit
`languages`, or pass `[]`, for the source-language transcript alone. `source_language` is an optional hint telling transcription
which language to expect, which helps on short, noisy or mixed-language audio;
without it the language is detected. Either way the finished task reports the
language the transcript is in. Language codes are not checked client-side — the
server owns that list, exactly as it does for dubbing.

Source videos may be at most 300 seconds long and 300 MB. Billing is per second
of video multiplied by the number of target languages at $0.001/second, a
transcript-only request counts as one language, and billing has a 10-second
floor. Self-serve accounts get 2 free calls — see [Free trial](#free-trial).

```python
from sonilo import Sonilo

with Sonilo() as client:
result = client.proofread.generate(
video_url="https://example.com/clip.mp4",
languages=["ja", "zh_cn"],
)
print(result.source_language, result.cue_count)
for language, path in result.save_all("./scripts").items():
print(language, path)
```

`ProofreadResult.subtitles` is a language → presigned `.srt`-URL map and always
includes the **detected** source language alongside the requested targets, so
even a request with no `languages` comes back with one file. Use
`result.save(language, path)` for one language or `save_all(dir)` for all of
them (`asave`/`asave_all` on `AsyncSonilo`), which write
`{prefix}.{language}.srt` with `prefix` defaulting to `proofread`. Use
`submit()` instead of `generate()` to get a `task_id` back immediately and poll
it yourself with
`client.tasks.wait(task_id, parser=parse_proofread_result)`.

`cue_count` is the number of subtitle cues in the source script; every language
has the same count. `warnings` maps a language
to the non-blocking issues its script raised and is empty when there are none —
nothing in it fails the task or withholds a file. Each issue carries `cue` (the
1-based cue it is about), `code` and `severity`, plus whatever measurement the
code brought with it, kept verbatim in `extras`:

```python
for language, issues in result.warnings.items():
for issue in issues:
print(language, issue.cue, issue.code, issue.get("characters_per_second"))
```

### Proofread, then dub

The two endpoints are two halves of one workflow: correct the `.srt` files
proofread returned, then hand them to `client.dubbing` as
`subtitles[<language>]` so the dub speaks exactly the approved wording.

```python
scripts = client.proofread.generate(
video_url="https://example.com/clip.mp4", languages=["es", "fr"]
).save_all("./scripts")

# ... edit ./scripts/proofread.es.srt and ./scripts/proofread.fr.srt ...

dub = client.dubbing.generate(
video_url="https://example.com/clip.mp4",
languages=["es", "fr"],
subtitles={"es": scripts["es"], "fr": scripts["fr"]},
timeout=7200,
)
dub.save_all("./dubbed")
```

Drop the source-language entry from `scripts` before passing it on: dubbing's
`subtitles` set must match its `languages` exactly, and proofread always
returns the source language too.

## Video analysis

`client.video_analysis` analyzes a video and returns a **creative brief** for
Expand Down Expand Up @@ -619,7 +705,7 @@ endpoints — no card required:

| Free runs | Endpoints |
| --- | --- |
| 2 each | text-to-music, text-to-sfx, audio-ducking, video-analysis |
| 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 |

Expand Down
8 changes: 6 additions & 2 deletions context7.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,16 @@
"Result media (.url) is a short-lived presigned URL, not the API's own domain \u2014 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 \u2014 callers usually handle these differently. All extend SoniloError.",
"video / video_url accept exactly one of the two, never both and never neither \u2014 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, video-analysis; 1 each for other video endpoints; none for dubbing), then bill normally. A first call succeeding is not proof billing works.",
"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.",
"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."
"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.",
"client.proofread transcribes a video and translates the transcript into editable .srt files. One of video/video_url (https), optional languages (the same codes as dubbing) and source_language (a hint; omit it and the language is detected). Max 300s.",
"Omit languages (or send []) to get the source-language transcript alone. ProofreadResult.subtitles maps language to .srt URL and ALWAYS includes the detected source_language, so even a transcript-only request comes back with one file.",
"Proofread is the step before dubbing: save the scripts (result.save(lang, path) / save_all(dir)), correct the wording, then pass the files to client.dubbing as subtitles[<language>] so the dub speaks exactly the approved lines.",
"proofread bills video seconds x the number of target languages ($0.001/sec; a transcript-only request counts as one), 2 free trial calls. result.warnings is language -> non-blocking issues (cue/code/severity + extras) and withholds nothing."
]
}
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.19.0"
version = "0.20.0"
description = "Official Python client for the Sonilo API"
readme = "README.md"
license = "MIT"
Expand Down
45 changes: 44 additions & 1 deletion sonilo-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ production sign-in coexist without overwriting each other.
# as a new .mp4 with the ducked mix muxed in
sonilo video-analysis --video clip.mp4 --variants 2
# prints a creative brief as JSON; generates nothing
sonilo proofread --video clip.mp4 --languages ja,zh_cn --output scripts/clip.srt
# writes scripts/clip.<lang>.srt, including the detected source language
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>
Expand Down Expand Up @@ -268,6 +270,47 @@ command that produces no media file — nothing is generated:
sonilo video-analysis --video clip.mp4 --output brief.json
sonilo video-to-music --video clip.mp4 --prompt "$(jq -r '.variations[0].prompt' brief.json)"

### Proofread

`proofread` transcribes a video and translates the transcript into editable `.srt` files — one per
language, plus the source-language transcript. The video must have an audio track. Nothing is
dubbed: this is the step **before** `dubbing`, so the wording can be corrected before any voice is
rendered.

sonilo proofread --video clip.mp4 --languages ja,zh_cn --output scripts/clip.srt
# writes scripts/clip.en.srt, scripts/clip.ja.srt, scripts/clip.zh_cn.srt

- `--languages` is comma-separated and takes the same codes as `dubbing` (see [Dubbing](#dubbing)
below for the list), so a proofread script can go straight into a dub. Omit it for the
source-language transcript alone.
- `--source-language` tells transcription which language to expect, which helps on short, noisy or
mixed-language audio. Omit it to have the language detected; either way the detected code is
printed and names the source-language file.
- `--output` is a filename template, not a single destination, exactly as it is for `dubbing`: one
`.srt` is written per language with the code inserted before the extension, so
`--output scripts/clip.srt` writes `scripts/clip.en.srt`, `scripts/clip.fr.srt`, etc. Missing
directories are created. Default: `proofread.srt`. Every language is always written — the URLs on
the result are presigned and expire, and the files are the point.
- The source language is **always** returned alongside the requested targets, so a one-language
request writes two files.
- After the files, the command prints the detected source language, the cue count, and one line per
non-blocking warning (`Warning fr: high_text_speed (warning) at cue 33 — ...`). A warning never
withholds a file.
- Source videos may be at most 300 seconds long and 300 MB. Billing is per second of video
multiplied by the number of target languages at $0.001/second, a transcript-only request counts as
one, and billing has a 10-second floor; there are 2 free runs — see [Free trial](#free-trial)
below.
- `--timeout` defaults to 600 seconds, the usual default: a proofread job typically finishes in well
under a minute. If the wait does time out, the task keeps running server-side — resume it with
`sonilo tasks wait <task-id>`.
- Edit the files, then feed them straight into `dubbing`, which makes the dub speak your exact
wording (drop the source-language file: `--subtitle` must match `--languages`):

sonilo proofread --video clip.mp4 --languages es,fr --output scripts/clip.srt
# ... correct scripts/clip.es.srt and scripts/clip.fr.srt ...
sonilo dubbing --video clip.mp4 --languages es,fr \
--subtitle es=scripts/clip.es.srt --subtitle fr=scripts/clip.fr.srt

### Dubbing

`dubbing` dubs a video into one or more target languages in a single async call:
Expand Down Expand Up @@ -317,7 +360,7 @@ required:

| Free runs | Endpoints |
| --- | --- |
| 2 each | text-to-music, text-to-sfx, audio-ducking, video-analysis |
| 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 |

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.18.0"
version = "0.19.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.19.0,<0.20"]
dependencies = ["sonilo>=0.20.0,<0.21"]
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.18.0"
__version__ = "0.19.0"

__all__ = ["__version__"]
89 changes: 86 additions & 3 deletions sonilo-cli/src/sonilo_cli/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -629,13 +629,17 @@ def cmd_video_analysis(client: Sonilo, args: argparse.Namespace) -> None:
DUBBING_WAIT_TIMEOUT = 7200.0


def _language_path(out: str, language: str) -> str:
def _language_path(out: str, language: str, default_suffix: str = ".mp4") -> 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."""
transform _stem_path applies for --stem, so both flags read the same way.

`default_suffix` only decides what an extension-less template gets, and
defaults to dubbing's `.mp4`; proofread passes `.srt` so `--output clip`
does not name its subtitles after a video container."""
base = Path(out)
return str(base.with_name(f"{base.stem}.{language}{base.suffix or '.mp4'}"))
return str(base.with_name(f"{base.stem}.{language}{base.suffix or default_suffix}"))


def _subtitles(values: Optional[List[str]]) -> Optional[Dict[str, str]]:
Expand Down Expand Up @@ -726,6 +730,53 @@ def cmd_dubbing(client: Sonilo, args: argparse.Namespace) -> None:
print(_export_line(language, result.subtitle_export.get(language, {})))


def _warning_line(language: str, issue: Any) -> str:
"""One line per non-blocking issue. The measurement that came with the
code (`characters_per_second` on `high_text_speed`) is appended verbatim,
because the codes are server-owned and each brings its own."""
where = f"cue {issue.cue}" if issue.cue is not None else "script"
line = f"Warning {language}: {issue.code} ({issue.severity}) at {where}"
if issue.extras:
details = ", ".join(f"{k}={v}" for k, v in sorted(issue.extras.items()))
line += f" — {details}"
return line


def cmd_proofread(client: Sonilo, args: argparse.Namespace) -> None:
"""Proofread writes one .srt per language, the same way dubbing writes one
video per language and through the same --output template: the URLs on the
result are presigned and expire, and the whole point of the endpoint is the
files you then edit. The source language always comes back too, whether or
not any target languages were asked for."""
out = args.output if args.output is not None else "proofread.srt"
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.proofread.generate(
video=args.video,
video_url=args.video_url,
languages=languages,
source_language=args.source_language,
timeout=args.timeout,
)
if not result.subtitles:
_fail("task succeeded but returned no subtitle files")
# Unlike dubbing's, this template routinely names a directory of its own
# ("--output scripts/clip.srt"), so create it rather than fail on the write.
Path(out).parent.mkdir(parents=True, exist_ok=True)
for language in sorted(result.subtitles):
path = result.save(language, _language_path(out, language, ".srt"))
_wrote(path, path.stat().st_size)
print(f"Source language: {result.source_language or 'unknown'}")
if result.cue_count is not None:
print(f"Cues: {result.cue_count}")
for language in sorted(result.warnings):
for issue in result.warnings[language]:
print(_warning_line(language, issue))


def _identity(body: Any) -> Any:
return body

Expand Down Expand Up @@ -1154,6 +1205,38 @@ def build_parser() -> argparse.ArgumentParser:
)
p_dub.set_defaults(func=cmd_dubbing)

p_pr = sub.add_parser(
"proofread",
help="Transcribe a video and translate the transcript into editable .srt files",
)
_add_global(p_pr)
_add_video_source(p_pr)
p_pr.add_argument(
"--languages", default=None,
help="Comma-separated target languages to translate the transcript into. "
"Omit it for the source-language transcript alone. Same codes as "
"`sonilo dubbing`, so a proofread script can go straight into a dub.",
)
p_pr.add_argument(
"--source-language", dest="source_language", default=None,
help="Tell transcription which language to expect, which helps on short, "
"noisy or mixed-language audio. One of the same codes. Omit it to "
"have the language detected.",
)
p_pr.add_argument(
"--output", default=None,
help="Filename template, not a single destination: one .srt is written "
"per language with the code inserted before the extension "
"(scripts/clip.srt -> scripts/clip.en.srt). Missing directories are "
"created. Default: proofread.srt",
)
p_pr.add_argument(
"--timeout", type=float, default=DEFAULT_WAIT_TIMEOUT,
help="Give up waiting after this many seconds. Default: 600. A timed-out "
"task may still finish — resume it with `sonilo tasks wait <task-id>`.",
)
p_pr.set_defaults(func=cmd_proofread)

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