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>
152 lines
4.9 KiB
TypeScript
152 lines
4.9 KiB
TypeScript
// POST /api/export
|
|
// Body (multi-size): { rows: [{ rowIndex: number, specs: BannerSpec[] }] }
|
|
// Body (legacy): { specs: BannerSpec[] }
|
|
//
|
|
// Validates each spec via BannerSpecSchema. Invalid specs collect into the
|
|
// errors array rather than 400-ing the whole request — the producer should
|
|
// still get zips for the valid ones. Valid specs are rendered through
|
|
// renderMany() (single Chromium, concurrent contexts, capped at 3).
|
|
//
|
|
// Per-row body groups output by feed row:
|
|
// exports/row-<N>/<artboard_id>.zip (one zip per size, per row)
|
|
// Legacy specs body keeps the older layout:
|
|
// exports/<campaign>/<version>.zip
|
|
//
|
|
// Synchronous: returns when every zip is on disk. The slice only produces
|
|
// a handful of banners per call so ~3-8 seconds blocking is acceptable.
|
|
// V1 will move to a queued + SSE-streamed flow.
|
|
//
|
|
// runtime: 'nodejs' — Playwright launches a subprocess and uses fs.
|
|
// dynamic: 'force-dynamic' — must run on every request; nothing about it
|
|
// is statically renderable.
|
|
//
|
|
// The render-worker is dynamic-imported for parity with /api/generate's
|
|
// pattern (heavy module, only loaded inside the request).
|
|
|
|
import { NextResponse } from 'next/server';
|
|
import type { BannerSpec } from '@banner-studio/types';
|
|
|
|
export const runtime = 'nodejs';
|
|
export const dynamic = 'force-dynamic';
|
|
|
|
interface ExportRequestBody {
|
|
specs?: unknown;
|
|
rows?: unknown;
|
|
}
|
|
|
|
interface RowInput {
|
|
rowIndex: number;
|
|
specs: BannerSpec[];
|
|
}
|
|
|
|
export async function POST(req: Request): Promise<NextResponse> {
|
|
let body: ExportRequestBody;
|
|
try {
|
|
body = (await req.json()) as ExportRequestBody;
|
|
} catch {
|
|
return NextResponse.json(
|
|
{ error: 'Request body must be JSON' },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
const path = await import('node:path');
|
|
const { BannerSpecSchema } = await import('@banner-studio/types');
|
|
const { renderMany } = await import('@banner-studio/render-worker');
|
|
|
|
const errors: { version_id?: string; error: string }[] = [];
|
|
|
|
// Per-row mode: validate every spec inside every row. Each spec carries
|
|
// its row index forward so the renderer can emit exports/row-N/<size>.zip.
|
|
let perRow: { spec: BannerSpec; rowIndex: number }[] | null = null;
|
|
if (Array.isArray(body.rows)) {
|
|
perRow = [];
|
|
for (let r = 0; r < body.rows.length; r++) {
|
|
const row = body.rows[r] as Partial<RowInput> | null;
|
|
if (!row || typeof row !== 'object' || !Array.isArray(row.specs)) continue;
|
|
const rowIndex = typeof row.rowIndex === 'number' ? row.rowIndex : r;
|
|
for (let i = 0; i < row.specs.length; i++) {
|
|
const parsed = BannerSpecSchema.safeParse(row.specs[i]);
|
|
if (parsed.success) {
|
|
perRow.push({ spec: parsed.data, rowIndex });
|
|
} else {
|
|
const raw = row.specs[i];
|
|
const maybeVersionId =
|
|
raw && typeof raw === 'object' && 'version_id' in raw
|
|
? String((raw as { version_id?: unknown }).version_id ?? '')
|
|
: undefined;
|
|
errors.push({
|
|
version_id: maybeVersionId,
|
|
error: `row[${r}].specs[${i}] failed validation: ${parsed.error.message}`
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Legacy specs mode: kept for any older caller. Flat array, default paths.
|
|
let flat: BannerSpec[] | null = null;
|
|
if (!perRow) {
|
|
const rawSpecs = Array.isArray(body.specs) ? body.specs : null;
|
|
if (!rawSpecs) {
|
|
return NextResponse.json(
|
|
{ error: 'Request body must include either a "rows" or "specs" array' },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
flat = [];
|
|
for (let i = 0; i < rawSpecs.length; i++) {
|
|
const parsed = BannerSpecSchema.safeParse(rawSpecs[i]);
|
|
if (parsed.success) {
|
|
flat.push(parsed.data);
|
|
} else {
|
|
const raw = rawSpecs[i];
|
|
const maybeVersionId =
|
|
raw && typeof raw === 'object' && 'version_id' in raw
|
|
? String((raw as { version_id?: unknown }).version_id ?? '')
|
|
: undefined;
|
|
errors.push({
|
|
version_id: maybeVersionId,
|
|
error: `spec[${i}] failed validation: ${parsed.error.message}`
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
const repoRoot = path.resolve(process.cwd(), '..', '..');
|
|
const outputDir = path.join(repoRoot, 'exports');
|
|
|
|
let result;
|
|
if (perRow) {
|
|
const specsFlat = perRow.map((p) => p.spec);
|
|
const rowIndexFor = perRow.map((p) => p.rowIndex);
|
|
result = await renderMany({
|
|
specs: specsFlat,
|
|
outputDir,
|
|
concurrency: 3,
|
|
repoRoot,
|
|
pathFor: (spec, idx) => ({
|
|
subdir: `row-${(rowIndexFor[idx] ?? idx) + 1}`,
|
|
filename: spec.artboards[0]!.artboard_id
|
|
})
|
|
});
|
|
} else {
|
|
result = await renderMany({
|
|
specs: flat!,
|
|
outputDir,
|
|
concurrency: 3,
|
|
repoRoot
|
|
});
|
|
}
|
|
|
|
return NextResponse.json({
|
|
exported: result.exported,
|
|
errors: [...errors, ...result.errors]
|
|
});
|
|
}
|
|
|
|
export async function GET() {
|
|
return NextResponse.json({
|
|
hint: 'POST { specs: BannerSpec[] } to render each into exports/<campaign>/<version>.zip'
|
|
});
|
|
}
|