fix(runtime): make the preview's scene swap safe before Studio turns it on (#4542)

* fix(runtime): a scene swap refuses when an animation outside the scene moves its elements

* test(runtime): pin the scene swap volume re-probe, host CSS variables and diagnostics-free swap

* fix(runtime): a scene swap keeps a video its scene script animates instead of reloading it

* fix(runtime): a scene swap stops every animation the old scene script started

* fix(runtime): record scene script animations only on pages a scene swap can act on

* docs(core): reword the scene animation recording comment

* fix(runtime): watch media as the page parses only on pages a scene swap can act on

* perf(runtime): a scene swap checks for outside tweens in one pass over the film's tweens

* test(runtime): pin the single revert and the cleared animation record of a scene swap

* fix(runtime): a scene swap rebuilds media when the edit changes the scene script

* fix(runtime): a scene swap undoes the sets a scene script made

* fix(runtime): a scene swap rechecks outside tweens and runs new scripts after every host

* test(runtime): pin that a tween started during the caption wait leaves the page untouched

* fix(runtime): a scene swap refuses when its script set a value on an element the swap keeps

* docs(core): say the scene recording drops a finished tween that the timeline would keep when paused

* fix(runtime): a scene swap refuses when two of its animations write to an element it keeps

* fix(runtime): a scene swap resets kept media and refuses animations outside the scene

* fix(runtime): a scene swap keeps a moved video in place and swaps tweens on plain objects

* chore(core): regenerate the inlined position-edits script for the kept-video move reapply

* fix(runtime): a scene swap checks the timeline registry as it is after the caption wait

* fix(runtime): a scene swap refuses when a scene changed during the wait or a revert re-registers

* fix(runtime): a scene swap stops every animation a revert chain registers and rechecks its hosts

* fix(runtime): a scene swap refuses a moved host or a cancelled swap and stops at a shortened end

* fix(runtime): a scene swap refuses when a scene leaves its place or the film during the wait

* fix(runtime): a scene swap checks only its host's place, so a later body append does not refuse it

* fix(runtime): a scene swap refuses when the film's scene layout changed during the wait

* fix(runtime): a scene swap refuses when the order around a scene changed during the wait

* test(runtime): pin that a scene swap checks the film root order when every scene is wrapped

* fix(runtime): a scene swap gives kept media the mute, rate and loop a fresh load gives it

* fix(runtime): a scene swap rebuilds media a script wrote attributes on, keeping the rest

* fix(runtime): a scene swap keeps media carrying the runtime classes or reordered attributes

* fix(runtime): a scene swap resets a kept video's volume as a load does, not through the volume probe
This commit is contained in:
Miguel Ángel
2026-09-28 08:49:12 +00:00
committed by GitHub
parent 5df0bea8e1
commit 080d2c7445
9 changed files with 1320 additions and 62 deletions
@@ -439,6 +439,75 @@ window.__timelines.scene = tl;
expect(gsapTargets).toEqual([["Scene"], ["Scene"]]);
});
it.each([
["records", `<meta name="hf-scene-parts" content="{}">`, true],
["on a page no scene swap can act on, records nothing of", "", false],
])(
"%s what a script starts on GSAP's global timeline, however it reaches GSAP",
(_, head, records) => {
const { document } = parseHTML(
`<html><head>${head}</head><body><div data-composition-id="scene"><p>x</p></div></body></html>`,
);
const children: object[] = [{ startedBefore: true }];
const start = () => {
const animation = { to: () => animation };
children.push(animation);
return animation;
};
const gsap = {
globalTimeline: { getChildren: () => [...children] },
timeline: start,
to: start,
};
const fakeWindow: Record<string, unknown> = { document, __timelines: {}, gsap };
const wrapped = wrapScopedCompositionScript(
`
gsap.timeline().to("p", { x: 1 });
const g = gsap; g.to("p", { x: 1 });
gsap["to"]("p", { x: 1 });
window.gsap.to("p", { x: 1 });
globalThis.gsap.to("p", { x: 1 });
`,
"scene",
);
vi.stubGlobal("gsap", gsap);
try {
new Function("window", "gsap", wrapped)(fakeWindow, gsap);
} finally {
vi.unstubAllGlobals();
}
expect(children).toHaveLength(6);
expect(fakeWindow.__hfSceneAnimations ?? null).toEqual(
records ? { scene: children.slice(1) } : null,
);
},
);
it("records a set, which completes as it is made, without leaving it on GSAP's global timeline", () => {
const { document } = parseHTML(
`<html><head><meta name="hf-scene-parts" content="{}"></head><body><div data-composition-id="scene"><p>x</p></div></body></html>`,
);
const children: object[] = [];
// As the library does: the global timeline drops each animation the moment it completes.
const globalTimeline = {
autoRemoveChildren: true,
getChildren: () => [...children],
remove: (child: object) => void children.splice(children.indexOf(child), 1),
};
const set = () => {
const tween = { totalProgress: () => 1 };
if (!globalTimeline.autoRemoveChildren) children.push(tween);
return tween;
};
const gsap = { globalTimeline, set };
const fakeWindow: Record<string, unknown> = { document, __timelines: {}, gsap };
const wrapped = wrapScopedCompositionScript(`gsap.set("p", { opacity: 0 });`, "scene");
new Function("window", "gsap", wrapped)(fakeWindow, gsap);
expect(fakeWindow.__hfSceneAnimations).toEqual({ scene: [expect.any(Object)] });
expect(children).toEqual([]);
expect(globalTimeline.autoRemoveChildren).toBe(true);
});
it("scopes each selector in a GSAP target array to the composition root", () => {
const { document } = parseHTML(`
<div data-composition-id="scene">
@@ -1,4 +1,5 @@
import postcss, { type AtRule, type Node, type Rule } from "postcss";
import { SCENE_PARTS_META } from "../sceneParts";
const AUTHORED_ROOT_ID_ATTR = "data-hf-authored-id";
const INNER_ROOT_ATTR = "data-hf-inner-root";
@@ -694,8 +695,33 @@ ${source.replace(/<\/(script)/gi, "<\\/$1")}
console.error(__hfErrorLabel, __hfCompId, _err);
}
};
// What the script started on the global gsap timeline, by any route, for a scene swap to revert.
// Only a page with a scene manifest can swap; elsewhere the first script stores null and none records.
var __hfRecordAnimations = function(run) {
if (window.__hfSceneAnimations === undefined) {
window.__hfSceneAnimations = window.document.querySelector(${jsonScriptLiteral(`meta[name="${SCENE_PARTS_META}"]`)})
? {}
: null;
}
var byComp = window.__hfSceneAnimations;
var globalTimeline = __hfBaseGsap && __hfBaseGsap.globalTimeline;
if (!byComp || !globalTimeline || !__hfTimelineCompId) return run();
var before = globalTimeline.getChildren(false);
// A set completes as it is made and would leave the timeline before the diff below.
var autoRemove = globalTimeline.autoRemoveChildren;
globalTimeline.autoRemoveChildren = false;
run();
globalTimeline.autoRemoveChildren = autoRemove;
var recorded = (byComp[__hfTimelineCompId] = byComp[__hfTimelineCompId] || []);
globalTimeline.getChildren(false).forEach(function(animation) {
if (before.indexOf(animation) >= 0) return;
recorded.push(animation);
// Dropped as the timeline drops a finished tween (it keeps a paused one); moved back, a tween re-adds itself.
if (autoRemove && !animation.getChildren && animation.totalProgress() === 1) globalTimeline.remove(animation);
});
};
__hfFindRoot();
__hfRun();
__hfRecordAnimations(__hfRun);
})();`;
}
@@ -1,6 +1,6 @@
// AUTO-GENERATED by scripts/build-position-edits-render.ts - do not edit
const POSITION_EDITS_RENDER_IIFE: string =
'"use strict";(()=>{function T(){return globalThis}function P(e,t){if(typeof window>"u")return;let o=T(),r=o.__hf?.onSwallowed;if(r)try{r({label:e,error:t})}catch(s){}(o.__hfDebug||o.__HYPERFRAMES_DEBUG)&&console.debug(`[hyperframes] ${e} swallowed:`,t)}var x=null;function R(e,t){if(x)try{x({source:"hf-preview",type:"analytics",event:e,properties:t??{}})}catch(o){P("runtime.analytics.site1",o)}}var W="http://www.w3.org/1999/xhtml";function N(e){return e!=null&&e.nodeType===1}function _(e){return N(e)&&(e.namespaceURI===W||e.namespaceURI==="http://www.w3.org/2000/svg")}var w="data-hf-edit-base-x",y="data-hf-edit-base-y",m="data-hf-edit-original-translate",k=e=>{let t=parseFloat(e??"");return Number.isFinite(t)?t:0},O=e=>{let t=[],o=0,r="";for(let s of e.trim())s==="("&&(o+=1),s===")"&&(o=Math.max(0,o-1)),/\\s/.test(s)&&o===0?(r&&t.push(r),r=""):r+=s;return r&&t.push(r),t},L=/^-?(?:\\d+(?:\\.\\d+)?|\\.\\d+)px$/,b=(e,t)=>L.test(e)&&L.test(t)?`${parseFloat(e)+parseFloat(t)}px`:`calc(${e} + ${t})`,V=(e,t,o)=>{if(!e||e==="none")return`${t} ${o}`;let[r,s,a]=O(e);if(r===void 0)return`${t} ${o}`;if(s===void 0)return`${b(r,t)} ${o}`;let i=a===void 0?"":` ${a}`;return`${b(r,t)} ${b(s,o)}${i}`},j=e=>{try{e.ownerDocument.defaultView?.gsap?.getProperty?.(e,"x")}catch{}},G=e=>{let t=e.style.getPropertyValue("translate").trim();if(t)return t==="none"?"":t;try{let o=e.ownerDocument.defaultView,r=o?o.getComputedStyle(e).getPropertyValue("translate").trim():"";return r==="none"?"":r}catch{return""}},E=new WeakMap;function B(e,t){let o=E.get(e);if(!t?.force&&o!==void 0&&e.style.getPropertyValue("translate")!==o){R("position_edit_fold_skipped",{hfId:e.getAttribute("data-hf-id")});return}let r=k(e.getAttribute("data-x"))-k(e.getAttribute(w)),s=k(e.getAttribute("data-y"))-k(e.getAttribute(y));e.hasAttribute(m)||e.setAttribute(m,G(e)),o===void 0&&j(e);let a=e.getAttribute(m)??"",i=V(a,`${r}px`,`${s}px`);e.style.setProperty("translate",i),E.set(e,e.style.getPropertyValue("translate"))}function S(e,t){let o=e.querySelectorAll(`[${m}]:not([${w}]):not([${y}])`);for(let a=0;a<o.length;a++){let i=o[a];if(i===void 0||!_(i))continue;let g=i.getAttribute(m)??"";g===""?i.style.removeProperty("translate"):i.style.setProperty("translate",g),i.removeAttribute(m),E.delete(i)}let r=e.querySelectorAll(`[${w}], [${y}]`),s=0;for(let a=0;a<r.length;a++){let i=r[a];i===void 0||!_(i)||(B(i,t),s+=1)}return s}var $="__hfPositionEditsSeekReapplyWrapped",D=new WeakSet,M=new WeakMap,H=new WeakMap;function F(e){let t=e,o=()=>{try{S(t.document)}catch{}},r=n=>typeof n=="function"&&(D.has(n)||!!n[$]),s=n=>{D.add(n);try{Object.defineProperty(n,$,{value:!0})}catch{}},a=n=>{if(typeof n!="function"||r(n))return n;let l=function(...u){let d=n.apply(this,u);return o(),d};return s(l),l},i=(n,l)=>{let u=M.get(n);if(u?.has(l))return!0;let d=Object.getOwnPropertyDescriptor(n,l);if(d?.configurable===!1){let c=n[l];return typeof c=="function"&&(n[l]=a(c),o()),!1}let p=n[l],f=d?.set;return Object.defineProperty(n,l,{configurable:!0,enumerable:d?.enumerable??!0,get:()=>p,set:c=>{p=a(c),f?.call(n,c)}}),p=a(p),u??(u=new Set),u.add(l),M.set(n,u),o(),!0},g=(n,l)=>{let u=H.get(t),d=Object.getOwnPropertyDescriptor(t,n);if(!u?.has(n)){if(d?.configurable===!1){let c=t[n];return c?i(c,l):!1}let f=t[n];Object.defineProperty(t,n,{configurable:!0,enumerable:d?.enumerable??!0,get:()=>f,set:c=>{f=c,f&&i(f,l)}}),u??(u=new Set),u.add(n),H.set(t,u)}let p=t[n];return p?i(p,l):!1},v=()=>{let n=g("__hf","seek"),l=g("__player","renderSeek");return n&&l};if(v())return;let h=120,A=t.setInterval(()=>{if(v()){t.clearInterval(A);return}h-=1,h<=0&&t.clearInterval(A)},50)}function I(){document.querySelector(`[${w}], [${y}]`)&&(S(document),F(window))}document.readyState==="loading"?document.addEventListener("DOMContentLoaded",I,{once:!0}):I();})();\n';
'"use strict";(()=>{function T(){return globalThis}function P(e,t){if(typeof window>"u")return;let o=T(),r=o.__hf?.onSwallowed;if(r)try{r({label:e,error:t})}catch(s){}(o.__hfDebug||o.__HYPERFRAMES_DEBUG)&&console.debug(`[hyperframes] ${e} swallowed:`,t)}var x=null;function R(e,t){if(x)try{x({source:"hf-preview",type:"analytics",event:e,properties:t??{}})}catch(o){P("runtime.analytics.site1",o)}}var W="http://www.w3.org/1999/xhtml";function N(e){return e!=null&&e.nodeType===1}function _(e){return N(e)&&(e.namespaceURI===W||e.namespaceURI==="http://www.w3.org/2000/svg")}var w="data-hf-edit-base-x",y="data-hf-edit-base-y",m="data-hf-edit-original-translate",k=e=>{let t=parseFloat(e??"");return Number.isFinite(t)?t:0},O=e=>{let t=[],o=0,r="";for(let s of e.trim())s==="("&&(o+=1),s===")"&&(o=Math.max(0,o-1)),/\\s/.test(s)&&o===0?(r&&t.push(r),r=""):r+=s;return r&&t.push(r),t},L=/^-?(?:\\d+(?:\\.\\d+)?|\\.\\d+)px$/,E=(e,t)=>L.test(e)&&L.test(t)?`${parseFloat(e)+parseFloat(t)}px`:`calc(${e} + ${t})`,V=(e,t,o)=>{if(!e||e==="none")return`${t} ${o}`;let[r,s,a]=O(e);if(r===void 0)return`${t} ${o}`;if(s===void 0)return`${E(r,t)} ${o}`;let i=a===void 0?"":` ${a}`;return`${E(r,t)} ${E(s,o)}${i}`},j=e=>{try{e.ownerDocument.defaultView?.gsap?.getProperty?.(e,"x")}catch{}},G=e=>{let t=e.style.getPropertyValue("translate").trim();if(t)return t==="none"?"":t;try{let o=e.ownerDocument.defaultView,r=o?o.getComputedStyle(e).getPropertyValue("translate").trim():"";return r==="none"?"":r}catch{return""}},b=new WeakMap;function B(e,t){let o=b.get(e);if(!t?.force&&o!==void 0&&e.style.getPropertyValue("translate")!==o){R("position_edit_fold_skipped",{hfId:e.getAttribute("data-hf-id")});return}let r=k(e.getAttribute("data-x"))-k(e.getAttribute(w)),s=k(e.getAttribute("data-y"))-k(e.getAttribute(y));e.hasAttribute(m)||e.setAttribute(m,G(e)),o===void 0&&j(e);let a=e.getAttribute(m)??"",i=V(a,`${r}px`,`${s}px`);e.style.setProperty("translate",i),b.set(e,e.style.getPropertyValue("translate"))}function S(e,t){let o=e.querySelectorAll(`[${m}]:not([${w}]):not([${y}])`);for(let a=0;a<o.length;a++){let i=o[a];if(i===void 0||!_(i))continue;let g=i.getAttribute(m)??"";g===""?i.style.removeProperty("translate"):i.style.setProperty("translate",g),i.removeAttribute(m),b.delete(i)}let r=e.querySelectorAll(`[${w}], [${y}]`),s=0;for(let a=0;a<r.length;a++){let i=r[a];i===void 0||!_(i)||(B(i,t),s+=1)}return s}var $="__hfPositionEditsSeekReapplyWrapped",D=new WeakSet,M=new WeakMap,H=new WeakMap;function F(e){let t=e,o=()=>{try{S(t.document)}catch{}},r=n=>typeof n=="function"&&(D.has(n)||!!n[$]),s=n=>{D.add(n);try{Object.defineProperty(n,$,{value:!0})}catch{}},a=n=>{if(typeof n!="function"||r(n))return n;let l=function(...u){let d=n.apply(this,u);return o(),d};return s(l),l},i=(n,l)=>{let u=M.get(n);if(u?.has(l))return!0;let d=Object.getOwnPropertyDescriptor(n,l);if(d?.configurable===!1){let c=n[l];return typeof c=="function"&&(n[l]=a(c),o()),!1}let p=n[l],f=d?.set;return Object.defineProperty(n,l,{configurable:!0,enumerable:d?.enumerable??!0,get:()=>p,set:c=>{p=a(c),f?.call(n,c)}}),p=a(p),u??(u=new Set),u.add(l),M.set(n,u),o(),!0},g=(n,l)=>{let u=H.get(t),d=Object.getOwnPropertyDescriptor(t,n);if(!u?.has(n)){if(d?.configurable===!1){let c=t[n];return c?i(c,l):!1}let f=t[n];Object.defineProperty(t,n,{configurable:!0,enumerable:d?.enumerable??!0,get:()=>f,set:c=>{f=c,f&&i(f,l)}}),u??(u=new Set),u.add(n),H.set(t,u)}let p=t[n];return p?i(p,l):!1},v=()=>{let n=g("__hf","seek"),l=g("__player","renderSeek");return n&&l};if(v())return;let h=120,A=t.setInterval(()=>{if(v()){t.clearInterval(A);return}h-=1,h<=0&&t.clearInterval(A)},50)}function I(){document.querySelector(`[${w}], [${y}]`)&&(S(document),F(window))}document.readyState==="loading"?document.addEventListener("DOMContentLoaded",I,{once:!0}):I();})();\n';
/** Returns the pre-built position-edits render IIFE as a string constant. */
export function getPositionEditsRenderScript(): string {
+2 -1
View File
@@ -1,4 +1,4 @@
import { initSandboxRuntimeModular } from "./init";
import { initSandboxRuntimeModular, installAuthoredMediaCapture } from "./init";
import { installAuthoredOpacityCapture } from "./colorGrading";
import { hideTimedClipsUntilFirstPass } from "./timedClipHide";
import { fitTextFontSize } from "../text/fitTextFontSize";
@@ -28,6 +28,7 @@ type HyperframeWindow = Window & {
// composition's animation scripts (and the grading hide) mutate it — must run
// at script evaluation time, while the document is still parsing.
installAuthoredOpacityCapture();
installAuthoredMediaCapture();
hideTimedClipsUntilFirstPass();
File diff suppressed because it is too large Load Diff
+240 -40
View File
@@ -69,7 +69,13 @@ import {
SCENE_PARTS_META,
type SceneParts,
} from "../sceneParts";
import { applyPositionEdits, installPositionEditsSeekReapply } from "./positionEdits";
import {
applyPositionEdits,
EDIT_ORIGINAL_TRANSLATE_ATTR,
forgetPositionEdit,
installPositionEditsSeekReapply,
} from "./positionEdits";
import { unproxiedSrc } from "./proxySrc";
import { applyVariableBindings, unproxiedMediaSrc } from "./applyVariableBindings";
import { createColorGradingRuntime, type RuntimeColorGradingApi } from "./colorGrading";
import { COLOR_GRADING_AUTHORED_OPACITY_ATTR } from "../colorGrading";
@@ -97,6 +103,7 @@ import type {
RuntimeSeekOptions,
RuntimeTimelineChildLike,
RuntimeTimelineLike,
SceneAnimation,
} from "./types";
import type { PlayerAPI } from "../core.types";
import { swallow } from "./diagnostics";
@@ -246,6 +253,63 @@ const authoredShape = (el: Element): string =>
el.innerHTML,
]);
// What the runtime writes on a video or audio: style (reset on a kept one), preload, the opacity stamp, a moved
// element's original translate and its own __hf- classes. A proxied src is compared as written; a bound one is not.
const RUNTIME_MEDIA_ATTRS = new Set([
"style",
"preload",
"src",
COLOR_GRADING_AUTHORED_OPACITY_ATTR,
EDIT_ORIGINAL_TRANSLATE_ATTR,
]);
const ownClasses = (value: string) =>
value
.split(/\s+/)
.filter((c) => c && !c.startsWith("__hf-"))
.join(" ");
const writtenShape = (el: Element): string =>
JSON.stringify([
Array.from(el.attributes, ({ name, value }) => [
name,
name === "class" ? ownClasses(value) : value,
])
.filter(([name, value]) => !RUNTIME_MEDIA_ATTRS.has(name!) && !(name === "class" && !value))
.sort(([a], [b]) => (a! < b! ? -1 : 1)),
el.hasAttribute("data-var-src") ? null : unproxiedSrc(el),
el.innerHTML,
]);
// Each video and audio as written, recorded as the page parses: a scene script can write to it before init.
const authoredMedia = new WeakMap<Element, string>();
const isMedia = (el: Element) => el.localName === "video" || el.localName === "audio";
const recordAuthoredMedia = (node: Node) => {
if (!isElementNode(node)) return;
// The parser adds one childless element at a time, so most nodes stop at the first check.
const media = isMedia(node)
? [node]
: node.firstElementChild
? node.querySelectorAll("video, audio")
: [];
for (const el of media) if (!authoredMedia.has(el)) authoredMedia.set(el, authoredShape(el));
};
let authoredMediaObserver: MutationObserver | null = null;
/** On a page with a scene manifest, records media as it parses, before scene scripts run; init stops it. */
export function installAuthoredMediaCapture(): void {
if (typeof MutationObserver === "undefined" || !readSceneParts(document)) return;
recordAuthoredMedia(document.documentElement);
authoredMediaObserver = new MutationObserver((records) => {
for (const record of records) {
record.addedNodes.forEach(recordAuthoredMedia);
// The parser can yield inside a <video>, so its <source> children may arrive after it.
const { target } = record;
if (isElementNode(target) && isMedia(target))
authoredMedia.set(target, authoredShape(target));
}
});
authoredMediaObserver.observe(document.documentElement, { childList: true, subtree: true });
}
// URL attributes a scene swap checks besides src, poster and srcset, by tag.
const MEDIA_URL_ATTRS = new Map([
["image", ["href", "xlink:href"]],
@@ -264,12 +328,9 @@ function pageAnimationsForOnePass(): () => Animation[] {
export function initSandboxRuntimeModular(): void {
const state = createRuntimeState();
// Each video and audio as written, captured before the runtime writes to it; a swap keeps only these.
const authoredMedia = new WeakMap<Element, string>();
if (readSceneParts(document)) {
for (const el of document.querySelectorAll("video, audio"))
authoredMedia.set(el, authoredShape(el));
}
authoredMediaObserver?.disconnect();
authoredMediaObserver = null;
if (readSceneParts(document)) recordAuthoredMedia(document.documentElement);
// Runtime-data handlers may replace the timeline object they mutate. Keep the
// reconciliation callback late-bound because the reporter is installed before
// the timeline resolver/binder is declared below. Delivery cannot complete
@@ -2359,16 +2420,19 @@ export function initSandboxRuntimeModular(): void {
}
};
const authoredMediaVolume = (el: HTMLMediaElement): number | null => {
const parsed = Number.parseFloat(el.dataset.volume ?? "");
return Number.isFinite(parsed) ? Math.max(0, Math.min(1, parsed)) : null;
};
const bindMediaMetadataListeners = () => {
if (state.tornDown) return;
const mediaEls = Array.from(document.querySelectorAll("video, audio")) as HTMLMediaElement[];
for (const mediaEl of mediaEls) {
if (metadataBoundMedia.has(mediaEl)) continue;
metadataBoundMedia.add(mediaEl);
const parsedVolume = Number.parseFloat(mediaEl.dataset.volume ?? "");
if (Number.isFinite(parsedVolume)) {
mediaEl.volume = Math.max(0, Math.min(1, parsedVolume));
}
const volume = authoredMediaVolume(mediaEl);
if (volume !== null) mediaEl.volume = volume;
mediaEl.addEventListener("loadedmetadata", scheduleMetadataDurationHydration);
mediaEl.addEventListener("durationchange", scheduleMetadataDurationHydration);
// Web Audio eligibility, reported at DISCOVERY rather than only at
@@ -3243,23 +3307,87 @@ export function initSandboxRuntimeModular(): void {
return urls;
};
let sceneSwapGeneration = 0;
// A video or audio the edit left as written keeps playing: the old element takes its copy's place.
const keepUnchangedMedia = (oldHost: Element, host: Element) => {
// A video or audio the edit left as written keeps playing, unless the scene's scripts changed: what the
// old script wrote to it directly is unknown. A rebuilt one is recorded from the new markup.
const keepUnchangedMedia = (oldHost: Element, host: Element, sameScripts: boolean) => {
const byShape = new Map<string, Element[]>();
for (const el of oldHost.querySelectorAll("video, audio")) {
for (const el of sameScripts ? oldHost.querySelectorAll("video, audio") : []) {
const shape = authoredMedia.get(el);
// Its grading canvas sits beside it in the old scene and cannot follow it.
if (!shape || colorGradingRuntime?.isGraded(el)) continue;
// A stream, output device or key session a script gave it is not in the markup.
const { srcObject, sinkId, mediaKeys } = el as HTMLMediaElement;
if (srcObject || sinkId || mediaKeys) continue;
byShape.set(shape, [...(byShape.get(shape) ?? []), el]);
}
for (const el of host.querySelectorAll("video, audio")) {
const kept = byShape.get(authoredShape(el))?.shift();
if (kept) el.replaceWith(kept);
const shape = authoredShape(el);
const kept = byShape.get(shape)?.shift();
// Anything a script wrote on it, under the old markup or not, is not in a fresh load's copy.
if (!kept || writtenShape(kept) !== writtenShape(el)) {
authoredMedia.set(el, shape);
continue;
}
el.replaceWith(kept);
// Back as written, the tween cache emptied, so the new script's tweens read what a fresh load's do.
window.gsap?.set?.(kept, { clearProps: "all" });
const style = el.getAttribute("style");
if (style === null) kept.removeAttribute("style");
else kept.setAttribute("style", style);
// The swap's closing pass puts its move back, as a load's first pass does.
forgetPositionEdit(kept as HTMLElement);
// Properties no attribute shows, as a load sets them.
const media = kept as HTMLMediaElement;
media.volume = authoredMediaVolume(media) ?? 1;
media.muted = state.bridgeMuted || state.mediaOutputMuted || media.defaultMuted;
media.defaultPlaybackRate = 1;
media.playbackRate = state.playbackRate;
media.preservesPitch = true;
}
};
const compositionIdsIn = (host: Element) =>
[host, ...host.querySelectorAll("[data-composition-id]")].flatMap(
(el) => el.getAttribute("data-composition-id") || [],
);
// gsap binds a tween to elements, so one from outside the scene would go on moving the replaced copy.
const refuseOutsideTweens = (
name: string,
host: Element,
timelines: Record<string, RuntimeTimelineLike | undefined>,
sceneAnimations: Record<string, SceneAnimation[]>,
) => {
const own = new Set<unknown>(
compositionIdsIn(host).flatMap((id) => [timelines[id], ...(sceneAnimations[id] ?? [])]),
);
const inScene = new Set<unknown>([host, ...host.querySelectorAll("*")]);
for (const tween of window.gsap?.globalTimeline?.getChildren?.(true, true, false) ?? []) {
if (!tween.targets?.().some((target) => inScene.has(target))) continue;
let owner: RuntimeTimelineChildLike | undefined = tween;
while (owner && !own.has(owner)) owner = owner.parent;
if (!owner) {
throw new Error(
`scene ${name} cannot be swapped: an animation outside it moves its elements`,
);
}
}
// A revert can leave a value on what the swap keeps; kept media are reset, page nodes outside are refused.
const outside = (target: unknown) =>
typeof (target as Node | null)?.nodeType === "number" && !inScene.has(target);
for (const animation of own as Set<SceneAnimation | undefined>) {
for (const tween of animation
? [animation, ...(animation.getChildren?.(true, true, false) ?? [])]
: []) {
if (tween.targets?.().some(outside)) {
throw new Error(
`scene ${name} cannot be swapped: its animations write outside the scene`,
);
}
}
}
};
// Swap edited scenes in place from a rebuilt preview document. Refuses before changing anything unless
// the documents differ only inside existing scenes; a later failure is left to the caller's reload.
const swapScenes = async (html: string): Promise<void> => {
const swapScenes = async (html: string, signal?: AbortSignal): Promise<void> => {
const generation = sceneSwapGeneration;
const next = new DOMParser().parseFromString(html, "text/html");
const liveParts = readSceneParts(document);
@@ -3306,40 +3434,108 @@ export function initSandboxRuntimeModular(): void {
if (styleCount(oldParts) !== styleCount(newParts)) {
throw new Error(`scene ${name} cannot be swapped: its styles moved`);
}
return { oldParts, newParts, oldHost, newHost };
return {
name,
oldParts,
homes: oldParts.map((el) => el.parentNode),
newParts,
oldHost,
newHost,
};
});
// Each parent on a scene host's way up to the film root, once, with its children in order. The runtime marks
// what it inserts beside media, such as grading canvases, as ignored.
const sceneLayout = () => {
const film = resolveRootCompositionElement();
const parents = new Set<Element>();
for (const el of document.querySelectorAll(`[${SCENE_PART_ATTR}]`)) {
if (el.tagName === "STYLE" || el.tagName === "SCRIPT") continue;
let node = el.parentElement;
for (; node; node = node === film ? null : node.parentElement) {
parents.add(node);
}
}
return [...parents].flatMap((parent) => [
parent,
...Array.from(parent.children).filter((child) => !child.hasAttribute("data-hf-ignore")),
null,
]);
};
const layout = sceneLayout();
// Fetched before the first write, so a stalled or failed request leaves the page as it was.
const captionOverrides = swaps.some(({ newHost }) => newHost.querySelector(".caption-group"))
? await fetchCaptionOverrides()
: [];
if (state.tornDown) throw new Error("the preview was torn down during the swap");
if (signal?.aborted) throw new Error("the swap was cancelled");
if (generation !== sceneSwapGeneration) {
throw new Error("the preview changed while this swap waited");
}
// A data handler or a revert callback can replace or move a scene's parts; the swap would then lose the scene.
const refuseReplacedScenes = () => {
const moved = ({ oldParts, homes }: (typeof swaps)[number]) =>
oldParts.some((el, i) => !el.isConnected || el.parentNode !== homes[i]);
const now = sceneLayout();
const relaidOut = now.length !== layout.length || now.some((node, i) => node !== layout[i]);
if (relaidOut || swaps.some(moved)) {
throw new Error("a scene changed while this swap waited");
}
};
refuseReplacedScenes();
// Read at each use: a data handler or an animation's callback may replace the registry mid-swap.
const timelines = () =>
(window.__timelines ??= {}) as Record<string, RuntimeTimelineLike | undefined>;
const sceneAnimations = () => (window.__hfSceneAnimations ??= {});
const refuseAnyOutsideTweens = () => {
for (const { name, oldHost } of swaps)
refuseOutsideTweens(name, oldHost, timelines(), sceneAnimations());
};
// Checked after the wait, which a tween could start in, and before anything changes.
refuseAnyOutsideTweens();
sceneSwapGeneration += 1;
const timelines = (window.__timelines ??= {}) as Record<
string,
RuntimeTimelineLike | undefined
>;
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[] = [];
for (const { oldParts, newParts, oldHost, newHost } of swaps) {
for (const el of [oldHost, ...oldHost.querySelectorAll("[data-composition-id]")]) {
const id = el.getAttribute("data-composition-id");
const previous = id ? timelines[id] : undefined;
if (!id || !previous) continue;
const old = previous as { revert?: () => void; kill?: () => void };
if (old.revert) old.revert();
else previous.totalTime?.(0, true);
root?.remove?.(previous);
// revert() has already killed it; a second kill() fires onInterrupt again.
if (!old.revert) old.kill?.();
delete timelines[id];
const oldIds = swaps.flatMap(({ oldHost }) => compositionIdsIn(oldHost));
const stopped = new Set<unknown>();
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];
}
// 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
@@ -3347,10 +3543,15 @@ export function initSandboxRuntimeModular(): void {
.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);
keepUnchangedMedia(oldHost, host);
const scripts = (parts: Element[]) =>
parts.flatMap((el) => (el.tagName === "SCRIPT" ? el.outerHTML : [])).join("");
keepUnchangedMedia(oldHost, host, scripts(oldParts) === scripts(newParts));
oldHost.replaceWith(host);
swappedHosts.push(host);
if (host.querySelector(".caption-group")) captionHosts.push(host);
}
// Run once every host is replaced, so no new script binds to a scene still to be swapped.
for (const { newParts } of swaps) {
for (const el of newParts) {
if (el.tagName !== "SCRIPT") continue;
// An imported <script> never runs; a created one does.
@@ -3359,8 +3560,6 @@ export function initSandboxRuntimeModular(): void {
script.textContent = el.textContent;
document.body.appendChild(script);
}
for (const el of host.querySelectorAll("video, audio"))
if (!authoredMedia.has(el)) authoredMedia.set(el, authoredShape(el));
}
document
.querySelector(`meta[name="${SCENE_PARTS_META}"]`)
@@ -3373,7 +3572,7 @@ export function initSandboxRuntimeModular(): void {
for (const host of captionHosts) {
for (const el of [host, ...host.querySelectorAll("[data-composition-id]")]) {
const id = el.getAttribute("data-composition-id");
if (id) timelines[id]?.totalTime?.(0, true);
if (id) window.__timelines?.[id]?.totalTime?.(0, true);
}
}
};
@@ -3395,8 +3594,9 @@ export function initSandboxRuntimeModular(): void {
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, forcing a render at an unchanged time.
transport.seek(state.currentTime, { keepPlaying: true });
// 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();
};
@@ -123,6 +123,11 @@ export const readCurrentTranslate = (el: StylableElement): string => {
*/
const lastAppliedTranslate = new WeakMap<StylableElement, string>();
/** For an element whose inline style was reset: its next application runs as the first did. */
export function forgetPositionEdit(el: StylableElement): void {
lastAppliedTranslate.delete(el);
}
/**
* Apply one element's position edit. Idempotent — the pre-edit translate is
* captured exactly once (into EDIT_ORIGINAL_TRANSLATE_ATTR, empty string
+10
View File
@@ -295,6 +295,16 @@ export type RuntimeTimelineChildLike = {
getChildren?: RuntimeTimelineLike["getChildren"];
};
/** A timeline or tween a composition script started, as a scene swap stops it. */
export type SceneAnimation = {
targets?: () => unknown[];
duration?: () => number;
getChildren?: (nested?: boolean, tweens?: boolean, timelines?: boolean) => SceneAnimation[];
revert?: () => void;
kill?: () => void;
totalTime?: (timeSeconds?: number, suppressEvents?: boolean) => unknown;
};
export type RuntimeTimelineLike = {
play: () => void;
pause: () => void;
+18 -2
View File
@@ -1,4 +1,10 @@
import type { RuntimeSeekOptions, RuntimeTimelineMessage, RuntimeTimelineLike } from "./types";
import type {
RuntimeSeekOptions,
RuntimeTimelineChildLike,
RuntimeTimelineMessage,
RuntimeTimelineLike,
SceneAnimation,
} from "./types";
import type { RuntimeColorGradingApi } from "./colorGrading";
import type { HyperframePickerApi } from "../inline-scripts/pickerApi";
import type { PlayerAPI } from "../core.types";
@@ -80,8 +86,10 @@ declare global {
__playerReady?: boolean;
__renderReady?: boolean;
__hfRuntimeTeardown?: (() => void) | null;
/** What each composition's scripts started on gsap's global timeline, by composition id. */
__hfSceneAnimations?: Record<string, SceneAnimation[]> | null;
/** Swap edited scenes from a rebuilt preview document; refuses before changing anything when it cannot. */
__hfSwapScenes?: (html: string) => Promise<void>;
__hfSwapScenes?: (html: string, signal?: AbortSignal) => Promise<void>;
__HF_EXPORT_RENDER_SEEK_CONFIG?: {
mode?: string;
diagnostics?: boolean;
@@ -127,11 +135,19 @@ declare global {
__HF_PICKER_API?: HyperframePickerApi;
gsap?: {
timeline: (params?: { paused?: boolean }) => RuntimeTimelineLike;
set?: (target: Element, vars: Record<string, unknown>) => unknown;
parseEase?: (
ease: string | ((progress: number) => number),
...args: unknown[]
) => ((progress: number) => number) | null;
registerPlugin?: (plugin: unknown) => void;
globalTimeline?: {
getChildren?: (
nested?: boolean,
tweens?: boolean,
timelines?: boolean,
) => RuntimeTimelineChildLike[];
};
ticker?: {
tick: () => void;
};