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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sonilo-cli
3
- Version: 0.3.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
- `--isolate-vocals`, and `--preserve-speech` each switch to the async submit-and-poll path.
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
- `--isolate-vocals`, and `--preserve-speech` each switch to the async submit-and-poll path.
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.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "sonilo-cli"
7
- version = "0.3.0"
7
+ version = "0.5.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"
@@ -1,3 +1,3 @@
1
- __version__ = "0.3.0"
1
+ __version__ = "0.5.0"
2
2
 
3
3
  __all__ = ["__version__"]
@@ -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(prompt=args.prompt, duration=args.duration)
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