@koda-sl/baker-cli 0.112.1 → 0.113.1

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
@@ -2,14 +2,17 @@
2
2
  import {
3
3
  ELEVENLABS_MAX_MUSIC_LENGTH_MS,
4
4
  IMAGE_GENERATE_MODELS,
5
+ LayerExecutionError,
5
6
  MODEL_REGISTRY,
6
7
  SEEDANCE_DURATIONS,
7
8
  ValidationError,
8
9
  createEngineFromEnv,
9
10
  defaultRegistry,
11
+ describeFailureReason,
10
12
  generateCatalog,
13
+ resolveConcurrency,
11
14
  validateCanvasDeep
12
- } from "./chunk-3JVYU72O.js";
15
+ } from "./chunk-7K2YAWUT.js";
13
16
 
14
17
  // src/cli.ts
15
18
  import { defineCommand as defineCommand155, runMain } from "citty";
@@ -11126,6 +11129,10 @@ var runCommand = defineCommand83({
11126
11129
  "outputs-dir": { type: "string", description: "Per-run outputs root (default ./canvas)" },
11127
11130
  "run-id": { type: "string", description: "Override run id" },
11128
11131
  "cache-policy": { type: "string", description: "read_write | bypass | read_only" },
11132
+ concurrency: {
11133
+ type: "string",
11134
+ description: "Max nodes per layer in flight at once (default 5; env BAKER_CANVAS_CONCURRENCY)"
11135
+ },
11129
11136
  "keep-runs": {
11130
11137
  type: "string",
11131
11138
  description: "After the run, prune old r_* run dirs, keeping the N newest (off by default)"
@@ -11173,7 +11180,11 @@ var runCommand = defineCommand83({
11173
11180
  const policy = args["cache-policy"] ?? "read_write";
11174
11181
  const result = await engine.run(parsed, {
11175
11182
  run_id: args["run-id"] ? String(args["run-id"]) : void 0,
11176
- cache_policy: policy
11183
+ cache_policy: policy,
11184
+ concurrency: resolveConcurrency(
11185
+ args.concurrency !== void 0 ? String(args.concurrency) : void 0,
11186
+ process.env.BAKER_CANVAS_CONCURRENCY
11187
+ )
11177
11188
  });
11178
11189
  const keepRuns = args["keep-runs"] !== void 0 ? Number(args["keep-runs"]) : void 0;
11179
11190
  if (keepRuns !== void 0 && Number.isFinite(keepRuns)) {
@@ -11203,6 +11214,14 @@ var runCommand = defineCommand83({
11203
11214
  );
11204
11215
  process.exit(2);
11205
11216
  }
11217
+ if (e instanceof LayerExecutionError) {
11218
+ const failures = e.failures.map((f) => ({ node_id: f.nodeId, message: describeFailureReason(f.reason) }));
11219
+ process.stderr.write(
11220
+ `${JSON.stringify({ ok: false, error: { code: "runtime", message: e.message, failures } }, null, 2)}
11221
+ `
11222
+ );
11223
+ process.exit(1);
11224
+ }
11206
11225
  const msg = e instanceof Error ? e.message : String(e);
11207
11226
  process.stderr.write(`${JSON.stringify({ ok: false, error: { code: "runtime", message: msg } }, null, 2)}
11208
11227
  `);
@@ -11534,8 +11553,9 @@ function buildDescribeCanvas(imageSource, imageIsUrl, describeModel, selectModel
11534
11553
  prompt: LAYOUT_PROMPT
11535
11554
  }
11536
11555
  }
11537
- ],
11538
- output: { node: "select", output: "text" }
11556
+ ]
11557
+ // No declared output: the engine's prune-to-output would drop `layout` (nothing
11558
+ // consumes it) and runVisionPasses reads all THREE node outputs after the run.
11539
11559
  };
11540
11560
  }
11541
11561
  async function runVisionPasses(canvas) {
@@ -11896,7 +11916,43 @@ var NARRATOR_SPEAKERS = /* @__PURE__ */ new Set([
11896
11916
  "offscreen",
11897
11917
  "off-screen"
11898
11918
  ]);
11899
- var SHARED_ASPECT_RATIOS = /* @__PURE__ */ new Set(["1:1", "16:9", "9:16", "4:3", "3:4", "21:9"]);
11919
+ var SEEDANCE_GEN_ASPECTS = ["1:1", "3:4", "9:16", "4:3", "16:9", "21:9"];
11920
+ var OUTPUT_ASPECTS = ["1:1", "16:9", "9:16", "4:3", "3:4", "21:9", "4:5", "2:3", "3:2"];
11921
+ function parseRatio(ar) {
11922
+ const m = ar?.trim().match(/^(\d+(?:\.\d+)?)[:x/](\d+(?:\.\d+)?)$/);
11923
+ if (!m) return void 0;
11924
+ const w = Number(m[1]);
11925
+ const h = Number(m[2]);
11926
+ return w > 0 && h > 0 ? w / h : void 0;
11927
+ }
11928
+ function nearestAspect(ratio, candidates) {
11929
+ let best = candidates[0];
11930
+ let bestDelta = Number.POSITIVE_INFINITY;
11931
+ for (const c of candidates) {
11932
+ const r = parseRatio(c);
11933
+ if (r === void 0) continue;
11934
+ const delta = Math.abs(ratio - r);
11935
+ if (delta < bestDelta) {
11936
+ best = c;
11937
+ bestDelta = delta;
11938
+ }
11939
+ }
11940
+ return best;
11941
+ }
11942
+ function resolveAspect(sourceAr, override, genAspects = SEEDANCE_GEN_ASPECTS) {
11943
+ let outAr;
11944
+ if (override !== void 0) {
11945
+ if (!OUTPUT_ASPECTS.includes(override)) {
11946
+ throw new Error(`unsupported --aspect "${override}" \u2014 supported aspect ratios: ${OUTPUT_ASPECTS.join(", ")}`);
11947
+ }
11948
+ outAr = override;
11949
+ } else {
11950
+ const ratio = parseRatio(sourceAr);
11951
+ outAr = ratio === void 0 ? "9:16" : nearestAspect(ratio, OUTPUT_ASPECTS);
11952
+ }
11953
+ const genAr = genAspects.includes(outAr) ? outAr : nearestAspect(parseRatio(outAr), genAspects);
11954
+ return { outAr, genAr, remapped: genAr !== outAr };
11955
+ }
11900
11956
  var EDGES = ["start", "end"];
11901
11957
  function snapToSeedance(durationS) {
11902
11958
  if (!Number.isFinite(durationS) || durationS <= 0) return SEEDANCE_DURATIONS[0];
@@ -11934,6 +11990,12 @@ function canvasDims(ar) {
11934
11990
  return { w: 1080, h: 1440 };
11935
11991
  case "21:9":
11936
11992
  return { w: 1920, h: 822 };
11993
+ case "4:5":
11994
+ return { w: 1080, h: 1350 };
11995
+ case "2:3":
11996
+ return { w: 1080, h: 1620 };
11997
+ case "3:2":
11998
+ return { w: 1620, h: 1080 };
11937
11999
  default:
11938
12000
  return { w: 1080, h: 1920 };
11939
12001
  }
@@ -11996,13 +12058,14 @@ function stillHoldArgs(durationS, dims) {
11996
12058
  "{{out.video}}"
11997
12059
  ];
11998
12060
  }
11999
- function trimArgs(durationS, offsetS = 0) {
12061
+ function trimArgs(durationS, offsetS = 0, dims) {
12000
12062
  return [
12001
12063
  "-i",
12002
12064
  "{{in.clip}}",
12003
12065
  ...offsetS > 0 ? ["-ss", offsetS.toFixed(3)] : [],
12004
12066
  "-t",
12005
12067
  durationS.toFixed(3),
12068
+ ...dims ? ["-vf", `scale=${dims.w}:${dims.h}:force_original_aspect_ratio=increase,crop=${dims.w}:${dims.h},setsar=1`] : [],
12006
12069
  "-an",
12007
12070
  "-c:v",
12008
12071
  "libx264",
@@ -12207,9 +12270,16 @@ function presenceOf(el) {
12207
12270
  }
12208
12271
  return map;
12209
12272
  }
12210
- function aspectRatioParam(blueprint) {
12211
- const ar = blueprint.source?.aspect_ratio;
12212
- return ar && SHARED_ASPECT_RATIOS.has(ar) ? ar : void 0;
12273
+ function aspectRemapTodo(aspect) {
12274
+ if (!aspect.remapped) return {};
12275
+ const dims = canvasDims(aspect.outAr);
12276
+ const trims = (parseRatio(aspect.genAr) ?? 0) > (parseRatio(aspect.outAr) ?? 0) ? "sides" : "top/bottom";
12277
+ return {
12278
+ aspect_remap: `Output is ${aspect.outAr} (${dims.w}\xD7${dims.h}) but the video model generates ${aspect.genAr}; every clip is center-cropped onto the ${aspect.outAr} canvas before the spine. Keep subjects centered in frame prompts \u2014 the crop trims the ${trims}.`
12279
+ };
12280
+ }
12281
+ function genAspectsFor(videoModel) {
12282
+ return /\bveo\b|\/veo-/.test(videoModel) ? ["16:9", "9:16"] : SEEDANCE_GEN_ASPECTS;
12213
12283
  }
12214
12284
  function annotateBlueprintWithElements(blueprintInput, elementsInput) {
12215
12285
  if (!blueprintInput || typeof blueprintInput !== "object") return blueprintInput;
@@ -12350,7 +12420,10 @@ function buildElementSheets(slots, nodes) {
12350
12420
  inputs: { references: [slot.ref] },
12351
12421
  params: {
12352
12422
  model: ACTOR_SHEET_MODEL,
12353
- subject_description: slot.description ?? `the ${slot.type}`,
12423
+ // The clean-plate clause mirrors the frame prompts' CLEAN PLATE block: a sheet
12424
+ // that comes back with a fake camera app baked in (P2-22) poisons EVERY frame
12425
+ // grounded on it, so the suppression must live on the sheet too.
12426
+ subject_description: `${slot.description ?? `the ${slot.type}`} \u2014 clean plate: no phone-camera UI chrome, no app interface, no watermarks, no captions or on-image text`,
12354
12427
  subject_type: subjectType,
12355
12428
  // 4K: the sheet packs up to 8 cells (angles + tight face/detail close-ups), and
12356
12429
  // it's the ONE reference every frame grounds on — per-cell sharpness here
@@ -12453,6 +12526,10 @@ function buildFramePrompt(edge, sceneIndex, framePrompt, present, hasAnchor, mod
12453
12526
  "{{target_blueprint}}"
12454
12527
  ].join("\n");
12455
12528
  }
12529
+ function silentClipTranscript(scene, native) {
12530
+ if (native) return "";
12531
+ return (scene.transcript_slice ?? []).map((w) => w.text?.trim()).filter(Boolean).join(" ").trim();
12532
+ }
12456
12533
  function ingestFrameRef(url, edge, ctx, nodes) {
12457
12534
  const cached2 = ctx.ingestCache?.get(url);
12458
12535
  if (cached2) return cached2;
@@ -12479,7 +12556,7 @@ function buildFrameRef(edge, url, framePrompt, present, ctx, nodes) {
12479
12556
  image_size: "2K",
12480
12557
  prompt: buildFramePrompt(edge, ctx.sceneIndex, framePrompt, present, hasOriginal, ctx.shootMode)
12481
12558
  };
12482
- if (ctx.ar) genParams.aspect_ratio = ctx.ar;
12559
+ if (ctx.genAr) genParams.aspect_ratio = ctx.genAr;
12483
12560
  const genId = `s${ctx.sceneIndex}${tag}_${edge}`;
12484
12561
  nodes.push({
12485
12562
  id: genId,
@@ -12528,7 +12605,7 @@ function buildSeedancePrompt(scene, sceneIndex, present, mode, audio, nativeLine
12528
12605
  if (lines.length > 0)
12529
12606
  parts.push(`Spoken context (do not render as audio): ${lines.map((l) => `"${l}"`).join(" ")}`);
12530
12607
  }
12531
- const transcript = (scene.transcript_slice ?? []).map((w) => w.text?.trim()).filter(Boolean).join(" ").trim();
12608
+ const transcript = silentClipTranscript(scene, Boolean(nativeLine));
12532
12609
  if (transcript) parts.push(`Transcript: ${loc(transcript)}`);
12533
12610
  const audioLine = seedanceAudioLine(scene, mode, audio, nativeLine);
12534
12611
  if (audioLine) parts.push(audioLine);
@@ -12655,7 +12732,7 @@ function emitSceneClip(i, scene, present, mode, nativeTurn, ambientBroll, frames
12655
12732
  // ambient b-roll beat generates diegetic ambient only; otherwise the clip is silent.
12656
12733
  generate_audio: Boolean(nativeTurn) || ambientBroll
12657
12734
  };
12658
- if (opts.ar) clipParams.aspect_ratio = opts.ar;
12735
+ if (opts.genAr) clipParams.aspect_ratio = opts.genAr;
12659
12736
  nodes.push({
12660
12737
  id: `s${i}${tag}_clip`,
12661
12738
  type: "video_generate",
@@ -12663,12 +12740,13 @@ function emitSceneClip(i, scene, present, mode, nativeTurn, ambientBroll, frames
12663
12740
  params: clipParams
12664
12741
  });
12665
12742
  const base = `$ref:s${i}${tag}_clip.video`;
12666
- if (lengths.genDur === lengths.trimTarget) return { ref: base, scene_s: lengths.dur, out };
12743
+ const normDims = opts.outAr && opts.genAr !== opts.outAr ? canvasDims(opts.outAr) : void 0;
12744
+ if (lengths.genDur === lengths.trimTarget && !normDims) return { ref: base, scene_s: lengths.dur, out };
12667
12745
  nodes.push({
12668
12746
  id: `s${i}${tag}_clip_trim`,
12669
12747
  type: "ffmpeg",
12670
12748
  inputs: { clip: base },
12671
- params: { args: trimArgs(lengths.trimTarget), outputs: { video: { kind: "video", ext: "mp4" } } }
12749
+ params: { args: trimArgs(lengths.trimTarget, 0, normDims), outputs: { video: { kind: "video", ext: "mp4" } } }
12672
12750
  });
12673
12751
  return { ref: `$ref:s${i}${tag}_clip_trim.video`, scene_s: lengths.dur, out };
12674
12752
  }
@@ -12750,7 +12828,7 @@ function slotsForRegion(present, isPresenter) {
12750
12828
  });
12751
12829
  }
12752
12830
  function buildCompositeScene(layout, regions, comp, scene, i, present, mode, nativeTurn, lengths, out, opts, nodes) {
12753
- const dims = canvasDims(opts.ar);
12831
+ const dims = canvasDims(opts.outAr);
12754
12832
  const presIdx = presenterIndexOf(regions, Boolean(nativeTurn));
12755
12833
  const regionRefs = [];
12756
12834
  let presenterPosition;
@@ -12760,7 +12838,7 @@ function buildCompositeScene(layout, regions, comp, scene, i, present, mode, nat
12760
12838
  const regionSlots = slotsForRegion(present, isPresenter);
12761
12839
  const ctx = {
12762
12840
  sceneIndex: i,
12763
- ar: opts.ar,
12841
+ genAr: opts.genAr,
12764
12842
  reuse: opts.reuse,
12765
12843
  imageModel: opts.imageModel,
12766
12844
  shootMode: mode,
@@ -12787,7 +12865,13 @@ function buildCompositeScene(layout, regions, comp, scene, i, present, mode, nat
12787
12865
  { first, last },
12788
12866
  lengths,
12789
12867
  null,
12790
- { ar: opts.ar, videoModel: opts.videoModel, resolution: opts.resolution, nativeLang: opts.nativeLang },
12868
+ {
12869
+ outAr: opts.outAr,
12870
+ genAr: opts.genAr,
12871
+ videoModel: opts.videoModel,
12872
+ resolution: opts.resolution,
12873
+ nativeLang: opts.nativeLang
12874
+ },
12791
12875
  nodes,
12792
12876
  tag
12793
12877
  );
@@ -12864,7 +12948,7 @@ function emitCompositeScene(composite, scene, i, present, mode, nativeTurn, leng
12864
12948
  );
12865
12949
  clips.push(built.clip);
12866
12950
  }
12867
- function emitFlashHold(i, scene, slots, ctx, lengths, out, ar, nodes, clips) {
12951
+ function emitFlashHold(i, scene, slots, ctx, lengths, out, outAr, nodes, clips) {
12868
12952
  const frame = buildFrameRef(
12869
12953
  "start",
12870
12954
  scene.start_frame_asset?.url,
@@ -12878,13 +12962,13 @@ function emitFlashHold(i, scene, slots, ctx, lengths, out, ar, nodes, clips) {
12878
12962
  type: "ffmpeg",
12879
12963
  inputs: { frame },
12880
12964
  params: {
12881
- args: stillHoldArgs(lengths.trimTarget, canvasDims(ar)),
12965
+ args: stillHoldArgs(lengths.trimTarget, canvasDims(outAr)),
12882
12966
  outputs: { video: { kind: "video", ext: "mp4" } }
12883
12967
  }
12884
12968
  });
12885
12969
  clips.push({ ref: `$ref:s${i}_clip.video`, scene_s: lengths.dur, out });
12886
12970
  }
12887
- function emitScreenScene(i, scene, lengths, out, ar, nodes, clips) {
12971
+ function emitScreenScene(i, scene, lengths, out, outAr, nodes, clips) {
12888
12972
  const label = commentSafe((scene.summary || scene.start_frame_prompt || "the app screen").slice(0, 120));
12889
12973
  const refId = `s${i}_screen_ref`;
12890
12974
  nodes.push({
@@ -12901,7 +12985,7 @@ function emitScreenScene(i, scene, lengths, out, ar, nodes, clips) {
12901
12985
  type: "ffmpeg",
12902
12986
  inputs: { frame: `$ref:${refId}.asset` },
12903
12987
  params: {
12904
- args: screenStillArgs(lengths.trimTarget, canvasDims(ar)),
12988
+ args: screenStillArgs(lengths.trimTarget, canvasDims(outAr)),
12905
12989
  outputs: { video: { kind: "video", ext: "mp4" } }
12906
12990
  }
12907
12991
  });
@@ -12943,13 +13027,13 @@ function colorPlateArgs(durationS, dims, color) {
12943
13027
  "{{out.video}}"
12944
13028
  ];
12945
13029
  }
12946
- function emitBrandCardScene(i, lengths, out, ar, color, nodes, clips) {
13030
+ function emitBrandCardScene(i, lengths, out, outAr, color, nodes, clips) {
12947
13031
  nodes.push({
12948
13032
  id: `s${i}_clip`,
12949
13033
  type: "ffmpeg",
12950
13034
  inputs: {},
12951
13035
  params: {
12952
- args: colorPlateArgs(lengths.trimTarget, canvasDims(ar), color),
13036
+ args: colorPlateArgs(lengths.trimTarget, canvasDims(outAr), color),
12953
13037
  outputs: { video: { kind: "video", ext: "mp4" } }
12954
13038
  }
12955
13039
  });
@@ -13081,6 +13165,35 @@ function makePresenterPresent(slots, canonical, opts = {}) {
13081
13165
  var PAUSE_GAP_S = 0.6;
13082
13166
  var SEEDANCE_SAFE_MAX_S = SEEDANCE_DURATIONS.find((d) => d >= 10) ?? 10;
13083
13167
  var PHRASE_MAX_S = SEEDANCE_SAFE_MAX_S;
13168
+ var JOIN_DEDUP_MAX_WORDS = 4;
13169
+ function joinKey(word) {
13170
+ return word.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, "");
13171
+ }
13172
+ function joinDialogueTexts(texts) {
13173
+ const out = [];
13174
+ for (const text of texts) {
13175
+ const prev = out[out.length - 1];
13176
+ if (!prev) {
13177
+ out.push(text);
13178
+ continue;
13179
+ }
13180
+ const prevWords = prev.split(/\s+/).filter(Boolean);
13181
+ const nextWords = text.split(/\s+/).filter(Boolean);
13182
+ let overlap = 0;
13183
+ const maxK = Math.min(JOIN_DEDUP_MAX_WORDS, prevWords.length, nextWords.length);
13184
+ for (let k = maxK; k >= 1; k--) {
13185
+ const tail = prevWords.slice(-k).map(joinKey).join(" ");
13186
+ const head = nextWords.slice(0, k).map(joinKey).join(" ");
13187
+ if (tail && tail === head) {
13188
+ overlap = k;
13189
+ break;
13190
+ }
13191
+ }
13192
+ const rest = nextWords.slice(overlap).join(" ");
13193
+ if (rest) out.push(rest);
13194
+ }
13195
+ return out.join(" ");
13196
+ }
13084
13197
  function collapseVoiceover(blueprint) {
13085
13198
  const casts = castIdSet(blueprint);
13086
13199
  const cameraOn = onCameraDialogue(blueprint);
@@ -13138,7 +13251,7 @@ function buildPhrases(blueprint, canonical, compositeScenes, presenterPresent, p
13138
13251
  speaker: cur.speaker,
13139
13252
  start_s: cur.start,
13140
13253
  end_s: cur.end,
13141
- text: cur.texts.join(" "),
13254
+ text: joinDialogueTexts(cur.texts),
13142
13255
  firstScene: cur.firstScene,
13143
13256
  shownScenes,
13144
13257
  presenterShown: shownScenes.length > 0
@@ -13211,7 +13324,7 @@ function emitPhraseClip(phrase, voiceNode, env, nodes, out) {
13211
13324
  const mode = sceneShootMode(anchorScene, present, nativeTurn, env.cameraOn, env.casts);
13212
13325
  const ctx = {
13213
13326
  sceneIndex: anchor,
13214
- ar: env.ar,
13327
+ genAr: env.genAr,
13215
13328
  reuse: env.reuse,
13216
13329
  imageModel: env.opts.imageModel,
13217
13330
  shootMode: mode,
@@ -13249,7 +13362,7 @@ function emitPhraseClip(phrase, voiceNode, env, nodes, out) {
13249
13362
  ...videoResolutionParam(env.opts.videoModel, env.opts.resolution),
13250
13363
  generate_audio: true
13251
13364
  };
13252
- if (env.ar) clipParams.aspect_ratio = env.ar;
13365
+ clipParams.aspect_ratio = env.genAr;
13253
13366
  nodes.push({
13254
13367
  id: `s${anchor}_clip`,
13255
13368
  type: "video_generate",
@@ -13363,7 +13476,8 @@ function emitCompositeInTimeline(composite, scene, i, isLast, env, canonical, en
13363
13476
  lengths,
13364
13477
  lengths.out,
13365
13478
  {
13366
- ar: env.ar,
13479
+ outAr: env.outAr,
13480
+ genAr: env.genAr,
13367
13481
  reuse: env.reuse,
13368
13482
  imageModel: env.opts.imageModel,
13369
13483
  videoModel: env.opts.videoModel,
@@ -13433,23 +13547,23 @@ function emitBrollScene(scene, i, isLast, env, nodes, out, prevEndFrame) {
13433
13547
  const lengths = sceneTiming(scene, isLast, void 0);
13434
13548
  const ctx = {
13435
13549
  sceneIndex: i,
13436
- ar: env.ar,
13550
+ genAr: env.genAr,
13437
13551
  reuse: env.reuse,
13438
13552
  imageModel: env.opts.imageModel,
13439
13553
  shootMode: mode,
13440
13554
  ingestCache: env.ingestCache
13441
13555
  };
13442
13556
  if (!env.reuse && sceneIsFullScreenUi(scene, present)) {
13443
- emitScreenScene(i, scene, lengths, lengths.out, env.ar, nodes, out.clips);
13557
+ emitScreenScene(i, scene, lengths, lengths.out, env.outAr, nodes, out.clips);
13444
13558
  return void 0;
13445
13559
  }
13446
13560
  const isCta = scene.narrative_role?.trim() === "cta" || isLast;
13447
13561
  if (!env.reuse && sceneIsBrandCard(scene, present, isCta)) {
13448
- emitBrandCardScene(i, lengths, lengths.out, env.ar, brandPlateColor(env.blueprint), nodes, out.clips);
13562
+ emitBrandCardScene(i, lengths, lengths.out, env.outAr, brandPlateColor(env.blueprint), nodes, out.clips);
13449
13563
  return void 0;
13450
13564
  }
13451
13565
  if (!ambientBroll && lengths.dur <= FLASH_HOLD_MAX_S) {
13452
- emitFlashHold(i, scene, env.slots, ctx, lengths, lengths.out, env.ar, nodes, out.clips);
13566
+ emitFlashHold(i, scene, env.slots, ctx, lengths, lengths.out, env.outAr, nodes, out.clips);
13453
13567
  return void 0;
13454
13568
  }
13455
13569
  const first = scene.continues_previous && prevEndFrame ? prevEndFrame : buildFrameRef(
@@ -13478,7 +13592,13 @@ function emitBrollScene(scene, i, isLast, env, nodes, out, prevEndFrame) {
13478
13592
  { first, last },
13479
13593
  { dur: lengths.dur, trimTarget: lengths.trimTarget, genDur: lengths.genDur },
13480
13594
  lengths.out,
13481
- { ar: env.ar, videoModel: env.opts.videoModel, resolution: env.opts.resolution, nativeLang: env.ttsLanguageCode },
13595
+ {
13596
+ outAr: env.outAr,
13597
+ genAr: env.genAr,
13598
+ videoModel: env.opts.videoModel,
13599
+ resolution: env.opts.resolution,
13600
+ nativeLang: env.ttsLanguageCode
13601
+ },
13482
13602
  nodes
13483
13603
  );
13484
13604
  if (ambientBroll) {
@@ -13506,11 +13626,13 @@ function buildTimeline(blueprint, slots, opts, nodes) {
13506
13626
  }
13507
13627
  const canonical = collapseVoiceover(blueprint);
13508
13628
  const ensureVoiceNode = makeVoiceFactory(blueprint, canonical, nodes);
13629
+ const aspect = resolveAspect(blueprint.source?.aspect_ratio, opts.aspect, genAspectsFor(opts.videoModel));
13509
13630
  const env = {
13510
13631
  blueprint,
13511
13632
  slots,
13512
13633
  opts,
13513
- ar: aspectRatioParam(blueprint),
13634
+ outAr: aspect.outAr,
13635
+ genAr: aspect.genAr,
13514
13636
  reuse,
13515
13637
  cameraOn: onCameraDialogue(blueprint),
13516
13638
  casts: castIdSet(blueprint),
@@ -13562,7 +13684,8 @@ function buildTimeline(blueprint, slots, opts, nodes) {
13562
13684
  }
13563
13685
  const slice = out.sceneSlice.get(i);
13564
13686
  if (slice) {
13565
- const whole = slice.offset === 0 && Math.abs(slice.len - slice.clipDur) <= 0.05;
13687
+ const normDims = env.genAr !== env.outAr ? canvasDims(env.outAr) : void 0;
13688
+ const whole = slice.offset === 0 && Math.abs(slice.len - slice.clipDur) <= 0.05 && !normDims;
13566
13689
  if (whole) {
13567
13690
  out.clips.push({ ref: slice.clipRef, scene_s: slice.len, out: null });
13568
13691
  } else {
@@ -13570,7 +13693,10 @@ function buildTimeline(blueprint, slots, opts, nodes) {
13570
13693
  id: `s${i}_seg`,
13571
13694
  type: "ffmpeg",
13572
13695
  inputs: { clip: slice.clipRef },
13573
- params: { args: trimArgs(slice.len, slice.offset), outputs: { video: { kind: "video", ext: "mp4" } } }
13696
+ params: {
13697
+ args: trimArgs(slice.len, slice.offset, normDims),
13698
+ outputs: { video: { kind: "video", ext: "mp4" } }
13699
+ }
13574
13700
  });
13575
13701
  out.clips.push({ ref: `$ref:s${i}_seg.video`, scene_s: slice.len, out: null });
13576
13702
  }
@@ -13687,6 +13813,8 @@ function collectCaptions(blueprint) {
13687
13813
  }) : [];
13688
13814
  }).sort((a, b) => a.at - b.at);
13689
13815
  }
13816
+ var OVERLAY_REDETECT_GAP_S = 2;
13817
+ var OVERLAY_MIN_DUR_S = 0.5;
13690
13818
  function mergeCaptions(blueprint) {
13691
13819
  const byText = /* @__PURE__ */ new Map();
13692
13820
  for (const e of collectCaptions(blueprint)) {
@@ -13698,7 +13826,7 @@ function mergeCaptions(blueprint) {
13698
13826
  for (const arr of byText.values()) {
13699
13827
  let cur = null;
13700
13828
  for (const e of arr) {
13701
- if (cur && e.at <= cur.end + 0.35) cur.end = Math.max(cur.end, e.end);
13829
+ if (cur && e.at <= cur.end + OVERLAY_REDETECT_GAP_S) cur.end = Math.max(cur.end, e.end);
13702
13830
  else {
13703
13831
  cur = { ...e };
13704
13832
  merged.push(cur);
@@ -13787,10 +13915,28 @@ function buildOverlayHtml(input) {
13787
13915
  ];
13788
13916
  const ovParts = mergeCaptions(blueprint).map((e) => overlayElement(e.ov, e.at, Math.round((e.end - e.at) * 1e3) / 1e3)).filter(Boolean);
13789
13917
  if (ovParts.length > 0) blocks.push(ovParts.join("\n"));
13918
+ const seenFloats = /* @__PURE__ */ new Map();
13919
+ const keepFloat = (fe, sceneStart) => {
13920
+ const at = fe.appears_at_s ?? sceneStart;
13921
+ const dur = fe.duration_s ?? 2.5;
13922
+ if (dur < OVERLAY_MIN_DUR_S) return false;
13923
+ const key = [
13924
+ (fe.kind ?? "element").toLowerCase(),
13925
+ (fe.brand_name || fe.what_it_represents || fe.description || "").toLowerCase().trim(),
13926
+ positionClass(fe.position)
13927
+ ].join("|");
13928
+ const lastEnd = seenFloats.get(key);
13929
+ if (lastEnd !== void 0 && at <= lastEnd + OVERLAY_REDETECT_GAP_S) {
13930
+ seenFloats.set(key, Math.max(lastEnd, at + dur));
13931
+ return false;
13932
+ }
13933
+ seenFloats.set(key, at + dur);
13934
+ return true;
13935
+ };
13790
13936
  for (const scene of blueprint.scenes) {
13791
13937
  const sceneStart = scene.start_s ?? 0;
13792
13938
  const floats = z9.array(FloatingElement).safeParse(scene.floating_elements ?? []);
13793
- const parts = (floats.success ? floats.data.map((fe) => floatingStub(fe, sceneStart)) : []).filter(Boolean);
13939
+ const parts = (floats.success ? floats.data.filter((fe) => keepFloat(fe, sceneStart)).map((fe) => floatingStub(fe, sceneStart)) : []).filter(Boolean);
13794
13940
  const pip = uiPipStub(scene);
13795
13941
  if (pip) parts.push(pip);
13796
13942
  if (parts.length > 0) blocks.push(parts.join("\n"));
@@ -13994,12 +14140,16 @@ function scaffoldVideoCanvas(input, elementsInput, opts) {
13994
14140
  });
13995
14141
  videoNode = "final";
13996
14142
  }
14143
+ const todo = {
14144
+ ...buildVideoTodo(videoReport(input, elementsInput), overlays.length, floating.length, opts, blueprint),
14145
+ ...aspectRemapTodo(resolveAspect(blueprint.source?.aspect_ratio, opts.aspect, genAspectsFor(opts.videoModel)))
14146
+ };
13997
14147
  return {
13998
14148
  schema: "baker-canvas/1",
13999
14149
  metadata: {
14000
14150
  name: "video reproduction",
14001
14151
  description: VIDEO_GUIDE,
14002
- todo: buildVideoTodo(videoReport(input, elementsInput), overlays.length, floating.length, opts, blueprint),
14152
+ todo,
14003
14153
  // The timing plan `baker canvas validate` checks before any billed render:
14004
14154
  // sequenced voiceover turns (no overlap), audio ≈ video length, and which
14005
14155
  // scenes must be lip-synced.
@@ -14360,6 +14510,27 @@ async function stageCaptions(outDir, transcript) {
14360
14510
  await cp(SHIPPED_CAPTIONS_DIR, compositionPath, { recursive: true });
14361
14511
  return { compositionPath };
14362
14512
  }
14513
+ function patchCompositionMeta(metaJson, dims) {
14514
+ try {
14515
+ const meta = JSON.parse(metaJson);
14516
+ if (!meta || typeof meta !== "object" || Array.isArray(meta)) return metaJson;
14517
+ return `${JSON.stringify({ ...meta, width: dims.w, height: dims.h }, null, 2)}
14518
+ `;
14519
+ } catch {
14520
+ return metaJson;
14521
+ }
14522
+ }
14523
+ function patchCompositionHtml(html, dims) {
14524
+ return html.replace(/(<meta\s+name="viewport"\s+content="width=)\d+(,\s*height=)\d+(")/i, `$1${dims.w}$2${dims.h}$3`).replace(/(width:\s*)\d+(px;\s*height:\s*)\d+(px;)/i, `$1${dims.w}$2${dims.h}$3`).replace(/(data-width=")\d+(")/i, `$1${dims.w}$2`).replace(/(data-height=")\d+(")/i, `$1${dims.h}$2`);
14525
+ }
14526
+ async function stampCompositionDims(compositionDir, dims) {
14527
+ const metaPath = path9.join(compositionDir, "meta.json");
14528
+ const rawMeta = await readFile6(metaPath, "utf8");
14529
+ await writeFile2(metaPath, patchCompositionMeta(rawMeta, dims), "utf8");
14530
+ const htmlPath = path9.join(compositionDir, "index.html");
14531
+ const rawHtml = await readFile6(htmlPath, "utf8");
14532
+ await writeFile2(htmlPath, patchCompositionHtml(rawHtml, dims), "utf8");
14533
+ }
14363
14534
  function parseElements2(raw) {
14364
14535
  const parsed = JSON.parse(raw);
14365
14536
  if (Array.isArray(parsed)) return parsed;
@@ -14512,6 +14683,10 @@ var scaffoldVideoCommand = defineCommand85({
14512
14683
  "select-model": { type: "string", description: "Override the text_generate model id for element selection" },
14513
14684
  "image-model": { type: "string", description: "Override the image_generate model id for frames" },
14514
14685
  "video-model": { type: "string", description: "Override the video_generate model id for clips" },
14686
+ aspect: {
14687
+ type: "string",
14688
+ description: `Output aspect ratio (e.g. "4:5"). Default: the source video's detected ratio. When the video model can't generate the ratio (Seedance has no 4:5) clips generate at the nearest supported ratio and are center-cropped onto the output canvas.`
14689
+ },
14515
14690
  resolution: {
14516
14691
  type: "string",
14517
14692
  description: `Output resolution for generated clips (e.g. "1080p"). Default 1080p \u2014 the highest the video model supports \u2014 so clips keep the keyframe sharpness instead of the model's low default.`
@@ -14545,8 +14720,26 @@ var scaffoldVideoCommand = defineCommand85({
14545
14720
  const annotated = annotateBlueprintWithElements(blueprint, elements);
14546
14721
  await writeFile2(blueprintPath, `${JSON.stringify(annotated, null, 2)}
14547
14722
  `, "utf8");
14723
+ let aspect;
14724
+ try {
14725
+ aspect = resolveAspect(
14726
+ blueprint.source?.aspect_ratio,
14727
+ args.aspect ? String(args.aspect) : void 0,
14728
+ genAspectsFor(videoModel)
14729
+ );
14730
+ } catch (e) {
14731
+ return fail2("aspect", e instanceof Error ? e.message : String(e));
14732
+ }
14733
+ const outDims = canvasDims(aspect.outAr);
14734
+ if (aspect.remapped) {
14735
+ process.stderr.write(
14736
+ `\u26A0\uFE0F ${aspect.outAr} output \u2192 clips generate at ${aspect.genAr} (the video model's nearest ratio) and are center-cropped to ${aspect.outAr} (${outDims.w}\xD7${outDims.h}) \u2014 keep subjects centered in frame prompts.
14737
+ `
14738
+ );
14739
+ }
14548
14740
  const compositionDest = path9.join(outDir, "video-overlay-composition");
14549
14741
  await cp(SHIPPED_COMPOSITION_DIR, compositionDest, { recursive: true });
14742
+ await stampCompositionDims(compositionDest, outDims);
14550
14743
  const indexPath = path9.join(compositionDest, "index.html");
14551
14744
  const overlayHtml = buildOverlayHtml(blueprint);
14552
14745
  const indexHtml = await readFile6(indexPath, "utf8");
@@ -14559,6 +14752,7 @@ var scaffoldVideoCommand = defineCommand85({
14559
14752
  }
14560
14753
  await writeFile2(indexPath, injected, "utf8");
14561
14754
  const captions = await stageCaptions(outDir, transcript);
14755
+ if (captions.compositionPath) await stampCompositionDims(captions.compositionPath, outDims);
14562
14756
  const opts = {
14563
14757
  imageModel,
14564
14758
  videoModel,
@@ -14567,6 +14761,7 @@ var scaffoldVideoCommand = defineCommand85({
14567
14761
  blueprintPath: path9.relative(outDir, blueprintPath),
14568
14762
  frames,
14569
14763
  ambient: Boolean(args.ambient),
14764
+ ...args.aspect ? { aspect: String(args.aspect) } : {},
14570
14765
  ...args.resolution ? { resolution: String(args.resolution) } : {}
14571
14766
  };
14572
14767
  let canvas;