From 5e53d76f44e38f26c55d822222cb9f9ee8c6eef9 Mon Sep 17 00:00:00 2001 From: pixelpaws Date: Mon, 6 Oct 2025 07:45:51 +0800 Subject: [PATCH] fix(model-card): throttle preview video loading --- static/js/components/shared/ModelCard.js | 116 +++++++++++++++- .../components/modelCard.videoQueue.test.js | 126 ++++++++++++++++++ 2 files changed, 235 insertions(+), 7 deletions(-) create mode 100644 tests/frontend/components/modelCard.videoQueue.test.js diff --git a/static/js/components/shared/ModelCard.js b/static/js/components/shared/ModelCard.js index cb972167..06bf7a14 100644 --- a/static/js/components/shared/ModelCard.js +++ b/static/js/components/shared/ModelCard.js @@ -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); }; diff --git a/tests/frontend/components/modelCard.videoQueue.test.js b/tests/frontend/components/modelCard.videoQueue.test.js new file mode 100644 index 00000000..af9b5ee4 --- /dev/null +++ b/tests/frontend/components/modelCard.videoQueue.test.js @@ -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); + }); +});