@koda-sl/baker-cli 0.319.0 → 0.321.0-dev.2d7a8f01b

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
@@ -67,7 +67,7 @@ import {
67
67
  validateCanvasDeep,
68
68
  withoutCastName,
69
69
  ytDlpBlockSignal
70
- } from "./chunk-WONWYE7B.js";
70
+ } from "./chunk-OLAB3JMW.js";
71
71
  import {
72
72
  csvOrJson,
73
73
  daysAgoIso,
@@ -14808,6 +14808,14 @@ var analyticsQueryRequestSchema = z31.object({
14808
14808
  * came for — a deal a CRM posted names the person and carries no cookie.
14809
14809
  */
14810
14810
  anonId: z31.string().max(120).optional(),
14811
+ /**
14812
+ * `stream` and `session`: one person by any key the store recognises them
14813
+ * by — a contact digest, a CRM id, a click id, a platform cookie — for the
14814
+ * rows a system posted about somebody no browser has named yet. A browser
14815
+ * wins when both are given.
14816
+ */
14817
+ personKind: z31.string().max(40).optional(),
14818
+ personValue: z31.string().max(255).optional(),
14811
14819
  /**
14812
14820
  * `people` only: keep only the visitors who converted.
14813
14821
  *
@@ -14844,9 +14852,9 @@ var analyticsQueryRequestSchema = z31.object({
14844
14852
  message: "The 'page' preset needs --path",
14845
14853
  path: ["path"]
14846
14854
  }).refine(
14847
- (value) => value.preset !== "session" || typeof value.sessionId === "string" || typeof value.anonId === "string",
14855
+ (value) => value.preset !== "session" || typeof value.sessionId === "string" || typeof value.anonId === "string" || typeof value.personValue === "string",
14848
14856
  {
14849
- message: "The 'session' preset needs --session or a browser id",
14857
+ message: "The 'session' preset needs --session, a browser id or a person key",
14850
14858
  path: ["sessionId"]
14851
14859
  }
14852
14860
  ).refine((value) => value.property === void 0 || typeof value.eventName === "string", {
@@ -14877,6 +14885,13 @@ var analyticsTotalsSchema = z31.object({
14877
14885
  * {@link convertingSessions}, and of `sessions`.
14878
14886
  */
14879
14887
  conversions: z31.number().int().nonnegative(),
14888
+ /**
14889
+ * Conversions in the window that no landing earned — by people none of the
14890
+ * company's pages saw. Not in `conversions`, which is a page metric; shown
14891
+ * beside it so a deal a CRM reported is never a silent zero. Zero under any
14892
+ * scope.
14893
+ */
14894
+ unplacedConversions: z31.number().int().nonnegative().default(0),
14880
14895
  /** Visits that produced at least one conversion. Never above `sessions`. */
14881
14896
  convertingSessions: z31.number().int().nonnegative(),
14882
14897
  /**
@@ -15369,8 +15384,14 @@ var analyticsConversionRowSchema = z31.object({
15369
15384
  * accident; saying so is what makes it findable.
15370
15385
  */
15371
15386
  countMode: z31.string(),
15372
- /** Counted the way each definition asked to be counted. */
15387
+ /** Counted the way each definition asked to be counted, and credited to a landing. */
15373
15388
  conversions: z31.number(),
15389
+ /**
15390
+ * Times it happened that no landing earned: by people none of the company's
15391
+ * pages saw, within 90 days. Zero under a landing or an ad scope, where the
15392
+ * gap would mean something else. Absent on a row built before it existed.
15393
+ */
15394
+ unplaced: z31.number().default(0),
15374
15395
  /** Distinct visits that produced it, never mode-adjusted. The gap between the two is the repeat behaviour. */
15375
15396
  convertingVisits: z31.number(),
15376
15397
  /** Null when it has never fired. */
@@ -30352,6 +30373,7 @@ function mergeStoredWithCounts(stored, reported) {
30352
30373
  // would make it indistinguishable from one nobody has named.
30353
30374
  conversions: counted?.conversions ?? 0,
30354
30375
  convertingVisits: counted?.convertingVisits ?? 0,
30376
+ unplaced: counted?.unplaced ?? 0,
30355
30377
  lastSeen: counted?.lastSeen ?? null,
30356
30378
  doors: counted?.doors ?? [],
30357
30379
  ...rows.some((row) => row.staged === true) ? { staged: true } : {}
@@ -41911,6 +41933,7 @@ function endCardCta(spec) {
41911
41933
  return declared.toLowerCase().replace(/[.!?]$/, "") === closing.replace(/[.!?]$/, "") ? null : declared;
41912
41934
  }
41913
41935
  var AD_CAST_ID = "customer";
41936
+ var AD_NARRATOR_ID = "ad_narrator";
41914
41937
  var AD_SET_ID = "set";
41915
41938
  function castedAvatarOnRoster(ad, roster) {
41916
41939
  if (ad.cast?.avatar) return null;
@@ -41966,9 +41989,9 @@ function adSpecCastElements(spec, avatar) {
41966
41989
  });
41967
41990
  return elements;
41968
41991
  }
41969
- function motionPrompt(beat, toCamera, isClosingCard, place, physics) {
41992
+ function motionPrompt(beat, toCamera, isClosingCard, place) {
41970
41993
  if (isClosingCard) return BRAND_PLATE;
41971
- const shot = `${beat.show}${place}${physics}`;
41994
+ const shot = `${beat.show}${place}`;
41972
41995
  if (toCamera) return shot;
41973
41996
  return `${shot} Nobody in frame is speaking: mouths CLOSED and STILL throughout, no talking, no lip movement. Whoever is in shot is looking, reacting or working \u2014 never addressing the camera.`;
41974
41997
  }
@@ -42016,11 +42039,9 @@ function beatsIntroducingSpeakerOffCamera(spec) {
42016
42039
  (beat, i) => lineIntroducesSpeaker(beat.say) && !beatToCamera(spec, beat, closesOnCard && i === last) ? [i] : []
42017
42040
  );
42018
42041
  }
42019
- function wantsNativeSpeech(spec, closesOnCard) {
42042
+ function beatSpeaksNatively(spec, beat, isClosingCard) {
42020
42043
  if (!spec.cast || spec.voiceover === false) return false;
42021
- const last = spec.beats.length - 1;
42022
- const speaking = spec.beats.filter((_, i) => !(closesOnCard && i === last));
42023
- return speaking.length > 0 && speaking.every((beat) => beatToCamera(spec, beat, false) && beat.cast !== false);
42044
+ return beatToCamera(spec, beat, isClosingCard) && beat.cast !== false;
42024
42045
  }
42025
42046
  function beatDialogue(spec, beat, window2) {
42026
42047
  if (spec.voiceover === false) return [];
@@ -42037,14 +42058,38 @@ function beatDialogue(spec, beat, window2) {
42037
42058
  // by definition never on camera — so every beat took the dubbed path: a clip generated
42038
42059
  // with one mouth and a separate ElevenLabs track laid over it. That is the mismatch
42039
42060
  // behind every "the mouth does not match" in this ad's history.
42040
- ...window2.nativeSpeech && !window2.isClosingCard ? { speaker: AD_CAST_ID } : {},
42041
- ...spec.voice?.description ? { voice_description: spec.voice.description } : {}
42061
+ //
42062
+ // A beat that is NOT hers names the ad's OWN narrator (`AD_NARRATOR_ID`), rather
42063
+ // than leaving the label off. A different speaker is what breaks the phrase, and a
42064
+ // phrase break is what keeps each beat its own clip: left unlabelled, the engine read
42065
+ // the cutaway as her narrating over an insert in one continuous take and merged the
42066
+ // beats around it into a single native read with the dubbed one laid on top.
42067
+ speaker: window2.nativeSpeech ? AD_CAST_ID : AD_NARRATOR_ID,
42068
+ // The narrator is cast from the ad's voice, and failing that from the CAST's own
42069
+ // description. Naming a speaker of our own cost this: the engine casts an unnamed
42070
+ // narrator by folding it into the lone presenter and reusing her casting, so the
42071
+ // closing line inherited her persona for free. Named, it is a speaker in nobody's
42072
+ // `cast`, and the engine's last-resort description for one of those is the literal
42073
+ // "<speaker> voice" — so an ad with an avatar, a closing card and no `voice`
42074
+ // (a perfectly ordinary spec) had its closing tagline cast off the words
42075
+ // "ad_narrator voice".
42076
+ ...narratorVoice(spec, window2.avatarVoice) ? { voice_description: narratorVoice(spec, window2.avatarVoice) } : {}
42042
42077
  }
42043
42078
  ];
42044
42079
  }
42045
- function voiceoverMode(spec, nativeSpeech) {
42080
+ function narratorVoice(spec, avatarVoice) {
42081
+ return spec.voice?.description?.trim() || avatarVoice?.trim() || spec.cast?.description?.trim() || void 0;
42082
+ }
42083
+ function nativeSpeechByBeat(spec, closesOnCard) {
42084
+ const last = spec.beats.length - 1;
42085
+ return spec.beats.map((beat, i) => beatSpeaksNatively(spec, beat, closesOnCard && i === last));
42086
+ }
42087
+ function voiceoverMode(spec, nativeByBeat, closesOnCard) {
42046
42088
  if (spec.voiceover === false) return "none";
42047
- return nativeSpeech ? "on_camera" : "voiceover";
42089
+ const spoken = closesOnCard ? nativeByBeat.slice(0, -1) : nativeByBeat;
42090
+ const native = spoken.filter(Boolean).length;
42091
+ if (native === 0) return "voiceover";
42092
+ return native === spoken.length ? "on_camera" : "mixed";
42048
42093
  }
42049
42094
  function castRole(spec) {
42050
42095
  if (!spec.cast) return void 0;
@@ -42061,7 +42106,8 @@ function adSpecToBlueprint(spec, opts = {}) {
42061
42106
  const currency = market ? CURRENCY_BY_MARKET[market.toLowerCase()] : void 0;
42062
42107
  const physics = frameRealismDirection({ currency, role: castRole(spec) });
42063
42108
  const closesOnCard = endCardWanted(spec);
42064
- const nativeSpeech = wantsNativeSpeech(spec, closesOnCard);
42109
+ const nativeByBeat = nativeSpeechByBeat(spec, closesOnCard);
42110
+ const spokenMode = voiceoverMode(spec, nativeByBeat, closesOnCard);
42065
42111
  let clock = 0;
42066
42112
  const scenes = spec.beats.map((beat, i) => {
42067
42113
  const isClosingCard = closesOnCard && i === total - 1;
@@ -42090,9 +42136,10 @@ function adSpecToBlueprint(spec, opts = {}) {
42090
42136
  start: Math.round(start * 100) / 100,
42091
42137
  end: Math.round(clock * 100) / 100,
42092
42138
  isClosingCard,
42093
- nativeSpeech
42139
+ nativeSpeech: nativeByBeat[i] === true,
42140
+ avatarVoice: opts.avatarVoice
42094
42141
  }),
42095
- motion_prompt: motionPrompt(beat, beatToCamera(spec, beat, isClosingCard), isClosingCard, place, physics),
42142
+ motion_prompt: motionPrompt(beat, beatToCamera(spec, beat, isClosingCard), isClosingCard, place),
42096
42143
  start_frame_prompt: isClosingCard ? BRAND_PLATE : `${beat.show}${place}${physics}`
42097
42144
  };
42098
42145
  });
@@ -42104,9 +42151,9 @@ function adSpecToBlueprint(spec, opts = {}) {
42104
42151
  // the script itself, so an ad that never named a place still says where it is set.
42105
42152
  ...market ? { market } : {},
42106
42153
  voiceover: {
42107
- mode: voiceoverMode(spec, nativeSpeech),
42154
+ mode: spokenMode,
42108
42155
  ...spokenVariety(spec, avatarAccent) ? { accent: spokenVariety(spec, avatarAccent) } : {},
42109
- ...spec.voice?.description ? { voice_description: spec.voice.description } : {}
42156
+ ...narratorVoice(spec, opts.avatarVoice) ? { voice_description: narratorVoice(spec, opts.avatarVoice) } : {}
42110
42157
  },
42111
42158
  ...spec.cast ? {
42112
42159
  cast: [
@@ -42136,15 +42183,10 @@ function beatsTooShortToPlay(spec) {
42136
42183
  // src/engine/scaffold/ad-brand-overlay.ts
42137
42184
  var markFallback = (name) => name.trim() ? ` onerror="this.replaceWith(Object.assign(document.createElement('span'),{className:this.className+' brand-wordmark',textContent:${escapeHtml2(JSON.stringify(name.trim()))}}))"` : "";
42138
42185
  var escapeHtml2 = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
42139
- function firstHex(palette) {
42140
- const hex = (palette ?? []).find((h) => /^#?[0-9a-fA-F]{6}$/.test(h.trim()));
42141
- if (!hex) return null;
42142
- const trimmed = hex.trim();
42143
- return trimmed.startsWith("#") ? trimmed : `#${trimmed}`;
42144
- }
42145
42186
  function buildAdBrandOverlay(opts) {
42146
- const { logo, cta, cardAtS, cardDurationS, totalS, accent } = opts;
42187
+ const { logo, cta, cardAtS, cardDurationS, totalS } = opts;
42147
42188
  if (!logo && !cta?.trim()) return "";
42189
+ const inkClass = opts.markIsLight ? " on-dark" : "";
42148
42190
  const parts = [
42149
42191
  "<!-- Brand layer, emitted by `baker canvas scaffold-ad`. The mark and the closing",
42150
42192
  " card are drawn HERE, by the composition \u2014 never generated as a picture, because",
@@ -42154,18 +42196,19 @@ function buildAdBrandOverlay(opts) {
42154
42196
  ];
42155
42197
  if (logo) {
42156
42198
  parts.push(
42157
- `<img class="clip brand-mark" src="${escapeHtml2(logo.file)}" alt="${escapeHtml2(logo.alt)}" data-start="0" data-dur="${totalS}"${markFallback(logo.alt)} />`
42199
+ `<img class="clip brand-mark${inkClass}" src="${escapeHtml2(logo.file)}" alt="${escapeHtml2(logo.alt)}" data-start="0" data-dur="${totalS}"${markFallback(logo.alt)} />`
42158
42200
  );
42159
42201
  }
42160
42202
  const cardBits = [];
42161
42203
  if (logo) {
42162
42204
  cardBits.push(
42163
- `<img class="endcard-logo" src="${escapeHtml2(logo.file)}" alt="${escapeHtml2(logo.alt)}"${markFallback(logo.alt)} />`
42205
+ `<img class="endcard-logo${inkClass}" src="${escapeHtml2(logo.file)}" alt="${escapeHtml2(logo.alt)}"${markFallback(logo.alt)} />`
42164
42206
  );
42165
42207
  }
42166
42208
  if (cta?.trim()) {
42167
- const style = accent ? ` style="background:${escapeHtml2(accent)}"` : "";
42168
- cardBits.push(`<div class="endcard-cta"${style}>${escapeHtml2(cta.trim())}</div>`);
42209
+ cardBits.push(
42210
+ `<div class="endcard-cta${opts.plateIsDark ? " on-dark-plate" : ""}">${escapeHtml2(cta.trim())}</div>`
42211
+ );
42169
42212
  }
42170
42213
  parts.push(
42171
42214
  `<div class="clip endcard" data-start="${cardAtS}" data-dur="${cardDurationS}" data-role="cta">`,
@@ -42182,6 +42225,8 @@ function brandLayerDriver() {
42182
42225
  " var els = Array.prototype.slice.call(document.querySelectorAll('.brand-mark, .endcard'));",
42183
42226
  " if (!els.length) return;",
42184
42227
  " els.forEach(function (el) { gsap.set(el, { visibility: 'hidden' }); });",
42228
+ " var card = document.querySelector('.endcard');",
42229
+ " var captions = document.getElementById('captions');",
42185
42230
  " function attach(tl) {",
42186
42231
  " if (!tl) return;",
42187
42232
  " els.forEach(function (el) {",
@@ -42190,6 +42235,18 @@ function brandLayerDriver() {
42190
42235
  " tl.set(el, { visibility: 'visible' }, at);",
42191
42236
  " if (dur > 0) tl.set(el, { visibility: 'hidden' }, at + dur);",
42192
42237
  " });",
42238
+ " // The closing card owns the frame it lands on. The caption track otherwise",
42239
+ " // keeps running underneath it, and what it captions there is the line spoken",
42240
+ " // OVER the card \u2014 the call to action, printed a second time in smaller letters",
42241
+ " // directly below the button that already says it. Hidden on the CONTAINER and",
42242
+ " // with `display`, because each caption group sets its own `visibility: visible`",
42243
+ " // and would win against anything set on its parent.",
42244
+ " if (card && captions) {",
42245
+ " var cardAt = parseFloat(card.getAttribute('data-start') || '0') || 0;",
42246
+ " var cardDur = parseFloat(card.getAttribute('data-dur') || '0') || 0;",
42247
+ " tl.set(captions, { display: 'none' }, cardAt);",
42248
+ " if (cardDur > 0) tl.set(captions, { display: '' }, cardAt + cardDur);",
42249
+ " }",
42193
42250
  " }",
42194
42251
  " var store = window.__timelines || {};",
42195
42252
  " var main = store.main;",
@@ -42214,11 +42271,12 @@ function adBrandOverlayCss() {
42214
42271
  " viewBox is wider than it is tall rendered clipped \u2014 the first real ad drew",
42215
42272
  " `greenlea`. Constraining the height and letting the width follow keeps any",
42216
42273
  " aspect ratio whole, and the max-width stops a very wide mark spanning the frame. */",
42217
- " /* A LIGHT panel behind the mark, always. A brand mark is drawn to sit on white",
42218
- " \u2014 this one is #1D5D47 on transparent \u2014 so a dark backing hid it on the corner",
42219
- " and the brand-coloured closing plate erased it completely: the end card came",
42220
- " out as an empty green frame with a single leaf floating in it. A near-white",
42221
- " panel is the one background every mark in a brand kit is designed against. */",
42274
+ " /* A LIGHT panel behind the mark by DEFAULT. A brand mark is usually drawn to sit",
42275
+ " on white \u2014 the first one here was #1D5D47 on transparent \u2014 so a dark backing hid",
42276
+ " it on the corner and the brand-coloured closing plate erased it completely: the",
42277
+ " end card came out as an empty green frame with a single leaf floating in it. A",
42278
+ " near-white panel is the background most marks in a brand kit are designed",
42279
+ " against; the ones that are not get `.on-dark` below. */",
42222
42280
  " .brand-mark { position: absolute; top: 90px; left: 56px; height: 52px; width: auto;",
42223
42281
  " max-width: 420px; object-fit: contain; padding: 14px 22px; border-radius: 18px;",
42224
42282
  " background: rgba(255,255,255,0.94);",
@@ -42232,6 +42290,14 @@ function adBrandOverlayCss() {
42232
42290
  " .endcard-logo { width: 62%; height: auto; max-height: 300px; object-fit: contain;",
42233
42291
  " padding: 46px 56px; border-radius: 40px; background: rgba(255,255,255,0.96);",
42234
42292
  " box-shadow: 0 10px 40px rgba(0,0,0,0.28); }",
42293
+ " /* A mark whose own ink is LIGHT gets the opposite panel. The near-white panel",
42294
+ " above is right for the mark a brand kit usually ships \u2014 drawn to sit on white",
42295
+ " \u2014 and it erases the one it does not: a client's white `logo-dark.svg` rendered",
42296
+ " as an empty white box in the corner, and again 300px tall on the closing card.",
42297
+ " Which one a mark is gets MEASURED off the file (`markInkIsLight`), never read",
42298
+ " off its name: `logo-dark` names the background it is for in one brand kit and",
42299
+ " the ink itself in the next. */",
42300
+ " .brand-mark.on-dark, .endcard-logo.on-dark { background: rgba(16,20,18,0.92); }",
42235
42301
  " /* The name, when the mark itself could not be drawn. */",
42236
42302
  " .brand-wordmark { display: inline-block; font-family: 'Arial Black', 'Helvetica Neue', Arial, sans-serif;",
42237
42303
  " font-weight: 900; color: #14231b; letter-spacing: -0.01em; }",
@@ -42246,10 +42312,17 @@ function adBrandOverlayCss() {
42246
42312
  " first attempt led with 'BrandFont', which the overlay composition defines and",
42247
42313
  " the caption composition does not: HyperFrames refuses an unmapped family and",
42248
42314
  " the whole render died with `No deterministic font mapping for: BrandFont`. */",
42315
+ " /* The button is the OPPOSITE of the plate it sits on. Filling it with the brand",
42316
+ " colour made it invisible \u2014 the plate is filled with that same colour, so the",
42317
+ " button was brand-on-brand and its white copy read as another caption line \u2014",
42318
+ " and a fixed near-black fill is that defect mirrored, on the black plate an ad",
42319
+ " with no palette gets. The plate carries the brand; the button carries the",
42320
+ " contrast. Light copy on the near-black default, dark copy on the light one. */",
42249
42321
  " .endcard-cta { font-family: 'Arial Black', 'Helvetica Neue', Arial, sans-serif;",
42250
42322
  " font-size: 64px; font-weight: 900; text-transform: uppercase;",
42251
42323
  " letter-spacing: 0.01em; color: #fff; padding: 28px 58px; border-radius: 56px;",
42252
- " max-width: 900px; line-height: 1.15; background: rgba(0,0,0,0.82); }"
42324
+ " max-width: 900px; line-height: 1.15; background: rgba(0,0,0,0.82); }",
42325
+ " .endcard-cta.on-dark-plate { background: rgba(255,255,255,0.96); color: #14231b; }"
42253
42326
  ].join("\n");
42254
42327
  }
42255
42328
  function injectAdBrandOverlay(indexHtml, overlayHtml) {
@@ -42266,6 +42339,7 @@ function injectAdBrandOverlay(indexHtml, overlayHtml) {
42266
42339
  // src/engine/scaffold/ad-brand-source.ts
42267
42340
  import { readdir as readdir8, readFile as readFile15 } from "fs/promises";
42268
42341
  import path22 from "path";
42342
+ import sharp from "sharp";
42269
42343
  var BRAND_DIR = "src/brand";
42270
42344
  function paletteFromBrandDoc(markdown) {
42271
42345
  const seen = /* @__PURE__ */ new Set();
@@ -42274,15 +42348,33 @@ function paletteFromBrandDoc(markdown) {
42274
42348
  const hex = `#${match[1].toLowerCase()}`;
42275
42349
  if (seen.has(hex)) continue;
42276
42350
  seen.add(hex);
42277
- const r = Number.parseInt(hex.slice(1, 3), 16);
42278
- const g = Number.parseInt(hex.slice(3, 5), 16);
42279
- const b = Number.parseInt(hex.slice(5, 7), 16);
42280
- const luma = 0.2126 * r + 0.7152 * g + 0.0722 * b;
42281
- if (luma > 236 || luma < 14) continue;
42351
+ const luma = hexLuma(hex);
42352
+ if (luma === null || luma > 236 || luma < 14) continue;
42282
42353
  out.push(hex);
42283
42354
  }
42284
42355
  return out;
42285
42356
  }
42357
+ function hexLuma(hex) {
42358
+ const m = hex.trim().match(/^#?([0-9a-fA-F]{6})$/);
42359
+ if (!m) return null;
42360
+ const v = m[1];
42361
+ const r = Number.parseInt(v.slice(0, 2), 16);
42362
+ const g = Number.parseInt(v.slice(2, 4), 16);
42363
+ const b = Number.parseInt(v.slice(4, 6), 16);
42364
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
42365
+ }
42366
+ function firstHex(palette) {
42367
+ const hex = (palette ?? []).find((h) => /^#?[0-9a-fA-F]{6}$/.test(h.trim()));
42368
+ if (!hex) return null;
42369
+ const trimmed = hex.trim();
42370
+ return trimmed.startsWith("#") ? trimmed : `#${trimmed}`;
42371
+ }
42372
+ function plateIsDark(palette) {
42373
+ const luma = hexLuma(firstHex(palette) ?? "");
42374
+ return luma === null || luma < PLATE_DARK_LUMA;
42375
+ }
42376
+ var PLATE_DARK_LUMA = 140;
42377
+ var MARK_VARIANT_NAMES = /\b(mono|white|black|blanco|negro|negra|dark|light|oscuro|oscura|claro|clara|negativo|negativa|positivo|inverse|inverso|inversa|inv|reverse|reversed|alt|outline|transparent|watermark|icon|favicon|mark-only|isotipo|isotype)\b/;
42286
42378
  function pickBrandMark(files) {
42287
42379
  const usable = files.filter((f) => /\.(svg|png|webp)$/i.test(f));
42288
42380
  if (usable.length === 0) return null;
@@ -42291,10 +42383,10 @@ function pickBrandMark(files) {
42291
42383
  let s = 0;
42292
42384
  if (/\.(png|webp)$/.test(name)) s += 1;
42293
42385
  if (/\b(logo|wordmark|lockup)\b/.test(name)) s += 2;
42294
- if (/\b(mono|white|black|inverse|icon|favicon|mark-only|isotipo)\b/.test(name)) s -= 3;
42386
+ if (MARK_VARIANT_NAMES.test(name)) s -= 3;
42295
42387
  return s;
42296
42388
  };
42297
- return [...usable].sort((a, b) => score(b) - score(a) || a.localeCompare(b))[0] ?? null;
42389
+ return [...usable].sort((a, b) => score(b) - score(a) || a.length - b.length || a.localeCompare(b))[0] ?? null;
42298
42390
  }
42299
42391
  async function readBrandFromWorkspace(root = ".") {
42300
42392
  const found = {};
@@ -42349,6 +42441,27 @@ async function markDirectoryIsReal(dir) {
42349
42441
  }
42350
42442
  return true;
42351
42443
  }
42444
+ var INK_ALPHA = 128;
42445
+ function inkLuma(rgba) {
42446
+ let sum = 0;
42447
+ let count = 0;
42448
+ for (let i = 0; i + 3 < rgba.length; i += 4) {
42449
+ if (rgba[i + 3] < INK_ALPHA) continue;
42450
+ sum += 0.2126 * rgba[i] + 0.7152 * rgba[i + 1] + 0.0722 * rgba[i + 2];
42451
+ count += 1;
42452
+ }
42453
+ return count === 0 ? null : sum / count;
42454
+ }
42455
+ var LIGHT_INK_LUMA = 170;
42456
+ async function markInkIsLight(markPath) {
42457
+ try {
42458
+ const { data } = await sharp(markPath, { density: 144 }).resize(96, 96, { fit: "inside", withoutEnlargement: true }).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
42459
+ const luma = inkLuma(data);
42460
+ return luma !== null && luma > LIGHT_INK_LUMA;
42461
+ } catch {
42462
+ return false;
42463
+ }
42464
+ }
42352
42465
 
42353
42466
  // src/commands/canvas/composition-path.ts
42354
42467
  import { existsSync as existsSync5 } from "fs";
@@ -42412,6 +42525,11 @@ registerSchema({
42412
42525
  }
42413
42526
  }
42414
42527
  });
42528
+ function avatarVoiceBrief(avatar) {
42529
+ if (!avatar) return void 0;
42530
+ const parts = [avatar.subjectDescription, avatar.profile?.persona, avatar.profile?.speech].map((p) => p?.trim()).filter((p) => Boolean(p));
42531
+ return parts.length > 0 ? parts.join("; ") : void 0;
42532
+ }
42415
42533
  var scaffoldAdCommand = defineCommand114({
42416
42534
  meta: {
42417
42535
  name: "scaffold-ad",
@@ -42654,7 +42772,12 @@ var scaffoldAdCommand = defineCommand114({
42654
42772
  spec.data.cast = { ...spec.data.cast, description: withoutCastName(spec.data.cast.description, names) };
42655
42773
  }
42656
42774
  }
42657
- const blueprint = adSpecToBlueprint(spec.data, { avatarAccent: avatar?.profile?.accent, videoModel: chosenVideoModel });
42775
+ const avatarVoice = avatarVoiceBrief(avatar);
42776
+ const blueprint = adSpecToBlueprint(spec.data, {
42777
+ avatarAccent: avatar?.profile?.accent,
42778
+ videoModel: chosenVideoModel,
42779
+ ...avatarVoice ? { avatarVoice } : {}
42780
+ });
42658
42781
  const slug = args4.slug ?? path24.basename(specPath).replace(/\.[^.]+$/, "");
42659
42782
  const outPath = args4.out ?? (args4.slug ? path24.join("src/creatives", slug, `${slug}.canvas.json`) : path24.join(path24.dirname(specPath), `${slug}.canvas.json`));
42660
42783
  const outDir = path24.dirname(outPath);
@@ -42704,6 +42827,19 @@ var scaffoldAdCommand = defineCommand114({
42704
42827
  // and rejected — it would swap the voice that matches the mouth and the directed
42705
42828
  // region for one cast off an English stock list. Only the on-camera voice; a
42706
42829
  // voiceover read has no rendered voice to keep and is still cast.
42830
+ //
42831
+ // The KNOWN RISK this carries, recorded here because it is decided here: keeping the
42832
+ // rendered voice also means no pass unifies her beats, and each beat is its own
42833
+ // generation, so nothing guarantees two of them sound like the same person. The
42834
+ // reference image fixes the face; nothing fixes the voice. Measured on a finished
42835
+ // three-beat ad (her, a cutaway, her): median speaking pitch 210.5 Hz and 216.2 Hz,
42836
+ // near-identical spreads — the same voice as far as pitch can say — and the two
42837
+ // extracts were then listened to side by side and judged to be the same person. Not
42838
+ // proof for every ad: one listener, one ad, and pitch is one dimension of a voice.
42839
+ //
42840
+ // If it does show up, the fix is not to drop `keepRenderedVoice` — that brings back
42841
+ // the stock voice this exists to avoid. It is to convert the LATER extracts onto the
42842
+ // first beat of the same speaker, which keeps a voice the clip really produced.
42707
42843
  ...avatar ? { keepRenderedVoice: true } : {},
42708
42844
  ...spec.data.voiceover === false ? { staticTranscriptPath: path24.relative(outDir, transcriptPath) } : {}
42709
42845
  };
@@ -42711,11 +42847,13 @@ var scaffoldAdCommand = defineCommand114({
42711
42847
  const renderNode4 = canvas.nodes.find((n) => n.type === "hyperframe_render");
42712
42848
  const renderedDir = renderNode4 ? path24.join(outDir, String(renderNode4.params?.composition ?? "")) : null;
42713
42849
  let staged = null;
42850
+ let markIsLight = false;
42714
42851
  if (logoPath && renderedDir) {
42715
42852
  const ext = path24.extname(logoPath) || ".png";
42716
42853
  try {
42717
42854
  await copyFile(logoPath, path24.join(renderedDir, `brand-mark${ext}`));
42718
42855
  staged = { file: `brand-mark${ext}`, alt: spec.data.brand?.name ?? "brand" };
42856
+ markIsLight = await markInkIsLight(logoPath);
42719
42857
  } catch {
42720
42858
  }
42721
42859
  }
@@ -42726,7 +42864,10 @@ var scaffoldAdCommand = defineCommand114({
42726
42864
  cardAtS: closing.start_s,
42727
42865
  cardDurationS: closing.duration_s,
42728
42866
  totalS: blueprint.estimated_duration_s,
42729
- accent: firstHex(spec.data.brand?.palette)
42867
+ markIsLight,
42868
+ // The card's button is coloured against the plate the engine will fill from
42869
+ // this same palette — including the black plate an ad with no palette gets.
42870
+ plateIsDark: plateIsDark(spec.data.brand?.palette)
42730
42871
  }) : "";
42731
42872
  if (overlayHtml && renderedDir) {
42732
42873
  const indexPath = path24.join(renderedDir, "index.html");
@@ -42801,7 +42942,7 @@ var scaffoldAdCommand = defineCommand114({
42801
42942
  // ad with no palette and no mark anywhere in 28 seconds.
42802
42943
  `This spec has no ${!spec.data.brand?.palette?.length ? "`brand.palette`" : ""}${!spec.data.brand?.palette?.length && !spec.data.brand?.logo ? " and no " : ""}${!spec.data.brand?.logo ? "`brand.logo`" : ""}. Read src/brand/BRAND.md and re-run with them \u2014 a name alone leaves the ad in someone else's clothes.`
42803
42944
  ],
42804
- "`brand.palette` dresses the caption type and the closing button; `brand.logo` is the repo path to the mark, and it is DRAWN by the composition \u2014 top-left throughout and large on the closing card \u2014 never generated as a picture, so it cannot come back garbled. Give it whenever the client has one."
42945
+ "`brand.palette` colours the closing plate the card sits on; `brand.logo` is the repo path to the mark, and it is DRAWN by the composition \u2014 top-left throughout and large on the closing card \u2014 never generated as a picture, so it cannot come back garbled. Give it whenever the client has one."
42805
42946
  ]
42806
42947
  });
42807
42948
  }
@@ -51301,9 +51442,9 @@ import { defineCommand as defineCommand169 } from "citty";
51301
51442
  import { defineCommand as defineCommand143 } from "citty";
51302
51443
 
51303
51444
  // src/lib/image/crop-sprite.ts
51304
- import sharp from "sharp";
51445
+ import sharp2 from "sharp";
51305
51446
  function cropSprite(input, region) {
51306
- return sharp(input).extract({
51447
+ return sharp2(input).extract({
51307
51448
  left: Math.round(region.x),
51308
51449
  top: Math.round(region.y),
51309
51450
  width: Math.round(region.width),
@@ -53563,7 +53704,7 @@ function solidifyEdges(data, saturationBoost = 0.25) {
53563
53704
  }
53564
53705
 
53565
53706
  // src/lib/image/image-processor.ts
53566
- import sharp2 from "sharp";
53707
+ import sharp3 from "sharp";
53567
53708
  function hasGradient(data) {
53568
53709
  const uniqueColors = /* @__PURE__ */ new Set();
53569
53710
  for (let i = 0; i < data.length; i += 4) {
@@ -53595,10 +53736,10 @@ function createSvgRecolorer(target, palette) {
53595
53736
  async function processGradient(data, info) {
53596
53737
  if (!hasGradient(data)) return { data, info };
53597
53738
  const solidified = solidifyEdges(data, 0.1);
53598
- const blurred = await sharp2(solidified, {
53739
+ const blurred = await sharp3(solidified, {
53599
53740
  raw: { channels: 4, width: info.width, height: info.height }
53600
53741
  }).png().blur(0.3).toBuffer();
53601
- const result = await sharp2(blurred).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
53742
+ const result = await sharp3(blurred).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
53602
53743
  return { data: result.data, info: result.info };
53603
53744
  }
53604
53745
  function isSvgBuffer(buffer) {
@@ -53607,13 +53748,13 @@ function isSvgBuffer(buffer) {
53607
53748
  }
53608
53749
  async function processInternal(inputBuffer, isSVG, options) {
53609
53750
  const stages = [];
53610
- const metadata = await sharp2(inputBuffer).metadata();
53751
+ const metadata = await sharp3(inputBuffer).metadata();
53611
53752
  let alreadyTransparent = false;
53612
53753
  if (metadata.hasAlpha) {
53613
- const { data: alphaData } = await sharp2(inputBuffer).raw().toBuffer({ resolveWithObject: true });
53754
+ const { data: alphaData } = await sharp3(inputBuffer).raw().toBuffer({ resolveWithObject: true });
53614
53755
  alreadyTransparent = hasTransparency(alphaData, 0.05);
53615
53756
  }
53616
- let { data: processedData, info } = await sharp2(inputBuffer).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
53757
+ let { data: processedData, info } = await sharp3(inputBuffer).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
53617
53758
  if (options.color) {
53618
53759
  stages.push("recolor");
53619
53760
  processedData = applyQuantization(processedData, 8);
@@ -53684,7 +53825,7 @@ async function processInternal(inputBuffer, isSVG, options) {
53684
53825
  processedData = result.data;
53685
53826
  info = result.info;
53686
53827
  }
53687
- let pipeline2 = sharp2(processedData, {
53828
+ let pipeline2 = sharp3(processedData, {
53688
53829
  raw: { channels: 4, width: info.width, height: info.height }
53689
53830
  }).png();
53690
53831
  if (options.shrinkToContent) {
@@ -53697,12 +53838,12 @@ async function processInternal(inputBuffer, isSVG, options) {
53697
53838
  function applyResize(buffer, resize) {
53698
53839
  const transparent = { r: 0, g: 0, b: 0, alpha: 0 };
53699
53840
  if (resize.size) {
53700
- return sharp2(buffer).resize(resize.size.width, resize.size.height, {
53841
+ return sharp3(buffer).resize(resize.size.width, resize.size.height, {
53701
53842
  fit: resize.fit ?? "contain",
53702
53843
  background: transparent
53703
53844
  }).toBuffer();
53704
53845
  }
53705
- return sharp2(buffer).resize(resize.width ?? null, resize.height ?? null, { fit: "inside", withoutEnlargement: false }).toBuffer();
53846
+ return sharp3(buffer).resize(resize.width ?? null, resize.height ?? null, { fit: "inside", withoutEnlargement: false }).toBuffer();
53706
53847
  }
53707
53848
  async function processImage(inputBuffer, options = {}) {
53708
53849
  const stages = [];
@@ -53711,7 +53852,7 @@ async function processImage(inputBuffer, options = {}) {
53711
53852
  if (options.resize) {
53712
53853
  stages.push("resize");
53713
53854
  const resized = await applyResize(inputBuffer, options.resize);
53714
- const meta3 = await sharp2(resized).metadata();
53855
+ const meta3 = await sharp3(resized).metadata();
53715
53856
  return {
53716
53857
  buffer: resized,
53717
53858
  format: "png",
@@ -53720,7 +53861,7 @@ async function processImage(inputBuffer, options = {}) {
53720
53861
  stages
53721
53862
  };
53722
53863
  }
53723
- const meta2 = await sharp2(inputBuffer).metadata();
53864
+ const meta2 = await sharp3(inputBuffer).metadata();
53724
53865
  return {
53725
53866
  buffer: inputBuffer,
53726
53867
  format: "svg",
@@ -53732,7 +53873,7 @@ async function processImage(inputBuffer, options = {}) {
53732
53873
  let workingBuffer = inputBuffer;
53733
53874
  if (isSVG) {
53734
53875
  stages.push("rasterize-svg");
53735
- workingBuffer = await sharp2(inputBuffer).png({ compressionLevel: 0, force: true, palette: false, quality: 100 }).toBuffer();
53876
+ workingBuffer = await sharp3(inputBuffer).png({ compressionLevel: 0, force: true, palette: false, quality: 100 }).toBuffer();
53736
53877
  }
53737
53878
  const pass = await processInternal(workingBuffer, isSVG, options);
53738
53879
  stages.push(...pass.stages);
@@ -53741,7 +53882,7 @@ async function processImage(inputBuffer, options = {}) {
53741
53882
  stages.push("resize");
53742
53883
  processed = await applyResize(processed, options.resize);
53743
53884
  }
53744
- const meta = await sharp2(processed).metadata();
53885
+ const meta = await sharp3(processed).metadata();
53745
53886
  return {
53746
53887
  buffer: processed,
53747
53888
  format: "png",
@@ -57360,7 +57501,7 @@ function routeKey(route) {
57360
57501
  }
57361
57502
 
57362
57503
  // src/engine/landing-library/fidelity.ts
57363
- import sharp3 from "sharp";
57504
+ import sharp4 from "sharp";
57364
57505
  var COMPARISON_SIZE = 32;
57365
57506
  function pixelSimilarity(a, b) {
57366
57507
  const length = Math.min(a.length, b.length);
@@ -57372,7 +57513,7 @@ function pixelSimilarity(a, b) {
57372
57513
  return 1 - total / (length * 255);
57373
57514
  }
57374
57515
  async function scoreFidelity(live, rendered) {
57375
- const [liveMeta, renderedMeta] = await Promise.all([sharp3(live).metadata(), sharp3(rendered).metadata()]);
57516
+ const [liveMeta, renderedMeta] = await Promise.all([sharp4(live).metadata(), sharp4(rendered).metadata()]);
57376
57517
  const liveSize = { width: liveMeta.width ?? 0, height: liveMeta.height ?? 0 };
57377
57518
  const renderedSize = { width: renderedMeta.width ?? 0, height: renderedMeta.height ?? 0 };
57378
57519
  const [livePixels, renderedPixels] = await Promise.all([toGreyGrid(live), toGreyGrid(rendered)]);
@@ -57386,7 +57527,7 @@ async function scoreFidelity(live, rendered) {
57386
57527
  };
57387
57528
  }
57388
57529
  async function toGreyGrid(image) {
57389
- const raw = await sharp3(image).greyscale().resize(COMPARISON_SIZE, COMPARISON_SIZE, { fit: "fill" }).raw().toBuffer();
57530
+ const raw = await sharp4(image).greyscale().resize(COMPARISON_SIZE, COMPARISON_SIZE, { fit: "fill" }).raw().toBuffer();
57390
57531
  return new Uint8Array(raw);
57391
57532
  }
57392
57533
 
@@ -57598,7 +57739,7 @@ var inPageCollectMotion = (selector) => {
57598
57739
  };
57599
57740
 
57600
57741
  // src/engine/landing-library/motionTake.ts
57601
- import sharp4 from "sharp";
57742
+ import sharp5 from "sharp";
57602
57743
 
57603
57744
  // src/engine/landing-library/certificate.ts
57604
57745
  function formatDate(unixSeconds) {
@@ -58265,8 +58406,8 @@ async function hideFloatingFurniture(page, selector) {
58265
58406
  }, selector);
58266
58407
  }
58267
58408
  async function composeFilmstrip(frames) {
58268
- const scaled = await Promise.all(frames.map((frame) => sharp4(frame).resize({ width: FRAME_WIDTH }).png().toBuffer()));
58269
- const first = await sharp4(scaled[0]).metadata();
58409
+ const scaled = await Promise.all(frames.map((frame) => sharp5(frame).resize({ width: FRAME_WIDTH }).png().toBuffer()));
58410
+ const first = await sharp5(scaled[0]).metadata();
58270
58411
  const frameHeight = first.height ?? 300;
58271
58412
  const cellHeight = frameHeight + LABEL_HEIGHT;
58272
58413
  const rows = Math.ceil(scaled.length / GRID_COLUMNS);
@@ -58285,7 +58426,7 @@ async function composeFilmstrip(frames) {
58285
58426
  { input: frame, left: column * FRAME_WIDTH, top: row * cellHeight + LABEL_HEIGHT }
58286
58427
  ];
58287
58428
  });
58288
- return await sharp4({
58429
+ return await sharp5({
58289
58430
  create: {
58290
58431
  width,
58291
58432
  height: cellHeight * rows,
@@ -58474,12 +58615,12 @@ var DEFAULT_SEGMENT_OPTIONS = {
58474
58615
  };
58475
58616
 
58476
58617
  // src/engine/landing-library/visualHash.ts
58477
- import sharp5 from "sharp";
58618
+ import sharp6 from "sharp";
58478
58619
  var HASH_WIDTH = 9;
58479
58620
  var HASH_HEIGHT = 8;
58480
58621
  async function perceptualHash(image) {
58481
58622
  try {
58482
- const raw = await sharp5(image).greyscale().resize(HASH_WIDTH, HASH_HEIGHT, { fit: "fill" }).raw().toBuffer();
58623
+ const raw = await sharp6(image).greyscale().resize(HASH_WIDTH, HASH_HEIGHT, { fit: "fill" }).raw().toBuffer();
58483
58624
  return bitsToHex(rowGradientBits(new Uint8Array(raw)));
58484
58625
  } catch {
58485
58626
  return null;
@@ -58993,7 +59134,7 @@ import { defineCommand as defineCommand176 } from "citty";
58993
59134
  // src/commands/landing/inspiration/shot.ts
58994
59135
  import { mkdir as mkdir13, writeFile as writeFile18 } from "fs/promises";
58995
59136
  import path42 from "path";
58996
- import sharp6 from "sharp";
59137
+ import sharp7 from "sharp";
58997
59138
  var READABLE_SHOT = {
58998
59139
  maxWidth: 1440,
58999
59140
  maxHeight: 4e3,
@@ -59002,7 +59143,7 @@ var READABLE_SHOT = {
59002
59143
  };
59003
59144
  async function toReadableShot(original) {
59004
59145
  try {
59005
- return await sharp6(original).resize({
59146
+ return await sharp7(original).resize({
59006
59147
  width: READABLE_SHOT.maxWidth,
59007
59148
  height: READABLE_SHOT.maxHeight,
59008
59149
  fit: "inside",