Skip to content

Commit de76deb

Browse files
committed
perf(studio): find timeline transition seams once per track, not once per row
Every timeline render re-sorted every clip in the project once per visible row to find transition seams, then kept the row's own. On the 50,000-clip viewport fixture that sort was the largest CPU cost in a scroll. Seams are now derived once per change of the clip set, grouped by track, and each row looks up its own. Pairing within a track also fixes a missing transition badge when a clip on another track starts between the transition's two clips.
1 parent 6e5f460 commit de76deb

5 files changed

Lines changed: 78 additions & 13 deletions

File tree

‎packages/studio/src/player/components/TimelineLanes.test.tsx‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,16 @@ import { usePlayerStore, type TimelineElement } from "../store/playerStore";
1414
import type { MultiDragPreviewInput } from "./timelineMultiDragPreview";
1515
import type { TimelineEditCallbacks } from "./timelineCallbacks";
1616
import type { DraggedClipState, BlockedClipState } from "./useTimelineClipDrag";
17+
import * as transitionSeams from "./timelineTransitionSeams";
18+
19+
vi.mock("./timelineTransitionSeams", async (importOriginal) => {
20+
const actual = await importOriginal<typeof import("./timelineTransitionSeams")>();
21+
return {
22+
...actual,
23+
deriveTimelineTransitionSeams: vi.fn(actual.deriveTimelineTransitionSeams),
24+
deriveTimelineTransitionSeamsByTrack: vi.fn(actual.deriveTimelineTransitionSeamsByTrack),
25+
};
26+
});
1727

1828
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
1929

@@ -497,3 +507,21 @@ describe("TimelineLanes clip joins", () => {
497507
act(() => view.root.unmount());
498508
});
499509
});
510+
511+
describe("TimelineLanes transition seams", () => {
512+
it("derives transition seams once for every row, not once per row", () => {
513+
const derivations = [
514+
transitionSeams.deriveTimelineTransitionSeams,
515+
transitionSeams.deriveTimelineTransitionSeamsByTrack,
516+
].map((derive) => vi.mocked(derive));
517+
for (const derive of derivations) derive.mockClear();
518+
519+
const view = renderLanes({
520+
elements: [element("clip-a", 0), element("clip-b", TRACK_A), element("clip-c", TRACK_B)],
521+
});
522+
523+
expect(view.host.querySelectorAll("[data-timeline-row]").length).toBeGreaterThanOrEqual(3);
524+
expect(derivations.reduce((calls, derive) => calls + derive.mock.calls.length, 0)).toBe(1);
525+
act(() => view.root.unmount());
526+
});
527+
});

‎packages/studio/src/player/components/TimelineLanes.tsx‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
3333
import { timelineClipFocusId } from "./timelineNavigationIdentity";
3434
import { useTimelineKeyboardActor } from "./useTimelineKeyboardActor";
3535
import { TimelineTransitionOverlays } from "./TimelineTransitionOverlays";
36+
import { deriveTimelineTransitionSeamsByTrack } from "./timelineTransitionSeams";
3637

3738
export function TimelineLanes({
3839
pps,
@@ -105,8 +106,9 @@ export function TimelineLanes({
105106
const { collapsedGroupIds, expandedLaneOwnerIds, toggleGroupExpanded, toggleLaneOwnerExpanded } =
106107
useTimelineGroupDisclosure();
107108
const automationLanes = useAutomationLanes();
108-
const allTransitionElements = useMemo(
109-
() => tracks.flatMap(([, elements]) => elements.map(getPreviewElement)),
109+
const transitionSeamsByTrack = useMemo(
110+
() =>
111+
deriveTimelineTransitionSeamsByTrack(tracks.flatMap(([, els]) => els.map(getPreviewElement))),
110112
[getPreviewElement, tracks],
111113
);
112114
// A group's automation clock is COMPOSITION time (groups doc §1.3), so its
@@ -551,8 +553,7 @@ export function TimelineLanes({
551553
})
552554
}
553555
<TimelineTransitionOverlays
554-
transitionElements={allTransitionElements}
555-
track={trackNum}
556+
seams={transitionSeamsByTrack.get(trackNum) ?? []}
556557
rowElements={draggedClip?.started ? [] : automationElements}
557558
rowBackground={rowBackground}
558559
pixelsPerSecond={pps}

‎packages/studio/src/player/components/TimelineTransitionOverlays.tsx‎

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
11
import { CLIP_Y } from "./timelineLayout";
22
import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
33
import type { TimelineElement } from "../store/playerStore";
4-
import { deriveTimelineTransitionSeams } from "./timelineTransitionSeams";
4+
import type { TimelineTransitionSeam } from "./timelineTransitionSeams";
55
import { TimelineTransitionBadge } from "./TimelineTransitionBadge";
66
import { TimelineClipJoins } from "./TimelineClipJoins";
77

88
interface TimelineTransitionOverlaysProps {
9-
transitionElements: readonly TimelineElement[];
10-
track: number;
9+
seams: readonly TimelineTransitionSeam[];
1110
rowElements: readonly TimelineElement[];
1211
rowBackground: string;
1312
pixelsPerSecond: number;
@@ -17,18 +16,14 @@ interface TimelineTransitionOverlaysProps {
1716

1817
/** What a row draws where its clips meet: a hairline at an exact join, a badge over a transition. */
1918
export function TimelineTransitionOverlays({
20-
transitionElements,
21-
track,
19+
seams,
2220
rowElements,
2321
rowBackground,
2422
pixelsPerSecond,
2523
rowHeight,
2624
clipBarHeight,
2725
}: TimelineTransitionOverlaysProps) {
2826
const top = CLIP_Y + (clipBarHeight ?? rowHeight - 2 * CLIP_Y) / 2;
29-
const seams = deriveTimelineTransitionSeams(transitionElements).filter(
30-
(seam) => seam.incoming.track === track,
31-
);
3227
return (
3328
<>
3429
<TimelineClipJoins

‎packages/studio/src/player/components/timelineTransitionSeams.test.ts‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import { describe, expect, it } from "vitest";
2-
import { deriveTimelineTransitionSeams } from "./timelineTransitionSeams";
2+
import {
3+
deriveTimelineTransitionSeams,
4+
deriveTimelineTransitionSeamsByTrack,
5+
} from "./timelineTransitionSeams";
36
import type { TimelineElement } from "../store/playerStore";
47

58
const clip = (
@@ -46,3 +49,20 @@ describe("deriveTimelineTransitionSeams", () => {
4649
expect(deriveTimelineTransitionSeams([clip("a", 0, 2), clip("b", 2, 1)])).toEqual([]);
4750
});
4851
});
52+
53+
describe("deriveTimelineTransitionSeamsByTrack", () => {
54+
it("finds each track's transitions even when another track's clip starts between them", () => {
55+
const label = "hf:transition:out:in:crossfade";
56+
const other = "hf:transition:a:b:wipe";
57+
const byTrack = deriveTimelineTransitionSeamsByTrack([
58+
clip("out", 0, 2, 0, label),
59+
clip("in", 1.8, 2, 0, label),
60+
clip("a", 5, 2, 2, other),
61+
clip("b", 6.5, 2, 2, other),
62+
clip("alone", 0, 3, 1),
63+
]);
64+
expect(byTrack.get(0)?.map((seam) => seam.incoming.id)).toEqual(["in"]);
65+
expect(byTrack.get(2)?.map((seam) => seam.incoming.id)).toEqual(["b"]);
66+
expect(byTrack.has(1)).toBe(false);
67+
});
68+
});

‎packages/studio/src/player/components/timelineTransitionSeams.ts‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,3 +34,24 @@ export function deriveTimelineTransitionSeams(
3434
}
3535
return seams;
3636
}
37+
38+
/**
39+
* Transition seams keyed by track, derived once for every row. Pairs are found within a track, so a clip on
40+
* another track that starts between a transition's two clips cannot hide it.
41+
*/
42+
export function deriveTimelineTransitionSeamsByTrack(
43+
elements: readonly TimelineElement[],
44+
): ReadonlyMap<number, readonly TimelineTransitionSeam[]> {
45+
const tracks = new Map<number, TimelineElement[]>();
46+
for (const element of elements) {
47+
const track = tracks.get(element.track);
48+
if (track) track.push(element);
49+
else tracks.set(element.track, [element]);
50+
}
51+
const byTrack = new Map<number, readonly TimelineTransitionSeam[]>();
52+
for (const [track, trackElements] of tracks) {
53+
const seams = deriveTimelineTransitionSeams(trackElements);
54+
if (seams.length > 0) byTrack.set(track, seams);
55+
}
56+
return byTrack;
57+
}

0 commit comments

Comments
 (0)