@koda-sl/baker-cli 0.395.0-dev.f8ecbaa42 → 0.395.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 +243 -132
  2. package/canvas/static-ad-composition/index.html +3 -154
  3. package/canvas/static-ad-composition/meta.json +1 -1
  4. package/dist/{chunk-AOVFDDVW.js → chunk-BRIJ4OWT.js} +14 -2
  5. package/dist/{chunk-AOVFDDVW.js.map → chunk-BRIJ4OWT.js.map} +1 -1
  6. package/dist/{chunk-NEZ3NAJV.js → chunk-EEHNRAFA.js} +4 -4
  7. package/dist/{chunk-Q3LMLQAI.js → chunk-LHJ5IXBN.js} +3963 -2664
  8. package/dist/chunk-LHJ5IXBN.js.map +1 -0
  9. package/dist/{chunk-OKE2QJSJ.js → chunk-Q7RQ3H6Y.js} +3 -3
  10. package/dist/{chunk-T2UNK5BW.js → chunk-QP7637HG.js} +3 -3
  11. package/dist/{chunk-QIQ57EIA.js → chunk-ZWMATKNS.js} +2 -2
  12. package/dist/cli.js +11599 -11312
  13. package/dist/cli.js.map +1 -1
  14. package/dist/{client-EQOSDYJL.js → client-4P6U75EX.js} +4 -4
  15. package/dist/engine/index.d.ts +175 -120
  16. package/dist/engine/index.js +3 -3
  17. package/dist/{env-ANKM47IJ.js → env-LJHSZSNA.js} +2 -2
  18. package/dist/{output-NR4EJQDV.js → output-HVBFJX4I.js} +5 -5
  19. package/dist/{shared-SLCDVV45.js → shared-7ZEF4DIX.js} +6 -6
  20. package/package.json +2 -1
  21. package/dist/chunk-Q3LMLQAI.js.map +0 -1
  22. /package/dist/{chunk-NEZ3NAJV.js.map → chunk-EEHNRAFA.js.map} +0 -0
  23. /package/dist/{chunk-OKE2QJSJ.js.map → chunk-Q7RQ3H6Y.js.map} +0 -0
  24. /package/dist/{chunk-T2UNK5BW.js.map → chunk-QP7637HG.js.map} +0 -0
  25. /package/dist/{chunk-QIQ57EIA.js.map → chunk-ZWMATKNS.js.map} +0 -0
  26. /package/dist/{client-EQOSDYJL.js.map → client-4P6U75EX.js.map} +0 -0
  27. /package/dist/{env-ANKM47IJ.js.map → env-LJHSZSNA.js.map} +0 -0
  28. /package/dist/{output-NR4EJQDV.js.map → output-HVBFJX4I.js.map} +0 -0
  29. /package/dist/{shared-SLCDVV45.js.map → shared-7ZEF4DIX.js.map} +0 -0
package/README.md CHANGED
@@ -106,16 +106,10 @@ offered, so a short option is never pointed at an unrelated one (`--from` at `--
106
106
  Previously such an option was accepted and ignored, so a call that
107
107
  looked like it had filtered or narrowed a result was in fact answering the unfiltered
108
108
  question — and nothing in the output said so. Both spellings of an option work
109
- (`--run-id` and `--runId`), as they always did. The error's `fix` names the exact call
109
+ (`--run-id` and `--run-id`), as they always did. The error's `fix` names the exact call
110
110
  that lists every option: `baker schema <command>` (`check_schema`) when the command
111
111
  registers a schema, `baker <command> --help` (`check_help`) when it does not.
112
112
 
113
- ### Unknown commands are refused
114
-
115
- A subcommand that does not exist (say, `scaffold` under `baker canvas`) stops with `UNKNOWN_COMMAND`
116
- and exit code 1, naming the real commands closest to it and every command the parent
117
- has, instead of printing the parent's help screen. Nothing runs.
118
-
119
113
  ### Narrowing a result: `--fields`
120
114
 
121
115
  `--fields a,b,c` works on every command that prints the JSON envelope. It keeps only
@@ -3207,19 +3201,23 @@ Search the **ad-dna** corpus of scored "winning" competitor ads for reference cr
3207
3201
 
3208
3202
  ### `baker winning-ads search <query>`
3209
3203
 
3210
- 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`; plus top-level `pool_size`, `match_confidence` (`high|medium|low`), and `below_floor_count` (matches dropped under the relevance floor). `--full` adds DNA detail (`angle`, `target_persona`, `hook_archetype`, `awareness_stage`, `industry`) + longevity (`days_active`, `reach`, `active`, `winner_category`, `media_kind`). `--output json` (default) returns the lean objects; `--output md` prints a table.
3204
+ 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). `--full` adds DNA detail (`angle`, `target_persona`, `hook_archetype`, `awareness_stage`, `industry`) + longevity (`days_active`, `reach`, `active`, `winner_category`, `media_kind`). `--output json` (default) returns the lean objects; `--output md` prints a table.
3211
3205
 
3212
3206
  > `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.
3213
3207
 
3214
- > Results are already de-junked and winner-first: the corpus auto-drops measured failures (duds, thrash) and ranks `final = relevance^0.25 × winner_score^0.75`. So `--winner-category winner` is usually unnecessary — it's a stricter filter that also removes `untested` (unproven-but-maybe-fresh) ads. Rely on the default + `--min-relevance`; only add `--winner-category` to force-exclude unproven creatives. (Restricting to a single `--advertiser-id` turns the failure-exclusion off — you then see that brand's duds too.)
3208
+ > Results are already de-junked and winner-first: the corpus auto-drops measured failures (duds, thrash) and ranks `final = relevance^0.25 × winner_score^0.75`. So `--winner-category winner` is usually unnecessary — it's a stricter filter that also removes `untested` (unproven-but-maybe-fresh) ads. Rely on the default + `--min-relevance`; only add `--winner-category` to force-exclude unproven creatives. (With a query, restricting to a single `--advertiser-id` turns the failure-exclusion and the per-advertiser cap off: you then see that brand's duds too. A search with no query is a browse, which always drops failures and `untested` ads. A company that follows one brand keeps both with `--scope followed`/`followed-first`.)
3215
3209
 
3216
3210
  ```bash
3217
3211
  # Only LinkedIn video ads:
3218
3212
  baker winning-ads search "B2B lead gen demo" --platform linkedin --format video --output md
3219
- # Cross-platform, exclude our own brand + an advertiser we already mined:
3220
- baker winning-ads search "fintech onboarding" --exclude-advertiser adv_ourbrand,adv_usedbefore --output md
3213
+ # Cross-platform, excluding our own brand (an ad we already adapted comes back last, marked already_adapted):
3214
+ baker winning-ads search "fintech onboarding" --exclude-advertiser adv_ourbrand --output md
3221
3215
  # Similar-to a reference ad, recent only:
3222
3216
  baker winning-ads search --ref-ad-id a_12345 --first-seen-after 2026-01-01T00:00:00Z --limit 5 --output md
3217
+ # One varied reference per concept (different brands, containers, hooks) in one call:
3218
+ baker winning-ads search "meal kit for busy parents" --scope followed-first --diverse --limit 5 --output md
3219
+ # The best proven ads in one container, from any category (no query needed):
3220
+ baker winning-ads search --format-pattern comparison_table --platform meta --output md
3223
3221
  ```
3224
3222
 
3225
3223
  | Flag | Purpose |
@@ -3227,35 +3225,40 @@ baker winning-ads search --ref-ad-id a_12345 --first-seen-after 2026-01-01T00:00
3227
3225
  | `query` (positional) | Free-text natural-language query |
3228
3226
  | `--ref-ad-id <id>` | Find ads similar to this ad (instead of free text) |
3229
3227
  | `--limit <n>` | Max results 1–100 (**default 10** — shortlist size) |
3230
- | `--max-per-advertiser <n>` | Cap results per advertiser 1–50 (default 3) |
3228
+ | `--max-per-advertiser <n>` | Cap results per advertiser 0–50 (default 3; `0` = no cap) |
3231
3229
  | `--min-relevance <0-1>` | Relevance floor; trims weak matches |
3232
- | `--platform <list>` | `meta,linkedin` — pass a single value to search **only** that platform |
3230
+ | `--platform <list>` | `meta,linkedin,tiktok` — pass a single value to search **only** that platform |
3233
3231
  | `--format <list>` | `video,static,carousel` |
3234
3232
  | `--winner-category <list>` | `winner,scaled_winner,evergreen,rising,untested,dud,…` (default: all) |
3235
3233
  | `--awareness <list>` | `unaware,problem_aware,solution_aware,product_aware,most_aware` |
3236
3234
  | `--advertiser-id <list>` | **Restrict to** these advertiser ids (browse one brand's winners) |
3237
- | `--exclude-advertiser <list>` | **Drop** these advertiser ids — your own brand + already-used references |
3235
+ | `--exclude-advertiser <list>` | **Drop** these advertiser ids, usually your own brand |
3238
3236
  | `--country <list>` / `--language <list>` | Filter by country / language codes |
3239
- | `--first-seen-after` / `--first-seen-before` | ISO datetime bounds (recency) |
3237
+ | `--first-seen-after` / `--first-seen-before` | ISO date (`2026-01-01`, read as the start of that day in UTC) or datetime bounds (recency) |
3240
3238
  | `--hook-archetype <list>` | Narrow to ads on a proven hook **seed** (from `winning-ads hooks`) |
3239
+ | `--format-pattern <list>` | Narrow to statics in these containers (from `winning-ads formats`, e.g. `comparison_table`) |
3240
+ | `--diverse` | One varied page (different brands, containers, hooks) instead of rank order; ignores paging |
3241
3241
  | `--output json\|md\|files` | Output format (default json) |
3242
3242
  | `--full` | Include DNA detail + longevity |
3243
3243
 
3244
+ 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).
3245
+
3244
3246
  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.
3245
3247
 
3246
3248
  ### `baker winning-ads hooks|mechanisms|formats`
3247
3249
 
3248
3250
  Read the **winner-weighted rollup** of the ad-dna corpus → `GET /api/ad-library/seed-catalog` — the hook /
3249
- mechanism / static-format patterns that actually win, ranked by `winner_score`, keyed on the closed seeds/enums
3250
- the extractor emits (no clustering). Ground a creative in a **proven** pattern for the exact segment instead of
3251
- guessing. (Distinct from `winning-ads patterns`, which contrasts two ad cohorts.)
3251
+ mechanism / static-format patterns that actually win, ranked by `winner_score` among the ads of the segment you
3252
+ pass (`--platform`, `--awareness`, `--industry`; an unset one means any), keyed on the closed seeds/enums the
3253
+ extractor emits (no clustering). Ground a creative in a **proven** pattern for the exact segment instead of
3254
+ guessing. The rollup is recomputed daily. (Distinct from `winning-ads patterns`, which contrasts two ad cohorts.)
3252
3255
 
3253
3256
  ```bash
3254
3257
  # Top winning hook seeds for problem-aware SaaS on Meta:
3255
- baker winning-ads hooks --platform meta --awareness problem_aware --industry saas --output md
3258
+ baker winning-ads hooks --platform meta --awareness problem_aware --industry "Software & SaaS" --output md
3256
3259
  # Then narrow a reference search to that proven hook:
3257
3260
  baker winning-ads search "onboarding demo" --hook-archetype callout --output md
3258
- # Also: mechanisms (persuasion), formats (static ad layouts):
3261
+ # Also: mechanisms (how the product claims to work, not the ad's device), formats (static ad layouts):
3259
3262
  baker winning-ads mechanisms --platform linkedin --output md
3260
3263
  baker winning-ads formats --awareness solution_aware --output md
3261
3264
  ```
@@ -3264,7 +3267,7 @@ baker winning-ads formats --awareness solution_aware --output md
3264
3267
  |---|---|
3265
3268
  | `--platform <p>` | Segment on a single platform |
3266
3269
  | `--awareness <stage>` | Segment on a single awareness stage |
3267
- | `--industry <seed>` | Segment on a single industry seed |
3270
+ | `--industry <seed>` | Segment on a single industry seed from the extractor vocabulary (`seedVocabularies.yaml`), any case; a term naming exactly one seed resolves to it (`saas` → `Software & SaaS`), an ambiguous or unknown one is refused with the valid list |
3268
3271
  | `--limit <n>` | Max keys 1–100 (default 20) |
3269
3272
  | `--full` | Include the per-key segment breakdown |
3270
3273
 
@@ -3933,7 +3936,7 @@ baker studio animate "she keeps walking, camera stays with her, then she stops a
3933
3936
  - **480p / 720p only** — 1080p and 4K are real Seedance tiers but not on this route.
3934
3937
  - **Sound costs nothing extra** here, unlike every other model that offers it.
3935
3938
  - **No watermark** unless `--watermark` asks for one; it cannot be removed after the render.
3936
- - **No people from an uploaded picture.** A privacy pre-check refuses any recognisable face in the input image — invented characters included, and images from ByteDance's own image model included. Clips that hit it are re-rendered automatically on Gemini Omni (reference mode) or Veo (frame mode).
3939
+ - **No people from an uploaded picture.** A privacy pre-check refuses any recognisable face in the input image — invented characters included, and images from ByteDance's own image model included. A reference-mode clip that hits it is re-rendered automatically on the same model through a second route that accepts faces (about twice the price); a frame-mode clip is reported as refused and never moved to another model — re-run it on Veo.
3937
3940
 
3938
3941
  | Flag | Description |
3939
3942
  |---|---|
@@ -4066,43 +4069,14 @@ baker canvas catalog --node video_generate
4066
4069
  # multi-line / accented copy; the edit is re-validated before it is written back)
4067
4070
  baker canvas set-prompt my-canvas.json s0_start --text "Close-up, flushed and breathless"
4068
4071
  baker canvas set-prompt my-canvas.json s0_start --text-file ./hook-frame.txt
4069
-
4070
- # 6. Set a static ad's words without editing prompt.json by hand. No flags lists
4071
- # every line with its role and layout region; flags change them in the copy
4072
- # and the layout together and keep everything else in prompt.json.
4073
- baker canvas copy spring-offer
4074
- baker canvas copy spring-offer --headline "Nine days, not 31." --cta "Book your free survey" \
4075
- --line body="Scaffolding included" --line body="MCS certificate included" \
4076
- --line eyebrow@header="ROOFTOP SOLAR" --treatment cta=pill --remove caption --remove-logo
4077
4072
  ```
4078
4073
 
4079
- `baker canvas copy <slug>` flags:
4080
-
4081
- | Flag | What it does |
4082
- |---|---|
4083
- | `--headline`, `--sub`, `--cta` | Shorthand for `--line headline=…`, `--line subheadline=…`, `--line cta=…` |
4084
- | `--line <role>[@<region>]=<text>` | Repeatable. A role you name gets exactly the lines you give, in order; old extra ones are dropped. A new line goes to the region of its role's last line, or the region you name. Roles: `headline`, `subheadline`, `body`, `cta`, `caption`, `fine_print`, `eyebrow` |
4085
- | `--remove <role>` | Repeatable. Drops every line of that role |
4086
- | `--treatment <role>=<kind>` | `plain`, `underline`, `arrow`, `button`, `pill`, `sticker`, `highlight` |
4087
- | `--color <role>=<#rrggbb>` | The ink of that role's lines |
4088
- | `--remove-logo` | Draws the ad without a logo |
4089
- | `--mechanism "<verbal\|visual\|structural>: <device>"` | Repeatable. Replaces the reference's winning mechanisms with ours |
4090
- | `--alternate "<hook>"` | Repeatable. Records a hook alternate on the creative's `_definition.md` |
4091
- | `--spec <file.json>` | The same fields as JSON, for long copy |
4092
- | `--dry-run` | Prints the changes without writing |
4093
-
4094
- It refuses, before writing anything, a role it does not know, a region the layout does not have, a new
4095
- line with no region to go to, and copy that says the same line twice. Replacing the headline that is
4096
- the creative's recorded `hook` keeps the old one in `hookAlternates` and makes the new one the hook.
4097
- The output lists each change, the resulting copy, what was recorded, and the next command
4098
- (`baker canvas run <slug> --draft`).
4099
-
4100
4074
  #### The brand, the ending and the cast are drawn, not generated
4101
4075
 
4102
4076
  `baker canvas scaffold-ad` renders everything with a known, fixed appearance locally,
4103
4077
  because a video model garbles a wordmark every time:
4104
4078
 
4105
- - **`brand.logo`** — the repo path to the mark. It is copied into the overlay composition
4079
+ - **`brand.logo`** — the repo path to the mark. It is copied into the caption composition
4106
4080
  and drawn as a real `<img>`: small top-left for the whole ad, large on the closing card.
4107
4081
  - **`end_card`** — on by default whenever the spec carries a brand. The last beat's PICTURE
4108
4082
  becomes a flat brand colour plate (rendered by ffmpeg, not generated), with the mark and
@@ -4122,9 +4096,24 @@ because a video model garbles a wordmark every time:
4122
4096
  alongside the music bed. Most beats should not have one: it belongs where the picture
4123
4097
  shows a specific physical event (a drill, a door, rain on glass), not on a talking head
4124
4098
  or a calm lifestyle shot. A bed plus two well-placed effects beats eight.
4125
- - **A real brand mark is required.** `scaffold-ad` refuses to build without one in
4126
- `src/brand/logos/` (SVG or PNG): the mark is drawn on every frame and on the closing
4127
- card, so a stand-in ships an ad wearing the wrong brand.
4099
+ - **The logo is optional.** With no mark in `src/brand/logos/` (and none confirmed for the
4100
+ company's own domain), the ad is built without the corner mark, and one hint says so and
4101
+ how to add one: put the client's logo file in `src/brand/logos/` and re-run. Meta shows
4102
+ the page name and picture above every ad either way. The closing card still carries the
4103
+ call to action when the spec has a `cta`; with neither a mark nor a `cta`, the last beat
4104
+ keeps its footage. A typed stand-in (an SVG wordmark in live `<text>`) is still refused,
4105
+ and `--no-brand` still means no brand layer at all: no mark and no closing card.
4106
+ - **Captions and the mark sit inside the safe area.** On 9:16 the caption cards end above
4107
+ the bottom 37% of the frame (Reels covers the bottom 35%) and the mark starts below the
4108
+ top 16% (the Stories and Reels header); 4:5 and 1:1 use modest feed margins. The closing
4109
+ card is centred in the same area. Every composition is sized to the spec's
4110
+ `format.aspect_ratio`, so a 4:5 ad lays its captions out on a 4:5 frame.
4111
+ - **Captions and the closing button are set in the brand's face**: a font file in
4112
+ `src/brand/fonts/`, else the typeface `tokens.json` or `BRAND.md` names, loaded as a web
4113
+ font. With neither, they keep the default heavy sans.
4114
+ - **A spoken ad's captions print the script.** The voice track is transcribed only to time
4115
+ the words: each caption word is the `say` line as written, at the moment it was said, so
4116
+ a word the recogniser mishears never reaches the screen.
4128
4117
  - **An avatar with no pinned voice cannot speak on camera.** Their lines would be read by
4129
4118
  a separately cast voice, which is dubbing and looks like it.
4130
4119
  - **`voiceover: false`** — a music-led ad. No voice, no transcription: the `say` lines
@@ -4139,6 +4128,13 @@ because a video model garbles a wordmark every time:
4139
4128
 
4140
4129
  Each of these also removes a generation: the closing beat costs no image and no clip.
4141
4130
 
4131
+ **The spec is kept, and the canvas is checked.** `scaffold-ad` writes the spec it built
4132
+ from beside the canvas as `ad.spec.json` (with the cast it resolved), so a later change
4133
+ edits that file and re-runs with `--spec` on it and the same `--slug`. With `--slug` it
4134
+ also writes the creative's `_definition.md` and syncs its workflow graph, as
4135
+ `scaffold-video` does. The canvas is validated before the command reports success: a
4136
+ blocking issue is recorded in the canvas and the command exits `2` with the issues.
4137
+
4142
4138
  #### The finished video is reviewed
4143
4139
 
4144
4140
  When a run's output is a video, `baker canvas run` samples twelve frames across it and
@@ -4229,6 +4225,8 @@ The top-level `output` declares the canvas's final result — the engine returns
4229
4225
 
4230
4226
  Every node shares the envelope `{ id, type, inputs?, params? }`. `inputs` wires other nodes' output slots in; `params` configures the node. The validator enforces required input kinds and per-model param shape **before** anything is billed.
4231
4227
 
4228
+ A node may also carry `tags` (`{ scene?, role?, region?, on_camera? }`) stating what it is in the ad (scene 3, region 1, the presenter's clip, the spine). `scaffold-video` stamps them and `canvas validate` reads them in place of id conventions like `s3_r1_clip`. They are descriptive only: tags never enter the cache key, so adding or editing them never re-renders or re-bills a node. Canvases without tags validate as they always did.
4229
+
4232
4230
  `baker canvas catalog` prints a one-line index of every node; `baker canvas catalog --node <id>` prints that node's always-current machine-readable schema (params, inputs, outputs, every model), and `--full` prints every node's. The tables below mirror the catalog at time of write.
4233
4231
 
4234
4232
  In every table:
@@ -4283,6 +4281,44 @@ Local ffmpeg passthrough. Write the argv you'd type, declare outputs, the engine
4283
4281
 
4284
4282
  ---
4285
4283
 
4284
+ ##### `video_concat`
4285
+
4286
+ Join clips and stills into one continuous video: the spine of a video ad. Every input is normalized onto one raster (size, frame rate, square pixels, `yuv420p`), cut or held to its timeline length, and joined with hard cuts or `xfade` transitions. Local ffmpeg, free. Because the length and size live in params, `canvas validate` sums the spine against the timeline (`VIDEO_TIMELINE_TOTAL_MISMATCH`) before anything renders.
4287
+
4288
+ **Inputs:** one video or image per clip, wired as `c0`, `c1`, … in timeline order (deep validation flags a missing or extra slot).
4289
+
4290
+ **Params:**
4291
+
4292
+ | Name | Type | Required | Notes |
4293
+ |---|---|---|---|
4294
+ | `width`, `height` | integer | yes | output raster; must be even (libx264 `yuv420p` rejects odd sizes) |
4295
+ | `fps` | integer | no | default `30` |
4296
+ | `seam_dedup` | `head` \| `tail` \| `off` | no | default `head`: which copy of a repeated splice frame survives on a `continues_frame` clip; the timeline length never changes |
4297
+ | `clips` | array | yes | per clip: `duration_s` (its timeline length), `length` (`source` default: play as rendered; `trim`: cut out of a longer input from `offset_s`; `still`: hold an image), `fit` (`fill` default crops, `pad` letterboxes a screenshot), `transition` (`{ type, duration_s }`, the xfade out of this clip; the clip carries that much extra footage), `continues_frame` |
4298
+
4299
+ The joined length is the sum of the clips' `duration_s` (a `source` clip counts what is wired into it). **Outputs:** `video` (mp4).
4300
+
4301
+ ---
4302
+
4303
+ ##### `av_mux`
4304
+
4305
+ Put the soundtrack under a finished picture: the last step of a video ad. The video is stream-copied, the audio loudness-normalized, upmixed to stereo and encoded to AAC. Local ffmpeg, free.
4306
+
4307
+ **Inputs:** `video`, `audio`.
4308
+
4309
+ **Params:**
4310
+
4311
+ | Name | Type | Required | Notes |
4312
+ |---|---|---|---|
4313
+ | `loudnorm` | `{ i, tp, lra }` \| `false` | no | default `{ i: -14, tp: -1.5, lra: 11 }` (the social target); `false` keeps the mix level |
4314
+ | `audio_bitrate` | string | no | default `"192k"` |
4315
+ | `sample_rate` | integer | no | default `48000` |
4316
+ | `length` | `"shortest"` \| `{ total_s }` | no | default `shortest`: end with the shorter stream; `{ total_s }` cuts both at that second |
4317
+
4318
+ **Outputs:** `video` (mp4).
4319
+
4320
+ ---
4321
+
4286
4322
  ##### `imagemagick`
4287
4323
 
4288
4324
  Local ImageMagick passthrough. Identical schema and safety contract to `ffmpeg`; uses `magick` (v7+) or `convert` (v6) on PATH.
@@ -4517,7 +4553,7 @@ No `image_size`: OpenAI derives the pixel dimensions from the ratio, so a size i
4517
4553
 
4518
4554
  ###### Model: `openai/gpt-5.4-image-2` (legacy)
4519
4555
 
4520
- Kept only so canvases authored before the gpt-image-2 switch still validate and re-run unchanged — `baker canvas rerun` replays a recorded canvas verbatim. It keeps the full standard ratio set (including `4:5` / `5:4`) and still accepts `image_size`, which the provider ignores. Prefer `openai/gpt-image-2` for anything new.
4556
+ Kept only so canvases authored before the gpt-image-2 switch still validate and re-run unchanged — `baker canvas run --slug` replays a recorded canvas verbatim. It keeps the full standard ratio set (including `4:5` / `5:4`) and still accepts `image_size`, which the provider ignores. Prefer `openai/gpt-image-2` for anything new.
4521
4557
 
4522
4558
  ###### Model: `google/gemini-3.1-flash-image` (legacy id `google/gemini-3.1-flash-image-preview` still accepted)
4523
4559
 
@@ -4871,7 +4907,7 @@ Accepted ref-image MIMEs vary by model — see per-model sections below.
4871
4907
  |---|---|---|
4872
4908
  | `video` | video | `video/mp4` |
4873
4909
 
4874
- **Cost** — 50 credits estimate per call.
4910
+ **Cost** — priced per model from the shared contract table (duration x resolution x audio; the fal route and source-clip premium when they apply), the same table the backend charges from. One clip may not cost more than $20: the backend refuses a dearer one before it runs, and no clip the registry allows reaches it.
4875
4911
 
4876
4912
  ---
4877
4913
 
@@ -4935,7 +4971,7 @@ Ref-image MIMEs: `image/png`, `image/jpeg`, `image/webp`.
4935
4971
  |---|---|---|---|
4936
4972
  | `model` | literal | yes | `"bytedance/seedance-2.0"` |
4937
4973
  | `prompt` | string | yes | non-empty |
4938
- | `duration` | enum | no | `4, 5, 6, 8, 10, 12, 15` (gaps trigger 400s) |
4974
+ | `duration` | enum | no | every whole second `4`-`15` |
4939
4975
  | `resolution` | enum | no | `480p \| 720p \| 1080p` |
4940
4976
  | `aspect_ratio` | enum | no | `1:1, 3:4, 9:16, 4:3, 16:9, 21:9, 9:21` |
4941
4977
  | `generate_audio` | boolean | no | native audio track |
@@ -4978,18 +5014,18 @@ Ref-image MIMEs: `image/png`, `image/jpeg`, `image/webp`, `image/gif` (via OpenR
4978
5014
 
4979
5015
  > **Switching a clip's model has cross-node constraints** — validate after every model edit. `validate` gates all of these before any billed run:
4980
5016
  > - **One aspect_ratio for the whole canvas.** Every `video_generate` node must agree (`VIDEO_ASPECT_MISMATCH`), or the composite silently crops/letterboxes. Switching one clip to Veo (which only offers `16:9`/`9:16`) forces every other clip to a shared ratio too.
4981
- > - **`duration` is a per-model hard enum.** Seedance `4,5,6,8,10,12,15`; Veo `4,6,8` only. A value outside the model's set is rejected; a scene whose natural span exceeds the model's max is flagged (`VIDEO_SPAN_EXCEEDS_MODEL`, advisory) — a 9.2s scene can't fit Veo's 8s cap and would truncate; split the scene or keep it on a longer-max model.
5017
+ > - **`duration` is a per-model hard enum.** Seedance 2.0 `4`-`15`, Seedance 2.5 `4`-`30`, Kling `3`-`15`, Omni `3`-`10`, Veo `4,6,8` only. The scaffolders snap clip lengths to the same registry sets. A value outside the model's set is rejected; a scene whose natural span exceeds the model's max is flagged (`VIDEO_SPAN_EXCEEDS_MODEL`, advisory) — a 9.2s scene can't fit Veo's 8s cap and would truncate; split the scene or keep it on a longer-max model.
4982
5018
  > - **`person_generation` differs.** Veo **image-to-video** accepts only `allow_adult` (the sole legal value for keyframe-driven modes, and the only one permitted in the EU/UK); `allow_all` is text-to-video only. The scaffolder sets `allow_adult` on every Veo clip automatically (`VIDEO_PERSON_GENERATION_MISSING` warns if it's missing on a hand-edited canvas).
4983
5019
  >
4984
5020
  > A scaffolded canvas carries this table inline at `metadata.todo.model_constraints`.
4985
5021
 
4986
- > **Per-model prompt & param profiles (applied automatically).** The scaffolder shapes each clip/frame prompt to the model's documented best practice and sets its request-param levers — see `packages/cli/src/engine/scaffold/lib/prompt-profiles.ts`:
5022
+ > **Per-model prompt & param profiles (applied automatically).** The scaffolder shapes each clip/frame prompt to the model's documented best practice and sets its request-param levers — see `packages/cli/src/engine/lib/prompt-profiles.ts`:
4987
5023
  > - **Seedance 2.0** (default video): dialogue in quotes with delivery in **prose** (not `[brackets]`), a `Preserve the composition and colors` keyframe hold, and an **affirmative** stability tail — `Keep it clean and stable: crisp anatomically-correct hands, one consistent face, smooth steady motion` — instead of an `Avoid: …` list (Seedance has no `negative_prompt` param, and inline negation tends to summon the artifact it names). Prompt kept to the ~120-word quality budget (`VIDEO_PROMPT_OVER_BUDGET` warns past ~120).
4988
5024
  > - **Veo 3.1** (fallback): `person_generation=allow_adult` + a caption-suppressing `negative_prompt` set as params; durations snapped to `{4,6,8}`.
4989
5025
  > - **gpt-image** (default frames): clean-plate exclusions placed **LAST** (OpenAI weights early text as composition), an explicit `photorealistic` cue, identity-critical reference **first**, and `quality=high` (gpt-image-2 forces high input fidelity automatically — the `input_fidelity` param is deliberately **not** sent). Gemini keeps exclusions inline; Recraft drops the photoreal cue.
4990
5026
  > - New advisories: `VIDEO_RAW_FACE_KEYFRAME` (a Seedance clip animating a RAW ingested image — real-person 422 risk), `IMAGE_PROMPT_PROFILE_MISSING`, `VIDEO_PROMPT_PROFILE_MISSING`.
4991
5027
 
4992
- > **Content-policy blocks are deterministic, not flaky — and now auto-recover.** ByteDance's Seedance rejects any first/last frame that reads as a real-person likeness (even an AI-generated face) — surfaced as `content_policy_blocked` (HTTP 422/`E005` "flagged as sensitive", **non-retryable on the same model**). This is ByteDance's upstream filter, so it fires on **any** reseller (Replicate or otherwise) — a provider swap does **not** route around it. **When a Seedance `video_generate` clip is blocked, the backend automatically re-renders that same clip on `google/veo-3.1-fast` (`person_generation: allow_adult`) — the one curated model that clears the filter — so the run still completes.** The Seedance attempt is never billed (its output was never finalized); only the Veo render is charged. To skip that wasted first attempt entirely (and control look/cost), scaffold a photoreal-cast ad directly on Veo up front (`--real-face`, which also covers AI-generated photoreal faces), or make the source frame less photorealistic.
5028
+ > **Content-policy blocks are deterministic, not flaky.** ByteDance's Seedance rejects a first/last frame or reference that reads as a real-person likeness (even an AI-generated face), surfaced as `content_policy_blocked` (HTTP 422/`E005` "flagged as sensitive", **non-retryable on the same route**). A blocked clip is **not** moved to another model: one guided only by reference images is re-rendered on the same Seedance model through the fal route, which accepts faces (priced at that route, about twice the rate); one that starts from a frame fails with `content_policy_blocked`, nothing billed, and is re-run on `google/veo-3.1` (`person_generation: allow_adult`). To skip the wasted attempt, scaffold a photoreal-cast ad on Veo up front (`--real-face`, which also covers AI-generated photoreal faces), or make the source frame less photorealistic.
4993
5029
 
4994
5030
  ---
4995
5031
 
@@ -5008,7 +5044,7 @@ None.
5008
5044
  | `audio` | audio | provider-determined (mp3 by default) |
5009
5045
  | `timestamps` | json | `application/json` — present only when `with_timestamps: true` |
5010
5046
 
5011
- **Cost** — `max(1, ceil(text.length × 0.0015))` credits.
5047
+ **Cost** — ElevenLabs characters x $0.00022 (0.022 credits a character), rounded up to whole credits; the backend charges the same rate on the character count ElevenLabs reports.
5012
5048
 
5013
5049
  ###### Model: `elevenlabs/eleven_v3`
5014
5050
 
@@ -5040,7 +5076,7 @@ None.
5040
5076
 
5041
5077
  ##### `video_lipsync`
5042
5078
 
5043
- Lip-sync a video to an audio track via Sync Labs `sync/lipsync-2` (Replicate).
5079
+ Lip-sync a video to an audio track. Model `replicate/sync-lipsync-2`, which runs Sync Labs `sync/lipsync-2` on Replicate.
5044
5080
 
5045
5081
  **Inputs**
5046
5082
 
@@ -5053,7 +5089,7 @@ Lip-sync a video to an audio track via Sync Labs `sync/lipsync-2` (Replicate).
5053
5089
 
5054
5090
  | Name | Type | Required | Notes |
5055
5091
  |---|---|---|---|
5056
- | `model` | literal | yes | `"fal/veed-lipsync"` |
5092
+ | `model` | enum | yes | `"replicate/sync-lipsync-2"`. The legacy id `"fal/veed-lipsync"` is still accepted and runs the same model. |
5057
5093
 
5058
5094
  **Outputs**
5059
5095
 
@@ -5061,7 +5097,7 @@ Lip-sync a video to an audio track via Sync Labs `sync/lipsync-2` (Replicate).
5061
5097
  |---|---|---|
5062
5098
  | `video` | video | `video/mp4` |
5063
5099
 
5064
- **Cost** — 20 credits per call.
5100
+ **Cost** — $0.40 per minute of the longer input (40 credits for a minute, the quote's assumption when the clips carry no duration). Runs as a background job.
5065
5101
 
5066
5102
  ---
5067
5103
 
@@ -5079,8 +5115,8 @@ Strip a video's background → transparent alpha WebM (VP9) or MOV (ProRes 4444)
5079
5115
 
5080
5116
  | Name | Type | Required | Default | Notes |
5081
5117
  |---|---|---|---|---|
5082
- | `model` | literal | no | `"fal/veed-video-background-removal"` | — |
5083
- | `edge_refinement` | boolean | no | `true` | — |
5118
+ | `model` | enum | no | `"replicate/birefnet-video"` | The legacy id `fal/veed-video-background-removal` is still accepted and runs the same model. |
5119
+ | `edge_refinement` | boolean | no | `true` | Refines soft edges against background bleed (the model's `refine_fg`). Off bills the lower rate. |
5084
5120
  | `output_codec` | enum | no | `"vp9"` | `vp9 \| h264` |
5085
5121
 
5086
5122
  **Outputs**
@@ -5089,7 +5125,7 @@ Strip a video's background → transparent alpha WebM (VP9) or MOV (ProRes 4444)
5089
5125
  |---|---|---|
5090
5126
  | `video` | video | `video/webm` (VP9 + alpha) or `video/mp4` (H264 RGB + alpha) |
5091
5127
 
5092
- **Cost** — 50 credits per call.
5128
+ **Cost** — $0.012 per 30 frames with edge refinement, $0.008 without, at 30fps (36 credits for the 30s the quote assumes). Runs as a background job.
5093
5129
 
5094
5130
  ---
5095
5131
 
@@ -5110,6 +5146,7 @@ Word-level transcription of a video OR a bare audio track (the `video` input acc
5110
5146
  | `language` | string | no | auto | ISO 639-1, 2–8 chars (e.g. `en`, `es`) |
5111
5147
  | `transcriber` | enum | no | `groq` | `groq` (Whisper) \| `deepgram` (Nova-3, adds the `rich` output) |
5112
5148
  | `keyterms` | string[] | no | — | Up to 30 names the recogniser must not mangle — a product, a person, a phrase the script coins. The **company's own name is always applied server-side**, so it never needs listing here |
5149
+ | `script` | string | no | — | The words the track was asked to say. Given, the `transcript` output is the script's words, spelled as written, at the times the recogniser heard them: aligned locally (misheard words, fillers, accents and numbers said as words are all tolerated), never sent to the recogniser. `scaffold-ad` sets it on every spoken ad |
5113
5150
 
5114
5151
  **Outputs**
5115
5152
 
@@ -5120,7 +5157,7 @@ Word-level transcription of a video OR a bare audio track (the `video` input acc
5120
5157
 
5121
5158
  **Known names are spelled the way the brand spells them.** Every term (the company's own name, plus `keyterms`) is biased into the request — Deepgram `keyterm`, Whisper `prompt` — and its spelling is then enforced on the words that come back, because bias is a probability and a caption prints the mistake at full size over the ad. Verified against the take that shipped wrong: the same Spanish clip returns "Cubicap" bare and "Cubicup" with the name applied. Corrections are listed on the output's `brand_spelling_corrections` metadata, so a rewrite is visible rather than silent. A short name (under 6 characters) gets the bias only — at that length ordinary words sit one edit away, and rewriting those to protect a brand would corrupt the line. **Never patch a mishearing inside a caption composition**: that fix dies with that one creative.
5122
5159
 
5123
- **Cost** — 2 credits estimate; backend reports actual based on duration (Groq: $0.04/hr, 10s minimum; Deepgram Nova-3: $0.0043/min).
5160
+ **Cost** — Groq: $0.04/hr, 10s minimum; Deepgram Nova-3: $0.0043/min. Quoted on one minute of audio; the backend charges the transcribed duration.
5124
5161
 
5125
5162
  **Requirements** — `ffmpeg` on PATH for audio extraction (already in the E2B sandbox image). Without it the node still works but uploads the full video and Groq's 100 MB cap applies.
5126
5163
 
@@ -5251,7 +5288,7 @@ Place and mix several audio clips onto one timeline — a music bed plus timed v
5251
5288
 
5252
5289
  ##### `image_background_remove`
5253
5290
 
5254
- Strip background → transparent PNG. Powered by `men1scus/birefnet` (Replicate). (Note: mask-only output is no longer produced.)
5291
+ Strip background → transparent PNG. Model `replicate/birefnet`, which runs `men1scus/birefnet` on Replicate. The model takes an image and a resolution only: it has no variants and returns no mask.
5255
5292
 
5256
5293
  **Inputs**
5257
5294
 
@@ -5263,19 +5300,18 @@ Strip background → transparent PNG. Powered by `men1scus/birefnet` (Replicate)
5263
5300
 
5264
5301
  | Name | Type | Required | Default | Notes |
5265
5302
  |---|---|---|---|---|
5266
- | `model` | literal | no | `"fal/birefnet-v2"` | — |
5267
- | `model_variant` | enum | no | `"General Use (Light)"` | `General Use (Light) \| General Use (Heavy) \| Matting \| Portrait \| DIS \| HRSOD \| COD` |
5303
+ | `model` | enum | no | `"replicate/birefnet"` | The legacy id `fal/birefnet-v2` is still accepted and runs the same model. |
5268
5304
  | `operating_resolution` | enum | no | — | `1024x1024 \| 2048x2048 \| 2304x2304` |
5269
- | `mask_only` | boolean | no | `false` | return binary mask instead of alpha-cut |
5305
+ | `model_variant` | enum | no | — | **Deprecated, ignored.** Accepted so older canvases still parse. |
5306
+ | `mask_only` | boolean | no | — | **Deprecated, ignored.** The cutout is always returned; there is no mask. |
5270
5307
 
5271
5308
  **Outputs**
5272
5309
 
5273
5310
  | Slot | Kind | MIME |
5274
5311
  |---|---|---|
5275
5312
  | `image` | image | `image/png` (with alpha) |
5276
- | `mask` | image | `image/png` (only when `mask_only: true`) |
5277
5313
 
5278
- **Cost** — 1 credit per call.
5314
+ **Cost** — 1 credit per call. Runs as a background job.
5279
5315
 
5280
5316
  ---
5281
5317
 
@@ -5298,7 +5334,7 @@ Generate music from prompt OR score an existing video clip.
5298
5334
  | `audio` | audio | provider-determined |
5299
5335
  | `timestamps` | json | `application/json` (optional) |
5300
5336
 
5301
- **Cost** — `max(1, ceil(seconds × 2.2))` credits (defaults to 30s if `music_length_ms` unset).
5337
+ **Cost** — $0.022 per second of audio (2.2 credits a second), 30s when `music_length_ms` is unset; rounded up to whole credits once, not per second. `elevenlabs/video-background-music-v1` runs as a background job.
5302
5338
 
5303
5339
  ###### Model: `elevenlabs/music-v1`
5304
5340
 
@@ -5356,7 +5392,7 @@ None.
5356
5392
 
5357
5393
  Same as `tts`.
5358
5394
 
5359
- **Cost** — `max(1, ceil(total_chars × 0.0015))` credits.
5395
+ **Cost** — total characters x $0.00022 (0.022 credits a character), rounded up to whole credits.
5360
5396
 
5361
5397
  ---
5362
5398
 
@@ -5385,7 +5421,7 @@ None.
5385
5421
  |---|---|---|
5386
5422
  | `audio` | audio | provider-determined |
5387
5423
 
5388
- **Cost** — `max(1, ceil(seconds × 2.2))` credits.
5424
+ **Cost** — $0.022 per second of audio (2.2 credits a second), 5s when `duration_seconds` is unset.
5389
5425
 
5390
5426
  ---
5391
5427
 
@@ -5460,16 +5496,18 @@ Cache policies (via `--cache-policy`):
5460
5496
  ### Safety limits
5461
5497
 
5462
5498
  - **Asset size cap: 2 GiB per asset.** Applies to every download (remote node outputs, `ingest` fetches, yt-dlp output) and to local files ingested via `source: "path"`. Oversized assets fail the node with a `file_too_large` / byte-limit error.
5463
- - **Per-request cost cap: $10 of estimated provider cost.** The backend pre-estimates cost for billable nodes (`video_generate` on Seedance, `tts`, `dialogue`, `music`, `sound_effect`) and rejects a single execution above the cap with `cost_limit_exceeded`. Split long renders or long scripts into smaller nodes.
5499
+ - **Per-request cost cap: $20 of estimated provider cost for a `video_generate` clip, $10 for every other node.** The backend pre-estimates cost from the shared contract table (for a Seedance clip, on the dearest route it may take) and rejects a single execution above the cap with `cost_limit_exceeded`. No clip the registry allows exceeds $20; split long audio or long scripts into smaller nodes.
5464
5500
  - **Retries never double-charge.** Every remote node execution carries a run-scoped idempotency key; if a request is retried after a transient failure, the backend replays the stored response instead of dispatching (and billing) the provider again. A fresh run (including `--cache-policy bypass`) always generates fresh.
5465
5501
 
5466
5502
  ---
5467
5503
 
5468
5504
  ### CLI commands
5469
5505
 
5470
- #### `baker canvas validate <slug|file.json>`
5506
+ #### `baker canvas validate <file.json>`
5471
5507
 
5472
- Takes a creative's slug (`baker canvas validate <slug>` reads `src/creatives/<slug>/<slug>.canvas.json`) or a canvas path; a slug that names no creative fails with `not_found` and lists the creatives there are. 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.
5508
+ 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.
5509
+
5510
+ **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 spec files the frames read (`prompt.style.json`, `prompt.plate.json`, `prompt.compose.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.
5473
5511
 
5474
5512
  ```bash
5475
5513
  $ baker canvas validate my-canvas.json
@@ -5484,7 +5522,7 @@ $ baker canvas validate my-canvas.json
5484
5522
  }
5485
5523
  ```
5486
5524
 
5487
- On error you get an array of `ValidationIssue`s, each with `path`, `code`, `message`, and optional `did_you_mean`.
5525
+ On error you get `{ ok: false, error: { code: "validation", issues } }`, each issue with `path`, `code`, `message`, and optional `did_you_mean`.
5488
5526
 
5489
5527
  **Absolute asset paths are healed, not reported.** A `source: "path"` ingest (or a `hyperframe_render`
5490
5528
  composition) written as an absolute path inside the workspace is rewritten to its canvas-relative form
@@ -5509,13 +5547,14 @@ names no camera/motion move **and** no exclusions — the six-decision doctrine)
5509
5547
  (the hook, scene 0, is thin across two or more of the four hook layers — text/sound/visual/vibe). Warnings never
5510
5548
  block a run.
5511
5549
 
5512
- #### `baker canvas critique <slug|file.json>`
5550
+ #### `baker canvas critique <file.json>`
5513
5551
 
5514
- Takes a creative's slug or a canvas path, like `validate`. Pre-spend creative critic — **advisory, never blocks**. Scores a scaffolded creative 0–1 before a billed `run`,
5552
+ Pre-spend creative critic — **advisory, never blocks**. Scores a scaffolded creative 0–1 before a billed `run`,
5515
5553
  so a weak spot gets fixed for free. It branches on modality: a **video** creative is scored on **hook strength**,
5516
5554
  **sound-off first-frame legibility**, **retention risk**, and **mechanism clarity**; a **static ad** on **baked-text
5517
5555
  legibility risk**, **identity grounding** (Rule 1 — every subject on a real reference), **brand-type fidelity** (a
5518
- font specimen wired), and **mechanism clarity**. Reads the canvas only (no generation, no Convex). Ground a video
5556
+ font specimen wired), and **mechanism clarity**. Scores the canvas after the shared preflight (so scene and copy
5557
+ edits are what gets scored); no generation, no Convex. Ground a video
5519
5558
  hook against a proven pattern with `baker winning-ads hooks`.
5520
5559
 
5521
5560
  ```bash
@@ -5528,23 +5567,28 @@ $ baker canvas critique my-static-ad.json
5528
5567
  "dimensions": [ { "dimension": "text_legibility_risk", "score": 1, "note": "2 copy block(s) — reads cleanly" }, … ] }
5529
5568
  ```
5530
5569
 
5531
- #### `baker canvas run <slug|file.json> [flags]`
5570
+ #### `baker canvas run <file.json> [flags]` / `baker canvas run --slug <slug> [flags]`
5571
+
5572
+ Validate, then execute the graph. Blocks until done. Logs one line per node. Returns `{ ok, run_id, outputs_dir, output, stats }` on stdout.
5532
5573
 
5533
- Takes a creative's slug (`baker canvas run spring-offer`) or a canvas path, resolved like `validate`. Validate, then execute the graph. Blocks until done. Logs one line per node. Returns `{ ok, run_id, outputs_dir, output, stats }` on stdout.
5574
+ `--slug <slug>` runs a creative by name instead of by file: its `src/creatives/<slug>/<slug>.canvas.json` when this workspace has it, else its files restored from the newest run that recorded a portable snapshot (sha-verified, into `src/creatives/<slug>/`), then the normal run flow. Local files are never overwritten unless `--force-remote` is passed. This replaces `baker canvas rerun <slug>`, which still works as a deprecated alias.
5575
+
5576
+ A completed render the review calls ready (`verdict` `ready` or `ready_with_notes` for a static ad, no blocking finding for a video) sets `status: rendered` in the creative's `_definition.md` (never moving a `published` or `archived` creative back); the output then carries `definition_status: "rendered"`.
5534
5577
 
5535
5578
  | Flag | Default | Effect |
5536
5579
  |---|---|---|
5537
- | `--cache-dir <path>` | `<workspace root>/canvas/.cache` | Where content-addressed bytes + cache index live. |
5538
- | `--outputs-dir <path>` | `<workspace root>/canvas` | Root for per-run output directories. |
5580
+ | `--slug <slug>` | none | Run a creative by name (see above). |
5581
+ | `--force-remote` | off | With `--slug`: restore the recorded files even over local ones. |
5539
5582
  | `--run-id <id>` | auto ULID | Override the generated run id. |
5540
5583
  | `--cache-policy <policy>` | `read_write` | `read_write`, `bypass`, or `read_only`. |
5541
5584
  | `--concurrency <n>` | `5` (or `BAKER_CANVAS_CONCURRENCY`) | Max nodes executing at once within a layer. |
5542
- | `--remote-cache <on\|off>` | `on` (or `BAKER_CANVAS_REMOTE_CACHE`) | Company-scoped remote cache + durable asset persistence. |
5585
+ | `--remote-cache <on\|off>` | `on` (or `BAKER_CANVAS_REMOTE_CACHE`) | Operator only. Company-scoped remote cache + durable asset persistence. |
5543
5586
  | `--max-credits <n>` | uncapped (or `BAKER_CANVAS_MAX_CREDITS`) | Credit ceiling: aborts before billing when the estimate exceeds it, and at layer boundaries once actual spend does. Completed nodes stay cached, so retrying with a higher cap loses nothing. |
5544
5587
  | `--no-record` | records | Skip posting the durable run-history record (and its live progress). |
5545
- | `--draft` | off | A static ad only: render one picture at `high` quality with no judge, then draw and review every format exactly as the final does. Iterate on the copy and the layout with it, and run once without it for the final (three pictures at the best quality, judged). The canvas file is not changed. |
5546
-
5547
- `stats.total_credits` is what the credits ledger debits for the run: each node's charge as the backend billed it (an estimate only when it reported none), plus the review's own model calls. `validate`'s figure is an estimate.
5588
+ | `--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`. |
5589
+ | `--cache-dir <path>` | `<workspace root>/canvas/.cache` | Operator only. Where content-addressed bytes + cache index live. |
5590
+ | `--outputs-dir <path>` | `<workspace root>/canvas` | Operator only. Root for per-run output directories. |
5591
+ | `--keep-runs <n>` | off | Operator only. Prune old `r_*` run dirs after the run, keeping the N newest. |
5548
5592
 
5549
5593
  **Run history streams live.** The run posts its plan (every node + its dependency edges) the moment validation passes, then re-posts a progress snapshot as each node starts and settles — the dashboard's creative workflow graph shows nodes flipping pending → running → done in real time, with each node's outputs attached as they land. A failed run keeps its per-node trail (what completed, what died). All best-effort: an unreachable backend never changes the run's outcome.
5550
5594
 
@@ -5591,7 +5635,7 @@ It then scaffolds the full pipeline like an **editing timeline**: each clip gets
5591
5635
 
5592
5636
  **Timing-faithful clip + extract (no overlap).** Each phrase clip is generated to its **coverage window** (the deconstruct's real scene/line timing, capped at the gateway-safe ~10s ceiling) and its converted voice is extracted to the **spoken window** (pause to pause) — *not* padded to a word-count estimate. Padding past the window was what ran the voice the clip's whole length and overlapped the next phrase; trusting the deconstruct's timing keeps consecutive phrases back-to-back and lets Seedance pace the quoted text to fit. `metadata.video.talking_scenes` records each phrase's `scene_s` vs `est_speech_s`; on top of that the scaffold flags any scene whose estimated speech overruns its window by more than ~1.3× as **`metadata.todo.overstuffed_scenes`** (also in the stdout checklist) — a loud advisory to shorten the copy or lengthen the scene before rendering, since an over-stuffed line pushes the picture off the audio timeline. It similarly flags **`oversize_scenes`** — a single scene whose own footage exceeds the gateway-safe ~10s clip ceiling (a b-roll shot or one-shot monologue). The phrase splitter only breaks at scene boundaries, so it can't shrink a single over-long scene; its clip would 524 at the gateway, so the advisory tells you to split that scene into two before rendering.
5593
5637
 
5594
- **Timeline-accurate picture — ONE clock.** Seedance can't render under 4s, so each clip is generated at the smallest allowed duration ≥ the scene length and **cut back to the exact scene duration inside the spine's own per-input filter chain** (no standalone trim nodes — one fewer re-encode per scene, and the graph stays a third smaller). Real screenshots/graphics loop fit-padded, generated stills loop fill-cropped, on the same chains. The spine is the ONE clock: every voice, SFX, music, and overlay placement is mapped onto where each scene's picture actually sits in the butted concat (not the reference video's own timestamps, which can carry dead air the picture doesn't reproduce), the audio mix's `total_ms` is pinned to the spine length, and every concat input is normalized to one raster clock (`yuv420p`, 30fps, square pixels, shared timebase) so a generated clip's unadvertised frame rate can never stretch the picture off the audio. The contract is stamped as `metadata.video.timeline` and `baker canvas validate` re-proves it from the live node params after any hand edit. Frames are also prompted as **clean text-free plates** (no baked captions/lower-thirds/tickers/logos-as-text) so the overlay layer is the single source of on-screen text.
5638
+ **Timeline-accurate picture — ONE clock.** Seedance can't render under 4s, so each clip is generated at the smallest allowed duration ≥ the scene length and **cut back to the exact scene duration inside the spine's own per-input filter chain** (no standalone trim nodes — one fewer re-encode per scene, and the graph stays a third smaller). Real screenshots/graphics loop fit-padded, generated stills loop fill-cropped, on the same chains. The spine is the ONE clock: every voice, SFX, music, and overlay placement is mapped onto where each scene's picture actually sits in the butted concat (not the reference video's own timestamps, which can carry dead air the picture doesn't reproduce), the audio mix's `total_ms` is pinned to the spine length, and every concat input is normalized to one raster clock (`yuv420p`, 30fps, square pixels, shared timebase) so a generated clip's unadvertised frame rate can never stretch the picture off the audio. The contract is stamped as `metadata.video.timeline` and `baker canvas validate` re-proves it from the live node params after any hand edit. The spine is a `video_concat` node (each clip's `duration_s`, trim or hold is a param, never argv) and the final mux an `av_mux`, and every scene node carries `tags` (its scene, region and role), which is what the validator reads. Frames are also prompted as **clean text-free plates** (no baked captions/lower-thirds/tickers/logos-as-text) so the overlay layer is the single source of on-screen text.
5595
5639
 
5596
5640
  **Scene transitions.** When the deconstruct flags a boundary as `fade`/`whip`/`zoom`/`dissolve`/`swipe` (`scene.transition_out`), the spine reproduces it as an ffmpeg **`xfade`** instead of a hard cut; plain `cut`/`match_cut` stay hard cuts. The overlap is consumed from **extra generated footage** (each transitioning clip is trimmed to `scene_s + transition` and the xfade `offset` lands on the cumulative scene start), so the total length still equals the sum of the scene lengths — the picture stays exactly on the audio timeline.
5597
5641
 
@@ -5623,12 +5667,14 @@ baker canvas run ./reference-ad.video.canvas.json
5623
5667
  | `--max-scenes <n>` | all source scenes | **Cost lever that reduces fidelity** — caps the deconstruct, MERGING away every scene beyond the cap (fewer cuts, lost beats). Prints a warning when set; omit it to reproduce every scene. |
5624
5668
  | `--language <code>` | auto | Transcript/dialogue language hint (e.g. `fr`, `en`). |
5625
5669
  | `--focus <text>` | — | Known provenance/emphasis to ground the deconstruct. |
5626
- | `--deconstruct-model <id>` | `~google/gemini-pro-latest` | Override the `video_deconstruct` model. |
5670
+ | `--deconstruct-model <id>` | `~google/gemini-flash-latest` | Override the `video_deconstruct` model. |
5627
5671
  | `--select-model <id>` | `~google/gemini-flash-latest` | Override the element-selection `text_generate` model. |
5628
- | `--image-model <id>` | `openai/gpt-image-2` | Override the per-frame `image_generate` model (defaults to the strongest, matching `scaffold-static-ad`). |
5672
+ | `--image-model <id>` | `openai/gpt-image-2.5-sunburst` | Override the per-frame `image_generate` model (defaults to the strongest, matching `scaffold-static-ad`). |
5629
5673
  | `--video-model <id>` | scored router | Override the `video_generate` model **and skip the router**. Curated roster: `google/gemini-omni-1.1-flash` (**the default video model** — native audio + reference images, `360p`\|`720p`\|`1080p`\|`4k` at ~$0.034\|$0.101\|$0.152\|$0.304 per second, `16:9`\|`9:16`, `duration` 3–10s and 10s when unset), `bytedance/seedance-2.0` (workhorse), `google/veo-3.1` (cine ceiling + real-face), `google/veo-3.1-fast` (cheap Veo), `kwaivgi/kling-v3.0-pro` (motion-transfer). |
5630
5674
  | `--real-face` | auto-detected | Force the Veo route for a photoreal human presenter (dodges the ByteDance real-person filter). **Usually unnecessary** — the scaffold auto-detects a photoreal on-camera person/animal cast from the deconstruct and routes it to Veo on its own; set this only to force it when the cast isn't detected. Covers AI-generated photoreal faces too, not just real ones. |
5631
5675
  | `--motion-transfer` | off | Motion driven from a reference / hyper-dynamic → the router picks Kling. |
5676
+
5677
+ A model flag naming a model the registry does not have for that node fails the command (`unknown_model`, listing the models it does have) before anything is billed; it no longer falls back silently to another model.
5632
5678
  | `--identity` | off | Same character/product across clips → the router picks the Seedance workhorse. |
5633
5679
  | `--budget <tier>` | `standard` | Router cost posture: `economy` (cheap Veo-fast tier) / `standard` / `premium`. |
5634
5680
  | `--avatar <handles>` | — | Cast company avatars as the reference's people, comma-separated in the order they appear (`marta` or `marta,joan`). Each recurring person grounds on that avatar's identity sheet and takes its subject description; a `same_as` look of them uses the same sheet. Resolved before anything is billed — a handle that is not `ready` fails the command. |
@@ -5651,9 +5697,9 @@ The two scaffold passes are billed (the full `video_deconstruct` is the heavy on
5651
5697
 
5652
5698
  #### `baker canvas scaffold-static-ad <image> [flags]`
5653
5699
 
5654
- `<image>` is a **local path or an `http(s)` URL** (e.g. a presigned S3 link from ad-dna). A URL is passed straight in; do not `curl` it to a file first. The source is uploaded to durable storage before any billed pass, so the canvas keeps running after a presigned URL expires.
5700
+ **New-creative limit:** a chat makes at most 10 new creatives. The eleventh `scaffold-static-ad`, `scaffold-ad` or `scaffold-video` with a new `--slug` exits 2 with `CREATIVE_LIMIT` before any billed pass. Re-scaffolding a creative already made in the chat, or one whose `_definition.md` existed before, does not count.
5655
5701
 
5656
- **Every scaffold is a creative in `src/creatives/<slug>/`** (`<slug>.canvas.json`, `prompt.json`, `_definition.md`), never a loose file in the workspace. Without `--slug` a slug is derived from `--title`, else the reference's file name, else `static-ad`, suffixed `-2`, `-3`… past any folder that already exists, and a hint names it. Every flag that shapes the bill (`--platform`, `--placements`, `--aspect`, `--takes`, `--avatar`) is checked before the first billed pass; an unknown value fails with `invalid_platform`, `invalid_placements`, `invalid_aspect`, `invalid_takes` or `avatar` and costs nothing. The output ends with `next_steps`: the one file to edit (`prompt.json`) and the exact command to run (`baker canvas run <slug>`).
5702
+ `<image>` is a **local path or an `http(s)` URL** (e.g. a presigned S3 link from ad-dna). A URL is passed straight into the `original` ingest node as `source: "url"` — do not `curl` it to a file first. Note a presigned URL embedded in the emitted canvas will **expire** (the `original` node is composition-only, so a later re-run just skips it); pass a stable URL or a local file if you need the canvas to stay runnable. For the URL case, `--out` defaults to the current directory — pass `--out` to scaffold two URL sources into distinct files, or the second run overwrites the first.
5657
5703
 
5658
5704
  Turn a source/inspiration image into a **runnable, self-validated static-ad canvas** — the static counterpart of `scaffold-video`. Like the video scaffold, this runs **billed Gemini passes** up front:
5659
5705
 
@@ -5668,74 +5714,69 @@ It then scaffolds a canvas that draws **only what `prompt.json` holds**; no elem
5668
5714
  - **`text_only`**: no picture is generated. The copy is drawn on the brand's lead colour in its fonts, for near-zero credits. On by default when the reference pictures nothing but copy (no subjects, no people); `--text-only` forces it, `--no-text-only` refuses it.
5669
5715
  - **`baked`**: the copy is painted into the plates, only with `--skip-font` or when no font can load; every other shape is re-framed by `image_aspect_adapt` on `google/gemini-3-pro-image`, never cropped.
5670
5716
 
5671
- **Fonts** come from the first source that has one: a file in `src/brand/fonts/` (via `src/brand/tokens.json` when present), else the heading and body families `tokens.json` or `BRAND.md`'s typography section name, fetched as web font files from Google Fonts or Fontsource and ingested by URL, else Inter (800 and 400). **The logo is optional**: it is drawn from `src/brand/logos/` only when `prompt.json` keeps a logo (a `logo` layout region, or an `advertiser_brand` entry in `brands_logos`) and a real file exists. Meta shows the page name and picture on every ad, so no logo is never an error, never a slot to fill and never a stand-in; a canvas scaffolded with an unfilled logo slot runs without it. Text that belongs to something in the scene (a phone note, a screenshot, a sign, packaging: `surface: "in_scene"` in `text_content`) is painted by the plate, not overlaid, and listed in the report's `in_scene_text`. The plates read `prompt.plate.json` (the scene, the reserved areas, the visual mechanisms and the in-scene text; no overlay copy, no brand names) and the compositions read `prompt.compose.json`; both are re-derived from `prompt.json` by `validate` and `run`, so a copy edit re-draws the formats from the cached plates without paying for a picture again. A format within 10% of the plate's shape is a free crop; a farther one extends the chosen plate once on the same model. 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 (the blueprint's layout carries the composition instead). Each **animal hero** is additionally fused into a generated **multi-view reference sheet** (`image_reference_sheet`, a turnaround built from the one dropped photo) that the render grounds on instead of the lone flat snapshot. Pass `--skip-actor-sheets` to ground straight 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. The render keeps the face it is given, so it is never a found photo of a real person. The canvas is validated before it's written. stdout returns `{ ok, slug, canvas_path, prompt_path, models, aspect_ratio, placements, output_formats, layout_regions, stats, checklist, next_steps }`; the **checklist** carries `mode`, `summary` (what a run does: pictures, judge, fonts, logo), `fonts`, `logo`, `in_scene_text` and every real asset to drop in.
5717
+ **Fonts** come from the first source that has one: a file in `src/brand/fonts/` (via `src/brand/tokens.json` when present), else the heading and body families `tokens.json` or `BRAND.md`'s typography section name, fetched as web font files from Google Fonts or Fontsource and ingested by URL, else Inter (800 and 400). **The logo is optional**: it is drawn from `src/brand/logos/` only when `prompt.json` keeps a logo (a `logo` layout region, or an `advertiser_brand` entry in `brands_logos`) and a real file exists. Meta shows the page name and picture on every ad, so no logo is never an error, never a slot to fill and never a stand-in; a canvas scaffolded with an unfilled logo slot runs without it. Text that belongs to something in the scene (a phone note, a screenshot, a sign, packaging: `surface: "in_scene"` in `text_content`) is painted by the plate, not overlaid, and listed in the report's `in_scene_text`. The plates read `prompt.plate.json` (the scene, the reserved areas, the visual mechanisms and the in-scene text; no overlay copy, no brand names) and the compositions read `prompt.compose.json`; both are re-derived from `prompt.json` by `validate` and `run`, so a copy edit re-draws the formats from the cached plates without paying for a picture again. A format within 10% of the plate's shape is a free crop; a farther one extends the chosen plate once on the same model. 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 (the blueprint's layout carries the composition instead). Each **animal hero** is additionally fused into a generated **multi-view reference sheet** (`image_reference_sheet`, a turnaround built from the one dropped photo) that the render grounds on instead of the lone flat snapshot. Pass `--skip-actor-sheets` to ground straight 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. The render keeps the face it is given, so it is never a found photo of a real person. The canvas is validated before it's written. stdout returns `{ ok, canvas_path, prompt_path, models, aspect_ratio, placements, output_formats, layout_regions, stats, checklist }`; the **checklist** carries `mode`, `summary` (what a run does: pictures, judge, fonts, logo), `fonts`, `logo`, `in_scene_text` and every real asset to drop in.
5672
5718
 
5673
5719
  **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 ratio the plates really render at (the model's nearest to the lead format), and `format_plan` says how each format is made.
5674
5720
 
5675
- **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, and one vision pass that reads each word against the copy `prompt.json` holds and checks one dominant idea, clipping, artifacts, 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-plate 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.
5721
+ **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, and one vision pass that reads each word against the copy `prompt.json` holds and checks one dominant idea, clipping, artifacts, 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-plate 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; its hints name the cheapest fix (overlapping or unreadable copy is fixed with a smaller `size`, a `highlight` or `sticker` treatment or shorter words, which re-draw the formats from the cached plates). **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.
5676
5722
 
5677
5723
  The logo is never a slot to fill: use the company's committed logo in `src/brand/logos/` (per `BRAND.md`), never `baker images logo <domain>`, whose Brandfetch CDN often serves an outdated mark.
5678
5724
 
5679
5725
  The plate 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. In the baked-copy fallback it quotes every line of `prompt.json`'s copy (never the logo's text) with an instruction to render each **once, verbatim**; that block is re-read from `prompt.json` on every `validate` and `run`, which also upgrades canvases scaffolded before this change. Run `baker canvas critique` on the scaffolded canvas for an advisory read before you spend.
5680
5726
 
5681
5727
  ```bash
5682
- baker canvas scaffold-static-ad ./reference-ad.png --slug spring-offer --context "competitor ad for <brand>, <category>, <market>"
5683
- # → writes src/creatives/spring-offer/{spring-offer.canvas.json,prompt.json,_definition.md}
5728
+ baker canvas scaffold-static-ad ./reference-ad.png --context "competitor ad for <brand>, <category>, <market>"
5729
+ # → writes ./static-ad.canvas.json + ./prompt.json
5684
5730
  # edit prompt.json into your ad + replace each [TODO] ingest path with a real file, then:
5685
- baker canvas validate spring-offer
5686
- baker canvas run spring-offer
5731
+ baker canvas validate ./static-ad.canvas.json
5732
+ baker canvas run ./static-ad.canvas.json
5687
5733
  ```
5688
5734
 
5689
5735
  | Flag | Default | Effect |
5690
5736
  |---|---|---|
5691
5737
  | `--context <text>` | — | Known provenance (advertiser, category, market) to ground the describe. |
5692
- | `--slug <slug>` | derived from `--title` or the reference | Creative slug (lowercase kebab): the creative's folder `src/creatives/<slug>/`, which attaches every run to its generation history. |
5693
- | `--title <text>` | title-cased slug | Creative title for `_definition.md`; also the source of a derived slug. |
5738
+ | `--out <path>` | `<image-dir>/static-ad.canvas.json` (cwd when `<image>` is a URL) | Where to write the canvas (`prompt.json` is written alongside). |
5739
+ | `--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 **downloaded into `src/creatives/<slug>/references/` and normalized to a model-safe format** (SVG/AVIF/HEIC/… → PNG), named from the actual bytes (not the URL string) so a presigned/extensionless URL never lands as PNG-bytes-in-`.jpg` — the canvas ingests that committed, portable path instead of the expiring URL. |
5694
5740
  | `--describe-model <id>` | registry default (`~google/gemini-pro-latest`) | Override the `image_describe` model. |
5695
5741
  | `--select-model <id>` | registry default (`~google/gemini-flash-latest`) | Override the element-selection `text_generate` model. |
5696
5742
  | `--layout-model <id>` | registry default (`~google/gemini-flash-latest`) | Override the global-layout `text_generate` model. |
5697
- | `--gen-model <id>` | `openai/gpt-image-2.5-sunburst` | Override the plate model. |
5743
+ | `--gen-model <id>` | `openai/gpt-image-2.5-sunburst` | Override the plate model. An unknown model id in any `--*-model` flag fails the command (`unknown_model`) before anything is billed. |
5698
5744
  | `--takes <n>` | `3` | Plates rendered and judged (1-3); `1` skips the judge. A text-only ad renders none. |
5699
- | `--text-only` | on when the reference pictures nothing but copy (a note, a letter, a comparison with no photo) | Draw a typographic ad: no picture generated; each line in the colour, weight and size prompt.json gives it, on the layout's background colour, with its cards, dividers, icons, tick and cross marks and app chrome. `--no-text-only` renders a picture anyway. |
5700
- | `--platform <channel>` | `meta` | `_definition.md` channel **and** the default placement set the hero fans out to (`meta`\|`google`\|`linkedin`\|`tiktok`\|`youtube`\|`x`\|`other`; `x`/`other` fan out to nothing). Any other value is refused. |
5745
+ | `--text-only` | on when the reference pictures nothing but copy | Draw a typographic ad: no picture generated, the copy on the brand colours in its fonts. `--no-text-only` renders a picture anyway. |
5746
+ | `--platform <channel>` | `meta` | `_definition.md` channel **and** the default placement set the hero fans out to (`meta`\|`google`\|`linkedin`\|`tiktok`\|`youtube`\|`x`\|`other`; `x`/`other` fan out to nothing). |
5701
5747
  | `--placements <preset\|none>` | derived from `--platform` | Override the fan-out set: `meta_feed`\|`meta_feed_stories`\|`meta_all`\|`linkedin`\|`google_demandgen`\|`tiktok`\|`youtube`, or `none` for a single base ad. |
5702
- | `--aspect <ratio>` | inferred from the image, else `9:16` | Force the lead format (it leads the placement set when the set has it): `1:1`, `4:5`, `9:16`, `16:9`, `4:3`, `3:4`, `2:3`, `3:2` or `21:9`. |
5748
+ | `--aspect <ratio>` | inferred from the image, else `9:16` | Force the lead format (it leads the placement set when the set has it). |
5703
5749
  | `--skip-font` | off | Bake the copy into the plates instead of drawing it in a real font (a font file, else the typeface the brand names, else Inter). |
5704
5750
  | `--skip-actor-sheets` | off | Ground each animal hero on its lone dropped photo instead of a generated multi-view reference sheet. |
5705
5751
  | `--avatar <handles>` | — | Cast company avatars as the image's people, comma-separated in the order they were detected (`marta` or `marta,joan`). Each person slot grounds on that avatar's identity sheet and takes its subject description. Checked before any billed pass; an unknown, not-ready or sheetless avatar fails with code `avatar`. A handle with no person to play is reported in `hints` as `NOT CAST`. |
5706
5752
 
5707
5753
  Scaffolding runs (and bills) the two vision passes; **running** the result generates a billed image. `baker canvas validate` does not check that the `[TODO]` paths exist — supply the real files before `run`.
5708
5754
 
5709
- **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. Resume also works from a **different workspace or a fresh sandbox**: when no local marker survives, the run history is consulted and an interrupted (or stale) run of the exact same canvas is adopted automatically. 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`).
5710
-
5711
- #### `baker canvas rerun <slug> [flags]`
5755
+ **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`).
5712
5756
 
5713
- Re-run a creative's latest recorded canvas **from run history** — no local files needed. Every `canvas run` of a creative uploads a portable snapshot (the canvas JSON plus the local files it ingests: prompt blueprints, composition dirs, reference images) to durable storage and records it on the run. `rerun` restores those files into `src/creatives/<slug>/` (sha-verified; files already matching are left untouched) and then executes the normal run flow — so an interrupted run **resumes** (in-flight jobs re-attach) and a completed one re-renders from the cache at zero credits.
5757
+ #### `baker canvas rerun <slug> [flags]` (deprecated)
5714
5758
 
5715
- | Flag | Default | Meaning |
5716
- | --- | --- | --- |
5717
- | `--force-remote` | off | Overwrite local files whose content differs from the snapshot (otherwise a conflict aborts with the differing paths). |
5718
- | `--fresh` | off | Start a new run id instead of resuming an interrupted one. |
5719
- | `--regenerate <ids>` | — | Same as `canvas run --regenerate`. |
5720
- | `--concurrency <n>` | — | Same as `canvas run --concurrency`. |
5759
+ 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.
5721
5760
 
5722
5761
  ```bash
5723
- baker canvas rerun spring-offer
5724
- baker canvas rerun spring-offer --force-remote --regenerate gen
5762
+ baker canvas run --slug spring-offer
5763
+ baker canvas run --slug spring-offer --force-remote --regenerate gen
5725
5764
  ```
5726
5765
 
5727
- Use it when a creative was built in another conversation (or its sandbox is gone) and you need to continue or re-render it here. Files the snapshot could not include (missing at run time, or oversized) are listed as warnings — supply those locally only if the run actually needs to regenerate the nodes that read them.
5766
+ #### `baker canvas stale`
5728
5767
 
5729
- #### `baker canvas inspect <run_id|slug> [--thumbnails]`
5768
+ 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`.
5730
5769
 
5731
- One-page summary of a completed run: per-node duration + cache status, list of files in the run dir, optional video thumbnails (start/middle/end frames extracted via ffmpeg).
5770
+ #### `baker canvas inspect <run_id> [--thumbnails]`
5771
+
5772
+ One-page summary of a run: per-node duration + cache status, list of files in the run dir, optional video thumbnails (start/middle/end frames extracted via ffmpeg). When the run has no folder in this workspace (an older version, or a run from another chat), it is read from the company's run history instead (`source: "run_history"`): status, stats, per-node results and the durable URL of every output.
5732
5773
 
5733
5774
  ```bash
5734
5775
  baker canvas inspect r_01JXYZ...
5735
5776
  baker canvas inspect r_01JXYZ... --thumbnails
5736
5777
  ```
5737
5778
 
5738
- Resolves the run dir against `--outputs-dir` (default `./canvas`) or accepts an absolute path. A creative's slug (`baker canvas inspect spring-offer`) opens that creative's latest run. Reads `manifest.json` for per-node stats.
5779
+ Resolves the run dir against `--outputs-dir` (default `./canvas`) or accepts an absolute path. Reads `manifest.json` for per-node stats.
5739
5780
 
5740
5781
  #### `baker canvas catalog`
5741
5782
 
@@ -6198,23 +6239,93 @@ import {
6198
6239
 
6199
6240
  ## Creatives
6200
6241
 
6201
- Publish an approved canvas render as a first-class Baker creative. The image uploads to the Baker image library (tagged `creative`), a creative record is created/updated, and the command prints the creative reference JSON the dashboard renders in chat.
6242
+ Publish an approved canvas render as a first-class Baker creative: a static image (PNG, JPG, WebP) or a finished video (MP4, MOV, WebM). An image uploads to the Baker image library (tagged `creative`); a video uploads to the Baker video library and is saved once it plays (bounded at 3 minutes). A creative record is created/updated, and the command prints the creative reference JSON the dashboard renders in chat.
6202
6243
 
6203
6244
  ```bash
6204
- baker creatives publish ./canvas/<run_id>/<final>.png --title "Spring Offer 4x5" \
6205
- --slug spring-offer-4x5 --run-id r_01JXYZ... \
6245
+ baker creatives publish --title "Spring Offer 4x5" \
6246
+ --slug spring-offer-4x5 --run-id r_01JXYZ... --output 4:5 \
6206
6247
  --source-reference-url "https://www.facebook.com/ads/library/?id=..."
6248
+ # or from a local file
6249
+ baker creatives publish ./canvas/<run_id>/<final>.png --title "Spring Offer 4x5" --slug spring-offer-4x5 --run-id r_01JXYZ...
6250
+ # a finished video render (needs the file and --slug)
6251
+ baker creatives publish ./canvas/<run_id>/final.mp4 --title "Spring Offer Reel" \
6252
+ --slug spring-offer-reel --run-id r_01JXYZ...
6207
6253
  ```
6208
6254
 
6209
6255
  | Flag | Effect |
6210
6256
  |---|---|
6257
+ | `<file>` | The image or video. For an image it is optional with `--run-id`: the run's own saved output (from its run record, sha-verified) is used instead, so a workspace that never had the run's folder can publish it. |
6211
6258
  | `--title <text>` | Required. Human title for the creative. |
6212
- | `--slug <slug>` | Creative slug (`src/creatives/<slug>/`) — attaches the image to that creative's row, marks it `published`. |
6259
+ | `--slug <slug>` | Creative slug (`src/creatives/<slug>/`): attaches the image or video to that creative's row, marks it `published`. Required for a video. With `--run-id` an image publish also sets `status: published` in the creative's `_definition.md` (`definitionStatus` in the output). |
6213
6260
  | `--run-id <r_…>` | Pins the approved generation from the creative's run history as the published one. |
6261
+ | `--output <label\|slot\|n>` | With `--run-id` and no file: which of the run's images to save when it made several (a label such as `4:5`, a slot, or a 1-based number). |
6214
6262
  | `--source-reference-url <url>` | Original reference ad URL, recorded on the creative. |
6215
- | `--context <text>` | Optional describe context for the uploaded image asset. |
6263
+ | `--context <text>` | Optional describe context for the uploaded image or video. |
6264
+
6265
+ An image prints `{ ok, data: { creativeId, imageId, reference } }`. A video prints `{ ok, data: { creativeId, videoId, reference }, hints }`, where `reference.asset.kind` is `video` (with `videoId`, `muxPlaybackId`, the poster as `thumbnailUrl`, size and duration) and `hints` names the next step to put it in a Meta ad: `baker ads meta media upload --kind video --video-id <videoId>`.
6266
+
6267
+ Without `--slug` an image behaves as before (one creative record per published image). With `--slug` it upserts the repo-convention row — the same one the dashboard's Creatives tab and the `src/creatives/{slug}/` folder describe — so publish, repo sync, and run history all land on a single record regardless of order.
6268
+
6269
+ ### `baker creatives performance`
6270
+
6271
+ How each published creative is doing in the live Meta ads that show it. A creative is linked to an ad when a chat's publish uploads its image or video to Meta and creates an ad with it (or swaps an existing ad onto it); the results are read live from Meta.
6272
+
6273
+ ```bash
6274
+ baker creatives performance # every creative in an ad, last 30 days
6275
+ baker creatives performance --slug spring-offer # one creative
6276
+ baker creatives performance --days 14 --full # + ids, last-7-day figures, one row per ad
6277
+ ```
6278
+
6279
+ | Flag | Effect |
6280
+ |---|---|
6281
+ | `--slug <slug>` | Only this creative. |
6282
+ | `--days <n>` | Window ending today, 1-90 (default 30). |
6283
+ | `--full` | Adds creative, image and video ids, result type, days live, last-7-day CTR and cost per result, a video's watch funnel (`video`: 3-second views, ThruPlays, 25/50/75/100% watched), and one row per ad. |
6284
+
6285
+ Each creative reports `spend`, `impressions`, `ctr` (percent), `cpc`, `results`, `costPerResult`, `frequency`, a `fatigue` verdict (`fresh`, `fading`, `fatigued`, `dead`, `too_early`, `no_delivery`) with its reason, and `definition`: the angle, hook, mechanism, dominantSlot, persona, awareness, hypothesis and reference fields read from the local `src/creatives/<slug>/_definition.md`. A video creative also reports `hookRate` (percent of impressions that watched 3 seconds) and `holdRate` (percent of those that reached a ThruPlay); a static one carries neither. `hints` name worn-out creatives, videos whose hook rate is under 25% (the first 3 seconds need new hooks) or whose hold rate is under 30% behind a working hook (the body loses people), the angle, mechanism and dominant slot with the lowest cost per result (each ranked once two or more values have results), and unreadable ad accounts.
6286
+
6287
+ ### `baker creatives feedback [--slug <slug>] [--full]`
6288
+
6289
+ What reviewers said about creative versions in Baker (Approve, or Not this one with reasons and a note; choosing a version to keep counts as an approval). Compact by default: approved and rejected counts, the top rejection reasons, what approved versions share (format, platform, option) and the 8 latest verdicts, plus `hints[]` saying what to do differently. `--slug` narrows to one creative; `--full` returns every verdict (up to 200) with whole notes.
6290
+
6291
+ ```bash
6292
+ baker creatives feedback
6293
+ baker creatives feedback --slug spring-offer --full
6294
+ ```
6295
+
6296
+ 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`).
6297
+
6298
+ ### `baker creatives feedback record`
6299
+
6300
+ 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.
6301
+
6302
+ ```bash
6303
+ baker creatives feedback record --slug spring-offer --run r_01K… --verdict rejected \
6304
+ --reasons text_mistakes,weak_hook --note "the price is 49 not 59"
6305
+ baker creatives feedback record --slug spring-offer --run r_01K… --option 2 --verdict approved
6306
+ ```
6307
+
6308
+ | Flag | Effect |
6309
+ |---|---|
6310
+ | `--slug <slug>` | Required. The creative (`src/creatives/<slug>/`). |
6311
+ | `--run <r_…>` | Required. The version. |
6312
+ | `--verdict approved\|rejected` | Required. |
6313
+ | `--option <o>` | One option of a version that made several: its label, its number (`2`) or its slot. |
6314
+ | `--reasons <a,b>` | Comma-separated: `text_mistakes`, `logo_or_brand_wrong`, `weak_hook`, `wrong_message`, `looks_fake`, `not_our_style`, `voice_or_sound`, `pacing`, `other`. The two video reasons: `voice_or_sound` (the voiceover, music or mix) and `pacing` (too slow, too fast, the hook lands late). A rejection needs reasons or a note. |
6315
+ | `--note <text>` | The person's own words (stored up to 1000 characters). |
6316
+
6317
+ After a rejection, `hints[]` restates what to change, starting from that version.
6318
+
6319
+ ### `baker creatives select`
6320
+
6321
+ Choose the version of a creative to keep, or only some of its options, the way choosing it in Baker does: the creative shows that version as the chosen one, and the choice records the chat sender's approval (`source: "selection"`). Naming options replaces the earlier pick. It does not add the image to the library; that is `baker creatives publish`, which, given `--run-id`, also counts as choosing that version.
6322
+
6323
+ ```bash
6324
+ baker creatives select --slug spring-offer --run r_01K…
6325
+ baker creatives select --slug spring-offer --run r_01K… --options "beach,2"
6326
+ ```
6216
6327
 
6217
- Without `--slug` the command behaves as before (one creative record per published image). With `--slug` it upserts the repo-convention row — the same one the dashboard's Creatives tab and the `src/creatives/{slug}/` folder describe — so publish, repo sync, and run history all land on a single record regardless of order.
6328
+ Prints `{ ok, data: { creative, runId, kept?, options, approvalRecorded }, hints }`, where `options` lists every option the version offers.
6218
6329
 
6219
6330
  ## Landing Quality (`baker landing`)
6220
6331