perf(studio): films with thousands of timed clips stop re-reading every clip each frame (#6805)

* perf(studio): films with thousands of timed clips stop re-reading every clip each frame

* perf(studio): drop the added comment lines the ratchet counts
This commit is contained in:
Miguel Ángel
2026-10-30 07:27:26 +00:00
committed by GitHub
parent b93c01c75a
commit d83f276743
5 changed files with 113 additions and 12 deletions
@@ -0,0 +1,57 @@
// @vitest-environment happy-dom
import { afterEach, expect, it, vi } from "vitest";
import { initSandboxRuntimeModular } from "./init";
import { createMockTimeline, resetRuntimeFixtureDom } from "./runtimeSeekFixture.test-helpers";
afterEach(() => {
window.__hfRuntimeTeardown?.();
vi.restoreAllMocks();
resetRuntimeFixtureDom();
});
it("reads a far clip's start once while playing, not on every frame", () => {
resetRuntimeFixtureDom();
let now = 0;
let id = 0;
const callbacks = new Map<number, FrameRequestCallback>();
vi.spyOn(performance, "now").mockImplementation(() => now);
vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => {
callbacks.set(++id, callback);
return id;
});
vi.spyOn(window, "cancelAnimationFrame").mockImplementation((key) => {
callbacks.delete(key);
});
const step = (frames: number) => {
for (let i = 0; i < frames; i++) {
now += 33;
const pending = [...callbacks.values()];
callbacks.clear();
for (const callback of pending) callback(now);
}
};
const words = Array.from(
{ length: 300 },
(_, i) => `<span id="w${i}" class="clip" data-start="${5 + i * 0.1}" data-duration="1"></span>`,
);
document.body.innerHTML = `<div data-composition-id="main" data-root="true" data-duration="60">${words.join("")}</div>`;
window.__timelines = { main: createMockTimeline(60) };
initSandboxRuntimeModular();
const player = window.__player!;
const far = document.getElementById("w299")!;
const startReads = vi.fn();
const getAttribute = far.getAttribute.bind(far);
far.getAttribute = (name: string) => {
if (name === "data-start") startReads();
return getAttribute(name);
};
player.play();
step(5);
const afterWarmUp = startReads.mock.calls.length;
step(60);
expect(player.isPlaying()).toBe(true);
expect(startReads.mock.calls.length - afterWarmUp).toBe(0);
});
+15 -4
View File
@@ -2948,12 +2948,23 @@ export function initSandboxRuntimeModular(): void {
// A clip's first visible style pass is slow; playing, each hidden clip gets it once just before its start.
const clipsNeverStyledVisible = new Set<StylableElement>();
const clipsStyledVisible = new WeakSet<Element>();
const styleClipsDueVisibleOnce = (t: number) => {
let neverStyledStarts = { revision: -1, starts: new Map<StylableElement, number>() };
const neverStyledStart = (el: StylableElement, revision: number): number => {
if (neverStyledStarts.revision !== revision)
neverStyledStarts = { revision, starts: new Map() };
let start = neverStyledStarts.starts.get(el);
if (start === undefined) {
start = resolveStartForElement(el, Number.NaN);
neverStyledStarts.starts.set(el, start);
}
return start;
};
const styleClipsDueVisibleOnce = (t: number, revision: number) => {
if (clipsNeverStyledVisible.size === 0 || !clock.isPlaying()) return;
const clips: StylableElement[] = [];
for (const el of clipsNeverStyledVisible) {
const start = resolveStartForElement(el, Number.NaN);
if (el.isConnected && el.hasAttribute(CLIP_HIDDEN_ATTR) && !(start <= t + LOOKAHEAD_SECONDS))
const start = neverStyledStart(el, revision);
if (!(start <= t + LOOKAHEAD_SECONDS) && el.isConnected && el.hasAttribute(CLIP_HIDDEN_ATTR))
continue;
clipsNeverStyledVisible.delete(el);
if (el.isConnected && el.hasAttribute(CLIP_HIDDEN_ATTR) && start > t) clips.push(el);
@@ -5290,7 +5301,7 @@ export function initSandboxRuntimeModular(): void {
const revision = opts?.revision ?? readCompositionTimingRevision();
if (revision !== tweensRenderedThrough.revision)
tweensRenderedThrough = { revision, time: Number.NEGATIVE_INFINITY };
styleClipsDueVisibleOnce(t);
styleClipsDueVisibleOnce(t, revision);
const firstRenders = t > tweensRenderedThrough.time;
// Only clips holding a tween that renders for the first time on this seek need their authored style read.
const firstRendered =
@@ -167,3 +167,24 @@ describe("live DOM manifest hydration", () => {
expect(findTimelineDomNodeForClip(doc, clip({ start: 9 }), 0)?.id).toBe("stable");
});
});
describe("selector occurrence indexes while hydrating many clips", () => {
it("queries the document once for a class many clips share, not once per clip", () => {
const count = 200;
const spans = Array.from(
{ length: count },
(_, i) =>
`<span class="clip w" data-start="${i}" data-duration="1" data-track-index="0"></span>`,
).join("");
const doc = documentWith(spans);
const query = vi.spyOn(doc, "querySelectorAll");
const clips = Array.from({ length: count }, (_, i) => clip({ tagName: "span", start: i }));
const elements = buildTimelineElementsFromClips(clips, doc);
expect(elements).toHaveLength(count);
expect(query.mock.calls.filter(([selector]) => selector === ".w").length).toBeLessThanOrEqual(
1,
);
});
});
@@ -9,6 +9,7 @@
* an argument, so each is callable — and readable — on its own.
*/
import { withSelectorIndexPass } from "../../utils/sourceScopedSelectorIndex";
import { createTimelineDomNodeResolver, findClipElementById } from "../lib/timelineElementHelpers";
import { usePlayerStore } from "../store/playerStore";
import type { TimelineElement, DomClipChild, SubCompositionHostState } from "../store/playerStore";
@@ -214,15 +215,17 @@ export function buildTimelineElementsFromClips(
iframeDoc: Document | null,
): TimelineElement[] {
const resolveHost = iframeDoc ? createTimelineDomNodeResolver(iframeDoc) : null;
return clips.map((clip, index) => {
const hostEl = resolveHost?.(clip, index) ?? null;
return createTimelineElementFromManifestClip({
clip,
fallbackIndex: index,
doc: iframeDoc,
hostEl,
const build = () =>
clips.map((clip, index) => {
const hostEl = resolveHost?.(clip, index) ?? null;
return createTimelineElementFromManifestClip({
clip,
fallbackIndex: index,
doc: iframeDoc,
hostEl,
});
});
});
return iframeDoc ? withSelectorIndexPass(iframeDoc, build) : build();
}
/**
@@ -10,6 +10,7 @@
import type { TimelineElement } from "../store/playerStore";
import type { ClipManifestClip, IframeWindow, TimelineLike } from "./playbackTypes";
import { withSelectorIndexPass } from "../../utils/sourceScopedSelectorIndex";
import { resolveCssStackingContextId } from "@hyperframes/core/runtime/stacking-context";
import { readClipTiming } from "@hyperframes/core/composition-contract";
import { readMasterFxChain } from "@hyperframes/core/audio-groups";
@@ -234,6 +235,14 @@ export function parseTimelineFromDOM(
doc: Document,
rootDuration: number,
timelines?: Readonly<Record<string, TimelineLike>>,
): TimelineElement[] {
return withSelectorIndexPass(doc, () => scanTimelineFromDOM(doc, rootDuration, timelines));
}
function scanTimelineFromDOM(
doc: Document,
rootDuration: number,
timelines?: Readonly<Record<string, TimelineLike>>,
): TimelineElement[] {
const rootComp = doc.querySelector("[data-composition-id]");
const nodes = doc.querySelectorAll("[data-start]");