Tighten comments in the image and video frontend
This commit is contained in:
parent
ecae46cbfb
commit
0017674664
12 changed files with 311 additions and 369 deletions
|
|
@ -93,10 +93,9 @@ const CHAT_ONLY_ALLOWED = new Set([
|
|||
function isChatOnlyAllowed(pathname: string): boolean {
|
||||
if (CHAT_ONLY_ALLOWED.has(pathname)) return true;
|
||||
if (pathname === "/data-recipes" || pathname.startsWith("/data-recipes/")) return true;
|
||||
// Images runs on CPU/MPS via the native sd.cpp engine, which is exactly the
|
||||
// no-GPU (chat-only) setup it was added for. The generic chat-only flag is about
|
||||
// training/export needing a GPU, so it must not redirect /images away here or the
|
||||
// native image path is unreachable on the hosts that need it.
|
||||
// Images runs on CPU/MPS via the native sd.cpp engine, exactly the no-GPU (chat-only) setup it
|
||||
// was added for. The chat-only flag is about training/export needing a GPU, so it must not
|
||||
// redirect /images away here or the native image path is unreachable where it's needed.
|
||||
if (pathname === "/images" || pathname.startsWith("/images/")) return true;
|
||||
return false;
|
||||
}
|
||||
|
|
@ -185,12 +184,11 @@ function RootLayout() {
|
|||
setVideoMounted(true);
|
||||
}
|
||||
const shouldMountVideo = isVideoRoute || videoMounted;
|
||||
// Chat, Images and Video all render their own full-height shell (a fixed top rail + an
|
||||
// internally-scrolling body), so all three want the chat-style layout: no outer pt-14
|
||||
// inset and no outer scroll. Keying the layout off isChatRoute alone gave /images and
|
||||
// /video the non-chat pt-14 + outer overflow, pushing the picker down and clipping the
|
||||
// bottom gallery. Treat them the same for the container padding/overflow only; the
|
||||
// keep-alive mounts below stay keyed to each specific route.
|
||||
// Chat, Images and Video all render their own full-height shell (fixed top rail +
|
||||
// internally-scrolling body), so all three want the chat-style layout: no outer pt-14 inset, no
|
||||
// outer scroll. Keying off isChatRoute alone gave /images and /video the non-chat pt-14 + outer
|
||||
// overflow, pushing the picker down and clipping the gallery. Treat them the same for container
|
||||
// padding/overflow only; the keep-alive mounts below stay keyed to each route.
|
||||
const isChatLike = isChatRoute || isImagesRoute || isVideoRoute;
|
||||
|
||||
useTrainingUnloadGuard();
|
||||
|
|
|
|||
|
|
@ -376,9 +376,9 @@ assert.equal(
|
|||
);
|
||||
|
||||
// ── official BF16 artifacts (added so groups are not unsloth-quant-only) ────────
|
||||
// Qwen-Image-2512 BF16 (54 GB) does not fit a 24/48 GB budget (bnb-4bit/fp8 win
|
||||
// there, asserted above) but on an 80 GB datacenter GPU (budget 56) the official
|
||||
// BF16 is the highest-quality artifact that fits and wins.
|
||||
// Qwen-Image-2512 BF16 (54 GB) doesn't fit a 24/48 GB budget (bnb-4bit/fp8 win there, asserted
|
||||
// above) but on an 80 GB datacenter GPU (budget 56) the official BF16 is the highest-quality
|
||||
// artifact that fits and wins.
|
||||
assert.equal(
|
||||
pickDefaultArtifact(qwenGroup, { gpuGb: 80, systemRamGb: 128, isDownloaded: notDownloaded })
|
||||
.format,
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
// One canonical name per diffusion model, with its published artifacts (GGUF
|
||||
// quants, prequant FP8 / bnb-4bit repos, official BF16 pipelines) as a second
|
||||
// level, plus a deterministic router that picks the best artifact for the
|
||||
// device. Pure helpers -- no React/DOM deps so they are easy to test (see
|
||||
// model-catalog.check.ts, run via `npm run catalog:check`).
|
||||
// One canonical name per diffusion model, with its published artifacts (GGUF quants, prequant
|
||||
// FP8 / bnb-4bit repos, official BF16 pipelines) as a second level, plus a deterministic router
|
||||
// picking the best artifact for the device. Pure helpers -- no React/DOM deps, so easy to test
|
||||
// (see model-catalog.check.ts, run via `npm run catalog:check`).
|
||||
|
||||
import type { ModelOption } from "./types";
|
||||
|
||||
|
|
@ -129,9 +128,9 @@ const bf16Single = (
|
|||
});
|
||||
|
||||
// ── curated catalogs ────────────────────────────────────────────────────────────
|
||||
// Sizes are steady resident estimates (GB) used only for routing; a missing size
|
||||
// means "never auto-pick unless downloaded". GGUF entries carry no size -- the
|
||||
// quant ladder (pickDefaultQuant) sizes the individual .gguf files.
|
||||
// Sizes are steady resident estimates (GB) used only for routing; a missing size means "never
|
||||
// auto-pick unless downloaded". GGUF entries carry no size -- the quant ladder
|
||||
// (pickDefaultQuant) sizes the individual .gguf files.
|
||||
|
||||
export const IMAGE_CATALOG: CatalogGroup[] = [
|
||||
{
|
||||
|
|
@ -278,12 +277,10 @@ export const IMAGE_CATALOG: CatalogGroup[] = [
|
|||
|
||||
export const VIDEO_CATALOG: CatalogGroup[] = [
|
||||
{
|
||||
// The distilled 2.3 release: Lightricks' own bf16/fp8 single-file DiT
|
||||
// checkpoints (loaded against the LTX-2 base for the VAE / Gemma3 text
|
||||
// encoder, both repos already on the backend trust list) plus the GGUF
|
||||
// quants. The single-file checkpoints keep the ~50 GB Gemma3-27B encoder in
|
||||
// bf16, so their resident footprint is datacenter-scale; consumer GPUs route
|
||||
// to GGUF, which offloads.
|
||||
// The distilled 2.3 release: Lightricks' own bf16/fp8 single-file DiT checkpoints (loaded
|
||||
// against the LTX-2 base for the VAE / Gemma3 text encoder, both already trusted) plus the GGUF
|
||||
// quants. The single-file checkpoints keep the ~50 GB Gemma3-27B encoder in bf16, so their
|
||||
// resident footprint is datacenter-scale; consumer GPUs route to GGUF, which offloads.
|
||||
canonicalId: "unsloth/LTX-2.3",
|
||||
displayName: "LTX 2.3 distilled",
|
||||
description: "Text-to-video with audio",
|
||||
|
|
@ -327,10 +324,10 @@ export const VIDEO_CATALOG: CatalogGroup[] = [
|
|||
description: "Text-to-video",
|
||||
scope: "video",
|
||||
artifacts: [
|
||||
// Highest-quality first: pickDefaultArtifact only sorts by FORMAT, so among these two bf16
|
||||
// artifacts it keeps catalog order and the fit loop returns the FIRST that fits the budget.
|
||||
// The 720p (52 GB) must precede the 480p (40 GB) so a bare click on a GPU where 720p fits
|
||||
// (e.g. 80 GB, 0.7*budget=56) picks 720p, falling back to 480p only on smaller cards.
|
||||
// Highest-quality first: pickDefaultArtifact sorts only by FORMAT, so among these two bf16
|
||||
// artifacts it keeps catalog order and the fit loop returns the FIRST that fits. The 720p (52
|
||||
// GB) must precede the 480p (40 GB) so a bare click on a GPU where 720p fits (e.g. 80 GB,
|
||||
// 0.7*budget=56) picks 720p, falling back to 480p only on smaller cards.
|
||||
bf16Pipeline("hunyuanvideo-community/HunyuanVideo-1.5-Diffusers-720p_t2v", 52, {
|
||||
label: "BF16 - 720p",
|
||||
keywords: ["bf16", "720p"],
|
||||
|
|
|
|||
|
|
@ -357,12 +357,10 @@ function formatBytes(bytes: number): string {
|
|||
// Guard non-positive / non-finite sizes (0, missing -> NaN, Infinity) so we
|
||||
// never render "NaN undefined" or a negative unit index.
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
|
||||
// Decimal (base-1000) units to match what Hugging Face reports for a repo's
|
||||
// file sizes -- e.g. 217 GB, not the 201.8 GiB a base-1024 divide would show.
|
||||
// (GPU-fit math below stays base-1024 since VRAM is binary.)
|
||||
// Divide iteratively rather than via Math.log, which has float error at exact
|
||||
// powers of 1000 (log(1e12)/log(1000) = 3.9999... would mislabel 1 TB as
|
||||
// "1000 GB"); the loop also can't run off the end of units.
|
||||
// Decimal (base-1000) units to match HF's reported file sizes -- e.g. 217 GB, not the
|
||||
// 201.8 GiB a base-1024 divide shows. (GPU-fit math below stays base-1024 since VRAM is
|
||||
// binary.) Divide iteratively, not via Math.log, which has float error at exact powers of
|
||||
// 1000 (log(1e12)/log(1000) = 3.9999... would mislabel 1 TB as "1000 GB").
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
let i = 0;
|
||||
let value = bytes;
|
||||
|
|
@ -762,11 +760,10 @@ function GgufVariantExpander({
|
|||
|
||||
const handleVariantClick = useCallback(
|
||||
(quant: string, filename: string, downloaded?: boolean, sizeBytes?: number) => {
|
||||
// Only seed the staged context for picks whose weights are already on
|
||||
// disk. The staging effect short-circuits on a known contextLength
|
||||
// (pendingHasContext) before starting the download, so attaching it to an
|
||||
// undownloaded quant from a partially cached repo would skip the download
|
||||
// entirely (and, with Load on selection, never load).
|
||||
// Only seed the staged context for picks whose weights are already on disk. The staging
|
||||
// effect short-circuits on a known contextLength (pendingHasContext) before downloading,
|
||||
// so attaching it to an undownloaded quant from a partially cached repo would skip the
|
||||
// download (and, with Load on selection, never load).
|
||||
const isAvailable = isLocalPath || downloaded === true;
|
||||
onSelect(repoId, {
|
||||
source: sourceOverride ?? (isLocalPath ? "local" : "hub"),
|
||||
|
|
@ -1228,10 +1225,10 @@ export const IMAGE_GEN_TASKS = [
|
|||
// Video page reuses this as its picker's `task` filter, so it lives here.
|
||||
export const VIDEO_GEN_TASKS = ["text-to-video"] as const;
|
||||
|
||||
// Diffusion GGUF archs the Images backend can't assemble yet (SD/SDXL/PixArt/Wan/
|
||||
// ...). The backend tags them with this task so the chat picker hides them -- they
|
||||
// die with "unknown model architecture" in llama.cpp -- while the Images picker,
|
||||
// which filters on IMAGE_GEN_TASKS, also leaves them out (they'd 400 on load).
|
||||
// Diffusion GGUF archs the Images backend can't assemble yet (SD/SDXL/PixArt/Wan/...). The
|
||||
// backend tags them with this task so the chat picker hides them (they die with "unknown model
|
||||
// architecture" in llama.cpp), and the Images picker (filters on IMAGE_GEN_TASKS) also leaves
|
||||
// them out (they'd 400 on load).
|
||||
const UNSUPPORTED_DIFFUSION_TASK = "image-diffusion-unsupported";
|
||||
|
||||
// Tasks that must never appear as a loadable chat model: the Images- and Video-handled
|
||||
|
|
@ -1243,22 +1240,20 @@ const NON_CHAT_TASKS: readonly string[] = [
|
|||
UNSUPPORTED_DIFFUSION_TASK,
|
||||
];
|
||||
|
||||
// Editing/inpaint checkpoints are tagged image-to-image but need an input image,
|
||||
// which the text-to-image backend rejects (mirrors its _EDIT_KEYWORDS). Hidden by
|
||||
// id so they don't show in the Images picker only to 400 on load. Keeping the
|
||||
// image-to-image task itself is required: some supported models (FLUX.2-klein)
|
||||
// carry that tag too. "layered" hides Qwen-Image-Layered, which needs a dedicated
|
||||
// pipeline (additional_t_cond) the standard text-to-image path can't drive.
|
||||
// Editing/inpaint checkpoints are tagged image-to-image but need an input image the
|
||||
// text-to-image backend rejects (mirrors its _EDIT_KEYWORDS). Hidden by id so they don't show
|
||||
// in the Images picker only to 400 on load. Keeping the image-to-image task itself is required:
|
||||
// some supported models (FLUX.2-klein) carry it too. "layered" hides Qwen-Image-Layered, which
|
||||
// needs a dedicated pipeline (additional_t_cond) the standard path can't drive.
|
||||
const IMAGE_EDIT_KEYWORDS = ["edit", "kontext", "inpaint", "layered"] as const;
|
||||
// Editing families the backend now SUPPORTS (their own Edit workflow) -- must not be
|
||||
// hidden even though their id contains an edit keyword. Mirrors the backend's
|
||||
// qwen-image-edit family in diffusion_families.py.
|
||||
const SUPPORTED_EDIT_KEYWORDS = ["qwen-image-edit", "kontext"] as const;
|
||||
// Match a keyword as a whole path/name segment (bounded by a separator or a string
|
||||
// edge), not a raw substring, so "edit" does not hide ".../edited/..." or an
|
||||
// "*-edition" repo and "kontext" does not hide ".../kontextual/...". These keywords
|
||||
// are literals of [a-z-], so no regex escaping is needed. Mirrors _token_in_needle in
|
||||
// diffusion_families.py.
|
||||
// Match a keyword as a whole path/name segment (bounded by a separator or string edge), not a
|
||||
// raw substring, so "edit" doesn't hide ".../edited/..." or an "*-edition" repo and "kontext"
|
||||
// doesn't hide ".../kontextual/...". These keywords are [a-z-] literals, so no regex escaping.
|
||||
// Mirrors _token_in_needle in diffusion_families.py.
|
||||
function idHasSegment(id: string, keyword: string): boolean {
|
||||
return new RegExp(`(?:^|[-_./\\\\])${keyword}(?:$|[-_./\\\\])`).test(id);
|
||||
}
|
||||
|
|
@ -1269,10 +1264,9 @@ function isImageEditModel(repoId: string | null | undefined): boolean {
|
|||
return IMAGE_EDIT_KEYWORDS.some((kw) => idHasSegment(id, kw));
|
||||
}
|
||||
|
||||
// Gate an on-device model by the picker's task scope. With a filter (the Images
|
||||
// page) keep only matching, non-editing tasks; with no filter (chat) drop
|
||||
// image-generation models so a downloaded diffusion GGUF doesn't show up as a
|
||||
// loadable chat model.
|
||||
// Gate an on-device model by the picker's task scope. With a filter (Images page) keep only
|
||||
// matching, non-editing tasks; with no filter (chat) drop image-generation models so a
|
||||
// downloaded diffusion GGUF doesn't show up as a loadable chat model.
|
||||
function passesTaskGate(
|
||||
repoTask: string | null | undefined,
|
||||
repoId: string | null | undefined,
|
||||
|
|
@ -1583,10 +1577,9 @@ export function HubModelPicker({
|
|||
enabled: online && section === "recommended",
|
||||
});
|
||||
|
||||
// Lowercased repo ids confirmed GGUF by the store or HF search.
|
||||
// Absence means "no hint" -> hasGgufSuffix is the fallback (don't
|
||||
// conflate unknown with known-not-GGUF). Lowercased so store and HF
|
||||
// IDs differing only by casing match the same hint.
|
||||
// Lowercased repo ids confirmed GGUF by the store or HF search. Absence means "no hint" ->
|
||||
// hasGgufSuffix is the fallback (don't conflate unknown with known-not-GGUF). Lowercased so
|
||||
// store and HF ids differing only by casing match the same hint.
|
||||
const modelGgufIds = useMemo(() => {
|
||||
const ids = new Set<string>();
|
||||
for (const model of models) {
|
||||
|
|
@ -1903,10 +1896,9 @@ export function HubModelPicker({
|
|||
.then(setRecommendedFolders)
|
||||
.catch(() => {});
|
||||
|
||||
// Always refetch cached GGUF/model lists. The module-level caches render
|
||||
// instantly with stale data (no spinner flash), but newly downloaded
|
||||
// repos need a fresh backend hit. cachedReady=alreadyCached initially,
|
||||
// so the background refresh is invisible when we already had data.
|
||||
// Always refetch cached GGUF/model lists. The module-level caches render instantly with
|
||||
// stale data (no spinner flash), but newly downloaded repos need a fresh backend hit.
|
||||
// cachedReady=alreadyCached initially, so the background refresh is invisible when we had data.
|
||||
let done = 0;
|
||||
const check = () => {
|
||||
if (++done >= 2) setCachedReady(true);
|
||||
|
|
@ -1943,11 +1935,10 @@ export function HubModelPicker({
|
|||
const deviceType = usePlatformStore((s) => s.deviceType);
|
||||
const isMac = deviceType === "mac";
|
||||
|
||||
// Drop models Studio can't run for chat (diffusion / image / video / etc.)
|
||||
// using the Hub's classifier on the tags the listing already carries. When the
|
||||
// picker is scoped to a task (e.g. the Images page asks for text-to-image),
|
||||
// models matching that task are exactly what we want — keep them even though
|
||||
// the chat classifier marks image tasks "unsupported".
|
||||
// Drop models Studio can't run for chat (diffusion / image / video / etc.) using the Hub's
|
||||
// classifier on the tags the listing carries. When the picker is task-scoped (e.g. Images asks
|
||||
// for text-to-image), models matching that task are what we want -- keep them even though the
|
||||
// chat classifier marks image tasks "unsupported".
|
||||
const isChatSupported = useCallback(
|
||||
(r: HfModelResult) => {
|
||||
// Image tab (task set): only task-matching, non-editing results. Anything
|
||||
|
|
@ -2041,12 +2032,11 @@ export function HubModelPicker({
|
|||
catalog,
|
||||
]);
|
||||
|
||||
// Curated non-GGUF (safetensors) models for the Images picker. The HF listing +
|
||||
// Recommended gate only surface GGUF on a GPU host (isRecommendableFormat), so a
|
||||
// bnb-4bit / fp8 safetensors model would never appear there. These curated entries
|
||||
// (the non-GGUF ModelOptions passed in) are shown explicitly above the GGUF rows so
|
||||
// the user can pick a full diffusers pipeline. Only the Images picker (task set)
|
||||
// curates them; already-downloaded ones show under Downloaded instead.
|
||||
// Curated non-GGUF (safetensors) models for the Images picker. The HF listing + Recommended
|
||||
// gate only surface GGUF on a GPU host (isRecommendableFormat), so a bnb-4bit / fp8 safetensors
|
||||
// model would never appear. These curated entries (the non-GGUF ModelOptions passed in) are
|
||||
// shown above the GGUF rows so the user can pick a full diffusers pipeline. Only the Images
|
||||
// picker (task set) curates them; already-downloaded ones show under Downloaded instead.
|
||||
const curatedSafetensorsRows = useMemo(() => {
|
||||
if (!task) return [];
|
||||
// Always list the curated safetensors (bnb-4bit / fp8) diffusion models. They
|
||||
|
|
@ -2074,23 +2064,21 @@ export function HubModelPicker({
|
|||
[downloadedSet],
|
||||
);
|
||||
const deviceBudget = useMemo(
|
||||
// Largest single device, NOT the multi-GPU sum: the diffusion/video
|
||||
// backends place the whole pipeline on one device (pipe.to / cpu-offload,
|
||||
// never device_map), so summed VRAM would pass groups no single card can
|
||||
// hold (e.g. a 114 GB group "fits" a 4x24 GB host) and a bare group click
|
||||
// would OOM -- the exact load the fit toggle exists to prevent.
|
||||
// Largest single device, NOT the multi-GPU sum: the diffusion/video backends place the whole
|
||||
// pipeline on one device (pipe.to / cpu-offload, never device_map), so summed VRAM would pass
|
||||
// groups no single card can hold (e.g. a 114 GB group "fits" a 4x24 GB host) and a bare group
|
||||
// click would OOM -- the load the fit toggle exists to prevent.
|
||||
() => ({
|
||||
gpuGb: gpu.available ? gpu.maxDeviceMemoryGb : 0,
|
||||
systemRamGb: gpu.systemRamAvailableGb || 0,
|
||||
}),
|
||||
[gpu],
|
||||
);
|
||||
// Variant expanders and format lists follow the same single-device budget as
|
||||
// deviceBudget when this picker is task-scoped (Images/Video): the diffusion
|
||||
// and video loaders place the whole pipeline on one device, so sorting and
|
||||
// recommending quants against the summed multi-GPU total would mark variants
|
||||
// as fitting that OOM at load. Chat pickers keep the summed total, where
|
||||
// llama.cpp can split layers across devices.
|
||||
// Variant expanders and format lists follow the same single-device budget as deviceBudget when
|
||||
// task-scoped (Images/Video): the diffusion and video loaders place the whole pipeline on one
|
||||
// device, so sorting/recommending quants against the summed multi-GPU total would mark variants
|
||||
// as fitting that OOM at load. Chat pickers keep the summed total, where llama.cpp splits
|
||||
// layers across devices.
|
||||
const expanderGpuGb = gpu.available
|
||||
? task
|
||||
? gpu.maxDeviceMemoryGb
|
||||
|
|
@ -2098,12 +2086,11 @@ export function HubModelPicker({
|
|||
: undefined;
|
||||
const routedArtifactFor = useCallback(
|
||||
(group: CatalogGroup): ModelArtifact => {
|
||||
// Honor the format filter when routing a bare group click. A group is only
|
||||
// visible under a GGUF/Safetensors/MLX filter because at least one of its
|
||||
// artifacts matches (catalogGroupMatchesFormat), so restrict the routing
|
||||
// candidates to those same artifacts before the ladder picks. Otherwise a
|
||||
// GGUF-filtered group could route to a large BF16/FP8 download the filter
|
||||
// never surfaced (pickDefaultArtifact prefers a fitting non-GGUF).
|
||||
// Honor the format filter when routing a bare group click. A group is visible under a
|
||||
// GGUF/Safetensors/MLX filter only because at least one artifact matches
|
||||
// (catalogGroupMatchesFormat), so restrict the routing candidates to those artifacts before
|
||||
// the ladder picks. Otherwise a GGUF-filtered group could route to a large BF16/FP8 download
|
||||
// the filter never surfaced (pickDefaultArtifact prefers a fitting non-GGUF).
|
||||
const scoped =
|
||||
formatFilter === "all"
|
||||
? group
|
||||
|
|
@ -2131,11 +2118,10 @@ export function HubModelPicker({
|
|||
});
|
||||
return;
|
||||
}
|
||||
// GGUF route: resolve the quant list, then load the ladder's pick. A
|
||||
// failed fetch falls back to opening the format list instead -- toggle the
|
||||
// caller's CONTEXT-SCOPED expandKey (not the context-free canonicalId), so the
|
||||
// chevron (which toggles expandKey) can still collapse it and the same group in
|
||||
// another list is not expanded too.
|
||||
// GGUF route: resolve the quant list, then load the ladder's pick. A failed fetch falls back
|
||||
// to opening the format list -- toggle the caller's CONTEXT-SCOPED expandKey (not the
|
||||
// context-free canonicalId), so the chevron can still collapse it and the same group in another
|
||||
// list isn't expanded too.
|
||||
setRoutingGroupId(group.canonicalId);
|
||||
try {
|
||||
const res = normalizeGgufVariantsResponse(
|
||||
|
|
@ -2269,10 +2255,10 @@ export function HubModelPicker({
|
|||
),
|
||||
[cachedGguf, downloadedSort, loadTimes, task],
|
||||
);
|
||||
// Cached non-GGUF repos. In chat, passesTaskGate drops diffusers image repos. In the
|
||||
// Images picker (task set) it keeps them, but limit to repos this backend can actually
|
||||
// load as diffusion: unsloth-hosted ones. Base repos (Qwen/Qwen-Image, FLUX bases) are
|
||||
// cached as dependencies and fail the diffusion trust gate, so listing them would dead-end.
|
||||
// Cached non-GGUF repos. In chat, passesTaskGate drops diffusers image repos. In the Images
|
||||
// picker (task set) it keeps them, but limit to repos this backend can load as diffusion:
|
||||
// unsloth-hosted ones. Base repos (Qwen/Qwen-Image, FLUX bases) are cached as dependencies and
|
||||
// fail the trust gate, so listing them would dead-end.
|
||||
const sortedCachedModels = useMemo(
|
||||
() =>
|
||||
sortCachedRepos(
|
||||
|
|
@ -2282,14 +2268,13 @@ export function HubModelPicker({
|
|||
// errors or triggers a silent multi-GB re-fetch on click (mirrors downloadedSet).
|
||||
!c.partial &&
|
||||
passesTaskGate(c.task, c.repo_id, task) &&
|
||||
// Diffusion pickers: unsloth repos plus any repo the backend can actually LOAD.
|
||||
// Gate on a curated ARTIFACT (artifactForRepoId, what loadSpecFor resolves), not a
|
||||
// group-key match: a base / uncurated-quant sibling (Qwen/Qwen-Image-2512) matches
|
||||
// the group by key but has no loadable artifact and dead-ends at the trust gate.
|
||||
// An unsloth repo must also be a full pipeline (not single_file): the selection
|
||||
// fall-through loads uncataloged rows as kind "pipeline", and from_pretrained on
|
||||
// a single-file checkpoint repo (no model_index.json) fails after the handoff.
|
||||
// Curated single-file artifacts stay: loadSpecFor carries their filename.
|
||||
// Diffusion pickers: unsloth repos plus any repo the backend can LOAD. Gate on a curated
|
||||
// ARTIFACT (artifactForRepoId, what loadSpecFor resolves), not a group-key match: a base /
|
||||
// uncurated-quant sibling (Qwen/Qwen-Image-2512) matches the group by key but has no loadable
|
||||
// artifact and dead-ends at the trust gate. An unsloth repo must also be a full pipeline (not
|
||||
// single_file): the selection fall-through loads uncataloged rows as "pipeline", and
|
||||
// from_pretrained on a single-file checkpoint repo (no model_index.json) fails after the
|
||||
// handoff. Curated single-file artifacts stay: loadSpecFor carries their filename.
|
||||
(!task ||
|
||||
(isUnslothRepoId(c.repo_id) && !c.single_file) ||
|
||||
(catalog ? artifactForRepoId(c.repo_id, catalog) !== null : false)),
|
||||
|
|
@ -2324,11 +2309,10 @@ export function HubModelPicker({
|
|||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[lmStudioModels, downloadedSort, formatFilter, loadTimes, localQuery, task],
|
||||
);
|
||||
// Local ./models entries. Chat-only Studio runs GGUF (any host) and MLX (Mac
|
||||
// only), so raw checkpoints there are hidden (mirrors the cached non-GGUF
|
||||
// rule). An MLX build a Mac user dropped in ./models stays selectable. A
|
||||
// task-scoped picker (Images) is exempt: the image backend loads local
|
||||
// diffusers/safetensors pipelines even on chat-only (no-GPU, native) hosts.
|
||||
// Local ./models entries. Chat-only Studio runs GGUF (any host) and MLX (Mac only), so raw
|
||||
// checkpoints there are hidden (mirrors the cached non-GGUF rule). An MLX build dropped in
|
||||
// ./models stays selectable. A task-scoped picker (Images) is exempt: the image backend loads
|
||||
// local diffusers/safetensors pipelines even on chat-only (no-GPU, native) hosts.
|
||||
const sortedLocalDir = useMemo(
|
||||
() =>
|
||||
sortLocalModels(
|
||||
|
|
@ -2582,12 +2566,11 @@ export function HubModelPicker({
|
|||
const hubOptionKeys = useMemo(() => {
|
||||
const keys: string[] = [];
|
||||
|
||||
// Roving keys for an On Device cached section, mirroring renderCachedRows'
|
||||
// render order exactly so arrow/Home/End nav matches the visual order.
|
||||
// Without a catalog the rows are flat; with one, catalog members collapse
|
||||
// under a canonical group row (whose key must lead), and the per-repo child
|
||||
// rows only render (and only join the roving list) while the group is
|
||||
// expanded. Ungrouped GGUF rows then ungrouped model rows follow.
|
||||
// Roving keys for an On Device cached section, mirroring renderCachedRows' render order so
|
||||
// arrow/Home/End nav matches the visual order. Without a catalog the rows are flat; with one,
|
||||
// catalog members collapse under a canonical group row (whose key must lead), and the per-repo
|
||||
// child rows render (and join the roving list) only while the group is expanded. Ungrouped GGUF
|
||||
// then ungrouped model rows follow.
|
||||
const groupedCachedKeys = (
|
||||
ggufRows: { repo_id: string }[],
|
||||
modelRows: { repo_id: string }[],
|
||||
|
|
@ -2721,12 +2704,10 @@ export function HubModelPicker({
|
|||
}
|
||||
|
||||
if (section === "recommended") {
|
||||
// Curated rows render ABOVE the recommended rows (and call getOptionProps),
|
||||
// so their keys must lead here or they fall back to the duplicate
|
||||
// ...-option-missing id and drop out of arrow-key navigation. With a
|
||||
// catalog (Images / Video) those are the canonical catalog-group rows,
|
||||
// gated by the same format filter as the render; without one they are the
|
||||
// flat curated safetensors rows.
|
||||
// Curated rows render ABOVE the recommended rows (and call getOptionProps), so their keys must
|
||||
// lead here or they fall back to the duplicate ...-option-missing id and drop out of arrow-key
|
||||
// nav. With a catalog (Images / Video) those are the canonical catalog-group rows, gated by the
|
||||
// same format filter as the render; without one they are the flat curated safetensors rows.
|
||||
if (catalog) {
|
||||
keys.push(
|
||||
...recommendedCatalogGroups.map((g) =>
|
||||
|
|
@ -2935,11 +2916,10 @@ export function HubModelPicker({
|
|||
// non-empty Fine-tuned section.
|
||||
fineTunedRows.length === 0;
|
||||
|
||||
// Sort dropdown shown inline to the right of the section toggle. Options
|
||||
// depend on the tab and stay visible while searching so results can be
|
||||
// sorted. Fixed width matching the Search Hub button so it and the format
|
||||
// dropdown always line up; text-xs matches that button too. The trigger label
|
||||
// clips (no ellipsis) when long; the open menu expands to show it in full.
|
||||
// Sort dropdown inline to the right of the section toggle. Options depend on the tab and stay
|
||||
// visible while searching so results can be sorted. Fixed width matching the Search Hub button
|
||||
// so it and the format dropdown line up; text-xs matches too. The trigger label clips (no
|
||||
// ellipsis) when long; the open menu expands to show it in full.
|
||||
const sortTriggerClassName =
|
||||
"w-[110px] shrink-0 justify-between pr-2.5 !border-0 text-xs [&>span]:!text-clip";
|
||||
// Tighter menu like the Projects activity Select: less left/top padding and
|
||||
|
|
@ -3308,13 +3288,12 @@ export function HubModelPicker({
|
|||
selected={selected}
|
||||
optionProps={hubModelList.getOptionProps(optionKey, selected)}
|
||||
onClick={() => {
|
||||
// routeGroupClick picks the best CURATED artifact; when one is on
|
||||
// disk pickDefaultArtifact returns it, so keep that path. But this
|
||||
// group can appear in On Device solely because a cached member
|
||||
// matched by key/alias (a sibling prequant that is not a curated
|
||||
// artifact). In that case the routed artifact is NOT downloaded, so
|
||||
// load an actual on-disk member instead of downloading a different
|
||||
// artifact -- the On Device row must "load the best on-disk artifact".
|
||||
// routeGroupClick picks the best CURATED artifact; when one is on disk
|
||||
// pickDefaultArtifact returns it, so keep that path. But this group can appear in On
|
||||
// Device solely because a cached member matched by key/alias (a sibling prequant that is
|
||||
// not a curated artifact). Then the routed artifact is NOT downloaded, so load an actual
|
||||
// on-disk member instead of downloading a different one -- the On Device row must "load
|
||||
// the best on-disk artifact".
|
||||
if (!isRepoDownloaded(routedArtifactFor(group).repoId)) {
|
||||
const cachedModel = rows.models[0];
|
||||
if (cachedModel) {
|
||||
|
|
|
|||
|
|
@ -785,9 +785,8 @@ export async function browseFolders(
|
|||
if (path !== undefined && path !== null) params.set("path", path);
|
||||
if (showHidden) params.set("show_hidden", "true");
|
||||
const qs = params.toString();
|
||||
// Forward the AbortSignal through authFetch -> fetch so a cancelled
|
||||
// FolderBrowser navigation actually cancels the in-flight request
|
||||
// server-side, instead of just dropping the response while the backend
|
||||
// Forward the AbortSignal through authFetch -> fetch so a cancelled FolderBrowser navigation
|
||||
// cancels the in-flight request server-side, instead of dropping the response while the backend
|
||||
// keeps walking large directory trees.
|
||||
const response = await authFetch(
|
||||
`/api/models/browse-folders${qs ? `?${qs}` : ""}`,
|
||||
|
|
@ -948,10 +947,9 @@ export async function* streamChatCompletions(
|
|||
}
|
||||
}
|
||||
} finally {
|
||||
// Only abort on an early/abnormal exit. After a natural [DONE] (or server
|
||||
// EOF) the request is logically complete and the backend finalizes its
|
||||
// api-monitor entry right after the sentinel; cancelling here can be seen as
|
||||
// a disconnect and mark a successful request as cancelled.
|
||||
// Only abort on an early/abnormal exit. After a natural [DONE] (or server EOF) the request is
|
||||
// logically complete and the backend finalizes its api-monitor entry right after the sentinel;
|
||||
// cancelling here can look like a disconnect and mark a successful request as cancelled.
|
||||
if (!completed) {
|
||||
try {
|
||||
await reader.cancel();
|
||||
|
|
|
|||
|
|
@ -4,10 +4,9 @@
|
|||
import { authFetch } from "@/features/auth";
|
||||
import { readFastApiError } from "@/lib/format-fastapi-error";
|
||||
|
||||
// One Advanced control's resolved value + provenance, for the "Auto: X" badges. `value`
|
||||
// is the engaged value (a scheme/mode string, null when off, or a boolean for cpu_offload);
|
||||
// `source` is "auto" (this backend decided) or "explicit" (the caller set it); `reason` is
|
||||
// the short why shown as a tooltip.
|
||||
// One Advanced control's resolved value + provenance, for the "Auto: X" badges. `value` is the
|
||||
// engaged value (scheme/mode string, null when off, or a boolean for cpu_offload); `source` is
|
||||
// "auto" (this backend decided) or "explicit" (the caller set it); `reason` is the tooltip why.
|
||||
export interface DiffusionResolvedControl {
|
||||
value: string | boolean | null;
|
||||
source: "auto" | "explicit";
|
||||
|
|
@ -35,9 +34,8 @@ export interface DiffusionStatus {
|
|||
// state). Diffusers only, for families with a ControlNet pipeline; false otherwise.
|
||||
supports_controlnet?: boolean;
|
||||
// Per-Advanced-control provenance, keyed by control name (speed_mode, transformer_quant,
|
||||
// attention_backend, memory_mode, transformer_cache, cpu_offload). Present only when a
|
||||
// model is loaded on a backend that records it; the "Auto: X" badges read it. Absent on
|
||||
// older backends.
|
||||
// attention_backend, memory_mode, transformer_cache, cpu_offload). Present only when a model is
|
||||
// loaded on a backend that records it (the "Auto: X" badges read it); absent on older backends.
|
||||
resolved?: Record<string, DiffusionResolvedControl> | null;
|
||||
}
|
||||
|
||||
|
|
@ -520,9 +518,9 @@ export async function uploadDiffusionDataset(
|
|||
}
|
||||
|
||||
// ── Dataset labeling + example imports (GET/PUT/DELETE .../dataset/{name}/...) ──
|
||||
// One image in a training dataset folder, with its resolved caption. `caption_source`
|
||||
// records where the caption came from ("metadata" beats a per-image "sidecar"; "none"
|
||||
// when uncaptioned) so the labeling grid can highlight images that still need one.
|
||||
// One image in a training dataset folder, with its resolved caption. `caption_source` records
|
||||
// where it came from ("metadata" beats a per-image "sidecar"; "none" when uncaptioned) so the
|
||||
// labeling grid can highlight images that still need one.
|
||||
export interface DiffusionDatasetImageRecord {
|
||||
filename: string;
|
||||
caption: string | null;
|
||||
|
|
|
|||
|
|
@ -82,11 +82,10 @@ import {
|
|||
} from "./api";
|
||||
import { DiffusionTrainPanel } from "./train/diffusion-train-panel";
|
||||
|
||||
// Curated models come from the shared catalog: one canonical group per model,
|
||||
// its artifacts (GGUF / FP8 / bnb-4bit / BF16) as data, and the load kind per
|
||||
// artifact via loadSpecFor (replacing the old SAFETENSORS_MODELS table). The
|
||||
// picker renders groups with a format second level and routes bare clicks to
|
||||
// the best artifact for the device.
|
||||
// Curated models come from the shared catalog: one canonical group per model, its artifacts
|
||||
// (GGUF / FP8 / bnb-4bit / BF16) as data, and the load kind per artifact via loadSpecFor
|
||||
// (replacing the old SAFETENSORS_MODELS table). The picker renders groups with a format
|
||||
// second level and routes bare clicks to the best artifact for the device.
|
||||
const MODELS: ModelOption[] = catalogToModelOptions(IMAGE_CATALOG);
|
||||
|
||||
// Workflow tabs. `requires` is the backend workflow id (status.workflows) that must
|
||||
|
|
@ -429,10 +428,9 @@ function SliderField({
|
|||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
// The slider is the primary control, so the native number spinners are
|
||||
// redundant — and on this narrow field their up/down arrows overlapped and
|
||||
// covered the value. Remove them on every engine: appearance:textfield for
|
||||
// Firefox, and zero out the webkit inner/outer spin buttons.
|
||||
// The slider is the primary control, so the native number spinners are redundant --
|
||||
// and on this narrow field their arrows overlapped the value. Remove them on every
|
||||
// engine: appearance:textfield for Firefox, zeroed webkit inner/outer spin buttons.
|
||||
className="w-14 text-right font-mono text-xs font-medium bg-muted/50 border border-border rounded-lg px-1.5 py-0.5 focus:outline-none focus:ring-1 focus:ring-primary/30 [appearance:textfield] [&::-webkit-outer-spin-button]:m-0 [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:m-0 [&::-webkit-inner-spin-button]:appearance-none"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -627,12 +625,11 @@ function ImageDropzone({
|
|||
);
|
||||
}
|
||||
|
||||
// A brush-based mask editor for inpainting. Shows the source image with a paintable
|
||||
// overlay and exports a grayscale PNG mask at the image's NATIVE resolution, following
|
||||
// the diffusers inpaint convention (white = repaint, black = keep). Strokes are drawn to
|
||||
// both a visible tinted overlay (feedback) and an offscreen mask canvas kept in lockstep,
|
||||
// so the exported mask always matches what the user sees. `brushPct` sizes the brush as a
|
||||
// fraction of the image's shorter side, so it stays consistent across resolutions.
|
||||
// A brush-based mask editor for inpainting. Shows the source image with a paintable overlay
|
||||
// and exports a grayscale PNG mask at the image's NATIVE resolution (diffusers convention:
|
||||
// white = repaint, black = keep). Strokes draw to both a visible tinted overlay (feedback)
|
||||
// and an offscreen mask canvas in lockstep, so the exported mask matches what the user sees.
|
||||
// `brushPct` sizes the brush as a fraction of the shorter side, so it's resolution-consistent.
|
||||
function MaskCanvas({
|
||||
image,
|
||||
brushPct,
|
||||
|
|
@ -782,10 +779,10 @@ function loadImage(src: string): Promise<HTMLImageElement> {
|
|||
// Which sides to grow when outpainting.
|
||||
type ExtendSides = { left: boolean; right: boolean; top: boolean; bottom: boolean };
|
||||
|
||||
// Build the (image, mask) pair for outpaint by reusing the inpaint backend: grow the
|
||||
// canvas by `pct` of each dimension on the selected sides, edge-bleed the original pixels
|
||||
// into the new bands (so the VAE encodes plausible content), and mask the new bands white
|
||||
// (= repaint) with a small overlap into the original on each grown side so the seam blends.
|
||||
// Build the (image, mask) pair for outpaint by reusing the inpaint backend: grow the canvas
|
||||
// by `pct` per dimension on the selected sides, edge-bleed the original pixels into the new
|
||||
// bands (so the VAE encodes plausible content), and mask the new bands white (= repaint) with
|
||||
// a small overlap into the original on each grown side so the seam blends.
|
||||
async function buildOutpaint(
|
||||
src: string,
|
||||
sides: ExtendSides,
|
||||
|
|
@ -834,10 +831,10 @@ async function buildOutpaint(
|
|||
mctx.fillStyle = "#000000"; // ...except the kept original (inset by the seam overlap).
|
||||
mctx.fillRect(l + ol, t + ot, w - ol - or, h - ot - ob);
|
||||
|
||||
// The grown canvas can exceed the backend's 4096px-per-side decode limit (e.g. a
|
||||
// 2048px source at 100% on both sides -> 6144px), which would 400 the load. Scale the
|
||||
// built pair down proportionally to fit, so Extend still returns an outpaint instead
|
||||
// of failing. The backend also rounds to /16, so exact dims here are not required.
|
||||
// The grown canvas can exceed the backend's 4096px-per-side decode limit (e.g. a 2048px
|
||||
// source at 100% on both sides -> 6144px), which would 400 the load. Scale the built pair
|
||||
// down proportionally to fit so Extend still returns an outpaint. The backend rounds to /16,
|
||||
// so exact dims aren't required.
|
||||
const MAX_SIDE = 4096;
|
||||
const longest = Math.max(nw, nh);
|
||||
if (longest > MAX_SIDE) {
|
||||
|
|
@ -1035,10 +1032,9 @@ export function ImagesPage({ active = true }: { active?: boolean }) {
|
|||
null,
|
||||
);
|
||||
// Render-safe mirror of "lastLoad.current was set by a user-initiated load": a resident
|
||||
// GGUF/single_file model discovered by refresh carries no checkpoint filename in status,
|
||||
// so lastLoad stays null for it and Reapply would be a dead control. Set only from event
|
||||
// handlers; the resident-pipeline case (which the effect below CAN wire) is derived from
|
||||
// status at render time instead (mirrors the video page's canReapply).
|
||||
// GGUF/single_file model discovered by refresh carries no checkpoint filename in status, so
|
||||
// lastLoad stays null and Reapply would be dead. Set only from event handlers; the
|
||||
// resident-pipeline case is derived from status at render time (mirrors the video page).
|
||||
const [canReapply, setCanReapply] = useState(false);
|
||||
// Repo id whose defaults we've already seeded from a discovered resident model, so
|
||||
// we seed the sliders once per resident model and never clobber a later manual edit.
|
||||
|
|
@ -1081,11 +1077,10 @@ export function ImagesPage({ active = true }: { active?: boolean }) {
|
|||
const loadToastId = useRef<string | number | null>(null);
|
||||
// Last load-progress signature shown, so a tick that moved nothing skips the toast.
|
||||
const lastLoadSig = useRef<string | null>(null);
|
||||
// The quant to restore if the current optimistic swap fails. A same-repo quant
|
||||
// change sets `quant` immediately for picker feedback; if the load then fails
|
||||
// AFTER starting (an error/eviction during download), the old pipeline stays
|
||||
// loaded, so the poll must roll the label back rather than advertise the failed
|
||||
// quant. `{ prev }` distinguishes "revert to null" from "nothing pending".
|
||||
// The quant to restore if the optimistic swap fails. A same-repo quant change sets `quant`
|
||||
// immediately for picker feedback; if the load then fails AFTER starting (error/eviction
|
||||
// during download) the old pipeline stays loaded, so the poll rolls the label back rather
|
||||
// than advertise the failed quant. `{ prev }` distinguishes "revert to null" from "nothing pending".
|
||||
const quantRevert = useRef<{ prev: string | null } | null>(null);
|
||||
// A trained adapter awaiting deployment: after Deploy loads the base, the LoRA discovery
|
||||
// effect applies this once the model is loaded + LoRA-capable for the matching family.
|
||||
|
|
@ -1104,15 +1099,13 @@ export function ImagesPage({ active = true }: { active?: boolean }) {
|
|||
galleryCache.quant = quant;
|
||||
}, [images, hasMore, selectedId, quant]);
|
||||
|
||||
// Refresh the LoRA picker's suggestions when the loaded model (family) changes. A LoRA is
|
||||
// trained for a specific base family, so a real model SWAP invalidates the current selection
|
||||
// -- clear it then (the user re-adds a suggestion or types a Hub repo id for the new family).
|
||||
// But do NOT clear on the first load or an unload: a user can restore a saved recipe (which
|
||||
// sets loras) BEFORE the model finishes loading, and clearing on that load->capable
|
||||
// transition would silently drop the restored adapters. We track the previously-loaded
|
||||
// family in a ref and clear only when it changes to a different loaded family. We also do
|
||||
// NOT filter the selection against the discovered catalog: a valid pick can be a free-text
|
||||
// Hugging Face repo id that is not in the (often empty) curated list.
|
||||
// Refresh the LoRA picker's suggestions when the loaded family changes. A LoRA is trained
|
||||
// for a specific base family, so a real model SWAP invalidates the selection -- clear it then.
|
||||
// But do NOT clear on the first load or an unload: a user can restore a saved recipe (setting
|
||||
// loras) BEFORE the model finishes loading, and clearing on that load->capable transition
|
||||
// would drop the restored adapters. We track the previous family in a ref and clear only on a
|
||||
// change to a different loaded family. We also do NOT filter the selection against the catalog:
|
||||
// a valid pick can be a free-text HF repo id absent from the (often empty) curated list.
|
||||
const loraCapable = Boolean(status?.loaded && status?.supports_lora);
|
||||
const prevLoraFamilyRef = useRef<string | null | undefined>(undefined);
|
||||
useEffect(() => {
|
||||
|
|
@ -1150,12 +1143,11 @@ export function ImagesPage({ active = true }: { active?: boolean }) {
|
|||
if (!cancelled) setAvailableLoras(list);
|
||||
})
|
||||
.catch(() => {
|
||||
// Clear only the OPTIONS on a failed catalog refresh. Unlike the catalog-only
|
||||
// picker below the stack, this free-text picker holds selections (bare HF repo
|
||||
// ids) that are valid without being in the catalog; a transient refresh failure
|
||||
// must not wipe them. Stale cross-family selections are already cleared by the
|
||||
// family-swap check above, and hidden LoRAs are never sent (handleGenerate is
|
||||
// gated on loraCapable).
|
||||
// Clear only the OPTIONS on a failed catalog refresh. Unlike the catalog-only picker
|
||||
// below, this free-text picker holds selections (bare HF repo ids) valid without being
|
||||
// in the catalog; a transient refresh failure must not wipe them. Stale cross-family
|
||||
// selections are already cleared by the family-swap check above, and hidden LoRAs are
|
||||
// never sent (handleGenerate is gated on loraCapable).
|
||||
if (!cancelled) setAvailableLoras([]);
|
||||
});
|
||||
return () => {
|
||||
|
|
@ -1314,10 +1306,10 @@ export function ImagesPage({ active = true }: { active?: boolean }) {
|
|||
const m = matchAspect(image.width, image.height);
|
||||
setAspect(m.key);
|
||||
setPortrait(m.portrait);
|
||||
// Restore selected LoRA adapters from the recipe ("id:weight" strings); split on the
|
||||
// LAST colon so an id that itself contains ':' is preserved. Unparseable entries are
|
||||
// skipped, and a recipe with no LoRAs clears the current selection so the restore
|
||||
// reproduces the image faithfully rather than leaking a stale form selection.
|
||||
// Restore selected LoRA adapters from the recipe ("id:weight" strings); split on the LAST
|
||||
// colon so an id containing ':' is preserved. Unparseable entries are skipped, and a recipe
|
||||
// with no LoRAs clears the selection so the restore reproduces the image faithfully rather
|
||||
// than leaking a stale form selection.
|
||||
const restoredLoras: LoraSpecInput[] = [];
|
||||
for (const entry of image.loras ?? []) {
|
||||
const idx = entry.lastIndexOf(":");
|
||||
|
|
@ -1369,11 +1361,10 @@ export function ImagesPage({ active = true }: { active?: boolean }) {
|
|||
}
|
||||
}, []);
|
||||
|
||||
// Track mount so a long generate run stops issuing GPU work when the page is
|
||||
// truly unmounted (app close / chat-only eject). The page now stays mounted
|
||||
// across tab switches (RootLayout keeps it alive like chat), so a switch no
|
||||
// longer breaks the loop -- a batch keeps generating off-tab. The mount-time
|
||||
// refreshStatus and timer/toast cleanup live in the load-resume effect below,
|
||||
// Track mount so a long generate run stops issuing GPU work only on a true unmount (app
|
||||
// close / chat-only eject). The page stays mounted across tab switches (RootLayout keeps it
|
||||
// alive like chat), so a switch doesn't break the loop -- a batch keeps generating off-tab.
|
||||
// The mount-time refreshStatus and timer/toast cleanup live in the load-resume effect below,
|
||||
// so this one carries only the mount flag.
|
||||
useEffect(() => {
|
||||
isMounted.current = true;
|
||||
|
|
@ -1392,10 +1383,9 @@ export function ImagesPage({ active = true }: { active?: boolean }) {
|
|||
})();
|
||||
}, [active, refreshStatus]);
|
||||
|
||||
// Collapse the body-ported popovers when leaving the tab. Their open state is
|
||||
// controlled and force-closed via `active && open` while off-tab, but the
|
||||
// underlying flag stays set, so returning to /images would otherwise pop them
|
||||
// back open unprompted. Reset it so the page comes back in a neutral state.
|
||||
// Collapse the body-ported popovers when leaving the tab. Their open state is controlled
|
||||
// and force-closed via `active && open` while off-tab, but the underlying flag stays set, so
|
||||
// returning to /images would pop them back open. Reset it so the page returns neutral.
|
||||
useEffect(() => {
|
||||
if (active) return;
|
||||
setSelectorOpen(false);
|
||||
|
|
@ -1434,10 +1424,9 @@ export function ImagesPage({ active = true }: { active?: boolean }) {
|
|||
return;
|
||||
}
|
||||
if (p.phase === null) {
|
||||
// No load in flight and nothing loaded: the load was cancelled or
|
||||
// evicted (e.g. a chat load took the GPU) and the backend cleared its
|
||||
// state. Terminal — otherwise this loop would spin forever and leave
|
||||
// busy stuck on "loading", deadening the picker and Generate button.
|
||||
// No load in flight and nothing loaded: the load was cancelled or evicted (e.g. a chat
|
||||
// load took the GPU) and the backend cleared its state. Terminal -- else this loop
|
||||
// spins forever and leaves busy stuck on "loading", deadening the picker and Generate.
|
||||
dismissLoadToast();
|
||||
setBusy(null);
|
||||
// Same optimistic-quant rollback as the error path: the swap did not take.
|
||||
|
|
@ -1464,10 +1453,9 @@ export function ImagesPage({ active = true }: { active?: boolean }) {
|
|||
useEffect(() => {
|
||||
void (async () => {
|
||||
await refreshStatus();
|
||||
// A load runs on the backend as a daemon thread that survives navigation.
|
||||
// On (re)mount, resume tracking one that's still in flight so the page
|
||||
// shows progress and updates on completion, instead of a stale view that
|
||||
// never polls (no toast, and no refresh when the load finishes).
|
||||
// A load runs on the backend as a daemon thread that survives navigation. On (re)mount,
|
||||
// resume tracking one still in flight so the page shows progress and updates on
|
||||
// completion, instead of a stale view that never polls.
|
||||
try {
|
||||
const p = await getDiffusionLoadProgress();
|
||||
if (p.phase === "downloading" || p.phase === "finalizing") {
|
||||
|
|
@ -1495,14 +1483,13 @@ export function ImagesPage({ active = true }: { active?: boolean }) {
|
|||
};
|
||||
}, [refreshStatus, dismissLoadToast, pollLoadProgress]);
|
||||
|
||||
// Seed the generation sliders to a resident model's recipe when the page discovers
|
||||
// one it did not load itself -- a model left loaded by a prior session or another
|
||||
// route. refreshStatus() only sets `status`; without this the sliders keep the
|
||||
// unrecognised-model fallback (few-step / no-CFG), so a resident full model (e.g.
|
||||
// flux.1-dev) would generate at 9 steps / guidance 0 and produce garbage until the
|
||||
// user re-picked it. Guarded by lastLoad.current === null (a user-initiated load
|
||||
// already seeds its own defaults via handleModelSelect) and a per-repo ref, so a
|
||||
// manual slider edit after the one-shot seed is never clobbered.
|
||||
// Seed the generation sliders to a resident model's recipe when the page discovers one it
|
||||
// did not load itself (left loaded by a prior session or another route). refreshStatus() only
|
||||
// sets `status`; without this the sliders keep the unrecognised-model fallback (few-step /
|
||||
// no-CFG), so a resident full model (e.g. flux.1-dev) generates at 9 steps / guidance 0 and
|
||||
// produces garbage until re-picked. Guarded by lastLoad.current === null (a user-initiated
|
||||
// load seeds its own defaults) and a per-repo ref, so a manual edit after the seed is never
|
||||
// clobbered.
|
||||
useEffect(() => {
|
||||
const repoId = status?.loaded ? status.repo_id : null;
|
||||
if (!repoId) return;
|
||||
|
|
@ -1512,12 +1499,11 @@ export function ImagesPage({ active = true }: { active?: boolean }) {
|
|||
const d = defaultsFor(repoId);
|
||||
setSteps(d.steps);
|
||||
setGuidance(d.guidance);
|
||||
// Wire "Reapply" to the resident model too, so an advanced-option reload works
|
||||
// without re-picking it from the dropdown. Only a full pipeline load needs no
|
||||
// checkpoint filename; a resident GGUF *and* single_file carry no filename in status,
|
||||
// and the backend rejects a gguf/single_file load without one (400 before it evicts),
|
||||
// so leave lastLoad null for those (the Reapply button stays hidden for them)
|
||||
// rather than wire a reload that can never complete.
|
||||
// Wire "Reapply" to the resident model too, so an advanced-option reload works without
|
||||
// re-picking from the dropdown. Only a full pipeline load needs no checkpoint filename; a
|
||||
// resident GGUF/single_file carries no filename in status and the backend rejects such a
|
||||
// load without one (400 before it evicts), so leave lastLoad null for those (Reapply stays
|
||||
// hidden) rather than wire a reload that can never complete.
|
||||
const kind = status?.model_kind;
|
||||
if (kind === "pipeline") {
|
||||
lastLoad.current = { repoId, kind };
|
||||
|
|
@ -1541,20 +1527,19 @@ export function ImagesPage({ active = true }: { active?: boolean }) {
|
|||
dismissLoadToast();
|
||||
lastLoadSig.current = null;
|
||||
loadToastId.current = toast(null, loadToastArgs(IDLE_PROGRESS));
|
||||
// Remember what was loaded so "Reapply" can reload it with new advanced options.
|
||||
// Snapshot the prior target first: a load that fails to START (validation, gated
|
||||
// repo, training guard) leaves the previous model resident, so Reapply and the
|
||||
// resident-default seeding must keep pointing at it, not at the failed pick.
|
||||
// Remember what was loaded so "Reapply" can reload it with new advanced options. Snapshot
|
||||
// the prior target first: a load that fails to START (validation, gated repo, training
|
||||
// guard) leaves the previous model resident, so Reapply and the resident-default seeding
|
||||
// must keep pointing at it, not the failed pick.
|
||||
const prevLastLoad = lastLoad.current;
|
||||
lastLoad.current = { repoId, kind: opts.kind, filename: opts.filename };
|
||||
setCanReapply(true);
|
||||
try {
|
||||
// Returns immediately — the load runs in the background; we poll for it.
|
||||
// The backend infers the family + base diffusers repo from the repo id.
|
||||
// Forward the saved HF token so gated bases (FLUX dev/klein) can download.
|
||||
// A pipeline load carries no filename (the repo IS the pipeline); the
|
||||
// single-file kinds send the GGUF / safetensors filename. Advanced options map
|
||||
// sentinels ("auto"/"off"/"none") to omitted so the backend uses its defaults.
|
||||
// Returns immediately -- the load runs in the background; we poll for it. The backend
|
||||
// infers the family + base diffusers repo from the id. Forward the saved HF token so
|
||||
// gated bases (FLUX dev/klein) download. A pipeline load carries no filename (the repo
|
||||
// IS the pipeline); single-file kinds send the GGUF / safetensors filename. Advanced
|
||||
// sentinels ("auto"/"off"/"none") map to omitted so the backend uses its defaults.
|
||||
await loadDiffusionModel({
|
||||
model_path: repoId,
|
||||
model_kind: opts.kind,
|
||||
|
|
@ -1625,12 +1610,11 @@ export function ImagesPage({ active = true }: { active?: boolean }) {
|
|||
void handleLoad(id, { kind: spec.kind, filename: spec.filename });
|
||||
return;
|
||||
}
|
||||
// GGUF quant pick from the variant expander. Optimistic for instant picker
|
||||
// feedback, but revert if the load fails to START (400/409/network) or LATER
|
||||
// during the poll (download/preflight error/eviction) -- in both cases the old
|
||||
// pipeline stays loaded, so the selector must not advertise the failed quant.
|
||||
// The poll owns the after-start revert via quantRevert; here we only handle
|
||||
// the never-started case.
|
||||
// GGUF quant pick from the variant expander. Optimistic for instant picker feedback, but
|
||||
// revert if the load fails to START (400/409/network) or LATER in the poll (download/
|
||||
// preflight error/eviction) -- in both cases the old pipeline stays loaded, so don't
|
||||
// advertise the failed quant. The poll owns the after-start revert via quantRevert; here
|
||||
// we only handle the never-started case.
|
||||
if (meta.ggufVariant && meta.ggufFilename) {
|
||||
const prevQuant = quant;
|
||||
quantRevert.current = { prev: prevQuant };
|
||||
|
|
@ -1673,10 +1657,10 @@ export function ImagesPage({ active = true }: { active?: boolean }) {
|
|||
});
|
||||
return;
|
||||
}
|
||||
// A direct local single-file .safetensors pick (custom folder / on-device file)
|
||||
// must load via from_single_file: the pipeline route rejects a bare file (no
|
||||
// model_index.json) and only after evicting the resident model. Split into
|
||||
// (parent dir, basename) exactly like the local GGUF branch above.
|
||||
// A direct local single-file .safetensors pick (custom folder / on-device file) must
|
||||
// load via from_single_file: the pipeline route rejects a bare file (no model_index.json)
|
||||
// and only after evicting the resident model. Split into (parent dir, basename) like the
|
||||
// local GGUF branch above.
|
||||
if (meta.source === "local" && id.toLowerCase().endsWith(".safetensors")) {
|
||||
const norm = id.replace(/\\/g, "/");
|
||||
const slash = norm.lastIndexOf("/");
|
||||
|
|
@ -1751,10 +1735,9 @@ export function ImagesPage({ active = true }: { active?: boolean }) {
|
|||
);
|
||||
|
||||
const handleUnload = useCallback(async () => {
|
||||
// Ejecting cancels any in-flight replacement load on the backend, so tear
|
||||
// down its client-side tracking too: the load poll reschedules on phase
|
||||
// null and the persistent toast never resolves, so both would otherwise
|
||||
// leak forever after the unload.
|
||||
// Ejecting cancels any in-flight replacement load on the backend, so tear down its
|
||||
// client-side tracking too: the load poll reschedules on phase null and the persistent
|
||||
// toast never resolves, so both would otherwise leak forever after the unload.
|
||||
if (pollTimer.current) clearTimeout(pollTimer.current);
|
||||
pollTimer.current = null;
|
||||
dismissLoadToast();
|
||||
|
|
@ -1875,10 +1858,10 @@ export function ImagesPage({ active = true }: { active?: boolean }) {
|
|||
const runs = Number.isFinite(count) && count >= 1 ? Math.floor(count) : 1;
|
||||
if (runs !== count) setCount(runs);
|
||||
|
||||
// An explicit seed near the 2**53-1 backend cap can overflow once the per-run
|
||||
// offset (base + i*batchSize) and the engine's in-batch +j offsets are added,
|
||||
// 422ing a later run AFTER earlier images already generated. Fail before any
|
||||
// GPU work. Subtraction keeps the comparison exact where the sum would round.
|
||||
// An explicit seed near the 2**53-1 backend cap can overflow once the per-run offset
|
||||
// (base + i*batchSize) and the engine's in-batch +j offsets are added, 422ing a later run
|
||||
// AFTER earlier images generated. Fail before any GPU work. Subtraction keeps the comparison
|
||||
// exact where the sum would round.
|
||||
if (baseSeed > Number.MAX_SAFE_INTEGER - (runs * batchSize - 1)) {
|
||||
toast.error("Seed too large for this run count and batch size; use a smaller seed");
|
||||
return;
|
||||
|
|
@ -1887,10 +1870,10 @@ export function ImagesPage({ active = true }: { active?: boolean }) {
|
|||
setBusy("generating");
|
||||
setGenDone(0);
|
||||
setGenStep(null);
|
||||
// Poll the backend's per-step progress across the whole run (all sequential
|
||||
// generations), so the bar tracks the live denoising steps. A named poll body
|
||||
// (guarded against overlap) also serves the visibilitychange listener: a
|
||||
// background tab's throttled interval catches up the moment the tab is visible.
|
||||
// Poll the backend's per-step progress across the whole run (all sequential generations)
|
||||
// so the bar tracks live denoising steps. A named poll body (guarded against overlap) also
|
||||
// serves the visibilitychange listener: a background tab's throttled interval catches up the
|
||||
// moment the tab is visible.
|
||||
let pollInFlight = false;
|
||||
const pollGenerateOnce = async () => {
|
||||
if (pollInFlight) return;
|
||||
|
|
@ -1943,11 +1926,10 @@ export function ImagesPage({ active = true }: { active?: boolean }) {
|
|||
strength: condStrength,
|
||||
upscale: condUpscale,
|
||||
reference_images: condRefImages,
|
||||
// Drop empty (no id typed yet) and zero-weight rows, and trim hand-typed repo ids,
|
||||
// so the recipe records only adapters that actually applied. Empty -> omit entirely.
|
||||
// Gate on loraCapable: a restore can leave adapters in state while the loaded model
|
||||
// does not support LoRA (picker hidden), and sending them would fail generation with
|
||||
// no visible row to remove.
|
||||
// Drop empty (no id yet) and zero-weight rows, and trim hand-typed repo ids, so the
|
||||
// recipe records only adapters that applied. Gate on loraCapable: a restore can leave
|
||||
// adapters in state while the loaded model doesn't support LoRA (picker hidden), and
|
||||
// sending them would fail generation with no visible row to remove.
|
||||
loras: (() => {
|
||||
if (!loraCapable) return undefined;
|
||||
const active = loras
|
||||
|
|
@ -2216,10 +2198,10 @@ export function ImagesPage({ active = true }: { active?: boolean }) {
|
|||
<div className="flex gap-1 rounded-xl bg-muted/50 p-1">
|
||||
{WORKFLOW_TABS.map((t) => {
|
||||
const wf = status?.workflows ?? [];
|
||||
// Create (requires null) needs txt2img once a model is loaded -- an
|
||||
// edit-only model (workflows: ["edit"]) has no text-to-image mode, so its
|
||||
// Create tab is disabled and Edit is the only enabled one. With nothing
|
||||
// loaded, Create stays available so the user can pick a model.
|
||||
// Create (requires null) needs txt2img once a model is loaded -- an edit-only
|
||||
// model (workflows: ["edit"]) has no text-to-image mode, so its Create tab is
|
||||
// disabled and Edit is the only enabled one. With nothing loaded, Create stays
|
||||
// available so the user can pick a model.
|
||||
const enabled =
|
||||
t.requires === null
|
||||
? !status?.loaded || wf.includes("txt2img")
|
||||
|
|
|
|||
|
|
@ -4,11 +4,10 @@
|
|||
import { type ReactElement, useMemo } from "react";
|
||||
|
||||
import type { TrainingSeriesPoint } from "@/features/training";
|
||||
// The loss + grad-norm cards are pure presentational (props only), so reuse them directly.
|
||||
// We do NOT reuse ChartsSection/ChartsContent: those also render an LR and an Eval Loss
|
||||
// card, which add little for diffusion LoRA training (the LR curve is the deterministic
|
||||
// schedule the user just picked; eval is not configured). This is a diffusion-only
|
||||
// two-card layout: Training Loss + Grad Norm (the actual training health signal).
|
||||
// The loss + grad-norm cards are pure presentational (props only), so reuse them directly. We
|
||||
// do NOT reuse ChartsSection/ChartsContent: those also render LR and Eval Loss cards, which add
|
||||
// little for diffusion LoRA (LR is the deterministic schedule just picked; eval isn't
|
||||
// configured). This is a diffusion-only two-card layout: Training Loss + Grad Norm.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { GradNormChartCard } from "@/features/studio/sections/charts/grad-norm-chart-card";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
|
|
|
|||
|
|
@ -152,9 +152,9 @@ function mergeFamilies(reported?: DiffusionTrainableFamily[]): FamilyPreset[] {
|
|||
return merged;
|
||||
}
|
||||
|
||||
// A full-page training workspace: left = configure (family, dataset, labeling, settings),
|
||||
// right = live run (progress, loss/grad-norm charts, completion + deploy). Kept mounted with the
|
||||
// page so a long run survives Create/Train tab switches; polling is gated on `active`.
|
||||
// A full-page training workspace: left = configure (family, dataset, labeling, settings), right
|
||||
// = live run (progress, loss/grad-norm charts, completion + deploy). Kept mounted with the page
|
||||
// so a long run survives tab switches; polling is gated on `active`.
|
||||
export function DiffusionTrainPanel({
|
||||
active,
|
||||
loadedFamily,
|
||||
|
|
@ -195,12 +195,11 @@ export function DiffusionTrainPanel({
|
|||
// sdxl trains the U-Net in mixed precision (no quantised base), so it uses the
|
||||
// mixed_precision control instead of base_precision. Everything else is a DiT family.
|
||||
const isDiT = familyName !== "sdxl";
|
||||
// An EMPTY precision_modes list on a DiT family is the backend's deliberate signal that
|
||||
// this host cannot train it at all (a non-bf16 CUDA GPU fails the trainer's preflight for
|
||||
// every mode, so /info advertises no precision rather than a start that always 400s; the
|
||||
// human-readable reason rides in vram_note). Only an ABSENT field means an older backend
|
||||
// and falls back to the default mode list. SDXL reports [] too but is not precision-gated
|
||||
// (it keeps its mixed_precision lever), hence the isDiT scope.
|
||||
// An EMPTY precision_modes list on a DiT family is the backend's signal that this host can't
|
||||
// train it at all (a non-bf16 CUDA GPU fails the preflight for every mode, so /info advertises
|
||||
// no precision rather than a start that always 400s; the reason rides in vram_note). Only an
|
||||
// ABSENT field means an older backend, falling back to the default modes. SDXL reports [] too
|
||||
// but isn't precision-gated (it keeps its mixed_precision lever), hence the isDiT scope.
|
||||
const familyUntrainable =
|
||||
isDiT &&
|
||||
reportedFamily?.precision_modes != null &&
|
||||
|
|
@ -403,21 +402,20 @@ export function DiffusionTrainPanel({
|
|||
}
|
||||
}, [family, loadedBaseRepo, reportedFamily?.recommended_precision]);
|
||||
|
||||
// mixed_precision is an SDXL-only lever (its UI control is hidden for DiT families). A
|
||||
// dense DiT base precision (bf16/int8/fp8) requires bf16 compute, and every DiT family
|
||||
// trains in bf16, so reset precision to bf16 when the family changes to a DiT. Without
|
||||
// this, an fp16/no value left over from SDXL rides along in the DiT start payload and the
|
||||
// backend rejects it (dense modes need mixed_precision=bf16). Kept in its own effect so it
|
||||
// does not re-trigger the base/settings reseed above.
|
||||
// mixed_precision is an SDXL-only lever (hidden for DiT families). A dense DiT base precision
|
||||
// (bf16/int8/fp8) requires bf16 compute, and every DiT family trains in bf16, so reset
|
||||
// precision to bf16 on a change to a DiT family. Without this, an fp16/no value left from SDXL
|
||||
// rides along in the DiT start payload and the backend rejects it (dense modes need
|
||||
// mixed_precision=bf16). Kept in its own effect so it doesn't re-trigger the reseed above.
|
||||
useEffect(() => {
|
||||
if (isDiT) setPrecision("bf16");
|
||||
}, [isDiT]);
|
||||
|
||||
// The base actually used everywhere (request, deploy, select value). baseChoice can
|
||||
// briefly hold another family's repo between a family switch and the reseed effect
|
||||
// (or if that effect is skipped); a raw <select value> would then DISPLAY the first
|
||||
// option while the request still carried the stale repo -- the user saw FLUX's gated
|
||||
// error while another family looked selected. Clamp to the current family's repos.
|
||||
// The base actually used everywhere (request, deploy, select value). baseChoice can briefly
|
||||
// hold another family's repo between a family switch and the reseed effect (or if it's
|
||||
// skipped); a raw <select value> would then DISPLAY the first option while the request carried
|
||||
// the stale repo -- the user saw FLUX's gated error while another family looked selected. Clamp
|
||||
// to the current family's repos.
|
||||
const effectiveBase =
|
||||
baseChoice === CUSTOM_BASE || (family?.base_repos ?? []).includes(baseChoice)
|
||||
? baseChoice
|
||||
|
|
@ -487,12 +485,11 @@ export function DiffusionTrainPanel({
|
|||
!(terminalStatuses.includes(status.status) && status.job_id === dismissedJobId),
|
||||
);
|
||||
|
||||
// Notify the parent exactly once per run that produced an adapter (full completion or
|
||||
// stop-and-save) so it rescans the LoRA picker. The flag is re-armed both here (when a
|
||||
// new run is observed as "running") and in onStart (the moment a start is requested), so
|
||||
// a second run still notifies even if the poll never catches the intermediate "running"
|
||||
// state; onStart also guards the double-fire when the poll re-observes the same terminal
|
||||
// status before the new run has begun.
|
||||
// Notify the parent once per run that produced an adapter (full completion or stop-and-save)
|
||||
// so it rescans the LoRA picker. The flag is re-armed both here (when a new run is seen
|
||||
// "running") and in onStart (when a start is requested), so a second run still notifies even if
|
||||
// the poll never catches the intermediate "running" state; onStart also guards the double-fire
|
||||
// when the poll re-observes the same terminal status before the new run begins.
|
||||
const notifiedComplete = useRef(false);
|
||||
useEffect(() => {
|
||||
const producedAdapter =
|
||||
|
|
@ -750,11 +747,11 @@ export function DiffusionTrainPanel({
|
|||
[poll],
|
||||
);
|
||||
|
||||
// Resolve the repo an adapter should be PREVIEWED on. Krea (and any family that trains on
|
||||
// one checkpoint but runs adapters on another) declares a deploy_base: preview the adapter
|
||||
// there instead of the training checkpoint, so the default Krea train-on-Raw flow does not
|
||||
// load the adapter on Raw's non-distilled recipe. Only a recognised training base is
|
||||
// overridden; a custom repo the user typed is respected as-is.
|
||||
// Resolve the repo an adapter should be PREVIEWED on. Krea (and any family that trains on one
|
||||
// checkpoint but runs adapters on another) declares a deploy_base: preview the adapter there
|
||||
// instead of the training checkpoint, so the default Krea train-on-Raw flow doesn't load the
|
||||
// adapter on Raw's non-distilled recipe. Only a recognised training base is overridden; a
|
||||
// custom typed repo is respected as-is.
|
||||
const deployBaseFor = useCallback(
|
||||
(trainedBase: string, famName: string): string => {
|
||||
const rec = info?.families?.find((f) => f.name === famName);
|
||||
|
|
|
|||
|
|
@ -12,10 +12,10 @@ import {
|
|||
importDiffusionDatasetExample,
|
||||
} from "../api";
|
||||
|
||||
// Best-effort preview thumbnails pulled from the public HF datasets-server. Cached per repo
|
||||
// (module-level) so re-renders and re-mounts do not refetch. A repo that the server cannot
|
||||
// serve (e.g. diffusers/dog-example) resolves to an empty list and the card renders without
|
||||
// previews - the import still works.
|
||||
// Best-effort preview thumbnails from the public HF datasets-server. Cached per repo
|
||||
// (module-level) so re-renders/re-mounts don't refetch. A repo the server can't serve (e.g.
|
||||
// diffusers/dog-example) resolves to an empty list and the card renders without previews -- the
|
||||
// import still works.
|
||||
const _previewCache = new Map<string, Promise<string[]>>();
|
||||
|
||||
async function fetchPreviews(repo: string): Promise<string[]> {
|
||||
|
|
@ -74,10 +74,9 @@ function ExamplePreviews({ repo }: { repo: string }) {
|
|||
);
|
||||
}
|
||||
|
||||
// One-click example-dataset importers. Each card shows the license so users see the terms
|
||||
// before importing, plus a few preview thumbnails so the set is visible before download. On
|
||||
// success the parent refreshes its dataset list and selects the imported folder (and can
|
||||
// seed the trigger prompt from suggested_trigger).
|
||||
// One-click example-dataset importers. Each card shows the license (terms before import) plus a
|
||||
// few preview thumbnails. On success the parent refreshes its dataset list and selects the
|
||||
// imported folder (and can seed the trigger prompt from suggested_trigger).
|
||||
//
|
||||
// Layout: one card per row (the config column is narrow, so a two-column grid wrapped titles
|
||||
// one word per line and let the long license text overrun into the next card).
|
||||
|
|
|
|||
|
|
@ -4,10 +4,9 @@
|
|||
import { authFetch } from "@/features/auth";
|
||||
import { readFastApiError } from "@/lib/format-fastapi-error";
|
||||
|
||||
// One Advanced control's resolved value + provenance, for the "Auto: X" badges. Same
|
||||
// shape the diffusion status uses. `value` is the engaged value (a scheme/mode string,
|
||||
// null when off, or a boolean); `source` is "auto" (this backend decided) or "explicit"
|
||||
// (the caller set it); `reason` is the short why shown as a tooltip.
|
||||
// One Advanced control's resolved value + provenance, for the "Auto: X" badges. Same shape the
|
||||
// diffusion status uses. `value` is the engaged value (scheme/mode string, null when off, or a
|
||||
// boolean); `source` is "auto" or "explicit"; `reason` is the tooltip why.
|
||||
export interface VideoResolvedControl {
|
||||
value: string | boolean | null;
|
||||
source: "auto" | "explicit";
|
||||
|
|
|
|||
|
|
@ -73,12 +73,11 @@ import {
|
|||
unloadVideoModel,
|
||||
} from "./api";
|
||||
|
||||
// Curated models come from the shared catalog: one canonical group per model
|
||||
// with its artifacts as data (the HunyuanVideo group carries both the 480p and
|
||||
// 720p repacks), and the load kind per artifact via loadSpecFor (replacing the
|
||||
// old PIPELINE_MODELS table). The picker renders groups with a format second
|
||||
// level -- which also finally surfaces LTX-2.3 in Recommended (its HF
|
||||
// pipeline_tag is image-to-video, so the live text-to-video listing missed it).
|
||||
// Curated models come from the shared catalog: one canonical group per model with its
|
||||
// artifacts as data (the HunyuanVideo group carries both the 480p and 720p repacks), and the
|
||||
// load kind per artifact via loadSpecFor (replacing the old PIPELINE_MODELS table). The picker
|
||||
// renders groups with a format second level -- which also surfaces LTX-2.3 in Recommended (its
|
||||
// HF pipeline_tag is image-to-video, so the live text-to-video listing missed it).
|
||||
const VIDEO_MODELS: ModelOption[] = catalogToModelOptions(VIDEO_CATALOG);
|
||||
|
||||
// Per-model generation defaults (steps + guidance), matched by repo-id substring, most
|
||||
|
|
@ -530,9 +529,9 @@ export function VideoPage({ active = true }: { active?: boolean }) {
|
|||
const lastLoad = useRef<{ repoId: string; kind: "gguf" | "single_file" | "pipeline"; filename?: string } | null>(
|
||||
null,
|
||||
);
|
||||
// Whether this session holds a reapply descriptor (set only by our own loads). On a mount/refresh
|
||||
// with a model already resident, status.loaded is true but lastLoad is null, so Reapply would
|
||||
// silently do nothing -- hide the button in that case rather than offer a dead control.
|
||||
// Whether this session holds a reapply descriptor (set only by our own loads). On a
|
||||
// mount/refresh with a model already resident, status.loaded is true but lastLoad is null, so
|
||||
// Reapply would do nothing -- hide the button rather than offer a dead control.
|
||||
const [canReapply, setCanReapply] = useState(false);
|
||||
|
||||
const [busy, setBusy] = useState<Busy>(null);
|
||||
|
|
@ -650,13 +649,13 @@ 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.
|
||||
// Seed steps/guidance from the loaded model's backend 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 wanting 40/4 generates a degraded clip. Key on the repo id so it
|
||||
// fires once per newly-loaded model (distilled vs base of the same family differ); a later user
|
||||
// edit isn't clobbered (the key changes only on model change), and a gallery restore (same
|
||||
// repo) is left untouched.
|
||||
const loadedModelKey = status?.loaded ? status.repo_id : null;
|
||||
const defaultSteps = status?.defaults?.steps;
|
||||
const defaultGuidance = status?.defaults?.guidance;
|
||||
|
|
@ -901,12 +900,11 @@ export function VideoPage({ active = true }: { active?: boolean }) {
|
|||
}
|
||||
}, []);
|
||||
|
||||
// Poll the backend's per-step progress so the bar tracks the live denoising steps
|
||||
// and the encode phase, and drive completion off the terminal phase: "completed"
|
||||
// carries the saved gallery record, "failed" the client-safe error. A named poll
|
||||
// body (guarded against overlap) also serves the visibilitychange listener: a
|
||||
// background tab's throttled interval catches up the moment the tab is visible.
|
||||
// Shared by handleGenerate and the mount-time resume of a job already in flight.
|
||||
// Poll the backend's per-step progress so the bar tracks live denoising steps and the encode
|
||||
// phase, and drive completion off the terminal phase: "completed" carries the saved gallery
|
||||
// record, "failed" the client-safe error. A named poll body (guarded against overlap) also
|
||||
// serves the visibilitychange listener: a background tab's throttled interval catches up when
|
||||
// visible. Shared by handleGenerate and the mount-time resume of an in-flight job.
|
||||
const startGenPoll = useCallback(() => {
|
||||
stopGenPoll();
|
||||
let pollInFlight = false;
|
||||
|
|
@ -985,10 +983,9 @@ export function VideoPage({ active = true }: { active?: boolean }) {
|
|||
setGenStep(g.phase === "queued" ? null : g);
|
||||
startGenPoll();
|
||||
} else if (g.phase === "completed" && g.video) {
|
||||
// The job finished while no page was mounted. The terminal record persists on
|
||||
// the backend until the next job, and the mount gallery fetch usually already
|
||||
// contains the clip; merging here (deduped) covers the race where the job
|
||||
// completed after that fetch was issued.
|
||||
// The job finished while no page was mounted. The terminal record persists on the
|
||||
// backend until the next job, and the mount gallery fetch usually already has the clip;
|
||||
// merging here (deduped) covers the race where the job completed after that fetch.
|
||||
const clip = g.video;
|
||||
setVideos((prev) => (prev.some((v) => v.id === clip.id) ? prev : [clip, ...prev]));
|
||||
void ensureSrc(clip);
|
||||
|
|
@ -1084,10 +1081,10 @@ export function VideoPage({ active = true }: { active?: boolean }) {
|
|||
if (spec && spec.kind !== "gguf") {
|
||||
setQuant(null);
|
||||
// The distilled variant lives in the single-file checkpoint name
|
||||
// (ltx-2.3-...-distilled...), not the repo id, so include the filename when
|
||||
// seeding defaults -- mirroring the GGUF branch below. Without it these
|
||||
// distilled BF16/FP8 entries fall through to the generic LTX 40-step/CFG-4
|
||||
// defaults instead of the distilled 8-step/guidance-1 schedule.
|
||||
// (ltx-2.3-...-distilled...), not the repo id, so include the filename when seeding
|
||||
// defaults (mirroring the GGUF branch below). Without it these distilled BF16/FP8
|
||||
// entries fall through to the generic LTX 40-step/CFG-4 defaults instead of the
|
||||
// distilled 8-step/guidance-1 schedule.
|
||||
const d = defaultsFor(spec.filename ? `${id}/${spec.filename}` : id);
|
||||
setSteps(d.steps);
|
||||
setGuidance(d.guidance);
|
||||
|
|
@ -1135,10 +1132,9 @@ export function VideoPage({ active = true }: { active?: boolean }) {
|
|||
});
|
||||
return;
|
||||
}
|
||||
// A direct local single-file .safetensors pick must load via from_single_file:
|
||||
// the pipeline route rejects a bare file (no model_index.json) and only after
|
||||
// evicting the resident model. Split into (parent dir, basename) exactly like
|
||||
// the local GGUF branch above.
|
||||
// A direct local single-file .safetensors pick must load via from_single_file: the
|
||||
// pipeline route rejects a bare file (no model_index.json) and only after evicting the
|
||||
// resident model. Split into (parent dir, basename) like the local GGUF branch above.
|
||||
if (meta.source === "local" && id.toLowerCase().endsWith(".safetensors")) {
|
||||
const norm = id.replace(/\\/g, "/");
|
||||
const slash = norm.lastIndexOf("/");
|
||||
|
|
@ -1224,10 +1220,10 @@ export function VideoPage({ active = true }: { active?: boolean }) {
|
|||
|
||||
setBusy("generating");
|
||||
setGenStep(null);
|
||||
// The POST only STARTS the job and returns at once (a clip takes minutes, and
|
||||
// secure mode's tunnel caps responses near 100s, so completion cannot ride the
|
||||
// POST). A synchronous rejection (no model / already generating / bad input)
|
||||
// still surfaces here; everything after acceptance arrives via the poll.
|
||||
// The POST only STARTS the job and returns at once (a clip takes minutes, and secure mode's
|
||||
// tunnel caps responses near 100s, so completion can't ride the POST). A synchronous
|
||||
// rejection (no model / already generating / bad input) still surfaces here; everything
|
||||
// after acceptance arrives via the poll.
|
||||
try {
|
||||
await generateVideo({
|
||||
prompt: prompt.trim(),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue