From 337eca2d539fd88237b15a624b3a24d533425cfc Mon Sep 17 00:00:00 2001 From: larssand Date: Fri, 11 Sep 2026 18:17:17 +0200 Subject: [PATCH] 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 --- CHANGELOG.md | 6 + ROADMAP.md | 11 ++ package-lock.json | 4 +- package.json | 2 +- scripts/record-video-guides.mjs | 211 +++++++++++++++++++++++++++++++- 5 files changed, 225 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e14a5df..c1cb3d8 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.19.0 - 2026-09-11 + +### Added + +- Four more narrated video-guide clips covering core new-user onboarding: `connect-decoder`, `add-drivers-cars`, `create-race`, and `run-a-session` (Race Day start/pause/resume/finish with a real countdown and start lights). `scripts/record-video-guides.mjs` gained a `prepare()` hook so a clip can spin up 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. + ## 1.18.2 - 2026-09-11 ### Changed diff --git a/ROADMAP.md b/ROADMAP.md index 6680244..4106705 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -463,6 +463,17 @@ Reported 2026-09-11 by the user right after listening to the first narrated batc Verified 2026-09-11: re-recorded all three clips with Piper and confirmed each still has a valid video+AAC-audio MKV (`ffprobe`). Re-checked the settings-tabs clip's volume envelope - still 5 clean speech bursts in the right places, now visibly fuller/louder (Piper's neural waveform vs. espeak-ng's thin formant one), confirming the sync logic itself didn't need to change, only the synthesis backend. +### Third follow-up: four more clips covering core onboarding, not just the recent tab changes + +The user asked whether the 3 existing clips were enough, or more were needed - a fair question, since all 3 only covered UI restructured earlier this session, not what a genuinely new user hits first. Asked the user which of 4 candidate topics to add (all 4 chosen): connecting a decoder, adding drivers/cars/classes, creating a race through the 4-step wizard, and running a session on Race Day. + +- [x] `connect-decoder`: the Connection tab, entering a decoder address and clicking Connect Now, the sidebar badge turning green - and, as a bonus demonstrating a real reliability feature, dropping the connection and showing it auto-reconnect. Needed a live fake decoder socket (the same one `tests/browser_fake_decoder.js` provides, imported directly rather than duplicated) running for the whole clip, not just as one-time seed data - added a `prepare()` hook to `recordClip` that runs before the server is seeded and returns `{ seedState, teardown, extra }`, with `extra` merged into the context every step's action receives (so a step can call `decoder.dropConnections()` mid-clip) and `teardown` guaranteed to run even if the recording fails partway through. +- [x] `add-drivers-cars`: create a class, then a driver, then a car, switching between the three directory pages. Added a `clickNav(label)` helper that clicks the real sidebar `.nav-item` button by its label - the app itself is a client-side SPA (`window.history.replaceState`, no reload), and the recorder's own `navigate()` helper (built for test reliability, always a full `Page.navigate`) would have shown a page-load flash on every switch instead of the smooth transition a real user sees. +- [x] `create-race`: the Race Setup wizard's 4 steps (basics, participants, session plan, confirm) end to end, using the `club_qualifying` preset and "select all" participants, matching the same flow `tests/browser_race_setup.js` already exercises headlessly. +- [x] `run-a-session`: Start (with a real countdown and start-lights sequence, not the instant-start practice shortcut), Pause, Resume, Finish, then over to Timing. Reused the pattern from `tests/browser_race_day_start_sequences.js` (raceAudio enabled+unmuted so the sequence actually runs, but every event's effect/voice silenced via the same `SILENCED_EVENTS` construction, so nothing attempts real playback in headless Chrome) - and hit a genuine preflight gap while wiring it up: the seeded event had no `raceConfig.minLapMs`/`maxLapMs`, and `buildRaceDayPreflight`'s lap-window check requires `minLapMs > 0` to pass for anything other than open_practice, which a hand-seeded event (unlike one built through the real wizard, which always sets these via a preset) doesn't get for free. Fixed by seeding `raceConfig: { minLapMs: 3000, maxLapMs: 60000 }` directly. + +Verified 2026-09-11: recorded all four clips (the first `run-a-session` attempt timed out waiting for the Start button to enable, which is what surfaced the lap-window preflight gap above - fixed and re-recorded clean). Visually inspected 2 frames per clip: connect-decoder shows the pre-filled decoder address then the green "Decoder Online" badge after the drop/reconnect; add-drivers-cars shows the created class, driver and car each appearing in their own list; create-race shows step 1 filled in and step 4's summary ("Friday Night Race", 4 participants, "1 x practice, 4 x qualification"); run-a-session shows the heat running with a real ticking clock and lit-up start lights, then the Timing page's header reading "HEAT - FINISHED" after the full start/pause/resume/finish cycle. + ## Documentation maintenance - [ ] Update the architecture's module/dependency inventory as modules change. diff --git a/package-lock.json b/package-lock.json index 902b6b9..0e45065 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "rc-racecore", - "version": "1.18.2", + "version": "1.19.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "rc-racecore", - "version": "1.18.2", + "version": "1.19.0", "hasInstallScript": true, "dependencies": { "better-sqlite3": "^12.11.1", diff --git a/package.json b/package.json index 52c59f7..0f7e819 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "rc-racecore", - "version": "1.18.2", + "version": "1.19.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 index 22b5e5d..bb4f35a 100644 --- a/scripts/record-video-guides.mjs +++ b/scripts/record-video-guides.mjs @@ -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);