diff --git a/README.md b/README.md index f64d5f3..9729e7b 100644 --- a/README.md +++ b/README.md @@ -118,6 +118,34 @@ if result.ducked: result.save("ducked.wav", which="ducked") ``` +### Variants (async) + +`variants_num` (1-10, default `1`) generates that many distinct music +variants in one request instead of one — each is its own creative direction +with its own title. It's an async-only option, same as `output_format`: +`submit()` / `generate_async()` accept it on both `text_to_music` and +`video_to_music`, auto-selecting async when it's above 1 (an explicit +non-async `mode` alongside `variants_num > 1` raises `SoniloError` locally, +same as the other async-only options above). Cost scales linearly — +`variants_num=3` costs three times a single-variant request — and values +above 1 are never covered by the free trial. + +```python +result = client.text_to_music.generate_async( + prompt="cinematic orchestral score", + duration=30, + variants_num=3, +) +for i in range(len(result.audio)): + result.save(f"variant_{i}.m4a", index=i) + title = result.audio[i].title + print(i, title.title if title else None) +``` + +`result.audio` always has one entry per variant; with `variants_num=1` (the +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`. + ## Video to video Generate music or sound effects and get back a **re-hosted video** with the @@ -139,6 +167,23 @@ sfx = client.video_to_video_sfx.generate( sfx.save("with_sfx.mp4") ``` +`video_to_video_music` 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. + +```python +music = client.video_to_video_music.generate( + video="my_video.mp4", prompt="cinematic orchestral swell", variants_num=3, +) +for i in range(len(music.videos)): + music.save(f"scored_{i}.mp4", index=i) +``` + +`music.videos` always has one entry per variant; `music.video` stays a +permanent alias for `music.videos[0]`, so `music.save("scored.mp4")` (no +`index`) keeps working exactly as it did before `variants_num` existed. + ## Video to sound `video_to_sound` and `video_to_video_sound` generate a music bed and sound @@ -174,6 +219,28 @@ result.save_stem("sfx.wav", which="sfx") `segments` takes the same `{"start", "end", "prompt"}` list as `video_to_sfx`. Input videos may be at most 180 seconds long. +Both also take `variants_num` (1-10, default `1`): each variant pairs its own +generated music with its own generated sound effects. Like +`video_to_video_music`, these endpoints are already async-only, so +`variants_num` needs no `mode` to auto-select. + +```python +result = client.video_to_sound.generate( + video_url="https://example.com/clip.mp4", + music_prompt="uplifting orchestral score", + variants_num=3, +) +for i in range(len(result.outputs)): + result.save(f"soundtrack_{i}.wav", index=i) + result.save_stem(f"music_{i}.m4a", which="music", index=i) +``` + +`result.outputs` always has one entry per variant, sorted by `variant_index`; +`output_url`/`output_type`/`output_bytes`/`music`/`music_processed`/`sfx` +stay aliases for `outputs[0]`'s corresponding fields, so `result.save(...)` +and `result.save_stem(...)` (no `index`) keep working exactly as they did +before `variants_num` existed. + Use `submit()` instead of `generate()` to get a `task_id` back immediately and poll it yourself with `client.tasks.wait(task_id, parser=parse_sound_result)`. `AsyncSonilo` exposes the same two resources with `await`-able diff --git a/pyproject.toml b/pyproject.toml index 3190109..4ae45ba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "sonilo" -version = "0.7.0" +version = "0.8.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 9bf1bfc..5ca9355 100644 --- a/sonilo-cli/README.md +++ b/sonilo-cli/README.md @@ -36,8 +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`, - `--preserve-speech`, and its legacy alias `--isolate-vocals` each switch to the async - submit-and-poll path. + `--preserve-speech`, `--variants` above 1, and the 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`. @@ -72,6 +72,24 @@ The two segment shapes are **not** interchangeable: - `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. +### Variants + +`--variants N` (1-10, default 1) generates that many distinct variants in one request instead of +one, on `text-to-music`, `video-to-music`, `video-to-video-music`, `video-to-sound`, and +`video-to-video-sound`. Cost scales linearly — `--variants 3` costs three times a single-variant +request — and values above 1 are never covered by the free trial. + + sonilo text-to-music --prompt "warm lo-fi piano" --duration 30 --variants 3 --output take.m4a + # writes take.0.m4a, take.1.m4a, take.2.m4a + +- `--variants` above 1 forces the async submit-and-poll path (see [Notes](#notes) above). +- With `--variants` unset (or `1`), a command writes the single `--output` file exactly as before + this flag existed. Above 1, it instead writes one file per variant, with the variant index + spliced before the extension: `take.m4a` becomes `take.0.m4a`, `take.1.m4a`, etc. — the same + naming `--stem` and dubbing's per-language output already use. +- On `video-to-sound` / `video-to-video-sound`, `--stem` is applied per variant too, e.g. + `take.0.music.m4a`. + ### Scored video `video-to-video-music` and `video-to-video-sfx` are the video-out counterparts of `video-to-music` @@ -92,6 +110,8 @@ file (default `output.mp4`): [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. +- `video-to-video-music` also takes `--variants` — see [Variants](#variants) above. + `video-to-video-sfx` does not. ### Combined soundtracks @@ -116,6 +136,7 @@ they differ only in what comes back: `video-to-sound` writes the mixed **audio** the combined output, so you can re-balance the mix yourself. With `--output soundtrack.wav`, the music stem lands at `soundtrack.music.m4a`. `music_processed` exists only when `--preserve-speech` or ducking altered the music bed. +- Both also take `--variants` — see [Variants](#variants) above. ### Dubbing diff --git a/sonilo-cli/pyproject.toml b/sonilo-cli/pyproject.toml index d24d369..bee5448 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.5.0" +version = "0.6.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.7.0,<0.8"] +dependencies = ["sonilo>=0.8.0,<0.9"] 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 696d169..6131df7 100644 --- a/sonilo-cli/src/sonilo_cli/__init__.py +++ b/sonilo-cli/src/sonilo_cli/__init__.py @@ -1,3 +1,3 @@ -__version__ = "0.5.0" +__version__ = "0.6.0" __all__ = ["__version__"] diff --git a/sonilo-cli/src/sonilo_cli/__main__.py b/sonilo-cli/src/sonilo_cli/__main__.py index 802bfd0..5f8a151 100644 --- a/sonilo-cli/src/sonilo_cli/__main__.py +++ b/sonilo-cli/src/sonilo_cli/__main__.py @@ -221,9 +221,33 @@ def _music_output(args: argparse.Namespace, fmt: str) -> str: return args.output if args.output is not None else f"output.{fmt}" +def _variant_path(out: str, index: int) -> str: + """Turn one --output value into a per-variant path: `clip.mp4` + `1` + becomes `clip.1.mp4`. Same transform as _stem_path/_language_path, used + whenever --variants > 1 fans a single --output into one file per variant.""" + base = Path(out) + return str(base.with_name(f"{base.stem}.{index}{base.suffix}")) + + +def _save_music_variants(result: Any, out: str) -> None: + """Save every entry of an async music result's `audio` list. With + --variants unset (or 1) this is a single file at `out`, byte-identical + to the pre-variants behaviour; with --variants > 1 it fans out to + `out.0.ext`, `out.1.ext`, etc.""" + count = len(result.audio or []) + if count <= 1: + path = result.save(out) + _wrote(path, path.stat().st_size) + return + for index in range(count): + path = result.save(_variant_path(out, index), index=index) + _wrote(path, path.stat().st_size) + + def cmd_text_to_music(client: Sonilo, args: argparse.Namespace) -> None: fmt = args.format - use_async = args.use_async or fmt == "wav" + multi = args.variants is not None and args.variants > 1 + use_async = args.use_async or fmt == "wav" or multi out = _music_output(args, fmt) segments = _segments(args) if use_async: @@ -232,9 +256,9 @@ def cmd_text_to_music(client: Sonilo, args: argparse.Namespace) -> None: duration=args.duration, segments=segments, output_format="wav" if fmt == "wav" else None, + variants_num=args.variants, ) - path = result.save(out) - _wrote(path, path.stat().st_size) + _save_music_variants(result, out) else: track = client.text_to_music.generate( prompt=args.prompt, duration=args.duration, segments=segments @@ -245,7 +269,10 @@ def cmd_text_to_music(client: Sonilo, args: argparse.Namespace) -> None: def cmd_video_to_music(client: Sonilo, args: argparse.Namespace) -> None: fmt = args.format - use_async = args.use_async or fmt == "wav" or args.isolate_vocals or args.preserve_speech + multi = args.variants is not None and args.variants > 1 + use_async = ( + args.use_async or fmt == "wav" or args.isolate_vocals or args.preserve_speech or multi + ) out = _music_output(args, fmt) segments = _segments(args) if use_async: @@ -257,9 +284,9 @@ def cmd_video_to_music(client: Sonilo, args: argparse.Namespace) -> None: isolate_vocals=args.isolate_vocals or None, preserve_speech=args.preserve_speech or None, output_format="wav" if fmt == "wav" else None, + variants_num=args.variants, ) - path = result.save(out) - _wrote(path, path.stat().st_size) + _save_music_variants(result, out) else: track = client.video_to_music.generate( video=args.video, video_url=args.video_url, prompt=args.prompt, @@ -312,13 +339,25 @@ def _run_sound(client: Sonilo, args: argparse.Namespace, resource: Any, default_ segments=_segments(args), preserve_speech=True if args.preserve_speech else None, ducking=False if args.no_ducking else None, + variants_num=args.variants, ) - path = result.save(out) - _wrote(path, path.stat().st_size) - for stem in args.stems or []: - stem_path = _stem_path(out, stem, getattr(result, stem, None)) - saved = result.save_stem(stem_path, which=stem) - _wrote(saved, saved.stat().st_size) + multi = args.variants is not None and args.variants > 1 and len(result.outputs) > 1 + if not multi: + path = result.save(out) + _wrote(path, path.stat().st_size) + for stem in args.stems or []: + stem_path = _stem_path(out, stem, getattr(result, stem, None)) + saved = result.save_stem(stem_path, which=stem) + _wrote(saved, saved.stat().st_size) + return + for index, entry in enumerate(result.outputs): + variant_out = _variant_path(out, index) + path = result.save(variant_out, index=index) + _wrote(path, path.stat().st_size) + for stem in args.stems or []: + stem_path = _stem_path(variant_out, stem, getattr(entry, stem, None)) + saved = result.save_stem(stem_path, which=stem, index=index) + _wrote(saved, saved.stat().st_size) def cmd_video_to_sound(client: Sonilo, args: argparse.Namespace) -> None: @@ -330,14 +369,22 @@ def cmd_video_to_video_sound(client: Sonilo, args: argparse.Namespace) -> None: def _run_video(args: argparse.Namespace, resource: Any, **params: Any) -> None: - """Run one of the video-returning endpoints and save the single result. + """Run one of the video-returning endpoints and save the 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. + These return the source picture with the generated audio muxed in — no + stems — so the default destination is an `.mp4`, matching + video-to-video-sound. A `variants_num` in `params` fans out into one + indexed file per variant when it is greater than 1; otherwise this stays + the single-file save from before variants existed. """ out = args.output if args.output is not None else "output.mp4" result = resource.generate(video=args.video, video_url=args.video_url, **params) + variants = params.get("variants_num") + if variants is not None and variants > 1 and len(result.videos) > 1: + for index in range(len(result.videos)): + path = result.save(_variant_path(out, index), index=index) + _wrote(path, path.stat().st_size) + return path = result.save(out) _wrote(path, path.stat().st_size) @@ -351,6 +398,7 @@ def cmd_video_to_video_music(client: Sonilo, args: argparse.Namespace) -> None: # 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, + variants_num=args.variants, ) @@ -459,6 +507,17 @@ def _add_segments(parser: argparse.ArgumentParser, shape: _SegmentShape) -> None parser.set_defaults(segments_shape=shape) +def _add_variants(parser: argparse.ArgumentParser) -> None: + parser.add_argument( + "--variants", type=int, default=None, + help="How many distinct variants to generate in one request, 1-10 " + "(default 1). Cost scales linearly, and values above 1 are never " + "covered by the free trial. Values above 1 force async and write " + "one indexed file per variant (output.0.ext, output.1.ext, ...) " + "instead of a single --output.", + ) + + def build_parser() -> argparse.ArgumentParser: parser = _Parser(prog="sonilo", description="Command-line interface for the Sonilo API") parser.add_argument("--version", action="version", version=__version__) @@ -484,6 +543,7 @@ def build_parser() -> argparse.ArgumentParser: help="Output container. wav forces async. Default: m4a") p_t2m.add_argument("--async", dest="use_async", action="store_true", help="Submit and poll instead of streaming.") + _add_variants(p_t2m) p_t2m.set_defaults(func=cmd_text_to_music) p_v2m = sub.add_parser("video-to-music", help="Generate music matched to a video") @@ -504,6 +564,7 @@ def build_parser() -> argparse.ArgumentParser: 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.") + _add_variants(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") @@ -542,6 +603,7 @@ def build_parser() -> argparse.ArgumentParser: p_v2sd.add_argument("--stem", dest="stems", action="append", choices=_SOUND_STEMS, default=None, help="Also save an individual stem. Repeatable.") p_v2sd.add_argument("--output", default=None, help="Where to save the combined audio.") + _add_variants(p_v2sd) p_v2sd.set_defaults(func=cmd_video_to_sound) p_v2vm = sub.add_parser( @@ -559,6 +621,7 @@ def build_parser() -> argparse.ArgumentParser: 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.") + _add_variants(p_v2vm) p_v2vm.set_defaults(func=cmd_video_to_video_music) p_v2vfx = sub.add_parser( @@ -588,6 +651,7 @@ def build_parser() -> argparse.ArgumentParser: p_v2vsd.add_argument("--stem", dest="stems", action="append", choices=_SOUND_STEMS, default=None, help="Also save an individual stem. Repeatable.") p_v2vsd.add_argument("--output", default=None, help="Where to save the combined video.") + _add_variants(p_v2vsd) p_v2vsd.set_defaults(func=cmd_video_to_video_sound) p_dub = sub.add_parser("dubbing", help="Dub a video into other languages") diff --git a/sonilo-cli/tests/test_cli.py b/sonilo-cli/tests/test_cli.py index 985a0e1..942fe29 100644 --- a/sonilo-cli/tests/test_cli.py +++ b/sonilo-cli/tests/test_cli.py @@ -161,6 +161,39 @@ def test_text_to_music_wav_forces_async(tmp_path): assert out.read_bytes() == b"RIF" +@respx.mock +def test_text_to_music_variants_forces_async_and_writes_indexed_files(tmp_path): + submit = respx.post(f"{BASE}/v1/text-to-music").mock( + return_value=httpx.Response(200, json={"task_id": "tv1", "status": "processing"}) + ) + respx.get(f"{BASE}/v1/tasks/tv1").mock( + return_value=httpx.Response(200, json={ + "task_id": "tv1", "type": "text_to_music", "status": "succeeded", + "variants_num": 2, + "audio": [ + {"stream_index": 0, "url": "https://r2.example.com/tv1.0.m4a"}, + {"stream_index": 1, "url": "https://r2.example.com/tv1.1.m4a"}, + ], + }) + ) + respx.get("https://r2.example.com/tv1.0.m4a").mock( + return_value=httpx.Response(200, content=b"A0") + ) + respx.get("https://r2.example.com/tv1.1.m4a").mock( + return_value=httpx.Response(200, content=b"A1") + ) + out = tmp_path / "take.m4a" + run(["text-to-music", "--prompt", "lofi", "--duration", "10", + "--variants", "2", "--output", str(out)]) + # --variants > 1 must force the async submit-and-poll path, same as --format wav. + assert submit.called + body = submit.calls.last.request.content.decode() + assert "variants_num=2" in body + assert (tmp_path / "take.0.m4a").read_bytes() == b"A0" + assert (tmp_path / "take.1.m4a").read_bytes() == b"A1" + assert not out.exists() + + def test_video_to_music_requires_a_video_source(): with pytest.raises(SystemExit) as exc: run(["video-to-music", "--prompt", "x"]) # neither --video nor --video-url @@ -394,6 +427,54 @@ def test_video_to_sound_preserve_speech_flag_sets_true(tmp_path): assert "preserve_speech=true" in body +@respx.mock +def test_video_to_sound_variants_writes_indexed_files_and_stems(tmp_path): + respx.post(f"{BASE}/v1/video-to-sound").mock( + return_value=httpx.Response(200, json={"task_id": "sv1", "status": "processing"}) + ) + respx.get(f"{BASE}/v1/tasks/sv1").mock( + return_value=httpx.Response(200, json={ + "task_id": "sv1", "type": "video_to_sound", "status": "succeeded", + "variants_num": 2, + "output_url": "https://r2.example.com/sv1.0.wav", + "output_type": "audio", "output_bytes": 5, + "music": {"url": "https://r2.example.com/sv1.0.music.m4a"}, + "sfx": {"url": "https://r2.example.com/sv1.0.sfx.wav"}, + "outputs": [ + { + "variant_index": 0, + "output_url": "https://r2.example.com/sv1.0.wav", + "output_type": "audio", "output_bytes": 5, + "music": {"url": "https://r2.example.com/sv1.0.music.m4a"}, + "sfx": {"url": "https://r2.example.com/sv1.0.sfx.wav"}, + }, + { + "variant_index": 1, + "output_url": "https://r2.example.com/sv1.1.wav", + "output_type": "audio", "output_bytes": 6, + "music": {"url": "https://r2.example.com/sv1.1.music.m4a"}, + "sfx": {"url": "https://r2.example.com/sv1.1.sfx.wav"}, + }, + ], + }) + ) + for url, content in [ + ("https://r2.example.com/sv1.0.wav", b"O0"), + ("https://r2.example.com/sv1.1.wav", b"O1"), + ("https://r2.example.com/sv1.0.music.m4a", b"M0"), + ("https://r2.example.com/sv1.1.music.m4a", b"M1"), + ]: + respx.get(url).mock(return_value=httpx.Response(200, content=content)) + out = tmp_path / "s.wav" + run(["video-to-sound", "--video-url", "http://x/y.mp4", "--variants", "2", + "--output", str(out), "--stem", "music"]) + assert (tmp_path / "s.0.wav").read_bytes() == b"O0" + assert (tmp_path / "s.1.wav").read_bytes() == b"O1" + assert (tmp_path / "s.0.music.m4a").read_bytes() == b"M0" + assert (tmp_path / "s.1.music.m4a").read_bytes() == b"M1" + assert not out.exists() + + @respx.mock def test_video_to_video_sound_defaults_to_mp4(tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) @@ -875,6 +956,38 @@ def test_video_to_video_music_rejects_both_sources(): assert exc.value.code == 1 +@respx.mock +def test_video_to_video_music_variants_writes_indexed_files(tmp_path): + route = respx.post(f"{BASE}/v1/video-to-video-music").mock( + return_value=httpx.Response(202, json={"task_id": "vm5", "status": "processing"}) + ) + respx.get(f"{BASE}/v1/tasks/vm5").mock( + return_value=httpx.Response(200, json={ + "task_id": "vm5", "type": "video_to_video_music", "status": "succeeded", + "variants_num": 2, + "videos": [ + {"url": "https://r2.example.com/vm5.0.mp4"}, + {"url": "https://r2.example.com/vm5.1.mp4"}, + ], + "video": {"url": "https://r2.example.com/vm5.0.mp4"}, + }) + ) + respx.get("https://r2.example.com/vm5.0.mp4").mock( + return_value=httpx.Response(200, content=b"V0") + ) + respx.get("https://r2.example.com/vm5.1.mp4").mock( + return_value=httpx.Response(200, content=b"V1") + ) + out = tmp_path / "scored.mp4" + run(["video-to-video-music", "--video-url", "http://x/y.mp4", + "--variants", "2", "--output", str(out)]) + assert (tmp_path / "scored.0.mp4").read_bytes() == b"V0" + assert (tmp_path / "scored.1.mp4").read_bytes() == b"V1" + assert not out.exists() + body = route.calls.last.request.content.decode() + assert "variants_num=2" in body + + 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.""" diff --git a/src/sonilo/_requests.py b/src/sonilo/_requests.py index fa7542b..525d668 100644 --- a/src/sonilo/_requests.py +++ b/src/sonilo/_requests.py @@ -25,6 +25,7 @@ def build_t2m_async_data( segments: Optional[List[Segment]], mode: Optional[str], output_format: Optional[str], + variants_num: Optional[int] = None, ) -> Dict[str, str]: data = build_t2m_data(prompt, duration, segments) resolved = mode or "async" @@ -33,6 +34,8 @@ def build_t2m_async_data( data["mode"] = resolved if output_format is not None: data["output_format"] = output_format + if variants_num is not None: + data["variants_num"] = str(variants_num) return data @@ -132,23 +135,25 @@ def _resolve_music_mode( preserve_speech: Optional[bool] = None, output_format: Optional[str] = None, ducking: Optional[bool] = None, + variants_num: Optional[int] = None, ) -> str: - """isolate_vocals/preserve_speech/ducking/output_format='wav' only work - with async processing: auto-select mode "async" when the caller didn't - specify one, but fail fast if they explicitly asked for anything else. - submit() also needs an async response (a task_id ack, not a stream), so - "async" is the default regardless. + """isolate_vocals/preserve_speech/ducking/output_format='wav'/ + variants_num>1 only work with async processing: auto-select mode "async" + when the caller didn't specify one, but fail fast if they explicitly + asked for anything else. submit() also needs an async response (a + task_id ack, not a stream), so "async" is the default regardless. """ needs_async = ( bool(isolate_vocals) or bool(preserve_speech) or output_format == "wav" or ducking is not None + or (variants_num is not None and variants_num > 1) ) if needs_async and mode is not None and mode != "async": raise SoniloError( - "isolate_vocals/preserve_speech/ducking/output_format='wav' " - "require mode='async'" + "isolate_vocals/preserve_speech/ducking/output_format='wav'/" + "variants_num>1 require mode='async'" ) return "async" if needs_async else (mode or "async") @@ -163,11 +168,12 @@ def build_v2m_async_parts( preserve_speech: Optional[bool] = None, output_format: Optional[str] = None, ducking: Optional[bool] = None, + variants_num: Optional[int] = 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.""" resolved_mode = _resolve_music_mode( - mode, isolate_vocals, preserve_speech, output_format, ducking + mode, isolate_vocals, preserve_speech, output_format, ducking, variants_num ) data, files, opened = build_v2m_parts(video, video_url, prompt, segments) data["mode"] = resolved_mode @@ -179,6 +185,8 @@ def build_v2m_async_parts( data["output_format"] = output_format if ducking is not None: data["ducking"] = "true" if ducking else "false" + if variants_num is not None: + data["variants_num"] = str(variants_num) return data, files, opened @@ -188,12 +196,18 @@ def build_v2v_music_parts( prompt: Optional[str], preserve_speech: Optional[bool], isolate_vocals: Optional[bool], + variants_num: Optional[int] = 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, None) if preserve_speech is not None: data["preserve_speech"] = "true" if preserve_speech else "false" if isolate_vocals is not None: data["isolate_vocals"] = "true" if isolate_vocals else "false" + if variants_num is not None: + data["variants_num"] = str(variants_num) return data, files, opened @@ -215,6 +229,7 @@ def build_v2s_parts( segments: Optional[List[SfxSegment]], preserve_speech: Optional[bool], ducking: Optional[bool], + variants_num: Optional[int] = None, ) -> Tuple[Dict[str, str], Optional[Dict[str, tuple]], bool]: """Multipart parts shared by /v1/video-to-sound and /v1/video-to-video-sound — their form fields are identical. @@ -222,7 +237,9 @@ def build_v2s_parts( These endpoints take `music_prompt`/`sfx_prompt` instead of a single `prompt`, so build_v2m_parts is called with prompt=None. Booleans are only emitted when explicitly passed: `ducking` is default-ON server-side, so an - unset value must not go out as "false". + unset value must not go out as "false". Both endpoints are async-only + (202 + poll), so — like video-to-video-music — variants_num travels + straight through with no mode guard. """ data, files, opened = build_v2m_parts(video, video_url, None, segments) if music_prompt is not None: @@ -233,6 +250,8 @@ def build_v2s_parts( data["preserve_speech"] = "true" if preserve_speech else "false" if ducking is not None: data["ducking"] = "true" if ducking else "false" + if variants_num is not None: + data["variants_num"] = str(variants_num) return data, files, opened diff --git a/src/sonilo/_version.py b/src/sonilo/_version.py index 49e0fc1..777f190 100644 --- a/src/sonilo/_version.py +++ b/src/sonilo/_version.py @@ -1 +1 @@ -__version__ = "0.7.0" +__version__ = "0.8.0" diff --git a/src/sonilo/resources/tasks.py b/src/sonilo/resources/tasks.py index 7762d91..ec4f574 100644 --- a/src/sonilo/resources/tasks.py +++ b/src/sonilo/resources/tasks.py @@ -14,6 +14,7 @@ SfxMedia, SfxResult, SfxTask, + SoundOutput, SoundResult, VideoResult, ) @@ -71,6 +72,16 @@ def parse_sfx_result(body: Dict[str, Any]) -> SfxResult: raise SoniloError(f"Malformed task response: missing {e.args[0]!r}") from e +def _music_title_from(data: Any) -> Optional[MusicTitle]: + if not isinstance(data, dict): + return None + return MusicTitle( + title=data.get("title"), + summary=data.get("summary"), + display_tags=data.get("display_tags"), + ) + + def _music_audio_from(data: Any) -> Optional[MusicAudioMedia]: if not isinstance(data, dict) or "url" not in data: return None @@ -81,6 +92,9 @@ def _music_audio_from(data: Any) -> Optional[MusicAudioMedia]: file_size=data.get("file_size"), sample_rate=data.get("sample_rate"), channels=data.get("channels"), + # Only `audio` entries carry a per-entry title (variants_num > 1); + # `mux`/`ducked` entries don't have one, so this is always None there. + title=_music_title_from(data.get("title")), ) @@ -91,16 +105,36 @@ def _music_audio_list_from(data: Any) -> Optional[List[MusicAudioMedia]]: return items -def _music_title_from(data: Any) -> Optional[MusicTitle]: - if not isinstance(data, dict): +def _media_list_from(data: Any) -> Optional[List[SfxMedia]]: + if not isinstance(data, list): return None - return MusicTitle( - title=data.get("title"), - summary=data.get("summary"), - display_tags=data.get("display_tags"), + items = [item for item in (_media_from(entry) for entry in data) if item is not None] + return items + + +def _sound_output_from(data: Any) -> Optional[SoundOutput]: + if not isinstance(data, dict) or "output_url" not in data: + return None + return SoundOutput( + variant_index=data.get("variant_index", 0), + output_url=data["output_url"], + output_type=data.get("output_type"), + output_bytes=data.get("output_bytes"), + music=_media_from(data.get("music")), + music_processed=_media_from(data.get("music_processed")), + sfx=_media_from(data.get("sfx")), ) +def _sound_output_list_from(data: Any) -> Optional[List[SoundOutput]]: + if not isinstance(data, list): + return None + items = [ + item for item in (_sound_output_from(entry) for entry in data) if item is not None + ] + return items + + def parse_music_result(body: Dict[str, Any]) -> MusicResult: """Map a GET /v1/tasks/{id} body for a video-to-music task to MusicResult; unknown fields are ignored. @@ -122,6 +156,7 @@ def parse_music_result(body: Dict[str, Any]) -> MusicResult: cost=body.get("cost"), error=body.get("error"), refunded=body.get("refunded"), + variants_num=body.get("variants_num"), ) except KeyError as e: raise SoniloError(f"Malformed task response: missing {e.args[0]!r}") from e @@ -136,10 +171,12 @@ def parse_video_result(body: Dict[str, Any]) -> "VideoResult": status=body["status"], type=body.get("type"), video=_media_from(body.get("video")), + videos=_media_list_from(body.get("videos")) or [], duration_seconds=body.get("duration_seconds"), cost=body.get("cost"), error=body.get("error"), refunded=body.get("refunded"), + variants_num=body.get("variants_num"), ) except KeyError as e: raise SoniloError(f"Malformed task response: missing {e.args[0]!r}") from e @@ -159,10 +196,12 @@ def parse_sound_result(body: Dict[str, Any]) -> "SoundResult": music=_media_from(body.get("music")), music_processed=_media_from(body.get("music_processed")), sfx=_media_from(body.get("sfx")), + outputs=_sound_output_list_from(body.get("outputs")) or [], duration_seconds=body.get("duration_seconds"), cost=body.get("cost"), error=body.get("error"), refunded=body.get("refunded"), + variants_num=body.get("variants_num"), ) except KeyError as e: raise SoniloError(f"Malformed task response: missing {e.args[0]!r}") from e diff --git a/src/sonilo/resources/text_to_music.py b/src/sonilo/resources/text_to_music.py index f74e87a..edace9d 100644 --- a/src/sonilo/resources/text_to_music.py +++ b/src/sonilo/resources/text_to_music.py @@ -50,13 +50,21 @@ def submit( segments: Optional[List[Segment]] = None, mode: Optional[str] = None, output_format: Optional[str] = None, + variants_num: Optional[int] = None, ) -> SfxTask: """Submit an async text-to-music task; poll with `client.tasks.wait(task_id, parser=sonilo.resources.tasks.parse_music_result)`. - Required for output_format="wav". `stream()`/`generate()` remain the - streaming path. + Required for output_format="wav" and for variants_num > 1. + `stream()`/`generate()` remain the streaming path. + + `variants_num` (1-10, default 1) generates that many distinct music + 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. """ - data = build_t2m_async_data(prompt, duration, segments, mode, output_format) + data = build_t2m_async_data( + prompt, duration, segments, mode, output_format, variants_num + ) return parse_sfx_task(self._client._post_json(PATH, data=data)) def generate_async( @@ -67,13 +75,14 @@ def generate_async( segments: Optional[List[Segment]] = None, mode: Optional[str] = None, output_format: Optional[str] = None, + variants_num: Optional[int] = None, poll_interval: float = DEFAULT_POLL_INTERVAL, timeout: float = DEFAULT_WAIT_TIMEOUT, ) -> MusicResult: """submit() + tasks.wait(), returning the parsed MusicResult.""" task = self.submit( prompt=prompt, duration=duration, segments=segments, - mode=mode, output_format=output_format, + mode=mode, output_format=output_format, variants_num=variants_num, ) return self._client.tasks.wait( task.task_id, poll_interval=poll_interval, timeout=timeout, @@ -114,13 +123,16 @@ async def submit( segments: Optional[List[Segment]] = None, mode: Optional[str] = None, output_format: Optional[str] = None, + variants_num: Optional[int] = None, ) -> SfxTask: """Submit an async text-to-music task; poll with `client.tasks.wait(task_id, parser=sonilo.resources.tasks.parse_music_result)`. - Required for output_format="wav". `stream()`/`generate()` remain the - streaming path. + Required for output_format="wav" and for variants_num > 1. + `stream()`/`generate()` remain the streaming path. """ - data = build_t2m_async_data(prompt, duration, segments, mode, output_format) + data = build_t2m_async_data( + prompt, duration, segments, mode, output_format, variants_num + ) return parse_sfx_task(await self._client._post_json(PATH, data=data)) async def generate_async( @@ -131,13 +143,14 @@ async def generate_async( segments: Optional[List[Segment]] = None, mode: Optional[str] = None, output_format: Optional[str] = None, + variants_num: Optional[int] = None, poll_interval: float = DEFAULT_POLL_INTERVAL, timeout: float = DEFAULT_WAIT_TIMEOUT, ) -> MusicResult: """submit() + tasks.wait(), returning the parsed MusicResult.""" task = await self.submit( prompt=prompt, duration=duration, segments=segments, - mode=mode, output_format=output_format, + mode=mode, output_format=output_format, variants_num=variants_num, ) return await self._client.tasks.wait( task.task_id, poll_interval=poll_interval, timeout=timeout, diff --git a/src/sonilo/resources/video_to_music.py b/src/sonilo/resources/video_to_music.py index 67aed63..826d82f 100644 --- a/src/sonilo/resources/video_to_music.py +++ b/src/sonilo/resources/video_to_music.py @@ -59,21 +59,28 @@ def submit( preserve_speech: Optional[bool] = None, output_format: Optional[str] = None, ducking: Optional[bool] = None, + variants_num: Optional[int] = None, ) -> SfxTask: """Submit an async video-to-music task and return its ack. - isolate_vocals/preserve_speech/ducking/output_format="wav" 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. Poll with + isolate_vocals/preserve_speech/ducking/output_format="wav"/ + 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. Poll with `client.tasks.wait(task_id, parser=sonilo.resources.tasks.parse_music_result)` or use `generate_async()` to submit and wait in one call. + + `variants_num` (1-10, default 1) generates that many distinct music + 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. """ data, files, opened = build_v2m_async_parts( video, video_url, prompt, segments, mode, isolate_vocals, preserve_speech=preserve_speech, output_format=output_format, ducking=ducking, + variants_num=variants_num, ) close_after = files["video"][1] if files is not None and opened else None return parse_sfx_task( @@ -92,6 +99,7 @@ def generate_async( preserve_speech: Optional[bool] = None, output_format: Optional[str] = None, ducking: Optional[bool] = None, + variants_num: Optional[int] = None, poll_interval: float = DEFAULT_POLL_INTERVAL, timeout: float = DEFAULT_WAIT_TIMEOUT, ) -> MusicResult: @@ -106,6 +114,7 @@ def generate_async( preserve_speech=preserve_speech, output_format=output_format, ducking=ducking, + variants_num=variants_num, ) return self._client.tasks.wait( task.task_id, @@ -155,19 +164,21 @@ async def submit( preserve_speech: Optional[bool] = None, output_format: Optional[str] = None, ducking: Optional[bool] = None, + variants_num: Optional[int] = None, ) -> SfxTask: """Submit an async video-to-music task and return its ack. - isolate_vocals/preserve_speech/ducking/output_format="wav" 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. + isolate_vocals/preserve_speech/ducking/output_format="wav"/ + 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. """ data, files, opened = build_v2m_async_parts( video, video_url, prompt, segments, mode, isolate_vocals, preserve_speech=preserve_speech, output_format=output_format, ducking=ducking, + variants_num=variants_num, ) close_after = files["video"][1] if files is not None and opened else None return parse_sfx_task( @@ -188,6 +199,7 @@ async def generate_async( preserve_speech: Optional[bool] = None, output_format: Optional[str] = None, ducking: Optional[bool] = None, + variants_num: Optional[int] = None, poll_interval: float = DEFAULT_POLL_INTERVAL, timeout: float = DEFAULT_WAIT_TIMEOUT, ) -> MusicResult: @@ -202,6 +214,7 @@ async def generate_async( preserve_speech=preserve_speech, output_format=output_format, ducking=ducking, + variants_num=variants_num, ) return await self._client.tasks.wait( task.task_id, diff --git a/src/sonilo/resources/video_to_sound.py b/src/sonilo/resources/video_to_sound.py index 4198131..51c07d9 100644 --- a/src/sonilo/resources/video_to_sound.py +++ b/src/sonilo/resources/video_to_sound.py @@ -32,10 +32,18 @@ def submit( segments: Optional[List[SfxSegment]] = None, preserve_speech: Optional[bool] = None, ducking: Optional[bool] = None, + variants_num: Optional[int] = None, ) -> SfxTask: + """`variants_num` (1-10, default 1) generates that many distinct + variants in one request; the result's `outputs` gets one entry per + variant, and the top-level output/stem fields stay aliases for + `outputs[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. + """ data, files, opened = build_v2s_parts( video, video_url, music_prompt, sfx_prompt, segments, - preserve_speech, ducking, + preserve_speech, ducking, variants_num, ) close_after = files["video"][1] if files is not None and opened else None return parse_sfx_task( @@ -52,6 +60,7 @@ def generate( segments: Optional[List[SfxSegment]] = None, preserve_speech: Optional[bool] = None, ducking: Optional[bool] = None, + variants_num: Optional[int] = None, poll_interval: float = DEFAULT_POLL_INTERVAL, timeout: float = DEFAULT_WAIT_TIMEOUT, ) -> SoundResult: @@ -63,6 +72,7 @@ def generate( segments=segments, preserve_speech=preserve_speech, ducking=ducking, + variants_num=variants_num, ) return self._client.tasks.wait( task.task_id, @@ -86,10 +96,11 @@ async def submit( segments: Optional[List[SfxSegment]] = None, preserve_speech: Optional[bool] = None, ducking: Optional[bool] = None, + variants_num: Optional[int] = None, ) -> SfxTask: data, files, opened = build_v2s_parts( video, video_url, music_prompt, sfx_prompt, segments, - preserve_speech, ducking, + preserve_speech, ducking, variants_num, ) close_after = files["video"][1] if files is not None and opened else None return parse_sfx_task( @@ -108,6 +119,7 @@ async def generate( segments: Optional[List[SfxSegment]] = None, preserve_speech: Optional[bool] = None, ducking: Optional[bool] = None, + variants_num: Optional[int] = None, poll_interval: float = DEFAULT_POLL_INTERVAL, timeout: float = DEFAULT_WAIT_TIMEOUT, ) -> SoundResult: @@ -119,6 +131,7 @@ async def generate( segments=segments, preserve_speech=preserve_speech, ducking=ducking, + variants_num=variants_num, ) 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 618a927..e131cc1 100644 --- a/src/sonilo/resources/video_to_video_music.py +++ b/src/sonilo/resources/video_to_video_music.py @@ -30,9 +30,16 @@ def submit( prompt: Optional[str] = None, preserve_speech: Optional[bool] = None, isolate_vocals: Optional[bool] = None, + variants_num: Optional[int] = 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. + """ data, files, opened = build_v2v_music_parts( - video, video_url, prompt, preserve_speech, isolate_vocals + video, video_url, prompt, preserve_speech, isolate_vocals, variants_num ) close_after = files["video"][1] if files is not None and opened else None return parse_sfx_task( @@ -47,6 +54,7 @@ def generate( prompt: Optional[str] = None, preserve_speech: Optional[bool] = None, isolate_vocals: Optional[bool] = None, + variants_num: Optional[int] = None, poll_interval: float = DEFAULT_POLL_INTERVAL, timeout: float = DEFAULT_WAIT_TIMEOUT, ) -> VideoResult: @@ -56,6 +64,7 @@ def generate( prompt=prompt, preserve_speech=preserve_speech, isolate_vocals=isolate_vocals, + variants_num=variants_num, ) return self._client.tasks.wait( task.task_id, @@ -77,9 +86,10 @@ async def submit( prompt: Optional[str] = None, preserve_speech: Optional[bool] = None, isolate_vocals: Optional[bool] = None, + variants_num: Optional[int] = None, ) -> SfxTask: data, files, opened = build_v2v_music_parts( - video, video_url, prompt, preserve_speech, isolate_vocals + video, video_url, prompt, preserve_speech, isolate_vocals, variants_num ) close_after = files["video"][1] if files is not None and opened else None return parse_sfx_task( @@ -96,6 +106,7 @@ async def generate( prompt: Optional[str] = None, preserve_speech: Optional[bool] = None, isolate_vocals: Optional[bool] = None, + variants_num: Optional[int] = None, poll_interval: float = DEFAULT_POLL_INTERVAL, timeout: float = DEFAULT_WAIT_TIMEOUT, ) -> VideoResult: @@ -105,6 +116,7 @@ async def generate( prompt=prompt, preserve_speech=preserve_speech, isolate_vocals=isolate_vocals, + variants_num=variants_num, ) return await self._client.tasks.wait( task.task_id, diff --git a/src/sonilo/resources/video_to_video_sound.py b/src/sonilo/resources/video_to_video_sound.py index 14e8c40..f60e41a 100644 --- a/src/sonilo/resources/video_to_video_sound.py +++ b/src/sonilo/resources/video_to_video_sound.py @@ -32,10 +32,18 @@ def submit( segments: Optional[List[SfxSegment]] = None, preserve_speech: Optional[bool] = None, ducking: Optional[bool] = None, + variants_num: Optional[int] = None, ) -> SfxTask: + """`variants_num` (1-10, default 1) generates that many distinct + variants in one request; the result's `outputs` gets one entry per + variant, and the top-level output/stem fields stay aliases for + `outputs[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. + """ data, files, opened = build_v2s_parts( video, video_url, music_prompt, sfx_prompt, segments, - preserve_speech, ducking, + preserve_speech, ducking, variants_num, ) close_after = files["video"][1] if files is not None and opened else None return parse_sfx_task( @@ -52,6 +60,7 @@ def generate( segments: Optional[List[SfxSegment]] = None, preserve_speech: Optional[bool] = None, ducking: Optional[bool] = None, + variants_num: Optional[int] = None, poll_interval: float = DEFAULT_POLL_INTERVAL, timeout: float = DEFAULT_WAIT_TIMEOUT, ) -> SoundResult: @@ -63,6 +72,7 @@ def generate( segments=segments, preserve_speech=preserve_speech, ducking=ducking, + variants_num=variants_num, ) return self._client.tasks.wait( task.task_id, @@ -86,10 +96,11 @@ async def submit( segments: Optional[List[SfxSegment]] = None, preserve_speech: Optional[bool] = None, ducking: Optional[bool] = None, + variants_num: Optional[int] = None, ) -> SfxTask: data, files, opened = build_v2s_parts( video, video_url, music_prompt, sfx_prompt, segments, - preserve_speech, ducking, + preserve_speech, ducking, variants_num, ) close_after = files["video"][1] if files is not None and opened else None return parse_sfx_task( @@ -108,6 +119,7 @@ async def generate( segments: Optional[List[SfxSegment]] = None, preserve_speech: Optional[bool] = None, ducking: Optional[bool] = None, + variants_num: Optional[int] = None, poll_interval: float = DEFAULT_POLL_INTERVAL, timeout: float = DEFAULT_WAIT_TIMEOUT, ) -> SoundResult: @@ -119,6 +131,7 @@ async def generate( segments=segments, preserve_speech=preserve_speech, ducking=ducking, + variants_num=variants_num, ) return await self._client.tasks.wait( task.task_id, diff --git a/src/sonilo/types.py b/src/sonilo/types.py index 21db947..085cf59 100644 --- a/src/sonilo/types.py +++ b/src/sonilo/types.py @@ -145,6 +145,15 @@ async def asave( return p +@dataclass +class MusicTitle: + """The `title` object on a succeeded music task.""" + + title: Optional[str] = None + summary: Optional[str] = None + display_tags: Optional[List[str]] = None + + @dataclass class MusicAudioMedia: """One entry of a music task's `audio` or `mux` array. @@ -152,6 +161,11 @@ class MusicAudioMedia: Unlike SfxMedia (used for single-media fields such as `vocals`), array entries carry a `stream_index` and — for `audio` specifically — `sample_rate`/`channels`, which `mux` entries don't populate. + + `title` is only populated on `audio` entries, and only when + `variants_num > 1` — each variant is its own creative direction with its + own title, unlike the single-variant case where `title` only appears at + the top level of the result. """ stream_index: int @@ -160,15 +174,7 @@ class MusicAudioMedia: file_size: Optional[int] = None sample_rate: Optional[int] = None channels: Optional[int] = None - - -@dataclass -class MusicTitle: - """The `title` object on a succeeded music task.""" - - title: Optional[str] = None - summary: Optional[str] = None - display_tags: Optional[List[str]] = None + title: Optional[MusicTitle] = None @dataclass @@ -193,6 +199,11 @@ class MusicResult: cost: Optional[float] = None error: Optional[Dict[str, Any]] = None refunded: Optional[bool] = None + variants_num: Optional[int] = None + """Echoed by GET /v1/tasks/{id} only when > 1. `audio` (and `mux`/`ducked` + when present) then holds one entry per variant instead of one entry per + stream of a single generation; `title` stays an alias for `audio[0]`'s + title.""" def _media(self, which: str, index: int) -> Union[SfxMedia, MusicAudioMedia]: if which == "vocals": @@ -256,27 +267,51 @@ class VideoResult: """State of an async video-to-video task (`tasks.get`) or its final result. The output is a re-hosted video (generated music or SFX muxed into the - source picture); `video` is a single presigned media object. + source picture); `video` is a single presigned media object — kept as a + permanent alias for `videos[0]` even when `variants_num > 1` produced + several scored videos. """ task_id: str status: str type: Optional[str] = None video: Optional[SfxMedia] = None + videos: List[SfxMedia] = field(default_factory=list) + """One entry per variant (video-to-video-music only). Empty unless the + task echoed a `videos` array; `video` above always mirrors `videos[0]`.""" duration_seconds: Optional[float] = None cost: Optional[float] = None error: Optional[Dict[str, Any]] = None refunded: Optional[bool] = None - - def _media(self) -> SfxMedia: + variants_num: Optional[int] = None + """Echoed by GET /v1/tasks/{id} only when > 1.""" + + def _media(self, index: Optional[int] = None) -> SfxMedia: + if index is not None: + if not self.videos: + raise SoniloError(f"No videos on this result (status={self.status})") + try: + return self.videos[index] + except IndexError: + raise SoniloError( + f"No video at index {index} (have {len(self.videos)})" + ) from None if self.video is None: raise SoniloError(f"No video on this result (status={self.status})") return self.video - def save(self, path: Union[str, Path], *, timeout: float = DOWNLOAD_TIMEOUT) -> Path: + def save( + self, + path: Union[str, Path], + *, + index: Optional[int] = None, + timeout: float = DOWNLOAD_TIMEOUT, + ) -> Path: """Download the result video to `path` and return it. The URL is - presigned — no API key is sent.""" - media = self._media() + presigned — no API key is sent. `index` selects a specific variant + from `videos`; omit it (the default) to keep downloading `video`, + unchanged from before `variants_num` existed.""" + media = self._media(index) response = httpx.get(media.url, follow_redirects=True, timeout=timeout) if response.status_code >= 400: raise SoniloError(f"Download failed: HTTP {response.status_code}") @@ -285,10 +320,14 @@ def save(self, path: Union[str, Path], *, timeout: float = DOWNLOAD_TIMEOUT) -> return p async def asave( - self, path: Union[str, Path], *, timeout: float = DOWNLOAD_TIMEOUT + self, + path: Union[str, Path], + *, + index: Optional[int] = None, + timeout: float = DOWNLOAD_TIMEOUT, ) -> Path: """Async variant of save().""" - media = self._media() + media = self._media(index) async with httpx.AsyncClient(follow_redirects=True, timeout=timeout) as http: response = await http.get(media.url) if response.status_code >= 400: @@ -322,6 +361,22 @@ async def _adownload_to(url: str, path: Union[str, Path], timeout: float) -> Pat return p +@dataclass +class SoundOutput: + """One entry of a video-to-sound / video-to-video-sound task's `outputs` + array — one per variant (`variants_num`), sorted by `variant_index`. + `music_processed` is present only when preserve_speech or ducking + altered that variant's music bed.""" + + variant_index: int + output_url: str + output_type: Optional[str] = None + output_bytes: Optional[int] = None + music: Optional[SfxMedia] = None + music_processed: Optional[SfxMedia] = None + sfx: Optional[SfxMedia] = None + + @dataclass class SoundResult: """State of a video-to-sound / video-to-video-sound task (`tasks.get`) or @@ -332,7 +387,9 @@ class SoundResult: whose kind is announced by `output_type` ("audio" for /v1/video-to-sound, "video" for /v1/video-to-video-sound). `music`, `music_processed` and `sfx` are the individual stems; `music_processed` is present only when - preserve_speech or ducking altered the music bed. + preserve_speech or ducking altered the music bed. All of the above stay + aliases for `outputs[0]`'s corresponding fields, even when + `variants_num > 1` produced several variants. """ task_id: str @@ -344,56 +401,97 @@ class SoundResult: music: Optional[SfxMedia] = None music_processed: Optional[SfxMedia] = None sfx: Optional[SfxMedia] = None + outputs: List[SoundOutput] = field(default_factory=list) + """One entry per variant. Empty unless the task echoed an `outputs` + array; the top-level fields above always mirror `outputs[0]`.""" duration_seconds: Optional[float] = None cost: Optional[float] = None error: Optional[Dict[str, Any]] = None refunded: Optional[bool] = None + variants_num: Optional[int] = None + """Echoed by GET /v1/tasks/{id} only when > 1.""" - def _output(self) -> str: + def _output(self, index: Optional[int] = None) -> str: + if index is not None: + entry = self._entry_at(index) + return entry.output_url if not self.output_url: raise SoniloError(f"No output on this result (status={self.status})") return self.output_url - def _stem(self, which: str) -> str: + def _entry_at(self, index: int) -> SoundOutput: + if not self.outputs: + raise SoniloError(f"No output on this result (status={self.status})") + try: + return self.outputs[index] + except IndexError: + raise SoniloError( + f"No output at index {index} (have {len(self.outputs)})" + ) from None + + def _stem(self, which: str, index: Optional[int] = None) -> str: if which not in _SOUND_STEMS: raise SoniloError( f"Unknown stem {which!r}; expected one of {', '.join(_SOUND_STEMS)}" ) + if index is not None: + media = getattr(self._entry_at(index), which) + if media is None: + raise SoniloError( + f"No {which} stem on this result at index {index} (status={self.status})" + ) + return media.url media = getattr(self, which) if media is None: raise SoniloError(f"No {which} stem on this result (status={self.status})") return media.url - def save(self, path: Union[str, Path], *, timeout: float = DOWNLOAD_TIMEOUT) -> Path: + def save( + self, + path: Union[str, Path], + *, + index: Optional[int] = None, + timeout: float = DOWNLOAD_TIMEOUT, + ) -> Path: """Download the combined result to `path` and return it. The URL is - presigned — no API key is sent.""" - return _download_to(self._output(), path, timeout) + presigned — no API key is sent. `index` selects a specific variant + from `outputs`; omit it (the default) to keep downloading + `output_url`, unchanged from before `variants_num` existed.""" + return _download_to(self._output(index), path, timeout) async def asave( - self, path: Union[str, Path], *, timeout: float = DOWNLOAD_TIMEOUT + self, + path: Union[str, Path], + *, + index: Optional[int] = None, + timeout: float = DOWNLOAD_TIMEOUT, ) -> Path: """Async variant of save().""" - return await _adownload_to(self._output(), path, timeout) + return await _adownload_to(self._output(index), path, timeout) def save_stem( self, path: Union[str, Path], *, which: str = "music", + index: Optional[int] = None, timeout: float = DOWNLOAD_TIMEOUT, ) -> Path: - """Download one stem ("music", "music_processed" or "sfx") to `path`.""" - return _download_to(self._stem(which), path, timeout) + """Download one stem ("music", "music_processed" or "sfx") to `path`. + `index` selects a specific variant from `outputs`; omit it (the + default) to keep using the top-level stem fields.""" + return _download_to(self._stem(which, index), path, timeout) async def asave_stem( self, path: Union[str, Path], *, which: str = "music", + index: Optional[int] = None, timeout: float = DOWNLOAD_TIMEOUT, ) -> Path: """Async variant of save_stem().""" - return await _adownload_to(self._stem(which), path, timeout) + return await _adownload_to(self._stem(which, index), path, timeout) @dataclass diff --git a/tests/test_requests.py b/tests/test_requests.py index 103bc6c..7d8e0e0 100644 --- a/tests/test_requests.py +++ b/tests/test_requests.py @@ -5,9 +5,11 @@ from sonilo._requests import ( build_dubbing_parts, + build_t2m_async_data, build_t2m_data, build_v2m_async_parts, build_v2m_parts, + build_v2s_parts, build_v2v_music_parts, build_v2v_sfx_parts, normalize_video, @@ -162,3 +164,65 @@ def test_build_dubbing_parts_passes_unknown_codes_through(): # would make this SDK reject codes added server-side later. data, _, _ = build_dubbing_parts(None, "https://x/v.mp4", ["xx"]) assert json.loads(data["languages"]) == ["xx"] + + +# --- variants_num ---------------------------------------------------------- + + +def test_build_t2m_async_data_forwards_variants_num(): + data = build_t2m_async_data("lofi", 30, None, None, None, variants_num=3) + assert data["variants_num"] == "3" + assert data["mode"] == "async" + + +def test_build_t2m_async_data_omits_variants_num_when_unset(): + data = build_t2m_async_data("lofi", 30, None, None, None) + assert "variants_num" not in data + + +def test_v2m_async_parts_forwards_variants_num(): + data, _, _ = build_v2m_async_parts( + None, "https://x/v.mp4", None, None, None, None, variants_num=3 + ) + assert data["variants_num"] == "3" + assert data["mode"] == "async" + + +def test_v2m_async_parts_variants_num_1_does_not_force_async(): + # variants_num=1 is the no-op case: unlike variants_num > 1, it must not + # by itself reject an explicit non-async mode. + data, _, _ = build_v2m_async_parts( + None, "https://x/v.mp4", None, None, "sync", None, variants_num=1 + ) + assert data["mode"] == "sync" + + +def test_v2m_async_parts_variants_num_above_1_requires_async(): + with pytest.raises(SoniloError): + build_v2m_async_parts( + None, "https://x/v.mp4", None, None, "sync", None, variants_num=2 + ) + + +def test_v2v_music_parts_forwards_variants_num(): + data, _, _ = build_v2v_music_parts( + None, "https://x/v.mp4", "p", None, None, variants_num=5 + ) + assert data["variants_num"] == "5" + + +def test_v2v_music_parts_omits_variants_num_when_unset(): + data, _, _ = build_v2v_music_parts(None, "https://x/v.mp4", "p", None, None) + assert "variants_num" not in data + + +def test_build_v2s_parts_forwards_variants_num(): + data, _, _ = build_v2s_parts( + None, "https://x/v.mp4", None, None, None, None, None, variants_num=4 + ) + assert data["variants_num"] == "4" + + +def test_build_v2s_parts_omits_variants_num_when_unset(): + data, _, _ = build_v2s_parts(None, "https://x/v.mp4", None, None, None, None, None) + assert "variants_num" not in data diff --git a/tests/test_sfx_types.py b/tests/test_sfx_types.py index 083afe6..ba8a634 100644 --- a/tests/test_sfx_types.py +++ b/tests/test_sfx_types.py @@ -10,7 +10,15 @@ TaskFailedError, TaskTimeoutError, ) -from sonilo.types import DOWNLOAD_TIMEOUT, MusicAudioMedia, MusicResult, VideoResult +from sonilo.types import ( + DOWNLOAD_TIMEOUT, + MusicAudioMedia, + MusicResult, + MusicTitle, + SoundOutput, + SoundResult, + VideoResult, +) AUDIO = SfxMedia(url="https://r2.example.com/audio.m4a", content_type="audio/mp4", file_size=10) @@ -186,6 +194,124 @@ def test_music_result_save_which_ducked(tmp_path): assert out.read_bytes() == b"duckedbytes" +# --- variants_num ----------------------------------------------------- + + +def test_music_audio_media_carries_optional_title(): + entry = MusicAudioMedia( + stream_index=1, + url="https://r2/a1.m4a", + title=MusicTitle(title="Variant 1", summary="s", display_tags=["x"]), + ) + assert entry.title.title == "Variant 1" + # Default stays None, matching mux/ducked entries which never carry one. + assert MusicAudioMedia(stream_index=0, url="https://r2/a0.m4a").title is None + + +def test_music_result_variants_num_defaults_to_none(): + result = MusicResult(task_id="m1", status="succeeded") + assert result.variants_num is None + + +@respx.mock +def test_video_result_videos_index_selects_a_variant(tmp_path): + respx.get("https://r2.example.com/v1.mp4").mock( + return_value=httpx.Response(200, content=b"variant1") + ) + result = make_video_result( + videos=[ + SfxMedia(url="https://r2.example.com/video.mp4"), + SfxMedia(url="https://r2.example.com/v1.mp4"), + ], + variants_num=2, + ) + out = result.save(tmp_path / "v1.mp4", index=1) + assert out.read_bytes() == b"variant1" + + +def test_video_result_default_save_ignores_videos_list(tmp_path): + # Omitting `index` must keep using `video`, unchanged from before + # `videos`/`variants_num` existed, even when `videos` is populated. + result = make_video_result(videos=[SfxMedia(url="https://r2.example.com/other.mp4")]) + assert result._media().url == "https://r2.example.com/video.mp4" + + +def test_video_result_videos_index_out_of_range_raises(): + result = make_video_result(videos=[SfxMedia(url="https://r2.example.com/v0.mp4")]) + with pytest.raises(SoniloError): + result.save("unused.mp4", index=5) + + +def test_video_result_videos_index_without_videos_raises(): + result = VideoResult(task_id="v2", status="succeeded") + with pytest.raises(SoniloError): + result.save("unused.mp4", index=0) + + +SOUND_STEM_MEDIA = SfxMedia(url="https://r2.example.com/s0.music.m4a") + + +def make_sound_result(**overrides) -> SoundResult: + kwargs = { + "task_id": "sd1", + "status": "succeeded", + "output_url": "https://r2.example.com/s0.wav", + "music": SOUND_STEM_MEDIA, + } + kwargs.update(overrides) + return SoundResult(**kwargs) + + +@respx.mock +def test_sound_result_outputs_index_selects_a_variant(tmp_path): + respx.get("https://r2.example.com/s1.wav").mock( + return_value=httpx.Response(200, content=b"variant1") + ) + result = make_sound_result( + outputs=[ + SoundOutput(variant_index=0, output_url="https://r2.example.com/s0.wav"), + SoundOutput(variant_index=1, output_url="https://r2.example.com/s1.wav"), + ], + variants_num=2, + ) + out = result.save(tmp_path / "v1.wav", index=1) + assert out.read_bytes() == b"variant1" + + +@respx.mock +def test_sound_result_save_stem_index_selects_a_variant(tmp_path): + respx.get("https://r2.example.com/s1.music.m4a").mock( + return_value=httpx.Response(200, content=b"music1") + ) + result = make_sound_result( + outputs=[ + SoundOutput( + variant_index=0, output_url="https://r2.example.com/s0.wav", + music=SOUND_STEM_MEDIA, + ), + SoundOutput( + variant_index=1, output_url="https://r2.example.com/s1.wav", + music=SfxMedia(url="https://r2.example.com/s1.music.m4a"), + ), + ], + ) + out = result.save_stem(tmp_path / "m1.m4a", which="music", index=1) + assert out.read_bytes() == b"music1" + + +def test_sound_result_default_save_ignores_outputs_list(tmp_path): + result = make_sound_result( + outputs=[SoundOutput(variant_index=0, output_url="https://r2.example.com/other.wav")] + ) + assert result._output() == "https://r2.example.com/s0.wav" + + +def test_sound_result_outputs_index_without_outputs_raises(): + result = SoundResult(task_id="sd2", status="succeeded") + with pytest.raises(SoniloError): + result.save("unused.wav", index=0) + + def test_task_errors_carry_fields(): failed = TaskFailedError("boom", code="GENERATION_FAILED", task_id="t1", refunded=True) assert isinstance(failed, SoniloError) diff --git a/tests/test_video_to_sound.py b/tests/test_video_to_sound.py index 66a5b2f..53f9ab4 100644 --- a/tests/test_video_to_sound.py +++ b/tests/test_video_to_sound.py @@ -145,3 +145,74 @@ async def test_async_video_to_video_sound_submit(): async with AsyncSonilo(api_key="k") as client: task = await client.video_to_video_sound.submit(video_url="https://x/v.mp4") assert task.task_id == "sd2" + + +# --- variants_num ------------------------------------------------------ + +MULTI_VARIANT_BODY = { + "task_id": "sd3", + "type": "video_to_sound", + "status": "succeeded", + "variants_num": 2, + "output_url": "https://r2/o0.wav", + "output_type": "audio", + "output_bytes": 5, + "music": {"url": "https://r2/m0.m4a"}, + "sfx": {"url": "https://r2/s0.wav"}, + "outputs": [ + { + "variant_index": 0, + "output_url": "https://r2/o0.wav", + "output_type": "audio", + "output_bytes": 5, + "music": {"url": "https://r2/m0.m4a"}, + "sfx": {"url": "https://r2/s0.wav"}, + }, + { + "variant_index": 1, + "output_url": "https://r2/o1.wav", + "output_type": "audio", + "output_bytes": 6, + "music": {"url": "https://r2/m1.m4a"}, + "music_processed": {"url": "https://r2/mp1.m4a"}, + "sfx": {"url": "https://r2/s1.wav"}, + }, + ], +} + + +def test_build_v2s_parts_forwards_variants_num_field(): + data, _, _ = build_v2s_parts( + None, "https://x/v.mp4", None, None, None, None, None, variants_num=2 + ) + assert data["variants_num"] == "2" + + +def test_parse_sound_result_reads_outputs_and_variants_num(): + result = parse_sound_result(MULTI_VARIANT_BODY) + assert result.variants_num == 2 + assert len(result.outputs) == 2 + assert result.outputs[1].output_url == "https://r2/o1.wav" + assert result.outputs[1].music_processed.url == "https://r2/mp1.m4a" + assert result.outputs[0].music_processed is None + # Top-level fields remain aliases for outputs[0]. + assert result.output_url == result.outputs[0].output_url + + +@respx.mock +def test_video_to_sound_generate_forwards_variants_num_and_saves_by_index(tmp_path): + respx.post("https://api.sonilo.com/v1/video-to-sound").mock( + return_value=httpx.Response(202, json={"task_id": "sd3", "status": "processing"}) + ) + respx.get("https://api.sonilo.com/v1/tasks/sd3").mock( + return_value=httpx.Response(200, json=MULTI_VARIANT_BODY) + ) + respx.get("https://r2/o1.wav").mock(return_value=httpx.Response(200, content=b"variant1")) + result = Sonilo(api_key="k").video_to_sound.generate( + video_url="https://x/v.mp4", variants_num=2, poll_interval=0 + ) + assert result.variants_num == 2 + out = result.save(tmp_path / "v1.wav", index=1) + assert out.read_bytes() == b"variant1" + content = respx.calls[0].request.content + assert b"variants_num" in content diff --git a/tests/test_video_to_video.py b/tests/test_video_to_video.py index d59c6b3..8afe216 100644 --- a/tests/test_video_to_video.py +++ b/tests/test_video_to_video.py @@ -85,3 +85,75 @@ def test_music_result_parses_ducked(): video_url="https://x/v.mp4", ducking=True, poll_interval=0 ) assert res.ducked[0].url == "https://r2/d.m4a" + + +# --- variants_num ------------------------------------------------------ + + +@respx.mock +def test_v2m_submit_forwards_variants_num_and_forces_async(): + respx.post("https://api.sonilo.com/v1/video-to-music").mock( + return_value=httpx.Response(202, json={"task_id": "m2", "status": "processing"}) + ) + Sonilo(api_key="k").video_to_music.submit(video_url="https://x/v.mp4", variants_num=3) + content = respx.calls[0].request.content + assert b"variants_num" in content and b"async" in content + + +@respx.mock +def test_music_result_parses_per_variant_titles_and_variants_num(): + respx.post("https://api.sonilo.com/v1/text-to-music").mock( + return_value=httpx.Response(202, json={"task_id": "v3", "status": "processing"}) + ) + respx.get("https://api.sonilo.com/v1/tasks/v3").mock( + return_value=httpx.Response(200, json={ + "task_id": "v3", "type": "text_to_music", "status": "succeeded", + "variants_num": 2, + "audio": [ + {"stream_index": 0, "url": "https://r2/a0.m4a", + "title": {"title": "Take 1", "summary": "s0", "display_tags": ["calm"]}}, + {"stream_index": 1, "url": "https://r2/a1.m4a", + "title": {"title": "Take 2", "summary": "s1", "display_tags": ["upbeat"]}}, + ], + "title": {"title": "Take 1", "summary": "s0", "display_tags": ["calm"]}, + }) + ) + res = Sonilo(api_key="k").text_to_music.generate_async( + prompt="lofi", duration=10, variants_num=2, poll_interval=0 + ) + assert res.variants_num == 2 + assert len(res.audio) == 2 + assert res.audio[0].title.title == "Take 1" + assert res.audio[1].title.title == "Take 2" + # Top-level `title` remains an alias for audio[0].title. + assert res.title.title == res.audio[0].title.title + + +@respx.mock +def test_v2v_music_forwards_variants_num_and_parses_videos_list(): + respx.post("https://api.sonilo.com/v1/video-to-video-music").mock( + return_value=httpx.Response(202, json={"task_id": "vv1", "status": "processing"}) + ) + respx.get("https://api.sonilo.com/v1/tasks/vv1").mock( + return_value=httpx.Response( + 200, + json={ + "task_id": "vv1", "type": "video_to_video_music", "status": "succeeded", + "variants_num": 2, + "videos": [ + {"url": "https://r2/o0.mp4", "content_type": "video/mp4", "file_size": 1}, + {"url": "https://r2/o1.mp4", "content_type": "video/mp4", "file_size": 2}, + ], + "video": {"url": "https://r2/o0.mp4", "content_type": "video/mp4", "file_size": 1}, + }, + ) + ) + result = Sonilo(api_key="k").video_to_video_music.generate( + video_url="https://x/v.mp4", variants_num=2, poll_interval=0 + ) + assert result.variants_num == 2 + assert [v.url for v in result.videos] == ["https://r2/o0.mp4", "https://r2/o1.mp4"] + # `video` stays an alias for videos[0]. + assert result.video.url == result.videos[0].url + content = respx.calls[0].request.content + assert b"variants_num" in content