From 4967299b04710dc9e96e724e0eefe84ec3bab122 Mon Sep 17 00:00:00 2001 From: larssand Date: Fri, 11 Sep 2026 16:56:00 +0200 Subject: [PATCH] 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 --- .gitignore | 1 + CHANGELOG.md | 6 + ROADMAP.md | 10 ++ package-lock.json | 4 +- package.json | 2 +- scripts/record-video-guides.mjs | 255 ++++++++++++++++++++++++++++++++ 6 files changed, 275 insertions(+), 3 deletions(-) create mode 100644 scripts/record-video-guides.mjs diff --git a/.gitignore b/.gitignore index 73ba79d..bfaa846 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ Thumbs.db coverage/ dist/ tmp/ +video-guides/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 6cef6d7..0f2a42c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ The application version is maintained in `package.json` and displayed in the adm No changes recorded yet. +## 1.18.0 - 2026-09-11 + +### Added + +- `scripts/record-video-guides.mjs`: records short MKV screen-capture clips of the UI (currently: Settings tabs, Event Manager tabs, and the race day Schedule panel) against a real, isolated instance of the app - same throwaway server/browser pattern as the browser test suite, so it never touches real installation data. Output lands in the gitignored `video-guides/` folder for use as new-user video guide material; run with `node scripts/record-video-guides.mjs [clip-name ...]`. + ## 1.17.2 - 2026-09-11 ### Changed diff --git a/ROADMAP.md b/ROADMAP.md index 2760237..fa471d1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -433,6 +433,16 @@ Requested 2026-09-11 by the user in response to "what's left in the tests, and w Verified 2026-09-11: `npm run test:browser` passes all 34 scenarios (11 new across the 3 new files, plus the pre-existing 23) against a real, isolated server and headless browser; `npm test` passes all 205 unit tests unchanged. +## Recorded MKV video guide clips for new users + +Suggested 2026-09-11 by the user, alongside the permanent-tests entry above: while writing the "real" tests, also produce an MKV video usable as a new-user video guide showing how to use the features. Confirmed scope with the user first: just the 3 UIs from that same entry (not a full onboarding walkthrough), as several short clips rather than one long video. + +- [x] Validated the recording approach with a throwaway proof of concept before building anything permanent: Chrome DevTools Protocol's `Page.startScreencast` (JPEG frames over the existing CDP WebSocket connection the test harness already uses, each frame acknowledged via `Page.screencastFrameAck` or the stream stalls) piped through `ffmpeg` into H.264 MKV. Confirmed with `ffprobe` and a visual frame check that this produces a real, correctly-sized, playable video before investing further. +- [x] `scripts/record-video-guides.mjs` (new, permanent): a small reusable recorder built on the same isolated-server-plus-headless-Chrome pattern as `tests/browser_smoke.js` (own temp SQLite dir, own browser profile - never touches real installation data), with a per-clip `seedState`/`record` definition. Three clips recorded: `settings-tabs`, `event-manager-tabs` (including the quick-jump Open Grid button switching to the Live tab), and `event-schedule` (filling in a start time/gap, saving, and showing the resulting cascaded table). Output goes to the gitignored `video-guides/` folder, not committed - these are sizeable binaries and the user hadn't asked for them checked into the repo. +- [x] First recording pass of the Event Manager and Schedule clips came out visually useless - the manage tabs and the Schedule panel both sit well below the fold on the Race Setup page, and a synthetic `element.click()` doesn't scroll anything into view the way a real user's click would, so the whole clip just sat on a static, unchanging top-of-page frame. Fixed by adding explicit `scrollIntoView` calls before each tab switch and before/after the schedule form actions, so the recording actually shows what it's demonstrating. + +Verified 2026-09-11: extracted and visually inspected sample frames from all three clips after the scroll fix - Settings clip clearly shows each tab's own panel switching, Event Manager clip shows the tab bar plus the Open Grid button jumping to the Live tab, and the Schedule clip shows the filled-in form followed by the real cascaded table (Practice 1 12:30-12:40, Qualifying 1 12:43-12:48 with the 3-minute gap applied) - all legible, no garbled or blank frames. `ffprobe` confirmed all three are valid 1600x900 H.264 MKV files (10-13s each, under 750KB). + ## Documentation maintenance - [ ] Update the architecture's module/dependency inventory as modules change. diff --git a/package-lock.json b/package-lock.json index 45ae681..2e8b0a3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "rc-racecore", - "version": "1.17.2", + "version": "1.18.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "rc-racecore", - "version": "1.17.2", + "version": "1.18.0", "hasInstallScript": true, "dependencies": { "better-sqlite3": "^12.11.1", diff --git a/package.json b/package.json index 4914505..8fa835f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "rc-racecore", - "version": "1.17.2", + "version": "1.18.0", "private": true, "description": "RC RaceCore live timing and race control with AMMC ingest and SQLite persistence", "engines": { diff --git a/scripts/record-video-guides.mjs b/scripts/record-video-guides.mjs new file mode 100644 index 0000000..c9516ca --- /dev/null +++ b/scripts/record-video-guides.mjs @@ -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/.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); +}