sonilo-cli 0.3.0__tar.gz → 0.5.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.3.0 → sonilo_cli-0.5.0}/PKG-INFO +60 -2
- {sonilo_cli-0.3.0 → sonilo_cli-0.5.0}/README.md +59 -1
- {sonilo_cli-0.3.0 → sonilo_cli-0.5.0}/pyproject.toml +1 -1
- {sonilo_cli-0.3.0 → sonilo_cli-0.5.0}/src/sonilo_cli/__init__.py +1 -1
- {sonilo_cli-0.3.0 → sonilo_cli-0.5.0}/src/sonilo_cli/__main__.py +232 -6
- {sonilo_cli-0.3.0 → sonilo_cli-0.5.0}/tests/test_cli.py +431 -0
- {sonilo_cli-0.3.0 → sonilo_cli-0.5.0}/.gitignore +0 -0
- {sonilo_cli-0.3.0 → sonilo_cli-0.5.0}/LICENSE +0 -0
- {sonilo_cli-0.3.0 → sonilo_cli-0.5.0}/tests/__init__.py +0 -0
- {sonilo_cli-0.3.0 → sonilo_cli-0.5.0}/tests/test_context7.py +0 -0
- {sonilo_cli-0.3.0 → sonilo_cli-0.5.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.5.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
|
|
@@ -38,8 +38,11 @@ or pass `--api-key sk-...` on any command.
|
|
|
38
38
|
sonilo video-to-music --video clip.mp4 --prompt "tense synths" --format wav
|
|
39
39
|
sonilo text-to-sfx --prompt "glass shattering on concrete" --duration 3
|
|
40
40
|
sonilo video-to-sfx --video clip.mp4 --output whoosh.wav
|
|
41
|
+
sonilo video-to-sfx --video clip.mp4 --segments @segments.json
|
|
41
42
|
sonilo video-to-sound --video clip.mp4 \
|
|
42
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
|
|
43
46
|
sonilo video-to-video-sound --video clip.mp4 --music-prompt "tense synths"
|
|
44
47
|
sonilo dubbing --video-url https://example.com/clip.mp4 --languages es,fr --output dubbed.mp4
|
|
45
48
|
# writes dubbed.es.mp4 and dubbed.fr.mp4
|
|
@@ -49,10 +52,63 @@ or pass `--api-key sk-...` on any command.
|
|
|
49
52
|
### Notes
|
|
50
53
|
|
|
51
54
|
- `text-to-music` / `video-to-music` stream a short `.m4a` by default. `--format wav`,
|
|
52
|
-
`--
|
|
55
|
+
`--preserve-speech`, and its legacy alias `--isolate-vocals` each switch to the async
|
|
56
|
+
submit-and-poll path.
|
|
53
57
|
- `text-to-sfx` / `video-to-sfx` are always async; `--format` accepts `wav|mp3|aac|flac`.
|
|
54
58
|
- Output defaults to `./output.<ext>`; override with `--output`.
|
|
55
59
|
|
|
60
|
+
### Segments
|
|
61
|
+
|
|
62
|
+
`--segments` scores a timeline instead of one whole-clip prompt. It takes a JSON array, in one of
|
|
63
|
+
three forms — inline, from a file, or from stdin:
|
|
64
|
+
|
|
65
|
+
sonilo text-to-music --prompt "warm lo-fi piano" --duration 30 \
|
|
66
|
+
--segments '[{"start":0,"label":"intro","prompt":"airy pads"}]'
|
|
67
|
+
sonilo video-to-sfx --video clip.mp4 --segments @segments.json
|
|
68
|
+
jq -c '.cues' storyboard.json | sonilo video-to-sfx --video clip.mp4 --segments @-
|
|
69
|
+
|
|
70
|
+
A value starting with `@` names a source to read the JSON from, and `@-` reads standard input — the
|
|
71
|
+
same convention as `curl`, `gh` and `aws`. Anything else is parsed as JSON directly.
|
|
72
|
+
|
|
73
|
+
The two segment shapes are **not** interchangeable:
|
|
74
|
+
|
|
75
|
+
| Shape | Commands | Fields |
|
|
76
|
+
| --- | --- | --- |
|
|
77
|
+
| Music | `text-to-music`, `video-to-music` | `{start, prompt, label?}` |
|
|
78
|
+
| SFX | `video-to-sfx`, `video-to-video-sfx`, `video-to-sound`, `video-to-video-sound` | `{start, end, prompt}` |
|
|
79
|
+
|
|
80
|
+
- `start` / `end` are seconds from the start of the track or clip.
|
|
81
|
+
- Passing one shape to a command that takes the other is rejected before any request is made, with
|
|
82
|
+
a message naming the shape that command expects.
|
|
83
|
+
- Only the shape is checked locally. Timing rules — the first segment starting at 0, minimum
|
|
84
|
+
spacing between segments, the `label` vocabulary, how many segments are allowed — are enforced by
|
|
85
|
+
the API, which answers with a `422` describing what it rejected.
|
|
86
|
+
- Keys the CLI does not recognise are forwarded as-is, so a newly added API field works without
|
|
87
|
+
upgrading the CLI.
|
|
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
|
+
### Scored video
|
|
92
|
+
|
|
93
|
+
`video-to-video-music` and `video-to-video-sfx` are the video-out counterparts of `video-to-music`
|
|
94
|
+
and `video-to-sfx`: same generation, but what comes back is the source picture with the new audio
|
|
95
|
+
already muxed in, so there is nothing to line up afterwards. Both are async-only and write a single
|
|
96
|
+
file (default `output.mp4`):
|
|
97
|
+
|
|
98
|
+
sonilo video-to-video-music --video clip.mp4 --prompt "tense synths" --output scored.mp4
|
|
99
|
+
sonilo video-to-video-sfx --video clip.mp4 \
|
|
100
|
+
--segments '[{"start":0,"end":5,"prompt":"footsteps on gravel"}]' --output scored.mp4
|
|
101
|
+
|
|
102
|
+
- `--prompt` is optional on both; without it the model scores from the picture alone.
|
|
103
|
+
- `video-to-video-music` also takes `--preserve-speech`, which keeps source speech in the mix;
|
|
104
|
+
omitting it leaves the server default untouched. `--isolate-vocals` is a legacy alias for the
|
|
105
|
+
same flag — the API ORs the two together, and this endpoint returns one muxed video with no
|
|
106
|
+
separate vocals stem.
|
|
107
|
+
- `video-to-video-sfx` takes `--segments` in the SFX shape `{start, end, prompt}` — see
|
|
108
|
+
[Segments](#segments).
|
|
109
|
+
- Neither command exposes `--format`: the output is a video, not an audio file.
|
|
110
|
+
- For music *and* effects in one call, use `video-to-video-sound` below.
|
|
111
|
+
|
|
56
112
|
### Combined soundtracks
|
|
57
113
|
|
|
58
114
|
`video-to-sound` and `video-to-video-sound` score a clip with a music bed *and* sound effects in one
|
|
@@ -67,6 +123,8 @@ they differ only in what comes back: `video-to-sound` writes the mixed **audio**
|
|
|
67
123
|
--output soundtrack.wav --stem music --stem sfx
|
|
68
124
|
|
|
69
125
|
- `--music-prompt` / `--sfx-prompt` steer the two layers separately; both are optional.
|
|
126
|
+
- `--segments` places individual effects on the timeline, in the SFX shape `{start, end, prompt}` —
|
|
127
|
+
see [Segments](#segments).
|
|
70
128
|
- `--preserve-speech` keeps speech from the source video in the mix.
|
|
71
129
|
- **Ducking is on by default** (music dips under speech). Pass `--no-ducking` to opt out — omitting
|
|
72
130
|
the flag leaves the server default untouched.
|
|
@@ -22,8 +22,11 @@ or pass `--api-key sk-...` on any command.
|
|
|
22
22
|
sonilo video-to-music --video clip.mp4 --prompt "tense synths" --format wav
|
|
23
23
|
sonilo text-to-sfx --prompt "glass shattering on concrete" --duration 3
|
|
24
24
|
sonilo video-to-sfx --video clip.mp4 --output whoosh.wav
|
|
25
|
+
sonilo video-to-sfx --video clip.mp4 --segments @segments.json
|
|
25
26
|
sonilo video-to-sound --video clip.mp4 \
|
|
26
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
|
|
27
30
|
sonilo video-to-video-sound --video clip.mp4 --music-prompt "tense synths"
|
|
28
31
|
sonilo dubbing --video-url https://example.com/clip.mp4 --languages es,fr --output dubbed.mp4
|
|
29
32
|
# writes dubbed.es.mp4 and dubbed.fr.mp4
|
|
@@ -33,10 +36,63 @@ or pass `--api-key sk-...` on any command.
|
|
|
33
36
|
### Notes
|
|
34
37
|
|
|
35
38
|
- `text-to-music` / `video-to-music` stream a short `.m4a` by default. `--format wav`,
|
|
36
|
-
`--
|
|
39
|
+
`--preserve-speech`, and its legacy alias `--isolate-vocals` each switch to the async
|
|
40
|
+
submit-and-poll path.
|
|
37
41
|
- `text-to-sfx` / `video-to-sfx` are always async; `--format` accepts `wav|mp3|aac|flac`.
|
|
38
42
|
- Output defaults to `./output.<ext>`; override with `--output`.
|
|
39
43
|
|
|
44
|
+
### Segments
|
|
45
|
+
|
|
46
|
+
`--segments` scores a timeline instead of one whole-clip prompt. It takes a JSON array, in one of
|
|
47
|
+
three forms — inline, from a file, or from stdin:
|
|
48
|
+
|
|
49
|
+
sonilo text-to-music --prompt "warm lo-fi piano" --duration 30 \
|
|
50
|
+
--segments '[{"start":0,"label":"intro","prompt":"airy pads"}]'
|
|
51
|
+
sonilo video-to-sfx --video clip.mp4 --segments @segments.json
|
|
52
|
+
jq -c '.cues' storyboard.json | sonilo video-to-sfx --video clip.mp4 --segments @-
|
|
53
|
+
|
|
54
|
+
A value starting with `@` names a source to read the JSON from, and `@-` reads standard input — the
|
|
55
|
+
same convention as `curl`, `gh` and `aws`. Anything else is parsed as JSON directly.
|
|
56
|
+
|
|
57
|
+
The two segment shapes are **not** interchangeable:
|
|
58
|
+
|
|
59
|
+
| Shape | Commands | Fields |
|
|
60
|
+
| --- | --- | --- |
|
|
61
|
+
| Music | `text-to-music`, `video-to-music` | `{start, prompt, label?}` |
|
|
62
|
+
| SFX | `video-to-sfx`, `video-to-video-sfx`, `video-to-sound`, `video-to-video-sound` | `{start, end, prompt}` |
|
|
63
|
+
|
|
64
|
+
- `start` / `end` are seconds from the start of the track or clip.
|
|
65
|
+
- Passing one shape to a command that takes the other is rejected before any request is made, with
|
|
66
|
+
a message naming the shape that command expects.
|
|
67
|
+
- Only the shape is checked locally. Timing rules — the first segment starting at 0, minimum
|
|
68
|
+
spacing between segments, the `label` vocabulary, how many segments are allowed — are enforced by
|
|
69
|
+
the API, which answers with a `422` describing what it rejected.
|
|
70
|
+
- Keys the CLI does not recognise are forwarded as-is, so a newly added API field works without
|
|
71
|
+
upgrading the CLI.
|
|
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
|
+
### Scored video
|
|
76
|
+
|
|
77
|
+
`video-to-video-music` and `video-to-video-sfx` are the video-out counterparts of `video-to-music`
|
|
78
|
+
and `video-to-sfx`: same generation, but what comes back is the source picture with the new audio
|
|
79
|
+
already muxed in, so there is nothing to line up afterwards. Both are async-only and write a single
|
|
80
|
+
file (default `output.mp4`):
|
|
81
|
+
|
|
82
|
+
sonilo video-to-video-music --video clip.mp4 --prompt "tense synths" --output scored.mp4
|
|
83
|
+
sonilo video-to-video-sfx --video clip.mp4 \
|
|
84
|
+
--segments '[{"start":0,"end":5,"prompt":"footsteps on gravel"}]' --output scored.mp4
|
|
85
|
+
|
|
86
|
+
- `--prompt` is optional on both; without it the model scores from the picture alone.
|
|
87
|
+
- `video-to-video-music` also takes `--preserve-speech`, which keeps source speech in the mix;
|
|
88
|
+
omitting it leaves the server default untouched. `--isolate-vocals` is a legacy alias for the
|
|
89
|
+
same flag — the API ORs the two together, and this endpoint returns one muxed video with no
|
|
90
|
+
separate vocals stem.
|
|
91
|
+
- `video-to-video-sfx` takes `--segments` in the SFX shape `{start, end, prompt}` — see
|
|
92
|
+
[Segments](#segments).
|
|
93
|
+
- Neither command exposes `--format`: the output is a video, not an audio file.
|
|
94
|
+
- For music *and* effects in one call, use `video-to-video-sound` below.
|
|
95
|
+
|
|
40
96
|
### Combined soundtracks
|
|
41
97
|
|
|
42
98
|
`video-to-sound` and `video-to-video-sound` score a clip with a music bed *and* sound effects in one
|
|
@@ -51,6 +107,8 @@ they differ only in what comes back: `video-to-sound` writes the mixed **audio**
|
|
|
51
107
|
--output soundtrack.wav --stem music --stem sfx
|
|
52
108
|
|
|
53
109
|
- `--music-prompt` / `--sfx-prompt` steer the two layers separately; both are optional.
|
|
110
|
+
- `--segments` places individual effects on the timeline, in the SFX shape `{start, end, prompt}` —
|
|
111
|
+
see [Segments](#segments).
|
|
54
112
|
- `--preserve-speech` keeps speech from the source video in the mix.
|
|
55
113
|
- **Ducking is on by default** (music dips under speech). Pass `--no-ducking` to opt out — omitting
|
|
56
114
|
the flag leaves the server default untouched.
|
|
@@ -6,7 +6,7 @@ import os
|
|
|
6
6
|
import sys
|
|
7
7
|
import time
|
|
8
8
|
from pathlib import Path
|
|
9
|
-
from typing import Any, Dict, List, NoReturn, Optional
|
|
9
|
+
from typing import Any, Dict, List, NamedTuple, NoReturn, Optional, Tuple
|
|
10
10
|
from urllib.parse import urlparse
|
|
11
11
|
|
|
12
12
|
from sonilo import Sonilo
|
|
@@ -36,6 +36,142 @@ def _wrote(path: Any, size: int) -> None:
|
|
|
36
36
|
print(f"Wrote {path} ({size:,} bytes)")
|
|
37
37
|
|
|
38
38
|
|
|
39
|
+
# --- --segments ----------------------------------------------------------
|
|
40
|
+
#
|
|
41
|
+
# `segments` is the only structured parameter the API takes — every other
|
|
42
|
+
# flag on this CLI is a scalar. The value follows the curl / gh / aws
|
|
43
|
+
# convention for anything that can get long: a leading `@` makes the value a
|
|
44
|
+
# *source* to read from rather than the value itself, and `@-` means stdin.
|
|
45
|
+
#
|
|
46
|
+
# Validation here is deliberately shape-only. The server owns the semantic
|
|
47
|
+
# rules (first segment at 0, minimum spacing, the label enum, item caps) and
|
|
48
|
+
# a copy of them in the CLI would drift the moment the backend changes, so a
|
|
49
|
+
# request that is the right shape but the wrong content is left to earn its
|
|
50
|
+
# own 422.
|
|
51
|
+
|
|
52
|
+
_STDIN = "@-"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class _SegmentShape(NamedTuple):
|
|
56
|
+
"""One command's segment contract, as used for validation and messages."""
|
|
57
|
+
|
|
58
|
+
summary: str
|
|
59
|
+
"""What a correct segment looks like, shown verbatim in errors."""
|
|
60
|
+
required: Tuple[Tuple[str, str], ...]
|
|
61
|
+
optional: Tuple[Tuple[str, str], ...]
|
|
62
|
+
foreign: Tuple[str, ...]
|
|
63
|
+
"""Fields that belong to the *other* shape — see _check_segment."""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
MUSIC_SHAPE = _SegmentShape(
|
|
67
|
+
summary="{start, prompt, label?}",
|
|
68
|
+
required=(("start", "number"), ("prompt", "string")),
|
|
69
|
+
optional=(("label", "string"),),
|
|
70
|
+
foreign=("end",),
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
SFX_SHAPE = _SegmentShape(
|
|
74
|
+
summary="{start, end, prompt}",
|
|
75
|
+
required=(("start", "number"), ("end", "number"), ("prompt", "string")),
|
|
76
|
+
optional=(),
|
|
77
|
+
foreign=("label",),
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
# JSON booleans are not numbers, but bool is an int subclass in Python, so
|
|
81
|
+
# `isinstance(True, int)` would let `{"start": true}` through.
|
|
82
|
+
_TYPE_CHECKS = {
|
|
83
|
+
"number": lambda v: isinstance(v, (int, float)) and not isinstance(v, bool),
|
|
84
|
+
"string": lambda v: isinstance(v, str),
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _describe(value: Any) -> str:
|
|
89
|
+
"""Name a JSON value the way the error messages talk about it. For an
|
|
90
|
+
object this lists its keys, which is what makes a shape mix-up obvious."""
|
|
91
|
+
if value is None:
|
|
92
|
+
return "null"
|
|
93
|
+
if isinstance(value, bool):
|
|
94
|
+
return "a boolean"
|
|
95
|
+
if isinstance(value, (int, float)):
|
|
96
|
+
return "a number"
|
|
97
|
+
if isinstance(value, str):
|
|
98
|
+
return "a string"
|
|
99
|
+
if isinstance(value, list):
|
|
100
|
+
return "an empty array" if not value else "an array"
|
|
101
|
+
if isinstance(value, dict):
|
|
102
|
+
return f"an object with keys {', '.join(value)}" if value else "an object with no keys"
|
|
103
|
+
return "an unsupported value"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _read_segments_source(raw: str) -> Tuple[str, str]:
|
|
107
|
+
"""Resolve a raw --segments value to (text, source name for errors)."""
|
|
108
|
+
if not raw.startswith("@"):
|
|
109
|
+
return raw, "--segments"
|
|
110
|
+
if raw == _STDIN:
|
|
111
|
+
return sys.stdin.read(), "stdin"
|
|
112
|
+
path = raw[1:]
|
|
113
|
+
if not path:
|
|
114
|
+
_fail("--segments @ needs a filename, e.g. --segments @segments.json (@- reads stdin)")
|
|
115
|
+
try:
|
|
116
|
+
return Path(path).read_text(), path
|
|
117
|
+
except OSError as exc:
|
|
118
|
+
_fail(f"could not read segments from {path}: {exc.strerror or exc}")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _check_segment(item: Any, index: int, shape: _SegmentShape, command: str) -> None:
|
|
122
|
+
expected = f"{command} segments take {shape.summary}"
|
|
123
|
+
# Element-level problems name the offending index (0-based, matching the
|
|
124
|
+
# Node CLI): an array is usually three or four items, and pointing at one
|
|
125
|
+
# saves the reader counting. The shape mismatch below is the exception —
|
|
126
|
+
# it is a whole-payload mistake, so it reads better without an index.
|
|
127
|
+
if not isinstance(item, dict):
|
|
128
|
+
_fail(f"{expected} — element {index} is not an object")
|
|
129
|
+
# A key that belongs to the *other* shape is the tell for the one
|
|
130
|
+
# predictable mistake here — SFX-shaped segments on a music command, or
|
|
131
|
+
# the reverse — so it is reported instead of forwarded, even though it
|
|
132
|
+
# would otherwise look like a required field is merely missing. Keys that
|
|
133
|
+
# belong to neither shape pass through untouched, so a field added to the
|
|
134
|
+
# API later needs no CLI release.
|
|
135
|
+
if any(key in item for key in shape.foreign) or any(
|
|
136
|
+
field not in item for field, _ in shape.required
|
|
137
|
+
):
|
|
138
|
+
_fail(f"{expected} — got {_describe(item)}")
|
|
139
|
+
for field, kind in shape.required + shape.optional:
|
|
140
|
+
if field in item and not _TYPE_CHECKS[kind](item[field]):
|
|
141
|
+
_fail(f'{expected} — "{field}" must be a {kind} (element {index})')
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def parse_segments(
|
|
145
|
+
raw: Optional[str], shape: _SegmentShape, command: str
|
|
146
|
+
) -> Optional[List[Dict[str, Any]]]:
|
|
147
|
+
"""Read, parse and shape-check one --segments value.
|
|
148
|
+
|
|
149
|
+
`raw` is the flag as typed (inline JSON, `@file`, or `@-`). Returns None
|
|
150
|
+
when the flag was not given, so the field stays out of the request
|
|
151
|
+
entirely rather than being sent as an empty list.
|
|
152
|
+
"""
|
|
153
|
+
if raw is None:
|
|
154
|
+
return None
|
|
155
|
+
text, source = _read_segments_source(raw)
|
|
156
|
+
try:
|
|
157
|
+
value = json.loads(text)
|
|
158
|
+
except ValueError as exc:
|
|
159
|
+
_fail(f"could not parse segments from {source}: {exc}")
|
|
160
|
+
if not isinstance(value, list) or not value:
|
|
161
|
+
_fail(
|
|
162
|
+
f"{command} --segments must be a non-empty JSON array of "
|
|
163
|
+
f"{shape.summary} objects — got {_describe(value)}"
|
|
164
|
+
)
|
|
165
|
+
for index, item in enumerate(value):
|
|
166
|
+
_check_segment(item, index, shape, command)
|
|
167
|
+
return value
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _segments(args: argparse.Namespace) -> Optional[List[Dict[str, Any]]]:
|
|
171
|
+
"""parse_segments() for whichever subcommand is running."""
|
|
172
|
+
return parse_segments(args.segments, args.segments_shape, args.command)
|
|
173
|
+
|
|
174
|
+
|
|
39
175
|
def build_client(api_key: Optional[str]) -> Sonilo:
|
|
40
176
|
key = api_key or os.environ.get("SONILO_API_KEY")
|
|
41
177
|
if not key:
|
|
@@ -89,16 +225,20 @@ def cmd_text_to_music(client: Sonilo, args: argparse.Namespace) -> None:
|
|
|
89
225
|
fmt = args.format
|
|
90
226
|
use_async = args.use_async or fmt == "wav"
|
|
91
227
|
out = _music_output(args, fmt)
|
|
228
|
+
segments = _segments(args)
|
|
92
229
|
if use_async:
|
|
93
230
|
result = client.text_to_music.generate_async(
|
|
94
231
|
prompt=args.prompt,
|
|
95
232
|
duration=args.duration,
|
|
233
|
+
segments=segments,
|
|
96
234
|
output_format="wav" if fmt == "wav" else None,
|
|
97
235
|
)
|
|
98
236
|
path = result.save(out)
|
|
99
237
|
_wrote(path, path.stat().st_size)
|
|
100
238
|
else:
|
|
101
|
-
track = client.text_to_music.generate(
|
|
239
|
+
track = client.text_to_music.generate(
|
|
240
|
+
prompt=args.prompt, duration=args.duration, segments=segments
|
|
241
|
+
)
|
|
102
242
|
path = track.save(out)
|
|
103
243
|
_wrote(path, len(track.audio))
|
|
104
244
|
|
|
@@ -107,11 +247,13 @@ def cmd_video_to_music(client: Sonilo, args: argparse.Namespace) -> None:
|
|
|
107
247
|
fmt = args.format
|
|
108
248
|
use_async = args.use_async or fmt == "wav" or args.isolate_vocals or args.preserve_speech
|
|
109
249
|
out = _music_output(args, fmt)
|
|
250
|
+
segments = _segments(args)
|
|
110
251
|
if use_async:
|
|
111
252
|
result = client.video_to_music.generate_async(
|
|
112
253
|
video=args.video,
|
|
113
254
|
video_url=args.video_url,
|
|
114
255
|
prompt=args.prompt,
|
|
256
|
+
segments=segments,
|
|
115
257
|
isolate_vocals=args.isolate_vocals or None,
|
|
116
258
|
preserve_speech=args.preserve_speech or None,
|
|
117
259
|
output_format="wav" if fmt == "wav" else None,
|
|
@@ -120,7 +262,8 @@ def cmd_video_to_music(client: Sonilo, args: argparse.Namespace) -> None:
|
|
|
120
262
|
_wrote(path, path.stat().st_size)
|
|
121
263
|
else:
|
|
122
264
|
track = client.video_to_music.generate(
|
|
123
|
-
video=args.video, video_url=args.video_url, prompt=args.prompt
|
|
265
|
+
video=args.video, video_url=args.video_url, prompt=args.prompt,
|
|
266
|
+
segments=segments,
|
|
124
267
|
)
|
|
125
268
|
path = track.save(out)
|
|
126
269
|
_wrote(path, len(track.audio))
|
|
@@ -142,7 +285,7 @@ def cmd_video_to_sfx(client: Sonilo, args: argparse.Namespace) -> None:
|
|
|
142
285
|
out = args.output if args.output is not None else f"output.{args.format}"
|
|
143
286
|
result = client.video_to_sfx.generate(
|
|
144
287
|
video=args.video, video_url=args.video_url,
|
|
145
|
-
prompt=args.prompt, audio_format=args.format,
|
|
288
|
+
prompt=args.prompt, segments=_segments(args), audio_format=args.format,
|
|
146
289
|
)
|
|
147
290
|
path = result.save(out)
|
|
148
291
|
_wrote(path, path.stat().st_size)
|
|
@@ -166,6 +309,7 @@ def _run_sound(client: Sonilo, args: argparse.Namespace, resource: Any, default_
|
|
|
166
309
|
video_url=args.video_url,
|
|
167
310
|
music_prompt=args.music_prompt,
|
|
168
311
|
sfx_prompt=args.sfx_prompt,
|
|
312
|
+
segments=_segments(args),
|
|
169
313
|
preserve_speech=True if args.preserve_speech else None,
|
|
170
314
|
ducking=False if args.no_ducking else None,
|
|
171
315
|
)
|
|
@@ -185,6 +329,40 @@ def cmd_video_to_video_sound(client: Sonilo, args: argparse.Namespace) -> None:
|
|
|
185
329
|
_run_sound(client, args, client.video_to_video_sound, "mp4")
|
|
186
330
|
|
|
187
331
|
|
|
332
|
+
def _run_video(args: argparse.Namespace, resource: Any, **params: Any) -> None:
|
|
333
|
+
"""Run one of the video-returning endpoints and save the single result.
|
|
334
|
+
|
|
335
|
+
These return the source picture with the generated audio muxed in — one
|
|
336
|
+
file, no stems — so the default destination is an `.mp4`, matching
|
|
337
|
+
video-to-video-sound.
|
|
338
|
+
"""
|
|
339
|
+
out = args.output if args.output is not None else "output.mp4"
|
|
340
|
+
result = resource.generate(video=args.video, video_url=args.video_url, **params)
|
|
341
|
+
path = result.save(out)
|
|
342
|
+
_wrote(path, path.stat().st_size)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def cmd_video_to_video_music(client: Sonilo, args: argparse.Namespace) -> None:
|
|
346
|
+
_run_video(
|
|
347
|
+
args,
|
|
348
|
+
client.video_to_video_music,
|
|
349
|
+
prompt=args.prompt,
|
|
350
|
+
# Unset flags forward None, not False, so the server default stands —
|
|
351
|
+
# same reasoning as --no-ducking on the sound commands.
|
|
352
|
+
preserve_speech=True if args.preserve_speech else None,
|
|
353
|
+
isolate_vocals=True if args.isolate_vocals else None,
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def cmd_video_to_video_sfx(client: Sonilo, args: argparse.Namespace) -> None:
|
|
358
|
+
_run_video(
|
|
359
|
+
args,
|
|
360
|
+
client.video_to_video_sfx,
|
|
361
|
+
prompt=args.prompt,
|
|
362
|
+
segments=_segments(args),
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
|
|
188
366
|
# Matched to the dubbing backend's own ceiling: it polls its pipeline for up
|
|
189
367
|
# to 7200s (2 hours), so anything shorter abandons a job the user has already
|
|
190
368
|
# been charged for. The SDK's generic DEFAULT_WAIT_TIMEOUT of 600s is far too
|
|
@@ -269,6 +447,18 @@ def _add_video_source(parser: argparse.ArgumentParser) -> None:
|
|
|
269
447
|
help="Remote video URL to score.")
|
|
270
448
|
|
|
271
449
|
|
|
450
|
+
def _add_segments(parser: argparse.ArgumentParser, shape: _SegmentShape) -> None:
|
|
451
|
+
parser.add_argument(
|
|
452
|
+
"--segments", default=None,
|
|
453
|
+
help=f"Timed segments, as a JSON array of {shape.summary} objects. "
|
|
454
|
+
"Pass the JSON inline, @FILE to read it from a file, "
|
|
455
|
+
"or @- to read it from stdin.",
|
|
456
|
+
)
|
|
457
|
+
# Carried on the namespace so the one shared reader knows which contract
|
|
458
|
+
# to check the value against, and can name it when the check fails.
|
|
459
|
+
parser.set_defaults(segments_shape=shape)
|
|
460
|
+
|
|
461
|
+
|
|
272
462
|
def build_parser() -> argparse.ArgumentParser:
|
|
273
463
|
parser = _Parser(prog="sonilo", description="Command-line interface for the Sonilo API")
|
|
274
464
|
parser.add_argument("--version", action="version", version=__version__)
|
|
@@ -288,6 +478,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
288
478
|
_add_global(p_t2m)
|
|
289
479
|
p_t2m.add_argument("--prompt", required=True, help="What the music should sound like.")
|
|
290
480
|
p_t2m.add_argument("--duration", type=int, required=True, help="Track length in seconds.")
|
|
481
|
+
_add_segments(p_t2m, MUSIC_SHAPE)
|
|
291
482
|
p_t2m.add_argument("--output", default=None, help="Where to save the audio.")
|
|
292
483
|
p_t2m.add_argument("--format", choices=["m4a", "wav"], default="m4a",
|
|
293
484
|
help="Output container. wav forces async. Default: m4a")
|
|
@@ -299,13 +490,18 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
299
490
|
_add_global(p_v2m)
|
|
300
491
|
_add_video_source(p_v2m)
|
|
301
492
|
p_v2m.add_argument("--prompt", default=None, help="Optional creative direction.")
|
|
493
|
+
_add_segments(p_v2m, MUSIC_SHAPE)
|
|
302
494
|
p_v2m.add_argument("--output", default=None, help="Where to save the audio.")
|
|
303
495
|
p_v2m.add_argument("--format", choices=["m4a", "wav"], default="m4a",
|
|
304
496
|
help="Output container. wav forces async.")
|
|
305
|
-
p_v2m.add_argument("--isolate-vocals", dest="isolate_vocals", action="store_true",
|
|
306
|
-
help="Split out a vocals-only stem. Forces async.")
|
|
307
497
|
p_v2m.add_argument("--preserve-speech", dest="preserve_speech", action="store_true",
|
|
308
498
|
help="Keep source speech in the mix. Forces async.")
|
|
499
|
+
# The API ORs isolate_vocals into preserve_speech (video_to_music.py:
|
|
500
|
+
# `isolate_vocals = bool(preserve_speech) or bool(isolate_vocals)`), so
|
|
501
|
+
# the two flags are one feature under two names, not two behaviours.
|
|
502
|
+
# isolate_vocals is the legacy name kept for existing callers.
|
|
503
|
+
p_v2m.add_argument("--isolate-vocals", dest="isolate_vocals", action="store_true",
|
|
504
|
+
help="Legacy alias for --preserve-speech. Forces async.")
|
|
309
505
|
p_v2m.add_argument("--async", dest="use_async", action="store_true",
|
|
310
506
|
help="Submit and poll instead of streaming.")
|
|
311
507
|
p_v2m.set_defaults(func=cmd_video_to_music)
|
|
@@ -323,6 +519,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
323
519
|
_add_global(p_v2s)
|
|
324
520
|
_add_video_source(p_v2s)
|
|
325
521
|
p_v2s.add_argument("--prompt", default=None, help="Optional creative direction.")
|
|
522
|
+
_add_segments(p_v2s, SFX_SHAPE)
|
|
326
523
|
p_v2s.add_argument("--output", default=None, help="Where to save the audio.")
|
|
327
524
|
p_v2s.add_argument("--format", choices=_SFX_FORMATS, default="wav",
|
|
328
525
|
help="Output format. Default: wav")
|
|
@@ -337,6 +534,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
337
534
|
help="Optional creative direction for the music bed.")
|
|
338
535
|
p_v2sd.add_argument("--sfx-prompt", dest="sfx_prompt", default=None,
|
|
339
536
|
help="Optional creative direction for the sound effects.")
|
|
537
|
+
_add_segments(p_v2sd, SFX_SHAPE)
|
|
340
538
|
p_v2sd.add_argument("--preserve-speech", dest="preserve_speech", action="store_true",
|
|
341
539
|
help="Keep source speech in the mix.")
|
|
342
540
|
p_v2sd.add_argument("--no-ducking", dest="no_ducking", action="store_true",
|
|
@@ -346,6 +544,33 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
346
544
|
p_v2sd.add_argument("--output", default=None, help="Where to save the combined audio.")
|
|
347
545
|
p_v2sd.set_defaults(func=cmd_video_to_sound)
|
|
348
546
|
|
|
547
|
+
p_v2vm = sub.add_parser(
|
|
548
|
+
"video-to-video-music", help="Generate music muxed into the source video"
|
|
549
|
+
)
|
|
550
|
+
_add_global(p_v2vm)
|
|
551
|
+
_add_video_source(p_v2vm)
|
|
552
|
+
p_v2vm.add_argument("--prompt", default=None, help="Optional creative direction.")
|
|
553
|
+
p_v2vm.add_argument("--preserve-speech", dest="preserve_speech", action="store_true",
|
|
554
|
+
help="Keep source speech in the mix.")
|
|
555
|
+
# Same aliasing as video-to-music, and here the endpoint collapses the two
|
|
556
|
+
# into a single boolean before it reaches the model (video_to_video.py:
|
|
557
|
+
# `keep_speech = bool(preserve_speech) or bool(isolate_vocals)`), with no
|
|
558
|
+
# vocals stem in the result — the output is one muxed video.
|
|
559
|
+
p_v2vm.add_argument("--isolate-vocals", dest="isolate_vocals", action="store_true",
|
|
560
|
+
help="Legacy alias for --preserve-speech; no separate stem.")
|
|
561
|
+
p_v2vm.add_argument("--output", default=None, help="Where to save the scored video.")
|
|
562
|
+
p_v2vm.set_defaults(func=cmd_video_to_video_music)
|
|
563
|
+
|
|
564
|
+
p_v2vfx = sub.add_parser(
|
|
565
|
+
"video-to-video-sfx", help="Generate sound effects muxed into the source video"
|
|
566
|
+
)
|
|
567
|
+
_add_global(p_v2vfx)
|
|
568
|
+
_add_video_source(p_v2vfx)
|
|
569
|
+
p_v2vfx.add_argument("--prompt", default=None, help="Optional creative direction.")
|
|
570
|
+
_add_segments(p_v2vfx, SFX_SHAPE)
|
|
571
|
+
p_v2vfx.add_argument("--output", default=None, help="Where to save the scored video.")
|
|
572
|
+
p_v2vfx.set_defaults(func=cmd_video_to_video_sfx)
|
|
573
|
+
|
|
349
574
|
p_v2vsd = sub.add_parser(
|
|
350
575
|
"video-to-video-sound", help="Generate matched music+sfx muxed into the source video"
|
|
351
576
|
)
|
|
@@ -355,6 +580,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
355
580
|
help="Optional creative direction for the music bed.")
|
|
356
581
|
p_v2vsd.add_argument("--sfx-prompt", dest="sfx_prompt", default=None,
|
|
357
582
|
help="Optional creative direction for the sound effects.")
|
|
583
|
+
_add_segments(p_v2vsd, SFX_SHAPE)
|
|
358
584
|
p_v2vsd.add_argument("--preserve-speech", dest="preserve_speech", action="store_true",
|
|
359
585
|
help="Keep source speech in the mix.")
|
|
360
586
|
p_v2vsd.add_argument("--no-ducking", dest="no_ducking", action="store_true",
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import io
|
|
1
2
|
import json
|
|
2
3
|
from urllib.parse import unquote_plus
|
|
3
4
|
|
|
@@ -536,3 +537,433 @@ def test_dubbing_without_languages_omits_the_field(tmp_path):
|
|
|
536
537
|
# absent. Sending `languages=[]` or the string "None" would silently
|
|
537
538
|
# override that default with something else.
|
|
538
539
|
assert b"languages" not in route.calls.last.request.content
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
# --- --segments -----------------------------------------------------------
|
|
543
|
+
#
|
|
544
|
+
# The two shapes are not interchangeable: music segments are
|
|
545
|
+
# {start, prompt, label?} and SFX segments are {start, end, prompt}. Both are
|
|
546
|
+
# sent as a JSON string inside the form-encoded body (see
|
|
547
|
+
# sonilo._requests.build_v2m_parts), so assertions decode the body first.
|
|
548
|
+
|
|
549
|
+
MUSIC_SEGMENTS = [{"start": 0, "label": "intro", "prompt": "airy pads"}]
|
|
550
|
+
SFX_SEGMENTS = [{"start": 0, "end": 5, "prompt": "footsteps on gravel"}]
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def _sent_segments(route):
|
|
554
|
+
"""The `segments` value as it left the CLI, decoded back to Python."""
|
|
555
|
+
body = unquote_plus(route.calls.last.request.content.decode())
|
|
556
|
+
for field in body.split("&"):
|
|
557
|
+
name, _, value = field.partition("=")
|
|
558
|
+
if name == "segments":
|
|
559
|
+
return json.loads(value)
|
|
560
|
+
return None
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
@respx.mock
|
|
564
|
+
def test_segments_inline_json_reaches_the_request_body(tmp_path):
|
|
565
|
+
route = respx.post(f"{BASE}/v1/text-to-music").mock(
|
|
566
|
+
return_value=httpx.Response(200, text=_music_stream_body())
|
|
567
|
+
)
|
|
568
|
+
run(["text-to-music", "--prompt", "lofi", "--duration", "30",
|
|
569
|
+
"--segments", json.dumps(MUSIC_SEGMENTS),
|
|
570
|
+
"--output", str(tmp_path / "song.m4a")])
|
|
571
|
+
assert _sent_segments(route) == MUSIC_SEGMENTS
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
@respx.mock
|
|
575
|
+
def test_segments_from_a_file(tmp_path):
|
|
576
|
+
route = respx.post(f"{BASE}/v1/text-to-music").mock(
|
|
577
|
+
return_value=httpx.Response(200, text=_music_stream_body())
|
|
578
|
+
)
|
|
579
|
+
src = tmp_path / "segments.json"
|
|
580
|
+
src.write_text(json.dumps(MUSIC_SEGMENTS))
|
|
581
|
+
run(["text-to-music", "--prompt", "lofi", "--duration", "30",
|
|
582
|
+
"--segments", f"@{src}", "--output", str(tmp_path / "song.m4a")])
|
|
583
|
+
assert _sent_segments(route) == MUSIC_SEGMENTS
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
@respx.mock
|
|
587
|
+
def test_segments_from_stdin(tmp_path, monkeypatch):
|
|
588
|
+
route = respx.post(f"{BASE}/v1/text-to-music").mock(
|
|
589
|
+
return_value=httpx.Response(200, text=_music_stream_body())
|
|
590
|
+
)
|
|
591
|
+
monkeypatch.setattr("sys.stdin", io.StringIO(json.dumps(MUSIC_SEGMENTS)))
|
|
592
|
+
run(["text-to-music", "--prompt", "lofi", "--duration", "30",
|
|
593
|
+
"--segments", "@-", "--output", str(tmp_path / "song.m4a")])
|
|
594
|
+
assert _sent_segments(route) == MUSIC_SEGMENTS
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
@respx.mock
|
|
598
|
+
def test_sfx_segments_reach_the_request_body(tmp_path):
|
|
599
|
+
route = respx.post(f"{BASE}/v1/video-to-sound").mock(
|
|
600
|
+
return_value=httpx.Response(200, json={"task_id": "sg1", "status": "processing"})
|
|
601
|
+
)
|
|
602
|
+
respx.get(f"{BASE}/v1/tasks/sg1").mock(
|
|
603
|
+
return_value=httpx.Response(200, json=_sound_body("sg1"))
|
|
604
|
+
)
|
|
605
|
+
respx.get("https://r2.example.com/sound.wav").mock(
|
|
606
|
+
return_value=httpx.Response(200, content=b"MIXED")
|
|
607
|
+
)
|
|
608
|
+
run(["video-to-sound", "--video-url", "http://x/y.mp4",
|
|
609
|
+
"--segments", json.dumps(SFX_SEGMENTS),
|
|
610
|
+
"--output", str(tmp_path / "s.wav")])
|
|
611
|
+
assert _sent_segments(route) == SFX_SEGMENTS
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
@respx.mock
|
|
615
|
+
def test_omitting_segments_sends_no_segments_field(tmp_path):
|
|
616
|
+
route = respx.post(f"{BASE}/v1/text-to-music").mock(
|
|
617
|
+
return_value=httpx.Response(200, text=_music_stream_body())
|
|
618
|
+
)
|
|
619
|
+
run(["text-to-music", "--prompt", "lofi", "--duration", "30",
|
|
620
|
+
"--output", str(tmp_path / "song.m4a")])
|
|
621
|
+
# Not `segments=[]` and not the string "None" — the field must be absent,
|
|
622
|
+
# exactly as --languages is for dubbing.
|
|
623
|
+
assert b"segments" not in route.calls.last.request.content
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def test_segments_unknown_keys_pass_through(tmp_path):
|
|
627
|
+
"""A field the API adds later must not need a CLI release to be usable."""
|
|
628
|
+
from sonilo_cli.__main__ import MUSIC_SHAPE, parse_segments
|
|
629
|
+
|
|
630
|
+
value = [{"start": 0, "prompt": "pads", "intensity": 0.4}]
|
|
631
|
+
assert parse_segments(json.dumps(value), MUSIC_SHAPE, "text-to-music") == value
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def test_segments_malformed_json_names_the_source(capsys):
|
|
635
|
+
with pytest.raises(SystemExit) as exc:
|
|
636
|
+
run(["video-to-music", "--video-url", "http://x/y.mp4",
|
|
637
|
+
"--segments", "[{start: 0}]"])
|
|
638
|
+
assert exc.value.code == 1
|
|
639
|
+
err = capsys.readouterr().err
|
|
640
|
+
assert err.startswith("sonilo:")
|
|
641
|
+
assert "--segments" in err # the offending source
|
|
642
|
+
assert "Expecting" in err # the parser's own complaint
|
|
643
|
+
assert "Traceback" not in err
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
def test_segments_malformed_json_from_a_file_names_the_file(tmp_path, capsys):
|
|
647
|
+
src = tmp_path / "segments.json"
|
|
648
|
+
src.write_text("{oops")
|
|
649
|
+
with pytest.raises(SystemExit) as exc:
|
|
650
|
+
run(["video-to-music", "--video-url", "http://x/y.mp4", "--segments", f"@{src}"])
|
|
651
|
+
assert exc.value.code == 1
|
|
652
|
+
assert str(src) in capsys.readouterr().err
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def test_segments_malformed_json_from_stdin_names_stdin(capsys, monkeypatch):
|
|
656
|
+
monkeypatch.setattr("sys.stdin", io.StringIO("{oops"))
|
|
657
|
+
with pytest.raises(SystemExit) as exc:
|
|
658
|
+
run(["video-to-music", "--video-url", "http://x/y.mp4", "--segments", "@-"])
|
|
659
|
+
assert exc.value.code == 1
|
|
660
|
+
# "stdin", not "standard input" — the Node CLI says stdin.
|
|
661
|
+
assert "could not parse segments from stdin:" in capsys.readouterr().err
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def test_segments_unreadable_file_exits_1(tmp_path, capsys):
|
|
665
|
+
missing = tmp_path / "nope.json"
|
|
666
|
+
with pytest.raises(SystemExit) as exc:
|
|
667
|
+
run(["video-to-music", "--video-url", "http://x/y.mp4", "--segments", f"@{missing}"])
|
|
668
|
+
assert exc.value.code == 1
|
|
669
|
+
err = capsys.readouterr().err
|
|
670
|
+
assert str(missing) in err
|
|
671
|
+
assert "Traceback" not in err
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def test_segments_must_be_a_list(capsys):
|
|
675
|
+
with pytest.raises(SystemExit) as exc:
|
|
676
|
+
run(["video-to-music", "--video-url", "http://x/y.mp4",
|
|
677
|
+
"--segments", '{"start": 0, "prompt": "pads"}'])
|
|
678
|
+
assert exc.value.code == 1
|
|
679
|
+
err = capsys.readouterr().err
|
|
680
|
+
assert "JSON array" in err
|
|
681
|
+
assert "{start, prompt, label?}" in err
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
def test_segments_must_not_be_empty(capsys):
|
|
685
|
+
with pytest.raises(SystemExit) as exc:
|
|
686
|
+
run(["video-to-music", "--video-url", "http://x/y.mp4", "--segments", "[]"])
|
|
687
|
+
assert exc.value.code == 1
|
|
688
|
+
assert "empty array" in capsys.readouterr().err
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
def test_segments_elements_must_be_objects(capsys):
|
|
692
|
+
with pytest.raises(SystemExit) as exc:
|
|
693
|
+
run(["video-to-music", "--video-url", "http://x/y.mp4", "--segments", '["intro"]'])
|
|
694
|
+
assert exc.value.code == 1
|
|
695
|
+
err = capsys.readouterr().err
|
|
696
|
+
assert "video-to-music segments take {start, prompt, label?}" in err
|
|
697
|
+
assert "element 0 is not an object" in err
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
def test_segments_element_errors_name_the_offending_index(capsys):
|
|
701
|
+
"""Indices are 0-based, and point at the bad element rather than always
|
|
702
|
+
reporting the first — matching the Node CLI, so the two agree."""
|
|
703
|
+
with pytest.raises(SystemExit) as exc:
|
|
704
|
+
run(["video-to-music", "--video-url", "http://x/y.mp4",
|
|
705
|
+
"--segments", '[{"start": 0, "prompt": "a"}, {"start": 5, "prompt": "b"}, "oops"]'])
|
|
706
|
+
assert exc.value.code == 1
|
|
707
|
+
assert "element 2 is not an object" in capsys.readouterr().err
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
def test_music_command_rejects_sfx_shaped_segments(capsys):
|
|
711
|
+
"""The predictable mistake, in the direction that is hardest to spot: the
|
|
712
|
+
music shape's required fields are all present, so only the foreign `end`
|
|
713
|
+
key gives it away."""
|
|
714
|
+
with pytest.raises(SystemExit) as exc:
|
|
715
|
+
run(["video-to-music", "--video-url", "http://x/y.mp4",
|
|
716
|
+
"--segments", json.dumps(SFX_SEGMENTS)])
|
|
717
|
+
assert exc.value.code == 1
|
|
718
|
+
err = capsys.readouterr().err
|
|
719
|
+
# Naming both the expected shape and the keys actually given is what makes
|
|
720
|
+
# this self-correcting without a docs lookup.
|
|
721
|
+
assert "video-to-music segments take {start, prompt, label?}" in err
|
|
722
|
+
assert "got an object with keys start, end, prompt" in err
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def test_sfx_command_rejects_music_shaped_segments(capsys):
|
|
726
|
+
with pytest.raises(SystemExit) as exc:
|
|
727
|
+
run(["video-to-sfx", "--video-url", "http://x/y.mp4",
|
|
728
|
+
"--segments", json.dumps(MUSIC_SEGMENTS)])
|
|
729
|
+
assert exc.value.code == 1
|
|
730
|
+
err = capsys.readouterr().err
|
|
731
|
+
assert "video-to-sfx segments take {start, end, prompt}" in err
|
|
732
|
+
assert "got an object with keys start, label, prompt" in err
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
def test_segments_field_types_are_checked(capsys):
|
|
736
|
+
with pytest.raises(SystemExit) as exc:
|
|
737
|
+
run(["video-to-sfx", "--video-url", "http://x/y.mp4",
|
|
738
|
+
"--segments", '[{"start": 0, "end": "5", "prompt": "thud"}]'])
|
|
739
|
+
assert exc.value.code == 1
|
|
740
|
+
err = capsys.readouterr().err
|
|
741
|
+
assert "video-to-sfx segments take {start, end, prompt}" in err
|
|
742
|
+
assert '"end" must be a number (element 0)' in err
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
def test_segments_field_type_errors_name_the_offending_index(capsys):
|
|
746
|
+
with pytest.raises(SystemExit) as exc:
|
|
747
|
+
run(["video-to-sfx", "--video-url", "http://x/y.mp4",
|
|
748
|
+
"--segments", '[{"start": 0, "end": 5, "prompt": "a"},'
|
|
749
|
+
' {"start": 5, "end": 9, "prompt": 7}]'])
|
|
750
|
+
assert exc.value.code == 1
|
|
751
|
+
assert '"prompt" must be a string (element 1)' in capsys.readouterr().err
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
def test_segments_semantic_rules_are_left_to_the_server():
|
|
755
|
+
"""Spacing, the label enum, the first segment's start and item caps are
|
|
756
|
+
server-side rules; duplicating them here would drift. Shape-valid input
|
|
757
|
+
must pass client-side however implausible it looks."""
|
|
758
|
+
from sonilo_cli.__main__ import MUSIC_SHAPE, parse_segments
|
|
759
|
+
|
|
760
|
+
value = [{"start": 900, "prompt": "x", "label": "not-in-the-enum"},
|
|
761
|
+
{"start": 901, "prompt": "y"}]
|
|
762
|
+
assert parse_segments(json.dumps(value), MUSIC_SHAPE, "text-to-music") == value
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
@pytest.mark.parametrize(
|
|
766
|
+
"command",
|
|
767
|
+
["text-to-music", "video-to-music", "video-to-sfx", "video-to-video-sfx",
|
|
768
|
+
"video-to-sound", "video-to-video-sound"],
|
|
769
|
+
)
|
|
770
|
+
def test_segments_help_shows_all_three_value_forms(command, capsys):
|
|
771
|
+
with pytest.raises(SystemExit):
|
|
772
|
+
main([command, "--help"])
|
|
773
|
+
help_text = capsys.readouterr().out
|
|
774
|
+
assert "--segments" in help_text
|
|
775
|
+
assert "@FILE" in help_text
|
|
776
|
+
assert "@-" in help_text
|
|
777
|
+
|
|
778
|
+
|
|
779
|
+
@pytest.mark.parametrize("command", ["text-to-sfx"])
|
|
780
|
+
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
|
+
The other segment-less endpoint, video-to-video-music, is covered by
|
|
783
|
+
test_video_to_video_music_rejects_segments (it takes no --duration)."""
|
|
784
|
+
with pytest.raises(SystemExit) as exc:
|
|
785
|
+
run([command, "--prompt", "x", "--duration", "3", "--segments", "[]"])
|
|
786
|
+
assert exc.value.code == 1
|
|
787
|
+
assert "unrecognized arguments" in capsys.readouterr().err
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
# --- video-to-video-music / video-to-video-sfx ---------------------------
|
|
791
|
+
#
|
|
792
|
+
# Both return the source picture with the generated audio muxed in, so the
|
|
793
|
+
# task body carries a single `video` object rather than `audio`/`output_url`
|
|
794
|
+
# (shape confirmed against tests/test_video_to_video.py in the SDK repo).
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
def _video_body(task_id, task_type):
|
|
798
|
+
return {
|
|
799
|
+
"task_id": task_id,
|
|
800
|
+
"type": task_type,
|
|
801
|
+
"status": "succeeded",
|
|
802
|
+
"video": {"url": f"https://r2.example.com/{task_id}.mp4",
|
|
803
|
+
"content_type": "video/mp4", "file_size": 7},
|
|
804
|
+
"duration_seconds": 4.0,
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
|
|
808
|
+
def _mock_video_task(endpoint, task_id, task_type, content=b"MP4DATA"):
|
|
809
|
+
"""Wire up submit + poll + download for one video-returning endpoint."""
|
|
810
|
+
route = respx.post(f"{BASE}/v1/{endpoint}").mock(
|
|
811
|
+
return_value=httpx.Response(202, json={"task_id": task_id, "status": "processing"})
|
|
812
|
+
)
|
|
813
|
+
respx.get(f"{BASE}/v1/tasks/{task_id}").mock(
|
|
814
|
+
return_value=httpx.Response(200, json=_video_body(task_id, task_type))
|
|
815
|
+
)
|
|
816
|
+
respx.get(f"https://r2.example.com/{task_id}.mp4").mock(
|
|
817
|
+
return_value=httpx.Response(200, content=content)
|
|
818
|
+
)
|
|
819
|
+
return route
|
|
820
|
+
|
|
821
|
+
|
|
822
|
+
@respx.mock
|
|
823
|
+
def test_video_to_video_music_saves_the_scored_video(tmp_path):
|
|
824
|
+
route = _mock_video_task("video-to-video-music", "vm1", "video_to_video_music")
|
|
825
|
+
out = tmp_path / "scored.mp4"
|
|
826
|
+
run(["video-to-video-music", "--video-url", "http://x/y.mp4",
|
|
827
|
+
"--prompt", "tense synths", "--output", str(out)])
|
|
828
|
+
assert route.called
|
|
829
|
+
assert out.read_bytes() == b"MP4DATA"
|
|
830
|
+
body = unquote_plus(route.calls.last.request.content.decode())
|
|
831
|
+
assert "video_url=http://x/y.mp4" in body
|
|
832
|
+
assert "prompt=tense synths" in body
|
|
833
|
+
|
|
834
|
+
|
|
835
|
+
@respx.mock
|
|
836
|
+
def test_video_to_video_music_defaults_to_mp4(tmp_path, monkeypatch):
|
|
837
|
+
monkeypatch.chdir(tmp_path)
|
|
838
|
+
_mock_video_task("video-to-video-music", "vm2", "video_to_video_music")
|
|
839
|
+
run(["video-to-video-music", "--video-url", "http://x/y.mp4"])
|
|
840
|
+
assert (tmp_path / "output.mp4").read_bytes() == b"MP4DATA"
|
|
841
|
+
|
|
842
|
+
|
|
843
|
+
@respx.mock
|
|
844
|
+
def test_video_to_video_music_flags_reach_the_request_body(tmp_path):
|
|
845
|
+
route = _mock_video_task("video-to-video-music", "vm3", "video_to_video_music")
|
|
846
|
+
run(["video-to-video-music", "--video-url", "http://x/y.mp4",
|
|
847
|
+
"--preserve-speech", "--isolate-vocals", "--output", str(tmp_path / "s.mp4")])
|
|
848
|
+
body = route.calls.last.request.content.decode()
|
|
849
|
+
assert "preserve_speech=true" in body
|
|
850
|
+
assert "isolate_vocals=true" in body
|
|
851
|
+
|
|
852
|
+
|
|
853
|
+
@respx.mock
|
|
854
|
+
def test_video_to_video_music_unset_flags_are_omitted(tmp_path):
|
|
855
|
+
route = _mock_video_task("video-to-video-music", "vm4", "video_to_video_music")
|
|
856
|
+
run(["video-to-video-music", "--video-url", "http://x/y.mp4",
|
|
857
|
+
"--output", str(tmp_path / "s.mp4")])
|
|
858
|
+
# Unset switches must forward None, not False, so the server default
|
|
859
|
+
# stands — same rule as --no-ducking on the sound commands.
|
|
860
|
+
body = route.calls.last.request.content.decode()
|
|
861
|
+
assert "preserve_speech=" not in body
|
|
862
|
+
assert "isolate_vocals=" not in body
|
|
863
|
+
assert "prompt=" not in body
|
|
864
|
+
|
|
865
|
+
|
|
866
|
+
def test_video_to_video_music_requires_a_video_source():
|
|
867
|
+
with pytest.raises(SystemExit) as exc:
|
|
868
|
+
run(["video-to-video-music", "--prompt", "x"])
|
|
869
|
+
assert exc.value.code == 1
|
|
870
|
+
|
|
871
|
+
|
|
872
|
+
def test_video_to_video_music_rejects_both_sources():
|
|
873
|
+
with pytest.raises(SystemExit) as exc:
|
|
874
|
+
run(["video-to-video-music", "--video", "a.mp4", "--video-url", "http://x/y.mp4"])
|
|
875
|
+
assert exc.value.code == 1
|
|
876
|
+
|
|
877
|
+
|
|
878
|
+
def test_video_to_video_music_rejects_segments(capsys):
|
|
879
|
+
"""The endpoint scores the whole clip in one pass — the SDK resource takes
|
|
880
|
+
no `segments`, so the CLI must not offer the flag."""
|
|
881
|
+
with pytest.raises(SystemExit) as exc:
|
|
882
|
+
run(["video-to-video-music", "--video-url", "http://x/y.mp4", "--segments", "[]"])
|
|
883
|
+
assert exc.value.code == 1
|
|
884
|
+
assert "unrecognized arguments" in capsys.readouterr().err
|
|
885
|
+
|
|
886
|
+
|
|
887
|
+
@respx.mock
|
|
888
|
+
def test_video_to_video_sfx_saves_the_scored_video(tmp_path):
|
|
889
|
+
route = _mock_video_task("video-to-video-sfx", "vf1", "video_to_video_sfx")
|
|
890
|
+
out = tmp_path / "scored.mp4"
|
|
891
|
+
run(["video-to-video-sfx", "--video-url", "http://x/y.mp4",
|
|
892
|
+
"--prompt", "footsteps", "--output", str(out)])
|
|
893
|
+
assert route.called
|
|
894
|
+
assert out.read_bytes() == b"MP4DATA"
|
|
895
|
+
body = unquote_plus(route.calls.last.request.content.decode())
|
|
896
|
+
assert "video_url=http://x/y.mp4" in body
|
|
897
|
+
assert "prompt=footsteps" in body
|
|
898
|
+
|
|
899
|
+
|
|
900
|
+
@respx.mock
|
|
901
|
+
def test_video_to_video_sfx_defaults_to_mp4(tmp_path, monkeypatch):
|
|
902
|
+
monkeypatch.chdir(tmp_path)
|
|
903
|
+
_mock_video_task("video-to-video-sfx", "vf2", "video_to_video_sfx")
|
|
904
|
+
run(["video-to-video-sfx", "--video-url", "http://x/y.mp4"])
|
|
905
|
+
assert (tmp_path / "output.mp4").read_bytes() == b"MP4DATA"
|
|
906
|
+
|
|
907
|
+
|
|
908
|
+
@respx.mock
|
|
909
|
+
def test_video_to_video_sfx_segments_reach_the_request_body(tmp_path):
|
|
910
|
+
route = _mock_video_task("video-to-video-sfx", "vf3", "video_to_video_sfx")
|
|
911
|
+
run(["video-to-video-sfx", "--video-url", "http://x/y.mp4",
|
|
912
|
+
"--segments", json.dumps(SFX_SEGMENTS), "--output", str(tmp_path / "s.mp4")])
|
|
913
|
+
assert _sent_segments(route) == SFX_SEGMENTS
|
|
914
|
+
|
|
915
|
+
|
|
916
|
+
@respx.mock
|
|
917
|
+
def test_video_to_video_sfx_omitting_segments_sends_no_field(tmp_path):
|
|
918
|
+
route = _mock_video_task("video-to-video-sfx", "vf4", "video_to_video_sfx")
|
|
919
|
+
run(["video-to-video-sfx", "--video-url", "http://x/y.mp4",
|
|
920
|
+
"--output", str(tmp_path / "s.mp4")])
|
|
921
|
+
assert b"segments" not in route.calls.last.request.content
|
|
922
|
+
|
|
923
|
+
|
|
924
|
+
def test_video_to_video_sfx_rejects_music_shaped_segments(capsys):
|
|
925
|
+
with pytest.raises(SystemExit) as exc:
|
|
926
|
+
run(["video-to-video-sfx", "--video-url", "http://x/y.mp4",
|
|
927
|
+
"--segments", json.dumps(MUSIC_SEGMENTS)])
|
|
928
|
+
assert exc.value.code == 1
|
|
929
|
+
err = capsys.readouterr().err
|
|
930
|
+
assert "video-to-video-sfx segments take {start, end, prompt}" in err
|
|
931
|
+
assert "got an object with keys start, label, prompt" in err
|
|
932
|
+
|
|
933
|
+
|
|
934
|
+
def test_video_to_video_sfx_requires_a_video_source():
|
|
935
|
+
with pytest.raises(SystemExit) as exc:
|
|
936
|
+
run(["video-to-video-sfx", "--prompt", "x"])
|
|
937
|
+
assert exc.value.code == 1
|
|
938
|
+
|
|
939
|
+
|
|
940
|
+
def test_video_to_video_sfx_rejects_both_sources():
|
|
941
|
+
with pytest.raises(SystemExit) as exc:
|
|
942
|
+
run(["video-to-video-sfx", "--video", "a.mp4", "--video-url", "http://x/y.mp4"])
|
|
943
|
+
assert exc.value.code == 1
|
|
944
|
+
|
|
945
|
+
|
|
946
|
+
@pytest.mark.parametrize("command", ["video-to-music", "video-to-video-music"])
|
|
947
|
+
def test_isolate_vocals_is_documented_as_an_alias(command, capsys):
|
|
948
|
+
"""Both endpoints OR the two fields into one behaviour server-side
|
|
949
|
+
(video_to_music.py: `isolate_vocals = bool(preserve_speech) or
|
|
950
|
+
bool(isolate_vocals)`; video_to_video.py: `keep_speech = bool(...) or
|
|
951
|
+
bool(...)`), so the help must not present --isolate-vocals as a separate
|
|
952
|
+
feature. On video-to-video-music there is no stem at all — the result is
|
|
953
|
+
a single muxed video."""
|
|
954
|
+
with pytest.raises(SystemExit):
|
|
955
|
+
main([command, "--help"])
|
|
956
|
+
help_text = capsys.readouterr().out
|
|
957
|
+
assert "Legacy alias for --preserve-speech" in help_text
|
|
958
|
+
assert "vocals-only stem" not in help_text
|
|
959
|
+
|
|
960
|
+
|
|
961
|
+
@pytest.mark.parametrize(
|
|
962
|
+
"command", ["video-to-video-music", "video-to-video-sfx"]
|
|
963
|
+
)
|
|
964
|
+
def test_video_to_video_commands_are_listed_in_top_level_help(command, capsys):
|
|
965
|
+
"""Both are documented publicly as `sonilo <command>`, so they have to be
|
|
966
|
+
discoverable from `sonilo --help`, not just by knowing the name."""
|
|
967
|
+
with pytest.raises(SystemExit):
|
|
968
|
+
main(["--help"])
|
|
969
|
+
assert command in capsys.readouterr().out
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|