mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-04 07:19:52 +00:00
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:
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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; }");
|
||||
});
|
||||
|
||||
@@ -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, "'")}'`
|
||||
: "";
|
||||
|
||||
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});`);
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
})
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user