connect-decoder, add-drivers-cars, create-race, and run-a-session join the 3 existing tab-focused clips, so the video guides now cover the actual first-run path a new user hits, not just this session's UI restructuring. record-video-guides.mjs gained a prepare() hook (for clips needing a live dependency, like a fake decoder socket, before the app is seeded) and a clickNav() helper that exercises the app's real client-side navigation instead of a full page reload, so switching views in a clip looks like the smooth transition a real user sees. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
544 lines
28 KiB
JavaScript
544 lines
28 KiB
JavaScript
// 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 (Piper, a small local neural TTS - far more intelligible
|
|
// than espeak-ng's formant synthesis, which is what this used before) 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/<name>.mkv
|
|
// (gitignored - these are sizeable binaries, not something to commit by default).
|
|
//
|
|
// Requires ffmpeg and Piper (`pipx install piper-tts`) on PATH, plus a downloaded voice
|
|
// model - by default ~/.local/share/piper-voices/en_US-lessac-medium.onnx (get it from
|
|
// https://huggingface.co/rhasspy/piper-voices, the .onnx and its matching .onnx.json).
|
|
// Override the model with the PIPER_MODEL env var.
|
|
|
|
import { spawn, execFileSync } from "node:child_process";
|
|
import { existsSync, mkdtempSync, rmSync, mkdirSync, writeFileSync } from "node:fs";
|
|
import { createServer } from "node:net";
|
|
import { homedir, tmpdir } from "node:os";
|
|
import { dirname, join } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { startFakeDecoder } from "../tests/browser_fake_decoder.js";
|
|
import { RACE_AUDIO_EVENT_DEFINITIONS } from "../src/race_audio.js";
|
|
|
|
// Silences every race-audio event's effect/voice so a clip that actually runs a start
|
|
// sequence (countdown, lights, "drivers ready"/"go" cues) never tries to play a real sound
|
|
// or call speechSynthesis in headless Chrome - only the timers driving the visuals run.
|
|
const SILENCED_EVENTS = Object.fromEntries(RACE_AUDIO_EVENT_DEFINITIONS.map((definition) => [definition.key, { effect: false, voice: false }]));
|
|
|
|
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("piper", ["--help"], { stdio: "ignore" }); } catch { throw new Error("piper not found on PATH (used for narration audio). Install with: pipx install piper-tts"); }
|
|
const PIPER_MODEL = process.env.PIPER_MODEL || join(homedir(), ".local", "share", "piper-voices", "en_US-lessac-medium.onnx");
|
|
if (!existsSync(PIPER_MODEL)) throw new Error(`Piper voice model not found at ${PIPER_MODEL}. Download it from https://huggingface.co/rhasspy/piper-voices (both the .onnx and .onnx.json files), or set PIPER_MODEL.`);
|
|
|
|
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("piper", ["-m", PIPER_MODEL, "-f", wavPath], { input: step.text, stdio: ["pipe", "ignore", "ignore"] });
|
|
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. `prepare()` is an optional async hook
|
|
// (for clips that need a live dependency, e.g. a fake decoder socket, before the server is
|
|
// seeded) returning { seedState, teardown, extra } - `extra` is merged into the context
|
|
// object passed to `setup` and every step's `action`, and `teardown` always runs, even if
|
|
// the recording fails partway through.
|
|
async function recordClip(name, { prepare, 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;
|
|
};
|
|
let prepared;
|
|
try {
|
|
console.log(`[${name}] synthesizing narration (${steps.length} lines)...`);
|
|
const narrationSteps = synthesizeNarration(steps, narrationDir);
|
|
|
|
prepared = await prepare?.();
|
|
const seedFn = prepared?.seedState || seedState;
|
|
|
|
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: {},
|
|
};
|
|
seedFn?.(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)}})`);
|
|
// Clicks a real sidebar nav item by its (English) label, exercising the app's actual
|
|
// client-side view switch (see renderNav in src/app.js) instead of a full Page.navigate
|
|
// reload - matches what a real user's click does, and looks like it in the recording.
|
|
const clickNav = (label) => evaluate(`Array.from(document.querySelectorAll(".nav-item")).find((b) => b.textContent.trim() === ${JSON.stringify(label)})?.click()`);
|
|
const state = async () => (await (await fetch(base + "/api/state")).json()).state;
|
|
|
|
await send("Runtime.enable");
|
|
await send("Page.enable");
|
|
await send("Emulation.setDeviceMetricsOverride", { width: 1600, height: 900, deviceScaleFactor: 1, mobile: false });
|
|
|
|
const ctx = { evaluate, navigate, fill, click, scrollTo, clickNav, state, pause, ...(prepared?.extra || {}) };
|
|
|
|
// 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?.(ctx);
|
|
|
|
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) {
|
|
await step.action?.(ctx);
|
|
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);
|
|
try { await prepared?.teardown?.(); } catch {}
|
|
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." },
|
|
],
|
|
},
|
|
|
|
"connect-decoder": {
|
|
async prepare() {
|
|
// A real AMMC decoder isn't available here - a socket that completes only the
|
|
// WebSocket handshake is enough to exercise the app's real connect path end to end
|
|
// (src/decoder_runtime.js doesn't need decoder frames to reach "connected").
|
|
const decoder = await startFakeDecoder();
|
|
return {
|
|
seedState(state) {
|
|
state.settings.wsUrl = `ws://127.0.0.1:${decoder.port}`;
|
|
state.settings.autoReconnect = true;
|
|
},
|
|
teardown: () => decoder.close(),
|
|
extra: { decoder },
|
|
};
|
|
},
|
|
async setup({ navigate }) {
|
|
await navigate("settings", "settingsForm");
|
|
},
|
|
steps: [
|
|
{ text: "This is the Connection tab, where you link RaceCore to your race timing decoder." },
|
|
{
|
|
text: "Enter the decoder's address, then click Connect now.",
|
|
async action({ click, evaluate }) {
|
|
await click("#settingsConnect");
|
|
await waitFor(async () => await evaluate('document.getElementById("connectionBadge")?.classList.contains("badge-online")'), "decoder connects");
|
|
},
|
|
},
|
|
{ text: "Once connected, the badge in the sidebar turns green and shows Decoder Online." },
|
|
{
|
|
text: "If the connection ever drops, RaceCore automatically reconnects for you.",
|
|
async action({ decoder, evaluate }) {
|
|
decoder.dropConnections();
|
|
await waitFor(async () => (await evaluate('document.getElementById("connectionBadge")?.classList.contains("badge-online")')) === false, "badge goes offline after drop");
|
|
},
|
|
},
|
|
],
|
|
},
|
|
|
|
"add-drivers-cars": {
|
|
async setup({ navigate }) {
|
|
await navigate("classes", "classForm");
|
|
},
|
|
steps: [
|
|
{ text: "Every driver and car belongs to a class - let's start there." },
|
|
{
|
|
text: "Give the class a name, like 2WD Buggy, and save it.",
|
|
async action({ fill, evaluate }) {
|
|
await fill("classForm", { name: "2WD Buggy" });
|
|
await evaluate('document.getElementById("classForm").requestSubmit()');
|
|
},
|
|
},
|
|
{
|
|
text: "Switch to Drivers to register your racers.",
|
|
async action({ clickNav }) { await clickNav("Drivers"); },
|
|
},
|
|
{
|
|
text: "Give them a name, pick their class, and enter their transponder number.",
|
|
async action({ fill, evaluate, state }) {
|
|
const classId = (await state()).classes.find((item) => item.name === "2WD Buggy")?.id;
|
|
await fill("driverForm", { name: "Alex Karlsson", classId, transponder: "7930001" });
|
|
await evaluate('document.getElementById("driverForm").requestSubmit()');
|
|
},
|
|
},
|
|
{
|
|
text: "And Cars, if you want to track a car separately from its driver.",
|
|
async action({ clickNav }) { await clickNav("Cars"); },
|
|
},
|
|
{
|
|
text: "Same idea - a name and a transponder number.",
|
|
async action({ fill, evaluate }) {
|
|
await fill("carForm", { name: "Red Buggy #7", transponder: "7930002" });
|
|
await evaluate('document.getElementById("carForm").requestSubmit()');
|
|
},
|
|
},
|
|
],
|
|
},
|
|
|
|
"create-race": {
|
|
seedState(state) {
|
|
const classId = "vid_wizard_class";
|
|
state.classes.push({ id: classId, name: "2WD Buggy" });
|
|
["Alex Karlsson", "Sam Nilsson", "Jamie Lee", "Robin Berg"].forEach((name, index) => {
|
|
state.drivers.push({ id: `vid_wizard_driver_${index}`, name, classId, transponder: `794000${index}` });
|
|
});
|
|
},
|
|
async setup({ navigate }) {
|
|
await navigate("race_setup", "raceWizardStepForm");
|
|
},
|
|
steps: [
|
|
{ text: "Creating a race has four steps. Let's start with the basics: name, date, and class." },
|
|
{
|
|
text: "Give the race a name, pick a class and format, then continue.",
|
|
async action({ fill, click }) {
|
|
await fill("raceWizardStepForm", { name: "Friday Night Race", date: "2026-09-18", classId: "vid_wizard_class", presetId: "club_qualifying" });
|
|
await click("#raceWizardNext");
|
|
},
|
|
},
|
|
{
|
|
text: "Choose who's racing - select everyone, or pick individually - then move on.",
|
|
async action({ click }) {
|
|
await click("#wizardSelectAllParticipants");
|
|
await click("#raceWizardNext");
|
|
},
|
|
},
|
|
{ text: "The session plan is generated from your preset - practice, qualifying, and finals - ready to fine-tune." },
|
|
{
|
|
text: "One more click to confirm everything.",
|
|
async action({ click }) { await click("#raceWizardNext"); },
|
|
},
|
|
{
|
|
text: "And the race is created, ready to manage.",
|
|
async action({ click }) { await click("#raceWizardCreate"); },
|
|
},
|
|
],
|
|
},
|
|
|
|
"run-a-session": {
|
|
async prepare() {
|
|
const decoder = await startFakeDecoder();
|
|
return {
|
|
seedState(state) {
|
|
const classId = "vid_run_class";
|
|
const driverA = { id: "vid_run_driver_a", name: "Alex Karlsson", classId, transponder: "7950001" };
|
|
const driverB = { id: "vid_run_driver_b", name: "Sam Nilsson", classId, transponder: "7950002" };
|
|
const raceEvent = { id: "vid_run_event", name: "Friday Night Race", classId, mode: "race", raceConfig: { minLapMs: 3000, maxLapMs: 60000 } };
|
|
const session = { id: "vid_run_session", eventId: raceEvent.id, name: "Heat 1", type: "heat", durationMin: 3, status: "ready" };
|
|
state.classes.push({ id: classId, name: "2WD Buggy" });
|
|
state.drivers.push(driverA, driverB);
|
|
state.events.push(raceEvent);
|
|
state.sessions.push(session);
|
|
state.activeSessionId = session.id;
|
|
state.settings.wsUrl = `ws://127.0.0.1:${decoder.port}`;
|
|
state.settings.autoReconnect = true;
|
|
// A real countdown+start-lights sequence (not the instant-start practice
|
|
// shortcut), sped up just enough to keep the clip short, with every event's own
|
|
// sound silenced so nothing tries to play audio in headless Chrome.
|
|
state.settings.raceAudio = {
|
|
...state.settings.raceAudio,
|
|
enabled: true, muted: false, startLights: true, lightIntervalMs: 150,
|
|
randomStartDelay: false, minStartDelay: 0, maxStartDelay: 0,
|
|
startCountdownSeconds: 5, events: SILENCED_EVENTS,
|
|
};
|
|
},
|
|
teardown: () => decoder.close(),
|
|
};
|
|
},
|
|
async setup({ navigate, evaluate }) {
|
|
await navigate("race_day", "raceDayStart");
|
|
await waitFor(async () => await evaluate('document.getElementById("connectionBadge")?.classList.contains("badge-online")'), "decoder connected");
|
|
await waitFor(async () => (await evaluate('document.getElementById("raceDayStart")?.disabled')) === false, "start enabled once preflight is ready");
|
|
},
|
|
steps: [
|
|
{ text: "This is Race Day, your control center for running a session live." },
|
|
{
|
|
text: "Hit Start, and RaceCore runs a countdown before the green light.",
|
|
async action({ click }) { await click("#raceDayStart"); },
|
|
},
|
|
{ text: "Start lights count down, then the session goes live and the clock starts." },
|
|
{
|
|
text: "Pause any time - lap timing stops without losing the session.",
|
|
async action({ click }) { await click("#raceDayPause"); },
|
|
},
|
|
{
|
|
text: "Resume picks up right where you left off.",
|
|
async action({ click }) { await click("#raceDayResume"); },
|
|
},
|
|
{
|
|
text: "And Finish locks in the results for this session.",
|
|
async action({ click }) { await click("#raceDayFinish"); },
|
|
},
|
|
{
|
|
text: "Head over to Timing to see the leaderboard and lap-by-lap results.",
|
|
async action({ click }) { await click("#raceDayTiming"); },
|
|
},
|
|
],
|
|
},
|
|
};
|
|
|
|
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);
|
|
}
|