Guard old diffusers, stream video exports, record conditioned recipes

Three fixes from review.

The 0.39-only pipeline classes (Flux2Klein, Z-Image, Krea 2, LTX-2,
HunyuanImage) were resolved by getattr deep in the load, so on the older
diffusers that packaging still allows on Python 3.9 -- diffusers dropped 3.9 in
0.38 and this project still supports it, so the 0.39 floor has to be conditional
or the extra becomes unresolvable -- an advertised model failed with a bare
AttributeError after its checkpoint had already been downloaded. Krea 2 already
guarded itself this way; assert_pipeline_class_available now runs the same check
for every image and video family from validation, before any fetch, and names
the version and the fix.

WebM export accumulated the whole VP9 output in a BytesIO and returned it as one
bytes object that the response held again. The request caps allow 2048x2048 for
1024 frames, so an export runs to hundreds of MB and concurrent clicks could
exhaust the process, while the MP4 route beside it already streamed from disk.
transcode_to_file encodes to a temp file and the route returns a FileResponse
with a background unlink, so nothing large is resident.

A conditioned generation's recipe carried only the txt2img fields, so the
gallery presented an inpaint or upscale result as a complete Create recipe and
restoring it replayed an unrelated text-to-image request. The images themselves
are still not persisted (user uploads with their own lifetime), but the workflow
and its scalars are, restore reapplies them, and the toast now names the inputs
that have to be supplied again instead of silently landing on Create.

Reported by Codex.
This commit is contained in:
Daniel Han 2026-07-27 07:41:12 +00:00
commit 9a933d375b
13 changed files with 279 additions and 23 deletions

View file

@ -35,6 +35,7 @@ from .diffusion_families import (
IDEOGRAM4_FAMILY_NAME,
LUMINA2_FAMILY_NAME,
DiffusionFamily,
assert_pipeline_class_available,
default_generation_params,
detect_family_for_pick,
excluded_model_reason,
@ -727,6 +728,9 @@ class DiffusionBackend:
f"pass family_override with that family name. (Video models and image models "
f"whose diffusers transformer has no single-file loader are not supported.)"
)
# Refuse a too-old diffusers here rather than deep in the load, after the checkpoint has
# already been downloaded (Flux2Klein / Z-Image / Krea 2 / HunyuanImage are 0.39-only).
assert_pipeline_class_available(fam.pipeline_class, fam.name)
# Families whose single file IS the whole pipeline have no GGUF path; reject before eviction.
if kind == "gguf" and fam.single_file_is_pipeline:
raise ValueError(
@ -3161,6 +3165,11 @@ class DiffusionBackend:
# compile and the generate request then carries none, so a recipe built from
# the request alone claimed no LoRA for an image that plainly used one.
"active_loras": _active_lora_pairs(state.pipe),
# The workflow this generation ACTUALLY ran, as resolved above from which
# conditioning inputs were present. The recipe records it so a conditioned
# image is not presented as a plain Create recipe that would replay as
# something unrelated.
"workflow": workflow,
}
finally:
# Deregister so a later unload/load can't poke a finished generation (if still ours).

View file

@ -620,6 +620,27 @@ def family_prequant_repo(
return None
def assert_pipeline_class_available(pipeline_class: str, family_name: str) -> None:
"""Raise before any download when the installed diffusers has no ``pipeline_class``.
The newer families (Flux2Klein, Z-Image, Krea 2, LTX-2, HunyuanImage) only exist from diffusers
0.39, and the packaging leaves an older diffusers installable on Python 3.9 -- diffusers dropped
3.9 in 0.38 and this project still supports it, so the 0.39 floor has to be conditional or the
whole extra becomes unresolvable. Without this check the getattr chain died with a bare
AttributeError deep in the load, after the checkpoint had already been fetched, which is an
expensive way to learn the environment is too old. Krea 2 already guarded itself this way; this
is the same check for every family, run from validation."""
import diffusers
if hasattr(diffusers, pipeline_class):
return
raise RuntimeError(
f"'{family_name}' needs diffusers >= 0.39.0 ({pipeline_class}); this environment has "
f"diffusers {getattr(diffusers, '__version__', 'unknown')}. Upgrade with: "
f"pip install -U diffusers (which needs Python >= 3.10; diffusers dropped 3.9 in 0.38)."
)
def family_gguf_loadable(fam: DiffusionFamily) -> bool:
"""True when a GGUF transformer can be assembled for this family.

View file

@ -394,6 +394,11 @@ class VideoBackend:
f"{', '.join(supported_video_family_names())}. If this is a variant of one "
f"of them, pass family_override with that family name."
)
# Refuse a too-old diffusers here rather than deep in the load, after the checkpoint has
# already been downloaded (LTX2Pipeline / HunyuanVideo15Pipeline are 0.39-only).
from .diffusion_families import assert_pipeline_class_available
assert_pipeline_class_available(fam.pipeline_class, fam.name)
if kind != "gguf" and not _is_trusted_video_repo(repo_id):
raise ValueError(
f"Non-GGUF video loads are limited to unsloth/* repos, the official "

View file

@ -78,33 +78,61 @@ def video_path(video_id: str) -> Optional[Path]:
return path if path.is_file() else None
def transcode(video_id: str, fmt: str) -> Optional[bytes]:
"""Re-encode a stored MP4 for the Download menu: "webm" (VP9) or "gif". Returns the bytes, or
None when the id doesn't resolve. Raises RuntimeError on missing codec/deps (route 501s). MP4
downloads stream the original via /file, not here."""
def transcode_to_file(video_id: str, fmt: str) -> Optional[Path]:
"""Re-encode a stored MP4 for the Download menu into a TEMP FILE and return its path, or None
when the id doesn't resolve. Raises RuntimeError on missing codec/deps (route 501s). The caller
owns the file and must delete it after serving.
A file rather than a buffer because the request caps allow 2048x2048 x 1024 frames: a VP9
export of a clip that size runs to hundreds of MB, and holding it as one ``bytes`` (then again
in the response) let a couple of concurrent export clicks exhaust the process. The MP4 route
already streams from disk; this makes the transcodes behave the same way."""
# Ownership-gate like /file: only transcode a Studio-owned clip (readable sidecar), so a guessed
# stem for a foreign/orphan MP4 the gallery hides can't be re-encoded out either.
path = owned_video_path(video_id)
if path is None:
return None
normalized = fmt.strip().lower()
if normalized == "webm":
return _transcode_webm(path)
if normalized == "gif":
return _transcode_gif(path)
raise ValueError(f"Unsupported export format '{fmt}'. Use webm or gif.")
if normalized not in ("webm", "gif"):
raise ValueError(f"Unsupported export format '{fmt}'. Use webm or gif.")
import tempfile
fd, tmp_name = tempfile.mkstemp(prefix = f"unsloth-export-{video_id}-", suffix = f".{normalized}")
os.close(fd)
dest = Path(tmp_name)
try:
if normalized == "webm":
_transcode_webm(path, dest)
else:
# GIF is already bounded by _GIF_MAX_FRAMES / _GIF_MAX_EDGE, so it is built in memory
# and written out; the cap is what keeps that safe.
dest.write_bytes(_transcode_gif(path))
except BaseException:
dest.unlink(missing_ok = True)
raise
return dest
def _transcode_webm(path: Path) -> bytes:
import io
def transcode(video_id: str, fmt: str) -> Optional[bytes]:
"""``transcode_to_file`` read back into memory. Kept for callers that want the bytes; the route
uses the file form so a large export is never fully resident."""
dest = transcode_to_file(video_id, fmt)
if dest is None:
return None
try:
return dest.read_bytes()
finally:
dest.unlink(missing_ok = True)
def _transcode_webm(path: Path, dest: Path) -> None:
"""Transcode ``path`` to VP9 (+ Opus when the clip has audio) at ``dest``."""
try:
import av
except Exception as exc: # noqa: BLE001 -- no PyAV -> no transcode
raise RuntimeError("WebM export needs the 'av' package (PyAV).") from exc
buf = io.BytesIO()
try:
with av.open(str(path)) as src, av.open(buf, "w", format = "webm") as dst:
with av.open(str(path)) as src, av.open(str(dest), "w", format = "webm") as dst:
if not src.streams.video:
raise RuntimeError("WebM export failed: the clip has no video stream.")
in_v = src.streams.video[0]
@ -171,7 +199,6 @@ def _transcode_webm(path: Path) -> bytes:
raise
except Exception as exc: # noqa: BLE001 -- surface as "encoder unavailable"
raise RuntimeError(f"WebM export failed (libvpx-vp9 unavailable?): {exc}") from exc
return buf.getvalue()
# Ceilings for a GIF export, which must hold every kept frame in memory before encoding. 720 px

View file

@ -2587,6 +2587,24 @@ class GalleryImage(BaseModel):
controlnet: Optional[str] = Field(
None, description = "ControlNet applied, formatted as 'id:control_type:strength'"
)
# Conditioned-workflow settings. The images themselves are NOT persisted (user uploads with
# their own lifetime), so these say what ran and let the client tell the user which inputs it
# needs back instead of silently restoring a conditioned image as a plain Create.
workflow: Optional[str] = Field(
None,
description = "Workflow that produced it: txt2img, img2img, inpaint, upscale, edit, "
"reference or controlnet. Absent on records written before this was recorded.",
)
strength: Optional[float] = Field(
None, description = "img2img/inpaint denoise strength, when the workflow used one"
)
upscale: Optional[float] = Field(None, description = "Upscale factor, for the upscale workflow")
controlnet_guidance: Optional[str] = Field(
None, description = "ControlNet guidance interval, formatted as 'start:end'"
)
reference_image_count: Optional[int] = Field(
None, description = "How many reference images the reference workflow used"
)
created_at: float = Field(..., description = "Creation time (epoch seconds)")

View file

@ -16390,6 +16390,22 @@ async def generate_diffusion_image(
if request.controlnet and request.controlnet.strength > 0
else None
),
# The conditioned workflows (Transform/Inpaint/Extend/Upscale/Edit/reference/
# ControlNet) keep their scalar settings here. The source, mask, reference and
# control IMAGES are deliberately not persisted -- they are user uploads with
# their own lifetime, and copying them into every recipe would grow the gallery
# without bound -- so a recipe records what it ran, and the client says plainly
# that the images have to be supplied again rather than silently replaying as
# a plain Create.
"workflow": result.get("workflow"),
"strength": request.strength,
"upscale": request.upscale,
"controlnet_guidance": (
f"{request.controlnet.guidance_start:g}:{request.controlnet.guidance_end:g}"
if request.controlnet and request.controlnet.strength > 0
else None
),
"reference_image_count": len(request.reference_images or []) or None,
"created_at": created_at,
},
)

View file

@ -356,18 +356,29 @@ async def export_gallery_video(
if fmt not in ("webm", "gif"):
raise HTTPException(status_code = 400, detail = "Unsupported format. Use webm or gif.")
try:
data = await asyncio.to_thread(video_gallery.transcode, video_id, fmt)
path = await asyncio.to_thread(video_gallery.transcode_to_file, video_id, fmt)
except RuntimeError as exc:
raise HTTPException(status_code = 501, detail = str(exc)) from exc
if data is None:
if path is None:
raise HTTPException(status_code = 404, detail = "Video not found.")
from fastapi.responses import Response
from fastapi.responses import FileResponse
from starlette.background import BackgroundTask
return Response(
content = data,
def _cleanup() -> None:
try:
path.unlink(missing_ok = True)
except OSError as e: # noqa: BLE001 -- a leaked temp file must not fail the download
logger.debug(f"Could not remove the export temp file {path}: {e}")
# FileResponse streams from disk, so a large VP9 export is never fully resident (the caps allow
# 2048x2048 x 1024 frames). The temp file is deleted once the response has been sent.
return FileResponse(
path,
media_type = "video/webm" if fmt == "webm" else "image/gif",
filename = f"{video_id}.{fmt}",
# Transcodes are deterministic per id+format; let the browser cache them.
headers = {"Cache-Control": "private, max-age=31536000, immutable"},
background = BackgroundTask(_cleanup),
)

View file

@ -1778,3 +1778,36 @@ def test_hub_local_rows_are_tagged_with_their_task():
src = inspect.getsource(local_inventory.list_local_models_response)
assert "_local_model_task" in src
assert 'model_copy(update = {"task"' in src
def test_pipeline_class_guard_fires_before_any_download():
# The 0.39-only families (Flux2Klein, Z-Image, Krea 2, LTX-2, HunyuanImage) used to die with a
# bare AttributeError deep in the load, after the checkpoint had been fetched, on the older
# diffusers that packaging still allows on Python 3.9. Validation refuses first, with the
# version and the fix in the message.
import pytest
from core.inference.diffusion_families import _FAMILIES, assert_pipeline_class_available
# Present -> no raise (every shipped family resolves on a current diffusers).
import diffusers
for fam in _FAMILIES:
assert_pipeline_class_available(fam.pipeline_class, fam.name)
stub = types.SimpleNamespace(__version__ = "0.37.0")
real = sys.modules.get("diffusers")
sys.modules["diffusers"] = stub
try:
with pytest.raises(RuntimeError) as excinfo:
assert_pipeline_class_available("ZImagePipeline", "z-image")
finally:
if real is not None:
sys.modules["diffusers"] = real
else: # pragma: no cover
del sys.modules["diffusers"]
msg = str(excinfo.value)
assert "z-image" in msg and "ZImagePipeline" in msg
assert "0.39" in msg and "0.37.0" in msg
assert "3.10" in msg # names the Python floor that carries a new enough diffusers
assert diffusers is not None

View file

@ -123,6 +123,12 @@ class _FakeBackend:
"images": [object() for _ in range(batch_size)],
"seed": seed if seed is not None else 4242,
"repo_id": "x/z-image",
# The real backend reports the workflow it resolved; the recipe records it.
"workflow": (
"inpaint"
if kwargs.get("mask_image")
else ("img2img" if kwargs.get("init_image") else "txt2img")
),
}
def generate_progress(self):
@ -1093,3 +1099,41 @@ def test_load_refused_when_only_the_diffusion_probe_can_be_read(client, monkeypa
json = {"model_path": "x/z-image", "gguf_filename": "q.gguf"},
)
assert resp.status_code == 200
def test_recipe_records_the_conditioned_workflow_settings(client, monkeypatch):
# A conditioned generation's recipe used to carry only the txt2img fields, so the gallery
# presented an inpaint result as a complete Create recipe and restoring it replayed an
# unrelated text-to-image request. The images are still not persisted (user uploads with their
# own lifetime), but what ran IS, so the client can say which inputs to supply again.
import base64
import io
from PIL import Image
client.post(
"/api/inference/images/load", json = {"model_path": "x/z-image", "gguf_filename": "q.gguf"}
)
buf = io.BytesIO()
Image.new("RGB", (8, 8), (120, 30, 90)).save(buf, format = "PNG")
px = base64.b64encode(buf.getvalue()).decode()
gen = client.post(
"/api/inference/images/generate",
json = {
"prompt": "a sloth",
"seed": 7,
"init_image": px,
"mask_image": px,
"strength": 0.42,
},
)
assert gen.status_code == 200
img = gen.json()["images"][0]
assert img["workflow"] == "inpaint"
assert img["strength"] == 0.42
# A plain txt2img still records its own workflow and leaves the conditioning fields empty.
plain = client.post("/api/inference/images/generate", json = {"prompt": "a sloth", "seed": 7})
assert plain.status_code == 200
plain_img = plain.json()["images"][0]
assert plain_img["workflow"] == "txt2img"
assert plain_img["strength"] is None and plain_img["upscale"] is None

View file

@ -470,6 +470,41 @@ def test_transcode_unknown_id_and_bad_format():
gallery.transcode(record["id"], "avi")
def test_transcode_to_file_writes_a_temp_file_the_caller_owns():
# The route streams the export from disk instead of materialising it: the request caps allow
# 2048x2048 x 1024 frames, and a VP9 export of that size held as bytes (then again in the
# response) let concurrent exports exhaust the process.
record = gallery.save(_real_mp4_bytes(), _meta())
for fmt, magic in (("webm", b"\x1a\x45\xdf\xa3"), ("gif", b"GIF8")):
path = gallery.transcode_to_file(record["id"], fmt)
assert path is not None and path.is_file(), fmt
assert path.suffix == f".{fmt}"
assert path.read_bytes()[: len(magic)] == magic
# It is a temp file, NOT something inside the gallery: deleting it must not touch the clip.
path.unlink()
assert gallery.video_path(record["id"]) is not None
assert gallery.transcode_to_file("does-not-exist", "webm") is None
def test_transcode_to_file_leaves_no_temp_file_when_the_encode_fails(monkeypatch):
# A half-written export must not accumulate in the temp dir on a host with no VP9 encoder.
import tempfile
from core.inference import video_gallery as vg
record = gallery.save(_real_mp4_bytes(), _meta())
def _boom(src, dest):
dest.write_bytes(b"partial")
raise RuntimeError("WebM export failed (libvpx-vp9 unavailable?)")
monkeypatch.setattr(vg, "_transcode_webm", _boom)
before = set(Path(tempfile.gettempdir()).glob("unsloth-export-*"))
with pytest.raises(RuntimeError):
vg.transcode_to_file(record["id"], "webm")
assert set(Path(tempfile.gettempdir()).glob("unsloth-export-*")) == before
def test_gif_export_bounds_frames_and_edge(monkeypatch):
"""Every kept frame is held as a paletted image before the encoder runs, so an unbounded walk
is a memory bomb: the generate request allows 2048x2048 for 1024 frames, and at the 12 fps

View file

@ -774,7 +774,7 @@ def test_export_endpoint_validation(client, monkeypatch):
def _boom(video_id, fmt):
raise RuntimeError("WebM export needs the 'av' package (PyAV).")
monkeypatch.setattr(gallery_module, "transcode", _boom)
monkeypatch.setattr(gallery_module, "transcode_to_file", _boom)
client.post(
"/api/inference/video/load",
json = {"model_path": "unsloth/LTX-2.3-GGUF", "gguf_filename": "q.gguf"},

View file

@ -175,6 +175,14 @@ export interface GalleryImage {
model: string | null;
loras?: string[];
controlnet?: string | null;
// Conditioned-workflow settings. The source/mask/reference/control images are not persisted, so
// these say what ran and let restore name the inputs the user has to supply again. Absent on
// records written before they were recorded.
workflow?: string | null;
strength?: number | null;
upscale?: number | null;
controlnet_guidance?: string | null;
reference_image_count?: number | null;
created_at: number;
}

View file

@ -181,6 +181,20 @@ const WORKFLOW_TABS: Array<{
// Per-model generation defaults (steps + guidance), matched by repo-id substring,
// most specific first. Distilled "turbo/schnell" models want few steps and little
// guidance; the full "dev" models want more steps and real CFG.
// The images each conditioned workflow consumed, named for the restore toast. A recipe keeps the
// scalar settings but not the uploads themselves, so restoring one of these lands on Create and the
// user needs to know which input to add back rather than pressing Generate on a silently different
// request. Keys are the backend's own workflow strings (diffusion.py); txt2img is absent because it
// restores completely.
const CONDITIONED_WORKFLOW_INPUTS: Record<string, string> = {
img2img: "the source image",
inpaint: "the source image and mask",
upscale: "the source image",
edit: "the source image",
reference: "the source and reference images",
controlnet: "the control image",
};
// Generation defaults when the model is unrecognised: the distilled few-step /
// no-CFG shape. Also seeds the sliders' initial state.
const DEFAULT_GEN = { steps: 9, guidance: 0 };
@ -1550,11 +1564,18 @@ export function ImagesPage({ active = true }: { active?: boolean }) {
if (id && Number.isFinite(weight)) restoredLoras.push({ id, weight });
}
setLoras(restoredLoras);
// The conditioned workflows' scalar settings ARE persisted, so restore them even though the
// form returns to Create: they are what the user re-applies after adding the image back.
if (typeof image.strength === "number") {
if (image.workflow === "upscale") setUpscaleStrength(image.strength);
else setStrength(image.strength);
}
if (typeof image.upscale === "number") setUpscaleFactor(image.upscale);
// None of the conditioning images are persisted in a recipe, so a restore must not leave the
// form pointing at whatever is currently loaded in the Transform / Inpaint / Edit tabs: the
// next Generate would condition on an unrelated image and reproduce something else entirely.
// Clear them all and return to Create, the workflow the restored recipe actually describes
// (the workflow-validity effect snaps this back if the loaded model is edit-only).
// Clear them all and return to Create (the workflow-validity effect snaps this back if the
// loaded model is edit-only).
setWorkflow("create");
setInitImage(null);
setMaskImage(null);
@ -1562,7 +1583,15 @@ export function ImagesPage({ active = true }: { active?: boolean }) {
// The control image isn't persisted, so clear any stale ControlNet selection.
setControlnetId("");
setControlImage(null);
toast.success("Settings restored to inputs");
// Say so, rather than letting a conditioned image restore as a plain Create that quietly
// generates something unrelated. The scalar settings ARE restored above; only the images the
// workflow consumed have to be supplied again.
const conditioned = CONDITIONED_WORKFLOW_INPUTS[image.workflow ?? ""];
if (conditioned) {
toast.success(`Settings restored. Add ${conditioned} again to reproduce this image.`);
} else {
toast.success("Settings restored to inputs");
}
}, []);
// A locked ratio keeps the paired dimension in step while dragging; "custom"