mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-25 07:05:43 -03:00
fix(model-card): throttle preview video loading
This commit is contained in:
@@ -531,19 +531,104 @@ export function createModelCard(model, modelType) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const VIDEO_LAZY_ROOT_MARGIN = '200px 0px';
|
const VIDEO_LAZY_ROOT_MARGIN = '200px 0px';
|
||||||
|
const VIDEO_LOAD_INTERVAL_MS = 120;
|
||||||
|
const VIDEO_LOAD_MAX_CONCURRENCY = 2;
|
||||||
let videoLazyObserver = null;
|
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() {
|
function ensureVideoLazyObserver() {
|
||||||
if (videoLazyObserver) {
|
if (videoLazyObserver) {
|
||||||
return videoLazyObserver;
|
return videoLazyObserver;
|
||||||
}
|
}
|
||||||
|
|
||||||
videoLazyObserver = new IntersectionObserver((entries, observer) => {
|
videoLazyObserver = new IntersectionObserver((entries) => {
|
||||||
entries.forEach(entry => {
|
entries.forEach(entry => {
|
||||||
if (entry.isIntersecting) {
|
if (entry.isIntersecting) {
|
||||||
const target = entry.target;
|
enqueueVideoElement(entry.target);
|
||||||
observer.unobserve(target);
|
|
||||||
loadVideoSource(target);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, {
|
}, {
|
||||||
@@ -576,15 +661,27 @@ function requestSafePlay(videoElement) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function loadVideoSource(videoElement) {
|
function loadVideoSource(videoElement) {
|
||||||
if (!videoElement || videoElement.dataset.loaded === 'true') {
|
if (!videoElement) {
|
||||||
return;
|
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 sourceElement = videoElement.querySelector('source');
|
||||||
const dataSrc = videoElement.dataset.src || sourceElement?.dataset?.src;
|
const dataSrc = videoElement.dataset.src || sourceElement?.dataset?.src;
|
||||||
|
|
||||||
if (!dataSrc) {
|
if (!dataSrc) {
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure src attributes are reset before applying
|
// Ensure src attributes are reset before applying
|
||||||
@@ -602,11 +699,14 @@ function loadVideoSource(videoElement) {
|
|||||||
videoElement.setAttribute('autoplay', '');
|
videoElement.setAttribute('autoplay', '');
|
||||||
requestSafePlay(videoElement);
|
requestSafePlay(videoElement);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function configureModelCardVideo(videoElement, autoplayOnHover) {
|
export function configureModelCardVideo(videoElement, autoplayOnHover) {
|
||||||
if (!videoElement) return;
|
if (!videoElement) return;
|
||||||
|
|
||||||
|
dequeueVideoElement(videoElement);
|
||||||
cleanupHoverHandlers(videoElement);
|
cleanupHoverHandlers(videoElement);
|
||||||
|
|
||||||
const sourceElement = videoElement.querySelector('source');
|
const sourceElement = videoElement.querySelector('source');
|
||||||
@@ -628,6 +728,7 @@ export function configureModelCardVideo(videoElement, autoplayOnHover) {
|
|||||||
videoElement.setAttribute('playsinline', '');
|
videoElement.setAttribute('playsinline', '');
|
||||||
videoElement.setAttribute('controls', '');
|
videoElement.setAttribute('controls', '');
|
||||||
videoElement.dataset.loaded = 'false';
|
videoElement.dataset.loaded = 'false';
|
||||||
|
delete videoElement.dataset.loading;
|
||||||
|
|
||||||
if (sourceElement) {
|
if (sourceElement) {
|
||||||
sourceElement.removeAttribute('src');
|
sourceElement.removeAttribute('src');
|
||||||
@@ -656,6 +757,7 @@ export function configureModelCardVideo(videoElement, autoplayOnHover) {
|
|||||||
const cardPreview = videoElement.closest('.card-preview');
|
const cardPreview = videoElement.closest('.card-preview');
|
||||||
if (cardPreview) {
|
if (cardPreview) {
|
||||||
const mouseEnter = () => {
|
const mouseEnter = () => {
|
||||||
|
dequeueVideoElement(videoElement);
|
||||||
loadVideoSource(videoElement);
|
loadVideoSource(videoElement);
|
||||||
requestSafePlay(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