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
29 changes: 27 additions & 2 deletions sonilo-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ or pass `--api-key sk-...` on any command.
sonilo video-to-sfx --video clip.mp4 --segments @segments.json
sonilo video-to-sound --video clip.mp4 \
--music-prompt "uplifting orchestral score" --sfx-prompt "match the on-screen action"
sonilo video-to-video-music --video clip.mp4 --prompt "tense synths" --output scored.mp4
sonilo video-to-video-sfx --video clip.mp4 --segments @segments.json --output scored.mp4
sonilo video-to-video-sound --video clip.mp4 --music-prompt "tense synths"
sonilo dubbing --video-url https://example.com/clip.mp4 --languages es,fr --output dubbed.mp4
# writes dubbed.es.mp4 and dubbed.fr.mp4
Expand All @@ -34,7 +36,8 @@ or pass `--api-key sk-...` on any command.
### Notes

- `text-to-music` / `video-to-music` stream a short `.m4a` by default. `--format wav`,
`--isolate-vocals`, and `--preserve-speech` each switch to the async submit-and-poll path.
`--preserve-speech`, and its legacy alias `--isolate-vocals` each switch to the async
submit-and-poll path.
- `text-to-sfx` / `video-to-sfx` are always async; `--format` accepts `wav|mp3|aac|flac`.
- Output defaults to `./output.<ext>`; override with `--output`.

Expand All @@ -56,7 +59,7 @@ The two segment shapes are **not** interchangeable:
| Shape | Commands | Fields |
| --- | --- | --- |
| Music | `text-to-music`, `video-to-music` | `{start, prompt, label?}` |
| SFX | `video-to-sfx`, `video-to-sound`, `video-to-video-sound` | `{start, end, prompt}` |
| SFX | `video-to-sfx`, `video-to-video-sfx`, `video-to-sound`, `video-to-video-sound` | `{start, end, prompt}` |

- `start` / `end` are seconds from the start of the track or clip.
- Passing one shape to a command that takes the other is rejected before any request is made, with
Expand All @@ -67,6 +70,28 @@ The two segment shapes are **not** interchangeable:
- Keys the CLI does not recognise are forwarded as-is, so a newly added API field works without
upgrading the CLI.
- `text-to-sfx` takes no segments (its output is a single effect, not a timeline).
- `video-to-video-music` takes no segments either — the API scores the whole clip in one pass.

### Scored video

`video-to-video-music` and `video-to-video-sfx` are the video-out counterparts of `video-to-music`
and `video-to-sfx`: same generation, but what comes back is the source picture with the new audio
already muxed in, so there is nothing to line up afterwards. Both are async-only and write a single
file (default `output.mp4`):

sonilo video-to-video-music --video clip.mp4 --prompt "tense synths" --output scored.mp4
sonilo video-to-video-sfx --video clip.mp4 \
--segments '[{"start":0,"end":5,"prompt":"footsteps on gravel"}]' --output scored.mp4

- `--prompt` is optional on both; without it the model scores from the picture alone.
- `video-to-video-music` also takes `--preserve-speech`, which keeps source speech in the mix;
omitting it leaves the server default untouched. `--isolate-vocals` is a legacy alias for the
same flag — the API ORs the two together, and this endpoint returns one muxed video with no
separate vocals stem.
- `video-to-video-sfx` takes `--segments` in the SFX shape `{start, end, prompt}` — see
[Segments](#segments).
- Neither command exposes `--format`: the output is a video, not an audio file.
- For music *and* effects in one call, use `video-to-video-sound` below.

### Combined soundtracks

Expand Down
2 changes: 1 addition & 1 deletion sonilo-cli/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-cli"
version = "0.4.0"
version = "0.5.0"
description = "Command-line interface for the Sonilo API: generate music and sound effects from text or video"
readme = "README.md"
license = "MIT"
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.4.0"
__version__ = "0.5.0"

__all__ = ["__version__"]
69 changes: 67 additions & 2 deletions sonilo-cli/src/sonilo_cli/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,40 @@ def cmd_video_to_video_sound(client: Sonilo, args: argparse.Namespace) -> None:
_run_sound(client, args, client.video_to_video_sound, "mp4")


def _run_video(args: argparse.Namespace, resource: Any, **params: Any) -> None:
"""Run one of the video-returning endpoints and save the single result.

These return the source picture with the generated audio muxed in — one
file, no stems — so the default destination is an `.mp4`, matching
video-to-video-sound.
"""
out = args.output if args.output is not None else "output.mp4"
result = resource.generate(video=args.video, video_url=args.video_url, **params)
path = result.save(out)
_wrote(path, path.stat().st_size)


def cmd_video_to_video_music(client: Sonilo, args: argparse.Namespace) -> None:
_run_video(
args,
client.video_to_video_music,
prompt=args.prompt,
# Unset flags forward None, not False, so the server default stands —
# same reasoning as --no-ducking on the sound commands.
preserve_speech=True if args.preserve_speech else None,
isolate_vocals=True if args.isolate_vocals else None,
)


def cmd_video_to_video_sfx(client: Sonilo, args: argparse.Namespace) -> None:
_run_video(
args,
client.video_to_video_sfx,
prompt=args.prompt,
segments=_segments(args),
)


# Matched to the dubbing backend's own ceiling: it polls its pipeline for up
# to 7200s (2 hours), so anything shorter abandons a job the user has already
# been charged for. The SDK's generic DEFAULT_WAIT_TIMEOUT of 600s is far too
Expand Down Expand Up @@ -460,10 +494,14 @@ def build_parser() -> argparse.ArgumentParser:
p_v2m.add_argument("--output", default=None, help="Where to save the audio.")
p_v2m.add_argument("--format", choices=["m4a", "wav"], default="m4a",
help="Output container. wav forces async.")
p_v2m.add_argument("--isolate-vocals", dest="isolate_vocals", action="store_true",
help="Split out a vocals-only stem. Forces async.")
p_v2m.add_argument("--preserve-speech", dest="preserve_speech", action="store_true",
help="Keep source speech in the mix. Forces async.")
# The API ORs isolate_vocals into preserve_speech (video_to_music.py:
# `isolate_vocals = bool(preserve_speech) or bool(isolate_vocals)`), so
# the two flags are one feature under two names, not two behaviours.
# isolate_vocals is the legacy name kept for existing callers.
p_v2m.add_argument("--isolate-vocals", dest="isolate_vocals", action="store_true",
help="Legacy alias for --preserve-speech. Forces async.")
p_v2m.add_argument("--async", dest="use_async", action="store_true",
help="Submit and poll instead of streaming.")
p_v2m.set_defaults(func=cmd_video_to_music)
Expand Down Expand Up @@ -506,6 +544,33 @@ def build_parser() -> argparse.ArgumentParser:
p_v2sd.add_argument("--output", default=None, help="Where to save the combined audio.")
p_v2sd.set_defaults(func=cmd_video_to_sound)

p_v2vm = sub.add_parser(
"video-to-video-music", help="Generate music muxed into the source video"
)
_add_global(p_v2vm)
_add_video_source(p_v2vm)
p_v2vm.add_argument("--prompt", default=None, help="Optional creative direction.")
p_v2vm.add_argument("--preserve-speech", dest="preserve_speech", action="store_true",
help="Keep source speech in the mix.")
# Same aliasing as video-to-music, and here the endpoint collapses the two
# into a single boolean before it reaches the model (video_to_video.py:
# `keep_speech = bool(preserve_speech) or bool(isolate_vocals)`), with no
# vocals stem in the result — the output is one muxed video.
p_v2vm.add_argument("--isolate-vocals", dest="isolate_vocals", action="store_true",
help="Legacy alias for --preserve-speech; no separate stem.")
p_v2vm.add_argument("--output", default=None, help="Where to save the scored video.")
p_v2vm.set_defaults(func=cmd_video_to_video_music)

p_v2vfx = sub.add_parser(
"video-to-video-sfx", help="Generate sound effects muxed into the source video"
)
_add_global(p_v2vfx)
_add_video_source(p_v2vfx)
p_v2vfx.add_argument("--prompt", default=None, help="Optional creative direction.")
_add_segments(p_v2vfx, SFX_SHAPE)
p_v2vfx.add_argument("--output", default=None, help="Where to save the scored video.")
p_v2vfx.set_defaults(func=cmd_video_to_video_sfx)

p_v2vsd = sub.add_parser(
"video-to-video-sound", help="Generate matched music+sfx muxed into the source video"
)
Expand Down
190 changes: 186 additions & 4 deletions sonilo-cli/tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -764,7 +764,7 @@ def test_segments_semantic_rules_are_left_to_the_server():

@pytest.mark.parametrize(
"command",
["text-to-music", "video-to-music", "video-to-sfx",
["text-to-music", "video-to-music", "video-to-sfx", "video-to-video-sfx",
"video-to-sound", "video-to-video-sound"],
)
def test_segments_help_shows_all_three_value_forms(command, capsys):
Expand All @@ -778,10 +778,192 @@ def test_segments_help_shows_all_three_value_forms(command, capsys):

@pytest.mark.parametrize("command", ["text-to-sfx"])
def test_commands_without_segments_reject_the_flag(command, capsys):
"""text-to-sfx takes no segments in the SDK, so the CLI must not offer it
(video-to-video-music, the other segment-less endpoint, has no CLI
command at all)."""
"""text-to-sfx takes no segments in the SDK, so the CLI must not offer it.
The other segment-less endpoint, video-to-video-music, is covered by
test_video_to_video_music_rejects_segments (it takes no --duration)."""
with pytest.raises(SystemExit) as exc:
run([command, "--prompt", "x", "--duration", "3", "--segments", "[]"])
assert exc.value.code == 1
assert "unrecognized arguments" in capsys.readouterr().err


# --- video-to-video-music / video-to-video-sfx ---------------------------
#
# Both return the source picture with the generated audio muxed in, so the
# task body carries a single `video` object rather than `audio`/`output_url`
# (shape confirmed against tests/test_video_to_video.py in the SDK repo).


def _video_body(task_id, task_type):
return {
"task_id": task_id,
"type": task_type,
"status": "succeeded",
"video": {"url": f"https://r2.example.com/{task_id}.mp4",
"content_type": "video/mp4", "file_size": 7},
"duration_seconds": 4.0,
}


def _mock_video_task(endpoint, task_id, task_type, content=b"MP4DATA"):
"""Wire up submit + poll + download for one video-returning endpoint."""
route = respx.post(f"{BASE}/v1/{endpoint}").mock(
return_value=httpx.Response(202, json={"task_id": task_id, "status": "processing"})
)
respx.get(f"{BASE}/v1/tasks/{task_id}").mock(
return_value=httpx.Response(200, json=_video_body(task_id, task_type))
)
respx.get(f"https://r2.example.com/{task_id}.mp4").mock(
return_value=httpx.Response(200, content=content)
)
return route


@respx.mock
def test_video_to_video_music_saves_the_scored_video(tmp_path):
route = _mock_video_task("video-to-video-music", "vm1", "video_to_video_music")
out = tmp_path / "scored.mp4"
run(["video-to-video-music", "--video-url", "http://x/y.mp4",
"--prompt", "tense synths", "--output", str(out)])
assert route.called
assert out.read_bytes() == b"MP4DATA"
body = unquote_plus(route.calls.last.request.content.decode())
assert "video_url=http://x/y.mp4" in body
assert "prompt=tense synths" in body


@respx.mock
def test_video_to_video_music_defaults_to_mp4(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
_mock_video_task("video-to-video-music", "vm2", "video_to_video_music")
run(["video-to-video-music", "--video-url", "http://x/y.mp4"])
assert (tmp_path / "output.mp4").read_bytes() == b"MP4DATA"


@respx.mock
def test_video_to_video_music_flags_reach_the_request_body(tmp_path):
route = _mock_video_task("video-to-video-music", "vm3", "video_to_video_music")
run(["video-to-video-music", "--video-url", "http://x/y.mp4",
"--preserve-speech", "--isolate-vocals", "--output", str(tmp_path / "s.mp4")])
body = route.calls.last.request.content.decode()
assert "preserve_speech=true" in body
assert "isolate_vocals=true" in body


@respx.mock
def test_video_to_video_music_unset_flags_are_omitted(tmp_path):
route = _mock_video_task("video-to-video-music", "vm4", "video_to_video_music")
run(["video-to-video-music", "--video-url", "http://x/y.mp4",
"--output", str(tmp_path / "s.mp4")])
# Unset switches must forward None, not False, so the server default
# stands — same rule as --no-ducking on the sound commands.
body = route.calls.last.request.content.decode()
assert "preserve_speech=" not in body
assert "isolate_vocals=" not in body
assert "prompt=" not in body


def test_video_to_video_music_requires_a_video_source():
with pytest.raises(SystemExit) as exc:
run(["video-to-video-music", "--prompt", "x"])
assert exc.value.code == 1


def test_video_to_video_music_rejects_both_sources():
with pytest.raises(SystemExit) as exc:
run(["video-to-video-music", "--video", "a.mp4", "--video-url", "http://x/y.mp4"])
assert exc.value.code == 1


def test_video_to_video_music_rejects_segments(capsys):
"""The endpoint scores the whole clip in one pass — the SDK resource takes
no `segments`, so the CLI must not offer the flag."""
with pytest.raises(SystemExit) as exc:
run(["video-to-video-music", "--video-url", "http://x/y.mp4", "--segments", "[]"])
assert exc.value.code == 1
assert "unrecognized arguments" in capsys.readouterr().err


@respx.mock
def test_video_to_video_sfx_saves_the_scored_video(tmp_path):
route = _mock_video_task("video-to-video-sfx", "vf1", "video_to_video_sfx")
out = tmp_path / "scored.mp4"
run(["video-to-video-sfx", "--video-url", "http://x/y.mp4",
"--prompt", "footsteps", "--output", str(out)])
assert route.called
assert out.read_bytes() == b"MP4DATA"
body = unquote_plus(route.calls.last.request.content.decode())
assert "video_url=http://x/y.mp4" in body
assert "prompt=footsteps" in body


@respx.mock
def test_video_to_video_sfx_defaults_to_mp4(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
_mock_video_task("video-to-video-sfx", "vf2", "video_to_video_sfx")
run(["video-to-video-sfx", "--video-url", "http://x/y.mp4"])
assert (tmp_path / "output.mp4").read_bytes() == b"MP4DATA"


@respx.mock
def test_video_to_video_sfx_segments_reach_the_request_body(tmp_path):
route = _mock_video_task("video-to-video-sfx", "vf3", "video_to_video_sfx")
run(["video-to-video-sfx", "--video-url", "http://x/y.mp4",
"--segments", json.dumps(SFX_SEGMENTS), "--output", str(tmp_path / "s.mp4")])
assert _sent_segments(route) == SFX_SEGMENTS


@respx.mock
def test_video_to_video_sfx_omitting_segments_sends_no_field(tmp_path):
route = _mock_video_task("video-to-video-sfx", "vf4", "video_to_video_sfx")
run(["video-to-video-sfx", "--video-url", "http://x/y.mp4",
"--output", str(tmp_path / "s.mp4")])
assert b"segments" not in route.calls.last.request.content


def test_video_to_video_sfx_rejects_music_shaped_segments(capsys):
with pytest.raises(SystemExit) as exc:
run(["video-to-video-sfx", "--video-url", "http://x/y.mp4",
"--segments", json.dumps(MUSIC_SEGMENTS)])
assert exc.value.code == 1
err = capsys.readouterr().err
assert "video-to-video-sfx segments take {start, end, prompt}" in err
assert "got an object with keys start, label, prompt" in err


def test_video_to_video_sfx_requires_a_video_source():
with pytest.raises(SystemExit) as exc:
run(["video-to-video-sfx", "--prompt", "x"])
assert exc.value.code == 1


def test_video_to_video_sfx_rejects_both_sources():
with pytest.raises(SystemExit) as exc:
run(["video-to-video-sfx", "--video", "a.mp4", "--video-url", "http://x/y.mp4"])
assert exc.value.code == 1


@pytest.mark.parametrize("command", ["video-to-music", "video-to-video-music"])
def test_isolate_vocals_is_documented_as_an_alias(command, capsys):
"""Both endpoints OR the two fields into one behaviour server-side
(video_to_music.py: `isolate_vocals = bool(preserve_speech) or
bool(isolate_vocals)`; video_to_video.py: `keep_speech = bool(...) or
bool(...)`), so the help must not present --isolate-vocals as a separate
feature. On video-to-video-music there is no stem at all — the result is
a single muxed video."""
with pytest.raises(SystemExit):
main([command, "--help"])
help_text = capsys.readouterr().out
assert "Legacy alias for --preserve-speech" in help_text
assert "vocals-only stem" not in help_text


@pytest.mark.parametrize(
"command", ["video-to-video-music", "video-to-video-sfx"]
)
def test_video_to_video_commands_are_listed_in_top_level_help(command, capsys):
"""Both are documented publicly as `sonilo <command>`, so they have to be
discoverable from `sonilo --help`, not just by knowing the name."""
with pytest.raises(SystemExit):
main(["--help"])
assert command in capsys.readouterr().out
Loading