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:
James Russo
2026-07-01 14:28:16 -07:00
committed by GitHub
co-authored by Claude Sonnet 5
parent cf573f7f3f
commit 24edb15095
20 changed files with 1150 additions and 18 deletions
@@ -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();
});
});
});
+46 -3
View File
@@ -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;
},
};
}
+143
View File
@@ -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);
+32 -1
View File
@@ -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;
+19
View File
@@ -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;