@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
@@ -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
+ };
@@ -0,0 +1,471 @@
1
+ /**
2
+ * Launch-film tooling around `html render`: stills and contact-sheet previews
3
+ * from the real injected overlay runtime, and web delivery of a rendered film.
4
+ */
5
+ import { createHash, randomUUID } from "node:crypto";
6
+ import { copyFile, mkdir, readFile, realpath, stat, writeFile } from "node:fs/promises";
7
+ import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
8
+ import { z } from "zod";
9
+
10
+ import type { ApplicationContext } from "../application/context";
11
+ import {
12
+ bindExactCapabilities, ExactCapabilityApplicationRunner, type ExactCapabilityBinding,
13
+ } from "../application/capability-binding";
14
+ import { bindHtmlOverlayBrowserRuntime } from "../application/html-overlay-browser-runtime";
15
+ import { createNodeBundleFileSystem } from "../core/storage";
16
+ import { CliError } from "./errors";
17
+ import { readHtmlSceneSource } from "./html-scene";
18
+ import { ensurePhysicalPrivateDirectoryWithin } from "./paths";
19
+ import { HTML_FILM_CUTS, type HtmlFilmCommand, type HtmlFilmCut } from "./html-film-names";
20
+ import { readSpatialJson } from "./spatial-scene-service";
21
+
22
+ export { HTML_FILM_CUTS, type HtmlFilmCommand, type HtmlFilmCut };
23
+
24
+ const MAX_REQUEST_BYTES = 2 * 1024 * 1024;
25
+ const MAX_STILLS = 120;
26
+ const PROCESS_TIMEOUT_MS = 10 * 60_000;
27
+
28
+ export interface HtmlFilmDependencies {
29
+ readonly bindBrowser?: typeof bindHtmlOverlayBrowserRuntime;
30
+ readonly bindTools?: typeof bindExactCapabilities;
31
+ readonly progress?: (message: string) => void;
32
+ }
33
+
34
+ /** Web delivery budgets in bytes. A file over its budget fails the command. */
35
+ export const HTML_FILM_BUDGETS = { mp4: 12_000_000, webm: 10_000_000, jpg: 250_000 } as const;
36
+
37
+ /** Per-beat clip length bounds in seconds, sized for social posts. */
38
+ export const BEAT_CLIP_SECONDS = { min: 6, max: 10 } as const;
39
+
40
+ // Frame selection
41
+
42
+ export interface HtmlFilmFrameSelection {
43
+ /** Requested time in seconds, in the order given. */
44
+ readonly at: number;
45
+ readonly frame: number;
46
+ }
47
+
48
+ /**
49
+ * Maps requested seconds onto frame indexes. Times past the end land on the
50
+ * last frame. The renderer needs its frames distinct and ascending, so
51
+ * `frames` is sorted and deduplicated while `selection` keeps request order.
52
+ */
53
+ export function selectHtmlFilmFrames(
54
+ at: readonly number[],
55
+ fps: number,
56
+ frameCount: number,
57
+ ): { readonly selection: readonly HtmlFilmFrameSelection[]; readonly frames: readonly number[] } {
58
+ if (at.length === 0) throw new CliError("usage", "Name at least one time in seconds.");
59
+ if (at.length > MAX_STILLS) throw new CliError("usage", `Name at most ${String(MAX_STILLS)} times.`);
60
+ const selection = at.map(seconds => {
61
+ if (!Number.isFinite(seconds) || seconds < 0) throw new CliError("usage", `Times must be seconds at or after 0; got ${String(seconds)}.`);
62
+ return { at: seconds, frame: Math.min(frameCount - 1, Math.round(seconds * fps)) };
63
+ });
64
+ const frames = [...new Set(selection.map(item => item.frame))].sort((a, b) => a - b);
65
+ return { selection, frames };
66
+ }
67
+
68
+ /** Times for a preview contact sheet: 0, every, 2*every, … before the end. */
69
+ export function previewTimes(durationSeconds: number, every: number): readonly number[] {
70
+ if (!(every > 0)) throw new CliError("usage", "--every must be more than 0 seconds.");
71
+ const times: number[] = [];
72
+ for (let index = 0; index * every < durationSeconds && times.length < MAX_STILLS; index++) {
73
+ times.push(Math.round(index * every * 1000) / 1000);
74
+ }
75
+ return times;
76
+ }
77
+
78
+ export function stillFileName(seconds: number): string {
79
+ return `still-${seconds.toFixed(2).padStart(6, "0")}s.png`;
80
+ }
81
+
82
+ // Delivery planning
83
+
84
+ export interface HtmlFilmBeat { readonly id: string; readonly start: number; readonly end: number; readonly caption?: string | undefined }
85
+
86
+ export const HtmlFilmBeatsSchema = z.object({
87
+ duration: z.number().positive(),
88
+ beats: z.array(z.object({
89
+ id: z.string().regex(/^[a-z0-9][a-z0-9-]{0,63}$/u),
90
+ start: z.number().min(0),
91
+ end: z.number().positive(),
92
+ caption: z.string().max(2000).optional(),
93
+ }).refine(beat => beat.end > beat.start, "A beat must end after it starts.")).min(1).max(64),
94
+ });
95
+
96
+ /**
97
+ * A 6 to 10 second window for each beat. Short beats grow forward (or back,
98
+ * near the end) so the clip holds the whole beat plus what follows. Beats
99
+ * that start at or after the rendered duration are left out.
100
+ */
101
+ export function beatClipWindows(beats: readonly HtmlFilmBeat[], duration: number): readonly HtmlFilmBeat[] {
102
+ // A draft render can be shorter than the beats file; a beat the film never
103
+ // reaches has no clip rather than a copy of the film's last seconds.
104
+ return beats.filter(beat => beat.start < duration).map(beat => {
105
+ const length = Math.min(BEAT_CLIP_SECONDS.max, Math.max(BEAT_CLIP_SECONDS.min, beat.end - beat.start), duration);
106
+ const start = Math.max(0, Math.min(beat.start, duration - length));
107
+ return { ...beat, start: round3(start), end: round3(start + length) };
108
+ });
109
+ }
110
+
111
+ function round3(value: number): number {
112
+ return Math.round(value * 1000) / 1000;
113
+ }
114
+
115
+ export type HtmlFilmDeliverableRole = "mp4" | "webm" | "poster" | "social" | "cut" | "clip";
116
+
117
+ export interface HtmlFilmDeliverable {
118
+ readonly role: HtmlFilmDeliverableRole;
119
+ readonly file: string;
120
+ readonly budget: number;
121
+ /** ffmpeg arguments after the executable; `{input}` and `{passlog}` are filled at run time. */
122
+ readonly passes: readonly (readonly string[])[];
123
+ readonly beat?: string;
124
+ readonly cut?: HtmlFilmCut;
125
+ }
126
+
127
+ const CUT_SIZES: Readonly<Record<HtmlFilmCut, readonly [number, number]>> = {
128
+ "1:1": [1080, 1080],
129
+ "4:5": [1080, 1350],
130
+ "9:16": [1080, 1920],
131
+ };
132
+
133
+ const H264 = ["-an", "-c:v", "libx264", "-preset", "slow", "-crf", "24", "-tune", "animation",
134
+ "-pix_fmt", "yuv420p", "-profile:v", "high", "-movflags", "+faststart"] as const;
135
+
136
+ function seconds(value: number): string {
137
+ return value.toFixed(3);
138
+ }
139
+
140
+ /**
141
+ * The files `html deliver` writes, in order, with their ffmpeg arguments.
142
+ * Pure, so the encode settings and budgets are testable without media.
143
+ */
144
+ export function planHtmlFilmDelivery(options: {
145
+ readonly basename: string;
146
+ readonly duration: number;
147
+ readonly posterAt: number;
148
+ readonly socialAt: number;
149
+ readonly cuts: readonly HtmlFilmCut[];
150
+ readonly beats?: readonly HtmlFilmBeat[];
151
+ }): readonly HtmlFilmDeliverable[] {
152
+ const name = options.basename;
153
+ if (!/^[a-z0-9][a-z0-9._-]{0,80}$/u.test(name)) {
154
+ throw new CliError("usage", "--basename must be lowercase letters, digits, dots, dashes or underscores.");
155
+ }
156
+ for (const [flag, value] of [["--poster-at", options.posterAt], ["--social-at", options.socialAt]] as const) {
157
+ if (!(value >= 0 && value < options.duration)) {
158
+ throw new CliError("usage", `${flag} must be within the film, 0 to ${seconds(options.duration)} seconds.`);
159
+ }
160
+ }
161
+ const still = (at: number, filter?: string): readonly string[] => [
162
+ "-ss", seconds(at), "-i", "{input}", "-frames:v", "1", ...(filter === undefined ? [] : ["-vf", filter]), "-q:v", "4",
163
+ ];
164
+ const vp9 = ["-i", "{input}", "-an", "-c:v", "libvpx-vp9", "-crf", "36", "-b:v", "0", "-row-mt", "1", "-pix_fmt", "yuv420p"];
165
+ const out: HtmlFilmDeliverable[] = [
166
+ { role: "mp4", file: `${name}.mp4`, budget: HTML_FILM_BUDGETS.mp4, passes: [["-i", "{input}", ...H264]] },
167
+ {
168
+ role: "webm", file: `${name}.webm`, budget: HTML_FILM_BUDGETS.webm, passes: [
169
+ [...vp9, "-pass", "1", "-passlogfile", "{passlog}", "-f", "null"],
170
+ [...vp9, "-pass", "2", "-passlogfile", "{passlog}"],
171
+ ],
172
+ },
173
+ { role: "poster", file: `${name}-poster.jpg`, budget: HTML_FILM_BUDGETS.jpg, passes: [still(options.posterAt)] },
174
+ {
175
+ role: "social", file: `${name}-social.jpg`, budget: HTML_FILM_BUDGETS.jpg,
176
+ passes: [still(options.socialAt, "scale=1200:630:force_original_aspect_ratio=increase,crop=1200:630")],
177
+ },
178
+ ];
179
+ for (const cut of [...new Set(options.cuts)]) {
180
+ const [width, height] = CUT_SIZES[cut];
181
+ // The whole frame stays visible over a blurred fill, so no copy is cropped.
182
+ const filter = `split[a][b];[a]scale=${String(width)}:${String(height)}:force_original_aspect_ratio=increase,crop=${String(width)}:${String(height)},boxblur=40:2,eq=brightness=-0.08[bg];`
183
+ + `[b]scale=${String(width)}:${String(height)}:force_original_aspect_ratio=decrease[fg];[bg][fg]overlay=(W-w)/2:(H-h)/2,setsar=1`;
184
+ out.push({
185
+ role: "cut", cut, file: `${name}-${cut.replace(":", "x")}.mp4`, budget: HTML_FILM_BUDGETS.mp4,
186
+ passes: [["-i", "{input}", "-filter_complex", filter, ...H264]],
187
+ });
188
+ }
189
+ for (const beat of beatClipWindows(options.beats ?? [], options.duration)) {
190
+ out.push({
191
+ role: "clip", beat: beat.id, file: `${name}-${beat.id}.mp4`, budget: HTML_FILM_BUDGETS.mp4,
192
+ passes: [["-ss", seconds(beat.start), "-t", seconds(beat.end - beat.start), "-i", "{input}", ...H264]],
193
+ });
194
+ }
195
+ return out;
196
+ }
197
+
198
+ export interface HtmlFilmDeliveredFile {
199
+ readonly role: HtmlFilmDeliverableRole;
200
+ readonly path: string;
201
+ readonly bytes: number;
202
+ readonly sha256: string;
203
+ readonly budget: number;
204
+ readonly withinBudget: boolean;
205
+ readonly beat?: string;
206
+ readonly cut?: HtmlFilmCut;
207
+ }
208
+
209
+ export function formatDeliveryLine(file: HtmlFilmDeliveredFile): string {
210
+ const size = (bytes: number) => bytes >= 1_000_000 ? `${(bytes / 1_000_000).toFixed(2)} MB` : `${String(Math.round(bytes / 1000))} KB`;
211
+ return `${file.withinBudget ? "ok " : "OVER"} ${basename(file.path)} ${size(file.bytes)} (max ${size(file.budget)})`;
212
+ }
213
+
214
+ // Execution
215
+
216
+ const ExportSchema = z.object({
217
+ kind: z.literal("slopcamera.html-scene-export"),
218
+ output: z.object({ path: z.string().min(1), bytes: z.number().int().positive(), sha256: z.string().regex(/^[0-9a-f]{64}$/u) }),
219
+ });
220
+
221
+ const ProbeSchema = z.object({ format: z.object({ duration: z.string() }) });
222
+
223
+ async function sha256File(path: string): Promise<{ readonly bytes: number; readonly sha256: string }> {
224
+ const data = await readFile(path);
225
+ return { bytes: data.byteLength, sha256: createHash("sha256").update(data).digest("hex") };
226
+ }
227
+
228
+ async function outputDirectory(application: ApplicationContext, requested: string): Promise<string> {
229
+ const root = application.paths.repositoryRoot;
230
+ const absolute = resolve(root, requested);
231
+ const inside = relative(root, absolute);
232
+ if (inside === "" || inside.startsWith("..") || isAbsolute(inside)) {
233
+ throw new CliError("unsafe-path", `The output directory must be inside ${root}.`);
234
+ }
235
+ await mkdir(absolute, { recursive: true });
236
+ const real = await realpath(absolute);
237
+ const realInside = relative(root, real);
238
+ if (realInside === "" || realInside.startsWith("..") || isAbsolute(realInside)) {
239
+ throw new CliError("unsafe-path", "The output directory resolves outside the project.");
240
+ }
241
+ return real;
242
+ }
243
+
244
+ async function requireLease(application: ApplicationContext, signal: AbortSignal, command: string): Promise<void> {
245
+ if (signal.aborted) throw new CliError("cancelled", `${command} was cancelled.`);
246
+ if (application.hostResourceLease === undefined) throw new CliError("unavailable", `${command} requires an admitted host-resource lease.`);
247
+ await application.hostResourceLease.assertOwned();
248
+ }
249
+
250
+ function capabilityRunner(application: ApplicationContext, tools: readonly ExactCapabilityBinding[], signal: AbortSignal) {
251
+ const runner = new ExactCapabilityApplicationRunner(application.runner, tools, application.paths.privateRoot);
252
+ const tool = (name: string): string => {
253
+ const found = tools.find(candidate => candidate.name === name);
254
+ if (found === undefined) throw new CliError("unavailable", `Missing capability: ${name}`);
255
+ return found.command;
256
+ };
257
+ const run = async (argv: readonly [string, ...string[]]) => {
258
+ await requireLease(application, signal, "The film step");
259
+ const result = await runner.run(argv, {
260
+ abortSignal: signal, timeoutMs: PROCESS_TIMEOUT_MS, maxOutputBytes: 1024 * 1024,
261
+ ...(application.hostResourceLease === undefined ? {} : { inheritedFileDescriptors: application.hostResourceLease.inheritedFileDescriptors }),
262
+ });
263
+ if (result.exitCode !== 0) throw new CliError("subprocess", `A film media step failed: ${result.stderr.trim().slice(0, 1000) || String(result.exitCode)}`);
264
+ return result;
265
+ };
266
+ return { tool, run };
267
+ }
268
+
269
+ /** Renders the selected frames of a scene with the real overlay runtime. */
270
+ async function renderSceneFrames(
271
+ application: ApplicationContext,
272
+ inputPath: string,
273
+ times: readonly number[],
274
+ signal: AbortSignal,
275
+ dependencies: HtmlFilmDependencies,
276
+ extraTools: readonly ("ffmpeg")[] = [],
277
+ ) {
278
+ await requireLease(application, signal, "Rendering stills");
279
+ const input = await readSpatialJson(resolve(application.paths.repositoryRoot, inputPath), MAX_REQUEST_BYTES);
280
+ const source = await readHtmlSceneSource(application, input, signal);
281
+ if (application.htmlOverlayRenderer === undefined) throw new CliError("unavailable", "The HTML scene renderer is unavailable.");
282
+ const { selection, frames } = selectHtmlFilmFrames(times, source.request.timing.fps, source.frameCount);
283
+ const tools = await (dependencies.bindTools ?? bindExactCapabilities)(application, ["html-browser", ...extraTools]);
284
+ const browserTool = tools.find(candidate => candidate.name === "html-browser");
285
+ if (browserTool === undefined) throw new CliError("unavailable", "Missing capability: html-browser");
286
+ dependencies.progress?.("browser");
287
+ const browserRuntime = await (dependencies.bindBrowser ?? bindHtmlOverlayBrowserRuntime)(browserTool, signal);
288
+ const jobDirectory = await ensurePhysicalPrivateDirectoryWithin(application.paths.repositoryRoot,
289
+ `artifacts/slopcamera/generated/html-stills/stills_${randomUUID().replaceAll("-", "")}`);
290
+ const rootFs = createNodeBundleFileSystem(application.paths.repositoryRoot);
291
+ await ensurePhysicalPrivateDirectoryWithin(jobDirectory, "source");
292
+ const resources = [];
293
+ for (const { declaration, loaded } of source.resources) {
294
+ const destination = join(jobDirectory, "source", `${declaration.name}-${loaded.artifact.sha256}`);
295
+ await rootFs.copyFileNoReplace!(loaded.artifact.path, relative(application.paths.repositoryRoot, destination), loaded.artifact,
296
+ async () => { await requireLease(application, signal, "Rendering stills"); });
297
+ const { path: _path, ...resource } = declaration;
298
+ resources.push({ ...resource, bytes: loaded.artifact.bytes, sha256: loaded.artifact.sha256, absolutePath: destination });
299
+ }
300
+ dependencies.progress?.("frames");
301
+ const renderDirectory = await ensurePhysicalPrivateDirectoryWithin(jobDirectory, "render");
302
+ const result = await application.htmlOverlayRenderer.renderFrames({
303
+ authoring: source.authoring, browserRuntime, outputDirectory: renderDirectory, resources, frames,
304
+ ...(source.request.executionProfile === undefined ? {} : { executionProfile: source.request.executionProfile }),
305
+ }, signal);
306
+ if (result.frameCount !== frames.length) throw new CliError("invalid-data", "The renderer returned a different number of stills than requested.");
307
+ const framePath = (frame: number) => result.framePattern.replace("%08d", String(frame).padStart(8, "0"));
308
+ return { source, selection, framePath, jobDirectory, tools };
309
+ }
310
+
311
+ export interface HtmlFilmStillsResult {
312
+ readonly kind: "slopcamera.html-film-stills";
313
+ readonly schemaVersion: 1;
314
+ readonly scene: string;
315
+ readonly width: number;
316
+ readonly height: number;
317
+ readonly fps: number;
318
+ readonly stills: readonly { readonly at: number; readonly frame: number; readonly path: string; readonly bytes: number; readonly sha256: string }[];
319
+ readonly contactSheet?: string;
320
+ }
321
+
322
+ async function writeStills(
323
+ application: ApplicationContext,
324
+ command: { readonly input: string; readonly output: string },
325
+ times: readonly number[],
326
+ signal: AbortSignal,
327
+ dependencies: HtmlFilmDependencies,
328
+ contactSheet: boolean,
329
+ ): Promise<HtmlFilmStillsResult> {
330
+ const rendered = await renderSceneFrames(application, command.input, times, signal, dependencies, contactSheet ? ["ffmpeg"] : []);
331
+ const directory = await outputDirectory(application, command.output);
332
+ const stills = [];
333
+ for (const item of rendered.selection) {
334
+ const path = join(directory, stillFileName(item.at));
335
+ await copyFile(rendered.framePath(item.frame), path);
336
+ stills.push({ ...item, path: relative(application.paths.repositoryRoot, path), ...await sha256File(path) });
337
+ }
338
+ let sheet: string | undefined;
339
+ if (contactSheet) {
340
+ const { tool, run } = capabilityRunner(application, rendered.tools, signal);
341
+ const sequence = await ensurePhysicalPrivateDirectoryWithin(rendered.jobDirectory, "sheet");
342
+ for (const [index, item] of rendered.selection.entries()) {
343
+ await copyFile(rendered.framePath(item.frame), join(sequence, `tile-${String(index).padStart(4, "0")}.png`));
344
+ }
345
+ const columns = Math.min(4, rendered.selection.length);
346
+ const rows = Math.ceil(rendered.selection.length / columns);
347
+ sheet = join(directory, "contact-sheet.png");
348
+ dependencies.progress?.("contact sheet");
349
+ await run([tool("ffmpeg"), "-nostdin", "-v", "error", "-y", "-framerate", "1", "-start_number", "0",
350
+ "-i", join(sequence, "tile-%04d.png"), "-vf", `scale=480:-2,tile=${String(columns)}x${String(rows)}:padding=8:margin=8:color=0x111214`,
351
+ "-frames:v", "1", sheet]);
352
+ sheet = relative(application.paths.repositoryRoot, sheet);
353
+ }
354
+ return {
355
+ kind: "slopcamera.html-film-stills", schemaVersion: 1, scene: command.input,
356
+ width: rendered.source.width, height: rendered.source.height, fps: rendered.source.request.timing.fps,
357
+ stills, ...(sheet === undefined ? {} : { contactSheet: sheet }),
358
+ };
359
+ }
360
+
361
+ export interface HtmlFilmDeliveryResult {
362
+ readonly kind: "slopcamera.html-film-delivery";
363
+ readonly schemaVersion: 1;
364
+ readonly source: { readonly path: string; readonly bytes: number; readonly sha256: string; readonly duration: number };
365
+ readonly outputDirectory: string;
366
+ readonly receipt: string;
367
+ readonly files: readonly HtmlFilmDeliveredFile[];
368
+ readonly withinBudget: boolean;
369
+ }
370
+
371
+ async function deliver(
372
+ application: ApplicationContext,
373
+ command: Extract<HtmlFilmCommand, { readonly action: "deliver" }>,
374
+ signal: AbortSignal,
375
+ dependencies: HtmlFilmDependencies,
376
+ ): Promise<HtmlFilmDeliveryResult> {
377
+ await requireLease(application, signal, "Delivering the film");
378
+ const root = application.paths.repositoryRoot;
379
+ const exportPath = resolve(root, command.exportPath);
380
+ let exported: z.infer<typeof ExportSchema>;
381
+ try {
382
+ exported = ExportSchema.parse(JSON.parse(await readFile(exportPath, "utf8")) as unknown);
383
+ } catch (error) {
384
+ throw new CliError("invalid-data", `${command.exportPath} is not an html render --json export: ${error instanceof Error ? error.message : String(error)}`);
385
+ }
386
+ const master = resolve(root, exported.output.path);
387
+ const actual = await sha256File(master).catch(() => {
388
+ throw new CliError("not-found", `The rendered film is missing: ${exported.output.path}. Run html deliver from the directory html render ran in.`);
389
+ });
390
+ if (actual.sha256 !== exported.output.sha256 || actual.bytes !== exported.output.bytes) {
391
+ throw new CliError("conflict", "The rendered film changed after its export receipt was written.");
392
+ }
393
+ const tools = await (dependencies.bindTools ?? bindExactCapabilities)(application, ["ffmpeg", "ffprobe"]);
394
+ const { tool, run } = capabilityRunner(application, tools, signal);
395
+ const probe = ProbeSchema.parse(JSON.parse((await run([tool("ffprobe"), "-v", "error", "-show_entries", "format=duration", "-of", "json", master])).stdout) as unknown);
396
+ const duration = Number(probe.format.duration);
397
+ if (!(duration > 0)) throw new CliError("invalid-data", "The rendered film has no duration.");
398
+ let beats: readonly HtmlFilmBeat[] | undefined;
399
+ if (command.perBeatClips) {
400
+ const beatsPath = resolve(root, command.beats ?? join(dirname(command.exportPath), "beats.json"));
401
+ try {
402
+ beats = HtmlFilmBeatsSchema.parse(JSON.parse(await readFile(beatsPath, "utf8")) as unknown).beats;
403
+ } catch (error) {
404
+ throw new CliError("invalid-data", `--per-beat-clips needs a beats file (${relative(root, beatsPath)}): ${error instanceof Error ? error.message : String(error)}`);
405
+ }
406
+ }
407
+ const plan = planHtmlFilmDelivery({
408
+ basename: command.basename, duration, posterAt: command.posterAt, socialAt: command.socialAt, cuts: command.cuts,
409
+ ...(beats === undefined ? {} : { beats }),
410
+ });
411
+ const directory = await outputDirectory(application, command.output ?? join(dirname(command.exportPath), "deliver"));
412
+ const work = await ensurePhysicalPrivateDirectoryWithin(root, `artifacts/slopcamera/generated/html-deliver/deliver_${randomUUID().replaceAll("-", "")}`);
413
+ const files: HtmlFilmDeliveredFile[] = [];
414
+ for (const item of plan) {
415
+ dependencies.progress?.(item.file);
416
+ const destination = join(directory, item.file);
417
+ for (const [index, pass] of item.passes.entries()) {
418
+ const argv = pass.map(value => value === "{input}" ? master : value === "{passlog}" ? join(work, `${item.role}-pass`) : value);
419
+ const last = index === item.passes.length - 1;
420
+ await run([tool("ffmpeg"), "-nostdin", "-hide_banner", "-v", "error", "-y", "-threads", "2", ...argv, last ? destination : "/dev/null"]);
421
+ }
422
+ const measured = await sha256File(destination);
423
+ files.push({
424
+ role: item.role, path: relative(root, destination), ...measured, budget: item.budget, withinBudget: measured.bytes <= item.budget,
425
+ ...(item.beat === undefined ? {} : { beat: item.beat }), ...(item.cut === undefined ? {} : { cut: item.cut }),
426
+ });
427
+ }
428
+ const receiptPath = join(directory, `${command.basename}-receipt.json`);
429
+ const withinBudget = files.every(file => file.withinBudget);
430
+ const result: HtmlFilmDeliveryResult = {
431
+ kind: "slopcamera.html-film-delivery", schemaVersion: 1,
432
+ source: { path: exported.output.path, ...actual, duration },
433
+ outputDirectory: relative(root, directory), receipt: relative(root, receiptPath), files, withinBudget,
434
+ };
435
+ await writeFile(receiptPath, `${JSON.stringify({ ...result, deliveredAt: application.clock.now().toISOString(), budgets: HTML_FILM_BUDGETS }, null, 2)}\n`);
436
+ if ((await stat(receiptPath)).size === 0) throw new CliError("internal", "The delivery receipt is empty.");
437
+ return result;
438
+ }
439
+
440
+ export async function executeHtmlFilmCommand(
441
+ application: ApplicationContext,
442
+ command: HtmlFilmCommand,
443
+ signal: AbortSignal,
444
+ dependencies: HtmlFilmDependencies = {},
445
+ ): Promise<HtmlFilmStillsResult | HtmlFilmDeliveryResult> {
446
+ switch (command.action) {
447
+ case "still":
448
+ return await writeStills(application, command, command.at, signal, dependencies, false);
449
+ case "preview": {
450
+ const input = await readSpatialJson(resolve(application.paths.repositoryRoot, command.input), MAX_REQUEST_BYTES);
451
+ const source = await readHtmlSceneSource(application, input, signal);
452
+ return await writeStills(application, command, previewTimes(source.durationUs / 1_000_000, command.every), signal, dependencies, true);
453
+ }
454
+ case "deliver":
455
+ return await deliver(application, command, signal, dependencies);
456
+ }
457
+ }
458
+
459
+ export function formatHtmlFilmResult(result: HtmlFilmStillsResult | HtmlFilmDeliveryResult): string {
460
+ if (result.kind === "slopcamera.html-film-stills") {
461
+ return [
462
+ ...result.stills.map(still => `${still.path} (${still.at.toFixed(2)} s, frame ${String(still.frame)})`),
463
+ ...(result.contactSheet === undefined ? [] : [`contact sheet ${result.contactSheet}`]),
464
+ ].join("\n");
465
+ }
466
+ return [
467
+ ...result.files.map(formatDeliveryLine),
468
+ `receipt ${result.receipt}`,
469
+ ...(result.withinBudget ? [] : ["One or more files are over budget. Shorten the film, lower its frame rate or simplify the grain, then render and deliver again."]),
470
+ ].join("\n");
471
+ }