sonilo-cli 0.5.0__tar.gz → 0.7.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sonilo-cli
3
- Version: 0.5.0
3
+ Version: 0.7.0
4
4
  Summary: Command-line interface for the Sonilo API: generate music and sound effects from text or video
5
5
  Project-URL: Repository, https://github.com/sonilo-ai/sonilo-python
6
6
  Author: Sonilo AI
@@ -8,7 +8,7 @@ License-Expression: MIT
8
8
  License-File: LICENSE
9
9
  Keywords: ai,cli,music,sfx,sonilo,text-to-music,video-to-music
10
10
  Requires-Python: >=3.9
11
- Requires-Dist: sonilo<0.8,>=0.7.0
11
+ Requires-Dist: sonilo<0.11,>=0.10.0
12
12
  Provides-Extra: dev
13
13
  Requires-Dist: pytest>=8; extra == 'dev'
14
14
  Requires-Dist: respx>=0.21; extra == 'dev'
@@ -52,8 +52,8 @@ or pass `--api-key sk-...` on any command.
52
52
  ### Notes
53
53
 
54
54
  - `text-to-music` / `video-to-music` stream a short `.m4a` by default. `--format wav`,
55
- `--preserve-speech`, and its legacy alias `--isolate-vocals` each switch to the async
56
- submit-and-poll path.
55
+ `--preserve-speech`, `--variants` above 1, and the legacy alias `--isolate-vocals` each switch
56
+ to the async submit-and-poll path.
57
57
  - `text-to-sfx` / `video-to-sfx` are always async; `--format` accepts `wav|mp3|aac|flac`.
58
58
  - Output defaults to `./output.<ext>`; override with `--output`.
59
59
 
@@ -88,6 +88,24 @@ The two segment shapes are **not** interchangeable:
88
88
  - `text-to-sfx` takes no segments (its output is a single effect, not a timeline).
89
89
  - `video-to-video-music` takes no segments either — the API scores the whole clip in one pass.
90
90
 
91
+ ### Variants
92
+
93
+ `--variants N` (1-10, default 1) generates that many distinct variants in one request instead of
94
+ one, on `text-to-music`, `video-to-music`, `video-to-video-music`, `video-to-sound`, and
95
+ `video-to-video-sound`. Cost scales linearly — `--variants 3` costs three times a single-variant
96
+ request — and values above 1 are never covered by the free trial.
97
+
98
+ sonilo text-to-music --prompt "warm lo-fi piano" --duration 30 --variants 3 --output take.m4a
99
+ # writes take.0.m4a, take.1.m4a, take.2.m4a
100
+
101
+ - `--variants` above 1 forces the async submit-and-poll path (see [Notes](#notes) above).
102
+ - With `--variants` unset (or `1`), a command writes the single `--output` file exactly as before
103
+ this flag existed. Above 1, it instead writes one file per variant, with the variant index
104
+ spliced before the extension: `take.m4a` becomes `take.0.m4a`, `take.1.m4a`, etc. — the same
105
+ naming `--stem` and dubbing's per-language output already use.
106
+ - On `video-to-sound` / `video-to-video-sound`, `--stem` is applied per variant too, e.g.
107
+ `take.0.music.m4a`.
108
+
91
109
  ### Scored video
92
110
 
93
111
  `video-to-video-music` and `video-to-video-sfx` are the video-out counterparts of `video-to-music`
@@ -108,6 +126,8 @@ file (default `output.mp4`):
108
126
  [Segments](#segments).
109
127
  - Neither command exposes `--format`: the output is a video, not an audio file.
110
128
  - For music *and* effects in one call, use `video-to-video-sound` below.
129
+ - `video-to-video-music` also takes `--variants` — see [Variants](#variants) above.
130
+ `video-to-video-sfx` does not.
111
131
 
112
132
  ### Combined soundtracks
113
133
 
@@ -132,6 +152,7 @@ they differ only in what comes back: `video-to-sound` writes the mixed **audio**
132
152
  the combined output, so you can re-balance the mix yourself. With `--output soundtrack.wav`, the
133
153
  music stem lands at `soundtrack.music.m4a`. `music_processed` exists only when `--preserve-speech`
134
154
  or ducking altered the music bed.
155
+ - Both also take `--variants` — see [Variants](#variants) above.
135
156
 
136
157
  ### Dubbing
137
158
 
@@ -36,8 +36,8 @@ or pass `--api-key sk-...` on any command.
36
36
  ### Notes
37
37
 
38
38
  - `text-to-music` / `video-to-music` stream a short `.m4a` by default. `--format wav`,
39
- `--preserve-speech`, and its legacy alias `--isolate-vocals` each switch to the async
40
- submit-and-poll path.
39
+ `--preserve-speech`, `--variants` above 1, and the legacy alias `--isolate-vocals` each switch
40
+ to the async submit-and-poll path.
41
41
  - `text-to-sfx` / `video-to-sfx` are always async; `--format` accepts `wav|mp3|aac|flac`.
42
42
  - Output defaults to `./output.<ext>`; override with `--output`.
43
43
 
@@ -72,6 +72,24 @@ The two segment shapes are **not** interchangeable:
72
72
  - `text-to-sfx` takes no segments (its output is a single effect, not a timeline).
73
73
  - `video-to-video-music` takes no segments either — the API scores the whole clip in one pass.
74
74
 
75
+ ### Variants
76
+
77
+ `--variants N` (1-10, default 1) generates that many distinct variants in one request instead of
78
+ one, on `text-to-music`, `video-to-music`, `video-to-video-music`, `video-to-sound`, and
79
+ `video-to-video-sound`. Cost scales linearly — `--variants 3` costs three times a single-variant
80
+ request — and values above 1 are never covered by the free trial.
81
+
82
+ sonilo text-to-music --prompt "warm lo-fi piano" --duration 30 --variants 3 --output take.m4a
83
+ # writes take.0.m4a, take.1.m4a, take.2.m4a
84
+
85
+ - `--variants` above 1 forces the async submit-and-poll path (see [Notes](#notes) above).
86
+ - With `--variants` unset (or `1`), a command writes the single `--output` file exactly as before
87
+ this flag existed. Above 1, it instead writes one file per variant, with the variant index
88
+ spliced before the extension: `take.m4a` becomes `take.0.m4a`, `take.1.m4a`, etc. — the same
89
+ naming `--stem` and dubbing's per-language output already use.
90
+ - On `video-to-sound` / `video-to-video-sound`, `--stem` is applied per variant too, e.g.
91
+ `take.0.music.m4a`.
92
+
75
93
  ### Scored video
76
94
 
77
95
  `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`):
92
110
  [Segments](#segments).
93
111
  - Neither command exposes `--format`: the output is a video, not an audio file.
94
112
  - For music *and* effects in one call, use `video-to-video-sound` below.
113
+ - `video-to-video-music` also takes `--variants` — see [Variants](#variants) above.
114
+ `video-to-video-sfx` does not.
95
115
 
96
116
  ### Combined soundtracks
97
117
 
@@ -116,6 +136,7 @@ they differ only in what comes back: `video-to-sound` writes the mixed **audio**
116
136
  the combined output, so you can re-balance the mix yourself. With `--output soundtrack.wav`, the
117
137
  music stem lands at `soundtrack.music.m4a`. `music_processed` exists only when `--preserve-speech`
118
138
  or ducking altered the music bed.
139
+ - Both also take `--variants` — see [Variants](#variants) above.
119
140
 
120
141
  ### Dubbing
121
142
 
@@ -4,13 +4,13 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "sonilo-cli"
7
- version = "0.5.0"
7
+ version = "0.7.0"
8
8
  description = "Command-line interface for the Sonilo API: generate music and sound effects from text or video"
9
9
  readme = "README.md"
10
10
  license = "MIT"
11
11
  requires-python = ">=3.9"
12
12
  authors = [{ name = "Sonilo AI" }]
13
- dependencies = ["sonilo>=0.7.0,<0.8"]
13
+ dependencies = ["sonilo>=0.10.0,<0.11"]
14
14
  keywords = ["sonilo", "cli", "music", "sfx", "text-to-music", "video-to-music", "ai"]
15
15
 
16
16
  [project.urls]
@@ -1,3 +1,3 @@
1
- __version__ = "0.5.0"
1
+ __version__ = "0.7.0"
2
2
 
3
3
  __all__ = ["__version__"]
@@ -221,9 +221,33 @@ def _music_output(args: argparse.Namespace, fmt: str) -> str:
221
221
  return args.output if args.output is not None else f"output.{fmt}"
222
222
 
223
223
 
224
+ def _variant_path(out: str, index: int) -> str:
225
+ """Turn one --output value into a per-variant path: `clip.mp4` + `1`
226
+ becomes `clip.1.mp4`. Same transform as _stem_path/_language_path, used
227
+ whenever --variants > 1 fans a single --output into one file per variant."""
228
+ base = Path(out)
229
+ return str(base.with_name(f"{base.stem}.{index}{base.suffix}"))
230
+
231
+
232
+ def _save_music_variants(result: Any, out: str) -> None:
233
+ """Save every entry of an async music result's `audio` list. With
234
+ --variants unset (or 1) this is a single file at `out`, byte-identical
235
+ to the pre-variants behaviour; with --variants > 1 it fans out to
236
+ `out.0.ext`, `out.1.ext`, etc."""
237
+ count = len(result.audio or [])
238
+ if count <= 1:
239
+ path = result.save(out)
240
+ _wrote(path, path.stat().st_size)
241
+ return
242
+ for index in range(count):
243
+ path = result.save(_variant_path(out, index), index=index)
244
+ _wrote(path, path.stat().st_size)
245
+
246
+
224
247
  def cmd_text_to_music(client: Sonilo, args: argparse.Namespace) -> None:
225
248
  fmt = args.format
226
- use_async = args.use_async or fmt == "wav"
249
+ multi = args.variants is not None and args.variants > 1
250
+ use_async = args.use_async or fmt != "m4a" or multi
227
251
  out = _music_output(args, fmt)
228
252
  segments = _segments(args)
229
253
  if use_async:
@@ -231,10 +255,10 @@ def cmd_text_to_music(client: Sonilo, args: argparse.Namespace) -> None:
231
255
  prompt=args.prompt,
232
256
  duration=args.duration,
233
257
  segments=segments,
234
- output_format="wav" if fmt == "wav" else None,
258
+ output_format=fmt if fmt != "m4a" else None,
259
+ variants_num=args.variants,
235
260
  )
236
- path = result.save(out)
237
- _wrote(path, path.stat().st_size)
261
+ _save_music_variants(result, out)
238
262
  else:
239
263
  track = client.text_to_music.generate(
240
264
  prompt=args.prompt, duration=args.duration, segments=segments
@@ -245,7 +269,10 @@ def cmd_text_to_music(client: Sonilo, args: argparse.Namespace) -> None:
245
269
 
246
270
  def cmd_video_to_music(client: Sonilo, args: argparse.Namespace) -> None:
247
271
  fmt = args.format
248
- use_async = args.use_async or fmt == "wav" or args.isolate_vocals or args.preserve_speech
272
+ multi = args.variants is not None and args.variants > 1
273
+ use_async = (
274
+ args.use_async or fmt != "m4a" or args.isolate_vocals or args.preserve_speech or multi
275
+ )
249
276
  out = _music_output(args, fmt)
250
277
  segments = _segments(args)
251
278
  if use_async:
@@ -256,10 +283,10 @@ def cmd_video_to_music(client: Sonilo, args: argparse.Namespace) -> None:
256
283
  segments=segments,
257
284
  isolate_vocals=args.isolate_vocals or None,
258
285
  preserve_speech=args.preserve_speech or None,
259
- output_format="wav" if fmt == "wav" else None,
286
+ output_format=fmt if fmt != "m4a" else None,
287
+ variants_num=args.variants,
260
288
  )
261
- path = result.save(out)
262
- _wrote(path, path.stat().st_size)
289
+ _save_music_variants(result, out)
263
290
  else:
264
291
  track = client.video_to_music.generate(
265
292
  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_
312
339
  segments=_segments(args),
313
340
  preserve_speech=True if args.preserve_speech else None,
314
341
  ducking=False if args.no_ducking else None,
342
+ variants_num=args.variants,
315
343
  )
316
- path = result.save(out)
317
- _wrote(path, path.stat().st_size)
318
- for stem in args.stems or []:
319
- stem_path = _stem_path(out, stem, getattr(result, stem, None))
320
- saved = result.save_stem(stem_path, which=stem)
321
- _wrote(saved, saved.stat().st_size)
344
+ multi = args.variants is not None and args.variants > 1 and len(result.outputs) > 1
345
+ if not multi:
346
+ path = result.save(out)
347
+ _wrote(path, path.stat().st_size)
348
+ for stem in args.stems or []:
349
+ stem_path = _stem_path(out, stem, getattr(result, stem, None))
350
+ saved = result.save_stem(stem_path, which=stem)
351
+ _wrote(saved, saved.stat().st_size)
352
+ return
353
+ for index, entry in enumerate(result.outputs):
354
+ variant_out = _variant_path(out, index)
355
+ path = result.save(variant_out, index=index)
356
+ _wrote(path, path.stat().st_size)
357
+ for stem in args.stems or []:
358
+ stem_path = _stem_path(variant_out, stem, getattr(entry, stem, None))
359
+ saved = result.save_stem(stem_path, which=stem, index=index)
360
+ _wrote(saved, saved.stat().st_size)
322
361
 
323
362
 
324
363
  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:
330
369
 
331
370
 
332
371
  def _run_video(args: argparse.Namespace, resource: Any, **params: Any) -> None:
333
- """Run one of the video-returning endpoints and save the single result.
372
+ """Run one of the video-returning endpoints and save the result.
334
373
 
335
- These return the source picture with the generated audio muxed in — one
336
- file, no stems — so the default destination is an `.mp4`, matching
337
- video-to-video-sound.
374
+ These return the source picture with the generated audio muxed in — no
375
+ stems — so the default destination is an `.mp4`, matching
376
+ video-to-video-sound. A `variants_num` in `params` fans out into one
377
+ indexed file per variant when it is greater than 1; otherwise this stays
378
+ the single-file save from before variants existed.
338
379
  """
339
380
  out = args.output if args.output is not None else "output.mp4"
340
381
  result = resource.generate(video=args.video, video_url=args.video_url, **params)
382
+ variants = params.get("variants_num")
383
+ if variants is not None and variants > 1 and len(result.videos) > 1:
384
+ for index in range(len(result.videos)):
385
+ path = result.save(_variant_path(out, index), index=index)
386
+ _wrote(path, path.stat().st_size)
387
+ return
341
388
  path = result.save(out)
342
389
  _wrote(path, path.stat().st_size)
343
390
 
@@ -351,6 +398,7 @@ def cmd_video_to_video_music(client: Sonilo, args: argparse.Namespace) -> None:
351
398
  # same reasoning as --no-ducking on the sound commands.
352
399
  preserve_speech=True if args.preserve_speech else None,
353
400
  isolate_vocals=True if args.isolate_vocals else None,
401
+ variants_num=args.variants,
354
402
  )
355
403
 
356
404
 
@@ -459,6 +507,17 @@ def _add_segments(parser: argparse.ArgumentParser, shape: _SegmentShape) -> None
459
507
  parser.set_defaults(segments_shape=shape)
460
508
 
461
509
 
510
+ def _add_variants(parser: argparse.ArgumentParser) -> None:
511
+ parser.add_argument(
512
+ "--variants", type=int, default=None,
513
+ help="How many distinct variants to generate in one request, 1-10 "
514
+ "(default 1). Cost scales linearly, and values above 1 are never "
515
+ "covered by the free trial. Values above 1 force async and write "
516
+ "one indexed file per variant (output.0.ext, output.1.ext, ...) "
517
+ "instead of a single --output.",
518
+ )
519
+
520
+
462
521
  def build_parser() -> argparse.ArgumentParser:
463
522
  parser = _Parser(prog="sonilo", description="Command-line interface for the Sonilo API")
464
523
  parser.add_argument("--version", action="version", version=__version__)
@@ -480,10 +539,11 @@ def build_parser() -> argparse.ArgumentParser:
480
539
  p_t2m.add_argument("--duration", type=int, required=True, help="Track length in seconds.")
481
540
  _add_segments(p_t2m, MUSIC_SHAPE)
482
541
  p_t2m.add_argument("--output", default=None, help="Where to save the audio.")
483
- p_t2m.add_argument("--format", choices=["m4a", "wav"], default="m4a",
484
- help="Output container. wav forces async. Default: m4a")
542
+ p_t2m.add_argument("--format", choices=["m4a", "wav", "mp3"], default="m4a",
543
+ help="Output container. Anything but m4a forces async. mp3 is 320 kbps. Default: m4a")
485
544
  p_t2m.add_argument("--async", dest="use_async", action="store_true",
486
545
  help="Submit and poll instead of streaming.")
546
+ _add_variants(p_t2m)
487
547
  p_t2m.set_defaults(func=cmd_text_to_music)
488
548
 
489
549
  p_v2m = sub.add_parser("video-to-music", help="Generate music matched to a video")
@@ -492,8 +552,8 @@ def build_parser() -> argparse.ArgumentParser:
492
552
  p_v2m.add_argument("--prompt", default=None, help="Optional creative direction.")
493
553
  _add_segments(p_v2m, MUSIC_SHAPE)
494
554
  p_v2m.add_argument("--output", default=None, help="Where to save the audio.")
495
- p_v2m.add_argument("--format", choices=["m4a", "wav"], default="m4a",
496
- help="Output container. wav forces async.")
555
+ p_v2m.add_argument("--format", choices=["m4a", "wav", "mp3"], default="m4a",
556
+ help="Output container. Anything but m4a forces async. mp3 is 320 kbps.")
497
557
  p_v2m.add_argument("--preserve-speech", dest="preserve_speech", action="store_true",
498
558
  help="Keep source speech in the mix. Forces async.")
499
559
  # The API ORs isolate_vocals into preserve_speech (video_to_music.py:
@@ -504,6 +564,7 @@ def build_parser() -> argparse.ArgumentParser:
504
564
  help="Legacy alias for --preserve-speech. Forces async.")
505
565
  p_v2m.add_argument("--async", dest="use_async", action="store_true",
506
566
  help="Submit and poll instead of streaming.")
567
+ _add_variants(p_v2m)
507
568
  p_v2m.set_defaults(func=cmd_video_to_music)
508
569
 
509
570
  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:
542
603
  p_v2sd.add_argument("--stem", dest="stems", action="append", choices=_SOUND_STEMS,
543
604
  default=None, help="Also save an individual stem. Repeatable.")
544
605
  p_v2sd.add_argument("--output", default=None, help="Where to save the combined audio.")
606
+ _add_variants(p_v2sd)
545
607
  p_v2sd.set_defaults(func=cmd_video_to_sound)
546
608
 
547
609
  p_v2vm = sub.add_parser(
@@ -559,6 +621,7 @@ def build_parser() -> argparse.ArgumentParser:
559
621
  p_v2vm.add_argument("--isolate-vocals", dest="isolate_vocals", action="store_true",
560
622
  help="Legacy alias for --preserve-speech; no separate stem.")
561
623
  p_v2vm.add_argument("--output", default=None, help="Where to save the scored video.")
624
+ _add_variants(p_v2vm)
562
625
  p_v2vm.set_defaults(func=cmd_video_to_video_music)
563
626
 
564
627
  p_v2vfx = sub.add_parser(
@@ -588,6 +651,7 @@ def build_parser() -> argparse.ArgumentParser:
588
651
  p_v2vsd.add_argument("--stem", dest="stems", action="append", choices=_SOUND_STEMS,
589
652
  default=None, help="Also save an individual stem. Repeatable.")
590
653
  p_v2vsd.add_argument("--output", default=None, help="Where to save the combined video.")
654
+ _add_variants(p_v2vsd)
591
655
  p_v2vsd.set_defaults(func=cmd_video_to_video_sound)
592
656
 
593
657
  p_dub = sub.add_parser("dubbing", help="Dub a video into other languages")
@@ -161,6 +161,39 @@ def test_text_to_music_wav_forces_async(tmp_path):
161
161
  assert out.read_bytes() == b"RIF"
162
162
 
163
163
 
164
+ @respx.mock
165
+ def test_text_to_music_variants_forces_async_and_writes_indexed_files(tmp_path):
166
+ submit = respx.post(f"{BASE}/v1/text-to-music").mock(
167
+ return_value=httpx.Response(200, json={"task_id": "tv1", "status": "processing"})
168
+ )
169
+ respx.get(f"{BASE}/v1/tasks/tv1").mock(
170
+ return_value=httpx.Response(200, json={
171
+ "task_id": "tv1", "type": "text_to_music", "status": "succeeded",
172
+ "variants_num": 2,
173
+ "audio": [
174
+ {"stream_index": 0, "url": "https://r2.example.com/tv1.0.m4a"},
175
+ {"stream_index": 1, "url": "https://r2.example.com/tv1.1.m4a"},
176
+ ],
177
+ })
178
+ )
179
+ respx.get("https://r2.example.com/tv1.0.m4a").mock(
180
+ return_value=httpx.Response(200, content=b"A0")
181
+ )
182
+ respx.get("https://r2.example.com/tv1.1.m4a").mock(
183
+ return_value=httpx.Response(200, content=b"A1")
184
+ )
185
+ out = tmp_path / "take.m4a"
186
+ run(["text-to-music", "--prompt", "lofi", "--duration", "10",
187
+ "--variants", "2", "--output", str(out)])
188
+ # --variants > 1 must force the async submit-and-poll path, same as --format wav.
189
+ assert submit.called
190
+ body = submit.calls.last.request.content.decode()
191
+ assert "variants_num=2" in body
192
+ assert (tmp_path / "take.0.m4a").read_bytes() == b"A0"
193
+ assert (tmp_path / "take.1.m4a").read_bytes() == b"A1"
194
+ assert not out.exists()
195
+
196
+
164
197
  def test_video_to_music_requires_a_video_source():
165
198
  with pytest.raises(SystemExit) as exc:
166
199
  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):
394
427
  assert "preserve_speech=true" in body
395
428
 
396
429
 
430
+ @respx.mock
431
+ def test_video_to_sound_variants_writes_indexed_files_and_stems(tmp_path):
432
+ respx.post(f"{BASE}/v1/video-to-sound").mock(
433
+ return_value=httpx.Response(200, json={"task_id": "sv1", "status": "processing"})
434
+ )
435
+ respx.get(f"{BASE}/v1/tasks/sv1").mock(
436
+ return_value=httpx.Response(200, json={
437
+ "task_id": "sv1", "type": "video_to_sound", "status": "succeeded",
438
+ "variants_num": 2,
439
+ "output_url": "https://r2.example.com/sv1.0.wav",
440
+ "output_type": "audio", "output_bytes": 5,
441
+ "music": {"url": "https://r2.example.com/sv1.0.music.m4a"},
442
+ "sfx": {"url": "https://r2.example.com/sv1.0.sfx.wav"},
443
+ "outputs": [
444
+ {
445
+ "variant_index": 0,
446
+ "output_url": "https://r2.example.com/sv1.0.wav",
447
+ "output_type": "audio", "output_bytes": 5,
448
+ "music": {"url": "https://r2.example.com/sv1.0.music.m4a"},
449
+ "sfx": {"url": "https://r2.example.com/sv1.0.sfx.wav"},
450
+ },
451
+ {
452
+ "variant_index": 1,
453
+ "output_url": "https://r2.example.com/sv1.1.wav",
454
+ "output_type": "audio", "output_bytes": 6,
455
+ "music": {"url": "https://r2.example.com/sv1.1.music.m4a"},
456
+ "sfx": {"url": "https://r2.example.com/sv1.1.sfx.wav"},
457
+ },
458
+ ],
459
+ })
460
+ )
461
+ for url, content in [
462
+ ("https://r2.example.com/sv1.0.wav", b"O0"),
463
+ ("https://r2.example.com/sv1.1.wav", b"O1"),
464
+ ("https://r2.example.com/sv1.0.music.m4a", b"M0"),
465
+ ("https://r2.example.com/sv1.1.music.m4a", b"M1"),
466
+ ]:
467
+ respx.get(url).mock(return_value=httpx.Response(200, content=content))
468
+ out = tmp_path / "s.wav"
469
+ run(["video-to-sound", "--video-url", "http://x/y.mp4", "--variants", "2",
470
+ "--output", str(out), "--stem", "music"])
471
+ assert (tmp_path / "s.0.wav").read_bytes() == b"O0"
472
+ assert (tmp_path / "s.1.wav").read_bytes() == b"O1"
473
+ assert (tmp_path / "s.0.music.m4a").read_bytes() == b"M0"
474
+ assert (tmp_path / "s.1.music.m4a").read_bytes() == b"M1"
475
+ assert not out.exists()
476
+
477
+
397
478
  @respx.mock
398
479
  def test_video_to_video_sound_defaults_to_mp4(tmp_path, monkeypatch):
399
480
  monkeypatch.chdir(tmp_path)
@@ -875,6 +956,38 @@ def test_video_to_video_music_rejects_both_sources():
875
956
  assert exc.value.code == 1
876
957
 
877
958
 
959
+ @respx.mock
960
+ def test_video_to_video_music_variants_writes_indexed_files(tmp_path):
961
+ route = respx.post(f"{BASE}/v1/video-to-video-music").mock(
962
+ return_value=httpx.Response(202, json={"task_id": "vm5", "status": "processing"})
963
+ )
964
+ respx.get(f"{BASE}/v1/tasks/vm5").mock(
965
+ return_value=httpx.Response(200, json={
966
+ "task_id": "vm5", "type": "video_to_video_music", "status": "succeeded",
967
+ "variants_num": 2,
968
+ "videos": [
969
+ {"url": "https://r2.example.com/vm5.0.mp4"},
970
+ {"url": "https://r2.example.com/vm5.1.mp4"},
971
+ ],
972
+ "video": {"url": "https://r2.example.com/vm5.0.mp4"},
973
+ })
974
+ )
975
+ respx.get("https://r2.example.com/vm5.0.mp4").mock(
976
+ return_value=httpx.Response(200, content=b"V0")
977
+ )
978
+ respx.get("https://r2.example.com/vm5.1.mp4").mock(
979
+ return_value=httpx.Response(200, content=b"V1")
980
+ )
981
+ out = tmp_path / "scored.mp4"
982
+ run(["video-to-video-music", "--video-url", "http://x/y.mp4",
983
+ "--variants", "2", "--output", str(out)])
984
+ assert (tmp_path / "scored.0.mp4").read_bytes() == b"V0"
985
+ assert (tmp_path / "scored.1.mp4").read_bytes() == b"V1"
986
+ assert not out.exists()
987
+ body = route.calls.last.request.content.decode()
988
+ assert "variants_num=2" in body
989
+
990
+
878
991
  def test_video_to_video_music_rejects_segments(capsys):
879
992
  """The endpoint scores the whole clip in one pass — the SDK resource takes
880
993
  no `segments`, so the CLI must not offer the flag."""
File without changes
File without changes
File without changes