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 <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
0a5918c4dd
commit
710d262516
@@ -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 `<Canvas>` 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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,25 +715,35 @@ 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();
|
||||
// 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);
|
||||
|
||||
const slot = acquireTextureLoadSlot();
|
||||
void slot.promise.then((release) => {
|
||||
if (disposed) {
|
||||
release();
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
releaseSlot = release;
|
||||
loader.load(
|
||||
url,
|
||||
(tex) => {
|
||||
@@ -738,9 +787,11 @@ function usePaintingTexture(urls: string[] | string | null) {
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
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) {
|
||||
<SceneErrorBoundary
|
||||
key={`env-${hallKey}-${glEpoch}`}
|
||||
fallback={null}
|
||||
onError={handleEnvReady}
|
||||
>
|
||||
<Suspense fallback={null}>
|
||||
<EnvironmentGate onReady={handleEnvReady}>
|
||||
<Environment
|
||||
preset={interiorStyle?.details === 'modern' || interiorStyle?.details === 'industrial' ? 'city' : 'warehouse'}
|
||||
environmentIntensity={interiorStyle ? 0.35 : 0.15}
|
||||
/>
|
||||
</EnvironmentGate>
|
||||
</Suspense>
|
||||
</SceneErrorBoundary>
|
||||
<GalleryTextureLoadContext.Provider value={textureLoad}>
|
||||
|
||||
@@ -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))),
|
||||
|
||||
Reference in New Issue
Block a user