Add a video-guide recorder for the UI (Settings tabs, Event Manager tabs, Schedule panel)

CDP screencast -> ffmpeg -> MKV, driven against the same isolated server/browser pattern
the test suite already uses. Recorded three short clips demonstrating this session's tab
and schedule features; output goes to the gitignored video-guides/ folder rather than the
repo since these are sizeable binaries.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
larssand
2026-09-11 16:56:00 +02:00
parent 05f0291ee9
commit 4967299b04
6 changed files with 275 additions and 3 deletions

View File

@@ -0,0 +1,255 @@
// Records short 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.
//
// 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/<name>.mkv
// (gitignored - these are sizeable binaries, not something to commit by default).
//
// Requires ffmpeg on PATH (used only to encode the captured JPEG frames into H.264 MKV).
import { spawn, execFileSync } from "node:child_process";
import { existsSync, mkdtempSync, rmSync, mkdirSync, writeFileSync, copyFileSync } 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.");
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;
}
// A single recording session: isolated server + headless Chrome + CDP screencast capture.
// `record(ctx)` receives { evaluate, navigate, fill, click, pause } and drives the demo.
async function recordClip(name, { seedState, record }) {
const temporary = mkdtempSync(join(tmpdir(), "racecore-recguide-"));
const framesDir = join(temporary, "frames");
mkdirSync(framesDir, { 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 {
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;
let captureStart = 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()`);
await send("Runtime.enable");
await send("Page.enable");
await send("Emulation.setDeviceMetricsOverride", { width: 1600, height: 900, deviceScaleFactor: 1, mobile: false });
console.log(`[${name}] recording...`);
captureStart = Date.now();
await send("Page.startScreencast", { format: "jpeg", quality: 80, maxWidth: 1600, maxHeight: 900, everyNthFrame: 1 });
await record({ evaluate, navigate, fill, click, pause });
await send("Page.stopScreencast");
await pause(300);
const elapsedSec = (Date.now() - captureStart) / 1000;
const inputFps = Math.max(1, frameCount / elapsedSec);
console.log(`[${name}] captured ${frameCount} frames over ${elapsedSec.toFixed(1)}s (~${inputFps.toFixed(1)} fps)`);
mkdirSync(outputDir, { recursive: true });
const outputPath = join(outputDir, `${name}.mkv`);
execFileSync("ffmpeg", [
"-y", "-framerate", inputFps.toFixed(2), "-i", join(framesDir, "frame_%06d.jpg"),
"-c:v", "libx264", "-pix_fmt", "yuv420p", "-r", "30", 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 record({ navigate, click, pause }) {
await navigate("settings", "settingsForm");
await pause(1200);
for (const tab of ["audio", "appearance", "system", "connection"]) {
await click(`[data-settings-tab="${tab}"]`);
await pause(1800);
}
},
},
"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 record({ evaluate, navigate, click, pause }) {
await navigate("race_setup", "raceWizardStepForm");
await click("#event-manage-vid_event");
// Wait for the manage tabs to exist before interacting with them.
const until = Date.now() + 15000;
while (Date.now() < until) {
if (await evaluate(`Boolean(document.querySelector(".manage-tabs"))`)) break;
await pause(100);
}
// The tabs bar sits well below the fold on the Race Setup page - scroll it into view
// first so the recording actually shows the tab-switching, not a static top-of-page
// frame the whole time.
await evaluate(`document.querySelector(".manage-tabs").scrollIntoView({block: "start"})`);
await pause(1200);
for (const tab of ["format", "live", "schedule", "setup"]) {
await click(`[data-manage-tab="${tab}"]`);
await evaluate(`document.querySelector('[data-manage-panel="${tab}"]').scrollIntoView({block: "start"})`);
await pause(1800);
}
await evaluate(`document.getElementById("session-grid-vid_quali").scrollIntoView({block: "center"})`);
await pause(1200);
await click("#session-grid-vid_quali");
await pause(400);
await evaluate(`document.querySelector('[data-manage-panel="live"]').scrollIntoView({block: "start"})`);
await pause(1800);
},
},
"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 record({ evaluate, navigate, fill, click, pause }) {
await navigate("race_setup", "raceWizardStepForm");
await click("#event-manage-vid_sched_event");
const until = Date.now() + 15000;
while (Date.now() < until) {
if (await evaluate(`Boolean(document.querySelector(".manage-tabs"))`)) break;
await pause(100);
}
await evaluate(`document.querySelector(".manage-tabs").scrollIntoView({block: "start"})`);
await pause(800);
await click('[data-manage-tab="schedule"]');
await evaluate(`document.getElementById("scheduleForm").scrollIntoView({block: "start"})`);
await pause(1500);
await fill("scheduleForm", { scheduleStartAt: "2026-09-12T12:30", scheduleGapMin: "3" });
await pause(800);
await evaluate(`document.getElementById("scheduleForm").requestSubmit()`);
await pause(600);
await evaluate(`document.querySelector('[data-manage-panel="schedule"] table')?.scrollIntoView({block: "start"})`);
await pause(2500);
},
},
};
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);
}