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.
This commit is contained in:
parent
37ed4efaa6
commit
bb937296e7
4 changed files with 123 additions and 14 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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<string | null>(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) => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue