mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-05 17:30:50 +00:00
176 lines
5.7 KiB
TypeScript
176 lines
5.7 KiB
TypeScript
import type { RuntimeDeterministicAdapter } from "../types";
|
|
import { swallow } from "../diagnostics";
|
|
|
|
export function createCssAdapter(params?: {
|
|
resolveStartSeconds?: (element: Element) => number;
|
|
}): RuntimeDeterministicAdapter {
|
|
let entries: Array<{
|
|
el: HTMLElement;
|
|
baseDelay: string;
|
|
basePlayState: string;
|
|
animations: Animation[];
|
|
}> = [];
|
|
|
|
const getAnimationsForElement = (el: HTMLElement): Animation[] => {
|
|
if (typeof el.getAnimations !== "function") return [];
|
|
try {
|
|
return el.getAnimations();
|
|
} catch {
|
|
return [];
|
|
}
|
|
};
|
|
|
|
const resolveEntryStartSeconds = (el: HTMLElement): number =>
|
|
params?.resolveStartSeconds
|
|
? params.resolveStartSeconds(el)
|
|
: Number.parseFloat(el.getAttribute("data-start") ?? "0") || 0;
|
|
|
|
/**
|
|
* End time (seconds, relative to composition start) for one WAAPI
|
|
* animation handle. `endSeconds` is set only when the timing is readable
|
|
* AND finite; `unbounded` is true when a timing was read but its endTime
|
|
* is Infinity/NaN (an infinite iteration count the caller can't
|
|
* auto-infer a duration from) — distinct from "no timing available at
|
|
* all" (both fields absent), which callers should simply skip.
|
|
*/
|
|
const inferAnimationEndSeconds = (
|
|
animation: Animation,
|
|
startSeconds: number,
|
|
): { endSeconds?: number; unbounded?: true } => {
|
|
let timing: ComputedEffectTiming | null = null;
|
|
try {
|
|
timing = animation.effect?.getComputedTiming?.() ?? null;
|
|
} catch (err) {
|
|
swallow("runtime.adapters.css.site5", err);
|
|
}
|
|
if (!timing) return {};
|
|
const endTimeMs = Number(timing.endTime);
|
|
if (!Number.isFinite(endTimeMs)) return { unbounded: true };
|
|
return { endSeconds: startSeconds + endTimeMs / 1000 };
|
|
};
|
|
|
|
const seekAnimations = (animations: Animation[], timeMs: number) => {
|
|
for (const animation of animations) {
|
|
try {
|
|
animation.currentTime = timeMs;
|
|
} catch (err) {
|
|
// ignore animations that reject currentTime writes
|
|
swallow("runtime.adapters.css.site1", err);
|
|
}
|
|
try {
|
|
animation.pause();
|
|
} catch (err) {
|
|
// infinite unresolved animations can throw on pause before currentTime sticks
|
|
swallow("runtime.adapters.css.site2", err);
|
|
}
|
|
}
|
|
};
|
|
|
|
const playAnimations = (animations: Animation[]) => {
|
|
for (const animation of animations) {
|
|
try {
|
|
animation.play();
|
|
} catch (err) {
|
|
// ignore animation edge-cases
|
|
swallow("runtime.adapters.css.site3", err);
|
|
}
|
|
}
|
|
};
|
|
|
|
const pauseAnimations = (animations: Animation[]) => {
|
|
for (const animation of animations) {
|
|
try {
|
|
animation.pause();
|
|
} catch (err) {
|
|
// ignore animation edge-cases
|
|
swallow("runtime.adapters.css.site4", err);
|
|
}
|
|
}
|
|
};
|
|
|
|
const restoreInlineStyles = (entry: (typeof entries)[number]) => {
|
|
if (entry.baseDelay) {
|
|
entry.el.style.animationDelay = entry.baseDelay;
|
|
} else {
|
|
entry.el.style.removeProperty("animation-delay");
|
|
}
|
|
if (entry.basePlayState) {
|
|
entry.el.style.animationPlayState = entry.basePlayState;
|
|
} else {
|
|
entry.el.style.removeProperty("animation-play-state");
|
|
}
|
|
};
|
|
|
|
return {
|
|
name: "css",
|
|
discover: () => {
|
|
entries = [];
|
|
const all = document.querySelectorAll("*");
|
|
for (const rawEl of all) {
|
|
if (!(rawEl instanceof HTMLElement)) continue;
|
|
const style = window.getComputedStyle(rawEl);
|
|
if (!style.animationName || style.animationName === "none") continue;
|
|
entries.push({
|
|
el: rawEl,
|
|
baseDelay: rawEl.style.animationDelay || "",
|
|
basePlayState: rawEl.style.animationPlayState || "",
|
|
animations: getAnimationsForElement(rawEl),
|
|
});
|
|
}
|
|
},
|
|
getInferredDurationSeconds: () => {
|
|
let maxEndSeconds = 0;
|
|
for (const entry of entries) {
|
|
if (!entry.el.isConnected) continue;
|
|
const start = resolveEntryStartSeconds(entry.el);
|
|
for (const animation of getAnimationsForElement(entry.el)) {
|
|
const result = inferAnimationEndSeconds(animation, start);
|
|
// Unbounded (Infinity/NaN endTime) animations are skipped here —
|
|
// they never contribute to maxEndSeconds. A finite animation
|
|
// elsewhere on the composition still supplies a valid duration
|
|
// signal; only fall through to null when nothing finite was found.
|
|
if (result.endSeconds != null) maxEndSeconds = Math.max(maxEndSeconds, result.endSeconds);
|
|
}
|
|
}
|
|
return maxEndSeconds > 0 ? maxEndSeconds : null;
|
|
},
|
|
seek: (ctx) => {
|
|
const time = Number(ctx.time) || 0;
|
|
for (const entry of entries) {
|
|
if (!entry.el.isConnected) continue;
|
|
const start = resolveEntryStartSeconds(entry.el);
|
|
const localTimeMs = Math.max(0, time - start) * 1000;
|
|
const animations = entry.animations;
|
|
if (animations.length > 0) {
|
|
seekAnimations(animations, localTimeMs);
|
|
continue;
|
|
}
|
|
|
|
// Fallback for environments without WAAPI-backed CSS animation handles.
|
|
entry.el.style.animationPlayState = "paused";
|
|
entry.el.style.animationDelay = `-${(localTimeMs / 1000).toFixed(3)}s`;
|
|
}
|
|
},
|
|
pause: () => {
|
|
for (const entry of entries) {
|
|
if (!entry.el.isConnected) continue;
|
|
const animations = entry.animations;
|
|
if (animations.length > 0) {
|
|
pauseAnimations(animations);
|
|
}
|
|
restoreInlineStyles(entry);
|
|
}
|
|
},
|
|
play: () => {
|
|
for (const entry of entries) {
|
|
if (!entry.el.isConnected) continue;
|
|
restoreInlineStyles(entry);
|
|
playAnimations(entry.animations);
|
|
}
|
|
},
|
|
revert: () => {
|
|
entries = [];
|
|
},
|
|
};
|
|
}
|