From 8db3bead13441a5882d8689dfc6a9f09c82375d9 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Wed, 25 Mar 2026 16:47:22 +0100 Subject: feat: masks, properties refactor, shaders, storage migrations, and more --- .../__tests__/transform-keyframes.test.ts | 313 --------------------- apps/web/src/lib/animation/color-channel.ts | 2 +- apps/web/src/lib/animation/effect-param-channel.ts | 55 +++- .../web/src/lib/animation/graphic-param-channel.ts | 79 ++++++ apps/web/src/lib/animation/index.ts | 32 ++- apps/web/src/lib/animation/interpolation.ts | 71 ++++- apps/web/src/lib/animation/keyframe-query.ts | 17 +- apps/web/src/lib/animation/keyframes.ts | 231 +++++++++++---- apps/web/src/lib/animation/number-channel.ts | 2 +- apps/web/src/lib/animation/property-groups.ts | 39 +++ apps/web/src/lib/animation/property-registry.ts | 93 +++--- apps/web/src/lib/animation/resolve.ts | 69 ++--- apps/web/src/lib/animation/target-resolver.ts | 279 ++++++++++++++++++ apps/web/src/lib/animation/types.ts | 119 ++++++++ apps/web/src/lib/animation/vector-channel.ts | 72 +++++ 15 files changed, 992 insertions(+), 481 deletions(-) delete mode 100644 apps/web/src/lib/animation/__tests__/transform-keyframes.test.ts create mode 100644 apps/web/src/lib/animation/graphic-param-channel.ts create mode 100644 apps/web/src/lib/animation/property-groups.ts create mode 100644 apps/web/src/lib/animation/target-resolver.ts create mode 100644 apps/web/src/lib/animation/types.ts create mode 100644 apps/web/src/lib/animation/vector-channel.ts (limited to 'apps/web/src/lib/animation') 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({ } as TChannel; } -function evaluateChannelValueAtTime({ +function evaluateChannelValueAtTime< + TKeyframe extends { time: number; value: TValue }, + TValue, +>({ keyframes, time, fallbackValue, @@ -214,7 +210,11 @@ function evaluateChannelValueAtTime { + 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>; + +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 {} + +export interface ColorKeyframe + extends BaseAnimationKeyframe {} + +export interface DiscreteKeyframe + extends BaseAnimationKeyframe {} + +export interface VectorKeyframe + extends BaseAnimationKeyframe {} + +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; +} -- cgit v1.3.1