From 0017674664c7eda62bad479df3eec25a488cbc6c Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Sun, 12 Jul 2026 12:15:36 +0000 Subject: [PATCH] Tighten comments in the image and video frontend --- studio/frontend/src/app/routes/__root.tsx | 18 +- .../model-selector/model-catalog.check.ts | 6 +- .../model-selector/model-catalog.ts | 33 ++- .../assistant-ui/model-selector/pickers.tsx | 203 +++++++--------- .../src/features/chat/api/chat-api.ts | 12 +- studio/frontend/src/features/images/api.ts | 18 +- .../src/features/images/images-page.tsx | 228 ++++++++---------- .../images/train/diffusion-charts.tsx | 9 +- .../images/train/diffusion-train-panel.tsx | 59 +++-- .../images/train/example-dataset-cards.tsx | 15 +- studio/frontend/src/features/video/api.ts | 7 +- .../src/features/video/video-page.tsx | 72 +++--- 12 files changed, 311 insertions(+), 369 deletions(-) diff --git a/studio/frontend/src/app/routes/__root.tsx b/studio/frontend/src/app/routes/__root.tsx index 27be71348b..fcf4ea437f 100644 --- a/studio/frontend/src/app/routes/__root.tsx +++ b/studio/frontend/src/app/routes/__root.tsx @@ -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(); diff --git a/studio/frontend/src/components/assistant-ui/model-selector/model-catalog.check.ts b/studio/frontend/src/components/assistant-ui/model-selector/model-catalog.check.ts index dccfe16cb1..51c677cf27 100644 --- a/studio/frontend/src/components/assistant-ui/model-selector/model-catalog.check.ts +++ b/studio/frontend/src/components/assistant-ui/model-selector/model-catalog.check.ts @@ -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, diff --git a/studio/frontend/src/components/assistant-ui/model-selector/model-catalog.ts b/studio/frontend/src/components/assistant-ui/model-selector/model-catalog.ts index 5fc3a0a7c5..c68991eac5 100644 --- a/studio/frontend/src/components/assistant-ui/model-selector/model-catalog.ts +++ b/studio/frontend/src/components/assistant-ui/model-selector/model-catalog.ts @@ -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"], diff --git a/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx b/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx index 6df5e41380..6b8f93cb29 100644 --- a/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx +++ b/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx @@ -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(); 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) { diff --git a/studio/frontend/src/features/chat/api/chat-api.ts b/studio/frontend/src/features/chat/api/chat-api.ts index c12ee92c44..7305ea9809 100644 --- a/studio/frontend/src/features/chat/api/chat-api.ts +++ b/studio/frontend/src/features/chat/api/chat-api.ts @@ -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(); diff --git a/studio/frontend/src/features/images/api.ts b/studio/frontend/src/features/images/api.ts index d9cef386ea..cfe9746685 100644 --- a/studio/frontend/src/features/images/api.ts +++ b/studio/frontend/src/features/images/api.ts @@ -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 | 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; diff --git a/studio/frontend/src/features/images/images-page.tsx b/studio/frontend/src/features/images/images-page.tsx index 6a43010a53..7cf2590ca1 100644 --- a/studio/frontend/src/features/images/images-page.tsx +++ b/studio/frontend/src/features/images/images-page.tsx @@ -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" /> @@ -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 { // 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(null); // Last load-progress signature shown, so a tick that moved nothing skips the toast. const lastLoadSig = useRef(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(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 }) {
{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") diff --git a/studio/frontend/src/features/images/train/diffusion-charts.tsx b/studio/frontend/src/features/images/train/diffusion-charts.tsx index 08d7b7bfb2..9d18334bac 100644 --- a/studio/frontend/src/features/images/train/diffusion-charts.tsx +++ b/studio/frontend/src/features/images/train/diffusion-charts.tsx @@ -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 diff --git a/studio/frontend/src/features/images/train/diffusion-train-panel.tsx b/studio/frontend/src/features/images/train/diffusion-train-panel.tsx index 2798678976..46753cdaaf 100644 --- a/studio/frontend/src/features/images/train/diffusion-train-panel.tsx +++ b/studio/frontend/src/features/images/train/diffusion-train-panel.tsx @@ -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 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); diff --git a/studio/frontend/src/features/images/train/example-dataset-cards.tsx b/studio/frontend/src/features/images/train/example-dataset-cards.tsx index 8bf5d3d38c..45a0eb8444 100644 --- a/studio/frontend/src/features/images/train/example-dataset-cards.tsx +++ b/studio/frontend/src/features/images/train/example-dataset-cards.tsx @@ -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>(); async function fetchPreviews(repo: string): Promise { @@ -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). diff --git a/studio/frontend/src/features/video/api.ts b/studio/frontend/src/features/video/api.ts index e20bf1ad69..f03b046b6b 100644 --- a/studio/frontend/src/features/video/api.ts +++ b/studio/frontend/src/features/video/api.ts @@ -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"; diff --git a/studio/frontend/src/features/video/video-page.tsx b/studio/frontend/src/features/video/video-page.tsx index d120743781..3733c7c0ce 100644 --- a/studio/frontend/src/features/video/video-page.tsx +++ b/studio/frontend/src/features/video/video-page.tsx @@ -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(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(),