← 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')}`);
}