perf(studio): prioritize timeline thumbnail work

This commit is contained in:
Miguel Angel Simon Sierra
2026-08-05 20:13:53 -07:00
parent 5ae9a8a96a
commit 8e4e16ac6b
17 changed files with 667 additions and 207 deletions
@@ -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(
<EditorShell
left={null}
right={null}
timelineToolbar={null}
renderClipContent={() => 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());
});
});
+31 -5
View File
@@ -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,
@@ -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,
});
}
});
});
@@ -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,
],
);
}
@@ -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,72 @@ 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("warns once while retrying a timeline selection after preview 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();
await harness.rerender({
selectedElementId: "clip-2",
selectedElementIds,
timelineElements: [...timelineElements],
domEditSelection: null,
domEditGroupSelections: [],
buildDomSelectionForTimelineElement,
applyDomSelection,
applyMarqueeSelection,
onSelectionNotFound,
});
expect(onSelectionNotFound).toHaveBeenCalledOnce();
previewReady = true;
await harness.rerender({
selectedElementId: "clip-2",
selectedElementIds,
timelineElements: [...timelineElements],
domEditSelection: null,
domEditGroupSelections: [],
buildDomSelectionForTimelineElement,
applyDomSelection,
applyMarqueeSelection,
onSelectionNotFound,
});
expect(applyDomSelection).toHaveBeenCalledWith(secondSelection);
harness.cleanup();
});
});
@@ -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<string>, anchor: string | null): string[] {
@@ -56,34 +57,51 @@ 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("");
const missingSelectionKeyRef = 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 });
missingSelectionKeyRef.current = "";
if (previousSelectedKey.length > 0 && currentIds.length > 0) {
applyDomSelection(null, { revealPanel: false });
}
return;
}
if (selectionIdsMatch(currentIds, selectedIds, currentAnchor, selectedElementId)) {
missingSelectionKeyRef.current = "";
return;
}
if (selectionIdsMatch(currentIds, selectedIds, currentAnchor, selectedElementId)) return;
let cancelled = false;
const syncSelection = async () => {
@@ -101,11 +119,18 @@ 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) {
if (missingSelectionKeyRef.current !== selectedKey) {
missingSelectionKeyRef.current = selectedKey;
onSelectionNotFound();
}
return;
}
missingSelectionKeyRef.current = "";
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 +140,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,
@@ -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(
<AudioWaveform
audioUrl="/media/voice.wav"
label=""
labelColor="#fff"
projectId="project-a"
sessionEpoch={9}
priority="interaction"
/>,
);
});
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());
});
});
@@ -1,74 +1,105 @@
import { memo, useRef, useState, useCallback, useEffect } from "react";
import { memo, useCallback, useMemo, useRef } from "react";
import { useMountEffect } from "../../hooks/useMountEffect";
import { useThumbnailLease } from "../../hooks/useThumbnailLease";
import { createThumbnailKey, type ThumbnailPriority } from "../lib/thumbnailScheduler";
interface AudioWaveformProps {
audioUrl: string;
waveformUrl?: string;
label: string;
labelColor: string;
/**
* Fraction (01) of the source the clip starts at, after the media-start
* trim. Defaults to 0 (no front trim).
*/
trimStartFraction?: number;
/**
* Fraction (01) 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 (01). */
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<string, number[]>();
const decodeInFlight = new Map<string, Promise<number[]>>();
async function loadWaveform(
audioUrl: string,
waveformUrl: string | undefined,
signal: AbortSignal,
): Promise<number[]> {
try {
return waveformUrl
? await fetchWaveformPeaks(waveformUrl, signal)
: await decodeWaveformPeaks(audioUrl, signal);
} 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.
*/
async function fetchWaveformPeaks(url: string, signal: AbortSignal): Promise<number[]> {
const response = await fetch(url, { 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;
}
async function decodeWaveformPeaks(url: string, signal: AbortSignal): Promise<number[]> {
const response = await fetch(url, { 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();
}
}
/** Bounded waveform subscriber; cache, cancellation and dedupe live in one scheduler. */
export const AudioWaveform = memo(function AudioWaveform({
audioUrl,
waveformUrl,
@@ -76,130 +107,96 @@ export const AudioWaveform = memo(function AudioWaveform({
labelColor,
trimStartFraction,
trimEndFraction,
projectId,
sessionEpoch,
priority,
}: AudioWaveformProps) {
const containerRef = useRef<HTMLDivElement | null>(null);
const barsRef = useRef<HTMLDivElement | null>(null);
const roRef = useRef<ResizeObserver | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const observerRef = useRef<ResizeObserver | null>(null);
const cacheKey = waveformUrl ?? audioUrl;
const [peaks, setPeaks] = useState<number[] | null>(peaksCache.get(cacheKey) ?? null);
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;
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]);
// 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 += `<div style="position:absolute;bottom:0;left:${i * STEP}px;width:${BAR_W}px;height:${pct}%;background:rgba(75,163,210,${opacity})"></div>`;
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);
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.fillStyle = `rgba(75,163,210,${(0.45 + amplitude * 0.4).toFixed(2)})`;
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();
},
[],
);
useMountEffect(() => () => observerRef.current?.disconnect());
return (
<div ref={setContainerRef} className="absolute inset-0 overflow-hidden">
<div ref={barsRef} className="absolute left-0 right-0 bottom-0" style={{ top: 16 }} />
{/* Shimmer while decoding */}
{!peaks && (
<div className="absolute inset-0 overflow-hidden">
<canvas
ref={setCanvasRef}
className="absolute inset-x-0 bottom-0 w-full"
style={{ top: 16 }}
/>
{snapshot.status === "loading" && (
<div
className="absolute left-0 right-0 bottom-0 animate-pulse"
className="absolute inset-x-0 bottom-0 top-4 animate-pulse"
style={{
top: 16,
background:
"linear-gradient(90deg, rgba(255,255,255,0.02) 0%, rgba(255,255,255,0.05) 50%, rgba(255,255,255,0.02) 100%)",
}}
/>
)}
{label && (
<div className="absolute top-0 left-0 right-0 px-1.5 py-0.5 z-10">
<div className="absolute inset-x-0 top-0 z-10 px-1.5 py-0.5">
<span
className="text-[9px] font-semibold truncate block leading-tight"
className="block truncate text-[9px] font-semibold leading-tight"
style={{ color: labelColor, textShadow: "0 1px 3px rgba(0,0,0,0.9)" }}
>
{label}
@@ -64,7 +64,6 @@ export {
getTimelineScrollTopForGeometryChange,
getTimelineVisibleTimeRange,
} from "./timelineViewportGeometry";
export const Timeline = memo(function Timeline({
onSeek,
onDrillDown,
@@ -317,24 +316,25 @@ export const Timeline = memo(function Timeline({
toggleSelectedKeyframe,
});
const { clipIndex, renderTimeRange, pinnedClipIdentities } = useTimelineClipRenderWindow({
tracks,
viewport,
pixelsPerSecond: pps,
contentOrigin,
duration: displayDuration,
selectedElementId: selectedElementId ?? undefined,
draggedElementId: draggedClip ? getTimelineElementIdentity(draggedClip.element) : undefined,
resizingElementIds,
focusedElementId: timelineFocus.pinnedElementId,
focusedEaseElementId: focusedEaseSegment?.elementId,
clipContextMenuElementId: clipContextMenu
? getTimelineElementIdentity(clipContextMenu.element)
: undefined,
keyframeContextMenuElementId: kfContextMenu
? getTimelineElementIdentity(kfContextMenu.element)
: undefined,
});
const { clipIndex, renderTimeRange, visibleTimeRange, pinnedClipIdentities } =
useTimelineClipRenderWindow({
tracks,
viewport,
pixelsPerSecond: pps,
contentOrigin,
duration: displayDuration,
selectedElementId: selectedElementId ?? undefined,
draggedElementId: draggedClip ? getTimelineElementIdentity(draggedClip.element) : undefined,
resizingElementIds,
focusedElementId: timelineFocus.pinnedElementId,
focusedEaseElementId: focusedEaseSegment?.elementId,
clipContextMenuElementId: clipContextMenu
? getTimelineElementIdentity(clipContextMenu.element)
: undefined,
keyframeContextMenuElementId: kfContextMenu
? getTimelineElementIdentity(kfContextMenu.element)
: undefined,
});
useTimelineActiveClips({
scrollRef,
currentTime,
@@ -504,6 +504,7 @@ export const Timeline = memo(function Timeline({
rowsVirtualized={timelineFocus.rowVirtualizationActive}
clipIndex={clipIndex}
renderTimeRange={renderTimeRange}
visibleTimeRange={visibleTimeRange}
pinnedClipIdentities={pinnedClipIdentities}
trackOrder={trackOrder}
tracks={tracks}
@@ -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 },
)}
</TimelineClip>
</div>
@@ -76,10 +76,14 @@ function renderLanes(options: RenderLanesOptions = {}): {
host: HTMLDivElement;
root: Root;
rerender: (next: RenderLanesOptions) => void;
setSelectedElementId: ReturnType<typeof vi.fn>;
onSelectElement: ReturnType<typeof vi.fn>;
} {
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
const setSelectedElementId = vi.fn();
const onSelectElement = vi.fn();
const render = (next: RenderLanesOptions) => {
const elements = next.elements ?? [element("clip-a", TRACK_A)];
const gsapAnimations = next.animations ?? new Map<string, GsapAnimation[]>();
@@ -118,6 +122,7 @@ function renderLanes(options: RenderLanesOptions = {}): {
})}
clipIndex={createTimelineClipIndex(tracks)}
renderTimeRange={{ start: 0, end: Number.POSITIVE_INFINITY }}
visibleTimeRange={{ start: 0, end: Number.POSITIVE_INFINITY }}
pinnedClipIdentities={new Set()}
trackOrder={displayTrackOrder}
tracks={tracks}
@@ -137,7 +142,7 @@ function renderLanes(options: RenderLanesOptions = {}): {
setRangeSelection={vi.fn()}
setResizingClip={vi.fn()}
setDraggedClip={vi.fn()}
setSelectedElementId={vi.fn()}
setSelectedElementId={setSelectedElementId}
shiftClickClipRef={createRef()}
getPreviewElement={(el) => el}
getTrackStyle={getTrackStyle}
@@ -149,6 +154,7 @@ function renderLanes(options: RenderLanesOptions = {}): {
onTogglePropertyGroupKeyframe={vi.fn()}
onResizeElement={vi.fn()}
onMoveElement={vi.fn()}
onSelectElement={onSelectElement}
onRazorSplit={vi.fn()}
onRazorSplitAll={vi.fn()}
/>,
@@ -156,7 +162,7 @@ function renderLanes(options: RenderLanesOptions = {}): {
});
};
render(options);
return { host, root, rerender: render };
return { host, root, rerender: render, setSelectedElementId, onSelectElement };
}
function visibilityLabels(host: HTMLElement): (string | null)[] {
@@ -347,3 +353,19 @@ describe("TimelineLanes disclosure target", () => {
act(() => view.root.unmount());
});
});
describe("TimelineLanes selection", () => {
it("keeps a selected clip selected when it is clicked again", () => {
const selected = element("clip-a", TRACK_A);
const view = renderLanes({
elements: [selected],
selectedElementIds: new Set([selected.id]),
});
act(() => view.host.querySelector<HTMLButtonElement>('[data-el-id="clip-a"]')?.click());
expect(view.setSelectedElementId).toHaveBeenCalledWith(selected.id);
expect(view.onSelectElement).toHaveBeenCalledWith(selected);
act(() => view.root.unmount());
});
});
@@ -22,7 +22,7 @@ import type { TimelineEditCallbacks } from "./timelineCallbacks";
import { trackStudioKeyframeLaneExpand } from "../../telemetry/events";
import { SPLIT_BOUNDARY_EPSILON_S } from "../../utils/timelineElementSplit";
import { isAudioTimelineElement, isMusicTrack } from "../../utils/timelineInspector";
import { renderClipChildren } from "./timelineClipChildren";
import { renderClipChildren, resolveClipRenderContext } from "./timelineClipChildren";
import { TimelineTrackRow } from "./TimelineTrackRow";
import { isTimelineClipActive } from "./useTimelineActiveClips";
import { queryTimelineClipIndex } from "../lib/timelineClipIndex";
@@ -57,6 +57,7 @@ export function TimelineLanes({
rowsVirtualized,
clipIndex,
renderTimeRange,
visibleTimeRange,
pinnedClipIdentities,
trackOrder,
tracks,
@@ -323,8 +324,7 @@ export function TimelineLanes({
const isSelected =
selectedElementId === elementKey || selectedElementIds.has(elementKey);
const isComposition = !!el.compositionSrc;
// The element identity is already unique per clip. Never fold in the map
// index, or a splice/reorder remounts every clip at/after the change.
// Element identity stays stable across clip splices and reorders.
const clipKey = elementKey;
const isDraggingClip =
draggedClip?.started === true &&
@@ -332,6 +332,11 @@ export function TimelineLanes({
getTimelineElementIdentity(draggedElement) === elementKey;
if (isDraggingClip) return null;
const previewElement = getPreviewElement(el);
const renderContext = resolveClipRenderContext(
previewElement,
visibleTimeRange,
isSelected || hoveredClip === clipKey || pinnedClipIdentities.has(clipKey),
);
// Passenger of a live multi-drag: preserve the formation without changing
// the passenger's timeline data until the owning drag commits.
const isPassenger =
@@ -475,15 +480,9 @@ 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);
// Clip selection is idempotent; empty timeline space owns deselection.
setSelectedElementId(elementKey);
onSelectElement?.(el);
}
}
onDoubleClick={(e) => {
@@ -497,6 +496,7 @@ export function TimelineLanes({
clipStyle,
renderClipContent,
renderClipOverlay,
renderContext,
)}
</TimelineClip>
);
@@ -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> | void;
@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { resolveClipRenderContext } from "./timelineClipChildren";
import type { TimelineElement } from "../store/playerStore";
const clip: TimelineElement = {
id: "clip",
tag: "video",
start: 10,
duration: 5,
track: 0,
};
describe("resolveClipRenderContext", () => {
it("prioritizes interactive clips and enables rich thumbnails", () => {
expect(resolveClipRenderContext(clip, { start: 0, end: 1 }, true)).toEqual({
priority: "interaction",
rich: true,
});
});
it("distinguishes visible clips from overscan clips", () => {
expect(resolveClipRenderContext(clip, { start: 14, end: 16 }, false)).toEqual({
priority: "visible",
rich: false,
});
expect(resolveClipRenderContext(clip, { start: 15, end: 20 }, false)).toEqual({
priority: "overscan",
rich: false,
});
});
});
@@ -1,6 +1,20 @@
import { type ReactNode } from "react";
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
import type { TimelineTimeRange } from "../lib/timelineClipIndex";
import type { TrackVisualStyle } from "./timelineIcons";
import type { TimelineClipRenderContext } from "./TimelineTypes";
export function resolveClipRenderContext(
element: TimelineElement,
visibleTimeRange: TimelineTimeRange,
interactive: boolean,
): TimelineClipRenderContext {
if (interactive) return { priority: "interaction", rich: true };
const visible =
element.start < visibleTimeRange.end &&
element.start + element.duration > visibleTimeRange.start;
return { priority: visible ? "visible" : "overscan", rich: false };
}
function ClipLintDot({ element }: { element: TimelineElement }) {
const lint = usePlayerStore((s) => s.lintFindingsByElement.get(element.key ?? element.id));
@@ -18,9 +32,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 +50,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.
<div className="absolute inset-0 overflow-hidden" style={{ borderRadius: "inherit" }}>
{renderClipContent(element, clipStyle)}
{renderClipContent(element, clipStyle, context)}
</div>
)}
</>
@@ -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 the scroll container ({@link import("./TimelineCanvas")}) and
@@ -33,6 +34,7 @@ export interface TimelineLaneBaseProps {
rowsVirtualized: boolean;
clipIndex: TimelineClipIndex;
renderTimeRange: TimelineTimeRange;
visibleTimeRange: TimelineTimeRange;
pinnedClipIdentities: ReadonlySet<string>;
trackOrder: number[];
tracks: [number, TimelineElement[]][];
@@ -48,6 +50,7 @@ export interface TimelineLaneBaseProps {
renderClipContent?: (
element: TimelineElement,
style: { clip: string; label: string },
context: TimelineClipRenderContext,
) => ReactNode;
renderClipOverlay?: (element: TimelineElement) => ReactNode;
onDrillDown?: (element: TimelineElement) => void;
@@ -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 };
}