From 7aec6ea325ea211dea2c2ac66b5a3696ace849f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sat, 21 Mar 2026 02:29:04 -0400 Subject: [PATCH] feat(core): add compiler entry point and runtime composition fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add @hyperframes/core/compiler — shared HTML bundler for studio and CLI. - bundleToSingleHtml(): runtime injection + sub-composition inlining - compileHtml(): timing compilation with optional ffprobe - validateHyperframeHtmlContract(): static guard - Path traversal protection via safePath() on all resolve calls Runtime fixes for composition rendering: - childrenBound flag prevents premature timeline skip - Visibility adapter skips sub-composition elements - Deferred rebinding for inlined compositions - className fallback for timeline clip labels --- packages/core/package.json | 13 +- packages/core/src/compiler/htmlBundler.ts | 403 +++++++++++++++++++++ packages/core/src/compiler/htmlCompiler.ts | 90 +++++ packages/core/src/compiler/index.ts | 14 + packages/core/src/compiler/staticGuard.ts | 39 ++ packages/core/src/index.ts | 6 +- packages/core/src/runtime/init.ts | 72 +++- packages/core/src/runtime/timeline.ts | 1 + 8 files changed, 631 insertions(+), 7 deletions(-) create mode 100644 packages/core/src/compiler/htmlBundler.ts create mode 100644 packages/core/src/compiler/htmlCompiler.ts create mode 100644 packages/core/src/compiler/staticGuard.ts 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 = /]*>[\s\S]*?<\/script>/gi; + const runtimeSrcMarkers = [ + "hyperframe.runtime.iife.js", + "sandbox-interceptor.modular-runtime.inline.js", + RUNTIME_BOOTSTRAP_ATTR, + ]; + const runtimeInlineMarkers = [ + "__hyperframeRuntimeBootstrapped", + "__magicEditRuntime", + "__magicEditRuntimeTeardown", + "window.__player =", + "window.__playerReady", + "window.__renderReady", + ]; + + const shouldStrip = (block: string): boolean => { + const lowered = block.toLowerCase(); + for (const marker of runtimeSrcMarkers) { + if (lowered.includes(marker.toLowerCase())) return true; + } + for (const marker of runtimeInlineMarkers) { + if (block.includes(marker)) return true; + } + return false; + }; + + return html.replace(scriptRe, (block) => (shouldStrip(block) ? "" : block)); +} + +function getRuntimeScriptUrl(): string { + const configured = (process.env.HYPERFRAME_RUNTIME_URL || "").trim(); + return configured || DEFAULT_RUNTIME_SCRIPT_URL; +} + +function injectInterceptor(html: string): string { + const sanitized = stripEmbeddedRuntimeScripts(html); + if (sanitized.includes(RUNTIME_BOOTSTRAP_ATTR)) return sanitized; + + const runtimeScriptUrl = getRuntimeScriptUrl().replace(/"/g, """); + const tag = ``; + 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,