mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 23:03:09 +00:00
fix(runtime): auto-infer composition duration for CSS/WAAPI/Lottie so data-duration is optional (#1830)
* fix(runtime): auto-infer composition duration for CSS/WAAPI/Lottie so data-duration is optional The #2 render failure bucket ("Composition has zero duration") accounts for ~27K errors / ~7K affected users over 30 days (PostHog project 356858). Root cause: only GSAP timelines got their duration auto-detected — CSS, WAAPI, and Lottie compositions had no source of truth for total duration unless the author remembered to set data-duration on the root element, and the render engine hard-failed capture when neither was present. Adds getInferredDurationSeconds() to the CSS, WAAPI, and Lottie runtime adapters (packages/core/src/runtime/adapters/*.ts) — each reports the longest finite end time it can discover from its own animations (CSS: computed timing offset by data-start; WAAPI: effect.getComputedTiming().endTime; Lottie: totalFrames/frameRate or the player's own duration). Infinite/ unbounded animations correctly return null and still require data-duration. Wires this into the runtime's existing duration-floor resolution (resolveAdapterDurationFloorSeconds in runtime/init.ts), alongside the existing media-duration and authored-composition floors, so window.__hf.duration becomes positive without any author action for finite-duration non-GSAP compositions. Three.js is unchanged — no AnimationClip/AnimationMixer inspection exists in that adapter, so data-duration remains required there. Tightens frameCapture.ts's zero-duration fast-fail gate to also check hf.duration directly (not just the two authored signals), so a composition mid-inference isn't fast-failed before its adapter-derived duration lands. Adds a new lint rule (root_composition_missing_duration_source) that errors only on genuinely non-inferable cases: no animation signal at all, Three.js without data-duration, or an infinite/unbounded CSS or WAAPI animation without data-duration. Deliberately silent on finite CSS/WAAPI/Lottie animations, since the runtime now infers those — an autofix that "inserts the inferred value" was considered and rejected: every case the rule flags has no derivable value (an infinite spinner has no finite end time; a duration-less Three.js scene has nothing to measure), so any autofix would have to fabricate a placeholder, trading a loud correct failure for a silent wrong-length render. Updates the CSS/WAAPI/Lottie/Three adapter skill docs and the hyperframes-core determinism-rules/data-attributes references to document the new optionality and the runtime mechanism backing it. Verified end-to-end against the real render pipeline (not just unit tests): a CSS-only composition with a finite 3s animation, no GSAP timeline, and no data-duration now renders a correct 3.000s MP4 via `hyperframes render` (previously: "Composition has zero duration" failure). The infinite-CSS negative control still fails fast with a clear diagnostic, matching the new lint rule. Adds a file-level fallow health exemption for lottie.ts's pre-existing `seek` handler — unrelated to this change, but its line numbers shifted when new functions were added earlier in the file, tripping fallow's inherited-finding fingerprint (documented pattern already used elsewhere in .fallowrc.jsonc for the same reason). Known limitation: the static WAAPI usage detector in the lint rule (/\.animate\(\s*[\[$A-Za-z_]/) can miss unusual call shapes; it only affects whether the "no signal at all" branch fires, and errs toward NOT flagging (reducing false positives) rather than over-flagging. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(lint): close 3 correctness gaps in root_composition_missing_duration_source - Strip JS/CSS comments before scanning for GSAP/WAAPI/Three/Lottie/CSS animation signals, so a commented-out `.animate()` call or a commented `animation: ... infinite` rule can no longer satisfy the "has a duration source" check and mask a real zero-duration render failure. - Broaden the WAAPI detection regex to also match the object-literal (PropertyIndexedKeyframes) form of `.animate()`, e.g. `el.animate({ opacity: [0,1] }, { duration: 2000 })`, which the previous character class silently missed. Corrected the adjacent comment that incorrectly claimed this shape "can't be a false negative". - Fix hasInfiniteCssAnimation to stop false-positiving on animation NAMEs that merely contain the substring "infinite" (e.g. `infinite-spin`) by anchoring the `infinite` keyword with hyphen-aware boundaries instead of a bare `\b`. Also makes the longhand `animation-name` + separately declared `animation-iteration-count: infinite` pattern detected consistently. Adds targeted unit tests for each fixed false-positive/false-negative. * fix(runtime): keep finite duration signal when an unbounded animation coexists getInferredDurationSeconds in the CSS and WAAPI adapters returned null outright whenever any animation on the composition was unbounded (infinite iteration count), even when other finite animations on the same composition could still supply a valid duration. This disagreed with the new root_composition_missing_duration_source lint rule, which treats any animation-name as sufficient — so a composition mixing a finite fadeIn with a decorative infinite spin passed lint but still failed at render with "zero duration". Unbounded animations are now skipped when computing the max end time instead of short-circuiting the whole calculation. null is only returned when every animation on the composition is unbounded, i.e. there is no finite signal to fall back on at all. Co-Authored-By: Claude <noreply@anthropic.com> * docs(skills): fix table separator width in data-attributes.md oxfmt flagged the merged Composition Root table from the post-rebase merge of the auto-infer-duration docs onto main's reformatted table — the separator row was one dash short of the header width. * fix(lint): keep infinite-CSS duration rule strict but make its message honest Post-review (Vance): after the finite+infinite adapter fix, the runtime infers a length for a mixed finite+infinite CSS composition, but this lint rule still (intentionally) errors on it — an unbounded animation makes the intended total length ambiguous, so we require explicit data-duration. Keep that strictness (lint is advisory by default; it only blocks under --strict, and data-duration is the one duration signal guaranteed correct across every adapter, known and future). But the message wrongly claimed the render "will fail" — false for the mixed case, where the runtime falls back to the finite animation. Rewrite it to describe the ambiguity honestly, correct the rule's block comment, and add a mixed finite+infinite test asserting it still errors with an honest message. --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
cf573f7f3f
commit
24edb15095
@@ -175,4 +175,158 @@ describe("css adapter", () => {
|
||||
document.body.removeChild(el);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("getInferredDurationSeconds", () => {
|
||||
it("returns null when nothing was discovered", () => {
|
||||
const adapter = createCssAdapter();
|
||||
adapter.discover();
|
||||
expect(adapter.getInferredDurationSeconds?.()).toBeNull();
|
||||
});
|
||||
|
||||
it("infers the longest finite animation end time, offset by data-start", () => {
|
||||
const el = document.createElement("div");
|
||||
el.setAttribute("data-start", "2");
|
||||
el.style.animationName = "fadeIn";
|
||||
document.body.appendChild(el);
|
||||
|
||||
vi.spyOn(window, "getComputedStyle").mockImplementation(() => {
|
||||
return { animationName: "fadeIn" } as CSSStyleDeclaration;
|
||||
});
|
||||
|
||||
const animation = {
|
||||
currentTime: 0,
|
||||
pause: vi.fn(),
|
||||
play: vi.fn(),
|
||||
effect: { getComputedTiming: () => ({ endTime: 3000 }) },
|
||||
} as unknown as Animation;
|
||||
(el as HTMLElement & { getAnimations?: () => Animation[] }).getAnimations = () => [animation];
|
||||
|
||||
const adapter = createCssAdapter();
|
||||
adapter.discover();
|
||||
|
||||
// start (2s) + endTime (3s) = 5s
|
||||
expect(adapter.getInferredDurationSeconds?.()).toBe(5);
|
||||
|
||||
document.body.removeChild(el);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("returns the max across multiple animated elements", () => {
|
||||
const elA = document.createElement("div");
|
||||
elA.style.animationName = "a";
|
||||
const elB = document.createElement("div");
|
||||
elB.style.animationName = "b";
|
||||
document.body.appendChild(elA);
|
||||
document.body.appendChild(elB);
|
||||
|
||||
vi.spyOn(window, "getComputedStyle").mockImplementation((target) => {
|
||||
return {
|
||||
animationName: target === elA ? "a" : target === elB ? "b" : "none",
|
||||
} as CSSStyleDeclaration;
|
||||
});
|
||||
|
||||
(elA as HTMLElement & { getAnimations?: () => Animation[] }).getAnimations = () => [
|
||||
{
|
||||
effect: { getComputedTiming: () => ({ endTime: 1000 }) },
|
||||
} as unknown as Animation,
|
||||
];
|
||||
(elB as HTMLElement & { getAnimations?: () => Animation[] }).getAnimations = () => [
|
||||
{
|
||||
effect: { getComputedTiming: () => ({ endTime: 4500 }) },
|
||||
} as unknown as Animation,
|
||||
];
|
||||
|
||||
const adapter = createCssAdapter();
|
||||
adapter.discover();
|
||||
|
||||
expect(adapter.getInferredDurationSeconds?.()).toBe(4.5);
|
||||
|
||||
document.body.removeChild(elA);
|
||||
document.body.removeChild(elB);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("returns null when an animation's endTime is Infinity (infinite iteration count)", () => {
|
||||
const el = document.createElement("div");
|
||||
el.style.animationName = "spin";
|
||||
document.body.appendChild(el);
|
||||
|
||||
vi.spyOn(window, "getComputedStyle").mockImplementation(() => {
|
||||
return { animationName: "spin" } as CSSStyleDeclaration;
|
||||
});
|
||||
|
||||
(el as HTMLElement & { getAnimations?: () => Animation[] }).getAnimations = () => [
|
||||
{
|
||||
effect: { getComputedTiming: () => ({ endTime: Infinity }) },
|
||||
} as unknown as Animation,
|
||||
];
|
||||
|
||||
const adapter = createCssAdapter();
|
||||
adapter.discover();
|
||||
|
||||
expect(adapter.getInferredDurationSeconds?.()).toBeNull();
|
||||
|
||||
document.body.removeChild(el);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("returns the finite animation's end time when a finite and an unbounded animation coexist", () => {
|
||||
const elFinite = document.createElement("div");
|
||||
elFinite.style.animationName = "fadeIn";
|
||||
const elInfinite = document.createElement("div");
|
||||
elInfinite.style.animationName = "spin";
|
||||
document.body.appendChild(elFinite);
|
||||
document.body.appendChild(elInfinite);
|
||||
|
||||
vi.spyOn(window, "getComputedStyle").mockImplementation((target) => {
|
||||
return {
|
||||
animationName: target === elFinite ? "fadeIn" : target === elInfinite ? "spin" : "none",
|
||||
} as CSSStyleDeclaration;
|
||||
});
|
||||
|
||||
(elFinite as HTMLElement & { getAnimations?: () => Animation[] }).getAnimations = () => [
|
||||
{
|
||||
effect: { getComputedTiming: () => ({ endTime: 3000 }) },
|
||||
} as unknown as Animation,
|
||||
];
|
||||
(elInfinite as HTMLElement & { getAnimations?: () => Animation[] }).getAnimations = () => [
|
||||
{
|
||||
effect: { getComputedTiming: () => ({ endTime: Infinity }) },
|
||||
} as unknown as Animation,
|
||||
];
|
||||
|
||||
const adapter = createCssAdapter();
|
||||
adapter.discover();
|
||||
|
||||
// The unbounded "spin" animation is ignored; the finite "fadeIn"
|
||||
// animation's 3s end time is still a valid duration signal.
|
||||
expect(adapter.getInferredDurationSeconds?.()).toBe(3);
|
||||
|
||||
document.body.removeChild(elFinite);
|
||||
document.body.removeChild(elInfinite);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("ignores disconnected elements", () => {
|
||||
const el = document.createElement("div");
|
||||
el.style.animationName = "fadeIn";
|
||||
document.body.appendChild(el);
|
||||
|
||||
vi.spyOn(window, "getComputedStyle").mockImplementation(() => {
|
||||
return { animationName: "fadeIn" } as CSSStyleDeclaration;
|
||||
});
|
||||
(el as HTMLElement & { getAnimations?: () => Animation[] }).getAnimations = () => [
|
||||
{
|
||||
effect: { getComputedTiming: () => ({ endTime: 3000 }) },
|
||||
} as unknown as Animation,
|
||||
];
|
||||
|
||||
const adapter = createCssAdapter();
|
||||
adapter.discover();
|
||||
document.body.removeChild(el);
|
||||
|
||||
expect(adapter.getInferredDurationSeconds?.()).toBeNull();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,6 +20,35 @@ export function createCssAdapter(params?: {
|
||||
}
|
||||
};
|
||||
|
||||
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: { endTime?: number | string } | 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 {
|
||||
@@ -89,13 +118,27 @@ export function createCssAdapter(params?: {
|
||||
});
|
||||
}
|
||||
},
|
||||
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 = params?.resolveStartSeconds
|
||||
? params.resolveStartSeconds(entry.el)
|
||||
: Number.parseFloat(entry.el.getAttribute("data-start") ?? "0") || 0;
|
||||
const start = resolveEntryStartSeconds(entry.el);
|
||||
const localTimeMs = Math.max(0, time - start) * 1000;
|
||||
const animations = entry.animations;
|
||||
if (animations.length > 0) {
|
||||
|
||||
@@ -147,4 +147,47 @@ describe("lottie adapter", () => {
|
||||
expect(() => adapter.revert!()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getInferredDurationSeconds", () => {
|
||||
it("returns null with no registered instances", () => {
|
||||
const adapter = createLottieAdapter();
|
||||
expect(adapter.getInferredDurationSeconds?.()).toBeNull();
|
||||
});
|
||||
|
||||
it("infers duration from lottie-web totalFrames/frameRate", () => {
|
||||
const anim = createLottieWebAnim({ totalFrames: 90, frameRate: 30 });
|
||||
lottieWindow.__hfLottie = [anim];
|
||||
const adapter = createLottieAdapter();
|
||||
expect(adapter.getInferredDurationSeconds?.()).toBe(3);
|
||||
});
|
||||
|
||||
it("infers duration from dotlottie player's duration field", () => {
|
||||
const player = createDotLottiePlayer({ duration: 4.2 });
|
||||
lottieWindow.__hfLottie = [player];
|
||||
const adapter = createLottieAdapter();
|
||||
expect(adapter.getInferredDurationSeconds?.()).toBe(4.2);
|
||||
});
|
||||
|
||||
it("falls back to totalFrames/frameRate when dotlottie duration is absent", () => {
|
||||
const player = createDotLottiePlayer({ totalFrames: 150, frameRate: 30, duration: 0 });
|
||||
lottieWindow.__hfLottie = [player];
|
||||
const adapter = createLottieAdapter();
|
||||
expect(adapter.getInferredDurationSeconds?.()).toBe(5);
|
||||
});
|
||||
|
||||
it("returns the max across multiple registered animations", () => {
|
||||
const short = createLottieWebAnim({ totalFrames: 30, frameRate: 30 });
|
||||
const long = createLottieWebAnim({ totalFrames: 300, frameRate: 30 });
|
||||
lottieWindow.__hfLottie = [short, long];
|
||||
const adapter = createLottieAdapter();
|
||||
expect(adapter.getInferredDurationSeconds?.()).toBe(10);
|
||||
});
|
||||
|
||||
it("returns null when the animation hasn't loaded yet (totalFrames=0)", () => {
|
||||
const anim = createLottieWebAnim({ totalFrames: 0, frameRate: 30 });
|
||||
lottieWindow.__hfLottie = [anim];
|
||||
const adapter = createLottieAdapter();
|
||||
expect(adapter.getInferredDurationSeconds?.()).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -137,9 +137,63 @@ export function createLottieAdapter(): RuntimeDeterministicAdapter {
|
||||
// Don't clear __hfLottie — the animation objects are owned by the composition.
|
||||
// Just let them be garbage collected naturally.
|
||||
},
|
||||
|
||||
getInferredDurationSeconds: () => {
|
||||
const instances = (window as LottieWindow).__hfLottie;
|
||||
if (!instances || instances.length === 0) return null;
|
||||
let maxSeconds = 0;
|
||||
let sawAny = false;
|
||||
for (const anim of instances) {
|
||||
let seconds: number | null = null;
|
||||
try {
|
||||
seconds = inferAnimationDurationSeconds(anim);
|
||||
} catch (err) {
|
||||
// ignore per-animation failures — keep going for other instances
|
||||
swallow("runtime.adapters.lottie.site4", err);
|
||||
}
|
||||
if (seconds == null) continue;
|
||||
sawAny = true;
|
||||
maxSeconds = Math.max(maxSeconds, seconds);
|
||||
}
|
||||
// Not-yet-loaded animations report totalFrames=0 — return null (not 0)
|
||||
// so the caller doesn't treat "still loading" as "genuinely zero
|
||||
// duration". A later discover cycle will pick up the real value once
|
||||
// the JSON has loaded.
|
||||
return sawAny ? maxSeconds : null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** A finite, positive number in seconds derived from a frame count + rate, or null. */
|
||||
function finiteFramesToSeconds(
|
||||
totalFrames: number | undefined,
|
||||
frameRate: number | undefined,
|
||||
): number | null {
|
||||
if (
|
||||
!Number.isFinite(totalFrames) ||
|
||||
!totalFrames ||
|
||||
totalFrames <= 0 ||
|
||||
!Number.isFinite(frameRate) ||
|
||||
!frameRate ||
|
||||
frameRate <= 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return totalFrames / frameRate;
|
||||
}
|
||||
|
||||
/** The inferred duration in seconds for one registered lottie-web/dotLottie instance, or null. */
|
||||
function inferAnimationDurationSeconds(anim: LottieWebAnimation | DotLottiePlayer): number | null {
|
||||
if (isLottieWebAnimation(anim)) {
|
||||
return finiteFramesToSeconds(anim.totalFrames, anim.frameRate);
|
||||
}
|
||||
if (!isDotLottiePlayer(anim)) return null;
|
||||
if (Number.isFinite(anim.duration) && (anim.duration ?? 0) > 0) {
|
||||
return anim.duration ?? null;
|
||||
}
|
||||
return finiteFramesToSeconds(anim.totalFrames, anim.frameRate);
|
||||
}
|
||||
|
||||
// ── Type guards ────────────────────────────────────────────────────────────────
|
||||
|
||||
function isLottieWebAnimation(anim: unknown): anim is LottieWebAnimation {
|
||||
|
||||
@@ -270,4 +270,104 @@ describe("waapi adapter", () => {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
describe("getInferredDurationSeconds", () => {
|
||||
it("returns null when there are no animations", () => {
|
||||
(document as any).getAnimations = vi.fn(() => []);
|
||||
const adapter = createWaapiAdapter();
|
||||
expect(adapter.getInferredDurationSeconds?.()).toBeNull();
|
||||
delete (document as any).getAnimations;
|
||||
});
|
||||
|
||||
it("returns the max finite endTime across animations, in seconds", () => {
|
||||
const short = {
|
||||
pause: vi.fn(),
|
||||
currentTime: 0,
|
||||
effect: { getComputedTiming: () => ({ endTime: 1200 }) },
|
||||
};
|
||||
const long = {
|
||||
pause: vi.fn(),
|
||||
currentTime: 0,
|
||||
effect: { getComputedTiming: () => ({ endTime: 4800 }) },
|
||||
};
|
||||
(document as any).getAnimations = vi.fn(() => [short, long]);
|
||||
|
||||
const adapter = createWaapiAdapter();
|
||||
expect(adapter.getInferredDurationSeconds?.()).toBe(4.8);
|
||||
|
||||
delete (document as any).getAnimations;
|
||||
});
|
||||
|
||||
it("returns the finite animation's end time when a finite and an unbounded animation coexist", () => {
|
||||
const finite = {
|
||||
pause: vi.fn(),
|
||||
currentTime: 0,
|
||||
effect: { getComputedTiming: () => ({ endTime: 2000 }) },
|
||||
};
|
||||
const infinite = {
|
||||
pause: vi.fn(),
|
||||
currentTime: 0,
|
||||
effect: { getComputedTiming: () => ({ endTime: Infinity }) },
|
||||
};
|
||||
(document as any).getAnimations = vi.fn(() => [finite, infinite]);
|
||||
|
||||
const adapter = createWaapiAdapter();
|
||||
// The unbounded animation is ignored; the finite animation's 2s end
|
||||
// time is still a valid duration signal.
|
||||
expect(adapter.getInferredDurationSeconds?.()).toBe(2);
|
||||
|
||||
delete (document as any).getAnimations;
|
||||
});
|
||||
|
||||
it("returns null when every animation has an unbounded (Infinity) endTime", () => {
|
||||
const infinite = {
|
||||
pause: vi.fn(),
|
||||
currentTime: 0,
|
||||
effect: { getComputedTiming: () => ({ endTime: Infinity }) },
|
||||
};
|
||||
(document as any).getAnimations = vi.fn(() => [infinite]);
|
||||
|
||||
const adapter = createWaapiAdapter();
|
||||
expect(adapter.getInferredDurationSeconds?.()).toBeNull();
|
||||
|
||||
delete (document as any).getAnimations;
|
||||
});
|
||||
|
||||
it("accounts for the composition-time baseline of animations discovered mid-composition", () => {
|
||||
const existing = { pause: vi.fn(), currentTime: 0 };
|
||||
let includeDynamic = false;
|
||||
const dynamic: { pause: () => void; currentTime: number; effect?: unknown } = {
|
||||
pause: vi.fn(),
|
||||
currentTime: 0,
|
||||
effect: { getComputedTiming: () => ({ endTime: 1000 }) },
|
||||
};
|
||||
(document as any).getAnimations = vi.fn(() =>
|
||||
includeDynamic ? [existing, dynamic] : [existing],
|
||||
);
|
||||
|
||||
const adapter = createWaapiAdapter();
|
||||
adapter.discover();
|
||||
adapter.seek({ time: 2 });
|
||||
|
||||
// `dynamic` first appears at composition time 2s (t=2 seek) — its
|
||||
// baseline.compositionTimeMs is recorded as 2000ms, so its inferred
|
||||
// end time is 2s (baseline) + 1s (own endTime) = 3s.
|
||||
includeDynamic = true;
|
||||
adapter.seek({ time: 2 });
|
||||
|
||||
expect(adapter.getInferredDurationSeconds?.()).toBe(3);
|
||||
|
||||
delete (document as any).getAnimations;
|
||||
});
|
||||
|
||||
it("handles missing getAnimations API", () => {
|
||||
const original = document.getAnimations;
|
||||
(document as Record<string, unknown>).getAnimations = undefined;
|
||||
|
||||
const adapter = createWaapiAdapter();
|
||||
expect(adapter.getInferredDurationSeconds?.()).toBeNull();
|
||||
|
||||
document.getAnimations = original;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -115,6 +115,30 @@ export function createWaapiAdapter(): RuntimeDeterministicAdapter {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* End time (seconds, relative to composition start) for one animation.
|
||||
* `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 the caller should simply skip.
|
||||
*/
|
||||
const inferAnimationEndSeconds = (
|
||||
animation: Animation,
|
||||
): { endSeconds?: number; unbounded?: true } => {
|
||||
let timing: { endTime?: number | string } | null = null;
|
||||
try {
|
||||
timing = animation.effect?.getComputedTiming?.() ?? null;
|
||||
} catch (err) {
|
||||
swallow("runtime.adapters.waapi.site4", err);
|
||||
}
|
||||
if (!timing) return {};
|
||||
const endTimeMs = Number(timing.endTime);
|
||||
if (!Number.isFinite(endTimeMs)) return { unbounded: true };
|
||||
const compositionStartSeconds = (baselines.get(animation)?.compositionTimeMs ?? 0) / 1000;
|
||||
return { endSeconds: compositionStartSeconds + endTimeMs / 1000 };
|
||||
};
|
||||
|
||||
return {
|
||||
name: "waapi",
|
||||
discover: () => {
|
||||
@@ -190,5 +214,17 @@ export function createWaapiAdapter(): RuntimeDeterministicAdapter {
|
||||
installedAnimate = undefined;
|
||||
animateHookInstalled = false;
|
||||
},
|
||||
getInferredDurationSeconds: () => {
|
||||
let maxEndSeconds = 0;
|
||||
for (const animation of snapshotAnimations()) {
|
||||
const result = inferAnimationEndSeconds(animation);
|
||||
// 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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1204,6 +1204,149 @@ describe("initSandboxRuntimeModular", () => {
|
||||
expect(window.__renderReady).toBe(true);
|
||||
});
|
||||
|
||||
it("infers hf.duration from a CSS animation's computed timing without data-duration or a GSAP timeline", () => {
|
||||
const root = document.createElement("div");
|
||||
root.setAttribute("data-composition-id", "main");
|
||||
root.setAttribute("data-root", "true");
|
||||
root.setAttribute("data-start", "0");
|
||||
root.setAttribute("data-width", "1920");
|
||||
root.setAttribute("data-height", "1080");
|
||||
document.body.appendChild(root);
|
||||
|
||||
const animated = document.createElement("div");
|
||||
animated.style.animationName = "fadeIn";
|
||||
root.appendChild(animated);
|
||||
|
||||
vi.spyOn(window, "getComputedStyle").mockImplementation((target) => {
|
||||
const real =
|
||||
Object.getPrototypeOf(window).getComputedStyle ?? (() => ({}) as CSSStyleDeclaration);
|
||||
return {
|
||||
...real,
|
||||
animationName: target === animated ? "fadeIn" : "none",
|
||||
} as CSSStyleDeclaration;
|
||||
});
|
||||
(animated as HTMLElement & { getAnimations?: () => Animation[] }).getAnimations = () => [
|
||||
{
|
||||
currentTime: 0,
|
||||
pause: () => {},
|
||||
play: () => {},
|
||||
effect: { getComputedTiming: () => ({ endTime: 6000 }) },
|
||||
} as unknown as Animation,
|
||||
];
|
||||
|
||||
window.__timelines = {};
|
||||
|
||||
initSandboxRuntimeModular();
|
||||
|
||||
expect(window.__renderReady).toBe(true);
|
||||
expect(window.__player?.getDuration()).toBe(6);
|
||||
});
|
||||
|
||||
it("still requires data-duration when a CSS animation is infinite (unbounded end time)", () => {
|
||||
const root = document.createElement("div");
|
||||
root.setAttribute("data-composition-id", "main");
|
||||
root.setAttribute("data-root", "true");
|
||||
root.setAttribute("data-start", "0");
|
||||
root.setAttribute("data-width", "1920");
|
||||
root.setAttribute("data-height", "1080");
|
||||
document.body.appendChild(root);
|
||||
|
||||
const animated = document.createElement("div");
|
||||
animated.style.animationName = "spin";
|
||||
root.appendChild(animated);
|
||||
|
||||
vi.spyOn(window, "getComputedStyle").mockImplementation((target) => {
|
||||
const real =
|
||||
Object.getPrototypeOf(window).getComputedStyle ?? (() => ({}) as CSSStyleDeclaration);
|
||||
return {
|
||||
...real,
|
||||
animationName: target === animated ? "spin" : "none",
|
||||
} as CSSStyleDeclaration;
|
||||
});
|
||||
(animated as HTMLElement & { getAnimations?: () => Animation[] }).getAnimations = () => [
|
||||
{
|
||||
currentTime: 0,
|
||||
pause: () => {},
|
||||
play: () => {},
|
||||
effect: { getComputedTiming: () => ({ endTime: Infinity }) },
|
||||
} as unknown as Animation,
|
||||
];
|
||||
|
||||
window.__timelines = {};
|
||||
|
||||
initSandboxRuntimeModular();
|
||||
|
||||
// No data-duration, no GSAP timeline, and the only animation is
|
||||
// unbounded — duration cannot be inferred, so it stays at 0. This is the
|
||||
// case that must still surface the "add data-duration" lint/runtime error.
|
||||
expect(window.__player?.getDuration()).toBe(0);
|
||||
});
|
||||
|
||||
it("infers hf.duration from a registered Lottie animation without data-duration or a GSAP timeline", () => {
|
||||
const root = document.createElement("div");
|
||||
root.setAttribute("data-composition-id", "main");
|
||||
root.setAttribute("data-root", "true");
|
||||
root.setAttribute("data-start", "0");
|
||||
root.setAttribute("data-width", "1920");
|
||||
root.setAttribute("data-height", "1080");
|
||||
document.body.appendChild(root);
|
||||
|
||||
(window as Window & { __hfLottie?: unknown[] }).__hfLottie = [
|
||||
{ play: () => {}, pause: () => {}, totalFrames: 150, frameRate: 30 },
|
||||
];
|
||||
|
||||
window.__timelines = {};
|
||||
|
||||
initSandboxRuntimeModular();
|
||||
|
||||
expect(window.__renderReady).toBe(true);
|
||||
expect(window.__player?.getDuration()).toBe(5);
|
||||
|
||||
delete (window as Window & { __hfLottie?: unknown[] }).__hfLottie;
|
||||
});
|
||||
|
||||
it("regression: a GSAP timeline's duration is unaffected by adapter duration inference", () => {
|
||||
// A GSAP composition can legitimately have an incidental, short CSS
|
||||
// animation running alongside the timeline (e.g. a decorative shimmer).
|
||||
// The GSAP timeline must remain the source of truth for total duration —
|
||||
// the new adapter-inference floor (resolveAdapterDurationFloorSeconds)
|
||||
// must not shrink or otherwise override it.
|
||||
const root = document.createElement("div");
|
||||
root.setAttribute("data-composition-id", "root");
|
||||
root.setAttribute("data-root", "true");
|
||||
root.setAttribute("data-start", "0");
|
||||
root.setAttribute("data-width", "1920");
|
||||
root.setAttribute("data-height", "1080");
|
||||
document.body.appendChild(root);
|
||||
|
||||
const shimmer = document.createElement("div");
|
||||
shimmer.style.animationName = "shimmer";
|
||||
root.appendChild(shimmer);
|
||||
|
||||
vi.spyOn(window, "getComputedStyle").mockImplementation((target) => {
|
||||
return {
|
||||
animationName: target === shimmer ? "shimmer" : "none",
|
||||
} as CSSStyleDeclaration;
|
||||
});
|
||||
(shimmer as HTMLElement & { getAnimations?: () => Animation[] }).getAnimations = () => [
|
||||
{
|
||||
currentTime: 0,
|
||||
pause: () => {},
|
||||
play: () => {},
|
||||
// Much shorter than the GSAP timeline below (2s vs 12s) — must not
|
||||
// become the reported duration.
|
||||
effect: { getComputedTiming: () => ({ endTime: 2000 }) },
|
||||
} as unknown as Animation,
|
||||
];
|
||||
|
||||
window.__timelines = { root: createMockTimeline(12) };
|
||||
|
||||
initSandboxRuntimeModular();
|
||||
|
||||
expect(window.__renderReady).toBe(true);
|
||||
expect(window.__player?.getDuration()).toBe(12);
|
||||
});
|
||||
|
||||
it("seeks captured timeline to currentTime on initial bind", () => {
|
||||
const seekTimes: number[] = [];
|
||||
const tl = createMockTimeline(5);
|
||||
|
||||
@@ -673,6 +673,32 @@ export function initSandboxRuntimeModular(): void {
|
||||
);
|
||||
};
|
||||
|
||||
// Non-GSAP runtimes (CSS, WAAPI, Lottie) have no window.__timelines entry
|
||||
// and thus no authored source of truth for total duration. Adapters that
|
||||
// implement getInferredDurationSeconds() report the longest end time they
|
||||
// can discover from their own animations (see runtime/types.ts). Folding
|
||||
// that into the duration floor here — the same mechanism data-duration and
|
||||
// media windows already use — makes data-duration optional wherever the
|
||||
// runtime can figure the duration out on its own, instead of hard-failing
|
||||
// capture with "Composition has zero duration".
|
||||
const resolveAdapterDurationFloorSeconds = (): number | null => {
|
||||
let maxSeconds = 0;
|
||||
for (const adapter of state.deterministicAdapters) {
|
||||
const getter = adapter.getInferredDurationSeconds;
|
||||
if (typeof getter !== "function") continue;
|
||||
let inferred: number | null = null;
|
||||
try {
|
||||
inferred = getter();
|
||||
} catch (err) {
|
||||
swallow("runtime.init.adapterDuration", err);
|
||||
}
|
||||
if (typeof inferred === "number" && Number.isFinite(inferred) && inferred > 0) {
|
||||
maxSeconds = Math.max(maxSeconds, inferred);
|
||||
}
|
||||
}
|
||||
return maxSeconds > MIN_VALID_TIMELINE_DURATION_SECONDS ? maxSeconds : null;
|
||||
};
|
||||
|
||||
const getSafeTimelineDurationSeconds = (
|
||||
timeline: RuntimeTimelineLike | null,
|
||||
fallback = 0,
|
||||
@@ -680,7 +706,12 @@ export function initSandboxRuntimeModular(): void {
|
||||
const timelineDuration = getTimelineDurationSeconds(timeline);
|
||||
const mediaFloor = resolveMediaDurationFloorSeconds();
|
||||
const authoredCompositionFloor = resolveAuthoredCompositionDurationFloorSeconds();
|
||||
const durationFloor = Math.max(mediaFloor ?? 0, authoredCompositionFloor ?? 0);
|
||||
const adapterFloor = resolveAdapterDurationFloorSeconds();
|
||||
const durationFloor = Math.max(
|
||||
mediaFloor ?? 0,
|
||||
authoredCompositionFloor ?? 0,
|
||||
adapterFloor ?? 0,
|
||||
);
|
||||
const fallbackDuration =
|
||||
Number.isFinite(fallback) && fallback > MIN_VALID_TIMELINE_DURATION_SECONDS ? fallback : 0;
|
||||
let safeDuration = 0;
|
||||
|
||||
@@ -259,6 +259,25 @@ export type RuntimeDeterministicAdapter = {
|
||||
* convention).
|
||||
*/
|
||||
getReadyPromise?: () => PromiseLike<unknown> | null;
|
||||
/**
|
||||
* Optional duration auto-inference. Non-GSAP runtimes (CSS, WAAPI, Lottie)
|
||||
* have no `window.__timelines` entry, so the runtime has no authored source
|
||||
* of truth for total composition length unless the author sets
|
||||
* `data-duration` on the root element. This hook lets an adapter report the
|
||||
* longest end time it can discover from its own animations, so the runtime
|
||||
* can fold it into the duration floor (see `resolveAdapterDurationFloorSeconds`
|
||||
* in `init.ts`) and treat `data-duration` as optional rather than required.
|
||||
*
|
||||
* Return the inferred duration in seconds, or `null` when nothing usable
|
||||
* was discovered (e.g. no animations yet, or an animation with unbounded /
|
||||
* infinite iteration count that can't be resolved to a finite end time —
|
||||
* those compositions must keep declaring `data-duration` explicitly).
|
||||
*
|
||||
* Called on every adapter-discovery cycle (same cadence as `discover`), so
|
||||
* it's safe — and expected — to return a growing value as async work
|
||||
* (Lottie JSON fetch, etc.) resolves.
|
||||
*/
|
||||
getInferredDurationSeconds?: () => number | null;
|
||||
};
|
||||
|
||||
export type RuntimeGsapSetTarget = string | Element | Element[] | null;
|
||||
|
||||
Reference in New Issue
Block a user