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..06c192c41a 100644 --- a/packages/core/src/runtime/adapters/css.test.ts +++ b/packages/core/src/runtime/adapters/css.test.ts @@ -175,4 +175,158 @@ describe("css adapter", () => { document.body.removeChild(el); vi.restoreAllMocks(); }); + + describe("getInferredDurationSeconds", () => { + it("returns null when nothing was discovered", () => { + const adapter = createCssAdapter(); + adapter.discover(); + expect(adapter.getInferredDurationSeconds?.()).toBeNull(); + }); + + it("infers the longest finite animation end time, offset by data-start", () => { + const el = document.createElement("div"); + el.setAttribute("data-start", "2"); + el.style.animationName = "fadeIn"; + document.body.appendChild(el); + + vi.spyOn(window, "getComputedStyle").mockImplementation(() => { + return { animationName: "fadeIn" } as CSSStyleDeclaration; + }); + + const animation = { + currentTime: 0, + pause: vi.fn(), + play: vi.fn(), + effect: { getComputedTiming: () => ({ endTime: 3000 }) }, + } as unknown as Animation; + (el as HTMLElement & { getAnimations?: () => Animation[] }).getAnimations = () => [animation]; + + const adapter = createCssAdapter(); + adapter.discover(); + + // start (2s) + endTime (3s) = 5s + expect(adapter.getInferredDurationSeconds?.()).toBe(5); + + document.body.removeChild(el); + vi.restoreAllMocks(); + }); + + it("returns the max across multiple animated elements", () => { + const elA = document.createElement("div"); + elA.style.animationName = "a"; + const elB = document.createElement("div"); + elB.style.animationName = "b"; + document.body.appendChild(elA); + document.body.appendChild(elB); + + vi.spyOn(window, "getComputedStyle").mockImplementation((target) => { + return { + animationName: target === elA ? "a" : target === elB ? "b" : "none", + } as CSSStyleDeclaration; + }); + + (elA as HTMLElement & { getAnimations?: () => Animation[] }).getAnimations = () => [ + { + effect: { getComputedTiming: () => ({ endTime: 1000 }) }, + } as unknown as Animation, + ]; + (elB as HTMLElement & { getAnimations?: () => Animation[] }).getAnimations = () => [ + { + effect: { getComputedTiming: () => ({ endTime: 4500 }) }, + } as unknown as Animation, + ]; + + const adapter = createCssAdapter(); + adapter.discover(); + + expect(adapter.getInferredDurationSeconds?.()).toBe(4.5); + + document.body.removeChild(elA); + document.body.removeChild(elB); + vi.restoreAllMocks(); + }); + + it("returns null when an animation's endTime is Infinity (infinite iteration count)", () => { + const el = document.createElement("div"); + el.style.animationName = "spin"; + document.body.appendChild(el); + + vi.spyOn(window, "getComputedStyle").mockImplementation(() => { + return { animationName: "spin" } as CSSStyleDeclaration; + }); + + (el as HTMLElement & { getAnimations?: () => Animation[] }).getAnimations = () => [ + { + effect: { getComputedTiming: () => ({ endTime: Infinity }) }, + } as unknown as Animation, + ]; + + const adapter = createCssAdapter(); + adapter.discover(); + + expect(adapter.getInferredDurationSeconds?.()).toBeNull(); + + document.body.removeChild(el); + vi.restoreAllMocks(); + }); + + it("returns the finite animation's end time when a finite and an unbounded animation coexist", () => { + const elFinite = document.createElement("div"); + elFinite.style.animationName = "fadeIn"; + const elInfinite = document.createElement("div"); + elInfinite.style.animationName = "spin"; + document.body.appendChild(elFinite); + document.body.appendChild(elInfinite); + + vi.spyOn(window, "getComputedStyle").mockImplementation((target) => { + return { + animationName: target === elFinite ? "fadeIn" : target === elInfinite ? "spin" : "none", + } as CSSStyleDeclaration; + }); + + (elFinite as HTMLElement & { getAnimations?: () => Animation[] }).getAnimations = () => [ + { + effect: { getComputedTiming: () => ({ endTime: 3000 }) }, + } as unknown as Animation, + ]; + (elInfinite as HTMLElement & { getAnimations?: () => Animation[] }).getAnimations = () => [ + { + effect: { getComputedTiming: () => ({ endTime: Infinity }) }, + } as unknown as Animation, + ]; + + const adapter = createCssAdapter(); + adapter.discover(); + + // The unbounded "spin" animation is ignored; the finite "fadeIn" + // animation's 3s end time is still a valid duration signal. + expect(adapter.getInferredDurationSeconds?.()).toBe(3); + + document.body.removeChild(elFinite); + document.body.removeChild(elInfinite); + vi.restoreAllMocks(); + }); + + it("ignores disconnected elements", () => { + const el = document.createElement("div"); + el.style.animationName = "fadeIn"; + document.body.appendChild(el); + + vi.spyOn(window, "getComputedStyle").mockImplementation(() => { + return { animationName: "fadeIn" } as CSSStyleDeclaration; + }); + (el as HTMLElement & { getAnimations?: () => Animation[] }).getAnimations = () => [ + { + effect: { getComputedTiming: () => ({ endTime: 3000 }) }, + } as unknown as Animation, + ]; + + const adapter = createCssAdapter(); + adapter.discover(); + document.body.removeChild(el); + + expect(adapter.getInferredDurationSeconds?.()).toBeNull(); + vi.restoreAllMocks(); + }); + }); }); diff --git a/packages/core/src/runtime/adapters/css.ts b/packages/core/src/runtime/adapters/css.ts index 950d319623..b884e928a4 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,27 @@ export function createCssAdapter(params?: { }); } }, + getInferredDurationSeconds: () => { + let maxEndSeconds = 0; + for (const entry of entries) { + if (!entry.el.isConnected) continue; + const start = resolveEntryStartSeconds(entry.el); + for (const animation of getAnimationsForElement(entry.el)) { + const result = inferAnimationEndSeconds(animation, start); + // Unbounded (Infinity/NaN endTime) animations are skipped here — + // they never contribute to maxEndSeconds. A finite animation + // elsewhere on the composition still supplies a valid duration + // signal; only fall through to null when nothing finite was found. + if (result.endSeconds != null) maxEndSeconds = Math.max(maxEndSeconds, result.endSeconds); + } + } + return maxEndSeconds > 0 ? maxEndSeconds : null; + }, seek: (ctx) => { const time = Number(ctx.time) || 0; for (const entry of entries) { if (!entry.el.isConnected) continue; - const start = params?.resolveStartSeconds - ? params.resolveStartSeconds(entry.el) - : Number.parseFloat(entry.el.getAttribute("data-start") ?? "0") || 0; + const start = resolveEntryStartSeconds(entry.el); const localTimeMs = Math.max(0, time - start) * 1000; const animations = entry.animations; if (animations.length > 0) { 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..d113489d07 100644 --- a/packages/core/src/runtime/adapters/waapi.test.ts +++ b/packages/core/src/runtime/adapters/waapi.test.ts @@ -270,4 +270,104 @@ describe("waapi adapter", () => { } } }); + + describe("getInferredDurationSeconds", () => { + it("returns null when there are no animations", () => { + (document as any).getAnimations = vi.fn(() => []); + const adapter = createWaapiAdapter(); + expect(adapter.getInferredDurationSeconds?.()).toBeNull(); + delete (document as any).getAnimations; + }); + + it("returns the max finite endTime across animations, in seconds", () => { + const short = { + pause: vi.fn(), + currentTime: 0, + effect: { getComputedTiming: () => ({ endTime: 1200 }) }, + }; + const long = { + pause: vi.fn(), + currentTime: 0, + effect: { getComputedTiming: () => ({ endTime: 4800 }) }, + }; + (document as any).getAnimations = vi.fn(() => [short, long]); + + const adapter = createWaapiAdapter(); + expect(adapter.getInferredDurationSeconds?.()).toBe(4.8); + + delete (document as any).getAnimations; + }); + + it("returns the finite animation's end time when a finite and an unbounded animation coexist", () => { + const finite = { + pause: vi.fn(), + currentTime: 0, + effect: { getComputedTiming: () => ({ endTime: 2000 }) }, + }; + const infinite = { + pause: vi.fn(), + currentTime: 0, + effect: { getComputedTiming: () => ({ endTime: Infinity }) }, + }; + (document as any).getAnimations = vi.fn(() => [finite, infinite]); + + const adapter = createWaapiAdapter(); + // The unbounded animation is ignored; the finite animation's 2s end + // time is still a valid duration signal. + expect(adapter.getInferredDurationSeconds?.()).toBe(2); + + delete (document as any).getAnimations; + }); + + it("returns null when every animation has an unbounded (Infinity) endTime", () => { + const infinite = { + pause: vi.fn(), + currentTime: 0, + effect: { getComputedTiming: () => ({ endTime: Infinity }) }, + }; + (document as any).getAnimations = vi.fn(() => [infinite]); + + const adapter = createWaapiAdapter(); + expect(adapter.getInferredDurationSeconds?.()).toBeNull(); + + delete (document as any).getAnimations; + }); + + it("accounts for the composition-time baseline of animations discovered mid-composition", () => { + const existing = { pause: vi.fn(), currentTime: 0 }; + let includeDynamic = false; + const dynamic: { pause: () => void; currentTime: number; effect?: unknown } = { + pause: vi.fn(), + currentTime: 0, + effect: { getComputedTiming: () => ({ endTime: 1000 }) }, + }; + (document as any).getAnimations = vi.fn(() => + includeDynamic ? [existing, dynamic] : [existing], + ); + + const adapter = createWaapiAdapter(); + adapter.discover(); + adapter.seek({ time: 2 }); + + // `dynamic` first appears at composition time 2s (t=2 seek) — its + // baseline.compositionTimeMs is recorded as 2000ms, so its inferred + // end time is 2s (baseline) + 1s (own endTime) = 3s. + includeDynamic = true; + adapter.seek({ time: 2 }); + + expect(adapter.getInferredDurationSeconds?.()).toBe(3); + + delete (document as any).getAnimations; + }); + + it("handles missing getAnimations API", () => { + const original = document.getAnimations; + (document as Record).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..f17af71540 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; + for (const animation of snapshotAnimations()) { + const result = inferAnimationEndSeconds(animation); + // Unbounded (Infinity/NaN endTime) animations are skipped here — + // they never contribute to maxEndSeconds. A finite animation + // elsewhere on the composition still supplies a valid duration + // signal; only fall through to null when nothing finite was found. + if (result.endSeconds != null) maxEndSeconds = Math.max(maxEndSeconds, result.endSeconds); + } + return maxEndSeconds > 0 ? maxEndSeconds : null; + }, }; } 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..2b38766123 100644 --- a/packages/lint/src/rules/composition.test.ts +++ b/packages/lint/src/rules/composition.test.ts @@ -1101,4 +1101,292 @@ describe("composition rules", () => { expect(find(result.findings)).toBeUndefined(); }); }); + + describe("root_composition_missing_duration_source", () => { + const CODE = "root_composition_missing_duration_source"; + const find = (findings: { code: string }[]) => findings.find((f) => f.code === CODE); + + it("errors when there is no data-duration, no GSAP timeline, and no animation signal at all", async () => { + const html = ` +
+
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("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 = ` +
+ +
+
+ `; + 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(); + }); + + 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 1d296a2277..9a2cbf0afe 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,148 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding }, ]; }, + + // root_composition_missing_duration_source + // + // The render engine (packages/engine/src/services/frameCapture.ts) needs a + // positive window.__hf.duration to know how many frames to capture. GSAP + // timelines set this automatically. Non-GSAP runtimes (CSS, WAAPI, Lottie) + // are now auto-inferred by the runtime too (see + // packages/core/src/runtime/init.ts resolveAdapterDurationFloorSeconds and + // the adapters' getInferredDurationSeconds) — so data-duration is optional + // wherever the runtime can work it out on its own. + // + // This rule fires for cases where the total render length is not reliably + // determinable without an explicit data-duration: + // - No GSAP timeline AND no data-duration AND no non-GSAP animation + // signal at all (nothing for any adapter to discover — render fails). + // - Three.js used with no data-duration (no discoverable AnimationClip + // duration in this codebase's adapter — see adapters/three.ts). + // - Any infinite CSS animation-iteration-count with no data-duration, + // EVEN when a finite CSS animation is present alongside it. An unbounded + // animation makes the intended total length ambiguous — the runtime will + // infer a finite sibling's length if one exists, but that's a fallback, + // not a declaration of intent, so we still require data-duration here. + // (This is intentionally stricter than the runtime's own inference.) + // Purely finite CSS/WAAPI animations and Lottie are excluded — the runtime + // infers those unambiguously, so requiring data-duration there would be a + // false positive against the runtime's own auto-inference. Note lint is + // advisory by default (see shouldBlockRender) — it only blocks render under + // --strict/--strict-all — so a strict flag here nudges toward an explicit, + // guaranteed-correct value without failing renders that would succeed. + // fallow-ignore-next-line complexity + ({ rootTag, scripts, styles, tags, options }) => { + if (options.isSubComposition) return []; + if (!rootTag) return []; + // Not every file linted as a "root" HTML document is a video composition + // — e.g. a slideshow demo.html mounts + // with no data-composition-id of its own. Nothing to capture there, so + // there's no duration contract to enforce. + if (readAttr(rootTag.raw, "data-composition-id") === null) return []; + if (readAttr(rootTag.raw, "data-duration") !== null) return []; + + // Strip comments before scanning for signals — a commented-out + // `.animate(...)` call or `/* animation: spin 2s infinite; */` must not + // satisfy the "has a duration source" check, or the composition still + // fails at render with zero duration despite lint passing. + const allScriptTexts = scripts.map((s) => stripJsComments(s.content)); + const hasGsapTimeline = allScriptTexts.some((t) => /gsap\.timeline\s*\(/.test(t)); + const hasRegisteredTimeline = allScriptTexts.some((t) => + WINDOW_TIMELINE_ASSIGN_PATTERN.test(t), + ); + // A GSAP timeline drives duration via window.__timelines regardless of + // data-duration — nothing to flag once one is registered. + if (hasGsapTimeline && hasRegisteredTimeline) return []; + + const allCss = styles.map((s) => s.content).join("\n"); + const allInlineStyles = tags.map((t) => readAttr(t.raw, "style") || "").join("\n"); + const combinedCss = `${allCss}\n${allInlineStyles}`.replace(/\/\*[\s\S]*?\*\//g, ""); + + const usesLottie = + tags.some((t) => readAttr(t.raw, "data-lottie-src") !== null) || + allScriptTexts.some((t) => /lottie\.(loadAnimation)\b|__hfLottie\b/.test(t)); + const usesThree = allScriptTexts.some((t) => /\bTHREE\./.test(t)); + // `.animate([...], ...)` catches the array-literal keyframes form; + // `.animate({...}, ...)` catches the object-literal (PropertyIndexedKeyframes) + // form; `.animate(someVar, ...)` catches keyframes built up in a variable + // first. + const usesWaapi = allScriptTexts.some((t) => /\.animate\s*\(\s*[[{$A-Za-z_]/.test(t)); + const hasCssAnimationName = /\banimation(?:-name)?\s*:/.test(combinedCss); + const hasInfiniteCssAnimation = + /\banimation(?:-iteration-count)?\s*:[^;{}]*(?" 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 makes the intended total length + // ambiguous, so we require an explicit data-duration even when a finite + // CSS animation is present alongside it. This is deliberately stricter + // than the runtime's own inference: the CSS adapter's + // getInferredDurationSeconds (see adapters/css.ts) returns the longest + // finite animation end-time when one exists (so a finite sibling would + // render at that length) and null when every animation is unbounded (so + // a render with no finite source fails outright). Either way the author + // hasn't declared how long the video should be — a decorative infinite + // spinner next to a 3s fade doesn't tell us the clip is meant to be 3s + // — so we flag it and let them state intent. The message stays honest + // about both outcomes rather than claiming the render always fails. + return [ + { + code: "root_composition_missing_duration_source", + severity: "error", + message: + "Root composition uses a CSS animation with animation-iteration-count: infinite and no " + + "data-duration, so the intended total length is ambiguous. If a finite animation is also " + + "present the runtime infers that length; with no finite source the render fails with " + + '"Composition has zero duration". Declare the intended length explicitly.', + fixHint: + 'Add data-duration="" 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-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-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..823cae5ef4 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: