Skip to content

feat: local-first Videoboom — on-device MLX pipeline + optional BYOK cloud, per-stage auto-config - #3

Open
lBroth wants to merge 94 commits into
mainfrom
local-mode
Open

feat: local-first Videoboom — on-device MLX pipeline + optional BYOK cloud, per-stage auto-config#3
lBroth wants to merge 94 commits into
mainfrom
local-mode

Conversation

@lBroth

@lBroth lBroth commented Aug 3, 2026

Copy link
Copy Markdown
Owner

Replaces #1 and #2, which are closed in favour of this one. Everything in both is here: #1's head was an ancestor of this branch, and #2 has been rebased on top with its two conflicts resolved (see "Merge note" below). Nothing was dropped — verified with git rev-list, all previous branches had zero unique commits.

What this is

Videoboom becomes local-first. A resident Python MLX sidecar on 127.0.0.1 runs the heavy stages on-device (Wan 2.2 i2v, FLUX keyframes, Whisper STT, Qwen LLM, Gemma VLM, RIFE, Real-ESRGAN), with cloud kept as an optional bring-your-own-key path resolved per stage. 82 commits, 94 files.

The per-stage auto-config resolver decides local-vs-cloud per stage from what is actually available — models on disk, RAM, and whether the user supplied keys — and emits the decision as VB_* env into the render process. There is a hard RAM gate at 48 GB (measured peaks: bf16-relay 32.6 GB, FastWan-5B 54.8 GB at 121f, Q4 67.7 GB) that refuses rather than silently falling back to cloud.

The video path

Quality is Wan 2.2 I2V-A14B in bf16 through a vendored "relay" fork of the MLX diffusion loop that keeps only one expert of the dual-expert model resident at a time — that is what makes a 14B model fit in 48 GB (32.6 GB peak) where the quantized build did not (67.7 GB, swaps). Lightning 4-step LoRA with CFG off, at 832x480, then RIFE interpolation and Real-ESRGAN to 1080p plus a filmic grade.

Measured on an M5 Pro 48 GB: 130.0 s for a 37-frame clip (110.0 s denoise, 27.5 s/step), which is ~1.2-1.4 h per minute of finished video. First+last-frame conditioning is always on — each scene morphs toward the next scene's keyframe, so boundaries are seamless rather than chained hard cuts.

Merge note (#2)

renderLocalScene/renderClip had gained a new last argument on both branches independently, for two different things, so the cherry-pick collided there. Both are kept and they are not interchangeable:

  • endImg — the next scene's keyframe, passed to the model as end_image so the scene morphs toward it. Only the sub-clip that ends a scene gets it.
  • anchor — the still that extracted last frames are pulled back toward with matchLevels, to bound exposure/saturation drift along a chain. It is the scene's own keyframe, or the anchor of the CUT scene that started the run.

Resolved as (..., endImg?: string, anchor: string | null = null) with the driver computing both.

Fixes included

#2's eight render-chain fixes, all of which were silent in production — nothing crashed, the video just came out worse than the code was capable of. The two worst: setEnv was a merge rather than a per-op replace, so one click of "Re-render · Quality" injected VB_LOCAL_WAN_STEPS=20 and poisoned every later render in the process; and RIFE targeted a fixed 2x, taking the 14B's 16 fps to 32, where the 24 fps conform then dropped one frame in four at uneven phase — a repeating cadence break on the default model.

Plus one found while auditing the MLX paths: the Settings "Download" button provisioned the wrong video engine. download.py picks the engine off VB_LOCAL_VIDEO_MODEL and falls back to '5b', but that variable is only ever emitted into the render spawn env, never process.env, and localEnv() does not carry it. So Download fetched the retired FastWan-5B (~24 GB) and wrote .model-path-5b, while videoReady() looked for .model-path — the VIDEO stage never went ready, and re-clicking repeated the 24 GB download.

Verification

npm test passes: typecheck clean, 33/33 unit tests (21 existing plus 12 added by #2's test/render-quality.test.ts), and the no-cloud check.

Note for reviewers: test/scene-shot-routing.test.ts depends on .vbdata-test/, which is gitignored, so it fails in a fresh clone or a git worktree where that fixture is absent. It passes in a normal working copy. Worth fixing separately.

🤖 Generated with Claude Code

https://claude.ai/code/session_01NkT9zMsGYFw6sJ39Q9mjZZ

lBroth and others added 30 commits June 29, 2026 20:59
…optional fully local)

Add an opt-in, per-stage local backend so the whole music-video pipeline can run
on-device (Apple Silicon / MLX) with no cloud keys. pipeline.ts is unchanged: each
provider routes cloud<->local via config.stageBackend(), selected per stage in Settings.

Architecture (no duplication across stages):
- local/server.py: model-agnostic HTTP sidecar (routes /i2v /stt /llm /vlm /keyframe;
  the model is named per request) + local/manager.py single-resident ModelManager that
  evicts other heavy models on load so one fits 48GB (stages run sequentially).
- src/engine/sidecar.ts: shared client (spawn + warm + POST); thin per-stage clients
  localVideo/localStt/localLlm/localVlm/localKeyframe.ts.

Stages (all validated end-to-end):
- video: Wan 2.2 I2V-A14B (MLX, Q4) + Wan2.2-Lightning 4-step LoRA (CFG off) for fast.
- stt: mlx-whisper (whisper-large-v3-turbo) word timing.
- llm: mlx-lm (Qwen3.6-35B-A3B-4bit), enable_thinking=False; JSON via tolerant parse.
- vlm: mlx-vlm (gemma-3-12b) caption + image-safety moderation.
- keyframe: mflux (FLUX.1-schnell ungated mirror; FLUX Kontext mirror for cast identity,
  with txt2img fallback when Kontext is unavailable).

Also fixes found while testing the local video path:
- segment: clamp cuts into [0, dur] instead of dropping out-of-range ones; a song whose
  vocals have no internal gap > PHRASE_GAP plus a duration that rounds up was collapsing
  to 0 scenes ("no clips"). Adds a regression test.
- local video: cap to 480p / 37 frames (Metal "Insufficient Memory" above that on 48GB),
  and cut local scenes to ~native clip length so clips render at real speed (no slow-mo).
- ffmpeg: W/H are now live env reads (vW/vH) so per-run resolution settings apply.

Setup: bash local/setup.sh (venv + mlx-video + Wan download/convert + Lightning LoRA);
other stage models download lazily on first use. local/.venv, models and path markers
are gitignored.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FX9VvLqn7CFnCukG2AYeh5
Before a stage can use its Local backend its model must be downloaded. Settings now shows,
per on-device stage (STT/LLM/VLM/keyframes), a Download button with live % progress; the
"Local" toggle stays disabled until the model is present.

- local/download.py <STAGE>: snapshot-downloads the stage's HF repo(s) and emits JSON
  progress lines (poll cache size vs HF file totals).
- src/main/localModels.ts: modelStatus() (HF-cache / Wan .model-path check) + downloadModel()
  streaming spawn. IPC: models:status, models:download (progress on download:<STAGE>),
  models:downloadCancel; preload + renderer types.
- Render guards (main): refuse a render when a selected local stage's model is missing
  (names which), while any model is downloading, or when a render is already running
  (one at a time — fixes the duplicate-render state seen in testing).
- renderer StageRow: Cloud/Local toggle with the gating + download progress UI.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FX9VvLqn7CFnCukG2AYeh5
…AM + deps)

The On-device section now only fully shows on a supported machine and surfaces the
requirements otherwise.

- localCapabilities() (main): platform/arch, unified-memory GB, deps-installed (venv),
  supported = Apple Silicon + >= 32GB. IPC local:capabilities + preload + types.
- Settings: card renders an amber "needs Apple Silicon + 32GB+ (48GB recommended)" note
  on unsupported machines (cloud only); when supported, shows RAM, a "run setup.sh" hint
  if the engine isn't installed, the video cloud/local toggle (Local gated on Wan present),
  and the per-stage download-gated toggles. Per-stage peak memory documented in the header.
…ama/ComfyUI)

Adds the v2 plan to LOCAL-MODELS.md: same sidecar/ModelManager/dispatch/gating reused,
torch handlers (diffusers WanImageToVideo + Flux/Kontext, transformers / llama-cpp-python,
faster-whisper) selected by VB_LOCAL_DEVICE; capability gate extended to NVIDIA via
nvidia-smi; minimum 16GB VRAM (24GB recommended). Also marks the MLX full-local path shipped.
… few clips)

The keyframe (FLUX ~10GB) / LLM (~19GB) model stayed resident in the ModelManager during
the clip pass, because Wan's generate_video self-loads its ~24GB outside the manager. After
a couple of clips the two together tipped 48GB unified memory over into Metal "Insufficient
Memory" (socket hang up -> scene failed). The /i2v handler now unload_all() first so Wan has
the full memory.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FX9VvLqn7CFnCukG2AYeh5
…st a media key

The Cast photo upload passes the picked file's absolute path; copyOut treated it as a media
key and joined it onto the data dir (ENOENT: .../data/Users/broth/Downloads/...). New
storage.copyInput() copies from an absolute path or a media key.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FX9VvLqn7CFnCukG2AYeh5
Live cost estimate per minute of video computed from the cloud stages currently selected
(local stages = €0): video per-second (dominant) + keyframes per-image + LLM tokens + STT
per-song, from a pricing table keyed by model slug. Labelled an estimate; cost.ts stays the
real post-render figure.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FX9VvLqn7CFnCukG2AYeh5
… photo used as-is

- UI: disable Render/Resume/Regenerate/Create while a generation runs (backend already
  refuses a second one-at-a-time render); surface the error instead of doing nothing.
- Character upload: use the uploaded photo directly (moderate + caption), don't regenerate
  it through the image model (which alters identity); only generate when description-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FX9VvLqn7CFnCukG2AYeh5
…led draft + Hunyuan-1.5 watchlist

Researched mid-2026: Wan 2.2 stays the default (best open i2v for realistic people, fits 48GB).
LTX-2.3 is not lighter/faster than Wan-14B on Mac MLX (its speed is the CUDA distilled path),
so only add the distilled LTX as a fast draft tier (+ native synced audio). HunyuanVideo-1.5
(8.3B, Apache) is the watchlist once a native MLX runner ships. CogVideoX/Mochi/SVD = skip on Mac.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FX9VvLqn7CFnCukG2AYeh5
…asured net-negative on 48GB)

- pipeline: order the keyframe pass so all no-ref (FLUX txt2img) scenes run together and all ref
  (FLUX Kontext) scenes run together, so a cast-mixed project swaps the heavy keyframe model at
  most once instead of per scene. Generation order doesn't affect the morph chaining — safe.
- Resident Wan weights across clips (memoize generate_video's transformer/VAE loaders + a
  ModelManager unload hook so a later keyframe/LLM load frees them). Measured on 48GB: clip 2's
  model load dropped 14.5s -> 0s, but the resident ~16GB transformers starve the denoise
  (memory pressure + per-call re-compile) and each clip got SLOWER overall. So it's OFF by
  default (VB_LOCAL_WAN_RESIDENT=1 to opt in on a 64/128GB Mac). + opt-in mx.set_wired_limit.

The real speed levers (TI2V-5B swap, fewer-frames + RIFE/ESRGAN) are tracked in ROADMAP.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FX9VvLqn7CFnCukG2AYeh5
…6x faster)

Benchmarked on M5 Pro 48GB at 480p, fixed seed/prompt/image (denoise + VAE + peak):
  5B-bf16 10-step  123.7s  (denoise 69.6 / VAE 47.6, peak 49.4GB)   <- 2.6x vs baseline
  5B-bf16 20-step  247.3s
  5B-bf16 40-step  304.2s
  14B-Q4 Lightning 4-step  ~324s (known baseline)
The 5B is single-DiT with the x64 Wan2.2 VAE -> ~1/4 the latent tokens, so even at 10 native
steps (CFG on) it beats the 14B 4-step. No few-step distill LoRA exists for the 5B, so it runs
native steps.

- localVideo: VB_LOCAL_VIDEO_MODEL ('5b' default | '14b'); 5B path uses the .model-path-5b dir,
  no Lightning LoRA (incompatible single-DiT), 24fps budgeting, native steps (VB_LOCAL_WAN_STEPS,
  default 10), config CFG (guide 5), 57-frame cap (~2.4s, keeps the VAE peak in 48GB).
- settings: drop the 14B-era VB_LOCAL_MAX_FRAMES=37 cap so the per-model default applies; keep 480p
  (720p risks OOM at the 5B's ~49GB peak). bench.py / bench_run.py harness added.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FX9VvLqn7CFnCukG2AYeh5
…ning/720p)

The local quality toggle now matches the 5B default: Fast = 10 steps (~2 min/clip), "Quality"
= 20 steps (~4 min, sharper), both 480p. Dropped the stale 14B labels (Lightning 4-step / 720p
40-step) and 720p HD (OOMs the 5B on 48GB). settingsEnv maps localQuality -> VB_LOCAL_WAN_STEPS
(10/20); the model-dir override now applies to both the 5B and 14B dirs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FX9VvLqn7CFnCukG2AYeh5
… were the new bottleneck)

With the 5B making i2v ~2.6x faster, the 20-step Kontext keyframes (~2.5 min each for cast
projects) became the slowest stage. 12 steps keeps identity at ~40% less time. Tunable via
VB_LOCAL_KONTEXT_STEPS. (Applies on the next sidecar start.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FX9VvLqn7CFnCukG2AYeh5
When a photo is uploaded for a character, use it exactly as the primary reference — moderate +
caption only — instead of running it through the image model to "reproduce the exact face",
which altered the identity. AI generation now happens only for description-only characters
(no upload). The cast pipeline (Kontext) still places the real photo into scenes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FX9VvLqn7CFnCukG2AYeh5
…trait block each other

While a video render runs you can no longer kick off an AI character-portrait generation (or
another render / scene regen), and vice versa — they'd otherwise fight for the model sidecar /
unified memory. The blocked op surfaces "a generation is already running" on its own channel.
gpuBusy() covers render:* and portrait:* ops; portrait and scene-regenerate are now guarded too.

(UI button-disable while busy is still a polish TODO; this is the backend correctness layer.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FX9VvLqn7CFnCukG2AYeh5
…eps, reusing scenes + keyframes

A fast 10-step preview can be re-rendered at quality (20 steps) WITHOUT redoing the slow stages:
the storyboard + keyframes are kept, only the video clip step re-runs. New engine command
rerender-clips resets the done scenes' clip status to pending (keeping prompt/timing/keyframe) and
re-runs the clip pass; buildKeyframe reuses the on-disk keyframes as-is. IPC render:requality forces
VB_LOCAL_WAN_STEPS=20 for that run (streamOp now takes an env override). Guarded by gpuBusy (one
generation at a time). Button shown on a rendered video.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FX9VvLqn7CFnCukG2AYeh5
…r cuts, no slow-mo)

A local scene now renders as ONE continuous shot of chained native sub-clips: each sub-clip is
i2v from the previous clip's last frame (the first from the keyframe), filling the scene length,
then concatenated. So scenes go back to ~6s vocal phrasing (VB_LOCAL_SCENE_SEC, default 6) =
fewer cuts, while motion stays real (no time-stretch slow-motion from a single short clip). A
scene that already fits one native clip renders directly. New ffmpeg helpers lastFrame + concatClips.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FX9VvLqn7CFnCukG2AYeh5
…ative sub-clips

Local scenes no longer time-stretch. Every sub-clip (and the single-clip case) renders a FULL
native clip, so the chained total is always >= the scene window; renderClip then TRIMS to the
exact frame-grid slot (trimToWindow: fps + frames:v, no setpts) — motion always plays at real
speed, never slow-motion. With ~6s scenes this also generates far fewer keyframes (the Kontext
bottleneck) → faster overall, exactly as expected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FX9VvLqn7CFnCukG2AYeh5
…e + anti-drift cap

The shot-list LLM now sets a per-scene "transition" (cut | continue): a 'continue' scene flows
out of the previous scene's last frame (no keyframe, seamless motion), a 'cut' starts a fresh
shot. Local renders sequentially through the chain; keyframes are generated ONLY for cut scenes
(+ the first, + an anti-drift cap VB_LOCAL_CHAIN_MAX=4 forcing a refresh) — so far fewer Kontext
keyframes (the bottleneck) and a smooth, mostly cut-free video the LLM edits to the song. A broken
chain or failed keyframe falls back to a fresh keyframe. Toggle with VB_LOCAL_CHAIN.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FX9VvLqn7CFnCukG2AYeh5
Adds rife-ncnn-vulkan-python-tntwise (MIT) to the venv requirements + local/interp.py: 2x a clip's
frame rate via RIFE (ncnn -> Vulkan -> MoltenVK -> Metal = real M-series GPU; verified on M5 Pro,
~3s for a 2.4s 480p clip). Lets us generate Wan clips at HALF the frames (faster denoise + VAE,
lower peak memory) then interpolate back to target fps with real motion. The rife-v4.6 model files
(flownet.param/.bin, ~10MB) load from the package models/ dir. Plus bench.py / bench_*.py harness
used to measure the 5B step floor and 14B paths.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FX9VvLqn7CFnCukG2AYeh5
… cap

Two harness bugs that caused a false "14B unusable on 48GB" read:
- bench.py called generate_video directly (not via run_i2v) so it didn't auto-set steps=4 when the
  Lightning LoRAs were present → ran 40 steps instead of 4. Pass steps explicitly in bench_14b.
- A default MLX memory cap strangled the 14B into swap (image-encode 4.4s -> 49s under a 46GB cap).
  The cap is now OFF by default (opt-in via VB_BENCH_MEM_GB). The reboot earlier was two model
  processes at once, not the 14B alone; the app already serialises to one GPU job.

Measured clean (14B Q4 Lightning 4-step, 13 frames, no cap, no crash): image 4.4s + load 13.9s +
denoise 117.7s (~29s/step, dual-expert 14B) + VAE 19.8s = 173.5s. Sharper than the 5B (x16 vs x64
VAE) but ~6x slower per second of video.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FX9VvLqn7CFnCukG2AYeh5
realesrgan-ncnn-py (bundles realesrgan-x4plus weights, runs on the M-series GPU via ncnn/Metal,
~0.7s/frame) added to the venv requirements. local/smoke_5b_esrgan.py generates ~8s of chained 5B
video and an ESRGAN-cleaned version side by side, to evaluate whether ESRGAN recovers the 5B's
x64-VAE softness.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FX9VvLqn7CFnCukG2AYeh5
…lt local model)

Adds LTX-2.3 distilled (MLX, two-stage 896x512) as the default on-device video model — it fixes the
5B's identity drift by conditioning each scene's clip on BOTH its start keyframe AND the next scene's
keyframe (--image + --end-image), so motion flows smoothly toward a consistent Kontext keyframe instead
of drifting from a single start frame.

- local/ltx_i2v.py: run_ltx_i2v via mlx-video's LTX generate_video; text encoder = gemma-3-12b-4bit
  (avoids the repo's 60GB encoder); frames ceil to 8n+1 so the clip is >= the window (trimmed, no stretch).
- server.py /i2v routes by engine ('ltx' vs Wan); evicts resident models first.
- localVideo: VB_LOCAL_VIDEO_MODEL ltx|5b|14b; LTX path passes end_image (next keyframe), 896x512.
- pipeline: LTX uses the standard per-scene-keyframe path (not Wan sub-clip chaining); renderClip feeds
  kfLast as the LTX end-image; local scenes capped ~3.9s (LTX native max ~4s) so trimToWindow fits.
- settings: localVideoModel (default ltx) + UI model picker; LTX sets 896x512, skips the Wan step knobs.

Measured: LTX i2v 896x512 ~2s clip in ~55s (incl. first+last) on M5 Pro — faster + higher-res than the
Wan paths. Setup via mlx-video convert.py (transformer shards; NOT the repo's 60GB text encoder).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FX9VvLqn7CFnCukG2AYeh5
…ion (identity unchanged)

The cast keyframe (FLUX Kontext) is now the render bottleneck (LTX video is fast). Two cuts that keep
identity (which is anchored to the reference photo, not steps/resolution) and final quality (LTX refines):
- Kontext steps 12 -> 8 (~33% faster; identity robust at 8).
- Keyframe resolution now matches the video model (LTX 896x512 / Wan 832x480) instead of a fixed 1024x576,
  so it isn't up/downscaled into the clip — ~22% fewer pixels for LTX + no resize.
Keyframes are already shared between adjacent LTX scenes (N+1 for N scenes), so no extra win there.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FX9VvLqn7CFnCukG2AYeh5
…LLM (keyframes already cached)

Re-rendering a project (e.g. switching video model, or tweaking settings) no longer redoes the slow
pre-video steps. render() reuses the existing storyboard when the project already has scenes whose
audio fingerprint (size + sha1 of head/tail) still matches — only re-pointing renderTarget for
preview/full. storyboard() stamps storyboardHash on completion; re-uploading the song invalidates it.
Pass regenStory (render --regen-story) to force a fresh STT + story/shot-list. Keyframes were already
reused from disk (buildKeyframe), so a cached re-render goes straight to the video step.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FX9VvLqn7CFnCukG2AYeh5
…ck at 100%)

A resume / re-render left the bar at the previous run's 100% during the keyframe phase. renderScenes
now resets progress to 0.3 at the start, the keyframe pass fills 0.3->0.5 as keyframes complete, and the
clip pass fills 0.5->1 — so the bar moves through "preparing keyframes" instead of showing a stale 100%.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FX9VvLqn7CFnCukG2AYeh5
…storyboard director, not just style)

Create picks a preconfigured format; each preset swaps the LLM story-bible + shot-list prompting and
pacing (music-video narrative vs ad product-hero + CTA), generalising cast -> subject (person | product
placed via Kontext). Reuses the whole pipeline; turns the music-video studio into an AI video studio.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FX9VvLqn7CFnCukG2AYeh5
…opt-in)

User comparison: single-image LTX has freer, more natural motion than the first+last morph, which read
as slow-motion when adjacent keyframes are similar. So the LTX backend now animates from just the scene's
keyframe by default; identity stays anchored per-scene by the Kontext keyframe. The first+last morph is
opt-in via VB_LOCAL_LTX_MORPH=1 for cases where a smooth tween between two specific keyframes is wanted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FX9VvLqn7CFnCukG2AYeh5
…rector

Create now picks a Format (Music video | Ad / Spot). The preset swaps the storyboard prompting, not just
the style string:
- music-video: narrative bible + lyric-synced shots (unchanged).
- ad: a commercial director — storyBible builds a hook -> product reveal -> benefit/lifestyle -> CTA arc,
  shotListPrompt makes the PRODUCT the hero (hero/product shots, benefit, aspirational, CTA close), punchy
  beat-synced cuts allowed. Instrumental tracks are accepted (no lyrics required for ads).
The "cast" generalises to the product: upload a product photo as a character and it's placed into scenes
via the same Kontext seam. format flows project -> createProject -> storyboard (SYS_AD / adShotListPrompt /
storyBible(format)); UI format picker sets a sensible default style.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FX9VvLqn7CFnCukG2AYeh5
… the visual style

The Style textarea was redundant now that the Format preset (music-video / ad) sets a sensible default
style. Removed it from Create; `style` still flows in the data model (set by the preset), so look control
can return later as look-presets rather than free text.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FX9VvLqn7CFnCukG2AYeh5
lBroth and others added 14 commits July 6, 2026 01:18
New EngineSetup component (renderer/App.tsx) drives the M3g bootstrap
backend: Install button → vb.startBootstrap(), live phase→friendly-label
progress (python/venv/deps/harden/verify) with the deps pct bar, Cancel,
phase-attributed errors; on {done} invalidates engineState/modelStatus/
bootstrapStatus so the UI advances Install → Download → Render.

Wire the tri-state (§7): the on-device HardwareCard hosts the primary
EngineSetup when engineState==='not-bootstrapped'; per-stage download rows
are gated behind it (can't download a model before the engine exists).
Onboarding gains an optional 'provision' step (compact EngineSetup + "Do
this later") reachable from "Skip — stay local" / "Start" on a supported,
un-provisioned Mac; keyless first run still ends cleanly.

Replace the 4 dead-end "bash local/setup.sh" strings with in-app CTAs:
HardwareCard branch → <EngineSetup/>; sidecar.ts / localModels.ts /
localVideo.ts throws → "Settings → On-device" guidance. Only dev-flow
code comments still mention setup.sh.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dc9Dq5aKfSAbc8GWUVUxkz
…tall

Validated the M3 vendor+bootstrap mechanics on-device (M5 Pro, macOS) before
the Developer-ID/notarization QA. Two bugs the local test caught:

- vendor-runtime.sh: `uv pip wheel` does not exist in uv 0.11.17
  ("unrecognized subcommand 'wheel'"). Build the wheelhouse with
  `python3.12 -m pip wheel` (mlx_video from its pinned git commit + every
  runtime dep as a macOS-arm64 wheel); keep uv only for `pip compile`.
  Compile the lock `--no-index --find-links build/wheels` so it resolves
  purely from the local wheelhouse (all 121 pins present locally).

- bootstrap.ts: `uv pip sync --find-links` is only additive — uv still
  re-downloads most wheels from PyPI, making the .dmg-vendored 600MB dead
  weight and the first-run install network-bound. Add `--no-index` so the
  install is hermetic/offline from the shipped wheelhouse.

Verified end-to-end on a fresh runtime dir with the vendored uv+lock+wheels:
`uv python install 3.12` → `uv venv` → `uv pip sync --require-hashes
--no-index --find-links build/wheels` → `import mlx.core` OK on 3.12.13
(121 pkgs, 1.5G venv, fresh cache proves no PyPI dependency). typecheck +
17 unit tests green. Vendor outputs stay git-ignored.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dc9Dq5aKfSAbc8GWUVUxkz
…ightning 4-step

On-device render QA surfaced three defects, all fixed here:

- Storyboard prompts (BOTH ad + music-video paths) let the small local LLM
  re-introduce close-ups and readable text/UI. Root causes: the close-up ban
  lived only in the framing rule (the LLM then wrote "close-up of the eyes" in
  the MOTION field), and the text ban didn't cover decorative/atmospheric marks
  (it produced "streams of red text", a "Merged" badge, "error logs", "status
  bar", the product name on screens). Added two shared HARD rules injected
  verbatim into both shot lists: HARD_FRAMING (no close-up anywhere incl. as a
  camera end-point — medium-to-wide only) and HARD_SCREENS (screens show only
  abstract glowing light; never code/logs/badges/checkmarks/UI/numbers/names).
  Verified: the next storyboard opened every scene "A medium-wide shot" and
  self-corrected ("logo, visual representation only, no text").

- Keyframe NOSIGN backstop (applied to every keyframe regardless of the LLM):
  added medium-wide framing + the UI/code/number/badge terms, so the image
  layer suppresses text even if a scene prompt slips one through.

- localVideo 14B "fast": the Lightning LoRA was applied but steps were never
  set, so the sidecar fell back to its 20-step default — ~5x slower AND
  over-denoised (this is what hung the 14B Quality preview mid-render). Force
  4 steps (Lightning's design point) unless VB_LOCAL_WAN_STEPS overrides.

typecheck clean. Prompt fixes verified live on-device; the 14B step fix is a
speed/stability fix (the reliable preview path stays Fast/5B).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dc9Dq5aKfSAbc8GWUVUxkz
Each scene can morph from its start frame toward the NEXT scene's keyframe, so
scene N ends exactly where N+1 begins -> seamless boundary + first+last
conditioning, on the already-wired local MLX engines (no new engine needed).

4 points threaded end to end:
- relay_generate.py: end_image param; 14B channel-concat (video last=end,
  mask first+last) and 5B mask-blend (z_img first+last, mask 0 at both). +30 LOC
- wan_i2v.run_i2v: route FastWan through relay_generate too (it carries morph);
  forward req['end_image'] on relay/FastWan only (stock path ignores it)
- localVideo.ts genVideoLocal(endImg?): put end_image in the /i2v payload
- backends/local/video.ts: morph each scene toward kfPaths[k+1] (next CUT
  keyframe); in a multi-sub-clip scene only the last sub-clip morphs

Validated standalone (research/wan-morph): scene_0->scene_1 morph, FastWan 33s
/ Wan bf16 76s, 832x480 cinematic, beats Kandinsky (512x512/169s). Gated by
VB_LOCAL_MORPH (default OFF = zero behavior change until validated in-app).
Per user direction: morph must always be active in all modes, not optional.
- removed the VB_LOCAL_MORPH gate (was opt-in) — end_image always sent
- keyframe pass now builds a keyframe for EVERY rendered scene (was CUT-only),
  so each scene has a target its predecessor morphs toward
- every scene morphs to kfPaths[k+1] (was: only when next was a CUT); final
  scene has no next keyframe -> first-frame only
Result: scene N ends exactly on kf[N+1] = scene N+1's start, for both cut
(start=own keyframe) and continue (start=real prev last-frame + morph target).
Seamless boundaries everywhere. Cost: a keyframe per scene vs per cut.
…ts + subset render

Decouples keyframe generation from clip generation so the user can curate the
whole storyboard cheaply (a keyframe is seconds vs a clip's minutes) before
committing GPU time to video. New engine commands + IPC + preload bridge:

- build-storyboard: run the LLM shot-list (prompts) + a keyframe IMAGE for EVERY
  scene, then STOP (no clips, no assemble). status='storyboard'. Reuses a cached
  storyboard + existing keyframes.
- regenerate-keyframe: re-roll ONE scene's keyframe only (no clip). Drops the
  cached file + bumps VB_LOCAL_KEYFRAME_SEED so the reroll actually differs (the
  local keyframe seed is otherwise path-derived → identical every time).
- update-scene: edit a scene's prompt/motion/title/transition in place; clip
  drops to pending.
- set-scene-keyframe: replace a scene's keyframe with a user-supplied image
  (ffmpeg-normalized to PNG at the scene's keyframe path).
- render-selected: render clips for an EXPLICIT set of user-checked scenes
  (rendered even if already done), reusing their keyframes, then assemble.

renderScenes() now takes an optional explicit index set (was prefix-only
index<target); the prefix path is unchanged. keyframeLocal honors
VB_LOCAL_KEYFRAME_SEED. Edits mark the scene pending (clip stale) so a later
render redoes only what changed. Every edit is a plain storage.putScene RMW —
timing (audio-derived) is untouched (v1 = no add/remove/reorder).

typecheck + build + 17 unit tests green. UI (rich SceneEditor) is the next slice.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dc9Dq5aKfSAbc8GWUVUxkz
The renderer half of the scene editor. New two-phase flow: Create → "Genera
storyboard" (prompts + a keyframe image per scene, no clips) → curate → pick
scenes → render video.

- CreateVideo: primary CTA is now "Genera storyboard" → buildStoryboard (was
  full render). Dropped the Preview/Full "Scope" control (scene selection now
  happens in the editor, not up front).
- SceneEditor rewritten as a vertical filmstrip (one row per scene, narrative
  order): keyframe image + editable prompt + editable motion + transition badge
  + status. Per-row actions: Rigenera img (keyframe-only reroll), Sostituisci
  img (pick your own), Salva prompt (updateScene). A checkbox per scene + a
  toolbar: select-all, "Rigenera storyboard", "Render selezionate (N)".
- RenderProvider: startStoryboard / startRenderSelected / startRegenKeyframe;
  the run tracker now counts 'keyframe' events and refreshes scene thumbnails
  live as each keyframe lands.
- 'storyboard' is now a READY-to-curate status (removed from IN_PROGRESS); the
  card auto-opens the editor there. buildStoryboard uses 'storyboarding' while
  building.

typecheck + build green. Known v1 limit: rendering a NON-contiguous subset
morphs each clip toward the next SELECTED scene's keyframe, not the next scene
by index (contiguous ranges + full render are exact). No add/remove/reorder yet
(timing model deferred).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dc9Dq5aKfSAbc8GWUVUxkz
Two fixes for the "the character changes every scene" bug (from a parallel
on-device session):

- localKeyframe.ts: when a character reference exists, derive the keyframe seed
  from the REF path instead of the per-scene output path — same lead → same seed
  in every scene, so the subject stops drifting scene-to-scene (Kontext already
  holds identity from the photo; a fixed seed removes the remaining variation).
  No ref → keep the per-scene seed for variety. The editor's re-roll still
  overrides via VB_LOCAL_KEYFRAME_SEED.
- local/keyframe.py: a ref means the caller REQUIRES that exact subject, so drop
  the silent txt2img fallback — it quietly swapped in a DIFFERENT person when
  Kontext failed. Raise loudly instead so the real cause (OOM / bad model)
  surfaces rather than shipping a wrong identity.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dc9Dq5aKfSAbc8GWUVUxkz
…y = 14B speeds

Per user direction: the x64-VAE FastWan-5B deformed people ("fast viene male"),
so it's no longer used. Standardize on Wan 14B (x16 VAE — holds detail +
character identity) and stop surfacing 5B:

- settings default localVideoModel = '14b' (was '5b'); migrate now coerces ANY
  stored value (incl. '5b') to '14b'. The field is kept for schema compat.
- Settings VIDEO Fast/Quality toggle now writes localQuality (both are 14B):
  Fast = Lightning 4-step + tiny-VAE (~2 min/clip), Quality = full 40-step
  master. Removed the "FastWan-5B · 3-step draft" copy.
- Updated the migrate tests (localVideoModel is always '14b' now).

The 5B model code/download/HF weights remain in place but unreachable from the
UI — a deliberate deep-removal sweep (download.py engines, relay_generate 5B
mask-blend, fastwan_dmd, wan_i2v routing, videoReady marker, docs) is a separate
follow-up. typecheck + 17 tests + build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dc9Dq5aKfSAbc8GWUVUxkz
`main()` looked the stage up in STAGE_REPOS before the `if stage == "VIDEO"`
branch could run. VIDEO has no STAGE_REPOS entry (it is a per-engine MLX repo,
not a plain repo list), so the lookup returned None and the function exited with
"unknown stage VIDEO" — making that branch dead code and the Settings →
On-device video download button a no-op.

OPTIONAL_REPOS had the same problem from the other side: it was defined and
never referenced anywhere, in Python or TypeScript, so FLUX Kontext could not be
downloaded through the app at all.

Dispatch VIDEO before the lookup, and fall back to OPTIONAL_REPOS for the
on-demand stages.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CBgr9cf6UJq2rsMWmRepzq
FastWan-5B was retired for the wrong reason. It was never memory-bound: it was
VAE-decode-bound. Measured on the real project (121f, 832x480, M5 Pro 48GB):

  official Wan2.2 VAE   272.1s total — 236.7s decode (87%), 24.8s denoise
  taew2_2 tiny-VAE       33.4s total —   0.8s decode,       27.0s denoise

296x on decode, 8.1x on the clip. Quality holds: 121 frames vs 121 (frame math
unchanged, both decoders are t_upscale 4 / trim 3), SSIM ~0.98 and mean offset
<= 2.3 on frames 0/60/120, visually indistinguishable side by side.

Three things were needed:

- tiny_vae.py now dispatches on config.vae_z_dim: 16ch (Wan 2.1 VAE / 14B) via
  taew2_1 as before, 48ch (Wan 2.2 VAE / 5B) via taew2_2. The two VAEs are NOT
  called the same way — relay_generate's 48ch branch invokes `vae(z)` with
  channels-last latents that have already been through denormalize_latents,
  while the 16ch branch calls `vae.decode(z)` with raw channels-first latents.
  TAEHV wants ~Gaussian latents either way, so the 48ch shim re-inverts the
  normalization (round-trip error 4.8e-07). A layout-naive shim would not have
  crashed — it would have produced wrong colors.

- wan_i2v.py mirrored the shim onto the relay fork only under `use_relay`, but
  the 5B has dual_model=false and reaches the fork via `fastwan_spec`. So
  tiny-VAE was a silent no-op on exactly the path where the official decode
  dominates. Now keyed on `via_relay = use_relay or fastwan_spec`.

- the 5B frame cap goes 57 -> 121 (5.0s at its native 24fps). The old cap was
  set against the official decoder's working set; 121f was measured repeatedly
  with no OOM under both decoders, including with first+last morph. Note this
  does not improve throughput per second of video (6.3 -> 6.7 s/s, attention
  grows superlinearly) — it saves one T5 load and one seam per scene.

taew2_2.safetensors is force-added alongside taew2_1 (local/models/ is ignored):
without the weight on disk the shim falls through to the official decoder
silently, which is the failure this commit exists to remove.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CBgr9cf6UJq2rsMWmRepzq
…no cast ref)

An identity model's job is to PLACE the reference person, so handing it a scene
with nobody in it puts one there regardless of the prompt. Observed on the test
project: scene 0 reads "No characters are visible yet, only the oppressive
weight of the environment" and rendered with the lead standing in the alley —
and paid FLUX Kontext (36.7s measured) instead of FLUX schnell (8.0s) to do it.

Filtering on an empty `characters` array could not have fixed this: the lead was
re-injected three separate times — `s.characters || [0]` in the storyboard pass,
the `!ids.length` fallback right after it, and again in refsForScene. An empty
array is also indistinguishable from "old project" or "LLM was lazy", so acting
on it would change behavior for every stored project.

New explicit `shot: 'character' | 'environment'` field instead:

- SCENES_SCHEMA gains the enum (required — the cloud LLM path uses strict mode)
  and both shot-list prompts are told when to use it. Ads always get 'character'.
- The load-bearing edit is refsForScene (backends/sceneShared.ts), the single
  seam BOTH the local and cloud keyframe backends resolve refs through, so this
  is one change rather than one per backend. It also fixes the `hasRef` grouping
  in both video backends for free.
- Local LLMs get the schema as advisory text and may drop the field, so there is
  a deterministic backstop: an explicitly empty `characters` on an instrumental
  moment (same test as the shot-list's own INSTRUMENTAL tag) means environment.
- `shot` added to all three keep-lists — dropping it on rerender/refresh would
  be the `transition` bug from cd5ebfd all over again.

Backward compatibility: a missing field is not 'environment', so every project
stored before this keeps byte-identical behavior. Covered by tests, so it stays
that way.

Known trade-off: routing sends more shots to schnell, which follows scene
description but ignores trailing negations — so the NOSIGN rule (no readable
text, no Asian signage) is now violated more visibly on environment shots. That
is an image-prompt fix, tracked separately; a character composited into a scene
written as empty is the worse of the two bugs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CBgr9cf6UJq2rsMWmRepzq
Eight fixes to the render chain. Every one of them was silent in production —
nothing crashed, the video just came out worse than the code was capable of.

1. setEnv is now a per-op REPLACE, not a merge (engine/config.ts).
   CFG only ever accumulated, so any key emitted for one model/op survived into
   the next. `render:requality` injects VB_LOCAL_WAN_STEPS=20 and the resolver
   emits it for the 5B — either one then poisoned every later 14B render in the
   same process: 20 steps forced through a 4-step Lightning distillation (flat,
   slow-motion output) and, worse, `isHd` read the leftover key and silently
   demoted an HD render to the fast VAE + short deadline while also skipping the
   LoRA — strictly worse than both Fast and HD.

2. isHd no longer depends on the absence of a step override (localVideo.ts).
   Quality is a settings question; a step override must not redefine it.

3. render:requality bumps steps for the 5B only (main/index.ts).
   The 5B runs native steps, so 20 is a real gain. Pushing 20 through the 14B's
   Lightning distillation over-denoises into flat motion, and its HD path already
   runs the full 40-step schedule.

4. RIFE targets an exact multiple of the timeline rate (localVideo.ts, interp.py).
   A fixed 2x took the 14B's native 16fps to 32, and the 24fps conform then
   dropped 1 frame in 4 at uneven phase — a repeating cadence break on the
   DEFAULT model. 3x lands on 48fps, an exact 2:1. interp.py generalizes to an
   arbitrary factor using RIFE v4's timestep conditioning (one pass, not a
   recursive 2x-of-2x that would interpolate synthesized frames).

5. A failed interpolation is logged instead of swallowed (localVideo.ts).
   The fallback conforms 16->24 by duplicating one frame in two — constant
   judder, previously with nothing in the logs to explain it.

6. The cloud retime is motion-compensated when the deviation is visible
   (ffmpeg.ts). The provider only accepts whole-second durations, so nearly
   every cloud clip is retimed, and setpts+fps resamples by duplicating or
   dropping whole frames with no local-style RIFE equivalent. Accepted only at
   the exact frame-grid length, so smoothing can never cost frame accuracy.

7. Clamped anti-drift on chained start frames (ffmpeg.ts, backends/local/video.ts).
   Chained i2v compounds exposure/saturation error per link with nothing bounding
   it — VB_LOCAL_CHAIN_MAX caps the chain across scenes, never within one, so a
   12s scene is 6 unbounded links. The clamp makes it a no-op when there is no
   drift and keeps it from ever fighting deliberate lighting.

8. One x264 generation removed, and the grade stops fighting the render
   (ffmpeg.ts, pipeline.ts). Timeline concat now stream-copies when the inputs
   are compatible, verified against the summed input duration. On an upscaled
   master the temporal denoise (which smears the frames RIFE just synthesized)
   and the second unsharp (over an already-sharpened ESRGAN output) come off.

Also: timeline resolution gets one definition in shared/videoRes.ts instead of
two hand-copied literal pairs, and autoconfig's stale "not yet wired" comment is
corrected — it has been wired into sidecarEnv all along.

Every new behavior is env-gated back to the old one. Verified: typecheck, 29 unit
tests (12 new), check-no-cloud, build, plus the ffmpeg helpers executed against
synthetic clips to prove the filtergraphs are valid and frame-exact rather than
silently falling back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TD7w2Asmu4Ga5HBGK99261
download.py picks the VIDEO engine off VB_LOCAL_VIDEO_MODEL and falls back
to '5b' (download.py:62). That variable is emitted by autoconfig.toEnv(),
which only ever builds the RENDER spawn env (index.ts:87) -- it is never
written to process.env, and localEnv() does not carry it. So the Download
button spawned download.py with no engine set and always took the '5b'
fallback.

The effect on a default install: Settings -> On-device -> Download fetched
the retired FastWan-5B (~24GB) and wrote .model-path-5b, while videoReady()
resolves the marker from settings.localVideoModel, which migrate() coerces
to '14b' for every stored value -- so it looked for .model-path, never found
it, and the VIDEO stage stayed 'absent'. Re-clicking Download repeated the
same 24GB fetch of a model the app cannot select.

Pass the setting the app actually runs. Verified: unset -> FastWan-5B /
.model-path-5b (the bug), '14b' -> Wan2.2-I2V-A14B-MLX-bf16 / .model-path
(what videoReady expects).

Found while auditing the MLX paths for dtype waste; unrelated to that audit,
which came back clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NkT9zMsGYFw6sJ39Q9mjZZ
… the LoRA

Three defects found auditing the local stack, all silent in production.

1. The sidecar outlived the app (src/engine/sidecar.ts).
   It is a plain child of the Electron main process, nothing ever killed it,
   and server.py had no idle timeout or parent watchdog -- so on macOS a quit
   re-parented it to launchd and it kept running until reboot. It does not
   idle cheaply: manager.py evicts only at the head of /i2v, so any session
   ending on a storyboard, a portrait, or a render that failed before the
   video stage left FLUX Kontext (~10GB) or the 35B LLM (~19GB) pinned in
   unified memory with no app on screen.

   Fixed on both ends: stopSidecar() (SIGTERM, SIGKILL after 2s, since a
   sidecar mid-diffusion holds GPU_LOCK and will not answer promptly) wired
   to app 'before-quit' and to process exit/SIGINT/SIGTERM; plus a
   --parent-pid watchdog thread in server.py that os._exit(0)s when the
   parent disappears, covering the hard-kill and crash cases the TS side
   cannot. Verified: the child exits ~3s after the parent dies.

   Only the GUI path was affected -- `npm run dev` from a terminal signals
   the whole process group on Ctrl-C, which is why it never showed in dev.

2. VIDEO readiness ignored the Lightning LoRA (src/main/localModels.ts).
   The 14B's default Fast path IS the 4-step Lightning distillation. Without
   the LoRA, lightningLoras() returns null, no `steps` is sent, and the
   sidecar falls back to its 20-step default: ~5x the denoise time and
   over-denoised, flat motion. videoReady() checked only the model download,
   so an interrupted LoRA fetch or a moved models dir reported "ready" and
   degraded every clip silently instead of prompting a re-download.

3. The "Local Wan folder" setting was invisible to the readiness gate
   (src/main/localModels.ts:39). It read process.env, but autoconfig emits
   that var only into the RENDER spawn env, never into main's own
   environment -- so pointing the setting at an existing weights folder
   (external SSD, another checkout) left the stage stuck at "absent" with no
   way to unblock it. Now resolved the way the engine does it, setting
   included, same precedence.

npm test green: typecheck, 33/33, no-cloud check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NkT9zMsGYFw6sJ39Q9mjZZ
@lBroth

lBroth commented Aug 3, 2026

Copy link
Copy Markdown
Owner Author

Audit backlog — verified defects still open on this branch

A four-dimension audit of the sidecar, the render chain, the Electron main process and the env/contract surface produced 16 candidates; each was then re-checked adversarially against this branch. Four were rejected as stale or not real, three are fixed in f52a357, and the rest are below. Everything here has a concrete user-visible failure, not a code smell.

Two candidates were rejected specifically because #2's fixes already cover them: the setEnv env-poisoning bug and the swallowed RIFE failure. They were reported against the pre-#2 tree.

Medium

tiny-VAE monkeypatch is permanent, so Quality silently decodes with the tiny VAElocal/tiny_vae.py:97 exposes patch() with no unpatch(), and it rebinds a module global for the life of the process. Render one clip on Fast, switch Settings to Quality, render again: the user pays the full 40-step denoise (~38 min/clip measured) but the frames still come out of the 22 MB TAEHV approximation instead of the official Wan VAE — which is the entire point of the Quality tier. Fix: save the original on first patch and add unpatch(), called per request from run_i2v based on tiny_vae.

assemble() drops pending scenes, desyncing picture from songsrc/engine/pipeline.ts:596 skips any scene that is not done/failed, but the audio is only truncated. Tick scenes #10-#12 of 30 in the editor and hit "Render selezionate": the assembled video plays the chorus footage over the intro. Fix: still-fill pending scenes that precede the last rendered index instead of skipping them.

refreshScene loses both morph targets and re-renders the neighbour for nothingsrc/engine/backends/local/video.ts:234 and :238 call renderClip without endImg/anchor, so "Regenerate scene" drops the seamless boundary, and it additionally re-renders scene k-1 with byte-identical inputs (same keyframe, same prompt, same hardcoded seed 42) — minutes of 14B GPU time for an identical result. Fix: mirror the cloud backend and pass the on-disk next keyframe.

Morph target ignores keyframes already on disksrc/engine/backends/local/video.ts:156 reads kfPaths, which is populated only over the scenes being rendered this pass. Rendering a subset from the editor gives the last scene of the subset a hard visual jump into the next, even though that keyframe exists on disk. Fix: fall back to S.mediaExists(...) / keyframePath(pid, k + 1).

A failed upscale leaks tens of GB and says nothinglocal/upscale.py:65 dumps every frame of the whole timeline as PNGs (~15-20 GB for a full song) with the cleanup outside a try/finally. If it runs out of disk or hits the watchdog, the user gets a soft 832x480 master with no message after an hour-plus render, and the frame dump stays on disk. Fix: try/finally: shutil.rmtree(...), chunk the extraction, and surface the failure as an event.

Low

Storyboard keyframes thrash the single-resident managerlocal/manager.py:38 evicts every other heavy model on each get(), and since the shot-routing change the Fase A loop alternates between the cast-ref model and the plain one, so a 30-scene storyboard can reload a ~10 GB model on every alternation. Fix: partition the loop by refsForScene(p, s).length > 0 so each variant loads once.

test/scene-shot-routing.test.ts depends on a gitignored fixture — it reads .vbdata-test/, which .gitignore:35 excludes, so the suite is green in a normal working copy and red in a fresh clone, in CI, or in a git worktree. Fix: build the fixture in the test, or commit a small tracked one.

Rejected on inspection

The /i2v GPU-lock-past-deadline concern and the "upscale scratch is left in the project directory" report did not survive verification — the scratch is under $TMPDIR/videoboom, and on the default configuration the lock case does not produce the described cascade.

🤖 Generated with Claude Code

https://claude.ai/code/session_01NkT9zMsGYFw6sJ39Q9mjZZ

lBroth and others added 11 commits August 3, 2026 19:12
…f video

The 14B 'hd' tier meant "drop the Lightning LoRA and run the model-config 40
steps with CFG": 80 transformer passes against Lightning's 4, measured at
~38 min per 2.31 s sub-clip. A 30-second video needs 13 sub-clips, so
choosing Quality cost 8.2 hours. Nobody could use it.

It was also not delivering the one thing it charged for. The tier's real
differentiator is the official Wan VAE instead of the 22 MB TAEHV
approximation -- but tiny_vae.patch() rebound a module global with no
inverse, and the sidecar is resident, so a single earlier Fast clip pinned
the tiny decoder for the life of the process. An hd render then paid the
full 40-step denoise and still decoded through TAEHV.

Since lightx2v ships only a 4-step I2V distillation for A14B (no 8-step),
the honest ladder on this hardware is the same distillation with a couple
more steps and the better decoder:

  fast  4 steps + tiny VAE       132 s/sub-clip   ~29 min per 30 s of video
  hd    6 steps + official VAE   246 s/sub-clip   ~53 min per 30 s of video

Both derived from the measured 27.5 s/step at 832x480/37f (LOCAL_PLAN.md:413)
plus the measured 62 s official decode. 6 was picked as the top of the range
where the distillation still gains detail -- pushing a 4-step LoRA far past
its schedule over-denoises into flat, slow-motion output, which is exactly
what the sidecar's 20-step default did. It needs an A/B on real frames to
confirm; VB_LOCAL_WAN_STEPS overrides it.

Also here:

* tiny_vae.unpatch(), and both branches now run on every request, so the
  decoder is a per-request choice rather than whatever the last request left
  behind. The relay mirror is unconditional for the same reason.
* The hd deadline drops from 90 to 30 minutes (~7x the measured hd clip). It
  was sized for the 38-minute clips; all it did afterwards was delay the
  report of a stuck job while the sidecar held the GPU lock.
* Scene boundaries keep their morph target in two places that lost it:
  rendering a subset from the editor (kfPaths only holds the current pass, so
  the last scene of a selection jumped into a neighbour whose keyframe was on
  disk all along), and refreshScene, which additionally re-rendered the
  previous scene from byte-identical inputs -- same keyframe, same prompt,
  same fixed seed -- for an identical result. It now morphs into the new
  keyframe, which is the reason to re-render it at all.

npm test green: typecheck, 33/33, no-cloud check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NkT9zMsGYFw6sJ39Q9mjZZ
Two ways a finished render came out worse than the user was told.

1. assemble() skipped every scene that was not done/failed, but the song is
   only trimmed to whatever the picture ended up being (`-t vdur`). Tick
   scenes 10-12 of 30 in the editor, hit "Render selected", and the assembled
   video played that footage from t=0 — chorus visuals over the intro, and
   the song cut short to match. Every scene now contributes its own window;
   the ones not rendered yet fill from their keyframe, so each real clip sits
   at its true timecode. A pending scene fills silently instead of stamping
   SCENE FAILED over a perfectly good still — not rendered yet is not an
   error.

2. A failed 1080p upscale was swallowed by a bare `catch {}`. After an
   hour-plus render the user silently got the 832x480 master with nothing to
   explain it, and the usual cause is recoverable (the frame dump needs
   ~15-20 GB of temp space). It now emits a warning naming the reason.

   That needed a channel: the renderer only ever consumed 'error', so a
   'warn' event would have been dropped just as silently. RunState gains
   `warning`, shown under the project card in amber once the run settles —
   the run still succeeds, it just no longer lies about what it produced.

npm test green: typecheck, 33/33, no-cloud check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NkT9zMsGYFw6sJ39Q9mjZZ
The loop printed one aggregate "Denoising: <t>s", and every plan in this
repo divides it by the step count. On a 4-step Lightning run that mean
carries two systematic biases with nowhere to hide:

  - step 0 pays the cold Metal pipeline cache, and on the relay also the
    first expert build;
  - the high->low expert swap happens mid-run and is averaged across.

So the 27.5 s/step everything is planned against (LOCAL_PLAN.md:413) is a
4-sample mean from a single cold-start run, not a steady-state figure. It is
the number behind the 29 min / 53 min tier estimates, and it has never been
separated into cold and warm.

Now prints the per-step series tagged by expert, plus the first step and the
warm median, e.g.

  Steps: high:31.2s  high:27.1s  low:26.9s  low:27.0s
  Per-step: first 31.2s (cold), warm median 27.0s over 3

mx.eval(latents) is the sync point that closes each iteration, so the
bracket is exact. Numerics untouched: no new arrays, no PRNG use, nothing
inside the graph.

This lands because an audit of the research repos for MLX speedups rejected
52 of 53 candidates against the measured profile -- GEMMs already run at
31.4 TFLOP/s (peak), mx.compile is already on, the LoRA is merged at load,
CFG is off, RoPE and cross-attention K/V are precomputed. The one survivor
was not a speedup but the observation that the measurement itself is
unguarded. Fix the instrument before chasing another 3%.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NkT9zMsGYFw6sJ39Q9mjZZ
…ted ready

Hit for real while provisioning the 69GB Wan-14B for a tier A/B: a DNS drop
mid-snapshot (httpx.ConnectError) left config.json + t5_encoder (11.4GB) +
vae (0.5GB) on disk and neither 28.6GB expert.

Both halves of the system then called that a complete model.

1. download.py gated the snapshot on `t5_encoder.safetensors AND
   config.json` existing. Both survived the interruption, so the next
   Download click skipped snapshot_download entirely, rewrote the marker and
   reported {"event":"done","pct":100} in under a second. The 57GB that were
   actually missing could never be fetched from the UI again — the button
   was permanently stuck succeeding. Now checked against every weight the
   engine opens, declared per engine in VIDEO_ENGINES.required, and the
   marker is only written once they are all present; otherwise it emits an
   error and exits non-zero. snapshot_download resumes and is a cheap etag
   check when complete, so re-running it on a partial dir is correct.

2. videoReady() used the same single sentinel, so the stage showed "ready"
   against a model that cannot load. It now requires the same file list.
   Note this failure needed no interrupted download to reproduce: a stale
   marker from an earlier successful fetch plus a since-emptied models dir
   was enough.

Also adds local/ab_tiers.py, which renders one shot through both 14B tiers
(4-step + tiny VAE vs 6-step + official VAE) with everything else held
equal, chaining sub-clips the way backends/local/video.ts does. The 6-step
Quality setting is a projection from the measured 27.5 s/step and has never
been looked at; this is what will confirm or drop it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NkT9zMsGYFw6sJ39Q9mjZZ
The 4-vs-6-step Quality setting was a projection with no frames behind it.
Rendered the same 3-second shot three ways on the now-downloaded 14B, same
keyframe, prompt, seed and canvas, changing one variable at a time:

  fast    4 steps + tiny VAE       131 s/sub-clip  28.4 min/30s  detail 132.6
          4 steps + official VAE   149 s/sub-clip  31.3 min/30s  detail 150.0
  hd      6 steps + official VAE   200 s/sub-clip  41.5 min/30s  detail 166.2

Detail is the mean Laplacian variance over 5 frames of the first sub-clip
(directly comparable — same seed, same 37 frames); high-frequency energy
agrees, 70.4% -> 75.2% -> 75.8%.

Three things the numbers changed:

* hd is 41.5 min per 30s, not the projected 53. The official VAE decode is
  23.4 s, not the 59 s extrapolated from an older 62 s figure.
* The middle arm was the point of the exercise: fast->hd moved the step
  count AND the decoder together, so the win could not be attributed. Split
  out, it is +13.2% from the decoder and +10.8% from the extra steps. But
  the decoder buys its 13.2% for 10% more time while the last two steps buy
  10.8% for 33% — roughly four times worse per unit of detail.
* Steady-state is 24.9 s/step, not 27.5. The old number was a 4-sample mean
  containing the cold first step and the relay's expert swap, which each add
  ~5 s to their own first step. Visible now that the per-step series is
  printed.

Both tier estimates in localVideo.ts are replaced with the measured values.
The --tier choices in ab_tiers.py are derived from TIERS, because adding an
arm to the dict but not the argparse list failed the run instantly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NkT9zMsGYFw6sJ39Q9mjZZ
… was red

The test pointed VB_DATA_DIR at `.vbdata-test/`, which .gitignore:35
excludes. refsForScene resolves a cast member by checking that
`characters/<id>/primary.png` exists, so on a machine without that directory
every ref resolved to zero and three of the four assertions failed.

That machine is any fresh clone, any git worktree, and the CI runner — which
is why every run on this branch has failed since it was created while
`npm test` stayed green locally. It went unnoticed because the branch it was
authored on was never the head of a PR; the last green CI ran on a base that
predates the test.

refsForScene never opens the file, it only checks existence, so the fixture
does not need to be a real image or be checked in at all. The test now
builds its own under mkdtemp.

Verified in a git worktree with no `.vbdata-test` present, i.e. the CI
condition: 33/33.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NkT9zMsGYFw6sJ39Q9mjZZ
Runs were warning that actions/checkout@v4 and actions/setup-node@v4 target
Node 20 and are being forced onto Node 24. The project's own Node version was
never the issue — node-version is already 24 and engines.node is >=24; it is
the actions' own runtime.

All seven were behind, several by more than the two majors the warning
mentions:

  actions/checkout               v4 -> v7
  actions/setup-node             v4 -> v7
  actions/upload-artifact        v4 -> v7
  actions/configure-pages        v5 -> v6
  actions/upload-pages-artifact  v3 -> v5
  actions/deploy-pages           v4 -> v5
  softprops/action-gh-release    v2 -> v3

CI exercises checkout/setup-node on all three OSes and will validate those
directly. The release and pages workflows only run on a tag and on pushes to
main, so upload-artifact, the pages trio and action-gh-release are NOT
covered by this PR's CI — they need a workflow_dispatch run before the next
release is cut.

This does not address the larger finding: the shipped app still runs Node
20.18.3, because that is what Electron 33 bundles. Node 24 in the app needs
Electron 40+.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NkT9zMsGYFw6sJ39Q9mjZZ
engines.node was already >=24 and CI already used node-version 24, but that
only ever described the BUILD toolchain. The packaged app runs on the Node
that Electron bundles, and Electron 33 bundles Node 20.18.3 with Chromium
130. Electron supports the newest three majors (43, 42, 41), so 33 had been
out of support for ten majors and was receiving no Chromium security
patches.

  electron            33.4.11 -> 43.2.0    node 20.18.3 -> 24.18.0
                                           chromium 130 -> 150
  electron-builder     25.1.8 -> 26.15.3
  @electron/notarize    2.5.0 -> 3.1.1
  @types/node         22.20.0 -> 24.13.3   (the 24 line, matching the runtime;
                                            26.x tracks Node 26, which we do not ship)

Zero source changes were needed across those ten majors. Verified locally:

  typecheck + 33/33 unit tests + no-cloud check
  engine self-test  (VB_ENGINE_TEST=1)  ok:true, every ffmpeg check passing
  renderer smoke    (VB_SMOKE=1)        ok:true, bridge + render + tabs + caps
  runtime versions inside Electron      node 24.18.0, chrome 150.0.7871.129
  electron-builder --dir --mac          "valid on disk", "satisfies its
                                        Designated Requirement", and the
                                        packaged framework reports
                                        node.js/v24.18.0 + Chrome/150

Not covered here: `npm run dist` with real Apple signing and notarization
only runs on a tag, so the @electron/notarize 2 -> 3 path is unexercised.
Cut a workflow_dispatch release build before the next tag.

Still behind, and deliberately left alone because none of it is required for
Node 24 and each is its own migration: react 18 -> 19, vite 6 -> 8,
typescript 5 -> 7, tailwind 3 -> 4, framer-motion 11 -> 12, lucide-react
0.468 -> 1.28.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NkT9zMsGYFw6sJ39Q9mjZZ
…ix E43 pickers

Three findings from auditing the Electron 33 -> 43 bump.

1. The signed macOS build could never have worked. package.json:111-112 set
   mac.entitlements and mac.entitlementsInherit to build/entitlements.mac.plist
   with hardenedRuntime: true, but that file has never existed: `build/` is
   gitignored, `git ls-files build` is empty, and vendor-runtime.sh only writes
   build/bin/uv and build/wheels. app-builder-lib passes the path through
   without an existence check, so codesign fails with "cannot read entitlement
   data" and `npm run dist` aborts before notarization.

   Pre-existing and unrelated to the bump; it stayed invisible because CI never
   runs dist and the ad-hoc fallback in scripts/mac-sign.js passes no
   --entitlements. SIDECAR_BOOTSTRAP_PLAN.md:28 and :392 assert the file
   "already declares" its keys, which was never true.

   The entitlements are not optional here: under the hardened runtime without
   disable-library-validation and allow-unsigned-executable-memory the
   Python/MLX sidecar is killed at startup. That is the bad version of this bug
   — a signed, notarized build where on-device generation just does not work.

   Dropped allow-dyld-environment-variables, which the plan claims but nothing
   needs: no first-party code sets a DYLD_* variable.

   Verified: plutil -lint OK, and codesign --options runtime --entitlements
   embeds all three and reads them back off the signed binary.

2. Electron 43 made showOpenDialog default to ~/Downloads and stopped the OS
   restoring the last-used folder (electron.d.ts:22800). dialog:openAudio and
   dialog:openImage passed no defaultPath, so both pickers reopened in
   Downloads every time — the least likely place for a song or a reference
   photo. They now remember their own directory, seeded from music/pictures.
   The two new fields have to be named in settingsSchema.migrate(), which
   rebuilds a whitelisted object rather than spreading raw, or they would be
   dropped on the next save.

3. CI ran typecheck and test:unit but not check:no-cloud, so the guard that
   keeps cloud calls out of a local-resolved render was never enforced on a PR.
   Added as its own step.

npm test green (33/33 + no-cloud), renderer smoke ok:true on Electron 43.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NkT9zMsGYFw6sJ39Q9mjZZ
release.yml ships a .exe, an .AppImage and a .deb, but nothing had ever
launched the app on Windows or macOS in CI. The matrix job only typechecks,
unit-tests and builds — none of which catches a main-process crash on
startup, a preload bridge that fails to expose, or a bundled ffmpeg that does
not resolve on that platform. The one job that does launch Electron ran on
ubuntu-latest alone.

On-device generation is Apple-Silicon-only by design (localCapabilities
requires darwin+arm64 and 48GB), and that is not what this proves. What it
proves off macOS is the shell a Windows or Linux user on a cloud key actually
depends on: the app boots, the IPC bridge is up, the renderer mounts, and the
bundled ffmpeg/ffprobe run.

xvfb is applied only on Linux; the macOS and Windows runners have a window
server. VB_ENGINE_TEST / VB_SMOKE move to `env:` so the same `run:` line works
under both cmd and sh.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NkT9zMsGYFw6sJ39Q9mjZZ
…g model

Two findings from a whole-project review, both of which a user hits without
doing anything unusual.

1. A render that did not finish left the project unusable, permanently.
   pipeline.ts writes status:'rendering' when the clip loop starts and only
   clears it on success, so quitting mid-render — the ONLY way to stop one,
   since the renderer never calls the op:cancel that main already
   implements — left 'rendering' in project.json. Nothing reconciled it at
   startup, and renderer/App.tsx computes `active` from that status alone,
   hiding the whole action row behind it: Finish, Re-render, Scenes,
   Download and Delete. The project could not even be removed from the app.
   Same outcome for any throw after the storyboard phase, e.g. a full disk
   during the final mux.

   Three parts: runEngine's catch now lands status:'failed' + the message on
   the project before rethrowing; reconcileInterruptedProjects() sweeps
   leftover in-progress projects to 'failed' at startup, where no run can be
   live; and Delete is rendered outside the `active` gate, because a project
   you cannot delete is a dead end whatever the cause.

2. local/setup.sh built a model the app cannot run, and made the app use it.
   README.md:89 sends every from-source user to it. It downloaded the ~120GB
   fp32 Wan checkpoint, spent hours converting to Q4 — then wrote that Q4
   directory into .model-path, the exact marker videoReady() and modelDir()
   resolve for the 14B engine. Q4's config carries a `quantization` key, so
   wan_i2v.py's relay never engages and both experts stay resident: 67.7GB
   peak against the app's own 48GB floor, and slower than bf16 (47.6 vs
   27.5 s/step). The stage reported Ready the whole time.

   setup.sh now keeps the venv/pip half and hands the engine to download.py,
   the same provisioner the Download button uses. One provisioner, one
   marker, the pre-converted bf16 repo that actually fits.

Also: README said ~50GB of free disk when the required set is ~100GB, and
the Settings "Quality" tile still advertised "full 40-step · max detail",
which commit eea12b2 replaced with 6-step Lightning + the official VAE
earlier today.

npm test green, renderer smoke ok:true.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NkT9zMsGYFw6sJ39Q9mjZZ
@lBroth

lBroth commented Aug 4, 2026

Copy link
Copy Markdown
Owner Author

Whole-project review — 21 confirmed, 19 dismissed

A five-dimension review (cross-platform reality, docs vs code, security, unhappy-path UX, dead weight), each finding then re-checked adversarially against the code. Six verifier agents died on connection errors, so a handful of items below carry their reviewer's evidence but no independent second read — flagged where that is the case.

Two were fixed in a3d98b4: interrupted renders leaving a project permanently stuck with no Delete, and local/setup.sh provisioning the Q4 model the app cannot run. The rest is below, grouped by what it costs.

Ships wrong behaviour

Windows/Linux onboarding claims every stage is "Local · private"renderer/Onboarding.tsx. A user finishes the wizard on Windows, pastes both keys, and the summary screen says Lyric timing / Story / Keyframes / Video / Face caption all run locally. Nothing local can run there: localCapabilities() requires darwin+arm64. The first render then refuses with a message contradicting what they were just shown.

Deleting a character leaves dangling ids in every scenesrc/main/index.ts. character:delete scrubs the id from each project's cast, but scene JSONs keep it in scene.characters. assertCastExists inspects only p.cast and passes; refsForScene then resolves the id against media that is gone. A re-render silently stars a different person, or none.

Scene-editor save and image-replace swallow their errorsrenderer/App.tsx. Both wrap setSceneKeyframe in try/finally with no catch. Its real, user-caused failures — 'image not found', 'could not import that image' (an HEIC the bundled ffmpeg cannot decode, a file on an unmounted volume) — vanish. The dialog closes as if it worked.

Security

The sidecar accepts jobs from anything on the machinelocal/server.py. Fixed port 127.0.0.1:8765, no auth, no Origin check, no Host check. Any other process running as the user, and any web page in a browser that does not gate local-network requests, can drive it while the app is open. The handlers take caller-controlled filesystem paths. Reported as verified live. An Origin/Host reject plus a per-launch token in the spawn env would close it without changing the client contract.

Documentation that would send someone into a wall

AGENTS.md orders the deletion of the product. It is the file every contributor and agent is told to obey, and it still describes a local-only, no-keys app — instructing that src/engine/cloud/* be removed. That code is the shipped BYOK path.

Every doc still says Fast = FastWan-5B. The 5B engine was retired from the UI; both tiers are the 14B now, separated by localQuality. docs/LOCAL-MODELS.md, docs/MODELS.md, docs/ROADMAP.md and the renderer's type comments all disagree with settingsSchema.ts:28.

local/README.md documents the first BYOK-era prototype — 720p defaults, 40 steps, 81 frames, "everything else stays cloud". docs/LOCAL-MODELS.md:9 points at it as the current tunables page, so its numbers get used. Every video default in its table is wrong.

Docs say local i2v has no last-frame conditioning. First+last morph has been unconditionally on since d436ec9. Anyone reasoning about scene seams reasons from a design that was replaced.

The landing page promises "0 keys required to start" and never mentions the 48 GB floor — site/index.html. Every Apple Silicon Mac below 48 GB, which is most of them, fails the gate and is told to add an OpenRouter key.

docs/FOLDER-STRUCTURE.md omits src/engine/cloud/, src/engine/backends/, src/shared/, src/engine/cost.ts and five src/main/ modules.

local/requirements.txt says torch is convert-time only. It is a runtime dependency of the default render path (the TAEHV tiny-VAE runs on torch-MPS), 437 MB installed, and vendor-runtime.sh bundles it deliberately.

Size

Every installer carries all three platforms' ffprobe binariesffprobe-static ships darwin + linux + win32 in one package (335 MB on disk), electron-builder copies production deps wholesale, and asarUnpack forces the tree out unpacked. Roughly 200 MB of dead weight in every install, on every OS.

Notable dismissals

Checked and found not to be problems: the deny-by-default firewall does cover the paths that reach providers; renderer-supplied keys are contained; Linux safeStorage does not silently fall back to plaintext in a way that matters here; local/download.py's '5b' default is now unreachable from the app.

🤖 Generated with Claude Code

https://claude.ai/code/session_01NkT9zMsGYFw6sJ39Q9mjZZ

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants