From ad86a4e2bca2be8ed92339178359a95076c38fb6 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 18 Jul 2026 22:47:31 +0200 Subject: [PATCH] perf(studio): prioritize timeline thumbnail work --- .../src/hooks/useRenderClipContent.test.ts | 38 ++- .../studio/src/hooks/useRenderClipContent.ts | 56 +++- .../player/components/AudioWaveform.test.tsx | 53 ++++ .../src/player/components/AudioWaveform.tsx | 279 ++++++++---------- .../studio/src/player/components/Timeline.tsx | 36 +-- .../components/TimelineGestureOverlay.tsx | 3 + .../player/components/TimelineLaneTypes.ts | 3 + .../src/player/components/TimelineLanes.tsx | 29 +- .../src/player/components/TimelineTypes.ts | 6 + .../components/timelineClipChildren.tsx | 10 +- .../components/useTimelineClipRenderWindow.ts | 11 +- 11 files changed, 334 insertions(+), 190 deletions(-) create mode 100644 packages/studio/src/player/components/AudioWaveform.test.tsx diff --git a/packages/studio/src/hooks/useRenderClipContent.test.ts b/packages/studio/src/hooks/useRenderClipContent.test.ts index 2c33e27909..8342f180b9 100644 --- a/packages/studio/src/hooks/useRenderClipContent.test.ts +++ b/packages/studio/src/hooks/useRenderClipContent.test.ts @@ -5,6 +5,7 @@ import { createRoot } from "react-dom/client"; import { afterEach, describe, expect, it } from "vitest"; import { CompositionThumbnail, VideoThumbnail } from "../player"; import { AudioWaveform } from "../player/components/AudioWaveform"; +import type { TimelineClipRenderContext } from "../player/components/TimelineTypes"; import { usePlayerStore, type TimelineElement } from "../player/store/playerStore"; import { normalizeCompositionSrc } from "./useRenderClipContent"; import { useRenderClipContent } from "./useRenderClipContent"; @@ -68,6 +69,7 @@ describe("useRenderClipContent", () => { function renderClipContent( el: TimelineElement, activePreviewUrl: string | null = "/api/projects/my-project/preview", + context?: TimelineClipRenderContext, ): ReactNode { const host = document.createElement("div"); document.body.append(host); @@ -81,7 +83,7 @@ describe("useRenderClipContent", () => { activePreviewUrl, effectiveTimelineDuration: 12, }); - content = render(el, { clip: "#222", label: "#fff" }); + content = render(el, { clip: "#222", label: "#fff" }, context); return null; } @@ -169,4 +171,38 @@ describe("useRenderClipContent", () => { } } }); + + it("forwards the viewport priority and interaction detail to media work", () => { + usePlayerStore.setState({ thumbnailMode: "adaptive", timelineSessionEpoch: 7 }); + + const content = renderClipContent( + { + id: "clip-video", + tag: "video", + start: 0, + duration: 4, + track: 0, + src: "assets/clip.mp4", + }, + null, + { priority: "interaction", rich: true }, + ); + + expect( + isValidElement<{ + projectId: string; + sessionEpoch: number; + priority: string; + rich: boolean; + }>(content), + ).toBe(true); + if (isValidElement(content)) { + expect(content.props).toMatchObject({ + projectId: "my-project", + sessionEpoch: 7, + priority: "interaction", + rich: true, + }); + } + }); }); diff --git a/packages/studio/src/hooks/useRenderClipContent.ts b/packages/studio/src/hooks/useRenderClipContent.ts index c4377d25c3..51bf3e9711 100644 --- a/packages/studio/src/hooks/useRenderClipContent.ts +++ b/packages/studio/src/hooks/useRenderClipContent.ts @@ -2,6 +2,7 @@ import { useCallback, type ReactNode } from "react"; import { createElement } from "react"; import { CompositionThumbnail, VideoThumbnail } from "../player"; import type { TimelineElement } from "../player"; +import type { TimelineClipRenderContext } from "../player/components/TimelineTypes"; import { AudioWaveform } from "../player/components/AudioWaveform"; import { ImageThumbnail } from "../player/components/ImageThumbnail"; import { encodePreviewPath, resolveMediaPreviewUrl } from "../player/components/thumbnailUtils"; @@ -53,7 +54,13 @@ function trimFractions(el: TimelineElement): { start?: number; end?: number } { * Build the waveform element for an audio clip, windowing the rendered peaks to * the trimmed source slice so the bars track the clip edges. */ -function renderAudioClip(el: TimelineElement, pid: string, labelColor: string): ReactNode { +function renderAudioClip( + el: TimelineElement, + pid: string, + sessionEpoch: number, + labelColor: string, + context: TimelineClipRenderContext, +): ReactNode { const srcRelative = resolvePreviewRelative(el.src, pid); // Encode each path segment (spaces, parens, U+202F, unicode) so the URL matches // what the assets panel loads — a raw segment 404s. resolvePreviewRelative @@ -73,6 +80,9 @@ function renderAudioClip(el: TimelineElement, pid: string, labelColor: string): labelColor, trimStartFraction: start, trimEndFraction: end, + projectId: pid, + sessionEpoch, + priority: context.priority, }); } @@ -93,17 +103,24 @@ export function useRenderClipContent({ // App.tsx plumbing. Off by default -> plain clip bars, snappy timeline (#2428). const thumbnailMode = usePlayerStore((s) => s.thumbnailMode); const effectiveMode = effectiveThumbnailMode(thumbnailMode); + const sessionEpoch = usePlayerStore((s) => s.timelineSessionEpoch); return useCallback( // Pre-existing clip-content dispatcher; reduced by extracting renderAudioClip. // fallow-ignore-next-line complexity - (el: TimelineElement, style: { clip: string; label: string }): ReactNode => { + ( + el: TimelineElement, + style: { clip: string; label: string }, + context: TimelineClipRenderContext = { priority: "visible", rich: false }, + ): ReactNode => { const pid = projectIdRef.current; if (!pid) return null; // Thumbnail generation disabled (perf) -> plain clip bars. Audio still shows // its waveform (cheap, not a frame thumbnail). Toggle: timeline toolbar. if (effectiveMode === "hidden") { - return el.tag === "audio" ? renderAudioClip(el, pid, style.label) : null; + return el.tag === "audio" + ? renderAudioClip(el, pid, sessionEpoch, style.label, context) + : null; } let compSrc = el.compositionSrc; @@ -128,6 +145,10 @@ export function useRenderClipContent({ seekTime: 0, duration: el.duration, + projectId: pid, + sessionEpoch, + priority: context.priority, + rich: context.rich, }); } @@ -135,7 +156,7 @@ export function useRenderClipContent({ // activePreviewUrl thumbnail branch; audio rows need waveform data, not a // captured frame from the currently drilled composition preview. if (el.tag === "audio") { - return renderAudioClip(el, pid, style.label); + return renderAudioClip(el, pid, sessionEpoch, style.label, context); } // When drilled into a composition, render all inner elements via @@ -150,6 +171,10 @@ export function useRenderClipContent({ selectorIndex: el.selectorIndex, seekTime: el.start, duration: el.duration, + projectId: pid, + sessionEpoch, + priority: context.priority, + rich: context.rich, }); } @@ -169,6 +194,10 @@ export function useRenderClipContent({ imageSrc: mediaSrc, label: "", labelColor: style.label, + projectId: pid, + sessionEpoch, + priority: context.priority, + rich: context.rich, }); } return createElement(VideoThumbnail, { @@ -176,6 +205,12 @@ export function useRenderClipContent({ label: "", labelColor: style.label, duration: el.duration, + sourceStart: el.playbackStart, + sourceRangeDuration: el.duration * (el.playbackRate ?? 1), + projectId: pid, + sessionEpoch, + priority: context.priority, + rich: context.rich, }); } @@ -189,11 +224,22 @@ export function useRenderClipContent({ selectorIndex: el.selectorIndex, seekTime: el.start, duration: el.duration, + projectId: pid, + sessionEpoch, + priority: context.priority, + rich: context.rich, }); } return null; }, - [projectIdRef, compIdToSrc, activePreviewUrl, effectiveTimelineDuration, effectiveMode], + [ + projectIdRef, + compIdToSrc, + activePreviewUrl, + effectiveTimelineDuration, + effectiveMode, + sessionEpoch, + ], ); } diff --git a/packages/studio/src/player/components/AudioWaveform.test.tsx b/packages/studio/src/player/components/AudioWaveform.test.tsx new file mode 100644 index 0000000000..30e79037bc --- /dev/null +++ b/packages/studio/src/player/components/AudioWaveform.test.tsx @@ -0,0 +1,53 @@ +// @vitest-environment happy-dom + +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +const { leaseSpy } = vi.hoisted(() => ({ + leaseSpy: vi.fn((_request: unknown) => ({ status: "loading" as const })), +})); + +vi.mock("../../hooks/useThumbnailLease", () => ({ + useThumbnailLease: leaseSpy, +})); + +import { AudioWaveform } from "./AudioWaveform"; + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +afterEach(() => { + leaseSpy.mockClear(); + document.body.innerHTML = ""; +}); + +describe("AudioWaveform", () => { + it("leases waveform decoding with the clip's project, session, and viewport priority", () => { + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + + act(() => { + root.render( + , + ); + }); + + expect(leaseSpy).toHaveBeenCalled(); + expect(leaseSpy.mock.calls.at(-1)?.[0]).toMatchObject({ + projectId: "project-a", + sessionEpoch: 9, + kind: "waveform", + priority: "interaction", + rich: false, + }); + + act(() => root.unmount()); + }); +}); diff --git a/packages/studio/src/player/components/AudioWaveform.tsx b/packages/studio/src/player/components/AudioWaveform.tsx index 2095ee1b6f..b464117e64 100644 --- a/packages/studio/src/player/components/AudioWaveform.tsx +++ b/packages/studio/src/player/components/AudioWaveform.tsx @@ -1,74 +1,97 @@ -import { memo, useRef, useState, useCallback, useEffect } from "react"; +import { memo, useCallback, useMemo, useRef } from "react"; +import { useThumbnailLease } from "../../hooks/useThumbnailLease"; +import { createThumbnailKey, type ThumbnailPriority } from "../lib/thumbnailScheduler"; interface AudioWaveformProps { audioUrl: string; waveformUrl?: string; label: string; labelColor: string; - /** - * Fraction (0–1) of the source the clip starts at, after the media-start - * trim. Defaults to 0 (no front trim). - */ trimStartFraction?: number; - /** - * Fraction (0–1) of the source the clip ends at. Defaults to 1 (no tail - * trim). Together these window the rendered peaks to the trimmed slice so the - * waveform tracks the clip edges instead of squeezing the whole file in. - */ trimEndFraction?: number; + projectId: string; + sessionEpoch: number; + priority: ThumbnailPriority; } -const BAR_W = 2; -const GAP = 1; -const STEP = BAR_W + GAP; +const BAR_WIDTH = 2; +const BAR_STEP = 3; -/** Downsample PCM channel data into peak amplitudes (0–1). */ function extractPeaks(channelData: Float32Array, barCount: number): number[] { const peaks: number[] = []; const samplesPerBar = Math.floor(channelData.length / barCount); if (samplesPerBar === 0) return Array(barCount).fill(0); - for (let i = 0; i < barCount; i++) { + for (let index = 0; index < barCount; index++) { let max = 0; - const start = i * samplesPerBar; + const start = index * samplesPerBar; const end = Math.min(start + samplesPerBar, channelData.length); - for (let j = start; j < end; j++) { - // fallow-ignore-next-line code-duplication - const abs = Math.abs(channelData[j] ?? 0); - if (abs > max) max = abs; + for (let sample = start; sample < end; sample++) { + max = Math.max(max, Math.abs(channelData[sample] ?? 0)); } peaks.push(max); } const maxPeak = Math.max(...peaks, 0.001); - return peaks.map((p) => p / maxPeak); + return peaks.map((peak) => peak / maxPeak); } -/** Deterministic fake waveform as fallback (matches demo app). */ function fakePeaks(url: string, count: number): number[] { let seed = 0; - for (let i = 0; i < url.length; i++) seed = ((seed << 5) - seed + url.charCodeAt(i)) | 0; + for (let index = 0; index < url.length; index++) { + seed = ((seed << 5) - seed + url.charCodeAt(index)) | 0; + } seed = Math.abs(seed) || 42; - const rand = () => { + const random = () => { seed = (seed * 16807) % 2147483647; return (seed & 0x7fffffff) / 2147483647; }; - const peaks: number[] = []; - for (let i = 0; i < count; i++) { - const t = i / count; - const envelope = 0.3 + 0.3 * Math.sin(t * Math.PI * 3.2) + 0.2 * Math.sin(t * Math.PI * 7.1); - peaks.push(Math.max(0.05, Math.min(1, envelope * (0.4 + 0.6 * rand())))); - } - return peaks; + return Array.from({ length: count }, (_, index) => { + const time = index / count; + const envelope = + 0.3 + 0.3 * Math.sin(time * Math.PI * 3.2) + 0.2 * Math.sin(time * Math.PI * 7.1); + return Math.max(0.05, Math.min(1, envelope * (0.4 + 0.6 * random()))); + }); } -// Module-level cache so decoded audio persists across re-renders and re-mounts -const peaksCache = new Map(); -const decodeInFlight = new Map>(); +async function loadWaveform( + audioUrl: string, + waveformUrl: string | undefined, + signal: AbortSignal, +) { + try { + if (waveformUrl) { + const response = await fetch(waveformUrl, { signal }); + if (!response.ok) throw new Error(`Waveform request failed (${response.status})`); + const data: unknown = await response.json(); + if ( + typeof data !== "object" || + data === null || + !("peaks" in data) || + !Array.isArray(data.peaks) || + !data.peaks.every((peak) => typeof peak === "number") + ) { + throw new Error("Invalid waveform response"); + } + return data.peaks; + } + const response = await fetch(audioUrl, { signal }); + if (!response.ok) throw new Error(`Audio request failed (${response.status})`); + const buffer = await response.arrayBuffer(); + if (signal.aborted) throw new DOMException("Aborted", "AbortError"); + const context = new AudioContext(); + try { + const decoded = await context.decodeAudioData(buffer); + if (signal.aborted) throw new DOMException("Aborted", "AbortError"); + return extractPeaks(decoded.getChannelData(0), 4000); + } finally { + await context.close(); + } + } catch (error) { + if (signal.aborted) throw error; + return fakePeaks(waveformUrl ?? audioUrl, 4000); + } +} -/** - * Audio waveform rendered from real PCM data via Web Audio API. - * Falls back to a deterministic fake pattern if decoding fails. - * Bars grow from bottom to top, rendered as CSS divs for zoom resilience. - */ +/** Bounded waveform subscriber; cache, cancellation and dedupe live in one scheduler. */ export const AudioWaveform = memo(function AudioWaveform({ audioUrl, waveformUrl, @@ -76,131 +99,89 @@ export const AudioWaveform = memo(function AudioWaveform({ labelColor, trimStartFraction, trimEndFraction, + projectId, + sessionEpoch, + priority, }: AudioWaveformProps) { - const containerRef = useRef(null); - const barsRef = useRef(null); - const roRef = useRef(null); + const canvasRef = useRef(null); + const observerRef = useRef(null); const cacheKey = waveformUrl ?? audioUrl; - const [peaks, setPeaks] = useState(peaksCache.get(cacheKey) ?? null); - - useEffect(() => { - if (peaks || !cacheKey) return; - - let cancelled = false; - - let promise = decodeInFlight.get(cacheKey); - if (!promise) { - promise = ( - waveformUrl - ? fetch(waveformUrl) - .then((r) => r.json()) - .then((d: { peaks?: number[] }) => { - if (!Array.isArray(d.peaks)) throw new Error("bad response"); - return d.peaks; - }) - : fetch(audioUrl) - .then((r) => r.arrayBuffer()) - .then((buf) => { - const ctx = new AudioContext(); - return ctx.decodeAudioData(buf).finally(() => ctx.close()); - }) - .then((decoded) => extractPeaks(decoded.getChannelData(0), 4000)) - ) - .catch(() => fakePeaks(cacheKey, 4000)) - .then((p) => { - peaksCache.set(cacheKey, p); - return p; - }) - .finally(() => decodeInFlight.delete(cacheKey)); - - decodeInFlight.set(cacheKey, promise); - } - - promise.then((p) => { - if (!cancelled) setPeaks(p); - }); - return () => { - cancelled = true; - }; - }, [audioUrl, waveformUrl, cacheKey, peaks]); + const request = useMemo( + () => ({ + key: createThumbnailKey({ kind: "waveform", source: cacheKey }), + projectId, + sessionEpoch, + kind: "waveform" as const, + priority, + rich: false, + load: async (signal: AbortSignal) => { + const peaks = await loadWaveform(audioUrl, waveformUrl, signal); + return { + value: { kind: "waveform" as const, peaks }, + weight: peaks.length * Float64Array.BYTES_PER_ELEMENT, + }; + }, + }), + [audioUrl, cacheKey, priority, projectId, sessionEpoch, waveformUrl], + ); + const snapshot = useThumbnailLease(cacheKey ? request : null); + const peaks = + snapshot.status === "ready" && snapshot.value.kind === "waveform" ? snapshot.value.peaks : null; - // Draw bars into the container using innerHTML (fast, zoom-resilient) const draw = useCallback(() => { - const container = containerRef.current; - const barsEl = barsRef.current; - if (!container || !barsEl || !peaks) return; - - // Window the peaks to the trimmed slice [start, end) of the source so the - // bars track the clip edges. Clamp to a valid, non-empty range. - const winStart = Math.max(0, Math.min(1, trimStartFraction ?? 0)); - const winEnd = Math.max(winStart, Math.min(1, trimEndFraction ?? 1)); - const lo = Math.floor(winStart * peaks.length); - const hi = Math.max(lo + 1, Math.ceil(winEnd * peaks.length)); - const span = hi - lo; - - // Fill the full (possibly zoomed) clip width with STEP-spaced bars, resampling - // the windowed peaks across them — upsampling (repeating peaks) when the clip - // is wider than the slice has samples, so the waveform stretches with zoom - // instead of stopping partway across. - const w = container.clientWidth || 400; - const barCount = Math.max(0, Math.floor(w / STEP)); - - let html = ""; - for (let i = 0; i < barCount; i++) { - // Map bar index to peak index within the windowed range (resample) - const peakIdx = lo + Math.min(span - 1, Math.floor((i / barCount) * span)); - const amp = peaks[peakIdx] ?? 0; - const pct = Math.max(3, Math.round(amp * 100)); - const opacity = (0.45 + amp * 0.4).toFixed(2); - html += `
`; + const canvas = canvasRef.current; + if (!canvas || !peaks) return; + const width = Math.max(1, canvas.clientWidth); + const height = Math.max(1, canvas.clientHeight); + const scale = window.devicePixelRatio || 1; + canvas.width = Math.ceil(width * scale); + canvas.height = Math.ceil(height * scale); + const context = canvas.getContext("2d"); + if (!context) return; + context.scale(scale, scale); + context.clearRect(0, 0, width, height); + const startFraction = Math.max(0, Math.min(1, trimStartFraction ?? 0)); + const endFraction = Math.max(startFraction, Math.min(1, trimEndFraction ?? 1)); + const start = Math.floor(startFraction * peaks.length); + const end = Math.max(start + 1, Math.ceil(endFraction * peaks.length)); + const span = end - start; + const barCount = Math.floor(width / BAR_STEP); + context.fillStyle = "rgba(75,163,210,0.78)"; + for (let index = 0; index < barCount; index++) { + const peakIndex = start + Math.min(span - 1, Math.floor((index / barCount) * span)); + const amplitude = peaks[peakIndex] ?? 0; + const barHeight = Math.max(2, amplitude * height); + context.fillRect(index * BAR_STEP, height - barHeight, BAR_WIDTH, barHeight); } - barsEl.innerHTML = html; - }, [peaks, trimStartFraction, trimEndFraction]); + }, [peaks, trimEndFraction, trimStartFraction]); - // Observe container size and redraw - const setContainerRef = useCallback( - (el: HTMLDivElement | null) => { - roRef.current?.disconnect(); - containerRef.current = el; - if (!el) return; + const setCanvasRef = useCallback( + (canvas: HTMLCanvasElement | null) => { + observerRef.current?.disconnect(); + canvasRef.current = canvas; + if (!canvas) return; draw(); - roRef.current = new ResizeObserver(() => draw()); - roRef.current.observe(el); + observerRef.current = new ResizeObserver(draw); + observerRef.current.observe(canvas); }, [draw], ); - // Redraw when peaks arrive - useEffect(() => { - draw(); - }, [draw]); - - useEffect( - () => () => { - roRef.current?.disconnect(); - }, - [], - ); - return ( -
-
- {/* Shimmer while decoding */} - {!peaks && ( -
+
+ + {snapshot.status === "loading" && ( +
)} {label && ( -
+
{label} diff --git a/packages/studio/src/player/components/Timeline.tsx b/packages/studio/src/player/components/Timeline.tsx index 826d369698..3e1eafea95 100644 --- a/packages/studio/src/player/components/Timeline.tsx +++ b/packages/studio/src/player/components/Timeline.tsx @@ -61,7 +61,6 @@ export { getTimelineScrollTopForGeometryChange, getTimelineVisibleTimeRange, } from "./timelineViewportGeometry"; - export const Timeline = memo(function Timeline({ onSeek, onDrillDown, @@ -310,23 +309,23 @@ export const Timeline = memo(function Timeline({ setKfContextMenu, toggleSelectedKeyframe, }); - - const { clipIndex, renderTimeRange, pinnedClipIdentities } = useTimelineClipRenderWindow({ - tracks, - viewport, - pixelsPerSecond: pps, - contentOrigin, - duration: displayDuration, - selectedElementId: selectedElementId ?? undefined, - draggedElementId: draggedClip?.element.key ?? draggedClip?.element.id, - resizingElementIds: - resizingClip?.groupPreview?.map((change) => change.key) ?? - (resizingClip ? [resizingClip.element.key ?? resizingClip.element.id] : undefined), - focusedElementId: pinnedElementId, - focusedEaseElementId: focusedEaseSegment?.elementId, - clipContextMenuElementId: clipContextMenu?.element.key ?? clipContextMenu?.element.id, - keyframeContextMenuElementId: kfContextMenu?.element.key ?? kfContextMenu?.element.id, - }); + const { clipIndex, renderTimeRange, visibleTimeRange, pinnedClipIdentities } = + useTimelineClipRenderWindow({ + tracks, + viewport, + pixelsPerSecond: pps, + contentOrigin, + duration: displayDuration, + selectedElementId: selectedElementId ?? undefined, + draggedElementId: draggedClip?.element.key ?? draggedClip?.element.id, + resizingElementIds: + resizingClip?.groupPreview?.map((change) => change.key) ?? + (resizingClip ? [resizingClip.element.key ?? resizingClip.element.id] : undefined), + focusedElementId: pinnedElementId, + focusedEaseElementId: focusedEaseSegment?.elementId, + clipContextMenuElementId: clipContextMenu?.element.key ?? clipContextMenu?.element.id, + keyframeContextMenuElementId: kfContextMenu?.element.key ?? kfContextMenu?.element.id, + }); useTimelineActiveClips({ scrollRef, currentTime, @@ -499,6 +498,7 @@ export const Timeline = memo(function Timeline({ rowsVirtualized={rowVirtualizationActive} clipIndex={clipIndex} renderTimeRange={renderTimeRange} + visibleTimeRange={visibleTimeRange} pinnedClipIdentities={pinnedClipIdentities} trackOrder={trackOrder} tracks={tracks} diff --git a/packages/studio/src/player/components/TimelineGestureOverlay.tsx b/packages/studio/src/player/components/TimelineGestureOverlay.tsx index 500d2c0ea2..3490f30abf 100644 --- a/packages/studio/src/player/components/TimelineGestureOverlay.tsx +++ b/packages/studio/src/player/components/TimelineGestureOverlay.tsx @@ -9,6 +9,7 @@ import { getTimelineDragOverlayPosition } from "./timelineClipDragPreview"; import type { DraggedClipState } from "./timelineClipDragTypes"; import type { TrackVisualStyle } from "./timelineIcons"; import { isTimelineClipActive } from "./useTimelineActiveClips"; +import type { TimelineClipRenderContext } from "./TimelineTypes"; interface TimelineGestureOverlayProps { drag: DraggedClipState | null; @@ -22,6 +23,7 @@ interface TimelineGestureOverlayProps { renderClipContent?: ( element: TimelineElement, style: { clip: string; label: string }, + context: TimelineClipRenderContext, ) => ReactNode; renderClipOverlay?: (element: TimelineElement) => ReactNode; } @@ -87,6 +89,7 @@ export const TimelineGestureOverlay = memo(function TimelineGestureOverlay({ getTrackStyle(element.tag), renderClipContent, renderClipOverlay, + { priority: "interaction", rich: true }, )}
diff --git a/packages/studio/src/player/components/TimelineLaneTypes.ts b/packages/studio/src/player/components/TimelineLaneTypes.ts index aa2157c3a6..44b18a9228 100644 --- a/packages/studio/src/player/components/TimelineLaneTypes.ts +++ b/packages/studio/src/player/components/TimelineLaneTypes.ts @@ -10,6 +10,7 @@ import type { TimelineClipIndex, TimelineTimeRange } from "../lib/timelineClipIn import type { TimelineRowGeometry } from "./timelineLayout"; import type { TimelineVirtualRow } from "./useTimelineVirtualRows"; import type { TimelineLogicalRow } from "./timelineKeyboardNavigation"; +import type { TimelineClipRenderContext } from "./TimelineTypes"; /** Props shared by TimelineCanvas and its lane renderer. */ export interface TimelineLaneBaseProps { @@ -27,6 +28,7 @@ export interface TimelineLaneBaseProps { rowsVirtualized: boolean; clipIndex: TimelineClipIndex; renderTimeRange: TimelineTimeRange; + visibleTimeRange: TimelineTimeRange; pinnedClipIdentities: ReadonlySet; trackOrder: number[]; tracks: [number, TimelineElement[]][]; @@ -42,6 +44,7 @@ export interface TimelineLaneBaseProps { renderClipContent?: ( element: TimelineElement, style: { clip: string; label: string }, + context: TimelineClipRenderContext, ) => ReactNode; renderClipOverlay?: (element: TimelineElement) => ReactNode; onDrillDown?: (element: TimelineElement) => void; diff --git a/packages/studio/src/player/components/TimelineLanes.tsx b/packages/studio/src/player/components/TimelineLanes.tsx index 33be905d00..19675490d7 100644 --- a/packages/studio/src/player/components/TimelineLanes.tsx +++ b/packages/studio/src/player/components/TimelineLanes.tsx @@ -26,6 +26,7 @@ import { queryTimelineClipIndex } from "../lib/timelineClipIndex"; import type { TimelineLogicalRow } from "./timelineKeyboardNavigation"; import { timelineClipFocusId } from "./timelineNavigationIdentity"; import { useTimelineKeyboardActor } from "./useTimelineKeyboardActor"; +import type { TimelineClipRenderContext } from "./TimelineTypes"; interface TimelineLanesProps extends TimelineLaneBaseProps { /** Live-derived by TimelineCanvas from {@link TimelineLaneBaseProps.draggedClip}. */ @@ -53,6 +54,7 @@ export function TimelineLanes({ rowsVirtualized, clipIndex, renderTimeRange, + visibleTimeRange, pinnedClipIdentities, trackOrder, tracks, @@ -170,11 +172,7 @@ export function TimelineLanes({ const ts = trackStyles.get(trackNum) ?? getTrackStyle(""); const isPendingTrack = draggedClip?.started === true && !trackOrder.includes(trackNum) && els.length === 0; - // All lanes use the same uniform color — no alternating stripes. const rowBackground = theme.rowBackground; - // The beat-dot strip occupies the top of this track's lane (active track, - // or the music track when nothing is selected). When shown, keyframe - // diamonds shrink + drop to the bottom half so they don't collide with it. const beatStripOnTrack = (beatAnalysis?.beatTimes?.length ?? 0) >= 2 && (selectedElementId @@ -182,9 +180,6 @@ export function TimelineLanes({ : els.some(isMusicTrack)); const isTrackHidden = els.length > 0 && els.every((element) => element.hidden === true); const isAudioTrack = els.length > 0 && els.some(isAudioTimelineElement); - // The one keyframed element this track shows lanes for (selected, else - // most lanes). A track can hold several elements; scoping to one keeps - // their keyframes from cramming into a single row. const keyframeClip = STUDIO_KEYFRAMES_ENABLED ? resolveTrackKeyframeClip(els, laneCounts, selectedElementId, selectedElementIds) : null; @@ -241,9 +236,6 @@ export function TimelineLanes({ }} className="relative" onContextMenu={(e: React.MouseEvent) => { - // Clip / keyframe-diamond context menus preventDefault at the - // target before this bubble handler runs — respect them so a - // right-click on a clip never also opens the gap menu. if (e.defaultPrevented || !onContextMenuLane) return; const rect = e.currentTarget.getBoundingClientRect(); const time = (e.clientX - rect.left) / pps; @@ -295,9 +287,6 @@ export function TimelineLanes({ renderElements.map((el) => { const clipStyle = getTrackStyle(el.tag); const elementKey = el.key ?? el.id; - // Only the track's active keyframe clip shows expanded lanes; - // other clips (incl. siblings on a shared track) show compact - // diamonds on their own bar instead. const showsLanes = STUDIO_KEYFRAMES_ENABLED && elementKey === keyframeClipKey && @@ -312,6 +301,19 @@ export function TimelineLanes({ (draggedElement?.key ?? draggedElement?.id) === elementKey; if (isDraggingClip) return null; const previewElement = getPreviewElement(el); + const isInteractive = + isSelected || hoveredClip === clipKey || pinnedClipIdentities.has(clipKey); + const intersectsVisible = + previewElement.start < visibleTimeRange.end && + previewElement.start + previewElement.duration > visibleTimeRange.start; + const renderContext: TimelineClipRenderContext = { + priority: isInteractive + ? "interaction" + : intersectsVisible + ? "visible" + : "overscan", + rich: isInteractive, + }; const isPassenger = multiDragPreview != null && isMultiDragPassenger(clipKey, multiDragPreview); const passengerOffsetPx = isPassenger @@ -475,6 +477,7 @@ export function TimelineLanes({ clipStyle, renderClipContent, renderClipOverlay, + renderContext, )} ); diff --git a/packages/studio/src/player/components/TimelineTypes.ts b/packages/studio/src/player/components/TimelineTypes.ts index 0838e8dccb..2908f026e6 100644 --- a/packages/studio/src/player/components/TimelineTypes.ts +++ b/packages/studio/src/player/components/TimelineTypes.ts @@ -4,6 +4,11 @@ import type { TimelineDropCallbacks } from "./timelineCallbacks"; import type { TimelineTheme } from "./timelineTheme"; import type { TimelineEditOverrides } from "./useResolvedTimelineEditCallbacks"; +export interface TimelineClipRenderContext { + priority: "overscan" | "visible" | "interaction"; + rich: boolean; +} + export interface TimelineProps extends TimelineDropCallbacks, TimelineEditOverrides { /** Project-scoped reset boundary; soft source refreshes retain the same epoch. */ sessionEpoch?: number; @@ -12,6 +17,7 @@ export interface TimelineProps extends TimelineDropCallbacks, TimelineEditOverri renderClipContent?: ( element: TimelineElement, style: { clip: string; label: string }, + context: TimelineClipRenderContext, ) => ReactNode; renderClipOverlay?: (element: TimelineElement) => ReactNode; onDeleteElement?: (element: TimelineElement) => Promise | void; diff --git a/packages/studio/src/player/components/timelineClipChildren.tsx b/packages/studio/src/player/components/timelineClipChildren.tsx index 3482fbdc00..b5d14d20fb 100644 --- a/packages/studio/src/player/components/timelineClipChildren.tsx +++ b/packages/studio/src/player/components/timelineClipChildren.tsx @@ -1,6 +1,7 @@ import { type ReactNode } from "react"; import { usePlayerStore, type TimelineElement } from "../store/playerStore"; import type { TrackVisualStyle } from "./timelineIcons"; +import type { TimelineClipRenderContext } from "./TimelineTypes"; function ClipLintDot({ element }: { element: TimelineElement }) { const lint = usePlayerStore((s) => s.lintFindingsByElement.get(element.key ?? element.id)); @@ -18,9 +19,14 @@ export function renderClipChildren( element: TimelineElement, clipStyle: TrackVisualStyle, renderClipContent: - | ((element: TimelineElement, style: { clip: string; label: string }) => ReactNode) + | (( + element: TimelineElement, + style: { clip: string; label: string }, + context: TimelineClipRenderContext, + ) => ReactNode) | undefined, renderClipOverlay: ((element: TimelineElement) => ReactNode) | undefined, + context: TimelineClipRenderContext = { priority: "visible", rich: false }, ): ReactNode { return ( <> @@ -31,7 +37,7 @@ export function renderClipChildren( // diamonds hang outside its bounds), so the thumbnail layer must clip // itself to the clip's rounded corners or sharp corners poke out.
- {renderClipContent(element, clipStyle)} + {renderClipContent(element, clipStyle, context)}
)} diff --git a/packages/studio/src/player/components/useTimelineClipRenderWindow.ts b/packages/studio/src/player/components/useTimelineClipRenderWindow.ts index b173dc2217..3b534b8541 100644 --- a/packages/studio/src/player/components/useTimelineClipRenderWindow.ts +++ b/packages/studio/src/player/components/useTimelineClipRenderWindow.ts @@ -1,6 +1,9 @@ import { useMemo } from "react"; import { createTimelineClipIndex } from "../lib/timelineClipIndex"; -import { getTimelineRenderTimeRange } from "./timelineViewportGeometry"; +import { + getTimelineRenderTimeRange, + getTimelineVisibleTimeRange, +} from "./timelineViewportGeometry"; import type { TimelineScrollViewportSnapshot } from "./useTimelineScrollViewport"; interface UseTimelineClipRenderWindowInput { @@ -37,6 +40,10 @@ export function useTimelineClipRenderWindow({ () => getTimelineRenderTimeRange(viewport, pixelsPerSecond, contentOrigin, duration), [contentOrigin, duration, pixelsPerSecond, viewport], ); + const visibleTimeRange = useMemo( + () => getTimelineVisibleTimeRange(viewport, pixelsPerSecond, contentOrigin, duration), + [contentOrigin, duration, pixelsPerSecond, viewport], + ); const pinnedClipIdentities = useMemo( () => new Set( @@ -60,5 +67,5 @@ export function useTimelineClipRenderWindow({ selectedElementId, ], ); - return { clipIndex, renderTimeRange, pinnedClipIdentities }; + return { clipIndex, renderTimeRange, visibleTimeRange, pinnedClipIdentities }; }