perf(studio): virtualize timeline clip windows

This commit is contained in:
Miguel Angel Simon Sierra
2026-07-19 00:16:00 +02:00
parent 5c0922bf65
commit 804cdced4f
12 changed files with 622 additions and 227 deletions
@@ -6,7 +6,6 @@ import { useExpandedTimelineElements } from "../hooks/useExpandedTimelineElement
import { defaultTimelineTheme } from "./timelineTheme";
import { useTimelineRangeSelection } from "./useTimelineRangeSelection";
import { useTimelinePlayhead } from "./useTimelinePlayhead";
import { useTimelineActiveClips } from "./useTimelineActiveClips";
import { useTimelineZoom } from "./useTimelineZoom";
import { useTimelineAssetDrop } from "./timelineDragDrop";
import { TimelineEmptyState } from "./TimelineEmptyState";
@@ -41,8 +40,9 @@ import { useTimelineShiftModifier } from "./useTimelineShiftModifier";
import { useTimelineTicks } from "./useTimelineTicks";
import { getTimelineElementIndexes } from "../lib/timelineElementIndexes";
import { useTimelineRowVirtualization } from "./useTimelineRowVirtualization";
import { useTimelineClipRenderWindow } from "./useTimelineClipRenderWindow";
import { useTimelineActiveClips } from "./useTimelineActiveClips";
// Re-export pure utilities so existing imports from "./Timeline" still resolve.
export {
generateTicks,
formatTimelineTickLabel,
@@ -119,9 +119,8 @@ export const Timeline = memo(function Timeline({
const selectedElementId = usePlayerStore((s) => s.selectedElementId);
const selectedElementIds = usePlayerStore((s) => s.selectedElementIds);
const clipRevealRequest = usePlayerStore((s) => s.clipRevealRequest);
const focusedEaseSegment = usePlayerStore((s) => s.focusedEaseSegment);
const gsapAnimations = usePlayerStore((s) => s.gsapAnimations);
// Label mode = comp has keyframed clips (not just when expanded): keeps the layer
// disclosure + property column visible and reserves a GUTTER before 0s (Figma).
const hasKeyframedClips = useMemo(
() => hasKeyframedTimelineClips(gsapAnimations),
[gsapAnimations],
@@ -154,7 +153,6 @@ export const Timeline = memo(function Timeline({
containerRef.current = el;
}, []);
// Last horizontal scroll offset, restored across the post-edit iframe reload (pinned zoom).
const lastScrollLeftRef = useRef(0);
const effectiveDuration = useMemo(
@@ -302,28 +300,46 @@ export const Timeline = memo(function Timeline({
toggleSelectedKeyframe,
});
const {
pps,
fitPps,
displayContentWidth,
displayDuration,
clipStateVersion,
zoomModeRef,
manualZoomPercentRef,
} = useTimelineGeometry({
viewportWidth: viewport.clientWidth,
effectiveDuration,
zoomMode,
manualZoomPercent,
ppsRef,
fitPpsRef,
draggedClip,
resizingClip,
expandedElements,
isDragging,
scrollRef,
lastScrollLeftRef,
const { pps, fitPps, displayContentWidth, displayDuration, zoomModeRef, manualZoomPercentRef } =
useTimelineGeometry({
viewportWidth: viewport.clientWidth,
effectiveDuration,
zoomMode,
manualZoomPercent,
ppsRef,
fitPpsRef,
draggedClip,
resizingClip,
expandedElements,
isDragging,
scrollRef,
lastScrollLeftRef,
contentOrigin,
});
const { clipIndex, renderTimeRange, pinnedClipIdentities } = useTimelineClipRenderWindow({
tracks,
viewport,
pixelsPerSecond: pps,
contentOrigin,
duration: displayDuration,
selectedElementId: selectedElementId ?? undefined,
draggedElementId: draggedClip?.element.key ?? draggedClip?.element.id,
resizingElementId: resizingClip?.element.key ?? resizingClip?.element.id,
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,
clipStateVersion: renderTimeRange,
elementStateVersion: expandedElements,
});
const laneGapStrips = useTimelineGapHighlights({
@@ -358,12 +374,6 @@ export const Timeline = memo(function Timeline({
onSeek,
contentOrigin,
});
useTimelineActiveClips({
scrollRef,
currentTime,
clipStateVersion,
});
const {
rangeSelection,
setRangeSelection,
@@ -396,8 +406,12 @@ export const Timeline = memo(function Timeline({
setRangeSelection(null),
);
const { major, minor } = useTimelineTicks(displayDuration, pps, timeDisplayMode);
const majorTickInterval = major.length >= 2 ? major[1] - major[0] : effectiveDuration;
const { major, minor, majorTickInterval } = useTimelineTicks(
displayDuration,
pps,
timeDisplayMode,
rowVirtualizationActive ? renderTimeRange : undefined,
);
const getPreviewElement = useCallback(
(element: TimelineElement): TimelineElement => getTimelinePreviewElement(element, resizingClip),
@@ -488,6 +502,9 @@ export const Timeline = memo(function Timeline({
rowGeometry={displayLayout.rowGeometry}
virtualRows={virtualRows}
rowsVirtualized={rowVirtualizationActive}
clipIndex={clipIndex}
renderTimeRange={renderTimeRange}
pinnedClipIdentities={pinnedClipIdentities}
trackOrder={trackOrder}
tracks={tracks}
trackStyles={trackStyles}
@@ -101,4 +101,73 @@ describe("Timeline row virtualization", () => {
act(() => root.unmount());
usePlayerStore.getState().reset();
});
it("windows clips and ruler cells while retaining an off-window selected clip", async () => {
const [{ Timeline }, { usePlayerStore }, { TIMELINE_VIEWPORT_BUDGETS }] = await Promise.all([
import("./Timeline"),
import("../store/playerStore"),
import("../lib/timelineViewportBudgets"),
]);
usePlayerStore.setState({
duration: 1_000,
timelineReady: true,
zoomMode: "manual",
manualZoomPercent: 2_000,
selectedElementId: "clip-490",
selectedElementIds: new Set(["clip-490"]),
elements: Array.from({ length: 500 }, (_, index) => ({
id: `clip-${index}`,
tag: "div",
start: index * 2,
duration: 1,
track: 0,
})),
});
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
await act(async () => root.render(React.createElement(Timeline, { sessionEpoch: 4 })));
await act(async () => {});
const initialClips = [...host.querySelectorAll<HTMLElement>("[data-clip]")];
const initialGridCells = host.querySelectorAll("[data-timeline-grid-cell]");
expect(initialClips.length).toBeGreaterThan(1);
expect(initialClips.length).toBeLessThanOrEqual(
TIMELINE_VIEWPORT_BUDGETS.maxMountedClipRootsPerRow + 1,
);
expect(initialGridCells.length).toBeLessThan(100);
expect(host.querySelector('[data-el-id="clip-490"]')).not.toBeNull();
const initialWindowIds = initialClips.map((clip) => clip.dataset.elId);
const scroller = host.querySelector<HTMLElement>("[data-timeline-scroll-viewport]");
expect(scroller).not.toBeNull();
if (scroller) {
scroller.scrollLeft = 8_000;
await act(async () => {
scroller.dispatchEvent(new Event("scroll"));
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
});
}
const scrolledClips = [...host.querySelectorAll<HTMLElement>("[data-clip]")];
expect(scrolledClips.map((clip) => clip.dataset.elId)).not.toEqual(initialWindowIds);
expect(scrolledClips.length).toBeLessThanOrEqual(
TIMELINE_VIEWPORT_BUDGETS.maxMountedClipRootsPerRow + 1,
);
expect(host.querySelector('[data-el-id="clip-490"]')).not.toBeNull();
expect(host.querySelectorAll("[data-timeline-grid-cell]").length).toBeLessThan(100);
await act(async () => usePlayerStore.getState().requestClipReveal("clip-300"));
await act(async () => {
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
});
await act(async () => {});
expect(usePlayerStore.getState().clipRevealRequest).toBeNull();
expect(document.activeElement?.getAttribute("data-el-id")).toBe("clip-300");
expect(host.querySelector('[data-el-id="clip-490"]')).not.toBeNull();
act(() => root.unmount());
usePlayerStore.getState().reset();
});
});
@@ -23,8 +23,8 @@ import { TimelineClip } from "./TimelineClip";
import { TimelineLanes } from "./TimelineLanes";
import type { TimelineLaneBaseProps } from "./TimelineLaneTypes";
import { renderClipChildren } from "./timelineClipChildren";
import { useTimelineRevealClip } from "./useTimelineRevealClip";
import type { TimelineLaneGapStrips } from "./useTimelineGapHighlights";
import { isTimelineClipActive } from "./useTimelineActiveClips";
interface TimelineCanvasProps extends TimelineLaneBaseProps {
major: number[];
@@ -57,8 +57,6 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
onRazorSplitAll,
} = useTimelineEditContextOptional();
const beatDragging = usePlayerStore((s) => s.beatDragging);
// Scroll a clip into view when the sidebar (asset card) requests a reveal.
useTimelineRevealClip(scrollRef);
const draggedElement = draggedClip?.element ?? null;
const activeDraggedElement =
draggedClip?.started === true && draggedElement
@@ -121,6 +119,7 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
theme={props.theme}
beatAnalysis={props.beatAnalysis}
contentOrigin={props.contentOrigin}
renderTimeRange={props.rowsVirtualized ? props.renderTimeRange : undefined}
/>
{/* Breathing room between the sticky ruler and the first track lane — the
@@ -152,7 +151,13 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
const rowIndex = displayTrackOrder.indexOf(strip.track);
if (rowIndex < 0) return null;
const loud = strip.kind === "hover";
return strip.intervals.map((gap) => (
const visibleIntervals = props.rowsVirtualized
? strip.intervals.filter(
(gap) =>
gap.start < props.renderTimeRange.end && gap.end > props.renderTimeRange.start,
)
: strip.intervals;
return visibleIntervals.map((gap) => (
<div
key={`gap-${strip.kind}-${strip.track}-${gap.start}`}
className="pointer-events-none absolute"
@@ -244,6 +249,7 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
}
isHovered={false}
isDragging={true}
isActive={isTimelineClipActive(activeDraggedElement, props.currentTime)}
hasCustomContent={!!props.renderClipContent}
capabilities={getTimelineEditCapabilities(activeDraggedElement)}
theme={props.theme}
@@ -12,6 +12,7 @@ interface TimelineClipProps {
isSelected: boolean;
isHovered: boolean;
isDragging?: boolean;
isActive?: boolean;
hasCustomContent: boolean;
capabilities: TimelineEditCapabilities;
theme?: TimelineTheme;
@@ -35,6 +36,7 @@ export const TimelineClip = memo(function TimelineClip({
isSelected,
isHovered,
isDragging = false,
isActive = false,
hasCustomContent,
capabilities,
theme = defaultTimelineTheme,
@@ -88,6 +90,8 @@ export const TimelineClip = memo(function TimelineClip({
data-clip-start={el.start}
data-clip-end={el.start + el.duration}
data-clip-hidden={el.hidden ? "true" : undefined}
data-active={isActive ? "" : undefined}
tabIndex={-1}
className={clipClassName}
style={style}
title={
@@ -6,6 +6,7 @@ import type { TimelineTheme } from "./timelineTheme";
import type { BlockedClipState, DraggedClipState, ResizingClipState } from "./useTimelineClipDrag";
import type { TrackVisualStyle } from "./timelineIcons";
import type { KeyframeCacheEntry, TimelineElement } from "../store/playerStore";
import type { TimelineClipIndex, TimelineTimeRange } from "../lib/timelineClipIndex";
import type { TimelineRowGeometry } from "./timelineLayout";
import type { TimelineVirtualRow } from "./useTimelineVirtualRows";
@@ -21,6 +22,9 @@ export interface TimelineLaneBaseProps {
rowGeometry: TimelineRowGeometry;
virtualRows: readonly TimelineVirtualRow[];
rowsVirtualized: boolean;
clipIndex: TimelineClipIndex;
renderTimeRange: TimelineTimeRange;
pinnedClipIdentities: ReadonlySet<string>;
trackOrder: number[];
tracks: [number, TimelineElement[]][];
trackStyles: Map<number, TrackVisualStyle>;
@@ -20,6 +20,8 @@ import { isAudioTimelineElement, isMusicTrack } from "../../utils/timelineInspec
import { renderClipChildren } from "./timelineClipChildren";
import type { TimelineLaneBaseProps } from "./TimelineLaneTypes";
import { TimelineTrackRow } from "./TimelineTrackRow";
import { isTimelineClipActive } from "./useTimelineActiveClips";
import { queryTimelineClipIndex } from "../lib/timelineClipIndex";
interface TimelineLanesProps extends TimelineLaneBaseProps {
/** Live-derived by TimelineCanvas from {@link TimelineLaneBaseProps.draggedClip}. */
@@ -43,6 +45,9 @@ export function TimelineLanes({
rowGeometry,
virtualRows,
rowsVirtualized,
clipIndex,
renderTimeRange,
pinnedClipIdentities,
trackOrder,
tracks,
trackStyles,
@@ -110,6 +115,29 @@ export function TimelineLanes({
if (trackNum === undefined) return null;
const rowHeight = rowGeometry.getRowHeight(row);
const els = tracks.find(([t]) => t === trackNum)?.[1] ?? [];
const indexedRenderElements = rowsVirtualized
? queryTimelineClipIndex(clipIndex, trackNum, renderTimeRange, pinnedClipIdentities)
: els;
const indexedRenderSet = new Set(indexedRenderElements);
const renderElements = rowsVirtualized
? els.filter((element) => {
if (indexedRenderSet.has(element)) return true;
if (
!multiDragPreview ||
!isMultiDragPassenger(element.key ?? element.id, multiDragPreview)
) {
return false;
}
const previewStart =
element.start +
multiDragPassengerOffsetPx(element.key ?? element.id, pps, multiDragPreview) /
pps;
const previewEnd = previewStart + Math.max(0, element.duration);
return previewEnd <= previewStart
? previewStart >= renderTimeRange.start && previewStart < renderTimeRange.end
: previewStart < renderTimeRange.end && previewEnd > renderTimeRange.start;
})
: els;
const ts = trackStyles.get(trackNum) ?? getTrackStyle("");
const isPendingTrack =
draggedClip?.started === true && !trackOrder.includes(trackNum) && els.length === 0;
@@ -202,6 +230,7 @@ export function TimelineLanes({
? draggedClip.snapTime
: null
}
renderTimeRange={rowsVirtualized ? renderTimeRange : undefined}
/>
{/* Beat dots on the active track (the one holding the selection),
falling back to the music track when nothing is selected. */}
@@ -210,6 +239,7 @@ export function TimelineLanes({
beatTimes={beatAnalysis?.beatTimes}
beatStrengths={beatAnalysis?.beatStrengths}
pps={pps}
renderTimeRange={rowsVirtualized ? renderTimeRange : undefined}
/>
)}
{isPendingTrack && (
@@ -229,7 +259,7 @@ export function TimelineLanes({
)}
{
// fallow-ignore-next-line complexity
els.map((el) => {
renderElements.map((el) => {
const clipStyle = getTrackStyle(el.tag);
const elementKey = el.key ?? el.id;
// Only the track's active keyframe clip shows expanded lanes;
@@ -276,6 +306,7 @@ export function TimelineLanes({
isSelected={isSelected}
isHovered={hoveredClip === clipKey}
isDragging={false}
isActive={isTimelineClipActive(previewElement, currentTime)}
hasCustomContent={!!renderClipContent}
capabilities={capabilities}
theme={theme}
@@ -1,113 +1,116 @@
// @vitest-environment happy-dom
import React, { act, useRef } from "react";
import { createRoot } from "react-dom/client";
import { describe, expect, it } from "vitest";
import { updateTimelineActiveClipClasses } from "./useTimelineActiveClips";
import { liveTime, type TimelineElement } from "../store/playerStore";
import {
isTimelineClipActive,
updateTimelineActiveClipClasses,
useTimelineActiveClips,
} from "./useTimelineActiveClips";
function appendClip(container: HTMLElement, id: string, start: string, end: string): HTMLElement {
const clip = document.createElement("div");
clip.dataset.clip = "true";
clip.dataset.elId = id;
clip.dataset.clipStart = start;
clip.dataset.clipEnd = end;
container.append(clip);
return clip;
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
function clip(id: string, start: number, duration: number, hidden = false): TimelineElement {
return { id, tag: "div", start, duration, track: 1, hidden };
}
describe("updateTimelineActiveClipClasses", () => {
it("toggles data-active only for clips containing the current time", () => {
function appendClip(container: HTMLElement, id: string, start: string, end: string): HTMLElement {
const element = document.createElement("div");
element.dataset.clip = "true";
element.dataset.elId = id;
element.dataset.clipStart = start;
element.dataset.clipEnd = end;
container.append(element);
return element;
}
function Harness({ version, heroStart = 2 }: { version: number; heroStart?: number }) {
const scrollRef = useRef<HTMLDivElement>(null);
useTimelineActiveClips({
scrollRef,
currentTime: 0,
clipStateVersion: 0,
elementStateVersion: version,
});
return React.createElement(
"div",
{ ref: scrollRef },
React.createElement("div", {
"data-clip": "true",
"data-el-id": "intro",
"data-clip-start": "0",
"data-clip-end": "1",
}),
React.createElement("div", {
"data-clip": "true",
"data-el-id": "hero",
"data-clip-start": String(heroStart),
"data-clip-end": String(heroStart + 1),
}),
);
}
describe("timeline active clips", () => {
it("uses model timing and keeps the end boundary inclusive", () => {
const element = clip("hero", 2, 3);
expect(isTimelineClipActive(element, 2)).toBe(true);
expect(isTimelineClipActive(element, 5)).toBe(true);
expect(isTimelineClipActive(element, 5.001)).toBe(false);
});
it("never activates hidden or invalid clips", () => {
expect(isTimelineClipActive(clip("hidden", 0, 5, true), 2)).toBe(false);
expect(isTimelineClipActive(clip("invalid", Number.NaN, 5), 2)).toBe(false);
});
it("synchronizes mounted clip attributes", () => {
const container = document.createElement("div");
const intro = appendClip(container, "intro", "0", "2");
const hero = appendClip(container, "hero", "2", "5");
const outro = appendClip(container, "outro", "5", "8");
const previous = new Set<string>();
updateTimelineActiveClipClasses(container, previous, 2.25);
expect(intro.hasAttribute("data-active")).toBe(false);
expect(hero.hasAttribute("data-active")).toBe(true);
expect(outro.hasAttribute("data-active")).toBe(false);
expect(previous).toEqual(new Set(["hero"]));
});
it("never marks hidden clips active inside their time window", () => {
const container = document.createElement("div");
const hidden = appendClip(container, "hidden", "0", "5");
const visible = appendClip(container, "visible", "0", "5");
hidden.dataset.clipHidden = "true";
const previous = new Set<string>();
updateTimelineActiveClipClasses(container, previous, 2);
expect(hidden.hasAttribute("data-active")).toBe(false);
expect(visible.hasAttribute("data-active")).toBe(true);
expect(previous).toEqual(new Set(["visible"]));
});
it("diffs against the previous active set", () => {
const container = document.createElement("div");
const intro = appendClip(container, "intro", "0", "2");
const hero = appendClip(container, "hero", "2", "5");
const previous = new Set(["intro"]);
intro.toggleAttribute("data-active", true);
updateTimelineActiveClipClasses(container, previous, 2);
expect(intro.hasAttribute("data-active")).toBe(true);
expect(hero.hasAttribute("data-active")).toBe(true);
expect(previous).toEqual(new Set(["intro", "hero"]));
});
it("keeps a clip active through its inclusive end boundary", () => {
const container = document.createElement("div");
const intro = appendClip(container, "intro", "0", "2");
const previous = new Set<string>();
updateTimelineActiveClipClasses(container, previous, 0);
expect(intro.hasAttribute("data-active")).toBe(true);
expect(previous).toEqual(new Set(["intro"]));
updateTimelineActiveClipClasses(container, previous, 2);
expect(intro.hasAttribute("data-active")).toBe(true);
expect(previous).toEqual(new Set(["intro"]));
updateTimelineActiveClipClasses(container, previous, 2.001);
expect(intro.hasAttribute("data-active")).toBe(false);
expect(previous).toEqual(new Set());
});
it("re-applies data-active to a fresh DOM node that stayed active across a re-render", () => {
// A clip that moves lanes on a reorder remounts as a new element. It stays
// in the previous active set, so the plain diff would skip it and leave the
// new node without data-active. syncAll must force the attribute on.
it("re-applies active state when a clip remounts inside the current window", () => {
const container = document.createElement("div");
appendClip(container, "hero", "0", "5");
const previous = new Set<string>();
updateTimelineActiveClipClasses(container, previous, 2);
expect(previous).toEqual(new Set(["hero"]));
// Simulate a remount: replace the hero clip's DOM node (no data-active).
container.replaceChildren();
const heroReborn = appendClip(container, "hero", "0", "5");
expect(heroReborn.hasAttribute("data-active")).toBe(false);
// Diff-only would skip it (still active → unchanged); syncAll re-applies.
const remounted = appendClip(container, "hero", "0", "5");
updateTimelineActiveClipClasses(container, previous, 2, true);
expect(heroReborn.hasAttribute("data-active")).toBe(true);
expect(remounted.hasAttribute("data-active")).toBe(true);
});
it("ignores clips with invalid timing data", () => {
const container = document.createElement("div");
const missingId = appendClip(container, "", "0", "2");
const missingTiming = appendClip(container, "bad", "", "2");
const previous = new Set<string>();
it("moves active state with live playback without changing store time", async () => {
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
await act(async () => root.render(React.createElement(Harness, { version: 0 })));
updateTimelineActiveClipClasses(container, previous, 1);
const intro = host.querySelector<HTMLElement>('[data-el-id="intro"]');
const hero = host.querySelector<HTMLElement>('[data-el-id="hero"]');
expect(intro?.hasAttribute("data-active")).toBe(true);
expect(hero?.hasAttribute("data-active")).toBe(false);
expect(missingId.hasAttribute("data-active")).toBe(false);
expect(missingTiming.hasAttribute("data-active")).toBe(false);
expect(previous).toEqual(new Set());
act(() => liveTime.notify(2.5));
expect(intro?.hasAttribute("data-active")).toBe(false);
expect(hero?.hasAttribute("data-active")).toBe(true);
await act(async () => root.render(React.createElement(Harness, { version: 1, heroStart: 4 })));
act(() => liveTime.notify(2.5));
expect(host.querySelector('[data-el-id="hero"]')?.hasAttribute("data-active")).toBe(false);
await act(async () => root.unmount());
host.remove();
});
});
@@ -1,5 +1,5 @@
import { useCallback, useLayoutEffect, useRef } from "react";
import { liveTime } from "../store/playerStore";
import { liveTime, type TimelineElement } from "../store/playerStore";
import { useMountEffect } from "../../hooks/useMountEffect";
interface ActiveClipRecord {
@@ -14,6 +14,33 @@ interface UseTimelineActiveClipsInput {
scrollRef: React.RefObject<HTMLDivElement | null>;
currentTime: number;
clipStateVersion: unknown;
elementStateVersion: unknown;
}
function isTimelineIntervalActive(
interval: Pick<ActiveClipRecord, "start" | "end" | "hidden">,
time: number,
): boolean {
return (
Number.isFinite(time) &&
!interval.hidden &&
Number.isFinite(interval.start) &&
Number.isFinite(interval.end) &&
time >= interval.start &&
time <= interval.end
);
}
/** Model-first active state. Rendered nodes receive this on their first mount. */
export function isTimelineClipActive(element: TimelineElement, time: number): boolean {
return isTimelineIntervalActive(
{
start: element.start,
end: element.start + Math.max(0, element.duration),
hidden: element.hidden === true,
},
time,
);
}
function readFiniteNumber(value: string | undefined): number | null {
@@ -27,66 +54,37 @@ function readClipRecord(element: Element): ActiveClipRecord | null {
const id = element.dataset.elId;
const start = readFiniteNumber(element.dataset.clipStart);
const end = readFiniteNumber(element.dataset.clipEnd);
const hidden = element.dataset.clipHidden === "true";
if (!id || start === null || end === null) return null;
return { id, start, end, hidden, element };
return { id, start, end, hidden: element.dataset.clipHidden === "true", element };
}
function collectTimelineClipRecords(container: HTMLElement): ActiveClipRecord[] {
const records: ActiveClipRecord[] = [];
for (const element of container.querySelectorAll('[data-clip="true"]')) {
const record = readClipRecord(element);
if (record) records.push(record);
}
return records;
}
function indexClipRecordsById(records: ActiveClipRecord[]): Map<string, ActiveClipRecord> {
const recordsById = new Map<string, ActiveClipRecord>();
for (const record of records) recordsById.set(record.id, record);
return recordsById;
return [...container.querySelectorAll('[data-clip="true"]')]
.map(readClipRecord)
.filter((record): record is ActiveClipRecord => record !== null);
}
function getActiveClipIds(records: ActiveClipRecord[], time: number): Set<string> {
const next = new Set<string>();
if (!Number.isFinite(time)) return next;
for (const record of records) {
if (record.hidden) continue;
if (time >= record.start && time <= record.end) next.add(record.id);
}
return next;
}
function setsMatch(left: Set<string>, right: Set<string>): boolean {
if (left.size !== right.size) return false;
for (const value of left) {
if (!right.has(value)) return false;
}
return true;
return new Set(
records.filter((record) => isTimelineIntervalActive(record, time)).map((record) => record.id),
);
}
function applyActiveClipDiff(
records: ActiveClipRecord[],
previous: Set<string>,
time: number,
// Force every record's attribute to match its active state instead of only
// touching clips whose active-state changed. Required whenever `records` were
// freshly re-queried after a render: a clip that stayed active but got a new
// DOM node (e.g. moved lanes on a reorder) would otherwise be skipped by the
// diff and render without `data-active` despite still being at the playhead.
syncAll = false,
) {
): void {
const next = getActiveClipIds(records, time);
const changed = !setsMatch(previous, next);
for (const record of records) {
const wasActive = previous.has(record.id);
const isActive = next.has(record.id);
if (!syncAll && wasActive === isActive) continue;
record.element.toggleAttribute("data-active", isActive);
if (syncAll || previous.has(record.id) !== isActive) {
record.element.toggleAttribute("data-active", isActive);
}
}
previous.clear();
for (const id of next) previous.add(id);
return changed;
}
export function updateTimelineActiveClipClasses(
@@ -94,30 +92,28 @@ export function updateTimelineActiveClipClasses(
previous: Set<string>,
time: number,
syncAll = false,
) {
): void {
applyActiveClipDiff(collectTimelineClipRecords(container), previous, time, syncAll);
}
/** Keeps the currently mounted clip window synchronized with the RAF playback clock. */
export function useTimelineActiveClips({
scrollRef,
currentTime,
clipStateVersion,
}: UseTimelineActiveClipsInput) {
elementStateVersion,
}: UseTimelineActiveClipsInput): void {
const recordsRef = useRef<ActiveClipRecord[]>([]);
const recordsByIdRef = useRef(new Map<string, ActiveClipRecord>());
const previousActiveIdsRef = useRef(new Set<string>());
const refreshRecords = useCallback(
(time: number) => {
const scroll = scrollRef.current;
if (!scroll) {
recordsRef.current = [];
recordsByIdRef.current.clear();
previousActiveIdsRef.current.clear();
return;
}
recordsRef.current = collectTimelineClipRecords(scroll);
recordsByIdRef.current = indexClipRecordsById(recordsRef.current);
applyActiveClipDiff(recordsRef.current, previousActiveIdsRef.current, time, true);
},
[scrollRef],
@@ -125,12 +121,10 @@ export function useTimelineActiveClips({
useLayoutEffect(() => {
refreshRecords(currentTime);
}, [currentTime, clipStateVersion, refreshRecords]);
useMountEffect(() => {
const unsub = liveTime.subscribe((time) => {
}, [clipStateVersion, currentTime, elementStateVersion, refreshRecords]);
useMountEffect(() =>
liveTime.subscribe((time) => {
applyActiveClipDiff(recordsRef.current, previousActiveIdsRef.current, time);
});
return unsub;
});
}),
);
}
@@ -0,0 +1,87 @@
import { useMemo, type RefObject } from "react";
import { createTimelineClipIndex } from "../lib/timelineClipIndex";
import type { TimelineElement } from "../store/playerStore";
import { getTimelineRenderTimeRange } from "./timelineViewportGeometry";
import type { TimelineRowGeometry } from "./timelineLayout";
import type { TimelineScrollViewportSnapshot } from "./useTimelineScrollViewport";
import { useTimelineRevealClip } from "./useTimelineRevealClip";
interface UseTimelineClipRenderWindowInput {
tracks: Parameters<typeof createTimelineClipIndex>[0];
viewport: TimelineScrollViewportSnapshot;
pixelsPerSecond: number;
contentOrigin: number;
duration: number;
selectedElementId?: string;
draggedElementId?: string;
resizingElementId?: string;
revealElementId?: string;
focusedEaseElementId?: string;
clipContextMenuElementId?: string;
keyframeContextMenuElementId?: string;
scrollRef: RefObject<HTMLDivElement | null>;
elements: readonly TimelineElement[];
rowGeometry: TimelineRowGeometry;
allowHorizontalReveal: boolean;
sessionEpoch: number;
}
export function useTimelineClipRenderWindow({
tracks,
viewport,
pixelsPerSecond,
contentOrigin,
duration,
selectedElementId,
draggedElementId,
resizingElementId,
revealElementId,
focusedEaseElementId,
clipContextMenuElementId,
keyframeContextMenuElementId,
scrollRef,
elements,
rowGeometry,
allowHorizontalReveal,
sessionEpoch,
}: UseTimelineClipRenderWindowInput) {
const clipIndex = useMemo(() => createTimelineClipIndex(tracks), [tracks]);
const renderTimeRange = useMemo(
() => getTimelineRenderTimeRange(viewport, pixelsPerSecond, contentOrigin, duration),
[contentOrigin, duration, pixelsPerSecond, viewport],
);
const pinnedClipIdentities = useMemo(
() =>
new Set(
[
selectedElementId,
draggedElementId,
resizingElementId,
revealElementId,
focusedEaseElementId,
clipContextMenuElementId,
keyframeContextMenuElementId,
].filter((identity): identity is string => identity !== undefined),
),
[
clipContextMenuElementId,
draggedElementId,
focusedEaseElementId,
keyframeContextMenuElementId,
resizingElementId,
revealElementId,
selectedElementId,
],
);
useTimelineRevealClip({
scrollRef,
elements,
rowGeometry,
pixelsPerSecond,
contentOrigin,
allowHorizontal: allowHorizontalReveal,
viewportVersion: viewport,
sessionEpoch,
});
return { clipIndex, renderTimeRange, pinnedClipIdentities };
}
@@ -0,0 +1,87 @@
// @vitest-environment happy-dom
import React, { act, useRef } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it } from "vitest";
import type { TimelineElement } from "../store/playerStore";
import { usePlayerStore } from "../store/playerStore";
import { createTimelineRowGeometry } from "./timelineLayout";
import { useTimelineRevealClip } from "./useTimelineRevealClip";
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
const element: TimelineElement = {
id: "hero",
tag: "div",
start: 20,
duration: 2,
track: 1,
};
const geometry = createTimelineRowGeometry([1], [48]);
function Harness({ mounted, version }: { mounted: boolean; version: number }) {
const scrollRef = useRef<HTMLDivElement>(null);
useTimelineRevealClip({
scrollRef,
elements: [element],
rowGeometry: geometry,
pixelsPerSecond: 100,
contentOrigin: 32,
allowHorizontal: true,
viewportVersion: version,
sessionEpoch: 1,
});
return (
<div
ref={(node) => {
scrollRef.current = node;
if (node) {
Object.defineProperty(node, "clientWidth", { configurable: true, value: 300 });
Object.defineProperty(node, "clientHeight", { configurable: true, value: 100 });
}
}}
>
{mounted && <div data-el-id="hero" tabIndex={-1} />}
</div>
);
}
afterEach(() => {
usePlayerStore.getState().reset();
document.body.replaceChildren();
});
describe("useTimelineRevealClip", () => {
it("scrolls from model coordinates, then consumes only after the clip mounts", async () => {
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
usePlayerStore.getState().requestClipReveal("hero");
await act(async () => root.render(<Harness mounted={false} version={0} />));
const scroll = host.firstElementChild as HTMLDivElement;
expect(scroll.scrollLeft).toBe(1_944);
expect(usePlayerStore.getState().clipRevealRequest?.elementId).toBe("hero");
await act(async () => root.render(<Harness mounted version={1} />));
expect(usePlayerStore.getState().clipRevealRequest).toBeNull();
expect(document.activeElement?.getAttribute("data-el-id")).toBe("hero");
scroll.scrollLeft = 0;
await act(async () => usePlayerStore.getState().requestClipReveal("hero"));
await act(async () => root.render(<Harness mounted version={2} />));
expect(scroll.scrollLeft).toBe(1_944);
expect(usePlayerStore.getState().clipRevealRequest).toBeNull();
await act(async () => root.unmount());
});
it("consumes an invalid target without scrolling", async () => {
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
usePlayerStore.getState().requestClipReveal("missing");
await act(async () => root.render(<Harness mounted={false} version={0} />));
expect(usePlayerStore.getState().clipRevealRequest).toBeNull();
await act(async () => root.unmount());
});
});
@@ -1,56 +1,144 @@
/**
* Consumes playerStore.clipRevealRequest: when another surface (the sidebar
* asset card / audio row) asks for a clip to be revealed, smooth-scroll the
* timeline's scroll container so that clip is visible — horizontally to its
* time and vertically to its lane.
*
* The request is consumed (cleared) whether or not the clip node is found, so
* a stale request can never replay a scroll later. Respects zoom mode: in
* "fit" the timeline disables horizontal scrolling (overflow-x-hidden), so
* only the vertical axis is scrolled there.
*/
import { useEffect } from "react";
import { useEffect, useRef } from "react";
import type { TimelineElement } from "../store/playerStore";
import { usePlayerStore } from "../store/playerStore";
import { GUTTER, RULER_H } from "./timelineLayout";
import { CLIP_Y, RULER_H, type TimelineRowGeometry } from "./timelineLayout";
import { computeRevealScroll } from "./timelineRevealScroll";
export function useTimelineRevealClip(scrollRef: React.RefObject<HTMLDivElement | null>): void {
const revealRequest = usePlayerStore((s) => s.clipRevealRequest);
interface UseTimelineRevealClipInput {
scrollRef: React.RefObject<HTMLDivElement | null>;
elements: readonly TimelineElement[];
rowGeometry: TimelineRowGeometry;
pixelsPerSecond: number;
contentOrigin: number;
allowHorizontal: boolean;
viewportVersion: unknown;
sessionEpoch: number;
}
function escapeSelectorValue(value: string): string {
return typeof CSS !== "undefined" && typeof CSS.escape === "function"
? CSS.escape(value)
: value.replace(/["\\]/g, "\\$&");
}
function scrollToTimelineElement(
container: HTMLDivElement,
element: TimelineElement,
row: number,
rowGeometry: TimelineRowGeometry,
pixelsPerSecond: number,
contentOrigin: number,
allowHorizontal: boolean,
): void {
const clipLeft = contentOrigin + element.start * pixelsPerSecond;
const target = computeRevealScroll({
scrollLeft: container.scrollLeft,
scrollTop: container.scrollTop,
viewportWidth: container.clientWidth,
viewportHeight: container.clientHeight,
clipLeft,
clipRight: clipLeft + Math.max(element.duration * pixelsPerSecond, 4),
clipTop: rowGeometry.getRowTop(row) + CLIP_Y,
clipBottom: rowGeometry.getRowTop(row) + rowGeometry.getRowHeight(row) - CLIP_Y,
stickyLeft: contentOrigin,
stickyTop: RULER_H,
allowHorizontal,
});
if (target.left !== null) container.scrollLeft = target.left;
if (target.top !== null) container.scrollTop = target.top;
if (target.left !== null || target.top !== null) container.dispatchEvent(new Event("scroll"));
}
function focusRevealedElement(container: HTMLDivElement, elementId: string): boolean {
const clip = container.querySelector(`[data-el-id="${escapeSelectorValue(elementId)}"]`);
if (!(clip instanceof HTMLElement)) return false;
clip.setAttribute("data-reveal-highlight", "true");
clip.focus({ preventScroll: true });
if (document.activeElement !== clip) {
clip.removeAttribute("data-reveal-highlight");
return false;
}
clip.addEventListener("blur", () => clip.removeAttribute("data-reveal-highlight"), {
once: true,
});
return true;
}
function resolveRevealTarget(
elements: readonly TimelineElement[],
rowGeometry: TimelineRowGeometry,
elementId: string,
): { element: TimelineElement; row: number } | null {
const element = elements.find((candidate) => (candidate.key ?? candidate.id) === elementId);
if (!element) return null;
const row = rowGeometry.getRowIndex(element.track);
return row < 0 ? null : { element, row };
}
function shouldScrollReveal(
previous: { request: { elementId: string; nonce: number }; sessionEpoch: number } | null,
request: { elementId: string; nonce: number },
sessionEpoch: number,
): boolean {
return previous?.request !== request || previous.sessionEpoch !== sessionEpoch;
}
/** Coordinate-first reveal; the request remains pinned until its clip mounts. */
export function useTimelineRevealClip({
scrollRef,
elements,
rowGeometry,
pixelsPerSecond,
contentOrigin,
allowHorizontal,
viewportVersion,
sessionEpoch,
}: UseTimelineRevealClipInput): void {
const revealRequest = usePlayerStore((state) => state.clipRevealRequest);
const scrolledRequestRef = useRef<{
request: { elementId: string; nonce: number };
sessionEpoch: number;
} | null>(null);
useEffect(() => {
if (!revealRequest) return;
// Consume the request first — reveal is one-shot, even when the clip node
// isn't currently rendered (e.g. drilled into a different composition).
usePlayerStore.getState().clearClipRevealRequest();
if (!revealRequest) {
scrolledRequestRef.current = null;
return;
}
const target = resolveRevealTarget(elements, rowGeometry, revealRequest.elementId);
if (!target) {
usePlayerStore.getState().clearClipRevealRequest();
return;
}
const container = scrollRef.current;
if (!container) return;
const clip = container.querySelector(`[data-el-id="${CSS.escape(revealRequest.elementId)}"]`);
if (!(clip instanceof HTMLElement)) return;
const containerRect = container.getBoundingClientRect();
const clipRect = clip.getBoundingClientRect();
const clipLeft = clipRect.left - containerRect.left + container.scrollLeft;
const clipTop = clipRect.top - containerRect.top + container.scrollTop;
if (shouldScrollReveal(scrolledRequestRef.current, revealRequest, sessionEpoch)) {
scrolledRequestRef.current = { request: revealRequest, sessionEpoch };
scrollToTimelineElement(
container,
target.element,
target.row,
rowGeometry,
pixelsPerSecond,
contentOrigin,
allowHorizontal,
);
}
const target = computeRevealScroll({
scrollLeft: container.scrollLeft,
scrollTop: container.scrollTop,
viewportWidth: container.clientWidth,
viewportHeight: container.clientHeight,
clipLeft,
clipRight: clipLeft + clipRect.width,
clipTop,
clipBottom: clipTop + clipRect.height,
stickyLeft: GUTTER,
stickyTop: RULER_H,
allowHorizontal: usePlayerStore.getState().zoomMode === "manual",
});
if (target.left === null && target.top === null) return;
container.scrollTo({
left: target.left ?? container.scrollLeft,
top: target.top ?? container.scrollTop,
behavior: "smooth",
});
}, [revealRequest, scrollRef]);
if (!focusRevealedElement(container, revealRequest.elementId)) return;
if (usePlayerStore.getState().clipRevealRequest === revealRequest) {
usePlayerStore.getState().clearClipRevealRequest();
}
}, [
allowHorizontal,
contentOrigin,
elements,
pixelsPerSecond,
revealRequest,
rowGeometry,
scrollRef,
sessionEpoch,
viewportVersion,
]);
}
@@ -1,16 +1,21 @@
import { useMemo } from "react";
import { STUDIO_PREVIEW_FPS } from "../lib/time";
import { generateTicks } from "./timelineLayout";
import { generateTicks, getTimelineMajorTickInterval } from "./timelineLayout";
import type { TimelineTimeRange } from "../lib/timelineClipIndex";
export function useTimelineTicks(
duration: number,
pixelsPerSecond: number,
timeDisplayMode: "time" | "frame",
): { major: number[]; minor: number[] } {
renderTimeRange?: TimelineTimeRange,
) {
const frameRate = timeDisplayMode === "frame" ? STUDIO_PREVIEW_FPS : undefined;
const { major, minor } = useMemo(
() => generateTicks(duration, pixelsPerSecond, frameRate),
[duration, frameRate, pixelsPerSecond],
const ticks = useMemo(
() => generateTicks(duration, pixelsPerSecond, frameRate, renderTimeRange),
[duration, frameRate, pixelsPerSecond, renderTimeRange],
);
return { major, minor };
return {
...ticks,
majorTickInterval: getTimelineMajorTickInterval(duration, pixelsPerSecond, frameRate),
};
}