@hraness/slopcamera 3.2.5 → 3.2.7

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 (35) hide show
  1. package/README.md +5 -2
  2. package/apps/desktop/application/operation.ts +1 -1
  3. package/apps/desktop/cli/args.ts +8 -0
  4. package/apps/desktop/cli/command-host-resources.ts +3 -0
  5. package/apps/desktop/cli/commands.ts +10 -1
  6. package/apps/desktop/cli/help.ts +13 -3
  7. package/apps/desktop/cli/html-scene.ts +368 -0
  8. package/apps/desktop/cli/main.ts +6 -1
  9. package/apps/desktop/cli/media-ingest-model.ts +2 -0
  10. package/apps/desktop/cli/media-ingest-program.ts +9 -4
  11. package/apps/desktop/cli/portable-surface.ts +1 -0
  12. package/apps/desktop/cli/recording-controller.ts +1 -1
  13. package/apps/desktop/cli/root-help-intro.ts +14 -0
  14. package/apps/desktop/dist/cli/main.js +241 -222
  15. package/apps/desktop/html-overlay/contracts.ts +3 -0
  16. package/apps/desktop/html-overlay/index.ts +19 -0
  17. package/apps/desktop/html-overlay/music-clock.ts +126 -0
  18. package/apps/desktop/html-overlay/rigged-glb.ts +429 -0
  19. package/apps/desktop/html-overlay/runtime.ts +2 -0
  20. package/apps/desktop/html-overlay/scaffolds.ts +2 -1
  21. package/apps/desktop/html-overlay/scene.ts +47 -0
  22. package/dist/cli.js +1 -1
  23. package/dist/{index-7tzd9155.js → index-rvghzt7b.js} +1 -1
  24. package/dist/index.js +1 -1
  25. package/docs/README.md +1 -0
  26. package/examples/html/music-video.html +514 -0
  27. package/examples/html/music-video.json +16 -0
  28. package/examples/studio/vgpu/README.md +1 -1
  29. package/package.json +3 -1
  30. package/skills/slopcamera/SKILL.md +2 -1
  31. package/skills/slopcamera/references/install.md +1 -1
  32. package/skills/slopcamera/references/music-video.md +149 -0
  33. package/skills/slopcamera/references/video-projects.md +6 -2
  34. package/skills/slopcamera/references/workflows-sdk.md +1 -1
  35. package/src/version.ts +1 -1
package/README.md CHANGED
@@ -33,12 +33,15 @@ project database.
33
33
 
34
34
  ## Install Slopcamera
35
35
 
36
+ The source tree prepares v3.2.7 with a compact interactive CLI introduction.
37
+ The install below stays on the verified v3.2.6 release until publication completes.
38
+
36
39
  Install [Bun 1.3.14 or newer](https://bun.sh), then install the verified
37
- [Slopcamera v3.2.4 release](https://github.com/hraness/slopcamera/releases/tag/v3.2.4)
40
+ [Slopcamera v3.2.6 release](https://github.com/hraness/slopcamera/releases/tag/v3.2.6)
38
41
  from its canonical archive:
39
42
 
40
43
  ```sh
41
- bun add --global https://github.com/hraness/slopcamera/releases/download/v3.2.4/hraness-slopcamera-3.2.4.tgz
44
+ bun add --global https://github.com/hraness/slopcamera/releases/download/v3.2.6/hraness-slopcamera-3.2.6.tgz
42
45
  slopcamera skill install --target agents
43
46
  ```
44
47
 
@@ -4,7 +4,7 @@ import type { z } from "zod";
4
4
 
5
5
  import type { ApplicationContext } from "./context";
6
6
 
7
- export const SLOPCAMERA_APPLICATION_TOOL_VERSION = "slopcamera-3.2.5" as const;
7
+ export const SLOPCAMERA_APPLICATION_TOOL_VERSION = "slopcamera-3.2.7" as const;
8
8
 
9
9
  export const OPERATION_KINDS = [
10
10
  "scene.inspect",
@@ -85,6 +85,7 @@ export type SpatialProjectCommand = JsonOption & {
85
85
  );
86
86
 
87
87
  export type CliCommand =
88
+ | { readonly kind: "html-render"; readonly input: string; readonly dryRun: boolean; readonly json: boolean }
88
89
  | StudioCommand
89
90
  | DirectingCommand
90
91
  | SpatialWorldCommand
@@ -3150,6 +3151,13 @@ export function parseCliArgs(argv: readonly string[]): CliCommand {
3150
3151
  }
3151
3152
  const command = argv[0]!;
3152
3153
  switch (command) {
3154
+ case "html": {
3155
+ if (argv[1] !== "render") fail("Use html render --input <scene.json> [--dry-run] [--json].");
3156
+ const parsed = parseOptions(argv.slice(2), { "--input": "value", "--dry-run": "flag", "--json": "flag" });
3157
+ const input = optionString(parsed, "--input");
3158
+ if (input === undefined || parsed.positionals.length !== 0) fail("Use html render --input <scene.json> [--dry-run] [--json].");
3159
+ return { kind: "html-render", input, dryRun: optionFlag(parsed, "--dry-run"), json: optionFlag(parsed, "--json") };
3160
+ }
3153
3161
  case "studio": return parseStudioArgs(argv.slice(1));
3154
3162
  case "direct": return parseDirectingArgs(argv.slice(1));
3155
3163
  case "scene": return parseSpatialSceneArgs(argv.slice(1));
@@ -125,6 +125,9 @@ export function commandHostResourceClaims(
125
125
  coordinator: HostResourceCoordinator,
126
126
  ): readonly HostResourceClaim[] {
127
127
  switch (command.kind) {
128
+ case "html-render": return command.dryRun
129
+ ? claims(coordinator, ["cpu", "local-io"])
130
+ : claims(coordinator, ["cpu", "local-io", "browser", "ffmpeg", "output-publication", "project-render"]);
128
131
  case "align-analyze":
129
132
  case "analyze-inactivity":
130
133
  case "analyze-music":
@@ -133,6 +133,7 @@ import { resolveEmojiAsset, searchEmojiAssets, inspectEmojiAssets } from "./emoj
133
133
  import { asCliError, CliError, EXIT_CODE } from "./errors";
134
134
  import { commandHelp, completions } from "./help";
135
135
  import { PlaywrightHtmlOverlayRenderer } from "./html-overlay-renderer";
136
+ import { executeHtmlSceneCommand } from "./html-scene";
136
137
  import { BunProcessRunner, processIo, writeJson, writeLine, type CliIo, type ProcessRunner } from "./io";
137
138
  import {
138
139
  codePreparationHostResourceClaims,
@@ -300,7 +301,7 @@ import {
300
301
  workflowRunStore,
301
302
  } from "./workflow-runs";
302
303
 
303
- export const SLOPCAMERA_VERSION = "3.2.5";
304
+ export const SLOPCAMERA_VERSION = "3.2.7";
304
305
 
305
306
  // Legacy direct renders predate per-target output contracts. Keep them
306
307
  // bounded generously enough for long-form production while preventing one
@@ -5761,6 +5762,13 @@ async function handleMediaColor(
5761
5762
 
5762
5763
  async function dispatch(context: CommandContext, command: CliCommand): Promise<void> {
5763
5764
  switch (command.kind) {
5765
+ case "html-render": {
5766
+ const output = await executeHtmlSceneCommand(applicationContext(context), command,
5767
+ context.abortSignal ?? new AbortController().signal,
5768
+ { progress: stage => context.io.stderr(`HTML scene: ${stage}\n`) });
5769
+ writeValue(context.io, command.json, output, () => JSON.stringify(output, null, 2));
5770
+ return;
5771
+ }
5764
5772
  case "studio": {
5765
5773
  const output = await executeStudioCommand(applicationContext(context), command, context.abortSignal ?? new AbortController().signal);
5766
5774
  writeValue(context.io, command.json, output, () => JSON.stringify(output, null, 2));
@@ -6581,6 +6589,7 @@ type MutationReference =
6581
6589
 
6582
6590
  function commandMutationReference(command: CliCommand): MutationReference | undefined {
6583
6591
  switch (command.kind) {
6592
+ case "html-render": return undefined; // A fresh retained attempt owns its publication lease.
6584
6593
  case "studio": return undefined; // Native jobs and the machine custody marker own explicit leases.
6585
6594
  case "directing": return undefined; // The directing store owns its explicit lease.
6586
6595
  case "spatial-world": return undefined; // Immutable world attempts and imports own their publication custody.
@@ -17,7 +17,7 @@ Commands:
17
17
  studio init|bundle|plan|probe|run|encode|asset|assemble|inspect|reconcile
18
18
  Author and render retained Blender, CAD and Manim productions
19
19
  image vectorize|generate Create a local SVG or generated image file
20
- html catalog|scaffold Inspect or create a transparent HTML overlay starter
20
+ html catalog|scaffold|render Author HTML scenes and export video with local audio
21
21
  workflows list|show|plan|run Plan or run a reviewed reusable workflow
22
22
  code init|check|plan|run Author, preflight, and run trusted TypeScript workflows
23
23
  runs list|show|resume|approve|cancel
@@ -162,12 +162,21 @@ content-addressed \`ai image generate\` lane and returns project-composable cont
162
162
  html: `Usage:
163
163
  slopcamera html catalog [--json]
164
164
  slopcamera html scaffold <${HTML_OVERLAY_SCAFFOLD_KIND_HELP}> --output <file.html>
165
+ slopcamera html render --input <scene.json> [--dry-run] [--json]
165
166
 
166
167
  Catalog lists the closed scaffold profiles in stable order with their primary jobs, render
167
168
  substrates, and current exact browser-library versions. Scaffold creates a complete transparent
168
169
  HTML overlay without overwriting an existing file. Scaffolds use the existing
169
170
  @hraness/slopcamera/local/html-overlay API and exact locked import maps. Render the document through
170
- workflow.media.htmlOverlay to receive a deterministic transparent video layer.`,
171
+ workflow.media.htmlOverlay to receive a deterministic transparent video layer.
172
+
173
+ Render accepts a slopcamera.html-scene source, retains its HTML, declared resources and
174
+ optional local soundtrack, and returns an H.264/AAC MP4 plus an ordinary editable project
175
+ with separate scene-video and original-audio tracks. Device dimensions must be even integers.
176
+ Duration rounds up to whole frames; audio starts at zero and is trimmed or padded to that
177
+ duration. Soundtracks encode as 48 kHz stereo AAC at 320 kb/s. Dry run checks source and
178
+ workload bounds without launching a browser, importing audio, or writing project state.
179
+ Progress uses stderr; --json keeps stdout machine-readable.`,
171
180
  operations: `Usage:
172
181
  slopcamera operations list [--json]
173
182
  slopcamera operations show <kind>[@<version>] [--json]
@@ -472,10 +481,11 @@ export function commandHelp(topic: readonly string[]): string {
472
481
 
473
482
  export function completions(words: readonly string[]): readonly string[] {
474
483
  const topLevel = [
475
- "operations", "diagram", "direct", "studio", "image", "workflows", "code", "runs", "doctor", "ai", "media", "record", "recordings", "projects", "project", "inspect", "events", "edit", "analyze", "align", "faces", "fillers", "render", "assets",
484
+ "operations", "diagram", "direct", "studio", "image", "html", "workflows", "code", "runs", "doctor", "ai", "media", "record", "recordings", "projects", "project", "inspect", "events", "edit", "analyze", "align", "faces", "fillers", "render", "assets",
476
485
  ];
477
486
  if (words.length <= 1) return topLevel;
478
487
  const command = words[0];
488
+ if (command === "html") return ["catalog", "scaffold", "render"];
479
489
  if (command === "direct") return ["init", "anchor", "plan", "start", "inspect", "revise", "generate", "resume", "review", "assemble", "cleanup"];
480
490
  if (command === "studio") return words[2] === "assets" || words[1] === "assets" ? ["search", "describe", "plan", "import"] : ["init", "bundle", "plan", "probe", "run", "encode", "asset", "assemble", "inspect", "reconcile", "assets"];
481
491
  if (command === "operations") return ["list", "show"];
@@ -0,0 +1,368 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { constants } from "node:fs";
3
+ import { open, readdir } from "node:fs/promises";
4
+ import { join, relative, resolve } from "node:path";
5
+ import { z } from "zod";
6
+ import type { ApplicationContext } from "../application/context";
7
+ import {
8
+ bindExactCapabilities, ExactCapabilityApplicationRunner,
9
+ type ExactCapabilityBinding,
10
+ } from "../application/capability-binding";
11
+ import { bindHtmlOverlayBrowserRuntime } from "../application/html-overlay-browser-runtime";
12
+ import { assertHtmlOverlayExecutionProfileLibraries, createHtmlOverlayExecutionBundle, HtmlOverlayExecutionIntegritySchema } from "../application/html-overlay-integrity";
13
+ import {
14
+ loadRepositoryMedia, type MediaArtifactReference,
15
+ } from "../application/operations/media/shared";
16
+ import {
17
+ ProjectAssetV1Schema, ProjectEditPlanV1Schema, ProjectPlacementV1Schema, VideoProjectV1Schema,
18
+ type ProjectAssetV1,
19
+ } from "../contracts";
20
+ import { canonicalJson, canonicalJsonSha256 } from "../core/canonical-json";
21
+ import { createDefaultProjectEditPlan } from "../core/project-plan";
22
+ import { createNodeBundleFileSystem } from "../core/storage";
23
+ import {
24
+ HTML_OVERLAY_MAX_HTML_BYTES, HTML_OVERLAY_MAX_RESOURCE_BYTES,
25
+ HTML_OVERLAY_MAX_TOTAL_RESOURCE_BYTES, HtmlOverlayAuthoringInputSchema,
26
+ htmlOverlayFrameCount,
27
+ } from "../html-overlay/contracts";
28
+ import { HtmlSceneInputSchema } from "../html-overlay/scene";
29
+ import { HtmlOverlayActiveLibraryLocksSchema, getApprovedHtmlOverlayLibraryLock } from "../html-overlay/libraries";
30
+ import { assertHtmlOverlayGpuEvidenceProfile } from "../html-overlay/execution-profile";
31
+ import { CliError } from "./errors";
32
+ import { ingestProjectMedia, SELF_CONTAINED_MEDIA_INPUT_ARGUMENTS } from "./media-ingest";
33
+ import { withMutationLock } from "./mutation-lock";
34
+ import { ensurePhysicalPrivateDirectoryWithin } from "./paths";
35
+ import { resolveVerifiedProjectMedia } from "./project-media-integrity";
36
+ import { readSpatialJson } from "./spatial-scene-service";
37
+
38
+ const MAX_MEDIA_BYTES = 512 * 1024 * 1024;
39
+ const MAX_REQUEST_BYTES = 2 * 1024 * 1024;
40
+ const PROCESS_TIMEOUT_MS = 5 * 60_000;
41
+
42
+ export type HtmlSceneStage = "source" | "browser" | "frames" | "encode" | "verify" | "project" | "complete";
43
+ export interface HtmlSceneDependencies {
44
+ /** Internal controlled-test seams; invocation input cannot replace host capabilities. */
45
+ readonly bindBrowser?: typeof bindHtmlOverlayBrowserRuntime;
46
+ readonly bindTools?: typeof bindExactCapabilities;
47
+ readonly progress?: (stage: HtmlSceneStage) => void;
48
+ }
49
+
50
+ async function readPngHeader(path: string, expectedBytes: number) {
51
+ const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
52
+ try {
53
+ const before = await handle.stat({ bigint: true });
54
+ if (!before.isFile() || before.size !== BigInt(expectedBytes)) throw new CliError("conflict", "Scene frame changed before header validation.");
55
+ const header = Buffer.alloc(Math.min(33, expectedBytes));
56
+ const read = await handle.read(header, 0, header.length, 0);
57
+ const after = await handle.stat({ bigint: true });
58
+ if (read.bytesRead !== header.length || before.size !== after.size
59
+ || before.mtimeNs !== after.mtimeNs || before.ctimeNs !== after.ctimeNs) {
60
+ throw new CliError("conflict", "Scene frame changed during header validation.");
61
+ }
62
+ return header;
63
+ } finally { await handle.close(); }
64
+ }
65
+
66
+ async function readSource(application: ApplicationContext, input: unknown, signal: AbortSignal) {
67
+ const request = HtmlSceneInputSchema.parse(input);
68
+ assertHtmlOverlayExecutionProfileLibraries(request.libraries, request.executionProfile);
69
+ const loadedDocument = "path" in request.document
70
+ ? await loadRepositoryMedia(application, request.document, signal, HTML_OVERLAY_MAX_HTML_BYTES)
71
+ : undefined;
72
+ const html = loadedDocument === undefined
73
+ ? (request.document as { readonly html: string }).html
74
+ : new TextDecoder("utf-8", { fatal: true }).decode(loadedDocument.data);
75
+ let totalBytes = 0;
76
+ const resources = [];
77
+ for (const resource of request.resources) {
78
+ const loaded = await loadRepositoryMedia(application, { path: resource.path }, signal,
79
+ Math.min(HTML_OVERLAY_MAX_RESOURCE_BYTES, HTML_OVERLAY_MAX_TOTAL_RESOURCE_BYTES - totalBytes));
80
+ totalBytes += loaded.data.byteLength;
81
+ if (totalBytes > HTML_OVERLAY_MAX_TOTAL_RESOURCE_BYTES) throw new CliError("invalid-data", "Scene resources exceed their aggregate byte bound.");
82
+ resources.push({ declaration: resource, loaded });
83
+ }
84
+ const authoring = HtmlOverlayAuthoringInputSchema.parse({
85
+ kind: "slopcamera.html-overlay", schemaVersion: 1, html,
86
+ canvas: request.canvas, timing: request.timing, libraries: request.libraries,
87
+ parameters: request.parameters, seed: request.seed,
88
+ resources: resources.map(({ declaration: { path: _path, ...declaration }, loaded }) => ({
89
+ ...declaration, bytes: loaded.artifact.bytes, sha256: loaded.artifact.sha256,
90
+ })),
91
+ });
92
+ const frameCount = htmlOverlayFrameCount(request.timing);
93
+ return { request, authoring, resources, frameCount,
94
+ durationUs: Math.round(frameCount * 1_000_000 / request.timing.fps),
95
+ width: request.canvas.width, height: request.canvas.height };
96
+ }
97
+
98
+ export async function planHtmlScene(application: ApplicationContext, input: unknown, signal: AbortSignal) {
99
+ const source = await readSource(application, input, signal);
100
+ return {
101
+ kind: "slopcamera.html-scene-plan" as const, schemaVersion: 1 as const,
102
+ executed: false, name: source.request.name, frameCount: source.frameCount,
103
+ requestedDurationUs: source.request.timing.durationUs, durationUs: source.durationUs,
104
+ width: source.width, height: source.height, fps: source.request.timing.fps,
105
+ authoringSha256: canonicalJsonSha256(source.authoring),
106
+ audio: source.request.audio === undefined ? "none" : "explicit-local-import",
107
+ timingPolicy: "ceil-frames-trim-or-pad-audio-v1",
108
+ audioValidated: false,
109
+ };
110
+ }
111
+
112
+ const ProbeSchema = z.object({
113
+ streams: z.array(z.object({
114
+ codec_type: z.enum(["video", "audio"]), codec_name: z.string(),
115
+ width: z.number().int().optional(), height: z.number().int().optional(),
116
+ nb_read_frames: z.string().optional(), channels: z.number().int().optional(),
117
+ sample_rate: z.string().optional(), start_time: z.string().optional(), duration: z.string().optional(),
118
+ pix_fmt: z.string().optional(), color_range: z.string().optional(), color_space: z.string().optional(),
119
+ color_transfer: z.string().optional(), color_primaries: z.string().optional(),
120
+ })).min(1).max(2),
121
+ format: z.object({ duration: z.string() }),
122
+ });
123
+
124
+ export function verifyHtmlSceneProbe(input: unknown, expected: {
125
+ readonly width: number; readonly height: number; readonly frameCount: number;
126
+ readonly durationUs: number; readonly audio: boolean;
127
+ }) {
128
+ const probe = ProbeSchema.parse(input);
129
+ const videos = probe.streams.filter(stream => stream.codec_type === "video");
130
+ const audios = probe.streams.filter(stream => stream.codec_type === "audio");
131
+ const video = videos[0];
132
+ const timeUs = (text: string | undefined): number => text !== undefined && /^-?\d+(?:\.\d+)?$/u.test(text)
133
+ ? Math.round(Number(text) * 1_000_000) : Number.NaN;
134
+ const durationUs = timeUs(probe.format.duration);
135
+ if (videos.length !== 1 || video?.codec_name !== "h264"
136
+ || video.pix_fmt !== "yuv420p" || video.color_range !== "tv" || video.color_space !== "bt709"
137
+ || video.color_transfer !== "bt709" || video.color_primaries !== "bt709"
138
+ || video.width !== expected.width || video.height !== expected.height
139
+ || Number(video.nb_read_frames) !== expected.frameCount
140
+ || !Number.isSafeInteger(timeUs(video.start_time)) || Math.abs(timeUs(video.start_time)) > 1
141
+ || !Number.isSafeInteger(timeUs(video.duration)) || Math.abs(timeUs(video.duration) - expected.durationUs) > 2
142
+ || !Number.isSafeInteger(durationUs) || Math.abs(durationUs - expected.durationUs) > 50_000
143
+ || audios.length !== (expected.audio ? 1 : 0)
144
+ || audios.some(audio => audio.codec_name !== "aac" || !Number.isSafeInteger(timeUs(audio.start_time))
145
+ || Math.abs(timeUs(audio.start_time)) > 30_000
146
+ || !Number.isSafeInteger(timeUs(audio.duration)) || Math.abs(timeUs(audio.duration) - expected.durationUs) > 30_000
147
+ || audio.channels !== 2 || audio.sample_rate !== "48000")) {
148
+ throw new CliError("invalid-data", "Encoded scene failed dimensions, frame count, duration, color, or audio verification.");
149
+ }
150
+ return { frameCount: expected.frameCount, width: video.width, height: video.height, durationUs,
151
+ audio: audios.length === 0 ? null : { codec: "aac", channels: 2, sampleRateHz: 48000, startTimeUs: timeUs(audios[0]!.start_time), durationUs: timeUs(audios[0]!.duration) } };
152
+ }
153
+
154
+ function placement(asset: ProjectAssetV1, durationUs: number, suffix: string) {
155
+ const endUs = Math.min(asset.durationUs, durationUs);
156
+ return ProjectPlacementV1Schema.parse({
157
+ placementId: `placement_${suffix}`, assetId: asset.assetId, enabled: true,
158
+ assetRange: { startUs: 0, endUs },
159
+ sync: { anchors: [{ assetTimeUs: 0, projectTimeUs: 0 }, { assetTimeUs: endUs, projectTimeUs: endUs }], provenance: { kind: "identity" } },
160
+ video: asset.streams.filter(stream => stream.kind === "video").map(stream => ({ streamId: stream.streamId,
161
+ presentation: asset.role === "music" ? { enabled: false } : {
162
+ enabled: true, blendMode: "normal", crop: { kind: "none" }, fit: "fill", layer: 0,
163
+ layout: { kind: "normalized", x: 0, y: 0, width: 1, height: 1 }, opacity: 1,
164
+ } })),
165
+ audio: asset.streams.filter(stream => stream.kind === "audio").map(stream => ({ streamId: stream.streamId,
166
+ presentation: { enabled: true, gainDb: 0, pan: 0 } })),
167
+ });
168
+ }
169
+
170
+ /** Retain authoring and separate source tracks, then publish one ordinary editable video project. */
171
+ export async function renderHtmlScene(
172
+ application: ApplicationContext, input: unknown, signal: AbortSignal, dependencies: HtmlSceneDependencies = {},
173
+ ) {
174
+ const active = async () => {
175
+ if (signal.aborted) throw new CliError("cancelled", "HTML scene rendering was cancelled.");
176
+ if (application.hostResourceLease === undefined) throw new CliError("unavailable", "HTML scene rendering requires an admitted host-resource lease.");
177
+ await application.hostResourceLease.assertOwned();
178
+ };
179
+ await active();
180
+ dependencies.progress?.("source");
181
+ const source = await readSource(application, input, signal);
182
+ if (application.htmlOverlayRenderer === undefined) throw new CliError("unavailable", "HTML scene renderer is unavailable.");
183
+ const tools = await (dependencies.bindTools ?? bindExactCapabilities)(application, ["ffmpeg", "ffprobe", "html-browser"]);
184
+ const tool = (name: string): ExactCapabilityBinding => {
185
+ const found = tools.find(candidate => candidate.name === name);
186
+ if (found === undefined) throw new CliError("unavailable", `Missing scene capability: ${name}`);
187
+ return found;
188
+ };
189
+ dependencies.progress?.("browser");
190
+ const browserRuntime = await (dependencies.bindBrowser ?? bindHtmlOverlayBrowserRuntime)(tool("html-browser"), signal);
191
+ const suffix = `html_${randomUUID().replaceAll("-", "")}`;
192
+ const jobDirectory = await ensurePhysicalPrivateDirectoryWithin(application.paths.repositoryRoot,
193
+ `artifacts/slopcamera/generated/html-scenes/${suffix}`);
194
+ const fs = createNodeBundleFileSystem(jobDirectory);
195
+ const rootFs = createNodeBundleFileSystem(application.paths.repositoryRoot);
196
+ const runner = new ExactCapabilityApplicationRunner(application.runner, tools, application.paths.privateRoot);
197
+ const artifact = async (path: string, maximumBytes = MAX_MEDIA_BYTES): Promise<MediaArtifactReference> => ({
198
+ path: relative(application.paths.repositoryRoot, join(jobDirectory, path)),
199
+ ...await fs.inspectFile!(path, maximumBytes),
200
+ });
201
+ return await withMutationLock(jobDirectory, { command: "html render", label: source.request.name }, async lease => {
202
+ const fence = async () => { await active(); await lease.assertOwned(); };
203
+ const retainJson = async (path: string, value: unknown) => {
204
+ await fs.writeTextNoReplace!(path, `${canonicalJson(value)}\n`, fence);
205
+ return await artifact(path, 4 * 1024 * 1024);
206
+ };
207
+ const run = async (argv: readonly [string, ...string[]]) => {
208
+ await fence();
209
+ const result = await runner.run(argv, { abortSignal: signal, timeoutMs: PROCESS_TIMEOUT_MS,
210
+ maxOutputBytes: 1024 * 1024, inheritedFileDescriptors: application.hostResourceLease!.inheritedFileDescriptors });
211
+ await fence();
212
+ if (result.exitCode !== 0) throw new CliError("subprocess", `HTML scene media step failed: ${result.stderr.trim().slice(0, 1000) || result.exitCode}`);
213
+ return result;
214
+ };
215
+ await ensurePhysicalPrivateDirectoryWithin(jobDirectory, "source");
216
+ await fs.writeTextNoReplace!("source/scene.html", source.authoring.html, fence);
217
+ const retainedDocument = await artifact("source/scene.html", HTML_OVERLAY_MAX_HTML_BYTES);
218
+ const retainedResources = [];
219
+ for (const { declaration, loaded } of source.resources) {
220
+ const destination = `source/${declaration.name}-${loaded.artifact.sha256}`;
221
+ await rootFs.copyFileNoReplace!(loaded.artifact.path,
222
+ relative(application.paths.repositoryRoot, join(jobDirectory, destination)), loaded.artifact, fence);
223
+ const { path: _path, ...resource } = declaration;
224
+ retainedResources.push({ ...resource, ...loaded.artifact, path: relative(application.paths.repositoryRoot, join(jobDirectory, destination)), absolutePath: join(jobDirectory, destination) });
225
+ }
226
+ let audio: Awaited<ReturnType<typeof ingestProjectMedia>> | undefined;
227
+ if (source.request.audio !== undefined) {
228
+ audio = await ingestProjectMedia({ ffprobe: tool("ffprobe").command, now: application.clock.now(),
229
+ projectDirectory: jobDirectory, repositoryRoot: application.paths.repositoryRoot, role: "music",
230
+ sourcePath: resolve(application.paths.repositoryRoot, source.request.audio.path), maximumBytes: MAX_MEDIA_BYTES,
231
+ runner: { run: async argv => await run(argv) } });
232
+ if (audio.asset.streams.filter(stream => stream.kind === "audio").length !== 1) throw new CliError("invalid-data", "A scene soundtrack must contain exactly one playable audio stream.");
233
+ const soundtrack = audio.asset.streams.find(stream => stream.kind === "audio")!;
234
+ if (soundtrack.segments.length !== 1 || soundtrack.segments[0]!.assetRange.startUs !== 0
235
+ || soundtrack.segments[0]!.fileRange.startUs !== 0) {
236
+ throw new CliError("invalid-data", "A scene soundtrack must begin at the imported media timeline origin. Supply an audio file without a delayed stream start.");
237
+ }
238
+ }
239
+ const retainedInput = HtmlSceneInputSchema.parse({ ...source.request,
240
+ document: { path: relative(application.paths.repositoryRoot, join(jobDirectory, "source/scene.html")) },
241
+ resources: retainedResources.map(({ absolutePath: _absolutePath, bytes: _bytes, sha256: _sha256, ...resource }) => resource),
242
+ ...(audio === undefined ? {} : { audio: { path: relative(application.paths.repositoryRoot, audio.absolutePath) } }),
243
+ });
244
+ const retainedSource = await retainJson("source.json", retainedInput);
245
+ const authoringReceipt = await retainJson("authoring.json", source.authoring);
246
+ const intent = await retainJson("intent.json", { kind: "slopcamera.html-scene-intent", schemaVersion: 1,
247
+ source: retainedSource, document: retainedDocument, authoring: authoringReceipt, tools, frameCount: source.frameCount,
248
+ durationUs: source.durationUs, requestedDurationUs: source.request.timing.durationUs,
249
+ timingPolicy: "ceil-frames-trim-or-pad-audio-v1" });
250
+ dependencies.progress?.("frames");
251
+ const frameDirectory = await ensurePhysicalPrivateDirectoryWithin(jobDirectory, "render");
252
+ const frames = await application.htmlOverlayRenderer!.renderFrames({ authoring: source.authoring,
253
+ browserRuntime, outputDirectory: frameDirectory, resources: retainedResources,
254
+ ...(source.request.executionProfile === undefined ? {} : { executionProfile: source.request.executionProfile }) }, signal);
255
+ await fence();
256
+ if (frames.frameCount !== source.frameCount || frames.framePattern !== join(frameDirectory, "frames", "frame-%08d.png")) {
257
+ throw new CliError("invalid-data", "Scene renderer returned an unexpected frame count or output location.");
258
+ }
259
+ const expectedIntegrity = createHtmlOverlayExecutionBundle(source.authoring, browserRuntime, source.request.executionProfile).integrity;
260
+ if (canonicalJsonSha256(HtmlOverlayExecutionIntegritySchema.parse(frames.executionIntegrity)) !== canonicalJsonSha256(expectedIntegrity)) {
261
+ throw new CliError("conflict", "Scene renderer returned integrity evidence that differs from the bound browser execution.");
262
+ }
263
+ if (canonicalJsonSha256(HtmlOverlayActiveLibraryLocksSchema.parse(frames.libraryLocks))
264
+ !== canonicalJsonSha256(source.authoring.libraries.map(getApprovedHtmlOverlayLibraryLock))) {
265
+ throw new CliError("conflict", "Scene renderer returned library locks that differ from the authoring selection.");
266
+ }
267
+ assertHtmlOverlayGpuEvidenceProfile(source.request.executionProfile, frames.gpuEvidence);
268
+ const frameFs = createNodeBundleFileSystem(join(frameDirectory, "frames"));
269
+ const verifyFrames = async () => {
270
+ const names = await readdir(join(frameDirectory, "frames"));
271
+ if (names.length !== source.frameCount) throw new CliError("invalid-data", "Scene frame directory contains an unexpected entry.");
272
+ const hash = createHash("sha256");
273
+ for (let index = 0; index < source.frameCount; index++) {
274
+ await fence();
275
+ const name = `frame-${String(index).padStart(8, "0")}.png`;
276
+ const found = await frameFs.inspectFile!(name, source.width * source.height * 5 + 1024 * 1024);
277
+ const first = await readPngHeader(join(frameDirectory, "frames", name), found.bytes);
278
+ if (first.length < 33 || first.subarray(0, 8).toString("hex") !== "89504e470d0a1a0a"
279
+ || first.toString("ascii", 12, 16) !== "IHDR" || first.readUInt32BE(16) !== source.width || first.readUInt32BE(20) !== source.height) {
280
+ throw new CliError("invalid-data", "Rendered scene frame differs from the declared PNG dimensions.");
281
+ }
282
+ hash.update(`${index}:${found.bytes}:${found.sha256}\n`);
283
+ }
284
+ return hash.digest("hex");
285
+ };
286
+ const frameSha256 = await verifyFrames();
287
+ const frameReceipt = await retainJson("frames.json", { ...frames, frameSha256 });
288
+ const videoPath = join(jobDirectory, "scene.mp4"), outputPath = join(jobDirectory, "video.mp4");
289
+ const frameRate = String(source.request.timing.fps), duration = (source.durationUs / 1_000_000).toFixed(6);
290
+ const background = source.request.background.replace("#", "0x");
291
+ dependencies.progress?.("encode");
292
+ await run([tool("ffmpeg").command, "-nostdin", "-v", "error", "-n", "-threads", "2",
293
+ "-protocol_whitelist", "file", "-f", "image2", "-framerate", frameRate, "-start_number", "0", "-i", frames.framePattern,
294
+ "-filter_complex_threads", "1", "-filter_complex",
295
+ `color=c=${background}:s=${source.width}x${source.height}:r=${frameRate},format=rgba[bg];[bg][0:v]overlay=shortest=1:format=auto,format=rgb24[v]`,
296
+ "-map", "[v]", "-an", "-frames:v", String(source.frameCount), "-c:v", "libx264rgb", "-crf", "0", "-preset", "fast",
297
+ "-threads", "2", "-color_trc", "iec61966-2-1", "-color_primaries", "bt709", "-colorspace", "rgb", "-color_range", "pc",
298
+ "-fs", String(MAX_MEDIA_BYTES), videoPath]);
299
+ const videoArtifact = await artifact("scene.mp4");
300
+ if (await verifyFrames() !== frameSha256) throw new CliError("conflict", "Scene frame bytes changed during encoding.");
301
+ const audioStream = audio?.asset.streams.find(stream => stream.kind === "audio");
302
+ if (audio !== undefined) for (const stream of audio.asset.streams) for (const segment of stream.segments) {
303
+ await resolveVerifiedProjectMedia({ repositoryRoot: application.paths.repositoryRoot, path: segment.path, expected: segment, label: "Scene soundtrack" });
304
+ }
305
+ await run([tool("ffmpeg").command, "-nostdin", "-v", "error", "-n", "-threads", "2",
306
+ ...SELF_CONTAINED_MEDIA_INPUT_ARGUMENTS, "-i", videoPath,
307
+ ...(audio === undefined ? [] : [...SELF_CONTAINED_MEDIA_INPUT_ARGUMENTS, "-i", audio.absolutePath]),
308
+ "-map", "0:v:0", ...(audioStream === undefined ? ["-an"] : ["-map", `1:${audioStream.segments[0]!.streamIndex}`, "-af", "asetpts=PTS-STARTPTS,apad", "-c:a", "aac", "-b:a", "320k", "-ar", "48000", "-ac", "2"]),
309
+ "-vf", "scale=in_range=pc:out_range=pc:out_color_matrix=bt709,format=yuv444p,colorspace=ispace=bt709:itrc=iec61966-2-1:iprimaries=bt709:irange=pc:space=bt709:trc=bt709:primaries=bt709:range=tv:format=yuv420p:fast=0",
310
+ "-c:v", "libx264", "-crf", "18", "-preset", "medium",
311
+ "-threads", "2", "-filter_threads", "1", "-t", duration, "-r", frameRate,
312
+ "-color_trc", "bt709", "-color_primaries", "bt709", "-colorspace", "bt709", "-color_range", "tv", "-movflags", "+faststart",
313
+ "-fs", String(MAX_MEDIA_BYTES), outputPath]);
314
+ dependencies.progress?.("verify");
315
+ const inspected = await run([tool("ffprobe").command, "-v", "error", ...SELF_CONTAINED_MEDIA_INPUT_ARGUMENTS,
316
+ "-count_frames", "-show_entries", "stream=codec_type,codec_name,width,height,nb_read_frames,channels,sample_rate,start_time,duration,pix_fmt,color_range,color_space,color_transfer,color_primaries:format=duration", "-of", "json", outputPath]);
317
+ const verification = verifyHtmlSceneProbe(JSON.parse(inspected.stdout) as unknown, { ...source, audio: audio !== undefined });
318
+ const output = await artifact("video.mp4");
319
+ const importedVideo = await ingestProjectMedia({ ffprobe: tool("ffprobe").command, now: application.clock.now(),
320
+ projectDirectory: jobDirectory, repositoryRoot: application.paths.repositoryRoot, role: "screen", sourcePath: videoPath,
321
+ maximumBytes: MAX_MEDIA_BYTES, runner: { run: async argv => await run(argv) } });
322
+ if (importedVideo.asset.source.kind !== "imported" || importedVideo.asset.source.sourceSha256 !== videoArtifact.sha256) {
323
+ throw new CliError("conflict", "Scene video changed before its editable project import.");
324
+ }
325
+ const videoAsset = ProjectAssetV1Schema.parse({ ...importedVideo.asset,
326
+ source: { kind: "generated", generator: "slopcamera.html-scene", generatorVersion: "1", sourceSha256: intent.sha256 } });
327
+ dependencies.progress?.("project");
328
+ const projectId = `project_${suffix}`, timestamp = application.clock.now().toISOString();
329
+ const videoPlacement = placement(videoAsset, source.durationUs, `${suffix}_video`);
330
+ const project = VideoProjectV1Schema.parse({
331
+ kind: "slopcamera.video-project", schemaVersion: 1, projectId, name: source.request.name,
332
+ createdAt: timestamp, updatedAt: timestamp, currentEditPlanPath: "edits/current.json", analyses: [],
333
+ assets: [videoAsset, ...(audio === undefined ? [] : [audio.asset])],
334
+ placements: [videoPlacement, ...(audio === undefined ? [] : [placement(audio.asset, source.durationUs, `${suffix}_audio`)])],
335
+ referencePlacementId: videoPlacement.placementId, timeline: { timebase: "microseconds", durationUs: source.durationUs },
336
+ });
337
+ const plan = createDefaultProjectEditPlan(project, ProjectEditPlanV1Schema.shape.planId.parse(`plan_${suffix}`), timestamp);
338
+ for (const retained of [retainedSource, retainedDocument, authoringReceipt, intent, frameReceipt, videoArtifact, output, ...retainedResources]) {
339
+ await fence();
340
+ await resolveVerifiedProjectMedia({ repositoryRoot: application.paths.repositoryRoot, path: retained.path, expected: retained, label: "Retained scene source" });
341
+ }
342
+ if (audio !== undefined) for (const stream of audio.asset.streams) for (const segment of stream.segments) {
343
+ await resolveVerifiedProjectMedia({ repositoryRoot: application.paths.repositoryRoot, path: segment.path, expected: segment, label: "Scene soundtrack after encoding" });
344
+ }
345
+ const projectDirectory = await ensurePhysicalPrivateDirectoryWithin(application.paths.projectRoot, projectId);
346
+ const projectFs = createNodeBundleFileSystem(projectDirectory);
347
+ await ensurePhysicalPrivateDirectoryWithin(projectDirectory, "edits");
348
+ const receipt = await retainJson("receipt.json", { kind: "slopcamera.html-scene-receipt", schemaVersion: 1,
349
+ intent, source: retainedSource, document: retainedDocument, authoring: authoringReceipt, frameReceipt, video: videoArtifact, output,
350
+ projectId, projectSha256: canonicalJsonSha256(project), planSha256: canonicalJsonSha256(plan), verification,
351
+ audio: audio?.asset ?? null, timingPolicy: "ceil-frames-trim-or-pad-audio-v1" });
352
+ await projectFs.writeTextNoReplace!("html-scene.json", canonicalJson({ source: retainedSource, receipt }), fence);
353
+ await projectFs.writeTextNoReplace!("edits/current.json", canonicalJson(plan), fence);
354
+ await projectFs.writeTextNoReplace!("project.json", canonicalJson(project), fence);
355
+ await fence();
356
+ dependencies.progress?.("complete");
357
+ return { kind: "slopcamera.html-scene-export" as const, schemaVersion: 1 as const, output, receipt,
358
+ source: retainedSource, projectId, projectPath: relative(application.paths.repositoryRoot, join(projectDirectory, "project.json")), verification };
359
+ });
360
+ }
361
+
362
+ export async function executeHtmlSceneCommand(application: ApplicationContext, command: {
363
+ readonly input: string; readonly dryRun: boolean;
364
+ }, signal: AbortSignal, dependencies: HtmlSceneDependencies = {}) {
365
+ const input = await readSpatialJson(resolve(application.paths.repositoryRoot, command.input), MAX_REQUEST_BYTES);
366
+ return command.dryRun ? await planHtmlScene(application, input, signal)
367
+ : await renderHtmlScene(application, input, signal, dependencies);
368
+ }
@@ -13,6 +13,7 @@ import {
13
13
  } from "./portable-surface";
14
14
  import { RecordingDaemonClient, runRecordingDaemon } from "./recording-daemon";
15
15
  import { renamedEnvironmentValue } from "./renamed-environment";
16
+ import { rootHelpIntro } from "./root-help-intro";
16
17
 
17
18
  function valueAfter(argv: readonly string[], name: string): string {
18
19
  const index = argv.indexOf(name);
@@ -74,6 +75,10 @@ export async function main(
74
75
  if (portableExitCode !== undefined) return portableExitCode;
75
76
  const earlyCommand = parseCliArgs(unifiedArgv);
76
77
  if (earlyCommand.kind === "help" || earlyCommand.kind === "version" || earlyCommand.kind === "complete") {
78
+ const intro = rootHelpIntro(unifiedArgv, {
79
+ isTTY: process.stdout.isTTY === true, term: process.env.TERM, columns: process.stdout.columns,
80
+ });
81
+ if (intro !== "") processIo.stdout(intro);
77
82
  return await runCli(unifiedArgv, { io: processIo });
78
83
  }
79
84
  const paths = await resolveRepositoryPaths(processIo.cwd(), processIo.env);
@@ -84,7 +89,7 @@ export async function main(
84
89
  daemonCommand: daemonCommand(),
85
90
  helperExecutable,
86
91
  });
87
- if (earlyCommand.kind === "spatial-world" || earlyCommand.kind === "directing" || earlyCommand.kind === "studio"
92
+ if (earlyCommand.kind === "html-render" || earlyCommand.kind === "spatial-world" || earlyCommand.kind === "directing" || earlyCommand.kind === "studio"
88
93
  || earlyCommand.kind === "spatial-scene" && earlyCommand.action === "camera-track") {
89
94
  const controller = new AbortController();
90
95
  const cancel = () => controller.abort(new CliError("cancelled", "Command interrupted."));
@@ -278,6 +278,8 @@ function importedStreamRole(
278
278
  }
279
279
 
280
280
  export interface IngestProjectMediaOptions {
281
+ /** A caller may narrow the import bound; it cannot expand the host limit. */
282
+ readonly maximumBytes?: number;
281
283
  /** @internal Injectable durability boundary for focused fault and ordering tests. */
282
284
  readonly durability?: MediaIngestDurability;
283
285
  readonly ffprobe: string;
@@ -67,7 +67,7 @@ function removeStagedImport(
67
67
  }));
68
68
  }
69
69
 
70
- function stageImport(projectDirectory: string, sourcePath: string): Program<StagedMediaImport> {
70
+ function stageImport(projectDirectory: string, sourcePath: string, maximumBytes: number): Program<StagedMediaImport> {
71
71
  return Effect.gen(function*() {
72
72
  const platform = yield* MediaIngestPlatform;
73
73
  const source = yield* platform.sourcePath(sourcePath);
@@ -78,8 +78,8 @@ function stageImport(projectDirectory: string, sourcePath: string): Program<Stag
78
78
  if (before.isSymbolicLink() || !before.isFile()) {
79
79
  throw new CliError("unsafe-path", `Media source must be a physical regular file: ${sourcePath}`);
80
80
  }
81
- if (before.size <= 0 || before.size > MAX_MEDIA_BYTES) {
82
- throw new CliError("invalid-data", `Media source must contain 1 through ${MAX_MEDIA_BYTES} bytes.`);
81
+ if (before.size <= 0 || before.size > maximumBytes) {
82
+ throw new CliError("invalid-data", `Media source must contain 1 through ${maximumBytes} bytes.`);
83
83
  }
84
84
  });
85
85
  const imports = yield* privateImportsDirectory(projectDirectory);
@@ -178,6 +178,11 @@ function commitImport(imported: StagedMediaImport): Program<boolean> {
178
178
  export function ingestProjectMediaProgram(options: IngestProjectMediaOptions): Program<IngestedProjectMedia> {
179
179
  return Effect.gen(function*() {
180
180
  const platform = yield* MediaIngestPlatform;
181
+ const maximumBytes = yield* operationValidation("media", () => {
182
+ const bound = options.maximumBytes ?? MAX_MEDIA_BYTES;
183
+ if (!Number.isSafeInteger(bound) || bound < 1 || bound > MAX_MEDIA_BYTES) throw new CliError("invalid-data", "Invalid media import byte bound.");
184
+ return bound;
185
+ });
181
186
  const role = yield* operationValidation("media", () => ProjectAssetRoleSchema.parse(options.role));
182
187
  const [repositoryRoot, projectDirectory] = yield* Effect.all([
183
188
  platform.realpath(options.repositoryRoot), platform.realpath(options.projectDirectory),
@@ -186,7 +191,7 @@ export function ingestProjectMediaProgram(options: IngestProjectMediaOptions): P
186
191
  })));
187
192
  yield* platform.assertWithin(repositoryRoot, projectDirectory, "Project media import directory is outside the repository.");
188
193
  return yield* operationResource(
189
- stageImport(projectDirectory, options.sourcePath),
194
+ stageImport(projectDirectory, options.sourcePath, maximumBytes),
190
195
  imported => Effect.gen(function*() {
191
196
  const probe = yield* probeProjectMediaProgram(options.ffprobe, options.runner, imported.temporaryPath);
192
197
  const location = yield* platform.assetLocation(repositoryRoot, imported.absolutePath, options.sourcePath);
@@ -214,6 +214,7 @@ export async function runPortableSurface(
214
214
  ): Promise<number | undefined> {
215
215
  const argv = canonicalizeUnifiedCliArgs(argvInput);
216
216
  if (argv[0] === "html") {
217
+ if (argv[1] === "render" || argv.includes("--help") || argv.includes("-h")) return undefined;
217
218
  if (argv[1] === "catalog") return runHtmlCatalog(argv, dependencies);
218
219
  return await runHtmlScaffold(argv, dependencies);
219
220
  }
@@ -1354,7 +1354,7 @@ export class CaptureHelperRecordingController implements RecordingController {
1354
1354
  helperVersion: this.#helperVersion,
1355
1355
  now: this.#now,
1356
1356
  recordingId,
1357
- toolVersion: "3.2.5",
1357
+ toolVersion: "3.2.7",
1358
1358
  verifier: this.#verifier,
1359
1359
  });
1360
1360
  await this.#bundleWriter.initialize();
@@ -0,0 +1,14 @@
1
+ const camera = " __\n _|__|_\n| (o) |\n|______|\n\n";
2
+
3
+ /** Decoration is confined to readable, interactive root help. */
4
+ export function rootHelpIntro(
5
+ argv: readonly string[],
6
+ terminal: Readonly<{ isTTY: boolean; term: string | undefined; columns: number | undefined }>,
7
+ ): string {
8
+ return terminal.isTTY && terminal.term !== "dumb"
9
+ && terminal.columns !== undefined && Number.isFinite(terminal.columns) && terminal.columns >= 48
10
+ && (argv.length === 0
11
+ || argv.length === 1 && (argv[0] === "help" || argv[0] === "--help" || argv[0] === "-h"))
12
+ ? camera
13
+ : "";
14
+ }