Skip to content

perf(studio): timeline thumbnails come back from disk in the next session - #6676

Merged
vanceingalls merged 2 commits into
mainfrom
perf/studio-thumbnails-on-disk
Oct 28, 2026
Merged

vanceingalls merged 2 commits into
mainfrom
perf/studio-thumbnails-on-disk

Conversation

@vanceingalls

@vanceingalls vanceingalls commented Oct 28, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Timeline video thumbnails now survive a reload. #6629 shared one reader per video and kept released frames in memory, but all of it was keyed by the session. So reopening a project re-read every video's index (at a 3.4 GB offset for a long camera file) and decoded every strip again.

Now each source's facts (aspect and duration) and every decoded frame are kept in Cache Storage, under the file's ETag, which is the version the server says it is.

  • Next session, or the same project opened again: each source is checked once per project open (its contentVersion) with a conditional one-byte read (Range: bytes=0-0, If-None-Match: <stored ETag>). A 304 means the stored frames are still that file's frames. A strip they cover then paints without opening a reader, reading the file or decoding anything. Frames come back into the shared memory cache, so sibling clips share them as before.
  • First session: the ETag comes from the reader's own first response (mediaFetch notes it), so a first visit costs no extra request. The info is stored once, and each frame is stored as it is decoded (fire-and-forget).
  • A changed file: a new ETag drops what was stored for the old one. That session falls through to the reader as before and decodes everything once.
  • Budget: whole sources are dropped, least recently used first, past 256 MiB. A small index in localStorage keeps the last ETag per source and each cache's size and last use. A frame stored again under its name counts once, and a save merges with what other tabs saved since, without bringing back caches this tab dropped.
  • Never a failure: if Cache Storage or crypto.subtle is missing (no secure context), the store fails (quota, private window), or the ETag isn't exposed (a cross-origin source whose CORS doesn't list ETag), Studio warns once and stays memory-only. Opening a cache creates it, so reads first check caches.has, and a read never leaves an empty cache behind for a version that is gone.

Desktop serves Studio from a saved port (studioPort.mjs reuses it across launches), so the origin, and with it this store, persists across app restarts. The main window uses Electron's default persistent session.

Before

Right after a reload on main, 3 of the 40 clips have their strip; the rest are still grey while every source is opened and decoded again (synthetic footage: three 10-minute 1080p test videos, 40 clips).

before

After

The same moment after a reload with this branch: all 40 strips are painted from disk, and no source was opened.

after

What I measured

  • Real camera footage, local only (three 44-minute 1080p H.264 files of 2.6–3.4 GB, 40 clips across the three, dev Studio on an M-series Mac, five reloads per condition). Time from page load to all 40 timeline thumbnails painted:
    • main: 697, 589, 435, 540 and 731 ms (median 589 ms). The disk index stayed frozen during these runs, so the main code really ran.
    • This branch from disk: 0, 0, 145, 356 and 0 ms (median 0 ms): the strips were already painted when the page finished loading.
    • This is the best case for main: a local SSD with perf(studio): share video sources and keep frames for timeline thumbnails #6629's sharing already in place. The saving grows with what a decode costs: hosted media, slower laptops, more clips.
  • Work avoided on reopen: one 304 per source, then no reads of the file and no decodes. The stored bytes didn't change across reloads (164 KB for 48 frames on the synthetic project, 201 KB for 40 frames across three sources).
  • Tests: thumbnailDiskCache.test.ts (8) covers round trips, the 304 rule, dropping an old version, least-used eviction, a broken store, a re-stored frame counted once, and a save that keeps another tab's entries but not this tab's drops. thumbnailVideoDecoder.test.ts adds a second session (after vi.resetModules()) that paints a strip with getPrimaryVideoTrack and the canvas sink never called. A second test reopens the same project in the same page after its video is replaced (ETag "v1" → "v2", a 206) and expects the reader to open again (Genesis's repro). Studio player tests: 2421 pass.
  • fails without the fix: paints a strip from a past session's frames on disk, without opening the file. Eight mutations are each caught: no disk read, no disk write, no version from the reader, a session-dependent disk key, the stored version memoised per source only, no subtraction of a replaced frame's bytes, no merge on save, and drops not tracked.
  • tsc --noEmit, oxlint, oxfmt, the comment ratchet and the pre-commit hooks (fallow) pass.

What I did NOT exercise

  • Desktop itself. I measured the OSS dev Studio, and Desktop picks this up with its next pin.
  • Hosted media, such as S3 sources on hyperframes.dev. They get this only if their CORS exposes ETag; otherwise they stay memory-only.
  • A disk that fills past the browser's quota. put failures are caught and warn once, but I didn't force one in a real browser.

🤖 Generated with Claude Code

…sion

Reopening a project re-read every video's index and re-decoded every strip:
the frame cache and the source facts lived in memory, keyed by the session.

Now each source's facts (aspect, duration) and every decoded frame are kept
in Cache Storage under the file's ETag, the version the server says it is. A
new session checks each source once with a conditional one-byte read: a 304
means the stored frames are still that file's, and a strip they cover paints
without opening a reader. A new ETag drops what was stored for the old one.
Sources are dropped whole, least recently used first, past 256 MiB. A store
that is missing or fails (private window, quota) warns once and the strip
decodes as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@github-actions

github-actions Bot commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

Edit accuracy: accurate 271 (base branch 271), smooth 129 of those

The gate passes.
Smoothness is reported in the artifact, not gated. A case fails only if it fails 2 of 3 runs.

Quarantined, measured but not gated (0)

Unstable (1)

  • seqplainkeys-none-center-r0-root-z100: tracking 0.06, pressJump 0, drop 0, reload 0, render 0.03, renderKey -, undo false, teleport true / tracking 0.06, pressJump 0, drop 0, reload 0, render 0.03, renderKey -, undo true, teleport true / tracking 0.06, pressJump 0, drop 0, reload 0, render 0.03, renderKey -, undo true, teleport true

@heygengenesis heygengenesis Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: REQUEST CHANGES · 1 blocker · 0 important · 1 nit · head 34a9762

The disk path ignores contentVersion, so a project reopened in the same page after a video was replaced shows the old file's frames. Repro below (passes on main, fails here). CI is green on this head.

Scope

Read all 6 files end to end, plus the preview route's ETag/304 handling (settledFileTag, exact If-None-Match match) and the scheduler timeout path. Ran thumbnailDiskCache.test.ts and thumbnailVideoDecoder.test.ts locally: 51 pass, plus the repro.

— Genesis


function used(name: string, addedBytes = 0): void {
const caches = loadIndex().caches;
caches[name] = { usedAt: Date.now(), bytes: (caches[name]?.bytes ?? 0) + addedBytes };

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: byte counts only ever go up, and each tab writes its whole snapshot of the index, so the 256 MiB cap drifts. A frame stored again under the same name (a disk frame that accepts rejected for another clip's window, which then gets decoded again) has its bytes counted twice. A second tab saving its older snapshot drops the first tab's caches and etags entries, so those caches fall outside the LRU, and when they're used again their bytes start counting from 0. Subtract the replaced entry's size on put, and merge with a fresh localStorage read before saving.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mostly fixed in f35c9bc: the double count and the lost caches entries are both handled. One part is still open, and it doesn't block: in mergedIndex, mine.etags wins for every key, including keys this tab only loaded at startup. So if another tab moves a source to a new ETag, this tab's next save puts the old one back. I checked with a throwaway test: the other tab writes "two", this tab saves for another source, and the index holds "one". The next session then validates the old ETag, gets a 206 and decodes that source from scratch. It loses a disk hit but never paints wrong frames. Fix: record the keys rememberVersion sets (the same way as dropped) and let only those win over the saved ones.

… index counts a frame once

The stored version a session validated was kept per video for the whole page,
so reopening a project after one of its videos was replaced at the same path
painted the old file's frames until a full reload. It is now kept per video
and project open (its contentVersion): each open re-validates once.

The index now counts a frame stored again under its name once, and merges
with what other tabs saved before writing, keeping caches this tab dropped
out, so the 256 MiB budget holds across tabs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

@heygengenesis heygengenesis Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: APPROVE — 1 resolved, 1 open (non-blocking nit, ETag merge), 0 new · 34a9762 → f35c9bc. CI green.

@vanceingalls
vanceingalls added this pull request to the merge queue Oct 28, 2026
Merged via the queue into main with commit e38585e Oct 28, 2026
113 of 117 checks passed
@vanceingalls
vanceingalls deleted the perf/studio-thumbnails-on-disk branch October 28, 2026 17:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant