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:
parent
479996f85b
commit
c8d5081e0e
4 changed files with 50 additions and 8 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue