// Records short, narrated MKV screen-capture clips of RaceCore's UI for use as new-user // video guides, driven against a real, isolated instance of the app (own temp SQLite dir, // own headless Chrome profile) exactly like the tests/browser_*.js suite - nothing here // ever touches a real installation's data. // // Each clip is a list of steps ({ text, action }). For every step, the narration line's // audio is synthesized up front (espeak-ng) so its real duration is known, the on-screen // action runs, and the recording pauses for exactly that duration before moving to the // next line - the actual wall-clock time each action took is recorded too, so the final // narration track is built from real measured offsets (via ffmpeg's adelay/amix), not an // idealized schedule that would drift out of sync with page-load/network latency. // // Usage: node scripts/record-video-guides.mjs [clip-name ...] // With no arguments, records every clip below. Pass one or more names (e.g. // "settings-tabs") to record only those. Output lands in video-guides/.mkv // (gitignored - these are sizeable binaries, not something to commit by default). // // Requires ffmpeg and espeak-ng on PATH. import { spawn, execFileSync } from "node:child_process"; import { existsSync, mkdtempSync, rmSync, mkdirSync, writeFileSync } from "node:fs"; import { createServer } from "node:net"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; const projectDir = dirname(dirname(fileURLToPath(import.meta.url))); const outputDir = join(projectDir, "video-guides"); const chrome = [process.env.CHROME_PATH, "/usr/bin/google-chrome", "/usr/bin/chromium", "/usr/bin/chromium-browser"].find((p) => p && existsSync(p)); if (!chrome) throw new Error("Chrome/Chromium not found. Set CHROME_PATH to the browser executable."); try { execFileSync("espeak-ng", ["--version"], { stdio: "ignore" }); } catch { throw new Error("espeak-ng not found on PATH (used for narration audio)."); } const NARRATION_VOICE = "en-us"; const NARRATION_RATE = 160; const LEAD_MS = 600; // silence before the first line, so the opening frame settles first const GAP_MS = 450; // silence between narration lines const TAIL_MS = 1400; // hold on the final frame after the last line finishes const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); async function waitFor(check, label, timeout = 15000) { const until = Date.now() + timeout; while (Date.now() < until) { try { if (await check()) return; } catch {} await pause(100); } throw new Error("Timed out: " + label); } async function freePort() { const server = createServer(); await new Promise((resolve, reject) => { server.once("error", reject); server.listen(0, "127.0.0.1", resolve); }); const port = server.address().port; await new Promise((resolve) => server.close(resolve)); return port; } function wavDurationMs(wavPath) { const out = execFileSync("ffprobe", ["-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", wavPath]).toString().trim(); return Math.round(parseFloat(out) * 1000); } function synthesizeNarration(steps, narrationDir) { return steps.map((step, index) => { const wavPath = join(narrationDir, `line_${String(index).padStart(2, "0")}.wav`); execFileSync("espeak-ng", ["-v", NARRATION_VOICE, "-s", String(NARRATION_RATE), "-w", wavPath, step.text]); return { ...step, wavPath, durationMs: wavDurationMs(wavPath) }; }); } // Places each narration line at its own real measured start offset (via adelay) and mixes // them onto one track (amix, normalize=0 since lines never overlap - normalizing would // quietly halve/third the volume for no reason). `normalize=0` is an amix filter option // available since ffmpeg 4.x. function mixNarrationTrack(narrated, outputPath) { const inputs = narrated.flatMap((line) => ["-i", line.wavPath]); const delayed = narrated.map((line, i) => `[${i}]adelay=${Math.max(0, Math.round(line.startMs))}|${Math.max(0, Math.round(line.startMs))}[a${i}]`); const mixInputs = narrated.map((_, i) => `[a${i}]`).join(""); const filter = `${delayed.join(";")};${mixInputs}amix=inputs=${narrated.length}:duration=longest:normalize=0[aout]`; execFileSync("ffmpeg", ["-y", ...inputs, "-filter_complex", filter, "-map", "[aout]", "-c:a", "pcm_s16le", outputPath], { stdio: "inherit" }); } // A single recording session: isolated server + headless Chrome + CDP screencast capture, // narrated by stepping through `steps` in order. async function recordClip(name, { seedState, setup, steps }) { const temporary = mkdtempSync(join(tmpdir(), "racecore-recguide-")); const framesDir = join(temporary, "frames"); const narrationDir = join(temporary, "narration"); mkdirSync(framesDir, { recursive: true }); mkdirSync(narrationDir, { recursive: true }); const children = []; let socket; const launch = (command, args, options = {}) => { const child = spawn(command, args, { cwd: projectDir, stdio: "ignore", ...options }); children.push(child); child.on("error", (e) => console.error(e.message)); return child; }; try { console.log(`[${name}] synthesizing narration (${steps.length} lines)...`); const narrationSteps = synthesizeNarration(steps, narrationDir); const port = await freePort(); const debugPort = await freePort(); const base = `http://127.0.0.1:${port}`; launch(process.execPath, ["server.js"], { env: { ...process.env, HOST: "127.0.0.1", PORT: String(port), RC_TIMING_DATA_DIR: join(temporary, "data"), RC_TIMING_PID_FILE: "" } }); await waitFor(async () => (await fetch(base + "/api/health")).ok, "isolated server"); const initial = { classes: [], drivers: [], cars: [], events: [], sessions: [], resultsBySession: {}, settings: { language: "en", autoReconnect: false, backendUrl: base, raceAudio: { enabled: false } }, training: {}, }; seedState?.(initial); await fetch(base + "/api/state", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(initial) }); launch(chrome, [ "--headless", "--disable-gpu", "--no-first-run", "--no-default-browser-check", "--disable-background-networking", ...(process.env.RC_BROWSER_NO_SANDBOX === "1" ? ["--no-sandbox"] : []), "--window-size=1600,900", `--user-data-dir=${join(temporary, "browser")}`, `--remote-debugging-port=${debugPort}`, "about:blank", ]); let target; await waitFor(async () => { target = (await (await fetch(`http://127.0.0.1:${debugPort}/json/list`)).json()).find((p) => p.type === "page"); return target; }, "headless browser"); socket = new WebSocket(target.webSocketDebuggerUrl); await new Promise((resolve, reject) => { socket.addEventListener("open", resolve, { once: true }); socket.addEventListener("error", reject, { once: true }); }); let nextId = 0; const requests = new Map(); let frameCount = 0; socket.addEventListener("message", (event) => { const message = JSON.parse(event.data); if (message.id) { const request = requests.get(message.id); requests.delete(message.id); if (message.error) request?.reject(new Error(message.error.message)); else request?.resolve(message.result); } else if (message.method === "Page.screencastFrame") { const { data, sessionId } = message.params; frameCount += 1; writeFileSync(join(framesDir, `frame_${String(frameCount).padStart(6, "0")}.jpg`), Buffer.from(data, "base64")); send("Page.screencastFrameAck", { sessionId }); } }); function send(method, params = {}) { return new Promise((resolve, reject) => { const id = ++nextId; requests.set(id, { resolve, reject }); socket.send(JSON.stringify({ id, method, params })); }); } async function evaluate(expression) { const result = await send("Runtime.evaluate", { expression, returnByValue: true, awaitPromise: true }); if (result.exceptionDetails) throw new Error(result.exceptionDetails.exception?.description || JSON.stringify(result.exceptionDetails)); return result.result.value; } async function navigate(view, form) { await send("Page.navigate", { url: base + "/?view=" + view }); await waitFor(async () => Boolean(await evaluate(`document.getElementById(${JSON.stringify(form)})`)) && (await evaluate(`document.getElementById("syncStatusDetail")?.textContent`) || "").includes("Data loaded"), view + " hydrated"); } async function fill(form, fields) { await evaluate(`(() => { const form = document.getElementById(${JSON.stringify(form)}); for (const [name,value] of Object.entries(${JSON.stringify(fields)})) { const field=form.elements.namedItem(name); if (!field) throw new Error("Missing field "+name); field.value=value; field.dispatchEvent(new Event("input", {bubbles:true})); } })()`); } const click = (selector) => evaluate(`document.querySelector(${JSON.stringify(selector)}).click()`); const scrollTo = (selector, block = "start") => evaluate(`document.querySelector(${JSON.stringify(selector)})?.scrollIntoView({block: ${JSON.stringify(block)}})`); await send("Runtime.enable"); await send("Page.enable"); await send("Emulation.setDeviceMetricsOverride", { width: 1600, height: 900, deviceScaleFactor: 1, mobile: false }); // Setup (navigation, opening the right event, scrolling to the starting point) happens // before the screencast starts, so the recording opens already on the right view // instead of capturing the page load itself. await setup?.({ evaluate, navigate, fill, click, scrollTo, pause }); console.log(`[${name}] recording...`); const captureStart = Date.now(); await send("Page.startScreencast", { format: "jpeg", quality: 80, maxWidth: 1600, maxHeight: 900, everyNthFrame: 1 }); await pause(LEAD_MS); const narrated = []; for (const step of narrationSteps) { const actionStart = Date.now(); await step.action?.({ evaluate, navigate, fill, click, scrollTo, pause }); narrated.push({ ...step, startMs: Date.now() - captureStart }); await pause(step.durationMs); await pause(GAP_MS); } await pause(TAIL_MS); await send("Page.stopScreencast"); await pause(300); const elapsedSec = (Date.now() - captureStart) / 1000; console.log(`[${name}] captured ${frameCount} frames over ${elapsedSec.toFixed(1)}s`); mkdirSync(outputDir, { recursive: true }); const silentVideoPath = join(temporary, "silent.mkv"); const inputFps = Math.max(1, frameCount / elapsedSec); execFileSync("ffmpeg", [ "-y", "-framerate", inputFps.toFixed(2), "-i", join(framesDir, "frame_%06d.jpg"), "-c:v", "libx264", "-pix_fmt", "yuv420p", "-r", "30", silentVideoPath, ], { stdio: "inherit" }); const audioPath = join(temporary, "narration.wav"); mixNarrationTrack(narrated, audioPath); const outputPath = join(outputDir, `${name}.mkv`); execFileSync("ffmpeg", [ "-y", "-i", silentVideoPath, "-i", audioPath, "-map", "0:v:0", "-map", "1:a:0", "-c:v", "copy", "-c:a", "aac", "-b:a", "96k", outputPath, ], { stdio: "inherit" }); console.log(`[${name}] wrote ${outputPath}`); } finally { try { socket?.close(); } catch {} for (const child of children.reverse()) { try { child.kill(); } catch {} } await pause(300); rmSync(temporary, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }); } } const clips = { "settings-tabs": { async setup({ navigate }) { await navigate("settings", "settingsForm"); }, steps: [ { text: "This is the Settings page, split into four tabs: Connection, Audio, Appearance, and System." }, { text: "Audio controls race sound announcements, volume, and which events get a spoken callout.", async action({ click }) { await click('[data-settings-tab="audio"]'); } }, { text: "Appearance and Overlay covers club branding, track layouts, and the OBS overlay setup.", async action({ click }) { await click('[data-settings-tab="appearance"]'); } }, { text: "System and Backup handles software updates, backups, and race presets.", async action({ click }) { await click('[data-settings-tab="system"]'); } }, { text: "And Connection, where we started, is where you link up the decoder.", async action({ click }) { await click('[data-settings-tab="connection"]'); } }, ], }, "event-manager-tabs": { seedState(state) { const classId = "vid_class"; const driverA = { id: "vid_driver_a", name: "Alex Karlsson", classId, transponder: "7910001" }; const driverB = { id: "vid_driver_b", name: "Sam Nilsson", classId, transponder: "7910002" }; const raceEvent = { id: "vid_event", name: "Club Race Night", classId, mode: "race", raceConfig: { participantsConfigured: true, driverIds: [driverA.id, driverB.id], teams: [] }, }; const practice = { id: "vid_practice", eventId: raceEvent.id, name: "Practice 1", type: "practice", durationMin: 10, status: "ready" }; const qualifying = { id: "vid_quali", eventId: raceEvent.id, name: "Qualifying 1", type: "qualification", startMode: "position", durationMin: 5, status: "ready" }; state.classes.push({ id: classId, name: "2WD Buggy" }); state.drivers.push(driverA, driverB); state.events.push(raceEvent); state.sessions.push(practice, qualifying); }, async setup({ navigate, click, evaluate, scrollTo }) { await navigate("race_setup", "raceWizardStepForm"); await click("#event-manage-vid_event"); await waitFor(async () => Boolean(await evaluate(`document.querySelector(".manage-tabs")`)), "manage tabs rendered"); await scrollTo(".manage-tabs"); }, steps: [ { text: "Opening Manage on a race event splits the page into four tabs: Setup, Format, Live, and Schedule." }, { text: "Format is where you configure practice, qualifying, and finals, and generate the session plan.", async action({ click, scrollTo }) { await click('[data-manage-tab="format"]'); await scrollTo('[data-manage-panel="format"]'); }, }, { text: "Live shows the grid editor and every standings table while the race is running.", async action({ click, scrollTo }) { await click('[data-manage-tab="live"]'); await scrollTo('[data-manage-panel="live"]'); }, }, { text: "Schedule lets you set a start time and see the whole day's running order.", async action({ click, scrollTo }) { await click('[data-manage-tab="schedule"]'); await scrollTo('[data-manage-panel="schedule"]'); }, }, { text: "And Setup, where we started, is for managing participants and teams.", async action({ click, scrollTo }) { await click('[data-manage-tab="setup"]'); await scrollTo('[data-manage-panel="setup"]'); }, }, { text: "The Open Grid button on any session jumps straight to its grid editor on the Live tab.", async action({ click, scrollTo, pause: pauseFn }) { await scrollTo("#session-grid-vid_quali", "center"); await pauseFn(500); await click("#session-grid-vid_quali"); await pauseFn(300); await scrollTo('[data-manage-panel="live"]'); }, }, ], }, "event-schedule": { seedState(state) { const classId = "vid_sched_class"; const driverA = { id: "vid_sched_driver_a", name: "Alex Karlsson", classId, transponder: "7920001" }; const raceEvent = { id: "vid_sched_event", name: "Club Race Night", classId, mode: "race", raceConfig: { participantsConfigured: true, driverIds: [driverA.id], teams: [] }, }; const practice = { id: "vid_sched_practice", eventId: raceEvent.id, name: "Practice 1", type: "practice", durationMin: 10, status: "ready" }; const qualifying = { id: "vid_sched_quali", eventId: raceEvent.id, name: "Qualifying 1", type: "qualification", durationMin: 5, status: "ready" }; state.classes.push({ id: classId, name: "2WD Buggy" }); state.drivers.push(driverA); state.events.push(raceEvent); state.sessions.push(practice, qualifying); }, async setup({ navigate, click, evaluate, scrollTo }) { await navigate("race_setup", "raceWizardStepForm"); await click("#event-manage-vid_sched_event"); await waitFor(async () => Boolean(await evaluate(`document.querySelector(".manage-tabs")`)), "manage tabs rendered"); await scrollTo(".manage-tabs"); }, steps: [ { text: "Let's set up a time-based schedule for race day.", async action({ click, scrollTo }) { await click('[data-manage-tab="schedule"]'); await scrollTo("#scheduleForm"); }, }, { text: "Enter a start time, and the gap you want between sessions, in minutes.", async action({ fill }) { await fill("scheduleForm", { scheduleStartAt: "2026-09-12T12:30", scheduleGapMin: "3" }); }, }, { text: "Save it, and RaceCore works out every session's planned clock time automatically.", async action({ evaluate, scrollTo, pause: pauseFn }) { await evaluate(`document.getElementById("scheduleForm").requestSubmit()`); await pauseFn(500); await scrollTo('[data-manage-panel="schedule"] table'); }, }, { text: "If a session starts late or runs long, everything after it shifts forward on its own." }, ], }, }; const requested = process.argv.slice(2); const names = requested.length ? requested : Object.keys(clips); for (const name of names) { const clip = clips[name]; if (!clip) { console.error(`Unknown clip "${name}". Available: ${Object.keys(clips).join(", ")}`); process.exitCode = 1; continue; } await recordClip(name, clip); }