/** * 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 ` 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; /** * 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 ` 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 { 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((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 { 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"); }