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);