-
perf(classroom): speed up classroom loading (media hydration, sidebar thumbnails, media range requests) (#1276)
发布于
2026-08-29 19:48:04 +00:00 - perf(classroom): defer non-priority media blob hydration off the load path
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.- 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.- perf(media): lazy image decoding and HTTP Range support for classroom media
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.-
chore(renderer): bump version to 0.1.4 for image lazy-loading change
-
fix(classroom): guard deferred hydration against restarted tasks and bound idle waits
A deferred record is only ever 'done' without an objectUrl; a task that
regeneration or retry restarted passes through pending/generating, so skip
those instead of attaching stale persisted bytes. Also give the idle
scheduling a timeout so a busy main thread cannot starve hydration.- fix(classroom): stop superseded deferred hydration before minting URLs
A restore epoch captured at apply time now gates each idle chunk: loading
another classroom or reloading the same one invalidates any older hydration
loop, so it neither keeps scheduling work for an abandoned classroom nor
attaches bytes read by an older load to a newer load's tasks.- fix(classroom): keep 416s uncached and classify element-keyed media as priority
A 416 with public immutable caching can poison the media URL for later
valid requests, so range errors now send Cache-Control: no-store. Priority
classification also collects the opening scene's media element ids, since
task lookup binds records keyed stage: even when the slide slot
carries a different opaque ref.- fix(classroom): tie deferred hydration liveness to the classroom load token
The restore epoch only advanced when the next load reached apply, so an
abandoned classroom kept hydrating during the next load's storage/network
phase. Compose the epoch with the load's isCurrent (load token plus effect
cleanup) so navigation stops the loop at the next idle boundary.- fix(classroom): classify legacy-recovered media before deferring it
Legacy singleton video recovery assigns a placeholderRef only at the end of
the task build, so a record keyed by an allocated id without placeholderRef
was deferred even when it backs the opening scene's gen_vid_* element. Run a
metadata-only build first to learn each record's effective ref and classify
against it, keeping the first visible page's legacy video eager.- fix(action): wait for deferred video bytes before starting play_video
executePlayVideo treated status done as immediately playable, but a deferred
restore is done without an objectUrl: the renderer shows a skeleton, no
- fix(classroom): include the stage whiteboard in priority media refs
The stage-level whiteboard stays open across standalone classroom switches,
so its media can be visible before any scene is. Classifying it as deferred
left visible whiteboard media pending behind idle hydration chunks.下载附件