@koda-sl/baker-cli 0.301.0-dev.50ab1c583 → 0.305.0-dev.2c645fe6b

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
@@ -22,6 +22,8 @@ import {
22
22
  YtDlpError,
23
23
  bestSupportedQuality,
24
24
  canonicalUrl,
25
+ clipAudioMode,
26
+ clipDurationsFor,
25
27
  clipParamRecipe,
26
28
  clipProfileFor,
27
29
  collectAssetRefLikes,
@@ -63,7 +65,7 @@ import {
63
65
  ulid,
64
66
  validateCanvasDeep,
65
67
  ytDlpBlockSignal
66
- } from "./chunk-SSXER76B.js";
68
+ } from "./chunk-7TL4VPK5.js";
67
69
  import {
68
70
  csvOrJson,
69
71
  daysAgoIso,
@@ -2380,7 +2382,6 @@ var IMAGE_ASSET_LINK_FIELD_TYPES = /* @__PURE__ */ new Set([
2380
2382
  "PORTRAIT_MARKETING_IMAGE",
2381
2383
  "AD_IMAGE"
2382
2384
  ]);
2383
- var IMAGE_ASSET_LINK_MESSAGE = "Google refuses the LINK, not the upload \u2014 so do not stop at this refusal, finish the handover. `assets create` with an image DOES publish: the image lands in the advertiser's Google Ads asset library and is theirs to use. Only this link is refused (MARKETING_IMAGE is a Performance Max asset-group slot \u2014 use `asset-groups attach`; AD_IMAGE comes back UNSUPPORTED_FIELD_TYPE at every level, on every API version). So: stage the image assets anyway, drop this link, and tell the user the images are already in their Google Ads account and only need adding to the campaign under Assets \u2014 name them. Baker stages the Task for that step itself, alongside the image, so it is already in this Session's changes \u2014 do not stage a second one with `baker actions`. Never tell them to upload the images; they are uploaded. The one image a Search campaign does take through a link is the advertiser's logo \u2014 BUSINESS_LOGO at campaign or customer level, on a verified account.";
2384
2385
  function isImageAssetLinkFieldType(fieldType) {
2385
2386
  return IMAGE_ASSET_LINK_FIELD_TYPES.has(fieldType);
2386
2387
  }
@@ -3384,7 +3385,6 @@ var assetLinkAttachSchema = z5.object({
3384
3385
  if (value.fieldType === "BUSINESS_LOGO" && value.level === "adGroup") {
3385
3386
  ctx.addIssue({ code: z5.ZodIssueCode.custom, path: ["fieldType"], message: BUSINESS_LOGO_LEVEL_MESSAGE });
3386
3387
  } else if (isImageAssetLinkFieldType(value.fieldType)) {
3387
- ctx.addIssue({ code: z5.ZodIssueCode.custom, path: ["fieldType"], message: IMAGE_ASSET_LINK_MESSAGE });
3388
3388
  } else if (!ASSET_FIELD_TYPES.includes(value.fieldType)) {
3389
3389
  ctx.addIssue({
3390
3390
  code: z5.ZodIssueCode.custom,
@@ -4464,7 +4464,13 @@ var avatarsCreateRequestSchema = z10.object({
4464
4464
  sourceImageIds: z10.array(z10.string().min(1)).max(6).optional(),
4465
4465
  /** The company's statement that it may use this person's likeness in ads. */
4466
4466
  likenessConfirmed: z10.boolean().optional(),
4467
- chatId: z10.string().optional()
4467
+ chatId: z10.string().optional(),
4468
+ /**
4469
+ * Who picks this avatar's face. `ask` (the default) means a picker is about to open,
4470
+ * so the sheet waits for the chosen portrait instead of being fused from one nobody
4471
+ * saw — two renders that were always going to be thrown away.
4472
+ */
4473
+ faceChoice: z10.enum(["ask", "auto"]).optional()
4468
4474
  }).refine((body) => !body.sourceImageIds?.length || body.likenessConfirmed === true, {
4469
4475
  message: "likenessConfirmed is required when building an avatar from photographs of a real person",
4470
4476
  path: ["likenessConfirmed"]
@@ -16592,8 +16598,8 @@ function imageAssetHints(payload) {
16592
16598
  return [];
16593
16599
  }
16594
16600
  return [
16595
- "This image publishes into the advertiser's Google Ads asset library \u2014 that half works. What Google refuses is the link that puts it on a Search campaign, so do not stage an `assets attach` for it.",
16596
- "Finish the handover rather than reporting a limitation: tell the user the images are already in their Google Ads account and only need adding to the campaign under Assets, and name the ones you staged. Baker stages the Task for that step itself the moment you stage the image, so it is already in this chat's changes \u2014 do not stage a second one with `baker actions`. Never tell them to upload images they already have. (A Performance Max asset group is different \u2014 images do link there, with `asset-groups attach`.)"
16601
+ "This image publishes into the advertiser's Google Ads asset library \u2014 that half works. What Google refuses is the link that puts it on a Search campaign, so the `assets attach` for it never stores: it is skipped with the reason, and staging it anyway is what tells Baker which campaign these images belong on.",
16602
+ "Finish the handover rather than reporting a limitation: tell the user the images are already in their Google Ads account and only need adding to the campaign under Assets, and name the ones you staged. Baker stages the Task for that step itself the moment you stage the image, so it is already in this chat's changes \u2014 do not stage a second one with `baker actions`. Stage the `assets attach` too if you know which campaign they belong on: it is skipped rather than stored, and it is what puts the campaign's name in that Task. Never tell them to upload images they already have. (A Performance Max asset group is different \u2014 images do link there, with `asset-groups attach`.)"
16597
16603
  ];
16598
16604
  }
16599
16605
  var assetsUpdateCommand = defineCommand31({
@@ -16636,7 +16642,7 @@ var assetsUpdateCommand = defineCommand31({
16636
16642
  var assetsCommand = defineCommand31({
16637
16643
  meta: {
16638
16644
  name: "assets",
16639
- description: "Stage asset create/update + asset-link attach/detach (via --file). For a high-performance Search campaign add \u22654 sitelinks, \u22653 callouts and \u22651 structured snippet per campaign. Image extensions are the one thing you cannot finish here \u2014 `create` uploads the image into the advertiser's account, but Google refuses the link that puts it on a Search campaign, so hand that one step over rather than telling them to upload anything. The image a Search campaign does take through a link is the advertiser's logo \u2014 BUSINESS_LOGO, at campaign or customer level, on a verified account."
16645
+ description: "Stage asset create/update + asset-link attach/detach (via --file). For a high-performance Search campaign add \u22654 sitelinks, \u22653 callouts and \u22651 structured snippet per campaign. Image extensions are the one thing you cannot finish here \u2014 `create` uploads the image into the advertiser's account, but Google refuses the link that puts it on a Search campaign, so hand that one step over rather than telling them to upload anything (stage the `attach` regardless: it is skipped, and it is what records the handover Task). The image a Search campaign does take through a link is the advertiser's logo \u2014 BUSINESS_LOGO, at campaign or customer level, on a verified account."
16640
16646
  },
16641
16647
  subCommands: {
16642
16648
  create: fileCreateCommand(
@@ -16647,7 +16653,7 @@ var assetsCommand = defineCommand31({
16647
16653
  ),
16648
16654
  update: assetsUpdateCommand,
16649
16655
  attach: fileCreateCommand("attach", "google.assetLink.attach", "Attach an asset to a campaign/adGroup/customer", [
16650
- "Image extensions are not attachable here \u2014 Google refuses AD_IMAGE and every marketing-image field type at every level, on every API version. The upload is not refused, though: stage the images with `assets create`, then tell the user they are already in their Google Ads account and only need adding to the campaign under Assets. Baker stages that Task by itself when the image is staged. Two images that DO work: the advertiser's logo through this command (`BUSINESS_LOGO`, campaign or customer level, on a verified account), and Performance Max asset-group images, which are a different mechanism (`asset-groups attach`)."
16656
+ "Image extensions cannot be linked \u2014 Google refuses AD_IMAGE and every marketing-image field type at every level, on every API version \u2014 but stage the link anyway: it is skipped rather than stored, the rest of a batch is unaffected, and it is what records the handover Task naming those images and that campaign. That is the ONLY record when the images are already in the account and you are uploading nothing. Then tell the user they are already in their Google Ads account and only need adding to the campaign under Assets; do not stage a second Task with `baker actions`. Two images that DO link: the advertiser's logo through this command (`BUSINESS_LOGO`, campaign or customer level, on a verified account), and Performance Max asset-group images, which are a different mechanism (`asset-groups attach`)."
16651
16657
  ]),
16652
16658
  detach: statusCommand2("google.assetLink.detach", "asset link")
16653
16659
  }
@@ -30245,7 +30251,10 @@ var createCommand2 = defineCommand96({
30245
30251
  ...sourceImageIds ? { sourceImageIds, likenessConfirmed: true } : {},
30246
30252
  ...args["voice-id"] ? { voiceId: args["voice-id"] } : {},
30247
30253
  ...args["voice-description"] ? { voiceDescription: args["voice-description"] } : {},
30248
- ...chatIdFromEnv() ? { chatId: chatIdFromEnv() } : {}
30254
+ ...chatIdFromEnv() ? { chatId: chatIdFromEnv() } : {},
30255
+ // Sent so the backend can hold the sheet until the chosen portrait exists —
30256
+ // otherwise it fuses one from a face nobody picked and pays for it twice.
30257
+ faceChoice
30249
30258
  };
30250
30259
  const data = await writeAvatars("/api/avatars", body);
30251
30260
  writeJson({ ok: true, data, hints: creationHints({ ...data, faceChoice }, body.profile) });
@@ -33462,7 +33471,7 @@ function sameSpeakerTrackEntries(segments) {
33462
33471
  };
33463
33472
  });
33464
33473
  }
33465
- function buildPerSpeakerVoiceConversion(segments, totalMs, nodes) {
33474
+ function buildPerSpeakerVoiceConversion(segments, totalMs, nodes, keepRenderedVoice = false) {
33466
33475
  const bySpeaker = /* @__PURE__ */ new Map();
33467
33476
  for (const seg of segments) {
33468
33477
  const arr = bySpeaker.get(seg.voiceNode) ?? [];
@@ -33471,7 +33480,7 @@ function buildPerSpeakerVoiceConversion(segments, totalMs, nodes) {
33471
33480
  }
33472
33481
  const tracks = [];
33473
33482
  for (const [voiceNode, segs] of bySpeaker) {
33474
- const trackId = `${voiceNode}_track`;
33483
+ const trackId = keepRenderedVoice ? `${voiceNode}_conv` : `${voiceNode}_track`;
33475
33484
  const convId = `${voiceNode}_conv`;
33476
33485
  const sorted = [...segs].sort((a, b) => a.start_s - b.start_s);
33477
33486
  const mixInputs = {};
@@ -33487,12 +33496,14 @@ function buildPerSpeakerVoiceConversion(segments, totalMs, nodes) {
33487
33496
  total_ms: totalMs
33488
33497
  }
33489
33498
  });
33490
- nodes.push({
33491
- id: convId,
33492
- type: "audio_voice_convert",
33493
- inputs: { audio: `$ref:${trackId}.audio`, voice_ref: `$ref:${voiceNode}.voice_id` },
33494
- params: { model: FIXED_VOICE_CONVERT_MODEL, voice: "{{voice_ref}}" }
33495
- });
33499
+ if (!keepRenderedVoice) {
33500
+ nodes.push({
33501
+ id: convId,
33502
+ type: "audio_voice_convert",
33503
+ inputs: { audio: `$ref:${trackId}.audio`, voice_ref: `$ref:${voiceNode}.voice_id` },
33504
+ params: { model: FIXED_VOICE_CONVERT_MODEL, voice: "{{voice_ref}}" }
33505
+ });
33506
+ }
33496
33507
  tracks.push({ slot: convId, ref: `$ref:${convId}.audio`, start_s: 0, duration_s: totalMs / 1e3, kind: "vo" });
33497
33508
  }
33498
33509
  return tracks;
@@ -34632,7 +34643,8 @@ function buildTimeline(blueprint, slots, opts, nodes) {
34632
34643
  graphicScenes: fullFrameGraphicScenes(blueprint),
34633
34644
  surfaceIngests: /* @__PURE__ */ new Map(),
34634
34645
  ttsLanguageCode: deriveTtsLanguageCode(blueprint),
34635
- spokenAccent: blueprint.global?.voiceover?.accent?.trim() || void 0
34646
+ spokenAccent: blueprint.global?.voiceover?.accent?.trim() || void 0,
34647
+ keepRenderedVoice: opts.keepRenderedVoice
34636
34648
  };
34637
34649
  const out = {
34638
34650
  routes: /* @__PURE__ */ new Map(),
@@ -34689,7 +34701,7 @@ function buildTimeline(blueprint, slots, opts, nodes) {
34689
34701
  prevEndFrame = emitBrollScene(scene, i, i === lastIndex, env, nodes, out, prevEndFrame);
34690
34702
  });
34691
34703
  const totalMs = Math.round(env.clock.totalS * 1e3);
34692
- out.voTracks.push(...buildPerSpeakerVoiceConversion(out.nativeSegments, totalMs, nodes));
34704
+ out.voTracks.push(...buildPerSpeakerVoiceConversion(out.nativeSegments, totalMs, nodes, env.keepRenderedVoice));
34693
34705
  return {
34694
34706
  clips: out.clips,
34695
34707
  voTracks: out.voTracks,
@@ -35127,6 +35139,18 @@ function emitCaptionChain(blueprint, tracks, clock, opts, videoRef, nodes) {
35127
35139
  });
35128
35140
  return { videoRef: "$ref:captions.video", videoNode: "captions" };
35129
35141
  }
35142
+ function dropUncastVoices(nodes) {
35143
+ const referenced = /* @__PURE__ */ new Set();
35144
+ for (const node of nodes) {
35145
+ for (const ref of Object.values(node.inputs ?? {})) {
35146
+ if (typeof ref === "string" && ref.startsWith("$ref:")) referenced.add(ref.slice(5).split(".")[0] ?? "");
35147
+ }
35148
+ }
35149
+ for (let i = nodes.length - 1; i >= 0; i--) {
35150
+ const node = nodes[i];
35151
+ if (node && node.type === "voice_select" && !referenced.has(node.id)) nodes.splice(i, 1);
35152
+ }
35153
+ }
35130
35154
  function scaffoldVideoCanvas(input, elementsInput, opts) {
35131
35155
  const blueprint = VideoBlueprint.parse(input);
35132
35156
  injectHookPhysicality(blueprint);
@@ -35240,6 +35264,7 @@ function scaffoldVideoCanvas(input, elementsInput, opts) {
35240
35264
  ...aspectRemapTodo(resolveAspect(blueprint.source?.aspect_ratio, opts.aspect, genAspectsFor(opts.videoModel))),
35241
35265
  model_constraints: buildModelConstraints()
35242
35266
  };
35267
+ dropUncastVoices(nodes);
35243
35268
  return {
35244
35269
  schema: "baker-canvas/1",
35245
35270
  metadata: {
@@ -36361,6 +36386,14 @@ var RENDER_REVIEW_MODEL = "gemini-pro-latest";
36361
36386
  var RENDER_REVIEW_FRAMES = 12;
36362
36387
  var RENDER_REVIEW_WIDTH = 1024;
36363
36388
  var RENDER_WATCH_MODEL = "gemini-3.7-flash";
36389
+ function usageFrom(model, body) {
36390
+ const meta = body?.usageMetadata;
36391
+ if (!meta) return null;
36392
+ const inputTokens = meta.promptTokenCount ?? 0;
36393
+ const outputTokens = meta.candidatesTokenCount ?? 0;
36394
+ if (inputTokens === 0 && outputTokens === 0) return null;
36395
+ return { model, inputTokens, outputTokens };
36396
+ }
36364
36397
  var WATCH_MAX_BYTES = 60 * 1024 * 1024;
36365
36398
  function sceneAt(windows, atS) {
36366
36399
  return windows.find((w) => atS >= w.startS && atS < w.endS)?.file;
@@ -36622,6 +36655,12 @@ function videoPathFromOutput(output) {
36622
36655
  }
36623
36656
  return found;
36624
36657
  }
36658
+ function parseVolume(volumedetect, key) {
36659
+ const raw = new RegExp(`${key}:\\s*(-?(?:inf|[\\d.]+)) dB`).exec(volumedetect)?.[1];
36660
+ if (raw === void 0) return Number.NaN;
36661
+ if (raw === "-inf") return Number.NEGATIVE_INFINITY;
36662
+ return Number.parseFloat(raw);
36663
+ }
36625
36664
  function classifyAudio(volumedetect, hasAudioStream) {
36626
36665
  if (!hasAudioStream) {
36627
36666
  return [
@@ -36631,9 +36670,17 @@ function classifyAudio(volumedetect, hasAudioStream) {
36631
36670
  }
36632
36671
  ];
36633
36672
  }
36634
- const mean = Number.parseFloat(volumedetect.match(/mean_volume:\s*(-?[\d.]+) dB/)?.[1] ?? "");
36635
- const max = Number.parseFloat(volumedetect.match(/max_volume:\s*(-?[\d.]+) dB/)?.[1] ?? "");
36673
+ const mean = parseVolume(volumedetect, "mean_volume");
36674
+ const max = parseVolume(volumedetect, "max_volume");
36636
36675
  const findings = [];
36676
+ if (mean === Number.NEGATIVE_INFINITY || max === Number.NEGATIVE_INFINITY) {
36677
+ return [
36678
+ {
36679
+ severity: "blocking",
36680
+ what: "The finished video has an audio track, but it is completely silent \u2014 digital zero from start to end. An ad that plays silent in a feed is not an ad."
36681
+ }
36682
+ ];
36683
+ }
36637
36684
  if (Number.isFinite(mean) && mean < -30) {
36638
36685
  findings.push({
36639
36686
  severity: "warning",
@@ -36691,7 +36738,7 @@ async function videoDuration(video) {
36691
36738
  return null;
36692
36739
  }
36693
36740
  }
36694
- async function askVideo(video, prompt) {
36741
+ async function askVideo(video, prompt, spent) {
36695
36742
  const key = frameVisionKey();
36696
36743
  if (!key) return null;
36697
36744
  try {
@@ -36714,13 +36761,15 @@ async function askVideo(video, prompt) {
36714
36761
  );
36715
36762
  if (!response.ok) return null;
36716
36763
  const body = await response.json();
36764
+ const usage = usageFrom(RENDER_WATCH_MODEL, body);
36765
+ if (usage) spent(usage);
36717
36766
  const text2 = body.candidates?.[0]?.content?.parts?.map((p) => p.text ?? "").join("");
36718
36767
  return text2 ? parseReviewJson(text2) : null;
36719
36768
  } catch {
36720
36769
  return null;
36721
36770
  }
36722
36771
  }
36723
- async function ask(images, prompt) {
36772
+ async function ask(images, prompt, spent) {
36724
36773
  const key = frameVisionKey();
36725
36774
  if (!key) return null;
36726
36775
  const parts = [{ text: prompt }];
@@ -36737,6 +36786,8 @@ async function ask(images, prompt) {
36737
36786
  );
36738
36787
  if (!response.ok) return null;
36739
36788
  const body = await response.json();
36789
+ const usage = usageFrom(RENDER_REVIEW_MODEL, body);
36790
+ if (usage) spent(usage);
36740
36791
  const text2 = body.candidates?.[0]?.content?.parts?.map((p) => p.text ?? "").join("");
36741
36792
  return text2 ? parseReviewJson(text2) : null;
36742
36793
  } catch {
@@ -36747,6 +36798,7 @@ async function reviewRenderedVideo(opts) {
36747
36798
  if (!hasFrameVisionKey()) return null;
36748
36799
  const duration = await videoDuration(opts.video);
36749
36800
  if (duration === null) return null;
36801
+ const spent = (usage) => opts.onSpend?.(usage);
36750
36802
  const dir = await mkdtemp(path15.join(tmpdir(), "baker-review-"));
36751
36803
  try {
36752
36804
  const grabbed = [];
@@ -36761,17 +36813,22 @@ async function reviewRenderedVideo(opts) {
36761
36813
  if (grabbed.length === 0) return null;
36762
36814
  const reel = await ask(
36763
36815
  grabbed.map((f) => f.image),
36764
- buildReelReviewPrompt({ subject: opts.subject, script: opts.script })
36816
+ buildReelReviewPrompt({ subject: opts.subject, script: opts.script }),
36817
+ spent
36765
36818
  );
36766
36819
  const framePrompt = buildFrameReviewPrompt({ subject: opts.subject });
36767
36820
  const perFrame = await mapWithLimit(grabbed, REVIEW_CONCURRENCY, async (f) => ({
36768
36821
  at: f.at,
36769
- json: await ask([f.image], framePrompt)
36822
+ json: await ask([f.image], framePrompt, spent)
36770
36823
  }));
36771
36824
  const unread = perFrame.filter((f) => f.json === null).length;
36772
36825
  if (unread === perFrame.length && reel === null) return null;
36773
- const watch = await askVideo(opts.video, buildWatchReviewPrompt({ market: opts.market, script: opts.script }));
36774
- const fidelity = opts.brief?.trim() ? await askVideo(opts.video, buildFidelityPrompt(opts.brief)) : null;
36826
+ const watch = await askVideo(
36827
+ opts.video,
36828
+ buildWatchReviewPrompt({ market: opts.market, script: opts.script }),
36829
+ spent
36830
+ );
36831
+ const fidelity = opts.brief?.trim() ? await askVideo(opts.video, buildFidelityPrompt(opts.brief), spent) : null;
36775
36832
  const findings = [
36776
36833
  ...classifyFrameFindings(perFrame, opts.windows ?? []),
36777
36834
  ...classifyReelFindings(reel),
@@ -36781,12 +36838,15 @@ async function reviewRenderedVideo(opts) {
36781
36838
  // Measured, not judged: a model looking at frames cannot hear the mix.
36782
36839
  ...await measureAudio(opts.video)
36783
36840
  ];
36784
- if (unread > 0) {
36785
- findings.push({
36786
- severity: "warning",
36787
- what: `${unread} of ${perFrame.length} frames could not be reviewed \u2014 that part of the video is unchecked.`
36788
- });
36789
- }
36841
+ findings.push(
36842
+ ...unreviewedNotes({
36843
+ watched: watch !== null,
36844
+ fidelityAsked: Boolean(opts.brief?.trim()),
36845
+ fidelityRead: fidelity !== null,
36846
+ unread,
36847
+ frames: perFrame.length
36848
+ })
36849
+ );
36790
36850
  return findings;
36791
36851
  } finally {
36792
36852
  await rm5(dir, { recursive: true, force: true });
@@ -36810,6 +36870,28 @@ async function sceneWindowsBeside(canvasPath) {
36810
36870
  }
36811
36871
  return windows;
36812
36872
  }
36873
+ function unreviewedNotes(state) {
36874
+ const notes = [];
36875
+ if (!state.watched) {
36876
+ notes.push({
36877
+ severity: "warning",
36878
+ what: "Nobody watched the finished video end to end \u2014 the accent, the voice against the mouth and the cuts are unchecked. The frame-by-frame review still ran."
36879
+ });
36880
+ }
36881
+ if (state.fidelityAsked && !state.fidelityRead) {
36882
+ notes.push({
36883
+ severity: "warning",
36884
+ what: "The video was not checked against what you asked for \u2014 only against whether it is well made. A well-made ad of the wrong brief passes every other check here."
36885
+ });
36886
+ }
36887
+ if (state.unread > 0) {
36888
+ notes.push({
36889
+ severity: "warning",
36890
+ what: `${state.unread} of ${state.frames} frames could not be reviewed \u2014 that part of the video is unchecked.`
36891
+ });
36892
+ }
36893
+ return notes;
36894
+ }
36813
36895
 
36814
36896
  // src/commands/canvas/run.ts
36815
36897
  var runCommand = defineCommand107({
@@ -37124,10 +37206,12 @@ ${describeRewrites(healed.rewrites)}
37124
37206
  `));
37125
37207
  }
37126
37208
  const reviewable = videoPathFromOutput(result.output);
37209
+ const reviewUsage = [];
37127
37210
  const review = reviewable ? await reviewRenderedVideo({
37128
37211
  video: reviewable,
37129
37212
  windows: await sceneWindowsBeside(filePath),
37130
- brief: opts.brief?.trim() || void 0
37213
+ brief: opts.brief?.trim() || void 0,
37214
+ onSpend: (usage) => reviewUsage.push(usage)
37131
37215
  }) : null;
37132
37216
  const hints2 = reviewable ? review === null ? [RENDER_REVIEW_UNAVAILABLE] : review.length === 0 ? ["Reviewed the finished video frame by frame \u2014 nothing to report."] : [
37133
37217
  `Reviewed the finished video and found ${review.length} thing${review.length === 1 ? "" : "s"} to fix. Show the user what is wrong before you show them the ad.`,
@@ -37141,7 +37225,12 @@ ${describeRewrites(healed.rewrites)}
37141
37225
  // `review` is null only when nobody looked (no key, no ffmpeg, unreadable video);
37142
37226
  // an empty array is a render that was reviewed and came back clean. The record
37143
37227
  // has to keep those apart.
37144
- buildRunRecord(result, { ...recordMeta, ...review ? { review } : {} }, progress?.planInfo(), finalLabels)
37228
+ buildRunRecord(
37229
+ result,
37230
+ { ...recordMeta, ...review ? { review } : {}, ...reviewUsage.length > 0 ? { reviewUsage } : {} },
37231
+ progress?.planInfo(),
37232
+ finalLabels
37233
+ )
37145
37234
  );
37146
37235
  }
37147
37236
  process.stdout.write(
@@ -38252,6 +38341,12 @@ function pinnedModelHint(input) {
38252
38341
  if (!speaks) return null;
38253
38342
  return `You pinned --model ${input.model} on a speaking avatar. Without it this clip routes to ${DEFAULT_VIDEO_GENERATE_MODEL}, which was chosen for exactly this case: the better picture, both orientations, and audio always on. Drop --model unless the user asked for a specific one \u2014 and note ${input.model} may not take the length or the reference you are counting on.`;
38254
38343
  }
38344
+ function silentModelHint(model, prompt) {
38345
+ if (!model || !prompt) return null;
38346
+ if (clipAudioMode(model) !== "never") return null;
38347
+ if (spokenWordsIn(prompt) === 0) return null;
38348
+ return `${model} renders no audio \u2014 the line in this prompt will be mouthed and never heard. For a shot where someone speaks use ${DEFAULT_VIDEO_GENERATE_MODEL} (audio always on), or alibaba/wan-3.0 past 10 seconds. Keep this model for shots with no voice in them.`;
38349
+ }
38255
38350
 
38256
38351
  // src/commands/canvas/scaffold-ad.ts
38257
38352
  import { copyFile, cp, mkdir as mkdir7, readFile as readFile16, stat as stat4, writeFile as writeFile9 } from "fs/promises";
@@ -38461,6 +38556,7 @@ var CURRENCY_BY_MARKET = {
38461
38556
  "the united kingdom": "\xA3 (pounds sterling)",
38462
38557
  "the united states": "$ (US dollars)"
38463
38558
  };
38559
+ var MARKERS_TO_NAME_A_PLACE = 3;
38464
38560
  var MARKET_BY_LANGUAGE = {
38465
38561
  es: "Spain",
38466
38562
  "es-es": "Spain",
@@ -38499,6 +38595,9 @@ var SCRIPT_LANGUAGE_MARKERS = [
38499
38595
  ["english", /\b(the|a|an|of|to|for|with|your|more|already|but|because|until|without)\b/gi]
38500
38596
  ];
38501
38597
  function detectScriptLanguage(lines) {
38598
+ return bestScriptLanguage(lines)?.name;
38599
+ }
38600
+ function bestScriptLanguage(lines) {
38502
38601
  const text2 = lines.join(" ");
38503
38602
  if (text2.trim().length < 12) return void 0;
38504
38603
  let best = null;
@@ -38506,7 +38605,7 @@ function detectScriptLanguage(lines) {
38506
38605
  const hits = (text2.match(re2) ?? []).length;
38507
38606
  if (hits > 0 && (!best || hits > best.hits)) best = { name, hits };
38508
38607
  }
38509
- return best?.name;
38608
+ return best ?? void 0;
38510
38609
  }
38511
38610
  function voiceLanguageFor(spec) {
38512
38611
  const declared = spec.voice?.language?.trim().toLowerCase();
@@ -38519,12 +38618,19 @@ function voiceLanguageFor(spec) {
38519
38618
  function marketFor(spec) {
38520
38619
  if (spec.market) return spec.market;
38521
38620
  const lang = spec.voice?.language?.trim().toLowerCase();
38522
- return lang ? MARKET_BY_LANGUAGE[lang] : void 0;
38523
- }
38524
- function beatSeconds(line) {
38621
+ const declared = lang ? MARKET_BY_LANGUAGE[lang] : void 0;
38622
+ if (declared) return declared;
38623
+ const read = bestScriptLanguage(spec.beats.map((b) => b.say));
38624
+ if (!read || read.hits < MARKERS_TO_NAME_A_PLACE) return void 0;
38625
+ const detected = read.name;
38626
+ if (detected.toLowerCase() === "english") return void 0;
38627
+ const code = detected ? Object.entries(VOICE_LANGUAGE_NAME).find(([, name]) => name.toLowerCase() === detected.toLowerCase())?.[0] : void 0;
38628
+ return code ? MARKET_BY_LANGUAGE[code] : void 0;
38629
+ }
38630
+ function beatSeconds(line, videoModel) {
38525
38631
  const words2 = line.trim().split(/\s+/).length;
38526
38632
  const spoken = words2 / 3.06;
38527
- const allowed = [...SEEDANCE_DURATIONS].sort((a, b) => a - b);
38633
+ const allowed = (videoModel ? clipDurationsFor(videoModel) : void 0) ?? [...SEEDANCE_DURATIONS].sort((a, b) => a - b);
38528
38634
  return allowed.find((d) => d >= spoken) ?? allowed[allowed.length - 1];
38529
38635
  }
38530
38636
  var BRAND_PLATE = "A solid flat brand colour plate \u2014 an end card. No people, no objects, no scenery, no text of any kind.";
@@ -38553,18 +38659,21 @@ function castedAvatarOnRoster(ad, roster) {
38553
38659
  if (ad.cast?.avatar) return null;
38554
38660
  const haystack = [ad.cast?.description ?? "", ...(ad.beats ?? []).map((b) => b.show ?? "")].join(" ");
38555
38661
  if (!haystack.trim()) return null;
38556
- const words2 = new Set(
38557
- haystack.toLowerCase().split(/[^\p{L}\p{N}]+/u).filter(Boolean)
38558
- );
38662
+ const written = new Set(haystack.split(/[^\p{L}\p{N}]+/u).filter(Boolean));
38559
38663
  for (const person of roster) {
38560
- for (const label of [person.name, person.handle]) {
38561
- const token = label.trim().toLowerCase();
38562
- if (!token || token.includes("-") || token.length < 3) continue;
38563
- if (words2.has(token)) return person.handle;
38564
- }
38664
+ if (labelWrittenAsName(person.name, written) || labelWrittenAsName(person.handle, written)) return person.handle;
38565
38665
  }
38566
38666
  return null;
38567
38667
  }
38668
+ function labelWrittenAsName(label, written) {
38669
+ const token = label.trim();
38670
+ if (!token || token.includes("-") || token.length < 3) return false;
38671
+ const folded = token.toLowerCase();
38672
+ for (const word of written) {
38673
+ if (word.toLowerCase() === folded && word[0] === word[0]?.toUpperCase()) return true;
38674
+ }
38675
+ return false;
38676
+ }
38568
38677
  function adSpecCastElements(spec, avatar) {
38569
38678
  const lastIndex = spec.beats.length - 1;
38570
38679
  const speakingScenes = spec.beats.map((_, i) => i).filter((i) => !(endCardWanted(spec) && i === lastIndex));
@@ -38606,11 +38715,11 @@ function motionPrompt(beat, toCamera, isClosingCard, place, physics) {
38606
38715
  if (toCamera) return shot;
38607
38716
  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.`;
38608
38717
  }
38609
- function staticTranscript(spec) {
38718
+ function staticTranscript(spec, videoModel) {
38610
38719
  const out = [];
38611
38720
  let clock = 0;
38612
38721
  for (const beat of spec.beats) {
38613
- const duration = beatSeconds(beat.say);
38722
+ const duration = beatSeconds(beat.say, videoModel);
38614
38723
  const words2 = beat.say.trim().split(/\s+/).filter(Boolean);
38615
38724
  const each = words2.length > 0 ? duration / words2.length : duration;
38616
38725
  words2.forEach((text2, i) => {
@@ -38676,9 +38785,12 @@ function voiceoverMode(spec, nativeSpeech) {
38676
38785
  function castRole(spec) {
38677
38786
  if (!spec.cast) return void 0;
38678
38787
  const described = spec.cast.description?.trim();
38679
- return described ? `${described} \u2014 a customer telling their own story, not a worker` : "a customer telling their own story, not a worker";
38788
+ if (!described) return "a customer telling their own story, not a worker";
38789
+ return NAMES_A_JOB.test(described) ? described : `${described} \u2014 a customer telling their own story, not a worker`;
38680
38790
  }
38681
- function adSpecToBlueprint(spec, avatarAccent) {
38791
+ var NAMES_A_JOB = /\b(installer|instalador[ao]?|técnic[oa]|tecnic[oa]|technician|engineer|ingenier[oa]|employee|emplead[oa]|worker|obrer[oa]|staff|clinician|doctor|médic[oa]|medic[oa]|nurse|enfermer[oa]|chef|cocioner[oa]|cociner[oa]|barista|stylist|estilista|trainer|entrenador[ao]?|founder|fundador[ao]?|owner|dueñ[oa]|our team|nuestro equipo|on site|en obra)\b/i;
38792
+ function adSpecToBlueprint(spec, opts = {}) {
38793
+ const avatarAccent = opts.avatarAccent;
38682
38794
  const total = spec.beats.length;
38683
38795
  const market = marketFor(spec);
38684
38796
  const place = market ? ` Set in ${market}: the architecture, streets and styling are ${market}'s.` : "";
@@ -38689,7 +38801,7 @@ function adSpecToBlueprint(spec, avatarAccent) {
38689
38801
  let clock = 0;
38690
38802
  const scenes = spec.beats.map((beat, i) => {
38691
38803
  const isClosingCard = closesOnCard && i === total - 1;
38692
- const duration = beatSeconds(beat.say);
38804
+ const duration = beatSeconds(beat.say, opts.videoModel);
38693
38805
  const start = clock;
38694
38806
  clock += duration;
38695
38807
  return {
@@ -39160,6 +39272,18 @@ var scaffoldAdCommand = defineCommand111({
39160
39272
  avatarError = `Could not read avatar \`${handle}\`: ${e instanceof Error ? e.message : String(e)}. \`baker avatars list\` shows who there is.`;
39161
39273
  }
39162
39274
  }
39275
+ if (avatarError && !spec.data.cast?.description?.trim()) {
39276
+ writeJson({
39277
+ ok: false,
39278
+ error: {
39279
+ code: "VALIDATION_ERROR",
39280
+ message: avatarError,
39281
+ fix: `This ad casts \`${handle}\` and describes nobody else, so without them every shot renders a person nobody chose. Wait for the avatar and re-run \u2014 \`baker avatars get ${handle} --wait\` returns the moment it is ready \u2014 or cast someone who is (\`baker avatars list\`). To build it with an invented person instead, put a written description in \`cast.description\`.`
39282
+ }
39283
+ });
39284
+ process.exit(1);
39285
+ return;
39286
+ }
39163
39287
  const fromWorkspace = await readBrandFromWorkspace();
39164
39288
  const filledLogo = !spec.data.brand?.logo && fromWorkspace.logo ? fromWorkspace.logo : void 0;
39165
39289
  const filledPalette = !spec.data.brand?.palette?.length && fromWorkspace.palette?.length ? fromWorkspace.palette : void 0;
@@ -39238,7 +39362,7 @@ var scaffoldAdCommand = defineCommand111({
39238
39362
  process.exit(1);
39239
39363
  return;
39240
39364
  }
39241
- const blueprint = adSpecToBlueprint(spec.data, avatar?.profile?.accent);
39365
+ const blueprint = adSpecToBlueprint(spec.data, { avatarAccent: avatar?.profile?.accent, videoModel: chosenVideoModel });
39242
39366
  const slug = args.slug ?? path24.basename(specPath).replace(/\.[^.]+$/, "");
39243
39367
  const outPath = args.out ?? (args.slug ? path24.join("src/creatives", slug, `${slug}.canvas.json`) : path24.join(path24.dirname(specPath), `${slug}.canvas.json`));
39244
39368
  const outDir = path24.dirname(outPath);
@@ -39251,10 +39375,10 @@ var scaffoldAdCommand = defineCommand111({
39251
39375
  const blueprintStylePath = path24.join(outDir, "prompt.style.json");
39252
39376
  await writeSceneFiles(outDir, blueprint);
39253
39377
  await writeFile9(blueprintStylePath, renderStyleProjectionFromValue(blueprint), "utf8");
39254
- const logoPath = spec.data.brand?.logo?.trim();
39378
+ const logoPath = noBrand ? void 0 : spec.data.brand?.logo?.trim();
39255
39379
  const transcriptPath = path24.join(outDir, "transcript.json");
39256
39380
  if (spec.data.voiceover === false) {
39257
- await writeFile9(transcriptPath, `${JSON.stringify(staticTranscript(spec.data), null, 2)}
39381
+ await writeFile9(transcriptPath, `${JSON.stringify(staticTranscript(spec.data, chosenVideoModel), null, 2)}
39258
39382
  `, "utf-8");
39259
39383
  }
39260
39384
  const opts = {
@@ -39283,6 +39407,12 @@ var scaffoldAdCommand = defineCommand111({
39283
39407
  // its accent. The clip speaks in the voice the video model renders, and the region is
39284
39408
  // directed in the prompt, which is the only place a pronunciation can come from.
39285
39409
  ...avatar?.profile?.accent?.trim() ? { spokenAccent: avatar.profile.accent.trim() } : {},
39410
+ // Same decision, one step further: with an avatar on camera the clip's own voice is
39411
+ // kept, not re-voiced. The re-voice is the speech-to-speech route ADR 0005 measured
39412
+ // and rejected — it would swap the voice that matches the mouth and the directed
39413
+ // region for one cast off an English stock list. Only the on-camera voice; a
39414
+ // voiceover read has no rendered voice to keep and is still cast.
39415
+ ...avatar ? { keepRenderedVoice: true } : {},
39286
39416
  ...spec.data.voiceover === false ? { staticTranscriptPath: path24.relative(outDir, transcriptPath) } : {}
39287
39417
  };
39288
39418
  const canvas = scaffoldVideoCanvas(blueprint, adSpecCastElements(spec.data, avatar), opts);
@@ -39298,7 +39428,7 @@ var scaffoldAdCommand = defineCommand111({
39298
39428
  }
39299
39429
  }
39300
39430
  const closing = blueprint.scenes[blueprint.scenes.length - 1];
39301
- const overlayHtml = endCardWanted(spec.data) && closing && renderedDir ? buildAdBrandOverlay({
39431
+ const overlayHtml = !noBrand && endCardWanted(spec.data) && closing && renderedDir ? buildAdBrandOverlay({
39302
39432
  logo: staged,
39303
39433
  cta: endCardCta(spec.data),
39304
39434
  cardAtS: closing.start_s,
@@ -50871,6 +51001,20 @@ function blankAstroFrontmatter(text2) {
50871
51001
  }
50872
51002
  return kept.join("") + text2.slice(cut);
50873
51003
  }
51004
+ function blankUnrenderedSource(text2) {
51005
+ return blankTemplateComments(blankAstroFrontmatter(text2));
51006
+ }
51007
+ function blankTemplateComments(text2) {
51008
+ const out = text2.split("");
51009
+ const pattern = /\{\s*\/\*[\s\S]*?\*\/\s*\}|<!--[\s\S]*?-->/g;
51010
+ for (const m of text2.matchAll(pattern)) {
51011
+ const start = m.index ?? 0;
51012
+ for (let i = start; i < start + m[0].length && i < out.length; i++) {
51013
+ if (out[i] !== "\n") out[i] = " ";
51014
+ }
51015
+ }
51016
+ return out.join("");
51017
+ }
50874
51018
  function frontmatterLength(text2) {
50875
51019
  if (!text2.startsWith("---")) return 0;
50876
51020
  const lines = text2.split("\n");
@@ -51244,7 +51388,7 @@ function distinctCompetitorNames(competitors) {
51244
51388
  return [...byFold.values()];
51245
51389
  }
51246
51390
  function mentionsInSource(source, names) {
51247
- const text2 = blankAstroFrontmatter(source.text);
51391
+ const text2 = blankUnrenderedSource(source.text);
51248
51392
  const visible = blankHtmlKeepingOffsets(text2);
51249
51393
  const tables = tableRanges(text2);
51250
51394
  const out = [];
@@ -51284,7 +51428,7 @@ function escapeRegExp(value) {
51284
51428
  }
51285
51429
  function detectSource(source) {
51286
51430
  const file = source.path;
51287
- const text2 = blankAstroFrontmatter(source.text);
51431
+ const text2 = blankUnrenderedSource(source.text);
51288
51432
  const findings = [];
51289
51433
  const lines = text2.split("\n");
51290
51434
  for (const matcher of LINE_MATCHERS) {
@@ -51302,7 +51446,7 @@ function detectSource(source) {
51302
51446
  }
51303
51447
  function detectPage(sources) {
51304
51448
  if (sources.length === 0) return [];
51305
- const combined = sources.map((s) => blankAstroFrontmatter(s.text)).join("\n");
51449
+ const combined = sources.map((s) => blankUnrenderedSource(s.text)).join("\n");
51306
51450
  const pageFile = sources.find((s) => s.path === "index.astro" || s.path.endsWith("/index.astro"))?.path ?? sources[0]?.path ?? "index.astro";
51307
51451
  const findings = [];
51308
51452
  for (const analyzer of ANALYZERS) {
@@ -51382,7 +51526,7 @@ function describeCounts(findings) {
51382
51526
  // src/commands/landing/snapshot.ts
51383
51527
  import { mkdir as mkdir10, rename as rename2, writeFile as writeFile14 } from "fs/promises";
51384
51528
  import path34 from "path";
51385
- var CRITIC_VERSION = "3";
51529
+ var CRITIC_VERSION = "4";
51386
51530
  function critiqueCacheDir(projectRoot) {
51387
51531
  return path34.join(projectRoot, ".cache", "landing-critique");
51388
51532
  }
@@ -57902,6 +58046,8 @@ function costHintsFor(body) {
57902
58046
  if (pinned) hints2.push(pinned);
57903
58047
  const pace = spokenPaceHint(body.prompt, body.duration, model);
57904
58048
  if (pace) hints2.push(pace);
58049
+ const silent = silentModelHint(model, body.prompt);
58050
+ if (silent) hints2.push(silent);
57905
58051
  if (count > 1) {
57906
58052
  hints2.push(
57907
58053
  "Ask for more than one take only when the MOTION is the risk \u2014 for a look you are unsure about, iterate on the still first."