{/* 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() {
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
diff --git a/studio/frontend/src/features/images/images-page.tsx b/studio/frontend/src/features/images/images-page.tsx
index 863bdf5752..58340003c9 100644
--- a/studio/frontend/src/features/images/images-page.tsx
+++ b/studio/frontend/src/features/images/images-page.tsx
@@ -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(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".
+ 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;
}
diff --git a/studio/install_sd_cpp_prebuilt.py b/studio/install_sd_cpp_prebuilt.py
index 193c5e4670..639e3a894d 100644
--- a/studio/install_sd_cpp_prebuilt.py
+++ b/studio/install_sd_cpp_prebuilt.py
@@ -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: