summaryrefslogtreecommitdiff
path: root/apps/web/src/lib/animation
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-02-27 16:33:57 +0100
committerMaze Winther <mazewinther@gmail.com>2026-02-27 16:33:57 +0100
commit49426f19cd8eaed7cf6c47999b66fee7ee637a56 (patch)
tree1b37451529cfcb36e6cae79494c61b9c616077df /apps/web/src/lib/animation
parent9b94f89def4f96836c4f42a23a73e97fe647a43c (diff)
downloadopencut-49426f19cd8eaed7cf6c47999b66fee7ee637a56.tar.gz
feat: implement keyframe animation system
Add keyframe support for transform, opacity, and volume properties. Includes animation engine (interpolation, mutations, resolvers), timeline markers with selection/snapping, properties panel toggles, keyframe-aware renderer, and full undo/redo command support. Also refactor element command constructors to object params, extract timeline pixel math to pixel-utils.ts, and update cursor rules.
Diffstat (limited to 'apps/web/src/lib/animation')
-rw-r--r--apps/web/src/lib/animation/__tests__/transform-keyframes.test.ts313
-rw-r--r--apps/web/src/lib/animation/index.ts39
-rw-r--r--apps/web/src/lib/animation/interpolation.ts365
-rw-r--r--apps/web/src/lib/animation/keyframe-query.ts67
-rw-r--r--apps/web/src/lib/animation/keyframes.ts593
-rw-r--r--apps/web/src/lib/animation/number-channel.ts20
-rw-r--r--apps/web/src/lib/animation/property-registry.ts230
-rw-r--r--apps/web/src/lib/animation/resolve.ts111
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,
+ });
+}