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
+288
View File
@@ -1101,4 +1101,292 @@ describe("composition rules", () => {
expect(find(result.findings)).toBeUndefined();
});
});
describe("root_composition_missing_duration_source", () => {
const CODE = "root_composition_missing_duration_source";
const find = (findings: { code: string }[]) => findings.find((f) => f.code === CODE);
it("errors when there is no data-duration, no GSAP timeline, and no animation signal at all", async () => {
const html = `<html><body>
<div data-composition-id="main" data-start="0" data-width="1920" data-height="1080">
<div>static content</div>
</div>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = find(result.findings);
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
});
it("does not error when data-duration is declared on the root", async () => {
const html = `<html><body>
<div data-composition-id="main" data-start="0" data-duration="6" data-width="1920" data-height="1080">
<div>static content</div>
</div>
</body></html>`;
const result = await lintHyperframeHtml(html);
expect(find(result.findings)).toBeUndefined();
});
it("does not error when a GSAP timeline is registered", async () => {
const html = `<html><body>
<div data-composition-id="main" data-start="0" data-width="1920" data-height="1080"></div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
window.__timelines["main"] = tl;
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
expect(find(result.findings)).toBeUndefined();
});
it("does not error for a finite CSS animation (runtime auto-infers duration)", async () => {
const html = `<html><body>
<div data-composition-id="main" data-start="0" data-width="1920" data-height="1080">
<style>
.box { animation: fadeIn 3s ease forwards; }
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
</style>
<div class="box"></div>
</div>
</body></html>`;
const result = await lintHyperframeHtml(html);
expect(find(result.findings)).toBeUndefined();
});
it("does not error for a WAAPI .animate() call (runtime auto-infers duration)", async () => {
const html = `<html><body>
<div data-composition-id="main" data-start="0" data-width="1920" data-height="1080">
<div class="box"></div>
</div>
<script>
document.querySelector(".box").animate([{ opacity: 0 }, { opacity: 1 }], { duration: 2000 });
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
expect(find(result.findings)).toBeUndefined();
});
it("does not error for a registered Lottie animation (runtime auto-infers duration)", async () => {
const html = `<html><body>
<div data-composition-id="main" data-start="0" data-width="1920" data-height="1080">
<div id="anim"></div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/bodymovin/5.12.2/lottie.min.js"></script>
<script>
window.__hfLottie = window.__hfLottie || [];
const anim = lottie.loadAnimation({
container: document.getElementById("anim"),
renderer: "svg",
loop: false,
autoplay: false,
path: "animation.json",
});
window.__hfLottie.push(anim);
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
expect(find(result.findings)).toBeUndefined();
});
it("errors for an infinite CSS animation with no data-duration", async () => {
const html = `<html><body>
<div data-composition-id="main" data-start="0" data-width="1920" data-height="1080">
<style>
.spinner { animation: spin 1s linear infinite; }
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
</style>
<div class="spinner"></div>
</div>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = find(result.findings);
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
});
it("errors for a mixed finite + infinite CSS animation with no data-duration (length is ambiguous)", async () => {
// The runtime CAN infer 3s here (from the finite `fadeIn`), but an
// unbounded `spin infinite` alongside it makes the intended total length
// ambiguous, so the rule stays strict and requires an explicit
// data-duration. Deliberately stricter than runtime inference — see the
// rule's block comment. Message must NOT claim the render will fail
// (it wouldn't — the runtime falls back to the finite animation).
const html = `<html><body>
<div data-composition-id="main" data-start="0" data-width="1920" data-height="1080">
<style>
.fade { animation: fadeIn 3s ease forwards; }
.spinner { animation: spin 1s linear infinite; }
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
</style>
<div class="fade"></div>
<div class="spinner"></div>
</div>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = find(result.findings);
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
// Honest message: describes the ambiguity, does not assert a hard failure.
expect(finding?.message).toContain("ambiguous");
expect(finding?.message).not.toContain("will fail");
});
it("does not error for an infinite CSS animation when data-duration is declared", async () => {
const html = `<html><body>
<div data-composition-id="main" data-start="0" data-duration="8" data-width="1920" data-height="1080">
<style>
.spinner { animation: spin 1s linear infinite; }
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
</style>
<div class="spinner"></div>
</div>
</body></html>`;
const result = await lintHyperframeHtml(html);
expect(find(result.findings)).toBeUndefined();
});
it("errors for Three.js usage with no data-duration", async () => {
const html = `<html><body>
<div data-composition-id="main" data-start="0" data-width="1920" data-height="1080">
<canvas id="scene"></canvas>
</div>
<script src="https://cdn.jsdelivr.net/npm/three@0.160/build/three.min.js"></script>
<script>
const renderer = new THREE.WebGLRenderer();
const scene = new THREE.Scene();
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = find(result.findings);
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
});
it("does not error for Three.js usage when data-duration is declared", async () => {
const html = `<html><body>
<div data-composition-id="main" data-start="0" data-duration="10" data-width="1920" data-height="1080">
<canvas id="scene"></canvas>
</div>
<script src="https://cdn.jsdelivr.net/npm/three@0.160/build/three.min.js"></script>
<script>
const renderer = new THREE.WebGLRenderer();
const scene = new THREE.Scene();
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
expect(find(result.findings)).toBeUndefined();
});
it("does not apply to sub-compositions", async () => {
const html = `<template id="scene-template">
<div data-composition-id="scene" data-start="0" data-width="1920" data-height="1080">
<div>static content</div>
</div>
</template>`;
const result = await lintHyperframeHtml(html, {
filePath: "compositions/scene.html",
isSubComposition: true,
});
expect(find(result.findings)).toBeUndefined();
});
it("errors when the only .animate() call is commented out (no real duration source)", async () => {
const html = `<html><body>
<div data-composition-id="main" data-start="0" data-width="1920" data-height="1080">
<div class="box"></div>
</div>
<script>
// document.querySelector(".box").animate([{ opacity: 0 }, { opacity: 1 }], { duration: 2000 });
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = find(result.findings);
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
});
it("errors when the only CSS animation is inside a comment (no real duration source)", async () => {
const html = `<html><body>
<div data-composition-id="main" data-start="0" data-width="1920" data-height="1080">
<style>
/* .box { animation: spin 2s infinite; } */
.box { color: red; }
</style>
<div class="box"></div>
</div>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = find(result.findings);
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
});
it("does not error for the object-literal (PropertyIndexedKeyframes) WAAPI form", async () => {
const html = `<html><body>
<div data-composition-id="main" data-start="0" data-width="1920" data-height="1080">
<div class="box"></div>
</div>
<script>
document.querySelector(".box").animate({ opacity: [0, 1] }, { duration: 2000 });
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
expect(find(result.findings)).toBeUndefined();
});
it("does not error for a finite CSS animation whose name merely contains 'infinite'", async () => {
const html = `<html><body>
<div data-composition-id="main" data-start="0" data-width="1920" data-height="1080">
<style>
.marquee { animation: infinite-spin 2s ease; }
@keyframes infinite-spin { from { transform: translateX(0); } to { transform: translateX(-100%); } }
</style>
<div class="marquee"></div>
</div>
</body></html>`;
const result = await lintHyperframeHtml(html);
expect(find(result.findings)).toBeUndefined();
});
it("errors for the longhand animation-name + animation-iteration-count: infinite combination", async () => {
const html = `<html><body>
<div data-composition-id="main" data-start="0" data-width="1920" data-height="1080">
<style>
.spinner {
animation-name: infinite-scroll;
animation-duration: 1s;
animation-iteration-count: infinite;
}
@keyframes infinite-scroll { from { transform: translateX(0); } to { transform: translateX(-100%); } }
</style>
<div class="spinner"></div>
</div>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = find(result.findings);
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
});
it("does not error for the longhand animation-name form with a finite iteration count", async () => {
const html = `<html><body>
<div data-composition-id="main" data-start="0" data-width="1920" data-height="1080">
<style>
.spinner {
animation-name: infinite-scroll;
animation-duration: 1s;
animation-iteration-count: 3;
}
@keyframes infinite-scroll { from { transform: translateX(0); } to { transform: translateX(-100%); } }
</style>
<div class="spinner"></div>
</div>
</body></html>`;
const result = await lintHyperframeHtml(html);
expect(find(result.findings)).toBeUndefined();
});
});
});
+152 -1
View File
@@ -1,5 +1,12 @@
import type { LintContext, HyperframeLintFinding, ExtractedBlock } from "../context";
import { findHtmlTag, readAttr, readJsonAttr, stripJsComments, truncateSnippet } from "../utils";
import {
findHtmlTag,
readAttr,
readJsonAttr,
stripJsComments,
truncateSnippet,
WINDOW_TIMELINE_ASSIGN_PATTERN,
} from "../utils";
import { COMPOSITION_VARIABLE_TYPES } from "@hyperframes/parsers/composition";
// Agent guidance thresholds: warning-only nudges for files/tracks that become hard
@@ -720,4 +727,148 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
},
];
},
// root_composition_missing_duration_source
//
// The render engine (packages/engine/src/services/frameCapture.ts) needs a
// positive window.__hf.duration to know how many frames to capture. GSAP
// timelines set this automatically. Non-GSAP runtimes (CSS, WAAPI, Lottie)
// are now auto-inferred by the runtime too (see
// packages/core/src/runtime/init.ts resolveAdapterDurationFloorSeconds and
// the adapters' getInferredDurationSeconds) — so data-duration is optional
// wherever the runtime can work it out on its own.
//
// This rule fires for cases where the total render length is not reliably
// determinable without an explicit data-duration:
// - No GSAP timeline AND no data-duration AND no non-GSAP animation
// signal at all (nothing for any adapter to discover — render fails).
// - Three.js used with no data-duration (no discoverable AnimationClip
// duration in this codebase's adapter — see adapters/three.ts).
// - Any infinite CSS animation-iteration-count with no data-duration,
// EVEN when a finite CSS animation is present alongside it. An unbounded
// animation makes the intended total length ambiguous — the runtime will
// infer a finite sibling's length if one exists, but that's a fallback,
// not a declaration of intent, so we still require data-duration here.
// (This is intentionally stricter than the runtime's own inference.)
// Purely finite CSS/WAAPI animations and Lottie are excluded — the runtime
// infers those unambiguously, so requiring data-duration there would be a
// false positive against the runtime's own auto-inference. Note lint is
// advisory by default (see shouldBlockRender) — it only blocks render under
// --strict/--strict-all — so a strict flag here nudges toward an explicit,
// guaranteed-correct value without failing renders that would succeed.
// fallow-ignore-next-line complexity
({ rootTag, scripts, styles, tags, options }) => {
if (options.isSubComposition) return [];
if (!rootTag) return [];
// Not every file linted as a "root" HTML document is a video composition
// — e.g. a slideshow demo.html mounts <hyperframes-player src="index.html">
// with no data-composition-id of its own. Nothing to capture there, so
// there's no duration contract to enforce.
if (readAttr(rootTag.raw, "data-composition-id") === null) return [];
if (readAttr(rootTag.raw, "data-duration") !== null) return [];
// Strip comments before scanning for signals — a commented-out
// `.animate(...)` call or `/* animation: spin 2s infinite; */` must not
// satisfy the "has a duration source" check, or the composition still
// fails at render with zero duration despite lint passing.
const allScriptTexts = scripts.map((s) => stripJsComments(s.content));
const hasGsapTimeline = allScriptTexts.some((t) => /gsap\.timeline\s*\(/.test(t));
const hasRegisteredTimeline = allScriptTexts.some((t) =>
WINDOW_TIMELINE_ASSIGN_PATTERN.test(t),
);
// A GSAP timeline drives duration via window.__timelines regardless of
// data-duration — nothing to flag once one is registered.
if (hasGsapTimeline && hasRegisteredTimeline) return [];
const allCss = styles.map((s) => s.content).join("\n");
const allInlineStyles = tags.map((t) => readAttr(t.raw, "style") || "").join("\n");
const combinedCss = `${allCss}\n${allInlineStyles}`.replace(/\/\*[\s\S]*?\*\//g, "");
const usesLottie =
tags.some((t) => readAttr(t.raw, "data-lottie-src") !== null) ||
allScriptTexts.some((t) => /lottie\.(loadAnimation)\b|__hfLottie\b/.test(t));
const usesThree = allScriptTexts.some((t) => /\bTHREE\./.test(t));
// `.animate([...], ...)` catches the array-literal keyframes form;
// `.animate({...}, ...)` catches the object-literal (PropertyIndexedKeyframes)
// form; `.animate(someVar, ...)` catches keyframes built up in a variable
// first.
const usesWaapi = allScriptTexts.some((t) => /\.animate\s*\(\s*[[{$A-Za-z_]/.test(t));
const hasCssAnimationName = /\banimation(?:-name)?\s*:/.test(combinedCss);
const hasInfiniteCssAnimation =
/\banimation(?:-iteration-count)?\s*:[^;{}]*(?<![\w-])infinite(?![\w-])/.test(combinedCss);
const hasAnyNonGsapSignal = usesLottie || usesThree || usesWaapi || hasCssAnimationName;
if (!hasAnyNonGsapSignal) {
// No GSAP timeline, no data-duration, and nothing for any adapter to
// discover — the composition has no source of truth for duration at
// all. This is the exact shape of the 27K "zero duration" render
// failures this rule exists to catch before render time.
return [
{
code: "root_composition_missing_duration_source",
severity: "error",
message:
"Root composition has no data-duration, no GSAP timeline, and no CSS/WAAPI/Lottie/Three.js " +
"animation for the runtime to infer a duration from. The render engine cannot determine " +
'how long to capture and will fail with "Composition has zero duration".',
fixHint:
'Add data-duration="<seconds>" to the root element, or add a paused GSAP timeline registered ' +
"on window.__timelines.",
snippet: truncateSnippet(rootTag.raw),
},
];
}
if (usesThree) {
// No AnimationMixer/AnimationClip discovery in the three.js adapter
// today (see adapters/three.ts) — genuinely not inferable.
return [
{
code: "root_composition_missing_duration_source",
severity: "error",
message:
"Root composition uses Three.js with no data-duration. The runtime cannot discover a " +
"Three.js scene's duration automatically (no AnimationClip/AnimationMixer inspection) — " +
'render will fail with "Composition has zero duration".',
fixHint: 'Add data-duration="<seconds>" to the root element.',
snippet: truncateSnippet(rootTag.raw),
},
];
}
if (hasInfiniteCssAnimation && !usesLottie && !usesWaapi) {
// An infinite/unbounded CSS animation makes the intended total length
// ambiguous, so we require an explicit data-duration even when a finite
// CSS animation is present alongside it. This is deliberately stricter
// than the runtime's own inference: the CSS adapter's
// getInferredDurationSeconds (see adapters/css.ts) returns the longest
// finite animation end-time when one exists (so a finite sibling would
// render at that length) and null when every animation is unbounded (so
// a render with no finite source fails outright). Either way the author
// hasn't declared how long the video should be — a decorative infinite
// spinner next to a 3s fade doesn't tell us the clip is meant to be 3s
// — so we flag it and let them state intent. The message stays honest
// about both outcomes rather than claiming the render always fails.
return [
{
code: "root_composition_missing_duration_source",
severity: "error",
message:
"Root composition uses a CSS animation with animation-iteration-count: infinite and no " +
"data-duration, so the intended total length is ambiguous. If a finite animation is also " +
"present the runtime infers that length; with no finite source the render fails with " +
'"Composition has zero duration". Declare the intended length explicitly.',
fixHint:
'Add data-duration="<seconds>" to the root element with the intended total length.',
snippet: truncateSnippet(rootTag.raw),
},
];
}
// Finite CSS animation, WAAPI .animate(), or Lottie — the runtime infers
// duration from these at render time (see resolveAdapterDurationFloorSeconds
// in runtime/init.ts). Not an error; data-duration is optional here.
return [];
},
];