Collapse the multi-line comment blocks across the image, video, sd.cpp and diffusion-training code to one or two lines each, and drop comments that only restate the statement below them. Comments only, no code or behaviour changes.
An fp8 text-encoder request loads a hosted PRE-CAST checkpoint, but the image
download plan never received text_encoder_quant, so the manager staged the base
repo's dense encoder (FLUX.2-dev's Mistral-24B is ~48 GB, Qwen-Image's
Qwen2.5-VL ~16.6 GB) and the load then pulled the pre-cast file inline, outside
the manager's progress and disk preflight. The plan now takes the field,
resolves the hosted artifact with the same resolver the injection uses, stages
that file, and drops only those components' dense weight shards. The load's own
prefetch takes the same treatment, since it paid the same cost. Only a
checkpoint that really resolves on the Hub earns the drop, so a gated or renamed
artifact still stages the dense encoder the load will fall back to.
The two trainers admitted each other with independent check-then-act guards:
the diffusion route checks the LLM backend several network-bound preflights
before it reserves, and the LLM route checks the diffusion service well before
it spawns, so two near-simultaneous starts could both pass and train on one GPU.
reserve() now re-tests the LLM backend under its own lock, and the LLM route
holds the diffusion service's gpu_load_admission across its spawn, so exactly
one of the two wins. Both halves fail open, so a chat-only install still
trains.
A cancelled video load could resume: begin_load cleared the shared cancel
event, and unload() drops _loading without waiting for the worker, so the
next load cleared the very object the cancelled worker was watching and its
multi-gigabyte pull ran on alongside the replacement until the token check
at the end. Each load now gets its own threading.Event, passed down through
_fetch_te_prequant and _predownload_base, so a cancelled worker stays
cancelled.
A cached repo with a model_index.json was advertised as text-to-image on the
trust rule alone, but validate_load_request also requires a detected image
family, so a trusted pipeline of an unsupported class produced a picker row
that deterministically 400s. The picker now applies both gates, mirroring the
video branch.
cond_cache_dir was accepted for sdxl and then ignored: only the DiT trainer
reads it, while the SDXL trainer builds a per-run in-memory latent cache, so
the promised cross-run reuse never happened. The route now refuses it with a
400 that names the families which do support it, checked against the resolved
family so an omitted model_family with an SDXL base is caught too.
DiffusionLoraConfig has carried cond_cache_dir for a while and the DiT trainer
acts on it, but DiffusionTrainingStartRequest omitted the field, so Pydantic
dropped it silently and every API-driven run fell back to the in-memory cache
that is rebuilt from scratch each time. The warm path skips loading the VAE and
the multi-GB text encoders on a rerun whose images, captions and resolution are
unchanged, so this was a real capability that could not be reached.
Contained like output_dir rather than left to the trainer subprocess's cwd,
since it is another directory the trainer writes to. Blank or omitted still
means the in-memory cache, so it must not resolve to the outputs root.
- The image and video load guards read is_active() and only then selected an
engine, acquired the arbiter and registered the load. A /train/diffusion/start
reserving inside that window freed residents the load had not registered yet,
so the trainer came up beside a brand-new pipeline. The service already had
exactly the right pattern for this in dataset_mutation, so gpu_load_admission
mirrors it: reserve() refuses while an admission is open, an admission refuses
once a start is reserved, both decided under the one lock. The span is only the
registration, since begin_load returns as soon as the load is registered and
_free_gpu_for_diffusion_training preempts an in-flight load from that point.
Chat is deliberately not covered: its load spans an eviction plus a multi-minute
GGUF load, and it admits models that fit beside training by design, which is a
different contract from the diffusion pipeline's all-or-nothing one.
- Hugging Face gives the LTX-2 family the image-to-video pipeline_tag (both
Lightricks/LTX-2 and unsloth/LTX-2.3-GGUF report it), so a text-to-video-only
filter dropped the flagship audio family out of Video Hub search while the rest
of the app routed it to Video.
- Task-scoped quant fit sized picks against the LARGEST visible device while
resolve_diffusion_device_target returns a bare "cuda" and torch places on the
current one. On a heterogeneous host that recommended a checkpoint sized for the
bigger card and then loaded it onto the smaller one. Fit now uses the device the
load actually lands on; identical on a homogeneous host.
Six tests read family_train_infos() (or a start preflight) without pinning the
host probes, so they only held on a machine with a bf16 accelerator: on a
GPU-less runner the DiT gate empties precision_modes, turns supports_compile
off, and replaces any other preflight message with the no-accelerator note, and
all six failed there. A conftest fixture pins both probes for exactly those
tests, so they assert the family metadata they are about on every host. The
gate's own CPU-only behaviour keeps its dedicated tests.
- The cached-model listing tagged any repo with a model_index.json as
text-to-image, so a community pipeline the image loader's trust rule refuses
still got a row in the Images picker, and a detected-but-untrusted video repo
fell through to that same tag. Gate the image tag on the load path's rule and
hide an untrusted video repo outright.
- A routed diffusion pick only carries a GGUF filename, which is all the chat
picker has, so a curated single-file artifact arrived with no quant and was
loaded as a pipeline: from_pretrained on a repo with no model_index.json. Pass
the page's own catalog spec into the route pick, so a routed pick resolves to
exactly what a direct pick on that page resolves to.
- The dataset mutation endpoints checked is_active() and only then handed their
filesystem work to a thread, so a start reserving in that gap changed captions
or removed images underneath the preflight or the running trainer. The
interlock is now registered for the whole request under the lock reserve()
uses, and a start refuses while a mutation is open rather than waiting on it.
- GIF export held every kept frame as a paletted image before encoding; a clip
may be 2048x2048 for 1024 frames, and at the 12 fps target the step is 1, so
one export click could allocate over 4 GB and take the backend down. Downscale
past 720 px and widen the step to keep at most 300 frames.
- seed accepted any Python int, so an out-of-range one passed every preflight,
evicted the resident models, spawned the trainer and only then died in
torch.manual_seed. Bound it to torch's 64-bit range in the request and config.
- source_revision() scanned the checkpoint root plus text_encoder/tokenizer but
not vae, so swapping or fine-tuning the VAE in place left the conditioning
cache namespace unchanged and a warm run trained against latents from the old
checkpoint. Include the vae directory, like any other component the cached
tensors come from.
- /images/generate answers only when the images are saved, and secure mode's
tunnel caps an origin response near 100 seconds, which a native CPU or a
high-step run passes routinely. The page reported failure while the work kept
running, and a retry would duplicate it. A lost response (fetch rejection or
a gateway status the origin never answered) is now told apart from a refusal:
the page waits out generate-progress and reloads the gallery, so the run it
started still lands.
- The trainer emits the EMA adapter's path with the terminal event, but the
state update dropped it, so neither the run history nor either response
schema carried it and an enabled EMA left nothing discoverable. Keep it, and
show it next to the primary adapter.
- weighting_scheme advertised a choice of timestep sampling; sampling is always
logit-normal and the flag only selects the bell loss weights. Describe what
it does.
Six review findings, three of them evict-then-fail orderings:
- The chat load reclaimed the GPU without telling the arbiter it existed. A
chat load holds no llama-server process until its GGUF has downloaded,
which is minutes, so a competing Images/Video acquire in that window
found nothing to cancel, took the GPU, and the chat load then spawned
onto the same device. It now registers an in-flight marker through
acquire_for's register hook (under the arbiter lock, as the image and
video loads do), the evictor cancels a marked load, and the route undoes
itself if ownership moved while it loaded.
- The Hub-download conflict check ran after that handoff, so a GGUF the
download manager already owns destroyed the resident Images/Video
pipeline and then 409'd, having loaded nothing. It moves above the
handoff, together with the marker it handshakes with.
- The image load released the engine router's transition lock before
registering the load, so a second load choosing the other engine could
unload the still-idle engine this one captured; the load then landed on a
deactivated engine, where generate, status, unload and the arbiter's
evictor can no longer reach it. Registration now happens under that lock
and refuses if the engine changed.
- Training a DiT family on a host with no GPU was accepted: nf4 is not a
CPU fallback, its 4-bit load goes through bitsandbytes, which requires
CUDA, XPU or MPS. The start unloaded the working Images pipeline, pulled
the text encoders, and only then died in the child. Rejected before the
teardown now, and /info stops advertising a precision that always 400s.
SDXL keeps its documented fp32-on-CPU path.
- Both diffusion pages kept the routed-pick marker forever, so re-picking
the same checkpoint (after chat evicted it) neither loaded nor cleared
the query string. The marker is released once the query is gone. The
Images key also carried a stray NUL byte, which made the file read as
binary to grep and other tooling.
- diffusers dropped Python 3.9 in 0.38, so the unconditional >=0.39.0 pin
left pip no candidate at all on 3.9 and made every install that composes
the huggingface extras unresolvable there. The floor is conditional now.
Also fixes tests that were already red on the branch: two hand-built
request fakes had gone stale against fields this branch added, and the
handoff-ordering test only failed on a host with fewer than two GPUs.
- The trainer keyed its persistent conditioning cache on family and
resolution only, while the keys themselves carry just the caption or
image content and crop variant. One cache directory reused for two
checkpoints, or for the same repo at a new revision, let a warm run
skip loading its encoders and train on the other model's embeddings
and latent statistics. Namespace on the base checkpoint and its
resolved revision as well. The revision helper now lives beside the
cache in diffusion_train_extras and the inference wrapper delegates to
it, so the two cannot disagree about what counts as the same source.
- The diffusion learning rate only checked positivity, but 1e309 floats
to inf and satisfies gt, so the route evicted the resident models and
started AdamW with an infinite rate: the first step destroys the
adapter while progress looks normal and the result is saved. Bound it
below 1.0, matching the LLM schema, which rejects inf for the same
reason.
Four correctness fixes on the training side:
- The labeling grid read caption sidecars under except OSError, but a
non-UTF-8 sidecar raises UnicodeDecodeError (a ValueError), so one bad
file 500d /diffusion/dataset/{name}/images and the grid could not be
opened to repair it. Read it as no caption, matching the info summary.
- An image past Pillow's own hard limit raises DecompressionBombError,
which derives straight from Exception and so escaped the upload guard's
(OSError, UnidentifiedImageError, ValueError) and returned 500 instead
of the intended 400.
- lora_dropout accepted 1.0, which makes PEFT build nn.Dropout(p=1.0):
lora_A and lora_B receive no gradient and the run saves an untrained
adapter while reporting normal progress. Bound it below 1.0, matching
the LLM request schema.
- The train panel re-seeded the base repo on every dataset refresh
because the family object identity changes on each info fetch, so an
upload or caption save silently replaced the user's chosen base and the
run started on a different model. Track the pick and only re-seed on a
real family change.
read_text raises UnicodeDecodeError, which is not an OSError, so one bad caption
sidecar or video sidecar 500d the info, upload and gallery routes. A flat image
now yields the all-black edge map instead of its own luminance, and the four DiT
loss knobs the trainer implements are declared so model_dump keeps them.
str(None) stored the literal "None" as the caption, so a null row counted as
captioned and would have trained on that text. Also drops an unused import.
Gallery clear/delete now scope to Studio-owned files: image_gallery and
video_gallery skip PNGs / MP4s without a readable recipe (a hand-dropped or
orphan file the listing already hides), so clear() and a guessed-id delete no
longer destroy files the gallery never surfaced.
Remote ControlNets now force use_safetensors: a bare owner/name reaches
from_pretrained without the base trust gate, and the Hub scan fails open when
unavailable, so requiring safetensors closes the pickle deserialization vector.
POSIX uninstall now stops resident sd-server / sd-cli under an owned sd.cpp root
before removing the tree (marker-gated), mirroring the Windows stop-before-delete
scan; a live native server no longer survives unlinking its binary.
Diffusion dataset containment: the training-start read path and the discovery
picker route bare names through the protected resolver, so a symlinked dataset
is rejected / not advertised like the caption/delete routes already do. Uploads
gain the inference decode guard (oversized real images 400 before OOMing the
trainer) and dataset upload/caption/delete/import are blocked with 409 while a
diffusion run is active.
JSONL readers (trainer + routes) tolerate non-object JSON and invalid UTF-8
instead of raising AttributeError / 500.
LoRA family compatibility is enforced in the shared resolver, not only the
picker, so a direct API client cannot apply a mismatched-family adapter.
GPU arbiter gains release_if so the image/video unload idle-check and release
are atomic against a concurrent same-owner load's registration. Native batch
recipes persist the base batch_seed and restore replays from it, so a native
batch_index>0 image no longer advances its seed twice.
FLUX.2-klein selects its sd.cpp text encoder by variant (4B -> Qwen3-4B,
9B -> Qwen3-8B) instead of the single family default.
Run backend.start_training off the event loop with asyncio.to_thread so the
synchronous diffusion/video unload calls (which wait on engine generation
locks) cannot freeze concurrent requests; guard against overlapping starts
with a _start_in_progress compare-and-set under the service lock.
Resolve bare diffusion dataset names directly under datasets_root() before
falling back to the generic resolver, so an unrelated LLM upload file or
recipe folder sharing the name cannot shadow the image dataset.
Reject exact duplicate filenames within one multipart upload batch: two
parts staged to the same destination would let the later tmp.replace
silently discard the earlier file. Case variants stay exempt per the
existing stem-guard contract.
Require an instance prompt in the train panel when only some images have
captions, since backend discovery silently skips uncaptioned images.
The SDXL trainer drew min(train_batch_size, len(pairs)) indices, so a dataset with
fewer images than the batch trained at a smaller effective batch than configured
while the scheduler and samples-per-second still assumed the full batch. The shared
PermutationBatchSampler already refills across permutation cycles to return exactly k
indices, and the DiT trainer calls it with the full batch size, so drop the clamp and
pass train_batch_size through for parity and to honor the configured batch.
Two evict/corruption fixes surfaced by review of the diffusion training path:
- krea-2 sets force_bf16 in the DiT trainer spec, but the route-level
_FORCE_BF16_FAMILIES preflight listed only qwen-image and z-image, so a
krea-2 start with mixed_precision=fp16 passed the route check, reserved
training and evicted resident GPU models, and only the child trainer then
raised. Add krea-2 to the set and a drift-guard test asserting it equals
the trainer specs whose force_bf16 is set.
- The dataset-upload same-stem duplicate check compared stems
case-sensitively, so on case-insensitive filesystems (Windows / default
macOS) sample.png and Sample.jpg both passed even though their caption
sidecars sample.txt / Sample.txt resolve to the same file, silently
sharing and corrupting one caption. Compare stems and the same-name guard
with casefold at both the on-disk and in-batch sites.
Fold PR #6872's image-generation fixes into the branch, deduped against the
round-12 dataset-upload and gallery integrity work already on image-generation.
Fixes carried forward from #6872:
- fp8 single-file transformer memory estimate: an fp8 checkpoint loads with no
quantization_config and diffusers upcasts it to bf16 (~2x resident), so budget
it accordingly in _plan_memory and estimate_safetensors_dense_mib.
- dense-quant OOM-evict preflight: when the GGUF fits resident but the dense bf16
transformer this path materializes does not, skip the fast path up front rather
than evict the current pipeline and OOM in finalization. Combined with the
existing offload->resident candidate re-plan so both the family-table estimate
and the on-disk shard measurement gate engagement (unified on the
transformer_resident_override_mib plan override).
- ControlNet: evict the previous module and its from_pipe wrapper before loading a
new one so swapping ControlNets within a base-model load cannot accumulate to OOM.
- ControlNet union_control_mode: raise on an unknown control type instead of
silently defaulting to canny.
- edit-family mask rejection: raise instead of silently dropping a mask on an
image-editing model that has no inpaint pipeline.
- companion cache: walk the snapshot dir and exclude transformer/ so the
dense-quant prefetch's cached shards do not inflate the companion total and
wrongly force offload.
- training: drop piecewise_constant from the LR scheduler enum and force bf16 for
fp16-incompatible families.
- dataset upload: batch-atomic staging with the same-stem duplicate guard.
- images page: guard negative-prompt restore on guidance>0, clear stale ControlNet
selection on restore, and revert an optimistic quant label when a pipeline load
never starts.
- uninstall (sh + ps1): keep the owner-marker guard on sd.cpp removal.
Conflicts resolved in favour of image-generation's evolved memory system,
loadSpecFor catalog, and stop-and-save (lora_path) run detection; #6872's fp8 and
dense-preflight fixes carried forward on top. All affected backend tests pass
(test_diffusion_backend, test_diffusion_training, test_diffusion_lora_trainer,
test_video_gallery, test_diffusion_controlnet).
Two overlapping /diffusion/start requests can interleave between the is_active()
check and the reservation, so reserve() itself must reject a second reservation
atomically. Otherwise both callers reserve, both free the GPU's resident chat or
image model, and the loser only 409s after the eviction -- the evict-then-fail the
reservation exists to prevent. reserve() now raises under the lock if a start is
already reserved or a job is already running.
start_diffusion_training freed resident GPU models and only then called
service.start(config), which is where is_active() first flips true. During that
free-then-spawn window a concurrent /images/load or /video/load saw training as
inactive, passed its training guard, acquired the GPU, and began a background load,
so the trainer and that pipeline both allocated VRAM. Add reserve()/unreserve() to
the training service (is_active() also reports the reservation) and reserve BEFORE
the free, in a try/finally so a failed start rolls the reservation back. An
overlapping load's guard now refuses during the window. Regression tests: the route
reserves before the free (and the free sees an active service), and the service
reservation marks active then rolls back.
The dataset upload took Path(filename).name, which on POSIX does not split on a
backslash, so a Windows client sending a backslash path in the multipart filename
stored the name verbatim. The caption/thumbnail/delete endpoints then run it
through _safe_dataset_image_path, which rejects backslashes and '..', so the
labeling grid could list an image it could never preview, caption, or delete (an
orphan). Fold backslashes to forward slashes before taking the basename so the
true name is stored, and reject a basename that still contains '..' at upload
rather than persisting an unmanageable entry. Regression test covers a Windows
backslash path (stored and served under the clean basename) and a '..' rejection.
start_diffusion_training is async but called _preflight_gated_base inline; it does
a blocking urllib urlopen HEAD to Hugging Face (up to a 5s timeout) to detect a
gated/unauthorized base repo. On a slow or unreachable network that stalled the
FastAPI event loop, freezing every concurrent status/progress/cancel request until
it returned or timed out. Wrap it in asyncio.to_thread, matching the dataset
preflight and GPU cleanup just below it. Regression test asserts it runs off the
coroutine thread.
start_diffusion_training is an async route, but it called the blocking
_free_gpu_for_diffusion_training() inline. That teardown waits on generation
locks and joins the export subprocess, so it can block for seconds and freeze the
FastAPI event loop, stalling every concurrent status/progress/cancel request
until it finishes. Wrap it in asyncio.to_thread, mirroring the dataset-preflight
call just above it and the inference routes' load/unload offloading. Add a
regression test asserting the cleanup runs off the coroutine thread.
- Image load now trust-gates a client-supplied base_repo. validate_load_request
rejects a base_repo that is not an unsloth/* repo, an allowlisted official base, or a
local path, mirroring the repo_id gate and the video loader. The route passes
base_repo into that pre-eviction validation, so an authenticated client can no longer
keep model_path on a trusted GGUF while pointing base_repo at an arbitrary remote repo
that the server would download and deserialize (a from_pretrained pickle/config path),
and no resident model is evicted for the rejected load.
- The keepwarm middleware now tracks the image and video generation routes
(/images/generate, /images/generations, /video/generate), so
other_inference_request_count() sees an in-flight generation and an API-key training
start is refused (409) before its unload would cancel that generation. endswith keeps
the GET *-progress and */cancel variants untracked.
- The OpenAI-compatible /v1 surface is now blanket body-capped like /api/inference,
instead of only /v1/chat/completions and /v1/completions. Every /v1 POST route
(images/generations, audio, embeddings, responses, messages, ...) buffers a JSON body
and none is a multipart-upload passthrough, so an unbounded ImageGenerationRequest
prompt on /v1/images/generations can no longer be buffered outside the request limit.
Adds regression tests: the base_repo trust gate at both the backend (untrusted remote
raises, local passes) and the route (untrusted base_repo returns 400 with no load), the
keepwarm tracking of the image/video generation paths (and not the progress/cancel
variants), and the /v1 surface being body-protected.
- Chat load defers the CHAT arbiter handoff until after identifier / gpu_ids /
training-memory validation, so a doomed chat load (bad id, unsupported gpu_ids on
GGUF, or a training 409) no longer evicts a resident Images/Video pipeline and then
errors. The already-loaded fast paths re-assert CHAT ownership themselves. Mirrors
the image and video loaders, which validate before acquire_for.
- Both training-start GPU cleanups now unload a resident Video pipeline and release the
VIDEO arbiter owner, not just Images/DIFFUSION, so starting LLM or diffusion training
after a video generation session no longer competes with the still-resident video
model and OOMs the run.
- Example dataset import materializes into a private staging dir and promotes into the
dataset folder only after the whole import succeeds. A materialize that fails partway
no longer leaves a partial dataset that a retry would treat as complete (imported=0),
stranding the user with a truncated dataset. Hidden dirs are skipped by the dataset
scan so the staging dir never surfaces as a dataset.
- Cached/local Wan GGUFs are now classified name-aware: arch "wan" alone is ambiguous
between the loadable single-DiT TI2V-5B and the dual-expert A14B MoE the loader
refuses, so _arch_to_task falls back to the repo/file name (as the loader's own
detect_video_family does) and tags only a non-MoE match text-to-video, surfacing
loadable Wan GGUFs in the Video picker without surfacing unloadable A14B files.
- The diffusion dataset upload route is added to the MaxBodyMiddleware upload
passthrough, so its own get_upload_limit_bytes() cap (plus multipart overhead, and a
raised max_upload_size_mb) applies instead of the default body limit rejecting
near-limit batches with 413 before the handler runs.
Adds regression tests for each: the chat handoff not evicting on a doomed load, the
video unload on diffusion-training start, the atomic import leaving no partial dataset,
the name-aware Wan GGUF classification (TI2V-5B video, A14B unsupported, bare arch
unsupported), and the diffusion upload passthrough cap.
- Diffusion dataset upload now streams each file into a sibling temp file and
atomically os.replace()s it into place only after the whole file is written and
within the size cap. A mid-batch 413 (or any abort) removes the temp, never an
example already stored under the same name, so re-uploading a too-large batch can
no longer truncate or delete a previously uploaded image.
- _diffusion_dataset_summary counts an image as captioned only when it resolves to a
non-empty caption via the same sidecar-over-metadata precedence the trainer uses. An
empty (tombstone) sidecar shadows a metadata row and makes the trainer skip the
image, so counting it over-reported caption_count and mislabeled an effectively
uncaptioned dataset as captioned.
- uninstall.sh/.ps1 now remove a custom/env-mode Studio's native diffusion build that
installs beside the root as a stable-diffusion.cpp sibling (find_sd_cpp_binary
resolves it from the Studio home's parent), guarded by the same unsafe-path check,
and stop processes locking the default-mode stable-diffusion.cpp before removing it.
Adds regression tests for the upload data-loss and caption-count paths and a hermetic
shell test for the custom-root stable-diffusion.cpp removal.
The start route preflight only rejected non-bf16 GPUs; an explicit int8 request on
a host with a missing or stub torchao passed the preflight, evicted resident GPU
workloads, then died in the trainer child (its int8 base quantizer has no fallback).
Fold both gates into training_precision_preflight_error so int8-without-torchao fails
fast before eviction. Also empty the advertised DiT precision_modes (and surface the
reason in vram_note, drop compile) whenever the bf16 preflight would reject the family,
so /info never offers an nf4 DiT option the route always 400s.
- normalized() + family_train_infos() mirror the inference fp8 deny for
Qwen-Image (activation outliers exceed fp8's range and corrupt the trained
result); int8 stays allowed and the UI no longer advertises fp8 for it.
- _resolve_base_precision() gates an explicit int8 on a FUNCTIONAL torchao, the
same gate auto and /info already apply, so a missing/stub torchao fails fast
instead of silently loading dense with compile disabled.
- train_precision_modes() gates the dense modes (bf16/int8/fp8/auto) on
torch.cuda.is_bf16_supported(), so a non-bf16 CUDA GPU (T4/V100/RTX 20xx) is
offered only nf4 instead of a start that evicts resident models and then fails.
- start_diffusion_training preflights bf16 support for the DiT families BEFORE
_free_gpu_for_diffusion_training(), so any DiT start (nf4 included, since the
trainer requires bf16 unconditionally on CUDA) fails fast without eviction.
Review follow-ups on the image-generation PR:
- ControlNet: resolve_controlnet accepts a bare owner/name repo without the
non-GGUF base trust gate, and _controlnet_pipe hands it straight to
from_pretrained. A malicious pickle .bin would deserialize on load, so run
the same Hugging Face malware preflight (evaluate_file_security) the chat and
export loaders use before any remote ControlNet load; local dirs are exempt.
- Dataset thumbnails: key the cache on the full filename instead of the stem so
sample.png and sample.jpg no longer collide on one .thumbs file (which could
serve or delete the wrong image); the delete cleanup globs the same key.
- Diffusion training start: mirror start_training's API-key guard so an API
client cannot start training (which frees VRAM by unloading chat) while an
inference request is streaming; it now returns 409 before any GPU is freed.
- Model picker: include the curated safetensors row keys in the recommended
roving key list so arrow-key navigation reaches those rows instead of hitting
the duplicate option-missing id.
Tests: ControlNet malware gate (remote blocked before from_pretrained, local
skipped), thumbnail same-stem cache separation, API-key diffusion-start 409
before GPU free. Full diffusion suites green.
Replace the with-replacement per-batch index draw in the SDXL and DiT LoRA
trainers with a shared PermutationBatchSampler that visits every image once per
cycle before repeating, so short runs cover the whole dataset. The sampler
reshuffles from the run's rng so the index stream stays seed-deterministic.
Guard the diffusion run detail route against a valid-JSON non-object record,
which previously raised TypeError and returned a 500; it now 404s like the list
path's shape check.
Add regression tests for both.
- Run the trainer's caption discovery in the start route BEFORE freeing GPU
residents, so a missing or uncaptionable dataset 400s without evicting the
loaded chat/Images model.
- sd.cpp unload now waits out a cancelled one-shot generation on the generate
lock before reporting the device free, matching the diffusers backend.
- Clearing a caption that came from metadata.jsonl writes an empty sidecar
tombstone instead of unlinking (both readers treat an existing sidecar as
authoritative), so the cleared label cannot resurface.
- The ControlNet wrapper pipe is only cached while its load is still current,
closing the unload race the model cache already handled.