From 710d262516350617996fe0790a28ba76faed45e3 Mon Sep 17 00:00:00 2001 From: Danila Khodjaef Date: Thu, 30 Jul 2026 20:40:06 +0300 Subject: [PATCH] Speed up large movement gallery entry (Byzantine) Hall frames use thumbs only (never multi-MB originals). Preload no longer blocks open; texture downloads are queued; boot overlay no longer waits on HDR Environment. Co-authored-by: Cursor --- Documentation/basics.md | 8 +- client/src/api/client.ts | 8 +- client/src/components/VirtualGallery.tsx | 191 +++++++++++++---------- client/src/pages/HomePage.tsx | 7 +- 4 files changed, 121 insertions(+), 93 deletions(-) diff --git a/Documentation/basics.md b/Documentation/basics.md index 6163e16..effa599 100644 --- a/Documentation/basics.md +++ b/Documentation/basics.md @@ -210,7 +210,7 @@ Pan, zoom, and era/event click-to-zoom only update **local** `viewStart` / `view | Overlay **“Loading art history…”** | Until the first catalog fetch (`bounds` + `timeline` + `artists`) completes | | Bottom banner **“Loading portraits…”** | While artist portrait thumbnails are still downloading on the movement flow (timeline stays interactive) | | Overlay **“Opening artist/movement gallery…”** / **“Loading artists…”** | Between clicking a portrait/movement and the 3D hall (or artist-filter modal) data being ready | -| Overlay **“Loading gallery…”** | While the 3D canvas initializes, HDR Environment settles, or door/hall shaders warm up after the hall opens (painting images continue loading after the overlay dismisses) | +| Overlay **“Loading gallery…”** | While the 3D canvas initializes and door/hall shaders warm up after the hall opens (HDR Environment and painting textures continue in the background) | | Overlay **“Loading paintings…”** | Brief counter while wall painting textures start downloading; large halls (e.g. Byzantine) keep loading after the overlay dismisses — a slow image no longer permanently blanks the frame | | Overlay **“Restoring gallery…”** | Briefly after WebGL context loss while the canvas remounts | @@ -373,9 +373,9 @@ Full guide: [tours.md](tours.md). ### Shared 3D behaviour -**3D images** prefer local thumbnail files (`galleryImageUrlCandidates` in `client/src/api/client.ts`: thumb → full → `GET /api/paintings/:id/image?size=thumb`). Remote Wikipedia fetches are too slow for realtime WebGL textures; the client calls `POST /api/artists/:id/preload-images` or `POST /api/movements/:id/preload-images` automatically when entering an **artist** or **movement** hall (public routes — link disk files and regenerate missing thumbs). While a texture is loading, the frame shows the canvas cover instead of a white placeholder. A per-image deadline only releases the boot overlay counter — it does **not** permanently blank the frame if the download finishes later (important for large halls such as Byzantine). +**3D images** prefer local **thumbnail** files (`galleryImageUrlCandidates` in `client/src/api/client.ts`: thumb → `GET /api/paintings/:id/image?size=thumb`; full originals are **not** used for hall frames — they can be tens of MB and made large halls take ~1 minute). Remote Wikipedia fetches are too slow for realtime WebGL textures. Entering an **artist** or **movement** hall fires `POST /api/artists/:id/preload-images` or `POST /api/movements/:id/preload-images` in the **background** (does not block hall open). Texture downloads are **queued** (max 8 parallel). While a texture is loading, the frame shows the canvas cover instead of a white placeholder. A per-image deadline only releases the boot overlay counter — it does **not** permanently blank the frame if the download finishes later. -**Boot overlay** (`VirtualGallery.tsx`): the center shows **“Loading gallery…”** until the WebGL canvas is ready, HDR `Environment` has settled (or timed out / failed), and a one-shot `gl.compileAsync` warm-up finishes so entrance doors / passages (often frustum-culled at spawn) do not hitch on the first turn. Painting textures keep loading in the background (prefer thumbnails; fallback to full / on-demand API; GPU upload after decode) so large halls (50+ works) are not stuck on the overlay. Env and shader warm-up also have short timeouts. The 3D hall stays mounted while painting detail or bio overlays are open; returning remounts the canvas when the hall becomes active again. +**Boot overlay** (`VirtualGallery.tsx`): the center shows **“Loading gallery…”** until the WebGL canvas is ready and a short `gl.compileAsync` warm-up finishes (so entrance doors / passages do not hitch on the first turn). HDR `Environment` loads in a Suspense boundary **without** blocking the overlay. Painting textures keep loading in the background. The 3D hall stays mounted while painting detail or bio overlays are open; returning remounts the canvas when the hall becomes active again. **WebGL context-loss recovery:** on some GPUs/drivers (notably certain Chrome setups) the browser can drop the WebGL context right after entering a hall, which would otherwise leave a permanent dark window. `VirtualGallery.tsx` listens for `webglcontextlost` / `webglcontextrestored`, calls `preventDefault()` so the browser can restore the context, and remounts the `` with a fresh context (a **“Restoring gallery…”** overlay shows briefly). The network-loaded HDR `Environment` map is wrapped in an error boundary so, if it fails to load, the hall still renders without reflections instead of unmounting the whole scene. @@ -431,7 +431,7 @@ Next to the toggle, **Show more** (checkbox, persisted in `localStorage`) opens - **Guided tours** add curator-ordered winged halls with stop text on painting detail — [tours.md](tours.md). - **Wall hang** is shared: first work on the left at the entrance, last on the right. - **Influence-based hall links** connect artists through documented painting relationships, grouped by movement at the exit. -- **3D gallery images** use locally cached files only; slow remote fetches would break realtime rendering. The client calls `POST /api/artists/:id/preload-images` or `POST /api/movements/:id/preload-images` automatically when entering an artist or movement hall (public routes — link disk files and regenerate missing thumbs). +- **3D gallery images** use local **thumbnails** for hall frames (not multi-MB originals). `POST /api/artists/:id/preload-images` / `POST /api/movements/:id/preload-images` run in the background when entering a hall (public routes — link disk files and regenerate missing thumbs). - **3D gallery session** stays mounted while painting detail or bio overlays are open; returning to the hall remounts the WebGL canvas when it becomes active again. - **3D gallery resilience:** a lost WebGL context is recovered by remounting the canvas with a fresh context (rather than showing a dark window), and the HDR environment map is isolated behind an error boundary so its failure never blanks the scene. - **Loading feedback:** `GalleryLoadingMarker` surfaces catalog load, portrait download, gallery entry, painting-texture / GPU warm-up, Environment settle, and context-restore states so the user always knows work is still in progress. diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 30a07c1..379c586 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -177,7 +177,7 @@ export function galleryImageUrl( return null; } -/** Ordered texture URL candidates for a hall frame (thumb → full → on-demand API). */ +/** Ordered hall texture URLs — thumbs only (never full originals; those can be 10MB+ each). */ export function galleryImageUrlCandidates( painting: { id?: number; @@ -194,10 +194,14 @@ export function galleryImageUrlCandidates( if (u && !urls.includes(u)) urls.push(u); }; if (painting.thumbnail_path) push(imageUrl(painting.thumbnail_path, revision)); - if (painting.image_path) push(imageUrl(painting.image_path, revision)); + // If DB has no thumb path but has a full file, still prefer the on-demand thumb API + // over streaming the multi-megabyte original into WebGL. if (painting.id != null) { push(`/api/paintings/${painting.id}/image?size=thumb`); } + if (!painting.thumbnail_path && painting.image_path) { + push(imageUrl(painting.image_path, revision)); + } return urls; } diff --git a/client/src/components/VirtualGallery.tsx b/client/src/components/VirtualGallery.tsx index 97b0184..9f2f0e1 100644 --- a/client/src/components/VirtualGallery.tsx +++ b/client/src/components/VirtualGallery.tsx @@ -98,9 +98,48 @@ const REVIEWED_MAT_BORDER = FRAME_MAT_BORDER * 2; const REVIEWED_RAIL = FRAME_RAIL * 2; const EYE_HEIGHT = 1.65; const FRAME_GAP = 0.32; -const SHADER_WARM_TIMEOUT_MS = 4000; -/** Per-image fetch/decode deadline so a stuck request cannot hold the counter forever. */ -const TEXTURE_LOAD_TIMEOUT_MS = 10000; +const SHADER_WARM_TIMEOUT_MS = 2000; +/** Per-image fetch/decode deadline so a stuck request cannot hold the overlay counter forever. */ +const TEXTURE_LOAD_TIMEOUT_MS = 20000; +/** Cap parallel WebGL texture downloads — large halls otherwise stampede the browser pool. */ +const MAX_PARALLEL_TEXTURE_LOADS = 8; + +const textureSlotWaiters: Array<() => void> = []; +let textureLoadsInFlight = 0; + +function acquireTextureLoadSlot(): { + promise: Promise<() => void>; + cancel: () => void; +} { + let grantFn: (() => void) | null = null; + let cancelled = false; + const promise = new Promise<() => void>((resolve) => { + grantFn = () => { + if (cancelled) return; + textureLoadsInFlight++; + let released = false; + resolve(() => { + if (released) return; + released = true; + textureLoadsInFlight = Math.max(0, textureLoadsInFlight - 1); + const next = textureSlotWaiters.shift(); + if (next) next(); + }); + }; + if (textureLoadsInFlight < MAX_PARALLEL_TEXTURE_LOADS) grantFn(); + else textureSlotWaiters.push(grantFn); + }); + return { + promise, + cancel: () => { + cancelled = true; + if (grantFn) { + const idx = textureSlotWaiters.indexOf(grantFn); + if (idx >= 0) textureSlotWaiters.splice(idx, 1); + } + }, + }; +} const MIN_FRAME_W = 0.45; const MAX_FRAME_W = 1.05; const MAX_FRAME_H = 1.35; @@ -676,71 +715,83 @@ function usePaintingTexture(urls: string[] | string | null) { let disposed = false; let loaded: THREE.Texture | null = null; let settled = false; + let releaseSlot: (() => void) | null = null; const loader = new THREE.TextureLoader(); - loader.setCrossOrigin('anonymous'); + // Relative /images URLs are same-origin via the Vite proxy — avoid CORS mode. + if (/^https?:\/\//i.test(url)) { + loader.setCrossOrigin('anonymous'); + } const finish = () => { if (settled) return; settled = true; textureLoad?.end(); + releaseSlot?.(); + releaseSlot = null; }; textureLoad?.begin(); - // Release the loading overlay after a deadline, but do NOT mark the texture - // failed — large halls (e.g. Byzantine, 45 works) queue behind ~6 browser - // connections and often finish after 10s. Marking failed permanently left - // blank canvases even when the image arrived later. const loadTimeout = window.setTimeout(() => { if (settled || disposed) return; finish(); }, TEXTURE_LOAD_TIMEOUT_MS); - loader.load( - url, - (tex) => { - window.clearTimeout(loadTimeout); - if (disposed) { - tex.dispose(); + const slot = acquireTextureLoadSlot(); + void slot.promise.then((release) => { + if (disposed) { + release(); + finish(); + return; + } + releaseSlot = release; + loader.load( + url, + (tex) => { + window.clearTimeout(loadTimeout); + if (disposed) { + tex.dispose(); + finish(); + return; + } + const img = tex.image as HTMLImageElement | undefined; + if (!img || img.width < 4 || img.height < 4) { + tex.dispose(); + finish(); + if (!disposed) { + if (urlIndex + 1 < candidates.length) setUrlIndex((i) => i + 1); + else setFailed(true); + } + return; + } + loaded = tex; + tex.colorSpace = THREE.SRGBColorSpace; + tex.anisotropy = 4; finish(); - return; - } - const img = tex.image as HTMLImageElement | undefined; - if (!img || img.width < 4 || img.height < 4) { - tex.dispose(); + if (!disposed) { + setFailed(false); + setTexture(tex); + } + try { + if (!disposed) gl.initTexture(tex); + } catch { + // Upload can fail after context loss; texture still usable later. + } + }, + undefined, + () => { + window.clearTimeout(loadTimeout); finish(); if (!disposed) { if (urlIndex + 1 < candidates.length) setUrlIndex((i) => i + 1); else setFailed(true); } - return; } - loaded = tex; - tex.colorSpace = THREE.SRGBColorSpace; - tex.anisotropy = 4; - finish(); - if (!disposed) { - setFailed(false); - setTexture(tex); - } - try { - if (!disposed) gl.initTexture(tex); - } catch { - // Upload can fail after context loss; texture still usable later. - } - }, - undefined, - () => { - window.clearTimeout(loadTimeout); - finish(); - if (!disposed) { - if (urlIndex + 1 < candidates.length) setUrlIndex((i) => i + 1); - else setFailed(true); - } - } - ); + ); + }); return () => { disposed = true; + slot.cancel(); window.clearTimeout(loadTimeout); finish(); loaded?.dispose(); @@ -1728,20 +1779,6 @@ function FrameloopSync({ active }: { active: boolean }) { return null; } -/** Fires onReady once Environment (inside Suspense) has resolved and mounted. */ -function EnvironmentGate({ - onReady, - children, -}: { - onReady: () => void; - children: ReactNode; -}) { - useEffect(() => { - onReady(); - }, [onReady]); - return <>{children}; -} - /** Compile all scene materials (incl. culled doors) before dismissing the loading overlay. */ function WarmHallGpu({ enabled, @@ -1988,7 +2025,6 @@ export default function VirtualGallery(props: Props) { const [isLooking, setIsLooking] = useState(false); const [texturesPending, setTexturesPending] = useState(0); const [canvasReady, setCanvasReady] = useState(false); - const [envReady, setEnvReady] = useState(false); const [shadersWarmed, setShadersWarmed] = useState(false); const [glEpoch, setGlEpoch] = useState(0); const [glLost, setGlLost] = useState(false); @@ -2001,10 +2037,6 @@ export default function VirtualGallery(props: Props) { [] ); - const handleEnvReady = useCallback(() => { - setEnvReady(true); - }, []); - const handleShadersWarmed = useCallback(() => { setShadersWarmed(true); }, []); @@ -2076,7 +2108,6 @@ export default function VirtualGallery(props: Props) { }, [hallKey]); useEffect(() => { - setEnvReady(false); setShadersWarmed(false); setTexturesPending(0); }, [hallKey, glEpoch]); @@ -2085,19 +2116,12 @@ export default function VirtualGallery(props: Props) { setShadersWarmed(false); }, [hallIndex]); - // HDR Environment can hang or fail (CDN / Suspense). Never block the hall forever. - useEffect(() => { - if (envReady) return; - const t = window.setTimeout(() => setEnvReady(true), 5000); - return () => window.clearTimeout(t); - }, [envReady, hallKey, glEpoch]); - // If shader warm-up never settles, dismiss the overlay anyway. useEffect(() => { - if (shadersWarmed || !canvasReady || !envReady) return; - const t = window.setTimeout(() => setShadersWarmed(true), SHADER_WARM_TIMEOUT_MS + 1000); + if (shadersWarmed || !canvasReady) return; + const t = window.setTimeout(() => setShadersWarmed(true), SHADER_WARM_TIMEOUT_MS + 500); return () => window.clearTimeout(t); - }, [shadersWarmed, canvasReady, envReady, hallKey, glEpoch, hallIndex]); + }, [shadersWarmed, canvasReady, hallKey, glEpoch, hallIndex]); const layout = useMemo(() => { if (isWingedHall && movementHalls.length > 0) { @@ -2106,17 +2130,17 @@ export default function VirtualGallery(props: Props) { return buildHallLayout(paintings, periods); }, [isWingedHall, movementHalls, hallIndex, paintings, periods]); - // Do not block the hall on every painting texture — large artists (e.g. Duccio, 50+) - // otherwise sit on "Loading paintings…" for a long time. Textures keep loading after. + // Do not block hall entry on HDR Environment (CDN) — warm shaders as soon as + // the canvas exists; Environment continues loading in the background. const gallerySceneLoading = active && !glLost && - (!canvasReady || !envReady || !shadersWarmed); + (!canvasReady || !shadersWarmed); const galleryLoadingMessage = canvasReady && texturesPending > 0 ? 'Loading paintings…' : 'Loading gallery…'; - const warmGpuEnabled = canvasReady && envReady && !shadersWarmed; + const warmGpuEnabled = canvasReady && !shadersWarmed; const computedWindows = useMemo(() => { if (!isMovement || !interiorStyle || !('hallIndex' in layout)) return undefined; @@ -2525,15 +2549,12 @@ export default function VirtualGallery(props: Props) { - - - + diff --git a/client/src/pages/HomePage.tsx b/client/src/pages/HomePage.tsx index 2c42a05..e6c7263 100644 --- a/client/src/pages/HomePage.tsx +++ b/client/src/pages/HomePage.tsx @@ -636,7 +636,7 @@ export default function HomePage() { const handleArtistClick = async (artistId: number) => { setGalleryEntryLoading('Opening artist gallery…'); try { - await api.preloadArtistImages(artistId).catch(() => undefined); + void api.preloadArtistImages(artistId).catch(() => undefined); const data = await api.getArtist(artistId); openArtistGallery(artistId, data); } catch { @@ -673,8 +673,11 @@ export default function HomePage() { setArtistFilterModal(null); setGalleryEntryLoading('Opening movement gallery…'); try { - await api.preloadMovementImages(movementId).catch(() => undefined); + // Do not await preload — it only syncs disk paths and must not delay hall open. + // Fire it in parallel so any missing thumbs regenerate while the gallery boots. + const preloadPromise = api.preloadMovementImages(movementId).catch(() => undefined); const data = await api.getMovementGallery(movementId); + void preloadPromise; const filtered: MovementGalleryDetail = { ...data, paintings: data.paintings.filter((p) => selectedIds.has(Number(p.artist_id))),