summaryrefslogtreecommitdiff
path: root/apps/web/src/lib
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-04-13 04:40:38 +0200
committerMaze Winther <mazewinther@gmail.com>2026-04-13 05:01:51 +0200
commit108d3f4ecad4c0ead14941c2067a21546750fedc (patch)
tree8969775b3292ac2570b95e7848774958caadbc8b /apps/web/src/lib
parent8230faf082c405c9fd0fa640f4ce2284315782ae (diff)
downloadopencut-108d3f4ecad4c0ead14941c2067a21546750fedc.tar.gz
feat: position animated independently per axis
Made-with: Cursor
Diffstat (limited to 'apps/web/src/lib')
-rw-r--r--apps/web/src/lib/animation/binding-values.ts12
-rw-r--r--apps/web/src/lib/animation/graph-channels.ts24
-rw-r--r--apps/web/src/lib/animation/index.ts4
-rw-r--r--apps/web/src/lib/animation/keyframes.ts46
-rw-r--r--apps/web/src/lib/animation/property-registry.ts31
-rw-r--r--apps/web/src/lib/animation/resolve.ts20
-rw-r--r--apps/web/src/lib/animation/types.ts6
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;