@hraness/slopcamera 3.5.0 → 3.7.0

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.
Files changed (52) hide show
  1. package/README.md +42 -26
  2. package/apps/desktop/README.md +1 -1
  3. package/apps/desktop/application/html-overlay-renderer.ts +6 -0
  4. package/apps/desktop/application/operation.ts +1 -1
  5. package/apps/desktop/cli/args.ts +63 -1
  6. package/apps/desktop/cli/command-host-resources.ts +5 -0
  7. package/apps/desktop/cli/commands.ts +27 -1
  8. package/apps/desktop/cli/help.ts +43 -21
  9. package/apps/desktop/cli/html-film-init.ts +158 -0
  10. package/apps/desktop/cli/html-film-names.ts +17 -0
  11. package/apps/desktop/cli/html-film.ts +471 -0
  12. package/apps/desktop/cli/html-overlay-renderer.ts +29 -2
  13. package/apps/desktop/cli/html-scene.ts +3 -3
  14. package/apps/desktop/cli/main.ts +1 -0
  15. package/apps/desktop/cli/menubar-status.ts +156 -0
  16. package/apps/desktop/cli/menubar.ts +88 -136
  17. package/apps/desktop/cli/portable-surface.ts +55 -1
  18. package/apps/desktop/dist/cli/main.js +353 -328
  19. package/apps/desktop/html-overlay/film.ts +418 -0
  20. package/dist/cli.js +19 -9
  21. package/dist/generate.js +1 -1
  22. package/dist/{index-8bhp7cj2.js → index-842t02pp.js} +1 -1
  23. package/dist/{index-jab2mg0s.js → index-nv3byqa1.js} +20 -4
  24. package/dist/index-p74xy76d.js +3 -0
  25. package/dist/{index-24w75s6a.js → index-y8spxs77.js} +10 -7
  26. package/dist/index.js +1 -1
  27. package/dist/operations.js +1 -1
  28. package/dist/workflow.js +1 -1
  29. package/docs/README.md +5 -5
  30. package/examples/html/launch-film/README.md +51 -0
  31. package/examples/html/launch-film/build.ts +158 -0
  32. package/examples/html/launch-film/film.css +593 -0
  33. package/examples/html/launch-film/film.html +85 -0
  34. package/examples/html/launch-film/film.js +410 -0
  35. package/examples/html/launch-film/film.json +63 -0
  36. package/examples/html/launch-film/mockups.tsx +74 -0
  37. package/examples/html/launch-film/timeline.ts +57 -0
  38. package/package.json +14 -8
  39. package/skills/slopcamera/SKILL.md +3 -2
  40. package/skills/slopcamera/references/install.md +3 -3
  41. package/skills/slopcamera/references/launch-films.md +154 -0
  42. package/skills/slopcamera/references/visual-communication.md +1 -1
  43. package/src/cli.ts +138 -19
  44. package/src/generate.ts +44 -1
  45. package/src/generation-pricing.ts +237 -0
  46. package/src/icon-set.ts +1086 -0
  47. package/src/icon.ts +396 -59
  48. package/src/index.ts +1 -0
  49. package/src/operations.ts +52 -0
  50. package/src/support.ts +6 -0
  51. package/src/version.ts +1 -1
  52. package/dist/index-77fjfg9f.js +0 -3
@@ -1,9 +1,9 @@
1
1
  ---
2
2
  name: slopcamera
3
- description: Create, inspect, edit and render visual media with the Slopcamera CLI and SDK. Use for diagrams, raster-to-SVG conversion, existing screen or camera recordings, video editing and captions, social collage banners, music videos from authored scenes and local tracks, AI images/video/speech/transcription, Three.js scenes and world-space media, Blender rigs and simulation, CadQuery models, parametric architectural designs, Manim educational films, HTML/WGSL motion graphics, and durable agent workflows.
3
+ description: Create, inspect, edit and render visual media with the SlopCamera CLI and SDK. Use for diagrams, raster-to-SVG conversion, existing screen or camera recordings, video editing and captions, social collage banners, music videos from authored scenes and local tracks, AI images/video/speech/transcription, Three.js scenes and world-space media, Blender rigs and simulation, CadQuery models, parametric architectural designs, Manim educational films, HTML/WGSL motion graphics, and durable agent workflows.
4
4
  ---
5
5
 
6
- # Create visual media with Slopcamera
6
+ # Create visual media with SlopCamera
7
7
 
8
8
  Start from the requested result and existing source. Use the installed `slopcamera` CLI, inspect its real capabilities and retain the authored source beside its derived media. Read only the references needed by the task.
9
9
 
@@ -32,6 +32,7 @@ Slopcamera installs from its verified release archive or from source. Historical
32
32
  | Raster artwork converted to SVG | [Vectorization](references/vectorization.md) |
33
33
  | Clean up, caption, frame, or deliver existing recordings and footage | [Video projects](references/video-projects.md) |
34
34
  | Music video, dancing mascot or timed HTML scene with a local soundtrack | [Music videos](references/music-video.md) |
35
+ | Product launch film, UI mockup motion, kinetic type, or coming from HyperFrames or Remotion | [Launch films](references/launch-films.md) |
35
36
  | Authorized YouTube or other public web-media excerpt (`yt-dlp` / `youtube-dl`) | [Web-media excerpts](references/web-media-excerpts.md) |
36
37
  | Generate images, clips, narration or transcripts | [Gateway media](references/gateway-media.md) |
37
38
  | Product mark, marketing illustration or topic icon | [Brand illustrations](references/brand-illustrations.md) |
@@ -1,4 +1,4 @@
1
- # Install and diagnose Slopcamera
1
+ # Install and diagnose SlopCamera
2
2
 
3
3
  Use this reference when the CLI is unavailable, a command differs from the installed release, or `slopcamera doctor` reports a missing dependency for the selected workflow.
4
4
 
@@ -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.4.0/hraness-slopcamera-3.4.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.6.0/hraness-slopcamera-3.6.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.4.0/hraness-slopcamera-3.4.0.tgz
24
+ bun add --global @hraness/slopcamera@https://github.com/hraness/slopcamera/releases/download/v3.6.0/hraness-slopcamera-3.6.0.tgz
25
25
  slopcamera --version
26
26
  ```
27
27
 
@@ -0,0 +1,154 @@
1
+ # Make a product launch film
2
+
3
+ Use this workflow for a short film that walks through a product: kinetic type,
4
+ the product's own UI in a browser frame with a moving camera, a cursor and a
5
+ drawn highlight, a proof act with real numbers, a limits card and an end card.
6
+ The film is plain HTML, CSS and JavaScript rendered by `slopcamera html render`.
7
+ React is used only to turn the site's mockup components into static markup.
8
+
9
+ The same source gives a web MP4 and WebM, a poster, a social still, square and
10
+ vertical cuts, one short clip per act, and WebVTT captions. Each act's caption
11
+ doubles as the source text for a social thread post.
12
+
13
+ ## Check the command
14
+
15
+ ```sh
16
+ slopcamera help html
17
+ slopcamera doctor --json
18
+ ```
19
+
20
+ The installed CLI must list `html init`, `html still` and `html deliver`.
21
+ Rendering needs the admitted local Chrome runtime and FFmpeg/FFprobe. Follow
22
+ [installation and readiness](install.md) if the command is missing.
23
+
24
+ ## Start a film
25
+
26
+ ```sh
27
+ slopcamera html init video --template launch-film --aspect 16:9
28
+ cd video
29
+ bun install
30
+ bun run build
31
+ ```
32
+
33
+ `--aspect` takes `16:9`, `1:1` or `9:16` and only sets the default in
34
+ `film.json`; `bun build.ts --aspect 9:16` re-lays the same film at another size.
35
+ The project pins the SlopCamera version that wrote it in `package.json`, so the
36
+ CLI version is recorded next to the source.
37
+
38
+ | File | Edit it to |
39
+ | --- | --- |
40
+ | `film.json` | Change copy, steps, proof items, colors, fonts and product CSS. |
41
+ | `mockups.tsx` | Replace the placeholder product with the site's real mockup components. |
42
+ | `timeline.ts` | Change act order and length. Captions and clips follow it. |
43
+ | `film.css` | Restyle the stage, masks, grain and browser frame. |
44
+ | `film.js` | Change the choreography. Keep every value a function of `t`. |
45
+
46
+ `bun run build` writes `out/film.html`, `out/scene.json`, `out/captions.vtt`
47
+ and `out/beats.json`. It inlines `@hraness/design-kit` `mockups.css` when the
48
+ project can resolve it, then any `productCss` files and fonts, and validates the
49
+ scene before writing it. The document limit is 1 MiB; the build prints its size.
50
+
51
+ ## Keep every frame a function of time
52
+
53
+ A renderer can ask for any frame in any order, once or twice. Build each frame
54
+ from `t` alone:
55
+
56
+ - Read time from `SlopcameraOverlay.onFrame(({ timeMs }) => ...)`. Never use
57
+ `Date.now`, `requestAnimationFrame`, CSS transitions, CSS animations or a
58
+ counter carried between frames.
59
+ - Hide finished scenes with `show(element, on)`. It sets both `visibility` and
60
+ `display`, so a hidden scene cannot leak a stale layer into a later frame.
61
+ - Measure layout once, before the first frame, and store the rectangles. Do not
62
+ measure inside a scaled or transformed scene.
63
+ - Load fonts and images through `SlopcameraOverlay.ready(...)` before frames.
64
+
65
+ The helpers in `@hraness/slopcamera/local/html-film` follow these rules.
66
+ `build.ts` bundles them into `out/film.html`.
67
+
68
+ | Need | Helpers |
69
+ | --- | --- |
70
+ | Numbers and easing | `clamp`, `lerp`, `prog(t, start, end)`, `easings`, `spring` |
71
+ | Kinetic type | `split(element, "word" \| "char")`, then `kin(element, t, inAt, outAt)` |
72
+ | Acts and state | `defineTimeline`, `crossfade`, `stepValue`, `show` |
73
+ | Product walk | `camera`, `cameraBetween`, `cameraTransform`, `cursor`, `placeCursor`, `drawMark` |
74
+ | Captions | `captionsFromTimeline(timeline)` writes WebVTT from act captions |
75
+
76
+ ## Direct each act
77
+
78
+ - **Cold open.** Two short lines over a collage of the problem. Stagger words
79
+ by 60 to 90 ms and hold each line long enough to read twice.
80
+ - **Title.** The product name and the same one-line promise the product's site
81
+ uses. One idea, one hold.
82
+ - **Product walk.** One step per entry in `film.json`. Each step names a
83
+ `data-film` surface for the camera `focus`, a `target` for the cursor click,
84
+ an optional `highlight` outline and an optional `after` state set on the target
85
+ once clicked. Let the camera settle before the cursor moves, click, then draw
86
+ the highlight. Keep steps near 4 seconds.
87
+ - **Proof.** Count numbers up from a facts file or release record. Never type a
88
+ number into `film.json` by hand.
89
+ - **Limits.** Say plainly what the product does not do.
90
+ - **End card.** Name, address and one line.
91
+
92
+ Keep public copy in plain words and sentence case, with no exclamation marks and
93
+ no hype. Keep the placeholder illustration note until the surfaces show the real
94
+ product.
95
+
96
+ ## Review stills before rendering
97
+
98
+ ```sh
99
+ slopcamera html still --input out/scene.json --at 3,12.5,20 --output out/stills
100
+ slopcamera html preview --input out/scene.json --every 2 --output out/preview
101
+ ```
102
+
103
+ Both render with the same injected runtime as `html render`, so a still matches
104
+ the rendered frame at that time. `preview` also writes `contact-sheet.png`. Look
105
+ at each act's hold, each click and each highlight before a full render. For a
106
+ quick draft render, `bun build.ts --scale 0.5 --fps 15 --until 3` keeps the
107
+ layout and shrinks the canvas, frame rate and length.
108
+
109
+ ## Render and deliver
110
+
111
+ ```sh
112
+ slopcamera html render --input out/scene.json --json > out/export.json
113
+ slopcamera html deliver out/export.json --basename launch --poster-at 9 --social-at 9 \
114
+ --cuts 1:1,9:16 --per-beat-clips
115
+ ```
116
+
117
+ `deliver` reads `output.path` from the export and checks its hash, then writes
118
+ into `out/deliver/`:
119
+
120
+ - `launch.mp4`, H.264 with faststart for the web, and `launch.webm`, VP9
121
+ - `launch-poster.jpg` and a 1200x630 `launch-social.jpg`
122
+ - `launch-1x1.mp4` and `launch-9x16.mp4`, the whole frame over a blurred fill
123
+ - one 6 to 10 second clip per act from `beats.json` beside the export, or the file `--beats` names
124
+ - `launch-receipt.json` with each file's size, hash and budget
125
+
126
+ Budgets are 12 MB per MP4, 10 MB for the WebM and 250 KB per JPEG. `deliver`
127
+ exits non-zero when a file is over budget; shorten the film, simplify the grain
128
+ or lower the frame rate rather than raising the budget.
129
+
130
+ ## Turn beats into a thread
131
+
132
+ `out/beats.json` lists each act's id, start, end and caption. Use one post per
133
+ act, in order, with that act's clip or still attached. The captions are already
134
+ short and in plain words; edit them in `film.json` so the film and the posts stay
135
+ the same. The WebVTT file carries the same lines for the web player.
136
+
137
+ ## Coming from HyperFrames or Remotion
138
+
139
+ There is no importer. The ideas map directly:
140
+
141
+ | HyperFrames or Remotion | Launch film |
142
+ | --- | --- |
143
+ | A composition's HTML, CSS and JS | `film.html`, `film.css` and `film.js` in a `plain` scene |
144
+ | `useCurrentFrame()` | `timeMs` from `SlopcameraOverlay.onFrame`, as `t` in seconds |
145
+ | `interpolate(frame, [a, b], [x, y])` | `lerp(x, y, prog(t, a, b))` |
146
+ | `spring({ frame, fps })` | `spring(seconds)` |
147
+ | `<Sequence from durationInFrames>` | an act in `defineTimeline`, read with `timeline.local(t, id)` |
148
+ | `<Series>` with overlaps | acts with `overlap`, blended by `crossfade` |
149
+ | `staticFile()` | a declared resource read with `SlopcameraOverlay.asset(name)` |
150
+ | `delayRender()` | `SlopcameraOverlay.ready(promise)` |
151
+ | Rendering with the CLI | `slopcamera html render`, then `html deliver` |
152
+
153
+ Keep React components as mockups rendered to static markup. Do not run React
154
+ inside the film: every frame must come from `t`, not from component state.
@@ -1,4 +1,4 @@
1
- # Slopcamera visual communication rules
1
+ # SlopCamera visual communication rules
2
2
 
3
3
  Use these rules to turn supplied content into a diagram without adding content.
4
4
 
package/src/cli.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
3
  import { readFile, writeFile } from "node:fs/promises"
4
- import { resolve } from "node:path"
4
+ import { dirname, resolve } from "node:path"
5
5
  import {
6
6
  artifactSummary,
7
7
  checkDiagramFile,
@@ -17,8 +17,16 @@ import {
17
17
  } from "./generate.js"
18
18
  import {
19
19
  generateSlopcameraIcon,
20
+ slopcameraIconContexts,
20
21
  slopcameraIconMaximumRounds,
22
+ type SlopcameraIconContext,
23
+ type SlopcameraIconPurpose,
21
24
  } from "./icon.js"
25
+ import {
26
+ generateSlopcameraIconSet,
27
+ parseSlopcameraIconSetSpec,
28
+ slopcameraIconSetMaximumSetRounds,
29
+ } from "./icon-set.js"
22
30
  import {
23
31
  generateSlopcameraImageGallery,
24
32
  parseSlopcameraGalleryVary,
@@ -40,11 +48,11 @@ import { checkDrawingFile, renderDrawingFile, starterDrawingSource } from "./dra
40
48
  import { SLOPCAMERA_VERSION } from "./version.js"
41
49
  import { createVisualStyleDirection, getVisualStyleProfile, VISUAL_STYLE_PROFILES } from "./visual-style.js"
42
50
  import { reportUsefulResult, type UsefulResultObserver } from "./support-completion.js"
43
- import { runProductSupportCommand, showProductSupportInvitation, standaloneSupportEnvironment } from "./support.js"
51
+ import { runProductSupportCommand, showProductSupportInvitation, slopcameraSupportAdvancedHelp, slopcameraSupportHelpLine, standaloneSupportEnvironment } from "./support.js"
44
52
 
45
53
  export const slopcameraCliVersion = SLOPCAMERA_VERSION
46
54
 
47
- const help = `slopcamera ${slopcameraCliVersion}
55
+ const help = () => `slopcamera ${slopcameraCliVersion}
48
56
 
49
57
  Turn source material into deterministic diagrams, images, and canvas assets.
50
58
 
@@ -59,7 +67,11 @@ Usage:
59
67
  slopcamera image generate <prompt> --output <file.png|jpg|webp> [--model <provider/model>] [--json]
60
68
  slopcamera image icon <subject> --output <file.svg> [--purpose <mark|illustration>]
61
69
  [--model <provider/model>] [--ink <#rgb|#rrggbb>] [--rounds <1-${slopcameraIconMaximumRounds}>]
70
+ [--context <${slopcameraIconContexts.join("|")}>] [--candidates <1-${slopcameraIconMaximumRounds}>]
62
71
  [--critique-model <provider/model>] [--keep-raster] [--json]
72
+ slopcamera image icon --set <file.json> --output-dir <directory>
73
+ [--context <${slopcameraIconContexts.join("|")}>] [--candidates <1-${slopcameraIconMaximumRounds}>]
74
+ [--set-rounds <1-${slopcameraIconSetMaximumSetRounds}>] [--keep-raster] [--json]
63
75
  slopcamera image gallery <subject> --output-dir <directory> [--kind <${slopcameraGalleryKinds.join("|")}>]
64
76
  [--count <1-${slopcameraGalleryLimits.candidates}>] [--vary <axis[=v1,v2][;axis...]>] [--candidates <file.json>]
65
77
  [--model <provider/model>] [--cell <${slopcameraGalleryLimits.cellEdgeMin}-${slopcameraGalleryLimits.cellEdgeMax}>] [--tile|--no-tile] [--json]
@@ -69,7 +81,6 @@ Usage:
69
81
  slopcamera code execute <operation> --input <JSON>
70
82
  slopcamera mcp --root <workspace>
71
83
  slopcamera doctor
72
- slopcamera support [--json|protocol --json|offer --json|shown <id>|release <id>|dismiss|snooze|enable|status --json]
73
84
  slopcamera skill path
74
85
  slopcamera skill install [--target codex|claude|agents] [--scope user|project] [--force]
75
86
 
@@ -104,14 +115,21 @@ vectorizer, and (when --rounds exceeds 1) critiqued by a vision model whose
104
115
  feedback revises the prompt for the next attempt. Only Slopcamera's own
105
116
  generated output is uploaded for critique — never user media.
106
117
 
118
+ Icon --set reads a bounded JSON manifest ({name?, ink?, context?, members:
119
+ [{slug, subject, purpose?, context?}]}) and generates the whole family at
120
+ once: each member keeps a small candidate pool, joint selection minimizes the
121
+ measured coverage and stroke-weight spread across the set, and a
122
+ contact-sheet critique reviews the family as a unit before SVGs publish.
123
+ Manifests and receipts stay local.
124
+
107
125
  Gallery generates several bounded candidates in parallel and composes them
108
126
  into one labelled contact sheet plus a receipt. Use it to review texture,
109
127
  skybox, backdrop, sprite, or design alternatives, then promote a chosen
110
128
  candidate file explicitly — nothing is applied automatically.
111
129
 
112
- Optional support: after useful work, agents can read slopcamera support protocol --json.
113
- Discovery uses stderr without claiming an invitation; HRANESS_SUPPORT_AUDIENCE=off disables it.
114
- No feature requires payment. Imported CLI/SDK calls and probes stay quiet.
130
+ ${slopcameraSupportHelpLine()}
131
+
132
+ Advanced verbs, including the support protocol, live under \`slopcamera help advanced\`.
115
133
 
116
134
  Code mode searches and executes a fixed semantic registry. Execute accepts
117
135
  typed JSON for one exact owned operation code; it never evaluates source text.
@@ -251,6 +269,7 @@ export interface SlopcameraCliDependencies {
251
269
  readonly generate?: typeof generateSlopcameraImageFile
252
270
  readonly hostResourceCoordinator?: HostResourceCoordinator
253
271
  readonly icon?: typeof generateSlopcameraIcon
272
+ readonly iconSet?: typeof generateSlopcameraIconSet
254
273
  readonly log?: (value: string) => void
255
274
  readonly vectorize?: typeof vectorizeImage
256
275
  }
@@ -364,7 +383,7 @@ function canonicalArguments(args: readonly string[]): readonly string[] {
364
383
  surface === "vectorize" ||
365
384
  surface === "generate"
366
385
  ) {
367
- throw new Error(`The flat \`${surface}\` command moved to a namespaced Slopcamera surface.\n\n${help}`)
386
+ throw new Error(`The flat \`${surface}\` command moved to a namespaced Slopcamera surface.\n\n${help()}`)
368
387
  }
369
388
  return args
370
389
  }
@@ -408,8 +427,12 @@ export async function main(
408
427
  return
409
428
  }
410
429
  const [command, ...rest] = canonicalArguments(args)
411
- if (command === undefined || command === "help" || command === "--help" || command === "-h") {
412
- console.log(help)
430
+ if (command === undefined || command === "--help" || command === "-h") {
431
+ console.log(help())
432
+ return
433
+ }
434
+ if (command === "help") {
435
+ console.log(rest[0] === "advanced" ? slopcameraSupportAdvancedHelp() : help())
413
436
  return
414
437
  }
415
438
  if (command === "version" || command === "--version" || command === "-v") {
@@ -562,7 +585,19 @@ export async function main(
562
585
  if (command === "icon") {
563
586
  const parsed = parseArguments(
564
587
  rest,
565
- new Set(["model", "output", "ink", "purpose", "rounds", "critique-model"]),
588
+ new Set([
589
+ "model",
590
+ "output",
591
+ "output-dir",
592
+ "ink",
593
+ "purpose",
594
+ "rounds",
595
+ "critique-model",
596
+ "context",
597
+ "candidates",
598
+ "set",
599
+ "set-rounds",
600
+ ]),
566
601
  )
567
602
  const unknownFlags = [...parsed.flags].filter(
568
603
  (flag) => flag !== "json" && flag !== "keep-raster",
@@ -570,13 +605,6 @@ export async function main(
570
605
  if (unknownFlags.length > 0) {
571
606
  throw new Error(`Unknown icon option: --${unknownFlags[0]}`)
572
607
  }
573
- if (parsed.positionals.length !== 1) {
574
- throw new Error("slopcamera image icon accepts exactly one subject")
575
- }
576
- const output = requiredOption(parsed, "output")
577
- if (!output.toLowerCase().endsWith(".svg")) {
578
- throw new Error("--output must end in .svg")
579
- }
580
608
  const model = parsed.options.model ?? slopcameraImageModels[1]
581
609
  if (
582
610
  model.length > 256 ||
@@ -604,10 +632,97 @@ export async function main(
604
632
  if (purpose !== undefined && purpose !== "mark" && purpose !== "illustration") {
605
633
  throw new Error("--purpose must be mark or illustration")
606
634
  }
635
+ const context = parsed.options.context
636
+ if (
637
+ context !== undefined &&
638
+ !slopcameraIconContexts.includes(context as never)
639
+ ) {
640
+ throw new Error(
641
+ `--context must be one of: ${slopcameraIconContexts.join(", ")}`,
642
+ )
643
+ }
607
644
  const rounds = parsePositiveInteger(parsed.options.rounds, "rounds")
608
645
  if (rounds !== undefined && rounds > slopcameraIconMaximumRounds) {
609
646
  throw new Error(`--rounds must be at most ${slopcameraIconMaximumRounds}`)
610
647
  }
648
+ const candidates = parsePositiveInteger(parsed.options.candidates, "candidates")
649
+ if (candidates !== undefined && candidates > slopcameraIconMaximumRounds) {
650
+ throw new Error(`--candidates must be at most ${slopcameraIconMaximumRounds}`)
651
+ }
652
+ const setRounds = parsePositiveInteger(parsed.options["set-rounds"], "set-rounds")
653
+ if (setRounds !== undefined && setRounds > slopcameraIconSetMaximumSetRounds) {
654
+ throw new Error(`--set-rounds must be at most ${slopcameraIconSetMaximumSetRounds}`)
655
+ }
656
+
657
+ if (parsed.options.set !== undefined) {
658
+ if (parsed.positionals.length !== 0) {
659
+ throw new Error("slopcamera image icon --set takes no subject positional")
660
+ }
661
+ if (parsed.options.output !== undefined) {
662
+ throw new Error("--output cannot be combined with --set; use --output-dir")
663
+ }
664
+ const outputDir = requiredOption(parsed, "output-dir")
665
+ const manifestPath = parsed.options.set
666
+ let manifestText: string
667
+ try {
668
+ manifestText = await readFile(manifestPath, "utf8")
669
+ } catch {
670
+ throw new Error(`--set manifest could not be read: ${manifestPath}`)
671
+ }
672
+ if (Buffer.byteLength(manifestText, "utf8") > 256 * 1024) {
673
+ throw new Error("--set manifest exceeds the 256 KiB bound")
674
+ }
675
+ let manifestJson: unknown
676
+ try {
677
+ manifestJson = JSON.parse(manifestText)
678
+ } catch {
679
+ throw new Error("--set manifest must be valid JSON")
680
+ }
681
+ const spec = parseSlopcameraIconSetSpec(manifestJson)
682
+ const result = await withSlopcameraOperationHostAdmission(
683
+ "slopcamera.image.icon",
684
+ async (lease) =>
685
+ await (dependencies.iconSet ?? generateSlopcameraIconSet)({
686
+ keepRaster: parsed.flags.has("keep-raster"),
687
+ manifestDir: dirname(resolve(manifestPath)),
688
+ model,
689
+ outputDir,
690
+ spec,
691
+ inheritedFileDescriptors: [lease.inheritedFileDescriptor],
692
+ ...(critiqueModel === undefined ? {} : { critiqueModel }),
693
+ ...(ink === undefined ? {} : { ink }),
694
+ ...(context === undefined
695
+ ? {}
696
+ : { context: context as SlopcameraIconContext }),
697
+ ...(purpose === undefined
698
+ ? {}
699
+ : { purpose: purpose as SlopcameraIconPurpose }),
700
+ ...(rounds === undefined ? {} : { rounds }),
701
+ ...(candidates === undefined ? {} : { candidatesPerMember: candidates }),
702
+ ...(setRounds === undefined ? {} : { setRounds }),
703
+ }),
704
+ hostAdmissionOptions(dependencies),
705
+ )
706
+ if (parsed.flags.has("json")) {
707
+ ;(dependencies.log ?? console.log)(JSON.stringify(result, null, 2))
708
+ } else {
709
+ ;(dependencies.log ?? console.log)(
710
+ `Icon set ${result.name ?? "icon-set"}: ${result.members.length} members to ${result.outputDir} ` +
711
+ `(target coverage ${result.target.coverageRatio.toFixed(3)}, ` +
712
+ `stroke ${result.target.strokePx.toFixed(1)}px, ${result.setRoundsUsed} set rounds)`,
713
+ )
714
+ }
715
+ reportUsefulResult(dependencies.onUsefulResult)
716
+ return
717
+ }
718
+
719
+ if (parsed.positionals.length !== 1) {
720
+ throw new Error("slopcamera image icon accepts exactly one subject")
721
+ }
722
+ const output = requiredOption(parsed, "output")
723
+ if (!output.toLowerCase().endsWith(".svg")) {
724
+ throw new Error("--output must end in .svg")
725
+ }
611
726
  const result = await withSlopcameraOperationHostAdmission(
612
727
  "slopcamera.image.icon",
613
728
  async (lease) => await (dependencies.icon ?? generateSlopcameraIcon)({
@@ -620,6 +735,10 @@ export async function main(
620
735
  ...(ink === undefined ? {} : { ink }),
621
736
  ...(purpose === undefined ? {} : { purpose }),
622
737
  ...(rounds === undefined ? {} : { rounds }),
738
+ ...(context === undefined
739
+ ? {}
740
+ : { context: context as SlopcameraIconContext }),
741
+ ...(candidates === undefined ? {} : { candidatePool: candidates }),
623
742
  }),
624
743
  hostAdmissionOptions(dependencies),
625
744
  )
@@ -849,7 +968,7 @@ export async function main(
849
968
  throw new Error("Use slopcamera skill path or install")
850
969
  }
851
970
 
852
- throw new Error(`Unknown command: ${command}\n\n${help}`)
971
+ throw new Error(`Unknown command: ${command}\n\n${help()}`)
853
972
  }
854
973
 
855
974
  if (import.meta.main) {
package/src/generate.ts CHANGED
@@ -2,6 +2,13 @@ import { createHash, randomUUID } from "node:crypto"
2
2
  import { link, rm, writeFile } from "node:fs/promises"
3
3
  import { dirname, extname, resolve } from "node:path"
4
4
  import { SlopcameraCloudError } from "./cloud-errors.js"
5
+ import {
6
+ imageCallCosts,
7
+ type SlopcameraImageUsage,
8
+ type SlopcameraProviderCost,
9
+ } from "./generation-pricing.js"
10
+
11
+ export type { SlopcameraProviderCost } from "./generation-pricing.js"
5
12
 
6
13
  export const slopcameraGatewayApiBaseUrl =
7
14
  "https://ai-gateway.vercel.sh/v4/ai" as const
@@ -85,6 +92,12 @@ export interface SlopcameraGenerateDependencies {
85
92
  readonly fetch?: SlopcameraGatewayFetch
86
93
  readonly loadRuntime?: () => Promise<GatewayRuntime>
87
94
  readonly maximumResponseBytes?: number
95
+ /**
96
+ * Called once for every image call that reached the provider, including a
97
+ * call that failed afterwards, with that call's provider cost lines.
98
+ * Billing uses it to settle or release with the actual cost.
99
+ */
100
+ readonly onProviderCost?: (costs: readonly SlopcameraProviderCost[]) => void
88
101
  }
89
102
 
90
103
  const defaultGenerationTimeoutMs = 5 * 60_000
@@ -522,6 +535,15 @@ function warningReceipt(value: unknown): string {
522
535
  return `${type} sha256:${createHash("sha256").update(detail).digest("hex")}`
523
536
  }
524
537
 
538
+ function returnedUsage(value: unknown): SlopcameraImageUsage | undefined {
539
+ if (!isObject(value) || !isObject(value.usage)) return undefined
540
+ const { inputTokens, outputTokens } = value.usage
541
+ return {
542
+ inputTokens: typeof inputTokens === "number" ? inputTokens : undefined,
543
+ outputTokens: typeof outputTokens === "number" ? outputTokens : undefined,
544
+ }
545
+ }
546
+
525
547
  function parseResult(
526
548
  value: unknown,
527
549
  model: string,
@@ -590,6 +612,24 @@ async function performGeneration(
590
612
  const prompt = validatePrompt(input.prompt)
591
613
  const credential = resolveSlopcameraGatewayCredential(dependencies.environment)
592
614
  const timeout = combineSignals(input.signal, validateTimeout(input.timeoutMs))
615
+ // Cost accounting: once the provider call starts it may be billed, so every
616
+ // exit after that point reports a cost, from returned usage when there is
617
+ // one, otherwise the worst case. A 4xx Gateway answer is a refusal the
618
+ // provider does not bill; a 5xx may come after the provider already
619
+ // produced the image, so it still reports the worst case.
620
+ let dispatched = false
621
+ let rejected = false
622
+ let reported = false
623
+ const report = (usage: SlopcameraImageUsage | undefined): void => {
624
+ if (reported || !dispatched || rejected) return
625
+ reported = true
626
+ dependencies.onProviderCost?.(imageCallCosts(model, prompt, usage))
627
+ }
628
+ const observedFetch: SlopcameraGatewayFetch = async (request, init) => {
629
+ const response = await (dependencies.fetch ?? globalThis.fetch)(request, init)
630
+ if (response.status >= 400 && response.status < 500) rejected = true
631
+ return response
632
+ }
593
633
  try {
594
634
  const generation = (async () => {
595
635
  assertGenerationActive(timeout.signal)
@@ -602,13 +642,14 @@ async function performGeneration(
602
642
  apiKey: credential.token,
603
643
  baseURL: slopcameraGatewayApiBaseUrl,
604
644
  fetch: createFixedGatewayFetch({
605
- ...(dependencies.fetch === undefined ? {} : { fetch: dependencies.fetch }),
645
+ fetch: observedFetch,
606
646
  ...(dependencies.maximumResponseBytes === undefined
607
647
  ? {}
608
648
  : { maximumResponseBytes: dependencies.maximumResponseBytes }),
609
649
  }),
610
650
  })
611
651
  assertGenerationActive(timeout.signal)
652
+ dispatched = true
612
653
  const generated = await runtime.generateImage({
613
654
  abortSignal: timeout.signal,
614
655
  maxRetries: 0,
@@ -616,10 +657,12 @@ async function performGeneration(
616
657
  n: 1,
617
658
  prompt,
618
659
  })
660
+ report(returnedUsage(generated))
619
661
  return parseResult(generated, model)
620
662
  })()
621
663
  return await Promise.race([generation, timeout.interruption])
622
664
  } catch (error) {
665
+ report(undefined)
623
666
  if (error instanceof SlopcameraCloudError) throw error
624
667
  throw new SlopcameraCloudError(
625
668
  "GENERATION_FAILED",