@koda-sl/baker-cli 0.320.0 → 0.321.0

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-WHUKLAGW.js";
71
71
  import {
72
72
  csvOrJson,
73
73
  daysAgoIso,
@@ -41933,6 +41933,7 @@ function endCardCta(spec) {
41933
41933
  return declared.toLowerCase().replace(/[.!?]$/, "") === closing.replace(/[.!?]$/, "") ? null : declared;
41934
41934
  }
41935
41935
  var AD_CAST_ID = "customer";
41936
+ var AD_NARRATOR_ID = "ad_narrator";
41936
41937
  var AD_SET_ID = "set";
41937
41938
  function castedAvatarOnRoster(ad, roster) {
41938
41939
  if (ad.cast?.avatar) return null;
@@ -41988,9 +41989,9 @@ function adSpecCastElements(spec, avatar) {
41988
41989
  });
41989
41990
  return elements;
41990
41991
  }
41991
- function motionPrompt(beat, toCamera, isClosingCard, place, physics) {
41992
+ function motionPrompt(beat, toCamera, isClosingCard, place) {
41992
41993
  if (isClosingCard) return BRAND_PLATE;
41993
- const shot = `${beat.show}${place}${physics}`;
41994
+ const shot = `${beat.show}${place}`;
41994
41995
  if (toCamera) return shot;
41995
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.`;
41996
41997
  }
@@ -42038,11 +42039,9 @@ function beatsIntroducingSpeakerOffCamera(spec) {
42038
42039
  (beat, i) => lineIntroducesSpeaker(beat.say) && !beatToCamera(spec, beat, closesOnCard && i === last) ? [i] : []
42039
42040
  );
42040
42041
  }
42041
- function wantsNativeSpeech(spec, closesOnCard) {
42042
+ function beatSpeaksNatively(spec, beat, isClosingCard) {
42042
42043
  if (!spec.cast || spec.voiceover === false) return false;
42043
- const last = spec.beats.length - 1;
42044
- const speaking = spec.beats.filter((_, i) => !(closesOnCard && i === last));
42045
- return speaking.length > 0 && speaking.every((beat) => beatToCamera(spec, beat, false) && beat.cast !== false);
42044
+ return beatToCamera(spec, beat, isClosingCard) && beat.cast !== false;
42046
42045
  }
42047
42046
  function beatDialogue(spec, beat, window2) {
42048
42047
  if (spec.voiceover === false) return [];
@@ -42059,14 +42058,38 @@ function beatDialogue(spec, beat, window2) {
42059
42058
  // by definition never on camera — so every beat took the dubbed path: a clip generated
42060
42059
  // with one mouth and a separate ElevenLabs track laid over it. That is the mismatch
42061
42060
  // behind every "the mouth does not match" in this ad's history.
42062
- ...window2.nativeSpeech && !window2.isClosingCard ? { speaker: AD_CAST_ID } : {},
42063
- ...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) } : {}
42064
42077
  }
42065
42078
  ];
42066
42079
  }
42067
- 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) {
42068
42088
  if (spec.voiceover === false) return "none";
42069
- 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";
42070
42093
  }
42071
42094
  function castRole(spec) {
42072
42095
  if (!spec.cast) return void 0;
@@ -42083,7 +42106,8 @@ function adSpecToBlueprint(spec, opts = {}) {
42083
42106
  const currency = market ? CURRENCY_BY_MARKET[market.toLowerCase()] : void 0;
42084
42107
  const physics = frameRealismDirection({ currency, role: castRole(spec) });
42085
42108
  const closesOnCard = endCardWanted(spec);
42086
- const nativeSpeech = wantsNativeSpeech(spec, closesOnCard);
42109
+ const nativeByBeat = nativeSpeechByBeat(spec, closesOnCard);
42110
+ const spokenMode = voiceoverMode(spec, nativeByBeat, closesOnCard);
42087
42111
  let clock = 0;
42088
42112
  const scenes = spec.beats.map((beat, i) => {
42089
42113
  const isClosingCard = closesOnCard && i === total - 1;
@@ -42112,9 +42136,10 @@ function adSpecToBlueprint(spec, opts = {}) {
42112
42136
  start: Math.round(start * 100) / 100,
42113
42137
  end: Math.round(clock * 100) / 100,
42114
42138
  isClosingCard,
42115
- nativeSpeech
42139
+ nativeSpeech: nativeByBeat[i] === true,
42140
+ avatarVoice: opts.avatarVoice
42116
42141
  }),
42117
- motion_prompt: motionPrompt(beat, beatToCamera(spec, beat, isClosingCard), isClosingCard, place, physics),
42142
+ motion_prompt: motionPrompt(beat, beatToCamera(spec, beat, isClosingCard), isClosingCard, place),
42118
42143
  start_frame_prompt: isClosingCard ? BRAND_PLATE : `${beat.show}${place}${physics}`
42119
42144
  };
42120
42145
  });
@@ -42126,9 +42151,9 @@ function adSpecToBlueprint(spec, opts = {}) {
42126
42151
  // the script itself, so an ad that never named a place still says where it is set.
42127
42152
  ...market ? { market } : {},
42128
42153
  voiceover: {
42129
- mode: voiceoverMode(spec, nativeSpeech),
42154
+ mode: spokenMode,
42130
42155
  ...spokenVariety(spec, avatarAccent) ? { accent: spokenVariety(spec, avatarAccent) } : {},
42131
- ...spec.voice?.description ? { voice_description: spec.voice.description } : {}
42156
+ ...narratorVoice(spec, opts.avatarVoice) ? { voice_description: narratorVoice(spec, opts.avatarVoice) } : {}
42132
42157
  },
42133
42158
  ...spec.cast ? {
42134
42159
  cast: [
@@ -42158,15 +42183,10 @@ function beatsTooShortToPlay(spec) {
42158
42183
  // src/engine/scaffold/ad-brand-overlay.ts
42159
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()))}}))"` : "";
42160
42185
  var escapeHtml2 = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
42161
- function firstHex(palette) {
42162
- const hex = (palette ?? []).find((h) => /^#?[0-9a-fA-F]{6}$/.test(h.trim()));
42163
- if (!hex) return null;
42164
- const trimmed = hex.trim();
42165
- return trimmed.startsWith("#") ? trimmed : `#${trimmed}`;
42166
- }
42167
42186
  function buildAdBrandOverlay(opts) {
42168
- const { logo, cta, cardAtS, cardDurationS, totalS, accent } = opts;
42187
+ const { logo, cta, cardAtS, cardDurationS, totalS } = opts;
42169
42188
  if (!logo && !cta?.trim()) return "";
42189
+ const inkClass = opts.markIsLight ? " on-dark" : "";
42170
42190
  const parts = [
42171
42191
  "<!-- Brand layer, emitted by `baker canvas scaffold-ad`. The mark and the closing",
42172
42192
  " card are drawn HERE, by the composition \u2014 never generated as a picture, because",
@@ -42176,18 +42196,19 @@ function buildAdBrandOverlay(opts) {
42176
42196
  ];
42177
42197
  if (logo) {
42178
42198
  parts.push(
42179
- `<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)} />`
42180
42200
  );
42181
42201
  }
42182
42202
  const cardBits = [];
42183
42203
  if (logo) {
42184
42204
  cardBits.push(
42185
- `<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)} />`
42186
42206
  );
42187
42207
  }
42188
42208
  if (cta?.trim()) {
42189
- const style = accent ? ` style="background:${escapeHtml2(accent)}"` : "";
42190
- 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
+ );
42191
42212
  }
42192
42213
  parts.push(
42193
42214
  `<div class="clip endcard" data-start="${cardAtS}" data-dur="${cardDurationS}" data-role="cta">`,
@@ -42204,6 +42225,8 @@ function brandLayerDriver() {
42204
42225
  " var els = Array.prototype.slice.call(document.querySelectorAll('.brand-mark, .endcard'));",
42205
42226
  " if (!els.length) return;",
42206
42227
  " els.forEach(function (el) { gsap.set(el, { visibility: 'hidden' }); });",
42228
+ " var card = document.querySelector('.endcard');",
42229
+ " var captions = document.getElementById('captions');",
42207
42230
  " function attach(tl) {",
42208
42231
  " if (!tl) return;",
42209
42232
  " els.forEach(function (el) {",
@@ -42212,6 +42235,18 @@ function brandLayerDriver() {
42212
42235
  " tl.set(el, { visibility: 'visible' }, at);",
42213
42236
  " if (dur > 0) tl.set(el, { visibility: 'hidden' }, at + dur);",
42214
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
+ " }",
42215
42250
  " }",
42216
42251
  " var store = window.__timelines || {};",
42217
42252
  " var main = store.main;",
@@ -42236,11 +42271,12 @@ function adBrandOverlayCss() {
42236
42271
  " viewBox is wider than it is tall rendered clipped \u2014 the first real ad drew",
42237
42272
  " `greenlea`. Constraining the height and letting the width follow keeps any",
42238
42273
  " aspect ratio whole, and the max-width stops a very wide mark spanning the frame. */",
42239
- " /* A LIGHT panel behind the mark, always. A brand mark is drawn to sit on white",
42240
- " \u2014 this one is #1D5D47 on transparent \u2014 so a dark backing hid it on the corner",
42241
- " and the brand-coloured closing plate erased it completely: the end card came",
42242
- " out as an empty green frame with a single leaf floating in it. A near-white",
42243
- " 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. */",
42244
42280
  " .brand-mark { position: absolute; top: 90px; left: 56px; height: 52px; width: auto;",
42245
42281
  " max-width: 420px; object-fit: contain; padding: 14px 22px; border-radius: 18px;",
42246
42282
  " background: rgba(255,255,255,0.94);",
@@ -42254,6 +42290,14 @@ function adBrandOverlayCss() {
42254
42290
  " .endcard-logo { width: 62%; height: auto; max-height: 300px; object-fit: contain;",
42255
42291
  " padding: 46px 56px; border-radius: 40px; background: rgba(255,255,255,0.96);",
42256
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); }",
42257
42301
  " /* The name, when the mark itself could not be drawn. */",
42258
42302
  " .brand-wordmark { display: inline-block; font-family: 'Arial Black', 'Helvetica Neue', Arial, sans-serif;",
42259
42303
  " font-weight: 900; color: #14231b; letter-spacing: -0.01em; }",
@@ -42268,10 +42312,17 @@ function adBrandOverlayCss() {
42268
42312
  " first attempt led with 'BrandFont', which the overlay composition defines and",
42269
42313
  " the caption composition does not: HyperFrames refuses an unmapped family and",
42270
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. */",
42271
42321
  " .endcard-cta { font-family: 'Arial Black', 'Helvetica Neue', Arial, sans-serif;",
42272
42322
  " font-size: 64px; font-weight: 900; text-transform: uppercase;",
42273
42323
  " letter-spacing: 0.01em; color: #fff; padding: 28px 58px; border-radius: 56px;",
42274
- " 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; }"
42275
42326
  ].join("\n");
42276
42327
  }
42277
42328
  function injectAdBrandOverlay(indexHtml, overlayHtml) {
@@ -42288,6 +42339,7 @@ function injectAdBrandOverlay(indexHtml, overlayHtml) {
42288
42339
  // src/engine/scaffold/ad-brand-source.ts
42289
42340
  import { readdir as readdir8, readFile as readFile15 } from "fs/promises";
42290
42341
  import path22 from "path";
42342
+ import sharp from "sharp";
42291
42343
  var BRAND_DIR = "src/brand";
42292
42344
  function paletteFromBrandDoc(markdown) {
42293
42345
  const seen = /* @__PURE__ */ new Set();
@@ -42296,15 +42348,33 @@ function paletteFromBrandDoc(markdown) {
42296
42348
  const hex = `#${match[1].toLowerCase()}`;
42297
42349
  if (seen.has(hex)) continue;
42298
42350
  seen.add(hex);
42299
- const r = Number.parseInt(hex.slice(1, 3), 16);
42300
- const g = Number.parseInt(hex.slice(3, 5), 16);
42301
- const b = Number.parseInt(hex.slice(5, 7), 16);
42302
- const luma = 0.2126 * r + 0.7152 * g + 0.0722 * b;
42303
- if (luma > 236 || luma < 14) continue;
42351
+ const luma = hexLuma(hex);
42352
+ if (luma === null || luma > 236 || luma < 14) continue;
42304
42353
  out.push(hex);
42305
42354
  }
42306
42355
  return out;
42307
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/;
42308
42378
  function pickBrandMark(files) {
42309
42379
  const usable = files.filter((f) => /\.(svg|png|webp)$/i.test(f));
42310
42380
  if (usable.length === 0) return null;
@@ -42313,10 +42383,10 @@ function pickBrandMark(files) {
42313
42383
  let s = 0;
42314
42384
  if (/\.(png|webp)$/.test(name)) s += 1;
42315
42385
  if (/\b(logo|wordmark|lockup)\b/.test(name)) s += 2;
42316
- 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;
42317
42387
  return s;
42318
42388
  };
42319
- 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;
42320
42390
  }
42321
42391
  async function readBrandFromWorkspace(root = ".") {
42322
42392
  const found = {};
@@ -42371,6 +42441,27 @@ async function markDirectoryIsReal(dir) {
42371
42441
  }
42372
42442
  return true;
42373
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
+ }
42374
42465
 
42375
42466
  // src/commands/canvas/composition-path.ts
42376
42467
  import { existsSync as existsSync5 } from "fs";
@@ -42434,6 +42525,11 @@ registerSchema({
42434
42525
  }
42435
42526
  }
42436
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
+ }
42437
42533
  var scaffoldAdCommand = defineCommand114({
42438
42534
  meta: {
42439
42535
  name: "scaffold-ad",
@@ -42676,7 +42772,12 @@ var scaffoldAdCommand = defineCommand114({
42676
42772
  spec.data.cast = { ...spec.data.cast, description: withoutCastName(spec.data.cast.description, names) };
42677
42773
  }
42678
42774
  }
42679
- 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
+ });
42680
42781
  const slug = args4.slug ?? path24.basename(specPath).replace(/\.[^.]+$/, "");
42681
42782
  const outPath = args4.out ?? (args4.slug ? path24.join("src/creatives", slug, `${slug}.canvas.json`) : path24.join(path24.dirname(specPath), `${slug}.canvas.json`));
42682
42783
  const outDir = path24.dirname(outPath);
@@ -42726,6 +42827,19 @@ var scaffoldAdCommand = defineCommand114({
42726
42827
  // and rejected — it would swap the voice that matches the mouth and the directed
42727
42828
  // region for one cast off an English stock list. Only the on-camera voice; a
42728
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.
42729
42843
  ...avatar ? { keepRenderedVoice: true } : {},
42730
42844
  ...spec.data.voiceover === false ? { staticTranscriptPath: path24.relative(outDir, transcriptPath) } : {}
42731
42845
  };
@@ -42733,11 +42847,13 @@ var scaffoldAdCommand = defineCommand114({
42733
42847
  const renderNode4 = canvas.nodes.find((n) => n.type === "hyperframe_render");
42734
42848
  const renderedDir = renderNode4 ? path24.join(outDir, String(renderNode4.params?.composition ?? "")) : null;
42735
42849
  let staged = null;
42850
+ let markIsLight = false;
42736
42851
  if (logoPath && renderedDir) {
42737
42852
  const ext = path24.extname(logoPath) || ".png";
42738
42853
  try {
42739
42854
  await copyFile(logoPath, path24.join(renderedDir, `brand-mark${ext}`));
42740
42855
  staged = { file: `brand-mark${ext}`, alt: spec.data.brand?.name ?? "brand" };
42856
+ markIsLight = await markInkIsLight(logoPath);
42741
42857
  } catch {
42742
42858
  }
42743
42859
  }
@@ -42748,7 +42864,10 @@ var scaffoldAdCommand = defineCommand114({
42748
42864
  cardAtS: closing.start_s,
42749
42865
  cardDurationS: closing.duration_s,
42750
42866
  totalS: blueprint.estimated_duration_s,
42751
- 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)
42752
42871
  }) : "";
42753
42872
  if (overlayHtml && renderedDir) {
42754
42873
  const indexPath = path24.join(renderedDir, "index.html");
@@ -42823,7 +42942,7 @@ var scaffoldAdCommand = defineCommand114({
42823
42942
  // ad with no palette and no mark anywhere in 28 seconds.
42824
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.`
42825
42944
  ],
42826
- "`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."
42827
42946
  ]
42828
42947
  });
42829
42948
  }
@@ -51323,9 +51442,9 @@ import { defineCommand as defineCommand169 } from "citty";
51323
51442
  import { defineCommand as defineCommand143 } from "citty";
51324
51443
 
51325
51444
  // src/lib/image/crop-sprite.ts
51326
- import sharp from "sharp";
51445
+ import sharp2 from "sharp";
51327
51446
  function cropSprite(input, region) {
51328
- return sharp(input).extract({
51447
+ return sharp2(input).extract({
51329
51448
  left: Math.round(region.x),
51330
51449
  top: Math.round(region.y),
51331
51450
  width: Math.round(region.width),
@@ -53585,7 +53704,7 @@ function solidifyEdges(data, saturationBoost = 0.25) {
53585
53704
  }
53586
53705
 
53587
53706
  // src/lib/image/image-processor.ts
53588
- import sharp2 from "sharp";
53707
+ import sharp3 from "sharp";
53589
53708
  function hasGradient(data) {
53590
53709
  const uniqueColors = /* @__PURE__ */ new Set();
53591
53710
  for (let i = 0; i < data.length; i += 4) {
@@ -53617,10 +53736,10 @@ function createSvgRecolorer(target, palette) {
53617
53736
  async function processGradient(data, info) {
53618
53737
  if (!hasGradient(data)) return { data, info };
53619
53738
  const solidified = solidifyEdges(data, 0.1);
53620
- const blurred = await sharp2(solidified, {
53739
+ const blurred = await sharp3(solidified, {
53621
53740
  raw: { channels: 4, width: info.width, height: info.height }
53622
53741
  }).png().blur(0.3).toBuffer();
53623
- const result = await sharp2(blurred).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
53742
+ const result = await sharp3(blurred).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
53624
53743
  return { data: result.data, info: result.info };
53625
53744
  }
53626
53745
  function isSvgBuffer(buffer) {
@@ -53629,13 +53748,13 @@ function isSvgBuffer(buffer) {
53629
53748
  }
53630
53749
  async function processInternal(inputBuffer, isSVG, options) {
53631
53750
  const stages = [];
53632
- const metadata = await sharp2(inputBuffer).metadata();
53751
+ const metadata = await sharp3(inputBuffer).metadata();
53633
53752
  let alreadyTransparent = false;
53634
53753
  if (metadata.hasAlpha) {
53635
- const { data: alphaData } = await sharp2(inputBuffer).raw().toBuffer({ resolveWithObject: true });
53754
+ const { data: alphaData } = await sharp3(inputBuffer).raw().toBuffer({ resolveWithObject: true });
53636
53755
  alreadyTransparent = hasTransparency(alphaData, 0.05);
53637
53756
  }
53638
- 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 });
53639
53758
  if (options.color) {
53640
53759
  stages.push("recolor");
53641
53760
  processedData = applyQuantization(processedData, 8);
@@ -53706,7 +53825,7 @@ async function processInternal(inputBuffer, isSVG, options) {
53706
53825
  processedData = result.data;
53707
53826
  info = result.info;
53708
53827
  }
53709
- let pipeline2 = sharp2(processedData, {
53828
+ let pipeline2 = sharp3(processedData, {
53710
53829
  raw: { channels: 4, width: info.width, height: info.height }
53711
53830
  }).png();
53712
53831
  if (options.shrinkToContent) {
@@ -53719,12 +53838,12 @@ async function processInternal(inputBuffer, isSVG, options) {
53719
53838
  function applyResize(buffer, resize) {
53720
53839
  const transparent = { r: 0, g: 0, b: 0, alpha: 0 };
53721
53840
  if (resize.size) {
53722
- return sharp2(buffer).resize(resize.size.width, resize.size.height, {
53841
+ return sharp3(buffer).resize(resize.size.width, resize.size.height, {
53723
53842
  fit: resize.fit ?? "contain",
53724
53843
  background: transparent
53725
53844
  }).toBuffer();
53726
53845
  }
53727
- 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();
53728
53847
  }
53729
53848
  async function processImage(inputBuffer, options = {}) {
53730
53849
  const stages = [];
@@ -53733,7 +53852,7 @@ async function processImage(inputBuffer, options = {}) {
53733
53852
  if (options.resize) {
53734
53853
  stages.push("resize");
53735
53854
  const resized = await applyResize(inputBuffer, options.resize);
53736
- const meta3 = await sharp2(resized).metadata();
53855
+ const meta3 = await sharp3(resized).metadata();
53737
53856
  return {
53738
53857
  buffer: resized,
53739
53858
  format: "png",
@@ -53742,7 +53861,7 @@ async function processImage(inputBuffer, options = {}) {
53742
53861
  stages
53743
53862
  };
53744
53863
  }
53745
- const meta2 = await sharp2(inputBuffer).metadata();
53864
+ const meta2 = await sharp3(inputBuffer).metadata();
53746
53865
  return {
53747
53866
  buffer: inputBuffer,
53748
53867
  format: "svg",
@@ -53754,7 +53873,7 @@ async function processImage(inputBuffer, options = {}) {
53754
53873
  let workingBuffer = inputBuffer;
53755
53874
  if (isSVG) {
53756
53875
  stages.push("rasterize-svg");
53757
- 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();
53758
53877
  }
53759
53878
  const pass = await processInternal(workingBuffer, isSVG, options);
53760
53879
  stages.push(...pass.stages);
@@ -53763,7 +53882,7 @@ async function processImage(inputBuffer, options = {}) {
53763
53882
  stages.push("resize");
53764
53883
  processed = await applyResize(processed, options.resize);
53765
53884
  }
53766
- const meta = await sharp2(processed).metadata();
53885
+ const meta = await sharp3(processed).metadata();
53767
53886
  return {
53768
53887
  buffer: processed,
53769
53888
  format: "png",
@@ -57382,7 +57501,7 @@ function routeKey(route) {
57382
57501
  }
57383
57502
 
57384
57503
  // src/engine/landing-library/fidelity.ts
57385
- import sharp3 from "sharp";
57504
+ import sharp4 from "sharp";
57386
57505
  var COMPARISON_SIZE = 32;
57387
57506
  function pixelSimilarity(a, b) {
57388
57507
  const length = Math.min(a.length, b.length);
@@ -57394,7 +57513,7 @@ function pixelSimilarity(a, b) {
57394
57513
  return 1 - total / (length * 255);
57395
57514
  }
57396
57515
  async function scoreFidelity(live, rendered) {
57397
- 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()]);
57398
57517
  const liveSize = { width: liveMeta.width ?? 0, height: liveMeta.height ?? 0 };
57399
57518
  const renderedSize = { width: renderedMeta.width ?? 0, height: renderedMeta.height ?? 0 };
57400
57519
  const [livePixels, renderedPixels] = await Promise.all([toGreyGrid(live), toGreyGrid(rendered)]);
@@ -57408,7 +57527,7 @@ async function scoreFidelity(live, rendered) {
57408
57527
  };
57409
57528
  }
57410
57529
  async function toGreyGrid(image) {
57411
- 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();
57412
57531
  return new Uint8Array(raw);
57413
57532
  }
57414
57533
 
@@ -57620,7 +57739,7 @@ var inPageCollectMotion = (selector) => {
57620
57739
  };
57621
57740
 
57622
57741
  // src/engine/landing-library/motionTake.ts
57623
- import sharp4 from "sharp";
57742
+ import sharp5 from "sharp";
57624
57743
 
57625
57744
  // src/engine/landing-library/certificate.ts
57626
57745
  function formatDate(unixSeconds) {
@@ -58287,8 +58406,8 @@ async function hideFloatingFurniture(page, selector) {
58287
58406
  }, selector);
58288
58407
  }
58289
58408
  async function composeFilmstrip(frames) {
58290
- const scaled = await Promise.all(frames.map((frame) => sharp4(frame).resize({ width: FRAME_WIDTH }).png().toBuffer()));
58291
- 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();
58292
58411
  const frameHeight = first.height ?? 300;
58293
58412
  const cellHeight = frameHeight + LABEL_HEIGHT;
58294
58413
  const rows = Math.ceil(scaled.length / GRID_COLUMNS);
@@ -58307,7 +58426,7 @@ async function composeFilmstrip(frames) {
58307
58426
  { input: frame, left: column * FRAME_WIDTH, top: row * cellHeight + LABEL_HEIGHT }
58308
58427
  ];
58309
58428
  });
58310
- return await sharp4({
58429
+ return await sharp5({
58311
58430
  create: {
58312
58431
  width,
58313
58432
  height: cellHeight * rows,
@@ -58496,12 +58615,12 @@ var DEFAULT_SEGMENT_OPTIONS = {
58496
58615
  };
58497
58616
 
58498
58617
  // src/engine/landing-library/visualHash.ts
58499
- import sharp5 from "sharp";
58618
+ import sharp6 from "sharp";
58500
58619
  var HASH_WIDTH = 9;
58501
58620
  var HASH_HEIGHT = 8;
58502
58621
  async function perceptualHash(image) {
58503
58622
  try {
58504
- 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();
58505
58624
  return bitsToHex(rowGradientBits(new Uint8Array(raw)));
58506
58625
  } catch {
58507
58626
  return null;
@@ -59015,7 +59134,7 @@ import { defineCommand as defineCommand176 } from "citty";
59015
59134
  // src/commands/landing/inspiration/shot.ts
59016
59135
  import { mkdir as mkdir13, writeFile as writeFile18 } from "fs/promises";
59017
59136
  import path42 from "path";
59018
- import sharp6 from "sharp";
59137
+ import sharp7 from "sharp";
59019
59138
  var READABLE_SHOT = {
59020
59139
  maxWidth: 1440,
59021
59140
  maxHeight: 4e3,
@@ -59024,7 +59143,7 @@ var READABLE_SHOT = {
59024
59143
  };
59025
59144
  async function toReadableShot(original) {
59026
59145
  try {
59027
- return await sharp6(original).resize({
59146
+ return await sharp7(original).resize({
59028
59147
  width: READABLE_SHOT.maxWidth,
59029
59148
  height: READABLE_SHOT.maxHeight,
59030
59149
  fit: "inside",