mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
fix(studio): timeline clips keep their pictures while you scroll (#4305)
* fix(studio): timeline clips keep their pictures while you scroll With row virtualization on, the timeline dropped every clip's picture for the length of a scroll gesture and put it back 100ms after the gesture stopped, so a zoomed-in timeline showed grey boxes the whole time a person was moving through it. Clip content now stays mounted while scrolling; pictures already loaded come straight from the thumbnail cache. * perf(studio): clips that scroll into view get their pictures when the scroll stops Keeping every clip's content mounted through a scroll made each jump of the 50,000-clip viewport gate mount and lay out a screenful of new thumbnails, a 90 to 130 ms long task per run where main has none. A clip's content now mounts only while the timeline is at rest and then stays through later scrolls, so a picture already on screen never blanks and a scroll never mounts new ones.
This commit is contained in:
@@ -140,7 +140,7 @@ describe("Timeline row virtualization", { timeout: 30_000 }, () => {
|
||||
usePlayerStore.getState().reset();
|
||||
});
|
||||
|
||||
it("defers rich clip content while scrolling without replacing the clip shell", async () => {
|
||||
it("keeps a clip's picture on screen while the timeline scrolls", async () => {
|
||||
const [{ Timeline }, { usePlayerStore }] = await Promise.all([
|
||||
import("./Timeline"),
|
||||
import("../store/playerStore"),
|
||||
@@ -167,17 +167,62 @@ describe("Timeline row virtualization", { timeout: 30_000 }, () => {
|
||||
expect(scroller).not.toBeNull();
|
||||
expect(clip).not.toBeNull();
|
||||
expect(clip?.title).toBe("Clip 0 • 0.0s – 10.0s");
|
||||
expect(host.querySelector("[data-rich-content]")).not.toBeNull();
|
||||
const picture = host.querySelector("[data-rich-content]");
|
||||
expect(picture).not.toBeNull();
|
||||
|
||||
if (scroller) await dispatchScroll(scroller);
|
||||
expect(host.querySelector('[data-el-id="clip-0"]')).toBe(clip);
|
||||
expect(host.querySelector("[data-rich-content]")).toBeNull();
|
||||
expect(host.querySelector("[data-rich-content]")).toBe(picture);
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 110));
|
||||
});
|
||||
expect(host.querySelector('[data-el-id="clip-0"]')).toBe(clip);
|
||||
expect(host.querySelector("[data-rich-content]")).not.toBeNull();
|
||||
expect(host.querySelector("[data-rich-content]")).toBe(picture);
|
||||
} finally {
|
||||
act(() => root.unmount());
|
||||
usePlayerStore.getState().reset();
|
||||
}
|
||||
});
|
||||
|
||||
it("gives a clip that appears mid-scroll its picture once the scroll settles", async () => {
|
||||
const [{ Timeline }, { usePlayerStore }] = await Promise.all([
|
||||
import("./Timeline"),
|
||||
import("../store/playerStore"),
|
||||
]);
|
||||
const clip = (id: string, start: number) => ({
|
||||
id,
|
||||
label: id,
|
||||
tag: "div",
|
||||
start,
|
||||
duration: 5,
|
||||
track: 0,
|
||||
});
|
||||
usePlayerStore.setState({ duration: 60, timelineReady: true, elements: [clip("clip-0", 0)] });
|
||||
|
||||
const { host, root } = await mountTimeline(
|
||||
React.createElement(Timeline, {
|
||||
renderClipContent: (element: { id: string }) =>
|
||||
React.createElement("span", { "data-rich-content": element.id }),
|
||||
}),
|
||||
);
|
||||
try {
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 110));
|
||||
});
|
||||
const scroller = host.querySelector<HTMLElement>("[data-timeline-scroll-viewport]");
|
||||
if (scroller) await dispatchScroll(scroller);
|
||||
act(() => {
|
||||
usePlayerStore.setState({ elements: [clip("clip-0", 0), clip("clip-1", 5)] });
|
||||
});
|
||||
expect(host.querySelector('[data-el-id="clip-1"]')).not.toBeNull();
|
||||
expect(host.querySelector('[data-rich-content="clip-1"]')).toBeNull();
|
||||
expect(host.querySelector('[data-rich-content="clip-0"]')).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 110));
|
||||
});
|
||||
expect(host.querySelector('[data-rich-content="clip-1"]')).not.toBeNull();
|
||||
} finally {
|
||||
act(() => root.unmount());
|
||||
usePlayerStore.getState().reset();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type ReactNode } from "react";
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
||||
import type { TimelineTimeRange } from "../lib/timelineClipIndex";
|
||||
import type { TrackVisualStyle } from "./timelineIcons";
|
||||
@@ -29,6 +29,16 @@ function ClipLintDot({ element }: { element: TimelineElement }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mounts a clip's content only once the timeline is at rest, then keeps it through later scrolls,
|
||||
* so a scroll never blanks a picture already on screen and never mounts a screenful of new ones.
|
||||
*/
|
||||
export function ClipContentOnceShown({ hold, children }: { hold: boolean; children: ReactNode }) {
|
||||
const [shown, setShown] = useState(!hold);
|
||||
if (!shown && !hold) setShown(true);
|
||||
return shown ? children : null;
|
||||
}
|
||||
|
||||
export function renderClipChildren(
|
||||
element: TimelineElement,
|
||||
clipStyle: TrackVisualStyle,
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
buildTimelineMeta,
|
||||
resolveRenderClipContent,
|
||||
resolveResizingElementIds,
|
||||
shouldIgnoreTimelinePointerDown,
|
||||
type TimelineMetaBuilderInputs,
|
||||
@@ -85,12 +84,6 @@ describe("buildTimelineMeta", () => {
|
||||
});
|
||||
|
||||
describe("timeline provider branch helpers", () => {
|
||||
it("resolves virtualized clip content", () => {
|
||||
const render = () => null;
|
||||
expect(resolveRenderClipContent(true, true, render)).toBeUndefined();
|
||||
expect(resolveRenderClipContent(false, true, render)).toBe(render);
|
||||
});
|
||||
|
||||
it("resolves resizing element ids", () => {
|
||||
expect(resolveResizingElementIds(null)).toBeUndefined();
|
||||
expect(resolveResizingElementIds({ element: { id: "clip" } } as never)).toEqual(["clip"]);
|
||||
|
||||
@@ -4,7 +4,6 @@ import type {
|
||||
TimelineViewportProps,
|
||||
} from "./TimelineProvider";
|
||||
import type { ResizingClipState } from "./useTimelineClipDrag";
|
||||
import type { TimelineLaneBaseProps } from "./timelineLaneProps";
|
||||
import type { DraggedClipState } from "./timelineClipDragTypes";
|
||||
import type { MultiDragPreviewInput } from "./timelineMultiDragPreview";
|
||||
import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
|
||||
@@ -34,14 +33,6 @@ export function resolveResizingElementIds(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function resolveRenderClipContent(
|
||||
rowVirtualizationActive: boolean,
|
||||
isScrolling: boolean,
|
||||
renderClipContent: TimelineLaneBaseProps["renderClipContent"],
|
||||
): TimelineLaneBaseProps["renderClipContent"] {
|
||||
return rowVirtualizationActive && isScrolling ? undefined : renderClipContent;
|
||||
}
|
||||
|
||||
export function shouldIgnoreTimelinePointerDown(target: EventTarget | null): boolean {
|
||||
return target instanceof Element && target.closest("button, input, select, a") !== null;
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import type { ClipContextMenuState, TimelineContextValue } from "./TimelineProvi
|
||||
import {
|
||||
buildTimelineMeta,
|
||||
resolveMultiDragPreview,
|
||||
resolveRenderClipContent,
|
||||
resolveResizingElementIds,
|
||||
shouldIgnoreTimelinePointerDown,
|
||||
} from "./timelineProviderStateBuilders";
|
||||
@@ -22,6 +21,7 @@ import { useTimelineGeometry } from "./useTimelineGeometry";
|
||||
import { useAutoExpandKeyframedClips } from "./useAutoExpandKeyframedClips";
|
||||
import { GUTTER, LABEL_COL_W, TRACKS_LEFT_PAD } from "./timelineLayout";
|
||||
import { useTimelineScrollViewport } from "./useTimelineScrollViewport";
|
||||
import { ClipContentOnceShown } from "./timelineClipChildren";
|
||||
import { useResolvedTimelineEditCallbacks } from "./useResolvedTimelineEditCallbacks";
|
||||
import type { TimelineProps } from "./TimelineTypes";
|
||||
import {
|
||||
@@ -509,10 +509,16 @@ export function useTimelineProviderState({
|
||||
onRazorSplit: editContext.onRazorSplit,
|
||||
onRazorSplitAll: editContext.onRazorSplitAll,
|
||||
};
|
||||
const timelineRenderClipContent = resolveRenderClipContent(
|
||||
timelineFocus.rowVirtualizationActive,
|
||||
viewport.isScrolling,
|
||||
renderClipContent,
|
||||
const holdNewClipContent = timelineFocus.rowVirtualizationActive && viewport.isScrolling;
|
||||
const timelineRenderClipContent = useMemo<typeof renderClipContent>(
|
||||
() =>
|
||||
renderClipContent &&
|
||||
((element, style, context) => (
|
||||
<ClipContentOnceShown hold={holdNewClipContent}>
|
||||
{renderClipContent(element, style, context)}
|
||||
</ClipContentOnceShown>
|
||||
)),
|
||||
[holdNewClipContent, renderClipContent],
|
||||
);
|
||||
const timelineMeta = buildTimelineMeta({
|
||||
emptyState: {
|
||||
|
||||
Reference in New Issue
Block a user