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": `
+
", 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`),
+ `
`,
+ );
+ 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`),
+ `
${"x".repeat(400)}
`,
+ );
+ 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"),
+ `