banner_studio/apps/web/app/api/export/route.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

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'
});
}