mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-04 07:19:52 +00:00
initial code (#2)
* feat: initial code port from hyperframes-internal Port all OSS-ready packages from the internal monorepo: - @hyperframes/core — shared types, HTML generation, GSAP utilities, runtime - @hyperframes/cli — CLI for creating, previewing, and rendering compositions - @hyperframes/engine — framework-agnostic rendering engine (BeginFrame + FFmpeg) - @hyperframes/producer — video rendering pipeline (Puppeteer + FFmpeg) - @hyperframes/ui-player — browser-based video player component - @hyperframes/studio — composition editor (React frontend + Hono backend) Includes regression test suite with Docker-based test harness. All HeyGen-internal references, deployment infrastructure, and proprietary assets have been removed. Package names migrated from @app/* to @hyperframes/*. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: scrub internal codenames and stale references from OSS port - Replace static.heygen.ai runtime URLs in test fixtures - Remove internal CDN publish script (publish-hyperframe-runtime.ts) - Replace sandbox-studio, sandbox-interceptor, __magicEditRuntime with neutral names (studio, hyperframe-runtime, __hyperframeRuntime) - Fix stale Vault API / localhost references in docs - Remove broken deprecated_studio link Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: remove remaining internal codenames and stale references - Delete stale producer README.md and PIPELINE.md (referenced nonexistent files) - Replace "Cerberus" codename with "HyperFrames" in test design reviews - Replace magic-edit postMessage identifiers with hf-preview/hf-parent - Rename debug-magic-edit-timeline.ts to debug-timeline.ts - Replace "Motion Cut" with "HyperFrames" in Timeline comments - Fix studio/CLI references to nonexistent archive package (use local data/projects/ dir, stub render proxy) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
10621e7903
commit
9f8e5ba5a1
@@ -0,0 +1,402 @@
|
||||
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 = "";
|
||||
|
||||
function stripEmbeddedRuntimeScripts(html: string): string {
|
||||
if (!html) return html;
|
||||
const scriptRe = /<script\b[^>]*>[\s\S]*?<\/script>/gi;
|
||||
const runtimeSrcMarkers = [
|
||||
"hyperframe.runtime.iife.js",
|
||||
"hyperframe-runtime.modular-runtime.inline.js",
|
||||
RUNTIME_BOOTSTRAP_ATTR,
|
||||
];
|
||||
const runtimeInlineMarkers = [
|
||||
"__hyperframeRuntimeBootstrapped",
|
||||
"__hyperframeRuntime",
|
||||
"__hyperframeRuntimeTeardown",
|
||||
"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 = `<script ${RUNTIME_BOOTSTRAP_ATTR}="1" src="${runtimeScriptUrl}"></script>`;
|
||||
if (sanitized.includes("</head>")) {
|
||||
return sanitized.replace("</head>", `${tag}\n</head>`);
|
||||
}
|
||||
const doctypeIdx = sanitized.toLowerCase().indexOf("<!doctype");
|
||||
if (doctypeIdx >= 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<string, { w: number; h: number }>();
|
||||
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<string>();
|
||||
$("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<string>();
|
||||
$("[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<string>();
|
||||
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(`<script>${stripped}</script>`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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<string> {
|
||||
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('<style data-hf-bundled-local-css="1"></style>'); 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(`<style>${localCssChunks.join("\n\n")}</style>`);
|
||||
}
|
||||
|
||||
// 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('<script data-hf-bundled-local-js="1"></script>'); 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(`<script>${localJsChunks.join("\n;\n")}</script>`);
|
||||
}
|
||||
|
||||
// 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(`<style>${compStyleChunks.join("\n\n")}</style>`);
|
||||
if (compScriptChunks.length) $("body").append(`<script>${compScriptChunks.join("\n;\n")}</script>`);
|
||||
|
||||
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();
|
||||
}
|
||||
@@ -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<number>;
|
||||
|
||||
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<string> {
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// Timing compiler (browser-safe)
|
||||
export {
|
||||
compileTimingAttrs,
|
||||
injectDurations,
|
||||
extractResolvedMedia,
|
||||
clampDurations,
|
||||
type UnresolvedElement,
|
||||
type ResolvedDuration,
|
||||
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";
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { compileTimingAttrs, injectDurations, extractResolvedMedia, clampDurations } from "./timingCompiler.js";
|
||||
|
||||
describe("compileTimingAttrs", () => {
|
||||
it("adds data-end when data-start and data-duration are present on a video", () => {
|
||||
const html = '<video id="v1" src="a.mp4" data-start="2" data-duration="5">';
|
||||
const { html: compiled, unresolved } = compileTimingAttrs(html);
|
||||
|
||||
expect(compiled).toContain('data-end="7"');
|
||||
expect(compiled).toContain('data-has-audio="true"');
|
||||
expect(unresolved).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("leaves data-end unchanged when already present", () => {
|
||||
const html = '<video id="v1" src="a.mp4" data-start="0" data-end="3">';
|
||||
const { html: compiled, unresolved } = compileTimingAttrs(html);
|
||||
|
||||
expect(compiled).toContain('data-end="3"');
|
||||
expect(compiled).not.toContain("data-duration");
|
||||
expect(unresolved).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("marks video as unresolved when data-duration and data-end are missing", () => {
|
||||
const html = '<video id="v1" src="a.mp4" data-start="1">';
|
||||
const { unresolved } = compileTimingAttrs(html);
|
||||
|
||||
expect(unresolved).toHaveLength(1);
|
||||
expect(unresolved[0].id).toBe("v1");
|
||||
expect(unresolved[0].tagName).toBe("video");
|
||||
expect(unresolved[0].start).toBe(1);
|
||||
});
|
||||
|
||||
it("compiles audio tags the same as video (minus data-has-audio)", () => {
|
||||
const html = '<audio id="a1" src="music.mp3" data-start="0" data-duration="10">';
|
||||
const { html: compiled } = compileTimingAttrs(html);
|
||||
|
||||
expect(compiled).toContain('data-end="10"');
|
||||
expect(compiled).not.toContain("data-has-audio");
|
||||
});
|
||||
|
||||
it("detects unresolved div/section elements with data-start but no data-end", () => {
|
||||
const html = '<div id="comp1" data-start="0" data-composition-src="comp.html">';
|
||||
const { unresolved } = compileTimingAttrs(html);
|
||||
|
||||
expect(unresolved).toHaveLength(1);
|
||||
expect(unresolved[0].id).toBe("comp1");
|
||||
expect(unresolved[0].tagName).toBe("div");
|
||||
expect(unresolved[0].compositionSrc).toBe("comp.html");
|
||||
});
|
||||
|
||||
it("does not report div as unresolved when data-end is present", () => {
|
||||
const html = '<div id="comp1" data-start="0" data-end="5">';
|
||||
const { unresolved } = compileTimingAttrs(html);
|
||||
|
||||
expect(unresolved).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("injectDurations", () => {
|
||||
it("adds data-duration and data-end for resolved elements", () => {
|
||||
const html = '<video id="v1" src="a.mp4" data-start="2">';
|
||||
const result = injectDurations(html, [{ id: "v1", duration: 4 }]);
|
||||
|
||||
expect(result).toContain('data-duration="4"');
|
||||
expect(result).toContain('data-end="6"');
|
||||
});
|
||||
|
||||
it("does not overwrite existing data-duration", () => {
|
||||
const html = '<video id="v1" src="a.mp4" data-start="0" data-duration="3">';
|
||||
const result = injectDurations(html, [{ id: "v1", duration: 10 }]);
|
||||
|
||||
// data-duration already present, should not be duplicated
|
||||
expect(result).toContain('data-duration="3"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractResolvedMedia", () => {
|
||||
it("extracts video and audio elements with data-duration set", () => {
|
||||
const html = [
|
||||
'<video id="v1" src="vid.mp4" data-start="1" data-duration="5" data-media-start="0">',
|
||||
'<audio id="a1" src="song.mp3" data-start="0" data-duration="10">',
|
||||
'<video id="v2" src="other.mp4" data-start="0">', // no duration
|
||||
].join("\n");
|
||||
|
||||
const resolved = extractResolvedMedia(html);
|
||||
|
||||
expect(resolved).toHaveLength(2);
|
||||
expect(resolved[0].id).toBe("v1");
|
||||
expect(resolved[0].tagName).toBe("video");
|
||||
expect(resolved[0].duration).toBe(5);
|
||||
expect(resolved[0].start).toBe(1);
|
||||
expect(resolved[1].id).toBe("a1");
|
||||
expect(resolved[1].tagName).toBe("audio");
|
||||
expect(resolved[1].duration).toBe(10);
|
||||
});
|
||||
|
||||
it("skips elements with invalid durations", () => {
|
||||
const html = '<video id="v1" src="a.mp4" data-start="0" data-duration="NaN">';
|
||||
const resolved = extractResolvedMedia(html);
|
||||
expect(resolved).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clampDurations", () => {
|
||||
it("replaces data-duration and recomputes data-end", () => {
|
||||
const html = '<video id="v1" src="a.mp4" data-start="2" data-duration="10" data-end="12">';
|
||||
const result = clampDurations(html, [{ id: "v1", duration: 5 }]);
|
||||
|
||||
expect(result).toContain('data-duration="5"');
|
||||
expect(result).toContain('data-end="7"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* Timing Compiler
|
||||
*
|
||||
* Shared, pure HTML compilation that normalizes timing attributes.
|
||||
* Works in both Node.js and browser (no dependencies, regex-based).
|
||||
*
|
||||
* Guarantees every timed element gets:
|
||||
* - data-end (computed from data-start + data-duration when possible)
|
||||
* - data-has-audio="true" on <video> elements
|
||||
*
|
||||
* For elements without data-duration (e.g. videos relying on source duration),
|
||||
* this compiler identifies them as "unresolved" so the caller can provide
|
||||
* durations via an environment-specific resolver (ffprobe, el.duration, etc.)
|
||||
* and call injectDurations() to complete the compilation.
|
||||
*/
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface UnresolvedElement {
|
||||
id: string;
|
||||
tagName: string;
|
||||
src?: string;
|
||||
start: number;
|
||||
end?: number;
|
||||
duration?: number;
|
||||
mediaStart: number;
|
||||
compositionSrc?: string;
|
||||
}
|
||||
|
||||
export interface ResolvedDuration {
|
||||
id: string;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
export interface ResolvedMediaElement {
|
||||
id: string;
|
||||
tagName: string;
|
||||
src?: string;
|
||||
start: number;
|
||||
duration: number;
|
||||
mediaStart: number;
|
||||
}
|
||||
|
||||
export interface CompilationResult {
|
||||
html: string;
|
||||
unresolved: UnresolvedElement[];
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
function getAttr(tag: string, attr: string): string | null {
|
||||
const match = tag.match(new RegExp(`${attr}=["']([^"']+)["']`));
|
||||
return match ? match[1] ?? null : null;
|
||||
}
|
||||
|
||||
function hasAttr(tag: string, attr: string): boolean {
|
||||
return new RegExp(`${attr}=["']`).test(tag);
|
||||
}
|
||||
|
||||
function injectAttr(tag: string, attr: string, value: string): string {
|
||||
return tag.replace(/>$/, ` ${attr}="${value}">`);
|
||||
}
|
||||
|
||||
// ── Core compilation ─────────────────────────────────────────────────────
|
||||
|
||||
function compileTag(tag: string, isVideo: boolean): { tag: string; unresolved: UnresolvedElement | null } {
|
||||
let result = tag;
|
||||
let unresolved: UnresolvedElement | null = null;
|
||||
|
||||
const id = getAttr(result, "id");
|
||||
const startStr = getAttr(result, "data-start");
|
||||
const start = startStr !== null ? parseFloat(startStr) : 0;
|
||||
const mediaStartStr = getAttr(result, "data-media-start");
|
||||
const mediaStart = mediaStartStr ? parseFloat(mediaStartStr) : 0;
|
||||
|
||||
// 1. Compute data-end from data-start + data-duration
|
||||
if (!hasAttr(result, "data-end")) {
|
||||
const durationStr = getAttr(result, "data-duration");
|
||||
if (durationStr !== null) {
|
||||
const end = start + parseFloat(durationStr);
|
||||
result = injectAttr(result, "data-end", String(end));
|
||||
} else if (id) {
|
||||
// No data-duration: mark as unresolved so caller can provide it
|
||||
unresolved = {
|
||||
id,
|
||||
tagName: isVideo ? "video" : "audio",
|
||||
src: getAttr(result, "src") ?? undefined,
|
||||
start,
|
||||
mediaStart,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Add data-has-audio="true" to <video> elements
|
||||
if (isVideo && !hasAttr(result, "data-has-audio")) {
|
||||
result = injectAttr(result, "data-has-audio", "true");
|
||||
}
|
||||
|
||||
return { tag: result, unresolved };
|
||||
}
|
||||
|
||||
/**
|
||||
* Compile timing attributes in HTML.
|
||||
*
|
||||
* Phase 1 (static): Adds data-end where data-duration exists,
|
||||
* adds data-has-audio on videos.
|
||||
*
|
||||
* Returns the compiled HTML and a list of elements that could not be
|
||||
* resolved statically (missing data-duration). The caller should resolve
|
||||
* these via ffprobe / el.duration and call injectDurations().
|
||||
*/
|
||||
export function compileTimingAttrs(html: string): CompilationResult {
|
||||
const unresolved: UnresolvedElement[] = [];
|
||||
|
||||
// Process <video ...> tags
|
||||
html = html.replace(/<video[^>]*>/gi, (match) => {
|
||||
const { tag, unresolved: u } = compileTag(match, true);
|
||||
if (u) unresolved.push(u);
|
||||
return tag;
|
||||
});
|
||||
|
||||
// Process <audio ...> tags
|
||||
html = html.replace(/<audio[^>]*>/gi, (match) => {
|
||||
const { tag, unresolved: u } = compileTag(match, false);
|
||||
if (u) unresolved.push(u);
|
||||
return tag;
|
||||
});
|
||||
|
||||
// Identify unresolved timed elements (divs with data-start but no data-end/data-duration)
|
||||
// These are typically compositions whose duration depends on GSAP timelines
|
||||
html.replace(/<(?:div|section)[^>]*>/gi, (match) => {
|
||||
if (!hasAttr(match, "data-start")) return match;
|
||||
if (hasAttr(match, "data-end") || hasAttr(match, "data-duration")) return match;
|
||||
|
||||
const id = getAttr(match, "id");
|
||||
const compositionSrc = getAttr(match, "data-composition-src");
|
||||
if (id) {
|
||||
const startStr = getAttr(match, "data-start");
|
||||
unresolved.push({
|
||||
id,
|
||||
tagName: "div",
|
||||
start: startStr ? parseFloat(startStr) : 0,
|
||||
mediaStart: 0,
|
||||
compositionSrc: compositionSrc ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return match;
|
||||
});
|
||||
|
||||
return { html, unresolved };
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject resolved durations into compiled HTML.
|
||||
*
|
||||
* For each resolved element, adds data-duration and data-end attributes.
|
||||
* Call this after resolving durations via ffprobe, el.duration, or
|
||||
* GSAP timeline queries.
|
||||
*/
|
||||
export function injectDurations(html: string, resolutions: ResolvedDuration[]): string {
|
||||
for (const { id, duration } of resolutions) {
|
||||
// Match the element's opening tag by id
|
||||
const idPattern = new RegExp(`(<[^>]*id=["']${escapeRegex(id)}["'][^>]*>)`, "gi");
|
||||
|
||||
html = html.replace(idPattern, (tag) => {
|
||||
let result = tag;
|
||||
|
||||
// Add data-duration if missing
|
||||
if (!hasAttr(result, "data-duration")) {
|
||||
result = injectAttr(result, "data-duration", String(duration));
|
||||
}
|
||||
|
||||
// Add data-end if missing
|
||||
if (!hasAttr(result, "data-end")) {
|
||||
const startStr = getAttr(result, "data-start");
|
||||
const start = startStr ? parseFloat(startStr) : 0;
|
||||
result = injectAttr(result, "data-end", String(start + duration));
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
function escapeRegex(str: string): string {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract video/audio elements that already have data-duration set.
|
||||
* Used by callers to validate declared durations against actual source durations.
|
||||
*/
|
||||
export function extractResolvedMedia(html: string): ResolvedMediaElement[] {
|
||||
const resolved: ResolvedMediaElement[] = [];
|
||||
|
||||
const mediaRegex = /<(?:video|audio)[^>]*>/gi;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = mediaRegex.exec(html)) !== null) {
|
||||
const tag = match[0];
|
||||
const id = getAttr(tag, "id");
|
||||
const durationStr = getAttr(tag, "data-duration");
|
||||
if (!id || durationStr === null) continue;
|
||||
|
||||
const duration = parseFloat(durationStr);
|
||||
if (!Number.isFinite(duration) || duration <= 0) continue;
|
||||
|
||||
const isVideo = /^<video/i.test(tag);
|
||||
const startStr = getAttr(tag, "data-start");
|
||||
const mediaStartStr = getAttr(tag, "data-media-start");
|
||||
|
||||
resolved.push({
|
||||
id,
|
||||
tagName: isVideo ? "video" : "audio",
|
||||
src: getAttr(tag, "src") ?? undefined,
|
||||
start: startStr !== null ? parseFloat(startStr) : 0,
|
||||
duration,
|
||||
mediaStart: mediaStartStr ? parseFloat(mediaStartStr) : 0,
|
||||
});
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clamp existing data-duration and data-end on media elements.
|
||||
* For each resolution, replaces the declared duration with the clamped value
|
||||
* and recomputes data-end accordingly.
|
||||
*/
|
||||
export function clampDurations(html: string, clamps: ResolvedDuration[]): string {
|
||||
for (const { id, duration } of clamps) {
|
||||
const idPattern = new RegExp(`(<[^>]*id=["']${escapeRegex(id)}["'][^>]*>)`, "gi");
|
||||
|
||||
html = html.replace(idPattern, (tag) => {
|
||||
// Replace data-duration value
|
||||
tag = tag.replace(/data-duration=["'][^"']*["']/, `data-duration="${duration}"`);
|
||||
|
||||
// Recompute data-end from data-start + clamped duration
|
||||
const startStr = getAttr(tag, "data-start");
|
||||
const start = startStr ? parseFloat(startStr) : 0;
|
||||
tag = tag.replace(/data-end=["'][^"']*["']/, `data-end="${start + duration}"`);
|
||||
|
||||
return tag;
|
||||
});
|
||||
}
|
||||
|
||||
return html;
|
||||
}
|
||||
Reference in New Issue
Block a user