diff --git a/README.md b/README.md index 62aa619..5eb2423 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,26 @@ for i in range(len(result.audio)): default) that's the same single-entry list as before this option existed, and the top-level `result.title` stays an alias for `result.audio[0].title`. +### Prompt influence + +`prompt_influence` (0-1, API default `0.5`) sets how strongly the generated +music follows the prompt: lower values let the video lead; higher values +follow the prompt more literally. It is free of charge, and unlike the +options above it is not async-only — every `video_to_music` method takes it, +streaming `generate()`/`stream()` included. `video_to_video_music` takes it +too; no other endpoint does. Omit it to keep the long-standing behavior +(the field stays off the wire and the API's own `0.5` default applies — +`0.0` is a real value and is sent); out-of-range values are rejected with +a 422. + +```python +track = client.video_to_music.generate( + video="my_video.mp4", + prompt="upbeat electro swing", + prompt_influence=0.8, # follow the prompt closely +) +``` + ## Video to video Generate music or sound effects and get back a **re-hosted video** with the @@ -182,7 +202,9 @@ and run no longer than 360 seconds; animated gif and VP8 webm are rejected. It also takes `variants_num` (1-10, default `1`): each variant scores the source video with a different musical direction. This endpoint is already async-only, so no `mode` to auto-select — `variants_num` -just travels straight through. +just travels straight through. And it takes `prompt_influence` (0-1, API +default `0.5`, free of charge): how strongly the generated music follows the +prompt — see [Prompt influence](#prompt-influence). ```python music = client.video_to_video_music.generate( diff --git a/pyproject.toml b/pyproject.toml index 010fefd..adff7cf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "sonilo" -version = "0.11.3" +version = "0.12.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 15fa5f5..0c225d1 100644 --- a/sonilo-cli/README.md +++ b/sonilo-cli/README.md @@ -90,6 +90,17 @@ request — and values above 1 are never covered by the free trial. - On `video-to-sound` / `video-to-video-sound`, `--stem` is applied per variant too, e.g. `take.0.music.m4a`. +### Prompt influence + +`--prompt-influence` (0-1, API default 0.5) sets how strongly the generated music follows the +prompt, on `video-to-music` and `video-to-video-music` only. Lower values let the video lead; +higher values follow the prompt more literally. It is free of charge, and unlike `--format wav` it +does not force the async path — it works on the streaming default too. Left unset, the field is +not sent at all and the API's own 0.5 default applies; `--prompt-influence 0` is a real value +("let the video lead entirely") and is sent. Out-of-range values earn a `422` from the API. + + sonilo video-to-music --video clip.mp4 --prompt "tense synths" --prompt-influence 0.8 + ### Scored video `video-to-video-music` and `video-to-video-sfx` are the video-out counterparts of `video-to-music` @@ -112,6 +123,8 @@ file (default `output.mp4`): - For music *and* effects in one call, use `video-to-video-sound` below. - `video-to-video-music` also takes `--variants` — see [Variants](#variants) above. `video-to-video-sfx` does not. +- `video-to-video-music` also takes `--prompt-influence` — see + [Prompt influence](#prompt-influence) above. `video-to-video-sfx` does not. ### Combined soundtracks diff --git a/sonilo-cli/pyproject.toml b/sonilo-cli/pyproject.toml index 6b0a822..7932740 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.9.0" +version = "0.10.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.11.0,<0.12"] +dependencies = ["sonilo>=0.12.0,<0.13"] 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 465a999..7563b7d 100644 --- a/sonilo-cli/src/sonilo_cli/__init__.py +++ b/sonilo-cli/src/sonilo_cli/__init__.py @@ -1,3 +1,3 @@ -__version__ = "0.9.0" +__version__ = "0.10.0" __all__ = ["__version__"] diff --git a/sonilo-cli/src/sonilo_cli/__main__.py b/sonilo-cli/src/sonilo_cli/__main__.py index b66355a..dc28432 100644 --- a/sonilo-cli/src/sonilo_cli/__main__.py +++ b/sonilo-cli/src/sonilo_cli/__main__.py @@ -309,12 +309,15 @@ def cmd_video_to_music(client: Sonilo, args: argparse.Namespace) -> None: preserve_speech=args.preserve_speech or None, output_format=fmt if fmt != "m4a" else None, variants_num=args.variants, + prompt_influence=args.prompt_influence, ) _save_music_variants(result, out) else: + # prompt_influence rides the streaming path too — it is a generation + # parameter, not a finalize-time one, so it never forces async. track = client.video_to_music.generate( video=args.video, video_url=args.video_url, prompt=args.prompt, - segments=segments, + segments=segments, prompt_influence=args.prompt_influence, ) path = track.save(out) _wrote(path, len(track.audio)) @@ -435,6 +438,7 @@ def cmd_video_to_video_music(client: Sonilo, args: argparse.Namespace) -> None: preserve_speech=True if args.preserve_speech else None, isolate_vocals=True if args.isolate_vocals else None, variants_num=args.variants, + prompt_influence=args.prompt_influence, ) @@ -543,6 +547,19 @@ def _add_segments(parser: argparse.ArgumentParser, shape: _SegmentShape) -> None parser.set_defaults(segments_shape=shape) +def _add_prompt_influence(parser: argparse.ArgumentParser) -> None: + # Only the two music-from-video commands take this — the API accepts it + # nowhere else. type=float so 0 arrives as 0.0, a real value ("let the + # video lead entirely"), distinct from the unset None that keeps the + # field off the wire and leaves the API its own 0.5 default. + parser.add_argument( + "--prompt-influence", dest="prompt_influence", type=float, default=None, + help="How strongly the generated music follows the prompt, 0-1 " + "(API default 0.5). Lower values let the video lead; higher " + "values follow the prompt more literally. Free of charge.", + ) + + def _add_variants(parser: argparse.ArgumentParser) -> None: parser.add_argument( "--variants", type=int, default=None, @@ -601,6 +618,7 @@ def build_parser() -> argparse.ArgumentParser: p_v2m.add_argument("--async", dest="use_async", action="store_true", help="Submit and poll instead of streaming.") _add_variants(p_v2m) + _add_prompt_influence(p_v2m) p_v2m.set_defaults(func=cmd_video_to_music) p_t2s = sub.add_parser("text-to-sfx", help="Generate a sound effect from a text prompt") @@ -677,6 +695,7 @@ def build_parser() -> argparse.ArgumentParser: help="Legacy alias for --preserve-speech; no separate stem.") p_v2vm.add_argument("--output", default=None, help="Where to save the scored video.") _add_variants(p_v2vm) + _add_prompt_influence(p_v2vm) p_v2vm.set_defaults(func=cmd_video_to_video_music) p_v2vfx = sub.add_parser( diff --git a/sonilo-cli/tests/test_cli.py b/sonilo-cli/tests/test_cli.py index 7758e3d..1a381a1 100644 --- a/sonilo-cli/tests/test_cli.py +++ b/sonilo-cli/tests/test_cli.py @@ -1096,3 +1096,64 @@ def test_video_to_video_commands_are_listed_in_top_level_help(command, capsys): with pytest.raises(SystemExit): main(["--help"]) assert command in capsys.readouterr().out + + +# --- --prompt-influence ------------------------------------------------------- +# +# Only video-to-music and video-to-video-music offer the flag — the API +# accepts prompt_influence nowhere else. Unset forwards None (field absent, +# API default 0.5 stands); 0 is a real value and must be sent as 0.0. + + +@respx.mock +def test_video_to_music_prompt_influence_rides_the_streaming_path(tmp_path): + """prompt_influence is a generation parameter, valid on stream and async + alike, so unlike --format wav it must NOT force the async path.""" + route = respx.post(f"{BASE}/v1/video-to-music").mock( + return_value=httpx.Response(200, text=_music_stream_body()) + ) + run(["video-to-music", "--video-url", "http://x/y.mp4", + "--prompt-influence", "0.8", "--output", str(tmp_path / "song.m4a")]) + body = route.calls.last.request.content.decode() + assert "prompt_influence=0.8" in body + + +@respx.mock +def test_video_to_music_prompt_influence_zero_is_sent(tmp_path): + """0 means "let the video lead entirely" — a real request, distinct from + unset, so it goes on the wire (as 0.0: argparse's float parse).""" + route = respx.post(f"{BASE}/v1/video-to-music").mock( + return_value=httpx.Response(200, text=_music_stream_body()) + ) + run(["video-to-music", "--video-url", "http://x/y.mp4", + "--prompt-influence", "0", "--output", str(tmp_path / "song.m4a")]) + body = route.calls.last.request.content.decode() + assert "prompt_influence=0.0" in body + + +@respx.mock +def test_video_to_music_omits_prompt_influence_when_unset(tmp_path): + route = respx.post(f"{BASE}/v1/video-to-music").mock( + return_value=httpx.Response(200, text=_music_stream_body()) + ) + run(["video-to-music", "--video-url", "http://x/y.mp4", + "--output", str(tmp_path / "song.m4a")]) + # Absent, not "None" and not an explicit 0.5 pinning the API's default. + assert b"prompt_influence" not in route.calls.last.request.content + + +@respx.mock +def test_video_to_video_music_prompt_influence_reaches_the_request_body(tmp_path): + route = _mock_video_task("video-to-video-music", "vmpi1", "video_to_video_music") + run(["video-to-video-music", "--video-url", "http://x/y.mp4", + "--prompt-influence", "0.3", "--output", str(tmp_path / "s.mp4")]) + body = route.calls.last.request.content.decode() + assert "prompt_influence=0.3" in body + + +@respx.mock +def test_video_to_video_music_omits_prompt_influence_when_unset(tmp_path): + route = _mock_video_task("video-to-video-music", "vmpi2", "video_to_video_music") + run(["video-to-video-music", "--video-url", "http://x/y.mp4", + "--output", str(tmp_path / "s.mp4")]) + assert b"prompt_influence" not in route.calls.last.request.content diff --git a/src/sonilo/_requests.py b/src/sonilo/_requests.py index 34eec9a..b5517ba 100644 --- a/src/sonilo/_requests.py +++ b/src/sonilo/_requests.py @@ -63,7 +63,14 @@ def build_v2m_parts( video_url: Optional[str], prompt: Optional[str], segments: Optional[List[Segment]], + *, + prompt_influence: Optional[float] = None, ) -> Tuple[Dict[str, str], Optional[Dict[str, tuple]], bool]: + """`prompt_influence` is keyword-only with a None default because this + builder is shared: only the two music endpoints (video-to-music and, via + build_v2v_music_parts, video-to-video-music) accept it, and every other + caller simply never passes it — the same pattern as output_format / + keep_original_sound on build_v2s_parts.""" if (video is None) == (video_url is None): raise SoniloError("Provide exactly one of video or video_url") @@ -75,6 +82,11 @@ def build_v2m_parts( data["prompt"] = prompt if segments is not None: data["segments"] = json.dumps(segments) + # Omitted when unset so the API's own default (0.5) applies. `is not None`, + # not truthiness: 0.0 is a meaningful value ("let the video lead entirely") + # and must go on the wire. + if prompt_influence is not None: + data["prompt_influence"] = str(prompt_influence) # Now open files (only after data is fully assembled) files: Optional[Dict[str, tuple]] = None @@ -177,13 +189,20 @@ def build_v2m_async_parts( output_format: Optional[str] = None, ducking: Optional[bool] = None, variants_num: Optional[int] = None, + prompt_influence: Optional[float] = None, ) -> Tuple[Dict[str, str], Optional[Dict[str, tuple]], bool]: """Like build_v2m_parts, plus the async-only fields for the - video-to-music submit()/generate_async() path.""" + video-to-music submit()/generate_async() path. + + `prompt_influence` is NOT async-only — it is an upstream generation + parameter, valid on stream and async alike — so it lives in + build_v2m_parts and takes no part in _resolve_music_mode.""" resolved_mode = _resolve_music_mode( mode, isolate_vocals, preserve_speech, output_format, ducking, variants_num ) - data, files, opened = build_v2m_parts(video, video_url, prompt, segments) + data, files, opened = build_v2m_parts( + video, video_url, prompt, segments, prompt_influence=prompt_influence + ) data["mode"] = resolved_mode if isolate_vocals is not None: data["isolate_vocals"] = "true" if isolate_vocals else "false" @@ -210,11 +229,14 @@ def build_v2v_music_parts( segments: Optional[List[Segment]] = None, ducking: Optional[bool] = None, keep_original_sound: Optional[bool] = None, + prompt_influence: Optional[float] = None, ) -> Tuple[Dict[str, str], Optional[Dict[str, tuple]], bool]: # video-to-video-music is 202/async-only by design (there is no streaming # mode to fall back to), so variants_num travels straight through with no # mode guard — unlike text-to-music/video-to-music. - data, files, opened = build_v2m_parts(video, video_url, prompt, segments) + data, files, opened = build_v2m_parts( + video, video_url, prompt, segments, prompt_influence=prompt_influence + ) # Every boolean is emitted only when explicitly passed, so the server's own # default stands. `ducking` and `keep_original_sound` are both default-OFF # today, but neither is pinned here — hardcoding either is what would have diff --git a/src/sonilo/_version.py b/src/sonilo/_version.py index 1bebb74..ea370a8 100644 --- a/src/sonilo/_version.py +++ b/src/sonilo/_version.py @@ -1 +1 @@ -__version__ = "0.11.3" +__version__ = "0.12.0" diff --git a/src/sonilo/resources/video_to_music.py b/src/sonilo/resources/video_to_music.py index 826d82f..50ebdf0 100644 --- a/src/sonilo/resources/video_to_music.py +++ b/src/sonilo/resources/video_to_music.py @@ -30,8 +30,15 @@ def stream( video_url: Optional[str] = None, prompt: Optional[str] = None, segments: Optional[List[Segment]] = None, + prompt_influence: Optional[float] = None, ) -> Iterator[StreamEvent]: - data, files, opened = build_v2m_parts(video, video_url, prompt, segments) + """`prompt_influence` (0-1, API default 0.5) sets how strongly the + generated music follows the prompt: lower values let the video lead; + higher values follow the prompt more literally. Free of charge, and + valid here on the streaming path as well as on submit().""" + data, files, opened = build_v2m_parts( + video, video_url, prompt, segments, prompt_influence=prompt_influence + ) close_after = files["video"][1] if files is not None and opened else None return self._client._stream_events(PATH, data=data, files=files, close_after=close_after) @@ -42,9 +49,16 @@ def generate( video_url: Optional[str] = None, prompt: Optional[str] = None, segments: Optional[List[Segment]] = None, + prompt_influence: Optional[float] = None, ) -> Track: return collect_track( - self.stream(video=video, video_url=video_url, prompt=prompt, segments=segments) + self.stream( + video=video, + video_url=video_url, + prompt=prompt, + segments=segments, + prompt_influence=prompt_influence, + ) ) def submit( @@ -60,6 +74,7 @@ def submit( output_format: Optional[str] = None, ducking: Optional[bool] = None, variants_num: Optional[int] = None, + prompt_influence: Optional[float] = None, ) -> SfxTask: """Submit an async video-to-music task and return its ack. @@ -74,6 +89,12 @@ def submit( variants in one request; the result's `audio` gets one entry per variant. Cost scales linearly, and values above 1 are never covered by the free trial. + + `prompt_influence` (0-1, API default 0.5) sets how strongly the + generated music follows the prompt: lower values let the video lead; + higher values follow the prompt more literally. Free of charge and + not async-only — stream()/generate() take it too. Out-of-range + values are rejected by the API with a 422. """ data, files, opened = build_v2m_async_parts( video, video_url, prompt, segments, mode, isolate_vocals, @@ -81,6 +102,7 @@ def submit( output_format=output_format, ducking=ducking, variants_num=variants_num, + prompt_influence=prompt_influence, ) close_after = files["video"][1] if files is not None and opened else None return parse_sfx_task( @@ -100,6 +122,7 @@ def generate_async( output_format: Optional[str] = None, ducking: Optional[bool] = None, variants_num: Optional[int] = None, + prompt_influence: Optional[float] = None, poll_interval: float = DEFAULT_POLL_INTERVAL, timeout: float = DEFAULT_WAIT_TIMEOUT, ) -> MusicResult: @@ -115,6 +138,7 @@ def generate_async( output_format=output_format, ducking=ducking, variants_num=variants_num, + prompt_influence=prompt_influence, ) return self._client.tasks.wait( task.task_id, @@ -135,8 +159,15 @@ def stream( video_url: Optional[str] = None, prompt: Optional[str] = None, segments: Optional[List[Segment]] = None, + prompt_influence: Optional[float] = None, ) -> AsyncIterator[StreamEvent]: - data, files, opened = build_v2m_parts(video, video_url, prompt, segments) + """`prompt_influence` (0-1, API default 0.5) sets how strongly the + generated music follows the prompt: lower values let the video lead; + higher values follow the prompt more literally. Free of charge, and + valid here on the streaming path as well as on submit().""" + data, files, opened = build_v2m_parts( + video, video_url, prompt, segments, prompt_influence=prompt_influence + ) close_after = files["video"][1] if files is not None and opened else None return self._client._stream_events(PATH, data=data, files=files, close_after=close_after) @@ -147,9 +178,16 @@ async def generate( video_url: Optional[str] = None, prompt: Optional[str] = None, segments: Optional[List[Segment]] = None, + prompt_influence: Optional[float] = None, ) -> Track: return await acollect_track( - self.stream(video=video, video_url=video_url, prompt=prompt, segments=segments) + self.stream( + video=video, + video_url=video_url, + prompt=prompt, + segments=segments, + prompt_influence=prompt_influence, + ) ) async def submit( @@ -165,6 +203,7 @@ async def submit( output_format: Optional[str] = None, ducking: Optional[bool] = None, variants_num: Optional[int] = None, + prompt_influence: Optional[float] = None, ) -> SfxTask: """Submit an async video-to-music task and return its ack. @@ -172,6 +211,11 @@ async def submit( variants_num>1 require mode="async" (auto-selected if `mode` is omitted); passing an explicit non-async mode alongside any of them raises a SoniloError before any request is made. + + `prompt_influence` (0-1, API default 0.5) sets how strongly the + generated music follows the prompt: lower values let the video lead; + higher values follow the prompt more literally. Free of charge and + not async-only — stream()/generate() take it too. """ data, files, opened = build_v2m_async_parts( video, video_url, prompt, segments, mode, isolate_vocals, @@ -179,6 +223,7 @@ async def submit( output_format=output_format, ducking=ducking, variants_num=variants_num, + prompt_influence=prompt_influence, ) close_after = files["video"][1] if files is not None and opened else None return parse_sfx_task( @@ -200,6 +245,7 @@ async def generate_async( output_format: Optional[str] = None, ducking: Optional[bool] = None, variants_num: Optional[int] = None, + prompt_influence: Optional[float] = None, poll_interval: float = DEFAULT_POLL_INTERVAL, timeout: float = DEFAULT_WAIT_TIMEOUT, ) -> MusicResult: @@ -215,6 +261,7 @@ async def generate_async( output_format=output_format, ducking=ducking, variants_num=variants_num, + prompt_influence=prompt_influence, ) return await self._client.tasks.wait( task.task_id, diff --git a/src/sonilo/resources/video_to_video_music.py b/src/sonilo/resources/video_to_video_music.py index c8e7cc5..e91b959 100644 --- a/src/sonilo/resources/video_to_video_music.py +++ b/src/sonilo/resources/video_to_video_music.py @@ -34,12 +34,18 @@ def submit( preserve_speech: Optional[bool] = None, isolate_vocals: Optional[bool] = None, variants_num: Optional[int] = None, + prompt_influence: Optional[float] = None, ) -> SfxTask: """`variants_num` (1-10, default 1) generates that many distinct scored videos in one request; the result's `videos` gets one entry per variant, and `video` stays an alias for `videos[0]`. Cost scales linearly, and values above 1 are never covered by the free trial. This endpoint is always async, so there is no mode to auto-select. + + `prompt_influence` (0-1, API default 0.5) sets how strongly the + generated music follows the prompt: lower values let the video lead; + higher values follow the prompt more literally. Free of charge; + out-of-range values are rejected by the API with a 422. """ data, files, opened = build_v2v_music_parts( video, @@ -51,6 +57,7 @@ def submit( segments=segments, ducking=ducking, keep_original_sound=keep_original_sound, + prompt_influence=prompt_influence, ) close_after = files["video"][1] if files is not None and opened else None return parse_sfx_task( @@ -69,6 +76,7 @@ def generate( preserve_speech: Optional[bool] = None, isolate_vocals: Optional[bool] = None, variants_num: Optional[int] = None, + prompt_influence: Optional[float] = None, poll_interval: float = DEFAULT_POLL_INTERVAL, timeout: float = DEFAULT_WAIT_TIMEOUT, ) -> VideoResult: @@ -82,6 +90,7 @@ def generate( preserve_speech=preserve_speech, isolate_vocals=isolate_vocals, variants_num=variants_num, + prompt_influence=prompt_influence, ) return self._client.tasks.wait( task.task_id, @@ -107,6 +116,7 @@ async def submit( preserve_speech: Optional[bool] = None, isolate_vocals: Optional[bool] = None, variants_num: Optional[int] = None, + prompt_influence: Optional[float] = None, ) -> SfxTask: data, files, opened = build_v2v_music_parts( video, @@ -118,6 +128,7 @@ async def submit( segments=segments, ducking=ducking, keep_original_sound=keep_original_sound, + prompt_influence=prompt_influence, ) close_after = files["video"][1] if files is not None and opened else None return parse_sfx_task( @@ -138,6 +149,7 @@ async def generate( preserve_speech: Optional[bool] = None, isolate_vocals: Optional[bool] = None, variants_num: Optional[int] = None, + prompt_influence: Optional[float] = None, poll_interval: float = DEFAULT_POLL_INTERVAL, timeout: float = DEFAULT_WAIT_TIMEOUT, ) -> VideoResult: @@ -151,6 +163,7 @@ async def generate( preserve_speech=preserve_speech, isolate_vocals=isolate_vocals, variants_num=variants_num, + prompt_influence=prompt_influence, ) return await self._client.tasks.wait( task.task_id, diff --git a/tests/test_prompt_influence.py b/tests/test_prompt_influence.py new file mode 100644 index 0000000..fabce65 --- /dev/null +++ b/tests/test_prompt_influence.py @@ -0,0 +1,142 @@ +"""Covers `prompt_influence` on the two music-from-video endpoints. + +The field sets how strongly the generated music follows the prompt (0-1; the +API's own default is 0.5, the long-standing behavior). These tests pin the +three things that keep the SDK honest about it — the field is omitted from the +wire unless explicitly passed (so an unset value never pins the API default), +an explicit 0.0 IS sent (`is not None`, never truthiness — 0.0 means "let the +video lead entirely"), and no other endpoint exposes it at all. Range checking +is deliberately left to the API's 422, same as variants_num. +""" +import inspect + +from sonilo._requests import ( + build_v2m_async_parts, + build_v2m_parts, + build_v2v_music_parts, +) +from sonilo.resources.dubbing import AsyncDubbing, Dubbing +from sonilo.resources.text_to_music import AsyncTextToMusic, TextToMusic +from sonilo.resources.video_to_music import AsyncVideoToMusic, VideoToMusic +from sonilo.resources.video_to_sfx import AsyncVideoToSfx, VideoToSfx +from sonilo.resources.video_to_sound import AsyncVideoToSound, VideoToSound +from sonilo.resources.video_to_video_music import ( + AsyncVideoToVideoMusic, + VideoToVideoMusic, +) +from sonilo.resources.video_to_video_sfx import ( + AsyncVideoToVideoSfx, + VideoToVideoSfx, +) +from sonilo.resources.video_to_video_sound import ( + AsyncVideoToVideoSound, + VideoToVideoSound, +) + + +# --- video-to-music (the stream builder, shared by stream()/generate()) ------ + +def test_v2m_omits_prompt_influence_when_none(): + """Unset must stay off the wire entirely: sending an explicit 0.5 would + pin a default the API owns.""" + data, _, _ = build_v2m_parts(None, "https://x/v.mp4", None, None) + assert "prompt_influence" not in data + + +def test_v2m_sends_prompt_influence(): + data, _, _ = build_v2m_parts( + None, "https://x/v.mp4", None, None, prompt_influence=0.8, + ) + assert data["prompt_influence"] == "0.8" + + +def test_v2m_sends_zero_prompt_influence(): + """0.0 is a meaningful value ("let the video lead entirely"), so it must + go on the wire — this is the truthiness trap the builder guards with + `is not None`.""" + data, _, _ = build_v2m_parts( + None, "https://x/v.mp4", None, None, prompt_influence=0.0, + ) + assert data["prompt_influence"] == "0.0" + + +# --- video-to-music async (submit()/generate_async()) ------------------------ + +def test_v2m_async_forwards_prompt_influence_without_touching_mode(): + """prompt_influence is a generation parameter, valid on stream and async + alike, so it must not participate in the async-mode resolution the + finalize-time params go through.""" + data, _, _ = build_v2m_async_parts( + None, "https://x/v.mp4", None, None, None, None, + prompt_influence=0.3, + ) + assert data["prompt_influence"] == "0.3" + + +def test_v2m_async_omits_prompt_influence_when_none(): + data, _, _ = build_v2m_async_parts( + None, "https://x/v.mp4", None, None, None, None, + ) + assert "prompt_influence" not in data + + +# --- video-to-video-music ----------------------------------------------------- + +def test_v2v_music_omits_prompt_influence_when_none(): + data, _, _ = build_v2v_music_parts(None, "https://x/v.mp4", None, None, None) + assert "prompt_influence" not in data + + +def test_v2v_music_sends_prompt_influence(): + data, _, _ = build_v2v_music_parts( + None, "https://x/v.mp4", None, None, None, + prompt_influence=0.8, + ) + assert data["prompt_influence"] == "0.8" + + +def test_v2v_music_sends_zero_prompt_influence(): + data, _, _ = build_v2v_music_parts( + None, "https://x/v.mp4", None, None, None, + prompt_influence=0.0, + ) + assert data["prompt_influence"] == "0.0" + + +# --- the public signatures ---------------------------------------------------- + +def test_music_endpoints_expose_prompt_influence_everywhere(): + """On video-to-music it is valid on the streaming path too — the API takes + it with no mode guard — so stream() and generate() must expose it, not + just the async pair.""" + for cls in (VideoToMusic, AsyncVideoToMusic): + for method in ("stream", "generate", "submit", "generate_async"): + params = inspect.signature(getattr(cls, method)).parameters + assert "prompt_influence" in params, f"{cls.__name__}.{method}" + for cls in (VideoToVideoMusic, AsyncVideoToVideoMusic): + for method in ("submit", "generate"): + params = inspect.signature(getattr(cls, method)).parameters + assert "prompt_influence" in params, f"{cls.__name__}.{method}" + + +def test_other_endpoints_do_not_expose_prompt_influence(): + """The API accepts prompt_influence nowhere else — not text-to-music, not + the sfx endpoints, not the sound combos, not dubbing. Asserted on the + public signatures so adding it by reflex would fail here.""" + for cls in ( + TextToMusic, + AsyncTextToMusic, + VideoToSfx, + AsyncVideoToSfx, + VideoToSound, + AsyncVideoToSound, + VideoToVideoSfx, + AsyncVideoToVideoSfx, + VideoToVideoSound, + AsyncVideoToVideoSound, + Dubbing, + AsyncDubbing, + ): + for method in ("submit", "generate"): + params = inspect.signature(getattr(cls, method)).parameters + assert "prompt_influence" not in params, f"{cls.__name__}.{method}"