Add 4 more video-guide clips covering core new-user onboarding

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>
This commit is contained in:
larssand
2026-09-11 18:17:17 +02:00
parent a17054d8d0
commit 337eca2d53
5 changed files with 225 additions and 9 deletions

View File

@@ -28,6 +28,13 @@ 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");
@@ -86,8 +93,12 @@ function mixNarrationTrack(narrated, outputPath) {
}
// 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 }) {
// 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");
@@ -101,10 +112,14 @@ async function recordClip(name, { seedState, setup, steps }) {
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}`;
@@ -116,7 +131,7 @@ async function recordClip(name, { seedState, setup, steps }) {
settings: { language: "en", autoReconnect: false, backendUrl: base, raceAudio: { enabled: false } },
training: {},
};
seedState?.(initial);
seedFn?.(initial);
await fetch(base + "/api/state", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(initial) });
launch(chrome, [
@@ -169,15 +184,22 @@ async function recordClip(name, { seedState, setup, steps }) {
}
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?.({ evaluate, navigate, fill, click, scrollTo, pause });
await setup?.(ctx);
console.log(`[${name}] recording...`);
const captureStart = Date.now();
@@ -186,8 +208,7 @@ async function recordClip(name, { seedState, setup, steps }) {
await pause(LEAD_MS);
const narrated = [];
for (const step of narrationSteps) {
const actionStart = Date.now();
await step.action?.({ evaluate, navigate, fill, click, scrollTo, pause });
await step.action?.(ctx);
narrated.push({ ...step, startMs: Date.now() - captureStart });
await pause(step.durationMs);
await pause(GAP_MS);
@@ -221,6 +242,7 @@ async function recordClip(name, { seedState, setup, steps }) {
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 });
}
}
@@ -333,6 +355,183 @@ const clips = {
{ 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);