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:
pixelpaws
2025-10-06 08:07:24 +08:00
committed by GitHub
2 changed files with 235 additions and 7 deletions

View File

@@ -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);
};

View 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);
});
});