From 44e44ad69f31c64dc93eabfc59533f8f00713588 Mon Sep 17 00:00:00 2001 From: Spencer Qian Date: Thu, 17 Sep 2026 23:26:58 -0700 Subject: [PATCH] video-analysis: mode parameter (default both), sound-design brief on the result; release sonilo 0.19.0 and sonilo-cli 0.18.0 POST /v1/video-analysis takes an optional `mode`: both (the server default), music or sfx. Both submit() and analyze() on the sync and async resources accept it and pass it straight through; it is omitted when unset so the server default applies, and its values are not checked client-side, like variants_num. VideoAnalysisResult gains `mode` (an echo of the request), `sfx_segments` (shot-sized sections, label always "none") and `sfx_prompt` (one string, the sound design is authored once regardless of variants_num). The parser sanitizes sfx_segments the way it already sanitizes segments, and reads a blank or non-string sfx_prompt as None. mode=music reproduces the previous result shape plus the echo. The CLI adds `--mode {both,music,sfx}` and emits mode, sfx_segments and sfx_prompt in its JSON brief when the task carries them; a music or sfx brief keeps the shape it always had. The video-analysis duration cap is now 480 seconds (was 360); the price is unchanged. README, the CLI README and context7.json say so. sonilo 0.18.2 -> 0.19.0, sonilo-cli 0.17.2 -> 0.18.0, and the CLI's core pin widens to sonilo>=0.19.0,<0.20. The CLI's uv.lock still resolves the published 0.18.1 and needs a refresh once 0.19.0 is on PyPI. --- README.md | 12 ++- context7.json | 4 +- pyproject.toml | 2 +- sonilo-cli/README.md | 5 +- sonilo-cli/pyproject.toml | 4 +- sonilo-cli/src/sonilo_cli/__init__.py | 2 +- sonilo-cli/src/sonilo_cli/__main__.py | 40 ++++++--- sonilo-cli/tests/test_cli.py | 74 +++++++++++++++- src/sonilo/_requests.py | 10 ++- src/sonilo/_version.py | 2 +- src/sonilo/resources/tasks.py | 31 +++++-- src/sonilo/resources/video_analysis.py | 26 +++++- src/sonilo/types.py | 15 +++- tests/test_video_analysis.py | 113 +++++++++++++++++++++++++ uv.lock | 2 +- 15 files changed, 301 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index 9939a50..b6284bc 100644 --- a/README.md +++ b/README.md @@ -509,9 +509,14 @@ The result is the work order — a time-aligned `segments` plan plus one 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 360 seconds long, and -billing has a 10-second floor, so a very short clip still costs the same as a +for the analysis, at most 2000 characters), `variants_num` (1-5, default +1 — billed per brief) and `mode`. `mode` defaults to `both`: the music brief +in `segments`/`variations` plus a sound-design brief in `sfx_segments` +(shot-sized sections) and `sfx_prompt` (one string, authored once regardless +of `variants_num`). Pass `mode="music"` or `mode="sfx"` for just one brief; +`mode="music"` reproduces the previous result shape. The price is the same +for all three. Source videos may be at most 480 seconds long, and billing +has a 10-second floor, so a very short clip still costs the same as a 10-second one. ```python @@ -525,6 +530,7 @@ with Sonilo() as client: ) for segment in brief.segments: print(f"{segment.start}-{segment.end}s [{segment.label}] {segment.prompt}") + print(brief.sfx_prompt) # the sound-design brief (mode="both") # Feed a variation's prompt straight into a generation call. track = client.video_to_music.generate_async( diff --git a/context7.json b/context7.json index 4eca749..2c1dbd3 100644 --- a/context7.json +++ b/context7.json @@ -32,7 +32,7 @@ "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: 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 360s, 10s billing floor." + "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." ] } diff --git a/pyproject.toml b/pyproject.toml index 3f7ad8c..1cce918 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "sonilo" -version = "0.18.2" +version = "0.19.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 2c95e82..2965996 100644 --- a/sonilo-cli/README.md +++ b/sonilo-cli/README.md @@ -259,7 +259,10 @@ command that produces no media file — nothing is generated: 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 360 seconds long, and billing has a 10-second floor. +- `--mode` is `both` (default), `music` or `sfx`. `both` adds a sound-design brief to the JSON: + `sfx_segments` (shot-sized sections) and `sfx_prompt` (one string). `music` reproduces the + previous output shape. Same price for all three. +- Source videos may be at most 480 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 diff --git a/sonilo-cli/pyproject.toml b/sonilo-cli/pyproject.toml index 2b3974b..09e3d5a 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.17.2" +version = "0.18.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.18.0,<0.19"] +dependencies = ["sonilo>=0.19.0,<0.20"] 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 7f54817..247c7f8 100644 --- a/sonilo-cli/src/sonilo_cli/__init__.py +++ b/sonilo-cli/src/sonilo_cli/__init__.py @@ -1,3 +1,3 @@ -__version__ = "0.17.2" +__version__ = "0.18.0" __all__ = ["__version__"] diff --git a/sonilo-cli/src/sonilo_cli/__main__.py b/sonilo-cli/src/sonilo_cli/__main__.py index 35abb94..c154b34 100644 --- a/sonilo-cli/src/sonilo_cli/__main__.py +++ b/sonilo-cli/src/sonilo_cli/__main__.py @@ -570,16 +570,23 @@ def _analysis_payload(result: Any) -> dict: 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. + + `mode` is echoed when the API sent one. The sound-design half of a + `both` brief — `sfx_segments` and `sfx_prompt` — is emitted only when + present, so a `music` or `sfx` brief keeps the shape it always had. """ - 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], - } + payload: dict = {"task_id": result.task_id, "status": result.status} + mode = getattr(result, "mode", None) + if mode is not None: + payload["mode"] = mode + payload["segments"] = _segments_payload(result.segments) + payload["variations"] = [{"prompt": v.prompt} for v in result.variations] + sfx_segments = getattr(result, "sfx_segments", None) or [] + if sfx_segments or mode == "both": + payload["sfx_segments"] = _segments_payload(sfx_segments) + sfx_prompt = getattr(result, "sfx_prompt", None) + if sfx_prompt is not None: + payload["sfx_prompt"] = sfx_prompt for key in ("variants_num", "duration_seconds", "cost"): value = getattr(result, key, None) if value is not None: @@ -587,13 +594,20 @@ def _analysis_payload(result: Any) -> dict: return payload +def _segments_payload(segments: Any) -> list: + return [ + {"start": s.start, "end": s.end, "label": s.label, "prompt": s.prompt} + for s in segments + ] + + 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, + prompt=args.prompt, variants_num=args.variants, mode=args.mode, timeout=args.timeout, ) if not result.variations: @@ -1063,6 +1077,12 @@ def build_parser() -> argparse.ArgumentParser: help="How many independent briefs to author for the same video (1-5). " "Billed per brief. Default: 1", ) + p_va.add_argument( + "--mode", default=None, choices=["both", "music", "sfx"], + help="Which brief to return: both (default) returns a music brief and a " + "sound-effects brief in one call; music or sfx returns only that " + "one. Same price for all three.", + ) p_va.add_argument( "--output", default=None, help="Write the brief to this .json file instead of printing it to stdout.", diff --git a/sonilo-cli/tests/test_cli.py b/sonilo-cli/tests/test_cli.py index c915709..5439db3 100644 --- a/sonilo-cli/tests/test_cli.py +++ b/sonilo-cli/tests/test_cli.py @@ -1609,12 +1609,22 @@ def test_empty_env_var_falls_through_to_the_credential(monkeypatch): } -def _mock_analysis(): +ANALYSIS_BOTH_BODY = { + **ANALYSIS_BODY, + "mode": "both", + "sfx_segments": [ + {"start": 0, "end": 4, "label": "none", "prompt": "wind, distant traffic"}, + ], + "sfx_prompt": "naturalistic exterior ambience", +} + + +def _mock_analysis(body=ANALYSIS_BODY): 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) + return_value=httpx.Response(200, json=body) ) @@ -1658,6 +1668,66 @@ def test_video_analysis_omits_unset_optionals(): body = unquote_plus(route.calls.last.request.content.decode()) assert "prompt" not in body assert "variants_num" not in body + assert "mode" not in body + + +@respx.mock +def test_video_analysis_sends_mode(): + _mock_analysis() + route = respx.routes[0] + run(["video-analysis", "--video-url", "https://x/v.mp4", "--mode", "sfx"]) + body = unquote_plus(route.calls.last.request.content.decode()) + assert "mode=sfx" in body + + +def test_video_analysis_rejects_an_unknown_mode(capsys): + """argparse owns the choice list, so a typo fails before any request + (the CLI maps argparse errors to exit 1, like every other usage error).""" + with pytest.raises(SystemExit) as exc: + main(["--api-key", "sk-test", "video-analysis", "--video-url", + "https://x/v.mp4", "--mode", "ambience"]) + assert exc.value.code == 1 + assert "invalid choice" in capsys.readouterr().err + + +@respx.mock +def test_video_analysis_prints_the_sound_design_brief_in_both_mode(capsys): + """A `both` brief carries mode, sfx_segments and sfx_prompt in the wire + shape, in a readable key order.""" + _mock_analysis(ANALYSIS_BOTH_BODY) + run(["video-analysis", "--video-url", "https://x/v.mp4"]) + out = json.loads(capsys.readouterr().out) + assert out["mode"] == "both" + assert out["sfx_segments"] == [ + {"start": 0, "end": 4, "label": "none", "prompt": "wind, distant traffic"} + ] + assert out["sfx_prompt"] == "naturalistic exterior ambience" + assert list(out)[:6] == [ + "task_id", "status", "mode", "segments", "variations", "sfx_segments" + ] + assert list(out)[6] == "sfx_prompt" + + +@respx.mock +def test_video_analysis_omits_sound_design_keys_when_absent(capsys): + """A music/sfx brief (and a pre-`mode` body) keeps the old shape: no + mode, sfx_segments or sfx_prompt key at all, not empty placeholders.""" + _mock_analysis() + run(["video-analysis", "--video-url", "https://x/v.mp4"]) + out = json.loads(capsys.readouterr().out) + assert "mode" not in out + assert "sfx_segments" not in out + assert "sfx_prompt" not in out + + +@respx.mock +def test_video_analysis_music_mode_echo_only(capsys): + _mock_analysis({**ANALYSIS_BODY, "mode": "music"}) + run(["video-analysis", "--video-url", "https://x/v.mp4", "--mode", "music"]) + out = json.loads(capsys.readouterr().out) + assert out["mode"] == "music" + assert "sfx_segments" not in out + assert "sfx_prompt" not in out @respx.mock diff --git a/src/sonilo/_requests.py b/src/sonilo/_requests.py index 3008dd4..2ec6a04 100644 --- a/src/sonilo/_requests.py +++ b/src/sonilo/_requests.py @@ -304,12 +304,14 @@ def build_video_analysis_parts( video_url: Optional[str], prompt: Optional[str], variants_num: Optional[int], + mode: Optional[str] = None, ) -> 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, + Every optional is omitted when unset so the server's own defaults apply + (no prompt, one variation, mode "both"). The 1-5 bound on variants_num, + the 2000-char bound on prompt and the allowed values of mode ("both", + "music", "sfx") 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): @@ -323,6 +325,8 @@ def build_video_analysis_parts( data["prompt"] = prompt if variants_num is not None: data["variants_num"] = str(variants_num) + if mode is not None: + data["mode"] = mode # Now open files (only after data is fully assembled) files: Optional[Dict[str, tuple]] = None diff --git a/src/sonilo/_version.py b/src/sonilo/_version.py index b0d7306..11ac8e1 100644 --- a/src/sonilo/_version.py +++ b/src/sonilo/_version.py @@ -1 +1 @@ -__version__ = "0.18.2" +__version__ = "0.19.0" diff --git a/src/sonilo/resources/tasks.py b/src/sonilo/resources/tasks.py index f1dfa23..7d1367d 100644 --- a/src/sonilo/resources/tasks.py +++ b/src/sonilo/resources/tasks.py @@ -303,6 +303,14 @@ def _analysis_segment_from(data: Any) -> Optional[AnalysisSegment]: return None +def _analysis_segments_from(raw: Any) -> List[AnalysisSegment]: + """Coerce a raw segment list (`segments` or `sfx_segments`), dropping + malformed entries; anything that is not a list reads as empty.""" + if not isinstance(raw, list): + return [] + return [s for s in map(_analysis_segment_from, raw) if s is not None] + + def _analysis_variation_from(data: Any) -> Optional[AnalysisVariation]: if not isinstance(data, dict): return None @@ -316,23 +324,27 @@ 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 + All three 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. + `sfx_segments`/`sfx_prompt` only exist in mode "both"; a blank or + non-string `sfx_prompt` reads as None so callers can test it for truth. """ - 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 [] - ) + segments = _analysis_segments_from(body.get("segments")) + sfx_segments = _analysis_segments_from(body.get("sfx_segments")) 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 [] ) + raw_sfx_prompt = body.get("sfx_prompt") + sfx_prompt = ( + raw_sfx_prompt.strip() + if isinstance(raw_sfx_prompt, str) and raw_sfx_prompt.strip() + else None + ) try: return VideoAnalysisResult( task_id=body["task_id"], @@ -345,6 +357,9 @@ def parse_video_analysis_result(body: Dict[str, Any]) -> "VideoAnalysisResult": error=body.get("error"), refunded=body.get("refunded"), variants_num=body.get("variants_num"), + mode=body.get("mode"), + sfx_segments=sfx_segments, + sfx_prompt=sfx_prompt, ) except KeyError as e: raise SoniloError(f"Malformed task response: missing {e.args[0]!r}") from e diff --git a/src/sonilo/resources/video_analysis.py b/src/sonilo/resources/video_analysis.py index 5d53960..1438160 100644 --- a/src/sonilo/resources/video_analysis.py +++ b/src/sonilo/resources/video_analysis.py @@ -28,6 +28,12 @@ class VideoAnalysis: straight to video_to_music, video_to_sfx, video_to_sound or their video-to-video counterparts. + `mode` picks the brief. The default, "both", returns the music brief in + `segments`/`variations` plus a sound-design brief in + `sfx_segments`/`sfx_prompt` in the same call; "music" or "sfx" returns + just that one brief. "music" reproduces the pre-`mode` result shape. The + price is the same for all three. + The method is `analyze`, not `generate`, for that reason: every other resource's `generate` returns something you save, and this one never does. @@ -43,9 +49,10 @@ def submit( video_url: Optional[str] = None, prompt: Optional[str] = None, variants_num: Optional[int] = None, + mode: Optional[str] = None, ) -> SfxTask: data, files, opened = build_video_analysis_parts( - video, video_url, prompt, variants_num + video, video_url, prompt, variants_num, mode ) close_after = files["video"][1] if files is not None and opened else None return parse_sfx_task( @@ -59,11 +66,15 @@ def analyze( video_url: Optional[str] = None, prompt: Optional[str] = None, variants_num: Optional[int] = None, + mode: Optional[str] = None, poll_interval: float = DEFAULT_POLL_INTERVAL, timeout: float = DEFAULT_WAIT_TIMEOUT, ) -> VideoAnalysisResult: + """Submit and wait for the brief. `mode` defaults to "both" (music + brief plus sound-design brief); pass "music" or "sfx" for one.""" task = self.submit( - video=video, video_url=video_url, prompt=prompt, variants_num=variants_num + video=video, video_url=video_url, prompt=prompt, variants_num=variants_num, + mode=mode, ) return self._client.tasks.wait( task.task_id, @@ -74,6 +85,8 @@ def analyze( class AsyncVideoAnalysis: + """Async twin of VideoAnalysis; same `mode` semantics (default "both").""" + def __init__(self, client: "AsyncSonilo") -> None: self._client = client @@ -84,9 +97,10 @@ async def submit( video_url: Optional[str] = None, prompt: Optional[str] = None, variants_num: Optional[int] = None, + mode: Optional[str] = None, ) -> SfxTask: data, files, opened = build_video_analysis_parts( - video, video_url, prompt, variants_num + video, video_url, prompt, variants_num, mode ) close_after = files["video"][1] if files is not None and opened else None return parse_sfx_task( @@ -102,11 +116,15 @@ async def analyze( video_url: Optional[str] = None, prompt: Optional[str] = None, variants_num: Optional[int] = None, + mode: Optional[str] = None, poll_interval: float = DEFAULT_POLL_INTERVAL, timeout: float = DEFAULT_WAIT_TIMEOUT, ) -> VideoAnalysisResult: + """Submit and wait for the brief. `mode` defaults to "both" (music + brief plus sound-design brief); pass "music" or "sfx" for one.""" task = await self.submit( - video=video, video_url=video_url, prompt=prompt, variants_num=variants_num + video=video, video_url=video_url, prompt=prompt, variants_num=variants_num, + mode=mode, ) return await self._client.tasks.wait( task.task_id, diff --git a/src/sonilo/types.py b/src/sonilo/types.py index 129ba29..5dc5e35 100644 --- a/src/sonilo/types.py +++ b/src/sonilo/types.py @@ -779,8 +779,9 @@ async def asave_all_subtitles( @dataclass class AnalysisSegment: - """One time-aligned section of the analyzed video, with the scoring - direction for that stretch. Bounds are whole seconds — the backend + """One time-aligned section of the analyzed video, with the creative + direction for that stretch: scoring direction in `segments`, sound-design + direction in `sfx_segments`. Bounds are whole seconds — the backend truncates any fractional upstream bound before it reaches the envelope.""" start: int @@ -810,6 +811,13 @@ class VideoAnalysisResult: 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. + + `mode` names the brief the task returned (an echo of the request: + "both", "music" or "sfx"). In "both" mode — the server default — the + music brief above is joined by a sound-design brief: `sfx_segments` + (shot-sized sections, label always "none") and `sfx_prompt` (one string; + the sound design is authored once regardless of `variants_num`). In + "music" and "sfx" mode both stay empty/None. """ task_id: str @@ -822,3 +830,6 @@ class VideoAnalysisResult: error: Optional[Dict[str, Any]] = None refunded: Optional[bool] = None variants_num: Optional[int] = None + mode: Optional[str] = None + sfx_segments: List[AnalysisSegment] = field(default_factory=list) + sfx_prompt: Optional[str] = None diff --git a/tests/test_video_analysis.py b/tests/test_video_analysis.py index 1075bb6..65eea90 100644 --- a/tests/test_video_analysis.py +++ b/tests/test_video_analysis.py @@ -73,6 +73,69 @@ def test_parse_rejects_a_body_without_a_task_id(): parse_video_analysis_result({"status": "succeeded"}) +BOTH_BODY = { + **SUCCESS_BODY, + "mode": "both", + "sfx_segments": [ + {"start": 0, "end": 4, "label": "none", "prompt": "wind, distant traffic"}, + {"start": 4, "end": 9, "label": "none", "prompt": "footsteps on gravel"}, + ], + "sfx_prompt": " naturalistic exterior ambience with sharp foley hits ", +} + + +def test_parse_reads_mode_sfx_segments_and_sfx_prompt(): + """mode "both" adds a sound-design brief next to the music one: shot-sized + `sfx_segments` and a single `sfx_prompt`, which is stripped.""" + result = parse_video_analysis_result(BOTH_BODY) + assert result.mode == "both" + assert [(s.start, s.end, s.label, s.prompt) for s in result.sfx_segments] == [ + (0, 4, "none", "wind, distant traffic"), + (4, 9, "none", "footsteps on gravel"), + ] + assert result.sfx_prompt == "naturalistic exterior ambience with sharp foley hits" + # The music half is untouched by the extra keys. + assert [s.label for s in result.segments] == ["intro", "none"] + assert len(result.variations) == 2 + + +def test_parse_skips_malformed_sfx_segments(): + result = parse_video_analysis_result( + { + **BOTH_BODY, + "sfx_segments": [ + "nope", + {"start": "x", "end": 3, "prompt": "bad start"}, + {"start": 0, "end": 3, "prompt": "kept"}, + ], + } + ) + assert [s.prompt for s in result.sfx_segments] == ["kept"] + assert result.sfx_segments[0].label == "none" + + +def test_parse_defaults_mode_and_sfx_fields_when_absent(): + """A pre-`mode` body (and a music/sfx-mode body) carries none of the + three: mode None, an empty list and None, so callers need no guard.""" + result = parse_video_analysis_result(SUCCESS_BODY) + assert result.mode is None + assert result.sfx_segments == [] + assert result.sfx_prompt is None + + +@pytest.mark.parametrize("raw", ["", " ", None, 7, ["not", "a", "string"]]) +def test_parse_blank_or_non_string_sfx_prompt_reads_as_none(raw): + result = parse_video_analysis_result({**BOTH_BODY, "sfx_prompt": raw}) + assert result.sfx_prompt is None + + +def test_parse_music_mode_echo_without_sfx_keys(): + result = parse_video_analysis_result({**SUCCESS_BODY, "mode": "music"}) + assert result.mode == "music" + assert result.sfx_segments == [] + assert result.sfx_prompt is None + + ACK = {"task_id": "va1", "status": "processing"} @@ -102,6 +165,56 @@ def test_submit_omits_unset_optionals(): sent = unquote_plus(route.calls.last.request.content.decode()) assert "prompt" not in sent assert "variants_num" not in sent + assert "mode" not in sent + + +@respx.mock +def test_submit_sends_mode_only_when_set(): + """`mode` is a plain pass-through: sent verbatim when given, omitted when + not so the server default ("both") applies. Values are not checked + client-side, so an unknown one reaches the API rather than an SDK error.""" + 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", mode="sfx") + sent = unquote_plus(route.calls.last.request.content.decode()) + assert "mode=sfx" in sent + client.video_analysis.submit(video_url="https://x/v.mp4", mode="whatever") + sent = unquote_plus(route.calls.last.request.content.decode()) + assert "mode=whatever" in sent + + +@respx.mock +def test_analyze_passes_mode_through_to_submit(): + route = 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=BOTH_BODY) + ) + with Sonilo(api_key="sk-test") as client: + result = client.video_analysis.analyze( + video_url="https://x/v.mp4", mode="both", poll_interval=0 + ) + assert "mode=both" in unquote_plus(route.calls.last.request.content.decode()) + assert result.sfx_prompt == "naturalistic exterior ambience with sharp foley hits" + + +@respx.mock +async def test_async_analyze_passes_mode_through_to_submit(): + route = 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=BOTH_BODY) + ) + async with AsyncSonilo(api_key="sk-test") as client: + result = await client.video_analysis.analyze( + video_url="https://x/v.mp4", mode="music", poll_interval=0 + ) + assert "mode=music" in unquote_plus(route.calls.last.request.content.decode()) + assert result.mode == "both" # whatever the mocked task body says @respx.mock diff --git a/uv.lock b/uv.lock index c283cb6..c936885 100644 --- a/uv.lock +++ b/uv.lock @@ -267,7 +267,7 @@ wheels = [ [[package]] name = "sonilo" -version = "0.18.2" +version = "0.19.0" source = { editable = "." } dependencies = [ { name = "httpx" },