@koda-sl/baker-cli 0.123.0-dev.4a85b9f30 → 0.123.0-dev.8e4328629

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
@@ -857,7 +857,6 @@ var LINKEDIN_LIMITS = {
857
857
  choiceOptionsMax: 30,
858
858
  choiceOptionTextMax: 100,
859
859
  thankYouMessageMax: 300,
860
- privacyPolicyTextMax: 2e3,
861
860
  legalDisclaimerMax: 2e3,
862
861
  consentsMax: 5,
863
862
  // Campaign Manager caps disclosure checkboxes at 5
@@ -1452,7 +1451,6 @@ var leadFormFields = {
1452
1451
  /** Form language, e.g. { country: "US", language: "en" }. Defaults to the account locale on LinkedIn. */
1453
1452
  locale: z2.object({ country: z2.string().length(2), language: z2.string().length(2) }).optional(),
1454
1453
  privacyPolicyUrl: httpsUrlSchema,
1455
- privacyPolicyText: z2.string().max(LEAD.privacyPolicyTextMax).optional(),
1456
1454
  questions: z2.array(leadFormQuestionSchema).min(1).max(LEAD.questionsMax),
1457
1455
  consents: z2.array(leadFormConsentSchema).max(LEAD.consentsMax).optional(),
1458
1456
  hiddenFields: z2.array(leadFormHiddenFieldSchema).max(LEAD.hiddenFieldsMax).optional(),
@@ -9392,7 +9390,7 @@ var leadFormsCreateCommand = defineCommand38({
9392
9390
  Required: name, headline (\u226460), privacyPolicyUrl, questions[] (\u226412; playbook: \u22644 for completion).
9393
9391
  Each question is a predefined profile field ({ name, predefinedField: "EMAIL" }) or a custom question ({ name, questionType: "MULTIPLE_CHOICE", options: [...] }; \u22643 custom).
9394
9392
  Best-practice fields the preview will nudge for if missing: 1-3 qualifying questions, consents[] (disclosure checkboxes), thankYou.message + thankYou.landingUrl|appointmentUrl.
9395
- Also supported: locale, formImageId|formImageUrn, privacyPolicyText, hiddenFields[], legalDisclaimer, thankYou.cta. Example: baker ads linkedin lead-forms create --file form.json`
9393
+ Also supported: locale, formImageId|formImageUrn, hiddenFields[], legalDisclaimer, thankYou.cta. Example: baker ads linkedin lead-forms create --file form.json`
9396
9394
  },
9397
9395
  args: {
9398
9396
  ...accountArgs,
@@ -14441,23 +14439,28 @@ function isResolvableRelative(value) {
14441
14439
  return typeof value === "string" && value.length > 0 && !value.includes("[TODO") && !path2.isAbsolute(value);
14442
14440
  }
14443
14441
 
14444
- // src/commands/canvas/run-record.ts
14445
- import path3 from "path";
14446
- var MAX_RUN_NODES = 200;
14447
- var MAX_OUTPUTS_PER_NODE = 10;
14448
- var MAX_FINAL_OUTPUTS = 10;
14442
+ // src/commands/canvas/node-preview.ts
14449
14443
  var MAX_PARAMS_PREVIEW_LENGTH = 4e3;
14450
- function paramsPreviewFromParams(params) {
14451
- if (params === void 0 || params === null) return void 0;
14452
- const text = humanParamText(params, "prompt") ?? humanParamText(params, "source") ?? compactJson(params);
14453
- if (!text) return void 0;
14454
- return text.length > MAX_PARAMS_PREVIEW_LENGTH ? `${text.slice(0, MAX_PARAMS_PREVIEW_LENGTH - 1)}\u2026` : text;
14455
- }
14456
- function humanParamText(params, key) {
14444
+ var SPEC_KEYS = [
14445
+ "model",
14446
+ "aspect_ratio",
14447
+ "image_size",
14448
+ "resolution",
14449
+ "duration",
14450
+ "subject_type",
14451
+ "font_size",
14452
+ "expect",
14453
+ "source",
14454
+ "path",
14455
+ "url"
14456
+ ];
14457
+ var BODY_KEYS = ["prompt", "text", "subject_description"];
14458
+ function scalarField(params, key) {
14457
14459
  const value = params[key];
14460
+ if (typeof value === "number") return String(value);
14458
14461
  if (typeof value !== "string") return void 0;
14459
14462
  const trimmed = value.trim();
14460
- return trimmed && !trimmed.startsWith("$ref:") ? trimmed : void 0;
14463
+ return trimmed && !trimmed.startsWith(REF_PREFIX) ? trimmed : void 0;
14461
14464
  }
14462
14465
  function compactJson(params) {
14463
14466
  try {
@@ -14467,6 +14470,32 @@ function compactJson(params) {
14467
14470
  return void 0;
14468
14471
  }
14469
14472
  }
14473
+ function cap(text) {
14474
+ return text.length > MAX_PARAMS_PREVIEW_LENGTH ? `${text.slice(0, MAX_PARAMS_PREVIEW_LENGTH - 1)}\u2026` : text;
14475
+ }
14476
+ function nodeParamsPreview(params, deps) {
14477
+ const refsLine = deps && deps.length > 0 ? `refs: ${deps.join(", ")}` : void 0;
14478
+ if (!params || typeof params !== "object") return refsLine;
14479
+ const record = params;
14480
+ const spec = [];
14481
+ for (const key of SPEC_KEYS) {
14482
+ const value = scalarField(record, key);
14483
+ if (value !== void 0) spec.push(`${key}: ${value}`);
14484
+ }
14485
+ if (refsLine) spec.push(refsLine);
14486
+ const body = BODY_KEYS.map((key) => scalarField(record, key)).find(Boolean);
14487
+ const header = spec.join("\n");
14488
+ const composed = header && body ? `${header}
14489
+
14490
+ ${body}` : header || body || compactJson(record);
14491
+ return composed ? cap(composed) : void 0;
14492
+ }
14493
+
14494
+ // src/commands/canvas/run-record.ts
14495
+ import path3 from "path";
14496
+ var MAX_RUN_NODES = 200;
14497
+ var MAX_OUTPUTS_PER_NODE = 10;
14498
+ var MAX_FINAL_OUTPUTS = 10;
14470
14499
  var MAX_CREATIVE_SLUG_LENGTH = 100;
14471
14500
  function creativeSlugFromCanvasPath(filePath) {
14472
14501
  const normalized = filePath.split(path3.sep).join("/");
@@ -14585,7 +14614,7 @@ var RunProgressTracker = class {
14585
14614
  outputs: [],
14586
14615
  deps: node.deps,
14587
14616
  status: "pending",
14588
- paramsPreview: paramsPreviewFromParams(node.params)
14617
+ paramsPreview: nodeParamsPreview(node.params, node.deps)
14589
14618
  });
14590
14619
  }
14591
14620
  this.planned = true;
@@ -15014,6 +15043,12 @@ import { defineCommand as defineCommand89 } from "citty";
15014
15043
  import { z as z11 } from "zod";
15015
15044
  var GEN_ASPECT_RATIOS = /* @__PURE__ */ new Set(["1:1", "4:5", "9:16", "16:9", "4:3", "3:4", "2:3", "3:2", "21:9"]);
15016
15045
  var DEFAULT_ASPECT_RATIO = "9:16";
15046
+ var SHEET_SUBJECT_TYPE = {
15047
+ person: "person",
15048
+ animal: "character"
15049
+ };
15050
+ var ACTOR_SHEET_MODEL = "google/gemini-3-pro-image-preview";
15051
+ var ACTOR_SHEET_IMAGE_SIZE = "4K";
15017
15052
  var Blueprint = z11.object({
15018
15053
  meta: z11.object({ estimated_aspect_ratio: z11.string().optional() }).loose().optional(),
15019
15054
  text_content: z11.array(z11.object({ text: z11.string().optional() }).loose()).optional()
@@ -15085,6 +15120,11 @@ function todoPath(el, label) {
15085
15120
  const lock = t === "person" || t === "animal" ? " \u2014 REQUIRED: this is the emotional hero; ground it in a real reference (image-library / video-library / Pinterest), do not delete this slot and free-generate" : "";
15086
15121
  return `[TODO: drop a real image for ${label} (${el.type})${desc}${expr}${lock}]`;
15087
15122
  }
15123
+ function sheetSubjectDescription(el) {
15124
+ const desc = el.description ?? `the ${el.type}`;
15125
+ const expr = el.expression ? `, ${el.expression} expression` : "";
15126
+ return `${desc}${expr} \u2014 clean plate: no phone-camera UI chrome, no app interface, no watermarks, no captions or on-image text`;
15127
+ }
15088
15128
  function aspectRatio(blueprint, opts) {
15089
15129
  const requested = opts.aspectRatio ?? blueprint.meta?.estimated_aspect_ratio;
15090
15130
  return requested && GEN_ASPECT_RATIOS.has(requested) ? requested : DEFAULT_ASPECT_RATIO;
@@ -15108,6 +15148,7 @@ function scaffoldStaticAd(input, elementsInput, opts) {
15108
15148
  type: "ingest",
15109
15149
  params: opts.imageIsUrl ? { source: "url", url: opts.imagePath, expect: "image" } : { source: "path", path: opts.imagePath, expect: "image" }
15110
15150
  });
15151
+ const includeActorSheets = opts.includeActorSheets !== false;
15111
15152
  const usedIds = /* @__PURE__ */ new Set(["prompt", "original", "gen", "brandfont", "type_ref"]);
15112
15153
  const elementSlots = [];
15113
15154
  assignElementLabels(elements).forEach(({ el, label }, i) => {
@@ -15119,7 +15160,25 @@ function scaffoldStaticAd(input, elementsInput, opts) {
15119
15160
  type: "ingest",
15120
15161
  params: { source: "path", path: todoPath(el, label), expect: "image" }
15121
15162
  });
15122
- elementSlots.push({ ref: `$ref:${id}.asset`, label, type: el.type });
15163
+ let ref = `$ref:${id}.asset`;
15164
+ const subjectType = includeActorSheets ? SHEET_SUBJECT_TYPE[el.type.toLowerCase()] : void 0;
15165
+ if (subjectType) {
15166
+ const sheetId = `${id}_sheet`;
15167
+ usedIds.add(sheetId);
15168
+ nodes.push({
15169
+ id: sheetId,
15170
+ type: "image_reference_sheet",
15171
+ inputs: { references: [ref] },
15172
+ params: {
15173
+ model: ACTOR_SHEET_MODEL,
15174
+ subject_description: sheetSubjectDescription(el),
15175
+ subject_type: subjectType,
15176
+ image_size: ACTOR_SHEET_IMAGE_SIZE
15177
+ }
15178
+ });
15179
+ ref = `$ref:${sheetId}.sheet`;
15180
+ }
15181
+ elementSlots.push({ ref, label, type: el.type });
15123
15182
  });
15124
15183
  const hasFont = includeFont;
15125
15184
  if (hasFont) {
@@ -15180,6 +15239,7 @@ function scaffoldStaticAd(input, elementsInput, opts) {
15180
15239
  function staticAdReport(input, elementsInput, opts) {
15181
15240
  const blueprint = Blueprint.parse(input);
15182
15241
  const elements = MainElements.parse(elementsInput);
15242
+ const includeActorSheets = opts.includeActorSheets !== false;
15183
15243
  return {
15184
15244
  element_count: elements.length,
15185
15245
  elements: assignElementLabels(elements).map(({ el, label }) => ({
@@ -15189,6 +15249,7 @@ function staticAdReport(input, elementsInput, opts) {
15189
15249
  asset_todo: todoPath(el, label)
15190
15250
  })),
15191
15251
  includes_font: opts.includeFont !== false,
15252
+ actor_sheets: includeActorSheets ? assignElementLabels(elements).filter(({ el }) => SHEET_SUBJECT_TYPE[el.type.toLowerCase()]).map(({ label }) => label) : [],
15192
15253
  aspect_ratio: aspectRatio(blueprint, opts)
15193
15254
  };
15194
15255
  }
@@ -15295,7 +15356,12 @@ function canvasToDefinitionGraph(canvas) {
15295
15356
  };
15296
15357
  walkStrings(inputs, collect);
15297
15358
  walkStrings(params, collect);
15298
- return deps.size > 0 ? { id, type, deps: [...deps] } : { id, type };
15359
+ const depList = [...deps];
15360
+ const paramsPreview = nodeParamsPreview(params, depList);
15361
+ const base = { id, type };
15362
+ if (depList.length > 0) base.deps = depList;
15363
+ if (paramsPreview) base.paramsPreview = paramsPreview;
15364
+ return base;
15299
15365
  });
15300
15366
  const rawOutput = canvas?.output;
15301
15367
  const outNode = rawOutput?.node;
@@ -15366,7 +15432,7 @@ function resolveModel(kind, preferred) {
15366
15432
  const ids = Object.keys(MODEL_REGISTRY[kind]);
15367
15433
  return ids.includes(preferred) ? preferred : ids[0] ?? preferred;
15368
15434
  }
15369
- var DESCRIBE_FOCUS = `the GLOBAL LAYOUT GEOMETRY above all \u2014 the column/row grid, each region's approximate bounds as a percentage of frame width and height, any panel SPLITS and their proportions (e.g. a left column split into a ~60% top photo and a ~20% bottom price box), what occupies every region, and for EVERY text block its string verbatim, relative size (x_large | large | medium | small), weight, case, color, and alignment \u2014 including small bottom-corner fine print. Also: the EXPRESSION and emotion of every person/animal (capture exaggerated or AI-edited faces), the ad_intent (what feeling it engineers and how), per-color brand_ownership (which colors are the advertiser's brand vs borrowed-functional like a red comparison column), logos by brand, and all visible text verbatim. CRITICAL \u2014 name the WINNING MECHANISM(S): the special sauce, the specific thing that makes THIS ad a candidate winner rather than a generic ad. A great ad is rarely great by accident, and the mechanism is invisible unless named, so a faithful rewrite quietly discards it. It may be VERBAL (a rhyme, pun, alliteration, rhythm/meter, repetition, antithesis, double meaning), VISUAL (an unexpected crop or scale, a visual gag or pun, a striking juxtaposition, a pattern interrupt, an exaggerated/AI-edited expression, a before/after or comparison, bold negative space, a surprising focal point), or STRUCTURAL (an unusual hook, order, or reveal). Record each as a \`winning_mechanisms\` array of { kind: verbal|visual|structural, device, why_it_works } (e.g. { kind: verbal, device: "rhyme \u2014 'today' / 'go away'", why_it_works: "memorable, playful, reframes cost as relief" }) so a rewrite can rebuild the SAUCE for our brand instead of adapting only the surface and losing what made it convert`;
15435
+ var DESCRIBE_FOCUS = "the GLOBAL LAYOUT GEOMETRY above all \u2014 the column/row grid, each region's approximate bounds as a percentage of frame width and height, any panel SPLITS and their proportions (e.g. a left column split into a ~60% top photo and a ~20% bottom price box), what occupies every region, and for EVERY text block its string verbatim, relative size (x_large | large | medium | small), weight, case, color, and alignment \u2014 including small bottom-corner fine print. Also: the EXPRESSION and emotion of every person/animal (capture exaggerated or AI-edited faces), the ad_intent (what feeling it engineers and how), per-color brand_ownership (which colors are the advertiser's brand vs borrowed-functional like a red comparison column), logos by brand, and all visible text verbatim. the TYPOGRAPHY as a first-class identity signal \u2014 for EVERY distinct typeface in the ad record a `fonts` entry: which text blocks use it, its classification (serif | slab-serif | sans | script | display | mono), a best-guess family or a close lookalike (e.g. 'a Tiempos-like high-contrast serif', 'Circular-like geometric sans'), and its weight/case/styling \u2014 because a faithful rewrite that keeps the words but loses the letterforms loses the brand; ALSO record whether the advertiser's LOGO appears in more than one lockup (a square/icon MARK and a horizontal WORDMARK are two different assets \u2014 capture BOTH). CRITICAL \u2014 name the WINNING MECHANISM(S): the special sauce, the specific thing that makes THIS ad a candidate winner rather than a generic ad. A great ad is rarely great by accident, and the mechanism is invisible unless named, so a faithful rewrite quietly discards it. It may be VERBAL (a rhyme, pun, alliteration, rhythm/meter, repetition, antithesis, double meaning), VISUAL (an unexpected crop or scale, a visual gag or pun, a striking juxtaposition, a pattern interrupt, an exaggerated/AI-edited expression, a before/after or comparison, bold negative space, a surprising focal point), or STRUCTURAL (an unusual hook, order, or reveal). Record each as a `winning_mechanisms` array of { kind: verbal|visual|structural, device, why_it_works } (e.g. { kind: verbal, device: \"rhyme \u2014 'today' / 'go away'\", why_it_works: \"memorable, playful, reframes cost as relief\" }) so a rewrite can rebuild the SAUCE for our brand instead of adapting only the surface and losing what made it convert";
15370
15436
  var LAYOUT_SYSTEM = "You convert an advertisement's JSON blueprint into a precise, structured LAYOUT MAP of the frame \u2014 the spatial grid a designer would rebuild it from. Be exhaustive and quantitative: every region, its bounds as a percentage of the frame, and every text block with its relative size. Output ONLY a JSON object, no prose.";
15371
15437
  var LAYOUT_PROMPT = `AD BLUEPRINT (from image_describe):
15372
15438
  {{blueprint}}
@@ -15392,7 +15458,7 @@ var SELECT_PROMPT = `AD BLUEPRINT (from image_describe):
15392
15458
  {{blueprint}}
15393
15459
 
15394
15460
  From this blueprint, list ONLY the elements that are prominent, important, and identity-bearing \u2014 the ones a reproduction must ground in a real asset:
15395
- - the brand logo/wordmark (from brands_logos with function_in_image = advertiser_brand) -> type "logo"
15461
+ - the brand logo/wordmark (from brands_logos with function_in_image = advertiser_brand) -> type "logo". A brand often ships its logo in TWO lockups \u2014 a square/icon MARK and a horizontal WORDMARK \u2014 and an ad may use both (e.g. an icon in a product mock plus the wordmark in the sign-off). When more than one distinct lockup of the SAME advertiser appears, emit a SEPARATE logo element for each (label them by lockup, e.g. LOGO_MARK and LOGO_WORDMARK) so the reproduction can drop the right file in each slot instead of stretching one logo to cover both.
15396
15462
  - trust/rating/certification/app-store/review badges (brands_logos with function_in_image = trust_badge | review_platform | certification_or_seal | app_store_badge | payment_method) -> type "badge"
15397
15463
  - a showcased/hero product or package (a foreground entry in subjects that the ad is selling) -> type "product"
15398
15464
  - a foreground person (from people) \u2014 keep it when its identity matters OR it is the emotional/hero focal point of the ad, even a generic one with no brand identity -> type "person"
@@ -15536,7 +15602,11 @@ var scaffoldStaticAdCommand = defineCommand89({
15536
15602
  "layout-model": { type: "string", description: "Override the text_generate model id for the layout pass" },
15537
15603
  "gen-model": { type: "string", description: "Override the image_generate model id" },
15538
15604
  aspect: { type: "string", description: "Force the output aspect ratio (else inferred from the image)" },
15539
- "skip-font": { type: "boolean", description: "Skip the brand-font \u2192 type-specimen slot" }
15605
+ "skip-font": { type: "boolean", description: "Skip the brand-font \u2192 type-specimen slot" },
15606
+ "skip-actor-sheets": {
15607
+ type: "boolean",
15608
+ description: "Ground each person/animal on its lone dropped photo instead of a generated multi-view sheet"
15609
+ }
15540
15610
  },
15541
15611
  async run({ args }) {
15542
15612
  const slug = args.slug ? String(args.slug) : void 0;
@@ -15586,7 +15656,8 @@ var scaffoldStaticAdCommand = defineCommand89({
15586
15656
  imageIsUrl: canvasImageIsUrl,
15587
15657
  blueprintPath: canvasBlueprintPath,
15588
15658
  aspectRatio: args.aspect ? String(args.aspect) : void 0,
15589
- includeFont: !args["skip-font"]
15659
+ includeFont: !args["skip-font"],
15660
+ includeActorSheets: !args["skip-actor-sheets"]
15590
15661
  };
15591
15662
  let canvas;
15592
15663
  let report;
@@ -15653,7 +15724,8 @@ var scaffoldStaticAdCommand = defineCommand89({
15653
15724
  checklist: {
15654
15725
  edit_prompt: `Edit ${path9.basename(blueprintPath)} \u2014 it is the blueprint generated from your image; rewrite it into the ad you want (palette, copy, claims, subjects). It feeds the generator directly.`,
15655
15726
  assets_to_supply: report.elements,
15656
- font_slot: report.includes_font ? "Drop a brand font at the [TODO] brandfont path, or delete the brandfont + type_ref nodes to skip it." : "skipped (--skip-font)",
15727
+ font_slot: report.includes_font ? "Drop a brand font at the [TODO] brandfont path (the describe pass recorded the ad's typefaces under `fonts` in prompt.json \u2014 match those). The font is wired into the render as a TYPE SPECIMEN reference so generated text takes the brand letterforms. Delete the brandfont + type_ref nodes to skip it." : "skipped (--skip-font)",
15728
+ actor_sheets: report.actor_sheets.length > 0 ? `Each living hero (${report.actor_sheets.join(", ")}) is fused into a generated multi-view reference sheet (image_reference_sheet) that the render grounds on \u2014 so drop ONE clean photo at that hero's ingest and the sheet builds the consistent turnaround. Pass --skip-actor-sheets to ground on the lone photo instead.` : "none (no person/animal heroes detected, or --skip-actor-sheets)",
15657
15729
  note: "Populate as you go: for each [TODO] ingest slot, source its real asset and wire it into the slot right away \u2014 one at a time, not all sourced first then reconciled at the end. When every slot is filled, `baker canvas validate` then `baker canvas run`. Running generates a billed image \u2014 it is not free."
15658
15730
  }
15659
15731
  },
@@ -16475,8 +16547,8 @@ function extendPresenceByPromptMentions(slots, blueprint) {
16475
16547
  }
16476
16548
  });
16477
16549
  }
16478
- var ACTOR_SHEET_MODEL = "google/gemini-3-pro-image-preview";
16479
- var SHEET_SUBJECT_TYPE = {
16550
+ var ACTOR_SHEET_MODEL2 = "google/gemini-3-pro-image-preview";
16551
+ var SHEET_SUBJECT_TYPE2 = {
16480
16552
  person: "person",
16481
16553
  animal: "character",
16482
16554
  product: "product",
@@ -16484,7 +16556,7 @@ var SHEET_SUBJECT_TYPE = {
16484
16556
  };
16485
16557
  function buildElementSheets(slots, nodes) {
16486
16558
  for (const slot of slots) {
16487
- const subjectType = SHEET_SUBJECT_TYPE[slot.type.toLowerCase()];
16559
+ const subjectType = SHEET_SUBJECT_TYPE2[slot.type.toLowerCase()];
16488
16560
  if (!subjectType) continue;
16489
16561
  if (slot.sameAs) continue;
16490
16562
  if (slot.presence.size < 1) continue;
@@ -16497,7 +16569,7 @@ function buildElementSheets(slots, nodes) {
16497
16569
  // The lone dropped ingest is the source; the sheet fans it into a turnaround.
16498
16570
  inputs: { references: [slot.ref] },
16499
16571
  params: {
16500
- model: ACTOR_SHEET_MODEL,
16572
+ model: ACTOR_SHEET_MODEL2,
16501
16573
  // The clean-plate clause mirrors the frame prompts' CLEAN PLATE block: a sheet
16502
16574
  // that comes back with a fake camera app baked in (P2-22) poisons EVERY frame
16503
16575
  // grounded on it, so the suppression must live on the sheet too.
@@ -17426,10 +17498,16 @@ function makePresenterPresent(slots, canonical, opts = {}) {
17426
17498
  return presence.has(sceneIndex);
17427
17499
  };
17428
17500
  }
17429
- var PAUSE_GAP_S = 0.6;
17430
17501
  var SEEDANCE_SAFE_MAX_S = SEEDANCE_DURATIONS.find((d) => d >= 10) ?? 10;
17431
17502
  var PHRASE_MAX_S = SEEDANCE_SAFE_MAX_S;
17432
- var SEEDANCE_MAX_WORDS_PER_TAKE = 16;
17503
+ var PAUSE_GAP_S = 0.6;
17504
+ function isAdjacentShownCut(ln, lastShownScene, scenes) {
17505
+ if (!ln.shown || lastShownScene === null) return false;
17506
+ return ln.sceneIndex === lastShownScene + 1 && scenes[ln.sceneIndex]?.continues_previous !== true;
17507
+ }
17508
+ function breaksPhrase(cur, ln, lineCover, lineClipStart, scenes) {
17509
+ return cur.speaker !== ln.speaker || ln.start - cur.end > PAUSE_GAP_S || isAdjacentShownCut(ln, cur.lastShownScene, scenes) || Math.max(cur.coverEnd, lineCover) - Math.min(cur.clipStart, lineClipStart) > PHRASE_MAX_S;
17510
+ }
17433
17511
  var JOIN_DEDUP_MAX_WORDS = 4;
17434
17512
  function joinKey(word) {
17435
17513
  return word.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, "");
@@ -17472,10 +17550,7 @@ function collapseVoiceover(blueprint) {
17472
17550
  const presenter = [...presenters][0];
17473
17551
  return (speaker) => NARRATOR_SPEAKERS.has(speaker.toLowerCase()) ? presenter : speaker;
17474
17552
  }
17475
- function buildPhrases(blueprint, canonical, compositeScenes, presenterPresent, presentStrict) {
17476
- const casts = castIdSet(blueprint);
17477
- const cameraOn = onCameraDialogue(blueprint);
17478
- const sceneEndS = (i) => blueprint.scenes[i]?.end_s ?? blueprint.scenes[i]?.start_s ?? 0;
17553
+ function multiSpeakerScenes(blueprint, casts, cameraOn, canonical, presentStrict) {
17479
17554
  const multiSpeaker = /* @__PURE__ */ new Set();
17480
17555
  blueprint.scenes.forEach((scene, i) => {
17481
17556
  const onCamAll = new Set(
@@ -17485,32 +17560,45 @@ function buildPhrases(blueprint, canonical, compositeScenes, presenterPresent, p
17485
17560
  const effective = onCamPresent.length > 0 ? new Set(onCamPresent) : onCamAll;
17486
17561
  if (effective.size >= 2) multiSpeaker.add(i);
17487
17562
  });
17488
- const lines = blueprint.scenes.flatMap(
17489
- (scene, sceneIndex) => compositeScenes.has(sceneIndex) ? [] : (scene.dialogue ?? []).filter((l) => Boolean(l.line?.trim())).map((l) => {
17563
+ return multiSpeaker;
17564
+ }
17565
+ function lineClipWindow(ln, scenes) {
17566
+ if (!ln.shown) return { cover: ln.end, clipStart: ln.start };
17567
+ const sc = scenes[ln.sceneIndex];
17568
+ const sceneEnd = sc?.end_s ?? sc?.start_s ?? 0;
17569
+ return { cover: Math.max(ln.end, sceneEnd), clipStart: Math.min(ln.start, sc?.start_s ?? ln.start) };
17570
+ }
17571
+ function dialogueLines(blueprint, ctx) {
17572
+ return blueprint.scenes.flatMap((scene, sceneIndex) => {
17573
+ if (ctx.compositeScenes.has(sceneIndex)) return [];
17574
+ return (scene.dialogue ?? []).filter((l) => Boolean(l.line?.trim())).map((l) => {
17490
17575
  const raw = l.speaker ?? "voiceover";
17491
- const sp = canonical(raw);
17492
17576
  const text = l.line.trim();
17493
17577
  const start = l.start_s ?? scene.start_s ?? 0;
17578
+ const shown = l.on_camera !== false && !sceneIsAllGraphic(scene) && isOnCameraSpeaker(raw, ctx.casts, ctx.cameraOn) && !ctx.multiSpeaker.has(sceneIndex) && ctx.presenterPresent(ctx.canonical(raw), sceneIndex);
17494
17579
  return {
17495
17580
  sceneIndex,
17496
- speaker: sp,
17497
- // Shown = a cast member speaking AND their element is actually on screen
17498
- // here (not a cutaway). A b-roll cutaway mid-phrase fails this and gets
17499
- // its own clip while the phrase voice plays under it. An explicit
17500
- // deconstruct voiceover stamp (`on_camera: false`) wins over element
17501
- // presence — a speaker pictured in a photo is "present" but not talking.
17502
- // An all-graphic composition (no camera region) is voiceover by
17503
- // definition: nobody is on screen to lip-sync.
17504
- shown: l.on_camera !== false && !sceneIsAllGraphic(scene) && isOnCameraSpeaker(raw, casts, cameraOn) && !multiSpeaker.has(sceneIndex) && presenterPresent(sp, sceneIndex),
17581
+ speaker: ctx.canonical(raw),
17582
+ shown,
17505
17583
  start,
17506
- // Real speech end. When the deconstruct gives no end_s, estimate it from
17507
- // the words — NOT the scene end (which would fabricate continuity across
17508
- // a long silent b-roll gap and wrongly merge two separate phrases).
17509
17584
  end: l.end_s ?? start + estSpeechS(text),
17510
17585
  text
17511
17586
  };
17512
- })
17513
- ).sort((a, b) => a.start - b.start);
17587
+ });
17588
+ }).sort((a, b) => a.start - b.start);
17589
+ }
17590
+ function buildPhrases(blueprint, canonical, compositeScenes, presenterPresent, presentStrict) {
17591
+ const casts = castIdSet(blueprint);
17592
+ const cameraOn = onCameraDialogue(blueprint);
17593
+ const multiSpeaker = multiSpeakerScenes(blueprint, casts, cameraOn, canonical, presentStrict);
17594
+ const lines = dialogueLines(blueprint, {
17595
+ compositeScenes,
17596
+ multiSpeaker,
17597
+ canonical,
17598
+ casts,
17599
+ cameraOn,
17600
+ presenterPresent
17601
+ });
17514
17602
  const phrases = [];
17515
17603
  let cur = null;
17516
17604
  const flush = () => {
@@ -17528,16 +17616,8 @@ function buildPhrases(blueprint, canonical, compositeScenes, presenterPresent, p
17528
17616
  cur = null;
17529
17617
  };
17530
17618
  for (const ln of lines) {
17531
- const lineCover = ln.shown ? Math.max(ln.end, sceneEndS(ln.sceneIndex)) : ln.end;
17532
- const lineClipStart = ln.shown ? Math.min(ln.start, blueprint.scenes[ln.sceneIndex]?.start_s ?? ln.start) : ln.start;
17533
- const breakRun = !cur || cur.speaker !== ln.speaker || ln.start - cur.end > PAUSE_GAP_S || // Cap by SCENE COVERAGE span, not line end — a presenter run whose sliced scenes span
17534
- // more than one Seedance clip splits into the next take here (at this scene's
17535
- // boundary, never mid-scene), so no segment ever reads past the generated clip.
17536
- Math.max(cur.coverEnd, lineCover) - Math.min(cur.clipStart, lineClipStart) > PHRASE_MAX_S || // Cap by SPOKEN WORDS too: a dense read that fits the time ceiling can still cram a
17537
- // monologue Seedance can't lip-sync cleanly. Break before this line pushes the run
17538
- // past the word cap (a fresh run always accepts its own first line, so one long line
17539
- // is never blocked — it just can't be sub-split without a mid-word cut).
17540
- cur.words + wordCount(ln.text) > SEEDANCE_MAX_WORDS_PER_TAKE;
17619
+ const { cover: lineCover, clipStart: lineClipStart } = lineClipWindow(ln, blueprint.scenes);
17620
+ const breakRun = !cur || breaksPhrase(cur, ln, lineCover, lineClipStart, blueprint.scenes);
17541
17621
  if (breakRun || !cur) {
17542
17622
  flush();
17543
17623
  cur = {
@@ -17548,7 +17628,7 @@ function buildPhrases(blueprint, canonical, compositeScenes, presenterPresent, p
17548
17628
  coverEnd: lineCover,
17549
17629
  clipStart: lineClipStart,
17550
17630
  texts: [ln.text],
17551
- words: wordCount(ln.text),
17631
+ lastShownScene: ln.shown ? ln.sceneIndex : null,
17552
17632
  shown: /* @__PURE__ */ new Set()
17553
17633
  };
17554
17634
  } else {
@@ -17556,7 +17636,7 @@ function buildPhrases(blueprint, canonical, compositeScenes, presenterPresent, p
17556
17636
  cur.end = Math.max(cur.end, ln.end);
17557
17637
  cur.coverEnd = Math.max(cur.coverEnd, lineCover);
17558
17638
  cur.clipStart = Math.min(cur.clipStart, lineClipStart);
17559
- cur.words += wordCount(ln.text);
17639
+ if (ln.shown) cur.lastShownScene = ln.sceneIndex;
17560
17640
  }
17561
17641
  if (ln.shown) cur.shown.add(ln.sceneIndex);
17562
17642
  }
@@ -17685,11 +17765,12 @@ function emitPhraseClip(phrase, voiceNode, env, nodes, out) {
17685
17765
  est_speech_s: Math.round(estSpeechWindowS(phrase.text, phrase.start_s, phrase.end_s) * 100) / 100,
17686
17766
  speech_words: wordCount(phrase.text)
17687
17767
  });
17688
- registerMergedPhraseSlices(phrase, clipRef, clipStart, genDur, env, out);
17768
+ registerMergedPhraseSlices(phrase, clipRef, clipStart, genDur, Boolean(chained), env, out);
17689
17769
  }
17690
- function registerMergedPhraseSlices(phrase, clipRef, clipStart, genDur, env, out) {
17770
+ function registerMergedPhraseSlices(phrase, clipRef, clipStart, genDur, chained, env, out) {
17691
17771
  const shown = [...phrase.shownScenes].sort((a, b) => a - b);
17692
17772
  let r = 0;
17773
+ let firstRegistered = true;
17693
17774
  while (r < shown.length) {
17694
17775
  const first = shown[r];
17695
17776
  let last = first;
@@ -17706,8 +17787,10 @@ function registerMergedPhraseSlices(phrase, clipRef, clipStart, genDur, env, out
17706
17787
  // the clip hits the whole-clip fast path instead of a needless re-encode + tiny shift.
17707
17788
  offset: rawOffset < 0.05 ? 0 : rawOffset,
17708
17789
  len: Math.max(0.5, runEnd - firstStart),
17709
- clipDur: genDur
17790
+ clipDur: genDur,
17791
+ ...firstRegistered && chained ? { continuesFrame: true } : {}
17710
17792
  });
17793
+ firstRegistered = false;
17711
17794
  for (let s = first + 1; s <= last; s++) {
17712
17795
  out.sceneSlice.set(s, { clipRef, offset: 0, len: 0, clipDur: genDur, skip: true });
17713
17796
  }
@@ -17865,7 +17948,8 @@ function emitBrollScene(scene, i, isLast, env, nodes, out, prevEndFrame) {
17865
17948
  emitFlashHold(i, scene, env.slots, ctx, lengths, lengths.out, env.outAr, nodes, out.clips);
17866
17949
  return void 0;
17867
17950
  }
17868
- const first = scene.continues_previous && prevEndFrame ? prevEndFrame : buildFrameRef(
17951
+ const sharesPrevFrame = Boolean(scene.continues_previous && prevEndFrame);
17952
+ const first = sharesPrevFrame && prevEndFrame ? prevEndFrame : buildFrameRef(
17869
17953
  "start",
17870
17954
  scene.start_frame_asset?.url,
17871
17955
  scene.start_frame_prompt,
@@ -17913,15 +17997,16 @@ function emitBrollScene(scene, i, isLast, env, nodes, out, prevEndFrame) {
17913
17997
  out.nativeSegments
17914
17998
  );
17915
17999
  }
17916
- out.clips.push(clip);
18000
+ out.clips.push(sharesPrevFrame ? { ...clip, continuesFrame: true } : clip);
17917
18001
  return last;
17918
18002
  }
17919
18003
  function emitPresenterSliceClip(i, slice, env, nodes, out) {
17920
18004
  if (slice.skip) return;
18005
+ const cont = slice.continuesFrame ? { continuesFrame: true } : {};
17921
18006
  const normDims = env.genAr !== env.outAr ? canvasDims(env.outAr) : void 0;
17922
18007
  const whole = slice.offset === 0 && Math.abs(slice.len - slice.clipDur) <= 0.05 && !normDims;
17923
18008
  if (whole) {
17924
- out.clips.push({ ref: slice.clipRef, scene_s: slice.len, out: null });
18009
+ out.clips.push({ ref: slice.clipRef, scene_s: slice.len, out: null, ...cont });
17925
18010
  return;
17926
18011
  }
17927
18012
  nodes.push({
@@ -17930,7 +18015,7 @@ function emitPresenterSliceClip(i, slice, env, nodes, out) {
17930
18015
  inputs: { clip: slice.clipRef },
17931
18016
  params: { args: trimArgs(slice.len, slice.offset, normDims), outputs: { video: { kind: "video", ext: "mp4" } } }
17932
18017
  });
17933
- out.clips.push({ ref: `$ref:s${i}_seg.video`, scene_s: slice.len, out: null });
18018
+ out.clips.push({ ref: `$ref:s${i}_seg.video`, scene_s: slice.len, out: null, ...cont });
17934
18019
  }
17935
18020
  function buildTimeline(blueprint, slots, opts, nodes) {
17936
18021
  const reuse = opts.frames === "reuse";
@@ -18290,25 +18375,40 @@ function lastSceneEnd(blueprint) {
18290
18375
  for (const s of blueprint.scenes) end = Math.max(end, s.end_s ?? 0);
18291
18376
  return end > 0 ? end : 8;
18292
18377
  }
18293
- function concatArgs(count) {
18378
+ function seamDropOps(clips, i, seam) {
18379
+ if (seam === "off") return null;
18380
+ if (seam === "head" && i > 0 && clips[i]?.continuesFrame) return "trim=start_frame=1,setpts=PTS-STARTPTS";
18381
+ if (seam === "tail" && clips[i + 1]?.continuesFrame) return "reverse,trim=start_frame=1,setpts=PTS-STARTPTS,reverse";
18382
+ return null;
18383
+ }
18384
+ function concatArgs(clips, seam) {
18294
18385
  const inputs = [];
18295
- let labels = "";
18296
- for (let i = 0; i < count; i++) {
18386
+ const pre = [];
18387
+ const labels = [];
18388
+ clips.forEach((_, i) => {
18297
18389
  inputs.push("-i", `{{in.c${i}}}`);
18298
- labels += `[${i}:v]`;
18299
- }
18300
- return [...inputs, "-filter_complex", `${labels}concat=n=${count}:v=1:a=0[v]`, "-map", "[v]", "{{out.video}}"];
18390
+ const ops = seamDropOps(clips, i, seam);
18391
+ if (ops) {
18392
+ pre.push(`[${i}:v]${ops}[c${i}]`);
18393
+ labels.push(`[c${i}]`);
18394
+ } else {
18395
+ labels.push(`[${i}:v]`);
18396
+ }
18397
+ });
18398
+ const graph = [...pre, `${labels.join("")}concat=n=${clips.length}:v=1:a=0[v]`].join(";");
18399
+ return [...inputs, "-filter_complex", graph, "-map", "[v]", "{{out.video}}"];
18301
18400
  }
18302
18401
  function clipInputLen(c) {
18303
18402
  return c.scene_s + (c.out?.dur ?? 0);
18304
18403
  }
18305
- function xfadeSpineArgs(clips) {
18404
+ function xfadeSpineArgs(clips, seam) {
18306
18405
  const n = clips.length;
18307
18406
  const inputs = [];
18308
18407
  const filt = [];
18309
18408
  for (let i = 0; i < n; i++) {
18310
18409
  inputs.push("-i", `{{in.c${i}}}`);
18311
- filt.push(`[${i}:v]format=yuv420p,fps=30,setsar=1,settb=AVTB[c${i}]`);
18410
+ const ops = seamDropOps(clips, i, seam);
18411
+ filt.push(`[${i}:v]format=yuv420p,fps=30,setsar=1,settb=AVTB${ops ? `,${ops}` : ""}[c${i}]`);
18312
18412
  }
18313
18413
  let cur = "c0";
18314
18414
  let accLen = clipInputLen(clips[0]);
@@ -18330,13 +18430,13 @@ function xfadeSpineArgs(clips) {
18330
18430
  }
18331
18431
  return [...inputs, "-filter_complex", filt.join(";"), "-map", "[v]", "{{out.video}}"];
18332
18432
  }
18333
- function buildSpine(clips, nodes) {
18433
+ function buildSpine(clips, seam, nodes) {
18334
18434
  const inputs = {};
18335
18435
  clips.forEach((c, i) => {
18336
18436
  inputs[`c${i}`] = c.ref;
18337
18437
  });
18338
18438
  const hasTransition = clips.length > 1 && clips.some((c) => c.out);
18339
- const args = hasTransition ? xfadeSpineArgs(clips) : concatArgs(clips.length);
18439
+ const args = hasTransition ? xfadeSpineArgs(clips, seam) : concatArgs(clips, seam);
18340
18440
  nodes.push({
18341
18441
  id: "spine",
18342
18442
  type: "ffmpeg",
@@ -18374,7 +18474,7 @@ function scaffoldVideoCanvas(input, elementsInput, opts) {
18374
18474
  });
18375
18475
  buildElementSheets(slots, nodes);
18376
18476
  const { clips, voTracks, vo_segments, talking_scenes } = buildTimeline(blueprint, slots, opts, nodes);
18377
- let videoRef = buildSpine(clips, nodes);
18477
+ let videoRef = buildSpine(clips, opts.seamDedup ?? "head", nodes);
18378
18478
  let videoNode = "spine";
18379
18479
  const overlays = blueprint.scenes.flatMap((s) => s.overlays ?? []);
18380
18480
  const floating = blueprint.scenes.flatMap((s) => s.floating_elements ?? []);
@@ -18616,7 +18716,7 @@ function buildMotionBoard(blueprint) {
18616
18716
  });
18617
18717
  }
18618
18718
  var VIDEO_GUIDE = [
18619
- "Scaffolded by `baker canvas scaffold-video` \u2014 a runnable reproduction of your reference video, built like an editing timeline. The VOICE is cut at PAUSES, not at visual cuts: each continuous-speech PHRASE is ONE Seedance clip (native lip-sync + audio) re-voiced to one brand voice, so a sentence never breaks mid-word across a cut. Each scene's PICTURE is independent: a scene that SHOWS the speaker slices its window out of the phrase clip; a b-roll cutaway gets its own silent clip (or a still hold for a sub-2s flash) laid over the continuing voice; a pure-voiceover stretch is one ElevenLabs tts read. Every clip gets a CLEAN-PLATE start AND end keyframe (no baked text), RECAST to your dropped reference assets \u2014 Seedance interpolates real in-shot motion between them. Each frame grounds ONLY on its own extracted frame + el_* slots (never another generated frame), so all frames render in PARALLEL (no cross-frame cascade). A SPLIT-SCREEN / PICTURE-IN-PICTURE / KEYED-PRESENTER scene is reproduced as one clip PER REGION, stacked or overlaid (see `metadata.todo.composition`). On-screen text/graphics are a separate HTML overlay layer you paint; audio is the voice + SFX + a ducked music bed, normalized stereo. It is a STARTING POINT, not a locked render: add, delete, reorder, split, merge, or re-time scenes freely (a b-roll cutaway INSIDE a phrase lands at an approximate beat \u2014 nudge it) \u2014 see `metadata.todo.full_flexibility`.",
18719
+ "Scaffolded by `baker canvas scaffold-video` \u2014 a runnable reproduction of your reference video, built like an editing timeline. It is a sequence of clear SHOTS separated at COMPLETE BREAKS (hard cuts): two adjacent presenter shots at a cut are TWO clips, never glued into one take. What stays continuous is the VOICE \u2014 a voiceover narration is ONE read across the b-roll it plays over, and a b-roll CUTAWAY between two on-camera moments leaves the presenter shot continuous with the insert sliced in \u2014 and each person keeps ONE brand voice (all their clips' native audio re-voiced in a single per-speaker pass), so timbre holds across the cuts. A presenter shot is ONE Seedance clip (native lip-sync + audio); a pure-voiceover stretch is one ElevenLabs tts read; a sub-2s flash is a still hold. A single shot too long for one clip splits into takes that share a boundary frame \u2014 the spine drops the duplicated frame (`--seam-dedup head|tail|off`). Every clip gets a CLEAN-PLATE start AND end keyframe (no baked text), RECAST to your dropped reference assets \u2014 Seedance interpolates real in-shot motion between them. Each frame grounds ONLY on its own extracted frame + el_* slots (never another generated frame), so all frames render in PARALLEL (no cross-frame cascade). A SPLIT-SCREEN / PICTURE-IN-PICTURE / KEYED-PRESENTER scene is reproduced as one clip PER REGION, stacked or overlaid (see `metadata.todo.composition`). On-screen text/graphics are a separate HTML overlay layer you paint; audio is the voice + SFX + a ducked music bed, normalized stereo. It is a STARTING POINT, not a locked render: add, delete, reorder, split, merge, or re-time scenes freely \u2014 see `metadata.todo.full_flexibility`.",
18620
18720
  "",
18621
18721
  "WHAT TO DO NEXT:",
18622
18722
  "0. RE-CRAFT THE SCRIPT FIRST (don't clone). This reference already won in-market, but copying a video is much harder than a static: the hook is targeting and may not transfer, and the message must become TRUE for our brand. Work the `metadata.todo.script_recraft` checklist \u2014 for each scene judge its role (hook/body/CTA), decide keep/cut/reorder/replace, and re-author every line for OUR customer's pain + OUR offer. See `references/script-craft.md` (hook/body/CTA framework) and the `meta-ads-playbook` skill. Most of the work lives here.",
@@ -18700,9 +18800,9 @@ function buildVideoTodo(report, overlayCount, floatingCount, opts, blueprint) {
18700
18800
  voice_description: d.voice_description,
18701
18801
  line: d.line
18702
18802
  })),
18703
- talking_head_note: "PHRASE-NATIVE: a continuous-speech phrase where the speaker is shown is ONE Seedance clip (the full phrase quoted in s<anchor>_clip's prompt + generate_audio) so lips+voice are generated together \u2014 no tts, no veed-lipsync. Scenes that show the speaker slice their window out of that clip (s<i>_seg); edit the phrase line in the s<anchor>_clip prompt to re-author it. A pure-voiceover phrase (speaker never shown) is one ElevenLabs tts read instead.",
18704
- voice_note: "ONE voice per person: a single voice_select is reused across all that person's phrases (on-camera AND off \u2014 the deconstruct's `voiceover` label folds into the sole presenter). Each presenter phrase's native audio is re-voiced to that brand voice via audio_voice_convert (eleven_multilingual_sts_v2, one convert per phrase, timing preserved so lips stay matched). Set voice_select.voice_id's gender/language to match the creator.",
18705
- native_timing: "The voice is cut at PAUSES, not at visual cuts, so a sentence spanning a cut stays one continuous read (no mid-word break). The clip is generated long enough for the estimated speech; if a line runs longer than its phrase window the voice continues a beat into the following pause (natural VO continuity). `metadata.video.talking_scenes` carries each phrase's scene_s vs est_speech_s. CAVEAT: a b-roll cutaway INSIDE a phrase lands at an approximate (proportional) time \u2014 Seedance exposes no word timing \u2014 so if a cutaway is off its beat, nudge the scene boundary (it's a starting point).",
18803
+ talking_head_note: "SHOT-NATIVE: a presenter shot is ONE Seedance clip (its line quoted in s<anchor>_clip's prompt + generate_audio) so lips+voice are generated together \u2014 no tts, no veed-lipsync. Two adjacent presenter shots at a hard cut are SEPARATE clips; a cutaway phrase (the presenter on camera, cut to b-roll, back on camera) stays one clip and slices its on-camera windows (s<i>_seg). Edit the line in the s<anchor>_clip prompt to re-author it. A pure-voiceover phrase (speaker never shown) is one ElevenLabs tts read instead.",
18804
+ voice_note: "ONE voice per person: a single voice_select is reused across all that person's shots (on-camera AND off \u2014 the deconstruct's `voiceover` label folds into the sole presenter). Every presenter clip's native audio is extracted and re-voiced to that brand voice through a SINGLE merged audio_voice_convert per speaker (<voice>_conv, eleven_multilingual_sts_v2, timing preserved so lips stay matched) \u2014 so timbre stays consistent across the separate shot clips. Set voice_select.voice_id's gender/language to match the creator.",
18805
+ native_timing: "Clips separate at COMPLETE BREAKS between shots, but the VOICE stays continuous where it should: a voiceover narration is ONE read across the b-roll it plays over, and a cutaway leaves the presenter's read continuous under the insert. Each clip is generated long enough for its estimated speech. `metadata.video.talking_scenes` carries each shot's scene_s vs est_speech_s. CAVEAT: a b-roll cutaway INSIDE a phrase lands at an approximate (proportional) time \u2014 Seedance exposes no word timing \u2014 so if a cutaway is off its beat, nudge the scene boundary (it's a starting point).",
18706
18806
  craft: {
18707
18807
  note: "Production-craft principles that raise every clip's realism. Full rationale: references/video-craft.md (production craft); references/script-craft.md + meta-ads-playbook for the hook/message layer.",
18708
18808
  principles: [
@@ -19011,6 +19111,12 @@ async function materializeReferenceVideo(fileArg2) {
19011
19111
  await writeFile3(dest, bytes);
19012
19112
  return dest;
19013
19113
  }
19114
+ function resolveSeamDedup(raw) {
19115
+ if (raw === void 0) return "head";
19116
+ const v = String(raw);
19117
+ if (v === "head" || v === "tail" || v === "off") return v;
19118
+ throw new Error(`--seam-dedup must be "head", "tail", or "off" (got "${v}")`);
19119
+ }
19014
19120
  function resolveModels2(args) {
19015
19121
  const pick = (flag, kind, fallback) => args[flag] ? String(args[flag]) : resolveModel2(kind, fallback);
19016
19122
  return {
@@ -19122,6 +19228,10 @@ var scaffoldVideoCommand = defineCommand90({
19122
19228
  type: "boolean",
19123
19229
  description: "Give silent b-roll scenes native diegetic ambient mixed deep under the music bed (off by default)"
19124
19230
  },
19231
+ "seam-dedup": {
19232
+ type: "string",
19233
+ description: `How to dedup the frame two clips SHARE when a long shot is split for length: "head" (default, drop the second clip's first frame), "tail" (drop the first clip's last frame), or "off" (keep both).`
19234
+ },
19125
19235
  "max-scenes": { type: "string", description: "Cap the number of scenes the deconstruct emits" },
19126
19236
  "shot-threshold": {
19127
19237
  type: "string",
@@ -19248,6 +19358,7 @@ var scaffoldVideoCommand = defineCommand90({
19248
19358
  blueprintStylePath: path12.relative(outDir, blueprintStylePath),
19249
19359
  frames,
19250
19360
  ambient: Boolean(args.ambient),
19361
+ seamDedup: resolveSeamDedup(args["seam-dedup"]),
19251
19362
  ...args.aspect ? { aspect: String(args.aspect) } : {},
19252
19363
  ...args.resolution ? { resolution: String(args.resolution) } : {}
19253
19364
  };