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:
Vance Ingalls
2026-03-21 22:43:56 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 10621e7903
commit 9f8e5ba5a1
401 changed files with 54545 additions and 2 deletions
+402
View File
@@ -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, "&quot;");
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;
}
+24
View File
@@ -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";
+39
View File
@@ -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;
}