mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-21 21:22:11 -03:00
Merge pull request #537 from willmiao/codex/implement-video-lazy-loading-with-queue
fix: throttle model card video lazy loading
This commit is contained in:
@@ -531,19 +531,104 @@ export function createModelCard(model, modelType) {
|
||||
}
|
||||
|
||||
const VIDEO_LAZY_ROOT_MARGIN = '200px 0px';
|
||||
const VIDEO_LOAD_INTERVAL_MS = 120;
|
||||
const VIDEO_LOAD_MAX_CONCURRENCY = 2;
|
||||
let videoLazyObserver = null;
|
||||
|
||||
const videoLoadQueue = [];
|
||||
const queuedVideoElements = new Set();
|
||||
let activeVideoLoads = 0;
|
||||
let queueTimer = null;
|
||||
|
||||
const scheduleFrame = typeof requestAnimationFrame === 'function'
|
||||
? requestAnimationFrame
|
||||
: (callback) => setTimeout(callback, 16);
|
||||
|
||||
function scheduleVideoQueueProcessing(delay = 0) {
|
||||
if (queueTimer !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
queueTimer = setTimeout(() => {
|
||||
queueTimer = null;
|
||||
processVideoLoadQueue();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
function dequeueVideoElement(videoElement) {
|
||||
if (!queuedVideoElements.has(videoElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
queuedVideoElements.delete(videoElement);
|
||||
const index = videoLoadQueue.indexOf(videoElement);
|
||||
if (index !== -1) {
|
||||
videoLoadQueue.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
function processVideoLoadQueue() {
|
||||
if (videoLoadQueue.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
while (activeVideoLoads < VIDEO_LOAD_MAX_CONCURRENCY && videoLoadQueue.length > 0) {
|
||||
const videoElement = videoLoadQueue.shift();
|
||||
queuedVideoElements.delete(videoElement);
|
||||
|
||||
if (!videoElement || !videoElement.isConnected || videoElement.dataset.loaded === 'true') {
|
||||
continue;
|
||||
}
|
||||
|
||||
activeVideoLoads++;
|
||||
videoElement.dataset.loading = 'true';
|
||||
|
||||
scheduleFrame(() => {
|
||||
try {
|
||||
loadVideoSource(videoElement);
|
||||
} finally {
|
||||
delete videoElement.dataset.loading;
|
||||
activeVideoLoads--;
|
||||
|
||||
if (videoLoadQueue.length > 0) {
|
||||
scheduleVideoQueueProcessing(VIDEO_LOAD_INTERVAL_MS);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (videoLoadQueue.length > 0 && queueTimer === null) {
|
||||
scheduleVideoQueueProcessing(VIDEO_LOAD_INTERVAL_MS);
|
||||
}
|
||||
}
|
||||
|
||||
function enqueueVideoElement(videoElement) {
|
||||
if (!videoElement || videoElement.dataset.loaded === 'true' || videoElement.dataset.loading === 'true') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!videoElement.isConnected) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (queuedVideoElements.has(videoElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
queuedVideoElements.add(videoElement);
|
||||
videoLoadQueue.push(videoElement);
|
||||
scheduleVideoQueueProcessing();
|
||||
}
|
||||
|
||||
function ensureVideoLazyObserver() {
|
||||
if (videoLazyObserver) {
|
||||
return videoLazyObserver;
|
||||
}
|
||||
|
||||
videoLazyObserver = new IntersectionObserver((entries, observer) => {
|
||||
videoLazyObserver = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
const target = entry.target;
|
||||
observer.unobserve(target);
|
||||
loadVideoSource(target);
|
||||
enqueueVideoElement(entry.target);
|
||||
}
|
||||
});
|
||||
}, {
|
||||
@@ -576,15 +661,27 @@ function requestSafePlay(videoElement) {
|
||||
}
|
||||
|
||||
function loadVideoSource(videoElement) {
|
||||
if (!videoElement || videoElement.dataset.loaded === 'true') {
|
||||
return;
|
||||
if (!videoElement) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (videoLazyObserver) {
|
||||
try {
|
||||
videoLazyObserver.unobserve(videoElement);
|
||||
} catch (error) {
|
||||
// Ignore observer errors (e.g., element already unobserved)
|
||||
}
|
||||
}
|
||||
|
||||
if (videoElement.dataset.loaded === 'true' || !videoElement.isConnected) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const sourceElement = videoElement.querySelector('source');
|
||||
const dataSrc = videoElement.dataset.src || sourceElement?.dataset?.src;
|
||||
|
||||
if (!dataSrc) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Ensure src attributes are reset before applying
|
||||
@@ -602,11 +699,14 @@ function loadVideoSource(videoElement) {
|
||||
videoElement.setAttribute('autoplay', '');
|
||||
requestSafePlay(videoElement);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function configureModelCardVideo(videoElement, autoplayOnHover) {
|
||||
if (!videoElement) return;
|
||||
|
||||
dequeueVideoElement(videoElement);
|
||||
cleanupHoverHandlers(videoElement);
|
||||
|
||||
const sourceElement = videoElement.querySelector('source');
|
||||
@@ -628,6 +728,7 @@ export function configureModelCardVideo(videoElement, autoplayOnHover) {
|
||||
videoElement.setAttribute('playsinline', '');
|
||||
videoElement.setAttribute('controls', '');
|
||||
videoElement.dataset.loaded = 'false';
|
||||
delete videoElement.dataset.loading;
|
||||
|
||||
if (sourceElement) {
|
||||
sourceElement.removeAttribute('src');
|
||||
@@ -656,6 +757,7 @@ export function configureModelCardVideo(videoElement, autoplayOnHover) {
|
||||
const cardPreview = videoElement.closest('.card-preview');
|
||||
if (cardPreview) {
|
||||
const mouseEnter = () => {
|
||||
dequeueVideoElement(videoElement);
|
||||
loadVideoSource(videoElement);
|
||||
requestSafePlay(videoElement);
|
||||
};
|
||||
|
||||
126
tests/frontend/components/modelCard.videoQueue.test.js
Normal file
126
tests/frontend/components/modelCard.videoQueue.test.js
Normal file
@@ -0,0 +1,126 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
// Ensure globals are defined before importing module under test
|
||||
const ORIGINAL_REQUEST_ANIMATION_FRAME = global.requestAnimationFrame;
|
||||
|
||||
class MockIntersectionObserver {
|
||||
static instances = [];
|
||||
|
||||
constructor(callback, options) {
|
||||
this.callback = callback;
|
||||
this.options = options;
|
||||
this.observed = new Set();
|
||||
MockIntersectionObserver.instances.push(this);
|
||||
}
|
||||
|
||||
observe(element) {
|
||||
this.observed.add(element);
|
||||
}
|
||||
|
||||
unobserve(element) {
|
||||
this.observed.delete(element);
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.observed.clear();
|
||||
}
|
||||
|
||||
trigger(entries) {
|
||||
this.callback(entries, this);
|
||||
}
|
||||
}
|
||||
|
||||
describe('ModelCard video lazy loading queue', () => {
|
||||
let configureModelCardVideo;
|
||||
let loadSpy;
|
||||
let pauseSpy;
|
||||
let playSpy;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(0);
|
||||
|
||||
MockIntersectionObserver.instances = [];
|
||||
global.IntersectionObserver = MockIntersectionObserver;
|
||||
global.requestAnimationFrame = (callback) => setTimeout(callback, 0);
|
||||
|
||||
({ configureModelCardVideo } = await import('../../../static/js/components/shared/ModelCard.js'));
|
||||
|
||||
loadSpy = vi.spyOn(HTMLMediaElement.prototype, 'load').mockImplementation(function () {
|
||||
this.dataset.loadCalls = `${parseInt(this.dataset.loadCalls || '0', 10) + 1}`;
|
||||
this.dataset.loadCallTime = `${Date.now()}`;
|
||||
});
|
||||
|
||||
pauseSpy = vi.spyOn(HTMLMediaElement.prototype, 'pause').mockImplementation(() => {});
|
||||
playSpy = vi.spyOn(HTMLMediaElement.prototype, 'play').mockImplementation(() => Promise.resolve());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
loadSpy.mockRestore();
|
||||
pauseSpy.mockRestore();
|
||||
playSpy.mockRestore();
|
||||
vi.useRealTimers();
|
||||
delete global.IntersectionObserver;
|
||||
if (ORIGINAL_REQUEST_ANIMATION_FRAME) {
|
||||
global.requestAnimationFrame = ORIGINAL_REQUEST_ANIMATION_FRAME;
|
||||
} else {
|
||||
delete global.requestAnimationFrame;
|
||||
}
|
||||
});
|
||||
|
||||
it('throttles large batches of intersecting videos', async () => {
|
||||
const container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
|
||||
const videoCount = 10;
|
||||
const videos = [];
|
||||
|
||||
for (let index = 0; index < videoCount; index += 1) {
|
||||
const preview = document.createElement('div');
|
||||
preview.className = 'card-preview';
|
||||
|
||||
const video = document.createElement('video');
|
||||
video.dataset.src = `video-${index}.mp4`;
|
||||
|
||||
const source = document.createElement('source');
|
||||
source.dataset.src = `video-${index}.mp4`;
|
||||
|
||||
video.appendChild(source);
|
||||
preview.appendChild(video);
|
||||
container.appendChild(preview);
|
||||
|
||||
configureModelCardVideo(video, false);
|
||||
videos.push(video);
|
||||
}
|
||||
|
||||
const observer = MockIntersectionObserver.instances.at(-1);
|
||||
observer.trigger(videos.map((video) => ({ target: video, isIntersecting: true })));
|
||||
|
||||
// Drain any immediate timers for the initial batch
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
|
||||
// Advance timers to drain remaining batches at the paced interval
|
||||
while (videos.some((video) => video.dataset.loaded !== 'true')) {
|
||||
await vi.advanceTimersByTimeAsync(120);
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
}
|
||||
|
||||
const allLoaded = videos.every((video) => video.dataset.loaded === 'true');
|
||||
expect(allLoaded).toBe(true);
|
||||
|
||||
const loadTimes = videos.map((video) => Number.parseInt(video.dataset.loadCallTime || '0', 10));
|
||||
const uniqueIntervals = new Set(loadTimes);
|
||||
expect(uniqueIntervals.size).toBeGreaterThan(1);
|
||||
|
||||
const loadsPerInterval = loadTimes.reduce((accumulator, time) => {
|
||||
const nextAccumulator = accumulator;
|
||||
nextAccumulator[time] = (nextAccumulator[time] || 0) + 1;
|
||||
return nextAccumulator;
|
||||
}, {});
|
||||
|
||||
const maxLoadsInInterval = Math.max(...Object.values(loadsPerInterval));
|
||||
expect(maxLoadsInInterval).toBeLessThanOrEqual(2);
|
||||
|
||||
document.body.removeChild(container);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user