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
This commit is contained in:
Will Miao
2026-09-01 22:26:50 +08:00
parent 9584fa85c9
commit ed2a17970f
5 changed files with 153 additions and 6 deletions
@@ -76,6 +76,7 @@ export function generateImageWrapper(media, shouldBlur, nsfwText, metadataPanel,
alt="Preview" alt="Preview"
width="${media.width}" width="${media.width}"
height="${media.height}" height="${media.height}"
fetchpriority="high"
class="lazy ${shouldBlur ? 'blurred' : ''}"> class="lazy ${shouldBlur ? 'blurred' : ''}">
${shouldBlur ? ` ${shouldBlur ? `
<div class="nsfw-overlay"> <div class="nsfw-overlay">
@@ -22,7 +22,7 @@ import {
} from './MediaUtils.js'; } from './MediaUtils.js';
import { generateMetadataPanel } from './MetadataPanel.js'; import { generateMetadataPanel } from './MetadataPanel.js';
import { generateImageWrapper, generateVideoWrapper } from './MediaRenderers.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 { openMediaViewer } from '../MediaViewer.js';
import { escapeAttribute } from '../utils.js'; import { escapeAttribute } from '../utils.js';
@@ -275,7 +275,7 @@ function renderThumbnail(img, index, exampleFiles) {
originalRemoteUrl.endsWith('.mp4') || originalRemoteUrl.endsWith('.webm'); originalRemoteUrl.endsWith('.mp4') || originalRemoteUrl.endsWith('.webm');
const mediaType = isVideo ? 'video' : 'image'; 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 nsfwLevel = img.nsfwLevel !== undefined ? img.nsfwLevel : 0;
const matureBlurThreshold = getMatureBlurThreshold(state.settings); const matureBlurThreshold = getMatureBlurThreshold(state.settings);
@@ -286,7 +286,7 @@ function renderThumbnail(img, index, exampleFiles) {
const mediaHtml = isVideo ? const mediaHtml = isVideo ?
`<video class="thumb-media${blurClass}" src="${escapeAttribute(thumbUrl)}" muted playsinline preload="metadata"></video> `<video class="thumb-media${blurClass}" src="${escapeAttribute(thumbUrl)}" muted playsinline preload="metadata"></video>
<i class="fas fa-play thumb-video-badge"></i>` : <i class="fas fa-play thumb-video-badge"></i>` :
`<img class="thumb-media${blurClass}" src="${escapeAttribute(thumbUrl)}" loading="lazy" alt="">`; `<img class="thumb-media${blurClass}" src="${escapeAttribute(thumbUrl)}" loading="lazy" fetchpriority="low" alt="">`;
const nsfwBadge = shouldBlur ? '<i class="fas fa-eye-slash thumb-nsfw-badge"></i>' : ''; const nsfwBadge = shouldBlur ? '<i class="fas fa-eye-slash thumb-nsfw-badge"></i>' : '';
return `<button class="gallery-thumb${activeClass}" data-index="${index}">${mediaHtml}${nsfwBadge}</button>`; return `<button class="gallery-thumb${activeClass}" data-index="${index}">${mediaHtml}${nsfwBadge}</button>`;
@@ -438,6 +438,40 @@ function findLocalFile(img, index, exampleFiles) {
return localFile; 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) * Switch the main viewer to another example (wraps around)
* @param {number} index - Target index in galleryState.images * @param {number} index - Target index in galleryState.images
@@ -470,6 +504,7 @@ export function updateMainDisplay(index) {
}); });
initMainMediaInteractions(container); initMainMediaInteractions(container);
prefetchAdjacentMedia();
} }
/** /**
@@ -683,6 +718,9 @@ export function initShowcaseContent(gallery) {
const container = gallery.querySelector('.main-media-container'); const container = gallery.querySelector('.main-media-container');
if (container && galleryState.expanded) { if (container && galleryState.expanded) {
initMainMediaInteractions(container); initMainMediaInteractions(container);
// Gallery just (re)rendered expanded: warm the cache for the
// examples adjacent to the active one
prefetchAdjacentMedia();
} }
// Reposition controls on window resize // Reposition controls on window resize
+17 -3
View File
@@ -11,6 +11,8 @@ export const OptimizationMode = {
SHOWCASE: 'showcase', SHOWCASE: 'showcase',
/** Thumbnail size for cards - uses /width=450,optimized=true */ /** Thumbnail size for cards - uses /width=450,optimized=true */
THUMBNAIL: 'thumbnail', 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'; 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 // Full quality for showcase - no width restriction
replacement = '/optimized=true'; replacement = '/optimized=true';
} else { } else {
// Thumbnail mode with width restriction // Thumbnail modes with width restriction
replacement = '/width=450,optimized=true'; const width = mode === OptimizationMode.GALLERY_THUMBNAIL ? 160 : 450;
replacement = `/width=${width},optimized=true`;
if (mediaType && mediaType.toLowerCase() === 'video') { 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); 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 * Check if a URL is from CivitAI
* *
@@ -197,4 +197,64 @@ describe('Showcase gallery', () => {
expect(document.querySelector('.gallery-indicator-bar')).toBeTruthy(); expect(document.querySelector('.gallery-indicator-bar')).toBeTruthy();
expect(document.querySelectorAll('.gallery-thumb')).toHaveLength(0); 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 = `<div id="showcase-tab">${renderShowcaseContent(images, [], PREVIEW_URL, true)}</div>`;
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 = `<div id="showcase-tab">${renderShowcaseContent(images, [], PREVIEW_URL)}</div>`;
initShowcaseContent(document.querySelector('.showcase-gallery'));
expect(prefetched).toHaveLength(0);
vi.unstubAllGlobals();
});
}); });
+34
View File
@@ -9,6 +9,7 @@ import {
getOptimizedUrl, getOptimizedUrl,
getShowcaseUrl, getShowcaseUrl,
getThumbnailUrl, getThumbnailUrl,
getGalleryThumbnailUrl,
extractCivitaiImageId, extractCivitaiImageId,
extractCivitaiModelUrlParts, extractCivitaiModelUrlParts,
classifyModelRelinkUrl, classifyModelRelinkUrl,
@@ -22,6 +23,7 @@ describe('civitaiUtils', () => {
it('should have correct mode values', () => { it('should have correct mode values', () => {
expect(OptimizationMode.SHOWCASE).toBe('showcase'); expect(OptimizationMode.SHOWCASE).toBe('showcase');
expect(OptimizationMode.THUMBNAIL).toBe('thumbnail'); 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'); 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', () => { it('should not rewrite URLs without /original=true', () => {
const originalUrl = 'https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/abc123/width=450/12345.jpeg'; const originalUrl = 'https://image.civitai.com/xG1nkqKTMzGDvpLrqFT7WA/abc123/width=450/12345.jpeg';
const [rewritten, wasRewritten] = rewriteCivitaiUrl(originalUrl, 'image', OptimizationMode.THUMBNAIL); 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', () => { describe('isCivitaiUrl', () => {
it('should return true for CivitAI URLs', () => { it('should return true for CivitAI URLs', () => {
expect(isCivitaiUrl('https://image.civitai.com/something')).toBe(true); expect(isCivitaiUrl('https://image.civitai.com/something')).toBe(true);