@hraness/slopcamera 3.2.7 → 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 (67) hide show
  1. package/NOTICE.md +1 -7
  2. package/PRIVACY.md +16 -7
  3. package/README.md +37 -12
  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 +14 -86
  10. package/apps/desktop/cli/command-host-resources.ts +2 -1
  11. package/apps/desktop/cli/commands.ts +28 -67
  12. package/apps/desktop/cli/help.ts +12 -18
  13. package/apps/desktop/cli/html-scene.ts +139 -28
  14. package/apps/desktop/cli/io.ts +2 -1
  15. package/apps/desktop/cli/main.ts +23 -47
  16. package/apps/desktop/cli/menubar.ts +244 -0
  17. package/apps/desktop/cli/music-analysis-service.ts +61 -0
  18. package/apps/desktop/cli/portable-surface.ts +12 -4
  19. package/apps/desktop/code/application-node-planner.ts +0 -47
  20. package/apps/desktop/code/host-source-layout.ts +46 -0
  21. package/apps/desktop/code/public.ts +0 -1
  22. package/apps/desktop/code/runtime-identity.ts +50 -8
  23. package/apps/desktop/code/semantic-builder.ts +0 -43
  24. package/apps/desktop/code/source-bundle.ts +23 -6
  25. package/apps/desktop/code/source-typecheck.ts +131 -43
  26. package/apps/desktop/code/worker-client.ts +14 -2
  27. package/apps/desktop/core/music-analysis.ts +124 -1
  28. package/apps/desktop/dist/cli/main.js +244 -250
  29. package/apps/desktop/html-overlay/audio-reactivity.ts +127 -0
  30. package/apps/desktop/html-overlay/index.ts +9 -0
  31. package/apps/desktop/html-overlay/runtime.ts +2 -0
  32. package/apps/desktop/html-overlay/scene.ts +29 -0
  33. package/dist/cli.js +83 -81
  34. package/dist/{index-rvghzt7b.js → index-7131pg1c.js} +43 -236
  35. package/dist/{index-mcy8z0br.js → index-fava6pge.js} +1 -1
  36. package/dist/{index-z7239b4h.js → index-h1k0fnjq.js} +1 -1
  37. package/dist/{index-p63wavx0.js → index-zfnddgay.js} +1 -1
  38. package/dist/index.js +4 -18
  39. package/dist/operations.js +2 -2
  40. package/dist/vectorize/worker.js +1 -1
  41. package/dist/workflow.js +3 -3
  42. package/docs/README.md +1 -1
  43. package/docs/studio.md +1 -1
  44. package/package.json +6 -21
  45. package/skills/slopcamera/SKILL.md +8 -0
  46. package/skills/slopcamera/references/diagrams.md +5 -14
  47. package/skills/slopcamera/references/install.md +1 -1
  48. package/skills/slopcamera/references/music-video.md +23 -3
  49. package/skills/slopcamera/references/reference-led-3d.md +1 -1
  50. package/skills/slopcamera/references/support.md +25 -0
  51. package/skills/slopcamera/references/web-media-excerpts.md +120 -0
  52. package/src/cli.ts +28 -98
  53. package/src/index.ts +0 -23
  54. package/src/support-completion.ts +16 -0
  55. package/src/support.ts +42 -0
  56. package/src/version.ts +1 -1
  57. package/apps/desktop/application/operations/recording/index.ts +0 -5
  58. package/apps/desktop/application/operations/recording/pause.ts +0 -34
  59. package/apps/desktop/application/operations/recording/resume.ts +0 -34
  60. package/apps/desktop/application/operations/recording/shared.ts +0 -199
  61. package/apps/desktop/application/operations/recording/start.ts +0 -169
  62. package/apps/desktop/application/operations/recording/stop.ts +0 -34
  63. package/apps/desktop/capture/protocol.ts +0 -689
  64. package/apps/desktop/cli/capture-bundle.ts +0 -1117
  65. package/apps/desktop/cli/recording-controller.ts +0 -1567
  66. package/apps/desktop/cli/recording-daemon.ts +0 -931
  67. package/src/desktop.ts +0 -253
@@ -1,3 +1,4 @@
1
+ import { reportUsefulResult, type UsefulResultObserver } from "../../../src/support-completion";
1
2
  import { createDirectingBlobSession } from "./directing-blob";
2
3
  import { assembleDirectingClips, extractDirectingEndpoint, importDirectingAnchor } from "./directing-media";
3
4
  import { executeDirectingCommand } from "./directing-service";
@@ -135,6 +136,7 @@ import { commandHelp, completions } from "./help";
135
136
  import { PlaywrightHtmlOverlayRenderer } from "./html-overlay-renderer";
136
137
  import { executeHtmlSceneCommand } from "./html-scene";
137
138
  import { BunProcessRunner, processIo, writeJson, writeLine, type CliIo, type ProcessRunner } from "./io";
139
+ import { launchMenubar, manageMenubar, reportOutputsRoot } from "./menubar";
138
140
  import {
139
141
  codePreparationHostResourceClaims,
140
142
  combineHostResourceClaims,
@@ -175,12 +177,6 @@ import {
175
177
  type OpenProject,
176
178
  } from "./project-service";
177
179
  import { resolveVerifiedProjectMedia } from "./project-media-integrity";
178
- import {
179
- executeRecordingAction,
180
- type RecordingController,
181
- type RecordingSnapshot,
182
- type RecordingStartOptions,
183
- } from "./recording-controller";
184
180
  import { buildFfmpegInvocation, prepareOverlaySources } from "./renderer";
185
181
  import { buildProjectFfmpegInvocation } from "./project-renderer";
186
182
  import {
@@ -301,7 +297,7 @@ import {
301
297
  workflowRunStore,
302
298
  } from "./workflow-runs";
303
299
 
304
- export const SLOPCAMERA_VERSION = "3.2.7";
300
+ export const SLOPCAMERA_VERSION = "3.2.8";
305
301
 
306
302
  // Legacy direct renders predate per-target output contracts. Keep them
307
303
  // bounded generously enough for long-form production while preventing one
@@ -310,6 +306,7 @@ const MAXIMUM_LEGACY_RECORDING_RENDER_OUTPUT_BYTES = 32 * 1024 * 1024 * 1024;
310
306
  const MAXIMUM_LEGACY_PROJECT_RENDER_OUTPUT_BYTES = 32 * 1024 * 1024 * 1024;
311
307
 
312
308
  export interface CliDependencies {
309
+ readonly onUsefulResult?: UsefulResultObserver;
313
310
  readonly abortSignal?: AbortSignal;
314
311
  readonly stateRoot?: string;
315
312
  readonly clock?: () => number;
@@ -320,7 +317,6 @@ export interface CliDependencies {
320
317
  readonly hostResourceCoordinator?: HostResourceCoordinator;
321
318
  readonly io?: CliIo;
322
319
  readonly paths?: RepositoryPaths;
323
- readonly recordingController?: RecordingController;
324
320
  readonly runner?: ProcessRunner;
325
321
  readonly sceneProviderFactory?: (options: GatewaySceneProviderOptions) => SceneDescriptionProvider;
326
322
  readonly sleep?: (milliseconds: number) => Promise<void>;
@@ -347,7 +343,6 @@ interface CommandContext {
347
343
  readonly hostResourceCoordinator: HostResourceCoordinator;
348
344
  readonly io: CliIo;
349
345
  readonly paths: RepositoryPaths;
350
- readonly recordingController: RecordingController | undefined;
351
346
  readonly runner: ProcessRunner;
352
347
  readonly sceneProviderFactory: (options: GatewaySceneProviderOptions) => SceneDescriptionProvider;
353
348
  readonly sleep: (milliseconds: number) => Promise<void>;
@@ -541,7 +536,6 @@ function applicationContext(
541
536
  context: CommandContext,
542
537
  providerOptions?: GatewayProviderOptions,
543
538
  ): ApplicationContext {
544
- const recordingController = context.recordingController;
545
539
  const toApplicationCapability = (capability: Capability) => ({
546
540
  available: capability.available,
547
541
  ...(capability.command === undefined ? {} : { command: capability.command }),
@@ -572,21 +566,6 @@ function applicationContext(
572
566
  : { hostResourceLease: context.hostResourceLease }),
573
567
  machineStateRoot: context.stateRoot,
574
568
  paths: context.paths,
575
- ...(recordingController === undefined
576
- ? {}
577
- : {
578
- recordingController: {
579
- execute: async (
580
- action: "pause" | "resume" | "start" | "stop",
581
- options?: unknown,
582
- ) => await executeRecordingAction(
583
- recordingController,
584
- action,
585
- options as RecordingStartOptions | undefined,
586
- ),
587
- status: async () => await recordingController.status(),
588
- },
589
- }),
590
569
  runner: context.runner,
591
570
  };
592
571
  return {
@@ -1715,11 +1694,6 @@ async function handleRender(
1715
1694
  writeValue(context.io, command.json, completed, () => `rendered ${output.outputPath}; plan=${artifactPath}`);
1716
1695
  }
1717
1696
 
1718
- function snapshotHuman(snapshot: RecordingSnapshot): string {
1719
- if (snapshot.state === "idle") return "idle";
1720
- return `${snapshot.state} ${snapshot.recordingId ?? "unknown"} ${humanTime(snapshot.logicalTimeUs)} segments=${snapshot.completedSegmentCount}`;
1721
- }
1722
-
1723
1697
  async function handleProjectEdit(
1724
1698
  context: CommandContext,
1725
1699
  command: Extract<CliCommand, { readonly kind: "project-edit" }>,
@@ -6260,18 +6234,14 @@ async function dispatch(context: CommandContext, command: CliCommand): Promise<v
6260
6234
  for (const completion of completions(command.words)) writeLine(context.io, completion);
6261
6235
  return;
6262
6236
  case "doctor": {
6263
- const [capabilities, emoji, active, macVersion] = await Promise.all([
6237
+ const [capabilities, emoji, macVersion] = await Promise.all([
6264
6238
  context.capabilities(),
6265
6239
  inspectEmojiAssets(context.paths.repositoryRoot),
6266
- context.recordingController === undefined
6267
- ? Promise.resolve(null)
6268
- : context.recordingController.status(),
6269
6240
  context.io.platform === "darwin"
6270
6241
  ? context.runner.run(["/usr/bin/sw_vers", "-productVersion"], { maxOutputBytes: 4_096 })
6271
6242
  : Promise.resolve({ exitCode: 0, stderr: "", stdout: "not-macos" }),
6272
6243
  ]);
6273
6244
  const output = {
6274
- activeRecording: active,
6275
6245
  artifactRoot: displayPath(context.paths.repositoryRoot, context.paths.artifactRoot),
6276
6246
  gatewayCredential: inspectGatewayCredential(context.io.env),
6277
6247
  emoji: {
@@ -6290,7 +6260,6 @@ async function dispatch(context: CommandContext, command: CliCommand): Promise<v
6290
6260
  `artifacts ${output.artifactRoot}`,
6291
6261
  `platform ${output.platform.name} ${output.platform.macOSVersion}`,
6292
6262
  ...capabilities.map((item) => `${item.name} ${item.available ? item.version ?? item.command : "unavailable"}`),
6293
- `recording ${active === null ? "controller-unavailable" : snapshotHuman(active)}`,
6294
6263
  `emoji ${emoji.provenance} ${emoji.installedCount}/${emoji.catalogCount}; generate: ${emoji.generationCommand}`,
6295
6264
  ].join("\n"));
6296
6265
  return;
@@ -6326,6 +6295,15 @@ async function dispatch(context: CommandContext, command: CliCommand): Promise<v
6326
6295
  case "ai-transcribe": await handleAiTranscribe(context, command); return;
6327
6296
  case "media-audio": await handleMediaAudio(context, command); return;
6328
6297
  case "media-color": await handleMediaColor(context, command); return;
6298
+ case "menubar": {
6299
+ if (command.action === "run") await launchMenubar(context.io, context.paths.repositoryRoot, command.json, command.mode);
6300
+ else await manageMenubar(context.io, context.paths.repositoryRoot, command.action, command.json);
6301
+ return;
6302
+ }
6303
+ case "outputs": {
6304
+ await reportOutputsRoot(context.io, context.stateRoot, command.json);
6305
+ return;
6306
+ }
6329
6307
  case "recordings-list": {
6330
6308
  const directories = (await listRecordingDirectories(context.paths.artifactRoot)).slice(0, command.limit);
6331
6309
  const recordings = await Promise.all(directories.map(async ({ id }) => {
@@ -6509,32 +6487,6 @@ async function dispatch(context: CommandContext, command: CliCommand): Promise<v
6509
6487
  ).join("\n"));
6510
6488
  return;
6511
6489
  }
6512
- case "record": {
6513
- if (context.recordingController === undefined) throw new CliError("unavailable", "Recording controller is unavailable.");
6514
- const options = command.action === "start"
6515
- ? {
6516
- camera: !command.webcam
6517
- ? { kind: "disabled" as const }
6518
- : command.cameraDeviceId === undefined
6519
- ? { kind: "default" as const }
6520
- : { deviceId: command.cameraDeviceId, kind: "device" as const },
6521
- displays: command.displays.length === 0
6522
- ? { kind: "all" as const }
6523
- : { displayIds: command.displays, kind: "selected" as const },
6524
- microphone: !command.microphone
6525
- ? { kind: "disabled" as const }
6526
- : command.microphoneDeviceId === undefined
6527
- ? { kind: "default" as const }
6528
- : { deviceId: command.microphoneDeviceId, kind: "device" as const },
6529
- strictInputs: command.strictInputs,
6530
- systemAudio: command.systemAudio,
6531
- typedText: command.typedText,
6532
- }
6533
- : undefined;
6534
- const snapshot = await executeRecordingAction(context.recordingController, command.action, options);
6535
- writeValue(context.io, command.json, snapshot, () => snapshotHuman(snapshot));
6536
- return;
6537
- }
6538
6490
  case "edit": await handleEdit(context, command); return;
6539
6491
  case "analyze-inactivity": await handleInactivity(context, command); return;
6540
6492
  case "analyze-faces": await handleFaceAnalysis(context, command); return;
@@ -6662,11 +6614,12 @@ function commandMutationReference(command: CliCommand): MutationReference | unde
6662
6614
  case "fillers-list":
6663
6615
  case "emoji-search":
6664
6616
  case "emoji-resolve":
6617
+ case "menubar":
6618
+ case "outputs":
6665
6619
  case "complete": return undefined;
6666
- // These write outside an existing mutable bundle: fresh projects publish by
6667
- // atomic rename and capture has its own controller.
6668
- case "projects-create":
6669
- case "record": return undefined;
6620
+ // Fresh projects write outside an existing mutable bundle and publish by
6621
+ // atomic rename.
6622
+ case "projects-create": return undefined;
6670
6623
  }
6671
6624
  }
6672
6625
 
@@ -6808,7 +6761,6 @@ export async function runCli(argv: readonly string[], dependencies: CliDependenc
6808
6761
  ?? createDefaultHostResourceCoordinator(),
6809
6762
  io,
6810
6763
  paths,
6811
- recordingController: dependencies.recordingController,
6812
6764
  runner,
6813
6765
  sceneProviderFactory: dependencies.sceneProviderFactory
6814
6766
  ?? (options => createGatewaySceneProvider(options)),
@@ -6828,6 +6780,15 @@ export async function runCli(argv: readonly string[], dependencies: CliDependenc
6828
6780
  await dispatch(admittedContext, command);
6829
6781
  });
6830
6782
  });
6783
+ if (command.kind === "projects-create" || command.kind === "project-inspect"
6784
+ || command.kind === "project-add" || command.kind === "inspect"
6785
+ || command.kind === "project-edit" || command.kind === "project-camera-edit"
6786
+ || command.kind === "project-metadata-edit" || command.kind === "project-overlay-edit"
6787
+ || command.kind === "edit"
6788
+ || command.kind === "render-run" && !command.dryRun
6789
+ || command.kind === "project-render" && command.action === "run" && !command.dryRun) {
6790
+ reportUsefulResult(dependencies.onUsefulResult);
6791
+ }
6831
6792
  return 0;
6832
6793
  } catch (error) {
6833
6794
  const failure = asCliError(error);
@@ -22,12 +22,15 @@ Commands:
22
22
  code init|check|plan|run Author, preflight, and run trusted TypeScript workflows
23
23
  runs list|show|resume|approve|cancel
24
24
  Inspect and control durable workflow runs
25
- doctor Check local capture, render, and asset capabilities
25
+ doctor Check local render and asset capabilities
26
26
  ai models|image|video|speech|transcribe
27
27
  Discover and run Vercel AI Gateway media models
28
28
  media audio|color Apply local non-destructive audio and video effects
29
- record start|pause|resume|stop|status
30
- Control the repository-local capture session
29
+ menubar [--background] Run the prebuilt menu-bar companion
30
+ menubar install|uninstall|status Manage its per-user LaunchAgent
31
+ support [--json|protocol --json|offer --json|shown <id>|release <id>|dismiss|snooze|enable|status --json]
32
+ Optional support; no feature requires payment
33
+ outputs Print the agent outputs directory
31
34
  recordings list List recording bundles
32
35
  projects list|create List projects or create one from a recording
33
36
  project inspect|add|edit|render
@@ -43,6 +46,10 @@ Commands:
43
46
  render plan|run <recording> Resolve or execute a render plan
44
47
  assets emoji search|resolve Find checked local emoji overlays
45
48
 
49
+ Agents: after useful work, read slopcamera support protocol --json for the optional
50
+ closeout protocol. Discovery uses stderr; it does not claim an invitation.
51
+ HRANESS_SUPPORT_AUDIENCE=off suppresses offers and incidental discovery.
52
+
46
53
  Run slopcamera help <command> for command-specific help.`;
47
54
 
48
55
  const HELP: Readonly<Record<string, string>> = {
@@ -419,19 +426,6 @@ Apply accepts only candidates with safe acoustic boundaries and current placemen
419
426
  [--min-duration <time>] [--min-confidence <0..1>] [--limit <1..1000>] [--json]
420
427
 
421
428
  Face track IDs describe local geometry continuity inside one immutable analysis. They do not identify a person.`,
422
- record: `Usage:
423
- slopcamera record start [--display <id> ...]
424
- [--camera-device <id>] [--microphone-device <id>]
425
- [--webcam <true|false>] [--microphone <true|false>]
426
- [--system-audio <true|false>] [--typed-text <true|false>]
427
- [--strict-inputs] [--json]
428
- slopcamera record pause|resume|stop|status [--json]
429
-
430
- Capture defaults to every current display, system audio, the default microphone,
431
- and the default camera. Repeat --display to record an exact non-empty subset.
432
- Device IDs select the exact camera or microphone reported by slopcamera doctor;
433
- unknown or duplicate IDs fail closed. Typed-text capture remains opt-in with
434
- --typed-text true.`,
435
429
  recordings: `Usage: slopcamera recordings list [--limit <n>] [--json]`,
436
430
  projects: `Usage:
437
431
  slopcamera projects list [--limit <n>] [--json]
@@ -481,10 +475,11 @@ export function commandHelp(topic: readonly string[]): string {
481
475
 
482
476
  export function completions(words: readonly string[]): readonly string[] {
483
477
  const topLevel = [
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",
478
+ "operations", "diagram", "direct", "studio", "image", "html", "workflows", "code", "runs", "doctor", "ai", "media", "menubar", "support", "outputs", "recordings", "projects", "project", "inspect", "events", "edit", "analyze", "align", "faces", "fillers", "render", "assets",
485
479
  ];
486
480
  if (words.length <= 1) return topLevel;
487
481
  const command = words[0];
482
+ if (command === "support") return ["protocol", "offer", "shown", "release", "dismiss", "snooze", "enable", "status"];
488
483
  if (command === "html") return ["catalog", "scaffold", "render"];
489
484
  if (command === "direct") return ["init", "anchor", "plan", "start", "inspect", "revise", "generate", "resume", "review", "assemble", "cleanup"];
490
485
  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"];
@@ -494,7 +489,6 @@ export function completions(words: readonly string[]): readonly string[] {
494
489
  if (command === "workflows") return ["list", "show", "plan", "run"];
495
490
  if (command === "code") return ["init", "check", "plan", "run"];
496
491
  if (command === "runs") return ["list", "show", "resume", "approve", "cancel"];
497
- if (command === "record") return ["start", "pause", "resume", "stop", "status"];
498
492
  if (command === "recordings") return ["list"];
499
493
  if (command === "projects") return ["list", "create"];
500
494
  if (command === "project") return ["inspect", "add", "edit", "render"];
@@ -1,6 +1,6 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
2
  import { constants } from "node:fs";
3
- import { open, readdir } from "node:fs/promises";
3
+ import { lstat, open, readdir } from "node:fs/promises";
4
4
  import { join, relative, resolve } from "node:path";
5
5
  import { z } from "zod";
6
6
  import type { ApplicationContext } from "../application/context";
@@ -28,8 +28,10 @@ import {
28
28
  import { HtmlSceneInputSchema } from "../html-overlay/scene";
29
29
  import { HtmlOverlayActiveLibraryLocksSchema, getApprovedHtmlOverlayLibraryLock } from "../html-overlay/libraries";
30
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";
31
32
  import { CliError } from "./errors";
32
33
  import { ingestProjectMedia, SELF_CONTAINED_MEDIA_INPUT_ARGUMENTS } from "./media-ingest";
34
+ import { analyzeRetainedAudioReactivity } from "./music-analysis-service";
33
35
  import { withMutationLock } from "./mutation-lock";
34
36
  import { ensurePhysicalPrivateDirectoryWithin } from "./paths";
35
37
  import { resolveVerifiedProjectMedia } from "./project-media-integrity";
@@ -38,8 +40,10 @@ import { readSpatialJson } from "./spatial-scene-service";
38
40
  const MAX_MEDIA_BYTES = 512 * 1024 * 1024;
39
41
  const MAX_REQUEST_BYTES = 2 * 1024 * 1024;
40
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.";
41
45
 
42
- export type HtmlSceneStage = "source" | "browser" | "frames" | "encode" | "verify" | "project" | "complete";
46
+ export type HtmlSceneStage = "source" | "browser" | "audio-analysis" | "frames" | "encode" | "verify" | "project" | "complete";
43
47
  export interface HtmlSceneDependencies {
44
48
  /** Internal controlled-test seams; invocation input cannot replace host capabilities. */
45
49
  readonly bindBrowser?: typeof bindHtmlOverlayBrowserRuntime;
@@ -106,6 +110,9 @@ export async function planHtmlScene(application: ApplicationContext, input: unkn
106
110
  audio: source.request.audio === undefined ? "none" : "explicit-local-import",
107
111
  timingPolicy: "ceil-frames-trim-or-pad-audio-v1",
108
112
  audioValidated: false,
113
+ audioReactivity: source.request.audio?.reactivity === undefined ? null : {
114
+ profile: "bands-v1", validated: false, resourceName: HTML_AUDIO_REACTIVITY_RESOURCE_NAME,
115
+ },
109
116
  };
110
117
  }
111
118
 
@@ -121,6 +128,44 @@ const ProbeSchema = z.object({
121
128
  format: z.object({ duration: z.string() }),
122
129
  });
123
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
+
124
169
  export function verifyHtmlSceneProbe(input: unknown, expected: {
125
170
  readonly width: number; readonly height: number; readonly frameCount: number;
126
171
  readonly durationUs: number; readonly audio: boolean;
@@ -129,26 +174,24 @@ export function verifyHtmlSceneProbe(input: unknown, expected: {
129
174
  const videos = probe.streams.filter(stream => stream.codec_type === "video");
130
175
  const audios = probe.streams.filter(stream => stream.codec_type === "audio");
131
176
  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);
177
+ const durationUs = probeTimeUs(probe.format.duration);
135
178
  if (videos.length !== 1 || video?.codec_name !== "h264"
136
179
  || video.pix_fmt !== "yuv420p" || video.color_range !== "tv" || video.color_space !== "bt709"
137
180
  || video.color_transfer !== "bt709" || video.color_primaries !== "bt709"
138
181
  || video.width !== expected.width || video.height !== expected.height
139
182
  || 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
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
142
185
  || !Number.isSafeInteger(durationUs) || Math.abs(durationUs - expected.durationUs) > 50_000
143
186
  || 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
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
147
190
  || audio.channels !== 2 || audio.sample_rate !== "48000")) {
148
191
  throw new CliError("invalid-data", "Encoded scene failed dimensions, frame count, duration, color, or audio verification.");
149
192
  }
150
193
  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) } };
194
+ audio: audios.length === 0 ? null : { codec: "aac", channels: 2, sampleRateHz: 48000, startTimeUs: probeTimeUs(audios[0]!.start_time), durationUs: probeTimeUs(audios[0]!.duration) } };
152
195
  }
153
196
 
154
197
  function placement(asset: ProjectAssetV1, durationUs: number, suffix: string) {
@@ -200,9 +243,9 @@ export async function renderHtmlScene(
200
243
  });
201
244
  return await withMutationLock(jobDirectory, { command: "html render", label: source.request.name }, async lease => {
202
245
  const fence = async () => { await active(); await lease.assertOwned(); };
203
- const retainJson = async (path: string, value: unknown) => {
246
+ const retainJson = async (path: string, value: unknown, maximumBytes = 4 * 1024 * 1024) => {
204
247
  await fs.writeTextNoReplace!(path, `${canonicalJson(value)}\n`, fence);
205
- return await artifact(path, 4 * 1024 * 1024);
248
+ return await artifact(path, maximumBytes);
206
249
  };
207
250
  const run = async (argv: readonly [string, ...string[]]) => {
208
251
  await fence();
@@ -224,6 +267,8 @@ export async function renderHtmlScene(
224
267
  retainedResources.push({ ...resource, ...loaded.artifact, path: relative(application.paths.repositoryRoot, join(jobDirectory, destination)), absolutePath: join(jobDirectory, destination) });
225
268
  }
226
269
  let audio: Awaited<ReturnType<typeof ingestProjectMedia>> | undefined;
270
+ let authoring = source.authoring;
271
+ let audioReactivity: MediaArtifactReference | undefined;
227
272
  if (source.request.audio !== undefined) {
228
273
  audio = await ingestProjectMedia({ ffprobe: tool("ffprobe").command, now: application.clock.now(),
229
274
  projectDirectory: jobDirectory, repositoryRoot: application.paths.repositoryRoot, role: "music",
@@ -235,33 +280,69 @@ export async function renderHtmlScene(
235
280
  || soundtrack.segments[0]!.fileRange.startUs !== 0) {
236
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.");
237
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
+ }
238
316
  }
317
+ const retainedAudio = source.request.audio;
239
318
  const retainedInput = HtmlSceneInputSchema.parse({ ...source.request,
240
319
  document: { path: relative(application.paths.repositoryRoot, join(jobDirectory, "source/scene.html")) },
241
320
  resources: retainedResources.map(({ absolutePath: _absolutePath, bytes: _bytes, sha256: _sha256, ...resource }) => resource),
242
- ...(audio === undefined ? {} : { audio: { path: relative(application.paths.repositoryRoot, audio.absolutePath) } }),
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 } }) } }),
243
324
  });
244
325
  const retainedSource = await retainJson("source.json", retainedInput);
245
- const authoringReceipt = await retainJson("authoring.json", source.authoring);
326
+ const authoringReceipt = await retainJson("authoring.json", authoring);
246
327
  const intent = await retainJson("intent.json", { kind: "slopcamera.html-scene-intent", schemaVersion: 1,
247
328
  source: retainedSource, document: retainedDocument, authoring: authoringReceipt, tools, frameCount: source.frameCount,
248
329
  durationUs: source.durationUs, requestedDurationUs: source.request.timing.durationUs,
249
330
  timingPolicy: "ceil-frames-trim-or-pad-audio-v1" });
250
331
  dependencies.progress?.("frames");
251
332
  const frameDirectory = await ensurePhysicalPrivateDirectoryWithin(jobDirectory, "render");
252
- const frames = await application.htmlOverlayRenderer!.renderFrames({ authoring: source.authoring,
333
+ const frames = await application.htmlOverlayRenderer!.renderFrames({ authoring,
253
334
  browserRuntime, outputDirectory: frameDirectory, resources: retainedResources,
254
335
  ...(source.request.executionProfile === undefined ? {} : { executionProfile: source.request.executionProfile }) }, signal);
255
336
  await fence();
256
337
  if (frames.frameCount !== source.frameCount || frames.framePattern !== join(frameDirectory, "frames", "frame-%08d.png")) {
257
338
  throw new CliError("invalid-data", "Scene renderer returned an unexpected frame count or output location.");
258
339
  }
259
- const expectedIntegrity = createHtmlOverlayExecutionBundle(source.authoring, browserRuntime, source.request.executionProfile).integrity;
340
+ const expectedIntegrity = createHtmlOverlayExecutionBundle(authoring, browserRuntime, source.request.executionProfile).integrity;
260
341
  if (canonicalJsonSha256(HtmlOverlayExecutionIntegritySchema.parse(frames.executionIntegrity)) !== canonicalJsonSha256(expectedIntegrity)) {
261
342
  throw new CliError("conflict", "Scene renderer returned integrity evidence that differs from the bound browser execution.");
262
343
  }
263
344
  if (canonicalJsonSha256(HtmlOverlayActiveLibraryLocksSchema.parse(frames.libraryLocks))
264
- !== canonicalJsonSha256(source.authoring.libraries.map(getApprovedHtmlOverlayLibraryLock))) {
345
+ !== canonicalJsonSha256(authoring.libraries.map(getApprovedHtmlOverlayLibraryLock))) {
265
346
  throw new CliError("conflict", "Scene renderer returned library locks that differ from the authoring selection.");
266
347
  }
267
348
  assertHtmlOverlayGpuEvidenceProfile(source.request.executionProfile, frames.gpuEvidence);
@@ -289,14 +370,44 @@ export async function renderHtmlScene(
289
370
  const frameRate = String(source.request.timing.fps), duration = (source.durationUs / 1_000_000).toFixed(6);
290
371
  const background = source.request.background.replace("#", "0x");
291
372
  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");
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
+ })();
300
411
  if (await verifyFrames() !== frameSha256) throw new CliError("conflict", "Scene frame bytes changed during encoding.");
301
412
  const audioStream = audio?.asset.streams.find(stream => stream.kind === "audio");
302
413
  if (audio !== undefined) for (const stream of audio.asset.streams) for (const segment of stream.segments) {
@@ -348,14 +459,14 @@ export async function renderHtmlScene(
348
459
  const receipt = await retainJson("receipt.json", { kind: "slopcamera.html-scene-receipt", schemaVersion: 1,
349
460
  intent, source: retainedSource, document: retainedDocument, authoring: authoringReceipt, frameReceipt, video: videoArtifact, output,
350
461
  projectId, projectSha256: canonicalJsonSha256(project), planSha256: canonicalJsonSha256(plan), verification,
351
- audio: audio?.asset ?? null, timingPolicy: "ceil-frames-trim-or-pad-audio-v1" });
462
+ audio: audio?.asset ?? null, audioReactivity: audioReactivity ?? null, timingPolicy: "ceil-frames-trim-or-pad-audio-v1" });
352
463
  await projectFs.writeTextNoReplace!("html-scene.json", canonicalJson({ source: retainedSource, receipt }), fence);
353
464
  await projectFs.writeTextNoReplace!("edits/current.json", canonicalJson(plan), fence);
354
465
  await projectFs.writeTextNoReplace!("project.json", canonicalJson(project), fence);
355
466
  await fence();
356
467
  dependencies.progress?.("complete");
357
468
  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 };
469
+ source: retainedSource, audioReactivity: audioReactivity ?? null, projectId, projectPath: relative(application.paths.repositoryRoot, join(projectDirectory, "project.json")), verification };
359
470
  });
360
471
  }
361
472
 
@@ -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",