@koda-sl/baker-cli 0.434.0-dev.42b55fa66 → 0.434.0-dev.4dbd56be6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/README.md +79 -22
  2. package/dist/{chunk-MMTPMFFH.js → chunk-7NKAKF3K.js} +438 -149
  3. package/dist/chunk-7NKAKF3K.js.map +1 -0
  4. package/dist/{chunk-Q6F5UNOU.js → chunk-7THIX4WW.js} +8 -5
  5. package/dist/{chunk-Q6F5UNOU.js.map → chunk-7THIX4WW.js.map} +1 -1
  6. package/dist/{chunk-GCTR4MDE.js → chunk-EURJVJQY.js} +2 -2
  7. package/dist/{chunk-FQOKJYKN.js → chunk-F7LXMR73.js} +17 -4
  8. package/dist/chunk-F7LXMR73.js.map +1 -0
  9. package/dist/{chunk-RFZYRG2Q.js → chunk-QLNGHVRT.js} +11 -3
  10. package/dist/chunk-QLNGHVRT.js.map +1 -0
  11. package/dist/{chunk-PHLFNYGQ.js → chunk-VNROEZTR.js} +4 -4
  12. package/dist/cli.js +16765 -12592
  13. package/dist/cli.js.map +1 -1
  14. package/dist/{client-GS7T7ETG.js → client-TCDMWAL3.js} +4 -4
  15. package/dist/engine/index.d.ts +13 -6
  16. package/dist/engine/index.js +3 -3
  17. package/dist/{env-SL22S6CM.js → env-ZPCFZJN5.js} +6 -2
  18. package/dist/output-CW4JBWIM.js +22 -0
  19. package/dist/{shared-XR54KFMH.js → shared-6I2TP4YQ.js} +6 -6
  20. package/package.json +1 -1
  21. package/dist/chunk-FQOKJYKN.js.map +0 -1
  22. package/dist/chunk-MMTPMFFH.js.map +0 -1
  23. package/dist/chunk-RFZYRG2Q.js.map +0 -1
  24. package/dist/output-OFDKEPPJ.js +0 -18
  25. /package/dist/{chunk-GCTR4MDE.js.map → chunk-EURJVJQY.js.map} +0 -0
  26. /package/dist/{chunk-PHLFNYGQ.js.map → chunk-VNROEZTR.js.map} +0 -0
  27. /package/dist/{client-GS7T7ETG.js.map → client-TCDMWAL3.js.map} +0 -0
  28. /package/dist/{env-SL22S6CM.js.map → env-ZPCFZJN5.js.map} +0 -0
  29. /package/dist/{output-OFDKEPPJ.js.map → output-CW4JBWIM.js.map} +0 -0
  30. /package/dist/{shared-XR54KFMH.js.map → shared-6I2TP4YQ.js.map} +0 -0
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  debugLogHttp,
3
3
  readBodyForLog
4
- } from "./chunk-GCTR4MDE.js";
4
+ } from "./chunk-EURJVJQY.js";
5
5
  import {
6
6
  API_KEY_FORMAT_MESSAGE,
7
7
  DIRECT_ROUTE,
@@ -22,7 +22,7 @@ import {
22
22
  refuseNonPublicUrl,
23
23
  requireConnection,
24
24
  shouldEscalate
25
- } from "./chunk-FQOKJYKN.js";
25
+ } from "./chunk-F7LXMR73.js";
26
26
 
27
27
  // ../../.pnpm-store/v10/links/@/safe-stable-stringify/2.5.0/766ad107c42df28eeadaf0ac2f9089c8a9e200f6ba0b2b1a817b8d536cbdcc49/node_modules/safe-stable-stringify/index.js
28
28
  var require_safe_stable_stringify = __commonJS({
@@ -3594,23 +3594,6 @@ function resolveConcurrency(...candidates) {
3594
3594
  }
3595
3595
  return DEFAULT_CONCURRENCY;
3596
3596
  }
3597
- async function mapWithConcurrency(items, limit, fn) {
3598
- const results = new Array(items.length);
3599
- let next = 0;
3600
- const worker = async () => {
3601
- while (next < items.length) {
3602
- const index = next++;
3603
- try {
3604
- results[index] = { status: "fulfilled", value: await fn(items[index], index) };
3605
- } catch (reason) {
3606
- results[index] = { status: "rejected", reason };
3607
- }
3608
- }
3609
- };
3610
- const workers = Array.from({ length: Math.min(limit, items.length) }, worker);
3611
- await Promise.all(workers);
3612
- return results;
3613
- }
3614
3597
 
3615
3598
  // src/engine/lib/json-walk.ts
3616
3599
  function isContainerObject(value) {
@@ -4415,7 +4398,18 @@ var CanvasMetadata = z4.object({
4415
4398
  // `scaffold-video`) so the editable checklist travels inside the canvas
4416
4399
  // instead of only printing to stdout. Ignored by the engine.
4417
4400
  todo: z4.unknown().optional(),
4418
- video: VideoMeta
4401
+ video: VideoMeta,
4402
+ // A static ad of a series (`seriesMemberAd`): the lead ad its picture is an edit of, the
4403
+ // lead's version, and the lead's words as that version drew them, which this ad's replace.
4404
+ // Ignored by the engine; the brief projection reads it.
4405
+ series: z4.object({
4406
+ lead: z4.string().min(1),
4407
+ run_id: z4.string().min(1),
4408
+ copy: z4.array(z4.object({ text: z4.string(), role: z4.string().optional() }).strict()),
4409
+ design: z4.string().optional(),
4410
+ // A translation of the lead: the language (BCP-47) its words are in.
4411
+ language: z4.string().min(2).optional()
4412
+ }).strict().optional()
4419
4413
  }).strict().optional();
4420
4414
  var CanvasSchema = z4.object({
4421
4415
  schema: z4.literal("baker-canvas/1"),
@@ -6706,6 +6700,7 @@ function textFidelityDirective(strings2, hasFont) {
6706
6700
  return [
6707
6701
  exact,
6708
6702
  "Render NO other words anywhere in the image \u2014 no invented labels, captions, sub-headers, or filler text.",
6703
+ "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.",
6709
6704
  "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).",
6710
6705
  "Keep all type crisp, legible, and large enough to read cleanly; no garbled, warped, or duplicated characters, and no unreadable microtext."
6711
6706
  ].join("\n");
@@ -6770,22 +6765,32 @@ var Viewer = z7.object({
6770
6765
  /** Who will see the ad, in the words a marketer would use. */
6771
6766
  who: z7.string().trim().min(1),
6772
6767
  market: Market.optional(),
6773
- /** What they already know about the brand and the offer. */
6774
- knows: z7.string().optional(),
6768
+ /** What they already know about the brand and the offer; a list (as _definition.md keeps it) reads as one line. */
6769
+ knows: z7.preprocess((v) => Array.isArray(v) ? v.map(String).join("; ") || void 0 : v, z7.string().optional()),
6775
6770
  temperature: z7.enum(["cold", "warm", "hot"]).optional(),
6776
6771
  /** Where they meet it (feed, Stories, Reels). */
6777
6772
  placement: z7.string().optional()
6778
6773
  }).strict();
6779
- var Scene = z7.object({
6774
+ var withoutFormatName = (v) => {
6775
+ if (!v || typeof v !== "object" || Array.isArray(v) || typeof v.concept !== "string")
6776
+ return v;
6777
+ const { concept: _format, ...scene } = v;
6778
+ return scene;
6779
+ };
6780
+ var SceneFields = z7.object({
6780
6781
  description: z7.string().trim().min(1),
6781
- /** A photograph, a designed graphic, or an illustration. */
6782
- look: z7.enum(["photo", "designed", "illustration"]),
6782
+ /** A photograph, a designed graphic, or an illustration; plain synonyms ("photography") read as them. */
6783
+ look: z7.preprocess(
6784
+ (v) => typeof v !== "string" ? v : /^photo/i.test(v.trim()) ? "photo" : /^illustrat/i.test(v.trim()) ? "illustration" : /^(design|graphic)/i.test(v.trim()) ? "designed" : v,
6785
+ z7.enum(["photo", "designed", "illustration"])
6786
+ ),
6783
6787
  /** How a photo was taken, in plain words (handheld at standing height, window light, faint glare). */
6784
6788
  capture: z7.string().optional(),
6785
6789
  mood: z7.string().optional(),
6786
6790
  /** How the frame is arranged, by what fills each part of it. */
6787
6791
  layout: z7.string().optional()
6788
6792
  }).strict();
6793
+ var Scene = z7.preprocess(withoutFormatName, SceneFields);
6789
6794
  var Brand = z7.object({
6790
6795
  name: z7.string().optional(),
6791
6796
  /** The words the brand's logo prints (its wordmark), so a check reads them as the logo, never as copy. */
@@ -6857,13 +6862,19 @@ var AdSpecSchema = z7.object({
6857
6862
  * editor's line numbers, a product's own label, a platform's badge. The render may draw them
6858
6863
  * and the review never calls them extra words.
6859
6864
  */
6860
- scene_text: z7.array(z7.string().trim().min(1)).default([]),
6865
+ scene_text: z7.preprocess(
6866
+ (v) => Array.isArray(v) ? v.map((w) => w && typeof w === "object" && "text" in w ? w.text : w) : v,
6867
+ z7.array(z7.string().trim().min(1)).default([])
6868
+ ),
6861
6869
  /** The formats the ad is made for; the canvas delivers them. */
6862
6870
  formats: z7.array(z7.string()).optional(),
6863
6871
  /** An optional note on direction for this concept. */
6864
6872
  direction: z7.string().optional(),
6865
6873
  /** The device the ad is built on, and why it works on a stranger. Judged, never drawn. */
6866
- mechanism: z7.array(Mechanism).default([]),
6874
+ mechanism: z7.preprocess((v) => {
6875
+ const list = v && typeof v === "object" && !Array.isArray(v) ? [v] : v;
6876
+ return Array.isArray(list) ? list.map((m) => typeof m === "string" ? { device: m } : m) : list;
6877
+ }, z7.array(Mechanism).default([])),
6867
6878
  /** Why the reference won and what this ad keeps of it (schema /3). */
6868
6879
  concept: Concept.optional(),
6869
6880
  /**
@@ -6931,6 +6942,49 @@ function unwrittenProblems(spec) {
6931
6942
  }
6932
6943
  return problems;
6933
6944
  }
6945
+ var QUOTED = /"([^"]{2,})"|“([^”]{2,})”|(?<![\p{L}\p{N}])['‘]([^'’]{2,}?)['’](?![\p{L}\p{N}])/gu;
6946
+ var FIGURE = /[£$€]\s?\d[\d.,]*|\d+(?:[.,]\d+)?\s?%/g;
6947
+ var comparable = (text2) => text2.normalize("NFD").replace(new RegExp("\\p{M}", "gu"), "").toLowerCase().replace(/[^\p{L}\p{N}£$€%]+/gu, " ").trim();
6948
+ function sceneWordProblems(spec) {
6949
+ const held = [...spec.copy.map((l) => l.text), ...spec.scene_text].map(comparable);
6950
+ const holds = (text2) => {
6951
+ const want = comparable(text2);
6952
+ return want.length === 0 || held.some((h) => h.includes(want));
6953
+ };
6954
+ const scene = [spec.scene.description, spec.scene.layout ?? ""].join(" ");
6955
+ const quoted = [...scene.matchAll(QUOTED)].map((m) => (m[1] ?? m[2] ?? m[3] ?? "").trim()).filter((q) => !holds(q));
6956
+ const figures = (scene.match(FIGURE) ?? []).map((f) => f.trim()).filter((f) => !holds(f));
6957
+ const unheld = [.../* @__PURE__ */ new Set([...quoted, ...figures])];
6958
+ if (unheld.length === 0) return [];
6959
+ return [
6960
+ `The scene asks the picture to show ${unheld.map((w) => `"${w}"`).join(", ")}, which the copy does not hold: the picture would print it and the run would then spend a round erasing it. Put each one the ad needs in \`copy\` as an \`in_scene\` line with \`on\` (the bill, the phone), or describe that surface without words (blank, blurred, face-down).`
6961
+ ];
6962
+ }
6963
+ var FIELD_SHAPES = {
6964
+ copy: '"copy": [{"text": "Book your free survey", "lettering": "designed", "role": "cta"}]',
6965
+ scene: '"scene": {"description": "a family on a sunny roof terrace", "look": "photo"}',
6966
+ colors: '"colors": [{"object": "the button", "name": "deep green", "hex": "#1F5C3A"}]',
6967
+ references: '"references": [{"id": "PRODUCT_1", "role": "product", "what": "the solar panel kit"}]',
6968
+ third_party_brands: '"third_party_brands": [{"name": "Trustpilot", "role": "proof", "logo": "BADGE_1"}]',
6969
+ mechanism: '"mechanism": [{"device": "a before and after", "why_it_works": "the change is seen, not told"}]',
6970
+ viewer: '"viewer": {"who": "homeowners with high energy bills", "knows": "nothing about us yet"}'
6971
+ };
6972
+ var SPEC_SHAPES_HINT = `prompt.json shapes: ${["copy", "colors", "viewer"].map((f) => FIELD_SHAPES[f]).join("; ")}.`;
6973
+ function adSpecErrorText(e) {
6974
+ if (!(e instanceof z7.ZodError)) return e instanceof Error ? e.message : String(e);
6975
+ const items = /* @__PURE__ */ new Map();
6976
+ for (const issue of e.issues) {
6977
+ const [field2, index, key] = issue.path;
6978
+ const at2 = typeof index === "number" ? `${String(field2)}[${index}]` : issue.path.map(String).join(".") || "the spec";
6979
+ const what = issue.code === "unrecognized_keys" ? `no field ${issue.keys.map((k) => `"${k}"`).join(", ")}` : key !== void 0 && typeof index === "number" ? `"${String(key)}" ${issue.message.replace(/^Invalid input: /, "")}` : issue.message.replace(/^Invalid input: /, "");
6980
+ items.set(at2, [...items.get(at2) ?? [], what]);
6981
+ }
6982
+ const fields = [...new Set(e.issues.map((i) => String(i.path[0] ?? "")))].filter((f) => FIELD_SHAPES[f]);
6983
+ return [
6984
+ ...[...items].map(([at2, whats]) => `${at2}: ${[...new Set(whats)].join("; ")}`),
6985
+ ...fields.map((f) => `Shape: ${FIELD_SHAPES[f]}`)
6986
+ ].join("\n");
6987
+ }
6934
6988
  function readAdSpec(raw) {
6935
6989
  if (isAdSpec(raw)) return AdSpecSchema.parse(raw);
6936
6990
  const advertiser = raw?.reference?.advertiser;
@@ -7421,7 +7475,7 @@ function pictureInstruction(p, ctx) {
7421
7475
  const keep = p.spec?.keep ? ` Keep ${plain(p.spec.keep, ctx.clean)}.` : "";
7422
7476
  switch (p.role) {
7423
7477
  case "base":
7424
- return ctx.job === "fix" ? "the current version of this ad, to edit." : "the finished ad, to re-lay for this shape.";
7478
+ return ctx.job === "fix" ? "the current version of this ad, to edit." : ctx.job === "series" ? "the finished ad this series repeats, to copy exactly with new words." : "the finished ad, to re-lay for this shape.";
7425
7479
  case "identity":
7426
7480
  return identityInstruction(p, what, keep, ctx.flowing);
7427
7481
  // The user saw one label come back two ways (E4 gallery, BENCH.md § "E1 v2"): the label is the product's.
@@ -7800,6 +7854,10 @@ function deliverableSection(c) {
7800
7854
  const change = sentence(c.opts.instruction?.trim() || "correct what is wrong");
7801
7855
  return c.flowing ? `Image 1 is the current version of this ad. ${capitalize(change)} Keep everything else in Image 1 exactly the same, preserving its picture, layout, colours, lettering and every word.` : `Edit: Image 1 is the current version of this ad. ${capitalize(change)} Keep everything else exactly as it is in Image 1: the picture, the layout, the colours, the lettering and every word.`;
7802
7856
  }
7857
+ if (c.job === "series") {
7858
+ const change = sentence(c.opts.instruction?.trim() || "set the new words");
7859
+ return c.flowing ? `Image 1 is a finished ad of this series. Make the next ad of the same series${forBrand}: ${change} Keep everything else in Image 1 exactly the same, preserving its picture, layout, background, shapes, colours, lettering style and logo, each word in the place and style of the one it replaces.` : `Edit: Image 1 is a finished ad of this series. Make the next ad of the same series${forBrand}. ${capitalize(change)} Keep everything else exactly as it is in Image 1: the picture, the layout, the background, the shapes, the colours, the lettering style and the logo, each new line in the place, size and style of the line it replaces.`;
7860
+ }
7803
7861
  if (c.job === "relay") {
7804
7862
  return c.flowing ? `Image 1 is a finished ad. Recompose this same ad as a ${noun}${forBrand}: the same picture, colours, lettering and every word, rearranged for the new shape, with more of the scene revealed at the edges.` : `Deliverable: the same ad as Image 1, re-laid as a finished ${noun}${forBrand}. Keep the same picture, colours, lettering and every word, rearranged for the new shape; show more of the scene rather than cropping it.`;
7805
7863
  }
@@ -8383,14 +8441,16 @@ function scaleText(plan, spec, flowing, zoned, short) {
8383
8441
  const rows = hasRows(spec) ? flowing ? " Rows, columns and lists are restacked for the tall frame, one item per line, each larger." : " Rows, columns and lists are restacked for the tall frame, one item per line, each larger, rather than shrunk." : "";
8384
8442
  return flowing ? `The words are set for this frame, larger than in Image 1: the headline large enough to fill most of the band's width, and every line, the logo and the button spread through the band's height, inside its edges.${rows}` : `Scale: larger than in Image 1: the headline set large enough to fill most of the band's width; every line, the logo and the button spread through the band's height, inside its edges, never a small block in its middle.${rows}`;
8385
8443
  }
8386
- var ZONE_HEADROOM = 0;
8444
+ var COVERED_HEADROOM = { top: 0.06, bottom: 0.08, side: 0.02 };
8445
+ var NO_HEADROOM = { top: 0, bottom: 0, side: 0 };
8387
8446
  var pct = (share) => `${Math.round(share * 100)}%`;
8388
8447
  function zonesText(plan, flowing, ground, short) {
8389
- const top = plan.safe.top + ZONE_HEADROOM;
8390
- const bottom = plan.safe.bottom + ZONE_HEADROOM;
8391
- const side = Math.max(plan.safe.left, plan.safe.right) + ZONE_HEADROOM;
8392
- const band = `from ${pct(top)} to ${pct(1 - bottom)} of the height, at least ${pct(side)} in from each side`;
8393
8448
  const covered = staticSafeArea(plan.format, plan.placements).placement !== "feed";
8449
+ const room = isTall(plan) && covered ? COVERED_HEADROOM : NO_HEADROOM;
8450
+ const top = plan.safe.top + room.top;
8451
+ const bottom = plan.safe.bottom + room.bottom;
8452
+ const side = Math.max(plan.safe.left, plan.safe.right) + room.side;
8453
+ const band = `from ${pct(top)} to ${pct(1 - bottom)} of the height, at least ${pct(side)} in from each side`;
8394
8454
  if (short && !flowing) {
8395
8455
  const around = isTall(plan) && covered ? "only the ad's own picture, larger, never a flat colour, a panel or a square" : "only the ad's own picture";
8396
8456
  return `Zones: every word, the logo and the button sit ${band}; around them, ${around}.`;
@@ -8399,7 +8459,7 @@ function zonesText(plan, flowing, ground, short) {
8399
8459
  return flowing ? `The logo, every line and the button sit ${band}; around them the frame shows only the ad's own picture.` : `Zones: the logo, every line and the button sit ${band}; around them, only the ad's own picture.`;
8400
8460
  }
8401
8461
  const picture = ground ? "Image 1's background with its shapes and graphics enlarged in the brand's colours" : "Image 1's people, product and setting extended";
8402
- return flowing ? `The words' band runs ${band}, holding the logo, every line and the button. Above and below it the app's interface covers the frame, so there it shows only the ad's own picture, larger: ${picture}, edge to edge.` : `Zones: the logo, every line and the button sit in one band, ${band}; the top ${pct(top)} and the bottom ${pct(bottom)} carry neither words, the logo nor the button. Above and below it, where the app's interface covers the frame, only the ad's own picture, larger: ${picture}, never a flat empty colour, a panel or a square on a background.`;
8462
+ return flowing ? `The words' band runs ${band}, holding the logo, every line and the button. Above and below it the app's interface covers the frame, so there it shows only the ad's own picture, larger: ${picture}, edge to edge.` : `Zones: the app's buttons and captions cover the top and the bottom of this frame, hiding any word there. The logo, every line and the button sit in one band, ${band}: the headline starts below the top fifth, and the button ends directly under the last line, never at the bottom edge where a feed ad puts it. Above and below it, where the app's interface covers the frame, only the ad's own picture, larger: ${picture}, never a flat empty colour, a panel or a square on a background.`;
8403
8463
  }
8404
8464
  function adaptBrief(spec, profile, opts) {
8405
8465
  const { plan, images } = opts;
@@ -8468,6 +8528,7 @@ var STATIC_ADAPT_ROLE = "static_adapt";
8468
8528
  var STATIC_SAFE_FIT_ROLE = "static_safe_fit";
8469
8529
  var STATIC_PLACE_ROLE = "static_place";
8470
8530
  var LOGO_COMPOSITE_ROLE = "logo_composite";
8531
+ var SERIES_LEAD_ROLE = "series_lead";
8471
8532
  var STATIC_FIX_ROLE = "static_fix";
8472
8533
  var LOGO_COMPOSITE_DEFAULT = false;
8473
8534
  var ADAPT_IMAGE_SIZE = "2K";
@@ -8487,6 +8548,19 @@ function rolesById(canvas) {
8487
8548
  }
8488
8549
  return out;
8489
8550
  }
8551
+ function relaidPictureRole(canvas) {
8552
+ const roles = rolesById(canvas);
8553
+ const final = nodesOf(canvas).find((n) => n.id === "final" && n.type === "collect");
8554
+ const labels = final?.params?.labels;
8555
+ const images = final?.inputs?.images;
8556
+ if (!Array.isArray(labels) || !Array.isArray(images)) return {};
8557
+ const at2 = images.findIndex((ref) => {
8558
+ const from = typeof ref === "string" ? /^\$ref:([^.]+)\./.exec(ref)?.[1] : void 0;
8559
+ return from !== void 0 && roles[from] === STATIC_MASTER_ROLE;
8560
+ });
8561
+ const format = labels[at2];
8562
+ return typeof format === "string" ? { [`last_${formatSlug(format)}`]: STATIC_MASTER_ROLE } : {};
8563
+ }
8490
8564
  function formatSlug(format) {
8491
8565
  return format.replace(":", "x").replace(".", "_");
8492
8566
  }
@@ -10136,7 +10210,7 @@ function checkOrphanNodes(ctx) {
10136
10210
  path: `nodes[${ctx.idToIndex.get(n.id)}]`,
10137
10211
  code: STAGE_CODES.ORPHAN_NODE,
10138
10212
  severity: "warning",
10139
- 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`
10213
+ 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`
10140
10214
  });
10141
10215
  }
10142
10216
  }
@@ -10207,6 +10281,17 @@ function unwrap(schema) {
10207
10281
  }
10208
10282
 
10209
10283
  // src/engine/engine/executor.ts
10284
+ function throwRunOutcome(signal, failures, held) {
10285
+ if (signal?.aborted) throw new RunAbortedError("signal", "run aborted by signal");
10286
+ if (failures.length === 1 && failures[0]) throw failures[0].reason;
10287
+ if (failures.length > 1) throw new LayerExecutionError([...failures]);
10288
+ if (held.length > 0) {
10289
+ throw new RunAbortedError(
10290
+ "checkpoint",
10291
+ `stopped before ${held.join(", ")} \u2014 everything up to here is done and cached, so continuing this run id pays for none of it again`
10292
+ );
10293
+ }
10294
+ }
10210
10295
  function heldByCheckpoint(canvas, layer, kinds) {
10211
10296
  if (!kinds?.size) return [];
10212
10297
  return layer.filter((id) => {
@@ -10295,45 +10380,63 @@ var Engine = class {
10295
10380
  return { run_id: runId, output, outputs_by_node: outputs, stats, outputs_dir: writer.runDir, node_runs: nodeRuns };
10296
10381
  }
10297
10382
  async runLayers(canvas, graph, outputs, runId, writer, opts, counters, nodeRuns, needsBytes) {
10298
- const layers = topologicalLayers(graph);
10383
+ const order = topologicalLayers(graph).flat();
10299
10384
  const limit = resolveConcurrency(opts.concurrency);
10300
- for (const layer of layers) {
10301
- this.assertLayerMayRun(canvas, layer, opts);
10302
- const settled = await mapWithConcurrency(layer, limit, (nodeId) => {
10303
- if (opts.signal?.aborted) {
10304
- return Promise.reject(new RunAbortedError("signal", "run aborted before node dispatch"));
10305
- }
10306
- this.emitProgress(opts, { kind: "node_start", node_id: nodeId });
10307
- return this.executeOne(canvas, nodeId, outputs, runId, writer, opts, needsBytes.has(nodeId)).then((r) => {
10308
- if (r.cached) counters.cachedNodes++;
10309
- counters.totalCredits += r.credits;
10310
- const node = canvas.nodes.find((n) => n.id === nodeId);
10311
- if (node) {
10312
- const run = {
10313
- node_id: nodeId,
10314
- node_type: node.type,
10315
- cached: r.cached,
10316
- duration_ms: r.durationMs,
10317
- credits: r.credits
10318
- };
10319
- nodeRuns.push(run);
10320
- this.emitProgress(opts, { kind: "node_settled", run, outputs: outputs[nodeId] ?? {} });
10321
- }
10322
- });
10323
- });
10324
- const failures = [];
10325
- settled.forEach((result, i) => {
10326
- const nodeId = layer[i];
10327
- if (result.status === "rejected" && nodeId) {
10328
- if (result.reason instanceof RunAbortedError) return;
10329
- failures.push({ nodeId, reason: result.reason });
10330
- this.emitProgress(opts, { kind: "node_failed", node_id: nodeId, error: failureDetail(result.reason) });
10385
+ if (opts.signal?.aborted) throw new RunAbortedError("signal", "run aborted before dispatch");
10386
+ const waitingOn = new Map(order.map((id) => [id, new Set(graph.get(id) ?? [])]));
10387
+ const running = /* @__PURE__ */ new Map();
10388
+ const failures = [];
10389
+ const held = [];
10390
+ const dispatch = (nodeId) => {
10391
+ waitingOn.delete(nodeId);
10392
+ this.emitProgress(opts, { kind: "node_start", node_id: nodeId });
10393
+ const settled = this.executeOne(canvas, nodeId, outputs, runId, writer, opts, needsBytes.has(nodeId)).then(
10394
+ (r) => {
10395
+ this.recordRun(canvas, nodeId, r, counters, nodeRuns, outputs, opts);
10396
+ for (const deps of waitingOn.values()) deps.delete(nodeId);
10397
+ },
10398
+ (reason) => {
10399
+ failures.push({ nodeId, reason });
10400
+ this.emitProgress(opts, { kind: "node_failed", node_id: nodeId, error: failureDetail(reason) });
10331
10401
  }
10332
- });
10333
- if (opts.signal?.aborted) throw new RunAbortedError("signal", "run aborted by signal");
10334
- if (failures.length === 1 && failures[0]) throw failures[0].reason;
10335
- if (failures.length > 1) throw new LayerExecutionError(failures);
10402
+ );
10403
+ running.set(
10404
+ nodeId,
10405
+ settled.finally(() => running.delete(nodeId))
10406
+ );
10407
+ };
10408
+ const isHeld = (nodeId) => heldByCheckpoint(canvas, [nodeId], opts.stop_before_kinds).length > 0;
10409
+ for (; ; ) {
10410
+ const ready = opts.signal?.aborted ? [] : order.filter((id) => waitingOn.get(id)?.size === 0);
10411
+ for (const nodeId of ready) {
10412
+ if (running.size >= limit) break;
10413
+ if (isHeld(nodeId)) {
10414
+ waitingOn.delete(nodeId);
10415
+ held.push(nodeId);
10416
+ } else dispatch(nodeId);
10417
+ }
10418
+ if (running.size === 0) break;
10419
+ await Promise.race(running.values());
10336
10420
  }
10421
+ const rank = new Map(order.map((id, i) => [id, i]));
10422
+ failures.sort((a, b) => (rank.get(a.nodeId) ?? 0) - (rank.get(b.nodeId) ?? 0));
10423
+ throwRunOutcome(opts.signal, failures, held);
10424
+ }
10425
+ /** A settled node counted, recorded and announced. */
10426
+ recordRun(canvas, nodeId, r, counters, nodeRuns, outputs, opts) {
10427
+ if (r.cached) counters.cachedNodes++;
10428
+ counters.totalCredits += r.credits;
10429
+ const node = canvas.nodes.find((n) => n.id === nodeId);
10430
+ if (!node) return;
10431
+ const run = {
10432
+ node_id: nodeId,
10433
+ node_type: node.type,
10434
+ cached: r.cached,
10435
+ duration_ms: r.durationMs,
10436
+ credits: r.credits
10437
+ };
10438
+ nodeRuns.push(run);
10439
+ this.emitProgress(opts, { kind: "node_settled", run, outputs: outputs[nodeId] ?? {} });
10337
10440
  }
10338
10441
  /** Progress consumers are observers only — an exception there must never fail the run. */
10339
10442
  emitProgress(opts, event) {
@@ -10425,20 +10528,6 @@ var Engine = class {
10425
10528
  this.logOutputWarnings(node.id, outputsObj);
10426
10529
  return { cached: false, credits, durationMs: elapsed };
10427
10530
  }
10428
- /**
10429
- * Every reason a layer must not dispatch, checked before any of its nodes starts, so a stop
10430
- * never leaves a layer half billed: a signal, the cost cap and a checkpoint.
10431
- */
10432
- assertLayerMayRun(canvas, layer, opts) {
10433
- if (opts.signal?.aborted) throw new RunAbortedError("signal", "run aborted before layer dispatch");
10434
- const held = heldByCheckpoint(canvas, layer, opts.stop_before_kinds);
10435
- if (held.length > 0) {
10436
- throw new RunAbortedError(
10437
- "checkpoint",
10438
- `stopped before ${held.join(", ")} \u2014 everything up to here is done and cached, so continuing this run id pays for none of it again`
10439
- );
10440
- }
10441
- }
10442
10531
  /** A cache hit replayed as this node's result: its outputs written, and free. */
10443
10532
  async replayCached(node, cacheKey, outputs, writer) {
10444
10533
  const cacheT0 = Date.now();
@@ -14603,6 +14692,18 @@ function boxesOfLine(line, boxes) {
14603
14692
  return b.kind === "text" && read.length > 0 && words.includes(` ${read} `);
14604
14693
  });
14605
14694
  }
14695
+ var BUTTON_PAD = 0.7;
14696
+ var isButtonLine = (line) => /^(cta|button|call_to_action)$/.test(roleKey2(line.role));
14697
+ function buttonShape(box) {
14698
+ const pad = BUTTON_PAD * (box.y1 - box.y0);
14699
+ return {
14700
+ ...box,
14701
+ x0: Math.max(0, box.x0 - pad),
14702
+ y0: Math.max(0, box.y0 - pad),
14703
+ x1: Math.min(1, box.x1 + pad),
14704
+ y1: Math.min(1, box.y1 + pad)
14705
+ };
14706
+ }
14606
14707
  function shownBoxes(boxes, lines, clear) {
14607
14708
  const linesOfBox = /* @__PURE__ */ new Map();
14608
14709
  for (const line of lines) {
@@ -14986,7 +15087,7 @@ async function videoDuration(video) {
14986
15087
  return null;
14987
15088
  }
14988
15089
  }
14989
- async function askGateway(model, parts, timeoutMs, spent) {
15090
+ async function askGateway(model, parts, timeoutMs, spent, reasoning) {
14990
15091
  const key = openRouterKey();
14991
15092
  if (!key) return null;
14992
15093
  try {
@@ -14996,7 +15097,8 @@ async function askGateway(model, parts, timeoutMs, spent) {
14996
15097
  body: JSON.stringify({
14997
15098
  model,
14998
15099
  messages: [{ role: "user", content: parts }],
14999
- usage: { include: true }
15100
+ usage: { include: true },
15101
+ ...reasoning ? { reasoning } : {}
15000
15102
  }),
15001
15103
  signal: AbortSignal.timeout(timeoutMs)
15002
15104
  });
@@ -15045,7 +15147,7 @@ async function askAboutStills(images, prompt, onSpend) {
15045
15147
  if (!hasOpenRouterKey()) return null;
15046
15148
  return await ask(images, prompt, (usage) => onSpend?.(usage));
15047
15149
  }
15048
- async function askAboutImages(model, images, prompt, onSpend, timeoutMs = 6e4) {
15150
+ async function askAboutImages(model, images, prompt, onSpend, timeoutMs = 6e4, reasoning) {
15049
15151
  if (!hasOpenRouterKey()) return null;
15050
15152
  return await askGateway(
15051
15153
  model,
@@ -15056,7 +15158,8 @@ async function askAboutImages(model, images, prompt, onSpend, timeoutMs = 6e4) {
15056
15158
  )
15057
15159
  ],
15058
15160
  timeoutMs,
15059
- (usage) => onSpend?.(usage)
15161
+ (usage) => onSpend?.(usage),
15162
+ reasoning
15060
15163
  );
15061
15164
  }
15062
15165
  async function askAboutText(model, prompt, onSpend) {
@@ -15220,7 +15323,9 @@ async function readTextBoxes(image, opts = {}) {
15220
15323
  return { status: "not_checked" };
15221
15324
  }
15222
15325
  for (let attempt = 0; attempt < 2; attempt++) {
15223
- const answer = await askAboutImages(TEXT_BOX_READER_MODEL, [jpeg], TEXT_BOX_PROMPT, opts.onSpend);
15326
+ const answer = await askAboutImages(TEXT_BOX_READER_MODEL, [jpeg], TEXT_BOX_PROMPT, opts.onSpend, 6e4, {
15327
+ effort: "minimal"
15328
+ });
15224
15329
  const boxes = textBoxesFromReader(answer);
15225
15330
  if (boxes) return { status: "read", boxes };
15226
15331
  }
@@ -15310,6 +15415,8 @@ var StaticFinding = z21.object({
15310
15415
  });
15311
15416
  var ABSENCE_NEVER_A_FINDING = /\b(?:missing|no|lacks?|without)\s+(?:an?\s+|any\s+|clear\s+|visible\s+)?(?:call to action|cta)\b|\b(?:does not|doesn't) (?:show|tell) the viewer what to do/i;
15312
15417
  var ALWAYS_BLOCKING = /^(?:SUBJECT|PERSON):/i;
15418
+ var ABOUT_OWN_COPY = /^COPY:|["“«]|\b(?:headline|subhead\w*|button|cta|tagline|copy)\b/i;
15419
+ var BLURRED_FIGURES = /\b(?:blur\w*|obscur\w*|unreadable|illegible|smudg\w*)\b(?=[^.]*\b(?:figures?|numbers?|digits?|amounts?|prices?|savings?|values?)\b)|\b(?:figures?|numbers?|digits?|amounts?|prices?|savings?|values?)\b(?=[^.]*\b(?:blur\w*|obscur\w*|unreadable|illegible|smudg\w*)\b)/i;
15313
15420
  function classifyStaticFindings(json) {
15314
15421
  const findings = json?.findings;
15315
15422
  if (!Array.isArray(findings)) return [];
@@ -15318,7 +15425,7 @@ function classifyStaticFindings(json) {
15318
15425
  if (!parsed.success) return [];
15319
15426
  const { severity, format, what } = parsed.data;
15320
15427
  if (ABSENCE_NEVER_A_FINDING.test(what)) return [];
15321
- const graded = ALWAYS_BLOCKING.test(what) ? "blocking" : severity;
15428
+ const graded = ALWAYS_BLOCKING.test(what) ? "blocking" : BLURRED_FIGURES.test(what) && !ABOUT_OWN_COPY.test(what) ? "warning" : severity;
15322
15429
  return [{ severity: graded, what: format?.trim() ? `${format.trim()}: ${what}` : what }];
15323
15430
  });
15324
15431
  }
@@ -15329,7 +15436,7 @@ var CopyRead = z21.object({
15329
15436
  seen: z21.string().nullable()
15330
15437
  });
15331
15438
  function comparableCopy(text2) {
15332
- return text2.replace(/[‘’‚′]/g, "'").replace(/[“”„″]/g, '"').replace(/[‐-―−]/g, "-").replace(/…/g, "...").replace(/\s+/g, " ").trim().toLowerCase();
15439
+ return text2.replace(/[‘’‚′]/g, "'").replace(/[“”„″]/g, '"').replace(/[‐-―−]/g, "-").replace(/…/g, "...").replace(/[«»]/g, '"').replace(/\s+/g, " ").trim().replace(/^"([^"]*)"$/, "$1").trim().toLowerCase();
15333
15440
  }
15334
15441
  function copyFindings(json, copy, formats, scrolled = {}) {
15335
15442
  if (copy.length === 0) return [];
@@ -15704,7 +15811,7 @@ function contextItems(opts) {
15704
15811
  opts.thumbnails ? "7. THUMBNAIL: at thumbnail size the lead line (the largest words) cannot be read. Judge only whether those words can be read, nothing else. That is blocking: most people only ever see the ad that small." : "7. THUMBNAIL: the lead line (the largest words) would be unreadable on a phone feed. That is blocking."
15705
15812
  ],
15706
15813
  sizes: opts.formats.length > 1 ? [
15707
- "12. SIZES: one size loses what the others are built on: the letter sheet, the email or chat window, the post card, the comic's panels and their order, the product the ad is about (blocking). Rearranged for its shape is fine."
15814
+ `12. SIZES: one size loses what the others are built on: the letter sheet, the email or chat window, the post card, the comic's panels and their order, the product the ad is about, or a size that shows something the others do not (blocking; start the finding with "SIZES:" and report it on the size that differs). Rearranged for its shape is fine.`
15708
15815
  ] : []
15709
15816
  };
15710
15817
  }
@@ -15757,6 +15864,7 @@ function mostOfALine(chunk, copy) {
15757
15864
  });
15758
15865
  }
15759
15866
  var CLAIM = /[%€$£]|\b(?:free|gratis|gratuit[oa]?s?|off|sale|oferta|descuento|rebajas?|promo)\b/i;
15867
+ var PRICE = /[€$£]\s?\d|\d\s?[€$£]/;
15760
15868
  var COPY_SIZE_SHARE = 0.8;
15761
15869
  function extraWordFindings(format, extra, boxes, copyBoxes) {
15762
15870
  if (extra.length === 0) return [];
@@ -15774,7 +15882,8 @@ function extraWordFindings(format, extra, boxes, copyBoxes) {
15774
15882
  const printed = (chunk) => usual > 0 && underCopy.includes(chunk) && (!CLAIM.test(chunk) || scenery(chunk));
15775
15883
  const quote = (chunks) => chunks.map((e) => `"${e}"`).join(", ");
15776
15884
  const asCopy = extra.filter((chunk) => !printed(chunk));
15777
- const inScene = extra.filter(printed);
15885
+ const prices = extra.filter((chunk) => printed(chunk) && PRICE.test(chunk));
15886
+ const inScene = extra.filter((chunk) => printed(chunk) && !prices.includes(chunk));
15778
15887
  return [
15779
15888
  ...asCopy.length > 0 ? [
15780
15889
  {
@@ -15783,6 +15892,13 @@ function extraWordFindings(format, extra, boxes, copyBoxes) {
15783
15892
  what: `${format}: the picture adds words that aren't in the copy: ${quote(asCopy)}. Remove them, or add them to the copy.`
15784
15893
  }
15785
15894
  ] : [],
15895
+ ...prices.length > 0 ? [
15896
+ {
15897
+ severity: "blocking",
15898
+ // `static-autofix.ts` SCENE_PRICES reads this sentence to blur them.
15899
+ what: `${format}: the picture shows prices the copy does not hold: ${quote(prices)}. Make them unreadable.`
15900
+ }
15901
+ ] : [],
15786
15902
  ...inScene.length > 0 ? [
15787
15903
  {
15788
15904
  severity: "warning",
@@ -15847,7 +15963,8 @@ function corroborateEdgeClaims(findings, boxesByFormat, lines) {
15847
15963
  const element = claimedBoxes(f.what, boxes, buttons);
15848
15964
  if (element.length === 0) return f;
15849
15965
  const named = claimedEdges(f.what);
15850
- const atEdge = element.some((b) => named.some((edge) => clearanceTo(b, edge) <= AT_EDGE));
15966
+ const pad = BUTTON.test(f.what) && !LOGO_CLAIM.test(f.what) ? BUTTON_PAD : 0;
15967
+ const atEdge = element.some((b) => named.some((edge) => clearanceTo(b, edge) - pad * (b.y1 - b.y0) <= AT_EDGE));
15851
15968
  return atEdge ? f : { ...f, severity: "warning" };
15852
15969
  });
15853
15970
  }
@@ -15855,9 +15972,11 @@ function claimedEdges(what) {
15855
15972
  const edges = EDGE_WORD.filter(([, word]) => word.test(what)).map(([edge]) => edge);
15856
15973
  return edges.length > 0 ? edges : ["top", "bottom", "left", "right"];
15857
15974
  }
15975
+ var LOGO_CLAIM = /\b(?:logos?|wordmark|mark)\b/i;
15976
+ var BUTTON = /\b(button|cta|call[- ]to[- ]action)\b/i;
15858
15977
  function claimedBoxes(what, boxes, buttons) {
15859
- if (/\b(?:logos?|wordmark|mark)\b/i.test(what)) return boxes.filter((b) => b.kind === "logo");
15860
- if (/\b(button|cta|call[- ]to[- ]action)\b/i.test(what)) return buttons.flatMap((l) => boxesOfLine(l, boxes));
15978
+ if (LOGO_CLAIM.test(what)) return boxes.filter((b) => b.kind === "logo");
15979
+ if (BUTTON.test(what)) return buttons.flatMap((l) => boxesOfLine(l, boxes));
15861
15980
  return ABOUT_COPY.test(what) ? boxes.filter((b) => b.kind === "text") : [];
15862
15981
  }
15863
15982
  async function bytesOf(source) {
@@ -15987,6 +16106,22 @@ function everySizeChecks(opts) {
15987
16106
  onSpend: opts.onSpend
15988
16107
  };
15989
16108
  }
16109
+ async function confirmBlocking(findings, stills, formats, ask2) {
16110
+ const claims = findings.filter((f) => f.severity === "blocking" && !CLIPPED.test(f.what));
16111
+ if (claims.length === 0 || stills.length === 0) return [...findings];
16112
+ const prompt = [
16113
+ `These are the finished sizes of one ad, in order: ${formats.map((f, i) => `Image ${i + 1} is the ${f}`).join("; ")}.`,
16114
+ "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.",
16115
+ "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.",
16116
+ ...claims.map((c, i) => `${i + 1}. ${c.what}`),
16117
+ `Reply with only this JSON: {"answers":[true|false, ...]}, one answer per claim, in order.`
16118
+ ].join("\n");
16119
+ const answer = await ask2([...stills], prompt).catch(() => null);
16120
+ const answers = answer?.answers;
16121
+ if (!Array.isArray(answers) || answers.length !== claims.length) return [...findings];
16122
+ const unconfirmed = new Set(claims.filter((_, i) => answers[i] === false));
16123
+ return findings.map((f) => unconfirmed.has(f) ? { ...f, severity: "warning" } : f);
16124
+ }
15990
16125
  async function reviewRenderedStatic(opts) {
15991
16126
  const { measured, originals, stills, thumbs } = await loadDelivered(opts.images);
15992
16127
  if (measured.length === 0) return null;
@@ -15995,6 +16130,12 @@ async function reviewRenderedStatic(opts) {
15995
16130
  const formats = measured.map((m) => m.format);
15996
16131
  const lines = opts.lines ?? opts.copy.map((text3) => ({ text: text3, lettering: "designed" }));
15997
16132
  const every = { lines, ...everySizeChecks(opts) };
16133
+ const ask2 = (measuredRead) => {
16134
+ const prompt = buildStaticReviewPrompt(reviewPromptOpts(opts, formats, measuredRead, reference !== null));
16135
+ const shown = [...measuredRead ? stills : [...stills, ...thumbs], ...reference ? [reference] : []];
16136
+ return askAboutStills(shown, prompt, opts.onSpend);
16137
+ };
16138
+ const asking = ask2(true);
15998
16139
  const checks = await Promise.all(
15999
16140
  originals.map(
16000
16141
  (bytes, i) => checkStaticImage(bytes, {
@@ -16006,13 +16147,19 @@ async function reviewRenderedStatic(opts) {
16006
16147
  );
16007
16148
  const read = checks.every((c) => c.status === "checked");
16008
16149
  const measuredText = read ? checks.flatMap((c) => c.status === "checked" ? c.findings : []) : [];
16009
- const prompt = buildStaticReviewPrompt(reviewPromptOpts(opts, formats, read, reference !== null));
16010
- const shown = [...read ? stills : [...stills, ...thumbs], ...reference ? [reference] : []];
16011
- const answer = await askAboutStills(shown, prompt, opts.onSpend) ?? await askAboutStills(shown, prompt, opts.onSpend);
16150
+ const first = read ? await asking : (void asking.catch(() => null), null);
16151
+ const answer = first ?? await ask2(read);
16012
16152
  if (answer === null) return unreviewed(shape, measuredText);
16013
16153
  const text2 = read ? measuredText : copyFindings(answer, opts.copy, formats, opts.scrolled);
16014
16154
  const boxesByFormat = read ? Object.fromEntries(checks.flatMap((c, i) => c.status === "checked" ? [[formats[i] ?? "", c.boxes]] : [])) : {};
16015
- return [...shape, ...text2, ...corroborateEdgeClaims(classifyStaticFindings(answer), boxesByFormat, lines)];
16155
+ const judged = corroborateEdgeClaims(classifyStaticFindings(answer), boxesByFormat, lines);
16156
+ const confirmed = await confirmBlocking(
16157
+ judged,
16158
+ stills,
16159
+ formats,
16160
+ (images, prompt) => askAboutStills(images, prompt, opts.onSpend)
16161
+ );
16162
+ return [...shape, ...text2, ...confirmed];
16016
16163
  }
16017
16164
 
16018
16165
  // src/engine/nodes/local/staticNative.ts
@@ -16256,7 +16403,9 @@ function boxesToPlace(boxes, format, opts = {}) {
16256
16403
  const past = (box) => bandViolations([box], format, { placements })[0];
16257
16404
  const cut = (box) => (past(box)?.clearance ?? 1) <= FRAME_CUT;
16258
16405
  const logoWords = opts.copy.filter((l) => roleKey2(l.role) === "logo").map((l) => l.text);
16259
- return shownBoxes(withWordmark(boxes, logoWords), opts.copy, (box) => !past(box)).filter((shown) => !shown.supporting || cut(shown.box)).map((shown) => shown.box);
16406
+ const read = withWordmark(boxes, logoWords);
16407
+ const buttons = new Set(opts.copy.filter(isButtonLine).flatMap((line) => boxesOfLine(line, read)));
16408
+ return shownBoxes(read, opts.copy, (box) => !past(box)).filter((shown) => !shown.supporting || cut(shown.box)).map((shown) => buttons.has(shown.box) ? buttonShape(shown.box) : shown.box);
16260
16409
  }
16261
16410
  function planSafeFit(boxes, format, opts = {}) {
16262
16411
  const area = blockingArea(format, opts.placements);
@@ -16360,6 +16509,9 @@ var MIN_EXTEND_SCALE = 0.6;
16360
16509
  var GREY_LEVEL = 128;
16361
16510
  var GREY = { r: GREY_LEVEL, g: GREY_LEVEL, b: GREY_LEVEL };
16362
16511
  var FEATHER = 0.012;
16512
+ var KEEP_PAD = 0.3;
16513
+ var KEEP_FEATHER = 0.03;
16514
+ var MOVED = 120;
16363
16515
  function ratioOf2(ratio) {
16364
16516
  const [w = 1, h = 1] = ratio.split(":").map(Number);
16365
16517
  return w / h;
@@ -16422,14 +16574,16 @@ function clearBusyMargins(rgba, frame, keep) {
16422
16574
  }
16423
16575
  return shown;
16424
16576
  }
16425
- async function mergeExtended(extended, placed, ratio) {
16577
+ async function mergeExtended(extended, placed, ratio, keep = []) {
16426
16578
  const ad = await sharp7(placed).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
16427
16579
  const { width, height } = ad.info;
16428
16580
  const bounds = placedBounds(ad.data, width, height);
16429
16581
  if (!bounds) return sharp7(placed).removeAlpha().png().toBuffer();
16430
16582
  const canvas = renderCanvas({ width, height }, ratio);
16431
16583
  const paint = await sharp7(extended).removeAlpha().resize(canvas.width, canvas.height, { fit: "fill" }).extract({ left: canvas.left, top: canvas.top, width, height }).raw().toBuffer();
16432
- const weight = edgeWeight(bounds, width, height, Math.max(1, Math.round(FEATHER * width)));
16584
+ const edge = edgeWeight(bounds, width, height, Math.max(1, Math.round(FEATHER * width)));
16585
+ const boxes = keep.length > 0 && keptInPlace(keep, ad.data, paint, width, height) ? keptWeights(keep, width, height) : null;
16586
+ const weight = boxes ? (x, y) => Math.min(edge(x, y), boxes[y * width + x] ?? 0) : edge;
16433
16587
  const out = Buffer.alloc(width * height * 3);
16434
16588
  for (let y = 0; y < height; y++) {
16435
16589
  for (let x = 0; x < width; x++) {
@@ -16468,18 +16622,65 @@ function edgeWeight(b, width, height, feather) {
16468
16622
  ramp(b.y1 - y, b.y1 === height - 1)
16469
16623
  );
16470
16624
  }
16625
+ function keptWeights(keep, width, height) {
16626
+ const out = new Float32Array(width * height);
16627
+ const feather = Math.max(1, KEEP_FEATHER * width);
16628
+ for (const box of keep) {
16629
+ const pad = KEEP_PAD * (box.y1 - box.y0) * height;
16630
+ const x0 = box.x0 * width - pad;
16631
+ const x1 = box.x1 * width + pad;
16632
+ const y0 = box.y0 * height - pad;
16633
+ const y1 = box.y1 * height + pad;
16634
+ const ys = Math.max(0, Math.floor(y0 - feather));
16635
+ const ye = Math.min(height - 1, Math.ceil(y1 + feather));
16636
+ const xs = Math.max(0, Math.floor(x0 - feather));
16637
+ const xe = Math.min(width - 1, Math.ceil(x1 + feather));
16638
+ for (let y = ys; y <= ye; y++) {
16639
+ const dy = Math.max(0, y0 - y, y - y1);
16640
+ for (let x = xs; x <= xe; x++) {
16641
+ const dx = Math.max(0, x0 - x, x - x1);
16642
+ const t = Math.max(0, 1 - Math.hypot(dx, dy) / feather);
16643
+ const w = t * t * (3 - 2 * t);
16644
+ const i = y * width + x;
16645
+ if (w > (out[i] ?? 0)) out[i] = w;
16646
+ }
16647
+ }
16648
+ }
16649
+ return out;
16650
+ }
16651
+ function keptInPlace(keep, ad, paint, width, height) {
16652
+ const diffs = keep.map((box) => boxDiff(box, ad, paint, width, height)).filter((d) => d !== null).sort((a, b) => a - b);
16653
+ return (diffs[Math.floor(diffs.length / 2)] ?? Number.POSITIVE_INFINITY) <= MOVED;
16654
+ }
16655
+ function boxDiff(box, ad, paint, width, height) {
16656
+ let sum = 0;
16657
+ let n = 0;
16658
+ const xs = Math.floor(box.x0 * width);
16659
+ const xe = Math.min(width - 1, Math.ceil(box.x1 * width));
16660
+ for (let y = Math.floor(box.y0 * height); y <= Math.min(height - 1, Math.ceil(box.y1 * height)); y++) {
16661
+ for (let x = xs; x <= xe; x++) {
16662
+ const i = y * width + x;
16663
+ if ((ad[i * 4 + 3] ?? 0) === 0) continue;
16664
+ for (let c = 0; c < 3; c++) sum += Math.abs((ad[i * 4 + c] ?? 0) - (paint[i * 3 + c] ?? 0));
16665
+ n++;
16666
+ }
16667
+ }
16668
+ return n > 0 ? sum / n : null;
16669
+ }
16471
16670
 
16472
16671
  // src/engine/nodes/local/staticPlace.ts
16473
16672
  var Usage = z25.object({ model: z25.string(), inputTokens: z25.number(), outputTokens: z25.number(), costUsd: z25.number() }).strict();
16474
16673
  var Fit = z25.object({ scale: z25.number(), x: z25.number(), y: z25.number() }).strict();
16674
+ var Unit = z25.number().min(0).max(1);
16675
+ var Span = z25.object({ x0: Unit, y0: Unit, x1: Unit, y1: Unit }).strict();
16475
16676
  var RenderRatio = z25.string().regex(/^\d+(?:\.\d+)?:\d+(?:\.\d+)?$/);
16476
16677
  var Copy = z25.array(z25.object({ text: z25.string().min(1), role: z25.string().optional() }).strict());
16477
16678
  var staticPlaceNode = defineNode({
16478
16679
  id: "static_place",
16479
- version: "1.0.0",
16680
+ version: "1.1.0",
16480
16681
  category: "image",
16481
16682
  location: "local",
16482
- summary: "Place a finished static ad's words and logo where no interface covers them and nothing runs off the frame, for an image model to paint the margins: reads where every line and the logo sit, shrinks the ad (to 60% at most) and sets it inside the size's safe area. `placed` keeps the margins clear (for `static_extend_merge`); `shown` is the same inside the model's render shape with grey margins, to paint. `fit` is null, and both are the ad unchanged, when nothing sits outside.",
16683
+ summary: "Place a finished static ad's words and logo where no interface covers them and nothing runs off the frame, for an image model to paint the margins: reads where every line and the logo sit, shrinks the ad (to 60% at most) and sets it inside the size's safe area. `placed` keeps the margins clear (for `static_extend_merge`); `shown` is the same inside the model's render shape with grey margins, to paint; `keep` is where every line, the logo and the buttons read sit in `placed`, all the merge lays back of the ad's own pixels. `fit` is null, and both are the ad unchanged, when nothing sits outside.",
16483
16684
  when_to_use: "Added by a static ad's autofix round (place-and-extend) before an `image_generate` that paints the margins and a `static_extend_merge`; never scaffolded. One box-reader call (about $0.005).",
16484
16685
  inputs: z25.object({ image: ImageRef }).strict(),
16485
16686
  params: z25.object({
@@ -16492,6 +16693,8 @@ var staticPlaceNode = defineNode({
16492
16693
  placed: ImageRef,
16493
16694
  shown: ImageRef,
16494
16695
  fit: Fit.nullable(),
16696
+ /** Where the words, logo and buttons read sit in `placed`: all the merge lays back of the ad's own pixels. */
16697
+ keep: z25.array(Span).optional(),
16495
16698
  reason: z25.enum(["not_read", "inside_band", "too_far"]).optional(),
16496
16699
  usage: z25.array(Usage).optional()
16497
16700
  }).strict(),
@@ -16534,18 +16737,28 @@ var staticPlaceNode = defineNode({
16534
16737
  placed: placedRef,
16535
16738
  shown: shownRef,
16536
16739
  fit,
16740
+ keep: keptBoxes([...reading.boxes, ...boxes.filter((b) => !reading.boxes.includes(b))], fit),
16537
16741
  ...spend
16538
16742
  };
16539
16743
  }
16540
16744
  });
16745
+ function keptBoxes(boxes, fit) {
16746
+ const at2 = (offset2, v) => Math.min(1, Math.max(0, offset2 + fit.scale * v));
16747
+ return boxes.map((b) => ({ x0: at2(fit.x, b.x0), y0: at2(fit.y, b.y0), x1: at2(fit.x, b.x1), y1: at2(fit.y, b.y1) }));
16748
+ }
16541
16749
  var staticExtendMergeNode = defineNode({
16542
16750
  id: "static_extend_merge",
16543
- version: "1.1.0",
16751
+ version: "1.2.0",
16544
16752
  category: "image",
16545
16753
  location: "local",
16546
- summary: "Have the image model paint a placed static ad's margins (`static_place`'s `shown`, with `paint`, an `image_generate`'s params) and lay the ad (`placed`) back over the painting: the ad's own pixels where it sits, the painting only in the margins, blended at their seam. Same pixel size as the ad. An ad that was never placed (`shown` is `placed`) passes on as it is, and nothing is painted or billed. A painting already made can be wired as `image` instead of `paint`.",
16754
+ summary: "Have the image model paint a placed static ad's margins (`static_place`'s `shown`, with `paint`, an `image_generate`'s params) and lay the ad's own pixels back over the painting only in `keep` (`static_place`'s boxes of the words, logo and buttons, padded and feathered): the painting is the rest of the frame, one picture with no edge where the shrunk ad was. Without `keep`, or when the painting moved the words, the whole ad (`placed`) is laid back, the painting only in the margins, blended at their seam. Same pixel size as the ad. An ad that was never placed (`shown` is `placed`) passes on as it is, and nothing is painted or billed. A painting already made can be wired as `image` instead of `paint`.",
16547
16755
  when_to_use: "Added by a static ad's autofix round after a `static_place`; never scaffolded. One picture at the painter's price, only for a size that was placed.",
16548
- inputs: z25.object({ placed: ImageRef, shown: ImageRef.optional(), image: ImageRef.optional() }).strict(),
16756
+ inputs: z25.object({
16757
+ placed: ImageRef,
16758
+ shown: ImageRef.optional(),
16759
+ image: ImageRef.optional(),
16760
+ keep: z25.array(Span).optional()
16761
+ }).strict(),
16549
16762
  params: z25.object({ render_ratio: RenderRatio, paint: z25.record(z25.string(), z25.unknown()).optional() }).strict(),
16550
16763
  outputs: z25.object({ image: ImageRef }).strict(),
16551
16764
  outputKinds: { image: "image" },
@@ -16555,7 +16768,7 @@ var staticExtendMergeNode = defineNode({
16555
16768
  const painted = painting?.path ?? painting?.url;
16556
16769
  const placed = inputs.placed.path ?? inputs.placed.url;
16557
16770
  if (!painted || !placed) return { image: inputs.placed };
16558
- const merged = await mergeExtended(await bytesOf(painted), await bytesOf(placed), params.render_ratio);
16771
+ const merged = await mergeExtended(await bytesOf(painted), await bytesOf(placed), params.render_ratio, inputs.keep);
16559
16772
  const image = await ctx.assets.ingestBytes({ bytes: merged, kind: "image", mime: "image/png" });
16560
16773
  return { image };
16561
16774
  }
@@ -16584,12 +16797,64 @@ import { z as z26 } from "zod";
16584
16797
  // src/engine/nodes/local/lib/variant-prefilter.ts
16585
16798
  import { createHash as createHash3 } from "crypto";
16586
16799
  import sharp8 from "sharp";
16587
- function coverFitBoxes(boxes, from, to) {
16800
+ function coverOf(from, to) {
16588
16801
  const scale = Math.max(to.width / from.width, to.height / from.height);
16589
- const offX = (from.width * scale - to.width) / 2;
16590
- const offY = (from.height * scale - to.height) / 2;
16591
- const x = (f) => (f * from.width * scale - offX) / to.width;
16592
- const y = (f) => (f * from.height * scale - offY) / to.height;
16802
+ const width = from.width * scale;
16803
+ const height = from.height * scale;
16804
+ return { scale, width, height, centre: { left: (width - to.width) / 2, top: (height - to.height) / 2 } };
16805
+ }
16806
+ function framingOf(from, to) {
16807
+ let frame;
16808
+ return {
16809
+ place: (boxes) => {
16810
+ frame = frameKeeping(boxes, from, to);
16811
+ return coverFitBoxes(boxes, from, to, frame);
16812
+ },
16813
+ moved: () => {
16814
+ const { centre } = coverOf(from, to);
16815
+ return frame && (Math.abs(frame.left - centre.left) >= 1 || Math.abs(frame.top - centre.top) >= 1) ? frame : void 0;
16816
+ }
16817
+ };
16818
+ }
16819
+ async function cutToFrame(bytes, frame, to) {
16820
+ const { width = 0, height = 0 } = await sharp8(bytes).metadata();
16821
+ const { scale } = coverOf({ width, height }, to);
16822
+ const w = Math.min(width, Math.round(to.width / scale));
16823
+ const h = Math.min(height, Math.round(to.height / scale));
16824
+ const left = Math.min(width - w, Math.max(0, Math.round(frame.left / scale)));
16825
+ const top = Math.min(height - h, Math.max(0, Math.round(frame.top / scale)));
16826
+ return sharp8(bytes).extract({ left, top, width: w, height: h }).png().toBuffer();
16827
+ }
16828
+ var FRAME_MARGIN = 0.02;
16829
+ function frameKeeping(boxes, from, to) {
16830
+ const cover = coverOf(from, to);
16831
+ if (boxes.length === 0) return cover.centre;
16832
+ const along = (offset2, extent, frame, lo, hi) => {
16833
+ const low = lo * extent;
16834
+ const high = hi * extent;
16835
+ const room = extent - frame;
16836
+ const margin = FRAME_MARGIN * frame;
16837
+ for (const m of [margin, 0]) {
16838
+ const min = Math.max(0, high + m - frame);
16839
+ const max = Math.min(room, low - m);
16840
+ if (min <= max) return Math.min(max, Math.max(min, offset2));
16841
+ }
16842
+ return offset2;
16843
+ };
16844
+ const x0 = Math.min(...boxes.map((b) => b.x0));
16845
+ const x1 = Math.max(...boxes.map((b) => b.x1));
16846
+ const y0 = Math.min(...boxes.map((b) => b.y0));
16847
+ const y1 = Math.max(...boxes.map((b) => b.y1));
16848
+ return {
16849
+ left: along(cover.centre.left, cover.width, to.width, x0, x1),
16850
+ top: along(cover.centre.top, cover.height, to.height, y0, y1)
16851
+ };
16852
+ }
16853
+ function coverFitBoxes(boxes, from, to, frame) {
16854
+ const cover = coverOf(from, to);
16855
+ const { left: offX, top: offY } = frame ?? cover.centre;
16856
+ const x = (f) => (f * cover.width - offX) / to.width;
16857
+ const y = (f) => (f * cover.height - offY) / to.height;
16593
16858
  return boxes.map((b) => ({ ...b, x0: x(b.x0), y0: y(b.y0), x1: x(b.x1), y1: y(b.y1) }));
16594
16859
  }
16595
16860
  var NO_LOGO = "The brand's logo was not found on it.";
@@ -16643,34 +16908,45 @@ async function prefilterVariants(variants, spec, formats, opts = {}) {
16643
16908
  });
16644
16909
  const format = plan.master.format;
16645
16910
  const grade = plan.masterShape === "relayout" ? plan.placements ? { placements: plan.placements } : {} : { band: plan.band };
16911
+ const want = { lines, buttons, ...brand ? { brand } : {}, logo, format, grade };
16646
16912
  const checked = await Promise.all(
16647
- variants.map(async (variant) => {
16648
- const source = variant.path ?? variant.url;
16649
- if (!source) return { index: variant.index, blocking: null };
16650
- try {
16651
- const bytes = await bytesOf(source);
16652
- const { width = 0, height = 0 } = await sharp8(bytes).metadata();
16653
- const check = await checkStaticImage(bytes, {
16654
- format,
16655
- lines,
16656
- allowed: brand ? [brand] : [],
16657
- ...grade,
16658
- toFrame: (boxes) => coverFitBoxes(boxes, { width, height }, plan.master.size),
16659
- ...opts.read ? { read: opts.read } : {},
16660
- ...opts.onSpend ? { onSpend: opts.onSpend } : {}
16661
- });
16662
- if (check.status === "not_checked") return { index: variant.index, blocking: null };
16663
- const prefix = `${format}: `;
16664
- const blocking = check.findings.filter((f) => f.severity === "blocking").map((f) => f.what.startsWith(prefix) ? f.what.slice(prefix.length) : f.what);
16665
- const want = { lines, buttons, ...brand ? { brand } : {}, logo, format, grade };
16666
- return { index: variant.index, blocking, ...rankOf(check.boxes, blocking, want) };
16667
- } catch {
16668
- return { index: variant.index, blocking: null };
16669
- }
16670
- })
16913
+ variants.map(
16914
+ (variant) => checkVariant(variant, { format, size: plan.master.size, lines, brand, grade, want, opts })
16915
+ )
16671
16916
  );
16672
16917
  const outcome = prefilterOutcome(checked);
16673
- return { ...outcome, kept: seededOrder(outcome.kept, variants) };
16918
+ const frames = Object.fromEntries(checked.flatMap((c) => "frame" in c && c.frame ? [[c.index, c.frame]] : []));
16919
+ return {
16920
+ ...outcome,
16921
+ kept: seededOrder(outcome.kept, variants),
16922
+ ...Object.keys(frames).length > 0 ? { frames, masterSize: plan.master.size } : {}
16923
+ };
16924
+ }
16925
+ async function checkVariant(variant, c) {
16926
+ const { format, lines, brand, grade, want, opts } = c;
16927
+ const source = variant.path ?? variant.url;
16928
+ if (!source) return { index: variant.index, blocking: null };
16929
+ try {
16930
+ const bytes = await bytesOf(source);
16931
+ const { width = 0, height = 0 } = await sharp8(bytes).metadata();
16932
+ const framing = framingOf({ width, height }, c.size);
16933
+ const check = await checkStaticImage(bytes, {
16934
+ format,
16935
+ lines,
16936
+ allowed: brand ? [brand] : [],
16937
+ ...grade,
16938
+ toFrame: framing.place,
16939
+ ...opts.read ? { read: opts.read } : {},
16940
+ ...opts.onSpend ? { onSpend: opts.onSpend } : {}
16941
+ });
16942
+ if (check.status === "not_checked") return { index: variant.index, blocking: null };
16943
+ const prefix = `${format}: `;
16944
+ const blocking = check.findings.filter((f) => f.severity === "blocking").map((f) => f.what.startsWith(prefix) ? f.what.slice(prefix.length) : f.what);
16945
+ const frame = framing.moved();
16946
+ return { index: variant.index, blocking, ...rankOf(check.boxes, blocking, want), ...frame ? { frame } : {} };
16947
+ } catch {
16948
+ return { index: variant.index, blocking: null };
16949
+ }
16674
16950
  }
16675
16951
  function seededOrder(indexes, variants) {
16676
16952
  const shaOf = new Map(variants.map((v) => [v.index, v.sha256]));
@@ -16746,7 +17022,7 @@ async function readSpec(ref) {
16746
17022
  }
16747
17023
  var staticPrefilterNode = defineNode({
16748
17024
  id: "static_prefilter",
16749
- version: "1.0.0",
17025
+ version: "1.1.0",
16750
17026
  category: "image",
16751
17027
  location: "local",
16752
17028
  // Reads each variant's pixels itself, from its file or its durable URL, so the variants
@@ -16779,7 +17055,8 @@ var staticPrefilterNode = defineNode({
16779
17055
  images: z26.array(ImageRef).min(1),
16780
17056
  // Which variant (its place among the inputs) each of `images` is, so the judge's "Image k" maps back.
16781
17057
  order: z26.array(z26.number().int().min(0)).optional(),
16782
- // The picture every size is made from: the checks' first, or on a tie the judge's.
17058
+ // The picture every size is made from: the checks' first, or on a tie the judge's. Cut to the
17059
+ // master's frame where every word stays whole when its centre would cut one, else as rendered.
16783
17060
  image: ImageRef.optional(),
16784
17061
  // Which variant it is (its place among the inputs).
16785
17062
  chosen: z26.number().int().min(0).optional(),
@@ -16792,12 +17069,12 @@ var staticPrefilterNode = defineNode({
16792
17069
  }).strict(),
16793
17070
  outputKinds: { images: "image", image: "image" },
16794
17071
  cost: () => ({ credits: 0, seconds_estimate: 1 }),
16795
- async execute({ inputs, params }) {
16796
- const variants = inputs.images.map((image, index) => ({
17072
+ async execute({ inputs, params, ctx }) {
17073
+ const variants = inputs.images.map((image2, index) => ({
16797
17074
  index,
16798
- sha256: image.sha256,
16799
- ...image.path ? { path: image.path } : {},
16800
- ...image.url ? { url: image.url } : {}
17075
+ sha256: image2.sha256,
17076
+ ...image2.path ? { path: image2.path } : {},
17077
+ ...image2.url ? { url: image2.url } : {}
16801
17078
  }));
16802
17079
  const usage = [];
16803
17080
  const result = await prefilterVariants(variants, params.checks ?? await readSpec(inputs.spec), params.formats, {
@@ -16813,10 +17090,11 @@ var staticPrefilterNode = defineNode({
16813
17090
  { model: params.judge_model, prompt: params.judge_prompt, onSpend: (u) => usage.push(u) }
16814
17091
  ) : null;
16815
17092
  const chosen = tie?.chosen ?? shown[0] ?? 0;
17093
+ const image = await framed(inputs.images[chosen], result.frames?.[chosen], result.masterSize, ctx);
16816
17094
  return {
16817
17095
  images: kept.length > 0 ? kept : inputs.images,
16818
17096
  order: shown,
16819
- ...inputs.images[chosen] ? { image: inputs.images[chosen] } : {},
17097
+ ...image ? { image } : {},
16820
17098
  chosen,
16821
17099
  ...tie ? { reasoning: tie.reasoning } : {},
16822
17100
  dropped: result.dropped,
@@ -16825,6 +17103,12 @@ var staticPrefilterNode = defineNode({
16825
17103
  };
16826
17104
  }
16827
17105
  });
17106
+ async function framed(picture, frame, size2, ctx) {
17107
+ const source = picture?.path ?? picture?.url;
17108
+ if (!picture || !frame || !size2 || !source) return picture;
17109
+ const bytes = await cutToFrame(await bytesOf(source), frame, size2);
17110
+ return await ctx.assets.ingestBytes({ bytes, kind: "image", mime: "image/png" });
17111
+ }
16828
17112
 
16829
17113
  // src/engine/nodes/local/staticSafeFit.ts
16830
17114
  import { z as z27 } from "zod";
@@ -18477,6 +18761,9 @@ export {
18477
18761
  conceptSummary,
18478
18762
  conceptProblems,
18479
18763
  unwrittenProblems,
18764
+ sceneWordProblems,
18765
+ SPEC_SHAPES_HINT,
18766
+ adSpecErrorText,
18480
18767
  readAdSpec,
18481
18768
  withRenderedLettering,
18482
18769
  conceptClassOf,
@@ -18503,10 +18790,12 @@ export {
18503
18790
  STATIC_SAFE_FIT_ROLE,
18504
18791
  STATIC_PLACE_ROLE,
18505
18792
  LOGO_COMPOSITE_ROLE,
18793
+ SERIES_LEAD_ROLE,
18506
18794
  STATIC_FIX_ROLE,
18507
18795
  LOGO_COMPOSITE_DEFAULT,
18508
18796
  nodesWithRole,
18509
18797
  rolesById,
18798
+ relaidPictureRole,
18510
18799
  formatSlug,
18511
18800
  staticMasterFormat,
18512
18801
  staticRenderNodes,
@@ -18564,4 +18853,4 @@ export {
18564
18853
  defaultRegistry,
18565
18854
  createEngineFromEnv
18566
18855
  };
18567
- //# sourceMappingURL=chunk-MMTPMFFH.js.map
18856
+ //# sourceMappingURL=chunk-7NKAKF3K.js.map