@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.
- package/README.md +5 -2
- package/apps/desktop/application/operation.ts +1 -1
- package/apps/desktop/cli/args.ts +8 -0
- package/apps/desktop/cli/command-host-resources.ts +3 -0
- package/apps/desktop/cli/commands.ts +10 -1
- package/apps/desktop/cli/help.ts +13 -3
- package/apps/desktop/cli/html-scene.ts +368 -0
- package/apps/desktop/cli/main.ts +6 -1
- package/apps/desktop/cli/media-ingest-model.ts +2 -0
- package/apps/desktop/cli/media-ingest-program.ts +9 -4
- package/apps/desktop/cli/portable-surface.ts +1 -0
- package/apps/desktop/cli/recording-controller.ts +1 -1
- package/apps/desktop/cli/root-help-intro.ts +14 -0
- package/apps/desktop/dist/cli/main.js +241 -222
- package/apps/desktop/html-overlay/contracts.ts +3 -0
- package/apps/desktop/html-overlay/index.ts +19 -0
- package/apps/desktop/html-overlay/music-clock.ts +126 -0
- package/apps/desktop/html-overlay/rigged-glb.ts +429 -0
- package/apps/desktop/html-overlay/runtime.ts +2 -0
- package/apps/desktop/html-overlay/scaffolds.ts +2 -1
- package/apps/desktop/html-overlay/scene.ts +47 -0
- package/dist/cli.js +1 -1
- package/dist/{index-7tzd9155.js → index-rvghzt7b.js} +1 -1
- package/dist/index.js +1 -1
- package/docs/README.md +1 -0
- package/examples/html/music-video.html +514 -0
- package/examples/html/music-video.json +16 -0
- package/examples/studio/vgpu/README.md +1 -1
- package/package.json +3 -1
- package/skills/slopcamera/SKILL.md +2 -1
- package/skills/slopcamera/references/install.md +1 -1
- package/skills/slopcamera/references/music-video.md +149 -0
- package/skills/slopcamera/references/video-projects.md +6 -2
- package/skills/slopcamera/references/workflows-sdk.md +1 -1
- 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.
|
|
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.
|
|
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.
|
|
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",
|
package/apps/desktop/cli/args.ts
CHANGED
|
@@ -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.
|
|
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.
|
package/apps/desktop/cli/help.ts
CHANGED
|
@@ -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
|
|
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
|
+
}
|
package/apps/desktop/cli/main.ts
CHANGED
|
@@ -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 >
|
|
82
|
-
throw new CliError("invalid-data", `Media source must contain 1 through ${
|
|
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.
|
|
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
|
+
}
|