← back to Debate Ring Viewer
scripts/storyboard-snap.cjs
86 lines
// Captures a single Playwright screenshot of the viewer with the Steve avatar
// already injected, so the pre-build "four-horsemen" review sees what a
// real frame of the recorded video will look like. Writes one PNG per
// run-state listed in the storyboard array (idle, mid-fight, KO).
const { chromium } = require('playwright');
const fs = require('fs');
const path = require('path');
const URL = process.env.URL || 'http://localhost:9730';
const OUT = process.env.OUT || './storyboards';
const AVATAR = path.join(__dirname, '..', '..', '..', '.claude/skills/app-demo-video/avatars/steve.svg');
(async () => {
fs.mkdirSync(OUT, { recursive: true });
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
await page.goto(URL, { waitUntil: 'networkidle', timeout: 20000 });
// Inject avatar exactly the way click-agent does, so the storyboard frame
// matches the recorded video frame 1:1.
let svg = '';
try { svg = fs.readFileSync(AVATAR, 'utf8'); } catch {}
if (svg) {
// Build the overlay via DOM APIs and parse the avatar SVG with DOMParser
// so a hostile avatar file (or one accidentally containing <script>) can't
// execute in the page. The previous template-literal `innerHTML = ${svg}`
// would have run any embedded handlers.
await page.evaluate(({ svg }) => {
const wrap = document.createElement('div');
wrap.style.cssText = 'position:fixed;right:24px;bottom:24px;width:200px;display:flex;flex-direction:row-reverse;align-items:flex-end;gap:10px;z-index:2147483645;font-family:-apple-system,system-ui,sans-serif';
const card = document.createElement('div');
card.style.cssText = 'position:relative;width:140px;height:140px;flex:0 0 140px;filter:drop-shadow(0 8px 18px rgba(0,0,0,.55))';
const ring = document.createElement('div');
ring.style.cssText = 'position:absolute;inset:-6px;border-radius:50%;border:3px solid #cc785c;opacity:.8';
card.appendChild(ring);
const photo = document.createElement('div');
photo.style.cssText = 'width:140px;height:140px;border-radius:50%;overflow:hidden;background:#0a1628';
try {
const doc = new DOMParser().parseFromString(svg, 'image/svg+xml');
const root = doc.documentElement;
if (root && root.nodeName.toLowerCase() === 'svg') {
// Strip any <script> elements and on* event-handler attributes that
// a malicious avatar file could carry over — DOMParser preserves them.
root.querySelectorAll('script').forEach(n => n.remove());
root.querySelectorAll('*').forEach(n => {
for (const a of Array.from(n.attributes)) {
if (/^on/i.test(a.name)) n.removeAttribute(a.name);
}
});
photo.appendChild(document.importNode(root, true));
}
} catch { /* unparseable svg → empty avatar circle, no XSS */ }
card.appendChild(photo);
const tag = document.createElement('div');
tag.style.cssText = 'position:absolute;left:-2px;bottom:-4px;background:#0a1628;color:#fffbe6;font-size:10px;letter-spacing:1.4px;padding:3px 8px;border-radius:4px;font-weight:700;border:1px solid #cc785c';
tag.textContent = 'STEVE';
card.appendChild(tag);
const bubble = document.createElement('div');
bubble.style.cssText = 'max-width:340px;background:#fffbe9;color:#16110a;border:2px solid #1a0e08;border-radius:14px;padding:10px 14px;font-size:13px;line-height:1.4;box-shadow:0 6px 18px rgba(0,0,0,.45)';
bubble.textContent = "Hey, I'm Steve. This is the Debate Ring — eight LLMs fighting over the words they wrote.";
wrap.appendChild(card);
wrap.appendChild(bubble);
document.body.appendChild(wrap);
}, { svg });
}
await page.waitForTimeout(800);
await page.screenshot({ path: path.join(OUT, 'frame-01-idle.png'), fullPage: false });
// Click into a run + start the fight, snap mid-action
try {
const r = await page.$('#runs li:nth-child(4)');
if (r) { await r.click(); await page.waitForTimeout(800); }
const play = await page.$('#play');
if (play) { await play.click(); await page.waitForTimeout(2200); }
} catch {}
await page.screenshot({ path: path.join(OUT, 'frame-02-fighting.png'), fullPage: false });
await page.waitForTimeout(3500);
await page.screenshot({ path: path.join(OUT, 'frame-03-deep.png'), fullPage: false });
await browser.close();
console.log(`[storyboard-snap] wrote 3 PNGs → ${OUT}`);
})().catch(e => { console.error('[storyboard-snap] FAIL', e.message); process.exit(1); });