style: apply oxfmt baseline formatting across all source files (#25)

## Summary
- Run `oxfmt .` across the entire codebase to establish formatted baseline
- 299 files changed — mechanical formatting only, no logic changes
- Double quotes, semicolons, 2-space indent, trailing commas, 100 print width

Part 3/4 of [VA-851](https://linear.app/heygen/issue/VA-851/pre-migration-configure-eslint-prettier-and-conventional-commits)

## Test plan
- [x] `pnpm format:check` — all 426 files pass
- [x] `pnpm -r typecheck` — all packages pass
- [x] `pnpm build` — all packages build
- [x] All 348 tests pass
This commit is contained in:
Vance Ingalls
2026-03-23 17:15:14 -07:00
committed by GitHub
parent 323ff8f860
commit 20be2ea1c2
299 changed files with 27750 additions and 16792 deletions
+2 -1
View File
@@ -20,7 +20,8 @@ export function createGSAPFrameAdapter(options: CreateGSAPFrameAdapterOptions):
const adapterId = options.id ?? "gsap";
const getDurationSeconds = (): number => {
const totalDuration = typeof timeline.totalDuration === "function" ? timeline.totalDuration() : timeline.duration();
const totalDuration =
typeof timeline.totalDuration === "function" ? timeline.totalDuration() : timeline.duration();
return Number.isFinite(totalDuration) && totalDuration > 0 ? totalDuration : 0;
};
+108 -39
View File
@@ -71,17 +71,31 @@ function injectInterceptor(html: string): string {
function isRelativeUrl(url: string): boolean {
if (!url) return false;
return !url.startsWith("http://") && !url.startsWith("https://") && !url.startsWith("//") && !url.startsWith("data:") && !isAbsolute(url);
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; }
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; }
try {
return readFileSync(filePath);
} catch {
return null;
}
}
function splitUrlSuffix(urlValue: string): { basePath: string; suffix: string } {
@@ -99,7 +113,8 @@ function appendSuffixToUrl(baseUrl: string, suffix: string): string {
const queryWithOptionalHash = suffix.slice(1);
if (!queryWithOptionalHash) return baseUrl;
const hashIdx = queryWithOptionalHash.indexOf("#");
const queryPart = hashIdx >= 0 ? queryWithOptionalHash.slice(0, hashIdx) : queryWithOptionalHash;
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("?") ? "&" : "?";
@@ -137,15 +152,18 @@ function maybeInlineRelativeAssetUrl(urlValue: string, projectDir: string): stri
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(", ");
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 {
@@ -178,9 +196,14 @@ function enforceCompositionPixelSizing($: cheerio.CheerioAPI): void {
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");
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`);
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;
});
@@ -234,7 +257,10 @@ function coalesceHeadStylesAndBodyScripts($: cheerio.CheerioAPI): void {
if (!raw) continue;
const nonImportCss = raw.replace(importRe, (match) => {
const cleaned = match.trim();
if (!seenImports.has(cleaned)) { seenImports.add(cleaned); imports.push(cleaned); }
if (!seenImports.has(cleaned)) {
seenImports.add(cleaned);
imports.push(cleaned);
}
return "";
});
const trimmed = nonImportCss.trim();
@@ -247,14 +273,20 @@ function coalesceHeadStylesAndBodyScripts($: cheerio.CheerioAPI): void {
}
}
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";
});
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();
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);
@@ -268,7 +300,9 @@ function stripJsCommentsParserSafe(source: string): string {
try {
const result = transformSync(source, { loader: "js", minify: false, legalComments: "none" });
return result.code.trim();
} catch { return source; }
} catch {
return source;
}
}
export interface BundleOptions {
@@ -285,7 +319,10 @@ export interface BundleOptions {
* - 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> {
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");
@@ -294,7 +331,9 @@ export async function bundleToSingleHtml(projectDir: string, options?: BundleOpt
const staticGuard = validateHyperframeHtmlContract(compiled);
if (!staticGuard.isValid) {
console.warn(`[StaticGuard] Invalid HyperFrame contract: ${staticGuard.missingKeys.join("; ")}`);
console.warn(
`[StaticGuard] Invalid HyperFrame contract: ${staticGuard.missingKeys.join("; ")}`,
);
}
const withInterceptor = injectInterceptor(compiled);
@@ -310,11 +349,17 @@ export async function bundleToSingleHtml(projectDir: string, options?: BundleOpt
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 (!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"));
if ($anchor.length)
$anchor.removeAttr("data-hf-bundled-local-css").text(localCssChunks.join("\n\n"));
else $("head").append(`<style>${localCssChunks.join("\n\n")}</style>`);
}
@@ -328,11 +373,17 @@ export async function bundleToSingleHtml(projectDir: string, options?: BundleOpt
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 (!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"));
if ($anchor.length)
$anchor.removeAttr("data-hf-bundled-local-js").text(localJsChunks.join("\n;\n"));
else $("body").append(`<script>${localJsChunks.join("\n;\n")}</script>`);
}
@@ -344,18 +395,30 @@ export async function bundleToSingleHtml(projectDir: string, options?: BundleOpt
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; }
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 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();
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("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); } })();`);
compScriptChunks.push(
`(function(){ try { ${$content(s).html() || ""} } catch (_err) { console.error('[HyperFrames] composition script error:', _err); } })();`,
);
$content(s).remove();
});
@@ -363,7 +426,8 @@ export async function bundleToSingleHtml(projectDir: string, options?: BundleOpt
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 (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();
@@ -376,7 +440,8 @@ export async function bundleToSingleHtml(projectDir: string, options?: BundleOpt
});
if (compStyleChunks.length) $("head").append(`<style>${compStyleChunks.join("\n\n")}</style>`);
if (compScriptChunks.length) $("body").append(`<script>${compScriptChunks.join("\n;\n")}</script>`);
if (compScriptChunks.length)
$("body").append(`<script>${compScriptChunks.join("\n;\n")}</script>`);
enforceCompositionPixelSizing($);
autoHealMissingCompositionIds($);
@@ -395,8 +460,12 @@ export async function bundleToSingleHtml(projectDir: string, options?: BundleOpt
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)); });
$("style").each((_, el) => {
$(el).text(rewriteCssUrlsWithInlinedAssets($(el).html() || "", projectDir));
});
$("[style]").each((_, el) => {
$(el).attr("style", rewriteCssUrlsWithInlinedAssets($(el).attr("style") || "", projectDir));
});
return $.html();
}
+1 -3
View File
@@ -14,9 +14,7 @@ import {
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);
return src.startsWith("http://") || src.startsWith("https://") ? src : resolve(projectDir, src);
}
/**
@@ -1,5 +1,10 @@
import { describe, it, expect } from "vitest";
import { compileTimingAttrs, injectDurations, extractResolvedMedia, clampDurations } from "./timingCompiler.js";
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", () => {
+5 -2
View File
@@ -50,7 +50,7 @@ export interface CompilationResult {
function getAttr(tag: string, attr: string): string | null {
const match = tag.match(new RegExp(`${attr}=["']([^"']+)["']`));
return match ? match[1] ?? null : null;
return match ? (match[1] ?? null) : null;
}
function hasAttr(tag: string, attr: string): boolean {
@@ -63,7 +63,10 @@ function injectAttr(tag: string, attr: string, value: string): string {
// ── Core compilation ─────────────────────────────────────────────────────
function compileTag(tag: string, isVideo: boolean): { tag: string; unresolved: UnresolvedElement | null } {
function compileTag(
tag: string,
isVideo: boolean,
): { tag: string; unresolved: UnresolvedElement | null } {
let result = tag;
let unresolved: UnresolvedElement | null = null;
+32 -7
View File
@@ -127,7 +127,12 @@ export interface EnumVariable extends CompositionVariableBase {
options: { value: string; label: string }[];
}
export type CompositionVariable = StringVariable | NumberVariable | ColorVariable | BooleanVariable | EnumVariable;
export type CompositionVariable =
| StringVariable
| NumberVariable
| ColorVariable
| BooleanVariable
| EnumVariable;
export interface CompositionSpec {
id: string;
@@ -155,7 +160,10 @@ export function isEnumVariable(v: CompositionVariable): v is EnumVariable {
return v.type === "enum";
}
export type TimelineElement = TimelineMediaElement | TimelineTextElement | TimelineCompositionElement;
export type TimelineElement =
| TimelineMediaElement
| TimelineTextElement
| TimelineCompositionElement;
export function isTextElement(el: TimelineElement): el is TimelineTextElement {
return el.type === "text";
@@ -225,7 +233,7 @@ export interface PlayerAPI {
id: string;
time: number;
properties: { x?: number; y?: number };
}> | null
}> | null,
): void;
setElementScale(elementId: string, scale: number): void;
setElementFontSize(elementId: string, fontSize: number): void;
@@ -235,7 +243,13 @@ export interface PlayerAPI {
setElementTextFontWeight(elementId: string, weight: number): void;
setElementTextFontFamily(elementId: string, fontFamily: string): void;
setElementTextOutline(elementId: string, enabled: boolean, color?: string, width?: number): void;
setElementTextHighlight(elementId: string, enabled: boolean, color?: string, padding?: number, radius?: number): void;
setElementTextHighlight(
elementId: string,
enabled: boolean,
color?: string,
padding?: number,
radius?: number,
): void;
setElementVolume(elementId: string, volume: number): void;
setStageZoom(scale: number, focusX: number, focusY: number): void;
getStageZoom(): { scale: number; focusX: number; focusY: number };
@@ -245,7 +259,7 @@ export interface PlayerAPI {
time: number;
zoom: { scale: number; focusX: number; focusY: number };
ease?: string;
}> | null
}> | null,
): void;
getStageZoomKeyframes(): Array<{
id: string;
@@ -256,7 +270,12 @@ export interface PlayerAPI {
addElement(data: AddElementData): boolean;
removeElement(elementId: string): boolean;
updateElementTiming(elementId: string, start?: number, end?: number): boolean;
setElementTiming(elementId: string, startTime: number, duration: number, mediaStartTime?: number): void;
setElementTiming(
elementId: string,
startTime: number,
duration: number,
mediaStartTime?: number,
): void;
updateElementSrc(elementId: string, src: string): boolean;
updateElementLayer(elementId: string, zIndex: number): boolean;
updateElementBasePosition(elementId: string, x?: number, y?: number, scale?: number): boolean;
@@ -269,7 +288,13 @@ export interface PlayerAPI {
renderSeek(time: number): void;
getElementVisibility(elementId: string): { visible: boolean; opacity?: number };
getVisibleElements(): Array<{ id: string; tagName: string; start: number; end: number }>;
getRenderState(): { time: number; duration: number; isPlaying: boolean; renderMode: boolean; timelineDirty: boolean };
getRenderState(): {
time: number;
duration: number;
isPlaying: boolean;
renderMode: boolean;
timelineDirty: boolean;
};
}
export interface AddElementData {
@@ -2,7 +2,11 @@
* @vitest-environment jsdom
*/
import { describe, it, expect } from "vitest";
import { generateHyperframesHtml, generateGsapTimelineScript, generateHyperframesStyles } from "./hyperframes.js";
import {
generateHyperframesHtml,
generateGsapTimelineScript,
generateHyperframesStyles,
} from "./hyperframes.js";
import { GSAP_CDN } from "../templates/constants.js";
import type { TimelineTextElement, TimelineMediaElement } from "../core.types";
@@ -274,7 +278,11 @@ describe("generateHyperframesStyles", () => {
it("includes custom CSS when provided", () => {
const elements = [makeTextElement()];
const { customCss } = generateHyperframesStyles(elements, "landscape", ".custom { color: blue; }");
const { customCss } = generateHyperframesStyles(
elements,
"landscape",
".custom { color: blue; }",
);
expect(customCss).toContain(".custom { color: blue; }");
});
+58 -25
View File
@@ -1,9 +1,4 @@
import type {
TimelineElement,
CanvasResolution,
Keyframe,
StageZoomKeyframe,
} from "../core.types";
import type { TimelineElement, CanvasResolution, Keyframe, StageZoomKeyframe } from "../core.types";
import {
CANVAS_DIMENSIONS,
isTextElement,
@@ -130,7 +125,8 @@ function generateElementStyles(element: TimelineElement): string {
const fontWeight = element.fontWeight ?? 700;
const fontFamily = element.fontFamily ?? "Inter";
const color = element.color ?? "white";
const textShadow = element.textShadow !== false ? "text-shadow: 2px 2px 4px rgba(0,0,0,0.8);" : "";
const textShadow =
element.textShadow !== false ? "text-shadow: 2px 2px 4px rgba(0,0,0,0.8);" : "";
// Text outline using -webkit-text-stroke
const textOutline = element.textOutline
@@ -191,12 +187,18 @@ export function generateGsapTimelineScript(
for (const element of sortedElements) {
const elementKeyframes = keyframes[element.id];
if (elementKeyframes && elementKeyframes.length > 0) {
const baseScale = isMediaElement(element) || isCompositionElement(element) ? (element.scale ?? 1) : 1;
const converted = keyframesToGsapAnimations(element.id, elementKeyframes, element.startTime, {
x: element.x ?? 0,
y: element.y ?? 0,
scale: baseScale,
});
const baseScale =
isMediaElement(element) || isCompositionElement(element) ? (element.scale ?? 1) : 1;
const converted = keyframesToGsapAnimations(
element.id,
elementKeyframes,
element.startTime,
{
x: element.x ?? 0,
y: element.y ?? 0,
scale: baseScale,
},
);
keyframeAnimations = keyframeAnimations.concat(converted);
}
}
@@ -211,7 +213,10 @@ export function generateGsapTimelineScript(
// Generate visibility animations for elements without keyframes
// When using keyframes path, elements without keyframes need explicit visibility
const visibilityAnimations = generateVisibilityForElementsWithoutKeyframes(sortedElements, keyframes);
const visibilityAnimations = generateVisibilityForElementsWithoutKeyframes(
sortedElements,
keyframes,
);
let gsapScript: string;
if (animations && animations.length > 0) {
@@ -221,7 +226,9 @@ export function generateGsapTimelineScript(
includeMediaSync: hasMedia,
});
// Prepend initial positions and visibility for elements without keyframes, append zoom animations
const prependAnimations = [initialPositionSets, visibilityAnimations].filter(Boolean).join("\n");
const prependAnimations = [initialPositionSets, visibilityAnimations]
.filter(Boolean)
.join("\n");
if (prependAnimations) {
gsapScript = gsapScript.replace(
"const tl = gsap.timeline({ paused: true });",
@@ -237,7 +244,9 @@ export function generateGsapTimelineScript(
includeMediaSync: hasMedia,
});
// Prepend initial positions and visibility for elements without keyframes, append zoom animations
const prependAnimations = [initialPositionSets, visibilityAnimations].filter(Boolean).join("\n");
const prependAnimations = [initialPositionSets, visibilityAnimations]
.filter(Boolean)
.join("\n");
if (prependAnimations) {
gsapScript = gsapScript.replace(
"const tl = gsap.timeline({ paused: true });",
@@ -248,7 +257,13 @@ export function generateGsapTimelineScript(
gsapScript += "\n" + zoomAnimations;
}
} else if (generateDefaultAnimations) {
gsapScript = generateDefaultGsapAnimations(sortedElements, totalDuration, stageZoomKeyframes, width, height);
gsapScript = generateDefaultGsapAnimations(
sortedElements,
totalDuration,
stageZoomKeyframes,
width,
height,
);
} else {
gsapScript = `
const tl = gsap.timeline({ paused: true });
@@ -282,7 +297,9 @@ export function generateHyperframesHtml(
// Include zoom keyframes in duration calculation
const maxZoomTime =
stageZoomKeyframes && stageZoomKeyframes.length > 0 ? Math.max(...stageZoomKeyframes.map((kf) => kf.time)) : 0;
stageZoomKeyframes && stageZoomKeyframes.length > 0
? Math.max(...stageZoomKeyframes.map((kf) => kf.time))
: 0;
const calculatedDuration =
elements.length > 0
@@ -291,7 +308,9 @@ export function generateHyperframesHtml(
const sortedElements = sortElements(elements);
const elementsHtml = sortedElements.map((el) => generateElementHtml(el, keyframes?.[el.id])).join("\n ");
const elementsHtml = sortedElements
.map((el) => generateElementHtml(el, keyframes?.[el.id]))
.join("\n ");
const customStyles = styles || "";
@@ -301,7 +320,11 @@ export function generateHyperframesHtml(
? ` data-zoom-keyframes='${JSON.stringify(stageZoomKeyframes).replace(/'/g, "&#39;")}'`
: "";
const { coreCss, customCss, googleFontsLink } = generateHyperframesStyles(sortedElements, resolution, customStyles);
const { coreCss, customCss, googleFontsLink } = generateHyperframesStyles(
sortedElements,
resolution,
customStyles,
);
const gsapScript = includeScripts
? generateGsapTimelineScript(sortedElements, totalDuration, {
@@ -575,7 +598,10 @@ function generateElementHtml(element: TimelineElement, keyframes?: Keyframe[]):
* _initializeElementCentering(), so we only set x, y, scale here.
* This keeps generated timeline code clean (no repeated xPercent/yPercent).
*/
function generateInitialPositionSets(elements: TimelineElement[], keyframes?: Record<string, Keyframe[]>): string {
function generateInitialPositionSets(
elements: TimelineElement[],
keyframes?: Record<string, Keyframe[]>,
): string {
const sets: string[] = [];
const timeEpsilon = 0.001;
@@ -584,7 +610,9 @@ function generateInitialPositionSets(elements: TimelineElement[], keyframes?: Re
const hasBaseKeyframe = elementKeyframes?.some(
(kf) =>
Math.abs(kf.time) <= timeEpsilon &&
(kf.properties.x !== undefined || kf.properties.y !== undefined || kf.properties.scale !== undefined),
(kf.properties.x !== undefined ||
kf.properties.y !== undefined ||
kf.properties.scale !== undefined),
);
const xVal = el.x ?? 0;
@@ -629,7 +657,8 @@ function generateVisibilityForElementsWithoutKeyframes(
for (const el of elements) {
const elementKeyframes = keyframes?.[el.id];
const opacityKeyframes = elementKeyframes?.filter((kf) => kf.properties.opacity !== undefined) || [];
const opacityKeyframes =
elementKeyframes?.filter((kf) => kf.properties.opacity !== undefined) || [];
const start = el.startTime;
const end = el.startTime + el.duration;
@@ -647,7 +676,9 @@ function generateVisibilityForElementsWithoutKeyframes(
// Only include opacity in visibility bookend if non-default or has opacity keyframes
const needsOpacity = elementOpacity !== 1 || opacityKeyframes.length > 0;
if (needsOpacity) {
animations.push(` tl.set("#${el.id}", { visibility: "visible", opacity: ${elementOpacity} }, ${start});`);
animations.push(
` tl.set("#${el.id}", { visibility: "visible", opacity: ${elementOpacity} }, ${start});`,
);
} else {
animations.push(` tl.set("#${el.id}", { visibility: "visible" }, ${start});`);
}
@@ -690,7 +721,9 @@ function generateDefaultGsapAnimations(
animations.push(` tl.set("#${el.id}", { visibility: "hidden" }, 0);`);
// Only include opacity if non-default
if (elementOpacity !== 1) {
animations.push(` tl.set("#${el.id}", { visibility: "visible", opacity: ${elementOpacity} }, ${start});`);
animations.push(
` tl.set("#${el.id}", { visibility: "visible", opacity: ${elementOpacity} }, ${start});`,
);
} else {
animations.push(` tl.set("#${el.id}", { visibility: "visible" }, ${start});`);
}
+12 -2
View File
@@ -98,9 +98,19 @@ export {
} from "./generators/hyperframes";
// Compiler (timing only — browser-safe, no cheerio/esbuild)
export type { UnresolvedElement, ResolvedDuration, ResolvedMediaElement, CompilationResult } from "./compiler/timingCompiler";
export type {
UnresolvedElement,
ResolvedDuration,
ResolvedMediaElement,
CompilationResult,
} from "./compiler/timingCompiler";
export { compileTimingAttrs, injectDurations, extractResolvedMedia, clampDurations } from "./compiler/timingCompiler";
export {
compileTimingAttrs,
injectDurations,
extractResolvedMedia,
clampDurations,
} from "./compiler/timingCompiler";
// Lint
export type {
@@ -11,10 +11,15 @@ export type HyperframesRuntimeBuildOptions = {
function applyDefaultParityMode(script: string, enabled: boolean): string {
const parityFlagPattern = /var\s+_parityModeEnabled\s*=\s*(?:true|false)\s*;/;
if (!parityFlagPattern.test(script)) return script;
return script.replace(parityFlagPattern, `var _parityModeEnabled = ${enabled ? "true" : "false"};`);
return script.replace(
parityFlagPattern,
`var _parityModeEnabled = ${enabled ? "true" : "false"};`,
);
}
export function buildHyperframesRuntimeScript(options: HyperframesRuntimeBuildOptions = {}): string {
export function buildHyperframesRuntimeScript(
options: HyperframesRuntimeBuildOptions = {},
): string {
const entryPath = resolve(dirname(fileURLToPath(import.meta.url)), "../runtime/entry.ts");
const result = buildSync({
entryPoints: [entryPath],
+15 -3
View File
@@ -22,9 +22,21 @@ export type HyperframePickerApi = {
isActive: () => boolean;
getHovered: () => HyperframePickerElementInfo | null;
getSelected: () => HyperframePickerElementInfo | null;
getCandidatesAtPoint: (clientX: number, clientY: number, limit?: number) => HyperframePickerElementInfo[];
pickAtPoint: (clientX: number, clientY: number, index?: number) => HyperframePickerElementInfo | null;
pickManyAtPoint: (clientX: number, clientY: number, indexes?: number[]) => HyperframePickerElementInfo[];
getCandidatesAtPoint: (
clientX: number,
clientY: number,
limit?: number,
) => HyperframePickerElementInfo[];
pickAtPoint: (
clientX: number,
clientY: number,
index?: number,
) => HyperframePickerElementInfo | null;
pickManyAtPoint: (
clientX: number,
clientY: number,
indexes?: number[],
) => HyperframePickerElementInfo[];
};
declare global {
+31 -10
View File
@@ -32,11 +32,15 @@ const TIMELINE_REGISTRY_INIT_PATTERN =
/window\.__timelines\s*=\s*window\.__timelines\s*\|\|\s*\{\}|window\.__timelines\s*=\s*\{\}|window\.__timelines\s*\?\?=\s*\{\}/i;
const TIMELINE_REGISTRY_ASSIGN_PATTERN = /window\.__timelines\[[^\]]+\]\s*=/i;
const INVALID_SCRIPT_CLOSE_PATTERN = /<script[^>]*>[\s\S]*?<\s*\/\s*script(?!>)/i;
const WINDOW_TIMELINE_ASSIGN_PATTERN = /window\.__timelines\[\s*["']([^"']+)["']\s*\]\s*=\s*([A-Za-z_$][\w$]*)/i;
const WINDOW_TIMELINE_ASSIGN_PATTERN =
/window\.__timelines\[\s*["']([^"']+)["']\s*\]\s*=\s*([A-Za-z_$][\w$]*)/i;
const META_GSAP_KEYS = new Set(["duration", "ease", "repeat", "yoyo", "overwrite", "delay"]);
export function lintHyperframeHtml(html: string, options: HyperframeLinterOptions = {}): HyperframeLintResult {
export function lintHyperframeHtml(
html: string,
options: HyperframeLinterOptions = {},
): HyperframeLintResult {
const source = html || "";
const filePath = options.filePath;
const findings: HyperframeLintFinding[] = [];
@@ -86,7 +90,10 @@ export function lintHyperframeHtml(html: string, options: HyperframeLinterOption
});
}
if (!TIMELINE_REGISTRY_INIT_PATTERN.test(source) && !TIMELINE_REGISTRY_ASSIGN_PATTERN.test(source)) {
if (
!TIMELINE_REGISTRY_INIT_PATTERN.test(source) &&
!TIMELINE_REGISTRY_ASSIGN_PATTERN.test(source)
) {
pushFinding({
code: "missing_timeline_registry",
severity: "error",
@@ -157,7 +164,8 @@ export function lintHyperframeHtml(html: string, options: HyperframeLinterOption
severity: "warning",
message: `Scoped CSS targets composition "${compId}" but no matching wrapper exists in this HTML.`,
selector: `[data-composition-id="${compId}"]`,
fixHint: "Preserve the matching composition wrapper or align the CSS scope to an existing wrapper.",
fixHint:
"Preserve the matching composition wrapper or align the CSS scope to an existing wrapper.",
});
}
@@ -191,7 +199,8 @@ export function lintHyperframeHtml(html: string, options: HyperframeLinterOption
severity: "error",
message: `Media id "${elementId}" is defined multiple times.`,
elementId,
fixHint: "Give each media element a unique id so preview and producer discover the same media graph.",
fixHint:
"Give each media element a unique id so preview and producer discover the same media graph.",
snippet: truncateSnippet(mediaTags[0]?.raw || ""),
});
}
@@ -206,7 +215,9 @@ export function lintHyperframeHtml(html: string, options: HyperframeLinterOption
severity: "warning",
message: `Detected ${count} matching ${tagName} entries with the same source/start/duration.`,
fixHint: "Avoid duplicated media nodes that can be discovered twice during compilation.",
snippet: truncateSnippet(`${tagName} src=${src} data-start=${dataStart} data-duration=${dataDuration}`),
snippet: truncateSnippet(
`${tagName} src=${src} data-start=${dataStart} data-duration=${dataDuration}`,
),
});
}
@@ -302,7 +313,11 @@ export function lintHyperframeHtml(html: string, options: HyperframeLinterOption
for (const tag of tags) {
if (tag.name === "video" || tag.name === "audio") continue;
if (readAttr(tag.raw, "data-start")) {
timedTagPositions.push({ name: tag.name, start: tag.index, id: readAttr(tag.raw, "id") || undefined });
timedTagPositions.push({
name: tag.name,
start: tag.index,
id: readAttr(tag.raw, "id") || undefined,
});
}
}
for (const tag of tags) {
@@ -424,7 +439,7 @@ function extractBlocks(source: string, pattern: RegExp): ExtractedBlock[] {
function findRootTag(source: string): OpenTag | null {
const bodyMatch = source.match(/<body\b[^>]*>([\s\S]*?)<\/body>/i);
const bodyContent = bodyMatch ? bodyMatch[1] ?? source : source;
const bodyContent = bodyMatch ? (bodyMatch[1] ?? source) : source;
const bodyTags = extractOpenTags(bodyContent);
for (const tag of bodyTags) {
if (["script", "style", "meta", "link", "title"].includes(tag.name)) {
@@ -517,7 +532,10 @@ function extractGsapWindows(script: string): GsapWindow[] {
const windows: GsapWindow[] = [];
const timelineVar = parsed.timelineVar;
const methodPattern = new RegExp(`${timelineVar}\\.(set|to|from|fromTo)\\s*\\(([^)]+(?:\\{[^}]*\\}[^)]*)+)\\)`, "g");
const methodPattern = new RegExp(
`${timelineVar}\\.(set|to|from|fromTo)\\s*\\(([^)]+(?:\\{[^}]*\\}[^)]*)+)\\)`,
"g",
);
let match: RegExpExecArray | null;
let index = 0;
@@ -620,7 +638,10 @@ function parseLooseObjectLiteral(source: string): Record<string, string | number
if (!key || rawValue == null) {
continue;
}
if ((rawValue.startsWith('"') && rawValue.endsWith('"')) || (rawValue.startsWith("'") && rawValue.endsWith("'"))) {
if (
(rawValue.startsWith('"') && rawValue.endsWith('"')) ||
(rawValue.startsWith("'") && rawValue.endsWith("'"))
) {
result[key] = rawValue.slice(1, -1);
continue;
}
+24 -7
View File
@@ -218,7 +218,10 @@ describe("gsapAnimationsToKeyframes", () => {
targetSelector: "#el1",
method: "to",
position: 0,
properties: { opacity: 1, x: 50, someUnsupportedProp: "value" } as Record<string, number | string>,
properties: { opacity: 1, x: 50, someUnsupportedProp: "value" } as Record<
string,
number | string
>,
duration: 1,
},
];
@@ -229,7 +232,9 @@ describe("gsapAnimationsToKeyframes", () => {
expect(keyframes[0].properties.opacity).toBe(1);
expect(keyframes[0].properties.x).toBe(50);
// String values are skipped (typeof value !== "number" check)
expect((keyframes[0].properties as Record<string, unknown>).someUnsupportedProp).toBeUndefined();
expect(
(keyframes[0].properties as Record<string, unknown>).someUnsupportedProp,
).toBeUndefined();
});
it("skips base set keyframes at time 0 when skipBaseSet is true", () => {
@@ -337,9 +342,7 @@ describe("keyframesToGsapAnimations", () => {
});
it("applies base x/y/scale offsets", () => {
const keyframes: Keyframe[] = [
{ id: "kf-1", time: 0, properties: { x: 10, y: 20, scale: 2 } },
];
const keyframes: Keyframe[] = [{ id: "kf-1", time: 0, properties: { x: 10, y: 20, scale: 2 } }];
const animations = keyframesToGsapAnimations("el1", keyframes, 0, {
x: 50,
@@ -491,8 +494,22 @@ describe("getAnimationsForElement", () => {
it("filters animations by element id", () => {
const animations: GsapAnimation[] = [
{ id: "a1", targetSelector: "#el1", method: "set", position: 0, properties: { opacity: 0 } },
{ id: "a2", targetSelector: "#el2", method: "to", position: 0, properties: { opacity: 1 }, duration: 1 },
{ id: "a3", targetSelector: "#el1", method: "to", position: 1, properties: { opacity: 1 }, duration: 0.5 },
{
id: "a2",
targetSelector: "#el2",
method: "to",
position: 0,
properties: { opacity: 1 },
duration: 1,
},
{
id: "a3",
targetSelector: "#el1",
method: "to",
position: 1,
properties: { opacity: 1 },
duration: 0.5,
},
];
const result = getAnimationsForElement(animations, "el1");
+26 -8
View File
@@ -78,7 +78,10 @@ function parseObjectLiteral(str: string): Record<string, number | string> {
let value: string | number = match[2] ?? "";
if (typeof value === "string") {
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
} else if (!isNaN(Number(value))) {
value = Number(value);
@@ -108,14 +111,21 @@ export function parseGsapScript(script: string): ParsedGsap {
let idCounter = 0;
const timelineMatch = script.match(/(?:const|let|var)\s+(\w+)\s*=\s*gsap\.timeline/);
const timelineVar = timelineMatch ? timelineMatch[1] ?? "tl" : "tl";
const timelineVar = timelineMatch ? (timelineMatch[1] ?? "tl") : "tl";
const preambleMatch = script.match(
new RegExp(`^[\\s\\S]*?(?:const|let|var)\\s+${timelineVar}\\s*=\\s*gsap\\.timeline\\s*\\([^)]*\\)\\s*;?`),
new RegExp(
`^[\\s\\S]*?(?:const|let|var)\\s+${timelineVar}\\s*=\\s*gsap\\.timeline\\s*\\([^)]*\\)\\s*;?`,
),
);
const preamble = preambleMatch ? preambleMatch[0] : `const ${timelineVar} = gsap.timeline({ paused: true });`;
const preamble = preambleMatch
? preambleMatch[0]
: `const ${timelineVar} = gsap.timeline({ paused: true });`;
const methodPattern = new RegExp(`${timelineVar}\\.(set|to|from|fromTo)\\s*\\(([^)]+(?:\\{[^}]*\\}[^)]*)+)\\)`, "g");
const methodPattern = new RegExp(
`${timelineVar}\\.(set|to|from|fromTo)\\s*\\(([^)]+(?:\\{[^}]*\\}[^)]*)+)\\)`,
"g",
);
let match;
while ((match = methodPattern.exec(script)) !== null) {
@@ -286,7 +296,11 @@ function serializeObject(obj: Record<string, number | string>): string {
return `{ ${entries.join(", ")} }`;
}
export function updateAnimationInScript(script: string, animationId: string, updates: Partial<GsapAnimation>): string {
export function updateAnimationInScript(
script: string,
animationId: string,
updates: Partial<GsapAnimation>,
): string {
const parsed = parseGsapScript(script);
const updated = parsed.animations.map((anim) => {
@@ -322,7 +336,10 @@ export function removeAnimationFromScript(script: string, animationId: string):
return serializeGsapAnimations(filtered, parsed.timelineVar);
}
export function getAnimationsForElement(animations: GsapAnimation[], elementId: string): GsapAnimation[] {
export function getAnimationsForElement(
animations: GsapAnimation[],
elementId: string,
): GsapAnimation[] {
const selector = `#${elementId}`;
return animations.filter((a) => a.targetSelector === selector);
}
@@ -478,7 +495,8 @@ export function gsapAnimationsToKeyframes(
} else if (key === "y") {
(properties as Record<string, number>).y = value - baseY;
} else if (key === "scale") {
(properties as Record<string, number>).scale = baseScale !== 0 ? value / baseScale : value;
(properties as Record<string, number>).scale =
baseScale !== 0 ? value / baseScale : value;
} else {
(properties as Record<string, number>)[key] = value;
}
+11 -2
View File
@@ -2,7 +2,14 @@
* @vitest-environment jsdom
*/
import { describe, it, expect } from "vitest";
import { parseHtml, updateElementInHtml, addElementToHtml, removeElementFromHtml, validateCompositionHtml, extractCompositionMetadata } from "./htmlParser.js";
import {
parseHtml,
updateElementInHtml,
addElementToHtml,
removeElementFromHtml,
validateCompositionHtml,
extractCompositionMetadata,
} from "./htmlParser.js";
describe("parseHtml", () => {
it("extracts elements with data-start and data-end", () => {
@@ -457,7 +464,9 @@ describe("validateCompositionHtml", () => {
const result = validateCompositionHtml(html);
expect(result.valid).toBe(false);
expect(result.errors).toContain("Missing data-composition-duration attribute on <html> element");
expect(result.errors).toContain(
"Missing data-composition-duration attribute on <html> element",
);
});
it("reports error for missing #stage", () => {
+52 -13
View File
@@ -40,7 +40,14 @@ function getElementType(el: Element): TimelineElementType | null {
if (dataType === "composition") return "composition";
if (dataType === "text") return "text";
// Fall back to tag-based detection for backwards compatibility
if (tag === "div" || tag === "p" || tag === "h1" || tag === "h2" || tag === "h3" || tag === "span") {
if (
tag === "div" ||
tag === "p" ||
tag === "h1" ||
tag === "h2" ||
tag === "h3" ||
tag === "span"
) {
return "text";
}
return null;
@@ -89,13 +96,17 @@ function parseResolutionFromCss(doc: Document, cssText: string | null): CanvasRe
}
if (cssText) {
const stageMatch = cssText.match(/#stage\s*\{[^}]*width:\s*(\d+)px[^}]*height:\s*(\d+)px[^}]*\}/);
const stageMatch = cssText.match(
/#stage\s*\{[^}]*width:\s*(\d+)px[^}]*height:\s*(\d+)px[^}]*\}/,
);
if (stageMatch) {
const w = parseInt(stageMatch[1] ?? "", 10);
const h = parseInt(stageMatch[2] ?? "", 10);
return w > h ? "landscape" : "portrait";
}
const stageMatchReverse = cssText.match(/#stage\s*\{[^}]*height:\s*(\d+)px[^}]*width:\s*(\d+)px[^}]*\}/);
const stageMatchReverse = cssText.match(
/#stage\s*\{[^}]*height:\s*(\d+)px[^}]*width:\s*(\d+)px[^}]*\}/,
);
if (stageMatchReverse) {
const h = parseInt(stageMatchReverse[1] ?? "", 10);
const w = parseInt(stageMatchReverse[2] ?? "", 10);
@@ -205,16 +216,22 @@ export function parseHtml(html: string): ParsedHtml {
const textOutline = textOutlineAttr === "true" ? true : undefined;
const textOutlineColor = el.getAttribute("data-text-outline-color") || undefined;
const textOutlineWidthAttr = el.getAttribute("data-text-outline-width");
const textOutlineWidth = textOutlineWidthAttr ? parseInt(textOutlineWidthAttr, 10) : undefined;
const textOutlineWidth = textOutlineWidthAttr
? parseInt(textOutlineWidthAttr, 10)
: undefined;
// Parse highlight properties
const textHighlightAttr = el.getAttribute("data-text-highlight");
const textHighlight = textHighlightAttr === "true" ? true : undefined;
const textHighlightColor = el.getAttribute("data-text-highlight-color") || undefined;
const textHighlightPaddingAttr = el.getAttribute("data-text-highlight-padding");
const textHighlightPadding = textHighlightPaddingAttr ? parseInt(textHighlightPaddingAttr, 10) : undefined;
const textHighlightPadding = textHighlightPaddingAttr
? parseInt(textHighlightPaddingAttr, 10)
: undefined;
const textHighlightRadiusAttr = el.getAttribute("data-text-highlight-radius");
const textHighlightRadius = textHighlightRadiusAttr ? parseInt(textHighlightRadiusAttr, 10) : undefined;
const textHighlightRadius = textHighlightRadiusAttr
? parseInt(textHighlightRadiusAttr, 10)
: undefined;
const textElement: TimelineTextElement = {
id,
@@ -375,7 +392,9 @@ export function parseHtml(html: string): ParsedHtml {
.filter(Boolean)
.join("\n\n") || null;
const customStyleTags = Array.from(styleTags).filter((s) => s.getAttribute("data-hf-custom") === "true");
const customStyleTags = Array.from(styleTags).filter(
(s) => s.getAttribute("data-hf-custom") === "true",
);
const customStylesFromTags =
customStyleTags
.map((s) => s.textContent?.trim())
@@ -463,7 +482,9 @@ function parseStageZoomKeyframes(doc: Document): StageZoomKeyframe[] {
* Extract x/y positions and scale from GSAP set() calls at position 0
* Returns a map of elementId -> { x, y, scale }
*/
function extractPositionsFromGsap(script: string): Map<string, { x?: number; y?: number; scale?: number }> {
function extractPositionsFromGsap(
script: string,
): Map<string, { x?: number; y?: number; scale?: number }> {
const positionMap = new Map<string, { x?: number; y?: number; scale?: number }>();
try {
@@ -482,7 +503,11 @@ function extractPositionsFromGsap(script: string): Map<string, { x?: number; y?:
const scale = typeof anim.properties.scale === "number" ? anim.properties.scale : undefined;
// Only add to map if x, y, or scale is defined and non-default
if ((x !== undefined && x !== 0) || (y !== undefined && y !== 0) || (scale !== undefined && scale !== 1)) {
if (
(x !== undefined && x !== 0) ||
(y !== undefined && y !== 0) ||
(scale !== undefined && scale !== 1)
) {
const existing = positionMap.get(elementId) || {};
positionMap.set(elementId, {
x: x !== undefined ? x : existing.x,
@@ -499,7 +524,12 @@ function extractPositionsFromGsap(script: string): Map<string, { x?: number; y?:
return positionMap;
}
function normalizeKeyframes(keyframes: Keyframe[], baseX: number, baseY: number, baseScale: number): Keyframe[] {
function normalizeKeyframes(
keyframes: Keyframe[],
baseX: number,
baseY: number,
baseScale: number,
): Keyframe[] {
const timeEpsilon = 0.001;
const valueEpsilon = 0.00001;
@@ -543,7 +573,11 @@ function normalizeKeyframes(keyframes: Keyframe[], baseX: number, baseY: number,
});
}
export function updateElementInHtml(html: string, elementId: string, updates: Partial<TimelineElement>): string {
export function updateElementInHtml(
html: string,
elementId: string,
updates: Partial<TimelineElement>,
): string {
const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html");
@@ -732,7 +766,8 @@ export function extractCompositionMetadata(html: string): CompositionMetadata {
return {
compositionId,
compositionDuration: compositionDuration && isFinite(compositionDuration) ? compositionDuration : null,
compositionDuration:
compositionDuration && isFinite(compositionDuration) ? compositionDuration : null,
variables,
};
}
@@ -833,7 +868,11 @@ function extractGsapScript(doc: Document): string | null {
const scripts = doc.querySelectorAll("script");
for (const script of scripts) {
const content = script.textContent || "";
if (content.includes("gsap.timeline") || content.includes(".set(") || content.includes(".to(")) {
if (
content.includes("gsap.timeline") ||
content.includes(".set(") ||
content.includes(".to(")
) {
return content;
}
}
@@ -21,7 +21,11 @@ function createLottieWebAnim(opts?: { totalFrames?: number; frameRate?: number }
};
}
function createDotLottiePlayer(opts?: { totalFrames?: number; frameRate?: number; duration?: number }) {
function createDotLottiePlayer(opts?: {
totalFrames?: number;
frameRate?: number;
duration?: number;
}) {
return {
play: vi.fn(),
pause: vi.fn(),
+5 -1
View File
@@ -156,7 +156,11 @@ export function createLottieAdapter(): RuntimeDeterministicAdapter {
// ── Type guards ────────────────────────────────────────────────────────────────
function isLottieWebAnimation(anim: unknown): anim is LottieWebAnimation {
return typeof anim === "object" && anim !== null && typeof (anim as LottieWebAnimation).goToAndStop === "function";
return (
typeof anim === "object" &&
anim !== null &&
typeof (anim as LottieWebAnimation).goToAndStop === "function"
);
}
function isDotLottiePlayer(anim: unknown): anim is DotLottiePlayer {
@@ -54,7 +54,9 @@ describe("waapi adapter", () => {
it("handles animation that throws on pause", () => {
const mockAnim = {
pause: vi.fn(() => { throw new Error("invalid state"); }),
pause: vi.fn(() => {
throw new Error("invalid state");
}),
currentTime: 0,
};
(document as any).getAnimations = vi.fn(() => [mockAnim]);
+11 -7
View File
@@ -86,18 +86,22 @@ describe("installRuntimeControlBridge", () => {
it("ignores messages from wrong source", () => {
const deps = createMockDeps();
const handler = installRuntimeControlBridge(deps);
handler(new MessageEvent("message", {
data: { source: "other", type: "control", action: "play" },
}));
handler(
new MessageEvent("message", {
data: { source: "other", type: "control", action: "play" },
}),
);
expect(deps.onPlay).not.toHaveBeenCalled();
});
it("ignores messages with wrong type", () => {
const deps = createMockDeps();
const handler = installRuntimeControlBridge(deps);
handler(new MessageEvent("message", {
data: { source: "hf-parent", type: "state", action: "play" },
}));
handler(
new MessageEvent("message", {
data: { source: "hf-parent", type: "state", action: "play" },
}),
);
expect(deps.onPlay).not.toHaveBeenCalled();
});
@@ -112,7 +116,7 @@ describe("installRuntimeControlBridge", () => {
const deps = createMockDeps();
const handler = installRuntimeControlBridge(deps);
expect(() =>
handler(makeControlMessage("flash-elements", { selectors: [".test"], duration: 500 }))
handler(makeControlMessage("flash-elements", { selectors: [".test"], duration: 500 })),
).not.toThrow();
});
});
@@ -44,9 +44,7 @@ describe("loadExternalCompositions", () => {
</body></html>
`;
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(compositionHtml, { status: 200 })
);
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(compositionHtml, { status: 200 }));
await loadExternalCompositions({ ...defaultParams });
@@ -68,9 +66,7 @@ describe("loadExternalCompositions", () => {
</body></html>
`;
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(compositionHtml, { status: 200 })
);
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(compositionHtml, { status: 200 }));
const injectedStyles: HTMLStyleElement[] = [];
await loadExternalCompositions({
@@ -102,7 +98,7 @@ describe("loadExternalCompositions", () => {
hostCompositionSrc: "https://example.com/broken.html",
errorMessage: "Network error",
}),
})
}),
);
});
@@ -111,9 +107,7 @@ describe("loadExternalCompositions", () => {
host.setAttribute("data-composition-src", "https://example.com/404.html");
document.body.appendChild(host);
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response("Not Found", { status: 404 })
);
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("Not Found", { status: 404 }));
const onDiagnostic = vi.fn();
await loadExternalCompositions({
@@ -124,7 +118,7 @@ describe("loadExternalCompositions", () => {
expect(onDiagnostic).toHaveBeenCalledWith(
expect.objectContaining({
code: "external_composition_load_failed",
})
}),
);
});
@@ -165,9 +159,7 @@ describe("loadExternalCompositions", () => {
document.body.appendChild(host);
const compositionHtml = `<html><body><p>New</p></body></html>`;
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(compositionHtml, { status: 200 })
);
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(compositionHtml, { status: 200 }));
await loadExternalCompositions({ ...defaultParams });
expect(host.querySelector("span")).toBeNull();
@@ -186,9 +178,7 @@ describe("loadExternalCompositions", () => {
</body></html>
`;
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(compositionHtml, { status: 200 })
);
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(compositionHtml, { status: 200 }));
const injectedScripts: HTMLScriptElement[] = [];
await loadExternalCompositions({
+15 -6
View File
@@ -88,10 +88,13 @@ async function mountCompositionContent(params: {
}): Promise<void> {
let innerRoot: Element | null = null;
if (params.hostCompositionId) {
const candidateRoots = Array.from(params.sourceNode.querySelectorAll<Element>("[data-composition-id]"));
const candidateRoots = Array.from(
params.sourceNode.querySelectorAll<Element>("[data-composition-id]"),
);
innerRoot =
candidateRoots.find((candidate) => candidate.getAttribute("data-composition-id") === params.hostCompositionId) ??
null;
candidateRoots.find(
(candidate) => candidate.getAttribute("data-composition-id") === params.hostCompositionId,
) ?? null;
}
const contentNode = innerRoot ?? params.sourceNode;
@@ -188,7 +191,9 @@ async function mountCompositionContent(params: {
}
}
export async function loadExternalCompositions(params: LoadExternalCompositionsParams): Promise<void> {
export async function loadExternalCompositions(
params: LoadExternalCompositionsParams,
): Promise<void> {
const hosts = Array.from(document.querySelectorAll("[data-composition-src]"));
if (hosts.length === 0) return;
@@ -207,7 +212,9 @@ export async function loadExternalCompositions(params: LoadExternalCompositionsP
const hostCompositionId = host.getAttribute("data-composition-id");
const localTemplate =
hostCompositionId != null
? document.querySelector<HTMLTemplateElement>(`template#${CSS.escape(hostCompositionId)}-template`)
? document.querySelector<HTMLTemplateElement>(
`template#${CSS.escape(hostCompositionId)}-template`,
)
: null;
if (localTemplate) {
await mountCompositionContent({
@@ -234,7 +241,9 @@ export async function loadExternalCompositions(params: LoadExternalCompositionsP
const doc = parser.parseFromString(html, "text/html");
const template =
(hostCompositionId
? doc.querySelector<HTMLTemplateElement>(`template#${CSS.escape(hostCompositionId)}-template`)
? doc.querySelector<HTMLTemplateElement>(
`template#${CSS.escape(hostCompositionId)}-template`,
)
: null) ?? doc.querySelector<HTMLTemplateElement>("template");
const sourceNode = template ? template.content : doc.body;
await mountCompositionContent({
+142 -39
View File
@@ -36,7 +36,11 @@ export function initSandboxRuntimeModular(): void {
const registerRuntimeCleanup = (callback: () => void) => {
runtimeCleanupCallbacks.push(callback);
};
const postRuntimeDiagnosticOnce = (code: string, details: Record<string, RuntimeJson>, dedupeKey?: string) => {
const postRuntimeDiagnosticOnce = (
code: string,
details: Record<string, RuntimeJson>,
dedupeKey?: string,
) => {
const key = dedupeKey ?? `${code}:${JSON.stringify(details)}`;
if (postedDiagnosticKeys.has(key)) {
return;
@@ -157,7 +161,10 @@ export function initSandboxRuntimeModular(): void {
category: string;
} => {
const message = rawMessage.toLowerCase();
if (message.includes("cannot read properties of null") || message.includes("cannot set properties of null")) {
if (
message.includes("cannot read properties of null") ||
message.includes("cannot set properties of null")
) {
return { code: "runtime_null_dom_access", category: "dom-null-access" };
}
if (message.includes("failed to execute 'queryselector'")) {
@@ -185,10 +192,13 @@ export function initSandboxRuntimeModular(): void {
if (explicitRoot instanceof HTMLElement) {
return explicitRoot;
}
const compositionNodes = Array.from(document.querySelectorAll("[data-composition-id]")) as HTMLElement[];
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.find((node) => !node.parentElement?.closest("[data-composition-id]")) ??
compositionNodes[0]
);
};
@@ -278,12 +288,18 @@ export function initSandboxRuntimeModular(): void {
el.style.position = "absolute";
}
const hasExplicitVerticalAnchor =
Boolean(el.style.top) || Boolean(el.style.bottom) || computed.top !== "auto" || computed.bottom !== "auto";
Boolean(el.style.top) ||
Boolean(el.style.bottom) ||
computed.top !== "auto" ||
computed.bottom !== "auto";
if (!hasExplicitVerticalAnchor) {
el.style.top = "0";
}
const hasExplicitHorizontalAnchor =
Boolean(el.style.left) || Boolean(el.style.right) || computed.left !== "auto" || computed.right !== "auto";
Boolean(el.style.left) ||
Boolean(el.style.right) ||
computed.left !== "auto" ||
computed.right !== "auto";
if (!hasExplicitHorizontalAnchor) {
el.style.left = "0";
}
@@ -312,14 +328,20 @@ export function initSandboxRuntimeModular(): void {
const resolveStartForElement = (element: Element, fallback = 0): number => {
const resolver = createRuntimeStartTimeResolver({
timelineRegistry: (window.__timelines ?? {}) as Record<string, RuntimeTimelineLike | undefined>,
timelineRegistry: (window.__timelines ?? {}) as Record<
string,
RuntimeTimelineLike | undefined
>,
});
return resolver.resolveStartForElement(element, fallback);
};
const resolveDurationForElement = (element: Element): number | null => {
const resolver = createRuntimeStartTimeResolver({
timelineRegistry: (window.__timelines ?? {}) as Record<string, RuntimeTimelineLike | undefined>,
timelineRegistry: (window.__timelines ?? {}) as Record<
string,
RuntimeTimelineLike | undefined
>,
});
return resolver.resolveDurationForElement(element);
};
@@ -399,13 +421,20 @@ export function initSandboxRuntimeModular(): void {
if (!isUsableTimelineDuration(mediaDurationFloorSeconds)) {
return MIN_VALID_TIMELINE_DURATION_SECONDS;
}
return Math.max(MIN_VALID_TIMELINE_DURATION_SECONDS, mediaDurationFloorSeconds * TIMELINE_FLOOR_COVERAGE_RATIO);
return Math.max(
MIN_VALID_TIMELINE_DURATION_SECONDS,
mediaDurationFloorSeconds * TIMELINE_FLOOR_COVERAGE_RATIO,
);
};
const getSafeTimelineDurationSeconds = (timeline: RuntimeTimelineLike | null, fallback = 0): number => {
const getSafeTimelineDurationSeconds = (
timeline: RuntimeTimelineLike | null,
fallback = 0,
): number => {
const timelineDuration = getTimelineDurationSeconds(timeline);
const mediaFloor = resolveMediaDurationFloorSeconds();
const fallbackDuration = Number.isFinite(fallback) && fallback > MIN_VALID_TIMELINE_DURATION_SECONDS ? fallback : 0;
const fallbackDuration =
Number.isFinite(fallback) && fallback > MIN_VALID_TIMELINE_DURATION_SECONDS ? fallback : 0;
let safeDuration = 0;
// Timeline is the source of truth for authored composition duration.
if (isUsableTimelineDuration(timelineDuration)) {
@@ -423,20 +452,30 @@ export function initSandboxRuntimeModular(): void {
const timelines = (window.__timelines ?? {}) as Record<string, RuntimeTimelineLike | undefined>;
const startResolver = createRuntimeStartTimeResolver({ timelineRegistry: timelines });
const mediaDurationFloorSeconds = resolveMediaDurationFloorSeconds();
const minCandidateDurationSeconds = resolveMinCandidateDurationSeconds(mediaDurationFloorSeconds);
const minCandidateDurationSeconds =
resolveMinCandidateDurationSeconds(mediaDurationFloorSeconds);
const resolveCompositionStartSeconds = (compositionId: string): number => {
const node = document.querySelector(`[data-composition-id="${CSS.escape(compositionId)}"]`) as Element | null;
const node = document.querySelector(
`[data-composition-id="${CSS.escape(compositionId)}"]`,
) as Element | null;
if (!node) return 0;
return startResolver.resolveStartForElement(node, 0);
};
const createCompositeTimelineFromCandidates = (
candidates: Array<{ compositionId: string; timeline: RuntimeTimelineLike; durationSeconds: number }>,
candidates: Array<{
compositionId: string;
timeline: RuntimeTimelineLike;
durationSeconds: number;
}>,
): RuntimeTimelineLike | null => {
const gsapApi = window.gsap;
if (!gsapApi || typeof gsapApi.timeline !== "function") return null;
const compositeTimeline = gsapApi.timeline({ paused: true }) as RuntimeTimelineLike;
for (const candidate of candidates) {
compositeTimeline.add(candidate.timeline, resolveCompositionStartSeconds(candidate.compositionId));
compositeTimeline.add(
candidate.timeline,
resolveCompositionStartSeconds(candidate.compositionId),
);
}
return compositeTimeline;
};
@@ -469,7 +508,11 @@ export function initSandboxRuntimeModular(): void {
};
const addMissingChildCandidatesToRootTimeline = (
rootTimeline: RuntimeTimelineLike,
candidates: Array<{ compositionId: string; timeline: RuntimeTimelineLike; durationSeconds: number }>,
candidates: Array<{
compositionId: string;
timeline: RuntimeTimelineLike;
durationSeconds: number;
}>,
): string[] => {
const rootWithChildren = rootTimeline as RuntimeTimelineLike & {
getChildren?: (...args: unknown[]) => unknown[];
@@ -509,7 +552,11 @@ export function initSandboxRuntimeModular(): void {
if (!rootCompositionNode) return [];
const seen = new Set<string>();
const childNodes = Array.from(rootCompositionNode.querySelectorAll("[data-composition-id]"));
const candidates: Array<{ compositionId: string; timeline: RuntimeTimelineLike; durationSeconds: number }> = [];
const candidates: Array<{
compositionId: string;
timeline: RuntimeTimelineLike;
durationSeconds: number;
}> = [];
for (const childNode of childNodes) {
const childId = childNode.getAttribute("data-composition-id");
if (!childId || childId === rootCompositionId) continue;
@@ -517,7 +564,10 @@ export function initSandboxRuntimeModular(): void {
seen.add(childId);
const candidateTimeline = timelines[childId] ?? null;
if (!candidateTimeline) continue;
if (typeof candidateTimeline.play !== "function" || typeof candidateTimeline.pause !== "function") {
if (
typeof candidateTimeline.play !== "function" ||
typeof candidateTimeline.pause !== "function"
) {
continue;
}
const candidateDuration = getTimelineDurationSeconds(candidateTimeline);
@@ -531,7 +581,11 @@ export function initSandboxRuntimeModular(): void {
};
const rootChildCandidates = collectRootChildCandidates();
const ensureChildCandidatesActive = (
candidates: Array<{ compositionId: string; timeline: RuntimeTimelineLike; durationSeconds: number }>,
candidates: Array<{
compositionId: string;
timeline: RuntimeTimelineLike;
durationSeconds: number;
}>,
): void => {
for (const candidate of candidates) {
const timelineWithPaused = candidate.timeline as RuntimeTimelineLike & {
@@ -554,7 +608,12 @@ export function initSandboxRuntimeModular(): void {
? addMissingChildCandidatesToRootTimeline(rootTimeline, rootChildCandidates)
: [];
// Mark children as bound so the polling loop stops re-resolving
if (rootChildCandidates.length > 0 || !document.querySelector("[data-composition-id]:not([data-composition-id='" + rootCompositionId + "'])")) {
if (
rootChildCandidates.length > 0 ||
!document.querySelector(
"[data-composition-id]:not([data-composition-id='" + rootCompositionId + "'])",
)
) {
childrenBound = true;
}
@@ -564,7 +623,9 @@ export function initSandboxRuntimeModular(): void {
try {
const currentTime = rootTimeline.time();
rootTimeline.seek(currentTime, false); // false = don't suppress events
} catch { /* ignore */ }
} catch {
/* ignore */
}
}
const rootDurationSeconds = getTimelineDurationSeconds(rootTimeline);
if (!isUsableTimelineDuration(rootDurationSeconds) && rootChildCandidates.length > 0) {
@@ -592,7 +653,10 @@ export function initSandboxRuntimeModular(): void {
},
};
}
const durationFloorTimeline = createDurationFloorTimeline(mediaDurationFloorSeconds ?? 0, rootTimeline);
const durationFloorTimeline = createDurationFloorTimeline(
mediaDurationFloorSeconds ?? 0,
rootTimeline,
);
const durationFloorSeconds = getTimelineDurationSeconds(durationFloorTimeline);
if (durationFloorTimeline && isUsableTimelineDuration(durationFloorSeconds)) {
return {
@@ -616,7 +680,10 @@ export function initSandboxRuntimeModular(): void {
}
}
if (!isUsableTimelineDuration(rootDurationSeconds) && rootChildCandidates.length === 0) {
const durationFloorTimeline = createDurationFloorTimeline(mediaDurationFloorSeconds ?? 0, rootTimeline);
const durationFloorTimeline = createDurationFloorTimeline(
mediaDurationFloorSeconds ?? 0,
rootTimeline,
);
const durationFloorSeconds = getTimelineDurationSeconds(durationFloorTimeline);
if (durationFloorTimeline && isUsableTimelineDuration(durationFloorSeconds)) {
return {
@@ -750,9 +817,15 @@ export function initSandboxRuntimeModular(): void {
const declaredHeight = Number(rootNode.getAttribute("data-height"));
const computedStyle = window.getComputedStyle(rootNode);
const hasDeclaredDimensions =
Number.isFinite(declaredWidth) && declaredWidth > 0 && Number.isFinite(declaredHeight) && declaredHeight > 0;
Number.isFinite(declaredWidth) &&
declaredWidth > 0 &&
Number.isFinite(declaredHeight) &&
declaredHeight > 0;
const looksCollapsed =
rect.width <= 0 || rect.height <= 0 || rootNode.clientWidth <= 0 || rootNode.clientHeight <= 0;
rect.width <= 0 ||
rect.height <= 0 ||
rootNode.clientWidth <= 0 ||
rootNode.clientHeight <= 0;
if (!hasDeclaredDimensions || !looksCollapsed) {
return;
}
@@ -811,7 +884,10 @@ export function initSandboxRuntimeModular(): void {
});
};
runtimeUnhandledRejectionListener = (event: PromiseRejectionEvent) => {
const normalized = normalizeDiagnosticMessage(event.reason).slice(0, MAX_DIAGNOSTIC_MESSAGE_LENGTH);
const normalized = normalizeDiagnosticMessage(event.reason).slice(
0,
MAX_DIAGNOSTIC_MESSAGE_LENGTH,
);
if (!normalized) {
return;
}
@@ -831,22 +907,31 @@ export function initSandboxRuntimeModular(): void {
};
const installAssetFailureDiagnostics = () => {
const assetNodes = Array.from(document.querySelectorAll("img, video, audio, source, link[rel='stylesheet']"));
const assetNodes = Array.from(
document.querySelectorAll("img, video, audio, source, link[rel='stylesheet']"),
);
for (const node of assetNodes) {
const onError = () => {
if (!(node instanceof Element)) {
return;
}
const tagName = node.tagName.toLowerCase();
const assetUrl = node.getAttribute("src") ?? node.getAttribute("href") ?? node.getAttribute("poster") ?? null;
const diagnosticCode = tagName === "link" ? "runtime_stylesheet_load_failed" : "runtime_asset_load_failed";
const assetUrl =
node.getAttribute("src") ??
node.getAttribute("href") ??
node.getAttribute("poster") ??
null;
const diagnosticCode =
tagName === "link" ? "runtime_stylesheet_load_failed" : "runtime_asset_load_failed";
postRuntimeDiagnosticOnce(
diagnosticCode,
{
tagName,
assetUrl,
currentSrc:
node instanceof HTMLImageElement || node instanceof HTMLMediaElement ? node.currentSrc || null : null,
node instanceof HTMLImageElement || node instanceof HTMLMediaElement
? node.currentSrc || null
: null,
readyState: node instanceof HTMLMediaElement ? node.readyState : null,
networkState: node instanceof HTMLMediaElement ? node.networkState : null,
},
@@ -890,7 +975,10 @@ export function initSandboxRuntimeModular(): void {
});
};
const rebindTimelineFromResolution = (resolution: TimelineResolution, reason: "loop_guard" | "manual"): boolean => {
const rebindTimelineFromResolution = (
resolution: TimelineResolution,
reason: "loop_guard" | "manual",
): boolean => {
if (!resolution.timeline) return false;
const previousTimeline = state.capturedTimeline;
if (previousTimeline && previousTimeline === resolution.timeline) {
@@ -940,7 +1028,9 @@ export function initSandboxRuntimeModular(): void {
metadataRebindDebounceTimerId = null;
const resolution = resolveRootTimelineFromDocument();
if (!resolution.timeline) return;
const hasResolvedMediaFloor = isUsableTimelineDuration(resolution.mediaDurationFloorSeconds ?? null);
const hasResolvedMediaFloor = isUsableTimelineDuration(
resolution.mediaDurationFloorSeconds ?? null,
);
if (!hasResolvedMediaFloor) return;
if (!state.capturedTimeline) {
if (bindRootTimelineIfAvailable()) {
@@ -951,7 +1041,8 @@ export function initSandboxRuntimeModular(): void {
}
if (metadataRebindApplied) return;
const currentDuration = getTimelineDurationSeconds(state.capturedTimeline);
const nextDuration = resolution.selectedDurationSeconds ?? getTimelineDurationSeconds(resolution.timeline);
const nextDuration =
resolution.selectedDurationSeconds ?? getTimelineDurationSeconds(resolution.timeline);
const isBetterCandidate =
isUsableTimelineDuration(nextDuration) &&
(!isUsableTimelineDuration(currentDuration) ||
@@ -1005,7 +1096,8 @@ export function initSandboxRuntimeModular(): void {
playing: state.isPlaying,
playbackRate: state.playbackRate,
});
const rootCompId = document.querySelector("[data-composition-id]")?.getAttribute("data-composition-id") ?? null;
const rootCompId =
document.querySelector("[data-composition-id]")?.getAttribute("data-composition-id") ?? null;
const visibilityNodes = Array.from(document.querySelectorAll("[data-start]"));
for (const rawNode of visibilityNodes) {
if (!(rawNode instanceof HTMLElement)) continue;
@@ -1038,7 +1130,9 @@ export function initSandboxRuntimeModular(): void {
if (compDur > 0) computedEnd = start + compDur;
}
}
const isVisibleNow = state.currentTime >= start && (Number.isFinite(computedEnd) ? state.currentTime < computedEnd : true);
const isVisibleNow =
state.currentTime >= start &&
(Number.isFinite(computedEnd) ? state.currentTime < computedEnd : true);
rawNode.style.visibility = isVisibleNow ? "visible" : "hidden";
}
};
@@ -1203,12 +1297,19 @@ export function initSandboxRuntimeModular(): void {
initRuntimeAnalytics(postRuntimeMessage as (payload: unknown) => void);
emitAnalyticsEvent("composition_loaded", {
duration: player.getDuration(),
compositionId: document.querySelector("[data-composition-id]")?.getAttribute("data-composition-id") ?? null,
compositionId:
document.querySelector("[data-composition-id]")?.getAttribute("data-composition-id") ?? null,
});
state.controlBridgeHandler = installRuntimeControlBridge({
onPlay: () => { player.play(); emitAnalyticsEvent("composition_played", { time: player.getTime() }); },
onPause: () => { player.pause(); emitAnalyticsEvent("composition_paused", { time: player.getTime() }); },
onPlay: () => {
player.play();
emitAnalyticsEvent("composition_played", { time: player.getTime() });
},
onPause: () => {
player.pause();
emitAnalyticsEvent("composition_paused", { time: player.getTime() });
},
onSeek: (frame, _seekMode) => {
const time = Math.max(0, frame) / state.canonicalFps;
player.seek(time);
@@ -1280,7 +1381,9 @@ export function initSandboxRuntimeModular(): void {
state.isPlaying &&
state.capturedTimeline != null &&
Math.max(0, state.currentTime || 0) < PLAY_REBIND_HOLD_SECONDS;
const timelineBoundThisTick = shouldHoldRebindDuringEarlyPlay ? false : bindRootTimelineIfAvailable();
const timelineBoundThisTick = shouldHoldRebindDuringEarlyPlay
? false
: bindRootTimelineIfAvailable();
if (state.capturedTimeline && !player._timeline) {
player._timeline = state.capturedTimeline;
}
+17 -8
View File
@@ -7,15 +7,17 @@ export type RuntimeMediaClip = {
volume: number | null;
};
export function refreshRuntimeMediaCache(params?: { resolveStartSeconds?: (element: Element) => number }): {
export function refreshRuntimeMediaCache(params?: {
resolveStartSeconds?: (element: Element) => number;
}): {
timedMediaEls: Array<HTMLVideoElement | HTMLAudioElement>;
mediaClips: RuntimeMediaClip[];
videoClips: RuntimeMediaClip[];
maxMediaEnd: number;
} {
const mediaEls = Array.from(document.querySelectorAll("video[data-start], audio[data-start]")) as Array<
HTMLVideoElement | HTMLAudioElement
>;
const mediaEls = Array.from(
document.querySelectorAll("video[data-start], audio[data-start]"),
) as Array<HTMLVideoElement | HTMLAudioElement>;
const mediaClips: RuntimeMediaClip[] = [];
const videoClips: RuntimeMediaClip[] = [];
let maxMediaEnd = 0;
@@ -24,12 +26,18 @@ export function refreshRuntimeMediaCache(params?: { resolveStartSeconds?: (eleme
? params.resolveStartSeconds(el)
: Number.parseFloat(el.dataset.start ?? "0");
if (!Number.isFinite(start)) continue;
const mediaStart = Number.parseFloat(el.dataset.playbackStart ?? el.dataset.mediaStart ?? "0") || 0;
const mediaStart =
Number.parseFloat(el.dataset.playbackStart ?? el.dataset.mediaStart ?? "0") || 0;
let duration = Number.parseFloat(el.dataset.duration ?? "");
if ((!Number.isFinite(duration) || duration <= 0) && Number.isFinite(el.duration) && el.duration > 0) {
if (
(!Number.isFinite(duration) || duration <= 0) &&
Number.isFinite(el.duration) &&
el.duration > 0
) {
duration = Math.max(0, el.duration - mediaStart);
}
const end = Number.isFinite(duration) && duration > 0 ? start + duration : Number.POSITIVE_INFINITY;
const end =
Number.isFinite(duration) && duration > 0 ? start + duration : Number.POSITIVE_INFINITY;
const volumeRaw = Number.parseFloat(el.dataset.volume ?? "");
const clip: RuntimeMediaClip = {
el,
@@ -56,7 +64,8 @@ export function syncRuntimeMedia(params: {
const { el } = clip;
if (!el.isConnected) continue;
const relTime = params.timeSeconds - clip.start + clip.mediaStart;
const isActive = params.timeSeconds >= clip.start && params.timeSeconds < clip.end && relTime >= 0;
const isActive =
params.timeSeconds >= clip.start && params.timeSeconds < clip.end && relTime >= 0;
if (isActive) {
if (clip.volume != null) el.volume = clip.volume;
try {
+6 -6
View File
@@ -8,7 +8,7 @@ function createMockPostMessage() {
describe("createPickerModule", () => {
afterEach(() => {
document.body.innerHTML = "";
document.head.querySelectorAll("style").forEach(s => s.remove());
document.head.querySelectorAll("style").forEach((s) => s.remove());
document.body.classList.remove("__hf-pick-active");
});
@@ -33,15 +33,15 @@ describe("createPickerModule", () => {
const picker = createPickerModule({ postMessage: createMockPostMessage() });
picker.enablePickMode();
const styles = document.head.querySelectorAll("style");
const hasPickStyle = Array.from(styles).some(s =>
s.textContent?.includes("__hf-pick-highlight")
const hasPickStyle = Array.from(styles).some((s) =>
s.textContent?.includes("__hf-pick-highlight"),
);
expect(hasPickStyle).toBe(true);
picker.disablePickMode();
const stylesAfter = document.head.querySelectorAll("style");
const hasPickStyleAfter = Array.from(stylesAfter).some(s =>
s.textContent?.includes("__hf-pick-highlight")
const hasPickStyleAfter = Array.from(stylesAfter).some((s) =>
s.textContent?.includes("__hf-pick-highlight"),
);
expect(hasPickStyleAfter).toBe(false);
});
@@ -137,7 +137,7 @@ describe("createPickerModule", () => {
expect.objectContaining({
source: "hf-preview",
type: "pick-mode-cancelled",
})
}),
);
});
+18 -5
View File
@@ -77,7 +77,8 @@ export function createPickerModule(deps: PickerModuleDeps): PickerModule {
const trimLabel = (value: string, maxChars: number) =>
value.length > maxChars ? `${value.slice(0, maxChars - 1)}` : value;
if (tag === "h1" || tag === "h2" || tag === "h3") return "Heading";
if (tag === "p" || tag === "span" || tag === "div") return text.length > 0 ? trimLabel(text, 56) : "Text";
if (tag === "p" || tag === "span" || tag === "div")
return text.length > 0 ? trimLabel(text, 56) : "Text";
if (tag === "img") return "Image";
if (tag === "video") return "Video";
if (tag === "audio") return "Audio";
@@ -132,7 +133,11 @@ export function createPickerModule(deps: PickerModuleDeps): PickerModule {
};
}
function getPickInfosFromPoint(clientX: number, clientY: number, limit?: number): RuntimePickerElementInfo[] {
function getPickInfosFromPoint(
clientX: number,
clientY: number,
limit?: number,
): RuntimePickerElementInfo[] {
return getPickCandidatesFromPoint(clientX, clientY, limit).map(extractElementInfo);
}
@@ -217,7 +222,9 @@ export function createPickerModule(deps: PickerModuleDeps): PickerModule {
getHovered: () => pickLastHoveredInfo,
getSelected: () => pickLastSelectedInfo,
getCandidatesAtPoint: (clientX, clientY, limit) =>
Number.isFinite(clientX) && Number.isFinite(clientY) ? getPickInfosFromPoint(clientX, clientY, limit) : [],
Number.isFinite(clientX) && Number.isFinite(clientY)
? getPickInfosFromPoint(clientX, clientY, limit)
: [],
pickAtPoint: (clientX, clientY, index) => {
if (!Number.isFinite(clientX) || !Number.isFinite(clientY)) return null;
const infos = getPickInfosFromPoint(clientX, clientY, 8);
@@ -240,12 +247,18 @@ export function createPickerModule(deps: PickerModuleDeps): PickerModule {
const idx = Math.max(0, Math.min(infos.length - 1, Math.floor(Number(rawIndex))));
const info = infos[idx];
if (!info) continue;
const duplicate = selected.some((item) => item.selector === info.selector && item.tagName === info.tagName);
const duplicate = selected.some(
(item) => item.selector === info.selector && item.tagName === info.tagName,
);
if (!duplicate) selected.push(info);
}
if (!selected.length) return [];
setLastSelectedInfo(selected[0] ?? null);
deps.postMessage({ source: "hf-preview", type: "element-picked-many", elementInfos: selected });
deps.postMessage({
source: "hf-preview",
type: "element-picked-many",
elementInfos: selected,
});
disablePickMode();
return selected;
},
+21 -7
View File
@@ -5,14 +5,24 @@ import type { RuntimeTimelineLike } from "./types";
function createMockTimeline(opts?: { time?: number; duration?: number }): RuntimeTimelineLike {
const state = { time: opts?.time ?? 0, duration: opts?.duration ?? 10, paused: false };
return {
play: vi.fn(() => { state.paused = false; }),
pause: vi.fn(() => { state.paused = true; }),
seek: vi.fn((t: number) => { state.time = t; }),
totalTime: vi.fn((t: number) => { state.time = t; }),
play: vi.fn(() => {
state.paused = false;
}),
pause: vi.fn(() => {
state.paused = true;
}),
seek: vi.fn((t: number) => {
state.time = t;
}),
totalTime: vi.fn((t: number) => {
state.time = t;
}),
time: vi.fn(() => state.time),
duration: vi.fn(() => state.duration),
add: vi.fn(),
paused: vi.fn((p?: boolean) => { if (p !== undefined) state.paused = p; }),
paused: vi.fn((p?: boolean) => {
if (p !== undefined) state.paused = p;
}),
timeScale: vi.fn(),
set: vi.fn(),
};
@@ -25,9 +35,13 @@ function createMockDeps(timeline?: RuntimeTimelineLike | null) {
getTimeline: vi.fn(() => timeline ?? null),
setTimeline: vi.fn(),
getIsPlaying: vi.fn(() => isPlaying),
setIsPlaying: vi.fn((v: boolean) => { isPlaying = v; }),
setIsPlaying: vi.fn((v: boolean) => {
isPlaying = v;
}),
getPlaybackRate: vi.fn(() => playbackRate),
setPlaybackRate: vi.fn((v: number) => { playbackRate = v; }),
setPlaybackRate: vi.fn((v: number) => {
playbackRate = v;
}),
getCanonicalFps: vi.fn(() => 30),
onSyncMedia: vi.fn(),
onStatePost: vi.fn(),
+9 -2
View File
@@ -40,7 +40,10 @@ export function createRuntimePlayer(deps: PlayerDeps): RuntimePlayer {
play: () => {
const timeline = deps.getTimeline();
if (!timeline || deps.getIsPlaying()) return;
const safeDuration = Math.max(0, Number(deps.getSafeDuration?.() ?? timeline.duration() ?? 0) || 0);
const safeDuration = Math.max(
0,
Number(deps.getSafeDuration?.() ?? timeline.duration() ?? 0) || 0,
);
if (safeDuration > 0) {
const currentTime = Math.max(0, Number(timeline.time()) || 0);
if (currentTime >= safeDuration) {
@@ -87,7 +90,11 @@ export function createRuntimePlayer(deps: PlayerDeps): RuntimePlayer {
renderSeek: (timeSeconds: number) => {
const timeline = deps.getTimeline();
if (!timeline) return;
const quantized = seekTimelineDeterministically(timeline, timeSeconds, deps.getCanonicalFps());
const quantized = seekTimelineDeterministically(
timeline,
timeSeconds,
deps.getCanonicalFps(),
);
deps.onDeterministicSeek(quantized);
deps.setIsPlaying(false);
deps.onSyncMedia(quantized, false);
@@ -7,8 +7,7 @@ beforeAll(() => {
(globalThis as any).CSS = {};
}
if (typeof CSS.escape !== "function") {
CSS.escape = (value: string) =>
value.replace(/([^\w-])/g, "\\$1");
CSS.escape = (value: string) => value.replace(/([^\w-])/g, "\\$1");
}
});
@@ -191,7 +190,16 @@ describe("createRuntimeStartTimeResolver", () => {
el.setAttribute("data-composition-id", "comp-1");
document.body.appendChild(el);
const mockTimeline = { duration: () => 12, time: () => 0, play: () => {}, pause: () => {}, seek: () => {}, add: () => {}, paused: () => {}, set: () => {} };
const mockTimeline = {
duration: () => 12,
time: () => 0,
play: () => {},
pause: () => {},
seek: () => {},
add: () => {},
paused: () => {},
set: () => {},
};
const resolver = createRuntimeStartTimeResolver({
timelineRegistry: { "comp-1": mockTimeline as any },
});
@@ -204,7 +212,16 @@ describe("createRuntimeStartTimeResolver", () => {
el.setAttribute("data-duration", "5");
document.body.appendChild(el);
const mockTimeline = { duration: () => 12, time: () => 0, play: () => {}, pause: () => {}, seek: () => {}, add: () => {}, paused: () => {}, set: () => {} };
const mockTimeline = {
duration: () => 12,
time: () => 0,
play: () => {},
pause: () => {},
seek: () => {},
add: () => {},
paused: () => {},
set: () => {},
};
const resolver = createRuntimeStartTimeResolver({
timelineRegistry: { "comp-1": mockTimeline as any },
});
+4 -1
View File
@@ -49,7 +49,10 @@ export function createRuntimeStartTimeResolver(params: {
const findReferenceTarget = (refId: string): Element | null => {
const byId = document.getElementById(refId);
if (byId) return byId;
return (document.querySelector(`[data-composition-id="${CSS.escape(refId)}"]`) as Element | null) ?? null;
return (
(document.querySelector(`[data-composition-id="${CSS.escape(refId)}"]`) as Element | null) ??
null
);
};
const resolveDurationForElement = (element: Element): number | null => {
+25 -4
View File
@@ -194,7 +194,10 @@ describe("collectRuntimeTimelinePayload", () => {
clip.setAttribute("data-duration", "5000");
root.appendChild(clip);
const result = collectRuntimeTimelinePayload({ canonicalFps: 30, maxTimelineDurationSeconds: 60 });
const result = collectRuntimeTimelinePayload({
canonicalFps: 30,
maxTimelineDurationSeconds: 60,
});
expect(result.durationInFrames).toBeLessThanOrEqual(60 * 30);
});
@@ -263,13 +266,31 @@ describe("collectRuntimeTimelinePayload", () => {
root.appendChild(comp);
(window as any).__timelines = {
"main": { duration: () => 15, time: () => 0, play: () => {}, pause: () => {}, seek: () => {}, add: () => {}, paused: () => {}, set: () => {} },
"scene-1": { duration: () => 8, time: () => 0, play: () => {}, pause: () => {}, seek: () => {}, add: () => {}, paused: () => {}, set: () => {} },
main: {
duration: () => 15,
time: () => 0,
play: () => {},
pause: () => {},
seek: () => {},
add: () => {},
paused: () => {},
set: () => {},
},
"scene-1": {
duration: () => 8,
time: () => 0,
play: () => {},
pause: () => {},
seek: () => {},
add: () => {},
paused: () => {},
set: () => {},
},
};
const result = collectRuntimeTimelinePayload(defaultParams);
// scene-1 should get duration 8 from timeline registry
const sceneClip = result.clips.find(c => c.compositionId === "scene-1");
const sceneClip = result.clips.find((c) => c.compositionId === "scene-1");
expect(sceneClip).toBeDefined();
expect(sceneClip?.duration).toBe(8);
});
+46 -16
View File
@@ -1,4 +1,9 @@
import type { RuntimeTimelineClip, RuntimeTimelineMessage, RuntimeTimelineScene, RuntimeTimelineLike } from "./types";
import type {
RuntimeTimelineClip,
RuntimeTimelineMessage,
RuntimeTimelineScene,
RuntimeTimelineLike,
} from "./types";
import { createRuntimeStartTimeResolver } from "./startResolver";
function parseNum(value: string | null | undefined): number | null {
@@ -50,22 +55,26 @@ export function collectRuntimeTimelinePayload(params: {
return null;
}
};
const resolveMediaElementDurationSeconds = (mediaEl: HTMLVideoElement | HTMLAudioElement): number | null => {
const resolveMediaElementDurationSeconds = (
mediaEl: HTMLVideoElement | HTMLAudioElement,
): number | null => {
const declaredDuration = parseNum(mediaEl.getAttribute("data-duration"));
if (declaredDuration != null && declaredDuration > 0) {
return declaredDuration;
}
const playbackStart =
parseNum(mediaEl.getAttribute("data-playback-start")) ?? parseNum(mediaEl.getAttribute("data-media-start")) ?? 0;
parseNum(mediaEl.getAttribute("data-playback-start")) ??
parseNum(mediaEl.getAttribute("data-media-start")) ??
0;
if (Number.isFinite(mediaEl.duration) && mediaEl.duration > playbackStart) {
return Math.max(0, mediaEl.duration - playbackStart);
}
return null;
};
const resolveMediaWindowEndSeconds = (): number | null => {
const mediaNodes = Array.from(document.querySelectorAll("video[data-start], audio[data-start]")) as Array<
HTMLVideoElement | HTMLAudioElement
>;
const mediaNodes = Array.from(
document.querySelectorAll("video[data-start], audio[data-start]"),
) as Array<HTMLVideoElement | HTMLAudioElement>;
if (mediaNodes.length === 0) return null;
let maxWindowEndSeconds = 0;
for (const mediaNode of mediaNodes) {
@@ -137,11 +146,15 @@ export function collectRuntimeTimelinePayload(params: {
? rootDurationFromTimeline
: null;
const attrDurationCandidate =
typeof rootDurationFromAttr === "number" && Number.isFinite(rootDurationFromAttr) && rootDurationFromAttr > 0
typeof rootDurationFromAttr === "number" &&
Number.isFinite(rootDurationFromAttr) &&
rootDurationFromAttr > 0
? rootDurationFromAttr
: null;
const mediaWindowDurationCandidate =
typeof mediaWindowDuration === "number" && Number.isFinite(mediaWindowDuration) && mediaWindowDuration > 0
typeof mediaWindowDuration === "number" &&
Number.isFinite(mediaWindowDuration) &&
mediaWindowDuration > 0
? mediaWindowDuration
: null;
const timelineLooksLoopInflated =
@@ -156,8 +169,11 @@ export function collectRuntimeTimelinePayload(params: {
? mediaWindowDurationCandidate
: (timelineDurationCandidate ?? mediaWindowDurationCandidate));
const rootCompositionDuration =
preferredRootDuration != null ? Math.min(preferredRootDuration, params.maxTimelineDurationSeconds) : null;
const rootCompositionEnd = rootCompositionDuration != null ? rootCompositionStart + rootCompositionDuration : null;
preferredRootDuration != null
? Math.min(preferredRootDuration, params.maxTimelineDurationSeconds)
: null;
const rootCompositionEnd =
rootCompositionDuration != null ? rootCompositionStart + rootCompositionDuration : null;
const timelineWindowEnd =
rootCompositionEnd ??
(typeof mediaWindowEnd === "number" && Number.isFinite(mediaWindowEnd) && mediaWindowEnd > 0
@@ -177,17 +193,27 @@ export function collectRuntimeTimelinePayload(params: {
for (let i = 0; i < nodes.length; i += 1) {
const node = nodes[i];
if (node === root) continue;
if (["SCRIPT", "STYLE", "LINK", "META", "TEMPLATE", "NOSCRIPT"].includes(node.tagName)) continue;
if (["SCRIPT", "STYLE", "LINK", "META", "TEMPLATE", "NOSCRIPT"].includes(node.tagName))
continue;
const compositionContext = resolveNearestCompositionContext(node, root);
const start = startResolver.resolveStartForElement(node, compositionContext.inheritedStart ?? 0);
const start = startResolver.resolveStartForElement(
node,
compositionContext.inheritedStart ?? 0,
);
const nodeCompositionId = node.getAttribute("data-composition-id");
let duration = parseNum(node.getAttribute("data-duration"));
if ((duration == null || duration <= 0) && nodeCompositionId && nodeCompositionId !== rootCompositionId) {
if (
(duration == null || duration <= 0) &&
nodeCompositionId &&
nodeCompositionId !== rootCompositionId
) {
duration = resolveTimelineDurationSeconds(nodeCompositionId);
}
if ((duration == null || duration <= 0) && node instanceof HTMLMediaElement) {
const mediaStart =
parseNum(node.getAttribute("data-playback-start")) ?? parseNum(node.getAttribute("data-media-start")) ?? 0;
parseNum(node.getAttribute("data-playback-start")) ??
parseNum(node.getAttribute("data-media-start")) ??
0;
if (Number.isFinite(node.duration) && node.duration > 0) {
duration = Math.max(0, node.duration - mediaStart);
}
@@ -228,7 +254,10 @@ export function collectRuntimeTimelinePayload(params: {
start,
duration,
track:
Number.parseInt(node.getAttribute("data-track-index") ?? node.getAttribute("data-track") ?? String(i), 10) || 0,
Number.parseInt(
node.getAttribute("data-track-index") ?? node.getAttribute("data-track") ?? String(i),
10,
) || 0,
kind,
tagName: tag,
compositionId: node.getAttribute("data-composition-id"),
@@ -250,7 +279,8 @@ export function collectRuntimeTimelinePayload(params: {
const start = startResolver.resolveStartForElement(compositionNode, 0);
const durationFromAttr = parseNum(compositionNode.getAttribute("data-duration"));
const durationFromTimeline = resolveTimelineDurationSeconds(compositionId);
const duration = durationFromAttr && durationFromAttr > 0 ? durationFromAttr : durationFromTimeline;
const duration =
durationFromAttr && durationFromAttr > 0 ? durationFromAttr : durationFromTimeline;
if (duration == null || duration <= 0) continue;
const clampedDuration = clampDurationToRootWindow(start, duration);
if (clampedDuration <= 0) continue;
+7 -1
View File
@@ -1,4 +1,10 @@
export type RuntimeJson = string | number | boolean | null | RuntimeJson[] | { [key: string]: RuntimeJson };
export type RuntimeJson =
| string
| number
| boolean
| null
| RuntimeJson[]
| { [key: string]: RuntimeJson };
export type RuntimeBridgeControlAction =
| "play"