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>
254 lines
8.8 KiB
TypeScript
254 lines
8.8 KiB
TypeScript
import { beforeAll, describe, expect, it, vi } from 'vitest';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { initLayoutEngine, isInitialized } from '@banner-studio/layout-engine';
|
|
import type { ExtractedContext, GenerateOutputV2 } from '@banner-studio/types';
|
|
import { orchestrateRow } from '../src/ai-orchestration/orchestrator.js';
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
const fontsDir = path.resolve(__dirname, '..', '..', '..', 'infra', 'fonts');
|
|
|
|
async function ensureEngine(): Promise<void> {
|
|
if (isInitialized()) return;
|
|
await 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')
|
|
}
|
|
]
|
|
});
|
|
}
|
|
|
|
const extractedCtx: ExtractedContext = {
|
|
subject: 'velvet sofa',
|
|
audience_hint: 'design-aware buyers',
|
|
tone_hint: 'premium, calm',
|
|
key_benefits: ['handmade', 'lifetime frame'],
|
|
cta_intent: 'shop now'
|
|
};
|
|
|
|
const fittingCopy: GenerateOutputV2 = {
|
|
per_size: {
|
|
'300x600': {
|
|
headline: 'Velvet sofas, made to last decades',
|
|
subheadline: 'Handmade frames, decade-long warranty.',
|
|
cta_text: 'Shop velvet sofas',
|
|
legal: 'Terms apply.'
|
|
},
|
|
'300x250': {
|
|
headline: 'Velvet sofas built to last',
|
|
subheadline: 'Handmade frames, decade warranty.',
|
|
cta_text: 'Shop now'
|
|
},
|
|
'728x90': {
|
|
headline: 'Velvet sofas, made to last',
|
|
subheadline: 'Handmade frames, decade warranty.',
|
|
cta_text: 'Shop now'
|
|
},
|
|
'160x600': {
|
|
headline: 'Velvet sofas',
|
|
subheadline: 'Handmade frames, decade warranty.',
|
|
cta_text: 'Shop now',
|
|
legal: 'Terms apply.'
|
|
}
|
|
},
|
|
rationale: {
|
|
copy: 'benefit-first wording',
|
|
asset: 'velvet sofa hero',
|
|
variant: 'standard',
|
|
animation: 'fade cascade'
|
|
}
|
|
};
|
|
|
|
const tooLongCopy: GenerateOutputV2 = {
|
|
per_size: {
|
|
'300x600': {
|
|
headline: 'An overlong headline that absolutely will not fit any character budget reasonable for a banner ad ever',
|
|
subheadline: 'a',
|
|
cta_text: 'Shop'
|
|
},
|
|
'300x250': {
|
|
headline: 'An overlong headline that will not fit the budget at thirty five characters',
|
|
subheadline: 'a',
|
|
cta_text: 'Shop'
|
|
},
|
|
'728x90': {
|
|
headline: 'An overlong headline that will not fit the leaderboard budget',
|
|
subheadline: 'a',
|
|
cta_text: 'Shop'
|
|
},
|
|
'160x600': {
|
|
headline: 'An overlong skyscraper headline that will not fit',
|
|
subheadline: 'a',
|
|
cta_text: 'Shop'
|
|
}
|
|
},
|
|
rationale: { copy: '', asset: '', variant: '', animation: '' }
|
|
};
|
|
|
|
describe('orchestrateRow', () => {
|
|
beforeAll(async () => {
|
|
await ensureEngine();
|
|
});
|
|
|
|
it('extract → generate → route → assemble → resolve produces one BannerSpec per template', async () => {
|
|
// Mocked Claude caller: first call (extract) returns the extracted context,
|
|
// second call (generate) returns fitting per-size copy.
|
|
const call = vi
|
|
.fn()
|
|
.mockImplementationOnce(async () => extractedCtx)
|
|
.mockImplementationOnce(async () => fittingCopy);
|
|
|
|
const result = await orchestrateRow({
|
|
row: {
|
|
raw_description: 'Handmade velvet sofas with lifetime frame warranty.',
|
|
product: 'Modular velvet sofa',
|
|
offer: 'Free white-glove delivery',
|
|
hero_image_url: 'https://cdn.example/hero.jpg',
|
|
click_url: 'https://example.com/landing'
|
|
},
|
|
rowIndex: 0,
|
|
campaignId: 'camp-test',
|
|
callClaude: call as unknown as Parameters<typeof orchestrateRow>[0]['callClaude']
|
|
});
|
|
|
|
expect(call).toHaveBeenCalledTimes(2);
|
|
expect(result.status).toBe('ok');
|
|
if (result.status !== 'ok') return;
|
|
|
|
// Four templates → four BannerSpecs, one per artboard size.
|
|
expect(result.specs).toHaveLength(4);
|
|
const artboardIds = result.specs.map((s) => s.artboards[0]!.artboard_id).sort();
|
|
expect(artboardIds).toEqual(['160x600', '300x250', '300x600', '728x90']);
|
|
|
|
for (const spec of result.specs) {
|
|
expect(spec.campaign_id).toBe('camp-test');
|
|
expect(spec.artboards).toHaveLength(1);
|
|
const artboard = spec.artboards[0]!;
|
|
expect(artboard.layers.length).toBeGreaterThan(0);
|
|
// Every text layer has populated layout_log + computed_font_size.
|
|
const textLayers = artboard.layers.filter((l) => l.type === 'text');
|
|
for (const t of textLayers) {
|
|
expect(t.layout_log).toBeDefined();
|
|
expect(typeof t.computed_font_size).toBe('number');
|
|
}
|
|
// ai_reasoning carries the shared rationale.
|
|
expect(spec.ai_reasoning.copy_rationale).toBe('benefit-first wording');
|
|
expect(spec.click_destinations).toEqual([
|
|
{ id: 'cta', url: 'https://example.com/landing' }
|
|
]);
|
|
// Hero asset is present.
|
|
const hero = artboard.layers.find((l) => l.type === 'smart_asset');
|
|
expect(hero).toBeDefined();
|
|
}
|
|
|
|
// The shared rationale is also on the result for consumers that want it
|
|
// without digging into a spec.
|
|
expect(result.rationale.copy).toBe('benefit-first wording');
|
|
});
|
|
|
|
it('returns skipped when generate exhausts attempts on overflow', async () => {
|
|
// Three Claude calls: 1 extract + 2 generate retries, both overflow.
|
|
const call = vi
|
|
.fn()
|
|
.mockImplementationOnce(async () => extractedCtx)
|
|
.mockImplementationOnce(async () => tooLongCopy)
|
|
.mockImplementationOnce(async () => tooLongCopy);
|
|
|
|
const result = await orchestrateRow({
|
|
row: {
|
|
raw_description: 'something',
|
|
product: 'thing',
|
|
hero_image_url: 'x',
|
|
click_url: 'y'
|
|
},
|
|
rowIndex: 4,
|
|
campaignId: 'camp-test',
|
|
callClaude: call as unknown as Parameters<typeof orchestrateRow>[0]['callClaude']
|
|
});
|
|
|
|
expect(result.status).toBe('skipped');
|
|
if (result.status === 'skipped') {
|
|
expect(result.rowIndex).toBe(4);
|
|
expect(result.reason).toMatch(/headline/);
|
|
}
|
|
});
|
|
|
|
it('returns error when an underlying call throws', async () => {
|
|
const call = vi.fn().mockRejectedValue(new Error('boom'));
|
|
const result = await orchestrateRow({
|
|
row: {
|
|
raw_description: 'x',
|
|
product: 'y',
|
|
hero_image_url: 'z',
|
|
click_url: 'w'
|
|
},
|
|
rowIndex: 2,
|
|
campaignId: 'camp',
|
|
callClaude: call as unknown as Parameters<typeof orchestrateRow>[0]['callClaude']
|
|
});
|
|
expect(result.status).toBe('error');
|
|
if (result.status === 'error') {
|
|
expect(result.error).toBe('boom');
|
|
}
|
|
});
|
|
|
|
it('exposes constraint_signals when the layout engine emits them', async () => {
|
|
// Copy that's short enough to satisfy per-size character budgets but long
|
|
// enough to make the layout engine hit the legibility floor on at least
|
|
// one size. We engineer this by stuffing the headline near the budget
|
|
// for the narrowest size (skyscraper headline limit is 28 chars, fits
|
|
// at fs 22; this exact length combined with the narrow column tends to
|
|
// wrap > target lines at floor).
|
|
const borderlineCopy: GenerateOutputV2 = {
|
|
...fittingCopy,
|
|
per_size: {
|
|
...fittingCopy.per_size,
|
|
'160x600': {
|
|
// Right at the 28-char budget but tough to wrap in the narrow column.
|
|
headline: 'Velvet sofas built for life',
|
|
subheadline: 'Handmade frames, decade warranty, free delivery available.',
|
|
cta_text: 'Shop now',
|
|
legal: 'Terms apply.'
|
|
}
|
|
}
|
|
};
|
|
|
|
const call = vi
|
|
.fn()
|
|
.mockImplementationOnce(async () => extractedCtx)
|
|
// First generate attempt: returns borderline copy.
|
|
.mockImplementationOnce(async () => borderlineCopy)
|
|
// If a constraint signal fires, orchestrator retries generate; we just
|
|
// return the same copy to exercise the path without diverging the test
|
|
// on layout-engine internals.
|
|
.mockImplementation(async () => borderlineCopy);
|
|
|
|
const result = await orchestrateRow({
|
|
row: {
|
|
raw_description: 'Velvet sofas with lifetime warranty.',
|
|
product: 'Velvet sofa',
|
|
hero_image_url: 'https://cdn.example/hero.jpg',
|
|
click_url: 'https://example.com/landing'
|
|
},
|
|
rowIndex: 7,
|
|
campaignId: 'camp-test',
|
|
callClaude: call as unknown as Parameters<typeof orchestrateRow>[0]['callClaude'],
|
|
maxConstraintRetries: 0 // don't actually retry — just expose the signals on the first pass
|
|
});
|
|
|
|
expect(result.status).toBe('ok');
|
|
if (result.status !== 'ok') return;
|
|
// We don't assert constraint_signals.length > 0 here — whether the engine
|
|
// actually emits one depends on dropflow's measured wrapping. The contract
|
|
// we lock is that the field is present and is an array.
|
|
expect(Array.isArray(result.constraint_signals)).toBe(true);
|
|
});
|
|
});
|