mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 17:27:09 +00:00
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:
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user