@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/README.md CHANGED
@@ -3768,6 +3768,9 @@ Validate, then execute the graph. Blocks until done. Logs one line per node. Ret
3768
3768
  | `--outputs-dir <path>` | `./canvas` | Root for per-run output directories. |
3769
3769
  | `--run-id <id>` | auto ULID | Override the generated run id. |
3770
3770
  | `--cache-policy <policy>` | `read_write` | `read_write`, `bypass`, or `read_only`. |
3771
+ | `--concurrency <n>` | `5` (or `BAKER_CANVAS_CONCURRENCY`) | Max nodes executing at once within a layer. |
3772
+
3773
+ **Failures don't abandon sibling work.** Nodes in a layer run under the concurrency cap and every one **settles** — a failed clip no longer kills its in-flight siblings, whose finished results still land in the content-addressed cache. One failure re-throws as-is; several are reported together (each failed node named). Re-running `baker canvas run` resumes from the cache and re-executes **only** the failed nodes and their descendants — never hand-orchestrate per-node renders. Long `video_generate` clips execute as **backend jobs** (the CLI polls; a CDN/proxy timeout can no longer kill a generation mid-flight).
3771
3774
 
3772
3775
  #### `baker canvas scaffold-video <video> [flags]`
3773
3776
 
@@ -3784,6 +3787,8 @@ It then scaffolds the full pipeline like an **editing timeline**: each clip gets
3784
3787
 
3785
3788
  **Composited scenes (split-screen / picture-in-picture / keyed presenter).** Real ads aren't always one full-frame shot — a frame can be **persistently divided** (b-roll on top, a presenter talking on the bottom) or **layer a presenter** over background footage (boxed in a corner, or green-screen keyed). The deconstruct now reports this per scene as `scene.composition` (`layout: split_screen | pip | keyed_overlay`, with one `region` per stream — each its own clean-plate frame + motion brief, the talking-head region flagged `is_presenter`). The scaffold reproduces a composited scene by building **one clip per region** (`s<i>_r0_*`, `s<i>_r1_*`, …) and compositing them with ffmpeg: a split-screen `vstack`/`hstack` (stack direction read from the region **panels**, so a top/bottom split always stacks vertically), or a picture-in-picture `overlay` of the presenter inset at its corner. A **keyed** presenter is first cut to transparency by `video_background_remove` (`s<i>_key`), then overlaid. The presenter region carries the native lip-synced voice; b-roll/render panels stay silent. To change a layout, edit `composition` in `prompt.json` and re-scaffold, or hand-edit the `s<i>_composite` ffmpeg args. Plain full-frame scenes (the default) are unaffected.
3786
3789
 
3790
+ **True output aspect, even when the video model can't generate it.** The scaffold resolves TWO ratios: the ad's **output** ratio (the source video's detected `aspect_ratio`, or **`--aspect`**, e.g. `4:5` → a 1080×1350 canvas) and the **generation** ratio — the nearest ratio the video model supports (Seedance has no 4:5, so clips + conditioning keyframes generate at `3:4`). When they differ the scaffold warns loudly, records `metadata.todo.aspect_remap`, normalizes **every clip** onto the output canvas (scale-to-fill, center-crop — keep subjects centered), sizes every ffmpeg plate/still/composite to the output dims, and stamps the copied overlay/caption compositions' `meta.json` to match. Nothing is silently dropped: an unknown ratio snaps to the nearest known one and stays explicit on every node.
3791
+
3787
3792
  **Montage flashes held as stills.** A rapid-cut beat shorter than ~2s with no spoken line is a **flash** — Seedance's shortest clip is 4s, so generating one (then trimming away most of it) burns credits for motion no viewer perceives. The scaffold instead **holds one keyframe as a still** for the scene length (a cheap ffmpeg loop, no billed `video_generate`), same look at a fraction of the cost. Talking/ambient beats keep a real clip (they need motion + native audio).
3788
3793
 
3789
3794
  **The phrase model (voice cut at pauses, not at visual cuts).** The voice is grouped into **phrases** — runs of continuous speech with no real pause, which may span several visual scenes. A phrase is voiced ONCE (so a sentence the deconstruct split at a visual cut never breaks mid-word): if the speaker is **shown** anywhere in the phrase it's a single Seedance clip (`s<anchor>_clip`, native lip-sync + audio) re-voiced to the brand voice; if the speaker is **never shown** it's one ElevenLabs `tts` read. The picture is then assembled **scene by scene**: a scene that shows the speaker **slices its window** out of the phrase clip (`s<i>_seg`, an ffmpeg `-ss`/`-t` cut — video and audio come from the *same* clip, so lip-sync holds), and a **b-roll cutaway** gets its own silent clip while the phrase's voice plays underneath. "Shown" is decided by the **presenter element's per-scene presence**, not just who's speaking — a scene where a cast member narrates over b-roll (their element absent) is treated as a cutaway, so the talking head never appears where the original cut away. A presenter run longer than the **gateway-safe ~10s clip ceiling splits at a scene boundary** into contiguous takes (each its own clip + convert), so a sliced window never reads past its clip. (Seedance's *API* max is 15s, but the generation gateway frequently times out — **HTTP 524** — before it can deliver a clip longer than ~10s, so the scaffold never asks for one that long; 10s is a Seedance-allowed duration, so the split clip still snaps cleanly.) A b-roll cutaway *inside* a phrase lands at an **approximate** time (Seedance exposes no word timing) — nudge the scene boundary if it's off its beat.
@@ -3808,7 +3813,7 @@ It then scaffolds the full pipeline like an **editing timeline**: each clip gets
3808
3813
 
3809
3814
  **Re-craft the script — the hook is the #1 decision.** A reproduction is *inspiration* from a proven ad, not a clone: its structure (hook → body → CTA) carries the persuasion, and the hook is *targeting*, so a competitor's hook often does **not** transfer. `metadata.todo.script_recraft` tags each scene with its `narrative_role` (from the deconstruct, else inferred) and carries the original line **flagged** so it is never shipped as-is — and the per-scene `recraft` instruction is **role-aware**: the **hook** scene's entry carries the diagnose → decide (keep/adapt/rebuild) → criteria (statement not question, benefit by ~2s, first frame legible **sound-off** in ~1s, no bait-and-switch) inline and routes to the skill's `references/hook-craft.md`. A dedicated top-level **`metadata.todo.hook`** key foregrounds it as the highest-leverage beat, mapped onto scene-0's artifacts (`s0_start` first frame, scene-0 overlay text, `s0_clip` line, micro-hook, hook-ramp).
3810
3815
 
3811
- The emitted canvas is validated (`validateCanvasDeep`) before it's written, so it always runs. It also carries a **`metadata.video`** timing plan that `baker canvas validate` proves **statically, before any billed render**: no two voiceover turns overlap, the audio length ≈ the video length, and every single-on-camera-speaker scene is a native talking head (its clip carries `generate_audio` and is wired to an `audio_voice_convert` node). The full editable checklist is embedded as **`metadata.todo`** (with a step-by-step guide in `metadata.description`). stdout returns `{ ok, canvas_path, prompt_path, models, stats, checklist }`.
3816
+ The emitted canvas is validated (`validateCanvasDeep`) before it's written, so it always runs. It also carries a **`metadata.video`** timing plan that `baker canvas validate` proves **statically, before any billed render**: no two voiceover turns overlap, the audio length ≈ the video length, every single-on-camera-speaker scene is a native talking head (its clip carries `generate_audio` and is wired to an `audio_voice_convert` node), **no re-crafted line physically overruns its clip** (`VIDEO_SPEECH_OVERRUN` — est. speech > ~1.6× the clip duration fails validate, since Seedance crams or dies on it), and **every clip agrees on one aspect ratio** (`VIDEO_ASPECT_MISMATCH`). The full editable checklist is embedded as **`metadata.todo`** (with a step-by-step guide in `metadata.description`). stdout returns `{ ok, canvas_path, prompt_path, models, stats, checklist }`.
3812
3817
 
3813
3818
  ```bash
3814
3819
  baker canvas scaffold-video ./reference-ad.mp4 --focus "competitor UGC ad for <brand>"
@@ -66,7 +66,10 @@
66
66
  data-height="1920"
67
67
  >
68
68
  <video id="bg" class="clip" src="background.mp4" data-start="0" data-duration="{{duration}}" muted></video>
69
- <div class="caption-container" id="captions"></div>
69
+ <!-- Captions intentionally paint over the full-bleed background video —
70
+ tell the pre-render occlusion check that's by design (it otherwise
71
+ flags any text sitting over an opaque full-canvas element). -->
72
+ <div class="caption-container" id="captions" data-layout-allow-occlusion></div>
70
73
  </div>
71
74
 
72
75
  <script>
@@ -101,7 +101,10 @@
101
101
  <body>
102
102
  <div id="root" data-composition-id="main" data-start="0" data-duration="{{duration}}" data-width="1080" data-height="1920">
103
103
  <video id="bg" class="clip" src="background.mp4" data-start="0" data-duration="{{duration}}" muted></video>
104
- <div id="overlay-root">
104
+ <!-- Overlays intentionally paint over the full-bleed background video —
105
+ tell the pre-render occlusion check that's by design (it otherwise
106
+ flags any text sitting over an opaque full-canvas element). -->
107
+ <div id="overlay-root" data-layout-allow-occlusion>
105
108
  <!--OVERLAYS-->
106
109
 
107
110
  <!-- NESTED BLOCK SLOT (optional): for an ANIMATED graphic (lower-third,
@@ -873,6 +873,18 @@ var NodeExecutionError = class extends CanvasError {
873
873
  this.cause = cause;
874
874
  }
875
875
  };
876
+ var LayerExecutionError = class extends CanvasError {
877
+ failures;
878
+ constructor(failures) {
879
+ const ids = failures.map((f) => f.nodeId).join(", ");
880
+ super(`${failures.length} nodes failed in one layer: ${ids}`);
881
+ this.name = "LayerExecutionError";
882
+ this.failures = failures;
883
+ }
884
+ };
885
+ function describeFailureReason(reason) {
886
+ return reason instanceof Error ? reason.message : String(reason);
887
+ }
876
888
  function describeCause(c) {
877
889
  switch (c.kind) {
878
890
  case "local":
@@ -1488,6 +1500,34 @@ function validateValue(key, value, schema, model) {
1488
1500
  }
1489
1501
  }
1490
1502
 
1503
+ // src/engine/lib/concurrency.ts
1504
+ var DEFAULT_CONCURRENCY = 5;
1505
+ function resolveConcurrency(...candidates) {
1506
+ for (const candidate of candidates) {
1507
+ if (candidate === void 0 || candidate === "") continue;
1508
+ const n = typeof candidate === "number" ? candidate : Number(candidate);
1509
+ if (Number.isFinite(n) && n >= 1) return Math.floor(n);
1510
+ }
1511
+ return DEFAULT_CONCURRENCY;
1512
+ }
1513
+ async function mapWithConcurrency(items, limit, fn) {
1514
+ const results = new Array(items.length);
1515
+ let next = 0;
1516
+ const worker = async () => {
1517
+ while (next < items.length) {
1518
+ const index = next++;
1519
+ try {
1520
+ results[index] = { status: "fulfilled", value: await fn(items[index], index) };
1521
+ } catch (reason) {
1522
+ results[index] = { status: "rejected", reason };
1523
+ }
1524
+ }
1525
+ };
1526
+ const workers = Array.from({ length: Math.min(limit, items.length) }, worker);
1527
+ await Promise.all(workers);
1528
+ return results;
1529
+ }
1530
+
1491
1531
  // src/engine/lib/ulid.ts
1492
1532
  import { randomBytes } from "crypto";
1493
1533
  var ENCODING = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
@@ -2094,9 +2134,18 @@ var STAGE_CODES = {
2094
2134
  OUTPUT: "OUTPUT_REF_INVALID",
2095
2135
  VO_OVERLAP: "VIDEO_VO_OVERLAP",
2096
2136
  AUDIO_DURATION: "VIDEO_AUDIO_DURATION",
2097
- LIPSYNC_MISSING: "VIDEO_LIPSYNC_MISSING"
2137
+ LIPSYNC_MISSING: "VIDEO_LIPSYNC_MISSING",
2138
+ SPEECH_OVERRUN: "VIDEO_SPEECH_OVERRUN",
2139
+ ASPECT_MISMATCH: "VIDEO_ASPECT_MISMATCH"
2098
2140
  };
2099
2141
  var VIDEO_TIME_SLACK_S = 0.75;
2142
+ var SPEECH_WORDS_PER_SECOND = 2.5;
2143
+ var SPEECH_OVERRUN_RATIO = 1.6;
2144
+ function nativeDialogueOf(prompt) {
2145
+ if (typeof prompt !== "string") return void 0;
2146
+ const m = prompt.match(/Dialogue: "(.*)"/);
2147
+ return m?.[1]?.trim() || void 0;
2148
+ }
2100
2149
  function validateCanvas(input, registry) {
2101
2150
  const issues = [];
2102
2151
  const shape = CanvasSchema.safeParse(input);
@@ -2453,6 +2502,44 @@ function checkVideoInvariants(ctx) {
2453
2502
  });
2454
2503
  }
2455
2504
  }
2505
+ checkSpeechOverrun(ctx, meta.talking_scenes);
2506
+ checkAspectConsistency(ctx);
2507
+ }
2508
+ function speechOverrunOf(node) {
2509
+ const params = node.params;
2510
+ if (params?.generate_audio !== true) return null;
2511
+ const line = nativeDialogueOf(params.prompt);
2512
+ const duration = typeof params.duration === "number" ? params.duration : void 0;
2513
+ if (!line || !duration) return null;
2514
+ const estSpeechS = line.split(/\s+/).filter(Boolean).length / SPEECH_WORDS_PER_SECOND;
2515
+ return estSpeechS > duration * SPEECH_OVERRUN_RATIO ? { estSpeechS, duration } : null;
2516
+ }
2517
+ function checkSpeechOverrun(ctx, talkingScenes) {
2518
+ for (const entry of talkingScenes) {
2519
+ if (typeof entry !== "object" || !("voice_convert_node" in entry)) continue;
2520
+ const nativeClipRe = new RegExp(`^s${entry.scene}(_r\\d+)?_clip$`);
2521
+ for (const n of ctx.canvas.nodes) {
2522
+ if (!nativeClipRe.test(n.id) || n.type !== "video_generate") continue;
2523
+ const overrun = speechOverrunOf(n);
2524
+ if (!overrun) continue;
2525
+ ctx.issues.push({
2526
+ path: `nodes[${ctx.idToIndex.get(n.id) ?? -1}].params.prompt`,
2527
+ code: STAGE_CODES.SPEECH_OVERRUN,
2528
+ message: `"${n.id}" asks Seedance to speak ~${Math.round(overrun.estSpeechS * 10) / 10}s of dialogue inside a ${overrun.duration}s clip \u2014 the line cannot fit (>${SPEECH_OVERRUN_RATIO}\xD7 the clip). Shorten the line, split the scene, or lengthen the clip duration`
2529
+ });
2530
+ }
2531
+ }
2532
+ }
2533
+ function checkAspectConsistency(ctx) {
2534
+ const clips = ctx.canvas.nodes.filter((n) => n.type === "video_generate");
2535
+ if (clips.length < 2) return;
2536
+ const ratios = new Set(clips.map((n) => String(n.params?.aspect_ratio ?? "(unset)")));
2537
+ if (ratios.size <= 1) return;
2538
+ ctx.issues.push({
2539
+ path: "nodes",
2540
+ code: STAGE_CODES.ASPECT_MISMATCH,
2541
+ message: `video_generate nodes disagree on aspect_ratio (${[...ratios].sort().join(", ")}) \u2014 the spine's segments would render at different shapes and the composite silently crops. Pin every clip to the same aspect_ratio`
2542
+ });
2456
2543
  }
2457
2544
  function checkOutputRef(ctx) {
2458
2545
  const out = ctx.canvas.output;
@@ -2628,8 +2715,11 @@ var Engine = class {
2628
2715
  }
2629
2716
  async runLayers(canvas, outputs, runId, writer, opts, counters, nodeRuns) {
2630
2717
  const layers = topologicalLayers(this.pruneToOutput(canvas, buildGraph(canvas)));
2718
+ const limit = resolveConcurrency(opts.concurrency);
2631
2719
  for (const layer of layers) {
2632
- const tasks = layer.map(
2720
+ const settled = await mapWithConcurrency(
2721
+ layer,
2722
+ limit,
2633
2723
  (nodeId) => this.executeOne(canvas, nodeId, outputs, runId, writer, opts).then((r) => {
2634
2724
  if (r.cached) counters.cachedNodes++;
2635
2725
  counters.totalCredits += r.credits;
@@ -2645,7 +2735,13 @@ var Engine = class {
2645
2735
  }
2646
2736
  })
2647
2737
  );
2648
- await Promise.all(tasks);
2738
+ const failures = [];
2739
+ settled.forEach((result, i) => {
2740
+ const nodeId = layer[i];
2741
+ if (result.status === "rejected" && nodeId) failures.push({ nodeId, reason: result.reason });
2742
+ });
2743
+ if (failures.length === 1 && failures[0]) throw failures[0].reason;
2744
+ if (failures.length > 1) throw new LayerExecutionError(failures);
2649
2745
  }
2650
2746
  }
2651
2747
  /**
@@ -6238,10 +6334,13 @@ function createEngineFromEnv(opts = {}) {
6238
6334
  }
6239
6335
 
6240
6336
  export {
6337
+ LayerExecutionError,
6338
+ describeFailureReason,
6241
6339
  SEEDANCE_DURATIONS,
6242
6340
  ELEVENLABS_MAX_MUSIC_LENGTH_MS,
6243
6341
  IMAGE_GENERATE_MODELS,
6244
6342
  MODEL_REGISTRY,
6343
+ resolveConcurrency,
6245
6344
  BackendClient2 as BackendClient,
6246
6345
  Engine2 as Engine,
6247
6346
  LocalAssetStore2 as LocalAssetStore,
@@ -6252,4 +6351,4 @@ export {
6252
6351
  defaultRegistry,
6253
6352
  createEngineFromEnv
6254
6353
  };
6255
- //# sourceMappingURL=chunk-3JVYU72O.js.map
6354
+ //# sourceMappingURL=chunk-7K2YAWUT.js.map