fix: composition issues in runtime code + producer parity

This commit is contained in:
Miguel Ángel
2026-03-23 23:56:42 -04:00
parent 94e25443ae
commit 062f6f1c10
9 changed files with 313 additions and 19 deletions
+2
View File
@@ -0,0 +1,2 @@
docs/
DOCS_GUIDELINES.md
+167 -3
View File
@@ -276,11 +276,11 @@ export function lintHyperframeHtml(
continue;
}
pushFinding({
code: "suspicious_global_gsap_selector",
code: "unscoped_gsap_selector",
severity: "warning",
message: `Timeline "${localTimelineCompId}" uses a global selector "${window.targetSelector}" that may escape composition scope.`,
message: `Timeline "${localTimelineCompId}" uses unscoped selector "${window.targetSelector}" that will target elements in ALL compositions when bundled, causing data loss (opacity, transforms, etc.).`,
selector: window.targetSelector,
fixHint: `Scope the selector like \`[data-composition-id="${localTimelineCompId}"] ${window.targetSelector}\` or use a unique id.`,
fixHint: `Scope the selector: \`[data-composition-id="${localTimelineCompId}"] ${window.targetSelector}\` or use a unique id.`,
snippet: truncateSnippet(window.raw),
});
}
@@ -346,6 +346,77 @@ export function lintHyperframeHtml(
}
}
// #3.5: Self-closing <audio .../> or <video .../> — CRITICAL
// In HTML5, <audio> and <video> are NOT void elements. The browser silently
// ignores the "/>", leaving the tag open. All subsequent sibling elements
// become invisible fallback content inside the media tag, making entire
// compositions disappear. This is the #1 cause of "black preview" bugs.
{
const selfClosingMediaRe = /<(audio|video)\b[^>]*\/>/gi;
let scMatch: RegExpExecArray | null;
while ((scMatch = selfClosingMediaRe.exec(source)) !== null) {
const tagName = scMatch[1] || "audio";
const elementId = readAttr(scMatch[0], "id") || undefined;
pushFinding({
code: "self_closing_media_tag",
severity: "error",
message: `Self-closing <${tagName}/> is invalid HTML. The browser will leave the tag open, swallowing all subsequent elements as invisible fallback content. This makes compositions INVISIBLE.`,
elementId,
fixHint: `Change <${tagName} .../> to <${tagName} ...></${tagName}> — media elements MUST have explicit closing tags.`,
snippet: truncateSnippet(scMatch[0]),
});
}
}
// #3.6: Placeholder/fake media URLs — CRITICAL
// Placeholder URLs (placehold.co, placeholder.com, example.com) will 404 at render time.
{
const PLACEHOLDER_DOMAINS =
/\b(placehold\.co|placeholder\.com|placekitten\.com|picsum\.photos|example\.com|via\.placeholder\.com|dummyimage\.com)\b/i;
for (const tag of tags) {
if (!isMediaTag(tag.name)) continue;
const src = readAttr(tag.raw, "src");
if (!src) continue;
if (PLACEHOLDER_DOMAINS.test(src)) {
const elementId = readAttr(tag.raw, "id") || undefined;
pushFinding({
code: "placeholder_media_url",
severity: "error",
message: `<${tag.name}${elementId ? ` id="${elementId}"` : ""}> uses a placeholder URL that will 404 at render time: ${src.slice(0, 80)}`,
elementId,
fixHint: "Replace with a real media URL. Placeholder domains will 404 at render time.",
snippet: truncateSnippet(tag.raw),
});
}
}
}
// #3.7: Fabricated inline base64 media — CRITICAL
// Inline base64 audio/video data is almost always fabricated garbage that
// won't play. Real audio files are 100KB+ when base64-encoded.
{
const base64MediaRe =
/src\s*=\s*["'](data:(?:audio|video)\/[^;]+;base64,([A-Za-z0-9+/=]{100,}))["']/gi;
let b64Match: RegExpExecArray | null;
while ((b64Match = base64MediaRe.exec(source)) !== null) {
// Check if it's suspiciously repetitive (fake data has long runs of repeated chars)
const sample = (b64Match[2] || "").slice(0, 200);
const uniqueChars = new Set(sample.replace(/[A-Za-z0-9+/=]/g, (c) => c)).size;
const dataSize = Math.round(((b64Match[2] || "").length * 3) / 4);
const isSuspicious = uniqueChars < 15 || (dataSize > 1000 && dataSize < 50000);
// Any embedded base64 audio is suspicious — real audio should be a file
pushFinding({
code: "fabricated_inline_media",
severity: isSuspicious ? "error" : "warning",
message: isSuspicious
? `Fabricated base64 media detected (${(dataSize / 1024).toFixed(0)} KB). This is almost certainly fake data that won't play.`
: `Embedded base64 audio/video detected (${(dataSize / 1024).toFixed(0)} KB). Consider using a file URL instead.`,
fixHint: "Remove the data: URI and use a real media file URL instead.",
snippet: truncateSnippet((b64Match[1] ?? "").slice(0, 80) + "..."),
});
}
}
// #4: Timed element missing visibility:hidden (no class="clip" or equivalent)
for (const tag of tags) {
if (tag.name === "audio" || tag.name === "script" || tag.name === "style") continue;
@@ -718,3 +789,96 @@ function truncateSnippet(value: string, maxLength = 220): string | undefined {
}
return `${normalized.slice(0, maxLength - 3)}...`;
}
// ── Async media URL accessibility checker ─────────────────────────────────
/**
* Extract all remote media URLs from HTML source.
*/
function extractMediaUrls(
html: string,
): Array<{ url: string; tagName: string; elementId?: string; snippet: string }> {
const results: Array<{ url: string; tagName: string; elementId?: string; snippet: string }> = [];
const tagRe = /<(video|audio|img|source)\b[^>]*>/gi;
let match: RegExpExecArray | null;
while ((match = tagRe.exec(html)) !== null) {
const tagName = (match[1] ?? "").toLowerCase();
const raw = match[0];
const src = readAttr(raw, "src");
if (!src) continue;
if (/^https?:\/\//i.test(src)) {
results.push({
url: src,
tagName,
elementId: readAttr(raw, "id") || undefined,
snippet: raw.length > 120 ? raw.slice(0, 117) + "..." : raw,
});
}
}
return results;
}
/**
* Async lint pass: HEAD-checks every remote media URL in the HTML.
* Returns findings for URLs that are unreachable (non-2xx status or network error).
*
* Call this after `lintHyperframeHtml()` and merge the findings.
*
* @param timeoutMs - per-request timeout (default 8000ms)
*/
export async function lintMediaUrls(
html: string,
options: { timeoutMs?: number } = {},
): Promise<HyperframeLintFinding[]> {
const urls = extractMediaUrls(html);
if (urls.length === 0) return [];
const timeout = options.timeoutMs ?? 8000;
const findings: HyperframeLintFinding[] = [];
// Dedupe by URL
const seen = new Set<string>();
const unique = urls.filter((u) => {
if (seen.has(u.url)) return false;
seen.add(u.url);
return true;
});
// Check all URLs in parallel
const checks = unique.map(async ({ url, tagName, elementId, snippet }) => {
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeout);
const resp = await fetch(url, {
method: "HEAD",
signal: controller.signal,
redirect: "follow",
});
clearTimeout(timer);
if (!resp.ok) {
findings.push({
code: "inaccessible_media_url",
severity: "error",
message: `<${tagName}${elementId ? ` id="${elementId}"` : ""}> references a URL that returned HTTP ${resp.status}: ${url.slice(0, 100)}`,
elementId,
fixHint: "This URL is not accessible. Replace with a valid, reachable media URL.",
snippet,
});
}
} catch (err) {
const reason = err instanceof Error ? err.name : "unknown";
findings.push({
code: "inaccessible_media_url",
severity: "error",
message: `<${tagName}${elementId ? ` id="${elementId}"` : ""}> references an unreachable URL (${reason}): ${url.slice(0, 100)}`,
elementId,
fixHint: "This URL is not accessible. Replace with a valid, reachable media URL.",
snippet,
});
}
});
await Promise.all(checks);
return findings;
}
+1 -1
View File
@@ -4,4 +4,4 @@ export type {
HyperframeLintResult,
HyperframeLinterOptions,
} from "./types";
export { lintHyperframeHtml } from "./hyperframeLinter";
export { lintHyperframeHtml, lintMediaUrls } from "./hyperframeLinter";
+9 -7
View File
@@ -184,21 +184,20 @@ export function initSandboxRuntimeModular(): void {
};
const resolveRootCompositionElement = (): HTMLElement | null => {
const mainComp = document.getElementById("main-comp");
if (mainComp instanceof HTMLElement && mainComp.hasAttribute("data-composition-id")) {
return mainComp;
}
// 1. Explicit root marker takes priority
const explicitRoot = document.querySelector('[data-composition-id][data-root="true"]');
if (explicitRoot instanceof HTMLElement) {
return explicitRoot;
}
// 3. Topmost composition element (not nested inside another)
const compositionNodes = Array.from(
document.querySelectorAll("[data-composition-id]"),
) as HTMLElement[];
if (compositionNodes.length === 0) return null;
return (
compositionNodes.find((node) => !node.parentElement?.closest("[data-composition-id]")) ??
compositionNodes[0]
compositionNodes[0] ??
null
);
};
@@ -215,15 +214,18 @@ export function initSandboxRuntimeModular(): void {
const sanitizeCompositionDurationAttributes = () => {
const rootEl = resolveRootCompositionElement();
const compositionNodes = Array.from(
document.querySelectorAll("[data-composition-id][data-duration]"),
const compositionNodes = Array.from(document.querySelectorAll("[data-composition-id]")).filter(
(n) => n.hasAttribute("data-duration") || n.hasAttribute("data-end"),
) as HTMLElement[];
for (const node of compositionNodes) {
// Preserve explicit root duration so timeline payload can distinguish
// authored finite duration from loop-inflated timeline duration.
if (rootEl && node === rootEl) continue;
// Non-root compositions derive duration from timeline.
// Strip both data-duration AND data-end so the visibility system
// falls back to the GSAP timeline duration (parity with preview).
node.removeAttribute("data-duration");
node.removeAttribute("data-end");
}
};
@@ -118,6 +118,24 @@ export function createRuntimeStartTimeResolver(params: {
try {
const expression = parseStartExpression(element.getAttribute("data-start"));
if (!expression) {
// If this element is a loaded composition inner root (has data-composition-id
// but no data-start), walk up to the host parent which carries the actual
// timing. This happens when the host uses a different data-composition-id
// than the loaded file — e.g. host="montage" but file has "scene-10".
// Check both data-composition-src (runtime) and data-composition-id (bundled,
// where data-composition-src is stripped after inlining).
if (element.hasAttribute("data-composition-id")) {
const parent = element.parentElement;
if (
parent &&
(parent.hasAttribute("data-composition-src") ||
parent.hasAttribute("data-composition-id"))
) {
const parentStart = resolveStartForElementInternal(parent, fallback);
startCache.set(element, parentStart);
return parentStart;
}
}
startCache.set(element, fallback);
return fallback;
}
+66 -2
View File
@@ -11,6 +11,60 @@ function parseNum(value: string | null | undefined): number | null {
return Number.isFinite(parsed) ? parsed : null;
}
/**
* When multiple content kinds share the same track number, split them
* onto separate tracks so the timeline UI shows distinct rows.
*
* Preferred kind order (top bottom): composition, video, image, element, audio.
* Tracks that contain only one kind are left untouched.
*/
const KIND_ORDER: Record<string, number> = {
composition: 0,
video: 1,
image: 2,
element: 3,
audio: 4,
};
function normalizeTrackAssignments(clips: RuntimeTimelineClip[]): void {
if (clips.length === 0) return;
// Group clips by their raw track number and detect which tracks have mixed kinds
const trackKinds = new Map<number, Set<string>>();
for (const clip of clips) {
const kinds = trackKinds.get(clip.track) ?? new Set();
kinds.add(clip.kind);
trackKinds.set(clip.track, kinds);
}
const hasMixedTracks = Array.from(trackKinds.values()).some((kinds) => kinds.size > 1);
if (!hasMixedTracks) return;
// Build new contiguous track numbers, splitting mixed tracks by kind
let nextTrack = 0;
const newTrackMap = new Map<string, number>(); // "origTrack:kind" → newTrack
const sortedTracks = [...trackKinds.keys()].sort((a, b) => a - b);
for (const track of sortedTracks) {
const kinds = trackKinds.get(track)!;
if (kinds.size === 1) {
newTrackMap.set(`${track}:${[...kinds][0]}`, nextTrack++);
} else {
// Split by kind in preferred order
const sorted = [...kinds].sort((a, b) => (KIND_ORDER[a] ?? 99) - (KIND_ORDER[b] ?? 99));
for (const kind of sorted) {
newTrackMap.set(`${track}:${kind}`, nextTrack++);
}
}
}
for (const clip of clips) {
const key = `${clip.track}:${clip.kind}`;
const newTrack = newTrackMap.get(key);
if (newTrack != null) clip.track = newTrack;
}
}
function toAbsoluteAssetUrl(rawValue: string | null | undefined): string | null {
const raw = String(rawValue ?? "").trim();
if (!raw) return null;
@@ -118,7 +172,10 @@ export function collectRuntimeTimelinePayload(params: {
inheritedStart = startResolver.resolveStartForElement(cursor, 0);
}
if (inheritedDuration == null) {
inheritedDuration = parseNum(cursor.getAttribute("data-duration")) ?? null;
inheritedDuration =
parseNum(cursor.getAttribute("data-duration")) ??
resolveTimelineDurationSeconds(compositionId) ??
null;
}
}
cursor = cursor.parentElement;
@@ -243,11 +300,12 @@ export function collectRuntimeTimelinePayload(params: {
? "image"
: "element";
clips.push({
id: (node as HTMLElement).id || `__node__index_${i}`,
id: (node as HTMLElement).id || nodeCompositionId || `__node__index_${i}`,
label:
node.getAttribute("data-timeline-label") ??
node.getAttribute("data-label") ??
node.getAttribute("aria-label") ??
nodeCompositionId ??
(node as HTMLElement).id ??
(node as HTMLElement).className?.split(" ")[0] ??
kind,
@@ -272,6 +330,12 @@ export function collectRuntimeTimelinePayload(params: {
timelinePriority: parseNum(node.getAttribute("data-timeline-priority")),
});
}
// ── Track normalization ────────────────────────────────────────────────
// When multiple content kinds (composition, audio, video, …) share the same
// data-track-index value, split them onto separate tracks so the timeline UI
// shows distinct rows for each kind.
normalizeTrackAssignments(clips);
for (const compositionNode of compositionNodes) {
if (compositionNode === root) continue;
const compositionId = compositionNode.getAttribute("data-composition-id");
+16
View File
@@ -57,6 +57,22 @@ await Promise.all([
}),
]);
// Copy core runtime artifacts so the producer can find them at dist/
import { copyFileSync, existsSync, readFileSync } from "fs";
const coreDistDir = resolve(scriptDir, "../core/dist");
try {
const manifestSrc = resolve(coreDistDir, "hyperframe.manifest.json");
if (existsSync(manifestSrc)) {
copyFileSync(manifestSrc, "dist/hyperframe.manifest.json");
const manifest = JSON.parse(readFileSync(manifestSrc, "utf8"));
const runtimeIife = manifest?.artifacts?.iife || "hyperframe.runtime.iife.js";
copyFileSync(resolve(coreDistDir, runtimeIife), `dist/${runtimeIife}`);
console.log(`[Build] Copied runtime: hyperframe.manifest.json, ${runtimeIife}`);
}
} catch (e) {
console.warn("[Build] Warning: Could not copy runtime artifacts:", e.message);
}
// Generate .d.ts declarations (esbuild doesn't emit them)
import { execSync } from "child_process";
execSync("tsc --emitDeclarationOnly --declaration --declarationMap", {
+32 -4
View File
@@ -61,7 +61,13 @@ async function resolveMediaDuration(
if (isHttpUrl(src)) {
if (!existsSync(downloadDir)) mkdirSync(downloadDir, { recursive: true });
filePath = await downloadToTemp(src, downloadDir);
try {
filePath = await downloadToTemp(src, downloadDir);
} catch {
// Download failed (e.g. 404 placeholder URL) — skip gracefully.
// The element will get duration 0 and be excluded from the render.
return { duration: 0, resolvedPath: src };
}
} else if (!filePath.startsWith("/")) {
filePath = join(baseDir, filePath);
}
@@ -574,9 +580,7 @@ function inlineSubCompositions(
const existing = host.getAttribute("style") || "";
const needsWidth = !existing.includes("width");
const needsHeight = !existing.includes("height");
const needsPosition = !existing.includes("position");
const additions = [
needsPosition ? "position:relative" : "",
needsWidth ? `width:${hostW}px` : "",
needsHeight ? `height:${hostH}px` : "",
]
@@ -609,6 +613,24 @@ function inlineSubCompositions(
* Returns everything the orchestrator needs: compiled HTML, all media elements,
* dimensions, and static duration.
*/
/**
* Ensure the HTML is a full document (has <html>, <head>, <body>).
* When index.html is a fragment (e.g. just a <div>), linkedom.parseHTML()
* returns a document with null head/body, causing inlineSubCompositions to
* silently discard all collected composition styles and scripts.
*/
function ensureFullDocument(html: string): string {
const trimmed = html.trim();
if (/^<!DOCTYPE\s+html/i.test(trimmed) || /^<html/i.test(trimmed)) {
return html;
}
return `<!DOCTYPE html>\n<html>\n<head>\n <meta charset="UTF-8">\n</head>\n<body>\n${html}\n</body>\n</html>`;
}
/**
* Compile an HTML composition project into a single self-contained HTML string
* with all media metadata resolved.
*/
export async function compileForRender(
projectDir: string,
htmlPath: string,
@@ -628,10 +650,16 @@ export async function compileForRender(
subCompositions,
} = await parseSubCompositions(compiledHtml, projectDir, downloadDir);
// Ensure the HTML is a full document before inlining sub-compositions.
// When index.html is a fragment (no <html>/<head>/<body>), linkedom.parseHTML()
// returns a document with null head/body, which causes inlineSubCompositions to
// silently discard all collected composition styles and scripts.
const fullHtml = ensureFullDocument(compiledHtml);
// Inline sub-compositions into the main HTML so the runtime takes the same
// synchronous code path as the bundled preview (no async fetch of
// data-composition-src). This mirrors what htmlBundler.ts does for preview.
const inlinedHtml = inlineSubCompositions(compiledHtml, subCompositions, projectDir);
const inlinedHtml = inlineSubCompositions(fullHtml, subCompositions, projectDir);
const html = injectDeterministicFontFaces(
coalesceHeadStylesAndBodyScripts(promoteCssImportsToLinkTags(inlinedHtml)),
@@ -15,7 +15,7 @@
import { existsSync, mkdirSync, rmSync, writeFileSync, copyFileSync, appendFileSync } from "fs";
import {
type EngineConfig as ProducerConfig,
type EngineConfig,
resolveConfig,
extractAllVideoFrames,
createFrameLookupTable,
@@ -94,7 +94,7 @@ export interface RenderConfig {
useGpu?: boolean;
debug?: boolean;
/** Full producer config. When provided, env vars are not read. */
producerConfig?: ProducerConfig;
producerConfig?: EngineConfig;
/** Custom logger. Defaults to console-based defaultLogger. */
logger?: ProducerLogger;
}