diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fa8918c..60c97c0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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' diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 37cb0d1..6e9cebd 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f77c7f2..960c791 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 @@ -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: | @@ -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: | diff --git a/.gitignore b/.gitignore index c9049d3..9a63df7 100644 --- a/.gitignore +++ b/.gitignore @@ -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 @@ -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 @@ -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/ diff --git a/AGENTS.md b/AGENTS.md index b6cf586..4a106b3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. @@ -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)` @@ -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 @@ -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. diff --git a/DUAL_BACKEND_PLAN.md b/DUAL_BACKEND_PLAN.md new file mode 100644 index 0000000..5135909 --- /dev/null +++ b/DUAL_BACKEND_PLAN.md @@ -0,0 +1,443 @@ +# Videoboom DUAL_BACKEND_PLAN + +Status: FINAL architecture plan, 2026-07-05. Base: branch `local-only-pivot` (clean, 12-commit local-only conversion). Owner: lead architect. This document defines the **reverse pivot** from local-only back to a **local-default hybrid**: cloud inference is re-added behind the same clean per-stage interface the local-only refactor produced, restored from the pre-pivot cloud stack at git `5125093~1`. It supersedes the four dual-backend draft designs (1: interface/config/auto-config, 2: cloud restoration, 3: pipeline unification, 4: UI/UX); every verifier blocker and major is resolved in-text or carried as an explicit open question in §10. The local cross-platform/tier/catalog/ETA content of `LOCAL_PLAN.md` still governs the *local path* — this plan only adds the cloud path and the dispatcher between them. + +--- + +## 0. Summary + +**End state.** One render pipeline (`src/engine/pipeline.ts`) that names no backend. Each of the five stages — **STT, LLM, VLM, KEYFRAME, VIDEO** (moderation rides VLM) — has a **cloud impl** and a **local impl** behind one interface, selected per-stage by a pure resolver. Cloud = OpenRouter (LLM story/shot-list, VLM caption/safety, keyframe image, moderation) + Kling-via-OpenRouter (video i2v) + Replicate (WhisperX forced alignment), all restored from `5125093~1` and refit to the interface. Local = the shipped MLX stack (Qwen3 / gemma-3 / FLUX+Kontext / Wan 2.2 / whisper). The renderer never decides a backend; it displays what the resolver resolved. + +**The four hard invariants** (encoded at exactly one choke point — `pickBackend` in `src/main/autoconfig.ts`): + +- **I1 — No key ⇒ local.** A stage whose provider has no key is *always* local. Checked first, un-overridable. STT's provider is Replicate; LLM/VLM/KEYFRAME/VIDEO's provider is OpenRouter. +- **I2 — Key ⇒ still local by default.** A present key never activates cloud on its own. Cloud runs only where the user explicitly pinned that stage to cloud, or set the master control to `prefer-cloud`. `auto`/`prefer-local` with a key present ⇒ still local (privacy-first). +- **I3 — Tier never enables cloud.** Hardware tier only selects *which local model/quant*. An unsupported-hardware machine with no key stays local (render blocked, UI nudges "add a key + opt in") — it is **never** silently sent to cloud. +- **I4 — Zero-key/local-only-build users lose nothing.** The just-shipped local-only build's users migrate to all-`auto` stages (which resolve all-local under I1/I2), keep every local knob (`sttLang`, `workers`, `localVideoModel`, `localQuality`, `localWanDir`), and every local-relevant UI control stays reachable without a key. + +**The three structural rules that make "tutto uguale (più possibile)" true:** +1. `pipeline.ts` calls only `P.*` accessors from `stages.ts`; it holds **no** `if (backend==='cloud')` branch, **no** `VB_*_BACKEND` env read, **no** direct `genVideoLocal`/cloud import. +2. The two irreducible video divergences (Wan chains native sub-clips from a single start frame; Kling makes one clip/scene with a first+last morph) live **inside** each `renderScenes` impl, behind one signature — not in the pipeline (this is the blocker-fix against Design 3). +3. Everything else — segmentation profile, keyframe identity, frame-grid conform target, resolution normalization at concat, grade, cost — is **shared** or read off the resolved backend object. + +--- + +## 1. The per-stage backend interface + +### 1.0 File layout + +``` +src/engine/ + backends/ + types.ts // the five stage interfaces + Stage/Backend types (NEW) + registry.ts // stageBackend() → impl object, for all five stages (NEW) + sceneShared.ts // backend-agnostic scene helpers shared by both video impls (NEW) + cloud/ // restored from 5125093~1:src/engine/providers.ts, split per stage (NEW) + http.ts llm.ts stt.ts vlm.ts keyframe.ts video.ts + localLlm.ts localStt.ts localVlm.ts localKeyframe.ts localVideo.ts // KEPT as-is + stages.ts // dispatchers: bodies delegate to registry; signatures unchanged (EDIT) + config.ts // re-add stageBackend() (EDIT) + cost.ts // restored verbatim from 5125093~1 (NEW) + ffmpeg.ts // re-add fitToWindow from 5125093~1 (EDIT) + pipeline.ts // route video through P.video(); drop hardcoded-local (EDIT) +``` + +`check-no-cloud.sh`'s path-keyed allowlist is exactly `src/engine/cloud/**`, `src/engine/cost.ts`, `src/main/keychain.ts`, `src/shared/netAllowlist.ts` — every provider URL lives only there (§6). + +### 1.1 `backends/types.ts` — the interfaces + +Each method carries the *exact* signature the current `stages.ts` already exposes, so `pipeline.ts`'s call sites are untouched. There are **five** stages; moderation is a method on the VLM interface (same provider, same module — no sixth stage, no `VB_MODERATION_BACKEND`; resolves the Design-1 dead-var minor). + +```ts +import type { Word, STTResult } from '../stages'; +export type Backend = 'cloud' | 'local'; +export type Stage = 'STT' | 'LLM' | 'VLM' | 'KEYFRAME' | 'VIDEO'; // moderation ⊂ VLM + +export interface LlmBackend { + // role tags the call so cloud picks VB_STORY_MODEL vs VB_LLM_MODEL; local ignores it (see §1.3). + llmJson(system: string, user: string, schema: any, + role: string | undefined, maxTokens: number, temperature: number): Promise; +} +export interface SttBackend { + // {ok:true,words:[]} = legitimate instrumental; {ok:false} = genuine failure. Same contract both sides. + transcribeWords(audioPath: string): Promise<{ ok: boolean; words: Word[]; error?: string }>; +} +export interface VlmBackend { + vlmCaption(imgPath: string): Promise; + moderateImage(path: string): Promise<[boolean, string[]]>; // fails OPEN +} +export interface KeyframeBackend { + keyframe(prompt: string, out: string, refs: [string, string][], toon: boolean): Promise; + concurrency(): number; // local → workers() (GPU-serial); cloud → max(workers, VB_KF_WORKERS) + needsGpu(): boolean; // local → true; cloud → false (used by the GPU lock, §5/§7) +} +export interface VideoBackend { + // ONE entry point owns the WHOLE keyframe+clip loop for these scenes (both continuity models). + renderScenes(ctx: SceneRenderCtx): Promise; + // single-scene refresh (+ neighbour re-render for the cloud morph); used by regenerateScene. + refreshScene(ctx: SceneRenderCtx, k: number, vary: string): Promise<[boolean, string]>; + timelineRes(): { w: number; h: number }; // local 832×480 · cloud 1280×720 — drives VB_W/VB_H (§5, blocker-fix) + needsUpscale(): boolean; // local → ESRGAN→1080 · cloud → light scale→1080 + needsGpu(): boolean; // local → true · cloud → false (GPU lock) +} +export interface SceneRenderCtx { + pid: string; p: any; toRender: number[]; target: number; + toon: boolean; emit: Emit; cancelled: Cancelled; +} +``` + +**Why `renderScenes` and not `renderScene` (Design-3 blocker fix).** Design 3 exposed a singular `renderScene(startImg, endImg, …)`, which forces the scene *loop* to stay in `pipeline.ts` and re-introduces the exact `if (seq) {…LOCAL…} else {…CLOUD…}` branch (with cloud-only `endImg`/`kf[k+1]` threading) that HARD CONSTRAINT #1 forbids. We adopt Design 1/2's `renderScenes(ctx)`: the entire keyframe pass + clip loop lives inside each impl. The pipeline calls one `await P.video().renderScenes(ctx)` with no `seq`/`parallel` branch, no `endImg` threading, no `LOCAL`/`CLOUD` comment. The sequential-chain vs parallel-morph split is the one irreducible divergence and it lives where it belongs — in the two impls. + +### 1.2 `backends/registry.ts` — dispatch + +```ts +import { stageBackend } from '../config'; +import type * as T from './types'; +export const stt = (): T.SttBackend => + stageBackend('STT') === 'cloud' ? require('../cloud/stt').cloudStt : require('./local/stt').localStt; +export const llm = (): T.LlmBackend => + stageBackend('LLM') === 'cloud' ? require('../cloud/llm').cloudLlm : require('./local/llm').localLlm; +export const vlm = (): T.VlmBackend => + stageBackend('VLM') === 'cloud' ? require('../cloud/vlm').cloudVlm : require('./local/vlm').localVlm; +export const keyframe = (): T.KeyframeBackend => + stageBackend('KEYFRAME') === 'cloud' ? require('../cloud/keyframe').cloudKeyframe : require('./local/keyframe').localKeyframe; +export const video = (): T.VideoBackend => + stageBackend('VIDEO') === 'cloud' ? require('../cloud/video').cloudVideo : require('./local/video').localVideo; +``` + +`local/*.ts` are thin adapters wrapping the existing `src/engine/local*.ts` (no file moves) into the interface shape; `require` (not top-level import) keeps a cloud-only render from loading the Python-driven local modules and vice-versa. `stageBackend` defaults to `'local'` (§2.1) so a missing env var never routes to cloud (defense-in-depth for I1–I3). + +### 1.3 STT stage — instrumental-vs-failure is the whole game + +- **Local** (`localStt.transcribeWordsLocal`, unchanged): already returns `{ ok, words, error? }` (`src/engine/localStt.ts:8`). +- **Cloud** (`cloud/stt.ts`, from `5125093~1:src/engine/providers.ts` private `transcribeWhisperx` ≈ :204 + public `transcribeWords` :254): the PRE code returned `null` for **both** a real failure and a zero-word instrumental (`if (!words.length) return null;`). **This must be reworked** (major fix): the cloud wrapper returns + - `{ ok:false, error }` on **any** submit / poll-timeout / HTTP / status≠succeeded / missing-token failure, and + - `{ ok:true, words:[] }` **only** for a genuinely empty *successful* transcript. + + Concretely: drop the `if (!words.length) return null` collapse; keep `toMp3_16k` + `data:audio/mpeg` inline + `version:VB_WHISPERX_VERSION` + `align_output:true` + 4 s poll / 300 s deadline + `costAdd(VB_WHISPERX_CENTS)`. The dispatcher `stages.transcribe` (unchanged shape, current `stages.ts:95`) wraps `{ok,words,error}` into `STTResult`, so `pipeline.ts:245-250`'s `if (!stt.ok)` behaves identically for both backends and an instrumental is never mistaken for a Replicate outage. + +### 1.4 LLM stage — two models behind one call (major fix) + +The current shared `storyBible` (`stages.ts:62-82`) passes `model=undefined` to `llmJson`. Pre-pivot, `storyBible` explicitly supplied `VB_STORY_MODEL` (`providers.ts:172-173`) so the narrative bible ran on `claude-sonnet` while the shot-list ran on `gemini-flash` (`VB_LLM_MODEL`, `providers.ts:89`). A cloud impl reading only `VB_LLM_MODEL` cannot tell the two calls apart. Fix: **thread a `role` tag** through the fourth positional arg (which currently is the vestigial `_model?`): + +```ts +// stages.ts (dispatcher) +export async function llmJson(system, user, schema, role?: string, maxTokens = 4000, temperature = 0.7) { + return R.llm().llmJson(system, user, schema, role, maxTokens, temperature); +} +// stages.ts storyBible — pass the tag instead of undefined: +return llmJson(system, user, BIBLE_SCHEMA, 'story', mt, 0.5); +// pipeline.ts shot-list call stays `P.llmJson(sys, user, SCENES_SCHEMA, undefined, …)` (→ shot-list model). +``` + +```ts +// cloud/llm.ts +llmJson(system, user, schema, role, mt, temp) { + const model = role === 'story' + ? env('VB_STORY_MODEL', 'anthropic/claude-sonnet-4.6') + : env('VB_LLM_MODEL', 'google/gemini-3.5-flash'); + /* verbatim body from providers.ts:84-116: response_format json_schema, 3× retry, strip, + brace-slice fallback, costAdd(orCost(r)) */ +} +``` + +`local/llm.ts` ignores `role` and calls `llmJsonLocal(system, user, schema, mt, temp)` (`localLlm.ts:27`). `llmComplete` (`providers.ts:56`) is restored into `cloud/llm.ts` for parity though the pipeline never calls it. + +### 1.5 VLM + moderation stage + +`cloud/vlm.ts` = `vlmCaption` (`providers.ts:332`) + `moderateImage` (`providers.ts:359`, fails OPEN, `moderationUri` downscale — `ffmpeg.moderationUri` still present at `ffmpeg.ts:87`). `moderateImage` keys off `stageBackend('VLM')` — no separate MODERATION stage. `local/vlm.ts` wraps `vlmCaptionLocal` / `moderateImageLocal` (`localVlm.ts:20,33`). + +### 1.6 KEYFRAME stage + +`cloud/keyframe.ts` = `cloudKeyframe` (`providers.ts:277`), signature already interface-shaped: no-text/no-Asian clause, `TOON_STYLE` (imported from `stages.ts`, not duplicated), refs inlined as `image_url`, `modalities:['image','text']`, `input_fidelity:high` for gpt models, 4× retry. `concurrency()` = `Math.max(workers(), envInt('VB_KF_WORKERS', 4))` (network-parallel) for cloud, `workers()` (GPU-serial) for local — this is the single home of the pre-pivot `kfWorkers` one-liner (`5125093~1:pipeline.ts:43`). `needsGpu()` = local-only. + +### 1.7 VIDEO stage — the irreducible pair, both loops inside the impl + +Both impls import shared helpers from `backends/sceneShared.ts`: `buildKeyframe`, `keyframePath`, `refsForScene`, `decideCutContinue`, `keyframePassOrdered`, `finishClip` (conform + first-frame-thumbnail + scene status/emit — the tail of today's `renderClip`, `pipeline.ts:446-459`), `assemble`. Only the loop shape and the generate call differ. + +- **`local/video.ts` `localVideo.renderScenes(ctx)`** = today's `renderScenesLocalChained` (`pipeline.ts:465-513`) moved verbatim: `decideCutContinue` (LLM cut/continue + anti-drift `VB_LOCAL_CHAIN_MAX` cap), keyframes for **cut** scenes only via `keyframePassOrdered(concurrency=P.keyframe().concurrency())`, then a **sequential** clip loop where a `continue` scene starts from the previous clip's real last frame. Per-clip generation is today's `renderLocalScene` (`pipeline.ts:393-422`): chain `ceil(wdur / nativeSec)` native sub-clips, each `genVideoLocal` i2v from the prior clip's `lastFrame`, `concatClips`, then `finishClip` → `trimToWindow`. RIFE 2× stays inside `genVideoLocal` (invisible). `timelineRes()` = `{832,480}`, `needsUpscale()` = true, `needsGpu()` = true. `endImg` does not exist here (Wan takes a single start frame). +- **`cloud/video.ts` `cloudVideo.renderScenes(ctx)`** = the pre-pivot standard path (`5125093~1:pipeline.ts:584-608`): one keyframe **per** scene, ordered pass at `concurrency=P.keyframe().concurrency()`, then a **`mapPool(renderable, workers())` parallel** clip pass where each scene calls `genVideo(kf[k], prompt, raw, wdur, kf[k+1] ?? null, p.videoModel ?? null, seed)` (`providers.ts:414`) — the `kf[k+1]` first+last morph and the per-project `p.videoModel` slug override (`5125093~1:pipeline.ts:437`, minor fix), then `finishClip` → `fitToWindow`. `timelineRes()` = `{1280,720}`, `needsUpscale()` = false, `needsGpu()` = false. + +`finishClip` picks trim-vs-fit from the same `VideoBackend` object (`raw ≥ window ? trim : fit`) — both target the identical telescoping frame grid `round(end·fps) − round(start·fps)`; only cut-vs-retime differs. `conformClip` (`ffmpeg.ts:136`, shared, unchanged) scale-fits+pads every clip to the current `vW×vH` timeline at concat, so a project rendered under one backend concatenates cleanly. + +**`refreshScene` (regenerateScene major fix).** `regenerateScene` (`pipeline.ts:666`) is a separate single-scene call site; the video interface owns it so the cloud morph survives. `localVideo.refreshScene(ctx, k, vary)`: fresh keyframe → `renderClip(start=kf, end=null)`. `cloudVideo.refreshScene(ctx, k, vary)`: fresh keyframe `fk` → compute `lastK = keyframe(k+1)` → `renderClip(k, fk, lastK)`, then re-render neighbour `k-1` with `fk` as **its** `last_frame` (restores `5125093~1:pipeline.ts:710-716`), so both scene boundaries morph correctly. `rerenderClips` (`pipeline.ts:642`) resets done scenes to pending and calls `renderScenes` — already backend-neutral, no special casing. + +--- + +## 2. Config & settings — v2 → v3 + +**One schema for all four artifacts** (blocker fix: Designs 1/2/4 forked three shapes). We adopt the **nested `mode`/`backend`** shape (the master audit's pick: it cleanly separates "follow the master control" from an explicit pin and matches the tri-state UI) with **UPPERCASE stage-id keys** (Design 2's casing — the audit-confirmed correct one: `localModels.STAGE_REPOS`, `modelStatus()`, `DL_STAGES`, and the `VB__BACKEND` env names are all uppercase; Design 4's lowercase would miss every `modelsStatus()` lookup). Cloud slugs use the **long git names** (`storyModel`…) required-and-seeded-from-DEFAULTS so `toEnv` always emits. + +### 2.1 `config.ts` — re-add `stageBackend` (default local) + +```ts +export function stageBackend(stage: string, d = 'local'): 'cloud' | 'local' { + return env('VB_' + stage + '_BACKEND', d) === 'cloud' ? 'cloud' : 'local'; +} +``` + +Default flips from the pre-pivot `'cloud'` to `'local'`. The resolver always emits an explicit `VB__BACKEND`, so this default is only a failsafe — and the failsafe is local. + +### 2.2 `src/main/settings.ts` — v3 schema (SETTINGS_VERSION = 3) + +```ts +export type Stage = 'STT' | 'LLM' | 'VLM' | 'KEYFRAME' | 'VIDEO'; +export type Backend = 'cloud' | 'local'; +export interface StageSelection { mode: 'auto' | 'manual'; backend?: Backend; } // backend only when mode:'manual' +export interface CloudModels { + storyModel: string; llmModel: string; keyframeModel: string; + videoModel: string; vlmModel: string; moderationModel: string; +} +export interface Settings { + settingsVersion: number; // 3 + backendPreference: 'auto' | 'prefer-local' | 'prefer-cloud'; // master control + stages: Record; // per-stage auto/pin + cloud: CloudModels; // OpenRouter/Replicate slugs (advanced) + // ── preserved v2 local knobs (unchanged, still emitted for local-resolved stages) ── + sttLang: string; workers: number; + localVideoModel: '5b' | '14b'; localQuality: 'fast' | 'hd'; localWanDir: string; +} +export const DEFAULTS: Settings = { + settingsVersion: 3, + backendPreference: 'auto', + stages: { STT:{mode:'auto'}, LLM:{mode:'auto'}, VLM:{mode:'auto'}, KEYFRAME:{mode:'auto'}, VIDEO:{mode:'auto'} }, + cloud: { + storyModel: 'anthropic/claude-sonnet-4.6', llmModel: 'google/gemini-3.5-flash', + keyframeModel: 'google/gemini-3.1-flash-image', videoModel: 'kwaivgi/kling-v3.0-std', + vlmModel: 'google/gemma-3-12b-it', moderationModel: 'google/gemini-3.5-flash', + }, + sttLang: '', workers: 4, localVideoModel: '14b', localQuality: 'fast', localWanDir: '', +}; +``` + +### 2.3 Migration (I4 — the shipped local-only build stays byte-for-byte local) + +`migrate(raw)` handles v2 (local-only, current), v1 (pre-pivot flat cloud), and unknown blobs: + +```ts +function migrate(raw: any): Settings { + const str = (x:any, d:string) => typeof x === 'string' && x ? x : d; + const sel = (b:any): StageSelection => (b === 'local' ? {mode:'manual', backend:'local'} : {mode:'auto'}); + return { + settingsVersion: 3, + // v2 has no backendPreference → 'auto'. v1 may carry it; else 'auto'. + backendPreference: raw?.backendPreference === 'prefer-local' || raw?.backendPreference === 'prefer-cloud' + ? raw.backendPreference : 'auto', + stages: { + // v1 flat fields (sttBackend/…) → 'local' becomes a manual pin; 'cloud'/absent → 'auto' + // (NOT a cloud pin — that would violate I2; the user re-opts-in explicitly after migrating). + STT: raw?.stages?.STT ?? sel(raw?.sttBackend), + LLM: raw?.stages?.LLM ?? sel(raw?.llmBackend), + VLM: raw?.stages?.VLM ?? sel(raw?.vlmBackend), + KEYFRAME: raw?.stages?.KEYFRAME ?? sel(raw?.keyframeBackend), + VIDEO: raw?.stages?.VIDEO ?? sel(raw?.videoBackend), + }, + cloud: { + storyModel: str(raw?.cloud?.storyModel ?? raw?.storyModel, DEFAULTS.cloud.storyModel), + llmModel: str(raw?.cloud?.llmModel ?? raw?.llmModel, DEFAULTS.cloud.llmModel), + keyframeModel: str(raw?.cloud?.keyframeModel ?? raw?.keyframeModel, DEFAULTS.cloud.keyframeModel), + videoModel: str(raw?.cloud?.videoModel ?? raw?.videoModel, DEFAULTS.cloud.videoModel), + vlmModel: str(raw?.cloud?.vlmModel ?? raw?.vlmModel, DEFAULTS.cloud.vlmModel), + moderationModel: str(raw?.cloud?.moderationModel ?? raw?.moderationModel, DEFAULTS.cloud.moderationModel), + }, + // ── PRESERVED v2 local fields, verbatim (I4) ── + sttLang: str(raw?.sttLang, ''), + workers: Number.isFinite(raw?.workers) ? Number(raw.workers) : 4, + localVideoModel: raw?.localVideoModel === '5b' ? '5b' : '14b', // 'ltx' legacy → '14b' + localQuality: raw?.localQuality === 'hd' ? 'hd' : 'fast', + localWanDir: str(raw?.localWanDir, ''), + }; +} +``` + +The existing persist-on-version-bump in `getSettings` (`settings.ts:59-65`) is kept: a v2 blob rewrites once at v3 with all-`auto` stages. Because a v2 user has **no `keys.json`**, the resolver forces every `auto` stage local (I1) — a byte-for-byte continuation of today's render. + +### 2.4 `settingsEnv()` → `resolveConfig(...).toEnv()` + +`main/index.ts`'s `sidecarEnv()` (currently `{...settingsEnv()}`, `index.ts:66-68`) becomes `{...keysEnv(), ...resolveConfig(caps, getSettings(), keyStatus()).toEnv()}`. `keysEnv()` (restored keychain) supplies `VB_OPENROUTER_API_KEY` / `REPLICATE_API_TOKEN`; `toEnv()` supplies the resolved backends + slugs + local block. `renderer/vb.d.ts`'s `Settings` mirrors §2.2 in lockstep. + +--- + +## 3. The auto-config resolver — `src/main/autoconfig.ts` + +Pure function, no IO — key state and hardware capability are passed as data so the estimator sees the same resolution the render will use. + +```ts +export interface KeyState { openrouter: boolean; replicate: boolean; } +export interface ResolvedStage { backend: Backend; reason: 'no-key'|'pinned'|'preference'|'default'; localAvailable: boolean; } +export interface ResolvedConfig { stages: Record; toEnv(): Record; } + +const PROVIDER: Record = { + STT:'replicate', LLM:'openrouter', VLM:'openrouter', KEYFRAME:'openrouter', VIDEO:'openrouter', +}; + +function pickBackend(stage: Stage, sel: StageSelection, master: string, keys: KeyState): {backend: Backend; reason: ResolvedStage['reason']} { + if (!keys[PROVIDER[stage]]) return { backend:'local', reason:'no-key' }; // I1 — hard, first, un-overridable + if (sel.mode === 'manual') return { backend: sel.backend ?? 'local', reason:'pinned' }; + // mode:'auto' → follow master. TIER IS ABSENT HERE (I3): tier can never flip a stage to cloud. + if (master === 'prefer-cloud') return { backend:'cloud', reason:'preference' }; // global opt-in + key + return { backend:'local', reason:'default' }; // auto / prefer-local → local (I2) +} +``` + +**Tier's role (initial scope + M5 dependency, resolving the "resolver depends on non-existent infra" major).** Phase-(a) backend selection above depends on **only** `keyState` + `settings` — both exist today, so the hybrid dispatcher ships **now**. Tier appears **only** in phase-(b): choosing the local variant (`modelId@quant`, `WxH`, `frames`, `steps`, offload) for stages that resolved local. Phase-(b) initially reads the **existing binary** signal — `localCapabilities()` (`localModels.ts:70`, `supported`/`reason`) plus the shipped `localVideoModel`/`localQuality` settings — and emits today's `VB_LOCAL_*` block unchanged. Tier-aware variant selection is a **follow-on gated on LOCAL_PLAN M5** (`hardware.ts` / `tiers.ts` / `DeviceProfile`); until then `resolveConfig`'s third argument is `LocalCapabilities`, later widened to `DeviceProfile`. No design step assumes M5 infra for the backend decision. + +**Unsupported-local + partial-key hard-block (major fix).** When a stage resolves **local** but its local model is absent/unrunnable on this machine, `ResolvedStage.localAvailable=false`. `guardRender` (§5) then blocks the render and the UI names the **specific missing provider key per blocked stage** — e.g. a machine that can't run local, with only an OpenRouter key: LLM/VLM/KEYFRAME/VIDEO resolve cloud, but STT resolves local (no Replicate key) and is unavailable ⇒ "STT needs a Replicate key on this machine (on-device STT isn't supported here)." STT is independently gateable on Replicate in the onboarding CTA (§7). + +### 3.1 `toEnv()` — the Settings→engine bridge + +Per stage, emit `VB_STT_BACKEND / VB_LLM_BACKEND / VB_VLM_BACKEND / VB_KEYFRAME_BACKEND / VB_VIDEO_BACKEND` from `stages[X].backend` (five vars, no `VB_MODERATION_BACKEND`). Always emit the six cloud slugs (`VB_STORY_MODEL, VB_LLM_MODEL, VB_KEYFRAME_MODEL, VB_OR_VIDEO_MODEL, VB_VLM_MODEL, VB_MODERATION_MODEL`). Emit the **timeline resolution** from the resolved VIDEO backend (blocker fix): `VB_W/VB_H = 1280/720` when `VIDEO==='cloud'`, else `832/480`; matching `VB_LOCAL_KEYFRAME_W/H` only when KEYFRAME is local. Emit the `VB_LOCAL_*` block (model/quality/steps/wan-dir) only for local-resolved stages. Force `VB_WORKERS='1'` **only when `VIDEO==='local'`** (the GPU serialization); an all-cloud render keeps `workers`. + +### 3.2 Decision table (mode × key × master × tier) + +| # | Provider key | Stage `mode`/`backend` | Master | Local runnable? | → Backend | Reason / invariant | +|---|---|---|---|---|---|---| +| 1 | absent | any | any | yes | **local** | I1 | +| 2 | absent | manual/cloud | prefer-cloud | yes | **local** | I1 beats pin + master | +| 3 | absent | any | any | **no** | **local → render BLOCKED**, UI: "add {provider} key" | I1 + I3 (never silent cloud) | +| 4 | present | auto | auto | yes | **local** | I2 (privacy-first default) | +| 5 | present | auto | prefer-local | yes | **local** | I2 | +| 6 | present | auto | prefer-cloud | — | **cloud** | global opt-in + key (I3-allowed) | +| 7 | present | manual/cloud | auto | — | **cloud** | explicit per-stage opt-in | +| 8 | present | manual/local | prefer-cloud | yes | **local** | pin beats master | +| 9 | present | manual/local | any | **no** | **local → BLOCKED**, UI: "unsupported here — pin cloud" | I3 | +| 10 | present | auto | auto | **no** | **local → BLOCKED**, UI: "add a key + opt in / pin cloud" | I3 — never silent cloud | + +Fresh install, no keys → rows 1/3 → all-local, offline. Add an OpenRouter key, change nothing → rows 4/5 → still fully local. The only routes to cloud are an explicit per-stage pin (7) or a deliberate `prefer-cloud` master with a key (6). The `(auto × key-present × unsupported-tier)` cell resolves **local-then-blocked** (row 10), never cloud — this is the exact ambiguous cell the audit flagged; I3 wins, no `tier-fallback` reason exists. + +--- + +## 4. Cloud restoration — which `5125093~1` code lands where + +| Concern | Restore from `5125093~1` | Lands at | Refit | +|---|---|---|---| +| Shared HTTP plumbing | top of `providers.ts` (`OR`, `orHdr`, `httpJson`, `dataUri`, `sleep`, `HttpErr`) | `src/engine/cloud/http.ts` | extract verbatim | +| LLM (+ story/shot-list split) | `llmComplete` :56, `llmJson` :84 | `cloud/llm.ts` | `role` tag → `VB_STORY_MODEL`/`VB_LLM_MODEL` (§1.4) | +| STT | `transcribeWhisperx` ≈:204, `transcribeWords` :254 | `cloud/stt.ts` | return `{ok,words,error}`, split failure vs instrumental (§1.3) | +| VLM + moderation | `vlmCaption` :332, `moderateImage` :359 | `cloud/vlm.ts` | verbatim; import `TOON_STYLE`/`isContentBlock` from `stages.ts` | +| KEYFRAME | `cloudKeyframe` :277 | `cloud/keyframe.ts` | verbatim + `concurrency()`/`needsGpu()` | +| VIDEO (Kling) | `genVideo` :414 | `cloud/video.ts` | wrapped in `renderScenes`/`refreshScene` loop + `p.videoModel` override | +| `MOTION`, `isContentBlock`, `TOON_STYLE`, schemas | already in current `stages.ts` | — | keep the shared copies; delete provider duplicates | +| Cost tracking | `cost.ts` (`costReset/costAdd/costTotal/orCost`) | `src/engine/cost.ts` | restore verbatim | +| Keychain (2-key `ENV_MAP`) | `keychain.ts` | `src/main/keychain.ts` | restore verbatim | +| Keys IPC (`keys:status`/`keys:set`) | `main/index.ts` + `preload/index.ts` | same | restore | +| `fitToWindow` | `ffmpeg.ts:128` | re-add to current `ffmpeg.ts` | verbatim (`trimToWindow`/`conformClip`/`toMp3_16k`/`moderationUri` already present) | + +**Keychain wiring.** Restore `keychain.ts` verbatim; **remove** the boot-time `keys.json` deletion (`main/index.ts:263`) — the keychain owns that file again. `keysEnv()` returns `{}` when no keys exist, so a keyless user injects zero secrets. + +**Cost wiring (minor fix).** Call `costReset()` once per op in `engine/index.ts` `runEngine` (right after `setEnv(...)`, `index.ts:110`) — the pivot deleted the old call site, so without this the module-level cents accumulate across renders. Add `costCents` to the engine's `EngineEvent` `'done'` variant (`engine/index.ts:15`) **and** the renderer `SidecarEvent` (`vb.d.ts:29-33`); `assemble` emits `costCents: costTotal()` on `done` (`pipeline.ts:854`). An all-local render ends at `0`. + +--- + +## 5. Pipeline unification — `pipeline.ts` names no backend + +**Grounding correction (major fix).** The current `pipeline.ts` has **zero** `VB_VIDEO_BACKEND` reads — the pivot removed all backend branching. The video path is **hardcoded-local**: `import { genVideoLocal, localNativeFps, localMaxFrames } from './localVideo'` (`:27`), `renderLocalScene` (`:393`/called `:439`), `trimToWindow` (`:448`), the `VB_LOCAL_CHAIN` split into `renderScenesLocalChained` (`:548-549`), and the `VB_LOCAL_UPSCALE` gate in `assemble` (`:788`). The transform is **hardcoded-local → resolver-bound `P.video()`** — not "delete env branches." + +Edits: + +1. **Imports (`:25-27`).** Drop the direct `genVideoLocal, localNativeFps, localMaxFrames` import (they move into `local/video.ts`). Drop `trimToWindow` (now chosen by `finishClip` inside the impl); keep `conformClip`, `FPS`, etc. +2. **`stages.ts` gains `export const video = () => R.video()`** and `export const keyframeConcurrency = () => R.keyframe().concurrency()`. +3. **Segmentation (`:255-260`).** Segmentation profile is **unified** across backends — both use the current `{target:6, max:12, min:3}` (Kling's 3–15 s window comfortably contains a 6 s target; Wan already chains at 6 s). So `segmentSong` is called with the shared profile; `storyboardHash` stays **audio-only** (`audioFingerprint`, `:343`/`:613`) and STT + bible + shot-list remain byte-identical across backends and fully cached across a backend switch (this dissolves the "fold VIDEO into storyboardHash over-invalidates" minor — nothing folds in). +4. **The scene loop (`:529-590`, `:465-513`).** Both `renderScenes` and `renderScenesLocalChained` move **out** of `pipeline.ts` into the two video impls (§1.7). `pipeline.render`/`resume`/`rerenderClips` build a `SceneRenderCtx` and call `await P.video().renderScenes(ctx)`, then the shared `assemble`. `regenerateScene` (`:666`) calls `await P.video().refreshScene(ctx, k, vary)`. The keyframe pool width comes from `P.keyframeConcurrency()`, not `workers()`. +5. **`assemble` finish (`:788`, minor fix).** `if (envBool('VB_LOCAL_UPSCALE', true))` → `if (P.video().needsUpscale())`. Local → ESRGAN sidecar → 1080. Cloud → a light ffmpeg lanczos scale → 1080 (no sidecar), so a paid Kling render finishes at **1080p from a 720p base**, not the 432p collapse the audit found. `grade` + `conformClip` stay shared and unchanged. `costCents` added to the `done` emit. +6. **Mixed-backend timeline (major fix — invalidate, don't mix).** Record `clipsBackend` (resolved VIDEO backend + `timelineRes` signature) on the project when clips render. On render, if the current resolved VIDEO signature ≠ `clipsBackend`, reset every `done` scene to `pending` before `renderScenes` so the whole timeline re-renders under **one** backend at **one** resolution — no 432p-local-next-to-720p-cloud concat, no single global `needsUpscale` applied to the wrong clips. Because segProfile is unified (step 3), scene windows are stable across the switch and the storyboard is fully reused; only clips + finish re-run. The UI confirms "switching the video backend re-renders all clips." + +**Divergence ledger (what's unified vs declared-per-backend-hint):** + +| Divergence | Decision | Where | +|---|---|---| +| Scene→clip mapping (Kling 1 clip / Wan `ceil(wdur/2.3)` sub-clips) | **irreducible — hidden** | inside each `renderScenes` | +| Continuity (sequential chain / parallel keyframe-morph) | **irreducible — hidden** | each `renderScenes` loop | +| i2v conditioning (Wan single start frame / Kling first+last morph) | **irreducible — hidden** | `renderClip`/`genVideo` call, `endImg` cloud-only, internal | +| Segmentation profile | **UNIFIED** to `{6,12,3}` | shared `segmentSong` call | +| Keyframe identity (refs, anchor, toon, non-empty contract) | **UNIFIED** (byte-identical) | shared `buildKeyframe` | +| Keyframe concurrency (GPU-serial / network-parallel) | **declared, off KEYFRAME backend** | `P.keyframe().concurrency()` | +| Frame-grid conform (trim / fit) | **declared, same grid** | `finishClip` reads the video backend | +| Timeline resolution | **declared** (832×480 / 1280×720) | `timelineRes()` → `VB_W/VB_H` | +| Concat normalization | **UNIFIED** | `conformClip`, unchanged | +| fps → 24; RIFE interp | **local-only compensation, hidden** | inside `genVideoLocal` | +| Upscale → 1080 | **declared finish** | `needsUpscale()` (both reach 1080) | +| Grade (film-grade + grain) | **UNIFIED, both backends** | `assemble`, unchanged | +| STT quality (WhisperX > mlx-whisper) | **irreducible; result type + instrumental policy unified** | `STTResult` | +| Cost | **additive, 0 for local** | `cost.ts` | + +--- + +## 6. Network policy for the hybrid (build now, not deferred to M6) + +The three artifacts the LOCAL_PLAN M6 lockdown described as "modify" **do not exist yet** and are **created** as part of cloud restoration (minor fix — without them, I3 is enforced only by a bug-free resolver, with no deny-by-default backstop and no CI grep once provider URLs return): + +**`src/shared/netAllowlist.ts` (new).** Single source of truth, per-provider, consumed by the Electron `session` firewall and `check-no-cloud.sh`: + +```ts +export const CLOUD_HOSTS = { + openrouter: ['openrouter.ai'], // LLM, VLM, keyframe, moderation, Kling + replicate: ['api.replicate.com', 'replicate.delivery', '*.replicate.delivery'], // WhisperX submit/poll + result +}; +``` + +**Policy (three rules):** +1. **Local stages never touch the network.** Local inference keeps `HF_HUB_OFFLINE=1`, `TRANSFORMERS_OFFLINE=1` on its children; the cloud dispatcher runs in-process (main/`cloud/http.ts`), never a child, and those offline vars are never set for it. +2. **Cloud stages talk only to their provider host, only when opted in.** The main-process `session.webRequest.onBeforeRequest` firewall denies by default and allows a host **only if** (a) it is in `CLOUD_HOSTS[provider]`, (b) `keyStatus()[provider]` is true, **and** (c) at least one stage resolved to that provider's cloud backend. Model-download / bootstrap hosts remain the separate LOCAL_PLAN §3.4 download-time allowlist. +3. **No key ⇒ zero inference network.** `keysEnv()` returns `{}` and the resolver forces every stage local (I1), so no cloud host is ever contacted. + +**Enforcement + tests:** +- **`scripts/check-no-cloud.sh` (created, inverted, path-keyed).** Assert `https?://` appears **nowhere outside** the allowlist (`src/engine/cloud/**`, `cost.ts`, `keychain.ts`, `netAllowlist.ts`) — i.e. `src/engine/local*.ts`, `pipeline.ts`, `stages.ts` are URL-free — and every host string in `cloud/**` is present in `netAllowlist.ts`. +- **`test/no-cloud-without-optin.test.ts` (new unit test, runs under `npm test`).** `resolveConfig(caps, settings, {openrouter:false, replicate:false})` across all masters ⇒ every stage `backend:'local'`. Keys present but all stages `mode:'auto'` ⇒ still all-local (auto never self-selects cloud — I2). Cloud only for explicit `manual/cloud` or `prefer-cloud`+key. +- **Firewall integration smoke:** a local-only render against a request-blocking mock session asserts zero attempted outbound requests. + +--- + +## 7. UI/UX — Settings, keys, cost, wizard + +All in `renderer/App.tsx` unless noted; types in `renderer/vb.d.ts`. + +**Posture line (top of Settings):** *"Videoboom runs on your Mac by default — private, no key, no cost. Add a key only to unlock cloud where you want it."* Every cloud affordance is opt-in, gated, visually secondary. + +**Card order:** Backend mode → Stages → On-device (Hardware) → API keys → Advanced. Keys sit **below** stages — the optional unlock, not the entry ticket. + +- **`BackendModeControl`** (master): 3-segment control bound to `settings.backendPreference` — `Auto · Prefer local · Prefer cloud`. `Auto`/`Prefer local` resolve every `auto` stage local; `Prefer cloud` resolves `auto` stages cloud **only where a provider key exists**. Selecting `Prefer cloud` with no key shows an inline amber note: *"No keys yet — everything still runs locally."* +- **`StageBackendRow` over `STAGE_ROWS` (five rows, UPPERCASE ids `STT/LLM/VLM/KEYFRAME/VIDEO`).** A **tri-state** segmented control `Auto · Local · Cloud` writing `stages[STAGE] = {mode:'auto'}` / `{mode:'manual',backend:'local'}` / `{mode:'manual',backend:'cloud'}`. The **Cloud** segment is disabled with a lock icon and a "Add your {OpenRouter|Replicate} key below" tooltip whenever `!keys[provider]` — making local-default *structural* (cloud is literally unclickable without the key step). The **Local** segment gates on `modelsStatus()[STAGE]==='ready'` (uppercase id) and grows the existing `StageDownloadRow` progress affordance when absent; a Cloud-resolved stage hides its download row. A **resolved badge** under the segments reads off `resolvedBackends()` (`settings:resolved` IPC → `resolveConfig(...).stages`): `🛡 Local · private` or `☁ Cloud · {provider} · leaves this Mac`, prefixed `Auto →` when following the master. The `VIDEO` row, only when it resolves **local**, expands today's `VIDEO_MODES` Fast/Quality (`localVideoModel`/`localQuality`); when it resolves **cloud**, a compact `aspect_ratio` + `generate_audio` pair (`VB_OR_ASPECT`/`VB_OR_GENERATE_AUDIO`). +- **On-device card (`localCapabilities()`), keeps local knobs always-visible (major fix).** The **Lyrics-language** (`sttLang`), **workers**, and **local Wan dir** (`localWanDir`) controls stay in this **non-gated** card — they are on-device knobs a keyless user must still reach (Design 4 buried `sttLang` behind a key-gated Advanced card, which would strip a working zero-key setting). An **unsupported** verdict no longer disables Create; it reads *"On-device video needs an Apple-Silicon Mac with 32 GB+. This machine can make videos via Cloud — add an OpenRouter key below (and a Replicate key for lyric timing)."* — the single place the UI recommends cloud, and only as an explicit CTA. +- **`KeyRow` over `KEY_FIELDS`** (restored verbatim in shape, reframed copy): password input, Save → `vb.setKey`, saved-check, "Get a key" link. Copy: *"Add a key to unlock cloud for any stage. Videoboom works fully without keys. Keys are encrypted with your OS keychain and never leave this machine."* OpenRouter hint: *"unlocks cloud LLM, images & video."* Replicate hint: *"unlocks cloud lyric timing (WhisperX)."* Saving a key **only un-greys** the Cloud segments — it never flips a stage (I2); a toast says *"Cloud is now available on N stages. Switch any stage above to use it."* +- **Advanced — `MODEL_FIELDS`** (cloud slugs `storyModel/llmModel/keyframeModel/videoModel/vlmModel/moderationModel`): rendered only when ≥1 key is present (nothing to configure otherwise) — this card is cloud-only and gating it on a key is correct, unlike the local `sttLang` control. +- **`CostBanner`** (`costCents` on `done`): a pill `≈ $0.42 · cloud stages` shown **only when `costCents > 0`**. An all-local render shows **no** cost UI (no `$0.00` placeholder) — the app reads as free until a stage is opted into cloud. Framed as *estimate, billed by your providers*. +- **Onboarding (`renderer/screens/Onboarding.tsx`, new).** Detect hardware → **Verdict + keys** (primary button **Skip — stay local**, secondary **Add a key**; keyless = "every stage runs on your Mac, nothing leaves this machine") → Storage → Runtime install (skippable if all-cloud) → Download set (a cloud-opted stage shows "skipped — using Cloud" instead of a 54 GB download) → Calibrate (local only) → Done (echoes the five resolved badges). Initial `stages` all `{mode:'auto'}`, `backendPreference:'auto'` ⇒ keyless first run resolves all-local. No screen pre-checks a cloud option. + +`renderer/vb.d.ts` additions: `keysStatus()`, `setKey()`, `resolvedBackends(): Promise>`, `SidecarEvent.costCents?`, and the v3 `Settings`/`StageSelection`/`CloudModels` types (§2.2). The renderer never re-implements the resolver — it renders `resolvedBackends()`, invalidated on every `setSettings`/`setKey`. + +--- + +## 8. Migration — local-only-build users lose nothing (I4) + +- **Settings:** v2 → v3 (§2.3) preserves `localVideoModel`/`localQuality`/`sttLang`/`workers`/`localWanDir` verbatim and sets all stages `auto`. +- **No keys on disk** (the local-only build deleted `keys.json` at boot and never wrote one) ⇒ `KeyState` all-false ⇒ resolver forces all-local ⇒ identical render to today. +- **UI:** every control a keyless user had — Fast/Quality, per-stage download, Lyrics-language — stays reachable with zero keys; the new Backend-mode/Stages/Keys cards are additive. +- **Projects:** existing projects render unchanged (all-local); `clipsBackend` is absent → treated as local, no forced re-render. +- **guardRender:** relaxed (§5/§3) — it now only requires local models for stages that resolved local *and* are render prerequisites, so a keyless user's requirement set is exactly today's (`STT/LLM/KEYFRAME/VIDEO`). + +--- + +## 9. Implementation roadmap (each commit keeps `npm test` — typecheck + `segment.test` — green) + +`npm test` = `tsc --noEmit && tsx --test test/segment.test.ts`. `segment.test.ts` touches only `segment.ts`, so it stays green throughout; the gate is **typecheck** at every commit. Behavior stays all-local until C5 wires the resolver, and cloud stays inert until a key + opt-in exists. + +- **C1 — schema + resolver skeleton (no behavior change).** `config.ts` re-add `stageBackend` (default local); `settings.ts` v3 schema + `migrate` + `DEFAULTS`; `autoconfig.ts` `resolveConfig`/`pickBackend`/`toEnv` (phase-a + existing local block); `vb.d.ts` v3 `Settings`. `settingsEnv` still emits today's local env (wire the resolver at C5). **Accept:** typecheck green; `test/no-cloud-without-optin.test.ts` added and green; `migrate` unit-checks preserve v2 local fields. +- **C2 — keychain + cost + keys IPC (dormant).** Restore `keychain.ts`, `cost.ts`; add `keys:status`/`keys:set` IPC + preload + `vb.d.ts`; `costReset()` per op in `engine/index.ts`; `costCents` on engine `EngineEvent` + renderer `SidecarEvent`; **remove** the `keys.json` boot delete. **Accept:** typecheck green; keyless `keyStatus()` = all-false; `costTotal()` = 0 after a local render. +- **C3 — cloud text/image stages + dispatch.** `backends/types.ts`, `backends/registry.ts`, `backends/local/*` adapters; `cloud/http.ts`, `cloud/llm.ts` (role split), `cloud/stt.ts` (failure-vs-instrumental), `cloud/vlm.ts`, `cloud/keyframe.ts`; `stages.ts` dispatchers delegate to the registry (signatures unchanged); `storyBible` passes `'story'`. **Accept:** typecheck green; with `VB_*_BACKEND` unset everything routes local (default); a forced `VB_LLM_BACKEND=cloud` unit-drives `cloud/llm.ts` against a mock fetch. +- **C4 — video seam + pipeline.** `ffmpeg.ts` re-add `fitToWindow`; `backends/sceneShared.ts` (extract `buildKeyframe`/`decideCutContinue`/`finishClip`/`assemble`); `backends/local/video.ts` (move `renderScenesLocalChained`+`renderLocalScene`), `cloud/video.ts` (`genVideo` + parallel morph loop + `p.videoModel`); `pipeline.ts` route through `P.video().renderScenes`/`refreshScene`, unified segProfile, `needsUpscale()` gate, `clipsBackend` invalidation; `stages.ts` `video()`/`keyframeConcurrency()`. **Accept:** typecheck green; a local render produces byte-equivalent output to pre-C4 (same chain, trim, upscale); `pipeline.ts` contains no `VB_*_BACKEND`/`genVideoLocal`/URL (checked by C6 script dry-run). +- **C5 — wire the resolver + guards.** `settingsEnv` → `resolveConfig(caps, getSettings(), keyStatus()).toEnv()`; `sidecarEnv` += `keysEnv()`; `guardRender` consults the resolver (only local-resolved prerequisite stages need models; name the missing provider key for unavailable-local stages); GPU lock (`gpuBusy`/`guardPortrait`) applies only when the op's VIDEO (render) or KEYFRAME/VLM (portrait) resolve local. **Accept:** typecheck green; keyless render still all-local and unblocked; OpenRouter-only render on unsupported hardware blocks with "STT needs a Replicate key." +- **C6 — network lockdown.** `src/shared/netAllowlist.ts`; `session.webRequest` firewall in `main/index.ts`; created inverted `scripts/check-no-cloud.sh`. **Accept:** `check-no-cloud.sh` green; firewall integration smoke shows zero outbound on a local render; a cloud stage reaches only its provider host. +- **C7 — Settings UI.** `BackendModeControl`, `StageBackendRow`+`STAGE_ROWS`, restored `KeyRow`+`KEY_FIELDS` (optional copy), `MODEL_FIELDS` advanced, resolved badges (`settings:resolved` IPC), `CostBanner` (>0), reframed on-device/Hardware card keeping `sttLang`/`workers`/`localWanDir` non-gated. **Accept:** typecheck + smoke (`VB_SMOKE`) green; keyless UI shows all-Local badges + no cost; Cloud segments locked without a key. +- **C8 — Onboarding wizard** (`renderer/screens/Onboarding.tsx`). **Accept:** typecheck green; keyless first run ends all-local; "Skip — stay local" is primary. + +--- + +## 10. Open questions + +1. **Unified 6 s segmentation for Kling.** §5 unifies segProfile to `{6,12,3}` for both backends to keep the storyboard cache backend-independent and dissolve the mixed-timeline problem. Kling's single 3–15 s clip may prefer vocal-phrase-length cuts; if quality testing shows 6 s is wrong for Kling, segProfile becomes a per-backend hint again **and** a VIDEO-backend switch must re-segment (re-running only `segmentSong` + scene-window remap, STT/bible cached). Decide after a cloud A/B. +2. **Cloud finish to 1080.** §5 specifies a light ffmpeg lanczos 720→1080 for cloud so cloud ≥ local quality. Alternative: leave cloud at native 720 (smaller files, faster) and make the timeline resolution user-visible. Which is the default? +3. **STT provider split in onboarding.** STT (Replicate) needs a *second* key OpenRouter doesn't cover. The CTA surfaces this per-stage — but is a two-key ask acceptable, or should an OpenRouter-only machine that can't run local STT be steered to instrumental-only rather than required to add Replicate? (Currently: blocked with a clear per-stage reason.) +4. **M5 tier dependency for local-variant quality.** Phase-(b) local variant selection ships on the binary `localCapabilities()` signal now; tier-aware quant/res/steps selection is gated on LOCAL_PLAN M5 (`hardware.ts`/`tiers.ts`/`DeviceProfile`). Confirm the hybrid ships **before** M5 with the coarse local recipe, or is sequenced after M5. +5. **Per-stage backend change mid-project.** §5 invalidates *clips* on a VIDEO switch. A STT/LLM/KEYFRAME switch changes storyboard/keyframes. Proposed: STT/LLM switch → re-storyboard next render; KEYFRAME switch → re-keyframe; detected via a recorded per-stage signature, never re-running STT unless STT itself changed. Confirm. +6. **`VB_WHISPERX_VERSION` pin.** The restored WhisperX Replicate version hash (`5125093~1:providers.ts`) may be stale by ship time; confirm the pinned Replicate model version before C3. diff --git a/LOCAL_PLAN.md b/LOCAL_PLAN.md new file mode 100644 index 0000000..8c7b43d --- /dev/null +++ b/LOCAL_PLAN.md @@ -0,0 +1,672 @@ +# Videoboom LOCAL_PLAN + +Status: FINAL architecture plan, 2026-07-04 (framing updated 2026-07-05: local-only → **local-default hybrid**). Base: branch `bf16-relay` (clean). Owner: lead architect. This document supersedes the four draft design sections (A: hardware/auto-config, B: model manager/ETA, C: backend, D: cloud removal); all verifier blockers and majors are resolved in the text below or carried as open questions in §13. + +> **Scope update — hybrid pivot.** The product is no longer local-only: cloud inference (OpenRouter LLM/VLM/keyframe/moderation, Kling video, Replicate WhisperX) is re-added as a **per-stage, opt-in** backend behind the same clean interface, restored from git `5125093~1`. **Local stays the default** (no data leaves the machine unless the user opts a stage into cloud; no key ⇒ always local). The dual-backend interface, resolver, settings v3, and cloud restoration are specified in **`DUAL_BACKEND_PLAN.md`** — read it alongside this file. Everything below (tiers, model catalog, ETA, cross-platform MLX+CUDA, packaging) still governs the **local path** unchanged; only the "local-only / no cloud ever" framing in §0, §1, and §3.4 is superseded by the hybrid policy. + +--- + +## 0. Executive summary + +Videoboom is a **local-default, cross-platform AI music-video studio** with an **opt-in cloud path per stage** (see `DUAL_BACKEND_PLAN.md`). The local path — the subject of this document — runs inference on-device on three platforms: **macOS Apple Silicon via the existing MLX sidecar** (kept, refactored) and **Windows/Linux NVIDIA via a new CUDA backend that drives a headless ComfyUI + llama-server behind the same sidecar HTTP contract**. The cloud path (OpenRouter/Replicate/Kling clients, API keys, keychain, cost tracking) is restored from git `5125093~1` behind the same per-stage interface and is used **only where the user explicitly opted a stage into cloud**; with no key present every stage is local. Network is used only for (a) user-consented provisioning (model downloads, first-run runtime install, optional catalog refresh) and (b) opted-in cloud stages talking to their own provider host — **local stages touch the network at inference time only when the user opted them into cloud**, enforced by `HF_HUB_OFFLINE=1` on local inference children, a per-stage Electron session firewall, and a CI tripwire keyed to an explicit allowlist module (§3.4). + +On first run the app detects OS/GPU/VRAM/RAM/disk in pure TypeScript (before any Python exists), classifies the machine into a tier (M16…M64 Apple, N8…N32 NVIDIA), and auto-selects models, quants, resolution, steps, and offload settings for every stage. A new **Models tab** lets users browse a pinned, sha256-verified model catalog per stage, download/pause/resume/delete variants with size-on-disk, and see a **live ETA per 1 minute of finished video on this machine** — seeded from a lookup table, calibrated by an on-device three-point micro-benchmark, refined by real render timings. + +End state: one settings schema (v3), one `DeviceProfile` file, one sidecar API (v2 jobs contract), one model catalog consumed by TS and Python, packaged installers for macOS (dmg, notarized), Windows (NSIS, SignPath-signed), Linux (AppImage/deb), each smoke-tested in CI against a mock backend. + +**De-risk pass (2026-07-04, §15):** every §11 risk and §13 open question was resolved against live sources or narrowed to a defined spike / user decision. Architecture-level outcomes: ComfyUI GPL posture closed (pinned commits + §16 licenses appendix); Linux llama-server = official Vulkan release binaries (the CI-built CUDA binary is deleted from the plan); CUDA python stack pinned to the torch 2.9.1+cu128 trio (measured ≈ 3.1 GB download Win / ≈ 4.4 GB Linux); Windows signing = SignPath Foundation (Azure/EV ruled out on live facts; the `portable` target is dropped); mlx-video commit-pinned in M0 and vendored in M2; the ETA formula gains a per-clip fixed cost + three-point NNLS bench; the M16 tier and the bf16-relay ETA seed each hinge on one defined spike (§7.2, §12 M5); registering the `videoboom` HF namespace is the sole decision left to the user (§14). + +--- + +## 1. Goals / non-goals + +**Goals** +1. Local-default: every stage runs on-device by default; a stage touches the network at inference time **only** when the user explicitly opts it into cloud (per-stage BYOK — see `DUAL_BACKEND_PLAN.md`). No key present ⇒ zero inference network. Provisioning network (model/runtime downloads, opt-in catalog refresh) is unchanged. +1b. Hybrid dual-backend: each stage (STT/LLM/VLM/keyframe/video) has a local **and** an opt-in cloud impl (OpenRouter/Kling/Replicate, restored from `5125093~1`) behind one clean interface + resolver; cloud is never a silent fallback (hardware tier never enables cloud). +2. Cross-platform inference: Windows + Linux with NVIDIA CUDA (driver ≥ 570, VRAM ≥ 8 GB, compute cap ≥ 8.6), macOS Apple Silicon (MLX/Metal, ≥ 32 GB unified supported; 16–31 GB experimental). +3. Hardware auto-configuration: tier detection on first run and on demand; user never needs to know what a quant is. +4. Model Manager UI: per-stage model/quant browse, verified resumable downloads, delete with refcounted shared deps, real-time per-machine ETA per minute of final video. +5. Shippable packages on all three OSes with CI-verified installs. + +**Non-goals** +- AMD/Intel GPUs, Intel Macs, CPU-only: graceful "unsupported" screen only. +- Lip-sync (deferred, unchanged). +- *Automatic/silent* cloud fallback: cloud is opt-in per stage only; the resolver never routes a stage to cloud without an explicit user pin or `prefer-cloud` master **and** a present key, and hardware tier never enables cloud (invariant I3, `DUAL_BACKEND_PLAN.md` §0/§3). (Opt-in cloud BYOK itself is now a supported goal, not a non-goal.) No telemetry, no auto-update (out of scope; if ever added it goes through the network allowlist as a reviewed act). +- Multi-GPU scheduling (we pick the largest GPU; `mapPool` survives for the future). + +--- + +## 2. Current state (branch `bf16-relay`) + +- **Engine** (`src/engine/`): pipeline with per-stage cloud/local branching (`stageBackend` in `config.ts`, 5 raw `VB_VIDEO_BACKEND` sites in `pipeline.ts`), cloud clients in `providers.ts` (OpenRouter LLM/VLM/moderation, WhisperX, Kling video), cost tracking in `cost.ts`. +- **Local path (macOS only)**: resident Python sidecar `local/server.py` on fixed port 8765, MLX everywhere — Wan 2.2 i2v A14B bf16 via relay-shedding (default / QUALITY champion, `relay_generate.py` fork), TI2V-5B — the 5B slot now runs **FastWan2.2-TI2V-5B DMD 3-step** (`fastwan_dmd.py`, `.model-path-5b` → `local/models/FastWan2.2-TI2V-5B-MLX`, git e095e90; the plan's target **FAST** champion), LTX-2 (`ltx_i2v.py`, morph mode), mflux FLUX.1-schnell keyframes + Kontext identity, mlx-lm LLM, mlx-vlm VLM, mlx-whisper STT, RIFE ncnn interp (subprocess-isolated for the MoltenVK segfault), Real-ESRGAN ncnn upscale. +- **Provisioning**: `local/setup.sh` (bash-only, 120 GB download-then-convert), `local/download.py` (whole-snapshot, unverified), `.model-path`/`.lightning-dir` marker files, `STAGE_REPOS` duplicated in `src/main/localModels.ts:10-15` and `download.py:17-22`. +- **Known-broken for the new goals**: POSIX-only venv paths and process groups, `process.cwd()`-relative `local/` resolution (dead in any packaged app), no auth/cancellation/job persistence on the sidecar, network fetches mid-generation (umt5 tokenizer at `relay_generate.py:372`), advisory-only model gating, 32 GB static RAM gate. Full register in §10. + +--- + +## 3. Target architecture + +``` +┌─────────────────────────────── Electron ────────────────────────────────┐ +│ renderer (React) main (Node) │ +│ ┌──────────────┐ IPC ┌────────────────────────────────────────┐ │ +│ │ Create/Videos │◄──────►│ index.ts (IPC, guardRender) │ │ +│ │ Models tab │ │ hardware.ts tiers.ts autoconfig.ts │ │ +│ │ Onboarding │ │ settings.ts (v3) bootstrap.ts │ │ +│ │ EtaPanel │ │ models/{catalog,status,downloads, │ │ +│ └──────────────┘ │ estimate}.ts │ │ +│ └───────┬───────────────┬────────────────┘ │ +│ engine (src/engine, in main) │ spawn+handshake │ +│ pipeline.ts stages.ts local*.ts │ {port,token} │ +└────────────────────────────────────┼───────────────┼────────────────────┘ + HTTP 127.0.0.1: + X-VB-Token + ┌───────────────▼───────────────────────────┐ + │ Python sidecar local/serve/server.py │ + │ job queue · typed errors · /capabilities │ + │ /jobs (i2v|keyframe|interp|upscale|bench) │ + │ /stt /llm /vlm (sync) · /shutdown │ + ├────────────────┬───────────────────────────┤ + │ backends/mlx │ backends/cuda │ + │ (macOS, in- │ coordinator → children: │ + │ process MLX) │ ComfyUI headless :p2 │ + │ │ llama-server :p3 │ + │ backends/common (ncnn, ffmpeg, manifest) │ + │ backends/mock (contract tests, CI) │ + └────────────────────────────────────────────┘ + Online processes (ONLY): src/main/bootstrap.ts children (uv, tarball + fetch) · local/serve/downloader.py (HF models) — both user-consented. + Inference processes run HF_HUB_OFFLINE=1 / TRANSFORMERS_OFFLINE=1. +``` + +### 3.1 Binding cross-section decisions (reconciliation) + +These resolve every contradiction between the draft sections. They are normative. + +1. **One DeviceProfile** — `src/shared/deviceProfile.ts`, persisted at `/device-profile.json`. Schema = Design A's shape (schemaVersion, detectedAt, fingerprint, os, cpu, ramGB, gpu{vendor,name,vramGB,unified,computeCap,driverVersion,fp8,gpuCores,cudaIndex,estTflopsFp16}, disk, tier, tierReasons, learnedCaps) **plus** a `calibration` block holding the estimator coefficients in B's key shape: `calibration: { benchedAt, benchVersion, coeffs: Record<'variantId|WxH|frames|steps', {c, a, b, tFixClip?, sIt?, tVae?, src:'seed'|'bench'|'actual', n}> }`. **One invalidation rule**: A's `fingerprint = sha1(gpu.name|vramGB|driverVersion|ramGB|platform)`; fingerprint change or `benchVersion` bump clears `learnedCaps` + `calibration`. Single writer: `src/main/hardware.ts`; `models/estimate.ts` reads/writes `calibration` through it. +2. **One Settings schema** — A's wins: `stages: Record` where `StageSelection = {mode:'auto'|'manual', modelId?, quant?, res?, steps?}`. B's `stageSelections: Record` does not exist; the Model Manager UI writes `modelId`/`quant` into `StageSelection` and flips `mode:'manual'`. `variantId ≡ modelId + '@' + quant`. Settings lineage is declared once: **v1** (cloud, today) → **v2** (M1/D-C5: local-only; transitionally keeps `localVideoModel:'14b'|'5b'`, `localQuality`, `localWanDir`, `workers`, `sttLang`) → **v3** (M5: `stages` record; deletes `workers`, `localWanDir`; folds `localVideoModel`/`localQuality` into `stages.video`). One `migrateSettings()` chain in `src/main/settings.ts`, each step consuming exactly the previous version, stamped `settingsVersion`. +3. **One hardware module and channel pair** — `src/main/hardware.ts` (per A and C). IPC: `hw:profile` + `hw:redetect`; `local:capabilities` stays as a thin alias until the smoke test and renderer are migrated (one release), then dies. B's `src/main/models/hardware.ts` shrinks to a pure device-class normalizer (`'NVIDIA GeForce RTX 4070 SUPER' → 'rtx_4070_super'`) imported by `estimate.ts`. +4. **One micro-benchmark implementation** — C's sidecar job (`POST /jobs {type:'bench'}` implemented in `local/serve/bench.py`): queue integration, token auth, cancellation, and typed errors come free, and a standalone GPU-touching script can't race a warm resident model. B's `bench_probe.py`/`bench:default` channel are dropped; `models:benchmark` in main submits the job and relays progress. +5. **One local/ layout** — C's tree (§3.3) is the target; B's downloader lands at `local/serve/downloader.py`. **One model registry**: `resources/model-catalog.json` is the single source, shipped via `extraResources`; the Python manifest resolver (`local/backends/common/manifest.py`) reads the same file (path passed at spawn) — `local/models.json` is never created. `local/setup.sh` is **deleted** in M3, fully replaced by `src/main/bootstrap.ts`; until M3 it survives untouched as the dev provisioning path. +6. **One network policy** — §3.4. D's "two hosts in the codebase" invariant is replaced by "**no network at inference time**"; provisioning hosts live in one allowlist module consumed by the runtime firewall and the CI grep. +7. **LTX-2 is deleted** (D-C2 amended). It is mac-only, absent from tiers/catalog/ETA, its weights have no in-app provisioning path once markers retire, and `ltx_i2v.py:34` fetches a text encoder by repo ID at generation time (offline-unsafe). Delete `local/ltx_i2v.py`, the `'ltx'` value of `localVideoModel`, `videoModel()`'s `'ltx'` arm (`src/engine/localVideo.ts:14`), the `.model-path-ltx` marker (`localVideo.ts:38`), the morph branches (`pipeline.ts:259-264, 447-449, 567-569`), **and the k+1 keyframe pass (`pipeline.ts:578`) together with them** — resolving the verifier's C2 contradiction (the pass existed only to feed LTX-morph `kfLast`). Re-adding LTX later is an open question (§13). +8. **One models root on Windows**: `%LOCALAPPDATA%\Videoboom\models` (short path, non-roaming — C's long-path mitigation wins over B's `%APPDATA%`). Resolved in Node as `path.join(process.env.LOCALAPPDATA, 'Videoboom', 'models')`; macOS/Linux keep `userData/models`. Small JSON state stays in `userData` everywhere. +9. **One TierId vocabulary**: `'M16'|'M32'|'M48'|'M64'|'N8'|'N12'|'N16'|'N24'|'N32'|'UNSUPPORTED'` defined in `src/shared/tiers.ts`, used verbatim in `tiers.ts` data, `model-catalog.json` `tierDefaultFor`, and UI (friendly label rendered from the enum). +10. **Landing order** (dependency-safe): D C1–C6 (cloud removal, M1) → C sidecar contract + backends + bootstrap (M2–M3) → B catalog/downloader/Model Manager (M4) → A tiers/wizard/autoconfig + ETA (M5) → D C7 network lockdown **last** (M6), after the umt5 tokenizer is vendored and the downloader/bootstrap allowlist exists. + +### 3.2 Backend strategy + +**macOS — keep the MLX sidecar.** Proven: A14B bf16 relay-shedding peaks at 36.8 GB on a 48 GB machine, Lightning 4-step, tiny-VAE. Refactored into `local/backends/mlx/`, not replaced. **mlx-video is vendored in M2, not depended on**: the Wan subtree (`mlx_video/models/wan_2/` 16 files, `mlx_video/lora/`, `models/ltx_2/video_vae/tiling.py`; ~560 KB, MIT) is copied at commit `87db56a5` into `local/backends/mlx/vendor/` with upstream LICENSE + a `VENDORED.md` recording the base commit; `relay_generate.py` merges into the vendored `generate.py` (relay = `memory_mode` flag, one file, no parallel fork), and the three runtime monkeypatches (loader memoize + `mx.compile` keep-compiled in `wan_i2v.py`, tiny-VAE loader swap in `tiny_vae.py`) become direct edits to vendored code. Rationale (2026-07-04 audit): 12 private-module import sites + 3 monkeypatches + a 950-line forked file against a package with no releases, no tags, no stable API, dormant 7.5 weeks, whose wan_2 layout was already deleted/relocated once (2026-03-18 `pc/unify-apis`); M2's cancel/timings/offline-umt5 requirements force editing the quantized-path stock code anyway. Upstream fixes are cherry-picked by diffing `..upstream/main -- mlx_video/models/wan_2 mlx_video/lora`. mlx-lm/mlx-vlm/mlx stay normal locked PyPI deps. The real wins of the `serve/` split are backend selection, the mock backend, and the job queue (note: `/health` was never blocked by ML imports — `server.py` already imports lazily; the old justification is dropped). + +**Windows/Linux — headless ComfyUI as CUDA execution engine, hidden behind our sidecar.** The Electron engine never talks to ComfyUI; `local/backends/cuda/comfy.py` templates workflow JSON, POSTs to ComfyUI's internal port, relays per-step progress from its WebSocket, and returns the same JSON shapes as MLX. Rationale vs raw diffusers: first-class Wan 2.2 fp8/GGUF nodes, community-maintained low-VRAM paths (city96 ComfyUI-GGUF; Kijai WanVideoWrapper block swap ran fp8 704×704×121f in ~6 GB), one engine covers video + keyframes + premium upscale. **Fallback recorded, not built — and verified real (2026-07-04)**: a diffusers `WanImageToVideoPipeline` implementation behind the same backend interface if ComfyUI's process model becomes a problem — swap touches zero TypeScript. Verified: diffusers v0.39.0 supports Wan 2.2's two-expert i2v (`transformer_2`/`boundary_ratio` in `pipeline_wan_i2v.py`) and Lightning LoRAs (`WanLoraLoaderMixin`); official ungated `Wan-AI/Wan2.2-I2V-A14B-Diffusers` weights exist. Known fallback losses: SeedVR2 (ComfyUI-node only, would be dropped) and the mature city96-GGUF/Kijai-block-swap low-VRAM paths (diffusers group-offload + GGUF loading is the replacement; low-VRAM parity unproven). GPL posture is no longer a trigger for this fallback — resolved per §11 and docs/LICENSES.md (§16). + +**FastWan-5B FAST tier on CUDA — FastVideo runtime, not a stock ComfyUI node (2026-07-05).** The FAST mode's `FastVideo/FastWan2.2-TI2V-5B-FullAttn` uses a **DMD 3-step sampler that is FastVideo-custom** (`WanDMDPipeline`, trained timesteps `1000,757,522` + renoise, guide=1) — plain `DiffusionPipeline.from_pretrained` or a stock ComfyUI Wan2.2-5B node will NOT reproduce it. So the CUDA fast tier drives **FastVideo's own runtime** (`pip install fastvideo` → `fastvideo generate --num-inference-steps 3 --dmd-denoising-steps "1000,757,522"`, `FASTVIDEO_ATTENTION_BACKEND=FLASH_ATTN`) behind the same backend interface as the recorded diffusers fallback — cleaner than templating custom-sigma ComfyUI graphs (no dedicated FastWan-DMD node exists). **No FastWan GGUF exists** (QuantStack GGUFs are the *stock* 5B, not the distilled FastWan; the FastWan DiT is ~10 GB bf16 + umt5-xxl ~11 GB + the x64 VAE) → **N16+ run bf16/fp16 comfortably; N8/N12 have no quant path** — N12 CPU-offloads the text encoder (FastVideo supports it, tight) and falls back to the stock TI2V-5B GGUF fast path only on spill, while N8 always falls back (bf16 won't fit 8 GB). The FullAttn 5B is dense attention (short ~20K seq → no VSA/sparse kernels; there is no VSA 5B repo), so no custom CUDA kernel is needed. QUALITY mode (14B) is unchanged. + +**ComfyUI provisioning (corrected — no pip package exists).** The PyPI `comfyui` package is a placeholder; ComfyUI is not an importable package, and ComfyUI-GGUF / ComfyUI-WanVideoWrapper are git-cloned custom nodes with their own requirements. Therefore `bootstrap.ts`: +- fetches **pinned-commit tarballs** of `Comfy-Org/ComfyUI` (repo transferred from `comfyanonymous/ComfyUI`, old URL 301-redirects; pin **v0.27.0 = `bb131be9e83d2f773c90f1d6f1e4b248a498c8c5`**, stable 2026-06-30), `city96/ComfyUI-GGUF` (pin **`6ea2651e7df66d7585f6ffee804b20e92fb38b8a`**, Apache-2.0), `kijai/ComfyUI-WanVideoWrapper` (pin **`088128b224242e110d3906c6750e9a3a348a659b`**, Apache-2.0; repo has no tags — commit pin only) — all three verified fetchable at these SHAs via codeload (11.5 MB + 0.03 MB + 19.1 MB ≈ 31 MB compressed) — mirrored into our `videoboom/videoboom-assets` HF repo (primary, keeps traffic inside the HF allowlist) with `codeload.github.com` as fallback — sha256-verified against a manifest we publish; +- unpacks under `userData/runtime/comfy/` (+ `custom_nodes/`); +- their `requirements.txt` contents are **merged into `local/requirements/cuda.lock` at lock-build time** (a repo script, `scripts/build-locks.py`, runs `uv pip compile` over base.in + the three requirements files), so `uv sync` provisions every Python dep in one hash-pinned step. **Torch pin (decided 2026-07-04): `torch==2.9.1+cu128` / `torchvision==0.24.1+cu128` / `torchaudio==2.9.1+cu128`** — a deliberately conservative, internally consistent cu128 trio with a measured footprint (ComfyUI requires torchaudio; torchaudio 2.9.1 hard-pins `torch==2.9.1`, so the set can never skew): cu128 keeps our ≥ 570 driver floor and covers Blackwell sm_120, while torch ≥ 2.12 PyPI defaults have moved to CUDA 13 (`nvidia-*-cu13`, driver floor ≥ 580). Newer cu128 trios exist up to torch 2.11.0 (torchvision/torchaudio included) and are the in-place upgrade path — any bump re-measures the size table; going past 2.11 means CUDA 13 + a driver-floor bump, a deliberate future decision. Linux lock resolves from PyPI (2.9.1's default Linux wheel *is* the cu128 split-nvidia build, 901 MB); the Windows lock resolves against the `download.pytorch.org/whl/cu128` extra index (PyPI win wheels are CPU-only). `build-locks.py` additionally emits `resources/runtime-sizes.json` — per-OS `{downloadBytes, installedBytes}` summed from each wheel's zip central directory — and CI fails any lock regen whose installed size exceeds a 10 GB budget. Recorded optional saving, not built: dropping `nvidia-nccl-cu12`+`nvidia-nvshmem-cu12` (distributed-only, ~0.45 GB dl / 0.62 GB disk) needs an import-torch spike first; +- the trio's commits are stamped into `userData/runtime/venv-manifest.json`; "Repair" re-syncs them. + +**LLM/VLM runtime (audited 2026-07-04 vs release b9873 — official binaries on BOTH platforms; CI-build deleted).** `llama-server`: +- **Windows**: official `ggml-org/llama.cpp` release, **cuda-12.4 pair**: `llama--bin-win-cuda-12.4-x64.zip` (~266 MB) + `cudart-llama-bin-win-cuda-12.4-x64.zip` (~391 MB), sha256-pinned in the bootstrap manifest (hashes come free from the release API `digest` field). 12.4 over 13.3: CUDA 13 needs driver r580+, our floor is 570. +- **Linux**: still no official CUDA asset (verified b9864–b9873: ubuntu builds are cpu/vulkan/rocm/sycl/openvino only). Decision **flipped**: official `llama--bin-ubuntu-vulkan-x64.tar.gz` (~31 MB, built on ubuntu-22.04) is **primary**; no CI-build exists anywhere in this plan. Verified in-archive: `llama-server`, `libmtmd.so` (`--mmproj`), `libggml-vulkan.so`. Runtime deps: glibc ≥ 2.34, `GLIBCXX_3.4.30` (gcc-12 libstdc++), `libvulkan.so.1`, `libcrypto.so.3`, `libgomp.so.1` → Ubuntu 22.04+/Debian 12+; `bootstrap.ts` preflights via `ldconfig -p` and names the missing package (`libvulkan1`, `libgomp1`). +- **Perf (NVIDIA, driver ≥ 570 ⇒ coopmat2)**: tg within 0–10% of CUDA (3060 ±0%, 4090 +1%, 5090 −9%), pp512 12–26% slower; the old "~20–30% slower" blanket figure was a driver-550 A100/KHR_coopmat number. Immaterial to ETA (§7: LLM stage = `8·tLlmCall + tVlm`, tens of seconds per finished minute). `perf-seeds.json` keys `llmTokS` per backend (`win-cuda`, `linux-vulkan`). +- **Known edge case**: image-specific mmproj degradation on Vulkan (llama.cpp #20081, closed-stale). Shipped mitigation: `local/backends/cuda/llama.py` passes `--no-mmproj-offload` (arg exists, common/arg.cpp:2339) on the Vulkan build — vision encoder on CPU, seconds per SAFE/UNSAFE check. Recorded escalation, never scheduled: CI-built CUDA binary (one manifest swap). +- **Distribution**: both artifacts mirrored into `videoboom/videoboom-assets` (**copy job, not build job**) with the official GitHub release URL as fallback; pin only tags with the complete 25-asset set (b9871 shipped zero assets). + +**Per-stage backend table** + +| Stage | macOS (MLX) | Windows/Linux (CUDA) | Shared | +|---|---|---|---| +| Video i2v | **QUALITY:** vendored Wan 2.2 A14B generate (memory_mode=relay) bf16/Q4. **FAST:** FastWan2.2-TI2V-5B-FullAttn DMD 3-step (`fastwan_dmd`, guide=1, renoise, native 24 fps) | **QUALITY:** ComfyUI native Wan nodes + ComfyUI-GGUF (QuantStack GGUF) or Kijai fp8-scaled; WanVideoWrapper block swap ≤ N12. **FAST:** FastWan-5B bf16 via the FastVideo runtime (N16+; DMD sampler is FastVideo-custom, not a stock ComfyUI node — §3.2); N12 attempts FastWan bf16 + TE CPU-offload (tight) else falls back, N8 always falls back to stock TI2V-5B GGUF (no FastWan GGUF) | Lightning 4-step LoRAs (lightx2v, **14B/QUALITY only**), frame math (4n+1), scheduler defaults; **both modes → 1080p via §4.2 upscale** | +| Keyframe | mflux FLUX.1-schnell 4-bit | ComfyUI: FLUX.1-schnell GGUF (N8/N12) / Qwen-Image GGUF + Lightning LoRA (N16+) | seed-from-outPath convention | +| Identity/cast | optional (§5, license-gated Kontext; Qwen-Image-Edit MLX pending) | Qwen-Image-Edit GGUF (N16+); descoped with typed warning on N8/N12 | Kontext-failure → typed warning, never silent | +| LLM | mlx-lm Qwen3.6-35B-A3B-4bit (M32+) / Qwen3-8B-4bit (M16) | llama-server, Qwen3-VL-8B-Instruct GGUF Q4_K_M (one model, LLM+VLM roles); 4B on N8 | prompt templates, tolerant-JSON in TS | +| VLM | mlx-vlm gemma-3-12b-4bit / 4b (M16) | same llama-server via `--mmproj` | SAFE/UNSAFE protocol | +| STT | mlx-whisper large-v3-turbo | faster-whisper (CT2): **large-v3-turbo int8 multilingual default on all tiers**; distil-large-v3 = English-only opt-in | word-timing JSON `{start,end,word}`; bundled ffmpeg prepended to PATH; win32 also prepends `\Lib\site-packages\torch\lib` (the ctranslate2 4.8 win wheel is 19 MB and does **not** bundle cuBLAS/cuDNN — it loads torch's DLLs; on Linux CT2 finds them in the venv's `nvidia-*` packages) | +| Interp | rife-ncnn-vulkan wheel, isolated subprocess (MoltenVK workaround stays) | Practical-RIFE torch v4.25/4.26 (~3× faster); ncnn binary fallback | RIFE v4.26 flownet files; exact-2n policy | +| Upscale | realesrgan-ncnn wheel, isolated subprocess | spandrel (torch), frame-streamed; SeedVR2 via ComfyUI node (N24+ optional) | Real-ESRGAN weights; stream-and-delete frames | +| Tiny VAE | TAEHV via torch-MPS | TAEHV via torch-CUDA | `taew2_1.safetensors` (22 MB) | + +### 3.3 Sidecar contract v2 (authoritative) + +Repo layout: + +``` +local/ + serve/ server.py (transport, auth, queue) · jobs.py · errors.py + resources.py (psutil/pynvml/sysctl, disk preflight) + bench.py · downloader.py + backends/ + mlx/ manager.py wan_i2v.py keyframe.py llm.py vlm.py stt.py + tiny_vae.py taehv_upstream.py (moved) + vendor/{wan2/, lora/, tiling.py, LICENSE, VENDORED.md} + (mlx-video @87db56a5 subtree; relay_generate.py merged into + vendor/wan2/generate.py as memory_mode='relay') + cuda/ comfy.py llama.py whisper_ct2.py rife_torch.py upscale_spandrel.py + common/ interp_ncnn.py upscale_ncnn.py ffmpeg_util.py manifest.py + mock/ deterministic instant outputs (contract tests) + workflows/ native family: wan22_i2v_a14b_lightning.json, wan22_ti2v_5b.json, + flux_schnell.json, qwen_image.json, qwen_image_edit.json, seedvr2.json + wrapper family: wan22_i2v_a14b_wrapper_blockswap.json, wan22_ti2v_5b_wrapper.json + requirements/ base.in · mlx.lock · cuda.lock (uv, hash-pinned; NO mlx-video dep — + wan2 vendored; mlx, mlx-lm, mlx-vlm, transformers, safetensors, ftfy, + imageio, imageio-ffmpeg, tqdm, numpy, Pillow made explicit) +``` + +Contract: +- **Spawn & handshake**: main spawns `python -m serve --backend mlx|cuda|mock --port 0`. Sidecar binds an ephemeral port, generates a random token, prints `{"event":"ready","port":N,"token":"…","version":""}` and mirrors it to `userData/runtime/sidecar.json`. `sidecar.ts` attaches `server.on('error')` (spawn failures surface immediately), sends `X-VB-Token` on every request. Fixes probe #9/#10/#11/#23. +- **Async jobs**: `POST /jobs {type:'i2v'|'keyframe'|'interp'|'upscale'|'bench', params} → {job_id}`; `GET /jobs/ → {state, progress:{step,steps,phase,eta_sec}, result?, error?}`; `POST /jobs//cancel`. Results persist to `/runtime/jobs/.json` until acknowledged — a client timeout no longer discards a finished 38-minute clip (#8/#18). The same file doubles as a **phase journal**: `{state, phase, params, ts}` written (fsync'd) at dispatch and at every phase transition, so a process killed with no exception (jetsam/oom_killer, §4.4) is classifiable post-mortem. `/stt` `/llm` `/vlm` stay synchronous. FIFO queue, one worker; cap 4, overflow → `QUEUE_FULL` (#24). Cancellation: MLX checks a `cancel_event` per denoise step (we own the vendored Wan generate loop — covers bf16-relay AND the quantized/5B stock path, which upstream `generate_video` could not cancel or time); CUDA POSTs ComfyUI `/interrupt`; llama-server cancels on socket close. `deadline_sec` enforced server-side. +- **Model refs are registry IDs**: `model:"wan22-i2v-a14b@q4_k_s-gguf"`; resolved via `manifest.py` reading `model-catalog.json` + installed-variant manifests. Missing → immediate `MODEL_MISSING`. Every inference process runs `HF_HUB_OFFLINE=1` (#5); the **umt5-xxl tokenizer is vendored into the converted/pre-converted Wan dirs** (prerequisite for M6 lockdown). +- **Typed errors** everywhere: `MODEL_MISSING | OOM | OOM_PREDICTED | CANCELLED | DEADLINE | BACKEND_CRASH | DISK_FULL | QUEUE_FULL | GATED`. Errors are structured `{code, phase?, sig?, raw?}`: `phase` from the timing brackets (load|text|img_enc|denoise|vae), `sig` from the §4.4 signature table, `raw` = first 500 chars of the underlying message. `BACKEND_CRASH` carries `{cause:'oom_suspected'|'unknown', exitCode, signal}`. Progress events may carry a non-fatal `PERF_DEGRADED {cause:'sysmem_fallback'|'slow', sStepObserved, sStepPredicted}` marker (§4.4). +- **`GET /health`** → `{ok, version, backend, pid, queue_len, resident_models}`; version mismatch after app update → shutdown + respawn. **`GET /capabilities`** → deep probe (pynvml/sysctl), refinement only, never load-bearing for the tier verdict. +- **Timings in every result**: `{load, text, img_enc, denoise:[per-step], vae}` for i2v (the vendored generate loop already brackets the phases); flat `elapsed` + unit counts elsewhere. Feeds the estimator (§7). +- **Residency**: `ResidencyRegistry` owns all residents incl. third-party caches (whisper's gets an unload hook; `unload_all()` fires all hooks unconditionally, #15). CUDA coordinator: ComfyUI `/free` before LLM-heavy phases; llama-server stopped before video jobs on < 16 GB tiers. Idle timer (5 min) evicts heavy residents (#9). +- **Dispatch-time resource check**: available RAM/VRAM/disk vs stage estimate before touching the GPU → `OOM_PREDICTED`/`DISK_FULL` (#12/#14/#30). +- **Managed temp root** `userData/tmp/` with GC at sidecar start — interp and upscale work dirs both move here (#26); frame streams pipe to ffmpeg stdin instead of PNG dirs (#14). `fs.renameSync` call sites get a copy+unlink EXDEV fallback helper (#28). Sidecar logging is TTY-gated: no ANSI/emoji when stdout is not a terminal (#29). +- **Windows process control**: `_run_isolated` win32 branch = `creationflags=CREATE_NEW_PROCESS_GROUP|CREATE_NO_WINDOW`, kill via `taskkill /T /F /PID`; POSIX branch gated on `os.name=='posix'` (#2). Shutdown: `before-quit` → `POST /shutdown`, 3 s grace, tree-kill. + +**Parity rule**: both backends implement identical routes, params, progress events, error codes. Golden contract tests run against `backends/mock` on every PR on all three OS runners. + +### 3.4 Network policy (single authoritative allowlist) + +`src/shared/netAllowlist.ts` exports the policy consumed by (a) the Electron session firewall, (b) `scripts/check-no-cloud.sh`, (c) docs: + +| Purpose | Hosts | Allowed process | +|---|---|---| +| Model downloads | `huggingface.co`, `cdn-lfs*.huggingface.co`, `*.hf.co` (+ `settings.hfEndpoint` mirror, e.g. `hf-mirror.com`) | `local/serve/downloader.py` child only | +| Runtime bootstrap | `pypi.org`, `files.pythonhosted.org`, `download.pytorch.org`, `github.com`, `objects.githubusercontent.com`, `codeload.github.com`, `astral.sh` (uv python-build-standalone), `huggingface.co`/`cdn-lfs*.huggingface.co`/`*.hf.co` (videoboom-assets runtime mirrors: ComfyUI trio, llama-server — fixes the §3.2-vs-allowlist omission) | `src/main/bootstrap.ts` children only | +| Catalog refresh (**opt-in**, fetched only when the user opens the Models tab AND enabled a "check for new models" toggle, default **off**) | `raw.githubusercontent.com` | `src/main/models/catalog.ts` | +| Inference — local stages | `127.0.0.1:` only | local inference children (default) | +| Inference — cloud stages (**opt-in per stage**) | `openrouter.ai` (LLM/VLM/keyframe/moderation/Kling video); `api.replicate.com`, `replicate.delivery`, `*.replicate.delivery` (WhisperX) — see `src/shared/netAllowlist.ts` `CLOUD_HOSTS` | in-process cloud dispatcher (`src/engine/cloud/**`), **only** when that stage resolved cloud **and** its provider key is present | +| Dev | `http://localhost:5273` **and `ws://localhost:5273`** (Vite HMR) when `!app.isPackaged` | renderer | + +- Invariant (**hybrid, updated 2026-07-05**): **local stages touch zero network at inference time; cloud stages talk only to their own provider host (per-provider allowlist above), and only when the user opted that stage into cloud AND its key is present; no key ⇒ zero inference network.** Provisioning is user-consented and confined to the designated online process classes. **Local** inference children get `HF_HUB_OFFLINE=1, TRANSFORMERS_OFFLINE=1, DO_NOT_TRACK=1, HF_HUB_DISABLE_TELEMETRY=1`; the **cloud** dispatcher runs in-process (main / `src/engine/cloud/http.ts`), never a child, and is never given those offline vars; downloader/bootstrap children are spawned **without** the offline vars. Enforcement: the session firewall allows a `CLOUD_HOSTS[provider]` host only if `keyStatus()[provider]` is true and at least one stage resolved to that provider's cloud backend (`DUAL_BACKEND_PLAN.md` §6). +- `scripts/check-no-cloud.sh` greps `https?://` in `src/ renderer/ local/` minus loopback, with a **path-keyed allowlist**: `src/main/bootstrap.ts`, `src/main/models/catalog.ts`, `local/serve/downloader.py`, `src/shared/netAllowlist.ts`, and (hybrid, 2026-07-05) the restored cloud files `src/engine/cloud/**`, `src/engine/cost.ts`, `src/main/keychain.ts` — every provider URL lives only there; the script now **inverts** (asserts no URL appears outside the allowlist, and that every host in `cloud/**` is present in `netAllowlist.ts`) rather than banning all URLs (not the legacy `local/download.py` filename). +- **Google Fonts removal** (missed by draft D): `renderer/index.css:1` imports Inter from `fonts.googleapis.com` and `renderer/index.html:8`'s CSP allows it. M1-C6 bundles the Inter woff2 files under `renderer/fonts/` with `@font-face` and strips the Google hosts from the CSP — before the tripwire is enabled, or CI is red on day one. + +--- + +## 4. Hardware detection & tier auto-config + +### 4.1 Detection (pure TS, main process, `src/main/hardware.ts`) + +Runs before any Python exists; zero native deps; < 300 ms NVIDIA, ~1.2 s macOS. + +- **macOS**: gate `darwin && arm64`; chip via `sysctl -n machdep.cpu.brand_string`; unified RAM via `os.totalmem()`; GPU cores via `system_profiler SPDisplaysDataType -json` → `sppci_cores`. +- **Windows**: `nvidia-smi --query-gpu=index,name,memory.total,memory.free,driver_version,compute_cap --format=csv,noheader,nounits` (PATH → `%SystemRoot%\System32` → `%ProgramFiles%\NVIDIA Corporation\NVSMI`). Multi-GPU: pick max `memory.total`, export `CUDA_VISIBLE_DEVICES=`. `compute_cap ≥ 8.9` → fp8; driver ≥ 570 floor (< 570 → "update your driver" screen). No nvidia-smi → unsupported; name the GPU via `Get-CimInstance Win32_VideoController` (never trust `AdapterRAM`). +- **Linux**: same nvidia-smi; pre-check `/dev/nvidia0`; naming fallback `lspci -nn | grep -Ei 'vga|3d'`. +- **All**: disk via `fs.statfsSync(modelsDir)`; re-checked at every download and render preflight. System RAM is a first-class tier input on NVIDIA (block swap parks both 14B experts + T5 in RAM). + +Profile persisted per §3.1(1). Re-detect triggers: every boot (cheap fingerprint), driver-only change (re-evaluates fp8/floor), manual button, render preflight (disk + `memory.free`; another app holding > 2 GB VRAM → warn, don't block). On fingerprint change: recompute tier, **update only stages with `mode:'auto'`**, clear `learnedCaps` + `calibration`, one-time banner. `tier` lives only in the device profile — Settings has no tier field. + +### 4.2 Tier matrix (`src/main/tiers.ts`, declarative data) + +**Minimum supported spec**: Apple Silicon M1+ with **≥ 32 GB unified** (16–31 GB = explicit *experimental* tier, see below), macOS 14+; or NVIDIA ≥ 8 GB VRAM, compute cap ≥ 8.6, driver ≥ 570, ≥ 32 GB system RAM, Windows 10+/Linux. Below: graceful unsupported card naming the hardware; app remains browsable; Create disabled with reason. The unsupported/minimum copy is centralized in `tiers.ts` and used verbatim from M1 onward ("Videoboom needs Apple Silicon (32 GB+; 16 GB experimental) or an NVIDIA GPU with 8 GB+ VRAM"). + +**Apple Silicon (MLX)** + +| | M16 (16–31 GB, EXPERIMENTAL) | M32 (32–47 GB) | M48 (48–63 GB) | M64 (64 GB+) | +|---|---|---|---|---| +| Video | **FAST:** Wan2.2-TI2V-5B-MLX **Q8 DiT (~5.3 GB) + umt5-enc MLX-Q8 (~6.0 GB) + VAE fp32 (2.82 GB)**, 640×384, 49f, 10 steps (**FastWan bf16 = 25.6 GB peak, does NOT fit the 16 GB budget** → M16 FAST stays this quantized stock 5B recipe; a FastWan-Q8 3-step conversion is a future spike). No QUALITY (14B doesn't fit 16 GB). Stage sequencing exists upstream (T5 freed after encode — generate.py:351; DiT freed before decode — :709-718); worst resident stage ≈ 7.5–8 GB vs the 10.9 GB Metal budget. **Ships only after the two-phase M16 validation (§12 M5)**; until then 16–31 GB machines see "experimental — not yet enabled" | **QUALITY:** Wan2.2-I2V-A14B MLX-Q4 + Lightning 4-step (lightx2v Seko-V1), 832×480, 37f@16fps, tiny-VAE. **FAST:** FastWan-5B DMD 3-step, 832×480/121f@24 fps — **tight** (25.6 GB peak vs the ~21.3 GB 32 GB budget) → gated on the M5 M32 fit check; spill → fall back to stock 5B-Q8 or the 14B-Q4 fast preset | **QUALITY:** Wan2.2-I2V-A14B **MLX-bf16 relay-shedding** (current champion), 832×480, 37f, Lightning 4-step, tiny-VAE. **FAST:** FastWan-5B DMD 3-step, 832×480/121f@24 fps, `fastwan_dmd` (guide=1, renoise) — **measured 264 s/clip, 25.6 GB peak** (git e095e90); native 24 fps drops RIFE | **QUALITY:** bf16 relay, 832×480/61f; 1280×704/49f HD unlocked; `VB_LOCAL_WAN_RESIDENT=1` opt-in **≥ 64 GB** (matches `local/wan_i2v.py:148-150`; verified kill at 48). **FAST:** FastWan-5B DMD 3-step, 832×480/121f@24 fps; 1280×704/121f native opt-in (measured 642 s/clip — wall-parity with the old 480p 14B path but a true 720p base) | +| Keyframe | FLUX.1-schnell mflux-4bit **`--low-ram`** (flag verified in locked mflux), 832×480, 4 steps | same | same | same, 1024×576 | +| LLM | mlx-community/Qwen3-8B-4bit (4.6 GB) | lmstudio-community/Qwen3.6-35B-A3B-MLX-4bit (20.4 GB, evicted before video) | same | same | +| VLM | mlx-community/gemma-3-4b-it-4bit | mlx-community/gemma-3-12b-it-4bit | same | same | +| STT | mlx-community/whisper-large-v3-turbo (1.6 GB) — all tiers | | | | +| Interp/Upscale | RIFE v4.26 ncnn / realesr-animevideov3-x2 → 1080p — all tiers | | | | + +**Two modes, both 1080p final (2026-07-05 remap):** every Apple tier exposes a **FAST** path (FastWan-5B DMD 3-step, native 832×480/121f@24 fps — MLX-viable and measured on M48/M64, tight on M32, not viable on M16 where FAST stays the stock 5B-Q8 recipe) and, where the hardware fits it, a **QUALITY** path (Wan-14B bf16-relay / Q4). Native generation stays at the tier's res; **both modes deliver 1080p** via the realesr-animevideov3-x2 upscale row (native 1080p diffusion is infeasible on-device — `localVideo.ts:116-119`). FastWan is native 24 fps, so the FAST path skips the 16→24 RIFE stage; the 5B x64-VAE softening of faces (why it is the draft tier, not QUALITY) is partly cleaned by the upscale. + +Rationale for the M16 change (mechanics corrected 2026-07-04, R5): coexistence is NOT the failure mode — stock mlx-video already stages (T5 freed after encode, generate.py:351-353; DiT freed before VAE decode, :709-718). The real blockers under the 16 GB Metal budget (recommendedMaxWorkingSetSize = 10,922.67 MB): (a) `load_t5_encoder` upcasts umt5 to float32 → ~22.7 GB at runtime (mlx_video utils.py:65-70); (b) even un-upcast, bf16 umt5 is 11.36 GB > budget; (c) `--quantize` converts the transformer only (convert.py:342). M16 therefore needs one supported conversion (DiT Q8, `--quantize --bits 8 --group-size 64`, ~5.3 GB) plus **two small mlx-video fork patches** (quantize umt5 to Q8 ≈ 6.0 GB at convert time; quantized no-upcast T5 load). Resulting stage peaks: T5-Q8 encode ≈ 6.3 GB · denoise ≈ 7.5–8 GB (Q8 DiT 5.3 + CFG activations) · fp32-VAE tiled decode ≈ 5–7 GB — ~3 GB headroom at the worst stage. Field support: Draw Things ships Wan 2.2 5B 6-bit SVDQuant for iPhone-class (≤8 GB) devices. The real-machine gate stays mandatory: macOS baseline (4–6 GB) shares the same 16 GB and cannot be simulated by a capped 48 GB machine. + +**NVIDIA CUDA** + +| | N8 (8–11 GB) | N12 (12–15 GB) | N16 (16–23 GB) | N24 (24–31 GB) | N32 (32 GB+) | +|---|---|---|---|---|---| +| Video | **FAST (only mode):** QuantStack Wan2.2-TI2V-5B-GGUF Q4_K_M (3.4 GB), 704×416, 49f, 8 steps — **FastWan has no GGUF and bf16 won't fit 8 GB**, so N8 keeps the stock 5B-GGUF fast path; 14B QUALITY locked out | **QUALITY:** QuantStack Wan2.2-I2V-A14B-GGUF Q4_K_S (8.75 GB ×2) + WanVideoWrapper block swap + Lightning, 832×480, 81f. **FAST:** FastWan-5B bf16 via FastVideo runtime + TE CPU-offload (tight, no GGUF) → else stock 5B-GGUF | **QUALITY:** fp8-capable Kijai fp8_e4m3fn_scaled (15 GB/expert); Ampere GGUF Q5_K_M. 832×480/81f, 1280×704 optional. **FAST:** FastWan-5B bf16/fp16 (FastVideo runtime, DMD 3-step) — fits comfortably @832×480 | **QUALITY:** fp8-scaled (Ada+) / Q8_0 (3090), 1280×704, 81f. **FAST:** FastWan-5B bf16, 832×480 or 1280×704-native | **QUALITY:** fp8-scaled, 1280×704/81f; 20-step HD preset. **FAST:** FastWan-5B bf16, 1280×704-native | +| Keyframe | city96 FLUX.1-schnell-gguf Q4_K_S (6.8 GB, T5 CPU-offload) | FLUX.1-schnell-gguf Q8_0 (12.7 GB) | Qwen-Image GGUF Q4_K_S (12.1 GB) + Lightning LoRA + **TE/VAE companions (§5)** | Qwen-Image Q8_0 | Qwen-Image Q8_0 | +| Identity | descoped (typed UI warning) | descoped | Qwen-Image-Edit GGUF Q4_K_S | same | same | +| LLM+VLM | Qwen3-VL-4B-Instruct GGUF Q4_K_M | Qwen3-VL-8B-Instruct GGUF Q4_K_M (5.8 GB incl. mmproj) | same 8B | same 8B | same 8B | +| STT | **large-v3-turbo CT2 int8 (multilingual, ~1.6 GB) — all tiers**; distil-large-v3 English-only opt-in | | large-v3 int8_float16 optional | same | same | +| Interp/Upscale | Practical-RIFE v4.26 torch / spandrel realesr-general-x4v3 — all tiers; SeedVR2 3B fp8 optional N24, 7B N32 | | | | | +| Disk to first render (tier-default models + runtime, computed 2026-07-05 — N16+ now include the ~23 GB FastWan FAST model; wizard recomputes from catalog at build time) | ≈ 34 GB Win / 36 GB Linux | ≈ 59 / 61 GB | ≈ 98 / 101 GB (+12 GB identity → 110 / 113) | ≈ 119 / 122 GB (+3.4 GB SeedVR2-3B opt.) | ≈ 119 / 122 GB (+16 GB SeedVR2-7B opt.) | + +Cross-cutting gates: N8/N12 with < 32 GB system RAM → block swap impossible → 14B locked out, 5B forced, wizard says why. **FAST mode uses FastWan-5B where viable** (N16+ bf16 via the FastVideo runtime — §3.2; the DMD sampler is FastVideo-custom, not a stock ComfyUI node; **no FastWan GGUF exists** → N8 always falls back to the stock TI2V-5B GGUF; N12 attempts FastWan bf16 with text-encoder CPU-offload (tight) and falls back to the stock 5B-GGUF only on spill). **Both modes deliver 1080p** via the upscale row (native at tier res, upscaled). Lightning 4-step CFG-off is a **14B/QUALITY** lever only (FastWan is guide-off DMD 3-step); HD presets M48+/N24+ only. + +### 4.3 Resolver, wizard, overrides + +- **`src/main/autoconfig.ts`**: pure `resolveConfig(profile, settings) → ResolvedConfig` (per-stage model id, quant file, WxH, frames, steps, offload flags). `settingsEnv()` (`src/main/settings.ts`) becomes `resolveConfig → toEnv`; the if-chains at settings.ts:83-115 die. The ETA estimator consumes the same ResolvedConfig, so estimate and reality cannot drift. +- **First-run wizard** (`renderer/screens/Onboarding.tsx`, rendered when `!settings.onboarded`, in the slot the `!hasKey` banner occupies today): 1) detect; 2) verdict card with seeded ETA range (±60% band — always available, see §7 fallback); 3) storage picker with live free space and the tier bundle size **computed from the catalog at build time** (not hand-written prose numbers); 4) **runtime install — unconditional on all platforms** (macOS: Python 3.12 + mlx.lock env ≈ 2 GB; Win/Linux CUDA env, measured 2026-07-04 from the pinned wheels' zip central directories: **Windows ≈ 3.1 GB download / ≈ 5.7 GB on disk**, **Linux ≈ 4.4 GB download / ≈ 8.3 GB on disk** — Linux pulls the split `nvidia-*-cu12` stack (cudnn 707 MB dl / 1.05 GB installed) while the Windows `+cu128` torch wheel bundles the CUDA DLLs (2.86 GB dl / 4.51 GB unpacked; measured unpack ratios ≈ 1.8× Linux stack, 1.6× Windows — not 2.2–2.5×). `scripts/build-locks.py` recomputes both numbers per OS into `resources/runtime-sizes.json` on every lock change; the wizard shows download and on-disk); 5) download recommended set (queued through the Model Manager); 6) calibrate (sidecar bench job, ~3–4 min, skippable → seed estimates until run); 7) done. +- **Manual overrides**: any edit flips the stage to `mode:'manual'`; tiering never touches manual stages. Validation LM-Studio-style: green fits / amber tight / red won't-fit (warns, never forbids). Per-stage "Reset to recommended". + +### 4.4 Degradation ladder (`src/engine/autotune.ts`) — hardened spec (R8 resolved 2026-07-04) + +Sidecar classifies failures into typed codes with structured payload `{code, phase, sig, raw}` (§3.3); the ladder hooks into `renderLocalScene` (**`src/engine/pipeline.ts:399`**) and `genVideoLocal` (`src/engine/localVideo.ts:56`). Max 3 rung applications per render; **a rung already active (config or learnedCaps) is skipped without consuming a retry**. + +**OOM signature table (normative).** `sig` = first matching rule against the exception type/message (MLX: in-process try/except) or ComfyUI's `execution_error` websocket message / `GET /history` `status.messages` fields `exception_type` + `exception_message` (CUDA; ComfyUI `execution.py:646-651,682-708,1269-1272`): + +| sig | Backend | Signature (match) | Detected by | Typed code | Source | +|---|---|---|---|---|---| +| `cuda-alloc` | CUDA | `exception_type` is `torch.OutOfMemoryError` (torch ≥2.4) or `torch.cuda.OutOfMemoryError`; message starts "CUDA out of memory. Tried to allocate" | comfy.py (ws + /history) | `OOM` | pytorch CUDACachingAllocator.cpp:1935; torch/cuda/__init__.py:504; ComfyUI model_management.py:375 | +| `cuda-runtime` | CUDA | RuntimeError/AcceleratorError containing "CUDA error: out of memory" or driver code 2 (cudaErrorMemoryAllocation) — parity with ComfyUI `is_oom()` | comfy.py | `OOM` | ComfyUI model_management.py:384-390 | +| `cudnn-ws` | CUDA | message contains "CUDNN_STATUS_ALLOC_FAILED" or "CUDNN_STATUS_NOT_INITIALIZED" or "Unable to find a valid cuDNN algorithm" (masked workspace OOM; hits Wan/SeedVR2 conv3d VAE). **ComfyUI does NOT classify these as OOM — comfy.py matches them itself, never trusts the tips string** | comfy.py | `OOM` | discuss.pytorch.org/t/78724 | +| `cublas-alloc` | CUDA | message contains "CUBLAS_STATUS_ALLOC_FAILED" or "CUBLAS_STATUS_NOT_INITIALIZED" (handle-creation OOM) | comfy.py | `OOM` | pytorch forums (cublasCreate OOM) | +| `child-killed` | CUDA | ComfyUI child exits mid-prompt (Linux oom_killer: signal 9 / exit 137; ws drops) | comfy.py coordinator (child owner; sidecar survives) | `BACKEND_CRASH {cause:'oom_suspected'}` → ladder treats as `OOM` at journal phase | phase journal §3.3 | +| `llama-alloc` | CUDA | llama-server stderr contains "cudaMalloc failed: out of memory", or exit during load | llama.py | `OOM` (stage llm) → residency eviction + one retry, NOT the video ladder | llama.cpp ggml-cuda.cu:789 | +| `sysmem-fallback` | CUDA/Win | **No exception.** WDDM spills into shared system RAM (~10× slower). Heuristic: pynvml used/total ≥ 0.97 AND observed s/step ≥ 3× predicted for 2 consecutive steps (prediction passed in job params). Optional confirm: perf counter `\GPU Process Memory(pid_*)\Shared Usage` > 1 GB. Thresholds tuned in the M5 drill | sidecar resources.py emits `PERF_DEGRADED {cause:'sysmem_fallback'}` in progress; TS autotune converts to OOM-equivalent | progress event, not error | NVIDIA KB a_id/5490 (546.01+ toggle is control-panel-only — not settable by us) | +| `mlx-malloc` | MLX | RuntimeError containing "[metal::malloc]" (max-buffer or resource-limit) or "[malloc] Unable to allocate" | sidecar (in-process) | `OOM` | mlx allocator.cpp:114-157 | +| `mtl-cmdbuf` | MLX | message contains "[METAL] Command buffer execution failed:" AND ("Insufficient Memory" or "kIOGPUCommandBufferCallbackErrorOutOfMemory") — the repo-observed Metal OOM (local/server.py:28, localVideo.ts:117). Other command-buffer errors → `BACKEND_CRASH` | sidecar | `OOM` | mlx device.cpp:507-510 | +| `jetsam` | MLX | **No exception** — macOS memory-pressure kill: whole sidecar dies with signal SIGKILL. TS watchdog: child `exit` with signal SIGKILL + phase journal in state `running` + last polled `sysctl kern.memorystatus_vm_pressure_level ≥ 2` (TS polls every 10 s during renders) → synthetic `OOM {sig:'jetsam'}` after respawn; without pressure corroboration → `BACKEND_CRASH {cause:'unknown'}` | **TS engine watchdog** (only layer alive) | synthetic `OOM` | sysctl verified on dev machine; §4.2 "verified kill at 48" | + +**Rung mapping is phase-aware** (fix: the old ladder was phase-blind — frames/res rungs cannot fix a load-phase OOM because peak weight residency is frame-count-invariant): +- `phase load|text` → rung 1 → rung 4 (quant, if on disk) → rung 5. Rungs 2–3 skipped. +- `phase denoise|img_enc` → rung 1 → 2 → 3 → 4 → 5. +- `phase vae` → rung 1's VAE lever (tiled decode) → rung 2 → rung 3. +- `child-killed`/`jetsam`: respawn child/sidecar first, then map by journal phase. + +Rungs (per-workflow-family data in `tiers.ts`; every rung strictly reduces predicted peak for the OOM'd phase — levers verified upstream): +1. **Offload/tiling up** — wrapper (N8/N12): `blocks_to_swap +8`, saturating at 40 (14B) / 30 (5B); per-job workflow-JSON input (WanVideoWrapper nodes_model_loading.py:299), no restart; **RAM preflight**: parked blocks land in system RAM — if psutil available RAM < swap delta, skip to next rung. Native (N16+): swap `VAEDecode` → `VAEDecodeTiled` in the templated JSON (per-job, core node ComfyUI nodes.py:340); on 2nd application only, **restart the ComfyUI child with `--reserve-vram` +1.5 GB** (cli_args.py:147 — launch flag; restart ~10–20 s, drops residents). MLX: VAE tiling auto→aggressive; force relay-shedding; **skip-if-active** (relay-shedding is already default on M48/M64). +2. **Frames down**: 81→61→49→37→21 (4n+1); chaining absorbs it (reduces token count n = (w/16)·(h/16)·(1+(f−1)/4) → attention/activation peak). +3. **Resolution down**: 1280×704 → 960×544 → 832×480 (→ 704×416 floor N8/M16). +4. **Quant down**: only if the smaller files are already on disk (never download mid-render); the only rung besides 1/5 that reduces load-phase weight residency. +5. **Model down**: 14B → 5B if present, else fail. +6. Scene soft-fails (`stillClip`, **pipeline.ts:790**) with a message naming the next rung ("Install the Q4 variant to let auto-recovery go further"). + +**Steps are explicitly NOT a rung** — step count does not change peak memory; steps appear only in the too-slow policy. `OOM_PREDICTED` (dispatch-time, §3.3) consumes no retry: autotune directly picks the lowest rung whose predicted peak fits. + +Too-slow / fallback policy: after step 2 of denoising, measured s/step vs prediction; projected overrun of `VB_LOCAL_DEADLINE_SEC` → auto-apply rung 2 for the rest of the render. `PERF_DEGRADED {sysmem_fallback}`: if < 25% of steps done → cancel clip, retry at rung 1; else finish the clip and apply rung 1 from the next clip; persist `sysmemFallback:true` — autoconfig thereafter caps configs at predicted VRAM ≤ 0.9 × total. Persistent > 2.5× slower without VRAM saturation never silently downgrades — post-render suggestion card. + +**learnedCaps schema** (`deviceProfile.learnedCaps.video`, single writer per §3.1): `{ family:'mlx'|'native'|'wrapper', rungFloor:{offloadLevel?, maxFrames?, maxRes?, quantCeil?}, maxOkTokens?, oomTokens?, sysmemFallback?, events:[{ts, code, sig, phase, tokens, action}] (ring, 10) }`. Every applied rung updates `rungFloor` (autoconfig starts future renders from the floor); cleared on fingerprint change (§3.1); surfaced as amber chips in the Model Manager and an `{event:'autotune', …}` engine event. + +--- + +## 5. Model catalog & quantizations + +### 5.1 Files & schema + +- **`resources/model-catalog.json`** — shipped via `extraResources`; `catalogVersion` int, `minAppVersion` semver. Optional newer copy in `/model-catalog.json` (opt-in refresh per §3.4). Loader `src/main/models/catalog.ts` picks the higher compatible version. Python reads the same file. +- Schema per draft B with **two added fields per variant**: `license` (spdx or label: `apache-2.0`, `gpl-3.0`, `flux-dev-nc`, `gemma-terms`) and `gated: boolean` — surfaced in UI rows and `docs/MODELS.md`. Plus `required: boolean` per stage (SeedVR2/HD variants optional; render gate checks only required stages). One variant = one download unit = one deletable directory; `revision` pins an HF commit; `requires` links refcounted shared components; `install.type: 'hf-files' | 'hf-snapshot' | 'hf-then-convert'`. +- **CI invariant** (verifier fix): a test cross-validates `tiers.ts` against `model-catalog.json` — every `tierDefault` must resolve to a catalog variant for that platform, and every bundle size shown in the wizard is computed by `scripts/catalog-sizes.ts` at build time. Full assertion list, script name, and rollout in §12.1 (`scripts/check-catalog-invariants.ts`). + +### 5.2 Seed catalog (concrete; sizes repo-verified live 2026-07-04 unless marked est.) + +| Stage | Variant id | Source | Bytes | Min VRAM/RAM | Backend | License | +|---|---|---|---|---|---|---| +| VIDEO | `wan22-14b-mlx-bf16` | `Wan-AI/Wan2.2-I2V-A14B` + convert → later pre-converted `videoboom/Wan2.2-I2V-A14B-MLX-bf16` (**umt5 tokenizer vendored in**) | ~54 GB converted est. (126.2 GB source dl) | — / 48 GB | mlx | apache-2.0 | +| VIDEO | `wan22-14b-mlx-q4` | same, `--quantize --bits 4` | ~18 GB | — / 32 GB | mlx | apache-2.0 | +| VIDEO | `wan22-5b-mlx-q8` (**new**, M16 gate) | `Wan-AI/Wan2.2-TI2V-5B` (source 34.2 GB) + convert: DiT Q8 ~5.3 GB + umt5-enc Q8 ~6.0 GB (**needs fork patches: T5-quantizing convert + no-fp32-upcast load**) + VAE fp32 2.82 GB | ~14.1 GB est. installed | — / 16 GB | mlx | apache-2.0 | +| VIDEO | `wan22-14b-fp8-kj` | `Kijai/WanVideo_comfy_fp8_scaled` HIGH+LOW | 30 GB | 16 GB / 64 GB | cuda | apache-2.0 | +| VIDEO | `wan22-14b-gguf-q4ks/-q5km/-q8` | `QuantStack/Wan2.2-I2V-A14B-GGUF` (×2 experts) | 17.5 / 21.6 / 30.8 GB | 12 / 16 / 24 GB | cuda | apache-2.0 | +| VIDEO | `wan22-5b-gguf-q4km` | `QuantStack/Wan2.2-TI2V-5B-GGUF` | 3.4 GB | 8 GB / 32 GB | cuda | apache-2.0 | +| VIDEO (**new — FAST mode default; optional variant / per-tier default preference — the VIDEO stage's `required` flag is satisfied by the stock 5B fallback (`wan22-5b-mlx-q8` / `wan22-5b-gguf-q4km`), not by this variant**) | `fastwan-5b-mlx-dmd` | `FastVideo/FastWan2.2-TI2V-5B-FullAttn-Diffusers` (23 GB source, rev `3e187042`) → `install.type:'hf-then-convert'` (mlx-video Wan converter, same as stock 5B) + inject `fastwan_dmd` marker `{sigmas:[1.0,0.757,0.522,0.0],renoise:true}` into `config.json` — **already implemented** (`local/fastwan_dmd.py`, `.model-path-5b`, git e095e90; converted weights key+shape-identical to the official 5B conversion) | ~20 GB converted est. (bf16 DiT ~10 + umt5-enc + VAE fp32) | — / 48 GB (measured **25.6 GB peak** @832×480/121f; tight on 32 GB, N/A on 16 GB) | mlx | apache-2.0 | +| VIDEO (**new — FAST mode, CUDA; optional variant / N16+ default preference; N8/N12 fall back to the stock 5B GGUF — the VIDEO stage's `required` flag is satisfied by `wan22-5b-gguf-q4km`, not by this variant**) | `fastwan-5b-cuda-bf16` | same `FastVideo/FastWan2.2-TI2V-5B-FullAttn-Diffusers` (rev `3e187042`, `install.type:'hf-files'` — diffusers layout, **no GGUF exists**) driven by the FastVideo runtime (custom DMD 3-step sampler, §3.2) | ~23 GB (DiT bf16 ~10 + umt5-xxl ~11 + VAE) | 16 GB / 32 GB (N16+ comfortable; N12 → TE CPU-offload (tight) else fall back to `wan22-5b-gguf-q4km`; N8 → always falls back, bf16 won't fit 8 GB) | cuda | apache-2.0 | +| shared | `wan22-lightning-i2v-lora` | `lightx2v/Wan2.2-Lightning` 4-step rank64 Seko-V1 | 2.5 GB | — | both | apache-2.0 | +| shared | `umt5-xxl-fp8` / `wan21-vae` / `taew2_1` | `Comfy-Org/Wan_2.2_ComfyUI_Repackaged` / `lightx2v/Autoencoders` | 6.7 / 0.25 / 0.022 GB | — | cuda/both | apache-2.0 | +| KEYFRAME | `flux-schnell-mflux-4bit` | `dhairyashil/FLUX.1-schnell-mflux-4bit` | ~10 GB | — / 16 GB with `--low-ram` (M16), 24 GB comfortable | mlx | apache-2.0 | +| KEYFRAME | `flux-schnell-gguf-q4ks/-q8` | `city96/FLUX.1-schnell-gguf` | 6.8 / 12.7 GB | 8 / 12 GB | cuda | apache-2.0 | +| shared | `t5xxl-fp8` + `clip-l` (FLUX TE) | `comfyanonymous/flux_text_encoders` (`t5xxl_fp8_e4m3fn.safetensors`, `clip_l.safetensors`) | 4.9 + 0.25 GB | — | cuda | apache-2.0 | +| KEYFRAME | `qwen-image-gguf-q4ks/-q8` (+Lightning LoRA) | `QuantStack/Qwen-Image-GGUF` | 12.1 / 21.8 GB | 16 / 24 GB | cuda | apache-2.0 | +| shared (**new — verifier fix**) | `qwen25-vl-7b-te-fp8` + `qwen-image-vae` | `Comfy-Org/Qwen-Image_ComfyUI` (`qwen_2.5_vl_7b_fp8_scaled.safetensors`, `qwen_image_vae.safetensors`) | 9.4 + 0.25 GB | — | cuda | apache-2.0 | +| IDENTITY | `qwen-image-edit-gguf-q4ks` (**replaces Kontext as default**) | `QuantStack/Qwen-Image-Edit-GGUF`, shares TE/VAE above | 12.1 GB | 16 GB | cuda | apache-2.0 | +| IDENTITY (optional, gated) | `flux-kontext-dev-*` | `black-forest-labs/FLUX.1-Kontext-dev` — **gated, non-commercial**; requires the optional HF-token + license-acceptance flow (§6); never a tier default | ~12 GB fp8 est. | 16 GB / 24 GB | cuda/mlx | flux-dev-nc | +| LLM | `qwen3.6-35b-a3b-mlx-4bit` | `lmstudio-community/Qwen3.6-35B-A3B-MLX-4bit` | 20.4 GB | — / 32 GB | mlx | apache-2.0 | +| LLM (**new**) | `qwen3-8b-mlx-4bit` | `mlx-community/Qwen3-8B-4bit` | 4.6 GB | — / 16 GB | mlx | apache-2.0 | +| LLM+VLM | `qwen3-vl-8b-gguf-q4km` / `-4b-` | `Qwen/Qwen3-VL-8B-Instruct-GGUF` (+mmproj) / 4B | 5.0+0.75 / 2.5+0.45 GB (model+mmproj) | 6 / 4 GB | cuda | apache-2.0 | +| VLM | `gemma3-12b-mlx-4bit` / (**new**) `gemma3-4b-mlx-4bit` | `mlx-community/gemma-3-12b-it-4bit` / `-3-4b-` | 8.1 / 3.4 GB | — / 16 GB | mlx | gemma-terms | +| STT | `whisper-l3-turbo-mlx` | `mlx-community/whisper-large-v3-turbo` | 1.6 GB | — | mlx | mit | +| STT | `faster-whisper-l3-turbo-int8` (default) / `-l3` / `distil-l3` (en-only opt-in) | `deepdml/faster-whisper-large-v3-turbo-ct2` / `Systran/faster-whisper-large-v3` / `-distil-large-v3` | 1.6 / 3.1 / 1.5 GB | 3 GB | cuda | mit | +| UPSCALE (**new**, optional) | `seedvr2-3b-fp8` / `seedvr2-7b` | ComfyUI-SeedVR2 node weights (`numz/SeedVR2_comfyUI` mirror) | 3.4 / 16.5 GB (+ shared `ema_vae_fp16.safetensors` 0.5 GB) | 24 / 32 GB | cuda | apache-2.0 | +| INTERP | `rife-v4.26` (+Practical-RIFE v4.25 pkl for CUDA) | `videoboom/videoboom-assets` mirror | 30 MB | — | both | mit | +| UPSCALE | `realesr-animevideov3-x2` (ncnn) / `realesr-general-x4v3` + `RealESRGAN_x4plus` (spandrel) | wheel-bundled / `videoboom/videoboom-assets` | 0 / 130 MB | — | mlx / cuda | bsd-3 | +| RUNTIME | `comfyui-bundle` (ComfyUI + ComfyUI-GGUF + WanVideoWrapper, pinned commits) / `llama-server-win-cuda124` (+cudart 12.4) / `llama-server-linux-vulkan` (official release asset, tag-pinned) | `videoboom/videoboom-assets` mirror of `ggml-org/llama.cpp` releases + ComfyUI-trio tarballs (sha256 = release-API digests) | download sizes: ~0.03 (measured 31 MB compressed, ≈0.15 GB unpacked) / ~0.66 / ~0.03 GB | — | cuda | gpl-3.0 + apache-2.0 / mit | + +**Catalog audit (2026-07-04, HF API `?blobs=true`)**: every repo above exists, public, `gated:false` — sole exception `black-forest-labs/FLUX.1-Kontext-dev` (`gated:"auto"`, non-commercial), which stays opt-in. Audited commit SHAs become the catalog `revision` pins: Wan-AI/Wan2.2-I2V-A14B@206a9ee1 · Wan-AI/Wan2.2-TI2V-5B@921dbaf3 · Kijai/WanVideo_comfy_fp8_scaled@033a4e48 · QuantStack/Wan2.2-I2V-A14B-GGUF@6c671745 · QuantStack/Wan2.2-TI2V-5B-GGUF@57437632 · lightx2v/Wan2.2-Lightning@18bccf88 · Comfy-Org/Wan_2.2_ComfyUI_Repackaged@fb1388ad · lightx2v/Autoencoders@02cbfd1a · dhairyashil/FLUX.1-schnell-mflux-4bit@0e0b247a · city96/FLUX.1-schnell-gguf@f495746e · comfyanonymous/flux_text_encoders@6af2a98e · QuantStack/Qwen-Image-GGUF@257f261f · Comfy-Org/Qwen-Image_ComfyUI@46839d33 · QuantStack/Qwen-Image-Edit-GGUF@acab6f9f · lmstudio-community/Qwen3.6-35B-A3B-MLX-4bit@0c4a20a6 · mlx-community/Qwen3-8B-4bit@545dc425 · Qwen/Qwen3-VL-8B-Instruct-GGUF@f982a075 · Qwen/Qwen3-VL-4B-Instruct-GGUF@1cd86afb · mlx-community/gemma-3-12b-it-4bit@86cc6a8d · mlx-community/gemma-3-4b-it-4bit@93724907 · mlx-community/whisper-large-v3-turbo@a4aaeec0 · deepdml/faster-whisper-large-v3-turbo-ct2@4df90f75 · Systran/faster-whisper-large-v3@edaa852e · Systran/faster-distil-whisper-large-v3@c3058b47 · numz/SeedVR2_comfyUI@09ced710 · black-forest-labs/FLUX.1-Kontext-dev@24e9dedc. **FastWan pin (added 2026-07-05, not in the 2026-07-04 sweep):** `FastVideo/FastWan2.2-TI2V-5B-FullAttn-Diffusers@3e187042` — public, ungated, Apache-2.0, ~23 GB source, DMD-distilled 3-step (the *only* published 5B FastWan variant; no VSA/GGUF equivalents), already in this machine's HF cache (`models--FastVideo--FastWan2.2-TI2V-5B-FullAttn-Diffusers`). Card caveat for `docs/MODELS.md`: `Comfy-Org/Wan_2.2_ComfyUI_Repackaged` and `mlx-community/whisper-large-v3-turbo` carry no license tag on the card — license inherited from upstream (Wan apache-2.0, Whisper MIT). The `videoboom` HF namespace was verified free (404) on 2026-07-04 — registration recommended in M0, before anything squats the name (user decision, §14 D1; the RUNTIME/INTERP/UPSCALE mirror rows above depend on it). Mirror legality: all critical assets Apache-2.0/MIT → full mirror into `videoboom-assets` permitted (ship LICENSE+NOTICE per Apache-2.0 §4); gemma repos are pin-only (no mirror — Gemma terms travel with redistributions); Kontext never mirrored. The catalog-build-time gate check + the weekly catalog-liveness job (§12 M4) write their result to `resources/catalog-audit.json` (`{generatedAt, entries:{[variantId]:{gated, license, status:'ok'|'gone'|'gated-changed', checkedRevision}}}`) — consumed by `scripts/check-catalog-invariants.ts` (§12.1). + +Identity policy (verifier blocker resolved): FLUX.1-Kontext-dev is gated + non-commercial; a tokenless downloader 401s on it and third-party re-uploads are unauthorized redistributions we will not auto-select. **Qwen-Image-Edit (Apache-2.0, ungated) is the identity default on N16+**; Kontext is opt-in behind the token flow; identity on N8/N12 is descoped with a typed, surfaced warning (fixing probe #16's silent-downgrade in the process). macOS identity default is pending an ungated MLX editor (§13). + +--- + +## 6. Model Manager UI + download manager + +### 6.1 Downloader (`local/serve/downloader.py`, replaces `local/download.py` in M4) + +Invocation: `python -m serve.downloader --job ` (`{variantId, artifacts:[{repo, revision, files:[{path,bytes,sha256}]}], destDir}`). Per-file `hf_hub_download(repo, filename, revision=)` → native `.incomplete` byte-range resume; real progress from `.incomplete` size vs manifest bytes (no 99% cap hack). After each file: stream-sha256 vs manifest (hashes sourced from HF LFS metadata at catalog build time); mismatch → delete, retry once, `{event:'error', code:'checksum'}`. Gated repo → `{event:'error', code:'GATED'}` + docs link (never a silent hang); the **optional HF-token flow** (Settings → "Hugging Face token", stored via safeStorage, used *only* by the downloader for variants marked `gated`, with an in-app license-acceptance checkbox recorded per variant) is the only credential in the app. Completion writes `/.vb-manifest.json`; installed-ness = manifest present + every file exists with matching size (full re-hash via "Verify" button). `install.type:'hf-then-convert'` covers the interim macOS Wan path (convert, delete fp32 source); mid-term pre-converted `videoboom/` org weights flip these to `hf-files`. Mirror: `settings.hfEndpoint` → `HF_ENDPOINT`. Offline: 3 s HEAD preflight → `{event:'offline'}`, queue item stays `queued`, backoff retry. + +### 6.2 Queue & storage (`src/main/models/downloads.ts`) + +FIFO, concurrency 1, states `queued → downloading → verifying → converting? → done|error|canceled`, persisted to `/download-queue.json`. **Pause is cooperative** (verifier fix — Windows has no SIGTERM): main writes a control line on the child's stdin; downloader finishes the current chunk and exits 0; hard kill (`taskkill /T /F` on win32, SIGKILL elsewhere) is reserved for **Cancel** (+ delete `.incomplete` + partial dest; sha256 catches any torn tail on resume anyway). Disk preflight: free ≥ `bytesTotal × 1.1 + 10 GiB` (+ `sourceBytes` for convert variants). Storage layout under `settings.modelsDir` (default: macOS/Linux `userData/models`, **Windows `%LOCALAPPDATA%\Videoboom\models`**): `/hf` (`HF_HUB_CACHE`, `HF_HUB_DISABLE_SYMLINKS=1` on win32), `/store//`, `/tmp/`. Delete refuses while a stage uses the variant or a render runs; shared components refcounted against installed variants' `requires` ("umt5-xxl no longer needed — free 6.7 GB?"). + +### 6.3 UI (`renderer/screens/Models.tsx`, new 5th tab) + +`TABS` gains `{key:'models', label:'Models'}` (App.tsx:98-103); smoke-test tabs list updated (**src/main/index.ts:51**). Layout: **HardwareCard** (GPU/chip, VRAM, disk, tier badge from the shared TierId enum, Re-detect, Recalibrate) → **EtaPanel** (sticky; headline `≈ 48–70 min per 1 minute of video`, provenance chip `seed ±60% · benched ±35% · calibrated ±20%`, per-stage breakdown bars; recomputes live with 150 ms debounce) → **StageSection ×7** with VariantRows: radio selection (writes `StageSelection`), quant chip, size, license chip (+ "gated" lock icon), fit badge (green/amber/red from `min`/`recommended` vs profile), per-row ETA delta ("−22 min/min vs current"), state (Installed ✓ size · Verify · Delete / Download / progress+pause+cancel / Queued) → **video settings strip** (res/quality/clip length, tier-gated) → **DiskUsageCard** (per-location stacked bar, relocator, unused-item GC list). Data flow: TanStack Query `['catalog'] ['modelStatus'] ['hardware'] ['estimate', selectionsDraft]`. + +IPC (each added in `src/main/index.ts`, `src/preload/index.ts`, `renderer/vb.d.ts`): `models:catalog`, `models:status`, `models:download/pause/cancel` (progress on `download:`), `models:delete`, `models:diskUsage`, `models:estimate` (pure sync, < 1 ms), `models:benchmark` (submits the sidecar bench job), plus `hw:profile`/`hw:redetect` per §3.1(3). + +--- + +## 7. Real-time ETA estimator + +### 7.1 Model (corrected — three-coefficient + per-clip fixed cost, token-keyed) + +The draft's single `^1.10` power law systematically underestimates Wan-14B by ~35% when extrapolating a 17-frame bench to 81 frames (attention is quadratic: per-step cost is `c + a·n + b·n²`, and at 832×480×81f n = 32,760 tokens where the n² term dominates). Corrected model, in `src/main/models/estimate.ts` (pure sync TS): + +``` +tokens(w,h,f) = (w/16)·(h/16)·(1+(f−1)/4) // 832×480×81f → 32,760 = the 14B's real seq_len + // (relay_generate.py:356-358). The 5B's true seq_len + // is n/4 (16× spatial VAE) — absorbed exactly by its + // per-variant coefficients; the key stays uniform +sStep(n) = c + a·n + b·n² // per device-class × variant; c,a,b ≥ 0. + // c is real on Apple: measured 14B per-step 29.4 s + // @n=6240 vs 46.8 s @n=15600 is strongly sublinear — + // an origin fit forces b<0 and predicts NEGATIVE + // s/step at 81f (−4.8 s) and non-monotone ETAs +clips(scene) = ceil(sceneFrames_preRIFE / framesPerClip) +tClip(n,f) = tFixClip + steps·sStep(n) + tVae(f,px) + tRifeClip +tVideo = Σ_scenes Σ_subclips tClip // sub-clip list from the shared chainPlan() + (extracted from pipeline.ts:402-416): nSub = ceil(wdur/nativeSec); last sub-clip = + snap4n+1(min(nativeSec, remaining+0.35)·fps), floor 21f — NOT framesPerClip for all +tFixClip = per-clip load + T5 encode + image encode + mux. Backend-dependent: MLX reloads + weights EVERY clip (wan_i2v.py:16-19,147-151 — resident is a ≥64 GB opt-in, measured + net-negative on 48 GB); the relay path additionally reloads both experts per clip. + Measured on M5 Pro 48GB: 44–75 s/clip (Q4 37f: 293−187−62 and 265−187−2.7). + CUDA: ComfyUI caches models → amortized tLoadOnce + per-clip encodes only +tKeyframes = kfPerMin · steps·sImg // kfPerMin derived from + VB_LOCAL_SCENE_SEC (~6 s → ~10 scenes/min, pipeline.ts:263) × CUT ratio → 2–10 + (floor = scenesPerMin/(VB_LOCAL_CHAIN_MAX+1), cap default 4, pipeline.ts:483,489; + keyframes are built for CUT scenes only) — NOT the draft's hardcoded 18 +tPost = framesTimeline·esrganPf + framesGen·2·rifePf + 15 // esrgan runs on the assembled + 24fps timeline; RIFE runs per sub-clip on 2× the GENERATED (pre-trim) frames. + Measured M5 Pro: esrganPf ≈ 0.25 s/f, RIFE ≈ 4 s per 37f clip, grade ≈ 2 s +tFixed = sttRealtime·60 + 8·tLlmCall + tVlm +ETAperMin = tFixed + tKeyframes + tVideo + tPost +``` + +Weight loading is **per clip on MLX** (upstream `generate_video` reloads T5 + transformers + VAE every call — docs/LOCAL-MODELS.md:61-63; residency is a ≥64 GB opt-in measured net-negative on 48 GB) and **once per render on CUDA** (ComfyUI caches between prompts). `tFixClip` carries this per backend; no term may assume a resident Wan on Mac. Correction to the draft headline: 480p→720p at 81f multiplies the video term **×3.5–4** (not ×2.6), consistent with community Wan reports. + +**FastWan FAST-mode deltas (2026-07-05).** The token model formula is unchanged; the FAST path swaps to the FastWan variant coefficients (`fastwan-5b-mlx-dmd` on Apple / `fastwan-5b-cuda-bf16` on CUDA — the seed row is device-class-keyed, so the device class selects the backend/variant) with three per-mode changes: **`steps = 3`** (DMD sigmas `[1.0,0.757,0.522,0.0]`, guide=1 — no CFG doubling), **`framesPerClip = 121`** (raised from 57; VAE peak only 25.6 GB @480p) so `clips(scene)` yields ~3× fewer sub-clips per scene and `tFixClip` amortizes over a 5.04 s clip instead of a 2.3 s one, and **`tRifeClip = 0`** (FastWan is native 24 fps — the 16→24 RIFE stage is dropped). The token *key* stays uniform (5B seq_len = n/4 via the 16× spatial VAE, absorbed by the variant's own (c,a,b)); only the coefficients, step count, per-clip frame count, and the dropped RIFE term differ from the 14B QUALITY path. FastWan (c,a,b) ship as **`extrap`** confidence until the §7.2 bench adds an on-device FastWan point (its only measured datum today is 264 s/clip @832×480/121f, git e095e90). + +### 7.2 Three layers, one number + +`ETA = seeds ⊕ bench ⊕ actuals`, precedence actuals > bench > seeds; the lowest precedence used anywhere sets the band (±20% / ±35% / ±60%). **The ±20% "calibrated" badge is shown only for configs whose token count is within ~1.3× of a benched point.** Predictions above 1.3× the largest benched token count (or below 0.7× the smallest) display one band level wider; with c,a,b ≥ 0 `sStep` is monotone in n by construction, so a bigger config can never show a smaller ETA than a benched smaller one. + +1. **Seeds** — `resources/perf-seeds.json`: per device-class × variant, `(c, a, b)` coefficients **keyed by tokens** (not "480p") plus a per-backend `tFixClip`, so one entry serves MLX 37f and CUDA 81f tiers correctly; plus `sImg`, `tVae`-per-Mpx-frame, `loadGBps`, `esrganPf`, `rifePf`, `llmTokS`. Confidence tags `reported|measured|extrap` (the FastWan FAST variant — `fastwan-5b-mlx-dmd` (mlx) / `fastwan-5b-cuda-bf16` (cuda), selected by device class — adds a `steps:3`, `rifePf:0` entry; its (c,a,b) ship `extrap` until the M5 FastWan bench, §12). +2. **Bench** — sidecar job (§3.1(4)), suite: `tLoad` (→ `loadGBps`); denoise at **three token counts** — 3 steps @ 832×480×17f, 2 steps @ a mid count (33f), 2 steps @ the tier's production frame count — using the per-step `denoise:[…]` arrays from §3.3 and **discarding the first step at every shape** (MLX compile is shape-keyed; each new shape re-warms), **fit `(c, a, b)` by non-negative least squares with `b` clamped ≥ 0** (on Apple the fit typically lands b≈0: measured per-step scaling is sublinear, and a 2-point origin fit on real M5 Pro data yields b<0 → negative predictions at 81f); the production point is also stored raw and **an exact-token-match prediction always uses the raw benched point, never the curve** (adds ~60–90 s; total budget ~3–4 min mid-GPU); `tFixClip` = mean(run wall − steps·sStep − tVae) over the suite; `tVae`; `sImg` (1 step @ 768²); 8-frame esrgan/rife probes (subprocess-isolated on macOS); `llmTokS` (64 tokens). **When the FAST variant is FastWan-5B**, the suite adds a **FastWan point** — 3-step DMD at 832×480×121f (guide=1, renoise) — so the FAST path gets its own fitted (c,a) + RIFE-free `tFixClip` instead of an extrapolated 14B curve, and the fast row of `perf-seeds.json` flips `extrap`→`measured`. Registers in `RUNS` so `guardRender` blocks renders during bench and vice-versa. +3. **Actuals** — every real clip's `timings` flow from `localVideo.ts` to `recordActual(configKey, timings)`: EMA `sStep ← 0.3·obs + 0.7·prev`, `n` bumped; live in-render re-projection `done·observed + remaining·predicted` on the existing `sidecar:render:` events. + +**Unknown-GPU fallback** (verifier fix — a name table goes stale): TFLOPS is **computed**, not looked up — NVIDIA: SM count × boost clock (both from nvidia-smi/pynvml) × per-compute-cap FLOPs/SM constant (~10-row table keyed by compute cap, never stale); Apple: chip family × `gpuCores`. `(c,a,b)` scale from the nearest seeded class by TFLOPS ratio. Guarantee: **every supported device shows a ±60% seeded estimate before any download**; EtaPanel/wizard copy for the state: "First estimate — calibrate after download for a tighter number." + +Quant scaling for unbenched variants: `sStep(variant) = sStep(benched) × Q`, Q = {fp8 1.0, bf16 1.45, gguf q4/q8 1.2 on Ada/Blackwell; fp8→1.45 on Ampere/Apple}. Seeded, corrected on first real run. + +Seeded headline numbers (per 1 min of final **1080p** video — native generation at tier res then upscaled, §4.2; est. until calibrated). **Two modes, both 1080p final:** + +- **QUALITY mode — Wan-14B bf16-relay** (native 832×480 → 1080p): RTX 5090 ≈ 25–40 min, 4090 ≈ 35–55 min, 4070 ≈ 1.7–2.5 h, 3060-12 ≈ 3–5 h, **M4 Max/M5 Pro ≈ 1.2–1.4 h (MEASURED 2026-07-05 on this M5 Pro 48 GB: bf16-relay production /i2v = 130 s/clip @832×480/37f Lightning tiny-VAE, peak 32.6 GB; ~1.9× faster than the old Q4-based 2.23 h/min row because the default is now bf16-relay, not Q4 — see §15/§7 validation record)**, M1 Max ≈ 2.2–3 h. (NVIDIA rows still `extrap`.) +- **FAST mode — FastWan-5B DMD 3-step** (native 832×480, 121f/24 fps, no RIFE → 1080p): **≈ 2.2× faster on the video-generation term** (measured on-device, §15: 264 s / 5.04 s = 52.4 s per finished s vs the old 14B-Lightning 265 s / 2.3 s = 115.2 s), which nets **≈ 1.9× end-to-end** after the fixed STT/LLM/keyframe/upscale overhead (which does not shrink) → **M5 Pro / M4 Max ≈ 0.8–1.0 h (MEASURED 2026-07-05: 166 s/clip @121f, but VAE-decode-bound — 134 s of it is VAE; a 5B tiny-VAE would drop FAST to ~0.3–0.4 h, see §7 validation record)**, M1 Max ≈ 1.5–2 h (Apple non-M5 rows `extrap` off the single measured M5 Pro point). **NVIDIA FastWan rows are `extrap`-pending the M5 FastWan bench** (§7.2/§12) — the 5B single-DiT + 3-step + native 24 fps (no RIFE) should run ~1.5–2× faster than the 14B QUALITY row → 5090 ≈ 15–25 min, 4090 ≈ 20–35 min, 4070 ≈ 1.1–1.7 h; **N8/N12 fall back** (no FastWan GGUF) to the stock TI2V-5B GGUF, so 3060-12's FAST figure ≈ its 5B-GGUF number. **All FastWan seed coefficients carry `extrap` confidence until the M5 bench includes a FastWan point.** + +**Validation record (2026-07-04).** The model above was audited against this repo's measured benches (git 276675c, ee2b573, cd5ebfd, 92ddef8; B1/B2 bench notes 2026-07-02): the draft two-coefficient origin fit reproduced the measured 14B per-step pair (29.4 s @n=6240, 46.8 s @n=15600) only with b<0, predicting −4.8 s/step at 81f, and omitting the MLX per-clip reload under-predicted the measured 265 s/clip (Q4 37f Lightning tiny-VAE) by ~28%. The corrected model reproduces the 7-render overnight baseline (~78 min per 35 s video) within ±10% bottom-up. + +**M48 seed spike — RESOLVED 2026-07-05** (production /i2v path, relay + tiny-VAE, fresh process per config, this M5 Pro 48 GB; parses relay_generate phase brackets). Measured, all 832×480, Lightning 4-step (14B) / DMD 3-step (FastWan): + +| variant | frames | total | denoise (per step) | image-enc | VAE | peak GB | tokens | +|---|---|---|---|---|---|---|---| +| bf16-relay (QUALITY) | 17f | 59.8 s | 47.3 s (11.8) | 5.6 s | 0.5 s | 30.8 | 7,800 | +| bf16-relay (QUALITY) | 37f | 130.0 s | 110.0 s (27.5) | 11.9 s | 0.6 s | 32.6 | 15,600 | +| A14B-Q4 (control) | 37f | 247.3 s | 190.5 s (47.6) | 11.8 s | 3.3 s | **67.7** | 15,600 | +| FastWan-5B (FAST) | 121f | 165.7 s | 21.9 s (7.3) | 0.4 s | **134.3 s** | 54.8 | 48,360 | + +Seed fit (bf16-relay QUALITY, M48): per-step denoise is slightly super-linear across the two shapes (11.8 s @7.8k → 27.5 s @15.6k = 2.33× for 2× tokens), so a 2-point `c+a·n` origin fit yields c<0 — as anticipated; store the two raw points and use them directly for exact-token matches, fit `b≥0` once a 3rd shape (25f) lands. **tFixClip is NOT constant** — image-encoding scales with frames (5.6 s @17f → 11.9 s @37f), so `tFix = T5(3.7) + imgEnc(≈0.20 s/frame) + VAE(≈0.6 s, tiny-VAE) + relay-load(≈5.8 s)`. QUALITY M5 Pro headline recomputes to **~1.2–1.4 h/min** (≈26 × 130 s video-gen + fixed STT/LLM/keyframe/1080p-upscale overhead), replacing the Q4-based 2.2–2.5 h. FastWan FAST: 12 × 166 s → **~0.8 h/min** (its only prior datum was 264 s/clip, git e095e90; this run is faster at 121f). + +**Two findings from the spike (feed back into the tier matrix / model list):** +1. **A14B-Q4-MLX is worse than bf16-relay on this Mac, not lighter.** Q4 does not trigger relay-shedding (`_wants_relay` is bf16-only), so both experts stay resident → **67.7 GB peak** (swaps hard on 48 GB) and denoise is 47.6 s/step vs the bf16-relay 27.5 s/step. Action: on Apple, either route A14B-Q4 through relay too, or drop it as a "lighter" QUALITY option — bf16-relay is strictly better ≥48 GB. Revisit the §4.2 M32 Q4 row. +2. **FastWan is VAE-decode-bound: 134.3 s of its 165.7 s is the VAE decode** (denoise is only 21.9 s). tiny-VAE currently patches only the 14B's 16-ch VAE, not the 5B. A tiny-VAE (or faster decoder) for the TI2V-5B VAE would cut FastWan ~3–4× (→ ~0.3–0.4 h/min FAST). Add as an M2/M5 optimization (`local/tiny_vae.py` 5B path); until then the FAST headline is VAE-bound. + +--- + +## 8. Cloud removal plan (M1, ordered commits) + +Invariant per commit: `npm test` green, selftest passes, app boots. This is draft D amended per verifier findings. + +- **C1 — `refactor: remove BYOK cost tracking`.** Delete `src/engine/cost.ts`; strip `costAdd/orCost` sites in `providers.ts` (L75, 104, 250, 321, **351**, 383, 475), `costTotal/costCents` from `pipeline.ts`, `index.ts`, `vb.d.ts`, `App.tsx`, AGENTS.md. Verify: grep zero; done-event closes run UI. +- **C2 — `refactor(engine): video is always local; delete LTX`.** Per §3.1(7): unconditional local branch at pipeline.ts:259/446/461-465/567/809, delete `genVideo` arm + `videoModel` plumbing, **delete the LTX engine and the k+1 keyframe pass together** (no dangling morph exception), delete `fitToWindow` from `ffmpeg.ts` **and add a legacy-clip conform in `assemble`** (verifier fix: archived cloud clips are stored at Kling-native resolution, e.g. 1280×720, while local clips are 832×480 — `fitToWindow` never scaled; concat with mixed dimensions corrupts output. New: per-clip `scale/pad to vW()×vH()` normalization before concat in `pipeline.ts:799-802`, so resume/re-render on old cloud projects works as documented). Verify: selftest (drop fitToWindow case, add mixed-dimension conform case); short full render, duration == audio window. +- **C3 — `refactor(engine): providers.ts → stages.ts, local-only`.** As draft D, with two amendments: (1) **typed STT result** — `transcribeWords` returns `{ok:true, words}` (empty = legitimate instrumental → pipeline proceeds in instrumental mode, preserving `pipeline.ts:249` ad flow and SPARSE tagging) vs `{ok:false, error}` (transcription failure → surfaced error with retry); music-video mode adds a soft confirm "No lyrics detected — continue as instrumental?" — **not** a blanket hard error; (2) **`src/engine/localKeyframe.ts:10-12`'s NOSIGN/TOON constants become canonical** — stages.ts keeps only what pipeline.ts imports (MOTION, schemas, storyBible, isContentBlock, TOON_STYLE only if the video-prompt path at pipeline.ts:440-443 is retargeted to it); the providers copies die with the cloud arm (no divergent policy strings, per AGENTS.md no-duplication rule). +- **C4 — `refactor(main): remove keychain, keys IPC, key UI`.** As draft D (delete `keychain.ts`, `keys:*` IPC, key UI, one-time `keys.json` rmSync; smoke test re-pointed at `localCapabilities()`). Note: safeStorage returns in M4 for the optional HF token only. +- **C5 — `refactor(settings): local-only schema v2`.** As draft D, amended: `settingsVersion:2` keeps `sttLang, workers, localVideoModel('14b'|'5b'), localQuality, localWanDir` **transitionally** (v3 in M5 consumes exactly this shape; a stored v1 `localVideoModel:'ltx'` is coerced to `'14b'` — the value dies with C2); unsupported-machine copy uses the centralized final-spec string (§4.2), not "32 GB+ or an NVIDIA GPU"; **`local/download.py` gains a VIDEO stage in this same commit** (small: reuse the snapshot path for `Wan-AI` repos + convert call) so the "every required stage" render gate is satisfiable in-app between C5 and M4 — the gate itself checks only stages `download.py` can provision until the catalog lands, and required-vs-optional becomes catalog data in M4. +- **C6 — `docs + fonts: local-only story`.** As draft D (delete `docs/PROVIDERS.md`, add `docs/MODELS.md`, rewrite README/AGENTS/ARCHITECTURE/site) **plus bundle Inter woff2 under `renderer/fonts/` with `@font-face`, delete the `fonts.googleapis.com` @import (`renderer/index.css:1`) and strip Google hosts from the CSP (`renderer/index.html:8`)** — prerequisite for the tripwire. +- **C7 — `feat(security): network lockdown + CI tripwire` — DEFERRED to M6.** Lands only after: umt5 tokenizer vendored in Wan artifacts (M2), downloader.py + bootstrap.ts exist (M3–M4), fonts bundled (C6). Session firewall allows `file:/devtools:/chrome-extension:` + dev `http|ws://localhost:5273`; sidecar inference env gets the offline vars; `scripts/check-no-cloud.sh` keyed to the §3.4 path allowlist. Verification includes a **fresh-user-profile render with empty HF cache and Wi-Fi off**. + +Settings/projects migration: keys were safeStorage blobs in `userData/keys.json` → deleted at boot (C4). `settings.json`: v1→v2 whitelist-copy in C5; v2→v3 in M5. Projects: schemaless; `createProject` stops writing `videoModel`; old cloud projects re-render locally, mixed timelines conformed per C2. + +--- + +## 9. Cross-platform packaging + +- **electron-builder targets**: macOS `dmg` + `zip` (arm64 only, hardened runtime, notarized — existing pipeline); Windows `nsis` x64 only (`portable` dropped — OQ8 resolved, §13.8); Linux `AppImage` + `deb` x64. +- **`extraResources` matrix** (all platforms unless noted): `local/` Python tree (serve/, backends/, workflows/, requirements/), `resources/model-catalog.json`, `resources/perf-seeds.json`, `uv` binary (per-OS, ~35 MB), `ffmpeg`/`ffprobe` static (per-OS), `renderer/fonts/`. Nothing model-sized ships in the installer; installers stay < 400 MB. +- **Path resolution**: all `local/` access via `process.resourcesPath` (packaged) / repo path (dev) through one helper; venv + runtime + models under `userData`/`%LOCALAPPDATA%` (probe #6). `venvPython(dir)` shared helper: `Scripts\python.exe` on win32, `bin/python` elsewhere (probe #1/#22). +- **Windows signing — SignPath Foundation free OSS route (decided 2026-07-04)**. Ruled out on live facts: Azure Trusted Signing (now **Azure Artifact Signing**) public-trust admits individual developers from **USA/Canada only** (orgs need a legal entity, USA/Canada/EU/UK) — an EU single dev with no company is ineligible; and **EV is dead folklore** — Microsoft removed the EV SmartScreen bypass (EV OIDs dropped from the Trusted Root Program Aug 2024; the May-2026 SmartScreen doc says paying for EV to avoid warnings 'is no longer justified'). Route: **SignPath Foundation** OSS certificate — OV-level, key on their HSM, publisher name reads **"SignPath Foundation"** (accepted tradeoff: Certum would print the maintainer's legal name; Azure is unavailable), **$0**. Eligibility verified: repo public, Apache-2.0, no dual licensing, v0.1.0 released. Setup: (1) GitHub 2FA on all maintainer accounts; (2) code-signing policy page on the site naming SignPath Foundation as Windows publisher (pattern: super-productivity.com/code-signing); (3) apply at signpath.org/apply (**at M0 — approval is discretionary and the longest external lead time**); (4) SignPath project: artifact config A = ZIP of `win-unpacked` with deep-signed PE files, config B = NSIS installer PE, signing policy `release-signing`, manual approval per release; (5) `release.yml` two-stage (SignPath cannot descend into NSIS — supported containers are MSI/CAB/APPX/MSIX/OPC/NuGet/JAR/ZIP only): build unsigned → zip `win-unpacked` → `signpath/github-action-submit-signing-request` (secret `SIGNPATH_API_TOKEN`; vars org-id, project-slug, policy-slug) → `electron-builder --prepackaged` rebuilds the NSIS from the signed tree → second signing request for the installer exe → publish; stop publishing Windows `latest*.yml`/`*.blockmap` (post-signing sha512 mismatch; auto-update is a non-goal anyway). **SmartScreen expectation (honest)**: reputation = publisher-cert + file-hash signals; nothing grants an instant pass anymore (not even Artifact Signing). The Foundation cert signs many OSS apps and ships warning-free in practice (Super Productivity — same Electron/electron-builder/GH-Actions shape), but Microsoft guarantees nothing. **Runtime-downloaded binaries** (uv-fetched python-build-standalone, llama-server zips, ComfyUI tarballs) are not Authenticode-signed and are not ours to sign — acceptable: SmartScreen evaluates MotW-marked shell-launched files, and `bootstrap.ts` children are CreateProcess'd without MotW; Defender content-scans them regardless, and python-build-standalone hashes are globally warm (uv's default distribution). sha256-manifest verification stays for all of them. Two hard additions: (1) **Smart App Control preflight** — SAC-enabled Win 11 blocks unsigned executables regardless of MotW; bootstrap reads `HKLM\SYSTEM\CurrentControlSet\Control\CI\Policy\VerifiedAndReputablePolicyState` and shows a dedicated explainer before downloading anything if SAC is enforcing; (2) **false-positive runbook** — signed Electron apps still trip Defender heuristics (LM Studio, Mar 2026, Trojan:JS/GlassWorm.ZZ!MTB while signed): any flagged release goes to Microsoft Security Intelligence file submission same-day. Last-resort support answer unchanged: documented Defender exclusion for `%LOCALAPPDATA%\Videoboom`. **Fallbacks recorded**: Certum Open Source card cert (~€69 first set + ~€29–30/yr, subject "Open Source Developer, ", smartcard on the dev box — no CI signing) if SignPath rejects; Azure Artifact Signing Basic ($9.99/mo, 5,000 sigs, needs electron-builder ≥ 26 `azureSignOptions` — we pin ^25.1.0) only if a legal EU entity is ever formed. +- **Linux**: `.desktop` file + icon in the deb; deb adds `Recommends: libvulkan1, libgomp1` (llama-server Vulkan runtime deps, preflighted by bootstrap); AppImage docs note libfuse2 + libvulkan1; no signing. +- **CI packaging job** (new, per release + nightly): build the packaged artifact on `macos-14-arm64`, `windows-2022`, `ubuntu-22.04`; install it (mount dmg / silent NSIS / run AppImage); launch with `--backend mock` and run `engine/selftest.ts` + the contract suite **from the installed location** — this is the regression net for probe #6-class bugs. GPU-required nightly (self-hosted mac-arm64 + NVIDIA Linux): bench suite, 1 keyframe, one TI2V-5B 9-frame clip, **one FastWan-5B 3-step FAST-path clip (fast-mode regression + seed check)**, RIFE 2×, upscale 24 frames; assert outputs exist and run the nightly estimator drift alarm — single implementation spec'd in §12.1: every bench metric within 3× of its seed, AND the run's actual clip timings fed back through `estimate.predictClip(sameConfig)` land inside the active provenance band (±35% benched), predicted vs actual printed in the job summary (visible trend). + +--- + +## 10. Known problems in current local flow (probe register) + +Fix-milestone key: M1 cloud removal · M2 sidecar v2 · M3 bootstrap/CUDA · M4 catalog/downloads · M5 tiers/ETA · M6 lockdown/packaging. + +| # | Sev | Problem (file) | Fixed in | +|---|---|---|---| +| 1 | blocker | POSIX venv path (`sidecar.ts:22`, `localModels.ts:21`) | M2 `venvPython()` | +| 2 | blocker | POSIX-only `_run_isolated` (`server.py:74-81`) | M2 win32 branch | +| 3 | blocker | bash-only `setup.sh` + error strings | M3 bootstrap.ts | +| 4 | blocker | MLX-only sidecar, no backend seam | M2 serve/backends split | +| 5 | blocker | network fetch mid-generation (umt5 `relay_generate.py:372`, et al.) | M2 vendored tokenizer + `HF_HUB_OFFLINE`; enforced M6 | +| 6 | blocker | `cwd`-relative `local/` — dead when packaged (`sidecar.ts:16`) | M2 resourcesPath; verified M6 CI | +| 7 | major | unpinned `mlx-video` git dep vs monkeypatches | M0 commit pin `87db56a5`; M2 vendored subtree (monkeypatches become direct edits) | +| 8 | major | no server-side cancellation (`server.py:145`) | M2 jobs API | +| 9 | major | no sidecar lifecycle ownership / stale reuse | M2 handshake+version+before-quit+idle evict | +| 10 | major | missing `server.on('error')` → main crash | M2 | +| 11 | major | fixed port 8765, no auth, ping trusts anyone | M2 ephemeral port + token | +| 12 | major | no OOM classify/recover | M2 typed codes + M5 ladder | +| 13 | major | partial downloads read as ready (`localModels.ts:29-36`) | M4 manifest verification | +| 14 | major | zero disk preflight; PNG-dir blowup (`upscale.py:78`) | M2 stream-and-delete + M4 preflight | +| 15 | major | residency holes (whisper cache; `unload_all` skips hooks) | M2 ResidencyRegistry | +| 16 | major | swallowed errors; silent Kontext→txt2img downgrade | M1 typed STT; M2 typed errors; §5 identity policy | +| 17 | major | hidden system-ffmpeg deps (whisper/interp/upscale) | M2 PATH prepend + loud failure | +| 18 | major | finished result lost after client timeout | M2 persisted job results | +| 19 | major | venv staleness across updates | M3 venv-manifest stamp + repair | +| 20 | minor | Python 3.11/3.12 enumeration | M3 uv-managed 3.12 | +| 21 | minor | conversion-complete sentinel fragile | M4 `.vb-manifest.json` | +| 22 | minor | STAGE_REPOS + localDir duplication | M4 one catalog; M2 shared helpers | +| 23 | minor | unauthenticated localhost server | M2 token | +| 24 | minor | unbounded request queueing | M2 queue cap + QUEUE_FULL | +| 25 | minor | cancel leaves invisible `.incomplete` GBs | M4 cancel cleanup + DiskUsageCard | +| 26 | minor | work dirs next to source video | M2 managed `userData/tmp` + startup GC (covers interp AND upscale) | +| 27 | minor | marker files, CRLF risk | M4 markers retired for manifests | +| 28 | minor | `renameSync` EXDEV (`localVideo.ts:170`) | M2 copy+unlink helper | +| 29 | minor | ANSI/emoji logs on Windows | M2 TTY-gated logging | +| 30 | minor | static UI-only RAM gate | M2 dispatch-time check + M5 tiers | + +--- + +## 11. Risk register (resolved 2026-07-04 — every row carries a final status; evidence in §15) + +| Risk | Sev | Status | Resolution / verified mitigation | +|---|---|---|---| +| ComfyUI (GPL-3) posture or breaking upstream changes | low (was high) | **RESOLVED** | License: GPL-3.0 unchanged since the 2023-01-03 initial commit; repo now `Comfy-Org/ComfyUI`; both custom nodes Apache-2.0. Posture: zero GPL code in installer (§9); bootstrap downloads verbatim unmodified source tarballs (GPL-3 §4 redistribution — notices intact, mirror README states upstream+SHA); ComfyUI runs as a separate OS process driven only over localhost HTTP/WS (FSF mere-aggregation boundary; precedent: Pinokio, MIT, launches ComfyUI identically). Full statement in `docs/LICENSES.md` (§16 appendix, lands M3). Drift: exact pins — ComfyUI **v0.27.0 `bb131be9…`**, GGUF **`6ea2651e`**, Wrapper **`088128b2`**; routes /prompt /interrupt /free /history /ws verified unchanged v0.3.34→v0.27.0 (12+ months); upgrades only via manifest-bump PR gated on the contract suite + re-mirror. Fallback verified real: diffusers ≥ 0.39 Wan 2.2 two-expert i2v + LoRA, ungated official weights. Residual: ~weekly upstream cadence → pin bumps are routine maintenance | +| HF repos gated/DMCA'd under pinned revisions (esp. third-party quants) | med (was high) | **MITIGATED** (resolved-by-audit) | All 26 catalog repos live-verified 2026-07-04: public + ungated (sole exception Kontext-dev, opt-in by design) with permissive licenses; sizes corrected to repo-listed bytes; revision SHAs pinned (§5.2 footnote); mirror legality confirmed (Apache-2.0/MIT critical path; gemma pin-only; Kontext never); `videoboom` namespace free → registration pending §14 D1. Residual (future takedown of a pinned rev): pinned revisions + sha256, typed `GATED` error, mirror-first host order in the downloader, weekly CI catalog-liveness job (HEAD every pinned file, alarms on 4xx) — gate check + liveness job write `resources/catalog-audit.json` (§5.2/§12.1 shape), consumed by `check-catalog-invariants` | +| ETA credibility (first number wrong → trust gone) | high until spike closes | **SPIKE PENDING** (formula RESOLVED) | Formula corrected 2026-07-04 by evidence audit (§7 validation record): `tFixClip + steps·(c+a·n+b·n²) + tVae + tRife` with c,a,b ≥ 0, three-point NNLS bench, exact-benched-point precedence, extrapolation band-widening — the draft two-point origin fit produced b<0 on real measured data (negative s/step at 81f) and the missing MLX per-clip reload under-predicted the flagship M48 config by ~28%; corrected model reproduces the 7-render overnight baseline within ±10%. Open: ~25-min bf16-relay bench spike (§7.2) re-derives the M48/M64 seed rows before `perf-seeds.json` ships. Plus ±-band honesty rules, computed-TFLOPS fallback, nightly drift alarm (§12.1) | +| Windows Defender/SmartScreen kills bootstrap | med (was high) | **MITIGATED** (route decided, §9) | SignPath Foundation OSS signing chosen 2026-07-04 ($0, publisher "SignPath Foundation", shared-reputation OV cert; eligibility verified; apply at M0), two-stage deep-sign in release.yml; Azure/EV ruled out on live facts (EU individuals ineligible; EV SmartScreen bypass removed by Microsoft); spawned runtime binaries carry no MotW + stay sha256-manifested; Smart App Control preflight; same-day Security Intelligence FP runbook; portable target dropped (§13.8); Defender exclusion doc = last resort. Residual: SignPath acceptance is discretionary → fallback Certum OSS card cert (~€69 + ~€29/yr) keeps GA unblocked | +| M16 (16 GB Mac) never validates | med | **SPIKE PENDING** (two-phase gate, §12 M5) | Fit confirmed by memory math 2026-07-04 — worst stage ≈ 7.5–8 GB vs the 10,922 MB 16-GB Metal budget (T5-Q8 encode 6.3 · denoise 7.5–8 · VAE decode 5–7); staging already exists upstream (generate.py:351/:709-718); precise prereqs = DiT Q8 (converter supports today) + two mlx-video fork patches (umt5-Q8 convert, no-fp32-upcast load — today T5 upcasts to ~22.7 GB, utils.py:65) + mflux `--low-ram` keyframes. Field: Draw Things runs Wan2.2-5B 6-bit on ≤8 GB iPhones. Phase A capped-Metal spike on the dev Mac (`sudo sysctl iogpu.wired_limit_mb=10922` + `mx.set_memory_limit`; pass = peak ≤ 10.0 GB, wall ≤ 2× uncapped); Phase B real-16 GB end-to-end render. Tier stays disabled until B passes; either phase fails → row deleted, floor 32 GB permanently | +| Linux llama-server distribution (was: CI-build maintenance sink) | low (was med) | **RESOLVED** | CI-build deleted 2026-07-04; official `ubuntu-vulkan-x64` release asset is primary (llama-server+libmtmd verified in b9873; tg −0–10% / pp −12–26% vs CUDA on RTX with driver ≥ 570; LLM stage is noise in §7 ETA). Residual Vulkan mmproj edge case → `--no-mmproj-offload`; escalation = CI-built CUDA binary, unscheduled | +| CUDA runtime footprint growth (was feared 9 GB Win / 11 GB Linux) | low (was med) | **RESOLVED** | Measured 2026-07-04 from the pinned wheels' central directories (torch 2.9.1+cu128 trio, §3.2): **Win ≈ 3.1 GB dl / 5.7 GB disk; Linux ≈ 4.4 GB dl / 8.3 GB disk** — under the feared figures. Hash-pinned locks freeze the footprint; `scripts/build-locks.py` emits `resources/runtime-sizes.json` (per-OS download+installed) on every lock regen and CI fails past a 10 GB installed budget; wizard + preflight consume the computed constants; co-located `UV_CACHE_DIR` hardlinks avoid install-time doubling. Residual watch item: cu128 wheels end at torch 2.11.0 (2.10/2.11 cu128 trios exist and are the in-place upgrade path); going past 2.11 is a CUDA-13/driver-≥580 decision, not silent growth | +| OOM diversity across drivers/tiers | med | **MITIGATED** (hardened spec, §4.4) | Every signature verified against upstream source 2026-07-04 (ComfyUI model_management.py:375/384-390 + execution.py:638-708/1269-1272; pytorch CUDACachingAllocator.cpp:1935; mlx allocator.cpp:114-157 + device.cpp:507-510; llama.cpp ggml-cuda.cu:789; NVIDIA KB a_id/5490; WanVideoWrapper nodes_model_loading.py:299; ComfyUI cli_args.py:147 + nodes.py:340). 10-signature table, structured `{code,phase,sig}` payload, phase-aware rung mapping; three ladder defects fixed (phase-blind rungs, MLX rung-1 no-op on M48/M64, wrapper rung RAM-peak increase); `--reserve-vram` correctly re-specified as restart-requiring; steps confirmed non-rung. Residual: WDDM heuristic thresholds are chosen constants — tuned in the existing M5 forced-OOM drill (now extended with a sysmem-fallback case) | +| mlx-video upstream drift vs relay fork | low (was med) | **RESOLVED** | CLOSED 2026-07-04: M0 pins `87db56a5` (== installed venv == upstream HEAD; upstream dormant since 2026-05-13, zero releases/tags ever, wan_2 layout already refactored once 2026-03-18); M2 vendors the subtree (wan_2/ 496 KB + lora/ 60 KB + ltx tiling.py, MIT) into `local/backends/mlx/vendor/`, merges relay_generate.py into it, deletes the 3 monkeypatches, drops the mlx-video dep. Upstream fixes = manual cherry-pick via VENDORED.md base commit (expected ~0: Wan fixes sit unmerged upstream, PRs #38/#29) | +| Estimator vs pipeline drift (scene math changes) | low | **RESOLVED** | Two-layer, 2026-07-04: (a) structural — shared `src/shared/renderPlan.ts` (chainPlan/planCuts) is the single implementation consumed by both pipeline.ts and estimate.ts; `tests/eta-parity.test.ts` in `npm test` (M5, per §12 M5 tasks) asserts property invariants, golden clip/keyframe counts, env-override parity, and the tokens(832,480,81)=32,760 pin; (b) CI — `scripts/check-estimator-invariants.ts` per §12.1 (M0 self-arming, enforcing M5) + the single nightly drift alarm in `nightly-gpu.yml` (3× seed sanity + predictClip-vs-actual provenance band, §9/§12.1) | +| Catalog ↔ tier drift | low | **RESOLVED** | Check spec'd normatively in §12.1 (`scripts/check-catalog-invariants.ts`, 8 assertions: tierDefault→platform-valid variant, 40-hex revision + sha256 + bytes per file, floor coverage per required stage, license enum + gated-never-default + `catalog-audit.json` consistency, `requires` integrity, TierId vocabulary, generated-not-hand-written bundle sizes, catalogVersion/minAppVersion sanity); lands M0 self-arming, enforcing from M4 | +| Old cloud projects on new app | low | **RESOLVED** | Cloud-era shape verified in repo 2026-07-04 (schemaless JSON, `pipeline.ts:901-921` fields; only cloud residue = inert `videoModel` string e.g. `'kwaivgi/kling-v3.0-std'` + Kling-native 1280×720 clips), fully covered by C2 conform + C5 migration; `legacy-cloud-conform` selftest + `settings-migration` unit test spec'd in §12.1; C5 amended to coerce v1 `localVideoModel:'ltx'`→`'14b'` | + +--- + +## 12. Phased roadmap + +### M0 — Reconciliation groundwork (≈ 3–4 days) +Tasks: create `src/shared/deviceProfile.ts`, `src/shared/tiers.ts` (TierId enum), `src/shared/netAllowlist.ts` (types + host list, not yet enforced); write the settings v1→v2→v3 lineage doc-comment in `src/main/settings.ts`; pin `local/requirements.txt` to `mlx-video @ git+https://github.com/Blaizzy/mlx-video.git@87db56a51758fefb748a359b90a5283bb8ba4837` (the commit already installed in `local/.venv` and current upstream HEAD — no behavior change); submit the SignPath Foundation application (§9 Windows signing — $0, discretionary approval, longest external lead time; prereqs: GitHub 2FA + code-signing policy page on the site); land the three CI guard scripts in self-arming mode per §12.1 — `scripts/check-catalog-invariants.ts`, `scripts/check-estimator-invariants.ts` (each exits 0 with `SKIP: not present` until its inputs exist), `scripts/check-no-cloud.sh` — wired into `.github/workflows/ci.yml` as a new ubuntu-only `invariants` job (`continue-on-error: true` until M6); pending the user's approval of §14 D1: register the `videoboom` HF org (user action, ~5 min); land this LOCAL_PLAN.md. +Acceptance: `npm test` green; types imported by nothing yet (no behavior change); `invariants` CI job runs all three scripts — catalog/estimator checks SKIP, check-no-cloud reports exactly today's known cloud-era hits (providers.ts, App.tsx, index.html/css, relay_generate.py, download.py) without failing CI. +Verify: tsc; review. + +### M1 — Cloud removal, C1–C6 (≈ 1.5 weeks) +Tasks: commits C1–C6 exactly per §8, touching `src/engine/{cost,providers→stages,pipeline,index,ffmpeg,config,localVideo}.ts`, `src/main/{keychain(del),index,settings}.ts`, `src/preload/index.ts`, `renderer/{vb.d.ts,App.tsx,index.css,index.html,fonts/}`, `local/{ltx_i2v.py(del),download.py(+VIDEO)}`, docs. +Acceptance: no cloud call sites in `src/`; keyless Create enabled; v1 settings migrate to v2; old cloud project resumes with conformed mixed clips; instrumental track renders (soft-confirm path). +Verify: per-commit matrix from §8; full short render; `--regen-story` render; portrait flow. + +### M2 — Sidecar v2 + MLX refactor (≈ 3 weeks) +Tasks: `local/serve/{server,jobs,errors,resources,bench}.py`; move MLX files to `local/backends/mlx/`; vendor the mlx-video Wan subtree at `87db56a5` into `local/backends/mlx/vendor/` (wan2/ ← `mlx_video/models/wan_2/`, lora/ ← `mlx_video/lora/`, tiling.py ← `mlx_video/models/ltx_2/video_vae/tiling.py`; copy upstream MIT LICENSE, write VENDORED.md with base commit + cherry-pick procedure; merge `relay_generate.py` into `vendor/wan2/generate.py`; delete the `wan_i2v.py` memoize/_keep_compiled and `tiny_vae.py` monkeypatches in favor of direct edits; drop `mlx-video` from requirements and add explicit `mlx`, `mlx-lm`, `mlx-vlm`, `transformers`, `safetensors`, `ftfy`, `imageio`, `imageio-ffmpeg`, `tqdm`, `numpy`, `Pillow` — final transitive set resolved by `scripts/build-locks.py` in M3); `local/backends/{common,mock}/`; jobs API + token + ephemeral port + handshake; cancellation in the vendored generate loop; typed error classification; ResidencyRegistry + idle evict; timings in all results; managed temp root + GC; EXDEV helper; TTY-gated logs; win32 `_run_isolated` branch; vendor umt5 tokenizer into Wan conversion output; TS side: `src/engine/sidecar.ts` (handshake, `on('error')`, token, resourcesPath, `venvPython()`), `localVideo.ts`/`pipeline.ts` job-API migration. +Acceptance: contract suite green against `mock` on 3 OS runners; kill-app-mid-clip → job result recovered on restart; `/health` version mismatch respawns; render works from a packaged dev build on macOS. +Verify: contract tests; manual 14B clip with mid-flight cancel; offline Wi-Fi render on warm cache. + +### M3 — Bootstrap + CUDA backend (≈ 4 weeks) +Tasks: `src/main/bootstrap.ts` (uv install, per-OS lockfiles, ComfyUI-trio tarballs, llama-server binaries, venv-manifest stamp/repair); `scripts/build-locks.py`; delete `local/setup.sh`; `local/backends/cuda/{comfy,llama,whisper_ct2,rife_torch,upscale_spandrel}.py`; `local/workflows/*.json` (both families); mirror pinned official llama-server artifacts (win-cuda-12.4 + cudart-12.4, ubuntu-vulkan-x64; sha256 from release-API digests) into `videoboom-assets` (copy job); add bootstrap `ldconfig -p` preflight for `libvulkan.so.1`/`libgomp.so.1`/`libcrypto.so.3`; publish pre-converted MLX Wan weights to `videoboom/` HF org; add `docs/LICENSES.md` (seed content in §16) and the mirror README (upstream URL + pinned SHA) in `videoboom-assets`. +Acceptance: fresh Windows and Linux machines reach a working sidecar from the installed app with only an NVIDIA driver present; a TI2V-5B clip renders on a 3060-12; block-swap 14B renders on N12; llama-server serves LLM+VLM on both OSes. +Verify: nightly GPU job; manual fresh-VM install on Win 11 + Ubuntu 22.04. + +### M4 — Catalog, downloader, Model Manager (≈ 3 weeks) +Tasks: `resources/model-catalog.json` (full §5.2 seed, license/gated/required fields); `src/main/models/{catalog,status,downloads,estimate(stub),hardware(normalizer)}.ts`; `local/serve/downloader.py` (cooperative pause, sha256, GATED, convert); delete `local/download.py` + markers; optional HF-token flow (safeStorage); `renderer/screens/Models.tsx` + tab; `models:*` IPC; refcounted delete + DiskUsageCard; CI catalog↔tier cross-validation + `scripts/catalog-sizes.ts`; weekly CI catalog-liveness workflow (HEAD every pinned catalog file, alarms on 4xx; writes `resources/catalog-audit.json` per §5.2/§12.1). +Acceptance: download → pause (cooperative, Windows) → resume → verify → delete round-trips for a GGUF and an hf-then-convert variant; partial download never reads installed; gated repo surfaces GATED with docs link; render gate driven by catalog `required`; `check-catalog-invariants` enforcing (SKIP guard removed for `model-catalog.json`) and green. +Verify: scripted downloader tests with a tiny HF repo; manual 17 GB Q4_K_S download on Windows with pause/resume. + +### M5 — Hardware/tiers/wizard/autoconfig + ETA (≈ 3 weeks) +Tasks: `src/main/hardware.ts` (probes, fingerprint, profile writer); `src/main/tiers.ts` (matrix + ladder-rung data per workflow family); `src/main/autoconfig.ts` (`resolveConfig→toEnv`, delete settings.ts if-chains); settings v3 migration; `renderer/screens/Onboarding.tsx` (7 steps, unconditional runtime install); `src/engine/autotune.ts` (ladder + learnedCaps); `hw:profile`/`hw:redetect` (+alias); `src/main/models/estimate.ts` full (tFixClip + steps·(c+a·n+b·n²), seeds, computed-TFLOPS fallback, recordActual EMA); `local/serve/bench.py` three-point NNLS suite (§7.2) **+ a FastWan-5B FAST-path point (DMD 3-step, 832×480×121f) so `perf-seeds.json`'s FAST rows flip `extrap`→`measured`**; `src/shared/renderPlan.ts` — pure `chainPlan(wdurSec,nativeFps,maxFrames)` and `planCuts(transitions,chainMax)` extracted from pipeline.ts:402-416 and :487-492, consumed by BOTH `renderLocalScene`/`renderScenesLocalChained` and `estimate.ts`; `tests/eta-parity.test.ts` (R10 parity, runs in `npm test`): (a) property test ~200 random (wdur,fps,maxFrames): chainPlan sub-clip seconds sum ≥ wdur, every frame count ≡ 1 mod 4 and ≥ 21, count == ceil(wdur/(maxFrames/fps)); (b) golden fixtures `tests/fixtures/eta-golden.json` (M48 default, N12 81f, M64 HD scenarios with unit coefficients): estimate's tVideo/kfCount must equal the hand-computed totals from chainPlan+planCuts; (c) with process.env.VB_LOCAL_SCENE_SEC and VB_LOCAL_CHAIN_MAX overridden, the estimator and the engine resolve identical values (both import the same exported readers — no literal copies); (d) regression pin tokens(832,480,81)===32760; `resources/perf-seeds.json`; EtaPanel live wiring; M16 two-phase validation gate — **Phase A (spike, runnable on the 48 GB dev M5 Pro)**: convert DiT Q8 (`python -m mlx_video.models.wan_2.convert --quantize --bits 8 --group-size 64`), spike-quantize umt5 to Q8 + bypass the fp32 upcast (utils.py:65), cap Metal to the 16 GB budget (`sudo sysctl iogpu.wired_limit_mb=10922`, restore `=0`; plus `mx.set_memory_limit(10922·2²⁰)` in-process), render one 640×384×49f×10-step clip + one mflux `--low-ram` keyframe; pass = completes, `mx.get_peak_memory()` ≤ 10.0 GB, wall ≤ 2× uncapped. **Phase B (gate)**: real 16 GB M-series, end-to-end 30 s render, no kernel kill, macOS responsive. Fail at either phase → delete the M16 row, floor 32 GB, drop `wan22-5b-mlx-q8` from the catalog. +Acceptance: fresh mac + fresh Windows onboard end-to-end (detect → install → download → calibrate → render) without the user naming a model; manual stage survives re-detect; OOM at 1280×704 on N16 recovers via native-family rung 1 without frame cuts; calibrated ETA within ±20% of a real render at a benched token count on 2 reference machines; unknown-GPU (name absent from seeds) still shows a ±60% estimate pre-download; `check-estimator-invariants` enforcing and green. +Verify: wizard walkthrough on 3 machines; forced-OOM test (VRAM ballast process) incl. a Windows WDDM sysmem-fallback case (ballast sized to spill, assert PERF_DEGRADED → rung 1 and sysmemFallback persisted) and a macOS kill-recovery case (kill -9 the sidecar mid-denoise, assert synthetic OOM from phase journal); ETA-vs-actual comparison logged in CI nightly. + +### M6 — Lockdown + packaging (≈ 2 weeks) +Tasks: D-C7 (session firewall incl. `ws://localhost:5273` dev clause, sidecar offline env, `scripts/check-no-cloud.sh` keyed to §3.4 paths, AGENTS.md rule); flip the CI `invariants` job blocking (drop `continue-on-error`); electron-builder configs per §9 (incl. dropping `portable` from `build.win.target` in package.json:72); SignPath two-stage signing wired into release.yml per §9 (+ SAC preflight in bootstrap.ts); CI packaged-artifact install+selftest job on 3 OSes; `docs/MODELS.md` license table. +Acceptance: **fresh user profile, empty HF cache, Wi-Fi off, models present → full render succeeds**; devtools Network tab empty at runtime; check-no-cloud green; packaged installers pass CI selftest on all 3 OSes; NSIS installer is SignPath-signed (valid Authenticode chain + timestamp, publisher "SignPath Foundation") and launches without SmartScreen block on a test VM; warn-free first-download is expected via the Foundation cert's shared reputation but is not a Microsoft guarantee — first-week SmartScreen/Defender reports are tracked against the §9 FP runbook. +Verify: §8 C7 matrix + §9 CI job. + +### 12.1 CI guard-script specs (normative; scripts land in M0, self-arming) + +All three run in one `invariants` job (ubuntu-latest) added to `.github/workflows/ci.yml` in M0 with `continue-on-error: true`; the job turns blocking per milestone as noted. TS scripts run via `tsx` (already a dev dep). Each script exits 0 with `SKIP: not present` while its input files don't exist, and hard-fails once they do. + +**`scripts/check-catalog-invariants.ts`** (R11; enforcing from M4). Loads `resources/model-catalog.json`, tier matrix data (`src/main/tiers.ts`), TierId enum (`src/shared/tiers.ts`), `resources/perf-seeds.json` and `resources/catalog-audit.json` (each optional-when-absent), `package.json`. Asserts: (1) every tier (≠ UNSUPPORTED) × required stage default resolves to an existing catalog variant whose `backend` matches the tier's platform (M*→mlx/both, N*→cuda/both); (2) every variant has `revision` matching `/^[0-9a-f]{40}$/` (pinned commit, never a branch), non-empty `files[]`, and every file `{path, bytes>0, sha256:/^[0-9a-f]{64}$/}`; (3) every `required:true` stage has ≥1 variant per platform fitting the floor tier (M32: 32 GB unified; N8: 8 GB VRAM / 32 GB RAM); (4) every variant has `license` ∈ the §5.1 enum and boolean `gated`; no `gated:true` variant is any tier default; when `resources/catalog-audit.json` is present (written by the catalog-build-time gate check + the weekly M4 catalog-liveness job, shape per §5.2: `{generatedAt, entries:{[variantId]:{gated, license, status:'ok'|'gone'|'gated-changed', checkedRevision}}}`), catalog `gated`/`license` must equal the audit's and every tier-default's `status` must be `ok`; (5) every `requires` id resolves, no cycles, shared-component backend compatible with all dependents; (6) every tier key used in the catalog ∈ TierId; variantIds unique and shaped `modelId@quant`; (7) bundle sizes are generated, never hand-written: recompute per-tier bundle bytes via the function `scripts/catalog-sizes.ts` exports and assert byte-equality with the committed `resources/tier-bundles.json` the wizard reads; (8) `catalogVersion` positive int; `minAppVersion` valid semver ≤ `package.json` version. + +**`scripts/check-estimator-invariants.ts`** (R10; enforcing from M5). Loads `estimate()`/`tokens()` from `src/main/models/estimate.ts`, `resolveConfig` from `src/main/autoconfig.ts`, `resources/perf-seeds.json`, tier matrix, `src/engine/segment.ts`/`config.ts` exports. Asserts: (1) token anchor `tokens(832,480,81) === 32760`; for f ∈ {17,37,49,61,81} tokens() frame handling equals the engine's 4n+1 frame-math helper; (2) constants are imports, not copies: estimate.ts re-exports the scene-sec/kf-ratio/FPS values it consumed and the check asserts `===` identity with the engine exports; regression grep bans a literal `18` kfPerMin in estimate.ts; (3) full-matrix sweep: for every tier × platform synthetic DeviceProfile plus one unknown-GPU profile (exercises the computed-TFLOPS fallback), `estimate(resolveConfig(profile, defaults))` is finite, > 0, band ∈ {20,35,60} — no throw/NaN; (4) every variantId referenced by `perf-seeds.json` exists in `model-catalog.json`; every seed entry carries `(c,a,b)` (or its scalar metric) + confidence ∈ {reported, measured, extrap}; (5) the FLOPs/SM-per-compute-cap table covers every compute cap `tiers.ts` admits and the Apple chip-family table covers every family it fingerprints; (6) band monotonicity per §7.2: seeds-only → ±60; +synthetic bench point → ±35; +actual → ±20 only when tokens are within 1.3× of a benched point. + +**Nightly estimator drift alarm** (the §11 ETA/drift rows' alarm — one implementation, here; the §9 nightly sentence references this spec): `.github/workflows/nightly-gpu.yml` (self-hosted mac-arm64 + NVIDIA Linux per §9): after the bench suite, (a) coarse seed sanity — for each measured metric (sStep at every benched token count (the three 14B points + the FastWan FAST point per §7.2), sImg, tVae, llmTokS, esrganPf, rifePf) assert `seed/3 ≤ measured ≤ 3·seed`; failure output names the seed key + machine fingerprint; (b) provenance-band check — the run's actual clip timings fed back through `estimate.predictClip(sameConfig)` must land inside the active provenance band (±35% benched); predicted vs actual printed in the job summary (visible trend). The M5 ETA-vs-actual comparison logs into the same job summary. + +**`scripts/check-no-cloud.sh`** (§3.4 tripwire; report-only from M0, blocking from M6/C7). Scopes to `git ls-files` output — never a directory walk: the gitignored `local/.venv` contains thousands of URL-bearing site-packages files that poison any `grep -r` (verified). File set: tracked `src/ renderer/ local/` files matching `\.(ts|tsx|py|css|html|json)$`, minus the path allowlist regex `^(src/main/bootstrap\.ts|src/main/models/catalog\.ts|local/serve/downloader\.py|src/shared/netAllowlist\.ts)$`. Failing patterns: (1) URL literals `grep -InE '(https?|wss?)://'` minus loopback `grep -vE '://(127\.0\.0\.1|localhost)([:/" ]|$)'`; (2) HF fetch APIs outside the downloader (catches repo-ID fetches with no URL, e.g. the old `relay_generate.py:372`): `grep -InE 'huggingface_hub|hf_hub_download|snapshot_download'` over non-allowlisted `.py`; (3) repo-ID `from_pretrained`: `grep -InE 'from_pretrained\(\s*["'][^/"']+/[^"']+["']'` (a string literal containing `/` is a hub ID; local paths arrive via variables); (4) provider identifiers, zero exceptions in any file: `grep -InE 'openrouter|replicate\.com|klingai|api\.openai|generativelanguage'`; (5) Google-Fonts regression guard over tracked `renderer/`: `grep -InE 'fonts\.(googleapis|gstatic)\.com'`; (6) offline-env presence: `grep -q 'HF_HUB_OFFLINE' src/engine/sidecar.ts`. Adding a legitimate network call site ⇒ the same PR must extend both `src/shared/netAllowlist.ts` and this script's allowlist regex (reviewed act). Verified baseline today: the only tracked-file hits are providers.ts (3), App.tsx (2), index.html (1), index.css (1), relay_generate.py + download.py (patterns 2/3) — each dies in C3/C4/C6/M2/M4, so the check goes green exactly when the roadmap says it must. + +**Legacy cloud-project migration tests** (R12): (a) unit `test/settings-migration.test.ts` (tsx/node:test): v1 settings fixture with cloud keys → v2 asserts whitelist copy, `settingsVersion:2`, and `localVideoModel:'ltx'` coerced to `'14b'`; (b) selftest case `legacy-cloud-conform` in the existing `VB_ENGINE_TEST=1` harness: build a fixture project at runtime under `VB_DATA_DIR` reproducing the verified cloud-era shape — `project.json` `{id, name, status:'done', format:'music-video', style, cast:[], quality:'fast', videoStyle:'realistic', audioKey, createdAt, videoModel:'kwaivgi/kling-v3.0-std', stage:'done', progress:1, videoKey, scenesFailed:0, previewScenes:2, error:''}` (fields per `pipeline.ts:901-921` and `:870`), scene 0 `done` with a 4 s **1280×720** ffmpeg-testsrc clip (Kling-native), scene 1 `done` with an **832×480** clip, scene 2 `failed` with no clip; run `resume()` and assert: project loads with the unknown `videoModel` field passed through (no crash); the assembled pre-upscale output has one video stream at exactly the engine's target `vW()×vH()`, duration == audio window ±0.1 s (proves the C2 per-clip conform scaled/padded the 720p clip and `stillClip` filled scene 2); the already-native 832×480 clip is not double-letterboxed. + +Total: ~16–17 engineer-weeks serial; M3/M4 partially parallelizable after M2. + +--- + +## 13. Open questions (statuses stamped 2026-07-04; the only live user decision is §14) + +1. **M16 (16–31 GB Mac)** — narrowed to a defined spike (2026-07-04, R5): fit is mathematically confirmed (worst stage ≈ 7.5–8 GB vs the 10,922 MB 16-GB Metal budget; staging already upstream; blockers = fp32 T5 upcast + missing quantized-T5 convert, both small fork patches) and field-supported (Draw Things: Wan2.2-5B 6-bit on iPhone-class devices). Projected speed from this repo's measured 5B benches (M5 Pro: 5.3 s/step @5,850 tokens, VAE 48 s): ≈75 s/clip at the M16 recipe on M5 Pro-20 ⇒ est. 3.5–4.5 min/clip on M4-10-core 16 GB (≈1.5–2 h per minute of video) and 8–10 min/clip on base M1-8-core (≈3.5–4.5 h/min) — acceptable for an experimental tier; scaling ratios to be pinned by the spike. Remaining unknowns (VAE tile working set, CFG transients, real-machine swap with the macOS baseline) close via the §12 M5 two-phase gate. Tier stays disabled until Phase B passes; fail → floor 32 GB permanently. +2. **macOS identity editing**: upstream RESOLVED (verified 2026-07-04) — mflux ≥ v0.18.0 (MIT, active; latest release 2026-06-07) implements Qwen-Image txt2img (`mflux-generate-qwen`) **and** Qwen-Image-Edit (`mflux-generate-qwen-edit`, multi-image + LoRA) using `Qwen/Qwen-Image-Edit-2509` (apache-2.0, ungated, verified), with load-time quantization and quantized `mflux-save` (edit-save fixed in 0.18.0). Remaining work is ours: an `hf-then-convert` catalog variant (~58 GB source → 4/8-bit local artifact) + an M32+ RAM/speed validation spike. Mac identity stays descoped until that spike passes; Kontext token-flow remains the only interim path. +3. **LTX-2 return — CLOSED (2026-07-04)**: stays dead. Sweep confirmed zero contradictions remain: LTX appears only in §2 (current-state description), §3.1(7)/§8-C2 (deletion), and here; it is absent from the tier matrix, seed catalog, ETA model, and degradation ladder. Re-adding would be a new feature proposal (catalog + ETA + offline-safe TE work), not a reopening of this plan. +4. **Linux llama-server — RESOLVED 2026-07-04**: official Vulkan build chosen; no CI-build exists in the plan. Audit (b9873): no Linux CUDA release asset; ubuntu-vulkan-x64 (31 MB) contains llama-server + libmtmd; measured gap on consumer RTX (driver ≥ 570, coopmat2) is tg −0–10% / pp −12–26% — the old ~20–30% figure was a driver-550 A100 number; LLM ≪ 1% of ETA-per-minute. `llmTokS` seeded per backend. Re-open only if a Vulkan correctness bug survives `--no-mmproj-offload`; escalation is a one-line manifest swap to a CI-built CUDA binary. +5. **Catalog refresh default — CLOSED (2026-07-04)**: ships opt-in, default OFF, exactly as specified in §3.4 (fetched only when the Models tab is open AND the toggle is on; host `raw.githubusercontent.com`; only from `src/main/models/catalog.ts`, which is in the tripwire path allowlist). Posture confirmed consistent across §3.4 and this section. Any future default-on flip requires an explicit privacy-reviewed consent screen and a plan amendment. +6. **SeedVR2 licensing/weights hosting**: RESOLVED (verified 2026-07-04). Apache-2.0 everywhere — GitHub `ByteDance-Seed/SeedVR` (SPDX Apache-2.0), HF cards `ByteDance-Seed/SeedVR2-3B`/`-7B` (`license: apache-2.0`), `numz/SeedVR2_comfyUI` mirror and `numz/ComfyUI-SeedVR2_VideoUpscaler` node (both Apache-2.0). Mirroring into `videoboom-assets` permitted with LICENSE+NOTICE+attribution. Sizes now repo-listed in §5.2 (3B fp8 3.39 GB, 7B fp16 16.48 GB, shared `ema_vae_fp16.safetensors` 0.50 GB); `seedvr2-terms` label deleted from §5.1. +7. **Pre-converted MLX weights org — USER DECISION (framed 2026-07-04; blocks nothing before M3, but the namespace is squattable until claimed)**: the `videoboom` HF namespace is verified unclaimed (org and user API endpoints both 404 as of 2026-07-04); the §5.2 RUNTIME/INTERP/UPSCALE mirror rows and the M3 `hf-then-convert`→`hf-files` flip all depend on owning this exact name. To publish there: (a) `videoboom/videoboom-assets` (≈ 0.9 GB: ComfyUI-trio tarballs 0.03, llama-server win pair 0.66 + linux-vulkan 0.03, RIFE 0.03, ESRGAN 0.13, sha256 manifest) — REQUIRED by M3 bootstrap regardless; (b) pre-converted MLX Wan weights (~106 GB total: bf16 ~54, Q4 ~18, 5B-Q8 ~14.1, FastWan-5B-DMD ~20 GB installed incl. umt5-enc Q8, per §5.2/R5; the FastWan MLX FAST default is a convert-artifact like `wan22-5b-mlx-q8`, so it joins the pre-converted set to spare each Mac FAST user a local convert pass; all Apache-2.0, redistribution permitted; gemma/flux are never rehosted) — flips `hf-then-convert`→`hf-files`, saving each mac user ~66 GB of download and ~67 GB of peak disk plus a local convert pass (setup.sh: ~120 GB source download vs ~54 GB direct). HF public storage on a free org is "best-effort" above the first few GB (hub docs); PRO includes up to 10 TB public if throttled. The four concrete decisions (claim-now, ownership, free-vs-PRO, re-conversion owner) are in **§14 D1–D4** with recommendations; the downloader keeps `hf-then-convert` as a permanent fallback path either way (mirror-outage resilience). +8. **Windows portable target — RESOLVED 2026-07-04: dropped.** Portable exes self-extract to %TEMP% and run (classic Defender-heuristic profile), are re-downloaded MotW-marked with a fresh per-release hash on every user (worst-case SmartScreen exposure vs an installed app, which carries no MotW), double the per-release SignPath signing-request load, and are portable in name only — bootstrap still writes runtime+models GBs into `%LOCALAPPDATA%`. `nsis` is the sole Windows target; `portable` is removed from package.json `build.win.target` at M6. + +--- + +## 14. Decisions needed from the user + +One decision cluster remains — everything else in this plan is resolved, mitigated with verified evidence, or carried by a defined spike. All four sub-decisions concern the `videoboom` Hugging Face organization (OQ7, §13.7). + +**D1 — Claim the `videoboom` HF namespace now?** Verified unclaimed 2026-07-04 (`/api/organizations/videoboom` and `/api/users/videoboom` both 404; `/api/models?author=videoboom` empty). First-come-first-served and squattable until claimed; §3.2/§5.2 mirror rows and the M3 `hf-then-convert`→`hf-files` flip hard-code the name. Options: (a) claim today under the maintainer's HF account — free, ~5 minutes; uploads only start at M3; (b) wait until M3 and risk losing the name; (c) pick a different namespace (plan-wide rename). **Recommendation: (a), today.** On approval, M0 executes its "register the `videoboom` HF org" task. + +**D2 — Org ownership/administration.** Options: (a) maintainer's HF account as sole owner + a fine-grained CI write token scoped to the two repos (`videoboom-assets`, pre-converted Wan weights); (b) add a second human admin. **Recommendation: (a)** — repo-scoped CI token; a second admin only if a real collaborator exists. + +**D3 — Free org vs PRO.** Required volume ≈ 0.9 GB runtime assets + ~106 GB pre-converted Wan weights (bf16 ~54, Q4 ~18, 5B-Q8 ~14.1, FastWan-5B-DMD ~20 incl. umt5-enc Q8, per §5.2). HF free public storage is officially "best-effort" beyond the first few GB (hub storage-limits doc); PRO includes up to 10 TB public storage (PRO monthly price not verified — check huggingface.co/pricing). **Recommendation: start free; upgrade to PRO only if uploads get throttled or flagged.** + +**D4 — Who re-converts on Wan updates?** Options: (a) a named human owner; (b) an approve-gated CI workflow in this repo (macOS runner: `mlx_video` convert + `hf upload` with the org token). **Recommendation: (b)** — removes the single-human bottleneck OQ7 worried about; the user only clicks approve. + +--- + +## 15. De-risk changelog (2026-07-04) + +What was verified per risk/question and what changed in the plan. All URLs/numbers checked live on 2026-07-04 unless noted. + +- **R1 ComfyUI GPL/drift → RESOLVED.** Verified: GPL-3.0 unchanged since initial commit 2023-01-03 (github API; repo transferred to `Comfy-Org/ComfyUI`, old URL 301s); ComfyUI-GGUF + WanVideoWrapper both Apache-2.0; pins ComfyUI v0.27.0 `bb131be9e83d2f773c90f1d6f1e4b248a498c8c5`, GGUF `6ea2651e7df66d7585f6ffee804b20e92fb38b8a`, Wrapper `088128b224242e110d3906c6750e9a3a348a659b` — tarballs fetchable via codeload at 11,524,306 + 31,636 + 19,068,927 B ≈ 31 MB compressed (the old §5.2 "~0.3 GB" was ~10× high); routes /prompt /interrupt /free /history /ws present v0.3.34→v0.27.0; diffusers fallback verified real (v0.39.0 `WanImageToVideoPipeline` with `transformer_2`/`boundary_ratio`; `Wan-AI/Wan2.2-I2V-A14B-Diffusers` ungated, 111k downloads); precedent Pinokio (MIT, launches ComfyUI). Changed: §3.2 pins + verified fallback, §5.2 RUNTIME row, §11 row, M3 task, new §16 `docs/LICENSES.md` seed. +- **R2 HF catalog gating/DMCA → MITIGATED.** All 26 external repos audited via HF API `?blobs=true`: public, `gated:false` — sole exception `black-forest-labs/FLUX.1-Kontext-dev` (`gated:"auto"`, non-commercial, opt-in unchanged). Sizes corrected to repo bytes (only >10% delta: gemma-3-4b +15% → 3.4 GB; also Qwen3.6-35B 20.4, Qwen3-8B 4.6, 5B-GGUF 3.4, Qwen-Image Q8 21.8, SeedVR2 3.4/16.5 + 0.5 VAE, VL 5.0+0.75/2.5+0.45); FLUX TE source corrected to `comfyanonymous/flux_text_encoders`; 26 revision SHAs pinned (§5.2 footnote); mirror legality: Apache-2.0/MIT mirrorable with LICENSE+NOTICE, gemma pin-only, Kontext never; `videoboom` namespace 404-free. Changed: §4.2/§5.2 sizes, §5.1 enum (`seedvr2-terms` deleted), audit footnote + `resources/catalog-audit.json` contract, weekly M4 liveness job, §11 row. +- **R3 ETA credibility → SPIKE PENDING (formula RESOLVED).** This repo's measured data (git 276675c, ee2b573, cd5ebfd, 92ddef8; B1/B2 notes 2026-07-02) broke the draft: two-point origin fit → b<0, −4.8 s/step @81f, non-monotone; "weights load once per render" false on MLX (per-clip reload 44–75 s → −28% on the M48 flagship config). New model `tFixClip + steps·(c+a·n+b·n²) + tVae + tRife` (c,a,b ≥ 0, NNLS, three-point bench, raw-benched-point precedence) reproduces the 7-render ~78 min/35 s overnight baseline within ±10%; kfPerMin floor corrected 3→2 (CHAIN_MAX 4, pipeline.ts:483). Open: ~25-min bf16-relay bench on the 48 GB Mac (the campaign's 7.3 min/clip figure has no recorded config) → M48/M64 seed rows. Changed: §3.1 coeffs shape, §7.1/§7.2 rewrite + validation record, §9 nightly sentence, M5 tasks (renderPlan.ts + eta-parity tests), §11 rows. +- **R4 Defender/SmartScreen + OQ8 → MITIGATED.** Azure Artifact Signing FAQ (ms.date 2026-05-14): public trust = orgs USA/Canada/EU/UK, individuals USA/Canada only → EU solo dev ineligible; Microsoft removed the EV SmartScreen bypass (EV OIDs out of Trusted Root Aug 2024; May-2026 doc: EV premium "no longer justified"). Chosen: SignPath Foundation ($0, OV on HSM, publisher "SignPath Foundation"; NSIS not deep-signable → two-stage ZIP-of-win-unpacked + installer-PE flow; precedent Super Productivity). Fallbacks: Certum OSS card (~€69 + ~€29–30/yr), Azure Basic $9.99/mo only with a future EU entity. Added: SAC preflight (`VerifiedAndReputablePolicyState`), same-day MS Security Intelligence FP runbook (LM Studio was flagged while signed, Mar 2026). OQ8: `portable` dropped (fresh MotW hash per user, %TEMP% self-extraction, 2× signing load, writes GBs to %LOCALAPPDATA% anyway). macOS notarize/staple pipeline unchanged and already wired (release.yml:33-37). +- **R5 M16 / OQ1 → SPIKE PENDING.** The plan's "DiT+umt5 cannot coexist" rationale was mechanically wrong — stock mlx-video stages (T5 freed generate.py:351-353; DiT freed :709-718). Real blockers: fp32 T5 upcast (~22.7 GB, utils.py:65-70) + `--quantize` converts transformer only (convert.py:342-343). Q8 math (HF-verified source sizes, 34.2 GB repo): DiT ~5.3 + umt5-enc ~6.0 + VAE fp32 2.82 ≈ 14.1 GB installed; worst stage 7.5–8 GB vs recommendedMaxWorkingSetSize 10,922.67 MB on 16 GB Macs; mflux `--low-ram` verified in locked package; Draw Things ships Wan 2.2 5B 6-bit for ≤8 GB iPhones. Changed: §4.2 M16 cells + rationale, §5.2 rows (5B-q8 ~14.1 GB installed; mflux row min-RAM 16 GB with --low-ram), §12 M5 two-phase gate (Phase A capped-Metal on the dev Mac: `iogpu.wired_limit_mb=10922` + `mx.set_memory_limit`, pass ≤ 10.0 GB peak; Phase B real 16 GB), §11/§13 rows. +- **R6 Linux llama-server / OQ4 → RESOLVED.** Releases b9864–b9873 (25-asset sets): still no ubuntu-CUDA asset; ubuntu-vulkan-x64 31.2 MB (sha256 = API digest, locally re-verified) contains llama-server + libmtmd.so + libggml-vulkan.so; deps glibc ≥ 2.34/GLIBCXX_3.4.30/libvulkan1/libgomp1/OpenSSL3. Perf (driver ≥ 570 coopmat2): tg ±0–10%, pp −12–26% vs CUDA on consumer RTX — the "~20–30%" figure traced to a driver-550 A100/KHR_coopmat setup; LLM stage is ETA noise. Windows = official cuda-12.4 pair (266.1 + 391.4 MB; 12.4 keeps the ≥570 floor, 13.3 needs r580+). CI-build deleted; mirror = copy job; b9871 shipped 0 assets → pin only complete tags; Vulkan mmproj edge case (#20081) → `--no-mmproj-offload` (arg.cpp:2339). Changed: §3.2 block, §3.4 allowlist (+HF hosts for runtime mirrors), §5.2 RUNTIME row, §9 Linux bullet, M3 tasks, §11/§13. +- **R7 CUDA footprint → RESOLVED.** Pin: torch 2.9.1+cu128 / torchvision 0.24.1+cu128 / torchaudio 2.9.1+cu128 — a deliberately conservative, internally consistent trio (torchaudio 2.9.1 hard-pins torch==2.9.1; newer cu128 trios exist up to 2.11.0 and are the upgrade path; torch ≥ 2.12 PyPI defaults to CUDA 13 → driver ≥ 580). Measured via HTTP-range reads of wheel central directories: Win ≈ 3.10 GB dl / ≈ 5.5–5.7 GB disk (torch win wheel 2,862.0 MB → 4,512.7 MB unpacked, 1.58×); Linux ≈ 4.36 GB dl / ≈ 8.0–8.3 GB disk (stack ratio ≈ 1.8×; cudnn 706.8 MB dl / 1,053.6 MB installed — not "1.5 GB"); both under the feared 9/11 GB. Guards: `build-locks.py` → `resources/runtime-sizes.json` + 10 GB CI budget; `UV_CACHE_DIR` co-located (no 2× peak); ctranslate2 win wheel 19 MB → torch\lib PATH prepend. New §4.2 disk-to-first-render row (N8 ≈ 34/36 … N24/N32 ≈ 96/99 GB). +- **R8 OOM diversity → MITIGATED.** Normative 10-signature table, all match strings source-verified: ComfyUI model_management.py:375 (`OOM_EXCEPTION`)/:384-390 (`is_oom`, AcceleratorError code 2), execution.py:638-708/1269-1272 (execution_error → /history); pytorch CUDACachingAllocator.cpp:1935 ("CUDA out of memory. Tried to allocate"); cudnn/cublas masked-OOM strings (discuss.pytorch.org/t/78724); llama.cpp ggml-cuda.cu:789; WDDM sysmem fallback (NVIDIA KB a_id/5490) as `PERF_DEGRADED` heuristic; MLX allocator.cpp:114-157 + device.cpp:507-510; jetsam via fsync'd phase journal + SIGKILL + `kern.memorystatus_vm_pressure_level`. Ladder fixes: phase-aware rung mapping; skip-if-active (relay-shedding already default M48/M64); wrapper block-swap RAM preflight; native rung = per-job VAEDecodeTiled first, `--reserve-vram` only via child restart (launch flag, cli_args.py:147); steps = non-rung. §3.3 gains structured error payload; M5 drill extended (sysmem-fallback + kill-recovery cases). +- **R9 mlx-video drift → RESOLVED.** `local/requirements.txt:3` was unpinned; installed venv == upstream HEAD == `87db56a51758fefb748a359b90a5283bb8ba4837` (Blaizzy/mlx-video: MIT, 0 releases, 0 tags, dormant since 2026-05-13, wan_2 layout deleted/relocated 2026-03-18; Wan fixes rot in unmerged PRs #38/#29; 12 private-module import sites + 3 monkeypatches + 950-line relay fork in this repo). M0: pin (zero behavior change). M2: vendor wan_2/ (496 KB) + lora/ (60 KB) + ltx tiling.py into `local/backends/mlx/vendor/`, merge relay_generate.py as `memory_mode='relay'`, delete monkeypatches, drop the dep, make mlx-lm/mlx-vlm/transitives explicit. Changed: §3.2/§3.3 layout, §10 probe 7, M0/M2 tasks. +- **R10 estimator↔pipeline drift → RESOLVED.** Merged two-layer resolution: shared `src/shared/renderPlan.ts` (chainPlan/planCuts extracted from pipeline.ts:402-416/:487-492) + `tests/eta-parity.test.ts` in `npm test` (M5); `scripts/check-estimator-invariants.ts` (§12.1, M0 self-arming → M5 enforcing); single nightly alarm in `nightly-gpu.yml` (3× seed sanity + predictClip provenance band). +- **R11 catalog↔tier drift → RESOLVED.** `scripts/check-catalog-invariants.ts` spec'd with 8 assertions (§12.1); consumes `resources/catalog-audit.json` written by the gate check + weekly liveness job; M0 self-arming → M4 enforcing. +- **R12 old cloud projects → RESOLVED.** Repo-verified: projects are schemaless (`src/main/projects.ts:19-45`); writer fields `pipeline.ts:901-921`/:870; only cloud residue = inert `videoModel` (`'kwaivgi/kling-v3.0-std'`, read only by the cloud arm C2 deletes) + Kling-native 1280×720 clips → covered by the C2 conform + C5 whitelist migration. Gap found & fixed: v1 `localVideoModel:'ltx'` now coerced to `'14b'` in C5. Tests spec'd in §12.1 (settings-migration unit + `legacy-cloud-conform` selftest). check-no-cloud dry run: tracked-file hits limited to providers.ts/App.tsx/index.html/index.css/relay_generate.py/download.py — all die on schedule (grep must scope to `git ls-files`; `local/.venv` poisons directory walks). +- **OQ2 mac identity → upstream RESOLVED, ours = spike.** mflux v0.18.0 (MIT, release 2026-06-07) ships `mflux-generate-qwen` + `mflux-generate-qwen-edit` on `Qwen/Qwen-Image-Edit-2509` (Apache-2.0, ungated); remaining: catalog variant + M32+ RAM/speed spike. **OQ3** LTX stays dead (sweep clean). **OQ5** catalog refresh stays opt-in/off (posture consistent). **OQ6** SeedVR2 Apache-2.0 everywhere (GitHub SPDX + HF cards + numz mirror/node) → mirroring permitted. **OQ7** → §14 D1–D4. **OQ8** portable dropped. + +### 2026-07-05 — two-mode remap (FAST = FastWan-5B, QUALITY = Wan-14B, both 1080p final) + +Replaces the old fast = 14B-Lightning-4-step framing. **Mapping:** FAST mode ⇒ `FastVideo/FastWan2.2-TI2V-5B-FullAttn` DMD 3-step (guide=1, sigmas `[1.0,0.757,0.522,0.0]`, renoise), native 832×480/121f@24 fps → upscale to 1080p; QUALITY mode ⇒ Wan 2.2 I2V-A14B bf16-relay (unchanged), native 832×480 → 1080p. **Both modes deliver 1080p final** (native at tier res, realesr-animevideov3-x2 → 1080p; native 1080p diffusion infeasible on-device — localVideo.ts:116-119). Changed: §2, §3.2 (backend table + FastVideo-runtime CUDA caveat + N8-vs-N12 offload/fallback split), §4.2 (Apple + NVIDIA Video rows + both-1080p note + cross-cutting gates + N16+ disk-to-first-render recompute for the +23 GB FastWan model), §5.2 (two FastWan rows + audit pin `3e187042`; FastWan variants modelled as optional per-tier default preference, VIDEO-stage `required` met by the stock 5B fallback), §7 (headline split, §7.1 FastWan deltas, §7.2 seed/bench points, device-class-qualified variant ids, validation-record FastWan spike), §12 (M5 bench task + nightly clip + drift-alarm metric), §13.7/§14 D3 (FastWan-5B-MLX ~20 GB added to the pre-converted org set, ~86 → ~106 GB), this line. + +- **Platform feasibility.** **MLX: already implemented and measured** (`local/fastwan_dmd.py`, `.model-path-5b` → `local/models/FastWan2.2-TI2V-5B-MLX`, git e095e90 — an ancestor of HEAD; convert-and-mark from the FullAttn-Diffusers checkpoint via the stock mlx-video Wan converter, weights validated key+shape-identical to the official 5B conversion; FullAttn = dense attention so no VSA/custom kernel). Viable **M48/M64** (measured 25.6 GB peak @832×480/121f, 264 s/clip; 720p-native 642 s/clip), **tight on M32** (25.6 GB peak vs ~21.3 GB budget → M5 fit gate; spill → stock 5B-Q8 / 14B-Q4), **not viable M16** (bf16 doesn't fit the 16 GB budget → M16 FAST stays the stock 5B-Q8 recipe; a FastWan-Q8 3-step conversion is a future spike). **CUDA:** the DMD sampler is **FastVideo-custom** (`WanDMDPipeline`, not a stock ComfyUI/diffusers node) → the FAST tier drives the FastVideo runtime; **no FastWan GGUF exists** → **N16+ run bf16/fp16; N12 attempts TE CPU-offload (tight) else falls back; N8 always falls back** to stock TI2V-5B GGUF. **i2v** via first-latent mask (same 5B path, `relay_generate.py:461/624/734`) — proven by e095e90 (keyframe→clip exercised; character ghosting in camera transitions is the accepted 3-step draft-tier tradeoff, alongside x64-VAE face-softening — why 5B is the draft tier, not QUALITY). License **Apache-2.0**, ungated. +- **ETA recompute (M5 Pro 48 GB, per 1 min of final 1080p; arithmetic).** Measured wall per finished second — FastWan 264 s / 5.04 s = **52.4 s/s** vs old 14B-Lightning 265 s / 2.3 s = **115.2 s/s** ⇒ **2.20× faster on the video-generation term**. Old headline 2.23 h/min (134 min) decomposes ≈ 115 min video-gen + ~19 min fixed (STT/LLM/keyframe/RIFE/upscale/grade). New FAST: video-gen 60 × 52.4 = **52.4 min**; drop RIFE (native 24 fps, ≈ −1.7 min from the fixed term); upscale unchanged (both 480p→1080p at the same 24 fps frame count, ~6 min already inside the fixed term) ⇒ ≈ 52.4 + 17.3 = **~70 min ≈ 1.0–1.3 h/min** (≈ **1.9× end-to-end**; the fixed overhead doesn't shrink, so end-to-end < the 2.2× denoise factor). Apple non-M5 rows and **all NVIDIA FastWan rows are `extrap`** until the M5 FastWan bench point lands (§7.2/§12). QUALITY-mode numbers are the pre-existing 14B headline (relabeled). + +--- + +## 16. Appendix — `docs/LICENSES.md` seed content (lands in M3 with bootstrap.ts) + +```markdown +# Third-party licenses & GPL posture + +Videoboom is Apache-2.0. It does not link against, import, or bundle any GPL code. + +## ComfyUI (GPL-3.0): how we use it and why the app stays Apache-2.0 + +- **What**: on Windows/Linux, Videoboom uses ComfyUI (https://github.com/Comfy-Org/ComfyUI, GPL-3.0) + as a headless local execution engine for CUDA inference. +- **Process boundary**: ComfyUI runs as a separate OS process spawned by our Python sidecar and is + controlled exclusively over its localhost HTTP/WebSocket API (/prompt, /history, /interrupt, /free, /ws) + with generic workflow-graph JSON. No Videoboom process imports ComfyUI code; nothing is compiled or + linked against it. Per the FSF's own guidance (GPL FAQ, "mere aggregation" / separate-programs criteria, + https://www.gnu.org/licenses/gpl-faq.html#MereAggregation), programs communicating at arm's length via + exec + sockets are separate works: the GPL governs ComfyUI, not Videoboom. +- **Distribution boundary**: the Videoboom installer contains no ComfyUI code. At first-run bootstrap, + with explicit user consent, the app downloads verbatim, unmodified source tarballs of the pinned + commits below from our mirror (videoboom/videoboom-assets on Hugging Face) or from GitHub + (codeload.github.com), verifies sha256, and unpacks them under the user's data directory. Mirroring an + unmodified source tarball is redistribution of the Program's source under GPL-3 §4: all notices and the + license text remain intact inside the tarball, and the mirror README states the upstream URL and exact + commit. We make no modifications; if we ever patch ComfyUI, the patched source tarball is published in + the same mirror (GPL-3 §5). +- **Python dependencies** (torch, comfyui-frontend-package, …) are installed by uv/pip from PyPI into a + venv created on the user's machine — they are obtained by the user from PyPI, not distributed by us. +- **Industry practice** (not legal advice): permissive-licensed launchers such as Pinokio (MIT) and + Stability Matrix download and run ComfyUI the same way; spawning GPL tools as separate processes from + permissively-licensed apps is long-standing accepted practice. + +## Pinned runtime components (fetched at bootstrap, never inside the installer) + +| Component | Upstream | Pin | License | +|---|---|---|---| +| ComfyUI | https://github.com/Comfy-Org/ComfyUI | bb131be9e83d2f773c90f1d6f1e4b248a498c8c5 (v0.27.0) | GPL-3.0 | +| ComfyUI-GGUF | https://github.com/city96/ComfyUI-GGUF | 6ea2651e7df66d7585f6ffee804b20e92fb38b8a | Apache-2.0 | +| ComfyUI-WanVideoWrapper | https://github.com/kijai/ComfyUI-WanVideoWrapper | 088128b224242e110d3906c6750e9a3a348a659b | Apache-2.0 | +| llama.cpp (llama-server) | https://github.com/ggml-org/llama.cpp | per bootstrap manifest | MIT | + +## Components inside the installer (extraResources) + +| Component | License | Note | +|---|---|---| +| uv | MIT OR Apache-2.0 | | +| ffmpeg/ffprobe static | LGPL/GPL depending on build | RULE: ship LGPL-configured builds only, or document GPL-build source offer here | +| Inter font | OFL-1.1 | | + +Model-weight licenses are tracked per variant in docs/MODELS.md (from resources/model-catalog.json license field). + +## Upgrade policy +Pinned commits change only via a bootstrap-manifest bump PR that (1) re-runs the golden contract suite on +all OS runners, (2) re-mirrors the new tarballs with fresh sha256, (3) updates this file. +``` diff --git a/README.md b/README.md index faa044d..01445fe 100644 --- a/README.md +++ b/README.md @@ -5,13 +5,13 @@

Videoboom

- Turn a song into a complete music video — on your own machine, with your own API keys. + Turn a song into a complete music video — on your own machine by default.

Latest release Website - Platforms + Platform CI License

@@ -30,13 +30,21 @@ Videoboom is an open-source desktop app. Drop in a song, optionally add a cast ( characters built from a photo or a description), and it writes a story from the lyrics, designs the shots, generates keyframes, animates each scene, and cuts the final video to the beat. -It's **bring-your-own-key (BYOK)**: you paste your own OpenRouter / Replicate keys, the app calls those -providers directly, and you pay them at cost. No accounts, no subscription, no markup — nothing leaves -your machine except the generation calls you pay for. +Videoboom is **local-default hybrid**. Every stage runs **on-device by default** on Apple Silicon via a +resident Python MLX sidecar — transcription (Whisper), story + shot list (Qwen3 LLM), keyframes +(FLUX + Kontext), image-to-video (Wan 2.2), and face caption + safety (gemma-3 VLM). With no key, generation +is fully local, private and offline: the only time Videoboom touches the network is to download the model +weights once, and your song and your video never leave your machine. -> **Why BYOK?** Cloud video generation is genuinely expensive (~€25–30 of provider cost per finished -> minute). A hosted service has to mark that up to survive, which makes it absurd for casual use. BYOK -> removes the middleman: you see exactly what each render costs and decide. +Local is the point: **private, free to run, offline** once the models are downloaded. Want more speed or a +specific model on one stage? You can **optionally** bring your own key to run that stage in the cloud +(OpenRouter · Kling · Replicate) — it's **off by default**, chosen per stage, and nothing leaves your +machine unless you turn it on for that stage. + +> **Why local-default?** Cloud video generation is expensive and sends your song to someone else's servers. +> Running the models locally means it's private, it's free to run, and it works offline once the models +> are downloaded. When you want more speed or a specific model, opt a stage into cloud with your own key. +> On-device generation needs a capable Mac (see requirements below). ``` song ─▶ transcribe (forced-aligned) ─▶ story from the lyrics ─▶ shot list @@ -49,52 +57,72 @@ song ─▶ transcribe (forced-aligned) ─▶ story from the lyrics ─▶ shot singing with no cumulative drift. - **Reusable cast** — a photo and/or a description becomes a consistent character reused across scenes. - **Per-scene refresh** — re-roll a single scene without touching the rest of the cut. -- **Preview first** — render the opening ~25% cheaply, then continue to the full song. -- **Your models** — swap the LLM / image / video models in Settings (e.g. a cheaper i2v model). +- **Preview first** — render the opening ~25% quickly, then continue to the full song. +- **On-device by default** — transcription, story, keyframes and video all run locally (Apple Silicon / MLX); + private and offline, no key required. +- **Optional cloud, per stage** — bring your own key to opt individual stages into OpenRouter (LLM / VLM / + keyframes), Kling (video) or Replicate (WhisperX timing). Off by default; nothing leaves the machine unless + you turn it on. - AI-generated output is tagged as such in the file metadata. +## System requirements +Videoboom runs the AI models **on your own GPU** — nothing is streamed, nothing is metered. On-device +generation needs one of: + +| GPU | Minimum | Status | +|-----|---------|--------| +| **Apple Silicon — MLX / Metal** | M-series Mac · **48 GB+ unified memory** (64 GB recommended — measured on-device peaks: Wan-14B bf16-relay ~33 GB, FastWan-5B ~55 GB) | ✅ shipping | +| **NVIDIA — CUDA** | driver ≥ 570 · **8 GB+ VRAM** · compute capability ≥ 8.6 · Windows / Linux | 🚧 on the roadmap | + +Plus **~100 GB free disk** for the model weights (downloaded once): the Wan-14B video engine is ~69 GB on its own, the Lightning LoRA ~2.5 GB, and the STT / LLM / VLM / keyframe stages another ~28 GB between them. The app detects your machine, picks the +right models and quantization for it, and gates local generation until you meet the bar. No supported GPU? +You can still generate by opting individual stages into cloud with your own key — but on-device stays the +default. + ## Install (run from source) ```bash -# repo root — Node only, no Python. ffmpeg ships bundled (ffmpeg-static). +# repo root — the Electron app (Node); ffmpeg ships bundled (ffmpeg-static). npm install npm run dev -``` -In **Settings**, add your keys, then **Create** a video: - -| Key | Needed | Used for | -|-----|--------|----------| -| **OpenRouter** (`VB_OPENROUTER_API_KEY`) | **required** | story + shot list (LLM), keyframes, image-to-video, moderation | -| **Replicate** (`REPLICATE_API_TOKEN`) | **required** | forced-aligned lyric timing (WhisperX) — reads the song's words + locks scenes to the singing | -Both keys are needed; keys are encrypted with your OS keychain and never leave the machine. +# one-time: install the on-device model sidecar (Python venv + MLX) and fetch the Wan video model +bash local/setup.sh +``` +The remaining stage models (STT / LLM / VLM / keyframes) download from **Settings → On-device → Download**. +Once every required stage shows *Ready*, **Create** a video — fully local, nothing to paste. To route a +stage through cloud instead, add your provider key in **Settings** and opt that stage in. ## Packaged builds (no install for end users) -`npm run dist` bundles ffmpeg into a native installer for the OS you run it on. The render engine is pure -TypeScript (no Python, no PyInstaller); the only per-OS piece is the bundled ffmpeg binary, downloaded by -`npm install`, so each OS is built on its own machine (or a CI runner): +`npm run dist` bundles ffmpeg into a native macOS installer. The Electron app orchestrates the render; +on-device generation uses the Python MLX sidecar installed once by `bash local/setup.sh` (a user-owned +venv, not bundled into the installer). -| OS | Output (`release/`) | -|---------|---------------------------------------------| -| Windows | `Setup .exe` (installer) + portable `.exe` | -| macOS | `.dmg` | -| Linux | `.AppImage` (click-to-run) + `.deb` | +| OS | Output (`release/`) | +|---------|---------------------| +| macOS | `.dmg` | ## Repository layout ``` -src/main/ Electron main — window, IPC, runs the engine, OS-keychain key storage +src/main/ Electron main — window, IPC, runs the engine, on-device model settings + downloads src/preload/ contextBridge — exposes window.vb (the only surface the renderer can touch) src/engine/ TypeScript render engine — transcribe ▸ story ▸ shot list ▸ keyframes ▸ i2v ▸ assemble + (stages.ts wraps the on-device local*.ts modules by default, and the cloud/ clients when a + stage is opted in) +src/engine/cloud/ Optional bring-your-own-key providers — OpenRouter (LLM/VLM/keyframes), Kling (video), + Replicate (WhisperX timing); used only for stages the user explicitly enables +local/ Python MLX sidecar (server.py) + setup/download scripts for the on-device models renderer/ React UI (Create / Videos / Cast / Settings), wired to window.vb icons/ app icon -docs/ ARCHITECTURE · FOLDER-STRUCTURE · PROVIDERS · ROADMAP (+ research/) +docs/ ARCHITECTURE · FOLDER-STRUCTURE · MODELS · LOCAL-MODELS · ROADMAP (+ research/) ``` ## How it works -The engine runs in-process in the Electron main process: per operation it streams progress events to the -UI, with your keys + model choices injected from the keychain. Generation is your own cloud API calls -(OpenRouter + Replicate); ffmpeg (bundled) does the cutting. Projects (state + media) are plain files -under the app's data directory. +The TypeScript engine runs in-process in the Electron main process: per operation it streams progress +events to the UI. By default each generation stage calls the on-device MLX models through the resident +Python sidecar (`local/server.py`); if you opt a stage into cloud with your own key, that stage calls the +matching client under `src/engine/cloud/` instead. ffmpeg (bundled) does the cutting. Projects (state + +media) are plain files under the app's data directory. With no key, nothing leaves the machine. ## License -Apache-2.0 — see [LICENSE](LICENSE). Videoboom only orchestrates third-party model APIs; you are -responsible for complying with each provider's terms and for the content you generate. +Apache-2.0 — see [LICENSE](LICENSE). You are responsible for the content you generate and for complying +with the licenses of the on-device models you download. diff --git a/SIDECAR_BOOTSTRAP_PLAN.md b/SIDECAR_BOOTSTRAP_PLAN.md new file mode 100644 index 0000000..d9e27cb --- /dev/null +++ b/SIDECAR_BOOTSTRAP_PLAN.md @@ -0,0 +1,786 @@ +# Videoboom — macOS Sidecar Bootstrap Plan (M3, Apple Silicon slice) + +Branch: `local-only-pivot` (authored on `bf16-relay`). This is the macOS slice of +`LOCAL_PLAN.md` M3. It turns the notarized `.dmg` — which today boots the UI but +cannot render on-device because `local/` is never bundled and there is no +first-run provisioning — into an app that provisions and runs the MLX sidecar +entirely from `userData`, with zero terminal, zero repo checkout, and no loss of +notarization validity. + +It is written from the two verified designs (A: bootstrap architecture; B: +first-run UX + models) and resolves every blocker and major verifier finding +in-text. The Windows/CUDA slice of M3 stays in `LOCAL_PLAN.md`. + +--- + +## 0. Summary + honest feasibility verdict + +**Verdict: YES — with a per-Mach-O ad-hoc re-sign and a post-harden exec probe.** + +A notarized, stapled, hardened-runtime `.dmg` *can* provision a Python +interpreter + native MLX/torch/ncnn wheels into `~/Library/Application +Support/Videoboom/local` at first run and `posix_spawn` it as the sidecar. This +is the proven Pinokio / ComfyUI-Desktop model: the parent app is not sandboxed, +sets no `LSFileQuarantineEnabled`, and the child interpreter is a *separate* +process whose hardened-runtime/library-validation posture does **not** inherit +from the parent across `exec`. No new entitlement is required — spawning a +sibling process needs none, and the four keys already in +`build/entitlements.mac.plist` (`allow-jit`, +`allow-unsigned-executable-memory`, `disable-library-validation`, +`allow-dyld-environment-variables`) govern the *Electron* process, not the +child. + +The feasibility hinges on **one** load-bearing mechanic that the designs got +partly wrong and the verifiers corrected: + +- **The ad-hoc re-sign is the fix, not the xattr strip.** On Apple Silicon a + freshly-written Mach-O launched by `exec` from a Developer-ID parent is + SIGKILLed (Sequoia: `Killed: 9`) or hangs at `_dyld_start` (Tahoe / macOS 26) + unless it carries a *fresh, valid* code signature. `codesign --force --sign -` + mints a new cdhash that clears `syspolicyd`/AMFI. `xattr -rd + com.apple.provenance` is a **no-op** (kernel-managed, SIP-protected — `xattr + -d` exits 0 and leaves it) and must not be relied on. Because uv writes fresh + Mach-Os at a fresh path under `userData`, the re-sign has clean files to sign; + that is why it works, not because provenance was stripped. + +- **`--deep` on `.venv` does not sign the nested Mach-Os.** `.venv` is a plain + directory tree, not a bundle; `codesign --deep` only descends a bundle's + `Contents/`. Run against `.venv` it either errors (`bundle format + unrecognized`) or exits without signing `libpython3.12.dylib`, the interpreter + binaries, or the wheels' `.dylibs/*.so`. AMFI checks *every* Mach-O on + `exec`/`dlopen`, so a single unsigned interpreter binary is fatal, silently. + The harden step therefore **iterates every Mach-O individually** and re-signs + each, failing the bootstrap loudly on any error. + +- **The failure is invisible at spawn time.** A bad signature does not raise the + "damaged" Gatekeeper *dialog* (that only fires for LaunchServices double-clicks + of quarantined bundles, never for a `posix_spawn`'d child). It surfaces as a + sidecar that never answers `/health`. So the bootstrap adds an explicit + **`verify` phase** that execs the provisioned interpreter (`python -c 'import + mlx.core, sys'`) with the real spawn env and treats SIGKILL/timeout/non-zero + as a hard, phase-attributed bootstrap error. + +Two operational caveats that keep this an honest "yes": + +1. **Tahoe (macOS 26) is the primary validation target, not Sequoia.** Tahoe + enforces `AppleSystemPolicy` harder and *removed* the `spctl` Gatekeeper + disable escape hatch, so there is no user-side rescue if the re-sign is + incomplete. The approach still works on Tahoe (confirmed by independent + reports), but the margin is thinner and depends entirely on (a) every Mach-O + being re-signed and (b) files being freshly written at a fresh path (uv → + `userData` satisfies this). The full download→open-quarantined-dmg→ + bootstrap→spawn flow **must** be tested on real macOS 26 hardware; CI cannot + reproduce it. + +2. **Model provisioning has one external blocker: the HF org.** The out-of-box + *Fast* tier needs `lBroth/FastWan2.2-TI2V-5B-MLX` published self-contained + (its `t5_encoder`/`vae` are currently symlinks on the dev box). Until it + lands, ship the **14B interim default**, which works from public/community + repos but is heavier and does not fit the app's own 32 GB floor. See §5, §10. + +Everything else — path resolution, the venv bootstrap, the electron-builder +wiring, the readiness gates, the UX — is code we own and lands incrementally +with `npm test` + `npm run build` green (§9). + +--- + +## 1. Runtime layout — read-only code vs writable runtime + +Two roots. **Code** ships inside the notarized bundle (immutable, signed). +**Runtime** (uv, Python, venv, weights, markers, caches) lives under +`app.getPath('userData')` = `~/Library/Application Support/Videoboom`, *outside* +the `.app`, so nothing provisioned at first run touches the notarized/stapled +artifact and staple validity is preserved. + +``` +Videoboom.app/Contents/Resources/ ← process.resourcesPath (read-only, signed, stapled) +├── app.asar dist/** + renderer-dist/** (unchanged build.files) +├── local/ ← codeDir() (extraResources; §8) +│ ├── server.py manager.py wan_i2v.py relay_generate.py fastwan_dmd.py +│ ├── tiny_vae.py taehv_upstream.py stt.py llm.py vlm.py keyframe.py +│ ├── interp.py upscale.py download.py requirements.txt +│ └── models/ ← tiny READ-ONLY, code-relative assets ONLY +│ ├── rife-v4.26/{flownet.param,.bin} interp.py:31 dirname(__file__)/models +│ └── taew2_1.safetensors tiny_vae.py:16-17 _HERE/models (22.6 MB) +├── bin/uv ← Developer-ID-signed uv (§4, §8) +├── wheels/ vendored *.whl incl. mlx_video + torch (§3, §8) +└── requirements.macos.lock hashed lock (§3) + +~/Library/Application Support/Videoboom/ ← app.getPath('userData') (writable) +└── local/ ← runtimeDir() + ├── uv/python/…/bin/python3.12 ← the REAL interpreter Mach-O (re-signed §4) + ├── uv/cache/ uv wheel cache + ├── .venv/bin/python ← venvPython() (symlink/copy of uv python) + ├── models/ ← modelsDir() (Wan/FastWan/Lightning snapshots) + ├── hf-cache/ ← HUGGINGFACE_HUB_CACHE (STT/LLM/VLM/KEYFRAME) + ├── .model-path .model-path-5b .lightning-dir ← markerDir() + └── venv-manifest.json bootstrap stamp (§3) +``` + +**Why two `models/` dirs.** `interp.py:31` (`os.path.join(_HERE, "models", +"rife-v4.26")`) and `tiny_vae.py:16-17` (`os.path.join(_HERE, "models", +"taew2_1.safetensors")`) read a **code-relative** path that is *not* +`VB_LOCAL_MODELS_DIR`-aware. Those two assets are tiny (rife-v4.26 flownet ≈ 44 +MB, taew2_1 = 22.6 MB), read-only, and ride next to the code in +`resourcesPath/local/models`. The heavy generated weights key off +`modelsDir()`/markers → `userData`. The two consumers never cross, so there is no +collision. **Correction to Design A: there is no `rife-v4.25`** — `ls +local/models` shows only `rife-v4.26` + `taew2_1.safetensors`. `interp.py:30` +probes `("rife-v4.26","rife-v4.25")` then falls back to the wheel's `rife-v4.6`, +so shipping only 4.26 is correct; drop every `rife-v4.25` reference from the +layout, the extraResources filter, and the git-add list. + +**Env the engine sets** (computed once in `paths.ts`, injected via `localEnv()`): + +| Var | Packaged value | Dev value | +|---|---|---| +| `VB_LOCAL_DIR` | `resourcesPath/local` | `/local` | +| `VB_LOCAL_PYTHON` | `userData/local/.venv/bin/python` | `/local/.venv/bin/python` | +| `VB_LOCAL_MODELS_DIR` | `userData/local/models` | `/local/models` | +| `VB_LOCAL_MARKER_DIR` *(new)* | `userData/local` | `/local` | +| `HUGGINGFACE_HUB_CACHE` | `userData/local/hf-cache` | `/local/hf-cache` | + +Today **nothing** sets these: `sidecarEnv()` (`index.ts:82-85`) merges only +`keysEnv()` + `resolvedConfig().toEnv()`, and `toEnv()` sets `VB_LOCAL_WAN_DIR` +only when `settings.localWanDir` is non-empty (default `''`, +`settingsSchema.ts:42`). So every path falls through to +`process.cwd()/local` — the packaged bug. This layout closes it. In dev, +`runtimeDir() === codeDir() === /local`, so the existing `.venv`, `models`, +and markers resolve unchanged: **zero dev disruption**, and `detect()` sees the +venv already present and skips bootstrap. + +--- + +## 2. Path-resolution rewrite (dev vs packaged, file:line) + +The resolver is **electron-aware** and lives in main (`src/main/paths.ts`, new). +The engine modules (`sidecar.ts`, `localVideo.ts`) must stay electron-free +(they live in `src/engine`, imported in-process, deliberately dependency-light — +`config.ts:14-17` `env()` already falls back to `process.env`). So main computes +the roots and **injects them as env**; the engine keeps thin env-readers. +`localModels.ts` (in main) imports `paths.ts` directly and deletes its duplicate +resolvers (`localModels.ts:20-30`). + +**New `src/main/paths.ts`:** + +```ts +import { app } from 'electron'; +import path from 'node:path'; + +const repoLocal = () => path.join(app.getAppPath(), 'local'); // dev: getAppPath()=repo root + +export function codeDir(): string { // read-only sidecar CODE + if (process.env.VB_LOCAL_DIR) return process.env.VB_LOCAL_DIR; + return app.isPackaged ? path.join(process.resourcesPath, 'local') : repoLocal(); +} +export function runtimeDir(): string { // writable runtime ROOT + if (process.env.VB_LOCAL_RUNTIME_DIR) return process.env.VB_LOCAL_RUNTIME_DIR; + return app.isPackaged ? path.join(app.getPath('userData'), 'local') : repoLocal(); +} +export const venvPython = () => process.env.VB_LOCAL_PYTHON || path.join(runtimeDir(), '.venv', 'bin', 'python'); +export const modelsDir = () => process.env.VB_LOCAL_MODELS_DIR || path.join(runtimeDir(), 'models'); +export const markerDir = () => process.env.VB_LOCAL_MARKER_DIR || runtimeDir(); +export const hfCacheDir = () => process.env.HUGGINGFACE_HUB_CACHE || path.join(runtimeDir(), 'hf-cache'); +export const uvBin = () => (app.isPackaged ? path.join(process.resourcesPath, 'bin', 'uv') : 'uv'); + +/** The env block injected into EVERY sidecar / download / bootstrap spawn. */ +export function localEnv(): Record { + return { + VB_LOCAL_DIR: codeDir(), + VB_LOCAL_PYTHON: venvPython(), + VB_LOCAL_MODELS_DIR: modelsDir(), + VB_LOCAL_MARKER_DIR: markerDir(), + HUGGINGFACE_HUB_CACHE: hfCacheDir(), + }; +} +``` + +**Wiring (main):** + +- `index.ts:82-85` `sidecarEnv()` → `{ ...keysEnv(), ...resolvedConfig().toEnv(), + ...localEnv() }`. Now `streamOp` (`index.ts:127`) and every stage get correct + paths. +- `localModels.ts:20-30` — delete the duplicated `localDir`/`localPython`/ + `hfCache`; import `codeDir`/`venvPython`/`hfCacheDir` from `paths.ts`. +- `localModels.ts:122` download spawn env → merge `localEnv()` (it currently + passes only `{ ...process.env, HF_HUB_DISABLE_XET }`). + +**Blocker resolved — HF cache 3-way consistency.** The verifiers found +`HUGGINGFACE_HUB_CACHE` reaching only the download spawn, so STT/LLM/KEYFRAME +would download into `userData/local/hf-cache` but be read from +`~/.cache/huggingface/hub` by both the sidecar and the readiness gate. All three +consumers are now unified on `hfCacheDir()`: + +1. **Sidecar spawn** (`sidecar.ts:61-67`) currently spawns `server.py` with `env: + { ...process.env, VB_FFMPEG, VB_FFPROBE }`. Since `config.setEnv()` writes to a + module-level `CFG` map (`config.ts:7-11`), **not** `process.env`, the Python + child never inherits the injected cache. Fix: `sidecar.ts` reads the cache via + `env('HUGGINGFACE_HUB_CACHE')` (already a `config.ts` reader) and adds it to + the spawn env: `env: { ...process.env, VB_FFMPEG, VB_FFPROBE, + HUGGINGFACE_HUB_CACHE: env('HUGGINGFACE_HUB_CACHE'), VB_LOCAL_MARKER_DIR: + env('VB_LOCAL_MARKER_DIR'), VB_LOCAL_MODELS_DIR: env('VB_LOCAL_MODELS_DIR') }`. + `stt.py`, `llm.py`, `keyframe.py`, `vlm.py` resolve a **repo id** out of the HF + cache (`stt.py` path_or_hf_repo, `llm.py` mlx_lm.load, `keyframe.py` + ModelConfig.from_name), so they now load from the same cache the download + wrote to. +2. **Readiness gate** — `localModels.hfCache()` is deleted and replaced by the + `paths.ts` `hfCacheDir()`, so `repoReady()`/`modelStatus()` look in the same + place. +3. **Download** — already reads `HF_HUB_CACHE`; `localEnv()` sets it. + +Note `netAllowlist.ts` confirms the Python subprocess is *outside* the Electron +firewall, so a cache mismatch would silently re-download multi-GB at render time +— exactly the failure this unification prevents. + +**Engine edits (`src/engine`, minimal):** + +- `sidecar.ts:24-31` `readMarker` — base on the **marker dir**, not the code dir + (the map's single most tangled coupling): + ```ts + const base = env('VB_LOCAL_MARKER_DIR', localDir()); + return fs.readFileSync(path.join(base, name), 'utf8').trim(); + ``` + `localVideo.ts:32-35,40` (`modelDir`/`lightningLoras` via `readMarker`) inherit + the fix for free. +- `sidecar.ts:21-23` `localPython()` — already reads `VB_LOCAL_PYTHON`; main now + always injects it, so the broken `localDir()/.venv` packaged default is never + hit. Keep it as a standalone-test failsafe. +- `sidecar.ts:15-17` `localDir()` — reads `VB_LOCAL_DIR` (now always injected); + the `cwd/local` fallback stays as a test-only failsafe. + +**Python side (`download.py:40-49,83`):** markers are hardcoded to `here` +(read-only when packaged). Honor the new env: +```py +here = os.path.dirname(os.path.abspath(__file__)) +marker_dir = os.environ.get("VB_LOCAL_MARKER_DIR", here) +os.makedirs(marker_dir, exist_ok=True) +marker = os.path.join(marker_dir, ".model-path") # or .model-path-5b (§5) +# ... os.path.join(marker_dir, ".lightning-dir") +``` +`models_dir` already honors `VB_LOCAL_MODELS_DIR` (`download.py:41`). `server.py` +`_run_isolated` uses `sys.executable` (the venv python) + `dirname(__file__)` — +both correct once spawned with the venv interpreter and `cwd:codeDir()`. + +--- + +## 3. Python + venv + deps bootstrap (uv, sizes, pinned lockfile, no git) + +New `src/main/bootstrap.ts`. Reuses the exact streaming pattern of +`downloadModel` (`localModels.ts:111-148`): spawn → parse newline JSON → forward +to a renderer channel `bootstrap`. Model download stays the separate +`downloadModel` flow, invoked after `deps` completes. + +**Phases:** `detect → python → venv → deps → harden → verify → stamp → done`. +Every step is idempotent and gated on its own readiness so a killed run +re-enters cheaply. + +```ts +type Phase = 'detect'|'python'|'venv'|'deps'|'harden'|'verify'|'done'; + +export function detect(): BootState { + const venvReady = fs.existsSync(venvPython()); + const m = readManifest(); // runtimeDir()/venv-manifest.json + const depsReady = venvReady && m?.lockSha256 === shippedLockSha(); + return { pythonReady: uvPythonPresent(), venvReady, depsReady }; +} +``` + +**`install()`** — all uv state redirected under `runtimeDir()` so nothing writes +into the bundle: + +```ts +const RT = runtimeDir(); +const uvEnv = { ...process.env, ...localEnv(), + UV_PYTHON_INSTALL_DIR: path.join(RT, 'uv', 'python'), + UV_CACHE_DIR: path.join(RT, 'uv', 'cache') }; + +// 0. preflight — reuse localCapabilities() (arm64, macOS≥14, RAM≥32) + free disk ≥25GB (deps+Fast tier) +await run(uvBin(), ['python','install','3.12'], uvEnv, 'python'); // skip if pythonReady +await run(uvBin(), ['venv', venvDir, '--python','3.12'], uvEnv, 'venv'); // skip if venvReady +await run(uvBin(), ['pip','sync','--python',venvPython(),'--require-hashes', // deps + '--find-links', path.join(process.resourcesPath,'wheels'), + path.join(process.resourcesPath,'requirements.macos.lock')], uvEnv, 'deps'); +await hardenProvisionedTree(venvDir, path.join(RT,'uv','python')); // §4 +await verifyRuntime(); // §4 post-harden exec probe +writeManifest({ schema:1, python:'3.12', lockSha256: shippedLockSha(), + wheels:{ mlx_video: MLX_VIDEO_SHA, torch: TORCH_VER }, + completedAt:new Date().toISOString(), platform:'macos-arm64' }); +``` + +**`uv venv` interpreter mechanics (Apple-Silicon-specific).** `uv python install +3.12` unpacks an Astral python-build-standalone interpreter into +`UV_PYTHON_INSTALL_DIR`; that directory's `bin/python3.12` + `lib/libpython3.12.dylib` +are the **real Mach-Os** that get `exec`'d. `uv venv` then creates +`.venv/bin/python` as a symlink (or, with `--copies`, a copy) of that +interpreter — matching what we see in the dev repo, where `.venv/bin/python → +python3.12 → /opt/homebrew/.../python3.12`. The harden step therefore must reach +**both** `UV_PYTHON_INSTALL_DIR` (the standalone interpreter + libpython) and +`.venv` (any copied binary + the wheels' `.so`/`.dylib`). Passing both dirs to +`hardenProvisionedTree` is correct; the per-Mach-O iteration inside it is what +makes it work (§4). + +**Dependency manifest — the lock.** `requirements.macos.lock` is generated in CI +by `uv pip compile --generate-hashes` from a macOS-only input and installed with +`--require-hashes` for byte-reproducibility. It references the **vendored** +`mlx_video` wheel by hash (not `git+https`), so **no git binary is invoked at +runtime** — closing the setup.sh dependency on a repo checkout. Contents (from +`requirements.txt`, minus the git URL): + +| Package | Role | Notes | +|---|---|---| +| `mlx`, `mlx-lm`, `mlx-vlm` | core + LLM + VLM | pulled by mlx-video; small | +| `mlx_video` (vendored whl) | Wan i2v engine | pure-python wheel by hash | +| `mlx-whisper` | STT | | +| `mflux` | FLUX keyframes | | +| `huggingface_hub` | downloads | | +| **`torch`** | **runtime** VAE decode | **mandatory — see below** | +| `rife-ncnn-vulkan-python-tntwise` | interp | native `.so` | +| `realesrgan-ncnn-py` | upscale | native `.so` | +| `numpy`, `safetensors`, `pillow`, `transformers`, `imageio`, `tqdm` | transitive | | + +**Blocker resolved — torch is a RUNTIME dependency, not convert-only.** +`requirements.txt:5-7` comments torch as "needed ONLY at convert time," and +Design B proposed dropping it with the convert path. That is wrong for the +shipped default. `tiny_vae.py:26` (`import torch`) + `taehv_upstream.py` implement +the TAEHV tiny-VAE decode, invoked by `wan_i2v.py:168-170` (`import tiny_vae; +tiny_vae.patch()`) whenever the `tiny_vae` flag is set — and `localVideo.ts:85` +sets it **on by default** for the non-hd path (`envBool('VB_LOCAL_TINY_VAE', +!isHd)`). Grep confirms `import torch` appears only in `tiny_vae.py` and +`taehv_upstream.py`, i.e. purely at *decode* time, and the shipped default +(14B fast / bf16-relay) hits that decode. **torch stays mandatory in the lock and +vendored wheels.** Only its use as the `mlx_video.models.wan_2.convert` tool goes +away (the convert path is deleted in §5). The macOS-arm64 torch wheel is ~60–90 MB +compressed; it dominates the deps download. + +**Sizes.** uv binary ~40 MB (shipped, not downloaded). uv-managed CPython 3.12 ≈ +30–45 MB download / ~120 MB on disk. Wheels ≈ 400–600 MB download (torch + mlx + +transformers + numpy) / ~1.0–1.5 GB installed. Show a **"~500 MB, 2–4 min"** +estimate up front. Because torch and the `.so`-carrying ncnn wheels are vendored +in `Resources/wheels`, `uv pip sync --find-links` can install fully **offline** if +the user provisions on a metered/absent connection — only the *models* (§5) need +the network. + +**Progress + resumability.** `run()` streams uv's per-package stdout; the `deps` +phase parses `Prepared/Installed N/T` lines into `{event:'progress',phase:'deps', +pct}`. `python`/`venv`/`harden`/`verify` emit coarse phase-start/end. Weighted +total pct = python 8 / venv 4 / deps 68 / harden 12 / verify 8. `uv pip sync` +reconverges the venv to the lock from *any* partial state, and uv's cache makes +re-entry cheap; a shipped-lock change flips `lockSha256` and triggers a delta +re-sync. + +**IPC** (`index.ts`, alongside `models:download` at :303): +```ts +ipcMain.handle('bootstrap:status', () => ({ ...detect(), caps: localCapabilities() })); +ipcMain.handle('bootstrap:start', () => { BOOTSTRAP.set('engine', run); + return install(ev => win?.webContents.send('bootstrap', ev)).finally(()=> BOOTSTRAP.delete('engine')); }); +ipcMain.handle('bootstrap:cancel', () => { bootChild?.kill(); return true; }); +``` +`preload/index.ts` gains `bootstrapStatus()`, `startBootstrap()`, `onBootstrap(cb)`, +`cancelBootstrap()`, cloning the `downloadModel`/`onDownload` shape +(`preload/index.ts:75-89`). Concurrency: track `BOOTSTRAP` as a `Map` like +`DOWNLOADS` (`index.ts:135`); `guardRender` (`index.ts:173-193`) and +`guardPortrait` (`index.ts:197-202`) refuse while it is non-empty. + +--- + +## 4. Gatekeeper / notarization handling — exact mechanism + +**No entitlement change and no plist change.** The notarized, hardened, +Developer-ID parent can `posix_spawn` a `userData` Python that `dlopen`s unsigned +torch/mlx/ncnn dylibs because **library validation and hardened runtime are +per-process and do not cross the `exec` boundary** — the ad-hoc, non-hardened +child carries no `CS_REQUIRE_LV`. `build/entitlements.mac.plist` already declares +`allow-jit`, `allow-unsigned-executable-memory`, `disable-library-validation`, +`allow-dyld-environment-variables`; none governs the child, and spawning needs +none. **Do not** add `LSFileQuarantineEnabled` to Info.plist and **do not** +sandbox the app — files uv/Node write into `userData` carry no +`com.apple.quarantine` because the app is not sandboxed. This is the +Pinokio/ComfyUI-Desktop posture. + +The **one required** mechanic is neutralizing the Apple-Silicon "no valid +signature → SIGKILL/hang on exec" trap. The corrected harden phase: + +```ts +async function hardenProvisionedTree(...dirs: string[]) { + for (const d of dirs) { + // Best-effort insurance only. quarantine IS removable; provenance is SIP-protected + // and this call is EXPECTED to no-op (kernel-managed) — it is NOT what fixes exec. + await execFile('xattr', ['-rd', 'com.apple.quarantine', d]).catch(()=>{}); + await execFile('xattr', ['-rd', 'com.apple.provenance', d]).catch(()=>{}); + } + // THE FIX: ad-hoc re-sign EVERY Mach-O individually (never --deep, never a bundle assumption). + const machos = await collectMachOs(dirs); // interpreter binaries + libpython + every *.so/*.dylib + for (const f of machos) { + // Ad-hoc ONLY — never --options runtime,library (that re-enables LV and blocks unsigned mlx/ncnn). + await execFile('codesign', ['--force', '--sign', '-', f]); // throws → bootstrap fails loudly + } +} +``` + +`collectMachOs` = `find -type f \( -name '*.so' -o -name '*.dylib' -o +-perm -u+x \)` filtered to Mach-O (magic `0xcafebabe`/`0xfeedfacf`, or +`file`/`otool -h`), plus the explicit interpreter binaries +(`.venv/bin/python3.12`, `uv/python/**/bin/python3.12`) and every `libpython*.dylib`. +Each is re-signed; any `codesign` non-zero **fails the bootstrap** with a +phase-attributed error (unlike the `xattr` lines, which swallow errors). + +**Why the corrections matter (verifier majors resolved):** + +- **`xattr -rd com.apple.provenance` is a no-op.** It is kernel-managed and + SIP-protected: `xattr -d` returns 0 but leaves the attribute. The design/research + treated it as one of two load-bearing operations; it does nothing. The **re-sign** + is what clears AMFI/`syspolicyd`, by minting a fresh cdhash on files that were + freshly written at a fresh path (uv → `userData`). The design text and code + comments are corrected to say exactly this; the provenance strip is downgraded to + harmless best-effort with a "expected to no-op" comment. `com.apple.quarantine` + removal is kept as cheap insurance (that xattr *is* removable). + +- **The harden rationale is AMFI mandatory-signing-on-exec, not pip dylib + rewriting.** Modern binary wheels (mlx, torch, ncnn) are pre-delocated by the + wheel builder; `uv pip sync` unpacks them and does **not** run `install_name_tool` + at install, so their `.so`/`.dylib` keep valid build-time ad-hoc signatures and + `dlopen` fine into a non-LV child anyway. The genuinely-must-re-sign target is the + **interpreter** produced/copied by uv (+ its `libpython`). Re-signing all Mach-Os + is belt-and-suspenders and is kept, but the *reason* the phase exists is the + interpreter, so `collectMachOs` must unambiguously include it. + +- **Tahoe (macOS 26) is the ship target.** The preflight floor stays macOS ≥14 + (`localCapabilities`), but Tahoe enforces `AppleSystemPolicy` harder (silent kill + / `_dyld_start` hang, no dialog, no log) and removed the `spctl` disable escape + hatch, so an incomplete re-sign is unrecoverable by the user. The re-sign + approach still works on Tahoe *because* files are fresh at a fresh path and each + Mach-O is re-signed. This is a manual QA gate (§9, M3m): download → open a + genuinely quarantined `.dmg` → bootstrap → confirm `/health` on real macOS 26 + hardware. + +**Post-harden exec probe (`verifyRuntime`) — resolves the silent-failure major.** +A broken signature does not raise the "damaged" *dialog* for a `posix_spawn`'d +child; it surfaces later as a sidecar that dies on spawn and times out on +`/health` — a generic "sidecar didn't start" with no cause. So the bootstrap adds: + +```ts +async function verifyRuntime() { + const env = { ...process.env, ...localEnv() }; // the REAL spawn env + const r = await execFile(venvPython(), ['-c', 'import mlx.core, sys; print(sys.version)'], + { env, timeout: 30_000 }).catch(e => e); + if (r?.killed || r?.signal || r?.code) throw new BootError('verify', + 'On-device runtime could not be signed to run on this Mac. See logs for the signing step.'); +} +``` +A SIGKILL, timeout, or non-zero exit becomes a **visible, phase-attributed** +bootstrap error, converting the silent runtime death into an actionable message. + +**Signing uv itself.** `bin/uv` is Astral-signed + notarized upstream; it ships as +a Mach-O in `Resources/bin/uv`. electron-builder walks nested Resources Mach-Os and +re-signs them under our Developer-ID + hardened runtime during the notarize path. +To be explicit, `scripts/mac-sign.js` adds `codesign --force --options runtime +--timestamp /Contents/Resources/bin/uv` **before** the `notarize()` call at +`mac-sign.js:24` (hardened runtime on *our* tool is fine — it is the *provisioned* +python that must stay ad-hoc/non-LV). The no-creds ad-hoc dev path +(`mac-sign.js:32` `--deep`) already covers it. + +--- + +## 5. Model provisioning — manifest, pre-converted vs community, HF-org dependency + +**Delete the download-then-convert path.** `download.py:34-87` `download_video()` +snapshots the ~120 GB fp32 `Wan-AI/Wan2.2-I2V-A14B`, runs +`mlx_video.models.wan_2.convert`, and needs torch as a *convert* tool. A `.dmg` +user cannot do 120 GB + a multi-hour convert. It becomes a plain +`snapshot_download` of a **pre-converted MLX** repo selected by engine, then a +per-engine marker write. + +**`VIDEO_ENGINES` manifest** (one constant, mirrored in `download.py` and +`localModels.ts`, keyed off `settings.localVideoModel`): + +| Engine | Repo | Size | Marker | Fits 32 GB? | +|---|---|---|---|---| +| `5b` Fast (target default) | `lBroth/FastWan2.2-TI2V-5B-MLX` | ~24 GB | `.model-path-5b` | **yes** | +| `14b` Quality (opt-in) | `lBroth/Wan2.2-I2V-A14B-MLX-bf16` (**our own**, published 2026-07-06) | ~64 GB | `.model-path` | fits 48 GB via relay-shedding (peak 32.6 GB — one expert at a time; Q4/Q8 keep both resident and peak 67.7 GB) | + +**Recommended (mandatory) set — the keyless render prerequisites.** +`RENDER_STAGES` (`index.ts:142-147`) is STT + LLM + KEYFRAME + VIDEO (VLM is +portrait-only). So the mandatory set is exactly those four: + +| Stage | Repo | Size | +|---|---|---| +| STT | `mlx-community/whisper-large-v3-turbo` | ~1.6 GB | +| LLM | `lmstudio-community/Qwen3.6-35B-A3B-MLX-4bit` | ~20 GB | +| KEYFRAME | `dhairyashil/FLUX.1-schnell-mflux-4bit` | ~9.6 GB | +| VIDEO (5b) | `lBroth/FastWan2.2-TI2V-5B-MLX` | ~24 GB | +| **Lightning LoRA** (14b tier only) | `lightx2v/Wan2.2-Lightning` (I2V-A14B-4steps) | ~2.5 GB | + +Fast tier total ≈ **~55 GB**; 14B interim tier ≈ 31 GB (STT+LLM+KEYFRAME) + 43 GB +(Q8) + 2.5 GB (Lightning) ≈ **~77 GB**. VLM (`gemma-3-12b-it-4bit`, ~8 GB) and +FLUX-Kontext stay optional (portrait/reference only). + +**Major resolved — KEYFRAME must not be gated on Kontext.** +`STAGE_REPOS['KEYFRAME']` lists both `FLUX.1-schnell` *and* +`akx/FLUX.1-Kontext-dev-mflux-4bit` (`download.py:25`, `localModels.ts:16`), and +`modelStatus()` gates KEYFRAME on `repos.every(repoReady)` (`localModels.ts:100`). +Since the recommended set ships only schnell, KEYFRAME would stay `absent` after +the recommended download and `guardRender` would refuse. **Split KEYFRAME:** +schnell is the render prerequisite; Kontext is a separate **optional** repo +(portrait/reference only). Concretely: +- `STAGE_REPOS['KEYFRAME'] = ['dhairyashil/FLUX.1-schnell-mflux-4bit']` (render + prereq). +- Add `OPTIONAL_REPOS['KEYFRAME_KONTEXT'] = + ['akx/FLUX.1-Kontext-dev-mflux-4bit']`, downloaded on demand by the + portrait/reference flow, not gated by `guardRender`. +- Fix `App.tsx:468` KEYFRAME size from `~15 GB` to `~9.6 GB` (schnell only). + +**Major resolved — Lightning LoRA is mandatory for the 14B fast tier.** +`localVideo.ts:90-104`: the 14B non-hd (default `localQuality:'fast'`) path runs 4 +steps *only if* `lightningLoras()` returns a LoRA; absent it and with +`VB_LOCAL_WAN_STEPS` unset, `wan_i2v` falls back to the model-config **40 steps** +(~38 min/clip — `localVideo.ts:106-107` warns it "would time out EVERY clip" +against the 1800 s deadline). So while the 14B interim default is the shipped +video engine, the **Lightning LoRA is in the mandatory set for that tier**. Load +must be verified against the *shipped* 14B weights (Q8, not just the dev bf16 — +§10). As a belt-and-suspenders fallback, the bootstrap can pin +`VB_LOCAL_WAN_STEPS` low if the LoRA is absent, but the correct fix is to +download it. + +**Major resolved — VIDEO readiness must be engine-aware.** +`videoReady()` (`localModels.ts:41-48`) reads only `VB_LOCAL_WAN_DIR || +.model-path` (the 14B marker), so a downloaded 5B (marker `.model-path-5b`) +reports `absent` and `guardRender` refuses — blocking the entire Fast tier. +Mirror the engine-side `modelDir()` (`localVideo.ts:32-35`): +```ts +function videoReady(): boolean { + const is5b = getSettings().localVideoModel === '5b'; + const marker = is5b ? '.model-path-5b' : '.model-path'; + const override = is5b ? process.env.VB_LOCAL_WAN_5B_DIR : process.env.VB_LOCAL_WAN_DIR; + try { + const dir = (override || fs.readFileSync(path.join(markerDir(), marker), 'utf8')).trim(); + return Boolean(dir) && fs.existsSync(path.join(dir, 't5_encoder.safetensors')); + } catch { return false; } +} +``` +The `t5_encoder.safetensors` gate passes for both shipped repos (Anes1032 Q8 has a +real ~11 GB `t5_encoder`; FastWan-5B has one once inlined — see below). + +**`download_video()` rewrite** collapses to the same clean, resumable, +progress-reporting `snapshot_download` + `HfApi.model_info` sizing path the +non-VIDEO branch already uses (`download.py:117-151`): pick the repo from +`VB_LOCAL_VIDEO_MODEL`, `snapshot_download(repo, +local_dir=models_dir/)`, write the correct per-engine marker into +`VB_LOCAL_MARKER_DIR`. No torch, no convert, one code path. + +**HF-org dependency (D1).** Spot-checks: `Anes1032/Wan2.2-I2V-A14B-mlx-q8` exists +(~42.7 GB, real `t5_encoder`, `config.json`) but surfaces library tag `mlx` (not +`mlx-video`) — **load-test it through the Blaizzy mlx-video pipeline before +committing**. **RESOLVED 2026-07-06:** `lBroth/FastWan2.2-TI2V-5B-MLX` is +**published, public, ungated, self-contained** (model.safetensors 10.0 GB + +t5_encoder 11.36 GB + vae 2.82 GB = 24.18 GB; the dev-box symlinks were followed +by `upload_folder` so the real content is on the Hub). The publish step +must **inline them as real files** (~10 GB model + ~11.4 GB T5 + ~2.8 GB VAE ≈ 24 +GB). Its `config.json` carries the `fastwan_dmd` block the engine reads +(`wan_i2v.py:138-140`), which the community base 5B repos lack — so no community +substitute exists. This single repo is the only mandatory-before-ship +re-host (§10). + +--- + +## 6. First-run UX + in-app CTAs replacing "run setup.sh" + +New `renderer/EngineSetup.tsx` — one component, reused in onboarding and Settings. +It renders off the tri-state `engineState()` (§7): + +- `not-bootstrapped` → **"Set up on-device engine"** → `vb.startBootstrap()`, live + progress bar (reuse `ModelDownload` markup, `App.tsx:544-571`), "~500 MB, 2–4 + min" estimate, and a **"Do this later"** escape (bootstrap is resumable — safe to + defer). +- `partial` (venv ready, models pending) → **"Download models (Fast · ~55 GB)"** + driving the recommended set (§5). +- `ready` → green "On-device ready". + +**Onboarding wiring** (`Onboarding.tsx`). Steps are `'welcome' | 'keys' | 'done'` +(`Onboarding.tsx:54`). Insert a `'provision'` step between welcome and done, shown +only when `caps.supported` (`Onboarding.tsx:57`). On **supported**, the "Skip — +stay local" button (`Onboarding.tsx:96-98`) advances to `'provision'` (mounting +`EngineSetup` with a persistent "Do this later") instead of calling `goDone()`. On +**unsupported**, the flow is unchanged — it routes to `'keys'` (cloud needs a key), +the current nudge. Provisioning never blocks finishing onboarding. + +**Kill the four dead-end `setup.sh` strings** — each becomes the `EngineSetup` +CTA: +- `App.tsx:679-682` HardwareCard `!caps.depsInstalled` branch ("run `bash + local/setup.sh`") → embed ``. +- `sidecar.ts:58` throw → "Set up the on-device engine in Settings → On-device." +- `localModels.ts:118` → same. +- `localVideo.ts:52` → "Download the on-device video model in Settings." + +The three CTAs — **Install engine / Download {stage} / Render** — map 1:1 to +`not-bootstrapped / partial / ready`. + +--- + +## 7. depsInstalled / guardRender extension for the hybrid + +Today `depsInstalled = fs.existsSync(localPython())` (`localModels.ts:75`) is +folded into `localRunnable` (`autoconfig.ts:44`), so on a *bootstrappable* but +un-provisioned Mac a local-resolved stage gets `localAvailable:false` +(`autoconfig.ts:48`) and `guardRender` tells the user to "pick Cloud" +(`index.ts:184-186`) — wrong; they should be told to install the engine. + +**Decouple.** In `resolveConfig` (`autoconfig.ts:44`), set `localRunnable = +Boolean(caps.supported)` — **hardware only**. `localAvailable` now means "this Mac +*can* run it on-device" (bootstrap + download are separate, in-app-fixable steps), +so the resolver never nudges a supported machine to cloud. `depsInstalled` stays +in `LocalCapabilities` for the UI, with its comment corrected to "engine +bootstrapped (venv present)." + +**New `engineState()`** — a discriminated status the UI and guard both read: +- `'unsupported'` — `!caps.supported`. +- `'not-bootstrapped'` — supported, `!fs.existsSync(venvPython())`. +- `'partial'` — bootstrapped, but some **locally-resolved required** stage + `modelStatus() !== 'ready'`. +- `'ready'` — bootstrapped + every locally-resolved required stage ready. + +**Major resolved — `partial` must consider only *locally-resolved* stages.** +Design B put `engineState()` in `localModels.ts`, which has no access to the +resolver, so it would check *all* required stages and show `partial` for a user +who set VIDEO→cloud (Kling) despite being fully provisioned for their hybrid. +`guardRender` is already correct here — it skips cloud stages (`index.ts:181 if +(rs.backend !== 'local') continue`). So `engineState()` takes +`resolveConfig().stages` as an argument (computed in `index.ts`, where the +resolver is already called via `resolvedConfig()`), and only considers stages +where `stages[key].backend === 'local'`. This matches `guardRender` exactly. + +**`guardRender` (`index.ts:173-193`)** gains one branch, ordered *before* the +model check (`index.ts:188`), every message an in-app CTA: +1. `DOWNLOADS.size || BOOTSTRAP.size` → "wait" (extend `index.ts:175`). +2. For each stage where `rs.backend === 'local'` (`index.ts:181`): + - `!rs.localAvailable` → hardware can't run it → existing key/cloud nudge + (`index.ts:184-186`). + - **new:** supported but `engineState()==='not-bootstrapped'` → refuse "Set up + the on-device engine in Settings → On-device." + - `modelStatus()[key] !== 'ready'` → refuse "Download {label} in Settings" + (existing `index.ts:188-190`). +3. GPU-busy (existing `index.ts:191`). + +**Renderer.** `StageBackendRow` (`App.tsx:600-639`) mounts `ModelDownload` only +when `localResolved && rs.localAvailable` (`App.tsx:635`). Wrap it: if +`engineState()==='not-bootstrapped'`, render `` (Install CTA) +instead of `ModelDownload` (Download CTA) — the same tri-state, per row. The +`HardwareCard` branch (`App.tsx:679-682`) hosts the primary `EngineSetup`. + +--- + +## 8. electron-builder changes + +Today `build.files = ["dist/**/*","renderer-dist/**/*","icons/icon.png"]` — `local/` +is not bundled (the root cause). Add `extraResources` (lands in +`Contents/Resources`, **outside** `app.asar`, so **no `asarUnpack`** is needed for +the sidecar; the existing `asarUnpack` for ffmpeg/ffprobe-static stays): + +```jsonc +"extraResources": [ + { "from": "local", "to": "local", "filter": [ + "*.py", "requirements.txt", + "!bench*.py", "!smoke_*.py", // resolves the *.py over-ship minor + "models/rife-v4.26/**", "models/taew2_1.safetensors" + ]}, // excludes .venv, big models/, dt/, __pycache__, setup.sh + { "from": "build/bin/uv", "to": "bin/uv" }, + { "from": "build/wheels", "to": "wheels" }, + { "from": "local/requirements.macos.lock","to": "requirements.macos.lock" } +] +``` + +- **Minor resolved — the `*.py` over-ship.** A bare `"*.py"` matches `bench.py`, + `bench_14b.py`, `bench_run.py`, `bench_steps.py`, `smoke_5b_esrgan.py`. They are + inert (server.py's import closure is manager, wan_i2v, fastwan_dmd, + relay_generate, tiny_vae, taehv_upstream, stt, llm, vlm, keyframe, interp, + upscale — none of the bench/smoke files), so shipping them is harmless, but the + negative filters `!bench*.py`, `!smoke_*.py` keep the bundle honest. `dt/` + (Draw-Things CLI experiment) is excluded by omission. +- **Minor resolved — rife assets.** Ship `models/rife-v4.26/**` + + `models/taew2_1.safetensors` only (no `rife-v4.25`, which does not exist). They + are git-ignored (`.gitignore:20 local/models/`), so force-track exactly these: + `git add -f local/models/rife-v4.26 local/models/taew2_1.safetensors`. Otherwise + interp silently falls back to the wheel's 2022 `rife-v4.6` (`interp.py:34`). +- **`uv`** (~40 MB) → `build/bin/uv`, a pinned Astral release, `codesign`ed in + `mac-sign.js` (§4). +- **`wheels/`** → `build/wheels/*.whl`, including the vendored pure-python + `mlx_video` wheel **and torch** (so first run needs no git and can install + offline). Inert zip data — no signing. +- **`requirements.macos.lock`** → CI-generated `uv pip compile --generate-hashes`, + installed `--require-hashes`. References the vendored mlx-video wheel hash, not + `git+https` → no git at runtime. + +`build.files`, `asarUnpack`, `mac.hardenedRuntime`, `entitlements`, and both +sign/notarize hooks are otherwise unchanged. **`entitlements.mac.plist`: no +change.** + +--- + +## 9. Commit roadmap M3a…M3n + +Each commit keeps `npm test` (`typecheck` + `tsx --test test/*.test.ts` + +`check-no-cloud`) and `npm run build` green. "CI-testable" = fully validated in +CI; "hardware-only" = needs a real notarized/quarantined `.dmg` on Apple Silicon +that CI cannot produce. + +| # | Commit | Scope | Validation | +|---|---|---|---| +| **M3a** | `paths.ts` + inject `localEnv()` | New `src/main/paths.ts`; `sidecarEnv()` += `localEnv()` (`index.ts:84`); delete dup resolvers (`localModels.ts:20-30`); `readMarker`→`VB_LOCAL_MARKER_DIR` (`sidecar.ts:24-31`); `download.py` markers→`VB_LOCAL_MARKER_DIR`. Dev unchanged (runtimeDir≡codeDir). | CI-testable | +| **M3b** | HF-cache unification | `hfCacheDir()` into the `server.py` spawn env (`sidecar.ts:61-67`) + download spawn (`localModels.ts:122`); `localModels.hfCache()`→`paths.hfCacheDir()`. | CI-testable (unit: all three read one path) | +| **M3c** | VIDEO readiness engine-aware | `videoReady()`/`modelStatus` read `.model-path-5b` when `localVideoModel==='5b'` (`localModels.ts:41-48`). | CI-testable (unit) | +| **M3d** | KEYFRAME split | schnell = render prereq; Kontext → `OPTIONAL_REPOS`; fix `every()` gate; `App.tsx:468` size→~9.6 GB. | CI-testable (unit) | +| **M3e** | Tri-state `engineState()` + resolver decouple | `autoconfig.ts:44` `localRunnable=caps.supported`; `engineState(stages)`; `guardRender` install-engine branch (`index.ts:181-190`). | CI-testable (unit) | +| **M3f** | `download.py` rewrite | Delete convert path (`download.py:34-87`); `VIDEO_ENGINES` manifest (mirrored `localModels.ts`); per-engine snapshot + marker; Lightning in 14B mandatory set. | CI-testable (dry-run / lint); real download hardware-only | +| **M3g** | `bootstrap.ts` core | detect/install/manifest state machine + IPC (`index.ts`) + preload additions. Harden/verify stubbed to skip in dev. | CI-testable (compiles, detect() unit); install hardware-only | +| **M3h** | Harden + verify phases | `hardenProvisionedTree` (per-Mach-O re-sign), `verifyRuntime` exec probe. | **hardware-only** | +| **M3i** | Renderer UX | `EngineSetup.tsx`; Onboarding `'provision'` step; StageBackendRow tri-state; replace 4 `setup.sh` strings; size labels. | CI-testable (build + `VB_SMOKE`) | +| **M3j** | electron-builder + vendoring | `extraResources`; `git add -f` rife-v4.26 + taew2_1; `build/bin/uv`; `build/wheels/*.whl`; `requirements.macos.lock` (CI compile job); sign `uv` in `mac-sign.js`. | Build green in CI; packaged sidecar resolution hardware-only | +| **M3k** | ~~Publish `lBroth/FastWan2.2-TI2V-5B-MLX`~~ **DONE 2026-07-06** | Published public + self-contained (24.18 GB, symlinks followed on upload). | ✅ done | +| **M3l** | Load-test + confirm 14B interim | Load-test `Anes1032` Q8 through mlx-video; confirm Lightning loads on Q8; keep `DEFAULTS.localVideoModel='14b'`, hide Fast tile "coming soon". | hardware-only | +| **M3m** | Real-hardware harden QA gate | Download→open quarantined `.dmg`→bootstrap→`/health` on macOS 26 (Tahoe) **and** Sequoia. | **hardware-only (blocking ship)** | +| **M3n** | Flip default to 5b | Once M3k lands + load-tested: `DEFAULTS.localVideoModel='5b'` (`settingsSchema.ts:42`) + `videoModel()` default (`localVideo.ts:15`); re-enable Fast tile; recommended set→Fast (~55 GB). One-line data change behind `VIDEO_ENGINES`. | CI-testable + hardware smoke | + +M3a–M3f are safe refactors that leave dev byte-identical (runtimeDir≡codeDir). +M3g–M3j build the bootstrap and packaging. M3h and M3m are the two commits that +**cannot** be validated in CI and gate ship. + +--- + +## 10. Open decisions for the user + +- **D1 — pre-converted hosting. RESOLVED 2026-07-06:** published under the + **`lBroth`** namespace (not a `videoboom` org): `lBroth/FastWan2.2-TI2V-5B-MLX` + is live, public, self-contained (24.18 GB). The Fast tier can now provision + out-of-box. Optional later re-hosts (a pre-converted 14B, a shared T5) are + provenance insurance, not correctness — publish under `lBroth` the same way. + Superseded original text: **Decision needed:** create/authorize the org and upload, or + ship the 14B-only interim indefinitely. + +- **D2 — RESOLVED 2026-07-06.** Quality 14B ships as **our own `lBroth/Wan2.2-I2V-A14B-MLX-bf16`** (published) — the only variant that fits 48 GB, because relay-shedding (bf16-only) loads one expert at a time (peak 32.6 GB) whereas Q4/Q8 keep both experts resident (Q4 peaked 67.7 GB). RAM floor raised to **48 GB** (MIN_RAM_GB), 64 GB recommended. Default stays **FastWan-5B** (Fast, ~24 GB, fits 48 GB at the default 57f). Superseded original text: The current HEAD engine default + is **14B bf16 via relay-shedding** (`.model-path` → `Wan2.2-I2V-A14B-MLX-bf16`, + ~54 GB, `_wants_relay()` sheds one expert to fit 48 GB). No community bf16 MLX + exists, so bf16 would need a `videoboom` re-host and does **not** fit the app's + own 32 GB floor. Design B's interim uses `Anes1032` **Q8** (~43 GB, tag `mlx` + not `mlx-video`, unverified-loadable, also needs ~48 GB). The old default, + **Q4** (~18 GB), fits 32 GB but predates the bf16 quality overhaul. **Decision + needed:** which 14B weight ships as the interim default — Q8 (public, heavier, + verify-load), bf16 (best quality, re-host, 48 GB-only), or Q4 (fits 32 GB, + lower quality)? This also decides whether the 32 GB-floor promise + (`localModels.ts:50`) holds before FastWan-5B lands. + +- **D3 — uv strategy: vendor wheels vs download.** Plan vendors `mlx_video` + + torch + ncnn wheels in `Resources/wheels` (~500 MB in the `.dmg`, offline first + run) vs a thinner `.dmg` that `uv pip install`s from PyPI at first run + (network-dependent, but keeps mlx/torch auto-updatable). **Decision needed:** + fat offline `.dmg` (recommended for reliability on the notarization-sensitive + first run) vs thin online `.dmg`. + +- **D4 — Fast tier as default (the 5b flip).** Once D1 lands and the 5B repo is + load-tested, flip `DEFAULTS.localVideoModel` to `'5b'` (M3n) so the out-of-box + default fits the 32 GB floor. **Decision needed:** flip immediately on publish, + or keep 14B default and expose 5B as an opt-in Fast tile. + +- **D5 — Load-test confirmations (verify before commit).** (a) `Anes1032` Q8 + through the Blaizzy mlx-video pipeline; (b) Wan2.2-Lightning 4-step LoRA onto the + *shipped* 14B weights (Q8/bf16), not just the dev bf16; (c) the full + download→quarantined-`.dmg`→bootstrap→`/health` flow on **macOS 26 (Tahoe)**. + These are the empirical gates behind the §0 "YES-with-X" verdict. diff --git a/build/entitlements.mac.plist b/build/entitlements.mac.plist new file mode 100644 index 0000000..641a721 --- /dev/null +++ b/build/entitlements.mac.plist @@ -0,0 +1,34 @@ + + + + + + + com.apple.security.cs.allow-jit + + + + com.apple.security.cs.allow-unsigned-executable-memory + + + + com.apple.security.cs.disable-library-validation + + + diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 96d8168..4d80e19 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,8 +1,9 @@ # Architecture -Videoboom is an **open-source, bring-your-own-key desktop app**. An Electron shell runs an in-process -TypeScript render engine. Everything runs on the user's machine; the only network calls are to the model -providers the user configured with their own keys. See also `AGENTS.md`. +Videoboom is an **open-source, local-only desktop app**. An Electron shell runs an in-process TypeScript +render engine; every generation stage runs **on-device** (Apple Silicon / MLX) through a resident Python +sidecar. Nothing leaves the machine — the only network use is downloading the model weights once. See also +`AGENTS.md`. ``` ┌──────────────────────────────────────────────────────────────────────────────┐ @@ -12,36 +13,42 @@ providers the user configured with their own keys. See also `AGENTS.md`. │ window.vb (contextBridge; the only surface the UI can touch) ┌───────────────┴────────────────────────────────────────────────────────────────┐ │ Electron main (src/main) │ -│ · window + IPC │ -│ · API keys stored ENCRYPTED via OS keychain (safeStorage); decrypted in-mem │ +│ · window + IPC · no secrets — everything runs locally │ +│ · on-device model settings + downloads (settings.ts / localModels.ts) │ │ · runs the engine per operation; forwards its events to the UI (sidecar:) │ └───────────────┬─────────────────────────────────────────────────────────────────┘ │ runEngine(command, args, env, onEvent) — in-process, async ┌───────────────┴─────────────────────────────────────────────────────────────────┐ │ Render engine — TypeScript (src/engine/) │ -│ index(dispatch) · pipeline · providers · segment · ffmpeg · storage · cost │ +│ index(dispatch) · pipeline · stages · segment · ffmpeg · storage │ │ create-project · render(preview) · resume · regenerate-scene · character-* · │ │ get-project. state + media = plain files under the userData data/ dir │ -│ │ OpenRouter (story LLM · keyframes · video clips · moderation) │ -│ │ Replicate (forced-aligned transcription for vocal-locked editing) │ -│ ▼ ffmpeg/ffprobe (bundled static binaries) → final MP4 │ +│ │ stages.ts → local*.ts → sidecar (localhost HTTP) │ +└───────────────┬─────────────────────────────────────────────────────────────────┘ + │ POST /stt · /llm · /keyframe · /vlm · /i2v · /interp · /upscale +┌───────────────┴─────────────────────────────────────────────────────────────────┐ +│ On-device model sidecar — Python/MLX (local/server.py) │ +│ mlx-whisper · mlx-lm (Qwen) · mflux (FLUX/Kontext) · mlx-video (Wan 2.2) · │ +│ mlx-vlm (gemma) · RIFE · Real-ESRGAN. ffmpeg/ffprobe (bundled) → final MP4 │ └──────────────────────────────────────────────────────────────────────────────────┘ ``` -The engine runs **in the main process** and is **async throughout** — ffmpeg runs as child processes, -model calls are `fetch` — so the UI never blocks. There is no Python and no separate process; events flow -back through the same `onEvent` callback the IPC layer forwards to the renderer. +The TypeScript engine runs **in the main process** and is **async throughout** — ffmpeg runs as child +processes, model calls are async HTTP to the localhost sidecar — so the UI never blocks. Events flow back +through the same `onEvent` callback the IPC layer forwards to the renderer. The sidecar is started on +demand (`src/engine/sidecar.ts`) and stays warm so the heavy MLX import is paid once. ## The render pipeline (`src/engine/pipeline.ts`, dispatched by command) -1. **storyboard** — transcribe the song (forced-aligned via Replicate) → story bible (LLM) → shot list - (LLM, structured output). Plans the **whole song** (one record per scene, capped at `MAX_SCENES`), - each with the `cast` in that scene. A **preview** render does only the opening ~25%; **resume** - renders the rest, reusing the preview. -2. **scene** (bounded-concurrency pool) — per scene: generate a **keyframe** placing the scene's cast - (reference photos + identity-preserving prompt) → generate the **video clip** (Kling, first+last-frame - morph) → fit to the frame grid + thumbnail. A permanently-failed scene is **tolerated** (left - `failed`; the rest still assemble). +1. **storyboard** — transcribe the song on-device (mlx-whisper, per-word timing) → story bible (LLM) → + shot list (LLM, structured output). Empty words is a legitimate instrumental (proceeds in mood mode), + not an error. Plans the **whole song** (one record per scene, capped at `MAX_SCENES`), each with the + `cast` in that scene. A **preview** render does only the opening ~25%; **resume** renders the rest. +2. **scene** — per scene: generate a **keyframe** placing the scene's cast (reference photos + + identity-preserving prompt) → generate the **video clip** (Wan 2.2, one continuous shot of chained + native sub-clips from a single start frame) → **trim** to the frame grid + thumbnail. Video is + GPU-serialized (one clip at a time). A permanently-failed scene is **tolerated** (left `failed`; the + rest still assemble). 3. **assemble** — concatenate the available clips + the song → MP4 + a first-frame **poster** → `done`. Output is tagged as AI-generated in the file metadata. 4. **regenerate-scene** — surgical per-scene fix: re-render one scene (keeping the neighbor seam) and @@ -50,9 +57,10 @@ back through the same `onEvent` callback the IPC layer forwards to the renderer. portrait — the cast's reusable identity. ## Vocal-aligned editing -WhisperX-style forced alignment (Replicate) gives per-word timestamps; `src/engine/segment.ts` retimes -scenes onto a frame grid (`fitToWindow`) so cuts lock to the singing with no cumulative drift. A vocal -scene's start snaps to its first sung word. +On-device whisper gives per-word timestamps; `src/engine/segment.ts` snaps scene boundaries onto a frame +grid so cuts lock to the singing with no cumulative drift. Each chained clip is `trimToWindow`'d (frame-count +trim, real speed — never a setpts retime) to its exact slot; `assemble` then conforms every clip to a single +resolution (`conformClip`) before the concat. A vocal scene's start snaps to its first sung word. ## Identity consistency Keyframes are generated from the cast's reference portraits with a strong "reproduce every facial feature @@ -60,15 +68,15 @@ exactly, no blending/de-aging" prompt; `VB_MAX_SUBJECTS` must cover the whole ca dropped reference = the model invents that subject). The clip animates the keyframe, so keyframe identity = clip identity. -## Keys, storage & privacy -Keys are the user's, stored encrypted with the OS keychain (`safeStorage`) and passed to the engine in -memory per operation (`src/engine/config.ts`) — never logged, never written to the project store or git. -Projects (state JSON + media) are plain files under the userData `data/` dir (`src/engine/storage.ts`, -local filesystem only). +## Storage & privacy +There are no keys or secrets — every stage runs on-device. Projects (state JSON + media) are plain files +under the userData `data/` dir (`src/engine/storage.ts`, local filesystem only). The only network use is +downloading model weights (`local/setup.sh` / `local/download.py`); generation is fully offline. Boot +removes any stale `keys.json` left by the old cloud build. ## Packaging -`electron-builder` produces the native installers; `ffmpeg-static` / `ffprobe-static` are bundled and -`asarUnpack`ed (the engine rewrites `app.asar` → `app.asar.unpacked` in the binary path). No Python, no -PyInstaller. The only per-OS piece is the ffmpeg binary (downloaded by `npm install`), so each OS is -built on its own machine (or CI runner): Windows → NSIS installer + portable `.exe`; macOS → `.dmg`; -Linux → `.AppImage` + `.deb`. +`electron-builder` produces the macOS `.dmg`; `ffmpeg-static` / `ffprobe-static` are bundled and +`asarUnpack`ed (the engine rewrites `app.asar` → `app.asar.unpacked` in the binary path). On-device +generation uses the Python MLX sidecar (`local/`), installed once by `bash local/setup.sh` as a user-owned +venv — it is not bundled into the installer. The `renderer/fonts/` Inter woff2 ships in-app, so the UI makes +no external font request (the CSP stays `'self'`-only). diff --git a/docs/FOLDER-STRUCTURE.md b/docs/FOLDER-STRUCTURE.md index eabdc35..9ab0606 100644 --- a/docs/FOLDER-STRUCTURE.md +++ b/docs/FOLDER-STRUCTURE.md @@ -3,35 +3,39 @@ The repo root **is** the desktop app (Electron + React + an in-process TypeScript render engine). ``` -package.json the app: scripts (dev/build/dist) + electron-builder config (win/mac/linux targets) +package.json the app: scripts (dev/build/dist) + electron-builder config (macOS target) vite.config.ts renderer build (root = renderer/, relative base for file:// in the packaged app) tsconfig.json TS for src/ + renderer/ tailwind.config.js · postcss.config.js renderer styling src/ - main/ Electron main — window, IPC, runs the engine, OS-keychain key storage + main/ Electron main — window, IPC, on-device model settings + downloads index.ts app/window lifecycle + the IPC surface; streamOp() runs an engine op - keychain.ts BYOK secrets — safeStorage-encrypted keys.json; decrypt -> engine config - settings.ts non-secret model/render settings -> VB_* config + settings.ts local-only model/render settings (v2) -> VB_* config + localModels.ts on-device capability gate + per-stage model availability/downloads projects.ts read-only project/scene/character state off disk for the renderer preload/ contextBridge — exposes window.vb (the only surface the renderer can touch) - engine/ the render engine (in-process, async; replaces the old Python sidecar) + engine/ the render engine (TypeScript, in-process, async) index.ts runEngine(command,args,env,onEvent) dispatch; emits events, returns result pipeline.ts storyboard -> keyframe pass -> clip pass -> assemble; regen; portrait - providers.ts OpenRouter (LLM/keyframe/VLM/moderation/i2v) + Replicate WhisperX transcription + stages.ts shared stage helpers + schemas; delegate to the local*.ts on-device modules + localStt/Llm/Vlm/Keyframe/Video.ts per-stage wrappers over the Python MLX sidecar + sidecar.ts starts/keeps the localhost model sidecar warm; POST helper segment.ts vocal-phrase segmentation + frame-grid timing + energy windows - ffmpeg.ts bundled ffmpeg/ffprobe; probe, thumb, ->png, fit-to-grid, still-fill, PCM decode + ffmpeg.ts bundled ffmpeg/ffprobe; probe, thumb, ->png, trim-to-grid, conform, still-fill, PCM storage.ts local-filesystem state + media under the userData data/ dir - config.ts · cost.ts injected key/model config; informational cost accounting + config.ts injected VB_* model config selftest.ts offline ffmpeg self-test (VB_ENGINE_TEST=1) +local/ Python MLX model sidecar — server.py + per-stage runners; setup.sh / download.py renderer/ React + TS + Vite UI App.tsx Create / Videos / Cast / Settings components/ui.tsx reusable kit (Button, Field, Card, Modal, Spinner, …) + fonts/ bundled Inter woff2 (no external font request) main.tsx · index.css · vb.d.ts (window.vb types) icons/ app icon -docs/ ARCHITECTURE · FOLDER-STRUCTURE · PROVIDERS · ROADMAP · research/ +docs/ ARCHITECTURE · FOLDER-STRUCTURE · MODELS · LOCAL-MODELS · ROADMAP · research/ ``` Build outputs (gitignored): `dist/` (esbuild main+preload), `renderer-dist/` (vite), `release/` diff --git a/docs/LOCAL-MODELS.md b/docs/LOCAL-MODELS.md new file mode 100644 index 0000000..b402dd6 --- /dev/null +++ b/docs/LOCAL-MODELS.md @@ -0,0 +1,150 @@ +# Local models (on-device, Apple Silicon) + +Status: **image-to-video is always on-device — Wan 2.2 via mlx-video.** The Fast/Quality +switch in Settings IS the model choice: **Fast** = FastWan-5B (DMD 3-step draft), **Quality** += Wan I2V-A14B (bf16-relay; sharp x16 VAE, best quality, no people-deform, slower). Both +render at 480p on-device and **finish at 1080p** via a finish chain (RIFE interpolation → +Real-ESRGAN upscale → filmic grade) that runs on every render. + +User-facing setup + tunables live in [`local/README.md`](../local/README.md). This +page is the architecture for contributors. + +## Shape + +``` +Electron main (in-process TS engine) + │ genVideoLocal() — video is always on-device + ▼ +src/engine/localVideo.ts ──HTTP /i2v──▶ local/server.py ──▶ mlx-video (Wan 2.2) + build payload + POST resident process generate_video() + ▲ (process lifecycle: sidecar.ts) one job at a time + └── the sidecar writes the mp4 straight to the scene's tmp path ─┘ +``` + +- **Why a separate process, not in-process:** MLX/Wan is Python. The app's engine + is TypeScript and stays that way. The sidecar's Python deps and the model weights are + never bundled into the installer (Wan alone is ~118GB) — users provision them with + `local/setup.sh` (or Settings → On-device → Download). +- **Why resident:** loading a 14B model is expensive; the process stays warm for the + app's lifetime so import + weights are paid once, not per scene. +- **Lifecycle:** `sidecar.ts` lazily spawns `server.py` on the first stage call and + polls `/health`; `localVideo.ts` then POSTs one `/i2v` per sub-clip. The sidecar writes + the mp4 to the scene's tmp path (same machine, same FS — no bytes over the socket). A + global lock in the server serialises diffusion runs; the engine also runs a single + worker (`VB_WORKERS=1`). + +## Where it plugs in + +- `src/engine/pipeline.ts` → `renderClip()` always renders on-device: it calls + `renderLocalScene()`, which chains native-length sub-clips through `genVideoLocal()` + (each i2v continues from the previous clip's last frame) into one continuous shot. There + is no cloud branch and no `providers.ts` — every stage goes through `src/engine/stages.ts`, + which delegates to the `local*.ts` wrappers. +- Wan i2v takes a **single** start frame — there is no last-frame conditioning; a long + scene gets its motion from the chained sub-clips, not a first+last morph. +- The chained clip is already ≥ the scene window, so `renderClip()` finishes it with + `trimToWindow()` (cut the excess frames — no `setpts` retime, so real speed, never + slow-mo), and `assemble()` normalises every clip with `conformClip()` before the concat. +- `src/engine/ffmpeg.ts` → `vW()`/`vH()` are live env reads (not load-time consts) so the + on-device 480p resolution (`VB_W=832`/`VB_H=480`, injected by settings) reaches both Wan + generation and the failed-scene fill — every clip shares one size for concat. +- `src/main/settings.ts` → `localVideoModel` (`5b`/`14b`) + the hd flag; `settingsEnv()` + injects `VB_LOCAL_VIDEO_MODEL`, `VB_LOCAL_QUALITY`, the 480p `VB_W`/`VB_H`, and a single + worker (`VB_WORKERS=1`). + +## Finish chain (fluidity + resolution) + +- **RIFE 2x** (`/interp`, rife-v4.26 ncnn under `local/models/`): every sub-24fps clip + (the 14B is 16fps native) is interpolated to 2× right after i2v, so the 24fps conform + DECIMATES instead of duplicating frames (duplication = the old visible judder). + Per-clip ONLY — never interpolate an assembled timeline (it would morph across cuts). +- **Real-ESRGAN upscale** (`/upscale`): assemble runs the concatenated timeline to 1080p + (`VB_UPSCALE_H`), then a **filmic grade** (`VB_FINISH` = off|subtle|filmic: deband → + S-curve → micro-sharpen → temporal grain). Both best-effort — failures leave the raw cut. +- The rife/realesrgan ncnn wheels each bundle MoltenVK — importing both in one process + SEGFAULTS, so the sidecar runs each job in an isolated subprocess (`_run_isolated`). + +## Known limits + +- **Weights reload per call.** Upstream `generate_video` loads + frees the text encoder + + transformers + VAE each request (LTX reloads everything per scene too). Warm process + + OS page cache help; a true weight-resident denoise loop is the Model Manager TODO. +- **48GB memory ceiling.** Attention is O(seq_len²), so frames×resolution is hard-capped. + 480p / 37 frames is the stable point; 49f or 720p hit Metal "Insufficient Memory". +- **Slow & heavy.** ~5.5 min/clip at 480p/37f Lightning 4-step; a full song is long. +- **Slow-mo fixed** by cutting local scenes to ~native clip length and rendering the last + chained sub-clip only as long as the remaining window needs. + +--- + +## Full local mode — every stage on-device (shipped) + +The whole pipeline runs offline behind the same sidecar: no network at generation time, +weights downloaded once. + +### Per-stage on-device models +| Stage | On-device model | Notes | +|---|---|---| +| VLM (face caption) | **gemma-3-12b** (mlx-vlm) | anchors cast identity; safety fail-open | +| STT (lyric timing) | whisper-large-v3-turbo (mlx-whisper) | per-word timings, cached | +| Story / shot-list LLM | Qwen3.6-35B-A3B-4bit (mlx-lm + llguidance JSON) | grammar-constrained JSON | +| Keyframe image | FLUX schnell + FLUX Kontext (mflux) | schnell txt2img; Kontext for cast identity | +| Video i2v | **Wan 2.2** (mlx-video) | Fast = FastWan-5B / Quality = Wan-14B | + +### Model Manager (single-resident, load-on-demand + unload) +Stages run sequentially (LLM storyboard → all keyframes → all clips), so only **one** +model needs to be resident at a time → fits 48GB. `ensure(modelKey)`: if a different +model is resident, **unload** it (free the MLX cache), then load the requested one; it +stays resident **within** a stage (e.g. Wan across every clip) — which also removes the +per-clip reload. Sidecar endpoints behind the same GPU lock: `/llm`, `/keyframe`, +`/vlm`, `/stt`, `/i2v`. + +### Settings: enable → download, with guards +- The Settings **Download** button fetches each stage's model (hf download, + convert for + Wan), shown with live progress + a `ready | downloading | error` state. +- **A download BLOCKS generation**: render is refused while any required model is + downloading or not-ready, with a clear message (e.g. "Downloading Wan 2.2 — 45%"). +- **One render at a time**: a global render lock — a second render request while one is + running is blocked/queued. + +### Caveats +Huge one-time downloads (Wan ~118GB, Qwen ~19GB, FLUX ~10GB, whisper ~2GB) and slow (video +dominates) — the trade for running the whole thing offline on your own machine. + +> **Status:** the MLX (Apple Silicon) version of all of the above is **implemented** — +> model-agnostic sidecar (`/i2v /stt /llm /vlm /keyframe`), ModelManager, per-stage +> dispatch, download-gated Settings toggles + render guards, capability gate. + +--- + +## TODO — CUDA backend (Windows / Linux, NVIDIA) + +MLX is Apple-only. Windows/Linux run the same pipeline on **NVIDIA CUDA**, using **direct +Python libraries** — explicitly **no Ollama, no ComfyUI** (those are just wrappers; the Mac +sidecar already imports mlx libs in-process, and the CUDA sidecar mirrors that with torch). +The whole framework (model-agnostic sidecar, ModelManager, per-stage dispatch, download +gating, Settings UI, capability gate) is **reused as-is** — only the handler implementations +change from mlx to torch. + +### Per-stage libraries (direct, in-process) +| Stage | CUDA library | +|---|---| +| Video i2v | diffusers `WanImageToVideoPipeline` (Wan 2.2; FP8/GGUF) — faster on CUDA than MLX | +| Keyframe | diffusers `FluxPipeline` / `FluxKontextPipeline` | +| LLM | transformers `AutoModelForCausalLM`, or **llama-cpp-python** (GGUF — the lib Ollama wraps, used directly) | +| VLM | transformers (gemma-3 vision) | +| STT | faster-whisper / whisperx (already CUDA-native) | + +### Wiring +- `VB_LOCAL_DEVICE = mlx | cuda`, auto-detected (Apple Silicon → mlx, NVIDIA present → cuda); + each handler imports the matching backend. Could split `local/` into `backends/mlx` + + `backends/cuda` sharing one `server.py` + `manager.py`. +- ModelManager unload on CUDA = drop ref + `gc.collect()` + `torch.cuda.empty_cache()`. +- `localCapabilities()` extended: detect NVIDIA + VRAM via `nvidia-smi + --query-gpu=memory.total`; supported = (Apple Silicon ≥32GB) OR (NVIDIA ≥16GB VRAM). + +### Minimum hardware (NVIDIA — video Wan 14B is the bottleneck) +- **≥16GB VRAM** (FP8/Q4 + 480p) minimum; **24GB recommended** (RTX 3090/4090). +- 12GB only with heavy offload → very slow. System RAM 32GB+, fast SSD. +- AMD ROCm = immature → skipped for now. A machine below the bar simply can't run on-device + — there is no cloud fallback; `localCapabilities()` / `HARDWARE_SPEC` gate it and say why. diff --git a/docs/MODELS.md b/docs/MODELS.md new file mode 100644 index 0000000..4ae6956 --- /dev/null +++ b/docs/MODELS.md @@ -0,0 +1,26 @@ +# Models + +Every generation stage runs **on-device** (Apple Silicon / MLX) through a resident Python sidecar +(`local/server.py`, started on demand by `src/engine/sidecar.ts`). There are no cloud calls and no API +keys — the network is used only to download the model weights once. The TypeScript stage wrappers live in +`src/engine/stages.ts` and delegate to the `src/engine/local*.ts` modules. + +| Stage | Model (default) | Wrapper | Notes | +|-------|-----------------|---------|-------| +| Transcription / timing (STT) | `whisper-large-v3-turbo` (mlx-whisper) | `localStt.ts` | per-word timestamps → vocal-locked editing; zero words = instrumental (proceeds) | +| Story bible + shot list (LLM) | Qwen3 (mlx-lm) | `localLlm.ts` | structured JSON output | +| Keyframes | FLUX schnell + FLUX Kontext (mflux) | `localKeyframe.ts` | no ref → schnell txt2img; cast ref → Kontext, identity-preserving | +| Video (image-to-video) | Wan 2.2 — FastWan-5B (Fast) / I2V-A14B bf16-relay (Quality), mlx-video | `localVideo.ts` | Fast = FastWan-5B DMD 3-step; Quality = 14B Lightning; renders 480p, chained sub-clips per scene, both finish at 1080p | +| Portrait caption + upload safety (VLM) | gemma-3 (mlx-vlm) | `localVlm.ts` | caption anchors identity; safety is fail-open | +| Interpolation | RIFE (ncnn) | sidecar `/interp` | de-judders the 14B's native 16fps onto the 24fps timeline | +| Upscale | Real-ESRGAN | sidecar `/upscale` | one 480p → 1080p pass over the assembled timeline | + +## Provisioning + +`bash local/setup.sh` (or **Settings → On-device → Download**, which runs `local/download.py`) fetches each +stage's weights into the Hugging Face cache. The Wan video model is additionally **converted** to a +quantized MLX model, and its path is recorded in `local/.model-path` — that marker is the readiness check +for the video stage. A render is gated until STT, LLM, keyframe, and video models are all present. + +The Fast/Quality video choice (= FastWan-5B / Wan-14B) is set in `src/main/settings.ts` and injected as `VB_*` env vars. +See `docs/LOCAL-MODELS.md` for the detailed model notes and the finish chain. diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md deleted file mode 100644 index e55b4ad..0000000 --- a/docs/PROVIDERS.md +++ /dev/null @@ -1,28 +0,0 @@ -# Providers - -All generation is cloud (no on-device models), called **directly from the user's machine with the -user's own keys** (BYOK). Every model is a `VB_*_MODEL` env var with a default, and is **swappable from -Settings** — naming models here and in the UI is fine (this is BYOK, not a hosted service). - -| Stage | Provider | Default model (env) | Notes | -|------|----------|---------------------|-------| -| Transcription / alignment | **Replicate** | WhisperX (`REPLICATE_API_TOKEN`) | forced-aligned word timestamps → vocal-locked editing | -| Story bible | OpenRouter | `anthropic/claude-sonnet-4.6` (`VB_STORY_MODEL`) | | -| Shot list / LLM | OpenRouter | `google/gemini-3.5-flash` (`VB_LLM_MODEL`) | structured output | -| Keyframes | OpenRouter | `google/gemini-3-pro-image` (`VB_KEYFRAME_MODEL`) | identity from cast refs; GPT image needs `input_fidelity:high` | -| Video clips | OpenRouter | `kwaivgi/kling-v3.0-std` / `-pro` (`VB_OR_VIDEO_MODEL`) | first+last-frame morph; std=Fast, pro=HD | -| Image moderation | OpenRouter (vision) | `google/gemini-3.5-flash` (`VB_MODERATION_MODEL`) | SAFE/UNSAFE on a downscaled upload; fail-open | - -**Keys** (entered in Settings, encrypted via the OS keychain `safeStorage`, passed to the in-process -engine as config per op in `src/engine/config.ts` — never logged, never in git): - -| Key | env | Needed | -|-----|-----|--------| -| OpenRouter | `VB_OPENROUTER_API_KEY` | **required** (LLM, keyframes, video, moderation) | -| Replicate | `REPLICATE_API_TOKEN` | **required** (WhisperX forced-aligned transcription) | - -Two keys, both required: OpenRouter can't transcribe, so Replicate reads the lyrics + timing; everything -else runs through OpenRouter. - -**Cost**: OpenRouter returns real `usage.cost` (we send `usage:{include:true}`) → cents. The app shows -that **at-cost** total (no margin, no wallet). The user pays the providers directly. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index fff3763..0d6db21 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,45 +1,82 @@ # Roadmap -> Videoboom is now an **open-source, bring-your-own-key desktop app**. It went local-first (Phase 0) → -> AWS serverless SaaS (coins/Cognito/DynamoDB) → back to a desktop app, this time open-source + BYOK. -> The SaaS code was deleted (recoverable from git history). +> Videoboom is an **open-source, local-only desktop app** — a song becomes a music video entirely on the +> user's own Apple Silicon Mac (an MLX Python sidecar): no accounts, no keys, no cloud. History: local-first +> prototype → AWS serverless SaaS (coins/Cognito/DynamoDB) → bring-your-own-key cloud desktop +> (OpenRouter/Replicate) → today's local-only build; every earlier stage was deleted (recoverable from git +> history). ## Done - **Desktop app**: Electron shell (`src/main`, `src/preload`, `renderer/`) + an in-process TypeScript - render engine (`src/engine/`), keys stored encrypted via the OS keychain, no server, no Python. -- **Render pipeline**: story from the actual lyrics → shot list → identity-preserving keyframes → - per-scene video (Kling, first+last-frame) → beat-cut assemble, with tolerant failure handling. -- **Vocal-aligned editing**: forced alignment (Replicate) + frame-grid retiming; a vocal scene snaps to - its first sung word. + render engine (`src/engine/`); no accounts, no server, no keys. +- **On-device stack**: every stage runs locally through the MLX 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). Zero per-render cost, fully offline, nothing leaves the machine. +- **Render pipeline**: story from the actual lyrics → shot list → identity-preserving keyframes → per-scene + video (on-device Wan 2.2, single start frame chained into one continuous shot) → beat-cut assemble, with + tolerant failure handling. +- **Vocal-aligned editing**: on-device forced alignment (mlx-whisper) + frame-grid retiming; a vocal scene + snaps to its first sung word. - **Cast**: reusable characters from a photo and/or prompt; multi-subject scenes. - **Per-scene refresh**, **preview-then-resume**, AI-tagged output metadata. -- **BYOK + at-cost**: user pastes their own OpenRouter / Replicate keys; the app shows real provider - cost with no markup. -- **Packaging**: `npm run dist` → native installers (Windows NSIS + portable, macOS dmg, Linux - AppImage + deb); ffmpeg bundled (`ffmpeg-static`) so end users need no toolchain. -- **Sidecar removed**: ported the Python render engine to TypeScript (in-process, async) — one runtime, - no PyInstaller, simpler cross-OS builds. -- **CI/CD**: GitHub Actions — typecheck + unit tests + build and headless engine/smoke tests on all - three OSes; on a `v*` tag a matrix build publishes the installers to GitHub Releases (v0.1.0 shipped). - -## Next — local / on-device models (the BYOK idea, all the way) -Let the user run models on their **own hardware** instead of paying a cloud provider — zero per-render -cost, fully offline, maximum privacy. The engine is already provider-abstracted (every stage is a model -+ endpoint), so this is mostly wiring + Settings, staged easiest-first: -- **LLM (story + shot list)** — point at any **OpenAI-compatible local server** (Ollama / LM Studio / - llama.cpp) via a custom base URL + model in Settings. Smallest lift: the engine already speaks the - OpenAI chat schema; just make the base URL configurable per stage. -- **Transcription** — local **whisper.cpp / WhisperX** instead of Replicate (word timings on-device). -- **Keyframes** — local image generation (**ComfyUI / Stable Diffusion**) behind the same `cloudKeyframe` - seam. -- **Video (i2v)** — local open models (**Wan / SVD**) via ComfyUI; heaviest, needs a capable GPU / Apple - Silicon, so it lands last. -- Keep it **opt-in and mix-and-match**: e.g. local LLM + cloud video, chosen per stage in Settings. +- **Engine in TS, models in a sidecar**: the render/orchestration engine is in-process TypeScript (async, + never blocks the main process); heavy model inference runs in a resident Python MLX sidecar + (`local/server.py`), reached over localhost HTTP. +- **Packaging**: `npm run dist` → `electron-builder` (macOS dmg is the shipped target; the config still + carries the Windows/Linux targets that wait on the CUDA backend); ffmpeg bundled (`ffmpeg-static`) so end + users need no toolchain. +- **CI/CD**: GitHub Actions — typecheck + unit tests + build and headless engine/smoke tests on all three + OSes; on a `v*` tag a matrix build publishes the installers to GitHub Releases (v0.1.0 shipped). + +## Next — local-only hardening (`LOCAL_PLAN.md`) +Running every stage on the user's own hardware is no longer a future idea — it's the **shipped** +architecture (see the Done list and `docs/MODELS.md` / `docs/LOCAL-MODELS.md`). What's left is hardening it, +and the detailed roadmap lives in [`LOCAL_PLAN.md`](../LOCAL_PLAN.md) — not duplicated here. Milestones: +- **M0** reconciliation groundwork + CI guard scripts. +- **M1** finish cutting the last cloud code (ordered commits C1–C6). +- **M2** sidecar contract v2 + MLX handler refactor. +- **M3** bootstrap + a **CUDA backend** (Windows / Linux, NVIDIA) so the app isn't Apple-only. +- **M4** model catalog + downloader + Model Manager UI. +- **M5** hardware detection / tiers / setup wizard / auto-config + a real-time ETA estimator. +- **M6** lockdown + cross-platform packaging. + +## TODO — local i2v model options (researched 2026) +Shipped today: **Quality = Wan 2.2 I2V-A14B bf16-relay + Wan2.2-Lightning** (the open leader for cinematic +i2v with realistic people, fits 48GB via relay-shedding) and **Fast = FastWan-5B (DMD 3-step)** as the +draft/preview tier. LTX was tried and removed (LTX-2.3 22B is not lighter/faster than Wan-14B on Mac — the +"fast LTX" reputation is its CUDA distilled pipeline). Remaining ideas: +- **Watchlist — HunyuanVideo-1.5** (8.3B, Apache-2.0): lightest of the strong models, best motion/physics; + add once a mature **native MLX** runner ships (only an MPS port of the original Hunyuan exists today). +- Not worth it on Mac: CogVideoX / Mochi / SVD / Wan2.2-Animate (obsolete or CUDA-only). +- The full cross-platform model/quant/tier plan (incl. CUDA backend and a model catalog) lives in + `LOCAL_PLAN.md` (M0–M6). + +## TODO — format presets (music video / ad-spot / …) instead of free-text style +Let the Create screen pick a **preconfigured format preset** rather than only typing a free style. Each +preset is a different *storyboard director* (it swaps the LLM's story-bible + shot-list prompting and the +pacing), not just a style string — so the same engine (Suno song + local pipeline + Kontext subject +placement) produces music videos OR ads/spots. +- **music-video** (today): narrative bible, lyric-synced shots, emotional arc, performer/scenes. +- **ad / product / spot**: the PRODUCT is the hero — benefit-driven shots, lifestyle context, hero/product + close-ups, brand mood, punchier beat-synced cuts, and a closing CTA / logo moment. The "cast" generalises + to a **product reference image** placed into scenes via the existing Kontext seam (same mechanism as a face). +- Future presets: news, shorts/vertical, animation/toon, trailer. +- Build: a `format` field on the project; branch `storyBible` + `shotListPrompt` (the SYS + rules in + pipeline.ts) on it; a preset picker in Create (renderer); generalise cast → "subject" (person | product). + ~90% of the infra already exists — the work is the per-format director prompts + UI + product framing. + +## TODO — block re-triggering a generation that's already running +A render is already one-at-a-time in the backend (the main process refuses a second render / resume / +scene-regenerate while one is active, returning a clear error — no new job is created). Finish the UX: +- **Disable the action buttons** (Render, Finish full song, Regenerate scene, Create) while any generation + is in progress — show a busy/disabled state so a second trigger can't even be attempted. +- If one is somehow triggered anyway, surface the backend's "a render is already in progress" error in the + UI instead of silently doing nothing. ## Also next -- **Settings polish**: per-stage model picker + a "test key" button; show estimated cost before render. -- **More i2v models**: expose cheaper / alternative image-to-video models cleanly (keep `genVideo()` - model-agnostic). +- **Settings polish**: per-stage model picker (see `LOCAL_PLAN.md` M4/M5). +- **More i2v models**: expose alternative on-device image-to-video models cleanly (keep the local i2v path + in `genVideoLocal()` model-agnostic). - **Landing page**: GitHub Pages site (built; goes live once the repo is public). ## Parked / later diff --git a/local/README.md b/local/README.md new file mode 100644 index 0000000..02ac86c --- /dev/null +++ b/local/README.md @@ -0,0 +1,75 @@ +# Videoboom — local models (Apple Silicon / MLX) + +Opt-in, on-device generation. The first model wired up is **Wan 2.2 I2V-A14B** +(image-to-video) running MLX-native via [Blaizzy/mlx-video](https://github.com/Blaizzy/mlx-video). +No keys, no cloud, no per-second cost — but it is **slow** (a 14B diffusion model +on a Mac: minutes per clip) and **heavy** (large download + lots of unified memory). + +This directory is a small Python sidecar the Electron app talks to over +`http://127.0.0.1:8765`. The app spawns it on demand and keeps it warm. + +## Requirements + +- Apple Silicon Mac (M-series), macOS 14+ +- Python ≥ 3.11 — `brew install python@3.12` +- ~85GB free disk for setup (67GB source checkpoint + 18GB MLX Q4). Use an + external SSD via `VB_LOCAL_MODELS_DIR=/Volumes/SSD/vb-models` if needed. +- 48GB+ unified memory recommended for 720p. + +## Setup (once) + +```bash +bash local/setup.sh +``` + +This creates `local/.venv`, installs `mlx-video`, downloads +`Wan-AI/Wan2.2-I2V-A14B`, converts it to a 4-bit MLX model under `local/models/`, +and records the path in `local/.model-path` (read automatically by the app). + +8-bit instead of 4-bit (bigger, slightly better): `VB_LOCAL_BITS=8 bash local/setup.sh`. + +## Turn it on + +In the app: **Settings → Video backend → Local (Wan 2.2 MLX)**. New renders use +local i2v; everything else (storyboard LLM, keyframes, lyric timing) stays cloud +until those stages get local backends too. + +Equivalent env (for `npm run dev`): + +``` +VB_VIDEO_BACKEND=local +VB_LOCAL_WAN_DIR=/abs/path/to/Wan2.2-I2V-A14B-MLX-Q4 # optional; else .model-path +``` + +## Tunables (env) + +| Var | Default | Meaning | +|-----|---------|---------| +| `VB_LOCAL_PORT` | `8765` | sidecar port | +| `VB_LOCAL_DIR` | `/local` | sidecar dir (server.py, .venv, .model-path) | +| `VB_LOCAL_PYTHON` | `local/.venv/bin/python` | interpreter that runs the server | +| `VB_LOCAL_WAN_DIR` | `local/.model-path` | converted MLX model dir | +| `VB_LOCAL_WAN_FPS` | `16` | native fps used for frame budgeting | +| `VB_LOCAL_WAN_STEPS` | config (40) | diffusion steps — `10` for fast previews | +| `VB_LOCAL_MAX_FRAMES` | `81` | per-clip frame cap (81 ≈ 5s native @16fps) | +| `VB_LOCAL_DEADLINE_SEC` | `1800` | per-clip timeout | +| `VB_W` / `VB_H` | `1280` / `704` (local) | render resolution (720p) | + +## Manual smoke test + +```bash +source local/.venv/bin/activate +python -m mlx_video.models.wan_2.generate \ + --model-dir "$(cat local/.model-path)" \ + --image some.png --prompt "the person slowly turns and smiles, cinematic" \ + --width 1280 --height 704 --num-frames 81 --steps 10 \ + --output-path /tmp/wan_test.mp4 +``` + +## Notes / roadmap + +- **Resident weights (Phase 2):** upstream `generate_video` reloads T5 + both + transformers + VAE every call. The warm process + OS page cache soften this, but + a true weight-resident denoise loop is a follow-up. +- Next local backends to add behind the same sidecar: storyboard LLM (mlx-lm), + keyframes (mflux / Qwen-Image), WhisperX → whisper.cpp / mlx-whisper. diff --git a/local/ab_tiers.py b/local/ab_tiers.py new file mode 100644 index 0000000..fec3592 --- /dev/null +++ b/local/ab_tiers.py @@ -0,0 +1,189 @@ +"""Render the same 3-second shot through both 14B tiers, for a side-by-side quality read. + +The tiers differ in two things and this script changes nothing else — same start keyframe, same prompt, +same seed, same canvas, same chaining: + + fast 4 steps + tiny VAE (TAEHV) expected ~132 s per sub-clip + quality 6 steps + the official Wan VAE expected ~246 s per sub-clip + +Both figures are projections from the measured 27.5 s/step at 832x480/37f; the point of this script is to +replace them with real numbers and real frames. The 6-step choice in particular is unvalidated: Lightning +is distilled to 4, and pushing a distilled schedule too far over-denoises into flat, slow-motion output. +If 6 does not visibly beat 4, the Quality tier should drop back to 4 steps and keep only the official VAE. + +3 seconds needs two chained sub-clips, because the 14B is capped at 37 frames (2.31 s at its native +16 fps). The chaining here mirrors src/engine/backends/local/video.ts: sub-clip 2 starts from sub-clip 1's +real last frame, so the seam is the same one production produces. + +Usage: + python local/ab_tiers.py --image --prompt "..." [--seconds 3] [--tier fast|quality|both] +""" + +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +import time +import urllib.request + +HERE = os.path.dirname(os.path.abspath(__file__)) +PORT = int(os.environ.get("VB_LOCAL_PORT", "8765")) +FPS = 16 # 14B native +MAX_FRAMES = 37 # the 48GB working-set cap; 2.31 s per sub-clip + +TIERS = { + # steps, tiny_vae, label + "fast": (4, 1, "Lightning 4-step + tiny VAE (TAEHV)"), + "quality": (6, 0, "Lightning 6-step + official Wan VAE"), + # The control arm. fast->quality changes the step count AND the decoder at once, so a quality win + # cannot be attributed. This isolates the decoder: if it matches 'quality', the extra two steps buy + # nothing and Quality should run at 4. + "vaeonly": (4, 0, "Lightning 4-step + official Wan VAE (isolates the decoder)"), +} + + +def post(route: str, payload: dict, timeout: int) -> dict: + req = urllib.request.Request( + f"http://127.0.0.1:{PORT}{route}", + data=json.dumps(payload).encode(), + headers={"Content-Type": "application/json"}, + ) + with urllib.request.urlopen(req, timeout=timeout) as r: + return json.loads(r.read()) + + +def ffmpeg() -> str: + return os.environ.get("VB_FFMPEG", "ffmpeg") + + +def last_frame(video: str, out: str) -> str: + """The clip's real final frame, the way ffmpeg.ts lastFrame() takes it (seek from EOF).""" + subprocess.run( + [ffmpeg(), "-y", "-loglevel", "error", "-sseof", "-0.4", "-i", video, "-update", "1", "-q:v", "2", out], + check=True, + ) + return out + + +def concat(parts: list[str], out: str) -> str: + lst = out + ".txt" + with open(lst, "w") as fh: + for p in parts: + fh.write(f"file '{os.path.abspath(p)}'\n") + subprocess.run( + [ffmpeg(), "-y", "-loglevel", "error", "-f", "concat", "-safe", "0", "-i", lst, "-c", "copy", out], + check=True, + ) + return out + + +def render_tier(tier: str, args, model_dir: str, lora: tuple[str, str] | None) -> dict: + steps, tiny, label = TIERS[tier] + native = MAX_FRAMES / FPS + n_sub = max(1, int(-(-args.seconds // native))) # ceil + outdir = os.path.join(args.outdir, tier) + os.makedirs(outdir, exist_ok=True) + + parts, timings, start_img = [], [], args.image + for i in range(n_sub): + remaining = args.seconds - i * native + secs = min(native, remaining + 0.35) if i == n_sub - 1 else native + sub = os.path.join(outdir, f"sub_{i}.mp4") + payload = { + "model_dir": model_dir, + "image": start_img, + "prompt": args.prompt, + "out": sub, + "seconds": secs, + "fps": FPS, + "width": args.width, + "height": args.height, + "seed": args.seed + i, + "max_frames": MAX_FRAMES, + "min_frames": 21, + "tiling": "auto", + "tiny_vae": tiny, + "steps": steps, + } + if lora: + payload["lora_high"], payload["lora_low"] = lora + payload["lora_strength_high"] = 0.6 + payload["lora_strength_low"] = 1.0 + print(f" [{tier}] sub-clip {i + 1}/{n_sub}: {steps} steps, tiny_vae={tiny}, {secs:.2f}s", flush=True) + t0 = time.time() + r = post("/i2v", payload, timeout=args.deadline) + dt = time.time() - t0 + if not r.get("ok"): + raise RuntimeError(f"{tier} sub-clip {i}: {r.get('error')}") + timings.append(dt) + parts.append(sub) + print(f" {dt:.1f}s ({r.get('num_frames')} frames)", flush=True) + if i < n_sub - 1: + start_img = last_frame(sub, os.path.join(outdir, f"last_{i}.png")) + + final = os.path.join(args.outdir, f"{tier}.mp4") + concat(parts, final) if len(parts) > 1 else subprocess.run( + [ffmpeg(), "-y", "-loglevel", "error", "-i", parts[0], "-c", "copy", final], check=True + ) + return { + "tier": tier, + "label": label, + "steps": steps, + "tiny_vae": bool(tiny), + "sub_clips": n_sub, + "per_sub_s": [round(t, 1) for t in timings], + "total_s": round(sum(timings), 1), + "out": final, + } + + +def main() -> None: + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--image", required=True) + ap.add_argument("--prompt", required=True) + ap.add_argument("--seconds", type=float, default=3.0) + ap.add_argument("--width", type=int, default=832) + ap.add_argument("--height", type=int, default=480) + ap.add_argument("--seed", type=int, default=42) + # Derived from TIERS so adding an arm cannot silently fail at the CLI boundary. + ap.add_argument("--tier", choices=[*TIERS, "both"], default="both") + ap.add_argument("--outdir", default=os.path.join(HERE, "..", "ab_out")) + ap.add_argument("--deadline", type=int, default=3600) + args = ap.parse_args() + args.outdir = os.path.abspath(args.outdir) + os.makedirs(args.outdir, exist_ok=True) + + model_dir = open(os.path.join(HERE, ".model-path")).read().strip() + ld = os.path.join(HERE, ".lightning-dir") + lora = None + if os.path.isfile(ld): + d = open(ld).read().strip() + hi, lo = os.path.join(d, "high_noise_model.safetensors"), os.path.join(d, "low_noise_model.safetensors") + if os.path.isfile(hi) and os.path.isfile(lo): + lora = (hi, lo) + if not lora: + sys.exit("Lightning LoRA missing — both tiers depend on it, refusing to render the 40-step path.") + + tiers = ["fast", "quality"] if args.tier == "both" else [args.tier] + results = [] + for t in tiers: + print(f"\n=== {t}: {TIERS[t][2]} ===", flush=True) + results.append(render_tier(t, args, model_dir, lora)) + + print("\n" + "=" * 68) + for r in results: + print(f"{r['tier']:<9}{r['steps']} steps tiny_vae={str(r['tiny_vae']):<5} " + f"{r['total_s']:>7.1f}s {r['per_sub_s']} -> {r['out']}") + if len(results) == 2: + f, q = results + print(f"\nquality/fast = {q['total_s'] / f['total_s']:.2f}x " + f"(projection was 246/132 = 1.86x)") + with open(os.path.join(args.outdir, "results.json"), "w") as fh: + json.dump(results, fh, indent=2) + + +if __name__ == "__main__": + main() diff --git a/local/bench.py b/local/bench.py new file mode 100644 index 0000000..8b8585d --- /dev/null +++ b/local/bench.py @@ -0,0 +1,58 @@ +"""One-config Wan benchmark run. Takes a JSON config on argv, runs generate_video in a FRESH process +(clean peak-memory), prints BENCH_RESULT json + lets generate_video's own Denoising/VAE/Total timers through. +""" +import json +import os +import sys +import time + +import mlx.core as mx + +# Optional MLX memory cap. OFF by default — a cap set BELOW what the 14B needs strangles it into swap +# (image-encode went 4.4s -> 49s under a 46GB cap), which earlier led to a false "14B unusable" read. The +# real crash that rebooted the Mac was two model processes running at once; the app already serialises to +# one GPU job, so no cap is needed there. Set VB_BENCH_MEM_GB only as a deliberate ceiling for stress tests. +_cap = os.environ.get("VB_BENCH_MEM_GB", "") +if _cap: + try: + mx.set_memory_limit(int(float(_cap) * 1024 ** 3)) + except Exception: + pass + +from mlx_video.models.wan_2.generate import generate_video + +cfg = json.loads(sys.argv[1]) +lh = [(cfg["lora_high"], 1.0)] if cfg.get("lora_high") else None +ll = [(cfg["lora_low"], 1.0)] if cfg.get("lora_low") else None + +t0 = time.time() +generate_video( + model_dir=cfg["model_dir"], + prompt=cfg["prompt"], + image=cfg["image"], + width=cfg["width"], + height=cfg["height"], + num_frames=cfg["num_frames"], + steps=cfg.get("steps"), + guide_scale=cfg.get("guide_scale"), + shift=cfg.get("shift"), + seed=42, + output_path=cfg["out"], + loras_high=lh, + loras_low=ll, + trim_first_frames=0, + tiling=cfg.get("tiling", "auto"), +) +dt = time.time() - t0 +peak = mx.get_peak_memory() / 1e9 +print("BENCH_RESULT " + json.dumps({ + "label": cfg["label"], + "total_s": round(dt, 1), + "peak_gb": round(peak, 1), + "model": cfg["model_dir"].split("/")[-1], + "steps": cfg.get("steps"), + "guide": cfg.get("guide_scale"), + "frames": cfg["num_frames"], + "res": f'{cfg["width"]}x{cfg["height"]}', + "out": cfg["out"], +}), flush=True) diff --git a/local/bench_14b.py b/local/bench_14b.py new file mode 100644 index 0000000..cf9eab5 --- /dev/null +++ b/local/bench_14b.py @@ -0,0 +1,46 @@ +"""Round 2 (SAFE) — 14B (x16 VAE = sharper than the 5B) at FEW frames + RIFE 2x interpolation. + +The 14B at 37 frames exhausted unified memory and kernel-panicked the Mac, so this NEVER runs 37 frames: +it generates short clips (13/17/21 frames, all 4n+1) — light on memory — then RIFE-doubles each back up to +a normal frame count, giving 14B quality at a safe peak. bench.py caps MLX memory so an over-budget run +fails cleanly instead of crashing the OS. Usage: python bench_14b.py +""" +import json +import os +import subprocess +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +PY = os.path.join(HERE, ".venv/bin/python") +M14 = os.path.join(HERE, "models/Wan2.2-I2V-A14B-MLX-Q4") +LDIR = open(os.path.join(HERE, ".lightning-dir")).read().strip() +LH, LL = os.path.join(LDIR, "high_noise_model.safetensors"), os.path.join(LDIR, "low_noise_model.safetensors") +IMG = sys.argv[1] +PROMPT = "the man turns from the window and walks slowly toward the camera, cinematic handheld camera, moody natural light" +# steps=4 + guide=1 = the Wan2.2-Lightning fast path (bench.py calls generate_video directly, so unlike +# wan_i2v.run_i2v it does NOT auto-set steps=4 when LoRAs are present — must pass it explicitly). +base = dict(model_dir=M14, image=IMG, prompt=PROMPT, width=832, height=480, steps=4, guide_scale="1", lora_high=LH, lora_low=LL) + +# 14B is 16fps native; keep frames LOW (memory-safe), RIFE 2x doubles them back to a usable clip. +# 4n+1 only: 13 (~0.8s -> 25f), 17 (~1.06s -> 33f), 21 (~1.3s -> 41f). 37 is the killer — excluded. +FRAMES = [int(x) for x in os.environ.get("VB_BENCH_FRAMES", "13,17,21").split(",")] +configs = [dict(base, label=f"14B-{f}f", num_frames=f, out=f"/tmp/b14_{f}.mp4") for f in FRAMES] + +KEEP = ("BENCH_RESULT", "Denoising:", "VAE decode:", "Total time:", "Insufficient", "Traceback") +for c in configs: + print(f"\n##### {c['label']} (num_frames={c['num_frames']})", flush=True) + p = subprocess.run([PY, os.path.join(HERE, "bench.py"), json.dumps(c)], capture_output=True, text=True) + for line in (p.stdout + p.stderr).splitlines(): + if any(k in line for k in KEEP): + print(line, flush=True) + # RIFE 2x the result (Apple GPU) so we can eyeball 14B-quality at a doubled frame count. + if os.path.exists(c["out"]): + rife_out = c["out"].replace(".mp4", "_rife.mp4") + env = {**os.environ, "VB_FFMPEG": os.environ.get("VB_FFMPEG", "ffmpeg"), "PYTHONPATH": HERE} + r = subprocess.run([PY, "-c", + f"from interp import run_interpolate; print(run_interpolate({{'video':'{c['out']}','out':'{rife_out}','out_fps':24}}))"], + capture_output=True, text=True, env=env) + for line in (r.stdout + r.stderr).splitlines(): + if "frames_out" in line or "ok" in line.lower(): + print(" RIFE:", line.strip(), flush=True) +print("\n##### 14B+RIFE BENCH DONE", flush=True) diff --git a/local/bench_run.py b/local/bench_run.py new file mode 100644 index 0000000..9ebb313 --- /dev/null +++ b/local/bench_run.py @@ -0,0 +1,34 @@ +"""Driver: run the Wan benchmark configs as fresh subprocesses (clean peak mem), print timings. +Usage: python bench_run.py +""" +import json +import os +import subprocess +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +PY = os.path.join(HERE, ".venv/bin/python") +M14 = os.path.join(HERE, "models/Wan2.2-I2V-A14B-MLX-Q4") +M5 = os.path.join(HERE, "models/Wan2.2-TI2V-5B-MLX") +LDIR = open(os.path.join(HERE, ".lightning-dir")).read().strip() +LH = os.path.join(LDIR, "high_noise_model.safetensors") +LL = os.path.join(LDIR, "low_noise_model.safetensors") +IMG = sys.argv[1] +PROMPT = "the man turns away from the window and walks slowly toward the camera, cinematic handheld camera, moody natural light" +base = dict(image=IMG, prompt=PROMPT, width=832, height=480) + +configs = [ + dict(label="14B-Q4-Lightning-4step", model_dir=M14, num_frames=37, steps=4, guide_scale="1", lora_high=LH, lora_low=LL, out="/tmp/b_14b.mp4", **base), + dict(label="5B-bf16-10step", model_dir=M5, num_frames=57, steps=10, guide_scale="5.0", out="/tmp/b_5b10.mp4", **base), + dict(label="5B-bf16-20step", model_dir=M5, num_frames=57, steps=20, guide_scale="5.0", out="/tmp/b_5b20.mp4", **base), + dict(label="5B-bf16-40step", model_dir=M5, num_frames=57, steps=40, guide_scale="5.0", out="/tmp/b_5b40.mp4", **base), +] + +KEEP = ("BENCH_RESULT", "Denoising:", "VAE decode:", "Total time:", "Models loaded:", "Image encoding:", "Insufficient", "Error", "Traceback") +for c in configs: + print(f"\n##### RUN {c['label']} (steps={c['steps']} frames={c['num_frames']})", flush=True) + p = subprocess.run([PY, os.path.join(HERE, "bench.py"), json.dumps(c)], capture_output=True, text=True) + for line in (p.stdout + p.stderr).splitlines(): + if any(k in line for k in KEEP): + print(line, flush=True) +print("\n##### BENCH DONE", flush=True) diff --git a/local/bench_steps.py b/local/bench_steps.py new file mode 100644 index 0000000..926ea31 --- /dev/null +++ b/local/bench_steps.py @@ -0,0 +1,28 @@ +"""Round 1 smoke test — 5B step floor. Run a fixed image/prompt/seed at several native step counts to find +the lowest steps that still hold quality. Reuses bench.py per config (fresh process = clean peak mem). +Usage: python bench_steps.py +""" +import json +import os +import subprocess +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +PY = os.path.join(HERE, ".venv/bin/python") +M5 = open(os.path.join(HERE, ".model-path-5b")).read().strip() +IMG = sys.argv[1] +PROMPT = "the man turns from the window and walks slowly toward the camera, cinematic handheld camera, moody natural light" +base = dict(model_dir=M5, image=IMG, prompt=PROMPT, width=832, height=480, num_frames=57, guide_scale="5.0") + +# steps to probe (descending so the fast ones come first) +STEPS = [int(x) for x in (os.environ.get("VB_BENCH_STEPS", "4,6,8,10").split(","))] +configs = [dict(base, label=f"5B-{s}step", steps=s, out=f"/tmp/bs_5b_{s}.mp4") for s in STEPS] + +KEEP = ("BENCH_RESULT", "Denoising:", "VAE decode:", "Total time:", "Insufficient", "Traceback") +for c in configs: + print(f"\n##### {c['label']}", flush=True) + p = subprocess.run([PY, os.path.join(HERE, "bench.py"), json.dumps(c)], capture_output=True, text=True) + for line in (p.stdout + p.stderr).splitlines(): + if any(k in line for k in KEEP): + print(line, flush=True) +print("\n##### STEPS BENCH DONE", flush=True) diff --git a/local/download.py b/local/download.py new file mode 100644 index 0000000..ab30f48 --- /dev/null +++ b/local/download.py @@ -0,0 +1,175 @@ +"""Download (and report progress for) the model(s) a local stage needs. + +Used by the app's Settings "Download" button: `python download.py ` snapshot-downloads the stage's +HF repo(s) into the shared HF cache and prints JSON progress lines the main process forwards to the UI: + {"event":"progress","pct":42.0,"mb":8123} + {"event":"done","pct":100} + {"event":"error","error":"..."} +VIDEO (Wan 2.2) is the heavy download+convert; it has its own path here (mirrors local/setup.sh) so the +in-app "all required stages present" render gate is satisfiable from the Download button, not just setup.sh. +""" +import json +import os +import shutil +import subprocess +import sys +import threading +import time + +# One source of truth for stage -> HF repos (mirrored read-only by src/main/localModels.ts for status). +# KEYFRAME is FLUX-schnell only (the render prerequisite); FLUX-Kontext is optional (portrait/reference). +# VIDEO is NOT a plain repo list — it's a PRE-CONVERTED MLX repo picked per engine (see VIDEO_ENGINES / +# download_video); readiness is the per-engine marker, not a raw cache check. +STAGE_REPOS = { + "STT": ["mlx-community/whisper-large-v3-turbo"], + "LLM": ["lmstudio-community/Qwen3.6-35B-A3B-MLX-4bit"], + "VLM": ["mlx-community/gemma-3-12b-it-4bit"], + "KEYFRAME": ["dhairyashil/FLUX.1-schnell-mflux-4bit"], +} +# Optional, on-demand (not a render prerequisite): FLUX-Kontext for cast/reference-driven keyframes. +OPTIONAL_REPOS = { + "KEYFRAME_KONTEXT": ["akx/FLUX.1-Kontext-dev-mflux-4bit"], +} +# Pre-converted MLX video engines, picked by settings.localVideoModel. No source download, no on-device +# convert — a plain snapshot of a ready-to-run MLX repo + a per-engine marker. +VIDEO_ENGINES = { + # Fast: FastWan-5B DMD 3-step (published self-contained, ~24GB, fits 32GB unified). + "5b": {"repo": "lBroth/FastWan2.2-TI2V-5B-MLX", "name": "FastWan2.2-TI2V-5B-MLX", "marker": ".model-path-5b", "lightning": False, + "required": ["config.json", "t5_encoder.safetensors", "vae.safetensors", "model.safetensors"]}, + # Quality: our own Wan-14B MLX bf16 (~64GB) — relay-shedding loads ONE expert at a time (peak ~32.6GB, + # fits 48GB), which quantized repos that keep both experts resident (Q4 peaked 67.7GB) do not. + "14b": {"repo": "lBroth/Wan2.2-I2V-A14B-MLX-bf16", "name": "Wan2.2-I2V-A14B-MLX-bf16", "marker": ".model-path", "lightning": True, + "required": ["config.json", "t5_encoder.safetensors", "vae.safetensors", + "high_noise_model.safetensors", "low_noise_model.safetensors"]}, +} + + +def emit(obj: dict) -> None: + print(json.dumps(obj), flush=True) + + +def download_video() -> None: + """Provision the video stage by snapshotting a PRE-CONVERTED MLX repo (no 120GB fp32 source, no on-device + convert, no torch) picked by settings.localVideoModel — '5b' Fast (FastWan) / '14b' Quality (Wan Q8) — and + writing the per-engine marker the app reads. 14b also fetches the Lightning 4-step LoRA (else it falls back + to the ~38 min/clip 40-step path).""" + from huggingface_hub import snapshot_download + + here = os.path.dirname(os.path.abspath(__file__)) + models_dir = os.environ.get("VB_LOCAL_MODELS_DIR", os.path.join(here, "models")) + # Markers go in the WRITABLE marker dir (userData when packaged; the code dir is read-only there). + marker_dir = os.environ.get("VB_LOCAL_MARKER_DIR", here) + os.makedirs(models_dir, exist_ok=True) + os.makedirs(marker_dir, exist_ok=True) + + engine = os.environ.get("VB_LOCAL_VIDEO_MODEL", "5b") + spec = VIDEO_ENGINES.get(engine, VIDEO_ENGINES["5b"]) + dest = os.path.join(models_dir, spec["name"]) + marker = os.path.join(marker_dir, spec["marker"]) + # Every weight the engine opens, not a sentinel pair. A sentinel made an interrupted fetch + # UNRECOVERABLE: a DNS drop mid-snapshot (observed 2026-08-03) left the small files — config.json and + # t5_encoder — and neither 28.6GB expert, and because those two exist the next Download click skipped + # the snapshot, rewrote the marker and reported success in under a second. The 57GB that were actually + # missing could never be fetched from the UI again. snapshot_download resumes and is a cheap etag + # check when everything is present, so re-running it on an incomplete dir is the correct behavior. + required = [os.path.join(dest, f) for f in spec["required"]] + + if not all(os.path.exists(f) for f in required): + emit({"event": "progress", "pct": 1, "repo": spec["repo"]}) + snapshot_download(spec["repo"], local_dir=dest) + # The marker is what readiness resolves, so it must never point at a partial dir. + missing = [os.path.basename(f) for f in required if not os.path.exists(f)] + if missing: + emit({"event": "error", "error": f"{spec['repo']}: incomplete download, missing {', '.join(missing)}"}) + raise SystemExit(1) + with open(marker, "w") as fh: + fh.write(dest) + + # Wan2.2-Lightning 4-step I2V LoRA — 14B only (the fast path: 4 steps + CFG off instead of 40 steps). + if spec["lightning"]: + light_dir = os.path.join(models_dir, "Wan2.2-Lightning") + light_lora = os.path.join(light_dir, "Wan2.2-I2V-A14B-4steps-lora-rank64-Seko-V1") + if not os.path.exists(os.path.join(light_lora, "high_noise_model.safetensors")): + emit({"event": "progress", "pct": 92, "repo": "lightx2v/Wan2.2-Lightning"}) + snapshot_download( + "lightx2v/Wan2.2-Lightning", + allow_patterns=["Wan2.2-I2V-A14B-4steps-lora-rank64-Seko-V1/*"], + local_dir=light_dir, + ) + if os.path.exists(os.path.join(light_lora, "high_noise_model.safetensors")): + with open(os.path.join(marker_dir, ".lightning-dir"), "w") as fh: + fh.write(light_lora) + + emit({"event": "done", "pct": 100}) + + +def _dir_size(path: str) -> int: + total = 0 + for root, _dirs, files in os.walk(path): + for f in files: + try: + total += os.path.getsize(os.path.join(root, f)) + except OSError: + pass + return total + + +def main() -> None: + os.environ.setdefault("HF_HUB_DISABLE_XET", "1") # the Xet backend stalls large downloads + from huggingface_hub import HfApi, snapshot_download + from huggingface_hub.constants import HF_HUB_CACHE + + stage = (sys.argv[1] if len(sys.argv) > 1 else "").upper() + + # VIDEO isn't a plain snapshot — it's a per-engine MLX repo, so it has no STAGE_REPOS entry and must be + # dispatched BEFORE the repo lookup (which would otherwise reject it as an unknown stage). + if stage == "VIDEO": + download_video() + return + + # Optional stages (KEYFRAME_KONTEXT) are downloaded on demand, not as a render prerequisite — they + # are still plain snapshots, so they share this path. + repos = STAGE_REPOS.get(stage) or OPTIONAL_REPOS.get(stage) + if not repos: + emit({"event": "error", "error": f"unknown stage {stage}"}) + return + + api = HfApi() + + def total_size(repo: str) -> int: + try: + info = api.model_info(repo, files_metadata=True) + return sum((s.size or 0) for s in (info.siblings or [])) + except Exception: # noqa: BLE001 + return 0 + + totals = {r: total_size(r) for r in repos} + grand = sum(totals.values()) or 1 + + def cache_dir(repo: str) -> str: + return os.path.join(HF_HUB_CACHE, "models--" + repo.replace("/", "--")) + + stop = threading.Event() + + def poll() -> None: + while not stop.is_set(): + got = sum(_dir_size(cache_dir(r)) for r in repos) + emit({"event": "progress", "pct": round(min(99.0, 100 * got / grand), 1), "mb": round(got / 1e6)}) + stop.wait(2) + + th = threading.Thread(target=poll, daemon=True) + th.start() + try: + for r in repos: + emit({"event": "progress", "pct": round(min(99.0, 100 * sum(_dir_size(cache_dir(x)) for x in repos) / grand), 1), "repo": r}) + snapshot_download(r) + stop.set() + emit({"event": "done", "pct": 100}) + except Exception as e: # noqa: BLE001 + stop.set() + emit({"event": "error", "error": str(e)[:300]}) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/local/fastwan_dmd.py b/local/fastwan_dmd.py new file mode 100644 index 0000000..cf0dbbb --- /dev/null +++ b/local/fastwan_dmd.py @@ -0,0 +1,61 @@ +"""FastWan DMD sampling support for the Wan sidecar. + +FastWan2.2-TI2V-5B is a 3-step DMD distill: it is trained on the EXACT +denoising list [1000, 757, 522] (generic set_timesteps(3) would give +[1000, 909, 714]) and the reference sampler re-noises to the next level with +FRESH noise (deterministic euler over the same sigmas ghosts characters in +camera transitions — measured, research/videogen storyboard 2026-07-05). + +A FastWan model dir is marked by a "fastwan_dmd" key in its config.json: + {"sigmas": [1.0, 0.757, 0.522, 0.0], "renoise": true} +patch(spec) monkeypatches FlowMatchEulerScheduler accordingly (idempotent per +process; the sidecar runs one generation per process, so no unpatch needed). +""" +import mlx.core as mx +import numpy as np + +import mlx_video.models.wan_2.scheduler as sched_mod + +_PATCHED = False + + +def patch(spec: dict) -> int: + """Apply the DMD schedule/sampler. Returns the step count to request.""" + global _PATCHED + sigmas = [float(s) for s in spec["sigmas"]] + steps = len(sigmas) - 1 + if _PATCHED: + return steps + + orig_set = sched_mod.FlowMatchEulerScheduler.set_timesteps + + def dmd_set_timesteps(self, num_steps, shift=1.0): + orig_set(self, num_steps, shift) + if num_steps == steps: + self.sigmas = mx.array(np.array(sigmas, dtype=np.float32)) + self.timesteps = mx.array( + np.array([s * self.num_train_timesteps for s in sigmas[:-1]], + dtype=np.float32) + ) + self._sigmas_float = list(sigmas) + self._step_index = 0 + + sched_mod.FlowMatchEulerScheduler.set_timesteps = dmd_set_timesteps + + if spec.get("renoise"): + def dmd_step(self, model_output, timestep, sample): + s = self._sigmas_float[self._step_index] + s_next = self._sigmas_float[self._step_index + 1] + x0 = sample - s * model_output + if s_next > 0: + noise = mx.random.normal(sample.shape).astype(sample.dtype) + out = (1.0 - s_next) * x0 + s_next * noise + else: + out = x0 + self._step_index += 1 + return out + + sched_mod.FlowMatchEulerScheduler.step = dmd_step + + _PATCHED = True + return steps diff --git a/local/interp.py b/local/interp.py new file mode 100644 index 0000000..42828e6 --- /dev/null +++ b/local/interp.py @@ -0,0 +1,114 @@ +"""Frame interpolation via RIFE (rife-ncnn-vulkan, Apple GPU through MoltenVK) — Nx a video's frame rate. + +Used to render Wan clips at a fraction of the timeline frames (faster denoise + VAE) then interpolate back +up with real motion. RIFE v4.x is timestep-conditioned, so an Nx pass synthesizes N-1 evenly spaced frames +per gap in ONE pass — near-ground-truth on cinematic pans/walking at these small gaps. MIT licensed +(wrapper + weights). Falls back to nothing here — the engine handles the ffmpeg fallback. + +run_interpolate(req): {video, out, [factor], [model], [gpuid], [out_fps]} -> {ok, frames_in, frames_out}. +Multiplies the frame count by `factor` (default 2, clamped to 2..4): N-1 midpoints between each pair, plus +the last frame repeated factor-1 times = n*factor frames, muxed at out_fps (pass factor x the source fps) +so the clip keeps exactly its source duration with real in-between motion. + +Why the caller may ask for 3x rather than 2x: the engine's timeline is 24fps and the 14B saves at 16fps. +2x lands on 32fps, which conforms to 24 by dropping 1 frame in 4 at uneven phase (visible cadence break); +3x lands on 48fps, an exact 2:1 decimation to 24. See rifeFactor() in src/engine/localVideo.ts. +""" +import os +import shutil +import subprocess + +_HERE = os.path.dirname(os.path.abspath(__file__)) + + +def _ffmpeg() -> str: + # ffmpeg-static path passed by the caller, else system ffmpeg + return os.environ.get("VB_FFMPEG", "ffmpeg") + + +def _default_model() -> str: + """Newest bundled RIFE first: rife-v4.25/4.26 handle large/fast motion far better than the wheel's + 2022-era rife-v4.6 (and the installed wheel special-cases their padding). Absolute dir under + local/models/ wins; VB_RIFE_MODEL overrides; rife-v4.6 (bundled in the wheel) is the fallback.""" + envm = os.environ.get("VB_RIFE_MODEL", "") + if envm: + return envm + for name in ("rife-v4.26", "rife-v4.25"): + d = os.path.join(_HERE, "models", name) + if os.path.isfile(os.path.join(d, "flownet.param")): + return d + return "rife-v4.6" + + +def _extract_frames(video: str, d: str) -> int: + os.makedirs(d, exist_ok=True) + subprocess.run([_ffmpeg(), "-y", "-loglevel", "error", "-i", video, os.path.join(d, "f_%05d.png")], check=True) + return len([f for f in os.listdir(d) if f.endswith(".png")]) + + +def run_interpolate(req: dict) -> dict: + from PIL import Image + from rife_ncnn_vulkan_python import Rife + + video = req["video"] + out = req["out"] + model = req.get("model") or _default_model() + gpuid = int(req.get("gpuid", 0)) + # Clamped: 1 would be a no-op pass, and past 4 the synthesized-to-real frame ratio stops buying + # smoothness while the per-clip cost keeps growing. + factor = max(2, min(4, int(req.get("factor", 2) or 2))) + + work = video + "_interp" + # A previous FAILED/killed run leaves stale frames here; ffmpeg's image2 demuxer would happily append + # them to a shorter retry's sequence (corrupted output) — always start from a clean dir. + shutil.rmtree(work, ignore_errors=True) + fin = os.path.join(work, "in") + fout = os.path.join(work, "out") + os.makedirs(fout, exist_ok=True) + n = _extract_frames(video, fin) + if n < 2: + shutil.rmtree(work, ignore_errors=True) + return {"ok": False, "error": "need >= 2 frames to interpolate"} + + frames = sorted(f for f in os.listdir(fin) if f.endswith(".png")) + first = Image.open(os.path.join(fin, frames[0])).convert("RGB") + w, h = first.size + # No caching: the sidecar runs each job in a fresh subprocess (MoltenVK isolation), so nothing persists. + rife = Rife(gpuid=gpuid, model=model, scale=2, width=w, height=h) + + # interleave: f0, [factor-1 midpoints], f1, [factor-1 midpoints], f2, ..., fN, then fN repeated + # factor-1 times -> exactly n*factor frames. The tail repeat keeps the clip at EXACTLY its source + # duration at factor x fps (n*factor-(factor-1) frames would run short per clip — enough to break the + # chained-total >= scene-window invariant and drift the timeline). + oi = 0 + prev = first + Image.open(os.path.join(fin, frames[0])).convert("RGB").save(os.path.join(fout, f"o_{oi:05d}.png")); oi += 1 + for i in range(1, n): + cur = Image.open(os.path.join(fin, frames[i])).convert("RGB") + for j in range(1, factor): + # RIFE v4.x takes an arbitrary timestep, so 3x is one pass with two midpoints per gap — not a + # recursive 2x-of-2x (which would interpolate already-synthesized frames and compound error). + rife.process(prev, cur, timestep=j / factor).save(os.path.join(fout, f"o_{oi:05d}.png")); oi += 1 + cur.save(os.path.join(fout, f"o_{oi:05d}.png")); oi += 1 + prev = cur + for _ in range(factor - 1): + prev.save(os.path.join(fout, f"o_{oi:05d}.png")); oi += 1 + + fps = float(req.get("out_fps", 24)) + subprocess.run([ + _ffmpeg(), "-y", "-loglevel", "error", "-framerate", str(fps), + "-i", os.path.join(fout, "o_%05d.png"), + # CRF 14: this hop feeds further re-encodes (trim/concat) — keep it visually lossless. + "-c:v", "libx264", "-crf", "14", "-preset", "medium", "-pix_fmt", "yuv420p", out, + ], check=True) + ok = os.path.exists(out) and os.path.getsize(out) > 0 + shutil.rmtree(work, ignore_errors=True) # frame PNGs are big; don't leak them per clip + return {"ok": ok, "frames_in": n, "frames_out": oi, "width": w, "height": h, "model": os.path.basename(str(model))} + + +if __name__ == "__main__": + # Subprocess entrypoint for the sidecar (see server._run_isolated): request JSON on stdin, result JSON + # as the last stdout line. Keeps this Vulkan wrapper out of the sidecar process (MoltenVK clash). + import json + import sys + print(json.dumps(run_interpolate(json.load(sys.stdin))), flush=True) diff --git a/local/keyframe.py b/local/keyframe.py new file mode 100644 index 0000000..a85f6e2 --- /dev/null +++ b/local/keyframe.py @@ -0,0 +1,79 @@ +"""Local keyframe stage (scene images), MLX-native via mflux. + +Text->image with FLUX schnell (4-step, fast); when an identity reference is given, FLUX Kontext places that +exact subject into the scene (the local answer to the cloud "put these people in the shot"). Single-resident +via the ModelManager. Kontext takes ONE reference image, so a multi-cast shot uses the lead's reference. +""" +import os + +from manager import get + + +def _model_config(name: str, base_model: str = "schnell"): + # A HF repo ("owner/name", e.g. an ungated pre-quantized mirror) loads via from_name + base_model arch; + # presets (flux2_klein_4b, …) are ModelConfig classmethods; plain names (schnell, dev) via from_name. + from mflux.models.common.config.model_config import ModelConfig + if "/" in name: + return ModelConfig.from_name(name, base_model=base_model) + preset = getattr(ModelConfig, name, None) + return preset() if callable(preset) else ModelConfig.from_name(name) + + +def _txt2img(quantize: int, name: str, base_model: str): + from mflux.models.flux.variants.txt2img.flux import Flux1 + return Flux1(quantize=quantize, model_config=_model_config(name, base_model)) + + +def _kontext(quantize: int, name: str, base_model: str): + from mflux.models.flux.variants.kontext.flux_kontext import Flux1Kontext + return Flux1Kontext(quantize=quantize, model_config=_model_config(name, base_model)) + + +def _txt2img_image(req, quant, w, h, seed): + # ungated pre-quantized mflux mirror of FLUX.1-schnell (BFL's own schnell repo is HF-gated) + name = req.get("model", "dhairyashil/FLUX.1-schnell-mflux-4bit") + base = req.get("base_model", "schnell") + flux = get("kf:txt2img:" + name, lambda: _txt2img(quant, name, base)) + return flux.generate_image( + seed, req["prompt"], num_inference_steps=int(req.get("steps", 4)), + height=h, width=w, guidance=float(req.get("guidance", 3.5)), + ) + + +def run_keyframe(req: dict) -> dict: + out = req["out"] + seed = int(req.get("seed", 42)) + quant = int(req.get("quantize", 4)) + w = int(req.get("width", 1024)) + h = int(req.get("height", 576)) + ref = req.get("ref") # optional single identity reference image (cast lead) + os.makedirs(os.path.dirname(out) or ".", exist_ok=True) + + img = None + mode = "txt2img" + if ref: + # Identity injection via FLUX Kontext (ungated mflux mirror; BFL FLUX.1-Kontext-dev is HF-gated). + # A ref means the caller REQUIRES this exact subject in the scene, so there is NO silent txt2img + # fallback: falling back would quietly swap in a DIFFERENT person — the "character changes every + # scene" bug. If Kontext fails (e.g. OOM under pipeline memory pressure, or a bad model), raise + # loudly so the real cause surfaces instead of shipping a wrong identity. + try: + name = req.get("kontext_model", "akx/FLUX.1-Kontext-dev-mflux-4bit") + base = req.get("kontext_base", "dev") + flux = get("kf:kontext:" + name, lambda: _kontext(quant, name, base)) + img = flux.generate_image( + seed, req["prompt"], num_inference_steps=int(req.get("kontext_steps", 12)), + height=h, width=w, guidance=float(req.get("kontext_guidance", 2.5)), image_path=ref, + ) + mode = "kontext" + except Exception as e: # noqa: BLE001 + raise RuntimeError( + f"[vb-local] Kontext identity keyframe failed (ref={ref}): {e}. " + "Refusing the silent text2img fallback — it would swap the character. " + "Fix the Kontext model/memory instead of shipping a wrong identity." + ) from e + else: + img = _txt2img_image(req, quant, w, h, seed) + + img.save(path=out, overwrite=True) + return {"ok": os.path.exists(out) and os.path.getsize(out) > 0, "width": w, "height": h, "mode": mode} diff --git a/local/llm.py b/local/llm.py new file mode 100644 index 0000000..b7b2192 --- /dev/null +++ b/local/llm.py @@ -0,0 +1,47 @@ +"""Local LLM stage (story bible + shot list), MLX-native via mlx-lm. + +Returns plain text; the engine's llmJson reuses its existing tolerant JSON extraction, so the structured +storyboard works the same as the cloud path. Qwen3 thinking is disabled (/no_think + strip) so the +token budget goes to the answer, not the scratchpad. Single-resident via the ModelManager — loading the LLM +evicts other heavy models (e.g. a previously-resident keyframe model). +""" +import re + +from manager import get + +_THINK = re.compile(r"[\s\S]*?", re.IGNORECASE) + + +def _load(repo: str): + import mlx_lm + return mlx_lm.load(repo) + + +def run_llm(req: dict) -> dict: + import mlx_lm + from mlx_lm.sample_utils import make_sampler + + repo = req.get("model", "lmstudio-community/Qwen3.6-35B-A3B-MLX-4bit") + model, tok = get("llm:" + repo, lambda: _load(repo)) + + system = (req.get("system") or "").strip() + user = req.get("prompt") or "" + max_tokens = int(req.get("max_tokens", 2000)) + temp = float(req.get("temperature", 0.7)) + + messages = [] + if system: + messages.append({"role": "system", "content": system}) + messages.append({"role": "user", "content": user}) + # Qwen3 is a reasoning model — enable_thinking=False is the hard switch that skips the block so + # the whole budget goes to the answer (the soft "/no_think" tag is ignored by Qwen3.6). Fall back if the + # tokenizer's template doesn't accept the kwarg. + try: + prompt = tok.apply_chat_template(messages, add_generation_prompt=True, enable_thinking=False) + except TypeError: + prompt = tok.apply_chat_template(messages, add_generation_prompt=True) + + sampler = make_sampler(temp=temp, top_p=float(req.get("top_p", 0.95))) + text = mlx_lm.generate(model, tok, prompt=prompt, max_tokens=max_tokens, sampler=sampler, verbose=False) + text = _THINK.sub("", text or "").strip() + return {"ok": True, "text": text} diff --git a/local/manager.py b/local/manager.py new file mode 100644 index 0000000..99d5851 --- /dev/null +++ b/local/manager.py @@ -0,0 +1,58 @@ +"""Single-resident model manager for the sidecar. + +48GB unified memory can't hold Wan (~20GB) + an LLM (~19GB) + FLUX (~10GB) at once, so we keep at most ONE +*heavy* model resident. Loading a new heavy model first evicts the previous one (drop the ref, gc, free the +MLX buffer cache). Within a stage (many calls, same key) the model is reused — fast; switching stage swaps +it. Light models (e.g. whisper ~1.6GB) can be marked heavy=False to coexist. + +Wan i2v is NOT managed here — upstream generate_video loads + frees its own weights per call. +""" +import gc + +import mlx.core as mx + +_RESIDENT: dict = {} # key -> loaded object +_HEAVY: set = set() # keys that count against the single-resident budget +_UNLOAD_HOOKS: list = [] # called when a heavy model loads — frees OTHER heavy state (e.g. resident Wan) + + +def register_unload_hook(fn) -> None: + """Register a callback run right before a heavy model loads, so external heavy caches (the resident Wan + weights, which live outside this manager) get freed to make room. Not called by unload_all().""" + _UNLOAD_HOOKS.append(fn) + + +def _free() -> None: + gc.collect() + try: + mx.clear_cache() + except Exception: # noqa: BLE001 + pass + + +def get(key: str, loader, heavy: bool = True): + """Return the model for `key`, loading via `loader()` if absent. Loading a heavy model evicts every + other heavy model first — and fires the unload hooks (e.g. drops resident Wan) — to free unified memory.""" + if key in _RESIDENT: + return _RESIDENT[key] + if heavy: + for k in [k for k in _RESIDENT if k in _HEAVY]: + del _RESIDENT[k] + _HEAVY.discard(k) + for hook in _UNLOAD_HOOKS: + try: + hook() + except Exception: # noqa: BLE001 + pass + _free() + obj = loader() + _RESIDENT[key] = obj + if heavy: + _HEAVY.add(key) + return obj + + +def unload_all() -> None: + _RESIDENT.clear() + _HEAVY.clear() + _free() diff --git a/local/models/rife-v4.26/flownet.bin b/local/models/rife-v4.26/flownet.bin new file mode 100644 index 0000000..be329ea Binary files /dev/null and b/local/models/rife-v4.26/flownet.bin differ diff --git a/local/models/rife-v4.26/flownet.param b/local/models/rife-v4.26/flownet.param new file mode 100644 index 0000000..a1248c1 --- /dev/null +++ b/local/models/rife-v4.26/flownet.param @@ -0,0 +1,392 @@ +7767517 +390 487 +Input in0 0 1 in0 +Split splitncnn_input0 1 7 in0 in0_splitncnn_0 in0_splitncnn_1 in0_splitncnn_2 in0_splitncnn_3 in0_splitncnn_4 in0_splitncnn_5 in0_splitncnn_6 +Input in1 0 1 in1 +Split splitncnn_input1 1 7 in1 in1_splitncnn_0 in1_splitncnn_1 in1_splitncnn_2 in1_splitncnn_3 in1_splitncnn_4 in1_splitncnn_5 in1_splitncnn_6 +Input in2 0 1 in2 +MemoryData block0.convblock.0.beta 0 1 block0.convblock.0.beta 0=1 1=1 2=192 +MemoryData block0.convblock.1.beta 0 1 block0.convblock.1.beta 0=1 1=1 2=192 +MemoryData block0.convblock.2.beta 0 1 block0.convblock.2.beta 0=1 1=1 2=192 +MemoryData block0.convblock.3.beta 0 1 block0.convblock.3.beta 0=1 1=1 2=192 +MemoryData block0.convblock.4.beta 0 1 block0.convblock.4.beta 0=1 1=1 2=192 +MemoryData block0.convblock.5.beta 0 1 block0.convblock.5.beta 0=1 1=1 2=192 +MemoryData block0.convblock.6.beta 0 1 block0.convblock.6.beta 0=1 1=1 2=192 +MemoryData block0.convblock.7.beta 0 1 block0.convblock.7.beta 0=1 1=1 2=192 +MemoryData block1.convblock.0.beta 0 1 block1.convblock.0.beta 0=1 1=1 2=128 +MemoryData block1.convblock.1.beta 0 1 block1.convblock.1.beta 0=1 1=1 2=128 +MemoryData block1.convblock.2.beta 0 1 block1.convblock.2.beta 0=1 1=1 2=128 +MemoryData block1.convblock.3.beta 0 1 block1.convblock.3.beta 0=1 1=1 2=128 +MemoryData block1.convblock.4.beta 0 1 block1.convblock.4.beta 0=1 1=1 2=128 +MemoryData block1.convblock.5.beta 0 1 block1.convblock.5.beta 0=1 1=1 2=128 +MemoryData block1.convblock.6.beta 0 1 block1.convblock.6.beta 0=1 1=1 2=128 +MemoryData block1.convblock.7.beta 0 1 block1.convblock.7.beta 0=1 1=1 2=128 +MemoryData block2.convblock.0.beta 0 1 block2.convblock.0.beta 0=1 1=1 2=96 +MemoryData block2.convblock.1.beta 0 1 block2.convblock.1.beta 0=1 1=1 2=96 +MemoryData block2.convblock.2.beta 0 1 block2.convblock.2.beta 0=1 1=1 2=96 +MemoryData block2.convblock.3.beta 0 1 block2.convblock.3.beta 0=1 1=1 2=96 +MemoryData block2.convblock.4.beta 0 1 block2.convblock.4.beta 0=1 1=1 2=96 +MemoryData block2.convblock.5.beta 0 1 block2.convblock.5.beta 0=1 1=1 2=96 +MemoryData block2.convblock.6.beta 0 1 block2.convblock.6.beta 0=1 1=1 2=96 +MemoryData block2.convblock.7.beta 0 1 block2.convblock.7.beta 0=1 1=1 2=96 +MemoryData block3.convblock.0.beta 0 1 block3.convblock.0.beta 0=1 1=1 2=64 +MemoryData block3.convblock.1.beta 0 1 block3.convblock.1.beta 0=1 1=1 2=64 +MemoryData block3.convblock.2.beta 0 1 block3.convblock.2.beta 0=1 1=1 2=64 +MemoryData block3.convblock.3.beta 0 1 block3.convblock.3.beta 0=1 1=1 2=64 +MemoryData block3.convblock.4.beta 0 1 block3.convblock.4.beta 0=1 1=1 2=64 +MemoryData block3.convblock.5.beta 0 1 block3.convblock.5.beta 0=1 1=1 2=64 +MemoryData block3.convblock.6.beta 0 1 block3.convblock.6.beta 0=1 1=1 2=64 +MemoryData block3.convblock.7.beta 0 1 block3.convblock.7.beta 0=1 1=1 2=64 +MemoryData block4.convblock.0.beta 0 1 block4.convblock.0.beta 0=1 1=1 2=32 +MemoryData block4.convblock.1.beta 0 1 block4.convblock.1.beta 0=1 1=1 2=32 +MemoryData block4.convblock.2.beta 0 1 block4.convblock.2.beta 0=1 1=1 2=32 +MemoryData block4.convblock.3.beta 0 1 block4.convblock.3.beta 0=1 1=1 2=32 +MemoryData block4.convblock.4.beta 0 1 block4.convblock.4.beta 0=1 1=1 2=32 +MemoryData block4.convblock.5.beta 0 1 block4.convblock.5.beta 0=1 1=1 2=32 +MemoryData block4.convblock.6.beta 0 1 block4.convblock.6.beta 0=1 1=1 2=32 +MemoryData block4.convblock.7.beta 0 1 block4.convblock.7.beta 0=1 1=1 2=32 +Concat /Concat 2 1 in0_splitncnn_6 in1_splitncnn_6 /Concat_output_0 +Crop /Slice 1 1 /Concat_output_0 /Slice_output_0 -23309=1,0 -23310=1,1 -23311=1,0 +BinaryOp /Mul 1 1 /Slice_output_0 /Mul_output_0 0=2 1=1 +BinaryOp /Add 1 1 /Mul_output_0 /Add_output_0 1=1 2=1.000000e+00 +BinaryOp /Mul_1 2 1 /Add_output_0 in2 /Mul_1_output_0 0=2 +Split splitncnn_0 1 5 /Mul_1_output_0 /Mul_1_output_0_splitncnn_0 /Mul_1_output_0_splitncnn_1 /Mul_1_output_0_splitncnn_2 /Mul_1_output_0_splitncnn_3 /Mul_1_output_0_splitncnn_4 +Crop /Slice_1 1 1 in0_splitncnn_5 /Slice_1_output_0 -23309=1,0 -23310=1,3 -23311=1,0 +Split splitncnn_1 1 2 /Slice_1_output_0 /Slice_1_output_0_splitncnn_0 /Slice_1_output_0_splitncnn_1 +Convolution /encode/cnn0/Conv 1 1 /Slice_1_output_0_splitncnn_1 /encode/relu/LeakyRelu_output_0 0=16 1=3 3=2 4=1 5=1 6=432 9=2 -23310=1,2.000000e-01 +Convolution /encode/cnn1/Conv 1 1 /encode/relu/LeakyRelu_output_0 /encode/relu_1/LeakyRelu_output_0 0=16 1=3 4=1 5=1 6=2304 9=2 -23310=1,2.000000e-01 +Convolution /encode/cnn2/Conv 1 1 /encode/relu_1/LeakyRelu_output_0 /encode/relu_2/LeakyRelu_output_0 0=16 1=3 4=1 5=1 6=2304 9=2 -23310=1,2.000000e-01 +Deconvolution /encode/cnn3/ConvTranspose 1 1 /encode/relu_2/LeakyRelu_output_0 /encode/cnn3/ConvTranspose_output_0 0=4 1=4 3=2 4=1 5=1 6=1024 +Split splitncnn_2 1 5 /encode/cnn3/ConvTranspose_output_0 /encode/cnn3/ConvTranspose_output_0_splitncnn_0 /encode/cnn3/ConvTranspose_output_0_splitncnn_1 /encode/cnn3/ConvTranspose_output_0_splitncnn_2 /encode/cnn3/ConvTranspose_output_0_splitncnn_3 /encode/cnn3/ConvTranspose_output_0_splitncnn_4 +Crop /Slice_2 1 1 in1_splitncnn_5 /Slice_2_output_0 -23309=1,0 -23310=1,3 -23311=1,0 +Split splitncnn_3 1 2 /Slice_2_output_0 /Slice_2_output_0_splitncnn_0 /Slice_2_output_0_splitncnn_1 +Convolution /encode/cnn0_1/Conv 1 1 /Slice_2_output_0_splitncnn_1 /encode/relu_3/LeakyRelu_output_0 0=16 1=3 3=2 4=1 5=1 6=432 9=2 -23310=1,2.000000e-01 +Convolution /encode/cnn1_1/Conv 1 1 /encode/relu_3/LeakyRelu_output_0 /encode/relu_4/LeakyRelu_output_0 0=16 1=3 4=1 5=1 6=2304 9=2 -23310=1,2.000000e-01 +Convolution /encode/cnn2_1/Conv 1 1 /encode/relu_4/LeakyRelu_output_0 /encode/relu_5/LeakyRelu_output_0 0=16 1=3 4=1 5=1 6=2304 9=2 -23310=1,2.000000e-01 +Deconvolution /encode/cnn3_1/ConvTranspose 1 1 /encode/relu_5/LeakyRelu_output_0 /encode/cnn3_1/ConvTranspose_output_0 0=4 1=4 3=2 4=1 5=1 6=1024 +Split splitncnn_4 1 5 /encode/cnn3_1/ConvTranspose_output_0 /encode/cnn3_1/ConvTranspose_output_0_splitncnn_0 /encode/cnn3_1/ConvTranspose_output_0_splitncnn_1 /encode/cnn3_1/ConvTranspose_output_0_splitncnn_2 /encode/cnn3_1/ConvTranspose_output_0_splitncnn_3 /encode/cnn3_1/ConvTranspose_output_0_splitncnn_4 +Concat /Concat_1 5 1 /Slice_1_output_0_splitncnn_0 /Slice_2_output_0_splitncnn_0 /encode/cnn3/ConvTranspose_output_0_splitncnn_4 /encode/cnn3_1/ConvTranspose_output_0_splitncnn_4 /Mul_1_output_0_splitncnn_4 /Concat_1_output_0 +Interp /block0/Resize 1 1 /Concat_1_output_0 /block0/Resize_output_0 0=2 1=6.250000e-02 2=6.250000e-02 +Convolution /block0/conv0/conv0.0/conv0.0.0/Conv 1 1 /block0/Resize_output_0 /block0/conv0/conv0.0/conv0.0.1/LeakyRelu_output_0 0=96 1=3 3=2 4=1 5=1 6=12960 9=2 -23310=1,2.000000e-01 +Convolution /block0/conv0/conv0.1/conv0.1.0/Conv 1 1 /block0/conv0/conv0.0/conv0.0.1/LeakyRelu_output_0 /block0/conv0/conv0.1/conv0.1.1/LeakyRelu_output_0 0=192 1=3 3=2 4=1 5=1 6=165888 9=2 -23310=1,2.000000e-01 +Split splitncnn_5 1 2 /block0/conv0/conv0.1/conv0.1.1/LeakyRelu_output_0 /block0/conv0/conv0.1/conv0.1.1/LeakyRelu_output_0_splitncnn_0 /block0/conv0/conv0.1/conv0.1.1/LeakyRelu_output_0_splitncnn_1 +Convolution /block0/convblock/convblock.0/conv/Conv 1 1 /block0/conv0/conv0.1/conv0.1.1/LeakyRelu_output_0_splitncnn_1 /block0/convblock/convblock.0/conv/Conv_output_0 0=192 1=3 4=1 5=1 6=331776 +BinaryOp /block0/convblock/convblock.0/Mul 2 1 /block0/convblock/convblock.0/conv/Conv_output_0 block0.convblock.0.beta /block0/convblock/convblock.0/Mul_output_0 0=2 +BinaryOp /block0/convblock/convblock.0/Add 2 1 /block0/convblock/convblock.0/Mul_output_0 /block0/conv0/conv0.1/conv0.1.1/LeakyRelu_output_0_splitncnn_0 /block0/convblock/convblock.0/Add_output_0 +ReLU /block0/convblock/convblock.0/relu/LeakyRelu 1 1 /block0/convblock/convblock.0/Add_output_0 /block0/convblock/convblock.0/relu/LeakyRelu_output_0 0=2.000000e-01 +Split splitncnn_6 1 2 /block0/convblock/convblock.0/relu/LeakyRelu_output_0 /block0/convblock/convblock.0/relu/LeakyRelu_output_0_splitncnn_0 /block0/convblock/convblock.0/relu/LeakyRelu_output_0_splitncnn_1 +Convolution /block0/convblock/convblock.1/conv/Conv 1 1 /block0/convblock/convblock.0/relu/LeakyRelu_output_0_splitncnn_1 /block0/convblock/convblock.1/conv/Conv_output_0 0=192 1=3 4=1 5=1 6=331776 +BinaryOp /block0/convblock/convblock.1/Mul 2 1 /block0/convblock/convblock.1/conv/Conv_output_0 block0.convblock.1.beta /block0/convblock/convblock.1/Mul_output_0 0=2 +BinaryOp /block0/convblock/convblock.1/Add 2 1 /block0/convblock/convblock.1/Mul_output_0 /block0/convblock/convblock.0/relu/LeakyRelu_output_0_splitncnn_0 /block0/convblock/convblock.1/Add_output_0 +ReLU /block0/convblock/convblock.1/relu/LeakyRelu 1 1 /block0/convblock/convblock.1/Add_output_0 /block0/convblock/convblock.1/relu/LeakyRelu_output_0 0=2.000000e-01 +Split splitncnn_7 1 2 /block0/convblock/convblock.1/relu/LeakyRelu_output_0 /block0/convblock/convblock.1/relu/LeakyRelu_output_0_splitncnn_0 /block0/convblock/convblock.1/relu/LeakyRelu_output_0_splitncnn_1 +Convolution /block0/convblock/convblock.2/conv/Conv 1 1 /block0/convblock/convblock.1/relu/LeakyRelu_output_0_splitncnn_1 /block0/convblock/convblock.2/conv/Conv_output_0 0=192 1=3 4=1 5=1 6=331776 +BinaryOp /block0/convblock/convblock.2/Mul 2 1 /block0/convblock/convblock.2/conv/Conv_output_0 block0.convblock.2.beta /block0/convblock/convblock.2/Mul_output_0 0=2 +BinaryOp /block0/convblock/convblock.2/Add 2 1 /block0/convblock/convblock.2/Mul_output_0 /block0/convblock/convblock.1/relu/LeakyRelu_output_0_splitncnn_0 /block0/convblock/convblock.2/Add_output_0 +ReLU /block0/convblock/convblock.2/relu/LeakyRelu 1 1 /block0/convblock/convblock.2/Add_output_0 /block0/convblock/convblock.2/relu/LeakyRelu_output_0 0=2.000000e-01 +Split splitncnn_8 1 2 /block0/convblock/convblock.2/relu/LeakyRelu_output_0 /block0/convblock/convblock.2/relu/LeakyRelu_output_0_splitncnn_0 /block0/convblock/convblock.2/relu/LeakyRelu_output_0_splitncnn_1 +Convolution /block0/convblock/convblock.3/conv/Conv 1 1 /block0/convblock/convblock.2/relu/LeakyRelu_output_0_splitncnn_1 /block0/convblock/convblock.3/conv/Conv_output_0 0=192 1=3 4=1 5=1 6=331776 +BinaryOp /block0/convblock/convblock.3/Mul 2 1 /block0/convblock/convblock.3/conv/Conv_output_0 block0.convblock.3.beta /block0/convblock/convblock.3/Mul_output_0 0=2 +BinaryOp /block0/convblock/convblock.3/Add 2 1 /block0/convblock/convblock.3/Mul_output_0 /block0/convblock/convblock.2/relu/LeakyRelu_output_0_splitncnn_0 /block0/convblock/convblock.3/Add_output_0 +ReLU /block0/convblock/convblock.3/relu/LeakyRelu 1 1 /block0/convblock/convblock.3/Add_output_0 /block0/convblock/convblock.3/relu/LeakyRelu_output_0 0=2.000000e-01 +Split splitncnn_9 1 2 /block0/convblock/convblock.3/relu/LeakyRelu_output_0 /block0/convblock/convblock.3/relu/LeakyRelu_output_0_splitncnn_0 /block0/convblock/convblock.3/relu/LeakyRelu_output_0_splitncnn_1 +Convolution /block0/convblock/convblock.4/conv/Conv 1 1 /block0/convblock/convblock.3/relu/LeakyRelu_output_0_splitncnn_1 /block0/convblock/convblock.4/conv/Conv_output_0 0=192 1=3 4=1 5=1 6=331776 +BinaryOp /block0/convblock/convblock.4/Mul 2 1 /block0/convblock/convblock.4/conv/Conv_output_0 block0.convblock.4.beta /block0/convblock/convblock.4/Mul_output_0 0=2 +BinaryOp /block0/convblock/convblock.4/Add 2 1 /block0/convblock/convblock.4/Mul_output_0 /block0/convblock/convblock.3/relu/LeakyRelu_output_0_splitncnn_0 /block0/convblock/convblock.4/Add_output_0 +ReLU /block0/convblock/convblock.4/relu/LeakyRelu 1 1 /block0/convblock/convblock.4/Add_output_0 /block0/convblock/convblock.4/relu/LeakyRelu_output_0 0=2.000000e-01 +Split splitncnn_10 1 2 /block0/convblock/convblock.4/relu/LeakyRelu_output_0 /block0/convblock/convblock.4/relu/LeakyRelu_output_0_splitncnn_0 /block0/convblock/convblock.4/relu/LeakyRelu_output_0_splitncnn_1 +Convolution /block0/convblock/convblock.5/conv/Conv 1 1 /block0/convblock/convblock.4/relu/LeakyRelu_output_0_splitncnn_1 /block0/convblock/convblock.5/conv/Conv_output_0 0=192 1=3 4=1 5=1 6=331776 +BinaryOp /block0/convblock/convblock.5/Mul 2 1 /block0/convblock/convblock.5/conv/Conv_output_0 block0.convblock.5.beta /block0/convblock/convblock.5/Mul_output_0 0=2 +BinaryOp /block0/convblock/convblock.5/Add 2 1 /block0/convblock/convblock.5/Mul_output_0 /block0/convblock/convblock.4/relu/LeakyRelu_output_0_splitncnn_0 /block0/convblock/convblock.5/Add_output_0 +ReLU /block0/convblock/convblock.5/relu/LeakyRelu 1 1 /block0/convblock/convblock.5/Add_output_0 /block0/convblock/convblock.5/relu/LeakyRelu_output_0 0=2.000000e-01 +Split splitncnn_11 1 2 /block0/convblock/convblock.5/relu/LeakyRelu_output_0 /block0/convblock/convblock.5/relu/LeakyRelu_output_0_splitncnn_0 /block0/convblock/convblock.5/relu/LeakyRelu_output_0_splitncnn_1 +Convolution /block0/convblock/convblock.6/conv/Conv 1 1 /block0/convblock/convblock.5/relu/LeakyRelu_output_0_splitncnn_1 /block0/convblock/convblock.6/conv/Conv_output_0 0=192 1=3 4=1 5=1 6=331776 +BinaryOp /block0/convblock/convblock.6/Mul 2 1 /block0/convblock/convblock.6/conv/Conv_output_0 block0.convblock.6.beta /block0/convblock/convblock.6/Mul_output_0 0=2 +BinaryOp /block0/convblock/convblock.6/Add 2 1 /block0/convblock/convblock.6/Mul_output_0 /block0/convblock/convblock.5/relu/LeakyRelu_output_0_splitncnn_0 /block0/convblock/convblock.6/Add_output_0 +ReLU /block0/convblock/convblock.6/relu/LeakyRelu 1 1 /block0/convblock/convblock.6/Add_output_0 /block0/convblock/convblock.6/relu/LeakyRelu_output_0 0=2.000000e-01 +Split splitncnn_12 1 2 /block0/convblock/convblock.6/relu/LeakyRelu_output_0 /block0/convblock/convblock.6/relu/LeakyRelu_output_0_splitncnn_0 /block0/convblock/convblock.6/relu/LeakyRelu_output_0_splitncnn_1 +Convolution /block0/convblock/convblock.7/conv/Conv 1 1 /block0/convblock/convblock.6/relu/LeakyRelu_output_0_splitncnn_1 /block0/convblock/convblock.7/conv/Conv_output_0 0=192 1=3 4=1 5=1 6=331776 +BinaryOp /block0/convblock/convblock.7/Mul 2 1 /block0/convblock/convblock.7/conv/Conv_output_0 block0.convblock.7.beta /block0/convblock/convblock.7/Mul_output_0 0=2 +BinaryOp /block0/convblock/convblock.7/Add 2 1 /block0/convblock/convblock.7/Mul_output_0 /block0/convblock/convblock.6/relu/LeakyRelu_output_0_splitncnn_0 /block0/convblock/convblock.7/Add_output_0 +ReLU /block0/convblock/convblock.7/relu/LeakyRelu 1 1 /block0/convblock/convblock.7/Add_output_0 /block0/convblock/convblock.7/relu/LeakyRelu_output_0 0=2.000000e-01 +Deconvolution /block0/lastconv/lastconv.0/ConvTranspose 1 1 /block0/convblock/convblock.7/relu/LeakyRelu_output_0 /block0/lastconv/lastconv.0/ConvTranspose_output_0 0=52 1=4 3=2 4=1 5=1 6=159744 +PixelShuffle /block0/lastconv/lastconv.1/DepthToSpace 1 1 /block0/lastconv/lastconv.0/ConvTranspose_output_0 /block0/lastconv/lastconv.1/DepthToSpace_output_0 0=2 +Interp /block0/Resize_1 1 1 /block0/lastconv/lastconv.1/DepthToSpace_output_0 /block0/Resize_1_output_0 0=2 1=1.600000e+01 2=1.600000e+01 +Split splitncnn_13 1 3 /block0/Resize_1_output_0 /block0/Resize_1_output_0_splitncnn_0 /block0/Resize_1_output_0_splitncnn_1 /block0/Resize_1_output_0_splitncnn_2 +Crop /block0/Slice 1 1 /block0/Resize_1_output_0_splitncnn_2 /block0/Slice_output_0 -23309=1,0 -23310=1,4 -23311=1,0 +BinaryOp /block0/Mul 1 1 /block0/Slice_output_0 /block0/Mul_output_0 0=2 1=1 2=1.600000e+01 +Split splitncnn_14 1 6 /block0/Mul_output_0 /block0/Mul_output_0_splitncnn_0 /block0/Mul_output_0_splitncnn_1 /block0/Mul_output_0_splitncnn_2 /block0/Mul_output_0_splitncnn_3 /block0/Mul_output_0_splitncnn_4 /block0/Mul_output_0_splitncnn_5 +Crop /block0/Slice_1 1 1 /block0/Resize_1_output_0_splitncnn_1 /block0/Slice_1_output_0 -23309=1,4 -23310=1,5 -23311=1,0 +Crop /block0/Slice_2 1 1 /block0/Resize_1_output_0_splitncnn_0 /block0/Slice_2_output_0 -23309=1,5 -23310=1,2147483647 -23311=1,0 +Crop /Slice_3 1 1 /block0/Mul_output_0_splitncnn_5 /Slice_3_output_0 -23309=1,0 -23310=1,2 -23311=1,0 +rife.Warp /warp 2 1 in0_splitncnn_4 /Slice_3_output_0 /warp_output_0 0=6 +Crop /Slice_4 1 1 /block0/Mul_output_0_splitncnn_4 /Slice_4_output_0 -23309=1,2 -23310=1,4 -23311=1,0 +rife.Warp /warp_1 2 1 in1_splitncnn_4 /Slice_4_output_0 /warp_1_output_0 0=6 +Crop /Slice_5 1 1 /block0/Mul_output_0_splitncnn_3 /Slice_5_output_0 -23309=1,0 -23310=1,2 -23311=1,0 +rife.Warp /warp_2 2 1 /encode/cnn3/ConvTranspose_output_0_splitncnn_3 /Slice_5_output_0 /warp_2_output_0 0=6 +Crop /Slice_6 1 1 /block0/Mul_output_0_splitncnn_2 /Slice_6_output_0 -23309=1,2 -23310=1,4 -23311=1,0 +rife.Warp /warp_3 2 1 /encode/cnn3_1/ConvTranspose_output_0_splitncnn_3 /Slice_6_output_0 /warp_3_output_0 0=6 +Crop /Slice_7 1 1 /warp_output_0 /Slice_7_output_0 -23309=1,0 -23310=1,3 -23311=1,0 +Crop /Slice_8 1 1 /warp_1_output_0 /Slice_8_output_0 -23309=1,0 -23310=1,3 -23311=1,0 +Concat /Concat_2 7 1 /Slice_7_output_0 /Slice_8_output_0 /warp_2_output_0 /warp_3_output_0 /Mul_1_output_0_splitncnn_3 /block0/Slice_1_output_0 /block0/Slice_2_output_0 /Concat_2_output_0 +Interp /block1/Resize 1 1 /Concat_2_output_0 /block1/Resize_output_0 0=2 1=1.250000e-01 2=1.250000e-01 +Interp /block1/Resize_1 1 1 /block0/Mul_output_0_splitncnn_1 /block1/Resize_1_output_0 0=2 1=1.250000e-01 2=1.250000e-01 +BinaryOp /block1/Div 1 1 /block1/Resize_1_output_0 /block1/Div_output_0 0=3 1=1 2=8.000000e+00 +Concat /block1/Concat 2 1 /block1/Resize_output_0 /block1/Div_output_0 /block1/Concat_output_0 +Convolution /block1/conv0/conv0.0/conv0.0.0/Conv 1 1 /block1/Concat_output_0 /block1/conv0/conv0.0/conv0.0.1/LeakyRelu_output_0 0=64 1=3 3=2 4=1 5=1 6=16128 9=2 -23310=1,2.000000e-01 +Convolution /block1/conv0/conv0.1/conv0.1.0/Conv 1 1 /block1/conv0/conv0.0/conv0.0.1/LeakyRelu_output_0 /block1/conv0/conv0.1/conv0.1.1/LeakyRelu_output_0 0=128 1=3 3=2 4=1 5=1 6=73728 9=2 -23310=1,2.000000e-01 +Split splitncnn_15 1 2 /block1/conv0/conv0.1/conv0.1.1/LeakyRelu_output_0 /block1/conv0/conv0.1/conv0.1.1/LeakyRelu_output_0_splitncnn_0 /block1/conv0/conv0.1/conv0.1.1/LeakyRelu_output_0_splitncnn_1 +Convolution /block1/convblock/convblock.0/conv/Conv 1 1 /block1/conv0/conv0.1/conv0.1.1/LeakyRelu_output_0_splitncnn_1 /block1/convblock/convblock.0/conv/Conv_output_0 0=128 1=3 4=1 5=1 6=147456 +BinaryOp /block1/convblock/convblock.0/Mul 2 1 /block1/convblock/convblock.0/conv/Conv_output_0 block1.convblock.0.beta /block1/convblock/convblock.0/Mul_output_0 0=2 +BinaryOp /block1/convblock/convblock.0/Add 2 1 /block1/convblock/convblock.0/Mul_output_0 /block1/conv0/conv0.1/conv0.1.1/LeakyRelu_output_0_splitncnn_0 /block1/convblock/convblock.0/Add_output_0 +ReLU /block1/convblock/convblock.0/relu/LeakyRelu 1 1 /block1/convblock/convblock.0/Add_output_0 /block1/convblock/convblock.0/relu/LeakyRelu_output_0 0=2.000000e-01 +Split splitncnn_16 1 2 /block1/convblock/convblock.0/relu/LeakyRelu_output_0 /block1/convblock/convblock.0/relu/LeakyRelu_output_0_splitncnn_0 /block1/convblock/convblock.0/relu/LeakyRelu_output_0_splitncnn_1 +Convolution /block1/convblock/convblock.1/conv/Conv 1 1 /block1/convblock/convblock.0/relu/LeakyRelu_output_0_splitncnn_1 /block1/convblock/convblock.1/conv/Conv_output_0 0=128 1=3 4=1 5=1 6=147456 +BinaryOp /block1/convblock/convblock.1/Mul 2 1 /block1/convblock/convblock.1/conv/Conv_output_0 block1.convblock.1.beta /block1/convblock/convblock.1/Mul_output_0 0=2 +BinaryOp /block1/convblock/convblock.1/Add 2 1 /block1/convblock/convblock.1/Mul_output_0 /block1/convblock/convblock.0/relu/LeakyRelu_output_0_splitncnn_0 /block1/convblock/convblock.1/Add_output_0 +ReLU /block1/convblock/convblock.1/relu/LeakyRelu 1 1 /block1/convblock/convblock.1/Add_output_0 /block1/convblock/convblock.1/relu/LeakyRelu_output_0 0=2.000000e-01 +Split splitncnn_17 1 2 /block1/convblock/convblock.1/relu/LeakyRelu_output_0 /block1/convblock/convblock.1/relu/LeakyRelu_output_0_splitncnn_0 /block1/convblock/convblock.1/relu/LeakyRelu_output_0_splitncnn_1 +Convolution /block1/convblock/convblock.2/conv/Conv 1 1 /block1/convblock/convblock.1/relu/LeakyRelu_output_0_splitncnn_1 /block1/convblock/convblock.2/conv/Conv_output_0 0=128 1=3 4=1 5=1 6=147456 +BinaryOp /block1/convblock/convblock.2/Mul 2 1 /block1/convblock/convblock.2/conv/Conv_output_0 block1.convblock.2.beta /block1/convblock/convblock.2/Mul_output_0 0=2 +BinaryOp /block1/convblock/convblock.2/Add 2 1 /block1/convblock/convblock.2/Mul_output_0 /block1/convblock/convblock.1/relu/LeakyRelu_output_0_splitncnn_0 /block1/convblock/convblock.2/Add_output_0 +ReLU /block1/convblock/convblock.2/relu/LeakyRelu 1 1 /block1/convblock/convblock.2/Add_output_0 /block1/convblock/convblock.2/relu/LeakyRelu_output_0 0=2.000000e-01 +Split splitncnn_18 1 2 /block1/convblock/convblock.2/relu/LeakyRelu_output_0 /block1/convblock/convblock.2/relu/LeakyRelu_output_0_splitncnn_0 /block1/convblock/convblock.2/relu/LeakyRelu_output_0_splitncnn_1 +Convolution /block1/convblock/convblock.3/conv/Conv 1 1 /block1/convblock/convblock.2/relu/LeakyRelu_output_0_splitncnn_1 /block1/convblock/convblock.3/conv/Conv_output_0 0=128 1=3 4=1 5=1 6=147456 +BinaryOp /block1/convblock/convblock.3/Mul 2 1 /block1/convblock/convblock.3/conv/Conv_output_0 block1.convblock.3.beta /block1/convblock/convblock.3/Mul_output_0 0=2 +BinaryOp /block1/convblock/convblock.3/Add 2 1 /block1/convblock/convblock.3/Mul_output_0 /block1/convblock/convblock.2/relu/LeakyRelu_output_0_splitncnn_0 /block1/convblock/convblock.3/Add_output_0 +ReLU /block1/convblock/convblock.3/relu/LeakyRelu 1 1 /block1/convblock/convblock.3/Add_output_0 /block1/convblock/convblock.3/relu/LeakyRelu_output_0 0=2.000000e-01 +Split splitncnn_19 1 2 /block1/convblock/convblock.3/relu/LeakyRelu_output_0 /block1/convblock/convblock.3/relu/LeakyRelu_output_0_splitncnn_0 /block1/convblock/convblock.3/relu/LeakyRelu_output_0_splitncnn_1 +Convolution /block1/convblock/convblock.4/conv/Conv 1 1 /block1/convblock/convblock.3/relu/LeakyRelu_output_0_splitncnn_1 /block1/convblock/convblock.4/conv/Conv_output_0 0=128 1=3 4=1 5=1 6=147456 +BinaryOp /block1/convblock/convblock.4/Mul 2 1 /block1/convblock/convblock.4/conv/Conv_output_0 block1.convblock.4.beta /block1/convblock/convblock.4/Mul_output_0 0=2 +BinaryOp /block1/convblock/convblock.4/Add 2 1 /block1/convblock/convblock.4/Mul_output_0 /block1/convblock/convblock.3/relu/LeakyRelu_output_0_splitncnn_0 /block1/convblock/convblock.4/Add_output_0 +ReLU /block1/convblock/convblock.4/relu/LeakyRelu 1 1 /block1/convblock/convblock.4/Add_output_0 /block1/convblock/convblock.4/relu/LeakyRelu_output_0 0=2.000000e-01 +Split splitncnn_20 1 2 /block1/convblock/convblock.4/relu/LeakyRelu_output_0 /block1/convblock/convblock.4/relu/LeakyRelu_output_0_splitncnn_0 /block1/convblock/convblock.4/relu/LeakyRelu_output_0_splitncnn_1 +Convolution /block1/convblock/convblock.5/conv/Conv 1 1 /block1/convblock/convblock.4/relu/LeakyRelu_output_0_splitncnn_1 /block1/convblock/convblock.5/conv/Conv_output_0 0=128 1=3 4=1 5=1 6=147456 +BinaryOp /block1/convblock/convblock.5/Mul 2 1 /block1/convblock/convblock.5/conv/Conv_output_0 block1.convblock.5.beta /block1/convblock/convblock.5/Mul_output_0 0=2 +BinaryOp /block1/convblock/convblock.5/Add 2 1 /block1/convblock/convblock.5/Mul_output_0 /block1/convblock/convblock.4/relu/LeakyRelu_output_0_splitncnn_0 /block1/convblock/convblock.5/Add_output_0 +ReLU /block1/convblock/convblock.5/relu/LeakyRelu 1 1 /block1/convblock/convblock.5/Add_output_0 /block1/convblock/convblock.5/relu/LeakyRelu_output_0 0=2.000000e-01 +Split splitncnn_21 1 2 /block1/convblock/convblock.5/relu/LeakyRelu_output_0 /block1/convblock/convblock.5/relu/LeakyRelu_output_0_splitncnn_0 /block1/convblock/convblock.5/relu/LeakyRelu_output_0_splitncnn_1 +Convolution /block1/convblock/convblock.6/conv/Conv 1 1 /block1/convblock/convblock.5/relu/LeakyRelu_output_0_splitncnn_1 /block1/convblock/convblock.6/conv/Conv_output_0 0=128 1=3 4=1 5=1 6=147456 +BinaryOp /block1/convblock/convblock.6/Mul 2 1 /block1/convblock/convblock.6/conv/Conv_output_0 block1.convblock.6.beta /block1/convblock/convblock.6/Mul_output_0 0=2 +BinaryOp /block1/convblock/convblock.6/Add 2 1 /block1/convblock/convblock.6/Mul_output_0 /block1/convblock/convblock.5/relu/LeakyRelu_output_0_splitncnn_0 /block1/convblock/convblock.6/Add_output_0 +ReLU /block1/convblock/convblock.6/relu/LeakyRelu 1 1 /block1/convblock/convblock.6/Add_output_0 /block1/convblock/convblock.6/relu/LeakyRelu_output_0 0=2.000000e-01 +Split splitncnn_22 1 2 /block1/convblock/convblock.6/relu/LeakyRelu_output_0 /block1/convblock/convblock.6/relu/LeakyRelu_output_0_splitncnn_0 /block1/convblock/convblock.6/relu/LeakyRelu_output_0_splitncnn_1 +Convolution /block1/convblock/convblock.7/conv/Conv 1 1 /block1/convblock/convblock.6/relu/LeakyRelu_output_0_splitncnn_1 /block1/convblock/convblock.7/conv/Conv_output_0 0=128 1=3 4=1 5=1 6=147456 +BinaryOp /block1/convblock/convblock.7/Mul 2 1 /block1/convblock/convblock.7/conv/Conv_output_0 block1.convblock.7.beta /block1/convblock/convblock.7/Mul_output_0 0=2 +BinaryOp /block1/convblock/convblock.7/Add 2 1 /block1/convblock/convblock.7/Mul_output_0 /block1/convblock/convblock.6/relu/LeakyRelu_output_0_splitncnn_0 /block1/convblock/convblock.7/Add_output_0 +ReLU /block1/convblock/convblock.7/relu/LeakyRelu 1 1 /block1/convblock/convblock.7/Add_output_0 /block1/convblock/convblock.7/relu/LeakyRelu_output_0 0=2.000000e-01 +Deconvolution /block1/lastconv/lastconv.0/ConvTranspose 1 1 /block1/convblock/convblock.7/relu/LeakyRelu_output_0 /block1/lastconv/lastconv.0/ConvTranspose_output_0 0=52 1=4 3=2 4=1 5=1 6=106496 +PixelShuffle /block1/lastconv/lastconv.1/DepthToSpace 1 1 /block1/lastconv/lastconv.0/ConvTranspose_output_0 /block1/lastconv/lastconv.1/DepthToSpace_output_0 0=2 +Interp /block1/Resize_2 1 1 /block1/lastconv/lastconv.1/DepthToSpace_output_0 /block1/Resize_2_output_0 0=2 1=8.000000e+00 2=8.000000e+00 +Split splitncnn_23 1 3 /block1/Resize_2_output_0 /block1/Resize_2_output_0_splitncnn_0 /block1/Resize_2_output_0_splitncnn_1 /block1/Resize_2_output_0_splitncnn_2 +Crop /block1/Slice 1 1 /block1/Resize_2_output_0_splitncnn_2 /block1/Slice_output_0 -23309=1,0 -23310=1,4 -23311=1,0 +Crop /block1/Slice_1 1 1 /block1/Resize_2_output_0_splitncnn_1 /block1/Slice_1_output_0 -23309=1,4 -23310=1,5 -23311=1,0 +Crop /block1/Slice_2 1 1 /block1/Resize_2_output_0_splitncnn_0 /block1/Slice_2_output_0 -23309=1,5 -23310=1,2147483647 -23311=1,0 +Eltwise /Add_1 2 1 /block0/Mul_output_0_splitncnn_0 /block1/Slice_output_0 /Add_1_output_0 0=1 -23301=2,1.000000e+00,8.000000e+00 +Split splitncnn_24 1 6 /Add_1_output_0 /Add_1_output_0_splitncnn_0 /Add_1_output_0_splitncnn_1 /Add_1_output_0_splitncnn_2 /Add_1_output_0_splitncnn_3 /Add_1_output_0_splitncnn_4 /Add_1_output_0_splitncnn_5 +Crop /Slice_9 1 1 /Add_1_output_0_splitncnn_5 /Slice_9_output_0 -23309=1,0 -23310=1,2 -23311=1,0 +rife.Warp /warp_4 2 1 in0_splitncnn_3 /Slice_9_output_0 /warp_4_output_0 0=6 +Crop /Slice_10 1 1 /Add_1_output_0_splitncnn_4 /Slice_10_output_0 -23309=1,2 -23310=1,4 -23311=1,0 +rife.Warp /warp_5 2 1 in1_splitncnn_3 /Slice_10_output_0 /warp_5_output_0 0=6 +Crop /Slice_11 1 1 /Add_1_output_0_splitncnn_3 /Slice_11_output_0 -23309=1,0 -23310=1,2 -23311=1,0 +rife.Warp /warp_6 2 1 /encode/cnn3/ConvTranspose_output_0_splitncnn_2 /Slice_11_output_0 /warp_6_output_0 0=6 +Crop /Slice_12 1 1 /Add_1_output_0_splitncnn_2 /Slice_12_output_0 -23309=1,2 -23310=1,4 -23311=1,0 +rife.Warp /warp_7 2 1 /encode/cnn3_1/ConvTranspose_output_0_splitncnn_2 /Slice_12_output_0 /warp_7_output_0 0=6 +Crop /Slice_13 1 1 /warp_4_output_0 /Slice_13_output_0 -23309=1,0 -23310=1,3 -23311=1,0 +Crop /Slice_14 1 1 /warp_5_output_0 /Slice_14_output_0 -23309=1,0 -23310=1,3 -23311=1,0 +Concat /Concat_3 7 1 /Slice_13_output_0 /Slice_14_output_0 /warp_6_output_0 /warp_7_output_0 /Mul_1_output_0_splitncnn_2 /block1/Slice_1_output_0 /block1/Slice_2_output_0 /Concat_3_output_0 +Interp /block2/Resize 1 1 /Concat_3_output_0 /block2/Resize_output_0 0=2 1=2.500000e-01 2=2.500000e-01 +Interp /block2/Resize_1 1 1 /Add_1_output_0_splitncnn_1 /block2/Resize_1_output_0 0=2 1=2.500000e-01 2=2.500000e-01 +BinaryOp /block2/Div 1 1 /block2/Resize_1_output_0 /block2/Div_output_0 0=3 1=1 2=4.000000e+00 +Concat /block2/Concat 2 1 /block2/Resize_output_0 /block2/Div_output_0 /block2/Concat_output_0 +Convolution /block2/conv0/conv0.0/conv0.0.0/Conv 1 1 /block2/Concat_output_0 /block2/conv0/conv0.0/conv0.0.1/LeakyRelu_output_0 0=48 1=3 3=2 4=1 5=1 6=12096 9=2 -23310=1,2.000000e-01 +Convolution /block2/conv0/conv0.1/conv0.1.0/Conv 1 1 /block2/conv0/conv0.0/conv0.0.1/LeakyRelu_output_0 /block2/conv0/conv0.1/conv0.1.1/LeakyRelu_output_0 0=96 1=3 3=2 4=1 5=1 6=41472 9=2 -23310=1,2.000000e-01 +Split splitncnn_25 1 2 /block2/conv0/conv0.1/conv0.1.1/LeakyRelu_output_0 /block2/conv0/conv0.1/conv0.1.1/LeakyRelu_output_0_splitncnn_0 /block2/conv0/conv0.1/conv0.1.1/LeakyRelu_output_0_splitncnn_1 +Convolution /block2/convblock/convblock.0/conv/Conv 1 1 /block2/conv0/conv0.1/conv0.1.1/LeakyRelu_output_0_splitncnn_1 /block2/convblock/convblock.0/conv/Conv_output_0 0=96 1=3 4=1 5=1 6=82944 +BinaryOp /block2/convblock/convblock.0/Mul 2 1 /block2/convblock/convblock.0/conv/Conv_output_0 block2.convblock.0.beta /block2/convblock/convblock.0/Mul_output_0 0=2 +BinaryOp /block2/convblock/convblock.0/Add 2 1 /block2/convblock/convblock.0/Mul_output_0 /block2/conv0/conv0.1/conv0.1.1/LeakyRelu_output_0_splitncnn_0 /block2/convblock/convblock.0/Add_output_0 +ReLU /block2/convblock/convblock.0/relu/LeakyRelu 1 1 /block2/convblock/convblock.0/Add_output_0 /block2/convblock/convblock.0/relu/LeakyRelu_output_0 0=2.000000e-01 +Split splitncnn_26 1 2 /block2/convblock/convblock.0/relu/LeakyRelu_output_0 /block2/convblock/convblock.0/relu/LeakyRelu_output_0_splitncnn_0 /block2/convblock/convblock.0/relu/LeakyRelu_output_0_splitncnn_1 +Convolution /block2/convblock/convblock.1/conv/Conv 1 1 /block2/convblock/convblock.0/relu/LeakyRelu_output_0_splitncnn_1 /block2/convblock/convblock.1/conv/Conv_output_0 0=96 1=3 4=1 5=1 6=82944 +BinaryOp /block2/convblock/convblock.1/Mul 2 1 /block2/convblock/convblock.1/conv/Conv_output_0 block2.convblock.1.beta /block2/convblock/convblock.1/Mul_output_0 0=2 +BinaryOp /block2/convblock/convblock.1/Add 2 1 /block2/convblock/convblock.1/Mul_output_0 /block2/convblock/convblock.0/relu/LeakyRelu_output_0_splitncnn_0 /block2/convblock/convblock.1/Add_output_0 +ReLU /block2/convblock/convblock.1/relu/LeakyRelu 1 1 /block2/convblock/convblock.1/Add_output_0 /block2/convblock/convblock.1/relu/LeakyRelu_output_0 0=2.000000e-01 +Split splitncnn_27 1 2 /block2/convblock/convblock.1/relu/LeakyRelu_output_0 /block2/convblock/convblock.1/relu/LeakyRelu_output_0_splitncnn_0 /block2/convblock/convblock.1/relu/LeakyRelu_output_0_splitncnn_1 +Convolution /block2/convblock/convblock.2/conv/Conv 1 1 /block2/convblock/convblock.1/relu/LeakyRelu_output_0_splitncnn_1 /block2/convblock/convblock.2/conv/Conv_output_0 0=96 1=3 4=1 5=1 6=82944 +BinaryOp /block2/convblock/convblock.2/Mul 2 1 /block2/convblock/convblock.2/conv/Conv_output_0 block2.convblock.2.beta /block2/convblock/convblock.2/Mul_output_0 0=2 +BinaryOp /block2/convblock/convblock.2/Add 2 1 /block2/convblock/convblock.2/Mul_output_0 /block2/convblock/convblock.1/relu/LeakyRelu_output_0_splitncnn_0 /block2/convblock/convblock.2/Add_output_0 +ReLU /block2/convblock/convblock.2/relu/LeakyRelu 1 1 /block2/convblock/convblock.2/Add_output_0 /block2/convblock/convblock.2/relu/LeakyRelu_output_0 0=2.000000e-01 +Split splitncnn_28 1 2 /block2/convblock/convblock.2/relu/LeakyRelu_output_0 /block2/convblock/convblock.2/relu/LeakyRelu_output_0_splitncnn_0 /block2/convblock/convblock.2/relu/LeakyRelu_output_0_splitncnn_1 +Convolution /block2/convblock/convblock.3/conv/Conv 1 1 /block2/convblock/convblock.2/relu/LeakyRelu_output_0_splitncnn_1 /block2/convblock/convblock.3/conv/Conv_output_0 0=96 1=3 4=1 5=1 6=82944 +BinaryOp /block2/convblock/convblock.3/Mul 2 1 /block2/convblock/convblock.3/conv/Conv_output_0 block2.convblock.3.beta /block2/convblock/convblock.3/Mul_output_0 0=2 +BinaryOp /block2/convblock/convblock.3/Add 2 1 /block2/convblock/convblock.3/Mul_output_0 /block2/convblock/convblock.2/relu/LeakyRelu_output_0_splitncnn_0 /block2/convblock/convblock.3/Add_output_0 +ReLU /block2/convblock/convblock.3/relu/LeakyRelu 1 1 /block2/convblock/convblock.3/Add_output_0 /block2/convblock/convblock.3/relu/LeakyRelu_output_0 0=2.000000e-01 +Split splitncnn_29 1 2 /block2/convblock/convblock.3/relu/LeakyRelu_output_0 /block2/convblock/convblock.3/relu/LeakyRelu_output_0_splitncnn_0 /block2/convblock/convblock.3/relu/LeakyRelu_output_0_splitncnn_1 +Convolution /block2/convblock/convblock.4/conv/Conv 1 1 /block2/convblock/convblock.3/relu/LeakyRelu_output_0_splitncnn_1 /block2/convblock/convblock.4/conv/Conv_output_0 0=96 1=3 4=1 5=1 6=82944 +BinaryOp /block2/convblock/convblock.4/Mul 2 1 /block2/convblock/convblock.4/conv/Conv_output_0 block2.convblock.4.beta /block2/convblock/convblock.4/Mul_output_0 0=2 +BinaryOp /block2/convblock/convblock.4/Add 2 1 /block2/convblock/convblock.4/Mul_output_0 /block2/convblock/convblock.3/relu/LeakyRelu_output_0_splitncnn_0 /block2/convblock/convblock.4/Add_output_0 +ReLU /block2/convblock/convblock.4/relu/LeakyRelu 1 1 /block2/convblock/convblock.4/Add_output_0 /block2/convblock/convblock.4/relu/LeakyRelu_output_0 0=2.000000e-01 +Split splitncnn_30 1 2 /block2/convblock/convblock.4/relu/LeakyRelu_output_0 /block2/convblock/convblock.4/relu/LeakyRelu_output_0_splitncnn_0 /block2/convblock/convblock.4/relu/LeakyRelu_output_0_splitncnn_1 +Convolution /block2/convblock/convblock.5/conv/Conv 1 1 /block2/convblock/convblock.4/relu/LeakyRelu_output_0_splitncnn_1 /block2/convblock/convblock.5/conv/Conv_output_0 0=96 1=3 4=1 5=1 6=82944 +BinaryOp /block2/convblock/convblock.5/Mul 2 1 /block2/convblock/convblock.5/conv/Conv_output_0 block2.convblock.5.beta /block2/convblock/convblock.5/Mul_output_0 0=2 +BinaryOp /block2/convblock/convblock.5/Add 2 1 /block2/convblock/convblock.5/Mul_output_0 /block2/convblock/convblock.4/relu/LeakyRelu_output_0_splitncnn_0 /block2/convblock/convblock.5/Add_output_0 +ReLU /block2/convblock/convblock.5/relu/LeakyRelu 1 1 /block2/convblock/convblock.5/Add_output_0 /block2/convblock/convblock.5/relu/LeakyRelu_output_0 0=2.000000e-01 +Split splitncnn_31 1 2 /block2/convblock/convblock.5/relu/LeakyRelu_output_0 /block2/convblock/convblock.5/relu/LeakyRelu_output_0_splitncnn_0 /block2/convblock/convblock.5/relu/LeakyRelu_output_0_splitncnn_1 +Convolution /block2/convblock/convblock.6/conv/Conv 1 1 /block2/convblock/convblock.5/relu/LeakyRelu_output_0_splitncnn_1 /block2/convblock/convblock.6/conv/Conv_output_0 0=96 1=3 4=1 5=1 6=82944 +BinaryOp /block2/convblock/convblock.6/Mul 2 1 /block2/convblock/convblock.6/conv/Conv_output_0 block2.convblock.6.beta /block2/convblock/convblock.6/Mul_output_0 0=2 +BinaryOp /block2/convblock/convblock.6/Add 2 1 /block2/convblock/convblock.6/Mul_output_0 /block2/convblock/convblock.5/relu/LeakyRelu_output_0_splitncnn_0 /block2/convblock/convblock.6/Add_output_0 +ReLU /block2/convblock/convblock.6/relu/LeakyRelu 1 1 /block2/convblock/convblock.6/Add_output_0 /block2/convblock/convblock.6/relu/LeakyRelu_output_0 0=2.000000e-01 +Split splitncnn_32 1 2 /block2/convblock/convblock.6/relu/LeakyRelu_output_0 /block2/convblock/convblock.6/relu/LeakyRelu_output_0_splitncnn_0 /block2/convblock/convblock.6/relu/LeakyRelu_output_0_splitncnn_1 +Convolution /block2/convblock/convblock.7/conv/Conv 1 1 /block2/convblock/convblock.6/relu/LeakyRelu_output_0_splitncnn_1 /block2/convblock/convblock.7/conv/Conv_output_0 0=96 1=3 4=1 5=1 6=82944 +BinaryOp /block2/convblock/convblock.7/Mul 2 1 /block2/convblock/convblock.7/conv/Conv_output_0 block2.convblock.7.beta /block2/convblock/convblock.7/Mul_output_0 0=2 +BinaryOp /block2/convblock/convblock.7/Add 2 1 /block2/convblock/convblock.7/Mul_output_0 /block2/convblock/convblock.6/relu/LeakyRelu_output_0_splitncnn_0 /block2/convblock/convblock.7/Add_output_0 +ReLU /block2/convblock/convblock.7/relu/LeakyRelu 1 1 /block2/convblock/convblock.7/Add_output_0 /block2/convblock/convblock.7/relu/LeakyRelu_output_0 0=2.000000e-01 +Deconvolution /block2/lastconv/lastconv.0/ConvTranspose 1 1 /block2/convblock/convblock.7/relu/LeakyRelu_output_0 /block2/lastconv/lastconv.0/ConvTranspose_output_0 0=52 1=4 3=2 4=1 5=1 6=79872 +PixelShuffle /block2/lastconv/lastconv.1/DepthToSpace 1 1 /block2/lastconv/lastconv.0/ConvTranspose_output_0 /block2/lastconv/lastconv.1/DepthToSpace_output_0 0=2 +Interp /block2/Resize_2 1 1 /block2/lastconv/lastconv.1/DepthToSpace_output_0 /block2/Resize_2_output_0 0=2 1=4.000000e+00 2=4.000000e+00 +Split splitncnn_33 1 3 /block2/Resize_2_output_0 /block2/Resize_2_output_0_splitncnn_0 /block2/Resize_2_output_0_splitncnn_1 /block2/Resize_2_output_0_splitncnn_2 +Crop /block2/Slice 1 1 /block2/Resize_2_output_0_splitncnn_2 /block2/Slice_output_0 -23309=1,0 -23310=1,4 -23311=1,0 +Crop /block2/Slice_1 1 1 /block2/Resize_2_output_0_splitncnn_1 /block2/Slice_1_output_0 -23309=1,4 -23310=1,5 -23311=1,0 +Crop /block2/Slice_2 1 1 /block2/Resize_2_output_0_splitncnn_0 /block2/Slice_2_output_0 -23309=1,5 -23310=1,2147483647 -23311=1,0 +Eltwise /Add_2 2 1 /Add_1_output_0_splitncnn_0 /block2/Slice_output_0 /Add_2_output_0 0=1 -23301=2,1.000000e+00,4.000000e+00 +Split splitncnn_34 1 6 /Add_2_output_0 /Add_2_output_0_splitncnn_0 /Add_2_output_0_splitncnn_1 /Add_2_output_0_splitncnn_2 /Add_2_output_0_splitncnn_3 /Add_2_output_0_splitncnn_4 /Add_2_output_0_splitncnn_5 +Crop /Slice_15 1 1 /Add_2_output_0_splitncnn_5 /Slice_15_output_0 -23309=1,0 -23310=1,2 -23311=1,0 +rife.Warp /warp_8 2 1 in0_splitncnn_2 /Slice_15_output_0 /warp_8_output_0 0=6 +Crop /Slice_16 1 1 /Add_2_output_0_splitncnn_4 /Slice_16_output_0 -23309=1,2 -23310=1,4 -23311=1,0 +rife.Warp /warp_9 2 1 in1_splitncnn_2 /Slice_16_output_0 /warp_9_output_0 0=6 +Crop /Slice_17 1 1 /Add_2_output_0_splitncnn_3 /Slice_17_output_0 -23309=1,0 -23310=1,2 -23311=1,0 +rife.Warp /warp_10 2 1 /encode/cnn3/ConvTranspose_output_0_splitncnn_1 /Slice_17_output_0 /warp_10_output_0 0=6 +Crop /Slice_18 1 1 /Add_2_output_0_splitncnn_2 /Slice_18_output_0 -23309=1,2 -23310=1,4 -23311=1,0 +rife.Warp /warp_11 2 1 /encode/cnn3_1/ConvTranspose_output_0_splitncnn_1 /Slice_18_output_0 /warp_11_output_0 0=6 +Crop /Slice_19 1 1 /warp_8_output_0 /Slice_19_output_0 -23309=1,0 -23310=1,3 -23311=1,0 +Crop /Slice_20 1 1 /warp_9_output_0 /Slice_20_output_0 -23309=1,0 -23310=1,3 -23311=1,0 +Concat /Concat_4 7 1 /Slice_19_output_0 /Slice_20_output_0 /warp_10_output_0 /warp_11_output_0 /Mul_1_output_0_splitncnn_1 /block2/Slice_1_output_0 /block2/Slice_2_output_0 /Concat_4_output_0 +Interp /block3/Resize 1 1 /Concat_4_output_0 /block3/Resize_output_0 0=2 1=5.000000e-01 2=5.000000e-01 +Interp /block3/Resize_1 1 1 /Add_2_output_0_splitncnn_1 /block3/Resize_1_output_0 0=2 1=5.000000e-01 2=5.000000e-01 +BinaryOp /block3/Div 1 1 /block3/Resize_1_output_0 /block3/Div_output_0 0=3 1=1 2=2.000000e+00 +Concat /block3/Concat 2 1 /block3/Resize_output_0 /block3/Div_output_0 /block3/Concat_output_0 +Convolution /block3/conv0/conv0.0/conv0.0.0/Conv 1 1 /block3/Concat_output_0 /block3/conv0/conv0.0/conv0.0.1/LeakyRelu_output_0 0=32 1=3 3=2 4=1 5=1 6=8064 9=2 -23310=1,2.000000e-01 +Convolution /block3/conv0/conv0.1/conv0.1.0/Conv 1 1 /block3/conv0/conv0.0/conv0.0.1/LeakyRelu_output_0 /block3/conv0/conv0.1/conv0.1.1/LeakyRelu_output_0 0=64 1=3 3=2 4=1 5=1 6=18432 9=2 -23310=1,2.000000e-01 +Split splitncnn_35 1 2 /block3/conv0/conv0.1/conv0.1.1/LeakyRelu_output_0 /block3/conv0/conv0.1/conv0.1.1/LeakyRelu_output_0_splitncnn_0 /block3/conv0/conv0.1/conv0.1.1/LeakyRelu_output_0_splitncnn_1 +Convolution /block3/convblock/convblock.0/conv/Conv 1 1 /block3/conv0/conv0.1/conv0.1.1/LeakyRelu_output_0_splitncnn_1 /block3/convblock/convblock.0/conv/Conv_output_0 0=64 1=3 4=1 5=1 6=36864 +BinaryOp /block3/convblock/convblock.0/Mul 2 1 /block3/convblock/convblock.0/conv/Conv_output_0 block3.convblock.0.beta /block3/convblock/convblock.0/Mul_output_0 0=2 +BinaryOp /block3/convblock/convblock.0/Add 2 1 /block3/convblock/convblock.0/Mul_output_0 /block3/conv0/conv0.1/conv0.1.1/LeakyRelu_output_0_splitncnn_0 /block3/convblock/convblock.0/Add_output_0 +ReLU /block3/convblock/convblock.0/relu/LeakyRelu 1 1 /block3/convblock/convblock.0/Add_output_0 /block3/convblock/convblock.0/relu/LeakyRelu_output_0 0=2.000000e-01 +Split splitncnn_36 1 2 /block3/convblock/convblock.0/relu/LeakyRelu_output_0 /block3/convblock/convblock.0/relu/LeakyRelu_output_0_splitncnn_0 /block3/convblock/convblock.0/relu/LeakyRelu_output_0_splitncnn_1 +Convolution /block3/convblock/convblock.1/conv/Conv 1 1 /block3/convblock/convblock.0/relu/LeakyRelu_output_0_splitncnn_1 /block3/convblock/convblock.1/conv/Conv_output_0 0=64 1=3 4=1 5=1 6=36864 +BinaryOp /block3/convblock/convblock.1/Mul 2 1 /block3/convblock/convblock.1/conv/Conv_output_0 block3.convblock.1.beta /block3/convblock/convblock.1/Mul_output_0 0=2 +BinaryOp /block3/convblock/convblock.1/Add 2 1 /block3/convblock/convblock.1/Mul_output_0 /block3/convblock/convblock.0/relu/LeakyRelu_output_0_splitncnn_0 /block3/convblock/convblock.1/Add_output_0 +ReLU /block3/convblock/convblock.1/relu/LeakyRelu 1 1 /block3/convblock/convblock.1/Add_output_0 /block3/convblock/convblock.1/relu/LeakyRelu_output_0 0=2.000000e-01 +Split splitncnn_37 1 2 /block3/convblock/convblock.1/relu/LeakyRelu_output_0 /block3/convblock/convblock.1/relu/LeakyRelu_output_0_splitncnn_0 /block3/convblock/convblock.1/relu/LeakyRelu_output_0_splitncnn_1 +Convolution /block3/convblock/convblock.2/conv/Conv 1 1 /block3/convblock/convblock.1/relu/LeakyRelu_output_0_splitncnn_1 /block3/convblock/convblock.2/conv/Conv_output_0 0=64 1=3 4=1 5=1 6=36864 +BinaryOp /block3/convblock/convblock.2/Mul 2 1 /block3/convblock/convblock.2/conv/Conv_output_0 block3.convblock.2.beta /block3/convblock/convblock.2/Mul_output_0 0=2 +BinaryOp /block3/convblock/convblock.2/Add 2 1 /block3/convblock/convblock.2/Mul_output_0 /block3/convblock/convblock.1/relu/LeakyRelu_output_0_splitncnn_0 /block3/convblock/convblock.2/Add_output_0 +ReLU /block3/convblock/convblock.2/relu/LeakyRelu 1 1 /block3/convblock/convblock.2/Add_output_0 /block3/convblock/convblock.2/relu/LeakyRelu_output_0 0=2.000000e-01 +Split splitncnn_38 1 2 /block3/convblock/convblock.2/relu/LeakyRelu_output_0 /block3/convblock/convblock.2/relu/LeakyRelu_output_0_splitncnn_0 /block3/convblock/convblock.2/relu/LeakyRelu_output_0_splitncnn_1 +Convolution /block3/convblock/convblock.3/conv/Conv 1 1 /block3/convblock/convblock.2/relu/LeakyRelu_output_0_splitncnn_1 /block3/convblock/convblock.3/conv/Conv_output_0 0=64 1=3 4=1 5=1 6=36864 +BinaryOp /block3/convblock/convblock.3/Mul 2 1 /block3/convblock/convblock.3/conv/Conv_output_0 block3.convblock.3.beta /block3/convblock/convblock.3/Mul_output_0 0=2 +BinaryOp /block3/convblock/convblock.3/Add 2 1 /block3/convblock/convblock.3/Mul_output_0 /block3/convblock/convblock.2/relu/LeakyRelu_output_0_splitncnn_0 /block3/convblock/convblock.3/Add_output_0 +ReLU /block3/convblock/convblock.3/relu/LeakyRelu 1 1 /block3/convblock/convblock.3/Add_output_0 /block3/convblock/convblock.3/relu/LeakyRelu_output_0 0=2.000000e-01 +Split splitncnn_39 1 2 /block3/convblock/convblock.3/relu/LeakyRelu_output_0 /block3/convblock/convblock.3/relu/LeakyRelu_output_0_splitncnn_0 /block3/convblock/convblock.3/relu/LeakyRelu_output_0_splitncnn_1 +Convolution /block3/convblock/convblock.4/conv/Conv 1 1 /block3/convblock/convblock.3/relu/LeakyRelu_output_0_splitncnn_1 /block3/convblock/convblock.4/conv/Conv_output_0 0=64 1=3 4=1 5=1 6=36864 +BinaryOp /block3/convblock/convblock.4/Mul 2 1 /block3/convblock/convblock.4/conv/Conv_output_0 block3.convblock.4.beta /block3/convblock/convblock.4/Mul_output_0 0=2 +BinaryOp /block3/convblock/convblock.4/Add 2 1 /block3/convblock/convblock.4/Mul_output_0 /block3/convblock/convblock.3/relu/LeakyRelu_output_0_splitncnn_0 /block3/convblock/convblock.4/Add_output_0 +ReLU /block3/convblock/convblock.4/relu/LeakyRelu 1 1 /block3/convblock/convblock.4/Add_output_0 /block3/convblock/convblock.4/relu/LeakyRelu_output_0 0=2.000000e-01 +Split splitncnn_40 1 2 /block3/convblock/convblock.4/relu/LeakyRelu_output_0 /block3/convblock/convblock.4/relu/LeakyRelu_output_0_splitncnn_0 /block3/convblock/convblock.4/relu/LeakyRelu_output_0_splitncnn_1 +Convolution /block3/convblock/convblock.5/conv/Conv 1 1 /block3/convblock/convblock.4/relu/LeakyRelu_output_0_splitncnn_1 /block3/convblock/convblock.5/conv/Conv_output_0 0=64 1=3 4=1 5=1 6=36864 +BinaryOp /block3/convblock/convblock.5/Mul 2 1 /block3/convblock/convblock.5/conv/Conv_output_0 block3.convblock.5.beta /block3/convblock/convblock.5/Mul_output_0 0=2 +BinaryOp /block3/convblock/convblock.5/Add 2 1 /block3/convblock/convblock.5/Mul_output_0 /block3/convblock/convblock.4/relu/LeakyRelu_output_0_splitncnn_0 /block3/convblock/convblock.5/Add_output_0 +ReLU /block3/convblock/convblock.5/relu/LeakyRelu 1 1 /block3/convblock/convblock.5/Add_output_0 /block3/convblock/convblock.5/relu/LeakyRelu_output_0 0=2.000000e-01 +Split splitncnn_41 1 2 /block3/convblock/convblock.5/relu/LeakyRelu_output_0 /block3/convblock/convblock.5/relu/LeakyRelu_output_0_splitncnn_0 /block3/convblock/convblock.5/relu/LeakyRelu_output_0_splitncnn_1 +Convolution /block3/convblock/convblock.6/conv/Conv 1 1 /block3/convblock/convblock.5/relu/LeakyRelu_output_0_splitncnn_1 /block3/convblock/convblock.6/conv/Conv_output_0 0=64 1=3 4=1 5=1 6=36864 +BinaryOp /block3/convblock/convblock.6/Mul 2 1 /block3/convblock/convblock.6/conv/Conv_output_0 block3.convblock.6.beta /block3/convblock/convblock.6/Mul_output_0 0=2 +BinaryOp /block3/convblock/convblock.6/Add 2 1 /block3/convblock/convblock.6/Mul_output_0 /block3/convblock/convblock.5/relu/LeakyRelu_output_0_splitncnn_0 /block3/convblock/convblock.6/Add_output_0 +ReLU /block3/convblock/convblock.6/relu/LeakyRelu 1 1 /block3/convblock/convblock.6/Add_output_0 /block3/convblock/convblock.6/relu/LeakyRelu_output_0 0=2.000000e-01 +Split splitncnn_42 1 2 /block3/convblock/convblock.6/relu/LeakyRelu_output_0 /block3/convblock/convblock.6/relu/LeakyRelu_output_0_splitncnn_0 /block3/convblock/convblock.6/relu/LeakyRelu_output_0_splitncnn_1 +Convolution /block3/convblock/convblock.7/conv/Conv 1 1 /block3/convblock/convblock.6/relu/LeakyRelu_output_0_splitncnn_1 /block3/convblock/convblock.7/conv/Conv_output_0 0=64 1=3 4=1 5=1 6=36864 +BinaryOp /block3/convblock/convblock.7/Mul 2 1 /block3/convblock/convblock.7/conv/Conv_output_0 block3.convblock.7.beta /block3/convblock/convblock.7/Mul_output_0 0=2 +BinaryOp /block3/convblock/convblock.7/Add 2 1 /block3/convblock/convblock.7/Mul_output_0 /block3/convblock/convblock.6/relu/LeakyRelu_output_0_splitncnn_0 /block3/convblock/convblock.7/Add_output_0 +ReLU /block3/convblock/convblock.7/relu/LeakyRelu 1 1 /block3/convblock/convblock.7/Add_output_0 /block3/convblock/convblock.7/relu/LeakyRelu_output_0 0=2.000000e-01 +Deconvolution /block3/lastconv/lastconv.0/ConvTranspose 1 1 /block3/convblock/convblock.7/relu/LeakyRelu_output_0 /block3/lastconv/lastconv.0/ConvTranspose_output_0 0=52 1=4 3=2 4=1 5=1 6=53248 +PixelShuffle /block3/lastconv/lastconv.1/DepthToSpace 1 1 /block3/lastconv/lastconv.0/ConvTranspose_output_0 /block3/lastconv/lastconv.1/DepthToSpace_output_0 0=2 +Interp /block3/Resize_2 1 1 /block3/lastconv/lastconv.1/DepthToSpace_output_0 /block3/Resize_2_output_0 0=2 1=2.000000e+00 2=2.000000e+00 +Split splitncnn_43 1 3 /block3/Resize_2_output_0 /block3/Resize_2_output_0_splitncnn_0 /block3/Resize_2_output_0_splitncnn_1 /block3/Resize_2_output_0_splitncnn_2 +Crop /block3/Slice 1 1 /block3/Resize_2_output_0_splitncnn_2 /block3/Slice_output_0 -23309=1,0 -23310=1,4 -23311=1,0 +Crop /block3/Slice_1 1 1 /block3/Resize_2_output_0_splitncnn_1 /block3/Slice_1_output_0 -23309=1,4 -23310=1,5 -23311=1,0 +Crop /block3/Slice_2 1 1 /block3/Resize_2_output_0_splitncnn_0 /block3/Slice_2_output_0 -23309=1,5 -23310=1,2147483647 -23311=1,0 +Eltwise /Add_3 2 1 /Add_2_output_0_splitncnn_0 /block3/Slice_output_0 /Add_3_output_0 0=1 -23301=2,1.000000e+00,2.000000e+00 +Split splitncnn_44 1 6 /Add_3_output_0 /Add_3_output_0_splitncnn_0 /Add_3_output_0_splitncnn_1 /Add_3_output_0_splitncnn_2 /Add_3_output_0_splitncnn_3 /Add_3_output_0_splitncnn_4 /Add_3_output_0_splitncnn_5 +Crop /Slice_21 1 1 /Add_3_output_0_splitncnn_5 /Slice_21_output_0 -23309=1,0 -23310=1,2 -23311=1,0 +rife.Warp /warp_12 2 1 in0_splitncnn_1 /Slice_21_output_0 /warp_12_output_0 0=6 +Crop /Slice_22 1 1 /Add_3_output_0_splitncnn_4 /Slice_22_output_0 -23309=1,2 -23310=1,4 -23311=1,0 +rife.Warp /warp_13 2 1 in1_splitncnn_1 /Slice_22_output_0 /warp_13_output_0 0=6 +Crop /Slice_23 1 1 /Add_3_output_0_splitncnn_3 /Slice_23_output_0 -23309=1,0 -23310=1,2 -23311=1,0 +rife.Warp /warp_14 2 1 /encode/cnn3/ConvTranspose_output_0_splitncnn_0 /Slice_23_output_0 /warp_14_output_0 0=6 +Crop /Slice_24 1 1 /Add_3_output_0_splitncnn_2 /Slice_24_output_0 -23309=1,2 -23310=1,4 -23311=1,0 +rife.Warp /warp_15 2 1 /encode/cnn3_1/ConvTranspose_output_0_splitncnn_0 /Slice_24_output_0 /warp_15_output_0 0=6 +Crop /Slice_25 1 1 /warp_12_output_0 /Slice_25_output_0 -23309=1,0 -23310=1,3 -23311=1,0 +Crop /Slice_26 1 1 /warp_13_output_0 /Slice_26_output_0 -23309=1,0 -23310=1,3 -23311=1,0 +Concat /Concat_5 7 1 /Slice_25_output_0 /Slice_26_output_0 /warp_14_output_0 /warp_15_output_0 /Mul_1_output_0_splitncnn_0 /block3/Slice_1_output_0 /block3/Slice_2_output_0 /Concat_5_output_0 +Interp /block4/Resize 1 1 /Concat_5_output_0 /block4/Resize_output_0 0=2 +Interp /block4/Resize_1 1 1 /Add_3_output_0_splitncnn_1 /block4/Resize_1_output_0 0=2 +Concat /block4/Concat 2 1 /block4/Resize_output_0 /block4/Resize_1_output_0 /block4/Concat_output_0 +Convolution /block4/conv0/conv0.0/conv0.0.0/Conv 1 1 /block4/Concat_output_0 /block4/conv0/conv0.0/conv0.0.1/LeakyRelu_output_0 0=16 1=3 3=2 4=1 5=1 6=4032 9=2 -23310=1,2.000000e-01 +Convolution /block4/conv0/conv0.1/conv0.1.0/Conv 1 1 /block4/conv0/conv0.0/conv0.0.1/LeakyRelu_output_0 /block4/conv0/conv0.1/conv0.1.1/LeakyRelu_output_0 0=32 1=3 3=2 4=1 5=1 6=4608 9=2 -23310=1,2.000000e-01 +Split splitncnn_45 1 2 /block4/conv0/conv0.1/conv0.1.1/LeakyRelu_output_0 /block4/conv0/conv0.1/conv0.1.1/LeakyRelu_output_0_splitncnn_0 /block4/conv0/conv0.1/conv0.1.1/LeakyRelu_output_0_splitncnn_1 +Convolution /block4/convblock/convblock.0/conv/Conv 1 1 /block4/conv0/conv0.1/conv0.1.1/LeakyRelu_output_0_splitncnn_1 /block4/convblock/convblock.0/conv/Conv_output_0 0=32 1=3 4=1 5=1 6=9216 +BinaryOp /block4/convblock/convblock.0/Mul 2 1 /block4/convblock/convblock.0/conv/Conv_output_0 block4.convblock.0.beta /block4/convblock/convblock.0/Mul_output_0 0=2 +BinaryOp /block4/convblock/convblock.0/Add 2 1 /block4/convblock/convblock.0/Mul_output_0 /block4/conv0/conv0.1/conv0.1.1/LeakyRelu_output_0_splitncnn_0 /block4/convblock/convblock.0/Add_output_0 +ReLU /block4/convblock/convblock.0/relu/LeakyRelu 1 1 /block4/convblock/convblock.0/Add_output_0 /block4/convblock/convblock.0/relu/LeakyRelu_output_0 0=2.000000e-01 +Split splitncnn_46 1 2 /block4/convblock/convblock.0/relu/LeakyRelu_output_0 /block4/convblock/convblock.0/relu/LeakyRelu_output_0_splitncnn_0 /block4/convblock/convblock.0/relu/LeakyRelu_output_0_splitncnn_1 +Convolution /block4/convblock/convblock.1/conv/Conv 1 1 /block4/convblock/convblock.0/relu/LeakyRelu_output_0_splitncnn_1 /block4/convblock/convblock.1/conv/Conv_output_0 0=32 1=3 4=1 5=1 6=9216 +BinaryOp /block4/convblock/convblock.1/Mul 2 1 /block4/convblock/convblock.1/conv/Conv_output_0 block4.convblock.1.beta /block4/convblock/convblock.1/Mul_output_0 0=2 +BinaryOp /block4/convblock/convblock.1/Add 2 1 /block4/convblock/convblock.1/Mul_output_0 /block4/convblock/convblock.0/relu/LeakyRelu_output_0_splitncnn_0 /block4/convblock/convblock.1/Add_output_0 +ReLU /block4/convblock/convblock.1/relu/LeakyRelu 1 1 /block4/convblock/convblock.1/Add_output_0 /block4/convblock/convblock.1/relu/LeakyRelu_output_0 0=2.000000e-01 +Split splitncnn_47 1 2 /block4/convblock/convblock.1/relu/LeakyRelu_output_0 /block4/convblock/convblock.1/relu/LeakyRelu_output_0_splitncnn_0 /block4/convblock/convblock.1/relu/LeakyRelu_output_0_splitncnn_1 +Convolution /block4/convblock/convblock.2/conv/Conv 1 1 /block4/convblock/convblock.1/relu/LeakyRelu_output_0_splitncnn_1 /block4/convblock/convblock.2/conv/Conv_output_0 0=32 1=3 4=1 5=1 6=9216 +BinaryOp /block4/convblock/convblock.2/Mul 2 1 /block4/convblock/convblock.2/conv/Conv_output_0 block4.convblock.2.beta /block4/convblock/convblock.2/Mul_output_0 0=2 +BinaryOp /block4/convblock/convblock.2/Add 2 1 /block4/convblock/convblock.2/Mul_output_0 /block4/convblock/convblock.1/relu/LeakyRelu_output_0_splitncnn_0 /block4/convblock/convblock.2/Add_output_0 +ReLU /block4/convblock/convblock.2/relu/LeakyRelu 1 1 /block4/convblock/convblock.2/Add_output_0 /block4/convblock/convblock.2/relu/LeakyRelu_output_0 0=2.000000e-01 +Split splitncnn_48 1 2 /block4/convblock/convblock.2/relu/LeakyRelu_output_0 /block4/convblock/convblock.2/relu/LeakyRelu_output_0_splitncnn_0 /block4/convblock/convblock.2/relu/LeakyRelu_output_0_splitncnn_1 +Convolution /block4/convblock/convblock.3/conv/Conv 1 1 /block4/convblock/convblock.2/relu/LeakyRelu_output_0_splitncnn_1 /block4/convblock/convblock.3/conv/Conv_output_0 0=32 1=3 4=1 5=1 6=9216 +BinaryOp /block4/convblock/convblock.3/Mul 2 1 /block4/convblock/convblock.3/conv/Conv_output_0 block4.convblock.3.beta /block4/convblock/convblock.3/Mul_output_0 0=2 +BinaryOp /block4/convblock/convblock.3/Add 2 1 /block4/convblock/convblock.3/Mul_output_0 /block4/convblock/convblock.2/relu/LeakyRelu_output_0_splitncnn_0 /block4/convblock/convblock.3/Add_output_0 +ReLU /block4/convblock/convblock.3/relu/LeakyRelu 1 1 /block4/convblock/convblock.3/Add_output_0 /block4/convblock/convblock.3/relu/LeakyRelu_output_0 0=2.000000e-01 +Split splitncnn_49 1 2 /block4/convblock/convblock.3/relu/LeakyRelu_output_0 /block4/convblock/convblock.3/relu/LeakyRelu_output_0_splitncnn_0 /block4/convblock/convblock.3/relu/LeakyRelu_output_0_splitncnn_1 +Convolution /block4/convblock/convblock.4/conv/Conv 1 1 /block4/convblock/convblock.3/relu/LeakyRelu_output_0_splitncnn_1 /block4/convblock/convblock.4/conv/Conv_output_0 0=32 1=3 4=1 5=1 6=9216 +BinaryOp /block4/convblock/convblock.4/Mul 2 1 /block4/convblock/convblock.4/conv/Conv_output_0 block4.convblock.4.beta /block4/convblock/convblock.4/Mul_output_0 0=2 +BinaryOp /block4/convblock/convblock.4/Add 2 1 /block4/convblock/convblock.4/Mul_output_0 /block4/convblock/convblock.3/relu/LeakyRelu_output_0_splitncnn_0 /block4/convblock/convblock.4/Add_output_0 +ReLU /block4/convblock/convblock.4/relu/LeakyRelu 1 1 /block4/convblock/convblock.4/Add_output_0 /block4/convblock/convblock.4/relu/LeakyRelu_output_0 0=2.000000e-01 +Split splitncnn_50 1 2 /block4/convblock/convblock.4/relu/LeakyRelu_output_0 /block4/convblock/convblock.4/relu/LeakyRelu_output_0_splitncnn_0 /block4/convblock/convblock.4/relu/LeakyRelu_output_0_splitncnn_1 +Convolution /block4/convblock/convblock.5/conv/Conv 1 1 /block4/convblock/convblock.4/relu/LeakyRelu_output_0_splitncnn_1 /block4/convblock/convblock.5/conv/Conv_output_0 0=32 1=3 4=1 5=1 6=9216 +BinaryOp /block4/convblock/convblock.5/Mul 2 1 /block4/convblock/convblock.5/conv/Conv_output_0 block4.convblock.5.beta /block4/convblock/convblock.5/Mul_output_0 0=2 +BinaryOp /block4/convblock/convblock.5/Add 2 1 /block4/convblock/convblock.5/Mul_output_0 /block4/convblock/convblock.4/relu/LeakyRelu_output_0_splitncnn_0 /block4/convblock/convblock.5/Add_output_0 +ReLU /block4/convblock/convblock.5/relu/LeakyRelu 1 1 /block4/convblock/convblock.5/Add_output_0 /block4/convblock/convblock.5/relu/LeakyRelu_output_0 0=2.000000e-01 +Split splitncnn_51 1 2 /block4/convblock/convblock.5/relu/LeakyRelu_output_0 /block4/convblock/convblock.5/relu/LeakyRelu_output_0_splitncnn_0 /block4/convblock/convblock.5/relu/LeakyRelu_output_0_splitncnn_1 +Convolution /block4/convblock/convblock.6/conv/Conv 1 1 /block4/convblock/convblock.5/relu/LeakyRelu_output_0_splitncnn_1 /block4/convblock/convblock.6/conv/Conv_output_0 0=32 1=3 4=1 5=1 6=9216 +BinaryOp /block4/convblock/convblock.6/Mul 2 1 /block4/convblock/convblock.6/conv/Conv_output_0 block4.convblock.6.beta /block4/convblock/convblock.6/Mul_output_0 0=2 +BinaryOp /block4/convblock/convblock.6/Add 2 1 /block4/convblock/convblock.6/Mul_output_0 /block4/convblock/convblock.5/relu/LeakyRelu_output_0_splitncnn_0 /block4/convblock/convblock.6/Add_output_0 +ReLU /block4/convblock/convblock.6/relu/LeakyRelu 1 1 /block4/convblock/convblock.6/Add_output_0 /block4/convblock/convblock.6/relu/LeakyRelu_output_0 0=2.000000e-01 +Split splitncnn_52 1 2 /block4/convblock/convblock.6/relu/LeakyRelu_output_0 /block4/convblock/convblock.6/relu/LeakyRelu_output_0_splitncnn_0 /block4/convblock/convblock.6/relu/LeakyRelu_output_0_splitncnn_1 +Convolution /block4/convblock/convblock.7/conv/Conv 1 1 /block4/convblock/convblock.6/relu/LeakyRelu_output_0_splitncnn_1 /block4/convblock/convblock.7/conv/Conv_output_0 0=32 1=3 4=1 5=1 6=9216 +BinaryOp /block4/convblock/convblock.7/Mul 2 1 /block4/convblock/convblock.7/conv/Conv_output_0 block4.convblock.7.beta /block4/convblock/convblock.7/Mul_output_0 0=2 +BinaryOp /block4/convblock/convblock.7/Add 2 1 /block4/convblock/convblock.7/Mul_output_0 /block4/convblock/convblock.6/relu/LeakyRelu_output_0_splitncnn_0 /block4/convblock/convblock.7/Add_output_0 +ReLU /block4/convblock/convblock.7/relu/LeakyRelu 1 1 /block4/convblock/convblock.7/Add_output_0 /block4/convblock/convblock.7/relu/LeakyRelu_output_0 0=2.000000e-01 +Deconvolution /block4/lastconv/lastconv.0/ConvTranspose 1 1 /block4/convblock/convblock.7/relu/LeakyRelu_output_0 /block4/lastconv/lastconv.0/ConvTranspose_output_0 0=52 1=4 3=2 4=1 5=1 6=26624 +PixelShuffle /block4/lastconv/lastconv.1/DepthToSpace 1 1 /block4/lastconv/lastconv.0/ConvTranspose_output_0 /block4/lastconv/lastconv.1/DepthToSpace_output_0 0=2 +Interp /block4/Resize_2 1 1 /block4/lastconv/lastconv.1/DepthToSpace_output_0 /block4/Resize_2_output_0 0=2 +Split splitncnn_53 1 2 /block4/Resize_2_output_0 /block4/Resize_2_output_0_splitncnn_0 /block4/Resize_2_output_0_splitncnn_1 +Crop /block4/Slice 1 1 /block4/Resize_2_output_0_splitncnn_1 /block4/Slice_output_0 -23309=1,0 -23310=1,4 -23311=1,0 +Crop /block4/Slice_1 1 1 /block4/Resize_2_output_0_splitncnn_0 /block4/Slice_1_output_0 -23309=1,4 -23310=1,5 -23311=1,0 +BinaryOp /Add_4 2 1 /Add_3_output_0_splitncnn_0 /block4/Slice_output_0 /Add_4_output_0 +Split splitncnn_54 1 2 /Add_4_output_0 /Add_4_output_0_splitncnn_0 /Add_4_output_0_splitncnn_1 +Crop /Slice_27 1 1 /Add_4_output_0_splitncnn_1 /Slice_27_output_0 -23309=1,0 -23310=1,2 -23311=1,0 +rife.Warp /warp_16 2 1 in0_splitncnn_0 /Slice_27_output_0 /warp_16_output_0 0=6 +Crop /Slice_28 1 1 /Add_4_output_0_splitncnn_0 /Slice_28_output_0 -23309=1,2 -23310=1,4 -23311=1,0 +rife.Warp /warp_17 2 1 in1_splitncnn_0 /Slice_28_output_0 /warp_17_output_0 0=6 +Sigmoid /Sigmoid 1 1 /block4/Slice_1_output_0 /Sigmoid_output_0 +Split splitncnn_55 1 2 /Sigmoid_output_0 /Sigmoid_output_0_splitncnn_0 /Sigmoid_output_0_splitncnn_1 +BinaryOp /Mul_2 2 1 /warp_16_output_0 /Sigmoid_output_0_splitncnn_1 /Mul_2_output_0 0=2 +BinaryOp /Sub 1 1 /Sigmoid_output_0_splitncnn_0 /Sub_output_0 0=7 1=1 2=1.000000e+00 +BinaryOp /Mul_3 2 1 /warp_17_output_0 /Sub_output_0 /Mul_3_output_0 0=2 +BinaryOp /Add_5 2 1 /Mul_2_output_0 /Mul_3_output_0 out0 diff --git a/local/models/taew2_1.safetensors b/local/models/taew2_1.safetensors new file mode 100644 index 0000000..a82e1e7 Binary files /dev/null and b/local/models/taew2_1.safetensors differ diff --git a/local/models/taew2_2.safetensors b/local/models/taew2_2.safetensors new file mode 100644 index 0000000..f97b1c3 Binary files /dev/null and b/local/models/taew2_2.safetensors differ diff --git a/local/relay_generate.py b/local/relay_generate.py new file mode 100644 index 0000000..dee7e81 --- /dev/null +++ b/local/relay_generate.py @@ -0,0 +1,1170 @@ +"""Wan2.2 Text-to-Video generation pipeline for MLX. + +Dual-expert (A14B) memory modes: the two experts switch ONCE at a +deterministic timestep boundary (high-noise phase, then low-noise phase). +memory_mode="relay" (the default for dual models) keeps only the ACTIVE +expert resident — build high, run the high phase, free it at the boundary, +build low. Peak memory ~ one expert instead of two, which makes the A14B +runnable in bf16 on 48GB unified memory (measured 36.8GB at 832x480x81f, +44.3GB with CFG). Cost: one extra weight load from disk at the boundary, +once per generation. memory_mode="parallel" restores both-resident behavior. + +Relay changes only WHEN weights are resident, never the math: with the same +seed, relay and parallel produce bit-identical latents (contract-tested via +dump_latents + MD5), and parallel is bit-identical with previous releases — +relay pre-consumes the construction-time PRNG draws with discarded lazy +replicas of the loader path, so the initial noise lands on the same stream +position in every mode. (Known exception: quantized + LoRA in relay mode is +deterministic but not seed-identical to parallel — the LoRA dequant-merge +constructs extra layers whose PRNG use depends on the LoRA configs.) +""" + +import argparse +import gc +import math +import random +import time +from pathlib import Path + +import mlx.core as mx +import numpy as np +from tqdm import tqdm + +from mlx_video.models.wan_2.i2v_utils import build_i2v_mask, preprocess_image +from mlx_video.models.wan_2.utils import ( + encode_text, + load_t5_encoder, + load_vae_decoder, + load_vae_encoder, + load_wan_model, +) +from mlx_video.models.wan_2.postprocess import save_video + + +class Colors: + """ANSI color codes for terminal output.""" + + CYAN = "\033[96m" + BLUE = "\033[94m" + GREEN = "\033[92m" + YELLOW = "\033[93m" + RED = "\033[91m" + MAGENTA = "\033[95m" + BOLD = "\033[1m" + DIM = "\033[2m" + RESET = "\033[0m" + + +# Backward-compat alias (tests and external code may use the old name) +_build_i2v_mask = build_i2v_mask + + +def _best_output_size(w, h, dw, dh, max_area): + """Compute the best output resolution that fits within max_area while + preserving the input aspect ratio and satisfying alignment constraints. + Matches the reference implementation's best_output_size(). + """ + ratio = w / h + ow = (max_area * ratio) ** 0.5 + oh = max_area / ow + + # Option 1: process width first + ow1 = int(ow // dw * dw) + oh1 = int(max_area / ow1 // dh * dh) + ratio1 = ow1 / oh1 + + # Option 2: process height first + oh2 = int(oh // dh * dh) + ow2 = int(max_area / oh2 // dw * dw) + ratio2 = ow2 / oh2 + + if max(ratio / ratio1, ratio1 / ratio) < max(ratio / ratio2, ratio2 / ratio): + return ow1, oh1 + return ow2, oh2 + + +def generate_video( + model_dir: str, + prompt: str, + negative_prompt: str | None = None, + image: str | None = None, + width: int = 1280, + height: int = 704, + num_frames: int = 81, + steps: int = None, + guide_scale: str | float | tuple = None, + shift: float = None, + seed: int = -1, + output_path: str = "output.mp4", + scheduler: str = "unipc", + loras: list | None = None, + loras_high: list | None = None, + loras_low: list | None = None, + tiling: str = "auto", + no_compile: bool = False, + trim_first_frames: int = 0, + debug_latents: bool = False, + memory_mode: str = "auto", + dump_latents: str | None = None, + end_image: str | None = None, +): + """Generate video using Wan pipeline (supports T2V and I2V). + + Args: + model_dir: Path to converted MLX model directory + prompt: Text prompt + negative_prompt: Negative prompt (None = use config default, "" = no negative prompt) + image: Path to input image for I2V (None = T2V mode) + width: Video width + height: Video height + num_frames: Number of frames (must be 4n+1) + steps: Number of diffusion steps (None = use config default) + guide_scale: Guidance scale: float for single, (low,high) for dual (None = config default) + shift: Noise schedule shift (None = use config default) + seed: Random seed (-1 for random) + output_path: Output video path + scheduler: Solver type: 'euler', 'dpm++', or 'unipc' (default) + loras: Optional list of (path, strength) tuples applied to all models + loras_high: Optional list of (path, strength) tuples for high-noise model only + loras_low: Optional list of (path, strength) tuples for low-noise model only + tiling: Tiling mode for VAE decoding. Options: + - "auto": Automatically determine tiling based on video size (default) + - "none": Disable tiling + - "default", "aggressive", "conservative": Preset tiling configs + - "spatial": Spatial tiling only + - "temporal": Temporal tiling only + no_compile: If True, skip mx.compile on models (useful for debugging) + trim_first_frames: Number of temporal latent positions to generate extra + and discard from the start. Each position = 4 pixel frames. Use 1 + to fix first-frame artifacts on 14B models (generates 4 extra frames, + discards first 4). Use 2 for more aggressive trimming. Default: 0. + debug_latents: If True, print per-temporal-position latent statistics + after denoising for diagnosing first-frame artifacts. + memory_mode: Expert residency for dual models. "parallel" = stock + behavior (both experts resident). "relay" = only the active expert + resident, freed/loaded at the phase boundary. "auto" = relay for + dual models, no-op for single. Ignored for single models. + dump_latents: Optional path; save the final pre-VAE latents as .npy + (float32) for bitwise relay-vs-parallel contract testing. + """ + import json + + from mlx_video.models.wan_2.config import WanModelConfig + from mlx_video.models.wan_2.scheduler import ( + FlowDPMPP2MScheduler, + FlowMatchEulerScheduler, + FlowUniPCScheduler, + ) + + # Fail fast on typos: a silently-unknown mode would neither prebuild nor + # free experts, degenerating into both-resident with no warning. + if memory_mode not in ("auto", "relay", "parallel"): + raise ValueError( + f"memory_mode must be 'auto', 'relay' or 'parallel', got {memory_mode!r}" + ) + + model_dir = Path(model_dir) + + # Load config from model dir if available, otherwise auto-detect + config_path = model_dir / "config.json" + quantization = None + if config_path.exists(): + with open(config_path) as f: + config_dict = json.load(f) + # Extract quantization config (not a model config field) + quantization = config_dict.pop("quantization", None) + # Handle tuple fields stored as lists in JSON + for key in ("patch_size", "vae_stride", "window_size", "sample_guide_scale"): + if key in config_dict and isinstance(config_dict[key], list): + config_dict[key] = tuple(config_dict[key]) + config = WanModelConfig( + **{ + k: v + for k, v in config_dict.items() + if k in WanModelConfig.__dataclass_fields__ + } + ) + else: + # Auto-detect: dual model files → 2.2, single model → 2.1 + if (model_dir / "low_noise_model.safetensors").exists(): + config = WanModelConfig.wan22_t2v_14b() + else: + # Detect 1.3B vs 14B from weight shapes + model_path = model_dir / "model.safetensors" + if model_path.exists(): + probe = mx.load(str(model_path), return_metadata=False) + for k, v in probe.items(): + if "patch_embedding_proj.weight" in k: + dim = v.shape[0] + if dim <= 2048: + config = WanModelConfig.wan21_t2v_1_3b() + else: + config = WanModelConfig.wan21_t2v_14b() + break + else: + config = WanModelConfig.wan21_t2v_14b() + del probe + else: + config = WanModelConfig.wan21_t2v_14b() + + is_dual = config.dual_model + is_i2v = image is not None + + # Validate config against actual weights (handles mismatched config.json) + if not is_dual: + model_path = model_dir / "model.safetensors" + if model_path.exists(): + probe = mx.load(str(model_path), return_metadata=False) + for k, v in probe.items(): + if "patch_embedding_proj.weight" in k: + actual_dim = v.shape[0] + if actual_dim != config.dim: + print( + f"{Colors.YELLOW} Config dim={config.dim} doesn't match weights dim={actual_dim}, auto-correcting...{Colors.RESET}" + ) + if actual_dim <= 2048: + config = WanModelConfig.wan21_t2v_1_3b() + else: + config = WanModelConfig.wan21_t2v_14b() + break + del probe + + # Auto-correct Wan2.2 VAE params from stale configs + if config.in_dim == 48 and config.vae_z_dim != 48: + print( + f"{Colors.YELLOW} Auto-correcting Wan2.2 VAE params (in_dim=48 but vae_z_dim={config.vae_z_dim}){Colors.RESET}" + ) + config = WanModelConfig( + **{ + **{ + f.name: getattr(config, f.name) + for f in config.__dataclass_fields__.values() + }, + "vae_z_dim": 48, + "vae_stride": (4, 16, 16), + "sample_fps": 24, + } + ) + + # Apply defaults from config if not overridden + if steps is None: + steps = config.sample_steps + if shift is None: + shift = config.sample_shift + if guide_scale is None: + guide_scale = config.sample_guide_scale + + # Normalize guide_scale + if isinstance(guide_scale, (int, float)): + guide_scale = float(guide_scale) + elif isinstance(guide_scale, str): + parts = [float(x) for x in guide_scale.split(",")] + guide_scale = tuple(parts) if len(parts) > 1 else parts[0] + + # Detect CFG-disabled mode (guide_scale=1.0 for all models → skip uncond pass for 2x speedup) + if isinstance(guide_scale, tuple): + cfg_disabled = all(gs <= 1.0 for gs in guide_scale) + else: + cfg_disabled = guide_scale <= 1.0 + + # Validate frame count + assert (num_frames - 1) % 4 == 0, f"num_frames must be 4n+1, got {num_frames}" + + gen_frames = num_frames + if trim_first_frames > 0: + gen_frames = num_frames + trim_first_frames * 4 + print( + f"{Colors.DIM} Trim: generating {gen_frames} frames, will discard first {trim_first_frames * 4}{Colors.RESET}" + ) + + version_str = f"Wan{config.model_version}" + mode_str = "dual-model" if is_dual else "single-model" + pipeline_str = "Image-to-Video" if is_i2v else "Text-to-Video" + # Resolve negative prompt: explicit user value > config default + # The official Wan2.2 uses a Chinese negative prompt (config.sample_neg_prompt) + # that prevents oversaturation, artifacts, and comic look. We use it by default. + # Text cleaning (_clean_text) normalizes fullwidth chars to match official tokenization. + if negative_prompt is None: + neg_prompt_resolved = config.sample_neg_prompt + else: + neg_prompt_resolved = negative_prompt + print(f"{Colors.CYAN}{'='*60}") + print(f" {version_str} {pipeline_str} Generation (MLX, {mode_str})") + print(f"{'='*60}{Colors.RESET}") + print(f"{Colors.DIM} Prompt: {prompt}") + if is_i2v: + print(f" Image: {image}") + if neg_prompt_resolved and neg_prompt_resolved.strip(): + neg_display = ( + neg_prompt_resolved[:60] + "..." + if len(neg_prompt_resolved) > 60 + else neg_prompt_resolved + ) + print(f" Neg prompt: {neg_display}") + print(f" Size: {width}x{height}, Frames: {num_frames}") + print( + f" Steps: {steps}, Guide: {guide_scale}, Shift: {shift}, Solver: {scheduler}" + ) + if cfg_disabled: + print(f" CFG: disabled (guide_scale≤1 → B=1 fast path, 2x denoising speedup)") + print(f"{Colors.RESET}") + + # Seed + if seed < 0: + seed = random.randint(0, 2**32 - 1) + mx.random.seed(seed) + np.random.seed(seed) + print(f"{Colors.DIM} Seed: {seed}{Colors.RESET}") + + # Align dimensions to patch_size * vae_stride (required for patchify) + vae_stride = config.vae_stride + patch_size = config.patch_size + align_h = patch_size[1] * vae_stride[1] # e.g. 2*16=32 + align_w = patch_size[2] * vae_stride[2] + if height % align_h != 0 or width % align_w != 0: + old_h, old_w = height, width + height = (height // align_h) * align_h + width = (width // align_w) * align_w + if height == 0: + height = align_h + if width == 0: + width = align_w + print( + f"{Colors.DIM} Aligned {old_w}x{old_h} → {width}x{height} (must be divisible by {align_w}x{align_h}){Colors.RESET}" + ) + + # Enforce max_area constraint (model-specific resolution limit) + if config.max_area > 0 and height * width > config.max_area: + old_h, old_w = height, width + width, height = _best_output_size( + width, height, align_w, align_h, config.max_area + ) + print( + f"{Colors.YELLOW} ⚠ Resolution {old_w}x{old_h} exceeds model's max area " + f"({config.max_area:,}px). Adjusted → {width}x{height}{Colors.RESET}" + ) + + # Compute target latent shape + z_dim = config.vae_z_dim + t_latent = (gen_frames - 1) // vae_stride[0] + 1 + h_latent = height // vae_stride[1] + w_latent = width // vae_stride[2] + target_shape = (z_dim, t_latent, h_latent, w_latent) + + + # Sequence length for transformer + seq_len = math.ceil( + (h_latent * w_latent) / (patch_size[1] * patch_size[2]) * t_latent + ) + + print(f"{Colors.DIM} Latent shape: {target_shape}") + print(f" Sequence length: {seq_len}{Colors.RESET}") + + # Load T5 encoder + t1 = time.time() + print(f"\n{Colors.BLUE}Loading T5 encoder...{Colors.RESET}") + t5_path = model_dir / "t5_encoder.safetensors" + t5_encoder = load_t5_encoder(t5_path, config) + + # Load tokenizer + from transformers import AutoTokenizer + + tokenizer = AutoTokenizer.from_pretrained("google/umt5-xxl") + + # Encode prompts + print(f"{Colors.BLUE}Encoding text...{Colors.RESET}") + context = encode_text(t5_encoder, tokenizer, prompt, config.text_len) + if cfg_disabled: + context_null = None + mx.eval(context) + else: + context_null = encode_text( + t5_encoder, tokenizer, neg_prompt_resolved, config.text_len + ) + mx.eval(context, context_null) + + # Free T5 from memory + del t5_encoder + gc.collect() + mx.clear_cache() + print(f"{Colors.DIM} T5 encoding: {time.time() - t1:.1f}s{Colors.RESET}") + + # I2V: encode image to latent space + z_img = None + i2v_mask = None + i2v_mask_tokens = None + y_i2v = None + is_i2v_channel_concat = is_i2v and config.model_type == "i2v" + is_i2v_mask_blend = is_i2v and config.model_type != "i2v" + if is_i2v: + print(f"\n{Colors.BLUE}Encoding input image...{Colors.RESET}") + t_img = time.time() + + vae_path = model_dir / "vae.safetensors" + + if is_i2v_channel_concat: + # I2V-14B: encode full video (first frame = image, rest = zeros) + # and construct y tensor with mask + encoded latents + from PIL import Image + + img = Image.open(image).convert("RGB") + scale = max(width / img.width, height / img.height) + img = img.resize( + (round(img.width * scale), round(img.height * scale)), Image.LANCZOS + ) + x1, y1 = (img.width - width) // 2, (img.height - height) // 2 + img = img.crop((x1, y1, x1 + width, y1 + height)) + img_arr = mx.array( + np.array(img, dtype=np.float32) / 255.0 * 2.0 - 1.0 + ) # [H, W, 3] + img_chw = img_arr.transpose(2, 0, 1) # [3, H, W] + + # Optional END frame (first+last morph): encode the target keyframe + # into the last pixel-frame slot so the clip interpolates image->end. + end_chw = None + if end_image is not None: + eimg = Image.open(end_image).convert("RGB") + escale = max(width / eimg.width, height / eimg.height) + eimg = eimg.resize( + (round(eimg.width * escale), round(eimg.height * escale)), Image.LANCZOS + ) + ex1, ey1 = (eimg.width - width) // 2, (eimg.height - height) // 2 + eimg = eimg.crop((ex1, ey1, ex1 + width, ey1 + height)) + end_arr = mx.array(np.array(eimg, dtype=np.float32) / 255.0 * 2.0 - 1.0) + end_chw = end_arr.transpose(2, 0, 1) # [3, H, W] + + # Build video: first frame = image, rest = zeros (last = end if given) + # Chunked encoding processes 1-frame + 4-frame chunks with temporal caching + if end_chw is not None: + video = mx.concatenate( + [ + img_chw[:, None, :, :], + mx.zeros((3, num_frames - 2, height, width)), + end_chw[:, None, :, :], + ], + axis=1, + ) + else: + video = mx.concatenate( + [ + img_chw[:, None, :, :], + mx.zeros((3, num_frames - 1, height, width)), + ], + axis=1, + ) + + # Encode through Wan2.1 VAE -> [1, z_dim, T_lat, H_lat, W_lat] + vae_enc = load_vae_encoder(vae_path, config) + z_video = vae_enc.encode(video[None]) # [1, 16, T_lat, H_lat, W_lat] + mx.eval(z_video) + z_video = z_video[0] # [16, T_lat, H_lat, W_lat] + + # Build mask: 1 for conditioned frames (first, +last if morph), 0 rest + if end_chw is not None: + msk = mx.concatenate( + [ + mx.ones((1, 1, h_latent, w_latent)), + mx.zeros((1, num_frames - 2, h_latent, w_latent)), + mx.ones((1, 1, h_latent, w_latent)), + ], + axis=1, + ) + else: + msk = mx.ones((1, num_frames, h_latent, w_latent)) + msk = mx.concatenate( + [msk[:, :1], mx.zeros((1, num_frames - 1, h_latent, w_latent))], axis=1 + ) + # Repeat first frame 4x, concat rest: [1, 4 + (F-1), H_lat, W_lat] + msk = mx.concatenate( + [ + mx.repeat(msk[:, :1], 4, axis=1), + msk[:, 1:], + ], + axis=1, + ) + # Reshape to [1, T_lat, 4, H_lat, W_lat] then transpose -> [4, T_lat, H_lat, W_lat] + msk = msk.reshape(1, msk.shape[1] // 4, 4, h_latent, w_latent) + msk = msk.transpose(0, 2, 1, 3, 4)[0] # [4, T_lat, H_lat, W_lat] + + # y = concat([mask, encoded_video]) -> [20, T_lat, H_lat, W_lat] + y_i2v = mx.concatenate([msk, z_video], axis=0) + mx.eval(y_i2v) + + del vae_enc, img_arr, img_chw, video, z_video, msk + else: + # TI2V-5B: encode image(s), blend with noise via mask + img_tensor = preprocess_image(image, width, height) + mx.eval(img_tensor) + + vae_enc = load_vae_encoder(vae_path, config) + z_first = vae_enc.encode(img_tensor) # [1, 1, H_lat, W_lat, z_dim] + mx.eval(z_first) + z_first = z_first[0].transpose(3, 0, 1, 2) # [z_dim, 1, H_lat, W_lat] + + if end_image is not None: + # first+last morph: encode target, condition frame 0 AND frame -1 + end_tensor = preprocess_image(end_image, width, height) + z_end = vae_enc.encode(end_tensor)[0].transpose(3, 0, 1, 2) + mx.eval(z_end) + C, T, H, W = target_shape + z_img = mx.concatenate( + [z_first, mx.zeros((C, T - 2, H, W)), z_end], axis=1 + ) # [z_dim, T_lat, H, W] + # mask: 0 (keep) at first AND last, 1 (noise) in between + i2v_mask = mx.concatenate( + [mx.zeros((C, 1, H, W)), mx.ones((C, T - 2, H, W)), mx.zeros((C, 1, H, W))], + axis=1, + ) + pt, ph, pw = config.patch_size + i2v_mask_tokens = i2v_mask[0, ::pt, ::ph, ::pw].reshape(1, -1) + del end_tensor, z_end + else: + z_img = z_first + i2v_mask, i2v_mask_tokens = build_i2v_mask(target_shape, config.patch_size) + + del vae_enc, img_tensor + + gc.collect() + mx.clear_cache() + print(f"{Colors.DIM} Image encoding: {time.time() - t_img:.1f}s{Colors.RESET}") + + # Load transformer models + print(f"\n{Colors.BLUE}Loading transformer model(s)...{Colors.RESET}") + if quantization: + print( + f"{Colors.DIM} Using {quantization['bits']}-bit quantized weights (group_size={quantization['group_size']}){Colors.RESET}" + ) + t2 = time.time() + + # Merge per-model LoRAs with shared LoRAs + _loras_low = (loras or []) + (loras_low or []) or None + _loras_high = (loras or []) + (loras_high or []) or None + _loras_single = loras + + # RoPE grid sizes are constant across all steps and independent of the model + f_grid = t_latent // patch_size[0] + h_grid = h_latent // patch_size[1] + w_grid = w_latent // patch_size[2] + if cfg_disabled: + rope_grid_sizes = [(f_grid, h_grid, w_grid)] + else: + rope_grid_sizes = [(f_grid, h_grid, w_grid), (f_grid, h_grid, w_grid)] + + if memory_mode == "auto": + memory_mode = "relay" if is_dual else "parallel" + if memory_mode not in ("relay", "parallel"): + raise ValueError( + f"memory_mode must be 'relay', 'parallel' or 'auto', got {memory_mode!r}" + ) + + def _build_expert(which, restore_rng=False): + """Load one expert and its per-model precomputes (text embedding, + cross-attn K/V, RoPE tables). Everything the denoise loop needs from + a resident expert lives in the returned bundle; freeing the bundle + frees the expert.""" + path = model_dir / f"{which}_noise_model.safetensors" + loras_w = _loras_high if which == "high" else _loras_low + tb = time.time() + # restore_rng=True ONLY for deferred (in-loop) relay builds: those + # must not perturb the global PRNG stream. The provider-init builds in + # parallel mode must consume the stream normally so parallel keeps the + # historical construction-order behavior. + _saved_rng = None + if restore_rng: + try: + _saved_rng = mx.random.state[0] + except Exception: + pass + m = load_wan_model(path, config, quantization, loras=loras_w) + if _saved_rng is not None: + mx.random.state[0] = _saved_rng + if cfg_disabled: + emb = m.embed_text([context]) + mx.eval(emb) + ctx = emb[0:1] + else: + emb = m.embed_text([context, context_null]) + mx.eval(emb) + ctx = mx.concatenate([emb[0:1], emb[1:2]], axis=0) + kv = m.prepare_cross_kv(ctx) + rcs = m.prepare_rope(rope_grid_sizes) + mx.eval(ctx, kv, rcs) + if not no_compile: + m._compiled = mx.compile(m) + print( + f"{Colors.DIM} [{memory_mode}] {which}-noise expert ready: " + f"{time.time() - tb:.1f}s{Colors.RESET}" + ) + return {"model": m, "ctx": ctx, "kv": kv, "rcs": rcs} + + class _PhaseProvider: + """Hands the denoise loop the bundle for the expert active at a given + timestep. parallel = both resident (stock behavior); relay = only the + active one, with a free+load at each phase change. The math is + identical in both modes — only weight residency differs.""" + + def __init__(self, mode): + self.mode = mode + self.bundles = {} + if mode == "parallel": + self.bundles["low"] = _build_expert("low") + self.bundles["high"] = _build_expert("high") + else: + # PRNG parity with stock/parallel: model construction consumes + # the global PRNG stream (keyless layer inits AND the + # QuantizedLinear constructors inside nn.quantize), and stock + # builds BOTH experts between mx.random.seed() and the initial + # noise draw. Relay defers the real builds, so pre-consume the + # stream with two discarded LAZY replicas of the loader's + # construction path (arrays are never evaluated — near-zero + # cost). Verified: replica consumption == real-loader + # consumption, so the noise (and every output) is bit-exact + # with parallel AND with previous releases for the same seed. + # Known exception: quantized models + LoRA (the dequant-merge + # path constructs additional Linears whose RNG use depends on + # the LoRA configs) — relay stays deterministic per-mode there + # but same-seed output differs from parallel. + import mlx.nn as _nn + + from mlx_video.models.wan_2.convert import _quantize_predicate + from mlx_video.models.wan_2.wan_2 import WanModel as _WM + + for _ in ("low", "high"): + _replica = _WM(config) + if quantization: + _nn.quantize( + _replica, + group_size=quantization["group_size"], + bits=quantization["bits"], + class_predicate=lambda p, m: _quantize_predicate(p, m), + ) + del _replica + + def get(self, timestep_val): + which = "high" if timestep_val >= boundary else "low" + if which not in self.bundles: + if self.mode == "relay": + self._free_all() + self.bundles[which] = _build_expert(which, restore_rng=True) + return self.bundles[which] + + def _free_all(self): + for b in list(self.bundles.values()): + b.clear() + self.bundles.clear() + gc.collect() + mx.clear_cache() + + def close(self): + self._free_all() + + # Boundary for model switching (dual model only) — parity-critical constant, + # single definition point (used by provider.get AND guide_scale selection) + boundary = (config.boundary * config.num_train_timesteps) if is_dual else None + + provider = None + if is_dual: + provider = _PhaseProvider(memory_mode) + else: + single_model = load_wan_model( + model_dir / "model.safetensors", config, quantization, loras=_loras_single + ) + if cfg_disabled: + context_emb = single_model.embed_text([context]) + mx.eval(context_emb) + context_cond = context_emb[0:1] + cross_kv = single_model.prepare_cross_kv(context_cond) + else: + context_emb = single_model.embed_text([context, context_null]) + mx.eval(context_emb) + context_cfg = mx.concatenate([context_emb[0:1], context_emb[1:2]], axis=0) + cross_kv = single_model.prepare_cross_kv(context_cfg) + mx.eval(cross_kv) + rope_cos_sin = single_model.prepare_rope(rope_grid_sizes) + mx.eval(rope_cos_sin) + print(f"{Colors.DIM} Models loaded: {time.time() - t2:.1f}s{Colors.RESET}") + + # Setup scheduler + _schedulers = { + "euler": FlowMatchEulerScheduler, + "dpm++": FlowDPMPP2MScheduler, + "unipc": FlowUniPCScheduler, + } + sched_cls = _schedulers.get(scheduler, FlowUniPCScheduler) + sched = sched_cls(num_train_timesteps=config.num_train_timesteps) + sched.set_timesteps(steps, shift=shift) + + # Generate initial noise — at the SAME stream position as previous + # versions (after both experts' construction-time PRNG consumption): + # parallel mode is bit-exact with prior releases, and relay pre-consumed + # an identical amount via lazy replicas (see _PhaseProvider.__init__). + noise = mx.random.normal(target_shape) + + # I2V initialization: TI2V-5B blends image with noise, I2V-14B uses pure noise + if is_i2v_mask_blend: + latents = (1.0 - i2v_mask) * z_img + i2v_mask * noise + else: + latents = noise + + # Diffusion loop + print(f"\n{Colors.GREEN}Denoising ({steps} steps)...{Colors.RESET}") + t3 = time.time() + + # Compile model forward for faster denoising. + # Dual experts are compiled inside _build_expert (relay mode may not have + # both resident here); only the single-model path is compiled at this point. + if not no_compile and not is_dual: + single_model._compiled = mx.compile(single_model) + + # Pre-convert timesteps to Python list to avoid .item() sync each step + timestep_list = sched.timesteps.tolist() + + # Per-step wall-clock. The aggregate below hides two systematic biases that matter whenever a step + # time is used to plan anything: step 0 pays the cold Metal pipeline cache (and, on the relay, the + # first expert build), and on a dual model the high->low swap is amortised across the mean. A 4-step + # Lightning run has so few samples that both land squarely in the average. + _step_times: list[tuple[float, str]] = [] + for i, t in enumerate(tqdm(range(steps), desc="Diffusion")): + _t_step = time.time() + timestep_val = timestep_list[i] + + # Select model, cached K/V, and precomputed RoPE + if is_dual: + # Drop stale aliases BEFORE provider.get: at the phase boundary the + # previous iteration's locals would otherwise keep the outgoing + # expert alive through _free_all, defeating the relay (peak = both). + model = kv = rcs = _call = ctx = _bundle = None + _bundle = provider.get(timestep_val) + model = _bundle["model"] + kv = _bundle["kv"] + rcs = _bundle["rcs"] + else: + model = single_model + kv = cross_kv + rcs = rope_cos_sin + + # Use compiled forward when available (faster after first trace) + _call = getattr(model, "_compiled", model) + + if cfg_disabled: + # No CFG: B=1 forward pass (2x faster than B=2 CFG batch) + if is_i2v_mask_blend: + t_tokens = i2v_mask_tokens * timestep_val + pad_len = seq_len - t_tokens.shape[1] + if pad_len > 0: + t_tokens = mx.concatenate( + [t_tokens, mx.full((1, pad_len), timestep_val)], axis=1 + ) + t_batch = t_tokens # [1, L] + else: + t_batch = mx.array([timestep_val]) + + y_arg = [y_i2v] if is_i2v_channel_concat else None + + if is_dual: + ctx = _bundle["ctx"] + else: + ctx = context_cond + preds = _call( + [latents], + t=t_batch, + context=ctx, + seq_len=seq_len, + cross_kv_caches=kv, + y=y_arg, + rope_cos_sin=rcs, + ) + noise_pred = preds[0] + del preds + else: + # CFG: batch cond + uncond into single B=2 forward pass + if is_dual: + gs = guide_scale[1] if timestep_val >= boundary else guide_scale[0] + else: + gs = ( + guide_scale + if isinstance(guide_scale, (int, float)) + else guide_scale[0] + ) + + if is_i2v_mask_blend: + t_tokens = i2v_mask_tokens * timestep_val + pad_len = seq_len - t_tokens.shape[1] + if pad_len > 0: + t_tokens = mx.concatenate( + [t_tokens, mx.full((1, pad_len), timestep_val)], axis=1 + ) + t_batch = mx.concatenate([t_tokens, t_tokens], axis=0) + else: + t_batch = mx.array([timestep_val, timestep_val]) + + y_arg = [y_i2v, y_i2v] if is_i2v_channel_concat else None + + ctx = context_cfg if not is_dual else _bundle["ctx"] + preds = _call( + [latents, latents], + t=t_batch, + context=ctx, + seq_len=seq_len, + cross_kv_caches=kv, + y=y_arg, + rope_cos_sin=rcs, + ) + noise_pred_cond, noise_pred_uncond = preds[0], preds[1] + noise_pred = noise_pred_uncond + gs * (noise_pred_cond - noise_pred_uncond) + del noise_pred_cond, noise_pred_uncond, preds + + latents = sched.step(noise_pred[None], timestep_val, latents[None]).squeeze(0) + + # TI2V-5B: re-apply mask to keep first frame frozen + if is_i2v_mask_blend: + latents = (1.0 - i2v_mask) * z_img + i2v_mask * latents + + # Release temporaries before eval to free memory for graph execution + del noise_pred + mx.eval(latents) + # mx.eval is the sync point, so this brackets the whole step exactly. + _step_times.append((time.time() - _t_step, ("high" if timestep_val >= boundary else "low") if is_dual else "-")) + + print(f"{Colors.DIM} Denoising: {time.time() - t3:.1f}s{Colors.RESET}") + if _step_times: + _detail = " ".join(f"{n}:{d_:.1f}s" for d_, n in _step_times) + print(f"{Colors.DIM} Steps: {_detail}{Colors.RESET}") + _warm = [d_ for d_, _ in _step_times[1:]] + if _warm: + _warm_sorted = sorted(_warm) + _median = _warm_sorted[len(_warm_sorted) // 2] + print( + f"{Colors.DIM} Per-step: first {_step_times[0][0]:.1f}s (cold), " + f"warm median {_median:.1f}s over {len(_warm)}{Colors.RESET}" + ) + + # Diagnostic: per-temporal-position latent statistics + if debug_latents: + lat_np = np.array(latents) # [C, T, H, W] + n_t = lat_np.shape[1] + print( + f"\n{Colors.CYAN} Latent diagnostics (shape {lat_np.shape}):{Colors.RESET}" + ) + print( + f" {'Pos':>4s} {'Mean':>8s} {'Std':>8s} {'Min':>8s} {'Max':>8s} {'AbsMean':>8s}" + ) + for t_pos in range(min(n_t, 8)): + frame = lat_np[:, t_pos, :, :] + print( + f" {t_pos:4d} {frame.mean():8.4f} {frame.std():8.4f} " + f"{frame.min():8.4f} {frame.max():8.4f} {np.abs(frame).mean():8.4f}" + ) + if n_t > 8: + interior = lat_np[:, 4:, :, :] + print( + f" {'4+':>4s} {interior.mean():8.4f} {interior.std():8.4f} " + f"{interior.min():8.4f} {interior.max():8.4f} {np.abs(interior).mean():8.4f}" + ) + print() + + # Contract-test hook: final pre-VAE latents, before anything stochastic + # or lossy (VAE, mp4 encode) touches them + if dump_latents: + np.save(dump_latents, np.array(latents.astype(mx.float32))) + print(f"{Colors.DIM} Latents dumped to {dump_latents}{Colors.RESET}") + + # Free transformer models and text embeddings. Drop ALL loop aliases first: + # _call is the mx.compile wrapper and closes over the model (traced tape + # holds the weight buffers); rcs/ctx alias its tables. Missing any of these + # keeps the last expert resident through the whole VAE decode — stock has + # the same leak via the identical loop locals (upstream-fix candidate). + # None-assignment (not del) also survives the steps==0 edge case. + model = kv = rcs = _call = ctx = _bundle = None + if is_dual: + provider.close() + else: + del single_model, cross_kv + if cfg_disabled: + del context_cond + else: + del context_cfg + rope_cos_sin = None + del context + if context_null is not None: + del context_null + gc.collect() + mx.clear_cache() + + # Load VAE and decode + print(f"\n{Colors.BLUE}Decoding with VAE...{Colors.RESET}") + t4 = time.time() + vae_path = model_dir / "vae.safetensors" + vae = load_vae_decoder(vae_path, config) + + is_wan22_vae = config.vae_z_dim == 48 + + # Temporal extend: prepend reflected latent frames to the VAE input so that + # the CausalConv3d zero-padding artifacts fall on the prefix (which we crop). + # This gives the first real frame a full temporal receptive field of real data. + # Select tiling configuration + from mlx_video.models.ltx_2.video_vae.tiling import TilingConfig + + if tiling == "none": + tiling_config = None + elif tiling == "auto": + tiling_config = TilingConfig.auto(height, width, num_frames) + elif tiling == "default": + tiling_config = TilingConfig.default() + elif tiling == "aggressive": + tiling_config = TilingConfig.aggressive() + elif tiling == "conservative": + tiling_config = TilingConfig.conservative() + elif tiling == "spatial": + tiling_config = TilingConfig.spatial_only() + elif tiling == "temporal": + tiling_config = TilingConfig.temporal_only() + else: + print( + f"{Colors.YELLOW} Unknown tiling mode '{tiling}', using auto{Colors.RESET}" + ) + tiling_config = TilingConfig.auto(height, width, num_frames) + + if tiling_config is not None: + spatial_info = ( + f"{tiling_config.spatial_config.tile_size_in_pixels}px" + if tiling_config.spatial_config + else "none" + ) + temporal_info = ( + f"{tiling_config.temporal_config.tile_size_in_frames}f" + if tiling_config.temporal_config + else "none" + ) + print( + f"{Colors.DIM} Tiling ({tiling}): spatial={spatial_info}, temporal={temporal_info}{Colors.RESET}" + ) + + if is_wan22_vae: + from mlx_video.models.wan_2.vae22 import denormalize_latents + + # latents: [C, T, H, W] → [1, T, H, W, C] (channels-last for Wan2.2 VAE) + z = latents.transpose(1, 2, 3, 0)[None] + z = denormalize_latents(z) + if tiling_config is not None: + video = vae.decode_tiled(z, tiling_config) + else: + video = vae(z) + mx.eval(video) + print(f"{Colors.DIM} VAE decode: {time.time() - t4:.1f}s{Colors.RESET}") + + video = np.array(video[0]) # [T', H', W', 3] + video = (video + 1.0) / 2.0 + video = np.clip(video * 255.0, 0, 255).astype(np.uint8) + else: + if tiling_config is not None: + video = vae.decode_tiled(latents[None], tiling_config) + else: + video = vae.decode(latents[None]) + mx.eval(video) + print(f"{Colors.DIM} VAE decode: {time.time() - t4:.1f}s{Colors.RESET}") + + video = np.array(video[0]) # [3, T', H, W] + video = (video + 1.0) / 2.0 + video = np.clip(video * 255.0, 0, 255).astype(np.uint8) + video = video.transpose(1, 2, 3, 0) # [T, H, W, 3] + + # Trim first N temporal chunks if requested (avoids first-frame artifacts) + if trim_first_frames > 0: + trim_pixels = trim_first_frames * 4 + video = video[trim_pixels:] + print( + f"{Colors.DIM} Trimmed first {trim_pixels} frames ({video.shape[0]} remaining){Colors.RESET}" + ) + + save_video(video, output_path, fps=config.sample_fps) + print(f"\n{Colors.GREEN}✓ Video saved to {output_path}{Colors.RESET}") + print(f"{Colors.DIM} Total time: {time.time() - t1:.1f}s{Colors.RESET}") + + +def main(): + parser = argparse.ArgumentParser(description="Wan Text-to-Video Generation (MLX)") + parser.add_argument( + "--model-dir", + type=str, + required=True, + help="Path to converted MLX model directory", + ) + parser.add_argument("--prompt", type=str, required=True, help="Text prompt") + parser.add_argument( + "--image", + type=str, + default=None, + help="Path to input image for I2V (omit for T2V mode)", + ) + parser.add_argument( + "--negative-prompt", + type=str, + default=None, + help="Negative prompt for CFG (default: official Chinese prompt from config)", + ) + parser.add_argument( + "--no-negative-prompt", + action="store_true", + help="Disable negative prompt (use empty string instead of config default)", + ) + parser.add_argument( + "--width", type=int, default=1280, help="Video width (default: 1280)" + ) + parser.add_argument( + "--height", + type=int, + default=704, + help="Video height (default: 704; 720p models use 704)", + ) + parser.add_argument( + "--num-frames", type=int, default=81, help="Number of frames (must be 4n+1)" + ) + parser.add_argument( + "--steps", + type=int, + default=None, + help="Number of diffusion steps (default: from config)", + ) + parser.add_argument( + "--guide-scale", + type=str, + default=None, + help="Guidance scale: single float or low,high pair", + ) + parser.add_argument( + "--shift", + type=float, + default=None, + help="Noise schedule shift (default: from config)", + ) + parser.add_argument("--seed", type=int, default=-1, help="Random seed") + parser.add_argument( + "--output-path", type=str, default="output.mp4", help="Output video path" + ) + parser.add_argument( + "--scheduler", + type=str, + default="unipc", + choices=["euler", "dpm++", "unipc"], + help="Diffusion solver: euler (1st order), dpm++ (2nd order), unipc (2nd order PC, default/official)", + ) + parser.add_argument( + "--lora", + nargs=2, + action="append", + metavar=("PATH", "STRENGTH"), + help="Apply a LoRA to all models (repeatable). Format: --lora path.safetensors 0.8", + ) + parser.add_argument( + "--lora-high", + nargs=2, + action="append", + metavar=("PATH", "STRENGTH"), + help="Apply a LoRA to high-noise model only (dual-model, repeatable)", + ) + parser.add_argument( + "--lora-low", + nargs=2, + action="append", + metavar=("PATH", "STRENGTH"), + help="Apply a LoRA to low-noise model only (dual-model, repeatable)", + ) + parser.add_argument( + "--tiling", + type=str, + default="auto", + choices=[ + "auto", + "none", + "default", + "aggressive", + "conservative", + "spatial", + "temporal", + ], + help="VAE tiling mode to reduce memory during decoding (default: auto)", + ) + parser.add_argument( + "--no-compile", + action="store_true", + help="Disable mx.compile on models (for debugging)", + ) + parser.add_argument( + "--trim-first-frames", + type=int, + default=0, + metavar="N", + help="Generate N extra temporal chunks (N×4 frames) and discard them from the start. " + "Fixes first-frame color/lighting artifacts on 14B models. Try 1 first (4 frames). " + "Default: 0 (disabled)", + ) + parser.add_argument( + "--debug-latents", + action="store_true", + help="Print per-temporal-position latent statistics after denoising (diagnostic)", + ) + parser.add_argument( + "--memory-mode", + type=str, + default="auto", + choices=["auto", "relay", "parallel"], + help="Dual-model expert residency: relay = only the active expert in " + "memory, swapped once at the phase boundary (fits A14B bf16 on 48GB); " + "parallel = both resident. auto (default) = relay for dual models", + ) + parser.add_argument( + "--dump-latents", + type=str, + default=None, + metavar="PATH", + help="Save the final pre-VAE latents as float32 .npy (for bitwise " + "relay-vs-parallel contract testing)", + ) + args = parser.parse_args() + + # Parse guide scale + guide_scale = None + if args.guide_scale is not None: + parts = [float(x) for x in args.guide_scale.split(",")] + guide_scale = tuple(parts) if len(parts) > 1 else parts[0] + + # Handle negative prompt: --no-negative-prompt forces empty, otherwise pass through + neg_prompt = args.negative_prompt + if args.no_negative_prompt: + neg_prompt = "" + + # Parse LoRA configs: convert [path, strength_str] → (path, float) + def _parse_lora_args(lora_list): + if not lora_list: + return None + return [(path, float(strength)) for path, strength in lora_list] + + generate_video( + model_dir=args.model_dir, + prompt=args.prompt, + negative_prompt=neg_prompt, + image=args.image, + width=args.width, + height=args.height, + num_frames=args.num_frames, + steps=args.steps, + guide_scale=guide_scale, + shift=args.shift, + seed=args.seed, + output_path=args.output_path, + scheduler=args.scheduler, + loras=_parse_lora_args(args.lora), + loras_high=_parse_lora_args(args.lora_high), + loras_low=_parse_lora_args(args.lora_low), + tiling=args.tiling, + no_compile=args.no_compile, + trim_first_frames=args.trim_first_frames, + debug_latents=args.debug_latents, + memory_mode=args.memory_mode, + dump_latents=args.dump_latents, + ) + + +if __name__ == "__main__": + main() diff --git a/local/requirements.txt b/local/requirements.txt new file mode 100644 index 0000000..1d13561 --- /dev/null +++ b/local/requirements.txt @@ -0,0 +1,15 @@ +# Videoboom local-model sidecar (Apple Silicon, MLX). Installed into local/.venv by setup.sh. +# mlx-video pulls mlx, transformers, safetensors, numpy, pillow, tqdm, imageio, etc. +mlx-video @ git+https://github.com/Blaizzy/mlx-video.git +huggingface_hub +# torch is needed ONLY at convert time, to read the Wan .pth files (T5 encoder + VAE). +# Without it the conversion crashes before the quantize step and leaves the transformers in bf16. +torch +# local stages: STT (whisper), keyframes (mflux FLUX). mlx-lm (LLM) + mlx-vlm (VLM) ship with mlx-video. +mlx-whisper +mflux +# frame interpolation (RIFE, ncnn->Vulkan->MoltenVK->Metal = Apple GPU). MIT. Generate fewer frames + 2x up. +# Needs the rife-v4.6 model files (flownet.param/.bin) downloaded into the package's models/ dir (setup.sh). +rife-ncnn-vulkan-python-tntwise +# upscale / detail recovery (Real-ESRGAN, ncnn->Metal Apple GPU). Bundles realesrgan-x4plus weights. +realesrgan-ncnn-py diff --git a/local/server.py b/local/server.py new file mode 100644 index 0000000..bb332f2 --- /dev/null +++ b/local/server.py @@ -0,0 +1,187 @@ +"""Resident local-model HTTP sidecar for Videoboom. + +Started on demand by the Electron engine (src/engine/sidecar.ts). Stays warm for the app's lifetime so the +heavy Python/MLX import (and, per model, the weights) is paid once, not per request. Localhost only; one +GPU job at a time (a single global lock). Model-agnostic: each request names the model(s) it needs, so one +process serves every on-device stage. + +Endpoints + GET /health -> {ok} + POST /i2v {json} -> {ok, num_frames, ...} Wan 2.2 image-to-video (blocks minutes) + POST /stt {json} -> {ok, words:[{start,end,word}], text} whisper transcription + word timing + +Heavy handlers write their output straight to a path in the request (same machine, same FS) or return +small JSON; serialised by GPU_LOCK so concurrent requests queue rather than oversubscribe memory. +""" +import argparse +import json +import os +import threading +import time +import traceback +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +GPU_LOCK = threading.Lock() # one model job at a time (shared unified memory) + + +def _handle_i2v(req: dict) -> dict: + # The video model self-loads/frees its weights outside the ModelManager, so first evict any resident + # keyframe/LLM/VLM model — otherwise that (~10GB FLUX, ~19GB LLM) plus the ~19-24GB video model OOMs + # unified memory (Metal "Insufficient Memory"). + from manager import unload_all + unload_all() + from wan_i2v import run_i2v # Wan 2.2 (5B / 14B) + return run_i2v(req) + + +def _handle_stt(req: dict) -> dict: + from stt import run_stt + return run_stt(req) + + +def _handle_llm(req: dict) -> dict: + from llm import run_llm + return run_llm(req) + + +def _handle_vlm(req: dict) -> dict: + from vlm import run_vlm + return run_vlm(req) + + +def _handle_keyframe(req: dict) -> dict: + from keyframe import run_keyframe + return run_keyframe(req) + + +def _run_isolated(script: str, req: dict) -> dict: + """Run an ncnn/Vulkan job (interp.py / upscale.py) in a FRESH python subprocess. The rife and + realesrgan wheels each statically bundle MoltenVK — importing both in one process duplicates objc + classes and SEGFAULTS (verified). Isolation also returns all Vulkan memory the moment the job ends. + Still under GPU_LOCK like every job. The child reads the request JSON on stdin and prints the result + JSON as its last stdout line (the ncnn wrappers spam progress lines first).""" + import os + import signal + import subprocess + import sys + here = os.path.dirname(os.path.abspath(__file__)) + # start_new_session so a timeout can kill the WHOLE process group: the worker spawns ffmpeg children, + # and killing only the python pid would leave a re-parented ffmpeg running (CPU + half-written files). + p = subprocess.Popen( + [sys.executable, os.path.join(here, script)], + stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + start_new_session=True, + ) + try: + out, err = p.communicate(input=json.dumps(req).encode(), timeout=int(req.get("timeout_sec", 5400))) + except subprocess.TimeoutExpired: + try: + os.killpg(os.getpgid(p.pid), signal.SIGKILL) + except (ProcessLookupError, PermissionError): + p.kill() + p.communicate() + return {"ok": False, "error": f"{script} timed out"} + if p.returncode != 0: + tail = (err or b"")[-800:].decode(errors="replace") + return {"ok": False, "error": f"{script} exited {p.returncode}: {tail}"} + for line in reversed((out or b"").decode(errors="replace").splitlines()): + line = line.strip() + if line.startswith("{"): + try: + return json.loads(line) + except ValueError: + pass + return {"ok": False, "error": f"{script}: no JSON result in output"} + + +def _handle_interp(req: dict) -> dict: + # RIFE frame interpolation — isolated subprocess (see _run_isolated), no MLX model, no unload_all(). + return _run_isolated("interp.py", req) + + +def _handle_upscale(req: dict) -> dict: + # Real-ESRGAN video upscale — isolated subprocess, same deal as /interp. + return _run_isolated("upscale.py", req) + + +ROUTES = { + "/i2v": _handle_i2v, + "/stt": _handle_stt, + "/llm": _handle_llm, + "/vlm": _handle_vlm, + "/keyframe": _handle_keyframe, + "/interp": _handle_interp, + "/upscale": _handle_upscale, +} + + +class Handler(BaseHTTPRequestHandler): + def _send(self, code: int, obj: dict) -> None: + body = json.dumps(obj).encode() + self.send_response(code) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_GET(self) -> None: + if self.path == "/health": + self._send(200, {"ok": True, "routes": sorted(ROUTES)}) + else: + self._send(404, {"ok": False, "error": "not found"}) + + def do_POST(self) -> None: + handler = ROUTES.get(self.path) + if handler is None: + self._send(404, {"ok": False, "error": "not found"}) + return + try: + n = int(self.headers.get("Content-Length", 0)) + req = json.loads(self.rfile.read(n) or b"{}") + except Exception as e: # noqa: BLE001 + self._send(400, {"ok": False, "error": f"bad request: {e}"}) + return + with GPU_LOCK: + try: + result = handler(req) + self._send(200 if result.get("ok") else 500, result) + except Exception as e: # noqa: BLE001 + traceback.print_exc() + self._send(500, {"ok": False, "error": str(e)}) + + def log_message(self, *args) -> None: # silence per-request stderr spam + pass + + +def _watch_parent(ppid: int) -> None: + """Exit when the Electron process that spawned us is gone. + + We are a plain child, so on macOS a quit re-parents us to launchd and we would otherwise keep running + forever — with the last heavy model still resident (manager.py evicts only at the head of /i2v), i.e. + 10-19 GB of unified memory held with no app on screen. sidecar.ts kills us on a clean quit; this covers + the cases where it cannot (crash, SIGKILL). os._exit skips atexit/GC on purpose: the point is to release + the memory immediately, and a diffusion job holding GPU_LOCK would stall a graceful shutdown. + """ + while True: + time.sleep(5) + try: + os.kill(ppid, 0) + except OSError: + print("[vb-local] parent gone — exiting", flush=True) + os._exit(0) + + +def main() -> None: + ap = argparse.ArgumentParser(description="Videoboom local-model sidecar") + ap.add_argument("--port", type=int, default=8765) + ap.add_argument("--parent-pid", type=int, default=0, help="exit when this pid disappears") + args = ap.parse_args() + if args.parent_pid: + threading.Thread(target=_watch_parent, args=(args.parent_pid,), daemon=True).start() + srv = ThreadingHTTPServer(("127.0.0.1", args.port), Handler) + print(f"[vb-local] sidecar on http://127.0.0.1:{args.port} routes={sorted(ROUTES)}", flush=True) + srv.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/local/setup.sh b/local/setup.sh new file mode 100755 index 0000000..effa65e --- /dev/null +++ b/local/setup.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# One-time setup for Videoboom local models (Apple Silicon / MLX). +# Creates the venv, installs Blaizzy/mlx-video, then hands the video engine to download.py. +# Run once: bash local/setup.sh +# +# Disk: ~69GB for the pre-converted Wan-14B bf16 repo plus ~2.5GB for the Lightning LoRA. The other +# stages (STT / LLM / VLM / keyframes) install from Settings -> On-device and add roughly another +# 28GB. Point the models dir at an +# external SSD with VB_LOCAL_MODELS_DIR if internal disk is tight. +set -euo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +PY_BASE="${VB_LOCAL_PYTHON_BASE:-$(command -v python3.12 || command -v python3.11 || true)}" +VENV="$HERE/.venv" +MODELS_DIR="${VB_LOCAL_MODELS_DIR:-$HERE/models}" +ENGINE="${VB_LOCAL_VIDEO_MODEL:-14b}" + +if [ -z "$PY_BASE" ]; then + echo "ERROR: need Python >= 3.11. Install with: brew install python@3.12" >&2 + exit 1 +fi +echo "==> Python base: $PY_BASE" +"$PY_BASE" --version + +echo "==> Creating venv at $VENV" +"$PY_BASE" -m venv "$VENV" +# shellcheck disable=SC1091 +source "$VENV/bin/activate" +python -m pip install -U pip wheel +echo "==> Installing mlx-video + huggingface_hub" +python -m pip install -r "$HERE/requirements.txt" + +mkdir -p "$MODELS_DIR" +export HF_HUB_DISABLE_XET=1 # the Xet backend stalls large downloads + +# The video engine comes from download.py, the same provisioner the app's Download button uses: a plain +# snapshot of a PRE-CONVERTED MLX repo, plus the Lightning LoRA and the per-engine marker. +# +# This used to download Wan-AI/Wan2.2-I2V-A14B (the ~120GB fp32 checkpoint), spend hours converting it to +# Q4, and write THAT into .model-path. Two things were wrong with it. Q4 does not fit: its config carries a +# `quantization` key, so wan_i2v.py's relay never engages and both experts stay resident — measured at +# 67.7GB peak against the app's own 48GB floor, where the pre-converted bf16 relay peaks at 32.6GB and is +# also faster (27.5 vs 47.6 s/step). And .model-path is exactly what videoReady() and modelDir() resolve, +# so the unusable model was the one the app picked up, reporting Ready. +# +# One provisioner, one marker. VB_LOCAL_VIDEO_MODEL selects the engine (14b default, 5b retired). +echo "==> Provisioning the video engine ($ENGINE) via download.py" +VB_LOCAL_VIDEO_MODEL="$ENGINE" VB_LOCAL_MODELS_DIR="$MODELS_DIR" VB_LOCAL_MARKER_DIR="$HERE" \ + python "$HERE/download.py" VIDEO + +echo "" +echo "==> Done. download.py wrote the model dir and its marker under $HERE." +echo " The app reads the marker automatically; nothing to configure." +echo "" +echo "The remaining stage models (STT / LLM / VLM / keyframes) install from" +echo "Settings -> On-device -> Download." +echo "" diff --git a/local/smoke_5b_esrgan.py b/local/smoke_5b_esrgan.py new file mode 100644 index 0000000..d84bab5 --- /dev/null +++ b/local/smoke_5b_esrgan.py @@ -0,0 +1,69 @@ +"""Smoke test: ~8s of Wan2.2-TI2V-5B video (chained native sub-clips) + a Real-ESRGAN upscaled version, +so we can eyeball whether ESRGAN cleans up the 5B's x64-VAE softness/deformation. Usage: + python smoke_5b_esrgan.py +Outputs /tmp/smoke_5b_raw.mp4 (8s, 480p) and /tmp/smoke_5b_esrgan.mp4 (8s, ESRGAN then back to a sharp 480p). +""" +import os +import subprocess +import sys +import time + +HERE = os.path.dirname(os.path.abspath(__file__)) +FF = os.environ.get("VB_FFMPEG", "ffmpeg") +M5 = open(os.path.join(HERE, ".model-path-5b")).read().strip() +START = sys.argv[1] +PROMPT = "the man walks slowly through the moody room toward the camera, subtle natural motion, cinematic handheld camera, film lighting" +FPS = 24 +SUB_SEC = 2.4 # one native 5B clip +N_SUB = 4 # ~9.6s total -> trim/keep ~8s +STEPS = int(os.environ.get("VB_SMOKE_STEPS", "12")) + + +def last_frame(video: str, out: str): + subprocess.run([FF, "-y", "-loglevel", "error", "-sseof", "-0.4", "-i", video, "-frames:v", "1", "-update", "1", out], check=True) + return out + + +def run(): + from wan_i2v import run_i2v + + subs = [] + start = START + t0 = time.time() + for i in range(N_SUB): + out = f"/tmp/smk_sub_{i}.mp4" + r = run_i2v({"model_dir": M5, "image": start, "prompt": PROMPT, "out": out, + "seconds": SUB_SEC, "fps": FPS, "steps": STEPS, "width": 832, "height": 480, "seed": 42 + i}) + if not r.get("ok"): + print("SUB", i, "FAILED", r); return + subs.append(out) + print(f" sub {i+1}/{N_SUB} ok ({time.time()-t0:.0f}s elapsed)", flush=True) + if i < N_SUB - 1: + start = last_frame(out, f"/tmp/smk_last_{i}.png") + # concat raw + lst = "/tmp/smk_concat.txt" + open(lst, "w").write("\n".join(f"file '{s}'" for s in subs) + "\n") + subprocess.run([FF, "-y", "-loglevel", "error", "-f", "concat", "-safe", "0", "-i", lst, + "-c:v", "libx264", "-pix_fmt", "yuv420p", "-an", "/tmp/smoke_5b_raw.mp4"], check=True) + print(f" RAW 8s done ({time.time()-t0:.0f}s)", flush=True) + + # ESRGAN: upscale every frame, then downscale back to a crisp 480p (detail recovery, same size to compare) + from PIL import Image + from realesrgan_ncnn_py import Realesrgan + up = Realesrgan(gpuid=0, model=0) + fin, fout = "/tmp/smk_frames_in", "/tmp/smk_frames_out" + os.makedirs(fin, exist_ok=True); os.makedirs(fout, exist_ok=True) + subprocess.run([FF, "-y", "-loglevel", "error", "-i", "/tmp/smoke_5b_raw.mp4", os.path.join(fin, "f_%05d.png")], check=True) + frames = sorted(f for f in os.listdir(fin) if f.endswith(".png")) + te = time.time() + for f in frames: + img = Image.open(os.path.join(fin, f)).convert("RGB") + out = up.process_pil(img).resize((832, 480), Image.LANCZOS) # upscale->detail, back to 480p to compare + out.save(os.path.join(fout, f)) + subprocess.run([FF, "-y", "-loglevel", "error", "-framerate", str(FPS), + "-i", os.path.join(fout, "f_%05d.png"), "-c:v", "libx264", "-pix_fmt", "yuv420p", "/tmp/smoke_5b_esrgan.mp4"], check=True) + print(f" ESRGAN {len(frames)} frames done ({time.time()-te:.0f}s). TOTAL {time.time()-t0:.0f}s", flush=True) + print("DONE raw=/tmp/smoke_5b_raw.mp4 esrgan=/tmp/smoke_5b_esrgan.mp4", flush=True) + + +run() diff --git a/local/stt.py b/local/stt.py new file mode 100644 index 0000000..e40f1d9 --- /dev/null +++ b/local/stt.py @@ -0,0 +1,31 @@ +"""Local speech-to-text with word timing, MLX-native, via mlx-whisper. + +Returns the same {start, end, word} shape the engine's WhisperX path produces, so providers.transcribeWords +can swap cloud<->local with no pipeline change. mlx-whisper caches the loaded model internally (lru), so a +warm sidecar reuses it across songs. + +Note: WhisperX adds wav2vec2 *forced alignment* for very tight word timing; whisper's own word_timestamps +are slightly looser but good enough for phrase-based scene segmentation. +""" + + +def run_stt(req: dict) -> dict: + import mlx_whisper + + audio = req["audio"] + model = req.get("model", "mlx-community/whisper-large-v3-turbo") + language = req.get("language") or None # None => auto-detect + + kw = {"path_or_hf_repo": model, "word_timestamps": True} + if language: + kw["language"] = language + res = mlx_whisper.transcribe(audio, **kw) + + words = [] + for seg in res.get("segments", []): + for w in seg.get("words", []): + st, en = w.get("start"), w.get("end") + wd = (w.get("word") or "").strip() + if st is not None and en is not None and float(en) > float(st) and wd: + words.append({"start": float(st), "end": float(en), "word": wd}) + return {"ok": True, "words": words, "text": (res.get("text") or "").strip()} diff --git a/local/taehv_upstream.py b/local/taehv_upstream.py new file mode 100644 index 0000000..4386d70 --- /dev/null +++ b/local/taehv_upstream.py @@ -0,0 +1,464 @@ +#!/usr/bin/env python3 +""" +Tiny AutoEncoder for Hunyuan Video +(DNN for encoding / decoding videos to Hunyuan Video's latent space) +""" +import torch +import torch.nn as nn +import torch.nn.functional as F +from tqdm.auto import tqdm +from collections import namedtuple + +TWorkItem = namedtuple("TWorkItem", ("input_tensor", "block_index")) + +def conv(n_in, n_out, **kwargs): + return nn.Conv2d(n_in, n_out, 3, padding=1, **kwargs) + +class Clamp(nn.Module): + def forward(self, x): + return torch.tanh(x / 3) * 3 + +class MemBlock(nn.Module): + def __init__(self, n_in, n_out): + super().__init__() + self.conv = nn.Sequential(conv(n_in * 2, n_out), nn.ReLU(inplace=True), conv(n_out, n_out), nn.ReLU(inplace=True), conv(n_out, n_out)) + self.skip = nn.Conv2d(n_in, n_out, 1, bias=False) if n_in != n_out else nn.Identity() + self.act = nn.ReLU(inplace=True) + def forward(self, x, past): + return self.act(self.conv(torch.cat([x, past], 1)) + self.skip(x)) + +class TPool(nn.Module): + def __init__(self, n_f, stride): + super().__init__() + self.stride = stride + self.conv = nn.Conv2d(n_f*stride,n_f, 1, bias=False) + def forward(self, x): + _NT, C, H, W = x.shape + return self.conv(x.reshape(-1, self.stride * C, H, W)) + +class TGrow(nn.Module): + def __init__(self, n_f, stride): + super().__init__() + self.stride = stride + self.conv = nn.Conv2d(n_f, n_f*stride, 1, bias=False) + def forward(self, x): + _NT, C, H, W = x.shape + x = self.conv(x) + return x.reshape(-1, C, H, W) + +def apply_model_with_memblocks_parallel(model, x, show_progress_bar): + """ + Apply a sequential model with memblocks to the given input, + with parallelization over the time axis and iteration over blocks. + + Args: + - model: nn.Sequential of blocks to apply + - x: input data, of dimensions NTCHW + - show_progress_bar: if True, enables tqdm progressbar display + + Returns NTCHW tensor of output data. + """ + assert x.ndim == 5, f"TAEHV operates on NTCHW tensors, but got {x.ndim}-dim tensor" + N, T, C, H, W = x.shape + x = x.reshape(N*T, C, H, W) + + # parallel over input timesteps, iterate over blocks + for b in tqdm(model, disable=not show_progress_bar): + if isinstance(b, MemBlock): + NT, C, H, W = x.shape + T = NT // N + _x = x.reshape(N, T, C, H, W) + # pad with zeros along time axis (i.e. empty memory), slice + block_memory = F.pad(_x, (0,0,0,0,0,0,1,0), value=0)[:,:T].reshape(x.shape) + x = b(x, block_memory) + else: + x = b(x) + NT, C, H, W = x.shape + T = NT // N + return x.view(N, T, C, H, W) + +def apply_model_with_memblocks_sequential_single_step(model, memory, work_queue, progress_bar=None): + """ + Process the work queue (a graph traversal over blocks and timesteps) + until an output frame is produced or the queue is empty. + Mutates memory and work_queue in place. + + Returns N1CHW output tensor, or None if the queue needs more input. + """ + while work_queue: + xt, i = work_queue.pop(0) + if progress_bar is not None and i == 0: + progress_bar.update(1) + if i == len(model): + return xt.unsqueeze(1) + b = model[i] + if isinstance(b, MemBlock): + # mem blocks are simple since we're visiting the graph in causal order + if memory[i] is None: + xt_new = b(xt, xt * 0) + else: + xt_new = b(xt, memory[i]) + memory[i] = xt + work_queue.insert(0, TWorkItem(xt_new, i+1)) + elif isinstance(b, TPool): + # pool blocks accumulate inputs until they have enough to pool + if memory[i] is None: + memory[i] = [] + memory[i].append(xt) + if len(memory[i]) > b.stride: + raise ValueError(f"TPool memory overflow: {len(memory[i])} items for stride {b.stride}") + elif len(memory[i]) == b.stride: + N, C, H, W = xt.shape + xt = b(torch.cat(memory[i], 1).view(N*b.stride, C, H, W)) + memory[i] = [] + work_queue.insert(0, TWorkItem(xt, i+1)) + elif isinstance(b, TGrow): + xt = b(xt) + NT, C, H, W = xt.shape + for xt_next in reversed(xt.view(NT//b.stride, b.stride*C, H, W).chunk(b.stride, 1)): + work_queue.insert(0, TWorkItem(xt_next, i+1)) + else: + xt = b(xt) + work_queue.insert(0, TWorkItem(xt, i+1)) + return None + +def apply_model_with_memblocks_sequential(model, x, show_progress_bar): + """ + Apply a sequential model with memblocks to the given input, + with iteration over timesteps as well as blocks. + + Args: + - model: nn.Sequential of blocks to apply + - x: input data, of dimensions NTCHW + - show_progress_bar: if True, enables tqdm progressbar display + + Returns NTCHW tensor of output data. + """ + assert x.ndim == 5, f"TAEHV operates on NTCHW tensors, but got {x.ndim}-dim tensor" + work_queue = [TWorkItem(xt, 0) for xt in x.unbind(1)] + memory = [None] * len(model) + progress_bar = tqdm(range(len(work_queue)), disable=not show_progress_bar) + out = [] + while work_queue: + xt = apply_model_with_memblocks_sequential_single_step(model, memory, work_queue, progress_bar) + if xt is not None: + out.append(xt) + progress_bar.close() + return torch.cat(out, 1) + +def apply_model_with_memblocks(model, x, parallel, show_progress_bar): + """ + Apply a sequential model with memblocks to the given input. + Args: + - model: nn.Sequential of blocks to apply + - x: input data, of dimensions NTCHW + - parallel: if True, parallelize over timesteps (fast but uses O(T) memory) + if False, each timestep will be processed sequentially (slow but uses O(1) memory) + - show_progress_bar: if True, enables tqdm progressbar display + + Returns NTCHW tensor of output data. + """ + if parallel: + return apply_model_with_memblocks_parallel(model, x, show_progress_bar) + else: + return apply_model_with_memblocks_sequential(model, x, show_progress_bar) + +class TAEHV(nn.Module): + def __init__(self, checkpoint_path="taehv.pth", encoder_time_downscale=(True, True, False), decoder_time_upscale=(False, True, True), decoder_space_upscale=(True, True, True), patch_size=1, latent_channels=16): + """Initialize pretrained TAEHV from the given checkpoint. + + Arg: + checkpoint_path: path to weight file to load. taehv.pth for Hunyuan, taew2_1.pth for Wan 2.1. + encoder_time_downscale: whether temporal downsampling is enabled for each block. + decoder_time_upscale: whether temporal upsampling is enabled for each block. upsampling can be disabled for a cheaper preview. + decoder_space_upscale: whether spatial upsampling is enabled for each block. upsampling can be disabled for a cheaper preview. + patch_size: input/output pixelshuffle patch-size for this model. + latent_channels: number of latent channels (z dim) for this model. + """ + super().__init__() + self.patch_size = patch_size + self.latent_channels = latent_channels + self.image_channels = 3 + if len(decoder_time_upscale) == 2: + decoder_time_upscale = (False, *decoder_time_upscale) + self.is_cogvideox = checkpoint_path is not None and "taecvx" in checkpoint_path + if checkpoint_path is not None and "taew2_2" in checkpoint_path: + self.patch_size, self.latent_channels = 2, 48 + if checkpoint_path is not None and "taehv1_5" in checkpoint_path: + self.patch_size, self.latent_channels = 2, 32 + if checkpoint_path is not None and "taeltx" in checkpoint_path: # same for both 2 and 2.3 + self.patch_size, self.latent_channels, encoder_time_downscale, decoder_time_upscale = 4, 128, (True, True, True), (True, True, True) + self.encoder = nn.Sequential( + conv(self.image_channels*self.patch_size**2, 64), nn.ReLU(inplace=True), + TPool(64, 2 if encoder_time_downscale[0] else 1), conv(64, 64, stride=2, bias=False), MemBlock(64, 64), MemBlock(64, 64), MemBlock(64, 64), + TPool(64, 2 if encoder_time_downscale[1] else 1), conv(64, 64, stride=2, bias=False), MemBlock(64, 64), MemBlock(64, 64), MemBlock(64, 64), + TPool(64, 2 if encoder_time_downscale[2] else 1), conv(64, 64, stride=2, bias=False), MemBlock(64, 64), MemBlock(64, 64), MemBlock(64, 64), + conv(64, self.latent_channels), + ) + n_f = [256, 128, 64, 64] + self.decoder = nn.Sequential( + Clamp(), conv(self.latent_channels, n_f[0]), nn.ReLU(inplace=True), + MemBlock(n_f[0], n_f[0]), MemBlock(n_f[0], n_f[0]), MemBlock(n_f[0], n_f[0]), nn.Upsample(scale_factor=2 if decoder_space_upscale[0] else 1), TGrow(n_f[0], 2 if decoder_time_upscale[0] else 1), conv(n_f[0], n_f[1], bias=False), + MemBlock(n_f[1], n_f[1]), MemBlock(n_f[1], n_f[1]), MemBlock(n_f[1], n_f[1]), nn.Upsample(scale_factor=2 if decoder_space_upscale[1] else 1), TGrow(n_f[1], 2 if decoder_time_upscale[1] else 1), conv(n_f[1], n_f[2], bias=False), + MemBlock(n_f[2], n_f[2]), MemBlock(n_f[2], n_f[2]), MemBlock(n_f[2], n_f[2]), nn.Upsample(scale_factor=2 if decoder_space_upscale[2] else 1), TGrow(n_f[2], 2 if decoder_time_upscale[2] else 1), conv(n_f[2], n_f[3], bias=False), + nn.ReLU(inplace=True), conv(n_f[3], self.image_channels*self.patch_size**2), + ) + # computed properties + self.t_downscale = 2**sum(t.stride == 2 for t in self.encoder if isinstance(t, TPool)) + self.t_upscale = 2**sum(t.stride == 2 for t in self.decoder if isinstance(t, TGrow)) + self.frames_to_trim = self.t_upscale - 1 + + if checkpoint_path is not None: + self.load_state_dict(self.patch_tgrow_layers(torch.load(checkpoint_path, map_location="cpu", weights_only=True))) + + def patch_tgrow_layers(self, sd): + """Patch TGrow layers to use a smaller kernel if needed. + + Args: + sd: state dict to patch + """ + new_sd = self.state_dict() + for i, layer in enumerate(self.decoder): + if isinstance(layer, TGrow): + key = f"decoder.{i}.conv.weight" + if sd[key].shape[0] > new_sd[key].shape[0]: + # take the last-timestep output channels + sd[key] = sd[key][-new_sd[key].shape[0]:] + return sd + + def preprocess_input_frames(self, x): + """Preprocess RGB input frames prior to the main encoder sequence.""" + if self.patch_size > 1: x = F.pixel_unshuffle(x, self.patch_size) + return x + + def encode_video(self, x, parallel=True, show_progress_bar=True): + """Encode a sequence of frames. + + Args: + x: input NTCHW RGB (C=3) tensor with values in [0, 1]. + parallel: if True, all frames will be processed at once. + (this is faster but may require more memory). + if False, frames will be processed sequentially. + Returns NTCHW latent tensor with ~Gaussian values. + """ + x = self.preprocess_input_frames(x) + if x.shape[1] % self.t_downscale != 0: + # pad at end to multiple of self.t_downscale + n_pad = self.t_downscale - x.shape[1] % self.t_downscale + padding = x[:, -1:].repeat_interleave(n_pad, dim=1) + x = torch.cat([x, padding], 1) + return apply_model_with_memblocks(self.encoder, x, parallel, show_progress_bar) + + def postprocess_output_frames(self, x): + """Postprocess RGB frames after the main decoder sequence.""" + if self.patch_size > 1: x = F.pixel_shuffle(x, self.patch_size) + return x.clamp_(0, 1) + + def decode_video(self, x, parallel=True, show_progress_bar=True): + """Decode a sequence of frames. + + Args: + x: input NTCHW latent (C=self.latent_channels) tensor with ~Gaussian values. + parallel: if True, all frames will be processed at once. + (this is faster but may require more memory). + if False, frames will be processed sequentially. + Returns NTCHW RGB tensor with ~[0, 1] values. + """ + skip_trim = self.is_cogvideox and x.shape[1] % 2 == 0 + x = apply_model_with_memblocks(self.decoder, x, parallel, show_progress_bar) + x = self.postprocess_output_frames(x) + if skip_trim: + # skip trimming for cogvideox to make frame counts match. + # this still doesn't have correct temporal alignment for certain frame counts + # (cogvideox seems to pad at the start?), but for multiple-of-4 it's fine. + return x + return x[:, self.frames_to_trim:] + +class StreamingTAEHV(nn.Module): + def __init__(self, taehv): + """Streaming wrapper around TAEHV for real-time use-cases (where not all inputs are available immediately). + + Encode-decode (video-to-video) usage: + streaming = StreamingTAEHV(taehv) + for frame in video_frames: + latent = streaming.encode(frame_tensor) + decoded = streaming.decode(latent) # feeds latent if not None, then returns next frame + if decoded is not None: + display(decoded) + for frame in streaming.flush(): + display(frame) + + Decode-only (world model) usage: + streaming = StreamingTAEHV(taehv) + while running: + latent = world_model.step() # latent represents t_upscale frames + frame = streaming.decode(latent) # returns first frame immediately + while frame is not None: # retrieve remaining frames from this latent + display(frame) + frame = streaming.decode() + """ + super().__init__() + self.taehv = taehv + self.reset() + + def reset(self): + """Reset all internal state. Call this to start encoding/decoding a new stream.""" + self.encoder_work_queue, self.encoder_memory = [], [None] * len(self.taehv.encoder) + self.decoder_work_queue, self.decoder_memory = [], [None] * len(self.taehv.decoder) + self.n_frames_encoded, self.n_frames_decoded = 0, 0 + self._last_encoder_input_frame = None + + def encode(self, x=None): + """Feed an input frame (optional) and try to produce an encoder output. + + The encoder accumulates t_downscale input frames before producing one latent, + so most calls will return None. Use flush_encoder() at end-of-stream to pad and + drain any remaining latents. + + Args: + x: NTCHW RGB frame tensor with values in [0, 1], or None to just process pending work. + Returns: N1CHW latent tensor, or None if not enough input has been accumulated. + """ + if x is not None: + assert x.ndim == 5 and x.shape[2] == self.taehv.image_channels, f"Expected NTCHW frames but got {x.shape=}" + self._last_encoder_input_frame = x[:, -1:] + x = self.taehv.preprocess_input_frames(x) + self.encoder_work_queue.extend(TWorkItem(xt, 0) for xt in x.unbind(1)) + self.n_frames_encoded += x.shape[1] + xt = apply_model_with_memblocks_sequential_single_step( + self.taehv.encoder, self.encoder_memory, self.encoder_work_queue) + return xt + + def decode(self, x=None): + """Feed a latent (optional) and try to produce a decoded frame. + + Each latent produces t_upscale output frames due to temporal upscaling. The first + decode(latent) call returns the first of these frames; call decode() with no argument + to retrieve the rest, one at a time. Each call does the minimum decoder work needed to + produce one frame. + + Startup frames (the first frames_to_trim raw decoder outputs, used for causal alignment + with the reference VAE) are consumed internally and never returned. + + Args: + x: NTCHW latent tensor, or None to retrieve the next pending frame. + Returns: N1CHW decoded RGB frame tensor, or None if the queue needs more input. + """ + if x is not None: + assert x.ndim == 5 and x.shape[2] == self.taehv.latent_channels, f"Expected NTCHW latents but got {x.shape=}" + self.decoder_work_queue.extend(TWorkItem(xt, 0) for xt in x.unbind(1)) + while True: + xt = apply_model_with_memblocks_sequential_single_step( + self.taehv.decoder, self.decoder_memory, self.decoder_work_queue) + if xt is None: + return None + self.n_frames_decoded += 1 + # skip startup frames (to match decode_video trim behavior) + if not self.taehv.is_cogvideox and self.n_frames_decoded <= self.taehv.frames_to_trim: + continue + return self.taehv.postprocess_output_frames(xt) + + def flush_encoder(self): + """Pad (if needed) and drain all remaining latents from the encoder. + + Returns list of N1CHW latent tensors. + """ + latents = [] + if self._last_encoder_input_frame is not None and self.n_frames_encoded % self.taehv.t_downscale != 0: + n_pad = self.taehv.t_downscale - self.n_frames_encoded % self.taehv.t_downscale + for _ in range(n_pad): + lat = self.encode(self._last_encoder_input_frame) + if lat is not None: + latents.append(lat) + while (lat := self.encode()) is not None: + latents.append(lat) + return latents + + def flush_decoder(self): + """Drain all remaining decoded frames from the decoder. + + Returns list of N1CHW decoded RGB frame tensors. + """ + frames = [] + while (frame := self.decode()) is not None: + frames.append(frame) + return frames + + def flush(self): + """Flush encoder (with padding) and decoder, returning all remaining decoded frames. + + Returns list of N1CHW decoded RGB frame tensors. + """ + frames = [] + for latent in self.flush_encoder(): + frame = self.decode(latent) + if frame is not None: + frames.append(frame) + frames.extend(self.flush_decoder()) + return frames + +@torch.no_grad() +def main(): + """Run TAEHV roundtrip reconstruction on the given video paths.""" + import os + import sys + import cv2 # no highly esteemed deed is commemorated here + + class VideoTensorReader: + def __init__(self, video_file_path): + self.cap = cv2.VideoCapture(video_file_path) + assert self.cap.isOpened(), f"Could not load {video_file_path}" + self.fps = self.cap.get(cv2.CAP_PROP_FPS) + def __iter__(self): + return self + def __next__(self): + ret, frame = self.cap.read() + if not ret: + self.cap.release() + raise StopIteration # End of video or error + return torch.from_numpy(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)).permute(2, 0, 1) # BGR HWC -> RGB CHW + + class VideoTensorWriter: + def __init__(self, video_file_path, width_height, fps=30): + self.writer = cv2.VideoWriter(video_file_path, cv2.VideoWriter_fourcc(*'mp4v'), fps, width_height) + assert self.writer.isOpened(), f"Could not create writer for {video_file_path}" + def write(self, frame_tensor): + assert frame_tensor.ndim == 3 and frame_tensor.shape[0] == 3, f"{frame_tensor.shape}??" + self.writer.write(cv2.cvtColor(frame_tensor.permute(1, 2, 0).numpy(), cv2.COLOR_RGB2BGR)) # RGB CHW -> BGR HWC + def __del__(self): + if hasattr(self, 'writer'): self.writer.release() + + dev = torch.device("cuda" if torch.cuda.is_available() else "mps" if torch.backends.mps.is_available() else "cpu") + dtype = torch.float16 + checkpoint_path = os.getenv("TAEHV_CHECKPOINT_PATH", "taehv.pth") + checkpoint_name = os.path.splitext(os.path.basename(checkpoint_path))[0] + print(f"Using device \033[31m{dev}\033[0m, dtype \033[32m{dtype}\033[0m, checkpoint \033[34m{checkpoint_name}\033[0m ({checkpoint_path})") + taehv = TAEHV(checkpoint_path=checkpoint_path).to(dev, dtype) + for video_path in sys.argv[1:]: + print(f"Processing {video_path}...") + video_in = VideoTensorReader(video_path) + video = torch.stack(list(video_in), 0)[None] + vid_dev = video.to(dev, dtype).div_(255.0) + # convert to device tensor + if video.numel() < 100_000_000: + print(f" {video_path} seems small enough, will process all frames in parallel") + # convert to device tensor + vid_enc = taehv.encode_video(vid_dev) + print(f" Encoded {video_path} -> {vid_enc.shape}. Decoding...") + vid_dec = taehv.decode_video(vid_enc) + print(f" Decoded {video_path} -> {vid_dec.shape}") + else: + print(f" {video_path} seems large, will process each frame sequentially") + # convert to device tensor + vid_enc = taehv.encode_video(vid_dev, parallel=False) + print(f" Encoded {video_path} -> {vid_enc.shape}. Decoding...") + vid_dec = taehv.decode_video(vid_enc, parallel=False) + print(f" Decoded {video_path} -> {vid_dec.shape}") + video_out_path = video_path + f".reconstructed_by_{checkpoint_name}.mp4" + video_out = VideoTensorWriter(video_out_path, (vid_dec.shape[-1], vid_dec.shape[-2]), fps=int(round(video_in.fps))) + for frame in vid_dec.clamp_(0, 1).mul_(255).round_().byte().cpu()[0]: + video_out.write(frame) + print(f" Saved to {video_out_path}") + +if __name__ == "__main__": + main() diff --git a/local/tiny_vae.py b/local/tiny_vae.py new file mode 100644 index 0000000..be98aed --- /dev/null +++ b/local/tiny_vae.py @@ -0,0 +1,136 @@ +"""Tiny-VAE decode for both local Wan engines: TAEHV (madebyollin, MIT) on torch-MPS in place of the +official Wan VAE decoder. TAEHV is a ~22MB conv net that decodes the same latents in seconds at +near-official quality. + +Two engines, two VAEs, two checkpoints: + * 14B (Wan 2.2 A14B still uses the 2.1 VAE) — 16 channels, patch 1. Official decode is ~62s of a + ~293s clip; taew2_1 takes it to ~3s. + * 5B (Wan 2.2 TI2V) — 48 channels, patch 2 (x16 spatial). The official decode DOMINATES that path + (134.3s of a 165.7s clip), which is why the engine was retired; taew2_2 is the whole point of + bringing it back. + +Weights: models/taew2_{1,2}.safetensors (HF lightx2v/Autoencoders, Apache-2.0 — safetensors, no pickle). +Code: taehv_upstream.py (audited copy of github.com/madebyollin/taehv taehv.py). + +Frame math matches the official decoder exactly for BOTH: t_upscale 4, frames_to_trim 3, so +latent T -> 4T raw frames -> trim 3 -> 4T-3 (official: 4*(T-1)+1 = 4T-3). Downstream frame budgeting +is unchanged either way. + +patch() monkeypatches mlx_video.models.wan_2.generate.load_vae_decoder, dispatching on config.vae_z_dim; +an unknown z dim (or a missing checkpoint) falls through to the original loader. +""" +import os + +_HERE = os.path.dirname(os.path.abspath(__file__)) + +# vae_z_dim -> (checkpoint, TAEHV shape). The shape args are passed explicitly rather than relying on +# TAEHV's filename sniffing, because that path also torch.loads the checkpoint (we load safetensors). +SPECS = { + 16: ("taew2_1.safetensors", {"patch_size": 1, "latent_channels": 16}), + 48: ("taew2_2.safetensors", {"patch_size": 2, "latent_channels": 48}), +} + + +def weights_path(z_dim: int) -> str: + return os.path.join(_HERE, "models", SPECS[z_dim][0]) + + +_DECODERS: dict = {} # z_dim -> decoder (the sidecar runs i2v in one resident process) + + +class _TaehvDecoder: + """Duck-types the slice of the mlx-video VAE decoder API generate_video uses: decode / decode_tiled.""" + + def __init__(self, z_dim: int): + import torch + from safetensors.torch import load_file + from taehv_upstream import TAEHV + + self._torch = torch + self._z_dim = z_dim + self._dev = "mps" if torch.backends.mps.is_available() else "cpu" + # checkpoint_path=None skips TAEHV's own torch.load; the shape comes from SPECS and the + # weights load from safetensors, so no pickle is ever touched. + model = TAEHV(checkpoint_path=None, **SPECS[z_dim][1]) + model.load_state_dict(model.patch_tgrow_layers(load_file(weights_path(z_dim)))) + self._model = model.to(self._dev, torch.float16).eval() + + def _decode_ntchw(self, zt): + """torch NTCHW latents -> torch NTCHW RGB in [-1, 1].""" + with self._torch.no_grad(): + rgb = self._model.decode_video(zt, parallel=True, show_progress_bar=False) # [N,T',3,H,W] in [0,1] + return rgb.float().mul_(2.0).sub_(1.0) + + # ── 16-channel (Wan 2.1 VAE / 14B): channels-first, raw sampler latents ──────────────────────── + def decode(self, z): + """z: mx.array [1, C, T, h, w] (raw sampler latents) -> mx.array [1, 3, T', H, W] in [-1, 1] + (generate_video then does (x+1)/2*255, same as with the official decoder).""" + import mlx.core as mx + import numpy as np + + zn = np.asarray(z.astype(mx.float32)) # [1, C, T, h, w] + zt = self._torch.from_numpy(zn).to(self._dev, self._torch.float16).permute(0, 2, 1, 3, 4) # NTCHW + out = self._decode_ntchw(zt).permute(0, 2, 1, 3, 4).cpu().numpy() # [1, 3, T', H, W] + return mx.array(out) + + # ── 48-channel (Wan 2.2 VAE / 5B): channels-LAST, and the caller has already denormalized ────── + def __call__(self, z): + """z: mx.array [1, T, h, w, C], already through vae22.denormalize_latents (that is what the + official Wan2.2 decoder wants). TAEHV wants ~Gaussian latents, so undo it exactly, then return + [1, T', H, W, 3] in [-1, 1] — the layout the caller's Wan2.2 branch expects.""" + import mlx.core as mx + import numpy as np + from mlx_video.models.wan_2.vae22 import VAE22_MEAN, VAE22_STD + + z = (z - VAE22_MEAN.reshape(1, 1, 1, 1, -1)) / VAE22_STD.reshape(1, 1, 1, 1, -1) + zn = np.asarray(z.astype(mx.float32)) # [1, T, h, w, C] + zt = self._torch.from_numpy(zn).to(self._dev, self._torch.float16).permute(0, 1, 4, 2, 3) # NTCHW + out = self._decode_ntchw(zt).permute(0, 1, 3, 4, 2).cpu().numpy() # [1, T', H, W, 3] + return mx.array(out) + + def decode_tiled(self, z, _tiling_config): + # TAEHV's working set is tiny (a 22MB conv net, streamed frame-by-frame) — tiling is pointless. + # Dispatch on z dim because the two Wan VAEs are called with different layouts. + return self(z) if self._z_dim == 48 else self.decode(z) + + +_ORIG_LOAD_VAE_DECODER = None + + +def patch() -> None: + """Replace load_vae_decoder for Wan VAEs we have a TAEHV checkpoint for (idempotent).""" + global _ORIG_LOAD_VAE_DECODER + from mlx_video.models.wan_2 import generate as gen + + if getattr(gen.load_vae_decoder, "_vb_tiny_vae", False): + return + orig = gen.load_vae_decoder + _ORIG_LOAD_VAE_DECODER = orig + + def load_vae_decoder(vae_path, config): + z_dim = getattr(config, "vae_z_dim", 16) + if z_dim in SPECS and os.path.isfile(weights_path(z_dim)): + if z_dim not in _DECODERS: + _DECODERS[z_dim] = _TaehvDecoder(z_dim) + return _DECODERS[z_dim] + return orig(vae_path, config) + + load_vae_decoder._vb_tiny_vae = True + gen.load_vae_decoder = load_vae_decoder + + +def unpatch() -> None: + """Restore the official Wan decoder (idempotent). + + The sidecar is resident, so a patch applied for one request outlives it. Without this, one Fast clip + silently downgraded every later Quality render in the same session: the user paid the full Quality + denoise but the frames still came out of the 22MB TAEHV approximation, which is precisely what the + Quality tier exists to avoid. The decoder choice has to be per-request, not per-process. + """ + from mlx_video.models.wan_2 import generate as gen + + if not getattr(gen.load_vae_decoder, "_vb_tiny_vae", False): + return + if _ORIG_LOAD_VAE_DECODER is not None: + gen.load_vae_decoder = _ORIG_LOAD_VAE_DECODER + _DECODERS.clear() diff --git a/local/upscale.py b/local/upscale.py new file mode 100644 index 0000000..97d6541 --- /dev/null +++ b/local/upscale.py @@ -0,0 +1,98 @@ +"""Video upscale via Real-ESRGAN (ncnn/Vulkan on the Apple GPU through MoltenVK). + +The diffusion models emit 832x480 / 896x512 — watched fullscreen that reads soft no matter how good the +denoise was. This upscales every frame with a GAN (validated on this Mac in smoke_5b_esrgan.py), then +scales to the target height (default 1080) with lanczos in the final encode. Frame-by-frame + streaming +directories, so memory stays flat regardless of video length. + +run_upscale(req): {video, out, [target_h=1080], [model=0], [gpuid]} -> {ok, frames, width, height} +Model indices (realesrgan_ncnn_py bundled weights): + 0 = realesr-animevideov3-x2 (default — video-tuned, least flicker, fast) + 1/2 = animevideov3 x3/x4, 3 = realesrgan-x4plus-anime, 4 = realesrgan-x4plus (photo GAN, more flicker) +""" +import os +import shutil +import subprocess + + +def _ffmpeg() -> str: + return os.environ.get("VB_FFMPEG", "ffmpeg") + + +def _ffprobe() -> str: + p = os.environ.get("VB_FFPROBE", "") + if p: + return p + ff = _ffmpeg() + guess = os.path.join(os.path.dirname(ff), "ffprobe") + return guess if os.path.isfile(guess) else "ffprobe" + + +def run_upscale(req: dict) -> dict: + from PIL import Image + from realesrgan_ncnn_py import Realesrgan + + video = req["video"] + out = req["out"] + target_h = int(req.get("target_h", 1080)) + model = int(req.get("model", os.environ.get("VB_UPSCALE_MODEL", "0"))) + gpuid = int(req.get("gpuid", 0)) + + work = video + "_upscale" + # Stale frames from a failed/killed previous run would get appended to a retry's image sequence by + # ffmpeg's image2 demuxer — always start clean. + shutil.rmtree(work, ignore_errors=True) + fin = os.path.join(work, "in") + fout = os.path.join(work, "out") + os.makedirs(fin, exist_ok=True) + os.makedirs(fout, exist_ok=True) + + # probe the source fps so the upscaled video keeps the exact same timing + p = subprocess.run( + [_ffprobe(), "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=r_frame_rate", + "-of", "default=nw=1:nk=1", video], + capture_output=True, text=True, + ) + rate = (p.stdout or "").strip() or "24/1" + try: + num, den = rate.split("/") + fps = float(num) / float(den) # ffprobe can report '0/0' (unknown) → ZeroDivisionError → fallback + except (ValueError, ZeroDivisionError): + fps = 24.0 + if not (0 < fps < 1000): + fps = 24.0 + + subprocess.run([_ffmpeg(), "-y", "-loglevel", "error", "-i", video, os.path.join(fin, "f_%06d.png")], check=True) + frames = sorted(f for f in os.listdir(fin) if f.endswith(".png")) + if not frames: + return {"ok": False, "error": "no frames extracted"} + + # No caching: the sidecar runs each job in a fresh subprocess (MoltenVK isolation), nothing persists. + up = Realesrgan(gpuid=gpuid, model=model) + + w = h = 0 + for f in frames: + img = Image.open(os.path.join(fin, f)).convert("RGB") + big = up.process_pil(img) + w, h = big.size + big.save(os.path.join(fout, f)) + os.remove(os.path.join(fin, f)) # keep the working set one frame deep on disk + + # even width for yuv420p at the target height + subprocess.run([ + _ffmpeg(), "-y", "-loglevel", "error", "-framerate", f"{fps:.6f}", + "-i", os.path.join(fout, "f_%06d.png"), + "-vf", f"scale=-2:{target_h}:flags=lanczos", + "-c:v", "libx264", "-crf", "14", "-preset", "medium", "-pix_fmt", "yuv420p", out, + ], check=True) + ok = os.path.exists(out) and os.path.getsize(out) > 0 + shutil.rmtree(work, ignore_errors=True) + return {"ok": ok, "frames": len(frames), "width": w, "height": h, "fps": fps, "target_h": target_h} + + +if __name__ == "__main__": + # Subprocess entrypoint for the sidecar (see server._run_isolated): request JSON on stdin, result JSON + # as the last stdout line. Keeps this Vulkan wrapper out of the sidecar process (MoltenVK clash). + import json + import sys + print(json.dumps(run_upscale(json.load(sys.stdin))), flush=True) diff --git a/local/vlm.py b/local/vlm.py new file mode 100644 index 0000000..82b420c --- /dev/null +++ b/local/vlm.py @@ -0,0 +1,30 @@ +"""Local VLM stage (portrait captioning), MLX-native via mlx-vlm. + +Same job as the cloud VLM: one concise description of a person for consistent re-generation. gemma-3-12b +can be literally the same model as the cloud default. Single-resident via the ModelManager. +""" +from manager import get + + +def _load(repo: str): + import mlx_vlm + from mlx_vlm.utils import load_config + model, processor = mlx_vlm.load(repo) + config = load_config(repo) + return (model, processor, config) + + +def run_vlm(req: dict) -> dict: + import mlx_vlm + from mlx_vlm.prompt_utils import apply_chat_template + + repo = req.get("model", "mlx-community/gemma-3-12b-it-4bit") + image = req["image"] + prompt = req.get("prompt", "Describe this image.") + max_tokens = int(req.get("max_tokens", 200)) + + model, processor, config = get("vlm:" + repo, lambda: _load(repo)) + formatted = apply_chat_template(processor, config, prompt, num_images=1) + out = mlx_vlm.generate(model, processor, formatted, image=image, max_tokens=max_tokens, verbose=False) + text = getattr(out, "text", None) or str(out) + return {"ok": True, "text": text.strip()} diff --git a/local/wan_i2v.py b/local/wan_i2v.py new file mode 100644 index 0000000..6512e53 --- /dev/null +++ b/local/wan_i2v.py @@ -0,0 +1,259 @@ +"""Wan 2.2 I2V-A14B image-to-video, MLX-native (Apple Silicon). + +Thin wrapper over Blaizzy/mlx-video's `generate_video` (the dual-model Wan2.2 +pipeline). One job at a time — the server serialises calls so only one diffusion +run touches the GPU/unified-memory at once. + +NOTE (Phase 2): upstream `generate_video` loads T5 + both transformers + VAE on +every call and frees them at the end, so weights are NOT cached across requests. +The server process staying warm + the OS page cache keep the model files hot, but +a true weight-resident loop would mean vendoring the denoise loop. Left as a +follow-up — correctness first. +""" +import functools +import os + +# Resident Wan weights: mlx-video's generate_video reloads T5 + both 14B transformers + VAE from disk on +# every call. We memoize the heavy loaders (the transformers + VAE — NOT T5, which generate_video frees +# before denoise to save memory) so clips 2..N reuse the in-memory weights instead of re-reading ~16GB. +# The cache lives outside the ModelManager; a manager unload-hook drops it when a keyframe/LLM model loads. +_RESIDENT_WAN: dict = {} +_PATCHED = False +_WIRED_SET = False + + +def _free_resident_wan() -> None: + if not _RESIDENT_WAN: + return + _RESIDENT_WAN.clear() + import gc + import mlx.core as mx + gc.collect() + try: + mx.clear_cache() + except Exception: # noqa: BLE001 + pass + + +def _memoize(mod, name: str) -> None: + orig = getattr(mod, name) + if getattr(orig, "_vb_memoized", False): + return + + @functools.wraps(orig) + def wrapper(*args, **kwargs): + path = str(args[0]) if args else "" + key = (name, path, repr(kwargs.get("loras"))) + if key not in _RESIDENT_WAN: + _RESIDENT_WAN[key] = orig(*args, **kwargs) + return _RESIDENT_WAN[key] + + wrapper._vb_memoized = True + setattr(mod, name, wrapper) + + +def _ensure_resident() -> None: + """Patch generate_video's heavy loaders to memoize by path (+ loras), and register a hook so the cache + is freed when another heavy model (keyframe/LLM) loads via the ModelManager.""" + global _PATCHED + if _PATCHED: + return + from mlx_video.models.wan_2 import generate as gen + for name in ("load_wan_model", "load_vae_decoder", "load_vae_encoder"): + _memoize(gen, name) + _keep_compiled(gen) + try: + from manager import register_unload_hook + register_unload_hook(_free_resident_wan) + except Exception: # noqa: BLE001 + pass + _PATCHED = True + + +def _keep_compiled(gen) -> None: + """generate_video re-wraps each transformer with `m._compiled = mx.compile(m)` on EVERY call, throwing + away the previous wrapper's traced graphs — so even with resident weights each clip re-traces the + forward (the main reason resident mode measured net-negative). Patch the module's mx.compile so an + object that already carries a `_compiled` wrapper keeps it; anything else compiles as usual.""" + mx_mod = gen.mx + orig = mx_mod.compile + if getattr(orig, "_vb_keep_compiled", False): + return + + def compile_keep(fn, *args, **kwargs): + existing = getattr(fn, "_compiled", None) + if existing is not None: + return existing + return orig(fn, *args, **kwargs) + + compile_keep._vb_keep_compiled = True + mx_mod.compile = compile_keep + + +def _set_wired_limit() -> None: + # Opt-in (VB_LOCAL_WIRED_GB): pin weights as wired so macOS doesn't compress/page the resident model. + global _WIRED_SET + if _WIRED_SET: + return + _WIRED_SET = True + gb = os.environ.get("VB_LOCAL_WIRED_GB", "") + if not gb: + return + try: + import mlx.core as mx + mx.set_wired_limit(int(float(gb) * 1024 ** 3)) + except Exception: # noqa: BLE001 + pass + + +def _snap_4n1(n: int) -> int: + """Wan requires num_frames = 4n+1 (5, 9, 13, ... 81).""" + n = max(5, int(n)) + return n - ((n - 1) % 4) + + +def _model_config(model_dir: str) -> dict: + import json + try: + with open(os.path.join(model_dir, "config.json")) as f: + return json.load(f) + except Exception: # noqa: BLE001 + return {} + + +def _wants_relay(model_dir: str) -> bool: + """True for a DUAL model stored unquantized (bf16): both experts resident + would be ~54GB, so it only fits 48GB via relay-shedding (one expert at a + time, swapped at the timestep boundary). Quantized dirs (config carries a + "quantization" key) keep the stock mlx-video path untouched.""" + c = _model_config(model_dir) + return bool(c.get("dual_model")) and "quantization" not in c + + +def run_i2v(req: dict) -> dict: + # Imported lazily so the server can answer /health before mlx-video is ready. + use_relay = _wants_relay(req["model_dir"]) + # FastWan DMD distill (draft tier): exact trained step list + renoise + # sampler, CFG off, euler. Marked by "fastwan_dmd" in the model config. + fastwan_spec = _model_config(req["model_dir"]).get("fastwan_dmd") + if fastwan_spec: + import fastwan_dmd + dmd_steps = fastwan_dmd.patch(fastwan_spec) + req = dict(req, steps=dmd_steps, guide_scale="1", scheduler="euler") + req.setdefault("tiling", "aggressive") + # Route bf16 dual (relay) AND FastWan through the vendored fork + # (local/relay_generate.py): bit-identical math, only expert residency + # differs, and it carries the first+last morph (end_image) support that the + # stock module lacks. FastWan (single model) runs it in parallel mode. + via_relay = bool(use_relay or fastwan_spec) + if via_relay: + from relay_generate import generate_video + else: + from mlx_video.models.wan_2.generate import generate_video + + # Resident Wan weights across clips: OFF by default and UNUSABLE on 48GB — verified twice (2026-07-02, + # even with compile-keep + tiny-VAE): clip 1 completes, then clip 2's 11GB bf16 T5 load on top of the + # ~16GB resident transformers gets the process memory-killed by the kernel. Opt in + # (VB_LOCAL_WAN_RESIDENT=1) only on 64/128GB Macs. On 48GB the reload savings come from the SMALL + # components instead: tiny-VAE decode (done) + a resident int8 T5 (mlx-umt5, ~6.3GB — B3). + if int(req.get("resident", os.environ.get("VB_LOCAL_WAN_RESIDENT", "0"))) and not use_relay: + _ensure_resident() + _set_wired_limit() + + # Tiny-VAE decode (TAEHV on torch-MPS): official decode -> seconds, near-official quality. Covers the + # 16ch (14B/2.1) VAE via taew2_1 and the 48ch (5B/2.2) VAE via taew2_2 — see tiny_vae.py. + # Both branches run on EVERY request: the sidecar is resident, so leaving a previous request's choice + # in place silently decided this one. A Fast clip used to pin the tiny decoder for the life of the + # process, so a later Quality render paid the full denoise and still got TAEHV frames. + import tiny_vae + if int(req.get("tiny_vae", os.environ.get("VB_LOCAL_TINY_VAE", "0"))): + tiny_vae.patch() + else: + tiny_vae.unpatch() + if via_relay: + # tiny_vae patches the STOCK module's loader; mirror it onto the relay fork so the shim + # applies there too. This must follow `via_relay`, not `use_relay`: FastWan (5B) also runs + # through the fork, and it is the path where the official decode actually dominates. + # Mirrored unconditionally, so unpatching propagates to the fork as well. + import relay_generate + from mlx_video.models.wan_2 import generate as _stock_gen + relay_generate.load_vae_decoder = _stock_gen.load_vae_decoder + + model_dir = req["model_dir"] + image = req["image"] + prompt = req["prompt"] + out = req["out"] + + fps = int(req.get("fps", 16)) # Wan2.2 native ~16fps (frame budgeting only) + seconds = float(req.get("seconds", 5)) + min_frames = _snap_4n1(int(req.get("min_frames", 21))) + max_frames = _snap_4n1(int(req.get("max_frames", 81))) + num_frames = req.get("num_frames") or round(seconds * fps) + num_frames = max(min_frames, min(max_frames, _snap_4n1(num_frames))) + + width = int(req.get("width", 1280)) + height = int(req.get("height", 704)) + seed = int(req.get("seed", -1)) + + # None -> use the model config defaults (I2V: 40 steps, guide 3.5/3.5, shift 5.0, + # official Chinese negative prompt). Pass-throughs let the app override for speed. + steps = req.get("steps") + guide_scale = req.get("guide_scale") # e.g. "3.5,3.5" + shift = req.get("shift") + negative_prompt = req.get("negative_prompt") # None = config default + + # Wan2.2-Lightning 4-step distilled LoRA (high/low noise). When present this is the "fast but keeps + # quality" path: 4 steps + CFG off (guide=1) ≈ 20x fewer 14B transformer passes than 40-step CFG. + # Motion is decided by the HIGH-noise expert and full-strength Lightning flattens it (the known + # slow-motion complaint, HF lightx2v discussions #5/#20) — a reduced strength there restores motion + # amplitude while the low-noise expert keeps full distillation for detail. + strength = float(req.get("lora_strength", 1.0)) + s_high = float(req.get("lora_strength_high", strength)) + s_low = float(req.get("lora_strength_low", strength)) + loras_high = [(req["lora_high"], s_high)] if req.get("lora_high") else None + loras_low = [(req["lora_low"], s_low)] if req.get("lora_low") else None + if loras_high or loras_low: + if steps is None: + steps = 4 + if guide_scale is None: + guide_scale = "1" # CFG off (skips the uncond pass → 2x faster per step) + + # First+last morph: when the app supplies a target keyframe (the NEXT + # scene's first frame), the clip interpolates image -> end_image. Only the + # vendored relay fork handles it (dual channel-concat + 5B mask-blend); the + # stock module ignores it, so end_image is honored only on relay/FastWan. + end_image = req.get("end_image") + + os.makedirs(os.path.dirname(out) or ".", exist_ok=True) + _gen_kwargs = {} + if end_image and (use_relay or fastwan_spec): + _gen_kwargs["end_image"] = end_image + + generate_video( + model_dir=model_dir, + prompt=prompt, + image=image, + width=width, + height=height, + num_frames=num_frames, + steps=(int(steps) if steps else None), + guide_scale=guide_scale, + shift=(float(shift) if shift else None), + seed=seed, + output_path=out, + negative_prompt=negative_prompt, + scheduler=req.get("scheduler", "unipc"), + # bf16-relay runs closer to the 48GB ceiling than Q4 — "aggressive" + # VAE tiling is the measured-safe default there (81f probes at 36.8GB); + # quantized paths keep the historical "auto". + tiling=req.get("tiling", "aggressive" if use_relay else "auto"), + # trim_first_frames is a T2V-only first-frame fix; it desyncs the I2V conditioning tensor (y is + # built from num_frames, latents from num_frames+trim*4) → keep 0 for i2v. The first frame here is + # the input image anyway. + trim_first_frames=int(req.get("trim_first_frames", 0)), + loras_high=loras_high, + loras_low=loras_low, + **_gen_kwargs, + ) + ok = os.path.exists(out) and os.path.getsize(out) > 0 + return {"ok": ok, "num_frames": num_frames, "width": width, "height": height, "fps": fps, "steps": steps, "lightning": bool(loras_high or loras_low), "morph": bool(_gen_kwargs.get("end_image"))} diff --git a/package-lock.json b/package-lock.json index 9ae16e3..58145d7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,15 +18,15 @@ "react-dom": "^18.3.1" }, "devDependencies": { - "@electron/notarize": "^2.5.0", - "@types/node": "^22.10.0", + "@electron/notarize": "^3.1.1", + "@types/node": "^24.13.3", "@types/react": "^18.3.0", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.0", "autoprefixer": "^10.4.20", "concurrently": "^9.1.0", - "electron": "^33.2.0", - "electron-builder": "^25.1.0", + "electron": "^43.2.0", + "electron-builder": "^26.15.3", "esbuild": "^0.24.0", "postcss": "^8.4.49", "tailwindcss": "^3.4.17", @@ -34,6 +34,9 @@ "typescript": "^5.7.0", "vite": "^6.0.0", "wait-on": "^8.0.0" + }, + "engines": { + "node": ">=24" } }, "node_modules/@alloc/quick-lru": { @@ -346,22 +349,14 @@ "node": ">=6.0.0" } }, - "node_modules/@develar/schema-utils": { - "version": "2.6.5", - "resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz", - "integrity": "sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig==", + "node_modules/@electron-internal/extract-zip": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@electron-internal/extract-zip/-/extract-zip-1.0.5.tgz", + "integrity": "sha512-+bqFCP98pLI0Tt0XQo1TmlXtwjWchISndDOxCkEcIuUgXWpBnLyRI+2DU+mesvnMMX6L1XDqYNA0lXNDHd/yiA==", "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^6.12.0", - "ajv-keywords": "^3.4.1" - }, + "license": "BSD-2-Clause", "engines": { - "node": ">= 8.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" + "node": ">=22.12.0" } }, "node_modules/@electron/asar": { @@ -390,9 +385,9 @@ "license": "MIT" }, "node_modules/@electron/asar/node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "dependencies": { @@ -413,44 +408,22 @@ "node": "*" } }, - "node_modules/@electron/get": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@electron/get/-/get-2.0.3.tgz", - "integrity": "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.1.1", - "env-paths": "^2.2.0", - "fs-extra": "^8.1.0", - "got": "^11.8.5", - "progress": "^2.0.3", - "semver": "^6.2.0", - "sumchecker": "^3.0.1" - }, - "engines": { - "node": ">=12" - }, - "optionalDependencies": { - "global-agent": "^3.0.0" - } - }, - "node_modules/@electron/notarize": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@electron/notarize/-/notarize-2.5.0.tgz", - "integrity": "sha512-jNT8nwH1f9X5GEITXaQ8IF/KdskvIkOFfB2CvwumsveVidzpSc+mvhhTMdAGSYF3O+Nq49lJ7y+ssODRXu06+A==", + "node_modules/@electron/fuses": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@electron/fuses/-/fuses-1.8.0.tgz", + "integrity": "sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw==", "dev": true, "license": "MIT", "dependencies": { - "debug": "^4.1.1", + "chalk": "^4.1.1", "fs-extra": "^9.0.1", - "promise-retry": "^2.0.1" + "minimist": "^1.2.5" }, - "engines": { - "node": ">= 10.0.0" + "bin": { + "electron-fuses": "dist/bin.js" } }, - "node_modules/@electron/notarize/node_modules/fs-extra": { + "node_modules/@electron/fuses/node_modules/fs-extra": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", @@ -466,33 +439,71 @@ "node": ">=10" } }, - "node_modules/@electron/notarize/node_modules/jsonfile": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", - "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "node_modules/@electron/get": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-5.1.0.tgz", + "integrity": "sha512-3kSBtG8ObcTVfXanm5vVJ6UnBLEVmVsRk1M+vGqCuMBV+XLCbJYuWQful+yIy0GQDsSlK0kHEriEHn7SPk4EnA==", "dev": true, "license": "MIT", "dependencies": { - "universalify": "^2.0.0" + "debug": "^4.1.1", + "env-paths": "^3.0.0", + "graceful-fs": "^4.2.11", + "progress": "^2.0.3", + "semver": "^7.6.3", + "sumchecker": "^3.0.1" + }, + "engines": { + "node": ">=22.12.0" }, "optionalDependencies": { - "graceful-fs": "^4.1.6" + "undici": "^7.24.4" } }, - "node_modules/@electron/notarize/node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "node_modules/@electron/get/node_modules/env-paths": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", + "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", "dev": true, "license": "MIT", "engines": { - "node": ">= 10.0.0" + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@electron/get/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@electron/notarize": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@electron/notarize/-/notarize-3.1.1.tgz", + "integrity": "sha512-uQQSlOiJnqRkTL1wlEBAxe90nVN/Fc/hEmk0bqpKk8nKjV1if/tXLHKUPePtv9Xsx90PtZU8aidx5lAiOpjkQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "promise-retry": "^2.0.1" + }, + "engines": { + "node": ">= 22.12.0" } }, "node_modules/@electron/osx-sign": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@electron/osx-sign/-/osx-sign-1.3.1.tgz", - "integrity": "sha512-BAfviURMHpmb1Yb50YbCxnOY0wfwaLXH5KJ4+80zS0gUkzDX3ec23naTlEqKsN+PwYn+a1cCzM7BJ4Wcd3sGzw==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@electron/osx-sign/-/osx-sign-1.3.3.tgz", + "integrity": "sha512-KZ8mhXvWv2rIEgMbWZ4y33bDHyUKMXnx4M0sTyPNK/vcB81ImdeY9Ggdqy0SWbMDgmbqyQ+phgejh6V3R2QuSg==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -511,21 +522,6 @@ "node": ">=12.0.0" } }, - "node_modules/@electron/osx-sign/node_modules/fs-extra": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", - "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/@electron/osx-sign/node_modules/isbinaryfile": { "version": "4.0.10", "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-4.0.10.tgz", @@ -539,117 +535,35 @@ "url": "https://github.com/sponsors/gjtorikian/" } }, - "node_modules/@electron/osx-sign/node_modules/jsonfile": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", - "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/@electron/osx-sign/node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10.0.0" - } - }, "node_modules/@electron/rebuild": { - "version": "3.6.1", - "resolved": "https://registry.npmjs.org/@electron/rebuild/-/rebuild-3.6.1.tgz", - "integrity": "sha512-f6596ZHpEq/YskUd8emYvOUne89ij8mQgjYFA5ru25QwbrRO+t1SImofdDv7kKOuWCmVOuU5tvfkbgGxIl3E/w==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@electron/rebuild/-/rebuild-4.2.0.tgz", + "integrity": "sha512-RKL/O+jGoXJMxrx/5771y1n0xTKmFuOYGO3gMmwypBM6rsH0kou0mswwdXA2JrhIkE4xyC7v9vGk0n6NPzgOxQ==", "dev": true, "license": "MIT", "dependencies": { "@malept/cross-spawn-promise": "^2.0.0", - "chalk": "^4.0.0", "debug": "^4.1.1", - "detect-libc": "^2.0.1", - "fs-extra": "^10.0.0", - "got": "^11.7.0", - "node-abi": "^3.45.0", - "node-api-version": "^0.2.0", - "node-gyp": "^9.0.0", - "ora": "^5.1.0", - "read-binary-file-arch": "^1.0.6", - "semver": "^7.3.5", - "tar": "^6.0.5", - "yargs": "^17.0.1" + "node-abi": "^4.2.0", + "node-api-version": "^0.2.1", + "node-gyp": "^12.2.0", + "read-binary-file-arch": "^1.0.6" }, "bin": { "electron-rebuild": "lib/cli.js" }, "engines": { - "node": ">=12.13.0" - } - }, - "node_modules/@electron/rebuild/node_modules/fs-extra": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", - "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@electron/rebuild/node_modules/jsonfile": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", - "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/@electron/rebuild/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@electron/rebuild/node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10.0.0" + "node": ">=22.12.0" } }, "node_modules/@electron/universal": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@electron/universal/-/universal-2.0.1.tgz", - "integrity": "sha512-fKpv9kg4SPmt+hY7SVBnIYULE9QJl8L3sCfcBsnqbJwwBwAeTLokJ9TRt9y7bK0JAzIW2y78TVVjvnQEms/yyA==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@electron/universal/-/universal-2.0.3.tgz", + "integrity": "sha512-Wn9sPYIVFRFl5HmwMJkARCCf7rqK/EurkfQ/rJZ14mHP3iYTjZSIOSVonEAnhWeAXwtw7zOekGRlc6yTtZ0t+g==", "dev": true, "license": "MIT", "dependencies": { - "@electron/asar": "^3.2.7", + "@electron/asar": "^3.3.1", "@malept/cross-spawn-promise": "^2.0.0", "debug": "^4.3.1", "dir-compare": "^4.2.0", @@ -669,9 +583,9 @@ "license": "MIT" }, "node_modules/@electron/universal/node_modules/brace-expansion": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", - "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", "dev": true, "license": "MIT", "dependencies": { @@ -679,9 +593,9 @@ } }, "node_modules/@electron/universal/node_modules/fs-extra": { - "version": "11.3.5", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.5.tgz", - "integrity": "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==", + "version": "11.4.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.0.tgz", + "integrity": "sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA==", "dev": true, "license": "MIT", "dependencies": { @@ -693,19 +607,6 @@ "node": ">=14.14" } }, - "node_modules/@electron/universal/node_modules/jsonfile": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", - "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, "node_modules/@electron/universal/node_modules/minimatch": { "version": "9.0.9", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", @@ -722,14 +623,43 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@electron/universal/node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "node_modules/@electron/windows-sign": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@electron/windows-sign/-/windows-sign-1.2.2.tgz", + "integrity": "sha512-dfZeox66AvdPtb2lD8OsIIQh12Tp0GNCRUDfBHIKGpbmopZto2/A8nSpYYLoedPIHpqkeblZ/k8OV0Gy7PYuyQ==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "peer": true, + "dependencies": { + "cross-dirname": "^0.1.0", + "debug": "^4.3.4", + "fs-extra": "^11.1.1", + "minimist": "^1.2.8", + "postject": "^1.0.0-alpha.6" + }, + "bin": { + "electron-windows-sign": "bin/electron-windows-sign.js" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/@electron/windows-sign/node_modules/fs-extra": { + "version": "11.4.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.0.tgz", + "integrity": "sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, "engines": { - "node": ">= 10.0.0" + "node": ">=14.14" } }, "node_modules/@esbuild/aix-ppc64": { @@ -1174,13 +1104,6 @@ "node": ">=18" } }, - "node_modules/@gar/promisify": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", - "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==", - "dev": true, - "license": "MIT" - }, "node_modules/@hapi/address": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/@hapi/address/-/address-5.1.1.tgz", @@ -1235,152 +1158,62 @@ "@hapi/hoek": "^11.0.2" } }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", "dev": true, "license": "ISC", "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + "minipass": "^7.0.4" }, "engines": { - "node": ">=12" + "node": ">=18.0.0" } }, - "node_modules/@isaacs/cliui/node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", "dev": true, "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" } }, - "node_modules/@isaacs/cliui/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", "dev": true, "license": "MIT", "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "node": ">=6.0.0" } }, - "node_modules/@isaacs/cliui/node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "dev": true, "license": "MIT" }, - "node_modules/@isaacs/cliui/node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@isaacs/cliui/node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.2.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", "dev": true, "license": "MIT", "dependencies": { @@ -1443,27 +1276,17 @@ "node": ">=10" } }, - "node_modules/@malept/flatpak-bundler/node_modules/jsonfile": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", - "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/@malept/flatpak-bundler/node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", "dev": true, "license": "MIT", "engines": { - "node": ">= 10.0.0" + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" } }, "node_modules/@nodelib/fs.scandir": { @@ -1504,57 +1327,56 @@ "node": ">= 8" } }, - "node_modules/@npmcli/fs": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-2.1.2.tgz", - "integrity": "sha512-yOJKRvohFOaLqipNtwYB9WugyZKhC/DZC4VYPmpaCzDBrA8YpK3qHZ8/HGscMnE4GqbkLNuVcCnxkeQEdGt6LQ==", + "node_modules/@peculiar/asn1-schema": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.8.0.tgz", + "integrity": "sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q==", "dev": true, - "license": "ISC", + "license": "MIT", "dependencies": { - "@gar/promisify": "^1.1.3", - "semver": "^7.3.5" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" } }, - "node_modules/@npmcli/fs/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "node_modules/@peculiar/json-schema": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@peculiar/json-schema/-/json-schema-1.1.12.tgz", + "integrity": "sha512-coUfuoMeIB7B8/NMekxaDzLhaYmp0HZNPEjYRm9goRou8UZIC3z21s0sL9AWoCw4EG876QyO3kYrc61WNF9B/w==", "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" }, "engines": { - "node": ">=10" + "node": ">=8.0.0" } }, - "node_modules/@npmcli/move-file": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@npmcli/move-file/-/move-file-2.0.1.tgz", - "integrity": "sha512-mJd2Z5TjYWq/ttPLLGqArdtnC74J6bOzg4rMDnN+p1xTacZ2yPRCk2y0oSWQtygLR9YVQXgOcONrwtnk3JupxQ==", - "deprecated": "This functionality has been moved to @npmcli/fs", + "node_modules/@peculiar/utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz", + "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==", "dev": true, "license": "MIT", "dependencies": { - "mkdirp": "^1.0.4", - "rimraf": "^3.0.2" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + "tslib": "^2.8.1" } }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "node_modules/@peculiar/webcrypto": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/@peculiar/webcrypto/-/webcrypto-1.7.1.tgz", + "integrity": "sha512-ODOov0sGMJMf3jPonOkgGqPknTsu+DdQ7kD++gz8aI+aFMOMHFbWAA2taqXXVTdP+OTOQR/znGvSpmkeI0WTYQ==", "dev": true, "license": "MIT", - "optional": true, + "dependencies": { + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/json-schema": "^1.1.12", + "@peculiar/utils": "^2.0.2", + "tslib": "^2.8.1", + "webcrypto-core": "^1.9.2" + }, "engines": { - "node": ">=14" + "node": ">=14.18.0" } }, "node_modules/@rolldown/pluginutils": { @@ -2012,16 +1834,6 @@ "react": "^18 || ^19" } }, - "node_modules/@tootallnate/once": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.1.tgz", - "integrity": "sha512-HqmEUIGRJ5fSXchkVgR5F7qn48bDBzv0kWj/Kfu5e6uci4UlEeng4331LnBkWffb++Ei3FOVLxo8JJWMFBDMeQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10" - } - }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -2132,25 +1944,13 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.20.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.0.tgz", - "integrity": "sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "node_modules/@types/plist": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@types/plist/-/plist-3.0.5.tgz", - "integrity": "sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==", + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", "dev": true, "license": "MIT", - "optional": true, "dependencies": { - "@types/node": "*", - "xmlbuilder": ">=11.0.1" + "undici-types": "~7.18.0" } }, "node_modules/@types/prop-types": { @@ -2191,25 +1991,6 @@ "@types/node": "*" } }, - "node_modules/@types/verror": { - "version": "1.10.11", - "resolved": "https://registry.npmjs.org/@types/verror/-/verror-1.10.11.tgz", - "integrity": "sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg==", - "dev": true, - "license": "MIT", - "optional": true - }, - "node_modules/@types/yauzl": { - "version": "2.10.3", - "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", - "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@vitejs/plugin-react": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", @@ -2232,28 +2013,24 @@ } }, "node_modules/@xmldom/xmldom": { - "version": "0.9.10", - "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.10.tgz", - "integrity": "sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==", + "version": "0.8.13", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz", + "integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==", "dev": true, "license": "MIT", "engines": { - "node": ">=14.6" + "node": ">=10.0.0" } }, - "node_modules/7zip-bin": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/7zip-bin/-/7zip-bin-5.2.0.tgz", - "integrity": "sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A==", - "dev": true, - "license": "MIT" - }, "node_modules/abbrev": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", - "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz", + "integrity": "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==", "dev": true, - "license": "ISC" + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } }, "node_modules/agent-base": { "version": "7.1.4", @@ -2265,60 +2042,23 @@ "node": ">= 14" } }, - "node_modules/agentkeepalive": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", - "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "humanize-ms": "^1.2.1" - }, - "engines": { - "node": ">= 8.0.0" - } - }, - "node_modules/aggregate-error": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", - "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", - "dev": true, - "license": "MIT", - "dependencies": { - "clean-stack": "^2.0.0", - "indent-string": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/ajv": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", - "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "dev": true, "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" }, "funding": { "type": "github", "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/ajv-keywords": { - "version": "3.5.2", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", - "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "ajv": "^6.9.1" - } - }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -2366,211 +2106,198 @@ "node": ">= 8" } }, - "node_modules/app-builder-bin": { - "version": "5.0.0-alpha.10", - "resolved": "https://registry.npmjs.org/app-builder-bin/-/app-builder-bin-5.0.0-alpha.10.tgz", - "integrity": "sha512-Ev4jj3D7Bo+O0GPD2NMvJl+PGiBAfS7pUGawntBNpCbxtpncfUixqFj9z9Jme7V7s3LBGqsWZZP54fxBX3JKJw==", - "dev": true, - "license": "MIT" - }, "node_modules/app-builder-lib": { - "version": "25.1.8", - "resolved": "https://registry.npmjs.org/app-builder-lib/-/app-builder-lib-25.1.8.tgz", - "integrity": "sha512-pCqe7dfsQFBABC1jeKZXQWhGcCPF3rPCXDdfqVKjIeWBcXzyC1iOWZdfFhGl+S9MyE/k//DFmC6FzuGAUudNDg==", + "version": "26.15.3", + "resolved": "https://registry.npmjs.org/app-builder-lib/-/app-builder-lib-26.15.3.tgz", + "integrity": "sha512-2VnyWkqsP5v5XbBhL3tD5Syx8iNPBYsoU7kY4S2fz7wg8Rj/nztWKCUzGKaFRTv0Xwf3/H058CR1Kvtd/3lRow==", "dev": true, "license": "MIT", "dependencies": { - "@develar/schema-utils": "~2.6.5", + "@electron/asar": "3.4.1", + "@electron/fuses": "^1.8.0", + "@electron/get": "^3.0.0", "@electron/notarize": "2.5.0", - "@electron/osx-sign": "1.3.1", - "@electron/rebuild": "3.6.1", - "@electron/universal": "2.0.1", + "@electron/osx-sign": "1.3.3", + "@electron/rebuild": "^4.0.4", + "@electron/universal": "2.0.3", "@malept/flatpak-bundler": "^0.4.0", + "@noble/hashes": "^2.2.0", + "@peculiar/webcrypto": "^1.7.1", "@types/fs-extra": "9.0.13", + "ajv": "^8.18.0", + "asn1js": "^3.0.10", "async-exit-hook": "^2.0.1", - "bluebird-lst": "^1.0.9", - "builder-util": "25.1.7", - "builder-util-runtime": "9.2.10", + "builder-util": "26.15.3", + "builder-util-runtime": "9.7.0", "chromium-pickle-js": "^0.2.0", - "config-file-ts": "0.2.8-rc1", + "ci-info": "4.3.1", "debug": "^4.3.4", "dotenv": "^16.4.5", "dotenv-expand": "^11.0.6", "ejs": "^3.1.8", - "electron-publish": "25.1.7", - "form-data": "^4.0.0", + "electron-publish": "26.15.3", "fs-extra": "^10.1.0", "hosted-git-info": "^4.1.0", - "is-ci": "^3.0.0", "isbinaryfile": "^5.0.0", + "jiti": "^2.4.2", "js-yaml": "^4.1.0", "json5": "^2.2.3", "lazy-val": "^1.0.5", - "minimatch": "^10.0.0", + "minimatch": "^10.2.5", + "pkijs": "^3.4.0", + "plist": "3.1.0", + "proper-lockfile": "^4.1.2", "resedit": "^1.7.0", - "sanitize-filename": "^1.6.3", - "semver": "^7.3.8", - "tar": "^6.1.12", - "temp-file": "^3.4.0" + "semver": "~7.7.3", + "tar": "^7.5.7", + "temp-file": "^3.4.0", + "tiny-async-pool": "1.3.0", + "unzipper": "^0.12.3", + "which": "^5.0.0" }, "engines": { "node": ">=14.0.0" }, "peerDependencies": { - "dmg-builder": "25.1.8", - "electron-builder-squirrel-windows": "25.1.8" + "dmg-builder": "26.15.3", + "electron-builder-squirrel-windows": "26.15.3" } }, - "node_modules/app-builder-lib/node_modules/fs-extra": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", - "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "node_modules/app-builder-lib/node_modules/@electron/get": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-3.1.0.tgz", + "integrity": "sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ==", "dev": true, "license": "MIT", "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" + "debug": "^4.1.1", + "env-paths": "^2.2.0", + "fs-extra": "^8.1.0", + "got": "^11.8.5", + "progress": "^2.0.3", + "semver": "^6.2.0", + "sumchecker": "^3.0.1" }, "engines": { - "node": ">=12" + "node": ">=14" + }, + "optionalDependencies": { + "global-agent": "^3.0.0" } }, - "node_modules/app-builder-lib/node_modules/jsonfile": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", - "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "node_modules/app-builder-lib/node_modules/@electron/get/node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", "dev": true, "license": "MIT", "dependencies": { - "universalify": "^2.0.0" + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/app-builder-lib/node_modules/@electron/get/node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "license": "MIT", "optionalDependencies": { "graceful-fs": "^4.1.6" } }, - "node_modules/app-builder-lib/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "node_modules/app-builder-lib/node_modules/@electron/get/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" } }, - "node_modules/app-builder-lib/node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "node_modules/app-builder-lib/node_modules/@electron/get/node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", "dev": true, "license": "MIT", "engines": { - "node": ">= 10.0.0" + "node": ">= 4.0.0" } }, - "node_modules/aproba": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz", - "integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==", - "dev": true, - "license": "ISC" - }, - "node_modules/archiver": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/archiver/-/archiver-5.3.2.tgz", - "integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==", + "node_modules/app-builder-lib/node_modules/@electron/notarize": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@electron/notarize/-/notarize-2.5.0.tgz", + "integrity": "sha512-jNT8nwH1f9X5GEITXaQ8IF/KdskvIkOFfB2CvwumsveVidzpSc+mvhhTMdAGSYF3O+Nq49lJ7y+ssODRXu06+A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "archiver-utils": "^2.1.0", - "async": "^3.2.4", - "buffer-crc32": "^0.2.1", - "readable-stream": "^3.6.0", - "readdir-glob": "^1.1.2", - "tar-stream": "^2.2.0", - "zip-stream": "^4.1.0" + "debug": "^4.1.1", + "fs-extra": "^9.0.1", + "promise-retry": "^2.0.1" }, "engines": { - "node": ">= 10" + "node": ">= 10.0.0" } }, - "node_modules/archiver-utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-2.1.0.tgz", - "integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==", + "node_modules/app-builder-lib/node_modules/@electron/notarize/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "glob": "^7.1.4", + "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", - "lazystream": "^1.0.0", - "lodash.defaults": "^4.2.0", - "lodash.difference": "^4.5.0", - "lodash.flatten": "^4.4.0", - "lodash.isplainobject": "^4.0.6", - "lodash.union": "^4.6.0", - "normalize-path": "^3.0.0", - "readable-stream": "^2.0.0" + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" }, "engines": { - "node": ">= 6" + "node": ">=10" } }, - "node_modules/archiver-utils/node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "node_modules/app-builder-lib/node_modules/ci-info": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.1.tgz", + "integrity": "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], "license": "MIT", - "peer": true, - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" + "engines": { + "node": ">=8" } }, - "node_modules/archiver-utils/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/archiver-utils/node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "node_modules/app-builder-lib/node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", "dev": true, "license": "MIT", - "peer": true, - "dependencies": { - "safe-buffer": "~5.1.0" + "bin": { + "jiti": "lib/jiti-cli.mjs" } }, - "node_modules/are-we-there-yet": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-3.0.1.tgz", - "integrity": "sha512-QZW4EDmGwlYur0Yyf/b2uGucHQMa8aFUP7eu9ddR73vvhFyt4V0Vl3QHPcTNJ8l6qYOBdxgXdnBXQrHilfRQBg==", - "deprecated": "This package is no longer supported.", + "node_modules/app-builder-lib/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "dev": true, "license": "ISC", - "dependencies": { - "delegates": "^1.0.0", - "readable-stream": "^3.6.0" + "bin": { + "semver": "bin/semver.js" }, "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + "node": ">=10" } }, "node_modules/arg": { @@ -2587,26 +2314,19 @@ "dev": true, "license": "Python-2.0" }, - "node_modules/assert-plus": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", - "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=0.8" - } - }, - "node_modules/astral-regex": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", - "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "node_modules/asn1js": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz", + "integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==", "dev": true, - "license": "MIT", - "optional": true, + "license": "BSD-3-Clause", + "dependencies": { + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.5", + "tslib": "^2.8.1" + }, "engines": { - "node": ">=8" + "node": ">=12.0.0" } }, "node_modules/async": { @@ -2680,6 +2400,13 @@ "postcss": "^8.1.0" } }, + "node_modules/aws4": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.13.2.tgz", + "integrity": "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==", + "dev": true, + "license": "MIT" + }, "node_modules/axios": { "version": "1.18.1", "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz", @@ -2777,18 +2504,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/bl": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", - "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "buffer": "^5.5.0", - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" - } - }, "node_modules/bluebird": { "version": "3.7.2", "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", @@ -2796,16 +2511,6 @@ "dev": true, "license": "MIT" }, - "node_modules/bluebird-lst": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/bluebird-lst/-/bluebird-lst-1.0.9.tgz", - "integrity": "sha512-7B1Rtx82hjnSD4PGLAjVWeYH3tHAcVUmChh85a3lltKQm6FresXh9ErQo6oAv6CqxttczC3/kEg8SY5NluPuUw==", - "dev": true, - "license": "MIT", - "dependencies": { - "bluebird": "^3.5.5" - } - }, "node_modules/boolean": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", @@ -2816,16 +2521,16 @@ "optional": true }, "node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/braces": { @@ -2875,41 +2580,6 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, - "node_modules/buffer-crc32": { - "version": "0.2.13", - "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", - "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "*" - } - }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -2917,34 +2587,35 @@ "license": "MIT" }, "node_modules/builder-util": { - "version": "25.1.7", - "resolved": "https://registry.npmjs.org/builder-util/-/builder-util-25.1.7.tgz", - "integrity": "sha512-7jPjzBwEGRbwNcep0gGNpLXG9P94VA3CPAZQCzxkFXiV2GMQKlziMbY//rXPI7WKfhsvGgFXjTcXdBEwgXw9ww==", + "version": "26.15.3", + "resolved": "https://registry.npmjs.org/builder-util/-/builder-util-26.15.3.tgz", + "integrity": "sha512-q2hn7Mbo2nFNkVekPiHFx6Nfo3hURmES3tfBn+k5Pqxl2RkmP3QGqZUhH/q9Pch/4G05NRhPjDlVj1O8q4Txvw==", "dev": true, "license": "MIT", "dependencies": { "@types/debug": "^4.1.6", - "7zip-bin": "~5.2.0", - "app-builder-bin": "5.0.0-alpha.10", - "bluebird-lst": "^1.0.9", - "builder-util-runtime": "9.2.10", + "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", - "cross-spawn": "^7.0.3", + "cross-spawn": "^7.0.6", "debug": "^4.3.4", "fs-extra": "^10.1.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.0", - "is-ci": "^3.0.0", "js-yaml": "^4.1.0", + "sanitize-filename": "^1.6.3", "source-map-support": "^0.5.19", "stat-mode": "^1.0.0", - "temp-file": "^3.4.0" + "temp-file": "^3.4.0", + "tiny-async-pool": "1.3.0" + }, + "engines": { + "node": ">=14.0.0" } }, "node_modules/builder-util-runtime": { - "version": "9.2.10", - "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.2.10.tgz", - "integrity": "sha512-6p/gfG1RJSQeIbz8TK5aPNkoztgY1q5TgmGFMAXcY8itsGW6Y2ld1ALsZ5UJn8rog7hKF3zHx5iQbNQ8uLcRlw==", + "version": "9.7.0", + "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.7.0.tgz", + "integrity": "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw==", "dev": true, "license": "MIT", "dependencies": { @@ -2955,133 +2626,14 @@ "node": ">=12.0.0" } }, - "node_modules/builder-util/node_modules/fs-extra": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", - "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/builder-util/node_modules/jsonfile": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", - "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/builder-util/node_modules/universalify": { + "node_modules/bytestreamjs": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/cacache": { - "version": "16.1.3", - "resolved": "https://registry.npmjs.org/cacache/-/cacache-16.1.3.tgz", - "integrity": "sha512-/+Emcj9DAXxX4cwlLmRI9c166RuL3w30zp4R7Joiv2cQTtTtA+jeuCAjH3ZlGnYS3tKENSrKhAzVVP9GVyzeYQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "@npmcli/fs": "^2.1.0", - "@npmcli/move-file": "^2.0.0", - "chownr": "^2.0.0", - "fs-minipass": "^2.1.0", - "glob": "^8.0.1", - "infer-owner": "^1.0.4", - "lru-cache": "^7.7.1", - "minipass": "^3.1.6", - "minipass-collect": "^1.0.2", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.4", - "mkdirp": "^1.0.4", - "p-map": "^4.0.0", - "promise-inflight": "^1.0.1", - "rimraf": "^3.0.2", - "ssri": "^9.0.0", - "tar": "^6.1.11", - "unique-filename": "^2.0.0" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } - }, - "node_modules/cacache/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/cacache/node_modules/brace-expansion": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", - "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/cacache/node_modules/glob": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", - "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^5.0.1", - "once": "^1.3.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/cacache/node_modules/lru-cache": { - "version": "7.18.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", - "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/cacache/node_modules/minimatch": { - "version": "5.1.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", - "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "resolved": "https://registry.npmjs.org/bytestreamjs/-/bytestreamjs-2.0.1.tgz", + "integrity": "sha512-U1Z/ob71V/bXfVABvNr/Kumf5VyeQRBEm6Txb0PQ6S7V5GpBM3w4Cbqz/xPDicR5tN0uvDifng8C+5qECeGwyQ==", "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, + "license": "BSD-3-Clause", "engines": { - "node": ">=10" + "node": ">=6.0.0" } }, "node_modules/cacheable-lookup": { @@ -3233,13 +2785,13 @@ } }, "node_modules/chownr": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", - "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "engines": { - "node": ">=10" + "node": ">=18" } }, "node_modules/chromium-pickle-js": { @@ -3250,9 +2802,9 @@ "license": "MIT" }, "node_modules/ci-info": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", - "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", "dev": true, "funding": [ { @@ -3265,60 +2817,6 @@ "node": ">=8" } }, - "node_modules/clean-stack": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", - "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/cli-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", - "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", - "dev": true, - "license": "MIT", - "dependencies": { - "restore-cursor": "^3.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/cli-spinners": { - "version": "2.9.2", - "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", - "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-truncate": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-2.1.0.tgz", - "integrity": "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "slice-ansi": "^3.0.0", - "string-width": "^4.2.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -3334,16 +2832,6 @@ "node": ">=12" } }, - "node_modules/clone": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", - "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8" - } - }, "node_modules/clone-response": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz", @@ -3377,16 +2865,6 @@ "dev": true, "license": "MIT" }, - "node_modules/color-support": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", - "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", - "dev": true, - "license": "ISC", - "bin": { - "color-support": "bin.js" - } - }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -3420,23 +2898,6 @@ "node": ">=0.10.0" } }, - "node_modules/compress-commons": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-4.1.2.tgz", - "integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "buffer-crc32": "^0.2.13", - "crc32-stream": "^4.0.2", - "normalize-path": "^3.0.0", - "readable-stream": "^3.6.0" - }, - "engines": { - "node": ">= 10" - } - }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -3484,89 +2945,6 @@ "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" } }, - "node_modules/config-file-ts": { - "version": "0.2.8-rc1", - "resolved": "https://registry.npmjs.org/config-file-ts/-/config-file-ts-0.2.8-rc1.tgz", - "integrity": "sha512-GtNECbVI82bT4RiDIzBSVuTKoSHufnU7Ce7/42bkWZJZFLjmDF2WBpVsvRkhKCfKBnTBb3qZrBwPpFBU/Myvhg==", - "dev": true, - "license": "MIT", - "dependencies": { - "glob": "^10.3.12", - "typescript": "^5.4.3" - } - }, - "node_modules/config-file-ts/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/config-file-ts/node_modules/brace-expansion": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", - "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/config-file-ts/node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/config-file-ts/node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.2" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/config-file-ts/node_modules/minipass": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", - "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/console-control-strings": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", - "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", - "dev": true, - "license": "ISC" - }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -3575,51 +2953,20 @@ "license": "MIT" }, "node_modules/core-util-is": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", - "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", "dev": true, "license": "MIT" }, - "node_modules/crc": { - "version": "3.8.0", - "resolved": "https://registry.npmjs.org/crc/-/crc-3.8.0.tgz", - "integrity": "sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ==", + "node_modules/cross-dirname": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/cross-dirname/-/cross-dirname-0.1.0.tgz", + "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", "dev": true, "license": "MIT", "optional": true, - "dependencies": { - "buffer": "^5.1.0" - } - }, - "node_modules/crc-32": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", - "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", - "dev": true, - "license": "Apache-2.0", - "peer": true, - "bin": { - "crc32": "bin/crc32.njs" - }, - "engines": { - "node": ">=0.8" - } - }, - "node_modules/crc32-stream": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-4.0.3.tgz", - "integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "crc-32": "^1.2.0", - "readable-stream": "^3.4.0" - }, - "engines": { - "node": ">= 10" - } + "peer": true }, "node_modules/cross-spawn": { "version": "7.0.6", @@ -3636,6 +2983,29 @@ "node": ">= 8" } }, + "node_modules/cross-spawn/node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/cross-spawn/node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -3702,19 +3072,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/defaults": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", - "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "clone": "^1.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/defer-to-connect": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", @@ -3773,23 +3130,6 @@ "node": ">=0.4.0" } }, - "node_modules/delegates": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", - "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, "node_modules/detect-node": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", @@ -3824,9 +3164,9 @@ "license": "MIT" }, "node_modules/dir-compare/node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "dependencies": { @@ -3855,86 +3195,16 @@ "license": "MIT" }, "node_modules/dmg-builder": { - "version": "25.1.8", - "resolved": "https://registry.npmjs.org/dmg-builder/-/dmg-builder-25.1.8.tgz", - "integrity": "sha512-NoXo6Liy2heSklTI5OIZbCgXC1RzrDQsZkeEwXhdOro3FT1VBOvbubvscdPnjVuQ4AMwwv61oaH96AbiYg9EnQ==", + "version": "26.15.3", + "resolved": "https://registry.npmjs.org/dmg-builder/-/dmg-builder-26.15.3.tgz", + "integrity": "sha512-O3zJUFUYHJKgzPqioHxfxzBzlSC1eXCSr79gMSBKBP5AgjjpmrydMsMLotEg9fAJF36vdUncb+4ndRNxoPdlSQ==", "dev": true, "license": "MIT", "dependencies": { - "app-builder-lib": "25.1.8", - "builder-util": "25.1.7", - "builder-util-runtime": "9.2.10", + "app-builder-lib": "26.15.3", + "builder-util": "26.15.3", "fs-extra": "^10.1.0", - "iconv-lite": "^0.6.2", "js-yaml": "^4.1.0" - }, - "optionalDependencies": { - "dmg-license": "^1.0.11" - } - }, - "node_modules/dmg-builder/node_modules/fs-extra": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", - "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/dmg-builder/node_modules/jsonfile": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", - "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/dmg-builder/node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/dmg-license": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/dmg-license/-/dmg-license-1.0.11.tgz", - "integrity": "sha512-ZdzmqwKmECOWJpqefloC5OJy1+WZBBse5+MR88z9g9Zn4VY+WYUkAyojmhzJckH5YbbZGcYIuGAkY5/Ys5OM2Q==", - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "dependencies": { - "@types/plist": "^3.0.1", - "@types/verror": "^1.10.3", - "ajv": "^6.10.0", - "crc": "^3.8.0", - "iconv-corefoundation": "^1.1.7", - "plist": "^3.0.4", - "smart-buffer": "^4.0.2", - "verror": "^1.10.0" - }, - "bin": { - "dmg-license": "bin/dmg-license.js" - }, - "engines": { - "node": ">=8" } }, "node_modules/dotenv": { @@ -3981,13 +3251,49 @@ "node": ">= 0.4" } }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "node_modules/duplexer2": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", + "integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "readable-stream": "^2.0.2" + } + }, + "node_modules/duplexer2/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/duplexer2/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "dev": true, "license": "MIT" }, + "node_modules/duplexer2/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/ejs": { "version": "3.1.10", "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", @@ -4005,38 +3311,38 @@ } }, "node_modules/electron": { - "version": "33.4.11", - "resolved": "https://registry.npmjs.org/electron/-/electron-33.4.11.tgz", - "integrity": "sha512-xmdAs5QWRkInC7TpXGNvzo/7exojubk+72jn1oJL7keNeIlw7xNglf8TGtJtkR4rWC5FJq0oXiIXPS9BcK2Irg==", + "version": "43.2.0", + "resolved": "https://registry.npmjs.org/electron/-/electron-43.2.0.tgz", + "integrity": "sha512-80zvrgG7ZRXD+tD0IyLvrnN9n+veSxadMRsMaC9wKKP3iUbtC7rGM8+dVuCmOb0Rrwwv8ESW4awnUZh9Hbp1fA==", "dev": true, - "hasInstallScript": true, "license": "MIT", "dependencies": { - "@electron/get": "^2.0.0", - "@types/node": "^20.9.0", - "extract-zip": "^2.0.1" + "@electron-internal/extract-zip": "^1.0.1", + "@electron/get": "^5.0.0", + "@types/node": "^24.9.0" }, "bin": { - "electron": "cli.js" + "electron": "cli.js", + "install-electron": "install.js" }, "engines": { - "node": ">= 12.20.55" + "node": ">= 22.12.0" } }, "node_modules/electron-builder": { - "version": "25.1.8", - "resolved": "https://registry.npmjs.org/electron-builder/-/electron-builder-25.1.8.tgz", - "integrity": "sha512-poRgAtUHHOnlzZnc9PK4nzG53xh74wj2Jy7jkTrqZ0MWPoHGh1M2+C//hGeYdA+4K8w4yiVCNYoLXF7ySj2Wig==", + "version": "26.15.3", + "resolved": "https://registry.npmjs.org/electron-builder/-/electron-builder-26.15.3.tgz", + "integrity": "sha512-a1KM5heqS3gQCZzizXEI8RjJy3QVogULPdeSknt76uLDpBIW/HDGsMg/XgP0riP6PI9COsRvFITKKGDqA8fJxA==", "dev": true, "license": "MIT", "dependencies": { - "app-builder-lib": "25.1.8", - "builder-util": "25.1.7", - "builder-util-runtime": "9.2.10", + "app-builder-lib": "26.15.3", + "builder-util": "26.15.3", + "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", - "dmg-builder": "25.1.8", + "ci-info": "^4.2.0", + "dmg-builder": "26.15.3", "fs-extra": "^10.1.0", - "is-ci": "^3.0.0", "lazy-val": "^1.0.5", "simple-update-notifier": "2.0.0", "yargs": "^17.6.2" @@ -4050,167 +3356,101 @@ } }, "node_modules/electron-builder-squirrel-windows": { - "version": "25.1.8", - "resolved": "https://registry.npmjs.org/electron-builder-squirrel-windows/-/electron-builder-squirrel-windows-25.1.8.tgz", - "integrity": "sha512-2ntkJ+9+0GFP6nAISiMabKt6eqBB0kX1QqHNWFWAXgi0VULKGisM46luRFpIBiU3u/TDmhZMM8tzvo2Abn3ayg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "app-builder-lib": "25.1.8", - "archiver": "^5.3.1", - "builder-util": "25.1.7", - "fs-extra": "^10.1.0" - } - }, - "node_modules/electron-builder-squirrel-windows/node_modules/fs-extra": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", - "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/electron-builder-squirrel-windows/node_modules/jsonfile": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", - "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/electron-builder-squirrel-windows/node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/electron-builder/node_modules/fs-extra": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", - "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/electron-builder/node_modules/jsonfile": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", - "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/electron-builder/node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "version": "26.15.3", + "resolved": "https://registry.npmjs.org/electron-builder-squirrel-windows/-/electron-builder-squirrel-windows-26.15.3.tgz", + "integrity": "sha512-Jc19XPV9y9+2bAdZPkXuVNGNIEFBq9poHC61l8Kv6FdK7DRG3+Ic0rerC0DXOaeHNz8yW0fg/JnF8GQROOF5MA==", "dev": true, "license": "MIT", - "engines": { - "node": ">= 10.0.0" + "peer": true, + "dependencies": { + "app-builder-lib": "26.15.3", + "builder-util": "26.15.3", + "electron-winstaller": "5.4.0" } }, "node_modules/electron-publish": { - "version": "25.1.7", - "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-25.1.7.tgz", - "integrity": "sha512-+jbTkR9m39eDBMP4gfbqglDd6UvBC7RLh5Y0MhFSsc6UkGHj9Vj9TWobxevHYMMqmoujL11ZLjfPpMX+Pt6YEg==", + "version": "26.15.3", + "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-26.15.3.tgz", + "integrity": "sha512-g/2bn8YTavY4cuS5F+jOS7zmZbXXBV8KZ8yHKfJjFPoKtzBqrpCdNPxBd3tqdBwP7BVd0lGzf7Bk2s0KesWZ4Q==", "dev": true, "license": "MIT", "dependencies": { "@types/fs-extra": "^9.0.11", - "builder-util": "25.1.7", - "builder-util-runtime": "9.2.10", + "aws4": "^1.13.2", + "builder-util": "26.15.3", + "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", + "form-data": "^4.0.5", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "mime": "^2.5.2" } }, - "node_modules/electron-publish/node_modules/fs-extra": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", - "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "node_modules/electron-to-chromium": { + "version": "1.5.378", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.378.tgz", + "integrity": "sha512-VinvOAuuPmdD1guEgGv5f2Qp7/vlfqOrUOMYNnOD4wj3pit8kRsQHzfIf6teyUGWo15Tg5+bOJaRunvyltpVWQ==", "dev": true, + "license": "ISC" + }, + "node_modules/electron-winstaller": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/electron-winstaller/-/electron-winstaller-5.4.0.tgz", + "integrity": "sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==", + "dev": true, + "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" + "@electron/asar": "^3.2.1", + "debug": "^4.1.1", + "fs-extra": "^7.0.1", + "lodash": "^4.17.21", + "temp": "^0.9.0" }, "engines": { - "node": ">=12" + "node": ">=8.0.0" + }, + "optionalDependencies": { + "@electron/windows-sign": "^1.1.2" } }, - "node_modules/electron-publish/node_modules/jsonfile": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", - "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "node_modules/electron-winstaller/node_modules/fs-extra": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", + "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "universalify": "^2.0.0" + "graceful-fs": "^4.1.2", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" + "engines": { + "node": ">=6 <7 || >=8" } }, - "node_modules/electron-publish/node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "node_modules/electron-winstaller/node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", "dev": true, "license": "MIT", - "engines": { - "node": ">= 10.0.0" + "peer": true, + "optionalDependencies": { + "graceful-fs": "^4.1.6" } }, - "node_modules/electron-to-chromium": { - "version": "1.5.378", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.378.tgz", - "integrity": "sha512-VinvOAuuPmdD1guEgGv5f2Qp7/vlfqOrUOMYNnOD4wj3pit8kRsQHzfIf6teyUGWo15Tg5+bOJaRunvyltpVWQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/electron/node_modules/@types/node": { - "version": "20.19.43", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", - "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "node_modules/electron-winstaller/node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", "dev": true, "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" + "peer": true, + "engines": { + "node": ">= 4.0.0" } }, "node_modules/emoji-regex": { @@ -4220,17 +3460,6 @@ "dev": true, "license": "MIT" }, - "node_modules/encoding": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", - "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "iconv-lite": "^0.6.2" - } - }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", @@ -4386,38 +3615,6 @@ "dev": true, "license": "Apache-2.0" }, - "node_modules/extract-zip": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", - "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "debug": "^4.1.1", - "get-stream": "^5.1.0", - "yauzl": "^2.10.0" - }, - "bin": { - "extract-zip": "cli.js" - }, - "engines": { - "node": ">= 10.17.0" - }, - "optionalDependencies": { - "@types/yauzl": "^2.9.1" - } - }, - "node_modules/extsprintf": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.4.1.tgz", - "integrity": "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==", - "dev": true, - "engines": [ - "node >=0.6.0" - ], - "license": "MIT", - "optional": true - }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -4455,12 +3652,22 @@ "node": ">= 6" } }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "node_modules/fast-uri": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", "dev": true, - "license": "MIT" + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" }, "node_modules/fastq": { "version": "1.20.1", @@ -4472,16 +3679,6 @@ "reusify": "^1.0.4" } }, - "node_modules/fd-slicer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", - "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "pend": "~1.2.0" - } - }, "node_modules/ffmpeg-static": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/ffmpeg-static/-/ffmpeg-static-5.3.0.tgz", @@ -4547,9 +3744,9 @@ "license": "MIT" }, "node_modules/filelist/node_modules/brace-expansion": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", - "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", "dev": true, "license": "MIT", "dependencies": { @@ -4603,36 +3800,6 @@ } } }, - "node_modules/foreground-child": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "dev": true, - "license": "ISC", - "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/foreground-child/node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/form-data": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", @@ -4691,40 +3858,19 @@ } } }, - "node_modules/fs-constants": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", - "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", - "dev": true, - "license": "MIT", - "peer": true - }, "node_modules/fs-extra": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", - "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", "dev": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.2.0", - "jsonfile": "^4.0.0", - "universalify": "^0.1.0" - }, - "engines": { - "node": ">=6 <7 || >=8" - } - }, - "node_modules/fs-minipass": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", - "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^3.0.0" + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" }, "engines": { - "node": ">= 8" + "node": ">=12" } }, "node_modules/fs.realpath": { @@ -4759,27 +3905,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/gauge": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/gauge/-/gauge-4.0.4.tgz", - "integrity": "sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg==", - "deprecated": "This package is no longer supported.", - "dev": true, - "license": "ISC", - "dependencies": { - "aproba": "^1.0.3 || ^2.0.0", - "color-support": "^1.1.3", - "console-control-strings": "^1.1.0", - "has-unicode": "^2.0.1", - "signal-exit": "^3.0.7", - "string-width": "^4.2.3", - "strip-ansi": "^6.0.1", - "wide-align": "^1.1.5" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } - }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -4898,9 +4023,9 @@ "license": "MIT" }, "node_modules/glob/node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "dev": true, "license": "MIT", "dependencies": { @@ -5071,13 +4196,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/has-unicode": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", - "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", - "dev": true, - "license": "ISC" - }, "node_modules/hasown": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", @@ -5188,95 +4306,6 @@ "node": ">= 14" } }, - "node_modules/humanize-ms": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", - "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.0.0" - } - }, - "node_modules/iconv-corefoundation": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/iconv-corefoundation/-/iconv-corefoundation-1.1.7.tgz", - "integrity": "sha512-T10qvkw0zz4wnm560lOEg0PovVqUXuOFhhHAkixw8/sycy7TJt7v/RrkEKEQnAw2viPSJu6iAkErxnzR0g8PpQ==", - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "dependencies": { - "cli-truncate": "^2.1.0", - "node-addon-api": "^1.6.3" - }, - "engines": { - "node": "^8.11.2 || >=10" - } - }, - "node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.19" - } - }, - "node_modules/indent-string": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", - "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/infer-owner": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/infer-owner/-/infer-owner-1.0.4.tgz", - "integrity": "sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==", - "dev": true, - "license": "ISC" - }, "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -5295,16 +4324,6 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, - "node_modules/ip-address": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", - "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -5318,19 +4337,6 @@ "node": ">=8" } }, - "node_modules/is-ci": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-3.0.1.tgz", - "integrity": "sha512-ZYvCgrefwqoQ6yTyYUbQu64HsITZ3NfKX1lzaEYdkTDcfKzzCI/wthRRYKkdjHKFVgNiXKAKm65Zo1pk2as/QQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ci-info": "^3.2.0" - }, - "bin": { - "is-ci": "bin.js" - } - }, "node_modules/is-core-module": { "version": "2.16.2", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", @@ -5373,30 +4379,13 @@ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", "dev": true, "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-interactive": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", - "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", - "dev": true, - "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, "engines": { - "node": ">=8" + "node": ">=0.10.0" } }, - "node_modules/is-lambda": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-lambda/-/is-lambda-1.0.1.tgz", - "integrity": "sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==", - "dev": true, - "license": "MIT" - }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -5407,26 +4396,12 @@ "node": ">=0.12.0" } }, - "node_modules/is-unicode-supported": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", - "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/isbinaryfile": { "version": "5.0.7", @@ -5442,26 +4417,13 @@ } }, "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, - "license": "ISC" - }, - "node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", + "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==", "dev": true, "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" + "engines": { + "node": ">=18" } }, "node_modules/jake": { @@ -5518,9 +4480,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", - "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", "dev": true, "funding": [ { @@ -5561,9 +4523,9 @@ "license": "MIT" }, "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "dev": true, "license": "MIT" }, @@ -5589,11 +4551,14 @@ } }, "node_modules/jsonfile": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", - "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", "dev": true, "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, "optionalDependencies": { "graceful-fs": "^4.1.6" } @@ -5615,56 +4580,6 @@ "dev": true, "license": "MIT" }, - "node_modules/lazystream": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", - "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "readable-stream": "^2.0.5" - }, - "engines": { - "node": ">= 0.6.3" - } - }, - "node_modules/lazystream/node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "node_modules/lazystream/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/lazystream/node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "safe-buffer": "~5.1.0" - } - }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -5692,63 +4607,6 @@ "dev": true, "license": "MIT" }, - "node_modules/lodash.defaults": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", - "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/lodash.difference": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz", - "integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/lodash.flatten": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", - "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/lodash.isplainobject": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", - "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/lodash.union": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz", - "integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/log-symbols": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", - "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^4.1.0", - "is-unicode-supported": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -5790,86 +4648,6 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" } }, - "node_modules/make-fetch-happen": { - "version": "10.2.1", - "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-10.2.1.tgz", - "integrity": "sha512-NgOPbRiaQM10DYXvN3/hhGVI2M5MtITFryzBGxHM5p4wnFxsVCbxkrBrDsk+EZ5OB4jEOT7AjDxtdF+KVEFT7w==", - "dev": true, - "license": "ISC", - "dependencies": { - "agentkeepalive": "^4.2.1", - "cacache": "^16.1.0", - "http-cache-semantics": "^4.1.0", - "http-proxy-agent": "^5.0.0", - "https-proxy-agent": "^5.0.0", - "is-lambda": "^1.0.1", - "lru-cache": "^7.7.1", - "minipass": "^3.1.6", - "minipass-collect": "^1.0.2", - "minipass-fetch": "^2.0.3", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.4", - "negotiator": "^0.6.3", - "promise-retry": "^2.0.1", - "socks-proxy-agent": "^7.0.0", - "ssri": "^9.0.0" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } - }, - "node_modules/make-fetch-happen/node_modules/agent-base": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "4" - }, - "engines": { - "node": ">= 6.0.0" - } - }, - "node_modules/make-fetch-happen/node_modules/http-proxy-agent": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", - "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@tootallnate/once": "2", - "agent-base": "6", - "debug": "4" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/make-fetch-happen/node_modules/https-proxy-agent": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", - "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "6", - "debug": "4" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/make-fetch-happen/node_modules/lru-cache": { - "version": "7.18.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", - "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=12" - } - }, "node_modules/matcher": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", @@ -5954,16 +4732,6 @@ "node": ">= 0.6" } }, - "node_modules/mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/mimic-response": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", @@ -5975,13 +4743,13 @@ } }, "node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.5" + "brace-expansion": "^5.0.8" }, "engines": { "node": "18 || 20 || >=22" @@ -6001,127 +4769,40 @@ } }, "node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass-collect": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-1.0.2.tgz", - "integrity": "sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/minipass-fetch": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-2.1.2.tgz", - "integrity": "sha512-LT49Zi2/WMROHYoqGgdlQIZh8mLPZmOrN2NdJjMXxYe4nkN6FUyuPuOAOedNJDrx0IRGg9+4guZewtp8hE6TxA==", - "dev": true, - "license": "MIT", - "dependencies": { - "minipass": "^3.1.6", - "minipass-sized": "^1.0.3", - "minizlib": "^2.1.2" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - }, - "optionalDependencies": { - "encoding": "^0.1.13" - } - }, - "node_modules/minipass-flush": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.7.tgz", - "integrity": "sha512-TbqTz9cUwWyHS2Dy89P3ocAGUGxKjjLuR9z8w4WUTGAVgEj17/4nhgo2Du56i0Fm3Pm30g4iA8Lcqctc76jCzA==", + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", "dev": true, "license": "BlueOak-1.0.0", - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/minipass-pipeline": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", - "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass-sized": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz", - "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^3.0.0" - }, "engines": { - "node": ">=8" + "node": ">=16 || 14 >=14.17" } }, - "node_modules/minipass/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, - "license": "ISC" - }, "node_modules/minizlib": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", - "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", - "dev": true, - "license": "MIT", - "dependencies": { - "minipass": "^3.0.0", - "yallist": "^4.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/minizlib/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", "dev": true, - "license": "ISC" + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } }, "node_modules/mkdirp": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", - "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", "dev": true, "license": "MIT", + "peer": true, + "dependencies": { + "minimist": "^1.2.6" + }, "bin": { "mkdirp": "bin/cmd.js" - }, - "engines": { - "node": ">=10" } }, "node_modules/motion-dom": { @@ -6176,27 +4857,17 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/negotiator": { - "version": "0.6.4", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", - "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/node-abi": { - "version": "3.92.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz", - "integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==", + "version": "4.33.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-4.33.0.tgz", + "integrity": "sha512-vLBWCKb+7LWsX+TbfzWOkw0W81m377tyx3hOweBTjO43CXZnRGS1/JPWs20fr0PgZyDXk6ROYrylsEycK8raDA==", "dev": true, "license": "MIT", "dependencies": { - "semver": "^7.3.5" + "semver": "^7.6.3" }, "engines": { - "node": ">=10" + "node": ">=22.12.0" } }, "node_modules/node-abi/node_modules/semver": { @@ -6212,14 +4883,6 @@ "node": ">=10" } }, - "node_modules/node-addon-api": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-1.7.2.tgz", - "integrity": "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg==", - "dev": true, - "license": "MIT", - "optional": true - }, "node_modules/node-api-version": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/node-api-version/-/node-api-version-0.2.1.tgz", @@ -6244,29 +4907,38 @@ } }, "node_modules/node-gyp": { - "version": "9.4.1", - "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-9.4.1.tgz", - "integrity": "sha512-OQkWKbjQKbGkMf/xqI1jjy3oCTgMKJac58G2+bjZb3fza6gW2YrCSdMQYaoTb70crvE//Gngr4f0AgVHmqHvBQ==", + "version": "12.4.0", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.4.0.tgz", + "integrity": "sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw==", "dev": true, "license": "MIT", "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", - "glob": "^7.1.4", "graceful-fs": "^4.2.6", - "make-fetch-happen": "^10.0.3", - "nopt": "^6.0.0", - "npmlog": "^6.0.0", - "rimraf": "^3.0.2", + "nopt": "^9.0.0", + "proc-log": "^6.0.0", "semver": "^7.3.5", - "tar": "^6.1.2", - "which": "^2.0.2" + "tar": "^7.5.4", + "tinyglobby": "^0.2.12", + "undici": "^6.25.0", + "which": "^6.0.0" }, "bin": { "node-gyp": "bin/node-gyp.js" }, "engines": { - "node": "^12.13 || ^14.13 || >=16" + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/node-gyp/node_modules/isexe": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", + "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=20" } }, "node_modules/node-gyp/node_modules/semver": { @@ -6282,6 +4954,39 @@ "node": ">=10" } }, + "node_modules/node-gyp/node_modules/undici": { + "version": "6.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz", + "integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/node-gyp/node_modules/which": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", + "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^4.0.0" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "dev": true, + "license": "MIT" + }, "node_modules/node-releases": { "version": "2.0.49", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.49.tgz", @@ -6293,19 +4998,19 @@ } }, "node_modules/nopt": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-6.0.0.tgz", - "integrity": "sha512-ZwLpbTgdhuZUnZzjd7nb1ZV+4DoiC6/sfiVKok72ym/4Tlf+DFdlHYmT2JPmcNNWV6Pi3SDf1kT+A4r9RTuT9g==", + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-9.0.0.tgz", + "integrity": "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==", "dev": true, "license": "ISC", "dependencies": { - "abbrev": "^1.0.0" + "abbrev": "^4.0.0" }, "bin": { "nopt": "bin/nopt.js" }, "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/normalize-path": { @@ -6331,23 +5036,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/npmlog": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-6.0.2.tgz", - "integrity": "sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg==", - "deprecated": "This package is no longer supported.", - "dev": true, - "license": "ISC", - "dependencies": { - "are-we-there-yet": "^3.0.0", - "console-control-strings": "^1.1.0", - "gauge": "^4.0.3", - "set-blocking": "^2.0.0" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } - }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -6389,46 +5077,6 @@ "wrappy": "1" } }, - "node_modules/onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "mimic-fn": "^2.1.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ora": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", - "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "bl": "^4.1.0", - "chalk": "^4.1.0", - "cli-cursor": "^3.1.0", - "cli-spinners": "^2.5.0", - "is-interactive": "^1.0.0", - "is-unicode-supported": "^0.1.0", - "log-symbols": "^4.1.0", - "strip-ansi": "^6.0.0", - "wcwidth": "^1.0.1" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/p-cancelable": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", @@ -6455,29 +5103,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/p-map": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", - "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "aggregate-error": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "dev": true, - "license": "BlueOak-1.0.0" - }, "node_modules/parse-cache-control": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parse-cache-control/-/parse-cache-control-1.0.1.tgz", @@ -6510,40 +5135,6 @@ "dev": true, "license": "MIT" }, - "node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, - "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/path-scurry/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/path-scurry/node_modules/minipass": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", - "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, "node_modules/pe-library": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/pe-library/-/pe-library-0.4.1.tgz", @@ -6559,13 +5150,6 @@ "url": "https://github.com/sponsors/jet2jet" } }, - "node_modules/pend": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", - "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", - "dev": true, - "license": "MIT" - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -6606,14 +5190,45 @@ "node": ">= 6" } }, + "node_modules/pkijs": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/pkijs/-/pkijs-3.4.0.tgz", + "integrity": "sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@noble/hashes": "1.4.0", + "asn1js": "^3.0.6", + "bytestreamjs": "^2.0.1", + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.3", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/pkijs/node_modules/@noble/hashes": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.4.0.tgz", + "integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/plist": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.1.tgz", - "integrity": "sha512-ZIfcLJC+7E7FBFnDxm9MPmt7D+DidyQ26lewieO75AdhA2ayMtsJSES0iWzqJQbcVRSrTufQoy0DR94xHue0oA==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz", + "integrity": "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==", "dev": true, "license": "MIT", "dependencies": { - "@xmldom/xmldom": "^0.9.10", + "@xmldom/xmldom": "^0.8.8", "base64-js": "^1.5.1", "xmlbuilder": "^15.1.1" }, @@ -6784,13 +5399,52 @@ "dev": true, "license": "MIT" }, + "node_modules/postject": { + "version": "1.0.0-alpha.6", + "resolved": "https://registry.npmjs.org/postject/-/postject-1.0.0-alpha.6.tgz", + "integrity": "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "commander": "^9.4.0" + }, + "bin": { + "postject": "dist/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/postject/node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": "^12.20.0 || >=14" + } + }, + "node_modules/proc-log": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", + "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/progress": { "version": "2.0.3", @@ -6801,13 +5455,6 @@ "node": ">=0.4.0" } }, - "node_modules/promise-inflight": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", - "integrity": "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==", - "dev": true, - "license": "ISC" - }, "node_modules/promise-retry": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", @@ -6822,6 +5469,18 @@ "node": ">=10" } }, + "node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, "node_modules/proxy-from-env": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", @@ -6843,14 +5502,24 @@ "once": "^1.3.1" } }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "node_modules/pvtsutils": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", + "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/pvutils": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz", + "integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==", "dev": true, "license": "MIT", "engines": { - "node": ">=6" + "node": ">=16.0.0" } }, "node_modules/queue-microtask": { @@ -6959,50 +5628,6 @@ "node": ">= 6" } }, - "node_modules/readdir-glob": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", - "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", - "dev": true, - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "minimatch": "^5.1.0" - } - }, - "node_modules/readdir-glob/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/readdir-glob/node_modules/brace-expansion": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", - "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/readdir-glob/node_modules/minimatch": { - "version": "5.1.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", - "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", - "dev": true, - "license": "ISC", - "peer": true, - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -7026,6 +5651,16 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resedit": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/resedit/-/resedit-1.7.2.tgz", @@ -7086,20 +5721,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/restore-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", - "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "onetime": "^5.1.0", - "signal-exit": "^3.0.2" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/retry": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", @@ -7122,20 +5743,18 @@ } }, "node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz", + "integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==", "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, "license": "ISC", + "peer": true, "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" } }, "node_modules/roarr": { @@ -7256,13 +5875,6 @@ ], "license": "MIT" }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true, - "license": "MIT" - }, "node_modules/sanitize-filename": { "version": "1.6.4", "resolved": "https://registry.npmjs.org/sanitize-filename/-/sanitize-filename-1.6.4.tgz", @@ -7274,9 +5886,9 @@ } }, "node_modules/sax": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", - "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz", + "integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==", "dev": true, "license": "BlueOak-1.0.0", "engines": { @@ -7325,14 +5937,7 @@ }, "funding": { "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/set-blocking": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", - "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", - "dev": true, - "license": "ISC" + } }, "node_modules/shebang-command": { "version": "2.0.0", @@ -7403,76 +6008,6 @@ "node": ">=10" } }, - "node_modules/slice-ansi": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-3.0.0.tgz", - "integrity": "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "ansi-styles": "^4.0.0", - "astral-regex": "^2.0.0", - "is-fullwidth-code-point": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/smart-buffer": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", - "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/socks": { - "version": "2.8.9", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz", - "integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==", - "dev": true, - "license": "MIT", - "dependencies": { - "ip-address": "^10.1.1", - "smart-buffer": "^4.2.0" - }, - "engines": { - "node": ">= 10.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/socks-proxy-agent": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-7.0.0.tgz", - "integrity": "sha512-Fgl0YPZ902wEsAyiQ+idGd1A7rSFx/ayC1CQVMw5P+EQx2V0SgpGtf6OKFhVjPflPUl9YMmEOnmfjCdMUsygww==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^6.0.2", - "debug": "^4.3.3", - "socks": "^2.6.2" - }, - "engines": { - "node": ">= 10" - } - }, - "node_modules/socks-proxy-agent/node_modules/agent-base": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "4" - }, - "engines": { - "node": ">= 6.0.0" - } - }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -7512,19 +6047,6 @@ "license": "BSD-3-Clause", "optional": true }, - "node_modules/ssri": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/ssri/-/ssri-9.0.1.tgz", - "integrity": "sha512-o57Wcn66jMQvfHG1FlYbWeZWW/dHZhJXjpIcTfXldXEk5nz5lStPo3mK0OJQfGR3RbZUlbISexbljkJzuEj/8Q==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^3.1.1" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } - }, "node_modules/stat-mode": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/stat-mode/-/stat-mode-1.0.0.tgz", @@ -7559,22 +6081,6 @@ "node": ">=8" } }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -7588,20 +6094,6 @@ "node": ">=8" } }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/sucrase": { "version": "3.35.1", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", @@ -7716,59 +6208,47 @@ } }, "node_modules/tar": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", - "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", - "deprecated": "Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "version": "7.5.22", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", + "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "chownr": "^2.0.0", - "fs-minipass": "^2.0.0", - "minipass": "^5.0.0", - "minizlib": "^2.1.1", - "mkdirp": "^1.0.3", - "yallist": "^4.0.0" + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" }, "engines": { - "node": ">=10" + "node": ">=18" } }, - "node_modules/tar-stream": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", - "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "node_modules/tar/node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "bl": "^4.0.3", - "end-of-stream": "^1.4.1", - "fs-constants": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^3.1.1" - }, + "license": "BlueOak-1.0.0", "engines": { - "node": ">=6" + "node": ">=18" } }, - "node_modules/tar/node_modules/minipass": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", - "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "node_modules/temp": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/temp/-/temp-0.9.4.tgz", + "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", "dev": true, - "license": "ISC", + "license": "MIT", + "peer": true, + "dependencies": { + "mkdirp": "^0.5.1", + "rimraf": "~2.6.2" + }, "engines": { - "node": ">=8" + "node": ">=6.0.0" } }, - "node_modules/tar/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, - "license": "ISC" - }, "node_modules/temp-file": { "version": "3.4.0", "resolved": "https://registry.npmjs.org/temp-file/-/temp-file-3.4.0.tgz", @@ -7780,44 +6260,6 @@ "fs-extra": "^10.0.0" } }, - "node_modules/temp-file/node_modules/fs-extra": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", - "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/temp-file/node_modules/jsonfile": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", - "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/temp-file/node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10.0.0" - } - }, "node_modules/thenify": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", @@ -7841,6 +6283,26 @@ "node": ">=0.8" } }, + "node_modules/tiny-async-pool": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tiny-async-pool/-/tiny-async-pool-1.3.0.tgz", + "integrity": "sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^5.5.0" + } + }, + "node_modules/tiny-async-pool/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -8475,47 +6937,61 @@ "node": ">=14.17" } }, + "node_modules/undici": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", "dev": true, "license": "MIT" }, - "node_modules/unique-filename": { + "node_modules/universalify": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-2.0.1.tgz", - "integrity": "sha512-ODWHtkkdx3IAR+veKxFV+VBkUMcN+FaqzUUd7IZzt+0zhDZFPFxhlqwPF3YQvMHx1TD0tdgYl+kuPnJ8E6ql7A==", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", "dev": true, - "license": "ISC", - "dependencies": { - "unique-slug": "^3.0.0" - }, + "license": "MIT", "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + "node": ">= 10.0.0" } }, - "node_modules/unique-slug": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-3.0.0.tgz", - "integrity": "sha512-8EyMynh679x/0gqE9fT9oilG+qEt+ibFyqjuVTsZn1+CMxH+XLlpvr2UZx4nVcCwTpx81nICr2JQFkM+HPLq4w==", + "node_modules/unzipper": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.12.5.tgz", + "integrity": "sha512-tXYOi9R57Uj/2Z25SOs5RRSzq886MBQj2gY8dPL+xl/kv6s6SvByoKfAtvfVeEuhntWDgjd2o9p2lb4TVPAz0A==", "dev": true, - "license": "ISC", + "license": "MIT", "dependencies": { - "imurmurhash": "^0.1.4" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + "bluebird": "~3.7.2", + "duplexer2": "~0.1.4", + "fs-extra": "11.3.1", + "graceful-fs": "^4.2.2", + "node-int64": "^0.4.0" } }, - "node_modules/universalify": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", - "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "node_modules/unzipper/node_modules/fs-extra": { + "version": "11.3.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.1.tgz", + "integrity": "sha512-eXvGGwZ5CL17ZSwHWd3bbgk7UUpF6IFHtP57NYYakPvHOs8GDgDe5KJI36jIJzDkJ6eJjuzRA8eBQb6SkKue0g==", "dev": true, "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, "engines": { - "node": ">= 4.0.0" + "node": ">=14.14" } }, "node_modules/update-browserslist-db": { @@ -8549,16 +7025,6 @@ "browserslist": ">= 4.21.0" } }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "punycode": "^2.1.0" - } - }, "node_modules/utf8-byte-length": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz", @@ -8572,22 +7038,6 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, - "node_modules/verror": { - "version": "1.10.1", - "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.1.tgz", - "integrity": "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "assert-plus": "^1.0.0", - "core-util-is": "1.0.2", - "extsprintf": "^1.2.0" - }, - "engines": { - "node": ">=0.6.0" - } - }, "node_modules/vite": { "version": "6.4.3", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", @@ -9198,40 +7648,34 @@ "node": ">=12.0.0" } }, - "node_modules/wcwidth": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", - "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", + "node_modules/webcrypto-core": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/webcrypto-core/-/webcrypto-core-1.9.2.tgz", + "integrity": "sha512-gsXecm82UQNlTBURJGuqOWy1Ww08S3kZUcr3aOJS02Pk0xLtkfeUAVC0u0xhgdonFme80edSJUIJyuvL/7250Q==", "dev": true, "license": "MIT", "dependencies": { - "defaults": "^1.0.3" + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/json-schema": "^1.1.12", + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" } }, "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", + "integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==", "dev": true, "license": "ISC", "dependencies": { - "isexe": "^2.0.0" + "isexe": "^3.1.1" }, "bin": { - "node-which": "bin/node-which" + "node-which": "bin/which.js" }, "engines": { - "node": ">= 8" - } - }, - "node_modules/wide-align": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", - "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^1.0.2 || 2 || 3 || 4" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/wrap-ansi": { @@ -9252,25 +7696,6 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -9334,17 +7759,6 @@ "node": ">=12" } }, - "node_modules/yauzl": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", - "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", - "dev": true, - "license": "MIT", - "dependencies": { - "buffer-crc32": "~0.2.3", - "fd-slicer": "~1.1.0" - } - }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -9357,45 +7771,6 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } - }, - "node_modules/zip-stream": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-4.1.1.tgz", - "integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "archiver-utils": "^3.0.4", - "compress-commons": "^4.1.2", - "readable-stream": "^3.6.0" - }, - "engines": { - "node": ">= 10" - } - }, - "node_modules/zip-stream/node_modules/archiver-utils": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-3.0.4.tgz", - "integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "glob": "^7.2.3", - "graceful-fs": "^4.2.0", - "lazystream": "^1.0.0", - "lodash.defaults": "^4.2.0", - "lodash.difference": "^4.5.0", - "lodash.flatten": "^4.4.0", - "lodash.isplainobject": "^4.0.6", - "lodash.union": "^4.6.0", - "normalize-path": "^3.0.0", - "readable-stream": "^3.6.0" - }, - "engines": { - "node": ">= 10" - } } } } diff --git a/package.json b/package.json index 8bfe415..ecfe134 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,9 @@ "private": true, "main": "dist/main/index.js", "type": "commonjs", + "engines": { + "node": ">=24" + }, "scripts": { "build:main": "esbuild src/main/index.ts src/preload/index.ts --bundle --platform=node --format=cjs --external:electron --external:ffmpeg-static --external:ffprobe-static --outdir=dist --out-extension:.js=.js --outbase=src", "build:renderer": "vite build", @@ -18,10 +21,13 @@ "dev:renderer": "vite", "start": "electron .", "dev": "concurrently -k \"npm:dev:renderer\" \"npm:dev:main\" \"wait-on tcp:5273 && electron .\"", - "dist": "npm run build && electron-builder --publish never", + "vendor:runtime": "bash scripts/vendor-runtime.sh", + "dist": "npm run vendor:runtime && npm run build && electron-builder --publish never", + "local:setup": "bash local/setup.sh", "typecheck": "tsc --noEmit", - "test:unit": "tsx --test test/segment.test.ts", - "test": "npm run typecheck && npm run test:unit" + "test:unit": "tsx --test \"test/*.test.ts\"", + "check:no-cloud": "bash scripts/check-no-cloud.sh", + "test": "npm run typecheck && npm run test:unit && npm run check:no-cloud" }, "dependencies": { "@tanstack/react-query": "^5.62.0", @@ -33,15 +39,15 @@ "react-dom": "^18.3.1" }, "devDependencies": { - "@electron/notarize": "^2.5.0", - "@types/node": "^22.10.0", + "@electron/notarize": "^3.1.1", + "@types/node": "^24.13.3", "@types/react": "^18.3.0", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.0", "autoprefixer": "^10.4.20", "concurrently": "^9.1.0", - "electron": "^33.2.0", - "electron-builder": "^25.1.0", + "electron": "^43.2.0", + "electron-builder": "^26.15.3", "esbuild": "^0.24.0", "postcss": "^8.4.49", "tailwindcss": "^3.4.17", @@ -53,22 +59,72 @@ "build": { "appId": "com.videoboom.desktop", "productName": "Videoboom", - "directories": { "output": "release" }, + "directories": { + "output": "release" + }, "icon": "icons/icon.png", - "files": ["dist/**/*", "renderer-dist/**/*", "icons/icon.png"], - "asarUnpack": ["node_modules/ffmpeg-static/**", "node_modules/ffprobe-static/**"], + "files": [ + "dist/**/*", + "renderer-dist/**/*", + "icons/icon.png" + ], + "asarUnpack": [ + "node_modules/ffmpeg-static/**", + "node_modules/ffprobe-static/**" + ], + "extraResources": [ + { + "from": "local", + "to": "local", + "filter": [ + "*.py", + "requirements.txt", + "!bench*.py", + "!smoke_*.py", + "models/rife-v4.26/**", + "models/taew2_1.safetensors" + ] + }, + { + "from": "build/bin/uv", + "to": "bin/uv" + }, + { + "from": "build/wheels", + "to": "wheels" + }, + { + "from": "local/requirements.macos.lock", + "to": "requirements.macos.lock" + } + ], "afterSign": "scripts/mac-sign.js", "afterAllArtifactBuild": "scripts/notarize-dmg.js", "mac": { "category": "public.app-category.video", - "target": ["dmg"], + "target": [ + "dmg" + ], "icon": "icons/icon.png", "hardenedRuntime": true, "gatekeeperAssess": false, "entitlements": "build/entitlements.mac.plist", "entitlementsInherit": "build/entitlements.mac.plist" }, - "win": { "target": ["nsis", "portable"], "icon": "icons/icon.png" }, - "linux": { "category": "AudioVideo", "target": ["AppImage", "deb"], "icon": "icons/icon.png" } + "win": { + "target": [ + "nsis", + "portable" + ], + "icon": "icons/icon.png" + }, + "linux": { + "category": "AudioVideo", + "target": [ + "AppImage", + "deb" + ], + "icon": "icons/icon.png" + } } } diff --git a/renderer/App.tsx b/renderer/App.tsx index 5b31b16..db16385 100644 --- a/renderer/App.tsx +++ b/renderer/App.tsx @@ -4,14 +4,19 @@ import { import { useQuery, useQueryClient } from '@tanstack/react-query'; import { Sparkles, Film, UserRound, Settings as SettingsIcon, Music, Plus, RotateCcw, Trash2, - KeyRound, Image as ImageIcon, Wand2, AlertTriangle, CheckCircle2, ExternalLink, ChevronRight, + Image as ImageIcon, Wand2, AlertTriangle, CheckCircle2, Download, + Lock, Shield, Cloud, Cpu, KeyRound, ExternalLink, type LucideIcon, } from 'lucide-react'; import { Button, IconButton, Card, Field, Segmented, ProgressBar, Spinner, StatusDot, EmptyState, Img, inputCls, cx, useConfirm, } from './components/ui'; -import type { Project, Character, Scene, Settings, SidecarEvent } from './vb'; +import type { + Project, Character, Scene, Settings, SidecarEvent, LocalCapabilities, + Stage, Backend, StageSelection, CloudModels, ResolvedStage, +} from './vb'; import logo from './logo.png'; +import { Onboarding } from './Onboarding'; const vb = window.vb; @@ -28,13 +33,19 @@ function useMedia(key?: string | null, bust?: unknown): string | null { } // ── live render runs (one per project; driven by the sidecar event stream) ── -interface RunState { active: boolean; label: string; stage?: string; total?: number; done: number; cost?: number; error?: string } +// `warning` is for non-fatal degradations the run recovered from but the user must still know about — +// a failed 1080p upscale silently hands back the 480p render at the end of an hour-long job. +interface RunState { active: boolean; label: string; stage?: string; total?: number; done: number; finished?: boolean; cost?: number; error?: string; warning?: string } const RenderCtx = createContext<{ runs: Record; startRender: (pid: string, preview: boolean) => void; startResume: (pid: string) => void; + startRequality: (pid: string) => void; startRegen: (pid: string, index: number) => void; -}>({ runs: {}, startRender: () => {}, startResume: () => {}, startRegen: () => {} }); + startStoryboard: (pid: string, regenStory?: boolean) => void; + startRenderSelected: (pid: string, scenes: number[]) => void; + startRegenKeyframe: (pid: string, index: number) => void; +}>({ runs: {}, startRender: () => {}, startResume: () => {}, startRequality: () => {}, startRegen: () => {}, startStoryboard: () => {}, startRenderSelected: () => {}, startRegenKeyframe: () => {} }); const useRender = () => useContext(RenderCtx); function RenderProvider({ children }: { children: ReactNode }) { @@ -47,13 +58,14 @@ function RenderProvider({ children }: { children: ReactNode }) { setRuns((r) => { const cur = r[pid] || { active: true, label, done: 0 }; const next: RunState = { ...cur }; - if (e.event === 'stage') { next.stage = e.stage; if (e.total != null) next.total = e.total; if (e.stage === 'clips') next.done = 0; } - else if (e.event === 'scene') next.done = (cur.done || 0) + 1; - else if (e.event === 'done') next.cost = e.costCents; + if (e.event === 'stage') { next.stage = e.stage; if (e.total != null) next.total = e.total; if (e.stage === 'clips' || e.stage === 'keyframes') next.done = 0; } + else if (e.event === 'scene' || e.event === 'keyframe') next.done = (cur.done || 0) + 1; + else if (e.event === 'done') { next.finished = true; if (e.costCents != null) next.cost = e.costCents; } else if (e.event === 'error') next.error = e.message; + else if (e.event === 'warn') next.warning = e.message; return { ...r, [pid]: next }; }); - if (e.event === 'scene' || e.event === 'done') { qc.invalidateQueries({ queryKey: ['scenes', pid] }); qc.invalidateQueries({ queryKey: ['projects'] }); } + if (e.event === 'scene' || e.event === 'keyframe' || e.event === 'done') { qc.invalidateQueries({ queryKey: ['scenes', pid] }); qc.invalidateQueries({ queryKey: ['projects'] }); } }); op() .catch((err: Error) => setRuns((r) => ({ ...r, [pid]: { ...(r[pid] || { label, done: 0 }), active: true, error: String(err?.message || err) } }))) @@ -69,7 +81,11 @@ function RenderProvider({ children }: { children: ReactNode }) { runs, startRender: (pid: string, preview: boolean) => launch(pid, preview ? 'Preview' : 'Full video', `render:${pid}`, () => vb.render(pid, preview)), startResume: (pid: string) => launch(pid, 'Full song', `render:${pid}`, () => vb.resume(pid)), + startRequality: (pid: string) => launch(pid, 'Re-render · Quality', `render:${pid}`, () => vb.requality(pid)), startRegen: (pid: string, index: number) => launch(pid, `Scene ${index + 1}`, `render:${pid}`, () => vb.regenerateScene(pid, index)), + startStoryboard: (pid: string, regenStory?: boolean) => launch(pid, 'Storyboard', `render:${pid}`, () => vb.buildStoryboard(pid, regenStory)), + startRenderSelected: (pid: string, scenes: number[]) => launch(pid, `Render ${scenes.length} scene${scenes.length === 1 ? '' : 's'}`, `render:${pid}`, () => vb.renderSelected(pid, scenes)), + startRegenKeyframe: (pid: string, index: number) => launch(pid, `Keyframe ${index + 1}`, `render:${pid}`, () => vb.regenerateKeyframe(pid, index)), }), [runs, launch]); return {children}; @@ -89,7 +105,8 @@ function runMessage(run?: RunState): string { } } -const IN_PROGRESS = new Set(['queued', 'storyboarding', 'storyboard', 'rendering', 'refresh']); +// 'storyboard' is a READY-to-curate state (keyframes done, awaiting the user's scene picks), NOT busy. +const IN_PROGRESS = new Set(['queued', 'storyboarding', 'rendering', 'refresh']); // ── tabs ── type TabKey = 'create' | 'videos' | 'characters' | 'settings'; @@ -102,8 +119,13 @@ const TABS: { key: TabKey; label: string; icon: typeof Sparkles }[] = [ export default function App() { const [tab, setTab] = useState('create'); - const keys = useQuery({ queryKey: ['keys'], queryFn: () => vb.keysStatus(), refetchInterval: 4000 }); - const hasKey = !!keys.data?.openrouter; + const settings = useQuery({ queryKey: ['settings'], queryFn: () => vb.getSettings() }); + const qc = useQueryClient(); + + // First run (or a migrated blob that never onboarded): show the wizard until it's completed/skipped. + if (settings.data && !settings.data.onboarded) { + return qc.invalidateQueries({ queryKey: ['settings'] })} />; + } return ( @@ -127,15 +149,7 @@ export default function App() {
- {!hasKey && tab !== 'settings' && ( - - )} -
setTab('videos')} />
+
setTab('videos')} />
@@ -146,14 +160,14 @@ export default function App() { } // ── Create ── -function CreateVideo({ hasKey, onDone }: { hasKey: boolean; onDone: () => void }) { +function CreateVideo({ onDone }: { onDone: () => void }) { const chars = useQuery({ queryKey: ['chars'], queryFn: () => vb.listCharacters() }); - const { startRender } = useRender(); + const { startStoryboard } = useRender(); const [audio, setAudio] = useState(''); const [name, setName] = useState(''); + const [format, setFormat] = useState<'music-video' | 'ad'>('music-video'); const [style, setStyle] = useState('cinematic photorealistic music video, dramatic lighting, film grade, shallow depth of field'); const [mode, setMode] = useState<'realistic' | 'toon'>('realistic'); - const [scope, setScope] = useState<'preview' | 'full'>('preview'); const [cast, setCast] = useState([]); // ordered; [0] = lead const [busy, setBusy] = useState(false); const [err, setErr] = useState(null); @@ -173,16 +187,35 @@ function CreateVideo({ hasKey, onDone }: { hasKey: boolean; onDone: () => void } setBusy(true); setErr(null); try { const castSpec = cast.map((id, i) => `${id}:${i === 0 ? 'lead' : 'supporting'}`).join(','); - const { projectId } = await vb.createProject({ audio, name: name || 'Untitled', style, cast: castSpec, quality: 'fast', mode }); - startRender(projectId, scope === 'preview'); + const { projectId } = await vb.createProject({ audio, name: name || 'Untitled', style, cast: castSpec, quality: 'fast', mode, format }); + // Fase A: build the storyboard (prompts + a keyframe per scene, no clips) so the user curates it first. + startStoryboard(projectId); onDone(); } catch (e) { setErr(String((e as Error).message || e)); } finally { setBusy(false); } }; + const pickFormat = (f: 'music-video' | 'ad') => { + setFormat(f); + setStyle(f === 'ad' + ? 'modern product commercial, clean studio + lifestyle, vibrant, premium brand look, crisp lighting' + : 'cinematic photorealistic music video, dramatic lighting, film grade, shallow depth of field'); + }; + return (
+ +
+ {([['music-video', 'Music video', 'A story cut to the lyrics'], ['ad', 'Ad / Spot', 'A product commercial']] as const).map(([f, t, sub]) => ( + + ))} +
+
- setName(e.target.value)} placeholder="My music video" /> - -