@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,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
+ }
@@ -1844,6 +1844,30 @@ async function fulfillPreparedRoute(
1844
1844
  });
1845
1845
  }
1846
1846
 
1847
+ /**
1848
+ * Validates a still/preview frame selection: distinct, ascending, in range.
1849
+ * Ascending order keeps the runtime's virtual clock monotonic.
1850
+ */
1851
+ export function selectedHtmlOverlayFrames(
1852
+ frames: readonly number[],
1853
+ frameCount: number,
1854
+ ): readonly number[] {
1855
+ if (frames.length === 0) {
1856
+ throw new ApplicationError("invalid-data", "A frame selection must name at least one frame.");
1857
+ }
1858
+ let previous = -1;
1859
+ for (const frame of frames) {
1860
+ if (!Number.isSafeInteger(frame) || frame < 0 || frame >= frameCount) {
1861
+ throw new ApplicationError("invalid-data", `Frame ${String(frame)} is outside the render range of ${String(frameCount)} frames.`);
1862
+ }
1863
+ if (frame <= previous) {
1864
+ throw new ApplicationError("invalid-data", "A frame selection must be strictly ascending.");
1865
+ }
1866
+ previous = frame;
1867
+ }
1868
+ return [...frames];
1869
+ }
1870
+
1847
1871
  export class PlaywrightHtmlOverlayRenderer implements HtmlOverlayRenderer {
1848
1872
  readonly #browserStepTimeoutMs: number;
1849
1873
  readonly #cacheRoot: string;
@@ -1963,6 +1987,9 @@ export class PlaywrightHtmlOverlayRenderer implements HtmlOverlayRenderer {
1963
1987
  join(request.outputDirectory, ".html-overlay-frames-"),
1964
1988
  );
1965
1989
  const frameCount = htmlOverlayFrameCount(request.authoring.timing);
1990
+ const frameIndexes = request.frames === undefined
1991
+ ? this.orderedFrameIndexes(frameCount)
1992
+ : selectedHtmlOverlayFrames(request.frames, frameCount);
1966
1993
  const diagnostics: string[] = [];
1967
1994
  const pageErrors: string[] = [];
1968
1995
  let browserRuntimeSnapshot: PreparedBrowserRuntimeSnapshot | undefined;
@@ -2306,7 +2333,7 @@ export class PlaywrightHtmlOverlayRenderer implements HtmlOverlayRenderer {
2306
2333
  "document load",
2307
2334
  );
2308
2335
  await assertNoDeniedBrowserActivity(host);
2309
- for (const frameIndex of this.orderedFrameIndexes(frameCount)) {
2336
+ for (const frameIndex of frameIndexes) {
2310
2337
  if (signal.aborted) throw cancellationReason(signal);
2311
2338
  const frame = createHtmlOverlayRuntimeFrame(
2312
2339
  frameIndex,
@@ -2445,7 +2472,7 @@ export class PlaywrightHtmlOverlayRenderer implements HtmlOverlayRenderer {
2445
2472
  renderResult = {
2446
2473
  ...(gpuEvidence === undefined ? {} : { gpuEvidence }),
2447
2474
  executionIntegrity: execution.integrity,
2448
- frameCount,
2475
+ frameCount: frameIndexes.length,
2449
2476
  framePattern: join(finalFrameDirectory, "frame-%08d.png"),
2450
2477
  libraryLocks: locks,
2451
2478
  };
@@ -67,7 +67,7 @@ async function readPngHeader(path: string, expectedBytes: number) {
67
67
  } finally { await handle.close(); }
68
68
  }
69
69
 
70
- async function readSource(application: ApplicationContext, input: unknown, signal: AbortSignal) {
70
+ export async function readHtmlSceneSource(application: ApplicationContext, input: unknown, signal: AbortSignal) {
71
71
  const request = HtmlSceneInputSchema.parse(input);
72
72
  assertHtmlOverlayExecutionProfileLibraries(request.libraries, request.executionProfile);
73
73
  const loadedDocument = "path" in request.document
@@ -100,7 +100,7 @@ async function readSource(application: ApplicationContext, input: unknown, signa
100
100
  }
101
101
 
102
102
  export async function planHtmlScene(application: ApplicationContext, input: unknown, signal: AbortSignal) {
103
- const source = await readSource(application, input, signal);
103
+ const source = await readHtmlSceneSource(application, input, signal);
104
104
  return {
105
105
  kind: "slopcamera.html-scene-plan" as const, schemaVersion: 1 as const,
106
106
  executed: false, name: source.request.name, frameCount: source.frameCount,
@@ -221,7 +221,7 @@ export async function renderHtmlScene(
221
221
  };
222
222
  await active();
223
223
  dependencies.progress?.("source");
224
- const source = await readSource(application, input, signal);
224
+ const source = await readHtmlSceneSource(application, input, signal);
225
225
  if (application.htmlOverlayRenderer === undefined) throw new CliError("unavailable", "HTML scene renderer is unavailable.");
226
226
  const tools = await (dependencies.bindTools ?? bindExactCapabilities)(application, ["ffmpeg", "ffprobe", "html-browser"]);
227
227
  const tool = (name: string): ExactCapabilityBinding => {