From 0add1accfd1ad1590fc543ab00654d3839ff485d Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Sun, 26 Jul 2026 19:29:54 +0000 Subject: [PATCH] Cancel an evicted safetensors load, spare the arbiter for CPU-only chat, fetch clips lazily Four fixes from the latest review round: - The GPU arbiter's chat evictor only cancelled the llama.cpp side. The orchestrator publishes active_model_name once its worker reports success, so an in-flight safetensors load was visible only as an entry in loading_models and finished onto the GPU after ownership had transferred. Cancel every pending load, and give the safetensors branch the post-load ownership recheck the GGUF branch already had. - A manual gpu_layers=0 GGUF load runs on the CPU with the GPUs hidden from the child, yet it took the arbiter unconditionally: it cancelled a running image or video generation for a model needing no VRAM, then held CHAT ownership so the next GPU workload unloaded it for nothing. Gate the acquire on the same predicate the launch-time CPU-only mask uses, as the image and video loaders gate on their resolved device. - The staged-download hook subscribes per repo, not per job, so another job on the same repo advanced the staged queue (starting a load whose scoped files were still downloading) or wiped a queue that was still running. Compare the variant each callback carries, like the chat page's auto-load does. - The video gallery fetched every record of a page into an object URL that lives until the page closes: 50 clips at tens to hundreds of MB each, for cards the user may never scroll to. Fetch a clip as its card nears the strip's edge, plus the selected one the player needs. --- studio/backend/core/inference/gpu_arbiter.py | 8 ++ studio/backend/core/inference/llama_cpp.py | 45 ++++++++ studio/backend/routes/inference.py | 71 ++++++++++-- studio/backend/tests/test_gpu_arbiter.py | 101 ++++++++++++++++++ studio/backend/tests/test_gpu_memory_mode.py | 83 ++++++++++++++ .../download-manager/use-staged-download.ts | 19 +++- .../src/features/video/video-page.tsx | 54 +++++++++- tests/studio/test_model_picker_contracts.py | 33 ++++++ 8 files changed, 399 insertions(+), 15 deletions(-) diff --git a/studio/backend/core/inference/gpu_arbiter.py b/studio/backend/core/inference/gpu_arbiter.py index 2b96059756..3d99b07e7d 100644 --- a/studio/backend/core/inference/gpu_arbiter.py +++ b/studio/backend/core/inference/gpu_arbiter.py @@ -45,6 +45,14 @@ def _evict_chat() -> None: orchestrator = get_inference_backend() if orchestrator.active_model_name: orchestrator.unload_model(orchestrator.active_model_name) + # An in-flight safetensors load has no active_model_name yet (it is published only once the + # worker reports success), so the unload above misses it and the load would finish onto the + # GPU we just granted away. cancel_load discards the loading marker BEFORE tearing the worker + # down, so a load parked between retries (or in _wait_response) observes the removal and + # aborts instead of publishing. It runs off the lifecycle gate, which the load itself holds + # for its whole duration, so this cannot deadlock. + for pending in list(getattr(orchestrator, "loading_models", ()) or ()): + orchestrator.cancel_load(pending) # Kill the subprocess too: its base CUDA context holds VRAM diffusion needs. orchestrator._shutdown_subprocess(timeout = 5.0) # The driver reclaims the killed VRAM asynchronously; wait for it to settle before diffusion diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py index 5b2f0fc459..04b6bfb56b 100644 --- a/studio/backend/core/inference/llama_cpp.py +++ b/studio/backend/core/inference/llama_cpp.py @@ -1412,6 +1412,35 @@ def chat_load_active() -> bool: return _CHAT_LOADS_IN_FLIGHT > 0 +def zero_vram_chat_load( + gpu_memory_mode: str, + gpu_layers: int, + extra_args: Optional[list] = None, + needs_mmproj: bool = False, + speculative_type: Optional[str] = None, +) -> bool: + """Whether a GGUF load will hold no VRAM at all, decided from the request. + + A deliberate manual zero-offload load runs on the CPU and is launched with the GPUs hidden + from the child (see ``_cpu_only_zero_offload``), so it neither needs the GPU arbiter nor + should evict an image/video pipeline for it. This mirrors that launch-time mask so both + agree: an mmproj, a GPU drafter, a device pin or a surviving tensor mode all keep the GPUs + visible, and the mask is skipped on Vulkan. ``--mmproj`` and ``--model-draft`` are added by + the backend rather than being present in ``extra_args``, so their intent is passed in. + """ + if gpu_memory_mode != "manual" or gpu_layers != 0: + return False + # Any speculative mode may launch a GPU drafter; only the request's own knobs are known + # here, so treat every non-empty selection as GPU-bearing rather than guess. + if needs_mmproj or speculative_type: + return False + if LlamaCppBackend._is_vulkan_backend(): + return False + return not LlamaCppBackend._zero_offload_keeps_gpu_visible( + [str(arg) for arg in (extra_args or [])], os.environ + ) + + def hf_gguf_load_in_flight(hf_repo: str) -> bool: """Return whether a GGUF load is active for hf_repo.""" key = (hf_repo or "").strip().lower() @@ -2539,6 +2568,22 @@ class LlamaCppBackend: """Requested --gpu-layers for manual mode (-1 when not manual).""" return self._gpu_layers + @property + def holds_no_vram(self) -> bool: + """Whether the resident server is a confirmed zero-VRAM launch. + + True only for a deliberate manual zero-offload load whose launched argv carried no GPU + companion, pin or tensor mode, so the child was started with the GPUs hidden. The GPU + arbiter uses this to leave an image/video pipeline alone when re-asserting ownership for + such a model. ``_gpu_offload_active`` is None when no GPU was detected at all (nothing to + arbitrate) and True when something still reached the GPU, so both keep the normal path. + """ + return ( + self._gpu_memory_mode == "manual" + and self._gpu_layers == 0 + and self._gpu_offload_active is False + ) + @property def n_cpu_moe(self) -> int: """MoE expert layers manual mode kept on CPU (--n-cpu-moe); 0 = none.""" diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py index 3d76543ee1..4a870e4139 100644 --- a/studio/backend/routes/inference.py +++ b/studio/backend/routes/inference.py @@ -4395,7 +4395,7 @@ async def _load_model_impl( # validation so a doomed load (bad id, unsupported gpu_ids on GGUF, training 409) can't # evict a working image/video model and then error. Mirrors the image/video loaders, # which validate before acquire_for. - from core.inference.gpu_arbiter import acquire_for, current_owner, CHAT + from core.inference.gpu_arbiter import acquire_for, current_owner, release, CHAT # ── Already-loaded check: skip reload if the exact model is active ── backend = get_inference_backend() @@ -4433,8 +4433,11 @@ async def _load_model_impl( _gguf_is_audio = getattr(llama_backend, "_is_audio", False) # Requested GGUF chat model already resident: assert CHAT ownership (no-op when # held) to correct a drifted arbiter owner. Guaranteed-success path, so evicting - # here is correct. - await asyncio.to_thread(acquire_for, CHAT) + # here is correct -- unless the resident server is a confirmed zero-VRAM one + # (manual zero offload, GPUs hidden from the child), which coexists with an + # image/video pipeline and so must not evict it to re-announce itself. + if not llama_backend.holds_no_vram: + await asyncio.to_thread(acquire_for, CHAT) return LoadResponse( status = "already_loaded", model = model_log_label @@ -4645,13 +4648,36 @@ async def _load_model_impl( # image and video loads do: a chat load holds no llama-server process until its GGUF has # downloaded, so a competing Images/Video acquire in that window found nothing to evict # and both then allocated VRAM at once. With the marker, that evictor cancels this load. - from core.inference.llama_cpp import chat_load_in_flight + from core.inference.llama_cpp import chat_load_in_flight, zero_vram_chat_load - await asyncio.to_thread( - acquire_for, - CHAT, - lambda: gguf_load_stack.enter_context(chat_load_in_flight()), + # ...but only when this load will actually use the GPU, exactly as the image and video + # loaders gate on their resolved device. A manual gpu_layers=0 GGUF load runs on the CPU + # with the GPUs hidden from the child, so taking the arbiter for it would cancel a + # running image/video generation for a model that needs no VRAM, and leave CHAT recorded + # as owner so the next GPU workload pointlessly unloads it. + chat_load_needs_gpu = not ( + config.is_gguf + and await asyncio.to_thread( + zero_vram_chat_load, + request.gpu_memory_mode, + request.gpu_layers, + extra_llama_args, + bool(config.is_vision and not extra_args_disable_mmproj(extra_llama_args)), + request.speculative_type, + ) ) + if chat_load_needs_gpu: + await asyncio.to_thread( + acquire_for, + CHAT, + lambda: gguf_load_stack.enter_context(chat_load_in_flight()), + ) + else: + # The marker still goes up (the download manager's handshake reads it, and it keeps + # this load cancellable). Any stale CHAT claim is dropped AFTER the load, not here: + # this load may still be replacing a GPU-backed chat model, and releasing up front + # would let an image/video load allocate alongside the model not yet unloaded. + gguf_load_stack.enter_context(chat_load_in_flight()) # ── GGUF path: load via llama-server ────────────────────── if config.is_gguf: @@ -4816,8 +4842,9 @@ async def _load_model_impl( # An Images/Video acquire can land in the gap between the acquire above and # load_model clearing the cancel event, so its cancellation is lost and this load # spawns anyway. Ownership survives that gap: whoever took the GPU keeps it, and this - # load undoes itself rather than leaving two models resident on one device. - if current_owner() != CHAT: + # load undoes itself rather than leaving two models resident on one device. A + # zero-VRAM load never took ownership, so it has nothing to lose and never yields. + if chat_load_needs_gpu and current_owner() != CHAT: await asyncio.to_thread(llama_backend.unload_model) raise HTTPException( status_code = 409, @@ -4826,6 +4853,13 @@ async def _load_model_impl( "so the load was cancelled. Unload that model, then try again." ), ) + if not chat_load_needs_gpu: + # Zero-VRAM load done, and whatever GPU-backed chat model it replaced went with + # it, so drop a now-stale CHAT claim: leaving it would make the next image/video + # load "evict" a server holding nothing. Owner-guarded, so it no-ops when an + # image/video model took the GPU while this was loading -- which is fine, they + # coexist. + await asyncio.to_thread(release, CHAT) logger.info( f"Loaded GGUF model via llama-server: {model_log_label if native_grant_backed else config.identifier}" @@ -4945,6 +4979,23 @@ async def _load_model_impl( detail = f"Failed to load model: {model_log_label if native_grant_backed else config.display_name}", ) + # Same guard the GGUF branch runs above: an Images/Video acquire can land in the gap + # between this load's cancellation and its publish, so the eviction is lost and the + # model lands anyway. Ownership survives that gap, so this load undoes itself rather + # than leaving two models resident on one device. + if current_owner() != CHAT: + await asyncio.to_thread(backend.unload_model, config.identifier) + # The worker's base CUDA context outlives the model unload, so kill it too -- + # that VRAM is exactly what the image/video pipeline just took the GPU for. + await asyncio.to_thread(backend._shutdown_subprocess, 5.0) + raise HTTPException( + status_code = 409, + detail = ( + "An image or video model took the GPU while this model was loading, " + "so the load was cancelled. Unload that model, then try again." + ), + ) + logger.info( f"Loaded model: {model_log_label if native_grant_backed else config.identifier}" ) diff --git a/studio/backend/tests/test_gpu_arbiter.py b/studio/backend/tests/test_gpu_arbiter.py index e122585e42..2e75b9129b 100644 --- a/studio/backend/tests/test_gpu_arbiter.py +++ b/studio/backend/tests/test_gpu_arbiter.py @@ -249,3 +249,104 @@ def test_evict_chat_cancels_a_chat_load_that_has_not_spawned_yet(monkeypatch): # The marker is released with the load, so a later eviction is a no-op again. arb._evict_chat() assert unloaded == [True] + + +def test_evict_chat_cancels_an_in_flight_safetensors_load(monkeypatch): + # The orchestrator publishes active_model_name only once its worker reports success, so an + # in-flight safetensors load is visible ONLY as an entry in loading_models. Gating the + # cancellation on active_model_name let that worker finish after ownership transferred and + # allocate the model alongside the image/video pipeline. + import core.inference as core_inference + import routes.inference as routes_inference + + cancelled: list[str] = [] + + class _FakeLlama: + is_active = False + is_loaded = False + + def unload_model(self): + pass + + def _wait_for_vram_settle(self, *, since_kill): + pass + + class _FakeOrchestrator: + active_model_name = None # not published yet: the load is still running + loading_models = {"unsloth/Qwen3-4B"} + + def unload_model(self, name): + raise AssertionError("unload_model must not run for an unpublished load") + + def cancel_load(self, name): + cancelled.append(name) + return True + + def _shutdown_subprocess(self, timeout = 5.0): + pass + + monkeypatch.setattr(routes_inference, "get_llama_cpp_backend", lambda: _FakeLlama()) + monkeypatch.setattr(core_inference, "get_inference_backend", lambda: _FakeOrchestrator()) + + arb._evict_chat() + + assert cancelled == ["unsloth/Qwen3-4B"] + + +def test_evict_chat_cancels_every_pending_load_over_a_live_snapshot(monkeypatch): + # cancel_load discards the marker it cancels, so iterate a snapshot rather than the live + # set (mutating during iteration raises) and cancel each pending entry. + import core.inference as core_inference + import routes.inference as routes_inference + + cancelled: list[str] = [] + + class _FakeLlama: + is_active = False + is_loaded = False + + def unload_model(self): + pass + + def _wait_for_vram_settle(self, *, since_kill): + pass + + class _FakeOrchestrator: + active_model_name = None + + def __init__(self): + self.loading_models = {"a/one", "b/two"} + + def cancel_load(self, name): + self.loading_models.discard(name) + cancelled.append(name) + return True + + def _shutdown_subprocess(self, timeout = 5.0): + pass + + orchestrator = _FakeOrchestrator() + monkeypatch.setattr(routes_inference, "get_llama_cpp_backend", lambda: _FakeLlama()) + monkeypatch.setattr(core_inference, "get_inference_backend", lambda: orchestrator) + + arb._evict_chat() + + assert sorted(cancelled) == ["a/one", "b/two"] + assert orchestrator.loading_models == set() + + +def test_the_safetensors_load_yields_a_gpu_it_lost_while_loading(): + # Mirror of the GGUF branch's guard: an Images/Video acquire can land in the gap between the + # eviction and the load's publish, so the load has to undo itself instead of leaving two + # models resident. Without it only the GGUF branch was safe. + from pathlib import Path + + route_src = (Path(__file__).resolve().parent.parent / "routes" / "inference.py").read_text( + encoding = "utf-8" + ) + load_impl = route_src[route_src.index("async def _load_model_impl") :] + unsloth_load = load_impl.index("success = await asyncio.to_thread(\n backend.load_model,") + tail = load_impl[unsloth_load:] + guard = tail.index("if current_owner() != CHAT:") + assert "await asyncio.to_thread(backend.unload_model, config.identifier)" in tail[guard:] + assert tail.index("status_code = 409", guard) > guard diff --git a/studio/backend/tests/test_gpu_memory_mode.py b/studio/backend/tests/test_gpu_memory_mode.py index 271a882b11..fb731deadf 100644 --- a/studio/backend/tests/test_gpu_memory_mode.py +++ b/studio/backend/tests/test_gpu_memory_mode.py @@ -1154,3 +1154,86 @@ def test_cmd_companion_ignores_cpu_forced_drafter(): # mmproj still counts even alongside a CPU drafter. cmd = ["llama-server", "-md", "d.gguf", "--spec-draft-ngl", "0", "--mmproj", "p.gguf"] assert has(cmd, {}) is True + + +# ── Zero-offload loads and the GPU arbiter ─────────────────────────── + + +@pytest.fixture +def not_vulkan(monkeypatch): + # The Vulkan probe reads the installed prebuilt; pin it so these assert the predicate. + monkeypatch.setattr(LlamaCppBackend, "_is_vulkan_backend", staticmethod(lambda *a, **k: False)) + + +def test_zero_vram_chat_load_only_for_a_deliberate_cpu_only_offload(not_vulkan): + # Manual + gpu_layers=0 is the one shape that launches with the GPUs hidden from the child, + # so it is the one shape allowed to skip the GPU arbiter. Auto (or any pinned layer count) + # puts the model on the GPU and must still evict an image/video pipeline. + zero = llama_cpp_module.zero_vram_chat_load + assert zero("manual", 0) is True + assert zero("auto", 0) is False + assert zero("manual", 1) is False + assert zero("manual", -1) is False + + +def test_zero_vram_chat_load_refuses_every_gpu_companion(not_vulkan): + # The launch-time mask keeps the GPUs visible for a device pin, tensor mode, an mmproj or a + # drafter, so those loads DO hold VRAM. --mmproj and --model-draft are added by the backend + # rather than carried in the extras, so their intent arrives as flags. + zero = llama_cpp_module.zero_vram_chat_load + assert zero("manual", 0, ["--device", "CUDA0"]) is False + assert zero("manual", 0, ["-dev", "CUDA0"]) is False + assert zero("manual", 0, ["--split-mode", "tensor"]) is False + assert zero("manual", 0, ["--model-draft", "/tmp/draft.gguf"]) is False + assert zero("manual", 0, [], True) is False + assert zero("manual", 0, [], False, "model") is False + # A CPU-pinned device and a CPU-forced drafter keep it zero-VRAM. + assert zero("manual", 0, ["--device", "none"]) is True + assert zero("manual", 0, ["--model-draft", "/tmp/d.gguf", "--spec-draft-ngl", "0"]) is True + + +def test_zero_vram_chat_load_is_skipped_on_vulkan(monkeypatch): + # Vulkan builds are exempt from the CPU-only mask at launch, so the arbiter gate must match. + monkeypatch.setattr(LlamaCppBackend, "_is_vulkan_backend", staticmethod(lambda *a, **k: True)) + assert llama_cpp_module.zero_vram_chat_load("manual", 0) is False + + +def test_holds_no_vram_needs_the_launch_to_have_confirmed_it(): + # The property reports the LAUNCHED server, so it follows _gpu_offload_active: True (something + # still reached the GPU) and None (no GPU detected at all) both keep the normal arbiter path. + backend = LlamaCppBackend() + backend._gpu_memory_mode = "manual" + backend._gpu_layers = 0 + backend._gpu_offload_active = False + assert backend.holds_no_vram is True + backend._gpu_offload_active = True + assert backend.holds_no_vram is False + backend._gpu_offload_active = None + assert backend.holds_no_vram is False + backend._gpu_offload_active = False + backend._gpu_layers = 20 + assert backend.holds_no_vram is False + backend._gpu_layers = 0 + backend._gpu_memory_mode = "auto" + assert backend.holds_no_vram is False + + +def test_a_cpu_only_chat_load_does_not_take_the_gpu_arbiter(): + # A load that needs no VRAM must not evict a resident Images/Video pipeline (nor leave CHAT + # recorded as owner, which would make the next GPU workload unload it for nothing). The + # in-flight marker still goes up, and the post-load ownership recheck -- which would 409 a + # load that never acquired -- is gated on the same flag. + route_src = (Path(_BACKEND_DIR) / "routes" / "inference.py").read_text(encoding = "utf-8") + load_impl = route_src[route_src.index("async def _load_model_impl") :] + assert "chat_load_needs_gpu = not (" in load_impl + gate = load_impl.index("chat_load_needs_gpu = not (") + acquire = load_impl.index("if chat_load_needs_gpu:", gate) + # The stale CHAT claim is dropped only AFTER the load: this load may still be replacing a + # GPU-backed chat model, and releasing before it would let an image/video load allocate + # alongside the model not yet unloaded. + release = load_impl.index("await asyncio.to_thread(release, CHAT)", acquire) + assert load_impl.index("if not chat_load_needs_gpu:", acquire) < release + assert load_impl.index("success = await load_with_tensor_fallback(", acquire) < release + assert "if chat_load_needs_gpu and current_owner() != CHAT:" in load_impl + # The already-loaded fast path re-asserts ownership too; same exemption. + assert "if not llama_backend.holds_no_vram:" in load_impl diff --git a/studio/frontend/src/features/hub/download-manager/use-staged-download.ts b/studio/frontend/src/features/hub/download-manager/use-staged-download.ts index f4fc1d1ce4..4af18d1e13 100644 --- a/studio/frontend/src/features/hub/download-manager/use-staged-download.ts +++ b/studio/frontend/src/features/hub/download-manager/use-staged-download.ts @@ -59,14 +59,27 @@ export function useStagedDownload({ kind: DOWNLOAD_KIND.MODEL, repoId: current?.repoId ?? "__staged_download_idle__", activeVariant, - onComplete: () => { + // The listener subscription is per REPO, not per job, and one repo can have several jobs in + // flight -- the Models tab downloading a chat quant of the same repo, say. Each callback + // carries the variant it fired for, so drop the ones that aren't this staged entry: a + // sibling's completion would otherwise advance the queue (and load a model whose scoped + // files are still downloading), and its failure would wipe a queue still running. The chat + // page's auto-load filters the same way. + onComplete: (variant) => { + if ((variant ?? null) !== activeVariant) return; const remaining = (queue ?? []).slice(1); advance(); // Every entry is on disk, so the load will find its cache warm. if (remaining.length === 0) onReady(); }, - onError: () => setQueue(null), - onCancelled: () => setQueue(null), + onError: (variant) => { + if ((variant ?? null) !== activeVariant) return; + setQueue(null); + }, + onCancelled: (variant) => { + if ((variant ?? null) !== activeVariant) return; + setQueue(null); + }, }); useEffect(() => { diff --git a/studio/frontend/src/features/video/video-page.tsx b/studio/frontend/src/features/video/video-page.tsx index 10cde6c577..892e079c84 100644 --- a/studio/frontend/src/features/video/video-page.tsx +++ b/studio/frontend/src/features/video/video-page.tsx @@ -701,6 +701,48 @@ export function VideoPage({ active = true }: { active?: boolean }) { } }, []); + // Fetching a clip pulls its whole MP4 into an object URL that lives until the page closes, so + // fetching a full gallery page (PAGE_SIZE records, each tens to hundreds of MB for a longer + // clip) up front pinned hundreds of MB -- gigabytes over a few "load more" pages -- for cards + // the user may never scroll to, and starved the one they were waiting on. Fetch a card as it + // nears the viewport instead; the tile already shows a spinner until its src lands. + // The cards are observed from here rather than through a ref on each tile: the tile is a + // Tooltip trigger, whose asChild clone owns that ref. Re-runs per page of records, so cards + // appended by "load more" are picked up and removed ones are dropped with the observer. + const stripRef = useRef(null); + useEffect(() => { + const root = stripRef.current; + if (!root || typeof IntersectionObserver === "undefined") return; + const io = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + if (!entry.isIntersecting) continue; + const id = (entry.target as HTMLElement).dataset.clipId; + const clip = id ? videos.find((v) => v.id === id) : undefined; + if (clip) void ensureSrc(clip); + } + }, + // rootMargin is added to the ROOT's box only, never to an intermediate clipping ancestor, + // so the root has to be the strip itself: a card scrolled past its right edge is clipped + // by the strip, and a margin on the default viewport root would never reach it. The strip + // scrolls horizontally, so the sideways margin is the one that matters -- it starts a + // card's fetch a few tiles before it is scrolled to, so it is ready on arrival. + { root, rootMargin: "0px 600px" }, + ); + for (const card of root.querySelectorAll("[data-clip-id]")) io.observe(card); + return () => io.disconnect(); + }, [videos, ensureSrc]); + + // The preview player is what the user actually watches, so the selected clip is fetched + // whether or not its card is on screen: a selection restored across a tab switch, a freshly + // generated clip, or a pick made just before the strip scrolls it out of view. + useEffect(() => { + if (!selected) return; + void (async () => { + await ensureSrc(selected); + })(); + }, [selected, ensureSrc]); + const loadGallery = useCallback(async () => { try { const page = await getVideoGallery(0, PAGE_SIZE); @@ -708,7 +750,11 @@ export function VideoPage({ active = true }: { active?: boolean }) { galleryCache.hasMore = page.has_more; setVideos(page.videos); setHasMore(page.has_more); - page.videos.forEach((video) => void ensureSrc(video)); + // No visibility signal without IntersectionObserver (jsdom / an old webview), so keep the + // eager fetch there rather than render a strip of permanent spinners. + if (typeof IntersectionObserver === "undefined") { + page.videos.forEach((video) => void ensureSrc(video)); + } } catch { // Best-effort: a failed gallery load shouldn't block the page. } @@ -727,7 +773,9 @@ export function VideoPage({ active = true }: { active?: boolean }) { }); galleryCache.hasMore = page.has_more; setHasMore(page.has_more); - page.videos.forEach((video) => void ensureSrc(video)); + if (typeof IntersectionObserver === "undefined") { + page.videos.forEach((video) => void ensureSrc(video)); + } } catch { // transient; the user can scroll again to retry } finally { @@ -1810,6 +1858,7 @@ export function VideoPage({ active = true }: { active?: boolean }) { {(videos.length > 0 || busy === "generating") && (
{ // Near the right edge: pull the next older page (infinite scroll). @@ -1829,6 +1878,7 @@ export function VideoPage({ active = true }: { active?: boolean }) {