mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
Timeline locking: - Add data-timeline-locked attribute support — fully disables move, trim-start, and trim-end in Studio for clips that carry this attr - Runtime propagates the attribute from inner composition root to host element so component authors control it from their HTML - All 15 caption components in the registry now carry the attribute Font fix: - Extract <link rel="stylesheet"> and <link rel="preconnect"> from sub-composition <head> alongside existing <style>/<script> extraction - Fixes caption components (and any sub-comp using Google Fonts via <link> tags) losing their font-family when loaded as sub-compositions - Applied in both runtime (compositionLoader) and compiler (inlineSubCompositions) paths
1279 lines
43 KiB
TypeScript
1279 lines
43 KiB
TypeScript
/**
|
|
* HTML Compiler for Producer
|
|
*
|
|
* Two-phase compilation that guarantees every media element has data-end:
|
|
* 1. Static pass via core's compileTimingAttrs() (data-start + data-duration → data-end)
|
|
* 2. ffprobe resolution for elements without data-duration
|
|
*
|
|
* Also handles sub-compositions referenced via data-composition-src,
|
|
* recursively extracting nested media from sub-sub-compositions.
|
|
*/
|
|
|
|
import { readFileSync, existsSync, mkdirSync } from "fs";
|
|
import { join, dirname, resolve } from "path";
|
|
import { parseHTML } from "linkedom";
|
|
import {
|
|
compileTimingAttrs,
|
|
injectDurations,
|
|
extractResolvedMedia,
|
|
clampDurations,
|
|
shouldClampMediaDuration,
|
|
type ResolvedDuration,
|
|
type UnresolvedElement,
|
|
} from "@hyperframes/core";
|
|
import { inlineSubCompositions as inlineSubCompositionsShared } from "@hyperframes/core/compiler";
|
|
import { extractMediaMetadata, extractAudioMetadata } from "../utils/ffprobe.js";
|
|
import { isPathInside, toExternalAssetKey } from "../utils/paths.js";
|
|
import {
|
|
parseVideoElements,
|
|
parseImageElements,
|
|
type VideoElement,
|
|
type ImageElement,
|
|
parseAudioElements,
|
|
type AudioElement,
|
|
analyzeKeyframeIntervals,
|
|
} from "@hyperframes/engine";
|
|
import { downloadToTemp, isHttpUrl } from "../utils/urlDownloader.js";
|
|
import type { Page } from "puppeteer-core";
|
|
import { injectDeterministicFontFaces } from "./deterministicFonts.js";
|
|
import { createStudioPositionSeekReapplyScript } from "@hyperframes/core/studio-api/manual-edits-render-script";
|
|
|
|
export interface CompiledComposition {
|
|
html: string;
|
|
subCompositions: Map<string, string>;
|
|
videos: VideoElement[];
|
|
audios: AudioElement[];
|
|
images: ImageElement[];
|
|
unresolvedCompositions: UnresolvedElement[];
|
|
/** Assets that resolve outside projectDir. Keys are the path used in HTML, values are absolute filesystem paths. */
|
|
externalAssets: Map<string, string>;
|
|
width: number;
|
|
height: number;
|
|
staticDuration: number;
|
|
renderModeHints: RenderModeHints;
|
|
hasShaderTransitions: boolean;
|
|
}
|
|
|
|
export type RenderModeHintCode = "iframe" | "requestAnimationFrame";
|
|
|
|
export interface RenderModeHint {
|
|
code: RenderModeHintCode;
|
|
message: string;
|
|
}
|
|
|
|
export interface RenderModeHints {
|
|
recommendScreenshot: boolean;
|
|
reasons: RenderModeHint[];
|
|
}
|
|
|
|
function dedupeElementsById<T extends { id: string }>(elements: T[]): T[] {
|
|
const deduped = new Map<string, T>();
|
|
for (const element of elements) {
|
|
deduped.set(element.id, element);
|
|
}
|
|
return Array.from(deduped.values());
|
|
}
|
|
|
|
const INLINE_SCRIPT_PATTERN = /<script\b([^>]*)>([\s\S]*?)<\/script>/gi;
|
|
const COMPILER_MOUNT_BLOCK_START = "/* __HF_COMPILER_MOUNT_START__ */";
|
|
const COMPILER_MOUNT_BLOCK_END = "/* __HF_COMPILER_MOUNT_END__ */";
|
|
|
|
function stripJsComments(source: string): string {
|
|
return source.replace(/\/\/.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, "");
|
|
}
|
|
|
|
function stripCompilerMountBootstrap(source: string): string {
|
|
return source.replace(
|
|
new RegExp(
|
|
`${COMPILER_MOUNT_BLOCK_START.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[\\s\\S]*?${COMPILER_MOUNT_BLOCK_END.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`,
|
|
"g",
|
|
),
|
|
"",
|
|
);
|
|
}
|
|
|
|
export function detectRenderModeHints(html: string): RenderModeHints {
|
|
const reasons: RenderModeHint[] = [];
|
|
const { document } = parseHTML(html);
|
|
|
|
if (document.querySelector("iframe")) {
|
|
reasons.push({
|
|
code: "iframe",
|
|
message:
|
|
"Detected <iframe> in the composition DOM. Nested iframe animation is routed through screenshot capture mode for compatibility.",
|
|
});
|
|
}
|
|
|
|
let scriptMatch: RegExpExecArray | null;
|
|
const scriptPattern = new RegExp(INLINE_SCRIPT_PATTERN.source, INLINE_SCRIPT_PATTERN.flags);
|
|
while ((scriptMatch = scriptPattern.exec(html)) !== null) {
|
|
const attrs = scriptMatch[1] || "";
|
|
if (/\bsrc\s*=/i.test(attrs)) continue;
|
|
const content = stripJsComments(stripCompilerMountBootstrap(scriptMatch[2] || ""));
|
|
if (!/requestAnimationFrame\s*\(/.test(content)) continue;
|
|
reasons.push({
|
|
code: "requestAnimationFrame",
|
|
message:
|
|
"Detected raw requestAnimationFrame() in an inline script. This render is routed through screenshot capture mode with virtual time enabled.",
|
|
});
|
|
break;
|
|
}
|
|
|
|
return {
|
|
recommendScreenshot: reasons.length > 0,
|
|
reasons,
|
|
};
|
|
}
|
|
|
|
const SHADER_TRANSITION_USAGE_PATTERN =
|
|
/\b(?:(?:window|globalThis)\s*\.\s*)?HyperShader\s*\.\s*init\s*\(|\b__hf\s*\.\s*transitions\s*=/;
|
|
|
|
export function detectShaderTransitionUsage(html: string): boolean {
|
|
let scriptMatch: RegExpExecArray | null;
|
|
const scriptPattern = new RegExp(INLINE_SCRIPT_PATTERN.source, INLINE_SCRIPT_PATTERN.flags);
|
|
while ((scriptMatch = scriptPattern.exec(html)) !== null) {
|
|
const attrs = scriptMatch[1] || "";
|
|
if (/\bsrc\s*=/i.test(attrs)) continue;
|
|
const content = stripJsComments(stripCompilerMountBootstrap(scriptMatch[2] || ""));
|
|
if (SHADER_TRANSITION_USAGE_PATTERN.test(content)) return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
async function resolveMediaDuration(
|
|
src: string,
|
|
mediaStart: number,
|
|
baseDir: string,
|
|
downloadDir: string,
|
|
tagName: string,
|
|
): Promise<{ duration: number; resolvedPath: string }> {
|
|
let filePath = src;
|
|
|
|
if (isHttpUrl(src)) {
|
|
if (!existsSync(downloadDir)) mkdirSync(downloadDir, { recursive: true });
|
|
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);
|
|
}
|
|
|
|
if (!existsSync(filePath)) {
|
|
return { duration: 0, resolvedPath: filePath };
|
|
}
|
|
|
|
let metadata: { durationSeconds: number };
|
|
if (tagName === "video") {
|
|
metadata = await extractMediaMetadata(filePath);
|
|
} else {
|
|
try {
|
|
metadata = await extractAudioMetadata(filePath);
|
|
} catch {
|
|
// Source file has no audio stream (e.g. a silent video used as an audio src).
|
|
// Return duration 0 so the element is excluded from the composition gracefully,
|
|
// matching how missing files and failed downloads are already handled above.
|
|
return { duration: 0, resolvedPath: filePath };
|
|
}
|
|
}
|
|
|
|
const fileDuration = metadata.durationSeconds;
|
|
const effectiveDuration = fileDuration - mediaStart;
|
|
const duration = effectiveDuration > 0 ? effectiveDuration : fileDuration;
|
|
|
|
return { duration, resolvedPath: filePath };
|
|
}
|
|
|
|
/**
|
|
* Compile a single HTML file: static pass + ffprobe for unresolved media.
|
|
* Returns compiled HTML and any unresolved composition elements that need browser resolution.
|
|
*/
|
|
async function compileHtmlFile(
|
|
html: string,
|
|
baseDir: string,
|
|
downloadDir: string,
|
|
): Promise<{ html: string; unresolvedCompositions: UnresolvedElement[] }> {
|
|
const { html: staticCompiled, unresolved } = compileTimingAttrs(html);
|
|
|
|
const mediaUnresolved = unresolved.filter(
|
|
(el) => (el.tagName === "video" || el.tagName === "audio") && el.src,
|
|
);
|
|
|
|
const unresolvedCompositions = unresolved.filter((el) => el.tagName === "div");
|
|
|
|
// Phase 1: Resolve missing durations (parallel ffprobe)
|
|
const resolvedResults = await Promise.all(
|
|
mediaUnresolved.map((el) =>
|
|
resolveMediaDuration(el.src!, el.mediaStart, baseDir, downloadDir, el.tagName).then(
|
|
({ duration }) => ({ id: el.id, duration }),
|
|
),
|
|
),
|
|
);
|
|
const resolutions: ResolvedDuration[] = resolvedResults.filter((r) => r.duration > 0);
|
|
|
|
let compiledHtml =
|
|
resolutions.length > 0 ? injectDurations(staticCompiled, resolutions) : staticCompiled;
|
|
|
|
// Phase 2: Validate pre-resolved media — clamp data-duration to actual source duration (parallel ffprobe)
|
|
const preResolved = extractResolvedMedia(compiledHtml);
|
|
const clampResults = await Promise.all(
|
|
preResolved
|
|
.filter((el) => !!el.src && !el.loop)
|
|
.map(async (el) => {
|
|
const { duration: maxDuration } = await resolveMediaDuration(
|
|
el.src!,
|
|
el.mediaStart,
|
|
baseDir,
|
|
downloadDir,
|
|
el.tagName,
|
|
);
|
|
return { id: el.id, duration: el.duration, maxDuration, src: el.src! };
|
|
}),
|
|
);
|
|
const clampList: ResolvedDuration[] = [];
|
|
for (const r of clampResults) {
|
|
if (r.maxDuration > 0 && shouldClampMediaDuration(r.duration, r.maxDuration)) {
|
|
clampList.push({ id: r.id, duration: r.maxDuration });
|
|
}
|
|
}
|
|
|
|
if (clampList.length > 0) {
|
|
compiledHtml = clampDurations(compiledHtml, clampList);
|
|
}
|
|
|
|
// Strip crossorigin from video elements: the render pipeline replaces them with
|
|
// injected frame images, so the browser never needs to load the source.
|
|
// Without this, videos with crossorigin="anonymous" targeting CORS-restricted
|
|
// origins (e.g. S3 without CORS headers) keep readyState=0, blocking page setup.
|
|
compiledHtml = compiledHtml.replace(/(<video\b[^>]*)\s+crossorigin(?:=["'][^"']*["'])?/gi, "$1");
|
|
|
|
return { html: compiledHtml, unresolvedCompositions };
|
|
}
|
|
|
|
/**
|
|
* Parse sub-compositions referenced via data-composition-src.
|
|
* Reads each file, compiles it, extracts video/audio, adjusts timing offsets.
|
|
* Recurses into nested sub-compositions with accumulated offsets.
|
|
*/
|
|
async function parseSubCompositions(
|
|
html: string,
|
|
projectDir: string,
|
|
downloadDir: string,
|
|
parentOffset: number = 0,
|
|
parentEnd: number = Infinity,
|
|
visited: Set<string> = new Set(),
|
|
): Promise<{
|
|
videos: VideoElement[];
|
|
audios: AudioElement[];
|
|
images: ImageElement[];
|
|
subCompositions: Map<string, string>;
|
|
}> {
|
|
const videos: VideoElement[] = [];
|
|
const audios: AudioElement[] = [];
|
|
const images: ImageElement[] = [];
|
|
const subCompositions = new Map<string, string>();
|
|
|
|
const { document } = parseHTML(html);
|
|
const compEls = document.querySelectorAll("[data-composition-src]");
|
|
|
|
// Build work items, filtering out invalid/circular entries synchronously
|
|
const workItems: Array<{
|
|
srcPath: string;
|
|
absoluteStart: number;
|
|
absoluteEnd: number;
|
|
filePath: string;
|
|
rawSubHtml: string;
|
|
nestedVisited: Set<string>;
|
|
}> = [];
|
|
|
|
for (const el of compEls) {
|
|
const srcPath = el.getAttribute("data-composition-src");
|
|
if (!srcPath) continue;
|
|
|
|
const elStart = parseFloat(el.getAttribute("data-start") || "0");
|
|
const elEndRaw = el.getAttribute("data-end");
|
|
const elEnd = elEndRaw ? parseFloat(elEndRaw) : Infinity;
|
|
|
|
const absoluteStart = parentOffset + elStart;
|
|
const absoluteEnd = Math.min(parentEnd, isFinite(elEnd) ? parentOffset + elEnd : Infinity);
|
|
|
|
const filePath = resolve(projectDir, srcPath);
|
|
|
|
// Circular reference guard
|
|
if (visited.has(filePath)) {
|
|
continue;
|
|
}
|
|
|
|
if (!existsSync(filePath)) {
|
|
continue;
|
|
}
|
|
|
|
const rawSubHtml = readFileSync(filePath, "utf-8");
|
|
const nestedVisited = new Set(visited);
|
|
nestedVisited.add(filePath);
|
|
|
|
workItems.push({ srcPath, absoluteStart, absoluteEnd, filePath, rawSubHtml, nestedVisited });
|
|
}
|
|
|
|
// Parallelize file compilation + recursive parsing
|
|
const results = await Promise.all(
|
|
workItems.map(async (item) => {
|
|
const { html: compiledSub } = await compileHtmlFile(
|
|
item.rawSubHtml,
|
|
dirname(item.filePath),
|
|
downloadDir,
|
|
);
|
|
|
|
const nested = await parseSubCompositions(
|
|
compiledSub,
|
|
projectDir,
|
|
downloadDir,
|
|
item.absoluteStart,
|
|
item.absoluteEnd,
|
|
item.nestedVisited,
|
|
);
|
|
|
|
const subVideos = parseVideoElements(compiledSub);
|
|
const subAudios = parseAudioElements(compiledSub);
|
|
const subImages = parseImageElements(compiledSub);
|
|
|
|
return {
|
|
srcPath: item.srcPath,
|
|
compiledSub,
|
|
nested,
|
|
subVideos,
|
|
subAudios,
|
|
subImages,
|
|
absoluteStart: item.absoluteStart,
|
|
absoluteEnd: item.absoluteEnd,
|
|
};
|
|
}),
|
|
);
|
|
|
|
// Merge results
|
|
for (const r of results) {
|
|
subCompositions.set(r.srcPath, r.compiledSub);
|
|
|
|
for (const [key, value] of r.nested.subCompositions) {
|
|
subCompositions.set(key, value);
|
|
}
|
|
videos.push(...r.nested.videos);
|
|
audios.push(...r.nested.audios);
|
|
images.push(...r.nested.images);
|
|
|
|
for (const v of r.subVideos) {
|
|
v.start += r.absoluteStart;
|
|
v.end += r.absoluteStart;
|
|
if (v.end > r.absoluteEnd) {
|
|
v.end = r.absoluteEnd;
|
|
}
|
|
if (v.start < r.absoluteEnd) {
|
|
videos.push(v);
|
|
}
|
|
}
|
|
|
|
for (const a of r.subAudios) {
|
|
a.start += r.absoluteStart;
|
|
a.end += r.absoluteStart;
|
|
if (a.end > r.absoluteEnd) {
|
|
a.end = r.absoluteEnd;
|
|
}
|
|
if (a.start < r.absoluteEnd) {
|
|
audios.push(a);
|
|
}
|
|
}
|
|
|
|
for (const img of r.subImages) {
|
|
img.start += r.absoluteStart;
|
|
img.end += r.absoluteStart;
|
|
if (img.end > r.absoluteEnd) {
|
|
img.end = r.absoluteEnd;
|
|
}
|
|
if (img.start < r.absoluteEnd) {
|
|
images.push(img);
|
|
}
|
|
}
|
|
|
|
if (
|
|
r.subVideos.length > 0 ||
|
|
r.subAudios.length > 0 ||
|
|
r.subImages.length > 0 ||
|
|
r.nested.videos.length > 0 ||
|
|
r.nested.audios.length > 0 ||
|
|
r.nested.images.length > 0
|
|
) {
|
|
}
|
|
}
|
|
|
|
return { videos, audios, images, subCompositions };
|
|
}
|
|
|
|
/**
|
|
* Extract CSS `@import url(...)` rules that load external stylesheets (e.g. Google Fonts)
|
|
* from inline `<style>` blocks and promote them to `<link rel="stylesheet">` +
|
|
* `<link rel="preload">` in `<head>`.
|
|
*
|
|
* This moves font discovery from the CSS cascade to the document parser level so
|
|
* Chromium's `load` event and `networkidle2` correctly track them, preventing
|
|
* font-swap artifacts during frame capture.
|
|
*/
|
|
function promoteCssImportsToLinkTags(html: string): string {
|
|
const { document } = parseHTML(html);
|
|
const head = document.querySelector("head");
|
|
if (!head) return html;
|
|
|
|
const importRe = /@import\s+url\(\s*['"]?([^'")\s]+)['"]?\s*\)\s*;?/gi;
|
|
const seenUrls = new Set<string>();
|
|
const styleEls = document.querySelectorAll("style");
|
|
|
|
for (const styleEl of styleEls) {
|
|
const original = styleEl.textContent || "";
|
|
let modified = original;
|
|
let match: RegExpExecArray | null;
|
|
importRe.lastIndex = 0;
|
|
while ((match = importRe.exec(original)) !== null) {
|
|
const url = match[1] ?? "";
|
|
if (!url.startsWith("http://") && !url.startsWith("https://")) continue;
|
|
if (seenUrls.has(url)) {
|
|
modified = modified.replace(match[0], "");
|
|
continue;
|
|
}
|
|
seenUrls.add(url);
|
|
modified = modified.replace(match[0], "");
|
|
|
|
const preload = document.createElement("link");
|
|
preload.setAttribute("rel", "preload");
|
|
preload.setAttribute("href", url);
|
|
preload.setAttribute("as", "style");
|
|
head.appendChild(preload);
|
|
|
|
const link = document.createElement("link");
|
|
link.setAttribute("rel", "stylesheet");
|
|
link.setAttribute("href", url);
|
|
head.appendChild(link);
|
|
}
|
|
if (modified !== original) {
|
|
styleEl.textContent = modified;
|
|
}
|
|
}
|
|
|
|
return document.toString();
|
|
}
|
|
|
|
/**
|
|
* Merge all `<head>` `<style>` blocks into a single tag with `@import` rules
|
|
* at the top, and merge all inline `<body>` `<script>` blocks into one at the
|
|
* end of `<body>`.
|
|
*
|
|
* Mirrors the bundler's `coalesceHeadStylesAndBodyScripts` to guarantee
|
|
* identical CSS cascade order and script execution order between preview and
|
|
* export, preventing font-loading and animation-ordering regressions.
|
|
*/
|
|
|
|
function coalesceHeadStylesAndBodyScripts(html: string): string {
|
|
const { document } = parseHTML(html);
|
|
const head = document.querySelector("head");
|
|
const body = document.querySelector("body");
|
|
if (!head) return html;
|
|
|
|
const styleEls = Array.from(head.querySelectorAll("style"));
|
|
if (styleEls.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 styleEls) {
|
|
const raw = (el.textContent || "").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 trimmedCss = nonImportCss.trim();
|
|
if (trimmedCss) cssParts.push(trimmedCss);
|
|
}
|
|
|
|
const mergedCss = [...imports, ...cssParts].join("\n\n").trim();
|
|
if (mergedCss) {
|
|
const firstStyleEl = styleEls[0];
|
|
if (firstStyleEl) firstStyleEl.textContent = mergedCss;
|
|
for (let i = 1; i < styleEls.length; i++) {
|
|
const el = styleEls[i];
|
|
if (el) el.remove();
|
|
}
|
|
}
|
|
}
|
|
|
|
if (body) {
|
|
const bodyScripts = Array.from(body.querySelectorAll("script")).filter((el) => {
|
|
const src = (el.getAttribute("src") || "").trim();
|
|
if (src) return false;
|
|
const type = (el.getAttribute("type") || "").trim().toLowerCase();
|
|
return !type || type === "text/javascript" || type === "application/javascript";
|
|
});
|
|
if (bodyScripts.length > 0) {
|
|
const mergedJs = bodyScripts
|
|
.map((el) => (el.textContent || "").trim())
|
|
.filter(Boolean)
|
|
.join("\n;\n")
|
|
.trim();
|
|
for (const el of bodyScripts) {
|
|
el.remove();
|
|
}
|
|
if (mergedJs) {
|
|
const script = document.createElement("script");
|
|
script.textContent = mergedJs;
|
|
body.appendChild(script);
|
|
}
|
|
}
|
|
}
|
|
|
|
return document.toString();
|
|
}
|
|
|
|
/**
|
|
* Inline sub-composition HTML into the main document using the shared
|
|
* inlining logic from @hyperframes/core. This wrapper handles the
|
|
* producer-specific concerns: parsing HTML via linkedom, resolving
|
|
* compositions from the pre-compiled map or disk, and setting explicit
|
|
* pixel dimensions on host elements for headless rendering.
|
|
*/
|
|
function inlineSubCompositions(
|
|
html: string,
|
|
subCompositions: Map<string, string>,
|
|
projectDir: string,
|
|
): string {
|
|
const { document } = parseHTML(html);
|
|
const head = document.querySelector("head");
|
|
const body = document.querySelector("body");
|
|
const hosts = Array.from(document.querySelectorAll("[data-composition-src]"));
|
|
|
|
if (!hosts.length) return html;
|
|
|
|
const result = inlineSubCompositionsShared(
|
|
document as unknown as Document,
|
|
hosts as unknown as Element[],
|
|
{
|
|
resolveHtml: (srcPath: string) => {
|
|
let compHtml = subCompositions.get(srcPath) || null;
|
|
if (!compHtml) {
|
|
const filePath = resolve(projectDir, srcPath);
|
|
if (existsSync(filePath)) {
|
|
compHtml = readFileSync(filePath, "utf-8");
|
|
}
|
|
}
|
|
return compHtml;
|
|
},
|
|
parseHtml: (htmlStr: string) => parseHTML(htmlStr).document as unknown as Document,
|
|
scriptErrorLabel: "[Compiler] Composition script failed",
|
|
},
|
|
);
|
|
|
|
// Set data-hf-authored-id on host elements so the scoped script proxy
|
|
// can rewrite #id selectors (e.g. #us-map → [data-hf-authored-id="us-map"]).
|
|
// Unlike flattenInnerRoot (which changes DOM structure and breaks baselines),
|
|
// this preserves the existing innerHTML-based inlining while enabling the
|
|
// authored-id selector contract.
|
|
for (const hostEl of hosts) {
|
|
const compId = hostEl.getAttribute("data-composition-id");
|
|
if (compId && !hostEl.getAttribute("data-hf-authored-id")) {
|
|
hostEl.setAttribute("data-hf-authored-id", compId);
|
|
}
|
|
}
|
|
|
|
// Producer-specific: set explicit pixel dimensions on host elements so
|
|
// children using width/height: 100% resolve correctly. The runtime does
|
|
// this automatically but compiled HTML needs it inline.
|
|
for (const host of hosts) {
|
|
const hostW = host.getAttribute("data-width");
|
|
const hostH = host.getAttribute("data-height");
|
|
if (hostW && hostH) {
|
|
const existing = host.getAttribute("style") || "";
|
|
const needsWidth = !existing.includes("width");
|
|
const needsHeight = !existing.includes("height");
|
|
const additions = [
|
|
needsWidth ? `width:${hostW}px` : "",
|
|
needsHeight ? `height:${hostH}px` : "",
|
|
]
|
|
.filter(Boolean)
|
|
.join(";");
|
|
if (additions) {
|
|
host.setAttribute("style", existing ? `${existing};${additions}` : additions);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (result.externalLinkHrefs.length && head) {
|
|
for (const href of result.externalLinkHrefs) {
|
|
if (document.querySelector(`link[href="${href}"]`)) continue;
|
|
const el = document.createElement("link");
|
|
el.setAttribute(
|
|
"rel",
|
|
href.includes(".css") || href.includes("css2?") ? "stylesheet" : "preconnect",
|
|
);
|
|
el.setAttribute("href", href);
|
|
head.appendChild(el);
|
|
}
|
|
}
|
|
|
|
// Append collected styles to <head>
|
|
if (result.styles.length && head) {
|
|
const styleEl = document.createElement("style");
|
|
styleEl.textContent = result.styles.join("\n\n");
|
|
head.appendChild(styleEl);
|
|
}
|
|
|
|
// Inject external CDN scripts before inline scripts so plugins (e.g.
|
|
// TextPlugin, ScrollTrigger) are registered before composition code runs.
|
|
// Deduplicate against scripts already present in the document.
|
|
if (result.externalScriptSrcs.length && body) {
|
|
const existingScriptSrcs = new Set(
|
|
Array.from(document.querySelectorAll("script[src]")).map((el: Element) =>
|
|
(el.getAttribute("src") || "").trim(),
|
|
),
|
|
);
|
|
for (const src of result.externalScriptSrcs) {
|
|
if (!existingScriptSrcs.has(src)) {
|
|
const scriptEl = document.createElement("script");
|
|
scriptEl.setAttribute("src", src);
|
|
body.appendChild(scriptEl);
|
|
existingScriptSrcs.add(src);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Append collected inline scripts to <body>
|
|
if (result.scripts.length && body) {
|
|
const scriptEl = document.createElement("script");
|
|
scriptEl.textContent = result.scripts.join("\n;\n");
|
|
body.appendChild(scriptEl);
|
|
}
|
|
|
|
return document.toString();
|
|
}
|
|
|
|
/**
|
|
* Full compilation pipeline for the producer.
|
|
*
|
|
* 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;
|
|
}
|
|
// Wrap fragment with a proper document including margin/padding reset.
|
|
// Without this, Chrome applies default body { margin: 8px } which creates
|
|
// visible white lines at the edges of rendered video.
|
|
return `<!DOCTYPE html>\n<html>\n<head>\n <meta charset="UTF-8">\n <style>*{margin:0;padding:0;box-sizing:border-box}body{overflow:hidden;background:#000}</style>\n</head>\n<body style="margin:0;overflow:hidden">\n${html}\n</body>\n</html>`;
|
|
}
|
|
|
|
/**
|
|
* Download external CDN scripts and inline them into the HTML so rendering
|
|
* works without network access (Docker, CI, restricted environments).
|
|
*/
|
|
export async function inlineExternalScripts(html: string): Promise<string> {
|
|
const fullHtml = ensureFullDocument(html);
|
|
const wrappedFragment = fullHtml !== html;
|
|
const { document } = parseHTML(fullHtml);
|
|
const scripts = document.querySelectorAll("script[src]");
|
|
const externalScripts: { el: Element; src: string }[] = [];
|
|
|
|
for (const el of scripts) {
|
|
const src = (el.getAttribute("src") || "").trim();
|
|
if (src && isHttpUrl(src)) {
|
|
externalScripts.push({ el: el as unknown as Element, src });
|
|
}
|
|
}
|
|
|
|
if (externalScripts.length === 0) return html;
|
|
|
|
const downloads = await Promise.allSettled(
|
|
externalScripts.map(async ({ src }) => {
|
|
const response = await fetch(src, {
|
|
signal: AbortSignal.timeout(15_000),
|
|
});
|
|
if (!response.ok) throw new Error(`HTTP ${response.status} for ${src}`);
|
|
return { src, text: await response.text() };
|
|
}),
|
|
);
|
|
|
|
for (let i = 0; i < downloads.length; i++) {
|
|
const download = downloads[i]!;
|
|
const { el, src } = externalScripts[i]!;
|
|
if (download.status === "fulfilled") {
|
|
// Escape </script in downloaded content to prevent premature tag closure.
|
|
// <\/script is safe: the HTML parser doesn't recognize it as a close tag,
|
|
// but JS treats \/ as / so the code executes identically.
|
|
const safeText = download.value.text.replace(/<\/script/gi, "<\\/script");
|
|
const inlineScript = document.createElement("script");
|
|
for (const attr of Array.from(el.attributes)) {
|
|
if (attr.name.toLowerCase() === "src") continue;
|
|
inlineScript.setAttribute(attr.name, attr.value);
|
|
}
|
|
inlineScript.textContent = `/* inlined: ${src} */\n${safeText}\n`;
|
|
el.replaceWith(inlineScript);
|
|
console.log(`[Compiler] Inlined CDN script: ${src}`);
|
|
} else {
|
|
console.warn(
|
|
`[Compiler] WARNING: Failed to download CDN script: ${src} — ${download.reason}. ` +
|
|
`The render may fail if this script is required (e.g. GSAP). ` +
|
|
`Consider bundling it locally in your project.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
return wrappedFragment ? document.body.innerHTML || "" : document.toString();
|
|
}
|
|
|
|
/**
|
|
* Scan compiled HTML for asset references that resolve outside projectDir.
|
|
* For each, map the normalized in-HTML path to the real filesystem path so
|
|
* the orchestrator can copy them into the compiled output directory.
|
|
*
|
|
* Handles: src/href attributes, CSS url(), inline style url().
|
|
*/
|
|
export function collectExternalAssets(
|
|
html: string,
|
|
projectDir: string,
|
|
): { html: string; externalAssets: Map<string, string> } {
|
|
const absProjectDir = resolve(projectDir);
|
|
const externalAssets = new Map<string, string>();
|
|
const CSS_URL_RE = /\burl\(\s*(["']?)([^)"']+)\1\s*\)/g;
|
|
|
|
function processPath(rawPath: string): string | null {
|
|
const trimmed = rawPath.trim();
|
|
if (
|
|
!trimmed ||
|
|
trimmed.startsWith("/") ||
|
|
trimmed.startsWith("http://") ||
|
|
trimmed.startsWith("https://") ||
|
|
trimmed.startsWith("//") ||
|
|
trimmed.startsWith("data:") ||
|
|
trimmed.startsWith("#")
|
|
) {
|
|
return null;
|
|
}
|
|
const absPath = resolve(absProjectDir, trimmed);
|
|
if (isPathInside(absPath, absProjectDir)) {
|
|
return null; // inside projectDir, file server handles this
|
|
}
|
|
if (!existsSync(absPath)) return null;
|
|
// resolve() already canonicalises the path (no .. components remain);
|
|
// toExternalAssetKey() produces a cross-platform relative key that
|
|
// `path.join(compileDir, key)` cannot escape on any OS.
|
|
const safeKey = toExternalAssetKey(absPath);
|
|
externalAssets.set(safeKey, absPath);
|
|
return safeKey;
|
|
}
|
|
|
|
const { document } = parseHTML(html);
|
|
|
|
// Rewrite src and href attributes
|
|
for (const el of document.querySelectorAll("[src], [href]")) {
|
|
for (const attr of ["src", "href"]) {
|
|
const val = (el.getAttribute(attr) || "").trim();
|
|
if (!val) continue;
|
|
const rewritten = processPath(val);
|
|
if (rewritten) el.setAttribute(attr, rewritten);
|
|
}
|
|
}
|
|
|
|
// Rewrite CSS url() in <style> blocks
|
|
for (const styleEl of document.querySelectorAll("style")) {
|
|
const css = styleEl.textContent || "";
|
|
if (!css.includes("url(")) continue;
|
|
const rewritten = css.replace(CSS_URL_RE, (full, quote: string, rawUrl: string) => {
|
|
const result = processPath((rawUrl || "").trim());
|
|
if (!result) return full;
|
|
return `url(${quote || ""}${result}${quote || ""})`;
|
|
});
|
|
if (rewritten !== css) styleEl.textContent = rewritten;
|
|
}
|
|
|
|
// Rewrite inline style url() on elements
|
|
for (const el of document.querySelectorAll("[style]")) {
|
|
const style = el.getAttribute("style") || "";
|
|
if (!style.includes("url(")) continue;
|
|
const rewritten = style.replace(CSS_URL_RE, (full, quote: string, rawUrl: string) => {
|
|
const result = processPath((rawUrl || "").trim());
|
|
if (!result) return full;
|
|
return `url(${quote || ""}${result}${quote || ""})`;
|
|
});
|
|
if (rewritten !== style) el.setAttribute("style", rewritten);
|
|
}
|
|
|
|
if (externalAssets.size > 0) {
|
|
console.log(
|
|
`[Compiler] Found ${externalAssets.size} asset(s) outside project directory — will copy to render output`,
|
|
);
|
|
}
|
|
|
|
return {
|
|
html: externalAssets.size > 0 ? document.toString() : html,
|
|
externalAssets,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Optional behavior toggles for {@link compileForRender}. All fields are
|
|
* additive; omitting `options` preserves the in-process renderer's defaults.
|
|
*/
|
|
export interface CompileForRenderOptions {
|
|
/**
|
|
* Threaded through to {@link injectDeterministicFontFaces}. When `true`,
|
|
* any external font fetch failure throws `FontFetchError` instead of
|
|
* silently falling back to system fonts. Distributed `plan()` sets this
|
|
* to `true` so font availability is part of the planDir's content-addressed
|
|
* hash and fetch failures surface as typed non-retryable errors. Default
|
|
* `false` preserves the in-process behavior.
|
|
*/
|
|
failClosedFontFetch?: boolean;
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
downloadDir: string,
|
|
options: CompileForRenderOptions = {},
|
|
): Promise<CompiledComposition> {
|
|
const rawHtml = readFileSync(htmlPath, "utf-8");
|
|
const { html: compiledHtml, unresolvedCompositions } = await compileHtmlFile(
|
|
rawHtml,
|
|
projectDir,
|
|
downloadDir,
|
|
);
|
|
|
|
// Parse sub-compositions first (extracts media + compiled HTML for each)
|
|
const {
|
|
videos: subVideos,
|
|
audios: subAudios,
|
|
images: subImages,
|
|
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(fullHtml, subCompositions, projectDir);
|
|
|
|
// Strip preload="none" from media elements — the renderer needs to load all
|
|
// media upfront for frame capture. Users add this to reduce browser memory in
|
|
// preview, but it causes the headless renderer to never load the media, leading
|
|
// to 45s timeout failures.
|
|
const sanitizedHtml = inlinedHtml.replace(
|
|
/(<(?:video|audio)\b[^>]*?)\s+preload\s*=\s*["']none["']/gi,
|
|
"$1",
|
|
);
|
|
const renderModeHints = detectRenderModeHints(sanitizedHtml);
|
|
const hasShaderTransitions = detectShaderTransitionUsage(sanitizedHtml);
|
|
|
|
const coalescedHtml = await injectDeterministicFontFaces(
|
|
coalesceHeadStylesAndBodyScripts(promoteCssImportsToLinkTags(sanitizedHtml)),
|
|
{ failClosedFontFetch: options.failClosedFontFetch === true },
|
|
);
|
|
|
|
// Download CDN scripts and inline them AFTER coalescing. This order matters:
|
|
// coalesceHeadStylesAndBodyScripts merges inline scripts and appends them at
|
|
// the end of <body>. If we inlined CDN scripts first, the GSAP library would
|
|
// become an inline script that gets moved after local <script src="script.js">
|
|
// tags that depend on it, causing "gsap is not defined" errors.
|
|
const assembledHtml = await inlineExternalScripts(coalescedHtml);
|
|
|
|
// Collect assets that resolve outside projectDir (e.g. ../shared-assets/hero.png).
|
|
// These can't be served by the file server, so we map them to paths the
|
|
// orchestrator will copy into the compiled output directory.
|
|
const { html: htmlWithAssets, externalAssets } = collectExternalAssets(assembledHtml, projectDir);
|
|
|
|
// Inject studio position seek re-apply script when positions are baked into HTML.
|
|
// GSAP overwrites the `translate` CSS property on every frame seek; this script
|
|
// re-asserts the CSS custom property var() form after each seek so dragged
|
|
// positions survive frame-by-frame rendering without a JSON sidecar.
|
|
const HF_POSITION_ATTRS = [
|
|
'data-hf-studio-path-offset="true"',
|
|
'data-hf-studio-box-size="true"',
|
|
'data-hf-studio-rotation="true"',
|
|
'data-hf-studio-motion="',
|
|
];
|
|
const hasPositionEdits = HF_POSITION_ATTRS.some((attr) => htmlWithAssets.includes(attr));
|
|
const html = hasPositionEdits
|
|
? htmlWithAssets.replace(
|
|
/<\/body>/i,
|
|
`<script>${createStudioPositionSeekReapplyScript()}</script></body>`,
|
|
)
|
|
: htmlWithAssets;
|
|
|
|
// Parse main HTML elements
|
|
const mainVideos = parseVideoElements(html);
|
|
const mainAudios = parseAudioElements(html);
|
|
const mainImages = parseImageElements(html);
|
|
|
|
// Keep inlined sub-composition media authoritative on ID collisions.
|
|
// inlineSubCompositions() hoists those nodes into the final HTML, so the
|
|
// producer should follow the same precedence the runtime sees in the merged DOM.
|
|
const videos = dedupeElementsById([...mainVideos, ...subVideos]);
|
|
const audios = dedupeElementsById([...mainAudios, ...subAudios]);
|
|
const images = dedupeElementsById([...mainImages, ...subImages]);
|
|
|
|
// Advisory video checks (sparse keyframes, VFR). Fire-and-forget — these spawn
|
|
// ffprobe subprocesses and should not block compilation since they only produce warnings.
|
|
for (const video of videos) {
|
|
if (isHttpUrl(video.src)) continue;
|
|
const videoPath = resolve(projectDir, video.src);
|
|
const reencode = `ffmpeg -i "${video.src}" -c:v libx264 -r 30 -g 30 -keyint_min 30 -movflags +faststart -c:a copy output.mp4`;
|
|
Promise.all([analyzeKeyframeIntervals(videoPath), extractMediaMetadata(videoPath)])
|
|
.then(([analysis, metadata]) => {
|
|
if (analysis.isProblematic) {
|
|
console.warn(
|
|
`[Compiler] WARNING: Video "${video.id}" has sparse keyframes (max interval: ${analysis.maxIntervalSeconds}s). ` +
|
|
`This causes seek failures and frame freezing. Re-encode with: ${reencode}`,
|
|
);
|
|
}
|
|
if (metadata.isVFR) {
|
|
console.info(
|
|
`[Compiler] Video "${video.id}" is variable frame rate (VFR); ` +
|
|
`the engine will normalize it to CFR before frame extraction. ` +
|
|
`If rendering feels slow on this video, pre-encode once with: ${reencode}`,
|
|
);
|
|
}
|
|
})
|
|
.catch(() => {});
|
|
}
|
|
|
|
// Read dimensions from root composition element using DOM parser
|
|
const { document } = parseHTML(html);
|
|
const rootEl = document.querySelector("[data-composition-id]");
|
|
|
|
const width = rootEl ? parseInt(rootEl.getAttribute("data-width") || "1080", 10) : 1080;
|
|
const height = rootEl ? parseInt(rootEl.getAttribute("data-height") || "1920", 10) : 1920;
|
|
|
|
// Static duration (may be 0 if set at runtime by GSAP)
|
|
const staticDuration = rootEl
|
|
? parseFloat(
|
|
rootEl.getAttribute("data-duration") ||
|
|
rootEl.getAttribute("data-composition-duration") ||
|
|
"0",
|
|
)
|
|
: 0;
|
|
|
|
return {
|
|
html,
|
|
subCompositions,
|
|
videos,
|
|
audios,
|
|
images,
|
|
unresolvedCompositions,
|
|
externalAssets,
|
|
width,
|
|
height,
|
|
staticDuration,
|
|
renderModeHints,
|
|
hasShaderTransitions,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Discover media elements from the browser DOM after JavaScript has run.
|
|
* This catches videos/audios whose `src` is set dynamically via JS
|
|
* (e.g. `document.getElementById("pip-video").src = URL`), which the
|
|
* static regex parsers miss because the HTML has `src=""`.
|
|
*/
|
|
export interface BrowserMediaElement {
|
|
id: string;
|
|
tagName: "video" | "audio";
|
|
src: string;
|
|
start: number;
|
|
end: number;
|
|
duration: number;
|
|
mediaStart: number;
|
|
loop: boolean;
|
|
hasAudio: boolean;
|
|
volume: number;
|
|
}
|
|
|
|
export async function discoverMediaFromBrowser(page: Page): Promise<BrowserMediaElement[]> {
|
|
const elements = await page.evaluate(() => {
|
|
const results: {
|
|
id: string;
|
|
tagName: string;
|
|
src: string;
|
|
start: number;
|
|
end: number;
|
|
duration: number;
|
|
mediaStart: number;
|
|
loop: boolean;
|
|
hasAudio: boolean;
|
|
volume: number;
|
|
}[] = [];
|
|
|
|
const mediaEls = document.querySelectorAll("video[data-start], audio[data-start]");
|
|
mediaEls.forEach((el) => {
|
|
const htmlEl = el as HTMLVideoElement | HTMLAudioElement;
|
|
const id = htmlEl.id;
|
|
if (!id) return;
|
|
|
|
const src = htmlEl.src || htmlEl.getAttribute("src") || "";
|
|
const start = parseFloat(htmlEl.getAttribute("data-start") || "0");
|
|
const end = parseFloat(htmlEl.getAttribute("data-end") || "0");
|
|
const duration = parseFloat(htmlEl.getAttribute("data-duration") || "0");
|
|
const mediaStart = parseFloat(htmlEl.getAttribute("data-media-start") || "0");
|
|
const loop = htmlEl.hasAttribute("loop");
|
|
const hasAudio = htmlEl.getAttribute("data-has-audio") === "true";
|
|
const volume = parseFloat(htmlEl.getAttribute("data-volume") || "1");
|
|
|
|
results.push({
|
|
id,
|
|
tagName: htmlEl.tagName.toLowerCase(),
|
|
src,
|
|
start,
|
|
end,
|
|
duration,
|
|
mediaStart,
|
|
loop,
|
|
hasAudio,
|
|
volume,
|
|
});
|
|
});
|
|
|
|
return results;
|
|
});
|
|
|
|
return elements as BrowserMediaElement[];
|
|
}
|
|
|
|
export interface VideoVisibilityWindow {
|
|
videoId: string;
|
|
visibleStart: number;
|
|
visibleEnd: number;
|
|
}
|
|
|
|
/**
|
|
* Seek the GSAP timeline to discover when each video's parent scene is visible.
|
|
* Only processes videos with the data-hf-auto-start sentinel (auto-injected timing).
|
|
*/
|
|
export async function discoverVideoVisibilityFromTimeline(
|
|
page: Page,
|
|
compositionDuration: number,
|
|
): Promise<VideoVisibilityWindow[]> {
|
|
if (compositionDuration <= 0) return [];
|
|
|
|
return page.evaluate((duration: number) => {
|
|
const results: { videoId: string; visibleStart: number; visibleEnd: number }[] = [];
|
|
const videos = document.querySelectorAll("video[data-hf-auto-start]");
|
|
if (videos.length === 0) return results;
|
|
|
|
const timelines = (window as unknown as { __timelines?: Record<string, unknown> }).__timelines;
|
|
if (!timelines) return results;
|
|
|
|
const rootEl = document.querySelector("[data-composition-id]");
|
|
const compId = rootEl?.getAttribute("data-composition-id");
|
|
if (!compId) return results;
|
|
|
|
const tl = timelines[compId] as
|
|
| {
|
|
totalTime?: (t: number, suppressEvents?: boolean) => unknown;
|
|
seek?: (t: number, suppressEvents?: boolean) => unknown;
|
|
}
|
|
| undefined;
|
|
if (!tl) return results;
|
|
|
|
const seekTl = (t: number) => {
|
|
if (typeof tl.totalTime === "function") {
|
|
tl.totalTime(t, true);
|
|
} else if (typeof tl.seek === "function") {
|
|
tl.seek(t, true);
|
|
}
|
|
};
|
|
|
|
const SAMPLE_STEP = 0.1;
|
|
const BINARY_PRECISION = 1 / 60;
|
|
|
|
for (const videoEl of videos) {
|
|
const id = videoEl.id;
|
|
if (!id) continue;
|
|
|
|
const sceneEl = videoEl.closest(".scene") || videoEl;
|
|
|
|
let firstVisible: number | null = null;
|
|
let lastVisible: number | null = null;
|
|
|
|
for (let t = 0; t <= duration; t += SAMPLE_STEP) {
|
|
seekTl(t);
|
|
const opacity = parseFloat(window.getComputedStyle(sceneEl).opacity);
|
|
if (opacity > 0) {
|
|
if (firstVisible === null) firstVisible = t;
|
|
lastVisible = t;
|
|
}
|
|
}
|
|
|
|
if (firstVisible === null || lastVisible === null) continue;
|
|
|
|
// Binary search left boundary
|
|
let lo = Math.max(0, firstVisible - SAMPLE_STEP);
|
|
let hi = firstVisible;
|
|
while (hi - lo > BINARY_PRECISION) {
|
|
const mid = (lo + hi) / 2;
|
|
seekTl(mid);
|
|
const opacity = parseFloat(window.getComputedStyle(sceneEl).opacity);
|
|
if (opacity > 0) hi = mid;
|
|
else lo = mid;
|
|
}
|
|
const exactStart = hi;
|
|
|
|
// Binary search right boundary
|
|
lo = lastVisible;
|
|
hi = Math.min(duration, lastVisible + SAMPLE_STEP);
|
|
while (hi - lo > BINARY_PRECISION) {
|
|
const mid = (lo + hi) / 2;
|
|
seekTl(mid);
|
|
const opacity = parseFloat(window.getComputedStyle(sceneEl).opacity);
|
|
if (opacity > 0) lo = mid;
|
|
else hi = mid;
|
|
}
|
|
const exactEnd = lo;
|
|
|
|
results.push({
|
|
videoId: id,
|
|
visibleStart: Math.max(0, exactStart),
|
|
visibleEnd: Math.min(duration, exactEnd),
|
|
});
|
|
}
|
|
|
|
seekTl(0);
|
|
return results;
|
|
}, compositionDuration);
|
|
}
|
|
|
|
/**
|
|
* Resolve composition durations via Puppeteer by querying window.__timelines.
|
|
* The page must already have the interceptor loaded and timelines registered.
|
|
*/
|
|
export async function resolveCompositionDurations(
|
|
page: Page,
|
|
unresolved: UnresolvedElement[],
|
|
): Promise<ResolvedDuration[]> {
|
|
if (unresolved.length === 0) return [];
|
|
|
|
const ids = unresolved.map((el) => el.id);
|
|
|
|
const results = await page.evaluate((compIds: string[]) => {
|
|
const win = window as unknown as { __timelines?: Record<string, { duration(): number }> };
|
|
const timelines = win.__timelines || {};
|
|
const resolved: { id: string; duration: number; source: string }[] = [];
|
|
|
|
for (const id of compIds) {
|
|
// Try window.__timelines[id].duration() first (GSAP timeline)
|
|
const tl = timelines[id];
|
|
if (tl && typeof tl.duration === "function") {
|
|
const dur = tl.duration();
|
|
if (dur > 0) {
|
|
resolved.push({ id, duration: dur, source: "__timelines" });
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// Fallback: check for authored duration on the element itself
|
|
const el = document.getElementById(id);
|
|
if (el) {
|
|
const compDurAttr =
|
|
el.getAttribute("data-duration") || el.getAttribute("data-composition-duration");
|
|
if (compDurAttr) {
|
|
const dur = parseFloat(compDurAttr);
|
|
if (dur > 0) {
|
|
resolved.push({ id, duration: dur, source: "data-duration" });
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
|
|
resolved.push({ id, duration: 0, source: "unresolved" });
|
|
}
|
|
|
|
return resolved;
|
|
}, ids);
|
|
|
|
const resolutions: ResolvedDuration[] = [];
|
|
for (const r of results) {
|
|
if (r.duration > 0) {
|
|
resolutions.push({ id: r.id, duration: r.duration });
|
|
}
|
|
}
|
|
|
|
return resolutions;
|
|
}
|
|
|
|
/**
|
|
* Re-compile after composition durations are resolved.
|
|
* Injects durations into the HTML and re-parses sub-composition media with proper bounds.
|
|
*/
|
|
export async function recompileWithResolutions(
|
|
compiled: CompiledComposition,
|
|
resolutions: ResolvedDuration[],
|
|
projectDir: string,
|
|
downloadDir: string,
|
|
): Promise<CompiledComposition> {
|
|
if (resolutions.length === 0) return compiled;
|
|
|
|
const html = injectDurations(compiled.html, resolutions);
|
|
|
|
// Re-parse sub-compositions with the updated parent bounds
|
|
const {
|
|
videos: subVideos,
|
|
audios: subAudios,
|
|
images: subImages,
|
|
subCompositions,
|
|
} = await parseSubCompositions(html, projectDir, downloadDir);
|
|
|
|
const mainVideos = parseVideoElements(html);
|
|
const mainAudios = parseAudioElements(html);
|
|
const mainImages = parseImageElements(html);
|
|
|
|
// Keep inlined sub-composition media authoritative on ID collisions.
|
|
const hasSubMedia = subVideos.length > 0 || subAudios.length > 0 || subImages.length > 0;
|
|
const videos = hasSubMedia ? dedupeElementsById([...mainVideos, ...subVideos]) : compiled.videos;
|
|
const audios = hasSubMedia ? dedupeElementsById([...mainAudios, ...subAudios]) : compiled.audios;
|
|
const images = hasSubMedia ? dedupeElementsById([...mainImages, ...subImages]) : compiled.images;
|
|
|
|
const remaining = compiled.unresolvedCompositions.filter(
|
|
(c) => !resolutions.some((r) => r.id === c.id),
|
|
);
|
|
|
|
return {
|
|
...compiled,
|
|
html,
|
|
subCompositions,
|
|
videos,
|
|
audios,
|
|
images,
|
|
unresolvedCompositions: remaining,
|
|
renderModeHints: compiled.renderModeHints,
|
|
hasShaderTransitions: compiled.hasShaderTransitions,
|
|
};
|
|
}
|