@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.
- package/NOTICE.md +1 -7
- package/PRIVACY.md +16 -7
- package/README.md +41 -13
- package/apps/desktop/README.md +14 -130
- package/apps/desktop/application/context.ts +0 -9
- package/apps/desktop/application/default-registry.ts +0 -10
- package/apps/desktop/application/operation.ts +1 -5
- package/apps/desktop/application/operations/index.ts +0 -1
- package/apps/desktop/cli/args.ts +22 -86
- package/apps/desktop/cli/command-host-resources.ts +5 -1
- package/apps/desktop/cli/commands.ts +37 -67
- package/apps/desktop/cli/help.ts +24 -20
- package/apps/desktop/cli/html-scene.ts +479 -0
- package/apps/desktop/cli/io.ts +2 -1
- package/apps/desktop/cli/main.ts +28 -47
- package/apps/desktop/cli/media-ingest-model.ts +2 -0
- package/apps/desktop/cli/media-ingest-program.ts +9 -4
- package/apps/desktop/cli/menubar.ts +244 -0
- package/apps/desktop/cli/music-analysis-service.ts +61 -0
- package/apps/desktop/cli/portable-surface.ts +13 -4
- package/apps/desktop/cli/root-help-intro.ts +14 -0
- package/apps/desktop/code/application-node-planner.ts +0 -47
- package/apps/desktop/code/host-source-layout.ts +46 -0
- package/apps/desktop/code/public.ts +0 -1
- package/apps/desktop/code/runtime-identity.ts +50 -8
- package/apps/desktop/code/semantic-builder.ts +0 -43
- package/apps/desktop/code/source-bundle.ts +23 -6
- package/apps/desktop/code/source-typecheck.ts +131 -43
- package/apps/desktop/code/worker-client.ts +14 -2
- package/apps/desktop/core/music-analysis.ts +124 -1
- package/apps/desktop/dist/cli/main.js +261 -248
- package/apps/desktop/html-overlay/audio-reactivity.ts +127 -0
- package/apps/desktop/html-overlay/contracts.ts +3 -0
- package/apps/desktop/html-overlay/index.ts +28 -0
- package/apps/desktop/html-overlay/music-clock.ts +126 -0
- package/apps/desktop/html-overlay/rigged-glb.ts +429 -0
- package/apps/desktop/html-overlay/runtime.ts +4 -0
- package/apps/desktop/html-overlay/scaffolds.ts +2 -1
- package/apps/desktop/html-overlay/scene.ts +76 -0
- package/dist/cli.js +83 -81
- package/dist/{index-jchqst8w.js → index-7131pg1c.js} +43 -236
- package/dist/{index-mcy8z0br.js → index-fava6pge.js} +1 -1
- package/dist/{index-z7239b4h.js → index-h1k0fnjq.js} +1 -1
- package/dist/{index-p63wavx0.js → index-zfnddgay.js} +1 -1
- package/dist/index.js +4 -18
- package/dist/operations.js +2 -2
- package/dist/vectorize/worker.js +1 -1
- package/dist/workflow.js +3 -3
- package/docs/README.md +2 -1
- package/docs/studio.md +1 -1
- package/examples/html/music-video.html +514 -0
- package/examples/html/music-video.json +16 -0
- package/package.json +8 -21
- package/skills/slopcamera/SKILL.md +10 -1
- package/skills/slopcamera/references/diagrams.md +5 -14
- package/skills/slopcamera/references/install.md +2 -2
- package/skills/slopcamera/references/music-video.md +169 -0
- package/skills/slopcamera/references/reference-led-3d.md +1 -1
- package/skills/slopcamera/references/support.md +25 -0
- package/skills/slopcamera/references/video-projects.md +6 -2
- package/skills/slopcamera/references/web-media-excerpts.md +120 -0
- package/skills/slopcamera/references/workflows-sdk.md +1 -1
- package/src/cli.ts +28 -98
- package/src/index.ts +0 -23
- package/src/support-completion.ts +16 -0
- package/src/support.ts +42 -0
- package/src/version.ts +1 -1
- package/apps/desktop/application/operations/recording/index.ts +0 -5
- package/apps/desktop/application/operations/recording/pause.ts +0 -34
- package/apps/desktop/application/operations/recording/resume.ts +0 -34
- package/apps/desktop/application/operations/recording/shared.ts +0 -199
- package/apps/desktop/application/operations/recording/start.ts +0 -169
- package/apps/desktop/application/operations/recording/stop.ts +0 -34
- package/apps/desktop/capture/protocol.ts +0 -689
- package/apps/desktop/cli/capture-bundle.ts +0 -1117
- package/apps/desktop/cli/recording-controller.ts +0 -1567
- package/apps/desktop/cli/recording-daemon.ts +0 -931
- package/src/desktop.ts +0 -253
|
@@ -67,7 +67,7 @@ function removeStagedImport(
|
|
|
67
67
|
}));
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
function stageImport(projectDirectory: string, sourcePath: string): Program<StagedMediaImport> {
|
|
70
|
+
function stageImport(projectDirectory: string, sourcePath: string, maximumBytes: number): Program<StagedMediaImport> {
|
|
71
71
|
return Effect.gen(function*() {
|
|
72
72
|
const platform = yield* MediaIngestPlatform;
|
|
73
73
|
const source = yield* platform.sourcePath(sourcePath);
|
|
@@ -78,8 +78,8 @@ function stageImport(projectDirectory: string, sourcePath: string): Program<Stag
|
|
|
78
78
|
if (before.isSymbolicLink() || !before.isFile()) {
|
|
79
79
|
throw new CliError("unsafe-path", `Media source must be a physical regular file: ${sourcePath}`);
|
|
80
80
|
}
|
|
81
|
-
if (before.size <= 0 || before.size >
|
|
82
|
-
throw new CliError("invalid-data", `Media source must contain 1 through ${
|
|
81
|
+
if (before.size <= 0 || before.size > maximumBytes) {
|
|
82
|
+
throw new CliError("invalid-data", `Media source must contain 1 through ${maximumBytes} bytes.`);
|
|
83
83
|
}
|
|
84
84
|
});
|
|
85
85
|
const imports = yield* privateImportsDirectory(projectDirectory);
|
|
@@ -178,6 +178,11 @@ function commitImport(imported: StagedMediaImport): Program<boolean> {
|
|
|
178
178
|
export function ingestProjectMediaProgram(options: IngestProjectMediaOptions): Program<IngestedProjectMedia> {
|
|
179
179
|
return Effect.gen(function*() {
|
|
180
180
|
const platform = yield* MediaIngestPlatform;
|
|
181
|
+
const maximumBytes = yield* operationValidation("media", () => {
|
|
182
|
+
const bound = options.maximumBytes ?? MAX_MEDIA_BYTES;
|
|
183
|
+
if (!Number.isSafeInteger(bound) || bound < 1 || bound > MAX_MEDIA_BYTES) throw new CliError("invalid-data", "Invalid media import byte bound.");
|
|
184
|
+
return bound;
|
|
185
|
+
});
|
|
181
186
|
const role = yield* operationValidation("media", () => ProjectAssetRoleSchema.parse(options.role));
|
|
182
187
|
const [repositoryRoot, projectDirectory] = yield* Effect.all([
|
|
183
188
|
platform.realpath(options.repositoryRoot), platform.realpath(options.projectDirectory),
|
|
@@ -186,7 +191,7 @@ export function ingestProjectMediaProgram(options: IngestProjectMediaOptions): P
|
|
|
186
191
|
})));
|
|
187
192
|
yield* platform.assertWithin(repositoryRoot, projectDirectory, "Project media import directory is outside the repository.");
|
|
188
193
|
return yield* operationResource(
|
|
189
|
-
stageImport(projectDirectory, options.sourcePath),
|
|
194
|
+
stageImport(projectDirectory, options.sourcePath, maximumBytes),
|
|
190
195
|
imported => Effect.gen(function*() {
|
|
191
196
|
const probe = yield* probeProjectMediaProgram(options.ffprobe, options.runner, imported.temporaryPath);
|
|
192
197
|
const location = yield* platform.assetLocation(repositoryRoot, imported.absolutePath, options.sourcePath);
|
|
@@ -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("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'"); }
|
|
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(
|
|
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
|
|
|
@@ -214,6 +221,7 @@ export async function runPortableSurface(
|
|
|
214
221
|
): Promise<number | undefined> {
|
|
215
222
|
const argv = canonicalizeUnifiedCliArgs(argvInput);
|
|
216
223
|
if (argv[0] === "html") {
|
|
224
|
+
if (argv[1] === "render" || argv.includes("--help") || argv.includes("-h")) return undefined;
|
|
217
225
|
if (argv[1] === "catalog") return runHtmlCatalog(argv, dependencies);
|
|
218
226
|
return await runHtmlScaffold(argv, dependencies);
|
|
219
227
|
}
|
|
@@ -234,7 +242,8 @@ export async function runPortableSurface(
|
|
|
234
242
|
const previousExitCode = process.exitCode;
|
|
235
243
|
process.exitCode = undefined;
|
|
236
244
|
try {
|
|
237
|
-
await (dependencies.runHeadless ?? runHeadlessSlopcameraCli)(argv
|
|
245
|
+
await (dependencies.runHeadless ?? runHeadlessSlopcameraCli)(argv,
|
|
246
|
+
dependencies.onUsefulResult === undefined ? {} : { onUsefulResult: dependencies.onUsefulResult });
|
|
238
247
|
return process.exitCode ?? 0;
|
|
239
248
|
} finally {
|
|
240
249
|
process.exitCode = previousExitCode;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
const camera = " __\n _|__|_\n| (o) |\n|______|\n\n";
|
|
2
|
+
|
|
3
|
+
/** Decoration is confined to readable, interactive root help. */
|
|
4
|
+
export function rootHelpIntro(
|
|
5
|
+
argv: readonly string[],
|
|
6
|
+
terminal: Readonly<{ isTTY: boolean; term: string | undefined; columns: number | undefined }>,
|
|
7
|
+
): string {
|
|
8
|
+
return terminal.isTTY && terminal.term !== "dumb"
|
|
9
|
+
&& terminal.columns !== undefined && Number.isFinite(terminal.columns) && terminal.columns >= 48
|
|
10
|
+
&& (argv.length === 0
|
|
11
|
+
|| argv.length === 1 && (argv[0] === "help" || argv[0] === "--help" || argv[0] === "-h"))
|
|
12
|
+
? camera
|
|
13
|
+
: "";
|
|
14
|
+
}
|
|
@@ -51,7 +51,6 @@ import {
|
|
|
51
51
|
bindProjectRenderInputV2,
|
|
52
52
|
bindProjectRenderInputV3,
|
|
53
53
|
bindProjectRenderToolchain,
|
|
54
|
-
parseRecordingOperationOutput,
|
|
55
54
|
projectEditCommitReceipt,
|
|
56
55
|
projectEditTransactionId,
|
|
57
56
|
reconcileVariantSelectionPromotion,
|
|
@@ -105,7 +104,6 @@ import type {
|
|
|
105
104
|
import { workflowBoundary, workflowValidation, type WorkflowFailure } from "./workflow-effects";
|
|
106
105
|
|
|
107
106
|
const ANALYSIS_ID_DOMAIN = "studio.workflow.analysis-id/v1";
|
|
108
|
-
type RecordingOperationKind = Extract<OperationKind, `recording.${string}`>;
|
|
109
107
|
type GatewayOperationKind = Extract<OperationKind, `gateway.${string}`>;
|
|
110
108
|
|
|
111
109
|
export const APPLICATION_VERIFIED_RECEIPT_RECONCILER_KINDS =
|
|
@@ -114,15 +112,6 @@ export const APPLICATION_VERIFIED_RECEIPT_RECONCILER_KINDS =
|
|
|
114
112
|
"render.project",
|
|
115
113
|
] as const satisfies readonly OperationKind[]);
|
|
116
114
|
|
|
117
|
-
function isRecordingOperationKind(
|
|
118
|
-
kind: OperationKind,
|
|
119
|
-
): kind is RecordingOperationKind {
|
|
120
|
-
return kind === "recording.start"
|
|
121
|
-
|| kind === "recording.pause"
|
|
122
|
-
|| kind === "recording.resume"
|
|
123
|
-
|| kind === "recording.stop";
|
|
124
|
-
}
|
|
125
|
-
|
|
126
115
|
function isGatewayOperationKind(
|
|
127
116
|
kind: OperationKind,
|
|
128
117
|
): kind is GatewayOperationKind {
|
|
@@ -934,36 +923,6 @@ function renderNodeReconciliation(result: ProjectRenderReconciliation | Awaited<
|
|
|
934
923
|
};
|
|
935
924
|
}
|
|
936
925
|
|
|
937
|
-
async function reconcileRecordingNode(
|
|
938
|
-
application: ApplicationContext,
|
|
939
|
-
operationKind: RecordingOperationKind,
|
|
940
|
-
): Promise<NodeReconciliation> {
|
|
941
|
-
const controller = application.recordingController;
|
|
942
|
-
if (controller === undefined) {
|
|
943
|
-
return {
|
|
944
|
-
kind: "ambiguous",
|
|
945
|
-
message: "Recording controller is unavailable for live-state reconciliation.",
|
|
946
|
-
};
|
|
947
|
-
}
|
|
948
|
-
let output;
|
|
949
|
-
try {
|
|
950
|
-
output = parseRecordingOperationOutput(await controller.status());
|
|
951
|
-
} catch (error) {
|
|
952
|
-
return {
|
|
953
|
-
kind: "ambiguous",
|
|
954
|
-
message: error instanceof Error
|
|
955
|
-
? `Recording state could not be reconciled: ${error.message}`
|
|
956
|
-
: "Recording state could not be reconciled.",
|
|
957
|
-
};
|
|
958
|
-
}
|
|
959
|
-
return {
|
|
960
|
-
kind: "ambiguous",
|
|
961
|
-
message: `Current controller state ${output.state} for ${
|
|
962
|
-
output.recordingId ?? "no active recording"
|
|
963
|
-
} is not an exact receipt proving ${operationKind} completed; issue a new explicit action.`,
|
|
964
|
-
};
|
|
965
|
-
}
|
|
966
|
-
|
|
967
926
|
async function reconcileGatewayNode(
|
|
968
927
|
application: ApplicationContext,
|
|
969
928
|
request: NodeReconciliationRequest,
|
|
@@ -1223,12 +1182,6 @@ async function reconcileApplicationNode(
|
|
|
1223
1182
|
request.operation.kind,
|
|
1224
1183
|
);
|
|
1225
1184
|
}
|
|
1226
|
-
if (isRecordingOperationKind(request.operation.kind)) {
|
|
1227
|
-
return await reconcileRecordingNode(
|
|
1228
|
-
application,
|
|
1229
|
-
request.operation.kind,
|
|
1230
|
-
);
|
|
1231
|
-
}
|
|
1232
1185
|
if (request.operation.kind === "project.commit-edits") {
|
|
1233
1186
|
return await reconcileProjectCommit(application, request);
|
|
1234
1187
|
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
2
|
+
|
|
3
|
+
export interface HostSourceRoots {
|
|
4
|
+
/**
|
|
5
|
+
* True when the running module is inside the committed bundle at
|
|
6
|
+
* apps/desktop/dist/cli. A bundled host still spawns the checked TypeScript
|
|
7
|
+
* sources below apps/desktop and src/.
|
|
8
|
+
*/
|
|
9
|
+
readonly bundled: boolean;
|
|
10
|
+
/** The installed apps/desktop source directory. */
|
|
11
|
+
readonly desktopRoot: string;
|
|
12
|
+
/** The checkout or package root containing src/, packages/, and package.json. */
|
|
13
|
+
readonly repositoryRoot: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Walks to the apps/desktop anchor instead of assuming a fixed module depth.
|
|
18
|
+
* From a checkout, caller modules sit below apps/desktop/<area>/; inside the
|
|
19
|
+
* committed bundle they all share apps/desktop/dist/cli.
|
|
20
|
+
*/
|
|
21
|
+
export function hostSourceRoots(moduleDirectory: string): HostSourceRoots {
|
|
22
|
+
const requested = resolve(moduleDirectory);
|
|
23
|
+
let candidate = requested;
|
|
24
|
+
while (true) {
|
|
25
|
+
if (basename(candidate) === "desktop" && basename(dirname(candidate)) === "apps") {
|
|
26
|
+
return {
|
|
27
|
+
bundled: requested === join(candidate, "dist", "cli"),
|
|
28
|
+
desktopRoot: candidate,
|
|
29
|
+
repositoryRoot: dirname(dirname(candidate)),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
const parent = dirname(candidate);
|
|
33
|
+
if (parent === candidate) {
|
|
34
|
+
// Embedded compiled-binary modules and foreign callers keep the
|
|
35
|
+
// historical assumption that the module sits one level below
|
|
36
|
+
// apps/desktop.
|
|
37
|
+
const desktopRoot = resolve(requested, "..");
|
|
38
|
+
return {
|
|
39
|
+
bundled: false,
|
|
40
|
+
desktopRoot,
|
|
41
|
+
repositoryRoot: resolve(desktopRoot, "../.."),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
candidate = parent;
|
|
45
|
+
}
|
|
46
|
+
}
|