[object Object]

← back to Quadrille Showroom

add boot-walk + carousel screen-recording harnesses

6c84fb0ad4b048cd1e86b099e6fa8b78bf89a8fe · 2026-06-30 19:32:41 -0700 · Steve Abrams

Files touched

Diff

commit 6c84fb0ad4b048cd1e86b099e6fa8b78bf89a8fe
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Jun 30 19:32:41 2026 -0700

    add boot-walk + carousel screen-recording harnesses
---
 scripts/record-boot-walk.mjs | 145 ++++++++++++++++++++++++++++++++++++++++
 scripts/record-carousel.mjs  | 155 +++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 300 insertions(+)

diff --git a/scripts/record-boot-walk.mjs b/scripts/record-boot-walk.mjs
new file mode 100644
index 0000000..7c928cc
--- /dev/null
+++ b/scripts/record-boot-walk.mjs
@@ -0,0 +1,145 @@
+#!/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/stevestudio2/.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')}`);
+}
diff --git a/scripts/record-carousel.mjs b/scripts/record-carousel.mjs
new file mode 100644
index 0000000..062ad53
--- /dev/null
+++ b/scripts/record-carousel.mjs
@@ -0,0 +1,155 @@
+#!/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/stevestudio2/.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')}`);
+}

← c66a587 fix Wing° control: VIEW_OPEN was set by the slider but never  ·  back to Quadrille Showroom  ·  spec: 5 room types for showroom room-preview 2b90e02 →