diff options
| author | Maze Winther <mazewinther@gmail.com> | 2026-04-13 04:40:38 +0200 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2026-04-13 05:01:51 +0200 |
| commit | 108d3f4ecad4c0ead14941c2067a21546750fedc (patch) | |
| tree | 8969775b3292ac2570b95e7848774958caadbc8b /apps/web/src/lib/animation | |
| parent | 8230faf082c405c9fd0fa640f4ce2284315782ae (diff) | |
| download | opencut-108d3f4ecad4c0ead14941c2067a21546750fedc.tar.gz | |
feat: position animated independently per axis
Made-with: Cursor
Diffstat (limited to 'apps/web/src/lib/animation')
| -rw-r--r-- | apps/web/src/lib/animation/binding-values.ts | 12 | ||||
| -rw-r--r-- | apps/web/src/lib/animation/graph-channels.ts | 24 | ||||
| -rw-r--r-- | apps/web/src/lib/animation/index.ts | 4 | ||||
| -rw-r--r-- | apps/web/src/lib/animation/keyframes.ts | 46 | ||||
| -rw-r--r-- | apps/web/src/lib/animation/property-registry.ts | 31 | ||||
| -rw-r--r-- | apps/web/src/lib/animation/resolve.ts | 20 | ||||
| -rw-r--r-- | apps/web/src/lib/animation/types.ts | 6 |
7 files changed, 99 insertions, 44 deletions
diff --git a/apps/web/src/lib/animation/binding-values.ts b/apps/web/src/lib/animation/binding-values.ts index 344fd776..66fb2b5d 100644 --- a/apps/web/src/lib/animation/binding-values.ts +++ b/apps/web/src/lib/animation/binding-values.ts @@ -47,6 +47,18 @@ export function isVectorValue(value: unknown): value is VectorValue { return isRecord(value) && typeof value.x === "number" && typeof value.y === "number"; } +export type EasingMode = "independent" | "shared"; + +/** + * Declares how easing curves apply to a binding's components. + * "shared" means all components always use the same curve (e.g. color — you never + * want to ease R independently from G/B/A). "independent" means each component + * can have its own curve. + */ +export function getEasingModeForKind(kind: AnimationBindingKind): EasingMode { + return kind === "color" ? "shared" : "independent"; +} + export function getBindingComponentKeys({ kind, }: { diff --git a/apps/web/src/lib/animation/graph-channels.ts b/apps/web/src/lib/animation/graph-channels.ts index 240b00ed..19d53e27 100644 --- a/apps/web/src/lib/animation/graph-channels.ts +++ b/apps/web/src/lib/animation/graph-channels.ts @@ -1,4 +1,5 @@ import type { + AnimationBindingInstance, AnimationPath, ElementAnimations, ScalarAnimationChannel, @@ -6,6 +7,11 @@ import type { ScalarGraphKeyframeContext, } from "@/lib/animation/types"; +export interface EditableScalarChannels { + binding: AnimationBindingInstance; + channels: ScalarGraphChannel[]; +} + function isScalarAnimationChannel( channel: ElementAnimations["channels"][string], ): channel is ScalarAnimationChannel { @@ -18,13 +24,13 @@ export function getEditableScalarChannels({ }: { animations: ElementAnimations | undefined; propertyPath: AnimationPath; -}): ScalarGraphChannel[] { +}): EditableScalarChannels | null { const binding = animations?.bindings[propertyPath]; if (!binding) { - return []; + return null; } - return binding.components.flatMap((component) => { + const channels = binding.components.flatMap((component) => { const channel = animations?.channels[component.channelId]; if (!isScalarAnimationChannel(channel)) { return []; @@ -36,9 +42,11 @@ export function getEditableScalarChannels({ componentKey: component.key, channelId: component.channelId, channel, - }, + } satisfies ScalarGraphChannel, ]; }); + + return { binding, channels }; } export function getEditableScalarChannel({ @@ -50,12 +58,8 @@ export function getEditableScalarChannel({ propertyPath: AnimationPath; componentKey: string; }): ScalarGraphChannel | null { - return ( - getEditableScalarChannels({ - animations, - propertyPath, - }).find((channel) => channel.componentKey === componentKey) ?? null - ); + const result = getEditableScalarChannels({ animations, propertyPath }); + return result?.channels.find((channel) => channel.componentKey === componentKey) ?? null; } export function getScalarKeyframeContext({ diff --git a/apps/web/src/lib/animation/index.ts b/apps/web/src/lib/animation/index.ts index 7d2646d9..384f3d17 100644 --- a/apps/web/src/lib/animation/index.ts +++ b/apps/web/src/lib/animation/index.ts @@ -49,6 +49,7 @@ export { } from "./keyframe-query"; export { + type EditableScalarChannels, getEditableScalarChannel, getEditableScalarChannels, getScalarKeyframeContext, @@ -90,5 +91,6 @@ export { } from "./property-groups"; export { - isVectorValue, + type EasingMode, + getEasingModeForKind, } from "./binding-values"; diff --git a/apps/web/src/lib/animation/keyframes.ts b/apps/web/src/lib/animation/keyframes.ts index 05290c1d..a5efb6ee 100644 --- a/apps/web/src/lib/animation/keyframes.ts +++ b/apps/web/src/lib/animation/keyframes.ts @@ -173,7 +173,7 @@ function createScalarKey({ id: string; time: number; value: number; - interpolation: AnimationInterpolation; + interpolation?: AnimationInterpolation; previousKey?: ScalarAnimationKey; }): ScalarAnimationKey { return { @@ -183,7 +183,8 @@ function createScalarKey({ leftHandle: previousKey?.leftHandle, rightHandle: previousKey?.rightHandle, segmentToNext: - previousKey?.segmentToNext ?? getScalarSegmentType({ interpolation }), + previousKey?.segmentToNext ?? + getScalarSegmentType({ interpolation: interpolation ?? "linear" }), tangentMode: previousKey?.tangentMode ?? "flat", }; } @@ -332,12 +333,14 @@ function upsertScalarChannelKey({ time, value, interpolation, + defaultInterpolation, keyframeId, }: { channel: ScalarAnimationChannel | undefined; time: number; value: number; - interpolation: AnimationInterpolation; + interpolation?: AnimationInterpolation; + defaultInterpolation?: AnimationInterpolation; keyframeId?: string; }): ScalarAnimationChannel { const normalizedChannel = normalizeChannel({ @@ -352,10 +355,13 @@ function upsertScalarChannelKey({ time, value, interpolation, - previousKey: { - ...keys[existingIndex], - segmentToNext: getScalarSegmentType({ interpolation }), - }, + previousKey: + interpolation != null + ? { + ...keys[existingIndex], + segmentToNext: getScalarSegmentType({ interpolation }), + } + : keys[existingIndex], }); return normalizeChannel({ channel: { @@ -376,10 +382,13 @@ function upsertScalarChannelKey({ time: keys[existingAtTimeIndex].time, value, interpolation, - previousKey: { - ...keys[existingAtTimeIndex], - segmentToNext: getScalarSegmentType({ interpolation }), - }, + previousKey: + interpolation != null + ? { + ...keys[existingAtTimeIndex], + segmentToNext: getScalarSegmentType({ interpolation }), + } + : keys[existingAtTimeIndex], }); return normalizeChannel({ channel: { @@ -395,7 +404,7 @@ function upsertScalarChannelKey({ id: keyframeId ?? generateUUID(), time, value, - interpolation, + interpolation: interpolation ?? defaultInterpolation, }), ); return normalizeChannel({ @@ -472,9 +481,13 @@ export function upsertPathKeyframe({ return animations; } - const nextInterpolation = getInterpolationForBinding({ + const explicitInterpolation = + interpolation != null + ? getInterpolationForBinding({ kind, interpolation }) + : undefined; + const validatedDefaultInterpolation = getInterpolationForBinding({ kind, - interpolation: interpolation ?? defaultInterpolation, + interpolation: defaultInterpolation, }); nextAnimations.bindings[propertyPath] = binding; for (const component of binding.components) { @@ -503,7 +516,8 @@ export function upsertPathKeyframe({ channel: targetChannel, time: targetKey.time, value: nextValue as number, - interpolation: nextInterpolation, + interpolation: explicitInterpolation, + defaultInterpolation: validatedDefaultInterpolation, keyframeId: targetKey.id, }); } @@ -592,7 +606,7 @@ export function upsertKeyframe({ channel, time, value, - interpolation: interpolation ?? "linear", + interpolation, keyframeId, }); } diff --git a/apps/web/src/lib/animation/property-registry.ts b/apps/web/src/lib/animation/property-registry.ts index ac15805e..65713e6d 100644 --- a/apps/web/src/lib/animation/property-registry.ts +++ b/apps/web/src/lib/animation/property-registry.ts @@ -3,9 +3,8 @@ import type { AnimationInterpolation, AnimationPropertyPath, AnimationValue, - VectorValue, } from "@/lib/animation/types"; -import { isVectorValue, parseColorToLinearRgba } from "./binding-values"; +import { parseColorToLinearRgba } from "./binding-values"; import type { TimelineElement } from "@/lib/timeline"; import { MIN_TRANSFORM_SCALE } from "@/constants/animation-constants"; import { @@ -116,24 +115,38 @@ const ANIMATION_PROPERTY_REGISTRY: Record< AnimationPropertyPath, AnimationPropertyDefinition > = { - "transform.position": { - kind: "vector2", - defaultInterpolation: "linear", + "transform.positionX": createNumberPropertyDefinition({ + numericRange: { step: 1 }, supportsElement: ({ element }) => isVisualElement(element), getValue: ({ element }) => - isVisualElement(element) ? element.transform.position : null, - coerceValue: ({ value }) => (isVectorValue(value) ? value : null), + isVisualElement(element) ? element.transform.position.x : null, setValue: ({ element, value }) => isVisualElement(element) ? { ...element, transform: { ...element.transform, - position: value as VectorValue, + position: { ...element.transform.position, x: value as number }, }, } : element, - }, + }), + "transform.positionY": createNumberPropertyDefinition({ + numericRange: { step: 1 }, + supportsElement: ({ element }) => isVisualElement(element), + getValue: ({ element }) => + isVisualElement(element) ? element.transform.position.y : null, + setValue: ({ element, value }) => + isVisualElement(element) + ? { + ...element, + transform: { + ...element.transform, + position: { ...element.transform.position, y: value as number }, + }, + } + : element, + }), "transform.scaleX": createNumberPropertyDefinition({ numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 }, supportsElement: ({ element }) => isVisualElement(element), diff --git a/apps/web/src/lib/animation/resolve.ts b/apps/web/src/lib/animation/resolve.ts index 67f4a487..42c1c73c 100644 --- a/apps/web/src/lib/animation/resolve.ts +++ b/apps/web/src/lib/animation/resolve.ts @@ -48,12 +48,20 @@ export function resolveTransformAtTime({ }): Transform { const safeLocalTime = Math.max(0, localTime); return { - position: resolveAnimationPathValueAtTime({ - animations, - propertyPath: "transform.position", - localTime: safeLocalTime, - fallbackValue: baseTransform.position, - }), + position: { + x: resolveAnimationPathValueAtTime({ + animations, + propertyPath: "transform.positionX", + localTime: safeLocalTime, + fallbackValue: baseTransform.position.x, + }), + y: resolveAnimationPathValueAtTime({ + animations, + propertyPath: "transform.positionY", + localTime: safeLocalTime, + fallbackValue: baseTransform.position.y, + }), + }, scaleX: resolveAnimationPathValueAtTime({ animations, propertyPath: "transform.scaleX", diff --git a/apps/web/src/lib/animation/types.ts b/apps/web/src/lib/animation/types.ts index 453723b0..115b4dbf 100644 --- a/apps/web/src/lib/animation/types.ts +++ b/apps/web/src/lib/animation/types.ts @@ -1,7 +1,8 @@ import type { ParamValues } from "@/lib/params"; export const ANIMATION_PROPERTY_PATHS = [ - "transform.position", + "transform.positionX", + "transform.positionY", "transform.scaleX", "transform.scaleY", "transform.rotate", @@ -34,7 +35,8 @@ 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.positionX": number; + "transform.positionY": number; "transform.scaleX": number; "transform.scaleY": number; "transform.rotate": number; |
