carbon-tokenization / backend /demo /lib /recording.ts
tfrere's picture
tfrere HF Staff
feat(demo): modular showcase replacing monolithic trio script
19b3f9c
Raw
History Blame Contribute Delete
4.22 kB
/**
* lib/recording.ts
*
* macOS auto screen recording via `screencapture -v`, plus the manual
* countdown used when the user prefers to drive QuickTime/Loom/OBS
* themselves. All recording concerns live here so showcase.ts can focus
* on orchestration.
*/
import { spawn, type ChildProcess } from "node:child_process";
import { mkdirSync } from "node:fs";
import { join } from "node:path";
export interface ScreenRecording {
outputPath: string;
/**
* Timestamp (ms since epoch) at which `screencapture` will naturally
* exit because of its `-V <duration>` flag. Consumers use this to know
* when the file becomes playable.
*/
finishesAt: number;
/**
* Resolves once the screencapture process has exited on its own. The
* resulting .mov is only guaranteed to be playable after this resolves.
*/
waitForFinish: () => Promise<void>;
/**
* Emergency abort: kills the process without waiting. The output file
* will be unplayable (see the comment on `-V`). Only use this on
* SIGINT / fatal errors where you'd throw the recording away anyway.
*/
abort: () => void;
}
/**
* Launches `screencapture -v -V <duration> <file.mov>` as a detached
* process. It records for exactly `duration` seconds then exits cleanly.
*
* Important quirk learned the hard way: sending SIGINT, SIGTERM, SIGHUP,
* SIGUSR1 or SIGUSR2 to `screencapture -v` kills it WITHOUT flushing the
* .mov container, leaving a 0-byte unplayable file. The only reliable
* way to obtain a valid recording is to let the `-V` timer elapse.
*
* Also: `screencapture -v` silently bails out if stdin is /dev/null, so
* we wire all three stdio streams to pipes.
*
* macOS prompts for Screen Recording permission the first time; grant it
* to the process running node (Terminal, iTerm, Cursor, etc.).
*/
export async function startScreenRecording(
outputPath: string,
durationSeconds: number,
): Promise<ScreenRecording | null> {
if (process.platform !== "darwin") {
console.warn(
"[showcase] --capture is macOS-only (needs /usr/sbin/screencapture). Skipping.",
);
return null;
}
mkdirSync(join(outputPath, ".."), { recursive: true });
const proc: ChildProcess = spawn(
"screencapture",
["-v", "-V", String(durationSeconds), outputPath],
{
stdio: ["pipe", "pipe", "pipe"],
detached: true,
},
);
const startedAt = Date.now();
let earlyStdout = "";
let earlyError = "";
proc.stdout?.on("data", (chunk: Buffer) => {
earlyStdout += chunk.toString();
});
proc.stderr?.on("data", (chunk: Buffer) => {
earlyError += chunk.toString();
});
// screencapture takes ~0.5s to spin up; if it's going to crash (e.g.
// no permission), it does so immediately. Give it a moment, then check.
await new Promise((r) => setTimeout(r, 1200));
if (proc.exitCode !== null) {
const details =
[earlyError.trim(), earlyStdout.trim()].filter(Boolean).join(" | ") ||
"no output";
console.warn(
`[showcase] screencapture failed to start (exit=${proc.exitCode}): ${details}`,
);
return null;
}
const waitForFinish = () =>
new Promise<void>((resolve) => {
if (proc.exitCode !== null) {
resolve();
return;
}
proc.once("exit", () => resolve());
});
const abort = () => {
try {
proc.kill("SIGKILL");
} catch {
/* ignore */
}
};
return {
outputPath,
finishesAt: startedAt + durationSeconds * 1000,
waitForFinish,
abort,
};
}
export function buildRecordingPath(): string {
const stamp = new Date()
.toISOString()
.replace(/[:.]/g, "-")
.replace("T", "_")
.slice(0, 19);
return join(process.cwd(), "demo", "recordings", `showcase-${stamp}.mov`);
}
export async function recordingCountdown(seconds: number): Promise<void> {
console.log("\n[showcase] === RECORDING MODE ===");
console.log("[showcase] Alice is up in fullscreen app mode.");
console.log("[showcase] Start your screen recorder NOW.\n");
for (let i = seconds; i > 0; i--) {
console.log(`[showcase] demo starts in ${i}...`);
await new Promise((r) => setTimeout(r, 1000));
}
console.log("[showcase] action!\n");
}