Merge remote-tracking branch 'origin/diffusion-image-workflows' into diffusion-lora

# Conflicts:
#	studio/backend/core/inference/sd_cpp_backend.py
This commit is contained in:
Daniel Han 2026-07-02 06:38:47 +00:00
commit 8bfa236798
11 changed files with 352 additions and 66 deletions

View file

@ -68,14 +68,6 @@ from .diffusion_attention import (
)
from . import diffusion_compile_cache as compile_cache
from . import diffusion_gguf_compile as gguf_compile
from .diffusion_eager_patches import (
install_compile_safe_patches,
uninstall_patches,
)
from .diffusion_arch_patches import (
install_arch_patches,
uninstall_arch_patches,
)
from .diffusion_cache import apply_step_cache
from .diffusion_precision import quantize_text_encoders
from .diffusion_prequant import (
@ -149,11 +141,6 @@ def _decode_b64_image(data: str, *, mode: str = "RGB") -> Any:
blob = base64.b64decode(raw, validate = False)
except (binascii.Error, ValueError) as exc:
raise ValueError(f"Invalid base64 image data: {exc}") from exc
try:
img = Image.open(io.BytesIO(blob))
img.load()
except Exception as exc: # noqa: BLE001 — surfaced as a 400 to the client
raise ValueError(f"Could not decode image: {exc}") from exc
# Bound the decoded size. Every image-conditioned workflow (img2img / inpaint / upscale /
# reference / edit) decodes through here, so this single guard protects init, mask, and
# each reference image uniformly. PIL only WARNS in its 89-178MP "decompression bomb" soft
@ -161,9 +148,19 @@ def _decode_b64_image(data: str, *, mode: str = "RGB") -> Any:
# well below that. 4096px covers txt2img's 2048 max, upscales, and normal outpaint canvases;
# anything larger is rejected with a clear 400 instead of risking an OOM.
max_side = 4096
w, h = img.size
if w > max_side or h > max_side:
raise ValueError(f"Image is too large ({w}x{h}); maximum is {max_side}px per side.")
try:
img = Image.open(io.BytesIO(blob))
# Read the declared dimensions from the header (Image.open is lazy) and reject an
# over-limit image BEFORE img.load() decompresses its pixels, so a crafted
# small-payload/huge-dimension file can't spike memory before the guard runs.
w, h = img.size
if w > max_side or h > max_side:
raise ValueError(f"Image is too large ({w}x{h}); maximum is {max_side}px per side.")
img.load()
except ValueError:
raise # the size guard's own message; don't wrap it as a decode error
except Exception as exc: # noqa: BLE001 — surfaced as a 400 to the client
raise ValueError(f"Could not decode image: {exc}") from exc
return img.convert(mode)
@ -469,6 +466,23 @@ class DiffusionBackend:
if kind in ("gguf", "single_file"):
if not gguf_filename:
raise ValueError(f"a single-file checkpoint name is required for a '{kind}' load.")
# Fail a kind/extension mismatch here (before the route evicts chat and grabs the
# GPU), instead of deep in the background from_single_file: a "gguf" load needs a
# .gguf file, and a "single_file" load must not be handed a .gguf.
is_gguf_name = gguf_filename.lower().endswith(".gguf")
if kind == "gguf" and not is_gguf_name:
raise ValueError("a 'gguf' load requires a .gguf checkpoint name.")
if kind == "single_file" and is_gguf_name:
raise ValueError("a .gguf checkpoint needs model_kind 'gguf', not 'single_file'.")
# A single-file load must name an actual checkpoint: an arbitrary repo file
# (README.md, config.json) would pass preflight, evict the chat model, and
# only fail in the background from_single_file -- the eviction this
# validation exists to prevent.
if kind == "single_file" and not gguf_filename.lower().endswith(".safetensors"):
raise ValueError(
f"'{gguf_filename}' is not a loadable single-file checkpoint "
f"(expected a .safetensors name; use a .gguf name for a GGUF load)."
)
if local_root.exists():
resolve_local_gguf_child(local_root, gguf_filename)
elif path_shaped:
@ -485,6 +499,16 @@ class DiffusionBackend:
)
elif path_shaped:
raise FileNotFoundError(f"Local model path does not exist: {repo_id}")
elif repo_id.upper().endswith("-GGUF"):
# A remote "*-GGUF" id is a single-file GGUF repo, not a full diffusers
# pipeline: loading it as a pipeline passes the trusted-repo check, evicts
# chat, then fails in the background when from_pretrained finds no
# model_index.json. Reject the certain case here (no network round-trip)
# so the bad pick fails before the GPU handoff, as the route expects.
raise ValueError(
f"'{repo_id}' is a single-file GGUF repo; load it with model_kind 'gguf' "
f"and a .gguf filename, not as a full pipeline."
)
return fam
# ── Background load + progress ─────────────────────────────────────────
@ -723,6 +747,30 @@ class DiffusionBackend:
return 0 # repo not in cache yet
return total
@staticmethod
def _local_dir_weight_bytes(path: Path, *, exclude_transformer: bool) -> int:
"""Sum the on-disk weight files under a local diffusers directory. The HF blob
cache is empty for a local path, so this is the only size signal for auto memory
planning; without it a large local model folds to zero and the planner skips
offload and OOMs. ``exclude_transformer`` drops the ``transformer/`` subfolder
for GGUF/single-file loads (their transformer is the single file, not resident
here); a full pipeline load keeps it (the whole repo is resident)."""
total = 0
for f in path.rglob("*"):
if f.suffix.lower() not in (".safetensors", ".bin", ".pt", ".ckpt"):
continue
try:
rel = f.relative_to(path)
except ValueError:
continue
if exclude_transformer and rel.parts and rel.parts[0] == "transformer":
continue
try:
total += f.stat().st_size
except OSError:
continue
return total
@staticmethod
def _companion_cache_bytes(base: str) -> int:
"""Resident companion (VAE + text-encoder) size for the memory plan.
@ -734,21 +782,7 @@ class DiffusionBackend:
weights to zero and auto planning can pick a resident placement that OOMs."""
local = Path(base).expanduser()
if local.is_dir():
total = 0
for f in local.rglob("*"):
if f.suffix.lower() not in (".safetensors", ".bin", ".pt", ".ckpt"):
continue
try:
rel = f.relative_to(local)
except ValueError:
continue
if rel.parts and rel.parts[0] == "transformer":
continue # supplied by the GGUF single-file; not resident here
try:
total += f.stat().st_size
except OSError:
continue
return total
return DiffusionBackend._local_dir_weight_bytes(local, exclude_transformer = True)
return DiffusionBackend._cache_bytes(base)
# ── Synchronous load / generate / unload ───────────────────────────────
@ -999,6 +1033,17 @@ class DiffusionBackend:
eager_patched = False
compile_ctx = None
state_committed = False
# Lazy import: these patch modules import torch at module level, so
# importing them here (not at module load) keeps diffusion.py torch-free
# to import, letting get_diffusion_backend() run on a torchless native install.
from .diffusion_eager_patches import (
install_compile_safe_patches,
uninstall_patches,
)
from .diffusion_arch_patches import (
install_arch_patches,
uninstall_arch_patches,
)
try:
if effective_speed != SPEED_OFF:
install_compile_safe_patches()
@ -1249,7 +1294,13 @@ class DiffusionBackend:
if kind == "pipeline":
# The whole repo (transformer + companions) is one cached download; the
# cached bytes are the resident estimate (bnb-4bit / fp8 stay compressed).
cached = self._cache_bytes(repo_id) if repo_id else 0
# A LOCAL pipeline path isn't in the HF blob cache, so sum its on-disk weights
# (transformer included) instead of folding to zero and skipping offload.
local_repo = Path(repo_id).expanduser() if repo_id else None
if local_repo is not None and local_repo.is_dir():
cached = self._local_dir_weight_bytes(local_repo, exclude_transformer = False)
else:
cached = self._cache_bytes(repo_id) if repo_id else 0
cached_mib = int(cached // (1024 * 1024)) if cached else None
model_dense_mib = estimate_safetensors_dense_mib(cached_mib)
companion_mib = None
@ -1319,7 +1370,14 @@ class DiffusionBackend:
# reuse the resident modules AT THEIR LOADED dtype, which is the whole point of
# from_pipe (component reuse, no reload, no extra VRAM).
pipe = getattr(diffusers, class_name).from_pipe(state.pipe, torch_dtype = None)
self._aux_pipes[class_name] = pipe
# Only publish to the shared aux cache if THIS load is still current. from_pipe runs
# under _generate_lock but NOT _lock, so an unload()/superseding load can clear
# _aux_pipes and null _state while it builds; caching unconditionally would re-insert
# a wrapper over now-stale modules that a later same-workflow load would reuse (or
# keep the old VRAM pinned). This generation still uses the returned pipe.
with self._lock:
if self._state is state:
self._aux_pipes[class_name] = pipe
return pipe
@staticmethod
@ -1566,6 +1624,15 @@ class DiffusionBackend:
fit = min(1.0, max_side / max(tw_f, th_f))
tw = max(16, int(round(tw_f * fit / 16.0)) * 16)
th = max(16, int(round(th_f * fit / 16.0)) * 16)
# After the absolute cap, the target must still exceed the input, or
# "upscale" would shrink it (e.g. a 3000px source at 2x clamps to 2048).
# Reject rather than silently return a smaller image than uploaded.
if max(tw, th) <= max(iw, ih):
raise ValueError(
f"Upscale would not enlarge this image: its longest side "
f"({max(iw, ih)}px) already meets the {max_side}px output limit. "
f"Use a smaller source image."
)
init_pil = init_pil.resize((tw, th), Image.LANCZOS)
if strength is None:
# Hires-fix default: low enough to preserve content, high enough to
@ -1762,6 +1829,10 @@ class DiffusionBackend:
# bit-identical dequant. Idempotent.
gguf_compile.uninstall_all()
if state.eager_patched:
# Lazy import (torch at module level) to keep diffusion.py torch-free to import.
from .diffusion_eager_patches import uninstall_patches
from .diffusion_arch_patches import uninstall_arch_patches
uninstall_patches()
uninstall_arch_patches()
# NOTE: we deliberately do NOT call state.pipe.unload_lora_weights() here. unload()

View file

@ -251,14 +251,28 @@ _FAMILIES: tuple[DiffusionFamily, ...] = (
_EDIT_KEYWORDS = ("edit", "kontext", "inpaint", "layered")
def _token_in_needle(token: str, needle: str) -> bool:
"""True when ``token`` appears in ``needle`` as a whole path/name segment, i.e.
delimited by a separator (``- _ . / \\``) or a string boundary, not merely as a
raw substring. This keeps multi-part tokens matching where they should
('qwen-image-edit' in 'qwen-image-edit-2511') while preventing a short token from
matching inside an unrelated word ('kontext' must not match 'kontextual', 'edit'
must not match 'edition')."""
return re.search(
r"(?:^|[-_./\\])" + re.escape(token) + r"(?:$|[-_./\\])", needle
) is not None
def _best_family_match(needle: str) -> Optional[DiffusionFamily]:
"""The family whose name/alias is the LONGEST substring of ``needle``. Longest =
most specific, so an edit checkpoint ('...qwen-image-edit-2511...') matches the
'qwen-image-edit' family rather than the generic 'qwen-image' one."""
"""The family whose name/alias is the LONGEST whole-segment token of ``needle``.
Longest = most specific, so an edit checkpoint ('...qwen-image-edit-2511...')
matches the 'qwen-image-edit' family rather than the generic 'qwen-image' one.
Segment matching (not raw substring) stops a short alias like 'kontext' from
hijacking an unrelated path such as '.../kontextual/z-image-...gguf'."""
best: Optional[tuple[DiffusionFamily, int]] = None
for fam in _FAMILIES:
for token in (fam.name, *fam.aliases):
if token in needle and (best is None or len(token) > best[1]):
if _token_in_needle(token, needle) and (best is None or len(token) > best[1]):
best = (fam, len(token))
return best[0] if best else None
@ -284,9 +298,16 @@ def detect_family(repo_id: str, override: Optional[str] = None) -> Optional[Diff
# Don't let a generic base family (e.g. qwen-image) swallow a variant it can't run
# (qwen-image-LAYERED, ...-Inpaint): if the id still carries a reject keyword the
# matched family does not itself declare, reject so the load fails fast + clearly.
# Scope the keyword check to the LAST path component (the model id or
# filename), not arbitrary parent directories: a valid file selected as
# repo_id `/models/edit` + filename `Z-Image-Turbo-Q4.gguf` must not be
# rejected because a parent folder happens to be named `edit`. The
# combined `repo_id/gguf_filename` fallback passes the filename last.
basename = re.split(r"[/\\]+", needle)[-1]
matched_tokens = (match.name, *match.aliases)
if any(
kw in needle and not any(kw in tok for tok in matched_tokens) for kw in _EDIT_KEYWORDS
_token_in_needle(kw, basename) and not any(kw in tok for tok in matched_tokens)
for kw in _EDIT_KEYWORDS
):
return None
return match

View file

@ -181,7 +181,7 @@ def _map_guidance(
classifier-free ``--cfg-scale``. A distilled 0/1 means CFG off (sd-cli's 1.0); a
value > 1 is real CFG. Mirrors the engine mapping validated in the CPU benchmark.
"""
if fam.name in ("flux.1", "flux.2-klein"):
if fam.name in ("flux.1", "flux.2-klein", "flux.2-dev"):
return None, (float(guidance) if guidance is not None else None)
cfg = float(guidance) if (guidance is not None and guidance > 1.0) else 1.0
return cfg, None
@ -506,10 +506,18 @@ class SdCppDiffusionBackend:
from core.inference import diffusion_lora
if init_image is not None or mask_image is not None or reference_images:
if (
init_image is not None
or mask_image is not None
or reference_images
or (upscale is not None and upscale > 1)
):
# upscale needs an input image, so a direct API call with upscale > 1 but no
# init_image must be rejected too rather than silently returning a plain,
# un-upscaled text-to-image result (the diffusers backend rejects the same).
raise ValueError(
"img2img / inpaint / reference are not yet supported on the native sd.cpp "
"engine; run on a GPU (diffusers) for image-conditioned workflows."
"img2img / inpaint / reference / upscale are not yet supported on the native "
"sd.cpp engine; run on a GPU (diffusers) for image-conditioned workflows."
)
cancel = threading.Event()
@ -691,6 +699,7 @@ class SdCppDiffusionBackend:
"transformer_cache": None,
"engine": "sd_cpp",
"supports_lora": False,
"workflows": [],
}
from core.inference import diffusion_lora
@ -722,6 +731,11 @@ class SdCppDiffusionBackend:
model_kind = "gguf",
transformer_quant = None,
),
# The native engine supports plain text-to-image only (generate() rejects
# img2img / inpaint / reference / upscale), so advertise just txt2img. Without
# this the status omits workflows, the UI reads [], and it disables the Create
# tab for a loaded native model, stranding the user on an image-only tab.
"workflows": ["txt2img"],
}

View file

@ -75,6 +75,14 @@ def _terminate(proc: "subprocess.Popen") -> None:
proc.kill()
except Exception: # noqa: BLE001 -- best-effort teardown
pass
# Reap the killed child so it does not linger as a zombie until the next Popen
# cleanup / interpreter exit. Callers raise immediately after _terminate (the
# cancellation and timeout paths), so without this a burst of image cancellations
# leaks process-table entries. SIGKILL is prompt, so a short bounded wait suffices.
try:
proc.wait(timeout = 5)
except Exception: # noqa: BLE001 -- best-effort reap; never block teardown
pass
def _binary_name(stem: str) -> str:

View file

@ -11051,12 +11051,10 @@ async def load_diffusion_model(
from core.inference.diffusion import get_diffusion_backend, resolve_model_kind
from core.inference.diffusion_device import resolve_diffusion_device_target
from core.inference.diffusion_engine_router import (
active_engine_name,
annotate_status,
select_and_activate_engine,
)
from core.inference.gpu_arbiter import acquire_for, release, DIFFUSION
from core.inference.sd_cpp_engine import ENGINE_SD_CPP
from utils.native_path_leases import redact_native_paths
backend = get_diffusion_backend()
@ -11084,12 +11082,15 @@ async def load_diffusion_model(
engine = await asyncio.to_thread(
select_and_activate_engine, fam, hf_token = request.hf_token, model_kind = kind
)
# Take the GPU from the chat backend only when this load will actually use it.
# diffusers always does; a *force-native* sd.cpp load on a CUDA/XPU/MPS box does
# too. But a native sd.cpp load on a pure-CPU host never touches the GPU, so
# acquiring would evict the resident chat model for nothing -- skip the handoff.
# Take the GPU from the chat backend only when this load will actually use it,
# which is exactly the resolved device being non-CPU. diffusers on an accelerator
# and a force-native sd.cpp load on CUDA/XPU/MPS both resolve to that device; a
# native sd.cpp load on a pure-CPU host does not. Crucially, a CPU-only host with
# no usable sd-cli falls back to diffusers ON CPU -- that also never touches GPU
# memory, so keying off the engine name (not the device) would wrongly evict a
# resident chat model for a load that cannot use the GPU. Gate on the device.
device = await asyncio.to_thread(lambda: resolve_diffusion_device_target().device)
needs_gpu = active_engine_name() != ENGINE_SD_CPP or device != "cpu"
needs_gpu = device != "cpu"
if needs_gpu:
# Then kick the (slow) load onto a background thread and return at once --
# the client polls images/load-progress.
@ -11200,8 +11201,13 @@ async def generate_diffusion_image(
{
"prompt": request.prompt,
"negative_prompt": request.negative_prompt,
"width": request.width,
"height": request.height,
# Persist the ACTUAL output size, not the request sliders: Transform/
# Inpaint/Edit derive it from the uploaded image, Extend grows the
# canvas, and Upscale resizes it, so request.width/height would record
# (and later restore) the wrong dimensions for those workflows. For
# plain txt2img the image size equals the sliders anyway.
"width": getattr(image, "width", None) or request.width,
"height": getattr(image, "height", None) or request.height,
"steps": request.steps,
"guidance": request.guidance,
"seed": seed,
@ -11236,6 +11242,8 @@ async def list_gallery_images(
offset: int = 0,
current_subject: str = Depends(get_current_subject),
):
from pydantic import ValidationError
from core.inference import image_gallery
limit = max(1, min(limit, 200))
@ -11243,10 +11251,18 @@ async def list_gallery_images(
# Fetch one extra to learn whether more remain, without a second scan.
records = await asyncio.to_thread(image_gallery.list_images, limit + 1, offset)
has_more = len(records) > limit
return GalleryListResponse(
images = [GalleryImage(**r) for r in records[:limit]],
has_more = has_more,
)
# Build the response per record and drop any that fail schema validation: a PNG
# whose recipe chunk has all required keys but a wrong value type (e.g. a
# hand-dropped or corrupted file) passes the presence-only read but would raise
# inside GalleryImage(**r). Skipping it keeps one bad file from 500-ing the whole
# gallery listing.
images = []
for r in records[:limit]:
try:
images.append(GalleryImage(**r))
except ValidationError:
continue
return GalleryListResponse(images = images, has_more = has_more)
@studio_router.get("/images/gallery/{image_id}/file")

View file

@ -22,6 +22,14 @@ from core.inference.diffusion import (
_base_file_downloaded,
_resolve_diffusion_compute_dtype,
)
# diffusion.py imports the compile/arch patch modules LAZILY (they pull torch at module
# level, and diffusion.py must stay importable on a torchless native install). Import them
# here at collection time -- under the real torch -- so they are cached in sys.modules
# before the fake-torch fixtures swap it out; otherwise the lazy import inside load_pipeline
# would try to build them against the incomplete stub torch.
import core.inference.diffusion_eager_patches # noqa: E402,F401
import core.inference.diffusion_arch_patches # noqa: E402,F401
from core.inference.diffusion_families import (
detect_family,
resolve_base_repo,
@ -77,6 +85,34 @@ def test_detect_family_from_repo_id():
assert detect_family("meta-llama/Llama-3-8B") is None
def test_detect_family_matches_reject_and_alias_by_segment():
# Reject keywords and short aliases must match whole path/name segments, not raw
# substrings, so an unrelated word that merely CONTAINS one does not misroute a
# valid base model (regression: substring matching broke these).
assert detect_family("/models/edited/z-image-turbo-Q4_K_M.gguf").name == "z-image"
assert detect_family("unsloth/Z-Image-Edition-GGUF").name == "z-image"
assert detect_family("/models/kontextual/z-image-turbo-Q4_K_M.gguf").name == "z-image"
# Supported edit families still resolve (edit / kontext are whole tokens there).
assert detect_family("unsloth/Qwen-Image-Edit-2511-GGUF").name == "qwen-image-edit"
assert detect_family("unsloth/FLUX.1-Kontext-dev-GGUF").name == "flux.1-kontext"
# Unsupported variants sharing only a base arch keyword are still rejected.
assert detect_family("unsloth/Qwen-Image-Layered-GGUF") is None
assert detect_family("unsloth/Qwen-Image-2512-Inpaint") is None
def test_detect_family_edit_keyword_scoped_to_basename():
from core.inference.diffusion_families import detect_family_for_pick
# A parent directory named `edit`/`inpaint` must NOT poison a valid pick: only
# the model id / filename basename is scanned for reject keywords. A direct
# local pick arrives as (parent_dir, filename).
assert detect_family("/models/edit") is None # the dir alone is ambiguous
assert detect_family_for_pick("/models/edit", "Z-Image-Turbo-Q4.gguf").name == "z-image"
assert detect_family_for_pick("/models/inpaint", "qwen-image-2512-Q4.gguf").name == "qwen-image"
# A genuinely unsupported variant keyword in the FILENAME still rejects.
assert detect_family_for_pick("/models/misc", "Qwen-Image-Layered-Q4.gguf") is None
def test_detect_family_override():
assert detect_family("local/path", override = "z-image").name == "z-image"
assert detect_family("local/path", override = "zimage").name == "z-image"
@ -1424,10 +1460,30 @@ def test_validate_load_request(tmp_path):
backend.validate_load_request("some-org/Z-Image", gguf_filename = "model.safetensors")
with pytest.raises(ValueError, match = "family"):
backend.validate_load_request("meta/Llama-3", gguf_filename = "q.gguf")
# A family-looking repo paired with a non-GGUF single-file name is rejected here,
# BEFORE the route evicts chat and hands over the GPU (the background load would
# otherwise be the first to notice README.md is not a checkpoint).
with pytest.raises(ValueError, match = r"\.gguf"):
backend.validate_load_request("unsloth/Z-Image-Turbo-GGUF", gguf_filename = "README.md")
assert (
backend.validate_load_request("unsloth/Z-Image-Turbo-GGUF", gguf_filename = "q.gguf").name
== "z-image"
)
# A kind/extension mismatch fails fast here, before the route evicts chat + grabs the
# GPU only to fail in the background from_single_file path.
with pytest.raises(ValueError, match = ".gguf"):
backend.validate_load_request(
"unsloth/Z-Image-Turbo-GGUF", gguf_filename = "model.safetensors", model_kind = "gguf"
)
with pytest.raises(ValueError, match = "gguf"):
backend.validate_load_request(
"unsloth/Qwen-Image-2512-FP8", gguf_filename = "q.gguf", model_kind = "single_file"
)
# A remote "*-GGUF" repo loaded as a full pipeline (no single-file name) is a single-file
# GGUF repo, so from_pretrained would find no pipeline manifest and fail after chat is
# already evicted; reject it here before the GPU handoff.
with pytest.raises(ValueError, match = "GGUF"):
backend.validate_load_request("unsloth/Z-Image-Turbo-GGUF", model_kind = "pipeline")
# A local path with a missing child fails here (before any GPU/network work).
with pytest.raises(FileNotFoundError):
backend.validate_load_request(

View file

@ -135,6 +135,25 @@ def test_runtime_env_handles_missing_lib_path():
assert env[var] == "/opt/sdcpp/bin"
def test_terminate_reaps_killed_child():
# Cancellation/timeout paths call _terminate then immediately raise, so it must
# reap the killed child itself -- otherwise a burst of image cancellations leaves
# zombies until a later Popen cleanup. After _terminate the returncode is set
# (the child has been waited on), so nothing lingers.
import subprocess
proc = subprocess.Popen(
[sys.executable, "-c", "import time; time.sleep(30)"],
start_new_session = (os.name == "posix"),
)
try:
eng._terminate(proc)
assert proc.returncode is not None
finally:
if proc.poll() is None:
proc.kill()
proc.wait()
# ── generate (fake subprocess) ──────────────────────────────────────────────

View file

@ -83,6 +83,11 @@ 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.
if (pathname === "/images" || pathname.startsWith("/images/")) return true;
return false;
}
@ -159,6 +164,13 @@ function RootLayout() {
setImagesMounted(true);
}
const shouldMountImages = isImagesRoute || imagesMounted;
// Chat and Images both render their own full-height shell (a fixed top rail + an
// internally-scrolling body), so both want the chat-style layout: no outer pt-14
// inset and no outer scroll. Keying the layout off isChatRoute alone gave /images
// the non-chat pt-14 + outer overflow, pushing its 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.
const isChatLike = isChatRoute || isImagesRoute;
useTrainingUnloadGuard();
// Global export driver: streams worker logs and tracks status from any route
@ -251,10 +263,10 @@ function RootLayout() {
className="!min-h-0 h-[calc(100dvh-var(--studio-titlebar-height,0px))] overflow-hidden"
>
<AppSidebar />
<SidebarInset className={isChatRoute ? "overflow-hidden" : "overflow-y-auto"}>
<SidebarInset className={isChatLike ? "overflow-hidden" : "overflow-y-auto"}>
<Navbar />
<div
className={`relative flex min-h-0 min-w-0 flex-1 basis-0 flex-col ${isChatRoute ? "overflow-hidden" : "overflow-visible"} ${isChatRoute ? "" : "pt-14 md:pt-[var(--studio-non-chat-content-top-inset,var(--studio-content-top-inset,0px))] md:[--studio-titlebar-height:var(--studio-non-chat-content-top-inset,var(--studio-content-top-inset,0px))]"}`}
className={`relative flex min-h-0 min-w-0 flex-1 basis-0 flex-col ${isChatLike ? "overflow-hidden" : "overflow-visible"} ${isChatLike ? "" : "pt-14 md:pt-[var(--studio-non-chat-content-top-inset,var(--studio-content-top-inset,0px))] md:[--studio-titlebar-height:var(--studio-non-chat-content-top-inset,var(--studio-content-top-inset,0px))]"}`}
>
{/* Stays mounted across navigation so an in-flight generation is
not cancelled when leaving /chat; hidden (not unmounted) off-route.
@ -280,7 +292,7 @@ function RootLayout() {
<div
className={
isImagesRoute
? "flex min-h-0 min-w-0 flex-1 basis-0 flex-col overflow-visible"
? "flex min-h-0 min-w-0 flex-1 basis-0 flex-col overflow-hidden"
: "hidden"
}
inert={!isImagesRoute || undefined}

View file

@ -1072,11 +1072,19 @@ const IMAGE_EDIT_KEYWORDS = ["edit", "kontext", "inpaint", "layered"] as const;
// 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.
function idHasSegment(id: string, keyword: string): boolean {
return new RegExp(`(?:^|[-_./\\\\])${keyword}(?:$|[-_./\\\\])`).test(id);
}
function isImageEditModel(repoId: string | null | undefined): boolean {
if (!repoId) return false;
const id = repoId.toLowerCase();
if (SUPPORTED_EDIT_KEYWORDS.some((kw) => id.includes(kw))) return false;
return IMAGE_EDIT_KEYWORDS.some((kw) => id.includes(kw));
if (SUPPORTED_EDIT_KEYWORDS.some((kw) => idHasSegment(id, kw))) return false;
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

View file

@ -755,6 +755,31 @@ 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.
const MAX_SIDE = 4096;
const longest = Math.max(nw, nh);
if (longest > MAX_SIDE) {
const scale = MAX_SIDE / longest;
const sw = Math.max(1, Math.round(nw * scale));
const sh = Math.max(1, Math.round(nh * scale));
const scaleCanvas = (source: HTMLCanvasElement): HTMLCanvasElement => {
const dst = document.createElement("canvas");
dst.width = sw;
dst.height = sh;
const dctx = dst.getContext("2d");
if (!dctx) throw new Error("Could not scale the extended canvas");
dctx.drawImage(source, 0, 0, sw, sh);
return dst;
};
return {
image: scaleCanvas(ic).toDataURL("image/png"),
mask: scaleCanvas(mc).toDataURL("image/png"),
};
}
return { image: ic.toDataURL("image/png"), mask: mc.toDataURL("image/png") };
}
@ -947,6 +972,12 @@ 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".
const quantRevert = useRef<{ prev: string | null } | null>(null);
const dismissLoadToast = useCallback(() => {
if (loadToastId.current != null) toast.dismiss(loadToastId.current);
@ -1182,12 +1213,22 @@ export function ImagesPage({ active = true }: { active?: boolean }) {
setStatus(await getDiffusionStatus());
toast.success("Model loaded");
setBusy(null);
// Load succeeded: the optimistic quant is now the real one, so drop the
// pending revert.
quantRevert.current = null;
return;
}
if (p.phase === "error") {
dismissLoadToast();
toast.error(p.error || "Failed to load model");
setBusy(null);
// A load that failed AFTER starting leaves the previous pipeline loaded, so
// roll the optimistic quant label back to what is actually loaded (status
// does not carry the quant, so refreshStatus alone can't correct it).
if (quantRevert.current) {
setQuant(quantRevert.current.prev);
quantRevert.current = null;
}
// A failed load may have freed a previously-loaded model, so resync to
// the real backend state (the synchronous failure path does the same).
void refreshStatus();
@ -1200,6 +1241,11 @@ export function ImagesPage({ active = true }: { active?: boolean }) {
// busy stuck on "loading", deadening the picker and Generate button.
dismissLoadToast();
setBusy(null);
// Same optimistic-quant rollback as the error path: the swap did not take.
if (quantRevert.current) {
setQuant(quantRevert.current.prev);
quantRevert.current = null;
}
void refreshStatus();
return;
}
@ -1341,17 +1387,23 @@ export function ImagesPage({ active = true }: { active?: boolean }) {
return;
}
// GGUF quant pick from the variant expander. Optimistic for instant picker
// feedback, but revert if the load fails to START (400/409/network): the
// selector must not advertise a quant that is not the loaded one. Poll-phase
// failures re-sync via refreshStatus.
// 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.
if (meta.ggufVariant && meta.ggufFilename) {
const prevQuant = quant;
quantRevert.current = { prev: prevQuant };
setQuant(meta.ggufVariant);
const dq = defaultsFor(id);
setSteps(dq.steps);
setGuidance(dq.guidance);
void handleLoad(id, { kind: "gguf", filename: meta.ggufFilename }).then((started) => {
if (!started) setQuant(prevQuant);
if (!started) {
setQuant(prevQuant);
quantRevert.current = null;
}
});
return;
}
@ -1366,14 +1418,19 @@ export function ImagesPage({ active = true }: { active?: boolean }) {
if (!filename.toLowerCase().endsWith(".gguf")) return;
// A direct pick carries no curated variant label; surface the filename so
// the selector stops advertising the previously loaded quant. Optimistic,
// reverted if the load fails to start (mirrors the curated branch above).
// reverted if the load fails to start OR fails later in the poll (mirrors the
// curated branch above; the poll owns the after-start revert via quantRevert).
const prevQuant = quant;
quantRevert.current = { prev: prevQuant };
setQuant(filename);
const dq2 = defaultsFor(id);
setSteps(dq2.steps);
setGuidance(dq2.guidance);
void handleLoad(dir, { kind: "gguf", filename }).then((started) => {
if (!started) setQuant(prevQuant);
if (!started) {
setQuant(prevQuant);
quantRevert.current = null;
}
});
return;
}

View file

@ -262,6 +262,10 @@ def _maybe_fetch_windows_cudart(release: dict, chosen: str, target: Path) -> Non
print(f"downloading CUDA runtime {cudart['name']} ...", flush = True)
try:
_download(cudart["browser_download_url"], dest)
# Verify integrity BEFORE extracting, like the main sd-cli archive: these DLLs are
# loaded into sd-cli.exe at runtime, so a corrupt/tampered runtime archive must be
# rejected rather than extracted next to the binary.
_verify_sha256(dest, cudart.get("digest"))
with zipfile.ZipFile(dest) as zf:
_safe_extractall(zf, target)
finally: