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:
Danila Khodjaef
2026-07-30 20:40:06 +03:00
co-authored by Cursor
parent 0a5918c4dd
commit 710d262516
4 changed files with 121 additions and 93 deletions
+6 -2
View File
@@ -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;
}
+106 -85
View File
@@ -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 texturelarge 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>
<Environment
preset={interiorStyle?.details === 'modern' || interiorStyle?.details === 'industrial' ? 'city' : 'warehouse'}
environmentIntensity={interiorStyle ? 0.35 : 0.15}
/>
</Suspense>
</SceneErrorBoundary>
<GalleryTextureLoadContext.Provider value={textureLoad}>
+5 -2
View File
@@ -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))),