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 @@ -71,6 +71,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
78 changes: 78 additions & 0 deletions packages/studio/src/player/components/Timeline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
getTimelineCanvasHeight,
resolveTimelineAssetDrop,
getTimelinePlayheadLeft,
getTimelinePlaybackFollowScrollLeft,
getTimelineScrollLeftForZoomAnchor,
getTimelineScrollLeftForZoomTransition,
shouldShowTimelineShortcutHint,
Expand Down Expand Up @@ -274,6 +275,33 @@ 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());
});

// fallow-ignore-next-line code-duplication
it("renders the gutter without legacy icons or hue dots", () => {
const { host, root } = renderBasicTimeline();

Expand Down Expand Up @@ -974,6 +1002,56 @@ describe("getTimelinePlayheadLeft", () => {
});
});

describe("getTimelinePlaybackFollowScrollLeft", () => {
it("holds the viewport still while the playhead remains inside the comfort area", () => {
expect(
getTimelinePlaybackFollowScrollLeft({
playheadX: 700,
currentScrollLeft: 100,
viewportWidth: 1000,
contentOrigin: 264,
maxScrollLeft: 2000,
}),
).toBe(100);
});

it("follows forward playback at the right-side comfort line", () => {
expect(
getTimelinePlaybackFollowScrollLeft({
playheadX: 1200,
currentScrollLeft: 100,
viewportWidth: 1000,
contentOrigin: 264,
maxScrollLeft: 2000,
}),
).toBe(384);
});

it("returns to the matching earlier viewport after a playback loop", () => {
expect(
getTimelinePlaybackFollowScrollLeft({
playheadX: 264,
currentScrollLeft: 900,
viewportWidth: 1000,
contentOrigin: 264,
maxScrollLeft: 2000,
}),
).toBe(0);
});

it("clamps at the end of the scrollable timeline", () => {
expect(
getTimelinePlaybackFollowScrollLeft({
playheadX: 5000,
currentScrollLeft: 100,
viewportWidth: 1000,
contentOrigin: 264,
maxScrollLeft: 1500,
}),
).toBe(1500);
});
});

describe("getTimelineCanvasHeight", () => {
it("includes bottom scroll buffer below the last track", () => {
expect(getTimelineCanvasHeight(3)).toBeGreaterThan(RULER_H + 3 * TRACK_H);
Expand Down
51 changes: 23 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 @@ -42,7 +42,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 All @@ -51,6 +51,7 @@ export {
shouldAutoScrollTimeline,
getTimelineScrollLeftForZoomTransition,
getTimelineScrollLeftForZoomAnchor,
getTimelinePlaybackFollowScrollLeft,
getTimelinePlayheadLeft,
getTimelineCanvasHeight,
shouldShowTimelineShortcutHint,
Expand Down Expand Up @@ -126,6 +127,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 @@ -284,32 +286,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 @@ -462,6 +453,7 @@ export const Timeline = memo(function Timeline({
<div
ref={setScrollRef}
data-timeline-scroll-viewport
data-timeline-auto-scroll-left-inset={labelMode ? LABEL_COL_W : 0}
tabIndex={-1}
className={`${zoomMode === "fit" ? "overflow-x-hidden" : "overflow-x-auto"} overflow-y-auto h-full outline-none`}
onScroll={(e) => {
Expand Down Expand Up @@ -498,6 +490,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();
}, 10_000);
});
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
Loading
Loading