sonilo-cli 0.4.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.4.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'
@@ -41,6 +41,8 @@ or pass `--api-key sk-...` on any command.
41
41
  sonilo video-to-sfx --video clip.mp4 --segments @segments.json
42
42
  sonilo video-to-sound --video clip.mp4 \
43
43
  --music-prompt "uplifting orchestral score" --sfx-prompt "match the on-screen action"
44
+ sonilo video-to-video-music --video clip.mp4 --prompt "tense synths" --output scored.mp4
45
+ sonilo video-to-video-sfx --video clip.mp4 --segments @segments.json --output scored.mp4
44
46
  sonilo video-to-video-sound --video clip.mp4 --music-prompt "tense synths"
45
47
  sonilo dubbing --video-url https://example.com/clip.mp4 --languages es,fr --output dubbed.mp4
46
48
  # writes dubbed.es.mp4 and dubbed.fr.mp4
@@ -50,7 +52,8 @@ or pass `--api-key sk-...` on any command.
50
52
  ### Notes
51
53
 
52
54
  - `text-to-music` / `video-to-music` stream a short `.m4a` by default. `--format wav`,
53
- `--isolate-vocals`, and `--preserve-speech` each switch to the async 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.
54
57
  - `text-to-sfx` / `video-to-sfx` are always async; `--format` accepts `wav|mp3|aac|flac`.
55
58
  - Output defaults to `./output.<ext>`; override with `--output`.
56
59
 
@@ -72,7 +75,7 @@ The two segment shapes are **not** interchangeable:
72
75
  | Shape | Commands | Fields |
73
76
  | --- | --- | --- |
74
77
  | Music | `text-to-music`, `video-to-music` | `{start, prompt, label?}` |
75
- | SFX | `video-to-sfx`, `video-to-sound`, `video-to-video-sound` | `{start, end, prompt}` |
78
+ | SFX | `video-to-sfx`, `video-to-video-sfx`, `video-to-sound`, `video-to-video-sound` | `{start, end, prompt}` |
76
79
 
77
80
  - `start` / `end` are seconds from the start of the track or clip.
78
81
  - Passing one shape to a command that takes the other is rejected before any request is made, with
@@ -83,6 +86,48 @@ The two segment shapes are **not** interchangeable:
83
86
  - Keys the CLI does not recognise are forwarded as-is, so a newly added API field works without
84
87
  upgrading the CLI.
85
88
  - `text-to-sfx` takes no segments (its output is a single effect, not a timeline).
89
+ - `video-to-video-music` takes no segments either — the API scores the whole clip in one pass.
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
+
109
+ ### Scored video
110
+
111
+ `video-to-video-music` and `video-to-video-sfx` are the video-out counterparts of `video-to-music`
112
+ and `video-to-sfx`: same generation, but what comes back is the source picture with the new audio
113
+ already muxed in, so there is nothing to line up afterwards. Both are async-only and write a single
114
+ file (default `output.mp4`):
115
+
116
+ sonilo video-to-video-music --video clip.mp4 --prompt "tense synths" --output scored.mp4
117
+ sonilo video-to-video-sfx --video clip.mp4 \
118
+ --segments '[{"start":0,"end":5,"prompt":"footsteps on gravel"}]' --output scored.mp4
119
+
120
+ - `--prompt` is optional on both; without it the model scores from the picture alone.
121
+ - `video-to-video-music` also takes `--preserve-speech`, which keeps source speech in the mix;
122
+ omitting it leaves the server default untouched. `--isolate-vocals` is a legacy alias for the
123
+ same flag — the API ORs the two together, and this endpoint returns one muxed video with no
124
+ separate vocals stem.
125
+ - `video-to-video-sfx` takes `--segments` in the SFX shape `{start, end, prompt}` — see
126
+ [Segments](#segments).
127
+ - Neither command exposes `--format`: the output is a video, not an audio file.
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.
86
131
 
87
132
  ### Combined soundtracks
88
133
 
@@ -107,6 +152,7 @@ they differ only in what comes back: `video-to-sound` writes the mixed **audio**
107
152
  the combined output, so you can re-balance the mix yourself. With `--output soundtrack.wav`, the
108
153
  music stem lands at `soundtrack.music.m4a`. `music_processed` exists only when `--preserve-speech`
109
154
  or ducking altered the music bed.
155
+ - Both also take `--variants` — see [Variants](#variants) above.
110
156
 
111
157
  ### Dubbing
112
158
 
@@ -25,6 +25,8 @@ or pass `--api-key sk-...` on any command.
25
25
  sonilo video-to-sfx --video clip.mp4 --segments @segments.json
26
26
  sonilo video-to-sound --video clip.mp4 \
27
27
  --music-prompt "uplifting orchestral score" --sfx-prompt "match the on-screen action"
28
+ sonilo video-to-video-music --video clip.mp4 --prompt "tense synths" --output scored.mp4
29
+ sonilo video-to-video-sfx --video clip.mp4 --segments @segments.json --output scored.mp4
28
30
  sonilo video-to-video-sound --video clip.mp4 --music-prompt "tense synths"
29
31
  sonilo dubbing --video-url https://example.com/clip.mp4 --languages es,fr --output dubbed.mp4
30
32
  # writes dubbed.es.mp4 and dubbed.fr.mp4
@@ -34,7 +36,8 @@ or pass `--api-key sk-...` on any command.
34
36
  ### Notes
35
37
 
36
38
  - `text-to-music` / `video-to-music` stream a short `.m4a` by default. `--format wav`,
37
- `--isolate-vocals`, and `--preserve-speech` each switch to the async 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.
38
41
  - `text-to-sfx` / `video-to-sfx` are always async; `--format` accepts `wav|mp3|aac|flac`.
39
42
  - Output defaults to `./output.<ext>`; override with `--output`.
40
43
 
@@ -56,7 +59,7 @@ The two segment shapes are **not** interchangeable:
56
59
  | Shape | Commands | Fields |
57
60
  | --- | --- | --- |
58
61
  | Music | `text-to-music`, `video-to-music` | `{start, prompt, label?}` |
59
- | SFX | `video-to-sfx`, `video-to-sound`, `video-to-video-sound` | `{start, end, prompt}` |
62
+ | SFX | `video-to-sfx`, `video-to-video-sfx`, `video-to-sound`, `video-to-video-sound` | `{start, end, prompt}` |
60
63
 
61
64
  - `start` / `end` are seconds from the start of the track or clip.
62
65
  - Passing one shape to a command that takes the other is rejected before any request is made, with
@@ -67,6 +70,48 @@ The two segment shapes are **not** interchangeable:
67
70
  - Keys the CLI does not recognise are forwarded as-is, so a newly added API field works without
68
71
  upgrading the CLI.
69
72
  - `text-to-sfx` takes no segments (its output is a single effect, not a timeline).
73
+ - `video-to-video-music` takes no segments either — the API scores the whole clip in one pass.
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
+
93
+ ### Scored video
94
+
95
+ `video-to-video-music` and `video-to-video-sfx` are the video-out counterparts of `video-to-music`
96
+ and `video-to-sfx`: same generation, but what comes back is the source picture with the new audio
97
+ already muxed in, so there is nothing to line up afterwards. Both are async-only and write a single
98
+ file (default `output.mp4`):
99
+
100
+ sonilo video-to-video-music --video clip.mp4 --prompt "tense synths" --output scored.mp4
101
+ sonilo video-to-video-sfx --video clip.mp4 \
102
+ --segments '[{"start":0,"end":5,"prompt":"footsteps on gravel"}]' --output scored.mp4
103
+
104
+ - `--prompt` is optional on both; without it the model scores from the picture alone.
105
+ - `video-to-video-music` also takes `--preserve-speech`, which keeps source speech in the mix;
106
+ omitting it leaves the server default untouched. `--isolate-vocals` is a legacy alias for the
107
+ same flag — the API ORs the two together, and this endpoint returns one muxed video with no
108
+ separate vocals stem.
109
+ - `video-to-video-sfx` takes `--segments` in the SFX shape `{start, end, prompt}` — see
110
+ [Segments](#segments).
111
+ - Neither command exposes `--format`: the output is a video, not an audio file.
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.
70
115
 
71
116
  ### Combined soundtracks
72
117
 
@@ -91,6 +136,7 @@ they differ only in what comes back: `video-to-sound` writes the mixed **audio**
91
136
  the combined output, so you can re-balance the mix yourself. With `--output soundtrack.wav`, the
92
137
  music stem lands at `soundtrack.music.m4a`. `music_processed` exists only when `--preserve-speech`
93
138
  or ducking altered the music bed.
139
+ - Both also take `--variants` — see [Variants](#variants) above.
94
140
 
95
141
  ### Dubbing
96
142
 
@@ -4,13 +4,13 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "sonilo-cli"
7
- version = "0.4.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.4.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:
@@ -329,6 +368,49 @@ def cmd_video_to_video_sound(client: Sonilo, args: argparse.Namespace) -> None:
329
368
  _run_sound(client, args, client.video_to_video_sound, "mp4")
330
369
 
331
370
 
371
+ def _run_video(args: argparse.Namespace, resource: Any, **params: Any) -> None:
372
+ """Run one of the video-returning endpoints and save the result.
373
+
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.
379
+ """
380
+ out = args.output if args.output is not None else "output.mp4"
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
388
+ path = result.save(out)
389
+ _wrote(path, path.stat().st_size)
390
+
391
+
392
+ def cmd_video_to_video_music(client: Sonilo, args: argparse.Namespace) -> None:
393
+ _run_video(
394
+ args,
395
+ client.video_to_video_music,
396
+ prompt=args.prompt,
397
+ # Unset flags forward None, not False, so the server default stands —
398
+ # same reasoning as --no-ducking on the sound commands.
399
+ preserve_speech=True if args.preserve_speech else None,
400
+ isolate_vocals=True if args.isolate_vocals else None,
401
+ variants_num=args.variants,
402
+ )
403
+
404
+
405
+ def cmd_video_to_video_sfx(client: Sonilo, args: argparse.Namespace) -> None:
406
+ _run_video(
407
+ args,
408
+ client.video_to_video_sfx,
409
+ prompt=args.prompt,
410
+ segments=_segments(args),
411
+ )
412
+
413
+
332
414
  # Matched to the dubbing backend's own ceiling: it polls its pipeline for up
333
415
  # to 7200s (2 hours), so anything shorter abandons a job the user has already
334
416
  # been charged for. The SDK's generic DEFAULT_WAIT_TIMEOUT of 600s is far too
@@ -425,6 +507,17 @@ def _add_segments(parser: argparse.ArgumentParser, shape: _SegmentShape) -> None
425
507
  parser.set_defaults(segments_shape=shape)
426
508
 
427
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
+
428
521
  def build_parser() -> argparse.ArgumentParser:
429
522
  parser = _Parser(prog="sonilo", description="Command-line interface for the Sonilo API")
430
523
  parser.add_argument("--version", action="version", version=__version__)
@@ -446,10 +539,11 @@ def build_parser() -> argparse.ArgumentParser:
446
539
  p_t2m.add_argument("--duration", type=int, required=True, help="Track length in seconds.")
447
540
  _add_segments(p_t2m, MUSIC_SHAPE)
448
541
  p_t2m.add_argument("--output", default=None, help="Where to save the audio.")
449
- p_t2m.add_argument("--format", choices=["m4a", "wav"], default="m4a",
450
- 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")
451
544
  p_t2m.add_argument("--async", dest="use_async", action="store_true",
452
545
  help="Submit and poll instead of streaming.")
546
+ _add_variants(p_t2m)
453
547
  p_t2m.set_defaults(func=cmd_text_to_music)
454
548
 
455
549
  p_v2m = sub.add_parser("video-to-music", help="Generate music matched to a video")
@@ -458,14 +552,19 @@ def build_parser() -> argparse.ArgumentParser:
458
552
  p_v2m.add_argument("--prompt", default=None, help="Optional creative direction.")
459
553
  _add_segments(p_v2m, MUSIC_SHAPE)
460
554
  p_v2m.add_argument("--output", default=None, help="Where to save the audio.")
461
- p_v2m.add_argument("--format", choices=["m4a", "wav"], default="m4a",
462
- help="Output container. wav forces async.")
463
- p_v2m.add_argument("--isolate-vocals", dest="isolate_vocals", action="store_true",
464
- help="Split out a vocals-only stem. 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.")
465
557
  p_v2m.add_argument("--preserve-speech", dest="preserve_speech", action="store_true",
466
558
  help="Keep source speech in the mix. Forces async.")
559
+ # The API ORs isolate_vocals into preserve_speech (video_to_music.py:
560
+ # `isolate_vocals = bool(preserve_speech) or bool(isolate_vocals)`), so
561
+ # the two flags are one feature under two names, not two behaviours.
562
+ # isolate_vocals is the legacy name kept for existing callers.
563
+ p_v2m.add_argument("--isolate-vocals", dest="isolate_vocals", action="store_true",
564
+ help="Legacy alias for --preserve-speech. Forces async.")
467
565
  p_v2m.add_argument("--async", dest="use_async", action="store_true",
468
566
  help="Submit and poll instead of streaming.")
567
+ _add_variants(p_v2m)
469
568
  p_v2m.set_defaults(func=cmd_video_to_music)
470
569
 
471
570
  p_t2s = sub.add_parser("text-to-sfx", help="Generate a sound effect from a text prompt")
@@ -504,8 +603,37 @@ def build_parser() -> argparse.ArgumentParser:
504
603
  p_v2sd.add_argument("--stem", dest="stems", action="append", choices=_SOUND_STEMS,
505
604
  default=None, help="Also save an individual stem. Repeatable.")
506
605
  p_v2sd.add_argument("--output", default=None, help="Where to save the combined audio.")
606
+ _add_variants(p_v2sd)
507
607
  p_v2sd.set_defaults(func=cmd_video_to_sound)
508
608
 
609
+ p_v2vm = sub.add_parser(
610
+ "video-to-video-music", help="Generate music muxed into the source video"
611
+ )
612
+ _add_global(p_v2vm)
613
+ _add_video_source(p_v2vm)
614
+ p_v2vm.add_argument("--prompt", default=None, help="Optional creative direction.")
615
+ p_v2vm.add_argument("--preserve-speech", dest="preserve_speech", action="store_true",
616
+ help="Keep source speech in the mix.")
617
+ # Same aliasing as video-to-music, and here the endpoint collapses the two
618
+ # into a single boolean before it reaches the model (video_to_video.py:
619
+ # `keep_speech = bool(preserve_speech) or bool(isolate_vocals)`), with no
620
+ # vocals stem in the result — the output is one muxed video.
621
+ p_v2vm.add_argument("--isolate-vocals", dest="isolate_vocals", action="store_true",
622
+ help="Legacy alias for --preserve-speech; no separate stem.")
623
+ p_v2vm.add_argument("--output", default=None, help="Where to save the scored video.")
624
+ _add_variants(p_v2vm)
625
+ p_v2vm.set_defaults(func=cmd_video_to_video_music)
626
+
627
+ p_v2vfx = sub.add_parser(
628
+ "video-to-video-sfx", help="Generate sound effects muxed into the source video"
629
+ )
630
+ _add_global(p_v2vfx)
631
+ _add_video_source(p_v2vfx)
632
+ p_v2vfx.add_argument("--prompt", default=None, help="Optional creative direction.")
633
+ _add_segments(p_v2vfx, SFX_SHAPE)
634
+ p_v2vfx.add_argument("--output", default=None, help="Where to save the scored video.")
635
+ p_v2vfx.set_defaults(func=cmd_video_to_video_sfx)
636
+
509
637
  p_v2vsd = sub.add_parser(
510
638
  "video-to-video-sound", help="Generate matched music+sfx muxed into the source video"
511
639
  )
@@ -523,6 +651,7 @@ def build_parser() -> argparse.ArgumentParser:
523
651
  p_v2vsd.add_argument("--stem", dest="stems", action="append", choices=_SOUND_STEMS,
524
652
  default=None, help="Also save an individual stem. Repeatable.")
525
653
  p_v2vsd.add_argument("--output", default=None, help="Where to save the combined video.")
654
+ _add_variants(p_v2vsd)
526
655
  p_v2vsd.set_defaults(func=cmd_video_to_video_sound)
527
656
 
528
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)
@@ -764,7 +845,7 @@ def test_segments_semantic_rules_are_left_to_the_server():
764
845
 
765
846
  @pytest.mark.parametrize(
766
847
  "command",
767
- ["text-to-music", "video-to-music", "video-to-sfx",
848
+ ["text-to-music", "video-to-music", "video-to-sfx", "video-to-video-sfx",
768
849
  "video-to-sound", "video-to-video-sound"],
769
850
  )
770
851
  def test_segments_help_shows_all_three_value_forms(command, capsys):
@@ -778,10 +859,224 @@ def test_segments_help_shows_all_three_value_forms(command, capsys):
778
859
 
779
860
  @pytest.mark.parametrize("command", ["text-to-sfx"])
780
861
  def test_commands_without_segments_reject_the_flag(command, capsys):
781
- """text-to-sfx takes no segments in the SDK, so the CLI must not offer it
782
- (video-to-video-music, the other segment-less endpoint, has no CLI
783
- command at all)."""
862
+ """text-to-sfx takes no segments in the SDK, so the CLI must not offer it.
863
+ The other segment-less endpoint, video-to-video-music, is covered by
864
+ test_video_to_video_music_rejects_segments (it takes no --duration)."""
784
865
  with pytest.raises(SystemExit) as exc:
785
866
  run([command, "--prompt", "x", "--duration", "3", "--segments", "[]"])
786
867
  assert exc.value.code == 1
787
868
  assert "unrecognized arguments" in capsys.readouterr().err
869
+
870
+
871
+ # --- video-to-video-music / video-to-video-sfx ---------------------------
872
+ #
873
+ # Both return the source picture with the generated audio muxed in, so the
874
+ # task body carries a single `video` object rather than `audio`/`output_url`
875
+ # (shape confirmed against tests/test_video_to_video.py in the SDK repo).
876
+
877
+
878
+ def _video_body(task_id, task_type):
879
+ return {
880
+ "task_id": task_id,
881
+ "type": task_type,
882
+ "status": "succeeded",
883
+ "video": {"url": f"https://r2.example.com/{task_id}.mp4",
884
+ "content_type": "video/mp4", "file_size": 7},
885
+ "duration_seconds": 4.0,
886
+ }
887
+
888
+
889
+ def _mock_video_task(endpoint, task_id, task_type, content=b"MP4DATA"):
890
+ """Wire up submit + poll + download for one video-returning endpoint."""
891
+ route = respx.post(f"{BASE}/v1/{endpoint}").mock(
892
+ return_value=httpx.Response(202, json={"task_id": task_id, "status": "processing"})
893
+ )
894
+ respx.get(f"{BASE}/v1/tasks/{task_id}").mock(
895
+ return_value=httpx.Response(200, json=_video_body(task_id, task_type))
896
+ )
897
+ respx.get(f"https://r2.example.com/{task_id}.mp4").mock(
898
+ return_value=httpx.Response(200, content=content)
899
+ )
900
+ return route
901
+
902
+
903
+ @respx.mock
904
+ def test_video_to_video_music_saves_the_scored_video(tmp_path):
905
+ route = _mock_video_task("video-to-video-music", "vm1", "video_to_video_music")
906
+ out = tmp_path / "scored.mp4"
907
+ run(["video-to-video-music", "--video-url", "http://x/y.mp4",
908
+ "--prompt", "tense synths", "--output", str(out)])
909
+ assert route.called
910
+ assert out.read_bytes() == b"MP4DATA"
911
+ body = unquote_plus(route.calls.last.request.content.decode())
912
+ assert "video_url=http://x/y.mp4" in body
913
+ assert "prompt=tense synths" in body
914
+
915
+
916
+ @respx.mock
917
+ def test_video_to_video_music_defaults_to_mp4(tmp_path, monkeypatch):
918
+ monkeypatch.chdir(tmp_path)
919
+ _mock_video_task("video-to-video-music", "vm2", "video_to_video_music")
920
+ run(["video-to-video-music", "--video-url", "http://x/y.mp4"])
921
+ assert (tmp_path / "output.mp4").read_bytes() == b"MP4DATA"
922
+
923
+
924
+ @respx.mock
925
+ def test_video_to_video_music_flags_reach_the_request_body(tmp_path):
926
+ route = _mock_video_task("video-to-video-music", "vm3", "video_to_video_music")
927
+ run(["video-to-video-music", "--video-url", "http://x/y.mp4",
928
+ "--preserve-speech", "--isolate-vocals", "--output", str(tmp_path / "s.mp4")])
929
+ body = route.calls.last.request.content.decode()
930
+ assert "preserve_speech=true" in body
931
+ assert "isolate_vocals=true" in body
932
+
933
+
934
+ @respx.mock
935
+ def test_video_to_video_music_unset_flags_are_omitted(tmp_path):
936
+ route = _mock_video_task("video-to-video-music", "vm4", "video_to_video_music")
937
+ run(["video-to-video-music", "--video-url", "http://x/y.mp4",
938
+ "--output", str(tmp_path / "s.mp4")])
939
+ # Unset switches must forward None, not False, so the server default
940
+ # stands — same rule as --no-ducking on the sound commands.
941
+ body = route.calls.last.request.content.decode()
942
+ assert "preserve_speech=" not in body
943
+ assert "isolate_vocals=" not in body
944
+ assert "prompt=" not in body
945
+
946
+
947
+ def test_video_to_video_music_requires_a_video_source():
948
+ with pytest.raises(SystemExit) as exc:
949
+ run(["video-to-video-music", "--prompt", "x"])
950
+ assert exc.value.code == 1
951
+
952
+
953
+ def test_video_to_video_music_rejects_both_sources():
954
+ with pytest.raises(SystemExit) as exc:
955
+ run(["video-to-video-music", "--video", "a.mp4", "--video-url", "http://x/y.mp4"])
956
+ assert exc.value.code == 1
957
+
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
+
991
+ def test_video_to_video_music_rejects_segments(capsys):
992
+ """The endpoint scores the whole clip in one pass — the SDK resource takes
993
+ no `segments`, so the CLI must not offer the flag."""
994
+ with pytest.raises(SystemExit) as exc:
995
+ run(["video-to-video-music", "--video-url", "http://x/y.mp4", "--segments", "[]"])
996
+ assert exc.value.code == 1
997
+ assert "unrecognized arguments" in capsys.readouterr().err
998
+
999
+
1000
+ @respx.mock
1001
+ def test_video_to_video_sfx_saves_the_scored_video(tmp_path):
1002
+ route = _mock_video_task("video-to-video-sfx", "vf1", "video_to_video_sfx")
1003
+ out = tmp_path / "scored.mp4"
1004
+ run(["video-to-video-sfx", "--video-url", "http://x/y.mp4",
1005
+ "--prompt", "footsteps", "--output", str(out)])
1006
+ assert route.called
1007
+ assert out.read_bytes() == b"MP4DATA"
1008
+ body = unquote_plus(route.calls.last.request.content.decode())
1009
+ assert "video_url=http://x/y.mp4" in body
1010
+ assert "prompt=footsteps" in body
1011
+
1012
+
1013
+ @respx.mock
1014
+ def test_video_to_video_sfx_defaults_to_mp4(tmp_path, monkeypatch):
1015
+ monkeypatch.chdir(tmp_path)
1016
+ _mock_video_task("video-to-video-sfx", "vf2", "video_to_video_sfx")
1017
+ run(["video-to-video-sfx", "--video-url", "http://x/y.mp4"])
1018
+ assert (tmp_path / "output.mp4").read_bytes() == b"MP4DATA"
1019
+
1020
+
1021
+ @respx.mock
1022
+ def test_video_to_video_sfx_segments_reach_the_request_body(tmp_path):
1023
+ route = _mock_video_task("video-to-video-sfx", "vf3", "video_to_video_sfx")
1024
+ run(["video-to-video-sfx", "--video-url", "http://x/y.mp4",
1025
+ "--segments", json.dumps(SFX_SEGMENTS), "--output", str(tmp_path / "s.mp4")])
1026
+ assert _sent_segments(route) == SFX_SEGMENTS
1027
+
1028
+
1029
+ @respx.mock
1030
+ def test_video_to_video_sfx_omitting_segments_sends_no_field(tmp_path):
1031
+ route = _mock_video_task("video-to-video-sfx", "vf4", "video_to_video_sfx")
1032
+ run(["video-to-video-sfx", "--video-url", "http://x/y.mp4",
1033
+ "--output", str(tmp_path / "s.mp4")])
1034
+ assert b"segments" not in route.calls.last.request.content
1035
+
1036
+
1037
+ def test_video_to_video_sfx_rejects_music_shaped_segments(capsys):
1038
+ with pytest.raises(SystemExit) as exc:
1039
+ run(["video-to-video-sfx", "--video-url", "http://x/y.mp4",
1040
+ "--segments", json.dumps(MUSIC_SEGMENTS)])
1041
+ assert exc.value.code == 1
1042
+ err = capsys.readouterr().err
1043
+ assert "video-to-video-sfx segments take {start, end, prompt}" in err
1044
+ assert "got an object with keys start, label, prompt" in err
1045
+
1046
+
1047
+ def test_video_to_video_sfx_requires_a_video_source():
1048
+ with pytest.raises(SystemExit) as exc:
1049
+ run(["video-to-video-sfx", "--prompt", "x"])
1050
+ assert exc.value.code == 1
1051
+
1052
+
1053
+ def test_video_to_video_sfx_rejects_both_sources():
1054
+ with pytest.raises(SystemExit) as exc:
1055
+ run(["video-to-video-sfx", "--video", "a.mp4", "--video-url", "http://x/y.mp4"])
1056
+ assert exc.value.code == 1
1057
+
1058
+
1059
+ @pytest.mark.parametrize("command", ["video-to-music", "video-to-video-music"])
1060
+ def test_isolate_vocals_is_documented_as_an_alias(command, capsys):
1061
+ """Both endpoints OR the two fields into one behaviour server-side
1062
+ (video_to_music.py: `isolate_vocals = bool(preserve_speech) or
1063
+ bool(isolate_vocals)`; video_to_video.py: `keep_speech = bool(...) or
1064
+ bool(...)`), so the help must not present --isolate-vocals as a separate
1065
+ feature. On video-to-video-music there is no stem at all — the result is
1066
+ a single muxed video."""
1067
+ with pytest.raises(SystemExit):
1068
+ main([command, "--help"])
1069
+ help_text = capsys.readouterr().out
1070
+ assert "Legacy alias for --preserve-speech" in help_text
1071
+ assert "vocals-only stem" not in help_text
1072
+
1073
+
1074
+ @pytest.mark.parametrize(
1075
+ "command", ["video-to-video-music", "video-to-video-sfx"]
1076
+ )
1077
+ def test_video_to_video_commands_are_listed_in_top_level_help(command, capsys):
1078
+ """Both are documented publicly as `sonilo <command>`, so they have to be
1079
+ discoverable from `sonilo --help`, not just by knowing the name."""
1080
+ with pytest.raises(SystemExit):
1081
+ main(["--help"])
1082
+ assert command in capsys.readouterr().out
File without changes
File without changes
File without changes