diff --git a/packages/core/src/runtime/init.mediaClipIndex.test.ts b/packages/core/src/runtime/init.mediaClipIndex.test.ts new file mode 100644 index 0000000000..3af044546d --- /dev/null +++ b/packages/core/src/runtime/init.mediaClipIndex.test.ts @@ -0,0 +1,222 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { RuntimeTimelineLike } from "./types"; +import { + createMockTimeline, + installImmediateAnimationFrame, + resetRuntimeFixtureDom, +} from "./runtimeSeekFixture.test-helpers"; + +/** + * `visits` counts the media elements handed to `syncRuntimeMedia` — the pass's + * own definition of "visited", since every per-clip effect happens inside that + * loop. + * + * `disableNarrowing` neutralises ONLY the index's element narrowing, leaving + * every other line of the runtime identical. That is what makes the cross-check + * below a comparison of the indexed path against the full-visit path, rather + * than a comparison of two differently-configured runtimes. + */ +const mediaSpy = vi.hoisted(() => ({ visits: [] as number[], disableNarrowing: false })); + +vi.mock("./media", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + refreshRuntimeMediaCache: (params?: Parameters[0]) => + actual.refreshRuntimeMediaCache( + mediaSpy.disableNarrowing && params ? { ...params, elements: undefined } : params, + ), + syncRuntimeMedia: (params: Parameters[0]) => { + mediaSpy.visits.push(params.clips.length); + return actual.syncRuntimeMedia(params); + }, + }; +}); + +const { initSandboxRuntimeModular } = await import("./init"); + +/** + * `count` back-to-back one-second clips, so the transport crosses exactly one + * start and one end per second of travel and the flip set is knowable without + * reproducing the index's arithmetic. + */ +function mountClips(count: number): void { + const clips = Array.from( + { length: count }, + (_unused, i) => ``, + ).join(""); + document.body.innerHTML = `
${clips}
`; + for (const video of document.querySelectorAll("video")) { + Object.defineProperty(video, "duration", { value: 1, configurable: true }); + } + window.__timelines = { main: createMockTimeline(count) }; +} + +/** Every observable the media pass can write, for every element. Compared + * between the two arms instead of re-deriving what each should have been. */ +function mediaState(): Array> { + return Array.from(document.querySelectorAll("video, audio")).map((raw) => { + const el = raw as HTMLMediaElement; + return { + id: el.id, + paused: el.paused, + currentTime: el.currentTime, + volume: el.volume, + muted: el.muted, + visibility: (el as HTMLElement).style.visibility, + }; + }); +} + +describe("per-seek media clip index", () => { + const originalRequestAnimationFrame = window.requestAnimationFrame; + const originalCancelAnimationFrame = window.cancelAnimationFrame; + + beforeEach(() => { + resetRuntimeFixtureDom(); + installImmediateAnimationFrame(); + mediaSpy.visits = []; + mediaSpy.disableNarrowing = false; + }); + + afterEach(() => { + window.__hfRuntimeTeardown?.(); + window.requestAnimationFrame = originalRequestAnimationFrame; + window.cancelAnimationFrame = originalCancelAnimationFrame; + document.body.innerHTML = ""; + window.__timelines = {} as Record; + delete window.__player; + delete window.__playerReady; + delete window.__renderReady; + mediaSpy.disableNarrowing = false; + }); + + /** Elements visited by ONE single-frame seek, after the transport has already + * settled at a time (so the sweep has a previous position to work from). */ + function visitsForOneFrameSeek(clipCount: number): number { + mountClips(clipCount); + initSandboxRuntimeModular(); + window.__player!.seek(3.0); + mediaSpy.visits = []; + window.__player!.seek(3.02); + expect(mediaSpy.visits).toHaveLength(1); + return mediaSpy.visits[0]!; + } + + /** + * The load-bearing assertion, and it is INVARIANCE, not a threshold. A + * threshold ("visits fewer than 20 elements") passes on a small fixture while + * a real composition of eighty videos still visits eighty. Quadrupling the + * clip count must not change what one frame costs at all. + * + * Before the index, both numbers were the clip count: every seek rebuilt the + * window of every media element in the document and handed all of them to the + * per-clip loop. + */ + it("visits the same number of clips for a one-frame seek at n clips and at 4n", () => { + const few = visitsForOneFrameSeek(8); + const many = visitsForOneFrameSeek(32); + + expect(many).toBe(few); + // A ceiling too: within one frame at t=3.0 only the clip covering [3,4) is + // in window and no boundary is crossed, so this is the active set alone. + expect(many).toBeLessThanOrEqual(2); + }); + + it("still visits every clip whose window a long jump crosses", () => { + mountClips(32); + initSandboxRuntimeModular(); + window.__player!.seek(1.0); + mediaSpy.visits = []; + // Twenty seconds of travel crosses twenty starts and twenty ends. The point + // is that the index does not silently skip them — a sublinear seek that + // under-visits leaves elements playing. + window.__player!.seek(21.0); + expect(mediaSpy.visits[0]!).toBeGreaterThanOrEqual(20); + }); + + /** + * The equivalence half, and the one that catches a wrong optimisation rather + * than a missing one. Random seek sequences, and after every single seek the + * indexed runtime's media state must equal the full-visit runtime's — the + * same code with only the narrowing turned off. + * + * Deterministic: a fixed seed, so a failure is reproducible rather than a + * flake someone reruns until it passes. + */ + it("leaves every media element in the same state as a full-visit pass", () => { + let seed = 0x5eed; + const random = () => { + seed = (seed * 1103515245 + 12345) & 0x7fffffff; + return seed / 0x7fffffff; + }; + + const runSequence = (times: number[], disableNarrowing: boolean) => { + mediaSpy.disableNarrowing = disableNarrowing; + mountClips(12); + initSandboxRuntimeModular(); + const states: Array>> = []; + for (const time of times) { + window.__player!.seek(time); + states.push(mediaState()); + } + window.__hfRuntimeTeardown?.(); + document.body.innerHTML = ""; + return states; + }; + + for (let sequence = 0; sequence < 4; sequence += 1) { + // A mix of single frames, mid-clip nudges and long jumps in both + // directions — the shapes that decide whether the sweep is right. + const times = Array.from({ length: 16 }, () => Math.round(random() * 1200) / 100); + expect(runSequence(times, false), `seek sequence ${sequence}`).toEqual( + runSequence(times, true), + ); + } + // Two full runtimes per sequence, and under coverage instrumentation that + // runs well past the 5s default. An explicit budget, not a global bump. + }, 30_000); + + /** + * The render path must not consult the index at all: capture depends on the + * exact state of every element on every frame, and a frame captured against a + * stale one cannot be recovered. Same latch, and the same reasoning, as the + * duration-floor cache. + */ + it("visits every clip on a render-capture seek, however small the step", () => { + mountClips(16); + initSandboxRuntimeModular(); + window.__player!.renderSeek(3.0); + mediaSpy.visits = []; + window.__player!.renderSeek(3.02); + + expect(mediaSpy.visits[0]!).toBe(16); + }); + + it("reseeds after a render seek, so a later live seek cannot trust a stale sweep", () => { + mountClips(16); + initSandboxRuntimeModular(); + window.__player!.renderSeek(3.0); + mediaSpy.visits = []; + // The sweep has no trustworthy previous position, so this visits everything + // rather than assuming the render pass left it one. + window.__player!.seek(3.02); + expect(mediaSpy.visits[0]!).toBe(16); + }); + + it("re-derives the index when a clip is moved on the timeline", () => { + mountClips(16); + initSandboxRuntimeModular(); + window.__player!.seek(3.0); + + // clip15 lived at [15,16). Moved onto the playhead it must become active, + // which it cannot if the index still holds its old window. + document.querySelector("#clip15")!.setAttribute("data-start", "3"); + mediaSpy.visits = []; + window.__player!.seek(3.01); + + expect(document.querySelector("#clip15")!.style.visibility).not.toBe( + "hidden", + ); + }); +}); diff --git a/packages/core/src/runtime/init.timingResolver.test.ts b/packages/core/src/runtime/init.timingResolver.test.ts index e5f5df483a..8393c28089 100644 --- a/packages/core/src/runtime/init.timingResolver.test.ts +++ b/packages/core/src/runtime/init.timingResolver.test.ts @@ -1,5 +1,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { RuntimeTimelineLike } from "./types"; +import { + createMockTimeline, + installImmediateAnimationFrame, + resetRuntimeFixtureDom, + stubDuration, +} from "./runtimeSeekFixture.test-helpers"; const resolverSpy = vi.hoisted(() => ({ constructions: 0 })); const mediaSpy = vi.hoisted(() => ({ beforeSync: null as null | (() => void) })); @@ -30,49 +35,13 @@ vi.mock("./media", async (importOriginal) => { const { initSandboxRuntimeModular } = await import("./init"); -function createMockTimeline(duration: number): RuntimeTimelineLike { - const state = { time: 0, paused: true }; - return { - play: () => { - state.paused = false; - }, - pause: () => { - state.paused = true; - }, - seek: (time?: number) => (time === undefined ? state.time : (state.time = time)), - totalTime: (time?: number) => (time === undefined ? state.time : (state.time = time)), - time: () => state.time, - duration: () => duration, - add: () => {}, - paused: (value?: boolean) => - typeof value === "boolean" ? (state.paused = value) : state.paused, - timeScale: () => {}, - set: () => {}, - getChildren: () => [], - }; -} - -/** jsdom leaves `duration` as NaN; a writable getter also lets a test mimic the - * `el.load()` reset `syncRuntimeMedia` performs on the seek-retry path. */ -function stubDuration(el: HTMLMediaElement, initial: number): { set: (next: number) => void } { - let value = initial; - Object.defineProperty(el, "duration", { get: () => value, configurable: true }); - return { set: (next: number) => (value = next) }; -} - describe("runtime timing resolver scoping", () => { const originalRequestAnimationFrame = window.requestAnimationFrame; const originalCancelAnimationFrame = window.cancelAnimationFrame; beforeEach(() => { - document.body.innerHTML = ""; - (globalThis as typeof globalThis & { CSS?: { escape?: (value: string) => string } }).CSS ??= {}; - globalThis.CSS.escape ??= (value: string) => value; - window.requestAnimationFrame = ((callback: FrameRequestCallback) => { - callback(0); - return 1; - }) as typeof window.requestAnimationFrame; - window.cancelAnimationFrame = (() => {}) as typeof window.cancelAnimationFrame; + resetRuntimeFixtureDom(); + installImmediateAnimationFrame(); resolverSpy.constructions = 0; mediaSpy.beforeSync = null; }); diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index b1ccb39811..3a7f2742a9 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -25,6 +25,7 @@ import { refreshRuntimeMediaCache, resolveRuntimeMediaClipDuration, resolveNaturalMediaTimelineDuration, + type RuntimeMediaClip, syncRuntimeMedia, } from "./media"; import { handleErrorForProxy, handleMetadataForProxy, maybeProxyProactively } from "./mediaProxy"; @@ -975,13 +976,21 @@ export function initSandboxRuntimeModular(): void { "id", "src", ]; - const DURATION_FLOOR_MEDIA_EVENTS = ["loadedmetadata", "durationchange", "emptied"] as const; + const COMPOSITION_TIMING_MEDIA_EVENTS = ["loadedmetadata", "durationchange", "emptied"] as const; let durationFloorsCache: DurationFloors | null = null; - let durationFloorsRegistrySignature: string | null = null; - let durationFloorsObserver: MutationObserver | null = null; + let durationFloorsRevision = -1; + let timelineRegistrySignature: string | null = null; + let compositionTimingObserver: MutationObserver | null = null; - const invalidateDurationFloors = () => { - durationFloorsCache = null; + // Bumped whenever anything above can have moved a clip's window. Two caches + // read it: the duration floors, and the media clip index below. It is a + // counter rather than a per-cache flag because DRAINING the observer is what + // detects a change, and only the first reader in a task gets the records — a + // second cache checking for itself would be told nothing changed. + let compositionTimingRevision = 0; + + const invalidateCompositionTimingCaches = () => { + compositionTimingRevision += 1; }; const deriveDurationFloors = (): DurationFloors => ({ @@ -1000,10 +1009,10 @@ export function initSandboxRuntimeModular(): void { return signature; }; - const watchDurationFloorInputs = () => { - if (durationFloorsObserver || typeof MutationObserver === "undefined") return; - durationFloorsObserver = new MutationObserver(invalidateDurationFloors); - durationFloorsObserver.observe(document.documentElement, { + const watchCompositionTimingInputs = () => { + if (compositionTimingObserver || typeof MutationObserver === "undefined") return; + compositionTimingObserver = new MutationObserver(invalidateCompositionTimingCaches); + compositionTimingObserver.observe(document.documentElement, { childList: true, subtree: true, attributes: true, @@ -1012,40 +1021,55 @@ export function initSandboxRuntimeModular(): void { // Media duration changes are published as events, not DOM mutations, and // they do not bubble — so listen in the capture phase, which reaches every // media element including ones added long after this binds. - for (const eventType of DURATION_FLOOR_MEDIA_EVENTS) { - document.addEventListener(eventType, invalidateDurationFloors, true); + for (const eventType of COMPOSITION_TIMING_MEDIA_EVENTS) { + document.addEventListener(eventType, invalidateCompositionTimingCaches, true); } runtimeCleanupCallbacks.push(() => { - durationFloorsObserver?.disconnect(); - durationFloorsObserver = null; - invalidateDurationFloors(); - for (const eventType of DURATION_FLOOR_MEDIA_EVENTS) { - document.removeEventListener(eventType, invalidateDurationFloors, true); + compositionTimingObserver?.disconnect(); + compositionTimingObserver = null; + invalidateCompositionTimingCaches(); + for (const eventType of COMPOSITION_TIMING_MEDIA_EVENTS) { + document.removeEventListener(eventType, invalidateCompositionTimingCaches, true); } }); }; + /** + * The current revision, after taking every signal that could have moved it. + * Call this exactly once per read, and compare it with the revision a cache + * was built at. + */ + const readCompositionTimingRevision = (): number => { + watchCompositionTimingInputs(); + // MutationObserver records are delivered in a microtask, so a caller that + // edits a timing attribute and reads a window back in the SAME synchronous + // block would otherwise be served the pre-edit value. Draining the queue + // here makes the caches correct within a task, not just across tasks. + // Taking the records suppresses the callback for them, which is exactly + // equivalent since the callback only bumps the revision. + if (compositionTimingObserver && compositionTimingObserver.takeRecords().length > 0) { + invalidateCompositionTimingCaches(); + } + // `window.__timelines` is a plain object: no MutationObserver and no event + // can see a composition script registering into it or lengthening what it + // already registered, so its shape is compared on every read. + const signature = readTimelineRegistrySignature(); + if (signature !== timelineRegistrySignature) { + timelineRegistrySignature = signature; + invalidateCompositionTimingCaches(); + } + return compositionTimingRevision; + }; + const resolveDurationFloors = (): DurationFloors => { // The render path never reads a cached floor. Capture depends on the exact // duration and a frame that rendered against a wrong one cannot be // recovered, so it pays the full derivation on every frame exactly as // before — a correct slow render beats a fast wrong one. if (renderCaptureSeekStarted) return deriveDurationFloors(); - watchDurationFloorInputs(); - // MutationObserver records are delivered in a microtask, so a caller that - // edits a timing attribute and reads the duration back in the SAME - // synchronous block would otherwise be served the pre-edit value. Draining - // the queue here makes the cache correct within a task, not just across - // tasks. Taking the records suppresses the callback for them, which is - // exactly equivalent since the callback only marks the cache stale. - if (durationFloorsObserver && durationFloorsObserver.takeRecords().length > 0) { - invalidateDurationFloors(); - } - const registrySignature = readTimelineRegistrySignature(); - if (durationFloorsCache && registrySignature === durationFloorsRegistrySignature) { - return durationFloorsCache; - } - durationFloorsRegistrySignature = registrySignature; + const revision = readCompositionTimingRevision(); + if (durationFloorsCache && durationFloorsRevision === revision) return durationFloorsCache; + durationFloorsRevision = revision; durationFloorsCache = deriveDurationFloors(); return durationFloorsCache; }; @@ -2304,48 +2328,162 @@ export function initSandboxRuntimeModular(): void { visibilityNodes: Element[] = Array.from(document.querySelectorAll("[data-start]")), ) => withTimingResolver(() => applyTimedElementVisibility(currentTime, visibilityNodes)); + /** + * Clip windows sorted by each endpoint, so a seek can ask which windows it + * crossed instead of asking every clip whether it is active. + * + * Rebuilt whenever the composition's timing inputs move — the same revision + * the duration floors ride on, because the two are derived from the same + * attributes, the same media metadata events and the same timeline registry. + */ + interface MediaClipIndex { + revision: number; + clips: RuntimeMediaClip[]; + byStart: RuntimeMediaClip[]; + byEnd: RuntimeMediaClip[]; + } + let mediaClipIndex: MediaClipIndex | null = null; + // The transport time the last sync ran at, and the clips whose authored window + // contained it. `null` means "unknown": the next pass visits everything and + // reseeds. Only ever advanced when `syncRuntimeMedia` actually ran, so a pass + // skipped for any reason widens the next sweep rather than skipping the + // boundaries crossed in between. + let lastSyncedMediaTimeSeconds: number | null = null; + let mediaClipsInWindow: RuntimeMediaClip[] = []; + + const buildRuntimeMediaCache = (elements?: Array) => + refreshRuntimeMediaCache({ + elements, + shouldIncludeElement: (element) => + element.hasAttribute("data-start") || + Boolean(resolveMediaCompositionContext(element).compositionRoot), + resolveStartSeconds: (element) => { + return resolveAbsoluteMediaStartSeconds(element); + }, + resolveDurationSeconds: (element) => { + const context = resolveMediaCompositionContext(element); + const start = resolveAbsoluteMediaStartSeconds(element); + const hostRemaining = + context.inheritedStart != null && + context.inheritedDuration != null && + context.inheritedDuration > 0 + ? Math.max(0, context.inheritedStart + context.inheritedDuration - start) + : null; + const sourceDuration = Number.isFinite(element.duration) + ? resolveNaturalMediaTimelineDuration(element, element.duration) + : null; + // The element's own data-duration is an explicit clip-length trim + // (the studio writes it when you drag the clip edge). It must bound + // playback so a trimmed track stops at its edge instead of running on + // to the source-file or host-composition end. Absent → no cap (an + // untrimmed clip plays its natural source length). + const ownDuration = parseStrictFiniteTimingNumber(element.dataset.duration); + const explicitDuration = ownDuration != null && ownDuration > 0 ? ownDuration : null; + return resolveRuntimeMediaClipDuration({ + isVideo: element.tagName === "VIDEO", + sourceDuration, + hostRemaining, + explicitDuration, + }); + }, + }); + + const resolveMediaClipIndex = (): MediaClipIndex => { + const revision = readCompositionTimingRevision(); + if (mediaClipIndex && mediaClipIndex.revision === revision) return mediaClipIndex; + const clips = buildRuntimeMediaCache().mediaClips; + mediaClipIndex = { + revision, + clips, + byStart: [...clips].sort((a, b) => a.start - b.start), + byEnd: [...clips].sort((a, b) => a.end - b.end), + }; + // The windows moved, so what the sweep remembers being in-window is about + // clips that no longer describe this composition. Reseed on the next pass. + lastSyncedMediaTimeSeconds = null; + mediaClipsInWindow = []; + return mediaClipIndex; + }; + + /** Clips whose `endpoint` lies in [lo, hi], via binary search on the sorted + * array. Inclusive at both ends: over-visiting is free, under-visiting is a + * clip left playing. */ + const clipsWithEndpointBetween = ( + sorted: RuntimeMediaClip[], + endpoint: (clip: RuntimeMediaClip) => number, + lo: number, + hi: number, + ): RuntimeMediaClip[] => { + let low = 0; + let high = sorted.length; + while (low < high) { + const mid = (low + high) >> 1; + if (endpoint(sorted[mid]!) < lo) low = mid + 1; + else high = mid; + } + const crossed: RuntimeMediaClip[] = []; + for (let i = low; i < sorted.length && endpoint(sorted[i]!) <= hi; i += 1) + crossed.push(sorted[i]!); + return crossed; + }; + + /** + * The media elements this pass must visit, and the argument that the rest can + * be skipped. + * + * `isActive` requires `start <= t < end`, so a clip whose window excludes the + * new time is inactive there no matter what its element state is. A clip that + * is in neither the previous in-window set nor the set of windows whose + * endpoint the transport just crossed was therefore out of window BEFORE and + * is out of window NOW — the pass would only have evicted sync state that is + * already empty and paused an element already paused, both of which it was + * left in the last time it went out of window, by a pass that did visit it. + * + * The endpoint search is what makes a jump honest rather than lucky: seeking + * over a hundred clips visits the hundred boundaries it crossed, and a + * single-frame step visits the one or two that flip. + */ + const collectMediaElementsToVisit = ( + index: MediaClipIndex, + toSeconds: number, + ): Array => { + const fromSeconds = lastSyncedMediaTimeSeconds; + if (fromSeconds === null) return index.clips.map((clip) => clip.el); + const lo = Math.min(fromSeconds, toSeconds); + const hi = Math.max(fromSeconds, toSeconds); + const visiting = new Set(); + for (const clip of mediaClipsInWindow) visiting.add(clip.el); + for (const clip of clipsWithEndpointBetween(index.byStart, (c) => c.start, lo, hi)) { + visiting.add(clip.el); + } + for (const clip of clipsWithEndpointBetween(index.byEnd, (c) => c.end, lo, hi)) { + visiting.add(clip.el); + } + return [...visiting]; + }; + const syncMediaForCurrentState = () => { // Scope 1 of 3 (see `withTimingResolver`). Closes before `syncRuntimeMedia`, // which may call `el.load()` and invalidate every cached duration. - const cache = withTimingResolver(() => - refreshRuntimeMediaCache({ - shouldIncludeElement: (element) => - element.hasAttribute("data-start") || - Boolean(resolveMediaCompositionContext(element).compositionRoot), - resolveStartSeconds: (element) => { - return resolveAbsoluteMediaStartSeconds(element); - }, - resolveDurationSeconds: (element) => { - const context = resolveMediaCompositionContext(element); - const start = resolveAbsoluteMediaStartSeconds(element); - const hostRemaining = - context.inheritedStart != null && - context.inheritedDuration != null && - context.inheritedDuration > 0 - ? Math.max(0, context.inheritedStart + context.inheritedDuration - start) - : null; - const sourceDuration = Number.isFinite(element.duration) - ? resolveNaturalMediaTimelineDuration(element, element.duration) - : null; - // The element's own data-duration is an explicit clip-length trim - // (the studio writes it when you drag the clip edge). It must bound - // playback so a trimmed track stops at its edge instead of running on - // to the source-file or host-composition end. Absent → no cap (an - // untrimmed clip plays its natural source length). - const ownDuration = parseStrictFiniteTimingNumber(element.dataset.duration); - const explicitDuration = ownDuration != null && ownDuration > 0 ? ownDuration : null; - return resolveRuntimeMediaClipDuration({ - isVideo: element.tagName === "VIDEO", - sourceDuration, - hostRemaining, - explicitDuration, - }); - }, - }), - ); + // + // The render/export path never consults the index: capture depends on the + // exact state of every element on every frame and a frame captured against a + // stale one cannot be recovered, so it visits all of them exactly as before. + // Same reasoning, and the same latch, as the duration floors above. + const indexed = !renderCaptureSeekStarted; + const mediaClips = withTimingResolver(() => { + if (!indexed) return buildRuntimeMediaCache().mediaClips; + const index = resolveMediaClipIndex(); + // Windows come from the index, but every field handed to `syncRuntimeMedia` + // is re-read here: `el.duration` is reset by any `el.load()` the retry path + // made last pass, and a cached copy of it would be exactly the stale + // duration the two-scope rule exists to prevent. + return buildRuntimeMediaCache(collectMediaElementsToVisit(index, state.currentTime)) + .mediaClips; + }); // Attach probed volume keyframes to clips so syncRuntimeMedia can use the // same envelope the renderer uses instead of tracking GSAP-change diffs. - for (const clip of cache.mediaClips) { + for (const clip of mediaClips) { const kf = volumeKeyframeCache.get(clip.el as HTMLMediaElement); if (kf) clip.volumeKeyframes = kf; } @@ -2354,7 +2492,7 @@ export function initSandboxRuntimeModular(): void { if (forceSync) state.mediaForceSyncNextTick = false; if (!state.nativeMediaSyncDisabled) { syncRuntimeMedia({ - clips: cache.mediaClips, + clips: mediaClips, timeSeconds: state.currentTime, playing: state.isPlaying, playbackRate: state.playbackRate, @@ -2372,6 +2510,17 @@ export function initSandboxRuntimeModular(): void { postRuntimeMessage({ source: "hf-preview", type: "media-autoplay-blocked" }); }, }); + if (indexed) { + lastSyncedMediaTimeSeconds = state.currentTime; + // Every clip not visited was out of window at both ends of this seek, so + // the visited ones are the only possible members. + mediaClipsInWindow = mediaClips.filter( + (clip) => state.currentTime >= clip.start && state.currentTime < clip.end, + ); + } else { + lastSyncedMediaTimeSeconds = null; + mediaClipsInWindow = []; + } } syncTimedElementVisibility(state.currentTime); }; diff --git a/packages/core/src/runtime/media.ts b/packages/core/src/runtime/media.ts index c66293a3ac..abb774a139 100644 --- a/packages/core/src/runtime/media.ts +++ b/packages/core/src/runtime/media.ts @@ -59,15 +59,28 @@ export function refreshRuntimeMediaCache(params?: { resolveStartSeconds?: (element: Element) => number; resolveDurationSeconds?: (element: HTMLVideoElement | HTMLAudioElement) => number | null; shouldIncludeElement?: (element: HTMLVideoElement | HTMLAudioElement) => boolean; + /** + * Build clips for exactly these elements instead of scanning the document for + * them. For a caller that already knows which media it needs this pass — the + * per-seek sync only touches the clips whose active state can change — and so + * should not pay to re-derive the window of every clip in the composition. + * + * Every field is still read FRESH from the element. This narrows which + * elements are visited, never how current the answer is: `el.duration` can be + * reset under the caller by an `el.load()` it did not make. + */ + elements?: Array; }): { timedMediaEls: Array; mediaClips: RuntimeMediaClip[]; videoClips: RuntimeMediaClip[]; maxMediaEnd: number; } { - const mediaEls = Array.from(document.querySelectorAll("video, audio")) as Array< - HTMLVideoElement | HTMLAudioElement - >; + const mediaEls = + params?.elements ?? + (Array.from(document.querySelectorAll("video, audio")) as Array< + HTMLVideoElement | HTMLAudioElement + >); const timedMediaEls = params?.shouldIncludeElement ? mediaEls.filter((el) => params.shouldIncludeElement?.(el)) : mediaEls.filter((el) => el.hasAttribute("data-start")); diff --git a/packages/core/src/runtime/runtimeSeekFixture.test-helpers.ts b/packages/core/src/runtime/runtimeSeekFixture.test-helpers.ts new file mode 100644 index 0000000000..72fc84d649 --- /dev/null +++ b/packages/core/src/runtime/runtimeSeekFixture.test-helpers.ts @@ -0,0 +1,64 @@ +/** + * Shared fixture for the runtime suites that drive real seeks through + * `initSandboxRuntimeModular` — the timing-resolver scoping suite and the media + * clip index suite. + * + * Not a `.test.ts` file on purpose: vitest's `include` is `src/**\/*.test.ts`, + * so this is imported, never collected. Each suite keeps its own `vi.mock` + * calls, which are file-scoped and cannot be shared. + */ +import type { RuntimeTimelineLike } from "./types"; + +/** A paused timeline of a fixed duration, registered on `window.__timelines`. + * The runtime only syncs a clock duration when a timeline is BOUND, so a + * fixture without one never reaches the code these suites are about. */ +export function createMockTimeline(duration: number): RuntimeTimelineLike { + const state = { time: 0, paused: true }; + return { + play: () => { + state.paused = false; + }, + pause: () => { + state.paused = true; + }, + seek: (time?: number) => (time === undefined ? state.time : (state.time = time)), + totalTime: (time?: number) => (time === undefined ? state.time : (state.time = time)), + time: () => state.time, + duration: () => duration, + add: () => {}, + paused: (value?: boolean) => + typeof value === "boolean" ? (state.paused = value) : state.paused, + timeScale: () => {}, + set: () => {}, + getChildren: () => [], + }; +} + +/** Empty document plus the `CSS.escape` jsdom does not ship, which the runtime's + * selector building needs. */ +export function resetRuntimeFixtureDom(): void { + document.body.innerHTML = ""; + (globalThis as typeof globalThis & { CSS?: { escape?: (value: string) => string } }).CSS ??= {}; + globalThis.CSS.escape ??= (value: string) => value; +} + +/** Run every animation frame callback immediately, so a test can drive the + * transport without waiting on a real frame clock. */ +export function installImmediateAnimationFrame(): void { + window.requestAnimationFrame = ((callback: FrameRequestCallback) => { + callback(0); + return 1; + }) as typeof window.requestAnimationFrame; + window.cancelAnimationFrame = (() => {}) as typeof window.cancelAnimationFrame; +} + +/** jsdom leaves `duration` as NaN; a writable getter also lets a test mimic the + * `el.load()` reset `syncRuntimeMedia` performs on the seek-retry path. */ +export function stubDuration( + el: HTMLMediaElement, + initial: number, +): { set: (next: number) => void } { + let value = initial; + Object.defineProperty(el, "duration", { get: () => value, configurable: true }); + return { set: (next: number) => (value = next) }; +} diff --git a/packages/core/tsconfig.runtime.json b/packages/core/tsconfig.runtime.json index 2b7e0e8b0c..3a03dd81ab 100644 --- a/packages/core/tsconfig.runtime.json +++ b/packages/core/tsconfig.runtime.json @@ -5,5 +5,5 @@ }, "files": [], "include": ["src/runtime/**/*.ts"], - "exclude": ["node_modules", "dist", "**/*.test.ts"] + "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.test-helpers.ts"] } diff --git a/packages/studio/src/components/editor/domEditLayerWalkCache.test.ts b/packages/studio/src/components/editor/domEditLayerWalkCache.test.ts new file mode 100644 index 0000000000..4d79394307 --- /dev/null +++ b/packages/studio/src/components/editor/domEditLayerWalkCache.test.ts @@ -0,0 +1,284 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it } from "vitest"; +import { + DOM_EDIT_LAYER_OBSERVER_INIT, + type DomEditLayerWalkCache, + createDomEditLayerWalkCache, + drainPendingLayerMutations, +} from "./domEditLayerWalkCache"; +import { collectDomEditLayerItems } from "./domEditingLayers"; +import type { DomEditLayerItem } from "./domEditingTypes"; + +const opts = { activeCompositionPath: "index.html", isMasterView: true }; + +/** Attached, not detached: the occurrence index is resolved with a whole-document + * query, so a detached fixture would number every element as a miss. */ +function mountPreview(cardCount: number): HTMLElement { + const cards = Array.from( + { length: cardCount }, + (_unused, i) => + `
card ${i}t${i}
`, + ).join(""); + document.body.innerHTML = `
${cards}
`; + return document.body.firstElementChild as HTMLElement; +} + +afterEach(() => { + document.body.innerHTML = ""; +}); + +/** Everything a caller can observe about a walk. Compared field by field so a + * divergence names itself instead of failing on object identity. */ +function shape(items: DomEditLayerItem[]): unknown { + return items.map((item) => ({ + key: item.key, + label: item.label, + tagName: item.tagName, + depth: item.depth, + childCount: item.childCount, + id: item.id, + hfId: item.hfId, + selector: item.selector, + selectorIndex: item.selectorIndex, + sourceFile: item.sourceFile, + element: item.element, + })); +} + +/** + * Count `getComputedStyle` calls, which is the per-element cost this cache + * exists to avoid: `isInspectableLayerElement` makes one for the element and + * `getDirectLayerChildren` one more for each direct child. A count that does not + * grow with the document is the whole claim. + */ +function countingComputedStyle(): { calls: () => number; restore: () => void } { + const real = window.getComputedStyle.bind(window); + let calls = 0; + window.getComputedStyle = ((el: Element, pseudo?: string | null) => { + calls += 1; + return real(el, pseudo ?? undefined); + }) as typeof window.getComputedStyle; + return { calls: () => calls, restore: () => (window.getComputedStyle = real) }; +} + +interface WarmWalk { + root: HTMLElement; + cache: DomEditLayerWalkCache; + observer: MutationObserver; +} + +/** + * A mounted preview whose cache has already done one full walk, with the + * observer live. Every test here needs exactly this before it can measure what + * a SECOND walk costs, so it lives in one place. + */ +function withWarmWalk(cardCount: number, run: (ctx: WarmWalk) => T): T { + const root = mountPreview(cardCount); + const cache = createDomEditLayerWalkCache(); + const observer = new MutationObserver(() => {}); + observer.observe(document.documentElement, DOM_EDIT_LAYER_OBSERVER_INIT); + try { + collectDomEditLayerItems(root, opts, undefined, cache); + return run({ root, cache, observer }); + } finally { + observer.disconnect(); + } +} + +describe("collectDomEditLayerItems incremental rebuild", () => { + /** + * The load-bearing assertion, and it is INVARIANCE, not a threshold: a + * threshold ("under 20 style reads") passes on a small fixture and still + * degrades linearly on a real preview. Quadrupling the card count must not + * change what one style edit costs at all. + * + * Before the cache both numbers tracked the document size (every element and + * every direct child re-derived on every mutation, whatever the mutation was). + */ + function styleEditRebuildCost(cardCount: number): number { + return withWarmWalk(cardCount, ({ root, cache, observer }) => { + // What animation writes on a frame: one element's transform, nothing that + // can change any element's selector, membership or label. + root.querySelector(".box")!.style.transform = "translateX(4px)"; + cache.ingest(observer.takeRecords()); + + const probe = countingComputedStyle(); + try { + collectDomEditLayerItems(root, opts, undefined, cache); + return probe.calls(); + } finally { + probe.restore(); + } + }); + } + + it("costs the same for one style edit at n cards and at 4n", () => { + const small = styleEditRebuildCost(12); + const large = styleEditRebuildCost(48); + + expect(large).toBe(small); + // A ceiling as well, so a future change that reintroduces a per-element term + // fails here even if it happens to be document-size-flat. The edited element, + // its parent, and each of their direct children is the whole budget. + expect(small).toBeLessThanOrEqual(12); + }); + + it("performs no document query at all for an edit that touches no selector", () => { + withWarmWalk(12, ({ root, cache, observer }) => { + root.querySelector(".tag")!.style.opacity = "0.5"; + cache.ingest(observer.takeRecords()); + + const real = document.querySelectorAll.bind(document); + let queries = 0; + Object.defineProperty(document, "querySelectorAll", { + configurable: true, + value: (selector: string) => { + queries += 1; + return real(selector); + }, + }); + try { + collectDomEditLayerItems(root, opts, undefined, cache); + } finally { + delete (document as Partial).querySelectorAll; + } + // The only document query in the walk resolves a selector's occurrence + // index, and no cached selector was invalidated. + expect(queries).toBe(0); + }); + }); + + /** + * The equivalence half, and the one that would catch a wrong optimisation + * rather than a missing one. Every mutation KIND the invalidation reasons + * about is applied in turn, and after each the incremental walk must equal a + * walk with no cache at all — which is what every other caller still gets. + */ + it("matches an uncached walk after every kind of mutation it reasons about", () => { + const root = mountPreview(6); + const cache = createDomEditLayerWalkCache(); + const observer = new MutationObserver(() => {}); + observer.observe(document.documentElement, DOM_EDIT_LAYER_OBSERVER_INIT); + // Captured up front: later cases change class and id, so re-querying `.box` + // would hand the next case a different element than it names. + const cards = Array.from(root.querySelectorAll(".box")); + const box = (i: number) => cards[i]!; + + const mutations: Array<[string, () => void]> = [ + ["a style write that cannot change membership", () => (box(0).style.opacity = "0.5")], + // Inherited: the subtree's computed visibility moves with it. + ["a style write that hides a subtree", () => (box(1).style.visibility = "hidden")], + ["clearing that same subtree hide", () => (box(1).style.visibility = "")], + // display:none removes the element from the list and shifts its + // descendants' depth by one, without touching their own computed display. + ["display:none on a card", () => (box(2).style.display = "none")], + // Renumbers `.box` for every card after it, and relabels this one. + ["a class change that renumbers a shared selector", () => (box(3).className = "crate")], + ["an id, which outranks the class selector", () => (box(4).id = "hero")], + ["edited text, which is a label input", () => (box(5).firstChild!.textContent = "renamed")], + // The unattributable case: the documented full-rebuild fallback. + ["a node inserted mid-document", () => box(0).append(document.createElement("i"))], + ["a node removed", () => box(0).querySelector("b")!.remove()], + ["a data attribute nothing derives from", () => box(0).setAttribute("data-x", "1")], + ]; + + try { + collectDomEditLayerItems(root, opts, undefined, cache); + for (const [what, mutate] of mutations) { + mutate(); + cache.ingest(observer.takeRecords()); + expect( + shape(collectDomEditLayerItems(root, opts, undefined, cache)), + `incremental walk diverged after ${what}`, + ).toEqual(shape(collectDomEditLayerItems(root, opts))); + } + } finally { + observer.disconnect(); + } + }); + + /** + * The subtree drop is the one expensive branch in the ingest path, and it runs + * in the raw observer callback — not behind the rebuild throttle. GSAP's + * `autoAlpha` writes `visibility: visible` into inline style ONCE on fade-in + * and leaves it there, so a rule that fires whenever the style attribute + * mentions visibility fires on every transform tick for the rest of the clip: + * a whole-subtree walk per animation frame, for the exact elements a + * composition animates most. + */ + it("does not re-walk a subtree when a faded-in element merely animates", () => { + const root = mountPreview(6); + const cache = createDomEditLayerWalkCache(); + const observer = new MutationObserver(() => {}); + observer.observe(document.documentElement, DOM_EDIT_LAYER_OBSERVER_INIT); + const card = root.querySelector(".box")!; + try { + collectDomEditLayerItems(root, opts, undefined, cache); + // The fade-in itself. This one legitimately invalidates the subtree. + card.style.visibility = "visible"; + cache.ingest(observer.takeRecords()); + + let subtreeWalks = 0; + const real = Element.prototype.querySelectorAll; + Element.prototype.querySelectorAll = function (this: Element, selector: string) { + if (selector === "*") subtreeWalks += 1; + return real.call(this, selector); + } as typeof Element.prototype.querySelectorAll; + try { + // Ordinary animation frames. `visibility: visible` is still sitting in + // the attribute, but the value has not moved. + card.style.transform = "translateX(1px)"; + cache.ingest(observer.takeRecords()); + card.style.transform = "translateX(2px)"; + cache.ingest(observer.takeRecords()); + } finally { + Element.prototype.querySelectorAll = real; + } + + expect(subtreeWalks).toBe(0); + } finally { + observer.disconnect(); + } + }); + + it("drops everything when the walk is re-scoped to a different composition", () => { + const root = mountPreview(3); + const cache = createDomEditLayerWalkCache(); + collectDomEditLayerItems(root, opts, undefined, cache); + + const rescoped = { ...opts, activeCompositionPath: "scenes/two.html" }; + expect(shape(collectDomEditLayerItems(root, rescoped, undefined, cache))).toEqual( + shape(collectDomEditLayerItems(root, rescoped)), + ); + }); +}); + +describe("drainPendingLayerMutations", () => { + it("applies records the observer has not delivered yet", () => { + withWarmWalk(6, ({ root, cache, observer }) => { + const card = root.querySelector(".box")!; + // Not delivered: the callback runs in a microtask, and this test never + // yields to one. + card.id = "renamed"; + + drainPendingLayerMutations(observer, cache); + + // An identity attribute drops the whole cache, so the walk after the drain + // must agree with an uncached walk. + expect(shape(collectDomEditLayerItems(root, opts, undefined, cache))).toEqual( + shape(collectDomEditLayerItems(root, opts)), + ); + // And the records really were consumed by the drain, not still queued. + expect(observer.takeRecords()).toHaveLength(0); + }); + }); + + it("is a no-op when no observer is attached yet", () => { + withWarmWalk(6, ({ root, cache }) => { + expect(() => drainPendingLayerMutations(null, cache)).not.toThrow(); + expect(collectDomEditLayerItems(root, opts, undefined, cache)).toHaveLength( + collectDomEditLayerItems(root, opts).length, + ); + }); + }); +}); diff --git a/packages/studio/src/components/editor/domEditLayerWalkCache.ts b/packages/studio/src/components/editor/domEditLayerWalkCache.ts new file mode 100644 index 0000000000..1105e237cb --- /dev/null +++ b/packages/studio/src/components/editor/domEditLayerWalkCache.ts @@ -0,0 +1,327 @@ +/** + * Incremental memory for `collectDomEditLayerItems`. + * + * The layer walk is re-run from scratch every time a MutationObserver says the + * preview changed, and the observer's loudest source is inline `style` — which + * is exactly what animation writes. So a paused-looking editor re-derived every + * element's patch target, label and child count several times a second, and each + * derivation costs two `getComputedStyle` reads (its own plus one per direct + * child), two ancestor walks for the source file, and a `textContent` read whose + * cost is the element's whole subtree. + * + * None of that depends on the traversal — only on the element. So the traversal + * stays live (it is pointer-chasing, and `depth` genuinely is positional) and the + * per-element half is memoized, with the mutation records deciding what to drop. + * + * WHAT MAKES A CACHED ENTRY WRONG, and how each is caught: + * + * - the element's own attributes (`style` -> computed display, `class`/`id`/ + * `data-hf-group` -> selector and label) -> attribute record on it + * - an ancestor's `visibility`, the ONLY inherited input to the + * display/visibility gate (`display:none` on an ancestor does not change a + * descendant's own computed `display`) -> attribute record on the + * ancestor, whose subtree is + * dropped only when the + * style declares visibility + * - a direct child appearing or disappearing from the layer list, which moves + * the parent's `childCount` -> the parent is dropped + * alongside every element + * - descendant text, which moves an ancestor's label -> characterData record, + * ancestors dropped + * - selector occurrence indices, which shift for EVERY element sharing a + * selector when one element's identity changes -> whole cache dropped + * - nodes added or removed, which shift indices, order, labels and counts at + * once and cannot be attributed to one element -> whole cache dropped + * - a different active composition path, or a new composition-id -> source-file + * map, both of which re-scope every source file -> checked per walk + * + * The bias is deliberate and matches the duration cache in the runtime: a + * redundant recomputation is a missed optimisation, a missed one is a wrong + * layer tree. + */ + +import { buildElementLabel, getCompositionSourceMapRevision, isHtmlElement } from "./domEditingDom"; +import { + getDirectLayerChildren, + isInspectableLayerElement, + resolveDomLayerIdentity, +} from "./domEditingElement"; +import type { DomEditSelection } from "./domEditingTypes"; + +/** How an element is addressed. Survives style writes; see `readIdentity`. */ +export type DomEditLayerIdentity = Pick< + DomEditSelection, + "id" | "hfId" | "selector" | "selectorIndex" | "sourceFile" +>; + +/** Whether the element renders, and how many of its direct children are layers. + * Both are computed style reads, and both a style write can flip. Filled + * lazily: `childCount` is only asked for once an element is known to be one. */ +interface PresenceMemo { + inspectable?: boolean; + childCount?: number; +} + +interface IdentityMemo { + identity?: DomEditLayerIdentity | null; + label?: string; +} + +/** + * Attributes that decide an element's SELECTOR, and therefore the occurrence + * index of every other element sharing that selector. One of these changing + * renumbers elements the record does not mention, so the only sound response is + * to drop everything. `class` is here because `buildStableSelector` falls back + * to the preferred class. + */ +const SELECTOR_IDENTITY_ATTRIBUTES = new Set([ + "id", + "class", + "data-hf-group", + "data-composition-id", + "data-composition-file", + "data-composition-src", + "data-hf-original-composition-id", +]); + +/** + * The observer configuration the cache's invalidation is written against. + * Exported so the refresh loop and the tests cannot drift from it. + * + * No `attributeFilter`: an unlisted attribute is one the cache would never learn + * about, and serving a stale entry is worse than the extra records. + */ +export const DOM_EDIT_LAYER_OBSERVER_INIT: MutationObserverInit = { + attributes: true, + characterData: true, + childList: true, + subtree: true, +}; + +/** + * The inline `visibility` each element last presented. Deliberately NOT cleared + * by `invalidateAll`: it records what the DOM said, not a value derived from it. + */ +const lastInlineVisibility = new WeakMap(); + +/** + * Did this style write move the element's inline `visibility`? + * + * `visibility` is inherited, so changing it changes the layer-list membership of + * the element's whole subtree, and only then is the subtree worth dropping. + * + * The comparison is on the PARSED value, remembered per element. Testing the + * attribute TEXT for "visibility" looks equivalent and is not: the standard + * GSAP fade (`autoAlpha`) writes `visibility: visible` once and leaves it in the + * attribute for the rest of the clip, so a text test stays true for every + * transform tick afterwards. That is a whole-subtree walk per animation frame, + * in the observer callback rather than behind the rebuild throttle, on exactly + * the elements a composition animates most. + * + * An element seen for the first time reports changed, which costs one drop and + * is the safe answer: its entries were derived before this write. + */ +function inlineVisibilityChanged(el: HTMLElement): boolean { + const next = el.style.visibility; + if (lastInlineVisibility.get(el) === next) return false; + lastInlineVisibility.set(el, next); + return true; +} + +export interface DomEditLayerWalkCache { + /** + * Start a walk. Drops everything when the scoping inputs the entries were + * built under no longer hold. Call once per `collectDomEditLayerItems`. + */ + beginWalk(activeCompositionPath: string | null): void; + /** + * Does `el` render? Cheap to invalidate and invalidated often: any attribute + * write on the element, on its parent, or a `visibility` write on an ancestor + * drops it, because all three can flip the answer. + */ + readInspectable(el: HTMLElement, compute: () => boolean): boolean; + /** How many of `el`'s direct children are layers. Same lifetime as + * `readInspectable`: it is a sum over their answers. */ + readChildCount(el: HTMLElement, compute: () => number): number; + /** + * `el`'s selector, occurrence index and source file. A SEPARATE, much longer + * lifetime than presence: a style write cannot renumber a selector, so this + * survives one, and it is the only part of the walk that queries the + * document. That split is what makes an ordinary animation frame cost no + * document queries at all. + */ + readIdentity( + el: HTMLElement, + compute: () => DomEditLayerIdentity | null, + ): DomEditLayerIdentity | null; + /** `el`'s display label. Shares the identity lifetime, plus descendant text. */ + readLabel(el: HTMLElement, compute: () => string): string; + /** Apply mutation records. Safe to call with an empty array. */ + ingest(records: MutationRecord[]): void; + /** Forget every entry — the fallback for anything not attributable. */ + invalidateAll(): void; +} + +export function createDomEditLayerWalkCache(): DomEditLayerWalkCache { + let presence = new WeakMap(); + let identities = new WeakMap(); + let scope: string | null = null; + + const invalidateAll = () => { + presence = new WeakMap(); + identities = new WeakMap(); + }; + + const drop = (el: HTMLElement | null) => { + if (el) presence.delete(el); + }; + + const dropSubtree = (el: HTMLElement) => { + for (const descendant of el.querySelectorAll("*")) { + if (isHtmlElement(descendant)) presence.delete(descendant); + } + }; + + const ingestOne = (record: MutationRecord): boolean => { + // Structural change: order, occurrence indices, labels and child counts all + // move at once, and the record names the parent rather than everything + // affected. This is the documented full-rebuild fallback. + if (record.type === "childList") return false; + + if (record.type === "characterData") { + // `buildElementLabel` falls back to `textContent`, so edited text changes + // the label of every ancestor that contains it. + for ( + let ancestor = record.target.parentElement; + ancestor; + ancestor = ancestor.parentElement + ) { + identities.delete(ancestor); + } + return true; + } + + const target = record.target; + if (!isHtmlElement(target)) return true; + if (record.attributeName && SELECTOR_IDENTITY_ATTRIBUTES.has(record.attributeName)) + return false; + + drop(target); + // The parent counts its layer children, and this element may have just + // joined or left that count. + drop(target.parentElement); + if (record.attributeName === "style" && inlineVisibilityChanged(target)) dropSubtree(target); + return true; + }; + + return { + beginWalk(activeCompositionPath) { + const nextScope = `${activeCompositionPath ?? ""}|${getCompositionSourceMapRevision()}`; + if (nextScope === scope) return; + scope = nextScope; + invalidateAll(); + }, + readInspectable(el, compute) { + const memo = presence.get(el) ?? {}; + if (memo.inspectable === undefined) { + memo.inspectable = compute(); + presence.set(el, memo); + } + return memo.inspectable; + }, + readChildCount(el, compute) { + const memo = presence.get(el) ?? {}; + if (memo.childCount === undefined) { + memo.childCount = compute(); + presence.set(el, memo); + } + return memo.childCount; + }, + readIdentity(el, compute) { + const memo = identities.get(el) ?? {}; + if (memo.identity === undefined) { + memo.identity = compute(); + identities.set(el, memo); + } + return memo.identity; + }, + readLabel(el, compute) { + const memo = identities.get(el) ?? {}; + if (memo.label === undefined) { + memo.label = compute(); + identities.set(el, memo); + } + return memo.label; + }, + ingest(records) { + for (const record of records) { + if (!ingestOne(record)) { + invalidateAll(); + return; + } + } + }, + invalidateAll, + }; +} + +/** One element's contribution to a layer walk. `depth` is absent on purpose: it + * belongs to the traversal, not to the element. */ +export interface DomEditLayerWalkEntry { + target: DomEditLayerIdentity; + label: string; + childCount: number; +} + +/** + * Everything `collectDomEditLayerItems` needs about ONE element, served from + * `cache` where it is still valid. Null when the element is not a layer. + * + * The four reads go through the cache separately rather than as one record + * because they do not go stale together: a style write flips whether an element + * renders without touching how it is addressed. Without a cache this is exactly + * the original per-element derivation, which is what every other caller of the + * walk still gets. + * + * Lives here rather than in the walk so the two halves of the memoization — + * what is stored and what is read — sit in one file. + */ +export function readDomEditLayerWalkEntry( + el: HTMLElement, + activeCompositionPath: string | null, + cache?: DomEditLayerWalkCache, +): DomEditLayerWalkEntry | null { + const inspectable = cache + ? cache.readInspectable(el, () => isInspectableLayerElement(el)) + : isInspectableLayerElement(el); + if (!inspectable) return null; + + const identity = () => resolveDomLayerIdentity(el, activeCompositionPath); + const target = cache ? cache.readIdentity(el, identity) : identity(); + if (!target) return null; + + const label = () => buildElementLabel(el); + const childCount = () => getDirectLayerChildren(el).length; + return { + target, + label: cache ? cache.readLabel(el, label) : label(), + childCount: cache ? cache.readChildCount(el, childCount) : childCount(), + }; +} + +/** + * Apply the records the observer has taken in but not yet delivered. + * + * Records arrive in a microtask, so an edit made earlier in THIS task would + * otherwise be read back against entries that predate it. Taking them suppresses + * the observer's own callback for them, which is equivalent here: that callback + * only ingests and marks a rebuild owed, and a caller draining is rebuilding + * regardless. A caller with no observer yet has nothing pending. + */ +export function drainPendingLayerMutations( + observer: MutationObserver | null, + cache: DomEditLayerWalkCache, +): void { + if (!observer) return; + cache.ingest(observer.takeRecords()); +} diff --git a/packages/studio/src/components/editor/domEditOverlayBasis.ts b/packages/studio/src/components/editor/domEditOverlayBasis.ts new file mode 100644 index 0000000000..c0b4ea3751 --- /dev/null +++ b/packages/studio/src/components/editor/domEditOverlayBasis.ts @@ -0,0 +1,72 @@ +/** + * The iframe→overlay coordinate basis: everything needed to map a rect measured + * inside the preview document into the Studio overlay's own coordinates. + * + * It is a property of the COMPOSITION and the canvas zoom, not of any element, + * so a caller measuring many elements in one synchronous pass resolves it once + * and threads it through the geometry functions in `domEditOverlayGeometry`. + * Resolved per element it costs a `querySelector("[data-composition-id]")` plus + * three layout reads each. + * + * Its own module because it is the one piece of that file every other piece + * depends on and nothing in it is about a single element's geometry. + */ + +/** iframe→overlay mapping basis shared by every overlay-geometry function. */ +export interface OverlayRootScale { + iframeRect: DOMRect; + overlayRect: DOMRect; + rootScaleX: number; + rootScaleY: number; +} + +export function readPositiveDimension(value: string | null): number | null { + if (!value) return null; + const parsed = Number.parseFloat(value); + return Number.isFinite(parsed) && parsed > 0 ? parsed : null; +} + +/** The composition root element inside the preview doc (or null when absent). */ +function findOverlayRootElement(doc: Document | null): HTMLElement | null { + return doc?.querySelector("[data-composition-id]") ?? doc?.documentElement ?? null; +} + +/** + * The root's effective width/height for scaling: prefer the composition's + * declared dimensions (data-width/data-height), which stay fixed while GSAP + * transforms mutate the measured rect; fall back to the measured rect. Null when + * unmeasurable. + */ +function resolveRootDimensions(root: HTMLElement | null): { width: number; height: number } | null { + if (!root) return null; + const rootRect = root.getBoundingClientRect(); + const width = readPositiveDimension(root.getAttribute("data-width")) ?? rootRect.width; + const height = readPositiveDimension(root.getAttribute("data-height")) ?? rootRect.height; + if (!width || !height) return null; + return { width, height }; +} + +/** + * The iframe/overlay client rects and the iframe→root scale factors. Uses the + * composition's declared dimensions (data-width/data-height) for the scale + * instead of rootRect.width/height: when GSAP applies transforms (scale, + * translate) to the root, rootRect dimensions change but the composition's + * canonical size stays fixed, and using rootRect misaligns the overlay during + * animated playback. Returns null when the geometry is unmeasurable. + */ +export function computeOverlayRootScale( + overlayEl: HTMLDivElement, + iframe: HTMLIFrameElement, + doc: Document | null, +): OverlayRootScale | null { + const iframeRect = iframe.getBoundingClientRect(); + const overlayRect = overlayEl.getBoundingClientRect(); + const dims = resolveRootDimensions(findOverlayRootElement(doc)); + if (!dims) return null; + return { + iframeRect, + overlayRect, + rootScaleX: iframeRect.width / dims.width, + rootScaleY: iframeRect.height / dims.height, + }; +} diff --git a/packages/studio/src/components/editor/domEditOverlayGeometry.ts b/packages/studio/src/components/editor/domEditOverlayGeometry.ts index b45b0ff460..f9d9cafe5e 100644 --- a/packages/studio/src/components/editor/domEditOverlayGeometry.ts +++ b/packages/studio/src/components/editor/domEditOverlayGeometry.ts @@ -1,4 +1,9 @@ import { type DomEditSelection, findElementForSelection } from "./domEditing"; +import { + computeOverlayRootScale, + type OverlayRootScale, + readPositiveDimension, +} from "./domEditOverlayBasis"; import { isElementVisibleThroughAncestors } from "./domEditingDom"; import { hugRectForElement } from "./domEditOverlayCrop"; import { composeElementTransform, type PlanarTransformOps } from "./domEditOverlayTransform"; @@ -47,12 +52,6 @@ export function isElementVisibleForOverlay(el: HTMLElement): boolean { // shapes (rectangular cards, text, full-bleed media) don't have interior holes, so this // doesn't bite. If ring/cutout shapes become editable targets, sample more densely or // hit-test against the element's actual painted geometry instead of its bounding box. -function readPositiveDimension(value: string | null): number | null { - if (!value) return null; - const parsed = Number.parseFloat(value); - return Number.isFinite(parsed) && parsed > 0 ? parsed : null; -} - function findSourceBoundary(element: HTMLElement): HTMLElement | null { let current: HTMLElement | null = element; while (current) { @@ -102,8 +101,9 @@ export function toVisibleOverlayRect( overlayEl: HTMLDivElement, iframe: HTMLIFrameElement, element: HTMLElement, + precomputedScale?: OverlayRootScale | null, ): OverlayRect | null { - const rect = toOverlayRect(overlayEl, iframe, element); + const rect = toOverlayRect(overlayEl, iframe, element, precomputedScale); return rect ? { ...rect, ...hugRectForElement(rect, element) } : null; } @@ -206,59 +206,6 @@ function rotationDegreesFromMatrix(matrix: DOMMatrix): number { * matrix-decomposition floating-point noise, never an actual rotation. */ const ROTATION_GATE_EPSILON_DEG = 1e-4; -/** iframe→overlay mapping basis shared by every overlay-geometry function. */ -interface OverlayRootScale { - iframeRect: DOMRect; - overlayRect: DOMRect; - rootScaleX: number; - rootScaleY: number; -} - -/** The composition root element inside the preview doc (or null when absent). */ -function findOverlayRootElement(doc: Document | null): HTMLElement | null { - return doc?.querySelector("[data-composition-id]") ?? doc?.documentElement ?? null; -} - -/** - * The root's effective width/height for scaling: prefer the composition's - * declared dimensions (data-width/data-height), which stay fixed while GSAP - * transforms mutate the measured rect; fall back to the measured rect. Null when - * unmeasurable. - */ -function resolveRootDimensions(root: HTMLElement | null): { width: number; height: number } | null { - if (!root) return null; - const rootRect = root.getBoundingClientRect(); - const width = readPositiveDimension(root.getAttribute("data-width")) ?? rootRect.width; - const height = readPositiveDimension(root.getAttribute("data-height")) ?? rootRect.height; - if (!width || !height) return null; - return { width, height }; -} - -/** - * The iframe/overlay client rects and the iframe→root scale factors. Uses the - * composition's declared dimensions (data-width/data-height) for the scale - * instead of rootRect.width/height: when GSAP applies transforms (scale, - * translate) to the root, rootRect dimensions change but the composition's - * canonical size stays fixed, and using rootRect misaligns the overlay during - * animated playback. Returns null when the geometry is unmeasurable. - */ -function computeOverlayRootScale( - overlayEl: HTMLDivElement, - iframe: HTMLIFrameElement, - doc: Document | null, -): OverlayRootScale | null { - const iframeRect = iframe.getBoundingClientRect(); - const overlayRect = overlayEl.getBoundingClientRect(); - const dims = resolveRootDimensions(findOverlayRootElement(doc)); - if (!dims) return null; - return { - iframeRect, - overlayRect, - rootScaleX: iframeRect.width / dims.width, - rootScaleY: iframeRect.height / dims.height, - }; -} - function toOverlayRect( overlayEl: HTMLDivElement, iframe: HTMLIFrameElement, @@ -415,8 +362,10 @@ export function orientedOverlayRect( overlayEl: HTMLDivElement, iframe: HTMLIFrameElement, element: HTMLElement, + precomputedScale?: OverlayRootScale | null, ): OverlayRect | null { - const scale = computeOverlayRootScale(overlayEl, iframe, iframe.contentDocument); + const scale = + precomputedScale ?? computeOverlayRootScale(overlayEl, iframe, iframe.contentDocument); if (!scale) return null; const base = toOverlayRect(overlayEl, iframe, element, scale); if (!base) return null; @@ -460,8 +409,9 @@ export function orientedVisibleOverlayRect( overlayEl: HTMLDivElement, iframe: HTMLIFrameElement, element: HTMLElement, + precomputedScale?: OverlayRootScale | null, ): OverlayRect | null { - const rect = orientedOverlayRect(overlayEl, iframe, element); + const rect = orientedOverlayRect(overlayEl, iframe, element, precomputedScale); return rect ? { ...rect, ...hugRectForElement(rect, element) } : null; } @@ -531,8 +481,9 @@ export function groupAwareOverlayRect( overlayEl: HTMLDivElement, iframe: HTMLIFrameElement, el: HTMLElement, + precomputedScale?: OverlayRootScale | null, ): OverlayRect | null { - const rect = toOverlayRect(overlayEl, iframe, el); + const rect = toOverlayRect(overlayEl, iframe, el, precomputedScale); if (!rect || !el.hasAttribute("data-hf-group")) return rect; // Union the MEMBERS' rendered rects — where the content actually is — not the // wrapper's own box. The wrapper is invisible and its box can sit apart from the @@ -540,7 +491,7 @@ export function groupAwareOverlayRect( // group's bounds (and its off-canvas marker) off to a stale position. const rects: OverlayRect[] = []; for (const child of Array.from(el.children)) { - const childRect = toOverlayRect(overlayEl, iframe, child as HTMLElement); + const childRect = toOverlayRect(overlayEl, iframe, child as HTMLElement, precomputedScale); if (childRect) rects.push(childRect); } const union = rects.length > 0 ? resolveDomEditGroupOverlayRect(rects) : null; @@ -551,15 +502,27 @@ export function groupAwareOverlayRect( return { ...union, editScaleX: rect.editScaleX, editScaleY: rect.editScaleY }; } -/** Groups stay axis-aligned unions; ordinary elements keep their oriented box. */ +/** + * Groups stay axis-aligned unions; ordinary elements keep their oriented box. + * + * `precomputedScale` is the iframe→overlay basis from `computeOverlayRootScale`. + * Without it every call resolves the composition root itself — one + * `querySelector("[data-composition-id]")` plus three `getBoundingClientRect` + * reads PER ELEMENT — and a caller measuring a whole preview therefore pays that + * once per element rather than once per composition. The basis is a property of + * the composition and the canvas zoom, not of the element, so a caller that + * measures many elements in one synchronous pass resolves it once and threads it + * through. See `toVisibleOverlayRects` for the same batching in miniature. + */ export function orientedGroupAwareOverlayRect( overlayEl: HTMLDivElement, iframe: HTMLIFrameElement, el: HTMLElement, + precomputedScale?: OverlayRootScale | null, ): OverlayRect | null { return el.hasAttribute("data-hf-group") - ? groupAwareOverlayRect(overlayEl, iframe, el) - : orientedOverlayRect(overlayEl, iframe, el); + ? groupAwareOverlayRect(overlayEl, iframe, el, precomputedScale) + : orientedOverlayRect(overlayEl, iframe, el, precomputedScale); } export function filterNestedDomEditGroupItems(items: T[]): T[] { diff --git a/packages/studio/src/components/editor/domEditingDom.ts b/packages/studio/src/components/editor/domEditingDom.ts index b498f3cae8..5deee32b09 100644 --- a/packages/studio/src/components/editor/domEditingDom.ts +++ b/packages/studio/src/components/editor/domEditingDom.ts @@ -109,9 +109,18 @@ export function findClosestByAttribute( // time, so module scope is the right lifetime; it's empty until set, in which case // resolution falls back to the historical attribute-only behavior. let compositionSourceMap: Map = new Map(); +// Bumped on every replacement so a consumer that memoizes a resolved source file +// can tell that the map it resolved against is gone. The map is module state +// with no DOM footprint, so nothing else can observe the change. +let compositionSourceMapRevision = 0; export function setCompositionSourceMap(map: Map): void { compositionSourceMap = map; + compositionSourceMapRevision += 1; +} + +export function getCompositionSourceMapRevision(): number { + return compositionSourceMapRevision; } function sourceFromCompositionId(ownerRoot: HTMLElement | null): string | undefined { diff --git a/packages/studio/src/components/editor/domEditingElement.ts b/packages/studio/src/components/editor/domEditingElement.ts index 916f9f9779..95c893e5a8 100644 --- a/packages/studio/src/components/editor/domEditingElement.ts +++ b/packages/studio/src/components/editor/domEditingElement.ts @@ -91,7 +91,13 @@ const DOM_LAYER_IGNORED_TAGS = new Set([ "wbr", ]); -function isInspectableLayerElement(el: HTMLElement): boolean { +/** + * Is the element rendered at all? The half of `getDomLayerPatchTarget` that a + * plain style write can flip, and the only half that reads computed style. + * Exported so a caller memoizing a layer walk can give it its own (short) + * cache lifetime — see `domEditLayerWalkCache`. + */ +export function isInspectableLayerElement(el: HTMLElement): boolean { const tagName = el.tagName.toLowerCase(); if (DOM_LAYER_IGNORED_TAGS.has(tagName)) return false; @@ -101,14 +107,29 @@ function isInspectableLayerElement(el: HTMLElement): boolean { return true; } -export function getDomLayerPatchTarget( +/** + * How the element is ADDRESSED — its selector, that selector's occurrence index + * within the source file, and the file itself. Independent of whether the + * element currently renders, so it survives every style write and moves only + * when an identity attribute changes or the document's population of elements + * sharing the selector does. + */ +/** + * The selector an element is addressed by, or undefined when it cannot be a + * layer at all. Sole owner of that rule: `isDomLayerElement` answers the same + * question without paying for the occurrence index, and a second copy of the + * conditions would go stale the first time one of them changed. + */ +function resolveLayerSelector(el: HTMLElement): string | undefined { + if (el.hasAttribute("data-composition-id")) return undefined; + return buildStableSelector(el); +} + +export function resolveDomLayerIdentity( el: HTMLElement, activeCompositionPath: string | null, ): Pick | null { - if (!isInspectableLayerElement(el)) return null; - if (el.hasAttribute("data-composition-id")) return null; - - const selector = buildStableSelector(el); + const selector = resolveLayerSelector(el); if (!selector) return null; const { sourceFile } = getSourceFileForElement(el, activeCompositionPath); @@ -127,6 +148,26 @@ export function getDomLayerPatchTarget( }; } +export function getDomLayerPatchTarget( + el: HTMLElement, + activeCompositionPath: string | null, +): Pick | null { + return isInspectableLayerElement(el) ? resolveDomLayerIdentity(el, activeCompositionPath) : null; +} + +/** + * Is `el` a layer at all? Exactly the condition under which + * `getDomLayerPatchTarget` returns non-null, for callers that want the yes/no + * and throw the target away. + * + * Worth its own function because the target carries the selector's OCCURRENCE + * INDEX, and resolving that is a whole-document query — which the yes/no does + * not depend on. Counting an element's layer children asked for one per child. + */ +export function isDomLayerElement(el: HTMLElement): boolean { + return isInspectableLayerElement(el) && resolveLayerSelector(el) !== undefined; +} + // ─── Clip ancestor / selection candidate ───────────────────────────────────── function getPreferredClipAncestor(startEl: HTMLElement): HTMLElement | null { @@ -323,12 +364,10 @@ export function findElementForTimelineElement( // ─── Layer children ─────────────────────────────────────────────────────────── -export function getDirectLayerChildren( - el: HTMLElement, - options: DomEditContextOptions, -): HTMLElement[] { +/** `el`'s direct children that are layers. No longer takes the context options: + * layer-ness does not depend on the active composition path. */ +export function getDirectLayerChildren(el: HTMLElement): HTMLElement[] { return Array.from(el.children).filter( - (child): child is HTMLElement => - isHtmlElement(child) && getDomLayerPatchTarget(child, options.activeCompositionPath) !== null, + (child): child is HTMLElement => isHtmlElement(child) && isDomLayerElement(child), ); } diff --git a/packages/studio/src/components/editor/domEditingLayers.ts b/packages/studio/src/components/editor/domEditingLayers.ts index 32c5e394f2..b0500bbc00 100644 --- a/packages/studio/src/components/editor/domEditingLayers.ts +++ b/packages/studio/src/components/editor/domEditingLayers.ts @@ -27,12 +27,12 @@ import { } from "./domEditingDom"; import { findElementForSelection, - getDomLayerPatchTarget, - getDirectLayerChildren, getSelectionCandidate, + isDomLayerElement, } from "./domEditingElement"; import { isCompositionRootLayer } from "./domEditingRootLayer"; import { withSelectorIndexPass } from "../../utils/sourceScopedSelectorIndex"; +import { type DomEditLayerWalkCache, readDomEditLayerWalkEntry } from "./domEditLayerWalkCache"; export function isEditableTextLeaf(el: HTMLElement): boolean { return isTextBearingTag(el.tagName.toLowerCase()) && el.children.length === 0; @@ -422,7 +422,7 @@ export function countDomEditChildLayers( const visit = (el: HTMLElement) => { for (const child of Array.from(el.children)) { if (!isHtmlElement(child)) continue; - if (getDomLayerPatchTarget(child, options.activeCompositionPath)) { + if (isDomLayerElement(child)) { count += 1; if (count >= maxCount) return; } @@ -443,23 +443,26 @@ export function collectDomEditLayerItems( root: HTMLElement | null | undefined, options: DomEditContextOptions, maxItems = Number.POSITIVE_INFINITY, + cache?: DomEditLayerWalkCache, ): DomEditLayerItem[] { if (!root) return []; + cache?.beginWalk(options.activeCompositionPath); const items: DomEditLayerItem[] = []; // fallow-ignore-next-line complexity const visit = (el: HTMLElement, depth: number) => { if (items.length >= maxItems) return; - const target = getDomLayerPatchTarget(el, options.activeCompositionPath); - if (target) { + const entry = readDomEditLayerWalkEntry(el, options.activeCompositionPath, cache); + if (entry) { + const { target } = entry; items.push({ key: getDomEditLayerKey(target), element: el, - label: buildElementLabel(el), + label: entry.label, tagName: el.tagName.toLowerCase(), depth, - childCount: getDirectLayerChildren(el, options).length, + childCount: entry.childCount, id: target.id ?? undefined, hfId: target.hfId ?? undefined, selector: target.selector ?? undefined, @@ -468,7 +471,7 @@ export function collectDomEditLayerItems( }); } - const nextDepth = target ? depth + 1 : depth; + const nextDepth = entry ? depth + 1 : depth; for (const child of Array.from(el.children)) { if (!isHtmlElement(child)) continue; visit(child, nextDepth); diff --git a/packages/studio/src/components/editor/offCanvasIndicatorGeometry.complexity.test.ts b/packages/studio/src/components/editor/offCanvasIndicatorGeometry.complexity.test.ts index d3e5c4da56..0df8da463c 100644 --- a/packages/studio/src/components/editor/offCanvasIndicatorGeometry.complexity.test.ts +++ b/packages/studio/src/components/editor/offCanvasIndicatorGeometry.complexity.test.ts @@ -13,6 +13,10 @@ afterEach(() => { interface Rebuild { /** querySelectorAll calls on the preview document, per selector. */ queriesBySelector: Map; + /** querySelector (singular) calls on the preview document, per selector. This + * is where the composition-root lookup that resolves the iframe→overlay + * basis shows up. */ + singleQueriesBySelector: Map; /** The indicator keys, which carry each element's selector occurrence index. */ keys: string[]; } @@ -58,6 +62,16 @@ function rebuildWithSharedSelector(cardCount: number): Rebuild { }, }); + const singleQueriesBySelector = new Map(); + const realQuerySelector = doc.querySelector.bind(doc); + Object.defineProperty(doc, "querySelector", { + configurable: true, + value: (selector: string) => { + singleQueriesBySelector.set(selector, (singleQueriesBySelector.get(selector) ?? 0) + 1); + return realQuerySelector(selector); + }, + }); + const sigRef = { current: "" } as React.MutableRefObject; const elementsRef = { current: new Map() } as React.MutableRefObject< Map @@ -79,9 +93,14 @@ function rebuildWithSharedSelector(cardCount: number): Rebuild { iframe.remove(); overlay.remove(); - return { queriesBySelector, keys: rects.map((rect) => rect.key) }; + return { queriesBySelector, singleQueriesBySelector, keys: rects.map((rect) => rect.key) }; } +/** The composition-root lookups a rebuild makes. `computeOverlayRootScale` and + * `recomputeOffCanvasIndicators` each resolve the root once; nothing else in + * the pass may. */ +const COMPOSITION_ROOT_SELECTOR = "[data-composition-id]"; + /** The queries that resolve a class selector's occurrence index — the work this * guards. Excluded: the per-element `[data-composition-id]` ancestor lookup and * the stylesheet scan, which are linear per element and a separate seam. */ @@ -115,3 +134,34 @@ describe("recomputeOffCanvasIndicators selector-index cost", () => { } }); }); + +describe("recomputeOffCanvasIndicators composition-basis cost", () => { + /** + * The defect this guards: the iframe→overlay basis was resolved INSIDE + * `orientedGroupAwareOverlayRect`, so every element in the preview paid its + * own `querySelector("[data-composition-id]")` plus three layout reads to + * rediscover a basis that is a property of the composition, not of the + * element. On a real preview that is one lookup per element per rebuild. + * + * The basis is hoisted to the caller and threaded through, so the count is + * a property of the COMPOSITION (one root, resolved twice: once for the walk + * root, once for the basis) and cannot grow with the element count. + */ + it("resolves the composition root the same number of times at n and at 4n", () => { + const small = rebuildWithSharedSelector(12); + const large = rebuildWithSharedSelector(48); + + expect(large.singleQueriesBySelector.get(COMPOSITION_ROOT_SELECTOR)).toEqual( + small.singleQueriesBySelector.get(COMPOSITION_ROOT_SELECTOR), + ); + // A ceiling as well as invariance, so a future caller that reintroduces a + // per-element lookup fails here even if it happens to be element-count-flat. + expect(small.singleQueriesBySelector.get(COMPOSITION_ROOT_SELECTOR)).toBe(2); + }); + + // Non-vacuity guard for the assertion above: it only means something if the + // fixture actually drives the per-element geometry path. + it("measures a rebuild that really did resolve every card's rect", () => { + expect(rebuildWithSharedSelector(12).keys).toHaveLength(12); + }); +}); diff --git a/packages/studio/src/components/editor/offCanvasIndicatorGeometry.ts b/packages/studio/src/components/editor/offCanvasIndicatorGeometry.ts index 8b61a3b7f9..22fd19e501 100644 --- a/packages/studio/src/components/editor/offCanvasIndicatorGeometry.ts +++ b/packages/studio/src/components/editor/offCanvasIndicatorGeometry.ts @@ -1,8 +1,10 @@ import type React from "react"; import type { OffCanvasRect } from "./OffCanvasIndicators"; import { hugRectForElement } from "./domEditOverlayCrop"; +import { computeOverlayRootScale } from "./domEditOverlayBasis"; import { orientedGroupAwareOverlayRect } from "./domEditOverlayGeometry"; import { isElementComputedVisible } from "./domEditingElement"; +import type { DomEditLayerWalkCache } from "./domEditLayerWalkCache"; import { collectDomEditLayerItems } from "./domEditingLayers"; function rounded(value: number): number { @@ -47,6 +49,9 @@ export function recomputeOffCanvasIndicators( sigRef: React.MutableRefObject, elementsRef: React.MutableRefObject>, setRects: (rects: OffCanvasRect[]) => void, + /** Reuses the previous rebuild's per-element derivations for the elements no + * mutation touched. Omitted (tests, one-off callers) => a full derivation. */ + walkCache?: DomEditLayerWalkCache, ): void { if (comp.width <= 0 || !doc) { sigRef.current = ""; @@ -57,10 +62,19 @@ export function recomputeOffCanvasIndicators( const root = doc.querySelector("[data-composition-id]") ?? doc.body; const acp = activeCompositionPath ?? "index.html"; - const items = collectDomEditLayerItems(root, { - activeCompositionPath: acp, - isMasterView: !acp || acp === "index.html", - }); + const items = collectDomEditLayerItems( + root, + { activeCompositionPath: acp, isMasterView: !acp || acp === "index.html" }, + undefined, + walkCache, + ); + // The iframe→overlay basis is a property of the composition and the canvas + // zoom, not of the element, and this loop neither writes to the DOM nor lets + // the canvas move under it — so it is resolved ONCE here instead of inside + // every `orientedGroupAwareOverlayRect` call. Unhoisted it cost a + // `querySelector("[data-composition-id]")` plus three layout reads per item, + // which on a preview of a few hundred elements is the bulk of the rebuild. + const scale = computeOverlayRootScale(overlay, iframe, doc); const rects: OffCanvasRect[] = []; const elMap = new Map(); for (const item of items) { @@ -69,7 +83,7 @@ export function recomputeOffCanvasIndicators( // whose members sit inside the canvas isn't flagged off-canvas by a stale // wrapper box. Crop-hug the result so an inset crop that keeps the visible // part on-canvas doesn't flag the element either. - const base = orientedGroupAwareOverlayRect(overlay, iframe, item.element); + const base = orientedGroupAwareOverlayRect(overlay, iframe, item.element, scale); const r = base ? { ...base, ...hugRectForElement(base, item.element) } : null; if (!r) continue; // Any edge crossing the composition border → gray-zone indicator (the diff --git a/packages/studio/src/components/editor/offCanvasIndicatorRefresh.ts b/packages/studio/src/components/editor/offCanvasIndicatorRefresh.ts index d10fb3a1ab..039402cf4d 100644 --- a/packages/studio/src/components/editor/offCanvasIndicatorRefresh.ts +++ b/packages/studio/src/components/editor/offCanvasIndicatorRefresh.ts @@ -1,5 +1,11 @@ import type React from "react"; import type { OffCanvasRect } from "./OffCanvasIndicators"; +import { + DOM_EDIT_LAYER_OBSERVER_INIT, + type DomEditLayerWalkCache, + createDomEditLayerWalkCache, + drainPendingLayerMutations, +} from "./domEditLayerWalkCache"; import { recomputeOffCanvasIndicators } from "./offCanvasIndicatorGeometry"; interface OffCanvasIndicatorRefreshOptions { @@ -26,20 +32,29 @@ function clearIndicators(options: OffCanvasIndicatorRefreshOptions): void { options.setRects([]); } -function observeDoc(doc: Document, markDirty: () => void): MutationObserver | null { +function observeDoc( + doc: Document, + cache: DomEditLayerWalkCache, + markDirty: () => void, +): MutationObserver | null { const Observer = doc.defaultView?.MutationObserver ?? globalThis.MutationObserver; if (!Observer) return null; - const observer = new Observer(markDirty); - observer.observe(doc.documentElement, { - attributes: true, - // data-hidden is included explicitly: hiding an element writes data-hidden, and - // although the runtime honoring also writes display:"none" (a style mutation we'd - // catch anyway), keying on the attribute directly makes the coupling robust to any - // future throttling of that runtime sync. - attributeFilter: ["style", "class", "transform", "width", "height", "data-hidden"], - childList: true, - subtree: true, + // The same records do two jobs: they say a rebuild is owed, and they say which + // elements the rebuild may not reuse. Reading them here rather than only + // marking a boolean is what makes the rebuild cost the edit rather than the + // document. + const observer = new Observer((records) => { + cache.ingest(records); + markDirty(); }); + // Unfiltered, unlike the boolean-dirty version this replaces, which watched + // only ["style", "class", "transform", "width", "height", "data-hidden"]. An + // attribute the cache never hears about is one it would answer stale for, and + // the filter omitted `id` and the `data-composition-*` attributes that decide + // a layer's identity. Widening only makes indicators refresh sooner: a rebuild + // is a pure read of the DOM and is throttled by RECOMPUTE_INTERVAL_MS either + // way. See DOM_EDIT_LAYER_OBSERVER_INIT for what each option is load-bearing for. + observer.observe(doc.documentElement, DOM_EDIT_LAYER_OBSERVER_INIT); return observer; } @@ -66,12 +81,18 @@ export function startOffCanvasIndicatorRefresh( let frame = 0; let lastCompSig = ""; let lastRecomputeAt = Number.NEGATIVE_INFINITY; + const walkCache = createDomEditLayerWalkCache(); const markDirty = () => { options.dirtyRef.current = true; }; const attachObserver = (doc: Document | null) => { options.observerRef.current?.disconnect(); - options.observerRef.current = doc?.documentElement ? observeDoc(doc, markDirty) : null; + // A new preview document shares no elements with the old one, and nothing + // reported the old one's teardown. + walkCache.invalidateAll(); + options.observerRef.current = doc?.documentElement + ? observeDoc(doc, walkCache, markDirty) + : null; options.observedDocRef.current = doc; options.sigRef.current = ""; }; @@ -99,6 +120,7 @@ export function startOffCanvasIndicatorRefresh( if (!rebuildDue(options.dirtyRef.current, lastRecomputeAt, now)) return; lastRecomputeAt = now; options.dirtyRef.current = false; + drainPendingLayerMutations(options.observerRef.current, walkCache); recomputeOffCanvasIndicators( iframe, overlayEl, @@ -108,6 +130,7 @@ export function startOffCanvasIndicatorRefresh( options.sigRef, options.elementsRef, options.setRects, + walkCache, ); }; frame = requestAnimationFrame(update); diff --git a/packages/studio/src/components/editor/useDomEditOverlayRects.ts b/packages/studio/src/components/editor/useDomEditOverlayRects.ts index ba402bafb6..e9b2973402 100644 --- a/packages/studio/src/components/editor/useDomEditOverlayRects.ts +++ b/packages/studio/src/components/editor/useDomEditOverlayRects.ts @@ -19,6 +19,7 @@ import { selectionCacheKey, orientedVisibleOverlayRect, } from "./domEditOverlayGeometry"; +import { computeOverlayRootScale } from "./domEditOverlayBasis"; function childRectsEqual(a: OverlayRect[], b: OverlayRect[]): boolean { if (a.length !== b.length) return false; @@ -144,6 +145,14 @@ export function useDomEditOverlayRects({ return; } + // One basis for the whole frame: the selection box, up to 60 child + // outlines, every group box and the hover box all map through the same + // iframe→overlay transform, and nothing below writes to the preview DOM + // or moves the canvas. Resolved per call it was one + // `querySelector("[data-composition-id]")` and three layout reads EACH, + // every frame. + const scale = computeOverlayRootScale(overlayEl, iframe, doc); + if (sel) { const el = resolveElementForOverlay( doc, @@ -164,7 +173,7 @@ export function useDomEditOverlayRects({ // (a cheap per-call check) and only pays for the full corner-transform // measurement when the element is actually rotated — this RAF loop runs // every frame for any single selection, so that gate matters here most. - const nextRect = orientedGroupAwareOverlayRect(overlayEl, iframe, el); + const nextRect = orientedGroupAwareOverlayRect(overlayEl, iframe, el, scale); setOverlayRect(nextRect); const descendants = el.querySelectorAll("*"); if (descendants.length > 0 && descendants.length <= 60) { @@ -174,7 +183,7 @@ export function useDomEditOverlayRects({ if (!child.getBoundingClientRect) continue; // Oriented, not axis-aligned: a child of a rotated element drew its // outline square around the rotated glyphs instead of on them. - const r = orientedVisibleOverlayRect(overlayEl, iframe, child); + const r = orientedVisibleOverlayRect(overlayEl, iframe, child, scale); if (r && r.width > 2 && r.height > 2) nextChildRects.push(r); } if (!childRectsEqual(childRectsRef.current, nextChildRects)) { @@ -213,7 +222,7 @@ export function useDomEditOverlayRects({ if (liveGroupKeys.has(key)) continue; liveGroupKeys.add(key); const el = resolveGroupElement(doc, groupSelection); - const base = el ? groupAwareOverlayRect(overlayEl, iframe, el) : null; + const base = el ? groupAwareOverlayRect(overlayEl, iframe, el, scale) : null; const rect = base && el ? { ...base, ...hugRectForElement(base, el) } : base; if (el && rect) nextGroupItems.push({ key, selection: groupSelection, element: el, rect }); @@ -251,7 +260,7 @@ export function useDomEditOverlayRects({ return; } - setHoverRect(orientedGroupAwareOverlayRect(overlayEl, iframe, hoverEl)); + setHoverRect(orientedGroupAwareOverlayRect(overlayEl, iframe, hoverEl, scale)); }; frame = requestAnimationFrame(update);