← back to Quadrille Showroom

scripts/record-carousel.mjs

156 lines

#!/usr/bin/env node
/**
 * record-carousel.mjs — screen-record the CAROUSEL view mode of the Quadrille showroom
 * AND assert that the wall group actually rotates.
 *
 * Loads http://127.0.0.1:7690/, waits for scene-ready, programmatically enters carousel
 * mode via the public API window._viewmode.set('carousel'), records ~12s, and samples the
 * rotating wall group's rotation.y at t=0 and t=8s to prove (or disprove) that it spins.
 *
 * The carousel mode (public/js/viewmodes.js) drives:  wg.rotation.y = Math.sin(carouselT)*0.5
 * on the "wall group" found by a scene traversal. We replicate that same findWallGroup()
 * heuristic here so we sample EXACTLY the group the mode animates.
 *
 * Outputs (into ../recordings/):
 *   - carousel.webm          (Playwright recordVideo)
 *   - carousel.mp4           (ffmpeg transcode, if ffmpeg is on PATH)
 *   - carousel.log           (console + pageerror capture)
 *   - carousel.json          (rotation samples + assertion summary)
 *
 * Self-contained: drives the page only through window._viewmode / window._qh public APIs.
 * Baseline expectation on the PRE-FIX build: rotationYDelta ≈ 0 (carousel NOT rotating = bug).
 *
 * Run: node scripts/record-carousel.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));
const logLines = [];
const rec = (s) => { logLines.push(s); console.log(s); };

// Injected into the page: replicate viewmodes.js findWallGroup() and read its rotation.y.
// Returns { found, rotationY } — found=false means no wall group matched the heuristic.
const READ_WALLGROUP_ROT = () => {
  const QH = window._qh;
  if (!QH || !QH.scene || !QH.CONFIG) return { found: false, rotationY: null, reason: 'no _qh' };
  const wingH = QH.CONFIG.wing && typeof QH.CONFIG.wing.height === 'number' ? QH.CONFIG.wing.height : null;
  let g = null;
  QH.scene.traverse((o) => {
    if (g) return;
    if (o.isGroup && o.children && o.children.some((ch) =>
      ch.geometry && ch.geometry.type === 'BoxGeometry' &&
      wingH != null && Math.abs(ch.position.y - (wingH + 0.08)) < 0.05)) g = o;
  });
  if (!g) return { found: false, rotationY: null, reason: 'no wallgroup matched' };
  return { found: true, rotationY: +g.rotation.y.toFixed(6) };
};

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();

page.on('console', (m) => logLines.push(`[console:${m.type()}] ${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 });

  await page.waitForFunction(
    () => window._qh && window._qh.wingBoards && window._qh.wingBoards.length > 0,
    { timeout: 25000 }
  );
  await sleep(2800); // loader fade + first textures + boot settle

  // Enter carousel mode via the public API.
  const entered = await page.evaluate(() => {
    if (!window._viewmode || typeof window._viewmode.set !== 'function') return { ok: false, reason: 'no _viewmode.set' };
    window._viewmode.set('carousel');
    return { ok: true, current: window._viewmode.current };
  });
  rec(`→ entered carousel mode: ${JSON.stringify(entered)}`);
  if (!entered.ok) throw new Error('could not enter carousel mode: ' + entered.reason);

  // Let the mode's enter() settle one frame before sampling t=0.
  await sleep(400);
  const rotAt0 = await page.evaluate(READ_WALLGROUP_ROT);
  rec(`  rotation.y @ t=0s: ${JSON.stringify(rotAt0)}`);
  await page.screenshot({ path: path.join(OUT, 'carousel-t0.png') });

  // Record ~12s of carousel; sample again at ~t=8s while it's still recording.
  rec('→ recording ~12s of carousel');
  await sleep(8000);
  const rotAt8 = await page.evaluate(READ_WALLGROUP_ROT);
  rec(`  rotation.y @ t=8s: ${JSON.stringify(rotAt8)}`);
  await page.screenshot({ path: path.join(OUT, 'carousel-t8.png') });
  await sleep(4000); // finish out ~12s

  // Assertion: did the wall group actually rotate?
  const y0 = rotAt0.found ? rotAt0.rotationY : null;
  const y8 = rotAt8.found ? rotAt8.rotationY : null;
  const delta = (y0 != null && y8 != null) ? +(y8 - y0).toFixed(6) : null;
  const rotated = delta != null && Math.abs(delta) > 0.001;
  summary.wallGroupFound = !!(rotAt0.found && rotAt8.found);
  summary.rotationYAt0 = y0;
  summary.rotationYAt8 = y8;
  summary.rotationYDelta = delta;
  summary.rotated = rotated;
  summary.verdict = !summary.wallGroupFound
    ? 'WALLGROUP_NOT_FOUND'
    : rotated ? 'ROTATING' : 'NOT_ROTATING (baseline bug)';
  rec(`→ ASSERT wall group rotates: delta=${delta}  rotated=${rotated}  verdict=${summary.verdict}`);
} catch (e) {
  logLines.push(`[FATAL] ${e.message}`);
  summary.error = e.message;
  console.error('RECORD FAILED:', e.message);
} finally {
  let vidPath = null;
  try { vidPath = await page.video().path(); } catch (e) { /* no video */ }
  await page.close().catch(() => {});
  await ctx.close().catch(() => {});
  await browser.close().catch(() => {});

  const finalWebm = path.join(OUT, 'carousel.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');
  }

  if (existsSync(finalWebm)) {
    const mp4 = path.join(OUT, 'carousel.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, 'carousel.log'), logLines.join('\n') + '\n');
  writeFileSync(path.join(OUT, 'carousel.json'), JSON.stringify(summary, null, 2) + '\n');
  rec(`✓ log:  ${path.join(OUT, 'carousel.log')}`);
  rec(`✓ json: ${path.join(OUT, 'carousel.json')}`);
}