Tighten comments in the image and video frontend

This commit is contained in:
Daniel Han 2026-07-12 12:15:36 +00:00
commit 0017674664
12 changed files with 311 additions and 369 deletions

View file

@ -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();

View file

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

View file

@ -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"],

View file

@ -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) {

View file

@ -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();

View file

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

View file

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

View file

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

View file

@ -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);

View file

@ -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).

View file

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

View file

@ -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(),