@hraness/slopcamera 3.2.6 → 3.2.8

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 (78) hide show
  1. package/NOTICE.md +1 -7
  2. package/PRIVACY.md +16 -7
  3. package/README.md +41 -13
  4. package/apps/desktop/README.md +14 -130
  5. package/apps/desktop/application/context.ts +0 -9
  6. package/apps/desktop/application/default-registry.ts +0 -10
  7. package/apps/desktop/application/operation.ts +1 -5
  8. package/apps/desktop/application/operations/index.ts +0 -1
  9. package/apps/desktop/cli/args.ts +22 -86
  10. package/apps/desktop/cli/command-host-resources.ts +5 -1
  11. package/apps/desktop/cli/commands.ts +37 -67
  12. package/apps/desktop/cli/help.ts +24 -20
  13. package/apps/desktop/cli/html-scene.ts +479 -0
  14. package/apps/desktop/cli/io.ts +2 -1
  15. package/apps/desktop/cli/main.ts +28 -47
  16. package/apps/desktop/cli/media-ingest-model.ts +2 -0
  17. package/apps/desktop/cli/media-ingest-program.ts +9 -4
  18. package/apps/desktop/cli/menubar.ts +244 -0
  19. package/apps/desktop/cli/music-analysis-service.ts +61 -0
  20. package/apps/desktop/cli/portable-surface.ts +13 -4
  21. package/apps/desktop/cli/root-help-intro.ts +14 -0
  22. package/apps/desktop/code/application-node-planner.ts +0 -47
  23. package/apps/desktop/code/host-source-layout.ts +46 -0
  24. package/apps/desktop/code/public.ts +0 -1
  25. package/apps/desktop/code/runtime-identity.ts +50 -8
  26. package/apps/desktop/code/semantic-builder.ts +0 -43
  27. package/apps/desktop/code/source-bundle.ts +23 -6
  28. package/apps/desktop/code/source-typecheck.ts +131 -43
  29. package/apps/desktop/code/worker-client.ts +14 -2
  30. package/apps/desktop/core/music-analysis.ts +124 -1
  31. package/apps/desktop/dist/cli/main.js +261 -248
  32. package/apps/desktop/html-overlay/audio-reactivity.ts +127 -0
  33. package/apps/desktop/html-overlay/contracts.ts +3 -0
  34. package/apps/desktop/html-overlay/index.ts +28 -0
  35. package/apps/desktop/html-overlay/music-clock.ts +126 -0
  36. package/apps/desktop/html-overlay/rigged-glb.ts +429 -0
  37. package/apps/desktop/html-overlay/runtime.ts +4 -0
  38. package/apps/desktop/html-overlay/scaffolds.ts +2 -1
  39. package/apps/desktop/html-overlay/scene.ts +76 -0
  40. package/dist/cli.js +83 -81
  41. package/dist/{index-jchqst8w.js → index-7131pg1c.js} +43 -236
  42. package/dist/{index-mcy8z0br.js → index-fava6pge.js} +1 -1
  43. package/dist/{index-z7239b4h.js → index-h1k0fnjq.js} +1 -1
  44. package/dist/{index-p63wavx0.js → index-zfnddgay.js} +1 -1
  45. package/dist/index.js +4 -18
  46. package/dist/operations.js +2 -2
  47. package/dist/vectorize/worker.js +1 -1
  48. package/dist/workflow.js +3 -3
  49. package/docs/README.md +2 -1
  50. package/docs/studio.md +1 -1
  51. package/examples/html/music-video.html +514 -0
  52. package/examples/html/music-video.json +16 -0
  53. package/package.json +8 -21
  54. package/skills/slopcamera/SKILL.md +10 -1
  55. package/skills/slopcamera/references/diagrams.md +5 -14
  56. package/skills/slopcamera/references/install.md +2 -2
  57. package/skills/slopcamera/references/music-video.md +169 -0
  58. package/skills/slopcamera/references/reference-led-3d.md +1 -1
  59. package/skills/slopcamera/references/support.md +25 -0
  60. package/skills/slopcamera/references/video-projects.md +6 -2
  61. package/skills/slopcamera/references/web-media-excerpts.md +120 -0
  62. package/skills/slopcamera/references/workflows-sdk.md +1 -1
  63. package/src/cli.ts +28 -98
  64. package/src/index.ts +0 -23
  65. package/src/support-completion.ts +16 -0
  66. package/src/support.ts +42 -0
  67. package/src/version.ts +1 -1
  68. package/apps/desktop/application/operations/recording/index.ts +0 -5
  69. package/apps/desktop/application/operations/recording/pause.ts +0 -34
  70. package/apps/desktop/application/operations/recording/resume.ts +0 -34
  71. package/apps/desktop/application/operations/recording/shared.ts +0 -199
  72. package/apps/desktop/application/operations/recording/start.ts +0 -169
  73. package/apps/desktop/application/operations/recording/stop.ts +0 -34
  74. package/apps/desktop/capture/protocol.ts +0 -689
  75. package/apps/desktop/cli/capture-bundle.ts +0 -1117
  76. package/apps/desktop/cli/recording-controller.ts +0 -1567
  77. package/apps/desktop/cli/recording-daemon.ts +0 -931
  78. package/src/desktop.ts +0 -253
@@ -0,0 +1,479 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { constants } from "node:fs";
3
+ import { lstat, 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 { HTML_AUDIO_REACTIVITY_MAX_BYTES, HTML_AUDIO_REACTIVITY_RESOURCE_NAME, HTML_AUDIO_REACTIVITY_RESOURCE_URL_PATH, HtmlOverlayAudioReactivitySchema } from "../html-overlay/audio-reactivity";
32
+ import { CliError } from "./errors";
33
+ import { ingestProjectMedia, SELF_CONTAINED_MEDIA_INPUT_ARGUMENTS } from "./media-ingest";
34
+ import { analyzeRetainedAudioReactivity } from "./music-analysis-service";
35
+ import { withMutationLock } from "./mutation-lock";
36
+ import { ensurePhysicalPrivateDirectoryWithin } from "./paths";
37
+ import { resolveVerifiedProjectMedia } from "./project-media-integrity";
38
+ import { readSpatialJson } from "./spatial-scene-service";
39
+
40
+ const MAX_MEDIA_BYTES = 512 * 1024 * 1024;
41
+ const MAX_REQUEST_BYTES = 2 * 1024 * 1024;
42
+ const PROCESS_TIMEOUT_MS = 5 * 60_000;
43
+ const SCENE_MEDIA_LIMIT_MESSAGE = "The lossless HTML scene intermediate may have reached the 512 MiB media bound; reduce canvas, frame rate, or duration and retry.";
44
+ const SCENE_MEDIA_TOO_LARGE_MESSAGE = "Lossless HTML scene intermediate exceeds the 512 MiB media bound; reduce canvas, frame rate, or duration and retry.";
45
+
46
+ export type HtmlSceneStage = "source" | "browser" | "audio-analysis" | "frames" | "encode" | "verify" | "project" | "complete";
47
+ export interface HtmlSceneDependencies {
48
+ /** Internal controlled-test seams; invocation input cannot replace host capabilities. */
49
+ readonly bindBrowser?: typeof bindHtmlOverlayBrowserRuntime;
50
+ readonly bindTools?: typeof bindExactCapabilities;
51
+ readonly progress?: (stage: HtmlSceneStage) => void;
52
+ }
53
+
54
+ async function readPngHeader(path: string, expectedBytes: number) {
55
+ const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
56
+ try {
57
+ const before = await handle.stat({ bigint: true });
58
+ if (!before.isFile() || before.size !== BigInt(expectedBytes)) throw new CliError("conflict", "Scene frame changed before header validation.");
59
+ const header = Buffer.alloc(Math.min(33, expectedBytes));
60
+ const read = await handle.read(header, 0, header.length, 0);
61
+ const after = await handle.stat({ bigint: true });
62
+ if (read.bytesRead !== header.length || before.size !== after.size
63
+ || before.mtimeNs !== after.mtimeNs || before.ctimeNs !== after.ctimeNs) {
64
+ throw new CliError("conflict", "Scene frame changed during header validation.");
65
+ }
66
+ return header;
67
+ } finally { await handle.close(); }
68
+ }
69
+
70
+ async function readSource(application: ApplicationContext, input: unknown, signal: AbortSignal) {
71
+ const request = HtmlSceneInputSchema.parse(input);
72
+ assertHtmlOverlayExecutionProfileLibraries(request.libraries, request.executionProfile);
73
+ const loadedDocument = "path" in request.document
74
+ ? await loadRepositoryMedia(application, request.document, signal, HTML_OVERLAY_MAX_HTML_BYTES)
75
+ : undefined;
76
+ const html = loadedDocument === undefined
77
+ ? (request.document as { readonly html: string }).html
78
+ : new TextDecoder("utf-8", { fatal: true }).decode(loadedDocument.data);
79
+ let totalBytes = 0;
80
+ const resources = [];
81
+ for (const resource of request.resources) {
82
+ const loaded = await loadRepositoryMedia(application, { path: resource.path }, signal,
83
+ Math.min(HTML_OVERLAY_MAX_RESOURCE_BYTES, HTML_OVERLAY_MAX_TOTAL_RESOURCE_BYTES - totalBytes));
84
+ totalBytes += loaded.data.byteLength;
85
+ if (totalBytes > HTML_OVERLAY_MAX_TOTAL_RESOURCE_BYTES) throw new CliError("invalid-data", "Scene resources exceed their aggregate byte bound.");
86
+ resources.push({ declaration: resource, loaded });
87
+ }
88
+ const authoring = HtmlOverlayAuthoringInputSchema.parse({
89
+ kind: "slopcamera.html-overlay", schemaVersion: 1, html,
90
+ canvas: request.canvas, timing: request.timing, libraries: request.libraries,
91
+ parameters: request.parameters, seed: request.seed,
92
+ resources: resources.map(({ declaration: { path: _path, ...declaration }, loaded }) => ({
93
+ ...declaration, bytes: loaded.artifact.bytes, sha256: loaded.artifact.sha256,
94
+ })),
95
+ });
96
+ const frameCount = htmlOverlayFrameCount(request.timing);
97
+ return { request, authoring, resources, frameCount,
98
+ durationUs: Math.round(frameCount * 1_000_000 / request.timing.fps),
99
+ width: request.canvas.width, height: request.canvas.height };
100
+ }
101
+
102
+ export async function planHtmlScene(application: ApplicationContext, input: unknown, signal: AbortSignal) {
103
+ const source = await readSource(application, input, signal);
104
+ return {
105
+ kind: "slopcamera.html-scene-plan" as const, schemaVersion: 1 as const,
106
+ executed: false, name: source.request.name, frameCount: source.frameCount,
107
+ requestedDurationUs: source.request.timing.durationUs, durationUs: source.durationUs,
108
+ width: source.width, height: source.height, fps: source.request.timing.fps,
109
+ authoringSha256: canonicalJsonSha256(source.authoring),
110
+ audio: source.request.audio === undefined ? "none" : "explicit-local-import",
111
+ timingPolicy: "ceil-frames-trim-or-pad-audio-v1",
112
+ audioValidated: false,
113
+ audioReactivity: source.request.audio?.reactivity === undefined ? null : {
114
+ profile: "bands-v1", validated: false, resourceName: HTML_AUDIO_REACTIVITY_RESOURCE_NAME,
115
+ },
116
+ };
117
+ }
118
+
119
+ const ProbeSchema = z.object({
120
+ streams: z.array(z.object({
121
+ codec_type: z.enum(["video", "audio"]), codec_name: z.string(),
122
+ width: z.number().int().optional(), height: z.number().int().optional(),
123
+ nb_read_frames: z.string().optional(), channels: z.number().int().optional(),
124
+ sample_rate: z.string().optional(), start_time: z.string().optional(), duration: z.string().optional(),
125
+ pix_fmt: z.string().optional(), color_range: z.string().optional(), color_space: z.string().optional(),
126
+ color_transfer: z.string().optional(), color_primaries: z.string().optional(),
127
+ })).min(1).max(2),
128
+ format: z.object({ duration: z.string() }),
129
+ });
130
+
131
+ function probeTimeUs(text: string | undefined): number {
132
+ return text !== undefined && /^-?\d+(?:\.\d+)?$/u.test(text)
133
+ ? Math.round(Number(text) * 1_000_000) : Number.NaN;
134
+ }
135
+
136
+ /**
137
+ * Verify the lossless RGB intermediate before it is retained or imported.
138
+ * FFmpeg's `-fs` option can stop with a successful exit after writing a
139
+ * playable but truncated MP4. Checking the declared frame count here turns
140
+ * that silent truncation into an actionable bounded-render error.
141
+ */
142
+ export function verifyHtmlSceneIntermediateProbe(input: unknown, expected: {
143
+ readonly width: number; readonly height: number; readonly frameCount: number;
144
+ readonly durationUs: number;
145
+ }) {
146
+ const parsed = ProbeSchema.safeParse(input);
147
+ const videos = parsed.success ? parsed.data.streams.filter(stream => stream.codec_type === "video") : [];
148
+ const audios = parsed.success ? parsed.data.streams.filter(stream => stream.codec_type === "audio") : [];
149
+ const video = videos[0];
150
+ const durationUs = parsed.success ? probeTimeUs(parsed.data.format.duration) : Number.NaN;
151
+ const valid = parsed.success && videos.length === 1 && audios.length === 0
152
+ && video?.codec_name === "h264"
153
+ && video.width === expected.width && video.height === expected.height
154
+ && Number(video.nb_read_frames) === expected.frameCount
155
+ && Number.isSafeInteger(probeTimeUs(video.start_time))
156
+ && Math.abs(probeTimeUs(video.start_time)) <= 1
157
+ && Number.isSafeInteger(probeTimeUs(video.duration))
158
+ && Math.abs(probeTimeUs(video.duration) - expected.durationUs) <= 50_000
159
+ && Number.isSafeInteger(durationUs)
160
+ && Math.abs(durationUs - expected.durationUs) <= 50_000;
161
+ if (!valid) {
162
+ const reported = video?.nb_read_frames === undefined ? "an unknown number of"
163
+ : `${video.nb_read_frames} of ${expected.frameCount}`;
164
+ throw new CliError("invalid-data", `Lossless HTML scene intermediate failed frame verification (reported ${reported} frames). ${SCENE_MEDIA_LIMIT_MESSAGE}`);
165
+ }
166
+ return { frameCount: expected.frameCount, width: video.width, height: video.height, durationUs };
167
+ }
168
+
169
+ export function verifyHtmlSceneProbe(input: unknown, expected: {
170
+ readonly width: number; readonly height: number; readonly frameCount: number;
171
+ readonly durationUs: number; readonly audio: boolean;
172
+ }) {
173
+ const probe = ProbeSchema.parse(input);
174
+ const videos = probe.streams.filter(stream => stream.codec_type === "video");
175
+ const audios = probe.streams.filter(stream => stream.codec_type === "audio");
176
+ const video = videos[0];
177
+ const durationUs = probeTimeUs(probe.format.duration);
178
+ if (videos.length !== 1 || video?.codec_name !== "h264"
179
+ || video.pix_fmt !== "yuv420p" || video.color_range !== "tv" || video.color_space !== "bt709"
180
+ || video.color_transfer !== "bt709" || video.color_primaries !== "bt709"
181
+ || video.width !== expected.width || video.height !== expected.height
182
+ || Number(video.nb_read_frames) !== expected.frameCount
183
+ || !Number.isSafeInteger(probeTimeUs(video.start_time)) || Math.abs(probeTimeUs(video.start_time)) > 1
184
+ || !Number.isSafeInteger(probeTimeUs(video.duration)) || Math.abs(probeTimeUs(video.duration) - expected.durationUs) > 2
185
+ || !Number.isSafeInteger(durationUs) || Math.abs(durationUs - expected.durationUs) > 50_000
186
+ || audios.length !== (expected.audio ? 1 : 0)
187
+ || audios.some(audio => audio.codec_name !== "aac" || !Number.isSafeInteger(probeTimeUs(audio.start_time))
188
+ || Math.abs(probeTimeUs(audio.start_time)) > 30_000
189
+ || !Number.isSafeInteger(probeTimeUs(audio.duration)) || Math.abs(probeTimeUs(audio.duration) - expected.durationUs) > 30_000
190
+ || audio.channels !== 2 || audio.sample_rate !== "48000")) {
191
+ throw new CliError("invalid-data", "Encoded scene failed dimensions, frame count, duration, color, or audio verification.");
192
+ }
193
+ return { frameCount: expected.frameCount, width: video.width, height: video.height, durationUs,
194
+ audio: audios.length === 0 ? null : { codec: "aac", channels: 2, sampleRateHz: 48000, startTimeUs: probeTimeUs(audios[0]!.start_time), durationUs: probeTimeUs(audios[0]!.duration) } };
195
+ }
196
+
197
+ function placement(asset: ProjectAssetV1, durationUs: number, suffix: string) {
198
+ const endUs = Math.min(asset.durationUs, durationUs);
199
+ return ProjectPlacementV1Schema.parse({
200
+ placementId: `placement_${suffix}`, assetId: asset.assetId, enabled: true,
201
+ assetRange: { startUs: 0, endUs },
202
+ sync: { anchors: [{ assetTimeUs: 0, projectTimeUs: 0 }, { assetTimeUs: endUs, projectTimeUs: endUs }], provenance: { kind: "identity" } },
203
+ video: asset.streams.filter(stream => stream.kind === "video").map(stream => ({ streamId: stream.streamId,
204
+ presentation: asset.role === "music" ? { enabled: false } : {
205
+ enabled: true, blendMode: "normal", crop: { kind: "none" }, fit: "fill", layer: 0,
206
+ layout: { kind: "normalized", x: 0, y: 0, width: 1, height: 1 }, opacity: 1,
207
+ } })),
208
+ audio: asset.streams.filter(stream => stream.kind === "audio").map(stream => ({ streamId: stream.streamId,
209
+ presentation: { enabled: true, gainDb: 0, pan: 0 } })),
210
+ });
211
+ }
212
+
213
+ /** Retain authoring and separate source tracks, then publish one ordinary editable video project. */
214
+ export async function renderHtmlScene(
215
+ application: ApplicationContext, input: unknown, signal: AbortSignal, dependencies: HtmlSceneDependencies = {},
216
+ ) {
217
+ const active = async () => {
218
+ if (signal.aborted) throw new CliError("cancelled", "HTML scene rendering was cancelled.");
219
+ if (application.hostResourceLease === undefined) throw new CliError("unavailable", "HTML scene rendering requires an admitted host-resource lease.");
220
+ await application.hostResourceLease.assertOwned();
221
+ };
222
+ await active();
223
+ dependencies.progress?.("source");
224
+ const source = await readSource(application, input, signal);
225
+ if (application.htmlOverlayRenderer === undefined) throw new CliError("unavailable", "HTML scene renderer is unavailable.");
226
+ const tools = await (dependencies.bindTools ?? bindExactCapabilities)(application, ["ffmpeg", "ffprobe", "html-browser"]);
227
+ const tool = (name: string): ExactCapabilityBinding => {
228
+ const found = tools.find(candidate => candidate.name === name);
229
+ if (found === undefined) throw new CliError("unavailable", `Missing scene capability: ${name}`);
230
+ return found;
231
+ };
232
+ dependencies.progress?.("browser");
233
+ const browserRuntime = await (dependencies.bindBrowser ?? bindHtmlOverlayBrowserRuntime)(tool("html-browser"), signal);
234
+ const suffix = `html_${randomUUID().replaceAll("-", "")}`;
235
+ const jobDirectory = await ensurePhysicalPrivateDirectoryWithin(application.paths.repositoryRoot,
236
+ `artifacts/slopcamera/generated/html-scenes/${suffix}`);
237
+ const fs = createNodeBundleFileSystem(jobDirectory);
238
+ const rootFs = createNodeBundleFileSystem(application.paths.repositoryRoot);
239
+ const runner = new ExactCapabilityApplicationRunner(application.runner, tools, application.paths.privateRoot);
240
+ const artifact = async (path: string, maximumBytes = MAX_MEDIA_BYTES): Promise<MediaArtifactReference> => ({
241
+ path: relative(application.paths.repositoryRoot, join(jobDirectory, path)),
242
+ ...await fs.inspectFile!(path, maximumBytes),
243
+ });
244
+ return await withMutationLock(jobDirectory, { command: "html render", label: source.request.name }, async lease => {
245
+ const fence = async () => { await active(); await lease.assertOwned(); };
246
+ const retainJson = async (path: string, value: unknown, maximumBytes = 4 * 1024 * 1024) => {
247
+ await fs.writeTextNoReplace!(path, `${canonicalJson(value)}\n`, fence);
248
+ return await artifact(path, maximumBytes);
249
+ };
250
+ const run = async (argv: readonly [string, ...string[]]) => {
251
+ await fence();
252
+ const result = await runner.run(argv, { abortSignal: signal, timeoutMs: PROCESS_TIMEOUT_MS,
253
+ maxOutputBytes: 1024 * 1024, inheritedFileDescriptors: application.hostResourceLease!.inheritedFileDescriptors });
254
+ await fence();
255
+ if (result.exitCode !== 0) throw new CliError("subprocess", `HTML scene media step failed: ${result.stderr.trim().slice(0, 1000) || result.exitCode}`);
256
+ return result;
257
+ };
258
+ await ensurePhysicalPrivateDirectoryWithin(jobDirectory, "source");
259
+ await fs.writeTextNoReplace!("source/scene.html", source.authoring.html, fence);
260
+ const retainedDocument = await artifact("source/scene.html", HTML_OVERLAY_MAX_HTML_BYTES);
261
+ const retainedResources = [];
262
+ for (const { declaration, loaded } of source.resources) {
263
+ const destination = `source/${declaration.name}-${loaded.artifact.sha256}`;
264
+ await rootFs.copyFileNoReplace!(loaded.artifact.path,
265
+ relative(application.paths.repositoryRoot, join(jobDirectory, destination)), loaded.artifact, fence);
266
+ const { path: _path, ...resource } = declaration;
267
+ retainedResources.push({ ...resource, ...loaded.artifact, path: relative(application.paths.repositoryRoot, join(jobDirectory, destination)), absolutePath: join(jobDirectory, destination) });
268
+ }
269
+ let audio: Awaited<ReturnType<typeof ingestProjectMedia>> | undefined;
270
+ let authoring = source.authoring;
271
+ let audioReactivity: MediaArtifactReference | undefined;
272
+ if (source.request.audio !== undefined) {
273
+ audio = await ingestProjectMedia({ ffprobe: tool("ffprobe").command, now: application.clock.now(),
274
+ projectDirectory: jobDirectory, repositoryRoot: application.paths.repositoryRoot, role: "music",
275
+ sourcePath: resolve(application.paths.repositoryRoot, source.request.audio.path), maximumBytes: MAX_MEDIA_BYTES,
276
+ runner: { run: async argv => await run(argv) } });
277
+ 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.");
278
+ const soundtrack = audio.asset.streams.find(stream => stream.kind === "audio")!;
279
+ if (soundtrack.segments.length !== 1 || soundtrack.segments[0]!.assetRange.startUs !== 0
280
+ || soundtrack.segments[0]!.fileRange.startUs !== 0) {
281
+ 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.");
282
+ }
283
+ if (source.request.audio.reactivity !== undefined) {
284
+ const segment = soundtrack.segments[0]!;
285
+ if (source.request.audio.reactivity.resource === undefined) {
286
+ dependencies.progress?.("audio-analysis");
287
+ const analysis = await analyzeRetainedAudioReactivity({ repositoryRoot: application.paths.repositoryRoot,
288
+ jobDirectory, audio: segment, durationUs: Math.min(source.durationUs, segment.assetRange.endUs),
289
+ streamIndex: segment.streamIndex, ffmpeg: tool("ffmpeg").command, run, fence });
290
+ audioReactivity = await retainJson("source/audio-reactivity.json", analysis, HTML_AUDIO_REACTIVITY_MAX_BYTES);
291
+ const declaration = { name: HTML_AUDIO_REACTIVITY_RESOURCE_NAME, urlPath: HTML_AUDIO_REACTIVITY_RESOURCE_URL_PATH,
292
+ mediaType: "application/json", transport: "fetch" as const };
293
+ retainedResources.push({ ...declaration, ...audioReactivity, absolutePath: join(jobDirectory, "source/audio-reactivity.json") });
294
+ authoring = HtmlOverlayAuthoringInputSchema.parse({ ...authoring,
295
+ resources: [...authoring.resources, { ...declaration, bytes: audioReactivity.bytes, sha256: audioReactivity.sha256 }] });
296
+ } else {
297
+ const retained = source.resources.find(item => item.declaration.name === HTML_AUDIO_REACTIVITY_RESOURCE_NAME);
298
+ if (retained === undefined) throw new CliError("invalid-data", "Audio reactivity source does not declare its retained resource.");
299
+ if (retained.loaded.artifact.bytes > HTML_AUDIO_REACTIVITY_MAX_BYTES) {
300
+ throw new CliError("invalid-data", "Audio reactivity resource exceeds its 4 MiB sidecar bound.");
301
+ }
302
+ if (source.request.audio.reactivity.sha256 !== retained.loaded.artifact.sha256) {
303
+ throw new CliError("conflict", "Audio reactivity source does not match the retained sidecar bytes.");
304
+ }
305
+ let parsed: unknown;
306
+ try { parsed = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(retained.loaded.data)); }
307
+ catch { throw new CliError("invalid-data", "Audio reactivity resource is not valid UTF-8 JSON."); }
308
+ const analysis = HtmlOverlayAudioReactivitySchema.parse(parsed);
309
+ if (analysis.source.bytes !== segment.bytes || analysis.source.sha256 !== segment.sha256) {
310
+ throw new CliError("conflict", "Audio reactivity resource does not match the retained soundtrack.");
311
+ }
312
+ audioReactivity = retainedResources.find(item => item.name === HTML_AUDIO_REACTIVITY_RESOURCE_NAME);
313
+ if (audioReactivity === undefined) throw new CliError("invalid-data", "Audio reactivity resource was not retained.");
314
+ }
315
+ }
316
+ }
317
+ const retainedAudio = source.request.audio;
318
+ const retainedInput = HtmlSceneInputSchema.parse({ ...source.request,
319
+ document: { path: relative(application.paths.repositoryRoot, join(jobDirectory, "source/scene.html")) },
320
+ resources: retainedResources.map(({ absolutePath: _absolutePath, bytes: _bytes, sha256: _sha256, ...resource }) => resource),
321
+ ...(audio === undefined || retainedAudio === undefined ? {} : { audio: { ...retainedAudio, path: relative(application.paths.repositoryRoot, audio.absolutePath),
322
+ ...(retainedAudio.reactivity === undefined ? {} : { reactivity: { profile: "bands-v1", resource: HTML_AUDIO_REACTIVITY_RESOURCE_NAME,
323
+ sha256: audioReactivity?.sha256 ?? retainedAudio.reactivity.sha256 } }) } }),
324
+ });
325
+ const retainedSource = await retainJson("source.json", retainedInput);
326
+ const authoringReceipt = await retainJson("authoring.json", authoring);
327
+ const intent = await retainJson("intent.json", { kind: "slopcamera.html-scene-intent", schemaVersion: 1,
328
+ source: retainedSource, document: retainedDocument, authoring: authoringReceipt, tools, frameCount: source.frameCount,
329
+ durationUs: source.durationUs, requestedDurationUs: source.request.timing.durationUs,
330
+ timingPolicy: "ceil-frames-trim-or-pad-audio-v1" });
331
+ dependencies.progress?.("frames");
332
+ const frameDirectory = await ensurePhysicalPrivateDirectoryWithin(jobDirectory, "render");
333
+ const frames = await application.htmlOverlayRenderer!.renderFrames({ authoring,
334
+ browserRuntime, outputDirectory: frameDirectory, resources: retainedResources,
335
+ ...(source.request.executionProfile === undefined ? {} : { executionProfile: source.request.executionProfile }) }, signal);
336
+ await fence();
337
+ if (frames.frameCount !== source.frameCount || frames.framePattern !== join(frameDirectory, "frames", "frame-%08d.png")) {
338
+ throw new CliError("invalid-data", "Scene renderer returned an unexpected frame count or output location.");
339
+ }
340
+ const expectedIntegrity = createHtmlOverlayExecutionBundle(authoring, browserRuntime, source.request.executionProfile).integrity;
341
+ if (canonicalJsonSha256(HtmlOverlayExecutionIntegritySchema.parse(frames.executionIntegrity)) !== canonicalJsonSha256(expectedIntegrity)) {
342
+ throw new CliError("conflict", "Scene renderer returned integrity evidence that differs from the bound browser execution.");
343
+ }
344
+ if (canonicalJsonSha256(HtmlOverlayActiveLibraryLocksSchema.parse(frames.libraryLocks))
345
+ !== canonicalJsonSha256(authoring.libraries.map(getApprovedHtmlOverlayLibraryLock))) {
346
+ throw new CliError("conflict", "Scene renderer returned library locks that differ from the authoring selection.");
347
+ }
348
+ assertHtmlOverlayGpuEvidenceProfile(source.request.executionProfile, frames.gpuEvidence);
349
+ const frameFs = createNodeBundleFileSystem(join(frameDirectory, "frames"));
350
+ const verifyFrames = async () => {
351
+ const names = await readdir(join(frameDirectory, "frames"));
352
+ if (names.length !== source.frameCount) throw new CliError("invalid-data", "Scene frame directory contains an unexpected entry.");
353
+ const hash = createHash("sha256");
354
+ for (let index = 0; index < source.frameCount; index++) {
355
+ await fence();
356
+ const name = `frame-${String(index).padStart(8, "0")}.png`;
357
+ const found = await frameFs.inspectFile!(name, source.width * source.height * 5 + 1024 * 1024);
358
+ const first = await readPngHeader(join(frameDirectory, "frames", name), found.bytes);
359
+ if (first.length < 33 || first.subarray(0, 8).toString("hex") !== "89504e470d0a1a0a"
360
+ || first.toString("ascii", 12, 16) !== "IHDR" || first.readUInt32BE(16) !== source.width || first.readUInt32BE(20) !== source.height) {
361
+ throw new CliError("invalid-data", "Rendered scene frame differs from the declared PNG dimensions.");
362
+ }
363
+ hash.update(`${index}:${found.bytes}:${found.sha256}\n`);
364
+ }
365
+ return hash.digest("hex");
366
+ };
367
+ const frameSha256 = await verifyFrames();
368
+ const frameReceipt = await retainJson("frames.json", { ...frames, frameSha256 });
369
+ const videoPath = join(jobDirectory, "scene.mp4"), outputPath = join(jobDirectory, "video.mp4");
370
+ const frameRate = String(source.request.timing.fps), duration = (source.durationUs / 1_000_000).toFixed(6);
371
+ const background = source.request.background.replace("#", "0x");
372
+ dependencies.progress?.("encode");
373
+ try {
374
+ await run([tool("ffmpeg").command, "-nostdin", "-v", "error", "-n", "-threads", "2",
375
+ "-protocol_whitelist", "file", "-f", "image2", "-framerate", frameRate, "-start_number", "0", "-i", frames.framePattern,
376
+ "-filter_complex_threads", "1", "-filter_complex",
377
+ `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]`,
378
+ "-map", "[v]", "-an", "-frames:v", String(source.frameCount), "-c:v", "libx264rgb", "-crf", "0", "-preset", "fast",
379
+ "-threads", "2", "-color_trc", "iec61966-2-1", "-color_primaries", "bt709", "-colorspace", "rgb", "-color_range", "pc",
380
+ "-fs", String(MAX_MEDIA_BYTES), videoPath]);
381
+ } catch (error) {
382
+ const details = await lstat(videoPath, { bigint: true }).catch(() => undefined);
383
+ if (details?.isFile() && details.size >= BigInt(MAX_MEDIA_BYTES)) throw new CliError("invalid-data", SCENE_MEDIA_LIMIT_MESSAGE);
384
+ throw error;
385
+ }
386
+ const intermediateProbe = await (async () => {
387
+ try {
388
+ return await run([tool("ffprobe").command, "-v", "error", ...SELF_CONTAINED_MEDIA_INPUT_ARGUMENTS,
389
+ "-count_frames", "-show_entries", "stream=codec_type,codec_name,width,height,nb_read_frames,start_time,duration:format=duration", "-of", "json", videoPath]);
390
+ } catch (error) {
391
+ const details = await lstat(videoPath, { bigint: true }).catch(() => undefined);
392
+ if (details?.isFile() && details.size >= BigInt(MAX_MEDIA_BYTES)) throw new CliError("invalid-data", SCENE_MEDIA_LIMIT_MESSAGE);
393
+ throw error;
394
+ }
395
+ })();
396
+ let intermediateJson: unknown;
397
+ try { intermediateJson = JSON.parse(intermediateProbe.stdout) as unknown; }
398
+ catch { throw new CliError("invalid-data", `Lossless HTML scene intermediate could not be inspected. ${SCENE_MEDIA_LIMIT_MESSAGE}`); }
399
+ verifyHtmlSceneIntermediateProbe(intermediateJson, source);
400
+ const videoArtifact = await (async () => {
401
+ try {
402
+ return await artifact("scene.mp4");
403
+ } catch (error) {
404
+ const details = await lstat(videoPath, { bigint: true }).catch(() => undefined);
405
+ if (details?.isFile() && details.size > BigInt(MAX_MEDIA_BYTES)) {
406
+ throw new CliError("invalid-data", SCENE_MEDIA_TOO_LARGE_MESSAGE);
407
+ }
408
+ throw error;
409
+ }
410
+ })();
411
+ if (await verifyFrames() !== frameSha256) throw new CliError("conflict", "Scene frame bytes changed during encoding.");
412
+ const audioStream = audio?.asset.streams.find(stream => stream.kind === "audio");
413
+ if (audio !== undefined) for (const stream of audio.asset.streams) for (const segment of stream.segments) {
414
+ await resolveVerifiedProjectMedia({ repositoryRoot: application.paths.repositoryRoot, path: segment.path, expected: segment, label: "Scene soundtrack" });
415
+ }
416
+ await run([tool("ffmpeg").command, "-nostdin", "-v", "error", "-n", "-threads", "2",
417
+ ...SELF_CONTAINED_MEDIA_INPUT_ARGUMENTS, "-i", videoPath,
418
+ ...(audio === undefined ? [] : [...SELF_CONTAINED_MEDIA_INPUT_ARGUMENTS, "-i", audio.absolutePath]),
419
+ "-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"]),
420
+ "-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",
421
+ "-c:v", "libx264", "-crf", "18", "-preset", "medium",
422
+ "-threads", "2", "-filter_threads", "1", "-t", duration, "-r", frameRate,
423
+ "-color_trc", "bt709", "-color_primaries", "bt709", "-colorspace", "bt709", "-color_range", "tv", "-movflags", "+faststart",
424
+ "-fs", String(MAX_MEDIA_BYTES), outputPath]);
425
+ dependencies.progress?.("verify");
426
+ const inspected = await run([tool("ffprobe").command, "-v", "error", ...SELF_CONTAINED_MEDIA_INPUT_ARGUMENTS,
427
+ "-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]);
428
+ const verification = verifyHtmlSceneProbe(JSON.parse(inspected.stdout) as unknown, { ...source, audio: audio !== undefined });
429
+ const output = await artifact("video.mp4");
430
+ const importedVideo = await ingestProjectMedia({ ffprobe: tool("ffprobe").command, now: application.clock.now(),
431
+ projectDirectory: jobDirectory, repositoryRoot: application.paths.repositoryRoot, role: "screen", sourcePath: videoPath,
432
+ maximumBytes: MAX_MEDIA_BYTES, runner: { run: async argv => await run(argv) } });
433
+ if (importedVideo.asset.source.kind !== "imported" || importedVideo.asset.source.sourceSha256 !== videoArtifact.sha256) {
434
+ throw new CliError("conflict", "Scene video changed before its editable project import.");
435
+ }
436
+ const videoAsset = ProjectAssetV1Schema.parse({ ...importedVideo.asset,
437
+ source: { kind: "generated", generator: "slopcamera.html-scene", generatorVersion: "1", sourceSha256: intent.sha256 } });
438
+ dependencies.progress?.("project");
439
+ const projectId = `project_${suffix}`, timestamp = application.clock.now().toISOString();
440
+ const videoPlacement = placement(videoAsset, source.durationUs, `${suffix}_video`);
441
+ const project = VideoProjectV1Schema.parse({
442
+ kind: "slopcamera.video-project", schemaVersion: 1, projectId, name: source.request.name,
443
+ createdAt: timestamp, updatedAt: timestamp, currentEditPlanPath: "edits/current.json", analyses: [],
444
+ assets: [videoAsset, ...(audio === undefined ? [] : [audio.asset])],
445
+ placements: [videoPlacement, ...(audio === undefined ? [] : [placement(audio.asset, source.durationUs, `${suffix}_audio`)])],
446
+ referencePlacementId: videoPlacement.placementId, timeline: { timebase: "microseconds", durationUs: source.durationUs },
447
+ });
448
+ const plan = createDefaultProjectEditPlan(project, ProjectEditPlanV1Schema.shape.planId.parse(`plan_${suffix}`), timestamp);
449
+ for (const retained of [retainedSource, retainedDocument, authoringReceipt, intent, frameReceipt, videoArtifact, output, ...retainedResources]) {
450
+ await fence();
451
+ await resolveVerifiedProjectMedia({ repositoryRoot: application.paths.repositoryRoot, path: retained.path, expected: retained, label: "Retained scene source" });
452
+ }
453
+ if (audio !== undefined) for (const stream of audio.asset.streams) for (const segment of stream.segments) {
454
+ await resolveVerifiedProjectMedia({ repositoryRoot: application.paths.repositoryRoot, path: segment.path, expected: segment, label: "Scene soundtrack after encoding" });
455
+ }
456
+ const projectDirectory = await ensurePhysicalPrivateDirectoryWithin(application.paths.projectRoot, projectId);
457
+ const projectFs = createNodeBundleFileSystem(projectDirectory);
458
+ await ensurePhysicalPrivateDirectoryWithin(projectDirectory, "edits");
459
+ const receipt = await retainJson("receipt.json", { kind: "slopcamera.html-scene-receipt", schemaVersion: 1,
460
+ intent, source: retainedSource, document: retainedDocument, authoring: authoringReceipt, frameReceipt, video: videoArtifact, output,
461
+ projectId, projectSha256: canonicalJsonSha256(project), planSha256: canonicalJsonSha256(plan), verification,
462
+ audio: audio?.asset ?? null, audioReactivity: audioReactivity ?? null, timingPolicy: "ceil-frames-trim-or-pad-audio-v1" });
463
+ await projectFs.writeTextNoReplace!("html-scene.json", canonicalJson({ source: retainedSource, receipt }), fence);
464
+ await projectFs.writeTextNoReplace!("edits/current.json", canonicalJson(plan), fence);
465
+ await projectFs.writeTextNoReplace!("project.json", canonicalJson(project), fence);
466
+ await fence();
467
+ dependencies.progress?.("complete");
468
+ return { kind: "slopcamera.html-scene-export" as const, schemaVersion: 1 as const, output, receipt,
469
+ source: retainedSource, audioReactivity: audioReactivity ?? null, projectId, projectPath: relative(application.paths.repositoryRoot, join(projectDirectory, "project.json")), verification };
470
+ });
471
+ }
472
+
473
+ export async function executeHtmlSceneCommand(application: ApplicationContext, command: {
474
+ readonly input: string; readonly dryRun: boolean;
475
+ }, signal: AbortSignal, dependencies: HtmlSceneDependencies = {}) {
476
+ const input = await readSpatialJson(resolve(application.paths.repositoryRoot, command.input), MAX_REQUEST_BYTES);
477
+ return command.dryRun ? await planHtmlScene(application, input, signal)
478
+ : await renderHtmlScene(application, input, signal, dependencies);
479
+ }
@@ -1,3 +1,4 @@
1
+ import { quietSupportChildEnvironment } from "../../../src/support-completion";
1
2
  import { CliError } from "./errors";
2
3
 
3
4
  export interface CliIo {
@@ -167,7 +168,7 @@ export class BunProcessRunner implements ProcessRunner {
167
168
  }
168
169
  const child = Bun.spawn([...argv], {
169
170
  ...(options.cwd === undefined ? {} : { cwd: options.cwd }),
170
- env: environment,
171
+ env: quietSupportChildEnvironment(environment),
171
172
  stdio: [
172
173
  "ignore",
173
174
  "pipe",
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
- import { join, resolve } from "node:path";
3
+ import { runProductSupportCommand, showProductSupportInvitation, standaloneSupportEnvironment } from "../../../src/support";
4
+
4
5
  import { parseCliArgs } from "./args";
5
6
  import { runCli } from "./commands";
6
7
  import { asCliError, CliError, EXIT_CODE } from "./errors";
@@ -11,26 +12,8 @@ import {
11
12
  runPortableSurface,
12
13
  type PortableSurfaceDependencies,
13
14
  } from "./portable-surface";
14
- import { RecordingDaemonClient, runRecordingDaemon } from "./recording-daemon";
15
- import { renamedEnvironmentValue } from "./renamed-environment";
16
-
17
- function valueAfter(argv: readonly string[], name: string): string {
18
- const index = argv.indexOf(name);
19
- const value = index === -1 ? undefined : argv[index + 1];
20
- if (value === undefined || value.startsWith("--")) throw new CliError("usage", `${name} requires a value.`);
21
- return value;
22
- }
23
15
 
24
- export function daemonCommandFor(executableInput: string, entrypointInput: string): readonly [string, ...string[]] {
25
- const executable = resolve(executableInput);
26
- if (entrypointInput.includes("$bunfs")) return [executable];
27
- const entrypoint = resolve(entrypointInput);
28
- return executable === entrypoint ? [executable] : [executable, entrypoint];
29
- }
30
-
31
- function daemonCommand(): readonly [string, ...string[]] {
32
- return daemonCommandFor(process.execPath, import.meta.path);
33
- }
16
+ import { rootHelpIntro } from "./root-help-intro";
34
17
 
35
18
  export function isEmbeddedVectorizeWorkerInvocation(
36
19
  argv: readonly string[],
@@ -45,6 +28,7 @@ export function isEmbeddedVectorizeWorkerInvocation(
45
28
  export async function main(
46
29
  argv: readonly string[] = process.argv.slice(2),
47
30
  portableDependencies: PortableSurfaceDependencies = {},
31
+ supportEnvironment?: Readonly<Record<string, string | undefined>>,
48
32
  ): Promise<number> {
49
33
  if (isEmbeddedVectorizeWorkerInvocation(argv)) {
50
34
  // The bundled headless supervisor preserves process isolation by spawning
@@ -54,45 +38,32 @@ export async function main(
54
38
  await import("../../../src/vectorize/worker.ts");
55
39
  return typeof process.exitCode === "number" ? process.exitCode : 0;
56
40
  }
57
- if (argv[0] === "__record_daemon") {
58
- if (argv.length !== 5 || argv[1] !== "--artifact-root" || argv[3] !== "--helper") {
59
- throw new CliError("usage", "Invalid internal recording-daemon invocation.");
60
- }
61
- const paths = await resolveRepositoryPaths(process.cwd(), process.env);
62
- const artifactRoot = valueAfter(argv, "--artifact-root");
63
- if (resolve(artifactRoot) !== resolve(paths.artifactRoot)) {
64
- throw new CliError("unsafe-path", "Recording daemon artifact root differs from the repository-owned root.");
65
- }
66
- await runRecordingDaemon({
67
- artifactRoot,
68
- helperExecutable: valueAfter(argv, "--helper"),
69
- });
70
- return 0;
41
+ if (argv[0] === "support") {
42
+ return await runProductSupportCommand(argv.slice(1),
43
+ supportEnvironment === undefined ? {} : { env: supportEnvironment });
71
44
  }
72
45
  const unifiedArgv = canonicalizeUnifiedCliArgs(argv);
73
46
  const portableExitCode = await runPortableSurface(unifiedArgv, portableDependencies);
74
47
  if (portableExitCode !== undefined) return portableExitCode;
75
48
  const earlyCommand = parseCliArgs(unifiedArgv);
76
49
  if (earlyCommand.kind === "help" || earlyCommand.kind === "version" || earlyCommand.kind === "complete") {
50
+ const intro = rootHelpIntro(unifiedArgv, {
51
+ isTTY: process.stdout.isTTY === true, term: process.env.TERM, columns: process.stdout.columns,
52
+ });
53
+ if (intro !== "") processIo.stdout(intro);
77
54
  return await runCli(unifiedArgv, { io: processIo });
78
55
  }
79
56
  const paths = await resolveRepositoryPaths(processIo.cwd(), processIo.env);
80
- const helperExecutable = renamedEnvironmentValue(processIo.env, "SLOPCAMERA_CAPTURE_HELPER")
81
- ?? join(paths.desktopRoot, "capture", "dist", "slopcamera-capture");
82
- const recordingController = new RecordingDaemonClient({
83
- artifactRoot: paths.artifactRoot,
84
- daemonCommand: daemonCommand(),
85
- helperExecutable,
86
- });
87
- if (earlyCommand.kind === "spatial-world" || earlyCommand.kind === "directing" || earlyCommand.kind === "studio"
57
+ if (earlyCommand.kind === "html-render" || earlyCommand.kind === "spatial-world" || earlyCommand.kind === "directing" || earlyCommand.kind === "studio"
88
58
  || earlyCommand.kind === "spatial-scene" && earlyCommand.action === "camera-track") {
89
59
  const controller = new AbortController();
90
60
  const cancel = () => controller.abort(new CliError("cancelled", "Command interrupted."));
91
61
  process.on("SIGINT", cancel);
92
62
  process.on("SIGTERM", cancel);
93
63
  try {
94
- return await runCli(unifiedArgv, { io: processIo, paths, recordingController,
95
- runner: new BunProcessRunner(), abortSignal: controller.signal });
64
+ return await runCli(unifiedArgv, { io: processIo, paths,
65
+ runner: new BunProcessRunner(), abortSignal: controller.signal,
66
+ ...(portableDependencies.onUsefulResult === undefined ? {} : { onUsefulResult: portableDependencies.onUsefulResult }) });
96
67
  } finally {
97
68
  process.off("SIGINT", cancel);
98
69
  process.off("SIGTERM", cancel);
@@ -101,16 +72,26 @@ export async function main(
101
72
  return await runCli(unifiedArgv, {
102
73
  io: processIo,
103
74
  paths,
104
- recordingController,
105
75
  runner: new BunProcessRunner(),
76
+ ...(portableDependencies.onUsefulResult === undefined ? {} : { onUsefulResult: portableDependencies.onUsefulResult }),
106
77
  });
107
78
  }
108
79
 
109
80
  export async function runMainEntrypoint(
110
81
  portableDependencies: PortableSurfaceDependencies = {},
82
+ standalone = false,
111
83
  ): Promise<void> {
112
84
  try {
113
- process.exitCode = await main(process.argv.slice(2), portableDependencies);
85
+ const argv = process.argv.slice(2);
86
+ const env = standalone && !isEmbeddedVectorizeWorkerInvocation(argv)
87
+ ? standaloneSupportEnvironment() : undefined;
88
+ let usefulResult = false;
89
+ process.exitCode = await main(argv, env === undefined ? portableDependencies : {
90
+ ...portableDependencies, onUsefulResult: () => { usefulResult = true; },
91
+ }, env);
92
+ if (env !== undefined && usefulResult && process.exitCode === 0) {
93
+ await showProductSupportInvitation({ env });
94
+ }
114
95
  } catch (error) {
115
96
  const failure = asCliError(error);
116
97
  process.stderr.write(`slopcamera: ${failure.message}\n`);
@@ -119,5 +100,5 @@ export async function runMainEntrypoint(
119
100
  }
120
101
 
121
102
  if (import.meta.main) {
122
- await runMainEntrypoint();
103
+ await runMainEntrypoint({}, true);
123
104
  }
@@ -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;