Skip to content
10 changes: 7 additions & 3 deletions src/features/timeline/components/clip-waveform/index.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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(
Expand Down
54 changes: 29 additions & 25 deletions src/features/timeline/components/timeline-markers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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

Expand All @@ -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()
}
Expand All @@ -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++) {
Expand All @@ -182,7 +187,7 @@ function drawTile(

ctx.beginPath()
ctx.moveTo(tickX, minorTickTop)
ctx.lineTo(tickX, minorTickBottom)
ctx.lineTo(tickX, canvasHeight)
ctx.stroke()
}
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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}`
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Store config in refs so the imperative scroll handler can access them
const displayWidthRef = useRef(displayWidth)
Expand Down Expand Up @@ -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)

Expand All @@ -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.
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand All @@ -931,16 +945,6 @@ export const TimelineMarkers = memo(function TimelineMarkers({
style={{ contain: 'layout style paint' }}
/>

{/* Vignette effects */}
<div
className="absolute inset-y-0 left-0 w-8 pointer-events-none"
style={{ background: 'linear-gradient(to right, oklch(0.15 0 0 / 0.15), transparent)' }}
/>
<div
className="absolute inset-y-0 right-0 w-8 pointer-events-none"
style={{ background: 'linear-gradient(to left, oklch(0.15 0 0 / 0.15), transparent)' }}
/>

{/* Full ruler highlight between in/out points */}
{safeInPoint !== null && safeOutPoint !== null && (
<div
Expand Down
134 changes: 117 additions & 17 deletions src/features/timeline/hooks/use-waveform.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { useState, useEffect, useRef, useEffectEvent } from 'react'
import { waveformCache, type CachedWaveform } from '../services/waveform-cache'
import {
waveformCache,
type CachedWaveform,
type CachedWaveformLevel,
} from '../services/waveform-cache'
import { chooseDisplayLevelForZoom } from '../services/waveform-opfs-storage'
import { getPreviewStartupDelayMs, schedulePreviewWork } from './preview-work-budget'
import { createLogger } from '@/shared/logging/logger'

Expand All @@ -16,6 +21,12 @@ interface UseWaveformOptions {
enabled?: boolean
/** Source/media duration used to size the deferred startup budget */
deferDurationSec?: number
/**
* Current timeline zoom (pixels/sec). Selects which downsampled resolution
* level to render so memory stays bounded regardless of clip length. Omit to
* render the full-resolution peaks (legacy behavior).
*/
pixelsPerSecond?: number
}

interface UseWaveformResult {
Expand All @@ -42,31 +53,52 @@ interface UseWaveformResult {
}

/**
* Hook for managing waveform data for an audio clip
* Hook for managing waveform data for an audio clip.
*
* - Loads cached waveforms when visible, even before a blobUrl is available
* - Only generates missing waveforms when visible and has valid blobUrl
* - Subscribes to progressive updates for streaming loading
* - Caches results in memory and OPFS
* - Defers startup until interaction/idle budget allows so new clips do not
* block creation gestures
* - Sync cache check on mount to avoid skeleton flash when moving clips
* Rendering source priority:
* 1. A zoom-appropriate downsampled level from the persisted OPFS
* multi-resolution file (small, bounded memory) — preferred when available.
* 2. The full-resolution peaks (memory cache → IndexedDB/OPFS → worker
* generation) — used while a waveform is still being generated, or for media
* that has no persisted multi-resolution file yet.
*
* Loading a level instead of the full-res peaks keeps only a fraction of the
* data resident when zoomed out, so a long clip no longer pins tens of MB in
* memory — and a clip that remounts (e.g. dragged to another track) renders
* from the synchronously-cached level without a skeleton flash.
*/
export function useWaveform({
mediaId,
blobUrl,
isVisible,
enabled = true,
deferDurationSec = 0,
pixelsPerSecond,
}: UseWaveformOptions): UseWaveformResult {
// State for waveform data - initialize from memory cache to avoid skeleton flash
// This is important when clips move across tracks (component remounts but cache persists)
// Which downsampled level the current zoom wants. When pixelsPerSecond is
// omitted, force the full-res path by treating the level as unavailable.
const useLevels = pixelsPerSecond !== undefined
const levelIndex = chooseDisplayLevelForZoom(pixelsPerSecond ?? 0)

// Preferred display source: a single downsampled level. Seeded synchronously
// so a remount with an already-loaded level shows no skeleton.
const [displayLevel, setDisplayLevel] = useState<CachedWaveformLevel | 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<boolean>(
() =>
!useLevels || (enabled && waveformCache.getDisplayLevelSync(mediaId, levelIndex) !== null),
)

// Full-resolution fallback state (generation + progressive streaming).
const [waveform, setWaveform] = useState<CachedWaveform | null>(() => {
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
})
Expand All @@ -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)
})
Comment on lines +144 to +157
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.

.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) => {
Expand All @@ -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
Comment on lines +197 to 200
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

}

Expand Down Expand Up @@ -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(() => {
Expand All @@ -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,
Expand All @@ -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,
}
Expand Down
Loading
Loading