@koda-sl/baker-cli 0.265.2-dev.1f1c09c80 → 0.267.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
@@ -3641,9 +3641,11 @@ because a video model garbles a wordmark every time:
3641
3641
  becomes a flat brand colour plate (rendered by ffmpeg, not generated), with the mark and
3642
3642
  the call to action over it. `end_card: false` keeps the footage; `end_card: { cta }` sets
3643
3643
  the words on the button, which otherwise default to the last line of the script.
3644
- - **`cast`** — one description of the person the ad is about. The engine builds a reference
3645
- sheet from it and grounds every beat they appear in on that one identity. A beat sets
3646
- `cast: false` to opt out (a fitter on a roof, a product close-up).
3644
+ - **`cast.avatar`** — the handle of a cast Avatar (`baker avatars list`). The ad grounds
3645
+ every beat they appear in on that avatar's identity sheet and copies its subject
3646
+ description verbatim, so the ad shows the same face as the rest of the company's work.
3647
+ This is the whole journey: `baker avatars create` → `cast.avatar` in the spec → render.
3648
+ - **`cast.description`** — a person described in words, when there is no avatar.
3647
3649
 
3648
3650
  Each of these also removes a generation: the closing beat costs no image and no clip.
3649
3651
 
package/dist/cli.js CHANGED
@@ -27314,6 +27314,12 @@ var RecurringElement = z27.object({
27314
27314
  // pink shirt and believer in a white shirt). Each look gets its own reference
27315
27315
  // slot, but the face/identity must stay identical across them.
27316
27316
  same_as: z27.string().nullable().optional(),
27317
+ /**
27318
+ * A picture of this element that already exists — a cast Avatar's identity sheet,
27319
+ * a product photo, a real location shot. Given, the slot ingests it; absent, the
27320
+ * slot emits a TODO for somebody to drop one by hand.
27321
+ */
27322
+ source_image: z27.string().optional(),
27317
27323
  // Scenes the element appears in. Either a bare list of scene indices (both
27318
27324
  // edges) or per-{scene,edge} entries. Both forms are accepted and merged.
27319
27325
  scenes: z27.array(z27.number()).optional(),
@@ -28683,7 +28689,7 @@ function buildPhrases(blueprint, canonical2, compositeScenes, presenterPresent,
28683
28689
  flush();
28684
28690
  return phrases;
28685
28691
  }
28686
- function makeVoiceFactory(blueprint, canonical2, nodes) {
28692
+ function makeVoiceFactory(blueprint, canonical2, nodes, voiceLanguage) {
28687
28693
  const bySpeaker = /* @__PURE__ */ new Map();
28688
28694
  const describe = (speaker) => {
28689
28695
  for (const scene of blueprint.scenes)
@@ -28697,7 +28703,9 @@ function makeVoiceFactory(blueprint, canonical2, nodes) {
28697
28703
  if (existing) return existing;
28698
28704
  const id = sanitizeId(`voice_${speaker}`, `voice_${bySpeaker.size}`);
28699
28705
  const description = describe(speaker);
28700
- nodes.push({ id, type: "voice_select", params: { description, ...parseVoiceTraits(description) } });
28706
+ const traits = parseVoiceTraits(description);
28707
+ if (voiceLanguage) traits.language = voiceLanguage;
28708
+ nodes.push({ id, type: "voice_select", params: { description, ...traits } });
28701
28709
  bySpeaker.set(speaker, id);
28702
28710
  return id;
28703
28711
  };
@@ -29098,7 +29106,7 @@ function buildTimeline(blueprint, slots, opts, nodes) {
29098
29106
  });
29099
29107
  }
29100
29108
  const canonical2 = collapseVoiceover(blueprint);
29101
- const ensureVoiceNode = makeVoiceFactory(blueprint, canonical2, nodes);
29109
+ const ensureVoiceNode = makeVoiceFactory(blueprint, canonical2, nodes, opts.voiceLanguage);
29102
29110
  const aspect = resolveAspect(blueprint.source?.aspect_ratio, opts.aspect, genAspectsFor(opts.videoModel));
29103
29111
  const env = {
29104
29112
  blueprint,
@@ -29596,10 +29604,16 @@ function scaffoldVideoCanvas(input, elementsInput, opts) {
29596
29604
  const slots = buildElementSlots(elements);
29597
29605
  extendPresenceByPromptMentions(slots, blueprint);
29598
29606
  slots.forEach((slot, i) => {
29607
+ const element = elements[i];
29608
+ const supplied = typeof element?.source_image === "string" ? element.source_image.trim() : "";
29599
29609
  nodes.push({
29600
29610
  id: slot.id,
29601
29611
  type: "ingest",
29602
- params: { source: "path", path: todoPath(elements[i], slot.label), expect: "image" }
29612
+ params: {
29613
+ source: "path",
29614
+ path: supplied || todoPath(element, slot.label),
29615
+ expect: "image"
29616
+ }
29603
29617
  });
29604
29618
  });
29605
29619
  buildElementSheets(slots, nodes);
@@ -30841,7 +30855,10 @@ var ReelReview = z28.object({
30841
30855
  brand_mark_visible: z28.boolean().default(false),
30842
30856
  /** Whether the last frames close the ad rather than trailing off. */
30843
30857
  ends_on_a_close: z28.boolean().default(false),
30844
- ending_note: z28.string().default("")
30858
+ ending_note: z28.string().default(""),
30859
+ /** Whether the ad ever cuts away from the presenter to show anything. */
30860
+ shows_more_than_a_face: z28.boolean().default(true),
30861
+ shows_note: z28.string().default("")
30845
30862
  });
30846
30863
  function buildFrameReviewPrompt(opts) {
30847
30864
  const subject = opts.subject ? ` The advertiser is ${opts.subject}.` : "";
@@ -30869,9 +30886,10 @@ Answer only about what is visible.
30869
30886
  1. CAST. Do the people stay the same people? If the script speaks about one customer and the frames show different faces in that role, say so and count them.
30870
30887
  2. BRAND. Is the advertiser's logo or wordmark visible in ANY frame? Answer yes only if you can actually see a mark \u2014 a colour is not a mark.
30871
30888
  3. ENDING. Do the final frames close the advertisement \u2014 a call to action, a mark, a card \u2014 or does the picture simply carry on and stop?
30889
+ 4. WHAT IT SHOWS. Does the advertisement ever cut away from the person to show the thing it is selling, or its result? Answer false if every frame is the same person with only the framing changed, and nothing is ever shown.
30872
30890
 
30873
30891
  Reply with only this JSON:
30874
- {"cast_is_consistent":true,"cast_note":"","brand_mark_visible":false,"ends_on_a_close":false,"ending_note":""}`;
30892
+ {"cast_is_consistent":true,"cast_note":"","brand_mark_visible":false,"ends_on_a_close":false,"ending_note":"","shows_more_than_a_face":true,"shows_note":""}`;
30875
30893
  }
30876
30894
  function parseReviewJson(raw) {
30877
30895
  const fenced = raw.match(/```(?:json)?\s*([\s\S]*?)```/);
@@ -30937,6 +30955,12 @@ function classifyReelFindings(json) {
30937
30955
  what: "No brand mark is visible in any frame. `baker canvas scaffold-ad` draws one from `brand.logo` (the repo path in src/brand/BRAND.md) \u2014 top-left throughout and large on the closing card \u2014 so a mark missing here means the spec carried no logo, or the path did not resolve and the scaffold said so."
30938
30956
  });
30939
30957
  }
30958
+ if (!r.shows_more_than_a_face) {
30959
+ findings.push({
30960
+ severity: "blocking",
30961
+ what: `The ad never shows what it is selling${r.shows_note.trim() ? `: ${r.shows_note.trim()}` : ""} \u2014 every frame is the same person with only the framing changed. Give the middle beats their own picture (the bill, the roof, the install, the house) and set \`cast: false\` on them.`
30962
+ });
30963
+ }
30940
30964
  if (!r.ends_on_a_close) {
30941
30965
  findings.push({
30942
30966
  severity: "warning",
@@ -32483,8 +32507,20 @@ var AdSpec = z30.object({
32483
32507
  * collage rather than a story.
32484
32508
  */
32485
32509
  cast: z30.object({
32510
+ /**
32511
+ * A cast Avatar's handle — `baker avatars list` shows who there is.
32512
+ *
32513
+ * The right way to put a person in an ad. The avatar already has a settled
32514
+ * identity and a rendered identity sheet, so the ad grounds on the same face the
32515
+ * rest of the company's work uses, and its subject description is copied verbatim
32516
+ * rather than re-written per spec — re-phrasing it per render is the other reason
32517
+ * a face drifts across a set.
32518
+ */
32519
+ avatar: z30.string().min(1).optional(),
32486
32520
  /** Who they are, plainly: age, build, hair, wardrobe, the register of the ad. */
32487
- description: z30.string().min(1)
32521
+ description: z30.string().min(1).optional()
32522
+ }).refine((c) => Boolean(c.avatar || c.description), {
32523
+ message: "give `avatar` (a cast avatar's handle) or `description` \u2014 a cast with neither grounds on nothing"
32488
32524
  }).optional(),
32489
32525
  /**
32490
32526
  * A bed, described in words. Omitted leaves the ad dry — the engine only wires
@@ -32516,6 +32552,11 @@ var AdSpec = z30.object({
32516
32552
  ]).optional(),
32517
32553
  beats: z30.array(Beat).min(2)
32518
32554
  });
32555
+ function presenterIsInEveryShot(spec) {
32556
+ if (!spec.cast) return false;
32557
+ const storyBeats = spec.beats.filter((_, i) => !(endCardWanted(spec) && i === spec.beats.length - 1));
32558
+ return storyBeats.length >= 3 && storyBeats.every((b) => b.cast !== false);
32559
+ }
32519
32560
  function narrativeRole(index, total) {
32520
32561
  if (index === 0) return "hook";
32521
32562
  if (index === total - 1) return "cta";
@@ -32544,6 +32585,29 @@ var MARKET_BY_LANGUAGE = {
32544
32585
  en: "the United Kingdom",
32545
32586
  "en-us": "the United States"
32546
32587
  };
32588
+ var VOICE_LANGUAGE_NAME = {
32589
+ es: "spanish",
32590
+ "es-es": "spanish",
32591
+ "es-mx": "spanish",
32592
+ pt: "portuguese",
32593
+ "pt-br": "portuguese",
32594
+ fr: "french",
32595
+ it: "italian",
32596
+ de: "german",
32597
+ nl: "dutch",
32598
+ pl: "polish",
32599
+ en: "english",
32600
+ "en-us": "english",
32601
+ "en-gb": "english"
32602
+ };
32603
+ function voiceLanguageFor(spec) {
32604
+ const declared = spec.voice?.language?.trim().toLowerCase();
32605
+ if (declared) return VOICE_LANGUAGE_NAME[declared];
32606
+ const market = spec.market?.trim().toLowerCase();
32607
+ if (!market) return void 0;
32608
+ const byMarket = Object.entries(MARKET_BY_LANGUAGE).find(([, place]) => place.toLowerCase() === market);
32609
+ return byMarket ? VOICE_LANGUAGE_NAME[byMarket[0]] : void 0;
32610
+ }
32547
32611
  function marketFor(spec) {
32548
32612
  if (spec.market) return spec.market;
32549
32613
  const lang = spec.voice?.language?.trim().toLowerCase();
@@ -32565,13 +32629,14 @@ function endCardCta(spec) {
32565
32629
  const declared = spec.end_card === false ? void 0 : spec.end_card?.cta;
32566
32630
  return declared?.trim() || (spec.beats[spec.beats.length - 1]?.say ?? "").trim() || null;
32567
32631
  }
32568
- function adSpecCastElements(spec) {
32569
- const description = spec.cast?.description?.trim();
32632
+ function adSpecCastElements(spec, avatar) {
32633
+ const description = (avatar?.subjectDescription ?? spec.cast?.description)?.trim();
32570
32634
  if (!description) return [];
32571
32635
  const lastIndex = spec.beats.length - 1;
32572
32636
  const scenes = spec.beats.map((beat, i) => ({ beat, i })).filter(({ beat, i }) => beat.cast !== false && !(endCardWanted(spec) && i === lastIndex)).map(({ i }) => i);
32573
32637
  if (scenes.length === 0) return [];
32574
- return [{ type: "person", label: "customer", description, scenes }];
32638
+ const sheet = avatar?.sheetUrl?.trim();
32639
+ return [{ type: "person", label: "customer", description, scenes, ...sheet ? { source_image: sheet } : {} }];
32575
32640
  }
32576
32641
  function adSpecToBlueprint(spec) {
32577
32642
  const total = spec.beats.length;
@@ -32602,7 +32667,10 @@ function adSpecToBlueprint(spec) {
32602
32667
  line: beat.say,
32603
32668
  start_s: Math.round(start * 100) / 100,
32604
32669
  end_s: Math.round(clock * 100) / 100,
32605
- on_camera: beat.on_camera === true,
32670
+ // Never on the closing plate: it is a flat colour card with no one in it, and
32671
+ // saying someone is on camera there told the engine to look for a mouth to
32672
+ // lip-sync on a picture that has no person.
32673
+ on_camera: beat.on_camera === true && !isClosingCard,
32606
32674
  ...spec.voice?.description ? { voice_description: spec.voice.description } : {}
32607
32675
  }
32608
32676
  ],
@@ -32711,12 +32779,18 @@ function adBrandOverlayCss() {
32711
32779
  " /* Positioned by this block alone. The composition that actually renders an ad",
32712
32780
  " is the caption track, which has no .pos-* helpers \u2014 relying on them put the",
32713
32781
  " brand layer in a file nothing opens and shipped an empty green end plate. */",
32714
- " .brand-mark { position: absolute; top: 90px; left: 56px; width: 220px; height: auto;",
32782
+ " /* Height-led, not width-led. Pinned to 220px WIDE, a wordmark whose SVG",
32783
+ " viewBox is wider than it is tall rendered clipped \u2014 the first real ad drew",
32784
+ " `greenlea`. Constraining the height and letting the width follow keeps any",
32785
+ " aspect ratio whole, and the max-width stops a very wide mark spanning the frame. */",
32786
+ " .brand-mark { position: absolute; top: 90px; left: 56px; height: 64px; width: auto;",
32787
+ " max-width: 420px; object-fit: contain;",
32715
32788
  " opacity: 0.92; filter: drop-shadow(0 2px 8px rgba(0,0,0,0.45)); }",
32716
32789
  " .endcard { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);",
32717
32790
  " display: flex; flex-direction: column; align-items: center; gap: 56px;",
32718
32791
  " width: 100%; max-width: 1080px; padding: 0 60px; text-align: center; }",
32719
- " .endcard-logo { width: 620px; max-width: 78%; height: auto; }",
32792
+ " .endcard-logo { max-width: 78%; max-height: 420px; width: auto; height: auto;",
32793
+ " object-fit: contain; }",
32720
32794
  " /* Capped and wrapping, not sized to its content: measured in a real browser,",
32721
32795
  " `GET YOUR FREE QUOTE` renders 948px wide against a 1080px canvas, so the next",
32722
32796
  " slightly longer call to action runs off the frame. Two lines beat an edge. */",
@@ -32766,7 +32840,7 @@ registerSchema({
32766
32840
  spec: {
32767
32841
  type: "string",
32768
32842
  required: true,
32769
- description: "Path to the ad spec JSON. Shape: { format?, market?, brand?, voice?, music?, beats: [{ say, show, on_camera? }] }. 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."
32843
+ 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.'
32770
32844
  },
32771
32845
  slug: {
32772
32846
  type: "string",
@@ -32783,7 +32857,7 @@ registerSchema({
32783
32857
  var scaffoldAdCommand = defineCommand104({
32784
32858
  meta: {
32785
32859
  name: "scaffold-ad",
32786
- description: 'Build a captioned vertical ad from a written spec, with no reference video to copy.\n\nA spec is a list of BEATS. One beat = one line the voice says + one shot on screen while it is said. That pairing is what keeps the ad honest: the caption card IS the line, so it can never end mid-sentence, and the picture can never drift from the read.\n\nTwo rules the spec enforces for you, both learned the expensive way:\n \u2022 `say` is ONE clause with its own punctuation. Two sentences in a beat is rejected.\n \u2022 Nobody in shot is speaking unless you set `on_camera` on that beat. A person visibly talking under a voice that is not theirs reads as bad dubbing.\n\nExample spec:\n { "beats": [\n { "say": "Mientras t\xFA sigues pagando la luz a precio de oro,", "show": "Aerial over Spanish rooftops at golden hour." },\n { "say": "Haz clic en el enlace y reserva tu estudio gratuito.", "show": "A couple walking home at sunset." } ] }\n\nThen: baker canvas run <the canvas it writes>'
32860
+ description: 'Build a captioned vertical ad from a written spec, with no reference video to copy.\n\nA spec is a list of BEATS. One beat = one line the voice says + one shot on screen while it is said. That pairing is what keeps the ad honest: the caption card IS the line, so it can never end mid-sentence, and the picture can never drift from the read.\n\nTwo rules the spec enforces for you, both learned the expensive way:\n \u2022 `say` is ONE clause with its own punctuation. Two sentences in a beat is rejected.\n \u2022 Nobody in shot is speaking unless you set `on_camera` on that beat. A person visibly talking under a voice that is not theirs reads as bad dubbing.\n\nExample spec:\n { "beats": [\n { "say": "Mientras t\xFA sigues pagando la luz a precio de oro,", "show": "Aerial over Spanish rooftops at golden hour." },\n { "say": "Haz clic en el enlace y reserva tu estudio gratuito.", "show": "A couple walking home at sunset." } ] }\n\nWho is in it:\n `cast: { avatar: "marta" }` grounds every beat on that avatar\'s face \u2014 cast one with `baker avatars`.\n Without a cast, each beat invents its own person and the ad reads as a stock-footage collage.\n\nHow it ends: with a `brand`, the last beat closes on a flat brand plate carrying the mark and the CTA.\n\nThen: baker canvas run <the canvas it writes>'
32787
32861
  },
32788
32862
  args: {
32789
32863
  spec: { type: "string", required: true, description: "Path to the ad spec JSON" },
@@ -32824,6 +32898,26 @@ var scaffoldAdCommand = defineCommand104({
32824
32898
  process.exit(1);
32825
32899
  return;
32826
32900
  }
32901
+ const handle = spec.data.cast?.avatar?.trim();
32902
+ let avatar = null;
32903
+ let avatarError = null;
32904
+ if (handle) {
32905
+ try {
32906
+ avatar = await readAvatars(
32907
+ "/api/avatars/get",
32908
+ { handle }
32909
+ );
32910
+ if (avatar?.status && avatar.status !== "ready") {
32911
+ avatarError = `Avatar \`${handle}\` is ${avatar.status}, not ready \u2014 only a ready avatar can be cast. Check \`baker avatars get ${handle}\`.`;
32912
+ avatar = null;
32913
+ } else if (!avatar?.sheetUrl) {
32914
+ avatarError = `Avatar \`${handle}\` has no identity sheet yet, so the ad cannot ground on their face.`;
32915
+ avatar = null;
32916
+ }
32917
+ } catch (e) {
32918
+ avatarError = `Could not read avatar \`${handle}\`: ${e instanceof Error ? e.message : String(e)}. \`baker avatars list\` shows who there is.`;
32919
+ }
32920
+ }
32827
32921
  const blueprint = adSpecToBlueprint(spec.data);
32828
32922
  const slug = args.slug ?? path23.basename(specPath).replace(/\.[^.]+$/, "");
32829
32923
  const outPath = args.out ?? (args.slug ? path23.join("src/creatives", slug, `${slug}.canvas.json`) : path23.join(path23.dirname(specPath), `${slug}.canvas.json`));
@@ -32851,9 +32945,12 @@ var scaffoldAdCommand = defineCommand104({
32851
32945
  // The composition's default of 3 is TikTok karaoke styling; applied to an ad
32852
32946
  // it overrode the clause rule on every line and rendered "IS STANDARD. WE".
32853
32947
  // 8 is the composition's own maximum and clears every beat measured here.
32854
- captionWordsPerGroup: 8
32948
+ captionWordsPerGroup: 8,
32949
+ // Told, not guessed: the voice description is written in the ad's own language
32950
+ // and the engine's trait parser reads English only.
32951
+ voiceLanguage: voiceLanguageFor(spec.data)
32855
32952
  };
32856
- const canvas = scaffoldVideoCanvas(blueprint, adSpecCastElements(spec.data), opts);
32953
+ const canvas = scaffoldVideoCanvas(blueprint, adSpecCastElements(spec.data, avatar), opts);
32857
32954
  const renderNode3 = canvas.nodes.find((n) => n.type === "hyperframe_render");
32858
32955
  const renderedDir = renderNode3 ? path23.join(outDir, String(renderNode3.params?.composition ?? "")) : null;
32859
32956
  let staged = null;
@@ -32890,6 +32987,19 @@ var scaffoldAdCommand = defineCommand104({
32890
32987
  ...logoPath && !staged ? [`Could not read the logo at \`${logoPath}\` \u2014 the ad has no mark on screen. Give the path as it appears in src/brand/BRAND.md, relative to the workspace root.`] : [],
32891
32988
  ...staged ? ["The brand mark is drawn by the composition \u2014 top-left throughout, and large on the closing card. It is never generated as a picture, so it cannot come back garbled."] : [],
32892
32989
  ...endCardWanted(spec.data) ? ["The last beat closes on a flat brand plate with the call to action over it, instead of one more photograph."] : [],
32990
+ ...avatarError ? [avatarError] : [],
32991
+ ...avatar ? [
32992
+ `Every beat the customer appears in is grounded on \`${handle}\`'s identity sheet, and their subject description was copied into the frames verbatim \u2014 so it is the same face throughout and the same face as the rest of this company's work.`
32993
+ ] : [],
32994
+ ...presenterIsInEveryShot(spec.data) ? [
32995
+ "The presenter is in every beat, so this ad has no cutaway \u2014 the viewer never sees the bill, the roof, the install or the house, only a face. A rendered ad built this way came back as three near-identical shots of one person smiling. Set `cast: false` on the beats that should show the thing itself."
32996
+ ] : [],
32997
+ ...!spec.data.cast ? [
32998
+ 'This ad declares no `cast`, so every beat renders whoever the model invents \u2014 a different face per shot. If a person appears more than once, run `baker avatars list`, cast one, and re-run with `cast: { avatar: "<handle>" }`.'
32999
+ ] : [],
33000
+ ...spec.data.cast && !avatar ? [
33001
+ "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."
33002
+ ] : [],
32893
33003
  "The caption cards are your `say` lines verbatim \u2014 read them back before running; that is the copy the viewer sees.",
32894
33004
  "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`.",
32895
33005
  ...spec.data.brand?.palette?.length && spec.data.brand?.logo ? [] : [