Claude + Gemini API · Dynamic Creative Optimization · ENGINE · TECHNICAL ARCHITECTURE
DCO Engine: technical description
The Engine: generation, control, export describes its four steps at the level of what each one accomplishes. This page formalises them as five Python functions, each wrapping one or more calls to the Claude or Gemini API: arguments in, what the function does, what comes out — not the full implementation. Step 3 splits into two of those five functions: the loop that generates variants, and the judge-and-correct loop that checks them. That second one deserved more than a signature, so it gets its own breakdown below: how a defect gets classified, and how a fix gets chosen. It closes with the job and result formats an upstream caller would use, what has to sit around the engine to run as a shared service, a market scan of what comparable products do and don't do, and five open problems typical of AI pipelines, three of them now partly addressed below.
On image continuity: conceptually, this is a problem of semantic serialisation, which today can already be tackled with frameworks such as ComfyUI (combined with open-source modules or closed-model APIs, such as Google's, Anthropic's or OpenAI's). So-called dynamic creative composition still suffers today from a penalty that comes from trying to treat generative AI as a pure prompt-engineering problem.
Running example throughout: the character pair Grappolotto and Foglina, a vineyard background plate, and a professional wooden waiter's corkscrew — same campaign as the parent post.
-
01
generate_reference_pair() — the character pair, the product, and background plates
One call per background plate, plus the neutral identity shot for the cast and, when the campaign's product needs a locked visual identity of its own rather than a generic stand-in, one more call for its reference image — all via the Gemini API. No training run and no LoRA stage follow this — this release has none — so whatever comes out of this function is what every later step points back to directly.
def generate_reference_pair( character_brief: str, # e.g. "a grape-bunch and a vine-leaf, vineyard mascot pair" background_briefs: list[str], # 2-3 settings, e.g. ["neutral studio", "vineyard at dusk"] product_brief: str | None = None, # e.g. "professional wooden waiter's # corkscrew, de-branded" -- only when # the product itself, not just its # category, has to stay identifiable; # usually the same text later passed # as campaign_subject below ) -> ReferenceSet: """ API calls : Gemini (image) -- one call per background, staged LEFT:/RIGHT: as on the rest of the site, plus one more if product_brief is given. Returns : ReferenceSet -- the identity reference image(s), the background plates, and -- when product_brief was given -- the product reference image, all as file paths. Generated, not commissioned or trained; every other function below takes this object as a fixed input. judge_and_correct() checks a beat's product against this reference exactly as it checks character identity and position: not just that the right product appears, but where and how. """The file that travels with the reference set:
brand_config.json. Read side by side, the two brand_config.json examples in DCO: One Pipeline, Many Products, chapter 02, have identical keys and different values. The keys are the schema: shared by every brand, and the only thing the loop knows how to read. The values are the brand. Three blocks do most of the work.identity_assertionsis what D checks in every shot, character by character.productis the object the story is about, and in the winery file it says "branded model only; never a generic or antique stand-in" — the failure shown in the corkscrew example of The Engine: generation, control, export, chapter 05, where the engine pulled a crude generic corkscrew from its repository instead of the brand's. Written into the file, that stops being something a reviewer has to notice and becomes an assertion D can check.palette,style,settings_allowedandregisterkeep the shots of one brand reading as one brand while the briefs vary.So what is reused, and what is not? Reused unchanged: the generate → judge → correct loop (
generate_variant()andjudge_and_correct()below), the master storyboard logic, the human checkpoints, the manifest and the export. Written per brand: the configuration file (short, a dozen or so fields), the reference pair generated above, and a calibration set — a batch of shots labelled by a person, so that D's agreement and false-pass rate are measured on this brand instead of assumed from the last one. The file is cheap to write; the calibration is the part that cannot be inherited. A judge calibrated on the farm pair says nothing about how it behaves on a grape-cluster head. And these two brands are a favourable test — same storybook style, same golden-hour light; a brand in a different visual register would need its own style anchor too, which is the limit stated in chapter 01 of DCO: One Pipeline, Many Products. That is the real cost of adding a second brand — an estimated six to eight hours, roughly €225–250 per brand, one-off, worked out in DCO: cost model, chapter 12 — and it is the number that decides whether a shared service makes economic sense (see The Agency Channel in Italy). -
02
generate_story_spec() — the story as a structured description file
One Claude call, no image generation. By default, Claude writes the beat list directly from the brief — the entry list is generated, not hand-built shot by shot, and only checked afterward. When the brand's own creative team has already written the story (The Engine: generation, control, export names this as one of the two ways a story can arrive),
pre_written_scriptcarries that text in instead: Claude's call switches from authoring to structuring — it parses the existing script into the sameBeatSpecschema, fills in the staging detail (character position, background plate) the schema needs but prose doesn't give, and checks it againstreference_set, without inventing new beats of its own. Either path returns the sameStorySpec, so nothing downstream (steps 03–04) needs to know which one ran.def generate_story_spec( campaign_subject: str, # e.g. "a professional wooden waiter's corkscrew" slogan: str | None, # supporting copy only; does not drive the beats reference_set: ReferenceSet, pre_written_script: str | None = None, # brand-authored story, if any ) -> StorySpec: """ API calls : Claude (text) -- one call. Behaviour : if pre_written_script is None, Claude AUTHORS the beat list from campaign_subject (default path); the slogan is supporting copy only, never the source of the beats. If it's given, Claude STRUCTURES that existing text into the same schema instead -- parsing, not inventing: it fills in only the staging detail (position, background plate) the schema needs and checks the result against reference_set, rather than generating new beats from scratch. Returns : StorySpec -- a list of BeatSpec entries, one per shot: { characters, left_right_position, background_plate, product_or_prop, narrative_role, scene_conditions } Structured, not free prose -- this is what steps 03-04 check candidates against. Same shape either way. """What the description file looks like. Here is an excerpt, three beats of one variant. It is written by the LLM (Claude, in this release), never typed by the campaign creator: the manual inputs are only the neutral image of the mascot pair, the brand or object to promote, the script that describes the story, and the slogans. Every field below — scene conditions such as weather or time of day included — is generated from those inputs and then checked by a person. The same file is described in prose in The Engine: generation, control, export, chapter 03.
# StorySpec (excerpt) -- written by the LLM (Claude), not typed by hand [ { "beat": 1, "narrative_role": "opening", "characters": ["Grappolotto", "Foglina"], "left_right_position": { "LEFT": "Grappolotto", "RIGHT": "Foglina" }, "background_plate": "vineyard_dusk", "product_or_prop": "wooden waiter's corkscrew, lying on the table", "scene_conditions": { "weather": "sun", "time": "evening" } }, { "beat": 2, "narrative_role": "reveal", "characters": ["Grappolotto", "Foglina"], "left_right_position": { "LEFT": "Grappolotto", "RIGHT": "Foglina" }, "background_plate": "vineyard_dusk", "product_or_prop": "the same corkscrew, in Grappolotto's hand, uncorking the bottle", "scene_conditions": { "weather": "sun", "time": "evening" } }, { "beat": 3, "narrative_role": "close", "characters": ["Grappolotto", "Foglina"], "left_right_position": { "LEFT": "Grappolotto", "RIGHT": "Foglina" }, "background_plate": "vineyard_dusk", "product_or_prop": "the corkscrew back on the table, beside two glasses", "scene_conditions": { "weather": "sun", "time": "evening" } } ] -
03
generate_variant() — the description file in, one story variant out
One Gemini call per beat in the spec. The description file drives the staging; the slogan is supporting copy, not the author of the story. Output here is unverified — same cast and setting as reference_set, staged as the spec says, but nothing has passed the judge loop yet.
def generate_variant( story_spec: StorySpec, reference_set: ReferenceSet, slogan: str, # supporting copy for this variant; the story # itself comes from story_spec ) -> list[ShotCandidate]: """ API calls : Gemini (image) -- one call per BeatSpec in story_spec. Returns : one ShotCandidate per beat, unverified. The staging comes from story_spec; `slogan` is supporting copy and does not build the story. """ -
04
judge_and_correct() — the A→D→E→D loop, function by function
This is where most of the engine's actual behaviour lives. One call runs the full loop for exactly one shot: Gemini as the VLM judge for D, Gemini again as the image editor for E.
def judge_and_correct( shot: ShotCandidate, spec_entry: BeatSpec, history: list[Attempt] = [], # tactics already tried on THIS shot -- # never let E repeat one that failed budget: int = 3, # max correction attempts before giving up ) -> JudgedShot: """ API calls : Gemini (VLM judge) for D, Gemini (image) for E. 1. D checks `shot` against `spec_entry`, assertion by assertion; returns a defect list: [{category, severity, region}, ...] 2. if defect list is empty -> return JudgedShot(status="passed_first_try") 3. classify_defect() buckets each defect (see table below) 4. choose_tactic() picks a fix, cheapest first, skipping anything already in `history` 5. E applies the chosen tactic -> new candidate shot 6. D rechecks; repeat from 2 until passed, or `budget` is exhausted -> status="discarded" Returns : JudgedShot -- final image, status ("passed_first_try" | "passed_after_correction" | "discarded"), and the full attempt history (what Chapter 06 of the main post counts to get yield and refusal rate). """Inside classify_defect(). Different failure types need different fixes, not the same "regenerate and hope" every time:
Category Typical defect What E does Identity drift wrong face, proportions or colours reweight or reorder the reference images; strengthen identity language in the prompt Content / semantic missing or wrong element (wrong product, missing prop) add or strengthen that element as a hard constraint in the prompt Style / brand palette, lighting or mood off add or strengthen a style anchor Spatial / compositional pose, placement, framing the loop's known weak category — see The Engine: generation, control, export Inside choose_tactic(). One tactic per bucket, in order of cost and risk — the cheapest, lowest-risk option is tried first, and
historyis what stops it from being tried twice:- Targeted local edit (mask/region-based, if the API supports it) — when D can localise the defect and the rest of the image already passed. Cheapest, lowest risk of breaking what already works.
- Full regeneration with a mutated prompt — rewrite to explicitly counter the failure, possibly change seed.
- Best-of-N resampling — generate 2–3 candidates with varied phrasing/seed, let D rank them, keep the best.
- Escalate — stop spending budget on this shot, flag it (or the underlying spec/reference image) for a human look.
The
historyargument is what makes this a loop with memory, not four independent guesses: if tactic 1 already failed once on this shot,choose_tactic()skips straight to tactic 2 on the next pass, rather than re-trying the same local edit and spending budget on a result D has already rejected. -
05
reformat_to_formats() — one shot in, N formats out
Takes one approved shot and the list of aspect ratios the campaign needs, and returns one image per ratio. The crop-or-outpaint choice isn't a guess at where the subject sits: it reads the cast's bounding boxes straight from the shot's own description file (chapter 02) — the same positions
judge_and_correct()already checked — and picks whichever framing keeps every character inside it.def reformat_to_formats( shot: ApprovedShot, formats: list[str], # e.g. ["1:1", "4:5", "9:16", "16:9"] geometry_table: list[GeometryRow], # one row per template x format ) -> list[FormattedShot]: """ For each requested aspect ratio: crop_box = choose_crop_or_outpaint(shot.image, shot.cast_bboxes, # from 02 ratio) reserved = geometry_table.lookup(shot.template_id, ratio) quiet = check_reserved_zone_is_quiet(crop_box, reserved) # ~20 lines: low pixel variance, few edges, no # face overlap inside the reserved rectangle FormattedShot(image=crop_box.render(), ratio=ratio, reserved_zone=reserved, quiet_check=quiet, review_status="auto" if quiet else "needs_review") Returns : list[FormattedShot] -- one per requested ratio, each flagged for human review when its reserved rectangle isn't clear, never silently auto-approved. """Two different things are bundled under one name here. Reactive cropping — choosing, after the fact, the crop that keeps the cast inside frame — is what's implemented:
shot.cast_bboxesalready exists, becausejudge_and_correct()needs it too. Proactive reserved-zone staging — composing the scene at generation time (chapter 03) so the rectangle is already clear, before any cropping happens — is designed, not built: it would needgenerate_variant()to readgeometry_tableas well, and no real client's template geometry has been fed into it yet to test against. Thequiet_checkabove only catches the problem after the fact; it doesn't feed back into generation the wayjudge_and_correct()'s A→D→E→D loop does for identity drift.Per-call image cost by model tier, and the open question of pricing a crop versus an outpaint call differently, are in DCO: cost model.
-
06
export_manifest() — flattening the pool into one manifest
Takes every formatted, QA-passed shot across a whole campaign run and flattens it into one manifest: a list where each row is one deliverable unit — one shot, in one format, for one slogan. A six-beat story, three slogans and four formats produces 72 rows — exactly the multiplication an agency still does by hand without this step.
def export_manifest( formatted_shots: list[FormattedShot], campaign_meta: CampaignMeta, ) -> list[ManifestRow]: """ One row per (shot, format) pair: ManifestRow( asset_id = f"{shot.variant_id}-{shot.role}-{ratio}", brand = campaign_meta.brand, product_line = campaign_meta.product_line, story_id = shot.story_id, beat = shot.beat_index, narrative_role = shot.role, cast = shot.cast_ids, format = ratio, reserved_zone = shot.reserved_zone, variant_labels = shot.labels, # e.g. weather:rain qa_status = shot.qa_status, # from judge_and_correct() review_status = shot.review_status, # from reformat_to_formats() image_url = shot.hosted_url, ) Returns : list[ManifestRow] -- one canonical manifest. Mapping it column-by-column into a specific tool's feed schema (Studio's sheet, Flashtalking's or Celtra's own format) is an adapter's job, not this function's -- see The Agency Channel in Italy. """Two free reports come out of the same table, no extra pass needed: a coverage report — did every (slogan × format) combination actually get produced, or did one get discarded into zero surviving rows — and a batch account — yield and refusal rate broken down per slogan, not just for the run as a whole. Neither existed before this function, because before it there was no single table to group by.
image_urlpoints at a hosted file; the manifest carries references, never the images themselves — publishing those images somewhere the destination tool can read them is a deployment detail, not something this function does. What counts as "DSP" versus "DCO tool" downstream, and which of the three named tools a given adapter targets, is covered in DCO and the Agency Market.A Manifest row, worked through. The function above named the fields; here is one actual row, filled in. Take the grape-harvest campaign that runs through The grape harvest of Grappolotto and Foglina: the evening-bottling beat, staged for the "…the harvest gives us a wonderful wine!" slogan, exported in the square format.
-
asset_id —
cavatappi-professionale-filari-tino-05-1x1, following the product-slogan-beat-format convention above — the brand slot is dropped rather than filled with a brand name, see the next field. -
brand, product_line —
anonymous,vendemmia-limited. The brand here is deliberately anonymous — the same reason the campaign's reference images were digitally de-branded — not a missing value. -
story_id, beat, narrative_role —
vendemmia,05,development— the fifth of six beats, not the opening and not the closing toast. -
cast —
[grappolotto, foglina]. -
format, reserved_zone —
1:1,bottom band clear for price and CTA. -
variant_labels —
[slogan:filari-tino, time:evening]. -
qa_status, review_status —
passed_after_correction,pending.
This single row is also what the two reports above are built out of: it counts once towards coverage, for the (slogan, format) cell it fills, and once towards the batch account, in whichever of generated, passed-first-try, passed-after-correction or discarded it falls under.
-
asset_id —
-
07
run_campaign() — orchestrating N variants and tracking yield
No new API calls of its own — this is the orchestrator that calls the six functions above in sequence, once per variant requested, then once more over the whole run for formats and export, and tallies what comes out.
The agents in play. The orchestrator itself is plain Python, not a model. It coordinates four model roles and one human one: Claude (or whichever LLM is used) as the text agent that structures or drafts the description file (chapter 02); Gemini as the image agent that generates each beat (chapter 03); Gemini again as the VLM judge, D, and as the image editor, E, inside the loop (chapter 04); and a person who supplies the manual inputs, approves the story upstream and signs off the final
review_status.Human-in-the-loop, at three points. The supervisor isn't watching every shot go by — the human role sits at three specific moments instead. Before the loop starts, someone approves the master storyboard D will measure every shot against. While it runs, at intervals, the supervisor spot-checks a sample of D's calls — that's the calibration step, described in The Engine: generation, control, export, chapter 05. And at the end, before anything ships, the supervisor signs off
review_statusfor brand and compliance (chapter 06); that signature is never automated. D handles everything shot by shot in between, but it doesn't stand in for any of these three.Two kinds of variant. In this release a variant is a script variant (type A): the campaign receives one script per story, written by a person (
scripts), the LLM structures each into its own description file, and the loop runs once per file and per slogan. Sub-variants (type B) — episodes the LLM would propose on its own starting from a type A story, such as other animals arriving or a city cousin visiting — are a future development: they would add a proposal step and a story-consistency loop on the text (Claude as proposer and checker) beforegenerate_variant(). They are not in the code below. Rationale and examples: The Engine: generation, control, export, chapter 04.def run_campaign( campaign_subject: str, scripts: list[str | None], # one per type A variant; None = engine drafts it slogans: list[str], # supporting copy character_brief: str, background_briefs: list[str], formats: list[str], geometry_table: list[GeometryRow], campaign_meta: CampaignMeta, n_per_slogan: int = 1, ) -> CampaignResult: """ Orchestrates 01-06 for one brand: reference_set = generate_reference_pair(character_brief, background_briefs, product_brief=campaign_subject) # 01 story_specs = [generate_story_spec(campaign_subject, None, reference_set, pre_written_script=s) for s in scripts] # 02: one description file # per type A script variant approved = [] for story_spec in story_specs: for slogan in slogans: # supporting copy only for _ in range(n_per_slogan): shots = generate_variant(story_spec, reference_set, slogan) # 03 judged = [judge_and_correct(s, spec) for s, spec in zip(shots, story_spec)] # 04 tally(judged) # passed_first_try / passed_after_correction # / discarded approved += [j for j in judged if j.qa_status != "discarded"] formatted = [reformat_to_formats(s, formats, geometry_table) for s in approved] # 05 manifest = export_manifest(flatten(formatted), campaign_meta) # 06 Returns : CampaignResult -- every generated-and-QA-repaired story variant, its per-format shots, and the export manifest, plus: yield = (passed_first_try + passed_after_correction) / generated refusal_rate = discarded / generated """ -
08
Job in, result out — two ways to call the engine
Everything above is a Python function. What an upstream caller — an agency's campaign-operations tool, a brand's creative workflow — actually exchanges with the engine is one job and one result, defined as JSON and deliverable in two modes. API mode: the caller POSTs the job, gets a
job_idback immediately, then either polls the job or receives the finished result at a callback URL; images come back as URLs. File mode: the caller dropsjob.jsonand the reference images into a folder, and the engine writesresult.jsonand the generated images next to them. Same two documents in both modes; only the transport changes. In this release the contract is specified, not deployed as a service:run_campaign()above is what runs, and itsCampaignResultis what gets serialised.# job.json -- what the caller sends { "job_id": "corkscrew-2026-q4-001", "brand": { "name": "anonymous", "campaign_subject": "professional wooden waiter's corkscrew", "slogans": [ "With Foglina among the rows and Grappolotto down by the vine, the harvest gives us a wonderful wine!", "Between a cow who's had one booze too many, and a cheeky little vine, Grappolotto and his girl are raising a glass of wine!" ] }, "cast": { "mode": "supplied", "reference_images": ["grappolo_foglia_neutral.png"], "character_brief": null }, "plates": ["vineyard_dusk.png", "studio_neutral.png"], "story": { "mode": "draft_from_brief", "scripts": [], "approved_by": null }, "n_per_slogan": 2, "callback_url": null }cast.modeissuppliedwhen the brand provides the reference images,generatewhen the engine should draft the pair fromcharacter_brief;story.modeissuppliedwhen the brand's creative team hands over the story script (instory.scripts, one script per type A variant; the engine's LLM structures it into the description file),draft_from_briefwhen the engine drafts the script itself. In both cases the run is released only onceapproved_bycarries a name — the engine never starts from a story nobody upstream has signed off.# result.json -- what comes back { "job_id": "corkscrew-2026-q4-001", "status": "completed", "models": { "text": "(Claude model id)", "image": "(Gemini model id)" }, "stats": { "generated": 12, "passed_first_try": 7, "passed_after_correction": 3, "discarded": 2, "yield": 0.83, "refusal_rate": 0.17 }, "variants": [ { "variant_id": "v001", "slogan": "With Foglina among the rows and Grappolotto down by the vine, the harvest gives us a wonderful wine!", "shots": [ { "shot_id": "v001-01", "role": "opening", "image_url": "https://(host)/v001-01.png", "qa_status": "passed_after_correction", "attempts": 2 } ] } ], "discarded": [ { "shot_id": "v003-04", "last_defect": "identity_drift", "attempts": 4 } ] }The result is shaped so that every shot flattens into one row of the feed dynamic-creative tools read — a unique ID (
shot_id), an image URL, the slogan text — which is the hand-off described in DCO and the Agency Market.statscarries the same yield and refusal rate asCampaignResult, andmodelsrecords which model versions produced the run, for the reproducibility problem in C below. -
09
Building this: a prototype estimate
A worked example of translating the architecture above into a working prototype — one developer, AI-assisted (an agentic coding tool such as Claude Code), reusing patterns already built for adjacent pipelines rather than starting from zero. The breakdown targets a file-mode prototype: the job/result contract above is honoured as-is, with a thin interface on top of it — a tool such as Gradio or Streamlit wrapping
run_campaign()directly — so a client without filesystem access to this machine can still choose inputs and parameters. That is short of the full API mode, which would add job queuing and callback delivery. It also excludes the five open points below; each one, if folded into the prototype instead of left for later, adds separately.Function What it covers Days Setup API wrappers for Claude and Gemini, config, logging 0.75 generate_reference_pair()character pair and background plates, one Gemini call per plate plus the neutral identity shot 0.5 generate_story_spec()one Claude call, structured beat list from the brief, schema validation 0.75 generate_variant()one Gemini call per beat, restaged for the slogan — unverified at this stage 0.5 judge_and_correct()— DGemini VLM judge, assertion-by-assertion check, defect severity classification 1.25 classify_defect()+choose_tactic()the four defect categories, the four tactics in cost/risk order, historyso a failed tactic isn't retried1.0 judge_and_correct()— Etargeted edit, full regeneration with a mutated prompt, best-of-N 1.0 Loop control retry budget cap, wiring A→D→E→D together 0.5 reformat_to_formats()reactive cropping only, from the cast bounding boxes the judge already produced — proactive reserved-zone staging stays out of scope 1.0 export_manifest()flattening into manifest rows, coverage report, batch account 0.75 run_campaign()orchestrator, one pass over the six functions above per variant, final tally 0.75 Job/result contract + I/O file mode plus a thin layer (Gradio/Streamlit) on the same contract, for a client choosing inputs without filesystem access 1.5 End-to-end testing against the real running example, tuning the judge — the line item that usually runs over 1.5 Demo polish — 0.5 Total ~12.75 The Job/result contract + I/O line is worth unpacking. In practice, the fastest way to give a client that interface without building the full API mode of chapter 08 is a thin layer on top of the same file-mode contract: a tool such as Gradio or Streamlit wrapping
run_campaign()directly, generating the form (brief, slogans, formats, reference-image upload) and a results gallery without a line of hand-written HTML or JS —job.jsonandresult.jsonstay exactly as defined above, only who writes and reads them changes. Gradio in particular ships a temporary shareable link out of the box, useful for an early demo before a domain is even in place. Once a domain exists, putting the same app behind a reverse proxy on a subdomain is a hosting detail, not a rewrite — and for a prototype a modest VPS is enough, since the API calls carry the real load, not the web layer.Each of the five open points below, if folded into the prototype instead of left for later, adds roughly two to four more days on top of the total above.
-
10
Closed vs. Open Models
To be filled inPlaceholder section.
-
11
Platform structure: what has to sit around the engine
Everything above (01–08) is the engine: a set of functions behind one job/result contract. None of it runs as a service by itself. A summary, forfait-level sketch of what has to sit around it — not designed yet, just named, at the level DCO: cost model, chapter 12, needs to put a flat monthly figure on it.
- Job intake. What actually receives the
job.jsonof chapter 08 above, from outside a script run by hand: a thin API layer (or the file-mode folder-watcher described there) and a queue, so several agencies' jobs don't block each other. This is also where the still-undesigned agency-facing API or dashboard — the thing an agency would log into to submit a job and look at its result — would sit. It isn't designed here either; it's just named as the missing piece. - A database, one row per brand. Holds what chapter 02 of DCO: One Pipeline, Many Products calls the configuration file, plus the reference pair, the calibration set and its measured agreement/false-pass rate, and the history of
result.jsonand manifests this brand has produced. A tenant key sits above the brand key already in the manifest, one row per agency, so one tenant's brands and runs stay invisible to another's — the isolation chapter 03 of the same post asks for. - A data-entry and review surface. Where a brand's own team types or edits its configuration file and uploads its reference images, and where the three human checkpoints of The Engine: generation, control, export — approve the story spec, spot-check what D flagged, sign off the manifest — actually happen and get written back to the database above. Today this is a person editing a JSON file and looking at images in a folder; a real surface is a form and a review queue over the same data.
- Plumbing. Object storage for generated images (the
image_urlvalues of chapter 08'sresult.json), plus logging and monitoring. Ordinary SaaS infrastructure, not anything specific to this pipeline, which is exactly why it can be forfaited rather than engineered here.
None of these four pieces changes what the five functions above compute; they decide who gets to call them, whose data they read and write, and what it costs to keep the lights on. DCO: cost model, chapter 12, puts a single flat number on all four together — a forfait, not a bill of materials — and keeps the agency-facing API/dashboard above out of that number, as a separate, one-off build.
- Job intake. What actually receives the
-
12
Beyond the prototype: two study objects for a market-grade engine
A look at what comparable products describe on their own pages (October 2026; none of it verified in use) produced two study objects, for opposite reasons. The first is a list of features the market has and this engine does not: a question of effort. The second is something this engine has and the products found do not describe, a way to measure whether its own loop can be trusted: a question of evidence. Day figures below are estimates in the unit of chapter 09 (one developer, AI-assisted), not measurements.
Prior art for the judge. Using a model as the judge is a recognised research direction, not a workaround built for this pipeline. The reference study is Zheng et al., NeurIPS 2023, where a GPT-4 judge agreed with human raters over 80% of the time and the judge's own biases are catalogued. For ad imagery specifically, a recent paper proposes a pipeline that takes customer data as input, generates a personalised advertising image conditioned on a product reference image, evaluates the generated outputs, and serves a final image — a multi-agent generate-then-judge loop, architecturally close to the A→D→E→D loop described in The Engine: generation, control, export. It is academic rather than a market to sell into, but it confirms that the direction is a recognised one, not an improvised one.
A1 — Rule-based brand checks (colour, typography, logo, tone). Jivox's Creative Compliance Checker, Gradial's Brand Manager and Cassidy's creative governance agent check creative against explicit rules — colour values, typography, logo usage, tone of voice, channel specs. As described on their pages, they flag deviations; none describes correcting them. D here judges identity, staging and plausibility but has no explicit rule layer. Adding one is mostly deterministic, and it extends the per-brand configuration file rather than the loop:
Check How it would work here Days (est.) Colour The palette of the configuration file becomes hex values; dominant colours are sampled from the shot and compared by colour distance (ΔE) against a per-brand threshold. No model call. 1.5 Logo / pack The productblock gets a reference image of the pack; D gets one more assertion ("pack matches reference") plus a detection pass for logo placement. The corkscrew case of The Engine: generation, control, export, chapter 05, is already this kind of check.2.5 Typography Only relevant if the engine renders text. Today copy is added downstream in the reserved zone, so this stays out of scope; if the engine rendered text: font allow-list plus an OCR check. 0 (2.5) Tone of voice One Claude call comparing slogan and copy against the brand's register, with positive and negative examples per rule, the format Gradial uses.1.0 Rule store A ruleslist in the configuration file (name, description, good and bad example), editable by the brand's team on the surface of chapter 11.1.5 Total ~6.5 (9 with text) The difference that matters is what happens after a violation. Here a rule failure can feed the same
classify_defect()andchoose_tactic()as any other defect: a colour drift is a regeneration with a mutated prompt, a wrong logo a targeted edit. The products found stop at "check"; the loop already has the "correct".A2 — Per-brand fine-tuning instead of a reference pair plus judge. Bria's branded-character product, as it describes itself, trains a character engine on 5–50 images of a brand's mascot (about an hour of training) and then generates from text prompts, through an API or plug-ins; its page describes no automated checking of outputs. This release has no training stage by design (chapter 01): identity comes from the reference pair and D enforces it afterwards. The two routes are complementary rather than alternatives. A tuned model would raise first-pass yield and shrink the work of the loop; the judge would still be needed, because tuning makes identity more likely, not verified.
Piece What it takes Days (est.) Training pipeline Choose the route (a provider's tuning API, or LoRA on an open-weights model); prepare the 5–50 image dataset from the reference pair — for a generated mascot those images must themselves be generated and curated first; train, evaluate and version per brand. 5–8 Serving and lifecycle Inference hosting for each brand's model, versioning, rollback, retraining when the brand changes. Ties to chapter 10, Closed vs. Open Models, still a placeholder. 4–7 Loop integration Swap the generate_variant()backend per brand; the judge and the rest of the loop stay as they are.1–2 Total ~10–17 The engineering days are the smaller part. Fine-tuning adds a cost that does not exist today — training compute and a round of dataset curation for every new brand — and a dependency on a model that can be tuned, which changes the cost structure of DCO: cost model, built on API list prices only. There is also a risk specific to synthetic mascots: tuning on a model's own generated images can lock its artefacts in. Effort alone should not decide this; B4 below proposes how to decide it with a measurement.
B — Measuring the loop. Everything above adds features. The complementary question is about what the prototype already has: how would a customer, or we, know that generate → judge with error classes → correct → calibrate on a labelled set actually works, and how efficiently? The products found describe what they check; none describes how well the checker itself performs. That is open question D below, and it is also the best candidate for the engine's differentiating claim. Two groups of metrics, kept separate because they answer different questions.
B1 — How good is the judge. Measured against the human-labelled calibration set described in chapter 01:
Metric Definition How to read it Agreement Share of shots where D's verdict equals the human label, reported overall, per assertion and per error class (conformity, semantic). One number hides which class D is blind to; the per-class split is the useful one. False-pass rate Defective shots (by human label) that D passed ÷ all defective shots. The dangerous one: defects reaching the client. False-reject rate Good shots D rejected ÷ all good shots. Costs money (regeneration), not reputation. Cohen's κ Agreement corrected for the agreement a coin-flip judge would reach by chance. Needed when most shots are good: a judge that always says "pass" scores a high raw agreement. Confusion matrix per class How D labels each defect class against the human label. A defect found but put in the wrong class gets the wrong tactic from choose_tactic().The denominators matter. The false-pass rate is computed over defective shots only, so a set of a few dozen mostly good shots may hold too few defects to measure anything: one miss among twelve defective shots is an estimate of 8%, but its 95% confidence interval runs from about 1% to 35%. The interval narrows slowly with more shots, which is why the set should be seeded on purpose with known defects (roughly half and half) and kept as a regression suite, re-run whenever the judge model or its prompt changes — the fixture library of open question E.
B2 — How efficient is the loop. Most of it is already in the
statsandattemptsfields of theresult.jsonof chapter 08, so nothing new has to be built to read it:Metric Definition Source First-pass yield passed_first_try÷generatedresult.jsonstatsYield after correction ( passed_first_try+passed_after_correction) ÷generated, i.e.yieldresult.jsonstatsCorrection lift passed_after_correction÷ (generated−passed_first_try): the share of failures the correct step rescuesresult.jsonstatsAttempts per approved shot Generation and edit calls ÷ approved shots attemptsper shotCost per approved shot Total API spend ÷ approved shots. Replaces the flat 30% waste multiplier of the cost model with a measured figure. API billing + approved count Human minutes per approved shot Time at the three checkpoints ÷ approved shots Review log in the database (chapter 11) Tactic success rate Per defect category and tactic: share of corrections that pass the recheck. Says which tactic is worth its cost. historyinjudge_and_correct()With the numbers of the
result.jsonexample in chapter 08 (12 generated, 7 passed first try, 3 passed after correction, 2 discarded): first-pass yield 7 ÷ 12 = 0.58, yield 10 ÷ 12 = 0.83, correction lift 3 ÷ (12 − 7) = 0.6 — three of the five failures were rescued.B3 — What reaches the client. The escape rate — the share of approved shots that a blind human audit finds defective — is the number the client actually cares about, and the only one not graded by D itself: the independent ground truth open question D asks for. The audit is a random draw from the approved shots of each campaign, reviewed without seeing D's verdict (the spot-check tooling of the DCO: One Pipeline, Many Products licensing discussion). A cheap complement, as an idea rather than something measured here: a second judge from a different model, with disagreements sent to the human queue.
B4 — A study to run before engineering anything. One brand (the farm pair), about thirty briefs, a labelled set of roughly sixty shots, half of them defective on purpose. Three arms: the engine as it is; the engine with the A1 rule checks; the engine with a tuned model (A2), if feasible. Compare false-pass rate with its interval, first-pass yield, correction lift, cost per approved shot and escape rate from the blind audit. Decision rule: adopt A1 if it lowers false-pass or escape rate for the days it costs; adopt A2 only if the fall in cost per approved shot outweighs training and serving over the campaigns a brand is expected to run. The measurement harness — labelling tool, metrics script, regression runner — is roughly 4–6 days (est.), plus the 4–5 hours of human labelling per brand already counted as onboarding in DCO: cost model, chapter 12.
The order follows the cost and the payoff. B first: about a week, the cheapest, and it produces the evidence that lets the service be priced and trusted. Then A1, deterministic and about a week. A2 last, and only if the study says so.
-
13
What "delivered into the DCO" actually means
There is no single "DCO/DSP" hand-off format — and conflating the two is exactly the ambiguity this pipeline's own name invites. A DSP buys media and serves whatever creative it's handed; it doesn't read feeds or apply template rules. That work happens one layer down, inside the DCO tool itself — which is why "delivered into the DCO" above is the precise claim, and "delivered into the DSP" would be the wrong one.
Concretely, that layer is Studio on Google's stack, reading a feed as a Google Sheet or CSV/XML with a fixed column set, or Flashtalking or Celtra on an independent DSP such as The Trade Desk, each reading its own internally-held feed — two different schemas, not a common standard at the feed level. What the pipeline has to produce, then, is not a DSP-ready file but a row that fits whichever of those schemas the client already runs, column by column: how that export and its adapters are built is described in The Engine: generation, control, export, chapter 06. -
14
A remaining technical gap: keeping the CTA box off the characters
The second specialty, keeping the price/CTA box off the characters, is partly solved elsewhere and open here. Current tools place text with rule-based layouts, saliency- or face-based smart crop and static safe-zone guides; Pencil's own help says drastic aspect-ratio jumps such as 1:1 to 9:16 often need manual repositioning, and that its beta does not handle safe zones. Research is active (TextCenGen, 2024; a December 2025 paper from Institute of Science Tokyo and HAKUHODO Technologies), but the best published method still shows residual occlusion, and no product turned up that is sold on it. The engine starts from something saliency tools lack: its description file already says where the characters stand, so the reserved rectangle can be composed before the image exists instead of repaired after (The Engine: generation, control, export, chapter 07). Three limits: nobody publishes how often overlays hit key content, a large vendor could add a basic geometric check cheaply, and the method has not yet met a real client's template geometry. The defensible part is the loop together with the known cast, not the overlap check alone.
Five things this architecture doesn't solve yet
The pipeline's one real invariant is "fixed cast, variable story" — same two mascots, changing slogan and background. That holds cleanly when the product barely varies in how it's staged, the way a cake is always framed roughly the same way: table, close-up, hands. It breaks down once products span categories, because each category needs its own shot grammar, not just a different prop value — a jam jar wants a shelf/spread/breakfast-table frame, a beverage wants a pour/hold frame, a baked good wants a slice/crumb-detail frame. That isn't a parameter to sweep, it's a different vocabulary of shots per category.
Structural implication: generate_story_spec() can't treat "product" as a slot the way it treats "slogan." It needs a small library of per-product-category staging templates that spec generation selects from and fills in, rather than one generic BeatSpec schema trying to cover every product. A content/spec-design problem, not a code problem.
generate_reference_pair() looks like a function you call once per campaign family and forget. A new mascot pair and its backgrounds aren't a parameter sweep either — it's a small creative-direction task with its own quality bar: the pair has to read as a coherent, on-brand duo. Gemini being able to generate candidates for it doesn't make the judgment-and-selection work disappear, it just moves where the effort sits.
Structural implication: cost and timeline estimates for "per campaign" need to separate what's genuinely reusable and near-free once built — generate_story_spec(), generate_variant(), judge_and_correct(), which scale across slogans and products cheaply — from what resets every time the mascots or the brand change: generate_reference_pair() itself.
Partly priced since this was written. Chapter 12 of DCO: cost model puts a forfait on per-brand onboarding, and now includes a line for this exact gap: a couple of hours of creative-direction time choosing and tuning candidates from generate_reference_pair(), on top of writing the configuration file and labelling the calibration set. Still not priced there: the recurring version of this cost when a brand's own story moves it into a different world mid-campaign, which is closer to a second onboarding than a tweak.
Run the same description file against the same references twice and you get two different sets of shots — the Chapter 04 stills in the parent post show exactly that, with the same slogans and plates producing different compositions. This release pins nothing: no seed control on the image calls, no frozen model version. A variant approved today therefore can't be regenerated identically in three months, when the image model behind the API may have been updated or retired, and a client asking for “the same, but with a new slogan” gets a relative of the original, not a copy.
Structural implication: approved outputs have to be stored as artefacts together with their spec, the model identifiers and the full attempt history that judge_and_correct() already records, and treated as the source of truth; re-running the pipeline is a way to make new variants, never to restore old ones.
Partly sketched since this was written. Chapter 11 above names the artefact store this needs — a database holding result.json and manifest history per brand, keyed by tenant — but only as a summary, not designed. Seed control and model-version pinning on the image calls stay fully open; nothing above proposes either.
judge_and_correct() decides what counts as defective, and run_campaign() then computes yield and refusal rate from those same decisions. There is no independent ground truth in the loop: if D is systematically blind to a class of defect — and the parent post already shows it is weaker on fine spatial alignment than on gross identity drift — the yield figure simply looks better than the output really is.
Structural implication: the numbers only mean something once a small human-labelled sample (a few dozen shots, judged by someone who knows the brand) has been run through D to estimate how often it misses a defect and how often it rejects a good shot — and that check has to be repeated whenever the judge model or its prompt changes. Calibration work with a recurring cost, not a one-off.
A method now exists, no measurement yet. Chapter 12 above (B1–B4) defines exactly this: agreement, false-pass rate with its confidence interval, false-reject rate, κ, a confusion matrix per defect class, and an escape-rate audit independent of D. That turns this from “no way to check” into “a way to check that hasn't been run” — still open until the study in B4 actually happens on real labelled data.
Every open question above (A–D) is currently caught, if at all, by running the real pipeline against a real campaign: there is no sandboxed test harness with synthetic cast, story and format inputs and known expected outputs to validate a change — a new product-category template (A), a new generate_reference_pair() call (B), a judge-model or prompt update (D), or a change to reformat_to_formats() or export_manifest() (Chapters 05–06) — before it reaches a client's budget. Right now, every iteration on the pipeline itself is billed to whichever campaign happens to be running.
A sketch, not a build — what below describes does not exist yet. Below is one concrete design for the fixture library the structural implication asked for, close enough to build from, kept inside this open question because sketching it is not the same as having it.
- Seeded fixtures, not just normal ones. A handful (6–10) of synthetic cast/story/format combinations, built once from the running example. Half generated to pass cleanly; half seeded with a known, specific defect — a reference image deliberately swapped for the wrong product (the corkscrew case of The Engine: generation, control, export, chapter 05, is already this kind of seed), a palette nudged off-brand, a beat description that asks for an impossible pose. A suite of only-good fixtures never exercises the reject-and-correct path; a suite of only-bad ones never checks that the loop leaves good shots alone.
- Golden traces, not golden pixels. Open question C already rules out pixel-identical reruns, so the stored baseline per fixture is not an image but the full trace: D's verdict per assertion, the defect class, which tactic E chose, how many attempts it took, and the final
qa_status. A rerun is compared verdict-by-verdict against that trace — does the seeded defect still get caught, still get classified the same way, still get fixed in a similar number of attempts — not pixel-by-pixel against the old image. - A regression runner. A script that runs
run_campaign()(or the single function under test) against each fixture and diffs the new trace against the golden one, flagging only real divergences: a seeded defect that stopped being caught, a defect that moved error class, an attempt count that jumped. Triggered on a judge-prompt or model-version change (D), a new product-category template (A), or a change toreformat_to_formats()/export_manifest()— and on a schedule, since C already notes the image model behind the API can move without anyone here changing anything. - Not the same thing as the calibration set. The per-brand calibration set of chapter 01 answers “can this brand trust D”, with real brand content and no seeded defects by design. This fixture library answers “did the last code or prompt change break something”, with synthetic, brand-agnostic content built to contain defects on purpose. One brand's calibration set could double as the seed material for the second, the running example already does here — but they are built for different questions and neither substitutes for the other.
What it would cost, and what it shares. Building the fixture set, the trace format and the regression runner is largely the same work as the measurement harness of chapter 12, B4 — roughly 4–6 days (est.), not an additional line item on top of it. Running the suite still spends real API calls (generation plus judge, per chapter 06 of DCO: cost model), but against 6–10 small fixtures instead of a client campaign: a few dollars per run, billed to engineering, not to a brand.
This is a technical deep-dive of The Engine: generation, control, export.