@koda-sl/baker-cli 0.149.0-dev.7b64ca6b5 → 0.150.0-dev.a970fa118

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.
package/README.md CHANGED
@@ -492,6 +492,8 @@ Command groups: `budgets`, `campaigns`, `ad-groups`, `keywords` (add/update/remo
492
492
 
493
493
  **Amending a staged op** — `draft amend <ref> (--file patch.json | --patch '<json>')` merges a JSON patch into an already-staged op's payload (objects deep-merge, `null` deletes a key, arrays/scalars replace) and re-validates it in full. Always prefer this over removing and re-staging — it preserves the op's ref (so dependents don't break) and its position in the draft. `draft show <ref>` prints the full staged payload (including warnings/annotations) so you can verify a change looks right before publish.
494
494
 
495
+ **Already-satisfied writes aren't staged** — an update or pause/resume whose every field already holds the requested value on the live account is dropped instead of staged: the envelope returns `"staged": false, "noop": true` with a `reason` ("this ad is already Paused on Google Ads"), plus a hint. This is a success, not an error — there is nothing to publish. Only a positively-read live snapshot can trigger it; a failed or empty read always stages. Batch responses list the dropped ops under `skipped`, and `count` reflects what actually entered the draft.
496
+
495
497
  **Batch keyword adds** — `keywords add`, `negative-keywords add`, and `keyword-lists add` take a whole batch in one command: comma-separate `--text` entries and/or pass `--file <list.txt>` (one keyword per line). A `:EXACT`/`:PHRASE`/`:BROAD` suffix per entry overrides the `--match-type` default. Batches stage all-or-nothing as one request (limit 500); each keyword still lands as its own draft op, so it stays individually removable/amendable.
496
498
 
497
499
  ---
@@ -1641,6 +1643,8 @@ baker images stock "office reaction" --people only
1641
1643
 
1642
1644
  Free tier exists but watermarks previews — pass `--license freemium` to filter to clean free assets explicitly.
1643
1645
 
1646
+ An empty result comes back with `hints` naming the filters that narrowed the search plus the fallbacks (`baker images find --sources …`, `baker images generate`); a provider failure returns an `error.fix` pointing at the same exits. `baker images google` does the same, minus the cross-provider fallback (it is the last-resort provider).
1647
+
1644
1648
  **Flags:**
1645
1649
 
1646
1650
  | Flag | Description |
@@ -1722,13 +1726,15 @@ baker images generate "flat geometric mascot, brand palette" \
1722
1726
  | Model | Best for | Aspect ratios | Sizes |
1723
1727
  |---|---|---|---|
1724
1728
  | `google/gemini-3.1-flash-image-preview` **(default)** | Fast iteration, reference editing, extreme aspect ratios (Nano Banana flash) | standard **+** `1:4` `4:1` `1:8` `8:1` | `0.5K`–`4K` |
1725
- | `google/gemini-3.5-flash` | Fast; extreme aspect ratios | standard **+** `1:4` `4:1` `1:8` `8:1` | `0.5K`–`4K` |
1726
1729
  | `google/gemini-3-pro-image-preview` | Highest fidelity (Nano Banana Pro) — strong prompt adherence and reference grounding | standard set | `1K` `2K` `4K` |
1727
- | `openai/gpt-5.4-image-2` | Photoreal + cleanest in-image text — ad/landing reproduction | standard set | `1K` `2K` `4K` |
1730
+ | `openai/gpt-image-2` | Photoreal + cleanest in-image text — ad/landing reproduction | standard set **minus** `4:5` `5:4` | *(none — derived from the ratio)* |
1731
+ | `openai/gpt-5.4-image-2` | **Legacy** — accepted so canvases authored before the gpt-image-2 switch still re-run. Don't pick it for new work. | standard set | `1K` `2K` `4K` (ignored upstream) |
1728
1732
  | `recraft/recraft-v4.1-pro-vector` | Vector/flat/SVG-style with palette control | standard set | `1K` `2K` `4K` |
1729
1733
 
1730
1734
  Standard aspect ratios: `1:1` `2:3` `3:2` `3:4` `4:3` `4:5` `5:4` `9:16` `16:9` `21:9`.
1731
1735
 
1736
+ `openai/gpt-image-2` accepts neither `image_size` nor the `4:5` / `5:4` portrait ratios — OpenAI picks the pixel dimensions from the ratio. To ship a 4:5 Meta feed ad from it, render the hero at `3:4` and adapt with `image_aspect_adapt`; the scaffolders do this for you.
1737
+
1732
1738
  **Flags:**
1733
1739
 
1734
1740
  | Flag | Description |
@@ -1748,8 +1754,6 @@ Returns `{ images: [{ imageId, imageUrl, deduped, width, height }], model, costU
1748
1754
 
1749
1755
  Brand logo via Brandfetch CDN (`fallback/404`). Probes all 5 logo variants in parallel and returns whichever ones the domain actually publishes. Auto-ingests the first by default.
1750
1756
 
1751
- **Variants are returned wordmark-first** (`logo` dark → `logo` light → `symbol` dark → `symbol` light → `icon`), so the default `--auto-ingest 1` gives you the wide lockup a "trusted by" strip needs. `icon` is deliberately last: for many brands (Salesforce, HubSpot) it is a solid coloured square with the mark knocked out of it, which renders as a filled box beside the wordmarks around it and cannot be rescued with CSS. Reach for it only in slots under ~32px, and then ask for it explicitly.
1752
-
1753
1757
  ```bash
1754
1758
  baker images logo stripe.com # all available variants
1755
1759
  baker images logo linear.app --variant logo # just full wordmark logos (light + dark)
@@ -1757,16 +1761,13 @@ baker images logo linear.app --variant icon # just the square app icon
1757
1761
  baker images logo linear.app --variant symbol # just the bare brand mark (no wordmark), light + dark
1758
1762
  baker images logo example.com --no-auto-ingest # browse without writing to library
1759
1763
  baker images logo stripe.com --auto-ingest 5 # ingest every returned variant
1760
- baker images logo stripe.com,intercom.com,notion.so --variant logo # a whole strip in one call
1761
1764
  ```
1762
1765
 
1763
- **Multiple domains.** Pass a comma-separated list (max 20) to source an entire logo row in one call. Domains are fetched in parallel and a failure on one never loses the rest, so a partial strip is still placeable. The response shape switches to `{ results: [{ domain, hits, error? }] }`, and domains Brandfetch didn't know come back in a `hints[]` entry naming the fallback ladder. A single domain returns the original shape unchanged.
1764
-
1765
1766
  **The 5 variants — pick by use case:**
1766
1767
 
1767
1768
  | `--variant` | `providerMeta.type` | `providerMeta.theme` | What it is | Typical use |
1768
1769
  |---|---|---|---|---|
1769
- | `icon` | `icon` | `null` (single) | App-style square icon (favicon-grade), often a **solid coloured plate** with the mark knocked out | Favicon and nav slots under ~32px only — **never a logo strip** |
1770
+ | `icon` | `icon` | `null` (single) | App-style square icon (favicon-grade), often rounded | Favicon, social proof grid, app-tile mock |
1770
1771
  | `logo` | `logo` | `light` | Full wordmark designed for **light backgrounds** (dark ink on transparent) | Logo on white page, light hero |
1771
1772
  | `logo` | `logo` | `dark` | Full wordmark designed for **dark backgrounds** (light ink on transparent) | Footer on dark, dark hero |
1772
1773
  | `symbol` | `symbol` | `light` | Brand mark/glyph only (no wordmark) for **light backgrounds** | Compact UI, avatar, small-space placement |
@@ -2030,7 +2031,7 @@ Inputs accept a single path, a quoted glob, or a comma-separated list. Writes in
2030
2031
  Pipeline stages, each opt-in via flag presence and idempotent on already-correct input:
2031
2032
 
2032
2033
  1. Rasterize SVG (only when raster ops are requested — pure resize keeps SVG)
2033
- 2. `--remove-bg` → two-pass background removal with gradient/solid branching (skipped when the source is already transparent). "Already transparent" is decided by **solidity** — how completely the opaque pixels fill their own bounding box — not by a raw transparent-pixel count. An app-icon tile is only 5–8% transparent (its rounded corners) while being a solid coloured plate everywhere else, so a global count treated it as already-transparent and left the plate on. Plates measure ~0.8–1.0 solidity; real wordmarks measure 0.2–0.45.
2034
+ 2. `--remove-bg` → two-pass background removal with gradient/solid branching (skipped when the source is already transparent)
2034
2035
  3. `--color <hex>` → recolor foreground pixels to the target color. Single-fill assets stay monochrome; multi-fill SVGs preserve relative contrast with target-color tones so foreground/background fills do not collapse into a solid shape.
2035
2036
  4. `--shrink-to-content` → trim transparent borders
2036
2037
  5. Resize:
@@ -2750,7 +2751,7 @@ A re-run with no changes hits the cache for every node — total runtime drops t
2750
2751
  "type": "text_generate",
2751
2752
  "inputs": { "topic": "$ref:topic.text" },
2752
2753
  "params": {
2753
- "model": "google/gemini-3.5-flash",
2754
+ "model": "google/gemini-3.1-flash-image-preview",
2754
2755
  "prompt": "Write a 6-word tagline for {{topic}}."
2755
2756
  }
2756
2757
  }
@@ -3049,37 +3050,31 @@ Aspect-ratio sets used below:
3049
3050
 
3050
3051
  ---
3051
3052
 
3052
- ###### Model: `openai/gpt-5.4-image-2`
3053
+ ###### Model: `openai/gpt-image-2`
3053
3054
 
3054
- Photorealistic generalist. Optional `reference` image.
3055
+ Photorealistic generalist, and the cleanest at rendering text inside the image. Optional `reference` image (up to 16).
3055
3056
 
3056
3057
  | Name | Type | Required | Notes |
3057
3058
  |---|---|---|---|
3058
- | `model` | literal | yes | `"openai/gpt-5.4-image-2"` |
3059
+ | `model` | literal | yes | `"openai/gpt-image-2"` |
3059
3060
  | `prompt` | string | yes | non-empty |
3060
- | `aspect_ratio` | enum | no | STD AR |
3061
- | `image_size` | enum | no | `1K \| 2K \| 4K` |
3061
+ | `aspect_ratio` | enum | no | GPT AR = `1:1, 2:3, 3:2, 3:4, 4:3, 9:16, 16:9, 21:9` — **no `4:5` / `5:4`** |
3062
3062
  | `quality` | enum | no | `auto \| low \| medium \| high` — rendering quality; the scaffolder sets `high` for photoreal frames. (Do **not** pass `input_fidelity`: gpt-image-2 forces high fidelity automatically and the param can fail the request.) |
3063
3063
 
3064
+ No `image_size`: OpenAI derives the pixel dimensions from the ratio, so a size is ignored. For a portrait ad format, render at `3:4` and adapt.
3065
+
3064
3066
  ```json
3065
3067
  { "id": "hero", "type": "image_generate",
3066
- "params": { "model": "openai/gpt-5.4-image-2", "prompt": "Photorealistic photo of …", "aspect_ratio": "16:9", "image_size": "2K", "quality": "high" } }
3068
+ "params": { "model": "openai/gpt-image-2", "prompt": "Photorealistic photo of …", "aspect_ratio": "16:9", "quality": "high" } }
3067
3069
  ```
3068
3070
 
3069
- ###### Model: `google/gemini-3.5-flash`
3071
+ ###### Model: `openai/gpt-5.4-image-2` (legacy)
3070
3072
 
3071
- Newest Gemini Flash image model. Always returns an image output. Best for fast iteration; supports extreme aspect ratios and the `0.5K` size for cheap previews.
3072
-
3073
- | Name | Type | Required | Notes |
3074
- |---|---|---|---|
3075
- | `model` | literal | yes | `"google/gemini-3.5-flash"` |
3076
- | `prompt` | string | yes | non-empty |
3077
- | `aspect_ratio` | enum | no | EXTREME AR |
3078
- | `image_size` | enum | no | `0.5K \| 1K \| 2K \| 4K` |
3073
+ 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.
3079
3074
 
3080
3075
  ###### Model: `google/gemini-3.1-flash-image-preview`
3081
3076
 
3082
- Preview-channel Gemini flash. Same param surface as `3.5-flash`.
3077
+ Gemini flash image model — the default. Fast iteration, reference editing, extreme aspect ratios, and the `0.5K` size for cheap previews.
3083
3078
 
3084
3079
  | Name | Type | Required | Notes |
3085
3080
  |---|---|---|---|
@@ -3296,7 +3291,7 @@ There is no `prompt` param — the extraction schema is the node. Use `focus` to
3296
3291
  "params": { "model": "~google/gemini-pro-latest" } },
3297
3292
  { "id": "remix", "type": "image_generate",
3298
3293
  "inputs": { "blueprint": "$ref:blueprint.description" },
3299
- "params": { "model": "openai/gpt-5.4-image-2",
3294
+ "params": { "model": "openai/gpt-image-2",
3300
3295
  "prompt": "Recreate an image matching this exact blueprint, but swap the product for a ceramic mug:\n{{blueprint}}" } }
3301
3296
  ],
3302
3297
  "output": { "node": "remix", "output": "images" }
@@ -3377,9 +3372,9 @@ Fail-fast: an invalid model selection (wrong count, duplicate or out-of-range pi
3377
3372
  {
3378
3373
  "schema": "baker-canvas/1",
3379
3374
  "nodes": [
3380
- { "id": "a", "type": "image_generate", "params": { "model": "google/gemini-3.5-flash", "prompt": "hero shot of a ceramic mug, warm morning light" } },
3381
- { "id": "b", "type": "image_generate", "params": { "model": "google/gemini-3.5-flash", "prompt": "hero shot of a ceramic mug, studio softbox" } },
3382
- { "id": "c", "type": "image_generate", "params": { "model": "google/gemini-3.5-flash", "prompt": "hero shot of a ceramic mug, dramatic side light" } },
3375
+ { "id": "a", "type": "image_generate", "params": { "model": "google/gemini-3.1-flash-image-preview", "prompt": "hero shot of a ceramic mug, warm morning light" } },
3376
+ { "id": "b", "type": "image_generate", "params": { "model": "google/gemini-3.1-flash-image-preview", "prompt": "hero shot of a ceramic mug, studio softbox" } },
3377
+ { "id": "c", "type": "image_generate", "params": { "model": "google/gemini-3.1-flash-image-preview", "prompt": "hero shot of a ceramic mug, dramatic side light" } },
3383
3378
  { "id": "best", "type": "image_select",
3384
3379
  "inputs": { "images": ["$ref:a.images#0", "$ref:b.images#0", "$ref:c.images#0"] },
3385
3380
  "params": { "model": "~google/gemini-pro-latest", "prompt": "most premium-feeling lighting, no harsh shadows" } }
@@ -4081,7 +4076,7 @@ baker canvas run ./reference-ad.video.canvas.json
4081
4076
  | `--focus <text>` | — | Known provenance/emphasis to ground the deconstruct. |
4082
4077
  | `--deconstruct-model <id>` | `~google/gemini-pro-latest` | Override the `video_deconstruct` model. |
4083
4078
  | `--select-model <id>` | `~google/gemini-flash-latest` | Override the element-selection `text_generate` model. |
4084
- | `--image-model <id>` | `openai/gpt-5.4-image-2` | Override the per-frame `image_generate` model (defaults to the strongest, matching `scaffold-static-ad`). |
4079
+ | `--image-model <id>` | `openai/gpt-image-2` | Override the per-frame `image_generate` model (defaults to the strongest, matching `scaffold-static-ad`). |
4085
4080
  | `--video-model <id>` | scored router | Override the `video_generate` model **and skip the router**. Curated roster: `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). |
4086
4081
  | `--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. |
4087
4082
  | `--motion-transfer` | off | Motion driven from a reference / hyper-dynamic → the router picks Kling. |
@@ -4138,7 +4133,7 @@ baker canvas run ./static-ad.canvas.json
4138
4133
  | `--describe-model <id>` | registry default (`~google/gemini-pro-latest`) | Override the `image_describe` model. |
4139
4134
  | `--select-model <id>` | registry default (`~google/gemini-flash-latest`) | Override the element-selection `text_generate` model. |
4140
4135
  | `--layout-model <id>` | registry default (`~google/gemini-flash-latest`) | Override the global-layout `text_generate` model. |
4141
- | `--gen-model <id>` | registry default (`openai/gpt-5.4-image-2`) | Override the `image_generate` model. |
4136
+ | `--gen-model <id>` | registry default (`openai/gpt-image-2`) | Override the `image_generate` model. |
4142
4137
  | `--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). |
4143
4138
  | `--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. |
4144
4139
  | `--aspect <ratio>` | inferred from the image, else `9:16` | Force the hero's aspect ratio (clamped into the placement set so it stays a free pass-through). |
@@ -4664,6 +4659,24 @@ baker landing critique spring-offer --full # also print advisory-tier findings
4664
4659
 
4665
4660
  Output is the standard envelope `{ ok, data, hints }` with `data = { advisory, slug, overall, counts, dimensions, findings }`. `dimensions` scores seven design families (typography, color, borders_depth, motion, spacing, copy, integrity) 0–1 (higher is better); `counts` is the block/warn/advisory tally.
4666
4661
 
4662
+ ## Brand Fonts (`baker brand fonts`)
4663
+
4664
+ Verifies that a brand's typefaces really load, and self-hosts them. A font that isn't actually available never announces itself — the browser substitutes its nearest fallback, so the page renders fine and is quietly off-brand. Reading a family name off a live site tells you what the CSS *asks for*, never what loads.
4665
+
4666
+ ```bash
4667
+ baker brand fonts check # are the brand's fonts real?
4668
+ baker brand fonts fetch "DM Sans" # self-host, weights taken from global.css
4669
+ baker brand fonts fetch "Manrope" --weights 500,700 # self-host specific weights
4670
+ baker brand fonts fetch "Inter" --subsets latin,latin-ext # add accented-glyph coverage
4671
+ ```
4672
+
4673
+ - **`check`** asks Google Fonts what it *actually* serves for every family and weight `src/styles/global.css` requests, and reports `requested` / `served` / `missing` plus the available unicode subsets. It exits 2 with `FONT_NOT_SERVED` when the brand asks for something Google won't serve, and distinguishes an unknown family name from a family that exists at other weights — the fix differs.
4674
+ - **`fetch`** downloads the family into `src/brand/fonts/` and prints the `@font-face` block to paste above `@theme`. Defaults to the `latin` subset; `--subsets latin,latin-ext` adds accented-glyph coverage for Spanish and Portuguese copy. One file per weight *and* subset, since Google ships them separately and `unicode-range` is carried through verbatim.
4675
+ - **It does not edit `global.css` for you.** Paste the block, then drop that family from the Google `@import` — otherwise the page self-hosts *and* still calls Google. Record the weights in `src/brand/BRAND.md` in the same change.
4676
+ - **Licensing:** Google Fonts are open-licensed and safe to self-host. A client's own commercial typeface is not — confirm before re-hosting one.
4677
+
4678
+ Complements the offline `validate-brand-fonts` check that runs in the scaffold's `verify`: that one compares what `global.css` declares against what provides it, with no network. Only the network can answer whether Google really serves a given family and weight.
4679
+
4667
4680
  ## Help & Discovery
4668
4681
 
4669
4682
  Every command supports `--help` for usage info:
@@ -1114,6 +1114,7 @@ function normalizeForCanonical(value) {
1114
1114
  // ../canvas-contract/src/registry.ts
1115
1115
  var OPENROUTER_IMAGE_AR = ["1:1", "2:3", "3:2", "3:4", "4:3", "4:5", "5:4", "9:16", "16:9", "21:9"];
1116
1116
  var OPENROUTER_IMAGE_AR_EXTREME = [...OPENROUTER_IMAGE_AR, "1:4", "4:1", "1:8", "8:1"];
1117
+ var OPENROUTER_IMAGE_AR_GPT = ["1:1", "2:3", "3:2", "3:4", "4:3", "9:16", "16:9", "21:9"];
1117
1118
  var OPENROUTER_IMAGE_SIZE = ["1K", "2K", "4K"];
1118
1119
  var OPENROUTER_IMAGE_SIZE_EXTENDED = ["0.5K", ...OPENROUTER_IMAGE_SIZE];
1119
1120
  var OPENROUTER_IMAGE_QUALITY = ["auto", "low", "medium", "high"];
@@ -1134,8 +1135,8 @@ var REPLICATE_VIDEO_MIMES = ["video/mp4", "video/webm", "video/quicktime"];
1134
1135
  var DECONSTRUCT_VIDEO_MIMES = ["video/mp4", "video/webm", "video/quicktime"];
1135
1136
  var REPLICATE_AUDIO_MIMES = ["audio/wav", "audio/mpeg", "audio/mp3"];
1136
1137
  var IMAGE_GENERATE_MODELS = [
1138
+ "openai/gpt-image-2",
1137
1139
  "openai/gpt-5.4-image-2",
1138
- "google/gemini-3.5-flash",
1139
1140
  "google/gemini-3.1-flash-image-preview",
1140
1141
  "google/gemini-3-pro-image-preview",
1141
1142
  "recraft/recraft-v4.1-pro-vector"
@@ -1234,28 +1235,37 @@ var MODEL_REGISTRY = {
1234
1235
  // `seed` is omitted on every entry — OpenRouter's `image_config` schema
1235
1236
  // has no `seed` slot for image-gen (different from video-gen), so passing
1236
1237
  // it would be a silent no-op.
1237
- "openai/gpt-5.4-image-2": {
1238
- label: "OpenAI GPT-5.4 Image 2",
1238
+ // The direct image model, not the `gpt-5.4-image-2` chat model that emitted
1239
+ // images as a side effect. Notable capability gaps vs the Gemini entries:
1240
+ // no `image_size` (OpenAI picks the pixel dimensions from the ratio, and a
1241
+ // `resolution` is silently ignored) and the narrower `..._AR_GPT` ratio set.
1242
+ "openai/gpt-image-2": {
1243
+ label: "OpenAI GPT Image 2",
1239
1244
  inputs: [],
1240
1245
  optional_inputs: [{ kind: "image", mimes: OPENROUTER_IMAGE_MIMES }],
1241
1246
  required: ["prompt"],
1242
1247
  params: {
1243
1248
  prompt: { kind: "string" },
1244
- aspect_ratio: { kind: "string", enum: OPENROUTER_IMAGE_AR },
1245
- image_size: { kind: "string", enum: OPENROUTER_IMAGE_SIZE },
1249
+ aspect_ratio: { kind: "string", enum: OPENROUTER_IMAGE_AR_GPT },
1246
1250
  quality: { kind: "string", enum: OPENROUTER_IMAGE_QUALITY }
1247
1251
  }
1248
1252
  },
1249
- "google/gemini-3.5-flash": {
1250
- // Newer Gemini flash image model. Always returns an image output.
1251
- label: "Google Gemini 3.5 Flash (Image)",
1253
+ // LEGACY — kept so canvases authored before the gpt-image-2 switch still
1254
+ // validate and re-run unchanged (`baker canvas rerun` restores a recorded
1255
+ // canvas verbatim, so dropping this model would break every stored creative
1256
+ // that used it). Its param surface is the ORIGINAL one on purpose: unlike
1257
+ // gpt-image-2 it still honors 4:5/5:4, and `image_size` is accepted-then-
1258
+ // ignored upstream rather than rejected. Verified live on /api/v1/images.
1259
+ // Don't reach for it in new work — the scaffolders default to gpt-image-2.
1260
+ "openai/gpt-5.4-image-2": {
1261
+ label: "OpenAI GPT-5.4 Image 2 (legacy)",
1252
1262
  inputs: [],
1253
1263
  optional_inputs: [{ kind: "image", mimes: OPENROUTER_IMAGE_MIMES }],
1254
1264
  required: ["prompt"],
1255
1265
  params: {
1256
1266
  prompt: { kind: "string" },
1257
- aspect_ratio: { kind: "string", enum: OPENROUTER_IMAGE_AR_EXTREME },
1258
- image_size: { kind: "string", enum: OPENROUTER_IMAGE_SIZE_EXTENDED },
1267
+ aspect_ratio: { kind: "string", enum: OPENROUTER_IMAGE_AR },
1268
+ image_size: { kind: "string", enum: OPENROUTER_IMAGE_SIZE },
1259
1269
  quality: { kind: "string", enum: OPENROUTER_IMAGE_QUALITY }
1260
1270
  }
1261
1271
  },
@@ -1607,6 +1617,36 @@ var MODEL_REGISTRY = {
1607
1617
  }
1608
1618
  }
1609
1619
  };
1620
+ function supportsParam(kind, model, param) {
1621
+ return MODEL_REGISTRY[kind]?.[model]?.params[param] !== void 0;
1622
+ }
1623
+ function supportedAspectRatios(kind, model) {
1624
+ const schema = MODEL_REGISTRY[kind]?.[model]?.params.aspect_ratio;
1625
+ return schema?.kind === "string" ? schema.enum : void 0;
1626
+ }
1627
+ function ratioValue(ratio) {
1628
+ const [w, h] = ratio.split(":").map(Number);
1629
+ if (!w || !h || !Number.isFinite(w) || !Number.isFinite(h)) return null;
1630
+ return w / h;
1631
+ }
1632
+ function nearestSupportedAspectRatio(kind, model, target) {
1633
+ const supported = supportedAspectRatios(kind, model);
1634
+ if (!supported || supported.includes(target)) return target;
1635
+ const wanted = ratioValue(target);
1636
+ if (wanted === null) return target;
1637
+ let best = target;
1638
+ let bestDistance = Number.POSITIVE_INFINITY;
1639
+ for (const candidate of supported) {
1640
+ const value = ratioValue(candidate);
1641
+ if (value === null) continue;
1642
+ const distance2 = Math.abs(Math.log(value / wanted));
1643
+ if (distance2 < bestDistance) {
1644
+ bestDistance = distance2;
1645
+ best = candidate;
1646
+ }
1647
+ }
1648
+ return best;
1649
+ }
1610
1650
 
1611
1651
  // ../canvas-contract/src/validateParams.ts
1612
1652
  function isManagedNodeKind(nodeType) {
@@ -6991,8 +7031,9 @@ var dialogueNode = delegated({
6991
7031
  // src/engine/nodes/remote/image.ts
6992
7032
  import { z as z17 } from "zod";
6993
7033
  var IMAGE_GENERATE_MODELS2 = [
7034
+ "openai/gpt-image-2",
7035
+ // Legacy — see the registry entry; kept so pre-switch canvases still run.
6994
7036
  "openai/gpt-5.4-image-2",
6995
- "google/gemini-3.5-flash",
6996
7037
  "google/gemini-3.1-flash-image-preview",
6997
7038
  "google/gemini-3-pro-image-preview",
6998
7039
  "recraft/recraft-v4.1-pro-vector"
@@ -7002,11 +7043,11 @@ var ImageGenerateParams = z17.object({
7002
7043
  prompt: z17.string().min(1),
7003
7044
  aspect_ratio: z17.enum(["1:1", "16:9", "9:16", "4:3", "3:4", "3:2", "2:3", "4:5", "5:4", "21:9", "1:4", "4:1", "1:8", "8:1"]).optional(),
7004
7045
  image_size: z17.enum(["0.5K", "1K", "2K", "4K"]).optional(),
7005
- // Rendering quality forwarded into `image_config`. OpenRouter models without a
7006
- // quality knob ignore it; the registry gates which models accept it (gpt-image, Gemini).
7046
+ // Rendering quality. OpenRouter models without a quality knob ignore it; the
7047
+ // registry gates which models accept it (gpt-image, Gemini).
7007
7048
  quality: z17.enum(["auto", "low", "medium", "high"]).optional(),
7008
- // Recraft v4 vector controls forwarded into `image_config`. Registry
7009
- // rejects them on non-Recraft models.
7049
+ // Recraft v4 vector controls, sent as the provider's own `controls` block.
7050
+ // Registry rejects them on non-Recraft models.
7010
7051
  strength: z17.number().min(0).max(1).optional(),
7011
7052
  rgb_colors: z17.array(z17.array(z17.number().int().min(0).max(255))).optional(),
7012
7053
  background_rgb_color: z17.array(z17.number().int().min(0).max(255)).optional()
@@ -7015,8 +7056,8 @@ var imageGenerateNode = delegated({
7015
7056
  id: "image_generate",
7016
7057
  version: "2.2.0",
7017
7058
  category: "image",
7018
- summary: "Generate images for ad creatives. Curated model set: GPT-5.4 Image, Gemini 3.5 Flash, Gemini 3.1 Flash Image Preview, Gemini 3 Pro Image, Recraft v4.1 Pro Vector. Per-model param support comes from the canvas-engine model registry.",
7019
- when_to_use: "Use for hero shots, product photography, illustrations, and vector logos. `recraft/recraft-v4.1-pro-vector` for crisp vector / logo work; `openai/gpt-5.4-image-2` for photorealistic; Gemini variants for fast iteration and editing via the `reference` input. `reference` accepts ONE image or an ARRAY of images \u2014 wire several to combine references in a single generation (e.g. a subject sheet + a font specimen + the original ad). Every reference is forwarded to the model in array order.",
7059
+ summary: "Generate images for ad creatives. Curated model set: GPT Image 2, Gemini 3.1 Flash Image Preview, Gemini 3 Pro Image, Recraft v4.1 Pro Vector. Per-model param support comes from the canvas-engine model registry.",
7060
+ when_to_use: "Use for hero shots, product photography, illustrations, and vector logos. `recraft/recraft-v4.1-pro-vector` for crisp vector / logo work; `openai/gpt-image-2` for photorealistic and in-image text (NOTE: it has no `image_size`, and no 4:5 / 5:4 \u2014 route portrait ad formats through Gemini or `image_aspect_adapt`); Gemini variants for fast iteration, extreme ratios, and editing via the `reference` input. `reference` accepts ONE image or an ARRAY of images \u2014 wire several to combine references in a single generation (e.g. a subject sheet + a font specimen + the original ad). Every reference is forwarded to the model in array order.",
7020
7061
  // `reference` is one image or an ordered array of images. The backend forwards
7021
7062
  // each as a separate `image_url` to the provider (OpenRouter accepts many).
7022
7063
  inputs: z17.object({ reference: z17.union([ImageRef, z17.array(ImageRef).min(1)]).optional() }).loose(),
@@ -7872,6 +7913,8 @@ export {
7872
7913
  IMAGE_GENERATE_MODELS,
7873
7914
  DEFAULT_VIDEO_GENERATE_MODEL,
7874
7915
  MODEL_REGISTRY,
7916
+ supportsParam,
7917
+ nearestSupportedAspectRatio,
7875
7918
  resolveConcurrency,
7876
7919
  ulid,
7877
7920
  isPersistedAssetRef,
@@ -7898,4 +7941,4 @@ export {
7898
7941
  defaultRegistry,
7899
7942
  createEngineFromEnv
7900
7943
  };
7901
- //# sourceMappingURL=chunk-ZBRVPPJP.js.map
7944
+ //# sourceMappingURL=chunk-JBDSJZBZ.js.map