From 8280ee6c6529888ee32fb0cb3205f170afac3a52 Mon Sep 17 00:00:00 2001 From: "[._.]/ Adam Eivy" Date: Thu, 28 May 2026 13:38:07 -0700 Subject: [PATCH 1/5] inline HF model download badge on image + video gen forms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surface whether the picked model's weights are already in the local HF cache (~/.cache/huggingface/hub/) before the user hits Render. Cached models get a green "Available · 7.8 GB" badge; missing ones get a "Download (~est)" button that pre-fetches the repo over SSE with a live file-by-file progress bar — so a multi-GB pull is opt-in, not a silent lazy-download surprise at render time. Lazy download remains the fallback for users who skip the pre-fetch. The video form also surfaces the active text encoder (a separate ~7–25 GB Gemma pull) with its own button, since the encoder is a shared dependency across every video render. Server: - server/lib/hfCache.js — async hub-cache inspector. Walks the latest snapshot dir, follows blob symlinks, treats dangling links (partial downloads) as "not cached" so users get the Download button instead of an "Available" badge that fails at render time. - server/lib/hfDownload.js — spawns scripts/hf_download_repo.py in the FLUX.2 venv (fallback: mflux pythonPath, gated on FLUX.2 venv health so a broken venv doesn't trap every download). Parses STAGE: / DOWNLOAD: / USER_ERROR: lines from the helper into SSE-friendly stage/progress/complete/error events. - server/lib/sseDownload.js — shared SSE driver for both image and video routes (and any future HF pre-fetch endpoint). Owns the cross-route in-flight Map keyed by repo so a FLUX repo referenced by both pages can't spawn two python children. - server/lib/mediaModels.js — repoForModel() maps mflux's legacy 'dev' / 'schnell' ids to their canonical Black Forest Labs repos so they get the same badge as every other entry. isHfRepoId() distinguishes 'org/name' from localPath text-encoder entries. - Routes: GET /api/{image,video}-gen/models/status returns per-model {cached, sizeBytes}; GET /api/{image,video}-gen/models/:id/download is the SSE endpoint. Video also exposes GET /api/video-gen/text-encoder/download for the Gemma pre-fetch. The status route parallelizes N inspections with Promise.all. Client: - client/src/hooks/useModelDownloadStatus.js — fetch status + drive the SSE pre-download. Memoizes the active model's enriched status so a new SSE frame only re-renders the active badge, not the whole ImageGenControls subtree. - client/src/components/media/ModelDownloadBadge.jsx — three states: cached (green "Available · 7.8 GB"), unknown repo (no badge), and needs-download (button → inline progress bar + stage label + cancel). - ImageGen.jsx, VideoGen.jsx, ImageGenControls.jsx — wire the badge in. Universe Builder batch-render and any other caller that doesn't pass the props simply gets no badge. Helper script: - scripts/hf_download_repo.py — per-file hf_hub_download with STAGE: marker emission so the SSE bridge can drive a real progress bar. Sequential rather than snapshot_download() so the UI can show per-file granular progress; trade-off documented inline. Tests: server 8073 passing (+8 hfCache scenarios), client 602. --- .changelog/NEXT.md | 1 + .../components/imageGen/ImageGenControls.jsx | 17 ++ .../components/media/ModelDownloadBadge.jsx | 107 ++++++++++++ client/src/hooks/README.md | 1 + client/src/hooks/index.js | 1 + client/src/hooks/useModelDownloadStatus.js | 113 ++++++++++++ client/src/pages/ImageGen.jsx | 10 ++ client/src/pages/VideoGen.jsx | 34 ++++ client/src/services/apiImageVideo.js | 7 + scripts/hf_download_repo.py | 122 +++++++++++++ server/lib/README.md | 3 + server/lib/hfCache.js | 126 ++++++++++++++ server/lib/hfCache.test.js | 134 +++++++++++++++ server/lib/hfDownload.js | 161 ++++++++++++++++++ server/lib/index.js | 3 + server/lib/mediaModels.js | 32 ++++ server/lib/sseDownload.js | 44 +++++ server/routes/imageGen.js | 40 ++++- server/routes/videoGen.js | 48 ++++++ server/routes/videoGen.test.js | 2 +- 20 files changed, 1004 insertions(+), 2 deletions(-) create mode 100644 client/src/components/media/ModelDownloadBadge.jsx create mode 100644 client/src/hooks/useModelDownloadStatus.js create mode 100755 scripts/hf_download_repo.py create mode 100644 server/lib/hfCache.js create mode 100644 server/lib/hfCache.test.js create mode 100644 server/lib/hfDownload.js create mode 100644 server/lib/sseDownload.js diff --git a/.changelog/NEXT.md b/.changelog/NEXT.md index 37cf37b1d4..2cd2be7a9d 100644 --- a/.changelog/NEXT.md +++ b/.changelog/NEXT.md @@ -14,6 +14,7 @@ TBD - **Brain → Links: edit a link's URL**: the per-link edit form now exposes the URL alongside the title (each with a visible label so they're unambiguous even when the title defaults to the URL). Changing the URL re-derives the GitHub repo metadata, guards against duplicate URLs (409), and resets stale clone state so a cloned repo's local path can't point at the wrong target. - **Base style image (style probe)** on a universe: generate a canonical image from the universe's embrace/avoid influences as the positive/negative prompt, with no character or subject — to preview the world's base visual emphasis. `styleNotes` is intentionally NOT mixed into the probe prompt because it never reaches the image model on any downstream prompt (canon refs, variations, sheets, comic pages) — including it would misrepresent what those renders will look like. Triggerable from both the Universe Builder (under the style/influences editor) and the Story Builder's Universe Aesthetic step; the result persists on the universe (`styleImageRefs`) so both surfaces share it. Includes a "Regenerate" affordance to re-roll the probe once an image already exists (new filenames append to `styleImageRefs[]` so prior renders survive as walk-back fallbacks). - **Reader Map** on a series arc (`series.arc.readerMap`): a distinct audience-experience roadmap — hooks, payoffs, emotional beats, and cliffhangers across the arc — built on top of the Vonnegut story shape, separate from the protagonist arc. Generated and refined via the new Story Builder reader-map step (also preserved by arc regeneration). +- **Image + Video Gen: inline model download status**. The model picker on both pages now shows whether the selected model's weights are already in the local HuggingFace cache. Available models get a green "Available · 7.8 GB" badge; missing ones get a "Download (~est)" button that pre-fetches the repo over SSE with a live progress bar (so users learn about a multi-GB pull before hitting Render instead of being surprised by a silent lazy download mid-generation). Hitting Render without pre-downloading still works — the existing lazy-download path is the fallback. The video form also surfaces the active text encoder (a separate ~7–25 GB Gemma pull) with its own button. New endpoints: `GET /api/image-gen/models/status`, `GET /api/image-gen/models/:id/download` (SSE), `GET /api/video-gen/models/status`, `GET /api/video-gen/models/:id/download` (SSE), `GET /api/video-gen/text-encoder/download` (SSE). Cache detection lives in `server/lib/hfCache.js` (`inspectModelCache`); download orchestration in `server/lib/hfDownload.js` and the new `scripts/hf_download_repo.py` helper (runs in the FLUX.2 venv, falls back to the mflux pythonPath). Mflux legacy `dev` / `schnell` map to their canonical `black-forest-labs/FLUX.1-*` repos so they get the same badge as every other entry. - Local LLMs: when an Ollama model pull fails with `412: requires a newer version of Ollama`, PortOS now auto-upgrades Ollama in place and retries the install — no confirm click. On macOS with `/Applications/Ollama.app` present, this downloads the latest `Ollama-darwin.zip` directly from GitHub releases, force-kills the running Ollama, replaces the `.app` bundle, strips quarantine, relaunches, and polls `/api/version` until the new binary is serving. (The old brew-only path silently left the old binary running, because `/usr/local/bin/ollama` symlinks into the `.app` bundle — even a successful `brew upgrade` doesn't change which binary is on disk inside the `.app`.) On Linux it still re-runs the official Ollama install script; for headless macOS installs (brew formula only, no `.app`), it still runs `brew upgrade ollama`. The UI now shows a prominent yellow warning banner with live step-by-step progress for the whole upgrade flow, instead of a quiet inline confirm row. Failed `brew` runs now also surface the actual stderr (e.g. `Error: ollama not installed`) instead of just an exit code. ## Changed diff --git a/client/src/components/imageGen/ImageGenControls.jsx b/client/src/components/imageGen/ImageGenControls.jsx index 086fe79b42..b301fc6e45 100644 --- a/client/src/components/imageGen/ImageGenControls.jsx +++ b/client/src/components/imageGen/ImageGenControls.jsx @@ -13,6 +13,7 @@ import { filterResolutions, resolveResolutionLabel } from '../../lib/imageGenRes import { randomSeed } from '../../lib/genUtils'; import { RUNNER_FAMILIES } from '../../lib/runnerFamilies'; import { IMAGE_GEN_MODE } from '../../lib/imageGenBackends'; +import ModelDownloadBadge, { deriveSizeEstimate } from '../media/ModelDownloadBadge'; const QUANTIZE_OPTIONS = [ { value: '3', label: '3-bit' }, @@ -39,6 +40,14 @@ export default function ImageGenControls({ // Optional column override — defaults to 2/3 like the Image Gen page. // Pass e.g. "grid-cols-2 sm:grid-cols-4" to fit a denser layout. className = 'grid grid-cols-2 sm:grid-cols-3 gap-3', + // Pre-download badge integration. `modelStatus` is the per-model entry from + // useModelDownloadStatus().getStatus(modelId); `onModelDownload` / + // `onModelDownloadCancel` are optional triggers. Omitting the props hides + // the badge — callers that don't care (Universe Builder batch render) opt + // out by simply not passing them. + modelStatus = null, + onModelDownload, + onModelDownloadCancel, }) { const isLocal = mode === IMAGE_GEN_MODE.LOCAL; const isCodex = mode === IMAGE_GEN_MODE.CODEX; @@ -71,6 +80,14 @@ export default function ImageGenControls({ > {models.map((m) => )} + {onModelDownload && modelStatus && ( + onModelDownload(modelId)} + onCancel={onModelDownloadCancel} + estimateLabel={deriveSizeEstimate(currentModel?.name)} + /> + )} )} diff --git a/client/src/components/media/ModelDownloadBadge.jsx b/client/src/components/media/ModelDownloadBadge.jsx new file mode 100644 index 0000000000..c3e49d3aa9 --- /dev/null +++ b/client/src/components/media/ModelDownloadBadge.jsx @@ -0,0 +1,107 @@ +// Inline "Available · 7.8 GB" / "Download (~8 GB)" badge for the image and +// video gen model pickers. Drops below the model so the user can // see — before hitting Render — whether their pick still needs a multi-GB -// HF pull. Rendering Render is NOT blocked: lazy download remains the +// HF pull. Hitting Render is NOT blocked: lazy download remains the // fallback, so a user who just wants to fire and wait can keep doing that. // // Three render states: diff --git a/scripts/hf_download_repo.py b/scripts/hf_download_repo.py index 4ce63ced98..7ecf7922ce 100755 --- a/scripts/hf_download_repo.py +++ b/scripts/hf_download_repo.py @@ -5,8 +5,10 @@ Downloads a full HF repo into the standard `~/.cache/huggingface/hub/` cache so the image / video gen forms can show a model as "Available" instead of forcing the user to discover a multi-GB pull mid-render. Spawned over SSE -from `server/routes/imageGen.js#/models/:repoId/download` and the matching -video route. +from `GET /api/image-gen/models/:id/download` and the matching +`GET /api/video-gen/models/:id/download` (model-id-keyed; the route maps +the id to an HF repo before invoking this helper), plus +`GET /api/video-gen/text-encoder/download` for the Gemma encoder. Wire protocol (matches the STAGE:/DOWNLOAD: convention the rest of the image-gen runners use, so the existing SSE bridge picks it up unchanged): diff --git a/server/lib/hfDownload.js b/server/lib/hfDownload.js index 86bd786425..5359033a32 100644 --- a/server/lib/hfDownload.js +++ b/server/lib/hfDownload.js @@ -11,7 +11,10 @@ // STAGE:list -> { type: 'stage', stage: 'list' } // STAGE:download:/: -> stage + progress n/total // STAGE:complete: -> { type: 'complete', sizeBytes } -// USER_ERROR:: -> typed-error capture +// USER_ERROR:: -> typed-error capture; +// is the repo id for list/auth +// failures and the filename +// for per-file download errors // ❌ -> errorMessage // Unknown lines fall through as raw `{ type: 'log', message }`. diff --git a/server/lib/mediaModels.js b/server/lib/mediaModels.js index 717cd28ebf..905ad70f5f 100644 --- a/server/lib/mediaModels.js +++ b/server/lib/mediaModels.js @@ -573,15 +573,26 @@ export const repoForModel = (model) => { // `getTextEncoderRepo()` can return either an HF repo id (`org/name`) or a // resolved local filesystem path when the registry entry has a `localPath` -// override. Anything that starts with `/` or `~` is a local path; only -// `org/name` is a valid input to HF-cache inspection / download endpoints. -export const isHfRepoId = (value) => ( - typeof value === 'string' - && value.length > 0 - && !value.startsWith('/') - && !value.startsWith('~') - && value.includes('/') -); +// override. Only `org/name` is a valid input to HF-cache inspection / +// download endpoints. +// +// Reject local-path shapes across platforms: +// - POSIX absolute / home-relative: `/foo/bar`, `~/foo` +// - Windows drive paths (both backslash and forward-slash style): `C:\…`, +// `C:/Users/…` — without this check, a Windows install with a +// forward-slash-style localPath text encoder would be misclassified as +// an HF repo, triggering bogus cache inspection / download requests. +// - Windows UNC paths: `\\server\share\…` +// - Any path containing a backslash (Windows separator) +// Then require exactly one `/` separator — standard HF repo ids are the +// `org/name` shape; zero (`legacy-bare-name`) and multiple (a path) are not. +export const isHfRepoId = (value) => { + if (typeof value !== 'string' || value.length === 0) return false; + if (value.startsWith('/') || value.startsWith('~')) return false; + if (value.includes('\\')) return false; + if (/^[A-Za-z]:/.test(value)) return false; + return (value.match(/\//g) || []).length === 1; +}; // Resolve the active text encoder to a path mlx_video can pass via // --text-encoder-repo. Prefers `localPath` (e.g. an existing LM Studio diff --git a/server/lib/mediaModels.test.js b/server/lib/mediaModels.test.js index 4af8a0bc57..48d6c152f8 100644 --- a/server/lib/mediaModels.test.js +++ b/server/lib/mediaModels.test.js @@ -605,6 +605,29 @@ describe('mediaModels registry', () => { logSpy.mockRestore(); }); + it('isHfRepoId accepts canonical org/name shape and rejects local paths cross-platform', async () => { + const { isHfRepoId } = await import('./mediaModels.js'); + expect(isHfRepoId('black-forest-labs/FLUX.1-dev')).toBe(true); + expect(isHfRepoId('mlx-community/gemma-3-12b-it-4bit')).toBe(true); + // POSIX / home-relative + expect(isHfRepoId('/usr/local/share/model')).toBe(false); + expect(isHfRepoId('~/.cache/huggingface/hub')).toBe(false); + // Windows drive paths — both backslash and forward-slash style. + // These would silently pass the old `includes('/')` check and trip the + // download endpoints into treating an LM Studio path as a Hub repo. + expect(isHfRepoId('C:/Users/foo/model')).toBe(false); + expect(isHfRepoId('C:\\Users\\foo\\model')).toBe(false); + expect(isHfRepoId('D:/lmstudio/models')).toBe(false); + // UNC and other backslash-bearing paths + expect(isHfRepoId('\\\\server\\share\\model')).toBe(false); + // Multi-slash shapes are paths, not repo ids + expect(isHfRepoId('org/name/subdir')).toBe(false); + // Empty / non-string / non-namespaced legacy bare names + expect(isHfRepoId('')).toBe(false); + expect(isHfRepoId(null)).toBe(false); + expect(isHfRepoId('bare-name')).toBe(false); + }); + it('does NOT auto-recover the drifted entry (warn loud, but trust the registry on disk)', async () => { // Same setup as the first drift test — confirm that the warning does // NOT cause normalizeRegistry to silently re-add the missing built-in.