From ed2a17970fe2ce4cb0609bad5f6b2dc3fcc0c29c Mon Sep 17 00:00:00 2001 From: Will Miao Date: Tue, 1 Sep 2026 22:26:50 +0800 Subject: [PATCH] perf(showcase): prefetch adjacent examples and shrink gallery thumbnails - Warm the HTTP cache for examples adjacent to the active one after expand and on every navigation, so prev/next feels instant (images only, deduped, low fetch priority) - Add GALLERY_THUMBNAIL optimization mode (width=160) for the 72px gallery strip instead of reusing the 450px card thumbnails - Hint priorities: fetchpriority=high on the main media, low on strip thumbnails --- .../shared/showcase/MediaRenderers.js | 1 + .../shared/showcase/ShowcaseView.js | 44 +++++++++++++- static/js/utils/civitaiUtils.js | 20 ++++++- .../components/showcase.gallery.test.js | 60 +++++++++++++++++++ tests/frontend/utils/civitaiUtils.test.js | 34 +++++++++++ 5 files changed, 153 insertions(+), 6 deletions(-) diff --git a/static/js/components/shared/showcase/MediaRenderers.js b/static/js/components/shared/showcase/MediaRenderers.js index e4a5de18..3b1e2c7b 100644 --- a/static/js/components/shared/showcase/MediaRenderers.js +++ b/static/js/components/shared/showcase/MediaRenderers.js @@ -76,6 +76,7 @@ export function generateImageWrapper(media, shouldBlur, nsfwText, metadataPanel, alt="Preview" width="${media.width}" height="${media.height}" + fetchpriority="high" class="lazy ${shouldBlur ? 'blurred' : ''}"> ${shouldBlur ? `
diff --git a/static/js/components/shared/showcase/ShowcaseView.js b/static/js/components/shared/showcase/ShowcaseView.js index 41108633..d2512be7 100644 --- a/static/js/components/shared/showcase/ShowcaseView.js +++ b/static/js/components/shared/showcase/ShowcaseView.js @@ -22,7 +22,7 @@ import { } from './MediaUtils.js'; import { generateMetadataPanel } from './MetadataPanel.js'; import { generateImageWrapper, generateVideoWrapper } from './MediaRenderers.js'; -import { getShowcaseUrl, getThumbnailUrl } from '../../../utils/civitaiUtils.js'; +import { getShowcaseUrl, getGalleryThumbnailUrl } from '../../../utils/civitaiUtils.js'; import { openMediaViewer } from '../MediaViewer.js'; import { escapeAttribute } from '../utils.js'; @@ -275,7 +275,7 @@ function renderThumbnail(img, index, exampleFiles) { originalRemoteUrl.endsWith('.mp4') || originalRemoteUrl.endsWith('.webm'); const mediaType = isVideo ? 'video' : 'image'; - const thumbUrl = localFile ? localFile.path : getThumbnailUrl(originalRemoteUrl, mediaType); + const thumbUrl = localFile ? localFile.path : getGalleryThumbnailUrl(originalRemoteUrl, mediaType); const nsfwLevel = img.nsfwLevel !== undefined ? img.nsfwLevel : 0; const matureBlurThreshold = getMatureBlurThreshold(state.settings); @@ -286,7 +286,7 @@ function renderThumbnail(img, index, exampleFiles) { const mediaHtml = isVideo ? ` ` : - ``; + ``; const nsfwBadge = shouldBlur ? '' : ''; return ``; @@ -438,6 +438,40 @@ function findLocalFile(img, index, exampleFiles) { return localFile; } +// URLs already warmed in the HTTP cache, so repeat navigations and re-renders +// never issue duplicate prefetch requests +const prefetchedUrls = new Set(); + +/** + * Warm the HTTP cache for the examples most likely to be shown next (the + * indices adjacent to the active one), so prev/next navigation feels instant. + * Images only: video payloads are too heavy for speculative prefetch, and + * locally stored examples need no network fetch at all. + */ +function prefetchAdjacentMedia() { + const { images, exampleFiles, activeIndex, expanded } = galleryState; + if (!expanded || images.length < 2) return; + + [activeIndex + 1, activeIndex - 1].forEach(i => { + const index = ((i % images.length) + images.length) % images.length; + const img = images[index]; + if (!img?.url || findLocalFile(img, index, exampleFiles)) return; + + const isVideo = img.url.endsWith('.mp4') || img.url.endsWith('.webm'); + if (isVideo) return; + + const url = getShowcaseUrl(img.url, 'image'); + if (prefetchedUrls.has(url)) return; + prefetchedUrls.add(url); + + // Off-DOM image: fills the HTTP/memory cache without affecting layout. + // Low priority keeps it from competing with the active media's load. + const preloader = new Image(); + preloader.fetchPriority = 'low'; + preloader.src = url; + }); +} + /** * Switch the main viewer to another example (wraps around) * @param {number} index - Target index in galleryState.images @@ -470,6 +504,7 @@ export function updateMainDisplay(index) { }); initMainMediaInteractions(container); + prefetchAdjacentMedia(); } /** @@ -683,6 +718,9 @@ export function initShowcaseContent(gallery) { const container = gallery.querySelector('.main-media-container'); if (container && galleryState.expanded) { initMainMediaInteractions(container); + // Gallery just (re)rendered expanded: warm the cache for the + // examples adjacent to the active one + prefetchAdjacentMedia(); } // Reposition controls on window resize diff --git a/static/js/utils/civitaiUtils.js b/static/js/utils/civitaiUtils.js index a5272eeb..4007aad0 100644 --- a/static/js/utils/civitaiUtils.js +++ b/static/js/utils/civitaiUtils.js @@ -11,6 +11,8 @@ export const OptimizationMode = { SHOWCASE: 'showcase', /** Thumbnail size for cards - uses /width=450,optimized=true */ THUMBNAIL: 'thumbnail', + /** Small thumbnails for the showcase gallery strip (72px display) - uses /width=160,optimized=true */ + GALLERY_THUMBNAIL: 'gallery-thumbnail', }; export const DEFAULT_CIVITAI_PAGE_HOST = 'civitai.com'; @@ -100,10 +102,11 @@ export function rewriteCivitaiUrl(sourceUrl, mediaType = null, mode = Optimizati // Full quality for showcase - no width restriction replacement = '/optimized=true'; } else { - // Thumbnail mode with width restriction - replacement = '/width=450,optimized=true'; + // Thumbnail modes with width restriction + const width = mode === OptimizationMode.GALLERY_THUMBNAIL ? 160 : 450; + replacement = `/width=${width},optimized=true`; if (mediaType && mediaType.toLowerCase() === 'video') { - replacement = '/transcode=true,width=450,optimized=true'; + replacement = `/transcode=true,width=${width},optimized=true`; } } @@ -161,6 +164,17 @@ export function getThumbnailUrl(url, type = 'image') { return getOptimizedUrl(url, type, OptimizationMode.THUMBNAIL); } +/** + * Get gallery-strip-thumbnail-optimized URL (width=160, for the 72px strip) + * + * @param {string} url - Original URL + * @param {string} type - Media type ("image" or "video") + * @returns {string} - Optimized URL for gallery strip thumbnail display + */ +export function getGalleryThumbnailUrl(url, type = 'image') { + return getOptimizedUrl(url, type, OptimizationMode.GALLERY_THUMBNAIL); +} + /** * Check if a URL is from CivitAI * diff --git a/tests/frontend/components/showcase.gallery.test.js b/tests/frontend/components/showcase.gallery.test.js index 3f41eb3e..c1a3e41f 100644 --- a/tests/frontend/components/showcase.gallery.test.js +++ b/tests/frontend/components/showcase.gallery.test.js @@ -197,4 +197,64 @@ describe('Showcase gallery', () => { expect(document.querySelector('.gallery-indicator-bar')).toBeTruthy(); expect(document.querySelectorAll('.gallery-thumb')).toHaveLength(0); }); + + it('prefetches adjacent example images (skipping videos) while expanded', async () => { + const { renderShowcaseContent, initShowcaseContent, updateMainDisplay } = await import(SHOWCASE_MODULE); + + const prefetched = []; + class MockImage { + set src(value) { prefetched.push(value); } + set fetchPriority(_value) { /* jsdom lacks fetchPriority */ } + } + vi.stubGlobal('Image', MockImage); + + // Unique URLs: the module-level prefetch dedup set persists across tests + const images = [ + { url: 'https://image.civitai.com/pf/aaa.jpeg', width: 100, height: 100, nsfwLevel: 0 }, + { url: 'https://image.civitai.com/pf/bbb.jpeg', width: 100, height: 100, nsfwLevel: 0 }, + { url: 'https://image.civitai.com/pf/ccc.mp4', width: 100, height: 100, nsfwLevel: 0 }, + ]; + document.body.innerHTML = `
${renderShowcaseContent(images, [], PREVIEW_URL, true)}
`; + initShowcaseContent(document.querySelector('.showcase-gallery')); + + // galleryState.activeIndex persists across tests → pin it to 0 + updateMainDisplay(0); + + // Active index 0 → prefetches index 1; index 2 is a video and is skipped + expect(prefetched).toContain('https://image.civitai.com/pf/bbb.jpeg'); + expect(prefetched).not.toContain('https://image.civitai.com/pf/ccc.mp4'); + + // Navigating to 1 prefetches the new neighbor (index 0) + updateMainDisplay(1); + expect(prefetched).toContain('https://image.civitai.com/pf/aaa.jpeg'); + + // Navigating back does not duplicate prefetch requests + const count = prefetched.length; + updateMainDisplay(0); + expect(prefetched).toHaveLength(count); + + vi.unstubAllGlobals(); + }); + + it('does not prefetch while collapsed', async () => { + const { renderShowcaseContent, initShowcaseContent } = await import(SHOWCASE_MODULE); + + const prefetched = []; + class MockImage { + set src(value) { prefetched.push(value); } + set fetchPriority(_value) { /* jsdom lacks fetchPriority */ } + } + vi.stubGlobal('Image', MockImage); + + const images = [ + { url: 'https://image.civitai.com/pc/ddd.jpeg', width: 100, height: 100, nsfwLevel: 0 }, + { url: 'https://image.civitai.com/pc/eee.jpeg', width: 100, height: 100, nsfwLevel: 0 }, + ]; + document.body.innerHTML = `
${renderShowcaseContent(images, [], PREVIEW_URL)}
`; + initShowcaseContent(document.querySelector('.showcase-gallery')); + + expect(prefetched).toHaveLength(0); + + vi.unstubAllGlobals(); + }); }); diff --git a/tests/frontend/utils/civitaiUtils.test.js b/tests/frontend/utils/civitaiUtils.test.js index d1e99d53..12b81c88 100644 --- a/tests/frontend/utils/civitaiUtils.test.js +++ b/tests/frontend/utils/civitaiUtils.test.js @@ -9,6 +9,7 @@ import { getOptimizedUrl, getShowcaseUrl, getThumbnailUrl, + getGalleryThumbnailUrl, extractCivitaiImageId, extractCivitaiModelUrlParts, classifyModelRelinkUrl, @@ -22,6 +23,7 @@ describe('civitaiUtils', () => { it('should have correct mode values', () => { expect(OptimizationMode.SHOWCASE).toBe('showcase'); expect(OptimizationMode.THUMBNAIL).toBe('thumbnail'); + expect(OptimizationMode.GALLERY_THUMBNAIL).toBe('gallery-thumbnail'); }); }); @@ -107,6 +109,22 @@ describe('civitaiUtils', () => { expect(rewritten).toBe('https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/abc123/width=450,optimized=true/12345.jpeg'); }); + it('should rewrite image URLs with /original=true for gallery-thumbnail mode (width=160)', () => { + const originalUrl = 'https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/abc123/original=true/12345.jpeg'; + const [rewritten, wasRewritten] = rewriteCivitaiUrl(originalUrl, 'image', OptimizationMode.GALLERY_THUMBNAIL); + + expect(wasRewritten).toBe(true); + expect(rewritten).toBe('https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/abc123/width=160,optimized=true/12345.jpeg'); + }); + + it('should rewrite video URLs with /original=true for gallery-thumbnail mode', () => { + const originalUrl = 'https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/abc123/original=true/12345.mp4'; + const [rewritten, wasRewritten] = rewriteCivitaiUrl(originalUrl, 'video', OptimizationMode.GALLERY_THUMBNAIL); + + expect(wasRewritten).toBe(true); + expect(rewritten).toBe('https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/abc123/transcode=true,width=160,optimized=true/12345.mp4'); + }); + it('should not rewrite URLs without /original=true', () => { const originalUrl = 'https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/abc123/width=450/12345.jpeg'; const [rewritten, wasRewritten] = rewriteCivitaiUrl(originalUrl, 'image', OptimizationMode.THUMBNAIL); @@ -232,6 +250,22 @@ describe('civitaiUtils', () => { }); }); + describe('getGalleryThumbnailUrl', () => { + it('should return gallery-thumbnail-optimized URL (width=160)', () => { + const originalUrl = 'https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/abc123/original=true/12345.jpeg'; + const thumbnailUrl = getGalleryThumbnailUrl(originalUrl, 'image'); + + expect(thumbnailUrl).toBe('https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/abc123/width=160,optimized=true/12345.jpeg'); + }); + + it('should handle videos for gallery thumbnails', () => { + const originalUrl = 'https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/abc123/original=true/12345.mp4'; + const thumbnailUrl = getGalleryThumbnailUrl(originalUrl, 'video'); + + expect(thumbnailUrl).toBe('https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/abc123/transcode=true,width=160,optimized=true/12345.mp4'); + }); + }); + describe('isCivitaiUrl', () => { it('should return true for CivitAI URLs', () => { expect(isCivitaiUrl('https://image.civitai.com/something')).toBe(true);