Video tab review fixes: on-device GGUF discovery, family defaults, cancel and chat-only polish

Tag the ltxv and wan GGUF archs text-to-video so cached video checkpoints
actually surface in the Video picker (they were classed unsupported and
hidden everywhere). Adopt the loaded family's default clip length instead
of silently keeping the 25-frame pre-load fallback, and derive steps and
guidance from the picked GGUF filename so a distilled variant gets its
few-step schedule. Suppress the error toast for the user's own Cancel and
disable the Video nav item on chat-only hosts with a hint, matching Train.
This commit is contained in:
Daniel Han 2026-07-05 00:28:21 +00:00
commit c8d5081e0e
4 changed files with 50 additions and 8 deletions

View file

@ -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:

View file

@ -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}"

View file

@ -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. */}
<NavItem
icon={Video01Icon}
label={t("shell.navigation.video")}
active={pathname === "/video" || pathname.startsWith("/video/")}
disabled={chatOnly}
tooltip={chatOnly ? "Video generation needs an NVIDIA or AMD GPU." : undefined}
onClick={() => {
navigate({ to: "/video" });
closeMobileIfOpen();

View file

@ -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<string | null>(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;