From 0a80cce1c8e6e02b5b8660ef014205f2abfa9f7b Mon Sep 17 00:00:00 2001 From: Spencer Qian Date: Sun, 16 Aug 2026 20:52:32 -0700 Subject: [PATCH] feat: video_analysis resource and CLI command (0.14.0 / cli 0.13.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Propagates POST /v1/video-analysis to the SDK and the CLI. video-analysis is the first Sonilo product whose result is not media: it generates nothing and there is no file to download. The result is a 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. That shapes the surface in two places: - The resource method is `analyze()`, not `generate()`, and VideoAnalysisResult has no `save()`. Every other resource returns something you download; this one never does, so persisting the brief is the caller's business. - The CLI prints the brief to stdout as JSON so it can be piped into the next command, and writes a file only when `--output` asks for one. Every other command's default is a media file on disk. Both list fields are coerced entry-by-entry and malformed entries dropped, for the same reason parse_dubbing_result coerces `outputs`: a differently-shaped entry from a later backend change should surface as a missing item, not an AttributeError deep in the caller's loop. The 1-5 variants_num bound and the 2000-char prompt bound are deliberately not re-checked client-side — the backend owns them, and a hardcoded copy would make this SDK reject values a later API widens. sonilo-cli's narrow pin on the core moves to >=0.14.0,<0.15 with the bump. --- README.md | 42 ++++++- context7.json | 20 +-- pyproject.toml | 2 +- sonilo-cli/README.md | 21 +++- sonilo-cli/pyproject.toml | 4 +- sonilo-cli/src/sonilo_cli/__init__.py | 2 +- sonilo-cli/src/sonilo_cli/__main__.py | 71 +++++++++++ sonilo-cli/tests/test_cli.py | 89 ++++++++++++++ src/sonilo/__init__.py | 6 + src/sonilo/_async_client.py | 2 + src/sonilo/_client.py | 2 + src/sonilo/_requests.py | 35 ++++++ src/sonilo/_version.py | 2 +- src/sonilo/resources/tasks.py | 67 ++++++++++ src/sonilo/resources/video_analysis.py | 116 ++++++++++++++++++ src/sonilo/types.py | 47 +++++++ tests/test_video_analysis.py | 162 +++++++++++++++++++++++++ 17 files changed, 674 insertions(+), 16 deletions(-) create mode 100644 src/sonilo/resources/video_analysis.py create mode 100644 tests/test_video_analysis.py diff --git a/README.md b/README.md index 4b70a4c..66828c3 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 | diff --git a/context7.json b/context7.json index 9fed88c..e351aa8 100644 --- a/context7.json +++ b/context7.json @@ -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/**", @@ -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." ] } diff --git a/pyproject.toml b/pyproject.toml index 72fcc39..191f424 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/sonilo-cli/README.md b/sonilo-cli/README.md index b89999c..173ec0c 100644 --- a/sonilo-cli/README.md +++ b/sonilo-cli/README.md @@ -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 @@ -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: @@ -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 | diff --git a/sonilo-cli/pyproject.toml b/sonilo-cli/pyproject.toml index 3276f37..f78471a 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.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] diff --git a/sonilo-cli/src/sonilo_cli/__init__.py b/sonilo-cli/src/sonilo_cli/__init__.py index 75aed2a..4d59e00 100644 --- a/sonilo-cli/src/sonilo_cli/__init__.py +++ b/sonilo-cli/src/sonilo_cli/__init__.py @@ -1,3 +1,3 @@ -__version__ = "0.12.0" +__version__ = "0.13.0" __all__ = ["__version__"] diff --git a/sonilo-cli/src/sonilo_cli/__main__.py b/sonilo-cli/src/sonilo_cli/__main__.py index a68c837..30a8670 100644 --- a/sonilo-cli/src/sonilo_cli/__main__.py +++ b/sonilo-cli/src/sonilo_cli/__main__.py @@ -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 @@ -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 `.", + ) + 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) diff --git a/sonilo-cli/tests/test_cli.py b/sonilo-cli/tests/test_cli.py index ec6c58a..0625e19 100644 --- a/sonilo-cli/tests/test_cli.py +++ b/sonilo-cli/tests/test_cli.py @@ -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 diff --git a/src/sonilo/__init__.py b/src/sonilo/__init__.py index 1b07c55..e6e6972 100644 --- a/src/sonilo/__init__.py +++ b/src/sonilo/__init__.py @@ -15,6 +15,8 @@ ) from sonilo.types import ( AccountServices, + AnalysisSegment, + AnalysisVariation, DubbingResult, MusicAudioMedia, MusicResult, @@ -27,12 +29,15 @@ StreamEvent, Track, TrialQuota, + VideoAnalysisResult, VideoResult, ) __all__ = [ "APIError", "AccountServices", + "AnalysisSegment", + "AnalysisVariation", "AsyncSonilo", "AuthenticationError", "BadRequestError", @@ -56,6 +61,7 @@ "Track", "TrialExhaustedError", "TrialQuota", + "VideoAnalysisResult", "VideoResult", "__version__", ] diff --git a/src/sonilo/_async_client.py b/src/sonilo/_async_client.py index 59651b7..be7fcc6 100644 --- a/src/sonilo/_async_client.py +++ b/src/sonilo/_async_client.py @@ -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 @@ -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) diff --git a/src/sonilo/_client.py b/src/sonilo/_client.py index d61bd14..614100a 100644 --- a/src/sonilo/_client.py +++ b/src/sonilo/_client.py @@ -14,6 +14,7 @@ from sonilo.resources.tasks import Tasks from sonilo.resources.text_to_music import TextToMusic from sonilo.resources.text_to_sfx import TextToSfx +from sonilo.resources.video_analysis import VideoAnalysis from sonilo.resources.video_to_music import VideoToMusic from sonilo.resources.video_to_sfx import VideoToSfx from sonilo.resources.video_to_video_music import VideoToVideoMusic @@ -83,6 +84,7 @@ def __init__( self.video_to_video_sound = VideoToVideoSound(self) self.audio_ducking = AudioDucking(self) self.dubbing = Dubbing(self) + self.video_analysis = VideoAnalysis(self) self.account = Account(self) self.tasks = Tasks(self) diff --git a/src/sonilo/_requests.py b/src/sonilo/_requests.py index 4420d35..3765f7b 100644 --- a/src/sonilo/_requests.py +++ b/src/sonilo/_requests.py @@ -212,6 +212,41 @@ def build_dubbing_parts( return data, files, opened +def build_video_analysis_parts( + video: Any, + video_url: Optional[str], + prompt: Optional[str], + variants_num: Optional[int], +) -> Tuple[Dict[str, str], Optional[Dict[str, tuple]], bool]: + """Build the multipart parts for POST /v1/video-analysis. + + Both optionals are omitted when unset so the server's own defaults apply + (no prompt, one variation). The 1-5 bound on variants_num and the 2000-char + bound on prompt are deliberately NOT checked here — the backend owns them, + and a hardcoded copy would make this SDK reject values a later API widens. + """ + if (video is None) == (video_url is None): + raise SoniloError("Provide exactly one of video or video_url") + + # Assemble data dict completely before opening any files + data: Dict[str, str] = {} + if video_url is not None: + data["video_url"] = video_url + if prompt is not None: + data["prompt"] = prompt + if variants_num is not None: + data["variants_num"] = str(variants_num) + + # Now open files (only after data is fully assembled) + files: Optional[Dict[str, tuple]] = None + opened = False + if video is not None: + filename, fileobj, opened = normalize_video(video) + files = {"video": (filename, fileobj, "video/mp4")} + + return data, files, opened + + def _resolve_music_mode( mode: Optional[str], isolate_vocals: Optional[bool], diff --git a/src/sonilo/_version.py b/src/sonilo/_version.py index f23a6b3..9e78220 100644 --- a/src/sonilo/_version.py +++ b/src/sonilo/_version.py @@ -1 +1 @@ -__version__ = "0.13.0" +__version__ = "0.14.0" diff --git a/src/sonilo/resources/tasks.py b/src/sonilo/resources/tasks.py index ec4f574..cf9adba 100644 --- a/src/sonilo/resources/tasks.py +++ b/src/sonilo/resources/tasks.py @@ -7,6 +7,8 @@ from sonilo.errors import SoniloError, TaskFailedError, TaskTimeoutError from sonilo.types import ( + AnalysisSegment, + AnalysisVariation, DubbingResult, MusicAudioMedia, MusicResult, @@ -16,6 +18,7 @@ SfxTask, SoundOutput, SoundResult, + VideoAnalysisResult, VideoResult, ) @@ -236,6 +239,70 @@ def parse_dubbing_result(body: Dict[str, Any]) -> "DubbingResult": raise SoniloError(f"Malformed task response: missing {e.args[0]!r}") from e +def _analysis_segment_from(data: Any) -> Optional[AnalysisSegment]: + if not isinstance(data, dict): + return None + try: + return AnalysisSegment( + start=int(data["start"]), + end=int(data["end"]), + prompt=str(data["prompt"]), + # The backend always emits a label (defaulting to the string + # "none"); mirror that default rather than None so callers can + # print it unconditionally. + label=str(data.get("label") or "none"), + ) + except (KeyError, TypeError, ValueError): + return None + + +def _analysis_variation_from(data: Any) -> Optional[AnalysisVariation]: + if not isinstance(data, dict): + return None + prompt = data.get("prompt") + if not isinstance(prompt, str): + return None + return AnalysisVariation(prompt=prompt) + + +def parse_video_analysis_result(body: Dict[str, Any]) -> "VideoAnalysisResult": + """Map a GET /v1/tasks/{id} body for a video-analysis task to + VideoAnalysisResult; unknown fields are ignored. + + Both lists are coerced entry-by-entry and malformed entries are dropped, + for the same reason parse_dubbing_result coerces `outputs`: a + differently-shaped entry from a backend change should not surface as an + AttributeError deep inside the caller's loop, long after the parse. + """ + raw_segments = body.get("segments") + segments = ( + [s for s in map(_analysis_segment_from, raw_segments) if s is not None] + if isinstance(raw_segments, list) + else [] + ) + raw_variations = body.get("variations") + variations = ( + [v for v in map(_analysis_variation_from, raw_variations) if v is not None] + if isinstance(raw_variations, list) + else [] + ) + try: + return VideoAnalysisResult( + task_id=body["task_id"], + status=body["status"], + type=body.get("type"), + segments=segments, + variations=variations, + duration_seconds=body.get("duration_seconds"), + cost=body.get("cost"), + error=body.get("error"), + refunded=body.get("refunded"), + variants_num=body.get("variants_num"), + ) + except KeyError as e: + raise SoniloError(f"Malformed task response: missing {e.args[0]!r}") from e + + def parse_sfx_task(body: Dict[str, Any]) -> SfxTask: """Map a submission ack to SfxTask.""" try: diff --git a/src/sonilo/resources/video_analysis.py b/src/sonilo/resources/video_analysis.py new file mode 100644 index 0000000..5d53960 --- /dev/null +++ b/src/sonilo/resources/video_analysis.py @@ -0,0 +1,116 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Optional + +from sonilo._requests import build_video_analysis_parts +from sonilo.resources.tasks import ( + DEFAULT_POLL_INTERVAL, + DEFAULT_WAIT_TIMEOUT, + parse_sfx_task, + parse_video_analysis_result, +) +from sonilo.types import SfxTask, VideoAnalysisResult + +if TYPE_CHECKING: + from sonilo._async_client import AsyncSonilo + from sonilo._client import Sonilo + +PATH = "/v1/video-analysis" + + +class VideoAnalysis: + """Analyze a video and get back a creative brief for scoring it. Async + only. + + This endpoint generates nothing — no audio, no video, no file to + download. The result is a work order: `segments` (a time-aligned section + plan) and one `prompt` per requested variation, each ready to pass + straight to video_to_music, video_to_sfx, video_to_sound or their + video-to-video counterparts. + + The method is `analyze`, not `generate`, for that reason: every other + resource's `generate` returns something you save, and this one never + does. + """ + + def __init__(self, client: "Sonilo") -> None: + self._client = client + + def submit( + self, + *, + video: Any = None, + video_url: Optional[str] = None, + prompt: Optional[str] = None, + variants_num: Optional[int] = None, + ) -> SfxTask: + data, files, opened = build_video_analysis_parts( + video, video_url, prompt, variants_num + ) + close_after = files["video"][1] if files is not None and opened else None + return parse_sfx_task( + self._client._post_json(PATH, data=data, files=files, close_after=close_after) + ) + + def analyze( + self, + *, + video: Any = None, + video_url: Optional[str] = None, + prompt: Optional[str] = None, + variants_num: Optional[int] = None, + poll_interval: float = DEFAULT_POLL_INTERVAL, + timeout: float = DEFAULT_WAIT_TIMEOUT, + ) -> VideoAnalysisResult: + task = self.submit( + video=video, video_url=video_url, prompt=prompt, variants_num=variants_num + ) + return self._client.tasks.wait( + task.task_id, + poll_interval=poll_interval, + timeout=timeout, + parser=parse_video_analysis_result, + ) + + +class AsyncVideoAnalysis: + def __init__(self, client: "AsyncSonilo") -> None: + self._client = client + + async def submit( + self, + *, + video: Any = None, + video_url: Optional[str] = None, + prompt: Optional[str] = None, + variants_num: Optional[int] = None, + ) -> SfxTask: + data, files, opened = build_video_analysis_parts( + video, video_url, prompt, variants_num + ) + close_after = files["video"][1] if files is not None and opened else None + return parse_sfx_task( + await self._client._post_json( + PATH, data=data, files=files, close_after=close_after + ) + ) + + async def analyze( + self, + *, + video: Any = None, + video_url: Optional[str] = None, + prompt: Optional[str] = None, + variants_num: Optional[int] = None, + poll_interval: float = DEFAULT_POLL_INTERVAL, + timeout: float = DEFAULT_WAIT_TIMEOUT, + ) -> VideoAnalysisResult: + task = await self.submit( + video=video, video_url=video_url, prompt=prompt, variants_num=variants_num + ) + return await self._client.tasks.wait( + task.task_id, + poll_interval=poll_interval, + timeout=timeout, + parser=parse_video_analysis_result, + ) diff --git a/src/sonilo/types.py b/src/sonilo/types.py index 2262af2..69c8e83 100644 --- a/src/sonilo/types.py +++ b/src/sonilo/types.py @@ -583,3 +583,50 @@ async def asave_all( ) for language in sorted(self.outputs) } + + +@dataclass +class AnalysisSegment: + """One time-aligned section of the analyzed video, with the scoring + direction for that stretch. Bounds are whole seconds — the backend + truncates any fractional upstream bound before it reaches the envelope.""" + + start: int + end: int + prompt: str + label: str = "none" + + +@dataclass +class AnalysisVariation: + """One independent creative brief for the whole video. Only the + generation prompt is public — the upstream's title/summary/tags are + internal display copy the API deliberately does not resell.""" + + prompt: str + + +@dataclass +class VideoAnalysisResult: + """State of a video-analysis task (`tasks.get`) or its final result + (`wait`/`analyze`). + + The only Sonilo result with no media artifact at all: nothing is + generated and there is nothing to download. The payload is the work + order — `segments` for a time-aligned plan, and one `prompt` per + requested variation, each ready to hand to video_to_music, + video_to_sfx, video_to_sound or their video-to-video counterparts. + There is therefore no `save()`; persisting the brief is the caller's + (or the CLI's) business. + """ + + task_id: str + status: str + type: Optional[str] = None + segments: List[AnalysisSegment] = field(default_factory=list) + variations: List[AnalysisVariation] = field(default_factory=list) + duration_seconds: Optional[float] = None + cost: Optional[float] = None + error: Optional[Dict[str, Any]] = None + refunded: Optional[bool] = None + variants_num: Optional[int] = None diff --git a/tests/test_video_analysis.py b/tests/test_video_analysis.py new file mode 100644 index 0000000..1075bb6 --- /dev/null +++ b/tests/test_video_analysis.py @@ -0,0 +1,162 @@ +from urllib.parse import unquote_plus + +import httpx +import pytest +import respx + +from sonilo import AsyncSonilo, Sonilo +from sonilo.errors import SoniloError +from sonilo.resources.tasks import parse_video_analysis_result + +SUCCESS_BODY = { + "task_id": "va1", + "type": "video_analysis", + "status": "succeeded", + "variants_num": 2, + "segments": [ + {"start": 0, "end": 12, "label": "intro", "prompt": "sparse piano, rising"}, + {"start": 12, "end": 30, "label": "none", "prompt": "full strings, driving"}, + ], + "variations": [ + {"prompt": "cinematic strings, 90bpm"}, + {"prompt": "lo-fi hip hop, warm keys"}, + ], + "duration_seconds": 30.0, + "cost": 0.24, +} + + +def test_parse_reads_segments_and_variations(): + result = parse_video_analysis_result(SUCCESS_BODY) + assert result.task_id == "va1" + assert result.status == "succeeded" + assert result.type == "video_analysis" + assert result.variants_num == 2 + assert result.duration_seconds == 30.0 + assert result.cost == 0.24 + assert [(s.start, s.end, s.label, s.prompt) for s in result.segments] == [ + (0, 12, "intro", "sparse piano, rising"), + (12, 30, "none", "full strings, driving"), + ] + assert [v.prompt for v in result.variations] == [ + "cinematic strings, 90bpm", + "lo-fi hip hop, warm keys", + ] + + +def test_parse_defaults_segments_and_variations_to_empty(): + """A processing task carries neither list; they must read as empty rather + than None so callers can iterate without a guard.""" + result = parse_video_analysis_result({"task_id": "va1", "status": "processing"}) + assert result.segments == [] + assert result.variations == [] + + +def test_parse_skips_malformed_entries(): + """A backend change that adds a differently-shaped entry must not turn + into an AttributeError deep in the caller's loop.""" + result = parse_video_analysis_result( + { + "task_id": "va1", + "status": "succeeded", + "segments": ["nope", {"start": 0, "end": 3, "prompt": "kept"}], + "variations": [{"no_prompt": 1}, {"prompt": "kept too"}], + } + ) + assert [s.prompt for s in result.segments] == ["kept"] + assert result.segments[0].label == "none" + assert [v.prompt for v in result.variations] == ["kept too"] + + +def test_parse_rejects_a_body_without_a_task_id(): + with pytest.raises(SoniloError): + parse_video_analysis_result({"status": "succeeded"}) + + +ACK = {"task_id": "va1", "status": "processing"} + + +@respx.mock +def test_submit_posts_to_v1_video_analysis(): + route = respx.post("https://api.sonilo.com/v1/video-analysis").mock( + return_value=httpx.Response(202, json=ACK) + ) + with Sonilo(api_key="sk-test") as client: + task = client.video_analysis.submit( + video_url="https://x/v.mp4", prompt="focus on the chase", variants_num=2 + ) + assert task.task_id == "va1" + sent = unquote_plus(route.calls.last.request.content.decode()) + assert "video_url=https://x/v.mp4" in sent + assert "prompt=focus on the chase" in sent + assert "variants_num=2" in sent + + +@respx.mock +def test_submit_omits_unset_optionals(): + route = respx.post("https://api.sonilo.com/v1/video-analysis").mock( + return_value=httpx.Response(202, json=ACK) + ) + with Sonilo(api_key="sk-test") as client: + client.video_analysis.submit(video_url="https://x/v.mp4") + sent = unquote_plus(route.calls.last.request.content.decode()) + assert "prompt" not in sent + assert "variants_num" not in sent + + +@respx.mock +def test_submit_requires_exactly_one_input(): + route = respx.post("https://api.sonilo.com/v1/video-analysis") + with Sonilo(api_key="sk-test") as client: + with pytest.raises(SoniloError): + client.video_analysis.submit() + with pytest.raises(SoniloError): + client.video_analysis.submit(video="v.mp4", video_url="https://x/v.mp4") + assert not route.called + + +@respx.mock +def test_submit_uploads_a_local_file(tmp_path): + clip = tmp_path / "clip.mp4" + clip.write_bytes(b"fake-mp4") + route = respx.post("https://api.sonilo.com/v1/video-analysis").mock( + return_value=httpx.Response(202, json=ACK) + ) + with Sonilo(api_key="sk-test") as client: + client.video_analysis.submit(video=str(clip)) + body = route.calls.last.request.content + assert b"fake-mp4" in body + assert b'filename="clip.mp4"' in body + + +@respx.mock +def test_analyze_polls_to_a_video_analysis_result(): + respx.post("https://api.sonilo.com/v1/video-analysis").mock( + return_value=httpx.Response(202, json=ACK) + ) + respx.get("https://api.sonilo.com/v1/tasks/va1").mock( + return_value=httpx.Response(200, json=SUCCESS_BODY) + ) + with Sonilo(api_key="sk-test") as client: + result = client.video_analysis.analyze( + video_url="https://x/v.mp4", variants_num=2, poll_interval=0 + ) + assert [v.prompt for v in result.variations] == [ + "cinematic strings, 90bpm", + "lo-fi hip hop, warm keys", + ] + + +@respx.mock +async def test_async_analyze_polls_to_a_video_analysis_result(): + respx.post("https://api.sonilo.com/v1/video-analysis").mock( + return_value=httpx.Response(202, json=ACK) + ) + respx.get("https://api.sonilo.com/v1/tasks/va1").mock( + return_value=httpx.Response(200, json=SUCCESS_BODY) + ) + async with AsyncSonilo(api_key="sk-test") as client: + result = await client.video_analysis.analyze( + video_url="https://x/v.mp4", poll_interval=0 + ) + assert result.segments[0].label == "intro"