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.
- {sonilo_cli-0.4.0 → sonilo_cli-0.7.0}/PKG-INFO +50 -4
- {sonilo_cli-0.4.0 → sonilo_cli-0.7.0}/README.md +48 -2
- {sonilo_cli-0.4.0 → sonilo_cli-0.7.0}/pyproject.toml +2 -2
- {sonilo_cli-0.4.0 → sonilo_cli-0.7.0}/src/sonilo_cli/__init__.py +1 -1
- {sonilo_cli-0.4.0 → sonilo_cli-0.7.0}/src/sonilo_cli/__main__.py +149 -20
- {sonilo_cli-0.4.0 → sonilo_cli-0.7.0}/tests/test_cli.py +299 -4
- {sonilo_cli-0.4.0 → sonilo_cli-0.7.0}/.gitignore +0 -0
- {sonilo_cli-0.4.0 → sonilo_cli-0.7.0}/LICENSE +0 -0
- {sonilo_cli-0.4.0 → sonilo_cli-0.7.0}/tests/__init__.py +0 -0
- {sonilo_cli-0.4.0 → sonilo_cli-0.7.0}/tests/test_context7.py +0 -0
- {sonilo_cli-0.4.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'
|
|
@@ -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
|
-
`--
|
|
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
|
-
`--
|
|
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.
|
|
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:
|
|
@@ -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.
|
|
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.
|
|
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
|
-
|
|
783
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|