@koda-sl/baker-cli 0.123.0-dev.4a85b9f30 → 0.123.0-dev.70bf43ce4

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
@@ -2672,18 +2672,6 @@ baker canvas run my-canvas.json --parallel 8
2672
2672
  # browsable outputs — pass --no-record too if you want nothing persisted.
2673
2673
  baker canvas run my-canvas.json --remote-cache off --no-record
2674
2674
 
2675
- # 2d. Regenerate a node whose prompt is fine (force a fresh roll). The engine is
2676
- # content-addressed: re-running an UNCHANGED node returns the identical cached
2677
- # render, never a new draw. To re-roll a node without editing its prompt (a
2678
- # color drifted, a face came out wrong), force it fresh two ways — NEVER
2679
- # restructure the canvas (repointing output / deleting nodes) to trick the cache:
2680
- # • One-shot flag — forces the named nodes + everything downstream fresh this
2681
- # run, leaving every other node cached (unknown ids fail loudly before billing):
2682
- baker canvas run my-canvas.json --regenerate gen_4x5,gen_9x16
2683
- # • Persistent — add/bump a node's `regenerate` field in the canvas JSON
2684
- # (e.g. "regenerate": 2) and re-run; the fresh render is reproducible in any
2685
- # later session. Bump it again (3, 4, …) for each additional draw.
2686
-
2687
2675
  # 3. Inspect a finished run (per-node timing, file list, optional video thumbs)
2688
2676
  baker canvas inspect <run_id>
2689
2677
 
@@ -3378,9 +3366,9 @@ Accepted ref-image MIMEs vary by model — see per-model sections below.
3378
3366
 
3379
3367
  ###### Model: `bytedance/seedance-2.0`
3380
3368
 
3381
- Production-quality ad-creative model. Routed via **Replicate** (`bytedance/seedance-2.0`). NOTE: ByteDance's upstream "real person" likeness filter still blocks photorealistic human reference frames on **any** reseller — the escape is a synthetic/AI presenter face or routing real faces to Veo, not the provider.
3369
+ Production-quality ad-creative model. Routed via **fal.ai** (not OpenRouter) because OpenRouter's Seedance passthrough rejects photorealistic human reference frames via ByteDance's "real person" safety filter.
3382
3370
 
3383
- Ref-image MIMEs: `image/png`, `image/jpeg`, `image/webp`.
3371
+ Ref-image MIMEs: `image/png`, `image/jpeg`, `image/webp` (via fal.ai).
3384
3372
 
3385
3373
  | Name | Type | Required | Notes |
3386
3374
  |---|---|---|---|
@@ -3434,7 +3422,7 @@ Ref-image MIMEs: `image/png`, `image/jpeg`, `image/webp`, `image/gif` (via OpenR
3434
3422
  >
3435
3423
  > A scaffolded canvas carries this table inline at `metadata.todo.model_constraints`.
3436
3424
 
3437
- > **Content-policy blocks are deterministic, not flaky.** ByteDance's Seedance rejects any first/last frame that reads as a real-person likeness (even an AI-generated face) — surfaced as `content_policy_blocked` (HTTP 422, **non-retryable**). This is ByteDance's upstream filter, so it fires on **any** reseller (Replicate or otherwise) — a provider swap does **not** route around it. Retrying **never** succeeds and wastes credits. Fix the cause: use a **synthetic/AI-generated** (non-identifiable) presenter face, or route a real face to **Veo** (`person_generation: allow_adult`), or make the source frame less photorealistic.
3425
+ > **Content-policy blocks are deterministic, not flaky.** fal.ai/Seedance rejects any first/last frame that reads as a real-person likeness (even an AI-generated face) — surfaced as `content_policy_blocked` (HTTP 422, **non-retryable**), even when fal's proxy chain masks it as a 5xx. Retrying **never** succeeds and wastes credits. Fix the cause: switch the clip to the other curated model (Veo routes around fal's filter), or make the source frame less photorealistic.
3438
3426
 
3439
3427
  ---
3440
3428
 
@@ -3485,7 +3473,7 @@ None.
3485
3473
 
3486
3474
  ##### `video_lipsync`
3487
3475
 
3488
- Lip-sync a video to an audio track via Sync Labs `sync/lipsync-2` (Replicate).
3476
+ Lip-sync a video to an audio track via VEED (fal.ai).
3489
3477
 
3490
3478
  **Inputs**
3491
3479
 
@@ -3512,7 +3500,7 @@ Lip-sync a video to an audio track via Sync Labs `sync/lipsync-2` (Replicate).
3512
3500
 
3513
3501
  ##### `video_background_remove`
3514
3502
 
3515
- Strip a video's background → transparent alpha WebM (VP9) or MOV (ProRes 4444). Powered by `sprited/birefnet-video` (Replicate).
3503
+ Strip a video's background → alpha WebM/H264. Powered by fal.ai VEED.
3516
3504
 
3517
3505
  **Inputs**
3518
3506
 
@@ -3688,7 +3676,7 @@ Place and mix several audio clips onto one timeline — a music bed plus timed v
3688
3676
 
3689
3677
  ##### `image_background_remove`
3690
3678
 
3691
- Strip background → transparent PNG. Powered by `men1scus/birefnet` (Replicate). (Note: mask-only output is no longer produced.)
3679
+ Strip background → transparent PNG (or mask). Powered by fal.ai BiRefNet v2.
3692
3680
 
3693
3681
  **Inputs**
3694
3682
 
@@ -3939,20 +3927,20 @@ Validate, then execute the graph. Blocks until done. Logs one line per node. Ret
3939
3927
 
3940
3928
  #### `baker canvas scaffold-video <video> [flags]`
3941
3929
 
3942
- Turn a reference video into a **runnable, self-validated reproduction canvas** in one command — the video counterpart of `scaffold-static-ad`. The `<video>` positional is a **local path OR an http(s) URL** (a `baker winning-ads` `media_url`, a library URL, any reel link) — a URL is downloaded for you (no manual `curl` first), so pass `--slug`/`--out` with it to give the canvas a home. It runs **billed passes** up front:
3930
+ Turn a reference video into a **runnable, self-validated reproduction canvas** in one command — the video counterpart of `scaffold-static-ad`. It runs **billed passes** up front:
3943
3931
 
3944
- 1. **`video_deconstruct`** (`~google/gemini-pro-latest`, full mode) — reverse-engineers the video into a scene-by-scene blueprint + word-level transcript, written next to the canvas as **`prompt.json`** (the human-editable source of truth). Each scene's `start_frame_prompt`/`end_frame_prompt` are inlined into the frame nodes (see below); the shared **global style reference** every frame reads via `target_blueprint` is a **slim projection** written alongside as **`prompt.style.json`** (`global` cast/palette/brand + `reference_elements` only, no per-scene array). A 33-scene blueprint is ~200 KB — inlining it into every one of a dozen frame prompts was pure waste and let a frame blend in another scene's content; the slim is ~5 KB. Edit `prompt.json` and re-scaffold to refresh the style projection.
3932
+ 1. **`video_deconstruct`** (`~google/gemini-pro-latest`, full mode) — reverse-engineers the video into a scene-by-scene blueprint + word-level transcript, written next to the canvas as **`prompt.json`**. Each scene's `start_frame_prompt`/`end_frame_prompt` are inlined into the frame nodes (see below); `prompt.json` then rides along as the shared **global style reference** (palette, cast cohesion) and as provenance.
3945
3933
  2. **recurring-element selection** (`~google/gemini-flash-latest`) — picks only the **recurring, identity-critical** elements (each `global.cast` person, a recurring animal, a showcased product, the brand logo) and the scene indices each appears in. One real reference image grounds each element across **every** frame it appears in, so the same actor stays consistent the whole video. This selection runs as a **second pass over a slimmed blueprint** (cast/branding + each scene's frame prompts only) — a long ad's full blueprint can exceed the engine's inline-prompt limit, so the heavy per-scene detail (dialogue, overlays, transcript) the selector never reads is dropped before the prompt.
3946
3934
 
3947
3935
  Before the deconstruct it runs a **local shot-cut pass** on the source file with **[PySceneDetect](https://www.scenedetect.com)** (`scenedetect` CLI, `detect-content` — the battle-tested HSV content detector, installed in the canvas sandbox) and passes the cut timestamps as `video_deconstruct`'s `shot_cuts`. The deconstruct snaps its scene boundaries onto those real cuts and **splits any scene that spans one**, so a scene's frames can never straddle a hard cut (the failure where a scene's start frame was the couch and its end frame the b-roll). Two knobs tuned for fast social ads: the content **threshold defaults to 18** (PySceneDetect's own default of 27 misses soft reframes) and the **minimum scene length is dropped to 0.25s** (its default ~0.6s merges away rapid montage flashes) — so super-fast cuts survive and become cheap still-holds downstream. The threshold is **adaptive**: if the first pass looks like a continuous shot shredded into many close micro-cuts (a talking-head selfie's natural motion), it re-runs at PySceneDetect's own default of 27 and **merges the two passes** — the high-threshold set is the base, and the low pass's *isolated* extras (real soft blur-morph transitions that vanish at 27) are added back while clustered extras (motion shred) stay dropped. Pinning **`--shot-threshold N`** disables the re-check (lower = more cuts). The backend snap window is likewise **adaptive** (up to 1s onto an unambiguous nearest cut, shrinking around dense cut pairs so a boundary never jumps past the wrong cut), any scene spanning an interior cut is split, and the residual-sliver coalesce is **cut-aware**: a drift sliver folds backward across its non-cut edge and never re-merges across a real cut. If `scenedetect` is unavailable it warns loudly and degrades to LLM-only boundaries.
3948
3936
 
3949
3937
  A shot longer than the video model's per-clip ceiling (Seedance's 15s, passed as `video_deconstruct`'s `max_clip_s`) is split into equal **continuation sub-scenes** that share their splice boundary exactly — so a long shot is reproduced in **full** (no truncation) and joins seamlessly. Each sub-scene carries `continues_previous`.
3950
3938
 
3951
- It then scaffolds the full pipeline like an **editing timeline**: each clip gets a **static-ad-grade start AND end keyframe** (`image_generate`, each with its **own self-contained `params.prompt`** — edit a frame node to change only that frame; the slim `prompt.style.json` wired as the **shared `target_blueprint`** style reference, plus a per-element reference legend). Each keyframe is **fully recast** to the dropped `el_*` reference images. The original extracted frame is kept LAST as a **pure composition anchor** (framing / camera angle / shot size / pose) whenever identity is safely locked — i.e. a frame with no person/animal, OR every cast member present is **sheet-backed** (a multi-view turnaround owns identity, so the anchor can reproduce the source's framing without dictating the face). Since every base element is now sheet-backed by default, cast frames keep their framing anchor too — this is what reproduces the source's composition (a side-profile stays a side-profile, the camera angle holds scene to scene) instead of drifting to a fresh guess. The anchor's legend forbids taking identity/text/palette from it. It is dropped only when a cast member rests on a weak lone-snapshot reference (e.g. a `same_as` second-look slot), where the original frame could re-leak the source actor. Both keyframes feed `video_generate` (`first_frame`+`last_frame`, so Seedance interpolates real in-shot motion; ultra-detailed motion brief; duration snapped to the nearest allowed clip length). Every keyframe grounds **only on its own extracted frame + `el_*` slots** — no reference to any other generated frame — so all images render **in parallel** (no cascade). Source-frame URLs are **deduped** (each ingested once). `--frames reuse` wires the real source frame straight in.
3939
+ It then scaffolds the full pipeline like an **editing timeline**: each clip gets a **static-ad-grade start AND end keyframe** (`image_generate`, each with its **own self-contained `params.prompt`** — edit a frame node to change only that frame; `prompt.json` wired as the **authoritative shared `target_blueprint`**, plus a per-element reference legend). Each keyframe is **fully recast** to the dropped `el_*` reference images. The original extracted frame is kept LAST as a **pure composition anchor** (framing / camera angle / shot size / pose) whenever identity is safely locked — i.e. a frame with no person/animal, OR every cast member present is **sheet-backed** (a multi-view turnaround owns identity, so the anchor can reproduce the source's framing without dictating the face). Since every base element is now sheet-backed by default, cast frames keep their framing anchor too — this is what reproduces the source's composition (a side-profile stays a side-profile, the camera angle holds scene to scene) instead of drifting to a fresh guess. The anchor's legend forbids taking identity/text/palette from it. It is dropped only when a cast member rests on a weak lone-snapshot reference (e.g. a `same_as` second-look slot), where the original frame could re-leak the source actor. Both keyframes feed `video_generate` (`first_frame`+`last_frame`, so Seedance interpolates real in-shot motion; ultra-detailed motion brief; duration snapped to the nearest allowed clip length). Every keyframe grounds **only on its own extracted frame + `el_*` slots** — no reference to any other generated frame — so all images render **in parallel** (no cascade). Source-frame URLs are **deduped** (each ingested once). `--frames reuse` wires the real source frame straight in.
3952
3940
 
3953
3941
  **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.
3954
3942
 
3955
- **Typed region kinds & real screen surfaces.** Each composition region now carries a `kind` — `camera` (filmed footage, re-generated), `screen_capture` (app/site/document screen recording), `static_graphic` (designed text/graphic panel), or `generated` (3D/motion graphics) — plus an optional `nested` list for video-in-video (a Loom-style camera bubble inside a screen share). `kind` is authoritative for routing (prose keywords remain the fallback for older blueprints): `screen_capture`/`static_graphic` regions are **never generated by the video model** — the scene renders as a clean background plate (its clip prompt is scrubbed of all screen narration and forbids rendering UI) and the real surface is composited on the overlay layer. The route is decided **once per persistent layout run** (consecutive scenes sharing one composition signature), so a layout that runs unbroken across many scenes can't flip between pipelines on wording differences. A persistent surface seeds **ONE grouped stub** in `video-overlay-composition/index.html` spanning its whole window, with a per-scene **state timeline** — build one continuous screen recording/mockup, not one screenshot per scene. A `screen_capture` region also carries `surface_id`: a source video routinely **splices two unrelated screen recordings** under one persistent layout (a live app-processing capture, then an unrelated pre-made demo note) — the deconstruct assigns a stable id while the SAME recording continues and a new one when the on-screen content genuinely changes, so the run splits into **separate stubs** at the splice instead of asking for one screenshot that can't cover both. Full-frame screen scenes reuse the same `surface_id`: consecutive full-frame UI beats of one screen (e.g. a 3-scene import flow) share **ONE `s<i>_screen_ref` ingest** — the operator supplies that screenshot once instead of dropping the same capture into a dozen identical `[TODO]`s (distinct surfaces stay distinct). `baker canvas validate` additionally warns (`VIDEO_UI_IN_PROMPT`) if any clip prompt still narrates a screen surface, and (`VIDEO_BRANDMARK_IN_PROMPT`) if a generate prompt asks the model to paint a brand logo/wordmark (generation garbles marks; source the real one with `baker images logo` and composite it on the overlay layer).
3943
+ **Typed region kinds & real screen surfaces.** Each composition region now carries a `kind` — `camera` (filmed footage, re-generated), `screen_capture` (app/site/document screen recording), `static_graphic` (designed text/graphic panel), or `generated` (3D/motion graphics) — plus an optional `nested` list for video-in-video (a Loom-style camera bubble inside a screen share). `kind` is authoritative for routing (prose keywords remain the fallback for older blueprints): `screen_capture`/`static_graphic` regions are **never generated by the video model** — the scene renders as a clean background plate (its clip prompt is scrubbed of all screen narration and forbids rendering UI) and the real surface is composited on the overlay layer. The route is decided **once per persistent layout run** (consecutive scenes sharing one composition signature), so a layout that runs unbroken across many scenes can't flip between pipelines on wording differences. A persistent surface seeds **ONE grouped stub** in `video-overlay-composition/index.html` spanning its whole window, with a per-scene **state timeline** — build one continuous screen recording/mockup, not one screenshot per scene. A `screen_capture` region also carries `surface_id`: a source video routinely **splices two unrelated screen recordings** under one persistent layout (a live app-processing capture, then an unrelated pre-made demo note) — the deconstruct assigns a stable id while the SAME recording continues and a new one when the on-screen content genuinely changes, so the run splits into **separate stubs** at the splice instead of asking for one screenshot that can't cover both. `baker canvas validate` additionally warns (`VIDEO_UI_IN_PROMPT`) if any clip prompt still narrates a screen surface, and (`VIDEO_BRANDMARK_IN_PROMPT`) if a generate prompt asks the model to paint a brand logo/wordmark (generation garbles marks; source the real one with `baker images logo` and composite it on the overlay layer).
3956
3944
 
3957
3945
  **Designed graphics are rebuilt, not generated.** A `static_graphic` surface (a newspaper-collage panel, a meme card, a marketing composition) seeds a **GRAPHIC PANEL** stub — rebuild it as brand HTML or drop the design asset; it never gets the "screenshot the live page" instruction (there is no live page). A **full-frame** designed-graphic scene (the deconstruct emits one full-frame `static_graphic` region for meme/collage/motion-graphic beats) routes to a real design plate the same way screens do — no `image_generate`/`video_generate` — and dialogue over an all-graphic scene is voiceover by definition (nobody is on screen to lip-sync). A region typed `generated` whose own prose reads like a UI/designed panel is treated as a surface candidate too (the frame-grounded continuity checker delivers the verdict and corrects the kind), so one mistyped kind can't re-open the Seedance-paints-UI hole. Floating FX elements (hearts, sparkles, badges) ride the overlay layer: their narration is **scrubbed from clip briefs** and a categorical no-decorations directive is added, so the model can't bake a second, uneditable copy under the real composited one.
3958
3946
 
@@ -3964,13 +3952,13 @@ It then scaffolds the full pipeline like an **editing timeline**: each clip gets
3964
3952
 
3965
3953
  **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).
3966
3954
 
3967
- **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. **Consecutive** shown scenes of one phrase clip are **merged into a single segment** (they're one continuous take with no real cut between them) — a run that tiles the whole clip rides the raw clip with no `_seg` at all, so a fast montage no longer explodes into one trim per micro-scene. "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 (joined by the shared boundary frame), so a sliced window never reads past its clip. A run also splits when its **accumulated spoken words exceed ~16** — Seedance's lip-sync drifts on a long continuous monologue (ByteDance's own guidance is ~5–10 words per line), so a dense read is broken into shorter, lip-sync-clean takes at the same boundary (a few more billed clips for legible mouths). (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.
3955
+ **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.
3968
3956
 
3969
3957
  **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.
3970
3958
 
3971
3959
  **Sequenced audio.** Dialogue is a back-and-forth on one absolute timeline, so each **contiguous same-speaker turn** becomes its own `tts` placed at its real `start_s` — turns alternate and never stack (the earlier design concatenated each speaker's whole monologue at their earliest timestamp, so two voices played in parallel for the entire video). Each speaker is locked to one shared `voice_select` voice; a `sound_effect` per SFX and a `music` bed (conditioned on the **ad's own script + emotional arc** so the bed supports the message, styled after the AudD-identified track when available, ducked under the voices, and started at the reference's `music.starts_at_s` rather than always at 0) round out the mix (`audio_timeline`). The final mux normalizes the soundtrack to **−14 LUFS (stereo)** so the output plays loud in every player — the raw mix is quiet mono, which reads as "no sound."
3972
3960
 
3973
- **Native talking heads + one voice per person (no post-hoc lip-sync).** Seedance 2.0 generates lip-synced speech **natively** — a presenter phrase puts the full phrase in the clip's prompt with `generate_audio`, so lips and voice are generated together (no `video_lipsync`/veed). Each presenter phrase's audio is extracted (the spoken window only) and the extracts are merged **per speaker** onto one timeline, then re-voiced through a **single** `audio_voice_convert` (`<voice>_conv`, ElevenLabs Voice Changer) to the brand voice one STS pass over the whole track instead of a convert node per clip, so it's fewer nodes, fewer calls, and a more consistent brand timbre (composite scenes already share this path); timing is preserved so the lips stay matched. There is **ONE voice per person**: a single `voice_select` is reused for all that person's phrases, and the deconstruct's `voiceover` label folds into the sole on-camera presenter (so on-camera and off-camera narration are the same voice, not two). A scene with **two speakers both on screen** can't be one clip — both lines become `tts` over a plain scene clip. But a scene with **one on-camera speaker trading lines with an OFF-camera voice** (an interviewer, a heard-but-not-shown assistant) keeps the on-camera speaker **native** (lip-synced) and reads the off-camera line as `tts` — "on screen" is decided by the speaker's element presence, so a heard-but-unshown voice no longer drops the whole scene to a silent clip. Every `tts` node is stamped with the spoken track's **`language_code`** when the blueprint states a language (cast localization note / voiceover persona / voice description), so numbers and units are read in the target tongue instead of ElevenLabs' English default (the "6900 read in English" bug). For **NATIVE (Seedance) lines** — which carry no language tag — the scaffold additionally **spells numerals into target-language words** across every part of the clip prompt Seedance can vocalize (the spoken line, the scene summary/action/motion, the transcript), so a French "6930 ?" becomes "six mille neuf cent trente ?" and is never read as English digits. Spelling covers **every language the blueprint can resolve** (fr, es, en, de, it, pt, nl, pl, ar, ja, ko, hi — via `n2words`); a language outside that set leaves digits (the `tts` path still localizes them via `language_code`).
3961
+ **Native talking heads + one voice per person (no post-hoc lip-sync).** Seedance 2.0 generates lip-synced speech **natively** — a presenter phrase puts the full phrase in the clip's prompt with `generate_audio`, so lips and voice are generated together (no `video_lipsync`/veed). Each presenter phrase's audio is extracted and re-voiced through a **per-phrase** `audio_voice_convert` (ElevenLabs Voice Changer; one per phrase keeps each ≤15s clip under the converter's length cap) to the brand voice timing preserved so the lips stay matched. There is **ONE voice per person**: a single `voice_select` is reused for all that person's phrases, and the deconstruct's `voiceover` label folds into the sole on-camera presenter (so on-camera and off-camera narration are the same voice, not two). A scene with **two speakers both on screen** can't be one clip — both lines become `tts` over a plain scene clip. But a scene with **one on-camera speaker trading lines with an OFF-camera voice** (an interviewer, a heard-but-not-shown assistant) keeps the on-camera speaker **native** (lip-synced) and reads the off-camera line as `tts` — "on screen" is decided by the speaker's element presence, so a heard-but-unshown voice no longer drops the whole scene to a silent clip. Every `tts` node is stamped with the spoken track's **`language_code`** when the blueprint states a language (cast localization note / voiceover persona / voice description), so numbers and units are read in the target tongue instead of ElevenLabs' English default (the "6900 read in English" bug). For **NATIVE (Seedance) lines** — which carry no language tag — the scaffold additionally **spells numerals into target-language words** across every part of the clip prompt Seedance can vocalize (the spoken line, the scene summary/action/motion, the transcript), so a French "6930 ?" becomes "six mille neuf cent trente ?" and is never read as English digits. Spelling covers **every language the blueprint can resolve** (fr, es, en, de, it, pt, nl, pl, ar, ja, ko, hi — via `n2words`); a language outside that set leaves digits (the `tts` path still localizes them via `language_code`).
3974
3962
 
3975
3963
  **Same-shot lip-sync caution.** A single held shot can carry only ONE lip-synced clip (voiceover turns must not overlap, and Seedance generates one clip per shot), so when the on-camera speaker has further turns in that shot (a rapid "3000? … 4000?" with an off-camera "Plus" between), the first turn is native and the rest play as `tts` over the same clip — where the mouth no longer matches those words. This is inherent to reproducing sparse same-shot dialogue, not a wiring fault; the scaffold lists the affected scenes/lines in **`metadata.video.lip_sync_caution`** (advisory, never gated) so you can cut away to b-roll over those lines or rely on the burned-in captions that already show them.
3976
3964
 
@@ -3986,9 +3974,7 @@ It then scaffolds the full pipeline like an **editing timeline**: each clip gets
3986
3974
 
3987
3975
  **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).
3988
3976
 
3989
- **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."
3990
-
3991
- 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`). 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`). stdout returns `{ ok, canvas_path, prompt_path, models, stats, checklist }`.
3977
+ 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 }`.
3992
3978
 
3993
3979
  ```bash
3994
3980
  baker canvas scaffold-video ./reference-ad.mp4 --focus "competitor UGC ad for <brand>"
@@ -780,9 +780,7 @@ function failedJobError(error) {
780
780
  retryable: error.retryable ?? false
781
781
  });
782
782
  }
783
- function pollInterval(attempt) {
784
- return attempt < 15 ? 1e3 : 3e3;
785
- }
783
+ var JOB_POLL_INTERVAL_MS = 3e3;
786
784
  var JOB_POLL_MAX_MS = 20 * 60 * 1e3;
787
785
  var BackendClient = class {
788
786
  http;
@@ -799,7 +797,7 @@ var BackendClient = class {
799
797
  async pollJob(jobId, signal) {
800
798
  const deadline = Date.now() + JOB_POLL_MAX_MS;
801
799
  const path16 = `/api/canvas/jobs/${encodeURIComponent(jobId)}`;
802
- for (let attempt = 0; ; attempt++) {
800
+ while (true) {
803
801
  if (signal?.aborted) {
804
802
  throw new BackendHttpError({ kind: "network", cause: signal.reason ?? new Error("aborted") });
805
803
  }
@@ -809,7 +807,7 @@ var BackendClient = class {
809
807
  if (Date.now() > deadline) {
810
808
  throw new BackendHttpError({ kind: "timeout", message: `job ${jobId} did not finish in time` });
811
809
  }
812
- await sleep(pollInterval(attempt));
810
+ await sleep(JOB_POLL_INTERVAL_MS);
813
811
  }
814
812
  }
815
813
  presignAssetUpload(sha256, mime, signal) {
@@ -840,14 +838,6 @@ var BackendClient = class {
840
838
  async recordRun(payload, signal) {
841
839
  await this.http.postJson("/api/canvas/runs", payload, signal);
842
840
  }
843
- /**
844
- * Chat-scoped blueprint sync — POST /api/creatives/definition. Lets the
845
- * dashboard draw a scaffolded creative's workflow graph BEFORE the first run.
846
- * Additive on the backend (never archives siblings, never sets definitionPath).
847
- */
848
- async syncCreativeDefinition(payload, signal) {
849
- await this.http.postJson("/api/creatives/definition", payload, signal);
850
- }
851
841
  getArtifact(kind, name, version, signal) {
852
842
  const path16 = version ? `/api/canvas/artifacts/${encodeURIComponent(kind)}/${encodeURIComponent(name)}/${encodeURIComponent(version)}` : `/api/canvas/artifacts/${encodeURIComponent(kind)}/${encodeURIComponent(name)}`;
853
843
  return this.http.getJson(path16, signal);
@@ -996,10 +986,10 @@ var ELEVENLABS_OUTPUT_FORMATS = [
996
986
  var ELEVENLABS_MAX_TEXT_CHARS = 45454;
997
987
  var ELEVENLABS_MAX_MUSIC_LENGTH_MS = 454545;
998
988
  var OPENROUTER_IMAGE_MIMES = ["image/png", "image/jpeg", "image/webp", "image/gif"];
999
- var REPLICATE_IMAGE_MIMES = ["image/png", "image/jpeg", "image/webp"];
1000
- var REPLICATE_VIDEO_MIMES = ["video/mp4", "video/webm", "video/quicktime"];
989
+ var FAL_IMAGE_MIMES = ["image/png", "image/jpeg", "image/webp"];
990
+ var FAL_VIDEO_MIMES = ["video/mp4", "video/webm", "video/quicktime"];
1001
991
  var DECONSTRUCT_VIDEO_MIMES = ["video/mp4", "video/webm", "video/quicktime"];
1002
- var REPLICATE_AUDIO_MIMES = ["audio/wav", "audio/mpeg", "audio/mp3"];
992
+ var FAL_AUDIO_MIMES = ["audio/wav", "audio/mpeg", "audio/mp3"];
1003
993
  var IMAGE_GENERATE_MODELS = [
1004
994
  "openai/gpt-5.4-image-2",
1005
995
  "google/gemini-3.5-flash",
@@ -1217,23 +1207,20 @@ var MODEL_REGISTRY = {
1217
1207
  },
1218
1208
  video_generate: {
1219
1209
  "bytedance/seedance-2.0": {
1220
- // Routed via Replicate's official `bytedance/seedance-2.0` model. NOTE:
1221
- // ByteDance's upstream "real person" likeness filter still blocks photoreal
1222
- // human reference frames on ANY reseller — the escape is a synthetic/AI
1223
- // presenter face or routing real faces to Veo, not the provider choice.
1210
+ // Routed via fal.ai (not OpenRouter) because OpenRouter's Seedance
1211
+ // passthrough rejects photorealistic human reference frames via
1212
+ // ByteDance's "real person" safety filter.
1224
1213
  label: "ByteDance Seedance 2.0",
1225
1214
  inputs: [],
1226
- optional_inputs: [{ kind: "image", mimes: REPLICATE_IMAGE_MIMES }],
1215
+ optional_inputs: [{ kind: "image", mimes: FAL_IMAGE_MIMES }],
1227
1216
  required: ["prompt"],
1228
1217
  params: {
1229
- // Replicate's Seedance wrapper hard-caps the prompt at 4000 chars; gate
1230
- // it here so an over-length prompt fails validate (free) not the billed call.
1231
- prompt: { kind: "string", maxLength: 4e3 },
1218
+ prompt: { kind: "string" },
1232
1219
  aspect_ratio: {
1233
1220
  kind: "string",
1234
1221
  enum: ["1:1", "3:4", "9:16", "4:3", "16:9", "21:9", "9:21"]
1235
1222
  },
1236
- resolution: { kind: "string", enum: ["480p", "720p", "1080p", "4k"] },
1223
+ resolution: { kind: "string", enum: ["480p", "720p", "1080p"] },
1237
1224
  duration: { kind: "number", enum: SEEDANCE_DURATIONS },
1238
1225
  seed: { kind: "number" },
1239
1226
  generate_audio: { kind: "boolean" }
@@ -1254,10 +1241,7 @@ var MODEL_REGISTRY = {
1254
1241
  duration: { kind: "number", enum: [4, 6, 8] },
1255
1242
  seed: { kind: "number" },
1256
1243
  generate_audio: { kind: "boolean" },
1257
- // Image-to-video and EU/UK/CH/MENA regions cap this at `allow_adult`;
1258
- // `allow_all` is text-to-video only. Allow both so an image-conditioned
1259
- // Veo clip (the real-face fallback) validates.
1260
- person_generation: { kind: "string", enum: ["allow_all", "allow_adult"] },
1244
+ person_generation: { kind: "string", enum: ["allow_all"] },
1261
1245
  enhance_prompt: { kind: "boolean" },
1262
1246
  conditioning_scale: { kind: "number" }
1263
1247
  }
@@ -1308,8 +1292,8 @@ var MODEL_REGISTRY = {
1308
1292
  "fal/veed-lipsync": {
1309
1293
  label: "VEED Lipsync (fal.ai)",
1310
1294
  inputs: [
1311
- { kind: "video", mimes: REPLICATE_VIDEO_MIMES },
1312
- { kind: "audio", mimes: REPLICATE_AUDIO_MIMES }
1295
+ { kind: "video", mimes: FAL_VIDEO_MIMES },
1296
+ { kind: "audio", mimes: FAL_AUDIO_MIMES }
1313
1297
  ],
1314
1298
  required: [],
1315
1299
  params: {}
@@ -1345,7 +1329,7 @@ var MODEL_REGISTRY = {
1345
1329
  // TARGET voice, preserving timing/prosody. Used to normalize a talking-head
1346
1330
  // clip's native (generator-chosen) voice into ONE consistent brand voice.
1347
1331
  label: "ElevenLabs Voice Changer (multilingual STS v2)",
1348
- inputs: [{ kind: "audio", mimes: REPLICATE_AUDIO_MIMES }],
1332
+ inputs: [{ kind: "audio", mimes: FAL_AUDIO_MIMES }],
1349
1333
  required: ["voice"],
1350
1334
  params: {
1351
1335
  voice: { kind: "string" },
@@ -1372,7 +1356,7 @@ var MODEL_REGISTRY = {
1372
1356
  },
1373
1357
  "elevenlabs/video-background-music-v1": {
1374
1358
  label: "ElevenLabs Video Background Music v1",
1375
- inputs: [{ kind: "video", mimes: REPLICATE_VIDEO_MIMES }],
1359
+ inputs: [{ kind: "video", mimes: FAL_VIDEO_MIMES }],
1376
1360
  required: [],
1377
1361
  params: {
1378
1362
  description: { kind: "string" },
@@ -1788,14 +1772,7 @@ var NodeDecl = z.object({
1788
1772
  version: z.string().min(1).optional(),
1789
1773
  inputs: z.record(z.string(), z.unknown()).optional(),
1790
1774
  params: z.record(z.string(), z.unknown()).optional(),
1791
- when: z.unknown().optional(),
1792
- // Regenerate knob. The engine is content-addressed: identical params + inputs
1793
- // return the cached render, so re-running an unchanged node NEVER re-bills or
1794
- // produces a new result. Bump this token (any string/number — a `2`, a `"v3"`,
1795
- // a note) and re-run to force THIS node to render fresh; because its new output
1796
- // changes downstream input hashes, everything depending on it regenerates too.
1797
- // This is the declarative "change a value, re-run, get a new render" affordance.
1798
- regenerate: z.union([z.string(), z.number()]).optional()
1775
+ when: z.unknown().optional()
1799
1776
  }).strict();
1800
1777
  var OutputRef = z.object({
1801
1778
  node: z.string(),
@@ -3078,7 +3055,6 @@ var Engine = class {
3078
3055
  const counters = { cachedNodes: 0, totalCredits: 0 };
3079
3056
  const nodeRuns = [];
3080
3057
  const graph = this.pruneToOutput(canvas, buildGraph(canvas));
3081
- const needsBytes = computeNeedsLocalBytes(canvas, graph, this.registry);
3082
3058
  this.emitProgress(opts, {
3083
3059
  kind: "plan",
3084
3060
  nodes: [...graph.entries()].map(([id, deps]) => {
@@ -3086,7 +3062,7 @@ var Engine = class {
3086
3062
  return { node_id: id, node_type: node?.type ?? "unknown", deps: [...deps], params: node?.params };
3087
3063
  })
3088
3064
  });
3089
- await this.runLayers(canvas, graph, outputs, runId, writer, opts, counters, nodeRuns, needsBytes);
3065
+ await this.runLayers(canvas, graph, outputs, runId, writer, opts, counters, nodeRuns);
3090
3066
  const output = pickFinalOutput(canvas, outputs);
3091
3067
  const stats = {
3092
3068
  total_nodes: canvas.nodes.length,
@@ -3111,13 +3087,13 @@ var Engine = class {
3111
3087
  this.log(`outputs in: ${writer.runDir}`);
3112
3088
  return { run_id: runId, output, outputs_by_node: outputs, stats, outputs_dir: writer.runDir, node_runs: nodeRuns };
3113
3089
  }
3114
- async runLayers(canvas, graph, outputs, runId, writer, opts, counters, nodeRuns, needsBytes) {
3090
+ async runLayers(canvas, graph, outputs, runId, writer, opts, counters, nodeRuns) {
3115
3091
  const layers = topologicalLayers(graph);
3116
3092
  const limit = resolveConcurrency(opts.concurrency);
3117
3093
  for (const layer of layers) {
3118
3094
  const settled = await mapWithConcurrency(layer, limit, (nodeId) => {
3119
3095
  this.emitProgress(opts, { kind: "node_start", node_id: nodeId });
3120
- return this.executeOne(canvas, nodeId, outputs, runId, writer, opts, needsBytes.has(nodeId)).then((r) => {
3096
+ return this.executeOne(canvas, nodeId, outputs, runId, writer, opts).then((r) => {
3121
3097
  if (r.cached) counters.cachedNodes++;
3122
3098
  counters.totalCredits += r.credits;
3123
3099
  const node = canvas.nodes.find((n) => n.id === nodeId);
@@ -3184,13 +3160,12 @@ var Engine = class {
3184
3160
  }
3185
3161
  await writer.writeManifest("_final", output);
3186
3162
  }
3187
- async executeOne(canvas, nodeId, outputs, runId, writer, opts, downloadOutputs) {
3163
+ async executeOne(canvas, nodeId, outputs, runId, writer, opts) {
3188
3164
  const node = canvas.nodes.find((n) => n.id === nodeId);
3189
3165
  if (!node) throw new Error(`executor: missing node ${nodeId}`);
3190
3166
  const def = this.registry.get(node.type);
3191
3167
  if (!def) throw new Error(`executor: missing registry entry for type ${node.type}`);
3192
- const regenerateToken = resolveRegenerateToken(node, opts.regenerate, runId);
3193
- const prepared = await prepareForExecution(node, outputs, def, canvas.cache_salt, regenerateToken, this.assets);
3168
+ const prepared = await prepareForExecution(node, outputs, def, canvas.cache_salt, this.assets);
3194
3169
  const policy = opts.cache_policy ?? "read_write";
3195
3170
  if (policy !== "bypass") {
3196
3171
  const cacheT0 = Date.now();
@@ -3208,14 +3183,12 @@ var Engine = class {
3208
3183
  nodeId: node.id,
3209
3184
  nodeType: node.type,
3210
3185
  cacheKey: prepared.cacheKey,
3211
- downloadOutputs,
3212
3186
  client: this.client,
3213
3187
  assets: this.assets,
3214
3188
  log: this.log,
3215
3189
  signal: opts.signal
3216
3190
  };
3217
- const preparedForExec = def.location === "local" ? { ...prepared, resolvedInputs: await this.materializeLocalInputs(prepared.resolvedInputs) } : prepared;
3218
- const { parsedInputs, parsedParams } = parseNodeArgs(def, preparedForExec, node.id, node.type);
3191
+ const { parsedInputs, parsedParams } = parseNodeArgs(def, prepared, node.id, node.type);
3219
3192
  const result = await invokeExecute(def, parsedInputs, parsedParams, ctx, node.id, node.type);
3220
3193
  const elapsed = Date.now() - t0;
3221
3194
  const credits = def.cost ? def.cost({ params: parsedParams }).credits : 0;
@@ -3257,42 +3230,8 @@ var Engine = class {
3257
3230
  }
3258
3231
  }
3259
3232
  }
3260
- /**
3261
- * Download any URL-only asset ref reachable in a local node's inputs so the
3262
- * bytes are on disk before the local runner stages them. Returns a copy —
3263
- * refs are replaced, never mutated in place, so the producer's cached output
3264
- * (shared object) keeps its URL-only shape.
3265
- */
3266
- async materializeLocalInputs(inputs) {
3267
- const fix = async (value) => {
3268
- if (Array.isArray(value)) return Promise.all(value.map(fix));
3269
- if (value && typeof value === "object") {
3270
- const v = value;
3271
- if (typeof v.kind === "string" && typeof v.url === "string" && typeof v.sha256 === "string" && typeof v.mime === "string" && typeof v.path !== "string") {
3272
- this.log(`[warn ] materializing URL-only input on demand (${v.kind}/${v.mime}) \u2014 missed graph edge`);
3273
- return this.assets.ingestRemote({
3274
- kind: v.kind,
3275
- url: v.url,
3276
- sha256: v.sha256,
3277
- mime: v.mime,
3278
- metadata: v.metadata
3279
- });
3280
- }
3281
- const out = {};
3282
- for (const [k, val] of Object.entries(v)) out[k] = await fix(val);
3283
- return out;
3284
- }
3285
- return value;
3286
- };
3287
- return await fix(inputs);
3288
- }
3289
3233
  };
3290
- function resolveRegenerateToken(node, forced, runId) {
3291
- if (forced?.has(node.id)) return `run:${runId}`;
3292
- if (node.regenerate !== void 0) return `node:${String(node.regenerate)}`;
3293
- return void 0;
3294
- }
3295
- async function prepareForExecution(node, outputs, def, cacheSalt, regenerateToken, assets) {
3234
+ async function prepareForExecution(node, outputs, def, cacheSalt, assets) {
3296
3235
  const resolvedInputs = resolveRefs(node.inputs ?? {}, { outputs }) ?? {};
3297
3236
  const resolvedParams = resolveRefs(node.params ?? {}, { outputs }) ?? {};
3298
3237
  const slotValues = await hydrateTextSlots(resolvedInputs, assets, node.id, node.type);
@@ -3305,9 +3244,6 @@ async function prepareForExecution(node, outputs, def, cacheSalt, regenerateToke
3305
3244
  throw new NodeExecutionError(node.id, node.type, { kind: "local", cause: e });
3306
3245
  }
3307
3246
  }
3308
- if (regenerateToken !== void 0) {
3309
- extras = { ...extras ?? {}, __regenerate__: regenerateToken };
3310
- }
3311
3247
  const cacheKey = computeCacheKey({
3312
3248
  node_id: node.type,
3313
3249
  node_version: def.version,
@@ -3346,16 +3282,6 @@ function pickFinalOutput(canvas, outputs) {
3346
3282
  const lastOut = outputs[last.id];
3347
3283
  return lastOut ? Object.values(lastOut)[0] : void 0;
3348
3284
  }
3349
- function computeNeedsLocalBytes(canvas, graph, registry) {
3350
- const typeById = new Map(canvas.nodes.map((n) => [n.id, n.type]));
3351
- const needs = /* @__PURE__ */ new Set();
3352
- for (const [consumerId, deps] of graph) {
3353
- const def = registry.get(typeById.get(consumerId) ?? "");
3354
- if (def?.location !== "local") continue;
3355
- for (const dep of deps) needs.add(dep);
3356
- }
3357
- return needs;
3358
- }
3359
3285
  function buildGraph(canvas) {
3360
3286
  const graph = /* @__PURE__ */ new Map();
3361
3287
  for (const n of canvas.nodes) graph.set(n.id, /* @__PURE__ */ new Set());
@@ -3486,16 +3412,7 @@ async function hydrateSlotValue(value, assets, nodeId, nodeType) {
3486
3412
  try {
3487
3413
  bytes = await assets.readBytes(value.sha256, value.mime);
3488
3414
  } catch (e) {
3489
- if (value.url) {
3490
- try {
3491
- await assets.ingestRemote({ kind: value.kind, url: value.url, sha256: value.sha256, mime: value.mime });
3492
- bytes = await assets.readBytes(value.sha256, value.mime);
3493
- } catch (e2) {
3494
- throw new NodeExecutionError(nodeId, nodeType, { kind: "local", cause: e2 });
3495
- }
3496
- } else {
3497
- throw new NodeExecutionError(nodeId, nodeType, { kind: "local", cause: e });
3498
- }
3415
+ throw new NodeExecutionError(nodeId, nodeType, { kind: "local", cause: e });
3499
3416
  }
3500
3417
  if (bytes.length > MAX_INLINE_TEXT_BYTES) {
3501
3418
  throw new NodeExecutionError(nodeId, nodeType, {
@@ -3630,9 +3547,7 @@ async function callBackendExec(args) {
3630
3547
  nodeVersion: args.nodeVersion,
3631
3548
  params: args.params,
3632
3549
  inputs: serialized,
3633
- idempotency_key: idempotencyKey,
3634
- canvas_run_id: args.ctx.canvasRunId,
3635
- node_id: args.ctx.nodeId
3550
+ idempotency_key: idempotencyKey
3636
3551
  },
3637
3552
  args.ctx.signal
3638
3553
  );
@@ -3684,9 +3599,6 @@ async function ingestValue(value, ctx, declaredKind) {
3684
3599
  }
3685
3600
  if (isRawAsset(value)) {
3686
3601
  const kind = value.kind ?? declaredKind ?? "json";
3687
- if (ctx.downloadOutputs === false) {
3688
- return buildRef({ kind, sha: value.sha256, mime: value.mime, url: value.url, metadata: value.metadata });
3689
- }
3690
3602
  return ctx.assets.ingestRemote({
3691
3603
  kind,
3692
3604
  url: value.url,
@@ -3924,15 +3836,7 @@ var EXT_TO_MIME = {
3924
3836
  jpeg: "image/jpeg",
3925
3837
  webp: "image/webp",
3926
3838
  gif: "image/gif",
3927
- // Non-model-safe rasters `toModelSafeImage` transcodes to PNG at ingest — they
3928
- // must resolve to an image mime here or the kind-check rejects the local file
3929
- // before normalization ever runs.
3930
3839
  avif: "image/avif",
3931
- heic: "image/heic",
3932
- heif: "image/heif",
3933
- tif: "image/tiff",
3934
- tiff: "image/tiff",
3935
- bmp: "image/bmp",
3936
3840
  mp4: "video/mp4",
3937
3841
  webm: "video/webm",
3938
3842
  mov: "video/quicktime",
@@ -4002,26 +3906,15 @@ async function toModelSafeImage(bytes) {
4002
3906
  throw new Error(`bytes are not a decodable image (${e.message})`);
4003
3907
  }
4004
3908
  }
4005
- function hasAscii(buf, offset, sig) {
4006
- return buf.length >= offset + sig.length && buf.toString("ascii", offset, offset + sig.length) === sig;
4007
- }
4008
- var HEIC_BRANDS = /* @__PURE__ */ new Set(["heic", "heix", "heim", "heis", "hevc", "hevx", "mif1", "msf1", "heif"]);
4009
- function sniffIsoBmff(buf) {
4010
- if (!hasAscii(buf, 4, "ftyp")) return null;
4011
- const brand = buf.subarray(8, 12).toString("ascii");
4012
- if (brand === "avif" || brand === "avis") return "image/avif";
4013
- if (HEIC_BRANDS.has(brand)) return "image/heic";
4014
- return null;
4015
- }
4016
3909
  function sniffImageMime(buf) {
4017
3910
  if (buf.length < 4) return null;
4018
- if (buf[0] === 137 && hasAscii(buf, 1, "PNG")) return "image/png";
3911
+ if (buf[0] === 137 && buf[1] === 80 && buf[2] === 78 && buf[3] === 71) return "image/png";
4019
3912
  if (buf[0] === 255 && buf[1] === 216 && buf[2] === 255) return "image/jpeg";
4020
- if (hasAscii(buf, 0, "GIF")) return "image/gif";
4021
- if (hasAscii(buf, 0, "RIFF") && hasAscii(buf, 8, "WEBP")) return "image/webp";
4022
- if (hasAscii(buf, 0, "II*\0") || hasAscii(buf, 0, "MM\0*")) return "image/tiff";
4023
- if (hasAscii(buf, 0, "BM")) return "image/bmp";
4024
- return sniffIsoBmff(buf);
3913
+ if (buf[0] === 71 && buf[1] === 73 && buf[2] === 70) return "image/gif";
3914
+ if (buf.length >= 12 && buf[0] === 82 && buf[1] === 73 && buf[2] === 70 && buf[3] === 70 && buf[8] === 87 && buf[9] === 69 && buf[10] === 66 && buf[11] === 80) {
3915
+ return "image/webp";
3916
+ }
3917
+ return null;
4025
3918
  }
4026
3919
  function findBoxPayload(buf, start, end, type) {
4027
3920
  let offset = start;
@@ -6899,8 +6792,6 @@ export {
6899
6792
  ulid,
6900
6793
  isPersistedAssetRef,
6901
6794
  collectAssetRefLikes,
6902
- REF_PREFIX,
6903
- parseRefExpr,
6904
6795
  sha256Hex,
6905
6796
  elementMentionKeywords,
6906
6797
  toModelSafeImage,
@@ -6914,4 +6805,4 @@ export {
6914
6805
  defaultRegistry,
6915
6806
  createEngineFromEnv
6916
6807
  };
6917
- //# sourceMappingURL=chunk-43KBQLP5.js.map
6808
+ //# sourceMappingURL=chunk-VSVGPYJK.js.map