@koda-sl/baker-cli 0.272.0-dev.1f1c09c80 → 0.275.0-dev.1f1c09c80

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/README.md CHANGED
@@ -3648,6 +3648,9 @@ because a video model garbles a wordmark every time:
3648
3648
  the ad needs something other than the client's default.
3649
3649
  - **The spoken language is read off the script** when the spec does not declare it, so a
3650
3650
  Spanish ad is cast from Spanish voices without anyone having to say so.
3651
+ - **`voiceover: false`** — a music-led ad. No voice, no transcription: the `say` lines
3652
+ become on-screen text and are captioned straight from the script, so the words are
3653
+ exact. Give it `music` too.
3651
3654
  - **`--avatar <handle>`** — the same flag as on `studio generate` / `studio animate`, and the way to cast a person into an ad. Overrides `cast.avatar` in the spec.
3652
3655
  - **`cast.avatar`** — the handle of a cast Avatar (`baker avatars list`). The ad grounds
3653
3656
  every beat they appear in on that avatar's identity sheet and copies its subject
package/dist/cli.js CHANGED
@@ -29566,9 +29566,32 @@ function emitBlueprintIngests(opts, nodes) {
29566
29566
  params: { source: "path", path: opts.blueprintStylePath ?? "./prompt.style.json", expect: "json" }
29567
29567
  });
29568
29568
  }
29569
+ function emitStaticCaptions(opts, videoRef, nodes) {
29570
+ if (!opts.staticTranscriptPath) return { videoRef };
29571
+ const wordsPerGroup = opts.captionWordsPerGroup ? { words_per_group: opts.captionWordsPerGroup } : {};
29572
+ nodes.push({
29573
+ id: "captions_transcript",
29574
+ type: "ingest",
29575
+ params: { source: "path", path: opts.staticTranscriptPath, expect: "json" }
29576
+ });
29577
+ const overlaid = nodes.find((n) => n.id === "overlaid");
29578
+ if (overlaid) {
29579
+ overlaid.inputs = { ...overlaid.inputs, transcript: "$ref:captions_transcript.json" };
29580
+ overlaid.params = { ...overlaid.params, ...wordsPerGroup };
29581
+ return { videoRef };
29582
+ }
29583
+ nodes.push({
29584
+ id: "captions",
29585
+ type: "hyperframe_render",
29586
+ inputs: { background: videoRef, transcript: "$ref:captions_transcript.json" },
29587
+ params: { composition: opts.captionsCompositionPath, ...wordsPerGroup }
29588
+ });
29589
+ return { videoRef: "$ref:captions.video", videoNode: "captions" };
29590
+ }
29569
29591
  function emitCaptionChain(blueprint, tracks, clock, opts, videoRef, nodes) {
29570
29592
  const voTrackList = tracks.filter((t) => t.kind === "vo");
29571
- if (!opts.captionsCompositionPath || voTrackList.length === 0) return { videoRef };
29593
+ if (!opts.captionsCompositionPath) return { videoRef };
29594
+ if (voTrackList.length === 0) return emitStaticCaptions(opts, videoRef, nodes);
29572
29595
  const voiceMixInputs = {};
29573
29596
  for (const t of voTrackList) voiceMixInputs[t.slot] = t.ref;
29574
29597
  nodes.push({
@@ -30100,6 +30123,9 @@ async function syncSceneNodeParams(canvas, canvasPath, log) {
30100
30123
  try {
30101
30124
  rebuildRaw = await readFile7(path9.join(creativeDir, REBUILD_FILE), "utf8");
30102
30125
  } catch {
30126
+ log(
30127
+ `[scenes] this creative has scene files but no ${REBUILD_FILE}, so edits to scenes/*.json are NOT reaching the render \u2014 the prompts inlined in the canvas are what renders. Re-scaffold the creative to restore the link, or edit the canvas node prompts directly.`
30128
+ );
30103
30129
  return "not_applicable";
30104
30130
  }
30105
30131
  const { elements, opts } = JSON.parse(rebuildRaw);
@@ -30330,7 +30356,8 @@ function buildRunRecord(result, meta, plan, finalLabels) {
30330
30356
  durationMs: result.stats.duration_ms
30331
30357
  },
30332
30358
  nodes,
30333
- finalOutputs: finalOutputs.length > 0 ? finalOutputs : void 0
30359
+ finalOutputs: finalOutputs.length > 0 ? finalOutputs : void 0,
30360
+ ...meta.review && meta.review.length > 0 ? { review: meta.review.slice(0, 40) } : {}
30334
30361
  };
30335
30362
  }
30336
30363
  function buildInitialRunRecord(runId, meta) {
@@ -31391,7 +31418,6 @@ ${describeRewrites(healed.rewrites)}
31391
31418
  } : void 0
31392
31419
  });
31393
31420
  await clearRunMarker(outputsDir, filePath);
31394
- if (poster) await poster.flush(buildRunRecord(result, recordMeta, progress?.planInfo(), finalLabels));
31395
31421
  const keepRuns = opts.keepRuns;
31396
31422
  if (keepRuns !== void 0 && Number.isFinite(keepRuns)) {
31397
31423
  await pruneOldRuns(outputsDir, keepRuns, result.run_id, (line) => process.stdout.write(`${line}
@@ -31406,6 +31432,11 @@ ${describeRewrites(healed.rewrites)}
31406
31432
  `Fix them, then re-run: edit the \`show\` of the scene files named above \u2014 a shot with a person handling equipment is where limbs and objects go wrong, so widen it, move the hands out of frame, or drop the person from that beat. Re-running re-renders ONLY the scenes you edited; everything else comes from the cache (this run: ${result.stats?.cached_nodes ?? 0} of ${result.stats?.total_nodes ?? 0} nodes were cached).`
31407
31433
  ] : []
31408
31434
  ] : [];
31435
+ if (poster) {
31436
+ await poster.flush(
31437
+ buildRunRecord(result, { ...recordMeta, ...review ? { review } : {} }, progress?.planInfo(), finalLabels)
31438
+ );
31439
+ }
31409
31440
  process.stdout.write(
31410
31441
  `${JSON.stringify(
31411
31442
  {
@@ -32487,12 +32518,16 @@ import { defineCommand as defineCommand104 } from "citty";
32487
32518
  // src/engine/scaffold/ad-spec.ts
32488
32519
  import { z as z30 } from "zod";
32489
32520
  var oneClause = (line) => (line.match(/[.!?](\s|$)/g) ?? []).length <= 1;
32521
+ var isSpeech = (line) => new RegExp("\\p{L}\\p{L}", "u").test(line);
32490
32522
  var Beat = z30.object({
32491
32523
  /**
32492
32524
  * The line the voice says in this beat — ONE clause, ending in its own
32493
32525
  * punctuation. It becomes the caption card verbatim.
32494
32526
  */
32495
- say: z30.string().min(1).refine(oneClause, "a beat is one clause: split a second sentence into its own beat"),
32527
+ say: z30.string().min(1).refine(
32528
+ isSpeech,
32529
+ "a beat's line has to be words someone says \u2014 a dot or a dash is not a line, and an ad with no voiceover is not supported yet"
32530
+ ).refine(oneClause, "a beat is one clause: split a second sentence into its own beat"),
32496
32531
  /** What is on screen while it is said, as a shot brief for the video model. */
32497
32532
  show: z30.string().min(1),
32498
32533
  /**
@@ -32594,6 +32629,24 @@ var AdSpec = z30.object({
32594
32629
  cta: z30.string().min(1).optional()
32595
32630
  })
32596
32631
  ]).optional(),
32632
+ /**
32633
+ * Whether a voice reads the lines.
32634
+ *
32635
+ * On by default. Turned off, the ad carries its music and its pictures and the `say`
32636
+ * lines become ON-SCREEN text instead of spoken ones — which is how most music-led
32637
+ * social ads are actually built.
32638
+ *
32639
+ * It exists because an agent wanted exactly this and had no way to ask: it wrote "·"
32640
+ * as every line with a voice description reading "No voiceover; music only". The dot
32641
+ * was sent to the voice model, the near-silence that came back was transcribed, and
32642
+ * the transcriber hallucinated "Thank you." — which became the ad's only caption card,
32643
+ * held over all sixteen seconds, in English, in a Spanish ad.
32644
+ *
32645
+ * The captions are BETTER on this path, not worse: they come from the script itself
32646
+ * rather than from transcribing a rendered read, so the text is exact and no speech
32647
+ * model is between the copy and the screen.
32648
+ */
32649
+ voiceover: z30.boolean().default(true),
32597
32650
  beats: z30.array(Beat).min(2)
32598
32651
  });
32599
32652
  function presenterIsInEveryShot(spec) {
@@ -32684,6 +32737,10 @@ function beatSeconds(line) {
32684
32737
  return allowed.find((d) => d >= spoken) ?? allowed[allowed.length - 1];
32685
32738
  }
32686
32739
  var BRAND_PLATE = "A solid flat brand colour plate \u2014 an end card. No people, no objects, no scenery, no text of any kind.";
32740
+ var ASKS_FOR_LEGIBLE = /\b(focus on the (number|amount|total|price|figure|text)|readable|legible|clearly (shows?|visible)|highlighted (number|amount|total)|showing the (number|amount|total|price))\b/i;
32741
+ function briefWantsLegibleText(show) {
32742
+ return ASKS_FOR_LEGIBLE.test(show);
32743
+ }
32687
32744
  function endCardWanted(spec) {
32688
32745
  if (spec.end_card === false) return false;
32689
32746
  return Boolean(spec.brand?.name || spec.brand?.logo);
@@ -32715,6 +32772,30 @@ function adSpecCastElements(spec, avatar) {
32715
32772
  }
32716
32773
  ];
32717
32774
  }
32775
+ function motionPrompt(beat, isClosingCard, place, physics) {
32776
+ if (isClosingCard) return BRAND_PLATE;
32777
+ const shot = `${beat.show}${place}${physics}`;
32778
+ return beat.on_camera ? shot : `${shot} Nobody in frame is speaking \u2014 mouths closed, no dialogue.`;
32779
+ }
32780
+ function staticTranscript(spec) {
32781
+ const out = [];
32782
+ let clock = 0;
32783
+ for (const beat of spec.beats) {
32784
+ const duration = beatSeconds(beat.say);
32785
+ const words2 = beat.say.trim().split(/\s+/).filter(Boolean);
32786
+ const each = words2.length > 0 ? duration / words2.length : duration;
32787
+ words2.forEach((text2, i) => {
32788
+ const start = clock + i * each;
32789
+ out.push({
32790
+ text: text2,
32791
+ start: Math.round(start * 100) / 100,
32792
+ end: Math.round((start + each) * 100) / 100
32793
+ });
32794
+ });
32795
+ clock += duration;
32796
+ }
32797
+ return out;
32798
+ }
32718
32799
  function adSpecToBlueprint(spec) {
32719
32800
  const total = spec.beats.length;
32720
32801
  const market = marketFor(spec);
@@ -32739,7 +32820,10 @@ function adSpecToBlueprint(spec) {
32739
32820
  // `on_camera` is the engine's own switch for "this line is narration" —
32740
32821
  // absent, it guesses from who is in frame, which is how a still photograph
32741
32822
  // once got lip-synced.
32742
- dialogue: [
32823
+ // No dialogue at all when nobody speaks: an empty array is what stops the engine
32824
+ // wiring a voice track, and no voice track is what makes this a music-led ad
32825
+ // rather than one with a silent narrator.
32826
+ dialogue: spec.voiceover === false ? [] : [
32743
32827
  {
32744
32828
  line: beat.say,
32745
32829
  start_s: Math.round(start * 100) / 100,
@@ -32751,11 +32835,7 @@ function adSpecToBlueprint(spec) {
32751
32835
  ...spec.voice?.description ? { voice_description: spec.voice.description } : {}
32752
32836
  }
32753
32837
  ],
32754
- motion_prompt: isClosingCard ? BRAND_PLATE : beat.on_camera ? `${beat.show}${place}${physics}` : (
32755
- // The subject must not be forming words: the voice belongs to the
32756
- // narration, and a mouth moving under it reads as dubbing.
32757
- `${beat.show}${place}${physics} Nobody in frame is speaking \u2014 mouths closed, no dialogue.`
32758
- ),
32838
+ motion_prompt: motionPrompt(beat, isClosingCard, place, physics),
32759
32839
  start_frame_prompt: isClosingCard ? BRAND_PLATE : `${beat.show}${place}${physics}`
32760
32840
  };
32761
32841
  });
@@ -32765,8 +32845,9 @@ function adSpecToBlueprint(spec) {
32765
32845
  transcript: spec.beats.map((b) => b.say).join(" "),
32766
32846
  voiceover: {
32767
32847
  // Narration over the picture. Never `on_camera`: that is what tells the
32768
- // engine a mouth is a lip-sync candidate.
32769
- mode: "voiceover",
32848
+ // engine a mouth is a lip-sync candidate. A music-led ad says `none`, which is
32849
+ // what keeps a voice out of it entirely.
32850
+ mode: spec.voiceover === false ? "none" : "voiceover",
32770
32851
  ...spec.voice?.description ? { voice_description: spec.voice.description } : {}
32771
32852
  },
32772
32853
  ...spec.brand?.name || spec.brand?.logo ? {
@@ -32980,7 +33061,7 @@ registerSchema({
32980
33061
  spec: {
32981
33062
  type: "string",
32982
33063
  required: true,
32983
- description: 'Path to the ad spec JSON. Shape: { format?, market?, brand?, cast?, end_card?, voice?, music?, beats: [{ say, show, on_camera?, cast? }] }. Fill `brand` from src/brand/BRAND.md \u2014 `palette` (its hex tokens, most important first) and `logo` (the repo path to the mark) are what make the ad look like the client rather than like stock. `market` is where the ad is SET; omitted, it is inferred from the voice language, and getting it wrong is what fills a Spanish ad with British houses. `say` is ONE clause ending in its own punctuation \u2014 it becomes a caption card verbatim, so two sentences in one beat produce a card holding both. `show` is the shot brief for that line. Set `on_camera` ONLY when that beat\'s subject talks to camera. `cast` is WHO the ad is about, and an ad with people in it needs one: `{ "avatar": "marta" }` names a cast avatar (`baker avatars list`) and every beat is grounded on that avatar\'s identity sheet with its subject description copied verbatim \u2014 the same face here as in the rest of the company\'s work. Without it each beat invents its own stranger, which is how one 28-second ad came back with five different men playing one customer. `{ "description": "..." }` is the fallback when there is no avatar, and it scaffolds a canvas that asks you to drop a photo before it can run \u2014 so prefer the avatar. A beat sets `cast: false` for a shot they are not in. `end_card` is on by default whenever `brand` is set: the last beat becomes a flat brand plate with the mark and a call to action drawn over it. `{ "cta": "..." }` sets the button copy, `false` keeps the footage.'
33064
+ description: 'Path to the ad spec JSON. Shape: { format?, market?, brand?, cast?, end_card?, voice?, music?, beats: [{ say, show, on_camera?, cast? }] }. Fill `brand` from src/brand/BRAND.md \u2014 `palette` (its hex tokens, most important first) and `logo` (the repo path to the mark) are what make the ad look like the client rather than like stock. `market` is where the ad is SET; omitted, it is inferred from the voice language, and getting it wrong is what fills a Spanish ad with British houses. `say` is ONE clause ending in its own punctuation \u2014 it becomes a caption card verbatim, so two sentences in one beat produce a card holding both. `show` is the shot brief for that line. Set `on_camera` ONLY when that beat\'s subject talks to camera. `voiceover: false` makes it a MUSIC-LED ad: nobody speaks, the `say` lines become on-screen text captioned straight from the script, and it needs `music`. `cast` is WHO the ad is about, and an ad with people in it needs one: `{ "avatar": "marta" }` names a cast avatar (`baker avatars list`) and every beat is grounded on that avatar\'s identity sheet with its subject description copied verbatim \u2014 the same face here as in the rest of the company\'s work. Without it each beat invents its own stranger, which is how one 28-second ad came back with five different men playing one customer. `{ "description": "..." }` is the fallback when there is no avatar, and it scaffolds a canvas that asks you to drop a photo before it can run \u2014 so prefer the avatar. A beat sets `cast: false` for a shot they are not in. `end_card` is on by default whenever `brand` is set: the last beat becomes a flat brand plate with the mark and a call to action drawn over it. `{ "cta": "..." }` sets the button copy, `false` keeps the footage.'
32984
33065
  },
32985
33066
  avatar: {
32986
33067
  type: "string",
@@ -33048,6 +33129,19 @@ var scaffoldAdCommand = defineCommand104({
33048
33129
  if (flagAvatar) {
33049
33130
  spec.data.cast = { ...spec.data.cast, avatar: flagAvatar };
33050
33131
  }
33132
+ const contradicts = spec.data.beats.map((beat, i) => ({ i, show: beat.show })).filter(({ show }) => briefWantsLegibleText(show));
33133
+ if (contradicts.length > 0) {
33134
+ writeJson({
33135
+ ok: false,
33136
+ error: {
33137
+ code: "VALIDATION_ERROR",
33138
+ message: `Beat ${contradicts.map(({ i }) => i + 1).join(", ")} asks the picture for a readable figure (e.g. "${contradicts[0]?.show.trim().slice(0, 90)}"). Every frame also carries "no readable text or numbers", so the two fight and the picture wins \u2014 one such brief rendered a bill reading $58,085.00 over a total of $139.00.`,
33139
+ fix: "Say what the shot LOOKS like, not what it reads: 'a bill in her hands, her face falling'. The ad's numbers belong in the `say` line, where the caption track renders them correctly."
33140
+ }
33141
+ });
33142
+ process.exit(1);
33143
+ return;
33144
+ }
33051
33145
  const handle = flagAvatar || spec.data.cast?.avatar?.trim();
33052
33146
  let avatar = null;
33053
33147
  let avatarError = null;
@@ -33092,6 +33186,11 @@ var scaffoldAdCommand = defineCommand104({
33092
33186
  await writeSceneFiles(outDir, blueprint);
33093
33187
  await writeFile9(blueprintStylePath, renderStyleProjectionFromValue(blueprint), "utf8");
33094
33188
  const logoPath = spec.data.brand?.logo?.trim();
33189
+ const transcriptPath = path24.join(outDir, "transcript.json");
33190
+ if (spec.data.voiceover === false) {
33191
+ await writeFile9(transcriptPath, `${JSON.stringify(staticTranscript(spec.data), null, 2)}
33192
+ `, "utf-8");
33193
+ }
33095
33194
  const opts = {
33096
33195
  imageModel: AD_IMAGE_MODEL,
33097
33196
  // The sheet on the SAME model as the frames it feeds. A sheet from another
@@ -33111,7 +33210,8 @@ var scaffoldAdCommand = defineCommand104({
33111
33210
  captionWordsPerGroup: 8,
33112
33211
  // Told, not guessed: the voice description is written in the ad's own language
33113
33212
  // and the engine's trait parser reads English only.
33114
- voiceLanguage: voiceLanguageFor(spec.data)
33213
+ voiceLanguage: voiceLanguageFor(spec.data),
33214
+ ...spec.data.voiceover === false ? { staticTranscriptPath: path24.relative(outDir, transcriptPath) } : {}
33115
33215
  };
33116
33216
  const canvas = scaffoldVideoCanvas(blueprint, adSpecCastElements(spec.data, avatar), opts);
33117
33217
  const renderNode3 = canvas.nodes.find((n) => n.type === "hyperframe_render");
@@ -33139,6 +33239,12 @@ var scaffoldAdCommand = defineCommand104({
33139
33239
  const html = await readFile16(indexPath, "utf-8");
33140
33240
  await writeFile9(indexPath, injectAdBrandOverlay(html, overlayHtml), "utf-8");
33141
33241
  }
33242
+ await writeFile9(
33243
+ path24.join(outDir, REBUILD_FILE),
33244
+ `${JSON.stringify({ elements: adSpecCastElements(spec.data, avatar), opts }, null, 2)}
33245
+ `,
33246
+ "utf8"
33247
+ );
33142
33248
  await writeFile9(outPath, `${JSON.stringify(canvas, null, 2)}
33143
33249
  `, "utf-8");
33144
33250
  writeJson({
@@ -33169,6 +33275,9 @@ var scaffoldAdCommand = defineCommand104({
33169
33275
  ...spec.data.cast && !avatar ? [
33170
33276
  "This cast has a description but no avatar, so the canvas asks you to drop a real source photo before it can run. `baker avatars list` \u2014 casting one and passing `cast.avatar` fills that slot for you and keeps the face consistent across every piece of work."
33171
33277
  ] : [],
33278
+ ...spec.data.voiceover === false ? [
33279
+ "Nobody speaks in this ad, so the `say` lines are ON-SCREEN text: they are captioned straight from the script, exactly as written, with no voice and no transcription in between. Give it `music` \u2014 a music-led ad with no bed is a silent one."
33280
+ ] : [],
33172
33281
  "The caption cards are your `say` lines verbatim \u2014 read them back before running; that is the copy the viewer sees.",
33173
33282
  "Every clip renders with nobody speaking unless a beat set `on_camera`. If a beat needs a presenter talking, set it there rather than describing it in `show`.",
33174
33283
  ...spec.data.brand?.palette?.length && spec.data.brand?.logo ? [] : [