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, } }