sonilo-cli 0.3.0__tar.gz → 0.4.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.4.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,6 +38,7 @@ 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"
43
44
  sonilo video-to-video-sound --video clip.mp4 --music-prompt "tense synths"
@@ -53,6 +54,36 @@ or pass `--api-key sk-...` on any command.
53
54
  - `text-to-sfx` / `video-to-sfx` are always async; `--format` accepts `wav|mp3|aac|flac`.
54
55
  - Output defaults to `./output.<ext>`; override with `--output`.
55
56
 
57
+ ### Segments
58
+
59
+ `--segments` scores a timeline instead of one whole-clip prompt. It takes a JSON array, in one of
60
+ three forms — inline, from a file, or from stdin:
61
+
62
+ sonilo text-to-music --prompt "warm lo-fi piano" --duration 30 \
63
+ --segments '[{"start":0,"label":"intro","prompt":"airy pads"}]'
64
+ sonilo video-to-sfx --video clip.mp4 --segments @segments.json
65
+ jq -c '.cues' storyboard.json | sonilo video-to-sfx --video clip.mp4 --segments @-
66
+
67
+ A value starting with `@` names a source to read the JSON from, and `@-` reads standard input — the
68
+ same convention as `curl`, `gh` and `aws`. Anything else is parsed as JSON directly.
69
+
70
+ The two segment shapes are **not** interchangeable:
71
+
72
+ | Shape | Commands | Fields |
73
+ | --- | --- | --- |
74
+ | Music | `text-to-music`, `video-to-music` | `{start, prompt, label?}` |
75
+ | SFX | `video-to-sfx`, `video-to-sound`, `video-to-video-sound` | `{start, end, prompt}` |
76
+
77
+ - `start` / `end` are seconds from the start of the track or clip.
78
+ - Passing one shape to a command that takes the other is rejected before any request is made, with
79
+ a message naming the shape that command expects.
80
+ - Only the shape is checked locally. Timing rules — the first segment starting at 0, minimum
81
+ spacing between segments, the `label` vocabulary, how many segments are allowed — are enforced by
82
+ the API, which answers with a `422` describing what it rejected.
83
+ - Keys the CLI does not recognise are forwarded as-is, so a newly added API field works without
84
+ upgrading the CLI.
85
+ - `text-to-sfx` takes no segments (its output is a single effect, not a timeline).
86
+
56
87
  ### Combined soundtracks
57
88
 
58
89
  `video-to-sound` and `video-to-video-sound` score a clip with a music bed *and* sound effects in one
@@ -67,6 +98,8 @@ they differ only in what comes back: `video-to-sound` writes the mixed **audio**
67
98
  --output soundtrack.wav --stem music --stem sfx
68
99
 
69
100
  - `--music-prompt` / `--sfx-prompt` steer the two layers separately; both are optional.
101
+ - `--segments` places individual effects on the timeline, in the SFX shape `{start, end, prompt}` —
102
+ see [Segments](#segments).
70
103
  - `--preserve-speech` keeps speech from the source video in the mix.
71
104
  - **Ducking is on by default** (music dips under speech). Pass `--no-ducking` to opt out — omitting
72
105
  the flag leaves the server default untouched.
@@ -22,6 +22,7 @@ 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"
27
28
  sonilo video-to-video-sound --video clip.mp4 --music-prompt "tense synths"
@@ -37,6 +38,36 @@ or pass `--api-key sk-...` on any command.
37
38
  - `text-to-sfx` / `video-to-sfx` are always async; `--format` accepts `wav|mp3|aac|flac`.
38
39
  - Output defaults to `./output.<ext>`; override with `--output`.
39
40
 
41
+ ### Segments
42
+
43
+ `--segments` scores a timeline instead of one whole-clip prompt. It takes a JSON array, in one of
44
+ three forms — inline, from a file, or from stdin:
45
+
46
+ sonilo text-to-music --prompt "warm lo-fi piano" --duration 30 \
47
+ --segments '[{"start":0,"label":"intro","prompt":"airy pads"}]'
48
+ sonilo video-to-sfx --video clip.mp4 --segments @segments.json
49
+ jq -c '.cues' storyboard.json | sonilo video-to-sfx --video clip.mp4 --segments @-
50
+
51
+ A value starting with `@` names a source to read the JSON from, and `@-` reads standard input — the
52
+ same convention as `curl`, `gh` and `aws`. Anything else is parsed as JSON directly.
53
+
54
+ The two segment shapes are **not** interchangeable:
55
+
56
+ | Shape | Commands | Fields |
57
+ | --- | --- | --- |
58
+ | Music | `text-to-music`, `video-to-music` | `{start, prompt, label?}` |
59
+ | SFX | `video-to-sfx`, `video-to-sound`, `video-to-video-sound` | `{start, end, prompt}` |
60
+
61
+ - `start` / `end` are seconds from the start of the track or clip.
62
+ - Passing one shape to a command that takes the other is rejected before any request is made, with
63
+ a message naming the shape that command expects.
64
+ - Only the shape is checked locally. Timing rules — the first segment starting at 0, minimum
65
+ spacing between segments, the `label` vocabulary, how many segments are allowed — are enforced by
66
+ the API, which answers with a `422` describing what it rejected.
67
+ - Keys the CLI does not recognise are forwarded as-is, so a newly added API field works without
68
+ upgrading the CLI.
69
+ - `text-to-sfx` takes no segments (its output is a single effect, not a timeline).
70
+
40
71
  ### Combined soundtracks
41
72
 
42
73
  `video-to-sound` and `video-to-video-sound` score a clip with a music bed *and* sound effects in one
@@ -51,6 +82,8 @@ they differ only in what comes back: `video-to-sound` writes the mixed **audio**
51
82
  --output soundtrack.wav --stem music --stem sfx
52
83
 
53
84
  - `--music-prompt` / `--sfx-prompt` steer the two layers separately; both are optional.
85
+ - `--segments` places individual effects on the timeline, in the SFX shape `{start, end, prompt}` —
86
+ see [Segments](#segments).
54
87
  - `--preserve-speech` keeps speech from the source video in the mix.
55
88
  - **Ducking is on by default** (music dips under speech). Pass `--no-ducking` to opt out — omitting
56
89
  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.4.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.4.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
  )
@@ -269,6 +413,18 @@ def _add_video_source(parser: argparse.ArgumentParser) -> None:
269
413
  help="Remote video URL to score.")
270
414
 
271
415
 
416
+ def _add_segments(parser: argparse.ArgumentParser, shape: _SegmentShape) -> None:
417
+ parser.add_argument(
418
+ "--segments", default=None,
419
+ help=f"Timed segments, as a JSON array of {shape.summary} objects. "
420
+ "Pass the JSON inline, @FILE to read it from a file, "
421
+ "or @- to read it from stdin.",
422
+ )
423
+ # Carried on the namespace so the one shared reader knows which contract
424
+ # to check the value against, and can name it when the check fails.
425
+ parser.set_defaults(segments_shape=shape)
426
+
427
+
272
428
  def build_parser() -> argparse.ArgumentParser:
273
429
  parser = _Parser(prog="sonilo", description="Command-line interface for the Sonilo API")
274
430
  parser.add_argument("--version", action="version", version=__version__)
@@ -288,6 +444,7 @@ def build_parser() -> argparse.ArgumentParser:
288
444
  _add_global(p_t2m)
289
445
  p_t2m.add_argument("--prompt", required=True, help="What the music should sound like.")
290
446
  p_t2m.add_argument("--duration", type=int, required=True, help="Track length in seconds.")
447
+ _add_segments(p_t2m, MUSIC_SHAPE)
291
448
  p_t2m.add_argument("--output", default=None, help="Where to save the audio.")
292
449
  p_t2m.add_argument("--format", choices=["m4a", "wav"], default="m4a",
293
450
  help="Output container. wav forces async. Default: m4a")
@@ -299,6 +456,7 @@ def build_parser() -> argparse.ArgumentParser:
299
456
  _add_global(p_v2m)
300
457
  _add_video_source(p_v2m)
301
458
  p_v2m.add_argument("--prompt", default=None, help="Optional creative direction.")
459
+ _add_segments(p_v2m, MUSIC_SHAPE)
302
460
  p_v2m.add_argument("--output", default=None, help="Where to save the audio.")
303
461
  p_v2m.add_argument("--format", choices=["m4a", "wav"], default="m4a",
304
462
  help="Output container. wav forces async.")
@@ -323,6 +481,7 @@ def build_parser() -> argparse.ArgumentParser:
323
481
  _add_global(p_v2s)
324
482
  _add_video_source(p_v2s)
325
483
  p_v2s.add_argument("--prompt", default=None, help="Optional creative direction.")
484
+ _add_segments(p_v2s, SFX_SHAPE)
326
485
  p_v2s.add_argument("--output", default=None, help="Where to save the audio.")
327
486
  p_v2s.add_argument("--format", choices=_SFX_FORMATS, default="wav",
328
487
  help="Output format. Default: wav")
@@ -337,6 +496,7 @@ def build_parser() -> argparse.ArgumentParser:
337
496
  help="Optional creative direction for the music bed.")
338
497
  p_v2sd.add_argument("--sfx-prompt", dest="sfx_prompt", default=None,
339
498
  help="Optional creative direction for the sound effects.")
499
+ _add_segments(p_v2sd, SFX_SHAPE)
340
500
  p_v2sd.add_argument("--preserve-speech", dest="preserve_speech", action="store_true",
341
501
  help="Keep source speech in the mix.")
342
502
  p_v2sd.add_argument("--no-ducking", dest="no_ducking", action="store_true",
@@ -355,6 +515,7 @@ def build_parser() -> argparse.ArgumentParser:
355
515
  help="Optional creative direction for the music bed.")
356
516
  p_v2vsd.add_argument("--sfx-prompt", dest="sfx_prompt", default=None,
357
517
  help="Optional creative direction for the sound effects.")
518
+ _add_segments(p_v2vsd, SFX_SHAPE)
358
519
  p_v2vsd.add_argument("--preserve-speech", dest="preserve_speech", action="store_true",
359
520
  help="Keep source speech in the mix.")
360
521
  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,251 @@ 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",
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
+ (video-to-video-music, the other segment-less endpoint, has no CLI
783
+ command at all)."""
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
File without changes
File without changes
File without changes