@koda-sl/baker-cli 0.422.0-dev.f9f855bf1 → 0.422.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/README.md +120 -40
  2. package/dist/chunk-2S6QIXLX.js +24533 -0
  3. package/dist/chunk-2S6QIXLX.js.map +1 -0
  4. package/dist/{chunk-VF77MVEM.js → chunk-3X2EMLBQ.js} +3 -3
  5. package/dist/{chunk-D76DSHL2.js → chunk-5JVAP5DC.js} +28 -1
  6. package/dist/{chunk-D76DSHL2.js.map → chunk-5JVAP5DC.js.map} +1 -1
  7. package/dist/{chunk-4RN4CBIQ.js → chunk-G5L5FX2B.js} +3 -3
  8. package/dist/{chunk-HFH5HQTP.js → chunk-S36N4SOX.js} +43 -4
  9. package/dist/chunk-S36N4SOX.js.map +1 -0
  10. package/dist/{chunk-TTBMFAGF.js → chunk-XNFV6BQU.js} +4 -4
  11. package/dist/cli.js +7497 -14138
  12. package/dist/cli.js.map +1 -1
  13. package/dist/{client-OA2QMHOI.js → client-PIKJZFRA.js} +4 -4
  14. package/dist/engine/index.d.ts +8 -0
  15. package/dist/engine/index.js +3 -3
  16. package/dist/{env-Q5FUIVIJ.js → env-6UAIAY4M.js} +6 -2
  17. package/dist/{output-NMXF2VIL.js → output-CFDBWWYJ.js} +5 -5
  18. package/dist/{shared-PD226SU6.js → shared-3O5RKQYZ.js} +6 -6
  19. package/package.json +4 -1
  20. package/dist/chunk-FQ7WNNOG.js +0 -11637
  21. package/dist/chunk-FQ7WNNOG.js.map +0 -1
  22. package/dist/chunk-HFH5HQTP.js.map +0 -1
  23. /package/dist/{chunk-VF77MVEM.js.map → chunk-3X2EMLBQ.js.map} +0 -0
  24. /package/dist/{chunk-4RN4CBIQ.js.map → chunk-G5L5FX2B.js.map} +0 -0
  25. /package/dist/{chunk-TTBMFAGF.js.map → chunk-XNFV6BQU.js.map} +0 -0
  26. /package/dist/{client-OA2QMHOI.js.map → client-PIKJZFRA.js.map} +0 -0
  27. /package/dist/{env-Q5FUIVIJ.js.map → env-6UAIAY4M.js.map} +0 -0
  28. /package/dist/{output-NMXF2VIL.js.map → output-CFDBWWYJ.js.map} +0 -0
  29. /package/dist/{shared-PD226SU6.js.map → shared-3O5RKQYZ.js.map} +0 -0
package/README.md CHANGED
@@ -950,6 +950,10 @@ baker ads meta ads --account-id act_123 --all-statuses # widen to
950
950
  # "Find the October ad" — Meta matches the name, so only that row is read
951
951
  baker ads meta ads --account-id act_123 --all-statuses --name-contains octubre --fields id,name,adset_id,creative_id
952
952
 
953
+ # "Who will see an ad in this campaign?" — a viewer per ad set: cold/warm/hot, what they
954
+ # already know (brand/offer/product), placements, and why — from targeting, audiences, objective
955
+ baker ads meta adsets --campaign-id 6123 --all-statuses --audience --output json
956
+
953
957
  # "Tell me about ad 9988"
954
958
  baker ads meta creatives --creative-id 9988
955
959
  baker ads meta preview --creative-id 9988 --ad-format MOBILE_FEED_STANDARD --out-file /tmp/p.html
@@ -3173,7 +3177,7 @@ baker testimonials tags
3173
3177
 
3174
3178
  ### Avatars (`baker avatars`)
3175
3179
 
3176
- Reusable AI presenters a company casts into images and video. An avatar holds one settled identity: an **identity sheet** (`sheetUrl`, a generated multi-view turnaround) plus the **subject description** (`subjectDescription`) that person is described with. Every render grounds on the sheet via `--reference` and reuses the subject description verbatim — that pairing is what keeps the same face across a whole campaign.
3180
+ Reusable AI presenters a company casts into images and video — for an actor who has to come back across ads; a person in a single ad needs no avatar. An avatar holds one settled identity: an **identity sheet** (`sheetUrl`, four candid phone photos of the same person in their own setting) plus the **subject description** (`subjectDescription`) that person is described with. Every render grounds on the sheet via `--reference` and reuses the subject description verbatim — that pairing is what keeps the same face across a whole campaign.
3177
3181
 
3178
3182
  `status` is `generating | ready | error | archived`; only `ready` can be cast. `create` returns immediately with `generating` because the identity sheet renders in the background (~30s). `likeness` is `synthetic` (invented from a description) or `licensed` (built from photographs of a real person, which requires `--likeness-confirmed`).
3179
3183
 
@@ -3287,7 +3291,7 @@ Search the **ad-dna** corpus of scored "winning" competitor ads for reference cr
3287
3291
 
3288
3292
  ### `baker winning-ads search <query>`
3289
3293
 
3290
- Semantic search (dense recall + BM25 + rerank) → `POST /api/ad-library/winners/search`. The CLI projects each result to a **lean, decision-focused** shape so the agent's context stays small — default fields: `advertiser`, `advertiser_id`, `platform`, `format`, `relevance`, `winner_score`, `summary` (what the ad is about), `media_url`, and `visual` (what the viewer sees) + `container` (a static's layout pattern) + `mechanism` (why it works) when known; plus top-level `pool_size`, `match_confidence` (`high|medium|low`), and `below_floor_count` (matches dropped under the relevance floor). The proof signals ride along (`winner_category`, `days_active`, `active`); `--full` adds DNA detail (`angle`, `target_persona`, `hook_archetype`, `awareness_stage`, `industry`) + `reach` and `media_kind`. By default the search draws from the brands the company follows first (`--scope followed-first`) and tops up from the shared library; every result says `source: followed|library`. `--output json` (default) returns the lean objects; `--output md` prints a table.
3294
+ Semantic search (dense recall + BM25 + rerank) → `POST /api/ad-library/winners/search`. The CLI projects each result to a **lean, decision-focused** shape so the agent's context stays small — default fields: `advertiser`, `advertiser_id`, `platform`, `format`, `relevance`, `winner_score`, `summary` (what the ad is about), `media_url`, and `visual` (what the viewer sees) + `container` (a static's layout pattern) + `mechanism` (why it works) when known; plus top-level `pool_size`, `match_confidence` (`high|medium|low`), and `below_floor_count` (matches dropped under the relevance floor). The proof signals ride along (`winner_category`, `days_active`, `active`), and so does who the ad was made for when the library knows it: `audience_temperature` (`0_cold|1_warm_engaged|2_hot_visited|3_customer`), `funnel_stage`, `advertiser_size`, and `presumes_brand_knowledge` (whether the copy only works on viewers who already know the brand, with `brand_knowledge_reason` when `true`; absent while the ad is not judged yet); `--full` adds DNA detail (`angle`, `target_persona`, `hook_archetype`, `awareness_stage`, `industry`) + `reach` and `media_kind`. By default the search draws from the brands the company follows first (`--scope followed-first`) and tops up from the shared library; every result says `source: followed|library`. `--output json` (default) returns the lean objects; `--output md` prints a table.
3291
3295
 
3292
3296
  > `media_url` is the creative itself: for `static` it's the image, for `video` it's the video file. ad-dna stores **no separate poster** for videos, so a video result has only the video URL.
3293
3297
 
@@ -3304,6 +3308,8 @@ baker winning-ads search --ref-ad-id a_12345 --first-seen-after 2026-01-01T00:00
3304
3308
  baker winning-ads search "meal kit for busy parents" --scope followed-first --diverse --limit 5 --output md
3305
3309
  # The best proven ads in one container, from any category (no query needed):
3306
3310
  baker winning-ads search --format-pattern comparison_table --platform meta --output md
3311
+ # References made for strangers that don't lean on their brand being known:
3312
+ baker winning-ads search "delivery driver recruitment" --audience-temperature 0_cold --exclude-brand-reliant --output md
3307
3313
  ```
3308
3314
 
3309
3315
  | Flag | Purpose |
@@ -3319,6 +3325,10 @@ baker winning-ads search --format-pattern comparison_table --platform meta --out
3319
3325
  | `--format <list>` | `video,static,carousel` |
3320
3326
  | `--winner-category <list>` | `winner,scaled_winner,evergreen,rising,untested,dud,…` (default: all) |
3321
3327
  | `--awareness <list>` | `unaware,problem_aware,solution_aware,product_aware,most_aware` |
3328
+ | `--audience-temperature <list>` | Who the ad was made for: `0_cold,1_warm_engaged,2_hot_visited,3_customer` |
3329
+ | `--funnel-stage <list>` | `TOF,MOF,BOF` |
3330
+ | `--advertiser-size <list>` | `solo,smb,mid,enterprise` |
3331
+ | `--exclude-brand-reliant` | Drop ads whose copy only works if the viewer already knows the brand (ads not judged yet are kept) |
3322
3332
  | `--advertiser-id <list>` | **Restrict to** these advertiser ids (browse one brand's winners) |
3323
3333
  | `--exclude-advertiser <list>` | **Drop** these advertiser ids, usually your own brand |
3324
3334
  | `--country <list>` / `--language <list>` | Filter by country / language codes |
@@ -3329,7 +3339,7 @@ baker winning-ads search --format-pattern comparison_table --platform meta --out
3329
3339
  | `--output json\|md\|files` | Output format (default json) |
3330
3340
  | `--full` | Include DNA detail + longevity |
3331
3341
 
3332
- A value outside a closed list (`--platform`, `--format`, `--winner-category`, `--awareness`, `--format-pattern`), a non-numeric `--limit`/`--max-per-advertiser`/`--min-relevance`, or a bound that is not a date is refused before the search runs; a refused list value comes with `error.fix.validValues`. References this company already adapted (`already_adapted`) never take a fresh reference's place: `--limit N` returns up to N fresh ones first. The search waits up to 120 s (`followed-first` can run two library searches).
3342
+ A value outside a closed list (`--platform`, `--format`, `--winner-category`, `--awareness`, `--audience-temperature`, `--funnel-stage`, `--advertiser-size`, `--format-pattern`), a non-numeric `--limit`/`--max-per-advertiser`/`--min-relevance`, or a bound that is not a date is refused before the search runs; a refused list value comes with `error.fix.validValues`. References this company already adapted (`already_adapted`) never take a fresh reference's place: `--limit N` returns up to N fresh ones first. The search waits up to 120 s (`followed-first` can run two library searches).
3333
3343
 
3334
3344
  Reading the scores: **`relevance`** (0–1) = match of the creative to your query; **`winner_score`** = how proven the ad is in-market. Pick references that are both relevant *and* proven.
3335
3345
 
@@ -3792,6 +3802,23 @@ baker history list --category ads --full # ad writes with raw metadata
3792
3802
 
3793
3803
  ---
3794
3804
 
3805
+ ### `baker credentials list | put | delete`
3806
+
3807
+ Credentials are a company's stored API keys, tokens and passwords. The CLI never returns a value: each credential has a placeholder, `{{credential:NAME}}`, that a request inside a Baker Session can carry in any header, URL or body. The Session's HTTPS proxy fills in the value only for the hosts the credential is bound to, and puts the placeholder back in responses.
3808
+
3809
+ ```bash
3810
+ baker credentials list # name, placeholder, allowedHosts, label
3811
+ baker credentials list --full # + source, lastUsedAt
3812
+ printf %s "$KEY" | baker credentials put stripe-key --host api.stripe.com --label "Stripe key"
3813
+ baker credentials delete stripe-key
3814
+ ```
3815
+
3816
+ - `put` reads the value from **stdin only** — a `--value` flag or a positional value is refused, because argv is visible to other processes. One trailing newline is trimmed; empty stdin is refused. `--host` is repeatable (or comma-separated) and accepts `*.example.com` wildcards.
3817
+ - `put` and `delete` act only on credentials the agent created. Credentials a person added are managed in the dashboard (Settings → Credentials).
3818
+ - The company must have credentials switched on; otherwise every command answers `FORBIDDEN`.
3819
+
3820
+ ---
3821
+
3795
3822
  ### `baker hubspot forms list | view | submissions` · `baker hubspot workflows list | view` · `baker hubspot pipelines list` · `baker hubspot contacts summary | lookup | properties | fields` · `baker hubspot deals properties | fields` · `baker hubspot meetings list | view`
3796
3823
 
3797
3824
  Read-only view of the company's **connected HubSpot account** — the forms and meeting links (calendars) that live there, the leads those forms received, and the workflows, deal pipelines, and contact records a lead runs through afterwards. Fetched from HubSpot live on every call; nothing is cached and nothing is ever written back.
@@ -4084,9 +4111,9 @@ baker canvas validate my-canvas.json
4084
4111
  # legal config to review before billing — a frame that describes a recurring element
4085
4112
  # (a pet/product) but doesn't wire its reference (VIDEO_REFERENCE_MISSING), or a scene
4086
4113
  # span that exceeds the assigned model's max clip duration (VIDEO_SPAN_EXCEEDS_MODEL),
4087
- # or the same ad rendered once per aspect ratio instead of one hero + a terminal
4088
- # image_aspect_adapt (IMAGE_PARALLEL_FORMAT_RENDERS — separate renders drift in type,
4089
- # crop, and color, so the sizes stop matching).
4114
+ # or the same ad rendered once per aspect ratio instead of one hero + cuts or a
4115
+ # terminal image_aspect_adapt (IMAGE_PARALLEL_FORMAT_RENDERS — separate renders drift
4116
+ # in type, crop, and color, so the sizes stop matching).
4090
4117
  # A generator prompt is measured with its {{slots}} filled from text nodes and local
4091
4118
  # prompt.json/text ingests: past the model's character cap it is refused as
4092
4119
  # PROMPT_OVER_MODEL_LIMIT, naming the node and the overshoot, before anything bills.
@@ -4098,6 +4125,8 @@ baker canvas run my-canvas.json
4098
4125
  # Advisory warnings print as `[warn] <CODE>: …` before anything bills.
4099
4126
  # Only nodes reachable from the declared output execute (orphaned nodes are skipped,
4100
4127
  # not billed). Add --keep-runs N to prune old r_* run dirs, keeping the N newest.
4128
+ # The JSON ends with `verdict` (static ads), `run_id` and `files` (each delivered size's
4129
+ # local file by its label), so `| tail` keeps them.
4101
4130
  # A failed run reports `error.code: "input_invalid"` when the backend refused a node's
4102
4131
  # own input (it fails the same way until the input changes), `"runtime"` otherwise.
4103
4132
 
@@ -4188,10 +4217,11 @@ because a video model garbles a wordmark every time:
4188
4217
  call to action when the spec has a `cta`; with neither a mark nor a `cta`, the last beat
4189
4218
  keeps its footage. A typed stand-in (an SVG wordmark in live `<text>`) is still refused,
4190
4219
  and `--no-brand` still means no brand layer at all: no mark and no closing card.
4191
- - **Captions and the mark sit inside the safe area.** On 9:16 the caption cards end above
4192
- the bottom 37% of the frame (Reels covers the bottom 35%) and the mark starts below the
4193
- top 16% (the Stories and Reels header); 4:5 and 1:1 use modest feed margins. The closing
4194
- card is centred in the same area. Every composition is sized to the spec's
4220
+ - **Captions and the mark sit inside the safe area** (`videoSafeArea` in
4221
+ `src/engine/scaffold/platform-safe-area.ts`, the one owner of those margins). On 9:16 the
4222
+ caption cards end above the Reels interface at the bottom and the mark starts below the
4223
+ Stories and Reels header; 4:5 and 1:1 use modest feed margins. The closing card is centred
4224
+ in the same area. Every composition is sized to the spec's
4195
4225
  `format.aspect_ratio`, so a 4:5 ad lays its captions out on a 4:5 frame.
4196
4226
  - **Captions and the closing button are set in the brand's face**: a font file in
4197
4227
  `src/brand/fonts/`, else the typeface `tokens.json` or `BRAND.md` names, loaded as a web
@@ -4209,7 +4239,7 @@ because a video model garbles a wordmark every time:
4209
4239
  every beat they appear in on that avatar's identity sheet and copies its subject
4210
4240
  description verbatim, so the ad shows the same face as the rest of the company's work.
4211
4241
  This is the whole journey: `baker avatars create` → `cast.avatar` in the spec → render.
4212
- - **`cast.description`** — a person described in words, when there is no avatar.
4242
+ - **`cast.description`** — a person described in words, when there is no avatar: a candid portrait is generated from it and every beat grounds on that one invented person.
4213
4243
 
4214
4244
  Each of these also removes a generation: the closing beat costs no image and no clip.
4215
4245
 
@@ -4464,6 +4494,14 @@ Open record keyed by the composition's `meta.json` inputs.
4464
4494
 
4465
4495
  ---
4466
4496
 
4497
+ ##### `static_prefilter`
4498
+
4499
+ Local; no credits (one box-reader call per variant, about $0.005, which rides on the run record's review spend). Ranks a static ad's variants before the judge by the deterministic checks (blocking defects, then every line of the ad spec's copy read back with its box, the brand's logo found, the button's words, every word and the logo inside the master's band) and passes on only the best-ranked, so the judge only breaks an exact tie. A variant the reader could not read ties with the best. **Inputs:** `images` (the variants' whole list), `spec` (the ad spec, `$ref:prompt.asset`). **Params:** `formats` (the delivered sizes). **Outputs:** `images` (the best-ranked variants, in an order seeded by the pictures rather than the render order; never empty: when every variant fails, the one with the fewest defects passes as `flagged`), `order` (which variant each of `images` is), `dropped` (each variant set aside, with its index and reasons), `flagged` (the variant kept when all failed) and `usage` (the reader's spend). Scaffolded by `scaffold-static-ad` between the variants and the judge.
4500
+
4501
+ ##### `static_safe_fit`
4502
+
4503
+ Local; no credits (one box-reader call, about $0.005, which rides on the run record's review spend). Brings a finished 9:16's words and logo inside the band Reels or Stories leave clear without rendering again: reads where every line and the logo sit and, when any reaches past the band, shrinks the whole picture a little (to 85% at most) and shifts it, filling the uncovered margin by mirroring the picture beside it, blurred under a feathered seam. The image model cannot move an element by a few points on request, so this replaces re-rendering for small misses. **Inputs:** `image`. **Params:** `format`, `placements` (the ad set's, as the sizes were planned). **Outputs:** `image` (same pixel size), `fit` (`{ scale, x, y }`, or null when passed on unchanged), `reason` (`not_read`, `inside_band`, or `too_far` when clearing the band needs more than the 85% shrink: the layout itself has to change) and `usage`. Scaffolded by `scaffold-static-ad` after every 9:16 an interface covers (`safe_9x16`, tagged `static_safe_fit`).
4504
+
4467
4505
  ##### `font_specimen`
4468
4506
 
4469
4507
  Render specimen text in a given font file to a **PNG** — black `#000` text on a white `#fff` background — via headless Chromium at 2× device-scale. Produces a **typeface reference image**: wire `image` into `image_generate`'s `reference` input so the model replicates the font when generating images that contain text.
@@ -4559,8 +4597,8 @@ None.
4559
4597
  | `model` | enum | yes | one of `~google/gemini-flash-latest`, `~google/gemini-pro-latest` |
4560
4598
  | `prompt` | string | yes | non-empty |
4561
4599
  | `system` | string | no | system prompt |
4562
- | `response_format` | enum | no | `text` (default) or `json_object`. Set `json_object` when a downstream `{{slot}}` consumes the output as JSON (e.g. the ad-blueprint transform) so the model returns clean JSON with no markdown fences or prose. |
4563
- | `web_search` | boolean | no | When `true`, routes through OpenRouter's `:online` web plugin so the model searches the live web before answering. Use it on the ad-blueprint transform so it adapts copy to the target brand's real facts (current pricing, the trust signals it actually has) instead of guessing. Adds web-search cost. |
4600
+ | `response_format` | enum | no | `text` (default) or `json_object`. Set `json_object` when a downstream `{{slot}}` consumes the output as JSON (e.g. a hand-built transform step) so the model returns clean JSON with no markdown fences or prose. |
4601
+ | `web_search` | boolean | no | When `true`, routes through OpenRouter's `:online` web plugin so the model searches the live web before answering. Use it on a text step that must adapt copy to the target brand's real facts (current pricing, the trust signals it actually has) instead of guessing. Adds web-search cost. |
4564
4602
  | `temperature` | number | no | 0–2 |
4565
4603
  | `max_tokens` | number | no | positive int |
4566
4604
 
@@ -4598,7 +4636,7 @@ Generate images via OpenRouter. The Zod schema is a permissive union; per-model
4598
4636
  | `reference` | image **or** image[] | no | `image/png`, `image/jpeg`, `image/webp`, `image/gif` |
4599
4637
 
4600
4638
  `reference` accepts a single image or an **array** of images. Wire several to combine references in one
4601
- generation — e.g. a subject reference sheet + a font specimen + the original ad. Each image is
4639
+ generation — e.g. a subject reference sheet + a product shot + a font specimen (never someone else's ad: the model copies what it is shown). Each image is
4602
4640
  forwarded to the model as a separate input, in array order (the provider accepts multiple). Wire an
4603
4641
  array as a literal list: `"reference": ["$ref:subject.sheet", "$ref:type.image"]`.
4604
4642
 
@@ -4608,7 +4646,9 @@ array as a literal list: `"reference": ["$ref:subject.sheet", "$ref:type.image"]
4608
4646
  |---|---|---|
4609
4647
  | `images` | image[] | `image/png \| image/jpeg \| image/webp` (provider-determined) |
4610
4648
 
4611
- **Cost** — 5 credits estimate per call (backend charges actual OpenRouter usage).
4649
+ **Cost** — 5 credits estimate per image asked for (`n`, default 1), 20 on Gemini 3 Pro Image (as `image_aspect_adapt` quotes it) and 20 at quality `max` (a static ad's re-laid-out sizes); the backend charges the call's actual OpenRouter usage.
4650
+
4651
+ **Several candidates from one call** — `n` (integer, default 1) asks one call for several images, returned together in `images`. Each image is billed as if it were its own call (`n: 3` costs three images); what it saves is requests and waiting. Only models that declare it take it (today `openai/gpt-image-2.5-sunburst`, up to 10); any other model refuses it before anything is billed. Wire the whole array into `image_select` to keep the best.
4612
4652
 
4613
4653
  > `seed` is not supported — OpenRouter's `image_config` schema has no seed slot for image-gen.
4614
4654
 
@@ -4779,7 +4819,7 @@ Guidance:
4779
4819
 
4780
4820
  Adapt ONE creative into multiple aspect ratios in a single step — the standard fan-out when a hero creative must ship to every placement (Meta: 9:16 stories, 1:1 feed, 4:5, 16:9…). The AI recomposes the layout per format: identical subject, faces, products, text (verbatim, same typography), logos, colors, and style; the scene is extended/restructured for the new canvas — never stretched, cropped, or letterboxed.
4781
4821
 
4782
- The adaptation prompt is baked into the backend and is direction-aware (a wider target gets horizontal recomposition guidance, a taller one vertical). Formats that already match the source ratio (within ~2%) pass the source asset through unchanged — no model call, no cost.
4822
+ The adaptation prompt is baked into the backend and is direction-aware by orientation: a portrait source adapted to landscape (or the reverse) is recomposed; a same-orientation change only extends the scene at the sides or at the ends, every element kept where it is. Optional `copy` (the ad's exact lines: each kept once, spelled as given) and `safe_areas` (per format, the fraction of each edge the platform's interface covers) are set by the static scaffold. Formats that already match the source ratio (within ~2%) pass the source asset through unchanged — no model call, no cost.
4783
4823
 
4784
4824
  **Inputs**
4785
4825
 
@@ -4921,13 +4961,13 @@ Search the web for real images instead of generating them. A backend LLM agent p
4921
4961
 
4922
4962
  ##### `image_select`
4923
4963
 
4924
- Pick the best `count` images out of 2+ candidates with a vision LLM, judged against a prompt. The output is a passthrough subset of the input refs — no new pixels are generated, so selection is cheap. Use it after fanning out several `image_generate` variants (or an `image_search` batch) to keep only the strongest before expensive downstream steps.
4964
+ Pick the best `count` images out of the candidates with a vision LLM, judged against a prompt. The output is a passthrough subset of the input refs — no new pixels are generated, so selection is cheap. Use it after an `image_generate` run with `n` > 1, several `image_generate` variants, or an `image_search` batch, to keep only the strongest before expensive downstream steps. A single candidate (a generation run at `n: 1`) passes through unjudged: no call, no charge, no `reasoning`.
4925
4965
 
4926
4966
  **Inputs**
4927
4967
 
4928
4968
  | Slot | Kind | Required | Accepted MIMEs |
4929
4969
  |---|---|---|---|
4930
- | `images` | image[] | yes — at least 2 | `image/png`, `image/jpeg`, `image/webp`, `image/gif` |
4970
+ | `images` | image[] | yes — at least 1 | `image/png`, `image/jpeg`, `image/webp`, `image/gif` |
4931
4971
 
4932
4972
  Wire either a whole upstream array (`"$ref:gen.images"`) or a literal list collecting refs from several nodes (`["$ref:a.images#0", "$ref:b.images#0"]`).
4933
4973
 
@@ -4938,7 +4978,7 @@ Wire either a whole upstream array (`"$ref:gen.images"`) or a literal list colle
4938
4978
  | `model` | enum | yes | one of `~google/gemini-flash-latest`, `~google/gemini-pro-latest` (`pro` for harder aesthetic judgement) |
4939
4979
  | `prompt` | string | yes | selection criteria, e.g. `"most professional product shot, clean background"` |
4940
4980
  | `count` | int | no | images to select, ≥ 1 (default 1); must not exceed the wired image count |
4941
- | `temperature` | number | no | 0–2, defaults to 0 |
4981
+ | `temperature` | number | no | 0–2; unset by default, so the Gemini judge keeps its own default |
4942
4982
  | `max_tokens` | number | no | positive int |
4943
4983
 
4944
4984
  **Outputs**
@@ -4946,7 +4986,7 @@ Wire either a whole upstream array (`"$ref:gen.images"`) or a literal list colle
4946
4986
  | Slot | Kind | MIME |
4947
4987
  |---|---|---|
4948
4988
  | `images` | image[] | per input — exactly `count` refs, in the model's preference order, so `images#0`…`images#count-1` are always safe to wire |
4949
- | `reasoning` | text | `text/plain` — why the winners won, compared against the rejected candidates |
4989
+ | `reasoning` | text | `text/plain` — why the winners won, compared against the rejected candidates; absent when a single candidate passed through |
4950
4990
 
4951
4991
  **Cost** — 1 credit estimate per call (backend charges actual OpenRouter usage).
4952
4992
 
@@ -5592,7 +5632,7 @@ Cache policies (via `--cache-policy`):
5592
5632
 
5593
5633
  Parse + schema-check + per-node deep validation (composition `meta.json` is consulted for `hyperframe_*`). Prints a structured result with a per-node cost preview. Exits non-zero on issues. Never calls Convex, never spends credits.
5594
5634
 
5595
- **The canvas file is brought up to date first.** Parts of a creative's canvas are projections of its blueprint: each video scene's frame and clip prompts (from `scenes/sNN.json`), a static ad's copy block and type specimen (from `prompt.json`), and the derived style file the frames read (`prompt.style.json`). `validate`, `run`, `critique` and `set-prompt` share one preflight that applies every projection and **writes the result back to the canvas file** (only when something changed; `canvas_updated` says so; `sync-definition`, which the file watcher runs over every creative, applies it without writing), so the file on disk, the graph the dashboard shows, the graph the critic scores and the graph that renders are the same. A scene edit that changes timing or the number of scenes is structural and still asks for a re-scaffold. Every preflight failure prints `{ ok: false, error: { code, message, fix? } }` and exits 2.
5635
+ **The canvas file is brought up to date first.** Parts of a creative's canvas are projections of its blueprint: each video scene's frame and clip prompts (from `scenes/sNN.json`), a static ad's render prompts, judge criteria and type specimen (compiled from the ad spec in `prompt.json`), and the derived style file the frames read (`prompt.style.json`). `validate`, `run`, `critique` and `set-prompt` share one preflight that applies every projection and **writes the result back to the canvas file** (only when something changed; `canvas_updated` says so; `sync-definition`, which the file watcher runs over every creative, applies it without writing), so the file on disk, the graph the dashboard shows, the graph the critic scores and the graph that renders are the same. A scene edit that changes timing or the number of scenes is structural and still asks for a re-scaffold. Every preflight failure prints `{ ok: false, error: { code, message, fix? } }` and exits 2.
5596
5636
 
5597
5637
  ```bash
5598
5638
  $ baker canvas validate my-canvas.json
@@ -5652,6 +5692,27 @@ $ baker canvas critique my-static-ad.json
5652
5692
  "dimensions": [ { "dimension": "text_legibility_risk", "score": 1, "note": "2 copy block(s) — reads cleanly" }, … ] }
5653
5693
  ```
5654
5694
 
5695
+ #### `baker canvas viewer-check <slug>`
5696
+
5697
+ Would the person a static ad reaches understand it? A model plays that viewer — what they already know and
5698
+ where they scroll — and says, from `prompt.json`'s copy and scene alone, what is offered, to whom and what to do
5699
+ next; the answer is compared with `prompt.json`'s `message`. The ad fails when the viewer can only guess what is
5700
+ offered, or, for a viewer who does not know the brand, when the words say they already did something with it (a
5701
+ cart, a trial, a donation, a registration); who it is for is reported but never fails an ad alone. The viewer is the `viewer` recorded in
5702
+ `_definition.md`, else the one its `targetAdSet` reaches (as `baker ads meta adsets --audience` reads it), else
5703
+ the persona and `awareness` from the Knowledge, else a cold stranger. Not understood → exit 2 with
5704
+ `viewer_check_failed` and a `fix` naming what the viewer missed. Answers are cached per copy, scene and viewer.
5705
+ `baker canvas run` makes the same check on a static ad before it spends (skipped for `--fix` and resumed runs;
5706
+ `--skip-viewer-check` renders without it; a model outage lets the render through with a warning). `--full` adds
5707
+ the viewer's whole answer and how the viewer was chosen.
5708
+
5709
+ ```bash
5710
+ $ baker canvas viewer-check club-whiteboard
5711
+ { "ok": false, "error": { "code": "viewer_check_failed",
5712
+ "message": "Not rendered: a cold viewer who knows nothing about the brand yet, on automatic placements would not understand this ad.",
5713
+ "fix": "Seen by a cold viewer …, the words and picture alone do not say what is offered. …", "missed": [ … ] } }
5714
+ ```
5715
+
5655
5716
  #### `baker canvas run <file.json> [flags]` / `baker canvas run --slug <slug> [flags]`
5656
5717
 
5657
5718
  Validate, then execute the graph. Blocks until done. Logs one line per node. Returns `{ ok, run_id, outputs_dir, output, stats }` on stdout.
@@ -5666,7 +5727,13 @@ A completed render the review calls ready (`verdict` `ready` or `ready_with_note
5666
5727
  | `--force-remote` | off | With `--slug`: restore the recorded files even over local ones. |
5667
5728
  | `--run-id <id>` | auto ULID | Override the generated run id. |
5668
5729
  | `--cache-policy <policy>` | `read_write` | `read_write`, `bypass`, or `read_only`. |
5669
- | `--concurrency <n>` | `5` (or `BAKER_CANVAS_CONCURRENCY`) | Max nodes executing at once within a layer. |
5730
+ | `--concurrency <n>` / `--parallel <n>` | `8` (or `BAKER_CANVAS_CONCURRENCY`) | Max nodes executing at once within a layer. |
5731
+ | `--fresh` | off | Start a new run id instead of resuming an interrupted run of this canvas. |
5732
+ | `--regenerate <ids\|roles>` | none | Force these nodes, and everything downstream, fresh this run only. A role names every node carrying it: on a static ad `static_variants` (or its id `gen`) renders the variants again. An unknown name exits 2 (`unknown_regenerate_node`). |
5733
+ | `--fix "<what to change>"` | none | Static ad only: correct a finished version with one render that changes only what you say; every size is made again from it (see below). One render of the round. |
5734
+ | `--use-variant <sha256>` | none | Static ad only: make every size again from another of a version's variants, the one the person chose; the only requests re-lay it out for each other size (see below). |
5735
+ | `--from <run_id>` | the last render | With `--fix` or `--use-variant`: the version to start from. |
5736
+ | `--skip-viewer-check` | off | Static ad only: render without the viewer check (`baker canvas viewer-check`), when the person heard what the viewer would miss and wants the copy as it is. |
5670
5737
  | `--remote-cache <on\|off>` | `on` (or `BAKER_CANVAS_REMOTE_CACHE`) | Operator only. Company-scoped remote cache + durable asset persistence. |
5671
5738
  | `--no-record` | records | Skip posting the durable run-history record (and its live progress). |
5672
5739
  | `--until frames` | none | Stop before the clips with the frames settled; the run is recorded as `awaiting_approval` (not failed, not interrupted) and resumes with `--run-id`. |
@@ -5678,6 +5745,10 @@ A completed render the review calls ready (`verdict` `ready` or `ready_with_note
5678
5745
 
5679
5746
  **Failures don't abandon sibling work.** Nodes in a layer run under the concurrency cap and every one **settles** — a failed clip no longer kills its in-flight siblings, whose finished results still land in the content-addressed cache. One failure re-throws as-is; several are reported together (each failed node named). Re-running `baker canvas run` resumes from the cache and re-executes **only** the failed nodes and their descendants — never hand-orchestrate per-node renders. **One render per canvas at a time:** a `run` of a canvas that another live process in the same workspace is still rendering (a backgrounded render, or a subagent's) is refused with `CONFLICT` instead of sharing its run, `--run-id` included — wait for it and use its result. Long `video_generate` clips execute as **backend jobs** (the CLI polls; a CDN/proxy timeout can no longer kill a generation mid-flight).
5680
5747
 
5748
+ **Resuming an interrupted run.** A long `baker canvas run` (multi-clip video) that is killed mid-render — session end, sandbox pause — leaves a marker under the outputs dir. The next `baker canvas run` of the same canvas automatically **resumes** that run: it reuses the run id so still-running billed jobs re-attach instead of being abandoned and re-billed, and completed nodes come from the cache. A live render keeps its marker's heartbeat fresh, so a second `run` of the same canvas is refused while it is alive (checked with `kill(pid, 0)` plus the heartbeat, on Linux and macOS alike). Resume also works from a **different workspace or a fresh sandbox**: when no local marker survives, the run history is consulted for a resumable run of the same source (canvas, `prompt.json`, scenes, compositions and formats), and it is adopted when the backend says it may be: interrupted, stopped at a checkpoint, or a `running` row whose keepalive is older than the backend's staleness threshold. Ctrl-C / SIGTERM aborts gracefully — no new nodes dispatch, a resumable snapshot is flushed, and the marker survives. A clean completion (or a handled failure) clears the marker, so a normal re-run starts a fresh generation. Force a new run with `--fresh`, or pin a specific run with `--run-id <id>` (also the escape hatch to adopt a run that is reported as concurrently live). Independent same-layer nodes (e.g. video clips) fan out in parallel up to `--parallel`/`--concurrency` (default 8; env `BAKER_CANVAS_CONCURRENCY`).
5749
+
5750
+ **Static ads: correct a version, or use another of its variants.** Correct a finished version with `--fix "<what to change>"`: one render takes that version (the last one, or `--from <run_id>`) as its first reference and changes only what you said, keeping the picture, the layout and every other word; it replaces the variants and the judge, every size is made again from the corrected picture, and it counts as one render of the round. Use another variant with `--use-variant <sha256>` (`--from <run_id>`, default the last render): when the person chose another of a version's variants over the judge's pick, every size is made again from that picture, and the only requests re-lay it out for each other size; `--regenerate adapt_9x16` lays out one size again from the same chosen picture when only that size came out wrong (after a `--fix` or `--use-variant`, from that corrected or chosen picture, so the size never brings back the uncorrected one; `--from <run_id>` names another version); `baker creatives feedback --slug <slug>` prints the choice (`variantChoice`) and the exact command.
5751
+
5681
5752
  #### `baker canvas scaffold-video <video> [flags]`
5682
5753
 
5683
5754
  Turn a reference video into a **runnable, self-validated reproduction canvas** in one command — the video counterpart of `scaffold-static-ad`. The `<video>` positional is a **local path OR an http(s) URL** (a `baker winning-ads` `media_url`, a library URL, any reel link) — a URL is downloaded for you (no manual `curl` first), so pass `--slug`/`--out` with it to give the canvas a home. It runs **billed passes** up front:
@@ -5691,7 +5762,7 @@ Before the deconstruct it runs a **local shot-cut pass** on the source file with
5691
5762
 
5692
5763
  A shot longer than the video model's per-clip ceiling (Seedance's 15s, passed as `video_deconstruct`'s `max_clip_s`) is split into equal **continuation sub-scenes** that share their splice boundary exactly — so a long shot is reproduced in **full** (no truncation) and joins seamlessly. Each sub-scene carries `continues_previous`.
5693
5764
 
5694
- It then scaffolds the full pipeline like an **editing timeline**: each clip gets a **static-ad-grade start AND end keyframe** (`image_generate`, each with its **own `params.prompt`** regenerated from that scene's `scenes/sNN.json` — edit the scene file to change that frame, never the node prompt by hand; the slim `prompt.style.json` wired as the **shared `target_blueprint`** style reference, plus a per-element reference legend). Each keyframe is **fully recast** to the dropped `el_*` reference images. The original extracted frame is kept LAST as a **pure composition anchor** (framing / camera angle / shot size / pose) only in an ad with **nobody in it** — no deconstructed cast and no recurring person. In any ad with people, no generated frame takes a source frame as a reference: an image model copies the face of whoever is in its references, whatever the prompt says, and which scenes a person is in is itself a model's guess — so framing is carried by the frame prompt instead. **People are cast from company avatars** (`--avatar <handle>`): each recurring person grounds on that avatar's identity sheet, used as-is (no sheet is built from it); a person with no avatar gets a `[TODO]` asking for one, never for a found photo. Both keyframes feed `video_generate` (`first_frame`+`last_frame`, so Seedance interpolates real in-shot motion; ultra-detailed motion brief; duration snapped to the nearest allowed clip length). Every keyframe grounds **only on its own extracted frame + `el_*` slots** — no reference to any other generated frame — so all images render **in parallel** (no cascade). Source-frame URLs are **deduped** (each ingested once). `--frames reuse` wires the real source frame straight in — only on a video with nobody in it: when the deconstruct found a cast or a recurring person, reuse is refused (`frames_reuse_refused`) and every frame is generated, so the source's actors never reach the ad.
5765
+ It then scaffolds the full pipeline like an **editing timeline**: each clip gets a **static-ad-grade start AND end keyframe** (`image_generate`, each with its **own `params.prompt`** regenerated from that scene's `scenes/sNN.json` — edit the scene file to change that frame, never the node prompt by hand; the slim `prompt.style.json` wired as the **shared `target_blueprint`** style reference, plus a per-element reference legend). Each keyframe is **fully recast** to the dropped `el_*` reference images. The original extracted frame is kept LAST as a **pure composition anchor** (framing / camera angle / shot size / pose) only in an ad with **nobody in it** — no deconstructed cast and no recurring person. In any ad with people, no generated frame takes a source frame as a reference: an image model copies the face of whoever is in its references, whatever the prompt says, and which scenes a person is in is itself a model's guess — so framing is carried by the frame prompt instead. **People are invented or cast**: a person with no avatar gets a generated candid portrait from their description (an `image_generate` node at the `el_*` slot, fused into a sheet), so every frame shows one realistic invented person; with `--avatar <handle>` each recurring person grounds on that avatar's identity sheet, used as-is. Never a found photo. Both keyframes feed `video_generate` (`first_frame`+`last_frame`, so Seedance interpolates real in-shot motion; ultra-detailed motion brief; duration snapped to the nearest allowed clip length). Every keyframe grounds **only on its own extracted frame + `el_*` slots** — no reference to any other generated frame — so all images render **in parallel** (no cascade). Source-frame URLs are **deduped** (each ingested once). `--frames reuse` wires the real source frame straight in — only on a video with nobody in it: when the deconstruct found a cast or a recurring person, reuse is refused (`frames_reuse_refused`) and every frame is generated, so the source's actors never reach the ad.
5695
5766
 
5696
5767
  **Composited scenes (split-screen / picture-in-picture / keyed presenter).** Real ads aren't always one full-frame shot — a frame can be **persistently divided** (b-roll on top, a presenter talking on the bottom) or **layer a presenter** over background footage (boxed in a corner, or green-screen keyed). The deconstruct now reports this per scene as `scene.composition` (`layout: split_screen | pip | keyed_overlay`, with one `region` per stream — each its own clean-plate frame + motion brief, the talking-head region flagged `is_presenter`). The scaffold reproduces a composited scene by building **one clip per region** (`s<i>_r0_*`, `s<i>_r1_*`, …) and compositing them with ffmpeg: a split-screen `vstack`/`hstack` (stack direction read from the region **panels**, so a top/bottom split always stacks vertically), or a picture-in-picture `overlay` of the presenter inset at its corner. A **keyed** presenter is first cut to transparency by `video_background_remove` (`s<i>_key`), then overlaid. The presenter region carries the native lip-synced voice; b-roll/render panels stay silent. To change a layout, edit `composition` in that scene's `scenes/sNN.json` and re-scaffold, or hand-edit the `s<i>_composite` ffmpeg args. Plain full-frame scenes (the default) are unaffected.
5697
5768
 
@@ -5787,29 +5858,32 @@ The two scaffold passes are billed (the full `video_deconstruct` is the heavy on
5787
5858
 
5788
5859
  Turn a source/inspiration image into a **runnable, self-validated static-ad canvas** — the static counterpart of `scaffold-video`. It runs **billed Gemini passes** up front:
5789
5860
 
5790
- 1. **`image_describe`** (`~google/gemini-pro-latest`) — reverse-engineers the image into a blueprint JSON, written next to the canvas as **`prompt.json`**. This is the editable "prompt": you rewrite it by hand into the ad you want (palette, copy, claims, subjects). It feeds the generator directly — there is **no automatic brand-transform step**. The blueprint also names the **`winning_mechanisms`** — the special sauce that makes the ad a candidate winner, each tagged `kind` (verbal: rhyme/pun/rhythm; visual: unexpected crop, visual gag, juxtaposition, pattern interrupt, before/after; structural: hook order/reveal) with a `device` and `why_it_works` — so your rewrite rebuilds the mechanism that makes the ad win instead of adapting only the surface and losing it. Every line of `text_content` carries its `surface` (`overlay` ad copy, or `in_scene`: written on a phone screen, a note, a sign, packaging) and `placement`.
5791
- 2. **element selection** (`~google/gemini-flash-latest`) — picks the **main, identity-critical** elements (the brand logo, a showcased product, a trust badge) **plus any foreground/hero person or animal** — the emotional focal point — even a generic one, because a free-generated face/muzzle reads as AI and grows artifacts; the emotional hero always gets a real-reference slot. When the advertiser's logo appears in **more than one lockup** (a square/icon **mark** and a horizontal **wordmark**), each is emitted as its **own** element (e.g. `LOGO_MARK`, `LOGO_WORDMARK`). The describe pass also records the ad's **typography** under a `fonts` block (each typeface's classification, a best-guess family, and its weight/case). Background extras are dropped. Each element is stamped back onto its blueprint entry as a `reference_image` label so the JSON self-documents which slot grounds which subject.
5861
+ 1. **`image_describe`** (`~google/gemini-pro-latest`) — reverse-engineers the image (layout by what fills each region, copy verbatim, typefaces, capture in words, the `mechanism` and the `audience` it is built for) and writes it next to the canvas as **`prompt.json`**, an **ad spec** (`"schema": "baker-static-ad/2"`) you rewrite into the ad you want: `viewer` (who sees it and what they already know), `message` (the one thing it tells them, never printed), `copy` (each line with `lettering`: `in_scene`, written on something in the picture (`on`, `look`), or `designed`, set in the brand's face), `scene` (`description`, `look`: photo | designed | illustration, `capture` in words, `mood`, `layout`), `brand`, `colors` (each tied to an object, from the brand palette), `references` (one picture each, labelled by role: `PERSON_1`, `ANIMAL_1`, `PRODUCT_1`, `BADGE_1`, `PROP_1`), `logo` (and `logo_position`), `third_party_brands` (other brands the offer is about, a car brand on a car loan: `name`, `what`, and `logo` naming the `BADGE_n` reference of its logo; their logos may appear), an optional `direction`, and the `mechanism` to rebuild for our brand. **You never write a model prompt**: every render prompt is compiled from this file for the model it runs on (short labelled sections for gpt-image, sentences with reasons for Gemini; each line quoted once; pictures numbered with one role each; colours from the brand; realism as capture cues; the safe area from its one owner; constraints last), on every `validate` and `run`, and shown on its node. A `prompt.json` written before the ad spec (the describe blueprint) is read through an adapter and keeps working. The source's own read (`source`, and the library's `reference`) stays as bookkeeping and is never sent to a model.
5862
+ 2. **element selection** (`~google/gemini-flash-latest`) — picks the **main, identity-critical** elements (the brand logo, a showcased product, a trust badge) **plus any foreground/hero person or animal** — the emotional focal point — even a generic one, because a free-generated face/muzzle reads as AI and grows artifacts; the emotional hero always gets a real-reference slot. When the advertiser's logo appears in **more than one lockup** (a square/icon **mark** and a horizontal **wordmark**), each is emitted as its **own** element (e.g. `LOGO_MARK`, `LOGO_WORDMARK`). The describe pass also records the ad's **typography** (each typeface's classification, a best-guess family, and its weight/case), which seeds the spec's `brand.typeface`. Background extras are dropped. Each element becomes a `references` entry in the spec, named by its role and naming the canvas slot it fills, so the file says which slot grounds which subject.
5792
5863
 
5793
5864
  It then scaffolds a canvas on which **the image model designs the finished ad**, holding only what `prompt.json` holds; no element is mandatory:
5794
5865
 
5795
- - Up to **three candidates** (`--takes`, default 3) render on `openai/gpt-image-2.5-sunburst` at quality `xhigh`, each a finished ad: the picture and every word of the copy set in type. The prompt quotes every string of `text_content` verbatim (never the logo's text) and asks for each once; the block is re-read from `prompt.json` on every `validate` and `run`, so an edit to the copy re-keys exactly the renders it touches. The brand's **typeface** is shown to the model as a `font_specimen` set on the ad's own copy: a file in `src/brand/fonts/` (via `src/brand/tokens.json` when present), else the heading family `tokens.json` or `BRAND.md` names, fetched as a web font file; when the brand names none, the model follows the blueprint's `fonts` and no neutral default is put in front of it. **The logo is optional**: the real file in `src/brand/logos/` goes to the model as a reference to reproduce, only when `prompt.json` keeps an `advertiser_brand` entry in `brands_logos` and the file exists; no logo is never an error, never a slot to fill and never a stand-in. Deleting that entry from `prompt.json` drops the logo on the next run; adding one to a canvas scaffolded without the file needs the file in `src/brand/logos/` and a re-scaffold. `prompt.json` also carries a **`brand`** block (ground, ink, accent, CTA and palette, from `src/brand/tokens.json` roles or `BRAND.md`), and the render binds its colours to it over the reference ad's. A 9:16 is told to keep every word, logo and button out of the Stories interface.
5796
- - An `image_select` **judge** scores the candidates on one dominant idea, thumbnail legibility, exact words, layout (nothing overlaps or is clipped), mechanism kept, brand with the logo covered and artifacts, and picks the highest total among those whose words are exact.
5797
- - Every other placement format is the winner **re-laid** by the same model from the ad itself (one billed render each, the winner as its first reference), so the set stays one ad. A format the model has no shape for (gpt-image has no 4:5) renders at its nearest shape (3:4) and is cropped exact by a free `imagemagick` node; the render prompt keeps every word 6% from the edges for it.
5798
- - Detected elements get **one `[TODO]` ingest slot** each (never the logo), and the original image is wired in for composition only, left out of the render when it shows a person, because the generator copies that face whatever the prompt says. Each **animal hero** is fused into a generated **multi-view reference sheet** (`image_reference_sheet`) that the render grounds on; pass `--skip-actor-sheets` to ground on the dropped photo. A **person hero** is cast from a company Avatar with `--avatar <handle>`: the slot ingests its identity sheet and `prompt.json` describes that person in the Avatar's own words.
5866
+ - Up to **three variants** (`--takes`, default 3) render on the model the router picks for the concept (today `openai/gpt-image-2.5-sunburst` at quality `max`; `--gen-model` overrides), each a finished ad: the picture and every word of the copy. Each variant is **its own request** (tagged `static_variant_call`), run in parallel on its own direction (the scene as written; another picture of the same message, by the scene's look; another line leading, named by number so no word changes), and a `collect` tagged `static_variants` gathers them, on every model: one request with `n` costs the same (`n` is billed per picture) but gave fewer distinct options, so the graph never uses it. The brief quotes every line of `copy` verbatim and asks for each once; it is compiled again from `prompt.json` on every `validate` and `run`, so an edit re-keys exactly the renders it touches. The brand's **typeface** is shown to the model as a `font_specimen` set on the `designed` lines: a file in `src/brand/fonts/` (via `src/brand/tokens.json` when present), else the heading family `tokens.json` or `BRAND.md` names, fetched as a web font file; an `in_scene` line keeps its own lettering only on the real packaging, signage or screen its `on` names (handwriting on post-its, whiteboards or chalkboards, and a line naming no surface, are set as designed type). Each line's `role` sets its size: a price or stat (else the headline) leads, the `cta` is a large solid button, fine print is smallest. **The logo is optional**: the real file in `src/brand/logos/` goes to the model as a reference to reproduce once, at least a fifth of the frame wide, where it contrasts or on a small brand-colour plate, only when `prompt.json` has `"logo": true` (an older blueprint: an `advertiser_brand` entry in `brands_logos`) and the file exists; no logo is never an error, never a slot to fill and never a stand-in. Setting it false drops the logo on the next run; adding one to a canvas scaffolded without the file needs the file in `src/brand/logos/` and a re-scaffold. `prompt.json` also carries a **`brand`** block (ground, ink, accent, CTA and palette, from `src/brand/tokens.json` roles or `BRAND.md`); a colour outside it never reaches the model. The brief describes a complete ad at the master's shape and asks only for its edge margin (see below). `baker canvas set-prompt` refuses these nodes and names `prompt.json`.
5867
+ - A **`static_prefilter`** node (tagged `variant_prefilter`) drops variants that fail the deterministic checks before the judge sees them, never all of them; an `image_select` **judge** (tagged `pick`) compares the rest pairwise against the compiled brief (one variant passes through unjudged).
5868
+ - **Every placement format is made from the chosen picture, and no size is a crop or a paste of another.** The pick goes to exact pixels as the master (an `imagemagick` node tagged `static_master`) at the lead size: the 4:5 when a feed size is delivered, else the square, else the first portrait size, rendered at the model's nearest ratio (3:4 on GPT Image) and filled to Meta's pixels. It is delivered as it is. Every other size is **re-laid out** from it by one request (`image_generate` node `adapt_<size>`, tagged `static_adapt_format`, on the router's `relayout` model, GPT Image 2.5 at quality `max`): the master, the variants' own product and people pictures and the logo file as references and a brief compiled from `prompt.json` for that frame on every run (`static-adapt.ts`): the exact lines and their hierarchy, the same people (same face, same clothes), product and label, logo and none the chosen ad does not show, the words and the button re-scaled for the frame, the frame stated as zones inside its safe area (on a 9:16, the words set large through the Reels band, the headline across most of its width, lists restacked rather than shrunk, and the ad's own picture larger above and below it, never flat colour), never the master shrunk onto a background, set as a square or cut. A `size_<size>` node (`static_size`) brings it to Meta's exact pixels (9:16 at 1080×1920, 1:1 at 1080×1080; a landscape size the same way). A 9:16 that Reels or Stories covers then passes a free `static_safe_fit` node (`safe_9x16`), which shrinks and shifts the picture just enough (to 85% at most) to bring a word or the logo a few points past the band back inside it; a canvas scaffolded before it gets the node on its next run, every other node kept. The master shape is one decision in the code (`DEFAULT_MASTER_SHAPE`: `relayout`; the `9x16_band` and `4x5_adapt` shapes, kept for the bench, make the sizes by cutting or reframing the master, and the person rating them rejected every such way; a canvas from the retired band master, which extended a square master into the 9:16, is upgraded to the re-layout graph on its next run). Placing the logo file on every size instead of having the model draw it (`logo_composite` nodes) is a scaffold option, off until it is proven on the bench.
5869
+ - Readers go by these roles, never by node ids: the run review, `--fix`, `--use-variant`, `--regenerate static_variants`, the render budget and the dashboard. A static ad scaffolded before this graph (`ad_a`/`ad_b`/`ad_c`, `pick`, re-lays `ad_4x5`/`ad_9x16`, `fit_4x5`) is **upgraded in place** on its next `validate` or `run`: prompt.json, every asset slot, the model and the formats are kept, the render part becomes this graph, and the creative is not reported as changed by the upgrade. Its old runs stay readable.
5870
+ - Detected elements get **one `[TODO]` ingest slot** each (never the logo), labelled by role (`el_product_1`, `el_person_1`), never by the source's brand. The original image stays on the canvas as the record of the source and is **never given to the render**: the generator copies what it is shown, whatever the prompt says. Each **animal hero** is fused into a generated **multi-view reference sheet** (`image_reference_sheet`) that the render grounds on; pass `--skip-actor-sheets` to ground on the dropped photo. A **person** with no avatar has no slot: the render draws an invented, realistic person from the element's description. With `--avatar <handle>` (for an actor who recurs across ads) the slot ingests the Avatar's identity sheet and `prompt.json` describes that person in the Avatar's own words.
5799
5871
 
5800
5872
  The canvas is validated before it is written. stdout returns `{ ok, canvas_path, prompt_path, models, aspect_ratio, placements, output_formats, stats, checklist }`; the **checklist** carries `summary` (what a run does: candidates, judge, type, logo, formats), `font`, `logo`, `in_scene_text`, `formats` and every real asset to drop in.
5801
5873
 
5802
- **Placement formats.** The canvas delivers the platform's full placement set, each format at its exact size, collected under `final` with the format as its label. The set is derived from `--platform` (`meta` → 4:5 + 9:16, `google` → 1:1 + 4:5 + 16:9, `linkedin` → 1:1, `tiktok` → 9:16, `youtube` → 16:9); override it with `--placements <preset>` or `--placements none` for a single format. The report's `aspect_ratio` is the shape the candidates really render at (the model's nearest to the lead format), and `format_plan` says how each format is made (`render` or `relay`, and whether it is cropped exact).
5874
+ **Placement formats.** The canvas delivers the platform's full placement set, each format at its exact size, collected under `final` with the format as its label. The set is derived from `--platform` (`meta` → 4:5 + 9:16, `google` → 1:1 + 4:5 + 16:9, `linkedin` → 1:1, `tiktok` → 9:16, `youtube` → 16:9); override it with `--placements <preset>` or `--placements none` for a single format. The report's `aspect_ratio` is the shape the variants really render at (the model's nearest to the master's format), and `format_plan` names the master and how each size is made from it (`master`, `crop` with its rectangle, or `adapt`).
5875
+
5876
+ **Who sees the ad.** `--target-adset <id>` names the Meta ad set the ad will run in. Before anything is billed, the viewer its targeting reaches is read exactly as `baker ads meta adsets --audience` reads it (`temperature`, what they already `knows`, `placements`) and written to `_definition.md` as `targetAdSet` and `viewer` (an existing definition keeps every other field). The sizes, the brief and the prefilter keep clear of that viewer's placements (`staticSafeArea`; `automatic`, a bare `facebook`/`instagram` or no placements is Advantage+, Reels included), and every `baker canvas run` checks the ad against that viewer. With no ad account, give the viewer from the Knowledge: `--viewer-temperature cold|warm|hot [--viewer-knows brand,offer,product] [--viewer-placements <placement,…>]`, recorded with `source: knowledge`; next to `--target-adset` they correct the read, and its `reasons` say so. A bad value exits 2 (`invalid_viewer`) before any billed pass; an ad set that cannot be read never stops the scaffold (a hint says why, and the sizes keep clear of every placement). With neither flag, a `viewer` (or `targetAdSet`) already in `_definition.md` is used as it is. stdout carries `target_adset`, `viewer` and a `checklist.viewer` line.
5803
5877
 
5804
- **After a run** the CLI reads the finished ad back on what makes a static win, not a checklist of elements: every delivered image's pixel shape against its format; **every string of `prompt.json`'s copy transcribed off each format and compared character for character** (case and accents kept; a string read differently or not found is blocking on its own, and a format the model transcribed nothing for is reported as unread, never passed); and one vision pass for clipping, overlaps, artifacts, the Stories safe zones on a 9:16, one dominant idea, the brand with the logo covered and the concept's mechanisms, with every format shown a second time at phone-feed thumbnail size (240px wide, same call): a lead message unreadable at that size is blocking. A missing logo, CTA or copy is never a finding; a logo that is drawn and broken is. stdout carries `verdict` (`ready` | `ready_with_notes` | `needs_fixes` | `not_reviewed`), `judge` (the per-candidate scores) and hints that end with what the run made; the findings are stored on the run as its `review`. `needs_fixes` means the ad is not done even though every node completed; every fix is an edit to `prompt.json` and a re-run (wrong words: the strings in `text_content`; wrong picture: the scene; a bad roll: `--regenerate ad_a`). **Render budget:** a static ad gets 3 renders per creative in a chat before the person reacts; the fourth exits 2 with `RENDER_BUDGET` without rendering, and `baker creatives feedback record` or `baker creatives select` on that creative opens a new round. Resuming an interrupted run does not count again, and a run that died only on an outage (timeout, network, a provider that says retry) gives its render back.
5878
+ **After a run** the CLI reads the finished ad back on what makes a static win, not a checklist of elements: every delivered image's pixel shape against its format; **every line of `prompt.json`'s copy (in-scene and designed alike) read back with its box by a box reader and compared** (a line more than 2% off, missing, or joined by words nobody asked for is blocking; case and punctuation fold, accents do not); **every word and the logo measured against the format's safe area** (`staticSafeArea`, the one owner of those areas: on a 9:16, the Reels and Stories interface unless the ad set's placements rule them out, which is blocking; on a feed image such as a 4:5, only a design margin, so words close to the edge are a warning and only words the frame cuts are blocking); a copy line written over two lines (`"15%\noff"`) is matched part by part wherever the reader finds each; designed type in a colour far from the brand palette is a warning. When the reader cannot answer, the vision pass transcribes the copy instead and judges the safe zones by eye. Then one vision pass for clipping, overlaps, artifacts, one dominant idea, the brand with the logo covered and the concept's mechanisms, with every format shown a second time at phone-feed thumbnail size (240px wide, same call): a lead message unreadable at that size is blocking. A missing logo, CTA or copy is never a finding; a logo that is drawn and broken is. stdout carries `verdict` (`ready` | `ready_with_notes` | `needs_fixes` | `not_reviewed`), `judge` (the per-candidate scores) and hints that end with what the run made; the findings are stored on the run as its `review`. `needs_fixes` means the ad is not done even though every node completed, and the run is recorded with `presentable: false` so it is never shown as a version; every fix is an edit to `prompt.json` and a re-run (wrong words: the lines in `copy`; wrong picture: the `scene`; a bad roll: `--regenerate static_variants`; one re-laid-out size wrong while the chosen picture is right: `--regenerate adapt_<size>`, named in the hints). The checks rank the variants before the pick and the judge only breaks an exact tie, comparing pairwise against the compiled brief, knowing the brand and the viewer; every variant stays on offer to the person in Baker. **Render budget:** a static ad gets 3 renders per round (`RENDERS_PER_ROUND`), and a round is one version the person sees; the backend counts them (`GET /api/canvas/render-budget`), with a local ledger only when it cannot be reached. The fourth exits 2 with `RENDER_BUDGET` without rendering. The person's reaction opens a new round: their answer in Baker, or `baker creatives feedback record` / `baker creatives select` from the chat. Resuming an interrupted run does not count again, and a finished run that billed nothing (an outage: timeout, network, a provider that says retry) gives its render back. When the round is spent and the verdict is still `needs_fixes`, the hints say to show the best version and name the defect in plain words rather than render again.
5805
5879
 
5806
- The render prompt is **model-aware**: for **gpt-image** the exclusions (no invented badges/watermark) are hoisted to the very *end* of the prompt where the model reads them as pure exclusions rather than composition. Run `baker canvas critique` on the scaffolded canvas for an advisory read before you spend.
5880
+ The render prompt is **model-aware**: each model's profile (`src/engine/lib/prompt-profiles.ts`) sets its shape, word budget, picture order and request params. gpt-image gets short labelled sections with the constraints last; Gemini gets sentences that give the reason and a positive closing list of the only words allowed. A profile change, or a new model on the router (`src/engine/lib/image-model-router.ts`), is re-run on the static bench (`scripts/creative-evals`, `--job static`) before it ships. Run `baker canvas critique` on the scaffolded canvas for an advisory read before you spend.
5807
5881
 
5808
5882
  **A reference from the winners library goes in by id.** `--source-ad-id <ad_id> --source-platform meta|linkedin|tiktok [--source-advertiser-id <id>]` (the ids beside the ad in `baker winning-ads search`) fetches the library's structured read of that ad, the same one `baker winning-ads content <ad_id> --full` returns, and writes it into `prompt.json` as a `reference` block: `{ ad_id, platform, advertiser, advertiser_id, format_pattern, layout: { composition, focal_subject, aspect_ratio, background, palette_mood, colors, production, aesthetic, cta_in_image, markers }, message: { mechanism, awareness, sophistication, angle, hook_archetype, pain_points, desires, value_props, objections_handled, proof, risk_reversal }, offer: { offer_type, pricing_model, entry_price, no_credit_card, guarantee, guarantee_days, urgency, bonus }, text_blocks: [{ text, role, position }], copy: { primary_text, headline, cta } }` (filled fields only). The describe pass still reads the pixels but is told that read as grounding, so the two cannot disagree on the container or the mechanism; when it names no `winning_mechanisms`, the library's container + mechanism seeds the list. The source ids (`sourceAdId`, `sourcePlatform`, `sourceAdvertiserId`, and `sourceAdvertiser` from the library when `--advertiser` is absent) are written to `_definition.md` and synced to the creative at scaffold time. A library read that fails warns, records the ids anyway and scaffolds from the picture alone (`hints`: `REFERENCE NOT READ`); with no picture either, the command exits 2 (`source_unavailable`). `baker canvas sync-definition` re-reads the three ids from `_definition.md` on every sync.
5809
5883
 
5810
5884
  ```bash
5811
- baker canvas scaffold-static-ad --source-ad-id adg_123 --source-platform meta --slug spring-offer --platform meta
5812
- # → the library's picture and read; writes src/creatives/spring-offer/{spring-offer.canvas.json, prompt.json, _definition.md}
5885
+ baker canvas scaffold-static-ad --source-ad-id adg_123 --source-platform meta --slug spring-offer --platform meta --target-adset 120249211706990208
5886
+ # → the library's picture and read, made for the viewer that ad set reaches; writes src/creatives/spring-offer/{spring-offer.canvas.json, prompt.json, _definition.md}
5813
5887
  baker canvas scaffold-static-ad ./reference-ad.png --context "competitor ad for <brand>, <category>, <market>"
5814
5888
  # → writes ./static-ad.canvas.json + ./prompt.json
5815
5889
  # edit prompt.json into your ad + replace each [TODO] ingest path with a real file, then:
@@ -5827,8 +5901,8 @@ baker canvas run ./static-ad.canvas.json
5827
5901
  | `--slug <slug>` | — | Creative slug (lowercase kebab): writes the canvas to `src/creatives/<slug>/<slug>.canvas.json` — the repo convention that attaches every run to the creative's dashboard generation history. `--out` wins over `--slug`. With a slug, the reference image is localized to durable storage and normalized to a model-safe format (SVG/AVIF/HEIC/… → PNG), so the canvas ingests a stable URL instead of an expiring one. |
5828
5902
  | `--describe-model <id>` | registry default (`~google/gemini-pro-latest`) | Override the `image_describe` model. |
5829
5903
  | `--select-model <id>` | registry default (`~google/gemini-flash-latest`) | Override the element-selection `text_generate` model. |
5830
- | `--gen-model <id>` | `openai/gpt-image-2.5-sunburst` | Override the render model. An unknown model id in any `--*-model` flag fails the command (`unknown_model`) before anything is billed. |
5831
- | `--takes <n>` | `3` | Finished ads rendered and judged (1-3); `1` skips the judge. |
5904
+ | `--gen-model <id>` | the router's pick for the concept | Override the render model. An unknown model id in any `--*-model` flag fails the command (`unknown_model`) before anything is billed. |
5905
+ | `--takes <n>` | `3` | Variants of the finished ad rendered (one request each) and judged (1-3); `1` skips the judge. |
5832
5906
  | `--platform <channel>` | `meta` | `_definition.md` channel **and** the default placement set (`meta`\|`google`\|`linkedin`\|`tiktok`\|`youtube`\|`x`\|`other`; `x`/`other` deliver one format). |
5833
5907
  | `--placements <preset\|none>` | derived from `--platform` | Override the set: `meta_feed`\|`meta_feed_stories`\|`meta_all`\|`linkedin`\|`google_demandgen`\|`tiktok`\|`youtube`, or `none` for a single base ad. |
5834
5908
  | `--aspect <ratio>` | inferred from the image, else `9:16` | Force the lead format (it leads the placement set when the set has it). |
@@ -5837,8 +5911,6 @@ baker canvas run ./static-ad.canvas.json
5837
5911
 
5838
5912
  Scaffolding runs (and bills) the two vision passes; **running** the result generates billed images. `baker canvas validate` does not check that the `[TODO]` paths exist — supply the real files before `run`.
5839
5913
 
5840
- **Resuming an interrupted run.** A long `baker canvas run` (multi-clip video) that is killed mid-render — session end, sandbox pause — leaves a marker under the outputs dir. The next `baker canvas run` of the same canvas automatically **resumes** that run: it reuses the run id so still-running billed jobs re-attach instead of being abandoned and re-billed, and completed nodes come from the cache. A live render keeps its marker's heartbeat fresh, so a second `run` of the same canvas is refused while it is alive (checked with `kill(pid, 0)` plus the heartbeat, on Linux and macOS alike). Resume also works from a **different workspace or a fresh sandbox**: when no local marker survives, the run history is consulted for a resumable run of the same source (canvas, `prompt.json`, scenes, compositions and formats), and it is adopted when the backend says it may be: interrupted, stopped at a checkpoint, or a `running` row whose keepalive is older than the backend's staleness threshold. Ctrl-C / SIGTERM aborts gracefully — no new nodes dispatch, a resumable snapshot is flushed, and the marker survives. A clean completion (or a handled failure) clears the marker, so a normal re-run starts a fresh generation. **Correct a finished static ad** with `--fix "<what to change>"`: one render takes that version (the last one, or `--from <run_id>`) as its first reference and changes only what you said, keeping the picture, the layout and every other word; the other formats and the review follow, and it counts as one render of the round. Force a new run with `--fresh`, or pin a specific run with `--run-id <id>` (also the escape hatch to adopt a run that is reported as concurrently live). Independent same-layer nodes (e.g. video clips) fan out in parallel up to `--parallel`/`--concurrency` (default 8; env `BAKER_CANVAS_CONCURRENCY`).
5841
-
5842
5914
  #### `baker canvas rerun <slug> [flags]` (deprecated)
5843
5915
 
5844
5916
  Now `baker canvas run --slug <slug>`; `rerun` stays as a hidden alias for one release and prints a deprecation note. Every `canvas run` of a creative records a portable snapshot (the canvas JSON plus the local files it ingests: prompt blueprints, composition dirs, reference images) in durable storage, so `run --slug` can rebuild `src/creatives/<slug>/` in a workspace that never had it. A snapshot whose files are exactly the last one uploaded for the creative is reused without a request, and a file the store already holds is not uploaded again. Files the snapshot could not include (missing at run time, or oversized) are listed as warnings.
@@ -5850,7 +5922,7 @@ baker canvas run --slug spring-offer --force-remote --regenerate gen
5850
5922
 
5851
5923
  #### `baker canvas stale`
5852
5924
 
5853
- Lists the creatives edited since their last render in this workspace, each with the command to re-render it: `{ ok, data: { stale: [{ slug, canvasPath, renderedRunId }] }, hints? }`. "Edited" is one content fingerprint of the creative's source: the canvas, `prompt.json`, every `scenes/sNN.json`, every local composition directory the canvas renders, and the `formats` in `_definition.md`, so a change counts however it was made. A completed `run` records the fingerprint it drew in `canvas/.rendered/<slug>.json`, and a render attempt of the current source is not asked for again until the source moves. Creatives never rendered in this workspace, and ones with an unfilled `[TODO]` asset slot, are not listed. Read-only, offline, free. The scaffold's turn-end reminder and its `validate-creative-renders` check read this list; the same fingerprint is what `sync-definition` and `run` post as `sourceSha`.
5925
+ Lists the creatives edited since their last render in this workspace, each with the command to re-render it: `{ ok, data: { stale: [{ slug, canvasPath, renderedRunId, sourceSha }] }, hints? }`. "Edited" is one content fingerprint of what the models would be sent: the canvas as it renders (after the copy, logo and scene projections), `prompt.json` without its bookkeeping (`reference`, `source_context`, `notes`), every `scenes/sNN.json`, every local composition directory the canvas renders, and the `formats` in `_definition.md`, so a change counts however it was made and reformatting a JSON file is not a change. The fingerprint is prefixed `v2:`; a render fingerprinted by the older rule (a bare sha) is never reported stale, so upgrading the CLI re-renders nothing. A completed `run` records the fingerprint it drew in `canvas/.rendered/<slug>.json`, and a render attempt of the current source is not asked for again until the source moves. Creatives never rendered in this workspace, and ones with an unfilled `[TODO]` asset slot, are not listed. Read-only, offline, free. The scaffold's turn-end reminder and its `validate-creative-renders` check read this list (at Publish it is a warning: Publish ships the last version the person was shown and never renders); the same fingerprint is what `sync-definition` and `run` post as `sourceSha`.
5854
5926
 
5855
5927
  #### `baker canvas inspect <run_id> [--thumbnails]`
5856
5928
 
@@ -5916,14 +5988,20 @@ baker canvas catalog --category audio --full
5916
5988
  // Path source — an absolute path is used as-is, and so resolves only on the machine that wrote it.
5917
5989
  // Keep canvases portable: creative canvases are validated for this (`absolute-reference`).
5918
5990
  { "id": "src", "type": "ingest", "params": { "source": "path", "path": "/abs/work/clip.mp4", "expect": "video" } }
5991
+
5992
+ // URL source — an earlier output, by its durable canvas-assets URL: the same bytes and sha back
5993
+ { "id": "src", "type": "ingest", "params": { "source": "url", "url": "https://media.withbaker.com/canvas-assets/5b/5b50…c672.mp3", "expect": "audio" } }
5919
5994
  ```
5920
5995
 
5996
+ **Never point `path` into `canvas/`.** `canvas/.cache/` and `canvas/r_<id>/` are the engine's own working folder and are never committed, so a canvas that reads from them renders only where it was written. `canvas validate` and `canvas run` rewrite such a path to the durable URL of the same bytes (reported under `normalized_paths`), keeping the file first if the store does not hold it yet.
5997
+
5921
5998
  **Strategy resolution** (deterministic from `source` + `expect` + URL extension for text):
5922
5999
 
5923
6000
  | `source` | `expect` | URL / path | Strategy |
5924
6001
  |----------|---------------|----------------------------------|-----------------|
5925
6002
  | path | any | any | `local_file` — read from disk, sniff mime, upload to R2 via presign |
5926
- | url | video / audio | any | `yt_dlp` (works on platform URLs *and* direct file URLs via the generic extractor) |
6003
+ | url | any | `…/canvas-assets/<xx>/<sha>.<ext>` | `canvas_asset` — the stored asset, verbatim, its sha checked |
6004
+ | url | video / audio | any other | `yt_dlp` (works on platform URLs *and* direct file URLs via the generic extractor; verifies TLS against the system bundle) |
5927
6005
  | url | image / json / font | any | `direct_fetch` (HTTP GET) |
5928
6006
  | url | text | ends in `.txt` or `.md` | `direct_fetch` |
5929
6007
  | url | text | anything else (HTML, PDF, …) | `firecrawl` `/v2/scrape` (extracted markdown — HTML/PDF made readable) |
@@ -6380,6 +6458,8 @@ baker creatives feedback --slug spring-offer --full
6380
6458
 
6381
6459
  Each entry carries its `source`: `review` (the buttons in Baker), `selection` (choosing or saving the version) or `chat` (what the person wrote in a chat, recorded with `feedback record`).
6382
6460
 
6461
+ With `--slug`, `data.variantChoice` (`runId`, `option`, `sha256`) says which variant of a version the person chose over our pick in Baker, and the first hint is the command that makes every size from it (the only requests re-lay it out for each other size): `baker canvas run --slug <slug> --use-variant <sha256> --from <runId>`. Once a finished run made its sizes from that picture, the choice is used up and no longer returned.
6462
+
6383
6463
  ### `baker creatives feedback record`
6384
6464
 
6385
6465
  Record what the person said about one version in a chat: an approval, or a rejection with reasons and their own words. It is stored like the Approve / Not this one buttons, as the verdict of the person who wrote the chat's latest message (`recordedFor: "sender"`), or of the chat itself when nobody has written in it (`"chat"`). One verdict per person per version or option: a later one replaces theirs. Must run from a chat.