mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
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:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user