mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-21 03:01:27 -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"
|
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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user