← back to Quadrille Showroom
add boot-walk + carousel screen-recording harnesses
6c84fb0ad4b048cd1e86b099e6fa8b78bf89a8fe · 2026-06-30 19:32:41 -0700 · Steve Abrams
Files touched
A scripts/record-boot-walk.mjsA scripts/record-carousel.mjs
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 →