mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-20 18:51:26 -03:00
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:
@@ -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 ? `
|
||||
<div class="nsfw-overlay">
|
||||
|
||||
@@ -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 ?
|
||||
`<video class="thumb-media${blurClass}" src="${escapeAttribute(thumbUrl)}" muted playsinline preload="metadata"></video>
|
||||
<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>' : '';
|
||||
|
||||
return `<button class="gallery-thumb${activeClass}" data-index="${index}">${mediaHtml}${nsfwBadge}</button>`;
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
*
|
||||
|
||||
@@ -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 = `<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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user