Skip to content

Timeline waveform rendering & ruler perf improvements#262

Merged
walterlow merged 8 commits into
stagingfrom
develop
May 28, 2026
Merged

Timeline waveform rendering & ruler perf improvements#262
walterlow merged 8 commits into
stagingfrom
develop

Conversation

@walterlow
Copy link
Copy Markdown
Owner

@walterlow walterlow commented May 28, 2026

Summary

  • Render clip waveforms from zoom-appropriate OPFS levels, using full-resolution data whenever zoomed in
  • Fix waveform skeleton flashing when moving a clip to a new track; retain oversized entries in the memory cache
  • Include canvasHeight in the ruler tile cache key so re-renders pick up height changes
  • Avoid re-rendering all tracks on media drop
  • Simplify ruler look with bottom-anchored ticks and a flat background

Test plan

  • Zoom in/out on clips with audio — waveforms stay sharp and don't flash
  • Drag a clip to a new track — no skeleton flash
  • Drop media onto the timeline — only the affected track re-renders
  • Resize timeline height — ruler ticks render correctly

Summary by CodeRabbit

  • New Features

    • Zoom-aware waveform rendering that selects suitable display resolutions for different zoom levels.
  • Bug Fixes

    • Eliminated flashing skeleton when remounting waveform components.
  • Performance

    • Faster timeline performance via improved waveform & tile caching and reduced unnecessary track re-renders; better handling of oversized cache entries.
  • Style

    • Refined timeline ruler ticks, labels, and background for clearer visuals.
  • Tests

    • Added comprehensive tests for the memory cache behavior.

Review Change Stack

walterlow added 7 commits May 28, 2026 22:01
…lat background

Convert full-height major tick lines to short bottom-anchored marks, flatten the
background gradient to a single tone, drop the edge vignettes, and soften the
label shadow for a calmer ruler that doesn't compete with the playhead/clip grid.
planTrackMediaDropPlacements always returned a freshly-cloned tracks
array, so applyResolvedTimelineDrop always saw a "change" and called
setTracks; setTracks then ran every track through normalizeTrack (which
always allocates), giving every track a new reference and breaking
TimelineTrack's identity-based memo. Result: every track row re-rendered
on every drop.

- planTrackMediaDropPlacements now returns the original tracks reference
  when no track was added/changed, so the redundant setTracks is skipped.
- setTracks now preserves per-track object identity (and the array
  reference) for unchanged tracks, which also benefits mute/rename/
  reorder/resize callers.
…a new track

Moving a clip across tracks remounts its TimelineItem (each track renders
its own keyed items), which re-initializes the waveform. Two gaps caused
a loading-skeleton flash on remount:

- The waveform memory cache budget was 20MB, and SizedAccessedMemoryCache
  rejects any item larger than the budget. A long clip's full-resolution
  peaks (1000 samples/sec stereo ~= 28.8MB/hour) exceeded that, so it was
  never cached and every (re)mount triggered an async OPFS reload + shimmer.
  Raised the budget to 128MB so realistic long-clip waveforms stay resident
  and remounts hit the sync cache.
- ClipWaveform measured its height in a post-paint useEffect, so a remount
  showed a height=0 skeleton frame even with cached peaks. Moved the
  measurement to useLayoutEffect so it commits before paint.
The 128MB budget bump fixed the reported case but left the underlying
footgun: SizedAccessedMemoryCache silently dropped any entry larger than
the budget, so a clip whose full-resolution peaks exceed the budget
(~4.5h stereo) would still never be cached and would reload with a
skeleton flash on every remount.

Remove the oversized-reject guard so the existing LRU eviction stores the
entry once the rest of the working set is evicted (briefly exceeding the
budget, reclaimed on the next add). This makes the waveform fix
size-independent rather than dependent on the magic 128MB number.

Adds unit tests for LRU eviction, size accounting, and oversized retention.
The timeline previously held full-resolution peaks (1000 samples/sec) in
memory for every visible audio clip, so a long clip pinned tens of MB and
display depended on the full-res cache staying resident.

Render from the persisted OPFS multi-resolution levels instead, choosing
the coarsest level that still has >=1 sample per pixel for the current
zoom (chooseDisplayLevelForZoom). When zoomed out this is a small fraction
of the data; full detail (level 0) is used only when zoomed in past ~200
px/s, matching the old look. A small dedicated level cache makes remounts
(e.g. dragging a clip to another track) and zoom-back instant with no
skeleton flash. Falls back to the full-resolution generate/load path while
a waveform is still generating or has no persisted multi-resolution file.

The wider, density-aware level selection avoids the blocky waveforms a
naive zoom->level mapping (chooseLevelForZoom) produced when zoomed in.
The density-aware level selection still picked the 200/sec level in the
50–200 px/s range, which visibly lost transient detail vs the old
always-1000/sec rendering. Render full resolution (level 0) at any editing
zoom (>=16 px/s) and only step down to a coarse level at overview zoom,
where the whole clip is on screen, the detail is imperceptible, and the
memory savings for long clips are largest.
Tile tick geometry is drawn relative to canvasHeight, so a ruler-height
change (e.g. a new editor-density preset) would otherwise reuse stale
cached tiles.
@vercel
Copy link
Copy Markdown

vercel Bot commented May 28, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
freecut Ready Ready Preview, Comment May 28, 2026 3:52pm

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 28, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 563b915f-3d58-4286-aac1-12f718a326e8

📥 Commits

Reviewing files that changed from the base of the PR and between eb37582 and 1cde85d.

📒 Files selected for processing (3)
  • src/features/timeline/components/timeline-markers.tsx
  • src/features/timeline/hooks/use-waveform.ts
  • src/features/timeline/services/waveform-cache.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • src/features/timeline/components/timeline-markers.tsx
  • src/features/timeline/services/waveform-cache.ts
  • src/features/timeline/hooks/use-waveform.ts

📝 Walkthrough

Walkthrough

Adds zoom-aware waveform display-level caching and selection, updates useWaveform to prefer persisted downsampled levels via pixelsPerSecond, integrates pre-paint height measurement in ClipWaveform, refines TimelineMarkers rendering and caching keys, and preserves track reference identity to avoid unnecessary re-renders.

Changes

Waveform zoom rendering and optimizations

Layer / File(s) Summary
Waveform cache display-level infrastructure and eviction policy
src/features/timeline/services/sized-accessed-memory-cache.ts, src/features/timeline/services/sized-accessed-memory-cache.test.ts, src/features/timeline/services/waveform-cache.ts, src/features/timeline/services/waveform-opfs-storage.ts
Adds display-level LRU levelCache, CachedWaveformLevel type, getDisplayLevelSync/getDisplayLevel with in-flight coalescing and generation-token guards, increases budgets (128MB full-res, 64MB levels), and changes sized-cache eviction to allow temporary oversized entries; includes tests for eviction/oversize behavior.
UseWaveform hook zoom-based display-level selection
src/features/timeline/hooks/use-waveform.ts
UseWaveformOptions gains optional pixelsPerSecond. Hook computes levelIndex, seeds displayLevel from cache synchronously, probes OPFS asynchronously with cancellation and levelProbed state, gates full-resolution generation with needsFullRes, and returns display-level peaks preferentially with updated isLoading semantics.
Component rendering integration with zoom parameters
src/features/timeline/components/clip-waveform/index.tsx, src/features/timeline/components/timeline-markers.tsx
ClipWaveform measures container height with useLayoutEffect and passes pixelsPerSecond into useWaveform. TimelineMarkers adds MAJOR_TICK_HEIGHT/MINOR_TICK_HEIGHT, updates tick geometry and opacities, includes canvasHeight and per-tile width in tile cache keys, adjusts label shadowing, and simplifies background to a solid oklch(...) color.
Reference identity preservation in stores and utilities
src/features/timeline/stores/items-store.ts, src/features/timeline/utils/track-media-drop.ts
setTracks reuses existing track objects when element-identical and returns the prior state.tracks array reference for no-op updates; planTrackMediaDropPlacements returns the original params.tracks reference when placements are unchanged.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • walterlow/freecut#138: Related changes to the waveform loading/generation control flow and use-waveform gating.
  • walterlow/freecut#24: Overlaps in waveform persistence/WaveformCacheService behavior and multi-resolution waveform handling.

🐰 I measured waves at morning light,
Downsampled hills held scales just right,
No flicker now when clips return,
Cached levels hum and buffers learn,
Tracks keep their shape — a tidy sight.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 75.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title clearly summarizes the main changes: waveform rendering improvements via zoom-appropriate OPFS levels and ruler/timeline performance optimizations through better caching and re-render prevention.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch develop

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint skipped: no ESLint configuration detected in root package.json. To enable, add eslint to devDependencies.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/features/timeline/components/timeline-markers.tsx`:
- Around line 463-466: The cache key used for tile redraw short-circuiting
(built as cacheKey from quantizedPPS, fps, and canvasHeight) ignores the ruler's
rendered width, so canvas.dataset.ck can falsely indicate a match when only
display width changes; update the key construction (the cacheKey string) to
include displayWidth (or the last tile's effective width) and ensure the same
augmented key is written to canvas.dataset.ck so width-only changes force a
redraw (refer to cacheKey, quantizedPPS.toFixed, canvasHeight, fps, and
canvas.dataset.ck in timeline-markers.tsx).

In `@src/features/timeline/hooks/use-waveform.ts`:
- Around line 144-157: The async display-level load guard only checks mediaId,
so slow responses for prior zooms can still call setDisplayLevel; capture the
requested level index/token when starting the request (e.g. const
requestLevelIndex = levelIndex or create a combined requestToken) and compare it
against a persistent ref that tracks the latest requested level (add
lastLevelIndexRef or lastDisplayRequestRef similar to lastMediaIdRef) inside the
then() callback; if cancelled or lastMediaIdRef.current !== requestMediaId or
lastLevelIndexRef.current !== requestLevelIndex then return, otherwise call
setDisplayLevel(...) and setLevelProbed(true). Ensure you update the ref
whenever a new request is started.

In `@src/features/timeline/services/waveform-cache.ts`:
- Around line 307-325: The async load in request() can cache a level after
clearMedia()/clearAll() runs, resurrecting deleted media; add a per-media
invalidation token/version (e.g., a map keyed by mediaId incremented by
clearMedia/clearAll) and read the current token before calling
waveformOPFSStorage.getLevel(), capture it, then before calling
this.levelCache.add(key, result) verify the token still matches (drop the result
if it changed) so late OPFS completions don’t reinsert stale levels; update
clearMedia()/clearAll() to bump the token for the affected media.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 473acb96-32fb-4f2b-bcdb-bb6f37d1a8b4

📥 Commits

Reviewing files that changed from the base of the PR and between 13d3d8d and eb37582.

📒 Files selected for processing (9)
  • src/features/timeline/components/clip-waveform/index.tsx
  • src/features/timeline/components/timeline-markers.tsx
  • src/features/timeline/hooks/use-waveform.ts
  • src/features/timeline/services/sized-accessed-memory-cache.test.ts
  • src/features/timeline/services/sized-accessed-memory-cache.ts
  • src/features/timeline/services/waveform-cache.ts
  • src/features/timeline/services/waveform-opfs-storage.ts
  • src/features/timeline/stores/items-store.ts
  • src/features/timeline/utils/track-media-drop.ts

Comment thread src/features/timeline/components/timeline-markers.tsx
Comment on lines +144 to +157
let cancelled = false
const requestMediaId = mediaId
waveformCache
.getDisplayLevel(mediaId, levelIndex)
.then((level) => {
if (cancelled || lastMediaIdRef.current !== requestMediaId) return
if (level) {
setDisplayLevel(level)
}
// Mark probed regardless: a null result means no persisted level, so
// the generation effect should take over. Keep any previously-shown
// level on null so a transient miss doesn't blank the clip.
setLevelProbed(true)
})
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.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Ignore stale display-level loads after zoom changes.

This guard only checks mediaId, so a slower request for an older levelIndex can still call setDisplayLevel after the user has zoomed again. That leaves the clip rendering the wrong resolution until another threshold change happens. Track the latest requested level/token and discard late completions that no longer match it.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/features/timeline/hooks/use-waveform.ts` around lines 144 - 157, The
async display-level load guard only checks mediaId, so slow responses for prior
zooms can still call setDisplayLevel; capture the requested level index/token
when starting the request (e.g. const requestLevelIndex = levelIndex or create a
combined requestToken) and compare it against a persistent ref that tracks the
latest requested level (add lastLevelIndexRef or lastDisplayRequestRef similar
to lastMediaIdRef) inside the then() callback; if cancelled or
lastMediaIdRef.current !== requestMediaId or lastLevelIndexRef.current !==
requestLevelIndex then return, otherwise call setDisplayLevel(...) and
setLevelProbed(true). Ensure you update the ref whenever a new request is
started.

Comment thread src/features/timeline/services/waveform-cache.ts
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 28, 2026

Greptile Summary

This PR improves timeline waveform rendering and ruler performance by introducing zoom-appropriate OPFS resolution levels, fixing skeleton flash on clip remount, and reducing unnecessary re-renders across several code paths.

  • Waveform level rendering: use-waveform.ts and waveform-cache.ts add a new levelCache that loads only the OPFS resolution level suited to the current zoom, keeping zoomed-out clips from pinning large full-res peak arrays in memory; waveform-opfs-storage.ts adds chooseDisplayLevelForZoom to select the coarsest level that still looks smooth.
  • Skeleton flash fixes: height measurement in ClipWaveform moves to useLayoutEffect so the first measurement commits before paint; SizedAccessedMemoryCache now retains oversized entries instead of silently dropping them; memory budget raised to 128 MB so long-clip peaks survive remounts.
  • Re-render reduction: setTracks preserves object identity for unchanged tracks, planTrackMediaDropPlacements returns the original tracks reference when no track was added, and the ruler tile cache key now includes canvasHeight.

Confidence Score: 3/5

Generally safe to merge for the UI improvements, but two data-consistency issues in the waveform level cache should be resolved before shipping to production.

The new pendingLevelRequests map in WaveformCacheService is never consulted or invalidated inside clearMedia, so a level load in-flight at the moment media is cleared can silently re-populate levelCache with stale peaks for the old audio content. Separately, when levelIndex changes and the new OPFS level probe returns null (e.g. because clearMedia raced with a zoom transition), the old displayLevel reference keeps needsFullRes false and full-resolution generation never starts, leaving the clip stuck rendering the wrong level's coarser peaks.

waveform-cache.ts (clearMedia race with pending level requests) and use-waveform.ts (stale displayLevel blocking full-res fallback).

Important Files Changed

Filename Overview
src/features/timeline/hooks/use-waveform.ts Significant rewrite: adds zoom-appropriate OPFS level rendering with displayLevel/levelProbed states; a stale displayLevel from a prior zoom level can permanently suppress full-res generation if OPFS returns null for the new level after the old one was shown.
src/features/timeline/services/waveform-cache.ts Adds levelCache (64 MB) and getDisplayLevel/getDisplayLevelSync; clearMedia deletes level entries but does not cancel in-flight pendingLevelRequests, allowing a race that re-inserts stale levels after clearing. Memory budget raised from 20 MB to 128 MB.
src/features/timeline/services/waveform-opfs-storage.ts Adds chooseDisplayLevelForZoom alongside the existing chooseLevelForZoom; logic correctly returns full-res (level 0) above 16 px/sec and selects the coarsest sufficient level below that threshold.
src/features/timeline/services/sized-accessed-memory-cache.ts Removes early-exit for oversized entries; now evicts LRU entries until the new one fits (or cache is empty), then stores it even if it exceeds the budget. Well-covered by new tests.
src/features/timeline/stores/items-store.ts Preserves track object identity in setTracks when the caller passes unmodified stored objects; correctly uses reference equality to short-circuit normalization and skips returning a new array when element-wise identity holds after sorting.
src/features/timeline/utils/track-media-drop.ts Returns the original params.tracks reference when no tracks were added or changed, preventing a spurious setTracks call from re-rendering every track row on every media drop.
src/features/timeline/components/clip-waveform/index.tsx Switches height measurement from useEffect to useLayoutEffect to commit the initial measurement before paint and avoid skeleton flash on clip remount; adds pixelsPerSecond to useWaveform options.
src/features/timeline/components/timeline-markers.tsx Adds canvasHeight to the tile cache key (correct fix for height-change stale tiles), converts ticks to bottom-anchored constants, simplifies ruler background from gradient to flat, and removes vignette divs.
src/features/timeline/services/sized-accessed-memory-cache.test.ts New test file covering oversized-entry retention, LRU eviction ordering, replace without double-counting, delete accounting, and clear — confirming the intentional behavior of the modified eviction policy.

Sequence Diagram

sequenceDiagram
    participant CW as ClipWaveform
    participant UW as useWaveform
    participant WC as WaveformCacheService
    participant OPFS as WaveformOPFSStorage
    participant MC as SizedAccessedMemoryCache

    CW->>UW: pixelsPerSecond (zoom)
    UW->>UW: chooseDisplayLevelForZoom(pps) → levelIndex
    UW->>WC: getDisplayLevelSync(mediaId, levelIndex)
    WC->>MC: get(key) → CachedWaveformLevel or null

    alt Level in memory cache
        WC-->>UW: CachedWaveformLevel (sync)
        UW-->>CW: render downsampled peaks immediately
    else Not in memory
        UW->>WC: getDisplayLevel(mediaId, levelIndex)
        WC->>OPFS: getLevel(mediaId, levelIndex)
        OPFS-->>WC: Float32Array peaks
        WC->>MC: add(key, CachedWaveformLevel)
        WC-->>UW: CachedWaveformLevel
        UW-->>CW: render downsampled peaks
    else OPFS has no file (null)
        WC-->>UW: null, setLevelProbed(true)
        UW->>WC: getCachedWaveform / getWaveform (full-res generation)
        WC-->>UW: CachedWaveform (full-res)
        UW-->>CW: render full-res peaks
    end
Loading

Comments Outside Diff (1)

  1. src/features/timeline/services/waveform-cache.ts, line 1243-1256 (link)

    P1 Stale level re-inserted into cache after clearMedia

    clearMedia deletes all levelCache entries and then erases the OPFS file, but it never cancels or invalidates in-flight entries in pendingLevelRequests. If a getDisplayLevel call was dispatched just before (or concurrently with) clearMedia, its OPFS read can complete after the delete, and the .finally()levelCache.add(key, result) line re-inserts the old peaks under the same key — effectively un-clearing the entry. The same race already exists for pendingRequests / memoryCache, but this PR introduces the identical pattern for the level cache.

    The concrete failure is media relinking: clearMedia is called with the original ID, new waveform generation starts, but the concurrent level load for the old audio completes and repopulates levelCache. The timeline then renders old waveform peaks for the relinked clip until the entry is LRU-evicted.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: src/features/timeline/services/waveform-cache.ts
    Line: 1243-1256
    
    Comment:
    **Stale level re-inserted into cache after `clearMedia`**
    
    `clearMedia` deletes all `levelCache` entries and then erases the OPFS file, but it never cancels or invalidates in-flight entries in `pendingLevelRequests`. If a `getDisplayLevel` call was dispatched just before (or concurrently with) `clearMedia`, its OPFS read can complete after the delete, and the `.finally()``levelCache.add(key, result)` line re-inserts the old peaks under the same key — effectively un-clearing the entry. The same race already exists for `pendingRequests` / `memoryCache`, but this PR introduces the identical pattern for the level cache.
    
    The concrete failure is media relinking: `clearMedia` is called with the original ID, new waveform generation starts, but the concurrent level load for the old audio completes and repopulates `levelCache`. The timeline then renders old waveform peaks for the relinked clip until the entry is LRU-evicted.
    
    How can I resolve this? If you propose a fix, please make it concise.

    Fix in Claude Code Fix in Codex

Fix All in Claude Code Fix All in Codex

Prompt To Fix All With AI
Fix the following 3 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 3
src/features/timeline/services/waveform-cache.ts:1243-1256
**Stale level re-inserted into cache after `clearMedia`**

`clearMedia` deletes all `levelCache` entries and then erases the OPFS file, but it never cancels or invalidates in-flight entries in `pendingLevelRequests`. If a `getDisplayLevel` call was dispatched just before (or concurrently with) `clearMedia`, its OPFS read can complete after the delete, and the `.finally()``levelCache.add(key, result)` line re-inserts the old peaks under the same key — effectively un-clearing the entry. The same race already exists for `pendingRequests` / `memoryCache`, but this PR introduces the identical pattern for the level cache.

The concrete failure is media relinking: `clearMedia` is called with the original ID, new waveform generation starts, but the concurrent level load for the old audio completes and repopulates `levelCache`. The timeline then renders old waveform peaks for the relinked clip until the entry is LRU-evicted.

### Issue 2 of 3
src/features/timeline/hooks/use-waveform.ts:197-200
**Stale `displayLevel` suppresses full-res generation when `levelIndex` changes but OPFS level is unavailable**

`needsFullRes` is `false` whenever `displayLevel` is non-null. When `levelIndex` changes (zoom crosses a threshold), the old level's data stays in `displayLevel` until the new level loads — intentional for smooth transitions. However, if the async `getDisplayLevel` for the new `levelIndex` returns `null` (OPFS file absent, e.g. cleared between a zoom change and the probe completing), the `then` branch skips `setDisplayLevel`, leaving `displayLevel` holding the stale coarser level. With `levelProbed = true` and `displayLevel` non-null, `needsFullRes` stays `false` indefinitely, so full-resolution generation is never triggered and the clip is permanently stuck rendering the wrong level's peaks.

In practice the OPFS file stores all levels atomically, so this scenario is unlikely under normal conditions; the concern becomes real if `clearMedia` races with a zoom transition.

### Issue 3 of 3
src/features/timeline/services/waveform-cache.ts:40-54
**Memory budget jump from 20 MB → 128 MB (plus new 64 MB level cache)**

The combined resident ceiling is now 192 MB for waveform data alone, before any other allocations. On low-memory mobile or mid-range Android browsers the tab may be OOM-killed when several hours of waveform peaks are simultaneously resident. The comment acknowledges the calculation (~28.8 MB/hr stereo) but 128 MB allows ~4.5 hours of full-res peaks, which could be the whole project timeline. Consider whether a lower default (e.g. 64 MB) with graceful degradation to on-demand OPFS reads is more appropriate for constrained devices.

Reviews (1): Last reviewed commit: "fix(timeline): include canvasHeight in r..." | Re-trigger Greptile

Comment on lines +197 to 200
const needsFullRes = !useLevels || (levelProbed && !displayLevel)
useEffect(() => {
if (!enabled) {
if (!enabled || !needsFullRes) {
return
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.

P1 Stale displayLevel suppresses full-res generation when levelIndex changes but OPFS level is unavailable

needsFullRes is false whenever displayLevel is non-null. When levelIndex changes (zoom crosses a threshold), the old level's data stays in displayLevel until the new level loads — intentional for smooth transitions. However, if the async getDisplayLevel for the new levelIndex returns null (OPFS file absent, e.g. cleared between a zoom change and the probe completing), the then branch skips setDisplayLevel, leaving displayLevel holding the stale coarser level. With levelProbed = true and displayLevel non-null, needsFullRes stays false indefinitely, so full-resolution generation is never triggered and the clip is permanently stuck rendering the wrong level's peaks.

In practice the OPFS file stores all levels atomically, so this scenario is unlikely under normal conditions; the concern becomes real if clearMedia races with a zoom transition.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/features/timeline/hooks/use-waveform.ts
Line: 197-200

Comment:
**Stale `displayLevel` suppresses full-res generation when `levelIndex` changes but OPFS level is unavailable**

`needsFullRes` is `false` whenever `displayLevel` is non-null. When `levelIndex` changes (zoom crosses a threshold), the old level's data stays in `displayLevel` until the new level loads — intentional for smooth transitions. However, if the async `getDisplayLevel` for the new `levelIndex` returns `null` (OPFS file absent, e.g. cleared between a zoom change and the probe completing), the `then` branch skips `setDisplayLevel`, leaving `displayLevel` holding the stale coarser level. With `levelProbed = true` and `displayLevel` non-null, `needsFullRes` stays `false` indefinitely, so full-resolution generation is never triggered and the clip is permanently stuck rendering the wrong level's peaks.

In practice the OPFS file stores all levels atomically, so this scenario is unlikely under normal conditions; the concern becomes real if `clearMedia` races with a zoom transition.

How can I resolve this? If you propose a fix, please make it concise.

Fix in Claude Code Fix in Codex

Comment on lines +40 to +54
// Memory cache budget — the working-set ceiling for resident waveforms.
// Full-resolution peaks are ~28.8MB/hour (1000 samples/sec, stereo), so the
// old 20MB held under an hour total and a single long clip evicted everything
// else. 128MB keeps several hours of waveform resident so the clips around the
// viewport stay cached and remounts (e.g. dragging a clip to another track) hit
// the sync cache instead of reloading with a skeleton flash.
// Note: SizedAccessedMemoryCache retains entries larger than this budget rather
// than dropping them, so a single clip longer than ~4.5h is still cached (it
// just evicts the rest of the working set while resident).
const MAX_CACHE_SIZE_BYTES = 128 * 1024 * 1024 // 128MB
// Separate, smaller budget for downsampled display levels (see getDisplayLevel).
// These are what the timeline renders: a zoom-appropriate resolution level
// (e.g. 10–50 samples/sec when zoomed out) is a fraction of the full-res peaks,
// so the working set of visible clips stays tiny regardless of clip length.
const MAX_LEVEL_CACHE_SIZE_BYTES = 64 * 1024 * 1024 // 64MB
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.

P2 Memory budget jump from 20 MB → 128 MB (plus new 64 MB level cache)

The combined resident ceiling is now 192 MB for waveform data alone, before any other allocations. On low-memory mobile or mid-range Android browsers the tab may be OOM-killed when several hours of waveform peaks are simultaneously resident. The comment acknowledges the calculation (~28.8 MB/hr stereo) but 128 MB allows ~4.5 hours of full-res peaks, which could be the whole project timeline. Consider whether a lower default (e.g. 64 MB) with graceful degradation to on-demand OPFS reads is more appropriate for constrained devices.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/features/timeline/services/waveform-cache.ts
Line: 40-54

Comment:
**Memory budget jump from 20 MB → 128 MB (plus new 64 MB level cache)**

The combined resident ceiling is now 192 MB for waveform data alone, before any other allocations. On low-memory mobile or mid-range Android browsers the tab may be OOM-killed when several hours of waveform peaks are simultaneously resident. The comment acknowledges the calculation (~28.8 MB/hr stereo) but 128 MB allows ~4.5 hours of full-res peaks, which could be the whole project timeline. Consider whether a lower default (e.g. 64 MB) with graceful degradation to on-demand OPFS reads is more appropriate for constrained devices.

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code Fix in Codex

…ale display levels

- Guard getDisplayLevel with per-media/global generation tokens so a late OPFS
  read can't re-insert a level after clearMedia/clearAll
- Clear a stale display level on a null probe so full-res generation can take
  over instead of stranding the clip on the wrong level's peaks
- Include per-tile rendered width in the ruler tile cache key so a duration or
  viewport change at fixed zoom redraws the last partial tile
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 28, 2026

Actionable comments posted: 0

@walterlow walterlow merged commit 2f98baf into staging May 28, 2026
5 checks passed
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