From 35fc5ad72fb30a3d4b83f4c5566e4d808b51056a Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Thu, 17 Sep 2026 16:11:05 -0700 Subject: [PATCH] Keep public Captutor exports limited to reusable capture contracts --- slab/captutor/bin/stage.mjs | 121 -- slab/captutor/captutor.mjs | 1222 -------------------- slab/captutor/lib/deliver.mjs | 529 --------- slab/captutor/lib/narrate.mjs | 103 -- slab/captutor/lib/stage-mode.mjs | 556 --------- slab/captutor/test/native-display.test.mjs | 2 - 6 files changed, 2533 deletions(-) delete mode 100755 slab/captutor/bin/stage.mjs delete mode 100644 slab/captutor/captutor.mjs delete mode 100644 slab/captutor/lib/deliver.mjs delete mode 100644 slab/captutor/lib/narrate.mjs delete mode 100644 slab/captutor/lib/stage-mode.mjs diff --git a/slab/captutor/bin/stage.mjs b/slab/captutor/bin/stage.mjs deleted file mode 100755 index f14e5895f5..0000000000 --- a/slab/captutor/bin/stage.mjs +++ /dev/null @@ -1,121 +0,0 @@ -#!/usr/bin/env node -// Run any Captutor command inside the reversible full-desk filming profile. - -import { spawn, spawnSync } from "node:child_process"; -import { existsSync, readFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { dirname, join, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; -import { enterStageMode, exitStageMode, parseStageFlags, nativeDisplayGeometry } from "../lib/stage-mode.mjs"; - -const HERE = dirname(fileURLToPath(import.meta.url)); -const REEL = resolve(HERE, "../vendor/reel.mjs"); -const REEL_STATE = join(process.env.HOME, ".local", "share", "slab", "state", "reel.state"); -const rawArgs = process.argv.slice(2); -let parsed; -try { parsed = parseStageFlags(rawArgs); } -catch (error) { - console.error(error.message); - process.exit(2); -} -const { vertical, native, brand, args } = parsed; -if (!args.length) { - console.error("usage: node bin/stage.mjs [--native | --vertical] [--brand fuser|classic] render [captutor options]"); - process.exit(2); -} - -let child; -let interrupted = false; -const forward = (signal) => { - interrupted = true; - try { child?.kill(signal); } catch {} -}; -process.on("SIGINT", () => forward("SIGINT")); -process.on("SIGTERM", () => forward("SIGTERM")); - -function verifyFilmingPermissions() { - const recorder = spawnSync(process.execPath, [REEL, "status"], { - encoding: "utf8", - env: process.env, - }); - if (recorder.status !== 0) { - throw new Error( - `SlabMenubar recording bridge is unavailable: ${(recorder.stderr || recorder.stdout || "").trim()}`, - ); - } - let status; - try { status = JSON.parse(recorder.stdout || "{}"); } - catch { throw new Error("SlabMenubar recording bridge returned invalid status"); } - if (typeof status.recording !== "boolean") { - throw new Error("SlabMenubar recording bridge did not report recording permission state"); - } - - const accessibility = spawnSync("/usr/bin/osascript", [ - "-e", 'tell application "System Events" to get UI elements enabled', - ], { encoding:"utf8", env:process.env }); - if (accessibility.status !== 0 || accessibility.stdout.trim() !== "true") { - throw new Error( - "System Events Accessibility is not enabled for Captutor; allow it in System Settings before filming", - ); - } - console.log("✓ filming permissions — SlabMenubar recorder + System Events Accessibility"); -} - -function stopOwnedReelIfNeeded() { - if (!child || !existsSync(REEL_STATE)) return; - let state; - try { state = JSON.parse(readFileSync(REEL_STATE, "utf8")); } - catch { return; } - if (!state.recording) return; - const interruptedClip = join(tmpdir(), `captutor-stage-interrupted-${Date.now()}.mp4`); - spawnSync(process.execPath, [REEL, "stop", "--out", interruptedClip], { - stdio: "inherit", - env: { - ...process.env, - PATH: `/opt/homebrew/bin:${process.env.HOME}/.local/bin:/usr/bin:/bin:/usr/sbin:/sbin`, - }, - }); -} - -let code = 1; -try { - verifyFilmingPermissions(); - // Enter is inside the guarded region deliberately: if a preference change - // fails halfway through, the state file still lets `finally` unwind it. - const nativeGeometry = native ? nativeDisplayGeometry() : null; - if(native)process.env.CAPTUTOR_STAGE_KEEP_DISPLAY="1"; - await enterStageMode({ vertical, brand }); - if (vertical) { - // Rotation can leave Chrome's process alive with no page window. Relaunch - // the dedicated filming profile only when its Fuser target disappeared. - const port = process.env.CDP_PORT || "9333"; - const pages = await fetch(`http://127.0.0.1:${port}/json/list`) - .then((response) => response.json()).catch(() => []); - if (!pages.some((page) => page.type === "page" && (page.url || "").includes("fuser.studio"))) { - const launched = spawnSync(resolve(HERE, "film-chrome.sh"), [ - "https://app.fuser.studio/w/me", port, - ], { stdio: "inherit", env: process.env }); - if (launched.status !== 0) throw new Error("could not relaunch Chrome after portrait rotation"); - } - } - child = spawn(process.execPath, [resolve(HERE, "../captutor.mjs"), ...args], { - stdio: "inherit", - env: { - ...process.env, - CAPTUTOR_STAGE_MODE: "1", - CAPTUTOR_NATIVE_DISPLAY: nativeGeometry ? JSON.stringify(nativeGeometry) : "", - CAPTUTOR_VERTICAL_MODE: vertical ? "1" : "0", - CAPTUTOR_BRAND: brand, - // The capture-visible Swift overlay is the normal filmed pointer. Keep - // the system cursor opt-in for explicitly human-driven takes only. - CAPTUTOR_REAL_CURSOR: process.env.CAPTUTOR_REAL_CURSOR ?? "0", - CDP_PORT: process.env.CDP_PORT || "9333", - PATH: `/opt/homebrew/bin:${process.env.HOME}/.local/bin:/usr/bin:/bin:/usr/sbin:/sbin`, - }, - }); - code = await new Promise((done) => child.once("exit", (status) => done(status ?? 1))); -} finally { - stopOwnedReelIfNeeded(); - await exitStageMode(); -} -process.exit(interrupted ? 130 : code); diff --git a/slab/captutor/captutor.mjs b/slab/captutor/captutor.mjs deleted file mode 100644 index 2d7afb8e7b..0000000000 --- a/slab/captutor/captutor.mjs +++ /dev/null @@ -1,1222 +0,0 @@ -#!/usr/bin/env node -// captutor — screen-recorded, narrated, captioned software tutorials. -// -// captutor render narrate → record → drive → compose → mp4 + vtt -// captutor narrate just the voice (cheap; check pacing first) -// captutor publish copy mp4 + vtt into fuser's docs and print the MDX -// captutor login make sure Iris is signed in (render does this too) -// captutor balance credits left, and what recent takes cost -// -// A screenplay is a list of BEATS. Each beat is one spoken line plus the thing -// the UI does while it is spoken: -// -// { say: "Open the gallery — this is where recipes live.", -// do: async ({ cdp, click }) => { await cdp.nav('/w/demo/gallery') } } -// -// How it stays in sync — the part worth understanding: -// -// 1. Every line is spoken FIRST (narrate.mjs), so each beat's exact duration -// is known before the camera rolls. -// 2. `reel` starts recording and reports `since` — the wall-clock instant the -// video's first frame exists. That is the timeline's true origin. -// 3. Each beat runs, and we stamp when it ACTUALLY began, relative to `since`. -// Fast beats are padded out to their narration length so the voice never -// runs ahead of the picture. Slow beats (an AI generation that takes 40s) -// are simply allowed to take as long as they take. -// 4. Narration is then laid down at those MEASURED offsets — never the planned -// ones. So an overrunning beat delays only itself, and every later beat is -// still pinned to the frame it belongs to. -// -// That is why there is no re-sync step, no whisper pass, and no drift. - -import { execFileSync } from "node:child_process"; -import { - appendFileSync, existsSync, mkdirSync, copyFileSync, readFileSync, writeFileSync, -} from "node:fs"; -import { dirname, join, resolve, basename } from "node:path"; -import { fileURLToPath, pathToFileURL } from "node:url"; - -import { narrate } from "./lib/narrate.mjs"; -import { assertBookmarksHidden } from "./lib/chrome-bookmarks.mjs"; -import { assertPresentationClean } from "./lib/presentation-check.mjs"; -import { attach, BrowserCrashError } from "./lib/cdp.mjs"; -import { createModalPolice, connectWithModalPolice } from "./lib/modal-police.mjs"; -import { - clickOn, dillydallyAtPoint, dragBetween, pointAt, startNativeCursor, - stopNativeCursor, typeInto, -} from "./lib/cursor.mjs"; -import { - spotlight, outline, burst, zoom, resetCamera, clearEffects, -} from "./lib/effects.mjs"; -import { tabController } from "./lib/tabs.mjs"; -import { mux, writeVTT, probe, preventOverlappingCues } from "./lib/compose.mjs"; -import { rhythmBedClip } from "./lib/rhythm-bed.mjs"; -import { deliver, FORMATS } from "./lib/deliver.mjs"; -import { translator, selectors, setLocale, LANGUAGES } from "./lib/i18n.mjs"; -import { ensureSignedIn, WORKSPACE } from "./lib/login.mjs"; -import * as credits from "./lib/credits.mjs"; -import { publishToOutbox } from "./lib/outbox.mjs"; -import { presentSignboard, setAmbient } from "./lib/signboard.mjs"; -import {nativeDisplayFormat} from "./lib/native-display.mjs"; -import { assertHiDPIStage } from "./lib/stage-contract.mjs"; -import { - BAKE_TIME_PRESET, condenseBakeTimeVideo, planBakeTime, -} from "./lib/bake-time.mjs"; - -const HERE = dirname(fileURLToPath(import.meta.url)); - -// Where `reel` lives. In @jeffrey's checkout captutor sits inside the vault -// inside the AC repo, so it is just up the tree. The office minis (panda, -// chicken) have NO repo — SlabMenubar is a hand-rsynced carve-out there — so the -// path is an env override. Iris runs on panda; this is what lets her film. -const INSTALLED_REEL = join(process.env.HOME, ".local", "bin", "reel.mjs"); -const REEL = process.env.CAPTUTOR_REEL - || (existsSync(INSTALLED_REEL) ? INSTALLED_REEL : join(resolve(HERE, "../../.."), "slab", "bin", "reel.mjs")); -const INSTALLED_FRAME = join(process.env.HOME, ".local", "bin", "frame.mjs"); -const REPO_FRAME = join(resolve(HERE, ".."), "slab", "bin", "frame.mjs"); -const FRAME = process.env.CAPTUTOR_FRAME - || (existsSync(INSTALLED_FRAME) ? INSTALLED_FRAME - : existsSync(REPO_FRAME) ? REPO_FRAME - : join(resolve(HERE, "../../.."), "slab", "bin", "frame.mjs")); -const FUSER = process.env.FUSER_REPO || `${process.env.HOME}/Developer/fuser`; -const DOCS_PUBLIC = join(FUSER, "apps", "docs", "public"); - -const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); -const now = () => Date.now() / 1000; -const REAL_CURSOR = process.env.CAPTUTOR_REAL_CURSOR === "1"; -const NATIVE_DISPLAY = process.env.CAPTUTOR_NATIVE_DISPLAY ? JSON.parse(process.env.CAPTUTOR_NATIVE_DISPLAY) : null; -const STAGE_MODE = process.env.CAPTUTOR_STAGE_MODE === "1"; -const PREFLIGHT_ONLY = process.env.CAPTUTOR_PREFLIGHT_ONLY === "1" - || process.argv[2] === "preflight"; -// CAPTUTOR_TASK_GID is present on every Iris mission, including a worker that -// was already running when this invariant was deployed. Local development -// renders remain possible without Stage; fleet takes do not. -const REQUIRE_HIDPI = process.env.CAPTUTOR_REQUIRE_HIDPI === "1" - || Boolean(process.env.CAPTUTOR_TASK_GID); -const VERTICAL_MODE = process.env.CAPTUTOR_VERTICAL_MODE === "1"; -// A Stage take is also a visual audit. Frame reads the complete display after -// each consequential interaction, but does so without OCR, cursor, targets, -// previews, or overlay windows. Those passive JPEGs prove that the visible UI -// followed the DOM/action receipt without putting tooling into the recording. -const PASSIVE_FRAME_AUDIT = STAGE_MODE - && process.env.CAPTUTOR_PASSIVE_FRAME_AUDIT !== "0"; - -// Frame's native OCR/target surfaces live above app windows, so even a window -// capture can film one. Retire every Frame-owned transient immediately before -// the reel starts. If Frame is installed, failure is a capture-safety failure: -// it is better to abort a take than ship tooling UI inside the tutorial. -function clearFrameOverlays() { - if (!existsSync(FRAME)) return; - execFileSync(process.execPath, [ - FRAME, "local", "--clear-overlays", "--quiet-overlay", "--no-ocr", "--json", - ], { encoding: "utf8", timeout: 10_000, stdio: ["ignore", "pipe", "pipe"] }); -} - -// Some filming seats (notably clamshell Macs on native-only external panels) -// cannot expose Captutor's usual 2560×1440/1280×720 HiDPI pair. Keep those -// pixels honest: allow Stage Mode to declare the physical desktop and its -// deliberately smaller browser window instead of upscaling a 1080p capture. -if (STAGE_MODE && !NATIVE_DISPLAY && process.env.CAPTUTOR_STAGE_GEOMETRY) { - const match = process.env.CAPTUTOR_STAGE_GEOMETRY.match( - /^(\d+)x(\d+):(\d+)x(\d+)$/, - ); - if (!match) { - throw new Error("CAPTUTOR_STAGE_GEOMETRY must be desktopWxH:windowWxH"); - } - const [, outW, outH, winW, winH] = match.map(Number); - FORMATS.docs.out = { w: outW, h: outH }; - FORMATS.docs.win = { w: winW, h: winH }; -} -if (!STAGE_MODE && process.env.CAPTUTOR_WINDOW_GEOMETRY) { - const match = process.env.CAPTUTOR_WINDOW_GEOMETRY.match(/^(\d+)x(\d+)$/); - if (!match) throw new Error("CAPTUTOR_WINDOW_GEOMETRY must be windowWxH"); - const [, width, height] = match.map(Number); - FORMATS.docs.win = { w: width, h: height }; - FORMATS.docs.out = { w: width, h: height }; -} -if (!STAGE_MODE && process.env.CAPTUTOR_OUTPUT_GEOMETRY) { - const match = process.env.CAPTUTOR_OUTPUT_GEOMETRY.match(/^(\d+)x(\d+)$/); - if (!match) throw new Error("CAPTUTOR_OUTPUT_GEOMETRY must be outputWxH"); - const [, width, height] = match.map(Number); - FORMATS.docs.out = { w: width, h: height }; -} - -if(NATIVE_DISPLAY){ - if(!STAGE_MODE)throw Error("Native display capture requires Stage Mode"); - Object.assign(FORMATS.docs,nativeDisplayFormat(NATIVE_DISPLAY)); -} - -const REEL_STATE = `${process.env.HOME}/.local/share/slab/state/reel.state`; -const FAILURE_LOG = join(HERE, "out", "failures.ndjson"); -const UPGRADE_TEXT = "Upgrade time!"; -const AUTO_RETRIES = Number(process.env.CAPTUTOR_AUTO_RETRIES || 2); - -class UpgradeInterruption extends Error { - constructor({ beat, elapsed, aborted }) { - super(`${UPGRADE_TEXT} interrupted the take at beat ${beat + 1}`); - this.name = "UpgradeInterruption"; - this.beat = beat; - this.elapsed = elapsed; - this.aborted = aborted; - } -} - -async function upgradeVisible(cdp) { - try { - return Boolean(await cdp.eval( - `document.body?.innerText?.includes(${JSON.stringify(UPGRADE_TEXT)})`, - )); - } catch { - return false; - } -} - -async function refreshPastUpgrade(cdp) { - if (!await upgradeVisible(cdp)) return false; - console.log(` ↻ ${UPGRADE_TEXT} detected before recording; refreshing Fuser`); - await cdp.nav(await cdp.eval("location.href")); - await cdp.waitFor( - `!document.body?.innerText?.includes(${JSON.stringify(UPGRADE_TEXT)})`, - { timeoutMs: 30000 }, - ); - return true; -} - -function logFailure({ sp, locale, format, attempt, error }) { - mkdirSync(dirname(FAILURE_LOG), { recursive: true }); - const record = { - schema: "captutor-failure/v1", - at: new Date().toISOString(), - screenplay: sp.slug, - locale, - format, - attempt, - reason: "upgrade-time", - message: error.message, - beat: error.beat + 1, - elapsedSec: Number(error.elapsed.toFixed(3)), - abortedVideo: error.aborted, - action: "cancel-and-retry", - }; - appendFileSync(FAILURE_LOG, `${JSON.stringify(record)}\n`); - return record; -} - -function logBrowserFailure({ sp, locale, format, attempt, error, beat, elapsed, aborted }) { - mkdirSync(dirname(FAILURE_LOG), { recursive: true }); - const record = { - schema:"captutor-failure/v1", - at:new Date().toISOString(), - screenplay:sp.slug, - locale, - format, - attempt, - reason:"browser-renderer-crash", - message:error.message, - beat:Math.max(0, beat) + 1, - elapsedSec:Number(elapsed.toFixed(3)), - abortedVideo:aborted, - signal:error.details?.signal || null, - action:"abort-and-restart-browser", - }; - appendFileSync(FAILURE_LOG, `${JSON.stringify(record)}\n`); - return record; -} - -function logOperationalFailure({ sp, locale, format, attempt, phase, error, before, after }) { - mkdirSync(dirname(FAILURE_LOG), { recursive: true }); - const normalized = String(error.message || error) - .replace(/\b-?\d+(?:\.\d+)?\b/g, "#").slice(0, 500); - const record = { - schema:"captutor-failure/v2", - at:new Date().toISOString(), - screenplay:sp.slug, - locale, - format, - attempt, - phase, - reason:error.code || error.name || "operational-error", - message:String(error.message || error), - signature:`${phase}:${error.code || error.name || "error"}:${normalized}`, - actionTelemetry:error.captutorTelemetry || error.telemetry || null, - before, - after, - action:"inspect-state-before-retry", - }; - appendFileSync(FAILURE_LOG, `${JSON.stringify(record)}\n`); - return record; -} - -// `since` is the load-bearing value: the wall-clock instant the recorder's first -// frame exists, on the same machine and the same epoch as our own Date.now(). -// Every beat offset is measured against it, so audio and video share an origin. -function reelStart({ window, fps, cursor = false }) { - const out = execFileSync("node", [ - REEL, "start", ...(window ? ["--window", window] : []), "--fps", String(fps), - ...(cursor ? ["--cursor"] : []), - ], { encoding: "utf8" }); - if (!/recording/.test(out)) throw new Error(`reel start failed: ${out}`); - return JSON.parse(readFileSync(REEL_STATE, "utf8")); -} - -function reelStop(out) { - execFileSync("node", [REEL, "stop", "--out", out], { encoding: "utf8" }); - return out; -} - -async function loadScreenplay(ref) { - const path = existsSync(ref) ? resolve(ref) - : resolve(HERE, "screenplays", ref.endsWith(".mjs") ? ref : `${ref}.mjs`); - if (!existsSync(path)) throw new Error(`no screenplay at ${path}`); - const mod = await import(pathToFileURL(path).href); - const sp = mod.default; - sp.slug ||= basename(path, ".mjs"); - return sp; -} - -/// A screenplay's text may be a plain string (one language) or a map keyed by -/// locale. Resolve against the locale we are filming in — and refuse to silently -/// fall back to English, which would put the wrong voice over a translated UI. -function say(value, locale) { - if (typeof value === "string") return value; - const hit = value?.[locale]; - if (!hit) throw new Error(`screenplay has no "${locale}" text for: ${JSON.stringify(value)?.slice(0, 60)}`); - return hit; -} - -async function cmdNarrate(sp, workDir, locale) { - console.log(`\n♪ narrating ${sp.beats.length} beats (${sp.voice || "jeffrey"} · ${LANGUAGES[locale]?.native || locale})`); - const localized = sp.beats.map((b) => ({ ...b, say: say(b.say, locale) })); - // The voice cache is keyed by LOCALE, not by format. The narration for a reel - // and for the docs cut is the same sentence in the same voice — caching it per - // format would re-bill ElevenLabs once per aspect ratio for identical audio. - const beats = await narrate(localized, { - voice: sp.voice, dir: join(HERE, "out", "voice", `${sp.slug}.${locale}`), - }); - const total = beats.reduce((a, b) => a + b.durationSec, 0); - console.log(` → ${total.toFixed(1)}s of narration`); - return beats; -} - -/// Size the browser window to the delivery aspect BEFORE recording. -/// -/// `reel` films the window, chrome and all, so the window IS the frame. Setting -/// it here means a 9:16 deliverable is filmed 9:16 — fuser drops into its narrow -/// responsive layout and we never crop, pad or letterbox anything afterwards. -/// -/// Browser.setWindowBounds takes OUTER bounds (title bar + tab strip + URL bar -/// included), which is exactly what gets filmed. -/// Close every tab but the one we are filming. -/// -/// `reel` films the WINDOW, so the tab strip is in shot — a stray "New Tab" or a -/// half-read article is in the tutorial forever. Nothing about the recording is -/// private, but a tab bar full of someone's browsing is noise at best and a leak -/// at worst. -async function soloTab(cdp) { - const targets = await fetch(`http://127.0.0.1:${process.env.CDP_PORT || 9222}/json`) - .then((r) => r.json()); - const keep = targets.find((t) => t.type === "page" && (t.url || "").includes("fuser.studio")); - // If we cannot positively identify the tab we are filming, close NOTHING. The - // first version of this had no such guard: on a run where the match failed it - // closed every page, Chrome exited, and the next five takes died with "no CDP - // page". A tidy tab strip is never worth killing the browser for. - if (!keep) return; - for (const t of targets) { - if (t.type !== "page" || t.id === keep.id) continue; - if ((t.url || "").includes("fuser.studio")) continue; // belt: never our app - await fetch(`http://127.0.0.1:${process.env.CDP_PORT || 9222}/json/close/${t.id}`).catch(() => {}); - } -} - -async function sizeWindow(cdp, win) { - const { windowId } = await cdp.send("Browser.getWindowForTarget"); - const display = await cdp.eval(`({ width: screen.availWidth, height: screen.availHeight })`); - const left = Math.max(0, Math.round((display.width - win.w) / 2)); - const top = Math.max(0, Math.round((display.height - win.h) / 2)); - await cdp.send("Browser.setWindowBounds", { - windowId, - bounds: { windowState: "normal", left, top, width: win.w, height: win.h }, - }); - await new Promise((r) => setTimeout(r, 900)); // let the layout settle -} - -async function cmdRender(sp, workDir, locale, format, attempt = 1) { - assertHiDPIStage({ - required:REQUIRE_HIDPI, - stageMode:STAGE_MODE, - vertical:VERTICAL_MODE, - nativeDisplay:NATIVE_DISPLAY, - }); - if(NATIVE_DISPLAY && format!=="docs")throw Error("Native display capture currently requires docs format"); - const beats = await cmdNarrate(sp, workDir, locale); - const t = translator(locale); - const s = selectors(t); - const F = FORMATS[format]; - if (!F) throw new Error(`unknown format: ${format}`); - - console.log(`\n⇢ attaching to ${sp.window || "browser"} over CDP`); - const cdp = STAGE_MODE - ? await connectWithModalPolice(() => attach(sp.match || sp.baseURL)) - : await attach(sp.match || sp.baseURL); - - // Stage takes over the whole desk before screenplay setup begins. Put Chrome - // in its final, centered delivery bounds immediately so the operator never - // sees a misleading off-center transitional window while login/setup runs. - // Re-applying the same bounds after the theme reload below remains harmless. - if (STAGE_MODE && F.compose?.fullDesktop && !NATIVE_DISPLAY) await sizeWindow(cdp, F.win); - - // DevTools overlay switches persist on the target across sessions. If a - // debugging run left paint rectangles or compositor borders enabled, those - // diagnostics would otherwise be burned into the recording. Reset every - // known visual overlay before staging the take; older Chrome builds may not - // support every switch, so an unknown method is harmless here. - await Promise.all([ - ["Overlay.setShowDebugBorders", { show: false }], - ["Overlay.setShowPaintRects", { result: false }], - ["Overlay.setShowLayoutShiftRegions", { result: false }], - ["Overlay.setShowHitTestBorders", { show: false }], - ["Overlay.setShowScrollBottleneckRects", { show: false }], - ["Overlay.setShowFPSCounter", { show: false }], - ["Overlay.setShowWebVitals", { show: false }], - ["Overlay.setShowViewportSizeOnResize", { show: false }], - ["Overlay.setShowAdHighlights", { show: false }], - ].map(([method, params]) => cdp.send(method, params).catch(() => {}))); - - // Puppet and the shared analysis layer draw directly into the page at the - // highest z-index. They normally self-fade, but a tutorial take must not - // depend on a timeout or on which automation client touched the tab last. - await cdp.eval(`(() => { - document.getElementById('__puppet_cursor')?.remove(); - document.getElementById('__analysis_overlay')?.remove(); - clearTimeout(window.__pcTimer); - delete window.__pcTimer; - })()`); - - // Captutor's visible pointer is a native click-through Swift surface. Remove - // any cursor left in the page by an older build; trusted input still travels - // through CDP and therefore remains independent from the presentation layer. - await cdp.eval(`(() => { - document.getElementById('__captutor_cursor')?.remove(); - delete window.__captutor; - })()`); - - // The screenplay says `click('[data-testid=fuse]')`, not - // `click(cdp, '[data-testid=fuse]')` — the session is plumbing, and a - // screenplay should read like stage directions. Bind it in here. - // AccentColor remains the generic default. Client screenplays may provide a - // reusable effectTheme; a beat's local options are the most specific layer. - const themedEffectOptions = (opts = {}) => ({ ...(sp.effectTheme || {}), ...opts }); - let traceSince = null; - let bakeTimeSequence = 0; - const storyboardEvents = []; - const trace = (kind, details = {}) => { - if (traceSince == null) return; - storyboardEvents.push({ kind, atSec:+(now() - traceSince).toFixed(3), ...details }); - }; - const uiSnapshot = async () => { - try { - const frame = await cdp.frame(); - return { - capturedAt:frame.capturedAt, - url:frame.url, - viewport:frame.viewport, - focus:frame.focus, - graph:frame.graph, - }; - } catch (error) { - return { unavailable:String(error.message || error) }; - } - }; - const frameAuditDir = join(workDir, "frame-audit", String(Date.now())); - let frameAuditSequence = 0; - const passiveFrameAudit = (kind) => { - if (!PASSIVE_FRAME_AUDIT || !["click", "drag", "type"].includes(kind)) return null; - if (!existsSync(FRAME)) { - throw new Error("passive Frame audit is required in Stage Mode but Frame is unavailable"); - } - mkdirSync(frameAuditDir, { recursive:true }); - const sequence = String(++frameAuditSequence).padStart(3, "0"); - const image = join(frameAuditDir, `${sequence}-${kind}.jpg`); - let envelope; - try { - envelope = JSON.parse(execFileSync(process.execPath, [ - FRAME, "local", "--screen", "--no-ocr", "--quiet-overlay", - "--out", image, "--json", - ], { encoding:"utf8", timeout:15_000, stdio:["ignore", "pipe", "pipe"] })); - } catch (error) { - throw new Error(`passive Frame audit failed after ${kind}: ${error.message}`); - } - if (!existsSync(image)) { - throw new Error(`passive Frame audit returned no pixels after ${kind}`); - } - return { - schema:"captutor-passive-frame-audit/v1", - image, - capture:envelope.capture || "ok", - frontmost:envelope.meta?.frontmost || null, - screen:envelope.meta?.screen || null, - }; - }; - const perform = async (kind, details, action) => { - const startedAt = now(); - const before = await uiSnapshot(); - let frameAudit = null; - try { - const result = await action(); - const after = await uiSnapshot(); - frameAudit = passiveFrameAudit(kind); - const forbiddenRoute = (sp.forbiddenRouteFragments || []).find((fragment) => { - try { return new URL(after.url).pathname.includes(fragment); } - catch { return false; } - }); - if (forbiddenRoute) { - const error = new Error( - `unexpected route after ${kind}: ${after.url} (forbidden ${forbiddenRoute})`, - ); - error.code = "UNEXPECTED_ROUTE"; - throw error; - } - trace(kind, { - ...details, - durationSec:+(now() - startedAt).toFixed(3), - result, - ui:{ before, after }, - frameAudit, - }); - return result; - } catch (error) { - const after = await uiSnapshot(); - error.captutorTelemetry = { - kind, details, durationSec:+(now() - startedAt).toFixed(3), before, after, frameAudit, - }; - throw error; - } - }; - const localizeCard = (card) => Object.fromEntries(Object.entries(card || {}).map( - ([key, value]) => [key, typeof value === "string" ? say(value, locale) : value], - )); - const englishCard = (card) => Object.fromEntries(Object.entries(card || {}).map( - ([key, value]) => [key, typeof value === "string" ? say(value, "en") : value], - )); - const ctx = { - cdp, - click: (sel, opts) => perform("click", { selector:sel, options:opts }, - () => clickOn(cdp, sel, opts)), - drag: (from, to, opts) => perform("drag", { from, to, options:opts }, - () => dragBetween(cdp, from, to, opts)), - point: (sel, opts) => perform("point", { selector:sel, options:opts }, - () => pointAt(cdp, sel, opts)), - dillydally: (point, opts) => perform("dillydally", { point, options:opts }, - () => dillydallyAtPoint(cdp, point, opts)), - type: (sel, text) => perform("type", { selector:sel, textLength:String(text).length }, - () => typeInto(cdp, sel, text)), - spotlight: (sel, opts) => perform("spotlight", { selector:sel, options:opts }, - () => spotlight(cdp, sel, themedEffectOptions(opts))), - outline: (sel, opts) => perform("outline", { selector:sel, options:opts }, - () => outline(cdp, sel, themedEffectOptions(opts))), - burst: (sel, opts) => perform("burst", { selector:sel, options:opts }, - () => burst(cdp, sel, themedEffectOptions(opts))), - zoom: (sel, opts) => perform("zoom", { selector:sel, options:opts }, - () => zoom(cdp, sel, themedEffectOptions(opts))), - resetCamera: (opts) => resetCamera(cdp, opts), - tabs: tabController(), - clearEffects: () => clearEffects(cdp), - signboard: (card, options) => perform("signboard", { - card:localizeCard(card), options, - }, () => presentSignboard(cdp, localizeCard(card), options)), - check: (name, evidence = {}) => trace("check", { name, evidence }), - // A model wait is real capture evidence, but its inert middle is not useful - // teaching time. Mark the exact async boundary; composition keeps a short - // live lead, applies the canonical bake-time fold, then returns on the - // result frame. The promise/function itself remains ordinary screenplay - // code, and failures still propagate normally. - bakeTime: async (waiter, options = {}) => { - const id = options.id || `bake-${++bakeTimeSequence}`; - const details = { - id, - label:options.label || "Model is generating", - preset:BAKE_TIME_PRESET.name, - liveLeadSec:options.liveLeadSec ?? BAKE_TIME_PRESET.liveLeadSec, - resultLeadSec:options.resultLeadSec ?? BAKE_TIME_PRESET.resultLeadSec, - minimumFoldSec:options.minimumFoldSec ?? BAKE_TIME_PRESET.minimumFoldSec, - transitionSec:options.transitionSec ?? BAKE_TIME_PRESET.transitionSec, - }; - trace("bake-time-start", details); - try { - return await (typeof waiter === "function" ? waiter() : waiter); - } finally { - trace("bake-time-end", { id, label:details.label, preset:details.preset }); - } - }, - effects: { - spotlight: (sel, opts) => spotlight(cdp, sel, themedEffectOptions(opts)), - outline: (sel, opts) => outline(cdp, sel, themedEffectOptions(opts)), - burst: (sel, opts) => burst(cdp, sel, themedEffectOptions(opts)), - zoom: (sel, opts) => zoom(cdp, sel, themedEffectOptions(opts)), - resetCamera: (opts) => resetCamera(cdp, opts), - clear: () => clearEffects(cdp), - }, - sleep, - locale, format, t, s, setLocale, // fuser's own strings drive both voice and clicks - }; - - // BEFORE ANYTHING ELSE: be logged in. - // - // Fuser's session cookie never reaches disk, so quitting Chrome logs Iris out - // — and a renderer that needs a human to type a code out of an inbox is not - // unattended. "A video was requested" therefore implies "make sure we are - // signed in first"; lib/login.mjs does it over email OTP and costs one eval - // when the session is already alive. - // - // Both this and the credit guard below are FUSER'S concerns, not the camera's. - // A screenplay filming an app with no account and no metered generations says - // `signIn: false` / `billable: false` and skips them — otherwise `signedIn()` - // finds no fuser session marker on a stranger's page, concludes we are logged - // out, and mails Iris a code for an app she does not have an account on. - if (sp.signIn !== false) { - console.log("\n⇢ checking Iris's session"); - await ensureSignedIn(cdp, { email: sp.account }); - } - - // Fuser can announce a freshly deployed build at any time. Clear a notice - // already present before setup; the live guard below handles one that lands - // after the reel starts. - await refreshPastUpgrade(cdp); - - // …and be able to pay for it. Generations debit a CLIENT'S PRODUCTION account, - // so this refuses to roll below a floor rather than filming a take that runs - // dry halfway through. See lib/credits.mjs. - const purse = sp.billable === false - ? null - : await credits.guard(cdp, { slug: sp.slug, locale, format }); - - // Let the operator's own setup run (seed, open the right project, pick the - // language) BEFORE the camera rolls, so none of it lands in the tutorial. - if (sp.setup) { - console.log(" running setup…"); - const before = await uiSnapshot(); - try { - await sp.setup(ctx); - } catch (error) { - const after = await uiSnapshot(); - logOperationalFailure({ - sp, locale, format, attempt, phase:"setup", error, before, after, - }); - await cdp.close(); - throw error; - } - } - - // Pin the theme preference, or a tutorial set will not look like one set. - // - // fuser defaults to `system` (packages/alloy/src/utils/theme.ts), so takes shot - // at different times of day come out in different themes — that is exactly what - // happened here: the English take is dark, the Spanish one came back light. - // - // The default is Fuser's own `system` setting: the filming seat follows the - // macOS appearance unless a screenplay explicitly asks for light or dark. - // It is a COOKIE first (`fuser-theme`, shared cross-subdomain), and only then - // localStorage — which is why probing localStorage for a theme key finds - // nothing. Emulating prefers-color-scheme does nothing either; the app is not - // reading the media query, it is reading its own cookie. Set both, then reload - // so the class actually lands on . - const theme = sp.theme || "system"; - const here = await cdp.eval("location.href"); - await cdp.eval(`(() => { - document.cookie = "fuser-theme=${theme};path=/;domain=.fuser.studio;max-age=31536000;samesite=lax"; - document.cookie = "fuser-theme=${theme};path=/;max-age=31536000;samesite=lax"; - localStorage.setItem("fuser-theme", "${theme}"); - })()`); - await cdp.nav(here); - // Assert the PREFERENCE, not the class. Only the /flow route stamps `dark` on - // ; the workspace hardcodes its own dark body and leaves the class empty, - // so waiting for the class hangs there forever even though the theme is set. - await cdp.waitFor(`document.cookie.includes("fuser-theme=${theme}")`); - - // The window IS the frame — clear the tab strip and size it, before rolling. - if (F.requiresVerticalStage) { - cdp.close(); - throw new Error(`format "${format}" requires: node bin/stage.mjs --vertical render …`); - } - if (!sp.preserveTabs) await soloTab(cdp); - if(!NATIVE_DISPLAY)await sizeWindow(cdp, F.win); - - // Some canvas state is viewport-relative. Let a screenplay do its final, - // off-camera framing only after the theme reload and delivery window size - // have settled; doing this in setup can be invalidated by either operation. - if (sp.beforeRecord) { - console.log(" finalizing shot…"); - const before = await uiSnapshot(); - try { - await sp.beforeRecord(ctx); - } catch (error) { - const after = await uiSnapshot(); - const failure = logOperationalFailure({ - sp, locale, format, attempt, phase:"before-record", - error, before, after, - }); - console.error(` ↳ failure receipt ${failure.signature}`); - await cdp.close(); - throw error; - } - } - - if (PREFLIGHT_ONLY) { - const state = await uiSnapshot(); - const preflight = join(workDir, "preflight.json"); - writeFileSync(preflight, JSON.stringify({ - schema:"captutor-preflight/v1", - at:new Date().toISOString(), - screenplay:sp.slug, - locale, - format, - state, - }, null, 2) + "\n"); - const holdIndex = rest.indexOf("--hold-ms"); - const holdMs = holdIndex === -1 ? 0 : Number(rest[holdIndex + 1]); - if (!Number.isFinite(holdMs) || holdMs < 0 || holdMs > 600_000) { - throw new Error("--hold-ms must be between 0 and 600000"); - } - if (holdMs > 0) { - console.log(` holding 2× HiDPI preflight for live inspection (${holdMs}ms)…`); - await sleep(holdMs); - } - if (sp.teardown) await sp.teardown(ctx); - await cdp.close(); - console.log(`✓ preflight accepted — ${preflight}`); - return { preflight, state }; - } - - // Raise the window we are about to film. Not cosmetic: Chrome throttles - // rendering and requestAnimationFrame in a backgrounded window, which would - // stutter the drawn cursor and can leave `reel` filming a stale surface. - await cdp.send("Page.bringToFront"); - await sleep(600); - - clearFrameOverlays(); - - // /json retains the original Fuser URL and title after a renderer dies, so - // those fields are not a health check. Require the page itself to answer just - // before the camera starts; this catches a pre-existing "Aw, Snap!" without - // filming it or debiting a generation. - try { - await cdp.assertHealthy("pre-record"); - if (STAGE_MODE) { - const modalEvents = []; - const preparation = await createModalPolice({ onEvent: event => modalEvents.push(event) }).check("preparing"); - const banner = { - schema: "captutor-chrome-presentation/v1", checkedAt: preparation.checkedAt, - dismissed: modalEvents.some(event => event.kind === "automation-banner" && event.type === "handled"), - bannerFound: modalEvents.some(event => event.kind === "automation-banner"), - }; - await sleep(250); - const clean = { ...assertPresentationClean(), ...assertBookmarksHidden() }; - writeFileSync(join(workDir, "chrome-presentation.json"), JSON.stringify({...banner,...clean}, null, 2)); - } - const screen = await cdp.eval(`({ - width: screen.width, - height: screen.height, - dpr: window.devicePixelRatio, - })`); - if(NATIVE_DISPLAY)writeFileSync(join(workDir,"capture-geometry.json"),JSON.stringify({mode:"native",display:NATIVE_DISPLAY,browser:screen,output:F.out,preserveWindow:true},null,2)); - assertHiDPIStage({ - required:REQUIRE_HIDPI, - stageMode:STAGE_MODE, - vertical:VERTICAL_MODE, - nativeDisplay:NATIVE_DISPLAY, - screen, - }); - } catch (error) { - await cdp.close(); - throw error; - } - - const stageDisplay = STAGE_MODE && F.compose?.fullDesktop; - console.log(`\n● recording (${stageDisplay ? "full Stage desktop" : `window: ${sp.window || "whole display"}`})`); - // Start the native pointer before the reel. Besides making its first frame - // deterministic, the overlay process explicitly hides the physical macOS - // cursor until capture ends. Reel also excludes the system cursor unless an - // explicitly human-driven REAL_CURSOR take opts back in. - await startNativeCursor(); - let state; - try { - state = reelStart({ - // Stage Mode already supplies the neutral desktop and deliberately sized - // browser. Capture those real pixels so every rounded window edge and equal - // margin survives. Delivery repairs only the tiny recorder badge. - window: stageDisplay ? undefined : sp.window, - fps: sp.fps || 60, - cursor: REAL_CURSOR, - }); - } catch (error) { - stopNativeCursor(); - throw error; - } - const since = state.since; - if (!since) { - stopNativeCursor(); - throw new Error("reel did not report a start time — cannot sync audio"); - } - traceSince = since; - - let recording = true; - let activeBeat = -1; - const stopRecording = (out) => { - if (!recording) return out; - recording = false; - stopNativeCursor(); - return reelStop(out); - }; - - const take = (async () => { - await sleep((sp.leadInMs ?? 700)); // a beat of stillness before we start moving - if (sp.openingCard) { - const card = { - phase: "title", ...localizeCard(sp.openingCard), - }; - await perform("signboard", { card, role:"opening" }, - () => presentSignboard(cdp, card, { - durationMs:sp.openingCard.durationMs ?? 2400, - transition:sp.openingCard.transition, - })); - } - const result = []; - for (const beat of beats) { - activeBeat = beat.index; - const startedAt = now(); - const offsetSec = startedAt - since; - trace("beat", { index:beat.index, narration:beat.say }); - process.stdout.write( - ` ${String(beat.index + 1).padStart(2)}. @${offsetSec.toFixed(1)}s ${beat.say.slice(0, 52)}\n`); - - if (STAGE_MODE) assertPresentationClean(); - if (beat.do) await beat.do(ctx); - - // Hold the shot for at least as long as the line takes to say. If the action - // already outlasted it, we do NOT claw the time back — the next beat is - // stamped where it truly starts, so the voice stays glued to the picture. - const remain = beat.durationSec + (beat.holdMs ?? 350) / 1000 - (now() - startedAt); - if (remain > 0) await sleep(remain * 1000); - if (STAGE_MODE) assertPresentationClean(); - result.push({ ...beat, offsetSec }); - } - if (sp.closingCard) { - const card = { - phase: "end", ...localizeCard(sp.closingCard), - }; - await perform("signboard", { card, role:"closing" }, - () => presentSignboard(cdp, card, { - durationMs:sp.closingCard.durationMs ?? 2200, - transition:sp.closingCard.transition, - })); - } - setAmbient(); - await sleep((sp.tailMs ?? 900)); - return result; - })(); - - const upgradeGuard = (async () => { - while (recording) { - if (await upgradeVisible(cdp)) { - const stamp = new Date().toISOString().replaceAll(/[:.]/g, "-"); - const aborted = join(workDir, `aborted-upgrade-${stamp}.mp4`); - stopRecording(aborted); // stop the camera before waiting on an in-flight action - const error = new UpgradeInterruption({ - beat: Math.max(0, activeBeat), elapsed: now() - since, aborted, - }); - logFailure({ sp, locale, format, attempt, error }); - throw error; - } - await sleep(250); - } - return null; - })(); - - const browserGuard = (async () => { - while (recording) { - await cdp.assertHealthy(`recording beat ${Math.max(0, activeBeat) + 1}`); - await sleep(500); - } - return null; - })(); - - const modalPolice = STAGE_MODE ? createModalPolice({ - onEvent: event => { - appendFileSync(join(workDir, "modal-police.jsonl"), JSON.stringify(event) + "\n"); - process.stderr.write(JSON.stringify({ source: "modal-police", ...event }) + "\n"); - }, - }) : null; - const modalGuard = (async () => { - if (!modalPolice) return new Promise(() => {}); - while (recording) { - assertBookmarksHidden(); - await modalPolice.check("recording"); - await sleep(750); - } - return null; - })(); - let timed; - try { - timed = await Promise.race([take, upgradeGuard, browserGuard, modalGuard]); - } catch (err) { - if (err instanceof UpgradeInterruption) { - // The camera is already stopped. Let any in-flight screenplay promise - // settle before refreshing, otherwise its late click could leak into the - // retry's setup. - await take.catch(() => {}); - if (purse) { - await credits.settle(cdp, purse, { - slug: sp.slug, locale, format, aborted: true, reason: "upgrade-time", - }); - } - cdp.close(); - if (attempt > AUTO_RETRIES) { - throw new Error(`${err.message}; automatic retry limit (${AUTO_RETRIES}) exhausted`); - } - console.warn(`\n↻ logged and discarded interrupted take; retrying cleanly (${attempt}/${AUTO_RETRIES})`); - await sleep(900); - return cmdRender(sp, workDir, locale, format, attempt + 1); - } - - if (err instanceof BrowserCrashError) { - const stamp = new Date().toISOString().replaceAll(/[:.]/g, "-"); - const aborted = stopRecording(join(workDir, `aborted-browser-${stamp}.mp4`)); - logBrowserFailure({ - sp, locale, format, attempt, error:err, - beat:activeBeat, elapsed:now() - since, aborted, - }); - console.error(`\n✗ browser renderer crashed at beat ${Math.max(0, activeBeat) + 1}; take aborted`); - if (purse) { - await credits.settle(cdp, purse, { - slug:sp.slug, locale, format, aborted:true, reason:"browser-renderer-crash", - }).catch((settleError) => { - console.warn(` credit settlement unavailable after crash: ${settleError.message}`); - }); - } - await cdp.close(); - throw err; - } - - console.error(`\n✗ beat ${activeBeat + 1} failed: ${err.message}`); - stopRecording(join(workDir, "aborted.mp4")); - const after = await uiSnapshot(); - logOperationalFailure({ - sp, locale, format, attempt, phase:`beat-${activeBeat + 1}`, - error:err, - before:err.captutorTelemetry?.before || null, - after, - }); - stopRecording(join(workDir, "aborted.mp4")); - if (purse) await credits.settle(cdp, purse, { slug: sp.slug, locale, format, aborted: true }); - cdp.close(); - throw err; - } - - const clip = stopRecording(join(workDir, "clip.mp4")); - console.log(`■ ${clip}`); - - // Close the books while the browser is still up: what did this video cost? - // Written to out/takes.json, which is also what the take cap reads. - const settlement = purse - ? await credits.settle(cdp, purse, { slug: sp.slug, locale, format }) - : null; - await clearEffects(cdp).catch(() => {}); - if (sp.teardown) await sp.teardown(ctx); - cdp.close(); - - let compositionClip = clip; - let compositionTimed = timed; - let receiptEvents = storyboardEvents; - let bakeTime = null; - const sourceDurationSec = Number(probe(clip).format.duration); - const bakePlan = planBakeTime({ events:storyboardEvents, durationSec:sourceDurationSec }); - if (bakePlan.edits.length) { - compositionClip = join(workDir, "clip-bake-time.mp4"); - console.log(` folding ${bakePlan.edits.length} bake-time wait${bakePlan.edits.length === 1 ? "" : "s"}…`); - condenseBakeTimeVideo({ input:clip, output:compositionClip, plan:bakePlan, fps:sp.fps || 60 }); - compositionTimed = timed.map((beat) => ({ - ...beat, - sourceOffsetSec:beat.offsetSec, - offsetSec:bakePlan.mapTime(beat.offsetSec), - })); - receiptEvents = storyboardEvents.map((event) => ({ - ...event, - sourceAtSec:event.atSec, - atSec:bakePlan.mapTime(event.atSec), - })); - bakeTime = { - preset:bakePlan.preset, - sourceDurationSec:+bakePlan.sourceDurationSec.toFixed(3), - outputDurationSec:+bakePlan.outputDurationSec.toFixed(3), - removedSec:+(bakePlan.sourceDurationSec - bakePlan.outputDurationSec).toFixed(3), - edits:bakePlan.edits.map((edit) => Object.fromEntries( - Object.entries(edit).map(([key, value]) => [key, typeof value === "number" ? +value.toFixed(3) : value]), - )), - }; - } - - // A bake-time fold remaps offsets independently of the runner's own - // never-overlap invariant; re-assert it mechanically on whatever the final - // timeline turns out to be, before it's written anywhere. - compositionTimed = preventOverlappingCues(compositionTimed); - - const outMp4 = join(workDir, `${sp.slug}.mp4`); - const outVtt = join(workDir, `${sp.slug}.vtt`); - console.log("\n⧉ composing"); - - // Captions first — the mux embeds them as a subtitle track, so they have to - // exist before ffmpeg runs. - const n = writeVTT(compositionTimed, outVtt); - - // Keep the measured offsets. They are the only record of when each beat - // actually happened, and without them a re-compose would mean a re-shoot. - writeFileSync(join(workDir, "cues.json"), JSON.stringify( - compositionTimed.map(({ index, say, offsetSec, sourceOffsetSec, durationSec, mp3, words }) => - ({ index, say, offsetSec, sourceOffsetSec, durationSec, mp3, words })), null, 2)); - - const finalDurationSec = bakeTime ? bakeTime.outputDurationSec : sourceDurationSec; - const bed = sp.rhythmBed - ? rhythmBedClip({ durationSec:finalDurationSec, ...(sp.rhythmBed === true ? {} : sp.rhythmBed) }) - : null; - - mux({ - clip:compositionClip, beats:compositionTimed, out:outMp4, vtt:outVtt, - bed, bedDurationSec:bed ? finalDurationSec : null, - }); - console.log(` → ${outMp4} (soft subs)`); - console.log(` → ${outVtt} (${n} caption cues)`); - - const burned = join(workDir, `${sp.slug}.${format}.mp4`); - const r = deliver({ - clip:outMp4, cues:compositionTimed, format, out:burned, workDir, locale, - brandChrome:sp.brandChrome || null, - }); - const p = probe(burned); - console.log(` → ${burned}`); - console.log(` ${r.W}×${r.H} · ${(+p.format.duration).toFixed(1)}s · ${(p.format.size / 1e6).toFixed(1)} MB · burned captions`); - const storyboard = join(workDir, "storyboard.json"); - writeFileSync(storyboard, JSON.stringify({ - schema:"captutor-storyboard/v1", - createdAt:new Date().toISOString(), - screenplay:sp.slug, - locale, - format, - theme, - effectTheme:sp.effectTheme || null, - title:say(sp.title, locale), - subtitle:sp.subtitle == null ? "" : say(sp.subtitle, locale), - // QA receipts are operational documents, not localized deliverables. Keep - // an English copy beside the filmed language so reviewers can always read - // the acceptance evidence without changing the captions or narration. - receiptEnglish:{ - title:say(sp.title, "en"), - subtitle:sp.subtitle == null ? "" : say(sp.subtitle, "en"), - openingCard:sp.openingCard ? englishCard(sp.openingCard) : null, - closingCard:sp.closingCard ? englishCard(sp.closingCard) : null, - beats:sp.beats.map((beat) => ({ - narration:say(beat.say, "en"), - logic:beat.logic ? say(beat.logic, "en") : null, - cursorIntent:beat.cursorIntent ? say(beat.cursorIntent, "en") : null, - })), - }, - openingCard:sp.openingCard ? localizeCard(sp.openingCard) : null, - closingCard:sp.closingCard ? localizeCard(sp.closingCard) : null, - acceptance:sp.acceptance || null, - brandChrome:sp.brandChrome ? { id:sp.brandChrome.id || "client" } : null, - media:{ - file:basename(burned), width:r.W, height:r.H, - durationSec:+(+p.format.duration).toFixed(3), bytes:+p.format.size, - }, - credits:purse ? { - before:purse.spendable, - after:settlement?.after?.spendable ?? null, - spent:settlement?.spent ?? null, - } : null, - bakeTime, - beats:compositionTimed.map((beat) => ({ - index:beat.index, - offsetSec:+beat.offsetSec.toFixed(3), - sourceOffsetSec:beat.sourceOffsetSec == null ? null : +beat.sourceOffsetSec.toFixed(3), - durationSec:+beat.durationSec.toFixed(3), - narration:beat.say, - logic:sp.beats[beat.index].logic ? say(sp.beats[beat.index].logic, locale) : null, - cursorIntent:sp.beats[beat.index].cursorIntent - ? say(sp.beats[beat.index].cursorIntent, locale) : null, - })), - events:receiptEvents, - }, null, 2) + "\n"); - const receipt = join(workDir, `${sp.slug}.${format}.storyboard-receipt.pdf`); - const receiptResult = JSON.parse(execFileSync(process.execPath, [ - join(HERE, "bin", "storyboard-receipt.mjs"), - "--video", burned, "--storyboard", storyboard, "--out", receipt, - ], { encoding:"utf8" })); - if (!receiptResult.accepted) { - throw new Error(`storyboard QA requires review: ${receipt}`); - } - console.log(` → ${receipt} (storyboard + QA receipt)`); - return { outMp4, outVtt, burned, storyboard, receipt }; -} - -function cmdPublish(sp, workDir) { - const mp4 = join(workDir, `${sp.slug}.mp4`); - const vtt = join(workDir, `${sp.slug}.vtt`); - if (!existsSync(mp4)) throw new Error(`nothing rendered yet — run: captutor render ${sp.slug}`); - if (!existsSync(DOCS_PUBLIC)) throw new Error(`fuser docs not found at ${DOCS_PUBLIC}`); - - copyFileSync(mp4, join(DOCS_PUBLIC, `${sp.slug}.mp4`)); - copyFileSync(vtt, join(DOCS_PUBLIC, `${sp.slug}.vtt`)); - console.log(`→ ${join(DOCS_PUBLIC, `${sp.slug}.mp4`)}`); - console.log(`→ ${join(DOCS_PUBLIC, `${sp.slug}.vtt`)}`); - console.log(`\nMDX to drop into the page:\n`); - console.log(`\n`); - console.log(`NOTE: is currently muted+looping (it was built for silent`); - console.log(`clips). A narrated tutorial needs the \`narrated\` variant — see README.`); -} - -/// Cut the take to a delivery format — burned captions, reframed, re-encoded. -/// Reads clip.mp4 + cues.json, so it never touches the app: the recording is the -/// negative, and every format is just another print from it. -function cmdDeliver(sp, workDir, formats, locale) { - // Recut the composed master, not the raw ScreenCaptureKit negative. The - // master carries narration and its full duration; using clip.mp4 here made a - // caption-only recut silently lose audio and hide a short static video track. - const clip = join(workDir, `${sp.slug}.mp4`); - const cuesPath = join(workDir, "cues.json"); - if (!existsSync(clip) || !existsSync(cuesPath)) { - throw new Error(`no composed take to cut — run: captutor render ${sp.slug}`); - } - const cues = JSON.parse(readFileSync(cuesPath, "utf8")); - const rendered = []; - for (const format of formats) { - const out = join(workDir, `${sp.slug}.${format}.mp4`); - process.stdout.write(` ${format}… `); - const r = deliver({ - clip, cues, format, out, workDir, - locale, - title: say(sp.title, locale), - subtitle: say(sp.subtitle, locale), - brandChrome:sp.brandChrome || null, - }); - const p = probe(out); - console.log(`${r.W}×${r.H} · ${r.cues} captions · ${(p.format.size / 1e6).toFixed(1)} MB`); - console.log(` ${out}`); - rendered.push({ format, video: out }); - } - return rendered; -} - -const [cmd, ref, ...rest] = process.argv.slice(2); - -// Two commands take no screenplay: they are about the ACCOUNT, not a video. -// -// captutor login make sure Iris is signed in (and sign her in if not) -// captutor balance what she has left to spend, and what the last takes cost -// -// Both are also how you check the machine before leaving it alone overnight. -if (cmd === "login" || cmd === "balance") { - const cdp = await attach("fuser.studio"); - const r = await ensureSignedIn(cdp); - console.log(r.already ? "✓ already signed in" : "✓ signed in"); - - if (cmd === "balance") { - const bal = await credits.readCredits(cdp); - console.log(`\n✦ ${credits.fmt(bal.spendable)} credits` + - (bal.orgCredits != null ? ` (org pool; personal: ${credits.fmt(bal.credits)})` : "") + - ` · via ${bal.source}`); - console.log(` floor ${credits.fmt(credits.FLOOR)} · warn ${credits.fmt(credits.WARN_BELOW)}` + - ` · cap ${credits.MAX_TAKES} takes / ${credits.WINDOW_MIN} min`); - if (bal.spendable < credits.FLOOR) console.log(` ⚠️ BELOW THE FLOOR — render will refuse.`); - else if (bal.spendable < credits.WARN_BELOW) console.log(` ⚠️ low.`); - - const recent = credits.recentTakes(); - console.log(`\n ${recent.length} take(s) in the last ${credits.WINDOW_MIN} min` + - ` (cap ${credits.MAX_TAKES})`); - for (const t of credits.recentTakes(24 * 60).slice(-6)) { - console.log(` ${t.at.slice(0, 16).replace("T", " ")} ${t.slug}.${t.locale}.${t.format}` + - ` cost ${t.spent == null ? "?" : credits.fmt(t.spent)}✦${t.aborted ? " (aborted)" : ""}`); - } - } - cdp.close(); - process.exit(0); -} - -if (!cmd || !ref) { - console.log("usage: captutor [--format docs,youtube,reel] [--outbox ] [--hold-ms N]"); - console.log(" captutor "); - process.exit(ref ? 1 : 0); -} - -const sp = await loadScreenplay(ref); - -// Each language is its own take: the UI is in that language, so the pixels differ -// — you cannot dub a screen recording. Keep them in separate directories. -const li = rest.indexOf("--locale"); -const locale = li === -1 ? "en" : rest[li + 1]; -if (!LANGUAGES[locale]) { - throw new Error(`unknown locale "${locale}" — fuser ships: ${Object.keys(LANGUAGES).join(", ")}`); -} -// A take is (screenplay × locale × aspect): the UI language changes the pixels, -// and so does the window shape. None of them can be dubbed or cropped out of -// another, so each gets its own directory and its own recording. -const fi = rest.indexOf("--format"); -const format = fi === -1 ? "docs" : rest[fi + 1]; -if (!FORMATS[format]) throw new Error(`unknown format "${format}" — have: ${Object.keys(FORMATS).join(", ")}`); -const workDir = join(HERE, "out", `${sp.slug}.${locale}.${format}`); -mkdirSync(workDir, { recursive: true }); - -if (cmd === "narrate") await cmdNarrate(sp, workDir, locale); -else if (cmd === "preflight") { - await cmdRender(sp, workDir, locale, format); - // Preflight intentionally stops before the recorder lifecycle. Some Node - // builds retain an idle native WebSocket handle after CDP closes; do not - // strand Stage Mode waiting for an otherwise-finished diagnostic child. - process.exit(0); -} -else if (cmd === "render") { - const rendered = await cmdRender(sp, workDir, locale, format); - const oi = rest.indexOf("--outbox"); - const outbox = oi === -1 ? process.env.CAPTUTOR_OUTBOX : rest[oi + 1]; - if (oi !== -1 && !outbox) throw new Error("--outbox needs a directory"); - if (outbox) { - const delivery = publishToOutbox({ - outbox, - video: rendered.burned, - captions: rendered.outVtt, - screenplay: sp.slug, - locale, - format, - taskGid: process.env.CAPTUTOR_TASK_GID || null, - storyboard: rendered.storyboard, - receipt: rendered.receipt, - }); - console.log(`\n⇢ outbox ${delivery.video}`); - console.log(` ${delivery.manifest}`); - } -} -else if (cmd === "publish") cmdPublish(sp, workDir); -else if (cmd === "deliver") { - const i = rest.indexOf("--format"); - const formats = i === -1 ? Object.keys(FORMATS) : rest[i + 1].split(","); - console.log(`\n⧉ cutting ${sp.slug} (${LANGUAGES[locale].native}) → ${formats.join(", ")}`); - const rendered = cmdDeliver(sp, workDir, formats, locale); - const oi = rest.indexOf("--outbox"); - const outbox = oi === -1 ? process.env.CAPTUTOR_OUTBOX : rest[oi + 1]; - if (oi !== -1 && !outbox) throw new Error("--outbox needs a directory"); - if (outbox) { - const captions = join(workDir, `${sp.slug}.vtt`); - for (const cut of rendered) { - const delivery = publishToOutbox({ - outbox, - video: cut.video, - captions, - screenplay: sp.slug, - locale, - format: cut.format, - taskGid: process.env.CAPTUTOR_TASK_GID || null, - }); - console.log(`\n⇢ outbox ${delivery.video}`); - console.log(` ${delivery.manifest}`); - } - } -} -else { console.error(`unknown command: ${cmd}`); process.exit(1); } diff --git a/slab/captutor/lib/deliver.mjs b/slab/captutor/lib/deliver.mjs deleted file mode 100644 index 5a52e18999..0000000000 --- a/slab/captutor/lib/deliver.mjs +++ /dev/null @@ -1,529 +0,0 @@ -import {assertNativePixels} from "./native-display.mjs"; -// deliver — burn captions and cut the take to standard formats. -// -// One recording, many deliverables. Everything here re-composes from `clip.mp4` -// plus `cues.json` (the measured beat offsets), so a new format or a caption -// restyle costs an ffmpeg pass — never another take. That is the point of -// keeping the cues: the app is driven once, and the edit is cheap forever after. -// -// Captions are BURNED with ImageMagick + ffmpeg `overlay`, not libass. This Mac's -// ffmpeg has no libass, no freetype and no fontconfig (`ffmpeg -version` shows -// none of them — the same reason recap's composer cannot run here), so -// `subtitles=` and `drawtext` are both unavailable. /pop hit this first and -// solved it the same way: pre-rasterize each caption to a transparent PNG in -// ImageMagick, which CAN load a font file directly, then composite the PNGs as -// timed overlays. Do not "simplify" this back to drawtext; it renders nothing. -// -// Soft subtitles still ship too (the mov_text track + .vtt sidecar). Burned text -// is for platforms that autoplay muted and strip tracks — reels, shorts, feeds. - -import { execFileSync } from "node:child_process"; -import { createHash } from "node:crypto"; -import { existsSync, mkdirSync, unlinkSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; -import { captionPhrases, isHighlightableCaptionToken } from "./captions.mjs"; -import { applyBrandChrome } from "./brand-chrome.mjs"; - -const FFMPEG = process.env.FFMPEG || "ffmpeg"; -const STAGE_MODE = process.env.CAPTUTOR_STAGE_MODE === "1"; -const VERTICAL_MODE = process.env.CAPTUTOR_VERTICAL_MODE === "1"; - -// Deliberately ordinary subtitle typography. Captions are navigation, not a -// brand surface: regular Arial stays readable over a busy UI and produces the -// familiar neutral shape people already recognize as subtitles. -const LATIN_FONT = process.env.CAPTUTOR_FONT - || "/System/Library/Fonts/Supplemental/Arial.ttf"; -const CAPTION_STYLE = "outlined-color-caption-karaoke-v9-sharp-shadow"; - -// Arial does not cover every script, so non-Latin locales use the corresponding -// macOS system face instead of silently dropping glyphs. -const SCRIPT_FONTS = { - "ko": "/System/Library/Fonts/AppleSDGothicNeo.ttc", - "zh-CN": "/System/Library/Fonts/Hiragino Sans GB.ttc", // NOT PingFang — see below - "hi": "/System/Library/Fonts/Kohinoor.ttc", - "fa": "/System/Library/Fonts/Supplemental/GeezaPro.ttc", -}; - -/// Pick a font that can actually draw this locale — and refuse to guess. -/// -/// A missing font file does not error: ImageMagick falls back, drops every glyph -/// it cannot draw, and hands back a caption containing only the punctuation. We -/// shipped a whole Chinese take that way (PingFang is NOT at -/// /System/Library/Fonts/PingFang.ttc, so it silently used the Latin face and the burned -/// captions read just "App"). Throwing here is the only way that stays fixed. -function fontFor(locale) { - const alt = SCRIPT_FONTS[locale]; - if (!alt) return LATIN_FONT; - if (!existsSync(alt)) { - throw new Error( - `no font for "${locale}" at ${alt}. The Latin face cannot draw this script, and ` + - `falling back to it would silently produce blank captions.`); - } - return alt; -} - -/// Did the glyphs actually land? -/// -/// The backstop for the above: a caption with text in it must have ink in it. If -/// the rasterizer dropped the script, the PNG comes back essentially empty — -/// catch that here rather than in the finished video. -function assertHasInk(png, text) { - const mean = +execFileSync("magick", [ - png, "-alpha", "extract", "-format", "%[fx:mean]", "info:", - ], { encoding: "utf8" }).trim(); - if (mean < 0.002) { - throw new Error( - `caption rendered blank: ${JSON.stringify(text.slice(0, 40))}\n` + - ` the font (${FONT}) has no glyphs for this script.`); - } -} - -let FONT = LATIN_FONT; // set per-render by deliver() -const TEXT = "#ffffff"; // plain white — subtitles are not a brand surface -const ACTIVE_TEXT = "#facc15"; // warm yellow — familiar, restrained karaoke cue -const BG = "#0a0a0a"; // neutral-950 -const ACCENT = "#4f46e5"; // indigo-600 — the app's own action colour - -// A clamshell Mac can expose a true HiDPI desktop that is taller than the -// 16:9 docs delivery while still matching its full output width. Keep that -// extra source resolution honest: accept only an exact-width, at-least-tall -// full-desktop negative, then remove equal amounts from the top and bottom. -// Smaller inputs and arbitrary window-shaped captures remain rejected. -export function fullDesktopCrop({ source, target }) { - if (source.w !== target.w || source.h < target.h) { - throw new Error( - `full-desktop negative must be ${target.w}px wide and at least ${target.h}px tall; ` + - `got ${source.w}x${source.h}`, - ); - } - if (source.h === target.h) return ""; - const y = Math.floor((source.h - target.h) / 2); - return `crop=${target.w}:${target.h}:0:${y},`; -} - -// NOTE: ImageMagick here has no fontconfig, so there is no default font at all — -// omit `-font` and it errors rather than guessing. That is a feature: a silent -// fallback to Helvetica would be off-brand and nobody would notice. - -/// Target geometries. -/// -/// `video` is where the recording sits in the frame; `cap` is the caption band. -/// Fractions are of the output frame, so a format is one small table entry and -/// not a pile of special cases. -/// EACH ASPECT RATIO IS ITS OWN RECORDING. -/// -/// The obvious shortcut — film once in landscape and crop a vertical window out -/// of it for reels — is what makes every software reel on the internet look bad: -/// a letterboxed desktop with dead bars, UI too small to read, and a crop that is -/// always looking slightly at the wrong thing. -/// -/// fuser's UI is responsive. So instead we RESIZE THE BROWSER WINDOW to the -/// target shape and drive the tutorial again. A portrait window gives the app's -/// real narrow layout, filmed natively at 9:16 — legible, correctly composed, and -/// not a crop of anything. It costs another take; takes are cheap and the app is -/// driven by a script. -/// -/// `win` is the OUTER window in CSS points — it includes the title bar, tab strip -/// and URL bar, because `reel` films the window, not the page. Size the window to -/// the delivery aspect and the chrome is part of the composition rather than -/// something to crop off later. -export const FORMATS = { - // Docs. A wide window at the shape a reader's own browser is: the clip should - // look like the app they are looking at. - docs: { - // Stage Mode runs the display at 2× HiDPI. A 1190×630-point window becomes - // 2380×1260 pixels: exactly 90 pixels of breathing room on every side of a - // 2560×1440 delivery. The window is intentionally wider than 16:9 so the - // frame's margins, rather than the browser's aspect ratio, set the geometry. - win: STAGE_MODE ? { w: 1190, h: 630 } : { w: 1512, h: 945 }, - out: STAGE_MODE ? { w: 2560, h: 1440 } : { w: 1512, h: 945 }, - fps: STAGE_MODE ? 60 : 30, - compose: STAGE_MODE ? { fullDesktop: true, badgeRepair: true } : undefined, - // Stage recordings are viewed inside a docs player, often at half their - // encoded size. Use presentation-scale captions so they remain readable, - // and lift them slightly to give the classic outline breathing room. - capWidth: 0.84, - capPx: STAGE_MODE ? 58 : 44, - capY: STAGE_MODE ? 0.86 : 0.90, - bar: false, - }, - // A real portrait desktop, not a landscape take cropped into a phone frame. - // `bin/stage.mjs --vertical` rotates Panda and selects the panel's 2× mode; - // 630×1190 points therefore records as 1260×2380 pixels with a uniform - // 90-pixel surround in the native 1440×2560 delivery. - vertical: { - win: { w: 630, h: 1190 }, - out: { w: 1440, h: 2560 }, - fps: 60, - compose: { fullDesktop: true, badgeRepair: true }, - capWidth: 0.88, - capPx: 58, - capY: 0.88, - bar: false, - requiresVerticalStage: !VERTICAL_MODE, - }, - // YouTube. A true 16:9 window, so the frame IS the window — no pillarboxing. - youtube: { - win: { w: 1600, h: 900 }, // 16:9 - out: { w: 1920, h: 1080 }, - capWidth: 0.80, capPx: 44, capY: 0.90, - bar: true, - }, - // Reels / Shorts / TikTok. - // - // A native 9:16 window is IMPOSSIBLE here and it is worth saying why: this Mac - // reports screen.availHeight = 851 points, so the tallest window Chrome will - // give us is 851. A true 9:16 at that height would be 479px wide — narrower - // than Chrome's minimum, and fuser would collapse to its phone layout. - // - // So the window is as TALL as the display allows and comfortably WIDE, and the - // portrait frame is composed around it. The win is legibility: a 1000px-wide - // window scaled into a 1080px-wide frame is 1.08× — the UI ends up BIGGER than - // life. Fitting the old 2054px landscape window into the same frame was 0.52×, - // which is precisely why that reel was unreadable. - reel: { - win: { w: 1000, h: 851 }, // as tall as the display permits - out: { w: 1080, h: 1920 }, - compose: { videoY: 0.09 }, // rides high; the stage below is for type - capWidth: 0.90, capPx: 56, capY: 0.66, - bar: true, - }, -}; - -const metricCache = new Map(); - -// Caption art is expensive enough to cache, but its filename must carry the -// words and layout that produced it. Index-only names reused stale PNGs after a -// screenplay edit: fresh timings then composited old copy, causing ghost text -// and collisions that appeared only in a previously rendered format. -export function captionCacheKey({ words, width, px, font, color }) { - return createHash("sha256").update(JSON.stringify({ - style:CAPTION_STYLE, - words:words.map((word) => word.text), - width, px, font, color:color || null, - })).digest("hex").slice(0, 16); -} - -function textMetrics(text, px) { - const key = `${FONT}\0${px}\0${text}`; - if (metricCache.has(key)) return metricCache.get(key); - const [w, h] = execFileSync("magick", [ - "-background", "none", "-font", FONT, "-pointsize", String(px), - `label:${text}`, "-format", "%w,%h", "info:", - ], { encoding: "utf8" }).trim().split(",").map(Number); - const value = { w, h }; - metricCache.set(key, value); - return value; -} - -/// Lay out explicit words rather than asking `caption:` to hide its wrapping -/// decisions. Besides making the result deterministic, this gives each word an -/// exact position so its fill can change while it is spoken without moving the -/// phrase by even one pixel. -function layoutWords(words, { width, px }) { - const sample = textMetrics("Ag", px); - const space = Math.max(1, textMetrics("A A", px).w - textMetrics("AA", px).w); - const lineHeight = Math.ceil(sample.h * 1.16); - const lines = []; - let line = { words: [], width: 0 }; - for (const [index, word] of words.entries()) { - const measured = textMetrics(word.text, px); - const gap = line.words.length ? space : 0; - if (line.words.length && line.width + gap + measured.w > width) { - lines.push(line); - line = { words: [], width: 0 }; - } - const x = line.width + (line.words.length ? space : 0); - line.words.push({ ...word, index, x, width: measured.w }); - line.width = x + measured.w; - } - if (line.words.length) lines.push(line); - - const height = Math.max(lineHeight, lines.length * lineHeight); - return { - width, height, - boxes: lines.map((row, rowIndex) => { - const padX = Math.round(px * 0.30); - const padY = Math.round(px * 0.11); - const inset = Math.round((width - row.width) / 2); - return { - x1: inset - padX, - y1: rowIndex * lineHeight - padY, - x2: inset + row.width + padX, - y2: (rowIndex + 1) * lineHeight + padY, - }; - }), - words: lines.flatMap((row, rowIndex) => { - const inset = Math.round((width - row.width) / 2); - return row.words.map((word) => ({ - ...word, - x: inset + word.x, - // `-draw text` takes a BASELINE coordinate. Every word on a row shares - // this exact value, unlike `-annotate`, which offsets each token from - // its own glyph bounds and makes short words visibly bob up and down. - baseline: Math.round( - rowIndex * lineHeight + (lineHeight - sample.h) / 2 + sample.h * 0.79, - ), - })); - }), - }; -} - -/// Rasterize a large color-coded subtitle over a compact translucent black -/// box, with a tight outline and hanging shadow for legibility over product UI. -/// `activeIndex` changes only the spoken word's fill for timed tracking. -function cuePng(words, { - width, px, out, activeIndex = -1, highlightOnly = false, color = TEXT, -}) { - const layout = layoutWords(words, { width, px }); - const mvg = (text) => text.replaceAll("\\", "\\\\").replaceAll('"', '\\"'); - const args = ["-size", `${layout.width}x${layout.height}`, "xc:none"]; - if (!highlightOnly) { - args.push("-fill", "rgba(0,0,0,0.68)", "-stroke", "none"); - for (const box of layout.boxes) { - const radius = Math.round(px * 0.16); - args.push( - "-draw", - `roundrectangle ${box.x1},${box.y1},${box.x2},${box.y2},${radius},${radius}`, - ); - } - } - args.push( - "-font", FONT, "-pointsize", String(px), - "-stroke", "rgba(0,0,0,.98)", "-strokewidth", String(Math.max(1, Math.round(px * 0.022))), - ); - // A compact, high-opacity hanging shadow keeps large outlined transcript - // labels crisp over live browser UI. Draw it as a second glyph instead of a - // broad blur so fine Devanagari counters and UI details remain clear. - const shadowOffset = Math.max(2, Math.round(px * 0.038)); - for (const word of layout.words) { - if (highlightOnly && word.index !== activeIndex) continue; - args.push( - "-fill", "rgba(24,18,31,.92)", - "-stroke", "rgba(165,140,188,.86)", - "-strokewidth", String(Math.max(1, Math.round(px * 0.014))), - "-draw", `text ${word.x + 1},${word.baseline + shadowOffset} \"${mvg(word.text)}\"`, - ); - } - args.push( - "-stroke", "rgba(0,0,0,.98)", - "-strokewidth", String(Math.max(1, Math.round(px * 0.022))), - ); - for (const word of layout.words) { - if (highlightOnly && word.index !== activeIndex) continue; - args.push( - "-fill", highlightOnly ? "#ffffff" : (color || TEXT), - "-draw", `text ${word.x},${word.baseline} \"${mvg(word.text)}\"`, - ); - } - args.push(out); - execFileSync("magick", args); - const ink = highlightOnly ? words[activeIndex]?.text : words.map((word) => word.text).join(" "); - assertHasInk(out, ink || "highlight"); - return out; -} - -function probeDims(clip) { - const raw = execFileSync("ffprobe", [ - "-v", "error", "-select_streams", "v:0", - "-show_entries", "stream=width,height", "-of", "csv=p=0", clip, - ], { encoding: "utf8" }).trim().split(","); - return { w: +raw[0], h: +raw[1] }; -} - -function duration(clip) { - return +execFileSync("ffprobe", [ - "-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", clip, - ], { encoding: "utf8" }).trim(); -} - -function videoDuration(clip) { - return +execFileSync("ffprobe", [ - "-v", "error", "-select_streams", "v:0", - "-show_entries", "stream=duration", "-of", "csv=p=0", clip, - ], { encoding: "utf8" }).trim(); -} - -export function deliver({ - clip, cues, format, out, workDir, locale = "en", brandChrome = null, - geometry = null, captionPx = null, captionY = null, -}) { - FONT = fontFor(locale); // brand face for Latin, script-capable fallback otherwise - const F = FORMATS[format]; - if (!F) throw new Error(`unknown format: ${format} (have: ${Object.keys(FORMATS).join(", ")})`); - - const src = probeDims(clip); - const dur = duration(clip); - const videoDur = videoDuration(clip); - // ScreenCaptureKit may stop emitting frames when a desktop is perfectly - // static even though narration is still running. Never let that truncate the - // visual stream: hold its final valid frame through the audio duration. - const pad = Math.max(0, dur - videoDur); - // Give the hold a small overrun and let `-shortest` trim to narration. Some - // ScreenCaptureKit files carry a final-frame timestamp almost one second - // earlier than their reported stream duration; padding only the arithmetic - // delta can therefore still leave a short video track in the final MP4. - const holdLastFrame = pad > 0.02 - ? `tpad=stop_mode=clone:stop_duration=${(pad + 2).toFixed(3)},` - : ""; - const W = geometry?.w || F.out.w; - const H = geometry?.h || F.out.h; - const capPx = captionPx || F.capPx; - const capY = captionY ?? F.capY; - - // Caption PNGs are cached because multilingual rasterization is expensive. - // Version the directory so a style change can never silently reuse an older - // size or outline during a cheap recut of an existing take. - const capDir = join(workDir, `caps-${format}-${CAPTION_STYLE}`); - mkdirSync(capDir, { recursive: true }); - - const band = Math.round(W * F.capWidth); - const cuts = captionPhrases(cues); - const pngs = cuts.map((c, i) => { - const stem = String(i).padStart(3, "0"); - const cacheKey = captionCacheKey({ - words:c.words, width:band, px:capPx, font:FONT, color:c.color, - }); - const base = join(capDir, `${stem}-${cacheKey}-base.png`); - if (!existsSync(base)) cuePng(c.words, { - width:band, px:capPx, out:base, color:c.color, - }); - const highlights = c.words - .map((word, wordIndex) => ({ word, wordIndex })) - .filter(({ word }) => isHighlightableCaptionToken(word.text)) - .map(({ word, wordIndex }) => { - const png = join(capDir, - `${stem}-${cacheKey}-word-${String(wordIndex).padStart(2, "0")}.png`); - if (!existsSync(png)) { - cuePng(c.words, { - width:band, px:capPx, out:png, activeIndex:wordIndex, - highlightOnly:true, color:c.color, - }); - } - return { ...word, png }; - }); - return { ...c, png: base, highlights }; - }); - - // Each phrase has one stable box + white-text base. Timed layers contain only - // one transparent yellow word, so the box and inactive text are never stacked - // twice. The old full-phrase highlight layers darkened the box on every word - // and briefly doubled it at boundaries, producing the reported gray flicker. - const captionLayers = pngs.flatMap((phrase) => [ - { from: phrase.from, to: phrase.to, png: phrase.png }, - ...phrase.highlights, - ]); - - // ── video base ──────────────────────────────────────────────────────────── - const args = ["-y", "-i", clip]; - const firstCaptionInput = 1; - for (const p of captionLayers) args.push("-i", p.png); - - const chain = []; - if (F.compose?.fullDesktop) { - let desktopCrop; - try { - if(F.nativeResolution)assertNativePixels(src,{w:W,h:H}); - desktopCrop = fullDesktopCrop({ source: src, target: { w: W, h: H } }); - } catch { - throw new Error( - `${format} Stage delivery needs a ${W}px-wide full-desktop negative at least ${H}px tall; ` + - `got ${src.w}x${src.h}. Record a new take instead of enlarging or recutting a window capture.`, - ); - } - // Preserve the complete 16:9 center of the physical desktop. A taller - // clamshell negative is cropped symmetrically, never enlarged. The sole repair is the tiny - // ScreenCaptureKit status dot at the extreme top-right: clone a live 2×2 - // sample of the adjacent stage wallpaper over a 34×28 patch. No browser or - // window pixels are touched. - chain.push( - `[0:v]${holdLastFrame}${desktopCrop}scale=${W}:${H},split=2[desktop][badgeSeed]`, - `[badgeSeed]crop=2:2:${W - 62}:12,scale=34:28:flags=neighbor[badgePatch]`, - `[desktop][badgePatch]overlay=${W - 34}:0:shortest=1[base]`); - } else if (F.compose) { - // Portrait frame, landscape-ish window: fill the WIDTH (so the UI is scaled - // UP, not down) and ride high, leaving the lower third as a caption stage. - const vw = Math.round(W / 2) * 2; - const vh = Math.round((vw * src.h / src.w) / 2) * 2; - const vy = Math.round(H * F.compose.videoY); - chain.push( - `color=c=${BG}:s=${W}x${H}:d=${dur.toFixed(3)},format=yuva420p[bg]`, - `[0:v]${holdLastFrame}scale=${vw}:${vh}[vid]`, - `[bg][vid]overlay=(W-w)/2:${vy}[base]`); - } else { - // Filmed at this exact shape already — just scale to the delivery size. - chain.push( - `[0:v]${holdLastFrame}scale=${W}:${H}:force_original_aspect_ratio=decrease,` + - `pad=${W}:${H}:(ow-iw)/2:(oh-ih)/2:color=${BG},format=yuva420p[base]`); - } - - // ── burned captions ─────────────────────────────────────────────────────── - // One timed overlay per phrase. `enable=between(t,…)` gates each PNG to the - // window its words are actually spoken in — the offsets came from the real - // take, so the type lands on the frame it describes. - let last = "base"; - captionLayers.forEach((c, i) => { - const label = i === captionLayers.length - 1 && !F.bar ? "outv" : `o${i}`; - const y = `${Math.round(H * capY)}-h/2`; - chain.push( - `[${last}][${firstCaptionInput + i}:v]overlay=(W-w)/2:${y}` + - `:enable='between(t,${c.from.toFixed(3)},${c.to.toFixed(3)})'[${label}]`); - last = label; - }); - - // ── progress bar ────────────────────────────────────────────────────────── - if (F.bar) { - const bh = Math.max(6, Math.round(H * 0.006)); - chain.push( - `[${last}]drawbox=x=0:y=${H - bh}:w='iw*t/${dur.toFixed(3)}':h=${bh}` + - `:color=${ACCENT}@0.95:t=fill[outv]`); - last = "outv"; - } - if (last !== "outv") chain.push(`[${last}]null[outv]`); - - // The body is encoded to settings a title card can be concatenated onto: - // fixed fps, yuv420p, stereo 48k. Without pinning these the two halves differ and - // the concat demuxer silently drops one of them. - const FPS = F.fps || 30; - - const encodedOut = brandChrome ? `${out}.pre-brand.mp4` : out; - args.push( - // Hundreds of short caption PNG inputs can make ffmpeg eagerly create more - // scaler workers than macOS will grant, producing a nondeterministic - // `Resource temporarily unavailable` before frame zero. One filter graph - // thread is fast enough for this offline pass and removes that ceiling. - "-filter_complex_threads", "1", - "-filter_complex", chain.join(";"), - "-map", "[outv]", "-map", "0:a?", - "-r", String(FPS), - "-c:v", "libx264", - "-preset", STAGE_MODE ? "slow" : "medium", - "-crf", STAGE_MODE ? "15" : "19", - "-pix_fmt", "yuv420p", - // Narration can end before a programmed closing signboard. Extend the - // audio with digital silence through the full filmed negative so - // `-shortest` trims to the picture, not to the last spoken word. - "-af", `apad=whole_dur=${dur.toFixed(3)}`, - "-c:a", "aac", "-b:a", "192k", "-ar", "48000", "-ac", "2", - "-shortest", - "-movflags", "+faststart", - encodedOut); - - try { - execFileSync(FFMPEG, args, { stdio: ["ignore", "ignore", "pipe"] }); - } catch (err) { - // execFileSync throws with stderr as a raw Buffer; printed straight it is a - // wall of byte codes. Surface the last few lines — that is where ffmpeg says - // which filter it choked on. - const msg = (err.stderr?.toString() || "").trim().split("\n").slice(-22).join("\n"); - throw new Error(`ffmpeg failed (${format}):\n${msg}`); - } - if (brandChrome) { - try { - applyBrandChrome({ input:encodedOut, out, theme:brandChrome, workDir, format }); - } finally { - if (existsSync(encodedOut)) unlinkSync(encodedOut); - } - } - return { out, W, H, cues: pngs.length }; -} diff --git a/slab/captutor/lib/narrate.mjs b/slab/captutor/lib/narrate.mjs deleted file mode 100644 index 3e15c66baf..0000000000 --- a/slab/captutor/lib/narrate.mjs +++ /dev/null @@ -1,103 +0,0 @@ -import {narrationRequest} from './narration-voice.mjs'; -// narrate — turn a screenplay's spoken lines into audio + word timings. -// -// This runs BEFORE anything is recorded, and that inversion is the whole design. -// The usual way to make a tutorial is to film it and then fight the narration -// into sync. Here the voice exists first, so we know exactly how long every beat -// lasts before the camera rolls — the UI performs to a click track. -// -// /api/say (jeffrey PVC) with `withTimestamps` returns ElevenLabs' per-CHARACTER -// alignment, so captions are exact and free: no whisper pass, no forced aligner, -// and none of the transcription-fixup ("notepat" → "Notepad") that every other -// pipeline in this repo needs, because we already know the words — we wrote them. -// -// NOTE the request field is `from`, not `text`. say.js does -// `body.from || "aesthetic.computer"` — pass the wrong key and it cheerfully -// returns 200 with a perfectly-aligned recording of the words "aesthetic -// computer". Verify alignment.characters matches your line. - -import { createHash } from "node:crypto"; -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; - -const SAY_URL = process.env.SAY_ENDPOINT || "https://aesthetic.computer/api/say"; - -/// Fold ElevenLabs' per-character alignment into words. A word is a run of -/// non-space characters; its span is [first char start, last char end]. -/// The shape ({text, fromMs, toMs}) is deliberately recap's words.json — so -/// recap's subtitle machinery can read it unchanged. -function wordsFromAlignment(a) { - const chars = a.characters || []; - const starts = a.character_start_times_seconds || []; - const ends = a.character_end_times_seconds || []; - const words = []; - let i = 0; - while (i < chars.length) { - if (/\s/.test(chars[i])) { i++; continue; } - const from = starts[i]; - let text = ""; - let to = ends[i]; - while (i < chars.length && !/\s/.test(chars[i])) { - text += chars[i]; - to = ends[i]; - i++; - } - words.push({ text, fromMs: Math.round(from * 1000), toMs: Math.round(to * 1000) }); - } - return words; -} - -/// Speak one line. Content-hash cached — ElevenLabs bills per character, and a -/// screenplay gets re-rendered many times while its blocking is tuned. -async function speak(line, { voice = "jeffrey", dir }) { - const request = narrationRequest(voice,line); - const key = createHash("sha256").update(`provider-v2:${JSON.stringify(request)}`).digest("hex").slice(0, 16); - const mp3 = join(dir, `${key}.mp3`); - const meta = join(dir, `${key}.json`); - - if (existsSync(mp3) && existsSync(meta)) { - return { mp3, ...JSON.parse(readFileSync(meta, "utf8")), cached: true }; - } - - const res = await fetch(SAY_URL, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(request), - redirect: "follow", - }); - if (!res.ok) throw new Error(`/api/say ${res.status}: ${await res.text()}`); - - const json = await res.json(); - const align = json.alignment || {}; - const spoken = (align.characters || []).join(""); - // The silent-default trap, caught loudly. If say.js fell back we'd otherwise - // ship a tutorial narrated as "aesthetic computer" over the right pictures. - if (spoken.trim() !== line.trim()) { - throw new Error( - `/api/say returned alignment for a DIFFERENT line.\n` + - ` asked: ${JSON.stringify(line)}\n` + - ` got: ${JSON.stringify(spoken)}\n` + - ` (is the request field still \`from\`?)`); - } - - writeFileSync(mp3, Buffer.from(json.audio, "base64")); - const words = wordsFromAlignment(align); - const durationSec = words.length ? words[words.length - 1].toMs / 1000 : 0; - const info = { line, voice, words, durationSec }; - writeFileSync(meta, JSON.stringify(info, null, 2)); - return { mp3, ...info, cached: false }; -} - -/// Narrate every beat. Returns the beats enriched with {mp3, words, durationSec}. -export async function narrate(beats, { voice = "jeffrey", dir }) { - mkdirSync(dir, { recursive: true }); - const out = []; - for (const [i, beat] of beats.entries()) { - const spoken = await speak(beat.say, { voice, dir }); - process.stdout.write( - ` ${String(i + 1).padStart(2)}. ${spoken.durationSec.toFixed(1)}s` + - `${spoken.cached ? " (cached)" : ""} ${beat.say.slice(0, 58)}\n`); - out.push({ ...beat, ...spoken, index: i }); - } - return out; -} diff --git a/slab/captutor/lib/stage-mode.mjs b/slab/captutor/lib/stage-mode.mjs deleted file mode 100644 index 8be3100cb9..0000000000 --- a/slab/captutor/lib/stage-mode.mjs +++ /dev/null @@ -1,556 +0,0 @@ -// Captutor Stage Mode — temporarily turn Panda into a clean HiDPI film stage. - -import { spawnSync } from "node:child_process"; -import { macPalStage } from "./stage-overlays.mjs"; -const macpal = macPalStage(); -import { chromeBookmarksStage } from "./chrome-bookmarks.mjs"; -const bookmarks = chromeBookmarksStage(); -import { - copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync, -} from "node:fs"; -import { homedir } from "node:os"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; - -const HOME = homedir(); -const STATE = join(HOME, ".local", "share", "captutor", "stage-mode.json"); -const SIGILS_OFF = join(HOME, ".local", "share", "slab", "state", "prompt-sigils-off"); -const BADGE_PLIST = join(HOME, "Library", "LaunchAgents", "computer.aesthetic.desktopbadge.plist"); -const POINTER_SOURCE = fileURLToPath(new URL("../bin/captutor-pointer.swift", import.meta.url)); -const POINTER_BIN = join(HOME, ".local", "bin", "captutor-pointer"); -const CURSOR_SOURCE = fileURLToPath(new URL("../bin/captutor-cursor.swift", import.meta.url)); -const CURSOR_BIN = join(HOME, ".local", "bin", "captutor-cursor"); -const WALLPAPER_SOURCE = process.env.CAPTUTOR_WALLPAPER_SOURCE - || fileURLToPath(new URL("../bin/captutor-wallpaper.swift", import.meta.url)); -const WALLPAPER_LOGO = fileURLToPath(new URL("../assets/fuser-thumbnail-logo.svg", import.meta.url)); -const WALLPAPER_MARK = fileURLToPath(new URL("../assets/fuser-mark.svg", import.meta.url)); -const WALLPAPER_METABALLS = [0, 1, 2].map((variant) => - fileURLToPath(new URL(`../assets/fuser-metaballs-${variant}.png`, import.meta.url))); -const WALLPAPER_APP = join(HOME, ".local", "share", "captutor", "Captutor Wallpaper.app"); -const WALLPAPER_BIN = join(WALLPAPER_APP, "Contents", "MacOS", "CaptutorWallpaper"); -const WALLPAPER_STATE = join(HOME, ".local", "share", "captutor", "wallpaper-card.json"); -const WALLPAPER = "/System/Library/Desktop Pictures/Solid Colors/Space Gray.png"; -const DISPLAYPLACER = "/opt/homebrew/bin/displayplacer"; - -export function normalizeStageBrand(value = "fuser") { - const brand = String(value || "fuser").trim().toLowerCase(); - if (brand !== "fuser" && brand !== "classic") { - throw new Error(`unsupported Captutor Stage brand "${value}"; expected fuser or classic`); - } - return brand; -} - -export function parseStageFlags(rawArgs = []) { - const vertical = rawArgs.includes("--vertical"); - const native = rawArgs.includes("--native"); - if(native && vertical)throw new Error("--native preserves display orientation; do not combine with --vertical"); - const brandIndex = rawArgs.indexOf("--brand"); - if (brandIndex >= 0 && (!rawArgs[brandIndex + 1] || rawArgs[brandIndex + 1].startsWith("--"))) { - throw new Error("--brand needs a value: fuser or classic"); - } - const brand = normalizeStageBrand(brandIndex >= 0 ? rawArgs[brandIndex + 1] : "fuser"); - const args = rawArgs.filter((arg, index) => - arg !== "--vertical" && arg !== "--native" && - (brandIndex < 0 || (index !== brandIndex && index !== brandIndex + 1))); - return { vertical, native, brand, args }; -} - -const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); -const run = (file, args = [], { allowFailure = false, timeout } = {}) => { - const result = spawnSync(file, args, { encoding: "utf8", timeout }); - if (!allowFailure && result.status !== 0) { - throw new Error(`${file} failed: ${(result.stderr || result.stdout || "").trim()}`); - } - return (result.stdout || "").trim(); -}; -const sh = (script, allowFailure = false) => run("/bin/bash", ["-lc", script], { allowFailure }); -const osa = (script, args = [], allowFailure = false) => - run("/usr/bin/osascript", ["-e", script, ...args], { allowFailure, timeout: 10000 }); -const swift = (source, args = []) => run("/usr/bin/swift", ["-e", source, ...args]); - -function readDefault(domain, key) { - const result = spawnSync("/usr/bin/defaults", ["read", domain, key], { encoding: "utf8" }); - return result.status === 0 ? result.stdout.trim() : null; -} - -function currentPointerSize() { - const value = Number(readDefault("com.apple.universalaccess.plist", "mouseDriverCursorSize")); - return Number.isFinite(value) ? value : 1; -} - -function restoreBoolean(domain, key, value) { - if (value == null) run("/usr/bin/defaults", ["delete", domain, key], { allowFailure: true }); - else run("/usr/bin/defaults", ["write", domain, key, "-bool", /^(1|true|yes)$/i.test(value) ? "true" : "false"]); -} - -export function nativeDisplayGeometry() { - return JSON.parse(swift('import CoreGraphics; import Foundation; let m = CGDisplayCopyDisplayMode(CGMainDisplayID())!; let d: [String:Int] = ["width":m.width,"height":m.height,"pixelWidth":m.pixelWidth,"pixelHeight":m.pixelHeight]; print(String(data:try! JSONSerialization.data(withJSONObject:d),encoding:.utf8)!)')); -} - -function displayModeID() { - return Number(swift('import CoreGraphics; print(CGDisplayCopyDisplayMode(CGMainDisplayID())!.ioDisplayModeID)')); -} - -function displayProfile() { - if (!existsSync(DISPLAYPLACER)) return null; - const listing = run(DISPLAYPLACER, ["list"]); - return listing.match(/displayplacer "([^"]+)"\s*$/m)?.[1] || null; -} - -function configureVerticalDisplay() { - if (!existsSync(DISPLAYPLACER)) { - throw new Error("vertical Stage Mode needs displayplacer (brew install displayplacer)"); - } - const profile = displayProfile(); - const id = profile?.match(/(?:^|\s)id:([^\s]+)/)?.[1]; - if (!id) throw new Error("displayplacer could not resolve Panda's main display"); - // The panel is physically 2560×1440. Rotated, 720×1280 with scaling enabled - // is its exact 2× portrait mode: 1440×2560 pixels with a large, legible UI. - run(DISPLAYPLACER, [ - `id:${id} res:720x1280 hz:144 color_depth:8 enabled:true scaling:on origin:(0,0) degree:90`, - ]); -} - -function configureDisplay(modeID = null) { - const selector = modeID == null - ? 'm.width == 1280 && m.height == 720 && m.pixelWidth == 2560 && m.pixelHeight == 1440 && Int(m.refreshRate) == 144' - : `Int(m.ioDisplayModeID) == ${Number(modeID)}`; - swift(` -import CoreGraphics -let d = CGMainDisplayID() -let options = [kCGDisplayShowDuplicateLowResolutionModes: true] as CFDictionary -let modes = CGDisplayCopyAllDisplayModes(d, options)! as! [CGDisplayMode] -guard let mode = modes.first(where: { m in ${selector} }) else { fatalError("display mode unavailable") } -var config: CGDisplayConfigRef? -guard CGBeginDisplayConfiguration(&config) == .success, let config else { fatalError("display begin") } -guard CGConfigureDisplayWithDisplayMode(config, d, mode, nil) == .success else { - CGCancelDisplayConfiguration(config); fatalError("display configure") -} -guard CGCompleteDisplayConfiguration(config, .forSession) == .success else { fatalError("display complete") } -`); -} - -function menuAutohide() { - return osa('tell application "System Events" to get autohide menu bar of dock preferences') === "true"; -} - -function setMenuAutohide(value) { - osa(`tell application "System Events" to set autohide menu bar of dock preferences to ${value ? "true" : "false"}`); -} - -function screenGeometry() { - return JSON.parse(swift(` -import AppKit -let screen = NSScreen.main! -let frame = screen.frame -let visible = screen.visibleFrame -print("{\\"width\\":\\(Int(frame.width)),\\"height\\":\\(Int(frame.height)),\\"visibleWidth\\":\\(Int(visible.width)),\\"visibleHeight\\":\\(Int(visible.height))}") -`)); -} - -function movePointerAwayFromMenuBar() { - swift(` -import CoreGraphics -let display = CGMainDisplayID() -let bounds = CGDisplayBounds(display) -CGWarpMouseCursorPosition(CGPoint(x: bounds.midX, y: bounds.midY)) -`); -} - -async function enforceHiddenSystemChrome() { - // The menu-bar preference is owned by SystemUIServer, not Dock. Restarting - // only Dock can leave the old bar composited into the first seconds of a - // take, and a pointer parked at y=0 keeps an auto-hidden bar revealed. - sh("killall Finder >/dev/null 2>&1 || true; killall Dock >/dev/null 2>&1 || true; killall SystemUIServer >/dev/null 2>&1 || true"); - movePointerAwayFromMenuBar(); - await sleep(1200); - if (!menuAutohide()) throw new Error("macOS did not enable menu-bar auto-hide"); - const geometry = screenGeometry(); - // A hidden Dock can reserve a tiny reveal strip, but the 30-point menu-bar - // inset must be gone before Reel is allowed to start. - if (geometry.height - geometry.visibleHeight > 8) { - throw new Error( - `system chrome still occupies the Stage (${geometry.visibleWidth}x${geometry.visibleHeight} visible inside ${geometry.width}x${geometry.height})`, - ); - } -} - -function darkMode() { - return osa('tell application "System Events" to tell appearance preferences to get dark mode') === "true"; -} - -function setDarkMode(value) { - osa(`tell application "System Events" to tell appearance preferences to set dark mode to ${value ? "true" : "false"}`); -} - -function setWallpaper(path) { - osa(`on run argv -set wallPath to item 1 of argv -tell application "System Events" - repeat with d in desktops - set picture of d to POSIX file wallPath - end repeat -end tell -end run`, [path]); -} - -function visibleApps() { - const raw = run("/usr/bin/osascript", ["-l", "JavaScript", "-e", ` -const se = Application("System Events"); -JSON.stringify(se.applicationProcesses.whose({ visible: true })() - .filter((process) => { - try { return !process.backgroundOnly(); } catch { return false; } - }) - .map((process) => process.name())); -`]); - return JSON.parse(raw || "[]"); -} - -function hideOtherApps() { - run("/usr/bin/osascript", ["-l", "JavaScript", "-e", ` -const se = Application("System Events"); -for (const process of se.applicationProcesses.whose({ visible: true })()) { - try { - if (!process.backgroundOnly() && process.name() !== "Google Chrome" && - process.name() !== "Captutor Wallpaper" && process.name() !== "CaptutorWallpaper") { - process.visible = false; - } - } catch {} -} -`]); -} - -function restoreVisibleApps(names) { - run("/usr/bin/osascript", ["-l", "JavaScript", "-e", ` -const wanted = new Set(${JSON.stringify(names.filter((name) => name !== "QuickTime Player"))}); -const se = Application("System Events"); -for (const process of se.applicationProcesses()) { - try { - if (wanted.has(process.name()) && !process.backgroundOnly()) process.visible = true; - } catch {} -} -`], { allowFailure: true }); -} - -async function setPointerSize(_from, to) { - run("/usr/bin/open", ["x-apple.systempreferences:com.apple.Accessibility-Settings.extension?Display"]); - await sleep(2200); - // Make the Pointer controls part of the live AX tree even when the compact - // HiDPI Settings window initially leaves them just below the fold. - swift(` -import CoreGraphics -CGWarpMouseCursorPosition(CGPoint(x: 500, y: 650)) -for _ in 0..<8 { - CGEvent(scrollWheelEvent2Source: nil, units: .line, wheelCount: 1, - wheel1: -8, wheel2: 0, wheel3: 0)?.post(tap: .cghidEventTap) - usleep(70_000) -}`); - await sleep(650); - - // System Settings exposes a stable AX identifier for this slider. Resolve - // its current bounds rather than baking in either the normal or HiDPI window - // geometry, then perform a real drag so macOS redraws the cursor immediately. - const slider = JSON.parse(run("/usr/bin/osascript", ["-l", "JavaScript", "-e", ` -const se = Application("System Events"); -const process = se.processes.byName("System Settings"); -const wanted = ${Number(to)}; -let answer = null; -function walk(element, depth) { - if (answer || depth > 24) return; - try { - if (element.role() === "AXSlider" && - element.attributes.byName("AXIdentifier").value() === "AX_CURSOR_SIZE") { - answer = { value: Number(element.value()), position: element.position(), size: element.size() }; - // Semantic actions are exact and do not depend on the current display's - // geometry. Start from the minimum, then walk to the requested value; - // the physical drag below remains only as a fallback for macOS builds - // that expose the slider but not its increment action. - for (let index = 0; index < 16; index += 1) { - element.actions.byName("AXDecrement").perform(); - } - if (wanted > 1.05) { - for (let index = 0; index < 16; index += 1) { - if (Number(element.value()) >= wanted - 0.04) break; - element.actions.byName("AXIncrement").perform(); - } - // AXIncrement is quantized to 0.2 on current macOS (1.4 → 1.6), while - // the slider itself accepts the requested continuous value. - try { element.value = wanted; } catch {} - } - answer.value = Number(element.value()); - return; - } - } catch {} - let children = []; - try { children = element.uiElements(); } catch {} - for (const child of children) walk(child, depth + 1); -} - -walk(process.windows[0], 0); -if (!answer) throw new Error("AX_CURSOR_SIZE is unavailable"); -JSON.stringify(answer); -`])); - if (Number(to) <= 1.05) { - await sleep(650); - const actual = Number(readDefault("com.apple.universalaccess.plist", "mouseDriverCursorSize")); - if (!Number.isFinite(actual) || Math.abs(actual - Number(to)) > 0.08) { - throw new Error(`pointer-size restore failed: wanted ${to}, got ${actual}`); - } - osa('tell application "System Settings" to quit', [], true); - osa('tell application "Google Chrome" to activate', [], true); - return; - } - await sleep(650); - const semanticActual = Number(readDefault("com.apple.universalaccess.plist", "mouseDriverCursorSize")); - // Current macOS exposes pointer-size keyboard increments in 0.2 steps. Its - // nearest semantic value to the requested 1.5 is 1.6; that tiny difference is - // preferable to a geometry-sensitive drag that can miss after display mode - // changes. Cleanup still returns to the exact 1.0 endpoint. - if (Number.isFinite(semanticActual) && Math.abs(semanticActual - Number(to)) <= 0.11) { - osa('tell application "System Settings" to quit', [], true); - osa('tell application "Google Chrome" to activate', [], true); - return; - } - const left = slider.position[0]; - const y = slider.position[1] + slider.size[1] / 2; - // AX reports the slider's full control bounds, while the knob travels on a - // track inset by its radius. Starting at `left` can therefore miss the knob - // when the saved size is exactly 1.0 — an intermittent Stage entry failure. - const knobRadius = slider.size[1] / 2; - const trackLeft = left + knobRadius; - const trackWidth = slider.size[0] - knobRadius * 2; - const x0 = trackLeft + ((Number(slider.value) - 1) / 3) * trackWidth; - const x1 = trackLeft + ((Number(to) - 1) / 3) * trackWidth; - swift(` -import CoreGraphics -let start = CGPoint(x: ${x0}, y: ${y}), end = CGPoint(x: ${x1}, y: ${y}) -CGWarpMouseCursorPosition(start); usleep(100_000) -let source = CGEventSource(stateID: .hidSystemState) -CGEvent(mouseEventSource: source, mouseType: .leftMouseDown, - mouseCursorPosition: start, mouseButton: .left)?.post(tap: .cghidEventTap) -for i in 1...20 { - let t = Double(i) / 20.0 - let p = CGPoint(x: start.x + (end.x - start.x) * t, y: start.y) - CGEvent(mouseEventSource: source, mouseType: .leftMouseDragged, - mouseCursorPosition: p, mouseButton: .left)?.post(tap: .cghidEventTap) - usleep(12_000) -} -CGEvent(mouseEventSource: source, mouseType: .leftMouseUp, - mouseCursorPosition: end, mouseButton: .left)?.post(tap: .cghidEventTap) -`); - await sleep(650); - const actual = Number(readDefault("com.apple.universalaccess.plist", "mouseDriverCursorSize")); - if (!Number.isFinite(actual) || Math.abs(actual - Number(to)) > 0.08) { - throw new Error(`pointer-size restore failed: wanted ${to}, got ${actual}`); - } - osa('tell application "System Settings" to quit', [], true); - osa('tell application "Google Chrome" to activate', [], true); -} - -async function setPointerSizeWithRetry(from, to) { - let last; - for (let attempt = 0; attempt < 2; attempt += 1) { - try { - await setPointerSize(from, to); - return; - } catch (error) { - last = error; - osa('tell application "System Settings" to quit', [], true); - await sleep(900); - } - } - throw last; -} - -function compilePointerBridge() { - mkdirSync(dirname(POINTER_BIN), { recursive: true }); - run("/usr/bin/swiftc", ["-O", POINTER_SOURCE, "-o", POINTER_BIN]); -} - -function compileNativeCursor() { - mkdirSync(dirname(CURSOR_BIN), { recursive: true }); - run("/usr/bin/pkill", ["-x", "captutor-cursor"], { allowFailure: true }); - run("/usr/bin/swiftc", ["-O", CURSOR_SOURCE, "-o", CURSOR_BIN]); -} - -function compileWallpaper() { - mkdirSync(dirname(WALLPAPER_BIN), { recursive: true }); - run("/usr/bin/swiftc", ["-O", WALLPAPER_SOURCE, "-o", WALLPAPER_BIN]); - const contents = dirname(dirname(WALLPAPER_BIN)); - const resources = join(contents, "Resources"); - mkdirSync(resources, { recursive: true }); - copyFileSync(WALLPAPER_LOGO, join(resources, "fuser-thumbnail-logo.svg")); - copyFileSync(WALLPAPER_MARK, join(resources, "fuser-mark.svg")); - for (const sheet of WALLPAPER_METABALLS) copyFileSync(sheet, join(resources, sheet.split("/").pop())); - writeFileSync(join(contents, "Info.plist"), ` - - -CFBundleExecutableCaptutorWallpaper -CFBundleIdentifierstudio.fuser.captutor-wallpaper -CFBundleNameCaptutor Wallpaper -CFBundlePackageTypeAPPL -LSUIElement -`); -} - -function stopWallpaper() { - run("/usr/bin/pkill", ["-x", "CaptutorWallpaper"], { allowFailure: true }); -} - -async function startWallpaper(brand) { - stopWallpaper(); - run("/usr/bin/open", ["-na", WALLPAPER_APP, "--args", "--brand", normalizeStageBrand(brand)]); - await sleep(900); - if (spawnSync("/usr/bin/pgrep", ["-x", "CaptutorWallpaper"]).status !== 0) { - throw new Error("Captutor dynamic wallpaper did not launch"); - } -} - -export async function enterStageMode({ - vertical = process.env.CAPTUTOR_VERTICAL_MODE === "1", - brand = process.env.CAPTUTOR_BRAND || "fuser", -} = {}) { - brand = normalizeStageBrand(brand); - const filmingRealCursor = process.env.CAPTUTOR_REAL_CURSOR === "1"; - if (existsSync(STATE)) await exitStageMode(); - const cursorSize = currentPointerSize(); - const state = { - displayMode: displayModeID(), - displayChanged: vertical || process.env.CAPTUTOR_STAGE_KEEP_DISPLAY !== "1", - displayProfile: displayProfile(), - vertical, - brand, - wallpaper: osa('tell application "System Events" to get picture of desktop 1 as text'), - createDesktop: readDefault("com.apple.finder", "CreateDesktop"), - dockAutohide: readDefault("com.apple.dock", "autohide"), - menuAutohide: menuAutohide(), - darkMode: darkMode(), - macpal: macpal.capture(), - chromeBookmarksShown: bookmarks.capture(), - badgeLoaded: spawnSync("/bin/launchctl", ["print", `gui/${process.getuid()}/computer.aesthetic.desktopbadge`]).status === 0, - statsRunning: spawnSync("/usr/bin/pgrep", ["-x", "Stats"]).status === 0, - visibleApps: visibleApps(), - sigilsWereOff: existsSync(SIGILS_OFF), - cursorSize, - // The default filmed cursor is our Swift overlay, so the system pointer is - // excluded by Reel and does not need a privileged Accessibility-settings - // detour. Preserve enlargement only for explicitly human-driven takes. - pointerChanged: filmingRealCursor && process.env.CAPTUTOR_STAGE_KEEP_POINTER !== "1", - }; - mkdirSync(dirname(STATE), { recursive: true }); - writeFileSync(STATE, JSON.stringify(state, null, 2)); - - // A preview from the previous take must never sit above Chrome or steal - // fullscreen/focus while the next mission rolls. Stage is an explicit clean - // takeover, so close QuickTime before changing display geometry. - if (process.env.CAPTUTOR_STAGE_SKIP_QUICKTIME !== "1") { - osa('tell application "QuickTime Player" to close every document saving no', [], true); - osa('tell application "QuickTime Player" to quit', [], true); - } - setDarkMode(false); - await sleep(650); - if (darkMode()) throw new Error("macOS did not enter Light appearance"); - - compilePointerBridge(); - compileNativeCursor(); - compileWallpaper(); - if (vertical) configureVerticalDisplay(); - else if (process.env.CAPTUTOR_STAGE_KEEP_DISPLAY !== "1") configureDisplay(); - setWallpaper(WALLPAPER); - run("/usr/bin/defaults", ["write", "com.apple.finder", "CreateDesktop", "-bool", "false"]); - run("/usr/bin/defaults", ["write", "com.apple.dock", "autohide", "-bool", "true"]); - setMenuAutohide(true); - await enforceHiddenSystemChrome(); - mkdirSync(dirname(SIGILS_OFF), { recursive: true }); - writeFileSync(SIGILS_OFF, ""); - await macpal.hide(state.macpal); - bookmarks.hide(); - if (state.badgeLoaded) run("/bin/launchctl", ["bootout", `gui/${process.getuid()}/computer.aesthetic.desktopbadge`], { allowFailure: true }); - if (state.statsRunning) osa('tell application "Stats" to quit', [], true); - if (state.pointerChanged) { - try { - await setPointerSizeWithRetry(cursorSize, 1.5); - } catch (error) { - // Pointer enlargement is filming polish, not a recording permission. - // New System Settings builds can keep AX access enabled while omitting - // AX_CURSOR_SIZE from the live tree. Continue only if the failed attempt - // left the saved cursor preference untouched; otherwise fail so cleanup - // retains responsibility for restoring it. - if (Math.abs(currentPointerSize() - cursorSize) > 0.08) throw error; - state.pointerChanged = false; - writeFileSync(STATE, JSON.stringify(state, null, 2)); - console.warn(`! pointer enlargement unavailable; filming with saved ${cursorSize}× cursor`); - } - } - hideOtherApps(); - writeFileSync(WALLPAPER_STATE, JSON.stringify({ phase: "ambient" })); - await startWallpaper(brand); - osa('tell application "Google Chrome" to activate', [], true); - console.log( - `✓ Captutor ${vertical ? "Vertical " : ""}Stage Mode active — ` + - `Light, ${process.env.CAPTUTOR_STAGE_KEEP_DISPLAY === "1" ? "native display" : "2× HiDPI"}, ` + - `${brand} desk, hidden system chrome, ${filmingRealCursor ? "real ~1.5×" : "native Swift"} pointer`, - ); -} - -export async function exitStageMode() { - // A crashed renderer may not get a chance to close its stdin. Never let its - // filmed pointer survive onto the operator's restored desktop. - run("/usr/bin/pkill", ["-x", "captutor-cursor"], { allowFailure: true }); - if (!existsSync(STATE)) return; - const state = JSON.parse(readFileSync(STATE, "utf8")); - const failures = []; - const restore = async (name, fn) => { - try { await fn(); } - catch (error) { failures.push(`${name}: ${error.message || error}`); } - }; - - // Each restore is independent. A broken wallpaper path must never strand the - // display in HiDPI, and a failed pointer drag must never keep Stats hidden. - await restore("dynamic wallpaper", () => stopWallpaper()); - if (state.pointerChanged !== false) { - await restore("pointer", async () => { - const wanted = Number(state.cursorSize ?? 1); - // An interrupted entry may have failed before changing the preference. - // Do not reopen System Settings merely to restore a value already exact. - if (Math.abs(currentPointerSize() - wanted) <= 0.08) return; - await setPointerSize(1.5, wanted); - }); - } - await restore("prompt sigils", () => { - if (!state.sigilsWereOff) rmSync(SIGILS_OFF, { force: true }); - }); - await restore("desktop badge", () => { - if (state.badgeLoaded) run("/bin/launchctl", ["bootstrap", `gui/${process.getuid()}`, BADGE_PLIST], { allowFailure: true }); - }); - await restore("Chrome bookmarks", () => bookmarks.restore(state.chromeBookmarksShown)); - await restore("MacPal", () => macpal.restore(state.macpal)); - await restore("Stats", () => { - if (state.statsRunning) run("/usr/bin/open", ["-a", "Stats"], { allowFailure: true }); - }); - await restore("wallpaper", () => setWallpaper(state.wallpaper)); - await restore("appearance", () => { - if (typeof state.darkMode === "boolean") setDarkMode(state.darkMode); - }); - await restore("desktop icons", () => restoreBoolean("com.apple.finder", "CreateDesktop", state.createDesktop)); - await restore("Dock", () => restoreBoolean("com.apple.dock", "autohide", state.dockAutohide)); - await restore("menu bar", () => setMenuAutohide(Boolean(state.menuAutohide))); - await restore("Finder, Dock, and menu bar", () => sh("killall Finder >/dev/null 2>&1 || true; killall Dock >/dev/null 2>&1 || true; killall SystemUIServer >/dev/null 2>&1 || true")); - await restore("display", () => { - if (state.displayChanged === false) return; - if (state.displayProfile && existsSync(DISPLAYPLACER)) { - run(DISPLAYPLACER, [state.displayProfile]); - } else { - configureDisplay(state.displayMode); - } - }); - await restore("application visibility", () => restoreVisibleApps(state.visibleApps || [])); - osa('tell application "Google Chrome" to activate', [], true); - - if (failures.length) { - // Keep the state file so the next invocation can retry the exact restore. - throw new Error(`Captutor Stage Mode restore incomplete:\n${failures.join("\n")}`); - } - rmSync(STATE, { force: true }); - console.log("✓ Captutor Stage Mode restored the normal desk"); -} diff --git a/slab/captutor/test/native-display.test.mjs b/slab/captutor/test/native-display.test.mjs index 36b71d8809..7e6e53d668 100644 --- a/slab/captutor/test/native-display.test.mjs +++ b/slab/captutor/test/native-display.test.mjs @@ -1,9 +1,7 @@ import test from 'node:test';import assert from 'node:assert/strict'; import {nativeDisplayFormat,assertNativePixels} from '../lib/native-display.mjs'; import {assertHiDPIStage} from '../lib/stage-contract.mjs'; -import {parseStageFlags} from '../lib/stage-mode.mjs'; const g={width:2560,height:1440,pixelWidth:2560,pixelHeight:1440}; test('native capture uses actual display pixels at 1x and 2x',()=>{assert.deepEqual(nativeDisplayFormat(g).out,{w:2560,h:1440});assert.deepEqual(nativeDisplayFormat({...g,pixelWidth:5120,pixelHeight:2880}).out,{w:5120,h:2880});assert.throws(()=>nativeDisplayFormat({...g,pixelWidth:2559}));}); test('native mode retains Stage requirement and detects display changes',()=>{assert.throws(()=>assertHiDPIStage({required:true,nativeDisplay:g}));assert.equal(assertHiDPIStage({required:true,stageMode:true,nativeDisplay:g,screen:{width:2560,height:1440,dpr:1}}).mode,'native');assert.throws(()=>assertHiDPIStage({required:true,stageMode:true,nativeDisplay:g,screen:{width:1280,height:720,dpr:2}}));}); test('native delivery refuses cropping and upscaling',()=>{assertNativePixels({w:2560,h:1440},{w:2560,h:1440});assert.throws(()=>assertNativePixels({w:1920,h:1080},{w:2560,h:1440}));assert.throws(()=>assertNativePixels({w:2560,h:1600},{w:2560,h:1440}));}); -test('native flag is consumed and refuses rotation',()=>{const p=parseStageFlags(['--native','--brand','fuser','render','demo.mjs']);assert.equal(p.native,true);assert.deepEqual(p.args,['render','demo.mjs']);assert.throws(()=>parseStageFlags(['--native','--vertical','render']));}); -- 2.51.2