summaryrefslogtreecommitdiff
path: root/apps/web/src/lib/animation
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/lib/animation')
-rw-r--r--apps/web/src/lib/animation/__tests__/transform-keyframes.test.ts313
-rw-r--r--apps/web/src/lib/animation/color-channel.ts2
-rw-r--r--apps/web/src/lib/animation/effect-param-channel.ts55
-rw-r--r--apps/web/src/lib/animation/graphic-param-channel.ts79
-rw-r--r--apps/web/src/lib/animation/index.ts32
-rw-r--r--apps/web/src/lib/animation/interpolation.ts71
-rw-r--r--apps/web/src/lib/animation/keyframe-query.ts17
-rw-r--r--apps/web/src/lib/animation/keyframes.ts231
-rw-r--r--apps/web/src/lib/animation/number-channel.ts2
-rw-r--r--apps/web/src/lib/animation/property-groups.ts39
-rw-r--r--apps/web/src/lib/animation/property-registry.ts93
-rw-r--r--apps/web/src/lib/animation/resolve.ts69
-rw-r--r--apps/web/src/lib/animation/target-resolver.ts279
-rw-r--r--apps/web/src/lib/animation/types.ts119
-rw-r--r--apps/web/src/lib/animation/vector-channel.ts72
15 files changed, 992 insertions, 481 deletions
diff --git a/apps/web/src/lib/animation/__tests__/transform-keyframes.test.ts b/apps/web/src/lib/animation/__tests__/transform-keyframes.test.ts
deleted file mode 100644
index 629bd0d6..00000000
--- a/apps/web/src/lib/animation/__tests__/transform-keyframes.test.ts
+++ /dev/null
@@ -1,313 +0,0 @@
-import { describe, expect, test } from "bun:test";
-import type { ElementAnimations } from "@/types/animation";
-import {
- clampAnimationsToDuration,
- getElementKeyframes,
- getKeyframeAtTime,
- hasKeyframesForPath,
- getChannelValueAtTime,
- getElementLocalTime,
- resolveTransformAtTime,
- splitAnimationsAtTime,
-} from "@/lib/animation";
-
-describe("transform keyframe evaluation", () => {
- test("uses fallback value when channel is missing", () => {
- const value = getChannelValueAtTime({
- channel: undefined,
- time: 1,
- fallbackValue: 42,
- });
- expect(value).toBe(42);
- });
-
- test("returns boundary value when time is within epsilon of first/last keyframe", () => {
- const channel = {
- valueKind: "number" as const,
- keyframes: [
- { id: "a", time: 0, value: 10, interpolation: "linear" as const },
- { id: "b", time: 2, value: 30, interpolation: "linear" as const },
- ],
- };
- expect(
- getChannelValueAtTime({
- channel,
- time: 0.0008,
- fallbackValue: 0,
- }),
- ).toBe(10);
- expect(
- getChannelValueAtTime({
- channel,
- time: 1.9992,
- fallbackValue: 0,
- }),
- ).toBe(30);
- });
-
- test("interpolates linear channels", () => {
- const value = getChannelValueAtTime({
- channel: {
- valueKind: "number",
- keyframes: [
- { id: "a", time: 0, value: 10, interpolation: "linear" },
- { id: "b", time: 2, value: 30, interpolation: "linear" },
- ],
- },
- time: 1,
- fallbackValue: 0,
- });
- expect(value).toBe(20);
- });
-
- test("clamps local time to [0, duration]", () => {
- expect(
- getElementLocalTime({
- timelineTime: 2,
- elementStartTime: 5,
- elementDuration: 4,
- }),
- ).toBe(0);
- expect(
- getElementLocalTime({
- timelineTime: 12,
- elementStartTime: 5,
- elementDuration: 4,
- }),
- ).toBe(4);
- expect(
- getElementLocalTime({
- timelineTime: 7,
- elementStartTime: 5,
- elementDuration: 4,
- }),
- ).toBe(2);
- });
-
- test("uses hold interpolation from the left keyframe", () => {
- const value = getChannelValueAtTime({
- channel: {
- valueKind: "number",
- keyframes: [
- { id: "a", time: 0, value: 10, interpolation: "hold" },
- { id: "b", time: 2, value: 30, interpolation: "linear" },
- ],
- },
- time: 1,
- fallbackValue: 0,
- });
- expect(value).toBe(10);
- });
-
- test("resolves transform by mixing animated and fallback properties", () => {
- const animations: ElementAnimations = {
- channels: {
- "transform.position.x": {
- valueKind: "number",
- keyframes: [
- { id: "a", time: 0, value: 0, interpolation: "linear" },
- { id: "b", time: 4, value: 80, interpolation: "linear" },
- ],
- },
- "transform.scale": {
- valueKind: "number",
- keyframes: [{ id: "c", time: 0, value: 2, interpolation: "hold" }],
- },
- },
- };
- const resolvedTransform = resolveTransformAtTime({
- baseTransform: {
- position: { x: 10, y: 20 },
- scale: 1,
- rotate: 15,
- },
- animations,
- localTime: 2,
- });
- expect(resolvedTransform).toEqual({
- position: { x: 40, y: 20 },
- scale: 2,
- rotate: 15,
- });
- });
-});
-
-describe("transform keyframe mutation utilities", () => {
- test("splits channels and rebases right side times", () => {
- const animations: ElementAnimations = {
- channels: {
- "transform.scale": {
- valueKind: "number",
- keyframes: [
- { id: "a", time: 0, value: 1, interpolation: "linear" },
- { id: "b", time: 2, value: 2, interpolation: "linear" },
- { id: "c", time: 6, value: 4, interpolation: "linear" },
- ],
- },
- },
- };
- const { leftAnimations, rightAnimations } = splitAnimationsAtTime({
- animations,
- splitTime: 4,
- });
-
- expect(
- leftAnimations?.channels["transform.scale"]?.keyframes.map(
- (keyframe) => keyframe.time,
- ),
- ).toEqual([0, 2, 4]);
- expect(
- rightAnimations?.channels["transform.scale"]?.keyframes.map(
- (keyframe) => keyframe.time,
- ),
- ).toEqual([0, 2]);
- expect(
- rightAnimations?.channels["transform.scale"]?.keyframes[0]?.value,
- ).toBe(3);
- });
-
- test("clamps channels to updated element duration", () => {
- const animations: ElementAnimations = {
- channels: {
- "transform.rotate": {
- valueKind: "number",
- keyframes: [
- { id: "a", time: 0, value: 0, interpolation: "linear" },
- { id: "b", time: 2, value: 20, interpolation: "linear" },
- { id: "c", time: 5, value: 50, interpolation: "linear" },
- ],
- },
- },
- };
- const clampedAnimations = clampAnimationsToDuration({
- animations,
- duration: 2,
- });
- expect(
- clampedAnimations?.channels["transform.rotate"]?.keyframes.map(
- (keyframe) => keyframe.time,
- ),
- ).toEqual([0, 2]);
- });
-});
-
-describe("typed channel interpolation", () => {
- test("interpolates color channels from hex keyframes", () => {
- const value = getChannelValueAtTime({
- channel: {
- valueKind: "color",
- keyframes: [
- { id: "a", time: 0, value: "#000000", interpolation: "linear" },
- { id: "b", time: 1, value: "#ffffff", interpolation: "linear" },
- ],
- },
- time: 0.5,
- fallbackValue: "#000000",
- });
- expect(typeof value).toBe("string");
- expect(value).toContain("rgba(");
- });
-
- test("uses hold behavior for discrete channels", () => {
- const value = getChannelValueAtTime({
- channel: {
- valueKind: "discrete",
- keyframes: [
- { id: "a", time: 0, value: "normal", interpolation: "hold" },
- { id: "b", time: 2, value: "multiply", interpolation: "hold" },
- ],
- },
- time: 1.2,
- fallbackValue: "normal",
- });
- expect(value).toBe("normal");
- });
-});
-
-describe("keyframe query helpers", () => {
- test("getElementKeyframes returns flat list of all keyframes across channels", () => {
- const animations: ElementAnimations = {
- channels: {
- "transform.position.x": {
- valueKind: "number",
- keyframes: [{ id: "x-1", time: 1, value: 64, interpolation: "linear" }],
- },
- opacity: {
- valueKind: "number",
- keyframes: [{ id: "o-1", time: 0, value: 1, interpolation: "linear" }],
- },
- },
- };
-
- const keyframes = getElementKeyframes({ animations });
- expect(keyframes).toHaveLength(2);
- expect(keyframes.map((keyframe) => keyframe.propertyPath).sort()).toEqual([
- "opacity",
- "transform.position.x",
- ]);
- });
-
- test("getElementKeyframes returns empty array when animations are missing or channels are empty", () => {
- expect(getElementKeyframes({ animations: undefined })).toEqual([]);
- expect(
- getElementKeyframes({
- animations: {
- channels: { opacity: { valueKind: "number", keyframes: [] } },
- },
- }),
- ).toEqual([]);
- });
-
- test("hasKeyframesForPath returns true only for paths with keyframes", () => {
- const animations: ElementAnimations = {
- channels: {
- "transform.position.x": {
- valueKind: "number",
- keyframes: [{ id: "x-1", time: 1, value: 64, interpolation: "linear" }],
- },
- "transform.position.y": {
- valueKind: "number",
- keyframes: [],
- },
- },
- };
-
- expect(
- hasKeyframesForPath({ animations, propertyPath: "transform.position.x" }),
- ).toBe(true);
- expect(
- hasKeyframesForPath({ animations, propertyPath: "transform.position.y" }),
- ).toBe(false);
- });
-
- test("getKeyframeAtTime finds keyframe within epsilon and returns full object", () => {
- const animations: ElementAnimations = {
- channels: {
- "transform.rotate": {
- valueKind: "number",
- keyframes: [
- { id: "r-1", time: 1, value: 15, interpolation: "linear" },
- { id: "r-2", time: 2, value: 30, interpolation: "linear" },
- ],
- },
- },
- };
-
- const found = getKeyframeAtTime({
- animations,
- propertyPath: "transform.rotate",
- time: 1.0008,
- });
- expect(found?.id).toBe("r-1");
- expect(found?.value).toBe(15);
- expect(found?.propertyPath).toBe("transform.rotate");
-
- expect(
- getKeyframeAtTime({
- animations,
- propertyPath: "transform.rotate",
- time: 1.01,
- }),
- ).toBeNull();
- });
-});
diff --git a/apps/web/src/lib/animation/color-channel.ts b/apps/web/src/lib/animation/color-channel.ts
index 2a340f94..a34b469b 100644
--- a/apps/web/src/lib/animation/color-channel.ts
+++ b/apps/web/src/lib/animation/color-channel.ts
@@ -2,7 +2,7 @@ import type {
AnimationPropertyPath,
ColorAnimationChannel,
ElementAnimations,
-} from "@/types/animation";
+} from "@/lib/animation/types";
export function getColorChannelForPath({
animations,
diff --git a/apps/web/src/lib/animation/effect-param-channel.ts b/apps/web/src/lib/animation/effect-param-channel.ts
index 07efe4d8..323d9f54 100644
--- a/apps/web/src/lib/animation/effect-param-channel.ts
+++ b/apps/web/src/lib/animation/effect-param-channel.ts
@@ -1,8 +1,10 @@
-import type { Effect, EffectParamValues } from "@/types/effects";
+import type { ParamValues } from "@/lib/params";
+import type { Effect } from "@/lib/effects/types";
import type {
ElementAnimations,
+ EffectParamPath,
NumberAnimationChannel,
-} from "@/types/animation";
+} from "@/lib/animation/types";
import {
getChannel,
removeKeyframe,
@@ -11,19 +13,56 @@ import {
} from "./keyframes";
import { getChannelValueAtTime } from "./interpolation";
-const EFFECT_PARAM_PATH_PREFIX = "effects.";
-const EFFECT_PARAM_PATH_SUFFIX = ".params.";
+export const EFFECT_PARAM_PATH_PREFIX = "effects.";
+export const EFFECT_PARAM_PATH_SUFFIX = ".params.";
-function buildEffectParamPath({
+export function buildEffectParamPath({
effectId,
paramKey,
}: {
effectId: string;
paramKey: string;
-}): string {
+}): EffectParamPath {
return `${EFFECT_PARAM_PATH_PREFIX}${effectId}${EFFECT_PARAM_PATH_SUFFIX}${paramKey}`;
}
+export function isEffectParamPath({
+ propertyPath,
+}: {
+ propertyPath: string;
+}): propertyPath is EffectParamPath {
+ return (
+ propertyPath.startsWith(EFFECT_PARAM_PATH_PREFIX) &&
+ propertyPath.includes(EFFECT_PARAM_PATH_SUFFIX)
+ );
+}
+
+export function parseEffectParamPath({
+ propertyPath,
+}: {
+ propertyPath: string;
+}): { effectId: string; paramKey: string } | null {
+ if (!isEffectParamPath({ propertyPath })) {
+ return null;
+ }
+
+ const withoutPrefix = propertyPath.slice(EFFECT_PARAM_PATH_PREFIX.length);
+ const separatorIndex = withoutPrefix.indexOf(EFFECT_PARAM_PATH_SUFFIX);
+ if (separatorIndex <= 0) {
+ return null;
+ }
+
+ const effectId = withoutPrefix.slice(0, separatorIndex);
+ const paramKey = withoutPrefix.slice(
+ separatorIndex + EFFECT_PARAM_PATH_SUFFIX.length,
+ );
+ if (!effectId || !paramKey) {
+ return null;
+ }
+
+ return { effectId, paramKey };
+}
+
export function resolveEffectParamsAtTime({
effect,
animations,
@@ -32,8 +71,8 @@ export function resolveEffectParamsAtTime({
effect: Effect;
animations: ElementAnimations | undefined;
localTime: number;
-}): EffectParamValues {
- const resolved: EffectParamValues = {};
+}): ParamValues {
+ const resolved: ParamValues = {};
for (const [paramKey, staticValue] of Object.entries(effect.params)) {
const path = buildEffectParamPath({ effectId: effect.id, paramKey });
diff --git a/apps/web/src/lib/animation/graphic-param-channel.ts b/apps/web/src/lib/animation/graphic-param-channel.ts
new file mode 100644
index 00000000..e77e4c73
--- /dev/null
+++ b/apps/web/src/lib/animation/graphic-param-channel.ts
@@ -0,0 +1,79 @@
+import type {
+ ElementAnimations,
+ GraphicParamPath,
+} from "@/lib/animation/types";
+import type { ParamValues } from "@/lib/params";
+import {
+ getGraphicDefinition,
+ resolveGraphicParams,
+} from "@/lib/graphics";
+import { getChannel } from "./keyframes";
+import { getChannelValueAtTime } from "./interpolation";
+
+export const GRAPHIC_PARAM_PATH_PREFIX = "params.";
+
+export function buildGraphicParamPath({
+ paramKey,
+}: {
+ paramKey: string;
+}): GraphicParamPath {
+ return `${GRAPHIC_PARAM_PATH_PREFIX}${paramKey}`;
+}
+
+export function isGraphicParamPath({
+ propertyPath,
+}: {
+ propertyPath: string;
+}): propertyPath is GraphicParamPath {
+ return propertyPath.startsWith(GRAPHIC_PARAM_PATH_PREFIX);
+}
+
+export function parseGraphicParamPath({
+ propertyPath,
+}: {
+ propertyPath: string;
+}): { paramKey: string } | null {
+ if (!isGraphicParamPath({ propertyPath })) {
+ return null;
+ }
+
+ const paramKey = propertyPath.slice(GRAPHIC_PARAM_PATH_PREFIX.length);
+ return paramKey.length > 0 ? { paramKey } : null;
+}
+
+export function resolveGraphicParamsAtTime({
+ element,
+ localTime,
+}: {
+ element: {
+ definitionId: string;
+ params: ParamValues;
+ animations?: ElementAnimations;
+ };
+ localTime: number;
+}): ParamValues {
+ const definition = getGraphicDefinition({
+ definitionId: element.definitionId,
+ });
+ const baseParams = resolveGraphicParams(definition, element.params);
+ const resolved: ParamValues = { ...baseParams };
+
+ for (const param of definition.params) {
+ const path = buildGraphicParamPath({ paramKey: param.key });
+ const channel = getChannel({
+ animations: element.animations,
+ propertyPath: path,
+ });
+ if (!channel || channel.keyframes.length === 0) {
+ continue;
+ }
+
+ resolved[param.key] = getChannelValueAtTime({
+ channel,
+ time: Math.max(0, localTime),
+ fallbackValue: baseParams[param.key] ?? param.default,
+ }) as number | string | boolean;
+ }
+
+ return resolved;
+}
diff --git a/apps/web/src/lib/animation/index.ts b/apps/web/src/lib/animation/index.ts
index c4396bb7..3dcddad3 100644
--- a/apps/web/src/lib/animation/index.ts
+++ b/apps/web/src/lib/animation/index.ts
@@ -1,6 +1,7 @@
export {
getChannelValueAtTime,
getNumberChannelValueAtTime,
+ getVectorChannelValueAtTime,
normalizeChannel,
} from "./interpolation";
@@ -13,6 +14,7 @@ export {
setChannel,
splitAnimationsAtTime,
upsertElementKeyframe,
+ upsertPathKeyframe,
} from "./keyframes";
export {
@@ -21,7 +23,6 @@ export {
resolveNumberAtTime,
resolveOpacityAtTime,
resolveTransformAtTime,
- resolveVolumeAtTime,
} from "./resolve";
export {
@@ -31,6 +32,9 @@ export {
getElementBaseValueForProperty,
isAnimationPropertyPath,
supportsAnimationProperty,
+ type AnimationPropertyDefinition,
+ type NumericSpec,
+ type NumericRange,
withElementBaseValueForProperty,
} from "./property-registry";
@@ -39,3 +43,29 @@ export {
getKeyframeAtTime,
hasKeyframesForPath,
} from "./keyframe-query";
+
+export {
+ buildGraphicParamPath,
+ isGraphicParamPath,
+ parseGraphicParamPath,
+ resolveGraphicParamsAtTime,
+} from "./graphic-param-channel";
+
+export {
+ isAnimationPath,
+ resolveAnimationTarget,
+ getParamValueKind,
+ getParamDefaultInterpolation,
+ type AnimationPathDescriptor,
+} from "./target-resolver";
+
+export {
+ getGroupKeyframesAtTime,
+ hasGroupKeyframeAtTime,
+ type GroupKeyframeRef,
+} from "./property-groups";
+
+export {
+ getVectorChannelForPath,
+ isVectorValue,
+} from "./vector-channel";
diff --git a/apps/web/src/lib/animation/interpolation.ts b/apps/web/src/lib/animation/interpolation.ts
index 7a6fc6cf..c5b9ea80 100644
--- a/apps/web/src/lib/animation/interpolation.ts
+++ b/apps/web/src/lib/animation/interpolation.ts
@@ -5,7 +5,10 @@ import type {
DiscreteValue,
DiscreteAnimationChannel,
NumberAnimationChannel,
-} from "@/types/animation";
+ VectorAnimationChannel,
+ VectorValue,
+} from "@/lib/animation/types";
+import { isVectorValue } from "./vector-channel";
import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
function byTimeAscending({
@@ -59,12 +62,7 @@ function parseHexColor({
const green = parseHexChannel({ hex: `${greenHex}${greenHex}` });
const blue = parseHexChannel({ hex: `${blueHex}${blueHex}` });
const alpha = parseHexChannel({ hex: `${alphaHex}${alphaHex}` });
- if (
- red === null ||
- green === null ||
- blue === null ||
- alpha === null
- ) {
+ if (red === null || green === null || blue === null || alpha === null) {
return null;
}
@@ -77,12 +75,7 @@ function parseHexColor({
const blue = parseHexChannel({ hex: rawHex.slice(4, 6) });
const alphaHex = rawHex.length === 8 ? rawHex.slice(6, 8) : "ff";
const alpha = parseHexChannel({ hex: alphaHex });
- if (
- red === null ||
- green === null ||
- blue === null ||
- alpha === null
- ) {
+ if (red === null || green === null || blue === null || alpha === null) {
return null;
}
@@ -177,7 +170,10 @@ export function normalizeChannel<TChannel extends AnimationChannel>({
} as TChannel;
}
-function evaluateChannelValueAtTime<TKeyframe extends { time: number; value: TValue }, TValue>({
+function evaluateChannelValueAtTime<
+ TKeyframe extends { time: number; value: TValue },
+ TValue,
+>({
keyframes,
time,
fallbackValue,
@@ -214,7 +210,11 @@ function evaluateChannelValueAtTime<TKeyframe extends { time: number; value: TVa
return lastKeyframe.value;
}
- for (let keyframeIndex = 0; keyframeIndex < keyframes.length - 1; keyframeIndex++) {
+ for (
+ let keyframeIndex = 0;
+ keyframeIndex < keyframes.length - 1;
+ keyframeIndex++
+ ) {
const leftKeyframe = keyframes[keyframeIndex];
const rightKeyframe = keyframes[keyframeIndex + 1];
@@ -304,6 +304,36 @@ export function getColorValueAtTime({
});
}
+export function getVectorChannelValueAtTime({
+ channel,
+ time,
+ fallbackValue,
+}: {
+ channel: VectorAnimationChannel | undefined;
+ time: number;
+ fallbackValue: VectorValue;
+}): VectorValue {
+ return evaluateChannelValueAtTime({
+ keyframes: channel?.keyframes,
+ time,
+ fallbackValue,
+ getInterpolatedValue: ({ leftKeyframe, rightKeyframe, progress }) => {
+ if (leftKeyframe.interpolation === "hold") {
+ return leftKeyframe.value;
+ }
+
+ return {
+ x:
+ leftKeyframe.value.x +
+ (rightKeyframe.value.x - leftKeyframe.value.x) * progress,
+ y:
+ leftKeyframe.value.y +
+ (rightKeyframe.value.y - leftKeyframe.value.y) * progress,
+ };
+ },
+ });
+}
+
function getDiscreteValueAtTime({
channel,
time,
@@ -358,6 +388,17 @@ export function getChannelValueAtTime({
});
}
+ if (channel.valueKind === "vector") {
+ if (!isVectorValue(fallbackValue)) {
+ return fallbackValue;
+ }
+ return getVectorChannelValueAtTime({
+ channel,
+ time,
+ fallbackValue: fallbackValue as VectorValue,
+ });
+ }
+
if (typeof fallbackValue !== "string" && typeof fallbackValue !== "boolean") {
return fallbackValue;
}
diff --git a/apps/web/src/lib/animation/keyframe-query.ts b/apps/web/src/lib/animation/keyframe-query.ts
index 6626e3e8..753e31d0 100644
--- a/apps/web/src/lib/animation/keyframe-query.ts
+++ b/apps/web/src/lib/animation/keyframe-query.ts
@@ -1,9 +1,10 @@
import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
import type {
- AnimationPropertyPath,
+ AnimationPath,
ElementAnimations,
ElementKeyframe,
-} from "@/types/animation";
+} from "@/lib/animation/types";
+import { isAnimationPath } from "./target-resolver";
export function getElementKeyframes({
animations,
@@ -16,12 +17,16 @@ export function getElementKeyframes({
return Object.entries(animations.channels).flatMap(
([propertyPath, channel]) => {
- if (!channel || channel.keyframes.length === 0) {
+ if (
+ !channel ||
+ channel.keyframes.length === 0 ||
+ !isAnimationPath({ propertyPath })
+ ) {
return [];
}
return channel.keyframes.map((keyframe) => ({
- propertyPath: propertyPath as AnimationPropertyPath,
+ propertyPath,
id: keyframe.id,
time: keyframe.time,
value: keyframe.value,
@@ -36,7 +41,7 @@ export function hasKeyframesForPath({
propertyPath,
}: {
animations: ElementAnimations | undefined;
- propertyPath: AnimationPropertyPath;
+ propertyPath: AnimationPath;
}): boolean {
const channel = animations?.channels[propertyPath];
return Boolean(channel && channel.keyframes.length > 0);
@@ -48,7 +53,7 @@ export function getKeyframeAtTime({
time,
}: {
animations: ElementAnimations | undefined;
- propertyPath: AnimationPropertyPath;
+ propertyPath: AnimationPath;
time: number;
}): ElementKeyframe | null {
const channel = animations?.channels[propertyPath];
diff --git a/apps/web/src/lib/animation/keyframes.ts b/apps/web/src/lib/animation/keyframes.ts
index 3e3613f2..5fa99fb8 100644
--- a/apps/web/src/lib/animation/keyframes.ts
+++ b/apps/web/src/lib/animation/keyframes.ts
@@ -2,6 +2,7 @@ import type {
AnimationChannel,
AnimationInterpolation,
AnimationKeyframe,
+ AnimationPath,
AnimationPropertyPath,
AnimationValue,
AnimationValueKind,
@@ -9,15 +10,19 @@ import type {
DiscreteAnimationChannel,
ElementAnimations,
NumberAnimationChannel,
-} from "@/types/animation";
+ VectorAnimationChannel,
+} from "@/lib/animation/types";
+import { isVectorValue } from "./vector-channel";
import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants";
import { generateUUID } from "@/utils/id";
+import { snapToStep } from "@/utils/math";
import { getChannelValueAtTime, normalizeChannel } from "./interpolation";
import {
coerceAnimationValueForProperty,
getDefaultInterpolationForProperty,
getAnimationPropertyDefinition,
isAnimationPropertyPath,
+ type NumericRange,
} from "./property-registry";
function isNearlySameTime({
@@ -126,6 +131,19 @@ function buildKeyframe({
};
}
+ if (channel.valueKind === "vector") {
+ if (!isVectorValue(value)) {
+ throw new Error("Vector channel keyframes require {x, y} values");
+ }
+
+ return {
+ id,
+ time,
+ value,
+ interpolation: interpolation === "hold" ? "hold" : "linear",
+ };
+ }
+
if (typeof value !== "string" && typeof value !== "boolean") {
throw new Error(
"Discrete channel keyframes require boolean or string values",
@@ -140,32 +158,178 @@ function buildKeyframe({
};
}
-function createEmptyChannel({
- propertyPath,
+function createEmptyChannelForValueKind({
+ valueKind,
}: {
- propertyPath: AnimationPropertyPath;
+ valueKind: AnimationValueKind;
}): AnimationChannel {
- const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
- if (propertyDefinition.valueKind === "number") {
+ if (valueKind === "number") {
return {
valueKind: "number",
keyframes: [],
} satisfies NumberAnimationChannel;
}
- if (propertyDefinition.valueKind === "color") {
+ if (valueKind === "color") {
return {
valueKind: "color",
keyframes: [],
} satisfies ColorAnimationChannel;
}
+ if (valueKind === "vector") {
+ return {
+ valueKind: "vector",
+ keyframes: [],
+ } satisfies VectorAnimationChannel;
+ }
+
return {
valueKind: "discrete",
keyframes: [],
} satisfies DiscreteAnimationChannel;
}
+function clampNumericRange({
+ value,
+ numericRange,
+}: {
+ value: number;
+ numericRange: NumericRange | undefined;
+}): number {
+ if (!numericRange) {
+ return value;
+ }
+
+ const steppedValue =
+ numericRange.step != null
+ ? snapToStep({ value, step: numericRange.step })
+ : value;
+ const minValue = numericRange.min ?? Number.NEGATIVE_INFINITY;
+ const maxValue = numericRange.max ?? Number.POSITIVE_INFINITY;
+ return Math.min(maxValue, Math.max(minValue, steppedValue));
+}
+
+function coerceAnimationValueForPath({
+ value,
+ valueKind,
+ numericRange,
+}: {
+ value: AnimationValue;
+ valueKind: AnimationValueKind;
+ numericRange?: NumericRange;
+}): AnimationValue | null {
+ if (valueKind === "number") {
+ if (typeof value !== "number" || Number.isNaN(value)) {
+ return null;
+ }
+
+ return clampNumericRange({ value, numericRange });
+ }
+
+ if (valueKind === "color") {
+ return typeof value === "string" ? value : null;
+ }
+
+ if (valueKind === "vector") {
+ return isVectorValue(value) ? value : null;
+ }
+
+ return typeof value === "string" || typeof value === "boolean" ? value : null;
+}
+
+export function upsertPathKeyframe({
+ animations,
+ propertyPath,
+ time,
+ value,
+ interpolation,
+ keyframeId,
+ valueKind,
+ defaultInterpolation,
+ numericRange,
+}: {
+ animations: ElementAnimations | undefined;
+ propertyPath: AnimationPath;
+ time: number;
+ value: AnimationValue;
+ interpolation?: AnimationInterpolation;
+ keyframeId?: string;
+ valueKind: AnimationValueKind;
+ defaultInterpolation: AnimationInterpolation;
+ numericRange?: NumericRange;
+}): ElementAnimations | undefined {
+ const coercedValue = coerceAnimationValueForPath({
+ value,
+ valueKind,
+ numericRange,
+ });
+ if (coercedValue === null) {
+ return animations;
+ }
+
+ const channel = getChannel({ animations, propertyPath });
+ const targetChannel =
+ channel && channel.valueKind === valueKind
+ ? channel
+ : createEmptyChannelForValueKind({ valueKind });
+ const updatedChannel = upsertKeyframe({
+ channel: targetChannel,
+ time,
+ value: coercedValue,
+ interpolation: interpolation ?? defaultInterpolation,
+ keyframeId,
+ });
+
+ return (
+ setChannel({
+ animations,
+ propertyPath,
+ channel: updatedChannel,
+ }) ?? { channels: {} }
+ );
+}
+
+export function upsertElementKeyframe({
+ animations,
+ propertyPath,
+ time,
+ value,
+ interpolation,
+ keyframeId,
+}: {
+ animations: ElementAnimations | undefined;
+ propertyPath: AnimationPropertyPath;
+ time: number;
+ value: AnimationValue;
+ interpolation?: AnimationInterpolation;
+ keyframeId?: string;
+}): ElementAnimations | undefined {
+ const coercedValue = coerceAnimationValueForProperty({
+ propertyPath,
+ value,
+ });
+ if (coercedValue === null) {
+ return animations;
+ }
+
+ const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
+ return upsertPathKeyframe({
+ animations,
+ propertyPath,
+ time,
+ value: coercedValue,
+ interpolation,
+ keyframeId,
+ valueKind: propertyDefinition.valueKind,
+ defaultInterpolation: getDefaultInterpolationForProperty({
+ propertyPath,
+ }),
+ numericRange: propertyDefinition.numericRange,
+ });
+}
+
+
export function upsertKeyframe({
channel,
time,
@@ -516,62 +680,13 @@ export function splitAnimationsAtTime({
};
}
-export function upsertElementKeyframe({
- animations,
- propertyPath,
- time,
- value,
- interpolation,
- keyframeId,
-}: {
- animations: ElementAnimations | undefined;
- propertyPath: AnimationPropertyPath;
- time: number;
- value: AnimationValue;
- interpolation?: AnimationInterpolation;
- keyframeId?: string;
-}): ElementAnimations | undefined {
- const coercedValue = coerceAnimationValueForProperty({
- propertyPath,
- value,
- });
- if (coercedValue === null) {
- return animations;
- }
-
- const defaultInterpolation = getDefaultInterpolationForProperty({
- propertyPath,
- });
- const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
- const channel = getChannel({ animations, propertyPath });
- const targetChannel =
- channel && channel.valueKind === propertyDefinition.valueKind
- ? channel
- : createEmptyChannel({ propertyPath });
- const updatedChannel = upsertKeyframe({
- channel: targetChannel,
- time,
- value: coercedValue,
- interpolation: interpolation ?? defaultInterpolation,
- keyframeId,
- });
-
- return (
- setChannel({
- animations,
- propertyPath,
- channel: updatedChannel,
- }) ?? { channels: {} }
- );
-}
-
export function removeElementKeyframe({
animations,
propertyPath,
keyframeId,
}: {
animations: ElementAnimations | undefined;
- propertyPath: AnimationPropertyPath;
+ propertyPath: AnimationPath;
keyframeId: string;
}): ElementAnimations | undefined {
const channel = getChannel({ animations, propertyPath });
@@ -593,7 +708,7 @@ export function retimeElementKeyframe({
time,
}: {
animations: ElementAnimations | undefined;
- propertyPath: AnimationPropertyPath;
+ propertyPath: AnimationPath;
keyframeId: string;
time: number;
}): ElementAnimations | undefined {
diff --git a/apps/web/src/lib/animation/number-channel.ts b/apps/web/src/lib/animation/number-channel.ts
index 67166156..859da863 100644
--- a/apps/web/src/lib/animation/number-channel.ts
+++ b/apps/web/src/lib/animation/number-channel.ts
@@ -2,7 +2,7 @@ import type {
AnimationPropertyPath,
ElementAnimations,
NumberAnimationChannel,
-} from "@/types/animation";
+} from "@/lib/animation/types";
export function getNumberChannelForPath({
animations,
diff --git a/apps/web/src/lib/animation/property-groups.ts b/apps/web/src/lib/animation/property-groups.ts
new file mode 100644
index 00000000..61b4fc4a
--- /dev/null
+++ b/apps/web/src/lib/animation/property-groups.ts
@@ -0,0 +1,39 @@
+import type {
+ AnimationPropertyGroup,
+ AnimationPropertyPath,
+ ElementAnimations,
+} from "@/lib/animation/types";
+import { ANIMATION_PROPERTY_GROUPS } from "@/lib/animation/types";
+import { getKeyframeAtTime } from "./keyframe-query";
+
+export interface GroupKeyframeRef {
+ propertyPath: AnimationPropertyPath;
+ keyframeId: string;
+}
+
+export function getGroupKeyframesAtTime({
+ animations,
+ group,
+ time,
+}: {
+ animations: ElementAnimations | undefined;
+ group: AnimationPropertyGroup;
+ time: number;
+}): GroupKeyframeRef[] {
+ return ANIMATION_PROPERTY_GROUPS[group].flatMap((propertyPath) => {
+ const keyframe = getKeyframeAtTime({ animations, propertyPath, time });
+ return keyframe ? [{ propertyPath, keyframeId: keyframe.id }] : [];
+ });
+}
+
+export function hasGroupKeyframeAtTime({
+ animations,
+ group,
+ time,
+}: {
+ animations: ElementAnimations | undefined;
+ group: AnimationPropertyGroup;
+ time: number;
+}): boolean {
+ return getGroupKeyframesAtTime({ animations, group, time }).length > 0;
+}
diff --git a/apps/web/src/lib/animation/property-registry.ts b/apps/web/src/lib/animation/property-registry.ts
index 361ae3ba..0b28e783 100644
--- a/apps/web/src/lib/animation/property-registry.ts
+++ b/apps/web/src/lib/animation/property-registry.ts
@@ -4,25 +4,35 @@ import type {
AnimationValue,
AnimationValueKind,
DiscreteValue,
-} from "@/types/animation";
-import type { TimelineElement } from "@/types/timeline";
+ VectorValue,
+} from "@/lib/animation/types";
+import { isVectorValue } from "./vector-channel";
+import type { TimelineElement } from "@/lib/timeline";
import { MIN_TRANSFORM_SCALE } from "@/constants/animation-constants";
import {
CORNER_RADIUS_MAX,
CORNER_RADIUS_MIN,
- DEFAULT_TEXT_BACKGROUND,
} from "@/constants/text-constants";
-import { isVisualElement } from "@/lib/timeline/element-utils";
+import {
+ canElementHaveAudio,
+ isVisualElement,
+} from "@/lib/timeline/element-utils";
+import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/lib/timeline/audio-constants";
+import { DEFAULTS } from "@/lib/timeline/defaults";
+import { snapToStep } from "@/utils/math";
-interface NumericRange {
+export interface NumericSpec {
min?: number;
max?: number;
+ step?: number;
}
-interface AnimationPropertyDefinition {
+export type NumericRange = NumericSpec;
+
+export interface AnimationPropertyDefinition {
valueKind: AnimationValueKind;
defaultInterpolation: AnimationInterpolation;
- numericRange?: NumericRange;
+ numericRange?: NumericSpec;
supportsElement: ({ element }: { element: TimelineElement }) => boolean;
getValue: ({ element }: { element: TimelineElement }) => AnimationValue | null;
setValue: ({
@@ -38,58 +48,57 @@ const ANIMATION_PROPERTY_REGISTRY: Record<
AnimationPropertyPath,
AnimationPropertyDefinition
> = {
- "transform.position.x": {
- valueKind: "number",
+ "transform.position": {
+ valueKind: "vector",
defaultInterpolation: "linear",
supportsElement: ({ element }) => isVisualElement(element),
getValue: ({ element }) =>
- isVisualElement(element) ? element.transform.position.x : null,
+ isVisualElement(element) ? element.transform.position : null,
setValue: ({ element, value }) =>
isVisualElement(element)
? {
...element,
transform: {
...element.transform,
- position: { ...element.transform.position, x: value as number },
+ position: value as VectorValue,
},
}
: element,
},
- "transform.position.y": {
+ "transform.scaleX": {
valueKind: "number",
defaultInterpolation: "linear",
+ numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 },
supportsElement: ({ element }) => isVisualElement(element),
getValue: ({ element }) =>
- isVisualElement(element) ? element.transform.position.y : null,
+ isVisualElement(element) ? element.transform.scaleX : null,
setValue: ({ element, value }) =>
isVisualElement(element)
? {
...element,
- transform: {
- ...element.transform,
- position: { ...element.transform.position, y: value as number },
- },
+ transform: { ...element.transform, scaleX: value as number },
}
: element,
},
- "transform.scale": {
+ "transform.scaleY": {
valueKind: "number",
defaultInterpolation: "linear",
- numericRange: { min: MIN_TRANSFORM_SCALE },
+ numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 },
supportsElement: ({ element }) => isVisualElement(element),
getValue: ({ element }) =>
- isVisualElement(element) ? element.transform.scale : null,
+ isVisualElement(element) ? element.transform.scaleY : null,
setValue: ({ element, value }) =>
isVisualElement(element)
? {
...element,
- transform: { ...element.transform, scale: value as number },
+ transform: { ...element.transform, scaleY: value as number },
}
: element,
},
"transform.rotate": {
valueKind: "number",
defaultInterpolation: "linear",
+ numericRange: { min: -360, max: 360, step: 1 },
supportsElement: ({ element }) => isVisualElement(element),
getValue: ({ element }) =>
isVisualElement(element) ? element.transform.rotate : null,
@@ -104,7 +113,7 @@ const ANIMATION_PROPERTY_REGISTRY: Record<
opacity: {
valueKind: "number",
defaultInterpolation: "linear",
- numericRange: { min: 0, max: 1 },
+ numericRange: { min: 0, max: 1, step: 0.01 },
supportsElement: ({ element }) => isVisualElement(element),
getValue: ({ element }) =>
isVisualElement(element) ? element.opacity : null,
@@ -116,12 +125,12 @@ const ANIMATION_PROPERTY_REGISTRY: Record<
volume: {
valueKind: "number",
defaultInterpolation: "linear",
- numericRange: { min: 0, max: 1 },
- supportsElement: ({ element }) => element.type === "audio",
+ numericRange: { min: VOLUME_DB_MIN, max: VOLUME_DB_MAX, step: 0.01 },
+ supportsElement: ({ element }) => canElementHaveAudio(element),
getValue: ({ element }) =>
- element.type === "audio" ? element.volume : null,
+ canElementHaveAudio(element) ? element.volume ?? 0 : null,
setValue: ({ element, value }) =>
- element.type === "audio"
+ canElementHaveAudio(element)
? { ...element, volume: value as number }
: element,
},
@@ -152,11 +161,11 @@ const ANIMATION_PROPERTY_REGISTRY: Record<
"background.paddingX": {
valueKind: "number",
defaultInterpolation: "linear",
- numericRange: { min: 0 },
+ numericRange: { min: 0, step: 1 },
supportsElement: ({ element }) => element.type === "text",
getValue: ({ element }) =>
element.type === "text"
- ? (element.background.paddingX ?? DEFAULT_TEXT_BACKGROUND.paddingX)
+ ? (element.background.paddingX ?? DEFAULTS.text.background.paddingX)
: null,
setValue: ({ element, value }) =>
element.type === "text"
@@ -169,11 +178,11 @@ const ANIMATION_PROPERTY_REGISTRY: Record<
"background.paddingY": {
valueKind: "number",
defaultInterpolation: "linear",
- numericRange: { min: 0 },
+ numericRange: { min: 0, step: 1 },
supportsElement: ({ element }) => element.type === "text",
getValue: ({ element }) =>
element.type === "text"
- ? (element.background.paddingY ?? DEFAULT_TEXT_BACKGROUND.paddingY)
+ ? (element.background.paddingY ?? DEFAULTS.text.background.paddingY)
: null,
setValue: ({ element, value }) =>
element.type === "text"
@@ -186,10 +195,11 @@ const ANIMATION_PROPERTY_REGISTRY: Record<
"background.offsetX": {
valueKind: "number",
defaultInterpolation: "linear",
+ numericRange: { step: 1 },
supportsElement: ({ element }) => element.type === "text",
getValue: ({ element }) =>
element.type === "text"
- ? (element.background.offsetX ?? DEFAULT_TEXT_BACKGROUND.offsetX)
+ ? (element.background.offsetX ?? DEFAULTS.text.background.offsetX)
: null,
setValue: ({ element, value }) =>
element.type === "text"
@@ -202,10 +212,11 @@ const ANIMATION_PROPERTY_REGISTRY: Record<
"background.offsetY": {
valueKind: "number",
defaultInterpolation: "linear",
+ numericRange: { step: 1 },
supportsElement: ({ element }) => element.type === "text",
getValue: ({ element }) =>
element.type === "text"
- ? (element.background.offsetY ?? DEFAULT_TEXT_BACKGROUND.offsetY)
+ ? (element.background.offsetY ?? DEFAULTS.text.background.offsetY)
: null,
setValue: ({ element, value }) =>
element.type === "text"
@@ -218,7 +229,7 @@ const ANIMATION_PROPERTY_REGISTRY: Record<
"background.cornerRadius": {
valueKind: "number",
defaultInterpolation: "linear",
- numericRange: { min: CORNER_RADIUS_MIN, max: CORNER_RADIUS_MAX },
+ numericRange: { min: CORNER_RADIUS_MIN, max: CORNER_RADIUS_MAX, step: 1 },
supportsElement: ({ element }) => element.type === "text",
getValue: ({ element }) =>
element.type === "text"
@@ -304,20 +315,24 @@ export function getDefaultInterpolationForProperty({
return propertyDefinition.defaultInterpolation;
}
-function clampNumericRange({
+function applyNumericSpec({
value,
numericRange,
}: {
value: number;
- numericRange: NumericRange | undefined;
+ numericRange: NumericSpec | undefined;
}): number {
if (!numericRange) {
return value;
}
+ const steppedValue =
+ numericRange.step != null
+ ? snapToStep({ value, step: numericRange.step })
+ : value;
const minValue = numericRange.min ?? Number.NEGATIVE_INFINITY;
const maxValue = numericRange.max ?? Number.POSITIVE_INFINITY;
- return Math.min(maxValue, Math.max(minValue, value));
+ return Math.min(maxValue, Math.max(minValue, steppedValue));
}
export function coerceAnimationValueForProperty({
@@ -334,7 +349,7 @@ export function coerceAnimationValueForProperty({
return null;
}
- return clampNumericRange({
+ return applyNumericSpec({
value,
numericRange: propertyDefinition.numericRange,
});
@@ -344,6 +359,10 @@ export function coerceAnimationValueForProperty({
return typeof value === "string" ? value : null;
}
+ if (propertyDefinition.valueKind === "vector") {
+ return isVectorValue(value) ? value : null;
+ }
+
if (typeof value === "string" || typeof value === "boolean") {
return value as DiscreteValue;
}
diff --git a/apps/web/src/lib/animation/resolve.ts b/apps/web/src/lib/animation/resolve.ts
index 147be1b3..8709627d 100644
--- a/apps/web/src/lib/animation/resolve.ts
+++ b/apps/web/src/lib/animation/resolve.ts
@@ -1,8 +1,16 @@
-import type { AnimationPropertyPath, ElementAnimations } from "@/types/animation";
-import type { Transform } from "@/types/timeline";
-import { getColorValueAtTime, getNumberChannelValueAtTime } from "./interpolation";
+import type {
+ AnimationPropertyPath,
+ ElementAnimations,
+} from "@/lib/animation/types";
+import type { Transform } from "@/lib/rendering";
+import {
+ getColorValueAtTime,
+ getNumberChannelValueAtTime,
+ getVectorChannelValueAtTime,
+} from "./interpolation";
import { getColorChannelForPath } from "./color-channel";
import { getNumberChannelForPath } from "./number-channel";
+import { getVectorChannelForPath } from "./vector-channel";
export function getElementLocalTime({
timelineTime,
@@ -36,31 +44,29 @@ export function resolveTransformAtTime({
}): Transform {
const safeLocalTime = Math.max(0, localTime);
return {
- position: {
- x: getNumberChannelValueAtTime({
- channel: getNumberChannelForPath({
- animations,
- propertyPath: "transform.position.x",
- }),
- time: safeLocalTime,
- fallbackValue: baseTransform.position.x,
+ position: getVectorChannelValueAtTime({
+ channel: getVectorChannelForPath({
+ animations,
+ propertyPath: "transform.position",
}),
- y: getNumberChannelValueAtTime({
- channel: getNumberChannelForPath({
- animations,
- propertyPath: "transform.position.y",
- }),
- time: safeLocalTime,
- fallbackValue: baseTransform.position.y,
+ time: safeLocalTime,
+ fallbackValue: baseTransform.position,
+ }),
+ scaleX: getNumberChannelValueAtTime({
+ channel: getNumberChannelForPath({
+ animations,
+ propertyPath: "transform.scaleX",
}),
- },
- scale: getNumberChannelValueAtTime({
+ time: safeLocalTime,
+ fallbackValue: baseTransform.scaleX,
+ }),
+ scaleY: getNumberChannelValueAtTime({
channel: getNumberChannelForPath({
animations,
- propertyPath: "transform.scale",
+ propertyPath: "transform.scaleY",
}),
time: safeLocalTime,
- fallbackValue: baseTransform.scale,
+ fallbackValue: baseTransform.scaleY,
}),
rotate: getNumberChannelValueAtTime({
channel: getNumberChannelForPath({
@@ -127,22 +133,3 @@ export function resolveColorAtTime({
fallbackValue: baseColor,
});
}
-
-export function resolveVolumeAtTime({
- baseVolume,
- animations,
- localTime,
-}: {
- baseVolume: number;
- animations: ElementAnimations | undefined;
- localTime: number;
-}): number {
- return getNumberChannelValueAtTime({
- channel: getNumberChannelForPath({
- animations,
- propertyPath: "volume",
- }),
- time: Math.max(0, localTime),
- fallbackValue: baseVolume,
- });
-}
diff --git a/apps/web/src/lib/animation/target-resolver.ts b/apps/web/src/lib/animation/target-resolver.ts
new file mode 100644
index 00000000..e801bc68
--- /dev/null
+++ b/apps/web/src/lib/animation/target-resolver.ts
@@ -0,0 +1,279 @@
+import type {
+ AnimationInterpolation,
+ AnimationPath,
+ AnimationValue,
+ AnimationValueKind,
+} from "@/lib/animation/types";
+import {
+ parseEffectParamPath,
+ isEffectParamPath,
+} from "@/lib/animation/effect-param-channel";
+import {
+ isGraphicParamPath,
+ parseGraphicParamPath,
+} from "@/lib/animation/graphic-param-channel";
+import type { ParamDefinition } from "@/lib/params";
+import { effectsRegistry, registerDefaultEffects } from "@/lib/effects";
+import { getGraphicDefinition } from "@/lib/graphics";
+import type { TimelineElement } from "@/lib/timeline";
+import { isVisualElement } from "@/lib/timeline/element-utils";
+import { snapToStep } from "@/utils/math";
+import {
+ coerceAnimationValueForProperty,
+ getAnimationPropertyDefinition,
+ getElementBaseValueForProperty,
+ isAnimationPropertyPath,
+ type NumericSpec,
+ withElementBaseValueForProperty,
+} from "./property-registry";
+
+export interface AnimationPathDescriptor {
+ valueKind: AnimationValueKind;
+ defaultInterpolation: AnimationInterpolation;
+ numericRange?: NumericSpec;
+ getBaseValue(): AnimationValue | null;
+ setBaseValue(value: AnimationValue): TimelineElement;
+}
+
+export function getParamValueKind({
+ param,
+}: {
+ param: ParamDefinition;
+}): AnimationValueKind {
+ if (param.type === "number") {
+ return "number";
+ }
+
+ if (param.type === "color") {
+ return "color";
+ }
+
+ return "discrete";
+}
+
+export function getParamDefaultInterpolation({
+ param,
+}: {
+ param: ParamDefinition;
+}): AnimationInterpolation {
+ return param.type === "number" || param.type === "color" ? "linear" : "hold";
+}
+
+function getParamNumericRange({
+ param,
+}: {
+ param: ParamDefinition;
+}): NumericSpec | undefined {
+ if (param.type !== "number") {
+ return undefined;
+ }
+
+ return {
+ min: param.min,
+ max: param.max,
+ step: param.step,
+ };
+}
+
+function coerceParamValue({
+ param,
+ value,
+}: {
+ param: ParamDefinition;
+ value: AnimationValue;
+}): number | string | boolean | null {
+ if (param.type === "number") {
+ if (typeof value !== "number" || Number.isNaN(value)) {
+ return null;
+ }
+
+ const steppedValue = snapToStep({ value, step: param.step });
+ const minValue = param.min;
+ const maxValue = param.max ?? Number.POSITIVE_INFINITY;
+ return Math.min(maxValue, Math.max(minValue, steppedValue));
+ }
+
+ if (param.type === "color") {
+ return typeof value === "string" ? value : null;
+ }
+
+ if (param.type === "boolean") {
+ return typeof value === "boolean" ? value : null;
+ }
+
+ if (typeof value !== "string") {
+ return null;
+ }
+
+ return param.options.some((option) => option.value === value) ? value : null;
+}
+
+function buildGraphicParamDescriptor({
+ element,
+ paramKey,
+}: {
+ element: TimelineElement;
+ paramKey: string;
+}): AnimationPathDescriptor | null {
+ if (element.type !== "graphic") {
+ return null;
+ }
+
+ const definition = getGraphicDefinition({
+ definitionId: element.definitionId,
+ });
+ const param = definition.params.find((candidate) => candidate.key === paramKey);
+ if (!param) {
+ return null;
+ }
+
+ return {
+ valueKind: getParamValueKind({ param }),
+ defaultInterpolation: getParamDefaultInterpolation({ param }),
+ numericRange: getParamNumericRange({ param }),
+ getBaseValue: () => element.params[param.key] ?? param.default,
+ setBaseValue: (value) => {
+ const coercedValue = coerceParamValue({ param, value });
+ if (coercedValue === null) {
+ return element;
+ }
+
+ return {
+ ...element,
+ params: {
+ ...element.params,
+ [param.key]: coercedValue,
+ },
+ };
+ },
+ };
+}
+
+function buildEffectParamDescriptor({
+ element,
+ effectId,
+ paramKey,
+}: {
+ element: TimelineElement;
+ effectId: string;
+ paramKey: string;
+}): AnimationPathDescriptor | null {
+ if (!isVisualElement(element)) {
+ return null;
+ }
+
+ const effect = element.effects?.find((candidate) => candidate.id === effectId);
+ if (!effect) {
+ return null;
+ }
+
+ registerDefaultEffects();
+ const definition = effectsRegistry.get(effect.type);
+ const param = definition.params.find((candidate) => candidate.key === paramKey);
+ if (!param) {
+ return null;
+ }
+
+ return {
+ valueKind: getParamValueKind({ param }),
+ defaultInterpolation: getParamDefaultInterpolation({ param }),
+ numericRange: getParamNumericRange({ param }),
+ getBaseValue: () => effect.params[param.key] ?? param.default,
+ setBaseValue: (value) => {
+ const coercedValue = coerceParamValue({ param, value });
+ if (coercedValue === null) {
+ return element;
+ }
+
+ return {
+ ...element,
+ effects:
+ element.effects?.map((candidate) =>
+ candidate.id !== effectId
+ ? candidate
+ : {
+ ...candidate,
+ params: {
+ ...candidate.params,
+ [param.key]: coercedValue,
+ },
+ },
+ ) ?? element.effects,
+ };
+ },
+ };
+}
+
+export function isAnimationPath({
+ propertyPath,
+}: {
+ propertyPath: string;
+}): propertyPath is AnimationPath {
+ return (
+ isAnimationPropertyPath({ propertyPath }) ||
+ isGraphicParamPath({ propertyPath }) ||
+ isEffectParamPath({ propertyPath })
+ );
+}
+
+export function resolveAnimationTarget({
+ element,
+ path,
+}: {
+ element: TimelineElement;
+ path: AnimationPath;
+}): AnimationPathDescriptor | null {
+ if (isAnimationPropertyPath({ propertyPath: path })) {
+ const propertyDefinition = getAnimationPropertyDefinition({
+ propertyPath: path,
+ });
+ if (!propertyDefinition.supportsElement({ element })) {
+ return null;
+ }
+
+ return {
+ valueKind: propertyDefinition.valueKind,
+ defaultInterpolation: propertyDefinition.defaultInterpolation,
+ numericRange: propertyDefinition.numericRange,
+ getBaseValue: () =>
+ getElementBaseValueForProperty({
+ element,
+ propertyPath: path,
+ }),
+ setBaseValue: (value) => {
+ const coercedValue = coerceAnimationValueForProperty({
+ propertyPath: path,
+ value,
+ });
+ if (coercedValue === null) {
+ return element;
+ }
+
+ return withElementBaseValueForProperty({
+ element,
+ propertyPath: path,
+ value: coercedValue,
+ });
+ },
+ };
+ }
+
+ const graphicParamTarget = parseGraphicParamPath({ propertyPath: path });
+ if (graphicParamTarget) {
+ return buildGraphicParamDescriptor({
+ element,
+ paramKey: graphicParamTarget.paramKey,
+ });
+ }
+
+ const effectParamTarget = parseEffectParamPath({ propertyPath: path });
+ if (effectParamTarget) {
+ return buildEffectParamDescriptor({
+ element,
+ effectId: effectParamTarget.effectId,
+ paramKey: effectParamTarget.paramKey,
+ });
+ }
+
+ return null;
+}
diff --git a/apps/web/src/lib/animation/types.ts b/apps/web/src/lib/animation/types.ts
new file mode 100644
index 00000000..913911d0
--- /dev/null
+++ b/apps/web/src/lib/animation/types.ts
@@ -0,0 +1,119 @@
+export const ANIMATION_PROPERTY_PATHS = [
+ "transform.position",
+ "transform.scaleX",
+ "transform.scaleY",
+ "transform.rotate",
+ "opacity",
+ "volume",
+ "color",
+ "background.color",
+ "background.paddingX",
+ "background.paddingY",
+ "background.offsetX",
+ "background.offsetY",
+ "background.cornerRadius",
+] as const;
+
+export type AnimationPropertyPath = (typeof ANIMATION_PROPERTY_PATHS)[number];
+export type GraphicParamPath = `params.${string}`;
+export type EffectParamPath = `effects.${string}.params.${string}`;
+export type AnimationPath =
+ | AnimationPropertyPath
+ | GraphicParamPath
+ | EffectParamPath;
+
+export const ANIMATION_PROPERTY_GROUPS = {
+ "transform.scale": ["transform.scaleX", "transform.scaleY"],
+} as const satisfies Record<string, ReadonlyArray<AnimationPropertyPath>>;
+
+export type AnimationPropertyGroup = keyof typeof ANIMATION_PROPERTY_GROUPS;
+
+export type VectorValue = { x: number; y: number };
+
+export type AnimationValueKind = "number" | "color" | "discrete" | "vector";
+export type DiscreteValue = boolean | string;
+export type AnimationValue = number | string | boolean | VectorValue;
+
+export type ContinuousKeyframeInterpolation = "linear" | "hold";
+export type DiscreteKeyframeInterpolation = "hold";
+export type AnimationInterpolation =
+ | ContinuousKeyframeInterpolation
+ | DiscreteKeyframeInterpolation;
+
+interface BaseAnimationKeyframe<
+ TValue extends AnimationValue,
+ TInterpolation extends AnimationInterpolation,
+> {
+ id: string;
+ time: number; // relative to element start time
+ value: TValue;
+ interpolation: TInterpolation;
+}
+
+export interface NumberKeyframe
+ extends BaseAnimationKeyframe<number, ContinuousKeyframeInterpolation> {}
+
+export interface ColorKeyframe
+ extends BaseAnimationKeyframe<string, ContinuousKeyframeInterpolation> {}
+
+export interface DiscreteKeyframe
+ extends BaseAnimationKeyframe<DiscreteValue, DiscreteKeyframeInterpolation> {}
+
+export interface VectorKeyframe
+ extends BaseAnimationKeyframe<VectorValue, ContinuousKeyframeInterpolation> {}
+
+export type AnimationKeyframe =
+ | NumberKeyframe
+ | ColorKeyframe
+ | DiscreteKeyframe
+ | VectorKeyframe;
+
+interface BaseAnimationChannel<
+ TValueKind extends AnimationValueKind,
+ TKeyframe extends AnimationKeyframe,
+> {
+ valueKind: TValueKind;
+ keyframes: TKeyframe[];
+}
+
+export interface NumberAnimationChannel
+ extends BaseAnimationChannel<"number", NumberKeyframe> {}
+
+export interface ColorAnimationChannel
+ extends BaseAnimationChannel<"color", ColorKeyframe> {}
+
+export interface DiscreteAnimationChannel
+ extends BaseAnimationChannel<"discrete", DiscreteKeyframe> {}
+
+export interface VectorAnimationChannel
+ extends BaseAnimationChannel<"vector", VectorKeyframe> {}
+
+export type AnimationChannel =
+ | NumberAnimationChannel
+ | ColorAnimationChannel
+ | DiscreteAnimationChannel
+ | VectorAnimationChannel;
+
+export type ElementAnimationChannelMap = Record<
+ string,
+ AnimationChannel | undefined
+>;
+
+export interface ElementAnimations {
+ channels: ElementAnimationChannelMap;
+}
+
+export interface ElementKeyframe {
+ propertyPath: AnimationPath;
+ id: string;
+ time: number;
+ value: AnimationValue;
+ interpolation: AnimationInterpolation;
+}
+
+export interface SelectedKeyframeRef {
+ trackId: string;
+ elementId: string;
+ propertyPath: AnimationPath;
+ keyframeId: string;
+}
diff --git a/apps/web/src/lib/animation/vector-channel.ts b/apps/web/src/lib/animation/vector-channel.ts
new file mode 100644
index 00000000..50c62e40
--- /dev/null
+++ b/apps/web/src/lib/animation/vector-channel.ts
@@ -0,0 +1,72 @@
+import type {
+ AnimationPropertyPath,
+ ElementAnimations,
+ VectorAnimationChannel,
+ VectorValue,
+} from "@/lib/animation/types";
+
+export function isVectorValue(value: unknown): value is VectorValue {
+ return (
+ typeof value === "object" &&
+ value !== null &&
+ "x" in value &&
+ "y" in value &&
+ typeof (value as VectorValue).x === "number" &&
+ typeof (value as VectorValue).y === "number"
+ );
+}
+
+export function getVectorChannelForPath({
+ animations,
+ propertyPath,
+}: {
+ animations: ElementAnimations | undefined;
+ propertyPath: AnimationPropertyPath;
+}): VectorAnimationChannel | undefined {
+ const channel = animations?.channels[propertyPath];
+ if (!channel || channel.valueKind !== "vector") {
+ return undefined;
+ }
+ return channel;
+}
+
+export function getVectorChannelValueAtTime({
+ channel,
+ time,
+ fallbackValue,
+}: {
+ channel: VectorAnimationChannel | undefined;
+ time: number;
+ fallbackValue: VectorValue;
+}): VectorValue {
+ if (!channel || channel.keyframes.length === 0) {
+ return fallbackValue;
+ }
+
+ const keyframes = [...channel.keyframes].sort((a, b) => a.time - b.time);
+ const first = keyframes[0];
+ const last = keyframes[keyframes.length - 1];
+
+ if (!first || !last) return fallbackValue;
+ if (time <= first.time) return first.value;
+ if (time >= last.time) return last.value;
+
+ for (let i = 0; i < keyframes.length - 1; i++) {
+ const left = keyframes[i];
+ const right = keyframes[i + 1];
+ if (time < left.time || time > right.time) continue;
+
+ if (left.interpolation === "hold") return left.value;
+
+ const span = right.time - left.time;
+ if (span === 0) return right.value;
+
+ const t = (time - left.time) / span;
+ return {
+ x: left.value.x + (right.value.x - left.value.x) * t,
+ y: left.value.y + (right.value.y - left.value.y) * t,
+ };
+ }
+
+ return last.value;
+}