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>
139 lines
4.1 KiB
TypeScript
139 lines
4.1 KiB
TypeScript
// POST /api/generate
|
|
// Loads /feeds/demo.csv, runs orchestrateRow against every row (capped
|
|
// concurrency), returns the resolved BannerSpecs.
|
|
//
|
|
// runtime: 'nodejs' is required because the Anthropic SDK uses Node APIs.
|
|
//
|
|
// Note: every layout-engine + api-lib import here is dynamic. Module-level
|
|
// imports of @banner-studio/layout-engine pull in dropflow, which in turn
|
|
// runs a `new URL('../dropflow.wasm', import.meta.url)` at load time —
|
|
// fine in pure Node, but webpack rewrites import.meta.url during build-time
|
|
// route discovery and breaks. Deferring the import side-steps it.
|
|
|
|
import { NextResponse } from 'next/server';
|
|
|
|
export const runtime = 'nodejs';
|
|
export const dynamic = 'force-dynamic';
|
|
|
|
let nodeEnginePromise: Promise<void> | null = null;
|
|
|
|
async function ensureNodeEngine(): Promise<void> {
|
|
if (nodeEnginePromise) return nodeEnginePromise;
|
|
const path = await import('node:path');
|
|
const { initLayoutEngine, isInitialized } = await import(
|
|
'@banner-studio/layout-engine'
|
|
);
|
|
if (isInitialized()) {
|
|
nodeEnginePromise = Promise.resolve();
|
|
return nodeEnginePromise;
|
|
}
|
|
const repoRoot = path.resolve(process.cwd(), '..', '..');
|
|
const fontsDir = path.join(repoRoot, 'infra', 'fonts');
|
|
nodeEnginePromise = initLayoutEngine({
|
|
fonts: [
|
|
{
|
|
family: 'Inter',
|
|
weight: 400,
|
|
path: 'file://' + path.join(fontsDir, 'Inter-Regular.ttf')
|
|
},
|
|
{
|
|
family: 'Inter',
|
|
weight: 700,
|
|
path: 'file://' + path.join(fontsDir, 'Inter-Bold.ttf')
|
|
}
|
|
]
|
|
});
|
|
return nodeEnginePromise;
|
|
}
|
|
|
|
export async function POST(): Promise<NextResponse> {
|
|
if (!process.env.ANTHROPIC_API_KEY) {
|
|
const { MissingApiKeyError } = await import('@banner-studio/api-lib');
|
|
return NextResponse.json(
|
|
{ error: new MissingApiKeyError().message },
|
|
{ status: 401 }
|
|
);
|
|
}
|
|
|
|
await ensureNodeEngine();
|
|
|
|
const path = await import('node:path');
|
|
const { loadFeed, orchestrateRow, callClaudeWithTool } = await import(
|
|
'@banner-studio/api-lib'
|
|
);
|
|
|
|
const repoRoot = path.resolve(process.cwd(), '..', '..');
|
|
const feedPath = path.join(repoRoot, 'feeds', 'demo.csv');
|
|
|
|
let feedRows;
|
|
try {
|
|
feedRows = loadFeed(feedPath);
|
|
} catch (err) {
|
|
return NextResponse.json(
|
|
{ error: err instanceof Error ? err.message : String(err) },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
const results = await runWithConcurrency(feedRows, 3, (row, i) =>
|
|
orchestrateRow({
|
|
row,
|
|
rowIndex: i,
|
|
campaignId: 'demo-campaign',
|
|
callClaude: callClaudeWithTool
|
|
})
|
|
);
|
|
|
|
// Group results by row so the per-row strip UI can render one strip per
|
|
// feed row containing the four per-size BannerSpecs. Skipped/errored rows
|
|
// are surfaced separately.
|
|
const rows = results
|
|
.filter((r) => r.status === 'ok')
|
|
.map((r) =>
|
|
r.status === 'ok'
|
|
? {
|
|
rowIndex: r.rowIndex,
|
|
specs: r.specs,
|
|
rationale: r.rationale,
|
|
constraint_signals: r.constraint_signals
|
|
}
|
|
: null
|
|
)
|
|
.filter((x) => x !== null);
|
|
const skipped = results
|
|
.filter((r) => r.status === 'skipped')
|
|
.map((r) => (r.status === 'skipped' ? { rowIndex: r.rowIndex, reason: r.reason } : null));
|
|
const errors = results
|
|
.filter((r) => r.status === 'error')
|
|
.map((r) => (r.status === 'error' ? { rowIndex: r.rowIndex, error: r.error } : null));
|
|
|
|
return NextResponse.json({ rows, skipped, errors });
|
|
}
|
|
|
|
export async function GET() {
|
|
return NextResponse.json({
|
|
hint: 'POST this endpoint to run the pipeline against feeds/demo.csv'
|
|
});
|
|
}
|
|
|
|
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 workers: Promise<void>[] = [];
|
|
const worker = async () => {
|
|
while (true) {
|
|
const i = next++;
|
|
if (i >= items.length) return;
|
|
results[i] = await fn(items[i]!, i);
|
|
}
|
|
};
|
|
for (let i = 0; i < Math.min(limit, items.length); i++) {
|
|
workers.push(worker());
|
|
}
|
|
await Promise.all(workers);
|
|
return results;
|
|
}
|