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;
},
};
}