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
42 changes: 41 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,46 @@ 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)`.

## Video analysis

`client.video_analysis` analyzes a video and returns a **creative brief** for
scoring it. Nothing is generated: no audio, no video, no file to download.
The result is the work order — a time-aligned `segments` plan plus one
`prompt` per requested variation, each ready to hand straight to
`video_to_music`, `video_to_sfx`, `video_to_sound` or their video-to-video
counterparts.

Pass exactly one of `video` / `video_url`, plus optional `prompt` (guidance
for the analysis, at most 2000 characters) and `variants_num` (1-5, default
1 — billed per brief). Source videos may be at most 600 seconds long, and
billing has a 10-second floor, so a very short clip still costs the same as a
10-second one.

```python
from sonilo import Sonilo

with Sonilo() as client:
brief = client.video_analysis.analyze(
video="trailer.mp4",
prompt="focus on the chase",
variants_num=2,
)
for segment in brief.segments:
print(f"{segment.start}-{segment.end}s [{segment.label}] {segment.prompt}")

# Feed a variation's prompt straight into a generation call.
track = client.video_to_music.generate_async(
video="trailer.mp4", prompt=brief.variations[0].prompt
)
```

The method is `analyze`, not `generate`, for the same reason there is no
`save()` on the result: every other resource returns something you download,
and this one never does. Persisting the brief is up to you. Use `submit()`
instead of `analyze()` to get a `task_id` back immediately and poll it
yourself with
`client.tasks.wait(task_id, parser=parse_video_analysis_result)`.

## Streaming

```python
Expand Down Expand Up @@ -452,7 +492,7 @@ endpoints — no card required:

| Free runs | Endpoints |
| --- | --- |
| 2 each | text-to-music, text-to-sfx, audio-ducking |
| 2 each | text-to-music, text-to-sfx, audio-ducking, video-analysis |
| 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
20 changes: 11 additions & 9 deletions context7.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"url": "https://context7.com/sonilo-ai/sonilo-python",
"public_key": "pk_h4BOrwVwv5CF9NJ5BIwvC",
"projectTitle": "Sonilo Python SDK",
"description": "Official Python client and CLI for the Sonilo API generate music, sound effects, and combined soundtracks from text or video.",
"description": "Official Python client and CLI for the Sonilo API \u2014 generate music, sound effects, and combined soundtracks from text or video.",
"excludeFolders": [
"**/dist/**",
"**/.venv/**",
Expand All @@ -16,19 +16,21 @@
"text-to-music and video-to-music: use generate() (streaming) for short synchronous tracks. It does NOT support output_format=\"wav\", isolate_vocals, preserve_speech or ducking.",
"For those options use generate_async(), or submit() + client.tasks.wait(parser=parse_music_result). isolate_vocals/preserve_speech/ducking are video-to-music only; text-to-music takes output_format alone.",
"text-to-sfx and video-to-sfx are always async: submit() returns a task and generate() (or tasks.wait()) polls it to completion. Neither has a streaming variant.",
"client.video_to_sound and client.video_to_video_sound score one clip with a music bed AND sound effects in a single call prefer them over chaining video-to-music with video-to-sfx, which is charged twice.",
"client.video_to_sound and client.video_to_video_sound score one clip with a music bed AND sound effects in a single call \u2014 prefer them over chaining video-to-music with video-to-sfx, which is charged twice.",
"Both sound endpoints are async-only and take identical options: music_prompt and sfx_prompt (NOT a single prompt), preserve_speech, ducking. video_to_sound returns mixed audio; video_to_video_sound returns the video with that audio muxed in.",
"ducking is default-OFF server-side on every endpoint; the builder sends a boolean only when not None, so leave it unset to keep the default and pass ducking=True to opt in. On video_to_sound, ducking=True is also what pulls the source's own speech in.",
"The same tri-state applies to preserve_speech: None means unset, not False.",
"SoundResult exposes the combined render as the presigned output_url save it with result.save(path). Individual layers come from result.save_stem(path, which=\"music\"), with stems music, music_processed and sfx.",
"SoundResult exposes the combined render as the presigned output_url \u2014 save it with result.save(path). Individual layers come from result.save_stem(path, which=\"music\"), with stems music, music_processed and sfx.",
"music_processed exists only when preserve_speech or ducking altered the music bed; save_stem raises SoniloError for an absent stem.",
"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 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.",
"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.",
"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 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."
"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: the method is analyze() (not generate()) and VideoAnalysisResult has no save(). Read result.segments (start/end/label/prompt) and result.variations[i].prompt.",
"Pass a video_analysis variation's prompt straight to video_to_music / video_to_sfx / video_to_sound as their prompt. It takes one of video/video_url plus optional prompt and variants_num (1-5, billed per brief); max 600s, 10s billing floor."
]
}
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.13.0"
version = "0.14.0"
description = "Official Python client for the Sonilo API"
readme = "README.md"
license = "MIT"
Expand Down
21 changes: 20 additions & 1 deletion sonilo-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ production sign-in coexist without overwriting each other.
sonilo audio-ducking --voice interview.mp4 --music-url https://example.com/bed.wav
# ducks the existing music bed under the voice; a video voice comes back
# 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 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 @@ -225,6 +227,23 @@ call instead.)
there, so the CLI rejects a local video file up front rather than let it be mishandled silently.
- Each input is capped at 360 seconds server-side.

### Video analysis

`video-analysis` analyzes a video and prints a **creative brief** for scoring it. It is the one
command that produces no media file — nothing is generated:

sonilo video-analysis --video clip.mp4 --prompt "focus on the chase" --variants 2

- The brief goes to **stdout as JSON** so it can be piped into another tool: `segments` (a
time-aligned section plan) and `variations` (one ready-to-use generation prompt each). Pass
`--output brief.json` to write it to a file instead.
- `--variants` is 1-5 (default 1) and is **billed per brief**.
- Source videos may be at most 600 seconds long, and billing has a 10-second floor.
- Feed a variation's prompt straight into the next command:

sonilo video-analysis --video clip.mp4 --output brief.json
sonilo video-to-music --video clip.mp4 --prompt "$(jq -r '.variations[0].prompt' brief.json)"

### Dubbing

`dubbing` dubs a video into one or more target languages in a single async call:
Expand All @@ -251,7 +270,7 @@ required:

| Free runs | Endpoints |
| --- | --- |
| 2 each | text-to-music, text-to-sfx, audio-ducking |
| 2 each | text-to-music, text-to-sfx, audio-ducking, video-analysis |
| 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.12.0"
version = "0.13.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.13.0,<0.14"]
dependencies = ["sonilo>=0.14.0,<0.15"]
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.12.0"
__version__ = "0.13.0"

__all__ = ["__version__"]
71 changes: 71 additions & 0 deletions sonilo-cli/src/sonilo_cli/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -510,6 +510,51 @@ def cmd_audio_ducking(client: Sonilo, args: argparse.Namespace) -> None:
_wrote(path, path.stat().st_size)


def _analysis_payload(result: Any) -> dict:
"""Flatten a VideoAnalysisResult back into the API's own envelope shape.

Deliberately re-emits the wire format rather than dumping the dataclass:
this output is meant to be piped into another tool (or read by an agent),
and it should look identical to what GET /v1/tasks returned. None-valued
accounting fields are dropped so a brief stays readable.
"""
payload: dict = {
"task_id": result.task_id,
"status": result.status,
"segments": [
{"start": s.start, "end": s.end, "label": s.label, "prompt": s.prompt}
for s in result.segments
],
"variations": [{"prompt": v.prompt} for v in result.variations],
}
for key in ("variants_num", "duration_seconds", "cost"):
value = getattr(result, key, None)
if value is not None:
payload[key] = value
return payload


def cmd_video_analysis(client: Sonilo, args: argparse.Namespace) -> None:
"""video-analysis is the one command that produces no media file. The
brief goes to stdout so it can be piped straight into the next tool;
--output is the opt-in for keeping a copy on disk."""
result = client.video_analysis.analyze(
video=args.video, video_url=args.video_url,
prompt=args.prompt, variants_num=args.variants,
timeout=args.timeout,
)
if not result.variations:
_fail("task succeeded but returned no creative brief")
payload = _analysis_payload(result)
if args.output is None:
_print_json(payload)
return
path = Path(args.output)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(payload, indent=2) + "\n")
_wrote(path, path.stat().st_size)


# 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
Expand Down Expand Up @@ -858,6 +903,32 @@ def build_parser() -> argparse.ArgumentParser:
)
p_duck.set_defaults(func=cmd_audio_ducking)

p_va = sub.add_parser(
"video-analysis",
help="Analyze a video and print a creative brief for scoring it",
)
_add_global(p_va)
_add_video_source(p_va)
p_va.add_argument(
"--prompt", default=None,
help="Optional guidance for the analysis, e.g. 'focus on the chase'.",
)
p_va.add_argument(
"--variants", type=int, default=None,
help="How many independent briefs to author for the same video (1-5). "
"Billed per brief. Default: 1",
)
p_va.add_argument(
"--output", default=None,
help="Write the brief to this .json file instead of printing it to stdout.",
)
p_va.add_argument(
"--timeout", type=float, default=600.0,
help="Give up waiting after this many seconds. Default: 600. A timed-out "
"task may still finish — resume it with `sonilo tasks get <task-id>`.",
)
p_va.set_defaults(func=cmd_video_analysis)

p_dub = sub.add_parser("dubbing", help="Dub a video into other languages")
_add_global(p_dub)
_add_video_source(p_dub)
Expand Down
89 changes: 89 additions & 0 deletions sonilo-cli/tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -1346,3 +1346,92 @@ def test_empty_env_var_falls_through_to_the_credential(monkeypatch):
)
main(["account"])
assert route.calls.last.request.headers["authorization"] == "Bearer sk-stored"


ANALYSIS_ACK = {"task_id": "va1", "status": "processing"}
ANALYSIS_BODY = {
"task_id": "va1",
"type": "video_analysis",
"status": "succeeded",
"variants_num": 2,
"segments": [
{"start": 0, "end": 12, "label": "intro", "prompt": "sparse piano"},
],
"variations": [
{"prompt": "cinematic strings, 90bpm"},
{"prompt": "lo-fi hip hop, warm keys"},
],
"duration_seconds": 30.0,
"cost": 0.24,
}


def _mock_analysis():
respx.post(f"{BASE}/v1/video-analysis").mock(
return_value=httpx.Response(202, json=ANALYSIS_ACK)
)
respx.get(f"{BASE}/v1/tasks/va1").mock(
return_value=httpx.Response(200, json=ANALYSIS_BODY)
)


@respx.mock
def test_video_analysis_prints_the_brief_as_json(capsys):
"""The result is a brief, not a file: it goes to stdout so it can be
piped, and nothing is written to disk unless --output says so."""
_mock_analysis()
run(["video-analysis", "--video-url", "https://x/v.mp4"])
out = json.loads(capsys.readouterr().out)
assert out["task_id"] == "va1"
assert out["segments"] == [
{"start": 0, "end": 12, "label": "intro", "prompt": "sparse piano"}
]
assert [v["prompt"] for v in out["variations"]] == [
"cinematic strings, 90bpm",
"lo-fi hip hop, warm keys",
]


@respx.mock
def test_video_analysis_sends_prompt_and_variants():
_mock_analysis()
route = respx.routes[0]
run([
"video-analysis",
"--video-url", "https://x/v.mp4",
"--prompt", "focus on the chase",
"--variants", "2",
])
body = unquote_plus(route.calls.last.request.content.decode())
assert "prompt=focus on the chase" in body
assert "variants_num=2" in body


@respx.mock
def test_video_analysis_omits_unset_optionals():
_mock_analysis()
route = respx.routes[0]
run(["video-analysis", "--video-url", "https://x/v.mp4"])
body = unquote_plus(route.calls.last.request.content.decode())
assert "prompt" not in body
assert "variants_num" not in body


@respx.mock
def test_video_analysis_output_writes_the_brief_to_a_file(tmp_path, capsys):
_mock_analysis()
out = tmp_path / "brief.json"
run(["video-analysis", "--video-url", "https://x/v.mp4", "--output", str(out)])
written = json.loads(out.read_text())
assert written["variations"][0]["prompt"] == "cinematic strings, 90bpm"
# With --output the brief goes to the file, not to stdout.
assert "Wrote" in capsys.readouterr().out


def test_video_analysis_requires_a_video_source(capsys):
with pytest.raises(SystemExit) as exc:
main(["--api-key", "sk-test", "video-analysis"])
assert exc.value.code == 1
# Asserted on the message, not just the exit code: an unknown command
# also exits 1, so the code alone would pass before the command exists.
assert "--video" in capsys.readouterr().err
6 changes: 6 additions & 0 deletions src/sonilo/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
)
from sonilo.types import (
AccountServices,
AnalysisSegment,
AnalysisVariation,
DubbingResult,
MusicAudioMedia,
MusicResult,
Expand All @@ -27,12 +29,15 @@
StreamEvent,
Track,
TrialQuota,
VideoAnalysisResult,
VideoResult,
)

__all__ = [
"APIError",
"AccountServices",
"AnalysisSegment",
"AnalysisVariation",
"AsyncSonilo",
"AuthenticationError",
"BadRequestError",
Expand All @@ -56,6 +61,7 @@
"Track",
"TrialExhaustedError",
"TrialQuota",
"VideoAnalysisResult",
"VideoResult",
"__version__",
]
2 changes: 2 additions & 0 deletions src/sonilo/_async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from sonilo.resources.account import AsyncAccount
from sonilo.resources.audio_ducking import AsyncAudioDucking
from sonilo.resources.dubbing import AsyncDubbing
from sonilo.resources.video_analysis import AsyncVideoAnalysis
from sonilo.resources.tasks import AsyncTasks
from sonilo.resources.text_to_music import AsyncTextToMusic
from sonilo.resources.text_to_sfx import AsyncTextToSfx
Expand Down Expand Up @@ -53,6 +54,7 @@ def __init__(
self.video_to_video_sound = AsyncVideoToVideoSound(self)
self.audio_ducking = AsyncAudioDucking(self)
self.dubbing = AsyncDubbing(self)
self.video_analysis = AsyncVideoAnalysis(self)
self.account = AsyncAccount(self)
self.tasks = AsyncTasks(self)

Expand Down
Loading
Loading