diff options
Diffstat (limited to 'apps/web/src/lib/animation')
| -rw-r--r-- | apps/web/src/lib/animation/__tests__/transform-keyframes.test.ts | 313 | ||||
| -rw-r--r-- | apps/web/src/lib/animation/index.ts | 39 | ||||
| -rw-r--r-- | apps/web/src/lib/animation/interpolation.ts | 365 | ||||
| -rw-r--r-- | apps/web/src/lib/animation/keyframe-query.ts | 67 | ||||
| -rw-r--r-- | apps/web/src/lib/animation/keyframes.ts | 593 | ||||
| -rw-r--r-- | apps/web/src/lib/animation/number-channel.ts | 20 | ||||
| -rw-r--r-- | apps/web/src/lib/animation/property-registry.ts | 230 | ||||
| -rw-r--r-- | apps/web/src/lib/animation/resolve.ts | 111 |
8 files changed, 1738 insertions, 0 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 new file mode 100644 index 00000000..629bd0d6 --- /dev/null +++ b/apps/web/src/lib/animation/__tests__/transform-keyframes.test.ts @@ -0,0 +1,313 @@ +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/index.ts b/apps/web/src/lib/animation/index.ts new file mode 100644 index 00000000..c41c1a7e --- /dev/null +++ b/apps/web/src/lib/animation/index.ts @@ -0,0 +1,39 @@ +export { + getChannelValueAtTime, + getNumberChannelValueAtTime, + normalizeChannel, +} from "./interpolation"; + +export { + clampAnimationsToDuration, + cloneAnimations, + getChannel, + removeElementKeyframe, + retimeElementKeyframe, + setChannel, + splitAnimationsAtTime, + upsertElementKeyframe, +} from "./keyframes"; + +export { + getElementLocalTime, + resolveOpacityAtTime, + resolveTransformAtTime, + resolveVolumeAtTime, +} from "./resolve"; + +export { + coerceAnimationValueForProperty, + getAnimationPropertyDefinition, + getDefaultInterpolationForProperty, + getElementBaseValueForProperty, + isAnimationPropertyPath, + supportsAnimationProperty, + withElementBaseValueForProperty, +} from "./property-registry"; + +export { + getElementKeyframes, + getKeyframeAtTime, + hasKeyframesForPath, +} from "./keyframe-query"; diff --git a/apps/web/src/lib/animation/interpolation.ts b/apps/web/src/lib/animation/interpolation.ts new file mode 100644 index 00000000..a8f60643 --- /dev/null +++ b/apps/web/src/lib/animation/interpolation.ts @@ -0,0 +1,365 @@ +import type { + AnimationChannel, + AnimationValue, + ColorAnimationChannel, + DiscreteValue, + DiscreteAnimationChannel, + NumberAnimationChannel, +} from "@/types/animation"; +import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants"; + +function byTimeAscending({ + leftTime, + rightTime, +}: { + leftTime: number; + rightTime: number; +}): number { + return leftTime - rightTime; +} + +function isWithinTimePair({ + time, + leftTime, + rightTime, +}: { + time: number; + leftTime: number; + rightTime: number; +}): boolean { + return ( + time >= leftTime - TIME_EPSILON_SECONDS && + time <= rightTime + TIME_EPSILON_SECONDS + ); +} + +function clamp01({ value }: { value: number }): number { + return Math.max(0, Math.min(1, value)); +} + +function parseHexChannel({ hex }: { hex: string }): number | null { + const value = Number.parseInt(hex, 16); + return Number.isNaN(value) ? null : value; +} + +function parseHexColor({ + color, +}: { + color: string; +}): { red: number; green: number; blue: number; alpha: number } | null { + const trimmed = color.trim(); + if (!trimmed.startsWith("#")) { + return null; + } + + const rawHex = trimmed.slice(1); + if (rawHex.length === 3 || rawHex.length === 4) { + const [redHex, greenHex, blueHex, alphaHex = "f"] = rawHex.split(""); + const red = parseHexChannel({ hex: `${redHex}${redHex}` }); + 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 + ) { + return null; + } + + return { red, green, blue, alpha: alpha / 255 }; + } + + if (rawHex.length === 6 || rawHex.length === 8) { + const red = parseHexChannel({ hex: rawHex.slice(0, 2) }); + const green = parseHexChannel({ hex: rawHex.slice(2, 4) }); + 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 + ) { + return null; + } + + return { red, green, blue, alpha: alpha / 255 }; + } + + return null; +} + +function formatRgbaColor({ + red, + green, + blue, + alpha, +}: { + red: number; + green: number; + blue: number; + alpha: number; +}): string { + const roundedRed = Math.round(red); + const roundedGreen = Math.round(green); + const roundedBlue = Math.round(blue); + const roundedAlpha = Math.round(clamp01({ value: alpha }) * 1000) / 1000; + return `rgba(${roundedRed}, ${roundedGreen}, ${roundedBlue}, ${roundedAlpha})`; +} + +function lerpNumber({ + leftValue, + rightValue, + progress, +}: { + leftValue: number; + rightValue: number; + progress: number; +}): number { + return leftValue + (rightValue - leftValue) * progress; +} + +function interpolateColor({ + leftColor, + rightColor, + progress, +}: { + leftColor: string; + rightColor: string; + progress: number; +}): string { + const leftParsed = parseHexColor({ color: leftColor }); + const rightParsed = parseHexColor({ color: rightColor }); + if (!leftParsed || !rightParsed) { + return progress >= 1 ? rightColor : leftColor; + } + + return formatRgbaColor({ + red: lerpNumber({ + leftValue: leftParsed.red, + rightValue: rightParsed.red, + progress, + }), + green: lerpNumber({ + leftValue: leftParsed.green, + rightValue: rightParsed.green, + progress, + }), + blue: lerpNumber({ + leftValue: leftParsed.blue, + rightValue: rightParsed.blue, + progress, + }), + alpha: lerpNumber({ + leftValue: leftParsed.alpha, + rightValue: rightParsed.alpha, + progress, + }), + }); +} + +export function normalizeChannel<TChannel extends AnimationChannel>({ + channel, +}: { + channel: TChannel; +}): TChannel { + return { + ...channel, + keyframes: [...channel.keyframes].sort((leftKeyframe, rightKeyframe) => + byTimeAscending({ + leftTime: leftKeyframe.time, + rightTime: rightKeyframe.time, + }), + ), + } as TChannel; +} + +function evaluateChannelValueAtTime<TKeyframe extends { time: number; value: TValue }, TValue>({ + keyframes, + time, + fallbackValue, + getInterpolatedValue, +}: { + keyframes: TKeyframe[] | undefined; + time: number; + fallbackValue: TValue; + getInterpolatedValue: ({ + leftKeyframe, + rightKeyframe, + progress, + }: { + leftKeyframe: TKeyframe; + rightKeyframe: TKeyframe; + progress: number; + }) => TValue; +}): TValue { + if (!keyframes || keyframes.length === 0) { + return fallbackValue; + } + + const firstKeyframe = keyframes[0]; + const lastKeyframe = keyframes[keyframes.length - 1]; + if (!firstKeyframe || !lastKeyframe) { + return fallbackValue; + } + + if (time <= firstKeyframe.time + TIME_EPSILON_SECONDS) { + return firstKeyframe.value; + } + + if (time >= lastKeyframe.time - TIME_EPSILON_SECONDS) { + return lastKeyframe.value; + } + + for (let keyframeIndex = 0; keyframeIndex < keyframes.length - 1; keyframeIndex++) { + const leftKeyframe = keyframes[keyframeIndex]; + const rightKeyframe = keyframes[keyframeIndex + 1]; + const isBetweenPair = isWithinTimePair({ + time, + leftTime: leftKeyframe.time, + rightTime: rightKeyframe.time, + }); + if (!isBetweenPair) { + continue; + } + + const span = rightKeyframe.time - leftKeyframe.time; + if (Math.abs(span) <= TIME_EPSILON_SECONDS) { + return rightKeyframe.value; + } + + const progress = clamp01({ + value: (time - leftKeyframe.time) / span, + }); + + return getInterpolatedValue({ + leftKeyframe, + rightKeyframe, + progress, + }); + } + + return lastKeyframe.value; +} + +export function getNumberChannelValueAtTime({ + channel, + time, + fallbackValue, +}: { + channel: NumberAnimationChannel | undefined; + time: number; + fallbackValue: number; +}): number { + return evaluateChannelValueAtTime({ + keyframes: channel?.keyframes, + time, + fallbackValue, + getInterpolatedValue: ({ leftKeyframe, rightKeyframe, progress }) => { + if (leftKeyframe.interpolation === "hold") { + return leftKeyframe.value; + } + + return lerpNumber({ + leftValue: leftKeyframe.value, + rightValue: rightKeyframe.value, + progress, + }); + }, + }); +} + +function getColorValueAtTime({ + channel, + time, + fallbackValue, +}: { + channel: ColorAnimationChannel | undefined; + time: number; + fallbackValue: string; +}): string { + return evaluateChannelValueAtTime({ + keyframes: channel?.keyframes, + time, + fallbackValue, + getInterpolatedValue: ({ leftKeyframe, rightKeyframe, progress }) => { + if (leftKeyframe.interpolation === "hold") { + return leftKeyframe.value; + } + + return interpolateColor({ + leftColor: leftKeyframe.value, + rightColor: rightKeyframe.value, + progress, + }); + }, + }); +} + +function getDiscreteValueAtTime({ + channel, + time, + fallbackValue, +}: { + channel: DiscreteAnimationChannel | undefined; + time: number; + fallbackValue: DiscreteValue; +}): DiscreteValue { + return evaluateChannelValueAtTime({ + keyframes: channel?.keyframes, + time, + fallbackValue, + getInterpolatedValue: ({ leftKeyframe }) => leftKeyframe.value, + }); +} + +export function getChannelValueAtTime({ + channel, + time, + fallbackValue, +}: { + channel: AnimationChannel | undefined; + time: number; + fallbackValue: AnimationValue; +}): AnimationValue { + if (!channel || channel.keyframes.length === 0) { + return fallbackValue; + } + + if (channel.valueKind === "number") { + if (typeof fallbackValue !== "number") { + return fallbackValue; + } + + return getNumberChannelValueAtTime({ + channel, + time, + fallbackValue, + }); + } + + if (channel.valueKind === "color") { + if (typeof fallbackValue !== "string") { + return fallbackValue; + } + + return getColorValueAtTime({ + channel, + time, + fallbackValue, + }); + } + + if (typeof fallbackValue !== "string" && typeof fallbackValue !== "boolean") { + return fallbackValue; + } + + return getDiscreteValueAtTime({ + channel, + time, + fallbackValue, + }); +} diff --git a/apps/web/src/lib/animation/keyframe-query.ts b/apps/web/src/lib/animation/keyframe-query.ts new file mode 100644 index 00000000..f3288e7a --- /dev/null +++ b/apps/web/src/lib/animation/keyframe-query.ts @@ -0,0 +1,67 @@ +import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants"; +import type { + AnimationPropertyPath, + ElementAnimations, + ElementKeyframe, +} from "@/types/animation"; + +export function getElementKeyframes({ + animations, +}: { + animations: ElementAnimations | undefined; +}): ElementKeyframe[] { + if (!animations) { + return []; + } + + return Object.entries(animations.channels).flatMap( + ([propertyPath, channel]) => { + if (!channel || channel.keyframes.length === 0) { + return []; + } + + return channel.keyframes.map((keyframe) => ({ + propertyPath: propertyPath as AnimationPropertyPath, + id: keyframe.id, + time: keyframe.time, + value: keyframe.value, + interpolation: keyframe.interpolation, + })); + }, + ); +} + +export function hasKeyframesForPath({ + animations, + propertyPath, +}: { + animations: ElementAnimations | undefined; + propertyPath: AnimationPropertyPath; +}): boolean { + const channel = animations?.channels[propertyPath]; + return Boolean(channel && channel.keyframes.length > 0); +} + +export function getKeyframeAtTime({ + animations, + propertyPath, + time, +}: { + animations: ElementAnimations | undefined; + propertyPath: AnimationPropertyPath; + time: number; +}): ElementKeyframe | null { + const channel = animations?.channels[propertyPath]; + if (!channel || channel.keyframes.length === 0) return null; + const keyframe = channel.keyframes.find( + (kf) => Math.abs(kf.time - time) <= TIME_EPSILON_SECONDS, + ); + if (!keyframe) return null; + return { + propertyPath, + id: keyframe.id, + time: keyframe.time, + value: keyframe.value, + interpolation: keyframe.interpolation, + }; +} diff --git a/apps/web/src/lib/animation/keyframes.ts b/apps/web/src/lib/animation/keyframes.ts new file mode 100644 index 00000000..56f156a4 --- /dev/null +++ b/apps/web/src/lib/animation/keyframes.ts @@ -0,0 +1,593 @@ +import type { + AnimationChannel, + AnimationInterpolation, + AnimationKeyframe, + AnimationPropertyPath, + AnimationValue, + AnimationValueKind, + ColorAnimationChannel, + DiscreteAnimationChannel, + ElementAnimations, + NumberAnimationChannel, +} from "@/types/animation"; +import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants"; +import { generateUUID } from "@/utils/id"; +import { getChannelValueAtTime, normalizeChannel } from "./interpolation"; +import { + coerceAnimationValueForProperty, + getDefaultInterpolationForProperty, + getAnimationPropertyDefinition, + isAnimationPropertyPath, +} from "./property-registry"; + +function isNearlySameTime({ + leftTime, + rightTime, +}: { + leftTime: number; + rightTime: number; +}): boolean { + return Math.abs(leftTime - rightTime) <= TIME_EPSILON_SECONDS; +} + +function toAnimation({ + channelEntries, +}: { + channelEntries: Array<[string, AnimationChannel]>; +}): ElementAnimations | undefined { + if (channelEntries.length === 0) { + return undefined; + } + + return { + channels: Object.fromEntries(channelEntries), + }; +} + +function toChannel({ + keyframes, + valueKind, +}: { + keyframes: AnimationKeyframe[]; + valueKind: AnimationValueKind; +}): AnimationChannel { + return normalizeChannel({ + channel: { + valueKind, + keyframes, + } as AnimationChannel, + }); +} + +export function getChannel({ + animations, + propertyPath, +}: { + animations: ElementAnimations | undefined; + propertyPath: string; +}): AnimationChannel | undefined { + return animations?.channels[propertyPath]; +} + +function getInterpolationForChannel({ + channel, + interpolation, +}: { + channel: AnimationChannel; + interpolation: AnimationInterpolation | undefined; +}): AnimationInterpolation { + if (channel.valueKind === "discrete") { + return "hold"; + } + + if (interpolation === "linear" || interpolation === "hold") { + return interpolation; + } + + return "linear"; +} + +function buildKeyframe({ + channel, + id, + time, + value, + interpolation, +}: { + channel: AnimationChannel; + id: string; + time: number; + value: AnimationValue; + interpolation: AnimationInterpolation; +}): AnimationKeyframe { + if (channel.valueKind === "number") { + if (typeof value !== "number") { + throw new Error("Number channel keyframes require numeric values"); + } + + return { + id, + time, + value, + interpolation: interpolation === "hold" ? "hold" : "linear", + }; + } + + if (channel.valueKind === "color") { + if (typeof value !== "string") { + throw new Error("Color channel keyframes require string 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"); + } + + return { + id, + time, + value, + interpolation: "hold", + }; +} + +function createEmptyChannel({ + propertyPath, +}: { + propertyPath: AnimationPropertyPath; +}): AnimationChannel { + const propertyDefinition = getAnimationPropertyDefinition({ propertyPath }); + if (propertyDefinition.valueKind === "number") { + return { valueKind: "number", keyframes: [] } satisfies NumberAnimationChannel; + } + + if (propertyDefinition.valueKind === "color") { + return { valueKind: "color", keyframes: [] } satisfies ColorAnimationChannel; + } + + return { valueKind: "discrete", keyframes: [] } satisfies DiscreteAnimationChannel; +} + +export function upsertKeyframe({ + channel, + time, + value, + interpolation, + keyframeId, +}: { + channel: AnimationChannel | undefined; + time: number; + value: AnimationValue; + interpolation?: AnimationInterpolation; + keyframeId?: string; +}): AnimationChannel | undefined { + if (!channel) { + return undefined; + } + + const currentKeyframes = channel.keyframes; + const nextKeyframes = [...currentKeyframes]; + const nextInterpolation = getInterpolationForChannel({ + channel, + interpolation, + }); + if (keyframeId) { + const keyframeByIdIndex = nextKeyframes.findIndex( + (keyframe) => keyframe.id === keyframeId, + ); + if (keyframeByIdIndex >= 0) { + nextKeyframes[keyframeByIdIndex] = buildKeyframe({ + channel, + id: nextKeyframes[keyframeByIdIndex].id, + time, + value, + interpolation: nextInterpolation, + }); + return toChannel({ + keyframes: nextKeyframes, + valueKind: channel.valueKind, + }); + } + } + + const keyframeAtTimeIndex = nextKeyframes.findIndex((keyframe) => + isNearlySameTime({ leftTime: keyframe.time, rightTime: time }), + ); + if (keyframeAtTimeIndex >= 0) { + nextKeyframes[keyframeAtTimeIndex] = buildKeyframe({ + channel, + id: nextKeyframes[keyframeAtTimeIndex].id, + time: nextKeyframes[keyframeAtTimeIndex].time, + value, + interpolation: nextInterpolation, + }); + return toChannel({ + keyframes: nextKeyframes, + valueKind: channel.valueKind, + }); + } + + nextKeyframes.push( + buildKeyframe({ + channel, + id: keyframeId ?? generateUUID(), + time, + value, + interpolation: nextInterpolation, + }), + ); + + return toChannel({ + keyframes: nextKeyframes, + valueKind: channel.valueKind, + }); +} + +export function removeKeyframe({ + channel, + keyframeId, +}: { + channel: AnimationChannel | undefined; + keyframeId: string; +}): AnimationChannel | undefined { + if (!channel) { + return undefined; + } + + const nextKeyframes = channel.keyframes.filter( + (keyframe) => keyframe.id !== keyframeId, + ); + if (nextKeyframes.length === 0) { + return undefined; + } + + return toChannel({ + keyframes: nextKeyframes, + valueKind: channel.valueKind, + }); +} + +export function retimeKeyframe({ + channel, + keyframeId, + time, +}: { + channel: AnimationChannel | undefined; + keyframeId: string; + time: number; +}): AnimationChannel | undefined { + if (!channel) { + return undefined; + } + + const keyframeByIdIndex = channel.keyframes.findIndex( + (keyframe) => keyframe.id === keyframeId, + ); + if (keyframeByIdIndex < 0) { + return channel; + } + + const nextKeyframes = [...channel.keyframes]; + nextKeyframes[keyframeByIdIndex] = { + ...nextKeyframes[keyframeByIdIndex], + time, + }; + + return toChannel({ + keyframes: nextKeyframes, + valueKind: channel.valueKind, + }); +} + +export function setChannel({ + animations, + propertyPath, + channel, +}: { + animations: ElementAnimations | undefined; + propertyPath: string; + channel: AnimationChannel | undefined; +}): ElementAnimations | undefined { + const currentChannels = animations?.channels ?? {}; + + const nextChannelEntries = Object.entries(currentChannels) + .filter(([path]) => path !== propertyPath) + .filter(([, ch]) => ch && ch.keyframes.length > 0) + .map(([path, ch]) => [path, ch] as [string, AnimationChannel]); + + if (channel && channel.keyframes.length > 0) { + nextChannelEntries.push([propertyPath, channel]); + } + + return toAnimation({ + channelEntries: nextChannelEntries, + }); +} + +export function cloneAnimations({ + animations, + shouldRegenerateKeyframeIds = false, +}: { + animations: ElementAnimations | undefined; + shouldRegenerateKeyframeIds?: boolean; +}): ElementAnimations | undefined { + if (!animations) { + return undefined; + } + + const clonedEntries = Object.entries(animations.channels).flatMap( + ([propertyPath, channel]) => { + if (!channel || channel.keyframes.length === 0) { + return []; + } + + const clonedKeyframes = channel.keyframes.map((keyframe) => ({ + ...keyframe, + id: shouldRegenerateKeyframeIds ? generateUUID() : keyframe.id, + })); + + return [ + [ + propertyPath, + toChannel({ + keyframes: clonedKeyframes, + valueKind: channel.valueKind, + }), + ] as [string, AnimationChannel], + ]; + }, + ); + + return toAnimation({ + channelEntries: clonedEntries, + }); +} + +export function clampAnimationsToDuration({ + animations, + duration, +}: { + animations: ElementAnimations | undefined; + duration: number; +}): ElementAnimations | undefined { + if (!animations) { + return undefined; + } + + const clampedEntries = Object.entries(animations.channels).flatMap( + ([propertyPath, channel]) => { + if (!channel) { + return []; + } + + const nextKeyframes = channel.keyframes.filter( + (keyframe) => keyframe.time >= 0 && keyframe.time <= duration, + ); + if (nextKeyframes.length === 0) { + return []; + } + + return [ + [ + propertyPath, + toChannel({ + keyframes: nextKeyframes, + valueKind: channel.valueKind, + }), + ] as [string, AnimationChannel], + ]; + }, + ); + + return toAnimation({ + channelEntries: clampedEntries, + }); +} + +export function splitAnimationsAtTime({ + animations, + splitTime, + shouldIncludeSplitBoundary = true, +}: { + animations: ElementAnimations | undefined; + splitTime: number; + shouldIncludeSplitBoundary?: boolean; +}): { + leftAnimations: ElementAnimations | undefined; + rightAnimations: ElementAnimations | undefined; +} { + if (!animations) { + return { leftAnimations: undefined, rightAnimations: undefined }; + } + + const leftChannels: Array<[string, AnimationChannel]> = []; + const rightChannels: Array<[string, AnimationChannel]> = []; + + for (const [propertyPath, channel] of Object.entries(animations.channels)) { + if (!channel || channel.keyframes.length === 0) { + continue; + } + + const normalizedChannel = normalizeChannel({ channel }); + let leftKeyframes = normalizedChannel.keyframes.filter( + (keyframe) => keyframe.time <= splitTime, + ); + let rightKeyframes = normalizedChannel.keyframes + .filter((keyframe) => keyframe.time >= splitTime) + .map((keyframe) => ({ + ...keyframe, + time: keyframe.time - splitTime, + })); + + const hasBoundaryOnLeft = leftKeyframes.some((keyframe) => + isNearlySameTime({ leftTime: keyframe.time, rightTime: splitTime }), + ); + const hasBoundaryOnRight = rightKeyframes.some((keyframe) => + isNearlySameTime({ leftTime: keyframe.time, rightTime: 0 }), + ); + if (shouldIncludeSplitBoundary && (!hasBoundaryOnLeft || !hasBoundaryOnRight)) { + const boundaryValue = getChannelValueAtTime({ + channel: normalizedChannel, + time: splitTime, + fallbackValue: normalizedChannel.keyframes[0].value, + }); + const knownPropertyPath = isAnimationPropertyPath({ propertyPath }) + ? (propertyPath as AnimationPropertyPath) + : null; + const boundaryInterpolation = knownPropertyPath + ? getDefaultInterpolationForProperty({ propertyPath: knownPropertyPath }) + : normalizedChannel.valueKind === "discrete" + ? "hold" + : "linear"; + + if (!hasBoundaryOnLeft) { + leftKeyframes = [ + ...leftKeyframes, + buildKeyframe({ + channel: normalizedChannel, + id: generateUUID(), + time: splitTime, + value: boundaryValue, + interpolation: boundaryInterpolation, + }), + ]; + } + + if (!hasBoundaryOnRight) { + rightKeyframes = [ + buildKeyframe({ + channel: normalizedChannel, + id: generateUUID(), + time: 0, + value: boundaryValue, + interpolation: boundaryInterpolation, + }), + ...rightKeyframes, + ]; + } + } + + const leftChannel = leftKeyframes.length + ? toChannel({ + keyframes: leftKeyframes, + valueKind: normalizedChannel.valueKind, + }) + : undefined; + const rightChannel = rightKeyframes.length + ? toChannel({ + keyframes: rightKeyframes, + valueKind: normalizedChannel.valueKind, + }) + : undefined; + if (leftChannel) { + leftChannels.push([propertyPath, leftChannel]); + } + if (rightChannel) { + rightChannels.push([propertyPath, rightChannel]); + } + } + + return { + leftAnimations: toAnimation({ channelEntries: leftChannels }), + rightAnimations: toAnimation({ channelEntries: rightChannels }), + }; +} + +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; + keyframeId: string; +}): ElementAnimations | undefined { + const channel = getChannel({ animations, propertyPath }); + const updatedChannel = removeKeyframe({ + channel, + keyframeId, + }); + return setChannel({ + animations, + propertyPath, + channel: updatedChannel, + }); +} + +export function retimeElementKeyframe({ + animations, + propertyPath, + keyframeId, + time, +}: { + animations: ElementAnimations | undefined; + propertyPath: AnimationPropertyPath; + keyframeId: string; + time: number; +}): ElementAnimations | undefined { + const channel = getChannel({ animations, propertyPath }); + const updatedChannel = retimeKeyframe({ + channel, + keyframeId, + time, + }); + return setChannel({ + animations, + propertyPath, + channel: updatedChannel, + }); +} diff --git a/apps/web/src/lib/animation/number-channel.ts b/apps/web/src/lib/animation/number-channel.ts new file mode 100644 index 00000000..67166156 --- /dev/null +++ b/apps/web/src/lib/animation/number-channel.ts @@ -0,0 +1,20 @@ +import type { + AnimationPropertyPath, + ElementAnimations, + NumberAnimationChannel, +} from "@/types/animation"; + +export function getNumberChannelForPath({ + animations, + propertyPath, +}: { + animations: ElementAnimations | undefined; + propertyPath: AnimationPropertyPath; +}): NumberAnimationChannel | undefined { + const channel = animations?.channels[propertyPath]; + if (!channel || channel.valueKind !== "number") { + return undefined; + } + + return channel; +} diff --git a/apps/web/src/lib/animation/property-registry.ts b/apps/web/src/lib/animation/property-registry.ts new file mode 100644 index 00000000..44f5d638 --- /dev/null +++ b/apps/web/src/lib/animation/property-registry.ts @@ -0,0 +1,230 @@ +import type { + AnimationInterpolation, + AnimationPropertyPath, + AnimationValue, + AnimationValueKind, + DiscreteValue, +} from "@/types/animation"; +import type { TimelineElement } from "@/types/timeline"; +import { MIN_TRANSFORM_SCALE } from "@/constants/animation-constants"; +import { isVisualElement } from "@/lib/timeline/element-utils"; + +interface NumericRange { + min?: number; + max?: number; +} + +interface AnimationPropertyDefinition { + valueKind: AnimationValueKind; + defaultInterpolation: AnimationInterpolation; + numericRange?: NumericRange; + supportsElement: ({ element }: { element: TimelineElement }) => boolean; + getValue: ({ element }: { element: TimelineElement }) => number | null; + setValue: ({ + element, + value, + }: { + element: TimelineElement; + value: number; + }) => TimelineElement; +} + +const ANIMATION_PROPERTY_REGISTRY: Record< + AnimationPropertyPath, + AnimationPropertyDefinition +> = { + "transform.position.x": { + valueKind: "number", + defaultInterpolation: "linear", + supportsElement: ({ element }) => isVisualElement(element), + getValue: ({ element }) => + isVisualElement(element) ? element.transform.position.x : null, + setValue: ({ element, value }) => + isVisualElement(element) + ? { + ...element, + transform: { + ...element.transform, + position: { ...element.transform.position, x: value }, + }, + } + : element, + }, + "transform.position.y": { + valueKind: "number", + defaultInterpolation: "linear", + supportsElement: ({ element }) => isVisualElement(element), + getValue: ({ element }) => + isVisualElement(element) ? element.transform.position.y : null, + setValue: ({ element, value }) => + isVisualElement(element) + ? { + ...element, + transform: { + ...element.transform, + position: { ...element.transform.position, y: value }, + }, + } + : element, + }, + "transform.scale": { + valueKind: "number", + defaultInterpolation: "linear", + numericRange: { min: MIN_TRANSFORM_SCALE }, + supportsElement: ({ element }) => isVisualElement(element), + getValue: ({ element }) => + isVisualElement(element) ? element.transform.scale : null, + setValue: ({ element, value }) => + isVisualElement(element) + ? { ...element, transform: { ...element.transform, scale: value } } + : element, + }, + "transform.rotate": { + valueKind: "number", + defaultInterpolation: "linear", + supportsElement: ({ element }) => isVisualElement(element), + getValue: ({ element }) => + isVisualElement(element) ? element.transform.rotate : null, + setValue: ({ element, value }) => + isVisualElement(element) + ? { ...element, transform: { ...element.transform, rotate: value } } + : element, + }, + opacity: { + valueKind: "number", + defaultInterpolation: "linear", + numericRange: { min: 0, max: 1 }, + supportsElement: ({ element }) => isVisualElement(element), + getValue: ({ element }) => + isVisualElement(element) ? element.opacity : null, + setValue: ({ element, value }) => + isVisualElement(element) ? { ...element, opacity: value } : element, + }, + volume: { + valueKind: "number", + defaultInterpolation: "linear", + numericRange: { min: 0, max: 1 }, + supportsElement: ({ element }) => element.type === "audio", + getValue: ({ element }) => + element.type === "audio" ? element.volume : null, + setValue: ({ element, value }) => + element.type === "audio" ? { ...element, volume: value } : element, + }, +}; + +export function isAnimationPropertyPath({ + propertyPath, +}: { + propertyPath: string; +}): boolean { + return propertyPath in ANIMATION_PROPERTY_REGISTRY; +} + +export function getAnimationPropertyDefinition({ + propertyPath, +}: { + propertyPath: AnimationPropertyPath; +}): AnimationPropertyDefinition { + return ANIMATION_PROPERTY_REGISTRY[propertyPath]; +} + +export function supportsAnimationProperty({ + element, + propertyPath, +}: { + element: TimelineElement; + propertyPath: AnimationPropertyPath; +}): boolean { + const propertyDefinition = getAnimationPropertyDefinition({ propertyPath }); + return propertyDefinition.supportsElement({ element }); +} + +export function getElementBaseValueForProperty({ + element, + propertyPath, +}: { + element: TimelineElement; + propertyPath: AnimationPropertyPath; +}): AnimationValue | null { + const definition = getAnimationPropertyDefinition({ propertyPath }); + if (!definition.supportsElement({ element })) { + return null; + } + return definition.getValue({ element }); +} + +export function withElementBaseValueForProperty({ + element, + propertyPath, + value, +}: { + element: TimelineElement; + propertyPath: AnimationPropertyPath; + value: AnimationValue; +}): TimelineElement { + const coercedValue = coerceAnimationValueForProperty({ propertyPath, value }); + if (coercedValue === null || typeof coercedValue !== "number") { + return element; + } + const definition = getAnimationPropertyDefinition({ propertyPath }); + if (!definition.supportsElement({ element })) { + return element; + } + return definition.setValue({ element, value: coercedValue }); +} + +export function getDefaultInterpolationForProperty({ + propertyPath, +}: { + propertyPath: AnimationPropertyPath; +}): AnimationInterpolation { + const propertyDefinition = getAnimationPropertyDefinition({ propertyPath }); + return propertyDefinition.defaultInterpolation; +} + +function clampNumericRange({ + value, + numericRange, +}: { + value: number; + numericRange: NumericRange | undefined; +}): number { + if (!numericRange) { + return value; + } + + const minValue = numericRange.min ?? Number.NEGATIVE_INFINITY; + const maxValue = numericRange.max ?? Number.POSITIVE_INFINITY; + return Math.min(maxValue, Math.max(minValue, value)); +} + +export function coerceAnimationValueForProperty({ + propertyPath, + value, +}: { + propertyPath: AnimationPropertyPath; + value: AnimationValue; +}): AnimationValue | null { + const propertyDefinition = getAnimationPropertyDefinition({ propertyPath }); + + if (propertyDefinition.valueKind === "number") { + if (typeof value !== "number" || Number.isNaN(value)) { + return null; + } + + return clampNumericRange({ + value, + numericRange: propertyDefinition.numericRange, + }); + } + + if (propertyDefinition.valueKind === "color") { + return typeof value === "string" ? value : null; + } + + if (typeof value === "string" || typeof value === "boolean") { + return value as DiscreteValue; + } + + return null; +} diff --git a/apps/web/src/lib/animation/resolve.ts b/apps/web/src/lib/animation/resolve.ts new file mode 100644 index 00000000..58f292b1 --- /dev/null +++ b/apps/web/src/lib/animation/resolve.ts @@ -0,0 +1,111 @@ +import type { ElementAnimations } from "@/types/animation"; +import type { Transform } from "@/types/timeline"; +import { getNumberChannelValueAtTime } from "./interpolation"; +import { getNumberChannelForPath } from "./number-channel"; + +export function getElementLocalTime({ + timelineTime, + elementStartTime, + elementDuration, +}: { + timelineTime: number; + elementStartTime: number; + elementDuration: number; +}): number { + const localTime = timelineTime - elementStartTime; + if (localTime <= 0) { + return 0; + } + + if (localTime >= elementDuration) { + return elementDuration; + } + + return localTime; +} + +export function resolveTransformAtTime({ + baseTransform, + animations, + localTime, +}: { + baseTransform: Transform; + animations: ElementAnimations | undefined; + localTime: number; +}): Transform { + const safeLocalTime = Math.max(0, localTime); + return { + position: { + x: getNumberChannelValueAtTime({ + channel: getNumberChannelForPath({ + animations, + propertyPath: "transform.position.x", + }), + time: safeLocalTime, + fallbackValue: baseTransform.position.x, + }), + y: getNumberChannelValueAtTime({ + channel: getNumberChannelForPath({ + animations, + propertyPath: "transform.position.y", + }), + time: safeLocalTime, + fallbackValue: baseTransform.position.y, + }), + }, + scale: getNumberChannelValueAtTime({ + channel: getNumberChannelForPath({ + animations, + propertyPath: "transform.scale", + }), + time: safeLocalTime, + fallbackValue: baseTransform.scale, + }), + rotate: getNumberChannelValueAtTime({ + channel: getNumberChannelForPath({ + animations, + propertyPath: "transform.rotate", + }), + time: safeLocalTime, + fallbackValue: baseTransform.rotate, + }), + }; +} + +export function resolveOpacityAtTime({ + baseOpacity, + animations, + localTime, +}: { + baseOpacity: number; + animations: ElementAnimations | undefined; + localTime: number; +}): number { + return getNumberChannelValueAtTime({ + channel: getNumberChannelForPath({ + animations, + propertyPath: "opacity", + }), + time: Math.max(0, localTime), + fallbackValue: baseOpacity, + }); +} + +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, + }); +} |
