Static ads
How a batch of Meta feed creatives gets made - a grammar rather than a template library, three gates, and the brand's real typeface.
A static ad batch is the app’s most structured generation path. It exists because generating twenty ads one prompt at a time produces twenty variations of one ad, and nobody notices until they are in the ad account.
A grammar, not a format library
The module this replaced held 79 finished format templates as prose paragraphs with [bracketed] slots. The model’s only job was substituting words into someone else’s frozen layout, which produces headline swaps rather than ads: nothing in a template can reason about hierarchy or contrast, every template carried assumptions (“dark background”, “bottom text bar”) that fight the actual offer, and the library could not grow without editing a 459-line file.
lib/static-ad-formats.ts holds the vocabulary an art director composes from:
| Axis | What it is |
|---|---|
| Image type | Graphic-centric (a cutout you arrange around) or photo-centric (a real scene you overlay) |
| Building blocks | Hook, product, subline, callouts, pills, diagrams, faux UI, screen splits… 2 to 6 per ad |
| Composition patterns | 10 geometry-only layout skeletons, mode-filtered. Optional |
| Visual devices | 8 moves written as instructions, not labels |
| Style treatments | One optional look over the whole concept |
| Writing styles | 24, exactly one per ad, and the subline follows it |
| Anchor proxies | Affiliate only: what stands in for a product that cannot be shown |
Around thirty primitives, thousands of valid formats, and each ad names its own. Growth is one array entry: renderToolkit() injects the arrays into the prompt at runtime and the Zod validator reads the same arrays, so the vocabulary and its enforcement cannot drift apart.
Devices are the differentiator
Layouts are mostly the same three or four skeletons. What separates an ad someone stops on is what the picture does: an object substituted for a body part, a material personified as a character with a fate, a subject dropped into a borrowed world, an invisible thing made visible.
These are written as verbs because a category name generates nothing. And they carry one rule: the device must be legible before the hook is read. If the picture only makes sense once you have read the words, it is decoration, not a device.
No composition pattern and no visual device may carry more than two ads in a batch. Three ads on one device is one idea told three times.
Three gates
Concepts come first because the concept is what an ad lives or dies on, and burying it inside a ten-ad specification full of hex colours gives the user nothing to react to. Each is one line: the device, the picture in a sentence, where the hook goes. The batch row is born here with status concepts.
Planning expands only the approved ideas. Both stages run the same Director through one loadBatchContext, so ideas are expanded against exactly the brief they were judged under.
Generation is the first spend. Nothing before it costs a credit.
The Director returns JSON, code writes the prompt
The Director never writes a prompt string. It returns structured JSON per ad - format name, image type, pattern, device, composition entries with percentage positions, copy, design with hex values and fonts - and buildAdPrompt() assembles the final prompt in code, in fixed sections: TASK, COMPOSITION, TEXT, DESIGN, PHOTOGRAPHY, CONSTRAINTS.
That split is the point. The rules that matter most are the ones a model quietly drops on ad 14 of 20:
- never describe the anchor subject’s appearance, size or proportions (the reference image carries it; describing it makes the model redraw and distort it)
- position everything with percentages
- never write amend language
Assembling in code means they hold for every ad, and it makes revision trivial: an ad’s whole plan is stored on images.ad_record, so a single-ad revision rebuilds a fresh specification rather than patching an old prompt.
Two modes, and disclosure is a separate axis
offer_type is the business relationship. product_disclosure is whether an ad may show the thing. They only correlate.
- ecom_brand - the product is the hero, placed from its real photos.
- affiliate, hidden - the product never appears or is named. The
productblock is unavailable and every ad is anchored by a proxy. - affiliate, visible - a gadget where seeing the object helps. The object may appear; its brand name, product name, logo and packaging text may not.
Unknown resolves to hidden on purpose: revealing a product that should have stayed hidden destroys the ad, while hiding one that could have been shown only weakens it. Both are frozen onto the batch at concept time, so changing the offer later cannot retroactively reinterpret ads the user already approved.
The brand’s real typeface
Naming a font in a prompt does very little. Image models do not load font files, but they will follow a font shown in a reference image, so the two halves of brand identity travel differently:
- Names are read off the brand’s own CSS (
lib/brand-assets.ts): Google Fonts links,@font-facefamilies with theirsrc, andfont-familydeclarations ranked by the selector they sit on, so a family onh1is the headline font. Never guessed from appearance - a font identified by eye is a plausible wrong answer, and a wrong name in the prompt reads as authoritative. - Letterforms become pixels. A public
/type-sheetroute renders each font at its real weight, the screenshot service captures it to R2, and it is attached to every generation marked FONT REFERENCE ONLY.
Detection and the type sheet fail independently, which is a feature: real font names with no sheet still beat invented prose. When a paid or CORS-blocked font cannot render, the fallback is a user-uploaded screenshot via list_recent_uploads + add_brand_reference.
Swipe references (example ads whose look the user wants) attach as style only and rotate by ad index, so a batch does not converge on one borrowed look.
Anti-repeat comes from data
Format names and angles already used for an offer are read off stored ad records and handed to the Director as a binding exclusion list. Patterns and devices join it as a soft list instead: with ten patterns and eight devices, a hard ban would exhaust the vocabulary after two batches and push the Director toward whatever was left rather than what fits.
Standing feedback
contentJson.adFeedbackRules is loaded before every batch. Record durable direction there (“never show a face”), never a one-off fix to a single ad - the test is whether it would still be true for a completely different ad. Sharpening replaces a rule in place rather than stacking a near-duplicate, because every line costs tokens and attention on work that has not happened yet.