@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.
- package/NOTICE.md +14 -0
- package/README.md +13 -6
- package/apps/desktop/application/html-overlay-integrity.ts +56 -6
- package/apps/desktop/application/operation.ts +1 -1
- package/apps/desktop/cli/capability-manifest.ts +15 -2
- package/apps/desktop/cli/commands.ts +1 -1
- package/apps/desktop/cli/help.ts +23 -4
- package/apps/desktop/cli/html-overlay-renderer.ts +23 -9
- package/apps/desktop/cli/portable-surface.ts +1 -0
- package/apps/desktop/cli/verbs.ts +4 -0
- package/apps/desktop/dist/cli/main.js +306 -284
- package/dist/cli.js +24 -4
- package/dist/{index-6jrxaser.js → index-jmyg6t9c.js} +4 -4
- package/dist/{index-842t02pp.js → index-m6g0ca9b.js} +1 -1
- package/dist/{index-y8spxs77.js → index-mgychzh3.js} +6 -3
- package/dist/index.js +1 -1
- package/dist/operations.js +1 -1
- package/dist/workflow.js +1 -1
- package/package.json +4 -2
- package/skills/slopcamera/SKILL.md +2 -0
- package/skills/slopcamera/references/customization.md +8 -1
- package/skills/slopcamera/references/icon-place.md +76 -0
- package/skills/slopcamera/references/install.md +2 -2
- package/skills/slopcamera/references/music-video.md +10 -2
- package/skills/slopcamera/references/soundtracks.md +58 -0
- package/src/bounded-file.ts +135 -0
- package/src/cli.ts +191 -0
- package/src/icon-scene.ts +695 -0
- package/src/index.ts +2 -0
- package/src/mcp/tools.ts +311 -0
- package/src/mcp/types.ts +4 -0
- package/src/operations.ts +306 -0
- package/src/soundtrack.ts +489 -0
- package/src/version.ts +1 -1
|
@@ -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.
|
|
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.
|
|
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
|
|
22
|
-
replace explicit musical timing with an unsupported
|
|
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,
|