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.
- {sonilo_cli-0.5.0 → sonilo_cli-0.8.0}/PKG-INFO +25 -4
- {sonilo_cli-0.5.0 → sonilo_cli-0.8.0}/README.md +23 -2
- {sonilo_cli-0.5.0 → sonilo_cli-0.8.0}/pyproject.toml +2 -2
- {sonilo_cli-0.5.0 → sonilo_cli-0.8.0}/src/sonilo_cli/__init__.py +1 -1
- {sonilo_cli-0.5.0 → sonilo_cli-0.8.0}/src/sonilo_cli/__main__.py +122 -26
- {sonilo_cli-0.5.0 → sonilo_cli-0.8.0}/tests/test_cli.py +113 -0
- {sonilo_cli-0.5.0 → sonilo_cli-0.8.0}/.gitignore +0 -0
- {sonilo_cli-0.5.0 → sonilo_cli-0.8.0}/LICENSE +0 -0
- {sonilo_cli-0.5.0 → sonilo_cli-0.8.0}/tests/__init__.py +0 -0
- {sonilo_cli-0.5.0 → sonilo_cli-0.8.0}/tests/test_context7.py +0 -0
- {sonilo_cli-0.5.0 → sonilo_cli-0.8.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.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.
|
|
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
|
|
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.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.
|
|
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]
|
|
@@ -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,
|
|
@@ -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
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
|
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 —
|
|
336
|
-
|
|
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.
|
|
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.
|
|
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="
|
|
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
|
|
File without changes
|
|
File without changes
|