fix(runtime): auto-infer composition duration for CSS/WAAPI/Lottie so data-duration is optional#1830
Conversation
miga-heygen
left a comment
There was a problem hiding this comment.
Review: fix(runtime): auto-infer composition duration for CSS/WAAPI/Lottie so data-duration is optional
Summary: Solid, well-scoped fix adding getInferredDurationSeconds() to CSS/WAAPI/Lottie adapters so data-duration becomes optional for finite non-GSAP compositions. Good architecture, strong test coverage, thorough documentation. One correctness gap to address.
Findings:
| # | Location | Severity | Note |
|---|---|---|---|
| 1 | css.ts — getInferredDurationSeconds |
concern | Returns null when any animation is unbounded (sawUnbounded = true), even when finite animations coexist. But the lint rule says "you're fine, no data-duration needed" for compositions with animation-name present. A composition with both fadeIn 3s ease forwards and spin 1s linear infinite passes lint but runtime can't infer duration → zero-duration failure. Fix: return the max finite end-time even when unbounded animations coexist (~3-line change). |
| 2 | waapi.ts — getInferredDurationSeconds |
concern | Same issue as #1 — returns null when sawUnbounded even if finite animations exist. Same fix applies. |
| 3 | init.ts — resolveRootTimelineFromDocument |
suggestion | Computes durationFloorSeconds from only mediaFloor and authoredCompositionFloor, NOT including adapterFloor. getSafeTimelineDurationSeconds does include it. Not a bug today (adapter floor flows through Math.max downstream), but a consistency gap worth a comment. |
| 4 | composition.ts:~268 |
nit | WAAPI detection regex [[$A-Za-z_] — unescaped $ works by luck (literal in character class), [ inside the class is intentional per comment. Correct but not obvious. |
| 5 | frameCapture.ts:~1039-1083 |
nit | Updated hint text fires in the !diag.hasTimeline branch — correct by construction (only called when duration <= 0), but fragile if called elsewhere for diagnostic logging. |
| 6 | Missing test | suggestion | No test for mixed finite + infinite CSS animations on the same composition. Directly tied to finding #1. |
What looks good:
getInferredDurationSecondsaddition toRuntimeDeterministicAdapteris well-typed (optional,number | null), non-breaking- Test coverage is strong: CSS (null/finite/offset/multi-element/infinite/disconnected), WAAPI (null/max/infinite/baseline/missing-API), Lottie (null/lottie-web/dotlottie/fallback/multi/not-loaded), runtime integration (CSS e2e, infinite still requires data-duration, Lottie e2e, GSAP no-regression)
- Lint rule correctly excludes sub-compositions and handles GSAP, CSS, WAAPI, Lottie with separate branches
- Doc updates are thorough and consistent across all four adapter skill docs
data-durationcorrectly marked "Conditional" indata-attributes.md- Autofix rejection is the right call
Ponytail: inferAnimationEndSeconds helpers in css.ts and waapi.ts are near-identical but signatures differ just enough (CSS takes startSeconds, WAAPI computes baseline internally) that extraction would add indirection without real dedup. Leave it. Lean already, one real concern to address.
Verdict: LGTM with concern — The mixed finite+infinite animation gap (findings #1/#2) is where lint and runtime disagree. Not a regression (same failure as before this PR), but it's a gap in the new surface. Recommend fixing before merge with a ~3-line change in each adapter to return max finite end-time even when unbounded animations coexist. Everything else is solid.
— Miga
… 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>
…ion_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.
… 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>
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.
b57d0f8 to
f9a45ef
Compare
vanceingalls
left a comment
There was a problem hiding this comment.
Post-rebase review: fix(runtime): auto-infer composition duration for CSS/WAAPI/Lottie
(James asked for a second look after the rebase; picking up cold, with Miga's earlier LGTM-with-concern as the anchor. Miguel has already approved at 17:17Z; Miga is the outstanding "concern" holder.)
Verdict: LGTM
Miga's core finding — the mixed finite + infinite gap in the CSS and WAAPI adapters — has been addressed exactly as prescribed. The rebased diff also picked up two nice cleanups on the lint rule (comment-stripping, WAAPI object-literal regex, hyphen-anchored infinite matching). The remaining Miga findings are either resolved or standing as accepted suggestion-level.
Post-rebase delta (since Miga's review at 16:49Z)
Four commits landed after Miga's pass:
| SHA | Nature | Addresses |
|---|---|---|
6e53dfa9 |
lint hardening: comment-stripping, WAAPI object-literal regex, hyphen-anchored infinite boundary, animation-name + separate iteration-count combined form |
Finding #4 (regex clarity), plus 3 false-positive/false-negative gaps the rebase surfaced |
1ff8012b |
the fix for Miga's #1/#2 — skip unbounded, keep max finite | Findings #1, #2, #6 (with tests) |
f9a45efa |
pure docs table-separator fix | trivial |
Verifying against HEAD (f9a45efa):
packages/core/src/runtime/adapters/css.ts:121-136—getInferredDurationSeconds()now iterates all animations, callsinferAnimationEndSecondsper animation, and only skips ones whereendSeconds == null(the null return is where unbounded/NaN/Infinity now lands). ReturnsmaxEndSeconds > 0 ? maxEndSeconds : null. Correct shape: a finitefadeIn 3scoexisting with an infinitespinnow yields3, notnull.packages/core/src/runtime/adapters/waapi.ts:217-228— same shape, iteratessnapshotAnimations(), skips null-end animations, returns max-finite-or-null.packages/core/src/runtime/adapters/css.test.ts:273— dedicated "finite + unbounded coexist" test (the exact shape Miga described).waapi.test.ts:301— mirror test withfinite(2s) +infiniteanimations, expects2.waapi.test.ts:322— negative control: all-unbounded → returns null. So the "no finite signal anywhere" fall-through is guarded by test.
Miga's finding #6 ("no test for mixed finite + infinite on same composition") is directly covered — the test files were updated in the same commit as the fix.
Standing concerns
1. Finding #3 (Miga) still applies — init.ts:761-763 resolveRootTimelineFromDocument still computes its local durationFloorSeconds from only mediaFloor + authoredCompositionFloor, omitting adapterFloor. The value feeds into resolveMinCandidateDurationSeconds(...), which drives candidate-timeline filtering. getSafeTimelineDurationSeconds at line 702 does include the adapter floor (line 709), so the runtime's final safe duration is correct. But if a composition is in the "adapter-inferred duration = 3s, no other signal" state, minCandidateDurationSeconds collapses to the tiny MIN_VALID_TIMELINE_DURATION_SECONDS constant during candidate filtering. Not a bug today — no path was demonstrated where filtering with the wrong minimum produces user-visible wrong behavior — but the two floor-computation sites now disagree, and one of them is silently the wrong shape. Consistent extraction to a single helper (or at least a comment at the resolve-root site explaining why adapter floor is omitted) would close this. Severity: consider — acceptable to land as-is, but worth a follow-up.
2. Lint/runtime disagreement now inverts on mixed pure-CSS. With the runtime fix, a composition mixing .fade { animation: fadeIn 3s; } with .spin { animation: spin 1s infinite; } is now inferable at runtime (yields 3s). But the lint rule at packages/lint/src/rules/composition.ts:833 fires root_composition_missing_duration_source on any hasInfiniteCssAnimation when no lottie/waapi is present — including this mixed case. The regex on line 790-791 catches spin 1s infinite, and the finite fadeIn 3s sibling doesn't disqualify. So the exact case Miga flagged as "passes lint but runtime can't infer" is now inverted: the runtime can infer, but lint still errors.
The PR description claims the rule errors "only on genuinely non-inferable cases." Mixed CSS is inferable post-#1/#2 fix, so the description overstates the guarantee. Two ways to reconcile:
- (a) Loosen the lint: if
hasCssAnimationName && !onlyInfiniteCss(i.e. some finite CSS exists alongside the infinite one), suppress the error — matches the runtime behavior. - (b) Keep the lint strict and adjust the description to say "requires
data-durationwhen any CSS animation on the composition is infinite, even if finite siblings exist, because the intent is ambiguous."
Personally I lean (b) — a decorative infinite spinner alongside a finite fade is ambiguous about total render length in a way that "silently take the finite one" doesn't help. But it's a design call for the author. Severity: consider — either resolution is defensible; the current state is only slightly out of step with the PR narrative, not with correctness.
No lint test covers this mixed-CSS case either way — composition.test.ts has "all-finite passes" (1144), "all-infinite errors" (1193), longhand form (1326), and false-positive guards, but no mixed. Whichever direction you resolve #2 in, add the test.
Envelope observation (heads-up, not blocking)
Per Miguel's own convention on hyperframes PRs, the PR body still contains the 🤖 Generated with [Claude Code] trailer and multiple commits carry Co-Authored-By: Claude ... <noreply@anthropic.com> (3eab9af2, 1ff8012b). Historically this warrants request-changes, but noting that Miguel — the one who enforces this — has already approved this PR at 17:17Z, so the envelope is presumably fine here. Flagging only for completeness in case Miguel scans this review.
What looks good
- The fix commit
1ff8012bis exactly the ~3-line-per-adapter shape Miga described. Well-scoped, easy to verify. - The lint hardening in
6e53dfa9is a genuine improvement, not a band-aid — the hyphen-anchoredinfiniteboundary is the right regex fix for theinfinite-spinclass-name false positive, and the object-literal WAAPI regex broadening covers a shape the original rule silently missed. - Test coverage for the fix is targeted and complete on the adapter side (finite+unbounded coexist, all-unbounded null).
getInferredDurationSecondsreturningnulluniformly onendSeconds == null(rather than a boolean flag + early return) is a cleaner shape than what Miga speculated on. NosawUnboundedflag anywhere in the final code.
Review by Via
…e 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.
vanceingalls
left a comment
There was a problem hiding this comment.
R2 — LGTM (head 72909a2c)
Returning to the case at James's request, magnifying glass in hand, on the singular commit added since R1 (f9a45efa → 72909a2c, +28/-0 test, +30/-13 rule).
The delta is surgically confined to packages/lint/src/rules/composition.ts and its sibling test file — no runtime code moved, no adapter re-shaped, no scope creep. A single question posed: does the new commit dispose of R1's two consider items? Let us examine each in turn.
Consider #2 (lint/runtime disagreement) — resolved, and resolved by the more honest of the two roads
R1 flagged that composition.ts:833 continued to claim the render will fail on any infinite CSS, when the finite+infinite adapter fix in 1ff8012b had already taught the runtime to fall back to the finite sibling. Two doors were open: loosen the lint (option B), or keep the lint strict and correct the message (option A). The author has chosen A — deliberately, and with documented reasoning.
composition.ts:838-861— the block comment and error message are both rewritten. The rule now openly declares itself "intentionally stricter than the runtime's own inference," and the message no longer asserts a hard failure. It describes both outcomes accurately: with a finite sibling, the runtime infers that length; with no finite source, the render fails. Author intent is called out as the reason we still requiredata-duration— a decorative infinite spinner alongside a 3s fade does not declare the clip is meant to be 3s.composition.test.ts:1209-1236— the new test locks the honest wording in place:expect(finding?.message).toContain("ambiguous")ANDexpect(finding?.message).not.toContain("will fail"). Symmetric assertion, not merely additive. The false claim cannot regress silently.- Consistent with the PR description, which already declared the stricter-than-runtime position under the "Adds a new lint rule" bullet. The prose and the code now agree.
The stance is defensible: data-duration is the one duration signal guaranteed correct across every adapter, known and future; the rule is advisory by default (blocks only under --strict/--strict-all); the strictness nudges rather than fails. No band-aid, no contradictory rule, no silent scope gap.
Consider #1 (init.ts:761-763 — adapterFloor omitted from resolveRootTimelineFromDocument's local durationFloorSeconds) — untouched
Read the file at head SHA:
const durationFloorSeconds =
Math.max(mediaDurationFloorSeconds ?? 0, authoredCompositionDurationFloorSeconds ?? 0) ||
null;
Still no adapterFloor term. The two floor-computation sites (resolveRootTimelineFromDocument's local computation vs. the primary path in initSandboxRuntimeModular) still disagree on whether adapter-inferred duration contributes to the floor. This was consider-level at R1 and remains consider-level at R2 — it is not a merge-blocker, but it is a real discrepancy worth a follow-up cleanup PR.
Miga's finite+infinite fix (1ff8012b)
Not touched by this delta — verified. The adapter still returns the max finite end time and skips unbounded entries instead of short-circuiting. Intact.
CI
All required checks green at time of review. Regression shards and Windows tests pending in the usual pattern; not blocking.
Envelope
The new commit 72909a2c itself is clean — no Co-Authored-By: Claude trailer. Pre-existing envelope from earlier commits (3eab9af2 Co-Authored-By: Claude Sonnet 5, 1ff8012b Co-Authored-By: Claude) and the 🤖 Generated with [Claude Code] in the PR body remain from the R1 SHA. Miguel approved with the envelope in place at R1, so heads-up only from me.
Verdict
LGTM. Ship it.
R2 by Via
miguel-heygen
left a comment
There was a problem hiding this comment.
R2 at 72909a2c: LGTM.
Audited: packages/core/src/runtime/adapters/{css,waapi,lottie}.ts, their adapter tests, packages/core/src/runtime/init.ts, and packages/lint/src/rules/composition.{ts,test.ts}.
Trusting: skill-doc/manifest updates and the full render/regression matrix beyond the GitHub check state.
What I verified:
packages/core/src/runtime/adapters/css.ts:121andpackages/core/src/runtime/adapters/waapi.ts:217now skip unbounded animations while preserving the max finite end-time, so Miga's mixed finite+infinite gap is closed. The paired regression tests incss.test.tsandwaapi.test.tspin both mixed and all-unbounded behavior.packages/lint/src/rules/composition.ts:840keeps the infinite-CSS rule strict by product intent, but the message now describes ambiguity instead of claiming every mixed case fails at render.packages/lint/src/rules/composition.test.ts:1210locks that wording and the mixed finite+infinite case.- The runtime duration floor integration is still through the existing safe-duration path, so finite CSS/WAAPI/Lottie compositions can produce a positive
hf.durationwithoutdata-duration.
Non-blocking follow-up: Via's resolveRootTimelineFromDocument floor note still stands. packages/core/src/runtime/init.ts:761 computes the local candidate floor without the adapter floor while getSafeTimelineDurationSeconds includes it, so a helper/comment cleanup would reduce future drift. I don't see a current user-visible failure from it.
No failing checks at review time; remaining pending checks are the long regression/Windows runs.
Verdict: APPROVE
Reasoning: The prior correctness concern is fixed at both adapter boundaries and covered by focused regressions; the lint/runtime policy is now explicit and tested, with only follow-up-level cleanup remaining.
— Magi
Summary
data-durationon the root element, and capture hard-failed when neither was present.getInferredDurationSeconds()to the CSS, WAAPI, and Lottie runtime adapters (packages/core/src/runtime/adapters/{css,waapi,lottie}.ts). Each reports the longest finite end time it can discover from its own animations:getAnimations()[].effect.getComputedTiming().endTime, offset by that element'sdata-start.document.getAnimations()[].effect.getComputedTiming().endTime, offset by the animation's composition-time baseline.totalFrames / frameRatefor lottie-web, or the player's owndurationfor dotLottie) — finite regardless ofloop.animation-iteration-count: infinite, WAAPI infiniteiterations) correctly returnnull— they genuinely have no derivable duration and still requiredata-duration.hf-seek; there's noAnimationClip/AnimationMixerinspection to infer from, sodata-durationremains required there.resolveAdapterDurationFloorSecondsinpackages/core/src/runtime/init.ts), alongside the pre-existing media-duration and authored-composition-duration floors.window.__hf.durationbecomes positive without any author action for finite-duration non-GSAP compositions.frameCapture.ts's zero-duration fast-fail gate to also checkhf.durationdirectly (not just "no GSAP timeline + no data-duration"), so a composition whose duration inference hasn't landed yet isn't fast-failed prematurely.root_composition_missing_duration_source, that errors when the total render length isn't reliably determinable without an explicitdata-duration: no animation signal at all for any adapter to discover, Three.js withoutdata-duration, or any infinite/unbounded CSS animation withoutdata-duration— including when a finite animation is present alongside it. The infinite case is deliberately stricter than the runtime's own inference: the runtime will fall back to a finite sibling's length if one exists, but an unbounded animation makes the intended total length ambiguous (a decorative infinite spinner next to a 3s fade doesn't declare the clip is meant to be 3s), so the rule requires the author to state intent. It is deliberately silent on purely finite CSS/WAAPI/Lottie animations, since the runtime infers those unambiguously. Note the rule is advisory by default (seeshouldBlockRender) — it only blocks render under--strict/--strict-all, so strictness here nudges toward an explicit, guaranteed-correct value without failing renders that would otherwise succeed.skills/hyperframes-animation/adapters/*.md) and thehyperframes-coredeterminism-rules/data-attributes references to document the new optionality and point at the runtime mechanism backing it.On the autofix
The task sketch asked for an autofix that inserts the inferred
data-durationvalue. I considered and rejected this: no case the rule flags has an unambiguous value to insert — a duration-less Three.js scene has nothing to measure, "no animation at all" has nothing to infer from, and an infinite animation makes the intended total length ambiguous even when a finite sibling gives the runtime a length to fall back on (that fallback is not necessarily the author's intended clip length). An autofix here would have to commit to a number the author never declared, trading a loud, correct prompt-for-intent for a silent, possibly-wrong-length render — which directly undercuts the goal of making duration-less compositions structurally hard to ship. A hard lint error with a clearfixHintis the correct structural fix; the unambiguously-inferable cases (purely finite CSS/WAAPI/Lottie) need no fix at all, because the runtime handles them.Verification
bun run build && bun run test) passes: 2118 core tests, 937 CLI tests, 783 engine tests, 1176 studio tests, 310 player tests, 10 shader-transitions tests — all green. (Theproducerpackage's regression harness has a pre-existing, environment-local fixture issue — a 404 on a remote-media test asset — reproduced identically onmainwith this branch's changes stashed; confirmed unrelated via a controlled single-suite run before/after.)animation: fadeSlide 3s, no GSAP timeline, nodata-durationanywhere) throughhyperframes render. Previously this would fail with "Composition has zero duration"; it now renders a correct 3.000s MP4 (verified viaffprobe).animation-iteration-count: infiniteinstead still fails fast with the expected diagnostic, andnpx hyperframes lintflags it withroot_composition_missing_duration_sourcebefore render — confirming lint and runtime agree.getInferredDurationSecondson CSS/WAAPI/Lottie (finite, multi-element max, infinite/unbounded, mid-composition discovery), runtime integration tests inpackages/core/src/runtime/init.test.ts(CSS/Lottie inference end-to-end viainitSandboxRuntimeModular, infinite-CSS still requiringdata-duration, and an explicit GSAP-no-regression test confirming a GSAP timeline's duration is unaffected by the new adapter floor even with a shorter incidental CSS animation present), and lint rule tests covering all branches plus a false-positive guard (non-composition root files, e.g. slideshowdemo.html, are correctly ignored — verified against all 112 real.htmlfiles underregistry/blocksandregistry/examples, zero false positives).Test plan
bun run build— all packages build cleanbun run test— core/cli/engine/studio/player/shader-transitions all greenbunx oxlint/bunx oxfmt --checkon all changed filestsc --noEmitoncoreandenginedata-duration→ correct-duration MP4; infinite CSS, nodata-duration→ expected fast-fail)npx hyperframes lintagreement check on the same two fixtures🤖 Generated with Claude Code