← back to Quadrille Showroom

scripts/record-boot-walk.mjs

146 lines

#!/usr/bin/env node
/**
 * record-boot-walk.mjs — screen-record the DEFAULT BOOT EXPERIENCE of the Quadrille showroom.
 *
 * Loads http://127.0.0.1:7690/, waits for scene-ready (window._qh.wingBoards populated),
 * then records ~15s of the guided boot (which will become a "walk through the centre").
 * During the record it does a gentle forward walk + strafe so the footage moves.
 *
 * Outputs (into ../recordings/):
 *   - boot-walk.webm         (Playwright recordVideo)
 *   - boot-walk.mp4          (ffmpeg transcode, if ffmpeg is on PATH)
 *   - boot-walk.log          (console + pageerror capture)
 *   - boot-walk.json         (scene diagnostics summary)
 *
 * Self-contained: does NOT import showroom.js / viewmodes.js — drives the page only through
 * the stable window._qh public API. Local 127.0.0.1 is auth-exempt, so no basic-auth needed.
 *
 * Run: node scripts/record-boot-walk.mjs
 */
import pw from '/Users/macstudio3/.npm-global/lib/node_modules/playwright/index.js';
const { chromium } = pw;
import { mkdirSync, existsSync, renameSync, writeFileSync } from 'fs';
import { execFileSync } from 'child_process';
import { fileURLToPath } from 'url';
import path from 'path';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const URL = process.env.SHOWROOM_URL || 'http://127.0.0.1:7690/';
const OUT = path.join(__dirname, '..', 'recordings');
mkdirSync(OUT, { recursive: true });

const sleep = (ms) => new Promise(r => setTimeout(r, ms));
async function holdKey(page, key, ms) {
  await page.keyboard.down(key);
  await sleep(ms);
  await page.keyboard.up(key);
}

const logLines = [];
const rec = (s) => { logLines.push(s); console.log(s); };

const browser = await chromium.launch({
  args: ['--use-gl=angle', '--use-angle=swiftshader', '--enable-webgl', '--ignore-gpu-blocklist'],
});
const ctx = await browser.newContext({
  viewport: { width: 1600, height: 900 },
  recordVideo: { dir: OUT, size: { width: 1600, height: 900 } },
});
const page = await ctx.newPage();

// Capture console + page errors to the log.
page.on('console', (m) => {
  const t = m.type();
  logLines.push(`[console:${t}] ${m.text()}`);
});
page.on('pageerror', (e) => logLines.push(`[pageerror] ${e.message}`));

let summary = { url: URL, when: new Date().toISOString() };

try {
  rec(`→ loading ${URL}`);
  await page.goto(URL, { waitUntil: 'domcontentloaded', timeout: 30000 });

  // Scene-ready: the async loadProducts()->buildWingWall() chain has populated the boards.
  await page.waitForFunction(
    () => window._qh && window._qh.wingBoards && window._qh.wingBoards.length > 0,
    { timeout: 25000 }
  );
  await sleep(2800); // loader fade + guided boot focus settles + first textures stream in

  const boards = await page.evaluate(() => window._qh.wingBoards.length);
  const mode0 = await page.evaluate(() => (window._viewmode ? window._viewmode.current : null));
  rec(`  scene ready — boards: ${boards}, boot mode: ${mode0}`);

  // ~15s of the boot/walk-through-the-centre. Gentle forward + strafe so footage moves.
  rec('→ recording ~15s boot walk (forward + strafe through the centre)');
  const t0 = Date.now();
  await holdKey(page, 'KeyW', 2000);      // walk forward toward the centre book
  await sleep(600);
  await holdKey(page, 'KeyD', 900);       // drift right
  await sleep(500);
  await holdKey(page, 'KeyA', 1600);      // drift left across centre
  await sleep(600);
  await holdKey(page, 'KeyW', 1400);      // press further in
  await sleep(700);
  await holdKey(page, 'KeyS', 900);       // ease back out
  // Pad the remainder so total record ~15s.
  const elapsed = Date.now() - t0;
  if (elapsed < 15000) await sleep(15000 - elapsed);

  // Scene diagnostics — proves the render is healthy.
  const diag = await page.evaluate(() => {
    const info = window._qh.renderer ? window._qh.renderer.info.render : null;
    const c = window._qh.camera ? window._qh.camera.position : null;
    return {
      mode: window._viewmode ? window._viewmode.current : null,
      boards: window._qh.wingBoards.length,
      bookPresent: !!window._qh.bookPresent,
      bookIndex: typeof window._qh.bookIndex === 'number' ? window._qh.bookIndex : null,
      drawCalls: info ? info.calls : null,
      triangles: info ? info.triangles : null,
      camPos: c ? [ +c.x.toFixed(3), +c.y.toFixed(3), +c.z.toFixed(3) ] : null,
    };
  });
  summary = { ...summary, ...diag };
  rec(`→ diagnostics: ${JSON.stringify(diag)}`);
} catch (e) {
  logLines.push(`[FATAL] ${e.message}`);
  summary.error = e.message;
  console.error('RECORD FAILED:', e.message);
} finally {
  // Finalize the video, then rename deterministically.
  let vidPath = null;
  try { vidPath = await page.video().path(); } catch (e) { /* no video */ }
  await page.close().catch(() => {});
  await ctx.close().catch(() => {});   // flushes the webm to disk
  await browser.close().catch(() => {});

  const finalWebm = path.join(OUT, 'boot-walk.webm');
  if (vidPath && existsSync(vidPath)) {
    try { renameSync(vidPath, finalWebm); summary.webm = finalWebm; rec(`✓ webm: ${finalWebm}`); }
    catch (e) { logLines.push(`[warn] rename webm failed: ${e.message}`); }
  } else {
    logLines.push('[warn] no video file produced');
  }

  // Transcode to mp4 if ffmpeg is available.
  if (existsSync(finalWebm)) {
    const mp4 = path.join(OUT, 'boot-walk.mp4');
    try {
      execFileSync('ffmpeg', ['-y', '-i', finalWebm, '-movflags', '+faststart',
        '-pix_fmt', 'yuv420p', '-c:v', 'libx264', '-crf', '20', mp4], { stdio: 'ignore' });
      summary.mp4 = mp4;
      rec(`✓ mp4: ${mp4}`);
    } catch (e) {
      logLines.push(`[warn] ffmpeg transcode skipped/failed: ${e.message}`);
      rec('  (ffmpeg not available — kept webm only)');
    }
  }

  writeFileSync(path.join(OUT, 'boot-walk.log'), logLines.join('\n') + '\n');
  writeFileSync(path.join(OUT, 'boot-walk.json'), JSON.stringify(summary, null, 2) + '\n');
  rec(`✓ log:  ${path.join(OUT, 'boot-walk.log')}`);
  rec(`✓ json: ${path.join(OUT, 'boot-walk.json')}`);
}