Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .fallowrc.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -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",
],
},
}
154 changes: 154 additions & 0 deletions packages/core/src/runtime/adapters/css.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
});
49 changes: 46 additions & 3 deletions packages/core/src/runtime/adapters/css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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) {
Expand Down
43 changes: 43 additions & 0 deletions packages/core/src/runtime/adapters/lottie.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
});
54 changes: 54 additions & 0 deletions packages/core/src/runtime/adapters/lottie.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading