From 3eab9af2f2bb69f6b4669d3c3a1a63887969bc0e Mon Sep 17 00:00:00 2001 From: James Date: Wed, 1 Jul 2026 01:45:00 -0700 Subject: [PATCH 1/5] fix(runtime): auto-infer composition duration for CSS/WAAPI/Lottie so data-duration is optional MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .fallowrc.jsonc | 7 + .../core/src/runtime/adapters/css.test.ts | 117 +++++++++++++ packages/core/src/runtime/adapters/css.ts | 50 +++++- .../core/src/runtime/adapters/lottie.test.ts | 43 +++++ packages/core/src/runtime/adapters/lottie.ts | 54 ++++++ .../core/src/runtime/adapters/waapi.test.ts | 84 +++++++++ packages/core/src/runtime/adapters/waapi.ts | 36 ++++ packages/core/src/runtime/init.test.ts | 143 +++++++++++++++ packages/core/src/runtime/init.ts | 33 +++- packages/core/src/runtime/types.ts | 19 ++ packages/engine/src/services/frameCapture.ts | 23 ++- packages/lint/src/rules/composition.test.ts | 164 ++++++++++++++++++ packages/lint/src/rules/composition.ts | 133 +++++++++++++- .../adapters/css-animations.md | 21 ++- .../hyperframes-animation/adapters/lottie.md | 5 + .../hyperframes-animation/adapters/three.md | 2 + .../hyperframes-animation/adapters/waapi.md | 7 + .../references/data-attributes.md | 16 +- .../references/determinism-rules.md | 11 ++ 19 files changed, 952 insertions(+), 16 deletions(-) diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index 6ce9ccca3f..5608f13478 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -423,6 +423,13 @@ // all at the lowest tier (<=5 cyclomatic). "packages/cli/src/commands/layout-audit.browser.js", "packages/cli/src/commands/contrast-audit.browser.js", + // lottie.ts: the flagged `seek` handler (lottie-web/dotLottie dual-API + // dispatch, unchanged by the duration-auto-inference work in this PR) + // pre-dates this PR's scope. New functions added earlier in the file + // (getInferredDurationSeconds + helpers) shifted its line numbers, + // breaking fallow's inherited-detection fingerprint. File-level + // exemption avoids the line-shift problem for this inherited finding. + "packages/core/src/runtime/adapters/lottie.ts", ], }, } diff --git a/packages/core/src/runtime/adapters/css.test.ts b/packages/core/src/runtime/adapters/css.test.ts index b93a65def8..42b16cc113 100644 --- a/packages/core/src/runtime/adapters/css.test.ts +++ b/packages/core/src/runtime/adapters/css.test.ts @@ -175,4 +175,121 @@ 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("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(); + }); + }); }); diff --git a/packages/core/src/runtime/adapters/css.ts b/packages/core/src/runtime/adapters/css.ts index 950d319623..0b5c6f0ad8 100644 --- a/packages/core/src/runtime/adapters/css.ts +++ b/packages/core/src/runtime/adapters/css.ts @@ -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,28 @@ export function createCssAdapter(params?: { }); } }, + getInferredDurationSeconds: () => { + let maxEndSeconds = 0; + let sawUnbounded = false; + 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); + if (result.unbounded) sawUnbounded = true; + if (result.endSeconds != null) maxEndSeconds = Math.max(maxEndSeconds, result.endSeconds); + } + } + // Infinity (or NaN) on any animation — unbounded/infinite iteration + // count, can't auto-infer. The caller must keep declaring data-duration. + if (sawUnbounded) return null; + 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) { diff --git a/packages/core/src/runtime/adapters/lottie.test.ts b/packages/core/src/runtime/adapters/lottie.test.ts index a8e5eb4a53..5e04d1dc08 100644 --- a/packages/core/src/runtime/adapters/lottie.test.ts +++ b/packages/core/src/runtime/adapters/lottie.test.ts @@ -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(); + }); + }); }); diff --git a/packages/core/src/runtime/adapters/lottie.ts b/packages/core/src/runtime/adapters/lottie.ts index e304f61f76..9bf747a39e 100644 --- a/packages/core/src/runtime/adapters/lottie.ts +++ b/packages/core/src/runtime/adapters/lottie.ts @@ -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 { diff --git a/packages/core/src/runtime/adapters/waapi.test.ts b/packages/core/src/runtime/adapters/waapi.test.ts index fd8ec8660f..6941ecde43 100644 --- a/packages/core/src/runtime/adapters/waapi.test.ts +++ b/packages/core/src/runtime/adapters/waapi.test.ts @@ -270,4 +270,88 @@ 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 null when any animation has an unbounded (Infinity) endTime", () => { + 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(); + 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).getAnimations = undefined; + + const adapter = createWaapiAdapter(); + expect(adapter.getInferredDurationSeconds?.()).toBeNull(); + + document.getAnimations = original; + }); + }); }); diff --git a/packages/core/src/runtime/adapters/waapi.ts b/packages/core/src/runtime/adapters/waapi.ts index 5b5ffc4b71..4541ed3cad 100644 --- a/packages/core/src/runtime/adapters/waapi.ts +++ b/packages/core/src/runtime/adapters/waapi.ts @@ -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; + let sawUnbounded = false; + for (const animation of snapshotAnimations()) { + const result = inferAnimationEndSeconds(animation); + if (result.unbounded) sawUnbounded = true; + if (result.endSeconds != null) maxEndSeconds = Math.max(maxEndSeconds, result.endSeconds); + } + // Infinity/NaN on any animation — unbounded iteration count, can't auto-infer. + if (sawUnbounded) return null; + return maxEndSeconds > 0 ? maxEndSeconds : null; + }, }; } diff --git a/packages/core/src/runtime/init.test.ts b/packages/core/src/runtime/init.test.ts index 2e73d4e79e..515a4a0874 100644 --- a/packages/core/src/runtime/init.test.ts +++ b/packages/core/src/runtime/init.test.ts @@ -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); diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index 8fedc633b4..c045ada4d8 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -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; diff --git a/packages/core/src/runtime/types.ts b/packages/core/src/runtime/types.ts index 00cbb29909..ade82a5c17 100644 --- a/packages/core/src/runtime/types.ts +++ b/packages/core/src/runtime/types.ts @@ -259,6 +259,25 @@ export type RuntimeDeterministicAdapter = { * convention). */ getReadyPromise?: () => PromiseLike | 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; diff --git a/packages/engine/src/services/frameCapture.ts b/packages/engine/src/services/frameCapture.ts index 45558d47f4..98cb5df0a7 100644 --- a/packages/engine/src/services/frameCapture.ts +++ b/packages/engine/src/services/frameCapture.ts @@ -588,7 +588,11 @@ function buildZeroDurationDiagnostic(diag: { if (!diag.hasTimeline) { hints.push( "No GSAP timeline registered (window.__timelines is empty). " + - "If using CSS/WAAPI/Lottie/Three.js animations, add data-duration to the root element.", + "CSS/WAAPI/Lottie animations are usually auto-detected (the runtime infers " + + "duration from the longest running animation) — this composition's duration " + + "could not be inferred, which usually means an infinite/unbounded animation " + + "(e.g. animation-iteration-count: infinite, repeat: -1 WAAPI, or a looping Lottie " + + "clip) or a Three.js scene with no discoverable AnimationClip.", ); } if (diag.declaredDuration <= 0 && !diag.hasTimeline) { @@ -642,13 +646,26 @@ async function pollHfReady(page: Page, timeoutMs: number, intervalMs: number = 1 if (now - lastDiagnosticAt >= DIAGNOSTIC_INTERVAL_MS) { lastDiagnosticAt = now; const diag = await evaluateHfDiagnostic(page); - // Only fast-fail when BOTH signals are permanently zero: + // Only fast-fail when ALL signals are permanently zero: // 1. No GSAP timeline registered (GSAP sets duration synchronously // before __renderReady, so a missing timeline won't self-correct). // 2. No data-duration declared on the root element. + // 3. hf.duration is still 0 — this also covers CSS/WAAPI/Lottie + // auto-inference (see runtime/init.ts resolveAdapterDurationFloorSeconds): + // those runtimes report a non-zero hf.duration once discovery + // resolves, without any GSAP timeline or data-duration. Checking + // hf.duration directly (rather than only the two authored + // signals) avoids fast-failing a composition whose inferred + // duration just hasn't landed yet. // A composition with a GSAP timeline but no data-duration is still // valid — GSAP drives duration via __timelines, not data-duration. - if (diag.renderReady && diag.hasSeek && !diag.hasTimeline && diag.declaredDuration <= 0) { + if ( + diag.renderReady && + diag.hasSeek && + !diag.hasTimeline && + diag.declaredDuration <= 0 && + diag.duration <= 0 + ) { throw new Error(buildZeroDurationDiagnostic(diag)); } } diff --git a/packages/lint/src/rules/composition.test.ts b/packages/lint/src/rules/composition.test.ts index 6e308a2a84..f668370436 100644 --- a/packages/lint/src/rules/composition.test.ts +++ b/packages/lint/src/rules/composition.test.ts @@ -1101,4 +1101,168 @@ 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 = ` +
+
static content
+
+ `; + 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 = ` +
+
static content
+
+ `; + const result = await lintHyperframeHtml(html); + expect(find(result.findings)).toBeUndefined(); + }); + + it("does not error when a GSAP timeline is registered", async () => { + const 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 = ` +
+ +
+
+ `; + 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 = ` +
+
+
+ + `; + 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 = ` +
+
+
+ + + `; + const result = await lintHyperframeHtml(html); + expect(find(result.findings)).toBeUndefined(); + }); + + it("errors for an infinite CSS animation with no data-duration", async () => { + const html = ` +
+ +
+
+ `; + const result = await lintHyperframeHtml(html); + const finding = find(result.findings); + expect(finding).toBeDefined(); + expect(finding?.severity).toBe("error"); + }); + + it("does not error for an infinite CSS animation when data-duration is declared", async () => { + const html = ` +
+ +
+
+ `; + const result = await lintHyperframeHtml(html); + expect(find(result.findings)).toBeUndefined(); + }); + + it("errors for Three.js usage with no data-duration", async () => { + const 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 = ` +
+ +
+ + + `; + const result = await lintHyperframeHtml(html); + expect(find(result.findings)).toBeUndefined(); + }); + + it("does not apply to sub-compositions", async () => { + const html = ``; + const result = await lintHyperframeHtml(html, { + filePath: "compositions/scene.html", + isSubComposition: true, + }); + expect(find(result.findings)).toBeUndefined(); + }); + }); }); diff --git a/packages/lint/src/rules/composition.ts b/packages/lint/src/rules/composition.ts index 1d296a2277..2c5f6d47a0 100644 --- a/packages/lint/src/rules/composition.ts +++ b/packages/lint/src/rules/composition.ts @@ -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,128 @@ 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 only fires for the cases that are NOT inferable, matching what + // the runtime itself can and can't determine: + // - No GSAP timeline AND no data-duration AND no non-GSAP animation + // signal at all (nothing for any adapter to discover). + // - CSS animation-iteration-count: infinite with no finite CSS animation + // alongside it (unbounded — the CSS adapter returns null). + // - Three.js used with no data-duration (no discoverable AnimationClip + // duration in this codebase's adapter — see adapters/three.ts). + // Lottie and finite CSS/WAAPI animations are excluded — the runtime infers + // those, so requiring data-duration there would be a false positive against + // the runtime's own auto-inference. + // 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 + // 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 []; + + const allScriptTexts = scripts.map((s) => 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}`; + + 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 common inline-keyframes call; + // `.animate(someVar, ...)` catches keyframes built up in a variable first + // (the inline form can't be a false negative — WAAPI's own signature + // requires an array/object as the first argument either way). + 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*:[^;{}]*\binfinite\b/.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="" 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="" to the root element.', + snippet: truncateSnippet(rootTag.raw), + }, + ]; + } + + if (hasInfiniteCssAnimation && !usesLottie && !usesWaapi) { + // An infinite/unbounded CSS animation-iteration-count can't be resolved + // to a finite end time — the CSS adapter's getInferredDurationSeconds + // returns null in this case (see adapters/css.ts). + 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. An infinite/unbounded animation has no finite end time for the runtime to " + + 'infer — render will fail with "Composition has zero duration".', + fixHint: + 'Add data-duration="" 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 []; + }, ]; diff --git a/skills/hyperframes-animation/adapters/css-animations.md b/skills/hyperframes-animation/adapters/css-animations.md index 64a9167f7c..9525433379 100644 --- a/skills/hyperframes-animation/adapters/css-animations.md +++ b/skills/hyperframes-animation/adapters/css-animations.md @@ -103,11 +103,29 @@ Use CSS custom properties to avoid duplicating keyframes: ## Avoid -- Infinite CSS animations unless you have verified the browser exposes seekable WAAPI-backed CSS animation handles. Prefer a finite iteration count covering the visible duration. +- Infinite CSS animations unless you have verified the browser exposes seekable WAAPI-backed CSS animation handles. Prefer a finite iteration count covering the visible duration. If you do use `infinite`, add `data-duration` to the root element — see Composition Duration below. - Animating layout properties like `top`, `left`, `width`, or `height` when transforms work. - Relying on hover, focus, scroll, or media queries to trigger render-critical motion. - Changing animation classes after startup unless another deterministic timeline controls that change. +## Composition Duration + +The render engine needs to know the composition's total length. GSAP timelines report this automatically; CSS-only compositions have no timeline object, so the runtime infers duration from the longest running animation's computed end time (`animation-delay` + `animation-duration` × finite `animation-iteration-count`, per element with `data-start` added as an offset). `data-duration` on the root element is optional whenever every CSS animation on the page is finite — you don't need to add it just because the composition is CSS-driven. + +`animation-iteration-count: infinite` (or any unresolved/unbounded animation) has no finite end time, so it cannot be auto-inferred. If the composition's only animation is infinite, you **must** add `data-duration=""` to the root `[data-composition-id]` element with your intended total length — `npx hyperframes lint` errors on this case (`root_composition_missing_duration_source`) precisely because there is nothing for the runtime to infer. + +```html +
+
+
+``` + ## Validation After editing CSS animation compositions: @@ -120,5 +138,6 @@ npx hyperframes validate ## Credits And References - HyperFrames adapter source: `packages/core/src/runtime/adapters/css.ts`. +- Duration auto-inference: `packages/core/src/runtime/init.ts` (`resolveAdapterDurationFloorSeconds`), `getInferredDurationSeconds` in the adapter above. - MDN CSS animation documentation: https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/animation - MDN `animation-fill-mode`: https://developer.mozilla.org/en-US/docs/Web/CSS/animation-fill-mode diff --git a/skills/hyperframes-animation/adapters/lottie.md b/skills/hyperframes-animation/adapters/lottie.md index 0db45cbcfa..073bb14096 100644 --- a/skills/hyperframes-animation/adapters/lottie.md +++ b/skills/hyperframes-animation/adapters/lottie.md @@ -82,6 +82,10 @@ window.__hfLottie.push(confettiAnim); HyperFrames seeks them all to the same composition time. +## Composition Duration + +The render engine needs the composition's total length. GSAP timelines report duration automatically; a Lottie-only composition has no timeline object, so the runtime reads the registered animation's native length directly — `totalFrames / frameRate` for `lottie-web`, or the player's own `duration` for dotLottie. `data-duration` on the root element is optional for Lottie compositions: as long as every animation is registered on `window.__hfLottie` (per the contract above), the runtime has a finite duration to work with even when you set `loop: true`. + ## Good Uses - After Effects exports that are already known to render correctly in lottie-web. @@ -107,6 +111,7 @@ npx hyperframes validate ## Credits And References - HyperFrames adapter source: `packages/core/src/runtime/adapters/lottie.ts`. +- Duration auto-inference: `packages/core/src/runtime/init.ts` (`resolveAdapterDurationFloorSeconds`), `getInferredDurationSeconds` in the adapter above. - lottie-web by Airbnb: https://github.com/airbnb/lottie-web - lottie-web `loadAnimation` options: https://github.com/airbnb/lottie-web/wiki/loadAnimation-options - dotLottie web player methods by LottieFiles: https://developers.lottiefiles.com/docs/dotlottie-player/dotlottie-web/methods diff --git a/skills/hyperframes-animation/adapters/three.md b/skills/hyperframes-animation/adapters/three.md index 1a59172abd..2d42577a39 100644 --- a/skills/hyperframes-animation/adapters/three.md +++ b/skills/hyperframes-animation/adapters/three.md @@ -14,6 +14,7 @@ HyperFrames supports Three.js through its `three` runtime adapter. The adapter d - Listen for the `hf-seek` event and render exactly that time. - Load models, textures, and HDRIs before render-critical seeking. Do not fetch them at seek time. - Avoid `requestAnimationFrame` or `renderer.setAnimationLoop` as the source of truth for render-critical motion. +- **Always set `data-duration=""` on the root `[data-composition-id]` element.** Unlike CSS/WAAPI/Lottie, the `three` adapter has no duration auto-inference — it only forwards time via `hf-seek`/`__hfThreeTime`, it doesn't inspect your scene for an `AnimationClip`/`AnimationMixer` length. Without `data-duration` (and no GSAP timeline), the render engine has no way to know how long to capture and fails with "Composition has zero duration". `npx hyperframes lint` errors on this (`root_composition_missing_duration_source`). The adapter sets `window.__hfThreeTime` and dispatches `new CustomEvent("hf-seek", { detail: { time } })` on each seek. @@ -125,5 +126,6 @@ npx hyperframes validate ## Credits And References - HyperFrames adapter source: `packages/core/src/runtime/adapters/three.ts`. +- Why `data-duration` is required here specifically (no auto-inference for this adapter): `packages/core/src/runtime/init.ts` (`resolveAdapterDurationFloorSeconds`) and the CSS/WAAPI/Lottie adapters' `getInferredDurationSeconds`, which the `three` adapter deliberately does not implement. - Three.js `WebGLRenderer` docs: https://threejs.org/docs/pages/WebGLRenderer.html - Three.js `AnimationMixer.setTime()` docs: https://threejs.org/docs/pages/AnimationMixer.html diff --git a/skills/hyperframes-animation/adapters/waapi.md b/skills/hyperframes-animation/adapters/waapi.md index 19ac007636..326256ad4f 100644 --- a/skills/hyperframes-animation/adapters/waapi.md +++ b/skills/hyperframes-animation/adapters/waapi.md @@ -70,6 +70,12 @@ document.querySelectorAll(".token").forEach((token, index) => { - Generated animations from structured data. - Simple timelines that can be represented as keyframes, delays, and offsets. +## Composition Duration + +The render engine needs the composition's total length to know how many frames to capture. GSAP timelines report duration automatically; a WAAPI-only composition has no timeline object, so the runtime infers duration from every animation's `effect.getComputedTiming().endTime` (offset by when the animation was created relative to composition start). `data-duration` on the root element is optional as long as every `element.animate()` call uses finite `duration` and `iterations` — which the contract above already requires. + +Infinite `iterations` has no finite `endTime`, so it can't be auto-inferred — that's one more reason to avoid it (see Avoid below). If you must use it, add `data-duration=""` to the root `[data-composition-id]` element or `npx hyperframes lint` will error (`root_composition_missing_duration_source`). + ## Avoid - Infinite `iterations`. @@ -90,5 +96,6 @@ npx hyperframes validate ## Credits And References - HyperFrames adapter source: `packages/core/src/runtime/adapters/waapi.ts`. +- Duration auto-inference: `packages/core/src/runtime/init.ts` (`resolveAdapterDurationFloorSeconds`), `getInferredDurationSeconds` in the adapter above. - MDN Web Animations API guide: https://developer.mozilla.org/docs/Web/API/Web_Animations_API/Using_the_Web_Animations_API - MDN `Animation.currentTime`: https://developer.mozilla.org/en-US/docs/Web/API/Animation/currentTime diff --git a/skills/hyperframes-core/references/data-attributes.md b/skills/hyperframes-core/references/data-attributes.md index 5a0d9e1632..34a8c38105 100644 --- a/skills/hyperframes-core/references/data-attributes.md +++ b/skills/hyperframes-core/references/data-attributes.md @@ -6,13 +6,15 @@ Every HyperFrames composition uses `data-*` attributes to declare timing and str Every renderable composition needs one root element: -| Attribute | Required | Meaning | -| ---------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `data-composition-id` | Yes | Unique ID. Must match the animation registry key on `window.__timelines`. | -| `data-width` / `data-height` | Yes | Pixel frame size. Common values: `1920x1080`, `1080x1920`, `1080x1080`. | -| `data-duration` | Yes | Render duration in seconds (total length / frame count), not the GSAP timeline length. **Read once at compile time, like `data-width` / `data-height`**: a static root `data-duration` is locked before scripts run, so a script (`root.setAttribute("data-duration", ...)`) or a `--variables`-driven value cannot change the render length. To vary length per render, author the root `data-duration` directly. (A clip's `data-duration` is different: re-read from the live DOM, so scripts/variables can drive it.) Only when the root omits `data-duration` does the renderer derive total length from the live DOM / timeline after scripts run. | -| `data-fps` | No | Optional frame rate hint. CLI render flags can override output fps. | -| `data-composition-variables` | No | JSON array of variable declarations (on ``). See `variables-and-media.md`. | +| Attribute | Required | Meaning | +| ----------------------------- | ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `data-composition-id` | Yes | Unique ID. Must match the animation registry key on `window.__timelines`. | +| `data-width` / `data-height` | Yes | Pixel frame size. Common values: `1920x1080`, `1080x1920`, `1080x1080`. | +| `data-duration` | Conditional\* | Render duration in seconds (total length / frame count), not the GSAP timeline length. **Read once at compile time, like `data-width` / `data-height`**: a static root `data-duration` is locked before scripts run, so a script (`root.setAttribute("data-duration", ...)`) or a `--variables`-driven value cannot change the render length. To vary length per render, author the root `data-duration` directly. (A clip's `data-duration` is different: re-read from the live DOM, so scripts/variables can drive it.) Only when the root omits `data-duration` does the renderer derive total length from the live DOM / timeline after scripts run. | +| `data-fps` | No | Optional frame rate hint. CLI render flags can override output fps. | +| `data-composition-variables` | No | JSON array of variable declarations (on ``). See `variables-and-media.md`. | + +\*`data-duration` is optional whenever the runtime can auto-infer duration: a registered GSAP timeline, a finite CSS animation, a finite WAAPI `element.animate()`, or a registered Lottie animation. It is **required** for Three.js (no auto-inference), for infinite/unbounded CSS or WAAPI animations, and for any composition with no GSAP timeline and no animation signal at all. `npx hyperframes lint` enforces this (`root_composition_missing_duration_source`). See `determinism-rules.md` → "Duration Contract For Non-GSAP Runtimes" for the per-runtime breakdown. The root should be `position: relative`, have explicit pixel dimensions, and hide overflow unless intentionally composing outside the frame. diff --git a/skills/hyperframes-core/references/determinism-rules.md b/skills/hyperframes-core/references/determinism-rules.md index 47ee41dcf6..30e9b6e463 100644 --- a/skills/hyperframes-core/references/determinism-rules.md +++ b/skills/hyperframes-core/references/determinism-rules.md @@ -19,6 +19,17 @@ For GSAP: Use the `hyperframes-animation` skill for tween syntax, position parameters, eases, and performance rules. +### Duration Contract For Non-GSAP Runtimes + +The render engine needs a positive total duration before it will capture a single frame — without one, capture fails outright with "Composition has zero duration." A GSAP timeline supplies this automatically. CSS, WAAPI, and Lottie compositions have no timeline object, so the runtime infers duration itself: + +- **CSS**: longest `animation-delay` + `animation-duration` × finite `animation-iteration-count` across animated elements (offset by each element's `data-start`). `animation-iteration-count: infinite` cannot be inferred. +- **WAAPI**: longest `element.animate()` effect's `getComputedTiming().endTime`. Infinite `iterations` cannot be inferred. +- **Lottie**: the registered animation's native length (`totalFrames / frameRate`, or the dotLottie player's own `duration`) — always finite regardless of `loop`. +- **Three.js**: **not inferable**. The `three` adapter only forwards time via `hf-seek` — it has no `AnimationClip`/`AnimationMixer` inspection. + +`data-duration` on the root `[data-composition-id]` element is therefore optional whenever every non-GSAP animation on the page is finite (CSS/WAAPI with finite iteration counts, or Lottie). It is **required** when: the composition has an infinite/unbounded CSS or WAAPI animation, the composition uses Three.js, or there is no GSAP timeline and no animation signal at all for any adapter to discover. `npx hyperframes lint` enforces exactly this (`root_composition_missing_duration_source`) — see the runtime/adapter-specific docs under `hyperframes-animation/adapters/` for the full contract per runtime. + ## Determinism Rules Rendered frames must be reproducible from the requested time. Do **not** use any of the following for visual state: From 6e53dfa9ebb46b8323792dac8f82c7f1b11b9054 Mon Sep 17 00:00:00 2001 From: James Date: Wed, 1 Jul 2026 09:52:35 -0700 Subject: [PATCH 2/5] 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. --- packages/lint/src/rules/composition.test.ts | 96 +++++++++++++++++++++ packages/lint/src/rules/composition.ts | 23 ++--- 2 files changed, 109 insertions(+), 10 deletions(-) diff --git a/packages/lint/src/rules/composition.test.ts b/packages/lint/src/rules/composition.test.ts index f668370436..77a319ee74 100644 --- a/packages/lint/src/rules/composition.test.ts +++ b/packages/lint/src/rules/composition.test.ts @@ -1264,5 +1264,101 @@ describe("composition rules", () => { }); expect(find(result.findings)).toBeUndefined(); }); + + it("errors when the only .animate() call is commented out (no real duration source)", async () => { + const 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 = ` +
+ +
+
+ `; + 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 = ` +
+
+
+ + `; + 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 = ` +
+ +
+
+ `; + 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 = ` +
+ +
+
+ `; + 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 = ` +
+ +
+
+ `; + const result = await lintHyperframeHtml(html); + expect(find(result.findings)).toBeUndefined(); + }); }); }); diff --git a/packages/lint/src/rules/composition.ts b/packages/lint/src/rules/composition.ts index 2c5f6d47a0..bbfca8fb1b 100644 --- a/packages/lint/src/rules/composition.ts +++ b/packages/lint/src/rules/composition.ts @@ -760,7 +760,11 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding if (readAttr(rootTag.raw, "data-composition-id") === null) return []; if (readAttr(rootTag.raw, "data-duration") !== null) return []; - const allScriptTexts = scripts.map((s) => s.content); + // 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), @@ -771,21 +775,20 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding 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}`; + 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 common inline-keyframes call; - // `.animate(someVar, ...)` catches keyframes built up in a variable first - // (the inline form can't be a false negative — WAAPI's own signature - // requires an array/object as the first argument either way). - const usesWaapi = allScriptTexts.some((t) => /\.animate\s*\(\s*[[$A-Za-z_]/.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*:[^;{}]*\binfinite\b/.test( - combinedCss, - ); + const hasInfiniteCssAnimation = + /\banimation(?:-iteration-count)?\s*:[^;{}]*(? Date: Wed, 1 Jul 2026 10:17:27 -0700 Subject: [PATCH 3/5] fix(runtime): keep finite duration signal when an unbounded animation coexists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../core/src/runtime/adapters/css.test.ts | 37 +++++++++++++++++++ packages/core/src/runtime/adapters/css.ts | 9 ++--- .../core/src/runtime/adapters/waapi.test.ts | 18 ++++++++- packages/core/src/runtime/adapters/waapi.ts | 8 ++-- 4 files changed, 62 insertions(+), 10 deletions(-) diff --git a/packages/core/src/runtime/adapters/css.test.ts b/packages/core/src/runtime/adapters/css.test.ts index 42b16cc113..06c192c41a 100644 --- a/packages/core/src/runtime/adapters/css.test.ts +++ b/packages/core/src/runtime/adapters/css.test.ts @@ -270,6 +270,43 @@ describe("css adapter", () => { 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"; diff --git a/packages/core/src/runtime/adapters/css.ts b/packages/core/src/runtime/adapters/css.ts index 0b5c6f0ad8..b884e928a4 100644 --- a/packages/core/src/runtime/adapters/css.ts +++ b/packages/core/src/runtime/adapters/css.ts @@ -120,19 +120,18 @@ export function createCssAdapter(params?: { }, getInferredDurationSeconds: () => { let maxEndSeconds = 0; - let sawUnbounded = false; 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); - if (result.unbounded) sawUnbounded = true; + // 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); } } - // Infinity (or NaN) on any animation — unbounded/infinite iteration - // count, can't auto-infer. The caller must keep declaring data-duration. - if (sawUnbounded) return null; return maxEndSeconds > 0 ? maxEndSeconds : null; }, seek: (ctx) => { diff --git a/packages/core/src/runtime/adapters/waapi.test.ts b/packages/core/src/runtime/adapters/waapi.test.ts index 6941ecde43..d113489d07 100644 --- a/packages/core/src/runtime/adapters/waapi.test.ts +++ b/packages/core/src/runtime/adapters/waapi.test.ts @@ -298,7 +298,7 @@ describe("waapi adapter", () => { delete (document as any).getAnimations; }); - it("returns null when any animation has an unbounded (Infinity) endTime", () => { + it("returns the finite animation's end time when a finite and an unbounded animation coexist", () => { const finite = { pause: vi.fn(), currentTime: 0, @@ -311,6 +311,22 @@ describe("waapi adapter", () => { }; (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(); diff --git a/packages/core/src/runtime/adapters/waapi.ts b/packages/core/src/runtime/adapters/waapi.ts index 4541ed3cad..f17af71540 100644 --- a/packages/core/src/runtime/adapters/waapi.ts +++ b/packages/core/src/runtime/adapters/waapi.ts @@ -216,14 +216,14 @@ export function createWaapiAdapter(): RuntimeDeterministicAdapter { }, getInferredDurationSeconds: () => { let maxEndSeconds = 0; - let sawUnbounded = false; for (const animation of snapshotAnimations()) { const result = inferAnimationEndSeconds(animation); - if (result.unbounded) sawUnbounded = true; + // 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); } - // Infinity/NaN on any animation — unbounded iteration count, can't auto-infer. - if (sawUnbounded) return null; return maxEndSeconds > 0 ? maxEndSeconds : null; }, }; From f9a45efa24c8ecb19c8faad1f1307dc83290eb28 Mon Sep 17 00:00:00 2001 From: James Date: Wed, 1 Jul 2026 11:02:35 -0700 Subject: [PATCH 4/5] docs(skills): fix table separator width in data-attributes.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- skills-manifest.json | 4 ++-- skills/hyperframes-core/references/data-attributes.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/skills-manifest.json b/skills-manifest.json index ffa97e4c74..8548b4f989 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -18,7 +18,7 @@ "files": 1 }, "hyperframes-animation": { - "hash": "3b592d54ca1d5b6e", + "hash": "19024009fab8e5d1", "files": 116 }, "hyperframes-cli": { @@ -26,7 +26,7 @@ "files": 7 }, "hyperframes-core": { - "hash": "956d95de15d8c7fc", + "hash": "e978b3168748eb39", "files": 13 }, "hyperframes-creative": { diff --git a/skills/hyperframes-core/references/data-attributes.md b/skills/hyperframes-core/references/data-attributes.md index 34a8c38105..823cae5ef4 100644 --- a/skills/hyperframes-core/references/data-attributes.md +++ b/skills/hyperframes-core/references/data-attributes.md @@ -7,7 +7,7 @@ Every HyperFrames composition uses `data-*` attributes to declare timing and str Every renderable composition needs one root element: | Attribute | Required | Meaning | -| ----------------------------- | ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| ---------------------------- | ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `data-composition-id` | Yes | Unique ID. Must match the animation registry key on `window.__timelines`. | | `data-width` / `data-height` | Yes | Pixel frame size. Common values: `1920x1080`, `1080x1920`, `1080x1080`. | | `data-duration` | Conditional\* | Render duration in seconds (total length / frame count), not the GSAP timeline length. **Read once at compile time, like `data-width` / `data-height`**: a static root `data-duration` is locked before scripts run, so a script (`root.setAttribute("data-duration", ...)`) or a `--variables`-driven value cannot change the render length. To vary length per render, author the root `data-duration` directly. (A clip's `data-duration` is different: re-read from the live DOM, so scripts/variables can drive it.) Only when the root omits `data-duration` does the renderer derive total length from the live DOM / timeline after scripts run. | From 72909a2c42281b990ff92faeb823469893a5c617 Mon Sep 17 00:00:00 2001 From: James Date: Wed, 1 Jul 2026 14:02:16 -0700 Subject: [PATCH 5/5] fix(lint): keep infinite-CSS duration rule strict but make its message honest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/lint/src/rules/composition.test.ts | 28 ++++++++++++++ packages/lint/src/rules/composition.ts | 43 ++++++++++++++------- 2 files changed, 58 insertions(+), 13 deletions(-) diff --git a/packages/lint/src/rules/composition.test.ts b/packages/lint/src/rules/composition.test.ts index 77a319ee74..2b38766123 100644 --- a/packages/lint/src/rules/composition.test.ts +++ b/packages/lint/src/rules/composition.test.ts @@ -1206,6 +1206,34 @@ describe("composition rules", () => { 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 = ` +
+ +
+
+
+ `; + 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 = `
diff --git a/packages/lint/src/rules/composition.ts b/packages/lint/src/rules/composition.ts index bbfca8fb1b..9a2cbf0afe 100644 --- a/packages/lint/src/rules/composition.ts +++ b/packages/lint/src/rules/composition.ts @@ -738,17 +738,24 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding // the adapters' getInferredDurationSeconds) — so data-duration is optional // wherever the runtime can work it out on its own. // - // This rule only fires for the cases that are NOT inferable, matching what - // the runtime itself can and can't determine: + // 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). - // - CSS animation-iteration-count: infinite with no finite CSS animation - // alongside it (unbounded — the CSS adapter returns null). + // 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). - // Lottie and finite CSS/WAAPI animations are excluded — the runtime infers - // those, so requiring data-duration there would be a false positive against - // the runtime's own auto-inference. + // - 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 []; @@ -831,17 +838,27 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding } if (hasInfiniteCssAnimation && !usesLottie && !usesWaapi) { - // An infinite/unbounded CSS animation-iteration-count can't be resolved - // to a finite end time — the CSS adapter's getInferredDurationSeconds - // returns null in this case (see adapters/css.ts). + // 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. An infinite/unbounded animation has no finite end time for the runtime to " + - 'infer — render will fail with "Composition has zero duration".', + "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="" to the root element with the intended total length.', snippet: truncateSnippet(rootTag.raw),