From bb937296e7c209d69a3e70ec16ac0f0256909d1f Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Mon, 6 Jul 2026 14:14:15 +0000 Subject: [PATCH] Guard in-flight video loads from deletion, resolve cached hub GGUFs by arch, seed gen controls Three video-tab fixes: - delete-cached refused a loaded video repo but not one a background load is still downloading (status().loaded is False in that window); add VideoBackend.loading_repo_ids() mirroring the image backend and check it in the route so deleting mid-download can no longer yank blobs. - the cached-gguf picker tags a cached HUB GGUF by its general.architecture, but the loader's arch fallback only read a LOCAL file, so an opaquely-named cached hub LTX GGUF the picker offered 400d on load; read the arch from the cached blob (network-free) too. - on a mount with a model already loaded (refresh / load from another client) steps and guidance stuck at the pre-load default, so a base checkpoint silently generated a degraded clip; seed them from the backend-authoritative status.defaults once per newly-loaded model. --- studio/backend/core/inference/video.py | 47 +++++++++++----- studio/backend/routes/models.py | 14 ++++- studio/backend/tests/test_video_backend.py | 56 +++++++++++++++++++ .../src/features/video/video-page.tsx | 20 +++++++ 4 files changed, 123 insertions(+), 14 deletions(-) diff --git a/studio/backend/core/inference/video.py b/studio/backend/core/inference/video.py index ee0909a212..11605d0d8a 100644 --- a/studio/backend/core/inference/video.py +++ b/studio/backend/core/inference/video.py @@ -119,18 +119,26 @@ def _is_trusted_video_repo(repo_id: str) -> bool: return rid.startswith("unsloth/") or rid in _TRUSTED_NON_GGUF_VIDEO_REPOS -def _local_gguf_arch(repo_id: str, gguf_filename: str) -> Optional[str]: - """``general.architecture`` of a LOCAL GGUF pick (``repo_id`` is a directory), or None. - The Video picker admits a local GGUF by its arch (not its name), so a renamed file whose - path carries no family token still shows up; reading the arch lets the loader resolve the - same family the picker offered. Header-only, bounds-checked read (no-op for a hub repo id - whose path does not exist).""" +def _picked_gguf_arch(repo_id: str, gguf_filename: str) -> Optional[str]: + """``general.architecture`` of a picked GGUF, or None. The Video picker admits a GGUF by its + arch (not its name) -- for a LOCAL dir (``repo_id`` is a directory) AND for a cached HUB repo + (the cached-gguf listing tags it by arch too), so a renamed/opaquely-named file whose path + carries no family token still shows up; reading the arch lets the loader resolve the same + family the picker offered. Reads the local file when present, else the cached hub blob + (network-free via try_to_load_from_cache). Header-only, bounds-checked.""" try: from pathlib import Path path = Path(repo_id).expanduser() / gguf_filename if not path.is_file(): - return None + # Not a local dir: resolve a cached HUB blob from the HF cache (no network). The + # cached-gguf picker only offers already-downloaded repos, so the blob is on disk. + from huggingface_hub import try_to_load_from_cache + + cached = try_to_load_from_cache(repo_id, gguf_filename) + if not isinstance(cached, str): + return None + path = Path(cached) from utils.models.gguf_metadata import read_gguf_general_metadata arch = (read_gguf_general_metadata(str(path)) or {}).get("general.architecture") @@ -152,12 +160,12 @@ def _detect_load_family( else None ) if fam is None and gguf_filename and not family_override: - # The picker admits a local GGUF by its general.architecture, but its path/name may - # carry no whole-segment family token (e.g. a renamed "model.gguf"), so the name-based - # detection above misses it. Resolve the same family the picker offered by reading the - # arch -- its string ("ltxv") is a family alias. A video arch with no backend family - # (e.g. "wan") still yields None, so an unsupported pick 400s exactly as before. - arch = _local_gguf_arch(repo_id, gguf_filename) + # The picker admits a GGUF (local dir OR cached hub repo) by its general.architecture, but + # its path/name may carry no whole-segment family token (e.g. a renamed "model.gguf"), so + # the name-based detection above misses it. Resolve the same family the picker offered by + # reading the arch -- its string ("ltxv") is a family alias. A video arch with no backend + # family (e.g. "wan") still yields None, so an unsupported pick 400s exactly as before. + arch = _picked_gguf_arch(repo_id, gguf_filename) if arch: fam = detect_video_family(repo_id, override = arch) return fam @@ -613,6 +621,19 @@ class VideoBackend: expected_bytes = int(expected) if expected else None, ) + def loading_repo_ids(self) -> tuple[str, ...]: + """Repo ids an in-flight background load is downloading (empty when idle). + + The delete-cached guard needs this: during a load ``status()["loaded"]`` is + still False, but deleting the target repo (or its companion base) would yank + blobs and snapshot files from under the download/assembly. Mirrors the image + backend's guard (DiffusionBackend.loading_repo_ids).""" + with self._lock: + loading = self._loading + if loading is None or loading.error is not None: + return () + return tuple(r for r in (loading.repo_id, loading.base_repo) if r) + # ── the load itself ────────────────────────────────────────────────────── def load_pipeline( diff --git a/studio/backend/routes/models.py b/studio/backend/routes/models.py index 67e87c7ae3..fd9507e9fa 100644 --- a/studio/backend/routes/models.py +++ b/studio/backend/routes/models.py @@ -3521,7 +3521,9 @@ async def delete_cached_model( # pipeline -- the same invariant the three guards above enforce. Repo-level match. try: from core.inference.video import get_video_backend - video_status = get_video_backend().status() + + video_backend = get_video_backend() + video_status = video_backend.status() if video_status.get("loaded") and video_status.get("repo_id"): loaded_id = str(video_status["repo_id"]).lower() if loaded_id == repo_id.lower() or loaded_id.startswith(repo_id.lower()): @@ -3529,6 +3531,16 @@ async def delete_cached_model( status_code = 400, detail = "Unload the model before deleting", ) + # Also refuse while a background VIDEO load is DOWNLOADING this repo (or its companion + # base): status().loaded is still False in that window, but deleting would remove blobs + # from under the in-flight download/assembly -- same as the Images guard above. + for lid in getattr(video_backend, "loading_repo_ids", tuple)(): + lid = str(lid).lower() + if lid == repo_id.lower() or lid.startswith(repo_id.lower()): + raise HTTPException( + status_code = 400, + detail = "A Video model load is using this repo; wait for it to finish", + ) except HTTPException: raise except Exception: diff --git a/studio/backend/tests/test_video_backend.py b/studio/backend/tests/test_video_backend.py index 146543da15..e0426b2cee 100644 --- a/studio/backend/tests/test_video_backend.py +++ b/studio/backend/tests/test_video_backend.py @@ -243,6 +243,62 @@ def test_detect_load_family_filename_fallback(): assert _detect_load_family("someorg/quants", "ltx-2-19b-Q4_K_M.gguf", "bogus") is None +def test_detect_load_family_cached_hub_arch_fallback(monkeypatch): + # A CACHED HUB GGUF is admitted to the picker by its general.architecture (the cached-gguf + # listing tags it text-to-video), but an opaque repo id + renamed file carry no family token, + # so name detection misses. The local-file arch read misses too (a hub repo id is not a local + # dir), so without a cache fallback the loader 400s a SUPPORTED checkpoint the picker offered. + import huggingface_hub + + import utils.models.gguf_metadata as gguf_meta + + # No local file at Path(repo_id)/filename; resolve the arch from the cached blob instead. + monkeypatch.setattr( + huggingface_hub, + "try_to_load_from_cache", + lambda repo_id, filename, **kw: "/fake/cache/blobs/model.gguf", + ) + monkeypatch.setattr( + gguf_meta, "read_gguf_general_metadata", lambda path: {"general.architecture": "ltxv"} + ) + fam = _detect_load_family("someorg/opaque-quants", "model.gguf", None) + assert fam is not None and fam.name == "ltx-2" + + # A cache MISS (blob not present -> None) still yields None (400 exactly as before). + monkeypatch.setattr(huggingface_hub, "try_to_load_from_cache", lambda *a, **k: None) + assert _detect_load_family("someorg/opaque-quants", "model.gguf", None) is None + + # A recognised-but-unsupported video arch (wan has no backend family in this build) stays None, + # so an unsupported cached pick 400s just like the local-dir case. + monkeypatch.setattr( + huggingface_hub, "try_to_load_from_cache", lambda *a, **k: "/fake/cache/blobs/model.gguf" + ) + monkeypatch.setattr( + gguf_meta, "read_gguf_general_metadata", lambda path: {"general.architecture": "wan"} + ) + assert _detect_load_family("someorg/opaque-quants", "model.gguf", None) is None + + +def test_loading_repo_ids_guards_in_flight_delete(): + # During a background load status()["loaded"] is still False, but the target repo (+ its + # companion base) is being downloaded, so the delete-cached guard needs loading_repo_ids to + # refuse deletion and avoid yanking blobs from under the in-flight download/assembly. + from core.inference.video import _VideoLoadingState + + backend = VideoBackend() + assert backend.loading_repo_ids() == () # idle: nothing to guard + backend._loading = _VideoLoadingState(repo_id = "org/ckpt", base_repo = "Lightricks/LTX-2") + assert set(backend.loading_repo_ids()) == {"org/ckpt", "Lightricks/LTX-2"} + # An errored load is no longer in flight -> the files are safe to delete. + backend._loading = _VideoLoadingState( + repo_id = "org/ckpt", base_repo = "Lightricks/LTX-2", error = "boom" + ) + assert backend.loading_repo_ids() == () + # A load whose base equals the repo (or is empty) yields just the one id. + backend._loading = _VideoLoadingState(repo_id = "org/ckpt", base_repo = "") + assert backend.loading_repo_ids() == ("org/ckpt",) + + def test_load_generate_unload_gguf(fake_runtime, tmp_path): backend = VideoBackend() status = _load_gguf(backend, tmp_path) diff --git a/studio/frontend/src/features/video/video-page.tsx b/studio/frontend/src/features/video/video-page.tsx index 993a6a001d..f2092aa9ba 100644 --- a/studio/frontend/src/features/video/video-page.tsx +++ b/studio/frontend/src/features/video/video-page.tsx @@ -613,6 +613,26 @@ export function VideoPage({ active = true }: { active?: boolean }) { }); }, [durationOptions, loadedFamily, familyDefaultFrames]); + // Seed steps/guidance from the loaded model's backend-authoritative defaults. On mount with a + // model already loaded (browser refresh, or a load from another client) only refreshStatus runs + // -- handleModelSelect never fires -- so the controls otherwise stick at the pre-load DEFAULT_GEN + // (8/1) and a base checkpoint that wants 40/4 silently generates a degraded clip. Key on the repo + // id so it fires once per newly-loaded model (a distilled vs base checkpoint of the same family + // has different defaults); a later user edit is not clobbered because the key only changes when + // the loaded model changes, and a gallery restore (which keeps the same repo) is left untouched. + const loadedModelKey = status?.loaded ? status.repo_id : null; + const defaultSteps = status?.defaults?.steps; + const defaultGuidance = status?.defaults?.guidance; + const prevLoadedModelRef = useRef(null); + useEffect(() => { + const modelChanged = loadedModelKey !== prevLoadedModelRef.current; + prevLoadedModelRef.current = loadedModelKey; + if (modelChanged && loadedModelKey && defaultSteps != null && defaultGuidance != null) { + setSteps(defaultSteps); + setGuidance(defaultGuidance); + } + }, [loadedModelKey, defaultSteps, defaultGuidance]); + // Fetch (once) the object URL for a record's MP4; cached across remounts. Same // auth-protected blob pattern the images gallery uses. const ensureSrc = useCallback(async (video: GalleryVideo) => {