// Concurrent render of N BannerSpecs through a single Chromium instance. // One browser, multiple isolated contexts, capped at min(N, 3). Failures // for one spec do not stop the others — they accumulate in the errors // list. Font + GSAP bytes are read once at the top so renderToZip never // hits disk for them. import fs from 'node:fs'; import path from 'node:path'; import { createRequire } from 'node:module'; import { fileURLToPath } from 'node:url'; import { chromium } from 'playwright'; import type { BannerSpec } from '@banner-studio/types'; import { renderToZip } from './render-to-zip.js'; export interface RenderManyArgs { specs: BannerSpec[]; outputDir: string; concurrency?: number; /** * Absolute path to the repository root, used to locate * infra/fonts/Inter-*.ttf and node_modules/gsap/dist/gsap.min.js. * Defaults to the caller's CWD if omitted. */ repoRoot?: string; /** * Optional callback to override the per-spec output path. Returns * `{ subdir, filename }` (filename without `.zip`). If omitted, falls * back to the renderToZip default (`/.zip`). * * Multi-size exports use this to group by feed row: * `({ spec }) => ({ subdir: \`row-\${i+1}\`, filename: spec.artboards[0]!.artboard_id })` */ pathFor?: (spec: BannerSpec, index: number) => { subdir: string; filename: string }; } export interface ExportedItem { version_id: string; zip_path: string; bytes: number; } export interface ExportError { version_id?: string; error: string; } export interface RenderManyResult { exported: ExportedItem[]; errors: ExportError[]; } function resolveGsapSourcePath(repoRoot: string): string { // pnpm puts the real package under a content-addressed folder, NOT at the // top-level node_modules. Resolve through Node's module resolver, anchored // on this file — render-worker declares gsap in its own package.json, so // this createRequire sees it via pnpm's nested node_modules. Anchoring on // the repo root fails because gsap isn't installed there. try { const here = fileURLToPath(import.meta.url); const req = createRequire(here); return req.resolve('gsap/dist/gsap.min.js'); } catch { // Fall through to a best-effort path. With pnpm this is almost certainly // wrong, but it's only hit if the import.meta.url anchor failed entirely. return path.join(repoRoot, 'node_modules', 'gsap', 'dist', 'gsap.min.js'); } } async function runWithConcurrency( items: T[], limit: number, fn: (item: T, index: number) => Promise ): Promise { const results: R[] = new Array(items.length); let next = 0; const worker = async () => { while (true) { const i = next++; if (i >= items.length) return; results[i] = await fn(items[i]!, i); } }; const workers: Promise[] = []; for (let i = 0; i < Math.min(limit, items.length); i++) { workers.push(worker()); } await Promise.all(workers); return results; } export async function renderMany({ specs, outputDir, concurrency, repoRoot, pathFor }: RenderManyArgs): Promise { const exported: ExportedItem[] = []; const errors: ExportError[] = []; if (specs.length === 0) return { exported, errors }; const root = repoRoot ?? process.cwd(); const fontsDir = path.join(root, 'infra', 'fonts'); let interRegular: Buffer; let interBold: Buffer; let gsapSource: string; try { interRegular = fs.readFileSync(path.join(fontsDir, 'Inter-Regular.ttf')); interBold = fs.readFileSync(path.join(fontsDir, 'Inter-Bold.ttf')); gsapSource = fs.readFileSync(resolveGsapSourcePath(root), 'utf8'); } catch (err) { return { exported, errors: [ { error: `Failed to load required assets (fonts/gsap): ${ err instanceof Error ? err.message : String(err) }` } ] }; } fs.mkdirSync(outputDir, { recursive: true }); const browser = await chromium.launch({ headless: true }); try { const limit = concurrency ?? Math.min(specs.length, 3); await runWithConcurrency(specs, limit, async (spec, idx) => { try { const override = pathFor ? pathFor(spec, idx) : undefined; const result = await renderToZip({ spec, outputDir, browser, gsapSource, interRegular, interBold, ...(override ? { subdir: override.subdir, filename: override.filename } : {}) }); exported.push({ version_id: spec.version_id, zip_path: result.zip_path, bytes: result.bytes }); } catch (err) { errors.push({ version_id: spec.version_id, error: err instanceof Error ? err.message : String(err) }); } }); } finally { await browser.close(); } return { exported, errors }; }