@koda-sl/baker-cli 0.447.0-dev.397af7869 → 0.452.0-dev.63867ce0e

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
@@ -1814,10 +1814,10 @@ baker creatives make <idea> [--slug <s>] [--adset <id>] [--reference <adId>] [--
1814
1814
  baker creatives learn [--days <n>] [--dry-run] # what each idea's ads did, written back onto the idea
1815
1815
  ```
1816
1816
 
1817
- - `brief` returns a verdict, not raw numbers: `account.bottleneck` is `hook`, `after_click`, `none` or `too_little_data`. A part that cannot be read says `unavailable`. `market` says what competitors' ads already say (the followed brands, else the library), how sophisticated the market is, and which awareness stages nobody speaks to.
1817
+ - `brief` returns a verdict, not raw numbers: `account.bottleneck` is `hook`, `after_click`, `none` or `too_little_data`. A part that cannot be read says `unavailable`; the market searches (the followed brands, and the library for what COMPANY.md's first line says the company sells, in a few words) run at once and get 12 seconds, so a slow ad library leaves them out rather than holding the brief. With no ideas on file, its hint lists the values `awareness`, `angle` and `format` take. `market` says what competitors' ads already say (the followed brands, else the library), how sophisticated the market is, and which awareness stages nobody speaks to.
1818
1818
  - `ideas` is free and local. An idea's `evidence` resolves to `src/content/insights/` entries, or it is an `explore` bet. Numbers must be in the Knowledge or our own customers' quotes (a Reddit, forum or web quote never sources a claim), no two ideas may share an angle and format, and a review or testimonial format needs a quote from our own customer.
1819
1819
  - `learn` pools each built idea's creatives, judges them against the rest of the account's cost per result and writes `status` (`winner`, `failed`, `inconclusive`, `testing`) and a frozen `result` onto the idea. `ideas` then warns when a new idea repeats a failed one.
1820
- - `make` builds one idea. A static searches the idea's container for ads made for our viewer, checks the closest three with `winning-ads fit` and scaffolds from the first that fits. For a new creative it starts scaffolding the best-ranked one while the fits are asked (kept when it fits, removed whole when another is kept), so the reference's describe pass no longer waits for them. A video idea takes its beats spec. Either way it records the idea on `_definition.md`, sets `prompt.json`'s `message` and syncs the definition. Drafted copy lines claiming a figure the company never wrote come back first in `hints`. When no proven ad fits (or with `--from-idea`), a static starts from the idea itself: `prompt.json` holds the message and placeholders, and `canvas run` refuses to render until the scene and copy are written.
1820
+ - `make` builds one idea. A static searches the idea's container for ads made for our viewer, checks the closest three with `winning-ads fit` and scaffolds from the first that fits. For a new creative it starts scaffolding the best-ranked one while the fits are asked (kept when it fits, stopped and removed whole when another or none is kept), so the reference's describe pass no longer waits for them. A creative whose ad is already written is never built over (`ALREADY_MADE`): edit its `prompt.json`, or pass `--slug` for another ad of the same idea. A video idea takes its beats spec. Either way it records the idea on `_definition.md`, sets `prompt.json`'s `message` and syncs the definition. Drafted copy lines claiming a figure the company never wrote come back first in `hints`. When no proven ad fits (or with `--from-idea`), a static starts from the idea itself: `prompt.json` holds the message and placeholders, and `canvas run` refuses to render until the scene and copy are written.
1821
1821
 
1822
1822
  ### Competitive Intelligence (`baker research`)
1823
1823
 
@@ -4627,7 +4627,7 @@ Open record keyed by the composition's `meta.json` inputs.
4627
4627
 
4628
4628
  ##### `static_prefilter`
4629
4629
 
4630
- Local; no credits (one box-reader call per variant, about $0.005, which rides on the run record's review spend). Ranks a static ad's variants before the judge by the deterministic checks (blocking defects, then every line of the ad spec's copy read back with its box, the brand's logo found, the button's words, every word and the logo inside the master's band) and passes on only the best-ranked, so the judge only breaks an exact tie. A variant the reader could not read ties with the best. **Inputs:** `images` (the variants' whole list), `spec` (the ad spec, `$ref:prompt.asset`). **Params:** `formats` (the delivered sizes). **Outputs:** `images` (the best-ranked variants, in an order seeded by the pictures rather than the render order; never empty: when every variant fails, the one with the fewest defects passes as `flagged`), `order` (which variant each of `images` is), `dropped` (each variant set aside, with its index and reasons), `flagged` (the variant kept when all failed) and `usage` (the reader's spend). Scaffolded by `scaffold-static-ad` between the variants and the judge.
4630
+ Local; no credits (one box-reader call per variant, about $0.005, which rides on the run record's review spend). Ranks a static ad's variants before the judge by the deterministic checks (blocking defects, then every line of the ad spec's copy read back with its box, the brand's logo found, the button's words, every word and the logo inside the master's band) and passes on only the best-ranked, so the judge only breaks an exact tie. A variant the reader could not read ties with the best. **Inputs:** `images` (the variants' whole list), `spec` (the ad spec, `$ref:prompt.asset`). **Params:** `formats` (the delivered sizes). **Outputs:** `images` (the best-ranked variants, in an order seeded by the pictures rather than the render order; never empty: when every variant fails, the one with the fewest defects passes as `flagged`), `order` (which variant each of `images` is), `dropped` (each variant set aside, with its index and reasons), `flagged` (the variant kept when all failed), `image` (the chosen picture: when a variant rendered at another shape, a 3:4 for a 4:5, would lose a word or the button to a cut about its centre, it comes cut to the master's frame where every word stays whole, else as rendered) and `usage` (the reader's spend). Scaffolded by `scaffold-static-ad` between the variants and the judge.
4631
4631
 
4632
4632
  ##### `static_safe_fit`
4633
4633
 
@@ -6009,7 +6009,7 @@ The canvas is validated before it is written. stdout returns `{ ok, canvas_path,
6009
6009
 
6010
6010
  **Who sees the ad.** `--target-adset <id>` names the Meta ad set the ad will run in. Before anything is billed, the viewer its targeting reaches is read exactly as `baker ads meta adsets --audience` reads it (`temperature`, what they already `knows`, `placements`) and written to `_definition.md` as `targetAdSet` and `viewer` (an existing definition keeps every other field). The sizes, the brief and the prefilter keep clear of that viewer's placements (`staticSafeArea`; `automatic`, a bare `facebook`/`instagram` or no placements is Advantage+, Reels included), and every `baker canvas run` checks the ad against that viewer. With no ad account, give the viewer from the Knowledge: `--viewer-temperature cold|warm|hot [--viewer-knows brand,offer,product] [--viewer-placements <placement,…>]`, recorded with `source: knowledge`; next to `--target-adset` they correct the read, and its `reasons` say so. A bad value exits 2 (`invalid_viewer`) before any billed pass; an ad set that cannot be read never stops the scaffold (a hint says why, and the sizes keep clear of every placement). With neither flag, a `viewer` (or `targetAdSet`) already in `_definition.md` is used as it is. stdout carries `target_adset`, `viewer` and a `checklist.viewer` line.
6011
6011
 
6012
- **After a run** the CLI reads the finished ad back on what makes a static win, not a checklist of elements: every delivered image's pixel shape against its format; **every line of `prompt.json`'s copy (in-scene and designed alike) read back with its box by a box reader and compared** (a line more than 2% off, missing, or joined by words nobody asked for is blocking; case and punctuation fold, as does a capital I read as an l (one stroke in a sans-serif), accents do not; words the picture adds are blocking only when set at the copy's usual size or making a claim (a figure, a price, an offer), and otherwise a note, as a screen's code, a poster or a mug in the scene; a copy line is "on the ad twice" only when most of its words repeat, never a word or two echoed on a prop; on a native screen, which code typesets from the spec, a line read differently is the reader's error and only a missing one counts); **the ad's own copy and its logo measured against the format's safe area** (`staticSafeArea`, the one owner of those areas; `must-show.ts` decides what is measured: never what the scene prints, nor a copy line printed again in the scene while the line itself sits clear). On a 9:16, which runs in Stories and Reels alike, what Stories' header or bottom bar covers (and any other app's interface named) is said as "where Stories and Reels cover it" ("where Reels covers it" for an ad set that runs only in Reels) and blocks for the logo, the button, the headline, the figure and the offer, while a supporting line (body, caption, label, small print) there is a note; what only Reels' taller footer covers is one note, and only for the lead line, since Stories shows it and the 9:16 is laid out in Stories' band; words merely close to where an app draws are no finding. The steps that place a size clear of an interface (`static_safe_fit`, `static_place`) are told the same copy (`prompt.json`'s lines, their roles, and the brand's name and the logo's words, a wordmark read close to them counting as the logo) and move the ad for those alone, a supporting line included only where the frame cuts it; on a feed image such as a 4:5, only a design margin, so words close to the edge are a warning and only words the frame cuts are blocking); a copy line written over two lines (`"15%\noff"`) is matched part by part wherever the reader finds each; designed type in a colour far from the brand palette is a warning; supporting lines too small to read on a phone are one note per size; the main line set smaller than a supporting one is a note, the brand's wordmark never counted as part of a line that starts with its name; the logo set twice is a note, a far smaller mark printed on something in the scene (a hoodie, a bag) is not. When the copy names a real person ("Lautaro Suarez, CTO at Vibiz"), the run casts them as themselves before it renders (`baker avatars research --create`, their avatar's sheet added to `prompt.json` `references` as a `PERSON_n`), and a face shown as a named person the ad has no photo of is blocking. When the reader cannot answer, the vision pass transcribes the copy instead and judges the safe zones by eye. Then one vision pass for clipping, overlaps, artifacts, one dominant idea, the brand with the logo covered and the concept's mechanisms, with every format shown a second time at phone-feed thumbnail size (240px wide, same call): a lead message unreadable at that size is blocking. A missing logo, CTA or copy is never a finding; a logo that is drawn and broken is. stdout carries `verdict` (`ready` | `ready_with_notes` | `needs_fixes` | `not_reviewed`), `judge` (the per-candidate scores) and hints that end with what the run made; the findings are stored on the run as its `review`. `needs_fixes` means the ad is not done even though every node completed, and the run is recorded with `presentable: false` so it is never shown as a version; every fix is an edit to `prompt.json` and a re-run (wrong words: the lines in `copy`; wrong picture: the `scene`; a bad roll: `--regenerate static_variants`; one size wrong while the chosen picture is right: `--regenerate adapt_<size>`, which re-lays it out, named in the hints); a blocking defect is first corrected by the run itself (`--autofix`, above). The checks rank the variants before the pick and the judge only breaks an exact tie, comparing pairwise against the compiled brief, knowing the brand and the viewer; every variant stays on offer to the person in Baker. Within one chat turn an ad gets two runs you start (each with its automatic corrections), and none once it is `ready` or `ready_with_notes`: the next is refused (`CONFLICT`, exit 2) with what to show instead; the person's next message starts a new turn (`turn-renders.ts`). When a defect still stands after the automatic rounds, the hints say to fix it with a `--fix`, then by changing the spec or the approach, and if it still cannot be fixed to show the best version and name what is left in one plain sentence, without a question.
6012
+ **After a run** the CLI reads the finished ad back on what makes a static win, not a checklist of elements: every delivered image's pixel shape against its format; **every line of `prompt.json`'s copy (in-scene and designed alike) read back with its box by a box reader and compared** (a line more than 2% off, missing, or joined by words nobody asked for is blocking; case and punctuation fold, as does a capital I read as an l (one stroke in a sans-serif), accents do not; words the picture adds are blocking only when set at the copy's usual size or making a claim (a figure, a price, an offer), and otherwise a note, as a screen's code, a poster or a mug in the scene, except a price (a currency sign beside a figure) the copy does not hold, blocking at any size and blurred by the correction, the document it is on kept; every render is asked for no readable prices on a bill, quote, receipt, screen or price tag beyond the copy; a copy line is "on the ad twice" only when most of its words repeat, never a word or two echoed on a prop; on a native screen, which code typesets from the spec, a line read differently is the reader's error and only a missing one counts); **the ad's own copy and its logo measured against the format's safe area** (`staticSafeArea`, the one owner of those areas; `must-show.ts` decides what is measured: never what the scene prints, nor a copy line printed again in the scene while the line itself sits clear). On a 9:16, which runs in Stories and Reels alike, what Stories' header or bottom bar covers (and any other app's interface named) is said as "where Stories and Reels cover it" ("where Reels covers it" for an ad set that runs only in Reels) and blocks for the logo, the button, the headline, the figure and the offer, while a supporting line (body, caption, label, small print) there is a note; what only Reels' taller footer covers is one note, and only for the lead line, since Stories shows it and the 9:16 is laid out in Stories' band; words merely close to where an app draws are no finding. The steps that place a size clear of an interface (`static_safe_fit`, `static_place`) are told the same copy (`prompt.json`'s lines, their roles, and the brand's name and the logo's words, a wordmark read close to them counting as the logo) and move the ad for those alone, a supporting line included only where the frame cuts it; on a feed image such as a 4:5, only a design margin, so words close to the edge are a warning and only words the frame cuts are blocking); a copy line written over two lines (`"15%\noff"`) is matched part by part wherever the reader finds each; designed type in a colour far from the brand palette is a warning; supporting lines too small to read on a phone are one note per size; the main line set smaller than a supporting one is a note, the brand's wordmark never counted as part of a line that starts with its name; the logo set twice is a note, a far smaller mark printed on something in the scene (a hoodie, a bag) is not. When the copy names a real person ("Lautaro Suarez, CTO at Vibiz"), the run casts them as themselves before it renders (`baker avatars research --create`, their avatar's sheet added to `prompt.json` `references` as a `PERSON_n`), and a face shown as a named person the ad has no photo of is blocking. When the reader cannot answer, the vision pass transcribes the copy instead and judges the safe zones by eye. Then one vision pass for clipping, overlaps, artifacts, one dominant idea, the brand with the logo covered and the concept's mechanisms, with every format shown a second time at phone-feed thumbnail size (240px wide, same call): a lead message unreadable at that size is blocking. A missing logo, CTA or copy is never a finding; a logo that is drawn and broken is. stdout carries `verdict` (`ready` | `ready_with_notes` | `needs_fixes` | `not_reviewed`), `judge` (the per-candidate scores) and hints that end with what the run made; the findings are stored on the run as its `review`. `needs_fixes` means the ad is not done even though every node completed, and the run is recorded with `presentable: false` so it is never shown as a version; every fix is an edit to `prompt.json` and a re-run (wrong words: the lines in `copy`; wrong picture: the `scene`; a bad roll: `--regenerate static_variants`; one size wrong while the chosen picture is right: `--regenerate adapt_<size>`, which re-lays it out, named in the hints); a blocking defect is first corrected by the run itself (`--autofix`, above). The checks rank the variants before the pick and the judge only breaks an exact tie, comparing pairwise against the compiled brief, knowing the brand and the viewer; every variant stays on offer to the person in Baker. Within one chat turn an ad gets two runs you start (each with its automatic corrections), and none once it is `ready` or `ready_with_notes`: the next is refused (`CONFLICT`, exit 2) with what to show instead; the person's next message starts a new turn (`turn-renders.ts`). When a defect still stands after the automatic rounds, the hints say to fix it with a `--fix`, then by changing the spec or the approach, and if it still cannot be fixed to show the best version and name what is left in one plain sentence, without a question.
6013
6013
 
6014
6014
  The render prompt is **model-aware**: each model's profile (`src/engine/lib/prompt-profiles.ts`) sets its shape, word budget, picture order and request params. gpt-image gets short labelled sections with the constraints last; Gemini gets sentences that give the reason and a positive closing list of the only words allowed. A profile change, or a new model on the router (`src/engine/lib/image-model-router.ts`), is re-run on the static bench (`scripts/creative-evals`, `--job static`) before it ships. Run `baker canvas critique` on the scaffolded canvas for an advisory read before you spend.
6015
6015
 
@@ -6689,6 +6689,7 @@ function textFidelityDirective(strings2, hasFont) {
6689
6689
  return [
6690
6690
  exact,
6691
6691
  "Render NO other words anywhere in the image \u2014 no invented labels, captions, sub-headers, or filler text.",
6692
+ "Any bill, quote, receipt, screen or price tag in the scene shows no readable prices or amounts beyond these strings: its figures are blurred or out of focus.",
6692
6693
  "For any brand name or uncommon word, lock its letters exactly as written (spell it out letter-by-letter if that helps the characters render correctly).",
6693
6694
  "Keep all type crisp, legible, and large enough to read cleanly; no garbled, warped, or duplicated characters, and no unreadable microtext."
6694
6695
  ].join("\n");
@@ -10164,7 +10165,7 @@ function checkOrphanNodes(ctx) {
10164
10165
  path: `nodes[${ctx.idToIndex.get(n.id)}]`,
10165
10166
  code: STAGE_CODES.ORPHAN_NODE,
10166
10167
  severity: "warning",
10167
- message: `node "${n.id}" (${n.type}) is not reachable from output "${out.node}" \u2014 it will still execute (and bill, if billable) but nothing consumes its result; wire it in or remove it`
10168
+ message: `node "${n.id}" (${n.type}) is not reachable from output "${out.node}": a run skips it, so nothing it would make is used; wire it in or remove it`
10168
10169
  });
10169
10170
  }
10170
10171
  }
@@ -15802,6 +15803,7 @@ function mostOfALine(chunk, copy) {
15802
15803
  });
15803
15804
  }
15804
15805
  var CLAIM = /[%€$£]|\b(?:free|gratis|gratuit[oa]?s?|off|sale|oferta|descuento|rebajas?|promo)\b/i;
15806
+ var PRICE = /[€$£]\s?\d|\d\s?[€$£]/;
15805
15807
  var COPY_SIZE_SHARE = 0.8;
15806
15808
  function extraWordFindings(format, extra, boxes, copyBoxes) {
15807
15809
  if (extra.length === 0) return [];
@@ -15819,7 +15821,8 @@ function extraWordFindings(format, extra, boxes, copyBoxes) {
15819
15821
  const printed = (chunk) => usual > 0 && underCopy.includes(chunk) && (!CLAIM.test(chunk) || scenery(chunk));
15820
15822
  const quote = (chunks) => chunks.map((e) => `"${e}"`).join(", ");
15821
15823
  const asCopy = extra.filter((chunk) => !printed(chunk));
15822
- const inScene = extra.filter(printed);
15824
+ const prices = extra.filter((chunk) => printed(chunk) && PRICE.test(chunk));
15825
+ const inScene = extra.filter((chunk) => printed(chunk) && !prices.includes(chunk));
15823
15826
  return [
15824
15827
  ...asCopy.length > 0 ? [
15825
15828
  {
@@ -15828,6 +15831,13 @@ function extraWordFindings(format, extra, boxes, copyBoxes) {
15828
15831
  what: `${format}: the picture adds words that aren't in the copy: ${quote(asCopy)}. Remove them, or add them to the copy.`
15829
15832
  }
15830
15833
  ] : [],
15834
+ ...prices.length > 0 ? [
15835
+ {
15836
+ severity: "blocking",
15837
+ // `static-autofix.ts` SCENE_PRICES reads this sentence to blur them.
15838
+ what: `${format}: the picture shows prices the copy does not hold: ${quote(prices)}. Make them unreadable.`
15839
+ }
15840
+ ] : [],
15831
15841
  ...inScene.length > 0 ? [
15832
15842
  {
15833
15843
  severity: "warning",
@@ -16032,6 +16042,22 @@ function everySizeChecks(opts) {
16032
16042
  onSpend: opts.onSpend
16033
16043
  };
16034
16044
  }
16045
+ async function confirmBlocking(findings, stills, formats, ask2) {
16046
+ const claims = findings.filter((f) => f.severity === "blocking");
16047
+ if (claims.length === 0 || stills.length === 0) return [...findings];
16048
+ const prompt = [
16049
+ `These are the finished sizes of one ad, in order: ${formats.map((f, i) => `Image ${i + 1} is the ${f}`).join("; ")}.`,
16050
+ "A reviewer reported each claim below. Look again at the size each one names (and, for a claim comparing sizes, at the others) and answer true only when the defect is plainly there for someone looking at it on a phone.",
16051
+ "Answer false when the thing is not there, when it is an ordinary part of the scene (a second house, a neighbour's roof, a reflection), or when the sizes it compares show the same thing.",
16052
+ ...claims.map((c, i) => `${i + 1}. ${c.what}`),
16053
+ `Reply with only this JSON: {"answers":[true|false, ...]}, one answer per claim, in order.`
16054
+ ].join("\n");
16055
+ const answer = await ask2([...stills], prompt).catch(() => null);
16056
+ const answers = answer?.answers;
16057
+ if (!Array.isArray(answers) || answers.length !== claims.length) return [...findings];
16058
+ const unconfirmed = new Set(claims.filter((_, i) => answers[i] === false));
16059
+ return findings.map((f) => unconfirmed.has(f) ? { ...f, severity: "warning" } : f);
16060
+ }
16035
16061
  async function reviewRenderedStatic(opts) {
16036
16062
  const { measured, originals, stills, thumbs } = await loadDelivered(opts.images);
16037
16063
  if (measured.length === 0) return null;
@@ -16062,7 +16088,14 @@ async function reviewRenderedStatic(opts) {
16062
16088
  if (answer === null) return unreviewed(shape, measuredText);
16063
16089
  const text2 = read ? measuredText : copyFindings(answer, opts.copy, formats, opts.scrolled);
16064
16090
  const boxesByFormat = read ? Object.fromEntries(checks.flatMap((c, i) => c.status === "checked" ? [[formats[i] ?? "", c.boxes]] : [])) : {};
16065
- return [...shape, ...text2, ...corroborateEdgeClaims(classifyStaticFindings(answer), boxesByFormat, lines)];
16091
+ const judged = corroborateEdgeClaims(classifyStaticFindings(answer), boxesByFormat, lines);
16092
+ const confirmed = await confirmBlocking(
16093
+ judged,
16094
+ stills,
16095
+ formats,
16096
+ (images, prompt) => askAboutStills(images, prompt, opts.onSpend)
16097
+ );
16098
+ return [...shape, ...text2, ...confirmed];
16066
16099
  }
16067
16100
 
16068
16101
  // src/engine/nodes/local/staticNative.ts
@@ -16634,12 +16667,64 @@ import { z as z26 } from "zod";
16634
16667
  // src/engine/nodes/local/lib/variant-prefilter.ts
16635
16668
  import { createHash as createHash3 } from "crypto";
16636
16669
  import sharp8 from "sharp";
16637
- function coverFitBoxes(boxes, from, to) {
16670
+ function coverOf(from, to) {
16638
16671
  const scale = Math.max(to.width / from.width, to.height / from.height);
16639
- const offX = (from.width * scale - to.width) / 2;
16640
- const offY = (from.height * scale - to.height) / 2;
16641
- const x = (f) => (f * from.width * scale - offX) / to.width;
16642
- const y = (f) => (f * from.height * scale - offY) / to.height;
16672
+ const width = from.width * scale;
16673
+ const height = from.height * scale;
16674
+ return { scale, width, height, centre: { left: (width - to.width) / 2, top: (height - to.height) / 2 } };
16675
+ }
16676
+ function framingOf(from, to) {
16677
+ let frame;
16678
+ return {
16679
+ place: (boxes) => {
16680
+ frame = frameKeeping(boxes, from, to);
16681
+ return coverFitBoxes(boxes, from, to, frame);
16682
+ },
16683
+ moved: () => {
16684
+ const { centre } = coverOf(from, to);
16685
+ return frame && (Math.abs(frame.left - centre.left) >= 1 || Math.abs(frame.top - centre.top) >= 1) ? frame : void 0;
16686
+ }
16687
+ };
16688
+ }
16689
+ async function cutToFrame(bytes, frame, to) {
16690
+ const { width = 0, height = 0 } = await sharp8(bytes).metadata();
16691
+ const { scale } = coverOf({ width, height }, to);
16692
+ const w = Math.min(width, Math.round(to.width / scale));
16693
+ const h = Math.min(height, Math.round(to.height / scale));
16694
+ const left = Math.min(width - w, Math.max(0, Math.round(frame.left / scale)));
16695
+ const top = Math.min(height - h, Math.max(0, Math.round(frame.top / scale)));
16696
+ return sharp8(bytes).extract({ left, top, width: w, height: h }).png().toBuffer();
16697
+ }
16698
+ var FRAME_MARGIN = 0.02;
16699
+ function frameKeeping(boxes, from, to) {
16700
+ const cover = coverOf(from, to);
16701
+ if (boxes.length === 0) return cover.centre;
16702
+ const along = (offset2, extent, frame, lo, hi) => {
16703
+ const low = lo * extent;
16704
+ const high = hi * extent;
16705
+ const room = extent - frame;
16706
+ const margin = FRAME_MARGIN * frame;
16707
+ for (const m of [margin, 0]) {
16708
+ const min = Math.max(0, high + m - frame);
16709
+ const max = Math.min(room, low - m);
16710
+ if (min <= max) return Math.min(max, Math.max(min, offset2));
16711
+ }
16712
+ return offset2;
16713
+ };
16714
+ const x0 = Math.min(...boxes.map((b) => b.x0));
16715
+ const x1 = Math.max(...boxes.map((b) => b.x1));
16716
+ const y0 = Math.min(...boxes.map((b) => b.y0));
16717
+ const y1 = Math.max(...boxes.map((b) => b.y1));
16718
+ return {
16719
+ left: along(cover.centre.left, cover.width, to.width, x0, x1),
16720
+ top: along(cover.centre.top, cover.height, to.height, y0, y1)
16721
+ };
16722
+ }
16723
+ function coverFitBoxes(boxes, from, to, frame) {
16724
+ const cover = coverOf(from, to);
16725
+ const { left: offX, top: offY } = frame ?? cover.centre;
16726
+ const x = (f) => (f * cover.width - offX) / to.width;
16727
+ const y = (f) => (f * cover.height - offY) / to.height;
16643
16728
  return boxes.map((b) => ({ ...b, x0: x(b.x0), y0: y(b.y0), x1: x(b.x1), y1: y(b.y1) }));
16644
16729
  }
16645
16730
  var NO_LOGO = "The brand's logo was not found on it.";
@@ -16693,34 +16778,45 @@ async function prefilterVariants(variants, spec, formats, opts = {}) {
16693
16778
  });
16694
16779
  const format = plan.master.format;
16695
16780
  const grade = plan.masterShape === "relayout" ? plan.placements ? { placements: plan.placements } : {} : { band: plan.band };
16781
+ const want = { lines, buttons, ...brand ? { brand } : {}, logo, format, grade };
16696
16782
  const checked = await Promise.all(
16697
- variants.map(async (variant) => {
16698
- const source = variant.path ?? variant.url;
16699
- if (!source) return { index: variant.index, blocking: null };
16700
- try {
16701
- const bytes = await bytesOf(source);
16702
- const { width = 0, height = 0 } = await sharp8(bytes).metadata();
16703
- const check = await checkStaticImage(bytes, {
16704
- format,
16705
- lines,
16706
- allowed: brand ? [brand] : [],
16707
- ...grade,
16708
- toFrame: (boxes) => coverFitBoxes(boxes, { width, height }, plan.master.size),
16709
- ...opts.read ? { read: opts.read } : {},
16710
- ...opts.onSpend ? { onSpend: opts.onSpend } : {}
16711
- });
16712
- if (check.status === "not_checked") return { index: variant.index, blocking: null };
16713
- const prefix = `${format}: `;
16714
- const blocking = check.findings.filter((f) => f.severity === "blocking").map((f) => f.what.startsWith(prefix) ? f.what.slice(prefix.length) : f.what);
16715
- const want = { lines, buttons, ...brand ? { brand } : {}, logo, format, grade };
16716
- return { index: variant.index, blocking, ...rankOf(check.boxes, blocking, want) };
16717
- } catch {
16718
- return { index: variant.index, blocking: null };
16719
- }
16720
- })
16783
+ variants.map(
16784
+ (variant) => checkVariant(variant, { format, size: plan.master.size, lines, brand, grade, want, opts })
16785
+ )
16721
16786
  );
16722
16787
  const outcome = prefilterOutcome(checked);
16723
- return { ...outcome, kept: seededOrder(outcome.kept, variants) };
16788
+ const frames = Object.fromEntries(checked.flatMap((c) => "frame" in c && c.frame ? [[c.index, c.frame]] : []));
16789
+ return {
16790
+ ...outcome,
16791
+ kept: seededOrder(outcome.kept, variants),
16792
+ ...Object.keys(frames).length > 0 ? { frames, masterSize: plan.master.size } : {}
16793
+ };
16794
+ }
16795
+ async function checkVariant(variant, c) {
16796
+ const { format, lines, brand, grade, want, opts } = c;
16797
+ const source = variant.path ?? variant.url;
16798
+ if (!source) return { index: variant.index, blocking: null };
16799
+ try {
16800
+ const bytes = await bytesOf(source);
16801
+ const { width = 0, height = 0 } = await sharp8(bytes).metadata();
16802
+ const framing = framingOf({ width, height }, c.size);
16803
+ const check = await checkStaticImage(bytes, {
16804
+ format,
16805
+ lines,
16806
+ allowed: brand ? [brand] : [],
16807
+ ...grade,
16808
+ toFrame: framing.place,
16809
+ ...opts.read ? { read: opts.read } : {},
16810
+ ...opts.onSpend ? { onSpend: opts.onSpend } : {}
16811
+ });
16812
+ if (check.status === "not_checked") return { index: variant.index, blocking: null };
16813
+ const prefix = `${format}: `;
16814
+ const blocking = check.findings.filter((f) => f.severity === "blocking").map((f) => f.what.startsWith(prefix) ? f.what.slice(prefix.length) : f.what);
16815
+ const frame = framing.moved();
16816
+ return { index: variant.index, blocking, ...rankOf(check.boxes, blocking, want), ...frame ? { frame } : {} };
16817
+ } catch {
16818
+ return { index: variant.index, blocking: null };
16819
+ }
16724
16820
  }
16725
16821
  function seededOrder(indexes, variants) {
16726
16822
  const shaOf = new Map(variants.map((v) => [v.index, v.sha256]));
@@ -16796,7 +16892,7 @@ async function readSpec(ref) {
16796
16892
  }
16797
16893
  var staticPrefilterNode = defineNode({
16798
16894
  id: "static_prefilter",
16799
- version: "1.0.0",
16895
+ version: "1.1.0",
16800
16896
  category: "image",
16801
16897
  location: "local",
16802
16898
  // Reads each variant's pixels itself, from its file or its durable URL, so the variants
@@ -16829,7 +16925,8 @@ var staticPrefilterNode = defineNode({
16829
16925
  images: z26.array(ImageRef).min(1),
16830
16926
  // Which variant (its place among the inputs) each of `images` is, so the judge's "Image k" maps back.
16831
16927
  order: z26.array(z26.number().int().min(0)).optional(),
16832
- // The picture every size is made from: the checks' first, or on a tie the judge's.
16928
+ // The picture every size is made from: the checks' first, or on a tie the judge's. Cut to the
16929
+ // master's frame where every word stays whole when its centre would cut one, else as rendered.
16833
16930
  image: ImageRef.optional(),
16834
16931
  // Which variant it is (its place among the inputs).
16835
16932
  chosen: z26.number().int().min(0).optional(),
@@ -16842,12 +16939,12 @@ var staticPrefilterNode = defineNode({
16842
16939
  }).strict(),
16843
16940
  outputKinds: { images: "image", image: "image" },
16844
16941
  cost: () => ({ credits: 0, seconds_estimate: 1 }),
16845
- async execute({ inputs, params }) {
16846
- const variants = inputs.images.map((image, index) => ({
16942
+ async execute({ inputs, params, ctx }) {
16943
+ const variants = inputs.images.map((image2, index) => ({
16847
16944
  index,
16848
- sha256: image.sha256,
16849
- ...image.path ? { path: image.path } : {},
16850
- ...image.url ? { url: image.url } : {}
16945
+ sha256: image2.sha256,
16946
+ ...image2.path ? { path: image2.path } : {},
16947
+ ...image2.url ? { url: image2.url } : {}
16851
16948
  }));
16852
16949
  const usage = [];
16853
16950
  const result = await prefilterVariants(variants, params.checks ?? await readSpec(inputs.spec), params.formats, {
@@ -16863,10 +16960,11 @@ var staticPrefilterNode = defineNode({
16863
16960
  { model: params.judge_model, prompt: params.judge_prompt, onSpend: (u) => usage.push(u) }
16864
16961
  ) : null;
16865
16962
  const chosen = tie?.chosen ?? shown[0] ?? 0;
16963
+ const image = await framed(inputs.images[chosen], result.frames?.[chosen], result.masterSize, ctx);
16866
16964
  return {
16867
16965
  images: kept.length > 0 ? kept : inputs.images,
16868
16966
  order: shown,
16869
- ...inputs.images[chosen] ? { image: inputs.images[chosen] } : {},
16967
+ ...image ? { image } : {},
16870
16968
  chosen,
16871
16969
  ...tie ? { reasoning: tie.reasoning } : {},
16872
16970
  dropped: result.dropped,
@@ -16875,6 +16973,12 @@ var staticPrefilterNode = defineNode({
16875
16973
  };
16876
16974
  }
16877
16975
  });
16976
+ async function framed(picture, frame, size2, ctx) {
16977
+ const source = picture?.path ?? picture?.url;
16978
+ if (!picture || !frame || !size2 || !source) return picture;
16979
+ const bytes = await cutToFrame(await bytesOf(source), frame, size2);
16980
+ return await ctx.assets.ingestBytes({ bytes, kind: "image", mime: "image/png" });
16981
+ }
16878
16982
 
16879
16983
  // src/engine/nodes/local/staticSafeFit.ts
16880
16984
  import { z as z27 } from "zod";
@@ -18617,4 +18721,4 @@ export {
18617
18721
  defaultRegistry,
18618
18722
  createEngineFromEnv
18619
18723
  };
18620
- //# sourceMappingURL=chunk-WMAHVZMY.js.map
18724
+ //# sourceMappingURL=chunk-Q2ZO6I47.js.map