From 1bd9fac203f09736c1225bbbe19e5d7cc31c0b93 Mon Sep 17 00:00:00 2001 From: Maze Date: Sun, 5 Apr 2026 13:15:58 +0200 Subject: refactor: rewrite animation system to use bindings and scalar channels --- apps/web/src/lib/animation/keyframe-query.ts | 370 +++++++++++++++++++++------ 1 file changed, 298 insertions(+), 72 deletions(-) (limited to 'apps/web/src/lib/animation/keyframe-query.ts') diff --git a/apps/web/src/lib/animation/keyframe-query.ts b/apps/web/src/lib/animation/keyframe-query.ts index 62c8a171..304a6698 100644 --- a/apps/web/src/lib/animation/keyframe-query.ts +++ b/apps/web/src/lib/animation/keyframe-query.ts @@ -1,72 +1,298 @@ -import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants"; -import type { - AnimationPath, - ElementAnimations, - ElementKeyframe, -} from "@/lib/animation/types"; -import { isAnimationPath } from "./target-resolver"; - -export function getElementKeyframes({ - animations, -}: { - animations: ElementAnimations | undefined; -}): ElementKeyframe[] { - if (!animations) { - return []; - } - - return Object.entries(animations.channels).flatMap( - ([propertyPath, channel]) => { - if ( - !channel || - channel.keyframes.length === 0 || - !isAnimationPath(propertyPath) - ) { - return []; - } - - return channel.keyframes.map((keyframe) => ({ - propertyPath, - id: keyframe.id, - time: keyframe.time, - value: keyframe.value, - interpolation: keyframe.interpolation, - })); - }, - ); -} - -export function hasKeyframesForPath({ - animations, - propertyPath, -}: { - animations: ElementAnimations | undefined; - propertyPath: AnimationPath; -}): boolean { - const channel = animations?.channels[propertyPath]; - return Boolean(channel && channel.keyframes.length > 0); -} - -export function getKeyframeAtTime({ - animations, - propertyPath, - time, -}: { - animations: ElementAnimations | undefined; - propertyPath: AnimationPath; - time: number; -}): ElementKeyframe | null { - const channel = animations?.channels[propertyPath]; - if (!channel || channel.keyframes.length === 0) return null; - const keyframe = channel.keyframes.find( - (keyframe) => Math.abs(keyframe.time - time) <= TIME_EPSILON_SECONDS, - ); - if (!keyframe) return null; - return { - propertyPath, - id: keyframe.id, - time: keyframe.time, - value: keyframe.value, - interpolation: keyframe.interpolation, - }; -} +import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants"; +import type { + AnimationBindingInstance, + AnimationChannel, + AnimationPath, + ElementAnimations, + ElementKeyframe, +} from "@/lib/animation/types"; +import { + type AnimationComponentValue, + composeAnimationValue, +} from "./binding-values"; +import { + getChannelValueAtTime, + getScalarSegmentInterpolation, +} from "./interpolation"; +import { isAnimationPath } from "./target-resolver"; + +function getBindingFallbackValue({ + channel, +}: { + channel: ElementAnimations["channels"][string]; +}) { + if (!channel || channel.keys.length === 0) { + return channel?.kind === "discrete" ? false : 0; + } + + return channel.keys[0].value; +} + +interface BindingKeyframeMatch { + componentIndex: number; + channel: AnimationChannel; + keyframe: AnimationChannel["keys"][number]; +} + +function getBindingKeyframeMatches({ + animations, + binding, +}: { + animations: ElementAnimations; + binding: AnimationBindingInstance; +}): BindingKeyframeMatch[] { + return binding.components.flatMap((component, componentIndex) => { + const channel = animations.channels[component.channelId]; + if (!channel || channel.keys.length === 0) { + return []; + } + + return channel.keys.map((keyframe) => ({ + componentIndex, + channel, + keyframe, + })); + }); +} + +function getUniqueBindingKeyframeMatches({ + animations, + binding, +}: { + animations: ElementAnimations; + binding: AnimationBindingInstance; +}): BindingKeyframeMatch[] { + const sortedMatches = getBindingKeyframeMatches({ + animations, + binding, + }).sort( + (leftMatch, rightMatch) => + leftMatch.keyframe.time - rightMatch.keyframe.time || + leftMatch.componentIndex - rightMatch.componentIndex, + ); + const uniqueMatches: BindingKeyframeMatch[] = []; + + for (const match of sortedMatches) { + const previousMatch = uniqueMatches[uniqueMatches.length - 1]; + if ( + !previousMatch || + Math.abs(previousMatch.keyframe.time - match.keyframe.time) > + TIME_EPSILON_SECONDS + ) { + uniqueMatches.push(match); + continue; + } + + if ( + previousMatch.componentIndex !== 0 && + match.componentIndex === 0 + ) { + uniqueMatches[uniqueMatches.length - 1] = match; + } + } + + return uniqueMatches; +} + +function getPreferredBindingKeyframeMatch({ + matches, +}: { + matches: BindingKeyframeMatch[]; +}): BindingKeyframeMatch | null { + return ( + matches.find((match) => match.componentIndex === 0) ?? + matches[0] ?? + null + ); +} + +function getComposedBindingValueAtTime({ + animations, + binding, + time, +}: { + animations: ElementAnimations; + binding: AnimationBindingInstance; + time: number; +}) { + const componentValues = Object.fromEntries( + binding.components.map((component) => { + const channel = animations.channels[component.channelId]; + return [ + component.key, + getChannelValueAtTime({ + channel, + time, + fallbackValue: getBindingFallbackValue({ channel }), + }), + ]; + }), + ) as Record; + + return composeAnimationValue({ + binding, + componentValues, + }); +} + +function getKeyframeInterpolation({ + channel, + keyframe, +}: { + channel: AnimationChannel; + keyframe: AnimationChannel["keys"][number]; +}) { + return channel.kind === "scalar" && "segmentToNext" in keyframe + ? getScalarSegmentInterpolation({ segment: keyframe.segmentToNext }) + : "hold"; +} + +function toElementKeyframe({ + animations, + binding, + propertyPath, + keyframeMatch, +}: { + animations: ElementAnimations; + binding: AnimationBindingInstance; + propertyPath: AnimationPath; + keyframeMatch: BindingKeyframeMatch; +}): ElementKeyframe | null { + const value = getComposedBindingValueAtTime({ + animations, + binding, + time: keyframeMatch.keyframe.time, + }); + if (value === null) { + return null; + } + + return { + propertyPath, + id: keyframeMatch.keyframe.id, + time: keyframeMatch.keyframe.time, + value, + interpolation: getKeyframeInterpolation({ + channel: keyframeMatch.channel, + keyframe: keyframeMatch.keyframe, + }), + }; +} + +export function getElementKeyframes({ + animations, +}: { + animations: ElementAnimations | undefined; +}): ElementKeyframe[] { + if (!animations) { + return []; + } + + return Object.entries(animations.bindings).flatMap( + ([propertyPath, binding]) => { + if (!binding || !isAnimationPath(propertyPath)) { + return []; + } + + return getUniqueBindingKeyframeMatches({ + animations, + binding, + }).flatMap((keyframeMatch) => { + const keyframe = toElementKeyframe({ + animations, + binding, + propertyPath, + keyframeMatch, + }); + if (!keyframe) { + return []; + } + + return [keyframe]; + }); + }, + ); +} + +export function hasKeyframesForPath({ + animations, + propertyPath, +}: { + animations: ElementAnimations | undefined; + propertyPath: AnimationPath; +}): boolean { + const binding = animations?.bindings[propertyPath]; + if (!binding) { + return false; + } + + return binding.components.some((component) => + Boolean(animations?.channels[component.channelId]?.keys.length), + ); +} + +export function getKeyframeAtTime({ + animations, + propertyPath, + time, +}: { + animations: ElementAnimations | undefined; + propertyPath: AnimationPath; + time: number; +}): ElementKeyframe | null { + const binding = animations?.bindings[propertyPath]; + if (!binding) { + return null; + } + + const keyframeMatch = getPreferredBindingKeyframeMatch({ + matches: getBindingKeyframeMatches({ + animations, + binding, + }).filter(({ keyframe }) => + Math.abs(keyframe.time - time) <= TIME_EPSILON_SECONDS, + ), + }); + if (!keyframeMatch) { + return null; + } + + return toElementKeyframe({ + animations, + binding, + propertyPath, + keyframeMatch, + }); +} + +export function getKeyframeById({ + animations, + propertyPath, + keyframeId, +}: { + animations: ElementAnimations | undefined; + propertyPath: AnimationPath; + keyframeId: string; +}): ElementKeyframe | null { + const binding = animations?.bindings[propertyPath]; + if (!binding) { + return null; + } + + const keyframeMatch = getPreferredBindingKeyframeMatch({ + matches: getBindingKeyframeMatches({ + animations, + binding, + }).filter(({ keyframe }) => keyframe.id === keyframeId), + }); + if (!keyframeMatch) { + return null; + } + + return toElementKeyframe({ + animations, + binding, + propertyPath, + keyframeMatch, + }); +} -- cgit v1.3.1