@hraness/slopcamera 3.6.0 → 3.8.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 (57) hide show
  1. package/README.md +49 -33
  2. package/apps/desktop/README.md +3 -10
  3. package/apps/desktop/application/html-overlay-renderer.ts +6 -0
  4. package/apps/desktop/application/operation.ts +1 -1
  5. package/apps/desktop/application/spatial-assets.ts +1 -1
  6. package/apps/desktop/cli/{menubar-status.ts → activity-status.ts} +24 -21
  7. package/apps/desktop/cli/args.ts +62 -10
  8. package/apps/desktop/cli/command-host-resources.ts +5 -1
  9. package/apps/desktop/cli/commands.ts +26 -9
  10. package/apps/desktop/cli/desktop-status.ts +271 -0
  11. package/apps/desktop/cli/help.ts +36 -28
  12. package/apps/desktop/cli/html-film-init.ts +158 -0
  13. package/apps/desktop/cli/html-film-names.ts +17 -0
  14. package/apps/desktop/cli/html-film.ts +471 -0
  15. package/apps/desktop/cli/html-overlay-renderer.ts +29 -2
  16. package/apps/desktop/cli/html-scene.ts +3 -3
  17. package/apps/desktop/cli/legacy-login.ts +184 -0
  18. package/apps/desktop/cli/main.ts +9 -0
  19. package/apps/desktop/cli/outputs.ts +19 -0
  20. package/apps/desktop/cli/portable-surface.ts +55 -1
  21. package/apps/desktop/cli/registry.ts +314 -0
  22. package/apps/desktop/cli/verbs.ts +193 -0
  23. package/apps/desktop/dist/cli/main.js +338 -316
  24. package/apps/desktop/html-overlay/film.ts +418 -0
  25. package/dist/cli.js +13 -2
  26. package/dist/generate.js +1 -1
  27. package/dist/{index-4z9y2wa2.js → index-842t02pp.js} +1 -1
  28. package/dist/{index-qxfexvj3.js → index-k9bjw80z.js} +20 -4
  29. package/dist/index-p74xy76d.js +3 -0
  30. package/dist/{index-fevf4ppk.js → index-y8spxs77.js} +10 -7
  31. package/dist/index.js +1 -1
  32. package/dist/operations.js +1 -1
  33. package/dist/workflow.js +1 -1
  34. package/docs/README.md +6 -6
  35. package/examples/html/launch-film/README.md +51 -0
  36. package/examples/html/launch-film/build.ts +158 -0
  37. package/examples/html/launch-film/film.css +593 -0
  38. package/examples/html/launch-film/film.html +85 -0
  39. package/examples/html/launch-film/film.js +410 -0
  40. package/examples/html/launch-film/film.json +63 -0
  41. package/examples/html/launch-film/mockups.tsx +74 -0
  42. package/examples/html/launch-film/timeline.ts +57 -0
  43. package/package.json +24 -11
  44. package/skills/slopcamera/SKILL.md +4 -3
  45. package/skills/slopcamera/references/install.md +3 -3
  46. package/skills/slopcamera/references/launch-films.md +158 -0
  47. package/skills/slopcamera/references/reference-led-3d.md +1 -1
  48. package/skills/slopcamera/references/visual-communication.md +1 -1
  49. package/src/cli.ts +125 -9
  50. package/src/generation-pricing.ts +21 -1
  51. package/src/icon-set.ts +1086 -0
  52. package/src/icon.ts +396 -59
  53. package/src/index.ts +1 -0
  54. package/src/operations.ts +52 -0
  55. package/src/version.ts +1 -1
  56. package/apps/desktop/cli/menubar.ts +0 -272
  57. package/dist/index-nxtmy18t.js +0 -3
@@ -0,0 +1,271 @@
1
+ import { lstatSync, readdirSync } from "node:fs";
2
+ import { extname, join } from "node:path";
3
+
4
+ import { okEnvelope, type Envelope, type NextStep } from "@hraness/desktop-foundation/registry";
5
+ import { box, clean, table, type View } from "@hraness/desktop-foundation/tui";
6
+
7
+ import type { LegacyLoginReport } from "./legacy-login";
8
+ import type { ActivityStatus } from "./activity-status";
9
+
10
+ /**
11
+ * `slopcamera status` and `slopcamera tui`: what Slopcamera is doing, how
12
+ * the last job ended, the last credits balance a command saw, the newest
13
+ * outputs, and whether the retired menu bar still starts at login. Pure
14
+ * except `listOutputs`, so every state has a golden.
15
+ */
16
+ export const STATUS_SCHEMA = "slopcamera.status/1";
17
+ export const OUTPUTS_LIMIT = 3;
18
+ /** A job still marked running after this long crashed or was killed. */
19
+ export const STALE_RUNNING_MS = 6 * 60 * 60 * 1000;
20
+ const MAX_LABEL_CHARS = 40;
21
+
22
+ export type ActivityState = "idle" | "running" | "done" | "failed" | "stale";
23
+
24
+ export interface StatusActivity {
25
+ readonly state: ActivityState;
26
+ readonly headline: string;
27
+ readonly detail: string;
28
+ readonly label?: string;
29
+ readonly startedAt?: string;
30
+ readonly finishedAt?: string;
31
+ readonly error?: string;
32
+ }
33
+
34
+ export interface StatusCredits {
35
+ readonly usd: string;
36
+ readonly low: boolean;
37
+ readonly checkedAt: string;
38
+ }
39
+
40
+ export interface StatusOutput {
41
+ readonly name: string;
42
+ readonly kind: string;
43
+ readonly bytes: number;
44
+ readonly modifiedAt: string;
45
+ }
46
+
47
+ export interface StatusOutputs {
48
+ readonly root: string;
49
+ readonly total: number;
50
+ readonly latest: readonly StatusOutput[];
51
+ }
52
+
53
+ export interface StatusData {
54
+ readonly product: "slopcamera";
55
+ readonly version: string;
56
+ readonly attention: boolean;
57
+ readonly activity: StatusActivity;
58
+ readonly credits: StatusCredits | null;
59
+ readonly outputs: StatusOutputs;
60
+ readonly legacyLoginItem: LegacyLoginReport;
61
+ }
62
+
63
+ export interface StatusInput {
64
+ readonly version: string;
65
+ readonly status: ActivityStatus;
66
+ readonly outputs: StatusOutputs;
67
+ readonly legacyLogin: LegacyLoginReport;
68
+ readonly now: Date;
69
+ }
70
+
71
+ export function ago(thenMs: number, nowMs: number): string {
72
+ const seconds = Math.max(0, Math.floor((nowMs - thenMs) / 1000));
73
+ if (seconds < 60) return "just now";
74
+ if (seconds < 3_600) return `${Math.floor(seconds / 60)} min ago`;
75
+ if (seconds < 7_200) return "1 hour ago";
76
+ if (seconds < 86_400) return `${Math.floor(seconds / 3_600)} hours ago`;
77
+ if (seconds < 172_800) return "yesterday";
78
+ return `${Math.floor(seconds / 86_400)} days ago`;
79
+ }
80
+
81
+ export function dollars(usd: string): string {
82
+ const bare = usd.replace(/^\$+/u, "");
83
+ return bare !== "" && /^[0-9.,]+$/u.test(bare) ? `$${bare}` : usd;
84
+ }
85
+
86
+ export function explain(code: string): string {
87
+ switch (code) {
88
+ case "cancelled": return "It was stopped before it finished";
89
+ case "not-found": return "A file it needed wasn't found";
90
+ case "unavailable": return "A tool it needed isn't available on this computer";
91
+ case "authorization-required": return "It needs more credits first";
92
+ case "subprocess": return "A render tool stopped with an error";
93
+ case "invalid-data":
94
+ case "incompatible": return "Its input wasn't in a form Slopcamera can use";
95
+ case "unsafe-path": return "It was asked to use a folder Slopcamera won't write to";
96
+ case "usage": return "The command had a typo or a missing option";
97
+ case "conflict": return "Something else was changing the same project";
98
+ default: return "Something went wrong. Run the command again to see why";
99
+ }
100
+ }
101
+
102
+ function plain(text: string, max: number): boolean {
103
+ return text !== "" && [...text].length <= max && /^[\p{L}\p{N} .,'$-]+$/u.test(text);
104
+ }
105
+
106
+ function iso(ms: number): string | undefined {
107
+ return Number.isFinite(ms) && ms >= 0 && ms <= 8.64e15 ? new Date(ms).toISOString() : undefined;
108
+ }
109
+
110
+ function activityOf(status: ActivityStatus, now: number): StatusActivity {
111
+ const activity = status.activity;
112
+ if (activity === undefined || !plain(activity.label, MAX_LABEL_CHARS) || !Number.isFinite(activity.startedAt)) {
113
+ return { state: "idle", headline: "Ready", detail: "Nothing rendered yet" };
114
+ }
115
+ const startedAt = iso(activity.startedAt);
116
+ const finishedMs = activity.finishedAt ?? activity.startedAt;
117
+ const finishedAt = activity.finishedAt === undefined ? undefined : iso(activity.finishedAt);
118
+ const times = { ...(startedAt === undefined ? {} : { startedAt }), ...(finishedAt === undefined ? {} : { finishedAt }) };
119
+ if (activity.state === "running") {
120
+ if (now - activity.startedAt < STALE_RUNNING_MS) {
121
+ return { state: "running", headline: activity.label, detail: `Started ${ago(activity.startedAt, now)}`, label: activity.label, ...times };
122
+ }
123
+ return { state: "stale", headline: "Last job stopped early", detail: `${activity.label} · started ${ago(activity.startedAt, now)}`, label: activity.label, ...times };
124
+ }
125
+ if (activity.state === "failed") {
126
+ const error = typeof activity.error === "string" && /^[a-z][a-z-]{0,40}$/u.test(activity.error) ? activity.error : "internal";
127
+ return { state: "failed", headline: "Last job didn't finish", detail: `${activity.label} · ${explain(error)}`, label: activity.label, error, ...times };
128
+ }
129
+ return { state: "done", headline: "Ready", detail: `Last job finished ${ago(finishedMs, now)}`, label: activity.label, ...times };
130
+ }
131
+
132
+ function creditsOf(status: ActivityStatus): StatusCredits | null {
133
+ const credits = status.credits;
134
+ if (credits === undefined || !plain(credits.usd, 16) || typeof credits.low !== "boolean") return null;
135
+ const checkedAt = iso(credits.checkedAt);
136
+ return checkedAt === undefined ? null : { usd: credits.usd, low: credits.low, checkedAt };
137
+ }
138
+
139
+ export function statusData(input: StatusInput): StatusData {
140
+ const now = input.now.getTime();
141
+ const activity = activityOf(input.status, now);
142
+ const credits = creditsOf(input.status);
143
+ return {
144
+ product: "slopcamera",
145
+ version: input.version,
146
+ attention: activity.state === "failed" || credits?.low === true || input.legacyLogin.state === "found",
147
+ activity,
148
+ credits,
149
+ outputs: input.outputs,
150
+ legacyLoginItem: input.legacyLogin,
151
+ };
152
+ }
153
+
154
+ export function statusNext(data: StatusData): NextStep[] {
155
+ const next: NextStep[] = [];
156
+ if (data.activity.state === "failed" || data.activity.state === "stale") {
157
+ next.push({ command: "slopcamera runs list --json", why: "See which run stopped and whether it can resume.", audience: "agent" });
158
+ }
159
+ if (data.credits?.low === true) {
160
+ next.push({ command: "slopcamera credits topup", why: "Credits are low; adding more opens a checkout for a person.", audience: "human" });
161
+ }
162
+ if (data.legacyLoginItem.state === "found") {
163
+ next.push({ command: "slopcamera legacy retire --json", why: "The retired menu bar still opens at login; this moves its login item aside.", audience: "agent" });
164
+ }
165
+ return next;
166
+ }
167
+
168
+ export function statusEnvelope(input: StatusInput): Envelope<StatusData> {
169
+ const data = statusData(input);
170
+ const next = statusNext(data);
171
+ return okEnvelope(STATUS_SCHEMA, data, next.length === 0 ? undefined : next, input.now);
172
+ }
173
+
174
+ function size(bytes: number): string {
175
+ if (bytes < 1024) return `${bytes} B`;
176
+ const units = ["KB", "MB", "GB", "TB"];
177
+ let value = bytes / 1024;
178
+ let unit = 0;
179
+ while (value >= 1024 && unit < units.length - 1) {
180
+ value /= 1024;
181
+ unit += 1;
182
+ }
183
+ return `${value < 10 ? Math.round(value * 10) / 10 : Math.round(value)} ${units[unit]}`;
184
+ }
185
+
186
+ function day(modifiedAt: string, now: number): string {
187
+ const then = Date.parse(modifiedAt);
188
+ const days = Math.floor((now - then) / 86_400_000);
189
+ if (days <= 0) return "today";
190
+ if (days === 1) return "yesterday";
191
+ return `${days} days ago`;
192
+ }
193
+
194
+ function mark(state: ActivityState): string {
195
+ switch (state) {
196
+ case "idle": return "○";
197
+ case "running": return "↻";
198
+ case "done": return "✓";
199
+ default: return "⚠";
200
+ }
201
+ }
202
+
203
+ function loginLine(report: LegacyLoginReport): string {
204
+ switch (report.state) {
205
+ case "found": return "⚠ The old menu bar still opens at login · run slopcamera legacy retire";
206
+ case "not-ours": return "– A menu bar login item changed outside Slopcamera is left alone";
207
+ default: return "✓ Nothing starts at login";
208
+ }
209
+ }
210
+
211
+ /** The one Status view, rendered by both `tui` and `tui --snapshot`. */
212
+ export function statusViews(now: () => Date): View<StatusData>[] {
213
+ return [{
214
+ id: "status",
215
+ title: "Slopcamera",
216
+ render(data, width) {
217
+ const nowMs = now().getTime();
218
+ const lines = [`${mark(data.activity.state)} ${clean(data.activity.headline)} · ${clean(data.activity.detail)}`];
219
+ if (data.credits !== null) {
220
+ const checked = ago(Date.parse(data.credits.checkedAt), nowMs);
221
+ lines.push(data.credits.low
222
+ ? `⚠ Credits are low · ${dollars(data.credits.usd)} left · checked ${checked}`
223
+ : `✓ ${dollars(data.credits.usd)} in credits · Checked ${checked}`);
224
+ }
225
+ const outputs = data.outputs.latest.length === 0
226
+ ? ["No outputs yet · Agents save finished files here"]
227
+ : table(["Output", "Size", "Saved", "Type"], data.outputs.latest.map(item => [
228
+ clean(item.name), size(item.bytes), day(item.modifiedAt, nowMs), item.kind,
229
+ ]), width - 2);
230
+ const more = data.outputs.total > data.outputs.latest.length
231
+ ? [`${data.outputs.total} outputs · slopcamera outputs list --json`]
232
+ : ["slopcamera outputs open"];
233
+ return [
234
+ ...box("Now", lines, width),
235
+ ...box("Outputs", [...outputs, ...more], width),
236
+ ...box("Login", [loginLine(data.legacyLoginItem)], width),
237
+ ];
238
+ },
239
+ }];
240
+ }
241
+
242
+ function kindOf(name: string): string {
243
+ const extension = extname(name).slice(1);
244
+ return extension === "" || extension.length > 8 ? "File" : extension.toUpperCase();
245
+ }
246
+
247
+ /**
248
+ * The newest regular files at the top of the outputs folder. Hidden files,
249
+ * links and folders are skipped; a missing folder is an empty list.
250
+ */
251
+ export function listOutputs(root: string, limit: number): StatusOutputs {
252
+ let names: string[];
253
+ try {
254
+ names = readdirSync(root);
255
+ } catch {
256
+ return { root, total: 0, latest: [] };
257
+ }
258
+ const files: StatusOutput[] = [];
259
+ for (const name of names) {
260
+ if (name.startsWith(".")) continue;
261
+ try {
262
+ const info = lstatSync(join(root, name));
263
+ if (!info.isFile()) continue;
264
+ files.push({ name, kind: kindOf(name), bytes: info.size, modifiedAt: new Date(info.mtimeMs).toISOString() });
265
+ } catch {
266
+ // Removed while listing.
267
+ }
268
+ }
269
+ files.sort((left, right) => right.modifiedAt.localeCompare(left.modifiedAt) || left.name.localeCompare(right.name));
270
+ return { root, total: files.length, latest: files.slice(0, limit) };
271
+ }
@@ -25,7 +25,8 @@ Commands:
25
25
  Author and render retained Blender, CAD and Manim productions
26
26
  image vectorize|generate|gallery|icon
27
27
  Create local SVGs, generated images, or review galleries
28
- html catalog|scaffold|render Author HTML scenes and export video with local audio
28
+ html catalog|scaffold|init|render|still|preview|deliver
29
+ Author HTML scenes and launch films, render, and deliver web video
29
30
  style list|show Discover reusable art direction and review criteria
30
31
  workflows list|show|plan|run Plan or run a reviewed reusable workflow
31
32
  code init|check|plan|run Author, preflight, and run trusted TypeScript workflows
@@ -38,6 +39,11 @@ Commands:
38
39
  Buy and inspect hosted generation credits
39
40
  media audio|color Apply local non-destructive audio and video effects
40
41
  outputs Print the agent outputs directory
42
+ outputs list|open|reveal List the newest outputs or open one on this Mac
43
+ status What Slopcamera is doing, credits and newest outputs
44
+ tui [--snapshot] The status screen; --snapshot prints it once
45
+ commands [--json] List every command with what it may change
46
+ legacy retire Stop the old menu bar opening at login
41
47
  recordings list List recording bundles
42
48
  projects list|create List projects or create one from a recording
43
49
  project inspect|add|edit|render
@@ -64,28 +70,6 @@ ${slopcameraSupportHelpLine()}`;
64
70
 
65
71
  const HELP: Readonly<Record<string, string>> = {
66
72
  advanced: `${slopcameraSupportAdvancedHelp()}`,
67
- menubar: `Usage:
68
- slopcamera menubar [--foreground|--background] [--json]
69
- slopcamera menubar install|uninstall|status|start [--json]
70
-
71
- Shows what Slopcamera is doing in the menu bar: a render in progress, how the
72
- last job ended and your last known credits balance, plus your newest outputs.
73
- It only reads that status and the outputs folder (slopcamera outputs).
74
-
75
- menubar Open it now; it closes when this command ends
76
- menubar start Open it now and return (same as --background)
77
- menubar install Open it every time you log in
78
- menubar uninstall Stop opening it at login and remove the installed copy
79
- menubar status Check whether it opens at login and is running
80
-
81
- If it's already in your menu bar, starting it again says so and succeeds.
82
- macOS shows a notice that slopcamera-menubar can open at login. Turn it off any
83
- time in System Settings › General › Login Items & Extensions.
84
-
85
- The menu bar isn't in released packages yet, so it isn't listed in
86
- slopcamera help. In a Slopcamera checkout, build it with
87
- cargo build --release --manifest-path desktop/Cargo.toml and slopcamera finds
88
- it; elsewhere, set SLOPCAMERA_MENUBAR to the built file.`,
89
73
  capabilities: `Usage: slopcamera capabilities [--json]
90
74
 
91
75
  Print the exact statically assembled capability manifest for this CLI build. Each module owns named operation, workflow, MCP-tool, command, and runtime-profile identities. The manifest includes trust classes, resource/effect policies, runtime requirements, qualification status, and a canonical SHA-256. It reads no workspace source, loads no plugin, probes no runtime, and grants no authority. Use doctor separately to inspect this machine's currently available executables.`,
@@ -304,7 +288,11 @@ compliance. Drawing sheets are exposed through this CLI and the root SDK, not co
304
288
  [--cell <n>] [--tile|--no-tile] [--json]
305
289
  slopcamera image icon <subject> --output <file.svg>
306
290
  [--purpose <mark|illustration>] [--model <model>] [--ink <#rgb>]
291
+ [--context <card|hero|inline>] [--candidates <1-4>]
307
292
  [--rounds <1-4>] [--critique-model <model>] [--keep-raster] [--json]
293
+ slopcamera image icon --set <file.json> --output-dir <directory>
294
+ [--context <card|hero|inline>] [--candidates <1-4>]
295
+ [--set-rounds <1-4>] [--keep-raster] [--json]
308
296
 
309
297
  Explicit --output file commands delegate to @hraness/slopcamera. Vectorization is local,
310
298
  bounded, checksum-pinned, and emits inert SVG. File generation uses Vercel AI Gateway with the
@@ -312,7 +300,10 @@ caller's environment credential. The --prompt spelling without --output is an al
312
300
  content-addressed \`ai image generate\` lane and returns project-composable content hash references.
313
301
  Icon combines a style-locked Gateway raster, local ink extraction and VTracer tracing into a
314
302
  canonical isometric line-art SVG; --rounds above 1 adds a vision-model critique that revises the
315
- prompt between attempts. Gallery fans one subject out to bounded parallel candidates — driven by
303
+ prompt between attempts. --context tunes the prompt and gates to where the art lands, and --set
304
+ generates a manifest-declared family at once: pooled candidates, joint selection toward the
305
+ family's measured coverage and stroke median, and a contact-sheet critique of the whole set before
306
+ anything publishes. Gallery fans one subject out to bounded parallel candidates — driven by
316
307
  variation axes or an explicit candidate list — keeps every candidate with its provenance, and
317
308
  composes a labelled contact sheet plus receipt for agent review. Texture cells repeat the
318
309
  candidate 2×2 so seams are inspectable (--tile/--no-tile overrides). It never replaces existing
@@ -330,7 +321,12 @@ direction, not proof of visual quality or historically authentic footage.`,
330
321
  html: `Usage:
331
322
  slopcamera html catalog [--json]
332
323
  slopcamera html scaffold <${HTML_OVERLAY_SCAFFOLD_KIND_HELP}> --output <file.html>
324
+ slopcamera html init <dir> --template launch-film [--aspect 16:9|1:1|9:16] [--json]
333
325
  slopcamera html render --input <scene.json> [--dry-run] [--json]
326
+ slopcamera html still --input <scene.json> --at <seconds>[,<seconds>...] --output <dir> [--json]
327
+ slopcamera html preview --input <scene.json> --output <dir> [--every <seconds>] [--json]
328
+ slopcamera html deliver <export.json> --basename <name> --poster-at <seconds> --social-at <seconds>
329
+ [--cuts 1:1,9:16,4:5] [--per-beat-clips] [--beats <beats.json>] [--output <dir>] [--json]
334
330
 
335
331
  Catalog lists the closed scaffold profiles in stable order with their primary jobs, render
336
332
  substrates, and current exact browser-library versions. Scaffold creates a complete transparent
@@ -344,7 +340,16 @@ with separate scene-video and original-audio tracks. Device dimensions must be e
344
340
  Duration rounds up to whole frames; audio starts at zero and is trimmed or padded to that
345
341
  duration. Soundtracks encode as 48 kHz stereo AAC at 320 kb/s. Dry run checks source and
346
342
  workload bounds without launching a browser, importing audio, or writing project state.
347
- Progress uses stderr; --json keeps stdout machine-readable.`,
343
+ Progress uses stderr; --json keeps stdout machine-readable.
344
+
345
+ Init creates a launch-film project (film.html, film.css, film.js, build.ts and a sample
346
+ product mockup) in a new directory. Its build writes out/scene.json, out/beats.json and
347
+ out/captions.vtt. Still and preview draw frames with the same injected runtime that render
348
+ uses, so a still matches the rendered frame at that time; preview adds a contact sheet.
349
+ Deliver reads output.path from html render --json output and writes a faststart H.264 MP4,
350
+ a VP9 WebM, a poster, a 1200x630 social still, optional aspect cuts over a blurred fill, and
351
+ optional 6 to 10 second clips per beat. It prints a receipt per file and exits non-zero when
352
+ a file is over its budget: 12 MB per MP4, 10 MB for the WebM, 250 KB per JPEG.`,
348
353
  operations: `Usage:
349
354
  slopcamera operations list [--json]
350
355
  slopcamera operations show <kind>[@<version>] [--json]
@@ -707,12 +712,12 @@ export function commandHelp(topic: readonly string[]): string {
707
712
 
708
713
  export function completions(words: readonly string[]): readonly string[] {
709
714
  const topLevel = [
710
- "capabilities", "operations", "diagram", "direct", "studio", "image", "html", "style", "workflows", "code", "runs", "doctor", "ai", "credits", "media", "menubar", "support", "outputs", "recordings", "projects", "project", "scene", "inspect", "events", "edit", "analyze", "align", "faces", "fillers", "render", "assets",
715
+ "capabilities", "operations", "diagram", "direct", "studio", "image", "html", "style", "workflows", "code", "runs", "doctor", "ai", "credits", "media", "support", "outputs", "status", "tui", "commands", "legacy", "recordings", "projects", "project", "scene", "inspect", "events", "edit", "analyze", "align", "faces", "fillers", "render", "assets",
711
716
  ];
712
717
  if (words.length <= 1) return topLevel;
713
718
  const command = words[0];
714
719
  if (command === "support") return ["protocol", "offer", "shown", "release", "dismiss", "snooze", "enable", "status"];
715
- if (command === "html") return ["catalog", "scaffold", "render"];
720
+ if (command === "html") return ["catalog", "scaffold", "init", "render", "still", "preview", "deliver"];
716
721
  if (command === "style") return ["list", "show"];
717
722
  if (command === "direct") return ["init", "anchor", "plan", "start", "inspect", "revise", "generate", "resume", "review", "assemble", "cleanup"];
718
723
  if (command === "studio") return words[2] === "assets" || words[1] === "assets" ? ["search", "describe", "plan", "import"] : ["init", "bundle", "plan", "probe", "run", "encode", "asset", "assemble", "inspect", "reconcile", "assets"];
@@ -733,7 +738,8 @@ export function completions(words: readonly string[]): readonly string[] {
733
738
  if (words[1] === "direction") return ["check", "plan", "gallery"];
734
739
  if (words[1] === "effects") return ["check", "plan", "bake"];
735
740
  if (words[1] === "behavior") return ["check", "bake", "gallery", "audit"];
736
- return ["init", "check", "inspect", "diff", "patch", "evaluate", "audit", "render-audit", "solve", "review", "camera-track", "generate", "plan", "render", "direction", "effects", "behavior", "temporal-audit"];
741
+ if (words[1] === "design") return ["catalog", "init", "inspect", "set", "compile", "gallery"];
742
+ return ["init", "check", "inspect", "diff", "patch", "evaluate", "audit", "render-audit", "solve", "review", "camera-track", "generate", "plan", "render", "direction", "effects", "behavior", "design", "temporal-audit"];
737
743
  }
738
744
  if (command === "edit") return ["init", "show", "trim", "cut", "speed", "zoom", "overlay", "cursor", "clicks", "keystrokes", "typed-text"];
739
745
  if (command === "analyze") return ["faces", "inactivity", "zooms", "music", "scenes", "speech"];
@@ -745,5 +751,7 @@ export function completions(words: readonly string[]): readonly string[] {
745
751
  if (command === "media") return ["audio", "color"];
746
752
  if (command === "render") return ["plan", "run"];
747
753
  if (command === "assets") return ["emoji"];
754
+ if (command === "outputs") return ["list", "open", "reveal"];
755
+ if (command === "legacy") return ["retire"];
748
756
  return [];
749
757
  }
@@ -0,0 +1,158 @@
1
+ import { constants } from "node:fs";
2
+ import { lstat, mkdir, open, readFile, realpath } from "node:fs/promises";
3
+ import { basename, dirname, join, resolve } from "node:path";
4
+
5
+ import { CliError } from "./errors";
6
+ import { discoverRepositoryRoot } from "./paths";
7
+
8
+ import { HTML_FILM_TEMPLATES, type HtmlFilmTemplate } from "./html-film-names";
9
+
10
+ export { HTML_FILM_TEMPLATES, type HtmlFilmTemplate };
11
+ export const HTML_FILM_ASPECTS = ["16:9", "1:1", "9:16"] as const;
12
+ export type HtmlFilmAspect = (typeof HTML_FILM_ASPECTS)[number];
13
+
14
+ /** Files copied from examples/html/<template>/, in write order. */
15
+ export const HTML_FILM_TEMPLATE_FILES = [
16
+ "README.md", "build.ts", "film.css", "film.html", "film.js", "film.json", "mockups.tsx", "timeline.ts",
17
+ ] as const;
18
+
19
+ const REACT_VERSION = "19.2.3";
20
+
21
+ export interface HtmlFilmInitResult {
22
+ readonly kind: "slopcamera.html-film-init";
23
+ readonly schemaVersion: 1;
24
+ readonly directory: string;
25
+ readonly template: HtmlFilmTemplate;
26
+ readonly aspect: HtmlFilmAspect;
27
+ readonly slopcameraVersion: string;
28
+ readonly files: readonly string[];
29
+ readonly executed: false;
30
+ readonly next: readonly string[];
31
+ }
32
+
33
+ export interface HtmlFilmTemplateSource {
34
+ readonly version: string;
35
+ readonly files: Readonly<Record<string, string>>;
36
+ }
37
+
38
+ /** Reads the template from the installed package or checkout that runs this CLI. */
39
+ export async function readHtmlFilmTemplate(template: HtmlFilmTemplate, from = import.meta.dir): Promise<HtmlFilmTemplateSource> {
40
+ if (from.startsWith("/$bunfs/")) {
41
+ throw new CliError("unavailable", "html init needs the @hraness/slopcamera package on disk. Run it through bunx @hraness/slopcamera or a checkout.");
42
+ }
43
+ const root = await discoverRepositoryRoot(from);
44
+ const manifest = JSON.parse(await readFile(join(root, "package.json"), "utf8")) as { readonly version?: unknown };
45
+ if (typeof manifest.version !== "string") throw new CliError("invalid-data", "The Slopcamera package has no version.");
46
+ const files: Record<string, string> = {};
47
+ for (const name of HTML_FILM_TEMPLATE_FILES) {
48
+ files[name] = await readFile(join(root, "examples", "html", template, name), "utf8");
49
+ }
50
+ return { version: manifest.version, files };
51
+ }
52
+
53
+ function packageName(directory: string): string {
54
+ const name = basename(directory).toLowerCase().replace(/[^a-z0-9._-]+/gu, "-").replace(/^[._-]+|[-]+$/gu, "");
55
+ return name === "" ? "launch-film" : name;
56
+ }
57
+
58
+ /**
59
+ * The files a new film project holds: the template, with its aspect set, plus
60
+ * a package.json that pins this Slopcamera version next to the source.
61
+ */
62
+ export function htmlFilmProjectFiles(
63
+ source: HtmlFilmTemplateSource,
64
+ options: { readonly directory: string; readonly aspect: HtmlFilmAspect },
65
+ ): Readonly<Record<string, string>> {
66
+ const film = JSON.parse(source.files["film.json"]!) as Record<string, unknown>;
67
+ const files: Record<string, string> = { ...source.files, "film.json": `${JSON.stringify({ ...film, aspect: options.aspect }, null, 2)}\n` };
68
+ files["package.json"] = `${JSON.stringify({
69
+ name: packageName(options.directory),
70
+ private: true,
71
+ type: "module",
72
+ scripts: {
73
+ build: "bun build.ts",
74
+ still: "slopcamera html still --input out/scene.json --at 3,12.5 --output out/stills",
75
+ draft: "bun build.ts --scale 0.5 --fps 15",
76
+ render: "slopcamera html render --input out/scene.json --json > out/export.json",
77
+ deliver: "slopcamera html deliver out/export.json --basename launch --poster-at 9 --social-at 9 --cuts 1:1,9:16 --per-beat-clips",
78
+ },
79
+ dependencies: {
80
+ "@hraness/slopcamera": source.version,
81
+ react: REACT_VERSION,
82
+ "react-dom": REACT_VERSION,
83
+ },
84
+ devDependencies: {
85
+ "@types/bun": "latest",
86
+ "@types/react": "^19.2.0",
87
+ "@types/react-dom": "^19.2.0",
88
+ },
89
+ }, null, 2)}\n`;
90
+ files["tsconfig.json"] = `${JSON.stringify({
91
+ compilerOptions: {
92
+ target: "ES2023",
93
+ module: "Preserve",
94
+ moduleResolution: "bundler",
95
+ jsx: "react-jsx",
96
+ strict: true,
97
+ noEmit: true,
98
+ allowImportingTsExtensions: true,
99
+ skipLibCheck: true,
100
+ types: ["bun"],
101
+ },
102
+ include: ["*.ts", "*.tsx"],
103
+ }, null, 2)}\n`;
104
+ files[".gitignore"] = "node_modules/\nout/\n";
105
+ return files;
106
+ }
107
+
108
+ async function writeNew(path: string, text: string): Promise<void> {
109
+ const handle = await open(path, constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW | constants.O_WRONLY, 0o644);
110
+ try {
111
+ await handle.writeFile(text, "utf8");
112
+ await handle.sync();
113
+ } finally {
114
+ await handle.close();
115
+ }
116
+ }
117
+
118
+ /** Creates a new film project. It never merges into an existing directory. */
119
+ export async function createHtmlFilmProject(
120
+ directoryInput: string,
121
+ options: { readonly template: HtmlFilmTemplate; readonly aspect: HtmlFilmAspect; readonly source?: HtmlFilmTemplateSource },
122
+ ): Promise<HtmlFilmInitResult> {
123
+ const requested = resolve(directoryInput);
124
+ const parent = await realpath(dirname(requested)).catch(() => {
125
+ throw new CliError("not-found", `The parent directory does not exist: ${dirname(requested)}`);
126
+ });
127
+ const directory = join(parent, basename(requested));
128
+ const source = options.source ?? await readHtmlFilmTemplate(options.template);
129
+ const files = htmlFilmProjectFiles(source, { directory, aspect: options.aspect });
130
+ try {
131
+ await mkdir(directory, { mode: 0o755 });
132
+ } catch (error) {
133
+ if (error instanceof Error && "code" in error && error.code === "EEXIST") {
134
+ throw new CliError("conflict", `Refusing to write into an existing path: ${directory}`);
135
+ }
136
+ throw error;
137
+ }
138
+ if (!(await lstat(directory)).isDirectory()) throw new CliError("conflict", "The film directory changed while it was created.");
139
+ const names = Object.keys(files).sort();
140
+ for (const name of names) await writeNew(join(directory, name), files[name]!);
141
+ return {
142
+ kind: "slopcamera.html-film-init",
143
+ schemaVersion: 1,
144
+ directory,
145
+ template: options.template,
146
+ aspect: options.aspect,
147
+ slopcameraVersion: source.version,
148
+ files: names,
149
+ executed: false,
150
+ next: [
151
+ `cd ${JSON.stringify(directory)} && bun install`,
152
+ "Edit film.json and mockups.tsx, then run bun run build.",
153
+ "slopcamera html still --input out/scene.json --at 3,12.5 --output out/stills",
154
+ "slopcamera html render --input out/scene.json --json > out/export.json",
155
+ "slopcamera html deliver out/export.json --basename launch --poster-at 9 --social-at 9",
156
+ ],
157
+ };
158
+ }
@@ -0,0 +1,17 @@
1
+ /** Launch-film command shapes, kept free of runtime imports for the argument parser. */
2
+
3
+ /** Film templates that `html init --template` writes; public copy cites these ids. */
4
+ export const HTML_FILM_TEMPLATES = ["launch-film"] as const;
5
+ export type HtmlFilmTemplate = (typeof HTML_FILM_TEMPLATES)[number];
6
+
7
+ export const HTML_FILM_CUTS = ["1:1", "9:16", "4:5"] as const;
8
+ export type HtmlFilmCut = (typeof HTML_FILM_CUTS)[number];
9
+
10
+ export type HtmlFilmCommand =
11
+ | { readonly kind: "html-film"; readonly action: "still"; readonly input: string; readonly at: readonly number[]; readonly output: string; readonly json: boolean }
12
+ | { readonly kind: "html-film"; readonly action: "preview"; readonly input: string; readonly every: number; readonly output: string; readonly json: boolean }
13
+ | {
14
+ readonly kind: "html-film"; readonly action: "deliver"; readonly exportPath: string; readonly basename: string;
15
+ readonly posterAt: number; readonly socialAt: number; readonly cuts: readonly HtmlFilmCut[];
16
+ readonly perBeatClips: boolean; readonly beats?: string; readonly output?: string; readonly json: boolean;
17
+ };