From 7d2143cff29cebe716c9fec7d28993c1c7c99e72 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Mon, 20 Jul 2026 21:56:39 +0200 Subject: [PATCH] perf(studio): prioritize timeline thumbnail work --- .../EditorShell.selectionSync.test.tsx | 109 +++++++ .../studio/src/components/EditorShell.tsx | 36 ++- packages/studio/src/hooks/useDomSelection.ts | 2 +- .../src/hooks/useRenderClipContent.test.ts | 38 ++- .../studio/src/hooks/useRenderClipContent.ts | 56 +++- .../useTimelineSelectionPreviewSync.test.tsx | 70 ++++- .../hooks/useTimelineSelectionPreviewSync.ts | 42 ++- .../player/components/AudioWaveform.test.tsx | 53 ++++ .../src/player/components/AudioWaveform.tsx | 279 ++++++++---------- .../studio/src/player/components/Timeline.tsx | 46 +-- .../components/TimelineGestureOverlay.tsx | 3 + .../player/components/TimelineLaneTypes.ts | 3 + .../src/player/components/TimelineLanes.tsx | 43 ++- .../src/player/components/TimelineTypes.ts | 6 + .../components/timelineClipChildren.tsx | 10 +- .../components/useTimelineClipRenderWindow.ts | 11 +- 16 files changed, 580 insertions(+), 227 deletions(-) create mode 100644 packages/studio/src/components/EditorShell.selectionSync.test.tsx create mode 100644 packages/studio/src/player/components/AudioWaveform.test.tsx diff --git a/packages/studio/src/components/EditorShell.selectionSync.test.tsx b/packages/studio/src/components/EditorShell.selectionSync.test.tsx new file mode 100644 index 0000000000..8565f5dd40 --- /dev/null +++ b/packages/studio/src/components/EditorShell.selectionSync.test.tsx @@ -0,0 +1,109 @@ +// @vitest-environment happy-dom + +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { EditorShell } from "./EditorShell"; + +const hookMocks = vi.hoisted(() => ({ + useTimelineSelectionPreviewSync: vi.fn(), +})); + +vi.mock("../hooks/useTimelineSelectionPreviewSync", () => hookMocks); +vi.mock("../contexts/StudioContext", () => ({ + useStudioPlaybackContext: () => ({ + captionEditMode: false, + refreshKey: 0, + refreshPreviewDocumentVersion: vi.fn(), + timelineElements: [], + }), + useStudioShellContext: () => ({ + projectId: "project-1", + activeCompPath: "index.html", + setActiveCompPath: vi.fn(), + handlePreviewIframeRef: vi.fn(), + showToast: vi.fn(), + }), +})); +vi.mock("../contexts/DomEditContext", () => ({ + useDomEditActionsContext: () => ({ + handleTimelineElementSelect: vi.fn(), + buildDomSelectionForTimelineElement: vi.fn(), + applyDomSelection: vi.fn(), + applyMarqueeSelection: vi.fn(), + }), + useDomEditSelectionContext: () => ({ + domEditSelection: null, + domEditGroupSelections: [], + }), +})); +vi.mock("./nle/NLEContext", () => ({ + NLEProvider: ({ children }: { children: React.ReactNode }) => children, + useNLEContext: () => ({ + compositionStack: [], + updateCompositionStack: vi.fn(), + containerRef: { current: null }, + }), +})); +vi.mock("./nle/useTimelineEditCallbacks", () => ({ + useTimelineEditCallbacks: () => ({}), +})); +vi.mock("./nle/PreviewPane", () => ({ PreviewPane: () => null })); +vi.mock("./nle/PreviewOverlays", () => ({ PreviewOverlays: () => null })); +vi.mock("./nle/TimelinePane", () => ({ TimelinePane: () => null })); +vi.mock("../captions/components/CaptionTimeline", () => ({ CaptionTimeline: () => null })); +vi.mock("./StudioFeedbackBar", () => ({ StudioFeedbackBar: () => null })); + +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + +afterEach(() => { + document.body.innerHTML = ""; + hookMocks.useTimelineSelectionPreviewSync.mockClear(); +}); + +describe("EditorShell timeline selection sync", () => { + it("keeps the timeline store mirrored into the preview selection", () => { + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + + act(() => { + root.render( + null} + handleTimelineElementDelete={vi.fn()} + handleTimelineAssetDrop={vi.fn()} + handleTimelineFileDrop={vi.fn()} + handleTimelineElementMove={vi.fn()} + handleTimelineElementsMove={vi.fn()} + handleTimelineElementResize={vi.fn()} + handleTimelineGroupResize={vi.fn()} + handleToggleTrackHidden={vi.fn()} + handleBlockedTimelineEdit={vi.fn()} + handleTimelineElementSplit={vi.fn()} + handleRazorSplit={vi.fn()} + handleRazorSplitAll={vi.fn()} + setCompIdToSrc={vi.fn()} + setCompositionLoading={vi.fn()} + shouldShowMotionPath={false} + shouldShowSelectedDomBounds={false} + />, + ); + }); + + expect(hookMocks.useTimelineSelectionPreviewSync).toHaveBeenCalledOnce(); + expect(hookMocks.useTimelineSelectionPreviewSync).toHaveBeenCalledWith( + expect.objectContaining({ + activeCompPath: "index.html", + timelineElements: [], + domEditSelection: null, + domEditGroupSelections: [], + }), + ); + + act(() => root.unmount()); + }); +}); diff --git a/packages/studio/src/components/EditorShell.tsx b/packages/studio/src/components/EditorShell.tsx index fbf0688713..3e0079a13e 100644 --- a/packages/studio/src/components/EditorShell.tsx +++ b/packages/studio/src/components/EditorShell.tsx @@ -10,11 +10,12 @@ import { NLEProvider, useNLEContext } from "./nle/NLEContext"; import { CaptionTimeline } from "../captions/components/CaptionTimeline"; import { StudioFeedbackBar } from "./StudioFeedbackBar"; import { useStudioPlaybackContext, useStudioShellContext } from "../contexts/StudioContext"; -import { useDomEditActionsContext } from "../contexts/DomEditContext"; +import { useDomEditActionsContext, useDomEditSelectionContext } from "../contexts/DomEditContext"; import { TimelineEditProvider } from "../contexts/TimelineEditContext"; -import type { TimelineElement } from "../player"; +import { usePlayerStore, type TimelineElement } from "../player"; import type { BlockPreviewInfo } from "./sidebar/BlocksTab"; import type { GestureRecordingState } from "./editor/GestureRecordControl"; +import { useTimelineSelectionPreviewSync } from "../hooks/useTimelineSelectionPreviewSync"; type RenderClipContent = ( element: TimelineElement, @@ -99,10 +100,35 @@ export function EditorShell({ blockPreview, gestureOverlay, }: EditorShellProps) { - const { projectId, activeCompPath, setActiveCompPath, handlePreviewIframeRef } = + const { projectId, activeCompPath, setActiveCompPath, handlePreviewIframeRef, showToast } = useStudioShellContext(); - const { refreshKey, captionEditMode, refreshPreviewDocumentVersion } = useStudioPlaybackContext(); - const { handleTimelineElementSelect } = useDomEditActionsContext(); + const { refreshKey, captionEditMode, refreshPreviewDocumentVersion, timelineElements } = + useStudioPlaybackContext(); + const { + handleTimelineElementSelect, + buildDomSelectionForTimelineElement, + applyDomSelection, + applyMarqueeSelection, + } = useDomEditActionsContext(); + const { domEditSelection, domEditGroupSelections } = useDomEditSelectionContext(); + const selectedElementId = usePlayerStore((state) => state.selectedElementId); + const selectedElementIds = usePlayerStore((state) => state.selectedElementIds); + const reportTimelineSelectionNotFound = useCallback(() => { + showToast("The selected clip is not available in the preview yet.", "info"); + }, [showToast]); + + useTimelineSelectionPreviewSync({ + selectedElementId, + selectedElementIds, + timelineElements, + domEditSelection, + domEditGroupSelections, + activeCompPath, + buildDomSelectionForTimelineElement, + applyDomSelection, + applyMarqueeSelection, + onSelectionNotFound: reportTimelineSelectionNotFound, + }); const timelineEditCallbacks = useTimelineEditCallbacks({ handleTimelineElementMove, diff --git a/packages/studio/src/hooks/useDomSelection.ts b/packages/studio/src/hooks/useDomSelection.ts index 568c964228..acdea986ba 100644 --- a/packages/studio/src/hooks/useDomSelection.ts +++ b/packages/studio/src/hooks/useDomSelection.ts @@ -370,8 +370,8 @@ export function useDomSelection({ const handleTimelineElementSelect = useCallback( async (element: TimelineElement | null) => { - if (!STUDIO_INSPECTOR_PANELS_ENABLED) return; const seq = ++timelineSelectSeqRef.current; + if (!STUDIO_INSPECTOR_PANELS_ENABLED) return; if (!element) { applyDomSelection(null, { revealPanel: false }); return; 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 3cc7a56b88..14b05a5583 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, }); } @@ -92,17 +102,24 @@ export function useRenderClipContent({ // Self-sourced so the adaptive policy gates thumbnail generation without App plumbing. 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; @@ -127,6 +144,10 @@ export function useRenderClipContent({ seekTime: 0, duration: el.duration, + projectId: pid, + sessionEpoch, + priority: context.priority, + rich: context.rich, }); } @@ -134,7 +155,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 @@ -149,6 +170,10 @@ export function useRenderClipContent({ selectorIndex: el.selectorIndex, seekTime: el.start, duration: el.duration, + projectId: pid, + sessionEpoch, + priority: context.priority, + rich: context.rich, }); } @@ -168,6 +193,10 @@ export function useRenderClipContent({ imageSrc: mediaSrc, label: "", labelColor: style.label, + projectId: pid, + sessionEpoch, + priority: context.priority, + rich: context.rich, }); } return createElement(VideoThumbnail, { @@ -175,6 +204,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, }); } @@ -188,11 +223,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/hooks/useTimelineSelectionPreviewSync.test.tsx b/packages/studio/src/hooks/useTimelineSelectionPreviewSync.test.tsx index 038ae30dd5..efd62118b2 100644 --- a/packages/studio/src/hooks/useTimelineSelectionPreviewSync.test.tsx +++ b/packages/studio/src/hooks/useTimelineSelectionPreviewSync.test.tsx @@ -24,6 +24,7 @@ interface HarnessProps { options?: { revealPanel?: boolean; additive?: boolean; preserveGroup?: boolean }, ) => void; applyMarqueeSelection: (selections: DomEditSelection[], additive: boolean) => void; + onSelectionNotFound: () => void; } afterEach(() => { @@ -67,8 +68,8 @@ function makeSyncFixture() { const firstSelection = makeSelection("First", firstElement); const secondSelection = makeSelection("Second", secondElement); const timelineElements: TimelineElement[] = [ - { id: "clip-1", tag: "div", start: 0, duration: 1, track: 0 }, - { id: "clip-2", tag: "div", start: 1, duration: 1, track: 1 }, + { id: "clip-1", domId: "clip-1", tag: "div", start: 0, duration: 1, track: 0 }, + { id: "clip-2", domId: "clip-2", tag: "div", start: 1, duration: 1, track: 1 }, ]; const selectionById = new Map([ ["clip-1", firstSelection], @@ -96,6 +97,7 @@ describe("useTimelineSelectionPreviewSync", () => { buildDomSelectionForTimelineElement, applyDomSelection, applyMarqueeSelection, + onSelectionNotFound: vi.fn(), }); expect(applyMarqueeSelection).toHaveBeenCalledWith([secondSelection, firstSelection], false); @@ -108,6 +110,21 @@ describe("useTimelineSelectionPreviewSync", () => { const applyDomSelection = vi.fn(); const applyMarqueeSelection = vi.fn(); const harness = renderHarness(); + const buildDomSelectionForTimelineElement = vi.fn(async (element: TimelineElement) => { + return selectionById.get(element.id) ?? null; + }); + + await harness.rerender({ + selectedElementId: "clip-1", + selectedElementIds: new Set(["clip-1"]), + timelineElements, + domEditSelection: firstSelection, + domEditGroupSelections: [firstSelection], + buildDomSelectionForTimelineElement, + applyDomSelection, + applyMarqueeSelection, + onSelectionNotFound: vi.fn(), + }); await harness.rerender({ selectedElementId: null, @@ -115,15 +132,58 @@ describe("useTimelineSelectionPreviewSync", () => { timelineElements, domEditSelection: firstSelection, domEditGroupSelections: [firstSelection], - buildDomSelectionForTimelineElement: vi.fn(async (element: TimelineElement) => { - return selectionById.get(element.id) ?? null; - }), + buildDomSelectionForTimelineElement, applyDomSelection, applyMarqueeSelection, + onSelectionNotFound: vi.fn(), }); expect(applyDomSelection).toHaveBeenCalledWith(null, { revealPanel: false }); expect(applyMarqueeSelection).not.toHaveBeenCalled(); harness.cleanup(); }); + + it("retries a timeline selection after the preview timeline refreshes", async () => { + const { secondSelection, timelineElements } = makeSyncFixture(); + const applyDomSelection = vi.fn(); + const applyMarqueeSelection = vi.fn(); + const onSelectionNotFound = vi.fn(); + let previewReady = false; + const buildDomSelectionForTimelineElement = vi.fn(async () => + previewReady ? secondSelection : null, + ); + const selectedElementIds = new Set(["clip-2"]); + const harness = renderHarness(); + + await harness.rerender({ + selectedElementId: "clip-2", + selectedElementIds, + timelineElements, + domEditSelection: null, + domEditGroupSelections: [], + buildDomSelectionForTimelineElement, + applyDomSelection, + applyMarqueeSelection, + onSelectionNotFound, + }); + + expect(onSelectionNotFound).toHaveBeenCalledOnce(); + expect(applyDomSelection).not.toHaveBeenCalled(); + + previewReady = true; + await harness.rerender({ + selectedElementId: "clip-2", + selectedElementIds, + timelineElements: [...timelineElements], + domEditSelection: null, + domEditGroupSelections: [], + buildDomSelectionForTimelineElement, + applyDomSelection, + applyMarqueeSelection, + onSelectionNotFound, + }); + + expect(applyDomSelection).toHaveBeenCalledWith(secondSelection); + harness.cleanup(); + }); }); diff --git a/packages/studio/src/hooks/useTimelineSelectionPreviewSync.ts b/packages/studio/src/hooks/useTimelineSelectionPreviewSync.ts index 33281c4df1..c979994fa1 100644 --- a/packages/studio/src/hooks/useTimelineSelectionPreviewSync.ts +++ b/packages/studio/src/hooks/useTimelineSelectionPreviewSync.ts @@ -1,4 +1,4 @@ -import { useEffect, useMemo } from "react"; +import { useEffect, useMemo, useRef } from "react"; import type { TimelineElement } from "../player"; import type { DomEditSelection } from "../components/editor/domEditing"; import { resolveTimelineIdForSelection } from "../utils/studioHelpers"; @@ -18,6 +18,7 @@ interface UseTimelineSelectionPreviewSyncParams { options?: { revealPanel?: boolean; additive?: boolean; preserveGroup?: boolean }, ) => void; applyMarqueeSelection: (selections: DomEditSelection[], additive: boolean) => void; + onSelectionNotFound: () => void; } function orderSelectedIds(ids: Set, anchor: string | null): string[] { @@ -56,31 +57,43 @@ export function useTimelineSelectionPreviewSync({ buildDomSelectionForTimelineElement, applyDomSelection, applyMarqueeSelection, + onSelectionNotFound, }: UseTimelineSelectionPreviewSyncParams): void { const selectedIds = useMemo( () => orderSelectedIds(selectedElementIds, selectedElementId), [selectedElementId, selectedElementIds], ); const selectedKey = selectedIds.join("\0"); + const domEditSelectionRef = useRef(domEditSelection); + const domEditGroupSelectionsRef = useRef(domEditGroupSelections); + const lastSyncedSelectedKeyRef = useRef(""); + domEditSelectionRef.current = domEditSelection; + domEditGroupSelectionsRef.current = domEditGroupSelections; useEffect(() => { + const previousSelectedKey = lastSyncedSelectedKeyRef.current; + lastSyncedSelectedKeyRef.current = selectedKey; + const currentDomEditSelection = domEditSelectionRef.current; + const currentDomEditGroupSelections = domEditGroupSelectionsRef.current; const currentSelections = - domEditGroupSelections.length > 1 - ? domEditGroupSelections - : domEditSelection - ? [domEditSelection] + currentDomEditGroupSelections.length > 1 + ? currentDomEditGroupSelections + : currentDomEditSelection + ? [currentDomEditSelection] : []; const currentIds = currentSelections .map((selection) => resolveTimelineIdForSelection(selection, timelineElements, activeCompPath), ) .filter((id): id is string => Boolean(id)); - const currentAnchor = domEditSelection - ? resolveTimelineIdForSelection(domEditSelection, timelineElements, activeCompPath) + const currentAnchor = currentDomEditSelection + ? resolveTimelineIdForSelection(currentDomEditSelection, timelineElements, activeCompPath) : null; if (selectedIds.length === 0) { - if (currentSelections.length > 0) applyDomSelection(null, { revealPanel: false }); + if (previousSelectedKey.length > 0 && currentIds.length > 0) { + applyDomSelection(null, { revealPanel: false }); + } return; } if (selectionIdsMatch(currentIds, selectedIds, currentAnchor, selectedElementId)) return; @@ -101,11 +114,14 @@ export function useTimelineSelectionPreviewSync({ // shrunk set back and silently drop the members whose DOM node was not ready. // Bail instead; a later effect run (on timelineElements/DOM change) applies the // full set once every resolvable member has a live node. - if (selections.length < resolvableCount) return; + if (selections.length < resolvableCount) { + onSelectionNotFound(); + return; + } if (selections.length === 0) { applyDomSelection(null, { revealPanel: false }); } else if (selections.length === 1) { - applyDomSelection(selections[0], { revealPanel: false }); + applyDomSelection(selections[0]); } else { applyMarqueeSelection(selections, false); } @@ -115,13 +131,15 @@ export function useTimelineSelectionPreviewSync({ return () => { cancelled = true; }; + // DOM selection changes are read through refs. Depending on them directly + // would let the preview-to-timeline echo cancel an in-flight timeline click. + // eslint-disable-next-line react-hooks/exhaustive-deps }, [ activeCompPath, applyDomSelection, applyMarqueeSelection, buildDomSelectionForTimelineElement, - domEditGroupSelections, - domEditSelection, + onSelectionNotFound, selectedElementId, selectedIds, selectedKey, 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 e3aebcf01d..3e4b889832 100644 --- a/packages/studio/src/player/components/Timeline.tsx +++ b/packages/studio/src/player/components/Timeline.tsx @@ -64,7 +64,6 @@ export { getTimelineScrollTopForGeometryChange, getTimelineVisibleTimeRange, } from "./timelineViewportGeometry"; - export const Timeline = memo(function Timeline({ onSeek, onDrillDown, @@ -183,7 +182,6 @@ export const Timeline = memo(function Timeline({ const ppsRef = useRef(100); const durationRef = useRef(effectiveDuration); durationRef.current = effectiveDuration; - // Declared before the fitPps derivation so the edit-pin wrappers can close over it. const fitPpsRef = useRef(100); const { @@ -321,27 +319,28 @@ export const Timeline = memo(function Timeline({ lastScrollLeftRef, contentOrigin, }); - 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), - revealElementId: clipRevealRequest?.elementId, - focusedEaseElementId: focusedEaseSegment?.elementId, - clipContextMenuElementId: clipContextMenu?.element.key ?? clipContextMenu?.element.id, - keyframeContextMenuElementId: kfContextMenu?.element.key ?? kfContextMenu?.element.id, - scrollRef, - elements: expandedElements, - rowGeometry: displayLayout.rowGeometry, - allowHorizontalReveal: zoomMode === "manual", - sessionEpoch, - }); + 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), + revealElementId: clipRevealRequest?.elementId, + focusedEaseElementId: focusedEaseSegment?.elementId, + clipContextMenuElementId: clipContextMenu?.element.key ?? clipContextMenu?.element.id, + keyframeContextMenuElementId: kfContextMenu?.element.key ?? kfContextMenu?.element.id, + scrollRef, + elements: expandedElements, + rowGeometry: displayLayout.rowGeometry, + allowHorizontalReveal: zoomMode === "manual", + sessionEpoch, + }); useTimelineActiveClips({ scrollRef, currentTime, @@ -508,6 +507,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 28a683f26a..093a6fe656 100644 --- a/packages/studio/src/player/components/TimelineLaneTypes.ts +++ b/packages/studio/src/player/components/TimelineLaneTypes.ts @@ -9,6 +9,7 @@ import type { KeyframeCacheEntry, TimelineElement } from "../store/playerStore"; import type { TimelineClipIndex, TimelineTimeRange } from "../lib/timelineClipIndex"; import type { TimelineRowGeometry } from "./timelineLayout"; import type { TimelineVirtualRow } from "./useTimelineVirtualRows"; +import type { TimelineClipRenderContext } from "./TimelineTypes"; /** Props shared by TimelineCanvas and its lane renderer. */ export interface TimelineLaneBaseProps { @@ -24,6 +25,7 @@ export interface TimelineLaneBaseProps { rowsVirtualized: boolean; clipIndex: TimelineClipIndex; renderTimeRange: TimelineTimeRange; + visibleTimeRange: TimelineTimeRange; pinnedClipIdentities: ReadonlySet; trackOrder: number[]; tracks: [number, TimelineElement[]][]; @@ -39,6 +41,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 a9c95a2de9..8867c853a6 100644 --- a/packages/studio/src/player/components/TimelineLanes.tsx +++ b/packages/studio/src/player/components/TimelineLanes.tsx @@ -22,6 +22,7 @@ import type { TimelineLaneBaseProps } from "./TimelineLaneTypes"; import { TimelineTrackRow } from "./TimelineTrackRow"; import { isTimelineClipActive } from "./useTimelineActiveClips"; import { queryTimelineClipIndex } from "../lib/timelineClipIndex"; +import type { TimelineClipRenderContext } from "./TimelineTypes"; interface TimelineLanesProps extends TimelineLaneBaseProps { /** Live-derived by TimelineCanvas from {@link TimelineLaneBaseProps.draggedClip}. */ @@ -47,6 +48,7 @@ export function TimelineLanes({ rowsVirtualized, clipIndex, renderTimeRange, + visibleTimeRange, pinnedClipIdentities, trackOrder, tracks, @@ -140,11 +142,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 @@ -152,9 +150,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; @@ -207,9 +202,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; @@ -261,9 +253,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 && @@ -286,6 +275,19 @@ export function TimelineLanes({ // compositor transform on a same-geometry wrapper (absolute // inset-0 → identical offset parent, so the clip's own // left/top are preserved), plus the ghost's elevated z/opacity. + 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 @@ -424,15 +426,11 @@ export function TimelineLanes({ } return; } - // Plain click single-selects: drop any marquee multi-selection. - // Only a click on the PRIMARY selection toggles it off — a click - // on a marquee-selected clip narrows the selection to that clip. - const hadMultiSelection = selectedElementIds.size > 0; - usePlayerStore.getState().clearSelectedElementIds(); - const nextElement = - selectedElementId === elementKey && !hadMultiSelection ? null : el; - setSelectedElementId(nextElement ? elementKey : null); - onSelectElement?.(nextElement); + // A clip click is an idempotent selection. Empty timeline space + // owns deselection; toggling here made a second click look like a + // failed selection and could clear preview focus during iframe sync. + setSelectedElementId(elementKey); + onSelectElement?.(el); } } onDoubleClick={(e) => { @@ -446,6 +444,7 @@ export function TimelineLanes({ clipStyle, renderClipContent, renderClipOverlay, + renderContext, )} {STUDIO_KEYFRAMES_ENABLED && !showsLanes && 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 808d02436e..033a2ec5da 100644 --- a/packages/studio/src/player/components/useTimelineClipRenderWindow.ts +++ b/packages/studio/src/player/components/useTimelineClipRenderWindow.ts @@ -1,7 +1,10 @@ import { useMemo, type RefObject } from "react"; import { createTimelineClipIndex } from "../lib/timelineClipIndex"; import type { TimelineElement } from "../store/playerStore"; -import { getTimelineRenderTimeRange } from "./timelineViewportGeometry"; +import { + getTimelineRenderTimeRange, + getTimelineVisibleTimeRange, +} from "./timelineViewportGeometry"; import type { TimelineRowGeometry } from "./timelineLayout"; import type { TimelineScrollViewportSnapshot } from "./useTimelineScrollViewport"; import { useTimelineRevealClip } from "./useTimelineRevealClip"; @@ -50,6 +53,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( @@ -83,5 +90,5 @@ export function useTimelineClipRenderWindow({ viewportVersion: viewport, sessionEpoch, }); - return { clipIndex, renderTimeRange, pinnedClipIdentities }; + return { clipIndex, renderTimeRange, visibleTimeRange, pinnedClipIdentities }; }