@koda-sl/baker-cli 0.70.0 → 0.74.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
@@ -9,10 +9,10 @@ import {
9
9
  defaultRegistry,
10
10
  generateCatalog,
11
11
  validateCanvasDeep
12
- } from "./chunk-XFDZVKLF.js";
12
+ } from "./chunk-JIDZ37KG.js";
13
13
 
14
14
  // src/cli.ts
15
- import { defineCommand as defineCommand138, runMain } from "citty";
15
+ import { defineCommand as defineCommand141, runMain } from "citty";
16
16
 
17
17
  // src/commands/actions/index.ts
18
18
  import { defineCommand as defineCommand12 } from "citty";
@@ -147,9 +147,9 @@ async function handleResponse(response) {
147
147
  throw new ApiError("INTERNAL_ERROR", "Failed to parse API response as JSON");
148
148
  }
149
149
  }
150
- async function apiGet(path6, params) {
150
+ async function apiGet(path7, params) {
151
151
  const env = getEnv();
152
- const url = new URL(path6, env.BAKER_API_URL);
152
+ const url = new URL(path7, env.BAKER_API_URL);
153
153
  if (params) {
154
154
  const clean = sanitizeParams(params);
155
155
  for (const [key, value] of Object.entries(clean)) {
@@ -174,12 +174,12 @@ async function apiGet(path6, params) {
174
174
  }
175
175
  return handleResponse(response);
176
176
  }
177
- async function apiPost(path6, body, opts) {
177
+ async function apiPost(path7, body, opts) {
178
178
  const env = getEnv();
179
179
  const timeoutMs = opts?.timeoutMs ?? 6e4;
180
180
  let response;
181
181
  try {
182
- response = await fetchWithRateLimitRetry(new URL(path6, env.BAKER_API_URL).toString(), {
182
+ response = await fetchWithRateLimitRetry(new URL(path7, env.BAKER_API_URL).toString(), {
183
183
  method: "POST",
184
184
  headers: {
185
185
  Authorization: `Bearer ${env.BAKER_API_KEY}`,
@@ -314,6 +314,52 @@ function testimonialNormalizer(record, full) {
314
314
  }
315
315
  return compactTestimonial(record);
316
316
  }
317
+ function round2(value) {
318
+ if (typeof value !== "number" || Number.isNaN(value)) {
319
+ return null;
320
+ }
321
+ return Math.round(value * 100) / 100;
322
+ }
323
+ function asRecord(value) {
324
+ return value && typeof value === "object" ? value : {};
325
+ }
326
+ function compactWinningAd(record) {
327
+ const dna = asRecord(record.dna);
328
+ return {
329
+ advertiser: String(record.advertiser ?? ""),
330
+ platform: String(record.platform ?? ""),
331
+ format: String(record.format ?? ""),
332
+ relevance: round2(record.relevance),
333
+ winner_score: round2(record.winner_score),
334
+ summary: String(dna.creative_concept ?? ""),
335
+ advertiser_id: String(record.advertiser_id ?? ""),
336
+ ad_id: String(record.ad_id ?? ""),
337
+ media_url: String(record.media_url ?? "")
338
+ };
339
+ }
340
+ function fullWinningAd(record) {
341
+ const compact = compactWinningAd(record);
342
+ const dna = asRecord(record.dna);
343
+ return {
344
+ ...compact,
345
+ winner_category: String(record.winner_category ?? ""),
346
+ media_kind: record.media_kind ?? null,
347
+ days_active: record.days_active ?? null,
348
+ reach: record.reach ?? null,
349
+ active: typeof record.active === "boolean" ? record.active : null,
350
+ angle: dna.angle ?? null,
351
+ awareness_stage: dna.awareness_stage ?? null,
352
+ target_persona: dna.target_persona ?? null,
353
+ hook_archetype: dna.hook_archetype ?? null,
354
+ industry: dna.industry ?? null
355
+ };
356
+ }
357
+ function winningAdNormalizer(record, full) {
358
+ if (full) {
359
+ return fullWinningAd(record);
360
+ }
361
+ return compactWinningAd(record);
362
+ }
317
363
  function applyFieldMask(data, fields) {
318
364
  const result = {};
319
365
  for (const field of fields) {
@@ -918,31 +964,31 @@ function cachePath(category, key) {
918
964
  return join(dir, `${hashKey(key)}.json`);
919
965
  }
920
966
  function cacheGet(category, key) {
921
- const path6 = cachePath(category, key);
922
- if (!existsSync(path6)) {
967
+ const path7 = cachePath(category, key);
968
+ if (!existsSync(path7)) {
923
969
  return null;
924
970
  }
925
971
  try {
926
- const raw = readFileSync(path6, "utf-8");
972
+ const raw = readFileSync(path7, "utf-8");
927
973
  const entry = JSON.parse(raw);
928
974
  if (entry.expiresAt < Date.now()) {
929
- rmSync(path6, { force: true });
975
+ rmSync(path7, { force: true });
930
976
  return null;
931
977
  }
932
978
  return entry;
933
979
  } catch {
934
- rmSync(path6, { force: true });
980
+ rmSync(path7, { force: true });
935
981
  return null;
936
982
  }
937
983
  }
938
984
  function cacheSet(category, key, data, ttlMs, fields) {
939
- const path6 = cachePath(category, key);
985
+ const path7 = cachePath(category, key);
940
986
  const entry = {
941
987
  expiresAt: Date.now() + ttlMs,
942
988
  data,
943
989
  fields
944
990
  };
945
- writeFileSync(path6, JSON.stringify(entry), "utf-8");
991
+ writeFileSync(path7, JSON.stringify(entry), "utf-8");
946
992
  }
947
993
  var HOUR = 60 * 60 * 1e3;
948
994
  var MINUTE = 60 * 1e3;
@@ -5633,10 +5679,10 @@ var IDENTITY_FIELDS_BY_LEVEL = {
5633
5679
  };
5634
5680
  function composeFields2(intent, level) {
5635
5681
  const intentFields = INSIGHTS_INTENTS[intent].fields;
5636
- const identity = IDENTITY_FIELDS_BY_LEVEL[level];
5682
+ const identity2 = IDENTITY_FIELDS_BY_LEVEL[level];
5637
5683
  const seen = /* @__PURE__ */ new Set();
5638
5684
  const out = [];
5639
- for (const f of [...identity, ...intentFields]) {
5685
+ for (const f of [...identity2, ...intentFields]) {
5640
5686
  if (!seen.has(f)) {
5641
5687
  seen.add(f);
5642
5688
  out.push(f);
@@ -7714,6 +7760,24 @@ async function probeDuration(filePath) {
7714
7760
  import { readFile as readFile2 } from "fs/promises";
7715
7761
  import path2 from "path";
7716
7762
  import { defineCommand as defineCommand74 } from "citty";
7763
+
7764
+ // src/commands/canvas/placeholders.ts
7765
+ function unsuppliedPlaceholderAssets(canvas) {
7766
+ const nodes = canvas?.nodes;
7767
+ if (!Array.isArray(nodes)) return [];
7768
+ const out = [];
7769
+ for (const n of nodes) {
7770
+ const node = n;
7771
+ if (node?.type !== "ingest") continue;
7772
+ const params = node.params;
7773
+ if (params?.source === "path" && typeof params.path === "string" && params.path.includes("[TODO")) {
7774
+ out.push({ node: String(node.id ?? "?"), placeholder: params.path });
7775
+ }
7776
+ }
7777
+ return out;
7778
+ }
7779
+
7780
+ // src/commands/canvas/run.ts
7717
7781
  var runCommand = defineCommand74({
7718
7782
  meta: { name: "run", description: "Validate and execute a canvas JSON file." },
7719
7783
  args: {
@@ -7735,6 +7799,25 @@ var runCommand = defineCommand74({
7735
7799
  `);
7736
7800
  process.exit(2);
7737
7801
  }
7802
+ const pending = unsuppliedPlaceholderAssets(parsed);
7803
+ if (pending.length > 0) {
7804
+ process.stderr.write(
7805
+ `${JSON.stringify(
7806
+ {
7807
+ ok: false,
7808
+ error: {
7809
+ code: "unsupplied_assets",
7810
+ message: "This canvas still has placeholder asset slots \u2014 supply a real image (or video) at each before running. Each `el_*` ingest is a [TODO] you fill with the real logo/subject/product, then re-run.",
7811
+ assets: pending
7812
+ }
7813
+ },
7814
+ null,
7815
+ 2
7816
+ )}
7817
+ `
7818
+ );
7819
+ process.exit(2);
7820
+ }
7738
7821
  const engine = createEngineFromEnv({
7739
7822
  cacheDir: args["cache-dir"] ? String(args["cache-dir"]) : void 0,
7740
7823
  outputsDir: args["outputs-dir"] ? String(args["outputs-dir"]) : void 0,
@@ -8192,7 +8275,7 @@ var scaffoldStaticAdCommand = defineCommand75({
8192
8275
 
8193
8276
  // src/commands/canvas/scaffold-video.ts
8194
8277
  import { cp, mkdir, readFile as readFile4, writeFile as writeFile2 } from "fs/promises";
8195
- import path4 from "path";
8278
+ import path5 from "path";
8196
8279
  import { defineCommand as defineCommand76 } from "citty";
8197
8280
 
8198
8281
  // src/engine/scaffold/video.ts
@@ -8200,7 +8283,19 @@ import { z as z3 } from "zod";
8200
8283
  var FIXED_TTS_MODEL = "elevenlabs/eleven_v3";
8201
8284
  var FIXED_SFX_MODEL = "elevenlabs/eleven_text_to_sound_v2";
8202
8285
  var FIXED_MUSIC_MODEL = "elevenlabs/music-v1";
8286
+ var FIXED_LIPSYNC_MODEL = "fal/veed-lipsync";
8203
8287
  var MUSIC_BED_GAIN_DB = -12;
8288
+ var NARRATOR_SPEAKERS = /* @__PURE__ */ new Set([
8289
+ "voiceover",
8290
+ "voice_over",
8291
+ "narrator",
8292
+ "narration",
8293
+ "vo",
8294
+ "announcer",
8295
+ "off_screen",
8296
+ "offscreen",
8297
+ "off-screen"
8298
+ ]);
8204
8299
  var SHARED_ASPECT_RATIOS = /* @__PURE__ */ new Set(["1:1", "16:9", "9:16", "4:3", "3:4", "21:9"]);
8205
8300
  var EDGES = ["start", "end"];
8206
8301
  function snapToSeedance(durationS) {
@@ -8216,11 +8311,39 @@ function snapToSeedance(durationS) {
8216
8311
  }
8217
8312
  return best;
8218
8313
  }
8314
+ function ceilToSeedance(durationS) {
8315
+ const max = SEEDANCE_DURATIONS[SEEDANCE_DURATIONS.length - 1];
8316
+ if (!Number.isFinite(durationS) || durationS <= 0) return SEEDANCE_DURATIONS[0];
8317
+ for (const d of SEEDANCE_DURATIONS) if (d >= durationS) return d;
8318
+ return max;
8319
+ }
8320
+ function sceneDurationS(scene) {
8321
+ const raw = scene.duration_s ?? (scene.end_s != null && scene.start_s != null ? scene.end_s - scene.start_s : 5);
8322
+ const max = SEEDANCE_DURATIONS[SEEDANCE_DURATIONS.length - 1];
8323
+ return Math.min(Math.max(raw, 0.5), max);
8324
+ }
8325
+ function trimArgs(durationS) {
8326
+ return [
8327
+ "-i",
8328
+ "{{in.clip}}",
8329
+ "-t",
8330
+ durationS.toFixed(3),
8331
+ "-an",
8332
+ "-c:v",
8333
+ "libx264",
8334
+ "-pix_fmt",
8335
+ "yuv420p",
8336
+ "{{out.video}}"
8337
+ ];
8338
+ }
8219
8339
  var FrameAsset = z3.object({ url: z3.string().optional() }).loose().optional();
8220
8340
  var DialogueLine = z3.object({
8221
8341
  speaker: z3.string().optional(),
8222
8342
  line: z3.string().optional(),
8343
+ // Absolute seconds on the source timeline (the deconstruct emits both).
8223
8344
  start_s: z3.number().optional(),
8345
+ end_s: z3.number().optional(),
8346
+ delivery: z3.string().optional(),
8224
8347
  voice_description: z3.string().optional()
8225
8348
  }).loose();
8226
8349
  var Sfx = z3.object({
@@ -8260,7 +8383,12 @@ var VideoBlueprint = z3.object({
8260
8383
  identified_track: z3.object({ title: z3.string().optional(), artist: z3.string().optional() }).loose().nullish()
8261
8384
  }).loose().optional(),
8262
8385
  cast: z3.array(z3.object({ id: z3.string().optional(), description: z3.string().optional() }).loose()).optional(),
8263
- voiceover: z3.object({ voice_description: z3.string().optional() }).loose().optional()
8386
+ voiceover: z3.object({
8387
+ // on_camera | mixed → mouths are on screen (lip-sync candidates);
8388
+ // voiceover | none → narration over the picture (no lip-sync).
8389
+ mode: z3.string().optional(),
8390
+ voice_description: z3.string().optional()
8391
+ }).loose().optional()
8264
8392
  }).loose().optional(),
8265
8393
  scenes: z3.array(Scene).min(1)
8266
8394
  }).loose();
@@ -8392,7 +8520,17 @@ function buildFramePrompt(edge, sceneIndex, framePrompt, present, hasAnchor) {
8392
8520
  ].join("\n");
8393
8521
  const description = framePrompt?.trim() || `the ${edge} frame of scene ${sceneIndex + 1} \u2014 describe the full composition, subjects, setting, action, lighting, and palette here. (Edit this line to change ONLY this frame.)`;
8394
8522
  return [
8395
- `Render the ${EDGE} frame of scene ${sceneIndex + 1} as a single still image. This prompt is self-contained and edit-per-frame: change the FRAME DESCRIPTION below to alter ONLY this frame. EXCLUDE all overlay text, captions, stickers, and watermarks \u2014 they are composited on top later.`,
8523
+ `Render the ${EDGE} frame of scene ${sceneIndex + 1} as a single still image. This prompt is self-contained and edit-per-frame: change the FRAME DESCRIPTION below to alter ONLY this frame.`,
8524
+ "",
8525
+ "CRITICAL \u2014 RENDER A CLEAN PLATE WITH ZERO TEXT OR GRAPHICS:",
8526
+ "This frame is a background plate. ALL words, captions, headlines, lower-third bars,",
8527
+ "news tickers/crawls, chyrons, on-screen logos/wordmarks, station bugs, watermarks,",
8528
+ "subtitles, UI and numbers are added afterwards as a separate HTML layer. Render NONE",
8529
+ "of them \u2014 no legible text anywhere in the image, not even in the background, on the",
8530
+ "news desk, on screens, or as part of a 'broadcast look'. If a reference image (a logo,",
8531
+ "a desk, a studio) contains any text or graphics, DO NOT reproduce that text \u2014 render",
8532
+ "the subject/scene only, with blank surfaces where text would be. Imperfect/garbled",
8533
+ "letterforms are the worst outcome; leave those areas clean.",
8396
8534
  "",
8397
8535
  "REFERENCE IMAGES (in the order provided):",
8398
8536
  legend,
@@ -8400,7 +8538,7 @@ function buildFramePrompt(edge, sceneIndex, framePrompt, present, hasAnchor) {
8400
8538
  "FRAME DESCRIPTION (this frame's editable prompt):",
8401
8539
  description,
8402
8540
  "",
8403
- "Keep every recurring element identical to its reference image across all frames. Use the GLOBAL STYLE REFERENCE only for shared cast identity, palette, typography mood, and aspect ratio \u2014 do NOT copy another scene's composition from it; this frame's content is the FRAME DESCRIPTION above.",
8541
+ "Keep every recurring element identical to its reference image across all frames. Use the GLOBAL STYLE REFERENCE only for shared cast identity, palette, typography mood, and aspect ratio \u2014 do NOT copy another scene's composition from it; this frame's content is the FRAME DESCRIPTION above. Again: NO rendered text or graphic overlays \u2014 clean plate only.",
8404
8542
  "",
8405
8543
  "GLOBAL STYLE REFERENCE (shared across frames; not this frame's content):",
8406
8544
  "{{target_blueprint}}"
@@ -8451,7 +8589,7 @@ function buildSeedancePrompt(scene, sceneIndex, present) {
8451
8589
  if (transcript) parts.push(`Transcript: ${transcript}`);
8452
8590
  return parts.join("\n");
8453
8591
  }
8454
- function buildSceneVisuals(blueprint, slots, opts, nodes) {
8592
+ function buildSceneVisuals(blueprint, slots, opts, nodes, sceneTurns) {
8455
8593
  const ar = aspectRatioParam(blueprint);
8456
8594
  const reuse = opts.frames === "reuse";
8457
8595
  const clipRefs = [];
@@ -8473,10 +8611,11 @@ function buildSceneVisuals(blueprint, slots, opts, nodes) {
8473
8611
  ctx,
8474
8612
  nodes
8475
8613
  );
8614
+ const dur = sceneDurationS(scene);
8476
8615
  const clipParams = {
8477
8616
  model: opts.videoModel,
8478
8617
  prompt: buildSeedancePrompt(scene, i, slotsForScene(slots, i)),
8479
- duration: snapToSeedance(scene.duration_s ?? 5)
8618
+ duration: ceilToSeedance(dur)
8480
8619
  };
8481
8620
  if (ar) clipParams.aspect_ratio = ar;
8482
8621
  nodes.push({
@@ -8485,7 +8624,29 @@ function buildSceneVisuals(blueprint, slots, opts, nodes) {
8485
8624
  inputs: { first_frame: firstFrame, last_frame: lastFrame },
8486
8625
  params: clipParams
8487
8626
  });
8488
- clipRefs.push(`$ref:s${i}_clip.video`);
8627
+ let base = `$ref:s${i}_clip.video`;
8628
+ const onCam = (sceneTurns.get(i) ?? []).filter((t) => t.onCamera);
8629
+ const solo = onCam.length === 1 ? onCam[0] : void 0;
8630
+ if (solo) {
8631
+ nodes.push({
8632
+ id: `s${i}_lipsync`,
8633
+ type: "video_lipsync",
8634
+ inputs: { video: base, audio: solo.audioRef },
8635
+ params: { model: FIXED_LIPSYNC_MODEL }
8636
+ });
8637
+ base = `$ref:s${i}_lipsync.video`;
8638
+ }
8639
+ if (ceilToSeedance(dur) === dur) {
8640
+ clipRefs.push(base);
8641
+ } else {
8642
+ nodes.push({
8643
+ id: `s${i}_trim`,
8644
+ type: "ffmpeg",
8645
+ inputs: { clip: base },
8646
+ params: { args: trimArgs(dur), outputs: { video: { kind: "video", ext: "mp4" } } }
8647
+ });
8648
+ clipRefs.push(`$ref:s${i}_trim.video`);
8649
+ }
8489
8650
  });
8490
8651
  return clipRefs;
8491
8652
  }
@@ -8498,8 +8659,21 @@ function musicBedPrompt(blueprint, musicPrompt) {
8498
8659
 
8499
8660
  Reference vibe: the original used "${title}"${by} (identified via AudD). Match its mood, tempo, and energy with ORIGINAL music \u2014 do not reproduce the track.`;
8500
8661
  }
8501
- function buildAudio(blueprint, nodes) {
8662
+ function onCameraDialogue(blueprint) {
8663
+ const mode = blueprint.global?.voiceover?.mode;
8664
+ return mode !== "voiceover" && mode !== "none";
8665
+ }
8666
+ var castIdSet = (blueprint) => new Set((blueprint.global?.cast ?? []).map((c) => c.id).filter((id) => Boolean(id)));
8667
+ function isOnCameraSpeaker(speaker, casts, cameraOn) {
8668
+ if (!cameraOn) return false;
8669
+ if (NARRATOR_SPEAKERS.has(speaker.toLowerCase())) return false;
8670
+ return casts.has(speaker);
8671
+ }
8672
+ function buildDialogue(blueprint, nodes) {
8502
8673
  const tracks = [];
8674
+ const sceneTurns = /* @__PURE__ */ new Map();
8675
+ const casts = castIdSet(blueprint);
8676
+ const cameraOn = onCameraDialogue(blueprint);
8503
8677
  const voiceNodeBySpeaker = /* @__PURE__ */ new Map();
8504
8678
  const speakerDescription = (speaker) => {
8505
8679
  for (const scene of blueprint.scenes) {
@@ -8518,41 +8692,62 @@ function buildAudio(blueprint, nodes) {
8518
8692
  voiceNodeBySpeaker.set(speaker, id);
8519
8693
  return id;
8520
8694
  };
8521
- const scriptBySpeaker = /* @__PURE__ */ new Map();
8522
- const orderedSpeakers = [];
8523
- for (const scene of blueprint.scenes) {
8524
- for (const line of scene.dialogue ?? []) {
8525
- if (!line.line) continue;
8526
- const speaker = line.speaker ?? "voiceover";
8527
- const start = line.start_s ?? scene.start_s ?? 0;
8528
- const existing = scriptBySpeaker.get(speaker);
8529
- if (existing) {
8530
- existing.lines.push(line.line);
8531
- existing.start = Math.min(existing.start, start);
8532
- } else {
8533
- scriptBySpeaker.set(speaker, { lines: [line.line], start });
8534
- orderedSpeakers.push(speaker);
8535
- }
8536
- }
8537
- }
8538
8695
  const usedVoIds = /* @__PURE__ */ new Set();
8539
- orderedSpeakers.forEach((speaker, idx) => {
8540
- const script = scriptBySpeaker.get(speaker);
8541
- if (!script) return;
8542
- const voiceNode = ensureVoiceNode(speaker);
8543
- let id = sanitizeId2(`vo_${speaker}`, `vo_${idx}`);
8544
- while (usedVoIds.has(id)) id = `${id}_${idx}`;
8545
- usedVoIds.add(id);
8546
- nodes.push({
8547
- id,
8548
- type: "tts",
8549
- inputs: { voice_ref: `$ref:${voiceNode}.voice_id` },
8550
- // Join lines with a space: each line keeps its own terminal punctuation, so
8551
- // sentence boundaries (and the pauses they imply) survive into one read.
8552
- params: { model: FIXED_TTS_MODEL, text: script.lines.join(" "), voice: "{{voice_ref}}" }
8696
+ blueprint.scenes.forEach((scene, sceneIndex) => {
8697
+ const lines = (scene.dialogue ?? []).filter((l) => Boolean(l.line?.trim())).slice().sort((a, b) => (a.start_s ?? 0) - (b.start_s ?? 0));
8698
+ if (lines.length === 0) return;
8699
+ const groups = [];
8700
+ for (const line of lines) {
8701
+ const speaker = line.speaker ?? "voiceover";
8702
+ const last = groups[groups.length - 1];
8703
+ if (last && last.speaker === speaker) last.lines.push(line);
8704
+ else groups.push({ speaker, lines: [line] });
8705
+ }
8706
+ const list = [];
8707
+ groups.forEach((group, gi) => {
8708
+ const first = group.lines[0];
8709
+ const last = group.lines[group.lines.length - 1];
8710
+ if (!first || !last) return;
8711
+ const start = first.start_s ?? scene.start_s ?? 0;
8712
+ const end = last.end_s ?? last.start_s ?? scene.end_s ?? start;
8713
+ const voiceNode = ensureVoiceNode(group.speaker);
8714
+ let id = sanitizeId2(`vo_s${sceneIndex}_${group.speaker}`, `vo_${sceneIndex}_${gi}`);
8715
+ if (usedVoIds.has(id)) {
8716
+ let n = 2;
8717
+ while (usedVoIds.has(`${id}_${n}`)) n++;
8718
+ id = `${id}_${n}`;
8719
+ }
8720
+ usedVoIds.add(id);
8721
+ nodes.push({
8722
+ id,
8723
+ type: "tts",
8724
+ inputs: { voice_ref: `$ref:${voiceNode}.voice_id` },
8725
+ // Lines join with a space; each keeps its terminal punctuation so eleven_v3
8726
+ // reads the sentence boundaries (and their pauses) within the one turn.
8727
+ params: {
8728
+ model: FIXED_TTS_MODEL,
8729
+ text: group.lines.map((l) => l.line.trim()).join(" "),
8730
+ voice: "{{voice_ref}}"
8731
+ }
8732
+ });
8733
+ const turn = {
8734
+ sceneIndex,
8735
+ speaker: group.speaker,
8736
+ onCamera: isOnCameraSpeaker(group.speaker, casts, cameraOn),
8737
+ start_s: start,
8738
+ end_s: end,
8739
+ ttsId: id,
8740
+ audioRef: `$ref:${id}.audio`
8741
+ };
8742
+ list.push(turn);
8743
+ tracks.push({ slot: id, ref: turn.audioRef, start_s: start, end_s: end, kind: "vo" });
8553
8744
  });
8554
- tracks.push({ slot: id, ref: `$ref:${id}.audio`, start_s: script.start });
8745
+ sceneTurns.set(sceneIndex, list);
8555
8746
  });
8747
+ return { tracks, sceneTurns };
8748
+ }
8749
+ function buildSfxMusic(blueprint, nodes) {
8750
+ const tracks = [];
8556
8751
  blueprint.scenes.forEach((scene, i) => {
8557
8752
  (scene.sfx ?? []).forEach((sfx, k) => {
8558
8753
  const text = sfx.sound_effect_prompt ?? sfx.description;
@@ -8561,7 +8756,12 @@ function buildAudio(blueprint, nodes) {
8561
8756
  const params = { model: FIXED_SFX_MODEL, text };
8562
8757
  if (typeof sfx.duration_s === "number") params.duration_seconds = Math.min(Math.max(sfx.duration_s, 0.5), 30);
8563
8758
  nodes.push({ id, type: "sound_effect", params });
8564
- tracks.push({ slot: `sfx_s${i}_${k}`, ref: `$ref:${id}.audio`, start_s: sfx.at_s ?? scene.start_s ?? 0 });
8759
+ tracks.push({
8760
+ slot: `sfx_s${i}_${k}`,
8761
+ ref: `$ref:${id}.audio`,
8762
+ start_s: sfx.at_s ?? scene.start_s ?? 0,
8763
+ kind: "sfx"
8764
+ });
8565
8765
  });
8566
8766
  });
8567
8767
  const musicPrompt = blueprint.global?.music?.music_prompt;
@@ -8573,10 +8773,90 @@ function buildAudio(blueprint, nodes) {
8573
8773
  type: "music",
8574
8774
  params: { model: FIXED_MUSIC_MODEL, prompt: musicBedPrompt(blueprint, musicPrompt), music_length_ms: musicMs }
8575
8775
  });
8576
- tracks.unshift({ slot: "music", ref: "$ref:music_bed.audio", start_s: 0, gain_db: MUSIC_BED_GAIN_DB });
8776
+ tracks.push({ slot: "music", ref: "$ref:music_bed.audio", start_s: 0, gain_db: MUSIC_BED_GAIN_DB, kind: "music" });
8577
8777
  }
8578
8778
  return tracks;
8579
8779
  }
8780
+ var OverlayStyle = z3.object({ color_hex: z3.string().optional(), background: z3.string().optional(), size: z3.string().optional() }).loose();
8781
+ var Overlay = z3.object({
8782
+ text: z3.string().optional(),
8783
+ appears_at_s: z3.number().optional(),
8784
+ duration_s: z3.number().optional(),
8785
+ position: z3.string().optional(),
8786
+ role: z3.string().optional(),
8787
+ animation: z3.string().optional(),
8788
+ animation_detail: z3.string().optional(),
8789
+ style: OverlayStyle.optional()
8790
+ }).loose();
8791
+ var FloatingElement = z3.object({
8792
+ kind: z3.string().optional(),
8793
+ description: z3.string().optional(),
8794
+ brand_name: z3.string().nullish(),
8795
+ what_it_represents: z3.string().optional(),
8796
+ appears_at_s: z3.number().optional(),
8797
+ duration_s: z3.number().optional(),
8798
+ position: z3.string().optional()
8799
+ }).loose();
8800
+ function escapeHtml(s) {
8801
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
8802
+ }
8803
+ function commentSafe(s) {
8804
+ return escapeHtml(s).replace(/-{2,}/g, "\u2013");
8805
+ }
8806
+ var SUPPORTED_ANIMS = /* @__PURE__ */ new Set(["fade", "slide_up", "slide_down", "pop"]);
8807
+ function normalizeAnim(animation) {
8808
+ if (!animation || animation === "none") return void 0;
8809
+ const mapped = animation === "slide" ? "slide_up" : animation;
8810
+ return SUPPORTED_ANIMS.has(mapped) ? mapped : void 0;
8811
+ }
8812
+ function positionClass(position) {
8813
+ const p = (position ?? "bottom_center").toLowerCase().replace(/[^a-z]+/g, "-");
8814
+ return `pos-${p}`;
8815
+ }
8816
+ function overlayElement(ov, sceneStart) {
8817
+ if (!ov.text?.trim()) return "";
8818
+ const at = ov.appears_at_s ?? sceneStart;
8819
+ const dur = ov.duration_s ?? 2.5;
8820
+ const role = ov.role ? ` data-role="${escapeHtml(ov.role)}"` : "";
8821
+ const normAnim = normalizeAnim(ov.animation);
8822
+ const anim = normAnim ? ` data-anim="${normAnim}"` : "";
8823
+ const detail = ov.animation_detail ? ` data-anim-detail="${escapeHtml(ov.animation_detail)}"` : "";
8824
+ return `<div class="ov ${positionClass(ov.position)}" data-start="${at}" data-dur="${dur}"${role}${anim}${detail}>${escapeHtml(ov.text.trim())}</div>`;
8825
+ }
8826
+ function floatingStub(fe, sceneStart) {
8827
+ const at = fe.appears_at_s ?? sceneStart;
8828
+ const dur = fe.duration_s ?? 2.5;
8829
+ const kind = commentSafe(fe.kind ?? "element");
8830
+ const label = commentSafe(fe.brand_name || fe.what_it_represents || fe.description || fe.kind || "element");
8831
+ const slug = (fe.kind ?? "element").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "element";
8832
+ return [
8833
+ `<!-- ${kind}: ${label} @ ${at}s for ${dur}s (${positionClass(fe.position)}). Drop an image in this dir and uncomment:`,
8834
+ `<img class="ov ${positionClass(fe.position)}" src="your-${slug}.png" data-start="${at}" data-dur="${dur}" alt="" /> -->`
8835
+ ].join("\n");
8836
+ }
8837
+ function buildOverlayHtml(input) {
8838
+ const blueprint = VideoBlueprint.parse(input);
8839
+ const blocks = [
8840
+ [
8841
+ "<!-- \u2B07 OVERLAY LAYER \u2014 this is YOUR HTML to paint. The reference's overlays are",
8842
+ " seeded below as plain elements (text + position class + data-start/data-dur).",
8843
+ " Restyle freely in <style>, regroup, animate, swap a logo placeholder for a",
8844
+ " real <img> you drop in this dir. The runtime only shows/hides by timestamp;",
8845
+ " it makes NO styling decisions. Positions: edit the .pos-* classes or add your own. -->"
8846
+ ].join("\n")
8847
+ ];
8848
+ for (const scene of blueprint.scenes) {
8849
+ const sceneStart = scene.start_s ?? 0;
8850
+ const overlays = z3.array(Overlay).safeParse(scene.overlays ?? []);
8851
+ const floats = z3.array(FloatingElement).safeParse(scene.floating_elements ?? []);
8852
+ const parts = [
8853
+ ...overlays.success ? overlays.data.map((ov) => overlayElement(ov, sceneStart)) : [],
8854
+ ...floats.success ? floats.data.map((fe) => floatingStub(fe, sceneStart)) : []
8855
+ ].filter(Boolean);
8856
+ if (parts.length > 0) blocks.push(parts.join("\n"));
8857
+ }
8858
+ return blocks.join("\n\n");
8859
+ }
8580
8860
  function lastSceneEnd(blueprint) {
8581
8861
  let end = 0;
8582
8862
  for (const s of blueprint.scenes) end = Math.max(end, s.end_s ?? 0);
@@ -8608,7 +8888,8 @@ function scaffoldVideoCanvas(input, elementsInput, opts) {
8608
8888
  params: { source: "path", path: todoPath2(elements[i], slot.label), expect: "image" }
8609
8889
  });
8610
8890
  });
8611
- const clipRefs = buildSceneVisuals(blueprint, slots, opts, nodes);
8891
+ const { tracks: voTracks, sceneTurns } = buildDialogue(blueprint, nodes);
8892
+ const clipRefs = buildSceneVisuals(blueprint, slots, opts, nodes, sceneTurns);
8612
8893
  const concatInputs = {};
8613
8894
  clipRefs.forEach((ref, i) => {
8614
8895
  concatInputs[`c${i}`] = ref;
@@ -8628,12 +8909,12 @@ function scaffoldVideoCanvas(input, elementsInput, opts) {
8628
8909
  id: "overlaid",
8629
8910
  type: "hyperframe_render",
8630
8911
  inputs: { background: videoRef },
8631
- params: { composition: opts.overlayCompositionPath, overlays, floating_elements: floating }
8912
+ params: { composition: opts.overlayCompositionPath }
8632
8913
  });
8633
8914
  videoRef = "$ref:overlaid.video";
8634
8915
  videoNode = "overlaid";
8635
8916
  }
8636
- const tracks = buildAudio(blueprint, nodes);
8917
+ const tracks = [...voTracks, ...buildSfxMusic(blueprint, nodes)];
8637
8918
  if (tracks.length > 0) {
8638
8919
  const mixInputs = {};
8639
8920
  for (const t of tracks) mixInputs[t.slot] = t.ref;
@@ -8666,8 +8947,18 @@ function scaffoldVideoCanvas(input, elementsInput, opts) {
8666
8947
  "1:a:0",
8667
8948
  "-c:v",
8668
8949
  "copy",
8950
+ // The raw mix is a quiet mono track (tts + ducked bed), which reads as
8951
+ // "no sound" in casual players. Normalize integrated loudness to the
8952
+ // social/broadcast target (-14 LUFS, -1.5 dBTP) and upmix to stereo so
8953
+ // every rendered ad is loud and plays everywhere.
8954
+ "-af",
8955
+ "loudnorm=I=-14:TP=-1.5:LRA=11,aformat=channel_layouts=stereo",
8669
8956
  "-c:a",
8670
8957
  "aac",
8958
+ "-b:a",
8959
+ "192k",
8960
+ "-ar",
8961
+ "48000",
8671
8962
  "-shortest",
8672
8963
  "{{out.video}}"
8673
8964
  ],
@@ -8681,43 +8972,69 @@ function scaffoldVideoCanvas(input, elementsInput, opts) {
8681
8972
  metadata: {
8682
8973
  name: "video reproduction",
8683
8974
  description: VIDEO_GUIDE,
8684
- todo: buildVideoTodo(videoReport(input, elementsInput), overlays.length, floating.length, opts)
8975
+ todo: buildVideoTodo(videoReport(input, elementsInput), overlays.length, floating.length, opts),
8976
+ // The timing plan `baker canvas validate` checks before any billed render:
8977
+ // sequenced voiceover turns (no overlap), audio ≈ video length, and which
8978
+ // scenes must be lip-synced.
8979
+ video: buildVideoMeta(blueprint, sceneTurns)
8685
8980
  },
8686
8981
  nodes,
8687
8982
  output: { node: videoNode, output: "video" }
8688
8983
  };
8689
8984
  }
8985
+ function buildVideoMeta(blueprint, sceneTurns) {
8986
+ const vo_segments = [];
8987
+ const talking_scenes = [];
8988
+ for (const [scene, turns] of [...sceneTurns.entries()].sort((a, b) => a[0] - b[0])) {
8989
+ for (const t of turns) {
8990
+ vo_segments.push({ slot: t.ttsId, start_s: t.start_s, end_s: t.end_s, scene, speaker: t.speaker });
8991
+ }
8992
+ if (turns.filter((t) => t.onCamera).length === 1) {
8993
+ talking_scenes.push({ scene, lipsync_node: `s${scene}_lipsync` });
8994
+ }
8995
+ }
8996
+ return {
8997
+ duration_s: blueprint.source?.duration_s ?? lastSceneEnd(blueprint),
8998
+ vo_segments,
8999
+ talking_scenes
9000
+ };
9001
+ }
8690
9002
  var VIDEO_GUIDE = [
8691
- "Scaffolded by `baker canvas scaffold-video` \u2014 a runnable reproduction of your reference video. Each scene is two AI-generated boundary frames (start/end) animated into a clip, concatenated, overlaid with timed text, and mixed with voiceover + SFX + music. Edit it, supply the real assets, then run.",
9003
+ "Scaffolded by `baker canvas scaffold-video` \u2014 a runnable reproduction of your reference video. Per scene: two AI-generated CLEAN-PLATE frames (no baked text) \u2192 a clip \u2192 trimmed to the real scene length so the picture stays on the audio timeline \u2192 optional lip-sync \u2192 concatenated. On-screen text is a separate HTML layer you paint; audio is sequenced voiceover + SFX + a ducked music bed, normalized stereo. It is a DRAFT: edit it, supply the real assets, then validate and run.",
8692
9004
  "",
8693
9005
  "WHAT TO DO NEXT:",
8694
9006
  "1. Edit each frame's prompt IN PLACE. Every `s<i>_start` / `s<i>_end` node has its OWN self-contained `params.prompt` (the FRAME DESCRIPTION) \u2014 editing one changes only that frame. Rewrite the cast, product, claims, palette into the ad you want.",
8695
- "2. Drop ONE real source image at each `el_*` ingest `[TODO]` path. Each recurring element (person/product/logo) is reused across every frame it appears in, so the same identity stays consistent.",
8696
- "3. Confirm the `voice_select` casting (one per speaker). The voiceover is ONE continuous `tts` per speaker \u2014 punctuation, ALL-CAPS, and spacing are read verbatim by eleven_v3 for emphasis/pauses, so edit `params.text` to shape delivery.",
8697
- "4. Text overlays are composited on top (not baked into frames) by the `overlaid` node \u2014 edit the `overlays` array there. For on-brand type, drop `brand-bold.otf` / `brand-regular.otf` into the `video-overlay-composition/` dir (referenced via @font-face); otherwise a system font is used. You don't always need text \u2014 it's often cleaner to overlay it than bake it in.",
8698
- "5. `baker canvas validate` then `baker canvas run`. Running generates many billed image/video/audio assets \u2014 it is not free.",
9007
+ "2. Drop ONE real source image at each `el_*` ingest `[TODO]` path. Each recurring element (person/product/logo) is reused across every frame it appears in, so the same identity stays consistent. `baker canvas run` REFUSES to start until every `[TODO]` slot holds a real source \u2014 so this is mandatory, not optional.",
9008
+ "3. Confirm the `voice_select` casting (one per speaker). Voiceover is SEQUENCED: each contiguous same-speaker turn is its own `tts` placed at its real start, so dialogue alternates instead of stacking. Edit a turn's `params.text` (punctuation / ALL-CAPS / line breaks are read verbatim by eleven_v3 for emphasis and pauses) to shape delivery; re-author the words to be TRUE for your brand.",
9009
+ "4. Lip-sync: scenes with a single on-camera speaker route their clip through `video_lipsync` (~20 cr each) so the mouth matches the line. Two-speaker scenes are left un-synced \u2014 split them or pick a primary speaker if you want sync. Drop the node to skip.",
9010
+ "5. Overlays are REAL HTML you paint. Open `video-overlay-composition/index.html`: the reference's overlays are seeded inside `#overlay-root` as plain elements (text + a `.pos-*` class + `data-start`/`data-dur`). Restyle the CSS freely \u2014 build lower-thirds, a ticker, whatever the look needs \u2014 and replace a logo placeholder with a real `<img>` you drop in that dir. The runtime only shows/hides by timestamp; it makes no styling decisions. Drop `brand-bold.otf` / `brand-regular.otf` there for on-brand type.",
9011
+ "6. `baker canvas validate` (proves audio/lip-sync timing for free) then `baker canvas run` (generates many billed image/video/audio assets \u2014 not free).",
8699
9012
  "",
8700
9013
  "Tip: `prompt.json` is the deconstruction provenance + the demoted GLOBAL STYLE REFERENCE each frame reads for shared palette/cast cohesion. It is NOT the per-frame editing surface \u2014 the frame nodes are."
8701
9014
  ].join("\n");
8702
9015
  function buildVideoTodo(report, overlayCount, floatingCount, opts) {
8703
9016
  return {
8704
- edit_frames_in_place: "Each s<i>_start / s<i>_end node has its own editable params.prompt (FRAME DESCRIPTION). Edit per frame; the blueprint is only a shared style reference.",
9017
+ edit_frames_in_place: "Each s<i>_start / s<i>_end node has its own editable params.prompt (FRAME DESCRIPTION). Edit per frame; the blueprint is only a shared style reference. Frames are CLEAN PLATES \u2014 they render no on-screen text; all text is the overlay HTML layer.",
8705
9018
  frames_mode: opts.frames ?? "generate",
9019
+ assets_required: "MANDATORY: drop a real image at every el_* [TODO] ingest before running \u2014 `baker canvas run` refuses to start (and bills nothing) until each placeholder holds a real source.",
8706
9020
  recurring_elements_to_supply: report.elements,
9021
+ text_strategy: "Decide per ad: text is either baked by the generated creative OR painted via the overlay HTML \u2014 not both. Default here is clean text-free frames + the HTML overlay layer (video-overlay-composition/index.html) as the single text source, which you fully control.",
9022
+ timeline: "Automatic: each clip is generated at >= its scene length then trimmed back to the real scene duration, so the concatenated picture stays on the same timeline as the absolute-timed audio (this is what makes the lips line up). You don't manage it.",
8707
9023
  voices_to_confirm: report.dialogue.map((d) => ({
8708
9024
  scene: d.scene,
8709
9025
  speaker: d.speaker,
8710
9026
  voice_description: d.voice_description,
8711
9027
  line: d.line
8712
9028
  })),
8713
- voiceover_note: "One continuous tts per speaker; same voice locked via voice_select.voice_id. Use punctuation / ALL CAPS / line breaks in params.text for emphasis and pacing (read verbatim).",
9029
+ voiceover_note: "Sequenced: one tts per contiguous same-speaker TURN, placed at its real start_s so turns alternate (no parallel monologues); same voice locked via voice_select.voice_id. Edit a turn's params.text (punctuation / ALL CAPS / line breaks read verbatim) to shape delivery.",
9030
+ lip_sync_note: "Scenes with a single on-camera speaker route their clip through video_lipsync (~20 cr each) so the mouth matches the line. Two-speaker scenes are left un-synced (one track can't drive two faces) \u2014 split or pick a primary. `baker canvas validate` checks every talking scene is synced.",
8714
9031
  text_overlays: {
8715
9032
  count: overlayCount,
8716
- note: "Composited by the `overlaid` node, animated per the blueprint (fade/pop/slide/typewriter/karaoke). Edit the `overlays` array. Drop brand-*.otf into video-overlay-composition/ for on-brand type."
9033
+ note: "Seeded as editable HTML inside `#overlay-root` in video-overlay-composition/index.html (text + a .pos-* class + data-start/data-dur). PAINT it: restyle the CSS, build lower-thirds/tickers, drop brand-*.otf for on-brand type. The runtime only shows/hides by timestamp."
8717
9034
  },
8718
9035
  floating_elements: {
8719
9036
  count: floatingCount,
8720
- note: floatingCount > 0 ? "Rendered as labeled placeholders. Replace with the real logo/sticker/cutout art (recurring logos are better handled as an el_* element baked into frames)." : "none detected"
9037
+ note: floatingCount > 0 ? "Seeded as labeled placeholders in index.html \u2014 replace each with a real <img> you drop into video-overlay-composition/. Recurring logos are also handled well as an el_* element baked into frames." : "none detected"
8721
9038
  },
8722
9039
  sound_effects: { count: report.sfx_count },
8723
9040
  music: {
@@ -8769,8 +9086,24 @@ function videoReport(input, elementsInput) {
8769
9086
  };
8770
9087
  }
8771
9088
 
9089
+ // src/commands/canvas/composition-path.ts
9090
+ import { existsSync as existsSync3 } from "fs";
9091
+ import path4 from "path";
9092
+ function resolveShippedCanvasDir(name, startDir, exists = existsSync3, maxDepth = 8) {
9093
+ const rel = path4.join("canvas", name);
9094
+ let dir = startDir;
9095
+ for (let i = 0; i < maxDepth; i++) {
9096
+ const candidate = path4.join(dir, rel);
9097
+ if (exists(path4.join(candidate, "meta.json"))) return candidate;
9098
+ const parent = path4.dirname(dir);
9099
+ if (parent === dir) break;
9100
+ dir = parent;
9101
+ }
9102
+ return path4.resolve(startDir, "../../../", rel);
9103
+ }
9104
+
8772
9105
  // src/commands/canvas/scaffold-video.ts
8773
- var SHIPPED_COMPOSITION_DIR = path4.resolve(import.meta.dirname, "../../../canvas/video-overlay-composition");
9106
+ var SHIPPED_COMPOSITION_DIR = resolveShippedCanvasDir("video-overlay-composition", import.meta.dirname);
8774
9107
  function resolveModel2(kind, preferred) {
8775
9108
  const ids = Object.keys(MODEL_REGISTRY[kind]);
8776
9109
  return ids.includes(preferred) ? preferred : ids[0] ?? preferred;
@@ -8891,13 +9224,19 @@ var scaffoldVideoCommand = defineCommand76({
8891
9224
  "video-model": { type: "string", description: "Override the video_generate model id for clips" }
8892
9225
  },
8893
9226
  async run({ args }) {
8894
- const videoPath = path4.resolve(String(args.file));
8895
- const base = path4.basename(videoPath, path4.extname(videoPath));
8896
- const outPath = args.out ? path4.resolve(String(args.out)) : path4.join(path4.dirname(videoPath), `${base}.video.canvas.json`);
8897
- const outDir = path4.dirname(outPath);
8898
- const blueprintPath = path4.join(outDir, "prompt.json");
9227
+ const videoPath = path5.resolve(String(args.file));
9228
+ const base = path5.basename(videoPath, path5.extname(videoPath));
9229
+ const outPath = args.out ? path5.resolve(String(args.out)) : path5.join(path5.dirname(videoPath), `${base}.video.canvas.json`);
9230
+ const outDir = path5.dirname(outPath);
9231
+ const blueprintPath = path5.join(outDir, "prompt.json");
8899
9232
  const frames = args.frames === "reuse" ? "reuse" : "generate";
8900
9233
  const maxScenes = args["max-scenes"] ? Number(args["max-scenes"]) : void 0;
9234
+ if (Number.isFinite(maxScenes)) {
9235
+ process.stderr.write(
9236
+ `\u26A0\uFE0F --max-scenes ${maxScenes} caps the deconstruct: any scenes beyond ${maxScenes} are MERGED away, reducing fidelity (fewer cuts, lost beats). Omit it to reproduce every scene.
9237
+ `
9238
+ );
9239
+ }
8901
9240
  const { deconstructModel, selectModel, imageModel, videoModel } = resolveModels2(args);
8902
9241
  const analysisCanvas = buildAnalysisCanvas(videoPath, deconstructModel, selectModel, {
8903
9242
  maxScenes: Number.isFinite(maxScenes) ? maxScenes : void 0,
@@ -8909,8 +9248,19 @@ var scaffoldVideoCommand = defineCommand76({
8909
9248
  const annotated = annotateBlueprintWithElements(blueprint, elements);
8910
9249
  await writeFile2(blueprintPath, `${JSON.stringify(annotated, null, 2)}
8911
9250
  `, "utf8");
8912
- const compositionDest = path4.join(outDir, "video-overlay-composition");
9251
+ const compositionDest = path5.join(outDir, "video-overlay-composition");
8913
9252
  await cp(SHIPPED_COMPOSITION_DIR, compositionDest, { recursive: true });
9253
+ const indexPath = path5.join(compositionDest, "index.html");
9254
+ const overlayHtml = buildOverlayHtml(blueprint);
9255
+ const indexHtml = await readFile4(indexPath, "utf8");
9256
+ const injected = indexHtml.replace("<!--OVERLAYS-->", () => overlayHtml);
9257
+ if (injected === indexHtml && overlayHtml.trim()) {
9258
+ fail2(
9259
+ "composition_marker_missing",
9260
+ `video-overlay-composition/index.html is missing the <!--OVERLAYS--> marker \u2014 cannot inject the overlay layer`
9261
+ );
9262
+ }
9263
+ await writeFile2(indexPath, injected, "utf8");
8914
9264
  const opts = {
8915
9265
  imageModel,
8916
9266
  videoModel,
@@ -8953,7 +9303,7 @@ var scaffoldVideoCommand = defineCommand76({
8953
9303
  run_estimated_credits: validation.estimatedCredits
8954
9304
  },
8955
9305
  checklist: {
8956
- edit_prompt: `Edit ${path4.basename(blueprintPath)} \u2014 the blueprint deconstructed from your video; rewrite it into the ad you want (cast, palette, copy, claims). Every scene frame reads it via target_blueprint.`,
9306
+ edit_prompt: `Edit ${path5.basename(blueprintPath)} \u2014 the blueprint deconstructed from your video; rewrite it into the ad you want (cast, palette, copy, claims). Every scene frame reads it via target_blueprint.`,
8957
9307
  recurring_elements_to_supply: report.elements,
8958
9308
  voices_to_confirm: report.dialogue.map((d) => ({
8959
9309
  scene: d.scene,
@@ -8978,7 +9328,7 @@ var scaffoldVideoCommand = defineCommand76({
8978
9328
 
8979
9329
  // src/commands/canvas/validate.ts
8980
9330
  import { readFile as readFile5 } from "fs/promises";
8981
- import path5 from "path";
9331
+ import path6 from "path";
8982
9332
  import { defineCommand as defineCommand77 } from "citty";
8983
9333
  var validateCommand = defineCommand77({
8984
9334
  meta: {
@@ -8987,7 +9337,7 @@ var validateCommand = defineCommand77({
8987
9337
  },
8988
9338
  args: { file: { type: "positional", required: true, description: "Path to canvas JSON" } },
8989
9339
  async run({ args }) {
8990
- const filePath = path5.resolve(String(args.file));
9340
+ const filePath = path6.resolve(String(args.file));
8991
9341
  const raw = await readFile5(filePath, "utf8");
8992
9342
  let parsed;
8993
9343
  try {
@@ -9222,7 +9572,7 @@ Examples:
9222
9572
  });
9223
9573
 
9224
9574
  // src/commands/ga4/query.ts
9225
- import { appendFileSync as appendFileSync2, existsSync as existsSync3, readFileSync as readFileSync6, writeFileSync as writeFileSync4 } from "fs";
9575
+ import { appendFileSync as appendFileSync2, existsSync as existsSync4, readFileSync as readFileSync6, writeFileSync as writeFileSync4 } from "fs";
9226
9576
  import { resolve as resolve2 } from "path";
9227
9577
  import { defineCommand as defineCommand81 } from "citty";
9228
9578
 
@@ -9296,7 +9646,7 @@ function writeRowsToFile2(filePath, rows, append) {
9296
9646
  const fields = extractFields2(rows);
9297
9647
  const ext = filePath.split(".").pop()?.toLowerCase();
9298
9648
  if (ext === "csv") {
9299
- if (!append || !existsSync3(filePath)) {
9649
+ if (!append || !existsSync4(filePath)) {
9300
9650
  writeFileSync4(filePath, `${toCsvRow(fields)}
9301
9651
  `, "utf-8");
9302
9652
  }
@@ -9307,12 +9657,12 @@ function writeRowsToFile2(filePath, rows, append) {
9307
9657
  const lines = rows.map((row) => JSON.stringify(row));
9308
9658
  const content = `${lines.join("\n")}
9309
9659
  `;
9310
- if (append && existsSync3(filePath)) {
9660
+ if (append && existsSync4(filePath)) {
9311
9661
  appendFileSync2(filePath, content, "utf-8");
9312
9662
  } else {
9313
9663
  writeFileSync4(filePath, content, "utf-8");
9314
9664
  }
9315
- } else if (append && existsSync3(filePath)) {
9665
+ } else if (append && existsSync4(filePath)) {
9316
9666
  const existing = JSON.parse(readFileSync6(filePath, "utf-8"));
9317
9667
  writeFileSync4(filePath, JSON.stringify([...existing, ...rows], null, 2), "utf-8");
9318
9668
  } else {
@@ -9453,7 +9803,7 @@ Examples:
9453
9803
  import { defineCommand as defineCommand86 } from "citty";
9454
9804
 
9455
9805
  // src/commands/gsc/query.ts
9456
- import { appendFileSync as appendFileSync3, existsSync as existsSync4, readFileSync as readFileSync7, writeFileSync as writeFileSync5 } from "fs";
9806
+ import { appendFileSync as appendFileSync3, existsSync as existsSync5, readFileSync as readFileSync7, writeFileSync as writeFileSync5 } from "fs";
9457
9807
  import { resolve as resolve3 } from "path";
9458
9808
  import { defineCommand as defineCommand83 } from "citty";
9459
9809
 
@@ -9582,7 +9932,7 @@ function writeRowsToFile3(filePath, rows, append) {
9582
9932
  const fields = extractFields3(rows);
9583
9933
  const ext = filePath.split(".").pop()?.toLowerCase();
9584
9934
  if (ext === "csv") {
9585
- if (!append || !existsSync4(filePath)) {
9935
+ if (!append || !existsSync5(filePath)) {
9586
9936
  writeFileSync5(filePath, `${toCsvRow(fields)}
9587
9937
  `, "utf-8");
9588
9938
  }
@@ -9592,12 +9942,12 @@ function writeRowsToFile3(filePath, rows, append) {
9592
9942
  } else if (ext === "jsonl") {
9593
9943
  const content = `${rows.map((row) => JSON.stringify(row)).join("\n")}
9594
9944
  `;
9595
- if (append && existsSync4(filePath)) {
9945
+ if (append && existsSync5(filePath)) {
9596
9946
  appendFileSync3(filePath, content, "utf-8");
9597
9947
  } else {
9598
9948
  writeFileSync5(filePath, content, "utf-8");
9599
9949
  }
9600
- } else if (append && existsSync4(filePath)) {
9950
+ } else if (append && existsSync5(filePath)) {
9601
9951
  const existing = JSON.parse(readFileSync7(filePath, "utf-8"));
9602
9952
  writeFileSync5(filePath, JSON.stringify([...existing, ...rows], null, 2), "utf-8");
9603
9953
  } else {
@@ -9915,9 +10265,9 @@ async function readImageBuffer(pathOrUrl) {
9915
10265
  }
9916
10266
  return readFile6(pathOrUrl);
9917
10267
  }
9918
- async function isDirectory(path6) {
10268
+ async function isDirectory(path7) {
9919
10269
  try {
9920
- const s = await stat2(path6);
10270
+ const s = await stat2(path7);
9921
10271
  return s.isDirectory();
9922
10272
  } catch {
9923
10273
  return false;
@@ -14296,6 +14646,317 @@ Examples:
14296
14646
  }
14297
14647
  });
14298
14648
 
14649
+ // src/commands/winning-ads/index.ts
14650
+ import { defineCommand as defineCommand140 } from "citty";
14651
+
14652
+ // src/commands/winning-ads/advertisers.ts
14653
+ import { defineCommand as defineCommand138 } from "citty";
14654
+ registerSchema({
14655
+ command: "winning-ads.advertisers",
14656
+ description: "Resolve a brand name to advertiser_id(s) in the ad-dna corpus \u2014 to find your OWN advertiser (to --exclude-advertiser) or a competitor (to --advertiser-id).",
14657
+ args: {
14658
+ query: { type: "string", description: "Brand / advertiser name to match (case-insensitive)", required: false },
14659
+ limit: { type: "number", description: "Max results (default 20)", required: false, default: 20 },
14660
+ platform: { type: "string", description: "Filter to a single platform", required: false }
14661
+ }
14662
+ });
14663
+ function identity(record) {
14664
+ return record;
14665
+ }
14666
+ var advertisersCommand2 = defineCommand138({
14667
+ meta: {
14668
+ name: "advertisers",
14669
+ description: 'Resolve a brand name to advertiser_id(s). Use it to find your own advertiser for --exclude-advertiser, or a competitor for --advertiser-id. Example: baker winning-ads advertisers "Deel" --output md'
14670
+ },
14671
+ args: {
14672
+ query: { type: "positional", description: "Brand / advertiser name", required: false },
14673
+ limit: { type: "string", description: "Max results (default 20)", required: false },
14674
+ platform: { type: "string", description: "Filter to a single platform", required: false },
14675
+ output: { type: "string", description: "Output format: json|files|md", required: false, default: "json" },
14676
+ fields: { type: "string", description: "Comma-separated field names to include", required: false }
14677
+ },
14678
+ run: async ({ args }) => {
14679
+ try {
14680
+ const params = {};
14681
+ const query = args.query;
14682
+ if (query) {
14683
+ params.q = query;
14684
+ }
14685
+ if (args.limit) {
14686
+ params.limit = String(args.limit);
14687
+ }
14688
+ if (args.platform) {
14689
+ params.platform = String(args.platform);
14690
+ }
14691
+ const data = await apiGet("/api/winning-ads/advertisers", params);
14692
+ const output = args.output || "json";
14693
+ if (output === "json") {
14694
+ writeJson({ ok: true, data });
14695
+ return;
14696
+ }
14697
+ const list = Array.isArray(data?.advertisers) ? data.advertisers : [];
14698
+ writeOutput(
14699
+ { ok: true, data: list },
14700
+ output,
14701
+ args.fields ? args.fields.split(",") : void 0,
14702
+ false,
14703
+ identity
14704
+ );
14705
+ } catch (err) {
14706
+ if (err instanceof ApiError) {
14707
+ writeJson({ ok: false, error: { code: err.code, message: err.message } });
14708
+ process.exit(1);
14709
+ }
14710
+ writeJson({ ok: false, error: { code: "INTERNAL_ERROR", message: "Unexpected error" } });
14711
+ process.exit(1);
14712
+ }
14713
+ }
14714
+ });
14715
+
14716
+ // src/commands/winning-ads/search.ts
14717
+ import { defineCommand as defineCommand139 } from "citty";
14718
+ registerSchema({
14719
+ command: "winning-ads.search",
14720
+ description: "Search the ad-dna corpus of scored winning ads. Returns a lean shortlist (advertiser, summary, scores, media_url) to pick a reference to reproduce.",
14721
+ args: {
14722
+ query: { type: "string", description: "Free-text natural-language query", required: false },
14723
+ "ref-ad-id": {
14724
+ type: "string",
14725
+ description: "Find ads similar to this ad id (instead of free text)",
14726
+ required: false
14727
+ },
14728
+ limit: { type: "number", description: "Max results 1-100 (default 10)", required: false, default: 10 },
14729
+ "max-per-advertiser": {
14730
+ type: "number",
14731
+ description: "Cap results per advertiser 1-50 (default 3)",
14732
+ required: false,
14733
+ default: 3
14734
+ },
14735
+ "min-relevance": { type: "number", description: "Relevance floor 0-1; trims weak matches", required: false },
14736
+ platform: {
14737
+ type: "string",
14738
+ description: "Comma list: meta,tiktok,linkedin,google_search,google_display,youtube,reddit,x,pinterest,snapchat",
14739
+ required: false
14740
+ },
14741
+ format: { type: "string", description: "Comma list: video,static,carousel", required: false },
14742
+ "winner-category": {
14743
+ type: "string",
14744
+ description: "Comma list: winner,scaled_winner,evergreen,rising,untested,dud,\u2026",
14745
+ required: false
14746
+ },
14747
+ awareness: {
14748
+ type: "string",
14749
+ description: "Comma list: unaware,problem_aware,solution_aware,product_aware,most_aware",
14750
+ required: false
14751
+ },
14752
+ country: { type: "string", description: "Comma list of country codes", required: false },
14753
+ language: { type: "string", description: "Comma list of language codes", required: false },
14754
+ "advertiser-id": {
14755
+ type: "string",
14756
+ description: "Restrict to these advertiser ids (browse one brand's winners)",
14757
+ required: false
14758
+ },
14759
+ "exclude-advertiser": {
14760
+ type: "string",
14761
+ description: "Drop these advertiser ids \u2014 your own brand + already-used references",
14762
+ required: false
14763
+ },
14764
+ "first-seen-after": {
14765
+ type: "string",
14766
+ description: "ISO datetime; only ads first seen after this",
14767
+ required: false
14768
+ },
14769
+ "first-seen-before": {
14770
+ type: "string",
14771
+ description: "ISO datetime; only ads first seen before this",
14772
+ required: false
14773
+ }
14774
+ }
14775
+ });
14776
+ function splitList(value) {
14777
+ if (!value) {
14778
+ return [];
14779
+ }
14780
+ return value.split(",").map((v) => v.trim()).filter(Boolean);
14781
+ }
14782
+ function setNumber(body, key, value) {
14783
+ if (value !== void 0 && value !== "") {
14784
+ body[key] = Number(value);
14785
+ }
14786
+ }
14787
+ function setList(target, key, value) {
14788
+ const list = splitList(value);
14789
+ if (list.length) {
14790
+ target[key] = list;
14791
+ }
14792
+ }
14793
+ function setString(target, key, value) {
14794
+ if (value) {
14795
+ target[key] = value;
14796
+ }
14797
+ }
14798
+ function buildSearchBody(args) {
14799
+ const body = {};
14800
+ if (args.query) {
14801
+ body.free_text_query = args.query;
14802
+ }
14803
+ if (args.refAdId) {
14804
+ body.ref_ad_id = args.refAdId;
14805
+ }
14806
+ setNumber(body, "limit", args.limit);
14807
+ setNumber(body, "max_per_advertiser", args.maxPerAdvertiser);
14808
+ setNumber(body, "min_relevance", args.minRelevance);
14809
+ const hardFilters = {};
14810
+ setList(hardFilters, "platform", args.platform);
14811
+ setList(hardFilters, "format", args.format);
14812
+ setList(hardFilters, "winner_category", args.winnerCategory);
14813
+ setList(hardFilters, "awareness_stage", args.awareness);
14814
+ setList(hardFilters, "country", args.country);
14815
+ setList(hardFilters, "language", args.language);
14816
+ setList(hardFilters, "advertiser_ids", args.advertiserId);
14817
+ setList(hardFilters, "exclude_advertiser_ids", args.excludeAdvertiser);
14818
+ setString(hardFilters, "first_seen_after", args.firstSeenAfter);
14819
+ setString(hardFilters, "first_seen_before", args.firstSeenBefore);
14820
+ if (Object.keys(hardFilters).length) {
14821
+ body.hard_filters = hardFilters;
14822
+ }
14823
+ return body;
14824
+ }
14825
+ var searchCommand4 = defineCommand139({
14826
+ meta: {
14827
+ name: "search",
14828
+ description: "Search winning reference ads. Example: baker winning-ads search 'B2B SaaS before/after AI automation' --platform meta --format static --winner-category winner --exclude-advertiser adv_123 --output md"
14829
+ },
14830
+ args: {
14831
+ query: { type: "positional", description: "Free-text search query", required: false },
14832
+ "ref-ad-id": { type: "string", description: "Find ads similar to this ad id", required: false },
14833
+ limit: {
14834
+ type: "string",
14835
+ description: "Max results 1-100 (default 10 \u2014 a shortlist)",
14836
+ required: false,
14837
+ default: "10"
14838
+ },
14839
+ "max-per-advertiser": {
14840
+ type: "string",
14841
+ description: "Cap results per advertiser 1-50 (default 3)",
14842
+ required: false
14843
+ },
14844
+ "min-relevance": { type: "string", description: "Relevance floor 0-1", required: false },
14845
+ platform: {
14846
+ type: "string",
14847
+ description: "Comma list of platforms (meta,linkedin,tiktok,\u2026) \u2014 search one or many",
14848
+ required: false
14849
+ },
14850
+ format: { type: "string", description: "Comma list of formats (video,static,carousel)", required: false },
14851
+ "winner-category": {
14852
+ type: "string",
14853
+ description: "Comma list of winner categories (default: all)",
14854
+ required: false
14855
+ },
14856
+ awareness: { type: "string", description: "Comma list of awareness stages", required: false },
14857
+ country: { type: "string", description: "Comma list of country codes", required: false },
14858
+ language: { type: "string", description: "Comma list of language codes", required: false },
14859
+ "advertiser-id": { type: "string", description: "Restrict to these advertiser ids", required: false },
14860
+ "exclude-advertiser": {
14861
+ type: "string",
14862
+ description: "Drop these advertiser ids (your own + already-used)",
14863
+ required: false
14864
+ },
14865
+ "first-seen-after": { type: "string", description: "ISO datetime lower bound", required: false },
14866
+ "first-seen-before": { type: "string", description: "ISO datetime upper bound", required: false },
14867
+ output: { type: "string", description: "Output format: json|files|md", required: false, default: "json" },
14868
+ fields: { type: "string", description: "Comma-separated field names to include", required: false },
14869
+ full: {
14870
+ type: "boolean",
14871
+ description: "Include DNA detail (angle, persona, hook) + longevity",
14872
+ required: false,
14873
+ default: false
14874
+ }
14875
+ },
14876
+ run: async ({ args }) => {
14877
+ try {
14878
+ const searchArgs = {
14879
+ query: args.query,
14880
+ refAdId: args["ref-ad-id"],
14881
+ limit: args.limit,
14882
+ maxPerAdvertiser: args["max-per-advertiser"],
14883
+ minRelevance: args["min-relevance"],
14884
+ platform: args.platform,
14885
+ format: args.format,
14886
+ winnerCategory: args["winner-category"],
14887
+ awareness: args.awareness,
14888
+ country: args.country,
14889
+ language: args.language,
14890
+ advertiserId: args["advertiser-id"],
14891
+ excludeAdvertiser: args["exclude-advertiser"],
14892
+ firstSeenAfter: args["first-seen-after"],
14893
+ firstSeenBefore: args["first-seen-before"]
14894
+ };
14895
+ const body = buildSearchBody(searchArgs);
14896
+ if (!("free_text_query" in body) && !("ref_ad_id" in body) && !("hard_filters" in body)) {
14897
+ writeJson({
14898
+ ok: false,
14899
+ error: { code: "VALIDATION_ERROR", message: "Provide a query, --ref-ad-id, or a filter flag" }
14900
+ });
14901
+ process.exit(1);
14902
+ }
14903
+ const data = await apiPost(
14904
+ "/api/winning-ads/search",
14905
+ body
14906
+ );
14907
+ const output = args.output || "json";
14908
+ const full = args.full;
14909
+ const rawResults = Array.isArray(data?.results) ? data.results : [];
14910
+ if (output === "json") {
14911
+ const results = rawResults.map((r) => winningAdNormalizer(r, full));
14912
+ writeJson({
14913
+ ok: true,
14914
+ data: { results, pool_size: data?.pool_size ?? null, match_confidence: data?.match_confidence ?? null }
14915
+ });
14916
+ return;
14917
+ }
14918
+ writeOutput(
14919
+ { ok: true, data: rawResults },
14920
+ output,
14921
+ args.fields ? args.fields.split(",") : void 0,
14922
+ full,
14923
+ winningAdNormalizer
14924
+ );
14925
+ } catch (err) {
14926
+ if (err instanceof ApiError) {
14927
+ writeJson({ ok: false, error: { code: err.code, message: err.message } });
14928
+ process.exit(1);
14929
+ }
14930
+ writeJson({ ok: false, error: { code: "INTERNAL_ERROR", message: "Unexpected error" } });
14931
+ process.exit(1);
14932
+ }
14933
+ }
14934
+ });
14935
+
14936
+ // src/commands/winning-ads/index.ts
14937
+ var winningAdsCommand = defineCommand140({
14938
+ meta: {
14939
+ name: "winning-ads",
14940
+ description: `Search the ad-dna corpus of scored "winning" ads for reference creatives to reproduce. Proxied through the Baker backend (BAKER_API_KEY) \u2014 no separate token needed.
14941
+
14942
+ Auth: BAKER_API_KEY (must start with bk_) + BAKER_API_URL (your Convex .convex.site URL).
14943
+
14944
+ Subcommands:
14945
+ baker winning-ads search "<free text>" \u2014 semantic search; returns a lean shortlist (advertiser, summary, scores, media_url)
14946
+ baker winning-ads advertisers "<brand>" \u2014 resolve a brand \u2192 advertiser_id(s), to --exclude-advertiser (your own) or --advertiser-id (a competitor)
14947
+
14948
+ Examples:
14949
+ baker winning-ads search "B2B SaaS ad: before/after of an overworked team replaced by AI automation" --platform meta --format static
14950
+ baker winning-ads search "skincare UGC testimonial" --platform tiktok --format video --output md
14951
+ baker winning-ads search "fintech onboarding" --winner-category winner --exclude-advertiser adv_ourbrand,adv_usedbefore --output md
14952
+ baker winning-ads advertisers "Acme" --output md # find our own advertiser id to exclude`
14953
+ },
14954
+ subCommands: {
14955
+ search: searchCommand4,
14956
+ advertisers: advertisersCommand2
14957
+ }
14958
+ });
14959
+
14299
14960
  // src/version.ts
14300
14961
  import { readFileSync as readFileSync8 } from "fs";
14301
14962
  function packageJsonUrl() {
@@ -14313,7 +14974,7 @@ function getCliVersion() {
14313
14974
  }
14314
14975
 
14315
14976
  // src/cli.ts
14316
- var main = defineCommand138({
14977
+ var main = defineCommand141({
14317
14978
  meta: {
14318
14979
  name: "baker",
14319
14980
  version: getCliVersion(),
@@ -14336,6 +14997,7 @@ Introspection: Run 'baker schema <command>' to inspect argument schemas.`
14336
14997
  videos: videosCommand,
14337
14998
  testimonials: testimonialsCommand,
14338
14999
  canvas: canvasCommand,
15000
+ "winning-ads": winningAdsCommand,
14339
15001
  schema: schemaCommand
14340
15002
  }
14341
15003
  });