@koda-sl/baker-cli 0.302.0-dev.3ebf34fad → 0.305.0-dev.4e8ef1cd7

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
@@ -23,6 +23,7 @@ import {
23
23
  bestSupportedQuality,
24
24
  canonicalUrl,
25
25
  clipAudioMode,
26
+ clipDurationsFor,
26
27
  clipParamRecipe,
27
28
  clipProfileFor,
28
29
  collectAssetRefLikes,
@@ -64,7 +65,7 @@ import {
64
65
  ulid,
65
66
  validateCanvasDeep,
66
67
  ytDlpBlockSignal
67
- } from "./chunk-KKJT24WA.js";
68
+ } from "./chunk-7TL4VPK5.js";
68
69
  import {
69
70
  csvOrJson,
70
71
  daysAgoIso,
@@ -2381,7 +2382,6 @@ var IMAGE_ASSET_LINK_FIELD_TYPES = /* @__PURE__ */ new Set([
2381
2382
  "PORTRAIT_MARKETING_IMAGE",
2382
2383
  "AD_IMAGE"
2383
2384
  ]);
2384
- 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.";
2385
2385
  function isImageAssetLinkFieldType(fieldType) {
2386
2386
  return IMAGE_ASSET_LINK_FIELD_TYPES.has(fieldType);
2387
2387
  }
@@ -3385,7 +3385,6 @@ var assetLinkAttachSchema = z5.object({
3385
3385
  if (value.fieldType === "BUSINESS_LOGO" && value.level === "adGroup") {
3386
3386
  ctx.addIssue({ code: z5.ZodIssueCode.custom, path: ["fieldType"], message: BUSINESS_LOGO_LEVEL_MESSAGE });
3387
3387
  } else if (isImageAssetLinkFieldType(value.fieldType)) {
3388
- ctx.addIssue({ code: z5.ZodIssueCode.custom, path: ["fieldType"], message: IMAGE_ASSET_LINK_MESSAGE });
3389
3388
  } else if (!ASSET_FIELD_TYPES.includes(value.fieldType)) {
3390
3389
  ctx.addIssue({
3391
3390
  code: z5.ZodIssueCode.custom,
@@ -4465,7 +4464,13 @@ var avatarsCreateRequestSchema = z10.object({
4465
4464
  sourceImageIds: z10.array(z10.string().min(1)).max(6).optional(),
4466
4465
  /** The company's statement that it may use this person's likeness in ads. */
4467
4466
  likenessConfirmed: z10.boolean().optional(),
4468
- 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()
4469
4474
  }).refine((body) => !body.sourceImageIds?.length || body.likenessConfirmed === true, {
4470
4475
  message: "likenessConfirmed is required when building an avatar from photographs of a real person",
4471
4476
  path: ["likenessConfirmed"]
@@ -16593,8 +16598,8 @@ function imageAssetHints(payload) {
16593
16598
  return [];
16594
16599
  }
16595
16600
  return [
16596
- "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.",
16597
- "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`.)"
16598
16603
  ];
16599
16604
  }
16600
16605
  var assetsUpdateCommand = defineCommand31({
@@ -16637,7 +16642,7 @@ var assetsUpdateCommand = defineCommand31({
16637
16642
  var assetsCommand = defineCommand31({
16638
16643
  meta: {
16639
16644
  name: "assets",
16640
- 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."
16641
16646
  },
16642
16647
  subCommands: {
16643
16648
  create: fileCreateCommand(
@@ -16648,7 +16653,7 @@ var assetsCommand = defineCommand31({
16648
16653
  ),
16649
16654
  update: assetsUpdateCommand,
16650
16655
  attach: fileCreateCommand("attach", "google.assetLink.attach", "Attach an asset to a campaign/adGroup/customer", [
16651
- "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`)."
16652
16657
  ]),
16653
16658
  detach: statusCommand2("google.assetLink.detach", "asset link")
16654
16659
  }
@@ -30246,7 +30251,10 @@ var createCommand2 = defineCommand96({
30246
30251
  ...sourceImageIds ? { sourceImageIds, likenessConfirmed: true } : {},
30247
30252
  ...args["voice-id"] ? { voiceId: args["voice-id"] } : {},
30248
30253
  ...args["voice-description"] ? { voiceDescription: args["voice-description"] } : {},
30249
- ...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
30250
30258
  };
30251
30259
  const data = await writeAvatars("/api/avatars", body);
30252
30260
  writeJson({ ok: true, data, hints: creationHints({ ...data, faceChoice }, body.profile) });
@@ -33463,7 +33471,7 @@ function sameSpeakerTrackEntries(segments) {
33463
33471
  };
33464
33472
  });
33465
33473
  }
33466
- function buildPerSpeakerVoiceConversion(segments, totalMs, nodes) {
33474
+ function buildPerSpeakerVoiceConversion(segments, totalMs, nodes, keepRenderedVoice = false) {
33467
33475
  const bySpeaker = /* @__PURE__ */ new Map();
33468
33476
  for (const seg of segments) {
33469
33477
  const arr = bySpeaker.get(seg.voiceNode) ?? [];
@@ -33472,7 +33480,7 @@ function buildPerSpeakerVoiceConversion(segments, totalMs, nodes) {
33472
33480
  }
33473
33481
  const tracks = [];
33474
33482
  for (const [voiceNode, segs] of bySpeaker) {
33475
- const trackId = `${voiceNode}_track`;
33483
+ const trackId = keepRenderedVoice ? `${voiceNode}_conv` : `${voiceNode}_track`;
33476
33484
  const convId = `${voiceNode}_conv`;
33477
33485
  const sorted = [...segs].sort((a, b) => a.start_s - b.start_s);
33478
33486
  const mixInputs = {};
@@ -33488,12 +33496,14 @@ function buildPerSpeakerVoiceConversion(segments, totalMs, nodes) {
33488
33496
  total_ms: totalMs
33489
33497
  }
33490
33498
  });
33491
- nodes.push({
33492
- id: convId,
33493
- type: "audio_voice_convert",
33494
- inputs: { audio: `$ref:${trackId}.audio`, voice_ref: `$ref:${voiceNode}.voice_id` },
33495
- params: { model: FIXED_VOICE_CONVERT_MODEL, voice: "{{voice_ref}}" }
33496
- });
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
+ }
33497
33507
  tracks.push({ slot: convId, ref: `$ref:${convId}.audio`, start_s: 0, duration_s: totalMs / 1e3, kind: "vo" });
33498
33508
  }
33499
33509
  return tracks;
@@ -34633,7 +34643,8 @@ function buildTimeline(blueprint, slots, opts, nodes) {
34633
34643
  graphicScenes: fullFrameGraphicScenes(blueprint),
34634
34644
  surfaceIngests: /* @__PURE__ */ new Map(),
34635
34645
  ttsLanguageCode: deriveTtsLanguageCode(blueprint),
34636
- spokenAccent: blueprint.global?.voiceover?.accent?.trim() || void 0
34646
+ spokenAccent: blueprint.global?.voiceover?.accent?.trim() || void 0,
34647
+ keepRenderedVoice: opts.keepRenderedVoice
34637
34648
  };
34638
34649
  const out = {
34639
34650
  routes: /* @__PURE__ */ new Map(),
@@ -34690,7 +34701,7 @@ function buildTimeline(blueprint, slots, opts, nodes) {
34690
34701
  prevEndFrame = emitBrollScene(scene, i, i === lastIndex, env, nodes, out, prevEndFrame);
34691
34702
  });
34692
34703
  const totalMs = Math.round(env.clock.totalS * 1e3);
34693
- out.voTracks.push(...buildPerSpeakerVoiceConversion(out.nativeSegments, totalMs, nodes));
34704
+ out.voTracks.push(...buildPerSpeakerVoiceConversion(out.nativeSegments, totalMs, nodes, env.keepRenderedVoice));
34694
34705
  return {
34695
34706
  clips: out.clips,
34696
34707
  voTracks: out.voTracks,
@@ -35128,6 +35139,18 @@ function emitCaptionChain(blueprint, tracks, clock, opts, videoRef, nodes) {
35128
35139
  });
35129
35140
  return { videoRef: "$ref:captions.video", videoNode: "captions" };
35130
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
+ }
35131
35154
  function scaffoldVideoCanvas(input, elementsInput, opts) {
35132
35155
  const blueprint = VideoBlueprint.parse(input);
35133
35156
  injectHookPhysicality(blueprint);
@@ -35241,6 +35264,7 @@ function scaffoldVideoCanvas(input, elementsInput, opts) {
35241
35264
  ...aspectRemapTodo(resolveAspect(blueprint.source?.aspect_ratio, opts.aspect, genAspectsFor(opts.videoModel))),
35242
35265
  model_constraints: buildModelConstraints()
35243
35266
  };
35267
+ dropUncastVoices(nodes);
35244
35268
  return {
35245
35269
  schema: "baker-canvas/1",
35246
35270
  metadata: {
@@ -36362,6 +36386,14 @@ var RENDER_REVIEW_MODEL = "gemini-pro-latest";
36362
36386
  var RENDER_REVIEW_FRAMES = 12;
36363
36387
  var RENDER_REVIEW_WIDTH = 1024;
36364
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
+ }
36365
36397
  var WATCH_MAX_BYTES = 60 * 1024 * 1024;
36366
36398
  function sceneAt(windows, atS) {
36367
36399
  return windows.find((w) => atS >= w.startS && atS < w.endS)?.file;
@@ -36623,6 +36655,12 @@ function videoPathFromOutput(output) {
36623
36655
  }
36624
36656
  return found;
36625
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
+ }
36626
36664
  function classifyAudio(volumedetect, hasAudioStream) {
36627
36665
  if (!hasAudioStream) {
36628
36666
  return [
@@ -36632,9 +36670,17 @@ function classifyAudio(volumedetect, hasAudioStream) {
36632
36670
  }
36633
36671
  ];
36634
36672
  }
36635
- const mean = Number.parseFloat(volumedetect.match(/mean_volume:\s*(-?[\d.]+) dB/)?.[1] ?? "");
36636
- 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");
36637
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
+ }
36638
36684
  if (Number.isFinite(mean) && mean < -30) {
36639
36685
  findings.push({
36640
36686
  severity: "warning",
@@ -36692,7 +36738,7 @@ async function videoDuration(video) {
36692
36738
  return null;
36693
36739
  }
36694
36740
  }
36695
- async function askVideo(video, prompt) {
36741
+ async function askVideo(video, prompt, spent) {
36696
36742
  const key = frameVisionKey();
36697
36743
  if (!key) return null;
36698
36744
  try {
@@ -36715,13 +36761,15 @@ async function askVideo(video, prompt) {
36715
36761
  );
36716
36762
  if (!response.ok) return null;
36717
36763
  const body = await response.json();
36764
+ const usage = usageFrom(RENDER_WATCH_MODEL, body);
36765
+ if (usage) spent(usage);
36718
36766
  const text2 = body.candidates?.[0]?.content?.parts?.map((p) => p.text ?? "").join("");
36719
36767
  return text2 ? parseReviewJson(text2) : null;
36720
36768
  } catch {
36721
36769
  return null;
36722
36770
  }
36723
36771
  }
36724
- async function ask(images, prompt) {
36772
+ async function ask(images, prompt, spent) {
36725
36773
  const key = frameVisionKey();
36726
36774
  if (!key) return null;
36727
36775
  const parts = [{ text: prompt }];
@@ -36738,6 +36786,8 @@ async function ask(images, prompt) {
36738
36786
  );
36739
36787
  if (!response.ok) return null;
36740
36788
  const body = await response.json();
36789
+ const usage = usageFrom(RENDER_REVIEW_MODEL, body);
36790
+ if (usage) spent(usage);
36741
36791
  const text2 = body.candidates?.[0]?.content?.parts?.map((p) => p.text ?? "").join("");
36742
36792
  return text2 ? parseReviewJson(text2) : null;
36743
36793
  } catch {
@@ -36748,6 +36798,7 @@ async function reviewRenderedVideo(opts) {
36748
36798
  if (!hasFrameVisionKey()) return null;
36749
36799
  const duration = await videoDuration(opts.video);
36750
36800
  if (duration === null) return null;
36801
+ const spent = (usage) => opts.onSpend?.(usage);
36751
36802
  const dir = await mkdtemp(path15.join(tmpdir(), "baker-review-"));
36752
36803
  try {
36753
36804
  const grabbed = [];
@@ -36762,17 +36813,22 @@ async function reviewRenderedVideo(opts) {
36762
36813
  if (grabbed.length === 0) return null;
36763
36814
  const reel = await ask(
36764
36815
  grabbed.map((f) => f.image),
36765
- buildReelReviewPrompt({ subject: opts.subject, script: opts.script })
36816
+ buildReelReviewPrompt({ subject: opts.subject, script: opts.script }),
36817
+ spent
36766
36818
  );
36767
36819
  const framePrompt = buildFrameReviewPrompt({ subject: opts.subject });
36768
36820
  const perFrame = await mapWithLimit(grabbed, REVIEW_CONCURRENCY, async (f) => ({
36769
36821
  at: f.at,
36770
- json: await ask([f.image], framePrompt)
36822
+ json: await ask([f.image], framePrompt, spent)
36771
36823
  }));
36772
36824
  const unread = perFrame.filter((f) => f.json === null).length;
36773
36825
  if (unread === perFrame.length && reel === null) return null;
36774
- const watch = await askVideo(opts.video, buildWatchReviewPrompt({ market: opts.market, script: opts.script }));
36775
- 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;
36776
36832
  const findings = [
36777
36833
  ...classifyFrameFindings(perFrame, opts.windows ?? []),
36778
36834
  ...classifyReelFindings(reel),
@@ -36782,12 +36838,15 @@ async function reviewRenderedVideo(opts) {
36782
36838
  // Measured, not judged: a model looking at frames cannot hear the mix.
36783
36839
  ...await measureAudio(opts.video)
36784
36840
  ];
36785
- if (unread > 0) {
36786
- findings.push({
36787
- severity: "warning",
36788
- what: `${unread} of ${perFrame.length} frames could not be reviewed \u2014 that part of the video is unchecked.`
36789
- });
36790
- }
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
+ );
36791
36850
  return findings;
36792
36851
  } finally {
36793
36852
  await rm5(dir, { recursive: true, force: true });
@@ -36811,6 +36870,28 @@ async function sceneWindowsBeside(canvasPath) {
36811
36870
  }
36812
36871
  return windows;
36813
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
+ }
36814
36895
 
36815
36896
  // src/commands/canvas/run.ts
36816
36897
  var runCommand = defineCommand107({
@@ -37125,10 +37206,12 @@ ${describeRewrites(healed.rewrites)}
37125
37206
  `));
37126
37207
  }
37127
37208
  const reviewable = videoPathFromOutput(result.output);
37209
+ const reviewUsage = [];
37128
37210
  const review = reviewable ? await reviewRenderedVideo({
37129
37211
  video: reviewable,
37130
37212
  windows: await sceneWindowsBeside(filePath),
37131
- brief: opts.brief?.trim() || void 0
37213
+ brief: opts.brief?.trim() || void 0,
37214
+ onSpend: (usage) => reviewUsage.push(usage)
37132
37215
  }) : null;
37133
37216
  const hints2 = reviewable ? review === null ? [RENDER_REVIEW_UNAVAILABLE] : review.length === 0 ? ["Reviewed the finished video frame by frame \u2014 nothing to report."] : [
37134
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.`,
@@ -37142,7 +37225,12 @@ ${describeRewrites(healed.rewrites)}
37142
37225
  // `review` is null only when nobody looked (no key, no ffmpeg, unreadable video);
37143
37226
  // an empty array is a render that was reviewed and came back clean. The record
37144
37227
  // has to keep those apart.
37145
- 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
+ )
37146
37234
  );
37147
37235
  }
37148
37236
  process.stdout.write(
@@ -38468,6 +38556,7 @@ var CURRENCY_BY_MARKET = {
38468
38556
  "the united kingdom": "\xA3 (pounds sterling)",
38469
38557
  "the united states": "$ (US dollars)"
38470
38558
  };
38559
+ var MARKERS_TO_NAME_A_PLACE = 3;
38471
38560
  var MARKET_BY_LANGUAGE = {
38472
38561
  es: "Spain",
38473
38562
  "es-es": "Spain",
@@ -38506,6 +38595,9 @@ var SCRIPT_LANGUAGE_MARKERS = [
38506
38595
  ["english", /\b(the|a|an|of|to|for|with|your|more|already|but|because|until|without)\b/gi]
38507
38596
  ];
38508
38597
  function detectScriptLanguage(lines) {
38598
+ return bestScriptLanguage(lines)?.name;
38599
+ }
38600
+ function bestScriptLanguage(lines) {
38509
38601
  const text2 = lines.join(" ");
38510
38602
  if (text2.trim().length < 12) return void 0;
38511
38603
  let best = null;
@@ -38513,7 +38605,7 @@ function detectScriptLanguage(lines) {
38513
38605
  const hits = (text2.match(re2) ?? []).length;
38514
38606
  if (hits > 0 && (!best || hits > best.hits)) best = { name, hits };
38515
38607
  }
38516
- return best?.name;
38608
+ return best ?? void 0;
38517
38609
  }
38518
38610
  function voiceLanguageFor(spec) {
38519
38611
  const declared = spec.voice?.language?.trim().toLowerCase();
@@ -38526,12 +38618,19 @@ function voiceLanguageFor(spec) {
38526
38618
  function marketFor(spec) {
38527
38619
  if (spec.market) return spec.market;
38528
38620
  const lang = spec.voice?.language?.trim().toLowerCase();
38529
- return lang ? MARKET_BY_LANGUAGE[lang] : void 0;
38530
- }
38531
- 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) {
38532
38631
  const words2 = line.trim().split(/\s+/).length;
38533
38632
  const spoken = words2 / 3.06;
38534
- const allowed = [...SEEDANCE_DURATIONS].sort((a, b) => a - b);
38633
+ const allowed = (videoModel ? clipDurationsFor(videoModel) : void 0) ?? [...SEEDANCE_DURATIONS].sort((a, b) => a - b);
38535
38634
  return allowed.find((d) => d >= spoken) ?? allowed[allowed.length - 1];
38536
38635
  }
38537
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.";
@@ -38560,18 +38659,21 @@ function castedAvatarOnRoster(ad, roster) {
38560
38659
  if (ad.cast?.avatar) return null;
38561
38660
  const haystack = [ad.cast?.description ?? "", ...(ad.beats ?? []).map((b) => b.show ?? "")].join(" ");
38562
38661
  if (!haystack.trim()) return null;
38563
- const words2 = new Set(
38564
- haystack.toLowerCase().split(/[^\p{L}\p{N}]+/u).filter(Boolean)
38565
- );
38662
+ const written = new Set(haystack.split(/[^\p{L}\p{N}]+/u).filter(Boolean));
38566
38663
  for (const person of roster) {
38567
- for (const label of [person.name, person.handle]) {
38568
- const token = label.trim().toLowerCase();
38569
- if (!token || token.includes("-") || token.length < 3) continue;
38570
- if (words2.has(token)) return person.handle;
38571
- }
38664
+ if (labelWrittenAsName(person.name, written) || labelWrittenAsName(person.handle, written)) return person.handle;
38572
38665
  }
38573
38666
  return null;
38574
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
+ }
38575
38677
  function adSpecCastElements(spec, avatar) {
38576
38678
  const lastIndex = spec.beats.length - 1;
38577
38679
  const speakingScenes = spec.beats.map((_, i) => i).filter((i) => !(endCardWanted(spec) && i === lastIndex));
@@ -38613,11 +38715,11 @@ function motionPrompt(beat, toCamera, isClosingCard, place, physics) {
38613
38715
  if (toCamera) return shot;
38614
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.`;
38615
38717
  }
38616
- function staticTranscript(spec) {
38718
+ function staticTranscript(spec, videoModel) {
38617
38719
  const out = [];
38618
38720
  let clock = 0;
38619
38721
  for (const beat of spec.beats) {
38620
- const duration = beatSeconds(beat.say);
38722
+ const duration = beatSeconds(beat.say, videoModel);
38621
38723
  const words2 = beat.say.trim().split(/\s+/).filter(Boolean);
38622
38724
  const each = words2.length > 0 ? duration / words2.length : duration;
38623
38725
  words2.forEach((text2, i) => {
@@ -38683,9 +38785,12 @@ function voiceoverMode(spec, nativeSpeech) {
38683
38785
  function castRole(spec) {
38684
38786
  if (!spec.cast) return void 0;
38685
38787
  const described = spec.cast.description?.trim();
38686
- 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`;
38687
38790
  }
38688
- 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;
38689
38794
  const total = spec.beats.length;
38690
38795
  const market = marketFor(spec);
38691
38796
  const place = market ? ` Set in ${market}: the architecture, streets and styling are ${market}'s.` : "";
@@ -38696,7 +38801,7 @@ function adSpecToBlueprint(spec, avatarAccent) {
38696
38801
  let clock = 0;
38697
38802
  const scenes = spec.beats.map((beat, i) => {
38698
38803
  const isClosingCard = closesOnCard && i === total - 1;
38699
- const duration = beatSeconds(beat.say);
38804
+ const duration = beatSeconds(beat.say, opts.videoModel);
38700
38805
  const start = clock;
38701
38806
  clock += duration;
38702
38807
  return {
@@ -39167,6 +39272,18 @@ var scaffoldAdCommand = defineCommand111({
39167
39272
  avatarError = `Could not read avatar \`${handle}\`: ${e instanceof Error ? e.message : String(e)}. \`baker avatars list\` shows who there is.`;
39168
39273
  }
39169
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
+ }
39170
39287
  const fromWorkspace = await readBrandFromWorkspace();
39171
39288
  const filledLogo = !spec.data.brand?.logo && fromWorkspace.logo ? fromWorkspace.logo : void 0;
39172
39289
  const filledPalette = !spec.data.brand?.palette?.length && fromWorkspace.palette?.length ? fromWorkspace.palette : void 0;
@@ -39245,7 +39362,7 @@ var scaffoldAdCommand = defineCommand111({
39245
39362
  process.exit(1);
39246
39363
  return;
39247
39364
  }
39248
- const blueprint = adSpecToBlueprint(spec.data, avatar?.profile?.accent);
39365
+ const blueprint = adSpecToBlueprint(spec.data, { avatarAccent: avatar?.profile?.accent, videoModel: chosenVideoModel });
39249
39366
  const slug = args.slug ?? path24.basename(specPath).replace(/\.[^.]+$/, "");
39250
39367
  const outPath = args.out ?? (args.slug ? path24.join("src/creatives", slug, `${slug}.canvas.json`) : path24.join(path24.dirname(specPath), `${slug}.canvas.json`));
39251
39368
  const outDir = path24.dirname(outPath);
@@ -39258,10 +39375,10 @@ var scaffoldAdCommand = defineCommand111({
39258
39375
  const blueprintStylePath = path24.join(outDir, "prompt.style.json");
39259
39376
  await writeSceneFiles(outDir, blueprint);
39260
39377
  await writeFile9(blueprintStylePath, renderStyleProjectionFromValue(blueprint), "utf8");
39261
- const logoPath = spec.data.brand?.logo?.trim();
39378
+ const logoPath = noBrand ? void 0 : spec.data.brand?.logo?.trim();
39262
39379
  const transcriptPath = path24.join(outDir, "transcript.json");
39263
39380
  if (spec.data.voiceover === false) {
39264
- await writeFile9(transcriptPath, `${JSON.stringify(staticTranscript(spec.data), null, 2)}
39381
+ await writeFile9(transcriptPath, `${JSON.stringify(staticTranscript(spec.data, chosenVideoModel), null, 2)}
39265
39382
  `, "utf-8");
39266
39383
  }
39267
39384
  const opts = {
@@ -39290,6 +39407,12 @@ var scaffoldAdCommand = defineCommand111({
39290
39407
  // its accent. The clip speaks in the voice the video model renders, and the region is
39291
39408
  // directed in the prompt, which is the only place a pronunciation can come from.
39292
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 } : {},
39293
39416
  ...spec.data.voiceover === false ? { staticTranscriptPath: path24.relative(outDir, transcriptPath) } : {}
39294
39417
  };
39295
39418
  const canvas = scaffoldVideoCanvas(blueprint, adSpecCastElements(spec.data, avatar), opts);
@@ -39305,7 +39428,7 @@ var scaffoldAdCommand = defineCommand111({
39305
39428
  }
39306
39429
  }
39307
39430
  const closing = blueprint.scenes[blueprint.scenes.length - 1];
39308
- const overlayHtml = endCardWanted(spec.data) && closing && renderedDir ? buildAdBrandOverlay({
39431
+ const overlayHtml = !noBrand && endCardWanted(spec.data) && closing && renderedDir ? buildAdBrandOverlay({
39309
39432
  logo: staged,
39310
39433
  cta: endCardCta(spec.data),
39311
39434
  cardAtS: closing.start_s,
@@ -50878,6 +51001,20 @@ function blankAstroFrontmatter(text2) {
50878
51001
  }
50879
51002
  return kept.join("") + text2.slice(cut);
50880
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
+ }
50881
51018
  function frontmatterLength(text2) {
50882
51019
  if (!text2.startsWith("---")) return 0;
50883
51020
  const lines = text2.split("\n");
@@ -51251,7 +51388,7 @@ function distinctCompetitorNames(competitors) {
51251
51388
  return [...byFold.values()];
51252
51389
  }
51253
51390
  function mentionsInSource(source, names) {
51254
- const text2 = blankAstroFrontmatter(source.text);
51391
+ const text2 = blankUnrenderedSource(source.text);
51255
51392
  const visible = blankHtmlKeepingOffsets(text2);
51256
51393
  const tables = tableRanges(text2);
51257
51394
  const out = [];
@@ -51291,7 +51428,7 @@ function escapeRegExp(value) {
51291
51428
  }
51292
51429
  function detectSource(source) {
51293
51430
  const file = source.path;
51294
- const text2 = blankAstroFrontmatter(source.text);
51431
+ const text2 = blankUnrenderedSource(source.text);
51295
51432
  const findings = [];
51296
51433
  const lines = text2.split("\n");
51297
51434
  for (const matcher of LINE_MATCHERS) {
@@ -51309,7 +51446,7 @@ function detectSource(source) {
51309
51446
  }
51310
51447
  function detectPage(sources) {
51311
51448
  if (sources.length === 0) return [];
51312
- const combined = sources.map((s) => blankAstroFrontmatter(s.text)).join("\n");
51449
+ const combined = sources.map((s) => blankUnrenderedSource(s.text)).join("\n");
51313
51450
  const pageFile = sources.find((s) => s.path === "index.astro" || s.path.endsWith("/index.astro"))?.path ?? sources[0]?.path ?? "index.astro";
51314
51451
  const findings = [];
51315
51452
  for (const analyzer of ANALYZERS) {
@@ -51389,7 +51526,7 @@ function describeCounts(findings) {
51389
51526
  // src/commands/landing/snapshot.ts
51390
51527
  import { mkdir as mkdir10, rename as rename2, writeFile as writeFile14 } from "fs/promises";
51391
51528
  import path34 from "path";
51392
- var CRITIC_VERSION = "3";
51529
+ var CRITIC_VERSION = "4";
51393
51530
  function critiqueCacheDir(projectRoot) {
51394
51531
  return path34.join(projectRoot, ".cache", "landing-critique");
51395
51532
  }