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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -62543,10 +62543,10 @@ function parseColor(raw) {
62543
62543
  function parseOklch(c) {
62544
62544
  const m = c.match(/oklch\(\s*([\d.]+%?)\s+([\d.]+%?)\s+([\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+%?))?\s*\)/i);
62545
62545
  if (!m) return null;
62546
- const pct = (s, scale) => s.endsWith("%") ? Number.parseFloat(s) / 100 * scale : Number.parseFloat(s);
62546
+ const pct2 = (s, scale) => s.endsWith("%") ? Number.parseFloat(s) / 100 * scale : Number.parseFloat(s);
62547
62547
  const alpha = m[4];
62548
- const a = alpha === void 0 ? 1 : pct(alpha, 1);
62549
- return { ...oklchToRgb(pct(m[1] ?? "0", 1), pct(m[2] ?? "0", 0.4), Number.parseFloat(m[3] ?? "0")), a };
62548
+ const a = alpha === void 0 ? 1 : pct2(alpha, 1);
62549
+ return { ...oklchToRgb(pct2(m[1] ?? "0", 1), pct2(m[2] ?? "0", 0.4), Number.parseFloat(m[3] ?? "0")), a };
62550
62550
  }
62551
62551
  function oklchToRgb(L, C, H) {
62552
62552
  const hRad = H * Math.PI / 180;
@@ -63920,6 +63920,36 @@ async function markInkIsLight(markPath) {
63920
63920
 
63921
63921
  // src/engine/scaffold/static-compose.ts
63922
63922
  import { z as z48 } from "zod";
63923
+
63924
+ // src/engine/scaffold/static-formats.ts
63925
+ var CROP_TOLERANCE = 0.1;
63926
+ function ratioValue(ratio) {
63927
+ const [w, h] = ratio.split(":").map(Number);
63928
+ if (!w || !h || !Number.isFinite(w) || !Number.isFinite(h)) throw new Error(`not a W:H ratio: "${ratio}"`);
63929
+ return w / h;
63930
+ }
63931
+ function ratioDelta(a, b) {
63932
+ return Math.abs(ratioValue(a) / ratioValue(b) - 1);
63933
+ }
63934
+ function round3(n) {
63935
+ return Math.round(n * 1e3) / 1e3;
63936
+ }
63937
+ function planStaticFormats(genModel, formats) {
63938
+ const first = formats[0];
63939
+ if (!first) throw new Error("a static ad needs at least one format");
63940
+ const plateRatio = nearestSupportedAspectRatio("image_generate", genModel, first);
63941
+ return {
63942
+ plateRatio,
63943
+ formats: formats.map((format) => {
63944
+ const delta = ratioDelta(format, plateRatio);
63945
+ if (delta <= CROP_TOLERANCE) return { format, source: "crop", delta: round3(delta) };
63946
+ const extendRatio = nearestSupportedAspectRatio("image_generate", genModel, format);
63947
+ return { format, source: "extend", delta: round3(ratioDelta(format, extendRatio)), extendRatio };
63948
+ })
63949
+ };
63950
+ }
63951
+
63952
+ // src/engine/scaffold/static-compose.ts
63923
63953
  var WinningMechanismSchema = z48.object({ kind: z48.string(), device: z48.string(), why_it_works: z48.string().optional() }).loose();
63924
63954
  function winningMechanisms(blueprint) {
63925
63955
  const raw = blueprint?.winning_mechanisms;
@@ -63954,7 +63984,31 @@ function copyEntries(blueprint) {
63954
63984
  return entries.flatMap((e) => {
63955
63985
  const text2 = typeof e?.text === "string" ? e.text.trim() : "";
63956
63986
  const role = normalize(e?.role);
63957
- return text2 && role !== "logo" ? [{ text: text2, role }] : [];
63987
+ return text2 && role !== "logo" ? [
63988
+ {
63989
+ text: text2,
63990
+ role,
63991
+ treatment: normalize(e?.treatment),
63992
+ surface: normalize(e?.surface),
63993
+ placement: normalize(e?.placement)
63994
+ }
63995
+ ] : [];
63996
+ });
63997
+ }
63998
+ var IN_SCENE_ROLES = /* @__PURE__ */ new Set(["in_scene", "scene", "ui", "screen", "screenshot", "packaging", "signage", "sign"]);
63999
+ var IN_SCENE_PLACEMENT = /\b(phone|screen|screenshot|app|packaging|package|bottle|jar|signage|signboard|handwritten|receipt|whiteboard|sticky note|notepad)\b/;
64000
+ function isInScene(entry) {
64001
+ if (entry.surface === "in_scene") return true;
64002
+ if (entry.surface === "overlay") return false;
64003
+ return IN_SCENE_ROLES.has(entry.role) || IN_SCENE_PLACEMENT.test(entry.placement);
64004
+ }
64005
+ function inSceneText(blueprint) {
64006
+ const raw = blueprint?.text_content;
64007
+ const entries = Array.isArray(raw) ? raw : [];
64008
+ return copyEntries(blueprint).filter(isInScene).map((entry) => {
64009
+ const source = entries.find((e) => typeof e?.text === "string" && e.text.trim() === entry.text);
64010
+ const placement = typeof source?.placement === "string" ? source.placement.trim() : "";
64011
+ return { text: entry.text, placement };
63958
64012
  });
63959
64013
  }
63960
64014
  function pairCopy(blueprint) {
@@ -64022,6 +64076,9 @@ function hexOrNull(value2) {
64022
64076
  function ctaTextOf(blueprint) {
64023
64077
  return normalize(blueprint?.ad_signals?.cta?.text);
64024
64078
  }
64079
+ function conceptHasLogo(blueprint) {
64080
+ return logoPlacement(blueprint) !== null;
64081
+ }
64025
64082
  function logoPlacement(blueprint) {
64026
64083
  const region = regionsOf(blueprint).find(isLogoRegion);
64027
64084
  if (region) return { band: bandOf(region, "top"), align: alignOf(void 0, region) };
@@ -64033,29 +64090,151 @@ function inkOn(background) {
64033
64090
  const luma = hexLuma(background);
64034
64091
  return luma !== null && luma > 150 ? "#111111" : "#ffffff";
64035
64092
  }
64036
- function composeSpec(blueprint, brand) {
64093
+ function treatmentNamed(description) {
64094
+ if (!description) return null;
64095
+ if (/underline/.test(description)) return "underline";
64096
+ if (/\b(button|pill|capsule)\b/.test(description)) return "button";
64097
+ if (/arrow|→|->|chevron|›|»/.test(description)) return "arrow";
64098
+ if (/sticker|starburst|stamp|seal|badge/.test(description)) return "sticker";
64099
+ if (/highlight|marker|label|\btag\b|banner|ribbon|\bbox\b/.test(description)) return "highlight";
64100
+ if (/rounded|rectangle/.test(description)) return "button";
64101
+ if (/plain|\btext\b|\blink\b|\bnone\b/.test(description)) return "plain";
64102
+ return null;
64103
+ }
64104
+ function shapeNamed(description) {
64105
+ if (/pill|capsule/.test(description)) return "pill";
64106
+ if (/round/.test(description)) return "rounded";
64107
+ if (/square|sharp|rectangle/.test(description)) return "square";
64108
+ return "rounded";
64109
+ }
64110
+ function ctaStyleOf(blueprint) {
64111
+ return normalize(blueprint?.ad_signals?.cta?.style);
64112
+ }
64113
+ function treatmentOf(args8) {
64114
+ const { entry, slot, role, brand } = args8;
64115
+ const region = slot?.region;
64116
+ const regionShape = [normalize(region?.shape), normalize(region?.notes)].join(" ").trim();
64117
+ const descriptions = [
64118
+ entry.treatment,
64119
+ normalize(slot?.text.treatment),
64120
+ ...role === "cta" ? [args8.ctaStyle, regionShape] : []
64121
+ ];
64122
+ let kind = "plain";
64123
+ let described = "";
64124
+ for (const description of descriptions) {
64125
+ const named = treatmentNamed(description);
64126
+ if (named) {
64127
+ kind = named;
64128
+ described = `${description} ${regionShape}`;
64129
+ break;
64130
+ }
64131
+ }
64132
+ if (kind === "plain" || kind === "underline" || kind === "arrow") return { kind };
64133
+ const regionFill = hexOrNull(region?.background_color);
64134
+ const background = kind === "button" ? hexOrNull(brand.ctaColor) ?? hexOrNull(brand.palette[0]) ?? regionFill ?? "#111111" : hexOrNull(brand.palette[1]) ?? hexOrNull(brand.palette[0]) ?? regionFill ?? "#111111";
64135
+ return { kind, shape: shapeNamed(described), background, color: inkOn(background) };
64136
+ }
64137
+ function cover(from, to) {
64138
+ return from > to ? { sx: from / to, sy: 1 } : { sx: 1, sy: to / from };
64139
+ }
64140
+ function contain(from, to) {
64141
+ return from > to ? { sx: 1, sy: to / from } : { sx: from / to, sy: 1 };
64142
+ }
64143
+ var scaleSpan = ([a, b], k) => [(a - 0.5) * k + 0.5, (b - 0.5) * k + 0.5];
64144
+ function clampSpan([a, b], lo, hi) {
64145
+ if (b - a >= hi - lo) return [lo, hi];
64146
+ if (a < lo) return [lo, lo + (b - a)];
64147
+ if (b > hi) return [hi - (b - a), hi];
64148
+ return [a, b];
64149
+ }
64150
+ var pct = (n) => Math.round(n * 1e3) / 10;
64151
+ function placeRegion(x, y, geometry) {
64152
+ let xs = [x[0] / 100, x[1] / 100];
64153
+ let ys = [y[0] / 100, y[1] / 100];
64154
+ const plate = geometry.plate;
64155
+ if (plate) {
64156
+ const format = ratioValue(geometry.format);
64157
+ let from = ratioValue(plate.ratio);
64158
+ if (plate.extendRatio) {
64159
+ const extended = ratioValue(plate.extendRatio);
64160
+ const fit = contain(from, extended);
64161
+ xs = scaleSpan(xs, fit.sx);
64162
+ ys = scaleSpan(ys, fit.sy);
64163
+ from = extended;
64164
+ }
64165
+ const crop = cover(from, format);
64166
+ xs = scaleSpan(xs, crop.sx);
64167
+ ys = scaleSpan(ys, crop.sy);
64168
+ }
64169
+ const { safe } = geometry;
64170
+ const [x0, x1] = clampSpan(xs, safe.left, 1 - safe.right);
64171
+ const [y0, y1] = clampSpan(ys, safe.top, 1 - safe.bottom);
64172
+ return { x: [pct(x0), pct(x1)], y: [pct(y0), pct(y1)] };
64173
+ }
64174
+ function regionId(region, index) {
64175
+ const id = typeof region.id === "string" ? region.id.trim().replace(/[^A-Za-z0-9_-]+/g, "-") : "";
64176
+ return `region-${id || index}`;
64177
+ }
64178
+ function distinctFrom(ground, candidates) {
64179
+ return candidates.find((c) => c !== null && c !== ground) ?? inkOn(ground);
64180
+ }
64181
+ function composeSpec(blueprint, brand, options = {}) {
64037
64182
  const ctaText = ctaTextOf(blueprint);
64038
- const copy = pairCopy(blueprint).map(({ entry, slot }) => {
64183
+ const ctaStyle = ctaStyleOf(blueprint);
64184
+ const formats = options.formats ?? [];
64185
+ const regions = regionsOf(blueprint);
64186
+ const groups = /* @__PURE__ */ new Map();
64187
+ const groupFor = (region, band, align) => {
64188
+ const x = span(region?.x_pct);
64189
+ const y = span(region?.y_pct);
64190
+ const id = region && x && y ? regionId(region, regions.indexOf(region)) : `band-${band}`;
64191
+ if (!groups.has(id)) {
64192
+ const at = x && y ? Object.fromEntries(formats.map((g) => [g.format, placeRegion(x, y, g)])) : {};
64193
+ groups.set(id, { id, band, align, at });
64194
+ }
64195
+ return id;
64196
+ };
64197
+ const ground = hexOrNull(brand.palette[0]) ?? "#111111";
64198
+ const overlay = pairCopy(blueprint).filter(({ entry }) => !isInScene(entry));
64199
+ const copy = overlay.map(({ entry, slot }) => {
64039
64200
  const role = roleOf(entry, ctaText);
64040
64201
  const region = slot?.region;
64202
+ const band = bandOf(region, DEFAULT_BAND[role]);
64203
+ const align = alignOf(slot?.text.align, region);
64204
+ let treatment = treatmentOf({ entry, slot, role, ctaStyle, brand });
64205
+ if (options.textOnly && "background" in treatment && treatment.background === ground) {
64206
+ const background = distinctFrom(ground, [hexOrNull(brand.ctaColor), ...brand.palette.map(hexOrNull)]);
64207
+ treatment = { ...treatment, background, color: inkOn(background) };
64208
+ }
64041
64209
  return {
64042
64210
  role,
64043
64211
  // The source's line breaks fall elsewhere in our font: the composition wraps instead.
64044
64212
  text: entry.text.replace(/\s*\n\s*/g, " "),
64045
- band: bandOf(region, DEFAULT_BAND[role]),
64046
- align: alignOf(slot?.text.align, region),
64213
+ band,
64214
+ align,
64047
64215
  size: pick2(slot?.text.size, ["x_large", "large", "medium", "small"]) ?? DEFAULT_SIZE[role],
64048
64216
  weight: pick2(slot?.text.weight, ["bold", "semibold", "regular"]) ?? (role === "headline" ? "bold" : "regular"),
64049
64217
  case: pick2(slot?.text.case, ["upper", "title", "sentence"]),
64050
- color: hexOrNull(slot?.text.color)
64218
+ // The source's ink was chosen for the source's picture; on our brand ground it is ours.
64219
+ color: options.textOnly ? null : hexOrNull(slot?.text.color),
64220
+ treatment,
64221
+ group: groupFor(region, band, align)
64051
64222
  };
64052
64223
  });
64224
+ const placement = options.logo ? logoPlacement(blueprint) : null;
64225
+ const logoRegion = regions.find(isLogoRegion);
64226
+ const logo = placement ? {
64227
+ ...placement,
64228
+ group: logoRegion && span(logoRegion.x_pct) && span(logoRegion.y_pct) ? groupFor(logoRegion, placement.band, placement.align) : null
64229
+ } : null;
64053
64230
  const ctaBackground = hexOrNull(brand.ctaColor) ?? hexOrNull(brand.palette[0]) ?? "#111111";
64054
64231
  return {
64055
64232
  copy,
64056
- logo: logoPlacement(blueprint) ?? { band: "bottom", align: "center" },
64233
+ logo,
64234
+ groups: [...groups.values()],
64057
64235
  palette: brand.palette.flatMap((h) => hexOrNull(h) ?? []),
64058
- cta: { background: ctaBackground, color: inkOn(ctaBackground) }
64236
+ cta: { background: ctaBackground, color: inkOn(ctaBackground) },
64237
+ background: { color: ground, ink: inkOn(ground) }
64059
64238
  };
64060
64239
  }
64061
64240
  var SCENE_KEYS = [
@@ -64090,15 +64269,16 @@ function scrub(value2, names) {
64090
64269
  return value2;
64091
64270
  }
64092
64271
  var BAND_SPAN = { top: [4, 32], middle: [40, 60], bottom: [72, 96] };
64093
- function reservedAreas(blueprint) {
64272
+ function reservedAreas(blueprint, logoDrawn2) {
64094
64273
  const areas = [];
64095
64274
  const seen = /* @__PURE__ */ new Set();
64096
- const logo = regionsOf(blueprint).find(isLogoRegion);
64275
+ const logo = logoDrawn2 ? regionsOf(blueprint).find(isLogoRegion) : void 0;
64097
64276
  const logoX = span(logo?.x_pct);
64098
64277
  const logoY = span(logo?.y_pct);
64099
64278
  if (logo && logoX && logoY) areas.push({ for: "logo", x_pct: logoX, y_pct: logoY });
64100
64279
  const ctaText = ctaTextOf(blueprint);
64101
64280
  for (const { entry, slot } of pairCopy(blueprint)) {
64281
+ if (isInScene(entry)) continue;
64102
64282
  const role = roleOf(entry, ctaText);
64103
64283
  const region = slot?.region;
64104
64284
  if (region && seen.has(region)) continue;
@@ -64113,19 +64293,25 @@ function reservedAreas(blueprint) {
64113
64293
  }
64114
64294
  return areas;
64115
64295
  }
64116
- function platePlan(blueprint) {
64296
+ function platePlan(blueprint, options = {}) {
64117
64297
  const names = brandNames(blueprint);
64118
64298
  const source = blueprint ?? {};
64119
64299
  const scene = Object.fromEntries(SCENE_KEYS.flatMap((k) => k in source ? [[k, scrub(source[k], names)]] : []));
64120
64300
  const mechanisms = winningMechanisms(blueprint).filter((m) => normalize(m.kind) !== "verbal").map((m) => scrub(m, names));
64121
- return { scene, reserved_for_overlay: reservedAreas(blueprint), mechanisms_to_keep: mechanisms };
64301
+ const inScene = inSceneText(blueprint);
64302
+ return {
64303
+ scene,
64304
+ reserved_for_overlay: reservedAreas(blueprint, options.logo === true),
64305
+ mechanisms_to_keep: mechanisms,
64306
+ ...inScene.length > 0 ? { in_scene_text: inScene } : {}
64307
+ };
64122
64308
  }
64123
- function renderPlateSpec(blueprint) {
64124
- return `${JSON.stringify(platePlan(blueprint), null, 2)}
64309
+ function renderPlateSpec(blueprint, options = {}) {
64310
+ return `${JSON.stringify(platePlan(blueprint, options), null, 2)}
64125
64311
  `;
64126
64312
  }
64127
- function renderComposeSpec(blueprint, brand) {
64128
- return `${JSON.stringify(composeSpec(blueprint, brand), null, 2).replace(/</g, "\\u003c")}
64313
+ function renderComposeSpec(blueprint, brand, options = {}) {
64314
+ return `${JSON.stringify(composeSpec(blueprint, brand, options), null, 2).replace(/</g, "\\u003c")}
64129
64315
  `;
64130
64316
  }
64131
64317
 
@@ -64282,6 +64468,66 @@ async function findWorkspaceRoot(start) {
64282
64468
  dir = parent;
64283
64469
  }
64284
64470
  }
64471
+ var snapshotsOf = (nodes) => nodes.filter((n) => n.type === "hyperframe_snapshot");
64472
+ var refId = (ref) => typeof ref === "string" ? ref.match(/^\$ref:([a-z][a-z0-9_]*)\./)?.[1] ?? null : null;
64473
+ async function isRealFile(file) {
64474
+ if (typeof file !== "string" || file.includes("[TODO")) return false;
64475
+ return stat(file).then(
64476
+ (s) => s.isFile(),
64477
+ () => false
64478
+ );
64479
+ }
64480
+ async function releaseUnsuppliedLogo(canvas) {
64481
+ const holder = canvas;
64482
+ if (!Array.isArray(holder.nodes)) return false;
64483
+ const nodes = holder.nodes;
64484
+ const logoIds = new Set(snapshotsOf(nodes).flatMap((n) => refId(n.inputs?.logo) ?? []));
64485
+ const empty = /* @__PURE__ */ new Set();
64486
+ for (const id of logoIds) {
64487
+ const node = nodes.find((n) => n.id === id && n.type === "ingest");
64488
+ const p = node?.params;
64489
+ if (p?.source === "path" && !await isRealFile(p.path)) empty.add(id);
64490
+ }
64491
+ if (empty.size === 0) return false;
64492
+ for (const node of snapshotsOf(nodes)) {
64493
+ const id = refId(node.inputs?.logo);
64494
+ if (id && empty.has(id) && node.inputs) delete node.inputs.logo;
64495
+ }
64496
+ holder.nodes = nodes.filter((n) => !(typeof n.id === "string" && empty.has(n.id)));
64497
+ return true;
64498
+ }
64499
+ function staticShape(nodes) {
64500
+ const snapshots = snapshotsOf(nodes);
64501
+ const formats = snapshots.flatMap((n) => {
64502
+ const frame = n.params?.frame ?? {};
64503
+ if (typeof frame.format !== "string") return [];
64504
+ const safe = frame.safe;
64505
+ if (!safe || typeof safe.top !== "number") return [];
64506
+ const plate = frame.plate;
64507
+ if (plate === void 0) return [];
64508
+ return [
64509
+ {
64510
+ format: frame.format,
64511
+ safe,
64512
+ ...plate && typeof plate.ratio === "string" ? {
64513
+ plate: {
64514
+ ratio: plate.ratio,
64515
+ ...typeof plate.extend_ratio === "string" ? { extendRatio: plate.extend_ratio } : {}
64516
+ }
64517
+ } : {}
64518
+ }
64519
+ ];
64520
+ });
64521
+ return {
64522
+ logoWired: snapshots.some((n) => refId(n.inputs?.logo) !== null),
64523
+ textOnly: snapshots.length > 0 && snapshots.every((n) => n.params?.frame?.plate === null),
64524
+ formats
64525
+ };
64526
+ }
64527
+ function composeOptionsFor(canvas, blueprint) {
64528
+ const shape = staticShape(nodesOf(canvas));
64529
+ return { logo: shape.logoWired && conceptHasLogo(blueprint), formats: shape.formats, textOnly: shape.textOnly };
64530
+ }
64285
64531
  async function syncStaticAdSpecs(canvas, log) {
64286
64532
  const nodes = nodesOf(canvas);
64287
64533
  const platePath = ingestPath(nodes, "plate_spec");
@@ -64291,6 +64537,10 @@ async function syncStaticAdSpecs(canvas, log) {
64291
64537
  const blueprint = JSON.parse(await readFile5(source, "utf8"));
64292
64538
  const root = await findWorkspaceRoot(path6.dirname(source));
64293
64539
  const brand = root ? await readStaticBrandKit(root) : { palette: [] };
64540
+ if (await releaseUnsuppliedLogo(canvas)) {
64541
+ log("[logo] the logo slot holds no real file, so the ad is drawn without a logo (Meta shows the page name and picture)");
64542
+ }
64543
+ const { logo, formats, textOnly } = composeOptionsFor(canvas, blueprint);
64294
64544
  let changed = 0;
64295
64545
  const write = async (file, content) => {
64296
64546
  if (!file) return;
@@ -64299,8 +64549,8 @@ async function syncStaticAdSpecs(canvas, log) {
64299
64549
  await writeFile2(file, content, "utf8");
64300
64550
  changed++;
64301
64551
  };
64302
- await write(platePath, renderPlateSpec(blueprint));
64303
- await write(composePath, renderComposeSpec(blueprint, brand));
64552
+ await write(platePath, renderPlateSpec(blueprint, { logo }));
64553
+ await write(composePath, renderComposeSpec(blueprint, brand, { logo, formats, textOnly }));
64304
64554
  if (changed === 0) return "up_to_date";
64305
64555
  log("[copy] prompt.plate.json and prompt.compose.json were re-derived from prompt.json");
64306
64556
  return "regenerated";
@@ -65075,12 +65325,12 @@ function spineClockFor(blueprint) {
65075
65325
  spineStarts.push(acc);
65076
65326
  acc += d;
65077
65327
  }
65078
- const totalS = round3(acc);
65079
- const startOf = (scene) => round3(spineStarts[scene] ?? totalS);
65328
+ const totalS = round32(acc);
65329
+ const startOf = (scene) => round32(spineStarts[scene] ?? totalS);
65080
65330
  const map = (scene, absS) => {
65081
65331
  const i = Math.min(Math.max(scene, 0), durs.length - 1);
65082
65332
  const delta = Math.min(Math.max(absS - refStarts[i], 0), durs[i]);
65083
- return round3(spineStarts[i] + delta);
65333
+ return round32(spineStarts[i] + delta);
65084
65334
  };
65085
65335
  const mapAbs = (absS) => {
65086
65336
  let i = 0;
@@ -65089,7 +65339,7 @@ function spineClockFor(blueprint) {
65089
65339
  };
65090
65340
  return { startOf, map, mapAbs, totalS };
65091
65341
  }
65092
- function round3(n) {
65342
+ function round32(n) {
65093
65343
  return Math.round(n * 1e3) / 1e3;
65094
65344
  }
65095
65345
  function canvasDims(ar) {
@@ -65796,9 +66046,9 @@ function ingestFrameRef(url, edge, ctx, nodes) {
65796
66046
  const cached = ctx.ingestCache?.get(url);
65797
66047
  if (cached) return cached;
65798
66048
  const tag = ctx.tag ?? "";
65799
- const refId = `s${ctx.sceneIndex}${tag}_${edge}_ref`;
65800
- nodes.push({ id: refId, type: "ingest", params: { source: "url", url, expect: "image" } });
65801
- const ref = `$ref:${refId}.asset`;
66049
+ const refId2 = `s${ctx.sceneIndex}${tag}_${edge}_ref`;
66050
+ nodes.push({ id: refId2, type: "ingest", params: { source: "url", url, expect: "image" } });
66051
+ const ref = `$ref:${refId2}.asset`;
65802
66052
  ctx.ingestCache?.set(url, ref);
65803
66053
  return ref;
65804
66054
  }
@@ -66414,12 +66664,12 @@ function emitFlashHold(i, scene, slots, ctx, lengths, out, nodes, clips) {
66414
66664
  function emitScreenScene(i, scene, lengths, out, surfaceIngests, nodes, clips) {
66415
66665
  const regions = (scene.composition?.regions ?? []).filter((r) => Boolean(r) && typeof r === "object");
66416
66666
  const surfaceId = regions.find((r) => r.surface_id)?.surface_id;
66417
- let refId = surfaceId ? surfaceIngests.get(surfaceId) : void 0;
66418
- if (!refId) {
66667
+ let refId2 = surfaceId ? surfaceIngests.get(surfaceId) : void 0;
66668
+ if (!refId2) {
66419
66669
  const label = commentSafe((scene.summary || scene.start_frame_prompt || "the app screen").slice(0, 120));
66420
- refId = `s${i}_screen_ref`;
66670
+ refId2 = `s${i}_screen_ref`;
66421
66671
  nodes.push({
66422
- id: refId,
66672
+ id: refId2,
66423
66673
  type: "ingest",
66424
66674
  params: {
66425
66675
  source: "path",
@@ -66427,9 +66677,9 @@ function emitScreenScene(i, scene, lengths, out, surfaceIngests, nodes, clips) {
66427
66677
  expect: "image"
66428
66678
  }
66429
66679
  });
66430
- if (surfaceId) surfaceIngests.set(surfaceId, refId);
66680
+ if (surfaceId) surfaceIngests.set(surfaceId, refId2);
66431
66681
  }
66432
- clips.push({ ref: `$ref:${refId}.asset`, scene_s: lengths.dur, out, still: { fit: "pad" } });
66682
+ clips.push({ ref: `$ref:${refId2}.asset`, scene_s: lengths.dur, out, still: { fit: "pad" } });
66433
66683
  }
66434
66684
  function sceneIsAllGraphic(scene) {
66435
66685
  const regions = (scene.composition?.regions ?? []).filter((r) => Boolean(r) && typeof r === "object");
@@ -66466,12 +66716,12 @@ function fullFrameGraphicScenes(blueprint) {
66466
66716
  function emitGraphicScene(i, scene, lengths, out, surfaceIngests, nodes, clips) {
66467
66717
  const regions = (scene.composition?.regions ?? []).filter((r) => Boolean(r) && typeof r === "object");
66468
66718
  const surfaceId = regions.find((r) => r.surface_id)?.surface_id;
66469
- let refId = surfaceId ? surfaceIngests.get(surfaceId) : void 0;
66470
- if (!refId) {
66719
+ let refId2 = surfaceId ? surfaceIngests.get(surfaceId) : void 0;
66720
+ if (!refId2) {
66471
66721
  const label = commentSafe((scene.summary || scene.start_frame_prompt || "the designed panel").slice(0, 120));
66472
- refId = `s${i}_graphic_ref`;
66722
+ refId2 = `s${i}_graphic_ref`;
66473
66723
  nodes.push({
66474
- id: refId,
66724
+ id: refId2,
66475
66725
  type: "ingest",
66476
66726
  params: {
66477
66727
  source: "path",
@@ -66479,9 +66729,9 @@ function emitGraphicScene(i, scene, lengths, out, surfaceIngests, nodes, clips)
66479
66729
  expect: "image"
66480
66730
  }
66481
66731
  });
66482
- if (surfaceId) surfaceIngests.set(surfaceId, refId);
66732
+ if (surfaceId) surfaceIngests.set(surfaceId, refId2);
66483
66733
  }
66484
- clips.push({ ref: `$ref:${refId}.asset`, scene_s: lengths.dur, out, still: { fit: "pad" } });
66734
+ clips.push({ ref: `$ref:${refId2}.asset`, scene_s: lengths.dur, out, still: { fit: "pad" } });
66485
66735
  }
66486
66736
  var BRAND_CARD_RE = /\b(?:solid|plain|flat|brand|logo|wordmark|end[- ]?card|cta card|title card|colou?r background|background colou?r)\b/i;
66487
66737
  function sceneIsBrandCard(scene, present2, isCta) {
@@ -69624,23 +69874,36 @@ function staticVerdict(findings) {
69624
69874
  return findings.length > 0 ? "ready_with_notes" : "ready";
69625
69875
  }
69626
69876
  function buildStaticReviewPrompt(opts) {
69627
- const copy = opts.copy.length > 0 ? opts.copy.map((c) => `- "${c}"`).join("\n") : "(no copy was specified)";
69628
69877
  const brand = opts.brand ? ` for ${opts.brand}` : "";
69878
+ const order = opts.formats.join(", ");
69879
+ const shown = opts.thumbnails ? `These are the finished versions of ONE static advertisement${brand}, in this order: ${order}. After them come the same versions again, in the same order, shrunk to phone-feed thumbnail size (about 240px wide).` : `These are the finished versions of ONE static advertisement${brand}, in this order: ${order}.`;
69880
+ const text2 = opts.copy.length > 0 ? [
69881
+ "Read every word in every full-size image. The words must be exactly these strings, each shown once, spelled exactly:",
69882
+ opts.copy.map((c) => `- "${c}"`).join("\n"),
69883
+ "1. TEXT: a listed string misspelled, duplicated, garbled or absent, or extra words that are not in the list (a brand name inside a drawn logo is fine)."
69884
+ ] : [
69885
+ "This ad has no words on purpose: the picture is the ad. Do not report missing copy, a missing headline or a missing call to action.",
69886
+ "1. TEXT: any letters or words that appear anyway (invented or garbled text is blocking)."
69887
+ ];
69888
+ const logo = opts.logo ? "2. LOGO: the brand logo distorted, cut off, or drawn more than once." : "2. LOGO: this ad carries no logo on purpose (the platform shows the brand's name and picture); never report a missing logo. A made-up logo or wordmark in the picture is blocking.";
69889
+ const mechanisms = opts.mechanisms && opts.mechanisms.length > 0 ? [`8. CONCEPT: the ad no longer carries these devices it is built on (warning): ${opts.mechanisms.join("; ")}.`] : [];
69629
69890
  return [
69630
- `These are the finished versions of ONE static advertisement${brand}, in this order: ${opts.formats.join(", ")}.`,
69631
- "Read every word in every image. The copy must be exactly these strings, each shown once, spelled exactly:",
69632
- copy,
69891
+ shown,
69892
+ ...text2,
69633
69893
  "",
69634
- "Report, per image, only what is actually wrong:",
69635
- "1. TEXT: a copy string missing, misspelled, duplicated, garbled, or any extra words that are not in the list (a brand name inside the logo is fine).",
69636
- "2. LOGO: the brand logo missing, distorted, cut off, or drawn more than once.",
69637
- "3. LAYOUT: text or logo clipped by the frame edge, overlapping each other, or unreadable against the picture.",
69894
+ "Report, per format, only what is actually wrong:",
69895
+ logo,
69896
+ "3. LAYOUT: anything the ad shows clipped by the frame edge, overlapping, or unreadable against what is behind it.",
69638
69897
  ...opts.drawnInSafeArea ? [] : [
69639
- "4. STORIES: on a 9:16 image, copy, logo or the call to action clearly inside the top 14% or the bottom 20% of the frame (measure it; an element that starts below the 14% line is fine)."
69898
+ "4. STORIES: on a 9:16 image, text, a logo or a button that exists clearly inside the top 14% or the bottom 20% of the frame (measure it; an element that starts below the 14% line is fine)."
69640
69899
  ],
69641
69900
  "5. PICTURE: AI artifacts on people, animals or products (hands, faces, mouths, warped objects), visible seams or smears.",
69642
- "Blocking = it cannot ship as is (wrong or missing words, a broken logo, clipped copy, a malformed face or product). Warning = it can ship but should improve.",
69643
- "An empty list is the right answer for a clean ad. Keep each item under twenty words.",
69901
+ "6. ONE IDEA: two or more elements compete equally for the eye, so no single promise leads (warning).",
69902
+ opts.thumbnails ? "7. THUMBNAIL: at thumbnail size the dominant message (the lead line, or the hero when there are no words) cannot be read or understood in under two seconds. That is blocking: most people only ever see the ad that small." : "7. THUMBNAIL: the lead line would be unreadable on a phone feed. That is blocking.",
69903
+ "BRAND: with the logo covered, nothing (colours, type, subject, format) says whose ad this is (warning).",
69904
+ ...mechanisms,
69905
+ "Blocking = it cannot ship as is (wrong or garbled words, a broken logo, clipped content, a malformed face or product, unreadable at thumbnail size). Warning = it can ship but should improve.",
69906
+ "Never report an element as missing because ads usually have one: judge only what this concept holds. An empty list is the right answer for a clean ad. Keep each item under twenty words.",
69644
69907
  'Reply with only this JSON: {"findings":[{"severity":"blocking|warning","format":"4:5","what":"..."}]}'
69645
69908
  ].join("\n");
69646
69909
  }
@@ -69650,33 +69913,42 @@ async function bytesOf(source) {
69650
69913
  if (!res.ok) throw new Error(`fetch ${res.status}`);
69651
69914
  return Buffer.from(await res.arrayBuffer());
69652
69915
  }
69653
- async function jpegFor(bytes) {
69654
- const jpeg = await sharp2(bytes).resize(1024, 1024, { fit: "inside", withoutEnlargement: true }).jpeg({ quality: 85 }).toBuffer();
69916
+ var THUMBNAIL_WIDTH = 240;
69917
+ async function jpegFor(bytes, width) {
69918
+ const resized = width ? sharp2(bytes).resize({ width, withoutEnlargement: true }) : sharp2(bytes).resize(1024, 1024, { fit: "inside", withoutEnlargement: true });
69919
+ const jpeg = await resized.jpeg({ quality: 85 }).toBuffer();
69655
69920
  return jpeg.toString("base64");
69656
69921
  }
69657
69922
  async function reviewRenderedStatic(opts) {
69658
69923
  const measured = [];
69659
69924
  const stills = [];
69925
+ const thumbs = [];
69660
69926
  for (const image of opts.images) {
69661
69927
  try {
69662
69928
  const bytes = await bytesOf(image.path);
69663
69929
  const meta = await sharp2(bytes).metadata();
69930
+ const still = await jpegFor(bytes);
69931
+ const thumb = await jpegFor(bytes, THUMBNAIL_WIDTH);
69664
69932
  measured.push({ format: image.format, width: meta.width ?? 0, height: meta.height ?? 0 });
69665
- stills.push(await jpegFor(bytes));
69933
+ stills.push(still);
69934
+ thumbs.push(thumb);
69666
69935
  } catch {
69667
69936
  }
69668
69937
  }
69669
69938
  if (measured.length === 0) return null;
69670
69939
  const shape = ratioFindings(measured);
69671
69940
  const answer = await askAboutStills(
69672
- stills,
69941
+ [...stills, ...thumbs],
69673
69942
  buildStaticReviewPrompt({
69674
69943
  copy: opts.copy,
69675
69944
  // `measured` grows in lockstep with `stills`, so an unreadable image drops out of
69676
69945
  // both and the model is never told a format it was not shown.
69677
69946
  formats: measured.map((m) => m.format),
69678
69947
  brand: opts.brand,
69679
- drawnInSafeArea: opts.drawnInSafeArea
69948
+ drawnInSafeArea: opts.drawnInSafeArea,
69949
+ logo: opts.logo,
69950
+ thumbnails: true,
69951
+ mechanisms: opts.mechanisms
69680
69952
  }),
69681
69953
  opts.onSpend
69682
69954
  );
@@ -69711,13 +69983,42 @@ function staticReviewTargets(parsed, output) {
69711
69983
  })
69712
69984
  };
69713
69985
  }
69714
- function drawsCopy(parsed) {
69986
+ function nodesOf2(parsed) {
69715
69987
  const nodes = parsed?.nodes;
69716
- if (!Array.isArray(nodes)) return false;
69717
- return nodes.some((n) => {
69718
- const node = n;
69719
- return node.type === "hyperframe_snapshot" && node.params?.frame?.draw_copy === true;
69720
- });
69988
+ return Array.isArray(nodes) ? nodes : [];
69989
+ }
69990
+ function staticRunShape(parsed) {
69991
+ const nodes = nodesOf2(parsed);
69992
+ const snapshots = nodes.filter((n) => n.type === "hyperframe_snapshot");
69993
+ const pictures = nodes.filter(
69994
+ (n) => n.type === "image_generate" && typeof n.id === "string" && /^plate(_[a-z])?$/.test(n.id)
69995
+ ).length;
69996
+ return {
69997
+ pictures,
69998
+ judged: nodes.some((n) => n.type === "image_select" && n.id === "pick"),
69999
+ text: snapshots.some((n) => n.params?.frame?.draw_copy === true) ? "drawn" : "baked",
70000
+ logo: snapshots.some((n) => typeof n.inputs?.logo === "string")
70001
+ };
70002
+ }
70003
+ function drawsCopy(parsed) {
70004
+ return nodesOf2(parsed).some((n) => n.type === "hyperframe_snapshot" && n.params?.frame?.draw_copy === true);
70005
+ }
70006
+ function shapeHint(shape, copy) {
70007
+ const pictures = shape.pictures === 0 ? "no picture (a text-only ad)" : `${shape.pictures} picture${shape.pictures === 1 ? "" : "s"}${shape.judged ? ", judged" : ", no judge"}`;
70008
+ const text2 = copy === 0 ? "no copy (the picture is the ad)" : `copy ${shape.text === "drawn" ? "drawn in real fonts" : "baked into the picture"}`;
70009
+ return `This run: ${pictures}; ${text2}; ${shape.logo ? "the brand logo drawn" : "no logo"}.`;
70010
+ }
70011
+ async function drawsLogo(parsed) {
70012
+ const compose = nodesOf2(parsed).find((n) => n.id === "compose_spec" && n.type === "ingest");
70013
+ const file = compose?.params?.path;
70014
+ if (typeof file === "string") {
70015
+ try {
70016
+ const spec = JSON.parse(await readFile17(file, "utf8"));
70017
+ return spec.logo !== null && spec.logo !== void 0;
70018
+ } catch {
70019
+ }
70020
+ }
70021
+ return staticRunShape(parsed).logo;
69721
70022
  }
69722
70023
  async function textOf(ref) {
69723
70024
  const r = ref;
@@ -69738,15 +70039,21 @@ async function reviewStaticRun(opts) {
69738
70039
  const reasoning = await textOf(opts.outputsByNode.pick?.reasoning);
69739
70040
  const judge = reasoning ? parseJudgeScores(reasoning) : null;
69740
70041
  let copy = [];
70042
+ let mechanisms = [];
69741
70043
  try {
69742
- copy = copyStrings(JSON.parse(await readFile17(targets.promptPath, "utf8")));
70044
+ const blueprint = JSON.parse(await readFile17(targets.promptPath, "utf8"));
70045
+ copy = copyStrings(blueprint);
70046
+ mechanisms = winningMechanisms(blueprint).map((m) => m.device);
69743
70047
  } catch {
69744
70048
  copy = [];
69745
70049
  }
70050
+ const shape = staticRunShape(opts.parsed);
69746
70051
  const review = await reviewRenderedStatic({
69747
70052
  images: targets.images,
69748
70053
  copy,
69749
70054
  drawnInSafeArea: drawsCopy(opts.parsed),
70055
+ logo: await drawsLogo(opts.parsed),
70056
+ mechanisms,
69750
70057
  onSpend: opts.onSpend
69751
70058
  });
69752
70059
  const verdict = staticVerdict(review);
@@ -69761,9 +70068,15 @@ async function reviewStaticRun(opts) {
69761
70068
  hints2.push("Reviewed every format of the finished ad: nothing to fix.");
69762
70069
  }
69763
70070
  for (const f of review ?? []) hints2.push(`${f.severity === "blocking" ? "Must fix" : "Should fix"}: ${f.what}`);
70071
+ hints2.push(shapeHint(shape, copy.length));
69764
70072
  if (judge && judge.length > 0) {
69765
70073
  const best = [...judge].sort((a, b) => b.total - a.total)[0];
69766
- if (best) hints2.push(`The judge picked picture ${best.image} of ${judge.length} (${best.total}/30); the others stay in this run as alternates.`);
70074
+ if (best) {
70075
+ const max = Object.keys(best.scores).length * 5;
70076
+ hints2.push(
70077
+ `The judge picked picture ${best.image} of ${judge.length} (${best.total}/${max}); the others stay in this run as alternates.`
70078
+ );
70079
+ }
69767
70080
  }
69768
70081
  return { review, verdict, judge, hints: hints2 };
69769
70082
  }
@@ -70366,38 +70679,126 @@ import { access, mkdir as mkdir6, readFile as readFile20, writeFile as writeFile
70366
70679
  import path26 from "path";
70367
70680
  import { defineCommand as defineCommand134 } from "citty";
70368
70681
 
70369
- // src/engine/scaffold/staticAd.ts
70370
- import { z as z52 } from "zod";
70371
-
70372
- // src/engine/scaffold/static-formats.ts
70373
- var CROP_TOLERANCE = 0.1;
70374
- function ratioValue(ratio) {
70375
- const [w, h] = ratio.split(":").map(Number);
70376
- if (!w || !h || !Number.isFinite(w) || !Number.isFinite(h)) throw new Error(`not a W:H ratio: "${ratio}"`);
70377
- return w / h;
70378
- }
70379
- function ratioDelta(a, b) {
70380
- return Math.abs(ratioValue(a) / ratioValue(b) - 1);
70682
+ // src/engine/scaffold/static-fonts.ts
70683
+ import { marked } from "marked";
70684
+ var DEFAULT_FONTS = { headline: { family: "Inter", weight: 800 }, body: { family: "Inter", weight: 400 } };
70685
+ var HEADLINE_WORDS = /\b(headings?|headlines?|display|titles?|h[1-3])\b/i;
70686
+ var BODY_WORDS = /\b(body|paragraphs?|text|copy)\b/i;
70687
+ var PROHIBITION = /\b(never|don'?t|do not|avoid|instead of|ban(?:ned)?|no)\b/i;
70688
+ var NOT_FAMILIES = /* @__PURE__ */ new Set([
70689
+ "system",
70690
+ "system stack",
70691
+ "sans",
70692
+ "font",
70693
+ "fonts",
70694
+ "typography",
70695
+ "typeface",
70696
+ "weight",
70697
+ "fallback"
70698
+ ]);
70699
+ function isFamily(candidate) {
70700
+ const name = candidate.trim();
70701
+ const lower = name.toLowerCase();
70702
+ if (name.length < 2 || !/^[A-Z0-9]/.test(name)) return false;
70703
+ if (GENERIC_FAMILIES.has(lower) || NOT_FAMILIES.has(lower) || isNotAFamily(lower)) return false;
70704
+ if (HEADLINE_WORDS.test(name) || BODY_WORDS.test(name)) return false;
70705
+ return !/^#?[0-9a-f]{6}$/i.test(name) && !name.includes(":");
70706
+ }
70707
+ function familiesOn(line) {
70708
+ const found = [];
70709
+ for (const m of line.matchAll(/(\*\*|__|`|"|“|')([^*_`"”']{2,40}?)\1|“([^”]{2,40})”/g)) {
70710
+ found.push((m[2] ?? m[3] ?? "").trim());
70711
+ }
70712
+ const labelled = line.match(
70713
+ /\b(?:headings?|headlines?|display|titles?|body|text|font|typeface|family)\s*(?:font|face|typeface)?\s*[:=–-]\s*\**`?([A-Z][A-Za-z0-9]*(?: [A-Z][A-Za-z0-9]*){0,2})/
70714
+ );
70715
+ if (labelled?.[1]) found.push(labelled[1]);
70716
+ return found.map((f) => f.replace(/\s+\d{3}$/, "").trim()).filter(isFamily);
70381
70717
  }
70382
- function round32(n) {
70383
- return Math.round(n * 1e3) / 1e3;
70718
+ function typographySection(brandMd) {
70719
+ const tokens = marked.lexer(brandMd);
70720
+ const out = [];
70721
+ let depth = null;
70722
+ for (const token of tokens) {
70723
+ if (token.type === "heading") {
70724
+ if (depth !== null && token.depth <= depth) break;
70725
+ if (depth === null && /typograph|fonts?\b|typefaces?/i.test(token.text)) {
70726
+ depth = token.depth;
70727
+ continue;
70728
+ }
70729
+ }
70730
+ if (depth !== null) out.push(token.raw);
70731
+ }
70732
+ return out.join("\n");
70384
70733
  }
70385
- function planStaticFormats(genModel, formats) {
70386
- const first = formats[0];
70387
- if (!first) throw new Error("a static ad needs at least one format");
70388
- const plateRatio = nearestSupportedAspectRatio("image_generate", genModel, first);
70734
+ function claimsIn(brandMd) {
70735
+ return typographySection(brandMd).split("\n").filter((line) => !PROHIBITION.test(line)).map((line) => ({ families: familiesOn(line), headline: HEADLINE_WORDS.test(line), body: BODY_WORDS.test(line) })).filter((claim) => claim.families.length > 0);
70736
+ }
70737
+ function fromBrandMd(brandMd) {
70738
+ const claims = claimsIn(brandMd);
70739
+ const named = (family) => family ? { family, origin: "brand_md" } : void 0;
70740
+ const loose = claims.filter((c) => !c.headline && !c.body).map((c) => c.families[0]);
70741
+ const headlineClaim = claims.find((c) => c.headline);
70742
+ const bodyClaim = claims.find((c) => c.body);
70743
+ const bodyFamily = bodyClaim?.headline ? bodyClaim.families[1] ?? bodyClaim.families[0] : bodyClaim?.families[0];
70744
+ const headline = named(headlineClaim?.families[0] ?? loose[0]);
70745
+ const body = named(bodyFamily ?? loose[1] ?? loose[0]);
70746
+ return { ...headline ? { headline } : {}, ...body ? { body } : {} };
70747
+ }
70748
+ function fromTokens2(tokensJson) {
70749
+ let json;
70750
+ try {
70751
+ json = JSON.parse(tokensJson);
70752
+ } catch {
70753
+ return {};
70754
+ }
70755
+ const parsed = brandTokensSchema.safeParse(json);
70756
+ if (!parsed.success) return {};
70757
+ const heading = parsed.data.fonts.find((f) => f.role === "heading")?.family;
70758
+ const body = parsed.data.fonts.find((f) => f.role === "body")?.family;
70389
70759
  return {
70390
- plateRatio,
70391
- formats: formats.map((format) => {
70392
- const delta = ratioDelta(format, plateRatio);
70393
- if (delta <= CROP_TOLERANCE) return { format, source: "crop", delta: round32(delta) };
70394
- const extendRatio = nearestSupportedAspectRatio("image_generate", genModel, format);
70395
- return { format, source: "extend", delta: round32(ratioDelta(format, extendRatio)), extendRatio };
70396
- })
70760
+ ...heading ? { headline: { family: heading, origin: "tokens" } } : {},
70761
+ ...body ? { body: { family: body, origin: "tokens" } } : {}
70397
70762
  };
70398
70763
  }
70764
+ function namedTypefaces(sources) {
70765
+ const tokens = sources.tokensJson ? fromTokens2(sources.tokensJson) : {};
70766
+ const doc = sources.brandMd ? fromBrandMd(sources.brandMd) : {};
70767
+ const headline = tokens.headline ?? doc.headline;
70768
+ const body = tokens.body ?? doc.body;
70769
+ return { ...headline ? { headline } : {}, ...body ? { body } : {} };
70770
+ }
70771
+ function nearest(faces, weight) {
70772
+ return [...faces].filter((f) => f.style === "normal").sort((a, b) => Math.abs(a.weight - weight) - Math.abs(b.weight - weight))[0];
70773
+ }
70774
+ async function webFontUrl(family, weight, deps) {
70775
+ const first = await resolveBrandFaces(family, [weight], ["latin"], deps);
70776
+ if (first.ok) return nearest(first.faces, weight)?.url ?? null;
70777
+ if (first.reason !== "weight" || first.available.length === 0) return null;
70778
+ const closest = [...first.available].sort((a, b) => Math.abs(a - weight) - Math.abs(b - weight))[0];
70779
+ if (closest === void 0) return null;
70780
+ const retry = await resolveBrandFaces(family, [closest], ["latin"], deps);
70781
+ return retry.ok ? nearest(retry.faces, closest)?.url ?? null : null;
70782
+ }
70783
+ async function webFace(named, fallback, weight, resolveUrl) {
70784
+ if (named) {
70785
+ const url2 = await resolveUrl(named.family, weight);
70786
+ if (url2) return { slot: { url: url2 }, family: named.family, origin: named.origin };
70787
+ }
70788
+ const url = await resolveUrl(fallback.family, fallback.weight);
70789
+ return url ? { slot: { url }, family: fallback.family, origin: "default" } : null;
70790
+ }
70791
+ async function resolveStaticFonts(args8) {
70792
+ const { files, named, resolveUrl } = args8;
70793
+ const file = (p) => ({ slot: { path: p }, family: null, origin: "brand_file" });
70794
+ const headline = files.headline ? file(files.headline) : await webFace(named.headline ?? named.body, DEFAULT_FONTS.headline, DEFAULT_FONTS.headline.weight, resolveUrl);
70795
+ if (!headline) return null;
70796
+ const body = files.body ? file(files.body) : files.headline ? headline : await webFace(named.body ?? named.headline, DEFAULT_FONTS.body, DEFAULT_FONTS.body.weight, resolveUrl) ?? headline;
70797
+ return { headline, body };
70798
+ }
70399
70799
 
70400
70800
  // src/engine/scaffold/staticAd.ts
70801
+ import { z as z52 } from "zod";
70401
70802
  var GEN_ASPECT_RATIOS = /* @__PURE__ */ new Set(["1:1", "4:5", "9:16", "16:9", "4:3", "3:4", "2:3", "3:2", "21:9"]);
70402
70803
  var DEFAULT_ASPECT_RATIO = "9:16";
70403
70804
  var SHEET_SUBJECT_TYPE2 = {
@@ -70407,7 +70808,7 @@ var ACTOR_SHEET_MODEL2 = "google/gemini-3-pro-image";
70407
70808
  var ACTOR_SHEET_IMAGE_SIZE = "4K";
70408
70809
  var ADAPT_MODEL = "google/gemini-3-pro-image";
70409
70810
  var ADAPT_IMAGE_SIZE = "2K";
70410
- var ADAPT_GUIDANCE = "Keep the headline, the call to action and the hero subject fully visible. Reproduce every text string verbatim, with no dropped, added, or altered characters, and preserve the exact brand-colour treatment. Draw no logo: it is placed afterwards.";
70811
+ var ADAPT_GUIDANCE = "Keep every line of text and the hero subject fully visible. Reproduce every text string verbatim, with no dropped, added, or altered characters, and preserve the exact colour treatment of each line. Draw no logo or wordmark.";
70411
70812
  var Blueprint = z52.object({
70412
70813
  meta: z52.object({ estimated_aspect_ratio: z52.string().optional() }).loose().optional(),
70413
70814
  text_content: z52.array(z52.object({ text: z52.string().optional() }).loose()).optional(),
@@ -70453,8 +70854,22 @@ function suppliedImage(el) {
70453
70854
  const url = typeof el.source_image === "string" ? el.source_image.trim() : "";
70454
70855
  return /^https?:\/\//i.test(url) ? url : void 0;
70455
70856
  }
70456
- function staticTextMode(opts) {
70457
- return opts.brand?.headlineFontPath && !opts.bakeText ? "composed" : "baked";
70857
+ function overlayCopy(blueprint) {
70858
+ return composeSpec(blueprint, { palette: [] }).copy.map((b) => b.text);
70859
+ }
70860
+ function staticMode(blueprint, opts) {
70861
+ if (opts.textOnly) return "text_only";
70862
+ if (overlayCopy(blueprint).length === 0) return "picture_only";
70863
+ return opts.brand?.fonts && !opts.bakeText ? "composed" : "baked";
70864
+ }
70865
+ var plateTextOf = (mode) => mode === "baked" ? "baked" : "clean";
70866
+ function isTextForward(input) {
70867
+ const blueprint = input;
70868
+ const none = (v) => !Array.isArray(v) || v.length === 0;
70869
+ return none(blueprint?.subjects) && none(blueprint?.people) && overlayCopy(input).length > 0;
70870
+ }
70871
+ function logoDrawn(blueprint, opts) {
70872
+ return Boolean(opts.brand?.logoPath) && conceptHasLogo(blueprint);
70458
70873
  }
70459
70874
  function layoutDirFor(format) {
70460
70875
  return `./layout-${format.replace(":", "x")}`;
@@ -70480,27 +70895,29 @@ var TAKE_DIRECTIONS = [
70480
70895
  "Take C: a fresh camera angle and lighting that still fit the brief, same subject and palette."
70481
70896
  ];
70482
70897
  var PLATE_RULES = [
70483
- "This is a CLEAN PLATE for an advertisement: the picture only. The headline, the copy, the call to action and the logo are added afterwards by a designer, so:",
70484
- "- Render NO text of any kind: no letters, words, numbers, captions, labels, signage, packaging copy you invent, watermarks, UI, buttons or logos.",
70898
+ "This is a CLEAN PLATE for an advertisement: the picture only. Whatever copy the ad carries is added afterwards by a designer, so:",
70899
+ "- Render NO text of any kind: no letters, words, numbers, captions, labels, signage, packaging copy you invent, watermarks, UI, buttons or logos. The one exception is `in_scene_text`: each of those strings is written ON the object its placement names (a phone screen, a note, a sign, packaging), exactly once, spelled exactly.",
70485
70900
  "- Keep every area in `reserved_for_overlay` calm: an even, low-detail continuation of the background, with no subject, face, product or strong edge inside it.",
70486
70901
  "- Keep the hero subject and product whole and inside the frame, clear of those areas.",
70487
70902
  "- Rebuild each device in `mechanisms_to_keep`: it is why the reference ad works."
70488
70903
  ].join("\n");
70489
70904
  var JUDGE_RUBRIC = [
70490
- "You are judging candidate PLATES for a static ad: the picture before the copy and the logo are drawn on it.",
70905
+ "You are judging candidate PLATES for a static ad: the picture before any copy is drawn on it. A winning static is ONE specific promise understood at a glance.",
70491
70906
  "Score every image 0-5 on each criterion:",
70492
- "- mechanism: rebuilds the devices in `mechanisms_to_keep` below (the reason the reference ad works).",
70493
- "- offer_space: the areas in `reserved_for_overlay` are calm enough for the offer to read in one second once the copy is drawn there.",
70494
- "- brand: the palette and the product or subject identity read as the brand's.",
70907
+ "- idea: ONE dominant element carries the idea; nothing competes with it for the eye.",
70908
+ "- thumbnail: shrunk to a phone feed thumbnail (about 240px wide), the dominant element still reads in under two seconds.",
70909
+ "- mechanism: rebuilds the devices in `mechanisms_to_keep` below (the reason the reference ad works); 5 when the list is empty and the picture is on brief.",
70910
+ "- offer_space: the areas in `reserved_for_overlay` are calm enough for what is drawn there to read in one second; 5 when the list is empty.",
70911
+ "- brand: with any logo covered, the palette, the subject and the treatment still read as the brand's.",
70495
70912
  "- artifacts: no AI artifacts (hands, faces, mouths, warped products, seams, duplicated limbs); 5 means none.",
70496
70913
  "- safe_zones: the hero stays out of the outer top 14% and bottom 20% of the frame (Stories and Reels chrome).",
70497
70914
  "- text: {{text_rule}}",
70498
- 'Begin `reasoning` with one line per image, exactly: "Image N: mechanism=x offer_space=x brand=x artifacts=x safe_zones=x text=x total=x" (total out of 30), then one sentence on why the winner won. Pick the highest total; break a tie on artifacts.',
70915
+ 'Begin `reasoning` with one line per image, exactly: "Image N: idea=x thumbnail=x mechanism=x offer_space=x brand=x artifacts=x safe_zones=x text=x total=x" (total out of 40), then one sentence on why the winner won. Pick the highest total; break a tie on artifacts.',
70499
70916
  "",
70500
70917
  "BRIEF:",
70501
70918
  "{{plate_spec}}"
70502
70919
  ].join("\n");
70503
- var TEXT_RULE_COMPOSED = "the plate has no letters, words, logos or UI at all; any text is a 0.";
70920
+ var TEXT_RULE_COMPOSED = "no letters, words, logos or UI anywhere, except the strings in `in_scene_text`, each once and spelled exactly on the object it belongs to; any other text is a 0.";
70504
70921
  var TEXT_RULE_BAKED = "read every word in the image: the copy is exactly the strings the brief quotes, each once, spelled right; a missing, extra or misspelled word lowers the score.";
70505
70922
  function referenceLegend(slots, originalAsReference) {
70506
70923
  return [
@@ -70512,14 +70929,14 @@ function referenceLegend(slots, originalAsReference) {
70512
70929
  }
70513
70930
  function platePrompt(args8) {
70514
70931
  const exclusions = "Exclusions: no invented badges, icons, or seals; render exactly the subjects the blueprint names and no others; no competitor branding; no watermark.";
70515
- const head = args8.mode === "composed" ? [
70932
+ const head = args8.mode === "clean" ? [
70516
70933
  "Render the picture for the advertisement described by the PLATE SPEC below. The `scene` is authoritative (subjects, expressions, palette, lighting, camera, style); follow its composition and leave the `reserved_for_overlay` areas free.",
70517
70934
  "",
70518
70935
  PLATE_RULES
70519
70936
  ] : [
70520
70937
  "Render the advertisement described by the TARGET BLUEPRINT below faithfully. The blueprint is authoritative \u2014 render exactly what it says (palette, expressions, intent, layout). Follow the blueprint's `layout` object for the global composition: reproduce the column/row grid, each region's position and size from its x_pct/y_pct bounds, the panel splits, and every text block at the relative size, weight, case, and alignment it specifies. Do not add brand logic, do not recolor on your own, do not invent any logo, badge, face, or product.",
70521
70938
  "",
70522
- "Leave the logo OUT: the brand's real logo file is placed on the finished ad afterwards, so draw no logo, wordmark or brand name anywhere."
70939
+ "Leave any logo OUT: draw no logo, wordmark or brand name anywhere. When the ad carries a logo, the brand's real file is placed afterwards."
70523
70940
  ];
70524
70941
  const copyBlock = args8.mode === "baked" ? ["", COPY_DIRECTIVE_HEADER, textFidelityDirective(args8.copy, false), ""] : [""];
70525
70942
  return [
@@ -70536,15 +70953,15 @@ function platePrompt(args8) {
70536
70953
  "Match the palette hex precisely. Render each subject's expression and the ad's intent.",
70537
70954
  ...args8.exclusionsLast ? [] : ["", exclusions],
70538
70955
  "",
70539
- args8.mode === "composed" ? "PLATE SPEC:" : "TARGET BLUEPRINT:",
70540
- args8.mode === "composed" ? "{{plate_spec}}" : "{{target_blueprint}}",
70956
+ args8.mode === "clean" ? "PLATE SPEC:" : "TARGET BLUEPRINT:",
70957
+ args8.mode === "clean" ? "{{plate_spec}}" : "{{target_blueprint}}",
70541
70958
  ...args8.exclusionsLast ? ["", exclusions] : []
70542
70959
  ].join("\n");
70543
70960
  }
70544
70961
  function extensionPrompt(format, legend, hasElementRefs) {
70545
70962
  return [
70546
70963
  `Extend the picture in the FIRST reference image to a ${format} frame. It is the same photograph with more of the scene revealed: keep the subject, the product, their scale, the lighting, the colors and the camera exactly as they are, and continue the background naturally into the new area.`,
70547
- "Keep the new area calm and even: the ad copy is placed there afterwards. Render NO text, letters, logos, UI or watermarks anywhere.",
70964
+ "Keep the new area calm and even: any ad copy is placed there afterwards. Keep text already written on objects in the picture exactly as it is, and add NO other text, letters, logos, UI or watermarks anywhere.",
70548
70965
  "Keep the hero whole and away from the outer top 14% and bottom 20% of the frame.",
70549
70966
  ...hasElementRefs ? ["", "The other references keep identities exact:", legend] : [],
70550
70967
  "",
@@ -70632,19 +71049,25 @@ function outputFormats(blueprint, opts) {
70632
71049
  function showsPerson(blueprint, elements) {
70633
71050
  return (blueprint.people?.length ?? 0) > 0 || elements.some(isPerson);
70634
71051
  }
70635
- function elementNodes(elements, opts) {
71052
+ function elementNodes(elements, opts, drawLogo) {
70636
71053
  const includeActorSheets = opts.includeActorSheets !== false;
70637
71054
  const usedIds = /* @__PURE__ */ new Set(["prompt", "plate_spec", "compose_spec", "original", "pick", "final"]);
70638
71055
  const nodes = [];
70639
71056
  const slots = [];
71057
+ const logoPath = drawLogo ? opts.brand?.logoPath : void 0;
70640
71058
  let logoRef;
70641
- assignElementLabels2(elements).forEach(({ el, label }, i) => {
70642
- const isLogo = el.type.toLowerCase() === "logo";
70643
- if (isLogo && logoRef) return;
71059
+ const wanted = (isLogo) => isLogo ? !logoRef && Boolean(logoPath) : !opts.textOnly;
71060
+ const freshId = (label, i) => {
70644
71061
  let id = sanitizeId2(`el_${label}`, `el_${i}`);
70645
71062
  while (usedIds.has(id)) id = `${id}_${i}`;
70646
71063
  usedIds.add(id);
70647
- nodes.push(elementIngest(id, el, label, isLogo ? opts.brand?.logoPath : void 0));
71064
+ return id;
71065
+ };
71066
+ assignElementLabels2(elements).forEach(({ el, label }, i) => {
71067
+ const isLogo = el.type.toLowerCase() === "logo";
71068
+ if (!wanted(isLogo)) return;
71069
+ const id = freshId(label, i);
71070
+ nodes.push(elementIngest(id, el, label, isLogo ? logoPath : void 0));
70648
71071
  if (isLogo) {
70649
71072
  logoRef = `$ref:${id}.asset`;
70650
71073
  return;
@@ -70653,8 +71076,8 @@ function elementNodes(elements, opts) {
70653
71076
  if (sheet) nodes.push(sheet);
70654
71077
  slots.push({ ref: sheet ? `$ref:${sheet.id}.sheet` : `$ref:${id}.asset`, label, type: el.type });
70655
71078
  });
70656
- if (!logoRef && opts.brand?.logoPath) {
70657
- nodes.push(ingestNode("brand_logo", opts.brand.logoPath, "image"));
71079
+ if (!logoRef && logoPath) {
71080
+ nodes.push(ingestNode("brand_logo", logoPath, "image"));
70658
71081
  logoRef = "$ref:brand_logo.asset";
70659
71082
  }
70660
71083
  return { nodes, slots, logoRef };
@@ -70682,15 +71105,18 @@ function actorSheetNode(el, ingestId) {
70682
71105
  }
70683
71106
  };
70684
71107
  }
71108
+ function fontIngest(id, font) {
71109
+ return "path" in font.slot ? ingestNode(id, font.slot.path, "font") : { id, type: "ingest", params: { source: "url", url: font.slot.url, expect: "font" } };
71110
+ }
71111
+ var slotKey = (font) => "path" in font.slot ? font.slot.path : font.slot.url;
70685
71112
  function fontNodes(brand) {
70686
- const headline = brand?.headlineFontPath;
70687
- if (!headline) return { nodes: [], refs: {} };
70688
- const nodes = [ingestNode("font_headline", headline, "font")];
70689
- const body = brand?.bodyFontPath;
70690
- if (!body || body === headline) {
71113
+ const fonts = brand?.fonts;
71114
+ if (!fonts) return { nodes: [], refs: {} };
71115
+ const nodes = [fontIngest("font_headline", fonts.headline)];
71116
+ if (slotKey(fonts.body) === slotKey(fonts.headline)) {
70691
71117
  return { nodes, refs: { font_headline: "$ref:font_headline.asset", font_body: "$ref:font_headline.asset" } };
70692
71118
  }
70693
- nodes.push(ingestNode("font_body", body, "font"));
71119
+ nodes.push(fontIngest("font_body", fonts.body));
70694
71120
  return { nodes, refs: { font_headline: "$ref:font_headline.asset", font_body: "$ref:font_body.asset" } };
70695
71121
  }
70696
71122
  function renderParamsFor(genModel) {
@@ -70702,12 +71128,12 @@ function renderParamsFor(genModel) {
70702
71128
  };
70703
71129
  }
70704
71130
  function plateNodes(args8) {
70705
- const { blueprint, slots, opts, mode } = args8;
71131
+ const { blueprint, slots, opts, text: text2 } = args8;
70706
71132
  const takes = takesFor(opts);
70707
71133
  const originalAsReference = !showsPerson(blueprint, args8.elements);
70708
71134
  const reference = [...slots.map((s) => s.ref), ...originalAsReference ? ["$ref:original.asset"] : []];
70709
71135
  const legend = referenceLegend(slots, originalAsReference);
70710
- const specInput = mode === "composed" ? { plate_spec: "$ref:plate_spec.asset" } : { target_blueprint: "$ref:prompt.asset" };
71136
+ const specInput = text2 === "clean" ? { plate_spec: "$ref:plate_spec.asset" } : { target_blueprint: "$ref:prompt.asset" };
70711
71137
  const nodes = [];
70712
71138
  const ids = Array.from({ length: takes }, (_, t) => takes === 1 ? "plate" : `plate_${String.fromCharCode(97 + t)}`);
70713
71139
  ids.forEach((id, t) => {
@@ -70720,7 +71146,7 @@ function plateNodes(args8) {
70720
71146
  aspect_ratio: args8.plateRatio,
70721
71147
  ...renderParamsFor(opts.genModel),
70722
71148
  prompt: platePrompt({
70723
- mode,
71149
+ mode: text2,
70724
71150
  copy: copyStrings(blueprint),
70725
71151
  take: TAKE_DIRECTIONS[t] ?? "",
70726
71152
  legend,
@@ -70737,18 +71163,19 @@ function plateNodes(args8) {
70737
71163
  type: "image_select",
70738
71164
  inputs: {
70739
71165
  images: ids.map((id) => `$ref:${id}.images#0`),
70740
- plate_spec: mode === "composed" ? "$ref:plate_spec.asset" : "$ref:prompt.asset"
71166
+ plate_spec: text2 === "clean" ? "$ref:plate_spec.asset" : "$ref:prompt.asset"
70741
71167
  },
70742
71168
  params: {
70743
71169
  model: JUDGE_MODEL,
70744
71170
  count: 1,
70745
71171
  temperature: 0,
70746
- prompt: JUDGE_RUBRIC.replace("{{text_rule}}", mode === "composed" ? TEXT_RULE_COMPOSED : TEXT_RULE_BAKED)
71172
+ prompt: JUDGE_RUBRIC.replace("{{text_rule}}", text2 === "clean" ? TEXT_RULE_COMPOSED : TEXT_RULE_BAKED)
70747
71173
  }
70748
71174
  });
70749
71175
  return { nodes, chosen: "$ref:pick.images#0" };
70750
71176
  }
70751
71177
  function takesFor(opts) {
71178
+ if (opts.textOnly) return 0;
70752
71179
  return Math.max(1, Math.min(TAKE_DIRECTIONS.length, opts.takes ?? TAKE_DIRECTIONS.length));
70753
71180
  }
70754
71181
  function bakedAdaptNode(plan, chosen) {
@@ -70764,72 +71191,151 @@ function bakedAdaptNode(plan, chosen) {
70764
71191
  }
70765
71192
  };
70766
71193
  }
71194
+ function snapshotNode(args8) {
71195
+ const slug = args8.format.replace(":", "x");
71196
+ return {
71197
+ id: `ad_${slug}`,
71198
+ type: "hyperframe_snapshot",
71199
+ inputs: {
71200
+ ...args8.plateRef ? { plate: args8.plateRef } : {},
71201
+ compose: "$ref:compose_spec.asset",
71202
+ ...args8.logoRef ? { logo: args8.logoRef } : {},
71203
+ ...args8.fontRefs
71204
+ },
71205
+ params: {
71206
+ composition: layoutDirFor(args8.format),
71207
+ wait_for: { kind: "selector", value: "#root[data-ready='1']" },
71208
+ ad: "{{compose}}",
71209
+ // `plate` tells the composition (and the run-time layout pass) which picture this
71210
+ // format is cut from, so each line lands where the source layout put it; null means
71211
+ // there is no picture at all.
71212
+ // `fonts` says whether any face is staged: one declared but never staged is a 404.
71213
+ frame: {
71214
+ format: args8.format,
71215
+ safe: safeAreaFor(args8.format),
71216
+ draw_copy: args8.drawCopy,
71217
+ plate: args8.plate,
71218
+ fonts: Object.keys(args8.fontRefs).length > 0
71219
+ }
71220
+ }
71221
+ };
71222
+ }
71223
+ function extendNode(entry, chosen, slots, genModel) {
71224
+ if (entry.source !== "extend" || !entry.extendRatio) return null;
71225
+ return {
71226
+ id: `extend_${entry.format.replace(":", "x")}`,
71227
+ type: "image_generate",
71228
+ inputs: { reference: [chosen, ...slots.map((s) => s.ref)], plate_spec: "$ref:plate_spec.asset" },
71229
+ params: {
71230
+ model: genModel,
71231
+ aspect_ratio: entry.extendRatio,
71232
+ ...renderParamsFor(genModel),
71233
+ prompt: extensionPrompt(entry.format, referenceLegend(slots, false), slots.length > 0)
71234
+ }
71235
+ };
71236
+ }
71237
+ function formatSource(args8) {
71238
+ const { entry, plan, adapt, chosen } = args8;
71239
+ if (!chosen) return { plateRef: null, plate: null, extend: null };
71240
+ const adapted = adapt ? adapt.formats.indexOf(entry.format) : -1;
71241
+ if (adapted >= 0) return { plateRef: `$ref:adapt.images#${adapted}`, plate: { ratio: entry.format }, extend: null };
71242
+ const extend = args8.mode !== "baked" ? extendNode(entry, chosen, args8.slots, args8.genModel) : null;
71243
+ if (!extend) return { plateRef: chosen, plate: { ratio: plan.plateRatio }, extend: null };
71244
+ return {
71245
+ plateRef: `$ref:${extend.id}.images#0`,
71246
+ plate: { ratio: plan.plateRatio, extend_ratio: String(extend.params?.aspect_ratio) },
71247
+ extend
71248
+ };
71249
+ }
70767
71250
  function formatNodes(args8) {
70768
71251
  const { plan, mode, chosen, slots } = args8;
70769
71252
  const nodes = [];
70770
71253
  const layoutIds = [];
70771
- const adapt = mode === "baked" ? bakedAdaptNode(plan, chosen) : null;
71254
+ const adapt = mode === "baked" && chosen ? bakedAdaptNode(plan, chosen) : null;
70772
71255
  if (adapt) nodes.push(adapt.node);
70773
71256
  for (const entry of plan.formats) {
70774
71257
  const slug = entry.format.replace(":", "x");
70775
- const adapted = adapt ? adapt.formats.indexOf(entry.format) : -1;
70776
- let plateRef = adapted >= 0 ? `$ref:adapt.images#${adapted}` : chosen;
70777
- if (entry.source === "extend" && mode === "composed" && entry.extendRatio) {
70778
- nodes.push({
70779
- id: `extend_${slug}`,
70780
- type: "image_generate",
70781
- inputs: { reference: [chosen, ...slots.map((s) => s.ref)], plate_spec: "$ref:plate_spec.asset" },
70782
- params: {
70783
- model: args8.genModel,
70784
- aspect_ratio: entry.extendRatio,
70785
- ...renderParamsFor(args8.genModel),
70786
- prompt: extensionPrompt(entry.format, referenceLegend(slots, false), slots.length > 0)
70787
- }
70788
- });
70789
- plateRef = `$ref:extend_${slug}.images#0`;
70790
- }
70791
- layoutIds.push(`ad_${slug}`);
70792
- nodes.push({
70793
- id: `ad_${slug}`,
70794
- type: "hyperframe_snapshot",
70795
- inputs: {
70796
- plate: plateRef,
70797
- compose: "$ref:compose_spec.asset",
70798
- ...args8.logoRef ? { logo: args8.logoRef } : {},
70799
- ...args8.fontRefs
70800
- },
70801
- params: {
70802
- composition: layoutDirFor(entry.format),
70803
- wait_for: { kind: "selector", value: "#root[data-ready='1']" },
70804
- ad: "{{compose}}",
70805
- frame: {
70806
- format: entry.format,
70807
- safe: safeAreaFor(entry.format),
70808
- draw_copy: mode === "composed"
70809
- }
70810
- }
71258
+ const { plateRef, plate, extend } = formatSource({
71259
+ entry,
71260
+ plan,
71261
+ adapt,
71262
+ chosen,
71263
+ mode,
71264
+ slots,
71265
+ genModel: args8.genModel
70811
71266
  });
71267
+ if (extend) nodes.push(extend);
71268
+ layoutIds.push(`ad_${slug}`);
71269
+ nodes.push(
71270
+ snapshotNode({
71271
+ format: entry.format,
71272
+ plateRef,
71273
+ plate,
71274
+ drawCopy: mode !== "baked",
71275
+ logoRef: args8.logoRef,
71276
+ fontRefs: args8.fontRefs
71277
+ })
71278
+ );
70812
71279
  }
70813
71280
  return { nodes, layoutIds };
70814
71281
  }
71282
+ function fontLabel(font) {
71283
+ const origin = {
71284
+ brand_file: "the brand's font file",
71285
+ tokens: "named in tokens.json, loaded as a web font",
71286
+ brand_md: "named in BRAND.md, loaded as a web font",
71287
+ default: "the default, since the brand names no typeface"
71288
+ };
71289
+ const name = font.family ?? ("path" in font.slot ? font.slot.path.split("/").pop() ?? font.slot.path : "a web font");
71290
+ return `${name} (${origin[font.origin]})`;
71291
+ }
71292
+ function describeText(mode, fonts) {
71293
+ if (mode === "picture_only") return "The ad has no copy: the picture is the ad, with nothing drawn on it.";
71294
+ if (mode === "baked") {
71295
+ return "The copy is baked into the picture by the image model (no font could be loaded, or --skip-font).";
71296
+ }
71297
+ if (!fonts) return "The copy is drawn from prompt.json in a fallback system face.";
71298
+ const oneFace = slotKey(fonts.body) === slotKey(fonts.headline) || fonts.body.family !== null && fonts.body.family === fonts.headline.family;
71299
+ const body = oneFace ? "" : ` and ${fontLabel(fonts.body)}`;
71300
+ return `The copy is drawn from prompt.json in ${fontLabel(fonts.headline)}${body}.`;
71301
+ }
71302
+ function describeRun(args8) {
71303
+ const { mode, takes, logo } = args8;
71304
+ const numbers = ["No", "One", "Two", "Three"];
71305
+ const pictures = mode === "text_only" ? "No picture is generated: every format is the copy drawn on the brand's colours." : takes > 1 ? `${numbers[takes] ?? takes} pictures are rendered and a judge picks one; every format is cut from it.` : "One picture is rendered (no judge); every format is cut from it.";
71306
+ const text2 = describeText(mode, args8.brand?.fonts);
71307
+ const scene = args8.inScene > 0 ? ` ${args8.inScene} line(s) written on something in the scene are painted into the picture.` : "";
71308
+ const mark = logo ? " The brand's logo file is drawn where the concept places it." : " No logo is drawn: the concept has none, or the brand has no logo file (Meta shows the page name and picture on every ad).";
71309
+ return `${pictures} ${text2}${scene}${mark}`;
71310
+ }
70815
71311
  function scaffoldStaticAd(input, elementsInput, opts) {
70816
71312
  const blueprint = Blueprint.parse(input);
70817
71313
  const elements = MainElements.parse(elementsInput);
70818
- const mode = staticTextMode(opts);
71314
+ const mode = staticMode(blueprint, opts);
70819
71315
  const plan = planStaticFormats(opts.genModel, outputFormats(blueprint, opts));
71316
+ const drawLogo = logoDrawn(blueprint, opts);
70820
71317
  const spine = [
70821
71318
  ingestNode("prompt", opts.blueprintPath ?? "./prompt.json", "json"),
70822
- ...mode === "composed" ? [ingestNode("plate_spec", PLATE_SPEC_PATH, "json")] : [],
71319
+ ...mode === "composed" || mode === "picture_only" ? [ingestNode("plate_spec", PLATE_SPEC_PATH, "json")] : [],
70823
71320
  ingestNode("compose_spec", COMPOSE_SPEC_PATH, "json"),
70824
- {
70825
- id: "original",
70826
- type: "ingest",
70827
- params: opts.imageIsUrl ? { source: "url", url: opts.imagePath, expect: "image" } : { source: "path", path: opts.imagePath, expect: "image" }
70828
- }
71321
+ ...mode === "text_only" ? [] : [
71322
+ {
71323
+ id: "original",
71324
+ type: "ingest",
71325
+ params: opts.imageIsUrl ? { source: "url", url: opts.imagePath, expect: "image" } : { source: "path", path: opts.imagePath, expect: "image" }
71326
+ }
71327
+ ]
70829
71328
  ];
70830
- const els = elementNodes(elements, opts);
70831
- const fonts = mode === "composed" ? fontNodes(opts.brand) : { nodes: [], refs: {} };
70832
- const plates = plateNodes({ blueprint, elements, slots: els.slots, opts, mode, plateRatio: plan.plateRatio });
71329
+ const els = elementNodes(elements, opts, drawLogo);
71330
+ const fonts = mode === "baked" ? { nodes: [], refs: {} } : fontNodes(opts.brand);
71331
+ const plates = mode === "text_only" ? { nodes: [], chosen: null } : plateNodes({
71332
+ blueprint,
71333
+ elements,
71334
+ slots: els.slots,
71335
+ opts,
71336
+ text: plateTextOf(mode),
71337
+ plateRatio: plan.plateRatio
71338
+ });
70833
71339
  const formats = formatNodes({
70834
71340
  plan,
70835
71341
  mode,
@@ -70843,7 +71349,9 @@ function scaffoldStaticAd(input, elementsInput, opts) {
70843
71349
  schema: "baker-canvas/1",
70844
71350
  metadata: {
70845
71351
  name: "static ad reproduction",
70846
- description: "Scaffolded by `baker canvas scaffold-static-ad`. Edit `prompt.json` (the blueprint generated from the source image) into the ad you want; the copy, the CTA and the layout are read from it on every run. Drop a real asset at each [TODO] ingest path, then `baker canvas run`. Three plates are rendered and judged, and every format is drawn from the winner with the brand's own fonts and logo."
71352
+ description: `Scaffolded by \`baker canvas scaffold-static-ad\`. Edit \`prompt.json\` (the blueprint generated from the source image) into the ad you want; the copy and the layout are read from it on every run, and only what it holds is drawn. Drop a real asset at each [TODO] ingest path, then \`baker canvas run\`. ${describeRun(
71353
+ { mode, takes: takesFor(opts), logo: drawLogo, brand: opts.brand, inScene: inSceneText(blueprint).length }
71354
+ )}`
70847
71355
  },
70848
71356
  nodes: [
70849
71357
  ...spine,
@@ -70861,26 +71369,45 @@ function scaffoldStaticAd(input, elementsInput, opts) {
70861
71369
  output: { node: "final", output: "images" }
70862
71370
  };
70863
71371
  }
71372
+ function logoWhy(blueprint, opts) {
71373
+ if (!conceptHasLogo(blueprint)) {
71374
+ return "The concept shows no logo, so none is drawn: Meta shows the page name and picture on every ad.";
71375
+ }
71376
+ if (!opts.brand?.logoPath) {
71377
+ return "The concept shows a logo but src/brand/logos/ has no real logo file, so none is drawn (never a stand-in). Add the file and re-scaffold to have it drawn.";
71378
+ }
71379
+ return `The brand logo ${opts.brand.logoPath} is drawn where the concept places it, never generated.`;
71380
+ }
70864
71381
  function staticAdReport(input, elementsInput, opts) {
70865
71382
  const blueprint = Blueprint.parse(input);
70866
71383
  const elements = MainElements.parse(elementsInput);
70867
71384
  const includeActorSheets = opts.includeActorSheets !== false;
70868
71385
  const plan = planStaticFormats(opts.genModel, outputFormats(blueprint, opts));
71386
+ const mode = staticMode(blueprint, opts);
71387
+ const drawLogo = logoDrawn(blueprint, opts);
71388
+ const takes = takesFor(opts);
71389
+ const fonts = mode !== "baked" ? opts.brand?.fonts : void 0;
71390
+ const inScene = inSceneText(blueprint).map((t) => t.text);
70869
71391
  return {
70870
71392
  element_count: elements.length,
70871
71393
  elements: assignElementLabels2(elements).map(({ el, label }) => ({
70872
71394
  label,
70873
71395
  type: el.type,
70874
71396
  description: el.description ?? null,
70875
- asset_todo: suppliedImage(el) ? "none \u2014 cast from a company Avatar's identity sheet" : el.type.toLowerCase() === "logo" && opts.brand?.logoPath ? `none \u2014 the brand logo ${opts.brand.logoPath} is drawn by the composition` : todoPath2(el, label)
71397
+ asset_todo: suppliedImage(el) ? "none: cast from a company Avatar's identity sheet" : el.type.toLowerCase() === "logo" ? drawLogo ? `none: the brand logo ${opts.brand?.logoPath} is drawn by the composition` : "none: no logo is drawn (the logo is optional)" : opts.textOnly ? "none: a text-only ad generates no picture" : todoPath2(el, label)
70876
71398
  })),
70877
- text_mode: staticTextMode(opts),
70878
- actor_sheets: includeActorSheets ? assignElementLabels2(elements).filter(({ el }) => SHEET_SUBJECT_TYPE2[el.type.toLowerCase()]).map(({ label }) => label) : [],
71399
+ mode,
71400
+ summary: describeRun({ mode, takes, logo: drawLogo, brand: opts.brand, inScene: inScene.length }),
71401
+ fonts: fonts ? { headline: fontLabel(fonts.headline), body: fontLabel(fonts.body) } : null,
71402
+ logo: { drawn: drawLogo, why: logoWhy(blueprint, opts) },
71403
+ in_scene_text: mode === "text_only" ? [] : inScene,
71404
+ actor_sheets: includeActorSheets && !opts.textOnly ? assignElementLabels2(elements).filter(({ el }) => SHEET_SUBJECT_TYPE2[el.type.toLowerCase()]).map(({ label }) => label) : [],
70879
71405
  aspect_ratio: plan.plateRatio,
70880
71406
  placements: opts.placements ?? null,
70881
71407
  output_formats: plan.formats.map((f) => f.format),
70882
71408
  format_plan: plan.formats,
70883
- takes: takesFor(opts),
71409
+ takes,
71410
+ judged: takes > 1,
70884
71411
  winning_mechanisms: winningMechanisms(blueprint),
70885
71412
  describe_gaps: describeGaps(blueprint)
70886
71413
  };
@@ -71192,6 +71719,46 @@ async function uploadSourceAsReference(source, isUrl, client) {
71192
71719
  if (!putRes.ok) throw new Error(`source upload failed (${putRes.status})`);
71193
71720
  return publicUrl;
71194
71721
  }
71722
+ async function brandFaces(root, kit, toCanvas) {
71723
+ const read = (file) => readFile20(path26.join(root, BRAND_DIR, file), "utf8").catch(() => null);
71724
+ const named = namedTypefaces({ tokensJson: await read("tokens.json"), brandMd: await read("BRAND.md") });
71725
+ const fonts = await resolveStaticFonts({
71726
+ files: { headline: kit.headlineFont, body: kit.bodyFont },
71727
+ named,
71728
+ resolveUrl: (family, weight) => webFontUrl(family, weight, { lookupGoogleCss, fetchJson })
71729
+ });
71730
+ if (!fonts) return null;
71731
+ const local = (font) => "path" in font.slot ? { ...font, slot: { path: toCanvas(font.slot.path) } } : font;
71732
+ return { headline: local(fonts.headline), body: local(fonts.body) };
71733
+ }
71734
+ function staticTextNote(report, bakeText) {
71735
+ const faces = !report.fonts ? "a fallback system face" : report.fonts.headline === report.fonts.body ? report.fonts.headline : `${report.fonts.headline} / ${report.fonts.body}`;
71736
+ switch (report.mode) {
71737
+ case "composed":
71738
+ return `The copy is DRAWN, not generated: each line of text_content in prompt.json (minus the logo and any text written in the scene) is set in ${faces} where the source layout put it, styled the way the reference styled it. A call to action stays plain, underlined, an arrow or a button as the reference had it (role "cta", and a "treatment" to change it); nothing is added that prompt.json does not hold. Edit the copy in prompt.json; every run reads it again.`;
71739
+ case "picture_only":
71740
+ return "The concept has no copy: the picture is the ad, with nothing drawn on it. Add lines to text_content in prompt.json to draw copy.";
71741
+ case "text_only":
71742
+ return `Text-only: no picture is generated. Every line of prompt.json is drawn on the brand's colours in ${faces}. Pass --no-text-only to render a picture instead.`;
71743
+ default:
71744
+ return bakeText ? "The copy is baked into the picture (--skip-font). Re-read every word on every format before publishing." : "The copy is baked into the picture because no font could be loaded: no file in src/brand/fonts/, and neither the typeface the brand names nor the default web font could be fetched. Re-read every word before publishing.";
71745
+ }
71746
+ }
71747
+ function staticFormatsNote(report, genModel) {
71748
+ const set = report.placements === null ? " Pass --placements meta_feed_stories|meta_all|linkedin|\u2026 for a platform's set." : "";
71749
+ if (report.mode === "text_only") {
71750
+ return `Delivers ${report.output_formats.join(", ")}, each drawn directly at its exact size (no picture).${set}`;
71751
+ }
71752
+ const how = report.format_plan.map((f) => {
71753
+ if (f.format === report.aspect_ratio) return `${f.format} is the picture itself`;
71754
+ if (report.mode === "baked") {
71755
+ return `${f.format} is re-framed from the chosen picture by Nano Banana Pro (one billed render; the copy is in the pixels, so it is never cropped)`;
71756
+ }
71757
+ return f.source === "crop" ? `${f.format} is a free crop of it` : `${f.format} extends the chosen picture to ${f.extendRatio} (one billed render) and crops the rest`;
71758
+ }).join("; ");
71759
+ const drawn = report.mode === "composed" ? " Nothing re-paints the copy or the logo." : "";
71760
+ return `Delivers ${report.output_formats.join(", ")}, each at its exact size. The pictures render at ${report.aspect_ratio} (the nearest shape ${genModel} renders); ${how}.${drawn}${set}`;
71761
+ }
71195
71762
  function resolveModel(kind, preferred) {
71196
71763
  const ids = Object.keys(MODEL_REGISTRY[kind]);
71197
71764
  return ids.includes(preferred) ? preferred : ids[0] ?? preferred;
@@ -71212,7 +71779,7 @@ Return a JSON object describing the GLOBAL LAYOUT of the frame precisely enough
71212
71779
  "content": what is here (name the subject/badge; reuse its description),
71213
71780
  "background_color": hex or null,
71214
71781
  "shape": e.g. "rounded-rectangle" | "pill" | null,
71215
- "text": [ { "string": verbatim, "size": "x_large"|"large"|"medium"|"small", "weight": "bold"|"semibold"|"regular", "case": "upper"|"title"|"sentence", "color": hex, "align": "left"|"center"|"right" } ],
71782
+ "text": [ { "string": verbatim, "size": "x_large"|"large"|"medium"|"small", "weight": "bold"|"semibold"|"regular", "case": "upper"|"title"|"sentence", "color": hex, "align": "left"|"center"|"right", "treatment": "plain"|"underline"|"arrow"|"button"|"sticker"|"highlight" } ],
71216
71783
  "notes": splits / overlaps / margins worth rebuilding
71217
71784
  } ] }
71218
71785
 
@@ -71374,7 +71941,7 @@ async function runVisionPasses(canvas) {
71374
71941
  var scaffoldStaticAdCommand = defineCommand134({
71375
71942
  meta: {
71376
71943
  name: "scaffold-static-ad",
71377
- description: "Turn a source/inspiration image into a runnable static-ad canvas. Runs billed passes \u2014 image_describe (the blueprint, baked to prompt.json as the editable 'prompt'), an AI selection of the image's MAIN identity elements, and a structured global-layout pass (the column/row grid with per-region bounds and text sizes) \u2014 then scaffolds a canvas that renders three candidate pictures (no text, no logo), judges them, and draws every placement format from the winner: the copy from prompt.json in the brand's fonts from src/brand/fonts/, the CTA, and the logo from src/brand/logos/. A format close to the picture's shape is a free crop; a far one extends the picture. With no brand font the copy is baked into the picture instead. Pass --placements none for a single base ad, or a preset to pick the set. Edit prompt.json and drop the real assets, then `baker canvas run` it. Start here: `baker avatars list`, then pass `--avatar <handle>` when the image shows a person \u2014 the ad's people are played by the company's avatars, never by a photo of anyone real."
71944
+ description: "Turn a source/inspiration image into a runnable static-ad canvas. Runs billed passes \u2014 image_describe (the blueprint, baked to prompt.json as the editable 'prompt'), an AI selection of the image's MAIN identity elements, and a structured global-layout pass (the column/row grid with per-region bounds and text sizes) \u2014 then scaffolds a canvas that renders up to three candidate pictures (no overlay text, no logo), judges them, and draws every placement format from the winner with only what prompt.json holds: each line where the source layout put it, styled as the reference styled it, in the brand's fonts (a font file, else the typeface the brand names as a web font, else Inter), and the logo from src/brand/logos/ only when the concept shows one. No copy means the picture is the ad; a reference that pictures nothing but copy becomes a text-only ad with no picture at all (--text-only). A format close to the picture's shape is a free crop; a far one extends the picture. The copy is baked into the picture only with --skip-font or when no font loads. Pass --placements none for a single base ad, or a preset to pick the set. Edit prompt.json and drop the real assets, then `baker canvas run` it. Start here: `baker avatars list`, then pass `--avatar <handle>` when the image shows a person \u2014 the ad's people are played by the company's avatars, never by a photo of anyone real."
71378
71945
  },
71379
71946
  args: {
71380
71947
  file: { type: "positional", required: true, description: "Path or http(s) URL to the source/inspiration image" },
@@ -71404,7 +71971,11 @@ var scaffoldStaticAdCommand = defineCommand134({
71404
71971
  },
71405
71972
  "skip-font": {
71406
71973
  type: "boolean",
71407
- description: "Bake the copy into the picture instead of drawing it in the brand's font. Only for a brand with no font file; by default a font in src/brand/fonts/ draws the copy."
71974
+ description: "Bake the copy into the picture instead of drawing it in a real font. By default the copy is drawn in a font file from src/brand/fonts/, else the typeface tokens.json or BRAND.md names (loaded as a web font), else Inter."
71975
+ },
71976
+ "text-only": {
71977
+ type: "boolean",
71978
+ description: "A typographic ad: no picture is generated, the copy from prompt.json is drawn on the brand's colours in its fonts (near-zero credits). On by default when the reference pictures nothing but copy; --no-text-only renders a picture anyway."
71408
71979
  },
71409
71980
  takes: {
71410
71981
  type: "string",
@@ -71490,6 +72061,9 @@ var scaffoldStaticAdCommand = defineCommand134({
71490
72061
  const relative = (file) => file ? toCanvasRelative2(canvasDir, file) : void 0;
71491
72062
  const takes = args8.takes !== void 0 ? Number.parseInt(String(args8.takes), 10) : void 0;
71492
72063
  if (takes !== void 0 && !(takes >= 1 && takes <= 3)) return fail4("invalid_takes", "--takes must be 1, 2 or 3");
72064
+ const bakeText = args8["skip-font"] === true;
72065
+ const textOnly = args8["text-only"] === void 0 ? isTextForward(blueprint) : args8["text-only"] === true;
72066
+ const fonts = bakeText ? null : await brandFaces(workspaceRoot, kit, (file) => toCanvasRelative2(canvasDir, file));
71493
72067
  const opts = {
71494
72068
  genModel,
71495
72069
  imagePath: canvasImagePath,
@@ -71498,13 +72072,10 @@ var scaffoldStaticAdCommand = defineCommand134({
71498
72072
  aspectRatio: args8.aspect ? String(args8.aspect) : void 0,
71499
72073
  includeActorSheets: !args8["skip-actor-sheets"],
71500
72074
  placements,
71501
- brand: {
71502
- logoPath: relative(kit.logo),
71503
- headlineFontPath: relative(kit.headlineFont),
71504
- bodyFontPath: relative(kit.bodyFont)
71505
- },
71506
- bakeText: args8["skip-font"] === true,
71507
- takes
72075
+ brand: { logoPath: relative(kit.logo), ...fonts ? { fonts } : {} },
72076
+ bakeText,
72077
+ takes,
72078
+ textOnly
71508
72079
  };
71509
72080
  let canvas;
71510
72081
  let report;
@@ -71517,8 +72088,9 @@ var scaffoldStaticAdCommand = defineCommand134({
71517
72088
  for (const layout2 of staticAdCompositions(blueprint, opts)) {
71518
72089
  await writeStaticComposition(path26.resolve(canvasDir, layout2.dir), layout2.width, layout2.height);
71519
72090
  }
71520
- await writeFile9(path26.resolve(canvasDir, PLATE_SPEC_PATH), renderPlateSpec(annotated), "utf8");
71521
- await writeFile9(path26.resolve(canvasDir, COMPOSE_SPEC_PATH), renderComposeSpec(annotated, kit), "utf8");
72091
+ const specOptions = composeOptionsFor(canvas, annotated);
72092
+ await writeFile9(path26.resolve(canvasDir, PLATE_SPEC_PATH), renderPlateSpec(annotated, specOptions), "utf8");
72093
+ await writeFile9(path26.resolve(canvasDir, COMPOSE_SPEC_PATH), renderComposeSpec(annotated, kit, specOptions), "utf8");
71522
72094
  const validation = await validateScaffoldedStaticAd(canvas, path26.dirname(outPath));
71523
72095
  if (validation.blocking.length > 0) {
71524
72096
  process.stderr.write(
@@ -71590,18 +72162,16 @@ var scaffoldStaticAdCommand = defineCommand134({
71590
72162
  winning_mechanisms: report.winning_mechanisms,
71591
72163
  rebuild_the_mechanism: "`winning_mechanisms` is WHY the source ad works. Rewrite each one for our brand in prompt.json (a rhyme stays a rhyme in our words, a before/after stays a before/after of our product); never copy the source's literal copy.",
71592
72164
  assets_to_supply: report.elements,
71593
- text: report.text_mode === "composed" ? `The copy is DRAWN, not generated: every line of text_content in prompt.json (minus the logo) is set in the brand's fonts (${[kit.headlineFont, kit.bodyFont].filter(Boolean).map((f) => path26.basename(String(f))).join(" / ")}) where the layout puts it, with the CTA as a button in the brand's CTA colour. Give the call to action role "cta". Edit the copy in prompt.json; every run reads it again.` : args8["skip-font"] ? "The copy is baked into the picture (--skip-font). Re-read every word on every format before publishing." : 'The copy is baked into the picture because the brand has no font file in src/brand/fonts/. Add one (`baker brand fonts fetch "<family>"`) and re-scaffold to have it drawn exactly instead. Re-read every word before publishing.',
71594
- logo: kit.logo ? `The logo is drawn from ${path26.basename(kit.logo)}, never generated.` : "No brand logo in src/brand/logos/: fill the logo slot with the brand's real file, or the ad goes out unsigned. It is drawn by the composition, never generated.",
71595
- takes: `${report.takes} picture${report.takes === 1 ? "" : "s"} are rendered and ${report.takes > 1 ? "judged on a fixed rubric (mechanism, space for the offer, brand, artifacts, safe zones, text); the winner becomes the ad and the others stay in the run as alternates." : "used as they are."}`,
72165
+ mode: report.mode,
72166
+ summary: report.summary,
72167
+ text: staticTextNote(report, bakeText),
72168
+ fonts: report.fonts,
72169
+ in_scene_text: report.in_scene_text,
72170
+ logo: report.logo.why,
72171
+ takes: report.takes === 0 ? "No picture is rendered: a text-only ad costs no image credits." : report.takes === 1 ? "1 picture is rendered and used as it is (no judge)." : `${report.takes} pictures are rendered and judged (one idea, thumbnail legibility, mechanism, space for what is drawn, brand, artifacts, safe zones, text); the winner becomes the ad and the others stay in the run as alternates.`,
71596
72172
  actor_sheets: report.actor_sheets.length > 0 ? `Each animal hero (${report.actor_sheets.join(", ")}) is fused into a generated multi-view reference sheet (image_reference_sheet) that the render grounds on \u2014 so drop ONE clean photo at that hero's ingest and the sheet builds the consistent turnaround. Pass --skip-actor-sheets to ground on the lone photo instead. A person hero is cast from a company Avatar (--avatar <handle>), never a found photo.` : "none (no animal heroes detected, or --skip-actor-sheets). A person hero is cast from a company Avatar (--avatar <handle>), never a found photo.",
71597
- formats: `Delivers ${report.output_formats.join(", ")}, each at its exact size. The pictures render at ${report.aspect_ratio} (the nearest shape ${opts.genModel} renders); ${report.format_plan.map((f) => {
71598
- if (f.format === report.aspect_ratio) return `${f.format} is the picture itself`;
71599
- if (report.text_mode === "baked") {
71600
- return `${f.format} is re-framed from the chosen picture by Nano Banana Pro (one billed render; the copy is in the pixels, so it is never cropped)`;
71601
- }
71602
- return f.source === "crop" ? `${f.format} is a free crop of it` : `${f.format} extends the chosen picture to ${f.extendRatio} (one billed render) and crops the rest`;
71603
- }).join("; ")}.${report.text_mode === "composed" ? " Nothing re-paints the copy or the logo." : ""}${report.placements === null ? " Pass --placements meta_feed_stories|meta_all|linkedin|\u2026 for a platform's set." : ""}`,
71604
- note: "Populate as you go: for each [TODO] ingest slot, source its real asset and wire it into the slot right away \u2014 one at a time, not all sourced first then reconciled at the end. For any LOGO slot, use the company's committed brand logo from `src/brand/logos/` (per BRAND.md's Logos map) \u2014 do NOT re-fetch it with `baker images logo <domain>` when a brand logo exists, because the Brandfetch CDN often serves an OUTDATED mark and that is how an old logo ships on a live ad. When every slot is filled, `baker canvas validate` then `baker canvas run`. Running generates a billed image \u2014 it is not free."
72173
+ formats: staticFormatsNote(report, opts.genModel),
72174
+ note: `Populate as you go: for each [TODO] ingest slot, source its real asset and wire it into the slot right away, one at a time. The logo is optional and never a slot to fill: it is drawn only when prompt.json keeps a logo (a layout region of kind "logo" or an advertiser entry in brands_logos) and src/brand/logos/ has the real file; never fetch or draw a stand-in. When every slot is filled, \`baker canvas validate\` then \`baker canvas run\`.${report.takes > 0 ? " Running generates a billed image: it is not free." : " A text-only ad renders no picture and costs next to nothing."}`
71605
72175
  }
71606
72176
  },
71607
72177
  null,
@@ -74334,7 +74904,7 @@ import { readFileSync as readFileSync17 } from "fs";
74334
74904
  import path32 from "path";
74335
74905
  import { defineCommand as defineCommand144 } from "citty";
74336
74906
  import { parse as parseYaml } from "yaml";
74337
- var DESCRIPTION4 = `How each Baker creative is doing in the live Meta ads that show it: spend, impressions, CTR, CPC, results, cost per result, frequency and a fatigue verdict, next to the angle and hook its definition set out to test. Use it before briefing new creatives, to learn which angle to make next and which creatives to replace.
74907
+ var DESCRIPTION4 = `How each Baker creative is doing in the live Meta ads that show it: spend, impressions, CTR, CPC, results, cost per result, frequency and a fatigue verdict, next to the angle, hook, mechanism and dominant slot its definition set out to test. Use it before briefing new creatives, to learn which angle and mechanism to make next and which creatives to replace.
74338
74908
 
74339
74909
  Start here:
74340
74910
  baker creatives performance # every creative in an ad, last 30 days
@@ -74357,7 +74927,7 @@ var ARGS6 = {
74357
74927
  }
74358
74928
  };
74359
74929
  registerSchema({ command: "creatives.performance", description: DESCRIPTION4, args: ARGS6 });
74360
- var INSIGHT_KEYS = ["angle", "hook", "persona", "awareness", "hypothesis"];
74930
+ var INSIGHT_KEYS = ["angle", "hook", "mechanism", "dominantSlot", "persona", "awareness", "hypothesis"];
74361
74931
  var FRONTMATTER = /^---\r?\n([\s\S]*?)\r?\n---/;
74362
74932
  function insightValue(value2) {
74363
74933
  if (typeof value2 === "string") return value2.trim() || null;
@@ -74389,20 +74959,25 @@ function readLocalDefinition(slug) {
74389
74959
  return null;
74390
74960
  }
74391
74961
  }
74392
- function angleHint(creatives) {
74393
- const byAngle = /* @__PURE__ */ new Map();
74962
+ var COMPARED_FIELDS = [
74963
+ { key: "angle", label: "angle", advice: "Brief the next creative on the cheapest angle with a new hook." },
74964
+ { key: "mechanism", label: "mechanism", advice: "Brief the next creative on the cheapest mechanism." },
74965
+ { key: "dominantSlot", label: "dominant slot", advice: "Lead the next creative with the cheapest dominant slot." }
74966
+ ];
74967
+ function cheapestHint(creatives, { key, label, advice }) {
74968
+ const byValue = /* @__PURE__ */ new Map();
74394
74969
  for (const creative of creatives) {
74395
- const angle = creative.definition?.angle;
74396
- if (!angle || creative.costPerResult === null) continue;
74397
- const best = byAngle.get(angle);
74970
+ const value2 = creative.definition?.[key];
74971
+ if (!value2 || creative.costPerResult === null) continue;
74972
+ const best = byValue.get(value2);
74398
74973
  if (!best || (best.costPerResult ?? Number.POSITIVE_INFINITY) > creative.costPerResult)
74399
- byAngle.set(angle, creative);
74974
+ byValue.set(value2, creative);
74400
74975
  }
74401
- if (byAngle.size < 2) return null;
74402
- const ranked = [...byAngle.entries()].sort(([, a], [, b]) => (a.costPerResult ?? 0) - (b.costPerResult ?? 0)).map(
74403
- ([angle, creative]) => `"${angle}" ${creative.costPerResult}${creative.currency ? ` ${creative.currency}` : ""} (${creative.slug ?? creative.title})`
74976
+ if (byValue.size < 2) return null;
74977
+ const ranked = [...byValue.entries()].sort(([, a], [, b]) => (a.costPerResult ?? 0) - (b.costPerResult ?? 0)).map(
74978
+ ([value2, creative]) => `"${value2}" ${creative.costPerResult}${creative.currency ? ` ${creative.currency}` : ""} (${creative.slug ?? creative.title})`
74404
74979
  );
74405
- return `Cost per result by angle: ${ranked.join(", ")}. Brief the next creative on the cheapest angle with a new hook.`;
74980
+ return `Cost per result by ${label}: ${ranked.join(", ")}. ${advice}`;
74406
74981
  }
74407
74982
  function withDefinitions(response, readDefinition) {
74408
74983
  const creatives = response.data.creatives.map((creative) => {
@@ -74411,8 +74986,10 @@ function withDefinitions(response, readDefinition) {
74411
74986
  return definition ? { ...creative, definition } : creative;
74412
74987
  });
74413
74988
  const hints2 = [...response.hints ?? []];
74414
- const byAngle = angleHint(creatives);
74415
- if (byAngle) hints2.push(byAngle);
74989
+ for (const field of COMPARED_FIELDS) {
74990
+ const hint = cheapestHint(creatives, field);
74991
+ if (hint) hints2.push(hint);
74992
+ }
74416
74993
  const unnamed = creatives.filter((creative) => !creative.definition?.angle).map((c) => c.slug ?? c.title);
74417
74994
  if (unnamed.length > 0) {
74418
74995
  hints2.push(
@@ -86235,14 +86812,14 @@ var ANALYZERS = [
86235
86812
  const counts = {};
86236
86813
  for (const v of rounded) counts[v] = (counts[v] || 0) + 1;
86237
86814
  const maxCount = Math.max(...Object.values(counts));
86238
- const pct = maxCount / rounded.length;
86815
+ const pct2 = maxCount / rounded.length;
86239
86816
  const unique = [...new Set(rounded)].filter((v) => v > 0);
86240
- if (pct <= 0.6 || unique.length > 3) return [];
86817
+ if (pct2 <= 0.6 || unique.length > 3) return [];
86241
86818
  const dominant = Object.entries(counts).sort((a, b) => b[1] - a[1])[0]?.[0] ?? "?";
86242
86819
  return [
86243
86820
  {
86244
86821
  id: "monotonous-spacing",
86245
- snippet: `~${dominant}px used ${maxCount}/${rounded.length} times (${Math.round(pct * 100)}%)`,
86822
+ snippet: `~${dominant}px used ${maxCount}/${rounded.length} times (${Math.round(pct2 * 100)}%)`,
86246
86823
  file
86247
86824
  }
86248
86825
  ];