@koda-sl/baker-cli 0.307.1-dev.62f0eca7d → 0.307.2-dev.4a3ed026b

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-ZBC4NFST.js";
70
+ } from "./chunk-GOVZCSVQ.js";
71
71
  import {
72
72
  csvOrJson,
73
73
  daysAgoIso,
@@ -30378,7 +30378,7 @@ var args = {
30378
30378
  },
30379
30379
  id: {
30380
30380
  type: "string",
30381
- description: "The send's id, when changing one that exists (`sends[].id` for a live one, `staged.set[].id` for one staged in this chat, like s1). Left out, a new id is minted and a NEW send is staged \u2014 so re-running a --set you already ran is refused, naming the send it would duplicate",
30381
+ description: "The send's id, when changing one that exists (`sends[].id`, like s1). Left out, a new id is minted and a new send is staged",
30382
30382
  required: false
30383
30383
  },
30384
30384
  remove: {
@@ -41073,6 +41073,7 @@ function endCardCta(spec) {
41073
41073
  return declared.toLowerCase().replace(/[.!?]$/, "") === closing.replace(/[.!?]$/, "") ? null : declared;
41074
41074
  }
41075
41075
  var AD_CAST_ID = "customer";
41076
+ var AD_NARRATOR_ID = "ad_narrator";
41076
41077
  var AD_SET_ID = "set";
41077
41078
  function castedAvatarOnRoster(ad, roster) {
41078
41079
  if (ad.cast?.avatar) return null;
@@ -41128,9 +41129,9 @@ function adSpecCastElements(spec, avatar) {
41128
41129
  });
41129
41130
  return elements;
41130
41131
  }
41131
- function motionPrompt(beat, toCamera, isClosingCard, place, physics) {
41132
+ function motionPrompt(beat, toCamera, isClosingCard, place) {
41132
41133
  if (isClosingCard) return BRAND_PLATE;
41133
- const shot = `${beat.show}${place}${physics}`;
41134
+ const shot = `${beat.show}${place}`;
41134
41135
  if (toCamera) return shot;
41135
41136
  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.`;
41136
41137
  }
@@ -41178,11 +41179,9 @@ function beatsIntroducingSpeakerOffCamera(spec) {
41178
41179
  (beat, i) => lineIntroducesSpeaker(beat.say) && !beatToCamera(spec, beat, closesOnCard && i === last) ? [i] : []
41179
41180
  );
41180
41181
  }
41181
- function wantsNativeSpeech(spec, closesOnCard) {
41182
+ function beatSpeaksNatively(spec, beat, isClosingCard) {
41182
41183
  if (!spec.cast || spec.voiceover === false) return false;
41183
- const last = spec.beats.length - 1;
41184
- const speaking = spec.beats.filter((_, i) => !(closesOnCard && i === last));
41185
- return speaking.length > 0 && speaking.every((beat) => beatToCamera(spec, beat, false) && beat.cast !== false);
41184
+ return beatToCamera(spec, beat, isClosingCard) && beat.cast !== false;
41186
41185
  }
41187
41186
  function beatDialogue(spec, beat, window2) {
41188
41187
  if (spec.voiceover === false) return [];
@@ -41199,14 +41198,38 @@ function beatDialogue(spec, beat, window2) {
41199
41198
  // by definition never on camera — so every beat took the dubbed path: a clip generated
41200
41199
  // with one mouth and a separate ElevenLabs track laid over it. That is the mismatch
41201
41200
  // behind every "the mouth does not match" in this ad's history.
41202
- ...window2.nativeSpeech && !window2.isClosingCard ? { speaker: AD_CAST_ID } : {},
41203
- ...spec.voice?.description ? { voice_description: spec.voice.description } : {}
41201
+ //
41202
+ // A beat that is NOT hers names the ad's OWN narrator (`AD_NARRATOR_ID`), rather
41203
+ // than leaving the label off. A different speaker is what breaks the phrase, and a
41204
+ // phrase break is what keeps each beat its own clip: left unlabelled, the engine read
41205
+ // the cutaway as her narrating over an insert in one continuous take and merged the
41206
+ // beats around it into a single native read with the dubbed one laid on top.
41207
+ speaker: window2.nativeSpeech ? AD_CAST_ID : AD_NARRATOR_ID,
41208
+ // The narrator is cast from the ad's voice, and failing that from the CAST's own
41209
+ // description. Naming a speaker of our own cost this: the engine casts an unnamed
41210
+ // narrator by folding it into the lone presenter and reusing her casting, so the
41211
+ // closing line inherited her persona for free. Named, it is a speaker in nobody's
41212
+ // `cast`, and the engine's last-resort description for one of those is the literal
41213
+ // "<speaker> voice" — so an ad with an avatar, a closing card and no `voice`
41214
+ // (a perfectly ordinary spec) had its closing tagline cast off the words
41215
+ // "ad_narrator voice".
41216
+ ...narratorVoice(spec, window2.avatarVoice) ? { voice_description: narratorVoice(spec, window2.avatarVoice) } : {}
41204
41217
  }
41205
41218
  ];
41206
41219
  }
41207
- function voiceoverMode(spec, nativeSpeech) {
41220
+ function narratorVoice(spec, avatarVoice) {
41221
+ return spec.voice?.description?.trim() || avatarVoice?.trim() || spec.cast?.description?.trim() || void 0;
41222
+ }
41223
+ function nativeSpeechByBeat(spec, closesOnCard) {
41224
+ const last = spec.beats.length - 1;
41225
+ return spec.beats.map((beat, i) => beatSpeaksNatively(spec, beat, closesOnCard && i === last));
41226
+ }
41227
+ function voiceoverMode(spec, nativeByBeat, closesOnCard) {
41208
41228
  if (spec.voiceover === false) return "none";
41209
- return nativeSpeech ? "on_camera" : "voiceover";
41229
+ const spoken = closesOnCard ? nativeByBeat.slice(0, -1) : nativeByBeat;
41230
+ const native = spoken.filter(Boolean).length;
41231
+ if (native === 0) return "voiceover";
41232
+ return native === spoken.length ? "on_camera" : "mixed";
41210
41233
  }
41211
41234
  function castRole(spec) {
41212
41235
  if (!spec.cast) return void 0;
@@ -41223,7 +41246,8 @@ function adSpecToBlueprint(spec, opts = {}) {
41223
41246
  const currency = market ? CURRENCY_BY_MARKET[market.toLowerCase()] : void 0;
41224
41247
  const physics = frameRealismDirection({ currency, role: castRole(spec) });
41225
41248
  const closesOnCard = endCardWanted(spec);
41226
- const nativeSpeech = wantsNativeSpeech(spec, closesOnCard);
41249
+ const nativeByBeat = nativeSpeechByBeat(spec, closesOnCard);
41250
+ const spokenMode = voiceoverMode(spec, nativeByBeat, closesOnCard);
41227
41251
  let clock = 0;
41228
41252
  const scenes = spec.beats.map((beat, i) => {
41229
41253
  const isClosingCard = closesOnCard && i === total - 1;
@@ -41252,9 +41276,10 @@ function adSpecToBlueprint(spec, opts = {}) {
41252
41276
  start: Math.round(start * 100) / 100,
41253
41277
  end: Math.round(clock * 100) / 100,
41254
41278
  isClosingCard,
41255
- nativeSpeech
41279
+ nativeSpeech: nativeByBeat[i] === true,
41280
+ avatarVoice: opts.avatarVoice
41256
41281
  }),
41257
- motion_prompt: motionPrompt(beat, beatToCamera(spec, beat, isClosingCard), isClosingCard, place, physics),
41282
+ motion_prompt: motionPrompt(beat, beatToCamera(spec, beat, isClosingCard), isClosingCard, place),
41258
41283
  start_frame_prompt: isClosingCard ? BRAND_PLATE : `${beat.show}${place}${physics}`
41259
41284
  };
41260
41285
  });
@@ -41266,9 +41291,9 @@ function adSpecToBlueprint(spec, opts = {}) {
41266
41291
  // the script itself, so an ad that never named a place still says where it is set.
41267
41292
  ...market ? { market } : {},
41268
41293
  voiceover: {
41269
- mode: voiceoverMode(spec, nativeSpeech),
41294
+ mode: spokenMode,
41270
41295
  ...spokenVariety(spec, avatarAccent) ? { accent: spokenVariety(spec, avatarAccent) } : {},
41271
- ...spec.voice?.description ? { voice_description: spec.voice.description } : {}
41296
+ ...narratorVoice(spec, opts.avatarVoice) ? { voice_description: narratorVoice(spec, opts.avatarVoice) } : {}
41272
41297
  },
41273
41298
  ...spec.cast ? {
41274
41299
  cast: [
@@ -41298,15 +41323,10 @@ function beatsTooShortToPlay(spec) {
41298
41323
  // src/engine/scaffold/ad-brand-overlay.ts
41299
41324
  var markFallback = (name) => name.trim() ? ` onerror="this.replaceWith(Object.assign(document.createElement('span'),{className:this.className+' brand-wordmark',textContent:${escapeHtml2(JSON.stringify(name.trim()))}}))"` : "";
41300
41325
  var escapeHtml2 = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
41301
- function firstHex(palette) {
41302
- const hex = (palette ?? []).find((h) => /^#?[0-9a-fA-F]{6}$/.test(h.trim()));
41303
- if (!hex) return null;
41304
- const trimmed = hex.trim();
41305
- return trimmed.startsWith("#") ? trimmed : `#${trimmed}`;
41306
- }
41307
41326
  function buildAdBrandOverlay(opts) {
41308
- const { logo, cta, cardAtS, cardDurationS, totalS, accent } = opts;
41327
+ const { logo, cta, cardAtS, cardDurationS, totalS } = opts;
41309
41328
  if (!logo && !cta?.trim()) return "";
41329
+ const inkClass = opts.markIsLight ? " on-dark" : "";
41310
41330
  const parts = [
41311
41331
  "<!-- Brand layer, emitted by `baker canvas scaffold-ad`. The mark and the closing",
41312
41332
  " card are drawn HERE, by the composition \u2014 never generated as a picture, because",
@@ -41316,18 +41336,19 @@ function buildAdBrandOverlay(opts) {
41316
41336
  ];
41317
41337
  if (logo) {
41318
41338
  parts.push(
41319
- `<img class="clip brand-mark" src="${escapeHtml2(logo.file)}" alt="${escapeHtml2(logo.alt)}" data-start="0" data-dur="${totalS}"${markFallback(logo.alt)} />`
41339
+ `<img class="clip brand-mark${inkClass}" src="${escapeHtml2(logo.file)}" alt="${escapeHtml2(logo.alt)}" data-start="0" data-dur="${totalS}"${markFallback(logo.alt)} />`
41320
41340
  );
41321
41341
  }
41322
41342
  const cardBits = [];
41323
41343
  if (logo) {
41324
41344
  cardBits.push(
41325
- `<img class="endcard-logo" src="${escapeHtml2(logo.file)}" alt="${escapeHtml2(logo.alt)}"${markFallback(logo.alt)} />`
41345
+ `<img class="endcard-logo${inkClass}" src="${escapeHtml2(logo.file)}" alt="${escapeHtml2(logo.alt)}"${markFallback(logo.alt)} />`
41326
41346
  );
41327
41347
  }
41328
41348
  if (cta?.trim()) {
41329
- const style = accent ? ` style="background:${escapeHtml2(accent)}"` : "";
41330
- cardBits.push(`<div class="endcard-cta"${style}>${escapeHtml2(cta.trim())}</div>`);
41349
+ cardBits.push(
41350
+ `<div class="endcard-cta${opts.plateIsDark ? " on-dark-plate" : ""}">${escapeHtml2(cta.trim())}</div>`
41351
+ );
41331
41352
  }
41332
41353
  parts.push(
41333
41354
  `<div class="clip endcard" data-start="${cardAtS}" data-dur="${cardDurationS}" data-role="cta">`,
@@ -41344,6 +41365,8 @@ function brandLayerDriver() {
41344
41365
  " var els = Array.prototype.slice.call(document.querySelectorAll('.brand-mark, .endcard'));",
41345
41366
  " if (!els.length) return;",
41346
41367
  " els.forEach(function (el) { gsap.set(el, { visibility: 'hidden' }); });",
41368
+ " var card = document.querySelector('.endcard');",
41369
+ " var captions = document.getElementById('captions');",
41347
41370
  " function attach(tl) {",
41348
41371
  " if (!tl) return;",
41349
41372
  " els.forEach(function (el) {",
@@ -41352,6 +41375,18 @@ function brandLayerDriver() {
41352
41375
  " tl.set(el, { visibility: 'visible' }, at);",
41353
41376
  " if (dur > 0) tl.set(el, { visibility: 'hidden' }, at + dur);",
41354
41377
  " });",
41378
+ " // The closing card owns the frame it lands on. The caption track otherwise",
41379
+ " // keeps running underneath it, and what it captions there is the line spoken",
41380
+ " // OVER the card \u2014 the call to action, printed a second time in smaller letters",
41381
+ " // directly below the button that already says it. Hidden on the CONTAINER and",
41382
+ " // with `display`, because each caption group sets its own `visibility: visible`",
41383
+ " // and would win against anything set on its parent.",
41384
+ " if (card && captions) {",
41385
+ " var cardAt = parseFloat(card.getAttribute('data-start') || '0') || 0;",
41386
+ " var cardDur = parseFloat(card.getAttribute('data-dur') || '0') || 0;",
41387
+ " tl.set(captions, { display: 'none' }, cardAt);",
41388
+ " if (cardDur > 0) tl.set(captions, { display: '' }, cardAt + cardDur);",
41389
+ " }",
41355
41390
  " }",
41356
41391
  " var store = window.__timelines || {};",
41357
41392
  " var main = store.main;",
@@ -41376,11 +41411,12 @@ function adBrandOverlayCss() {
41376
41411
  " viewBox is wider than it is tall rendered clipped \u2014 the first real ad drew",
41377
41412
  " `greenlea`. Constraining the height and letting the width follow keeps any",
41378
41413
  " aspect ratio whole, and the max-width stops a very wide mark spanning the frame. */",
41379
- " /* A LIGHT panel behind the mark, always. A brand mark is drawn to sit on white",
41380
- " \u2014 this one is #1D5D47 on transparent \u2014 so a dark backing hid it on the corner",
41381
- " and the brand-coloured closing plate erased it completely: the end card came",
41382
- " out as an empty green frame with a single leaf floating in it. A near-white",
41383
- " panel is the one background every mark in a brand kit is designed against. */",
41414
+ " /* A LIGHT panel behind the mark by DEFAULT. A brand mark is usually drawn to sit",
41415
+ " on white \u2014 the first one here was #1D5D47 on transparent \u2014 so a dark backing hid",
41416
+ " it on the corner and the brand-coloured closing plate erased it completely: the",
41417
+ " end card came out as an empty green frame with a single leaf floating in it. A",
41418
+ " near-white panel is the background most marks in a brand kit are designed",
41419
+ " against; the ones that are not get `.on-dark` below. */",
41384
41420
  " .brand-mark { position: absolute; top: 90px; left: 56px; height: 52px; width: auto;",
41385
41421
  " max-width: 420px; object-fit: contain; padding: 14px 22px; border-radius: 18px;",
41386
41422
  " background: rgba(255,255,255,0.94);",
@@ -41394,6 +41430,14 @@ function adBrandOverlayCss() {
41394
41430
  " .endcard-logo { width: 62%; height: auto; max-height: 300px; object-fit: contain;",
41395
41431
  " padding: 46px 56px; border-radius: 40px; background: rgba(255,255,255,0.96);",
41396
41432
  " box-shadow: 0 10px 40px rgba(0,0,0,0.28); }",
41433
+ " /* A mark whose own ink is LIGHT gets the opposite panel. The near-white panel",
41434
+ " above is right for the mark a brand kit usually ships \u2014 drawn to sit on white",
41435
+ " \u2014 and it erases the one it does not: a client's white `logo-dark.svg` rendered",
41436
+ " as an empty white box in the corner, and again 300px tall on the closing card.",
41437
+ " Which one a mark is gets MEASURED off the file (`markInkIsLight`), never read",
41438
+ " off its name: `logo-dark` names the background it is for in one brand kit and",
41439
+ " the ink itself in the next. */",
41440
+ " .brand-mark.on-dark, .endcard-logo.on-dark { background: rgba(16,20,18,0.92); }",
41397
41441
  " /* The name, when the mark itself could not be drawn. */",
41398
41442
  " .brand-wordmark { display: inline-block; font-family: 'Arial Black', 'Helvetica Neue', Arial, sans-serif;",
41399
41443
  " font-weight: 900; color: #14231b; letter-spacing: -0.01em; }",
@@ -41408,10 +41452,17 @@ function adBrandOverlayCss() {
41408
41452
  " first attempt led with 'BrandFont', which the overlay composition defines and",
41409
41453
  " the caption composition does not: HyperFrames refuses an unmapped family and",
41410
41454
  " the whole render died with `No deterministic font mapping for: BrandFont`. */",
41455
+ " /* The button is the OPPOSITE of the plate it sits on. Filling it with the brand",
41456
+ " colour made it invisible \u2014 the plate is filled with that same colour, so the",
41457
+ " button was brand-on-brand and its white copy read as another caption line \u2014",
41458
+ " and a fixed near-black fill is that defect mirrored, on the black plate an ad",
41459
+ " with no palette gets. The plate carries the brand; the button carries the",
41460
+ " contrast. Light copy on the near-black default, dark copy on the light one. */",
41411
41461
  " .endcard-cta { font-family: 'Arial Black', 'Helvetica Neue', Arial, sans-serif;",
41412
41462
  " font-size: 64px; font-weight: 900; text-transform: uppercase;",
41413
41463
  " letter-spacing: 0.01em; color: #fff; padding: 28px 58px; border-radius: 56px;",
41414
- " max-width: 900px; line-height: 1.15; background: rgba(0,0,0,0.82); }"
41464
+ " max-width: 900px; line-height: 1.15; background: rgba(0,0,0,0.82); }",
41465
+ " .endcard-cta.on-dark-plate { background: rgba(255,255,255,0.96); color: #14231b; }"
41415
41466
  ].join("\n");
41416
41467
  }
41417
41468
  function injectAdBrandOverlay(indexHtml, overlayHtml) {
@@ -41428,6 +41479,7 @@ function injectAdBrandOverlay(indexHtml, overlayHtml) {
41428
41479
  // src/engine/scaffold/ad-brand-source.ts
41429
41480
  import { readdir as readdir8, readFile as readFile15 } from "fs/promises";
41430
41481
  import path22 from "path";
41482
+ import sharp from "sharp";
41431
41483
  var BRAND_DIR = "src/brand";
41432
41484
  function paletteFromBrandDoc(markdown) {
41433
41485
  const seen = /* @__PURE__ */ new Set();
@@ -41436,15 +41488,33 @@ function paletteFromBrandDoc(markdown) {
41436
41488
  const hex = `#${match[1].toLowerCase()}`;
41437
41489
  if (seen.has(hex)) continue;
41438
41490
  seen.add(hex);
41439
- const r = Number.parseInt(hex.slice(1, 3), 16);
41440
- const g = Number.parseInt(hex.slice(3, 5), 16);
41441
- const b = Number.parseInt(hex.slice(5, 7), 16);
41442
- const luma = 0.2126 * r + 0.7152 * g + 0.0722 * b;
41443
- if (luma > 236 || luma < 14) continue;
41491
+ const luma = hexLuma(hex);
41492
+ if (luma === null || luma > 236 || luma < 14) continue;
41444
41493
  out.push(hex);
41445
41494
  }
41446
41495
  return out;
41447
41496
  }
41497
+ function hexLuma(hex) {
41498
+ const m = hex.trim().match(/^#?([0-9a-fA-F]{6})$/);
41499
+ if (!m) return null;
41500
+ const v = m[1];
41501
+ const r = Number.parseInt(v.slice(0, 2), 16);
41502
+ const g = Number.parseInt(v.slice(2, 4), 16);
41503
+ const b = Number.parseInt(v.slice(4, 6), 16);
41504
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
41505
+ }
41506
+ function firstHex(palette) {
41507
+ const hex = (palette ?? []).find((h) => /^#?[0-9a-fA-F]{6}$/.test(h.trim()));
41508
+ if (!hex) return null;
41509
+ const trimmed = hex.trim();
41510
+ return trimmed.startsWith("#") ? trimmed : `#${trimmed}`;
41511
+ }
41512
+ function plateIsDark(palette) {
41513
+ const luma = hexLuma(firstHex(palette) ?? "");
41514
+ return luma === null || luma < PLATE_DARK_LUMA;
41515
+ }
41516
+ var PLATE_DARK_LUMA = 140;
41517
+ 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/;
41448
41518
  function pickBrandMark(files) {
41449
41519
  const usable = files.filter((f) => /\.(svg|png|webp)$/i.test(f));
41450
41520
  if (usable.length === 0) return null;
@@ -41453,10 +41523,10 @@ function pickBrandMark(files) {
41453
41523
  let s = 0;
41454
41524
  if (/\.(png|webp)$/.test(name)) s += 1;
41455
41525
  if (/\b(logo|wordmark|lockup)\b/.test(name)) s += 2;
41456
- if (/\b(mono|white|black|inverse|icon|favicon|mark-only|isotipo)\b/.test(name)) s -= 3;
41526
+ if (MARK_VARIANT_NAMES.test(name)) s -= 3;
41457
41527
  return s;
41458
41528
  };
41459
- return [...usable].sort((a, b) => score(b) - score(a) || a.localeCompare(b))[0] ?? null;
41529
+ return [...usable].sort((a, b) => score(b) - score(a) || a.length - b.length || a.localeCompare(b))[0] ?? null;
41460
41530
  }
41461
41531
  async function readBrandFromWorkspace(root = ".") {
41462
41532
  const found = {};
@@ -41511,6 +41581,27 @@ async function markDirectoryIsReal(dir) {
41511
41581
  }
41512
41582
  return true;
41513
41583
  }
41584
+ var INK_ALPHA = 128;
41585
+ function inkLuma(rgba) {
41586
+ let sum = 0;
41587
+ let count = 0;
41588
+ for (let i = 0; i + 3 < rgba.length; i += 4) {
41589
+ if (rgba[i + 3] < INK_ALPHA) continue;
41590
+ sum += 0.2126 * rgba[i] + 0.7152 * rgba[i + 1] + 0.0722 * rgba[i + 2];
41591
+ count += 1;
41592
+ }
41593
+ return count === 0 ? null : sum / count;
41594
+ }
41595
+ var LIGHT_INK_LUMA = 170;
41596
+ async function markInkIsLight(markPath) {
41597
+ try {
41598
+ const { data } = await sharp(markPath, { density: 144 }).resize(96, 96, { fit: "inside", withoutEnlargement: true }).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
41599
+ const luma = inkLuma(data);
41600
+ return luma !== null && luma > LIGHT_INK_LUMA;
41601
+ } catch {
41602
+ return false;
41603
+ }
41604
+ }
41514
41605
 
41515
41606
  // src/commands/canvas/composition-path.ts
41516
41607
  import { existsSync as existsSync5 } from "fs";
@@ -41574,6 +41665,11 @@ registerSchema({
41574
41665
  }
41575
41666
  }
41576
41667
  });
41668
+ function avatarVoiceBrief(avatar) {
41669
+ if (!avatar) return void 0;
41670
+ const parts = [avatar.subjectDescription, avatar.profile?.persona, avatar.profile?.speech].map((p) => p?.trim()).filter((p) => Boolean(p));
41671
+ return parts.length > 0 ? parts.join("; ") : void 0;
41672
+ }
41577
41673
  var scaffoldAdCommand = defineCommand113({
41578
41674
  meta: {
41579
41675
  name: "scaffold-ad",
@@ -41816,7 +41912,12 @@ var scaffoldAdCommand = defineCommand113({
41816
41912
  spec.data.cast = { ...spec.data.cast, description: withoutCastName(spec.data.cast.description, names) };
41817
41913
  }
41818
41914
  }
41819
- const blueprint = adSpecToBlueprint(spec.data, { avatarAccent: avatar?.profile?.accent, videoModel: chosenVideoModel });
41915
+ const avatarVoice = avatarVoiceBrief(avatar);
41916
+ const blueprint = adSpecToBlueprint(spec.data, {
41917
+ avatarAccent: avatar?.profile?.accent,
41918
+ videoModel: chosenVideoModel,
41919
+ ...avatarVoice ? { avatarVoice } : {}
41920
+ });
41820
41921
  const slug = args4.slug ?? path24.basename(specPath).replace(/\.[^.]+$/, "");
41821
41922
  const outPath = args4.out ?? (args4.slug ? path24.join("src/creatives", slug, `${slug}.canvas.json`) : path24.join(path24.dirname(specPath), `${slug}.canvas.json`));
41822
41923
  const outDir = path24.dirname(outPath);
@@ -41866,6 +41967,19 @@ var scaffoldAdCommand = defineCommand113({
41866
41967
  // and rejected — it would swap the voice that matches the mouth and the directed
41867
41968
  // region for one cast off an English stock list. Only the on-camera voice; a
41868
41969
  // voiceover read has no rendered voice to keep and is still cast.
41970
+ //
41971
+ // The KNOWN RISK this carries, recorded here because it is decided here: keeping the
41972
+ // rendered voice also means no pass unifies her beats, and each beat is its own
41973
+ // generation, so nothing guarantees two of them sound like the same person. The
41974
+ // reference image fixes the face; nothing fixes the voice. Measured on a finished
41975
+ // three-beat ad (her, a cutaway, her): median speaking pitch 210.5 Hz and 216.2 Hz,
41976
+ // near-identical spreads — the same voice as far as pitch can say — and the two
41977
+ // extracts were then listened to side by side and judged to be the same person. Not
41978
+ // proof for every ad: one listener, one ad, and pitch is one dimension of a voice.
41979
+ //
41980
+ // If it does show up, the fix is not to drop `keepRenderedVoice` — that brings back
41981
+ // the stock voice this exists to avoid. It is to convert the LATER extracts onto the
41982
+ // first beat of the same speaker, which keeps a voice the clip really produced.
41869
41983
  ...avatar ? { keepRenderedVoice: true } : {},
41870
41984
  ...spec.data.voiceover === false ? { staticTranscriptPath: path24.relative(outDir, transcriptPath) } : {}
41871
41985
  };
@@ -41873,11 +41987,13 @@ var scaffoldAdCommand = defineCommand113({
41873
41987
  const renderNode4 = canvas.nodes.find((n) => n.type === "hyperframe_render");
41874
41988
  const renderedDir = renderNode4 ? path24.join(outDir, String(renderNode4.params?.composition ?? "")) : null;
41875
41989
  let staged = null;
41990
+ let markIsLight = false;
41876
41991
  if (logoPath && renderedDir) {
41877
41992
  const ext = path24.extname(logoPath) || ".png";
41878
41993
  try {
41879
41994
  await copyFile(logoPath, path24.join(renderedDir, `brand-mark${ext}`));
41880
41995
  staged = { file: `brand-mark${ext}`, alt: spec.data.brand?.name ?? "brand" };
41996
+ markIsLight = await markInkIsLight(logoPath);
41881
41997
  } catch {
41882
41998
  }
41883
41999
  }
@@ -41888,7 +42004,10 @@ var scaffoldAdCommand = defineCommand113({
41888
42004
  cardAtS: closing.start_s,
41889
42005
  cardDurationS: closing.duration_s,
41890
42006
  totalS: blueprint.estimated_duration_s,
41891
- accent: firstHex(spec.data.brand?.palette)
42007
+ markIsLight,
42008
+ // The card's button is coloured against the plate the engine will fill from
42009
+ // this same palette — including the black plate an ad with no palette gets.
42010
+ plateIsDark: plateIsDark(spec.data.brand?.palette)
41892
42011
  }) : "";
41893
42012
  if (overlayHtml && renderedDir) {
41894
42013
  const indexPath = path24.join(renderedDir, "index.html");
@@ -41963,7 +42082,7 @@ var scaffoldAdCommand = defineCommand113({
41963
42082
  // ad with no palette and no mark anywhere in 28 seconds.
41964
42083
  `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.`
41965
42084
  ],
41966
- "`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."
42085
+ "`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."
41967
42086
  ]
41968
42087
  });
41969
42088
  }
@@ -50203,9 +50322,9 @@ import { defineCommand as defineCommand168 } from "citty";
50203
50322
  import { defineCommand as defineCommand142 } from "citty";
50204
50323
 
50205
50324
  // src/lib/image/crop-sprite.ts
50206
- import sharp from "sharp";
50325
+ import sharp2 from "sharp";
50207
50326
  function cropSprite(input, region) {
50208
- return sharp(input).extract({
50327
+ return sharp2(input).extract({
50209
50328
  left: Math.round(region.x),
50210
50329
  top: Math.round(region.y),
50211
50330
  width: Math.round(region.width),
@@ -52465,7 +52584,7 @@ function solidifyEdges(data, saturationBoost = 0.25) {
52465
52584
  }
52466
52585
 
52467
52586
  // src/lib/image/image-processor.ts
52468
- import sharp2 from "sharp";
52587
+ import sharp3 from "sharp";
52469
52588
  function hasGradient(data) {
52470
52589
  const uniqueColors = /* @__PURE__ */ new Set();
52471
52590
  for (let i = 0; i < data.length; i += 4) {
@@ -52497,10 +52616,10 @@ function createSvgRecolorer(target, palette) {
52497
52616
  async function processGradient(data, info) {
52498
52617
  if (!hasGradient(data)) return { data, info };
52499
52618
  const solidified = solidifyEdges(data, 0.1);
52500
- const blurred = await sharp2(solidified, {
52619
+ const blurred = await sharp3(solidified, {
52501
52620
  raw: { channels: 4, width: info.width, height: info.height }
52502
52621
  }).png().blur(0.3).toBuffer();
52503
- const result = await sharp2(blurred).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
52622
+ const result = await sharp3(blurred).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
52504
52623
  return { data: result.data, info: result.info };
52505
52624
  }
52506
52625
  function isSvgBuffer(buffer) {
@@ -52509,13 +52628,13 @@ function isSvgBuffer(buffer) {
52509
52628
  }
52510
52629
  async function processInternal(inputBuffer, isSVG, options) {
52511
52630
  const stages = [];
52512
- const metadata = await sharp2(inputBuffer).metadata();
52631
+ const metadata = await sharp3(inputBuffer).metadata();
52513
52632
  let alreadyTransparent = false;
52514
52633
  if (metadata.hasAlpha) {
52515
- const { data: alphaData } = await sharp2(inputBuffer).raw().toBuffer({ resolveWithObject: true });
52634
+ const { data: alphaData } = await sharp3(inputBuffer).raw().toBuffer({ resolveWithObject: true });
52516
52635
  alreadyTransparent = hasTransparency(alphaData, 0.05);
52517
52636
  }
52518
- let { data: processedData, info } = await sharp2(inputBuffer).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
52637
+ let { data: processedData, info } = await sharp3(inputBuffer).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
52519
52638
  if (options.color) {
52520
52639
  stages.push("recolor");
52521
52640
  processedData = applyQuantization(processedData, 8);
@@ -52586,7 +52705,7 @@ async function processInternal(inputBuffer, isSVG, options) {
52586
52705
  processedData = result.data;
52587
52706
  info = result.info;
52588
52707
  }
52589
- let pipeline2 = sharp2(processedData, {
52708
+ let pipeline2 = sharp3(processedData, {
52590
52709
  raw: { channels: 4, width: info.width, height: info.height }
52591
52710
  }).png();
52592
52711
  if (options.shrinkToContent) {
@@ -52599,12 +52718,12 @@ async function processInternal(inputBuffer, isSVG, options) {
52599
52718
  function applyResize(buffer, resize) {
52600
52719
  const transparent = { r: 0, g: 0, b: 0, alpha: 0 };
52601
52720
  if (resize.size) {
52602
- return sharp2(buffer).resize(resize.size.width, resize.size.height, {
52721
+ return sharp3(buffer).resize(resize.size.width, resize.size.height, {
52603
52722
  fit: resize.fit ?? "contain",
52604
52723
  background: transparent
52605
52724
  }).toBuffer();
52606
52725
  }
52607
- return sharp2(buffer).resize(resize.width ?? null, resize.height ?? null, { fit: "inside", withoutEnlargement: false }).toBuffer();
52726
+ return sharp3(buffer).resize(resize.width ?? null, resize.height ?? null, { fit: "inside", withoutEnlargement: false }).toBuffer();
52608
52727
  }
52609
52728
  async function processImage(inputBuffer, options = {}) {
52610
52729
  const stages = [];
@@ -52613,7 +52732,7 @@ async function processImage(inputBuffer, options = {}) {
52613
52732
  if (options.resize) {
52614
52733
  stages.push("resize");
52615
52734
  const resized = await applyResize(inputBuffer, options.resize);
52616
- const meta3 = await sharp2(resized).metadata();
52735
+ const meta3 = await sharp3(resized).metadata();
52617
52736
  return {
52618
52737
  buffer: resized,
52619
52738
  format: "png",
@@ -52622,7 +52741,7 @@ async function processImage(inputBuffer, options = {}) {
52622
52741
  stages
52623
52742
  };
52624
52743
  }
52625
- const meta2 = await sharp2(inputBuffer).metadata();
52744
+ const meta2 = await sharp3(inputBuffer).metadata();
52626
52745
  return {
52627
52746
  buffer: inputBuffer,
52628
52747
  format: "svg",
@@ -52634,7 +52753,7 @@ async function processImage(inputBuffer, options = {}) {
52634
52753
  let workingBuffer = inputBuffer;
52635
52754
  if (isSVG) {
52636
52755
  stages.push("rasterize-svg");
52637
- workingBuffer = await sharp2(inputBuffer).png({ compressionLevel: 0, force: true, palette: false, quality: 100 }).toBuffer();
52756
+ workingBuffer = await sharp3(inputBuffer).png({ compressionLevel: 0, force: true, palette: false, quality: 100 }).toBuffer();
52638
52757
  }
52639
52758
  const pass = await processInternal(workingBuffer, isSVG, options);
52640
52759
  stages.push(...pass.stages);
@@ -52643,7 +52762,7 @@ async function processImage(inputBuffer, options = {}) {
52643
52762
  stages.push("resize");
52644
52763
  processed = await applyResize(processed, options.resize);
52645
52764
  }
52646
- const meta = await sharp2(processed).metadata();
52765
+ const meta = await sharp3(processed).metadata();
52647
52766
  return {
52648
52767
  buffer: processed,
52649
52768
  format: "png",
@@ -56262,7 +56381,7 @@ function routeKey(route) {
56262
56381
  }
56263
56382
 
56264
56383
  // src/engine/landing-library/fidelity.ts
56265
- import sharp3 from "sharp";
56384
+ import sharp4 from "sharp";
56266
56385
  var COMPARISON_SIZE = 32;
56267
56386
  function pixelSimilarity(a, b) {
56268
56387
  const length = Math.min(a.length, b.length);
@@ -56274,7 +56393,7 @@ function pixelSimilarity(a, b) {
56274
56393
  return 1 - total / (length * 255);
56275
56394
  }
56276
56395
  async function scoreFidelity(live, rendered) {
56277
- const [liveMeta, renderedMeta] = await Promise.all([sharp3(live).metadata(), sharp3(rendered).metadata()]);
56396
+ const [liveMeta, renderedMeta] = await Promise.all([sharp4(live).metadata(), sharp4(rendered).metadata()]);
56278
56397
  const liveSize = { width: liveMeta.width ?? 0, height: liveMeta.height ?? 0 };
56279
56398
  const renderedSize = { width: renderedMeta.width ?? 0, height: renderedMeta.height ?? 0 };
56280
56399
  const [livePixels, renderedPixels] = await Promise.all([toGreyGrid(live), toGreyGrid(rendered)]);
@@ -56288,7 +56407,7 @@ async function scoreFidelity(live, rendered) {
56288
56407
  };
56289
56408
  }
56290
56409
  async function toGreyGrid(image) {
56291
- const raw = await sharp3(image).greyscale().resize(COMPARISON_SIZE, COMPARISON_SIZE, { fit: "fill" }).raw().toBuffer();
56410
+ const raw = await sharp4(image).greyscale().resize(COMPARISON_SIZE, COMPARISON_SIZE, { fit: "fill" }).raw().toBuffer();
56292
56411
  return new Uint8Array(raw);
56293
56412
  }
56294
56413
 
@@ -56500,7 +56619,7 @@ var inPageCollectMotion = (selector) => {
56500
56619
  };
56501
56620
 
56502
56621
  // src/engine/landing-library/motionTake.ts
56503
- import sharp4 from "sharp";
56622
+ import sharp5 from "sharp";
56504
56623
 
56505
56624
  // src/engine/landing-library/certificate.ts
56506
56625
  function formatDate(unixSeconds) {
@@ -57167,8 +57286,8 @@ async function hideFloatingFurniture(page, selector) {
57167
57286
  }, selector);
57168
57287
  }
57169
57288
  async function composeFilmstrip(frames) {
57170
- const scaled = await Promise.all(frames.map((frame) => sharp4(frame).resize({ width: FRAME_WIDTH }).png().toBuffer()));
57171
- const first = await sharp4(scaled[0]).metadata();
57289
+ const scaled = await Promise.all(frames.map((frame) => sharp5(frame).resize({ width: FRAME_WIDTH }).png().toBuffer()));
57290
+ const first = await sharp5(scaled[0]).metadata();
57172
57291
  const frameHeight = first.height ?? 300;
57173
57292
  const cellHeight = frameHeight + LABEL_HEIGHT;
57174
57293
  const rows = Math.ceil(scaled.length / GRID_COLUMNS);
@@ -57187,7 +57306,7 @@ async function composeFilmstrip(frames) {
57187
57306
  { input: frame, left: column * FRAME_WIDTH, top: row * cellHeight + LABEL_HEIGHT }
57188
57307
  ];
57189
57308
  });
57190
- return await sharp4({
57309
+ return await sharp5({
57191
57310
  create: {
57192
57311
  width,
57193
57312
  height: cellHeight * rows,
@@ -57376,12 +57495,12 @@ var DEFAULT_SEGMENT_OPTIONS = {
57376
57495
  };
57377
57496
 
57378
57497
  // src/engine/landing-library/visualHash.ts
57379
- import sharp5 from "sharp";
57498
+ import sharp6 from "sharp";
57380
57499
  var HASH_WIDTH = 9;
57381
57500
  var HASH_HEIGHT = 8;
57382
57501
  async function perceptualHash(image) {
57383
57502
  try {
57384
- const raw = await sharp5(image).greyscale().resize(HASH_WIDTH, HASH_HEIGHT, { fit: "fill" }).raw().toBuffer();
57503
+ const raw = await sharp6(image).greyscale().resize(HASH_WIDTH, HASH_HEIGHT, { fit: "fill" }).raw().toBuffer();
57385
57504
  return bitsToHex(rowGradientBits(new Uint8Array(raw)));
57386
57505
  } catch {
57387
57506
  return null;
@@ -57895,7 +58014,7 @@ import { defineCommand as defineCommand175 } from "citty";
57895
58014
  // src/commands/landing/inspiration/shot.ts
57896
58015
  import { mkdir as mkdir13, writeFile as writeFile18 } from "fs/promises";
57897
58016
  import path42 from "path";
57898
- import sharp6 from "sharp";
58017
+ import sharp7 from "sharp";
57899
58018
  var READABLE_SHOT = {
57900
58019
  maxWidth: 1440,
57901
58020
  maxHeight: 4e3,
@@ -57904,7 +58023,7 @@ var READABLE_SHOT = {
57904
58023
  };
57905
58024
  async function toReadableShot(original) {
57906
58025
  try {
57907
- return await sharp6(original).resize({
58026
+ return await sharp7(original).resize({
57908
58027
  width: READABLE_SHOT.maxWidth,
57909
58028
  height: READABLE_SHOT.maxHeight,
57910
58029
  fit: "inside",