// 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-/.zip (one zip per size, per row) // Legacy specs body keeps the older layout: // exports//.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 { 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/.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 | 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//.zip' }); }