banner_studio/packages/render-worker/src/render-many.ts
Simeon Schecter ccbdb47162 Day 5+6 of the vertical slice: multi-size + per-row strips
Expands the slice from a single 300x250 banner to four IAB sizes
(300x600, 300x250, 728x90, 160x600) driven by a designer-authored
TypeSystem and a per-row strip review surface.

Layout engine
- TypeSystem with role-based typography (headline/subheadline/cta/legal)
  and piecewise size-class derivation: half_page / rectangle /
  leaderboard / skyscraper / mobile_banner.
- resolveLayout now derives per-size font/leading from the role +
  artboard size, then clamps to a legibility floor and emits a
  constraint_signal when copy does not fit at the floor.
- Four reference templates with character constraints per size.

AI pipeline (Shape B)
- One extract + one generate per feed row; generate returns per-size
  copy keyed by artboard_id plus a shared rationale block.
- Constraint-signal retry: orchestrator tightens per-(artboard, field)
  limits and re-calls generate before giving up.
- orchestrateRow returns specs[] + rationale + constraint_signals.

Review UI
- /review renders one strip per feed row, all four sizes side-by-side
  at true pixel dimensions, synced on a single GSAP master timeline.
- AiReasoningDrawer shows a per-size copy table, shared rationale, and
  any constraint signals that fired.
- /api/generate response grouped by row; /api/export accepts the same
  shape and writes exports/row-N/artboard_id.zip.

Render worker
- render-to-zip / render-many accept optional subdir + filename
  overrides so multi-size exports can be grouped by feed row.

Docs
- VERTICAL_SLICE and BUILD_SEQUENCE updated for the multi-size scope.
- RESOLVED_FEED.md documents the V1 Resolved Creative Feed proposal.
- SLICE_DEVIATIONS.md records where the slice diverges from V1.

Tests: 56 pass (28 layout-engine + 14 api-lib + 14 render-worker).
Web app: tsc clean, next build succeeds.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-18 14:22:26 -04:00

159 lines
4.8 KiB
TypeScript

// 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 (`<campaign>/<version>.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<T, R>(
items: T[],
limit: number,
fn: (item: T, index: number) => Promise<R>
): Promise<R[]> {
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<void>[] = [];
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<RenderManyResult> {
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 };
}