diff options
Diffstat (limited to 'apps/web/src/lib/animation')
| -rw-r--r-- | apps/web/src/lib/animation/curve-bridge.ts | 82 | ||||
| -rw-r--r-- | apps/web/src/lib/animation/graph-channels.ts | 95 | ||||
| -rw-r--r-- | apps/web/src/lib/animation/index.ts | 13 | ||||
| -rw-r--r-- | apps/web/src/lib/animation/keyframes.ts | 141 | ||||
| -rw-r--r-- | apps/web/src/lib/animation/resolve.ts | 20 | ||||
| -rw-r--r-- | apps/web/src/lib/animation/types.ts | 60 |
6 files changed, 389 insertions, 22 deletions
diff --git a/apps/web/src/lib/animation/curve-bridge.ts b/apps/web/src/lib/animation/curve-bridge.ts new file mode 100644 index 00000000..55f96933 --- /dev/null +++ b/apps/web/src/lib/animation/curve-bridge.ts @@ -0,0 +1,82 @@ +import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants"; +import { + getDefaultLeftHandle, + getDefaultRightHandle, +} from "@/lib/animation/bezier"; +import type { + CurveHandle, + NormalizedCubicBezier, + ScalarAnimationKey, +} from "@/lib/animation/types"; + +const VALUE_EPSILON = 1e-6; + +function clamp01({ value }: { value: number }): number { + return Math.max(0, Math.min(1, value)); +} + +export function getNormalizedCubicBezierForScalarSegment({ + leftKey, + rightKey, +}: { + leftKey: ScalarAnimationKey; + rightKey: ScalarAnimationKey; +}): NormalizedCubicBezier | null { + const spanTime = rightKey.time - leftKey.time; + const spanValue = rightKey.value - leftKey.value; + if ( + Math.abs(spanTime) <= TIME_EPSILON_SECONDS || + Math.abs(spanValue) <= VALUE_EPSILON + ) { + return null; + } + + const rightHandle = + leftKey.rightHandle ?? getDefaultRightHandle({ leftKey, rightKey }); + const leftHandle = + rightKey.leftHandle ?? getDefaultLeftHandle({ leftKey, rightKey }); + + return [ + clamp01({ value: rightHandle.dt / spanTime }), + rightHandle.dv / spanValue, + clamp01({ value: 1 + leftHandle.dt / spanTime }), + 1 + leftHandle.dv / spanValue, + ]; +} + +export function getCurveHandlesForNormalizedCubicBezier({ + leftKey, + rightKey, + cubicBezier, +}: { + leftKey: ScalarAnimationKey; + rightKey: ScalarAnimationKey; + cubicBezier: NormalizedCubicBezier; +}): { + rightHandle: CurveHandle; + leftHandle: CurveHandle; +} | null { + const spanTime = rightKey.time - leftKey.time; + const spanValue = rightKey.value - leftKey.value; + if ( + Math.abs(spanTime) <= TIME_EPSILON_SECONDS || + Math.abs(spanValue) <= VALUE_EPSILON + ) { + return null; + } + + const [rawX1, y1, rawX2, y2] = cubicBezier; + const x1 = clamp01({ value: rawX1 }); + const x2 = clamp01({ value: rawX2 }); + + return { + rightHandle: { + dt: spanTime * x1, + dv: spanValue * y1, + }, + leftHandle: { + dt: spanTime * (x2 - 1), + dv: spanValue * (y2 - 1), + }, + }; +} diff --git a/apps/web/src/lib/animation/graph-channels.ts b/apps/web/src/lib/animation/graph-channels.ts new file mode 100644 index 00000000..240b00ed --- /dev/null +++ b/apps/web/src/lib/animation/graph-channels.ts @@ -0,0 +1,95 @@ +import type { + AnimationPath, + ElementAnimations, + ScalarAnimationChannel, + ScalarGraphChannel, + ScalarGraphKeyframeContext, +} from "@/lib/animation/types"; + +function isScalarAnimationChannel( + channel: ElementAnimations["channels"][string], +): channel is ScalarAnimationChannel { + return channel?.kind === "scalar"; +} + +export function getEditableScalarChannels({ + animations, + propertyPath, +}: { + animations: ElementAnimations | undefined; + propertyPath: AnimationPath; +}): ScalarGraphChannel[] { + const binding = animations?.bindings[propertyPath]; + if (!binding) { + return []; + } + + return binding.components.flatMap((component) => { + const channel = animations?.channels[component.channelId]; + if (!isScalarAnimationChannel(channel)) { + return []; + } + + return [ + { + propertyPath, + componentKey: component.key, + channelId: component.channelId, + channel, + }, + ]; + }); +} + +export function getEditableScalarChannel({ + animations, + propertyPath, + componentKey, +}: { + animations: ElementAnimations | undefined; + propertyPath: AnimationPath; + componentKey: string; +}): ScalarGraphChannel | null { + return ( + getEditableScalarChannels({ + animations, + propertyPath, + }).find((channel) => channel.componentKey === componentKey) ?? null + ); +} + +export function getScalarKeyframeContext({ + animations, + propertyPath, + componentKey, + keyframeId, +}: { + animations: ElementAnimations | undefined; + propertyPath: AnimationPath; + componentKey: string; + keyframeId: string; +}): ScalarGraphKeyframeContext | null { + const scalarChannel = getEditableScalarChannel({ + animations, + propertyPath, + componentKey, + }); + if (!scalarChannel) { + return null; + } + + const keyframeIndex = scalarChannel.channel.keys.findIndex( + (keyframe) => keyframe.id === keyframeId, + ); + if (keyframeIndex < 0) { + return null; + } + + return { + ...scalarChannel, + keyframe: scalarChannel.channel.keys[keyframeIndex], + keyframeIndex, + previousKey: scalarChannel.channel.keys[keyframeIndex - 1] ?? null, + nextKey: scalarChannel.channel.keys[keyframeIndex + 1] ?? null, + }; +} diff --git a/apps/web/src/lib/animation/index.ts b/apps/web/src/lib/animation/index.ts index 9cb85d2a..7d2646d9 100644 --- a/apps/web/src/lib/animation/index.ts +++ b/apps/web/src/lib/animation/index.ts @@ -12,8 +12,10 @@ export { getChannel, removeElementKeyframe, retimeElementKeyframe, + setBindingComponentChannel, setChannel, splitAnimationsAtTime, + updateScalarKeyframeCurve, upsertElementKeyframe, upsertPathKeyframe, } from "./keyframes"; @@ -47,6 +49,17 @@ export { } from "./keyframe-query"; export { + getEditableScalarChannel, + getEditableScalarChannels, + getScalarKeyframeContext, +} from "./graph-channels"; + +export { + getCurveHandlesForNormalizedCubicBezier, + getNormalizedCubicBezierForScalarSegment, +} from "./curve-bridge"; + +export { buildGraphicParamPath, isGraphicParamPath, parseGraphicParamPath, diff --git a/apps/web/src/lib/animation/keyframes.ts b/apps/web/src/lib/animation/keyframes.ts index 8c17f8a6..7eaa5ed6 100644 --- a/apps/web/src/lib/animation/keyframes.ts +++ b/apps/web/src/lib/animation/keyframes.ts @@ -11,6 +11,7 @@ import type { ElementAnimations, ScalarAnimationChannel, ScalarAnimationKey, + ScalarCurveKeyframePatch, ScalarSegmentType, } from "@/lib/animation/types"; import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants"; @@ -209,7 +210,7 @@ function getBinding({ propertyPath, }: { animations: ElementAnimations | undefined; - propertyPath: string; + propertyPath: AnimationPath; }): AnimationBindingInstance | undefined { return animations?.bindings[propertyPath]; } @@ -224,6 +225,16 @@ function getChannelById({ return animations?.channels[channelId]; } +function getBindingComponent({ + binding, + componentKey, +}: { + binding: AnimationBindingInstance; + componentKey: string; +}) { + return binding.components.find((component) => component.key === componentKey) ?? null; +} + function getTargetKeyMetadata({ channel, time, @@ -402,7 +413,7 @@ export function getChannel({ propertyPath, }: { animations: ElementAnimations | undefined; - propertyPath: string; + propertyPath: AnimationPath; }): AnimationChannel | undefined { const binding = getBinding({ animations, propertyPath }); const primaryChannelId = @@ -651,7 +662,7 @@ export function setChannel({ channel, }: { animations: ElementAnimations | undefined; - propertyPath: string; + propertyPath: AnimationPath; channel: AnimationChannel | undefined; }): ElementAnimations | undefined { const binding = getBinding({ animations, propertyPath }); @@ -659,29 +670,66 @@ export function setChannel({ return animations; } + if (binding.components.length !== 1) { + throw new Error( + `setChannel only supports single-component bindings. Received "${propertyPath}" with ${binding.components.length} components.`, + ); + } + const primaryComponent = getPrimaryComponent({ binding }); if (!primaryComponent) { return animations; } + return setBindingComponentChannel({ + animations, + propertyPath, + componentKey: primaryComponent.key, + channel, + }); +} + +export function setBindingComponentChannel({ + animations, + propertyPath, + componentKey, + channel, +}: { + animations: ElementAnimations | undefined; + propertyPath: AnimationPath; + componentKey: string; + channel: AnimationChannel | undefined; +}): ElementAnimations | undefined { + const binding = getBinding({ animations, propertyPath }); + if (!binding) { + return animations; + } + + const component = getBindingComponent({ + binding, + componentKey, + }); + if (!component) { + return animations; + } + const nextAnimations = cloneAnimationsState({ animations }); if (!channel || !hasChannelKeys({ channel })) { - for (const component of binding.components) { - delete nextAnimations.channels[component.channelId]; + delete nextAnimations.channels[component.channelId]; + const hasRemainingKeys = binding.components.some((candidate) => + hasChannelKeys({ + channel: nextAnimations.channels[candidate.channelId], + }), + ); + if (!hasRemainingKeys) { + delete nextAnimations.bindings[propertyPath]; } - delete nextAnimations.bindings[propertyPath]; return toAnimation({ animations: nextAnimations, }); } - if (binding.components.length !== 1) { - throw new Error( - `setChannel only supports single-component bindings. Received "${propertyPath}" with ${binding.components.length} components.`, - ); - } - - nextAnimations.channels[primaryComponent.channelId] = normalizeChannel({ + nextAnimations.channels[component.channelId] = normalizeChannel({ channel, }); return toAnimation({ @@ -689,6 +737,73 @@ export function setChannel({ }); } +export function updateScalarKeyframeCurve({ + animations, + propertyPath, + componentKey, + keyframeId, + patch, +}: { + animations: ElementAnimations | undefined; + propertyPath: AnimationPath; + componentKey: string; + keyframeId: string; + patch: ScalarCurveKeyframePatch; +}): ElementAnimations | undefined { + const binding = getBinding({ animations, propertyPath }); + if (!binding) { + return animations; + } + + const component = getBindingComponent({ + binding, + componentKey, + }); + if (!component) { + return animations; + } + + const channel = getChannelById({ + animations, + channelId: component.channelId, + }); + if (channel?.kind !== "scalar") { + return animations; + } + + const keyframeIndex = channel.keys.findIndex((keyframe) => keyframe.id === keyframeId); + if (keyframeIndex < 0) { + return animations; + } + + const nextKeys = [...channel.keys]; + const currentKey = nextKeys[keyframeIndex]; + nextKeys[keyframeIndex] = { + ...currentKey, + leftHandle: + patch.leftHandle === undefined + ? currentKey.leftHandle + : patch.leftHandle ?? undefined, + rightHandle: + patch.rightHandle === undefined + ? currentKey.rightHandle + : patch.rightHandle ?? undefined, + segmentToNext: patch.segmentToNext ?? currentKey.segmentToNext, + tangentMode: patch.tangentMode ?? currentKey.tangentMode, + }; + + return setBindingComponentChannel({ + animations, + propertyPath, + componentKey, + channel: { + kind: "scalar", + keys: nextKeys, + extrapolation: channel.extrapolation, + }, + }); +} + export function cloneAnimations({ animations, shouldRegenerateKeyframeIds = false, diff --git a/apps/web/src/lib/animation/resolve.ts b/apps/web/src/lib/animation/resolve.ts index 3de97026..67f4a487 100644 --- a/apps/web/src/lib/animation/resolve.ts +++ b/apps/web/src/lib/animation/resolve.ts @@ -1,5 +1,9 @@ import type { + AnimationColorPropertyPath, + AnimationNumericPropertyPath, + AnimationPath, AnimationPropertyPath, + AnimationValueForPath, ElementAnimations, } from "@/lib/animation/types"; import type { Transform } from "@/lib/rendering"; @@ -96,7 +100,7 @@ export function resolveNumberAtTime({ }: { baseValue: number; animations: ElementAnimations | undefined; - propertyPath: AnimationPropertyPath; + propertyPath: AnimationNumericPropertyPath; localTime: number; }): number { return resolveAnimationPathValueAtTime({ @@ -115,7 +119,7 @@ export function resolveColorAtTime({ }: { baseColor: string; animations: ElementAnimations | undefined; - propertyPath: AnimationPropertyPath; + propertyPath: AnimationColorPropertyPath; localTime: number; }): string { return resolveAnimationPathValueAtTime({ @@ -126,19 +130,17 @@ export function resolveColorAtTime({ }); } -export function resolveAnimationPathValueAtTime< - T extends number | string | boolean | Transform["position"], ->({ +export function resolveAnimationPathValueAtTime<TPath extends AnimationPath>({ animations, propertyPath, localTime, fallbackValue, }: { animations: ElementAnimations | undefined; - propertyPath: string; + propertyPath: TPath; localTime: number; - fallbackValue: T; -}): T { + fallbackValue: AnimationValueForPath<TPath>; +}): AnimationValueForPath<TPath> { const binding = animations?.bindings[propertyPath]; if (!binding) { return fallbackValue; @@ -170,5 +172,5 @@ export function resolveAnimationPathValueAtTime< return (composeAnimationValue({ binding, componentValues, - }) ?? fallbackValue) as T; + }) ?? fallbackValue) as AnimationValueForPath<TPath>; } diff --git a/apps/web/src/lib/animation/types.ts b/apps/web/src/lib/animation/types.ts index 1d07e81d..453723b0 100644 --- a/apps/web/src/lib/animation/types.ts +++ b/apps/web/src/lib/animation/types.ts @@ -1,3 +1,5 @@ +import type { ParamValues } from "@/lib/params"; + export const ANIMATION_PROPERTY_PATHS = [ "transform.position", "transform.scaleX", @@ -31,6 +33,34 @@ export type AnimationPropertyGroup = keyof typeof ANIMATION_PROPERTY_GROUPS; export type VectorValue = { x: number; y: number }; export type DiscreteValue = boolean | string; export type AnimationValue = number | string | boolean | VectorValue; +export interface AnimationPropertyValueMap { + "transform.position": VectorValue; + "transform.scaleX": number; + "transform.scaleY": number; + "transform.rotate": number; + opacity: number; + volume: number; + color: string; + "background.color": string; + "background.paddingX": number; + "background.paddingY": number; + "background.offsetX": number; + "background.offsetY": number; + "background.cornerRadius": number; +} +export type DynamicAnimationPathValue = ParamValues[string]; +export type AnimationValueForPath<TPath extends AnimationPath> = + TPath extends AnimationPropertyPath + ? AnimationPropertyValueMap[TPath] + : TPath extends GraphicParamPath | EffectParamPath + ? DynamicAnimationPathValue + : never; +export type AnimationNumericPropertyPath = { + [K in AnimationPropertyPath]: AnimationValueForPath<K> extends number ? K : never; +}[AnimationPropertyPath]; +export type AnimationColorPropertyPath = { + [K in AnimationPropertyPath]: AnimationValueForPath<K> extends string ? K : never; +}[AnimationPropertyPath]; export type ContinuousKeyframeInterpolation = "linear" | "hold" | "bezier"; export type DiscreteKeyframeInterpolation = "hold"; @@ -144,6 +174,36 @@ export interface ElementAnimations { channels: ElementAnimationChannelMap; } +export type NormalizedCubicBezier = [number, number, number, number]; + +export interface ScalarGraphChannelTarget { + propertyPath: AnimationPath; + componentKey: string; + channelId: string; +} + +export interface ScalarGraphChannel extends ScalarGraphChannelTarget { + channel: ScalarAnimationChannel; +} + +export interface ScalarGraphKeyframeRef extends ScalarGraphChannelTarget { + keyframeId: string; +} + +export interface ScalarGraphKeyframeContext extends ScalarGraphChannel { + keyframe: ScalarAnimationKey; + keyframeIndex: number; + previousKey: ScalarAnimationKey | null; + nextKey: ScalarAnimationKey | null; +} + +export interface ScalarCurveKeyframePatch { + leftHandle?: CurveHandle | null; + rightHandle?: CurveHandle | null; + segmentToNext?: ScalarSegmentType; + tangentMode?: TangentMode; +} + export interface ElementKeyframe { propertyPath: AnimationPath; id: string; |
