diff --git a/packages/core/package.json b/packages/core/package.json
index 7144f1d9..fc432743 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -13,6 +13,10 @@
"import": "./src/lint/index.ts",
"types": "./src/lint/index.ts"
},
+ "./compiler": {
+ "import": "./src/compiler/index.ts",
+ "types": "./src/compiler/index.ts"
+ },
"./runtime": "./dist/hyperframe.runtime.iife.js"
},
"files": [
@@ -33,6 +37,10 @@
"import": "./dist/lint/index.js",
"types": "./dist/lint/index.d.ts"
},
+ "./compiler": {
+ "import": "./dist/compiler/index.js",
+ "types": "./dist/compiler/index.d.ts"
+ },
"./runtime": "./dist/hyperframe.runtime.iife.js"
}
},
@@ -59,11 +67,14 @@
"debug:timeline": "tsx scripts/debug-magic-edit-timeline.ts",
"prepublishOnly": "pnpm build"
},
+ "optionalDependencies": {
+ "cheerio": "^1.2.0",
+ "esbuild": "^0.25.12"
+ },
"devDependencies": {
"@types/jsdom": "^28.0.0",
"@types/node": "^24.10.13",
"@vitest/coverage-v8": "^3.2.4",
- "esbuild": "^0.25.12",
"jsdom": "^29.0.0",
"tsx": "^4.21.0",
"typescript": "^5.0.0",
diff --git a/packages/core/src/compiler/htmlBundler.ts b/packages/core/src/compiler/htmlBundler.ts
new file mode 100644
index 00000000..30b113d0
--- /dev/null
+++ b/packages/core/src/compiler/htmlBundler.ts
@@ -0,0 +1,403 @@
+import { readFileSync, existsSync } from "fs";
+import { join, resolve, isAbsolute, sep } from "path";
+import * as cheerio from "cheerio";
+import { transformSync } from "esbuild";
+import { compileHtml, type MediaDurationProber } from "./htmlCompiler";
+import { validateHyperframeHtmlContract } from "./staticGuard";
+
+/** Resolve a relative path within projectDir, rejecting traversal outside it. */
+function safePath(projectDir: string, relativePath: string): string | null {
+ const resolved = resolve(projectDir, relativePath);
+ const normalizedBase = resolve(projectDir) + sep;
+ if (!resolved.startsWith(normalizedBase) && resolved !== resolve(projectDir)) return null;
+ return resolved;
+}
+
+const RUNTIME_BOOTSTRAP_ATTR = "data-hyperframes-preview-runtime";
+const DEFAULT_RUNTIME_SCRIPT_URL =
+ "https://unpkg.com/@hyperframes/core/dist/hyperframe.runtime.iife.js";
+
+function stripEmbeddedRuntimeScripts(html: string): string {
+ if (!html) return html;
+ const scriptRe = /`;
+ if (sanitized.includes("")) {
+ return sanitized.replace("", `${tag}\n`);
+ }
+ const doctypeIdx = sanitized.toLowerCase().indexOf("= 0) {
+ const insertPos = sanitized.indexOf(">", doctypeIdx) + 1;
+ return sanitized.slice(0, insertPos) + tag + sanitized.slice(insertPos);
+ }
+ return tag + sanitized;
+}
+
+function isRelativeUrl(url: string): boolean {
+ if (!url) return false;
+ return !url.startsWith("http://") && !url.startsWith("https://") && !url.startsWith("//") && !url.startsWith("data:") && !isAbsolute(url);
+}
+
+function safeReadFile(filePath: string): string | null {
+ if (!existsSync(filePath)) return null;
+ try { return readFileSync(filePath, "utf-8"); } catch { return null; }
+}
+
+function safeReadFileBuffer(filePath: string): Buffer | null {
+ if (!existsSync(filePath)) return null;
+ try { return readFileSync(filePath); } catch { return null; }
+}
+
+function splitUrlSuffix(urlValue: string): { basePath: string; suffix: string } {
+ const queryIdx = urlValue.indexOf("?");
+ const hashIdx = urlValue.indexOf("#");
+ if (queryIdx < 0 && hashIdx < 0) return { basePath: urlValue, suffix: "" };
+ const cutIdx = queryIdx < 0 ? hashIdx : hashIdx < 0 ? queryIdx : Math.min(queryIdx, hashIdx);
+ return { basePath: urlValue.slice(0, cutIdx), suffix: urlValue.slice(cutIdx) };
+}
+
+function appendSuffixToUrl(baseUrl: string, suffix: string): string {
+ if (!suffix) return baseUrl;
+ if (suffix.startsWith("#")) return `${baseUrl}${suffix}`;
+ if (suffix.startsWith("?")) {
+ const queryWithOptionalHash = suffix.slice(1);
+ if (!queryWithOptionalHash) return baseUrl;
+ const hashIdx = queryWithOptionalHash.indexOf("#");
+ const queryPart = hashIdx >= 0 ? queryWithOptionalHash.slice(0, hashIdx) : queryWithOptionalHash;
+ const hashPart = hashIdx >= 0 ? queryWithOptionalHash.slice(hashIdx) : "";
+ if (!queryPart) return `${baseUrl}${hashPart}`;
+ const joiner = baseUrl.includes("?") ? "&" : "?";
+ return `${baseUrl}${joiner}${queryPart}${hashPart}`;
+ }
+ return baseUrl;
+}
+
+function guessMimeType(filePath: string): string {
+ const l = filePath.toLowerCase();
+ if (l.endsWith(".svg")) return "image/svg+xml";
+ if (l.endsWith(".json")) return "application/json";
+ if (l.endsWith(".txt")) return "text/plain";
+ if (l.endsWith(".xml")) return "application/xml";
+ return "application/octet-stream";
+}
+
+function shouldInlineAsDataUrl(filePath: string): boolean {
+ const l = filePath.toLowerCase();
+ return l.endsWith(".svg") || l.endsWith(".json") || l.endsWith(".txt") || l.endsWith(".xml");
+}
+
+function maybeInlineRelativeAssetUrl(urlValue: string, projectDir: string): string | null {
+ if (!urlValue || !isRelativeUrl(urlValue)) return null;
+ const { basePath, suffix } = splitUrlSuffix(urlValue.trim());
+ if (!basePath) return null;
+ const filePath = safePath(projectDir, basePath);
+ if (!filePath || !shouldInlineAsDataUrl(filePath)) return null;
+ const content = safeReadFileBuffer(filePath);
+ if (content == null) return null;
+ const mimeType = guessMimeType(filePath);
+ const dataUrl = `data:${mimeType};base64,${content.toString("base64")}`;
+ return appendSuffixToUrl(dataUrl, suffix);
+}
+
+function rewriteSrcsetWithInlinedAssets(srcsetValue: string, projectDir: string): string {
+ if (!srcsetValue) return srcsetValue;
+ return srcsetValue.split(",").map((rawCandidate) => {
+ const candidate = rawCandidate.trim();
+ if (!candidate) return candidate;
+ const parts = candidate.split(/\s+/);
+ if (parts.length === 0) return candidate;
+ const maybeInlined = maybeInlineRelativeAssetUrl(parts[0] ?? "", projectDir);
+ if (maybeInlined) parts[0] = maybeInlined;
+ return parts.join(" ");
+ }).join(", ");
+}
+
+function rewriteCssUrlsWithInlinedAssets(cssText: string, projectDir: string): string {
+ if (!cssText) return cssText;
+ return cssText.replace(
+ /\burl\(\s*(["']?)([^)"']+)\1\s*\)/g,
+ (_full, quote: string, rawUrl: string) => {
+ const maybeInlined = maybeInlineRelativeAssetUrl((rawUrl || "").trim(), projectDir);
+ if (!maybeInlined) return _full;
+ return `url(${quote || ""}${maybeInlined}${quote || ""})`;
+ },
+ );
+}
+
+function enforceCompositionPixelSizing($: cheerio.CheerioAPI): void {
+ const compositionEls = $("[data-composition-id][data-width][data-height]").toArray();
+ if (compositionEls.length === 0) return;
+ const sizeMap = new Map();
+ for (const el of compositionEls) {
+ const compId = $(el).attr("data-composition-id");
+ const w = Number($(el).attr("data-width"));
+ const h = Number($(el).attr("data-height"));
+ if (compId && Number.isFinite(w) && Number.isFinite(h) && w > 0 && h > 0) {
+ sizeMap.set(compId, { w, h });
+ }
+ }
+ if (sizeMap.size === 0) return;
+ $("style").each((_, styleEl) => {
+ let css = $(styleEl).html() || "";
+ let modified = false;
+ for (const [compId, { w, h }] of sizeMap) {
+ const escaped = compId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+ const blockRe = new RegExp(`(\\[data-composition-id=["']${escaped}["']\\]\\s*\\{)([^}]*)(})`, "g");
+ css = css.replace(blockRe, (_, open, body, close) => {
+ const newBody = body.replace(/(\bwidth\s*:\s*)100%/g, `$1${w}px`).replace(/(\bheight\s*:\s*)100%/g, `$1${h}px`);
+ if (newBody !== body) modified = true;
+ return open + newBody + close;
+ });
+ }
+ if (modified) $(styleEl).text(css);
+ });
+}
+
+function autoHealMissingCompositionIds($: cheerio.CheerioAPI): void {
+ const compositionIdRe = /data-composition-id=["']([^"']+)["']/gi;
+ const referencedIds = new Set();
+ $("style, script").each((_, el) => {
+ const text = ($(el).html() || "").trim();
+ if (!text) return;
+ let match: RegExpExecArray | null;
+ while ((match = compositionIdRe.exec(text)) !== null) {
+ const compId = (match[1] || "").trim();
+ if (compId) referencedIds.add(compId);
+ }
+ });
+ if (referencedIds.size === 0) return;
+
+ const existingIds = new Set();
+ $("[data-composition-id]").each((_, el) => {
+ const id = ($(el).attr("data-composition-id") || "").trim();
+ if (id) existingIds.add(id);
+ });
+
+ for (const compId of referencedIds) {
+ if (compId === "root" || existingIds.has(compId)) continue;
+ const candidates = [`${compId}-layer`, `${compId}-comp`, compId];
+ for (const targetId of candidates) {
+ const match = $(`#${targetId}`).first();
+ if (match.length > 0 && !match.attr("data-composition-id")) {
+ match.attr("data-composition-id", compId);
+ break;
+ }
+ }
+ }
+}
+
+function coalesceHeadStylesAndBodyScripts($: cheerio.CheerioAPI): void {
+ const headStyleEls = $("head style").toArray();
+ if (headStyleEls.length > 1) {
+ const importRe = /@import\s+url\([^)]*\)\s*;|@import\s+["'][^"']+["']\s*;/gi;
+ const imports: string[] = [];
+ const cssParts: string[] = [];
+ const seenImports = new Set();
+ for (const el of headStyleEls) {
+ const raw = ($(el).html() || "").trim();
+ if (!raw) continue;
+ const nonImportCss = raw.replace(importRe, (match) => {
+ const cleaned = match.trim();
+ if (!seenImports.has(cleaned)) { seenImports.add(cleaned); imports.push(cleaned); }
+ return "";
+ });
+ const trimmed = nonImportCss.trim();
+ if (trimmed) cssParts.push(trimmed);
+ }
+ const merged = [...imports, ...cssParts].join("\n\n").trim();
+ if (merged) {
+ $(headStyleEls[0]).text(merged);
+ for (let i = 1; i < headStyleEls.length; i++) $(headStyleEls[i]).remove();
+ }
+ }
+
+ const bodyInlineScripts = $("body script").toArray().filter((el) => {
+ const src = ($(el).attr("src") || "").trim();
+ if (src) return false;
+ const type = ($(el).attr("type") || "").trim().toLowerCase();
+ return !type || type === "text/javascript" || type === "application/javascript";
+ });
+ if (bodyInlineScripts.length > 0) {
+ const mergedJs = bodyInlineScripts.map((el) => ($(el).html() || "").trim()).filter(Boolean).join("\n;\n").trim();
+ for (const el of bodyInlineScripts) $(el).remove();
+ if (mergedJs) {
+ const stripped = stripJsCommentsParserSafe(mergedJs);
+ $("body").append(``);
+ }
+ }
+}
+
+function stripJsCommentsParserSafe(source: string): string {
+ if (!source) return source;
+ try {
+ const result = transformSync(source, { loader: "js", minify: false, legalComments: "none" });
+ return result.code.trim();
+ } catch { return source; }
+}
+
+export interface BundleOptions {
+ /** Optional media duration prober (e.g., ffprobe). If omitted, media durations are not resolved. */
+ probeMediaDuration?: MediaDurationProber;
+}
+
+/**
+ * Bundle a project's index.html into a single self-contained HTML file.
+ *
+ * - Compiles timing attributes and optionally resolves media durations
+ * - Injects the HyperFrames runtime script
+ * - Inlines local CSS and JS files
+ * - Inlines sub-composition HTML fragments (data-composition-src)
+ * - Inlines small textual assets as data URLs
+ */
+export async function bundleToSingleHtml(projectDir: string, options?: BundleOptions): Promise {
+ const indexPath = join(projectDir, "index.html");
+ if (!existsSync(indexPath)) throw new Error("index.html not found in project directory");
+
+ const rawHtml = readFileSync(indexPath, "utf-8");
+ const compiled = await compileHtml(rawHtml, projectDir, options?.probeMediaDuration);
+
+ const staticGuard = validateHyperframeHtmlContract(compiled);
+ if (!staticGuard.isValid) {
+ console.warn(`[StaticGuard] Invalid HyperFrame contract: ${staticGuard.missingKeys.join("; ")}`);
+ }
+
+ const withInterceptor = injectInterceptor(compiled);
+ const $ = cheerio.load(withInterceptor);
+
+ // Inline local CSS
+ const localCssChunks: string[] = [];
+ let cssAnchorPlaced = false;
+ $('link[rel="stylesheet"]').each((_, el) => {
+ const href = $(el).attr("href");
+ if (!href || !isRelativeUrl(href)) return;
+ const cssPath = safePath(projectDir, href);
+ const css = cssPath ? safeReadFile(cssPath) : null;
+ if (css == null) return;
+ localCssChunks.push(css);
+ if (!cssAnchorPlaced) { $(el).replaceWith(''); cssAnchorPlaced = true; } else { $(el).remove(); }
+ });
+ if (localCssChunks.length > 0) {
+ const $anchor = $('style[data-hf-bundled-local-css="1"]').first();
+ if ($anchor.length) $anchor.removeAttr("data-hf-bundled-local-css").text(localCssChunks.join("\n\n"));
+ else $("head").append(``);
+ }
+
+ // Inline local JS
+ const localJsChunks: string[] = [];
+ let jsAnchorPlaced = false;
+ $("script[src]").each((_, el) => {
+ const src = $(el).attr("src");
+ if (!src || !isRelativeUrl(src)) return;
+ const jsPath = safePath(projectDir, src);
+ const js = jsPath ? safeReadFile(jsPath) : null;
+ if (js == null) return;
+ localJsChunks.push(js);
+ if (!jsAnchorPlaced) { $(el).replaceWith(''); jsAnchorPlaced = true; } else { $(el).remove(); }
+ });
+ if (localJsChunks.length > 0) {
+ const $anchor = $('script[data-hf-bundled-local-js="1"]').first();
+ if ($anchor.length) $anchor.removeAttr("data-hf-bundled-local-js").text(localJsChunks.join("\n;\n"));
+ else $("body").append(``);
+ }
+
+ // Inline sub-compositions
+ const compStyleChunks: string[] = [];
+ const compScriptChunks: string[] = [];
+ $("[data-composition-src]").each((_, hostEl) => {
+ const src = $(hostEl).attr("data-composition-src");
+ if (!src || !isRelativeUrl(src)) return;
+ const compPath = safePath(projectDir, src);
+ const compHtml = compPath ? safeReadFile(compPath) : null;
+ if (compHtml == null) { console.warn(`[Bundler] Composition file not found: ${src}`); return; }
+
+ const $comp = cheerio.load(compHtml);
+ const compId = $(hostEl).attr("data-composition-id");
+ const $contentRoot = $comp("template").first();
+ const contentHtml = $contentRoot.length ? $contentRoot.html() || "" : $comp("body").html() || "";
+ const $content = cheerio.load(contentHtml);
+ const $innerRoot = compId ? $content(`[data-composition-id="${compId}"]`).first() : $content("[data-composition-id]").first();
+
+ $content("style").each((_, s) => { compStyleChunks.push($content(s).html() || ""); $content(s).remove(); });
+ $content("script").each((_, s) => {
+ compScriptChunks.push(`(function(){ try { ${$content(s).html() || ""} } catch (_err) { console.error('[HyperFrames] composition script error:', _err); } })();`);
+ $content(s).remove();
+ });
+
+ if ($innerRoot.length) {
+ const innerCompId = $innerRoot.attr("data-composition-id");
+ const innerW = $innerRoot.attr("data-width");
+ const innerH = $innerRoot.attr("data-height");
+ if (innerCompId && !$(hostEl).attr("data-composition-id")) $(hostEl).attr("data-composition-id", innerCompId);
+ if (innerW && !$(hostEl).attr("data-width")) $(hostEl).attr("data-width", innerW);
+ if (innerH && !$(hostEl).attr("data-height")) $(hostEl).attr("data-height", innerH);
+ $innerRoot.find("style, script").remove();
+ $(hostEl).html($innerRoot.html() || "");
+ } else {
+ $content("style, script").remove();
+ $(hostEl).html($content.html() || "");
+ }
+ $(hostEl).removeAttr("data-composition-src");
+ });
+
+ if (compStyleChunks.length) $("head").append(``);
+ if (compScriptChunks.length) $("body").append(``);
+
+ enforceCompositionPixelSizing($);
+ autoHealMissingCompositionIds($);
+ coalesceHeadStylesAndBodyScripts($);
+
+ // Inline textual assets
+ $("[src], [href], [poster], [xlink\\:href]").each((_, el) => {
+ for (const attr of ["src", "href", "poster", "xlink:href"] as const) {
+ const value = $(el).attr(attr);
+ if (!value) continue;
+ const inlined = maybeInlineRelativeAssetUrl(value, projectDir);
+ if (inlined) $(el).attr(attr, inlined);
+ }
+ });
+ $("[srcset]").each((_, el) => {
+ const srcset = $(el).attr("srcset");
+ if (srcset) $(el).attr("srcset", rewriteSrcsetWithInlinedAssets(srcset, projectDir));
+ });
+ $("style").each((_, el) => { $(el).text(rewriteCssUrlsWithInlinedAssets($(el).html() || "", projectDir)); });
+ $("[style]").each((_, el) => { $(el).attr("style", rewriteCssUrlsWithInlinedAssets($(el).attr("style") || "", projectDir)); });
+
+ return $.html();
+}
diff --git a/packages/core/src/compiler/htmlCompiler.ts b/packages/core/src/compiler/htmlCompiler.ts
new file mode 100644
index 00000000..4efc4465
--- /dev/null
+++ b/packages/core/src/compiler/htmlCompiler.ts
@@ -0,0 +1,90 @@
+import { resolve } from "path";
+import {
+ compileTimingAttrs,
+ injectDurations,
+ extractResolvedMedia,
+ clampDurations,
+ type ResolvedDuration,
+} from "./timingCompiler";
+
+/**
+ * Callback to probe media duration. If not provided, media duration resolution is skipped.
+ * Return duration in seconds, or 0 if unknown.
+ */
+export type MediaDurationProber = (src: string) => Promise;
+
+function resolveMediaSrc(src: string, projectDir: string): string {
+ return src.startsWith("http://") || src.startsWith("https://")
+ ? src
+ : resolve(projectDir, src);
+}
+
+/**
+ * Compile HTML with full duration resolution.
+ *
+ * 1. Static pass: compileTimingAttrs() adds data-end where data-duration exists
+ * 2. For unresolved video/audio (no data-duration): probe via probeMediaDuration, inject durations
+ * 3. For pre-resolved video/audio: validate data-duration against actual source, clamp if needed
+ *
+ * @param rawHtml - The raw HTML string
+ * @param projectDir - The project directory for resolving relative paths
+ * @param probeMediaDuration - Optional callback to probe media duration (e.g., via ffprobe)
+ */
+export async function compileHtml(
+ rawHtml: string,
+ projectDir: string,
+ probeMediaDuration?: MediaDurationProber,
+): Promise {
+ const { html: staticCompiled, unresolved } = compileTimingAttrs(rawHtml);
+ let html = staticCompiled;
+
+ if (!probeMediaDuration) return html;
+
+ // Phase 1: Resolve missing durations
+ const mediaUnresolved = unresolved.filter(
+ (el) => el.tagName === "video" || el.tagName === "audio",
+ );
+
+ if (mediaUnresolved.length > 0) {
+ const resolutions: ResolvedDuration[] = [];
+
+ for (const el of mediaUnresolved) {
+ if (!el.src) continue;
+ const src = resolveMediaSrc(el.src, projectDir);
+ const fileDuration = await probeMediaDuration(src);
+ if (fileDuration <= 0) continue;
+
+ const effectiveDuration = fileDuration - el.mediaStart;
+ resolutions.push({
+ id: el.id,
+ duration: effectiveDuration > 0 ? effectiveDuration : fileDuration,
+ });
+ }
+
+ if (resolutions.length > 0) {
+ html = injectDurations(html, resolutions);
+ }
+ }
+
+ // Phase 2: Validate pre-resolved media — clamp data-duration to actual source duration
+ const preResolved = extractResolvedMedia(html);
+ const clampList: ResolvedDuration[] = [];
+
+ for (const el of preResolved) {
+ if (!el.src) continue;
+ const src = resolveMediaSrc(el.src, projectDir);
+ const fileDuration = await probeMediaDuration(src);
+ if (fileDuration <= 0) continue;
+
+ const maxDuration = fileDuration - el.mediaStart;
+ if (maxDuration > 0 && el.duration > maxDuration) {
+ clampList.push({ id: el.id, duration: maxDuration });
+ }
+ }
+
+ if (clampList.length > 0) {
+ html = clampDurations(html, clampList);
+ }
+
+ return html;
+}
diff --git a/packages/core/src/compiler/index.ts b/packages/core/src/compiler/index.ts
index a6a8f575..feed7c1b 100644
--- a/packages/core/src/compiler/index.ts
+++ b/packages/core/src/compiler/index.ts
@@ -1,3 +1,4 @@
+// Timing compiler (browser-safe)
export {
compileTimingAttrs,
injectDurations,
@@ -8,3 +9,16 @@ export {
type ResolvedMediaElement,
type CompilationResult,
} from "./timingCompiler";
+
+// HTML compiler (Node.js — requires fs)
+export { compileHtml, type MediaDurationProber } from "./htmlCompiler";
+
+// HTML bundler (Node.js — requires fs, cheerio, esbuild)
+export { bundleToSingleHtml, type BundleOptions } from "./htmlBundler";
+
+// Static guard
+export {
+ validateHyperframeHtmlContract,
+ type HyperframeStaticFailureReason,
+ type HyperframeStaticGuardResult,
+} from "./staticGuard";
diff --git a/packages/core/src/compiler/staticGuard.ts b/packages/core/src/compiler/staticGuard.ts
new file mode 100644
index 00000000..372d538b
--- /dev/null
+++ b/packages/core/src/compiler/staticGuard.ts
@@ -0,0 +1,39 @@
+import { lintHyperframeHtml } from "../lint/hyperframeLinter";
+
+export type HyperframeStaticFailureReason =
+ | "missing_composition_id"
+ | "missing_composition_dimensions"
+ | "missing_timeline_registry"
+ | "invalid_script_syntax"
+ | "invalid_static_hyperframe_contract";
+
+export type HyperframeStaticGuardResult = {
+ isValid: boolean;
+ missingKeys: string[];
+ failureReason: HyperframeStaticFailureReason | null;
+};
+
+export function validateHyperframeHtmlContract(html: string): HyperframeStaticGuardResult {
+ const result = lintHyperframeHtml(html);
+ const missingKeys = result.findings
+ .filter((finding) => finding.severity === "error")
+ .map((finding) => finding.message);
+
+ if (missingKeys.length === 0) {
+ return { isValid: true, missingKeys: [], failureReason: null };
+ }
+
+ const joined = missingKeys.join(" ").toLowerCase();
+ let failureReason: HyperframeStaticFailureReason = "invalid_static_hyperframe_contract";
+ if (joined.includes("data-composition-id")) {
+ failureReason = "missing_composition_id";
+ } else if (joined.includes("data-width") || joined.includes("data-height")) {
+ failureReason = "missing_composition_dimensions";
+ } else if (joined.includes("window.__timelines")) {
+ failureReason = "missing_timeline_registry";
+ } else if (joined.includes("script syntax")) {
+ failureReason = "invalid_script_syntax";
+ }
+
+ return { isValid: false, missingKeys, failureReason };
+}
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index a90ecead..76172e79 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -97,10 +97,10 @@ export {
generateHyperframesStyles,
} from "./generators/hyperframes";
-// Compiler
-export type { UnresolvedElement, ResolvedDuration, ResolvedMediaElement, CompilationResult } from "./compiler";
+// Compiler (timing only — browser-safe, no cheerio/esbuild)
+export type { UnresolvedElement, ResolvedDuration, ResolvedMediaElement, CompilationResult } from "./compiler/timingCompiler";
-export { compileTimingAttrs, injectDurations, extractResolvedMedia, clampDurations } from "./compiler";
+export { compileTimingAttrs, injectDurations, extractResolvedMedia, clampDurations } from "./compiler/timingCompiler";
// Lint
export type {
diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts
index 6d569478..71f18f19 100644
--- a/packages/core/src/runtime/init.ts
+++ b/packages/core/src/runtime/init.ts
@@ -483,7 +483,8 @@ export function initSandboxRuntimeModular(): void {
const alreadyIncluded = existingChildren.some((child) => child === candidate.timeline);
if (alreadyIncluded) continue;
try {
- rootTimeline.add(candidate.timeline, resolveCompositionStartSeconds(candidate.compositionId));
+ const startSec = resolveCompositionStartSeconds(candidate.compositionId);
+ rootTimeline.add(candidate.timeline, startSec);
addedIds.push(candidate.compositionId);
} catch {
// ignore broken child add attempts
@@ -552,6 +553,19 @@ export function initSandboxRuntimeModular(): void {
rootChildCandidates.length > 0
? addMissingChildCandidatesToRootTimeline(rootTimeline, rootChildCandidates)
: [];
+ // Mark children as bound so the polling loop stops re-resolving
+ if (rootChildCandidates.length > 0 || !document.querySelector("[data-composition-id]:not([data-composition-id='" + rootCompositionId + "'])")) {
+ childrenBound = true;
+ }
+
+ // Force GSAP to render the current frame so child animations show their correct state.
+ // Without this, children added after the root was created may still show initial styles.
+ if (autoNestedChildren.length > 0) {
+ try {
+ const currentTime = rootTimeline.time();
+ rootTimeline.seek(currentTime, false); // false = don't suppress events
+ } catch { /* ignore */ }
+ }
const rootDurationSeconds = getTimelineDurationSeconds(rootTimeline);
if (!isUsableTimelineDuration(rootDurationSeconds) && rootChildCandidates.length > 0) {
const selectedTimelineIds = rootChildCandidates.map((candidate) => candidate.compositionId);
@@ -677,12 +691,22 @@ export function initSandboxRuntimeModular(): void {
state.currentTime = Math.max(0, nextTime);
};
+ // Track whether child composition timelines have been added to the root.
+ // This prevents the polling loop from skipping rebind when TARGET_DURATION
+ // makes the root "usable" before children register. Assumption: child scripts
+ // must register timelines synchronously or in the immediate microtask queue
+ // (setTimeout(0)). Scripts using requestAnimationFrame or longer delays may
+ // not be discovered.
+ let childrenBound = false;
const bindRootTimelineIfAvailable = (): boolean => {
if (!externalCompositionsReady) return false;
const currentTimeline = state.capturedTimeline;
const currentDuration = getTimelineDurationSeconds(currentTimeline);
const currentTimelineUsable = isUsableTimelineDuration(currentDuration);
- if (currentTimeline && currentTimelineUsable) return false;
+ // Skip rebind ONLY if we already have a usable timeline AND children have been bound.
+ // Without childrenBound check, the TARGET_DURATION spacer makes the timeline "usable"
+ // before child composition timelines are added, causing them to never be discovered.
+ if (currentTimeline && currentTimelineUsable && childrenBound) return false;
const resolution = resolveRootTimelineFromDocument();
if (!resolution.timeline) return false;
if (currentTimeline && currentTimeline === resolution.timeline) {
@@ -981,15 +1005,40 @@ export function initSandboxRuntimeModular(): void {
playing: state.isPlaying,
playbackRate: state.playbackRate,
});
+ const rootCompId = document.querySelector("[data-composition-id]")?.getAttribute("data-composition-id") ?? null;
const visibilityNodes = Array.from(document.querySelectorAll("[data-start]"));
for (const rawNode of visibilityNodes) {
if (!(rawNode instanceof HTMLElement)) continue;
const tag = rawNode.tagName.toLowerCase();
if (tag === "script" || tag === "style" || tag === "link" || tag === "meta") continue;
+
+ // Skip elements INSIDE sub-compositions — their visibility is managed by GSAP,
+ // not the global time-based adapter. Only manage visibility for:
+ // 1. Composition host elements (have data-composition-id themselves)
+ // 2. Direct children of root composition (audio, etc.)
+ // Skip: elements whose nearest composition ancestor is NOT the root
+ const ownCompId = rawNode.getAttribute("data-composition-id");
+ if (!ownCompId) {
+ // Not a composition host — check if it's inside a sub-composition
+ const parentComp = rawNode.closest("[data-composition-id]");
+ const parentCompId = parentComp?.getAttribute("data-composition-id") ?? null;
+ if (parentCompId && parentCompId !== rootCompId) continue;
+ }
+
const start = resolveStartForElement(rawNode, 0);
const duration = resolveDurationForElement(rawNode);
const end = duration != null && duration > 0 ? start + duration : Number.POSITIVE_INFINITY;
- const isVisibleNow = state.currentTime >= start && (Number.isFinite(end) ? state.currentTime < end : true);
+ // For composition hosts, use the composition timeline's duration to compute end
+ let computedEnd = end;
+ const compId = rawNode.getAttribute("data-composition-id");
+ if (compId && !Number.isFinite(end)) {
+ const compTimeline = (window.__timelines ?? {})[compId];
+ if (compTimeline && typeof compTimeline.duration === "function") {
+ const compDur = compTimeline.duration();
+ if (compDur > 0) computedEnd = start + compDur;
+ }
+ }
+ const isVisibleNow = state.currentTime >= start && (Number.isFinite(computedEnd) ? state.currentTime < computedEnd : true);
rawNode.style.visibility = isVisibleNow ? "visible" : "hidden";
}
};
@@ -1183,6 +1232,23 @@ export function initSandboxRuntimeModular(): void {
player._timeline = state.capturedTimeline;
}
+ // When the bundler inlines compositions, data-composition-src is removed so
+ // loadExternalCompositions() is skipped. But inline scripts registering child
+ // timelines in __timelines haven't executed yet (they run in the browser's next
+ // microtask). Defer a rebinding attempt to catch them.
+ if (externalCompositionsReady) {
+ setTimeout(() => {
+ const prevTimeline = state.capturedTimeline;
+ if (bindRootTimelineIfAvailable() && state.capturedTimeline !== prevTimeline) {
+ player._timeline = state.capturedTimeline;
+ }
+ // Re-run adapters to discover new elements
+ runAdapters("discover", state.currentTime);
+ postTimeline();
+ postState(true);
+ }, 0);
+ }
+
state.deterministicAdapters = [
createCssAdapter({
resolveStartSeconds: (element) => resolveStartForElement(element, 0),
diff --git a/packages/core/src/runtime/timeline.ts b/packages/core/src/runtime/timeline.ts
index 4a0adae7..5fce145a 100644
--- a/packages/core/src/runtime/timeline.ts
+++ b/packages/core/src/runtime/timeline.ts
@@ -223,6 +223,7 @@ export function collectRuntimeTimelinePayload(params: {
node.getAttribute("data-label") ??
node.getAttribute("aria-label") ??
(node as HTMLElement).id ??
+ (node as HTMLElement).className?.split(" ")[0] ??
kind,
start,
duration,