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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user