← back to Consulting Rentv Com

data/audit-video/capture.mjs

122 lines

// RENTV portal walkthrough capture harness.
//   node capture.mjs --shots            -> per-tab screenshots into ./shots/
//   node capture.mjs --record           -> timed screen recording into ./rec/, driven by timing.json
// Playwright resolved from the global install (symlinked into node_modules); drives system Chrome.
import { chromium } from 'playwright';
import fs from 'fs';
import path from 'path';

const PORTAL_URL = 'http://localhost:9701/portal';
const CREDS = { username: 'admin', password: 'DW2024!' };
const HERE = path.dirname(new URL(import.meta.url).pathname);
const NAR = JSON.parse(fs.readFileSync(path.join(HERE, 'narration.json'), 'utf8'));
const SEGS = NAR.segments;
const mode = process.argv[2] || '--shots';

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

async function openOnly(page, id) {
  await page.evaluate((id) => {
    if (typeof window.setAll === 'function') { try { window.setAll(false); } catch (e) {} }
    document.querySelectorAll('section.open').forEach((s) => {
      if (s.id !== id) { const h = s.querySelector('.acchead'); if (h) h.click(); }
    });
    const s = document.getElementById(id);
    if (s) {
      const head = s.querySelector('.acchead');
      if (head && !s.classList.contains('open')) head.click();
      const anchor = s.querySelector('.eyebrow') || s;
      anchor.scrollIntoView({ behavior: 'instant', block: 'start' });
      window.scrollBy(0, -70);
      // force Leaflet/charts that init inside a just-revealed accordion to lay out
      window.dispatchEvent(new Event('resize'));
      setTimeout(() => window.dispatchEvent(new Event('resize')), 400);
    }
  }, id);
}

// slowly scroll through a tall panel until absolute time `until`, so the whole tab is read
async function readScroll(page, id, until) {
  const info = await page.evaluate((id) => {
    const s = document.getElementById(id);
    const r = s.getBoundingClientRect();
    return { top: window.scrollY + r.top - 70, height: s.scrollHeight };
  }, id);
  const overflow = Math.max(0, info.height - 1080);
  const segStart = Date.now();
  const segLen = Math.max(1, until - segStart);
  while (Date.now() < until) {
    if (overflow > 0) {
      const frac = Math.min(1, (Date.now() - segStart) / segLen);
      const e = frac < 0.15 ? 0 : frac > 0.9 ? 1 : (frac - 0.15) / 0.75; // linger top & bottom
      await page.evaluate((y) => window.scrollTo(0, y), info.top + overflow * e);
    }
    await sleep(180);
  }
}

async function main() {
  const record = mode === '--record';
  const recDir = path.join(HERE, 'rec');
  const shotsDir = path.join(HERE, 'shots');
  fs.mkdirSync(record ? recDir : shotsDir, { recursive: true });

  // bundled Chromium (isolated from system/OpenClaw Chrome); headless is stable for recordVideo
  const browser = await chromium.launch({ headless: true, args: ['--disable-dev-shm-usage'] });
  const ctx = await browser.newContext({
    viewport: { width: 1920, height: 1080 },
    deviceScaleFactor: 1,
    httpCredentials: CREDS,
    ...(record ? { recordVideo: { dir: recDir, size: { width: 1920, height: 1080 } } } : {}),
  });
  const page = await ctx.newPage();
  await page.goto(PORTAL_URL, { waitUntil: 'load', timeout: 45000 });
  await sleep(1200);

  if (!record) {
    for (let i = 0; i < SEGS.length; i++) {
      await openOnly(page, SEGS[i].id);
      await sleep(1100);
      const f = path.join(shotsDir, String(i + 1).padStart(2, '0') + '-' + SEGS[i].id + '.png');
      await page.screenshot({ path: f });
      console.log('shot', f);
    }
    await ctx.close();
    await browser.close();
    return;
  }

  // ---- record mode: absolute timeline from timing.json (zero drift) ----
  const timing = JSON.parse(fs.readFileSync(path.join(HERE, 'timing.json'), 'utf8'));
  const starts = timing.starts;      // {id: narration start seconds}
  const audioEnd = timing.audio_end; // total seconds
  await openOnly(page, SEGS[0].id);  // summary already on screen at t=0
  await sleep(300);
  const T0 = Date.now();
  const video = page.video();
  try {
    for (let i = 0; i < SEGS.length; i++) {
      const id = SEGS[i].id;
      const offset = (i === 0 ? 0 : starts[id]) * 1000;
      const nextOffset = (i + 1 < SEGS.length ? starts[SEGS[i + 1].id] * 1000 : audioEnd * 1000);
      while (Date.now() - T0 < offset) await sleep(50);
      try {
        if (i !== 0) await openOnly(page, id);
        await readScroll(page, id, T0 + nextOffset);
      } catch (e) {
        console.log(`tab ${id}: recovered from error (${e.message.slice(0, 40)}) — holding`);
        while (Date.now() - T0 < nextOffset) await sleep(100);
      }
      console.log(`tab ${id}: ${(offset / 1000).toFixed(1)}s -> ${(nextOffset / 1000).toFixed(1)}s`);
    }
  } finally {
    await ctx.close();
    const vp = await video.path();
    const dest = path.join(recDir, 'portal-walk.webm');
    fs.renameSync(vp, dest);
    console.log('VIDEO', dest);
    await browser.close();
  }
}
main().catch((e) => { console.error(e); process.exit(1); });