perf(studio): find timeline transition seams once per track, not once per row (#4324)

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.
This commit is contained in:
Miguel Ángel
2026-09-23 09:20:19 -04:00
committed by GitHub
parent ed75203cb6
commit e02120fd0e
5 changed files with 78 additions and 13 deletions
@@ -14,6 +14,16 @@ import { usePlayerStore, type TimelineElement } from "../store/playerStore";
import type { MultiDragPreviewInput } from "./timelineMultiDragPreview";
import type { TimelineEditCallbacks } from "./timelineCallbacks";
import type { DraggedClipState, BlockedClipState } from "./useTimelineClipDrag";
import * as transitionSeams from "./timelineTransitionSeams";
vi.mock("./timelineTransitionSeams", async (importOriginal) => {
const actual = await importOriginal<typeof import("./timelineTransitionSeams")>();
return {
...actual,
deriveTimelineTransitionSeams: vi.fn(actual.deriveTimelineTransitionSeams),
deriveTimelineTransitionSeamsByTrack: vi.fn(actual.deriveTimelineTransitionSeamsByTrack),
};
});
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
@@ -497,3 +507,21 @@ describe("TimelineLanes clip joins", () => {
act(() => view.root.unmount());
});
});
describe("TimelineLanes transition seams", () => {
it("derives transition seams once for every row, not once per row", () => {
const derivations = [
transitionSeams.deriveTimelineTransitionSeams,
transitionSeams.deriveTimelineTransitionSeamsByTrack,
].map((derive) => vi.mocked(derive));
for (const derive of derivations) derive.mockClear();
const view = renderLanes({
elements: [element("clip-a", 0), element("clip-b", TRACK_A), element("clip-c", TRACK_B)],
});
expect(view.host.querySelectorAll("[data-timeline-row]").length).toBeGreaterThanOrEqual(3);
expect(derivations.reduce((calls, derive) => calls + derive.mock.calls.length, 0)).toBe(1);
act(() => view.root.unmount());
});
});
@@ -33,6 +33,7 @@ import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
import { timelineClipFocusId } from "./timelineNavigationIdentity";
import { useTimelineKeyboardActor } from "./useTimelineKeyboardActor";
import { TimelineTransitionOverlays } from "./TimelineTransitionOverlays";
import { deriveTimelineTransitionSeamsByTrack } from "./timelineTransitionSeams";
export function TimelineLanes({
pps,
@@ -105,8 +106,9 @@ export function TimelineLanes({
const { collapsedGroupIds, expandedLaneOwnerIds, toggleGroupExpanded, toggleLaneOwnerExpanded } =
useTimelineGroupDisclosure();
const automationLanes = useAutomationLanes();
const allTransitionElements = useMemo(
() => tracks.flatMap(([, elements]) => elements.map(getPreviewElement)),
const transitionSeamsByTrack = useMemo(
() =>
deriveTimelineTransitionSeamsByTrack(tracks.flatMap(([, els]) => els.map(getPreviewElement))),
[getPreviewElement, tracks],
);
// A group's automation clock is COMPOSITION time (groups doc §1.3), so its
@@ -551,8 +553,7 @@ export function TimelineLanes({
})
}
<TimelineTransitionOverlays
transitionElements={allTransitionElements}
track={trackNum}
seams={transitionSeamsByTrack.get(trackNum) ?? []}
rowElements={draggedClip?.started ? [] : automationElements}
rowBackground={rowBackground}
pixelsPerSecond={pps}
@@ -1,13 +1,12 @@
import { CLIP_Y } from "./timelineLayout";
import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
import type { TimelineElement } from "../store/playerStore";
import { deriveTimelineTransitionSeams } from "./timelineTransitionSeams";
import type { TimelineTransitionSeam } from "./timelineTransitionSeams";
import { TimelineTransitionBadge } from "./TimelineTransitionBadge";
import { TimelineClipJoins } from "./TimelineClipJoins";
interface TimelineTransitionOverlaysProps {
transitionElements: readonly TimelineElement[];
track: number;
seams: readonly TimelineTransitionSeam[];
rowElements: readonly TimelineElement[];
rowBackground: string;
pixelsPerSecond: number;
@@ -17,8 +16,7 @@ interface TimelineTransitionOverlaysProps {
/** What a row draws where its clips meet: a hairline at an exact join, a badge over a transition. */
export function TimelineTransitionOverlays({
transitionElements,
track,
seams,
rowElements,
rowBackground,
pixelsPerSecond,
@@ -26,9 +24,6 @@ export function TimelineTransitionOverlays({
clipBarHeight,
}: TimelineTransitionOverlaysProps) {
const top = CLIP_Y + (clipBarHeight ?? rowHeight - 2 * CLIP_Y) / 2;
const seams = deriveTimelineTransitionSeams(transitionElements).filter(
(seam) => seam.incoming.track === track,
);
return (
<>
<TimelineClipJoins
@@ -1,5 +1,8 @@
import { describe, expect, it } from "vitest";
import { deriveTimelineTransitionSeams } from "./timelineTransitionSeams";
import {
deriveTimelineTransitionSeams,
deriveTimelineTransitionSeamsByTrack,
} from "./timelineTransitionSeams";
import type { TimelineElement } from "../store/playerStore";
const clip = (
@@ -46,3 +49,20 @@ describe("deriveTimelineTransitionSeams", () => {
expect(deriveTimelineTransitionSeams([clip("a", 0, 2), clip("b", 2, 1)])).toEqual([]);
});
});
describe("deriveTimelineTransitionSeamsByTrack", () => {
it("finds each track's transitions even when another track's clip starts between them", () => {
const label = "hf:transition:out:in:crossfade";
const other = "hf:transition:a:b:wipe";
const byTrack = deriveTimelineTransitionSeamsByTrack([
clip("out", 0, 2, 0, label),
clip("in", 1.8, 2, 0, label),
clip("a", 5, 2, 2, other),
clip("b", 6.5, 2, 2, other),
clip("alone", 0, 3, 1),
]);
expect(byTrack.get(0)?.map((seam) => seam.incoming.id)).toEqual(["in"]);
expect(byTrack.get(2)?.map((seam) => seam.incoming.id)).toEqual(["b"]);
expect(byTrack.has(1)).toBe(false);
});
});
@@ -34,3 +34,24 @@ export function deriveTimelineTransitionSeams(
}
return seams;
}
/**
* Transition seams keyed by track, derived once for every row. Pairs are found within a track, so a clip on
* another track that starts between a transition's two clips cannot hide it.
*/
export function deriveTimelineTransitionSeamsByTrack(
elements: readonly TimelineElement[],
): ReadonlyMap<number, readonly TimelineTransitionSeam[]> {
const tracks = new Map<number, TimelineElement[]>();
for (const element of elements) {
const track = tracks.get(element.track);
if (track) track.push(element);
else tracks.set(element.track, [element]);
}
const byTrack = new Map<number, readonly TimelineTransitionSeam[]>();
for (const [track, trackElements] of tracks) {
const seams = deriveTimelineTransitionSeams(trackElements);
if (seams.length > 0) byTrack.set(track, seams);
}
return byTrack;
}