@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.
- package/README.md +49 -33
- package/apps/desktop/README.md +3 -10
- package/apps/desktop/application/html-overlay-renderer.ts +6 -0
- package/apps/desktop/application/operation.ts +1 -1
- package/apps/desktop/application/spatial-assets.ts +1 -1
- package/apps/desktop/cli/{menubar-status.ts → activity-status.ts} +24 -21
- package/apps/desktop/cli/args.ts +62 -10
- package/apps/desktop/cli/command-host-resources.ts +5 -1
- package/apps/desktop/cli/commands.ts +26 -9
- package/apps/desktop/cli/desktop-status.ts +271 -0
- package/apps/desktop/cli/help.ts +36 -28
- package/apps/desktop/cli/html-film-init.ts +158 -0
- package/apps/desktop/cli/html-film-names.ts +17 -0
- package/apps/desktop/cli/html-film.ts +471 -0
- package/apps/desktop/cli/html-overlay-renderer.ts +29 -2
- package/apps/desktop/cli/html-scene.ts +3 -3
- package/apps/desktop/cli/legacy-login.ts +184 -0
- package/apps/desktop/cli/main.ts +9 -0
- package/apps/desktop/cli/outputs.ts +19 -0
- package/apps/desktop/cli/portable-surface.ts +55 -1
- package/apps/desktop/cli/registry.ts +314 -0
- package/apps/desktop/cli/verbs.ts +193 -0
- package/apps/desktop/dist/cli/main.js +338 -316
- package/apps/desktop/html-overlay/film.ts +418 -0
- package/dist/cli.js +13 -2
- package/dist/generate.js +1 -1
- package/dist/{index-4z9y2wa2.js → index-842t02pp.js} +1 -1
- package/dist/{index-qxfexvj3.js → index-k9bjw80z.js} +20 -4
- package/dist/index-p74xy76d.js +3 -0
- package/dist/{index-fevf4ppk.js → index-y8spxs77.js} +10 -7
- package/dist/index.js +1 -1
- package/dist/operations.js +1 -1
- package/dist/workflow.js +1 -1
- package/docs/README.md +6 -6
- package/examples/html/launch-film/README.md +51 -0
- package/examples/html/launch-film/build.ts +158 -0
- package/examples/html/launch-film/film.css +593 -0
- package/examples/html/launch-film/film.html +85 -0
- package/examples/html/launch-film/film.js +410 -0
- package/examples/html/launch-film/film.json +63 -0
- package/examples/html/launch-film/mockups.tsx +74 -0
- package/examples/html/launch-film/timeline.ts +57 -0
- package/package.json +24 -11
- package/skills/slopcamera/SKILL.md +4 -3
- package/skills/slopcamera/references/install.md +3 -3
- package/skills/slopcamera/references/launch-films.md +158 -0
- package/skills/slopcamera/references/reference-led-3d.md +1 -1
- package/skills/slopcamera/references/visual-communication.md +1 -1
- package/src/cli.ts +125 -9
- package/src/generation-pricing.ts +21 -1
- package/src/icon-set.ts +1086 -0
- package/src/icon.ts +396 -59
- package/src/index.ts +1 -0
- package/src/operations.ts +52 -0
- package/src/version.ts +1 -1
- package/apps/desktop/cli/menubar.ts +0 -272
- 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
|
+
}
|
package/apps/desktop/cli/help.ts
CHANGED
|
@@ -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
|
|
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.
|
|
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", "
|
|
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
|
-
|
|
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
|
+
};
|