Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "sonilo"
version = "0.11.3"
version = "0.12.0"
description = "Official Python client for the Sonilo API"
readme = "README.md"
license = "MIT"
Expand Down
13 changes: 13 additions & 0 deletions sonilo-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand All @@ -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

Expand Down
4 changes: 2 additions & 2 deletions sonilo-cli/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@ build-backend = "hatchling.build"

[project]
name = "sonilo-cli"
version = "0.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]
Expand Down
2 changes: 1 addition & 1 deletion sonilo-cli/src/sonilo_cli/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
__version__ = "0.9.0"
__version__ = "0.10.0"

__all__ = ["__version__"]
21 changes: 20 additions & 1 deletion sonilo-cli/src/sonilo_cli/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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,
)


Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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(
Expand Down
61 changes: 61 additions & 0 deletions sonilo-cli/tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
28 changes: 25 additions & 3 deletions src/sonilo/_requests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand All @@ -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
Expand Down Expand Up @@ -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"
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/sonilo/_version.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "0.11.3"
__version__ = "0.12.0"
Loading
Loading