From f967ed7125df709ac05bea0936e3d34faf5ca4d1 Mon Sep 17 00:00:00 2001 From: wyuc Date: Fri, 28 Aug 2026 09:46:34 -0400 Subject: [PATCH 01/11] perf(classroom): defer non-priority media blob hydration off the load path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Entering a classroom awaited the full mediaFiles restore — one object URL per restored image/video blob — before the loading gate opened. For video-heavy courses that is hundreds of MB of IndexedDB materialization blocking first paint. Split the restore into two phases: - The awaited phase now builds metadata-complete task entries but only creates object URLs for failed rows (none needed) and for media referenced by the scene the classroom opens on (persisted cursor, else the first scene). buildRestoredMediaTasks gains an optional shouldHydrateBlob predicate, defaulting to eager hydration so existing callers are unchanged. - Remaining blob-backed records hydrate in the background, chunked over requestIdleCallback (setTimeout fallback). Each deferred task stays 'done' — so generation resume never re-runs it — but carries no objectUrl yet, which the media resolution state machine already renders as a pending skeleton until the URL lands. Background hydration is guarded per record: a task that was replaced (classroom switch, regeneration, retry) or that belongs to another stage is skipped and its freshly minted URLs are revoked immediately. --- lib/classroom/load-classroom.ts | 176 ++++++++++++-- .../classroom/deferred-media-restore.test.ts | 228 ++++++++++++++++++ tests/classroom/load-classroom.test.ts | 24 +- 3 files changed, 400 insertions(+), 28 deletions(-) create mode 100644 tests/classroom/deferred-media-restore.test.ts diff --git a/lib/classroom/load-classroom.ts b/lib/classroom/load-classroom.ts index 72eabe1a90..dafc7094cf 100644 --- a/lib/classroom/load-classroom.ts +++ b/lib/classroom/load-classroom.ts @@ -18,11 +18,16 @@ import type { MediaFileRecord } from '@/lib/utils/database'; import { unmarkStageDeleted } from '@/lib/utils/deleted-stages'; import type { GeneratedAgentConfig, Scene, Stage } from '@/lib/types/stage'; import type { DocumentMigrationDeps } from '@/lib/document-store/migration'; -import type { PPTElement } from '@openmaic/dsl'; +import type { PPTElement, Slide } from '@openmaic/dsl'; import { collectDocumentMediaElements, withDocumentLegacyVideoRecovery, } from '@/lib/media/media-task-resolution'; +import { slideMediaReferenceSlots } from '@/lib/media/slide-media-slots'; +import { isConcreteMediaAddress } from '@/lib/media/resolve-media-ref'; +import { createLogger } from '@/lib/logger'; + +const moduleLog = createLogger('ClassroomLoad'); export interface ClassroomPayload { stage: Stage; @@ -153,6 +158,9 @@ export async function runClassroomLoad({ } if (!isCurrent()) return; + // Metadata-only on the critical path: the default loader defers object-URL + // creation for non-priority blobs, so this await is a table read, not a + // full media hydration (the rest hydrates in the background after apply). const mediaTasks = await loadRestoredMediaTasks(classroomId); if (!isCurrent()) { discardRestoredMediaTasks(mediaTasks); @@ -304,18 +312,68 @@ export function applyClassroomStageAndScenes( } } -export async function loadRestoredMediaTasksFromDB( - stageId: string, -): Promise> { +/** + * Restored media state split by hydration phase. `tasks` is metadata-complete + * and applied before first paint; records in `deferred` have their blob object + * URLs materialized afterwards in the background (see + * `hydrateDeferredMediaTasks`), so entering a media-heavy classroom never waits + * on `URL.createObjectURL` for every restored blob. + */ +export interface RestoredMediaTasks { + readonly stageId: string; + readonly tasks: Record; + readonly deferred: readonly MediaFileRecord[]; +} + +/** + * Media refs owned by the scene the classroom opens on (the persisted cursor, + * else the first scene). Their blobs hydrate eagerly during load so the first + * visible page paints its media immediately; every other record defers. + */ +export function collectPriorityMediaRefs( + scenes: readonly Scene[], + currentSceneId: string | null, +): Set { + const refs = new Set(); + const scene = scenes.find((candidate) => candidate.id === currentSceneId) ?? scenes[0]; + if (!scene) return refs; + const collect = (slide: Pick) => { + for (const slot of slideMediaReferenceSlots(slide)) { + const ref = slot.read(); + if (ref && !isConcreteMediaAddress(ref)) refs.add(ref); + } + }; + if (scene.content.type === 'slide') collect(scene.content.canvas); + for (const slide of scene.whiteboards ?? []) collect(slide); + return refs; +} + +export async function loadRestoredMediaTasksFromDB(stageId: string): Promise { try { const { db } = await import('@/lib/utils/database'); const records = await db.mediaFiles.where('stageId').equals(stageId).toArray(); const state = useStageStore.getState(); - const documentElements = - state.stage?.id === stageId ? collectDocumentMediaElements(state.stage, state.scenes) : []; - return buildRestoredMediaTasks(stageId, records, documentElements); + const sameStage = state.stage?.id === stageId; + const documentElements = sameStage + ? collectDocumentMediaElements(state.stage, state.scenes) + : []; + const priorityRefs = sameStage + ? collectPriorityMediaRefs(state.scenes, state.currentSceneId) + : new Set(); + const deferred: MediaFileRecord[] = []; + const tasks = buildRestoredMediaTasks(stageId, records, documentElements, (record) => { + const elementId = record.id.includes(':') + ? record.id.split(':').slice(1).join(':') + : record.id; + const isPriority = + priorityRefs.has(elementId) || + (!!record.placeholderRef && priorityRefs.has(record.placeholderRef)); + if (!isPriority) deferred.push(record); + return isPriority; + }); + return { stageId, tasks, deferred }; } catch { - return {}; + return { stageId, tasks: {}, deferred: [] }; } } @@ -323,6 +381,7 @@ export function buildRestoredMediaTasks( stageId: string, records: readonly MediaFileRecord[], documentElements: readonly PPTElement[] = [], + shouldHydrateBlob: (record: MediaFileRecord) => boolean = () => true, ): Record { const restored: Record = {}; for (const rec of records) { @@ -345,7 +404,14 @@ export function buildRestoredMediaTasks( continue; } - const blob = rec.blob.type ? rec.blob : new Blob([rec.blob], { type: rec.mimeType }); + // A deferred record keeps its done status (so generation resume never + // re-runs it) but carries no objectUrl yet: media resolution treats a + // known task without bytes as pending and re-renders when background + // hydration fills the URL in. + const hydrate = shouldHydrateBlob(rec); + const objectUrl = hydrate + ? URL.createObjectURL(rec.blob.type ? rec.blob : new Blob([rec.blob], { type: rec.mimeType })) + : undefined; restored[elementId] = { elementId, placeholderRef: rec.placeholderRef, @@ -353,8 +419,8 @@ export function buildRestoredMediaTasks( status: 'done', prompt: rec.prompt, params, - objectUrl: URL.createObjectURL(blob), - poster: rec.poster ? URL.createObjectURL(rec.poster) : undefined, + objectUrl, + poster: hydrate && rec.poster ? URL.createObjectURL(rec.poster) : undefined, retryCount: 0, stageId, }; @@ -362,20 +428,94 @@ export function buildRestoredMediaTasks( return withDocumentLegacyVideoRecovery(restored, documentElements, stageId); } -export function applyRestoredMediaTasks(tasks: Record): void { - if (Object.keys(tasks).length === 0) return; - useMediaGenerationStore.setState((state) => ({ - tasks: { ...state.tasks, ...tasks }, - })); +export function applyRestoredMediaTasks(restored: RestoredMediaTasks): void { + if (Object.keys(restored.tasks).length > 0) { + useMediaGenerationStore.setState((state) => ({ + tasks: { ...state.tasks, ...restored.tasks }, + })); + } + // Blob hydration continues off the load path; each deferred URL lands as an + // in-place task update that media resolution picks up as pending → url. + if (restored.deferred.length > 0) { + void hydrateDeferredMediaTasks(restored.stageId, restored.deferred).catch((error) => { + moduleLog.warn('Deferred media hydration failed:', error); + }); + } } -export function discardRestoredMediaTasks(tasks: Record): void { - for (const task of Object.values(tasks)) { +export function discardRestoredMediaTasks(restored: RestoredMediaTasks): void { + // Only eagerly hydrated tasks own object URLs; deferred records hold raw + // blobs and are dropped with nothing to revoke. + for (const task of Object.values(restored.tasks)) { if (task.objectUrl) URL.revokeObjectURL(task.objectUrl); if (task.poster) URL.revokeObjectURL(task.poster); } } +/** Records hydrated per idle slice — a handful of `createObjectURL` calls. */ +const MEDIA_HYDRATION_CHUNK_SIZE = 4; + +/** Yield to the browser between hydration chunks (idle callback when available). */ +function nextIdleSlice(): Promise { + return new Promise((resolve) => { + if (typeof requestIdleCallback === 'function') { + requestIdleCallback(() => resolve()); + } else { + setTimeout(resolve, 0); + } + }); +} + +/** + * Materialize object URLs for restored media records whose blobs were deferred + * during the blocking load phase. Runs chunk-by-chunk at idle time. A record + * whose task has since been replaced (classroom switch, regeneration, retry) + * is skipped and its freshly minted URLs are revoked immediately, so a late + * hydration can never leak URLs or overwrite another stage's task. + */ +export async function hydrateDeferredMediaTasks( + stageId: string, + records: readonly MediaFileRecord[], +): Promise { + for (let start = 0; start < records.length; start += MEDIA_HYDRATION_CHUNK_SIZE) { + await nextIdleSlice(); + const chunk = records.slice(start, start + MEDIA_HYDRATION_CHUNK_SIZE); + const hydrated = chunk.map((rec) => { + const elementId = rec.id.includes(':') ? rec.id.split(':').slice(1).join(':') : rec.id; + const blob = rec.blob.type ? rec.blob : new Blob([rec.blob], { type: rec.mimeType }); + return { + elementId, + objectUrl: URL.createObjectURL(blob), + poster: rec.poster ? URL.createObjectURL(rec.poster) : undefined, + }; + }); + useMediaGenerationStore.setState((state) => { + let changed = false; + const tasks = { ...state.tasks }; + for (const entry of hydrated) { + const existing = tasks[entry.elementId]; + if (!existing || existing.stageId !== stageId || existing.objectUrl) { + URL.revokeObjectURL(entry.objectUrl); + if (entry.poster) URL.revokeObjectURL(entry.poster); + continue; + } + let poster = entry.poster; + if (poster && existing.poster) { + URL.revokeObjectURL(poster); + poster = undefined; + } + tasks[entry.elementId] = { + ...existing, + objectUrl: entry.objectUrl, + ...(poster ? { poster } : {}), + }; + changed = true; + } + return changed ? { tasks } : state; + }); + } +} + /** * True when the persisted roster MAY still need the legacy mirror consulted. * The absent-vs-empty distinction matters: diff --git a/tests/classroom/deferred-media-restore.test.ts b/tests/classroom/deferred-media-restore.test.ts new file mode 100644 index 0000000000..f1137c4e97 --- /dev/null +++ b/tests/classroom/deferred-media-restore.test.ts @@ -0,0 +1,228 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + buildRestoredMediaTasks, + collectPriorityMediaRefs, + hydrateDeferredMediaTasks, +} from '@/lib/classroom/load-classroom'; +import { resolveMediaRef } from '@/lib/media/resolve-media-ref'; +import { useMediaGenerationStore } from '@/lib/store/media-generation'; +import type { MediaFileRecord } from '@/lib/utils/database'; +import type { Scene } from '@/lib/types/stage'; + +const stageId = 'stage-media'; + +function mediaRecord(ref: string, extra: Partial = {}): MediaFileRecord { + const blob = new Blob(['media-bytes'], { type: 'image/png' }); + return { + id: `${stageId}:${ref}`, + stageId, + type: 'image', + blob, + mimeType: 'image/png', + size: blob.size, + prompt: 'a generated image', + params: '{}', + createdAt: 1, + ...extra, + }; +} + +function slideScene(id: string, imageRef: string): Scene { + return { + id, + stageId, + type: 'slide', + title: id, + order: 1, + content: { + type: 'slide', + canvas: { + id: `canvas-${id}`, + viewportSize: 1000, + viewportRatio: 0.5625, + theme: { + backgroundColor: '#fff', + themeColors: ['#000'], + fontColor: '#000', + fontName: 'Inter', + }, + elements: [ + { + id: `el-${id}`, + type: 'image', + src: imageRef, + left: 0, + top: 0, + width: 100, + height: 100, + rotate: 0, + fixedRatio: true, + }, + ], + }, + }, + }; +} + +beforeEach(() => { + useMediaGenerationStore.setState({ tasks: {} }); + vi.spyOn(URL, 'createObjectURL').mockImplementation( + (obj: Blob | MediaSource) => + `blob:mock-${(obj as Blob).size}-${Math.random().toString(36).slice(2)}`, + ); + vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined); +}); + +afterEach(() => { + vi.restoreAllMocks(); + useMediaGenerationStore.setState({ tasks: {} }); +}); + +describe('buildRestoredMediaTasks blob deferral', () => { + it('hydrates every blob by default (legacy callers unchanged)', () => { + const tasks = buildRestoredMediaTasks(stageId, [mediaRecord('gen_img_1')]); + expect(tasks['gen_img_1']?.status).toBe('done'); + expect(tasks['gen_img_1']?.objectUrl).toMatch(/^blob:/); + }); + + it('keeps deferred records done but without an objectUrl, resolving as pending', () => { + const tasks = buildRestoredMediaTasks(stageId, [mediaRecord('gen_img_1')], [], () => false); + const task = tasks['gen_img_1']; + expect(task?.status).toBe('done'); + expect(task?.objectUrl).toBeUndefined(); + // The media resolution state machine must degrade gracefully: a known task + // without bytes renders as pending (skeleton), never as a broken image. + expect(resolveMediaRef('gen_img_1', task).kind).toBe('pending'); + }); + + it('never defers failed records', () => { + const failed = mediaRecord('gen_img_1', { + error: 'CONTENT_SENSITIVE', + errorCode: 'CONTENT_SENSITIVE', + }); + const tasks = buildRestoredMediaTasks(stageId, [failed], [], () => false); + expect(tasks['gen_img_1']?.status).toBe('failed'); + }); +}); + +describe('collectPriorityMediaRefs', () => { + it('collects the media refs of the current scene', () => { + const scenes = [slideScene('scene-1', 'gen_img_1'), slideScene('scene-2', 'gen_img_2')]; + expect([...collectPriorityMediaRefs(scenes, 'scene-2')]).toEqual(['gen_img_2']); + }); + + it('falls back to the first scene when no cursor is set', () => { + const scenes = [slideScene('scene-1', 'gen_img_1'), slideScene('scene-2', 'gen_img_2')]; + expect([...collectPriorityMediaRefs(scenes, null)]).toEqual(['gen_img_1']); + }); + + it('ignores concrete addresses (only generated refs need blob hydration)', () => { + const scenes = [slideScene('scene-1', 'https://cdn.example.com/a.png')]; + expect(collectPriorityMediaRefs(scenes, null).size).toBe(0); + }); +}); + +describe('hydrateDeferredMediaTasks', () => { + it('fills the objectUrl of a restored task in place', async () => { + useMediaGenerationStore.setState({ + tasks: { + gen_img_1: { + elementId: 'gen_img_1', + type: 'image', + status: 'done', + prompt: 'p', + params: {}, + retryCount: 0, + stageId, + }, + }, + }); + + await hydrateDeferredMediaTasks(stageId, [mediaRecord('gen_img_1')]); + + const task = useMediaGenerationStore.getState().tasks['gen_img_1']; + expect(task?.objectUrl).toMatch(/^blob:/); + expect(resolveMediaRef('gen_img_1', task).kind).toBe('url'); + }); + + it('hydrates the poster together with the blob', async () => { + useMediaGenerationStore.setState({ + tasks: { + gen_vid_1: { + elementId: 'gen_vid_1', + type: 'video', + status: 'done', + prompt: 'p', + params: {}, + retryCount: 0, + stageId, + }, + }, + }); + const record = mediaRecord('gen_vid_1', { + type: 'video', + mimeType: 'video/mp4', + poster: new Blob(['poster'], { type: 'image/png' }), + }); + + await hydrateDeferredMediaTasks(stageId, [record]); + + const task = useMediaGenerationStore.getState().tasks['gen_vid_1']; + expect(task?.objectUrl).toMatch(/^blob:/); + expect(task?.poster).toMatch(/^blob:/); + }); + + it('skips and revokes URLs when the task was replaced by another stage', async () => { + useMediaGenerationStore.setState({ tasks: {} }); + + await hydrateDeferredMediaTasks(stageId, [mediaRecord('gen_img_1')]); + + expect(useMediaGenerationStore.getState().tasks['gen_img_1']).toBeUndefined(); + expect(URL.revokeObjectURL).toHaveBeenCalled(); + }); + + it('does not overwrite a task that already has an objectUrl (regeneration won)', async () => { + useMediaGenerationStore.setState({ + tasks: { + gen_img_1: { + elementId: 'gen_img_1', + type: 'image', + status: 'done', + prompt: 'p', + params: {}, + objectUrl: 'blob:fresh', + retryCount: 1, + stageId, + }, + }, + }); + + await hydrateDeferredMediaTasks(stageId, [mediaRecord('gen_img_1')]); + + const task = useMediaGenerationStore.getState().tasks['gen_img_1']; + expect(task?.objectUrl).toBe('blob:fresh'); + expect(URL.revokeObjectURL).toHaveBeenCalled(); + }); + + it('never writes into another stage task with the same elementId', async () => { + useMediaGenerationStore.setState({ + tasks: { + gen_img_1: { + elementId: 'gen_img_1', + type: 'image', + status: 'done', + prompt: 'p', + params: {}, + retryCount: 0, + stageId: 'stage-other', + }, + }, + }); + + await hydrateDeferredMediaTasks(stageId, [mediaRecord('gen_img_1')]); + + const task = useMediaGenerationStore.getState().tasks['gen_img_1']; + expect(task?.stageId).toBe('stage-other'); + expect(task?.objectUrl).toBeUndefined(); + }); +}); diff --git a/tests/classroom/load-classroom.test.ts b/tests/classroom/load-classroom.test.ts index 828aa9df46..c13b90467e 100644 --- a/tests/classroom/load-classroom.test.ts +++ b/tests/classroom/load-classroom.test.ts @@ -1097,17 +1097,21 @@ describe('discardRestoredMediaTasks', () => { const revokeObjectURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined); discardRestoredMediaTasks({ - image: { - elementId: 'image', - type: 'image', - status: 'done', - prompt: 'image', - params: {}, - objectUrl: 'blob:image', - poster: 'blob:poster', - retryCount: 0, - stageId: 'stage-a', + stageId: 'stage-a', + tasks: { + image: { + elementId: 'image', + type: 'image', + status: 'done', + prompt: 'image', + params: {}, + objectUrl: 'blob:image', + poster: 'blob:poster', + retryCount: 0, + stageId: 'stage-a', + }, }, + deferred: [], }); expect(revokeObjectURL).toHaveBeenCalledWith('blob:image'); From 1524f2d86b55db98657a7f3fff732f30d3b1c568 Mon Sep 17 00:00:00 2001 From: wyuc Date: Fri, 28 Aug 2026 09:53:55 -0400 Subject: [PATCH 02/11] perf(classroom): lazy-render slide thumbnails in the playback sidebar The playback scene sidebar mounted a full SlideCanvas for every slide scene the moment the classroom opened (and every off-screen video element opened a preload="metadata" fetch), because SlideThumbnail's existing visible prop was never passed. Extract the editor nav rail's near-viewport IntersectionObserver hook to lib/hooks/use-near-viewport.ts and gate the sidebar's slide thumbnails through it: only scenes within 200px of the viewport render the live canvas; the rest show SlideThumbnail's existing placeholder until scrolled near. The placeholder keeps the same box size, so gating never shifts layout. The shared hook now starts hidden instead of eager: with the previous eager-initial state, opening a long deck still mounted every canvas for a frame before the observer could flip off-screen items off. The observer's guaranteed initial delivery flips near-viewport items within a frame; the no-IntersectionObserver fallback (e.g. jsdom) defers to a microtask so the effect never synchronously re-renders. --- components/edit/SlideNavRail/ThumbItem.tsx | 26 +------ components/stage/scene-sidebar.tsx | 39 +++++++++- lib/hooks/use-near-viewport.ts | 40 +++++++++++ tests/hooks/use-near-viewport.test.ts | 84 ++++++++++++++++++++++ 4 files changed, 164 insertions(+), 25 deletions(-) create mode 100644 lib/hooks/use-near-viewport.ts create mode 100644 tests/hooks/use-near-viewport.test.ts diff --git a/components/edit/SlideNavRail/ThumbItem.tsx b/components/edit/SlideNavRail/ThumbItem.tsx index 5c85fe255f..bfa7d243d1 100644 --- a/components/edit/SlideNavRail/ThumbItem.tsx +++ b/components/edit/SlideNavRail/ThumbItem.tsx @@ -1,6 +1,6 @@ 'use client'; -import { memo, useCallback, useEffect, useRef, useState } from 'react'; +import { memo, useCallback, useRef, useState } from 'react'; import { Reorder } from 'motion/react'; import { MoreHorizontal } from 'lucide-react'; import { cn } from '@/lib/utils'; @@ -13,6 +13,7 @@ import { import { SceneThumbnailContent } from '@/components/stage/scene-thumbnail-content'; import { SCENE_CREATION_ENABLED } from '@/lib/edit/scene-creation-enabled'; import { sceneHasIssues } from '@/lib/edit/content-validation'; +import { useNearViewport } from '@/lib/hooks/use-near-viewport'; import type { Scene } from '@/lib/types/stage'; import { useCanvasStore } from '@/lib/store/canvas'; import { useStageStore } from '@/lib/store/stage'; @@ -275,26 +276,3 @@ function ThumbItemComponent({ * read-only inside Pro mode (no auto-exit on click). */ export const ThumbItem = memo(ThumbItemComponent); - -/** - * Cheap "near viewport" IntersectionObserver so off-screen thumbs - * skip the live ThumbnailSlide render (which mounts a downscaled - * slide-renderer scene). Items within 200px of the viewport remain - * eager so scrolling feels instant. - */ -function useNearViewport(ref: React.RefObject) { - const [visible, setVisible] = useState(true); - useEffect(() => { - const el = ref.current; - if (!el || typeof IntersectionObserver === 'undefined') return; - const io = new IntersectionObserver( - (entries) => { - for (const e of entries) setVisible(e.isIntersecting); - }, - { root: null, rootMargin: '200px 0px', threshold: 0 }, - ); - io.observe(el); - return () => io.disconnect(); - }, [ref]); - return visible; -} diff --git a/components/stage/scene-sidebar.tsx b/components/stage/scene-sidebar.tsx index 72f81092de..8567e9431a 100644 --- a/components/stage/scene-sidebar.tsx +++ b/components/stage/scene-sidebar.tsx @@ -18,6 +18,7 @@ import { SlideThumbnail } from '@/components/slide-renderer/SlideThumbnail'; import { ThumbnailInteractive } from '@/components/slide-renderer/components/ThumbnailInteractive'; import { useStageStore, useCanvasStore } from '@/lib/store'; import { useI18n } from '@/lib/hooks/use-i18n'; +import { useNearViewport } from '@/lib/hooks/use-near-viewport'; import type { SceneType, SlideContent, InteractiveContent } from '@/lib/types/stage'; import { PENDING_SCENE_ID } from '@/lib/store/stage'; @@ -202,7 +203,7 @@ export function SceneSidebar({
{isSlide && slideContent ? ( - ); } + +/** + * Viewport-gated slide thumbnail for the playback sidebar. Scenes far outside + * the viewport render SlideThumbnail's cheap placeholder instead of a full + * SlideCanvas — which also spares every off-screen video element its + * `preload="metadata"` fetch when the classroom opens. The placeholder keeps + * the same box size, so gating never shifts layout. + */ +function LazySlideThumbnail({ + slide, + sceneId, + viewportSize, + viewportRatio, + size, +}: { + readonly slide: SlideContent['canvas']; + readonly sceneId: string; + readonly viewportSize: number; + readonly viewportRatio: number; + readonly size: number; +}) { + const ref = useRef(null); + const visible = useNearViewport(ref); + return ( +
+ +
+ ); +} diff --git a/lib/hooks/use-near-viewport.ts b/lib/hooks/use-near-viewport.ts new file mode 100644 index 0000000000..22d92b8dcc --- /dev/null +++ b/lib/hooks/use-near-viewport.ts @@ -0,0 +1,40 @@ +import { useEffect, useState, type RefObject } from 'react'; + +/** Extra vertical slack around the viewport that still counts as "near". */ +const NEAR_VIEWPORT_MARGIN_PX = 200; + +/** + * Cheap "near viewport" IntersectionObserver so off-screen thumbnails skip the + * live slide-canvas render (which mounts a downscaled slide-renderer scene). + * Items within 200px of the viewport render eagerly so scrolling feels instant. + * + * The initial state is `false`: a sidebar opening with many scenes must not + * mount a full slide canvas for every item before the observer answers. The + * observer's initial delivery (guaranteed on `observe`) flips near-viewport + * items to their canvas within a frame. + * + * Shared by the editor nav rail and the playback scene sidebar. + */ +export function useNearViewport(ref: RefObject): boolean { + const [visible, setVisible] = useState(false); + useEffect(() => { + const el = ref.current; + if (!el) return; + if (typeof IntersectionObserver === 'undefined') { + // No observer support (e.g. jsdom): fall back to always-render. Deferred + // to a microtask so the effect only subscribes, never synchronously + // re-renders. + queueMicrotask(() => setVisible(true)); + return; + } + const io = new IntersectionObserver( + (entries) => { + for (const e of entries) setVisible(e.isIntersecting); + }, + { root: null, rootMargin: `${NEAR_VIEWPORT_MARGIN_PX}px 0px`, threshold: 0 }, + ); + io.observe(el); + return () => io.disconnect(); + }, [ref]); + return visible; +} diff --git a/tests/hooks/use-near-viewport.test.ts b/tests/hooks/use-near-viewport.test.ts new file mode 100644 index 0000000000..f0b9aab575 --- /dev/null +++ b/tests/hooks/use-near-viewport.test.ts @@ -0,0 +1,84 @@ +// @vitest-environment jsdom +import { createElement, createRef, type RefObject } from 'react'; +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { useNearViewport } from '@/lib/hooks/use-near-viewport'; + +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + +type IOCallback = (entries: Array>) => void; + +let observedCallback: IOCallback | undefined; +let observedOptions: IntersectionObserverInit | undefined; +const observeMock = vi.fn(); +const disconnectMock = vi.fn(); + +class FakeIntersectionObserver { + constructor(callback: IntersectionObserverCallback, options?: IntersectionObserverInit) { + observedCallback = (entries) => + callback(entries as IntersectionObserverEntry[], this as unknown as IntersectionObserver); + observedOptions = options; + } + observe = observeMock; + unobserve = vi.fn(); + disconnect = disconnectMock; + root = null; + rootMargin = ''; + thresholds = []; + takeRecords = () => []; +} + +function Probe({ targetRef }: { readonly targetRef: RefObject }) { + const visible = useNearViewport(targetRef); + return createElement( + 'div', + { ref: targetRef }, + createElement('output', { 'data-testid': 'state' }, visible ? 'visible' : 'hidden'), + ); +} + +function mount(): { root: Root; targetRef: RefObject } { + const container = document.createElement('div'); + document.body.append(container); + const targetRef = createRef(); + const root = createRoot(container); + act(() => root.render(createElement(Probe, { targetRef }))); + return { root, targetRef }; +} + +afterEach(() => { + document.body.replaceChildren(); + vi.unstubAllGlobals(); + vi.clearAllMocks(); + observedCallback = undefined; + observedOptions = undefined; +}); + +describe('useNearViewport', () => { + it('stays hidden until the observer reports the element near the viewport', () => { + vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver); + const container = mount(); + + expect(document.querySelector('[data-testid="state"]')?.textContent).toBe('hidden'); + expect(observeMock).toHaveBeenCalledOnce(); + expect(observedOptions?.rootMargin).toBe('200px 0px'); + + act(() => observedCallback?.([{ isIntersecting: true }])); + expect(document.querySelector('[data-testid="state"]')?.textContent).toBe('visible'); + + act(() => observedCallback?.([{ isIntersecting: false }])); + expect(document.querySelector('[data-testid="state"]')?.textContent).toBe('hidden'); + + act(() => container.root.unmount()); + expect(disconnectMock).toHaveBeenCalledOnce(); + }); + + it('falls back to always-visible when IntersectionObserver is unavailable', async () => { + mount(); + expect(document.querySelector('[data-testid="state"]')?.textContent).toBe('hidden'); + // The fallback flips on a microtask so the effect never synchronously re-renders. + await act(async () => {}); + expect(document.querySelector('[data-testid="state"]')?.textContent).toBe('visible'); + }); +}); From 9ee9622b44b674790e70b1edde4ee162ea214108 Mon Sep 17 00:00:00 2001 From: wyuc Date: Fri, 28 Aug 2026 10:04:50 -0400 Subject: [PATCH 03/11] perf(media): lazy image decoding and HTTP Range support for classroom media MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renderer: BaseImageElement's now carries loading="lazy" and decoding="async", so thumbnail-heavy surfaces (playback sidebar, editor nav rail, course cards) no longer fetch and decode every slide image up front. In-viewport images are unaffected — the browser fetches them immediately. slideToPng forces eager loading inside its permanently off-screen snapshot tree, where lazy images would otherwise never fetch and exports would capture blank slides. Server: GET /api/classroom-media/[classroomId]/[...path] now answers single byte-range requests with 206 Partial Content (Content-Range, Accept-Ranges, correct Content-Length), enabling progressive playback and seeking for hosted video/audio instead of downloading whole files. Suffix ranges are supported; unsatisfiable ranges get 416 with the full size; unsupported units or multi-range sets fall back to the plain 200 full-body response, which is always a legal answer. Existing caching headers are kept on every response shape. --- .../[classroomId]/[...path]/route.ts | 66 +++++++--- lib/server/http-range.ts | 43 +++++++ .../src/elements/image/BaseImageElement.tsx | 6 + .../@openmaic/renderer/src/snapshot/index.ts | 8 ++ .../elements/image/BaseImageElement.test.tsx | 27 ++++ tests/api/classroom-media-range.test.ts | 121 ++++++++++++++++++ tests/server/http-range.test.ts | 49 +++++++ 7 files changed, 304 insertions(+), 16 deletions(-) create mode 100644 lib/server/http-range.ts create mode 100644 packages/@openmaic/renderer/test/elements/image/BaseImageElement.test.tsx create mode 100644 tests/api/classroom-media-range.test.ts create mode 100644 tests/server/http-range.test.ts diff --git a/app/api/classroom-media/[classroomId]/[...path]/route.ts b/app/api/classroom-media/[classroomId]/[...path]/route.ts index 5b9fd3acab..b93b5764f4 100644 --- a/app/api/classroom-media/[classroomId]/[...path]/route.ts +++ b/app/api/classroom-media/[classroomId]/[...path]/route.ts @@ -1,7 +1,8 @@ -import { promises as fs, createReadStream } from 'fs'; +import { promises as fs, createReadStream, type ReadStream } from 'fs'; import path from 'path'; import { NextRequest, NextResponse } from 'next/server'; import { CLASSROOMS_DIR, isValidClassroomId } from '@/lib/server/classroom-storage'; +import { parseRangeHeader } from '@/lib/server/http-range'; import { createLogger } from '@/lib/logger'; const log = createLogger('ClassroomMedia'); @@ -20,8 +21,24 @@ const MIME_TYPES: Record = { '.aac': 'audio/aac', }; +const CACHE_HEADERS = { 'Cache-Control': 'public, max-age=86400, immutable' } as const; + +/** Bridge a fs ReadStream into a web ReadableStream, propagating errors and cancel. */ +function toWebStream(stream: ReadStream): ReadableStream { + return new ReadableStream({ + start(controller) { + stream.on('data', (chunk: Buffer | string) => controller.enqueue(chunk)); + stream.on('end', () => controller.close()); + stream.on('error', (err) => controller.error(err)); + }, + cancel() { + stream.destroy(); + }, + }); +} + export async function GET( - _req: NextRequest, + req: NextRequest, { params }: { params: Promise<{ classroomId: string; path: string[] }> }, ) { const { classroomId, path: pathSegments } = await params; @@ -61,25 +78,42 @@ export async function GET( const ext = path.extname(realPath).toLowerCase(); const contentType = MIME_TYPES[ext] || 'application/octet-stream'; - // Stream the file to avoid loading large videos into memory - const stream = createReadStream(realPath); - const webStream = new ReadableStream({ - start(controller) { - stream.on('data', (chunk: Buffer | string) => controller.enqueue(chunk)); - stream.on('end', () => controller.close()); - stream.on('error', (err) => controller.error(err)); - }, - cancel() { - stream.destroy(); - }, - }); + // Range requests enable progressive playback and seeking for hosted media + // (e.g.