From 3f6057a2b2903ace8d57f23ed64d3a3179740241 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Mon, 27 Jul 2026 05:08:45 +0000 Subject: [PATCH] Bound the gallery blob cache, and three interlock fixes Four review findings, all reproduced first: - The gallery object-URL caches were unbounded. A clip runs from a few MB to a few hundred, both pages stay mounted after their first visit, and entries were only dropped on delete, so scrolling pinned everything for the session. Both pages now share a byte-budgeted LRU (512 MB video / 192 MB images) keyed off the visibility signal the near-viewport fetching already provides. On-screen media, the selected clip or image, and the item just fetched are never evicted, so eviction is invisible and a single item larger than the whole budget cannot evict itself into a refetch loop. - The image, video and chat load guards ran two independent training probes but returned early when the FIRST one raised, so an unreadable LLM backend disabled the diffusion interlock and a load could proceed straight into an active diffusion trainer on the same GPU. The probes are independent now. - An engine switch swallowed a failed teardown and published the new engine anyway, which is exactly the leak the unload exists to prevent: the arbiter's evictor, /images/unload and the next load all resolve through get_active_diffusion_engine(), so the still-resident pipeline (or a live sd-server) became unreachable and the next load allocated on top of it. The switch now fails and leaves the old engine published, so it stays reclaimable. - The native generation timeout was 30 minutes while the Images page waits up to 6 hours (SETTLE_MAX_MS), so slow-but-progressing CPU jobs died deterministically at the deadline. Measured on GPU-less runners, a 512x512 4-step Q2_K generation took 900 s on Linux and 1465 s on Windows, so larger images or step counts clear half an hour easily. The ceiling now matches the page's window and applies to the whole request: chunks of a split batch share one deadline instead of each getting a full budget. Cancellation is unchanged. Declined: gating the huggingfacenotorch extra off Python 3.9 over the conditional diffusers marker. The marker is deliberate and its comment says why: diffusers dropped 3.9 in 0.38, so pinning >=0.39 outright leaves pip no candidate and the whole extra unresolvable there. The pipelines it names live in studio/backend, which cannot install on 3.9 anyway (studio.txt pins matplotlib==3.10.9 and fastmcp>=3.0.2, both requires_python >=3.10), and the extra is the general core one, so the alternative drops 3.9 for library users who never touch Studio. --- .../core/inference/diffusion_engine_router.py | 15 ++- .../backend/core/inference/sd_cpp_backend.py | 11 +- .../backend/core/inference/sd_cpp_engine.py | 12 +- .../backend/core/inference/sd_cpp_server.py | 8 +- studio/backend/routes/inference.py | 8 +- studio/backend/routes/video.py | 4 +- .../tests/test_diffusion_engine_router.py | 26 ++++- studio/backend/tests/test_diffusion_routes.py | 30 +++++ studio/backend/tests/test_sd_cpp_backend.py | 13 ++- studio/backend/tests/test_sd_cpp_engine.py | 19 ++++ studio/backend/tests/test_video_routes.py | 30 +++++ studio/frontend/src/features/images/api.ts | 9 +- .../src/features/images/images-page.tsx | 49 ++++++-- studio/frontend/src/features/video/api.ts | 9 +- .../src/features/video/video-page.tsx | 60 +++++++--- studio/frontend/src/lib/blob-url-cache.ts | 106 ++++++++++++++++++ 16 files changed, 359 insertions(+), 50 deletions(-) create mode 100644 studio/frontend/src/lib/blob-url-cache.ts diff --git a/studio/backend/core/inference/diffusion_engine_router.py b/studio/backend/core/inference/diffusion_engine_router.py index 9cb460bb67..e8b7a7981d 100644 --- a/studio/backend/core/inference/diffusion_engine_router.py +++ b/studio/backend/core/inference/diffusion_engine_router.py @@ -110,8 +110,19 @@ def _activate(name: str, reason: Optional[str]) -> Any: # evict the new (empty) engine while the old model is still freeing VRAM. try: engine_to_unload.unload() - except Exception as exc: # noqa: BLE001 -- best-effort; never block the switch - logger.warning("failed to unload previous engine %s: %s", old_name, exc) + except Exception as exc: + # Do NOT publish the new engine after a failed teardown. The old model (or the + # resident sd-server process) is still holding its memory, and flipping the name + # would make it unreachable through get_active_diffusion_engine(): the arbiter's + # evictor, /images/unload and the next load all resolve through that, so the + # leak would be permanent and the next load would allocate on top of it. Leaving + # the old engine active keeps it reclaimable and lets the caller retry. + logger.error("failed to unload previous engine %s: %s", old_name, exc) + raise RuntimeError( + f"Could not switch the diffusion engine to {name}: unloading the current " + f"{old_name} model failed ({exc}). The current model is still loaded; " + "unload it and try again." + ) from exc with _lock: _active_engine_name = name _fallback_reason = reason if name == ENGINE_DIFFUSERS else None diff --git a/studio/backend/core/inference/sd_cpp_backend.py b/studio/backend/core/inference/sd_cpp_backend.py index b96bf65d4a..b4a97a3fba 100644 --- a/studio/backend/core/inference/sd_cpp_backend.py +++ b/studio/backend/core/inference/sd_cpp_backend.py @@ -58,6 +58,7 @@ from core.inference.sd_cpp_args import ( offload_flags, ) from core.inference.sd_cpp_engine import ( + NATIVE_GENERATION_TIMEOUT_S, SdCppCancelled, SdCppEngine, find_sd_cpp_binary, @@ -80,9 +81,6 @@ _install_lock = threading.Lock() # Max images per img_gen job; larger Studio batches (up to 32) are split into these chunks. _MAX_SERVER_BATCH = 8 -# Per-image server-job budget, so a batch's timeout scales with image count. -_SERVER_PER_IMAGE_TIMEOUT_S = 1800.0 - def _default_threads() -> int: """Physical-core thread count for the sd.cpp CPU backend. @@ -928,6 +926,11 @@ class SdCppDiffusionBackend: } for m in materialized ] + # One deadline for the whole request, shared by its chunks: a batch is chunked only because + # the server caps images per job, so giving each chunk its own full budget would let a batch + # run for a multiple of the window the page is still waiting on. Each chunk gets whatever is + # left, so a slow single image can use all of it and a long batch still ends on time. + deadline = time.monotonic() + NATIVE_GENERATION_TIMEOUT_S try: for offset in range(0, total, _MAX_SERVER_BATCH): if cancel.is_set(): @@ -952,7 +955,7 @@ class SdCppDiffusionBackend: payload, on_step = self._on_log, cancel_event = cancel, - total_timeout = _SERVER_PER_IMAGE_TIMEOUT_S * count, + total_timeout = max(deadline - time.monotonic(), 1.0), ) # All-or-nothing per chunk: fail rather than silently drop images from the batch. if not cancel.is_set() and len(blobs) != count: diff --git a/studio/backend/core/inference/sd_cpp_engine.py b/studio/backend/core/inference/sd_cpp_engine.py index 1ed2900d6a..ebf312178d 100644 --- a/studio/backend/core/inference/sd_cpp_engine.py +++ b/studio/backend/core/inference/sd_cpp_engine.py @@ -44,6 +44,14 @@ _LEGACY_STEM = "sd" # The persistent HTTP server target, shipped next to sd-cli in both prebuilt and cmake builds. _SERVER_STEM = "sd-server" +# Ceiling for one native run. The native engine exists FOR slow CPU hosts: measured on GPU-less +# CI runners, a 512x512 4-step Q2_K generation took 900 s on Linux and 1465 s on Windows, so a +# larger image or step count clears half an hour easily and a 30-minute cap killed jobs that were +# still progressing. This matches the Images page's own SETTLE_MAX_MS (6 h), past which the UI has +# given up anyway, so the ceiling only stops a WEDGED process from holding the lock forever. +# It is not the user-facing abort path: cancel_event interrupts a run at any point. +NATIVE_GENERATION_TIMEOUT_S = 6 * 60 * 60.0 + class SdCppCancelled(RuntimeError): """A generation cancelled via its ``cancel_event`` (unload / superseding load / arbiter @@ -253,7 +261,7 @@ class SdCppEngine: threads: Optional[int] = None, verbose: bool = False, extra_args: Optional[list[str]] = None, - timeout: Optional[float] = 1800.0, + timeout: Optional[float] = NATIVE_GENERATION_TIMEOUT_S, env: Optional[dict[str, str]] = None, on_log: Optional[Callable[[str], None]] = None, cancel_event: Optional[threading.Event] = None, @@ -294,7 +302,7 @@ class SdCppEngine: output_path: str, verbose: bool = False, extra_args: Optional[list[str]] = None, - timeout: Optional[float] = 1800.0, + timeout: Optional[float] = NATIVE_GENERATION_TIMEOUT_S, env: Optional[dict[str, str]] = None, on_log: Optional[Callable[[str], None]] = None, cancel_event: Optional[threading.Event] = None, diff --git a/studio/backend/core/inference/sd_cpp_server.py b/studio/backend/core/inference/sd_cpp_server.py index 022943eed5..c9148eae4e 100644 --- a/studio/backend/core/inference/sd_cpp_server.py +++ b/studio/backend/core/inference/sd_cpp_server.py @@ -46,7 +46,11 @@ from typing import Any, Callable, Optional import httpx from core.inference.sd_cpp_args import SdCppModelFiles, build_sd_cpp_server_command -from core.inference.sd_cpp_engine import SdCppCancelled, runtime_env +from core.inference.sd_cpp_engine import ( + NATIVE_GENERATION_TIMEOUT_S, + SdCppCancelled, + runtime_env, +) from utils.native_path_leases import child_env_without_native_path_secret from utils.process_lifetime import adopt_pid, child_popen_kwargs, forget_pid from utils.subprocess_compat import windows_hidden_subprocess_kwargs @@ -366,7 +370,7 @@ class SdCppServer: cancel_event: Optional[threading.Event] = None, poll_interval: float = 0.4, submit_timeout: float = 60.0, - total_timeout: float = 1800.0, + total_timeout: float = NATIVE_GENERATION_TIMEOUT_S, ) -> list[bytes]: """Submit one async ``img_gen`` job, poll it to completion, return image bytes. diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py index fd2f5f3980..dcf10573a4 100644 --- a/studio/backend/routes/inference.py +++ b/studio/backend/routes/inference.py @@ -4048,8 +4048,10 @@ def _guard_chat_load_against_training( try: llm_active = get_training_backend().is_training_active() except Exception as e: + # Independent probes: an unreadable LLM backend must still fall through to the diffusion + # check below, which reads a different service and may know a trainer IS running. logger.warning("Could not check training state for chat-load guard: %s", e) - return + llm_active = False if not llm_active: # An SDXL LoRA trainer runs in its own subprocess and its VRAM can't be cheaply fit-checked here, @@ -16008,8 +16010,10 @@ def _guard_diffusion_load_against_training() -> None: try: llm_active = get_training_backend().is_training_active() except Exception as e: + # The two probes are independent: an unreadable LLM backend must not disable the diffusion + # interlock below, which reads a different service and may well know a trainer IS running. logger.warning("Could not check training state for image-load guard: %s", e) - return + llm_active = False # An SDXL LoRA trainer runs in its own subprocess on the same GPU, so an image load must be # refused while one is active or the pipeline contends with the trainer for VRAM. Symmetric with # the diffusion-start interlock. diff --git a/studio/backend/routes/video.py b/studio/backend/routes/video.py index 85dd098b0e..39998e5f94 100644 --- a/studio/backend/routes/video.py +++ b/studio/backend/routes/video.py @@ -53,8 +53,10 @@ def _guard_video_load_against_training() -> None: try: llm_active = get_training_backend().is_training_active() except Exception as e: # noqa: BLE001 + # Independent probes: an unreadable LLM backend must not disable the diffusion interlock + # below, which reads a different service and may know a trainer IS running. logger.warning("Could not check training state for video-load guard: %s", e) - return + llm_active = False diffusion_active = False try: from core.training.diffusion_training_service import get_diffusion_training_service diff --git a/studio/backend/tests/test_diffusion_engine_router.py b/studio/backend/tests/test_diffusion_engine_router.py index 649608ba81..e8e4270e1b 100644 --- a/studio/backend/tests/test_diffusion_engine_router.py +++ b/studio/backend/tests/test_diffusion_engine_router.py @@ -27,10 +27,14 @@ def _clean_env_and_state(monkeypatch): monkeypatch.delenv(e, raising = False) # A light status-capable stub so neither selection nor active_status() imports the heavy # diffusers/sd.cpp backends; the active engine NAME comes from module state. + # unload() is part of the engine contract (a switch tears the old engine down and now refuses to + # publish the new one if that fails), so the stub has to honour it. monkeypatch.setattr( r, "get_active_diffusion_engine", - lambda: SimpleNamespace(status = lambda: {"loaded": False, "repo_id": None}), + lambda: SimpleNamespace( + status = lambda: {"loaded": False, "repo_id": None}, unload = lambda: None + ), ) # Default: no resident sd-server (so existing tests exercise the sd-cli path) and a stubbed # runnability probe, so neither reaches the real install/exec path. @@ -326,3 +330,23 @@ def test_begin_load_on_holds_the_transition_lock_while_registering(monkeypatch): assert r._transition_lock.locked() release.set() t.join(2.0) + + +def test_switch_aborts_when_the_old_engine_fails_to_unload(monkeypatch): + # A swallowed teardown failure published the new engine anyway, which stranded the old model: + # the arbiter's evictor, /images/unload and the next load all resolve through + # get_active_diffusion_engine(), so nothing could reach the still-resident pipeline (or a live + # sd-server) to reclaim it, and the next load allocated on top of it. Keep the old engine + # published and fail the switch instead. + def _fake_engine(): + def _boom(): + raise RuntimeError("sd-server would not die") + + return SimpleNamespace(unload = _boom, status = lambda: {"loaded": True, "repo_id": "x"}) + + monkeypatch.setattr(r, "get_active_diffusion_engine", lambda: _fake_engine()) + r._active_engine_name = ENGINE_SD_CPP + with pytest.raises(RuntimeError, match = "Could not switch the diffusion engine"): + r._activate(ENGINE_DIFFUSERS, "switch test") + # Still the old engine, so the resident model remains reachable and reclaimable. + assert r.active_engine_name() == ENGINE_SD_CPP diff --git a/studio/backend/tests/test_diffusion_routes.py b/studio/backend/tests/test_diffusion_routes.py index b9d47c0967..863a978214 100644 --- a/studio/backend/tests/test_diffusion_routes.py +++ b/studio/backend/tests/test_diffusion_routes.py @@ -1063,3 +1063,33 @@ def test_download_plan_forwards_the_load_time_controls(client, monkeypatch): assert seen["memory_mode"] == "low_vram" assert seen["cpu_offload"] is True assert len(seen["loras"] or []) == 1 + + +def test_load_refused_when_only_the_diffusion_probe_can_be_read(client, monkeypatch): + # The two training probes are independent. An LLM backend that raises used to short-circuit the + # guard entirely, so an image load sailed past a KNOWN-active diffusion trainer and contended + # with it for VRAM. An unreadable LLM state must not disable the diffusion interlock. + import core.training as core_training + import routes.inference as inference_routes + + class _Broken: + def is_training_active(self): + raise RuntimeError("training backend unavailable") + + monkeypatch.setattr(core_training, "get_training_backend", lambda: _Broken()) + monkeypatch.setattr(inference_routes, "_diffusion_training_active", lambda: True) + + resp = client.post( + "/api/inference/images/load", + json = {"model_path": "x/z-image", "gguf_filename": "q.gguf"}, + ) + assert resp.status_code == 409 + assert "training" in resp.json()["detail"].lower() + + # With neither trainer active the unreadable LLM probe still must not block the load. + monkeypatch.setattr(inference_routes, "_diffusion_training_active", lambda: False) + resp = client.post( + "/api/inference/images/load", + json = {"model_path": "x/z-image", "gguf_filename": "q.gguf"}, + ) + assert resp.status_code == 200 diff --git a/studio/backend/tests/test_sd_cpp_backend.py b/studio/backend/tests/test_sd_cpp_backend.py index a21476fd65..2ffba5bb6e 100644 --- a/studio/backend/tests/test_sd_cpp_backend.py +++ b/studio/backend/tests/test_sd_cpp_backend.py @@ -577,11 +577,14 @@ def test_server_generate_splits_batches_above_server_limit(monkeypatch): assert len(out["images"]) == 10 counts = [p["batch_count"] for p in servers[0].payloads] assert counts == [bk._MAX_SERVER_BATCH, 10 - bk._MAX_SERVER_BATCH] # [8, 2] - # Each chunk's timeout scales with its image count, not one fixed batch deadline. - assert servers[0].timeouts == [ - bk._SERVER_PER_IMAGE_TIMEOUT_S * 8, - bk._SERVER_PER_IMAGE_TIMEOUT_S * 2, - ] + # Chunks share ONE request deadline rather than each getting a full budget: a batch is split + # only because the server caps images per job, so per-chunk budgets would let a batch outlive + # the window the page is still waiting on. Each chunk therefore gets what is left, which is at + # most the ceiling and never increases. + assert servers[0].timeouts[0] <= bk.NATIVE_GENERATION_TIMEOUT_S + assert servers[0].timeouts[1] <= servers[0].timeouts[0] + # A single slow image can still use the whole window (the old per-image cap was 30 minutes). + assert servers[0].timeouts[-1] > 1800.0 # Seeds run contiguously across chunks (chunk 2 submitted at base + 8). assert out["seeds"] == list(range(100, 110)) assert servers[0].payloads[1]["seed"] == 108 diff --git a/studio/backend/tests/test_sd_cpp_engine.py b/studio/backend/tests/test_sd_cpp_engine.py index 94ace6784d..2372deee28 100644 --- a/studio/backend/tests/test_sd_cpp_engine.py +++ b/studio/backend/tests/test_sd_cpp_engine.py @@ -10,6 +10,7 @@ PNG -- no real ``sd-cli``, no GPU. from __future__ import annotations +import inspect import os import sys import time @@ -491,3 +492,21 @@ def test_routing_prefer_native_overrides_gpu(): select_diffusion_engine("cuda", native_available = False, prefer_native = True) == ENGINE_DIFFUSERS ) + + +def test_native_generation_timeout_matches_the_ui_settle_window(): + # The native engine exists for slow CPU hosts: measured on GPU-less CI runners a 512x512 4-step + # Q2_K generation took 900 s (Linux) and 1465 s (Windows), so the old 30-minute default killed + # still-progressing jobs at higher resolutions or step counts while the Images page waited hours + # for them. The ceiling now matches that page's SETTLE_MAX_MS. + from core.inference.sd_cpp_engine import NATIVE_GENERATION_TIMEOUT_S, SdCppEngine + from core.inference import sd_cpp_backend + + assert NATIVE_GENERATION_TIMEOUT_S == 6 * 60 * 60 + for fn in (SdCppEngine.generate, SdCppEngine.upscale): + assert ( + inspect.signature(fn).parameters["timeout"].default == NATIVE_GENERATION_TIMEOUT_S + ), fn.__name__ + # The resident-server path shares the same ceiling (applied per request, see + # test_server_generate_splits_batches_above_server_limit). + assert sd_cpp_backend.NATIVE_GENERATION_TIMEOUT_S == NATIVE_GENERATION_TIMEOUT_S diff --git a/studio/backend/tests/test_video_routes.py b/studio/backend/tests/test_video_routes.py index f9ec9df1f2..82cdea2a21 100644 --- a/studio/backend/tests/test_video_routes.py +++ b/studio/backend/tests/test_video_routes.py @@ -836,3 +836,33 @@ def test_video_download_plan_forwards_the_encoder_policy(client, monkeypatch): assert resp.status_code == 200 assert seen["text_encoder_quant"] == "fp8" assert seen["hf_token"] == "hf_secret" + + +def test_video_load_guard_still_checks_diffusion_when_the_llm_probe_raises(client, monkeypatch): + # Same independence rule as the image guard: a raising LLM probe used to return early, so a + # video load ran straight into an active diffusion trainer on the same GPU. + import core.training as core_training + import routes.video as video_routes + + class _Broken: + def is_training_active(self): + raise RuntimeError("training backend unavailable") + + class _Diffusion: + def is_active(self): + return True + + monkeypatch.setattr(core_training, "get_training_backend", lambda: _Broken()) + monkeypatch.setattr( + "core.training.diffusion_training_service.get_diffusion_training_service", + lambda: _Diffusion(), + raising = False, + ) + + resp = client.post( + "/api/inference/video/load", + json = {"model_path": "unsloth/LTX-2.3-GGUF", "gguf_filename": "q.gguf"}, + ) + assert resp.status_code == 409 + assert "training" in resp.json()["detail"].lower() + assert video_routes is not None diff --git a/studio/frontend/src/features/images/api.ts b/studio/frontend/src/features/images/api.ts index 5f827bbf2c..d4f414eb60 100644 --- a/studio/frontend/src/features/images/api.ts +++ b/studio/frontend/src/features/images/api.ts @@ -353,10 +353,15 @@ export async function clearGallery(): Promise { /** Fetch a gallery PNG (auth-protected, so it can't be a plain ) and * wrap it in an object URL. Callers must revoke the URL when done. */ -export async function fetchGalleryObjectUrl(url: string): Promise { +export async function fetchGalleryObjectUrl( + url: string, +): Promise<{ url: string; bytes: number }> { const res = await authFetch(url); if (!res.ok) throw new Error(await readFastApiError(res)); - return URL.createObjectURL(await res.blob()); + // The blob's size travels with the URL: the gallery cache is budgeted in bytes (see + // BlobUrlCache), which the caller cannot work out from the URL alone. + const blob = await res.blob(); + return { url: URL.createObjectURL(blob), bytes: blob.size }; } // ── Diffusion LoRA training ─────────────────────────────────────────────────── diff --git a/studio/frontend/src/features/images/images-page.tsx b/studio/frontend/src/features/images/images-page.tsx index 98996250ec..dbf49dae22 100644 --- a/studio/frontend/src/features/images/images-page.tsx +++ b/studio/frontend/src/features/images/images-page.tsx @@ -74,6 +74,7 @@ import { ModelLoadDescription } from "@/features/chat/components/model-load-stat import { getHfToken, hfApiToken } from "@/features/hub/stores/hf-token-store"; import { formatBytes, formatEta } from "@/features/hub/lib/format"; import { cn } from "@/lib/utils"; +import { BlobUrlCache } from "@/lib/blob-url-cache"; import { diffusionRoutePick } from "@/lib/diffusion-route-pick"; import { toast } from "@/lib/toast"; @@ -280,12 +281,18 @@ function matchAspect(width: number, height: number): { key: string; portrait: bo // Module cache of the backend-persisted gallery, so a tab switch re-renders // instantly. Object URLs are revoked only on delete (not unmount), so they stay // valid across remounts. +// Blob budget for cached gallery PNGs. A 1024x1024 PNG is ~1-2 MB, and the page stays mounted +// after its first visit, so an unbounded cache grew for the whole session as the user scrolled. +// 192 MB is ~100-200 images: far more than any viewport holds, while capping what the webview +// can be made to pin. On-screen and open-in-the-viewer images are never evicted. +const IMAGE_BLOB_BUDGET_BYTES = 192 * 1024 * 1024; + const galleryCache: { images: GalleryImage[]; hasMore: boolean; selectedId: string | null; quant: string | null; - srcById: Map; + srcById: BlobUrlCache; // Ids with a fetch in flight, so concurrent ensureSrc calls don't double-fetch // (and leak the duplicate object URL). inflight: Set; @@ -298,7 +305,7 @@ const galleryCache: { hasMore: false, selectedId: null, quant: null, - srcById: new Map(), + srcById: new BlobUrlCache(IMAGE_BLOB_BUDGET_BYTES), inflight: new Set(), deleted: new Set(), }; @@ -1193,13 +1200,16 @@ export function ImagesPage({ active = true }: { active?: boolean }) { const [hasMore, setHasMore] = useState(() => galleryCache.hasMore); const [selectedId, setSelectedId] = useState(() => galleryCache.selectedId); const [srcById, setSrcById] = useState>(() => - Object.fromEntries(galleryCache.srcById), + galleryCache.srcById.toRecord(), ); // Guards a "load more" so a fast scroll can't fire several at once. const loadingMore = useRef(false); // The gallery strip, used as the IntersectionObserver root so a tile's PNG is fetched as it // nears view instead of every tile of every page up front. const stripRef = useRef(null); + // Ids currently intersecting the strip. The blob cache never evicts these, so pruning cannot + // pull an image out from under a visible tile. + const visibleIds = useRef>(new Set()); // False once the page truly unmounts (app close / chat-only eject). The page now // stays mounted across tab switches, so a switch does NOT flip this -- a batch keeps // generating off-tab; the multi-run loop only stops on a real unmount. @@ -1372,13 +1382,23 @@ export function ImagesPage({ active = true }: { active?: boolean }) { if (galleryCache.srcById.has(image.id) || galleryCache.inflight.has(image.id)) return; galleryCache.inflight.add(image.id); try { - const url = await fetchGalleryObjectUrl(image.url); + const { url, bytes } = await fetchGalleryObjectUrl(image.url); if (galleryCache.deleted.has(image.id)) { URL.revokeObjectURL(url); return; } - galleryCache.srcById.set(image.id, url); - setSrcById((prev) => ({ ...prev, [image.id]: url })); + galleryCache.srcById.set(image.id, url, bytes); + // Evict the coldest off-screen images this one pushed over budget. On-screen tiles and the + // image open in the viewer are protected, so eviction is never visible; an evicted tile + // re-fetches when it is scrolled back to. + const evicted = galleryCache.srcById.prune( + new Set([image.id, ...visibleIds.current, galleryCache.selectedId ?? ""]), + ); + setSrcById((prev) => { + const next = { ...prev, [image.id]: url }; + for (const id of evicted) delete next[id]; + return next; + }); } catch { // Leave it without a src; the tile shows a placeholder. } finally { @@ -1441,9 +1461,17 @@ export function ImagesPage({ active = true }: { active?: boolean }) { const io = new IntersectionObserver( (entries) => { for (const entry of entries) { - if (!entry.isIntersecting) continue; const id = (entry.target as HTMLElement).dataset.imageId; - const image = id ? images.find((i) => i.id === id) : undefined; + if (!id) continue; + // Visibility is also the cache's recency and protection signal: an on-screen tile is + // never evicted, and leaving the viewport makes it a candidate again. + if (!entry.isIntersecting) { + visibleIds.current.delete(id); + continue; + } + visibleIds.current.add(id); + galleryCache.srcById.touch(id); + const image = images.find((i) => i.id === id); if (image) void ensureSrc(image); } }, @@ -1477,9 +1505,8 @@ export function ImagesPage({ active = true }: { active?: boolean }) { toast.error(err instanceof Error ? err.message : "Failed to delete image"); return; } - const url = galleryCache.srcById.get(id); - if (url?.startsWith("blob:")) URL.revokeObjectURL(url); - galleryCache.srcById.delete(id); + galleryCache.srcById.delete(id); // revokes the URL with the entry + visibleIds.current.delete(id); // A fetch still in flight for this id must discard its blob rather than cache it. galleryCache.deleted.add(id); setSrcById((prev) => { diff --git a/studio/frontend/src/features/video/api.ts b/studio/frontend/src/features/video/api.ts index 613d6f31a0..43813defed 100644 --- a/studio/frontend/src/features/video/api.ts +++ b/studio/frontend/src/features/video/api.ts @@ -250,10 +250,15 @@ export async function clearVideoGallery(): Promise { /** Fetch a gallery MP4 (auth-protected, so it can't be a plain