diff --git a/src/features/timeline/components/clip-waveform/index.tsx b/src/features/timeline/components/clip-waveform/index.tsx
index be67d0e5..ad3e7902 100644
--- a/src/features/timeline/components/clip-waveform/index.tsx
+++ b/src/features/timeline/components/clip-waveform/index.tsx
@@ -1,4 +1,4 @@
-import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { TiledCanvas } from '../clip-filmstrip/tiled-canvas'
import { WaveformSkeleton } from './waveform-skeleton'
import { useWaveform } from '../../hooks/use-waveform'
@@ -89,8 +89,11 @@ export const ClipWaveform = memo(function ClipWaveform({
const conformStartedRef = useRef(false)
const { blobUrl, setBlobUrl, hasStartedLoadingRef, blobUrlVersion } = useMediaBlobUrl(mediaId)
- // Measure container height
- useEffect(() => {
+ // Measure container height. useLayoutEffect (not useEffect) so the initial
+ // measurement commits before paint: when a clip remounts under a new track
+ // (moving a segment across tracks), height would otherwise start at 0 for one
+ // painted frame and flash the loading skeleton even though peaks are cached.
+ useLayoutEffect(() => {
const container = containerRef.current
if (!container) return
@@ -193,6 +196,7 @@ export const ClipWaveform = memo(function ClipWaveform({
isVisible,
enabled: audioCodecSupported,
deferDurationSec: sourceDuration,
+ pixelsPerSecond,
})
const normalizationPeak = maxPeak > 0 ? maxPeak : 1
const peakSampleCount = useMemo(
diff --git a/src/features/timeline/components/timeline-markers.tsx b/src/features/timeline/components/timeline-markers.tsx
index 7235c655..2b67c053 100644
--- a/src/features/timeline/components/timeline-markers.tsx
+++ b/src/features/timeline/components/timeline-markers.tsx
@@ -41,6 +41,11 @@ const TILE_WIDTH = 1000
const MAX_VISIBLE_MINOR_MARKERS = 72
const MIN_MINOR_TICK_SPACING_PX = 14
+// Tick marks rise from the ruler's bottom edge. Majors are taller to read as the
+// primary division; both stay short so they don't compete with the playhead/clip grid.
+const MAJOR_TICK_HEIGHT = 14
+const MINOR_TICK_HEIGHT = 7
+
// Quantize pixelsPerSecond for cache keys to avoid redrawing on every minor zoom change
// Uses logarithmic steps for perceptually uniform quantization across zoom range
function quantizePPSForCache(pps: number): number {
@@ -133,8 +138,8 @@ function drawTile(
// Use pre-computed marker interval (intervalInSeconds is already set correctly in config)
const intervalInSeconds = markerConfig.intervalInSeconds
const markerWidthPx = timeToPixels(intervalInSeconds)
- const minorTickTop = canvasHeight - 16
- const minorTickBottom = canvasHeight - 8
+ const majorTickTop = canvasHeight - MAJOR_TICK_HEIGHT
+ const minorTickTop = canvasHeight - MINOR_TICK_HEIGHT
if (markerWidthPx <= 0) return
@@ -156,13 +161,13 @@ function drawTile(
const absoluteX = timeToPixels(timeInSeconds)
const x = absoluteX - tileOffset // Convert to tile-relative coordinate
- // Major tick line - only draw if within tile bounds
+ // Major tick mark - bottom-anchored, only draw if within tile bounds
if (x >= 0 && x <= actualTileWidth) {
const lineX = Math.round(x) + 0.5
- ctx.strokeStyle = 'rgba(255, 255, 255, 0.25)'
+ ctx.strokeStyle = 'rgba(255, 255, 255, 0.30)'
ctx.lineWidth = 1
ctx.beginPath()
- ctx.moveTo(lineX, 0)
+ ctx.moveTo(lineX, majorTickTop)
ctx.lineTo(lineX, canvasHeight)
ctx.stroke()
}
@@ -173,7 +178,7 @@ function drawTile(
const lastTickX = x + tickSpacing * (markerConfig.minorTicks - 1)
if (lastTickX < 0 || x > actualTileWidth) continue
- ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)'
+ ctx.strokeStyle = 'rgba(255, 255, 255, 0.14)'
ctx.lineWidth = 1
for (let j = 1; j < markerConfig.minorTicks; j++) {
@@ -182,7 +187,7 @@ function drawTile(
ctx.beginPath()
ctx.moveTo(tickX, minorTickTop)
- ctx.lineTo(tickX, minorTickBottom)
+ ctx.lineTo(tickX, canvasHeight)
ctx.stroke()
}
}
@@ -259,7 +264,7 @@ function syncLabels(
span.style.top = '2px'
span.style.fontFamily = 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace'
span.style.fontFeatureSettings = '"tnum"'
- span.style.textShadow = '1px 1px 0 rgba(0, 0, 0, 0.5)'
+ span.style.textShadow = '0 1px 2px rgba(0, 0, 0, 0.45)'
span.style.zIndex = '24'
container.appendChild(span)
pool.set(i, span)
@@ -455,8 +460,10 @@ export const TimelineMarkers = memo(function TimelineMarkers({
// This dramatically reduces redraws during continuous zoom
const quantizedPPS = quantizePPSForCache(pixelsPerSecond)
- // Cache key uses quantized PPS for better hit rate during zoom
- const cacheKey = `${quantizedPPS.toFixed(4)}-${fps}`
+ // Cache key uses quantized PPS for better hit rate during zoom. canvasHeight is
+ // included because tile tick geometry is drawn relative to it — without it, a
+ // ruler-height change (e.g. a new editor-density preset) would reuse stale tiles.
+ const cacheKey = `${quantizedPPS.toFixed(4)}-${fps}-${canvasHeight}`
// Store config in refs so the imperative scroll handler can access them
const displayWidthRef = useRef(displayWidth)
@@ -532,6 +539,14 @@ export const TimelineMarkers = memo(function TimelineMarkers({
const renderTimeToPixels = (time: number) => time * qPPS
const markerConfig = calculateMarkerInterval(qPPS)
+ // Per-tile cache key. The last (partial) tile's rendered width depends on
+ // displayWidth (`dw`), so a duration/viewport change at a fixed zoom would
+ // otherwise reuse a stale-width tile (ck alone is width-agnostic). Full
+ // tiles always resolve to TILE_WIDTH, so their key stays stable and the
+ // zoom-bucket reuse is preserved.
+ const tileKeyFor = (idx: number) =>
+ `${idx}-${ck}-${Math.round(Math.min(TILE_WIDTH, dw - idx * TILE_WIDTH))}`
+
for (let tileIndex = startTile; tileIndex <= endTile; tileIndex++) {
visibleTileIndices.add(tileIndex)
@@ -549,7 +564,7 @@ export const TimelineMarkers = memo(function TimelineMarkers({
}
// Existing canvases keep their transform — tileIndex is stable per pool entry
- const tileCacheKey = `${tileIndex}-${ck}`
+ const tileCacheKey = tileKeyFor(tileIndex)
// Skip redraw if this canvas already shows the correct content.
// data-ck tracks the cache key used for the last successful paint.
@@ -593,14 +608,14 @@ export const TimelineMarkers = memo(function TimelineMarkers({
// Pre-render adjacent tiles during idle
const maxTile = Math.ceil(dw / TILE_WIDTH) - 1
const adjacentTiles = [startTile - 1, endTile + 1].filter(
- (t) => t >= 0 && t <= maxTile && !tileCache.has(`${t}-${ck}`),
+ (t) => t >= 0 && t <= maxTile && !tileCache.has(tileKeyFor(t)),
)
if (adjacentTiles.length > 0) {
requestIdleCallback(
(deadline) => {
for (const adj of adjacentTiles) {
if (deadline.timeRemaining() < 10 || tileCacheRef.current !== tileCache) break
- const adjKey = `${adj}-${ck}`
+ const adjKey = tileKeyFor(adj)
if (tileCache.has(adjKey)) continue
const offscreen = document.createElement('canvas')
drawTile(offscreen, adj, TILE_WIDTH, ch, markerConfig, renderTimeToPixels, dw)
@@ -913,8 +928,7 @@ export const TimelineMarkers = memo(function TimelineMarkers({
className="border-b border-border/80 relative"
onMouseDown={handleMouseDown}
style={{
- background:
- 'linear-gradient(to bottom, oklch(0.22 0 0 / 0.30), oklch(0.22 0 0 / 0.20), oklch(0.22 0 0 / 0.10))',
+ background: 'oklch(0.22 0 0 / 0.22)',
userSelect: 'none',
height: EDITOR_LAYOUT_CSS_VALUES.timelineRulerHeight,
width: width ? `${width}px` : undefined,
@@ -931,16 +945,6 @@ export const TimelineMarkers = memo(function TimelineMarkers({
style={{ contain: 'layout style paint' }}
/>
- {/* Vignette effects */}
-
-
-
{/* Full ruler highlight between in/out points */}
{safeInPoint !== null && safeOutPoint !== null && (
(() =>
+ useLevels && enabled ? waveformCache.getDisplayLevelSync(mediaId, levelIndex) : null,
+ )
+ // Whether we've checked OPFS for a persisted level for the current media.
+ // Generation is gated on this so we never regenerate a clip that already has
+ // a persisted multi-resolution file.
+ const [levelProbed, setLevelProbed] = useState(
+ () =>
+ !useLevels || (enabled && waveformCache.getDisplayLevelSync(mediaId, levelIndex) !== null),
+ )
+
+ // Full-resolution fallback state (generation + progressive streaming).
const [waveform, setWaveform] = useState(() => {
return waveformCache.getFromMemoryCacheSync(mediaId)
})
const [isLoading, setIsLoading] = useState(false)
const [progress, setProgress] = useState(() => {
- // If we have cached data, start at 100%
const cached = waveformCache.getFromMemoryCacheSync(mediaId)
return cached?.isComplete ? 100 : 0
})
@@ -87,8 +119,52 @@ export function useWaveform({
setIsLoading(false)
setProgress(waveformCache.getFromMemoryCacheSync(mediaId)?.isComplete ? 100 : 0)
setError(null)
+ const seededLevel =
+ useLevels && enabled ? waveformCache.getDisplayLevelSync(mediaId, levelIndex) : null
+ setDisplayLevel(seededLevel)
+ setLevelProbed(!useLevels || seededLevel !== null)
+ }
+ }, [mediaId, useLevels, enabled, levelIndex])
+
+ // Load the zoom-appropriate display level. Re-runs when the level changes
+ // (zoom crossing a resolution threshold). The previous level stays visible
+ // until the new one loads, so zooming never flashes a skeleton.
+ useEffect(() => {
+ if (!useLevels || !enabled || !isVisible) {
+ return
+ }
+
+ const sync = waveformCache.getDisplayLevelSync(mediaId, levelIndex)
+ if (sync) {
+ setDisplayLevel(sync)
+ setLevelProbed(true)
+ return
}
- }, [mediaId])
+
+ let cancelled = false
+ const requestMediaId = mediaId
+ waveformCache
+ .getDisplayLevel(mediaId, levelIndex)
+ .then((level) => {
+ if (cancelled || lastMediaIdRef.current !== requestMediaId) return
+ // A null result means this zoom's level isn't persisted: clear any
+ // previously-shown (now stale) level so `needsFullRes` flips true and
+ // the full-resolution generation path takes over. Leaving a stale
+ // coarser level in place would keep `needsFullRes` false forever and
+ // permanently strand the clip on the wrong level's peaks.
+ setDisplayLevel(level)
+ setLevelProbed(true)
+ })
+ .catch((err) => {
+ if (cancelled || lastMediaIdRef.current !== requestMediaId) return
+ logger.warn(`Failed to load waveform display level for ${mediaId}`, err)
+ setLevelProbed(true)
+ })
+
+ return () => {
+ cancelled = true
+ }
+ }, [mediaId, levelIndex, isVisible, enabled, useLevels])
// Progress callback - using useEffectEvent so it doesn't need to be in effect deps
const onProgress = useEffectEvent((nextProgress: number) => {
@@ -114,9 +190,13 @@ export function useWaveform({
return unsubscribe
}, [mediaId, enabled])
- // Load waveform when visible and conditions are met
+ // Generate the full-resolution waveform — only when no persisted display
+ // level exists (levelProbed && !displayLevel) or when levels are disabled.
+ // A clip with a persisted multi-resolution file renders from its level and
+ // never reaches this path, so its full-res peaks stay off the heap.
+ const needsFullRes = !useLevels || (levelProbed && !displayLevel)
useEffect(() => {
- if (!enabled) {
+ if (!enabled || !needsFullRes) {
return
}
@@ -211,7 +291,7 @@ export function useWaveform({
}
cancelScheduledStart()
}
- }, [mediaId, blobUrl, isVisible, enabled, waveform?.isComplete, deferDurationSec])
+ }, [mediaId, blobUrl, isVisible, enabled, waveform?.isComplete, deferDurationSec, needsFullRes])
// Cleanup on unmount
useEffect(() => {
@@ -220,6 +300,26 @@ export function useWaveform({
}
}, [mediaId])
+ // Prefer the downsampled display level; fall back to full-resolution peaks
+ // while generating or for media without a persisted multi-resolution file.
+ if (displayLevel) {
+ return {
+ peaks: displayLevel.peaks,
+ duration: displayLevel.duration,
+ sampleRate: displayLevel.sampleRate,
+ channels: displayLevel.channels,
+ stereo: displayLevel.stereo,
+ maxPeak: displayLevel.maxPeak,
+ loadedSamples: displayLevel.loadedSamples,
+ isLoading: false,
+ progress: 100,
+ error: null,
+ }
+ }
+
+ // No level yet: show full-res if present, otherwise report loading until the
+ // OPFS probe (and any generation) resolves.
+ const loadingUntilResolved = useLevels && !levelProbed && isVisible && enabled && !waveform
return {
peaks: waveform?.peaks ?? null,
duration: waveform?.duration || 0,
@@ -228,7 +328,7 @@ export function useWaveform({
stereo: waveform?.stereo ?? false,
maxPeak: waveform?.maxPeak ?? 1,
loadedSamples: waveform?.loadedSamples ?? 0,
- isLoading,
+ isLoading: isLoading || loadingUntilResolved,
progress,
error,
}
diff --git a/src/features/timeline/services/sized-accessed-memory-cache.test.ts b/src/features/timeline/services/sized-accessed-memory-cache.test.ts
new file mode 100644
index 00000000..57107dfa
--- /dev/null
+++ b/src/features/timeline/services/sized-accessed-memory-cache.test.ts
@@ -0,0 +1,97 @@
+import { SizedAccessedMemoryCache } from './sized-accessed-memory-cache'
+
+interface Entry {
+ sizeBytes: number
+ lastAccessed: number
+ value: string
+}
+
+function makeEntry(value: string, sizeBytes: number): Entry {
+ return { value, sizeBytes, lastAccessed: Date.now() }
+}
+
+describe('SizedAccessedMemoryCache', () => {
+ it('stores and retrieves entries', () => {
+ const cache = new SizedAccessedMemoryCache(100)
+ cache.add('a', makeEntry('A', 10))
+ expect(cache.get('a')?.value).toBe('A')
+ expect(cache.get('missing')).toBeNull()
+ })
+
+ it('evicts the least-recently-accessed entry when over budget', async () => {
+ const cache = new SizedAccessedMemoryCache(100)
+ cache.add('a', makeEntry('A', 40))
+ cache.add('b', makeEntry('B', 40))
+
+ // Touch 'a' so 'b' becomes the least-recently-accessed. lastAccessed uses
+ // Date.now(), so advance the clock to guarantee a distinct timestamp.
+ await new Promise((resolve) => setTimeout(resolve, 2))
+ cache.get('a')
+
+ // Adding 'c' (40) pushes total to 120 > 100 → evict LRU ('b').
+ cache.add('c', makeEntry('C', 40))
+
+ expect(cache.get('a')?.value).toBe('A')
+ expect(cache.get('c')?.value).toBe('C')
+ expect(cache.get('b')).toBeNull()
+ })
+
+ it('replaces an existing key without double-counting its size', () => {
+ const cache = new SizedAccessedMemoryCache(100)
+ cache.add('a', makeEntry('A', 30))
+ cache.add('b', makeEntry('B', 30))
+ // Re-add 'a' with a larger size; 'b' should survive (30 + 60 = 90 <= 100).
+ cache.add('a', makeEntry('A2', 60))
+
+ expect(cache.get('a')?.value).toBe('A2')
+ expect(cache.get('b')?.value).toBe('B')
+ })
+
+ it('retains an entry larger than the whole budget instead of dropping it', () => {
+ // Regression: oversized items used to be silently rejected, so a long
+ // clip's waveform was never cached and reloaded (skeleton flash) on every
+ // remount. They must now be stored.
+ const cache = new SizedAccessedMemoryCache(100)
+ cache.add('small', makeEntry('S', 50))
+ cache.add('huge', makeEntry('H', 250))
+
+ expect(cache.get('huge')?.value).toBe('H')
+ // Adding the oversized entry evicts everything else to make room.
+ expect(cache.get('small')).toBeNull()
+ })
+
+ it('reclaims an oversized entry on the next add', () => {
+ const cache = new SizedAccessedMemoryCache(100)
+ cache.add('huge', makeEntry('H', 250))
+ expect(cache.get('huge')?.value).toBe('H')
+
+ // The next add evicts the oversized entry to get back toward budget.
+ cache.add('normal', makeEntry('N', 40))
+ expect(cache.get('normal')?.value).toBe('N')
+ expect(cache.get('huge')).toBeNull()
+ })
+
+ it('frees space on delete so later adds are not over-evicted', () => {
+ const cache = new SizedAccessedMemoryCache(100)
+ cache.add('a', makeEntry('A', 60))
+ cache.delete('a')
+ cache.add('b', makeEntry('B', 60))
+ cache.add('c', makeEntry('C', 40))
+
+ // a was deleted (freeing 60), so b (60) + c (40) = 100 both fit.
+ expect(cache.get('b')?.value).toBe('B')
+ expect(cache.get('c')?.value).toBe('C')
+ })
+
+ it('clear() empties the cache and resets accounting', () => {
+ const cache = new SizedAccessedMemoryCache(100)
+ cache.add('a', makeEntry('A', 60))
+ cache.clear()
+ expect(cache.get('a')).toBeNull()
+ // Accounting reset: a fresh 80-byte entry plus another 20 both fit (100).
+ cache.add('b', makeEntry('B', 80))
+ cache.add('c', makeEntry('C', 20))
+ expect(cache.get('b')?.value).toBe('B')
+ expect(cache.get('c')?.value).toBe('C')
+ })
+})
diff --git a/src/features/timeline/services/sized-accessed-memory-cache.ts b/src/features/timeline/services/sized-accessed-memory-cache.ts
index 7ec17e03..e201271e 100644
--- a/src/features/timeline/services/sized-accessed-memory-cache.ts
+++ b/src/features/timeline/services/sized-accessed-memory-cache.ts
@@ -20,16 +20,20 @@ export class SizedAccessedMemoryCache {
}
add(key: string, entry: TEntry): void {
- if (entry.sizeBytes > this.maxSizeBytes) {
- return
- }
-
const existing = this.entries.get(key)
if (existing) {
this.currentSizeBytes -= existing.sizeBytes
this.entries.delete(key)
}
+ // Evict LRU entries until the new entry fits (or the cache is empty).
+ // An entry larger than the whole budget is still stored once everything
+ // else has been evicted, temporarily exceeding maxSizeBytes; it is
+ // reclaimed on the next add. We intentionally do NOT reject oversized
+ // entries: this cache holds waveform peaks, and a single long clip's
+ // full-resolution peaks can exceed the budget. Dropping them meant such a
+ // clip was never cached and reloaded (with a skeleton flash) on every
+ // remount — e.g. when dragged to another track.
while (this.currentSizeBytes + entry.sizeBytes > this.maxSizeBytes && this.entries.size > 0) {
this.evictOldest()
}
diff --git a/src/features/timeline/services/waveform-cache.ts b/src/features/timeline/services/waveform-cache.ts
index 998ee5ad..5bcce922 100644
--- a/src/features/timeline/services/waveform-cache.ts
+++ b/src/features/timeline/services/waveform-cache.ts
@@ -37,8 +37,21 @@ import {
const logger = createLogger('WaveformCache')
-// Memory cache configuration
-const MAX_CACHE_SIZE_BYTES = 20 * 1024 * 1024 // 20MB
+// 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
const MAX_CONCURRENT_WAVEFORM_GENERATIONS = 1
const WAVEFORM_PROGRESS_NOTIFY_INTERVAL_MS = 120
const WAVEFORM_PROGRESS_NOTIFY_STEP = 2
@@ -62,6 +75,23 @@ export interface CachedWaveform {
isComplete: boolean
}
+/**
+ * A single downsampled resolution level used for timeline rendering. Unlike
+ * CachedWaveform this never holds full-resolution peaks unless the chosen zoom
+ * level is the highest one — when zoomed out it is a small fraction of the size.
+ */
+export interface CachedWaveformLevel {
+ peaks: Float32Array
+ sampleRate: number
+ channels: number
+ stereo: boolean
+ duration: number
+ maxPeak: number
+ loadedSamples: number
+ sizeBytes: number
+ lastAccessed: number
+}
+
export class AbortError extends Error {
constructor(message = 'Aborted') {
super(message)
@@ -89,6 +119,14 @@ type WaveformUpdateCallback = (waveform: CachedWaveform) => void
class WaveformCacheService {
private memoryCache = new SizedAccessedMemoryCache(MAX_CACHE_SIZE_BYTES)
+ private levelCache = new SizedAccessedMemoryCache(MAX_LEVEL_CACHE_SIZE_BYTES)
+ private pendingLevelRequests = new Map>()
+ // Generation tokens guard against clearMedia/clearAll racing with an in-flight
+ // getDisplayLevel: the async OPFS read captures the token at the start and
+ // drops its cache insert if the token moved on, so a late completion can't
+ // resurrect a just-cleared level.
+ private levelMediaGeneration = new Map()
+ private levelGlobalGeneration = 0
private pendingRequests = new Map()
private updateCallbacks = new Map>()
private workerRequestId = 0
@@ -239,6 +277,75 @@ class WaveformCacheService {
this.memoryCache.add(mediaId, data)
}
+ private levelCacheKey(mediaId: string, levelIndex: number): string {
+ return `${mediaId}:${levelIndex}`
+ }
+
+ private currentLevelToken(mediaId: string): string {
+ return `${this.levelGlobalGeneration}:${this.levelMediaGeneration.get(mediaId) ?? 0}`
+ }
+
+ /**
+ * Synchronously read a cached display level. Lets a remounting clip render
+ * immediately (no skeleton) when the level was already loaded this session.
+ */
+ getDisplayLevelSync(mediaId: string, levelIndex: number): CachedWaveformLevel | null {
+ return this.levelCache.get(this.levelCacheKey(mediaId, levelIndex))
+ }
+
+ /**
+ * Load a single downsampled resolution level for display, from the persisted
+ * OPFS multi-resolution file. Returns null when no persisted waveform exists
+ * (caller should fall back to the full-resolution generate/load path).
+ *
+ * This is what the timeline should render from: it keeps only a
+ * zoom-appropriate level resident (tiny when zoomed out) instead of the full
+ * 1000-samples/sec peaks, so display memory is bounded regardless of clip
+ * length. Max-pooling during downsampling preserves the global peak, so
+ * normalization is consistent across levels.
+ */
+ async getDisplayLevel(mediaId: string, levelIndex: number): Promise {
+ const key = this.levelCacheKey(mediaId, levelIndex)
+ const cached = this.levelCache.get(key)
+ if (cached) return cached
+
+ // De-dupe concurrent loads of the same level (e.g. several clips of the
+ // same media entering the viewport at once).
+ const inFlight = this.pendingLevelRequests.get(key)
+ if (inFlight) return inFlight
+
+ const tokenAtStart = this.currentLevelToken(mediaId)
+ const request = (async (): Promise => {
+ const level = await waveformOPFSStorage.getLevel(mediaId, levelIndex)
+ if (!level) return null
+
+ // If clearMedia/clearAll ran while we were reading OPFS, drop the result —
+ // re-inserting it would resurrect a just-cleared level.
+ if (this.currentLevelToken(mediaId) !== tokenAtStart) return null
+
+ const floatsPerSample = level.channels >= 2 ? 2 : 1
+ const sampleCount = level.peaks.length / floatsPerSample
+ const result: CachedWaveformLevel = {
+ peaks: level.peaks,
+ sampleRate: level.sampleRate,
+ channels: level.channels,
+ stereo: level.channels >= 2,
+ duration: level.sampleRate > 0 ? sampleCount / level.sampleRate : 0,
+ maxPeak: this.computeMaxPeak(level.peaks),
+ loadedSamples: level.peaks.length,
+ sizeBytes: level.peaks.byteLength,
+ lastAccessed: Date.now(),
+ }
+ this.levelCache.add(key, result)
+ return result
+ })().finally(() => {
+ this.pendingLevelRequests.delete(key)
+ })
+
+ this.pendingLevelRequests.set(key, request)
+ return request
+ }
+
private makeCachedWaveform(
peaks: Float32Array,
duration: number,
@@ -1149,8 +1256,15 @@ class WaveformCacheService {
* Clear waveform for a media item from all caches
*/
async clearMedia(mediaId: string): Promise {
- // Clear from memory cache
+ // Bump the token first so an in-flight getDisplayLevel can't re-insert a
+ // stale level after the deletes below.
+ this.levelMediaGeneration.set(mediaId, (this.levelMediaGeneration.get(mediaId) ?? 0) + 1)
+
+ // Clear from memory cache (full-res and all display levels)
this.memoryCache.delete(mediaId)
+ for (let levelIndex = 0; levelIndex < WAVEFORM_LEVELS.length; levelIndex++) {
+ this.levelCache.delete(this.levelCacheKey(mediaId, levelIndex))
+ }
// Clear from OPFS
await waveformOPFSStorage.delete(mediaId)
@@ -1164,7 +1278,12 @@ class WaveformCacheService {
* Clear all cached waveforms
*/
clearAll(): void {
+ // Invalidate every in-flight getDisplayLevel so a late OPFS completion can't
+ // re-insert a level we just cleared.
+ this.levelGlobalGeneration += 1
+ this.levelMediaGeneration.clear()
this.memoryCache.clear()
+ this.levelCache.clear()
}
/**
diff --git a/src/features/timeline/services/waveform-opfs-storage.ts b/src/features/timeline/services/waveform-opfs-storage.ts
index dd76c13e..68fe53c3 100644
--- a/src/features/timeline/services/waveform-opfs-storage.ts
+++ b/src/features/timeline/services/waveform-opfs-storage.ts
@@ -73,8 +73,8 @@ export interface MultiResolutionWaveform {
}
/**
- * Choose the best resolution level for a given pixelsPerSecond
- * Higher zoom (more pixels/sec) = higher resolution needed
+ * Choose a resolution level for range-based reads where bars are a few pixels
+ * wide (used by getWaveformRange/getWaveformLevel).
*/
export function chooseLevelForZoom(pixelsPerSecond: number): number {
// Bars are typically 2-3 pixels wide, so we want ~1 sample per 3 pixels
@@ -88,6 +88,36 @@ export function chooseLevelForZoom(pixelsPerSecond: number): number {
return WAVEFORM_LEVELS.length - 1
}
+// At or above this zoom we render full resolution (level 0). Any downsampled
+// level visibly loses transient detail when scrutinizing a waveform, so we only
+// drop below full-res at overview zoom, where the whole (often long) clip is on
+// screen — there the detail is imperceptible but the memory savings are largest
+// (16 px/s ≈ a full minute of audio across a ~960px viewport).
+const DISPLAY_FULL_RES_MIN_PIXELS_PER_SECOND = 16
+
+/**
+ * Choose the resolution level for rendering a full clip's waveform at a given
+ * zoom. Returns full resolution (level 0) whenever zoomed in enough to inspect
+ * detail; only at overview zoom does it step down to the coarsest level that
+ * still keeps comfortably more than one sample per pixel (so it stays smooth
+ * while using a fraction of the memory for a long clip).
+ */
+export function chooseDisplayLevelForZoom(pixelsPerSecond: number): number {
+ if (pixelsPerSecond >= DISPLAY_FULL_RES_MIN_PIXELS_PER_SECOND) {
+ return 0
+ }
+
+ const neededSamplesPerSecond = Math.max(1, pixelsPerSecond * 1.5)
+ // WAVEFORM_LEVELS is descending; walk from coarsest to finest and take the
+ // first (coarsest) level that still meets the needed density.
+ for (let i = WAVEFORM_LEVELS.length - 1; i >= 0; i--) {
+ if (WAVEFORM_LEVELS[i]! >= neededSamplesPerSecond) {
+ return i
+ }
+ }
+ return 0
+}
+
/**
* OPFS Waveform Storage Service
* Provides efficient multi-resolution binary storage with range-based access
diff --git a/src/features/timeline/stores/items-store.ts b/src/features/timeline/stores/items-store.ts
index 70c25f93..896b1dd5 100644
--- a/src/features/timeline/stores/items-store.ts
+++ b/src/features/timeline/stores/items-store.ts
@@ -132,10 +132,29 @@ export const useItemsStore = create()((set, get) => (
return withItemIndexes(normalizedItems, state)
}),
setTracks: (tracks) =>
- set({
- tracks: [...tracks]
- .map((track) => normalizeTrack(track))
- .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)),
+ set((state) => {
+ // Preserve object identity for tracks that didn't change. normalizeTrack
+ // always allocates a new object, so without this every setTracks call
+ // (drop, mute, rename, reorder, resize) gives every track a fresh
+ // reference — breaking the identity-based memo on TimelineTrack
+ // (areTrackPropsEqual) and re-rendering every track row. When the caller
+ // passes the existing stored object, normalization is a no-op re-clone, so
+ // reuse the previous reference.
+ const previousById = new Map(state.tracks.map((track) => [track.id, track]))
+ const nextTracks = tracks
+ .map((track) => {
+ const previous = previousById.get(track.id)
+ return previous === track ? previous : normalizeTrack(track)
+ })
+ .sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
+
+ // If the result is element-wise identical to the current tracks, keep the
+ // same array reference so `s.tracks` selectors don't fire at all.
+ const unchanged =
+ nextTracks.length === state.tracks.length &&
+ nextTracks.every((track, index) => track === state.tracks[index])
+
+ return { tracks: unchanged ? state.tracks : nextTracks }
}),
// Add item
diff --git a/src/features/timeline/utils/track-media-drop.ts b/src/features/timeline/utils/track-media-drop.ts
index 4529693d..4496892c 100644
--- a/src/features/timeline/utils/track-media-drop.ts
+++ b/src/features/timeline/utils/track-media-drop.ts
@@ -243,9 +243,19 @@ export function planTrackMediaDropPlacements(params: {
currentPosition = placements[0]!.from + entry.durationInFrames
}
+ // Preserve the original tracks reference when planning added/changed no track.
+ // workingTracks starts as a fresh copy (`[...params.tracks]`), so returning it
+ // unconditionally would make callers see a "changed" tracks array on every drop
+ // and trigger a redundant setTracks() — which reclones every track object and
+ // forces all timeline track rows to re-render. Returning the original reference
+ // lets the no-op case short-circuit (dropResult.tracks === currentTracks).
+ const tracksUnchanged =
+ workingTracks.length === params.tracks.length &&
+ workingTracks.every((track, index) => track === params.tracks[index])
+
return {
plannedItems,
- tracks: workingTracks,
+ tracks: tracksUnchanged ? params.tracks : workingTracks,
}
}