fix: isolate studio sub-composition previews

This commit is contained in:
Miguel Ángel
2026-04-27 15:51:53 -04:00
parent 2935be6bf6
commit c32db3ea0a
16 changed files with 1015 additions and 158 deletions
@@ -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>
+40 -11
View File
@@ -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();
+3
View File
@@ -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>
+115 -32
View File
@@ -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");
+29 -1
View File
@@ -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}})();`;
}
+69 -2
View File
@@ -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");
+74 -29
View File
@@ -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");
});
});
+11 -67
View File
@@ -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();
-16
View File
@@ -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)} />