@hraness/slopcamera 3.8.1 → 3.9.1

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.
@@ -0,0 +1,76 @@
1
+ # Compose and render vector icon scenes
2
+
3
+ Use this workflow when the user wants a deterministic vector icon, a small
4
+ composed scene of icons, or a drawn construction program, without a model or a
5
+ network request. Slopcamera bundles the icon.place library at one exact version
6
+ for this. For a generated product mark or topic icon from a text brief, use
7
+ [brand illustrations](brand-illustrations.md) instead.
8
+
9
+ ## Pick a source
10
+
11
+ Both commands read one local JSON file of at most 1 MiB. The kind is detected
12
+ from its content:
13
+
14
+ - A **scene**: up to eight placed icons with a canvas and a background.
15
+ - A **collection**: up to six layered icons that compose into one scene.
16
+ - A **construction program**: parts, operations and a drawing recipe that
17
+ compile to one drawing.
18
+ - A **recipe** that an earlier render wrote. A recipe records the source, the
19
+ render settings and the output digests.
20
+
21
+ Keep the source in the workspace as the authoritative file. The SVG is a
22
+ replaceable derivative.
23
+
24
+ ## Compose
25
+
26
+ `compose` parses and solves the source and reports its digests, element count
27
+ and diagnostics. It writes nothing unless `--output` names a `.json` file for the
28
+ canonical solved document:
29
+
30
+ ```sh
31
+ slopcamera image icon compose icons/window.json --json
32
+ slopcamera image icon compose icons/window.json --output icons/window.solved.json
33
+ ```
34
+
35
+ Composing a solved document again gives the same digests. Report any
36
+ diagnostics; they are the library's own layout findings.
37
+
38
+ ## Render
39
+
40
+ `render` draws the source to an inert SVG. `--recipe` also writes a replayable
41
+ recipe:
42
+
43
+ ```sh
44
+ slopcamera image icon render icons/window.json --output icons/window.svg \
45
+ --recipe icons/window.recipe.json --size 256 --palette ink
46
+ ```
47
+
48
+ - `--size` sets the output edge from 16 to 1024 pixels.
49
+ - `--palette` (`original`, `ink`, `earth` or `night`) applies to scenes and
50
+ collections. `--background` paints the paper behind any source.
51
+ - `--language` selects one of the drawing languages for a construction program,
52
+ such as `outline`, `woodcut` or `stipple`. `slopcamera help image` lists them.
53
+
54
+ To reproduce an icon, render its recipe. A recipe fixes its own settings, so do
55
+ not pass `--size`, `--palette`, `--background` or `--language` with it. Replay
56
+ fails if the recipe was edited after it was written or if the drawing no longer
57
+ matches the recorded digests; report that instead of re-rendering from scratch.
58
+
59
+ The same renders are available as the `compose_icon` and `render_icon` MCP
60
+ tools with root-relative paths, and as the `slopcamera.icon.compose` and
61
+ `slopcamera.icon.render` operations.
62
+
63
+ ## Checks
64
+
65
+ - The output is a path-only SVG. Scripts, styles, images, event handlers,
66
+ external references and foreign content are rejected before anything is
67
+ written.
68
+ - Source, output and recipe must be three different files. Existing outputs are
69
+ replaced atomically.
70
+ - Receipts record the source digest, the solved and SVG digests, and the
71
+ library version. Keep the receipt when provenance matters.
72
+ - Inspect the SVG at its intended size. A clean render is not evidence that
73
+ the icon communicates the intended idea.
74
+
75
+ For diagram icons from a third-party icon package, keep using a local adapter as
76
+ described in [customization](customization.md).
@@ -14,14 +14,14 @@ command -v slopcamera
14
14
 
15
15
  A restricted shell can omit package-manager paths. Check known host installation paths before declaring a tool unavailable. If Bun is genuinely absent, follow its official [installation guide](https://bun.sh/docs/installation) within the user’s authorized setup scope. Do not switch package managers or pipe an unreviewed installer into a shell.
16
16
 
17
- Slopcamera installs from its verified release archive: `bun add --global https://github.com/hraness/slopcamera/releases/download/v3.8.0/hraness-slopcamera-3.8.0.tgz`, then `slopcamera doctor --json`. Building from source is the contributor path. Historical Atet archives do not install the renamed CLI; never substitute `slopcamera` into an old archive URL.
17
+ Slopcamera installs from its verified release archive: `bun add --global https://github.com/hraness/slopcamera/releases/download/v3.9.0/hraness-slopcamera-3.9.0.tgz`, then `slopcamera doctor --json`. Building from source is the contributor path. Historical Atet archives do not install the renamed CLI; never substitute `slopcamera` into an old archive URL.
18
18
 
19
19
  If an update from a previous local archive reports `DependencyLoop`, use Bun's
20
20
  [named tarball syntax](https://bun.com/docs/pm/cli/add#tarball-dependencies) with
21
21
  the same verified archive:
22
22
 
23
23
  ```sh
24
- bun add --global @hraness/slopcamera@https://github.com/hraness/slopcamera/releases/download/v3.8.0/hraness-slopcamera-3.8.0.tgz
24
+ bun add --global @hraness/slopcamera@https://github.com/hraness/slopcamera/releases/download/v3.9.0/hraness-slopcamera-3.9.0.tgz
25
25
  slopcamera --version
26
26
  ```
27
27
 
@@ -18,8 +18,16 @@ delivery. Keep the scene's deliberate style and original media intact.
18
18
 
19
19
  Use the user's selected track and supplied tempo. Establish an explicit duration,
20
20
  beat-zero offset, and beats per bar from the task or relevant audio inspection.
21
- The first version does not detect tempo, downbeats, or track duration. Do not
22
- replace explicit musical timing with an unsupported automatic-analysis claim.
21
+ The first version does not detect tempo, downbeats, or track duration from
22
+ audio. Do not replace explicit musical timing with an unsupported
23
+ automatic-analysis claim.
24
+
25
+ When the track was made from a Soundfish score or a MIDI file, derive the timing
26
+ from that source instead with
27
+ `slopcamera media soundtrack grid <score> --start-us <n> --json`. Its `music`
28
+ object drops straight into `parameters.music` below, and its section cue times
29
+ mark shot changes. See [soundtracks](soundtracks.md). Take `timing.durationUs`
30
+ from the audio file itself.
23
31
 
24
32
  Check the installed command and host before preparing exact source:
25
33
 
@@ -0,0 +1,58 @@
1
+ # Derive a soundtrack beat grid
2
+
3
+ Use this workflow when a music video or timed HTML scene has a Soundfish score
4
+ or a Standard MIDI file, and the scene needs the track's tempo, meter and
5
+ section cue times. Slopcamera bundles the Soundfish library at one exact version
6
+ to read scores. It renders no audio: pair the grid with the audio file the user
7
+ mixes or supplies.
8
+
9
+ ## Supported sources
10
+
11
+ One local file of at most 1 MiB, detected from its content or set with
12
+ `--format`:
13
+
14
+ - `compose`: Soundfish compose text for one loop.
15
+ - `song`: Soundfish song text with named sections.
16
+ - `json`: a Soundfish loop or song document.
17
+ - `midi`: a Standard MIDI file with one constant tempo and meter.
18
+
19
+ To write or edit scores, install the `@hraness/soundfish` package and use its
20
+ own agent skill. Slopcamera does not bundle that skill.
21
+
22
+ ## Verify a score
23
+
24
+ ```sh
25
+ slopcamera media soundtrack compose music/hook.song --json
26
+ slopcamera media soundtrack compose music/hook.song --output music/hook.json
27
+ ```
28
+
29
+ `compose` parses and verifies the score and reports its canonical digest, kind
30
+ (loop or song), tempo, beats per bar, bars and duration. `--output` writes the
31
+ verified score document as `.json`. Report any warnings the receipt carries.
32
+
33
+ ## Derive the grid
34
+
35
+ ```sh
36
+ slopcamera media soundtrack grid music/hook.song --start-us 500000 \
37
+ --output music/hook.grid.json --json
38
+ ```
39
+
40
+ The receipt's `music` object is exactly the `{ "bpm", "beatOffsetUs",
41
+ "beatsPerBar" }` shape an HTML scene request accepts under
42
+ `parameters.music`. `--start-us` is the timeline microsecond where the first
43
+ beat lands; it defaults to `0`. Each section carries its label, repeat, bars,
44
+ beats and `startUs`/`endUs` cue times on the same timeline.
45
+
46
+ - Cue times are whole microseconds and never drift more than half a microsecond
47
+ from the beat they name.
48
+ - Sections tile the score from `beatOffsetUs` to `endUs` with no gaps.
49
+ - The same score always yields the same digest and grid.
50
+
51
+ Use the grid in [music videos](music-video.md): copy `music` into the scene
52
+ request and use the section cues for shot changes. Set `timing.durationUs` from
53
+ the audio file, not only from the grid, because a mixed track can have a
54
+ different tail.
55
+
56
+ The same reads are available as the `compose_soundtrack` and
57
+ `derive_soundtrack_grid` MCP tools with root-relative paths, and as the
58
+ `slopcamera.soundtrack.compose` and `slopcamera.soundtrack.grid` operations.
@@ -0,0 +1,135 @@
1
+ import { createHash, randomUUID } from "node:crypto"
2
+ import { constants } from "node:fs"
3
+ import { mkdir, open, rename, rm, stat } from "node:fs/promises"
4
+ import { dirname, join, resolve } from "node:path"
5
+
6
+ /**
7
+ * Bounded local file reads and atomic replaceable writes shared by the
8
+ * icon-scene and soundtrack operations. Reads check the size before and
9
+ * after reading so a growing file cannot exceed the declared bound.
10
+ */
11
+
12
+ export class BoundedFileError extends Error {
13
+ readonly code: "SOURCE_NOT_FOUND" | "SOURCE_NOT_FILE" | "SOURCE_TOO_LARGE" | "SOURCE_UNREADABLE"
14
+
15
+ constructor(code: BoundedFileError["code"], message: string) {
16
+ super(message)
17
+ this.name = "BoundedFileError"
18
+ this.code = code
19
+ }
20
+ }
21
+
22
+ function errorCode(error: unknown): string | undefined {
23
+ return typeof error === "object" && error !== null && "code" in error
24
+ ? String((error as { readonly code: unknown }).code)
25
+ : undefined
26
+ }
27
+
28
+ export async function readBoundedFile(
29
+ path: string,
30
+ maximumBytes: number,
31
+ label: string,
32
+ ): Promise<Uint8Array> {
33
+ let handle
34
+ try {
35
+ // Non-blocking so a FIFO or device fails the regular-file check instead of hanging.
36
+ handle = await open(path, constants.O_RDONLY | constants.O_NONBLOCK)
37
+ } catch (error) {
38
+ if (errorCode(error) === "ENOENT") {
39
+ throw new BoundedFileError("SOURCE_NOT_FOUND", `${label} does not exist.`)
40
+ }
41
+ throw new BoundedFileError("SOURCE_UNREADABLE", `${label} could not be opened.`)
42
+ }
43
+ try {
44
+ const metadata = await handle.stat()
45
+ if (!metadata.isFile()) {
46
+ throw new BoundedFileError("SOURCE_NOT_FILE", `${label} must be a regular file.`)
47
+ }
48
+ if (metadata.size > maximumBytes) {
49
+ throw new BoundedFileError(
50
+ "SOURCE_TOO_LARGE",
51
+ `${label} exceeds the ${String(maximumBytes)}-byte limit.`,
52
+ )
53
+ }
54
+ // Read one byte past the bound so a file that grew after stat fails closed.
55
+ const buffer = new Uint8Array(maximumBytes + 1)
56
+ let length = 0
57
+ for (;;) {
58
+ const { bytesRead } = await handle.read(buffer, length, buffer.length - length, length)
59
+ if (bytesRead === 0) break
60
+ length += bytesRead
61
+ if (length > maximumBytes) {
62
+ throw new BoundedFileError(
63
+ "SOURCE_TOO_LARGE",
64
+ `${label} exceeds the ${String(maximumBytes)}-byte limit.`,
65
+ )
66
+ }
67
+ }
68
+ return buffer.slice(0, length)
69
+ } finally {
70
+ await handle.close()
71
+ }
72
+ }
73
+
74
+ export function decodeUtf8Source(bytes: Uint8Array, label: string): string {
75
+ try {
76
+ return new TextDecoder("utf-8", { fatal: true, ignoreBOM: false }).decode(bytes)
77
+ } catch {
78
+ throw new BoundedFileError("SOURCE_UNREADABLE", `${label} must be UTF-8 text.`)
79
+ }
80
+ }
81
+
82
+ export function sha256Hex(value: string | Uint8Array): string {
83
+ return createHash("sha256").update(value).digest("hex")
84
+ }
85
+
86
+ /** Replace `path` atomically with `value`, creating its parent directory. */
87
+ export async function publishReplaceableFile(
88
+ path: string,
89
+ value: string | Uint8Array,
90
+ ): Promise<void> {
91
+ const directory = dirname(path)
92
+ await mkdir(directory, { recursive: true })
93
+ // A fixed-length name stays under NAME_MAX whatever the target's basename.
94
+ const temporaryPath = join(directory, `.slopcamera-${randomUUID()}.tmp`)
95
+ try {
96
+ const handle = await open(temporaryPath, "wx")
97
+ try {
98
+ await handle.writeFile(value)
99
+ await handle.sync()
100
+ } finally {
101
+ await handle.close()
102
+ }
103
+ await rename(temporaryPath, path)
104
+ } finally {
105
+ await rm(temporaryPath, { force: true }).catch(() => undefined)
106
+ }
107
+ }
108
+
109
+ /**
110
+ * True when two declared paths name the same file, such as an output that
111
+ * would replace its source. Equal resolved spellings collide; existing files
112
+ * also collide by device and inode, which catches links and case-insensitive
113
+ * spellings of one file.
114
+ */
115
+ export async function hasPathCollision(paths: readonly (string | undefined)[]): Promise<boolean> {
116
+ const spellings = new Set<string>()
117
+ const identities = new Set<string>()
118
+ for (const path of paths) {
119
+ if (path === undefined) continue
120
+ const absolute = resolve(path)
121
+ if (spellings.has(absolute)) return true
122
+ spellings.add(absolute)
123
+ let identity: string
124
+ try {
125
+ const metadata = await stat(absolute, { bigint: true })
126
+ identity = `${String(metadata.dev)}:${String(metadata.ino)}`
127
+ } catch (error) {
128
+ if (errorCode(error) === "ENOENT" || errorCode(error) === "ENOTDIR") continue
129
+ throw new BoundedFileError("SOURCE_UNREADABLE", "A declared path could not be inspected.")
130
+ }
131
+ if (identities.has(identity)) return true
132
+ identities.add(identity)
133
+ }
134
+ return false
135
+ }
package/src/cli.ts CHANGED
@@ -42,6 +42,12 @@ import {
42
42
  withSlopcameraOperationHostAdmission,
43
43
  } from "./operations.js"
44
44
  import type { HostResourceCoordinator } from "./host-resources.js"
45
+ import {
46
+ slopcameraIconLanguages,
47
+ slopcameraIconPalettes,
48
+ slopcameraIconSceneLimits,
49
+ } from "./icon-scene.js"
50
+ import { slopcameraSoundtrackFormats } from "./soundtrack.js"
45
51
  import { installSkill, type SkillScope, type SkillTarget } from "./skill-install.js"
46
52
  import { pathExists } from "./fs.js"
47
53
  import { checkDrawingFile, renderDrawingFile, starterDrawingSource } from "./drawing.js"
@@ -75,6 +81,14 @@ Usage:
75
81
  slopcamera image gallery <subject> --output-dir <directory> [--kind <${slopcameraGalleryKinds.join("|")}>]
76
82
  [--count <1-${slopcameraGalleryLimits.candidates}>] [--vary <axis[=v1,v2][;axis...]>] [--candidates <file.json>]
77
83
  [--model <provider/model>] [--cell <${slopcameraGalleryLimits.cellEdgeMin}-${slopcameraGalleryLimits.cellEdgeMax}>] [--tile|--no-tile] [--json]
84
+ slopcamera image icon compose <source.json> [--output <file.json>] [--json]
85
+ slopcamera image icon render <source.json> --output <file.svg> [--recipe <file.json>]
86
+ [--size <${slopcameraIconSceneLimits.sizeMin}-${slopcameraIconSceneLimits.sizeMax}>] [--background] [--palette <${slopcameraIconPalettes.join("|")}>]
87
+ [--language <language>] [--json]
88
+ slopcamera media soundtrack compose <score> [--output <file.json>]
89
+ [--format <${slopcameraSoundtrackFormats.join("|")}>] [--json]
90
+ slopcamera media soundtrack grid <score> [--output <file.json>] [--start-us <n>]
91
+ [--format <${slopcameraSoundtrackFormats.join("|")}>] [--json]
78
92
  slopcamera style list [--json]
79
93
  slopcamera style show <id> [--json]
80
94
  slopcamera code search [query] [--limit <number>]
@@ -122,6 +136,16 @@ measured coverage and stroke-weight spread across the set, and a
122
136
  contact-sheet critique reviews the family as a unit before SVGs publish.
123
137
  Manifests and receipts stay local.
124
138
 
139
+ Icon compose and render are local and deterministic: they solve and draw
140
+ vector icon scenes, collections, construction programs, and recipes with the
141
+ bundled icon.place library, emit inert SVG, and replay a recipe only when its
142
+ recorded digests match. Languages: ${slopcameraIconLanguages.join(", ")}.
143
+
144
+ Media soundtrack verifies a Soundfish loop or song (compose text, song text,
145
+ score JSON, or a Standard MIDI file) and derives its beat grid as the
146
+ {bpm, beatOffsetUs, beatsPerBar} music timing HTML scene requests accept, plus
147
+ section cue times. It renders no audio and makes no network requests.
148
+
125
149
  Gallery generates several bounded candidates in parallel and composes them
126
150
  into one labelled contact sheet plus a receipt. Use it to review texture,
127
151
  skybox, backdrop, sprite, or design alternatives, then promote a chosen
@@ -365,6 +389,10 @@ function canonicalArguments(args: readonly string[]): readonly string[] {
365
389
  }
366
390
  throw new Error("Use slopcamera diagram init, check, render, or sheets")
367
391
  }
392
+ if (surface === "media") {
393
+ if (subcommand === "soundtrack") return ["soundtrack", ...rest]
394
+ throw new Error("Use slopcamera media soundtrack compose or grid")
395
+ }
368
396
  if (surface === "image") {
369
397
  if (
370
398
  subcommand === "vectorize" ||
@@ -388,6 +416,161 @@ function canonicalArguments(args: readonly string[]): readonly string[] {
388
416
  return args
389
417
  }
390
418
 
419
+ function rejectUnknownCliFlags(
420
+ parsed: ParsedArguments,
421
+ allowed: readonly string[],
422
+ command: string,
423
+ ): void {
424
+ const unknown = [...parsed.flags].find((flag) => !allowed.includes(flag))
425
+ if (unknown !== undefined) throw new Error(`Unknown ${command} option: --${unknown}`)
426
+ }
427
+
428
+ function exactlyOnePositional(parsed: ParsedArguments, usage: string): string {
429
+ if (parsed.positionals.length !== 1) throw new Error(`Use ${usage}`)
430
+ return parsed.positionals[0]!
431
+ }
432
+
433
+ function logOperationResult(
434
+ dependencies: SlopcameraCliDependencies,
435
+ json: boolean,
436
+ receipt: unknown,
437
+ summary: string,
438
+ ): void {
439
+ ;(dependencies.log ?? console.log)(json ? JSON.stringify(receipt, null, 2) : summary)
440
+ reportUsefulResult(dependencies.onUsefulResult)
441
+ }
442
+
443
+ async function runIconScene(
444
+ action: "compose" | "render",
445
+ args: readonly string[],
446
+ dependencies: SlopcameraCliDependencies,
447
+ ): Promise<void> {
448
+ if (action === "compose") {
449
+ const usage = "slopcamera image icon compose <source.json> [--output <file.json>] [--json]"
450
+ const parsed = parseArguments(args, new Set(["output"]))
451
+ rejectUnknownCliFlags(parsed, ["json"], "icon compose")
452
+ const sourcePath = exactlyOnePositional(parsed, usage)
453
+ const receipt = await executeSlopcameraOperation(
454
+ "slopcamera.icon.compose",
455
+ {
456
+ sourcePath,
457
+ ...(parsed.options.output === undefined ? {} : { outputPath: parsed.options.output }),
458
+ },
459
+ hostAdmissionOptions(dependencies),
460
+ )
461
+ logOperationResult(
462
+ dependencies,
463
+ parsed.flags.has("json"),
464
+ receipt,
465
+ `Composed ${receipt.form} "${receipt.title}": ${receipt.elementCount} element${receipt.elementCount === 1 ? "" : "s"}, geometry ${receipt.digests.geometry}` +
466
+ `${receipt.diagnostics.length === 0 ? "" : `, ${receipt.diagnostics.length} diagnostic${receipt.diagnostics.length === 1 ? "" : "s"}`}` +
467
+ `${receipt.output === null ? "" : ` -> ${receipt.output.path}`}`,
468
+ )
469
+ return
470
+ }
471
+ const parsed = parseArguments(args, new Set(["output", "recipe", "size", "palette", "language"]))
472
+ rejectUnknownCliFlags(parsed, ["json", "background"], "icon render")
473
+ const sourcePath = exactlyOnePositional(
474
+ parsed,
475
+ "slopcamera image icon render <source.json> --output <file.svg> [options]",
476
+ )
477
+ const size = parsed.options.size
478
+ if (size !== undefined && !/^\d{1,5}$/u.test(size)) {
479
+ throw new Error("--size must be an integer")
480
+ }
481
+ const receipt = await executeSlopcameraOperation(
482
+ "slopcamera.icon.render",
483
+ {
484
+ sourcePath,
485
+ outputPath: requiredOption(parsed, "output"),
486
+ ...(parsed.options.recipe === undefined ? {} : { recipePath: parsed.options.recipe }),
487
+ ...(size === undefined ? {} : { size: Number(size) }),
488
+ ...(parsed.flags.has("background") ? { background: true } : {}),
489
+ ...(parsed.options.palette === undefined ? {} : { palette: parsed.options.palette }),
490
+ ...(parsed.options.language === undefined ? {} : { language: parsed.options.language }),
491
+ },
492
+ hostAdmissionOptions(dependencies),
493
+ )
494
+ logOperationResult(
495
+ dependencies,
496
+ parsed.flags.has("json"),
497
+ receipt,
498
+ `Rendered ${receipt.form} "${receipt.title}"${receipt.replayed ? " (replayed recipe)" : ""}: ${receipt.output.path} ` +
499
+ `(${receipt.width}x${receipt.height}, svg ${receipt.digests.svg})` +
500
+ `${receipt.recipe === null ? "" : `\nRecipe: ${receipt.recipe.path}`}`,
501
+ )
502
+ }
503
+
504
+ async function runSoundtrack(
505
+ args: readonly string[],
506
+ dependencies: SlopcameraCliDependencies,
507
+ ): Promise<void> {
508
+ const [action, ...rest] = args
509
+ if (action === "compose") {
510
+ const parsed = parseArguments(rest, new Set(["output", "format"]))
511
+ rejectUnknownCliFlags(parsed, ["json"], "soundtrack compose")
512
+ const sourcePath = exactlyOnePositional(
513
+ parsed,
514
+ "slopcamera media soundtrack compose <score> [--output <file.json>] [--format <format>] [--json]",
515
+ )
516
+ const receipt = await executeSlopcameraOperation(
517
+ "slopcamera.soundtrack.compose",
518
+ {
519
+ sourcePath,
520
+ ...(parsed.options.output === undefined ? {} : { outputPath: parsed.options.output }),
521
+ ...(parsed.options.format === undefined ? {} : { format: parsed.options.format }),
522
+ },
523
+ hostAdmissionOptions(dependencies),
524
+ )
525
+ logOperationResult(
526
+ dependencies,
527
+ parsed.flags.has("json"),
528
+ receipt,
529
+ `Verified ${receipt.kind} "${receipt.title}": ${receipt.bpm} BPM, ${receipt.beatsPerBar}/bar, ${receipt.bars} bars, ` +
530
+ `${(receipt.durationUs / 1_000_000).toFixed(3)} s, digest ${receipt.digest}` +
531
+ `${receipt.output === null ? "" : ` -> ${receipt.output.path}`}`,
532
+ )
533
+ return
534
+ }
535
+ if (action === "grid") {
536
+ const parsed = parseArguments(rest, new Set(["output", "format", "start-us"]))
537
+ rejectUnknownCliFlags(parsed, ["json"], "soundtrack grid")
538
+ const sourcePath = exactlyOnePositional(
539
+ parsed,
540
+ "slopcamera media soundtrack grid <score> [--output <file.json>] [--start-us <n>] [--format <format>] [--json]",
541
+ )
542
+ const startUs = parsed.options["start-us"]
543
+ if (startUs !== undefined && !/^\d{1,10}$/u.test(startUs)) {
544
+ throw new Error("--start-us must be a non-negative integer")
545
+ }
546
+ const receipt = await executeSlopcameraOperation(
547
+ "slopcamera.soundtrack.grid",
548
+ {
549
+ sourcePath,
550
+ ...(parsed.options.output === undefined ? {} : { outputPath: parsed.options.output }),
551
+ ...(parsed.options.format === undefined ? {} : { format: parsed.options.format }),
552
+ ...(startUs === undefined ? {} : { startUs: Number(startUs) }),
553
+ },
554
+ hostAdmissionOptions(dependencies),
555
+ )
556
+ logOperationResult(
557
+ dependencies,
558
+ parsed.flags.has("json"),
559
+ receipt,
560
+ [
561
+ `Beat grid for ${receipt.scoreKind} "${receipt.title}": ${JSON.stringify(receipt.music)}`,
562
+ ...receipt.sections.map(
563
+ (section) =>
564
+ ` ${section.index}. ${section.label} bars ${section.startBar}-${section.endBar} ${section.startUs}-${section.endUs} us`,
565
+ ),
566
+ ...(receipt.output === null ? [] : [`Grid: ${receipt.output.path}`]),
567
+ ].join("\n"),
568
+ )
569
+ return
570
+ }
571
+ throw new Error("Use slopcamera media soundtrack compose or grid")
572
+ }
573
+
391
574
  export async function main(
392
575
  args: readonly string[],
393
576
  dependencies: SlopcameraCliDependencies = {},
@@ -582,6 +765,14 @@ export async function main(
582
765
  return
583
766
  }
584
767
 
768
+ if (command === "icon" && (rest[0] === "compose" || rest[0] === "render")) {
769
+ await runIconScene(rest[0], rest.slice(1), dependencies)
770
+ return
771
+ }
772
+ if (command === "soundtrack") {
773
+ await runSoundtrack(rest, dependencies)
774
+ return
775
+ }
585
776
  if (command === "icon") {
586
777
  const parsed = parseArguments(
587
778
  rest,