diff --git a/sonilo-cli/README.md b/sonilo-cli/README.md index 868d160..9bf1bfc 100644 --- a/sonilo-cli/README.md +++ b/sonilo-cli/README.md @@ -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 @@ -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.`; override with `--output`. @@ -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 @@ -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 diff --git a/sonilo-cli/pyproject.toml b/sonilo-cli/pyproject.toml index f3bd70e..d24d369 100644 --- a/sonilo-cli/pyproject.toml +++ b/sonilo-cli/pyproject.toml @@ -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" diff --git a/sonilo-cli/src/sonilo_cli/__init__.py b/sonilo-cli/src/sonilo_cli/__init__.py index b786da2..696d169 100644 --- a/sonilo-cli/src/sonilo_cli/__init__.py +++ b/sonilo-cli/src/sonilo_cli/__init__.py @@ -1,3 +1,3 @@ -__version__ = "0.4.0" +__version__ = "0.5.0" __all__ = ["__version__"] diff --git a/sonilo-cli/src/sonilo_cli/__main__.py b/sonilo-cli/src/sonilo_cli/__main__.py index 0443b85..802bfd0 100644 --- a/sonilo-cli/src/sonilo_cli/__main__.py +++ b/sonilo-cli/src/sonilo_cli/__main__.py @@ -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 @@ -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) @@ -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" ) diff --git a/sonilo-cli/tests/test_cli.py b/sonilo-cli/tests/test_cli.py index 5d0de97..985a0e1 100644 --- a/sonilo-cli/tests/test_cli.py +++ b/sonilo-cli/tests/test_cli.py @@ -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): @@ -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 `, 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