@koda-sl/baker-cli 0.396.0-dev.88120240 → 0.397.0-dev.afb6a264b

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
@@ -5621,13 +5621,20 @@ Turn a source/inspiration image into a **runnable, self-validated static-ad canv
5621
5621
  2. **element selection** (`~google/gemini-flash-latest`) — picks the **main, identity-critical** elements (the brand logo, a showcased product, a trust badge) **plus any foreground/hero person or animal** — the emotional focal point — even a generic one, because a free-generated face/muzzle reads as AI and grows artifacts; the emotional hero always gets a real-reference slot. When the advertiser's logo appears in **more than one lockup** (a square/icon **mark** and a horizontal **wordmark**), each is emitted as its **own** element (e.g. `LOGO_MARK`, `LOGO_WORDMARK`) so you drop the right file in each slot instead of stretching one logo to cover both. The describe pass also records the ad's **typography** under a `fonts` block (each typeface's classification, a best-guess family, and its weight/case) so you know exactly what to drop at the brand-font slot. Background extras are dropped. Each element is stamped back onto its blueprint entry as a `reference_image` label so the JSON self-documents which slot grounds which subject.
5622
5622
  3. **global layout** (`~google/gemini-flash-latest`) — produces a structured `layout` block in `prompt.json`: the column/row grid, each region's `x_pct`/`y_pct` bounds, panel splits, background/shape, and every text block's relative size/weight/case/alignment. This is what gives the generator a precise composition to rebuild.
5623
5623
 
5624
- It then scaffolds a canvas that renders the ad in two layers. The image model (`openai/gpt-image-2.5-sunburst`, quality `xhigh`) paints **three candidate plates** (the picture with no text and no logo, keeping calm the areas the copy will occupy), an `image_select` judge scores them on a fixed rubric (mechanism kept, space for the offer, brand, artifacts, Stories safe zones, text) and picks one, and every delivered format is drawn by an HTML composition (`layout-4x5/`, `layout-9x16/`, one per format, snapshotted by `hyperframe_snapshot`): the copy from `prompt.json` in the brand's heading and body fonts from `src/brand/fonts/` (via `src/brand/tokens.json` when present), the call to action as a button in the brand CTA colour, and the real logo file from `src/brand/logos/`. The plates read `prompt.plate.json` (the scene, the reserved areas and the visual mechanisms; no 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. With no font file in `src/brand/fonts/` (or `--skip-font`) the copy is baked into the plates instead, and every other shape is re-framed by `image_aspect_adapt` on `google/gemini-3-pro-image`, never cropped. Detected elements get **one `[TODO]` ingest slot** each (the logo slot is pre-filled with the brand logo and feeds the composition, never the picture model), 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 — the same identity lock the video scaffold uses, so the muzzle stays consistent and artifact-free from a single reference. 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** lists every real asset to drop in (and which heroes get a sheet).
5624
+ It then scaffolds a canvas that draws **only what `prompt.json` holds**; no element is mandatory. The report's `mode` names which of four the ad is:
5625
+
5626
+ - **`composed`**: the image model (`openai/gpt-image-2.5-sunburst`, quality `xhigh`) paints up to **three candidate plates** (`--takes`, default 3; the picture with no overlay text and no logo, keeping calm the areas the copy will occupy), an `image_select` judge scores them on one dominant idea, thumbnail legibility, mechanism kept, space for what is drawn, brand with the logo covered, artifacts, Stories safe zones and text, and every delivered format is drawn by an HTML composition (`layout-4x5/`, `layout-9x16/`, one per format, snapshotted by `hyperframe_snapshot`). Each line sits at its layout region, carried through the format's crop or extension into its safe area (a band only when the layout gives it no position), and is set the way the reference set it: plain, underlined, followed by an arrow, a button (in the brand CTA colour), a sticker or a highlight. A call to action is never forced into a pill.
5627
+ - **`picture_only`**: the concept has no copy, so the plate is the ad and nothing is drawn on it.
5628
+ - **`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.
5629
+ - **`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.
5630
+
5631
+ **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.
5625
5632
 
5626
5633
  **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.
5627
5634
 
5628
- **After a run** the CLI reads the finished ad back: every delivered image's pixel shape against its format, and one vision pass that reads each word against the copy and checks the logo, clipping, legibility and artifacts. stdout carries `verdict` (`ready` | `ready_with_notes` | `needs_fixes` | `not_reviewed`) and `judge` (the per-plate scores); the findings are stored on the run as its `review`. `needs_fixes` means the ad is not done even though every node completed.
5635
+ **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.
5629
5636
 
5630
- The checklist `note` reminds you to fill **logo** slots from the company's committed brand logo in `src/brand/logos/` (per `BRAND.md`) — **not** from `baker images logo <domain>`, whose Brandfetch CDN often serves an outdated mark (a common cause of an old logo shipping on a live ad).
5637
+ 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.
5631
5638
 
5632
5639
  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.
5633
5640
 
@@ -5648,11 +5655,12 @@ baker canvas run ./static-ad.canvas.json
5648
5655
  | `--select-model <id>` | registry default (`~google/gemini-flash-latest`) | Override the element-selection `text_generate` model. |
5649
5656
  | `--layout-model <id>` | registry default (`~google/gemini-flash-latest`) | Override the global-layout `text_generate` model. |
5650
5657
  | `--gen-model <id>` | `openai/gpt-image-2.5-sunburst` | Override the plate model. |
5651
- | `--takes <n>` | `3` | Plates rendered and judged (1-3); `1` skips the judge. |
5658
+ | `--takes <n>` | `3` | Plates rendered and judged (1-3); `1` skips the judge. A text-only ad renders none. |
5659
+ | `--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. |
5652
5660
  | `--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). |
5653
5661
  | `--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. |
5654
5662
  | `--aspect <ratio>` | inferred from the image, else `9:16` | Force the lead format (it leads the placement set when the set has it). |
5655
- | `--skip-font` | off | Bake the copy into the plates even though a brand font exists. |
5663
+ | `--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). |
5656
5664
  | `--skip-actor-sheets` | off | Ground each animal hero on its lone dropped photo instead of a generated multi-view reference sheet. |
5657
5665
  | `--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`. |
5658
5666
 
@@ -6184,7 +6192,7 @@ baker creatives performance --days 14 --full # + ids, last-7-day figures, on
6184
6192
  | `--days <n>` | Window ending today, 1-90 (default 30). |
6185
6193
  | `--full` | Adds creative and image ids, result type, days live, last-7-day CTR and cost per result, and one row per ad. |
6186
6194
 
6187
- 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, persona, awareness, hypothesis and reference fields read from the local `src/creatives/<slug>/_definition.md`. `hints` name worn-out creatives, the angle with the lowest cost per result, and unreadable ad accounts.
6195
+ 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`. `hints` name worn-out creatives, 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.
6188
6196
 
6189
6197
  ### `baker creatives feedback [--slug <slug>] [--full]`
6190
6198
 
@@ -5,8 +5,10 @@
5
5
  <meta name="viewport" content="width=__WIDTH__, height=__HEIGHT__" />
6
6
  <script src="./gsap.min.js"></script>
7
7
  <style>
8
- /* The brand's own faces, staged beside this file by the render. One range per face,
9
- so a single-weight file is used as it is instead of being faked bolder. */
8
+ /* The ad's own faces, staged beside this file by the render (a brand file or a web font
9
+ file). One range per face, so a single-weight file is used as it is instead of being
10
+ faked bolder. The browser fetches a face only when text uses it, and text uses it
11
+ only when a face was staged (see FRAME.fonts below). */
10
12
  @font-face { font-family: 'AdHeadline'; src: url('./headline.font'); font-weight: 100 900; font-display: block; }
11
13
  @font-face { font-family: 'AdBody'; src: url('./body.font'); font-weight: 100 900; font-display: block; }
12
14
  * { margin: 0; padding: 0; box-sizing: border-box; }
@@ -14,22 +16,20 @@
14
16
  #root { position: absolute; inset: 0; overflow: hidden; }
15
17
  .plate { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; object-position: 50% 50%; }
16
18
  .scrim { position: absolute; left: 0; right: 0; pointer-events: none; display: none; }
17
- .band { position: absolute; display: flex; flex-direction: column; gap: 0; }
19
+ .group { position: absolute; display: flex; flex-direction: column; gap: 0; }
18
20
  .block { font-family: 'AdBody', 'Helvetica Neue', Arial, sans-serif; overflow-wrap: break-word; text-wrap: pretty; }
19
21
  .block.headline, .block.subheadline, .block.cta { font-family: 'AdHeadline', 'AdBody', 'Helvetica Neue', Arial, sans-serif; }
20
22
  .block.headline { text-wrap: balance; letter-spacing: -0.01em; }
21
- .cta-pill { display: inline-block; border-radius: 999px; font-weight: 700; line-height: 1.1; }
23
+ .fill { display: inline-block; line-height: 1.1; padding: 0.5em 1.2em; }
24
+ .fill.highlight { padding: 0.12em 0.4em; box-decoration-break: clone; -webkit-box-decoration-break: clone; }
25
+ .fill.sticker { transform: rotate(-3deg); }
22
26
  .logo { display: block; width: auto; object-fit: contain; }
23
27
  </style>
24
28
  </head>
25
29
  <body>
26
30
  <div id="root" data-composition-id="main" data-start="0" data-duration="1" data-width="__WIDTH__" data-height="__HEIGHT__">
27
- <img class="plate" id="plate" src="./plate.png" alt="" />
28
31
  <div class="scrim" id="scrim-top"></div>
29
32
  <div class="scrim" id="scrim-bottom"></div>
30
- <div class="band" id="band-top" data-role="copy-top"></div>
31
- <div class="band" id="band-middle" data-role="copy-middle"></div>
32
- <div class="band" id="band-bottom" data-role="copy-bottom"></div>
33
33
  </div>
34
34
 
35
35
  <script>
@@ -43,11 +43,31 @@
43
43
  var box = { x: safe.left * W, y: safe.top * H, w: W * (1 - safe.left - safe.right), h: H * (1 - safe.top - safe.bottom) };
44
44
  // One unit per percent of the shorter side, so type reads the same size on every format.
45
45
  var U = Math.min(W, H) / 100;
46
+ // A text-only ad has no picture (`plate: null`): the copy sits on the brand's ground.
47
+ var hasPlate = FRAME.plate !== null;
48
+ var ground = AD.background || { color: "#111111", ink: "#ffffff" };
46
49
 
47
50
  var SIZE = { x_large: 8.6, large: 6.6, medium: 4.4, small: 3.0 };
48
51
  var WEIGHT = { bold: 800, semibold: 600, regular: 400 };
49
52
  var CASE = { upper: "uppercase", title: "capitalize", sentence: "none" };
50
53
  var MAX_SHARE = { top: 0.44, middle: 0.3, bottom: 0.36 };
54
+ var RADIUS = { pill: "999px", rounded: "0.35em", square: "0" };
55
+
56
+ // With no face staged, the text never names the ad's faces, so the browser never asks
57
+ // for a file that is not there (a 404 the pre-render check refuses).
58
+ var FALLBACK_FACE = "'Helvetica Neue', Arial, sans-serif";
59
+ var facesStaged = FRAME.fonts !== false;
60
+
61
+ if (hasPlate) {
62
+ var plate = document.createElement("img");
63
+ plate.className = "plate";
64
+ plate.id = "plate";
65
+ plate.src = "./plate.png";
66
+ plate.alt = "";
67
+ root.insertBefore(plate, root.firstChild);
68
+ } else {
69
+ root.style.background = ground.color;
70
+ }
51
71
 
52
72
  function luma(hex) {
53
73
  var m = /^#?([0-9a-f]{6})$/i.exec(hex || "");
@@ -56,18 +76,23 @@
56
76
  return 0.2126 * ((v >> 16) & 255) + 0.7152 * ((v >> 8) & 255) + 0.0722 * (v & 255);
57
77
  }
58
78
 
59
- var bands = { top: [], middle: [], bottom: [] };
60
- var drawCopy = FRAME.draw_copy !== false;
61
- (drawCopy ? AD.copy || [] : []).forEach(function (b) {
62
- (bands[b.band] || bands.top).push(b);
79
+ function alignSelf(align) {
80
+ return align === "left" ? "flex-start" : align === "right" ? "flex-end" : "center";
81
+ }
82
+
83
+ // Every line is drawn in its group: the layout region the reference put it in (placed
84
+ // at that region's box on this format), or a band when the layout gave no position.
85
+ var groups = {};
86
+ var bands = { top: null, middle: null, bottom: null };
87
+ (AD.groups || []).forEach(function (g) {
88
+ groups[g.id] = g;
63
89
  });
64
90
 
65
91
  function bandEl(name) {
66
- return document.getElementById("band-" + name);
67
- }
68
-
69
- function place(name) {
70
- var el = bandEl(name);
92
+ if (bands[name]) return bands[name];
93
+ var el = document.createElement("div");
94
+ el.className = "group band";
95
+ el.dataset.band = name;
71
96
  el.style.left = box.x + "px";
72
97
  el.style.width = box.w + "px";
73
98
  el.style.gap = 1.6 * U + "px";
@@ -77,80 +102,110 @@
77
102
  el.style.top = box.y + box.h / 2 + "px";
78
103
  el.style.transform = "translateY(-50%)";
79
104
  }
105
+ el.dataset.max = String(box.h * MAX_SHARE[name]);
106
+ root.appendChild(el);
107
+ bands[name] = el;
108
+ return el;
80
109
  }
81
110
 
82
- function alignSelf(align) {
83
- return align === "left" ? "flex-start" : align === "right" ? "flex-end" : "center";
111
+ var placed = {};
112
+ function containerFor(groupId, band) {
113
+ var g = groups[groupId];
114
+ var at = g && g.at && g.at[FRAME.format];
115
+ if (!at) return bandEl((g && g.band) || band || "top");
116
+ if (placed[groupId]) return placed[groupId];
117
+ var el = document.createElement("div");
118
+ el.className = "group placed";
119
+ el.style.left = (at.x[0] / 100) * W + "px";
120
+ el.style.width = ((at.x[1] - at.x[0]) / 100) * W + "px";
121
+ el.style.top = (at.y[0] / 100) * H + "px";
122
+ el.style.minHeight = ((at.y[1] - at.y[0]) / 100) * H + "px";
123
+ el.style.justifyContent = "center";
124
+ el.style.gap = 1.2 * U + "px";
125
+ // Copy rewritten for our brand runs longer than the source's: allow some growth
126
+ // past the region before the type shrinks.
127
+ el.dataset.max = String(((at.y[1] - at.y[0]) / 100) * H * 1.35);
128
+ root.appendChild(el);
129
+ placed[groupId] = el;
130
+ return el;
84
131
  }
85
132
 
86
- function addLogo(name, align) {
133
+ function addLogo(logo) {
87
134
  var img = document.createElement("img");
88
135
  img.className = "logo";
89
136
  img.src = "./logo.png";
90
137
  img.alt = "";
91
138
  img.style.height = 5.4 * U + "px";
92
139
  img.style.maxWidth = box.w * 0.42 + "px";
93
- img.style.alignSelf = alignSelf(align);
140
+ img.style.alignSelf = alignSelf(logo.align);
94
141
  img.onerror = function () {
95
142
  img.remove();
96
143
  };
97
- var el = bandEl(name);
98
- if (name === "bottom") el.appendChild(img);
144
+ var el = containerFor(logo.group, logo.band);
145
+ if (el.dataset.band === "bottom") el.appendChild(img);
99
146
  else el.insertBefore(img, el.firstChild);
100
147
  }
101
148
 
102
- function addBlock(name, b) {
149
+ function addBlock(b) {
103
150
  var el = document.createElement("div");
104
151
  el.className = "block " + b.role;
105
- el.dataset.size = String(SIZE[b.size] || SIZE.medium);
106
- var size = b.role === "fine_print" ? 2.4 : b.role === "cta" ? 4.2 : SIZE[b.size] || SIZE.medium;
152
+ if (!facesStaged) el.style.fontFamily = FALLBACK_FACE;
153
+ var size = b.role === "fine_print" ? 2.4 : SIZE[b.size] || SIZE.medium;
107
154
  el.dataset.base = String(size);
108
155
  el.style.fontSize = size * U + "px";
109
- el.style.fontWeight = String(b.role === "cta" ? 700 : WEIGHT[b.weight] || 400);
156
+ el.style.fontWeight = String(WEIGHT[b.weight] || (b.role === "cta" ? 700 : 400));
110
157
  el.style.lineHeight = b.role === "headline" ? "1.04" : "1.22";
111
158
  el.style.textTransform = CASE[b.case] || "none";
112
159
  el.style.textAlign = b.align || "center";
113
160
  el.style.alignSelf = alignSelf(b.align);
114
- if (b.role === "cta") {
115
- var pill = document.createElement("span");
116
- pill.className = "cta-pill";
117
- pill.textContent = b.text;
118
- pill.style.background = AD.cta.background;
119
- pill.style.color = AD.cta.color;
120
- pill.style.padding = "0.55em 1.4em";
121
- el.appendChild(pill);
161
+ var t = b.treatment || { kind: b.role === "cta" && !AD.groups ? "button" : "plain" };
162
+ var ink = b.color || (hasPlate ? "#ffffff" : ground.ink);
163
+ var text = b.text;
164
+ if (t.kind === "arrow" && !/[→›»]\s*$/.test(text)) text = text + " →";
165
+ if (t.kind === "button" || t.kind === "sticker" || t.kind === "highlight") {
166
+ var fill = document.createElement("span");
167
+ fill.className = "fill " + t.kind;
168
+ fill.textContent = text;
169
+ fill.style.background = t.background || (AD.cta && AD.cta.background) || "#111111";
170
+ fill.style.color = t.color || (AD.cta && AD.cta.color) || "#ffffff";
171
+ fill.style.borderRadius = RADIUS[t.shape] || RADIUS.rounded;
172
+ el.appendChild(fill);
122
173
  } else {
123
- el.textContent = b.text;
124
- var ink = b.color || "#ffffff";
174
+ el.textContent = text;
125
175
  el.style.color = ink;
176
+ if (t.kind === "underline") {
177
+ el.style.textDecoration = "underline";
178
+ el.style.textDecorationThickness = "0.08em";
179
+ el.style.textUnderlineOffset = "0.18em";
180
+ }
126
181
  var dark = (luma(ink) || 255) < 110;
127
- el.style.textShadow = dark ? "0 1px 10px rgba(255,255,255,0.35)" : "0 2px 14px rgba(0,0,0,0.45)";
128
182
  el.dataset.dark = dark ? "1" : "0";
183
+ if (hasPlate) el.style.textShadow = dark ? "0 1px 10px rgba(255,255,255,0.35)" : "0 2px 14px rgba(0,0,0,0.45)";
129
184
  }
130
- bandEl(name).appendChild(el);
185
+ containerFor(b.group, b.band).appendChild(el);
131
186
  }
132
187
 
133
- ["top", "middle", "bottom"].forEach(function (name) {
134
- place(name);
135
- bands[name].forEach(function (b) {
136
- addBlock(name, b);
137
- });
138
- });
139
- if (AD.logo) addLogo(AD.logo.band, AD.logo.align);
140
-
141
- // Shrink a band's type until it fits its share of the safe area and no line runs
142
- // past the edge. The copy is whatever prompt.json says today, so its length is
143
- // not known when the layout is written.
144
- function fit(name) {
145
- var el = bandEl(name);
146
- var max = box.h * MAX_SHARE[name];
188
+ var drawCopy = FRAME.draw_copy !== false;
189
+ (drawCopy ? AD.copy || [] : []).forEach(addBlock);
190
+ if (AD.logo) addLogo(AD.logo);
191
+
192
+ function containers() {
193
+ return Array.prototype.slice.call(root.querySelectorAll(".group"));
194
+ }
195
+
196
+ // Shrink a group's type until it fits its room and no line runs past its edge. The
197
+ // copy is whatever prompt.json says today, so its length is not known when the
198
+ // layout is written.
199
+ function fit(el) {
200
+ var max = Number(el.dataset.max);
201
+ var width = el.getBoundingClientRect().width;
147
202
  var scale = 1;
148
203
  for (var i = 0; i < 40; i++) {
149
204
  var tooTall = el.scrollHeight > max;
150
205
  var tooWide = Array.prototype.some.call(el.children, function (c) {
151
- return c.scrollWidth > box.w + 1;
206
+ return c.scrollWidth > width + 1;
152
207
  });
153
- if (!tooTall && !tooWide) break;
208
+ if ((!tooTall && !tooWide) || scale < 0.45) break;
154
209
  scale *= 0.94;
155
210
  Array.prototype.forEach.call(el.querySelectorAll(".block"), function (c) {
156
211
  c.style.fontSize = Number(c.dataset.base) * U * scale + "px";
@@ -158,15 +213,16 @@
158
213
  }
159
214
  }
160
215
 
161
- // A soft scrim behind a band of light type (or a light one behind dark type), so the
162
- // copy reads on any plate without boxing it in.
216
+ // A soft scrim behind a band of light type over the picture (or a light one behind
217
+ // dark type), so copy with no position of its own reads on any plate. Copy placed
218
+ // by the layout sits on the calm area the plate kept for it.
163
219
  function scrim(name) {
164
- if (name === "middle" || bands[name].length === 0) return;
165
- var el = bandEl(name);
220
+ var el = bands[name];
221
+ if (!hasPlate || !el) return;
166
222
  var s = document.getElementById("scrim-" + name);
167
- var firstText = el.querySelector(".block:not(.cta)");
168
- var dark = firstText && firstText.dataset.dark === "1";
169
- var tint = dark ? "255,255,255" : "0,0,0";
223
+ var firstText = el.querySelector(".block[data-dark]");
224
+ if (!firstText) return;
225
+ var tint = firstText.dataset.dark === "1" ? "255,255,255" : "0,0,0";
170
226
  var rect = el.getBoundingClientRect();
171
227
  var reach = name === "top" ? rect.bottom + 6 * U : H - rect.top + 6 * U;
172
228
  s.style.display = "block";
@@ -187,7 +243,7 @@
187
243
 
188
244
  var ready = document.fonts && document.fonts.ready ? document.fonts.ready : Promise.resolve();
189
245
  ready.then(function () {
190
- ["top", "middle", "bottom"].forEach(fit);
246
+ containers().forEach(fit);
191
247
  ["top", "bottom"].forEach(scrim);
192
248
  root.setAttribute("data-ready", "1");
193
249
  });
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "static-ad",
3
- "title": "Static ad: a rendered plate with the copy, the call to action and the logo drawn on top",
4
- "description": "Written by `baker canvas scaffold-static-ad`, one copy per delivered format. The plate is the image model's clean picture; the copy, the CTA and the logo are drawn here from prompt.compose.json (derived from prompt.json) in the brand's own fonts and logo file. Width and height are this format's delivered size.",
3
+ "title": "Static ad: the picture (or the brand colours) with what the concept holds drawn on top",
4
+ "description": "Written by `baker canvas scaffold-static-ad`, one copy per delivered format. Draws only what prompt.compose.json (derived from prompt.json) holds: each line of copy where the source layout placed it, set the way the reference set it (plain, underlined, with an arrow, as a button or sticker), in the staged fonts, and the logo file only when the concept shows one. The plate is the image model's clean picture; a text-only ad has none and sits on the brand colour. Width and height are this format's delivered size.",
5
5
  "width": 1080,
6
6
  "height": 1350,
7
7
  "fps": 1,
@@ -9,15 +9,15 @@
9
9
  "inputs": {
10
10
  "plate": {
11
11
  "kind": "image",
12
- "required": true,
12
+ "required": false,
13
13
  "staged_as": "plate.png",
14
- "description": "The picture, with no text and no logo."
14
+ "description": "The picture, with no overlay text and no logo. Absent for a text-only ad."
15
15
  },
16
16
  "logo": {
17
17
  "kind": "image",
18
18
  "required": false,
19
19
  "staged_as": "logo.png",
20
- "description": "The brand's logo file."
20
+ "description": "The brand logo file, wired only when the concept shows a logo."
21
21
  },
22
22
  "font_headline": {
23
23
  "kind": "font",
@@ -47,7 +47,7 @@
47
47
  "frame": {
48
48
  "kind": "json",
49
49
  "required": true,
50
- "description": "This format's safe area and whether the copy is drawn here."
50
+ "description": "This format: its safe area, whether copy is drawn, the picture it is cut from (plate: null for none), and whether fonts are staged."
51
51
  }
52
52
  }
53
53
  }