mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-21 03:01:27 -03:00
perf(showcase): direction-aware prefetch and lazy video thumbnails
- Track last navigation direction and prefetch one extra example ahead along it, so repeated prev/next clicks stay cache-hot - Start strip video thumbnails at preload=none and enable metadata loading only when they scroll into view
This commit is contained in:
@@ -284,7 +284,7 @@ function renderThumbnail(img, index, exampleFiles) {
|
|||||||
const activeClass = index === galleryState.activeIndex ? ' active' : '';
|
const activeClass = index === galleryState.activeIndex ? ' active' : '';
|
||||||
const blurClass = shouldBlur ? ' blurred' : '';
|
const blurClass = shouldBlur ? ' blurred' : '';
|
||||||
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="none" data-lazy-video></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" fetchpriority="low" 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>' : '';
|
||||||
@@ -442,18 +442,24 @@ function findLocalFile(img, index, exampleFiles) {
|
|||||||
// never issue duplicate prefetch requests
|
// never issue duplicate prefetch requests
|
||||||
const prefetchedUrls = new Set();
|
const prefetchedUrls = new Set();
|
||||||
|
|
||||||
|
// Direction of the last main-viewer navigation (+1 next / -1 prev); users
|
||||||
|
// tend to keep clicking the same arrow, so prefetch reaches one further
|
||||||
|
// ahead along it. Defaults to forward (Next is the most common navigation)
|
||||||
|
let lastNavDirection = 1;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Warm the HTTP cache for the examples most likely to be shown next (the
|
* Warm the HTTP cache for the examples most likely to be shown next: both
|
||||||
* indices adjacent to the active one), so prev/next navigation feels instant.
|
* indices adjacent to the active one, plus one extra ahead along the last
|
||||||
* Images only: video payloads are too heavy for speculative prefetch, and
|
* navigation direction, so prev/next navigation feels instant. Images only:
|
||||||
* locally stored examples need no network fetch at all.
|
* video payloads are too heavy for speculative prefetch, and locally stored
|
||||||
|
* examples need no network fetch at all.
|
||||||
*/
|
*/
|
||||||
function prefetchAdjacentMedia() {
|
function prefetchAdjacentMedia() {
|
||||||
const { images, exampleFiles, activeIndex, expanded } = galleryState;
|
const { images, exampleFiles, activeIndex, expanded } = galleryState;
|
||||||
if (!expanded || images.length < 2) return;
|
if (!expanded || images.length < 2) return;
|
||||||
|
|
||||||
[activeIndex + 1, activeIndex - 1].forEach(i => {
|
[1, -1, lastNavDirection * 2].forEach(offset => {
|
||||||
const index = ((i % images.length) + images.length) % images.length;
|
const index = ((activeIndex + offset) % images.length + images.length) % images.length;
|
||||||
const img = images[index];
|
const img = images[index];
|
||||||
if (!img?.url || findLocalFile(img, index, exampleFiles)) return;
|
if (!img?.url || findLocalFile(img, index, exampleFiles)) return;
|
||||||
|
|
||||||
@@ -480,6 +486,11 @@ export function updateMainDisplay(index) {
|
|||||||
const count = galleryState.images.length;
|
const count = galleryState.images.length;
|
||||||
if (!count || !galleryState.expanded) return;
|
if (!count || !galleryState.expanded) return;
|
||||||
|
|
||||||
|
// Remember the navigation direction for direction-aware prefetching
|
||||||
|
// (a raw index of -1 / count means wrap-around prev / next)
|
||||||
|
const delta = index - galleryState.activeIndex;
|
||||||
|
if (delta !== 0) lastNavDirection = delta > 0 ? 1 : -1;
|
||||||
|
|
||||||
galleryState.activeIndex = ((index % count) + count) % count;
|
galleryState.activeIndex = ((index % count) + count) % count;
|
||||||
|
|
||||||
const container = document.getElementById('mainMediaContainer');
|
const container = document.getElementById('mainMediaContainer');
|
||||||
@@ -659,6 +670,40 @@ function setupScrollToExpand(gallery) {
|
|||||||
}, { passive: true });
|
}, { passive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defer metadata fetches for video thumbnails until they scroll into view:
|
||||||
|
* with preload="metadata" on every strip video, expanding the gallery would
|
||||||
|
* otherwise hit the network for all of them at once
|
||||||
|
* @param {HTMLElement} gallery - The .showcase-gallery element
|
||||||
|
*/
|
||||||
|
function initStripVideoLazyLoading(gallery) {
|
||||||
|
const videos = gallery.querySelectorAll('.gallery-strip video[data-lazy-video]');
|
||||||
|
if (!videos.length) return;
|
||||||
|
|
||||||
|
const enable = (video) => {
|
||||||
|
video.preload = 'metadata';
|
||||||
|
video.load();
|
||||||
|
video.removeAttribute('data-lazy-video');
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof IntersectionObserver === 'undefined') {
|
||||||
|
videos.forEach(enable);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No explicit root: intersection accounts for the strip's overflow
|
||||||
|
// clipping, so off-screen thumbnails stay at preload="none"
|
||||||
|
const observer = new IntersectionObserver((entries) => {
|
||||||
|
entries.forEach(entry => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
enable(entry.target);
|
||||||
|
observer.unobserve(entry.target);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
videos.forEach(video => observer.observe(video));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize all gallery interactions
|
* Initialize all gallery interactions
|
||||||
* @param {HTMLElement} gallery - The .showcase-gallery element
|
* @param {HTMLElement} gallery - The .showcase-gallery element
|
||||||
@@ -721,6 +766,8 @@ export function initShowcaseContent(gallery) {
|
|||||||
// Gallery just (re)rendered expanded: warm the cache for the
|
// Gallery just (re)rendered expanded: warm the cache for the
|
||||||
// examples adjacent to the active one
|
// examples adjacent to the active one
|
||||||
prefetchAdjacentMedia();
|
prefetchAdjacentMedia();
|
||||||
|
// Video thumbnails start at preload="none"; enable them on visibility
|
||||||
|
initStripVideoLazyLoading(gallery);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reposition controls on window resize
|
// Reposition controls on window resize
|
||||||
|
|||||||
@@ -257,4 +257,58 @@ describe('Showcase gallery', () => {
|
|||||||
|
|
||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('prefetches one extra example ahead along the navigation direction', 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 = [0, 1, 2, 3, 4].map(i => ({
|
||||||
|
url: `https://image.civitai.com/pd/${i}.jpeg`, width: 100, height: 100, nsfwLevel: 0,
|
||||||
|
}));
|
||||||
|
document.body.innerHTML = `<div id="showcase-tab">${renderShowcaseContent(images, [], PREVIEW_URL, true)}</div>`;
|
||||||
|
initShowcaseContent(document.querySelector('.showcase-gallery'));
|
||||||
|
|
||||||
|
// Pin position, then step forward: prefetch reaches +2 ahead (index 3)
|
||||||
|
updateMainDisplay(0);
|
||||||
|
updateMainDisplay(1);
|
||||||
|
expect(prefetched).toContain('https://image.civitai.com/pd/2.jpeg');
|
||||||
|
expect(prefetched).toContain('https://image.civitai.com/pd/3.jpeg');
|
||||||
|
|
||||||
|
// Step backward: prefetch reaches -2 ahead (index 4 wrapping around)
|
||||||
|
updateMainDisplay(0);
|
||||||
|
expect(prefetched).toContain('https://image.civitai.com/pd/4.jpeg');
|
||||||
|
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defers video thumbnail metadata fetches until the strip shows them', async () => {
|
||||||
|
const { renderShowcaseContent, initShowcaseContent } = await import(SHOWCASE_MODULE);
|
||||||
|
|
||||||
|
const images = [
|
||||||
|
{ url: 'https://image.civitai.com/lv/fff.jpeg', width: 100, height: 100, nsfwLevel: 0 },
|
||||||
|
{ url: 'https://image.civitai.com/lv/ggg.mp4', width: 100, height: 100, nsfwLevel: 0 },
|
||||||
|
];
|
||||||
|
document.body.innerHTML = `<div id="showcase-tab">${renderShowcaseContent(images, [], PREVIEW_URL, true)}</div>`;
|
||||||
|
|
||||||
|
const video = document.querySelector('.gallery-strip video');
|
||||||
|
expect(video?.getAttribute('preload')).toBe('none');
|
||||||
|
expect(video?.hasAttribute('data-lazy-video')).toBe(true);
|
||||||
|
|
||||||
|
// jsdom's HTMLMediaElement.load() is a not-implemented stub that logs
|
||||||
|
const loadSpy = vi.spyOn(HTMLMediaElement.prototype, 'load').mockImplementation(() => {});
|
||||||
|
|
||||||
|
// jsdom has no IntersectionObserver → fallback enables everything at once
|
||||||
|
initShowcaseContent(document.querySelector('.showcase-gallery'));
|
||||||
|
expect(video.preload).toBe('metadata');
|
||||||
|
expect(video.hasAttribute('data-lazy-video')).toBe(false);
|
||||||
|
expect(loadSpy).toHaveBeenCalled();
|
||||||
|
loadSpy.mockRestore();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user