mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 17:27:09 +00:00
fix(preview): show the correct scene instead of every scene at once on some loads (#3848)
* fix(runtime): show the right scene when the preview DOM comes from another window The Studio preview sometimes builds the composition body in the editor window and adopts it into the preview frame's document. Adopted nodes keep the prototypes of the realm that created them, so `node instanceof HTMLElement` is false for every element in the composition even though the elements are ordinary HTML sitting in that document. Every guard in the runtime written as `if (!(node instanceof HTMLElement))` then skipped the whole document, silently: nothing threw, nothing logged, and readiness still reported success. The timed-element visibility pass wrote no inline visibility at all, so on those loads the preview painted every scene on top of every other and the editor drew off-canvas markers for elements the user could not see. The auto-stamp pass stopped stamping too, which is why the composition came up one timeline clip short. Replace every realm-sensitive element check in the runtime with structural predicates that ask what a node IS (node type, namespace, tag name) rather than which window's constructor made it. Two local `doc.defaultView.HTMLElement` workarounds are deleted with it: they fix only the case where the nodes belong to the document's own realm, and adoption is exactly what breaks that. * fix(studio): scrub the music track the timeline named, not the first audio `resolveScrubAudioEl` tested `byId instanceof HTMLAudioElement` on a node from the preview iframe's document. That node is an instance of the IFRAME's `HTMLAudioElement`, never this module's, so the check was false on every load and the `musicId` hint was dead. Scrub fell through to the first `<audio>` in the document, which the comment right above it warns can be the voiceover, so dragging the playhead could preview the wrong track. Ask what the node is instead. Also close the gaps an independent review found in the runtime fix: - the cross-realm predicate test had no `<audio>` and no `<img>`, so reverting `isAudioElement` or `isImageElement` to `instanceof` left it green. It no longer does, and the audio case also pins `isMediaElement`, which composes from it and gates the media sync path. - nothing stopped the runtime regressing. `lint-runtime-preview-guards.ts` gains a second check kind: patterns that must be ABSENT under a directory, seeded with DOM-typed `instanceof` under `src/runtime`, pointing at domRealm.ts. Comment lines and tests are exempt, both on purpose. - domRealm.ts stated the adoption mechanism as settled fact. The mixed prototypes are measured; how the nodes get into the frame is not identified, and the docstring now says which is which. Its ownership claim is scoped to the runtime, since packages/studio still hand-rolls its own checks.
This commit is contained in:
@@ -13,6 +13,33 @@ type GuardCheckResult = {
|
||||
failed: GuardSpec[];
|
||||
};
|
||||
|
||||
type BanSpec = {
|
||||
id: string;
|
||||
description: string;
|
||||
directory: string;
|
||||
pattern: RegExp;
|
||||
remedy: string;
|
||||
};
|
||||
|
||||
type BanHit = { id: string; filePath: string; line: number; text: string; remedy: string };
|
||||
|
||||
/**
|
||||
* Source shapes that must not come back. Unlike GUARD_SPECS (a pattern that must
|
||||
* be PRESENT in one file), these are patterns that must be ABSENT everywhere
|
||||
* under a directory.
|
||||
*/
|
||||
const BAN_SPECS: BanSpec[] = [
|
||||
{
|
||||
id: "realm_bound_dom_instanceof",
|
||||
description:
|
||||
"`instanceof` against a DOM interface answers which realm built the node, not what the node is. The composition DOM is not always built by the realm the runtime runs in, so these checks can return false for every element in the document — silently, with nothing thrown.",
|
||||
directory: "src/runtime",
|
||||
pattern:
|
||||
/\binstanceof\s+(?:Element|Node|Text|Document|DocumentFragment|ShadowRoot|HTML[A-Za-z]*Element|SVG[A-Za-z]*Element)\b/,
|
||||
remedy: "Use a structural predicate from src/runtime/domRealm.ts instead.",
|
||||
},
|
||||
];
|
||||
|
||||
// Keep only temporary source-shape guards that do not yet have behavioral
|
||||
// coverage. Timeline replacement and early-play rebinding are exercised by
|
||||
// init.test.ts and timelineRebindPolicy.test.ts instead of regexes.
|
||||
@@ -75,13 +102,65 @@ function checkGuards(guards: GuardSpec[]): GuardCheckResult {
|
||||
return { passed, failed };
|
||||
}
|
||||
|
||||
/** Tests assert the banned shape on purpose, to prove the predicates match it. */
|
||||
function isScannableSource(entry: fs.Dirent): boolean {
|
||||
return entry.isFile() && entry.name.endsWith(".ts") && !entry.name.endsWith(".test.ts");
|
||||
}
|
||||
|
||||
function collectSourceFiles(dir: string): string[] {
|
||||
return fs
|
||||
.readdirSync(dir, { withFileTypes: true, recursive: true })
|
||||
.filter(isScannableSource)
|
||||
.map((entry) => path.join(entry.parentPath, entry.name));
|
||||
}
|
||||
|
||||
/** A comment may legitimately name the banned shape while explaining why it is banned. */
|
||||
function isCommentLine(line: string): boolean {
|
||||
const trimmed = line.trimStart();
|
||||
return trimmed.startsWith("//") || trimmed.startsWith("*") || trimmed.startsWith("/*");
|
||||
}
|
||||
|
||||
function checkBans(bans: BanSpec[]): BanHit[] {
|
||||
const hits: BanHit[] = [];
|
||||
for (const ban of bans) {
|
||||
const root = resolveFilePath(ban.directory);
|
||||
if (!fs.existsSync(root)) continue;
|
||||
for (const file of collectSourceFiles(root)) {
|
||||
const lines = fs.readFileSync(file, "utf8").split("\n");
|
||||
lines.forEach((line, index) => {
|
||||
if (isCommentLine(line) || !ban.pattern.test(line)) return;
|
||||
hits.push({
|
||||
id: ban.id,
|
||||
filePath: path.relative(process.cwd(), file),
|
||||
line: index + 1,
|
||||
text: line.trim().slice(0, 160),
|
||||
remedy: ban.remedy,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
return hits;
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
const bannedHits = checkBans(BAN_SPECS);
|
||||
if (bannedHits.length > 0) {
|
||||
console.error(
|
||||
JSON.stringify({
|
||||
event: "runtime_preview_guards_lint_failed",
|
||||
bannedShapes: bannedHits,
|
||||
}),
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const result = checkGuards(GUARD_SPECS);
|
||||
if (result.failed.length === 0) {
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
event: "runtime_preview_guards_lint_passed",
|
||||
checkedGuards: GUARD_SPECS.length,
|
||||
checkedBans: BAN_SPECS.length,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
|
||||
@@ -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 A(){return globalThis}function P(e,t){if(typeof window>"u")return;let n=A(),r=n.__hf?.onSwallowed;if(r)try{r({label:e,error:t})}catch(i){}(n.__hfDebug||n.__HYPERFRAMES_DEBUG)&&console.debug(`[hyperframes] ${e} swallowed:`,t)}var T=null;function R(e,t){if(T)try{T({source:"hf-preview",type:"analytics",event:e,properties:t??{}})}catch(n){P("runtime.analytics.site1",n)}}var w="data-hf-edit-base-x",k="data-hf-edit-base-y",y="data-hf-edit-original-translate",S=e=>{let t=parseFloat(e??"");return Number.isFinite(t)?t:0},V=e=>{let t=[],n=0,r="";for(let i of e.trim())i==="("&&(n+=1),i===")"&&(n=Math.max(0,n-1)),/\\s/.test(i)&&n===0?(r&&t.push(r),r=""):r+=i;return r&&t.push(r),t},$=/^-?(?:\\d+(?:\\.\\d+)?|\\.\\d+)px$/,E=(e,t)=>$.test(e)&&$.test(t)?`${parseFloat(e)+parseFloat(t)}px`:`calc(${e} + ${t})`,I=(e,t,n)=>{if(!e||e==="none")return`${t} ${n}`;let[r,i,u]=V(e);if(r===void 0)return`${t} ${n}`;if(i===void 0)return`${E(r,t)} ${n}`;let d=u===void 0?"":` ${u}`;return`${E(r,t)} ${E(i,n)}${d}`},O=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 n=e.ownerDocument.defaultView,r=n?n.getComputedStyle(e).getPropertyValue("translate").trim():"";return r==="none"?"":r}catch{return""}},h=new WeakMap;function H(e,t){let n=h.get(e);if(!t?.force&&n!==void 0&&e.style.getPropertyValue("translate")!==n){R("position_edit_fold_skipped",{hfId:e.getAttribute("data-hf-id")});return}let r=S(e.getAttribute("data-x"))-S(e.getAttribute(w)),i=S(e.getAttribute("data-y"))-S(e.getAttribute(k));e.hasAttribute(y)||e.setAttribute(y,G(e)),n===void 0&&O(e);let u=e.getAttribute(y)??"",d=I(u,`${r}px`,`${i}px`);e.style.setProperty("translate",d),h.set(e,e.style.getPropertyValue("translate"))}function v(e,t){let n=e.defaultView?.HTMLElement,r=e.defaultView?.SVGElement,i=a=>n||r?n!==void 0&&a instanceof n||r!==void 0&&a instanceof r:typeof a.style?.setProperty=="function",u=e.querySelectorAll(`[${y}]:not([${w}]):not([${k}])`);for(let a=0;a<u.length;a++){let l=u[a];if(l===void 0||!i(l))continue;let b=l.getAttribute(y)??"";b===""?l.style.removeProperty("translate"):l.style.setProperty("translate",b),l.removeAttribute(y),h.delete(l)}let d=e.querySelectorAll(`[${w}], [${k}]`),m=0;for(let a=0;a<d.length;a++){let l=d[a];l===void 0||!i(l)||(H(l,t),m+=1)}return m}var x="__hfPositionEditsSeekReapplyWrapped",D=new WeakSet,F=new WeakMap,W=new WeakMap;function L(e){let t=e,n=()=>{try{v(t.document)}catch{}},r=o=>typeof o=="function"&&(D.has(o)||!!o[x]),i=o=>{D.add(o);try{Object.defineProperty(o,x,{value:!0})}catch{}},u=o=>{if(typeof o!="function"||r(o))return o;let s=function(...c){let f=o.apply(this,c);return n(),f};return i(s),s},d=(o,s)=>{let c=F.get(o);if(c?.has(s))return!0;let f=Object.getOwnPropertyDescriptor(o,s);if(f?.configurable===!1){let p=o[s];return typeof p=="function"&&(o[s]=u(p),n()),!1}let g=o[s],_=f?.set;return Object.defineProperty(o,s,{configurable:!0,enumerable:f?.enumerable??!0,get:()=>g,set:p=>{g=u(p),_?.call(o,p)}}),g=u(g),c??(c=new Set),c.add(s),F.set(o,c),n(),!0},m=(o,s)=>{let c=W.get(t),f=Object.getOwnPropertyDescriptor(t,o);if(!c?.has(o)){if(f?.configurable===!1){let p=t[o];return p?d(p,s):!1}let _=t[o];Object.defineProperty(t,o,{configurable:!0,enumerable:f?.enumerable??!0,get:()=>_,set:p=>{_=p,_&&d(_,s)}}),c??(c=new Set),c.add(o),W.set(t,c)}let g=t[o];return g?d(g,s):!1},a=()=>{let o=m("__hf","seek"),s=m("__player","renderSeek");return o&&s};if(a())return;let l=120,b=t.setInterval(()=>{if(a()){t.clearInterval(b);return}l-=1,l<=0&&t.clearInterval(b)},50)}function M(){document.querySelector(`[${w}], [${k}]`)&&(v(document),L(window))}document.readyState==="loading"?document.addEventListener("DOMContentLoaded",M,{once:!0}):M();})();\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$/,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,F=new WeakMap;function H(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=F.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),F.set(t,u)}let p=t[n];return p?i(p,l):!1},h=()=>{let n=g("__hf","seek"),l=g("__player","renderSeek");return n&&l};if(h())return;let v=120,A=t.setInterval(()=>{if(h()){t.clearInterval(A);return}v-=1,v<=0&&t.clearInterval(A)},50)}function I(){document.querySelector(`[${w}], [${y}]`)&&(S(document),H(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,5 +1,6 @@
|
||||
import type { RuntimeDeterministicAdapter } from "../types";
|
||||
import { swallow } from "../diagnostics";
|
||||
import { isHtmlElement } from "../domRealm";
|
||||
|
||||
export function createCssAdapter(params?: {
|
||||
resolveStartSeconds?: (element: Element) => number;
|
||||
@@ -107,7 +108,7 @@ export function createCssAdapter(params?: {
|
||||
entries = [];
|
||||
const all = document.querySelectorAll("*");
|
||||
for (const rawEl of all) {
|
||||
if (!(rawEl instanceof HTMLElement)) continue;
|
||||
if (!isHtmlElement(rawEl)) continue;
|
||||
const style = window.getComputedStyle(rawEl);
|
||||
if (!style.animationName || style.animationName === "none") continue;
|
||||
entries.push({
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
*/
|
||||
|
||||
import { findInjectedRenderFrame } from "../renderFrameSibling.js";
|
||||
import { isImageElement, isVideoElement } from "../domRealm";
|
||||
|
||||
/**
|
||||
* Resolve the decoded render-frame `<img>` for a source `<video>`, if the
|
||||
@@ -28,7 +29,7 @@ import { findInjectedRenderFrame } from "../renderFrameSibling.js";
|
||||
function resolveRenderFrameImage(video: HTMLVideoElement): HTMLImageElement | null {
|
||||
const sibling = video.nextElementSibling;
|
||||
if (
|
||||
sibling instanceof HTMLImageElement &&
|
||||
isImageElement(sibling) &&
|
||||
sibling.classList.contains("__render_frame__") &&
|
||||
sibling.complete &&
|
||||
sibling.naturalWidth > 0
|
||||
@@ -60,7 +61,7 @@ export function patchVideoTextureCompat(): void {
|
||||
destination: unknown,
|
||||
copySize: unknown,
|
||||
) {
|
||||
if (source?.source instanceof HTMLVideoElement) {
|
||||
if (isVideoElement(source?.source)) {
|
||||
const img = resolveRenderFrameImage(source.source);
|
||||
if (img) {
|
||||
return orig.call(this, { ...source, source: img }, destination, copySize);
|
||||
@@ -98,7 +99,7 @@ export function patchWebGLVideoTextureCompat(): void {
|
||||
const patched = function (this: unknown, ...args: unknown[]) {
|
||||
const lastIndex = args.length - 1;
|
||||
const last = args[lastIndex];
|
||||
if (last instanceof HTMLVideoElement) {
|
||||
if (isVideoElement(last)) {
|
||||
const img = resolveRenderFrameImage(last);
|
||||
if (img) args[lastIndex] = img;
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
isScalarVariableValue as isScalar,
|
||||
isSafeMediaUrl,
|
||||
} from "@hyperframes/parsers/composition";
|
||||
import { isHtmlElement } from "./domRealm";
|
||||
|
||||
// data-var-src only rebinds media `src` on media elements. A user-controlled
|
||||
// variable value assigned to a src is an XSS surface on tags whose src executes
|
||||
@@ -143,7 +144,7 @@ function applyCssCustomProperties(doc: Document, cache: ScopeValuesCache): void
|
||||
const values = valuesForElement(root, cache);
|
||||
for (const [id, value] of Object.entries(values)) {
|
||||
const css = cssValueFor(value);
|
||||
if (css !== null && root instanceof HTMLElement) {
|
||||
if (css !== null && isHtmlElement(root)) {
|
||||
root.style.setProperty(`--${id}`, sanitizeCssValue(css));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
* 2. `wordIndex` — fallback, DOM traversal order across .caption-group > span
|
||||
*/
|
||||
|
||||
import { isHtmlElement } from "./domRealm";
|
||||
|
||||
interface CaptionOverride {
|
||||
wordId?: string;
|
||||
wordIndex?: number;
|
||||
@@ -70,7 +72,7 @@ function declaredCaptionState(tween: GsapTween): "dim" | "active" | undefined {
|
||||
}
|
||||
|
||||
function resolveCaptionWordElement(el: Element | null): HTMLElement | null {
|
||||
if (!(el instanceof HTMLElement)) return null;
|
||||
if (!isHtmlElement(el)) return null;
|
||||
if (el.dataset.captionWrapper !== "true") return el;
|
||||
|
||||
const inner = el.querySelector<HTMLElement>(":scope > span");
|
||||
@@ -83,7 +85,7 @@ function getCaptionWordElements(): HTMLElement[] {
|
||||
|
||||
for (const group of groups) {
|
||||
for (const child of group.children) {
|
||||
if (!(child instanceof HTMLElement)) continue;
|
||||
if (!isHtmlElement(child)) continue;
|
||||
|
||||
const wordEl =
|
||||
child.dataset.captionWrapper === "true"
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
|
||||
import type { RuntimeTimelineLike } from "./types";
|
||||
import { parseStrictFiniteTimingNumber, resolveNaturalMediaTimelineDuration } from "./playbackRate";
|
||||
import { isMediaElement } from "./domRealm";
|
||||
|
||||
export interface ClipNode {
|
||||
readonly id: string;
|
||||
@@ -66,7 +67,7 @@ function durationFromTimeline(
|
||||
}
|
||||
|
||||
function durationFromMedia(el: Element): number | null {
|
||||
if (!(el instanceof HTMLMediaElement) || !Number.isFinite(el.duration)) return null;
|
||||
if (!isMediaElement(el) || !Number.isFinite(el.duration)) return null;
|
||||
return resolveNaturalMediaTimelineDuration(el, el.duration);
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ import { copyMediaVisualStyles } from "../inline-scripts/parityContract";
|
||||
import { readVariablesForElement } from "./variableScope";
|
||||
import { swallow } from "./diagnostics";
|
||||
import { findInjectedRenderFrame } from "./renderFrameSibling";
|
||||
import { isElementNode, isHtmlElement, isImageElement, isVideoElement } from "./domRealm";
|
||||
|
||||
type ColorGradingMediaElement = HTMLVideoElement | HTMLImageElement;
|
||||
|
||||
@@ -307,12 +308,12 @@ export function installAuthoredOpacityCapture(): void {
|
||||
const root = document.documentElement;
|
||||
if (!root) return;
|
||||
const stamp = (el: Element): void => {
|
||||
if (!(el instanceof HTMLElement)) return;
|
||||
if (!isHtmlElement(el)) return;
|
||||
if (el.hasAttribute(COLOR_GRADING_AUTHORED_OPACITY_ATTR)) return;
|
||||
el.setAttribute(COLOR_GRADING_AUTHORED_OPACITY_ATTR, el.style.opacity);
|
||||
};
|
||||
const scan = (node: Node): void => {
|
||||
if (!(node instanceof Element)) return;
|
||||
if (!isElementNode(node)) return;
|
||||
if (node.matches("video, img")) stamp(node);
|
||||
for (const el of node.querySelectorAll("video, img")) stamp(el);
|
||||
};
|
||||
@@ -1459,7 +1460,7 @@ const KUWAHARA_RESOLVE_FRAGMENT_SHADER = [
|
||||
].join("\n");
|
||||
|
||||
function isColorGradingMediaElement(value: Element): value is ColorGradingMediaElement {
|
||||
return value instanceof HTMLVideoElement || value instanceof HTMLImageElement;
|
||||
return isVideoElement(value) || isImageElement(value);
|
||||
}
|
||||
|
||||
function isVisibleForColorGrading(element: ColorGradingMediaElement): boolean {
|
||||
@@ -2443,12 +2444,12 @@ function resolveTarget(
|
||||
}
|
||||
|
||||
function readSourceSize(source: TexImageSource): { width: number; height: number } | null {
|
||||
if (source instanceof HTMLVideoElement) {
|
||||
if (isVideoElement(source)) {
|
||||
return source.videoWidth > 0 && source.videoHeight > 0
|
||||
? { width: source.videoWidth, height: source.videoHeight }
|
||||
: null;
|
||||
}
|
||||
if (source instanceof HTMLImageElement) {
|
||||
if (isImageElement(source)) {
|
||||
return source.naturalWidth > 0 && source.naturalHeight > 0
|
||||
? { width: source.naturalWidth, height: source.naturalHeight }
|
||||
: null;
|
||||
@@ -2457,14 +2458,14 @@ function readSourceSize(source: TexImageSource): { width: number; height: number
|
||||
}
|
||||
|
||||
function isDrawableSource(source: TexImageSource): boolean {
|
||||
if (source instanceof HTMLVideoElement) {
|
||||
if (isVideoElement(source)) {
|
||||
return (
|
||||
source.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA &&
|
||||
source.videoWidth > 0 &&
|
||||
source.videoHeight > 0
|
||||
);
|
||||
}
|
||||
if (source instanceof HTMLImageElement) {
|
||||
if (isImageElement(source)) {
|
||||
return source.complete && source.naturalWidth > 0 && source.naturalHeight > 0;
|
||||
}
|
||||
return false;
|
||||
@@ -2476,7 +2477,7 @@ function findRenderFrameImage(video: HTMLVideoElement): HTMLImageElement | null
|
||||
}
|
||||
|
||||
function hasInjectedRenderFrame(element: ColorGradingMediaElement): boolean {
|
||||
if (!(element instanceof HTMLVideoElement)) return false;
|
||||
if (!isVideoElement(element)) return false;
|
||||
const frame = findRenderFrameImage(element);
|
||||
if (!frame) return false;
|
||||
const style = window.getComputedStyle(frame);
|
||||
@@ -2484,7 +2485,7 @@ function hasInjectedRenderFrame(element: ColorGradingMediaElement): boolean {
|
||||
}
|
||||
|
||||
function isRenderFrameImage(source: TexImageSource): source is HTMLImageElement {
|
||||
return source instanceof HTMLImageElement && source.classList.contains("__render_frame__");
|
||||
return isImageElement(source) && source.classList.contains("__render_frame__");
|
||||
}
|
||||
|
||||
function keepCanvasAboveSource(entry: ColorGradingEntry, source: HTMLImageElement): void {
|
||||
@@ -2494,7 +2495,7 @@ function keepCanvasAboveSource(entry: ColorGradingEntry, source: HTMLImageElemen
|
||||
}
|
||||
|
||||
function getDrawableSource(element: ColorGradingMediaElement): TexImageSource | null {
|
||||
if (element instanceof HTMLVideoElement) {
|
||||
if (isVideoElement(element)) {
|
||||
const renderFrame = findRenderFrameImage(element);
|
||||
if (renderFrame) return renderFrame;
|
||||
}
|
||||
@@ -3057,7 +3058,7 @@ function drawEntry(entry: ColorGradingEntry): boolean {
|
||||
}
|
||||
const sourceSize = readSourceSize(source);
|
||||
if (!sourceSize) return false;
|
||||
const styleSource = source instanceof HTMLElement ? source : entry.element;
|
||||
const styleSource = isHtmlElement(source) ? source : entry.element;
|
||||
const sourceOpacity = entry.element.style.getPropertyValue("opacity");
|
||||
const sourceOpacityPriority = entry.element.style.getPropertyPriority("opacity");
|
||||
const hiddenByColorGrading =
|
||||
@@ -3125,7 +3126,7 @@ function drawEntry(entry: ColorGradingEntry): boolean {
|
||||
const frameTime =
|
||||
typeof runtimeTime === "number" && Number.isFinite(runtimeTime)
|
||||
? Math.max(0, runtimeTime)
|
||||
: entry.element instanceof HTMLVideoElement
|
||||
: isVideoElement(entry.element)
|
||||
? Math.max(0, entry.element.currentTime)
|
||||
: 0;
|
||||
const grainSeed = entry.grainSeed + Math.floor(frameTime * 60);
|
||||
@@ -3200,7 +3201,7 @@ function previewEffectTime(element: ColorGradingMediaElement, useMediaTime: bool
|
||||
if (typeof runtimeTime === "number" && Number.isFinite(runtimeTime)) {
|
||||
return Math.max(0, runtimeTime);
|
||||
}
|
||||
return element instanceof HTMLVideoElement ? Math.max(0, element.currentTime) : 0;
|
||||
return isVideoElement(element) ? Math.max(0, element.currentTime) : 0;
|
||||
}
|
||||
|
||||
function preparePreviewFrame(
|
||||
@@ -3216,7 +3217,7 @@ function preparePreviewFrame(
|
||||
const dimensions = previewDimensions(element, sourceSize, maxDimension);
|
||||
renderer.canvas.width = dimensions.width;
|
||||
renderer.canvas.height = dimensions.height;
|
||||
const styleSource = source instanceof HTMLElement ? source : element;
|
||||
const styleSource = isHtmlElement(source) ? source : element;
|
||||
const style = window.getComputedStyle(styleSource);
|
||||
const uv = calculateObjectFitUv(
|
||||
dimensions.width,
|
||||
@@ -3233,7 +3234,7 @@ function preparePreviewFrame(
|
||||
effectTime: previewEffectTime(element, useMediaTime),
|
||||
grainSeed:
|
||||
seedForElement(element) +
|
||||
(element instanceof HTMLVideoElement ? Math.floor(element.currentTime * 60) : 0),
|
||||
(isVideoElement(element) ? Math.floor(element.currentTime * 60) : 0),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3337,7 +3338,7 @@ function cancelScheduledFrame(entry: ColorGradingEntry): void {
|
||||
window.cancelAnimationFrame(entry.animationFrame);
|
||||
entry.animationFrame = null;
|
||||
}
|
||||
if (entry.videoFrameHandle !== null && entry.element instanceof HTMLVideoElement) {
|
||||
if (entry.videoFrameHandle !== null && isVideoElement(entry.element)) {
|
||||
const videoFrameHost: VideoFrameCallbackHost = entry.element;
|
||||
videoFrameHost.cancelVideoFrameCallback?.(entry.videoFrameHandle);
|
||||
entry.videoFrameHandle = null;
|
||||
@@ -3345,7 +3346,7 @@ function cancelScheduledFrame(entry: ColorGradingEntry): void {
|
||||
}
|
||||
|
||||
function scheduleVideoDraw(entry: ColorGradingEntry): void {
|
||||
if (entry.destroyed || !(entry.element instanceof HTMLVideoElement)) return;
|
||||
if (entry.destroyed || !isVideoElement(entry.element)) return;
|
||||
if (entry.videoFrameHandle !== null || entry.animationFrame !== null) return;
|
||||
const video = entry.element;
|
||||
const videoFrameHost: VideoFrameCallbackHost = video;
|
||||
@@ -3374,7 +3375,7 @@ function installEntryListeners(entry: ColorGradingEntry): void {
|
||||
addListener(entry, entry.element, "seeked", redraw);
|
||||
addListener(entry, entry.element, "timeupdate", redraw);
|
||||
addListener(entry, window, "resize", redraw);
|
||||
if (entry.element instanceof HTMLVideoElement) {
|
||||
if (isVideoElement(entry.element)) {
|
||||
addListener(entry, entry.element, "play", () => scheduleVideoDraw(entry));
|
||||
addListener(entry, entry.element, "pause", redraw);
|
||||
}
|
||||
@@ -3498,7 +3499,7 @@ export function createColorGradingRuntime(): RuntimeColorGradingApi {
|
||||
existing.grading = grading;
|
||||
existing.source = source;
|
||||
drawEntry(existing);
|
||||
if (element instanceof HTMLVideoElement && !element.paused) scheduleVideoDraw(existing);
|
||||
if (isVideoElement(element) && !element.paused) scheduleVideoDraw(existing);
|
||||
return true;
|
||||
}
|
||||
let renderer = idleRenderers.pop();
|
||||
@@ -3553,7 +3554,7 @@ export function createColorGradingRuntime(): RuntimeColorGradingApi {
|
||||
trackedElements.add(element);
|
||||
installEntryListeners(entry);
|
||||
drawEntry(entry);
|
||||
if (element instanceof HTMLVideoElement && !element.paused) scheduleVideoDraw(entry);
|
||||
if (isVideoElement(element) && !element.paused) scheduleVideoDraw(entry);
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -3665,7 +3666,7 @@ export function createColorGradingRuntime(): RuntimeColorGradingApi {
|
||||
(property) => readAnimatedValue(element, property) !== null,
|
||||
);
|
||||
if (!hasAnimatedProperty) continue;
|
||||
if (element instanceof HTMLVideoElement && !element.paused && !element.ended) continue;
|
||||
if (isVideoElement(element) && !element.paused && !element.ended) continue;
|
||||
if (drawEntry(entry)) drawn += 1;
|
||||
}
|
||||
return drawn;
|
||||
@@ -3770,7 +3771,7 @@ export function createColorGradingRuntime(): RuntimeColorGradingApi {
|
||||
target: HfColorGradingTarget | string | null | undefined,
|
||||
): (() => void) | null => {
|
||||
const element = resolveTarget(target);
|
||||
if (!(element instanceof HTMLVideoElement)) return null;
|
||||
if (!isVideoElement(element)) return null;
|
||||
if (!element.paused) return () => undefined;
|
||||
const time = element.currentTime;
|
||||
const loop = element.loop;
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
warnUnknownEnumValues,
|
||||
readRenderOverrides,
|
||||
} from "./getVariables";
|
||||
import { isElementNode, isHtmlElement, isLinkElement, isStyleElement } from "./domRealm";
|
||||
|
||||
type LoadExternalCompositionsParams = {
|
||||
injectedStyles: HTMLStyleElement[];
|
||||
@@ -426,7 +427,7 @@ async function mountCompositionContent(params: {
|
||||
});
|
||||
// The mount sizes and flattens the root, which needs an HTMLElement; a root
|
||||
// that is not one mounts as plain content, exactly as before.
|
||||
const innerRoot = plan.innerRoot instanceof HTMLElement ? plan.innerRoot : null;
|
||||
const innerRoot = isHtmlElement(plan.innerRoot) ? plan.innerRoot : null;
|
||||
const contentNode = innerRoot ?? params.sourceNode;
|
||||
const authoredScopeCompositionId = plan.authoredCompositionId;
|
||||
// Scripts follow the id the CONTENT declares, CSS the id the HOST asked for.
|
||||
@@ -449,7 +450,7 @@ async function mountCompositionContent(params: {
|
||||
if (params.compositionUrl && isSameDocumentUrl(href, params.compositionUrl)) continue;
|
||||
if (document.head.querySelector(`link[href="${CSS.escape(href)}"]`)) continue;
|
||||
const clonedLink = link.cloneNode(true);
|
||||
if (!(clonedLink instanceof HTMLLinkElement)) continue;
|
||||
if (!isLinkElement(clonedLink)) continue;
|
||||
clonedLink.href = href;
|
||||
document.head.appendChild(clonedLink);
|
||||
params.injectedLinks.push(clonedLink);
|
||||
@@ -458,7 +459,7 @@ async function mountCompositionContent(params: {
|
||||
const injectScopedStyles = (styleEls: Iterable<Element>): void => {
|
||||
for (const style of styleEls) {
|
||||
const clonedStyle = style.cloneNode(true);
|
||||
if (!(clonedStyle instanceof HTMLStyleElement)) continue;
|
||||
if (!isStyleElement(clonedStyle)) continue;
|
||||
if (authoredScopeCompositionId) {
|
||||
clonedStyle.textContent = scopeCssToComposition(
|
||||
clonedStyle.textContent || "",
|
||||
@@ -511,8 +512,8 @@ async function mountCompositionContent(params: {
|
||||
const heightPx = params.parseDimensionPx(heightRaw);
|
||||
if (widthRaw) params.host.setAttribute("data-width", widthRaw);
|
||||
if (heightRaw) params.host.setAttribute("data-height", heightRaw);
|
||||
if (widthPx && params.host instanceof HTMLElement) params.host.style.width = widthPx;
|
||||
if (heightPx && params.host instanceof HTMLElement) params.host.style.height = heightPx;
|
||||
if (widthPx && isHtmlElement(params.host)) params.host.style.width = widthPx;
|
||||
if (heightPx && isHtmlElement(params.host)) params.host.style.height = heightPx;
|
||||
if (innerRoot.hasAttribute("data-timeline-locked")) {
|
||||
params.host.setAttribute("data-timeline-locked", "");
|
||||
}
|
||||
@@ -770,7 +771,7 @@ function stashInstanceVariables(
|
||||
): void {
|
||||
const declaredDefaults =
|
||||
params.declaredVariableDefaults ??
|
||||
(contentNode instanceof Element ? readDeclaredDefaults(contentNode) : {});
|
||||
(isElementNode(contentNode) ? readDeclaredDefaults(contentNode) : {});
|
||||
const merged = {
|
||||
...declaredDefaults,
|
||||
...parseHostVariableValues(params.host),
|
||||
@@ -779,7 +780,7 @@ function stashInstanceVariables(
|
||||
// out-of-set enum guard runs here too, against the same merged values the
|
||||
// instance reads back out of __hfVariablesByComp.
|
||||
warnUnknownEnumValues(
|
||||
params.variableDeclarer ?? (contentNode instanceof Element ? contentNode : null),
|
||||
params.variableDeclarer ?? (isElementNode(contentNode) ? contentNode : null),
|
||||
merged,
|
||||
runtimeScopeCompositionId,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { JSDOM } from "jsdom";
|
||||
import {
|
||||
isAudioElement,
|
||||
isElementNode,
|
||||
isHtmlElement,
|
||||
isImageElement,
|
||||
isLinkElement,
|
||||
isMediaElement,
|
||||
isStylableElement,
|
||||
isStyleElement,
|
||||
isVideoElement,
|
||||
} from "./domRealm";
|
||||
import { initSandboxRuntimeModular } from "./init";
|
||||
import type { RuntimeTimelineLike } from "./types";
|
||||
|
||||
/**
|
||||
* A second realm whose elements are ADOPTED into this realm's document. That is
|
||||
* what the Studio preview does on some loads: the composition body is built in
|
||||
* the editor window and moved into the preview iframe's document, so the nodes
|
||||
* sit in `document` with another realm's prototypes. Every `instanceof
|
||||
* HTMLElement` in the runtime then answers false and the guard it protects skips
|
||||
* the entire composition, silently.
|
||||
*/
|
||||
function foreignRealm() {
|
||||
const dom = new JSDOM("<!doctype html><html><body></body></html>");
|
||||
return {
|
||||
create: <K extends keyof HTMLElementTagNameMap>(tag: K) =>
|
||||
dom.window.document.createElement(tag) as unknown as HTMLElementTagNameMap[K],
|
||||
createSvg: (tag: string) =>
|
||||
dom.window.document.createElementNS(
|
||||
"http://www.w3.org/2000/svg",
|
||||
tag,
|
||||
) as unknown as SVGElement,
|
||||
close: () => dom.window.close(),
|
||||
};
|
||||
}
|
||||
|
||||
/** A composition root built in the other realm and adopted into this document. */
|
||||
function mountCrossRealmRoot(other: ReturnType<typeof foreignRealm>): HTMLElement {
|
||||
const root = other.create("div");
|
||||
root.setAttribute("data-composition-id", "main");
|
||||
root.setAttribute("data-root", "true");
|
||||
root.setAttribute("data-start", "0");
|
||||
root.setAttribute("data-width", "1920");
|
||||
root.setAttribute("data-height", "1080");
|
||||
document.body.appendChild(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
function mockTimeline(duration: number): RuntimeTimelineLike {
|
||||
const state = { time: 0, paused: true };
|
||||
return {
|
||||
play: () => {
|
||||
state.paused = false;
|
||||
},
|
||||
pause: () => {
|
||||
state.paused = true;
|
||||
},
|
||||
seek: (time?: number) => {
|
||||
if (time !== undefined) state.time = time;
|
||||
return state.time;
|
||||
},
|
||||
totalTime: (time?: number) => {
|
||||
if (time !== undefined) state.time = time;
|
||||
return state.time;
|
||||
},
|
||||
time: () => state.time,
|
||||
duration: () => duration,
|
||||
add: () => {},
|
||||
paused: (value?: boolean) => {
|
||||
if (typeof value === "boolean") state.paused = value;
|
||||
return state.paused;
|
||||
},
|
||||
timeScale: () => {},
|
||||
set: () => {},
|
||||
getChildren: () => [],
|
||||
};
|
||||
}
|
||||
|
||||
describe("domRealm predicates", () => {
|
||||
it("matches instanceof for same-realm nodes", () => {
|
||||
const div = document.createElement("div");
|
||||
const video = document.createElement("video");
|
||||
const audio = document.createElement("audio");
|
||||
const img = document.createElement("img");
|
||||
const style = document.createElement("style");
|
||||
const link = document.createElement("link");
|
||||
const svg = document.createElementNS("http://www.w3.org/2000/svg", "rect");
|
||||
|
||||
expect([
|
||||
isHtmlElement(div),
|
||||
isVideoElement(video),
|
||||
isAudioElement(audio),
|
||||
isMediaElement(video),
|
||||
isMediaElement(audio),
|
||||
isImageElement(img),
|
||||
isStyleElement(style),
|
||||
isLinkElement(link),
|
||||
isElementNode(svg),
|
||||
]).toEqual([true, true, true, true, true, true, true, true, true]);
|
||||
|
||||
// Parity with instanceof HTMLElement, which excludes SVG.
|
||||
expect(svg instanceof HTMLElement).toBe(false);
|
||||
expect(isHtmlElement(svg)).toBe(false);
|
||||
// Position edits apply to SVG too, so the stylable test includes it.
|
||||
expect(isStylableElement(svg)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects non-elements without throwing", () => {
|
||||
expect([
|
||||
isHtmlElement(null),
|
||||
isHtmlElement(undefined),
|
||||
isHtmlElement("div"),
|
||||
isHtmlElement(document),
|
||||
isHtmlElement(document.createTextNode("x")),
|
||||
isVideoElement({ localName: "video" }),
|
||||
]).toEqual([false, false, false, false, false, false]);
|
||||
});
|
||||
|
||||
it("accepts an element adopted from another realm, where instanceof does not", () => {
|
||||
const other = foreignRealm();
|
||||
try {
|
||||
const div = other.create("div");
|
||||
const video = other.create("video");
|
||||
const audio = other.create("audio");
|
||||
const img = other.create("img");
|
||||
document.body.append(div, video, audio, img);
|
||||
|
||||
expect(div instanceof HTMLElement).toBe(false);
|
||||
expect(video instanceof HTMLVideoElement).toBe(false);
|
||||
expect(audio instanceof HTMLAudioElement).toBe(false);
|
||||
expect(img instanceof HTMLImageElement).toBe(false);
|
||||
|
||||
expect(isHtmlElement(div)).toBe(true);
|
||||
expect(isVideoElement(video)).toBe(true);
|
||||
expect(isMediaElement(video)).toBe(true);
|
||||
expect(isAudioElement(audio)).toBe(true);
|
||||
expect(isImageElement(img)).toBe(true);
|
||||
|
||||
// `isMediaElement` is the audio-or-video composition, and the media sync
|
||||
// path narrows through it, so an audio-only regression must fail here too.
|
||||
expect(isMediaElement(audio)).toBe(true);
|
||||
expect(isVideoElement(audio)).toBe(false);
|
||||
expect(isAudioElement(video)).toBe(false);
|
||||
|
||||
// Position edits apply to SVG as well as HTML, so that branch needs the
|
||||
// same cross-realm guarantee.
|
||||
const rect = other.createSvg("rect");
|
||||
document.body.append(rect);
|
||||
expect(rect instanceof SVGElement).toBe(false);
|
||||
expect(isStylableElement(rect)).toBe(true);
|
||||
expect(isHtmlElement(rect)).toBe(false);
|
||||
} finally {
|
||||
other.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("runtime passes over a composition adopted from another realm", () => {
|
||||
afterEach(() => {
|
||||
window.__hfRuntimeTeardown?.();
|
||||
document.body.innerHTML = "";
|
||||
window.__timelines = {} as Record<string, RuntimeTimelineLike>;
|
||||
delete window.__player;
|
||||
delete window.__renderReady;
|
||||
});
|
||||
|
||||
/**
|
||||
* Every timed element must end up with an explicit inline `visibility`, and
|
||||
* it must track the playhead. Leave any of them unwritten and the browser
|
||||
* paints every scene on top of every other for the life of the page.
|
||||
*/
|
||||
it("writes timed-element visibility for cross-realm clips", () => {
|
||||
const other = foreignRealm();
|
||||
try {
|
||||
const root = mountCrossRealmRoot(other);
|
||||
|
||||
const early = other.create("div");
|
||||
early.setAttribute("data-start", "0");
|
||||
early.setAttribute("data-duration", "2");
|
||||
root.appendChild(early);
|
||||
|
||||
const late = other.create("div");
|
||||
late.setAttribute("data-start", "4");
|
||||
late.setAttribute("data-duration", "2");
|
||||
root.appendChild(late);
|
||||
|
||||
window.__timelines = { main: mockTimeline(10) };
|
||||
initSandboxRuntimeModular();
|
||||
window.__player?.renderSeek(1);
|
||||
|
||||
expect([early.style.visibility, late.style.visibility]).toEqual(["visible", "hidden"]);
|
||||
|
||||
window.__player?.renderSeek(4.5);
|
||||
expect([early.style.visibility, late.style.visibility]).toEqual(["hidden", "visible"]);
|
||||
} finally {
|
||||
other.close();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* An ID'd child of the composition root must still be auto-stamped, so the
|
||||
* Studio timeline can show it as a clip and the design panel can select it.
|
||||
*/
|
||||
it("auto-stamps ID'd children of a cross-realm composition root", () => {
|
||||
const other = foreignRealm();
|
||||
try {
|
||||
const root = mountCrossRealmRoot(other);
|
||||
|
||||
const chip = other.create("div");
|
||||
chip.id = "kicker-chip";
|
||||
root.appendChild(chip);
|
||||
|
||||
window.__timelines = { main: mockTimeline(10) };
|
||||
// The stamp only runs inside the studio preview (`window.parent !== window`).
|
||||
const realParent = window.parent;
|
||||
Object.defineProperty(window, "parent", { value: {}, configurable: true });
|
||||
try {
|
||||
initSandboxRuntimeModular();
|
||||
} finally {
|
||||
Object.defineProperty(window, "parent", { value: realParent, configurable: true });
|
||||
}
|
||||
|
||||
expect(chip.getAttribute("data-start")).toBe("0");
|
||||
expect(chip.getAttribute("data-hf-autostamped")).toBe("1");
|
||||
expect(document.querySelectorAll("[data-start]")).toHaveLength(2);
|
||||
} finally {
|
||||
other.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Realm-independent "what kind of node is this" tests for composition DOM.
|
||||
*
|
||||
* THE INVARIANT: the runtime never assumes the composition DOM was built by the
|
||||
* same JavaScript realm the runtime is running in. It asks what a node IS (node
|
||||
* type, namespace, tag name), never which window's constructor made it.
|
||||
*
|
||||
* Why that matters. `node instanceof HTMLElement` compares against the
|
||||
* `HTMLElement` of the realm this module was loaded into, so it answers "which
|
||||
* window built this node", not "what is this node". One document can hold
|
||||
* elements from several realms at once, and a node keeps the prototype chain of
|
||||
* whichever realm created it however it got there.
|
||||
*
|
||||
* MEASURED, in the Studio preview, on some loads: `<html>`, `<head>` and
|
||||
* `<script>` carry the preview frame's prototypes while `<body>` and every
|
||||
* element under it carry the editor window's. Tagging each realm's
|
||||
* `Element.prototype` before any page script ran and reading the tag back off
|
||||
* the live nodes is how that was established. NOT established: how the nodes
|
||||
* come to be there. No `adoptNode`, `importNode` or `document.write` call was
|
||||
* caught doing it, and the frame is populated by `src`/`srcdoc`, so treat the
|
||||
* mechanism as an open question and the mixed realms as a fact.
|
||||
*
|
||||
* Either way `instanceof` does not throw and does not warn. It just returns
|
||||
* false, forever, for every element in the composition, and every guard written
|
||||
* as `if (!(node instanceof HTMLElement)) continue;` silently skips the whole
|
||||
* document. A whole pass goes dark with no error: the timed-element visibility
|
||||
* pass stops writing, so every scene paints on top of every other.
|
||||
*
|
||||
* `doc.defaultView.HTMLElement` is not a sufficient repair, which is why this
|
||||
* module replaced two hand-rolled attempts at it. It fixes the case where the
|
||||
* nodes belong to the document's own realm and still fails when they belong to
|
||||
* a third one. A structural test has no such case to get wrong.
|
||||
*
|
||||
* Scope of ownership: these predicates own the RUNTIME's answer to "what kind of
|
||||
* node is this", for HTML and SVG, which is everything a composition contains.
|
||||
* `packages/studio` still hand-rolls its own checks in several places and is not
|
||||
* covered by this module or by the lint guard that keeps `src/runtime` clean
|
||||
* (`scripts/lint-runtime-preview-guards.ts`).
|
||||
*/
|
||||
|
||||
const XHTML_NS = "http://www.w3.org/1999/xhtml";
|
||||
|
||||
interface NodeLike {
|
||||
readonly nodeType?: unknown;
|
||||
readonly namespaceURI?: unknown;
|
||||
readonly localName?: unknown;
|
||||
}
|
||||
|
||||
/** Any element node, from any realm (HTML, SVG, MathML). Mirrors `instanceof Element`. */
|
||||
export function isElementNode(value: unknown): value is Element {
|
||||
return value != null && (value as NodeLike).nodeType === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* An HTML element, from any realm. Mirrors `instanceof HTMLElement`, including
|
||||
* its exclusion of SVG/MathML: "is an HTMLElement" and "is an element in the
|
||||
* XHTML namespace" are the same set.
|
||||
*/
|
||||
export function isHtmlElement(value: unknown): value is HTMLElement {
|
||||
return isElementNode(value) && (value as NodeLike).namespaceURI === XHTML_NS;
|
||||
}
|
||||
|
||||
/** Single tag, by value: a rest parameter here allocates on every call, and these
|
||||
* predicates run per element per seek and per animation frame. */
|
||||
function isHtmlTag(value: unknown, tag: string): boolean {
|
||||
return isHtmlElement(value) && (value as NodeLike).localName === tag;
|
||||
}
|
||||
|
||||
/** `<video>`, from any realm. Mirrors `instanceof HTMLVideoElement`. */
|
||||
export function isVideoElement(value: unknown): value is HTMLVideoElement {
|
||||
return isHtmlTag(value, "video");
|
||||
}
|
||||
|
||||
/** `<audio>`, from any realm. Mirrors `instanceof HTMLAudioElement`. */
|
||||
export function isAudioElement(value: unknown): value is HTMLAudioElement {
|
||||
return isHtmlTag(value, "audio");
|
||||
}
|
||||
|
||||
/** `<video>` or `<audio>`, from any realm. Mirrors `instanceof HTMLMediaElement`. */
|
||||
export function isMediaElement(value: unknown): value is HTMLMediaElement {
|
||||
return isVideoElement(value) || isAudioElement(value);
|
||||
}
|
||||
|
||||
/** `<img>`, from any realm. Mirrors `instanceof HTMLImageElement`. */
|
||||
export function isImageElement(value: unknown): value is HTMLImageElement {
|
||||
return isHtmlTag(value, "img");
|
||||
}
|
||||
|
||||
/** `<style>`, from any realm. Mirrors `instanceof HTMLStyleElement`. */
|
||||
export function isStyleElement(value: unknown): value is HTMLStyleElement {
|
||||
return isHtmlTag(value, "style");
|
||||
}
|
||||
|
||||
/** `<link>`, from any realm. Mirrors `instanceof HTMLLinkElement`. */
|
||||
export function isLinkElement(value: unknown): value is HTMLLinkElement {
|
||||
return isHtmlTag(value, "link");
|
||||
}
|
||||
|
||||
/**
|
||||
* An element that carries a CSS `style` map: HTML **or** SVG. Position edits and
|
||||
* transform writes apply to both (authored SVG `<text>`/shapes honour the CSS
|
||||
* `translate` longhand exactly as HTML does).
|
||||
*/
|
||||
export function isStylableElement(value: unknown): value is HTMLElement | SVGElement {
|
||||
return (
|
||||
isElementNode(value) &&
|
||||
((value as NodeLike).namespaceURI === XHTML_NS ||
|
||||
(value as NodeLike).namespaceURI === "http://www.w3.org/2000/svg")
|
||||
);
|
||||
}
|
||||
@@ -74,6 +74,14 @@ import {
|
||||
setRuntimeDataAppliedReporter,
|
||||
setRuntimeDataErrorReporter,
|
||||
} from "./runtimeData";
|
||||
import {
|
||||
isAudioElement,
|
||||
isElementNode,
|
||||
isHtmlElement,
|
||||
isImageElement,
|
||||
isMediaElement,
|
||||
isVideoElement,
|
||||
} from "./domRealm";
|
||||
|
||||
const AUTHORED_DURATION_ATTR = "data-hf-authored-duration";
|
||||
const AUTHORED_END_ATTR = "data-hf-authored-end";
|
||||
@@ -316,7 +324,7 @@ export function initSandboxRuntimeModular(): void {
|
||||
// closure-based `resolveRootCompositionElement` does on multi-comp pages.
|
||||
const findRootCompositionEl = (): HTMLElement | null => {
|
||||
const explicitRoot = document.querySelector('[data-composition-id][data-root="true"]');
|
||||
if (explicitRoot instanceof HTMLElement) return explicitRoot;
|
||||
if (isHtmlElement(explicitRoot)) return explicitRoot;
|
||||
const nodes = Array.from(document.querySelectorAll("[data-composition-id]")) as HTMLElement[];
|
||||
return (
|
||||
nodes.find((node) => !node.parentElement?.closest("[data-composition-id]")) ??
|
||||
@@ -869,7 +877,7 @@ export function initSandboxRuntimeModular(): void {
|
||||
rootEl.querySelectorAll("[data-composition-id][data-start]"),
|
||||
);
|
||||
for (const node of compositionNodes) {
|
||||
if (!(node instanceof Element)) continue;
|
||||
if (!isElementNode(node)) continue;
|
||||
const parentComposition = node.parentElement?.closest("[data-composition-id]");
|
||||
if (parentComposition !== rootEl) continue;
|
||||
const start = startResolver.resolveStartForElement(node, 0);
|
||||
@@ -1682,7 +1690,7 @@ export function initSandboxRuntimeModular(): void {
|
||||
for (const child of state.capturedTimeline.getChildren(true)) {
|
||||
if (typeof child.targets !== "function") continue;
|
||||
for (const target of child.targets()) {
|
||||
if (!(target instanceof HTMLElement)) continue;
|
||||
if (!isHtmlElement(target)) continue;
|
||||
if (target === rootComp) continue;
|
||||
if (isAudioGroupBus(target)) continue;
|
||||
if (target.hasAttribute("data-start")) continue;
|
||||
@@ -1704,9 +1712,9 @@ export function initSandboxRuntimeModular(): void {
|
||||
// Stamp all ID'd children of the composition root so they appear
|
||||
// in the timeline even without animations. Enables selecting and
|
||||
// adding animations from the design panel on a blank canvas.
|
||||
if (rootComp instanceof HTMLElement) {
|
||||
if (isHtmlElement(rootComp)) {
|
||||
for (const el of rootComp.querySelectorAll("[id]")) {
|
||||
if (!(el instanceof HTMLElement)) continue;
|
||||
if (!isHtmlElement(el)) continue;
|
||||
if (el === rootComp) continue;
|
||||
if (el.hasAttribute("data-start")) continue;
|
||||
if (hasAuthoredTimedAncestor(el)) continue;
|
||||
@@ -1766,7 +1774,7 @@ export function initSandboxRuntimeModular(): void {
|
||||
|
||||
const emitRootStageLayoutDiagnostics = () => {
|
||||
const rootNode = resolveRootCompositionElement();
|
||||
if (!(rootNode instanceof HTMLElement)) {
|
||||
if (!isHtmlElement(rootNode)) {
|
||||
return;
|
||||
}
|
||||
const rect = rootNode.getBoundingClientRect();
|
||||
@@ -1869,7 +1877,7 @@ export function initSandboxRuntimeModular(): void {
|
||||
);
|
||||
for (const node of assetNodes) {
|
||||
const onError = () => {
|
||||
if (!(node instanceof Element)) {
|
||||
if (!isElementNode(node)) {
|
||||
return;
|
||||
}
|
||||
const tagName = node.tagName.toLowerCase();
|
||||
@@ -1886,11 +1894,9 @@ export function initSandboxRuntimeModular(): void {
|
||||
tagName,
|
||||
assetUrl,
|
||||
currentSrc:
|
||||
node instanceof HTMLImageElement || node instanceof HTMLMediaElement
|
||||
? node.currentSrc || null
|
||||
: null,
|
||||
readyState: node instanceof HTMLMediaElement ? node.readyState : null,
|
||||
networkState: node instanceof HTMLMediaElement ? node.networkState : null,
|
||||
isImageElement(node) || isMediaElement(node) ? node.currentSrc || null : null,
|
||||
readyState: isMediaElement(node) ? node.readyState : null,
|
||||
networkState: isMediaElement(node) ? node.networkState : null,
|
||||
},
|
||||
`${diagnosticCode}:${tagName}:${assetUrl ?? "unknown"}`,
|
||||
);
|
||||
@@ -2054,12 +2060,12 @@ export function initSandboxRuntimeModular(): void {
|
||||
// listeners below, reusing `metadataBoundMedia` as the once-per-element
|
||||
// dedupe (no separate tracking set needed).
|
||||
const onMediaLoadedMetadataForProxy = (event: Event) => {
|
||||
if (event.currentTarget instanceof HTMLMediaElement) {
|
||||
if (isMediaElement(event.currentTarget)) {
|
||||
handleMetadataForProxy(event.currentTarget);
|
||||
}
|
||||
};
|
||||
const onMediaErrorForProxy = (event: Event) => {
|
||||
if (event.currentTarget instanceof HTMLMediaElement) {
|
||||
if (isMediaElement(event.currentTarget)) {
|
||||
handleErrorForProxy(event.currentTarget);
|
||||
}
|
||||
};
|
||||
@@ -2069,7 +2075,7 @@ export function initSandboxRuntimeModular(): void {
|
||||
// cross-origin `<video>` is not affected and must not be reported as if it
|
||||
// were.
|
||||
const reportWebAudioRoute = (mediaEl: HTMLMediaElement) => {
|
||||
if (!(mediaEl instanceof HTMLAudioElement)) return;
|
||||
if (!isAudioElement(mediaEl)) return;
|
||||
// Before resource selection settles, the verdict is built from `<source>`
|
||||
// children the browser might still pass over — good enough for the
|
||||
// schedule path's conservative withhold, not good enough to put in front
|
||||
@@ -2082,7 +2088,7 @@ export function initSandboxRuntimeModular(): void {
|
||||
|
||||
const onMediaLoadedMetadataForRoute = (event: Event) => {
|
||||
const target = event.currentTarget;
|
||||
if (target instanceof HTMLMediaElement) reportWebAudioRoute(target);
|
||||
if (isMediaElement(target)) reportWebAudioRoute(target);
|
||||
};
|
||||
|
||||
const unbindMediaMetadataListeners = () => {
|
||||
@@ -2251,7 +2257,7 @@ export function initSandboxRuntimeModular(): void {
|
||||
const applyTimedElementVisibility = (currentTime: number, visibilityNodes: Element[]) => {
|
||||
const rootComp = resolveRootCompositionElement();
|
||||
for (const rawNode of visibilityNodes) {
|
||||
if (!(rawNode instanceof HTMLElement)) continue;
|
||||
if (!isHtmlElement(rawNode)) continue;
|
||||
|
||||
if (rawNode.hasAttribute("data-hidden")) {
|
||||
if (!dataHiddenDisplayNodes.has(rawNode)) {
|
||||
@@ -2261,7 +2267,7 @@ export function initSandboxRuntimeModular(): void {
|
||||
groupMuteDirty = true;
|
||||
}
|
||||
rawNode.style.display = "none";
|
||||
if (rawNode instanceof HTMLVideoElement || rawNode instanceof HTMLImageElement) {
|
||||
if (isVideoElement(rawNode) || isImageElement(rawNode)) {
|
||||
colorGradingRuntime?.setSourceVisibility(rawNode, false);
|
||||
}
|
||||
continue;
|
||||
@@ -2288,7 +2294,7 @@ export function initSandboxRuntimeModular(): void {
|
||||
let ancestor = rawNode.parentElement;
|
||||
while (ancestor) {
|
||||
if (ancestor === rootComp) break;
|
||||
if (ancestor instanceof HTMLElement && ancestor.hasAttribute("data-start")) {
|
||||
if (isHtmlElement(ancestor) && ancestor.hasAttribute("data-start")) {
|
||||
if (!isTimedElementVisibleAt(ancestor, currentTime)) {
|
||||
isVisibleNow = false;
|
||||
break;
|
||||
@@ -2298,7 +2304,7 @@ export function initSandboxRuntimeModular(): void {
|
||||
}
|
||||
}
|
||||
rawNode.style.visibility = isVisibleNow ? "visible" : "hidden";
|
||||
if (rawNode instanceof HTMLVideoElement || rawNode instanceof HTMLImageElement) {
|
||||
if (isVideoElement(rawNode) || isImageElement(rawNode)) {
|
||||
colorGradingRuntime?.setSourceVisibility(rawNode, isVisibleNow);
|
||||
}
|
||||
if (isVisibleNow) {
|
||||
@@ -2785,7 +2791,7 @@ export function initSandboxRuntimeModular(): void {
|
||||
}
|
||||
const mediaEls = document.querySelectorAll("video, audio");
|
||||
for (const el of mediaEls) {
|
||||
if (!(el instanceof HTMLMediaElement)) continue;
|
||||
if (!isMediaElement(el)) continue;
|
||||
try {
|
||||
el.playbackRate = state.playbackRate;
|
||||
} catch (err) {
|
||||
@@ -3401,7 +3407,7 @@ export function initSandboxRuntimeModular(): void {
|
||||
const audioEls = document.querySelectorAll("audio[data-start]");
|
||||
let foundActive = false;
|
||||
for (const rawEl of audioEls) {
|
||||
if (!(rawEl instanceof HTMLMediaElement) || !rawEl.isConnected) continue;
|
||||
if (!isMediaElement(rawEl) || !rawEl.isConnected) continue;
|
||||
if (isSilencedByHidden(rawEl)) continue;
|
||||
const start = resolveAbsoluteMediaStartSeconds(rawEl);
|
||||
const durAttr = parseStrictFiniteTimingNumber(rawEl.dataset.duration);
|
||||
@@ -3492,7 +3498,7 @@ export function initSandboxRuntimeModular(): void {
|
||||
const hardSyncAllMedia = (timeSeconds: number) => {
|
||||
const mediaEls = document.querySelectorAll("video, audio");
|
||||
for (const el of mediaEls) {
|
||||
if (!(el instanceof HTMLMediaElement)) continue;
|
||||
if (!isMediaElement(el)) continue;
|
||||
if (!el.isConnected) continue;
|
||||
if (!el.hasAttribute("data-start")) continue;
|
||||
const start = resolveAbsoluteMediaStartSeconds(el);
|
||||
@@ -3523,7 +3529,7 @@ export function initSandboxRuntimeModular(): void {
|
||||
const gen = webAudio.startGeneration();
|
||||
const audioEls = document.querySelectorAll("audio[data-start]");
|
||||
for (const rawEl of audioEls) {
|
||||
if (!(rawEl instanceof HTMLMediaElement) || !rawEl.isConnected) continue;
|
||||
if (!isMediaElement(rawEl) || !rawEl.isConnected) continue;
|
||||
if (isSilencedByHidden(rawEl)) continue;
|
||||
const compStart = resolveAbsoluteMediaStartSeconds(rawEl);
|
||||
if (!Number.isFinite(compStart)) continue;
|
||||
@@ -3660,7 +3666,7 @@ export function initSandboxRuntimeModular(): void {
|
||||
webAudio.stopAll();
|
||||
const mediaEls = document.querySelectorAll("video, audio");
|
||||
for (const el of mediaEls) {
|
||||
if (el instanceof HTMLMediaElement && !el.paused) el.pause();
|
||||
if (isMediaElement(el) && !el.paused) el.pause();
|
||||
}
|
||||
},
|
||||
onSeek: (timeSeconds, _seekMode) => {
|
||||
@@ -3673,7 +3679,7 @@ export function initSandboxRuntimeModular(): void {
|
||||
webAudio.setMuted(effective);
|
||||
const mediaEls = document.querySelectorAll("video, audio");
|
||||
for (const el of mediaEls) {
|
||||
if (!(el instanceof HTMLMediaElement)) continue;
|
||||
if (!isMediaElement(el)) continue;
|
||||
el.muted = effective || el.defaultMuted;
|
||||
}
|
||||
},
|
||||
@@ -3682,7 +3688,7 @@ export function initSandboxRuntimeModular(): void {
|
||||
webAudio.setVolume(volume);
|
||||
const mediaEls = document.querySelectorAll("video, audio");
|
||||
for (const el of mediaEls) {
|
||||
if (!(el instanceof HTMLMediaElement)) continue;
|
||||
if (!isMediaElement(el)) continue;
|
||||
const parsed = parseFloat(el.dataset.volume ?? "");
|
||||
const clipVolume = Number.isFinite(parsed) ? parsed : 1;
|
||||
// `data-volume` carries authored gain, which goes above unity now that
|
||||
@@ -3703,7 +3709,7 @@ export function initSandboxRuntimeModular(): void {
|
||||
webAudio.setMuted(effective);
|
||||
const mediaEls = document.querySelectorAll("video, audio");
|
||||
for (const el of mediaEls) {
|
||||
if (!(el instanceof HTMLMediaElement)) continue;
|
||||
if (!isMediaElement(el)) continue;
|
||||
el.muted = effective || el.defaultMuted;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -3,6 +3,7 @@ import { swallow } from "./diagnostics";
|
||||
import { evictMediaSyncState } from "./media";
|
||||
import { findInjectedRenderFrame } from "./renderFrameSibling";
|
||||
import type { RuntimeJson } from "./types";
|
||||
import { isVideoElement } from "./domRealm";
|
||||
|
||||
/**
|
||||
* One entry per project-root-relative asset pathname, injected by the
|
||||
@@ -63,7 +64,7 @@ function currentSrcValue(el: HTMLMediaElement): string {
|
||||
*/
|
||||
function isRenderMode(el: HTMLMediaElement): boolean {
|
||||
if (window.__HF_EXPORT_RENDER_SEEK_CONFIG) return true;
|
||||
return el instanceof HTMLVideoElement && !!findInjectedRenderFrame(el);
|
||||
return isVideoElement(el) && !!findInjectedRenderFrame(el);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -263,7 +264,7 @@ export function swapToProxy(
|
||||
*/
|
||||
export function maybeProxyProactively(el: HTMLMediaElement): void {
|
||||
if (isRenderMode(el)) return;
|
||||
if (!(el instanceof HTMLVideoElement)) return;
|
||||
if (!isVideoElement(el)) return;
|
||||
if (swappedElements.has(el)) return;
|
||||
const map = window.__HF_MEDIA_CODEC_MAP__;
|
||||
if (!map) return;
|
||||
@@ -292,7 +293,7 @@ export function maybeProxyProactively(el: HTMLMediaElement): void {
|
||||
*/
|
||||
export function handleMetadataForProxy(el: HTMLMediaElement): void {
|
||||
if (isRenderMode(el)) return;
|
||||
if (!(el instanceof HTMLVideoElement)) return;
|
||||
if (!isVideoElement(el)) return;
|
||||
if (el.videoWidth !== 0) return;
|
||||
const src = currentSrcValue(el);
|
||||
if (swappedElements.has(el)) {
|
||||
@@ -324,7 +325,7 @@ export function handleMetadataForProxy(el: HTMLMediaElement): void {
|
||||
*/
|
||||
export function handleErrorForProxy(el: HTMLMediaElement): void {
|
||||
if (isRenderMode(el)) return;
|
||||
if (!(el instanceof HTMLVideoElement)) return;
|
||||
if (!isVideoElement(el)) return;
|
||||
const src = currentSrcValue(el);
|
||||
if (swappedElements.has(el)) {
|
||||
emitUnavailableDiagnostic(el, "proxy_playback_failed", src);
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { RuntimeTimelineLike } from "./types";
|
||||
import { clampAudioGain, withUnclampedVolume } from "../audioGain.js";
|
||||
import { parseStrictFiniteTimingNumber } from "./playbackRate";
|
||||
import { createRuntimeStartTimeResolver } from "./startResolver";
|
||||
import { isMediaElement } from "./domRealm";
|
||||
|
||||
/**
|
||||
* Shared volume-automation utilities used by both the renderer (offline PCM
|
||||
@@ -235,7 +236,7 @@ export function probeAndCacheElementVolume(
|
||||
): void {
|
||||
if (options.allowLiveTimelineSeek === false) return;
|
||||
if (!timeline) return;
|
||||
if (!(mediaEl instanceof HTMLAudioElement) && !(mediaEl instanceof HTMLVideoElement)) return;
|
||||
if (!isMediaElement(mediaEl)) return;
|
||||
if (compositionDuration <= 0) return;
|
||||
|
||||
const seekFn = (t: number) => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { RuntimeJson, RuntimeOutboundMessage, RuntimePickerElementInfo } from "./types";
|
||||
import { COLOR_GRADING_SOURCE_HIDDEN_ATTR } from "../colorGrading";
|
||||
import { swallow } from "./diagnostics";
|
||||
import { isElementNode } from "./domRealm";
|
||||
|
||||
type PickerModuleDeps = {
|
||||
postMessage: (payload: RuntimeOutboundMessage) => void;
|
||||
@@ -188,7 +189,7 @@ export function createPickerModule(deps: PickerModuleDeps): PickerModule {
|
||||
function onPickMouseMove(event: MouseEvent): void {
|
||||
if (!pickModeActive) return;
|
||||
const candidates = getPickCandidatesFromPoint(event.clientX, event.clientY, 1);
|
||||
const target = candidates[0] ?? (event.target instanceof Element ? event.target : null);
|
||||
const target = candidates[0] ?? (isElementNode(event.target) ? event.target : null);
|
||||
if (!isPickableElement(target)) return;
|
||||
if (pickModeHighlightEl === target) return;
|
||||
if (pickModeHighlightEl) {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { isMediaElement } from "./domRealm";
|
||||
|
||||
export function normalizePlaybackRate(raw: number): number {
|
||||
return Number.isFinite(raw) && raw > 0 ? Math.max(0.1, Math.min(5, raw)) : 1;
|
||||
}
|
||||
@@ -12,7 +14,7 @@ export function readElementPlaybackRate(el: Pick<Element, "getAttribute">): numb
|
||||
const raw =
|
||||
Number.isFinite(authored) && authored > 0
|
||||
? authored
|
||||
: typeof HTMLMediaElement !== "undefined" && el instanceof HTMLMediaElement
|
||||
: isMediaElement(el)
|
||||
? el.defaultPlaybackRate
|
||||
: 1;
|
||||
return normalizePlaybackRate(raw);
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
*/
|
||||
|
||||
import { emitAnalyticsEvent } from "./analytics";
|
||||
import { isStylableElement } from "./domRealm";
|
||||
|
||||
export const EDIT_BASE_X_ATTR = "data-hf-edit-base-x";
|
||||
export const EDIT_BASE_Y_ATTR = "data-hf-edit-base-y";
|
||||
@@ -176,30 +177,17 @@ export function applyPositionEditToElement(el: StylableElement, opts?: { force?:
|
||||
* longer matches and the non-forced path would silently skip the redo.
|
||||
*/
|
||||
export function applyPositionEdits(doc: Document, opts?: { force?: boolean }): number {
|
||||
// Not `instanceof HTMLElement`: `doc` is frequently an iframe's document (the
|
||||
// SDK's edit preview, a host embedding a composition), and its elements are
|
||||
// HTMLElement instances of THAT frame's realm — never this module's. A
|
||||
// module-scope `instanceof HTMLElement` check silently no-ops on every element
|
||||
// cross-realm. Use the document's own realm's constructor; duck-type on
|
||||
// `.style` when defaultView is unavailable (a detached/synthetic document).
|
||||
const RealmHTMLElement = doc.defaultView?.HTMLElement;
|
||||
const RealmSVGElement = doc.defaultView?.SVGElement;
|
||||
// Stylable = HTML OR SVG element (SVG `<text>`/shapes are positioned via the same CSS `translate`
|
||||
// longhand). The old HTML-only check silently dropped every SVG move. Cross-realm safe (uses the
|
||||
// document's own realm constructors — see the note above); duck-type on `.style` when defaultView
|
||||
// is unavailable.
|
||||
const isStylable = (el: Element): el is StylableElement =>
|
||||
RealmHTMLElement || RealmSVGElement
|
||||
? (RealmHTMLElement !== undefined && el instanceof RealmHTMLElement) ||
|
||||
(RealmSVGElement !== undefined && el instanceof RealmSVGElement)
|
||||
: typeof (el as HTMLElement).style?.setProperty === "function";
|
||||
|
||||
// `isStylableElement` covers HTML **and** SVG (SVG `<text>`/shapes are positioned via the same
|
||||
// CSS `translate` longhand; an HTML-only check silently dropped every SVG move), and it is
|
||||
// realm-independent, which this needs to be twice over: `doc` is frequently an iframe's document
|
||||
// (the SDK's edit preview, a host embedding a composition), AND its elements are not necessarily
|
||||
// from that iframe's realm either — see domRealm.ts.
|
||||
const orphaned = doc.querySelectorAll(
|
||||
`[${EDIT_ORIGINAL_TRANSLATE_ATTR}]:not([${EDIT_BASE_X_ATTR}]):not([${EDIT_BASE_Y_ATTR}])`,
|
||||
);
|
||||
for (let i = 0; i < orphaned.length; i++) {
|
||||
const el = orphaned[i];
|
||||
if (el === undefined || !isStylable(el)) continue;
|
||||
if (el === undefined || !isStylableElement(el)) continue;
|
||||
const original = el.getAttribute(EDIT_ORIGINAL_TRANSLATE_ATTR) ?? "";
|
||||
if (original === "") {
|
||||
el.style.removeProperty("translate");
|
||||
@@ -214,7 +202,7 @@ export function applyPositionEdits(doc: Document, opts?: { force?: boolean }): n
|
||||
let applied = 0;
|
||||
for (let i = 0; i < marked.length; i++) {
|
||||
const el = marked[i];
|
||||
if (el === undefined || !isStylable(el)) continue;
|
||||
if (el === undefined || !isStylableElement(el)) continue;
|
||||
applyPositionEditToElement(el, opts);
|
||||
applied += 1;
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
*/
|
||||
|
||||
import { MEDIA_RENDER_ID_ATTR } from "../compiler/mediaRenderIds.js";
|
||||
import { isImageElement } from "./domRealm";
|
||||
|
||||
/**
|
||||
* The render id an element is addressed by: its stamped, document-unique id
|
||||
@@ -55,5 +56,5 @@ export function findInjectedRenderFrame(media: Element): HTMLImageElement | null
|
||||
const frameId = renderFrameElementId(media);
|
||||
if (!frameId) return null;
|
||||
const frame = document.getElementById(frameId);
|
||||
return frame instanceof HTMLImageElement ? frame : null;
|
||||
return isImageElement(frame) ? frame : null;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
readElementPlaybackRate,
|
||||
readMediaStart,
|
||||
} from "./playbackRate";
|
||||
import { isMediaElement } from "./domRealm";
|
||||
import { parseStartExpression } from "./startExpression";
|
||||
import { MEDIA_START_BASIS_ATTR, resolveAbsoluteMediaStartSeconds } from "../mediaTiming";
|
||||
|
||||
@@ -42,14 +43,6 @@ export function createRuntimeStartTimeResolver(params: {
|
||||
);
|
||||
};
|
||||
|
||||
// Realm-safe: an iframe document's media elements are instances of THAT
|
||||
// frame's HTMLMediaElement, never this module's global one.
|
||||
const isMediaElement = (el: Element): el is HTMLMediaElement => {
|
||||
const RealmMedia = el.ownerDocument.defaultView?.HTMLMediaElement;
|
||||
if (RealmMedia) return el instanceof RealmMedia;
|
||||
return typeof HTMLMediaElement !== "undefined" && el instanceof HTMLMediaElement;
|
||||
};
|
||||
|
||||
const resolveDurationForElement = (element: Element): number | null => {
|
||||
const cached = durationCache.get(element);
|
||||
if (cached !== undefined) return cached;
|
||||
|
||||
@@ -18,6 +18,7 @@ import { createRuntimeStartTimeResolver } from "./startResolver";
|
||||
import { isSceneLikeCompositionId } from "../slideshow/index.js";
|
||||
import { COMPOSITION_CONTRACT_VERSION } from "../compositionContract.js";
|
||||
import { runtimeProtocolMetadata } from "./protocol.js";
|
||||
import { isElementNode, isMediaElement } from "./domRealm";
|
||||
|
||||
function parseNum(value: string | null | undefined): number | null {
|
||||
return parseStrictFiniteTimingNumber(value);
|
||||
@@ -356,7 +357,7 @@ export function collectRuntimeTimelinePayload(params: {
|
||||
if (duration == null && nodeCompositionId && nodeCompositionId !== rootCompositionId) {
|
||||
duration = resolveTimelineDurationSeconds(nodeCompositionId);
|
||||
}
|
||||
if (duration == null && node instanceof HTMLMediaElement) {
|
||||
if (duration == null && isMediaElement(node)) {
|
||||
if (Number.isFinite(node.duration)) {
|
||||
duration = resolveNaturalMediaTimelineDuration(node, node.duration);
|
||||
}
|
||||
@@ -470,7 +471,7 @@ export function collectRuntimeTimelinePayload(params: {
|
||||
const tweenEnd = tweenStart + tween.duration();
|
||||
if (!Number.isFinite(tweenStart) || !Number.isFinite(tweenEnd)) continue;
|
||||
for (const target of tween.targets()) {
|
||||
if (!(target instanceof Element)) continue;
|
||||
if (!isElementNode(target)) continue;
|
||||
// Bubble up to the scene-level ancestor
|
||||
const scene = findSceneAncestor(target);
|
||||
if (!scene) continue;
|
||||
|
||||
@@ -87,6 +87,42 @@ describe("scrubPreviewAudio", () => {
|
||||
expect(audio.volume).toBeCloseTo(0.1);
|
||||
stopScrubPreviewAudio();
|
||||
});
|
||||
|
||||
/**
|
||||
* The preview document is a different realm, so `instanceof HTMLAudioElement`
|
||||
* is false for every node in it. That threw the `musicId` hint away and left
|
||||
* the first `<audio>` in the document as the only route — and the first
|
||||
* `<audio>` is often the voiceover, so scrubbing previewed the wrong track.
|
||||
* Two elements, music second, is what tells the two paths apart: with one
|
||||
* element the fallback reaches the right node by accident.
|
||||
*/
|
||||
it("previews the track named by musicId, not the first audio in the document", () => {
|
||||
const iframe = document.createElement("iframe");
|
||||
document.body.append(iframe);
|
||||
const previewDoc = iframe.contentDocument;
|
||||
if (!previewDoc?.body) throw new Error("expected an iframe document");
|
||||
|
||||
const voiceover = previewDoc.createElement("audio");
|
||||
voiceover.id = "voiceover";
|
||||
voiceover.play = vi.fn(async () => {});
|
||||
voiceover.pause = vi.fn();
|
||||
|
||||
const music = previewDoc.createElement("audio");
|
||||
music.id = "music-bed";
|
||||
music.play = vi.fn(async () => {});
|
||||
music.pause = vi.fn();
|
||||
|
||||
previewDoc.body.append(voiceover, music);
|
||||
|
||||
// The node really is cross-realm; this is the condition, not a contrivance.
|
||||
expect(music instanceof HTMLAudioElement).toBe(false);
|
||||
|
||||
scrubPreviewAudio(iframe, 0.5, "music-bed", 1);
|
||||
|
||||
expect(music.play).toHaveBeenCalled();
|
||||
expect(voiceover.play).not.toHaveBeenCalled();
|
||||
stopScrubPreviewAudio();
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyPreviewAudioFlags", () => {
|
||||
|
||||
@@ -218,10 +218,26 @@ let scrubPrevVolume: number | null = null;
|
||||
// Resolve the SAME element the store identified as music: prefer its id, then
|
||||
// the role attribute, and only fall back to the first <audio> (which could be a
|
||||
// voiceover, so the id hint matters).
|
||||
/**
|
||||
* `doc` is the preview iframe's document, so its `<audio>` nodes are instances of
|
||||
* the IFRAME's `HTMLAudioElement`, never this module's. `instanceof
|
||||
* HTMLAudioElement` here is false for every one of them, which silently threw the
|
||||
* `musicId` hint away and fell through to "first `<audio>` in the document" — the
|
||||
* very thing the comment above warns can be a voiceover. Ask what the node IS.
|
||||
* Same rule and same reasoning as packages/core/src/runtime/domRealm.ts.
|
||||
*/
|
||||
function isAudioNode(node: Element | null): node is HTMLAudioElement {
|
||||
return (
|
||||
node !== null &&
|
||||
node.namespaceURI === "http://www.w3.org/1999/xhtml" &&
|
||||
node.localName === "audio"
|
||||
);
|
||||
}
|
||||
|
||||
function resolveScrubAudioEl(doc: Document, musicId?: string | null): HTMLAudioElement | null {
|
||||
if (musicId) {
|
||||
const byId = doc.getElementById(musicId);
|
||||
if (byId instanceof HTMLAudioElement) return byId;
|
||||
if (isAudioNode(byId)) return byId;
|
||||
}
|
||||
return (
|
||||
doc.querySelector<HTMLAudioElement>("audio[data-timeline-role='music']") ??
|
||||
|
||||
Reference in New Issue
Block a user