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.
- {sonilo_cli-0.5.0 → sonilo_cli-0.7.0}/PKG-INFO +25 -4
- {sonilo_cli-0.5.0 → sonilo_cli-0.7.0}/README.md +23 -2
- {sonilo_cli-0.5.0 → sonilo_cli-0.7.0}/pyproject.toml +2 -2
- {sonilo_cli-0.5.0 → sonilo_cli-0.7.0}/src/sonilo_cli/__init__.py +1 -1
- {sonilo_cli-0.5.0 → sonilo_cli-0.7.0}/src/sonilo_cli/__main__.py +86 -22
- {sonilo_cli-0.5.0 → sonilo_cli-0.7.0}/tests/test_cli.py +113 -0
- {sonilo_cli-0.5.0 → sonilo_cli-0.7.0}/.gitignore +0 -0
- {sonilo_cli-0.5.0 → sonilo_cli-0.7.0}/LICENSE +0 -0
- {sonilo_cli-0.5.0 → sonilo_cli-0.7.0}/tests/__init__.py +0 -0
- {sonilo_cli-0.5.0 → sonilo_cli-0.7.0}/tests/test_context7.py +0 -0
- {sonilo_cli-0.5.0 → sonilo_cli-0.7.0}/tests/test_smoke.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sonilo-cli
|
|
3
|
-
Version: 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.
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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]
|
|
@@ -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
|
-
|
|
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=
|
|
258
|
+
output_format=fmt if fmt != "m4a" else None,
|
|
259
|
+
variants_num=args.variants,
|
|
235
260
|
)
|
|
236
|
-
|
|
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
|
-
|
|
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=
|
|
286
|
+
output_format=fmt if fmt != "m4a" else None,
|
|
287
|
+
variants_num=args.variants,
|
|
260
288
|
)
|
|
261
|
-
|
|
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
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
|
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 —
|
|
336
|
-
|
|
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.
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|