@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,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
|
|
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
|
|
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
|
|
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
|
|
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 => {
|