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