From da78933c8ec795e9991ce9128f7f167e05912146 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 24 Oct 2026 00:01:59 -0400 Subject: [PATCH 01/40] perf(core): skip rendering preview scenes far from the playhead --- packages/core/src/runtime/farScenes.test.ts | 77 ++++++++++++++++++ packages/core/src/runtime/farScenes.ts | 68 ++++++++++++++++ packages/core/src/runtime/init.ts | 8 ++ packages/core/src/studioPreviewMark.ts | 2 + .../src/services/coreRuntimeBrowser.test.ts | 79 +++++++++++++++++++ 5 files changed, 234 insertions(+) create mode 100644 packages/core/src/runtime/farScenes.test.ts create mode 100644 packages/core/src/runtime/farScenes.ts diff --git a/packages/core/src/runtime/farScenes.test.ts b/packages/core/src/runtime/farScenes.test.ts new file mode 100644 index 00000000000..e9a88d2a609 --- /dev/null +++ b/packages/core/src/runtime/farScenes.test.ts @@ -0,0 +1,77 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { createFarSceneSkip } from "./farScenes"; +import { resetRuntimeFixtureDom } from "./runtimeSeekFixture.test-helpers"; + +const FAR = "data-hf-far-scene"; + +// Six 2 s scenes back to back, plus a scene style part that is not a host. +function film(tagged = true): HTMLElement[] { + resetRuntimeFixtureDom(); + const tag = (i: number) => (tagged ? ` data-hf-scene="s${i}"` : ""); + document.body.innerHTML = + `p{}` + + Array.from( + { length: 6 }, + (_, i) => `
`, + ).join(""); + return Array.from(document.querySelectorAll("div")); +} + +const spanOf = (host: Element) => { + const start = Number(host.getAttribute("data-start")); + return { start, end: start + 2 }; +}; +const farIds = () => Array.from(document.querySelectorAll(`[${FAR}]`), (el) => el.id); + +afterEach(() => { + document.head.innerHTML = ""; + document.body.innerHTML = ""; +}); + +describe("createFarSceneSkip", () => { + it("skips every scene but the playing one and its neighbours, following play, seek and scrub", () => { + film(); + const skip = createFarSceneSkip(spanOf); + skip(1); + expect(farIds()).toEqual(["s2", "s3", "s4", "s5"]); + // Played across the cut: both scenes play at it, so each keeps its other neighbour. + for (let t = 1; t <= 2; t += 1 / 30) skip(t); + skip(2); + expect(farIds()).toEqual(["s3", "s4", "s5"]); + skip(9); + expect(farIds()).toEqual(["s0", "s1", "s2"]); + for (const t of [7, 3, 5]) skip(t); + expect(farIds()).toEqual(["s0", "s4", "s5"]); + expect(document.querySelector(`style[${FAR}]`)).toBeNull(); + }); + + it("keeps a scene with no known start rendered", () => { + film(); + const skip = createFarSceneSkip((host) => (host.id === "s5" ? null : spanOf(host))); + skip(0); + expect(farIds()).toEqual(["s2", "s3", "s4"]); + }); + + it("keeps a scene host in flow rendered, since containment would move what it holds", () => { + film()[3]!.style.position = "relative"; + createFarSceneSkip(spanOf)(0); + expect(farIds()).toEqual(["s2", "s4", "s5"]); + }); + + it("does nothing on a page without scene hosts, as in a render", () => { + film(false); + createFarSceneSkip(spanOf)(1); + expect(farIds()).toEqual([]); + expect(document.head.querySelector("style")).toBeNull(); + }); + + it("holds a skipped scene at the size it rendered at", () => { + const [, , s2] = film(); + Object.defineProperty(s2, "offsetWidth", { value: 640 }); + Object.defineProperty(s2, "offsetHeight", { value: 360 }); + createFarSceneSkip(spanOf)(0); + const css = document.head.querySelector("style")?.textContent ?? ""; + expect(css).toContain(`[${FAR}] { content-visibility: hidden; }`); + expect(css).toContain(`[data-hf-scene="s2"][${FAR}] { contain-intrinsic-size: 640px 360px; }`); + }); +}); diff --git a/packages/core/src/runtime/farScenes.ts b/packages/core/src/runtime/farScenes.ts new file mode 100644 index 00000000000..e65ff4c2660 --- /dev/null +++ b/packages/core/src/runtime/farScenes.ts @@ -0,0 +1,68 @@ +import { SCENE_PART_ATTR } from "../sceneParts"; +import { STUDIO_PREVIEW_FAR_SCENE_ATTR as FAR } from "../studioPreviewMark"; + +type Span = { start: number; end: number } | null; + +const HOSTS = `[${SCENE_PART_ATTR}]:not(style, script)`; +const OUT_OF_FLOW = new Set(["absolute", "fixed"]); + +/** + * Preview scenes beyond the playing scene's neighbours stay mounted but skip style, layout and paint. + * Each keeps the size it had when last rendered, so a script measuring its host reads the same box. + * Only preview bundles tag scene hosts, so a render never gets here with any. + */ +export function createFarSceneSkip(spanOf: (host: Element) => Span): (time: number) => void { + let hosts: HTMLElement[] | null = null; + let sheet: HTMLStyleElement | null = null; + const sizes = new Map(); + return (time) => { + if (!hosts || hosts.some((host) => !host.isConnected)) + hosts = Array.from(document.querySelectorAll(HOSTS)); + if (hosts.length === 0) return; + const timed = hosts + .map((host) => ({ host, span: spanOf(host) })) + .filter((s): s is { host: HTMLElement; span: { start: number; end: number } } => + Number.isFinite(s.span?.start), + ) + .sort((a, b) => a.span.start - b.span.start); + let lo = -1; + let hi = -1; + timed.forEach(({ span }, i) => { + if (span.start > time || time > span.end) return; + if (lo < 0) lo = i; + hi = i; + }); + // Between scenes, the one before the gap stands in for the playing one. + if (lo < 0) lo = hi = timed.filter(({ span }) => span.start <= time).length - 1; + const far = new Set(timed.filter((_, i) => i < lo - 1 || i > hi + 1).map((s) => s.host)); + // Measured before any is marked, so one layout serves them all. + let resized = false; + for (const host of far) { + if (host.hasAttribute(FAR)) continue; + // Containment makes a host a containing block and margin boundary; only an out-of-flow one already is. + if (!OUT_OF_FLOW.has(getComputedStyle(host).position)) { + far.delete(host); + continue; + } + const name = host.getAttribute(SCENE_PART_ATTR)!; + const size = `${host.offsetWidth}px ${host.offsetHeight}px`; + if (sizes.get(name) === size) continue; + sizes.set(name, size); + resized = true; + } + if (resized || !sheet?.isConnected) { + sheet ??= document.createElement("style"); + sheet.textContent = [ + `[${FAR}] { content-visibility: hidden; }`, + ...Array.from( + sizes, + ([name, size]) => + `[${SCENE_PART_ATTR}="${CSS.escape(name)}"][${FAR}] { contain-intrinsic-size: ${size}; }`, + ), + ].join("\n"); + if (!sheet.isConnected) document.head.appendChild(sheet); + } + for (const host of hosts) + if (far.has(host) !== host.hasAttribute(FAR)) host.toggleAttribute(FAR); + }; +} diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index e4042fe6fa9..7975df0b1f4 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -1,5 +1,6 @@ // fallow-ignore-file code-duplication complexity import { setPreviewRasterScale } from "./previewRasterHints"; +import { createFarSceneSkip } from "./farScenes"; import { refreshSvgSelectorAliases } from "../compiler/svgSelectorAliases"; import { RUNTIME_FILLER } from "./protocol"; import { preloadMedia, releaseMedia, lengthIsAuthored, stopMediaDownload } from "./preloadMedia"; @@ -2824,6 +2825,12 @@ export function initSandboxRuntimeModular(): void { const hiddenImagesSkipped = skipsHiddenImages(); const mediaDeferred = defersMedia(); + const skipFarScenes = createFarSceneSkip((host) => + runtimeElementWindow(host, { + timelineRegistry: window.__timelines ?? {}, + resolver: timingResolverFor(true), + }), + ); const LOOKAHEAD_SECONDS = 2; const RELEASE_LOOKAHEAD_SECONDS = 4; // Unskipped while within the window on either side of the playhead, so a step or shuttle back @@ -3124,6 +3131,7 @@ export function initSandboxRuntimeModular(): void { } } if (decidedTimedClip && revealTimedClipsAfterFirstPass()) colorGradingRuntime?.refresh(); + skipFarScenes(currentTime); // Only when a `data-hidden` mutation actually moved something: the skips // this reschedule exists to re-run are what change the active set, so // firing it otherwise was an audible stop-and-restart across the whole mix diff --git a/packages/core/src/studioPreviewMark.ts b/packages/core/src/studioPreviewMark.ts index dbb5cc73199..a79a2c094dc 100644 --- a/packages/core/src/studioPreviewMark.ts +++ b/packages/core/src/studioPreviewMark.ts @@ -5,8 +5,10 @@ export const STUDIO_PREVIEW_LAZY_ATTR = "data-hf-preview-lazy"; export const STUDIO_PREVIEW_ERRORS = "__hfPreviewErrors"; export const PREVIEW_RASTER_ATTR = "data-hf-preview-raster"; export const RUNTIME_STAMPED_ATTR = "data-hf-autostamped"; +export const STUDIO_PREVIEW_FAR_SCENE_ATTR = "data-hf-far-scene"; export const PREVIEW_ONLY_ATTRS = [ STUDIO_PREVIEW_UPCOMING_ATTR, + STUDIO_PREVIEW_FAR_SCENE_ATTR, STUDIO_PREVIEW_LAZY_ATTR, PREVIEW_RASTER_ATTR, RUNTIME_STAMPED_ATTR, diff --git a/packages/producer/src/services/coreRuntimeBrowser.test.ts b/packages/producer/src/services/coreRuntimeBrowser.test.ts index b0c1f77d47c..ad50aa1ccf7 100644 --- a/packages/producer/src/services/coreRuntimeBrowser.test.ts +++ b/packages/producer/src/services/coreRuntimeBrowser.test.ts @@ -1233,6 +1233,85 @@ describe("core runtime browser contract", () => { 30_000, ); + it.each([true, false])( + "skips rendering preview scenes far from the playhead, measuring them unchanged (scene parts: %s)", + async (sceneParts) => { + const dir = mkdtempSync(resolve(tmpdir(), "far-scenes-browser-")); + const scenePage = await browser.newPage(); + try { + // Content-sized hosts, so a skipped one that lost its size would shrink what it holds. + const hosts = [0, 1, 2, 3, 4] + .map( + (i) => + `
`, + ) + .join(""); + writeFileSync( + resolve(dir, "index.html"), + `
${hosts}
`, + ); + for (const i of [0, 1, 2, 3, 4]) + writeFileSync( + resolve(dir, `s${i}.html`), + ``, + ); + await scenePage.setContent( + await bundleToSingleHtml(dir, { sceneParts, staticGuard: false }), + ); + await scenePage.waitForFunction(() => window.__playerReady && window.__renderReady); + const result = await scenePage.evaluate(async () => { + const far = () => + Array.from(document.querySelectorAll("[data-hf-far-scene]"), (el) => + el.getAttribute("data-composition-id"), + ); + const atStart = far(); + window.__player!.seek(7); + await new Promise(requestAnimationFrame); + const afterSeek = far(); + const skipped = Array.from( + document.querySelectorAll("[data-hf-far-scene]"), + (el) => getComputedStyle(el).contentVisibility, + ); + const boxes = () => + Array.from( + document.querySelectorAll( + "[data-hf-scene] :is(p, i), [data-hf-scene]:not(style, script)", + ), + (el) => { + const { x, y, width, height } = el.getBoundingClientRect(); + return [ + x, + y, + width, + height, + el.offsetWidth, + el.offsetHeight, + getComputedStyle(el).width, + ]; + }, + ); + const measured = boxes(); + for (const el of document.querySelectorAll("[data-hf-far-scene]")) + el.removeAttribute("data-hf-far-scene"); + return { atStart, afterSeek, skipped, measured, rendered: boxes() }; + }); + if (!sceneParts) { + expect(result.atStart).toEqual([]); + expect(result.afterSeek).toEqual([]); + return; + } + expect(result.atStart).toEqual(["s2", "s3", "s4"]); + expect(result.afterSeek).toEqual(["s0", "s1"]); + expect(result.skipped).toEqual(["hidden", "hidden"]); + expect(result.measured).toEqual(result.rendered); + } finally { + await scenePage.close(); + rmSync(dir, { recursive: true, force: true }); + } + }, + 30_000, + ); + it("removes the control bridge during teardown", async () => { const result = await page.evaluate(async () => { const runtimeWindow = window as unknown as { From 8439142c03ed2cd30b5aace0f8352ca3efb97f68 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 24 Oct 2026 01:03:09 -0400 Subject: [PATCH 02/40] refactor(core): split the far-scene pass into small helpers --- packages/core/src/runtime/farScenes.ts | 72 ++++++++++++++------------ 1 file changed, 39 insertions(+), 33 deletions(-) diff --git a/packages/core/src/runtime/farScenes.ts b/packages/core/src/runtime/farScenes.ts index e65ff4c2660..6997203995b 100644 --- a/packages/core/src/runtime/farScenes.ts +++ b/packages/core/src/runtime/farScenes.ts @@ -6,6 +6,19 @@ type Span = { start: number; end: number } | null; const HOSTS = `[${SCENE_PART_ATTR}]:not(style, script)`; const OUT_OF_FLOW = new Set(["absolute", "fixed"]); +type Timed = { host: HTMLElement; span: { start: number; end: number } }; + +/** Hosts outside the playing scene(s) and one neighbour each side; between scenes, the one before the gap plays. */ +function farFrom(timed: Timed[], time: number): Set { + const playing = timed.flatMap(({ span }, i) => + span.start <= time && time <= span.end ? [i] : [], + ); + const before = timed.filter(({ span }) => span.start <= time).length - 1; + const lo = playing.length > 0 ? playing[0]! : before; + const hi = playing.length > 0 ? playing[playing.length - 1]! : before; + return new Set(timed.filter((_, i) => i < lo - 1 || i > hi + 1).map((s) => s.host)); +} + /** * Preview scenes beyond the playing scene's neighbours stay mounted but skip style, layout and paint. * Each keeps the size it had when last rendered, so a script measuring its host reads the same box. @@ -15,27 +28,13 @@ export function createFarSceneSkip(spanOf: (host: Element) => Span): (time: numb let hosts: HTMLElement[] | null = null; let sheet: HTMLStyleElement | null = null; const sizes = new Map(); - return (time) => { - if (!hosts || hosts.some((host) => !host.isConnected)) - hosts = Array.from(document.querySelectorAll(HOSTS)); - if (hosts.length === 0) return; - const timed = hosts + const timedHosts = (list: HTMLElement[]): Timed[] => + list .map((host) => ({ host, span: spanOf(host) })) - .filter((s): s is { host: HTMLElement; span: { start: number; end: number } } => - Number.isFinite(s.span?.start), - ) + .filter((s): s is Timed => Number.isFinite(s.span?.start)) .sort((a, b) => a.span.start - b.span.start); - let lo = -1; - let hi = -1; - timed.forEach(({ span }, i) => { - if (span.start > time || time > span.end) return; - if (lo < 0) lo = i; - hi = i; - }); - // Between scenes, the one before the gap stands in for the playing one. - if (lo < 0) lo = hi = timed.filter(({ span }) => span.start <= time).length - 1; - const far = new Set(timed.filter((_, i) => i < lo - 1 || i > hi + 1).map((s) => s.host)); - // Measured before any is marked, so one layout serves them all. + // Measured before any is marked, so one layout serves them all; true when a size changed. + const measureLeaving = (far: Set): boolean => { let resized = false; for (const host of far) { if (host.hasAttribute(FAR)) continue; @@ -46,22 +45,29 @@ export function createFarSceneSkip(spanOf: (host: Element) => Span): (time: numb } const name = host.getAttribute(SCENE_PART_ATTR)!; const size = `${host.offsetWidth}px ${host.offsetHeight}px`; - if (sizes.get(name) === size) continue; + resized ||= sizes.get(name) !== size; sizes.set(name, size); - resized = true; - } - if (resized || !sheet?.isConnected) { - sheet ??= document.createElement("style"); - sheet.textContent = [ - `[${FAR}] { content-visibility: hidden; }`, - ...Array.from( - sizes, - ([name, size]) => - `[${SCENE_PART_ATTR}="${CSS.escape(name)}"][${FAR}] { contain-intrinsic-size: ${size}; }`, - ), - ].join("\n"); - if (!sheet.isConnected) document.head.appendChild(sheet); } + return resized; + }; + const writeSheet = () => { + sheet ??= document.createElement("style"); + sheet.textContent = [ + `[${FAR}] { content-visibility: hidden; }`, + ...Array.from( + sizes, + ([name, size]) => + `[${SCENE_PART_ATTR}="${CSS.escape(name)}"][${FAR}] { contain-intrinsic-size: ${size}; }`, + ), + ].join("\n"); + if (!sheet.isConnected) document.head.appendChild(sheet); + }; + return (time) => { + if (!hosts || hosts.some((host) => !host.isConnected)) + hosts = Array.from(document.querySelectorAll(HOSTS)); + if (hosts.length === 0) return; + const far = farFrom(timedHosts(hosts), time); + if (measureLeaving(far) || !sheet?.isConnected) writeSheet(); for (const host of hosts) if (far.has(host) !== host.hasAttribute(FAR)) host.toggleAttribute(FAR); }; From 44f774270cac90bb8630bb764e3653f8bab2b8ab Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 24 Oct 2026 01:29:30 -0400 Subject: [PATCH 03/40] perf(core): a first render reveals only the clips it reaches --- packages/core/src/runtime/init.test.ts | 21 +++++++++++++++++++++ packages/core/src/runtime/init.ts | 13 ++++++++++++- packages/core/src/runtime/timedClipHide.ts | 6 ++++-- 3 files changed, 37 insertions(+), 3 deletions(-) diff --git a/packages/core/src/runtime/init.test.ts b/packages/core/src/runtime/init.test.ts index ff1177d1a48..d0b9bf2c21b 100644 --- a/packages/core/src/runtime/init.test.ts +++ b/packages/core/src/runtime/init.test.ts @@ -563,6 +563,27 @@ describe("initSandboxRuntimeModular", () => { }, ); + it("shows only the clips a first render crosses, not later ones", () => { + document.body.innerHTML = + `
` + + `
` + + `
` + + `
`; + const seen: string[] = []; + const read = () => { + for (const id of ["line", "later"]) + seen.push(document.getElementById(id)!.style.visibility); + return 1; + }; + const tl = gsap.timeline({ paused: true }); + tl.to({ x: 0 }, { x: read, duration: 0.1 }, 3); + window.__timelines = { main: tl as unknown as RuntimeTimelineLike }; + initSandboxRuntimeModular(); + window.__player?.seek(1); + window.__player?.seek(3.5); + expect(seen).toEqual(["visible", "hidden"]); + }); + it("keeps a hidden scene's contents hidden when a rewound GSAP set marks one visible inline", () => { hideTimedClipsUntilFirstPass(); try { diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index 7975df0b1f4..681e05db51a 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -4863,7 +4863,18 @@ export function initSandboxRuntimeModular(): void { if (revision !== tweensRenderedThrough.revision) tweensRenderedThrough = { revision, time: Number.NEGATIVE_INFINITY }; const firstRenders = t > tweensRenderedThrough.time; - const hideClipsAgain = firstRenders ? showHiddenClips() : null; + // Only clips live in the span rendered for the first time hold tweens that render now. + const windowOptions = { + timelineRegistry: window.__timelines ?? {}, + resolver: timingResolverFor(true), + }; + const renderedFrom = tweensRenderedThrough.time; + const hideClipsAgain = firstRenders + ? showHiddenClips((el) => { + const span = runtimeElementWindow(el, windowOptions); + return !span || (span.start <= t && span.end >= renderedFrom); + }) + : null; if (tl) { // #10: when data-duration exceeds the timeline's intrinsic length the // engine requests frames past the last tween. Seeking a paused GSAP diff --git a/packages/core/src/runtime/timedClipHide.ts b/packages/core/src/runtime/timedClipHide.ts index 5a0c8c44d4e..81bae5b335c 100644 --- a/packages/core/src/runtime/timedClipHide.ts +++ b/packages/core/src/runtime/timedClipHide.ts @@ -25,8 +25,10 @@ const FILTER_OUTPUT_LAYERS = [HIDDEN_CLIP, `${HIDDEN_CLIP} *`] .join(", "); const HIDE_FILTER_OUTPUT = `${FILTER_OUTPUT_LAYERS} { filter: none !important; }`; -export function showHiddenClips(): () => void { - const hidden = Array.from(document.querySelectorAll(`[${CLIP_HIDDEN_ATTR}]`)); +export function showHiddenClips(only: (el: HTMLElement) => boolean = () => true): () => void { + const hidden = Array.from(document.querySelectorAll(`[${CLIP_HIDDEN_ATTR}]`)).filter( + only, + ); for (const el of hidden) el.style.visibility = "visible"; return () => { for (const el of hidden) el.style.visibility = "hidden"; From 6044331c9cb377f7ca925f4f451f79927605c76f Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 24 Oct 2026 02:31:28 -0400 Subject: [PATCH 04/40] fix(core): reveal the clips holding first-rendered tweens, wherever their window is --- .../src/runtime/firstRenderReveal.test.ts | 29 ++++++++++++++ .../core/src/runtime/firstRenderReveal.ts | 38 +++++++++++++++++++ packages/core/src/runtime/init.test.ts | 7 ++-- packages/core/src/runtime/init.ts | 15 +++----- 4 files changed, 76 insertions(+), 13 deletions(-) create mode 100644 packages/core/src/runtime/firstRenderReveal.test.ts create mode 100644 packages/core/src/runtime/firstRenderReveal.ts diff --git a/packages/core/src/runtime/firstRenderReveal.test.ts b/packages/core/src/runtime/firstRenderReveal.test.ts new file mode 100644 index 00000000000..be56060c56d --- /dev/null +++ b/packages/core/src/runtime/firstRenderReveal.test.ts @@ -0,0 +1,29 @@ +import gsap from "gsap"; +import { describe, expect, it } from "vitest"; +import { clipsFirstRenderedIn } from "./firstRenderReveal"; +import type { RuntimeTimelineChildLike } from "./types"; + +describe("clipsFirstRenderedIn", () => { + document.body.innerHTML = + `

` + + `

`; + const root = gsap.timeline({ paused: true }); + const scene = gsap.timeline(); + scene.to("#text", { x: 1, duration: 1 }, 1); + scene.to("#free", { x: 1, duration: 1 }, 1); + root.add(scene, 10); + const ids = (clips: Set | null) => (clips ? [...clips].map((el) => el.id) : null); + + it("returns the hidden clips around targets of tweens starting in the span, at their nested time", () => { + const tl = root as unknown as RuntimeTimelineChildLike; + expect(ids(clipsFirstRenderedIn(tl, 5, 11.5))).toEqual(["clip", "scene"]); + expect(ids(clipsFirstRenderedIn(tl, 11, 20))).toEqual([]); + expect(ids(clipsFirstRenderedIn(tl, 0, 10.5))).toEqual([]); + }); + + it("cannot tell for a nested timeline that repeats", () => { + const looped = gsap.timeline({ paused: true }); + looped.add(gsap.timeline({ repeat: 2 }).to("#text", { x: 1, duration: 1 }), 0); + expect(clipsFirstRenderedIn(looped as unknown as RuntimeTimelineChildLike, 0, 5)).toBeNull(); + }); +}); diff --git a/packages/core/src/runtime/firstRenderReveal.ts b/packages/core/src/runtime/firstRenderReveal.ts new file mode 100644 index 00000000000..8b9d2f92dd6 --- /dev/null +++ b/packages/core/src/runtime/firstRenderReveal.ts @@ -0,0 +1,38 @@ +import { isElementNode } from "./domRealm"; +import { CLIP_HIDDEN_ATTR } from "./timedClipHide"; +import type { RuntimeTimelineChildLike } from "./types"; + +type Child = RuntimeTimelineChildLike & { timeScale?: () => number; repeat?: () => number }; + +/** + * The hidden clips holding a target of a tween that starts in (from, to], so renders for the first time on a seek + * there. Null when a nested timeline is scaled or repeats, as its children's times are then not plain offsets. + */ +const plain = (child: Child) => (child.timeScale?.() ?? 1) === 1 && (child.repeat?.() ?? 0) === 0; +const childrenOf = (parent: RuntimeTimelineChildLike) => + (parent.getChildren?.(false, true, true) ?? []) as Child[]; +const startOf = (child: Child, offset: number) => offset + (child.startTime?.() ?? 0); +const targetsOf = (child: Child) => child.targets?.() ?? []; + +function addHiddenClipsHolding(target: unknown, clips: Set): void { + for (let el = isElementNode(target) ? target : null; el; el = el.parentElement) + if (el.hasAttribute(CLIP_HIDDEN_ATTR)) clips.add(el); +} + +export function clipsFirstRenderedIn( + timeline: RuntimeTimelineChildLike, + from: number, + to: number, +): Set | null { + const clips = new Set(); + const visit = (parent: RuntimeTimelineChildLike, offset: number): boolean => + childrenOf(parent).every((child) => { + if (!plain(child)) return false; + const start = startOf(child, offset); + if (child.getChildren) return visit(child, start); + if (start > from && start <= to) + for (const target of targetsOf(child)) addHiddenClipsHolding(target, clips); + return true; + }); + return visit(timeline, 0) ? clips : null; +} diff --git a/packages/core/src/runtime/init.test.ts b/packages/core/src/runtime/init.test.ts index d0b9bf2c21b..c18e0362a68 100644 --- a/packages/core/src/runtime/init.test.ts +++ b/packages/core/src/runtime/init.test.ts @@ -566,8 +566,8 @@ describe("initSandboxRuntimeModular", () => { it("shows only the clips a first render crosses, not later ones", () => { document.body.innerHTML = `
` + - `
` + - `
` + + `

` + + `

` + `
`; const seen: string[] = []; const read = () => { @@ -576,7 +576,8 @@ describe("initSandboxRuntimeModular", () => { return 1; }; const tl = gsap.timeline({ paused: true }); - tl.to({ x: 0 }, { x: read, duration: 0.1 }, 3); + tl.to("#said", { x: read, duration: 0.1 }, 3); + tl.to("#next", { x: 1, duration: 0.1 }, 8); window.__timelines = { main: tl as unknown as RuntimeTimelineLike }; initSandboxRuntimeModular(); window.__player?.seek(1); diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index 681e05db51a..2545063aaec 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -1,6 +1,7 @@ // fallow-ignore-file code-duplication complexity import { setPreviewRasterScale } from "./previewRasterHints"; import { createFarSceneSkip } from "./farScenes"; +import { clipsFirstRenderedIn } from "./firstRenderReveal"; import { refreshSvgSelectorAliases } from "../compiler/svgSelectorAliases"; import { RUNTIME_FILLER } from "./protocol"; import { preloadMedia, releaseMedia, lengthIsAuthored, stopMediaDownload } from "./preloadMedia"; @@ -4863,17 +4864,11 @@ export function initSandboxRuntimeModular(): void { if (revision !== tweensRenderedThrough.revision) tweensRenderedThrough = { revision, time: Number.NEGATIVE_INFINITY }; const firstRenders = t > tweensRenderedThrough.time; - // Only clips live in the span rendered for the first time hold tweens that render now. - const windowOptions = { - timelineRegistry: window.__timelines ?? {}, - resolver: timingResolverFor(true), - }; - const renderedFrom = tweensRenderedThrough.time; + // Only clips holding a tween that renders for the first time on this seek need their authored style read. + const firstRendered = + firstRenders && tl ? clipsFirstRenderedIn(tl, tweensRenderedThrough.time, t) : null; const hideClipsAgain = firstRenders - ? showHiddenClips((el) => { - const span = runtimeElementWindow(el, windowOptions); - return !span || (span.start <= t && span.end >= renderedFrom); - }) + ? showHiddenClips(firstRendered ? (el) => firstRendered.has(el) : undefined) : null; if (tl) { // #10: when data-duration exceeds the timeline's intrinsic length the From 4359fb8eeac61a96af2bc5fc09f5b75f3588cf42 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 24 Oct 2026 03:28:08 -0400 Subject: [PATCH 05/40] feat(core): a scene may load a project helper and wait for fonts and still swap --- .../core/src/compiler/htmlBundler.test.ts | 51 +++++++++++++++++-- packages/core/src/compiler/htmlBundler.ts | 4 ++ .../src/compiler/inlineSubCompositions.ts | 19 ++++++- .../core/src/runtime/firstRenderReveal.ts | 51 +++++++++++++------ 4 files changed, 104 insertions(+), 21 deletions(-) diff --git a/packages/core/src/compiler/htmlBundler.test.ts b/packages/core/src/compiler/htmlBundler.test.ts index 8978a744947..ec84a2f8438 100644 --- a/packages/core/src/compiler/htmlBundler.test.ts +++ b/packages/core/src/compiler/htmlBundler.test.ts @@ -3346,9 +3346,8 @@ describe("bundleToSingleHtml sceneParts", () => { "its script uses addEventListener", ); expect(host("b")?.hasAttribute("data-hf-scene-no-swap")).toBe(false); - expect(host("c")?.getAttribute("data-hf-scene-no-swap")).toBe( - "it runs a script that is not a known library", - ); + // A project file is read and held to the rules of an inline script. + expect(host("c")?.getAttribute("data-hf-scene-no-swap")).toBe("its script uses .animate("); // The root loading the same URL does not make a scene's own initializer a library. expect(host("d")?.getAttribute("data-hf-scene-no-swap")).toBe( "it runs a script that is not a known library", @@ -3364,6 +3363,52 @@ describe("bundleToSingleHtml sceneParts", () => { expect(parseHTML(rendered).document.querySelector("[data-hf-scene-no-swap]")).toBeNull(); }); + it("lets a scene load a project helper and wait for fonts, and refuses anything else either leaves pending", async () => { + const scenes: Record = { + helper: ``, + fontsThen: ``, + fontsAwait: ``, + timerHelper: ``, + missingHelper: ``, + fetchThen: ``, + otherAwait: ``, + promise: ``, + }; + const names = Object.keys(scenes); + const dir = makeTempProject({ + "index.html": ` +
+ ${names.map((n) => `
`).join("\n")} +
`, + "lib/helper.js": `window.build = function (id) { var tl = gsap.timeline({ paused: true }); tl.to(document.querySelector('[data-composition-id="' + id + '"] p'), { x: 1 }); return tl; };`, + "lib/ticker.js": `setInterval(function () {}, 100);`, + ...Object.fromEntries( + names.map((n) => [ + `compositions/${n}.html`, + ``, + ]), + ), + }); + const doc = parseHTML(await bundleToSingleHtml(dir, { sceneParts: true })).document; + const refusals = Object.fromEntries( + names.map((n) => [ + n, + doc.querySelector(`div[data-hf-scene="${n}"]`)?.getAttribute("data-hf-scene-no-swap") ?? + null, + ]), + ); + expect(refusals).toEqual({ + helper: null, + fontsThen: null, + fontsAwait: null, + timerHelper: "its script uses setInterval", + missingHelper: "it runs a script that is not a known library", + fetchThen: "its script uses fetch", + otherAwait: "its script uses async", + promise: "its script uses Promise", + }); + }); + const rootProject = (root: string, extra: Record = {}) => makeTempProject({ "index.html": ` diff --git a/packages/core/src/compiler/htmlBundler.ts b/packages/core/src/compiler/htmlBundler.ts index 17b5530d9d7..0e7de34fe6e 100644 --- a/packages/core/src/compiler/htmlBundler.ts +++ b/packages/core/src/compiler/htmlBundler.ts @@ -1191,6 +1191,10 @@ async function bundleProject(projectDir: string, options?: BundleOptions): Promi }, flattenInnerRoot: prepareFlattenedInnerRoot, tagScenes: options?.sceneParts === true, + readProjectScript: (path: string) => { + const resolved = isRelativeUrl(path) ? resolveEntryUrl(path) : null; + return resolved ? safeReadFile(resolved) : null; + }, readVariableDefaults: readDeclaredDefaults, parseHostVariables: parseHostVariableValues, buildScopeSelector: (compId: string) => cssAttributeSelector("data-composition-id", compId), diff --git a/packages/core/src/compiler/inlineSubCompositions.ts b/packages/core/src/compiler/inlineSubCompositions.ts index 80f8a1de55a..8dfb08fb721 100644 --- a/packages/core/src/compiler/inlineSubCompositions.ts +++ b/packages/core/src/compiler/inlineSubCompositions.ts @@ -58,12 +58,24 @@ const SWAP_SAFE_LIBRARY_URL = const isSwapSafeLibrary = (src: string) => URL.canParse(src) && SWAP_SAFE_LIBRARY_URL.test(new URL(src).href); +// Waiting for web fonts settles once, so a script re-run after a swap is not left with anything pending. +const FONT_READY_WAIT_RE = + /\bdocument\s*\.\s*fonts\s*\.\s*ready\s*\.\s*then\s*\(|\bawait\s+document\s*\.\s*fonts\s*\.\s*ready\b/g; + /** Why an authored scene script cannot be swapped out cleanly, or null when it can. */ function sceneScriptSwapRefusal(script: string): string | null { - const match = SIDE_EFFECT_RE.exec(script); + const withoutFontWaits = script.replace(FONT_READY_WAIT_RE, ""); + // An async function whose only await is the font wait pends no more than the wait itself. + const awaitedFontsOnly = /\bawait\b/.test(script) && !/\bawait\b/.test(withoutFontWaits); + const checked = awaitedFontsOnly ? withoutFontWaits.replace(/\basync\b/g, "") : withoutFontWaits; + const match = SIDE_EFFECT_RE.exec(checked); return match ? `its script uses ${match[0].trim()}` : null; } +// A project file is held to the inline rules; one that cannot be read is not known. +const projectScriptRefusal = (text: string | null) => + text === null ? "it runs a script that is not a known library" : sceneScriptSwapRefusal(text); + // A quoted string in a script, in any of the three quote styles. const STRING_LITERAL_RE = /(["'`])((?:\\.|(?!\1)[^\\\n])*?)\1/g; // Text ending where createElement takes its tag: that string makes a node, it selects none. @@ -239,6 +251,8 @@ export interface InlineSubCompositionsOptions { onMissingComposition?: (srcPath: string, reason?: string) => void; /** Tag each top-level host with `data-hf-scene` and report every part's scene, for preview swaps. */ tagScenes?: boolean; + /** With `tagScenes`: the text of a project script a scene loads, by its rebased path, or null. */ + readProjectScript?: (path: string) => string | null; } export interface ExternalLink { @@ -333,6 +347,7 @@ export function inlineSubCompositions( onMissingComposition, assetExists, tagScenes = false, + readProjectScript, } = options; const rootStyles = [...document.querySelectorAll("style")]; @@ -544,7 +559,7 @@ export function inlineSubCompositions( ? sceneScriptSwapRefusal(scriptEl.textContent || "") : isSwapSafeLibrary(externalSrc) ? null - : "it runs a script that is not a known library", + : projectScriptRefusal(readProjectScript?.(externalSrc) ?? null), ); if (type === "importmap") { const map = parseImportMap(scriptEl.textContent || "", (url) => { diff --git a/packages/core/src/runtime/firstRenderReveal.ts b/packages/core/src/runtime/firstRenderReveal.ts index 8b9d2f92dd6..454aeca97ef 100644 --- a/packages/core/src/runtime/firstRenderReveal.ts +++ b/packages/core/src/runtime/firstRenderReveal.ts @@ -2,16 +2,21 @@ import { isElementNode } from "./domRealm"; import { CLIP_HIDDEN_ATTR } from "./timedClipHide"; import type { RuntimeTimelineChildLike } from "./types"; -type Child = RuntimeTimelineChildLike & { timeScale?: () => number; repeat?: () => number }; +type Child = RuntimeTimelineChildLike & { + timeScale?: () => number; + repeat?: () => number; + totalDuration?: () => number; + getChildren?: ( + nested?: boolean, + tweens?: boolean, + timelines?: boolean, + ignoreBeforeTime?: number, + ) => Child[]; +}; -/** - * The hidden clips holding a target of a tween that starts in (from, to], so renders for the first time on a seek - * there. Null when a nested timeline is scaled or repeats, as its children's times are then not plain offsets. - */ const plain = (child: Child) => (child.timeScale?.() ?? 1) === 1 && (child.repeat?.() ?? 0) === 0; -const childrenOf = (parent: RuntimeTimelineChildLike) => - (parent.getChildren?.(false, true, true) ?? []) as Child[]; const startOf = (child: Child, offset: number) => offset + (child.startTime?.() ?? 0); +const endOf = (child: Child, start: number) => start + (child.totalDuration?.() ?? Infinity); const targetsOf = (child: Child) => child.targets?.() ?? []; function addHiddenClipsHolding(target: unknown, clips: Set): void { @@ -19,20 +24,34 @@ function addHiddenClipsHolding(target: unknown, clips: Set): void { if (el.hasAttribute(CLIP_HIDDEN_ATTR)) clips.add(el); } +/** + * The hidden clips holding a target of a tween that starts in (from, to], so renders for the first time on a seek + * there. Null when a nested timeline is scaled or repeats, as its children's times are then not plain offsets. + */ export function clipsFirstRenderedIn( timeline: RuntimeTimelineChildLike, from: number, to: number, ): Set | null { const clips = new Set(); - const visit = (parent: RuntimeTimelineChildLike, offset: number): boolean => - childrenOf(parent).every((child) => { - if (!plain(child)) return false; - const start = startOf(child, offset); - if (child.getChildren) return visit(child, start); - if (start > from && start <= to) - for (const target of targetsOf(child)) addHiddenClipsHolding(target, clips); - return true; + const overlaps = (start: number, end: number) => end > from && start <= to; + const timelinesIn = (parent: Child) => parent.getChildren?.(false, false, true) ?? []; + // ignoreBeforeTime leaves out tweens that started before the span, which were drawn already. + const tweensIn = (parent: Child, offset: number) => + parent.getChildren?.(false, true, false, from - offset) ?? []; + const addTargets = (tween: Child, offset: number) => { + const start = startOf(tween, offset); + if (start > from && start <= to) + for (const target of targetsOf(tween)) addHiddenClipsHolding(target, clips); + }; + // Timelines wholly before the span were drawn already and ones after it are not reached, so neither is opened. + const visit = (parent: Child, offset: number): boolean => { + const nestedPlain = timelinesIn(parent).every((nested) => { + const start = startOf(nested, offset); + return plain(nested) && (!overlaps(start, endOf(nested, start)) || visit(nested, start)); }); - return visit(timeline, 0) ? clips : null; + if (nestedPlain) for (const tween of tweensIn(parent, offset)) addTargets(tween, offset); + return nestedPlain; + }; + return visit(timeline as Child, 0) ? clips : null; } From edb0b51f39c36c56600c78d7f4f3e583ca103ff1 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 24 Oct 2026 04:26:31 -0400 Subject: [PATCH 06/40] perf(core): the preview mounts only the scenes near the playhead --- packages/core/src/runtime/entry.ts | 2 + packages/core/src/runtime/farScenes.ts | 23 +- .../core/src/runtime/init.swapScenes.test.ts | 138 +++++++++ packages/core/src/runtime/init.ts | 286 +++++++++++++----- packages/core/src/runtime/sceneWindow.ts | 128 ++++++++ packages/core/src/runtime/timeline.ts | 50 ++- packages/core/src/studioPreviewMark.ts | 3 + 7 files changed, 531 insertions(+), 99 deletions(-) create mode 100644 packages/core/src/runtime/sceneWindow.ts diff --git a/packages/core/src/runtime/entry.ts b/packages/core/src/runtime/entry.ts index 236222cfb75..1e7a2c183c6 100644 --- a/packages/core/src/runtime/entry.ts +++ b/packages/core/src/runtime/entry.ts @@ -18,6 +18,7 @@ import { AFTER_FONTS_SCRIPTS } from "../compiler/scriptRuns"; import { hasFrameSources, registerFrameSource } from "./frameSources"; import { createFilmBridge } from "./filmBridge"; import { trackAcceleratedCanvases } from "./captureMotionBlur"; +import { parkFarScenesAtBoot } from "./sceneWindow"; type HyperframeWindow = Window & { __hyperframeRuntimeBootstrapped?: boolean; @@ -77,6 +78,7 @@ function bootstrapHyperframeRuntime(): void { // Compiled composition scripts wait for web fonts, so what they measure matches every run. function startAfterCompositionScripts(): void { try { + parkFarScenesAtBoot(); refreshSvgSelectorAliases(); const deferred = Array.from(document.querySelectorAll(AFTER_FONTS_SCRIPTS)); if (deferred.length === 0) bootstrapHyperframeRuntime(); diff --git a/packages/core/src/runtime/farScenes.ts b/packages/core/src/runtime/farScenes.ts index 6997203995b..9f65f9d71a5 100644 --- a/packages/core/src/runtime/farScenes.ts +++ b/packages/core/src/runtime/farScenes.ts @@ -8,15 +8,24 @@ const OUT_OF_FLOW = new Set(["absolute", "fixed"]); type Timed = { host: HTMLElement; span: { start: number; end: number } }; -/** Hosts outside the playing scene(s) and one neighbour each side; between scenes, the one before the gap plays. */ -function farFrom(timed: Timed[], time: number): Set { - const playing = timed.flatMap(({ span }, i) => - span.start <= time && time <= span.end ? [i] : [], - ); - const before = timed.filter(({ span }) => span.start <= time).length - 1; +/** Whether each span, sorted by start, is the playing one or a neighbour; between scenes, the one before the gap plays. */ +export function nearPlayhead( + spans: Array<{ start: number; end: number }>, + time: number, +): boolean[] { + const playing = spans.flatMap((span, i) => (span.start <= time && time <= span.end ? [i] : [])); + const before = spans.filter((span) => span.start <= time).length - 1; const lo = playing.length > 0 ? playing[0]! : before; const hi = playing.length > 0 ? playing[playing.length - 1]! : before; - return new Set(timed.filter((_, i) => i < lo - 1 || i > hi + 1).map((s) => s.host)); + return spans.map((_, i) => i >= lo - 1 && i <= hi + 1); +} + +function farFrom(timed: Timed[], time: number): Set { + const near = nearPlayhead( + timed.map((s) => s.span), + time, + ); + return new Set(timed.filter((_, i) => !near[i]).map((s) => s.host)); } /** diff --git a/packages/core/src/runtime/init.swapScenes.test.ts b/packages/core/src/runtime/init.swapScenes.test.ts index f553ecea812..330bfe8ea94 100644 --- a/packages/core/src/runtime/init.swapScenes.test.ts +++ b/packages/core/src/runtime/init.swapScenes.test.ts @@ -8,6 +8,7 @@ import { WebAudioTransport } from "./webAudioTransport"; import { probeAndCacheElementVolume } from "./mediaVolumeEnvelope.js"; import { wrapScopedCompositionScript } from "../compiler/compositionScoping"; import { AFTER_FONTS_SCRIPT_TYPE } from "../compiler/scriptRuns"; +import { parkFarScenesAtBoot } from "./sceneWindow"; vi.mock("./mediaVolumeEnvelope.js", async (importOriginal) => { const actual = await importOriginal(); @@ -1709,3 +1710,140 @@ window.__timelines.a = tl;`; delete (window as unknown as { gsap?: unknown }).gsap; }); }); + +describe("the preview's scene window", () => { + // Three 2 s scenes; at 0 the first two play or neighbour, so the third waits parked. + const S0: Scene = { id: "s0", start: 0, body: "

zero

", css: "", label: "a1", hash: "h0" }; + const S1: Scene = { id: "s1", start: 2, body: "

one

", css: "", label: "b", hash: "h1" }; + const S2: Scene = { + id: "s2", + start: 4, + body: '

two

', + css: "", + label: "a2", + hash: "h2", + }; + const parked = () => + [...document.querySelectorAll("[data-hf-parked-scene]")].map((el) => + el.getAttribute("data-hf-scene"), + ); + // As the runtime's entry does: park before any scene script runs, then run the rest. + function bootWindowed(scenes: Scene[], root: Tl, withManifest = true) { + const { head, body } = preview(scenes); + document.head.innerHTML = withManifest ? head : head.replace(/]*>/, ""); + document.body.innerHTML = body; + window.__timelines = { main: root }; + parkFarScenesAtBoot(); + for (const s of scenes) if (!parked().includes(s.id)) window.__timelines[s.id] = made[s.label]; + const replaceWith = Element.prototype.replaceWith; + vi.spyOn(Element.prototype, "replaceWith").mockImplementation(function ( + this: Element, + ...nodes + ) { + replaceWith.apply(this, nodes); + for (const node of nodes) { + if (node instanceof HTMLScriptElement && node.hasAttribute("data-hf-scene")) + new Function(node.textContent ?? "")(); + } + }); + initSandboxRuntimeModular(); + } + const seekAndSettle = async (time: number) => { + window.__player?.seek(time); + await window.__hfWaitForSeekCompletion?.(); + }; + + beforeEach(() => { + resetRuntimeDataForTests(); + (globalThis as { CSS?: { escape?: (v: string) => string } }).CSS ??= {}; + globalThis.CSS.escape ??= (v: string) => v; + window.requestAnimationFrame = ((cb: FrameRequestCallback) => ( + cb(0), 1 + )) as typeof window.requestAnimationFrame; + window.cancelAnimationFrame = (() => {}) as typeof window.cancelAnimationFrame; + for (const k of Object.keys(made)) delete made[k]; + for (const label of ["a1", "a2", "b"]) made[label] = tl(label); + (window as unknown as { __made: typeof made }).__made = made; + }); + afterEach(() => { + window.__hfRuntimeTeardown?.(); + document.head.innerHTML = ""; + document.body.innerHTML = ""; + delete window.__hfSceneAnimations; + vi.restoreAllMocks(); + }); + + it("parks a scene away from the start, keeping its timing, and never runs its script", async () => { + bootWindowed([S0, S1, S2], trackingRoot().root); + await tick(); + expect(parked()).toEqual(["s2"]); + const placeholder = sceneHost("s2"); + expect([placeholder.childElementCount, placeholder.getAttribute("data-start")]).toEqual([ + 0, + "4", + ]); + expect(document.querySelector('script[data-hf-scene="s2"]')).toBeNull(); + expect(window.__timelines?.s2).toBeUndefined(); + expect(sceneHost("s0").textContent).toBe("zero"); + }); + + it("mounts the scenes a seek reaches before the seek counts as done, and parks the far ones", async () => { + const { root, children } = trackingRoot(); + bootWindowed([S0, S1, S2], root); + await tick(); + await seekAndSettle(5); + expect(parked()).toEqual(["s0"]); + expect(sceneHost("s2").textContent).toBe("two"); + expect(window.__timelines?.s2).toBe(made.a2); + expect(children.map((c) => c.child)).toContain(made.a2); + expect(made.a1!.kill).toHaveBeenCalled(); + expect(children.map((c) => c.child)).not.toContain(made.a1); + expect(window.__player?.getTime()).toBe(5); + await seekAndSettle(0.5); + expect(parked()).toEqual(["s2"]); + expect(sceneHost("s0").textContent).toBe("zero"); + }); + + it("keeps drawing the scene it showed while the scene a seek lands in mounts", async () => { + bootWindowed([S0, S1, S2], trackingRoot().root); + await tick(); + await seekAndSettle(0.5); + window.__player?.seek(5); + expect(sceneHost("s0").getAttribute("style")).toContain("visibility: visible"); + await window.__hfWaitForSeekCompletion?.(); + expect(sceneHost("s2").getAttribute("style")).toContain("visibility: visible"); + }); + + it("keeps a parked scene's clips on the timeline, at the time its scene puts them", async () => { + bootWindowed([S0, S1, S2], trackingRoot().root); + await tick(); + const clip = window.__clipManifest?.clips.find( + (c) => c.parentCompositionId === "s2" && c.tagName === "i", + ); + expect(clip && [clip.start, clip.duration]).toEqual([4.5, 1]); + }); + + it("gives an edit to a parked scene to its next mount, without mounting it", async () => { + bootWindowed([S0, S1, S2], trackingRoot().root); + await tick(); + const edited: Scene = { ...S2, body: "

two, edited

", hash: "h2b" }; + await window.__hfSwapScenes!(preview([S0, S1, edited]).html); + expect(parked()).toEqual(["s2"]); + await seekAndSettle(5); + expect(sceneHost("s2").textContent).toBe("two, edited"); + }); + + it("mounts every scene of a page without a scene manifest, and every scene the swap gate refuses", async () => { + bootWindowed([S0, S1, S2], trackingRoot().root, false); + await tick(); + expect(parked()).toEqual([]); + window.__hfRuntimeTeardown?.(); + vi.restoreAllMocks(); + bootWindowed( + [S0, S1, { ...S2, extraAttrs: ' data-hf-scene-no-swap="its script uses fetch"' }], + trackingRoot().root, + ); + await tick(); + expect(parked()).toEqual([]); + }); +}); diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index 2545063aaec..09db23ff7df 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -1,6 +1,17 @@ // fallow-ignore-file code-duplication complexity import { setPreviewRasterScale } from "./previewRasterHints"; import { createFarSceneSkip } from "./farScenes"; +import { + hostOf as sceneHostOf, + isParked, + parkedClips, + parkedTimelineScenes, + park as parkScene, + rememberScene, + scenesNear, + unpark, + windowedScenes, +} from "./sceneWindow"; import { clipsFirstRenderedIn } from "./firstRenderReveal"; import { refreshSvgSelectorAliases } from "../compiler/svgSelectorAliases"; import { RUNTIME_FILLER } from "./protocol"; @@ -19,6 +30,7 @@ import { import { PREVIEW_ONLY_ATTRS, RUNTIME_STAMPED_ATTR, + STUDIO_PREVIEW_PARKED_SCENE_ATTR, STUDIO_PREVIEW_LAZY_ATTR, STUDIO_PREVIEW_UPCOMING_ATTR, } from "../studioPreviewMark"; @@ -2824,6 +2836,12 @@ export function initSandboxRuntimeModular(): void { return true; }; + // A parked scene's clips stay on the timeline, read from its markup. + const timelinePayloadParams = () => ({ + canonicalFps: state.canonicalFps, + parked: parkedTimelineScenes(), + parkedClips, + }); const hiddenImagesSkipped = skipsHiddenImages(); const mediaDeferred = defersMedia(); const skipFarScenes = createFarSceneSkip((host) => @@ -2832,6 +2850,8 @@ export function initSandboxRuntimeModular(): void { resolver: timingResolverFor(true), }), ); + // Set once the scene swap is in place; true while a scene playing at the time is still parked. + let moveSceneWindow = (_time: number): boolean => false; const LOOKAHEAD_SECONDS = 2; const RELEASE_LOOKAHEAD_SECONDS = 4; // Unskipped while within the window on either side of the playhead, so a step or shuttle back @@ -3066,6 +3086,8 @@ export function initSandboxRuntimeModular(): void { timingRevision?: number, playedFromSeconds?: number, ) => { + // While the scene the playhead landed in mounts, the page keeps what it last drew. + if (moveSceneWindow(currentTime)) return; const rootComp = resolveRootCompositionElement(); let decidedTimedClip = false; const visibleAt = timedVisibilityAt(timingRevision); @@ -3598,9 +3620,7 @@ export function initSandboxRuntimeModular(): void { if (jumpLanding && !renderCaptureSeekStarted && hasPendingSeekCompletion()) return postTimelineAfterSeek(); jumpLanding = false; - const payload = collectRuntimeTimelinePayload({ - canonicalFps: state.canonicalFps, - }); + const payload = collectRuntimeTimelinePayload(timelinePayloadParams()); window.__clipManifest = payload; lastPostedManifest = JSON.stringify(payload); @@ -3638,9 +3658,7 @@ export function initSandboxRuntimeModular(): void { // What no observer sees (a label, a track or an inline z-index edited on a playing clip) still reaches Studio. const postTimelineIfManifestChanged = () => { - const manifest = JSON.stringify( - collectRuntimeTimelinePayload({ canonicalFps: state.canonicalFps }), - ); + const manifest = JSON.stringify(collectRuntimeTimelinePayload(timelinePayloadParams())); if (manifest !== lastPostedManifest) postTimeline(); }; @@ -3907,6 +3925,91 @@ export function initSandboxRuntimeModular(): void { } } }; + // Stops every animation a scene's scripts started, newest first, and forgets them. `check` runs once they stop. + const stopSceneAnimations = (hosts: Element[], check: () => void = () => {}) => { + const timelines = () => + (window.__timelines ??= {}) as Record; + const sceneAnimations = () => (window.__hfSceneAnimations ??= {}); + const root = state.capturedTimeline as + | (RuntimeTimelineLike & { remove?: (child: unknown) => unknown }) + | null; + const oldIds = hosts.flatMap((host) => compositionIdsIn(host)); + const stopped = new Set(); + const stopOldAnimations = () => { + const before = stopped.size; + for (const id of oldIds) { + // Newest first: each revert restores what the animation before it wrote. + for (const previous of [ + ...(sceneAnimations()[id] ?? []).slice().reverse(), + timelines()[id], + ]) { + if (!previous || stopped.has(previous)) continue; + stopped.add(previous); + const old = previous as SceneAnimation; + if (old.revert) old.revert(); + else old.totalTime?.(0, true); + root?.remove?.(previous); + // revert() has already killed it; a second kill() fires onInterrupt again. + if (!old.revert) old.kill?.(); + } + } + return stopped.size > before; + }; + // A revert fires the animation's onInterrupt, which can register more; stop those too, until none appear. + let passes = 0; + while (stopOldAnimations()) { + if (++passes > 8) throw new Error("the old scene keeps starting animations as they stop"); + } + check(); + for (const id of oldIds) { + delete timelines()[id]; + delete sceneAnimations()[id]; + } + }; + // What a load does after scene markup changed: bindings, media, captions, the root timeline, then a redraw. + const settleChangedScenes = ( + swappedHosts: Element[], + captionHosts: Element[], + captionOverrides: Awaited>, + ) => { + // Boot's order: bindings settle each src before media binding proxies and loads it. + for (const host of swappedHosts) applyVariableBindings(document, host); + bindMediaMetadataListeners(); + // Rewound before the rewrite, so each rewritten tween re-reads its start from the reset word. + const rewindCaptionTimelines = () => { + for (const host of captionHosts) { + for (const el of [host, ...host.querySelectorAll("[data-composition-id]")]) { + const id = el.getAttribute("data-composition-id"); + if (id) window.__timelines?.[id]?.totalTime?.(0, true); + } + } + }; + initVfx(document.body, state.canonicalFps); + initMotionBlur(); + applyFetchedCaptionOverrides(captionOverrides, captionHosts, rewindCaptionTimelines); + releaseDetachedMedia(); + childrenBound = false; + bindRootTimelineIfAvailable(); + // Probed before the scene was nested, so the new media found no volume envelope. + const swapMovers = passMovers(); + for (const host of swappedHosts) { + for (const el of host.querySelectorAll("video, audio")) { + volumeKeyframeCache.delete(el); + probeAndCacheVolumeKeyframes(el, swapMovers); + } + } + const duration = getSafeTimelineDurationSeconds(state.capturedTimeline, 0); + if (duration > 0) clock.setDuration(duration); + // Adapters drive only the elements they found at discover time; the new scene's are new. + runAdapters("discover", state.currentTime); + // The rebind above skips these when the root timeline object did not change. + applyPositionEdits(document); + // Redraw the current frame as a seek does; a playing film cut to end at or before it stops there, as at its end. + const cutShort = clock.isPlaying() && duration > 0 && state.currentTime >= duration; + transport.seek(cutShort ? duration : state.currentTime, { keepPlaying: !cutShort }); + syncTimedElementVisibility(state.currentTime); + postTimeline(); + }; // Only the rules not here yet: rewriting a loaded rule's style would load its font again. const addNewFontFaces = (next: Document) => { const holders = (doc: Document) => @@ -3942,8 +4045,33 @@ export function initSandboxRuntimeModular(): void { ) { throw new Error("scenes were added or removed"); } - const changed = names.filter((name) => nextParts.scenes[name] !== liveParts.scenes[name]); - if (changed.length === 0) throw new Error("no scene changed"); + const edited = names.filter((name) => nextParts.scenes[name] !== liveParts.scenes[name]); + if (edited.length === 0) throw new Error("no scene changed"); + const nextPartsOf = (name: string) => + Array.from(next.querySelectorAll(`[${SCENE_PART_ATTR}="${CSS.escape(name)}"]`)); + // A parked scene runs nothing, so only the markup it mounts with changes. + const parkedHost = (name: string) => { + const host = sceneHostOf(name); + return host !== null && isParked(host); + }; + for (const name of edited.filter(parkedHost)) { + const parts = nextPartsOf(name); + const host = parts.find((el) => el.tagName !== "STYLE" && el.tagName !== "SCRIPT"); + if (!host) throw new Error(`scene ${name} cannot be swapped: it has no single host`); + rememberScene( + name, + host, + parts.filter((el) => el.tagName === "SCRIPT"), + ); + } + const changed = edited.filter((name) => !parkedHost(name)); + if (changed.length === 0) { + document + .querySelector(`meta[name="${SCENE_PARTS_META}"]`) + ?.setAttribute("content", JSON.stringify(nextParts)); + postTimeline(); + return; + } const swaps = changed.map((name) => { const partsIn = (doc: Document) => Array.from(doc.querySelectorAll(`[${SCENE_PART_ATTR}="${CSS.escape(name)}"]`)); @@ -4033,45 +4161,16 @@ export function initSandboxRuntimeModular(): void { // Checked after the wait, which a tween could start in, and before anything changes. refuseAnyOutsideTweens(); sceneSwapGeneration += 1; - const root = state.capturedTimeline as - | (RuntimeTimelineLike & { remove?: (child: unknown) => unknown }) - | null; // Overrides re-dim every word they touch, so only the swapped scenes' captions get them. const captionHosts: Element[] = []; const swappedHosts: Element[] = []; - const oldIds = swaps.flatMap(({ oldHost }) => compositionIdsIn(oldHost)); - const stopped = new Set(); - const stopOldAnimations = () => { - const before = stopped.size; - for (const id of oldIds) { - // Newest first: each revert restores what the animation before it wrote. - for (const previous of [ - ...(sceneAnimations()[id] ?? []).slice().reverse(), - timelines()[id], - ]) { - if (!previous || stopped.has(previous)) continue; - stopped.add(previous); - const old = previous as SceneAnimation; - if (old.revert) old.revert(); - else old.totalTime?.(0, true); - root?.remove?.(previous); - // revert() has already killed it; a second kill() fires onInterrupt again. - if (!old.revert) old.kill?.(); - } - } - return stopped.size > before; - }; - // A revert fires the animation's onInterrupt, which can register more; stop those too, until none appear. - let passes = 0; - while (stopOldAnimations()) { - if (++passes > 8) throw new Error("the old scene keeps starting animations as they stop"); - } - refuseReplacedScenes(); - refuseAnyOutsideTweens(); - for (const id of oldIds) { - delete timelines()[id]; - delete sceneAnimations()[id]; - } + stopSceneAnimations( + swaps.map(({ oldHost }) => oldHost), + () => { + refuseReplacedScenes(); + refuseAnyOutsideTweens(); + }, + ); // Released before kept media is reset: a later stop puts back the mute and volume it saved under the old script. webAudio.stopAll(); clock.detachAudioSource(); @@ -4104,44 +4203,77 @@ export function initSandboxRuntimeModular(): void { document .querySelector(`meta[name="${SCENE_PARTS_META}"]`) ?.setAttribute("content", JSON.stringify(nextParts)); - // Boot's order: bindings settle each src before media binding proxies and loads it. - for (const host of swappedHosts) applyVariableBindings(document, host); - bindMediaMetadataListeners(); - // Rewound before the rewrite, so each rewritten tween re-reads its start from the reset word. - const rewindCaptionTimelines = () => { - for (const host of captionHosts) { - for (const el of [host, ...host.querySelectorAll("[data-composition-id]")]) { - const id = el.getAttribute("data-composition-id"); - if (id) window.__timelines?.[id]?.totalTime?.(0, true); - } - } - }; - initVfx(document.body, state.canonicalFps); - initMotionBlur(); - applyFetchedCaptionOverrides(captionOverrides, captionHosts, rewindCaptionTimelines); + for (const { name, newParts, newHost } of swaps) + rememberScene( + name, + newHost, + newParts.filter((el) => el.tagName === "SCRIPT"), + ); + settleChangedScenes(swappedHosts, captionHosts, captionOverrides); + windowScenes = windowedScenes(); + }; + + // Preview only: scenes away from the playhead were parked at boot; the window mounts and retires them as it moves. + let windowScenes = windowedScenes(); + const parkedNames = new Set( + Array.from(document.querySelectorAll(`[${STUDIO_PREVIEW_PARKED_SCENE_ATTR}]`), (el) => + el.getAttribute(SCENE_PART_ATTR), + ), + ); + let windowWork: Promise | null = null; + let windowNear = ""; + const mountScene = async (name: string) => { + const unparked = unpark(name); + if (!unparked) return; + parkedNames.delete(name); + const { host, scripts } = unparked; + recordAuthoredMedia(host); + refreshSvgSelectorAliases(); + const captionHosts = host.querySelector(".caption-group") ? [host] : []; + const captionOverrides = captionHosts.length > 0 ? await fetchCaptionOverrides() : []; + await runScriptsAfterFonts(scripts.map((el) => document.body.appendChild(el))); + if (!state.tornDown) settleChangedScenes([host], captionHosts, captionOverrides); + }; + const retireScene = (name: string) => { + const host = sceneHostOf(name); + if (!host || isParked(host)) return; + stopSceneAnimations([host]); + parkScene(name); + parkedNames.add(name); releaseDetachedMedia(); childrenBound = false; bindRootTimelineIfAvailable(); - // Probed before the scene was nested, so the new media found no volume envelope. - const swapMovers = passMovers(); - for (const host of swappedHosts) { - for (const el of host.querySelectorAll("video, audio")) { - volumeKeyframeCache.delete(el); - probeAndCacheVolumeKeyframes(el, swapMovers); - } - } - const duration = getSafeTimelineDurationSeconds(state.capturedTimeline, 0); - if (duration > 0) clock.setDuration(duration); - // Adapters drive only the elements they found at discover time; the new scene's are new. - runAdapters("discover", state.currentTime); - // The rebind above skips these when the root timeline object did not change. - applyPositionEdits(document); - // Redraw the current frame as a seek does; a playing film cut to end at or before it stops there, as at its end. - const cutShort = clock.isPlaying() && duration > 0 && state.currentTime >= duration; - transport.seek(cutShort ? duration : state.currentTime, { keepPlaying: !cutShort }); - syncTimedElementVisibility(state.currentTime); postTimeline(); }; + moveSceneWindow = (time: number): boolean => { + if (windowScenes.length === 0 || state.tornDown) return false; + const parked = (name: string) => parkedNames.has(name); + const landedParked = windowScenes.some( + ({ name, start, end }) => start <= time && time <= end && parked(name), + ); + if (windowWork) return landedParked; + const near = scenesNear(windowScenes, time); + const key = [...near].join("\n"); + if (key === windowNear) return landedParked; + // Nearest first, so the scene the playhead is in mounts before its neighbours. + const toMount = windowScenes + .filter(({ name }) => near.has(name) && parked(name)) + .sort((a, b) => Math.abs(a.start - time) - Math.abs(b.start - time)); + const toRetire = windowScenes.filter(({ name }) => !near.has(name) && !parked(name)); + windowNear = key; + if (toMount.length === 0 && toRetire.length === 0) return landedParked; + windowWork = (async () => { + for (const { name } of toMount) await mountScene(name); + for (const { name } of toRetire) retireScene(name); + })().finally(() => { + windowWork = null; + windowNear = ""; + moveSceneWindow(state.currentTime); + }); + // A seek is not done until the scenes it shows are mounted. + registerSeekCompletion(windowWork); + return landedParked; + }; // Only a preview served with a scene manifest can swap; elsewhere the caller reloads directly. if (readSceneParts(document)) { window.__hfSwapScenes = swapScenes; diff --git a/packages/core/src/runtime/sceneWindow.ts b/packages/core/src/runtime/sceneWindow.ts new file mode 100644 index 00000000000..80c8209e4a8 --- /dev/null +++ b/packages/core/src/runtime/sceneWindow.ts @@ -0,0 +1,128 @@ +import { SCENE_NO_SWAP_ATTR, SCENE_PART_ATTR, SCENE_PARTS_META } from "../sceneParts"; +import { STUDIO_PREVIEW_PARKED_SCENE_ATTR as PARKED } from "../studioPreviewMark"; +import { nearPlayhead } from "./farScenes"; +import type { ParkedTimelineScene } from "./timeline"; +import type { RuntimeTimelineClip } from "./types"; + +/** A scene the preview may unmount: its host's own attributes time it, and its parts can be run again. */ +export type WindowedScene = { name: string; start: number; end: number }; + +// Each windowed scene as the page first had it, before any script ran: what a mount puts back. +const pristine = new Map(); + +const partsSelector = (name: string, tag = "") => + `${tag}[${SCENE_PART_ATTR}="${CSS.escape(name)}"]`; +const ownNumber = (el: Element, attr: string) => + Number(el.getAttribute(attr)?.trim() || Number.NaN); + +function windowedHosts(): Array { + if (!document.querySelector(`meta[name="${SCENE_PARTS_META}"]`)) return []; + return Array.from(document.querySelectorAll(`[${SCENE_PART_ATTR}]:not(style, script)`)) + .flatMap((host) => { + const start = ownNumber(host, "data-start"); + const duration = ownNumber(host, "data-duration"); + const film = host.parentElement; + const windowed = + Number.isFinite(start) && + duration > 0 && + !host.hasAttribute(SCENE_NO_SWAP_ATTR) && + !host.hasAttribute("data-hf-original-composition-id") && + film?.hasAttribute("data-composition-id") === true && + film.closest(`[${SCENE_PART_ATTR}]`) === null; + const name = host.getAttribute(SCENE_PART_ATTR)!; + return windowed ? [{ name, host, start, end: start + duration }] : []; + }) + .sort((a, b) => a.start - b.start); +} + +/** The scenes boot kept for remounting, in start order; mounted or parked, a host keeps its timing. */ +export const windowedScenes = (): WindowedScene[] => + windowedHosts() + .filter(({ name }) => pristine.has(name)) + .map(({ name, start, end }) => ({ name, start, end })); + +/** The names of the scenes to keep mounted at `time`: the playing scene and a neighbour each side. */ +export function scenesNear(scenes: WindowedScene[], time: number): Set { + const near = nearPlayhead(scenes, time); + return new Set(scenes.filter((_, i) => near[i]).map((s) => s.name)); +} + +export const hostOf = (name: string): Element | null => + document.querySelector(`${partsSelector(name)}:not(style, script)`); + +export const isParked = (host: Element): boolean => host.hasAttribute(PARKED); + +/** Swaps a scene's host for an empty copy that keeps its timing, and takes its scripts out of the page. */ +export function park(name: string): Element | null { + const host = hostOf(name); + if (!host || isParked(host)) return host; + for (const script of document.querySelectorAll(partsSelector(name, "script"))) script.remove(); + const placeholder = host.cloneNode(false) as Element; + placeholder.setAttribute(PARKED, ""); + host.replaceWith(placeholder); + return placeholder; +} + +/** Puts a parked scene back as the page first had it, with what the runtime wrote on its host; returns its scripts. */ +export function unpark(name: string): { host: Element; scripts: Element[] } | null { + const placeholder = hostOf(name); + const parts = pristine.get(name); + if (!placeholder || !isParked(placeholder) || !parts) return null; + const template = document.createElement("template"); + template.innerHTML = parts.html; + const host = document.importNode(template.content.firstElementChild!, true); + for (const { name: attr, value } of Array.from(placeholder.attributes)) + if (attr !== PARKED) host.setAttribute(attr, value); + placeholder.replaceWith(host); + return { host, scripts: parts.scripts.map((el) => el.cloneNode(true) as Element) }; +} + +/** + * Before any scene script runs: keeps each windowed scene as written and parks those away from the start, + * so their scripts never run until the playhead comes near. A page without a scene manifest is left alone. + */ +export function parkFarScenesAtBoot(time = 0): void { + pristine.clear(); + parkedClips.clear(); + const hosts = windowedHosts(); + const near = scenesNear(hosts, time); + for (const { name, host } of hosts) { + const scripts = Array.from(document.querySelectorAll(partsSelector(name, "script"))); + pristine.set(name, { + html: host.outerHTML, + scripts: scripts.map((el) => el.cloneNode(true) as Element), + }); + if (!near.has(name)) park(name); + } +} + +/** Forgets what a swap replaced: the swapped scene's new markup is what a later mount puts back. */ +export function rememberScene(name: string, host: Element, scripts: Element[]): void { + if (!pristine.has(name)) return; + parkedClips.delete(name); + pristine.set(name, { + html: host.outerHTML, + scripts: scripts.map((el) => el.cloneNode(true) as Element), + }); +} + +/** The timeline's clips of each parked scene, built from its markup once and dropped when an edit changes it. */ +export const parkedClips = new Map(); + +/** The parked scenes, each able to build its markup detached under a copy of the film root for the timeline. */ +export function parkedTimelineScenes(): ParkedTimelineScene[] { + return Array.from(document.querySelectorAll(`[${PARKED}]`)).flatMap((placeholder) => { + const name = placeholder.getAttribute(SCENE_PART_ATTR); + const parts = name === null ? undefined : pristine.get(name); + const film = placeholder.parentElement; + if (name === null || !parts || !film) return []; + const host = () => { + const detachedFilm = film.cloneNode(false) as Element; + const template = document.createElement("template"); + template.innerHTML = parts.html; + detachedFilm.append(template.content); + return detachedFilm.firstElementChild!; + }; + return [{ name, host }]; + }); +} diff --git a/packages/core/src/runtime/timeline.ts b/packages/core/src/runtime/timeline.ts index 21805116b71..8438a6c3174 100644 --- a/packages/core/src/runtime/timeline.ts +++ b/packages/core/src/runtime/timeline.ts @@ -259,8 +259,14 @@ function buildTimelineClipLabel(node: Element, kind: RuntimeTimelineClip["kind"] return `${humanizeTimelineToken(kind)} ${ordinal + 1}`; } +/** A scene the preview unmounted: `host` builds its markup detached, under a copy of the film root. */ +export type ParkedTimelineScene = { name: string; host: () => Element }; + export function collectRuntimeTimelinePayload(params: { canonicalFps: number; + parked?: ParkedTimelineScene[]; + /** Clips of parked scenes by scene, kept across calls; a parked scene's markup only changes by an edit. */ + parkedClips?: Map; }): RuntimeTimelineMessage { const stackingByParent = new Map(); const runtimeWindow = window as Window & { @@ -425,16 +431,13 @@ export function collectRuntimeTimelinePayload(params: { // as clips — they have no declared timing so the timeline can't show their // actual visibility window. They can still appear as scenes via the separate // scene collection below. - const nodes = Array.from( - document.querySelectorAll( - "[data-start], [data-track-index], [data-composition-id], video, audio, img", - ), - ); + const CLIP_NODES = "[data-start], [data-track-index], [data-composition-id], video, audio, img"; + const nodes = Array.from(document.querySelectorAll(CLIP_NODES)); let maxEnd = 0; - for (const [i, node] of nodes.entries()) { - if (node === root) continue; + const clipOf = (node: Element, i: number): RuntimeTimelineClip | null => { + if (node === root) return null; if (["SCRIPT", "STYLE", "LINK", "META", "TEMPLATE", "NOSCRIPT"].includes(node.tagName)) - continue; + return null; const compositionContext = resolveNearestCompositionContext(node, root); const tag = node.tagName.toLowerCase(); let start = @@ -461,19 +464,17 @@ export function collectRuntimeTimelinePayload(params: { duration = Math.max(0, inheritedEnd - start); } } - if (duration == null || duration <= 0) continue; + if (duration == null || duration <= 0) return null; duration = clampDurationToRootWindow(start, duration); - if (duration <= 0) continue; + if (duration <= 0) return null; const slots = startResolver.resolveHostSlotsForElement(node); if (slots.length > 0) { const played = cutToHostSlots({ start, end: start + duration }, slots); const keepZeroLengthSoStudioNestsChildren = isNestedHost; - if (played.end <= played.start && !keepZeroLengthSoStudioNestsChildren) continue; + if (played.end <= played.start && !keepZeroLengthSoStudioNestsChildren) return null; start = played.start; duration = played.end - played.start; } - const end = start + duration; - maxEnd = Math.max(maxEnd, end); const kind: RuntimeTimelineClip["kind"] = nodeCompositionId && nodeCompositionId !== rootCompositionId ? "composition" @@ -484,7 +485,7 @@ export function collectRuntimeTimelinePayload(params: { : tag === "img" ? "image" : "element"; - clips.push({ + return { id: stableClipId(node) ?? nodeCompositionId ?? null, label: buildTimelineClipLabel(node, kind, clips.length), start, @@ -506,7 +507,26 @@ export function collectRuntimeTimelinePayload(params: { timelineLabel: node.getAttribute("data-timeline-label"), timelineGroup: node.getAttribute("data-timeline-group"), timelinePriority: parseNum(node.getAttribute("data-timeline-priority")), - }); + }; + }; + const addClip = (clip: RuntimeTimelineClip) => { + clips.push(clip); + maxEnd = Math.max(maxEnd, clip.start + clip.duration); + }; + for (const [i, node] of nodes.entries()) { + const clip = clipOf(node, i); + if (clip) addClip(clip); + } + for (const scene of params.parked ?? []) { + let parkedClips = params.parkedClips?.get(scene.name); + if (!parkedClips) { + const offset = nodes.length; + parkedClips = Array.from(scene.host().querySelectorAll(CLIP_NODES)).flatMap( + (node, j) => clipOf(node, offset + j) ?? [], + ); + params.parkedClips?.set(scene.name, parkedClips); + } + for (const clip of parkedClips) addClip(clip); } // ── GSAP introspection ────────────────────────────────────────────────── // Discover elements animated by GSAP that weren't picked up by the DOM query diff --git a/packages/core/src/studioPreviewMark.ts b/packages/core/src/studioPreviewMark.ts index a79a2c094dc..cab5a98019d 100644 --- a/packages/core/src/studioPreviewMark.ts +++ b/packages/core/src/studioPreviewMark.ts @@ -6,9 +6,12 @@ export const STUDIO_PREVIEW_ERRORS = "__hfPreviewErrors"; export const PREVIEW_RASTER_ATTR = "data-hf-preview-raster"; export const RUNTIME_STAMPED_ATTR = "data-hf-autostamped"; export const STUDIO_PREVIEW_FAR_SCENE_ATTR = "data-hf-far-scene"; +/** On an empty stand-in for a scene the preview unmounted; the scene comes back when the playhead nears it. */ +export const STUDIO_PREVIEW_PARKED_SCENE_ATTR = "data-hf-parked-scene"; export const PREVIEW_ONLY_ATTRS = [ STUDIO_PREVIEW_UPCOMING_ATTR, STUDIO_PREVIEW_FAR_SCENE_ATTR, + STUDIO_PREVIEW_PARKED_SCENE_ATTR, STUDIO_PREVIEW_LAZY_ATTR, PREVIEW_RASTER_ATTR, RUNTIME_STAMPED_ATTR, From 5b2021c651289ab1045c26491d40b172f9968fa5 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 24 Oct 2026 04:43:53 -0400 Subject: [PATCH 07/40] perf(core): a seek waits only for the scene it lands in; neighbours mount one per turn --- .../core/src/runtime/init.swapScenes.test.ts | 19 ++++++++-- packages/core/src/runtime/init.ts | 38 ++++++++++++------- 2 files changed, 39 insertions(+), 18 deletions(-) diff --git a/packages/core/src/runtime/init.swapScenes.test.ts b/packages/core/src/runtime/init.swapScenes.test.ts index 330bfe8ea94..191e1460f76 100644 --- a/packages/core/src/runtime/init.swapScenes.test.ts +++ b/packages/core/src/runtime/init.swapScenes.test.ts @@ -1787,21 +1787,32 @@ describe("the preview's scene window", () => { expect(sceneHost("s0").textContent).toBe("zero"); }); - it("mounts the scenes a seek reaches before the seek counts as done, and parks the far ones", async () => { + it("mounts the scene a seek lands in before the seek counts as done, then parks the far ones", async () => { const { root, children } = trackingRoot(); bootWindowed([S0, S1, S2], root); await tick(); - await seekAndSettle(5); - expect(parked()).toEqual(["s0"]); + // Fonts that settle a moment later keep the mount pending past the seek's own work. + const fonts = { ready: new Promise((settle) => window.setTimeout(settle, 50)) }; + Object.defineProperty(document, "fonts", { configurable: true, value: fonts }); + window.__player?.seek(5); + let settled = false; + const done = window.__hfWaitForSeekCompletion!().then(() => (settled = true)); + await tick(); + expect([settled, window.__timelines?.s2]).toEqual([false, undefined]); + await done; + Reflect.deleteProperty(document, "fonts"); expect(sceneHost("s2").textContent).toBe("two"); + for (let i = 0; i < 5; i++) await tick(); + expect(parked()).toEqual(["s0"]); expect(window.__timelines?.s2).toBe(made.a2); expect(children.map((c) => c.child)).toContain(made.a2); expect(made.a1!.kill).toHaveBeenCalled(); expect(children.map((c) => c.child)).not.toContain(made.a1); expect(window.__player?.getTime()).toBe(5); await seekAndSettle(0.5); - expect(parked()).toEqual(["s2"]); expect(sceneHost("s0").textContent).toBe("zero"); + for (let i = 0; i < 5; i++) await tick(); + expect(parked()).toEqual(["s2"]); }); it("keeps drawing the scene it showed while the scene a seek lands in mounts", async () => { diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index 09db23ff7df..7722fcf5529 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -4248,31 +4248,41 @@ export function initSandboxRuntimeModular(): void { moveSceneWindow = (time: number): boolean => { if (windowScenes.length === 0 || state.tornDown) return false; const parked = (name: string) => parkedNames.has(name); - const landedParked = windowScenes.some( + const landed = windowScenes.find( ({ name, start, end }) => start <= time && time <= end && parked(name), ); - if (windowWork) return landedParked; + if (windowWork) return landed !== undefined; const near = scenesNear(windowScenes, time); const key = [...near].join("\n"); - if (key === windowNear) return landedParked; - // Nearest first, so the scene the playhead is in mounts before its neighbours. - const toMount = windowScenes + if (key === windowNear) return landed !== undefined; + // One step at a time, planned again after each, so a later seek or scrub overtakes the rest. + const neighbour = windowScenes .filter(({ name }) => near.has(name) && parked(name)) - .sort((a, b) => Math.abs(a.start - time) - Math.abs(b.start - time)); - const toRetire = windowScenes.filter(({ name }) => !near.has(name) && !parked(name)); - windowNear = key; - if (toMount.length === 0 && toRetire.length === 0) return landedParked; + .sort((a, b) => Math.abs(a.start - time) - Math.abs(b.start - time))[0]; + const toMount = landed ?? neighbour; + const toRetire = toMount + ? [] + : windowScenes.filter(({ name }) => !near.has(name) && !parked(name)); + if (!toMount && toRetire.length === 0) { + windowNear = key; + return false; + } windowWork = (async () => { - for (const { name } of toMount) await mountScene(name); - for (const { name } of toRetire) retireScene(name); + if (landed) await mountScene(landed.name); + else if (toMount) { + // A neighbour waits a turn: a seek queued meanwhile may want another scene first. + await new Promise((resolve) => window.setTimeout(resolve, 0)); + if (scenesNear(windowScenes, state.currentTime).has(toMount.name)) + await mountScene(toMount.name); + } else for (const { name } of toRetire) retireScene(name); })().finally(() => { windowWork = null; windowNear = ""; moveSceneWindow(state.currentTime); }); - // A seek is not done until the scenes it shows are mounted. - registerSeekCompletion(windowWork); - return landedParked; + // A seek is not done until the scene it lands in is mounted. + if (landed) registerSeekCompletion(windowWork); + return landed !== undefined; }; // Only a preview served with a scene manifest can swap; elsewhere the caller reloads directly. if (readSceneParts(document)) { From 5a1ad71393fe3b9e2db2a3e1429051158273e417 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 24 Oct 2026 04:51:00 -0400 Subject: [PATCH 08/40] feat(studio): selecting a clip in an unmounted scene mounts that scene first --- .../core/src/runtime/init.swapScenes.test.ts | 13 +++++++++ packages/core/src/runtime/init.ts | 21 ++++++++++++-- packages/core/src/runtime/window.d.ts | 2 ++ packages/studio/src/hooks/useDomSelection.ts | 4 +++ packages/studio/src/utils/sceneHold.test.ts | 29 +++++++++++++++++++ packages/studio/src/utils/sceneHold.ts | 20 +++++++++++++ 6 files changed, 87 insertions(+), 2 deletions(-) create mode 100644 packages/studio/src/utils/sceneHold.test.ts create mode 100644 packages/studio/src/utils/sceneHold.ts diff --git a/packages/core/src/runtime/init.swapScenes.test.ts b/packages/core/src/runtime/init.swapScenes.test.ts index 191e1460f76..aea0527158b 100644 --- a/packages/core/src/runtime/init.swapScenes.test.ts +++ b/packages/core/src/runtime/init.swapScenes.test.ts @@ -1825,6 +1825,19 @@ describe("the preview's scene window", () => { expect(sceneHost("s2").getAttribute("style")).toContain("visibility: visible"); }); + it("mounts a scene Studio holds for editing and keeps it until Studio lets go", async () => { + bootWindowed([S0, S1, S2], trackingRoot().root); + await tick(); + await window.__hfHoldScenes!(["main", "s2"]); + expect(sceneHost("s2").textContent).toBe("two"); + await seekAndSettle(0.5); + for (let i = 0; i < 5; i++) await tick(); + expect(parked()).toEqual([]); + await window.__hfHoldScenes!([]); + for (let i = 0; i < 5; i++) await tick(); + expect(parked()).toEqual(["s2"]); + }); + it("keeps a parked scene's clips on the timeline, at the time its scene puts them", async () => { bootWindowed([S0, S1, S2], trackingRoot().root); await tick(); diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index 7722fcf5529..0661d225759 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -4245,6 +4245,8 @@ export function initSandboxRuntimeModular(): void { bindRootTimelineIfAvailable(); postTimeline(); }; + // Scenes Studio holds mounted for selecting or editing, whatever the playhead does. + let heldScenes = new Set(); moveSceneWindow = (time: number): boolean => { if (windowScenes.length === 0 || state.tornDown) return false; const parked = (name: string) => parkedNames.has(name); @@ -4253,6 +4255,7 @@ export function initSandboxRuntimeModular(): void { ); if (windowWork) return landed !== undefined; const near = scenesNear(windowScenes, time); + for (const name of heldScenes) near.add(name); const key = [...near].join("\n"); if (key === windowNear) return landed !== undefined; // One step at a time, planned again after each, so a later seek or scrub overtakes the rest. @@ -4267,13 +4270,14 @@ export function initSandboxRuntimeModular(): void { windowNear = key; return false; } + const wantedNow = (name: string) => + heldScenes.has(name) || scenesNear(windowScenes, state.currentTime).has(name); windowWork = (async () => { if (landed) await mountScene(landed.name); else if (toMount) { // A neighbour waits a turn: a seek queued meanwhile may want another scene first. await new Promise((resolve) => window.setTimeout(resolve, 0)); - if (scenesNear(windowScenes, state.currentTime).has(toMount.name)) - await mountScene(toMount.name); + if (wantedNow(toMount.name)) await mountScene(toMount.name); } else for (const { name } of toRetire) retireScene(name); })().finally(() => { windowWork = null; @@ -4284,11 +4288,24 @@ export function initSandboxRuntimeModular(): void { if (landed) registerSeekCompletion(windowWork); return landed !== undefined; }; + // Mounts the named scenes and keeps them mounted until the next call names others. + const holdScenes = async (names: string[]): Promise => { + heldScenes = new Set(names.filter((name) => windowScenes.some((s) => s.name === name))); + windowNear = ""; + // Bounded: a scene that cannot mount must not keep this waiting. + for (let turn = 0; turn <= windowScenes.length && !state.tornDown; turn++) { + moveSceneWindow(state.currentTime); + if (![...heldScenes].some((name) => parkedNames.has(name))) return; + await (windowWork ?? Promise.resolve()); + } + }; // Only a preview served with a scene manifest can swap; elsewhere the caller reloads directly. if (readSceneParts(document)) { window.__hfSwapScenes = swapScenes; + window.__hfHoldScenes = holdScenes; registerRuntimeCleanup(() => { delete window.__hfSwapScenes; + delete window.__hfHoldScenes; }); } diff --git a/packages/core/src/runtime/window.d.ts b/packages/core/src/runtime/window.d.ts index c086ab80e8f..21757051c13 100644 --- a/packages/core/src/runtime/window.d.ts +++ b/packages/core/src/runtime/window.d.ts @@ -99,6 +99,8 @@ declare global { __hfSceneAnimations?: Record | null; /** Swap edited scenes from a rebuilt preview document; refuses before changing anything when it cannot. */ __hfSwapScenes?: (html: string, signal?: AbortSignal) => Promise; + /** Preview: mounts the named scenes and keeps them mounted until the next call names others. */ + __hfHoldScenes?: (names: string[]) => Promise; __HF_EXPORT_RENDER_SEEK_CONFIG?: { mode?: string; diagnostics?: boolean; diff --git a/packages/studio/src/hooks/useDomSelection.ts b/packages/studio/src/hooks/useDomSelection.ts index 856dd0dfa2e..4fad4631c96 100644 --- a/packages/studio/src/hooks/useDomSelection.ts +++ b/packages/studio/src/hooks/useDomSelection.ts @@ -19,6 +19,7 @@ import { type DomEditSelection, } from "../components/editor/domEditing"; import { reapplyPositionEditsAfterSeek } from "../components/editor/manualEdits"; +import { holdScenesOf } from "../utils/sceneHold"; import { useStudioTestHooks } from "./useStudioTestHooks"; import { logSelect } from "../utils/selectDebug"; import { announceTimelineSelection as announceSelectionToTimeline } from "./domSelectionTimelineMirror"; @@ -308,6 +309,8 @@ export function useDomSelection({ } if (!doc) return null; + // A long film's preview mounts only the scenes near the playhead: have the element's scene mounted first. + await holdScenesOf(iframe, element); reapplyPositionEditsAfterSeek(doc); const targetElement = findElementForTimelineElement(doc, element, { @@ -328,6 +331,7 @@ export function useDomSelection({ async (element: TimelineElement | null) => { const seq = ++timelineSelectSeqRef.current; if (!element) { + void holdScenesOf(previewIframeRef.current, null); applyDomSelection(null, { revealPanel: false }); return; } diff --git a/packages/studio/src/utils/sceneHold.test.ts b/packages/studio/src/utils/sceneHold.test.ts new file mode 100644 index 00000000000..1b2bbbc0022 --- /dev/null +++ b/packages/studio/src/utils/sceneHold.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it, vi } from "vitest"; +import type { TimelineElement } from "../player"; +import { holdScenesOf } from "./sceneHold"; + +const previewWith = (hold?: (names: string[]) => Promise) => + ({ contentWindow: { __hfHoldScenes: hold } }) as unknown as HTMLIFrameElement; + +describe("holdScenesOf", () => { + it("has the preview mount the scenes holding the element, and releases them for no element", async () => { + const hold = vi.fn(async () => {}); + const element = { + id: "word", + tag: "span", + start: 700, + duration: 1, + track: 0, + compositionAncestors: ["main", "s23"], + parentCompositionId: "s23", + } as TimelineElement; + await holdScenesOf(previewWith(hold), element); + await holdScenesOf(previewWith(hold), null); + expect(hold.mock.calls).toEqual([[["main", "s23", "s23"]], [[]]]); + }); + + it("does nothing in a preview that mounts every scene", async () => { + await expect(holdScenesOf(previewWith(), null)).resolves.toBeUndefined(); + await expect(holdScenesOf(null, null)).resolves.toBeUndefined(); + }); +}); diff --git a/packages/studio/src/utils/sceneHold.ts b/packages/studio/src/utils/sceneHold.ts new file mode 100644 index 00000000000..5e8f88e55b8 --- /dev/null +++ b/packages/studio/src/utils/sceneHold.ts @@ -0,0 +1,20 @@ +import type { TimelineElement } from "../player"; + +type SceneHoldingWindow = Window & { __hfHoldScenes?: (names: string[]) => Promise }; + +/** Mounts, in a preview that unmounts far scenes, the scenes holding a timeline element; nothing elsewhere. */ +export async function holdScenesOf( + iframe: HTMLIFrameElement | null, + element: TimelineElement | null, +): Promise { + const names = element + ? [...(element.compositionAncestors ?? []), element.parentCompositionId ?? ""] + : []; + try { + await (iframe?.contentWindow as SceneHoldingWindow | null)?.__hfHoldScenes?.( + names.filter(Boolean), + ); + } catch { + // A preview torn down meanwhile has nothing to mount. + } +} From ef14523877a57b151cee75bef4066495fde2fc39 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 24 Oct 2026 04:57:01 -0400 Subject: [PATCH 09/40] fix(core): a source a play and a seek both need is fetched and decoded once --- .../core/src/runtime/webAudioTransport.test.ts | 18 ++++++++++++++++++ packages/core/src/runtime/webAudioTransport.ts | 14 ++++++++++++-- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/packages/core/src/runtime/webAudioTransport.test.ts b/packages/core/src/runtime/webAudioTransport.test.ts index a01c3e842ca..5712dc28174 100644 --- a/packages/core/src/runtime/webAudioTransport.test.ts +++ b/packages/core/src/runtime/webAudioTransport.test.ts @@ -1634,5 +1634,23 @@ describe("WebAudioTransport", () => { expect(fetchMock).toHaveBeenCalledTimes(1); // short-circuited, no re-fetch vi.unstubAllGlobals(); }); + + it("fetches and decodes a source once when a play and a seek both ask before it decodes", async () => { + const decoded = {} as AudioBuffer; + const transport = transportWithDecode(async () => decoded); + const fetchMock = vi.fn(async () => ({ + ok: true, + arrayBuffer: async () => new ArrayBuffer(8), + })); + vi.stubGlobal("fetch", fetchMock); + + const both = await Promise.all([ + transport.decodeAudioElement(el("sfx.wav")), + transport.decodeAudioElement(el("sfx.wav")), + ]); + expect(both).toEqual([decoded, decoded]); + expect(fetchMock).toHaveBeenCalledTimes(1); + vi.unstubAllGlobals(); + }); }); }); diff --git a/packages/core/src/runtime/webAudioTransport.ts b/packages/core/src/runtime/webAudioTransport.ts index d95ed3134d1..40bd47fe06c 100644 --- a/packages/core/src/runtime/webAudioTransport.ts +++ b/packages/core/src/runtime/webAudioTransport.ts @@ -153,6 +153,7 @@ const IDLE_SUSPEND_MS = 10000; export class WebAudioTransport { private _ctx: AudioContext | null = null; private _bufferCache = new Map(); + private _decoding = new Map>(); private _failedSrcs = new Set(); private _mediaElementSources = new WeakMap(); private _activeSources: ScheduledSource[] = []; @@ -270,9 +271,18 @@ export class WebAudioTransport { } } - async decodeAudioElement(el: HTMLMediaElement): Promise { + /** One fetch and decode per source at a time: a play and a seek scheduling together share it. */ + decodeAudioElement(el: HTMLMediaElement): Promise { const src = decodeKey(el); - if (!src) return null; + if (!src) return Promise.resolve(null); + const pending = this._decoding.get(src); + if (pending) return pending; + const decoding = this.fetchAndDecode(src).finally(() => this._decoding.delete(src)); + this._decoding.set(src, decoding); + return decoding; + } + + private async fetchAndDecode(src: string): Promise { if (this._bufferCache.has(src)) return this._bufferCache.get(src)!; if (this._failedSrcs.has(src)) return null; if (!this._ctx) return null; From f15fa8c94a3ae00ca51015dd4940ff064bab1cc8 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 24 Oct 2026 05:09:34 -0400 Subject: [PATCH 10/40] fix(core): a scene mounting during play joins the mix without restarting what plays --- .../core/src/runtime/init.swapScenes.test.ts | 44 +++++++++++++++++++ packages/core/src/runtime/init.ts | 19 +++++++- 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/packages/core/src/runtime/init.swapScenes.test.ts b/packages/core/src/runtime/init.swapScenes.test.ts index aea0527158b..6de45ae08cd 100644 --- a/packages/core/src/runtime/init.swapScenes.test.ts +++ b/packages/core/src/runtime/init.swapScenes.test.ts @@ -1857,6 +1857,50 @@ describe("the preview's scene window", () => { expect(sceneHost("s2").textContent).toBe("two, edited"); }); + const audio = (src: string) => ``; + + it("lets a scene mounting ahead of a playing film join its mix, leaving what already plays alone", async () => { + vi.spyOn(WebAudioTransport.prototype, "init").mockResolvedValue(true); + const schedule = vi + .spyOn(WebAudioTransport.prototype, "scheduleMediaElementPlayback") + .mockResolvedValue(null); + vi.spyOn(WebAudioTransport.prototype, "decodeAudioElement").mockResolvedValue(null); + const stop = vi.spyOn(WebAudioTransport.prototype, "stopAll"); + quietMedia(); + vi.spyOn(HTMLMediaElement.prototype, "play").mockResolvedValue(undefined); + bootWindowed( + [{ ...S0, body: audio("/zero.mp3") }, S1, { ...S2, body: audio("/two.mp3") }], + trackingRoot().root, + ); + window.requestAnimationFrame = (() => 1) as typeof window.requestAnimationFrame; + await tick(); + window.__player?.play(); + // Into the middle scene: the last one mounts a turn later as its neighbour. + window.__player?.seek(2.5, { keepPlaying: true }); + await window.__hfWaitForSeekCompletion?.(); + for (const spy of [schedule, stop]) spy.mockClear(); + for (let i = 0; i < 5; i++) await tick(); + expect(parked()).toEqual([]); + expect(stop).not.toHaveBeenCalled(); + expect(schedule.mock.calls.map(([el]) => el.getAttribute("src"))).toEqual(["/two.mp3"]); + }); + + it("drops the decoded audio of a scene it parks", async () => { + const release = vi.spyOn(WebAudioTransport.prototype, "releaseDecodedExcept"); + quietMedia(); + bootWindowed( + [{ ...S0, body: audio("/zero.mp3") }, S1, { ...S2, body: audio("/two.mp3") }], + trackingRoot().root, + ); + await tick(); + release.mockClear(); + await seekAndSettle(5); + for (let i = 0; i < 5; i++) await tick(); + expect(parked()).toEqual(["s0"]); + const kept = release.mock.calls.at(-1)?.[0].map((el) => el.getAttribute("src")); + expect(kept).toEqual(["/two.mp3"]); + }); + it("mounts every scene of a page without a scene manifest, and every scene the swap gate refuses", async () => { bootWindowed([S0, S1, S2], trackingRoot().root, false); await tick(); diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index 2bece5d4ab2..5e06b6bfb9b 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -3966,11 +3966,20 @@ export function initSandboxRuntimeModular(): void { delete sceneAnimations()[id]; } }; + // A scene mounting under a playing film joins it: what already plays is neither stopped nor seeked. + const joinPlayback = (hosts: Element[]) => { + const pageAnimations = seekTimelineAndAdapters(state.currentTime); + runAdapters("pause", 0, pageAnimations); + runAdapters("play"); + if (webAudioReady && !state.nativeMediaSyncDisabled && !state.webAudioMediaDisabled) + scheduleWebAudioFor(hosts.flatMap(webAudioMediaIn), webAudio.currentGeneration()); + }; // What a load does after scene markup changed: bindings, media, captions, the root timeline, then a redraw. const settleChangedScenes = ( swappedHosts: Element[], captionHosts: Element[], captionOverrides: Awaited>, + joining = false, ) => { // Boot's order: bindings settle each src before media binding proxies and loads it. for (const host of swappedHosts) applyVariableBindings(document, host); @@ -4006,7 +4015,8 @@ export function initSandboxRuntimeModular(): void { applyPositionEdits(document); // Redraw the current frame as a seek does; a playing film cut to end at or before it stops there, as at its end. const cutShort = clock.isPlaying() && duration > 0 && state.currentTime >= duration; - transport.seek(cutShort ? duration : state.currentTime, { keepPlaying: !cutShort }); + if (joining && clock.isPlaying() && !cutShort) joinPlayback(swappedHosts); + else transport.seek(cutShort ? duration : state.currentTime, { keepPlaying: !cutShort }); syncTimedElementVisibility(state.currentTime); postTimeline(); }; @@ -4232,7 +4242,7 @@ export function initSandboxRuntimeModular(): void { const captionHosts = host.querySelector(".caption-group") ? [host] : []; const captionOverrides = captionHosts.length > 0 ? await fetchCaptionOverrides() : []; await runScriptsAfterFonts(scripts.map((el) => document.body.appendChild(el))); - if (!state.tornDown) settleChangedScenes([host], captionHosts, captionOverrides); + if (!state.tornDown) settleChangedScenes([host], captionHosts, captionOverrides, true); }; const retireScene = (name: string) => { const host = sceneHostOf(name); @@ -4241,6 +4251,7 @@ export function initSandboxRuntimeModular(): void { parkScene(name); parkedNames.add(name); releaseDetachedMedia(); + webAudio.releaseDecodedExcept(webAudioMediaIn(document)); childrenBound = false; bindRootTimelineIfAvailable(); postTimeline(); @@ -5526,6 +5537,10 @@ export function initSandboxRuntimeModular(): void { const gen = webAudio.startGeneration(); const clips = webAudioMediaIn(document); webAudio.releaseDecodedExcept(clips); + scheduleWebAudioFor(clips, gen); + }; + // Within one play generation, so a clip added mid-play joins the mix without restarting it. + const scheduleWebAudioFor = (clips: HTMLMediaElement[], gen: number) => { for (const rawEl of clips) { if (isSilencedByHidden(rawEl)) continue; const compStart = resolveAbsoluteMediaStartSeconds(rawEl); From 8db1b12aaf5ae7da4f64054710c7eb611b38183c Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 24 Oct 2026 05:36:51 -0400 Subject: [PATCH 11/40] fix(studio): selecting a clip waits on a scene mount only in a preview that unmounts scenes --- packages/studio/src/hooks/useDomSelection.ts | 6 ++--- packages/studio/src/utils/sceneHold.test.ts | 6 ++--- packages/studio/src/utils/sceneHold.ts | 25 ++++++++++++-------- 3 files changed, 21 insertions(+), 16 deletions(-) diff --git a/packages/studio/src/hooks/useDomSelection.ts b/packages/studio/src/hooks/useDomSelection.ts index 4fad4631c96..cddf0b01100 100644 --- a/packages/studio/src/hooks/useDomSelection.ts +++ b/packages/studio/src/hooks/useDomSelection.ts @@ -309,8 +309,8 @@ export function useDomSelection({ } if (!doc) return null; - // A long film's preview mounts only the scenes near the playhead: have the element's scene mounted first. - await holdScenesOf(iframe, element); + const held = holdScenesOf(iframe, element); + if (held) await held; reapplyPositionEditsAfterSeek(doc); const targetElement = findElementForTimelineElement(doc, element, { @@ -348,7 +348,7 @@ export function useDomSelection({ // and Delete acts on the canvas first — so it removed that, not the clip. applyDomSelection(null, { revealPanel: false, announce: false }); }, - [applyDomSelection, buildDomSelectionForTimelineElement], + [applyDomSelection, buildDomSelectionForTimelineElement, previewIframeRef], ); // Forward handle to the group refresher defined below: the single-selection diff --git a/packages/studio/src/utils/sceneHold.test.ts b/packages/studio/src/utils/sceneHold.test.ts index 1b2bbbc0022..1fea9a47632 100644 --- a/packages/studio/src/utils/sceneHold.test.ts +++ b/packages/studio/src/utils/sceneHold.test.ts @@ -22,8 +22,8 @@ describe("holdScenesOf", () => { expect(hold.mock.calls).toEqual([[["main", "s23", "s23"]], [[]]]); }); - it("does nothing in a preview that mounts every scene", async () => { - await expect(holdScenesOf(previewWith(), null)).resolves.toBeUndefined(); - await expect(holdScenesOf(null, null)).resolves.toBeUndefined(); + it("gives nothing to wait for in a preview that mounts every scene", () => { + expect(holdScenesOf(previewWith(), null)).toBeUndefined(); + expect(holdScenesOf(null, null)).toBeUndefined(); }); }); diff --git a/packages/studio/src/utils/sceneHold.ts b/packages/studio/src/utils/sceneHold.ts index 5e8f88e55b8..ba5c0e54dc0 100644 --- a/packages/studio/src/utils/sceneHold.ts +++ b/packages/studio/src/utils/sceneHold.ts @@ -2,19 +2,24 @@ import type { TimelineElement } from "../player"; type SceneHoldingWindow = Window & { __hfHoldScenes?: (names: string[]) => Promise }; -/** Mounts, in a preview that unmounts far scenes, the scenes holding a timeline element; nothing elsewhere. */ -export async function holdScenesOf( +/** + * Mounts, in a preview that unmounts far scenes, the scenes holding a timeline element. + * Elsewhere there is nothing to wait for, so it returns nothing and a caller goes on in the same turn. + */ +export function holdScenesOf( iframe: HTMLIFrameElement | null, element: TimelineElement | null, -): Promise { - const names = element - ? [...(element.compositionAncestors ?? []), element.parentCompositionId ?? ""] - : []; +): Promise | undefined { + let hold: SceneHoldingWindow["__hfHoldScenes"]; try { - await (iframe?.contentWindow as SceneHoldingWindow | null)?.__hfHoldScenes?.( - names.filter(Boolean), - ); + hold = (iframe?.contentWindow as SceneHoldingWindow | null)?.__hfHoldScenes; } catch { - // A preview torn down meanwhile has nothing to mount. + return undefined; // A cross-origin preview has no scenes to mount. } + if (!hold) return undefined; + const names = element + ? [...(element.compositionAncestors ?? []), element.parentCompositionId ?? ""] + : []; + // A preview torn down meanwhile has nothing to mount. + return hold(names.filter(Boolean)).catch(() => {}); } From b79cf697ab98be46578769feae500c848feb51f5 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 24 Oct 2026 05:36:55 -0400 Subject: [PATCH 12/40] fix(core): far scenes still mount after an edit, and an edit to a parked scene keeps its styles --- .../src/compiler/inlineSubCompositions.ts | 4 -- .../core/src/runtime/init.swapScenes.test.ts | 53 ++++++++++++++++++- packages/core/src/runtime/init.ts | 39 +++++++++----- packages/core/src/runtime/sceneWindow.ts | 37 ++++++++----- packages/core/src/runtime/timeline.ts | 4 +- packages/core/src/runtime/window.d.ts | 1 - packages/core/src/studioPreviewMark.ts | 1 - 7 files changed, 102 insertions(+), 37 deletions(-) diff --git a/packages/core/src/compiler/inlineSubCompositions.ts b/packages/core/src/compiler/inlineSubCompositions.ts index 8dfb08fb721..ae60f1328cd 100644 --- a/packages/core/src/compiler/inlineSubCompositions.ts +++ b/packages/core/src/compiler/inlineSubCompositions.ts @@ -58,21 +58,18 @@ const SWAP_SAFE_LIBRARY_URL = const isSwapSafeLibrary = (src: string) => URL.canParse(src) && SWAP_SAFE_LIBRARY_URL.test(new URL(src).href); -// Waiting for web fonts settles once, so a script re-run after a swap is not left with anything pending. const FONT_READY_WAIT_RE = /\bdocument\s*\.\s*fonts\s*\.\s*ready\s*\.\s*then\s*\(|\bawait\s+document\s*\.\s*fonts\s*\.\s*ready\b/g; /** Why an authored scene script cannot be swapped out cleanly, or null when it can. */ function sceneScriptSwapRefusal(script: string): string | null { const withoutFontWaits = script.replace(FONT_READY_WAIT_RE, ""); - // An async function whose only await is the font wait pends no more than the wait itself. const awaitedFontsOnly = /\bawait\b/.test(script) && !/\bawait\b/.test(withoutFontWaits); const checked = awaitedFontsOnly ? withoutFontWaits.replace(/\basync\b/g, "") : withoutFontWaits; const match = SIDE_EFFECT_RE.exec(checked); return match ? `its script uses ${match[0].trim()}` : null; } -// A project file is held to the inline rules; one that cannot be read is not known. const projectScriptRefusal = (text: string | null) => text === null ? "it runs a script that is not a known library" : sceneScriptSwapRefusal(text); @@ -251,7 +248,6 @@ export interface InlineSubCompositionsOptions { onMissingComposition?: (srcPath: string, reason?: string) => void; /** Tag each top-level host with `data-hf-scene` and report every part's scene, for preview swaps. */ tagScenes?: boolean; - /** With `tagScenes`: the text of a project script a scene loads, by its rebased path, or null. */ readProjectScript?: (path: string) => string | null; } diff --git a/packages/core/src/runtime/init.swapScenes.test.ts b/packages/core/src/runtime/init.swapScenes.test.ts index 6de45ae08cd..34b6c28ebbf 100644 --- a/packages/core/src/runtime/init.swapScenes.test.ts +++ b/packages/core/src/runtime/init.swapScenes.test.ts @@ -1850,13 +1850,64 @@ describe("the preview's scene window", () => { it("gives an edit to a parked scene to its next mount, without mounting it", async () => { bootWindowed([S0, S1, S2], trackingRoot().root); await tick(); - const edited: Scene = { ...S2, body: "

two, edited

", hash: "h2b" }; + const edited: Scene = { ...S2, body: "

two, edited

", css: ".two{}", hash: "h2b" }; await window.__hfSwapScenes!(preview([S0, S1, edited]).html); expect(parked()).toEqual(["s2"]); + expect(cssText()).toContain(".two{}"); await seekAndSettle(5); expect(sceneHost("s2").textContent).toBe("two, edited"); }); + it("still mounts parked scenes after an edit to a mounted one", async () => { + bootWindowed([S0, S1, S2], trackingRoot().root); + await tick(); + await window.__hfSwapScenes!( + preview([{ ...S0, body: "

zero, edited

", hash: "h0b" }, S1, S2]).html, + ); + await seekAndSettle(5); + expect(sceneHost("s2").textContent).toBe("two"); + }); + + it("refuses an edit that would leave a parked scene unable to mount or to show its clips", async () => { + bootWindowed([S0, S1, S2], trackingRoot().root); + await tick(); + const fetching: Scene = { + ...S2, + extraAttrs: ' data-hf-scene-no-swap="its script uses fetch"', + hash: "h2b", + }; + const untrimmed: Scene = { + ...S2, + body: '', + hash: "h2c", + }; + for (const edit of [fetching, untrimmed]) + await expect(window.__hfSwapScenes!(preview([S0, S1, edit]).html)).rejects.toThrow("s2"); + await seekAndSettle(5); + expect(sceneHost("s2").textContent).toBe("two"); + }); + + it("keeps mounted a scene whose clip lengths come from its media, from boot or from an edit", async () => { + quietMedia(); + const untrimmed = ''; + bootWindowed([S0, S1, { ...S2, body: untrimmed }], trackingRoot().root); + await tick(); + expect(parked()).toEqual([]); + window.__hfRuntimeTeardown?.(); + vi.restoreAllMocks(); + quietMedia(); + const trimmed = { + ...S0, + body: '', + }; + bootWindowed([trimmed, S1, S2], trackingRoot().root); + await tick(); + await window.__hfSwapScenes!(preview([{ ...S0, body: untrimmed, hash: "h0b" }, S1, S2]).html); + await seekAndSettle(5); + for (let i = 0; i < 5; i++) await tick(); + expect(parked()).toEqual([]); + }); + const audio = (src: string) => ``; it("lets a scene mounting ahead of a playing film join its mix, leaving what already plays alone", async () => { diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index 5e06b6bfb9b..2edcfcf5413 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -2,6 +2,7 @@ import { setPreviewRasterScale } from "./previewRasterHints"; import { createFarSceneSkip } from "./farScenes"; import { + clipLengthsWritten, hostOf as sceneHostOf, isParked, parkedClips, @@ -4057,17 +4058,32 @@ export function initSandboxRuntimeModular(): void { } const edited = names.filter((name) => nextParts.scenes[name] !== liveParts.scenes[name]); if (edited.length === 0) throw new Error("no scene changed"); - const nextPartsOf = (name: string) => - Array.from(next.querySelectorAll(`[${SCENE_PART_ATTR}="${CSS.escape(name)}"]`)); + const partsOf = (doc: Document, name: string) => + Array.from(doc.querySelectorAll(`[${SCENE_PART_ATTR}="${CSS.escape(name)}"]`)); // A parked scene runs nothing, so only the markup it mounts with changes. const parkedHost = (name: string) => { const host = sceneHostOf(name); return host !== null && isParked(host); }; - for (const name of edited.filter(parkedHost)) { - const parts = nextPartsOf(name); + const styles = (parts: Element[]) => parts.filter((el) => el.tagName === "STYLE"); + const parkedEdits = edited.filter(parkedHost).map((name) => { + const parts = partsOf(next, name); const host = parts.find((el) => el.tagName !== "STYLE" && el.tagName !== "SCRIPT"); if (!host) throw new Error(`scene ${name} cannot be swapped: it has no single host`); + // A mount runs the scene as a load would, so only what keeps it parkable is checked. + const refusal = host.getAttribute(SCENE_NO_SWAP_ATTR); + if (refusal !== null) throw new Error(`scene ${name} cannot be swapped: ${refusal}`); + if (!clipLengthsWritten(host)) + throw new Error(`scene ${name} cannot be swapped: a clip's length is not written`); + const oldStyles = styles(partsOf(document, name)); + if (oldStyles.length !== styles(parts).length) + throw new Error(`scene ${name} cannot be swapped: its styles moved`); + return { name, parts, host, oldStyles }; + }); + if (parkedEdits.length > 0) addNewFontFaces(next); + for (const { name, parts, host, oldStyles } of parkedEdits) { + const newStyles = styles(parts); + oldStyles.forEach((el, i) => el.replaceWith(document.importNode(newStyles[i]!, true))); rememberScene( name, host, @@ -4083,11 +4099,9 @@ export function initSandboxRuntimeModular(): void { return; } const swaps = changed.map((name) => { - const partsIn = (doc: Document) => - Array.from(doc.querySelectorAll(`[${SCENE_PART_ATTR}="${CSS.escape(name)}"]`)); const isHost = (el: Element) => el.tagName !== "STYLE" && el.tagName !== "SCRIPT"; - const oldParts = partsIn(document); - const newParts = partsIn(next); + const oldParts = partsOf(document, name); + const newParts = partsOf(next, name); const oldHosts = oldParts.filter(isHost); const newHosts = newParts.filter(isHost); const oldHost = oldHosts[0]; @@ -4108,8 +4122,7 @@ export function initSandboxRuntimeModular(): void { `scene ${name} cannot be swapped: it loads media this scene has not loaded`, ); } - const styleCount = (parts: Element[]) => parts.filter((el) => el.tagName === "STYLE").length; - if (styleCount(oldParts) !== styleCount(newParts)) { + if (styles(oldParts).length !== styles(newParts).length) { throw new Error(`scene ${name} cannot be swapped: its styles moved`); } return { @@ -4186,10 +4199,8 @@ export function initSandboxRuntimeModular(): void { clock.detachAudioSource(); for (const { oldParts, newParts, oldHost, newHost } of swaps) { // Each new style takes its own old one's place: same-named @keyframes resolve by order. - const newStyles = newParts.filter((el) => el.tagName === "STYLE"); - oldParts - .filter((el) => el.tagName === "STYLE") - .forEach((el, i) => el.replaceWith(document.importNode(newStyles[i]!, true))); + const newStyles = styles(newParts); + styles(oldParts).forEach((el, i) => el.replaceWith(document.importNode(newStyles[i]!, true))); for (const el of oldParts) if (el !== oldHost) el.remove(); const host = document.importNode(newHost, true); // Text only: a live script that ran no longer carries the deferred type its new copy has. diff --git a/packages/core/src/runtime/sceneWindow.ts b/packages/core/src/runtime/sceneWindow.ts index 80c8209e4a8..32dcbee08ff 100644 --- a/packages/core/src/runtime/sceneWindow.ts +++ b/packages/core/src/runtime/sceneWindow.ts @@ -1,7 +1,7 @@ import { SCENE_NO_SWAP_ATTR, SCENE_PART_ATTR, SCENE_PARTS_META } from "../sceneParts"; import { STUDIO_PREVIEW_PARKED_SCENE_ATTR as PARKED } from "../studioPreviewMark"; import { nearPlayhead } from "./farScenes"; -import type { ParkedTimelineScene } from "./timeline"; +import { parseElementDurationAttr, type ParkedTimelineScene } from "./timeline"; import type { RuntimeTimelineClip } from "./types"; /** A scene the preview may unmount: its host's own attributes time it, and its parts can be run again. */ @@ -12,24 +12,35 @@ const pristine = new Map(); const partsSelector = (name: string, tag = "") => `${tag}[${SCENE_PART_ATTR}="${CSS.escape(name)}"]`; -const ownNumber = (el: Element, attr: string) => - Number(el.getAttribute(attr)?.trim() || Number.NaN); +// What the timeline reads: the runtime moves a nested host's authored length off `data-duration`. +const ownDuration = (el: Element) => parseElementDurationAttr(el) ?? 0; + +/** Parked, a clip whose length only its loaded media or its own timeline gives would show to the scene's end. */ +export const clipLengthsWritten = (host: Element): boolean => + Array.from(host.querySelectorAll("video, audio, [data-composition-id]")).every( + (el) => ownDuration(el) > 0, + ); + +// A scene of the film itself, whose script a mount may run again and whose clips keep their lengths parked. +const parkable = (host: Element): boolean => { + const film = host.parentElement; + return ( + !host.hasAttribute(SCENE_NO_SWAP_ATTR) && + !host.hasAttribute("data-hf-original-composition-id") && + (isParked(host) || clipLengthsWritten(host)) && + film?.hasAttribute("data-composition-id") === true && + film.closest(`[${SCENE_PART_ATTR}]`) === null + ); +}; function windowedHosts(): Array { if (!document.querySelector(`meta[name="${SCENE_PARTS_META}"]`)) return []; return Array.from(document.querySelectorAll(`[${SCENE_PART_ATTR}]:not(style, script)`)) .flatMap((host) => { - const start = ownNumber(host, "data-start"); - const duration = ownNumber(host, "data-duration"); - const film = host.parentElement; - const windowed = - Number.isFinite(start) && - duration > 0 && - !host.hasAttribute(SCENE_NO_SWAP_ATTR) && - !host.hasAttribute("data-hf-original-composition-id") && - film?.hasAttribute("data-composition-id") === true && - film.closest(`[${SCENE_PART_ATTR}]`) === null; + const start = Number(host.getAttribute("data-start")?.trim() || Number.NaN); + const duration = ownDuration(host); const name = host.getAttribute(SCENE_PART_ATTR)!; + const windowed = Number.isFinite(start) && duration > 0 && parkable(host); return windowed ? [{ name, host, start, end: start + duration }] : []; }) .sort((a, b) => a.start - b.start); diff --git a/packages/core/src/runtime/timeline.ts b/packages/core/src/runtime/timeline.ts index 8438a6c3174..648eb8a0216 100644 --- a/packages/core/src/runtime/timeline.ts +++ b/packages/core/src/runtime/timeline.ts @@ -101,7 +101,7 @@ function parseNum(value: string | null | undefined): number | null { return parseStrictFiniteTimingNumber(value); } -function parseElementDurationAttr(element: Element): number | null { +export function parseElementDurationAttr(element: Element): number | null { const publicDuration = element.getAttribute("data-duration"); const authoredDuration = element.getAttribute(AUTHORED_DURATION_ATTR); const resolved = resolveAuthoredTimingWindow({ @@ -259,13 +259,11 @@ function buildTimelineClipLabel(node: Element, kind: RuntimeTimelineClip["kind"] return `${humanizeTimelineToken(kind)} ${ordinal + 1}`; } -/** A scene the preview unmounted: `host` builds its markup detached, under a copy of the film root. */ export type ParkedTimelineScene = { name: string; host: () => Element }; export function collectRuntimeTimelinePayload(params: { canonicalFps: number; parked?: ParkedTimelineScene[]; - /** Clips of parked scenes by scene, kept across calls; a parked scene's markup only changes by an edit. */ parkedClips?: Map; }): RuntimeTimelineMessage { const stackingByParent = new Map(); diff --git a/packages/core/src/runtime/window.d.ts b/packages/core/src/runtime/window.d.ts index 21757051c13..0cd00a0ed37 100644 --- a/packages/core/src/runtime/window.d.ts +++ b/packages/core/src/runtime/window.d.ts @@ -99,7 +99,6 @@ declare global { __hfSceneAnimations?: Record | null; /** Swap edited scenes from a rebuilt preview document; refuses before changing anything when it cannot. */ __hfSwapScenes?: (html: string, signal?: AbortSignal) => Promise; - /** Preview: mounts the named scenes and keeps them mounted until the next call names others. */ __hfHoldScenes?: (names: string[]) => Promise; __HF_EXPORT_RENDER_SEEK_CONFIG?: { mode?: string; diff --git a/packages/core/src/studioPreviewMark.ts b/packages/core/src/studioPreviewMark.ts index cab5a98019d..63e176dd173 100644 --- a/packages/core/src/studioPreviewMark.ts +++ b/packages/core/src/studioPreviewMark.ts @@ -6,7 +6,6 @@ export const STUDIO_PREVIEW_ERRORS = "__hfPreviewErrors"; export const PREVIEW_RASTER_ATTR = "data-hf-preview-raster"; export const RUNTIME_STAMPED_ATTR = "data-hf-autostamped"; export const STUDIO_PREVIEW_FAR_SCENE_ATTR = "data-hf-far-scene"; -/** On an empty stand-in for a scene the preview unmounted; the scene comes back when the playhead nears it. */ export const STUDIO_PREVIEW_PARKED_SCENE_ATTR = "data-hf-parked-scene"; export const PREVIEW_ONLY_ATTRS = [ STUDIO_PREVIEW_UPCOMING_ATTR, From 09e87ecce966176776cd6a33e35d573df06856e4 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 24 Oct 2026 06:07:32 -0400 Subject: [PATCH 13/40] perf(core): web audio schedules a clip only as the playhead comes near it --- packages/core/src/runtime/init.test.ts | 48 ++++++++++++++++++++++++++ packages/core/src/runtime/init.ts | 30 ++++++++++++++++ 2 files changed, 78 insertions(+) diff --git a/packages/core/src/runtime/init.test.ts b/packages/core/src/runtime/init.test.ts index 7365510ba97..4c12d292125 100644 --- a/packages/core/src/runtime/init.test.ts +++ b/packages/core/src/runtime/init.test.ts @@ -6024,6 +6024,54 @@ describe("initSandboxRuntimeModular", () => { }); }); + describe("Web Audio on a long film", () => { + const ctx = useMockAudioContext(); + + it("schedules a clip only as the playhead comes near it, and none that already ended", async () => { + const raf = createManualRaf(); + vi.spyOn(performance, "now").mockImplementation(() => raf.now()); + window.requestAnimationFrame = + raf.requestAnimationFrame as typeof window.requestAnimationFrame; + window.cancelAnimationFrame = raf.cancelAnimationFrame as typeof window.cancelAnimationFrame; + document.body.innerHTML = + `
` + + `` + + `` + + `` + + `
`; + for (const audio of document.querySelectorAll("audio")) { + audio.load = () => {}; + audio.play = vi.fn(() => Promise.resolve()); + // Loaded: a buffering clip would hold the playhead. + Object.defineProperty(audio, "readyState", { value: 4, configurable: true }); + } + window.__timelines = { main: createMockTimeline(200) }; + const capture = vi + .spyOn(WebAudioTransport.prototype, "scheduleMediaElementPlayback") + .mockResolvedValue(null); + vi.spyOn(WebAudioTransport.prototype, "decodeAudioElement").mockResolvedValue(null); + const scheduled = () => capture.mock.calls.map(([el]) => el.id); + initSandboxRuntimeModular(); + await Promise.resolve(); + window.__player?.seek(20); + window.__player?.play(); + await Promise.resolve(); + expect(scheduled()).toEqual(["now"]); + const playTo = (time: number) => { + // Steps under the clock's stall threshold, which would fold a longer gap away. + for (let i = 0; i < 400 && (window.__player?.getTime() ?? 0) < time; i++) { + ctx.time += 0.4; + raf.step(400); + } + expect(window.__player?.getTime()).toBeGreaterThanOrEqual(time); + }; + playTo(85); + expect(scheduled()).toEqual(["now"]); + playTo(92); + expect(scheduled()).toEqual(["now", "later"]); + }); + }); + // `el.volume` stops at 1, so a video's authored boost can only be carried by a Web Audio gain. describe("a video's sound above unity", () => { const ctx = useMockAudioContext(); diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index 2edcfcf5413..1c53a888c6b 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -5496,6 +5496,7 @@ export function initSandboxRuntimeModular(): void { } if (clock.isPlaying()) { + scheduleDueWebAudio(); syncMediaForCurrentState(timingRevision); } else if (hasRunningTimedMedia()) { // Nothing may run while the clock is paused, and the paused side used to @@ -5550,8 +5551,32 @@ export function initSandboxRuntimeModular(): void { webAudio.releaseDecodedExcept(clips); scheduleWebAudioFor(clips, gen); }; + // Clips of the playing generation that start beyond the horizon: the tick schedules each as it comes near. + const WEB_AUDIO_AHEAD_SECONDS = 10; + let laterWebAudio: { gen: number; clips: Array<{ el: HTMLMediaElement; start: number }> } = { + gen: -1, + clips: [], + }; + const webAudioHorizon = () => + clock.now() + WEB_AUDIO_AHEAD_SECONDS * Math.max(1, state.playbackRate); + const scheduleDueWebAudio = () => { + const later = laterWebAudio; + if (later.gen !== webAudio.currentGeneration() || later.clips.length === 0) return; + if (state.nativeMediaSyncDisabled || state.webAudioMediaDisabled) return; + const horizon = webAudioHorizon(); + const due = later.clips.filter((clip) => clip.start <= horizon); + if (due.length === 0) return; + later.clips = later.clips.filter((clip) => clip.start > horizon); + scheduleWebAudioFor( + due.flatMap(({ el }) => (el.isConnected ? [el] : [])), + later.gen, + ); + }; // Within one play generation, so a clip added mid-play joins the mix without restarting it. const scheduleWebAudioFor = (clips: HTMLMediaElement[], gen: number) => { + if (laterWebAudio.gen !== gen) laterWebAudio = { gen, clips: [] }; + const now = clock.now(); + const horizon = webAudioHorizon(); for (const rawEl of clips) { if (isSilencedByHidden(rawEl)) continue; const compStart = resolveAbsoluteMediaStartSeconds(rawEl); @@ -5575,6 +5600,11 @@ export function initSandboxRuntimeModular(): void { if (kept.end <= kept.start) continue; clipDuration = kept.end - compStart; } + if (kept.end <= now) continue; + if (kept.start > horizon) { + laterWebAudio.clips.push({ el: rawEl, start: kept.start }); + continue; + } // Decided BEFORE the transport is asked, because the two verdicts want // two different fallback chains — and only one of them is the chain // that existed before (#3458). From fe1dfee38a3a0590a7ca9904b3f4b286f8a733fb Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 24 Oct 2026 06:29:02 -0400 Subject: [PATCH 14/40] chore(studio): trim the scene hold helper's comments to its package's share --- packages/studio/src/utils/sceneHold.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/studio/src/utils/sceneHold.ts b/packages/studio/src/utils/sceneHold.ts index ba5c0e54dc0..5efc34d120c 100644 --- a/packages/studio/src/utils/sceneHold.ts +++ b/packages/studio/src/utils/sceneHold.ts @@ -2,10 +2,7 @@ import type { TimelineElement } from "../player"; type SceneHoldingWindow = Window & { __hfHoldScenes?: (names: string[]) => Promise }; -/** - * Mounts, in a preview that unmounts far scenes, the scenes holding a timeline element. - * Elsewhere there is nothing to wait for, so it returns nothing and a caller goes on in the same turn. - */ +/** Mounts the scenes holding a timeline element; undefined, so no wait, where the preview mounts every scene. */ export function holdScenesOf( iframe: HTMLIFrameElement | null, element: TimelineElement | null, @@ -14,12 +11,11 @@ export function holdScenesOf( try { hold = (iframe?.contentWindow as SceneHoldingWindow | null)?.__hfHoldScenes; } catch { - return undefined; // A cross-origin preview has no scenes to mount. + return undefined; } if (!hold) return undefined; const names = element ? [...(element.compositionAncestors ?? []), element.parentCompositionId ?? ""] : []; - // A preview torn down meanwhile has nothing to mount. return hold(names.filter(Boolean)).catch(() => {}); } From 89db7a1b45b11edbb7e026bac9f8b2a8fb3ccc9c Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 24 Oct 2026 09:11:05 -0400 Subject: [PATCH 15/40] fix(core): a scene the preview unmounts is freed, not kept by audio, caches or old roots --- packages/core/src/runtime/init.ts | 51 ++++++--- .../src/runtime/webAudioTransport.test.ts | 33 ++++++ .../core/src/runtime/webAudioTransport.ts | 47 +++++--- .../src/services/coreRuntimeBrowser.test.ts | 105 +++++++++++++++++- 4 files changed, 200 insertions(+), 36 deletions(-) diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index 1c53a888c6b..3cc638fbadc 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -1535,6 +1535,15 @@ export function initSandboxRuntimeModular(): void { (timeline as { __hfReal?: object }).__hfReal ?? timeline; // The composite that holds the children of a root with no timeline, and how many it holds. let missingRootComposite: { timeline: RuntimeTimelineLike; size: number } | null = null; + // Roots the runtime built. One it no longer holds would stay on GSAP's global timeline with all it last held. + const builtRoots = new Set(); + const dropUnusedBuiltRoots = () => { + for (const root of builtRoots) { + if (root === state.capturedTimeline || root === missingRootComposite?.timeline) continue; + builtRoots.delete(root); + (root as { kill?: () => void }).kill?.(); + } + }; const resolveRootTimelineFromDocument = (): TimelineResolution => { const timelines = (window.__timelines ?? {}) as Record; @@ -1626,6 +1635,7 @@ export function initSandboxRuntimeModular(): void { const gsapApi = window.gsap; if (!gsapApi || typeof gsapApi.timeline !== "function") return null; const compositeTimeline = gsapApi.timeline({ paused: true }) as RuntimeTimelineLike; + builtRoots.add(compositeTimeline); for (const candidate of candidates) nestAtHostStart(compositeTimeline, candidate); // Before any duration read: GSAP leaves paused children out of the duration it caches. ensureChildCandidatesActive(nestedCandidates(compositeTimeline, candidates)); @@ -1639,6 +1649,7 @@ export function initSandboxRuntimeModular(): void { const gsapApi = window.gsap; if (!gsapApi || typeof gsapApi.timeline !== "function") return null; const fallbackTimeline = gsapApi.timeline({ paused: true }) as RuntimeTimelineLike; + builtRoots.add(fallbackTimeline); if (existingRootTimeline) { try { fallbackTimeline.add(existingRootTimeline, 0); @@ -2072,6 +2083,7 @@ export function initSandboxRuntimeModular(): void { return false; } state.capturedTimeline = resolution.timeline; + dropUnusedBuiltRoots(); if (typeof state.capturedTimeline.timeScale === "function") { state.capturedTimeline.timeScale(state.playbackRate); } @@ -2448,6 +2460,7 @@ export function initSandboxRuntimeModular(): void { } const previousTime = Math.max(0, state.currentTime || 0); state.capturedTimeline = resolution.timeline; + dropUnusedBuiltRoots(); if (typeof state.capturedTimeline.timeScale === "function") { state.capturedTimeline.timeScale(state.playbackRate); } @@ -2613,7 +2626,10 @@ export function initSandboxRuntimeModular(): void { unbindMedia(mediaEl); mediaEl.pause(); stopMediaDownload(mediaEl); + // An element Web Audio captured lives as long as the audio context: alone, it keeps no detached tree. + mediaEl.remove(); } + webAudio.releaseDetached(); }; const authoredMediaVolume = (el: HTMLMediaElement): number | null => { @@ -3617,7 +3633,11 @@ export function initSandboxRuntimeModular(): void { } bindRootTimelineIfAvailable(); timelinePostHeld = clock.isPlaying(); - if (timelinePostHeld) return; + if (timelinePostHeld) { + // Stale until the held post rebuilds it, and its elements would keep unmounted scenes alive meanwhile. + delete window.__clipTree; + return; + } if (jumpLanding && !renderCaptureSeekStarted && hasPendingSeekCompletion()) return postTimelineAfterSeek(); jumpLanding = false; @@ -4650,14 +4670,7 @@ export function initSandboxRuntimeModular(): void { bindMediaMetadataListeners(); runAdapters("discover"); const publishRenderReadyAfterTimelineBinding = () => { - const prevTimeline = state.capturedTimeline; - const rebound = bindRootTimelineIfAvailable(); - if ( - state.capturedTimeline && - (rebound || state.capturedTimeline !== prevTimeline || !player._timeline) - ) { - player._timeline = state.capturedTimeline; - } + bindRootTimelineIfAvailable(); const boundDuration = getSafeTimelineDurationSeconds(state.capturedTimeline, 0); if (boundDuration > 0) { clock.setDuration(boundDuration); @@ -4822,17 +4835,24 @@ export function initSandboxRuntimeModular(): void { } }; - const standaloneHosts = new Map(); + // Weak: a scene the preview unmounted must not stay alive, whole, in this cache. + const standaloneHosts = new Map | { missingAt: number }>(); const standaloneHostOf = (compositionId: string): Element | null => { const known = standaloneHosts.get(compositionId); if (!renderCaptureSeekStarted && known) { - if (!isElementNode(known)) { + if (!(known instanceof WeakRef)) { if (known.missingAt === compositionTimingRevision) return null; - } else if (known.isConnected && known.getAttribute("data-composition-id") === compositionId) - return known; + } else { + const host = known.deref(); + if (host?.isConnected && host.getAttribute("data-composition-id") === compositionId) + return host; + } } const node = document.querySelector(`[data-composition-id="${CSS.escape(compositionId)}"]`); - standaloneHosts.set(compositionId, node ?? { missingAt: compositionTimingRevision }); + standaloneHosts.set( + compositionId, + node ? new WeakRef(node) : { missingAt: compositionTimingRevision }, + ); return node; }; type SceneClock = { @@ -5398,9 +5418,6 @@ export function initSandboxRuntimeModular(): void { ) { const prevTimeline = state.capturedTimeline; if (bindRootTimelineIfAvailable()) { - if (state.capturedTimeline && !player._timeline) { - player._timeline = state.capturedTimeline; - } if (state.capturedTimeline && state.capturedTimeline !== prevTimeline) { pauseTimelineIfPossible(state.capturedTimeline); } diff --git a/packages/core/src/runtime/webAudioTransport.test.ts b/packages/core/src/runtime/webAudioTransport.test.ts index 5712dc28174..2059ad19826 100644 --- a/packages/core/src/runtime/webAudioTransport.test.ts +++ b/packages/core/src/runtime/webAudioTransport.test.ts @@ -160,6 +160,39 @@ describe("WebAudioTransport", () => { expect(transport.routesElement(mockEl)).toBe(false); }); + it("stops what plays from media gone from the page without sending it to the destination", async () => { + const { transport, mock, gen } = setupTransport(100); + const nodes: Array<{ + connect: ReturnType; + disconnect: ReturnType; + }> = []; + mock.ctx.createMediaElementSource = vi.fn(() => { + nodes.push({ connect: vi.fn(), disconnect: vi.fn() }); + return nodes.at(-1)!; + }) as never; + const media = () => + ({ ...mockEl, isConnected: true }) as HTMLMediaElement & { isConnected: boolean }; + const [gone, kept] = [media(), media()]; + for (const el of [gone, kept]) + await transport.scheduleMediaElementPlayback(el, 0, 0, 0, 1, gen, 1); + for (const node of nodes) { + node.connect.mockClear(); + node.disconnect.mockClear(); + } + gone.isConnected = false; + transport.releaseDetached(); + + const touched = nodes.map((n) => [ + n.disconnect.mock.calls.length > 0, + n.connect.mock.calls.length > 0, + ]); + expect(touched).toEqual([ + [true, false], + [false, false], + ]); + expect([transport.routesElement(gone), transport.routesElement(kept)]).toEqual([false, true]); + }); + it("creates one MediaElementAudioSourceNode per element and context", async () => { const { transport, mock } = setupTransport(100); diff --git a/packages/core/src/runtime/webAudioTransport.ts b/packages/core/src/runtime/webAudioTransport.ts index 40bd47fe06c..8a564ffca8f 100644 --- a/packages/core/src/runtime/webAudioTransport.ts +++ b/packages/core/src/runtime/webAudioTransport.ts @@ -903,30 +903,41 @@ export class WebAudioTransport { } stopAll(): void { - for (const source of this._activeSources) { - try { - if (isBufferSource(source)) source.sourceNode.stop(); - source.sourceNode.disconnect(); - source.fx?.dispose(); - source.clipGainNode.disconnect(); - source.laneNode.disconnect(); - source.gainNode.disconnect(); - } catch { - // already stopped - } - if (isBufferSource(source)) source.el.muted = source.priorMuted; - else { - source.el.volume = source.priorVolume; - // Captured, it has no native output; outside a play it sounds through the destination. - if (this._ctx) source.sourceNode.connect(this._ctx.destination); - } - } + for (const source of this._activeSources) this.dispose(source); this._activeSources = []; this._paused = true; this._playGeneration += 1; this.rest(); } + /** Stops what plays from media gone from the page, without sending it to the destination as a stop does. */ + releaseDetached(): void { + this._activeSources = this._activeSources.filter((source) => { + if (source.el.isConnected) return true; + this.dispose(source, false); + return false; + }); + } + + private dispose(source: ScheduledSource, keepsSounding = true): void { + try { + if (isBufferSource(source)) source.sourceNode.stop(); + source.sourceNode.disconnect(); + source.fx?.dispose(); + source.clipGainNode.disconnect(); + source.laneNode.disconnect(); + source.gainNode.disconnect(); + } catch { + // already stopped + } + if (isBufferSource(source)) source.el.muted = source.priorMuted; + else { + source.el.volume = source.priorVolume; + // Captured, it has no native output; outside a play it sounds through the destination. + if (this._ctx && keepsSounding) source.sourceNode.connect(this._ctx.destination); + } + } + setVolume(volume: number): void { this._masterVolume = Math.max(0, Math.min(1, volume)); this.applyMasterGain(); diff --git a/packages/producer/src/services/coreRuntimeBrowser.test.ts b/packages/producer/src/services/coreRuntimeBrowser.test.ts index ad50aa1ccf7..228a8aa72a8 100644 --- a/packages/producer/src/services/coreRuntimeBrowser.test.ts +++ b/packages/producer/src/services/coreRuntimeBrowser.test.ts @@ -1,7 +1,7 @@ import { afterAll, beforeAll, describe, expect, it, onTestFailed } from "vitest"; import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { bundleToSingleHtml } from "@hyperframes/core/compiler"; +import { addScenePartsManifest, bundleToSingleHtml } from "@hyperframes/core/compiler"; import { resolve } from "node:path"; import puppeteer, { type Browser, type Page } from "puppeteer"; import type {} from "../../../core/src/runtime/window"; @@ -15,6 +15,25 @@ const RUNTIME_PATH = resolve(import.meta.dirname, "../../../core/dist/hyperframe const PNG_1PX = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg=="; +// A tenth of a second of 8 kHz, 8-bit mono silence. +function silentWav(): Buffer { + const samples = 800; + const wav = Buffer.alloc(44 + samples, 128); + wav.write("RIFF", 0); + wav.writeUInt32LE(36 + samples, 4); + wav.write("WAVEfmt ", 8); + wav.writeUInt32LE(16, 16); + wav.writeUInt16LE(1, 20); + wav.writeUInt16LE(1, 22); + wav.writeUInt32LE(8000, 24); + wav.writeUInt32LE(8000, 28); + wav.writeUInt16LE(1, 32); + wav.writeUInt16LE(8, 34); + wav.write("data", 36); + wav.writeUInt32LE(samples, 40); + return wav; +} + describe("core runtime browser contract", () => { let browser: Browser; let page: Page; @@ -1312,6 +1331,90 @@ describe("core runtime browser contract", () => { 30_000, ); + it("lets go of each scene the preview unmounts while it plays, its captured audio included", async () => { + const dir = mkdtempSync(resolve(tmpdir(), "scene-window-memory-")); + // Its own browser: Web Audio only captures media where the context may start without a gesture. + const audioBrowser = await puppeteer.launch({ + headless: true, + args: [ + "--no-sandbox", + "--disable-setuid-sandbox", + "--autoplay-policy=no-user-gesture-required", + ], + }); + try { + const scenes = [0, 1, 2, 3, 4, 5]; + const hosts = scenes + .map( + (i) => + `
`, + ) + .join(""); + writeFileSync( + resolve(dir, "index.html"), + `
${hosts}
`, + ); + writeFileSync(resolve(dir, "tone.wav"), silentWav()); + for (const i of scenes) + writeFileSync( + resolve(dir, `s${i}.html`), + ``, + ); + const html = addScenePartsManifest( + await bundleToSingleHtml(dir, { sceneParts: true, staticGuard: false }), + ); + const page = await audioBrowser.newPage(); + await page.evaluateOnNewDocument( + readFileSync( + resolve(import.meta.dirname, "../../../core/node_modules/gsap/dist/gsap.min.js"), + "utf8", + ), + ); + await page.setRequestInterception(true); + page.on( + "request", + (request) => void request.respond({ contentType: "text/html", body: html }), + ); + await page.goto("http://scenes.test/"); + await page.waitForFunction(() => window.__playerReady && window.__renderReady); + const cdp = await page.createCDPSession(); + await cdp.send("Performance.enable"); + // DOM nodes alive but out of the page, after a full collection. + const kept = async () => { + await cdp.send("HeapProfiler.collectGarbage"); + const { metrics } = await cdp.send("Performance.getMetrics"); + const inPage = await page.evaluate(() => { + const walker = document.createTreeWalker(document, NodeFilter.SHOW_ALL); + let count = 1; + while (walker.nextNode()) count++; + return count; + }); + return metrics.find((m) => m.name === "Nodes")!.value - inPage; + }; + const before = await kept(); + const unmounted = await page.evaluate(async () => { + const player = window.__player as unknown as { + play: () => void; + seek: (time: number, options: { keepPlaying: boolean }) => void; + }; + player.play(); + for (const time of [2.5, 4.5, 6.5, 8.5, 10.5]) { + player.seek(time, { keepPlaying: true }); + await window.__hfWaitForSeekCompletion?.(); + await new Promise((settle) => setTimeout(settle, 200)); + } + return document.querySelectorAll("[data-hf-parked-scene]").length; + }); + // Four scenes went out of the window, each over 800 nodes. Chrome keeps a captured element alive while + // its audio context lives, so each scene's audio element stays, but nothing of the scene around it. + expect(unmounted).toBe(4); + expect((await kept()) - before).toBeLessThan(800); + } finally { + await audioBrowser.close(); + rmSync(dir, { recursive: true, force: true }); + } + }, 60_000); + it("removes the control bridge during teardown", async () => { const result = await page.evaluate(async () => { const runtimeWindow = window as unknown as { From 4283542d4441dd0c52258e0be8a1968ff114c554 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 24 Oct 2026 09:30:45 -0400 Subject: [PATCH 16/40] fix(studio): an unmounted scene's rows and their state come from the markup it mounts with --- .../core/src/runtime/init.swapScenes.test.ts | 9 +++++ packages/core/src/runtime/init.ts | 3 ++ packages/core/src/runtime/sceneWindow.ts | 37 +++++++++++-------- packages/core/src/runtime/window.d.ts | 1 + .../hooks/timelineSyncHydration.test.ts | 20 ++++++++++ .../src/player/hooks/timelineSyncHydration.ts | 15 +++++++- 6 files changed, 67 insertions(+), 18 deletions(-) diff --git a/packages/core/src/runtime/init.swapScenes.test.ts b/packages/core/src/runtime/init.swapScenes.test.ts index 34b6c28ebbf..2d1036025d9 100644 --- a/packages/core/src/runtime/init.swapScenes.test.ts +++ b/packages/core/src/runtime/init.swapScenes.test.ts @@ -1847,6 +1847,15 @@ describe("the preview's scene window", () => { expect(clip && [clip.start, clip.duration]).toEqual([4.5, 1]); }); + it("hands Studio a parked scene's markup, and nothing for a mounted one", async () => { + bootWindowed([S0, S1, S2], trackingRoot().root); + await tick(); + const markup = window.__hfParkedSceneMarkup!; + expect(markup(sceneHost("s2"))?.textContent).toBe("two"); + expect(markup(sceneHost("s2"))?.isConnected).toBe(false); + expect(markup(sceneHost("s0"))).toBeNull(); + }); + it("gives an edit to a parked scene to its next mount, without mounting it", async () => { bootWindowed([S0, S1, S2], trackingRoot().root); await tick(); diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index 3cc638fbadc..f240e3d31af 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -6,6 +6,7 @@ import { hostOf as sceneHostOf, isParked, parkedClips, + parkedSceneMarkup, parkedTimelineScenes, park as parkScene, rememberScene, @@ -4345,9 +4346,11 @@ export function initSandboxRuntimeModular(): void { if (readSceneParts(document)) { window.__hfSwapScenes = swapScenes; window.__hfHoldScenes = holdScenes; + window.__hfParkedSceneMarkup = parkedSceneMarkup; registerRuntimeCleanup(() => { delete window.__hfSwapScenes; delete window.__hfHoldScenes; + delete window.__hfParkedSceneMarkup; }); } diff --git a/packages/core/src/runtime/sceneWindow.ts b/packages/core/src/runtime/sceneWindow.ts index 32dcbee08ff..2cd1fcdac29 100644 --- a/packages/core/src/runtime/sceneWindow.ts +++ b/packages/core/src/runtime/sceneWindow.ts @@ -120,20 +120,25 @@ export function rememberScene(name: string, host: Element, scripts: Element[]): /** The timeline's clips of each parked scene, built from its markup once and dropped when an edit changes it. */ export const parkedClips = new Map(); -/** The parked scenes, each able to build its markup detached under a copy of the film root for the timeline. */ -export function parkedTimelineScenes(): ParkedTimelineScene[] { - return Array.from(document.querySelectorAll(`[${PARKED}]`)).flatMap((placeholder) => { - const name = placeholder.getAttribute(SCENE_PART_ATTR); - const parts = name === null ? undefined : pristine.get(name); - const film = placeholder.parentElement; - if (name === null || !parts || !film) return []; - const host = () => { - const detachedFilm = film.cloneNode(false) as Element; - const template = document.createElement("template"); - template.innerHTML = parts.html; - detachedFilm.append(template.content); - return detachedFilm.firstElementChild!; - }; - return [{ name, host }]; - }); +const parkedName = (placeholder: Element): string | null => { + const name = isParked(placeholder) ? placeholder.getAttribute(SCENE_PART_ATTR) : null; + return name !== null && pristine.has(name) && placeholder.parentElement ? name : null; +}; + +/** A parked scene's markup, built detached under a copy of the film root; null for any other element. */ +export function parkedSceneMarkup(placeholder: Element): Element | null { + const name = parkedName(placeholder); + if (name === null) return null; + const detachedFilm = placeholder.parentElement!.cloneNode(false) as Element; + const template = document.createElement("template"); + template.innerHTML = pristine.get(name)!.html; + detachedFilm.append(template.content); + return detachedFilm.firstElementChild; } + +/** The parked scenes, each able to build its markup for the timeline. */ +export const parkedTimelineScenes = (): ParkedTimelineScene[] => + Array.from(document.querySelectorAll(`[${PARKED}]`)).flatMap((placeholder) => { + const name = parkedName(placeholder); + return name === null ? [] : [{ name, host: () => parkedSceneMarkup(placeholder)! }]; + }); diff --git a/packages/core/src/runtime/window.d.ts b/packages/core/src/runtime/window.d.ts index 0cd00a0ed37..f0ef7703906 100644 --- a/packages/core/src/runtime/window.d.ts +++ b/packages/core/src/runtime/window.d.ts @@ -100,6 +100,7 @@ declare global { /** Swap edited scenes from a rebuilt preview document; refuses before changing anything when it cannot. */ __hfSwapScenes?: (html: string, signal?: AbortSignal) => Promise; __hfHoldScenes?: (names: string[]) => Promise; + __hfParkedSceneMarkup?: (placeholder: Element) => Element | null; __HF_EXPORT_RENDER_SEEK_CONFIG?: { mode?: string; diagnostics?: boolean; diff --git a/packages/studio/src/player/hooks/timelineSyncHydration.test.ts b/packages/studio/src/player/hooks/timelineSyncHydration.test.ts index 1fdaacabd69..6b90e293828 100644 --- a/packages/studio/src/player/hooks/timelineSyncHydration.test.ts +++ b/packages/studio/src/player/hooks/timelineSyncHydration.test.ts @@ -107,6 +107,26 @@ describe("collectSubCompositionHostState", () => { expect(rows.map((child) => child.id)).toEqual(["scene-2-title"]); }); + it("reads a scene the preview unmounted from the markup it mounts with", () => { + const markup = mountScene().getElementById("scene-2-slot")!.cloneNode(true) as Element; + document.body.innerHTML = `
`; + const view = window as Window & { __hfParkedSceneMarkup?: (el: Element) => Element | null }; + view.__hfParkedSceneMarkup = (el) => (el.hasAttribute("data-hf-parked-scene") ? markup : null); + try { + const rows = collectSubCompositionDomChildren(document, sceneClips, new Map()); + expect(rows.map((child) => child.id)).toEqual([ + "scene-2-video-region", + "scene-2-title", + "scene-2-caption", + ]); + expect(collectSubCompositionHostState(document, sceneClips).get("scene-2-video")).toEqual({ + hidden: true, + }); + } finally { + delete view.__hfParkedSceneMarkup; + } + }); + it("returns empty without a document, rather than throwing", () => { expect(collectSubCompositionHostState(null, sceneClips).size).toBe(0); }); diff --git a/packages/studio/src/player/hooks/timelineSyncHydration.ts b/packages/studio/src/player/hooks/timelineSyncHydration.ts index 7f0dbc0bda0..b05c14a70ca 100644 --- a/packages/studio/src/player/hooks/timelineSyncHydration.ts +++ b/packages/studio/src/player/hooks/timelineSyncHydration.ts @@ -112,6 +112,17 @@ function collectHostDomChildren( } } +type SceneMarkupWindow = Window & { + __hfParkedSceneMarkup?: (placeholder: Element) => Element | null; +}; + +/** A clip's host; for a scene the preview unmounted, the markup it mounts with. */ +function clipHostOf(iframeDoc: Document, clip: ClipManifestClip): Element | null { + const host = findClipElementById(iframeDoc, clip); + const view = iframeDoc.defaultView as SceneMarkupWindow | null; + return (host && view?.__hfParkedSceneMarkup?.(host)) ?? host; +} + /** * Every sub-composition's internal elements, across the whole manifest. * @@ -129,7 +140,7 @@ export function collectSubCompositionDomChildren( if (!iframeDoc) return out; for (const clip of clips) { if (clip.kind !== "composition" || !clip.id) continue; - const hostEl = findClipElementById(iframeDoc, clip); + const hostEl = clipHostOf(iframeDoc, clip); if (!hostEl) continue; const innerRoot = hostEl.querySelector("[data-hf-inner-root]") ?? hostEl; collectHostDomChildren(clip.id, innerRoot, clip.id, parentMap, out); @@ -173,7 +184,7 @@ export function collectSubCompositionHostState( if (!iframeDoc) return out; for (const clip of clips) { if (clip.kind !== "composition" || !clip.id) continue; - const hostEl = findClipElementById(iframeDoc, clip); + const hostEl = clipHostOf(iframeDoc, clip); if (!hostEl) continue; for (const el of Array.from(hostEl.querySelectorAll("[id]"))) { const state = readSubCompositionHostState(el); From 66cdebe583a151c3060fc83739ababd502681f8b Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 24 Oct 2026 12:08:42 -0400 Subject: [PATCH 17/40] perf(core): the preview mounts scenes ahead of play and keeps recent ones mounted --- .../core/src/runtime/init.swapScenes.test.ts | 53 ++++++++++++++++--- packages/core/src/runtime/init.ts | 26 ++++++--- packages/core/src/runtime/sceneWindow.ts | 40 +++++++++++--- .../src/services/coreRuntimeBrowser.test.ts | 17 ++++-- 4 files changed, 110 insertions(+), 26 deletions(-) diff --git a/packages/core/src/runtime/init.swapScenes.test.ts b/packages/core/src/runtime/init.swapScenes.test.ts index 2d1036025d9..1d456d61dac 100644 --- a/packages/core/src/runtime/init.swapScenes.test.ts +++ b/packages/core/src/runtime/init.swapScenes.test.ts @@ -8,7 +8,7 @@ import { WebAudioTransport } from "./webAudioTransport"; import { probeAndCacheElementVolume } from "./mediaVolumeEnvelope.js"; import { wrapScopedCompositionScript } from "../compiler/compositionScoping"; import { AFTER_FONTS_SCRIPT_TYPE } from "../compiler/scriptRuns"; -import { parkFarScenesAtBoot } from "./sceneWindow"; +import { parkFarScenesAtBoot, setKeptSceneElementsForTests } from "./sceneWindow"; vi.mock("./mediaVolumeEnvelope.js", async (importOriginal) => { const actual = await importOriginal(); @@ -54,9 +54,9 @@ function tl(label: string, duration = 2): Tl { } as unknown as Tl; } -function trackingRoot() { +function trackingRoot(duration = 6) { const children: Array<{ child: unknown; at: number | undefined }> = []; - const root = tl("root", 6) as Tl & { remove: (c: unknown) => void }; + const root = tl("root", duration) as Tl & { remove: (c: unknown) => void }; root.add = ((child: unknown, at?: number) => void children.push({ child, at })) as Tl["add"]; root.getChildren = (() => children.map((c) => c.child)) as Tl["getChildren"]; root.remove = (child) => { @@ -92,7 +92,7 @@ function preview(scenes: Scene[], shared = "s1", sharedMarkup = "") { `` + scenes.map((s) => ``).join(""); const body = - `
${sharedMarkup}` + + `
${sharedMarkup}` + scenes .map( (s) => @@ -1764,6 +1764,8 @@ describe("the preview's scene window", () => { for (const k of Object.keys(made)) delete made[k]; for (const label of ["a1", "a2", "b"]) made[label] = tl(label); (window as unknown as { __made: typeof made }).__made = made; + // None kept: every scene the window leaves parks, unless a test gives a budget. + keptBudget = setKeptSceneElementsForTests(0); }); afterEach(() => { window.__hfRuntimeTeardown?.(); @@ -1771,7 +1773,9 @@ describe("the preview's scene window", () => { document.body.innerHTML = ""; delete window.__hfSceneAnimations; vi.restoreAllMocks(); + setKeptSceneElementsForTests(keptBudget); }); + let keptBudget = 0; it("parks a scene away from the start, keeping its timing, and never runs its script", async () => { bootWindowed([S0, S1, S2], trackingRoot().root); @@ -1803,7 +1807,7 @@ describe("the preview's scene window", () => { Reflect.deleteProperty(document, "fonts"); expect(sceneHost("s2").textContent).toBe("two"); for (let i = 0; i < 5; i++) await tick(); - expect(parked()).toEqual(["s0"]); + expect(parked()).toEqual(["s0", "s1"]); expect(window.__timelines?.s2).toBe(made.a2); expect(children.map((c) => c.child)).toContain(made.a2); expect(made.a1!.kill).toHaveBeenCalled(); @@ -1821,6 +1825,10 @@ describe("the preview's scene window", () => { await seekAndSettle(0.5); window.__player?.seek(5); expect(sceneHost("s0").getAttribute("style")).toContain("visibility: visible"); + // A frame drawn between the mount's tasks, the scene not yet settled, still holds. + await tick(); + window.__player?.seek(5); + expect(sceneHost("s0").getAttribute("style")).toContain("visibility: visible"); await window.__hfWaitForSeekCompletion?.(); expect(sceneHost("s2").getAttribute("style")).toContain("visibility: visible"); }); @@ -1914,7 +1922,38 @@ describe("the preview's scene window", () => { await window.__hfSwapScenes!(preview([{ ...S0, body: untrimmed, hash: "h0b" }, S1, S2]).html); await seekAndSettle(5); for (let i = 0; i < 5; i++) await tick(); + expect(parked()).toEqual(["s1"]); + }); + + it("mounts the next scene once its cut is within the lead of a playing film, not at the cut before it", async () => { + window.requestAnimationFrame = (() => 1) as typeof window.requestAnimationFrame; + bootWindowed([S0, S1, { ...S2, start: 30 }], trackingRoot(32).root); + await tick(); + window.__player?.play(); + window.__player?.seek(2.5, { keepPlaying: true }); + await window.__hfWaitForSeekCompletion?.(); + for (let i = 0; i < 5; i++) await tick(); + expect(parked()).toEqual(["s0", "s2"]); + window.__player?.seek(21, { keepPlaying: true }); + for (let i = 0; i < 5; i++) await tick(); + expect(parked()).toEqual(["s0", "s1"]); + expect(sceneHost("s2").textContent).toBe("two"); + }); + + it("keeps the scenes the playhead left mounted, most recent first, while they fit the budget", async () => { + // Each scene here is its host and one child: two elements. + setKeptSceneElementsForTests(2); + bootWindowed([S0, S1, S2], trackingRoot().root); + await tick(); + await seekAndSettle(2.5); + for (let i = 0; i < 5; i++) await tick(); expect(parked()).toEqual([]); + const left = sceneHost("s1"); + await seekAndSettle(5); + for (let i = 0; i < 5; i++) await tick(); + expect(parked()).toEqual(["s0"]); + await seekAndSettle(2.5); + expect(sceneHost("s1")).toBe(left); }); const audio = (src: string) => ``; @@ -1940,7 +1979,7 @@ describe("the preview's scene window", () => { await window.__hfWaitForSeekCompletion?.(); for (const spy of [schedule, stop]) spy.mockClear(); for (let i = 0; i < 5; i++) await tick(); - expect(parked()).toEqual([]); + expect(parked()).toEqual(["s0"]); expect(stop).not.toHaveBeenCalled(); expect(schedule.mock.calls.map(([el]) => el.getAttribute("src"))).toEqual(["/two.mp3"]); }); @@ -1956,7 +1995,7 @@ describe("the preview's scene window", () => { release.mockClear(); await seekAndSettle(5); for (let i = 0; i < 5; i++) await tick(); - expect(parked()).toEqual(["s0"]); + expect(parked()).toEqual(["s0", "s1"]); const kept = release.mock.calls.at(-1)?.[0].map((el) => el.getAttribute("src")); expect(kept).toEqual(["/two.mp3"]); }); diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index f240e3d31af..b0e2479dd7c 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -10,7 +10,8 @@ import { parkedTimelineScenes, park as parkScene, rememberScene, - scenesNear, + scenesToKeep, + scenesWanted, unpark, windowedScenes, } from "./sceneWindow"; @@ -4264,16 +4265,20 @@ export function initSandboxRuntimeModular(): void { ); let windowWork: Promise | null = null; let windowNear = ""; + const nextTask = () => new Promise((resolve) => window.setTimeout(resolve, 0)); + // In three tasks, so frames draw between them; the scene counts as parked, its last frame held, until it settles. const mountScene = async (name: string) => { const unparked = unpark(name); if (!unparked) return; - parkedNames.delete(name); const { host, scripts } = unparked; recordAuthoredMedia(host); refreshSvgSelectorAliases(); const captionHosts = host.querySelector(".caption-group") ? [host] : []; const captionOverrides = captionHosts.length > 0 ? await fetchCaptionOverrides() : []; + await nextTask(); await runScriptsAfterFonts(scripts.map((el) => document.body.appendChild(el))); + await nextTask(); + parkedNames.delete(name); if (!state.tornDown) settleChangedScenes([host], captionHosts, captionOverrides, true); }; const retireScene = (name: string) => { @@ -4290,6 +4295,8 @@ export function initSandboxRuntimeModular(): void { }; // Scenes Studio holds mounted for selecting or editing, whatever the playhead does. let heldScenes = new Set(); + // Scenes the window wanted, most recently first: those it left stay mounted while they fit the budget. + let recentScenes: string[] = []; moveSceneWindow = (time: number): boolean => { if (windowScenes.length === 0 || state.tornDown) return false; const parked = (name: string) => parkedNames.has(name); @@ -4297,29 +4304,32 @@ export function initSandboxRuntimeModular(): void { ({ name, start, end }) => start <= time && time <= end && parked(name), ); if (windowWork) return landed !== undefined; - const near = scenesNear(windowScenes, time); + const near = scenesWanted(windowScenes, time, clock.isPlaying()); for (const name of heldScenes) near.add(name); const key = [...near].join("\n"); if (key === windowNear) return landed !== undefined; + recentScenes = [...near, ...recentScenes.filter((name) => !near.has(name))]; // One step at a time, planned again after each, so a later seek or scrub overtakes the rest. const neighbour = windowScenes .filter(({ name }) => near.has(name) && parked(name)) .sort((a, b) => Math.abs(a.start - time) - Math.abs(b.start - time))[0]; const toMount = landed ?? neighbour; - const toRetire = toMount - ? [] - : windowScenes.filter(({ name }) => !near.has(name) && !parked(name)); + const kept = toMount ? null : scenesToKeep(recentScenes.filter((name) => !near.has(name))); + const toRetire = windowScenes.filter( + ({ name }) => kept && !near.has(name) && !kept.has(name) && !parked(name), + ); if (!toMount && toRetire.length === 0) { windowNear = key; return false; } const wantedNow = (name: string) => - heldScenes.has(name) || scenesNear(windowScenes, state.currentTime).has(name); + heldScenes.has(name) || + scenesWanted(windowScenes, state.currentTime, clock.isPlaying()).has(name); windowWork = (async () => { if (landed) await mountScene(landed.name); else if (toMount) { // A neighbour waits a turn: a seek queued meanwhile may want another scene first. - await new Promise((resolve) => window.setTimeout(resolve, 0)); + await nextTask(); if (wantedNow(toMount.name)) await mountScene(toMount.name); } else for (const { name } of toRetire) retireScene(name); })().finally(() => { diff --git a/packages/core/src/runtime/sceneWindow.ts b/packages/core/src/runtime/sceneWindow.ts index 2cd1fcdac29..18d55ea6e20 100644 --- a/packages/core/src/runtime/sceneWindow.ts +++ b/packages/core/src/runtime/sceneWindow.ts @@ -1,6 +1,5 @@ import { SCENE_NO_SWAP_ATTR, SCENE_PART_ATTR, SCENE_PARTS_META } from "../sceneParts"; import { STUDIO_PREVIEW_PARKED_SCENE_ATTR as PARKED } from "../studioPreviewMark"; -import { nearPlayhead } from "./farScenes"; import { parseElementDurationAttr, type ParkedTimelineScene } from "./timeline"; import type { RuntimeTimelineClip } from "./types"; @@ -52,10 +51,39 @@ export const windowedScenes = (): WindowedScene[] => .filter(({ name }) => pristine.has(name)) .map(({ name, start, end }) => ({ name, start, end })); -/** The names of the scenes to keep mounted at `time`: the playing scene and a neighbour each side. */ -export function scenesNear(scenes: WindowedScene[], time: number): Set { - const near = nearPlayhead(scenes, time); - return new Set(scenes.filter((_, i) => near[i]).map((s) => s.name)); +// Playing, a scene mounts this long before its cut, so the cut's frames do no mount work. +const MOUNT_AHEAD_SECONDS = 10; + +/** The scenes wanted mounted at `time`: the playing ones, then those starting within the lead, or paused, the next. */ +export function scenesWanted(scenes: WindowedScene[], time: number, playing: boolean): Set { + const next = scenes.find((s) => s.start > time); + const wanted = (s: WindowedScene) => { + if (s.start <= time) return time <= s.end; + return playing ? s.start <= time + MOUNT_AHEAD_SECONDS : s === next; + }; + return new Set(scenes.filter(wanted).map((s) => s.name)); +} + +// At most about 120 MB at the ~2 KB of renderer memory per element a long film measured. +let keptSceneElements = 60_000; +export const setKeptSceneElementsForTests = (elements: number): number => { + const was = keptSceneElements; + keptSceneElements = elements; + return was; +}; + +/** Of the mounted scenes the playhead left, most recent first, those that stay mounted within the element budget. */ +export function scenesToKeep(recentFirst: string[]): Set { + const kept = new Set(); + let elements = 0; + for (const name of recentFirst) { + const host = hostOf(name); + if (!host || isParked(host)) continue; + elements += host.getElementsByTagName("*").length + 1; + if (elements > keptSceneElements) break; + kept.add(name); + } + return kept; } export const hostOf = (name: string): Element | null => @@ -96,7 +124,7 @@ export function parkFarScenesAtBoot(time = 0): void { pristine.clear(); parkedClips.clear(); const hosts = windowedHosts(); - const near = scenesNear(hosts, time); + const near = scenesWanted(hosts, time, false); for (const { name, host } of hosts) { const scripts = Array.from(document.querySelectorAll(partsSelector(name, "script"))); pristine.set(name, { diff --git a/packages/producer/src/services/coreRuntimeBrowser.test.ts b/packages/producer/src/services/coreRuntimeBrowser.test.ts index 228a8aa72a8..a693e155292 100644 --- a/packages/producer/src/services/coreRuntimeBrowser.test.ts +++ b/packages/producer/src/services/coreRuntimeBrowser.test.ts @@ -1344,6 +1344,8 @@ describe("core runtime browser contract", () => { }); try { const scenes = [0, 1, 2, 3, 4, 5]; + // Over half the preview's kept-scene budget each, so of the scenes the playhead left only the latest stays. + const filler = `

${"".repeat(31_000)}

`; const hosts = scenes .map( (i) => @@ -1358,7 +1360,7 @@ describe("core runtime browser contract", () => { for (const i of scenes) writeFileSync( resolve(dir, `s${i}.html`), - ``, + ``, ); const html = addScenePartsManifest( await bundleToSingleHtml(dir, { sceneParts: true, staticGuard: false }), @@ -1392,7 +1394,7 @@ describe("core runtime browser contract", () => { return metrics.find((m) => m.name === "Nodes")!.value - inPage; }; const before = await kept(); - const unmounted = await page.evaluate(async () => { + await page.evaluate(async () => { const player = window.__player as unknown as { play: () => void; seek: (time: number, options: { keepPlaying: boolean }) => void; @@ -1403,11 +1405,16 @@ describe("core runtime browser contract", () => { await window.__hfWaitForSeekCompletion?.(); await new Promise((settle) => setTimeout(settle, 200)); } - return document.querySelectorAll("[data-hf-parked-scene]").length; }); - // Four scenes went out of the window, each over 800 nodes. Chrome keeps a captured element alive while + // The window unmounts only once it has mounted what is ahead. + await page.waitForFunction( + () => document.querySelectorAll("[data-hf-parked-scene]").length >= 4, + ); + // Four scenes unmounted, each over 30k nodes. Chrome keeps a captured element alive while // its audio context lives, so each scene's audio element stays, but nothing of the scene around it. - expect(unmounted).toBe(4); + expect( + await page.evaluate(() => document.querySelectorAll("[data-hf-parked-scene]").length), + ).toBe(4); expect((await kept()) - before).toBeLessThan(800); } finally { await audioBrowser.close(); From 3bcb4b3b1339149fb0148246a8eb40f1d4f38244 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 24 Oct 2026 12:33:48 -0400 Subject: [PATCH 18/40] refactor(core): keep the far-scene neighbour rule private to its module --- packages/core/src/runtime/farScenes.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/runtime/farScenes.ts b/packages/core/src/runtime/farScenes.ts index 9f65f9d71a5..cd0734e8feb 100644 --- a/packages/core/src/runtime/farScenes.ts +++ b/packages/core/src/runtime/farScenes.ts @@ -9,7 +9,7 @@ const OUT_OF_FLOW = new Set(["absolute", "fixed"]); type Timed = { host: HTMLElement; span: { start: number; end: number } }; /** Whether each span, sorted by start, is the playing one or a neighbour; between scenes, the one before the gap plays. */ -export function nearPlayhead( +function nearPlayhead( spans: Array<{ start: number; end: number }>, time: number, ): boolean[] { From 993c830b75536389b59ce300ab793ed915a3d732 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 24 Oct 2026 13:16:43 -0400 Subject: [PATCH 19/40] style(core): format the far-scene neighbour rule --- packages/core/src/runtime/farScenes.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/core/src/runtime/farScenes.ts b/packages/core/src/runtime/farScenes.ts index cd0734e8feb..2719032c316 100644 --- a/packages/core/src/runtime/farScenes.ts +++ b/packages/core/src/runtime/farScenes.ts @@ -9,10 +9,7 @@ const OUT_OF_FLOW = new Set(["absolute", "fixed"]); type Timed = { host: HTMLElement; span: { start: number; end: number } }; /** Whether each span, sorted by start, is the playing one or a neighbour; between scenes, the one before the gap plays. */ -function nearPlayhead( - spans: Array<{ start: number; end: number }>, - time: number, -): boolean[] { +function nearPlayhead(spans: Array<{ start: number; end: number }>, time: number): boolean[] { const playing = spans.flatMap((span, i) => (span.start <= time && time <= span.end ? [i] : [])); const before = spans.filter((span) => span.start <= time).length - 1; const lo = playing.length > 0 ? playing[0]! : before; From 17a68b128613cd75f6c8f2a62b1200f5b16a9260 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 24 Oct 2026 13:42:21 -0400 Subject: [PATCH 20/40] perf(core): the preview unmounts scenes the playhead leaves, as kept ones slowed each mount --- .../core/src/runtime/init.swapScenes.test.ts | 22 +------------------ packages/core/src/runtime/init.ts | 11 +++------- packages/core/src/runtime/sceneWindow.ts | 22 ------------------- .../src/services/coreRuntimeBrowser.test.ts | 10 ++++----- 4 files changed, 8 insertions(+), 57 deletions(-) diff --git a/packages/core/src/runtime/init.swapScenes.test.ts b/packages/core/src/runtime/init.swapScenes.test.ts index 1d456d61dac..9422f76997c 100644 --- a/packages/core/src/runtime/init.swapScenes.test.ts +++ b/packages/core/src/runtime/init.swapScenes.test.ts @@ -8,7 +8,7 @@ import { WebAudioTransport } from "./webAudioTransport"; import { probeAndCacheElementVolume } from "./mediaVolumeEnvelope.js"; import { wrapScopedCompositionScript } from "../compiler/compositionScoping"; import { AFTER_FONTS_SCRIPT_TYPE } from "../compiler/scriptRuns"; -import { parkFarScenesAtBoot, setKeptSceneElementsForTests } from "./sceneWindow"; +import { parkFarScenesAtBoot } from "./sceneWindow"; vi.mock("./mediaVolumeEnvelope.js", async (importOriginal) => { const actual = await importOriginal(); @@ -1764,8 +1764,6 @@ describe("the preview's scene window", () => { for (const k of Object.keys(made)) delete made[k]; for (const label of ["a1", "a2", "b"]) made[label] = tl(label); (window as unknown as { __made: typeof made }).__made = made; - // None kept: every scene the window leaves parks, unless a test gives a budget. - keptBudget = setKeptSceneElementsForTests(0); }); afterEach(() => { window.__hfRuntimeTeardown?.(); @@ -1773,9 +1771,7 @@ describe("the preview's scene window", () => { document.body.innerHTML = ""; delete window.__hfSceneAnimations; vi.restoreAllMocks(); - setKeptSceneElementsForTests(keptBudget); }); - let keptBudget = 0; it("parks a scene away from the start, keeping its timing, and never runs its script", async () => { bootWindowed([S0, S1, S2], trackingRoot().root); @@ -1940,22 +1936,6 @@ describe("the preview's scene window", () => { expect(sceneHost("s2").textContent).toBe("two"); }); - it("keeps the scenes the playhead left mounted, most recent first, while they fit the budget", async () => { - // Each scene here is its host and one child: two elements. - setKeptSceneElementsForTests(2); - bootWindowed([S0, S1, S2], trackingRoot().root); - await tick(); - await seekAndSettle(2.5); - for (let i = 0; i < 5; i++) await tick(); - expect(parked()).toEqual([]); - const left = sceneHost("s1"); - await seekAndSettle(5); - for (let i = 0; i < 5; i++) await tick(); - expect(parked()).toEqual(["s0"]); - await seekAndSettle(2.5); - expect(sceneHost("s1")).toBe(left); - }); - const audio = (src: string) => ``; it("lets a scene mounting ahead of a playing film join its mix, leaving what already plays alone", async () => { diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index b0e2479dd7c..64bd1fbdf70 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -10,7 +10,6 @@ import { parkedTimelineScenes, park as parkScene, rememberScene, - scenesToKeep, scenesWanted, unpark, windowedScenes, @@ -4295,8 +4294,6 @@ export function initSandboxRuntimeModular(): void { }; // Scenes Studio holds mounted for selecting or editing, whatever the playhead does. let heldScenes = new Set(); - // Scenes the window wanted, most recently first: those it left stay mounted while they fit the budget. - let recentScenes: string[] = []; moveSceneWindow = (time: number): boolean => { if (windowScenes.length === 0 || state.tornDown) return false; const parked = (name: string) => parkedNames.has(name); @@ -4308,16 +4305,14 @@ export function initSandboxRuntimeModular(): void { for (const name of heldScenes) near.add(name); const key = [...near].join("\n"); if (key === windowNear) return landed !== undefined; - recentScenes = [...near, ...recentScenes.filter((name) => !near.has(name))]; // One step at a time, planned again after each, so a later seek or scrub overtakes the rest. const neighbour = windowScenes .filter(({ name }) => near.has(name) && parked(name)) .sort((a, b) => Math.abs(a.start - time) - Math.abs(b.start - time))[0]; const toMount = landed ?? neighbour; - const kept = toMount ? null : scenesToKeep(recentScenes.filter((name) => !near.has(name))); - const toRetire = windowScenes.filter( - ({ name }) => kept && !near.has(name) && !kept.has(name) && !parked(name), - ); + const toRetire = toMount + ? [] + : windowScenes.filter(({ name }) => !near.has(name) && !parked(name)); if (!toMount && toRetire.length === 0) { windowNear = key; return false; diff --git a/packages/core/src/runtime/sceneWindow.ts b/packages/core/src/runtime/sceneWindow.ts index 18d55ea6e20..3e605bfedf0 100644 --- a/packages/core/src/runtime/sceneWindow.ts +++ b/packages/core/src/runtime/sceneWindow.ts @@ -64,28 +64,6 @@ export function scenesWanted(scenes: WindowedScene[], time: number, playing: boo return new Set(scenes.filter(wanted).map((s) => s.name)); } -// At most about 120 MB at the ~2 KB of renderer memory per element a long film measured. -let keptSceneElements = 60_000; -export const setKeptSceneElementsForTests = (elements: number): number => { - const was = keptSceneElements; - keptSceneElements = elements; - return was; -}; - -/** Of the mounted scenes the playhead left, most recent first, those that stay mounted within the element budget. */ -export function scenesToKeep(recentFirst: string[]): Set { - const kept = new Set(); - let elements = 0; - for (const name of recentFirst) { - const host = hostOf(name); - if (!host || isParked(host)) continue; - elements += host.getElementsByTagName("*").length + 1; - if (elements > keptSceneElements) break; - kept.add(name); - } - return kept; -} - export const hostOf = (name: string): Element | null => document.querySelector(`${partsSelector(name)}:not(style, script)`); diff --git a/packages/producer/src/services/coreRuntimeBrowser.test.ts b/packages/producer/src/services/coreRuntimeBrowser.test.ts index a693e155292..94db24e9d2a 100644 --- a/packages/producer/src/services/coreRuntimeBrowser.test.ts +++ b/packages/producer/src/services/coreRuntimeBrowser.test.ts @@ -1344,8 +1344,6 @@ describe("core runtime browser contract", () => { }); try { const scenes = [0, 1, 2, 3, 4, 5]; - // Over half the preview's kept-scene budget each, so of the scenes the playhead left only the latest stays. - const filler = `

${"".repeat(31_000)}

`; const hosts = scenes .map( (i) => @@ -1360,7 +1358,7 @@ describe("core runtime browser contract", () => { for (const i of scenes) writeFileSync( resolve(dir, `s${i}.html`), - ``, + ``, ); const html = addScenePartsManifest( await bundleToSingleHtml(dir, { sceneParts: true, staticGuard: false }), @@ -1408,13 +1406,13 @@ describe("core runtime browser contract", () => { }); // The window unmounts only once it has mounted what is ahead. await page.waitForFunction( - () => document.querySelectorAll("[data-hf-parked-scene]").length >= 4, + () => document.querySelectorAll("[data-hf-parked-scene]").length >= 5, ); - // Four scenes unmounted, each over 30k nodes. Chrome keeps a captured element alive while + // Five scenes went out of the window, each over 800 nodes. Chrome keeps a captured element alive while // its audio context lives, so each scene's audio element stays, but nothing of the scene around it. expect( await page.evaluate(() => document.querySelectorAll("[data-hf-parked-scene]").length), - ).toBe(4); + ).toBe(5); expect((await kept()) - before).toBeLessThan(800); } finally { await audioBrowser.close(); From 0a2885458da082ba972da457e2a116cc6ded2c35 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 24 Oct 2026 17:23:29 -0400 Subject: [PATCH 21/40] fix(core): scenes that measure text, call their own animate or wait on font loads can unmount --- .../core/src/compiler/htmlBundler.test.ts | 53 +++++++++++++++++++ .../src/compiler/inlineSubCompositions.ts | 53 ++++++++++++++++--- 2 files changed, 100 insertions(+), 6 deletions(-) diff --git a/packages/core/src/compiler/htmlBundler.test.ts b/packages/core/src/compiler/htmlBundler.test.ts index a929713d178..97539808603 100644 --- a/packages/core/src/compiler/htmlBundler.test.ts +++ b/packages/core/src/compiler/htmlBundler.test.ts @@ -3525,6 +3525,59 @@ describe("bundleToSingleHtml sceneParts", () => { }); }); + it("lets a scene measure text on an unattached canvas, call its own animate and wait on font loads, and nothing more", async () => { + const measure = `let ctx = null; window.measure = (s) => { if (!ctx) { ctx = document.createElement("canvas").getContext("2d"); } ctx.font = "800 46px Outfit"; return ctx.measureText(s).width; };`; + const scenes: Record = { + measureCanvas: ``, + ownAnimate: ``, + fontLoads: ``, + fontLoadThen: ``, + fontLoadAwait: ``, + namedCanvas: ``, + canvasBack: ``, + webglCanvas: ``, + browserAnimate: ``, + spreadAnimate: ``, + otherPromise: ``, + dynamicFontLoad: ``, + }; + const names = Object.keys(scenes); + const dir = makeTempProject({ + "index.html": ` +
+ ${names.map((n) => `
`).join("\n")} +
`, + ...Object.fromEntries( + names.map((n) => [ + `compositions/${n}.html`, + ``, + ]), + ), + }); + const doc = parseHTML(await bundleToSingleHtml(dir, { sceneParts: true })).document; + const refusals = Object.fromEntries( + names.map((n) => [ + n, + doc.querySelector(`div[data-hf-scene="${n}"]`)?.getAttribute("data-hf-scene-no-swap") ?? + null, + ]), + ); + expect(refusals).toEqual({ + measureCanvas: null, + ownAnimate: null, + fontLoads: null, + fontLoadThen: null, + fontLoadAwait: null, + namedCanvas: "its script uses getContext", + canvasBack: "its script uses getContext", + webglCanvas: "its script uses getContext", + browserAnimate: "its script uses .animate(", + spreadAnimate: "its script uses .animate(", + otherPromise: "its script uses Promise", + dynamicFontLoad: "its script uses .then(", + }); + }); + const rootProject = (root: string, extra: Record = {}) => makeTempProject({ "index.html": ` diff --git a/packages/core/src/compiler/inlineSubCompositions.ts b/packages/core/src/compiler/inlineSubCompositions.ts index ae60f1328cd..2bb73e6c9ca 100644 --- a/packages/core/src/compiler/inlineSubCompositions.ts +++ b/packages/core/src/compiler/inlineSubCompositions.ts @@ -50,7 +50,7 @@ import { SCENE_NO_SWAP_ATTR, SCENE_PART_ATTR } from "../sceneParts"; // Anything a scene script can leave running, pending or registered outside its timeline, or that // throws when run again: only the timeline is torn down when a scene is swapped, so when unsure, refuse. const SIDE_EFFECT_RE = - /\b(addEventListener|requestAnimationFrame|requestIdleCallback|setTimeout|setInterval|queueMicrotask|getContext|WebGL\w*|WebGPU\w*|gpu|Worker|WebSocket|EventSource|Audio\w*|\w*Observer|fetch|import|eval|Function|Promise|async|await|delayedCall|ScrollTrigger|Draggable|anime|customElements|registerProperty|addListener|BroadcastChannel|pushState|replaceState|adoptedStyleSheets|documentElement|getElementsByTagName|lottie|THREE|__hf[A-Z]\w*)\b|\.then\s*\(|\.animate\s*\(|\.ticker\b|repeat\s*:\s*-1|\.repeat\s*\(\s*-1|defineProperty\s*\(\s*(window|globalThis|self|document)\b|\bfonts\s*\.\s*add\b|\.on[a-z]+\s*=(?!=)|\bon(resize|scroll|message|key\w+|click|pointer\w+|mouse\w+|wheel|visibilitychange|hashchange|popstate|error|load)\s*=(?!=)|\[\s*["']on[a-z]+["']\s*\]|document\s*\.\s*(head|body)\b|querySelector(All)?\(\s*["'](head|body)["']|\bgsap\s*\.\s*(?:to|from|fromTo)\s*\(|\bgsap\s*\.\s*timeline\s*\((?:[^()]|\([^()]*\))*\)\s*\.\s*(?:to|from|fromTo|set|add|call)\s*\(/; + /\b(addEventListener|requestAnimationFrame|requestIdleCallback|setTimeout|setInterval|queueMicrotask|getContext|WebGL\w*|WebGPU\w*|gpu|Worker|WebSocket|EventSource|Audio\w*|\w*Observer|fetch|import|eval|Function|Promise|async|await|delayedCall|ScrollTrigger|Draggable|anime|customElements|registerProperty|addListener|BroadcastChannel|pushState|replaceState|adoptedStyleSheets|documentElement|getElementsByTagName|lottie|THREE|__hf[A-Z]\w*)\b|\.then\s*\(|\.ticker\b|repeat\s*:\s*-1|\.repeat\s*\(\s*-1|defineProperty\s*\(\s*(window|globalThis|self|document)\b|\bfonts\s*\.\s*add\b|\.on[a-z]+\s*=(?!=)|\bon(resize|scroll|message|key\w+|click|pointer\w+|mouse\w+|wheel|visibilitychange|hashchange|popstate|error|load)\s*=(?!=)|\[\s*["']on[a-z]+["']\s*\]|document\s*\.\s*(head|body)\b|querySelector(All)?\(\s*["'](head|body)["']|\bgsap\s*\.\s*(?:to|from|fromTo)\s*\(|\bgsap\s*\.\s*timeline\s*\((?:[^()]|\([^()]*\))*\)\s*\.\s*(?:to|from|fromTo|set|add|call)\s*\(/; // npm packages that only define globals when loaded; lottie-web is absent because it scans the page on load. const SWAP_SAFE_LIBRARY_URL = @@ -58,16 +58,57 @@ const SWAP_SAFE_LIBRARY_URL = const isSwapSafeLibrary = (src: string) => URL.canParse(src) && SWAP_SAFE_LIBRARY_URL.test(new URL(src).href); -const FONT_READY_WAIT_RE = - /\bdocument\s*\.\s*fonts\s*\.\s*ready\s*\.\s*then\s*\(|\bawait\s+document\s*\.\s*fonts\s*\.\s*ready\b/g; +// Waits on the page's fonts, ready or loading named faces: each settles once, leaving nothing behind. +const STRING_ARG = String.raw`(?:"(?:\\.|[^"\\\n])*"|'(?:\\.|[^'\\\n])*')`; +const FONT_LOAD = String.raw`document\s*\.\s*fonts\s*\.\s*load\s*\(\s*${STRING_ARG}\s*(?:,\s*${STRING_ARG}\s*)?\)`; +const FONT_PROMISE = String.raw`(?:document\s*\.\s*fonts\s*\.\s*ready\b|${FONT_LOAD}|Promise\s*\.\s*all\s*\(\s*\[\s*${FONT_LOAD}(?:\s*,\s*${FONT_LOAD})*\s*,?\s*\]\s*\))`; +const FONT_WAIT_RE = new RegExp( + String.raw`\b${FONT_PROMISE}\s*\.\s*then\s*\(|\bawait\s+${FONT_PROMISE}`, + "g", +); + +// A canvas made only to measure text: never named, so nothing attaches it to the page or draws on it there. +const TEXT_MEASURE_CANVAS_RE = + /\bdocument\s*\.\s*createElement\s*\(\s*(["'])canvas\1\s*\)\s*\.\s*getContext\s*\(\s*(["'])2d\2\s*\)/g; + +/** Whether a call of the browser's Element.animate may leave an animation: it gets timing, its second argument. */ +function lastingAnimateCall(script: string): boolean { + for (const call of script.matchAll(/\.animate\s*\(/g)) { + if (topLevelArgs(script, call.index + call[0].length) !== 1) return true; + } + return false; +} + +// Arguments of the call whose "(" ends before `from`; none, a spread or an unclosed call never count as one. +function topLevelArgs(script: string, from: number): number { + let depth = 0; + let args = 1; + if (/^\s*(\.\.\.|\))/.test(script.slice(from))) return Number.POSITIVE_INFINITY; + for (let i = from; i < script.length; i++) { + const ch = script[i]!; + if (ch === '"' || ch === "'" || ch === "`") { + const end = script.indexOf(ch, i + 1); + if (end < 0) return Number.POSITIVE_INFINITY; + i = end; + } else if ("([{".includes(ch)) depth++; + else if (")]}".includes(ch)) { + if (depth === 0) return args; + depth--; + } else if (ch === "," && depth === 0) args++; + } + return Number.POSITIVE_INFINITY; +} /** Why an authored scene script cannot be swapped out cleanly, or null when it can. */ function sceneScriptSwapRefusal(script: string): string | null { - const withoutFontWaits = script.replace(FONT_READY_WAIT_RE, ""); + const withoutFontWaits = script.replace(FONT_WAIT_RE, ""); const awaitedFontsOnly = /\bawait\b/.test(script) && !/\bawait\b/.test(withoutFontWaits); - const checked = awaitedFontsOnly ? withoutFontWaits.replace(/\basync\b/g, "") : withoutFontWaits; + let checked = awaitedFontsOnly ? withoutFontWaits.replace(/\basync\b/g, "") : withoutFontWaits; + // The context hands its canvas back through .canvas, which could then reach the page. + if (!/\.\s*canvas\b/.test(checked)) checked = checked.replace(TEXT_MEASURE_CANVAS_RE, ""); const match = SIDE_EFFECT_RE.exec(checked); - return match ? `its script uses ${match[0].trim()}` : null; + if (match) return `its script uses ${match[0].trim()}`; + return lastingAnimateCall(checked) ? "its script uses .animate(" : null; } const projectScriptRefusal = (text: string | null) => From 695c74f2178d001952430ff7ee63c9e5021770d0 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 24 Oct 2026 17:45:01 -0400 Subject: [PATCH 22/40] fix(core): each preview scene carries the helpers it loads, so it mounts on its own --- .../core/src/compiler/htmlBundler.test.ts | 27 +++++++++++++++++++ packages/core/src/compiler/htmlBundler.ts | 6 +++-- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/packages/core/src/compiler/htmlBundler.test.ts b/packages/core/src/compiler/htmlBundler.test.ts index 97539808603..a308b8e33a7 100644 --- a/packages/core/src/compiler/htmlBundler.test.ts +++ b/packages/core/src/compiler/htmlBundler.test.ts @@ -3525,6 +3525,33 @@ describe("bundleToSingleHtml sceneParts", () => { }); }); + it("gives each scene its own copy of a helper it shares, so a scene mounts without the one that loaded it first", async () => { + const dir = makeTempProject({ + "index.html": ` +
+
+
+
`, + "lib/kit.js": `window.Kit = { build: function (id) { return id; } };`, + ...Object.fromEntries( + ["late", "early"].map((n) => [ + `compositions/${n}.html`, + ``, + ]), + ), + }); + const doc = parseHTML(await bundleToSingleHtml(dir, { sceneParts: true })).document; + const runs = (scene: string) => + [...doc.querySelectorAll(`script[data-hf-scene="${scene}"]`)] + .map((el) => el.textContent) + .join(""); + expect([runs("late").includes("window.Kit ="), runs("early").includes("window.Kit =")]).toEqual( + [true, true], + ); + const rendered = parseHTML(await bundleToSingleHtml(dir)).document.documentElement.outerHTML; + expect(rendered.match(/window\.Kit =/g)).toHaveLength(1); + }); + it("lets a scene measure text on an unattached canvas, call its own animate and wait on font loads, and nothing more", async () => { const measure = `let ctx = null; window.measure = (s) => { if (!ctx) { ctx = document.createElement("canvas").getContext("2d"); } ctx.font = "800 46px Outfit"; return ctx.measureText(s).width; };`; const scenes: Record = { diff --git a/packages/core/src/compiler/htmlBundler.ts b/packages/core/src/compiler/htmlBundler.ts index 9bf6317f289..599a4be6d33 100644 --- a/packages/core/src/compiler/htmlBundler.ts +++ b/packages/core/src/compiler/htmlBundler.ts @@ -1254,8 +1254,10 @@ async function bundleProject(projectDir: string, options?: BundleOptions): Promi seenCompScriptSrcs.add(extSrc); continue; } - if (seenCompScriptSrcs.has(extSrc)) continue; - seenCompScriptSrcs.add(extSrc); + // A scene mounts and runs alone, so each scene keeps its own copy of what it loads. + const seenKey = scriptItem.scene === undefined ? extSrc : `${scriptItem.scene}\n${extSrc}`; + if (seenCompScriptSrcs.has(seenKey)) continue; + seenCompScriptSrcs.add(seenKey); if (isRelativeUrl(extSrc) && scriptItem.type !== "module") { const jsPath = resolveEntryUrl(extSrc); const js = jsPath ? safeReadFile(jsPath) : null; From b80688c7a4a3f01b5193d1f6522998a7b63877f5 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 24 Oct 2026 18:14:45 -0400 Subject: [PATCH 23/40] fix(core): a mounted scene shows though its stand-in was hidden by display --- packages/core/src/runtime/init.swapScenes.test.ts | 10 ++++++++++ packages/core/src/runtime/init.ts | 5 ++++- packages/core/src/runtime/sceneWindow.ts | 6 ++++-- 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/packages/core/src/runtime/init.swapScenes.test.ts b/packages/core/src/runtime/init.swapScenes.test.ts index 9422f76997c..7a43bea878a 100644 --- a/packages/core/src/runtime/init.swapScenes.test.ts +++ b/packages/core/src/runtime/init.swapScenes.test.ts @@ -1829,6 +1829,16 @@ describe("the preview's scene window", () => { expect(sceneHost("s2").getAttribute("style")).toContain("visibility: visible"); }); + it("shows a mounted scene whose empty stand-in the visibility pass hid by display", async () => { + // In flow, an empty stand-in is a leaf clip, which the visibility pass hides with display:none. + bootWindowed([S0, S1, { ...S2, extraAttrs: ' style="position: static"' }], trackingRoot().root); + await tick(); + expect(sceneHost("s2").getAttribute("style")).toContain("display: none"); + await seekAndSettle(5); + expect(sceneHost("s2").textContent).toBe("two"); + expect(sceneHost("s2").getAttribute("style")).not.toContain("display: none"); + }); + it("mounts a scene Studio holds for editing and keeps it until Studio lets go", async () => { bootWindowed([S0, S1, S2], trackingRoot().root); await tick(); diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index 64bd1fbdf70..da65da92c25 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -4269,7 +4269,10 @@ export function initSandboxRuntimeModular(): void { const mountScene = async (name: string) => { const unparked = unpark(name); if (!unparked) return; - const { host, scripts } = unparked; + const { host, placeholder, scripts } = unparked; + // The host took the stand-in's style, a display:none the visibility pass gave it included; showing undoes it. + const hidden = displayBeforeHide.get(placeholder as StylableElement); + if (hidden) displayBeforeHide.set(host as StylableElement, hidden); recordAuthoredMedia(host); refreshSvgSelectorAliases(); const captionHosts = host.querySelector(".caption-group") ? [host] : []; diff --git a/packages/core/src/runtime/sceneWindow.ts b/packages/core/src/runtime/sceneWindow.ts index 3e605bfedf0..09b22e20602 100644 --- a/packages/core/src/runtime/sceneWindow.ts +++ b/packages/core/src/runtime/sceneWindow.ts @@ -81,7 +81,9 @@ export function park(name: string): Element | null { } /** Puts a parked scene back as the page first had it, with what the runtime wrote on its host; returns its scripts. */ -export function unpark(name: string): { host: Element; scripts: Element[] } | null { +export function unpark( + name: string, +): { host: Element; placeholder: Element; scripts: Element[] } | null { const placeholder = hostOf(name); const parts = pristine.get(name); if (!placeholder || !isParked(placeholder) || !parts) return null; @@ -91,7 +93,7 @@ export function unpark(name: string): { host: Element; scripts: Element[] } | nu for (const { name: attr, value } of Array.from(placeholder.attributes)) if (attr !== PARKED) host.setAttribute(attr, value); placeholder.replaceWith(host); - return { host, scripts: parts.scripts.map((el) => el.cloneNode(true) as Element) }; + return { host, placeholder, scripts: parts.scripts.map((el) => el.cloneNode(true) as Element) }; } /** From 72a6589d68c637aa69b45842b3004f71867a95ce Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 24 Oct 2026 19:12:41 -0400 Subject: [PATCH 24/40] fix(core): a helper scenes share runs once for the film, and a remounted scene keeps showing --- .../core/src/compiler/htmlBundler.test.ts | 24 ++++++++----------- packages/core/src/compiler/htmlBundler.ts | 14 +++++++---- .../core/src/runtime/init.swapScenes.test.ts | 15 ++++++++++++ packages/core/src/runtime/init.ts | 12 ++++++---- 4 files changed, 42 insertions(+), 23 deletions(-) diff --git a/packages/core/src/compiler/htmlBundler.test.ts b/packages/core/src/compiler/htmlBundler.test.ts index a308b8e33a7..cc03e1eb472 100644 --- a/packages/core/src/compiler/htmlBundler.test.ts +++ b/packages/core/src/compiler/htmlBundler.test.ts @@ -3525,7 +3525,7 @@ describe("bundleToSingleHtml sceneParts", () => { }); }); - it("gives each scene its own copy of a helper it shares, so a scene mounts without the one that loaded it first", async () => { + it("runs a helper scenes share once for the film, so a scene mounts without the one that loaded it first", async () => { const dir = makeTempProject({ "index.html": `
@@ -3533,21 +3533,17 @@ describe("bundleToSingleHtml sceneParts", () => {
`, "lib/kit.js": `window.Kit = { build: function (id) { return id; } };`, - ...Object.fromEntries( - ["late", "early"].map((n) => [ - `compositions/${n}.html`, - ``, - ]), - ), + "lib/own.js": `window.Own = 1;`, + "compositions/late.html": ``, + "compositions/early.html": ``, }); const doc = parseHTML(await bundleToSingleHtml(dir, { sceneParts: true })).document; - const runs = (scene: string) => - [...doc.querySelectorAll(`script[data-hf-scene="${scene}"]`)] - .map((el) => el.textContent) - .join(""); - expect([runs("late").includes("window.Kit ="), runs("early").includes("window.Kit =")]).toEqual( - [true, true], - ); + const texts = (selector: string) => + [...doc.querySelectorAll(selector)].map((el) => el.textContent ?? "").join("\n"); + const film = texts("script:not([data-hf-scene])"); + expect(film.match(/window\.Kit =/g)).toHaveLength(1); + expect(texts('script[data-hf-scene="late"]')).toContain("window.Own = 1"); + expect(texts("script[data-hf-scene]")).not.toContain("window.Kit ="); const rendered = parseHTML(await bundleToSingleHtml(dir)).document.documentElement.outerHTML; expect(rendered.match(/window\.Kit =/g)).toHaveLength(1); }); diff --git a/packages/core/src/compiler/htmlBundler.ts b/packages/core/src/compiler/htmlBundler.ts index 599a4be6d33..c85a032ca6d 100644 --- a/packages/core/src/compiler/htmlBundler.ts +++ b/packages/core/src/compiler/htmlBundler.ts @@ -1243,6 +1243,10 @@ async function bundleProject(projectDir: string, options?: BundleOptions): Promi ...subCompResult.variablesByComp, }; const seenCompScriptSrcs = new Set(); + const scenesBySrc = new Map>(); + for (const item of subCompResult.scriptItems) + if (item.kind !== "inline") + scenesBySrc.set(item.src, (scenesBySrc.get(item.src) ?? new Set()).add(item.scene)); for (const scriptItem of subCompResult.scriptItems) { if (scriptItem.kind === "inline") { pushRun(scriptRuns, scriptItem.scene, scriptItem.content); @@ -1254,17 +1258,17 @@ async function bundleProject(projectDir: string, options?: BundleOptions): Promi seenCompScriptSrcs.add(extSrc); continue; } - // A scene mounts and runs alone, so each scene keeps its own copy of what it loads. - const seenKey = scriptItem.scene === undefined ? extSrc : `${scriptItem.scene}\n${extSrc}`; - if (seenCompScriptSrcs.has(seenKey)) continue; - seenCompScriptSrcs.add(seenKey); + if (seenCompScriptSrcs.has(extSrc)) continue; + seenCompScriptSrcs.add(extSrc); + // Run once for the film when scenes share it: a scene mounts without the one that loaded it first. + const scene = (scenesBySrc.get(extSrc)?.size ?? 0) > 1 ? undefined : scriptItem.scene; if (isRelativeUrl(extSrc) && scriptItem.type !== "module") { const jsPath = resolveEntryUrl(extSrc); const js = jsPath ? safeReadFile(jsPath) : null; if (js != null) { const chunk = () => preserveLocalScriptIntegrity(document, extSrc, resolveEntryUrl) ? "" : js; - pushRun(scriptRuns, scriptItem.scene, chunk); + pushRun(scriptRuns, scene, chunk); continue; } } diff --git a/packages/core/src/runtime/init.swapScenes.test.ts b/packages/core/src/runtime/init.swapScenes.test.ts index 7a43bea878a..12a71c144df 100644 --- a/packages/core/src/runtime/init.swapScenes.test.ts +++ b/packages/core/src/runtime/init.swapScenes.test.ts @@ -1839,6 +1839,21 @@ describe("the preview's scene window", () => { expect(sceneHost("s2").getAttribute("style")).not.toContain("display: none"); }); + it("shows a scene mounted again after the visibility pass hid it by display and it unmounted", async () => { + const leaf: Scene = { ...S2, body: "

two

", extraAttrs: ' style="position: static"' }; + bootWindowed([S0, S1, leaf], trackingRoot().root); + await tick(); + // Paused in the middle scene, the last one mounts as next, hidden until it plays. + await seekAndSettle(2.5); + for (let i = 0; i < 5; i++) await tick(); + expect(sceneHost("s2").getAttribute("style")).toContain("display: none"); + await seekAndSettle(0.5); + for (let i = 0; i < 5; i++) await tick(); + expect(parked()).toContain("s2"); + await seekAndSettle(5); + expect(sceneHost("s2").getAttribute("style")).not.toContain("display: none"); + }); + it("mounts a scene Studio holds for editing and keeps it until Studio lets go", async () => { bootWindowed([S0, S1, S2], trackingRoot().root); await tick(); diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index da65da92c25..4a31ff94773 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -4265,14 +4265,17 @@ export function initSandboxRuntimeModular(): void { let windowWork: Promise | null = null; let windowNear = ""; const nextTask = () => new Promise((resolve) => window.setTimeout(resolve, 0)); + // A host and its stand-in trade style, a display:none the visibility pass wrote included; the way back goes along. + const moveDisplayHide = (from: Element, to: Element) => { + const hidden = displayBeforeHide.get(from as StylableElement); + if (hidden) displayBeforeHide.set(to as StylableElement, hidden); + }; // In three tasks, so frames draw between them; the scene counts as parked, its last frame held, until it settles. const mountScene = async (name: string) => { const unparked = unpark(name); if (!unparked) return; const { host, placeholder, scripts } = unparked; - // The host took the stand-in's style, a display:none the visibility pass gave it included; showing undoes it. - const hidden = displayBeforeHide.get(placeholder as StylableElement); - if (hidden) displayBeforeHide.set(host as StylableElement, hidden); + moveDisplayHide(placeholder, host); recordAuthoredMedia(host); refreshSvgSelectorAliases(); const captionHosts = host.querySelector(".caption-group") ? [host] : []; @@ -4287,7 +4290,8 @@ export function initSandboxRuntimeModular(): void { const host = sceneHostOf(name); if (!host || isParked(host)) return; stopSceneAnimations([host]); - parkScene(name); + const placeholder = parkScene(name); + if (placeholder) moveDisplayHide(host, placeholder); parkedNames.add(name); releaseDetachedMedia(); webAudio.releaseDecodedExcept(webAudioMediaIn(document)); From 7f6030a8995046257ddd10c3f6a26a5774e22fab Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 24 Oct 2026 22:55:47 -0400 Subject: [PATCH 25/40] fix(core): a scene that also publishes its timeline under another id stays mounted --- .../core/src/runtime/init.swapScenes.test.ts | 19 +++++++++++++++++++ packages/core/src/runtime/init.ts | 15 ++++++++++++++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/packages/core/src/runtime/init.swapScenes.test.ts b/packages/core/src/runtime/init.swapScenes.test.ts index 12a71c144df..ed02070be4c 100644 --- a/packages/core/src/runtime/init.swapScenes.test.ts +++ b/packages/core/src/runtime/init.swapScenes.test.ts @@ -1839,6 +1839,25 @@ describe("the preview's scene window", () => { expect(sceneHost("s2").getAttribute("style")).not.toContain("display: none"); }); + it("keeps a scene mounted whose script published its timeline under another id as well", async () => { + bootWindowed([S0, S1, S2], trackingRoot().root); + await tick(); + // What the recorder keeps of s0's script, which also published its timeline as "elsewhere". + window.__hfSceneAnimations = { s0: [made.a1] }; + window.__timelines.elsewhere = made.a1; + try { + await seekAndSettle(5); + expect(parked()).toEqual(["s1"]); + expect(sceneHost("s0").getAttribute("data-hf-scene-no-swap")).toBe( + "its script registers a timeline as elsewhere", + ); + expect(window.__timelines.elsewhere).toBe(made.a1); + expect(made.a1.kill).not.toHaveBeenCalled(); + } finally { + delete window.__hfSceneAnimations; + } + }); + it("shows a scene mounted again after the visibility pass hid it by display and it unmounted", async () => { const leaf: Scene = { ...S2, body: "

two

", extraAttrs: ' style="position: static"' }; bootWindowed([S0, S1, leaf], trackingRoot().root); diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index 4a31ff94773..14629a03b2d 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -4286,9 +4286,22 @@ export function initSandboxRuntimeModular(): void { parkedNames.delete(name); if (!state.tornDown) settleChangedScenes([host], captionHosts, captionOverrides, true); }; + // Unmounting stops a scene's timelines but would leave one it also published under another id registered there. + const timelinePublishedElsewhere = (host: Element) => { + const ids = compositionIdsIn(host); + const own = new Set(ids.flatMap((id) => window.__hfSceneAnimations?.[id] ?? [])); + return Object.entries(window.__timelines ?? {}).find( + ([id, timeline]) => own.has(timeline) && !ids.includes(id), + )?.[0]; + }; const retireScene = (name: string) => { const host = sceneHostOf(name); - if (!host || isParked(host)) return; + if (!host || isParked(host) || host.hasAttribute(SCENE_NO_SWAP_ATTR)) return; + const elsewhere = timelinePublishedElsewhere(host); + if (elsewhere !== undefined) { + host.setAttribute(SCENE_NO_SWAP_ATTR, `its script registers a timeline as ${elsewhere}`); + return; + } stopSceneAnimations([host]); const placeholder = parkScene(name); if (placeholder) moveDisplayHide(host, placeholder); From 9883d96f42ad47c4e52f12c316d49bed8d0b09d0 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 24 Oct 2026 22:55:48 -0400 Subject: [PATCH 26/40] feat(core): scenes that draw in 2D or chain the timeline they register can unmount --- .../core/src/compiler/htmlBundler.test.ts | 30 +++++- .../src/compiler/inlineSubCompositions.ts | 14 +-- .../src/services/coreRuntimeBrowser.test.ts | 96 +++++++++++++++++++ 3 files changed, 130 insertions(+), 10 deletions(-) diff --git a/packages/core/src/compiler/htmlBundler.test.ts b/packages/core/src/compiler/htmlBundler.test.ts index cc03e1eb472..63ac1d58189 100644 --- a/packages/core/src/compiler/htmlBundler.test.ts +++ b/packages/core/src/compiler/htmlBundler.test.ts @@ -3548,7 +3548,7 @@ describe("bundleToSingleHtml sceneParts", () => { expect(rendered.match(/window\.Kit =/g)).toHaveLength(1); }); - it("lets a scene measure text on an unattached canvas, call its own animate and wait on font loads, and nothing more", async () => { + it("lets a scene draw in 2D, chain a timeline into the registry, call its own animate and wait on font loads, and nothing more", async () => { const measure = `let ctx = null; window.measure = (s) => { if (!ctx) { ctx = document.createElement("canvas").getContext("2d"); } ctx.font = "800 46px Outfit"; return ctx.measureText(s).width; };`; const scenes: Record = { measureCanvas: ``, @@ -3558,7 +3558,18 @@ describe("bundleToSingleHtml sceneParts", () => { fontLoadAwait: ``, namedCanvas: ``, canvasBack: ``, + drawOnStage: ``, + registeredChain: ``, + registeredChainByKey: ``, + unregisteredChain: ``, + chainKeptAside: ``, + registeredChainAndTween: ``, webglCanvas: ``, + webgl2Canvas: ``, + experimentalWebgl: ``, + webgpuCanvas: ``, + bitmapCanvas: ``, + dynamicContext: ``, browserAnimate: ``, spreadAnimate: ``, otherPromise: ``, @@ -3573,7 +3584,7 @@ describe("bundleToSingleHtml sceneParts", () => { ...Object.fromEntries( names.map((n) => [ `compositions/${n}.html`, - ``, + ``, ]), ), }); @@ -3591,9 +3602,20 @@ describe("bundleToSingleHtml sceneParts", () => { fontLoads: null, fontLoadThen: null, fontLoadAwait: null, - namedCanvas: "its script uses getContext", - canvasBack: "its script uses getContext", + namedCanvas: null, + canvasBack: null, + drawOnStage: null, + registeredChain: null, + registeredChainByKey: null, + unregisteredChain: "its script uses gsap.timeline().to(", + chainKeptAside: "its script uses gsap.timeline({ paused: true }).to(", + registeredChainAndTween: "its script uses gsap.to(", webglCanvas: "its script uses getContext", + webgl2Canvas: "its script uses getContext", + experimentalWebgl: "its script uses getContext", + webgpuCanvas: "its script uses getContext", + bitmapCanvas: "its script uses getContext", + dynamicContext: "its script uses getContext", browserAnimate: "its script uses .animate(", spreadAnimate: "its script uses .animate(", otherPromise: "its script uses Promise", diff --git a/packages/core/src/compiler/inlineSubCompositions.ts b/packages/core/src/compiler/inlineSubCompositions.ts index 2bb73e6c9ca..cb001980a9b 100644 --- a/packages/core/src/compiler/inlineSubCompositions.ts +++ b/packages/core/src/compiler/inlineSubCompositions.ts @@ -67,9 +67,11 @@ const FONT_WAIT_RE = new RegExp( "g", ); -// A canvas made only to measure text: never named, so nothing attaches it to the page or draws on it there. -const TEXT_MEASURE_CANVAS_RE = - /\bdocument\s*\.\s*createElement\s*\(\s*(["'])canvas\1\s*\)\s*\.\s*getContext\s*\(\s*(["'])2d\2\s*\)/g; +// A 2D context keeps no GPU state or callback: a remount runs the script again, which draws again. +const TWO_D_CONTEXT_RE = /\bgetContext\s*\(\s*(["'])2d\1\s*\)/g; +// A timeline chained as it is registered: the registry holds it, so unmounting the scene stops it. +const REGISTERED_CHAIN_RE = + /(\b(?:window\s*\.\s*)?__timelines\s*\[[^\[\]]*\]\s*=\s*)gsap\s*\.\s*timeline\s*\(/g; /** Whether a call of the browser's Element.animate may leave an animation: it gets timing, its second argument. */ function lastingAnimateCall(script: string): boolean { @@ -103,9 +105,9 @@ function topLevelArgs(script: string, from: number): number { function sceneScriptSwapRefusal(script: string): string | null { const withoutFontWaits = script.replace(FONT_WAIT_RE, ""); const awaitedFontsOnly = /\bawait\b/.test(script) && !/\bawait\b/.test(withoutFontWaits); - let checked = awaitedFontsOnly ? withoutFontWaits.replace(/\basync\b/g, "") : withoutFontWaits; - // The context hands its canvas back through .canvas, which could then reach the page. - if (!/\.\s*canvas\b/.test(checked)) checked = checked.replace(TEXT_MEASURE_CANVAS_RE, ""); + const checked = (awaitedFontsOnly ? withoutFontWaits.replace(/\basync\b/g, "") : withoutFontWaits) + .replace(TWO_D_CONTEXT_RE, "") + .replace(REGISTERED_CHAIN_RE, "$1("); const match = SIDE_EFFECT_RE.exec(checked); if (match) return `its script uses ${match[0].trim()}`; return lastingAnimateCall(checked) ? "its script uses .animate(" : null; diff --git a/packages/producer/src/services/coreRuntimeBrowser.test.ts b/packages/producer/src/services/coreRuntimeBrowser.test.ts index 94db24e9d2a..cff590e441b 100644 --- a/packages/producer/src/services/coreRuntimeBrowser.test.ts +++ b/packages/producer/src/services/coreRuntimeBrowser.test.ts @@ -1420,6 +1420,102 @@ describe("core runtime browser contract", () => { } }, 60_000); + it("draws a mounted-again scene's 2D canvas as it first did, drawn once at load or on each update", async () => { + const dir = mkdtempSync(resolve(tmpdir(), "scene-window-canvas-")); + const scenePage = await browser.newPage(); + try { + const scripts = [ + // Drawn once, as the script runs. + `const g = document.querySelector("canvas").getContext('2d'); g.fillStyle = "#c33"; g.fillRect(10, 10, 60, 30); g.font = "20px sans-serif"; g.fillText("once", 20, 80); const tl = gsap.timeline({ paused: true }); tl.to({ t: 0 }, { t: 1, duration: 2 }); window.__timelines["s0"] = tl;`, + // Cleared and drawn again by its timeline, chained as it is registered. + `const g = document.querySelector("canvas").getContext('2d'); const clock = { t: 0 }; window.__timelines["s1"] = gsap.timeline({ paused: true, onUpdate: () => { g.clearRect(0, 0, 320, 180); g.fillStyle = "#36c"; g.fillRect(20 + clock.t * 50, 20, 30, 30); } }).to(clock, { t: 2, duration: 2, ease: "none" });`, + `window.__timelines["s2"] = gsap.timeline({ paused: true });`, + ]; + writeFileSync( + resolve(dir, "index.html"), + `
${scripts + .map( + (_, i) => + `
`, + ) + .join("")}
`, + ); + scripts.forEach((script, i) => + writeFileSync( + resolve(dir, `s${i}.html`), + ``, + ), + ); + const html = addScenePartsManifest( + await bundleToSingleHtml(dir, { sceneParts: true, staticGuard: false }), + ); + await scenePage.evaluateOnNewDocument( + readFileSync( + resolve(import.meta.dirname, "../../../core/node_modules/gsap/dist/gsap.min.js"), + "utf8", + ), + ); + await scenePage.setRequestInterception(true); + scenePage.on( + "request", + (request) => void request.respond({ contentType: "text/html", body: html }), + ); + await scenePage.goto("http://scenes.test/"); + await scenePage.waitForFunction(() => window.__playerReady && window.__renderReady); + expect( + await scenePage.evaluate(() => document.querySelectorAll("[data-hf-scene-no-swap]").length), + ).toBe(0); + const canvasAt = (time: number, scene: string) => + scenePage.evaluate( + async (time, scene) => { + window.__player!.seek(time); + await window.__hfWaitForSeekCompletion?.(); + for (let i = 0; i < 2; i++) await new Promise(requestAnimationFrame); + return document + .querySelector(`[data-hf-scene="${scene}"] canvas`)! + .toDataURL(); + }, + time, + scene, + ); + const blank = await scenePage.evaluate(() => document.createElement("canvas").toDataURL()); + const first = [await canvasAt(1, "s0"), await canvasAt(3.4, "s1")]; + expect(first).not.toContain(blank); + await scenePage.evaluate(() => { + (window as unknown as { first: unknown[] }).first = [ + window.__timelines!.s0, + window.__timelines!.s1, + ]; + window.__player!.seek(5); + }); + await scenePage.waitForFunction( + () => document.querySelectorAll('[data-hf-parked-scene][data-hf-scene^="s"]').length === 2, + ); + for (const [time, scene, i] of [ + [1, "s0", 0], + [3.4, "s1", 1], + ] as const) { + await scenePage.evaluate((time) => window.__player!.seek(time), time); + // Mounted again once its script has run and registered a fresh timeline. + await scenePage.waitForFunction( + (scene, i) => { + const fresh = window.__timelines?.[scene]; + return ( + fresh !== undefined && fresh !== (window as unknown as { first: unknown[] }).first[i] + ); + }, + {}, + scene, + i, + ); + expect(await canvasAt(time, scene)).toBe(first[i]); + } + } finally { + await scenePage.close(); + rmSync(dir, { recursive: true, force: true }); + } + }, 60_000); + it("removes the control bridge during teardown", async () => { const result = await page.evaluate(async () => { const runtimeWindow = window as unknown as { From aea086b3239400d2df2c41c1862a46843f1460b4 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 25 Oct 2026 00:01:03 -0400 Subject: [PATCH 27/40] perf(core): a play frame that draws nothing new skips the hidden-clip lookup --- packages/core/src/runtime/init.test.ts | 24 ++++++++++++++++++++++++ packages/core/src/runtime/init.ts | 8 +++++--- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/packages/core/src/runtime/init.test.ts b/packages/core/src/runtime/init.test.ts index 4c12d292125..6fc779c7aeb 100644 --- a/packages/core/src/runtime/init.test.ts +++ b/packages/core/src/runtime/init.test.ts @@ -585,6 +585,30 @@ describe("initSandboxRuntimeModular", () => { expect(seen).toEqual(["visible", "hidden"]); }); + it("looks for hidden clips only on a jump where some tween draws for the first time", () => { + document.body.innerHTML = + `
` + + `

` + + `
`; + const tl = gsap.timeline({ paused: true }); + tl.to("#said", { x: 1, duration: 0.1 }, 3); + window.__timelines = { main: tl as unknown as RuntimeTimelineLike }; + initSandboxRuntimeModular(); + const query = vi.spyOn(document, "querySelectorAll"); + const looked = () => + query.mock.calls.filter(([selector]) => selector === "[data-hf-clip-hidden]").length; + try { + window.__player?.seek(1); + window.__player?.seek(1.5); + const beforeTheTween = looked(); + // Past the line's span: its tween at 3 s draws for the first time while the line is hidden. + window.__player?.seek(6); + expect([beforeTheTween, looked()]).toEqual([0, 1]); + } finally { + query.mockRestore(); + } + }); + it("keeps a hidden scene's contents hidden when a rewound GSAP set marks one visible inline", () => { hideTimedClipsUntilFirstPass(); try { diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index 14629a03b2d..fa4c7e6da88 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -5096,9 +5096,11 @@ export function initSandboxRuntimeModular(): void { // Only clips holding a tween that renders for the first time on this seek need their authored style read. const firstRendered = firstRenders && tl ? clipsFirstRenderedIn(tl, tweensRenderedThrough.time, t) : null; - const hideClipsAgain = firstRenders - ? showHiddenClips(firstRendered ? (el) => firstRendered.has(el) : undefined) - : null; + // An empty set means no tween draws for the first time: nothing to show, so no page-wide query. + const hideClipsAgain = + firstRenders && firstRendered?.size !== 0 + ? showHiddenClips(firstRendered ? (el) => firstRendered.has(el) : undefined) + : null; if (tl) { // #10: when data-duration exceeds the timeline's intrinsic length the // engine requests frames past the last tween. Seeking a paused GSAP From 8351fe3ff13f34f704d8e49a9d483573cdf7b5a0 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 25 Oct 2026 01:55:46 -0400 Subject: [PATCH 28/40] fix(core): a GSAP file copied into a project counts as the library for scene unmounting --- .../core/src/compiler/htmlBundler.test.ts | 29 +++++++++++++++++++ packages/core/src/compiler/htmlBundler.ts | 4 ++- .../src/compiler/inlineSubCompositions.ts | 9 +++++- 3 files changed, 40 insertions(+), 2 deletions(-) diff --git a/packages/core/src/compiler/htmlBundler.test.ts b/packages/core/src/compiler/htmlBundler.test.ts index 63ac1d58189..e6c5714fb1e 100644 --- a/packages/core/src/compiler/htmlBundler.test.ts +++ b/packages/core/src/compiler/htmlBundler.test.ts @@ -3548,6 +3548,35 @@ describe("bundleToSingleHtml sceneParts", () => { expect(rendered.match(/window\.Kit =/g)).toHaveLength(1); }); + it("treats a GSAP file copied into the project as the library, at the root and in a scene, and no other file", async () => { + const gsapCopy = `/*!\n * GSAP 3.14.2\n * https://gsap.com\n */\nwindow.gsap = { all: "*" }; requestAnimationFrame(() => {});`; + const project = (file: string) => + makeTempProject({ + "index.html": ` +
+
+
+
`, + [`vendor/${file}`]: + file === "gsap.min.js" ? gsapCopy : gsapCopy.replace("GSAP 3.14.2", "Helpers 1.0.0"), + "compositions/a.html": ``, + "compositions/b.html": ``, + }); + const marks = async (dir: string) => { + const doc = parseHTML(await bundleToSingleHtml(dir, { sceneParts: true })).document; + return ["a", "b"].map( + (id) => + doc.querySelector(`div[data-hf-scene="${id}"]`)?.getAttribute("data-hf-scene-no-swap") ?? + null, + ); + }; + expect(await marks(project("gsap.min.js"))).toEqual([null, null]); + expect(await marks(project("helpers.js"))).toEqual([ + "its script uses requestAnimationFrame", + "a script outside the scene selects *", + ]); + }); + it("lets a scene draw in 2D, chain a timeline into the registry, call its own animate and wait on font loads, and nothing more", async () => { const measure = `let ctx = null; window.measure = (s) => { if (!ctx) { ctx = document.createElement("canvas").getContext("2d"); } ctx.font = "800 46px Outfit"; return ctx.measureText(s).width; };`; const scenes: Record = { diff --git a/packages/core/src/compiler/htmlBundler.ts b/packages/core/src/compiler/htmlBundler.ts index c85a032ca6d..60f86bf3c56 100644 --- a/packages/core/src/compiler/htmlBundler.ts +++ b/packages/core/src/compiler/htmlBundler.ts @@ -65,6 +65,7 @@ import { readDeclaredDefaults } from "../runtime/getVariables"; import { ensureExternalLinkTag, inlineSubCompositions, + isLibraryCopy, refuseSwapsReachedByRootScripts, } from "./inlineSubCompositions"; import { isSafePath, resolveWithinProject } from "../safePath.js"; @@ -1177,7 +1178,8 @@ async function bundleProject(projectDir: string, options?: BundleOptions): Promi ].map((el) => { const src = el.getAttribute("src"); const path = src && isRelativeUrl(src) ? resolveEntryUrl(src) : null; - return src ? (path && safeReadFile(path)) || "" : el.textContent || ""; + const text = src ? (path && safeReadFile(path)) || "" : el.textContent || ""; + return isLibraryCopy(text) ? "" : text; }) : []; diff --git a/packages/core/src/compiler/inlineSubCompositions.ts b/packages/core/src/compiler/inlineSubCompositions.ts index cb001980a9b..6586d480a32 100644 --- a/packages/core/src/compiler/inlineSubCompositions.ts +++ b/packages/core/src/compiler/inlineSubCompositions.ts @@ -57,6 +57,9 @@ const SWAP_SAFE_LIBRARY_URL = /^https:\/\/(cdn\.jsdelivr\.net\/npm|unpkg\.com)\/(gsap|three|d3|d3-[a-z-]+|topojson-client|clipper-lib)(@[^/]+)?\//; const isSwapSafeLibrary = (src: string) => URL.canParse(src) && SWAP_SAFE_LIBRARY_URL.test(new URL(src).href); +// GSAP's published file copied into a project, known by its license banner: it only defines globals too. +const GSAP_BANNER_RE = /^\s*\/\*!\s*\*\s*GSAP \d+\.\d+\.\d+\s*\*\s*https:\/\/gsap\.com\b/; +export const isLibraryCopy = (script: string) => GSAP_BANNER_RE.test(script); // Waits on the page's fonts, ready or loading named faces: each settles once, leaving nothing behind. const STRING_ARG = String.raw`(?:"(?:\\.|[^"\\\n])*"|'(?:\\.|[^'\\\n])*')`; @@ -114,7 +117,11 @@ function sceneScriptSwapRefusal(script: string): string | null { } const projectScriptRefusal = (text: string | null) => - text === null ? "it runs a script that is not a known library" : sceneScriptSwapRefusal(text); + text === null + ? "it runs a script that is not a known library" + : isLibraryCopy(text) + ? null + : sceneScriptSwapRefusal(text); // A quoted string in a script, in any of the three quote styles. const STRING_LITERAL_RE = /(["'`])((?:\\.|(?!\1)[^\\\n])*?)\1/g; From 555eee56d896b60d8353c3358f7915e24b12be8c Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 25 Oct 2026 03:36:25 -0400 Subject: [PATCH 29/40] style(core): drop an unneeded escape in the registered-chain pattern --- packages/core/src/compiler/inlineSubCompositions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/compiler/inlineSubCompositions.ts b/packages/core/src/compiler/inlineSubCompositions.ts index 6586d480a32..8b580604746 100644 --- a/packages/core/src/compiler/inlineSubCompositions.ts +++ b/packages/core/src/compiler/inlineSubCompositions.ts @@ -74,7 +74,7 @@ const FONT_WAIT_RE = new RegExp( const TWO_D_CONTEXT_RE = /\bgetContext\s*\(\s*(["'])2d\1\s*\)/g; // A timeline chained as it is registered: the registry holds it, so unmounting the scene stops it. const REGISTERED_CHAIN_RE = - /(\b(?:window\s*\.\s*)?__timelines\s*\[[^\[\]]*\]\s*=\s*)gsap\s*\.\s*timeline\s*\(/g; + /(\b(?:window\s*\.\s*)?__timelines\s*\[[^[\]]*\]\s*=\s*)gsap\s*\.\s*timeline\s*\(/g; /** Whether a call of the browser's Element.animate may leave an animation: it gets timing, its second argument. */ function lastingAnimateCall(script: string): boolean { From 4e90fd4b9cbed9b505fb7213afbae53c117d4e15 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 25 Oct 2026 03:36:26 -0400 Subject: [PATCH 30/40] chore: core and producer size budgets make room for the preview scene window --- scripts/package-size-budgets.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/package-size-budgets.json b/scripts/package-size-budgets.json index c078389849e..91a803cb655 100644 --- a/scripts/package-size-budgets.json +++ b/scripts/package-size-budgets.json @@ -9,7 +9,7 @@ }, "@hyperframes/core": { "packed": 1645000, - "unpacked": 3965000 + "unpacked": 4218111 }, "@hyperframes/engine": { "packed": 456397, @@ -36,7 +36,7 @@ "unpacked": 541601 }, "@hyperframes/producer": { - "packed": 768210, + "packed": 815077, "unpacked": 2807938 }, "@hyperframes/sdk": { From 4f4f2fbdf477ffe840573ab770ed3ebf05a830eb Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 25 Oct 2026 04:54:11 -0400 Subject: [PATCH 31/40] fix(core): a scene that draws on a 2D canvas stays mounted --- .../core/src/compiler/htmlBundler.test.ts | 8 +- .../src/compiler/inlineSubCompositions.ts | 13 ++- .../src/services/coreRuntimeBrowser.test.ts | 98 ++++++++++--------- 3 files changed, 62 insertions(+), 57 deletions(-) diff --git a/packages/core/src/compiler/htmlBundler.test.ts b/packages/core/src/compiler/htmlBundler.test.ts index 81fb3c97232..34dd796b4ad 100644 --- a/packages/core/src/compiler/htmlBundler.test.ts +++ b/packages/core/src/compiler/htmlBundler.test.ts @@ -3674,7 +3674,7 @@ describe("bundleToSingleHtml sceneParts", () => { ]); }); - it("lets a scene draw in 2D, chain a timeline into the registry, call its own animate and wait on font loads, and nothing more", async () => { + it("lets a scene measure text on an unattached canvas, chain a timeline into the registry, call its own animate and wait on font loads, and nothing more", async () => { const measure = `let ctx = null; window.measure = (s) => { if (!ctx) { ctx = document.createElement("canvas").getContext("2d"); } ctx.font = "800 46px Outfit"; return ctx.measureText(s).width; };`; const scenes: Record = { measureCanvas: ``, @@ -3728,9 +3728,9 @@ describe("bundleToSingleHtml sceneParts", () => { fontLoads: null, fontLoadThen: null, fontLoadAwait: null, - namedCanvas: null, - canvasBack: null, - drawOnStage: null, + namedCanvas: "its script uses getContext", + canvasBack: "its script uses getContext", + drawOnStage: "its script uses getContext", registeredChain: null, registeredChainByKey: null, unregisteredChain: "its script uses gsap.timeline().to(", diff --git a/packages/core/src/compiler/inlineSubCompositions.ts b/packages/core/src/compiler/inlineSubCompositions.ts index 8b580604746..0cbbd9202f2 100644 --- a/packages/core/src/compiler/inlineSubCompositions.ts +++ b/packages/core/src/compiler/inlineSubCompositions.ts @@ -70,8 +70,9 @@ const FONT_WAIT_RE = new RegExp( "g", ); -// A 2D context keeps no GPU state or callback: a remount runs the script again, which draws again. -const TWO_D_CONTEXT_RE = /\bgetContext\s*\(\s*(["'])2d\1\s*\)/g; +// A canvas made only to measure text: never named, so nothing attaches it to the page or draws on it there. +const TEXT_MEASURE_CANVAS_RE = + /\bdocument\s*\.\s*createElement\s*\(\s*(["'])canvas\1\s*\)\s*\.\s*getContext\s*\(\s*(["'])2d\2\s*\)/g; // A timeline chained as it is registered: the registry holds it, so unmounting the scene stops it. const REGISTERED_CHAIN_RE = /(\b(?:window\s*\.\s*)?__timelines\s*\[[^[\]]*\]\s*=\s*)gsap\s*\.\s*timeline\s*\(/g; @@ -108,9 +109,11 @@ function topLevelArgs(script: string, from: number): number { function sceneScriptSwapRefusal(script: string): string | null { const withoutFontWaits = script.replace(FONT_WAIT_RE, ""); const awaitedFontsOnly = /\bawait\b/.test(script) && !/\bawait\b/.test(withoutFontWaits); - const checked = (awaitedFontsOnly ? withoutFontWaits.replace(/\basync\b/g, "") : withoutFontWaits) - .replace(TWO_D_CONTEXT_RE, "") - .replace(REGISTERED_CHAIN_RE, "$1("); + let checked = ( + awaitedFontsOnly ? withoutFontWaits.replace(/\basync\b/g, "") : withoutFontWaits + ).replace(REGISTERED_CHAIN_RE, "$1("); + // The context hands its canvas back through .canvas, which could then reach the page. + if (!/\.\s*canvas\b/.test(checked)) checked = checked.replace(TEXT_MEASURE_CANVAS_RE, ""); const match = SIDE_EFFECT_RE.exec(checked); if (match) return `its script uses ${match[0].trim()}`; return lastingAnimateCall(checked) ? "its script uses .animate(" : null; diff --git a/packages/producer/src/services/coreRuntimeBrowser.test.ts b/packages/producer/src/services/coreRuntimeBrowser.test.ts index 7e2aff572a8..e9482f10b2b 100644 --- a/packages/producer/src/services/coreRuntimeBrowser.test.ts +++ b/packages/producer/src/services/coreRuntimeBrowser.test.ts @@ -1606,15 +1606,15 @@ describe("core runtime browser contract", () => { } }, 60_000); - it("draws a mounted-again scene's 2D canvas as it first did, drawn once at load or on each update", async () => { + it("keeps a scene that draws on a 2D canvas mounted, and mounts a chained-timeline scene again as it was", async () => { const dir = mkdtempSync(resolve(tmpdir(), "scene-window-canvas-")); const scenePage = await browser.newPage(); try { const scripts = [ - // Drawn once, as the script runs. - `const g = document.querySelector("canvas").getContext('2d'); g.fillStyle = "#c33"; g.fillRect(10, 10, 60, 30); g.font = "20px sans-serif"; g.fillText("once", 20, 80); const tl = gsap.timeline({ paused: true }); tl.to({ t: 0 }, { t: 1, duration: 2 }); window.__timelines["s0"] = tl;`, - // Cleared and drawn again by its timeline, chained as it is registered. - `const g = document.querySelector("canvas").getContext('2d'); const clock = { t: 0 }; window.__timelines["s1"] = gsap.timeline({ paused: true, onUpdate: () => { g.clearRect(0, 0, 320, 180); g.fillStyle = "#36c"; g.fillRect(20 + clock.t * 50, 20, 30, 30); } }).to(clock, { t: 2, duration: 2, ease: "none" });`, + // Drawn once, as the script runs: a mount would need the script to draw again. + `const g = document.querySelector("canvas").getContext('2d'); g.fillStyle = "#c33"; g.fillRect(10, 10, 60, 30); const tl = gsap.timeline({ paused: true }); tl.to({ t: 0 }, { t: 1, duration: 2 }); window.__timelines["s0"] = tl;`, + // Placed by its timeline, chained as it is registered. + `const box = document.querySelector(".box"); const clock = { t: 0 }; window.__timelines["s1"] = gsap.timeline({ paused: true, onUpdate: () => { box.style.transform = "translateX(" + Math.round(clock.t * 50) + "px)"; } }).to(clock, { t: 2, duration: 2, ease: "none" });`, `window.__timelines["s2"] = gsap.timeline({ paused: true });`, ]; writeFileSync( @@ -1629,7 +1629,7 @@ describe("core runtime browser contract", () => { scripts.forEach((script, i) => writeFileSync( resolve(dir, `s${i}.html`), - ``, + ``, ), ); const html = addScenePartsManifest( @@ -1648,54 +1648,56 @@ describe("core runtime browser contract", () => { ); await scenePage.goto("http://scenes.test/"); await scenePage.waitForFunction(() => window.__playerReady && window.__renderReady); - expect( - await scenePage.evaluate(() => document.querySelectorAll("[data-hf-scene-no-swap]").length), - ).toBe(0); - const canvasAt = (time: number, scene: string) => - scenePage.evaluate( - async (time, scene) => { - window.__player!.seek(time); - await window.__hfWaitForSeekCompletion?.(); - for (let i = 0; i < 2; i++) await new Promise(requestAnimationFrame); - return document - .querySelector(`[data-hf-scene="${scene}"] canvas`)! - .toDataURL(); - }, - time, - scene, + const refusals = () => + scenePage.evaluate(() => + ["s0", "s1"].map((s) => + document + .querySelector(`[data-hf-scene="${s}"]:not(style, script)`)! + .getAttribute("data-hf-scene-no-swap"), + ), + ); + expect(await refusals()).toEqual(["its script uses getContext", null]); + const seek = (time: number) => + scenePage.evaluate(async (time) => { + window.__player!.seek(time); + await window.__hfWaitForSeekCompletion?.(); + for (let i = 0; i < 2; i++) await new Promise(requestAnimationFrame); + }, time); + const canvas = () => + scenePage.evaluate(() => + document.querySelector('[data-hf-scene="s0"] canvas')!.toDataURL(), + ); + const boxAt = async (time: number) => { + await seek(time); + return scenePage.evaluate( + () => document.querySelector('[data-hf-scene="s1"] .box')!.style.transform, ); - const blank = await scenePage.evaluate(() => document.createElement("canvas").toDataURL()); - const first = [await canvasAt(1, "s0"), await canvasAt(3.4, "s1")]; - expect(first).not.toContain(blank); + }; + await seek(1); + const drawn = await canvas(); + const placed = await boxAt(3.4); await scenePage.evaluate(() => { - (window as unknown as { first: unknown[] }).first = [ - window.__timelines!.s0, - window.__timelines!.s1, - ]; + (window as unknown as { first: unknown }).first = window.__timelines!.s1; window.__player!.seek(5); }); await scenePage.waitForFunction( - () => document.querySelectorAll('[data-hf-parked-scene][data-hf-scene^="s"]').length === 2, + () => document.querySelector('[data-hf-parked-scene][data-hf-scene="s1"]') !== null, ); - for (const [time, scene, i] of [ - [1, "s0", 0], - [3.4, "s1", 1], - ] as const) { - await scenePage.evaluate((time) => window.__player!.seek(time), time); - // Mounted again once its script has run and registered a fresh timeline. - await scenePage.waitForFunction( - (scene, i) => { - const fresh = window.__timelines?.[scene]; - return ( - fresh !== undefined && fresh !== (window as unknown as { first: unknown[] }).first[i] - ); - }, - {}, - scene, - i, - ); - expect(await canvasAt(time, scene)).toBe(first[i]); - } + const parked = await scenePage.evaluate(() => + [...document.querySelectorAll("[data-hf-parked-scene]")].map((el) => + el.getAttribute("data-hf-scene"), + ), + ); + expect(parked).not.toContain("s0"); + await seek(1); + expect(await canvas()).toBe(drawn); + await scenePage.evaluate(() => window.__player!.seek(3.4)); + // Mounted again once its script has run and registered a fresh timeline. + await scenePage.waitForFunction(() => { + const fresh = window.__timelines?.s1; + return fresh !== undefined && fresh !== (window as unknown as { first: unknown }).first; + }); + expect(await boxAt(3.4)).toBe(placed); } finally { await scenePage.close(); rmSync(dir, { recursive: true, force: true }); From 94576f08d22828d89867b53492908f4507f0667b Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 25 Oct 2026 06:51:44 -0400 Subject: [PATCH 32/40] fix(core): a scene the preview unmounts lets go of timelines it built after a font wait --- .../core/src/runtime/init.swapScenes.test.ts | 21 ++ packages/core/src/runtime/init.ts | 89 +++++++-- .../src/services/coreRuntimeBrowser.test.ts | 189 ++++++++++-------- 3 files changed, 194 insertions(+), 105 deletions(-) diff --git a/packages/core/src/runtime/init.swapScenes.test.ts b/packages/core/src/runtime/init.swapScenes.test.ts index ed02070be4c..c94b78700fb 100644 --- a/packages/core/src/runtime/init.swapScenes.test.ts +++ b/packages/core/src/runtime/init.swapScenes.test.ts @@ -1012,6 +1012,27 @@ describe("__hfSwapScenes", () => { expect(sceneHost("a").querySelector("p")?.textContent).toBe("A two"); }); + it("swaps a scene whose timeline drives one its script built later, and stops that one too", async () => { + const { root } = trackingRoot(); + const globalTimeline = { getChildren: () => [innerTween] }; + // Built after a font wait, so never recorded: only the registered timeline's tween of its time leads to it. + const inner = { + parent: globalTimeline, + totalTime: () => 0, + kill: () => {}, + revert: vi.fn(), + getChildren: () => [innerTween], + }; + const innerTween = { parent: inner, targets: () => [sceneHost("a")] }; + Object.assign(made.a1!, { getChildren: () => [{ parent: made.a1, targets: () => [inner] }] }); + (window as unknown as { gsap: unknown }).gsap = { set: () => {}, globalTimeline }; + boot([A1, B], root); + await tick(); + await window.__hfSwapScenes!(preview([A2, B]).html); + expect(sceneHost("a").querySelector("p")?.textContent).toBe("A two"); + expect(inner.revert).toHaveBeenCalledTimes(1); + }); + it.each([ ["a timeline it never registers", `gsap.timeline().to("p", { x: 100 });`], ["gsap under another name", `const g = gsap; g.to("p", { x: 100 });`], diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index 68d2f336385..5370dd58c39 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -123,7 +123,7 @@ import { import { createRuntimeStartTimeResolver } from "./startResolver"; import { createClipTree } from "./clipTree"; import { loadCompositions } from "./compositionLoader"; -import { runScriptsAfterFonts } from "./afterFonts"; +import { runScriptsAfterFonts, waitForFonts } from "./afterFonts"; import { applyCaptionOverrides, applyFetchedCaptionOverrides, @@ -3947,6 +3947,22 @@ export function initSandboxRuntimeModular(): void { [host, ...host.querySelectorAll("[data-composition-id]")].flatMap( (el) => el.getAttribute("data-composition-id") || [], ); + // A timeline a scene's animation drives (a tween of its time, as tweenFromTo makes) is the scene's too, unless + // another owner registered it. A timeline the script built after it returned, after a font wait, is known only so. + const drivenBy = (animation: SceneAnimation, ids: string[]): SceneAnimation[] => { + const others = new Set([state.capturedTimeline, window.gsap?.globalTimeline]); + for (const [id, timeline] of Object.entries(window.__timelines ?? {})) + if (!ids.includes(id)) others.add(timeline); + const tweens = animation.getChildren ? animation.getChildren(true, true, false) : [animation]; + return tweens.flatMap((tween) => + (tween.targets?.() ?? []).filter( + (target): target is SceneAnimation => + typeof (target as SceneAnimation | null)?.totalTime === "function" && + typeof (target as SceneAnimation).kill === "function" && + !others.has(target), + ), + ); + }; // gsap binds a tween to elements, so one from outside the scene would go on moving the replaced copy. const refuseOutsideTweens = ( name: string, @@ -3954,9 +3970,13 @@ export function initSandboxRuntimeModular(): void { timelines: Record, sceneAnimations: Record, ) => { + const ids = compositionIdsIn(host); const own = new Set( - compositionIdsIn(host).flatMap((id) => [timelines[id], ...(sceneAnimations[id] ?? [])]), + ids.flatMap((id) => [timelines[id], ...(sceneAnimations[id] ?? [])]), ); + // A Set visits what is added while it is iterated, so this follows drives of drives. + for (const animation of own as Set) + if (animation) for (const driven of drivenBy(animation, ids)) own.add(driven); const inScene = new Set([host, ...host.querySelectorAll("*")]); for (const tween of window.gsap?.globalTimeline?.getChildren?.(true, true, false) ?? []) { if (!tween.targets?.().some((target) => inScene.has(target))) continue; @@ -3983,6 +4003,24 @@ export function initSandboxRuntimeModular(): void { } } }; + // Runs a scene's scripts, then records what they started after returning: a script resumed by a font + // wait builds there, past the recorder that wraps it. With several scenes, a late animation's scene is unknown. + const runSceneScripts = async (hosts: Element[], scripts: Element[]) => { + const globalTimeline = window.gsap?.globalTimeline; + const before = new Set(globalTimeline?.getChildren?.(false, true, true)); + await runScriptsAfterFonts(scripts); + const recorded = window.__hfSceneAnimations; + const id = hosts.length === 1 ? compositionIdsIn(hosts[0]!)[0] : undefined; + if (!recorded || !id || !globalTimeline?.getChildren) return; + // The gate lets a scene's script wait on fonts only, so their settling ends it. + await waitForFonts(); + await new Promise((resolve) => window.setTimeout(resolve, 0)); + const known = new Set(Object.values(recorded).flat()); + const late = globalTimeline + .getChildren(false, true, true) + .filter((animation) => !before.has(animation) && !known.has(animation)); + (recorded[id] ??= []).push(...(late as SceneAnimation[])); + }; // Stops every animation a scene's scripts started, newest first, and forgets them. `check` runs once they stop. const stopSceneAnimations = (hosts: Element[], check: () => void = () => {}) => { const timelines = () => @@ -3993,24 +4031,32 @@ export function initSandboxRuntimeModular(): void { | null; const oldIds = hosts.flatMap((host) => compositionIdsIn(host)); const stopped = new Set(); + const stop = (previous: unknown) => { + if (!previous || stopped.has(previous)) return; + stopped.add(previous); + const old = previous as SceneAnimation; + if (old.revert) old.revert(); + else old.totalTime?.(0, true); + root?.remove?.(previous); + // revert() has already killed it; a second kill() fires onInterrupt again. + if (!old.revert) old.kill?.(); + }; const stopOldAnimations = () => { const before = stopped.size; - for (const id of oldIds) { - // Newest first: each revert restores what the animation before it wrote. - for (const previous of [ - ...(sceneAnimations()[id] ?? []).slice().reverse(), - timelines()[id], - ]) { - if (!previous || stopped.has(previous)) continue; - stopped.add(previous); - const old = previous as SceneAnimation; - if (old.revert) old.revert(); - else old.totalTime?.(0, true); - root?.remove?.(previous); - // revert() has already killed it; a second kill() fires onInterrupt again. - if (!old.revert) old.kill?.(); - } - } + // Newest first: each revert restores what the animation before it wrote. + const own = oldIds.flatMap((id) => [ + ...(sceneAnimations()[id] ?? []).slice().reverse(), + timelines()[id], + ]); + // What they drive goes last: a driver's revert sets its time, which puts a stopped one back on GSAP's + // global timeline, keeping the scene alive. A Set visits what is added while it is iterated. + const driven = new Set(); + for (const animation of own) + if (animation) drivenBy(animation as SceneAnimation, oldIds).forEach((d) => driven.add(d)); + for (const animation of driven) + drivenBy(animation as SceneAnimation, oldIds).forEach((d) => driven.add(d)); + for (const previous of [...own.filter((animation) => !driven.has(animation)), ...driven]) + stop(previous); return stopped.size > before; }; // A revert fires the animation's onInterrupt, which can register more; stop those too, until none appear. @@ -4276,7 +4322,7 @@ export function initSandboxRuntimeModular(): void { .filter((el) => el.tagName === "SCRIPT") .map((el) => document.body.appendChild(document.importNode(el, true))), ); - await runScriptsAfterFonts(sceneScripts); + await runSceneScripts(swappedHosts, sceneScripts); if (state.tornDown) throw new Error("the preview was torn down during the swap"); document .querySelector(`meta[name="${SCENE_PARTS_META}"]`) @@ -4317,7 +4363,10 @@ export function initSandboxRuntimeModular(): void { const captionHosts = host.querySelector(".caption-group") ? [host] : []; const captionOverrides = captionHosts.length > 0 ? await fetchCaptionOverrides() : []; await nextTask(); - await runScriptsAfterFonts(scripts.map((el) => document.body.appendChild(el))); + await runSceneScripts( + [host], + scripts.map((el) => document.body.appendChild(el)), + ); await nextTask(); parkedNames.delete(name); if (!state.tornDown) settleChangedScenes([host], captionHosts, captionOverrides, true); diff --git a/packages/producer/src/services/coreRuntimeBrowser.test.ts b/packages/producer/src/services/coreRuntimeBrowser.test.ts index e9482f10b2b..c2228b6a1b5 100644 --- a/packages/producer/src/services/coreRuntimeBrowser.test.ts +++ b/packages/producer/src/services/coreRuntimeBrowser.test.ts @@ -1517,94 +1517,113 @@ describe("core runtime browser contract", () => { 30_000, ); - it("lets go of each scene the preview unmounts while it plays, its captured audio included", async () => { - const dir = mkdtempSync(resolve(tmpdir(), "scene-window-memory-")); - // Its own browser: Web Audio only captures media where the context may start without a gesture. - const audioBrowser = await puppeteer.launch({ - headless: true, - args: [ - "--no-sandbox", - "--disable-setuid-sandbox", - "--autoplay-policy=no-user-gesture-required", - ], - }); - try { - const scenes = [0, 1, 2, 3, 4, 5]; - const hosts = scenes - .map( - (i) => - `
`, - ) - .join(""); - writeFileSync( - resolve(dir, "index.html"), - `
${hosts}
`, - ); - writeFileSync(resolve(dir, "tone.wav"), silentWav()); - for (const i of scenes) + const builtAsItRuns = (i: number) => + `const tl = gsap.timeline({ paused: true }); tl.to('[data-composition-id="s${i}"] b', { opacity: 0.5, duration: 1 }); window.__timelines["s${i}"] = tl;`; + it.each([ + ["its timeline built as the script runs", builtAsItRuns], + [ + "its timeline driving one built after a font wait", + (i: number) => + `const tl = gsap.timeline({ paused: true }); Promise.all([document.fonts.load("16px serif")]).then(() => { const inner = gsap.timeline({ paused: true }); inner.to('[data-composition-id="s${i}"] b', { opacity: 0.5, duration: 1 }); tl.add(inner.tweenFromTo(0, 1), 0); window.__timelines["s${i}"] = tl; });`, + ], + [ + "a timeline built after a font wait to pose it once", + (i: number) => + `const tl = gsap.timeline({ paused: true }); window.__timelines["s${i}"] = tl; Promise.all([document.fonts.load("16px serif")]).then(() => { const pose = gsap.timeline({ paused: true }); pose.to('[data-composition-id="s${i}"] b', { opacity: 0.5, duration: 1 }); pose.time(0.5); });`, + ], + ])( + "lets go of each scene the preview mounts and unmounts while it plays, its captured audio included (%s)", + async (_, sceneScript) => { + const dir = mkdtempSync(resolve(tmpdir(), "scene-window-memory-")); + // Its own browser: Web Audio only captures media where the context may start without a gesture. + const audioBrowser = await puppeteer.launch({ + headless: true, + args: [ + "--no-sandbox", + "--disable-setuid-sandbox", + "--autoplay-policy=no-user-gesture-required", + ], + }); + try { + const scenes = [0, 1, 2, 3, 4, 5]; + const hosts = scenes + .map( + (i) => + `
`, + ) + .join(""); writeFileSync( - resolve(dir, `s${i}.html`), - ``, + resolve(dir, "index.html"), + `
${hosts}
`, ); - const html = addScenePartsManifest( - await bundleToSingleHtml(dir, { sceneParts: true, staticGuard: false }), - ); - const page = await audioBrowser.newPage(); - await page.evaluateOnNewDocument( - readFileSync( - resolve(import.meta.dirname, "../../../core/node_modules/gsap/dist/gsap.min.js"), - "utf8", - ), - ); - await page.setRequestInterception(true); - page.on( - "request", - (request) => void request.respond({ contentType: "text/html", body: html }), - ); - await page.goto("http://scenes.test/"); - await page.waitForFunction(() => window.__playerReady && window.__renderReady); - const cdp = await page.createCDPSession(); - await cdp.send("Performance.enable"); - // DOM nodes alive but out of the page, after a full collection. - const kept = async () => { - await cdp.send("HeapProfiler.collectGarbage"); - const { metrics } = await cdp.send("Performance.getMetrics"); - const inPage = await page.evaluate(() => { - const walker = document.createTreeWalker(document, NodeFilter.SHOW_ALL); - let count = 1; - while (walker.nextNode()) count++; - return count; - }); - return metrics.find((m) => m.name === "Nodes")!.value - inPage; - }; - const before = await kept(); - await page.evaluate(async () => { - const player = window.__player as unknown as { - play: () => void; - seek: (time: number, options: { keepPlaying: boolean }) => void; + writeFileSync(resolve(dir, "tone.wav"), silentWav()); + // s0 and s1 are mounted at open, their scripts run with the film's: a font wait's work there has no known scene. + for (const i of scenes) + writeFileSync( + resolve(dir, `s${i}.html`), + ``, + ); + const html = addScenePartsManifest( + await bundleToSingleHtml(dir, { sceneParts: true, staticGuard: false }), + ); + const page = await audioBrowser.newPage(); + await page.evaluateOnNewDocument( + readFileSync( + resolve(import.meta.dirname, "../../../core/node_modules/gsap/dist/gsap.min.js"), + "utf8", + ), + ); + await page.setRequestInterception(true); + page.on( + "request", + (request) => void request.respond({ contentType: "text/html", body: html }), + ); + await page.goto("http://scenes.test/"); + await page.waitForFunction(() => window.__playerReady && window.__renderReady); + const cdp = await page.createCDPSession(); + await cdp.send("Performance.enable"); + // DOM nodes alive but out of the page, after a full collection. + const kept = async () => { + await cdp.send("HeapProfiler.collectGarbage"); + const { metrics } = await cdp.send("Performance.getMetrics"); + const inPage = await page.evaluate(() => { + const walker = document.createTreeWalker(document, NodeFilter.SHOW_ALL); + let count = 1; + while (walker.nextNode()) count++; + return count; + }); + return metrics.find((m) => m.name === "Nodes")!.value - inPage; }; - player.play(); - for (const time of [2.5, 4.5, 6.5, 8.5, 10.5]) { - player.seek(time, { keepPlaying: true }); - await window.__hfWaitForSeekCompletion?.(); - await new Promise((settle) => setTimeout(settle, 200)); - } - }); - // The window unmounts only once it has mounted what is ahead. - await page.waitForFunction( - () => document.querySelectorAll("[data-hf-parked-scene]").length >= 5, - ); - // Five scenes went out of the window, each over 800 nodes. Chrome keeps a captured element alive while - // its audio context lives, so each scene's audio element stays, but nothing of the scene around it. - expect( - await page.evaluate(() => document.querySelectorAll("[data-hf-parked-scene]").length), - ).toBe(5); - expect((await kept()) - before).toBeLessThan(800); - } finally { - await audioBrowser.close(); - rmSync(dir, { recursive: true, force: true }); - } - }, 60_000); + const before = await kept(); + await page.evaluate(async () => { + const player = window.__player as unknown as { + play: () => void; + seek: (time: number, options: { keepPlaying: boolean }) => void; + }; + player.play(); + for (const time of [2.5, 4.5, 6.5, 8.5, 10.5]) { + player.seek(time, { keepPlaying: true }); + await window.__hfWaitForSeekCompletion?.(); + await new Promise((settle) => setTimeout(settle, 200)); + } + }); + // The window unmounts only once it has mounted what is ahead. + await page.waitForFunction( + () => document.querySelectorAll("[data-hf-parked-scene]").length >= 5, + ); + // Five scenes went out of the window, each over 800 nodes. Chrome keeps a captured element alive while + // its audio context lives, so each scene's audio element stays, but nothing of the scene around it. + expect( + await page.evaluate(() => document.querySelectorAll("[data-hf-parked-scene]").length), + ).toBe(5); + expect((await kept()) - before).toBeLessThan(800); + } finally { + await audioBrowser.close(); + rmSync(dir, { recursive: true, force: true }); + } + }, + 60_000, + ); it("keeps a scene that draws on a 2D canvas mounted, and mounts a chained-timeline scene again as it was", async () => { const dir = mkdtempSync(resolve(tmpdir(), "scene-window-canvas-")); From 73f344f02b9ba0a5dd85c65e72d1302a01785b78 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 25 Oct 2026 09:36:33 -0400 Subject: [PATCH 33/40] perf(core): a scene leaving the playhead keeps its last rendered size, no forced layout at the cut --- packages/core/src/runtime/farScenes.test.ts | 38 ++++++++++++++++++++- packages/core/src/runtime/farScenes.ts | 34 +++++++++++++++--- 2 files changed, 67 insertions(+), 5 deletions(-) diff --git a/packages/core/src/runtime/farScenes.test.ts b/packages/core/src/runtime/farScenes.test.ts index e9a88d2a609..f855d7308d9 100644 --- a/packages/core/src/runtime/farScenes.test.ts +++ b/packages/core/src/runtime/farScenes.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { createFarSceneSkip } from "./farScenes"; import { resetRuntimeFixtureDom } from "./runtimeSeekFixture.test-helpers"; @@ -24,6 +24,8 @@ const spanOf = (host: Element) => { const farIds = () => Array.from(document.querySelectorAll(`[${FAR}]`), (el) => el.id); afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); document.head.innerHTML = ""; document.body.innerHTML = ""; }); @@ -74,4 +76,38 @@ describe("createFarSceneSkip", () => { expect(css).toContain(`[${FAR}] { content-visibility: hidden; }`); expect(css).toContain(`[data-hf-scene="s2"][${FAR}] { contain-intrinsic-size: 640px 360px; }`); }); + it("takes a leaving scene's size and position from its last render, reading nothing at the cut", () => { + const hosts = film(); + let report: (entries: unknown[]) => void = () => {}; + vi.stubGlobal( + "ResizeObserver", + class { + constructor(callback: (entries: unknown[]) => void) { + report = callback; + } + observe() {} + unobserve() {} + }, + ); + const skip = createFarSceneSkip(spanOf); + skip(1); + // What the browser reports after the next layout. + report( + hosts.map((target) => ({ target, borderBoxSize: [{ inlineSize: 640, blockSize: 360 }] })), + ); + // The cut's own changes leave style dirty: a read here would force a full style and layout pass. + const computed = vi.spyOn(window, "getComputedStyle"); + for (const host of hosts) + Object.defineProperty(host, "offsetWidth", { + get: () => { + throw new Error("read at the cut"); + }, + }); + skip(5); + expect(farIds()).toEqual(["s0", "s4", "s5"]); + expect(computed).not.toHaveBeenCalled(); + expect(document.head.querySelector("style")?.textContent).toContain( + `[data-hf-scene="s0"][${FAR}] { contain-intrinsic-size: 640px 360px; }`, + ); + }); }); diff --git a/packages/core/src/runtime/farScenes.ts b/packages/core/src/runtime/farScenes.ts index 2719032c316..6d9fc62325c 100644 --- a/packages/core/src/runtime/farScenes.ts +++ b/packages/core/src/runtime/farScenes.ts @@ -39,18 +39,40 @@ export function createFarSceneSkip(spanOf: (host: Element) => Span): (time: numb .map((host) => ({ host, span: spanOf(host) })) .filter((s): s is Timed => Number.isFinite(s.span?.start)) .sort((a, b) => a.span.start - b.span.start); - // Measured before any is marked, so one layout serves them all; true when a size changed. + // Each host's box and position as of its last render, reported after layout: read at a cut, where the cut + // leaves the page's style dirty, they would force a full style and layout pass first. + const rendered = new WeakMap(); + const observer = + typeof ResizeObserver === "function" + ? new ResizeObserver((entries) => { + for (const { target, borderBoxSize } of entries) { + const box = borderBoxSize?.[0]; + if (!box) continue; + rendered.set(target, { + size: `${Math.round(box.inlineSize)}px ${Math.round(box.blockSize)}px`, + outOfFlow: OUT_OF_FLOW.has(getComputedStyle(target).position), + }); + } + }) + : null; + // Read directly only for a host not reported yet; measured before any is marked, so one layout serves them all. + const lastRender = (host: HTMLElement) => + rendered.get(host) ?? { + size: `${host.offsetWidth}px ${host.offsetHeight}px`, + outOfFlow: OUT_OF_FLOW.has(getComputedStyle(host).position), + }; + // True when a size changed. const measureLeaving = (far: Set): boolean => { let resized = false; for (const host of far) { if (host.hasAttribute(FAR)) continue; + const { size, outOfFlow } = lastRender(host); // Containment makes a host a containing block and margin boundary; only an out-of-flow one already is. - if (!OUT_OF_FLOW.has(getComputedStyle(host).position)) { + if (!outOfFlow) { far.delete(host); continue; } const name = host.getAttribute(SCENE_PART_ATTR)!; - const size = `${host.offsetWidth}px ${host.offsetHeight}px`; resized ||= sizes.get(name) !== size; sizes.set(name, size); } @@ -69,8 +91,12 @@ export function createFarSceneSkip(spanOf: (host: Element) => Span): (time: numb if (!sheet.isConnected) document.head.appendChild(sheet); }; return (time) => { - if (!hosts || hosts.some((host) => !host.isConnected)) + if (!hosts || hosts.some((host) => !host.isConnected)) { + // An observed element stays alive with its observer, so a host that left the page is let go. + for (const host of hosts ?? []) if (!host.isConnected) observer?.unobserve(host); hosts = Array.from(document.querySelectorAll(HOSTS)); + for (const host of hosts) observer?.observe(host); + } if (hosts.length === 0) return; const far = farFrom(timedHosts(hosts), time); if (measureLeaving(far) || !sheet?.isConnected) writeSheet(); From 7752323f0f9f066c6a863fc0ad2be32b416b1742 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 25 Oct 2026 10:32:19 -0400 Subject: [PATCH 34/40] refactor(core): the far-scene skip keeps last-render boxes in their own helper --- packages/core/src/runtime/farScenes.ts | 75 +++++++++++++++----------- 1 file changed, 45 insertions(+), 30 deletions(-) diff --git a/packages/core/src/runtime/farScenes.ts b/packages/core/src/runtime/farScenes.ts index 6d9fc62325c..86e97eac6a9 100644 --- a/packages/core/src/runtime/farScenes.ts +++ b/packages/core/src/runtime/farScenes.ts @@ -25,6 +25,46 @@ function farFrom(timed: Timed[], time: number): Set { return new Set(timed.filter((_, i) => !near[i]).map((s) => s.host)); } +type Box = { size: string; outOfFlow: boolean }; + +const boxOf = (host: Element, size: string): Box => ({ + size, + outOfFlow: OUT_OF_FLOW.has(getComputedStyle(host).position), +}); + +/** + * Each host's box and position as of its last render, reported after layout: read at a cut, where the cut + * leaves the page's style dirty, they would force a full style and layout pass first. A host not reported + * yet is read directly. + */ +function createLastRender() { + const rendered = new WeakMap(); + const observer = + typeof ResizeObserver === "function" + ? new ResizeObserver((entries) => { + for (const { target, borderBoxSize } of entries) { + const box = borderBoxSize?.[0]; + if (box) + rendered.set( + target, + boxOf(target, `${Math.round(box.inlineSize)}px ${Math.round(box.blockSize)}px`), + ); + } + }) + : null; + let watched: HTMLElement[] = []; + return { + watch(hosts: HTMLElement[]) { + // An observed element stays alive with its observer, so a host that left the page is let go. + for (const host of watched) if (!host.isConnected) observer?.unobserve(host); + for (const host of hosts) observer?.observe(host); + watched = hosts; + }, + of: (host: HTMLElement): Box => + rendered.get(host) ?? boxOf(host, `${host.offsetWidth}px ${host.offsetHeight}px`), + }; +} + /** * Preview scenes beyond the playing scene's neighbours stay mounted but skip style, layout and paint. * Each keeps the size it had when last rendered, so a script measuring its host reads the same box. @@ -34,39 +74,18 @@ export function createFarSceneSkip(spanOf: (host: Element) => Span): (time: numb let hosts: HTMLElement[] | null = null; let sheet: HTMLStyleElement | null = null; const sizes = new Map(); + const lastRender = createLastRender(); const timedHosts = (list: HTMLElement[]): Timed[] => list .map((host) => ({ host, span: spanOf(host) })) .filter((s): s is Timed => Number.isFinite(s.span?.start)) .sort((a, b) => a.span.start - b.span.start); - // Each host's box and position as of its last render, reported after layout: read at a cut, where the cut - // leaves the page's style dirty, they would force a full style and layout pass first. - const rendered = new WeakMap(); - const observer = - typeof ResizeObserver === "function" - ? new ResizeObserver((entries) => { - for (const { target, borderBoxSize } of entries) { - const box = borderBoxSize?.[0]; - if (!box) continue; - rendered.set(target, { - size: `${Math.round(box.inlineSize)}px ${Math.round(box.blockSize)}px`, - outOfFlow: OUT_OF_FLOW.has(getComputedStyle(target).position), - }); - } - }) - : null; - // Read directly only for a host not reported yet; measured before any is marked, so one layout serves them all. - const lastRender = (host: HTMLElement) => - rendered.get(host) ?? { - size: `${host.offsetWidth}px ${host.offsetHeight}px`, - outOfFlow: OUT_OF_FLOW.has(getComputedStyle(host).position), - }; - // True when a size changed. + // Measured before any is marked, so one layout serves them all; true when a size changed. const measureLeaving = (far: Set): boolean => { let resized = false; for (const host of far) { if (host.hasAttribute(FAR)) continue; - const { size, outOfFlow } = lastRender(host); + const { size, outOfFlow } = lastRender.of(host); // Containment makes a host a containing block and margin boundary; only an out-of-flow one already is. if (!outOfFlow) { far.delete(host); @@ -91,12 +110,8 @@ export function createFarSceneSkip(spanOf: (host: Element) => Span): (time: numb if (!sheet.isConnected) document.head.appendChild(sheet); }; return (time) => { - if (!hosts || hosts.some((host) => !host.isConnected)) { - // An observed element stays alive with its observer, so a host that left the page is let go. - for (const host of hosts ?? []) if (!host.isConnected) observer?.unobserve(host); - hosts = Array.from(document.querySelectorAll(HOSTS)); - for (const host of hosts) observer?.observe(host); - } + if (!hosts || hosts.some((host) => !host.isConnected)) + lastRender.watch((hosts = Array.from(document.querySelectorAll(HOSTS)))); if (hosts.length === 0) return; const far = farFrom(timedHosts(hosts), time); if (measureLeaving(far) || !sheet?.isConnected) writeSheet(); From defa68e4c0d693356dd89cb1ac06cbca8faa022f Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 25 Oct 2026 11:21:28 -0400 Subject: [PATCH 35/40] fix(core): a mounting scene never records the runtime's root timeline as its own --- .../core/src/runtime/init.swapScenes.test.ts | 18 ++++++++++++++++++ packages/core/src/runtime/init.ts | 16 +++++++++++++--- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/packages/core/src/runtime/init.swapScenes.test.ts b/packages/core/src/runtime/init.swapScenes.test.ts index c94b78700fb..7409e9524d7 100644 --- a/packages/core/src/runtime/init.swapScenes.test.ts +++ b/packages/core/src/runtime/init.swapScenes.test.ts @@ -2029,6 +2029,24 @@ describe("the preview's scene window", () => { expect(schedule.mock.calls.map(([el]) => el.getAttribute("src"))).toEqual(["/two.mp3"]); }); + it("records only what a mounting scene starts as its own, never the runtime's root", async () => { + const { root } = trackingRoot(); + let onGlobal: unknown[] = []; + const own = { label: "late" }; + (window as unknown as { gsap: unknown }).gsap = { + set: () => {}, + globalTimeline: { getChildren: () => onGlobal }, + }; + // While s2's scripts run, the root lands on GSAP's global timeline, as a rebuild of it does, beside one of s2's own. + (window as unknown as { __lateStart: () => void }).__lateStart = () => (onGlobal = [root, own]); + bootWindowed([S0, S1, { ...S2, script: "window.__lateStart && window.__lateStart();" }], root); + window.__hfSceneAnimations = {}; + await tick(); + await seekAndSettle(5); + expect(window.__hfSceneAnimations?.s2).toEqual([own]); + delete (window as unknown as { gsap?: unknown }).gsap; + }); + it("drops the decoded audio of a scene it parks", async () => { const release = vi.spyOn(WebAudioTransport.prototype, "releaseDecodedExcept"); quietMedia(); diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index b2f5163fbd0..01167381e64 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -3952,10 +3952,19 @@ export function initSandboxRuntimeModular(): void { ); // A timeline a scene's animation drives (a tween of its time, as tweenFromTo makes) is the scene's too, unless // another owner registered it. A timeline the script built after it returned, after a font wait, is known only so. - const drivenBy = (animation: SceneAnimation, ids: string[]): SceneAnimation[] => { - const others = new Set([state.capturedTimeline, window.gsap?.globalTimeline]); + // What is never a scene's own: the runtime's roots and the timelines other compositions registered. + const ownedElsewhere = (ids: string[]): Set => { + const others = new Set([ + state.capturedTimeline, + window.gsap?.globalTimeline, + ...builtRoots, + ]); for (const [id, timeline] of Object.entries(window.__timelines ?? {})) if (!ids.includes(id)) others.add(timeline); + return others; + }; + const drivenBy = (animation: SceneAnimation, ids: string[]): SceneAnimation[] => { + const others = ownedElsewhere(ids); const tweens = animation.getChildren ? animation.getChildren(true, true, false) : [animation]; return tweens.flatMap((tween) => (tween.targets?.() ?? []).filter( @@ -4018,7 +4027,8 @@ export function initSandboxRuntimeModular(): void { // The gate lets a scene's script wait on fonts only, so their settling ends it. await waitForFonts(); await new Promise((resolve) => window.setTimeout(resolve, 0)); - const known = new Set(Object.values(recorded).flat()); + // The runtime rebuilds its root while the scene settles; that root, and other scenes' records, are not its. + const known = new Set([...Object.values(recorded).flat(), ...ownedElsewhere([id])]); const late = globalTimeline .getChildren(false, true, true) .filter((animation) => !before.has(animation) && !known.has(animation)); From be9a9876a8cbdcb25b6a8c4e8e0fa87b043e9809 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 25 Oct 2026 12:35:17 -0400 Subject: [PATCH 36/40] chore(producer): raise the size budget to the measured size with the preview runtime inlined --- scripts/package-size-budgets.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/package-size-budgets.json b/scripts/package-size-budgets.json index 2fe897b4070..79e1cf2f01f 100644 --- a/scripts/package-size-budgets.json +++ b/scripts/package-size-budgets.json @@ -36,8 +36,8 @@ "unpacked": 541601 }, "@hyperframes/producer": { - "packed": 770990, - "unpacked": 2808835 + "packed": 782289, + "unpacked": 2841105 }, "@hyperframes/sdk": { "packed": 90662, From e1ab276a82baad62bf795411e9f880692dabcdc8 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 25 Oct 2026 13:32:57 -0400 Subject: [PATCH 37/40] docs(core): keep the far-scene and scene-memory comments within the comment limits --- packages/core/src/runtime/farScenes.ts | 12 +++++------- packages/core/src/runtime/init.swapScenes.test.ts | 2 +- .../producer/src/services/coreRuntimeBrowser.test.ts | 2 +- 3 files changed, 7 insertions(+), 9 deletions(-) diff --git a/packages/core/src/runtime/farScenes.ts b/packages/core/src/runtime/farScenes.ts index 86e97eac6a9..93f9d1f61b6 100644 --- a/packages/core/src/runtime/farScenes.ts +++ b/packages/core/src/runtime/farScenes.ts @@ -8,7 +8,7 @@ const OUT_OF_FLOW = new Set(["absolute", "fixed"]); type Timed = { host: HTMLElement; span: { start: number; end: number } }; -/** Whether each span, sorted by start, is the playing one or a neighbour; between scenes, the one before the gap plays. */ +/** Whether each span, sorted by start, is the playing one or a neighbour; in a gap, the one before it plays. */ function nearPlayhead(spans: Array<{ start: number; end: number }>, time: number): boolean[] { const playing = spans.flatMap((span, i) => (span.start <= time && time <= span.end ? [i] : [])); const before = spans.filter((span) => span.start <= time).length - 1; @@ -33,9 +33,8 @@ const boxOf = (host: Element, size: string): Box => ({ }); /** - * Each host's box and position as of its last render, reported after layout: read at a cut, where the cut - * leaves the page's style dirty, they would force a full style and layout pass first. A host not reported - * yet is read directly. + * Each host's box and position as of its last render, so a cut reads them without forcing style and layout. + * A host not reported yet is read directly. */ function createLastRender() { const rendered = new WeakMap(); @@ -66,9 +65,8 @@ function createLastRender() { } /** - * Preview scenes beyond the playing scene's neighbours stay mounted but skip style, layout and paint. - * Each keeps the size it had when last rendered, so a script measuring its host reads the same box. - * Only preview bundles tag scene hosts, so a render never gets here with any. + * Preview scenes beyond the playing scene's neighbours stay mounted but skip style, layout and paint, + * keeping their last rendered size. Only preview bundles tag scene hosts, so renders never skip. */ export function createFarSceneSkip(spanOf: (host: Element) => Span): (time: number) => void { let hosts: HTMLElement[] | null = null; diff --git a/packages/core/src/runtime/init.swapScenes.test.ts b/packages/core/src/runtime/init.swapScenes.test.ts index 7409e9524d7..366c6975a3f 100644 --- a/packages/core/src/runtime/init.swapScenes.test.ts +++ b/packages/core/src/runtime/init.swapScenes.test.ts @@ -2037,7 +2037,7 @@ describe("the preview's scene window", () => { set: () => {}, globalTimeline: { getChildren: () => onGlobal }, }; - // While s2's scripts run, the root lands on GSAP's global timeline, as a rebuild of it does, beside one of s2's own. + // While s2's scripts run, the root lands on GSAP's global timeline, as a rebuild does, beside one of s2's own. (window as unknown as { __lateStart: () => void }).__lateStart = () => (onGlobal = [root, own]); bootWindowed([S0, S1, { ...S2, script: "window.__lateStart && window.__lateStart();" }], root); window.__hfSceneAnimations = {}; diff --git a/packages/producer/src/services/coreRuntimeBrowser.test.ts b/packages/producer/src/services/coreRuntimeBrowser.test.ts index c2228b6a1b5..c232674262c 100644 --- a/packages/producer/src/services/coreRuntimeBrowser.test.ts +++ b/packages/producer/src/services/coreRuntimeBrowser.test.ts @@ -1557,7 +1557,7 @@ describe("core runtime browser contract", () => { `
${hosts}
`, ); writeFileSync(resolve(dir, "tone.wav"), silentWav()); - // s0 and s1 are mounted at open, their scripts run with the film's: a font wait's work there has no known scene. + // s0 and s1 mount at open and their scripts run with the film's: a font wait's work there has no known scene. for (const i of scenes) writeFileSync( resolve(dir, `s${i}.html`), From d4ff3fc5180f5f52bcc6d38b54f899b764de163f Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 25 Oct 2026 13:56:30 -0400 Subject: [PATCH 38/40] perf(core): style each hidden clip visible once ahead of its cut, so the cut frame does not --- packages/core/src/runtime/clipReveal.ts | 2 +- packages/core/src/runtime/init.test.ts | 32 ++++++++++++++++++++++ packages/core/src/runtime/init.ts | 22 +++++++++++++++ packages/core/src/runtime/timedClipHide.ts | 8 ++++-- 4 files changed, 61 insertions(+), 3 deletions(-) diff --git a/packages/core/src/runtime/clipReveal.ts b/packages/core/src/runtime/clipReveal.ts index 75f1bb50ce6..556d499b453 100644 --- a/packages/core/src/runtime/clipReveal.ts +++ b/packages/core/src/runtime/clipReveal.ts @@ -8,7 +8,7 @@ type ShaderWindow = Window & { }; /** Shows a clip the visibility pass hid, contents included; returns a function that puts the mark back. */ -export function revealClip(el: HTMLElement, priority = ""): () => void { +export function revealClip(el: Element & ElementCSSInlineStyle, priority = ""): () => void { el.style.setProperty("visibility", "visible", priority); if (!el.hasAttribute(CLIP_HIDDEN_ATTR)) return () => {}; el.removeAttribute(CLIP_HIDDEN_ATTR); diff --git a/packages/core/src/runtime/init.test.ts b/packages/core/src/runtime/init.test.ts index b131ddb2063..9aa7ba7565b 100644 --- a/packages/core/src/runtime/init.test.ts +++ b/packages/core/src/runtime/init.test.ts @@ -609,6 +609,38 @@ describe("initSandboxRuntimeModular", () => { } }); + it("styles each hidden clip visible once, far scene included, so its cut does not do it first", () => { + document.body.innerHTML = + `
` + + `

` + + `
`; + window.__timelines = { main: createMockTimeline(10) }; + const later = document.getElementById("later")!; + let styledVisible = 0; + const original = window.getComputedStyle.bind(window); + const style = vi.spyOn(window, "getComputedStyle").mockImplementation((el, pseudo) => { + const shown = + later.style.visibility === "visible" && !later.hasAttribute("data-hf-clip-hidden"); + if (el === later && shown && !later.hasAttribute("data-hf-far-scene")) styledVisible++; + return original(el, pseudo); + }); + try { + initSandboxRuntimeModular(); + window.__player?.seek(1); + const once = styledVisible; + window.__player?.seek(2); + window.__player?.seek(3); + expect([once, styledVisible]).toEqual([1, 1]); + expect([later.style.visibility, later.hasAttribute("data-hf-clip-hidden")]).toEqual([ + "hidden", + true, + ]); + expect(later.hasAttribute("data-hf-far-scene")).toBe(true); + } finally { + style.mockRestore(); + } + }); + it("keeps a hidden scene's contents hidden when a rewound GSAP set marks one visible inline", () => { hideTimedClipsUntilFirstPass(); try { diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index 01167381e64..8905cf78393 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -31,6 +31,7 @@ import { CLIP_HIDDEN_ATTR, revealTimedClipsAfterFirstPass, SKIPPED_CLIP, + showClips, showHiddenClips, defersMedia, skipsHiddenImages, @@ -38,6 +39,7 @@ import { import { PREVIEW_ONLY_ATTRS, RUNTIME_STAMPED_ATTR, + STUDIO_PREVIEW_FAR_SCENE_ATTR as FAR_SCENE_ATTR, STUDIO_PREVIEW_PARKED_SCENE_ATTR, STUDIO_PREVIEW_LAZY_ATTR, STUDIO_PREVIEW_UPCOMING_ATTR, @@ -2887,6 +2889,23 @@ export function initSandboxRuntimeModular(): void { resolver: timingResolverFor(true), }), ); + // A clip's first visible style pass is slow; each hidden clip gets it once on a seek, so its cut does not. + const clipsNeverStyledVisible = new Set(); + const clipsStyledVisible = new WeakSet(); + const styleHiddenClipsVisibleOnce = () => { + const clips = Array.from(clipsNeverStyledVisible).filter( + (el) => el.isConnected && el.hasAttribute(CLIP_HIDDEN_ATTR), + ); + clipsNeverStyledVisible.clear(); + if (clips.length === 0) return; + const farHosts = new Set(clips.flatMap((el) => el.closest(`[${FAR_SCENE_ATTR}]`) ?? [])); + for (const host of farHosts) host.removeAttribute(FAR_SCENE_ATTR); + const hideAgain = showClips(clips); + void getComputedStyle(clips[0]!).visibility; + hideAgain(); + for (const host of farHosts) host.setAttribute(FAR_SCENE_ATTR, ""); + for (const el of clips) clipsStyledVisible.add(el); + }; // Set once the scene swap is in place; true while a scene playing at the time is still parked. let moveSceneWindow = (_time: number): boolean => false; const LOOKAHEAD_SECONDS = 2; @@ -3185,6 +3204,8 @@ export function initSandboxRuntimeModular(): void { const isRuntimeStamped = isDiscoveryStamped(rawNode); if (!isRuntimeStamped) rawNode.style.visibility = isVisibleNow ? "visible" : "hidden"; rawNode.toggleAttribute(CLIP_HIDDEN_ATTR, !isRuntimeStamped && !isVisibleNow); + if (!isRuntimeStamped && !isVisibleNow && !clipsStyledVisible.has(rawNode)) + clipsNeverStyledVisible.add(rawNode); const upcoming = (hiddenImagesSkipped || mediaDeferred) && !isVisibleNow && @@ -5190,6 +5211,7 @@ export function initSandboxRuntimeModular(): void { const revision = opts?.revision ?? readCompositionTimingRevision(); if (revision !== tweensRenderedThrough.revision) tweensRenderedThrough = { revision, time: Number.NEGATIVE_INFINITY }; + styleHiddenClipsVisibleOnce(); 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 = diff --git a/packages/core/src/runtime/timedClipHide.ts b/packages/core/src/runtime/timedClipHide.ts index e0cf440edbb..41f0c8fce3f 100644 --- a/packages/core/src/runtime/timedClipHide.ts +++ b/packages/core/src/runtime/timedClipHide.ts @@ -31,9 +31,13 @@ const hideHiddenClipContents = (clip: string) => { }; export function showHiddenClips(only: (el: HTMLElement) => boolean = () => true): () => void { - const hidden = Array.from(document.querySelectorAll(`[${CLIP_HIDDEN_ATTR}]`)).filter( - only, + return showClips( + Array.from(document.querySelectorAll(`[${CLIP_HIDDEN_ATTR}]`)).filter(only), ); +} + +/** Shows the given hidden clips; returns a function that hides them again. */ +export function showClips(hidden: Array): () => void { const putMarksBack = hidden.map((el) => revealClip(el)); return () => { for (const el of hidden) el.style.visibility = "hidden"; From 5a5458324bf0ff464643d98162889f94d667e794 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 25 Oct 2026 14:39:13 -0400 Subject: [PATCH 39/40] fix(core): a seek into a scene still mounting as a neighbour waits for that mount --- packages/core/src/runtime/init.swapScenes.test.ts | 13 +++++++++++++ packages/core/src/runtime/init.ts | 5 ++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/core/src/runtime/init.swapScenes.test.ts b/packages/core/src/runtime/init.swapScenes.test.ts index 366c6975a3f..752a4adcc97 100644 --- a/packages/core/src/runtime/init.swapScenes.test.ts +++ b/packages/core/src/runtime/init.swapScenes.test.ts @@ -1836,6 +1836,19 @@ describe("the preview's scene window", () => { expect(parked()).toEqual(["s2"]); }); + it("waits for the scene a seek lands in while its mount as a neighbour is still running", async () => { + bootWindowed([S0, S1, S2], trackingRoot().root); + await tick(); + await seekAndSettle(5); + for (let i = 0; i < 5; i++) await tick(); + expect(parked()).toEqual(["s0", "s1"]); + // Once s0 is in, s1 mounts as its neighbour; this seek lands in s1 meanwhile. + await seekAndSettle(0.5); + window.__player?.seek(2.5); + await window.__hfWaitForSeekCompletion?.(); + expect(sceneHost("s1").textContent).toBe("one"); + }); + it("keeps drawing the scene it showed while the scene a seek lands in mounts", async () => { bootWindowed([S0, S1, S2], trackingRoot().root); await tick(); diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index 8905cf78393..0c4e5b75fd7 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -4439,7 +4439,10 @@ export function initSandboxRuntimeModular(): void { const landed = windowScenes.find( ({ name, start, end }) => start <= time && time <= end && parked(name), ); - if (windowWork) return landed !== undefined; + if (windowWork) { + if (landed) registerSeekCompletion(windowWork.then(() => windowWork)); + return landed !== undefined; + } const near = scenesWanted(windowScenes, time, clock.isPlaying()); for (const name of heldScenes) near.add(name); const key = [...near].join("\n"); From 2a9fe05cf0a6278f506c4a690b839ec5199af9d2 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 25 Oct 2026 14:39:16 -0400 Subject: [PATCH 40/40] chore(core): keep comment share and size budgets to main plus the measured core growth --- packages/core/src/runtime/timedClipHide.ts | 1 - scripts/package-size-budgets.json | 8 ++++---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/core/src/runtime/timedClipHide.ts b/packages/core/src/runtime/timedClipHide.ts index 41f0c8fce3f..84f8de6aea3 100644 --- a/packages/core/src/runtime/timedClipHide.ts +++ b/packages/core/src/runtime/timedClipHide.ts @@ -36,7 +36,6 @@ export function showHiddenClips(only: (el: HTMLElement) => boolean = () => true) ); } -/** Shows the given hidden clips; returns a function that hides them again. */ export function showClips(hidden: Array): () => void { const putMarksBack = hidden.map((el) => revealClip(el)); return () => { diff --git a/scripts/package-size-budgets.json b/scripts/package-size-budgets.json index 930c98e5da4..8b2a64ec0cc 100644 --- a/scripts/package-size-budgets.json +++ b/scripts/package-size-budgets.json @@ -8,8 +8,8 @@ "unpacked": 23722874 }, "@hyperframes/core": { - "packed": 1645000, - "unpacked": 4218111 + "packed": 1661748, + "unpacked": 4024585 }, "@hyperframes/engine": { "packed": 456397, @@ -36,8 +36,8 @@ "unpacked": 541601 }, "@hyperframes/producer": { - "packed": 792611, - "unpacked": 2872405 + "packed": 781312, + "unpacked": 2840135 }, "@hyperframes/sdk": { "packed": 90662,