@hraness/slopcamera 3.2.7 → 3.3.1

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 (188) hide show
  1. package/NOTICE.md +1 -7
  2. package/PRIVACY.md +16 -7
  3. package/README.md +65 -20
  4. package/apps/desktop/README.md +14 -130
  5. package/apps/desktop/application/context.ts +6 -9
  6. package/apps/desktop/application/default-registry.ts +26 -10
  7. package/apps/desktop/application/operation.ts +14 -5
  8. package/apps/desktop/application/operations/index.ts +4 -1
  9. package/apps/desktop/application/operations/spatial-behavior.ts +151 -0
  10. package/apps/desktop/application/operations/spatial-direction.ts +222 -0
  11. package/apps/desktop/application/operations/spatial-rendered-audit.ts +174 -0
  12. package/apps/desktop/application/operations/spatial-review.ts +194 -0
  13. package/apps/desktop/application/operations/spatial-scene.ts +25 -1
  14. package/apps/desktop/application/registry.ts +2 -2
  15. package/apps/desktop/application/spatial-asset-admission.ts +201 -0
  16. package/apps/desktop/application/spatial-assets.ts +160 -31
  17. package/apps/desktop/application/spatial-render.ts +217 -20
  18. package/apps/desktop/application/spatial-rendered-audit.ts +273 -0
  19. package/apps/desktop/application/spatial-review.ts +361 -0
  20. package/apps/desktop/application/spatial-spz.ts +48 -3
  21. package/apps/desktop/application/spatial-world-import.ts +26 -5
  22. package/apps/desktop/application/spatial-world-metadata.ts +105 -0
  23. package/apps/desktop/cli/args.ts +528 -97
  24. package/apps/desktop/cli/capability-manifest.ts +374 -0
  25. package/apps/desktop/cli/cinema-renderer.ts +608 -0
  26. package/apps/desktop/cli/command-host-resources.ts +31 -2
  27. package/apps/desktop/cli/commands.ts +1215 -70
  28. package/apps/desktop/cli/gateway-review-provider.ts +334 -0
  29. package/apps/desktop/cli/help.ts +183 -23
  30. package/apps/desktop/cli/html-scene.ts +139 -28
  31. package/apps/desktop/cli/io.ts +2 -1
  32. package/apps/desktop/cli/main.ts +26 -50
  33. package/apps/desktop/cli/menubar.ts +244 -0
  34. package/apps/desktop/cli/music-analysis-service.ts +61 -0
  35. package/apps/desktop/cli/portable-surface.ts +18 -5
  36. package/apps/desktop/cli/spatial-asset-service.ts +124 -0
  37. package/apps/desktop/cli/spatial-design-service.ts +237 -0
  38. package/apps/desktop/cli/spatial-generate-service.ts +370 -0
  39. package/apps/desktop/cli/spatial-scene-service.ts +268 -4
  40. package/apps/desktop/cli/workflow-code.ts +1 -1
  41. package/apps/desktop/code/application-node-planner.ts +0 -47
  42. package/apps/desktop/code/host-source-layout.ts +46 -0
  43. package/apps/desktop/code/public.ts +0 -1
  44. package/apps/desktop/code/runtime-identity.ts +50 -8
  45. package/apps/desktop/code/semantic-builder.ts +24 -43
  46. package/apps/desktop/code/source-bundle.ts +23 -6
  47. package/apps/desktop/code/source-typecheck.ts +131 -43
  48. package/apps/desktop/code/worker-client.ts +14 -2
  49. package/apps/desktop/contracts/cinema.ts +736 -0
  50. package/apps/desktop/contracts/index.ts +1 -0
  51. package/apps/desktop/contracts/spatial-asset.ts +16 -0
  52. package/apps/desktop/contracts/spatial-world.ts +26 -3
  53. package/apps/desktop/core/cinema-plan.ts +1320 -0
  54. package/apps/desktop/core/index.ts +1 -0
  55. package/apps/desktop/core/music-analysis.ts +124 -1
  56. package/apps/desktop/core/storage.ts +75 -0
  57. package/apps/desktop/dist/cli/main.js +737 -291
  58. package/apps/desktop/html-overlay/audio-reactivity.ts +127 -0
  59. package/apps/desktop/html-overlay/index.ts +9 -0
  60. package/apps/desktop/html-overlay/runtime.ts +2 -0
  61. package/apps/desktop/html-overlay/scene.ts +29 -0
  62. package/apps/desktop/html-overlay/spatial.ts +619 -57
  63. package/apps/desktop/workflows/cinematic-world.ts +127 -0
  64. package/apps/desktop/workflows/index.ts +6 -0
  65. package/dist/cli.js +32 -434
  66. package/dist/code/advanced.js +1 -269
  67. package/dist/code/index.js +16 -2239
  68. package/dist/generate.js +1 -23
  69. package/dist/host-resources.js +1 -25
  70. package/dist/index-0bbesn4m.js +35 -0
  71. package/dist/index-296qhpf2.js +3 -0
  72. package/dist/index-3cxf2c58.js +3 -0
  73. package/dist/index-77fjfg9f.js +3 -0
  74. package/dist/index-80a36bc0.js +3 -0
  75. package/dist/index-a3vvs3rf.js +3 -0
  76. package/dist/index-b3geqczn.js +5 -0
  77. package/dist/index-cgpkyjqa.js +3 -0
  78. package/dist/index-f0ptv8dm.js +7 -0
  79. package/dist/index-grk47tgs.js +3 -0
  80. package/dist/index-qry58nj2.js +10 -0
  81. package/dist/index.js +1 -240
  82. package/dist/operations.js +1 -28
  83. package/dist/skill-install-gx7afxsd.js +2 -0
  84. package/dist/vectorize/worker.js +1 -141
  85. package/dist/workflow.js +1 -15
  86. package/docs/README.md +5 -1
  87. package/docs/studio.md +1 -1
  88. package/package.json +11 -24
  89. package/skills/slopcamera/SKILL.md +14 -1
  90. package/skills/slopcamera/references/diagrams.md +5 -14
  91. package/skills/slopcamera/references/directed-scenes.md +22 -2
  92. package/skills/slopcamera/references/gateway-media.md +15 -1
  93. package/skills/slopcamera/references/image-galleries.md +166 -0
  94. package/skills/slopcamera/references/install.md +2 -2
  95. package/skills/slopcamera/references/music-video.md +23 -3
  96. package/skills/slopcamera/references/parametric-design.md +14 -0
  97. package/skills/slopcamera/references/reference-led-3d.md +1 -1
  98. package/skills/slopcamera/references/scene-building.md +32 -0
  99. package/skills/slopcamera/references/social-collage-banners.md +204 -0
  100. package/skills/slopcamera/references/support.md +25 -0
  101. package/skills/slopcamera/references/web-media-excerpts.md +120 -0
  102. package/skills/slopcamera/references/workflows-sdk.md +3 -3
  103. package/skills/slopcamera/scripts/compose-social-collage-banner.ts +728 -0
  104. package/src/capability-manifest.ts +361 -0
  105. package/src/cli.ts +250 -102
  106. package/src/code/canonical-json.ts +9 -0
  107. package/src/generate.ts +2 -2
  108. package/src/icon.ts +1111 -0
  109. package/src/image-gallery.ts +1049 -0
  110. package/src/index.ts +11 -23
  111. package/src/mcp/boundary.ts +11 -11
  112. package/src/mcp/index.ts +3 -0
  113. package/src/mcp/server.ts +2 -1
  114. package/src/mcp/tools.ts +1642 -130
  115. package/src/mcp/types.ts +19 -0
  116. package/src/operations.ts +412 -0
  117. package/src/portable-capability-manifest.ts +67 -0
  118. package/src/scene-gallery.ts +184 -0
  119. package/src/spatial-scene/asset-admission.ts +183 -0
  120. package/src/spatial-scene/audit-rendered.ts +692 -0
  121. package/src/spatial-scene/audit.ts +617 -0
  122. package/src/spatial-scene/behavior-audit.ts +252 -0
  123. package/src/spatial-scene/behavior-bake.ts +233 -0
  124. package/src/spatial-scene/behavior-fns.ts +585 -0
  125. package/src/spatial-scene/behavior-gallery.ts +109 -0
  126. package/src/spatial-scene/behavior-stdlib.ts +323 -0
  127. package/src/spatial-scene/behavior-trace.ts +325 -0
  128. package/src/spatial-scene/behavior.ts +618 -0
  129. package/src/spatial-scene/build.ts +632 -0
  130. package/src/spatial-scene/camera-rig.ts +125 -0
  131. package/src/spatial-scene/camera-track.ts +4 -2
  132. package/src/spatial-scene/character.ts +124 -0
  133. package/src/spatial-scene/contracts.ts +75 -7
  134. package/src/spatial-scene/design-templates.ts +351 -0
  135. package/src/spatial-scene/design.ts +327 -0
  136. package/src/spatial-scene/direction-compile.ts +584 -0
  137. package/src/spatial-scene/direction.ts +122 -0
  138. package/src/spatial-scene/effects.ts +149 -0
  139. package/src/spatial-scene/evaluate.ts +109 -29
  140. package/src/spatial-scene/gallery.ts +277 -0
  141. package/src/spatial-scene/generate.ts +239 -0
  142. package/src/spatial-scene/geometry-native.ts +165 -0
  143. package/src/spatial-scene/geometry.ts +1216 -0
  144. package/src/spatial-scene/gltf.ts +600 -77
  145. package/src/spatial-scene/identity.ts +55 -2
  146. package/src/spatial-scene/index.ts +36 -0
  147. package/src/spatial-scene/inspect.ts +35 -6
  148. package/src/spatial-scene/material-lighting.ts +609 -0
  149. package/src/spatial-scene/math.ts +21 -4
  150. package/src/spatial-scene/motion-evidence.ts +88 -0
  151. package/src/spatial-scene/parametric.ts +765 -0
  152. package/src/spatial-scene/particle-preparation.ts +183 -0
  153. package/src/spatial-scene/particle.ts +195 -0
  154. package/src/spatial-scene/patch.ts +109 -15
  155. package/src/spatial-scene/performance.ts +1613 -0
  156. package/src/spatial-scene/probe.ts +137 -0
  157. package/src/spatial-scene/recipe-pack.ts +86 -0
  158. package/src/spatial-scene/render-effects.ts +240 -0
  159. package/src/spatial-scene/review.ts +639 -0
  160. package/src/spatial-scene/simulation-bake.ts +206 -0
  161. package/src/spatial-scene/simulation.ts +187 -0
  162. package/src/spatial-scene/solve.ts +366 -0
  163. package/src/spatial-scene/temporal-audit.ts +354 -0
  164. package/src/support-completion.ts +16 -0
  165. package/src/support.ts +42 -0
  166. package/src/version.ts +1 -1
  167. package/apps/desktop/application/operations/recording/index.ts +0 -5
  168. package/apps/desktop/application/operations/recording/pause.ts +0 -34
  169. package/apps/desktop/application/operations/recording/resume.ts +0 -34
  170. package/apps/desktop/application/operations/recording/shared.ts +0 -199
  171. package/apps/desktop/application/operations/recording/start.ts +0 -169
  172. package/apps/desktop/application/operations/recording/stop.ts +0 -34
  173. package/apps/desktop/capture/protocol.ts +0 -689
  174. package/apps/desktop/cli/capture-bundle.ts +0 -1117
  175. package/apps/desktop/cli/recording-controller.ts +0 -1567
  176. package/apps/desktop/cli/recording-daemon.ts +0 -931
  177. package/dist/index-42zsesc1.js +0 -2257
  178. package/dist/index-7308egqr.js +0 -48
  179. package/dist/index-8txs6fkn.js +0 -712
  180. package/dist/index-mcy8z0br.js +0 -192
  181. package/dist/index-p63wavx0.js +0 -2029
  182. package/dist/index-r7gdhmsp.js +0 -424
  183. package/dist/index-rvghzt7b.js +0 -2013
  184. package/dist/index-sh6xbav6.js +0 -1219
  185. package/dist/index-z1w83f81.js +0 -4
  186. package/dist/index-z7239b4h.js +0 -2363
  187. package/dist/skill-install-dh0ntqba.js +0 -10
  188. package/src/desktop.ts +0 -253
@@ -0,0 +1,244 @@
1
+ import { closeSync, constants, fstatSync, lstatSync, mkdirSync, mkdtempSync, openSync, readSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
+ import type { Stats } from "node:fs";
3
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
4
+ import { CliError } from "./errors";
5
+ import type { CliIo } from "./io";
6
+ import { writeJson, writeLine } from "./io";
7
+ import { ensurePrivateDirectory } from "./paths";
8
+
9
+ const SETTLE_MS = 400;
10
+ const LABEL = "com.hraness.slopcamera.menubar";
11
+
12
+ function xml(value: string): string { return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&apos;"); }
13
+ function exists(path: string): boolean { try { const info = lstatSync(path); return isAbsolute(path) && realpathSync(path) === resolve(path) && (info.uid === process.getuid?.() || info.uid === 0) && info.isFile() && (info.mode & 0o111) !== 0 && (info.mode & 0o022) === 0; } catch { return false; } }
14
+
15
+ /** The directory agents write user-facing outputs into. */
16
+ export function outputsRoot(stateRoot: string): string {
17
+ const productRoot = stateRoot.endsWith(`${sep}cli`) ? dirname(stateRoot) : stateRoot;
18
+ return join(productRoot, "outputs");
19
+ }
20
+ export function resolveMenubarBinary(repositoryRoot: string, environment: Readonly<Record<string, string | undefined>> = process.env): string | null {
21
+ const explicit = environment.SLOPCAMERA_MENUBAR;
22
+ if (explicit !== undefined) return explicit !== "" && exists(explicit) ? explicit : null;
23
+ const candidates = [ resolve(dirname(process.execPath), "slopcamera-menubar"), ...(environment.HOME === undefined || environment.HOME === "" ? [] : [installedBinaryPath(environment)]), resolve(repositoryRoot, "desktop", "target", "release", "slopcamera-menubar")];
24
+ for (const candidate of candidates) if (candidate !== undefined && candidate !== "" && exists(candidate)) return candidate;
25
+ return null;
26
+ }
27
+ export function launchAgentPath(environment: Readonly<Record<string, string | undefined>> = process.env): string {
28
+ const home = environment.HOME; if (home === undefined || home === "" || !isAbsolute(home)) throw new CliError("unavailable", "HOME is required for a per-user LaunchAgent.");
29
+ return join(home, "Library", "LaunchAgents", `${LABEL}.plist`);
30
+ }
31
+ export function installedBinaryPath(environment: Readonly<Record<string, string | undefined>> = process.env): string {
32
+ const home = environment.HOME; if (home === undefined || home === "" || !isAbsolute(home)) throw new CliError("unavailable", "HOME is required for a per-user menu-bar install.");
33
+ return join(home, "Library", "Application Support", "Slopcamera", "menubar", "slopcamera-menubar");
34
+ }
35
+ export function launchAgentPlist(binary: string): string {
36
+ return `<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0"><dict><key>Label</key><string>${xml(LABEL)}</string><key>ProgramArguments</key><array><string>${xml(binary)}</string></array><key>RunAtLoad</key><true/><key>KeepAlive</key><false/><key>ProcessType</key><string>Interactive</string><key>LimitLoadToSessionType</key><string>Aqua</string></dict></plist>\n`;
37
+ }
38
+ type Environment = Readonly<Record<string, string | undefined>>;
39
+ const MAX_BINARY_BYTES = 128 * 1024 * 1024;
40
+
41
+ function infoAt(path: string): Stats | null {
42
+ try { return lstatSync(path); } catch (error) {
43
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
44
+ throw error;
45
+ }
46
+ }
47
+
48
+ /** Existing shared directories retain their permissions; links are never followed. */
49
+ function checkParents(path: string, environment: Environment, create = false): void {
50
+ const home = environment.HOME;
51
+ if (!home || !isAbsolute(home) || realpathSync(home) !== resolve(home)) {
52
+ throw new CliError("unsafe-path", "HOME must be an absolute physical directory.");
53
+ }
54
+ const suffix = relative(home, dirname(path));
55
+ if (suffix.startsWith(`..${sep}`) || suffix === ".." || isAbsolute(suffix)) {
56
+ throw new CliError("unsafe-path", "The menu-bar install must remain under HOME.");
57
+ }
58
+ let cursor = home;
59
+ for (const part of ["", ...suffix.split(sep)]) {
60
+ if (part) cursor = join(cursor, part);
61
+ let info = infoAt(cursor);
62
+ if (info === null) {
63
+ if (!create) return;
64
+ mkdirSync(cursor, { mode: 0o700 });
65
+ info = lstatSync(cursor);
66
+ }
67
+ if (!info.isDirectory() || info.uid !== process.getuid?.() || (info.mode & 0o022) !== 0) {
68
+ throw new CliError("unsafe-path", "Menu-bar install directories must be owned physical directories without shared write access.");
69
+ }
70
+ }
71
+ }
72
+
73
+ function assertOwnedFile(path: string): void {
74
+ const info = infoAt(path);
75
+ if (info !== null && (!info.isFile() || info.nlink !== 1 || info.uid !== process.getuid?.() || (info.mode & 0o022) !== 0)) {
76
+ throw new CliError("unsafe-path", "The menu-bar install contains an unsafe file.");
77
+ }
78
+ }
79
+
80
+ function readRegular(path: string, maximum: number, executable = false): Buffer {
81
+ if (!isAbsolute(path) || realpathSync(path) !== resolve(path) || Buffer.byteLength(path) > 4096 || /[\u0000-\u001f\u007f{}"\\]/u.test(path)) {
82
+ throw new CliError("unsafe-path", "The menu-bar file must have a bounded physical absolute path.");
83
+ }
84
+ const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
85
+ try {
86
+ const info = fstatSync(fd);
87
+ if (!info.isFile() || (info.uid !== process.getuid?.() && info.uid !== 0) || info.size > maximum || info.size === 0 || (info.mode & 0o022) !== 0 || (executable && (info.mode & 0o111) === 0)) {
88
+ throw new CliError("unsafe-path", "The menu-bar file must be bounded, regular, and protected from shared writes.");
89
+ }
90
+ const bytes = Buffer.alloc(info.size + 1);
91
+ let count = 0;
92
+ while (count < bytes.length) {
93
+ const read = readSync(fd, bytes, count, bytes.length - count, count);
94
+ if (read === 0) break;
95
+ count += read;
96
+ }
97
+ const after = fstatSync(fd);
98
+ if (count !== info.size || after.size !== info.size || after.mtimeMs !== info.mtimeMs || after.ctimeMs !== info.ctimeMs) {
99
+ throw new CliError("conflict", "The menu-bar file changed while it was being read.");
100
+ }
101
+ return bytes.subarray(0, count);
102
+ } finally { closeSync(fd); }
103
+ }
104
+
105
+ function readAgent(path: string, environment: Environment): string | null {
106
+ checkParents(path, environment);
107
+ if (infoAt(path) === null) return null;
108
+ assertOwnedFile(path);
109
+ return readRegular(path, 16 * 1024).toString("utf8");
110
+ }
111
+
112
+ /** Configuration state only; it does not claim the launchd service is running. */
113
+ export type LaunchAgentState = "absent" | "installed" | "conflict";
114
+ export type LaunchctlRunner = (args: readonly string[], allowMissing?: boolean) => string | null;
115
+ function launchctl(args: readonly string[], allowMissing = false): string | null {
116
+ const uid = process.getuid?.();
117
+ if (uid === undefined) throw new CliError("unavailable", "The current user has no launchd GUI domain.");
118
+ const result = Bun.spawnSync(["/bin/launchctl", ...args.map((arg) => arg.replaceAll("{uid}", String(uid)))], { stdin: "ignore", stdout: "pipe", stderr: "pipe", timeout: 10_000, maxBuffer: 64 * 1024 });
119
+ return interpretLaunchctlResult(result, uid, allowMissing);
120
+ }
121
+ export function interpretLaunchctlResult(result: { success: boolean; exitCode: number; stdout: Uint8Array; stderr: Uint8Array }, uid: number, allowMissing: boolean): string | null {
122
+ if (result.stdout.byteLength + result.stderr.byteLength > 64 * 1024) throw new CliError("unavailable", "launchctl exceeded the bounded response size.");
123
+ if (result.success && result.exitCode === 0) return Buffer.from(result.stdout).toString();
124
+ if (allowMissing && result.exitCode === 113 && result.stdout.length === 0 && Buffer.from(result.stderr).toString() === `Bad request.\nCould not find service "${LABEL}" in domain for user gui: ${uid}\n`) return null;
125
+ throw new CliError("unavailable", "launchctl could not reconcile the Slopcamera menu-bar LaunchAgent. Run menubar install again to retry.");
126
+ }
127
+
128
+ function loadedOwned(path: string, binary: string, current: string | null, run: LaunchctlRunner): boolean {
129
+ const output = run(["print", `gui/{uid}/${LABEL}`], true);
130
+ if (output === null) return false;
131
+ const uid = process.getuid?.();
132
+ if (current === null || Buffer.byteLength(output) > 64 * 1024 || !output.startsWith(`gui/${uid}/${LABEL} = {\n`)) {
133
+ throw new CliError("conflict", "The loaded menu-bar service has an unverified identity.");
134
+ }
135
+ const lines = output.split("\n").map(line => line.trim());
136
+ const field = (key: string): string | null => {
137
+ const values = lines.filter(line => line.startsWith(`${key} = `));
138
+ return values.length === 1 ? values[0]!.slice(key.length + 3) : null;
139
+ };
140
+ const argumentsStart = lines.indexOf("arguments = {");
141
+ const argumentsEnd = lines.indexOf("}", argumentsStart + 1);
142
+ if (field("path") !== path || field("program") !== binary || argumentsStart < 0 || argumentsEnd !== argumentsStart + 2 || lines[argumentsStart + 1] !== binary) {
143
+ throw new CliError("conflict", "The loaded menu-bar service does not match this install; it was left untouched.");
144
+ }
145
+ return true;
146
+ }
147
+ export function launchAgentState(binary: string, environment: Environment = process.env): LaunchAgentState {
148
+ const content = readAgent(launchAgentPath(environment), environment);
149
+ if (content === null) return "absent";
150
+ checkParents(binary, environment);
151
+ assertOwnedFile(binary);
152
+ return content === launchAgentPlist(binary) && exists(binary) ? "installed" : "conflict";
153
+ }
154
+ function assertMac(platform: NodeJS.Platform): void {
155
+ if (platform !== "darwin") throw new CliError("unsupported-plan", "Slopcamera menu-bar LaunchAgents are supported only on macOS.");
156
+ }
157
+ export function installLaunchAgent(binary: string, environment: Environment = process.env, platform: NodeJS.Platform = process.platform, runLaunchctl: LaunchctlRunner = launchctl): LaunchAgentState {
158
+ assertMac(platform);
159
+ if (!isAbsolute(binary)) throw new CliError("unsafe-path", "LaunchAgent binary must be an absolute path.");
160
+ const stable = installedBinaryPath(environment);
161
+ const path = launchAgentPath(environment);
162
+ const current = readAgent(path, environment);
163
+ const expected = launchAgentPlist(stable);
164
+ checkParents(stable, environment);
165
+ assertOwnedFile(stable);
166
+ if (current !== null && current !== expected || current === null && infoAt(stable) !== null) {
167
+ throw new CliError("conflict", "The existing Slopcamera menu-bar install is not owned by this command.");
168
+ }
169
+ const bytes = readRegular(binary, MAX_BINARY_BYTES, true);
170
+ loadedOwned(path, stable, current, runLaunchctl);
171
+ checkParents(path, environment, true);
172
+ checkParents(stable, environment, true);
173
+ // Unique private staging directories prevent predictable temporary-file clobbering.
174
+ const binaryStage = mkdtempSync(join(dirname(stable), ".install-"));
175
+ let agentStage: string | undefined;
176
+ try {
177
+ agentStage = mkdtempSync(join(dirname(path), ".slopcamera-install-"));
178
+ const stagedBinary = join(binaryStage, "binary");
179
+ const stagedAgent = join(agentStage, "agent.plist");
180
+ writeFileSync(stagedBinary, bytes, { mode: 0o700, flag: "wx" });
181
+ writeFileSync(stagedAgent, expected, { mode: 0o600, flag: "wx" });
182
+ if (readAgent(path, environment) !== current) throw new CliError("conflict", "The menu-bar install changed concurrently.");
183
+ assertOwnedFile(stable);
184
+ // Retry bootstrap even when the plist matches: a previous bootstrap may have failed.
185
+ if (loadedOwned(path, stable, current, runLaunchctl)) runLaunchctl(["bootout", `gui/{uid}/${LABEL}`], true);
186
+ renameSync(stagedBinary, stable);
187
+ renameSync(stagedAgent, path);
188
+ runLaunchctl(["bootstrap", "gui/{uid}", path]);
189
+ return "installed";
190
+ } finally {
191
+ rmSync(binaryStage, { recursive: true });
192
+ if (agentStage !== undefined) rmSync(agentStage, { recursive: true });
193
+ }
194
+ }
195
+ export function uninstallLaunchAgent(_binary: string, environment: Environment = process.env, platform: NodeJS.Platform = process.platform, runLaunchctl: LaunchctlRunner = launchctl): LaunchAgentState {
196
+ assertMac(platform);
197
+ const stable = installedBinaryPath(environment);
198
+ const path = launchAgentPath(environment);
199
+ const current = readAgent(path, environment);
200
+ if (current === null) return "absent";
201
+ if (current !== launchAgentPlist(stable)) throw new CliError("conflict", "The existing Slopcamera menu-bar LaunchAgent is not owned by this command.");
202
+ checkParents(stable, environment);
203
+ assertOwnedFile(stable);
204
+ if (loadedOwned(path, stable, current, runLaunchctl)) runLaunchctl(["bootout", `gui/{uid}/${LABEL}`], true);
205
+ if (readAgent(path, environment) !== current) throw new CliError("conflict", "The menu-bar install changed concurrently.");
206
+ assertOwnedFile(stable);
207
+ rmSync(path);
208
+ if (infoAt(stable) !== null) rmSync(stable);
209
+ return "absent";
210
+ }
211
+ async function runBinary(binary: string, foreground: boolean): Promise<number> {
212
+ let child: Bun.Subprocess;
213
+ try { child = Bun.spawn([binary], foreground ? { stdin: "inherit", stdout: "inherit", stderr: "inherit" } : { stdin: "ignore", stdout: "ignore", stderr: "ignore" }); } catch { throw new CliError("unavailable", "The Slopcamera menu bar could not start."); }
214
+ if (!foreground) { child.unref(); const settled = await Promise.race([child.exited.then((code) => code as number | null), Bun.sleep(SETTLE_MS).then(() => null)]); if (settled !== null) throw new CliError("unavailable", "The Slopcamera menu bar exited during startup."); return 0; }
215
+ return await child.exited;
216
+ }
217
+ export async function launchMenubar(io: CliIo, repositoryRoot: string, asJson: boolean, mode: "foreground" | "background" = "foreground"): Promise<void> {
218
+ const binary = resolveMenubarBinary(repositoryRoot, io.env);
219
+ if (binary === null) throw new CliError("unavailable", "The Slopcamera menu bar is not installed. Build it separately or set SLOPCAMERA_MENUBAR.");
220
+ const code = await runBinary(binary, mode === "foreground");
221
+ if (code !== 0) throw new CliError("unavailable", "The Slopcamera menu bar exited during startup.");
222
+ if (asJson) writeJson(io, { running: mode === "background", foreground: mode === "foreground" });
223
+ else writeLine(io, mode === "foreground" ? "Slopcamera menu bar exited." : "Slopcamera menu bar is running.");
224
+ }
225
+ export async function manageMenubar(io: CliIo, repositoryRoot: string, action: "install" | "uninstall" | "status", asJson: boolean): Promise<void> {
226
+ assertMac(io.platform);
227
+ const stable = installedBinaryPath(io.env);
228
+ let state: LaunchAgentState;
229
+ if (action === "install") {
230
+ const binary = resolveMenubarBinary(repositoryRoot, io.env);
231
+ if (binary === null) throw new CliError("unavailable", "The Slopcamera menu bar is not installed. Build it separately or set SLOPCAMERA_MENUBAR.");
232
+ state = installLaunchAgent(binary, io.env, io.platform);
233
+ } else {
234
+ state = action === "uninstall" ? uninstallLaunchAgent(stable, io.env, io.platform) : launchAgentState(stable, io.env);
235
+ }
236
+ if (asJson) writeJson(io, { launchAgent: state });
237
+ else writeLine(io, `Slopcamera menu-bar LaunchAgent: ${state}.`);
238
+ }
239
+ export async function reportOutputsRoot(io: CliIo, stateRoot: string, asJson: boolean): Promise<void> {
240
+ const directory = outputsRoot(stateRoot);
241
+ await ensurePrivateDirectory(directory);
242
+ if (asJson) writeJson(io, { outputs: directory });
243
+ else writeLine(io, directory);
244
+ }
@@ -15,18 +15,25 @@ import {
15
15
  } from "../contracts";
16
16
  import {
17
17
  analyzeMusic,
18
+ analyzeAudioReactivity,
19
+ AUDIO_REACTIVITY_MAX_DURATION_US,
20
+ AUDIO_REACTIVITY_SAMPLE_RATE_HZ,
18
21
  canonicalSlopcameraPersistenceDocument,
19
22
  canonicalJson,
20
23
  canonicalJsonSha256,
24
+ createNodeBundleFileSystem,
21
25
  saveAnalysisArtifact,
22
26
  saveVideoProject,
23
27
  sha256Hex,
24
28
  type MonoPcm,
25
29
  } from "../core";
30
+ import { HtmlOverlayAudioReactivitySchema } from "../html-overlay/audio-reactivity";
26
31
  import { decodeAlignmentPcm, resolveAudioAnalysisSubject } from "./audio-analysis";
27
32
  import { CliError } from "./errors";
28
33
  import type { ProcessRunner } from "./io";
34
+ import { SELF_CONTAINED_MEDIA_INPUT_ARGUMENTS } from "./media-ingest-model";
29
35
  import { ensurePhysicalPrivateDirectoryWithin } from "./paths";
36
+ import { resolveVerifiedProjectMedia, type PhysicalProjectMediaFingerprint } from "./project-media-integrity";
30
37
  import { projectAnalysisPath, type OpenProject } from "./project-service";
31
38
 
32
39
  export const MUSIC_ANALYSIS_SAMPLE_RATE_HZ = 8_000;
@@ -174,6 +181,60 @@ export async function readMonoFloat32PcmBounded(
174
181
  }
175
182
  }
176
183
 
184
+ const MAX_AUDIO_REACTIVITY_PCM_BYTES = 600 * AUDIO_REACTIVITY_SAMPLE_RATE_HZ * 4;
185
+
186
+ /** Decode one verified retained soundtrack prefix for the optional bands-v1 sidecar. */
187
+ export async function analyzeRetainedAudioReactivity(options: {
188
+ readonly repositoryRoot: string;
189
+ readonly jobDirectory: string;
190
+ readonly audio: PhysicalProjectMediaFingerprint;
191
+ readonly durationUs: number;
192
+ readonly streamIndex: number;
193
+ readonly ffmpeg: string;
194
+ readonly run: (argv: readonly [string, ...string[]]) => Promise<unknown>;
195
+ readonly fence: () => Promise<void>;
196
+ }) {
197
+ if (!Number.isSafeInteger(options.durationUs) || options.durationUs < 1
198
+ || options.durationUs > AUDIO_REACTIVITY_MAX_DURATION_US
199
+ || !Number.isSafeInteger(options.streamIndex) || options.streamIndex < 0) {
200
+ throw new CliError("invalid-data", "Audio reactivity duration or stream index is outside its bound.");
201
+ }
202
+ const verify = async () => {
203
+ await options.fence();
204
+ return await resolveVerifiedProjectMedia({ repositoryRoot: options.repositoryRoot,
205
+ path: options.audio.path, expected: options.audio, label: "Audio reactivity soundtrack" });
206
+ };
207
+ const source = await verify();
208
+ const pcmPath = join(options.jobDirectory, "audio-reactivity.f32le");
209
+ const fs = createNodeBundleFileSystem(options.jobDirectory);
210
+ try {
211
+ await options.run([options.ffmpeg, "-nostdin", "-v", "error", "-n", "-threads", "2",
212
+ ...SELF_CONTAINED_MEDIA_INPUT_ARGUMENTS, "-i", source, "-map", `0:${options.streamIndex}`,
213
+ "-vn", "-sn", "-dn", "-t", (options.durationUs / 1_000_000).toFixed(6),
214
+ "-ac", "1", "-ar", String(AUDIO_REACTIVITY_SAMPLE_RATE_HZ), "-c:a", "pcm_f32le", "-f", "f32le",
215
+ "-fs", String(MAX_AUDIO_REACTIVITY_PCM_BYTES + 4), pcmPath]);
216
+ await verify();
217
+ const before = await fs.inspectFile!("audio-reactivity.f32le", MAX_AUDIO_REACTIVITY_PCM_BYTES);
218
+ const pcm = await readMonoFloat32PcmBounded(pcmPath, { maxBytes: MAX_AUDIO_REACTIVITY_PCM_BYTES, sampleRateHz: AUDIO_REACTIVITY_SAMPLE_RATE_HZ });
219
+ const expectedSamples = Math.round(options.durationUs * AUDIO_REACTIVITY_SAMPLE_RATE_HZ / 1_000_000);
220
+ if (Math.abs(pcm.samples.length - expectedSamples) > 2) {
221
+ throw new CliError("invalid-data", "Audio reactivity decode did not cover the requested soundtrack prefix.");
222
+ }
223
+ const analysis = analyzeAudioReactivity(pcm);
224
+ await options.fence();
225
+ const after = await fs.inspectFile!("audio-reactivity.f32le", MAX_AUDIO_REACTIVITY_PCM_BYTES);
226
+ if (before.bytes !== after.bytes || before.sha256 !== after.sha256) {
227
+ throw new CliError("conflict", "Audio reactivity PCM changed during analysis.");
228
+ }
229
+ return HtmlOverlayAudioReactivitySchema.parse({ kind: "slopcamera.audio-reactivity", schemaVersion: 1,
230
+ profile: "bands-v1", rateHz: 60, ...analysis,
231
+ source: { bytes: options.audio.bytes, sha256: options.audio.sha256 },
232
+ pcm: { sampleCount: pcm.samples.length, sha256: before.sha256 } });
233
+ } finally {
234
+ await rm(pcmPath, { force: true });
235
+ }
236
+ }
237
+
177
238
  export interface WithDecodedMusicPcmOptions {
178
239
  readonly ffmpeg: string;
179
240
  readonly maxDecodedPcmBytes?: number;
@@ -1,3 +1,5 @@
1
+ import { reportUsefulResult, type UsefulResultObserver } from "../../../src/support-completion";
2
+
1
3
  import { constants } from "node:fs";
2
4
  import { mkdir, open } from "node:fs/promises";
3
5
  import { dirname, resolve } from "node:path";
@@ -16,7 +18,10 @@ import { CliError } from "./errors";
16
18
 
17
19
  const HEADLESS_SLOPCAMERA_CLI_MODULE = "@hraness/slopcamera/cli";
18
20
 
19
- async function runHeadlessSlopcameraCli(argv: readonly string[]): Promise<void> {
21
+ async function runHeadlessSlopcameraCli(
22
+ argv: readonly string[],
23
+ options?: Readonly<{ onUsefulResult?: UsefulResultObserver }>,
24
+ ): Promise<void> {
20
25
  // Keep this as a runtime package import so the headless CLI retains its own
21
26
  // package-relative skill and asset resolution inside an installed bundle.
22
27
  const module: unknown = await import(HEADLESS_SLOPCAMERA_CLI_MODULE);
@@ -28,13 +33,14 @@ async function runHeadlessSlopcameraCli(argv: readonly string[]): Promise<void>
28
33
  ) {
29
34
  throw new CliError("unavailable", "The portable Slopcamera CLI is unavailable.");
30
35
  }
31
- await module.main(argv);
36
+ await module.main(argv, options);
32
37
  }
33
38
 
34
39
  export interface PortableSurfaceDependencies {
40
+ readonly onUsefulResult?: UsefulResultObserver;
35
41
  readonly cwd?: () => string;
36
42
  readonly log?: (value: string) => void;
37
- readonly runHeadless?: (argv: readonly string[]) => Promise<void>;
43
+ readonly runHeadless?: (argv: readonly string[], options?: Readonly<{ onUsefulResult?: UsefulResultObserver }>) => Promise<void>;
38
44
  readonly writeScaffold?: (path: string, html: string) => Promise<void>;
39
45
  }
40
46
  function optionValue(argv: readonly string[], name: string): string | undefined {
@@ -187,6 +193,7 @@ async function runHtmlScaffold(
187
193
  createHtmlOverlayScaffold(kind),
188
194
  );
189
195
  (dependencies.log ?? console.log)(`Created ${outputPath}`);
196
+ reportUsefulResult(dependencies.onUsefulResult);
190
197
  return 0;
191
198
  }
192
199
 
@@ -228,14 +235,20 @@ export async function runPortableSurface(
228
235
  )
229
236
  || (
230
237
  argv[0] === "image"
231
- && (argv[1] === "vectorize" || argv[1] === "generate")
238
+ && (
239
+ argv[1] === "vectorize" ||
240
+ argv[1] === "generate" ||
241
+ argv[1] === "icon" ||
242
+ argv[1] === "gallery"
243
+ )
232
244
  );
233
245
  if (!delegatesToHeadless) return undefined;
234
246
 
235
247
  const previousExitCode = process.exitCode;
236
248
  process.exitCode = undefined;
237
249
  try {
238
- await (dependencies.runHeadless ?? runHeadlessSlopcameraCli)(argv);
250
+ await (dependencies.runHeadless ?? runHeadlessSlopcameraCli)(argv,
251
+ dependencies.onUsefulResult === undefined ? {} : { onUsefulResult: dependencies.onUsefulResult });
239
252
  return process.exitCode ?? 0;
240
253
  } finally {
241
254
  process.exitCode = previousExitCode;
@@ -0,0 +1,124 @@
1
+ import { lstat, realpath } from "node:fs/promises";
2
+ import { dirname, isAbsolute, relative, resolve } from "node:path";
3
+
4
+ import type { ApplicationContext } from "../application/context";
5
+ import { admitSpatialAssetFile, SpatialAssetAdmissionError } from "../application/spatial-asset-admission";
6
+ import { SpatialAssetAdmissionV1Schema } from "../contracts/spatial-asset";
7
+ import type { SpatialAssetCommand } from "./args";
8
+ import { CliError } from "./errors";
9
+ import { displayPath, ensurePhysicalPrivateDirectoryWithin, resolveSafePath } from "./paths";
10
+ import { publishSpatialSource } from "./spatial-scene-service";
11
+
12
+ function isWithin(root: string, candidate: string): boolean {
13
+ const pathRelative = relative(root, candidate);
14
+ return pathRelative === "" || (!pathRelative.startsWith("..") && !isAbsolute(pathRelative));
15
+ }
16
+
17
+ function isMissing(error: unknown): boolean {
18
+ return error instanceof Error && "code" in error && error.code === "ENOENT";
19
+ }
20
+
21
+ /** A source root must exist and be physical before bounding one file below it. */
22
+ async function physicalSourceRoot(path: string): Promise<string> {
23
+ let details;
24
+ try { details = await lstat(path); }
25
+ catch (error) {
26
+ if (isMissing(error)) throw new CliError("not-found", `--source-root is not a directory: ${path}`);
27
+ throw error;
28
+ }
29
+ if (details.isSymbolicLink() || !details.isDirectory()) {
30
+ throw new CliError("unsafe-path", `--source-root must be a physical directory without symlinks: ${path}`);
31
+ }
32
+ return await realpath(path);
33
+ }
34
+
35
+ /**
36
+ * Admit one explicit local GLB below --source-root (default: the project root),
37
+ * publish its exact bytes and derived facts beside --output, and write the
38
+ * deterministic admission document without replacing any existing path.
39
+ */
40
+ export async function executeSpatialAssetCommand(
41
+ application: ApplicationContext,
42
+ command: SpatialAssetCommand,
43
+ adapter: Readonly<{ signal?: AbortSignal }>,
44
+ ): Promise<unknown> {
45
+ const repositoryRoot = application.paths.repositoryRoot;
46
+ const signal = adapter.signal ?? new AbortController().signal;
47
+ const fence = async () => {
48
+ signal.throwIfAborted();
49
+ await application.hostResourceLease?.assertOwned();
50
+ signal.throwIfAborted();
51
+ };
52
+ try {
53
+ signal.throwIfAborted();
54
+ const sourceRoot = await physicalSourceRoot(resolve(repositoryRoot, command.sourceRoot ?? "."));
55
+ const source = resolve(sourceRoot, command.file);
56
+ let physicalSource: string;
57
+ try { physicalSource = await realpath(source); }
58
+ catch (error) {
59
+ if (isMissing(error)) throw new CliError("not-found", `Admitted asset does not exist: ${command.file}`);
60
+ throw error;
61
+ }
62
+ if (!isWithin(sourceRoot, physicalSource)) {
63
+ throw new CliError("unsafe-path", `Admitted asset escapes --source-root: ${command.file}`);
64
+ }
65
+ if (physicalSource !== source) {
66
+ throw new CliError("unsafe-path", `Admitted asset must be a physical file without symlink components: ${command.file}`);
67
+ }
68
+ if (!(await lstat(physicalSource)).isFile()) {
69
+ throw new CliError("invalid-data", `Admitted asset is not a regular file: ${command.file}`);
70
+ }
71
+ const output = await resolveSafePath(repositoryRoot, command.output);
72
+ try {
73
+ await lstat(output);
74
+ throw new CliError("conflict", "Scene asset admission output already exists. Choose a new path to retain both revisions.");
75
+ } catch (error) {
76
+ if (error instanceof CliError) throw error;
77
+ if (!isMissing(error)) throw error;
78
+ }
79
+ const outputParent = relative(repositoryRoot, dirname(output));
80
+ const assetRoot = outputParent === ""
81
+ ? repositoryRoot
82
+ : await ensurePhysicalPrivateDirectoryWithin(repositoryRoot, outputParent);
83
+ const result = await admitSpatialAssetFile({
84
+ filePath: physicalSource, assetRoot,
85
+ ...(command.assetId === undefined ? {} : { assetId: command.assetId }),
86
+ metersPerUnit: command.metersPerUnit, sourceUp: command.sourceUp,
87
+ signal, beforePublication: fence,
88
+ });
89
+ const document = SpatialAssetAdmissionV1Schema.parse({
90
+ kind: "slopcamera.spatial-asset-admission", schemaVersion: 1,
91
+ manifest: result.manifest, factsManifest: result.factsManifest, facts: result.facts,
92
+ bounds: result.bounds, entity: result.entity, artifacts: result.artifacts,
93
+ operations: result.operations,
94
+ });
95
+ try {
96
+ await publishSpatialSource(output, document, fence);
97
+ } catch (error) {
98
+ const failure = error instanceof CliError ? error : new CliError("conflict", "Scene asset admission output publication failed.");
99
+ throw new CliError(signal.aborted ? "cancelled" : failure.code, failure.message, {
100
+ ...failure.details, published: [result.artifacts.payload, result.artifacts.facts], output: command.output,
101
+ });
102
+ }
103
+ return {
104
+ output: displayPath(repositoryRoot, output),
105
+ assetId: result.manifest.assetId,
106
+ factsAssetId: result.factsManifest.assetId,
107
+ entityId: result.entity.entityId,
108
+ bounds: result.bounds,
109
+ artifacts: result.artifacts,
110
+ document,
111
+ };
112
+ } catch (error) {
113
+ if (error instanceof SpatialAssetAdmissionError) {
114
+ throw new CliError(signal.aborted ? "cancelled" : "conflict", error.message, {
115
+ published: error.published, attempted: error.attempted, output: command.output,
116
+ });
117
+ }
118
+ if (signal.aborted && !(error instanceof CliError)) {
119
+ throw new CliError("cancelled", "Scene asset admission cancelled; retain its source and any publication evidence.");
120
+ }
121
+ if (error instanceof RangeError) throw new CliError("invalid-data", error.message);
122
+ throw error;
123
+ }
124
+ }