Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
94 commits
Select commit Hold shift + click to select a range
1acfe5b
feat(local): on-device MLX pipeline behind a Python sidecar (BYOK -> …
lBroth Jun 29, 2026
28f2a3e
feat(local): download-gated stage toggles + render guards in Settings
lBroth Jun 29, 2026
87cda29
feat(local): gate On-device Settings by capability (Apple Silicon + R…
lBroth Jun 29, 2026
eb0f3ae
docs(local): plan CUDA backend for Windows/Linux (direct libs, no Oll…
lBroth Jun 29, 2026
118c909
fix(local): free resident models before each Wan i2v run (OOM after a…
lBroth Jun 29, 2026
e464df5
fix(engine): character portrait accepts an absolute file path, not ju…
lBroth Jun 29, 2026
60d1a47
docs(roadmap): plan estimated €/min in Settings from active cloud models
lBroth Jun 29, 2026
53a739f
docs(roadmap): add TODOs — block re-trigger during a render; uploaded…
lBroth Jun 29, 2026
f7fb4c4
docs(roadmap): local i2v model options — keep Wan 2.2, add LTX-distil…
lBroth Jun 29, 2026
5c9a266
perf(local): group keyframes by FLUX variant; resident-Wan opt-in (me…
lBroth Jun 29, 2026
ee2b573
feat(local): adopt Wan2.2-TI2V-5B as the default local i2v model (~2.…
lBroth Jun 29, 2026
bfcd36b
feat(local): Settings local-quality reflects the 5B (steps, not Light…
lBroth Jun 30, 2026
8886e5f
perf(local): cut FLUX Kontext keyframe steps 20 -> 12 (cast keyframes…
lBroth Jun 30, 2026
5da163b
feat(cast): uploaded character photo is used as-is (no AI regeneration)
lBroth Jun 30, 2026
0004666
feat(guards): one GPU generation at a time — render and character por…
lBroth Jun 30, 2026
8692428
feat(render): "Re-render · Quality" — upgrade a fast preview to 20 st…
lBroth Jun 30, 2026
16a82b6
feat(local): clip-chaining — long scenes as one continuous shot (fewe…
lBroth Jun 30, 2026
74cb5dd
feat(local): remove slow-motion entirely — TRIM chained clips, full-n…
lBroth Jun 30, 2026
33e286b
feat(local): LLM-directed continuous chaining — cut/continue per scen…
lBroth Jun 30, 2026
bececa5
feat(local): RIFE frame interpolation (Apple GPU) + benchmark harness
lBroth Jun 30, 2026
276675c
fix(bench): steps=4 for 14B Lightning; drop strangling default memory…
lBroth Jun 30, 2026
61526c9
feat(local): add Real-ESRGAN upscaler (Apple GPU) + 5B+ESRGAN smoke test
lBroth Jun 30, 2026
ef877de
feat(local): LTX-2.3 video backend with first+last-frame morph (defau…
lBroth Jun 30, 2026
7f8a311
perf(local): faster Kontext keyframes — 8 steps + match video resolut…
lBroth Jun 30, 2026
68789ae
feat(render): cache the storyboard per audio — re-renders skip STT + …
lBroth Jun 30, 2026
0d9311c
fix(ui): reset + tick progress on resume/re-render (bar no longer stu…
lBroth Jun 30, 2026
b8f7193
docs(roadmap): plan format presets — music-video / ad-spot (preset = …
lBroth Jun 30, 2026
29dfdd1
feat(local): LTX single-image by default (more natural motion; morph …
lBroth Jun 30, 2026
cc361f1
feat(format): ad/spot mode — format preset switches the storyboard di…
lBroth Jun 30, 2026
79540c5
feat(create): drop the free-text Style field — the Format preset sets…
lBroth Jun 30, 2026
48ed9d2
fix(toon): make cast keyframes + video actually toon (style was being…
lBroth Jun 30, 2026
4050aad
feat(ui): Download button on finished videos (Save As -> mp4)
lBroth Jun 30, 2026
cd5ebfd
feat(local): overhaul on-device video quality, speed, fluidity
lBroth Jul 3, 2026
92ddef8
feat(local): 14B bf16 via relay-shedding as default Wan engine
lBroth Jul 4, 2026
e095e90
feat(local): FastWan DMD draft tier on the 5B slot
lBroth Jul 4, 2026
5125093
refactor: remove BYOK cost tracking (C1, local-only pivot)
lBroth Jul 4, 2026
9fb1f79
refactor(engine): video is always local; delete LTX
lBroth Jul 5, 2026
c99cd92
refactor(engine): providers.ts -> stages.ts, local-only
lBroth Jul 5, 2026
688e5d7
refactor(main): remove keychain, keys IPC, key UI
lBroth Jul 5, 2026
3eafbc0
refactor(settings): local-only schema v2
lBroth Jul 5, 2026
275f7cc
docs + fonts: local-only story
lBroth Jul 5, 2026
8f8ac52
docs: LOCAL_PLAN.md — local-only pivot plan (de-risked + FastWan/1080…
lBroth Jul 5, 2026
1e8b62b
refactor(ui): local-only Settings; Fast/Quality = FastWan-5B/Wan-14B
lBroth Jul 5, 2026
df5f8b5
docs: refresh stale cloud comments; FastWan/Quality framing
lBroth Jul 5, 2026
9d51aa2
docs: videoModel docstring — 5b = FastWan Fast, 14b = Quality, both 1…
lBroth Jul 5, 2026
2e0cfe0
docs: refresh LOCAL-MODELS.md + ROADMAP.md for local-only
lBroth Jul 5, 2026
fe0321d
docs(plan): ETA seed spike RESOLVED — measured M48 + FastWan, two fin…
lBroth Jul 5, 2026
11456c3
chore(local): sync relay fork — full seed reproducibility (relay == s…
lBroth Jul 5, 2026
7f42f31
docs: DUAL_BACKEND_PLAN.md — reverse pivot to local-default hybrid
lBroth Jul 5, 2026
eb2145a
feat(hybrid): C1 — settings v3 schema + auto-config resolver (no beha…
lBroth Jul 5, 2026
907492a
feat(hybrid): C2 — restore keychain + cost + keys IPC (dormant)
lBroth Jul 5, 2026
e87f77b
feat(hybrid): C3 — cloud text/image stages + per-stage dispatch
lBroth Jul 5, 2026
d287844
feat(hybrid): C4 — video seam; pipeline names no backend
lBroth Jul 5, 2026
c2c7a19
feat(hybrid): C5 — wire the resolver; cloud becomes selectable
lBroth Jul 5, 2026
2590049
feat(hybrid): C6 — network lockdown (deny-by-default)
lBroth Jul 5, 2026
6247fd0
feat(hybrid): C7 — hybrid Settings UI (per-stage backend, keys, cost)
lBroth Jul 5, 2026
3b67a43
feat(hybrid): C8 — first-run onboarding wizard
lBroth Jul 5, 2026
3c8f252
ci: standardize on Node 24 everywhere (fixes Windows unit tests)
lBroth Jul 5, 2026
a111ea1
docs: README + site — local-default hybrid framing
lBroth Jul 5, 2026
27a4abe
docs(readme): lead with local; add MLX + NVIDIA system requirements
lBroth Jul 5, 2026
6bbf070
docs: SIDECAR_BOOTSTRAP_PLAN.md — M3 macOS slice (make the .dmg run o…
lBroth Jul 5, 2026
3526ea0
feat(m3a): dev-vs-packaged path resolution for the sidecar
lBroth Jul 5, 2026
da8024e
feat(m3ce): engine-aware readiness + tri-state engineState + install-…
lBroth Jul 5, 2026
91da634
docs(m3k): FastWan-5B MLX published to lBroth (D1 resolved)
lBroth Jul 5, 2026
9906f8d
feat(m3f): pre-converted video manifest (no on-device convert) + Fast…
lBroth Jul 5, 2026
de89cd8
feat(m3): ship our bf16 as Quality 14B + raise RAM floor to 48GB
lBroth Jul 5, 2026
a1c0b27
feat(m3g): bootstrap.ts — first-run uv Python/venv/deps provisioning
lBroth Jul 5, 2026
805d97c
feat(m3j): package the sidecar + uv/wheels/lock runtime (electron-bui…
lBroth Jul 5, 2026
2a7a354
feat(m3i): in-app engine-setup UX; kill the setup.sh strings
lBroth Jul 5, 2026
3efffd5
fix(m3j): local test — pip wheelhouse build + hermetic --no-index ins…
lBroth Jul 6, 2026
7de6c32
fix(storyboard+video): kill close-ups & readable screen-text; force L…
lBroth Jul 7, 2026
8cfa751
feat(local): first+last morph-chaining for Wan/FastWan (opt-in)
lBroth Jul 7, 2026
d436ec9
feat(local): first+last morph ALWAYS on — keyframe per scene, no flag
lBroth Jul 7, 2026
13a0066
feat(scene-editor): Fase A backend — storyboard phase + per-scene edi…
lBroth Jul 7, 2026
d3f5b10
feat(scene-editor): Fase A UI — vertical filmstrip storyboard editor
lBroth Jul 7, 2026
1034c7f
fix(keyframe): stable character identity across scenes
lBroth Jul 7, 2026
ccab4b6
feat(video): retire FastWan-5B from the UI — default 14B, Fast/Qualit…
lBroth Jul 7, 2026
8676a95
fix(local): dispatch VIDEO + optional stages in download.py
lBroth Jul 20, 2026
cec6f41
feat(local): tiny-VAE for the 5B path (taew2_2) — decode 236.7s -> 0.8s
lBroth Jul 20, 2026
f6bc2bd
feat(storyboard): shot-aware keyframe routing (environment shots get …
lBroth Jul 20, 2026
287e23b
fix(render): quality + fluidity defects across the local and cloud paths
claude Aug 3, 2026
0714a3e
fix(local): Download button provisioned the wrong video engine
lBroth Aug 3, 2026
f52a357
fix(local): stop leaking the sidecar on quit; gate VIDEO readiness on…
lBroth Aug 3, 2026
eea12b2
fix(local): make the Quality tier usable — 8.2 h -> 53 min for 30 s o…
lBroth Aug 3, 2026
21f76a7
fix(render): keep picture on the song, and stop hiding a failed upscale
lBroth Aug 3, 2026
591a7f1
perf(local): emit per-step denoise timings, so the baseline stops lying
lBroth Aug 3, 2026
1f00584
fix(local): an interrupted model download was unrecoverable and repor…
lBroth Aug 4, 2026
a699b4d
test(local): measure the tier ladder on real frames; replace projections
lBroth Aug 4, 2026
02b2e11
fix(test): scene-shot-routing depended on a gitignored fixture, so CI…
lBroth Aug 4, 2026
e6a955b
ci: bump every action to its current major (the Node 20 deprecation)
lBroth Aug 4, 2026
882a1d4
build: Electron 33 -> 43, so the shipped app finally runs Node 24 LTS
lBroth Aug 4, 2026
0d75080
fix(mac): add the entitlements plist signing has always pointed at; f…
lBroth Aug 4, 2026
3b82077
ci: run the Electron smoke tests on Windows and macOS, not just Linux
lBroth Aug 4, 2026
a3d98b4
fix: unstick interrupted renders, and stop setup.sh building the wron…
lBroth Aug 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 30 additions & 10 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,32 +22,52 @@ jobs:
os: [ubuntu-latest, windows-latest, macos-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 20
node-version: 24
cache: npm
- run: npm ci
- name: Typecheck
run: npm run typecheck
- name: Unit tests
run: npm run test:unit
# `npm test` is typecheck + test:unit + check:no-cloud, but CI only ran the first two, so the guard
# that keeps cloud calls out of a local-resolved render was never enforced on a PR.
- name: No-cloud guard
run: npm run check:no-cloud
- name: Build (main + renderer)
run: npm run build

# Electron integration: the offline engine self-test (real ffmpeg) + the renderer smoke test.
# Headless on Linux via xvfb; exit code is the pass/fail signal (app.exit(0|1)).
# Exit code is the pass/fail signal (app.exit(0|1)).
#
# Runs on all three OSes, not just Linux. Installers are shipped for Windows and Linux, but until now
# nothing had ever launched the app there — the matrix job below only typechecks, unit-tests and builds,
# which cannot catch a main-process crash, a broken preload bridge or an ffmpeg binary that does not
# resolve. On-device generation is Apple-Silicon-only by design (localCapabilities), so what this proves
# off macOS is the shell: the app boots, the bridge is up, the renderer mounts, and the bundled
# ffmpeg/ffprobe work — which is exactly what a Windows or Linux user on a cloud key depends on.
electron:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 20
node-version: 24
cache: npm
- run: npm ci
- run: npm run build
# Only Linux needs a virtual display; macOS and Windows runners have a window server.
- name: Engine self-test (offline ffmpeg path)
run: xvfb-run -a env VB_ENGINE_TEST=1 npx electron --no-sandbox .
run: ${{ matrix.os == 'ubuntu-latest' && 'xvfb-run -a ' || '' }}npx electron --no-sandbox .
env:
VB_ENGINE_TEST: '1'
- name: Renderer smoke test
run: xvfb-run -a env VB_SMOKE=1 npx electron --no-sandbox .
run: ${{ matrix.os == 'ubuntu-latest' && 'xvfb-run -a ' || '' }}npx electron --no-sandbox .
env:
VB_SMOKE: '1'
8 changes: 4 additions & 4 deletions .github/workflows/pages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,10 @@ jobs:
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/configure-pages@v5
- uses: actions/upload-pages-artifact@v3
- uses: actions/checkout@v7
- uses: actions/configure-pages@v6
- uses: actions/upload-pages-artifact@v5
with:
path: site
- id: deployment
uses: actions/deploy-pages@v4
uses: actions/deploy-pages@v5
10 changes: 5 additions & 5 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,10 @@ jobs:
os: [macos-latest, windows-latest, ubuntu-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 20
node-version: 24
cache: npm
- run: npm ci

Expand All @@ -38,7 +38,7 @@ jobs:

# CI artifacts for every run (also for workflow_dispatch, which has no tag/release).
- name: Upload build artifacts
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: installers-${{ matrix.os }}
path: |
Expand All @@ -51,7 +51,7 @@ jobs:
# Attach to the GitHub Release for the tag (each OS job appends its own files).
- name: Publish to release
if: startsWith(github.ref, 'refs/tags/')
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@v3
with:
fail_on_unmatched_files: false
files: |
Expand Down
14 changes: 14 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ node_modules/
# builds (electron main bundle + renderer + packaged installers)
dist/
build/
# ...except the signing entitlements, which package.json references and codesign needs.
!build/entitlements.mac.plist
renderer-dist/
release/
*.tsbuildinfo
Expand All @@ -15,6 +17,16 @@ __pycache__/
sidecar/build/
sidecar/dist/

# local-model sidecar: venv, downloaded/converted weights, and the path marker (code is tracked)
local/.venv/
local/models/
local/.model-path
local/.model-path-5b
local/.model-path-ltx
local/.lightning-dir
# generated by scripts/vendor-runtime.sh (uv+wheels live under build/ which is already ignored)
local/requirements.macos.lock

# bundled sidecar (built per-OS by scripts/build-sidecar.sh)
resources/sidecar

Expand All @@ -33,3 +45,5 @@ resources/sidecar

# encrypted key store (lives in userData at runtime; never commit if it lands in-tree)
keys.json
# Draw Things headless spike: binaries + model zoo (30GB+), never in the repo
local/dt/
70 changes: 37 additions & 33 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,25 @@
Rules any agent (human or AI) MUST follow in this repo. Violations have caused real bugs.

## What this is
Videoboom is an **open-source, bring-your-own-key (BYOK) desktop app** that turns a song into a music
video on the user's own machine. An **Electron** shell (`src/main`, `src/preload`, `renderer/`) runs an
**in-process TypeScript render engine** (`src/engine/`); ffmpeg ships bundled (`ffmpeg-static`). The user
pastes their own provider keys; the app calls those providers directly and the user pays them at cost.
**No accounts, no server, no wallet, no Python, nothing leaves the machine** except the generation API
calls. Ships for Windows / macOS / Linux.
Videoboom is an **open-source, local-only desktop app** that turns a song into a music video entirely on
the user's own machine. An **Electron** shell (`src/main`, `src/preload`, `renderer/`) runs an **in-process
TypeScript render engine** (`src/engine/`); ffmpeg ships bundled (`ffmpeg-static`). Every generation stage
runs **on-device** (Apple Silicon / MLX) through a resident Python sidecar (`local/server.py`). **No
accounts, no server, no wallet, no API keys** — the only network use is downloading the model weights once.
Nothing about the song or the video leaves the machine. Targets macOS (Apple Silicon).

> History: this was once a local-first Mac app (Phase 0), then an AWS serverless SaaS (coins / Cognito /
> DynamoDB / Step Functions). Both are gone — that code was deleted (recoverable from git history).
> Anything mentioning coins, wallets, Cognito, DynamoDB, S3, Lambda, or CDK is **historical**.
> History: this was once an AWS serverless SaaS (coins / Cognito / DynamoDB), then a bring-your-own-key
> cloud build (OpenRouter / Replicate). Both are gone — that code was deleted (recoverable from git
> history). Anything mentioning coins, wallets, Cognito/DynamoDB/S3/Lambda/CDK, **or cloud provider keys
> (OpenRouter, Replicate, `VB_*_MODEL` slugs, `safeStorage` keychain)** is **historical**.

## Docs
- **Keep docs in sync with the code.** Any change to the architecture, the render flow, the engine/IPC
contract, key storage, providers, or env/config MUST update the matching doc in the SAME change —
contract, the on-device model stack, or env/config MUST update the matching doc in the SAME change —
stale docs that claim the wrong behavior are treated as bugs.
- Current docs that MUST stay accurate (keep minimal + truthful): `README.md`, `AGENTS.md`,
`docs/ARCHITECTURE.md`, `docs/FOLDER-STRUCTURE.md`, `docs/PROVIDERS.md`, `docs/ROADMAP.md`.
`docs/ARCHITECTURE.md`, `docs/FOLDER-STRUCTURE.md`, `docs/MODELS.md`, `docs/LOCAL-MODELS.md`,
`docs/ROADMAP.md`.
- Don't hoard docs — few accurate ones beat many stale ones. `docs/research/*` is point-in-time
reference; truly dead docs are deleted, not left to rot.

Expand All @@ -36,19 +38,21 @@ calls. Ships for Windows / macOS / Linux.
- **English only** — all UI text, code, comments, docs, and commit messages. (Assistant chat replies may
match the user's language; anything written into the repo is English.)

## Keys & privacy (BYOK)
- API keys are the **user's**. Stored **encrypted via the OS keychain** (Electron `safeStorage`) in the
app's userData; decrypted only in-memory and injected into the render engine per operation as config
(`VB_OPENROUTER_API_KEY`, `REPLICATE_API_TOKEN`, …, read via `src/engine/config.ts`). **Never log
secrets**; never write them to the project store or to git. The gitignored `keys.json` must never be
committed.
- Nothing is uploaded to a Videoboom server — there is none. The only network calls are to the provider
APIs the user configured.
## Privacy (local-only)
- There are **no API keys and no secrets** — every stage runs on-device. Do NOT reintroduce cloud clients,
key storage (`safeStorage`/`keys.json`), or `VB_*_API_KEY` / provider-token env. Boot removes any stale
`keys.json` left by the old cloud build.
- Nothing is uploaded anywhere. The **only** network use is downloading model weights (Hugging Face) via
`local/setup.sh` / `local/download.py`. Generation itself is fully offline.

## Models are user-facing (the opposite of the old SaaS)
- This is BYOK: the user **chooses** the LLM / image / video models in **Settings**. Every model is a
`VB_*_MODEL` env var with a sensible default. Naming models in the UI/docs is fine here. Do NOT
re-introduce the old "hide the provider" stripping — that was a SaaS concern.
## On-device models
- Stages run locally on Apple Silicon (MLX) through the sidecar: STT (mlx-whisper), story/shot-list LLM
(mlx-lm), keyframes (mflux FLUX + Kontext), image-to-video (mlx-video Wan 2.2), portrait caption + safety
(mlx-vlm). The TypeScript wrappers in `src/engine/stages.ts` delegate to the `src/engine/local*.ts`
modules; keep that indirection (no cloud branch). Video model + fast/hd quality are chosen in Settings
(`src/main/settings.ts`, injected as `VB_*` env). See `docs/MODELS.md`.
- A machine that can't run on-device (not Apple Silicon, or under the RAM floor) is surfaced via
`localCapabilities()`; the required-hardware spec lives in ONE place (`HARDWARE_SPEC`).

## Engine contract
- The engine runs **in-process** (`src/engine/`), driven by `runEngine(command, args, env, onEvent)`
Expand All @@ -58,16 +62,21 @@ calls. Ships for Windows / macOS / Linux.
contract). Same command set as before: `create-project`, `render` (`--preview`), `resume`,
`regenerate-scene`, `character-create`, `character-portrait`, `get-project`.
- The engine is async throughout — it runs in the main process, so it must **never block the event loop**:
ffmpeg runs as async child processes, model calls are `fetch`. No `spawnSync` on hot paths.
ffmpeg runs as async child processes, model calls are async HTTP to the localhost sidecar. No `spawnSync`
on hot paths.
- State + media are **plain files** under `VB_DATA_DIR` (default the app's userData `data/`).
`src/engine/storage.ts` is **local filesystem only** — do not reintroduce any cloud coupling.
- ffmpeg/ffprobe come from `ffmpeg-static` / `ffprobe-static`; packaged builds `asarUnpack` them and the
engine rewrites `app.asar` → `app.asar.unpacked` in the binary path.

## Image / video generation
- **Video model = Kling** (`kwaivgi/kling-v3.0-std` for Fast, `-pro` for HD) via OpenRouter, first+last
frame morph (duration {5,10}). Kling animates realistic adults, children, AND toon. Keep
`genVideo()` model-agnostic so the user can swap in another i2v model from Settings.
- **Video model = Wan 2.2** on-device (mlx-video). The Fast/Quality choice IS the model choice: **Fast** =
FastWan-5B (DMD 3-step draft, `.model-path-5b` → FastWan2.2-TI2V-5B-MLX, marker-forced in
`local/wan_i2v.py`); **Quality** = Wan I2V-A14B bf16-relay. **Both finish at 1080p** — the shot renders
at 480p on-device, then the finish pass interpolates (RIFE) + upscales (Real-ESRGAN) to 1080p (native
1080p diffusion OOMs on-device). Each scene renders as one continuous shot of chained native sub-clips
(single start frame), then trims to the frame grid — never a stretched slow-mo clip. Keep the `5b`/`14b`
selection in `localVideo.ts` intact (`localVideoModel` in settings drives it).
- **Identity**: keyframes are built from the cast's reference portraits with a strong "reproduce every
facial feature exactly, no blending/de-aging" prompt; the cap (`VB_MAX_SUBJECTS`) must cover the whole
cast (a dropped reference = an invented subject). The clip animates the keyframe, so keyframe identity
Expand All @@ -77,13 +86,8 @@ calls. Ships for Windows / macOS / Linux.
explicit character/reference image.
- Prefer **medium/wide shots**; avoid tight close-ups until lip-sync is solved.

## Cost
- Track REAL provider cost in **cents** (`src/engine/cost.ts`, `costTotal()`); OpenRouter returns it in
`usage.cost` (send `usage:{include:true}`). The user sees the **at-cost** number — there is no margin
and no wallet. Record cost on success AND failure (partial cost on failed ops).

## Code quality
- **No duplication.** Shared logic lives in ONE helper (config/env reads, storage paths, cost accounting,
- **No duplication.** Shared logic lives in ONE helper (config/env reads, storage paths,
the progress event emitter). Never copy a block across the engine modules — extract it.
- **Reusable UI.** Build on the shared `renderer/components/ui.tsx` primitives (Button, Field, Card, …);
don't re-implement inputs/buttons/modals per screen.
Expand Down
Loading