diff --git a/packages/core/src/compiler/htmlBundler.test.ts b/packages/core/src/compiler/htmlBundler.test.ts index 856c075b804..34dd796b4ad 100644 --- a/packages/core/src/compiler/htmlBundler.test.ts +++ b/packages/core/src/compiler/htmlBundler.test.ts @@ -3559,9 +3559,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", @@ -3577,6 +3576,179 @@ 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", + }); + }); + + 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": ` +
+
+
+
`, + "lib/kit.js": `window.Kit = { build: function (id) { return id; } };`, + "lib/own.js": `window.Own = 1;`, + "compositions/late.html": ``, + "compositions/early.html": ``, + }); + const doc = parseHTML(await bundleToSingleHtml(dir, { sceneParts: true })).document; + 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); + }); + + 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 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: ``, + ownAnimate: ``, + fontLoads: ``, + fontLoadThen: ``, + fontLoadAwait: ``, + namedCanvas: ``, + canvasBack: ``, + drawOnStage: ``, + registeredChain: ``, + registeredChainByKey: ``, + unregisteredChain: ``, + chainKeptAside: ``, + registeredChainAndTween: ``, + webglCanvas: ``, + webgl2Canvas: ``, + experimentalWebgl: ``, + webgpuCanvas: ``, + bitmapCanvas: ``, + dynamicContext: ``, + 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", + drawOnStage: "its script uses getContext", + 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", + dynamicFontLoad: "its script uses .then(", + }); + }); + 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 641eb10fdc8..02ee3c1449c 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"; @@ -1179,7 +1180,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; }) : []; @@ -1218,6 +1220,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), @@ -1241,6 +1247,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,13 +1264,15 @@ async function bundleProject(projectDir: string, options?: BundleOptions): Promi } 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/compiler/inlineSubCompositions.ts b/packages/core/src/compiler/inlineSubCompositions.ts index 80f8a1de55a..0cbbd9202f2 100644 --- a/packages/core/src/compiler/inlineSubCompositions.ts +++ b/packages/core/src/compiler/inlineSubCompositions.ts @@ -50,20 +50,82 @@ 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 = /^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])*')`; +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; +// 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 { + 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 match = SIDE_EFFECT_RE.exec(script); - return match ? `its script uses ${match[0].trim()}` : 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 + ).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; } +const projectScriptRefusal = (text: string | null) => + 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; // Text ending where createElement takes its tag: that string makes a node, it selects none. @@ -239,6 +301,7 @@ 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; + readProjectScript?: (path: string) => string | null; } export interface ExternalLink { @@ -333,6 +396,7 @@ export function inlineSubCompositions( onMissingComposition, assetExists, tagScenes = false, + readProjectScript, } = options; const rootStyles = [...document.querySelectorAll("style")]; @@ -544,7 +608,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/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/entry.ts b/packages/core/src/runtime/entry.ts index 765f3893067..4ccb40216ce 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; @@ -78,6 +79,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.test.ts b/packages/core/src/runtime/farScenes.test.ts new file mode 100644 index 00000000000..f855d7308d9 --- /dev/null +++ b/packages/core/src/runtime/farScenes.test.ts @@ -0,0 +1,113 @@ +import { afterEach, describe, expect, it, vi } 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(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + 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; }`); + }); + 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 new file mode 100644 index 00000000000..93f9d1f61b6 --- /dev/null +++ b/packages/core/src/runtime/farScenes.ts @@ -0,0 +1,119 @@ +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"]); + +type Timed = { host: HTMLElement; span: { start: number; end: number } }; + +/** 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; + const lo = playing.length > 0 ? playing[0]! : before; + const hi = playing.length > 0 ? playing[playing.length - 1]! : before; + 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)); +} + +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, so a cut reads them without forcing style and layout. + * 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, + * 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; + 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); + // 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.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); + continue; + } + const name = host.getAttribute(SCENE_PART_ATTR)!; + resized ||= sizes.get(name) !== size; + sizes.set(name, size); + } + 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)) + 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(); + for (const host of hosts) + if (far.has(host) !== host.hasAttribute(FAR)) host.toggleAttribute(FAR); + }; +} 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..454aeca97ef --- /dev/null +++ b/packages/core/src/runtime/firstRenderReveal.ts @@ -0,0 +1,57 @@ +import { isElementNode } from "./domRealm"; +import { CLIP_HIDDEN_ATTR } from "./timedClipHide"; +import type { RuntimeTimelineChildLike } from "./types"; + +type Child = RuntimeTimelineChildLike & { + timeScale?: () => number; + repeat?: () => number; + totalDuration?: () => number; + getChildren?: ( + nested?: boolean, + tweens?: boolean, + timelines?: boolean, + ignoreBeforeTime?: number, + ) => Child[]; +}; + +const plain = (child: Child) => (child.timeScale?.() ?? 1) === 1 && (child.repeat?.() ?? 0) === 0; +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 { + for (let el = isElementNode(target) ? target : null; el; el = el.parentElement) + 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 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)); + }); + if (nestedPlain) for (const tween of tweensIn(parent, offset)) addTargets(tween, offset); + return nestedPlain; + }; + return visit(timeline as Child, 0) ? clips : null; +} diff --git a/packages/core/src/runtime/init.swapScenes.test.ts b/packages/core/src/runtime/init.swapScenes.test.ts index f553ecea812..752a4adcc97 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(); @@ -53,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) => { @@ -91,7 +92,7 @@ function preview(scenes: Scene[], shared = "s1", sharedMarkup = "") { `` + scenes.map((s) => ``).join(""); const body = - `
${sharedMarkup}` + + `
${sharedMarkup}` + scenes .map( (s) => @@ -1011,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 });`], @@ -1709,3 +1731,362 @@ 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 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(); + // 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", "s1"]); + 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(sceneHost("s0").textContent).toBe("zero"); + for (let i = 0; i < 5; i++) await tick(); + 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(); + 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"); + }); + + 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("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); + 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(); + 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(); + const clip = window.__clipManifest?.clips.find( + (c) => c.parentCompositionId === "s2" && c.tagName === "i", + ); + 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(); + 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(["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"); + }); + + 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(["s0"]); + expect(stop).not.toHaveBeenCalled(); + 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 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(); + 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", "s1"]); + 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(); + 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.test.ts b/packages/core/src/runtime/init.test.ts index 25973fb3bf5..9aa7ba7565b 100644 --- a/packages/core/src/runtime/init.test.ts +++ b/packages/core/src/runtime/init.test.ts @@ -563,6 +563,84 @@ 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("#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); + window.__player?.seek(3.5); + 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("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 { @@ -6040,6 +6118,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 f0b4a60f8ce..0c4e5b75fd7 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -1,5 +1,20 @@ // fallow-ignore-file code-duplication complexity import { setPreviewRasterScale } from "./previewRasterHints"; +import { createFarSceneSkip } from "./farScenes"; +import { + clipLengthsWritten, + hostOf as sceneHostOf, + isParked, + parkedClips, + parkedSceneMarkup, + parkedTimelineScenes, + park as parkScene, + rememberScene, + scenesWanted, + unpark, + windowedScenes, +} from "./sceneWindow"; +import { clipsFirstRenderedIn } from "./firstRenderReveal"; import { refreshSvgSelectorAliases } from "../compiler/svgSelectorAliases"; import { RUNTIME_FILLER } from "./protocol"; import { @@ -16,6 +31,7 @@ import { CLIP_HIDDEN_ATTR, revealTimedClipsAfterFirstPass, SKIPPED_CLIP, + showClips, showHiddenClips, defersMedia, skipsHiddenImages, @@ -23,6 +39,8 @@ 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, } from "../studioPreviewMark"; @@ -107,7 +125,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, @@ -1539,6 +1557,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; @@ -1630,6 +1657,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)); @@ -1643,6 +1671,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); @@ -2076,6 +2105,7 @@ export function initSandboxRuntimeModular(): void { return false; } state.capturedTimeline = resolution.timeline; + dropUnusedBuiltRoots(); if (typeof state.capturedTimeline.timeScale === "function") { state.capturedTimeline.timeScale(state.playbackRate); } @@ -2452,6 +2482,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); } @@ -2617,7 +2648,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 => { @@ -2841,8 +2875,39 @@ 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) => + runtimeElementWindow(host, { + timelineRegistry: window.__timelines ?? {}, + 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; const RELEASE_LOOKAHEAD_SECONDS = 4; // Unskipped while within the window on either side of the playhead, so a step or shuttle back @@ -3094,6 +3159,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); @@ -3137,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 && @@ -3163,6 +3232,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 @@ -3624,13 +3694,15 @@ 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; - const payload = collectRuntimeTimelinePayload({ - canonicalFps: state.canonicalFps, - }); + const payload = collectRuntimeTimelinePayload(timelinePayloadParams()); window.__clipManifest = payload; lastPostedManifest = JSON.stringify(payload); @@ -3668,9 +3740,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(); }; @@ -3901,6 +3971,31 @@ 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. + // 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( + (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, @@ -3908,9 +4003,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; @@ -3937,6 +4036,128 @@ 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)); + // 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)); + (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 = () => + (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 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; + // 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. + 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]; + } + }; + // 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); + 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; + if (joining && clock.isPlaying() && !cutShort) joinPlayback(swappedHosts); + else 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) => @@ -3972,14 +4193,52 @@ 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 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); + }; + 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, + 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)}"]`)); 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]; @@ -4000,8 +4259,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 { @@ -4063,54 +4321,23 @@ 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(); 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. @@ -4129,54 +4356,148 @@ 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}"]`) ?.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 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; + moveDisplayHide(placeholder, host); + recordAuthoredMedia(host); + refreshSvgSelectorAliases(); + const captionHosts = host.querySelector(".caption-group") ? [host] : []; + const captionOverrides = captionHosts.length > 0 ? await fetchCaptionOverrides() : []; + await nextTask(); + await runSceneScripts( + [host], + scripts.map((el) => document.body.appendChild(el)), + ); + await nextTask(); + 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) || 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); + parkedNames.add(name); releaseDetachedMedia(); + webAudio.releaseDecodedExcept(webAudioMediaIn(document)); 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(); }; + // 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); + const landed = windowScenes.find( + ({ name, start, end }) => start <= time && time <= end && parked(name), + ); + 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"); + 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))[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; + } + const wantedNow = (name: string) => + 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 nextTask(); + if (wantedNow(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 scene it lands in is mounted. + 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; + window.__hfParkedSceneMarkup = parkedSceneMarkup; registerRuntimeCleanup(() => { delete window.__hfSwapScenes; + delete window.__hfHoldScenes; + delete window.__hfParkedSceneMarkup; }); } @@ -4499,14 +4820,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); @@ -4671,17 +4985,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 = { @@ -4893,8 +5214,16 @@ 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; - const hideClipsAgain = firstRenders ? showHiddenClips() : null; + // 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; + // 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 @@ -5242,9 +5571,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); } @@ -5340,6 +5666,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 @@ -5392,6 +5719,34 @@ export function initSandboxRuntimeModular(): void { const gen = webAudio.startGeneration(); const clips = webAudioMediaIn(document); 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); @@ -5415,6 +5770,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). diff --git a/packages/core/src/runtime/sceneWindow.ts b/packages/core/src/runtime/sceneWindow.ts new file mode 100644 index 00000000000..09b22e20602 --- /dev/null +++ b/packages/core/src/runtime/sceneWindow.ts @@ -0,0 +1,152 @@ +import { SCENE_NO_SWAP_ATTR, SCENE_PART_ATTR, SCENE_PARTS_META } from "../sceneParts"; +import { STUDIO_PREVIEW_PARKED_SCENE_ATTR as PARKED } from "../studioPreviewMark"; +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. */ +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)}"]`; +// 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 = 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); +} + +/** 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 })); + +// 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)); +} + +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; placeholder: 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, placeholder, 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 = scenesWanted(hosts, time, false); + 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(); + +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/timedClipHide.ts b/packages/core/src/runtime/timedClipHide.ts index 5e8ddf7acad..84f8de6aea3 100644 --- a/packages/core/src/runtime/timedClipHide.ts +++ b/packages/core/src/runtime/timedClipHide.ts @@ -30,8 +30,13 @@ const hideHiddenClipContents = (clip: string) => { return `${clip} * { visibility: hidden !important; } ${filterOutputLayers} { 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 { + return showClips( + Array.from(document.querySelectorAll(`[${CLIP_HIDDEN_ATTR}]`)).filter(only), + ); +} + +export function showClips(hidden: Array): () => void { const putMarksBack = hidden.map((el) => revealClip(el)); return () => { for (const el of hidden) el.style.visibility = "hidden"; diff --git a/packages/core/src/runtime/timeline.ts b/packages/core/src/runtime/timeline.ts index 21805116b71..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,8 +259,12 @@ function buildTimelineClipLabel(node: Element, kind: RuntimeTimelineClip["kind"] return `${humanizeTimelineToken(kind)} ${ordinal + 1}`; } +export type ParkedTimelineScene = { name: string; host: () => Element }; + export function collectRuntimeTimelinePayload(params: { canonicalFps: number; + parked?: ParkedTimelineScene[]; + parkedClips?: Map; }): RuntimeTimelineMessage { const stackingByParent = new Map(); const runtimeWindow = window as Window & { @@ -425,16 +429,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 +462,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 +483,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 +505,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/runtime/webAudioTransport.test.ts b/packages/core/src/runtime/webAudioTransport.test.ts index b438f6a155d..d67df10ae53 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); @@ -1637,6 +1670,24 @@ describe("WebAudioTransport", () => { 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(); + }); + it("keeps a listed clip's decoded audio while its load is between resources", async () => { // An in-place sub-composition reload resets the root voiceover's resource selection, so // `currentSrc` reads empty and the relative attribute behind it keyed a second cache slot: diff --git a/packages/core/src/runtime/webAudioTransport.ts b/packages/core/src/runtime/webAudioTransport.ts index 82ad7754524..b31cafc6046 100644 --- a/packages/core/src/runtime/webAudioTransport.ts +++ b/packages/core/src/runtime/webAudioTransport.ts @@ -162,6 +162,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[] = []; @@ -279,9 +280,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; @@ -902,30 +912,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/core/src/runtime/window.d.ts b/packages/core/src/runtime/window.d.ts index c086ab80e8f..f0ef7703906 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; + __hfHoldScenes?: (names: string[]) => Promise; + __hfParkedSceneMarkup?: (placeholder: Element) => Element | null; __HF_EXPORT_RENDER_SEEK_CONFIG?: { mode?: string; diagnostics?: boolean; diff --git a/packages/core/src/studioPreviewMark.ts b/packages/core/src/studioPreviewMark.ts index dbb5cc73199..63e176dd173 100644 --- a/packages/core/src/studioPreviewMark.ts +++ b/packages/core/src/studioPreviewMark.ts @@ -5,8 +5,12 @@ 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 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, diff --git a/packages/producer/src/services/coreRuntimeBrowser.test.ts b/packages/producer/src/services/coreRuntimeBrowser.test.ts index 0d785ab15dd..c232674262c 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; @@ -1419,6 +1438,291 @@ 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, + ); + + 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, "index.html"), + `
${hosts}
`, + ); + writeFileSync(resolve(dir, "tone.wav"), silentWav()); + // 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`), + ``, + ); + 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; + }; + 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-")); + const scenePage = await browser.newPage(); + try { + const scripts = [ + // 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( + 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); + 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, + ); + }; + 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!.s1; + window.__player!.seek(5); + }); + await scenePage.waitForFunction( + () => document.querySelector('[data-hf-parked-scene][data-hf-scene="s1"]') !== null, + ); + 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 }); + } + }, 60_000); + it("removes the control bridge during teardown", async () => { const result = await page.evaluate(async () => { const runtimeWindow = window as unknown as { diff --git a/packages/studio/src/hooks/useDomSelection.ts b/packages/studio/src/hooks/useDomSelection.ts index 856dd0dfa2e..cddf0b01100 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; + const held = holdScenesOf(iframe, element); + if (held) await held; 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; } @@ -344,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/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); diff --git a/packages/studio/src/utils/sceneHold.test.ts b/packages/studio/src/utils/sceneHold.test.ts new file mode 100644 index 00000000000..1fea9a47632 --- /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("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 new file mode 100644 index 00000000000..5efc34d120c --- /dev/null +++ b/packages/studio/src/utils/sceneHold.ts @@ -0,0 +1,21 @@ +import type { TimelineElement } from "../player"; + +type SceneHoldingWindow = Window & { __hfHoldScenes?: (names: string[]) => Promise }; + +/** 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, +): Promise | undefined { + let hold: SceneHoldingWindow["__hfHoldScenes"]; + try { + hold = (iframe?.contentWindow as SceneHoldingWindow | null)?.__hfHoldScenes; + } catch { + return undefined; + } + if (!hold) return undefined; + const names = element + ? [...(element.compositionAncestors ?? []), element.parentCompositionId ?? ""] + : []; + return hold(names.filter(Boolean)).catch(() => {}); +} diff --git a/scripts/package-size-budgets.json b/scripts/package-size-budgets.json index 48c62dd1f81..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": 3965000 + "packed": 1661748, + "unpacked": 4024585 }, "@hyperframes/engine": { "packed": 456397,