Skip to content
Draft
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
5 changes: 5 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/studio/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@
"@hyperframes/sdk": "workspace:*",
"@hyperframes/studio-server": "workspace:*",
"@phosphor-icons/react": "^2.1.10",
"@tanstack/react-virtual": "^3.14.6",
"bpm-detective": "^2.0.5",
"dompurify": "^3.2.4",
"gsap": "^3.13.0",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export function LayerDisclosureRow({
>
<button
type="button"
aria-expanded={isExpanded}
aria-label={`${isExpanded ? "Collapse" : "Expand"} ${name} keyframes`}
title={`${isExpanded ? "Collapse" : "Expand"} keyframe lanes`}
className="flex h-5 w-4 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 text-white/55 hover:text-white focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC]"
Expand Down
26 changes: 26 additions & 0 deletions packages/studio/src/player/components/Timeline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,32 @@ describe("Timeline provider boundary", () => {
act(() => root.unmount());
});

it("renders the complete track list while row virtualization is gated off", () => {
const host = createSizedTimelineHost(640);
usePlayerStore.setState({
duration: 4,
timelineReady: true,
elements: Array.from({ length: 12 }, (_, track) => ({
id: `clip-${track}`,
tag: "div",
start: 0,
duration: 2,
track,
})),
});
const root = createRoot(host);
act(() => root.render(React.createElement(Timeline)));

const list = host.querySelector<HTMLElement>('[role="list"]');
const rows = list?.querySelectorAll('[role="listitem"]') ?? [];
expect(rows).toHaveLength(12);
expect(rows[0]?.getAttribute("aria-posinset")).toBe("1");
expect(rows[0]?.getAttribute("aria-setsize")).toBe("12");
expect(rows[11]?.getAttribute("aria-posinset")).toBe("12");

act(() => root.unmount());
});

it("renders the gutter without legacy icons or hue dots", () => {
const { host, root } = renderBasicTimeline();

Expand Down
49 changes: 21 additions & 28 deletions packages/studio/src/player/components/Timeline.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useRef, useMemo, useCallback, useState, useLayoutEffect, memo } from "react";
import { useRef, useMemo, useCallback, useState, memo } from "react";
import { useMusicBeatAnalysis } from "../../hooks/useMusicBeatAnalysis";
import { remapBeatAnalysisToComposition } from "../../utils/beatEditActions";
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
Expand Down Expand Up @@ -40,7 +40,7 @@ import { useTimelineSelectionLifecycle } from "./useTimelineSelectionLifecycle";
import { useTimelineShiftModifier } from "./useTimelineShiftModifier";
import { useTimelineTicks } from "./useTimelineTicks";
import { getTimelineElementIndexes } from "../lib/timelineElementIndexes";
import { getTimelineScrollTopForGeometryChange } from "./timelineViewportGeometry";
import { useTimelineRowVirtualization } from "./useTimelineRowVirtualization";

// Re-export pure utilities so existing imports from "./Timeline" still resolve.
export {
Expand Down Expand Up @@ -118,6 +118,7 @@ export const Timeline = memo(function Timeline({
const timelineReady = usePlayerStore((s) => s.timelineReady);
const selectedElementId = usePlayerStore((s) => s.selectedElementId);
const selectedElementIds = usePlayerStore((s) => s.selectedElementIds);
const clipRevealRequest = usePlayerStore((s) => s.clipRevealRequest);
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).
Expand Down Expand Up @@ -273,32 +274,21 @@ export const Timeline = memo(function Timeline({
expandedElements.length,
displayLayout.totalH,
]);
const previousLayoutRef = useRef(displayLayout.rowGeometry);
const previousSessionEpochRef = useRef(sessionEpoch);
useLayoutEffect(() => {
const scroll = scrollRef.current;
const previousGeometry = previousLayoutRef.current;
if (previousSessionEpochRef.current !== sessionEpoch) {
previousSessionEpochRef.current = sessionEpoch;
lastScrollLeftRef.current = 0;
if (scroll) {
scroll.scrollLeft = 0;
scroll.scrollTop = 0;
syncScrollViewport(scroll);
}
} else if (scroll && previousGeometry !== displayLayout.rowGeometry) {
const nextScrollTop = getTimelineScrollTopForGeometryChange(
previousGeometry,
displayLayout.rowGeometry,
scroll.scrollTop,
);
if (nextScrollTop !== scroll.scrollTop) {
scroll.scrollTop = nextScrollTop;
syncScrollViewport(scroll);
}
}
previousLayoutRef.current = displayLayout.rowGeometry;
}, [displayLayout.rowGeometry, sessionEpoch, syncScrollViewport]);
const { enabled: rowVirtualizationActive, virtualRows } = useTimelineRowVirtualization({
scrollRef,
viewport,
rowGeometry: displayLayout.rowGeometry,
sessionEpoch,
elements: expandedElements,
selectedElementId,
revealElementId: clipRevealRequest?.elementId ?? null,
draggedRowKey: draggedClip?.started ? draggedClip.previewTrack : undefined,
resizingRowKey: resizingClip?.element.track,
clipContextMenuRowKey: clipContextMenu?.element.track,
keyframeContextMenuRowKey: kfContextMenu?.element.track,
lastScrollLeftRef,
syncScrollViewport,
});
const selectedKeyframes = usePlayerStore((s) => s.selectedKeyframes);
const toggleSelectedKeyframe = usePlayerStore((s) => s.toggleSelectedKeyframe);
const { onClickKeyframe, onSelectSegment, onShiftClickKeyframe, onContextMenuKeyframe } =
Expand Down Expand Up @@ -495,6 +485,9 @@ export const Timeline = memo(function Timeline({
theme={theme}
displayTrackOrder={displayLayout.displayTrackOrder}
rowHeights={displayLayout.displayRowHeights}
rowGeometry={displayLayout.rowGeometry}
virtualRows={virtualRows}
rowsVirtualized={rowVirtualizationActive}
trackOrder={trackOrder}
tracks={tracks}
trackStyles={trackStyles}
Expand Down
104 changes: 104 additions & 0 deletions packages/studio/src/player/components/Timeline.virtualization.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
// @vitest-environment happy-dom

import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";

(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;

class MockResizeObserver {
constructor(private readonly callback: ResizeObserverCallback) {}
observe(target: Element) {
this.callback(
[
{
target,
borderBoxSize: [{ inlineSize: target.clientWidth, blockSize: target.clientHeight }],
} as unknown as ResizeObserverEntry,
],
this as unknown as ResizeObserver,
);
}
unobserve() {}
disconnect() {}
}

const originalResizeObserver = globalThis.ResizeObserver;
const originalClientWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "clientWidth");
const originalClientHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "clientHeight");

beforeAll(() => {
vi.stubEnv("VITE_STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED", "1");
globalThis.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver;
Object.defineProperty(HTMLElement.prototype, "clientWidth", {
configurable: true,
get: () => 900,
});
Object.defineProperty(HTMLElement.prototype, "clientHeight", {
configurable: true,
get: () => 240,
});
});

afterAll(() => {
vi.unstubAllEnvs();
globalThis.ResizeObserver = originalResizeObserver;
if (originalClientWidth)
Object.defineProperty(HTMLElement.prototype, "clientWidth", originalClientWidth);
if (originalClientHeight)
Object.defineProperty(HTMLElement.prototype, "clientHeight", originalClientHeight);
document.body.innerHTML = "";
});

describe("Timeline row virtualization", () => {
it("mounts a bounded list range over the full geometry height", async () => {
const [{ Timeline }, { usePlayerStore }, { getTimelineCanvasHeight }] = await Promise.all([
import("./Timeline"),
import("../store/playerStore"),
import("./timelineLayout"),
]);
usePlayerStore.setState({
duration: 60,
timelineReady: true,
elements: Array.from({ length: 1_000 }, (_, track) => ({
id: `clip-${track}`,
tag: "div",
start: 0,
duration: 1,
track,
})),
});

const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
await act(async () => root.render(React.createElement(Timeline, { sessionEpoch: 3 })));
await act(async () => {});

const list = host.querySelector<HTMLElement>('[role="list"]');
const rows = list?.querySelectorAll('[role="listitem"]') ?? [];
expect(rows.length).toBeGreaterThan(0);
expect(rows.length).toBeLessThanOrEqual(16);
expect(rows[0]?.getAttribute("aria-posinset")).toBe("1");
expect(rows[0]?.getAttribute("aria-setsize")).toBe("1000");
expect(list?.parentElement?.style.height).toBe(`${getTimelineCanvasHeight(1_000)}px`);

const firstRow = rows[0] as HTMLElement;
const focusedControl = firstRow.querySelector<HTMLButtonElement>("button");
expect(focusedControl).not.toBeNull();
act(() => focusedControl?.focus());
const scroller = host.querySelector<HTMLElement>("[data-timeline-scroll-viewport]");
expect(scroller).not.toBeNull();
if (scroller) {
scroller.scrollTop = 500 * 48;
await act(async () => {
scroller.dispatchEvent(new Event("scroll"));
});
}
expect(list?.querySelector('[data-timeline-row-key="0"]')).not.toBeNull();
expect(document.activeElement).toBe(focusedControl);

act(() => root.unmount());
usePlayerStore.getState().reset();
});
});
4 changes: 2 additions & 2 deletions packages/studio/src/player/components/TimelineCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas

{/* Breathing room between the sticky ruler and the first track lane — the
top half of the CapCut-style padding (see TRACKS_TOP_PAD). */}
<div aria-hidden="true" style={{ height: TRACKS_TOP_PAD }} />
<div aria-hidden="true" style={{ height: props.rowsVirtualized ? 0 : TRACKS_TOP_PAD }} />

<TimelineLanes
{...props}
Expand All @@ -142,7 +142,7 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
{/* Breathing room below the last track lane (~1.5 track heights) — a real
scrollable surface, so a clip can be dragged into the void to create a
new bottom track comfortably (see TRACKS_BOTTOM_PAD / getTimelineCanvasHeight). */}
<div aria-hidden="true" style={{ height: TRACKS_BOTTOM_PAD }} />
<div aria-hidden="true" style={{ height: props.rowsVirtualized ? 0 : TRACKS_BOTTOM_PAD }} />

{/* Gap strips — loud dashed fill for the gap(s) a hovered "Close gap(s)"
menu row would collapse; a quiet tint for every gap on the selected
Expand Down
5 changes: 5 additions & 0 deletions packages/studio/src/player/components/TimelineLaneTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ 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 { TimelineRowGeometry } from "./timelineLayout";
import type { TimelineVirtualRow } from "./useTimelineVirtualRows";

/** Props shared by TimelineCanvas and its lane renderer. */
export interface TimelineLaneBaseProps {
Expand All @@ -16,6 +18,9 @@ export interface TimelineLaneBaseProps {
theme: TimelineTheme;
displayTrackOrder: number[];
rowHeights: readonly number[];
rowGeometry: TimelineRowGeometry;
virtualRows: readonly TimelineVirtualRow[];
rowsVirtualized: boolean;
trackOrder: number[];
tracks: [number, TimelineElement[]][];
trackStyles: Map<number, TrackVisualStyle>;
Expand Down
46 changes: 26 additions & 20 deletions packages/studio/src/player/components/TimelineLanes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { TimelinePropertyLanes } from "./TimelinePropertyLanes";
import { TimelineTrackHeader } from "./TimelineTrackHeader";
import { resolveTrackKeyframeClip } from "./useTimelineTrackLayout";
import { getTimelineEditCapabilities, resolveBlockedTimelineEditIntent } from "./timelineEditing";
import { CLIP_Y, CLIP_HANDLE_W, TRACK_H, getTimelineRowHeight } from "./timelineLayout";
import { CLIP_Y, CLIP_HANDLE_W, TRACK_H } from "./timelineLayout";
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
import {
isMultiDragPassenger,
Expand All @@ -19,6 +19,7 @@ import { SPLIT_BOUNDARY_EPSILON_S } from "../../utils/timelineElementSplit";
import { isAudioTimelineElement, isMusicTrack } from "../../utils/timelineInspector";
import { renderClipChildren } from "./timelineClipChildren";
import type { TimelineLaneBaseProps } from "./TimelineLaneTypes";
import { TimelineTrackRow } from "./TimelineTrackRow";

interface TimelineLanesProps extends TimelineLaneBaseProps {
/** Live-derived by TimelineCanvas from {@link TimelineLaneBaseProps.draggedClip}. */
Expand All @@ -39,7 +40,9 @@ export function TimelineLanes({
trackContentWidth,
theme,
displayTrackOrder,
rowHeights,
rowGeometry,
virtualRows,
rowsVirtualized,
trackOrder,
tracks,
trackStyles,
Expand Down Expand Up @@ -95,16 +98,17 @@ export function TimelineLanes({
toggleClipExpanded(key);
};
return (
<>
<div
role="list"
aria-label="Timeline tracks"
className={rowsVirtualized ? "absolute inset-0" : undefined}
>
{
// NOTE (deliberate no-virtualization): lanes and their clips render via a
// plain `.map()` inside the scroll container rather than a windowing/virtualized
// list. NLE clip counts are small (dozens to low hundreds), so the DOM cost is
// bounded and virtualization's complexity isn't worth it. TODO: revisit and swap
// in a virtualizer if editorial workflows ever push very high clip counts.
// fallow-ignore-next-line complexity
displayTrackOrder.map((trackNum, row) => {
const rowHeight = getTimelineRowHeight(row, rowHeights);
virtualRows.map(({ index: row, rowKey }) => {
const trackNum = displayTrackOrder[row];
if (trackNum === undefined) return null;
const rowHeight = rowGeometry.getRowHeight(row);
const els = tracks.find(([t]) => t === trackNum)?.[1] ?? [];
const ts = trackStyles.get(trackNum) ?? getTrackStyle("");
const isPendingTrack =
Expand All @@ -131,14 +135,16 @@ export function TimelineLanes({
const keyframeClipExpanded =
keyframeClipKey != null && expandedClipIds.has(keyframeClipKey);
return (
<div
key={trackNum}
className="relative flex"
style={{
height: rowHeight,
background: rowBackground,
borderBottom: `1px solid ${theme.rowBorder}`,
}}
<TimelineTrackRow
key={rowKey}
index={row}
rowKey={rowKey}
rowCount={displayTrackOrder.length}
top={rowGeometry.getRowTop(row)}
height={rowHeight}
virtualized={rowsVirtualized}
background={rowBackground}
borderColor={theme.rowBorder}
>
<TimelineTrackHeader
trackNumber={trackNum}
Expand Down Expand Up @@ -501,10 +507,10 @@ export function TimelineLanes({
})
}
</div>
</div>
</TimelineTrackRow>
);
})
}
</>
</div>
);
}
Loading
Loading