sonilo-cli 0.5.0__tar.gz → 0.8.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.8.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.12,>=0.11.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.8.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.11.0,<0.12"]
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.8.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,
@@ -304,6 +331,13 @@ def _stem_path(out: str, stem: str, media: Any) -> str:
304
331
 
305
332
  def _run_sound(client: Sonilo, args: argparse.Namespace, resource: Any, default_ext: str) -> None:
306
333
  out = args.output if args.output is not None else f"output.{default_ext}"
334
+ # video-to-video-sound only. Its parser is the only one that defines the
335
+ # flag, and video_to_sound.generate() does not accept the keyword at all,
336
+ # so it has to be omitted here rather than forwarded as None — the same
337
+ # split the SDK enforces by never passing it from the audio resource.
338
+ extra: Dict[str, Any] = {}
339
+ if getattr(args, "keep_original_sound", False):
340
+ extra["keep_original_sound"] = True
307
341
  result = resource.generate(
308
342
  video=args.video,
309
343
  video_url=args.video_url,
@@ -312,13 +346,26 @@ def _run_sound(client: Sonilo, args: argparse.Namespace, resource: Any, default_
312
346
  segments=_segments(args),
313
347
  preserve_speech=True if args.preserve_speech else None,
314
348
  ducking=False if args.no_ducking else None,
349
+ variants_num=args.variants,
350
+ **extra,
315
351
  )
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)
352
+ multi = args.variants is not None and args.variants > 1 and len(result.outputs) > 1
353
+ if not multi:
354
+ path = result.save(out)
355
+ _wrote(path, path.stat().st_size)
356
+ for stem in args.stems or []:
357
+ stem_path = _stem_path(out, stem, getattr(result, stem, None))
358
+ saved = result.save_stem(stem_path, which=stem)
359
+ _wrote(saved, saved.stat().st_size)
360
+ return
361
+ for index, entry in enumerate(result.outputs):
362
+ variant_out = _variant_path(out, index)
363
+ path = result.save(variant_out, index=index)
364
+ _wrote(path, path.stat().st_size)
365
+ for stem in args.stems or []:
366
+ stem_path = _stem_path(variant_out, stem, getattr(entry, stem, None))
367
+ saved = result.save_stem(stem_path, which=stem, index=index)
368
+ _wrote(saved, saved.stat().st_size)
322
369
 
323
370
 
324
371
  def cmd_video_to_sound(client: Sonilo, args: argparse.Namespace) -> None:
@@ -330,14 +377,22 @@ def cmd_video_to_video_sound(client: Sonilo, args: argparse.Namespace) -> None:
330
377
 
331
378
 
332
379
  def _run_video(args: argparse.Namespace, resource: Any, **params: Any) -> None:
333
- """Run one of the video-returning endpoints and save the single result.
380
+ """Run one of the video-returning endpoints and save the result.
334
381
 
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.
382
+ These return the source picture with the generated audio muxed in — no
383
+ stems — so the default destination is an `.mp4`, matching
384
+ video-to-video-sound. A `variants_num` in `params` fans out into one
385
+ indexed file per variant when it is greater than 1; otherwise this stays
386
+ the single-file save from before variants existed.
338
387
  """
339
388
  out = args.output if args.output is not None else "output.mp4"
340
389
  result = resource.generate(video=args.video, video_url=args.video_url, **params)
390
+ variants = params.get("variants_num")
391
+ if variants is not None and variants > 1 and len(result.videos) > 1:
392
+ for index in range(len(result.videos)):
393
+ path = result.save(_variant_path(out, index), index=index)
394
+ _wrote(path, path.stat().st_size)
395
+ return
341
396
  path = result.save(out)
342
397
  _wrote(path, path.stat().st_size)
343
398
 
@@ -348,9 +403,15 @@ def cmd_video_to_video_music(client: Sonilo, args: argparse.Namespace) -> None:
348
403
  client.video_to_video_music,
349
404
  prompt=args.prompt,
350
405
  # Unset flags forward None, not False, so the server default stands —
351
- # same reasoning as --no-ducking on the sound commands.
406
+ # same reasoning as --no-ducking on the sound commands. The two
407
+ # defaults run opposite ways: ducking is default-ON server-side and
408
+ # keep_original_sound default-OFF, so each is only ever sent to change
409
+ # the default, never to restate it.
410
+ keep_original_sound=True if args.keep_original_sound else None,
411
+ ducking=False if args.no_ducking else None,
352
412
  preserve_speech=True if args.preserve_speech else None,
353
413
  isolate_vocals=True if args.isolate_vocals else None,
414
+ variants_num=args.variants,
354
415
  )
355
416
 
356
417
 
@@ -459,6 +520,17 @@ def _add_segments(parser: argparse.ArgumentParser, shape: _SegmentShape) -> None
459
520
  parser.set_defaults(segments_shape=shape)
460
521
 
461
522
 
523
+ def _add_variants(parser: argparse.ArgumentParser) -> None:
524
+ parser.add_argument(
525
+ "--variants", type=int, default=None,
526
+ help="How many distinct variants to generate in one request, 1-10 "
527
+ "(default 1). Cost scales linearly, and values above 1 are never "
528
+ "covered by the free trial. Values above 1 force async and write "
529
+ "one indexed file per variant (output.0.ext, output.1.ext, ...) "
530
+ "instead of a single --output.",
531
+ )
532
+
533
+
462
534
  def build_parser() -> argparse.ArgumentParser:
463
535
  parser = _Parser(prog="sonilo", description="Command-line interface for the Sonilo API")
464
536
  parser.add_argument("--version", action="version", version=__version__)
@@ -480,10 +552,11 @@ def build_parser() -> argparse.ArgumentParser:
480
552
  p_t2m.add_argument("--duration", type=int, required=True, help="Track length in seconds.")
481
553
  _add_segments(p_t2m, MUSIC_SHAPE)
482
554
  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")
555
+ p_t2m.add_argument("--format", choices=["m4a", "wav", "mp3"], default="m4a",
556
+ help="Output container. Anything but m4a forces async. mp3 is 320 kbps. Default: m4a")
485
557
  p_t2m.add_argument("--async", dest="use_async", action="store_true",
486
558
  help="Submit and poll instead of streaming.")
559
+ _add_variants(p_t2m)
487
560
  p_t2m.set_defaults(func=cmd_text_to_music)
488
561
 
489
562
  p_v2m = sub.add_parser("video-to-music", help="Generate music matched to a video")
@@ -492,8 +565,8 @@ def build_parser() -> argparse.ArgumentParser:
492
565
  p_v2m.add_argument("--prompt", default=None, help="Optional creative direction.")
493
566
  _add_segments(p_v2m, MUSIC_SHAPE)
494
567
  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.")
568
+ p_v2m.add_argument("--format", choices=["m4a", "wav", "mp3"], default="m4a",
569
+ help="Output container. Anything but m4a forces async. mp3 is 320 kbps.")
497
570
  p_v2m.add_argument("--preserve-speech", dest="preserve_speech", action="store_true",
498
571
  help="Keep source speech in the mix. Forces async.")
499
572
  # The API ORs isolate_vocals into preserve_speech (video_to_music.py:
@@ -504,6 +577,7 @@ def build_parser() -> argparse.ArgumentParser:
504
577
  help="Legacy alias for --preserve-speech. Forces async.")
505
578
  p_v2m.add_argument("--async", dest="use_async", action="store_true",
506
579
  help="Submit and poll instead of streaming.")
580
+ _add_variants(p_v2m)
507
581
  p_v2m.set_defaults(func=cmd_video_to_music)
508
582
 
509
583
  p_t2s = sub.add_parser("text-to-sfx", help="Generate a sound effect from a text prompt")
@@ -542,6 +616,7 @@ def build_parser() -> argparse.ArgumentParser:
542
616
  p_v2sd.add_argument("--stem", dest="stems", action="append", choices=_SOUND_STEMS,
543
617
  default=None, help="Also save an individual stem. Repeatable.")
544
618
  p_v2sd.add_argument("--output", default=None, help="Where to save the combined audio.")
619
+ _add_variants(p_v2sd)
545
620
  p_v2sd.set_defaults(func=cmd_video_to_sound)
546
621
 
547
622
  p_v2vm = sub.add_parser(
@@ -550,8 +625,18 @@ def build_parser() -> argparse.ArgumentParser:
550
625
  _add_global(p_v2vm)
551
626
  _add_video_source(p_v2vm)
552
627
  p_v2vm.add_argument("--prompt", default=None, help="Optional creative direction.")
628
+ p_v2vm.add_argument("--keep-original-sound", dest="keep_original_sound",
629
+ action="store_true",
630
+ help="Keep the source video's whole original audio, with the "
631
+ "generated music under it. Off by default, so by default "
632
+ "the result's audio is the generated music alone. "
633
+ "Supersedes --preserve-speech.")
634
+ p_v2vm.add_argument("--no-ducking", dest="no_ducking", action="store_true",
635
+ help="Combine the voice and the music as a static mix instead "
636
+ "of a dynamic duck. No effect without "
637
+ "--keep-original-sound or --preserve-speech.")
553
638
  p_v2vm.add_argument("--preserve-speech", dest="preserve_speech", action="store_true",
554
- help="Keep source speech in the mix.")
639
+ help="Keep only the source's isolated speech in the mix.")
555
640
  # Same aliasing as video-to-music, and here the endpoint collapses the two
556
641
  # into a single boolean before it reaches the model (video_to_video.py:
557
642
  # `keep_speech = bool(preserve_speech) or bool(isolate_vocals)`), with no
@@ -559,6 +644,7 @@ def build_parser() -> argparse.ArgumentParser:
559
644
  p_v2vm.add_argument("--isolate-vocals", dest="isolate_vocals", action="store_true",
560
645
  help="Legacy alias for --preserve-speech; no separate stem.")
561
646
  p_v2vm.add_argument("--output", default=None, help="Where to save the scored video.")
647
+ _add_variants(p_v2vm)
562
648
  p_v2vm.set_defaults(func=cmd_video_to_video_music)
563
649
 
564
650
  p_v2vfx = sub.add_parser(
@@ -581,13 +667,23 @@ def build_parser() -> argparse.ArgumentParser:
581
667
  p_v2vsd.add_argument("--sfx-prompt", dest="sfx_prompt", default=None,
582
668
  help="Optional creative direction for the sound effects.")
583
669
  _add_segments(p_v2vsd, SFX_SHAPE)
670
+ p_v2vsd.add_argument("--keep-original-sound", dest="keep_original_sound",
671
+ action="store_true",
672
+ help="Keep the source video's whole original audio, with the "
673
+ "generated music and effects under it. Off by default, so "
674
+ "by default the result's audio is the generated audio "
675
+ "alone and there is no music_processed stem. Supersedes "
676
+ "--preserve-speech. This command only.")
584
677
  p_v2vsd.add_argument("--preserve-speech", dest="preserve_speech", action="store_true",
585
- help="Keep source speech in the mix.")
678
+ help="Keep only the source's isolated speech in the mix.")
586
679
  p_v2vsd.add_argument("--no-ducking", dest="no_ducking", action="store_true",
587
- help="Disable automatic ducking (on by default server-side).")
680
+ help="Combine the voice and the generated bed as a static mix "
681
+ "instead of a dynamic duck. No effect without "
682
+ "--keep-original-sound or --preserve-speech.")
588
683
  p_v2vsd.add_argument("--stem", dest="stems", action="append", choices=_SOUND_STEMS,
589
684
  default=None, help="Also save an individual stem. Repeatable.")
590
685
  p_v2vsd.add_argument("--output", default=None, help="Where to save the combined video.")
686
+ _add_variants(p_v2vsd)
591
687
  p_v2vsd.set_defaults(func=cmd_video_to_video_sound)
592
688
 
593
689
  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