diff --git a/studio/backend/routes/models.py b/studio/backend/routes/models.py index d4501517c8..f910c27a46 100644 --- a/studio/backend/routes/models.py +++ b/studio/backend/routes/models.py @@ -3178,13 +3178,17 @@ _UNSUPPORTED_DIFFUSION_GGUF_ARCHS = frozenset( "aura", "hidream", "cosmos", - "ltxv", "hyvid", - "wan", "lumina2", } ) +# Video GGUF archs the video backend CAN load (LTX-2.x ships as "ltxv"; the Wan +# community GGUFs as "wan"). Tagged text-to-video so they surface in the Video +# picker (VIDEO_GEN_TASKS) and stay out of chat (NON_CHAT_TASKS). +_VIDEO_GGUF_ARCHS = frozenset({"ltxv", "wan"}) +_VIDEO_GEN_TASK = "text-to-video" + # Task tag for the archs above; mirrored by the frontend NON_CHAT_TASKS gate. _UNSUPPORTED_DIFFUSION_TASK = "image-diffusion-unsupported" @@ -3204,6 +3208,8 @@ def _arch_to_task(arch: Optional[str]) -> Optional[str]: a = arch.lower() if a in _DIFFUSION_GGUF_ARCHS: return "text-to-image" + if a in _VIDEO_GGUF_ARCHS: + return _VIDEO_GEN_TASK # A diffusion arch the backend can't assemble: hide it from chat (it would die # in llama.cpp) without surfacing it in Images (it would 400 in validate_load). if a in _UNSUPPORTED_DIFFUSION_GGUF_ARCHS: diff --git a/studio/backend/tests/test_cached_gguf_routes.py b/studio/backend/tests/test_cached_gguf_routes.py index 365e7624c4..0f8f773e3e 100644 --- a/studio/backend/tests/test_cached_gguf_routes.py +++ b/studio/backend/tests/test_cached_gguf_routes.py @@ -749,15 +749,26 @@ def test_arch_to_task_hides_unsupported_diffusion_from_chat(): # ("text-generation") NOR a loadable image task ("text-to-image"), so the chat # picker hides them (they'd die in llama.cpp) and the Images picker leaves them # out (they'd 400 in validate_load). - for arch in ("sdxl", "sd1", "sd3", "wan", "lumina2", "hidream", "cosmos"): + for arch in ("sdxl", "sd1", "sd3", "lumina2", "hidream", "cosmos", "hyvid"): task = models_route._arch_to_task(arch) assert task == models_route._UNSUPPORTED_DIFFUSION_TASK assert task not in ("text-generation", "text-to-image") + # Video archs the video backend loads surface with the Video-picker task, + # which is neither chat nor an image task (unsloth LTX-2.x GGUFs ship + # general.architecture "ltxv"; community Wan GGUFs ship "wan"). + for arch in ("ltxv", "wan"): + task = models_route._arch_to_task(arch) + assert task == models_route._VIDEO_GEN_TASK + assert task not in ("text-generation", "text-to-image") # Drift guard: every diffusion arch llama.cpp rejects as a chat model must be - # classified here as some image task (loadable OR unsupported), never chat. + # classified here as some non-chat task (image, video, or unsupported). from core.inference.llama_cpp import LlamaCppBackend - classified = models_route._DIFFUSION_GGUF_ARCHS | models_route._UNSUPPORTED_DIFFUSION_GGUF_ARCHS + classified = ( + models_route._DIFFUSION_GGUF_ARCHS + | models_route._UNSUPPORTED_DIFFUSION_GGUF_ARCHS + | models_route._VIDEO_GGUF_ARCHS + ) missing = {a for a in LlamaCppBackend._DIFFUSION_ARCHES if a.lower() not in classified} assert not missing, f"diffusion archs would still show in chat: {missing}" diff --git a/studio/frontend/src/components/app-sidebar.tsx b/studio/frontend/src/components/app-sidebar.tsx index a431582ae5..5df3ff4482 100644 --- a/studio/frontend/src/components/app-sidebar.tsx +++ b/studio/frontend/src/components/app-sidebar.tsx @@ -1208,10 +1208,15 @@ export function AppSidebar() { closeMobileIfOpen(); }} /> + {/* Video is diffusers-only (no native CPU engine), so a chat-only host can + never load it; disable with a hint instead of bouncing off the root + guard's redirect. */} { navigate({ to: "/video" }); closeMobileIfOpen(); diff --git a/studio/frontend/src/features/video/video-page.tsx b/studio/frontend/src/features/video/video-page.tsx index b173ead71b..993a6a001d 100644 --- a/studio/frontend/src/features/video/video-page.tsx +++ b/studio/frontend/src/features/video/video-page.tsx @@ -589,13 +589,29 @@ export function VideoPage({ active = true }: { active?: boolean }) { useEffect(() => { setResolutionIdx((idx) => (idx < resolutionPresets.length ? idx : 0)); }, [resolutionPresets.length]); + const loadedFamily = status?.loaded ? status.family : null; + const familyDefaultFrames = status?.defaults?.num_frames; + const prevFamilyRef = useRef(null); useEffect(() => { + const familyChanged = loadedFamily !== prevFamilyRef.current; + prevFamilyRef.current = loadedFamily; setNumFrames((cur) => { + // A newly loaded family brings its own default clip length (121 frames for + // LTX-2); without this the pre-load fallback (25 frames, still on the new + // lattice) silently sticks and every default run is a ~1s clip. + if (familyChanged && loadedFamily && familyDefaultFrames) { + const best = durationOptions.reduce((a, b) => + Math.abs(b.frames - familyDefaultFrames) < Math.abs(a.frames - familyDefaultFrames) + ? b + : a, + ); + return best?.frames ?? cur; + } if (durationOptions.some((o) => o.frames === cur)) return cur; // Prefer the ~3s preset (index 2) as a sensible default, else the first. return durationOptions[2]?.frames ?? durationOptions[0]?.frames ?? cur; }); - }, [durationOptions]); + }, [durationOptions, loadedFamily, familyDefaultFrames]); // Fetch (once) the object URL for a record's MP4; cached across remounts. Same // auth-protected blob pattern the images gallery uses. @@ -901,7 +917,9 @@ export function VideoPage({ active = true }: { active?: boolean }) { const prevQuant = quant; quantRevert.current = { prev: prevQuant }; setQuant(meta.ggufVariant); - const dq = defaultsFor(id); + // Include the picked filename: the variant (distilled vs dev) lives there, + // not in the repo id. + const dq = defaultsFor(`${id}/${meta.ggufFilename}`); setSteps(dq.steps); setGuidance(dq.guidance); void handleLoad(id, { kind: "gguf", filename: meta.ggufFilename }).then((started) => { @@ -1038,7 +1056,9 @@ export function VideoPage({ active = true }: { active?: boolean }) { setSelectedId(res.video.id); void ensureSrc(res.video); } catch (err) { - toast.error(err instanceof Error ? err.message : "Video generation failed"); + const msg = err instanceof Error ? err.message : "Video generation failed"; + // The user's own Cancel comes back as the backend's 409 sentinel; not an error. + if (!msg.toLowerCase().includes("cancelled")) toast.error(msg); } finally { if (genPollTimer.current) clearInterval(genPollTimer.current); genPollTimer.current = null;