@koda-sl/baker-cli 0.129.1-dev.972f3c9aa → 0.131.0-dev.cad5e0bc7

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
@@ -2754,6 +2754,16 @@ baker canvas run my-canvas.json --regenerate gen_4x5,gen_9x16
2754
2754
  # (e.g. "regenerate": 2) and re-run; the fresh render is reproducible in any
2755
2755
  # later session. Bump it again (3, 4, …) for each additional draw.
2756
2756
 
2757
+ # 2e. Push a creative's current node-graph + wired input thumbnails to the
2758
+ # dashboard WITHOUT rendering — so its workflow view reflects an edit (a
2759
+ # swapped logo/hero, a rewired ref, a prompt tweak) the moment you save,
2760
+ # with no credits spent. Runs automatically on every canvas.json/prompt.json
2761
+ # edit (a file watcher), but you can invoke it directly. Accepts the
2762
+ # .canvas.json, its prompt.json, or the creative folder. The rendered image
2763
+ # still only changes on a real `run`, so the dashboard shows an "edited since
2764
+ # last render" badge in the meantime.
2765
+ baker canvas sync-definition src/creatives/<slug>/
2766
+
2757
2767
  # 3. Inspect a finished run (per-node timing, file list, optional video thumbs)
2758
2768
  baker canvas inspect <run_id>
2759
2769
 
@@ -4078,9 +4088,9 @@ It then scaffolds the full pipeline like an **editing timeline**: each clip gets
4078
4088
 
4079
4089
  **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.
4080
4090
 
4081
- **Montage flashes held as stills — unless the picture really moves.** 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. The deconstruct now stamps each scene's **`motion_level`** (`static` / `subtle` / `dynamic`): a **dynamic** flash (pouring chocolate, hands working, walking) keeps a **real trimmed clip** — freezing a moving montage turns it into a slideshow — while genuinely static beats (a logo card, a pinned photo, a product still) keep the cheap hold. Talking/ambient beats always keep a real clip (they need motion + native audio). The deconstruct also stamps each dialogue line's **`on_camera`** flag — a voice playing over b-roll, a graphic, or a mere *photo* of the speaker stays voiceover, so the scaffold never lip-syncs a scene with no speaking face (the polaroid close-up failure).
4091
+ **Montage flashes held as stills — unless the picture really moves.** 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 (looped directly on the spine's input — no billed `video_generate`, no extra node), same look at a fraction of the cost. A beat the deconstruct **explicitly** stamps `static`/`subtle` holds a still up to **~3.5s** (just under the Seedance 4s floor); an unstamped beat keeps the conservative 2s ceiling. The deconstruct now stamps each scene's **`motion_level`** (`static` / `subtle` / `dynamic`): a **dynamic** flash (pouring chocolate, hands working, walking) keeps a **real trimmed clip** — freezing a moving montage turns it into a slideshow — while genuinely static beats (a logo card, a pinned photo, a product still) keep the cheap hold. Talking/ambient beats always keep a real clip (they need motion + native audio). The deconstruct also stamps each dialogue line's **`on_camera`** flag — a voice playing over b-roll, a graphic, or a mere *photo* of the speaker stays voiceover, so the scaffold never lip-syncs a scene with no speaking face (the polaroid close-up failure).
4082
4092
 
4083
- **One clip per shot — separated at complete breaks.** A video is a sequence of clear **shots** with **complete breaks** (hard cuts) between them, and that is what the scaffold separates by: **two adjacent presenter shots at a hard cut become TWO clips**, never glued into one invented take just because the speech runs continuously across the cut. Each presenter shot is one Seedance clip (`s<anchor>_clip`, native lip-sync + audio) re-voiced to the brand voice. What is **NOT** split: a **voiceover** narration stays ONE ElevenLabs `tts` read across the b-roll it plays over, and a **b-roll cutaway** between two on-camera moments leaves the presenter shot continuous — the shown scenes aren't adjacent (the insert sits between them), so the clip covers both on-camera windows (sliced as `s<i>_seg`, an ffmpeg `-ss`/`-t` cut — video+audio from the *same* clip so lip-sync holds) while the cutaway plays its own silent clip over the continuing voice. "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 a cutaway, so the talking head never appears where the original cut away. A single shot longer than the **gateway-safe ~10s clip ceiling** (Seedance's *API* max is 15s, but the gateway often times out — **HTTP 524** — past ~10s) **splits into contiguous takes joined by a shared boundary frame**; the spine then **seam-dedups** that duplicated frame so the concat doesn't freeze on it (`--seam-dedup head|tail|off`, default `head` = drop the second clip's first frame), and clone-pads one frame back so the drop never shortens the picture against the absolute-timed audio. Timbre stays consistent across all the separate shot clips because every clip's native audio is re-voiced in **one merged per-speaker pass** (not per clip). 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.
4093
+ **One clip per shot — separated at complete breaks.** A video is a sequence of clear **shots** with **complete breaks** (hard cuts) between them, and that is what the scaffold separates by: **two adjacent presenter shots at a hard cut become TWO clips**, never glued into one invented take just because the speech runs continuously across the cut. Each presenter shot is one Seedance clip (`s<anchor>_clip`, native lip-sync + audio) re-voiced to the brand voice. What is **NOT** split: a **voiceover** narration stays ONE ElevenLabs `tts` read across the b-roll it plays over, and a **b-roll cutaway** between two on-camera moments leaves the presenter shot continuous — the shown scenes aren't adjacent (the insert sits between them), so the clip covers both on-camera windows (each on-camera window cut out of the clip inside the spine's per-input `trim=` chain — video+audio from the *same* clip so lip-sync holds, no extra nodes) while the cutaway plays its own silent clip over the continuing voice. "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 a cutaway, so the talking head never appears where the original cut away. A single shot longer than the **gateway-safe ~10s clip ceiling** (Seedance's *API* max is 15s, but the gateway often times out — **HTTP 524** — past ~10s) **splits into contiguous takes joined by a shared boundary frame**; the spine then **seam-dedups** that duplicated frame so the concat doesn't freeze on it (`--seam-dedup head|tail|off`, default `head` = drop the second clip's first frame), and clone-pads one frame back so the drop never shortens the picture against the absolute-timed audio. Timbre stays consistent across all the separate shot clips because every clip's native audio is re-voiced in **one merged per-speaker pass** (not per clip). 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.
4084
4094
 
4085
4095
  **A starting point, not a locked render.** The canvas mirrors the reference's structure to give you a faithful scaffold, but `metadata.todo.full_flexibility` makes explicit that the agent has **full editing freedom**: add / delete / reorder / split / merge scenes, re-prompt any frame or motion brief, change a scene's layout (full-frame ↔ composite), or rewrite any line — the content-addressed cache re-bills only what changes, and `baker canvas validate` re-checks timing/lip-sync after any edit.
4086
4096
 
@@ -4092,7 +4102,7 @@ It then scaffolds the full pipeline like an **editing timeline**: each clip gets
4092
4102
 
4093
4103
  **Timing-faithful clip + extract (no overlap).** Each phrase clip is generated to its **coverage window** (the deconstruct's real scene/line timing, capped at the gateway-safe ~10s ceiling) and its converted voice is extracted to the **spoken window** (pause to pause) — *not* padded to a word-count estimate. Padding past the window was what ran the voice the clip's whole length and overlapped the next phrase; trusting the deconstruct's timing keeps consecutive phrases back-to-back and lets Seedance pace the quoted text to fit. `metadata.video.talking_scenes` records each phrase's `scene_s` vs `est_speech_s`; on top of that the scaffold flags any scene whose estimated speech overruns its window by more than ~1.3× as **`metadata.todo.overstuffed_scenes`** (also in the stdout checklist) — a loud advisory to shorten the copy or lengthen the scene before rendering, since an over-stuffed line pushes the picture off the audio timeline. It similarly flags **`oversize_scenes`** — a single scene whose own footage exceeds the gateway-safe ~10s clip ceiling (a b-roll shot or one-shot monologue). The phrase splitter only breaks at scene boundaries, so it can't shrink a single over-long scene; its clip would 524 at the gateway, so the advisory tells you to split that scene into two before rendering.
4094
4104
 
4095
- **Timeline-accurate picture — ONE clock.** Seedance can't render under 4s, so each clip is generated at the smallest allowed duration ≥ the scene length and then **trimmed back to the exact scene duration** before concat. The spine is the ONE clock: every voice, SFX, music, and overlay placement is mapped onto where each scene's picture actually sits in the butted concat (not the reference video's own timestamps, which can carry dead air the picture doesn't reproduce), the audio mix's `total_ms` is pinned to the spine length, and every concat input is normalized to one raster clock (`yuv420p`, 30fps, square pixels, shared timebase) so a generated clip's unadvertised frame rate can never stretch the picture off the audio. The contract is stamped as `metadata.video.timeline` and `baker canvas validate` re-proves it from the live node params after any hand edit. Frames are also prompted as **clean text-free plates** (no baked captions/lower-thirds/tickers/logos-as-text) so the overlay layer is the single source of on-screen text.
4105
+ **Timeline-accurate picture — ONE clock.** Seedance can't render under 4s, so each clip is generated at the smallest allowed duration ≥ the scene length and **cut back to the exact scene duration inside the spine's own per-input filter chain** (no standalone trim nodes — one fewer re-encode per scene, and the graph stays a third smaller). Real screenshots/graphics loop fit-padded, generated stills loop fill-cropped, on the same chains. The spine is the ONE clock: every voice, SFX, music, and overlay placement is mapped onto where each scene's picture actually sits in the butted concat (not the reference video's own timestamps, which can carry dead air the picture doesn't reproduce), the audio mix's `total_ms` is pinned to the spine length, and every concat input is normalized to one raster clock (`yuv420p`, 30fps, square pixels, shared timebase) so a generated clip's unadvertised frame rate can never stretch the picture off the audio. The contract is stamped as `metadata.video.timeline` and `baker canvas validate` re-proves it from the live node params after any hand edit. Frames are also prompted as **clean text-free plates** (no baked captions/lower-thirds/tickers/logos-as-text) so the overlay layer is the single source of on-screen text.
4096
4106
 
4097
4107
  **Scene transitions.** When the deconstruct flags a boundary as `fade`/`whip`/`zoom`/`dissolve`/`swipe` (`scene.transition_out`), the spine reproduces it as an ffmpeg **`xfade`** instead of a hard cut; plain `cut`/`match_cut` stay hard cuts. The overlap is consumed from **extra generated footage** (each transitioning clip is trimmed to `scene_s + transition` and the xfade `offset` lands on the cumulative scene start), so the total length still equals the sum of the scene lengths — the picture stays exactly on the audio timeline.
4098
4108
 
@@ -4104,7 +4114,7 @@ It then scaffolds the full pipeline like an **editing timeline**: each clip gets
4104
4114
 
4105
4115
  **The inspiration video is preserved.** Like `scaffold-static-ad` keeps its reference image, the video command now auto-writes a **`_definition.md`** (so the creative joins the `creatives` collection) recording the source it was built from: `sourceKind: video`, `sourceAdvertiser` (the brand the deconstruct identified, or `--advertiser`), `platform` (`--platform`, default `meta`), and **`sourceReferenceUrl`** — the **durable, content-addressed R2 URL** the deconstruct already uploaded the source to (`prompt.json`'s `source.url`), which the dashboard's Inspiration card plays inline. Unlike the static flow it does **not** commit the video into `references/`: a reference clip can be up to 2 GiB and the video canvas never re-ingests the source at run time (it uses the extracted frame URLs), so a git copy would be pure bloat — the durable R2 URL is the reference. The `_definition.md` is preserved on re-scaffold, and the same `sourceReferenceUrl` is synced to the backend so the creative shows "built from this ad."
4106
4116
 
4107
- 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`) **or its extract window** (`VIDEO_SPEECH_EXCEEDS_EXTRACT` — the spoken window is the real audio budget; a 3s line in a 1.2s window passes the clip check and then gets cut on the spine), and **every clip agrees on one aspect ratio** (`VIDEO_ASPECT_MISMATCH`). The one-clock contract is proven from the LIVE graph after any hand edit: `VIDEO_TIMELINE_TOTAL_MISMATCH` (picture vs pinned audio length — what `-shortest` would silently truncate), `VIDEO_NATIVE_SEG_OVERLAP` (two same-speaker voice windows playing at once — echo), plus advisories `VIDEO_SPINE_UNNORMALIZED` (bare concat inputs), `VIDEO_ODD_DIMENSIONS` (libx264-fatal odd sizes), `VIDEO_REGION_DROPPED` (billed region clips a composite never consumes), `VIDEO_OVERLAY_OUT_OF_BOUNDS` (an overlay window past the video end), and `VIDEO_PROMPT_PROFILE_MISSING` (a video model with no clip-prompt profile — prompts are authored per model family now: Seedance's [brackets] delivery cues vs Veo's prose + no-subtitles rule). When a **photoreal on-camera cast** generates on **Seedance**, the checklist carries a **`content_policy_risk`** note: ByteDance's real-person-likeness filter can reject a photoreal AI face with a **non-retryable 422** (`content_policy_blocked`) that **no prompt reframe clears** — the escapes are regenerating on Veo (`--video-model google/veo-3.1-fast`) or a less-photoreal frame. Surfaced before the billed run so a face-heavy ad isn't discovered broken mid-render. The full editable checklist is embedded as **`metadata.todo`** (with a step-by-step guide in `metadata.description`). The checklist also carries a **`prompt_discipline`** note steering the edit toward the six-decision prompt structure (Route / Spec / Beats / Copy / Technique / Negatives) and on-brand **motion** — easing, transition (cut vs fade), pacing, and accent pulled from `BRAND.md` § Brand in Motion so the overlays move like the client's brand, not the reference's. stdout returns `{ ok, canvas_path, prompt_path, models, stats, checklist }`.
4117
+ 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`) **or its extract window** (`VIDEO_SPEECH_EXCEEDS_EXTRACT` — the spoken window is the real audio budget; a 3s line in a 1.2s window passes the clip check and then gets cut on the spine), and **every clip agrees on one aspect ratio** (`VIDEO_ASPECT_MISMATCH`). The one-clock contract is proven from the LIVE graph after any hand edit: `VIDEO_TIMELINE_TOTAL_MISMATCH` (picture vs pinned audio length — what `-shortest` would silently truncate), `VIDEO_NATIVE_SEG_OVERLAP` (two same-speaker voice windows playing at once — echo), plus advisories `VIDEO_SPINE_UNNORMALIZED` (bare concat inputs), `VIDEO_ODD_DIMENSIONS` (libx264-fatal odd sizes), `VIDEO_REGION_DROPPED` (billed region clips a composite never consumes), `VIDEO_OVERLAY_OUT_OF_BOUNDS` (an overlay window past the video end), and `VIDEO_PROMPT_PROFILE_MISSING` (a video model with no clip-prompt profile — prompts are authored per model family now: Seedance's [brackets] delivery cues vs Veo's prose + no-subtitles rule). When a **photoreal on-camera cast** generates on **Seedance**, the checklist carries a **`content_policy_risk`** note: ByteDance's real-person-likeness filter can reject a photoreal AI face with a **non-retryable 422** (`content_policy_blocked`) that **no prompt reframe clears** — the escapes are regenerating on Veo (`--video-model google/veo-3.1-fast`) or a less-photoreal frame. Surfaced before the billed run so a face-heavy ad isn't discovered broken mid-render. The full editable checklist is embedded as **`metadata.todo`** (with a step-by-step guide in `metadata.description`). The checklist also carries a **`prompt_discipline`** note steering the edit toward the six-decision prompt structure (Route / Spec / Beats / Copy / Technique / Negatives) and on-brand **motion** — easing, transition (cut vs fade), pacing, and accent pulled from `BRAND.md` § Brand in Motion so the overlays move like the client's brand, not the reference's. A short (≤4s) b-roll take also skips its **end keyframe** (one billed image instead of two — over so short a span the second keyframe adds no motion guidance), unless the next scene continues the take and needs the shared splice frame. The canvas stamps **`metadata.video.graph_stats`** (node totals + histogram + one route per scene: `clip` / `still_hold` / `screen_still` / `graphic_plate` / `brand_card` / `phrase_slice` / `phrase_run` / `composite`) — the first thing to read when you wonder why the graph has a node — mirrored in stdout under `stats.graph`. `baker canvas validate` additionally warns `ORPHAN_NODE` on any non-ingest node unreachable from the output (it would execute — and bill — for nothing). stdout returns `{ ok, canvas_path, prompt_path, models, stats, checklist }`.
4108
4118
 
4109
4119
  ```bash
4110
4120
  baker canvas scaffold-video ./reference-ad.mp4 --focus "competitor UGC ad for <brand>"
@@ -1995,7 +1995,17 @@ var VideoMeta = z.object({
1995
1995
  // Per video_generate node: the scene's natural visual span. The validator warns
1996
1996
  // when a clip's span exceeds the assigned model's max clip duration (e.g. a 9.2s
1997
1997
  // scene on Veo, which caps at 8s) — the clip would truncate.
1998
- clip_spans: z.array(z.object({ node: z.string(), span_s: z.number() })).optional()
1998
+ clip_spans: z.array(z.object({ node: z.string(), span_s: z.number() })).optional(),
1999
+ // WHY the graph has the nodes it has: one route per scene (clip / still_hold /
2000
+ // screen_still / graphic_plate / brand_card / phrase_slice / phrase_run /
2001
+ // composite) + a node-type histogram, stamped by scaffold-video. Read this first
2002
+ // when a canvas looks big.
2003
+ graph_stats: z.object({
2004
+ nodes_total: z.number(),
2005
+ nodes_billable: z.number(),
2006
+ node_types: z.record(z.string(), z.number()),
2007
+ scene_routes: z.array(z.object({ scene: z.number(), route: z.string(), window_s: z.number() }))
2008
+ }).strict().optional()
1999
2009
  }).strict().optional();
2000
2010
  var CanvasMetadata = z.object({
2001
2011
  name: z.string().optional(),
@@ -2519,6 +2529,41 @@ function imageProfileFor(modelId) {
2519
2529
  return void 0;
2520
2530
  }
2521
2531
 
2532
+ // src/engine/scaffold/spine-input.ts
2533
+ function spineInputFlags(work, lenS) {
2534
+ return work.still ? ["-loop", "1", "-t", lenS.toFixed(3)] : [];
2535
+ }
2536
+ function spineInputOps(work, lenS, dims) {
2537
+ const fit = work.still?.fit ?? "fill";
2538
+ const scale = fit === "pad" ? `scale=${dims.w}:${dims.h}:force_original_aspect_ratio=decrease,pad=${dims.w}:${dims.h}:(ow-iw)/2:(oh-ih)/2:color=black` : `scale=${dims.w}:${dims.h}:force_original_aspect_ratio=increase,crop=${dims.w}:${dims.h}`;
2539
+ const normalize = `${scale},format=yuv420p,fps=30,setsar=1,settb=AVTB`;
2540
+ if (work.trim) {
2541
+ const start = work.trim.offset_s > 0 ? `start=${work.trim.offset_s.toFixed(3)}:` : "";
2542
+ return `${normalize},trim=${start}duration=${lenS.toFixed(3)},setpts=PTS-STARTPTS`;
2543
+ }
2544
+ if (work.still) return `${normalize},trim=duration=${lenS.toFixed(3)}`;
2545
+ return normalize;
2546
+ }
2547
+ function parseSpineInputLens(args) {
2548
+ const lens = /* @__PURE__ */ new Map();
2549
+ let inputIndex = 0;
2550
+ for (let i = 0; i < args.length; i++) {
2551
+ if (args[i] !== "-i") continue;
2552
+ if (args[i - 2] === "-t") {
2553
+ const v = Number(args[i - 1]);
2554
+ if (Number.isFinite(v)) lens.set(inputIndex, v);
2555
+ }
2556
+ inputIndex++;
2557
+ }
2558
+ const graph = args.join(" ");
2559
+ for (const m of graph.matchAll(/\[(\d+):v\][^;[]*?trim=(?:start=[\d.]+:)?duration=([\d.]+)/g)) {
2560
+ const idx = Number(m[1]);
2561
+ const v = Number(m[2]);
2562
+ if (Number.isFinite(idx) && Number.isFinite(v)) lens.set(idx, v);
2563
+ }
2564
+ return lens;
2565
+ }
2566
+
2522
2567
  // src/engine/engine/define.ts
2523
2568
  function resolveOutputKinds(spec, params) {
2524
2569
  if (!spec) return {};
@@ -2614,7 +2659,8 @@ var STAGE_CODES = {
2614
2659
  SPINE_UNNORMALIZED: "VIDEO_SPINE_UNNORMALIZED",
2615
2660
  ODD_DIMENSIONS: "VIDEO_ODD_DIMENSIONS",
2616
2661
  REGION_DROPPED: "VIDEO_REGION_DROPPED",
2617
- OVERLAY_OUT_OF_BOUNDS: "VIDEO_OVERLAY_OUT_OF_BOUNDS"
2662
+ OVERLAY_OUT_OF_BOUNDS: "VIDEO_OVERLAY_OUT_OF_BOUNDS",
2663
+ ORPHAN_NODE: "ORPHAN_NODE"
2618
2664
  };
2619
2665
  var SPAN_MODEL_SLACK_S = 0.25;
2620
2666
  var VIDEO_TIME_SLACK_S = 0.75;
@@ -2678,6 +2724,7 @@ function validateCanvas(input, registry) {
2678
2724
  checkAllSlots(ctx);
2679
2725
  const estimatedCredits = estimateCredits(ctx);
2680
2726
  checkOutputRef(ctx);
2727
+ checkOrphanNodes(ctx);
2681
2728
  checkVideoInvariants(ctx);
2682
2729
  const hasBlocking = issues.some(isBlocking);
2683
2730
  if (hasBlocking) return { ok: false, issues };
@@ -3207,8 +3254,11 @@ function spineTotalS(ctx, spine) {
3207
3254
  let total = 0;
3208
3255
  const inputs = Object.entries(spine.inputs ?? {}).filter(([k]) => /^c\d+$/.test(k));
3209
3256
  if (inputs.length === 0) return null;
3210
- for (const [, v] of inputs) {
3211
- const d = liveNodeDurationS(ctx, refNodeOf(ctx, v));
3257
+ const rawArgs = spine.params.args;
3258
+ const absorbed = Array.isArray(rawArgs) ? parseSpineInputLens(rawArgs.map(String)) : /* @__PURE__ */ new Map();
3259
+ for (const [k, v] of inputs) {
3260
+ const inputIdx = Number(k.slice(1));
3261
+ const d = absorbed.get(inputIdx) ?? liveNodeDurationS(ctx, refNodeOf(ctx, v));
3212
3262
  if (d === null) return null;
3213
3263
  total += d;
3214
3264
  }
@@ -3550,6 +3600,28 @@ function checkAspectConsistency(ctx) {
3550
3600
  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`
3551
3601
  });
3552
3602
  }
3603
+ function checkOrphanNodes(ctx) {
3604
+ const out = ctx.canvas.output;
3605
+ if (!out || !ctx.idToIndex.has(out.node)) return;
3606
+ const graph = buildDepGraph(ctx.canvas);
3607
+ const reachable = /* @__PURE__ */ new Set();
3608
+ const stack = [out.node];
3609
+ while (stack.length > 0) {
3610
+ const id = stack.pop();
3611
+ if (id === void 0 || reachable.has(id)) continue;
3612
+ reachable.add(id);
3613
+ for (const dep of graph.get(id) ?? []) stack.push(dep);
3614
+ }
3615
+ for (const n of ctx.canvas.nodes) {
3616
+ if (reachable.has(n.id) || n.type === "ingest") continue;
3617
+ ctx.issues.push({
3618
+ path: `nodes[${ctx.idToIndex.get(n.id)}]`,
3619
+ code: STAGE_CODES.ORPHAN_NODE,
3620
+ severity: "warning",
3621
+ message: `node "${n.id}" (${n.type}) is not reachable from output "${out.node}" \u2014 it will still execute (and bill, if billable) but nothing consumes its result; wire it in or remove it`
3622
+ });
3623
+ }
3624
+ }
3553
3625
  function checkOutputRef(ctx) {
3554
3626
  const out = ctx.canvas.output;
3555
3627
  if (!out) return;
@@ -7610,6 +7682,8 @@ export {
7610
7682
  clipProfileFor,
7611
7683
  clipParamRecipe,
7612
7684
  imageProfileFor,
7685
+ spineInputFlags,
7686
+ spineInputOps,
7613
7687
  elementMentionKeywords,
7614
7688
  toModelSafeImage,
7615
7689
  BackendClient2,
@@ -7622,4 +7696,4 @@ export {
7622
7696
  defaultRegistry,
7623
7697
  createEngineFromEnv
7624
7698
  };
7625
- //# sourceMappingURL=chunk-J2LYFDVC.js.map
7699
+ //# sourceMappingURL=chunk-5LCTMZJ5.js.map