Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 57 additions & 34 deletions packages/studio/src/player/components/Timeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -42,9 +41,11 @@ import { useTimelineSelectionLifecycle } from "./useTimelineSelectionLifecycle";
import { useTimelineShiftModifier } from "./useTimelineShiftModifier";
import { useTimelineTicks } from "./useTimelineTicks";
import { getTimelineElementIndexes } from "../lib/timelineElementIndexes";
import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
import { useTimelineRowVirtualization } from "./useTimelineRowVirtualization";
import { useTimelineClipRenderWindow } from "./useTimelineClipRenderWindow";
import { useTimelineActiveClips } from "./useTimelineActiveClips";

// Re-export pure utilities so existing imports from "./Timeline" still resolve.
export {
shouldAutoScrollTimeline,
getTimelineScrollLeftForZoomTransition,
Expand Down Expand Up @@ -126,9 +127,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],
Expand Down Expand Up @@ -163,7 +163,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(
Expand Down Expand Up @@ -320,28 +319,50 @@ 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 ? getTimelineElementIdentity(draggedClip.element) : undefined,
resizingElementId: resizingClip ? getTimelineElementIdentity(resizingClip.element) : undefined,
revealElementId: clipRevealRequest?.elementId,
focusedEaseElementId: focusedEaseSegment?.elementId,
clipContextMenuElementId: clipContextMenu
? getTimelineElementIdentity(clipContextMenu.element)
: undefined,
keyframeContextMenuElementId: kfContextMenu
? getTimelineElementIdentity(kfContextMenu.element)
: undefined,
scrollRef,
elements: expandedElements,
rowGeometry: displayLayout.rowGeometry,
allowHorizontalReveal: zoomMode === "manual",
sessionEpoch,
});
useTimelineActiveClips({
scrollRef,
currentTime,
clipStateVersion: renderTimeRange,
elementStateVersion: expandedElements,
});

const laneGapStrips = useTimelineGapHighlights({
Expand Down Expand Up @@ -376,11 +397,6 @@ export const Timeline = memo(function Timeline({
onSeek,
contentOrigin,
});
useTimelineActiveClips({
scrollRef,
currentTime,
clipStateVersion,
});
const { razorGuideX, updateRazorGuide, clearRazorGuide, splitAllAtPointer } =
useTimelineRazorInteraction({
active: activeTool === "razor",
Expand Down Expand Up @@ -422,8 +438,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),
Expand Down Expand Up @@ -500,6 +520,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}
Expand All @@ -513,7 +536,7 @@ export const Timeline = memo(function Timeline({
blockedClipRef={blockedClipRef}
suppressClickRef={suppressClickRef}
scrollRef={scrollRef}
renderClipContent={renderClipContent}
renderClipContent={viewport.isScrolling ? undefined : renderClipContent}
renderClipOverlay={renderClipOverlay}
playheadRef={playheadRef}
onDrillDown={onDrillDown}
Expand Down
156 changes: 153 additions & 3 deletions packages/studio/src/player/components/Timeline.virtualization.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,25 @@ afterAll(() => {
document.body.innerHTML = "";
});

describe("Timeline row virtualization", () => {
/**
* The virtualized list only mounts rows/clips after its ResizeObserver and the
* follow-up layout effect have both flushed, which is more than one React tick.
* A fixed number of flushes is a coin flip once the rest of the suite is
* competing for workers, so wait for the DOM the assertions actually need.
*/
async function settleUntil(predicate: () => boolean, tries = 60): Promise<void> {
for (let attempt = 0; attempt < tries; attempt++) {
if (predicate()) return;
await act(async () => {
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
});
}
}

// These tests mount 500-10,000 timeline elements and settle the virtualizer,
// which lands right on the 5s default once the rest of the suite is competing
// for workers. The generous ceiling is a flake guard, not an expected runtime.
describe("Timeline row virtualization", { timeout: 30_000 }, () => {
it("keeps a zero-size first render bounded while the feature flag is enabled", async () => {
clientWidth = 0;
clientHeight = 0;
Expand Down Expand Up @@ -89,7 +107,60 @@ describe("Timeline row virtualization", () => {

act(() => root.unmount());
usePlayerStore.getState().reset();
}, 10_000);
});

it("defers rich clip content while scrolling without replacing the clip shell", async () => {
const [{ Timeline }, { usePlayerStore }] = await Promise.all([
import("./Timeline"),
import("../store/playerStore"),
]);
usePlayerStore.setState({
duration: 60,
timelineReady: true,
selectedElementId: "clip-0",
elements: [{ id: "clip-0", label: "Clip 0", tag: "div", start: 0, duration: 10, track: 0 }],
});

const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
try {
await act(async () =>
root.render(
React.createElement(Timeline, {
renderClipContent: () => React.createElement("span", { "data-rich-content": true }),
}),
),
);
await act(async () => {});
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 110));
});

const scroller = host.querySelector<HTMLElement>("[data-timeline-scroll-viewport]");
const clip = host.querySelector<HTMLElement>('[data-el-id="clip-0"]');
expect(scroller).not.toBeNull();
expect(clip).not.toBeNull();
expect(clip?.title).toBe("Clip 0 • 0.0s – 10.0s");
expect(host.querySelector("[data-rich-content]")).not.toBeNull();

await act(async () => {
scroller?.dispatchEvent(new Event("scroll"));
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
});
expect(host.querySelector('[data-el-id="clip-0"]')).toBe(clip);
expect(host.querySelector("[data-rich-content]")).toBeNull();

await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 110));
});
expect(host.querySelector('[data-el-id="clip-0"]')).toBe(clip);
expect(host.querySelector("[data-rich-content]")).not.toBeNull();
} finally {
act(() => root.unmount());
usePlayerStore.getState().reset();
}
});

it("mounts a bounded list range over the full geometry height", async () => {
const [{ Timeline }, { usePlayerStore }, { getTimelineCanvasHeight, TRACK_H }] =
Expand All @@ -101,6 +172,8 @@ describe("Timeline row virtualization", () => {
usePlayerStore.setState({
duration: 60,
timelineReady: true,
// Repeated fixture shape intentionally contrasts row and clip windowing scales.
// fallow-ignore-next-line code-duplication
elements: Array.from({ length: 1_000 }, (_, track) => ({
id: `clip-${track}`,
tag: "div",
Expand All @@ -116,6 +189,11 @@ describe("Timeline row virtualization", () => {
await act(async () => root.render(React.createElement(Timeline, { sessionEpoch: 3 })));
await act(async () => {});

await settleUntil(
() =>
(host.querySelector('[role="list"]')?.querySelectorAll('[role="listitem"]').length ?? 0) >
0,
);
const list = host.querySelector<HTMLElement>('[role="list"]');
const rows = list?.querySelectorAll('[role="listitem"]') ?? [];
expect(rows.length).toBeGreaterThan(0);
Expand Down Expand Up @@ -143,5 +221,77 @@ describe("Timeline row virtualization", () => {

act(() => root.unmount());
usePlayerStore.getState().reset();
}, 10_000);
});

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"]),
// Repeated fixture shape intentionally contrasts row and clip windowing scales.
// fallow-ignore-next-line code-duplication
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 () => {});

await settleUntil(() => host.querySelectorAll("[data-clip]").length > 1);
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();
});
});
Loading
Loading