mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
fix: isolate studio sub-composition previews
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseHTML } from "linkedom";
|
||||
import { scopeCssToComposition, wrapScopedCompositionScript } from "./compositionScoping";
|
||||
|
||||
describe("composition scoping", () => {
|
||||
it("scopes regular selectors while preserving global at-rules", () => {
|
||||
const scoped = scopeCssToComposition(
|
||||
`
|
||||
@import url("https://example.com/font.css");
|
||||
.title, .card:hover { opacity: 0; }
|
||||
@media (min-width: 800px) {
|
||||
.title { transform: translateY(30px); }
|
||||
}
|
||||
@keyframes rise {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
[data-composition-id="scene"] .already { color: red; }
|
||||
body { margin: 0; }
|
||||
`,
|
||||
"scene",
|
||||
);
|
||||
|
||||
expect(scoped).toContain('@import url("https://example.com/font.css");');
|
||||
expect(scoped).toContain(
|
||||
'[data-composition-id="scene"] .title, [data-composition-id="scene"] .card:hover',
|
||||
);
|
||||
expect(scoped).toContain('[data-composition-id="scene"] .title { transform');
|
||||
expect(scoped).toContain("@keyframes rise");
|
||||
expect(scoped).toContain("from { opacity: 0; }");
|
||||
expect(scoped).toContain('[data-composition-id="scene"] .already { color: red; }');
|
||||
expect(scoped).toContain("body { margin: 0; }");
|
||||
});
|
||||
|
||||
it("wraps classic scripts without render-loop requestAnimationFrame waits", () => {
|
||||
const wrapped = wrapScopedCompositionScript("window.__ran = true;", "scene");
|
||||
|
||||
expect(wrapped).toContain('var __hfCompId = "scene";');
|
||||
expect(wrapped).toContain("new Proxy(window.document");
|
||||
expect(wrapped).toContain("new Proxy(__hfBaseGsap");
|
||||
expect(wrapped).not.toContain("requestAnimationFrame");
|
||||
});
|
||||
|
||||
it("executes document and GSAP selectors inside the composition root", () => {
|
||||
const { document } = parseHTML(`
|
||||
<div data-composition-id="scene"><h1 class="title">Scene</h1></div>
|
||||
<div data-composition-id="other"><h1 class="title">Other</h1></div>
|
||||
`);
|
||||
const gsapTargets: string[][] = [];
|
||||
const fakeWindow = {
|
||||
document,
|
||||
__selectedTitle: "",
|
||||
__timelines: {},
|
||||
gsap: {
|
||||
timeline: () => ({
|
||||
to(targets: Element[]) {
|
||||
gsapTargets.push(Array.from(targets).map((target) => target.textContent || ""));
|
||||
return this;
|
||||
},
|
||||
}),
|
||||
},
|
||||
};
|
||||
const wrapped = wrapScopedCompositionScript(
|
||||
`
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to('.title', { opacity: 1 });
|
||||
window.__selectedTitle = document.querySelector('.title')?.textContent || '';
|
||||
window.__timelines.scene = tl;
|
||||
`,
|
||||
"scene",
|
||||
);
|
||||
|
||||
new Function("window", "gsap", wrapped)(fakeWindow, fakeWindow.gsap);
|
||||
|
||||
expect(fakeWindow.__selectedTitle).toBe("Scene");
|
||||
expect(gsapTargets).toEqual([["Scene"]]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,346 @@
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function escapeCssAttributeValue(value: string): string {
|
||||
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
||||
}
|
||||
|
||||
function findNextCssToken(css: string, start: number, token: "{" | ";"): number {
|
||||
let quote: string | null = null;
|
||||
let inComment = false;
|
||||
for (let i = start; i < css.length; i++) {
|
||||
const char = css[i];
|
||||
const next = css[i + 1];
|
||||
if (inComment) {
|
||||
if (char === "*" && next === "/") {
|
||||
inComment = false;
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (quote) {
|
||||
if (char === "\\") {
|
||||
i++;
|
||||
} else if (char === quote) {
|
||||
quote = null;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (char === "/" && next === "*") {
|
||||
inComment = true;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (char === '"' || char === "'") {
|
||||
quote = char;
|
||||
continue;
|
||||
}
|
||||
if (char === token) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function findMatchingCssBrace(css: string, openIndex: number): number {
|
||||
let depth = 0;
|
||||
let quote: string | null = null;
|
||||
let inComment = false;
|
||||
for (let i = openIndex; i < css.length; i++) {
|
||||
const char = css[i];
|
||||
const next = css[i + 1];
|
||||
if (inComment) {
|
||||
if (char === "*" && next === "/") {
|
||||
inComment = false;
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (quote) {
|
||||
if (char === "\\") {
|
||||
i++;
|
||||
} else if (char === quote) {
|
||||
quote = null;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (char === "/" && next === "*") {
|
||||
inComment = true;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (char === '"' || char === "'") {
|
||||
quote = char;
|
||||
continue;
|
||||
}
|
||||
if (char === "{") {
|
||||
depth++;
|
||||
} else if (char === "}") {
|
||||
depth--;
|
||||
if (depth === 0) return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function splitSelectorList(selectorText: string): string[] {
|
||||
const selectors: string[] = [];
|
||||
let current = "";
|
||||
let quote: string | null = null;
|
||||
let inComment = false;
|
||||
let bracketDepth = 0;
|
||||
let parenDepth = 0;
|
||||
for (let i = 0; i < selectorText.length; i++) {
|
||||
const char = selectorText[i];
|
||||
const next = selectorText[i + 1];
|
||||
if (inComment) {
|
||||
current += char;
|
||||
if (char === "*" && next === "/") {
|
||||
current += next;
|
||||
inComment = false;
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (quote) {
|
||||
current += char;
|
||||
if (char === "\\") {
|
||||
current += next ?? "";
|
||||
i++;
|
||||
} else if (char === quote) {
|
||||
quote = null;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (char === "/" && next === "*") {
|
||||
current += char + next;
|
||||
inComment = true;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (char === '"' || char === "'") {
|
||||
current += char;
|
||||
quote = char;
|
||||
continue;
|
||||
}
|
||||
if (char === "[") bracketDepth++;
|
||||
if (char === "]") bracketDepth = Math.max(0, bracketDepth - 1);
|
||||
if (char === "(") parenDepth++;
|
||||
if (char === ")") parenDepth = Math.max(0, parenDepth - 1);
|
||||
if (char === "," && bracketDepth === 0 && parenDepth === 0) {
|
||||
selectors.push(current);
|
||||
current = "";
|
||||
continue;
|
||||
}
|
||||
current += char;
|
||||
}
|
||||
selectors.push(current);
|
||||
return selectors;
|
||||
}
|
||||
|
||||
function scopeSelector(selector: string, scope: string, compositionId: string): string {
|
||||
const trimmed = selector.trim();
|
||||
if (!trimmed) return selector;
|
||||
if (/^(html|body|:root|\*)$/i.test(trimmed)) return selector;
|
||||
const compositionIdPattern = new RegExp(
|
||||
`data-composition-id\\s*=\\s*(["'])${escapeRegExp(compositionId)}\\1`,
|
||||
);
|
||||
if (compositionIdPattern.test(trimmed)) return selector;
|
||||
const leading = selector.match(/^\s*/)?.[0] ?? "";
|
||||
const trailing = selector.match(/\s*$/)?.[0] ?? "";
|
||||
return `${leading}${scope} ${trimmed}${trailing}`;
|
||||
}
|
||||
|
||||
function scopeSelectorList(selectorText: string, scope: string, compositionId: string): string {
|
||||
return splitSelectorList(selectorText)
|
||||
.map((selector) => scopeSelector(selector, scope, compositionId))
|
||||
.join(",");
|
||||
}
|
||||
|
||||
function scopeCssBlock(css: string, scope: string, compositionId: string): string {
|
||||
let output = "";
|
||||
let index = 0;
|
||||
const globalAtRules = new Set(["keyframes", "-webkit-keyframes", "font-face"]);
|
||||
|
||||
while (index < css.length) {
|
||||
const braceIndex = findNextCssToken(css, index, "{");
|
||||
if (braceIndex < 0) {
|
||||
output += css.slice(index);
|
||||
break;
|
||||
}
|
||||
|
||||
const semicolonIndex = findNextCssToken(css, index, ";");
|
||||
if (semicolonIndex >= 0 && semicolonIndex < braceIndex) {
|
||||
output += css.slice(index, semicolonIndex + 1);
|
||||
index = semicolonIndex + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const closeIndex = findMatchingCssBrace(css, braceIndex);
|
||||
if (closeIndex < 0) {
|
||||
output += css.slice(index);
|
||||
break;
|
||||
}
|
||||
|
||||
const prelude = css.slice(index, braceIndex);
|
||||
const body = css.slice(braceIndex + 1, closeIndex);
|
||||
const trimmedPrelude = prelude.trim();
|
||||
if (trimmedPrelude.startsWith("@")) {
|
||||
const atRuleName = trimmedPrelude.match(/^@([-\w]+)/)?.[1]?.toLowerCase() ?? "";
|
||||
const scopedBody = globalAtRules.has(atRuleName)
|
||||
? body
|
||||
: scopeCssBlock(body, scope, compositionId);
|
||||
output += `${prelude}{${scopedBody}}`;
|
||||
} else {
|
||||
output += `${scopeSelectorList(prelude, scope, compositionId)}{${body}}`;
|
||||
}
|
||||
index = closeIndex + 1;
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
export function scopeCssToComposition(css: string, compositionId: string): string {
|
||||
const trimmedCompositionId = compositionId.trim();
|
||||
if (!css || !trimmedCompositionId) return css;
|
||||
const scope = `[data-composition-id="${escapeCssAttributeValue(trimmedCompositionId)}"]`;
|
||||
return scopeCssBlock(css, scope, trimmedCompositionId);
|
||||
}
|
||||
|
||||
export function wrapScopedCompositionScript(
|
||||
source: string,
|
||||
compositionId: string,
|
||||
errorLabel = "[HyperFrames] composition script error:",
|
||||
): string {
|
||||
const compositionIdLiteral = JSON.stringify(compositionId);
|
||||
const errorLabelLiteral = JSON.stringify(errorLabel);
|
||||
return `(function(){
|
||||
var __hfCompId = ${compositionIdLiteral};
|
||||
var __hfErrorLabel = ${errorLabelLiteral};
|
||||
var __hfEscapeAttr = function(value) {
|
||||
return (value + "").replace(/\\\\/g, "\\\\\\\\").replace(/"/g, "\\\\\\"");
|
||||
};
|
||||
var __hfRootSelector = __hfCompId
|
||||
? '[data-composition-id="' + __hfEscapeAttr(__hfCompId) + '"]'
|
||||
: "";
|
||||
var __hfRoot = null;
|
||||
var __hfFindRoot = function() {
|
||||
if (!__hfRoot && __hfRootSelector) {
|
||||
__hfRoot = window.document.querySelector(__hfRootSelector);
|
||||
}
|
||||
return __hfRoot;
|
||||
};
|
||||
var __hfContains = function(node) {
|
||||
var root = __hfFindRoot();
|
||||
return !root || node === root || root.contains(node);
|
||||
};
|
||||
var __hfQueryAll = function(selector) {
|
||||
var root = __hfFindRoot();
|
||||
if (!root || typeof selector !== "string") {
|
||||
return window.document.querySelectorAll(selector);
|
||||
}
|
||||
return Array.prototype.filter.call(window.document.querySelectorAll(selector), function(node) {
|
||||
return __hfContains(node);
|
||||
});
|
||||
};
|
||||
var __hfQueryOne = function(selector) {
|
||||
var matches = __hfQueryAll(selector);
|
||||
return matches[0] || null;
|
||||
};
|
||||
var __hfScopedDocument = typeof Proxy === "function"
|
||||
? new Proxy(window.document, {
|
||||
get: function(target, prop, receiver) {
|
||||
if (prop === "querySelector") return __hfQueryOne;
|
||||
if (prop === "querySelectorAll") return __hfQueryAll;
|
||||
if (prop === "getElementById") {
|
||||
return function(id) {
|
||||
var found = target.getElementById(id);
|
||||
return found && __hfContains(found) ? found : null;
|
||||
};
|
||||
}
|
||||
var value = Reflect.get(target, prop, receiver);
|
||||
return typeof value === "function" ? value.bind(target) : value;
|
||||
},
|
||||
})
|
||||
: window.document;
|
||||
var __hfResolveGsapTarget = function(target) {
|
||||
if (typeof target !== "string") return target;
|
||||
return __hfQueryAll(target);
|
||||
};
|
||||
var __hfScopeTimeline = function(timeline) {
|
||||
if (!timeline || timeline.__hfScopedCompositionRoot === __hfFindRoot()) return timeline;
|
||||
["to", "from", "fromTo", "set"].forEach(function(method) {
|
||||
var original = timeline[method];
|
||||
if (typeof original !== "function") return;
|
||||
timeline[method] = function(target) {
|
||||
var args = Array.prototype.slice.call(arguments);
|
||||
args[0] = __hfResolveGsapTarget(target);
|
||||
return original.apply(timeline, args);
|
||||
};
|
||||
});
|
||||
try {
|
||||
Object.defineProperty(timeline, "__hfScopedCompositionRoot", {
|
||||
value: __hfFindRoot(),
|
||||
configurable: true,
|
||||
});
|
||||
} catch (_err) {}
|
||||
return timeline;
|
||||
};
|
||||
var __hfBaseGsap = typeof gsap === "undefined" ? window.gsap : gsap;
|
||||
var __hfScopedGsap = !__hfBaseGsap || typeof Proxy !== "function"
|
||||
? __hfBaseGsap
|
||||
: new Proxy(__hfBaseGsap, {
|
||||
get: function(target, prop, receiver) {
|
||||
if (prop === "timeline") {
|
||||
return function() {
|
||||
return __hfScopeTimeline(target.timeline.apply(target, arguments));
|
||||
};
|
||||
}
|
||||
if (prop === "to" || prop === "from" || prop === "fromTo" || prop === "set") {
|
||||
return function(firstArg) {
|
||||
var args = Array.prototype.slice.call(arguments);
|
||||
args[0] = __hfResolveGsapTarget(firstArg);
|
||||
return target[prop].apply(target, args);
|
||||
};
|
||||
}
|
||||
if (prop === "utils" && target.utils && typeof Proxy === "function") {
|
||||
return new Proxy(target.utils, {
|
||||
get: function(utilsTarget, utilsProp, utilsReceiver) {
|
||||
if (utilsProp === "toArray") {
|
||||
return function(firstArg) {
|
||||
var args = Array.prototype.slice.call(arguments);
|
||||
args[0] = __hfResolveGsapTarget(firstArg);
|
||||
return utilsTarget.toArray.apply(utilsTarget, args);
|
||||
};
|
||||
}
|
||||
if (utilsProp === "selector") {
|
||||
return function(base) {
|
||||
var baseEl = typeof base === "string" ? __hfQueryOne(base) : base;
|
||||
var root = baseEl || __hfFindRoot();
|
||||
return function(selector) {
|
||||
if (!root || typeof selector !== "string") return [];
|
||||
return Array.prototype.slice.call(root.querySelectorAll(selector));
|
||||
};
|
||||
};
|
||||
}
|
||||
var value = Reflect.get(utilsTarget, utilsProp, utilsReceiver);
|
||||
return typeof value === "function" ? value.bind(utilsTarget) : value;
|
||||
},
|
||||
});
|
||||
}
|
||||
var value = Reflect.get(target, prop, receiver);
|
||||
return typeof value === "function" ? value.bind(target) : value;
|
||||
},
|
||||
});
|
||||
var __hfRun = function() {
|
||||
try {
|
||||
(function(document, gsap) {
|
||||
${source}
|
||||
}).call(window, __hfScopedDocument, __hfScopedGsap);
|
||||
} catch (_err) {
|
||||
console.error(__hfErrorLabel, __hfCompId, _err);
|
||||
}
|
||||
};
|
||||
__hfFindRoot();
|
||||
__hfRun();
|
||||
})()`;
|
||||
}
|
||||
@@ -196,6 +196,82 @@ describe("bundleToSingleHtml", () => {
|
||||
expect(bundled).toContain("Sized content");
|
||||
});
|
||||
|
||||
it("preserves the sub-composition root when inlining external compositions", async () => {
|
||||
const dir = makeTempProject({
|
||||
"index.html": `<!doctype html>
|
||||
<html><head></head><body>
|
||||
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
|
||||
<div
|
||||
id="scene-host"
|
||||
data-composition-id="scene"
|
||||
data-composition-src="compositions/scene.html"
|
||||
data-start="intro"
|
||||
data-duration="5"></div>
|
||||
</div>
|
||||
<script>window.__timelines={};</script>
|
||||
</body></html>`,
|
||||
"compositions/scene.html": `<template id="scene-template">
|
||||
<div data-composition-id="scene" data-start="0" data-width="1920" data-height="1080">
|
||||
<style>[data-composition-id="scene"][data-start="0"] .title { opacity: 0; }</style>
|
||||
<h1 class="title">Scene</h1>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const root = document.querySelector('[data-composition-id="scene"][data-start="0"]');
|
||||
window.__timelines["scene"] = { root };
|
||||
</script>
|
||||
</div>
|
||||
</template>`,
|
||||
});
|
||||
|
||||
const bundled = await bundleToSingleHtml(dir);
|
||||
|
||||
expect(bundled).toContain('id="scene-host"');
|
||||
expect(bundled).toContain('data-composition-id="scene" data-start="0"');
|
||||
expect(bundled).toContain('[data-composition-id="scene"][data-start="0"]');
|
||||
});
|
||||
|
||||
it("scopes external sub-composition styles and classic scripts", async () => {
|
||||
const dir = makeTempProject({
|
||||
"index.html": `<!doctype html>
|
||||
<html><head>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
|
||||
</head><body>
|
||||
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
|
||||
<div
|
||||
id="scene-host"
|
||||
data-composition-id="scene"
|
||||
data-composition-src="compositions/scene.html"
|
||||
data-start="0"
|
||||
data-duration="5"></div>
|
||||
<div data-composition-id="other"><h1 class="title">Other</h1></div>
|
||||
</div>
|
||||
<script>window.__timelines={};</script>
|
||||
</body></html>`,
|
||||
"compositions/scene.html": `<template id="scene-template">
|
||||
<div data-composition-id="scene" data-width="1920" data-height="1080">
|
||||
<style>
|
||||
.title { opacity: 0; transform: translateY(30px); }
|
||||
@media (min-width: 800px) { .title { color: red; } }
|
||||
</style>
|
||||
<h1 class="title">Scene</h1>
|
||||
<script>
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to('.title', { opacity: 1 });
|
||||
window.__timelines["scene"] = tl;
|
||||
</script>
|
||||
</div>
|
||||
</template>`,
|
||||
});
|
||||
|
||||
const bundled = await bundleToSingleHtml(dir);
|
||||
|
||||
expect(bundled).toContain('[data-composition-id="scene"] .title');
|
||||
expect(bundled).toContain('[data-composition-id="scene"] .title { color: red; }');
|
||||
expect(bundled).toContain("new Proxy(window.document");
|
||||
expect(bundled).toContain("new Proxy(__hfBaseGsap");
|
||||
expect(bundled).toContain('tl.to(".title"');
|
||||
});
|
||||
|
||||
it("rewrites CSS url(...) asset paths from sub-compositions when styles are hoisted", async () => {
|
||||
const dir = makeTempProject({
|
||||
"index.html": `<!doctype html>
|
||||
|
||||
@@ -3,7 +3,12 @@ import { join, resolve, isAbsolute, sep } from "path";
|
||||
import { parseHTML } from "linkedom";
|
||||
import { transformSync } from "esbuild";
|
||||
import { compileHtml, type MediaDurationProber } from "./htmlCompiler";
|
||||
import { rewriteAssetPaths, rewriteCssAssetUrls } from "./rewriteSubCompPaths";
|
||||
import {
|
||||
rewriteAssetPaths,
|
||||
rewriteCssAssetUrls,
|
||||
rewriteInlineStyleAssetUrls,
|
||||
} from "./rewriteSubCompPaths";
|
||||
import { scopeCssToComposition, wrapScopedCompositionScript } from "./compositionScoping";
|
||||
import { validateHyperframeHtmlContract } from "./staticGuard";
|
||||
|
||||
/**
|
||||
@@ -439,6 +444,8 @@ export async function bundleToSingleHtml(
|
||||
const innerRoot = compId
|
||||
? contentDoc.querySelector(`[data-composition-id="${compId}"]`)
|
||||
: contentDoc.querySelector("[data-composition-id]");
|
||||
const inferredCompId = innerRoot?.getAttribute("data-composition-id")?.trim() || "";
|
||||
const scopeCompId = compId || inferredCompId;
|
||||
|
||||
// When a sub-composition is a full HTML document (no <template>), styles
|
||||
// and scripts in <head> are not part of contentDoc (which only has body
|
||||
@@ -446,7 +453,8 @@ export async function bundleToSingleHtml(
|
||||
// scripts (e.g. GSAP CDN) are not silently dropped.
|
||||
if (!contentRoot && compDoc.head) {
|
||||
for (const s of [...compDoc.head.querySelectorAll("style")]) {
|
||||
compStyleChunks.push(rewriteCssAssetUrls(s.textContent || "", src));
|
||||
const css = rewriteCssAssetUrls(s.textContent || "", src);
|
||||
compStyleChunks.push(scopeCompId ? scopeCssToComposition(css, scopeCompId) : css);
|
||||
}
|
||||
for (const s of [...compDoc.head.querySelectorAll("script")]) {
|
||||
const externalSrc = (s.getAttribute("src") || "").trim();
|
||||
@@ -457,7 +465,8 @@ export async function bundleToSingleHtml(
|
||||
}
|
||||
|
||||
for (const s of [...contentDoc.querySelectorAll("style")]) {
|
||||
compStyleChunks.push(rewriteCssAssetUrls(s.textContent || "", src));
|
||||
const css = rewriteCssAssetUrls(s.textContent || "", src);
|
||||
compStyleChunks.push(scopeCompId ? scopeCssToComposition(css, scopeCompId) : css);
|
||||
s.remove();
|
||||
}
|
||||
for (const s of [...contentDoc.querySelectorAll("script")]) {
|
||||
@@ -470,7 +479,13 @@ export async function bundleToSingleHtml(
|
||||
}
|
||||
} else {
|
||||
compScriptChunks.push(
|
||||
`(function(){ try { ${s.textContent || ""} } catch (_err) { console.error('[HyperFrames] composition script error:', _err); } })();`,
|
||||
scopeCompId
|
||||
? wrapScopedCompositionScript(
|
||||
s.textContent || "",
|
||||
scopeCompId,
|
||||
"[HyperFrames] composition script error:",
|
||||
)
|
||||
: `(function(){ try { ${s.textContent || ""} } catch (_err) { console.error('[HyperFrames] composition script error:', _err); } })();`,
|
||||
);
|
||||
}
|
||||
s.remove();
|
||||
@@ -499,7 +514,7 @@ export async function bundleToSingleHtml(
|
||||
if (innerW && !hostEl.getAttribute("data-width")) hostEl.setAttribute("data-width", innerW);
|
||||
if (innerH && !hostEl.getAttribute("data-height")) hostEl.setAttribute("data-height", innerH);
|
||||
for (const child of [...innerRoot.querySelectorAll("style, script")]) child.remove();
|
||||
hostEl.innerHTML = innerRoot.innerHTML || "";
|
||||
hostEl.innerHTML = innerRoot.outerHTML || "";
|
||||
} else {
|
||||
for (const child of [...contentDoc.querySelectorAll("style, script")]) child.remove();
|
||||
hostEl.innerHTML = contentDoc.body.innerHTML || "";
|
||||
@@ -532,7 +547,8 @@ export async function bundleToSingleHtml(
|
||||
if (innerRoot) {
|
||||
// Hoist styles into the collected style chunks
|
||||
for (const styleEl of [...innerRoot.querySelectorAll("style")]) {
|
||||
compStyleChunks.push(styleEl.textContent || "");
|
||||
const css = styleEl.textContent || "";
|
||||
compStyleChunks.push(compId ? scopeCssToComposition(css, compId) : css);
|
||||
styleEl.remove();
|
||||
}
|
||||
// Hoist scripts into the collected script chunks
|
||||
@@ -544,7 +560,13 @@ export async function bundleToSingleHtml(
|
||||
}
|
||||
} else {
|
||||
compScriptChunks.push(
|
||||
`(function(){ try { ${scriptEl.textContent || ""} } catch (_err) { console.error('[HyperFrames] composition script error:', _err); } })();`,
|
||||
compId
|
||||
? wrapScopedCompositionScript(
|
||||
scriptEl.textContent || "",
|
||||
compId,
|
||||
"[HyperFrames] composition script error:",
|
||||
)
|
||||
: `(function(){ try { ${scriptEl.textContent || ""} } catch (_err) { console.error('[HyperFrames] composition script error:', _err); } })();`,
|
||||
);
|
||||
}
|
||||
scriptEl.remove();
|
||||
@@ -556,12 +578,13 @@ export async function bundleToSingleHtml(
|
||||
if (innerW && !host.getAttribute("data-width")) host.setAttribute("data-width", innerW);
|
||||
if (innerH && !host.getAttribute("data-height")) host.setAttribute("data-height", innerH);
|
||||
|
||||
// Set host content from inner root
|
||||
host.innerHTML = innerRoot.innerHTML || "";
|
||||
// Preserve the inner composition root so bundled previews match the runtime loader.
|
||||
host.innerHTML = innerRoot.outerHTML || "";
|
||||
} else {
|
||||
// No matching inner root — inject all template content directly
|
||||
for (const styleEl of [...innerDoc.querySelectorAll("style")]) {
|
||||
compStyleChunks.push(styleEl.textContent || "");
|
||||
const css = styleEl.textContent || "";
|
||||
compStyleChunks.push(compId ? scopeCssToComposition(css, compId) : css);
|
||||
styleEl.remove();
|
||||
}
|
||||
for (const scriptEl of [...innerDoc.querySelectorAll("script")]) {
|
||||
@@ -572,7 +595,13 @@ export async function bundleToSingleHtml(
|
||||
}
|
||||
} else {
|
||||
compScriptChunks.push(
|
||||
`(function(){ try { ${scriptEl.textContent || ""} } catch (_err) { console.error('[HyperFrames] composition script error:', _err); } })();`,
|
||||
compId
|
||||
? wrapScopedCompositionScript(
|
||||
scriptEl.textContent || "",
|
||||
compId,
|
||||
"[HyperFrames] composition script error:",
|
||||
)
|
||||
: `(function(){ try { ${scriptEl.textContent || ""} } catch (_err) { console.error('[HyperFrames] composition script error:', _err); } })();`,
|
||||
);
|
||||
}
|
||||
scriptEl.remove();
|
||||
|
||||
@@ -22,3 +22,6 @@ export {
|
||||
type HyperframeStaticFailureReason,
|
||||
type HyperframeStaticGuardResult,
|
||||
} from "./staticGuard";
|
||||
|
||||
// Composition isolation helpers
|
||||
export { scopeCssToComposition, wrapScopedCompositionScript } from "./compositionScoping";
|
||||
|
||||
@@ -95,6 +95,29 @@ describe("composition rules", () => {
|
||||
expect(finding).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reports error when a selector combines data attributes in one bracket", () => {
|
||||
const html = `
|
||||
<template id="scene-template">
|
||||
<div data-composition-id="scene" data-start="0" data-width="1920" data-height="1080">
|
||||
<style>
|
||||
[data-composition-id="scene" data-start="0"] .title { opacity: 0; }
|
||||
</style>
|
||||
<script>
|
||||
window.__timelines = window.__timelines || {};
|
||||
const title = document.querySelector('[data-composition-id="scene" data-start="0"] .title');
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to('[data-composition-id="scene" data-start="0"]', { opacity: 0, duration: 0.5 }, 4);
|
||||
window.__timelines["scene"] = tl;
|
||||
</script>
|
||||
</div>
|
||||
</template>`;
|
||||
const result = lintHyperframeHtml(html, { filePath: "compositions/scene.html" });
|
||||
const findings = result.findings.filter((f) => f.code === "split_data_attribute_selector");
|
||||
expect(findings.length).toBe(1);
|
||||
expect(findings[0]?.severity).toBe("error");
|
||||
expect(findings[0]?.fixHint).toContain('[data-composition-id="scene"][data-start="0"]');
|
||||
});
|
||||
|
||||
describe("timed_element_missing_clip_class", () => {
|
||||
it("flags element with data-start but no class='clip'", () => {
|
||||
const html = `
|
||||
|
||||
@@ -62,6 +62,35 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
|
||||
return findings;
|
||||
},
|
||||
|
||||
// split_data_attribute_selector
|
||||
({ scripts, styles }) => {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
const splitDataAttrSelectorPattern =
|
||||
/\[data-composition-id=(["'])([^"'\]]+)\1\s+(data-[\w:-]+)=(["'])([^"'\]]*)\4\]/g;
|
||||
const scan = (content: string) => {
|
||||
splitDataAttrSelectorPattern.lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = splitDataAttrSelectorPattern.exec(content)) !== null) {
|
||||
const compId = match[2] ?? "";
|
||||
const attrName = match[3] ?? "";
|
||||
const attrValue = match[5] ?? "";
|
||||
findings.push({
|
||||
code: "split_data_attribute_selector",
|
||||
severity: "error",
|
||||
message:
|
||||
`Selector "${match[0]}" combines two attributes inside one CSS attribute selector. ` +
|
||||
"Browsers reject it, so GSAP timelines or querySelector calls will fail before registering.",
|
||||
selector: match[0],
|
||||
fixHint: `Use separate attribute selectors: [data-composition-id="${compId}"][${attrName}="${attrValue}"].`,
|
||||
snippet: truncateSnippet(match[0]),
|
||||
});
|
||||
}
|
||||
};
|
||||
for (const style of styles) scan(style.content);
|
||||
for (const script of scripts) scan(script.content);
|
||||
return findings;
|
||||
},
|
||||
|
||||
// template_literal_selector
|
||||
({ scripts }) => {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
|
||||
@@ -90,6 +90,51 @@ describe("media rules", () => {
|
||||
expect(finding?.severity).toBe("error");
|
||||
});
|
||||
|
||||
it("reports error for media with src but no data-start", () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
|
||||
<video id="demo-video" src="clip.mp4" muted playsinline></video>
|
||||
</div>
|
||||
<script>window.__timelines = window.__timelines || {}; window.__timelines["c1"] = gsap.timeline({ paused: true });</script>
|
||||
</body></html>`;
|
||||
const result = lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "media_missing_data_start");
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.severity).toBe("error");
|
||||
expect(finding?.elementId).toBe("demo-video");
|
||||
});
|
||||
|
||||
it("allows audible video clips to omit muted when data-has-audio is true", () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
|
||||
<video id="demo-video" data-start="0" data-duration="5" data-has-audio="true" src="clip.mp4" playsinline></video>
|
||||
</div>
|
||||
<script>window.__timelines = window.__timelines || {}; window.__timelines["c1"] = gsap.timeline({ paused: true });</script>
|
||||
</body></html>`;
|
||||
const result = lintHyperframeHtml(html);
|
||||
expect(result.findings.find((f) => f.code === "video_missing_muted")).toBeUndefined();
|
||||
expect(
|
||||
result.findings.find((f) => f.code === "video_muted_with_declared_audio"),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reports error for videos that declare audio while muted", () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
|
||||
<video id="demo-video" data-start="0" data-duration="5" data-has-audio="true" src="clip.mp4" muted playsinline></video>
|
||||
</div>
|
||||
<script>window.__timelines = window.__timelines || {}; window.__timelines["c1"] = gsap.timeline({ paused: true });</script>
|
||||
</body></html>`;
|
||||
const result = lintHyperframeHtml(html);
|
||||
const finding = result.findings.find((f) => f.code === "video_muted_with_declared_audio");
|
||||
expect(finding).toBeDefined();
|
||||
expect(finding?.severity).toBe("error");
|
||||
expect(finding?.elementId).toBe("demo-video");
|
||||
});
|
||||
|
||||
it("does NOT flag <video> as nested in a void element with data-start (regression)", () => {
|
||||
// Regression: void elements like <img> have no closing tag, so the previous
|
||||
// implementation kept them on the parent stack indefinitely and flagged any
|
||||
@@ -142,6 +187,24 @@ describe("media rules", () => {
|
||||
expect(finding?.severity).toBe("error");
|
||||
});
|
||||
|
||||
it("reports imperative muted/play control on class-selected media without ids", () => {
|
||||
const html = `
|
||||
<template id="scene-template">
|
||||
<div data-composition-id="scene" data-width="1920" data-height="1080">
|
||||
<video class="demo-video" src="clip.mp4" muted playsinline></video>
|
||||
<script>
|
||||
const vid = document.querySelector('[data-composition-id="scene"] .demo-video');
|
||||
if (vid) { vid.muted = true; vid.play(); }
|
||||
</script>
|
||||
</div>
|
||||
</template>`;
|
||||
const result = lintHyperframeHtml(html, { filePath: "compositions/scene.html" });
|
||||
const imperativeFindings = result.findings.filter((f) => f.code === "imperative_media_control");
|
||||
expect(imperativeFindings.length).toBe(2);
|
||||
expect(imperativeFindings.some((f) => f.snippet === "vid.muted =")).toBe(true);
|
||||
expect(imperativeFindings.some((f) => f.snippet === "vid.play(")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not flag play() on non-media elements", () => {
|
||||
const html = `
|
||||
<html><body>
|
||||
|
||||
@@ -5,48 +5,86 @@ function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function selectorTargetsManagedMedia(selector: string, mediaIds: Set<string>): boolean {
|
||||
function hasAttrName(tagSource: string, attr: string): boolean {
|
||||
const escaped = escapeRegExp(attr);
|
||||
const attrs = tagSource.replace(/^<\s*[a-z][\w:-]*/i, "");
|
||||
return new RegExp(`(?:^|\\s)${escaped}(?:\\s*=|\\s|/?>)`, "i").test(attrs);
|
||||
}
|
||||
|
||||
function classNamesFromAttr(classAttr: string | null): string[] {
|
||||
if (!classAttr) return [];
|
||||
return classAttr.split(/\s+/).filter(Boolean);
|
||||
}
|
||||
|
||||
type MediaSelectorIndex = {
|
||||
ids: Set<string>;
|
||||
classes: Set<string>;
|
||||
hasVideo: boolean;
|
||||
hasAudio: boolean;
|
||||
};
|
||||
|
||||
function selectorTargetsManagedMedia(selector: string, mediaIndex: MediaSelectorIndex): boolean {
|
||||
const normalized = selector.trim();
|
||||
if (!normalized) return false;
|
||||
if (/\b(video|audio)\b/i.test(normalized)) return true;
|
||||
for (const mediaId of mediaIds) {
|
||||
if (mediaIndex.hasVideo && /\bvideo\b/i.test(normalized)) return true;
|
||||
if (mediaIndex.hasAudio && /\baudio\b/i.test(normalized)) return true;
|
||||
for (const mediaId of mediaIndex.ids) {
|
||||
const escapedId = escapeRegExp(mediaId);
|
||||
if (
|
||||
normalized.includes(`#${mediaId}`) ||
|
||||
new RegExp(`#${escapedId}(?![\\w-])`).test(normalized) ||
|
||||
normalized.includes(`[id="${mediaId}"]`) ||
|
||||
normalized.includes(`[id='${mediaId}']`)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
for (const className of mediaIndex.classes) {
|
||||
if (new RegExp(`\\.${escapeRegExp(className)}(?![\\w-])`).test(normalized)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function findImperativeMediaControlFindings(ctx: LintContext): HyperframeLintFinding[] {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
const managedMediaIds = new Set(
|
||||
ctx.tags
|
||||
.filter((tag) => tag.name === "video" || tag.name === "audio")
|
||||
.map((tag) => readAttr(tag.raw, "id"))
|
||||
.filter((id): id is string => Boolean(id)),
|
||||
);
|
||||
const mediaTags = ctx.tags.filter((tag) => tag.name === "video" || tag.name === "audio");
|
||||
const mediaIndex: MediaSelectorIndex = {
|
||||
ids: new Set(
|
||||
mediaTags.map((tag) => readAttr(tag.raw, "id")).filter((id): id is string => Boolean(id)),
|
||||
),
|
||||
classes: new Set(mediaTags.flatMap((tag) => classNamesFromAttr(readAttr(tag.raw, "class")))),
|
||||
hasVideo: mediaTags.some((tag) => tag.name === "video"),
|
||||
hasAudio: mediaTags.some((tag) => tag.name === "audio"),
|
||||
};
|
||||
|
||||
if (managedMediaIds.size === 0 || ctx.scripts.length === 0) return findings;
|
||||
if (mediaTags.length === 0 || ctx.scripts.length === 0) return findings;
|
||||
|
||||
for (const script of ctx.scripts) {
|
||||
const mediaVars = new Map<string, string | undefined>();
|
||||
const assignmentPatterns = [
|
||||
/\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:document|window\.document)\.getElementById\(\s*["']([^"']+)["']\s*\)/g,
|
||||
/\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:document|window\.document)\.querySelector\(\s*["']([^"']+)["']\s*\)/g,
|
||||
{
|
||||
pattern:
|
||||
/\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:document|window\.document)\.getElementById\(\s*["']([^"']+)["']\s*\)/g,
|
||||
variableIndex: 1,
|
||||
targetIndex: 2,
|
||||
},
|
||||
{
|
||||
pattern:
|
||||
/\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:document|window\.document)\.querySelector\(\s*(["'])([\s\S]*?)\2\s*\)/g,
|
||||
variableIndex: 1,
|
||||
targetIndex: 3,
|
||||
},
|
||||
];
|
||||
|
||||
for (const pattern of assignmentPatterns) {
|
||||
for (const { pattern, variableIndex, targetIndex } of assignmentPatterns) {
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = pattern.exec(script.content)) !== null) {
|
||||
const variableName = match[1];
|
||||
const target = match[2];
|
||||
const variableName = match[variableIndex];
|
||||
const target = match[targetIndex];
|
||||
if (!variableName || !target) continue;
|
||||
if (managedMediaIds.has(target) || selectorTargetsManagedMedia(target, managedMediaIds)) {
|
||||
mediaVars.set(variableName, managedMediaIds.has(target) ? target : undefined);
|
||||
if (mediaIndex.ids.has(target) || selectorTargetsManagedMedia(target, mediaIndex)) {
|
||||
mediaVars.set(variableName, mediaIndex.ids.has(target) ? target : undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -56,42 +94,60 @@ function findImperativeMediaControlFindings(ctx: LintContext): HyperframeLintFin
|
||||
pattern:
|
||||
/\b(?:document|window\.document)\.getElementById\(\s*["']([^"']+)["']\s*\)\.play\s*\(/g,
|
||||
kind: "play()",
|
||||
targetIndex: 1,
|
||||
},
|
||||
{
|
||||
pattern:
|
||||
/\b(?:document|window\.document)\.getElementById\(\s*["']([^"']+)["']\s*\)\.pause\s*\(/g,
|
||||
kind: "pause()",
|
||||
targetIndex: 1,
|
||||
},
|
||||
{
|
||||
pattern:
|
||||
/\b(?:document|window\.document)\.getElementById\(\s*["']([^"']+)["']\s*\)\.currentTime\s*=/g,
|
||||
kind: "currentTime",
|
||||
targetIndex: 1,
|
||||
},
|
||||
{
|
||||
pattern:
|
||||
/\b(?:document|window\.document)\.querySelector\(\s*["']([^"']+)["']\s*\)\.play\s*\(/g,
|
||||
/\b(?:document|window\.document)\.getElementById\(\s*["']([^"']+)["']\s*\)\.muted\s*=/g,
|
||||
kind: "muted assignment",
|
||||
targetIndex: 1,
|
||||
},
|
||||
{
|
||||
pattern:
|
||||
/\b(?:document|window\.document)\.querySelector\(\s*(["'])([\s\S]*?)\1\s*\)\.play\s*\(/g,
|
||||
kind: "play()",
|
||||
targetIndex: 2,
|
||||
},
|
||||
{
|
||||
pattern:
|
||||
/\b(?:document|window\.document)\.querySelector\(\s*["']([^"']+)["']\s*\)\.pause\s*\(/g,
|
||||
/\b(?:document|window\.document)\.querySelector\(\s*(["'])([\s\S]*?)\1\s*\)\.pause\s*\(/g,
|
||||
kind: "pause()",
|
||||
targetIndex: 2,
|
||||
},
|
||||
{
|
||||
pattern:
|
||||
/\b(?:document|window\.document)\.querySelector\(\s*["']([^"']+)["']\s*\)\.currentTime\s*=/g,
|
||||
/\b(?:document|window\.document)\.querySelector\(\s*(["'])([\s\S]*?)\1\s*\)\.currentTime\s*=/g,
|
||||
kind: "currentTime",
|
||||
targetIndex: 2,
|
||||
},
|
||||
{
|
||||
pattern:
|
||||
/\b(?:document|window\.document)\.querySelector\(\s*(["'])([\s\S]*?)\1\s*\)\.muted\s*=/g,
|
||||
kind: "muted assignment",
|
||||
targetIndex: 2,
|
||||
},
|
||||
];
|
||||
|
||||
for (const { pattern, kind } of directIdPatterns) {
|
||||
for (const { pattern, kind, targetIndex } of directIdPatterns) {
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = pattern.exec(script.content)) !== null) {
|
||||
const target = match[1];
|
||||
const target = match[targetIndex];
|
||||
if (!target) continue;
|
||||
const elementId = managedMediaIds.has(target)
|
||||
const elementId = mediaIndex.ids.has(target)
|
||||
? target
|
||||
: selectorTargetsManagedMedia(target, managedMediaIds)
|
||||
: selectorTargetsManagedMedia(target, mediaIndex)
|
||||
? undefined
|
||||
: null;
|
||||
if (elementId === null) continue;
|
||||
@@ -101,7 +157,7 @@ function findImperativeMediaControlFindings(ctx: LintContext): HyperframeLintFin
|
||||
message: `Inline <script> imperatively controls managed media via ${kind}. HyperFrames must own media play/pause/seek to keep preview, timeline, and renders deterministic.`,
|
||||
elementId: elementId || undefined,
|
||||
fixHint:
|
||||
"Remove imperative media play/pause/currentTime control. Express timing with data-start/data-duration and media offsets like data-media-start or data-playback-start instead.",
|
||||
"Remove imperative media play/pause/currentTime/muted control. Express timing with data-start/data-duration and media offsets like data-media-start or data-playback-start instead.",
|
||||
snippet: truncateSnippet(match[0]),
|
||||
});
|
||||
}
|
||||
@@ -113,6 +169,10 @@ function findImperativeMediaControlFindings(ctx: LintContext): HyperframeLintFin
|
||||
{ pattern: new RegExp(`\\b${escapedVar}\\.play\\s*\\(`, "g"), kind: "play()" },
|
||||
{ pattern: new RegExp(`\\b${escapedVar}\\.pause\\s*\\(`, "g"), kind: "pause()" },
|
||||
{ pattern: new RegExp(`\\b${escapedVar}\\.currentTime\\s*=`, "g"), kind: "currentTime" },
|
||||
{
|
||||
pattern: new RegExp(`\\b${escapedVar}\\.muted\\s*=`, "g"),
|
||||
kind: "muted assignment",
|
||||
},
|
||||
];
|
||||
for (const { pattern, kind } of variablePatterns) {
|
||||
let match: RegExpExecArray | null;
|
||||
@@ -123,7 +183,7 @@ function findImperativeMediaControlFindings(ctx: LintContext): HyperframeLintFin
|
||||
message: `Inline <script> imperatively controls managed media via ${kind}. HyperFrames must own media play/pause/seek to keep preview, timeline, and renders deterministic.`,
|
||||
elementId,
|
||||
fixHint:
|
||||
"Remove imperative media play/pause/currentTime control. Express timing with data-start/data-duration and media offsets like data-media-start or data-playback-start instead.",
|
||||
"Remove imperative media play/pause/currentTime/muted control. Express timing with data-start/data-duration and media offsets like data-media-start or data-playback-start instead.",
|
||||
snippet: truncateSnippet(match[0]),
|
||||
});
|
||||
}
|
||||
@@ -192,16 +252,29 @@ export const mediaRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> =
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
for (const tag of tags) {
|
||||
if (tag.name !== "video") continue;
|
||||
const hasMuted = /\bmuted\b/i.test(tag.raw);
|
||||
if (!hasMuted && readAttr(tag.raw, "data-start")) {
|
||||
const hasMuted = hasAttrName(tag.raw, "muted");
|
||||
const hasDeclaredAudio = readAttr(tag.raw, "data-has-audio") === "true";
|
||||
if (!hasMuted && !hasDeclaredAudio && readAttr(tag.raw, "data-start")) {
|
||||
const elementId = readAttr(tag.raw, "id") || undefined;
|
||||
findings.push({
|
||||
code: "video_missing_muted",
|
||||
severity: "error",
|
||||
message: `<video${elementId ? ` id="${elementId}"` : ""}> has data-start but is not muted. The framework expects video to be muted with a separate <audio> element for sound.`,
|
||||
message: `<video${elementId ? ` id="${elementId}"` : ""}> has data-start but is not muted. Mark audible videos with data-has-audio="true"; otherwise keep video muted and use a separate <audio> element for sound.`,
|
||||
elementId,
|
||||
fixHint:
|
||||
"Add the `muted` attribute to the <video> tag and use a separate <audio> element with the same src for audio playback.",
|
||||
'Add the `muted` attribute for silent video, or add data-has-audio="true" when the video track should contribute audio.',
|
||||
snippet: truncateSnippet(tag.raw),
|
||||
});
|
||||
}
|
||||
if (hasMuted && hasDeclaredAudio) {
|
||||
const elementId = readAttr(tag.raw, "id") || undefined;
|
||||
findings.push({
|
||||
code: "video_muted_with_declared_audio",
|
||||
severity: "error",
|
||||
message: `<video${elementId ? ` id="${elementId}"` : ""}> declares data-has-audio="true" but also has muted. Studio preview will silence the video audio.`,
|
||||
elementId,
|
||||
fixHint:
|
||||
'Remove the `muted` attribute if this video should be audible, or remove data-has-audio="true" and use data-volume="0" for silent visual video.',
|
||||
snippet: truncateSnippet(tag.raw),
|
||||
});
|
||||
}
|
||||
@@ -336,7 +409,7 @@ export const mediaRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> =
|
||||
return findings;
|
||||
},
|
||||
|
||||
// media_missing_id + media_missing_src + media_preload_none
|
||||
// media_missing_data_start + media_missing_id + media_missing_src + media_preload_none
|
||||
({ tags }) => {
|
||||
const findings: HyperframeLintFinding[] = [];
|
||||
for (const tag of tags) {
|
||||
@@ -344,6 +417,16 @@ export const mediaRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> =
|
||||
const hasDataStart = readAttr(tag.raw, "data-start");
|
||||
const hasId = readAttr(tag.raw, "id");
|
||||
const hasSrc = readAttr(tag.raw, "src");
|
||||
if (hasSrc && !hasDataStart) {
|
||||
findings.push({
|
||||
code: "media_missing_data_start",
|
||||
severity: "error",
|
||||
message: `<${tag.name}${hasId ? ` id="${hasId}"` : ""}> has src but no data-start. HyperFrames cannot own playback for untimed media, so preview and render behavior can diverge.`,
|
||||
elementId: hasId || undefined,
|
||||
fixHint: `Add data-start="0" (or the intended start time) and data-duration if the clip should stop before the source ends.`,
|
||||
snippet: truncateSnippet(tag.raw),
|
||||
});
|
||||
}
|
||||
if (hasDataStart && !hasId) {
|
||||
findings.push({
|
||||
code: "media_missing_id",
|
||||
|
||||
@@ -15,6 +15,8 @@ describe("loadExternalCompositions", () => {
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
document.head.querySelectorAll("style").forEach((s) => s.remove());
|
||||
delete (window as Window & { gsap?: unknown; __selectedTitle?: unknown }).gsap;
|
||||
delete (window as Window & { gsap?: unknown; __selectedTitle?: unknown }).__selectedTitle;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
@@ -49,9 +51,11 @@ describe("loadExternalCompositions", () => {
|
||||
await loadExternalCompositions({ ...defaultParams });
|
||||
|
||||
const mountedParagraph = host.querySelector("p");
|
||||
const innerRoot = host.firstElementChild;
|
||||
|
||||
expect(mountedParagraph).toBeTruthy();
|
||||
expect(mountedParagraph?.textContent).toBe("Hello World");
|
||||
expect(innerRoot?.getAttribute("data-composition-id")).toBe("scene-1");
|
||||
});
|
||||
|
||||
it("injects styles into document head", async () => {
|
||||
@@ -190,6 +194,44 @@ describe("loadExternalCompositions", () => {
|
||||
expect(injectedScripts[0].textContent).toContain("console.log");
|
||||
});
|
||||
|
||||
it("scopes injected styles and document selectors to the mounted composition root", async () => {
|
||||
const otherRoot = document.createElement("div");
|
||||
otherRoot.setAttribute("data-composition-id", "other");
|
||||
otherRoot.innerHTML = '<h1 class="title">Other</h1>';
|
||||
document.body.appendChild(otherRoot);
|
||||
|
||||
const host = document.createElement("div");
|
||||
host.setAttribute("data-composition-src", "https://example.com/comp.html");
|
||||
host.setAttribute("data-composition-id", "scene");
|
||||
document.body.appendChild(host);
|
||||
|
||||
const compositionHtml = `
|
||||
<html><body>
|
||||
<div data-composition-id="scene" data-width="1920" data-height="1080">
|
||||
<style>.title { opacity: 0; }</style>
|
||||
<h1 class="title">Scene</h1>
|
||||
<script>
|
||||
window.__selectedTitle = document.querySelector('.title')?.textContent;
|
||||
</script>
|
||||
</div>
|
||||
</body></html>
|
||||
`;
|
||||
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(compositionHtml, { status: 200 }));
|
||||
|
||||
const injectedStyles: HTMLStyleElement[] = [];
|
||||
const injectedScripts: HTMLScriptElement[] = [];
|
||||
await loadExternalCompositions({
|
||||
...defaultParams,
|
||||
injectedStyles,
|
||||
injectedScripts,
|
||||
});
|
||||
|
||||
expect(injectedStyles[0]?.textContent).toContain('[data-composition-id="scene"] .title');
|
||||
expect(injectedScripts[0]?.textContent).toContain('var __hfCompId = "scene";');
|
||||
expect(injectedScripts[0]?.textContent).toContain("new Proxy(window.document");
|
||||
});
|
||||
|
||||
it("handles multiple compositions in parallel", async () => {
|
||||
const host1 = document.createElement("div");
|
||||
host1.setAttribute("data-composition-src", "https://example.com/a.html");
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { scopeCssToComposition, wrapScopedCompositionScript } from "../compiler/compositionScoping";
|
||||
|
||||
type LoadExternalCompositionsParams = {
|
||||
injectedStyles: HTMLStyleElement[];
|
||||
injectedScripts: HTMLScriptElement[];
|
||||
@@ -13,6 +15,7 @@ type PendingScript =
|
||||
kind: "inline";
|
||||
content: string;
|
||||
type: string;
|
||||
scopeCompositionId: string | null;
|
||||
}
|
||||
| {
|
||||
kind: "external";
|
||||
@@ -101,6 +104,8 @@ async function mountCompositionContent(params: {
|
||||
) ?? null;
|
||||
}
|
||||
const contentNode = innerRoot ?? params.sourceNode;
|
||||
const scopeCompositionId =
|
||||
innerRoot?.getAttribute("data-composition-id")?.trim() || params.hostCompositionId || null;
|
||||
|
||||
// Inject <head> styles from non-template sub-compositions first (they define
|
||||
// element styles like backgrounds and positioning that the composition needs).
|
||||
@@ -108,6 +113,12 @@ async function mountCompositionContent(params: {
|
||||
for (const style of params.headStyles) {
|
||||
const clonedStyle = style.cloneNode(true);
|
||||
if (!(clonedStyle instanceof HTMLStyleElement)) continue;
|
||||
if (scopeCompositionId) {
|
||||
clonedStyle.textContent = scopeCssToComposition(
|
||||
clonedStyle.textContent || "",
|
||||
scopeCompositionId,
|
||||
);
|
||||
}
|
||||
document.head.appendChild(clonedStyle);
|
||||
params.injectedStyles.push(clonedStyle);
|
||||
}
|
||||
@@ -117,6 +128,12 @@ async function mountCompositionContent(params: {
|
||||
for (const style of styles) {
|
||||
const clonedStyle = style.cloneNode(true);
|
||||
if (!(clonedStyle instanceof HTMLStyleElement)) continue;
|
||||
if (scopeCompositionId) {
|
||||
clonedStyle.textContent = scopeCssToComposition(
|
||||
clonedStyle.textContent || "",
|
||||
scopeCompositionId,
|
||||
);
|
||||
}
|
||||
document.head.appendChild(clonedStyle);
|
||||
params.injectedStyles.push(clonedStyle);
|
||||
}
|
||||
@@ -134,7 +151,12 @@ async function mountCompositionContent(params: {
|
||||
} else {
|
||||
const scriptText = script.textContent?.trim() ?? "";
|
||||
if (scriptText) {
|
||||
headScriptPayloads.push({ kind: "inline", content: scriptText, type: scriptType });
|
||||
headScriptPayloads.push({
|
||||
kind: "inline",
|
||||
content: scriptText,
|
||||
type: scriptType,
|
||||
scopeCompositionId,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -159,6 +181,7 @@ async function mountCompositionContent(params: {
|
||||
kind: "inline",
|
||||
content: scriptText,
|
||||
type: scriptType,
|
||||
scopeCompositionId,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -202,6 +225,11 @@ async function mountCompositionContent(params: {
|
||||
injectedScript.src = scriptPayload.src;
|
||||
} else if (scriptPayload.type.toLowerCase() === "module") {
|
||||
injectedScript.textContent = scriptPayload.content;
|
||||
} else if (scriptPayload.scopeCompositionId) {
|
||||
injectedScript.textContent = wrapScopedCompositionScript(
|
||||
scriptPayload.content,
|
||||
scriptPayload.scopeCompositionId,
|
||||
);
|
||||
} else {
|
||||
injectedScript.textContent = `(function(){${scriptPayload.content}})();`;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { initSandboxRuntimeModular } from "./init";
|
||||
import type { RuntimeTimelineLike } from "./types";
|
||||
|
||||
function createMockTimeline(duration: number): RuntimeTimelineLike {
|
||||
const state = { time: 0, paused: true };
|
||||
const state = { time: 0, paused: true, duration };
|
||||
return {
|
||||
play: () => {
|
||||
state.paused = false;
|
||||
@@ -18,7 +18,7 @@ function createMockTimeline(duration: number): RuntimeTimelineLike {
|
||||
state.time = time;
|
||||
},
|
||||
time: () => state.time,
|
||||
duration: () => duration,
|
||||
duration: () => state.duration,
|
||||
add: () => {},
|
||||
paused: (value?: boolean) => {
|
||||
if (typeof value === "boolean") {
|
||||
@@ -32,6 +32,19 @@ function createMockTimeline(duration: number): RuntimeTimelineLike {
|
||||
};
|
||||
}
|
||||
|
||||
function createPaddableMockTimeline(duration: number): RuntimeTimelineLike {
|
||||
const timeline = createMockTimeline(duration) as RuntimeTimelineLike & {
|
||||
to: (_target: object, vars: { duration: number }, position: number) => void;
|
||||
};
|
||||
const baseDuration = timeline.duration;
|
||||
let paddedDuration = baseDuration();
|
||||
timeline.duration = () => paddedDuration;
|
||||
timeline.to = (_target, vars, position) => {
|
||||
paddedDuration = Math.max(paddedDuration, position + Math.max(0, Number(vars.duration) || 0));
|
||||
};
|
||||
return timeline;
|
||||
}
|
||||
|
||||
describe("initSandboxRuntimeModular", () => {
|
||||
const originalRequestAnimationFrame = window.requestAnimationFrame;
|
||||
const originalCancelAnimationFrame = window.cancelAnimationFrame;
|
||||
@@ -62,6 +75,7 @@ describe("initSandboxRuntimeModular", () => {
|
||||
const root = document.createElement("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);
|
||||
@@ -95,6 +109,7 @@ describe("initSandboxRuntimeModular", () => {
|
||||
const root = document.createElement("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);
|
||||
@@ -124,6 +139,58 @@ describe("initSandboxRuntimeModular", () => {
|
||||
expect(child.style.visibility).toBe("hidden");
|
||||
});
|
||||
|
||||
it("pads the root timeline to the authored composition schedule before seeking visibility", () => {
|
||||
const root = document.createElement("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);
|
||||
|
||||
const slide1 = document.createElement("div");
|
||||
slide1.id = "slide-1";
|
||||
slide1.setAttribute("data-composition-id", "slide-1");
|
||||
slide1.setAttribute("data-start", "0");
|
||||
slide1.setAttribute("data-hf-authored-duration", "14");
|
||||
root.appendChild(slide1);
|
||||
|
||||
const slide2 = document.createElement("div");
|
||||
slide2.id = "slide-2";
|
||||
slide2.setAttribute("data-composition-id", "slide-2");
|
||||
slide2.setAttribute("data-start", "slide-1");
|
||||
slide2.setAttribute("data-hf-authored-duration", "12");
|
||||
root.appendChild(slide2);
|
||||
|
||||
const slide3 = document.createElement("div");
|
||||
slide3.id = "slide-3";
|
||||
slide3.setAttribute("data-composition-id", "slide-3");
|
||||
slide3.setAttribute("data-start", "slide-2");
|
||||
slide3.setAttribute("data-hf-authored-duration", "16");
|
||||
root.appendChild(slide3);
|
||||
|
||||
(window as Window & { __timelines?: Record<string, RuntimeTimelineLike> }).__timelines = {
|
||||
main: createPaddableMockTimeline(14),
|
||||
};
|
||||
|
||||
initSandboxRuntimeModular();
|
||||
|
||||
const player = (
|
||||
window as Window & {
|
||||
__player?: { getDuration: () => number; seek: (timeSeconds: number) => void };
|
||||
}
|
||||
).__player;
|
||||
expect(player).toBeDefined();
|
||||
expect(player?.getDuration()).toBe(42);
|
||||
|
||||
player?.seek(30);
|
||||
|
||||
expect(root.style.visibility).toBe("visible");
|
||||
expect(slide1.style.visibility).toBe("hidden");
|
||||
expect(slide2.style.visibility).toBe("hidden");
|
||||
expect(slide3.style.visibility).toBe("visible");
|
||||
});
|
||||
|
||||
it("pauses nested media that is outside the timed-media cache after a seek", () => {
|
||||
const root = document.createElement("div");
|
||||
root.setAttribute("data-composition-id", "main");
|
||||
|
||||
@@ -358,12 +358,17 @@ export function initSandboxRuntimeModular(): void {
|
||||
}
|
||||
};
|
||||
|
||||
const resolveStartForElement = (element: Element, fallback = 0): number => {
|
||||
const resolveStartForElement = (
|
||||
element: Element,
|
||||
fallback = 0,
|
||||
opts?: { includeAuthoredTimingAttrs?: boolean },
|
||||
): number => {
|
||||
const resolver = createRuntimeStartTimeResolver({
|
||||
timelineRegistry: (window.__timelines ?? {}) as Record<
|
||||
string,
|
||||
RuntimeTimelineLike | undefined
|
||||
>,
|
||||
includeAuthoredTimingAttrs: opts?.includeAuthoredTimingAttrs ?? true,
|
||||
});
|
||||
return resolver.resolveStartForElement(element, fallback);
|
||||
};
|
||||
@@ -459,6 +464,30 @@ export function initSandboxRuntimeModular(): void {
|
||||
return maxWindowEndSeconds > MIN_VALID_TIMELINE_DURATION_SECONDS ? maxWindowEndSeconds : null;
|
||||
};
|
||||
|
||||
const resolveAuthoredCompositionDurationFloorSeconds = (): number | null => {
|
||||
const rootEl = resolveRootCompositionElement();
|
||||
if (!rootEl) return null;
|
||||
const timelines = (window.__timelines ?? {}) as Record<string, RuntimeTimelineLike | undefined>;
|
||||
const startResolver = createRuntimeStartTimeResolver({
|
||||
timelineRegistry: timelines,
|
||||
includeAuthoredTimingAttrs: true,
|
||||
});
|
||||
let maxWindowEndSeconds = 0;
|
||||
const compositionNodes = Array.from(
|
||||
rootEl.querySelectorAll("[data-composition-id][data-start]"),
|
||||
);
|
||||
for (const node of compositionNodes) {
|
||||
if (!(node instanceof Element)) continue;
|
||||
const parentComposition = node.parentElement?.closest("[data-composition-id]");
|
||||
if (parentComposition !== rootEl) continue;
|
||||
const start = startResolver.resolveStartForElement(node, 0);
|
||||
const duration = startResolver.resolveDurationForElement(node);
|
||||
if (!Number.isFinite(start) || duration == null || duration <= 0) continue;
|
||||
maxWindowEndSeconds = Math.max(maxWindowEndSeconds, Math.max(0, start) + duration);
|
||||
}
|
||||
return maxWindowEndSeconds > MIN_VALID_TIMELINE_DURATION_SECONDS ? maxWindowEndSeconds : null;
|
||||
};
|
||||
|
||||
const resolveMediaDurationFloorSeconds = (): number | null => {
|
||||
const mediaWindowDuration = resolveMediaWindowDurationSeconds();
|
||||
if (
|
||||
@@ -487,14 +516,16 @@ export function initSandboxRuntimeModular(): void {
|
||||
): number => {
|
||||
const timelineDuration = getTimelineDurationSeconds(timeline);
|
||||
const mediaFloor = resolveMediaDurationFloorSeconds();
|
||||
const authoredCompositionFloor = resolveAuthoredCompositionDurationFloorSeconds();
|
||||
const durationFloor = Math.max(mediaFloor ?? 0, authoredCompositionFloor ?? 0);
|
||||
const fallbackDuration =
|
||||
Number.isFinite(fallback) && fallback > MIN_VALID_TIMELINE_DURATION_SECONDS ? fallback : 0;
|
||||
let safeDuration = 0;
|
||||
// Timeline is the source of truth for authored composition duration.
|
||||
if (isUsableTimelineDuration(timelineDuration)) {
|
||||
safeDuration = Math.max(timelineDuration, fallbackDuration);
|
||||
} else if (isUsableTimelineDuration(mediaFloor)) {
|
||||
safeDuration = Math.max(mediaFloor, fallbackDuration);
|
||||
safeDuration = Math.max(timelineDuration, durationFloor, fallbackDuration);
|
||||
} else if (isUsableTimelineDuration(durationFloor)) {
|
||||
safeDuration = Math.max(durationFloor, fallbackDuration);
|
||||
} else {
|
||||
safeDuration = fallbackDuration;
|
||||
}
|
||||
@@ -504,10 +535,17 @@ export function initSandboxRuntimeModular(): void {
|
||||
|
||||
const resolveRootTimelineFromDocument = (): TimelineResolution => {
|
||||
const timelines = (window.__timelines ?? {}) as Record<string, RuntimeTimelineLike | undefined>;
|
||||
const startResolver = createRuntimeStartTimeResolver({ timelineRegistry: timelines });
|
||||
const startResolver = createRuntimeStartTimeResolver({
|
||||
timelineRegistry: timelines,
|
||||
includeAuthoredTimingAttrs: true,
|
||||
});
|
||||
const mediaDurationFloorSeconds = resolveMediaDurationFloorSeconds();
|
||||
const minCandidateDurationSeconds =
|
||||
resolveMinCandidateDurationSeconds(mediaDurationFloorSeconds);
|
||||
const authoredCompositionDurationFloorSeconds =
|
||||
resolveAuthoredCompositionDurationFloorSeconds();
|
||||
const durationFloorSeconds =
|
||||
Math.max(mediaDurationFloorSeconds ?? 0, authoredCompositionDurationFloorSeconds ?? 0) ||
|
||||
null;
|
||||
const minCandidateDurationSeconds = resolveMinCandidateDurationSeconds(durationFloorSeconds);
|
||||
const resolveCompositionStartSeconds = (compositionId: string): number => {
|
||||
const node = document.querySelector(
|
||||
`[data-composition-id="${CSS.escape(compositionId)}"]`,
|
||||
@@ -701,6 +739,7 @@ export function initSandboxRuntimeModular(): void {
|
||||
minCandidateDurationSeconds,
|
||||
selectedDurationSeconds: compositeDurationSeconds,
|
||||
mediaDurationFloorSeconds,
|
||||
authoredCompositionDurationFloorSeconds,
|
||||
selectedTimelineIds,
|
||||
autoNestedChildren,
|
||||
},
|
||||
@@ -708,15 +747,15 @@ export function initSandboxRuntimeModular(): void {
|
||||
};
|
||||
}
|
||||
const durationFloorTimeline = createDurationFloorTimeline(
|
||||
mediaDurationFloorSeconds ?? 0,
|
||||
durationFloorSeconds ?? 0,
|
||||
rootTimeline,
|
||||
);
|
||||
const durationFloorSeconds = getTimelineDurationSeconds(durationFloorTimeline);
|
||||
if (durationFloorTimeline && isUsableTimelineDuration(durationFloorSeconds)) {
|
||||
const floorTimelineDurationSeconds = getTimelineDurationSeconds(durationFloorTimeline);
|
||||
if (durationFloorTimeline && isUsableTimelineDuration(floorTimelineDurationSeconds)) {
|
||||
return {
|
||||
timeline: durationFloorTimeline,
|
||||
selectedTimelineIds: [rootCompositionId],
|
||||
selectedDurationSeconds: durationFloorSeconds,
|
||||
selectedDurationSeconds: floorTimelineDurationSeconds,
|
||||
mediaDurationFloorSeconds,
|
||||
diagnostics: {
|
||||
code: "root_timeline_unusable_media_floor_fallback",
|
||||
@@ -725,7 +764,8 @@ export function initSandboxRuntimeModular(): void {
|
||||
rootDurationSeconds,
|
||||
fallbackKind: "media_duration_floor",
|
||||
mediaDurationFloorSeconds,
|
||||
selectedDurationSeconds: durationFloorSeconds,
|
||||
authoredCompositionDurationFloorSeconds,
|
||||
selectedDurationSeconds: floorTimelineDurationSeconds,
|
||||
selectedTimelineIds: [rootCompositionId],
|
||||
autoNestedChildren,
|
||||
},
|
||||
@@ -735,15 +775,15 @@ export function initSandboxRuntimeModular(): void {
|
||||
}
|
||||
if (!isUsableTimelineDuration(rootDurationSeconds) && rootChildCandidates.length === 0) {
|
||||
const durationFloorTimeline = createDurationFloorTimeline(
|
||||
mediaDurationFloorSeconds ?? 0,
|
||||
durationFloorSeconds ?? 0,
|
||||
rootTimeline,
|
||||
);
|
||||
const durationFloorSeconds = getTimelineDurationSeconds(durationFloorTimeline);
|
||||
if (durationFloorTimeline && isUsableTimelineDuration(durationFloorSeconds)) {
|
||||
const floorTimelineDurationSeconds = getTimelineDurationSeconds(durationFloorTimeline);
|
||||
if (durationFloorTimeline && isUsableTimelineDuration(floorTimelineDurationSeconds)) {
|
||||
return {
|
||||
timeline: durationFloorTimeline,
|
||||
selectedTimelineIds: [rootCompositionId],
|
||||
selectedDurationSeconds: durationFloorSeconds,
|
||||
selectedDurationSeconds: floorTimelineDurationSeconds,
|
||||
mediaDurationFloorSeconds,
|
||||
diagnostics: {
|
||||
code: "root_timeline_unusable_media_floor_fallback",
|
||||
@@ -752,37 +792,41 @@ export function initSandboxRuntimeModular(): void {
|
||||
rootDurationSeconds,
|
||||
fallbackKind: "media_duration_floor",
|
||||
mediaDurationFloorSeconds,
|
||||
selectedDurationSeconds: durationFloorSeconds,
|
||||
authoredCompositionDurationFloorSeconds,
|
||||
selectedDurationSeconds: floorTimelineDurationSeconds,
|
||||
selectedTimelineIds: [rootCompositionId],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
// If the root composition declares an explicit data-duration that meaningfully
|
||||
// exceeds the captured GSAP timeline, extend the timeline in-place by placing
|
||||
// a zero-duration no-op tween at the declared end position. This makes
|
||||
// timeline.duration() report the declared length without creating a composite
|
||||
// (which would double-count the original duration).
|
||||
// If the authored composition schedule meaningfully exceeds the captured
|
||||
// GSAP timeline, extend the timeline in-place with a zero-duration no-op
|
||||
// tween. Studio previews can inline only part of the timeline registry
|
||||
// while preserving the full host schedule in data-hf-authored-duration.
|
||||
const rootDeclaredDurAttr = rootCompositionNode?.getAttribute("data-duration");
|
||||
if (rootDeclaredDurAttr) {
|
||||
const rootDeclaredDur = parseFloat(rootDeclaredDurAttr);
|
||||
const rootDeclaredDur = rootDeclaredDurAttr ? parseFloat(rootDeclaredDurAttr) : null;
|
||||
const rootDurationFloorSeconds = Math.max(
|
||||
isUsableTimelineDuration(rootDeclaredDur) ? rootDeclaredDur : 0,
|
||||
authoredCompositionDurationFloorSeconds ?? 0,
|
||||
);
|
||||
if (rootDurationFloorSeconds > 0) {
|
||||
if (
|
||||
isUsableTimelineDuration(rootDeclaredDur) &&
|
||||
isUsableTimelineDuration(rootDurationFloorSeconds) &&
|
||||
isUsableTimelineDuration(rootDurationSeconds) &&
|
||||
// Only pad when the gap is meaningful (>= 0.5s) to avoid floating-point
|
||||
// false positives on compositions whose GSAP duration is already close
|
||||
// to data-duration.
|
||||
rootDeclaredDur >= rootDurationSeconds + 0.5
|
||||
rootDurationFloorSeconds >= rootDurationSeconds + 0.5
|
||||
) {
|
||||
const tlWithTo = rootTimeline as RuntimeTimelineLike & {
|
||||
to?: (target: object, vars: { duration: number }, position: number) => unknown;
|
||||
};
|
||||
if (typeof tlWithTo.to === "function") {
|
||||
try {
|
||||
// Placing a zero-duration tween AT rootDeclaredDur extends
|
||||
// timeline.duration() to exactly rootDeclaredDur.
|
||||
tlWithTo.to({}, { duration: 0 }, rootDeclaredDur);
|
||||
// Placing a zero-duration tween at the floor extends
|
||||
// timeline.duration() to exactly that point.
|
||||
tlWithTo.to({}, { duration: 0 }, rootDurationFloorSeconds);
|
||||
} catch {
|
||||
// keep runtime resilient
|
||||
}
|
||||
@@ -800,6 +844,7 @@ export function initSandboxRuntimeModular(): void {
|
||||
rootCompositionId,
|
||||
rootDurationSeconds,
|
||||
rootDeclaredDur,
|
||||
authoredCompositionDurationFloorSeconds,
|
||||
newDur,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -473,6 +473,7 @@ describe("template-wrapped sub-composition media offsets", () => {
|
||||
join(projectDir, "index.html"),
|
||||
`<!DOCTYPE html>
|
||||
<html>
|
||||
<head></head>
|
||||
<body>
|
||||
<div
|
||||
id="root"
|
||||
@@ -506,6 +507,8 @@ describe("template-wrapped sub-composition media offsets", () => {
|
||||
data-height="360"
|
||||
data-duration="4"
|
||||
>
|
||||
<style>.title { opacity: 0; }</style>
|
||||
<h1 class="title">Scene</h1>
|
||||
<video
|
||||
id="scene-video"
|
||||
src="../assets/clip.mp4"
|
||||
@@ -594,4 +597,18 @@ describe("template-wrapped sub-composition media offsets", () => {
|
||||
end: 25.5,
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves the sub-composition root in compiled render HTML", async () => {
|
||||
const { projectDir, indexPath } = writeTemplateWrappedProject(
|
||||
'data-start="20" data-duration="6" data-width="640" data-height="360"',
|
||||
'data-start="1.5" data-duration="4"',
|
||||
);
|
||||
|
||||
const compiled = await compileForRender(projectDir, indexPath, projectDir);
|
||||
|
||||
expect(compiled.html).toContain('id="scene-host"');
|
||||
expect(compiled.html).toContain('data-composition-id="scene" data-start="0"');
|
||||
expect(compiled.html).toContain('[data-composition-id="scene"] .title');
|
||||
expect(compiled.html).toContain("new Proxy(window.document");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
import { readFileSync, existsSync, mkdirSync } from "fs";
|
||||
import { join, dirname, resolve } from "path";
|
||||
import { parseHTML } from "linkedom";
|
||||
import postcss from "postcss";
|
||||
import {
|
||||
compileTimingAttrs,
|
||||
injectDurations,
|
||||
@@ -23,6 +22,7 @@ import {
|
||||
rewriteAssetPaths,
|
||||
rewriteCssAssetUrls,
|
||||
} from "@hyperframes/core";
|
||||
import { scopeCssToComposition, wrapScopedCompositionScript } from "@hyperframes/core/compiler";
|
||||
import { extractMediaMetadata, extractAudioMetadata } from "../utils/ffprobe.js";
|
||||
import { isPathInside, toExternalAssetKey } from "../utils/paths.js";
|
||||
import {
|
||||
@@ -448,45 +448,6 @@ function promoteCssImportsToLinkTags(html: string): string {
|
||||
* export, preventing font-loading and animation-ordering regressions.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Scope CSS rules to a specific composition by prepending each selector
|
||||
* with `[data-composition-id="<id>"]`. This prevents class name collisions
|
||||
* when multiple sub-compositions use the same class names (e.g. ".content").
|
||||
*
|
||||
* Handles:
|
||||
* - Regular rules: `.foo { }` → `[data-composition-id="x"] .foo { }`
|
||||
* - @media and other at-rules: preserved, inner selectors are scoped
|
||||
* - @import, @font-face, @keyframes: left unscoped (global by nature)
|
||||
*/
|
||||
function scopeCssToComposition(css: string, compositionId: string): string {
|
||||
const scope = `[data-composition-id="${compositionId}"]`;
|
||||
const globalAtRules = new Set(["keyframes", "-webkit-keyframes", "font-face"]);
|
||||
const root = postcss.parse(css);
|
||||
|
||||
root.walkRules((rule) => {
|
||||
// Skip rules nested inside @keyframes or @font-face — they're global
|
||||
let node: postcss.Node | undefined = rule.parent;
|
||||
while (node) {
|
||||
if (
|
||||
node.type === "atrule" &&
|
||||
globalAtRules.has((node as postcss.AtRule).name.toLowerCase())
|
||||
) {
|
||||
return;
|
||||
}
|
||||
node = (node as postcss.ChildNode).parent;
|
||||
}
|
||||
|
||||
rule.selectors = rule.selectors.map((sel) => {
|
||||
if (!sel.trim()) return sel;
|
||||
if (/^(html|body|:root|\*)$/i.test(sel.trim())) return sel;
|
||||
if (sel.includes(`data-composition-id="${compositionId}"`)) return sel;
|
||||
return `${scope} ${sel}`;
|
||||
});
|
||||
});
|
||||
|
||||
return root.toResult().css;
|
||||
}
|
||||
|
||||
function coalesceHeadStylesAndBodyScripts(html: string): string {
|
||||
const { document } = parseHTML(html);
|
||||
const head = document.querySelector("head");
|
||||
@@ -665,28 +626,15 @@ function inlineSubCompositions(
|
||||
const content = (scriptEl.textContent || "").trim();
|
||||
if (content) {
|
||||
const scriptMountCompId = compId || inferredCompId || "";
|
||||
const compIdLiteral = JSON.stringify(scriptMountCompId);
|
||||
collectedScripts.push(`(function(){
|
||||
var __compId = ${compIdLiteral};
|
||||
var __run = function() {
|
||||
try {
|
||||
${content}
|
||||
} catch (_err) {
|
||||
console.error("[Compiler] Composition script failed", __compId, _err);
|
||||
}
|
||||
};
|
||||
if (!__compId) { __run(); return; }
|
||||
${COMPILER_MOUNT_BLOCK_START}
|
||||
var __selector = '[data-composition-id="' + (__compId + '').replace(/"/g, '\\\\"') + '"]';
|
||||
var __attempt = 0;
|
||||
var __tryRun = function() {
|
||||
if (document.querySelector(__selector)) { __run(); return; }
|
||||
if (++__attempt >= 8) { __run(); return; }
|
||||
requestAnimationFrame(__tryRun);
|
||||
};
|
||||
__tryRun();
|
||||
${COMPILER_MOUNT_BLOCK_END}
|
||||
})()`);
|
||||
collectedScripts.push(
|
||||
scriptMountCompId
|
||||
? wrapScopedCompositionScript(
|
||||
content,
|
||||
scriptMountCompId,
|
||||
"[Compiler] Composition script failed",
|
||||
)
|
||||
: `(function(){ try { ${content} } catch (_err) { console.error("[Compiler] Composition script failed", _err); } })()`,
|
||||
);
|
||||
}
|
||||
scriptEl.remove();
|
||||
}
|
||||
@@ -707,11 +655,7 @@ function inlineSubCompositions(
|
||||
if (innerW && !host.getAttribute("data-width")) host.setAttribute("data-width", innerW);
|
||||
if (innerH && !host.getAttribute("data-height")) host.setAttribute("data-height", innerH);
|
||||
innerRoot.querySelectorAll("style, script").forEach((el) => el.remove());
|
||||
if (!compId && inferredCompId) {
|
||||
host.innerHTML = innerRoot.outerHTML || "";
|
||||
} else {
|
||||
host.innerHTML = innerRoot.innerHTML || "";
|
||||
}
|
||||
host.innerHTML = innerRoot.outerHTML || "";
|
||||
} else {
|
||||
contentDoc.querySelectorAll("style, script").forEach((el) => el.remove());
|
||||
host.innerHTML = contentDoc.toString();
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useState, useCallback, useRef, useEffect, useMemo, type ReactNode } from "react";
|
||||
import { useMountEffect } from "./hooks/useMountEffect";
|
||||
import { NLELayout } from "./components/nle/NLELayout";
|
||||
import { TimelineEditorNotice } from "./components/nle/TimelineEditorNotice";
|
||||
import { SourceEditor } from "./components/editor/SourceEditor";
|
||||
import { LeftSidebar } from "./components/sidebar/LeftSidebar";
|
||||
import { RenderQueue } from "./components/renders/RenderQueue";
|
||||
@@ -38,9 +37,7 @@ import {
|
||||
getTimelineZoomPercent,
|
||||
} from "./player/components/timelineZoom";
|
||||
import {
|
||||
getTimelineEditorHintDismissed,
|
||||
getTimelineToggleTitle,
|
||||
setTimelineEditorHintDismissed,
|
||||
shouldHandleTimelineToggleHotkey,
|
||||
} from "./utils/timelineDiscovery";
|
||||
|
||||
@@ -267,9 +264,6 @@ export function StudioApp() {
|
||||
const [globalDragOver, setGlobalDragOver] = useState(false);
|
||||
const [appToast, setAppToast] = useState<AppToast | null>(null);
|
||||
const [timelineVisible, setTimelineVisible] = useState(true);
|
||||
const [timelineEditorHintDismissed, setTimelineEditorHintState] = useState(
|
||||
getTimelineEditorHintDismissed,
|
||||
);
|
||||
const dragCounterRef = useRef(0);
|
||||
const toastTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const lastBlockedTimelineToastAtRef = useRef(0);
|
||||
@@ -307,10 +301,6 @@ export function StudioApp() {
|
||||
useMountEffect(() => () => {
|
||||
if (toastTimerRef.current) clearTimeout(toastTimerRef.current);
|
||||
});
|
||||
const dismissTimelineEditorHint = useCallback(() => {
|
||||
setTimelineEditorHintState(true);
|
||||
setTimelineEditorHintDismissed(true);
|
||||
}, []);
|
||||
const handleTimelineToggleHotkey = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
if (!shouldHandleTimelineToggleHotkey(event)) return;
|
||||
@@ -1619,12 +1609,6 @@ export function StudioApp() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{timelineElements.length > 0 && !timelineEditorHintDismissed && (
|
||||
<div className="pointer-events-none absolute bottom-5 left-5 z-[140]">
|
||||
<TimelineEditorNotice onDismiss={dismissTimelineEditorHint} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Lint modal */}
|
||||
{lintModal !== null && projectId && (
|
||||
<LintModal findings={lintModal} projectId={projectId} onClose={() => setLintModal(null)} />
|
||||
|
||||
Reference in New Issue
Block a user