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>
159 lines
4.8 KiB
TypeScript
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 };
|
|
}
|