summaryrefslogtreecommitdiff
path: root/apps/web/src/animation
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/animation')
-rw-r--r--apps/web/src/animation/__tests__/binding-values.test.ts26
-rw-r--r--apps/web/src/animation/bezier.ts90
-rw-r--r--apps/web/src/animation/binding-values.ts344
-rw-r--r--apps/web/src/animation/curve-bridge.ts95
-rw-r--r--apps/web/src/animation/effect-param-channel.ts100
-rw-r--r--apps/web/src/animation/graph-channels.ts99
-rw-r--r--apps/web/src/animation/graphic-param-channel.ts73
-rw-r--r--apps/web/src/animation/index.ts96
-rw-r--r--apps/web/src/animation/interpolation.ts375
-rw-r--r--apps/web/src/animation/keyframe-query.ts294
-rw-r--r--apps/web/src/animation/keyframes.ts1338
-rw-r--r--apps/web/src/animation/property-groups.ts39
-rw-r--r--apps/web/src/animation/property-registry.ts390
-rw-r--r--apps/web/src/animation/resolve.ts184
-rw-r--r--apps/web/src/animation/target-resolver.ts285
-rw-r--r--apps/web/src/animation/timeline-snap-points.ts35
-rw-r--r--apps/web/src/animation/transform.ts1
-rw-r--r--apps/web/src/animation/types.ts222
18 files changed, 4086 insertions, 0 deletions
diff --git a/apps/web/src/animation/__tests__/binding-values.test.ts b/apps/web/src/animation/__tests__/binding-values.test.ts
new file mode 100644
index 00000000..a1f2010c
--- /dev/null
+++ b/apps/web/src/animation/__tests__/binding-values.test.ts
@@ -0,0 +1,26 @@
+import { describe, expect, test } from "bun:test";
+import {
+ composeAnimationValue,
+ createAnimationBinding,
+} from "@/animation/binding-values";
+
+describe("binding values", () => {
+ test("formats composed animated colors as hex", () => {
+ const binding = createAnimationBinding({
+ path: "color",
+ kind: "color",
+ });
+
+ expect(
+ composeAnimationValue({
+ binding,
+ componentValues: {
+ r: 1,
+ g: 0,
+ b: 0,
+ a: 1,
+ },
+ }),
+ ).toBe("#ff0000");
+ });
+});
diff --git a/apps/web/src/animation/bezier.ts b/apps/web/src/animation/bezier.ts
new file mode 100644
index 00000000..6f9a1d18
--- /dev/null
+++ b/apps/web/src/animation/bezier.ts
@@ -0,0 +1,90 @@
+import type { ScalarAnimationKey } from "@/animation/types";
+
+const BEZIER_SOLVE_ITERATIONS = 20;
+
+export function getBezierPoint({
+ progress,
+ p0,
+ p1,
+ p2,
+ p3,
+}: {
+ progress: number;
+ p0: number;
+ p1: number;
+ p2: number;
+ p3: number;
+}) {
+ const mt = 1 - progress;
+ return (
+ mt * mt * mt * p0 +
+ 3 * mt * mt * progress * p1 +
+ 3 * mt * progress * progress * p2 +
+ progress * progress * progress * p3
+ );
+}
+
+export function getDefaultRightHandle({
+ leftKey,
+ rightKey,
+}: {
+ leftKey: ScalarAnimationKey;
+ rightKey: ScalarAnimationKey;
+}) {
+ const span = rightKey.time - leftKey.time;
+ const valueDelta = rightKey.value - leftKey.value;
+ return {
+ dt: span / 3,
+ dv: valueDelta / 3,
+ };
+}
+
+export function getDefaultLeftHandle({
+ leftKey,
+ rightKey,
+}: {
+ leftKey: ScalarAnimationKey;
+ rightKey: ScalarAnimationKey;
+}) {
+ const span = rightKey.time - leftKey.time;
+ const valueDelta = rightKey.value - leftKey.value;
+ return {
+ dt: -span / 3,
+ dv: -valueDelta / 3,
+ };
+}
+
+export function solveBezierProgressForTime({
+ time,
+ leftKey,
+ rightKey,
+}: {
+ time: number;
+ leftKey: ScalarAnimationKey;
+ rightKey: ScalarAnimationKey;
+}) {
+ let lower = 0;
+ let upper = 1;
+ const rightHandle =
+ leftKey.rightHandle ?? getDefaultRightHandle({ leftKey, rightKey });
+ const leftHandle =
+ rightKey.leftHandle ?? getDefaultLeftHandle({ leftKey, rightKey });
+
+ for (let iteration = 0; iteration < BEZIER_SOLVE_ITERATIONS; iteration++) {
+ const mid = (lower + upper) / 2;
+ const estimate = getBezierPoint({
+ progress: mid,
+ p0: leftKey.time,
+ p1: leftKey.time + rightHandle.dt,
+ p2: rightKey.time + leftHandle.dt,
+ p3: rightKey.time,
+ });
+ if (estimate < time) {
+ lower = mid;
+ } else {
+ upper = mid;
+ }
+ }
+
+ return (lower + upper) / 2;
+}
diff --git a/apps/web/src/animation/binding-values.ts b/apps/web/src/animation/binding-values.ts
new file mode 100644
index 00000000..a46aa9a6
--- /dev/null
+++ b/apps/web/src/animation/binding-values.ts
@@ -0,0 +1,344 @@
+import { converter, formatHex, formatHex8, parse } from "culori";
+import type {
+ AnimationBindingComponent,
+ AnimationBindingOfKind,
+ AnimationBindingInstance,
+ AnimationBindingKind,
+ ColorAnimationBinding,
+ DiscreteAnimationBinding,
+ NumberAnimationBinding,
+ AnimationPath,
+ AnimationValue,
+ DiscreteValue,
+ Vector2AnimationBinding,
+ VectorValue,
+} from "@/animation/types";
+import { clamp } from "@/utils/math";
+
+interface LinearRgba {
+ r: number;
+ g: number;
+ b: number;
+ a: number;
+}
+
+export type AnimationComponentValue = number | DiscreteValue;
+
+const toRgb = converter("rgb");
+
+function srgbToLinear({ value }: { value: number }): number {
+ return value <= 0.04045
+ ? value / 12.92
+ : Math.pow((value + 0.055) / 1.055, 2.4);
+}
+
+function linearToSrgb({ value }: { value: number }): number {
+ const clamped = clamp({ value, min: 0, max: 1 });
+ return clamped <= 0.0031308
+ ? clamped * 12.92
+ : 1.055 * Math.pow(clamped, 1 / 2.4) - 0.055;
+}
+
+function isRecord(value: unknown): value is Record<string, unknown> {
+ return typeof value === "object" && value !== null;
+}
+
+export function isVectorValue(value: unknown): value is VectorValue {
+ return isRecord(value) && typeof value.x === "number" && typeof value.y === "number";
+}
+
+export type EasingMode = "independent" | "shared";
+
+/**
+ * Declares how easing curves apply to a binding's components.
+ * "shared" means all components always use the same curve (e.g. color — you never
+ * want to ease R independently from G/B/A). "independent" means each component
+ * can have its own curve.
+ */
+export function getEasingModeForKind(kind: AnimationBindingKind): EasingMode {
+ return kind === "color" ? "shared" : "independent";
+}
+
+export function getBindingComponentKeys({
+ kind,
+}: {
+ kind: AnimationBindingKind;
+}): string[] {
+ if (kind === "vector2") {
+ return ["x", "y"];
+ }
+
+ if (kind === "color") {
+ return ["r", "g", "b", "a"];
+ }
+
+ return ["value"];
+}
+
+export function buildBindingChannelId({
+ path,
+ componentKey,
+}: {
+ path: AnimationPath;
+ componentKey: string;
+}): string {
+ return `${path}:${componentKey}`;
+}
+
+function createBindingComponent<TKey extends string>({
+ path,
+ key,
+}: {
+ path: AnimationPath;
+ key: TKey;
+}): AnimationBindingComponent<TKey> {
+ return {
+ key,
+ channelId: buildBindingChannelId({ path, componentKey: key }),
+ };
+}
+
+function cloneBindingComponents<TKey extends string>({
+ components,
+}: {
+ components: AnimationBindingComponent<TKey>[];
+}): AnimationBindingComponent<TKey>[] {
+ return components.map((component) => ({ ...component }));
+}
+
+const animationBindingFactories = {
+ color: ({ path }: { path: AnimationPath }): ColorAnimationBinding => ({
+ path,
+ kind: "color",
+ colorSpace: "srgb-linear",
+ components: [
+ createBindingComponent({ path, key: "r" }),
+ createBindingComponent({ path, key: "g" }),
+ createBindingComponent({ path, key: "b" }),
+ createBindingComponent({ path, key: "a" }),
+ ],
+ }),
+ vector2: ({ path }: { path: AnimationPath }): Vector2AnimationBinding => ({
+ path,
+ kind: "vector2",
+ components: [
+ createBindingComponent({ path, key: "x" }),
+ createBindingComponent({ path, key: "y" }),
+ ],
+ }),
+ number: ({ path }: { path: AnimationPath }): NumberAnimationBinding => ({
+ path,
+ kind: "number",
+ components: [createBindingComponent({ path, key: "value" })],
+ }),
+ discrete: ({ path }: { path: AnimationPath }): DiscreteAnimationBinding => ({
+ path,
+ kind: "discrete",
+ components: [createBindingComponent({ path, key: "value" })],
+ }),
+} satisfies {
+ [K in AnimationBindingKind]: ({
+ path,
+ }: {
+ path: AnimationPath;
+ }) => AnimationBindingOfKind<K>;
+};
+
+export function createAnimationBinding<TKind extends AnimationBindingKind>({
+ path,
+ kind,
+}: {
+ path: AnimationPath;
+ kind: TKind;
+}): AnimationBindingOfKind<TKind>;
+export function createAnimationBinding({
+ path,
+ kind,
+}: {
+ path: AnimationPath;
+ kind: AnimationBindingKind;
+}): AnimationBindingInstance {
+ return animationBindingFactories[kind]({ path });
+}
+
+const animationBindingCloners = {
+ color: ({ binding }: { binding: ColorAnimationBinding }): ColorAnimationBinding => ({
+ ...binding,
+ components: cloneBindingComponents({
+ components: binding.components,
+ }),
+ }),
+ vector2: ({
+ binding,
+ }: {
+ binding: Vector2AnimationBinding;
+ }): Vector2AnimationBinding => ({
+ ...binding,
+ components: cloneBindingComponents({
+ components: binding.components,
+ }),
+ }),
+ number: ({
+ binding,
+ }: {
+ binding: NumberAnimationBinding;
+ }): NumberAnimationBinding => ({
+ ...binding,
+ components: cloneBindingComponents({
+ components: binding.components,
+ }),
+ }),
+ discrete: ({
+ binding,
+ }: {
+ binding: DiscreteAnimationBinding;
+ }): DiscreteAnimationBinding => ({
+ ...binding,
+ components: cloneBindingComponents({
+ components: binding.components,
+ }),
+ }),
+} satisfies {
+ [K in AnimationBindingKind]: ({
+ binding,
+ }: {
+ binding: AnimationBindingOfKind<K>;
+ }) => AnimationBindingOfKind<K>;
+};
+
+export function cloneAnimationBinding<TKind extends AnimationBindingKind>({
+ binding,
+}: {
+ binding: AnimationBindingOfKind<TKind>;
+}): AnimationBindingOfKind<TKind>;
+export function cloneAnimationBinding({
+ binding,
+}: {
+ binding: AnimationBindingInstance;
+}): AnimationBindingInstance {
+ switch (binding.kind) {
+ case "color":
+ return animationBindingCloners.color({ binding });
+ case "vector2":
+ return animationBindingCloners.vector2({ binding });
+ case "number":
+ return animationBindingCloners.number({ binding });
+ case "discrete":
+ return animationBindingCloners.discrete({ binding });
+ }
+}
+
+export function parseColorToLinearRgba({
+ color,
+}: {
+ color: string;
+}): LinearRgba | null {
+ const parsed = parse(color);
+ const rgb = parsed ? toRgb(parsed) : null;
+ if (!rgb) {
+ return null;
+ }
+
+ return {
+ r: srgbToLinear({ value: rgb.r ?? 0 }),
+ g: srgbToLinear({ value: rgb.g ?? 0 }),
+ b: srgbToLinear({ value: rgb.b ?? 0 }),
+ a: clamp({ value: rgb.alpha ?? 1, min: 0, max: 1 }),
+ };
+}
+
+export function formatLinearRgba({
+ color,
+}: {
+ color: LinearRgba;
+}): string {
+ const rgb = {
+ mode: "rgb",
+ r: linearToSrgb({ value: color.r }),
+ g: linearToSrgb({ value: color.g }),
+ b: linearToSrgb({ value: color.b }),
+ alpha: clamp({ value: color.a, min: 0, max: 1 }),
+ } as const;
+ return rgb.alpha < 1 ? formatHex8(rgb) : formatHex(rgb);
+}
+
+export function decomposeAnimationValue({
+ kind,
+ value,
+}: {
+ kind: AnimationBindingKind;
+ value: AnimationValue;
+}): Record<string, AnimationComponentValue> | null {
+ if (kind === "number") {
+ return typeof value === "number" ? { value } : null;
+ }
+
+ if (kind === "vector2") {
+ return isVectorValue(value) ? { x: value.x, y: value.y } : null;
+ }
+
+ if (kind === "color") {
+ if (typeof value !== "string") {
+ return null;
+ }
+ const parsed = parseColorToLinearRgba({ color: value });
+ if (!parsed) {
+ return null;
+ }
+ return {
+ r: parsed.r,
+ g: parsed.g,
+ b: parsed.b,
+ a: parsed.a,
+ };
+ }
+
+ return typeof value === "string" || typeof value === "boolean"
+ ? { value }
+ : null;
+}
+
+export function composeAnimationValue({
+ binding,
+ componentValues,
+}: {
+ binding: AnimationBindingInstance;
+ componentValues: Record<string, AnimationComponentValue | undefined>;
+}): AnimationValue | null {
+ if (binding.kind === "number") {
+ const value = componentValues.value;
+ return typeof value === "number" ? value : null;
+ }
+
+ if (binding.kind === "vector2") {
+ const x = componentValues.x;
+ const y = componentValues.y;
+ return typeof x === "number" && typeof y === "number" ? { x, y } : null;
+ }
+
+ if (binding.kind === "color") {
+ const r = componentValues.r;
+ const g = componentValues.g;
+ const b = componentValues.b;
+ const a = componentValues.a;
+ if (
+ typeof r !== "number" ||
+ typeof g !== "number" ||
+ typeof b !== "number" ||
+ typeof a !== "number"
+ ) {
+ return null;
+ }
+ return formatLinearRgba({
+ color: {
+ r,
+ g,
+ b,
+ a,
+ },
+ });
+ }
+
+ const value = componentValues.value;
+ return typeof value === "string" || typeof value === "boolean" ? value : null;
+}
diff --git a/apps/web/src/animation/curve-bridge.ts b/apps/web/src/animation/curve-bridge.ts
new file mode 100644
index 00000000..af411dd1
--- /dev/null
+++ b/apps/web/src/animation/curve-bridge.ts
@@ -0,0 +1,95 @@
+import {
+ getDefaultLeftHandle,
+ getDefaultRightHandle,
+} from "@/animation/bezier";
+import type {
+ CurveHandle,
+ NormalizedCubicBezier,
+ ScalarAnimationKey,
+} from "@/animation/types";
+
+const VALUE_EPSILON = 1e-6;
+
+function clamp01({ value }: { value: number }): number {
+ return Math.max(0, Math.min(1, value));
+}
+
+export function getNormalizedCubicBezierForScalarSegment({
+ leftKey,
+ rightKey,
+ referenceSpanValue,
+}: {
+ leftKey: ScalarAnimationKey;
+ rightKey: ScalarAnimationKey;
+ /** Fallback Y-axis scale used when the segment is flat (spanValue ≈ 0). */
+ referenceSpanValue?: number;
+}): NormalizedCubicBezier | null {
+ const spanTime = rightKey.time - leftKey.time;
+ const spanValue = rightKey.value - leftKey.value;
+ const effectiveSpanValue =
+ Math.abs(spanValue) > VALUE_EPSILON
+ ? spanValue
+ : referenceSpanValue !== undefined && Math.abs(referenceSpanValue) > VALUE_EPSILON
+ ? referenceSpanValue
+ : null;
+
+ if (spanTime === 0 || effectiveSpanValue === null) {
+ return null;
+ }
+
+ const rightHandle =
+ leftKey.rightHandle ?? getDefaultRightHandle({ leftKey, rightKey });
+ const leftHandle =
+ rightKey.leftHandle ?? getDefaultLeftHandle({ leftKey, rightKey });
+
+ return [
+ clamp01({ value: rightHandle.dt / spanTime }),
+ rightHandle.dv / effectiveSpanValue,
+ clamp01({ value: 1 + leftHandle.dt / spanTime }),
+ 1 + leftHandle.dv / effectiveSpanValue,
+ ];
+}
+
+export function getCurveHandlesForNormalizedCubicBezier({
+ leftKey,
+ rightKey,
+ cubicBezier,
+ referenceSpanValue,
+}: {
+ leftKey: ScalarAnimationKey;
+ rightKey: ScalarAnimationKey;
+ cubicBezier: NormalizedCubicBezier;
+ /** Fallback Y-axis scale used when the segment is flat (spanValue ≈ 0). */
+ referenceSpanValue?: number;
+}): {
+ rightHandle: CurveHandle;
+ leftHandle: CurveHandle;
+} | null {
+ const spanTime = rightKey.time - leftKey.time;
+ const spanValue = rightKey.value - leftKey.value;
+ const effectiveSpanValue =
+ Math.abs(spanValue) > VALUE_EPSILON
+ ? spanValue
+ : referenceSpanValue !== undefined && Math.abs(referenceSpanValue) > VALUE_EPSILON
+ ? referenceSpanValue
+ : null;
+
+ if (spanTime === 0 || effectiveSpanValue === null) {
+ return null;
+ }
+
+ const [rawX1, y1, rawX2, y2] = cubicBezier;
+ const x1 = clamp01({ value: rawX1 });
+ const x2 = clamp01({ value: rawX2 });
+
+ return {
+ rightHandle: {
+ dt: spanTime * x1,
+ dv: effectiveSpanValue * y1,
+ },
+ leftHandle: {
+ dt: spanTime * (x2 - 1),
+ dv: effectiveSpanValue * (y2 - 1),
+ },
+ };
+}
diff --git a/apps/web/src/animation/effect-param-channel.ts b/apps/web/src/animation/effect-param-channel.ts
new file mode 100644
index 00000000..c635bb82
--- /dev/null
+++ b/apps/web/src/animation/effect-param-channel.ts
@@ -0,0 +1,100 @@
+import type { ParamValues } from "@/params";
+import type { Effect } from "@/effects/types";
+import type {
+ ElementAnimations,
+ EffectParamPath,
+} from "@/animation/types";
+import { removeElementKeyframe } from "./keyframes";
+import { resolveAnimationPathValueAtTime } from "./resolve";
+
+export const EFFECT_PARAM_PATH_PREFIX = "effects.";
+export const EFFECT_PARAM_PATH_SUFFIX = ".params.";
+
+export function buildEffectParamPath({
+ effectId,
+ paramKey,
+}: {
+ effectId: string;
+ paramKey: string;
+}): EffectParamPath {
+ return `${EFFECT_PARAM_PATH_PREFIX}${effectId}${EFFECT_PARAM_PATH_SUFFIX}${paramKey}`;
+}
+
+export function isEffectParamPath(
+ 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,
+ localTime,
+}: {
+ effect: Effect;
+ animations: ElementAnimations | undefined;
+ localTime: number;
+}): ParamValues {
+ const resolved: ParamValues = {};
+
+ for (const [paramKey, staticValue] of Object.entries(effect.params)) {
+ const path = buildEffectParamPath({ effectId: effect.id, paramKey });
+ resolved[paramKey] = animations?.bindings[path]
+ ? resolveAnimationPathValueAtTime({
+ animations,
+ propertyPath: path,
+ localTime,
+ fallbackValue: staticValue,
+ })
+ : staticValue;
+ }
+
+ return resolved;
+}
+
+export function removeEffectParamKeyframe({
+ animations,
+ effectId,
+ paramKey,
+ keyframeId,
+}: {
+ animations: ElementAnimations | undefined;
+ effectId: string;
+ paramKey: string;
+ keyframeId: string;
+}): ElementAnimations | undefined {
+ return removeElementKeyframe({
+ animations,
+ propertyPath: buildEffectParamPath({ effectId, paramKey }),
+ keyframeId,
+ });
+}
diff --git a/apps/web/src/animation/graph-channels.ts b/apps/web/src/animation/graph-channels.ts
new file mode 100644
index 00000000..d6482602
--- /dev/null
+++ b/apps/web/src/animation/graph-channels.ts
@@ -0,0 +1,99 @@
+import type {
+ AnimationBindingInstance,
+ AnimationPath,
+ ElementAnimations,
+ ScalarAnimationChannel,
+ ScalarGraphChannel,
+ ScalarGraphKeyframeContext,
+} from "@/animation/types";
+
+export interface EditableScalarChannels {
+ binding: AnimationBindingInstance;
+ channels: ScalarGraphChannel[];
+}
+
+function isScalarAnimationChannel(
+ channel: ElementAnimations["channels"][string],
+): channel is ScalarAnimationChannel {
+ return channel?.kind === "scalar";
+}
+
+export function getEditableScalarChannels({
+ animations,
+ propertyPath,
+}: {
+ animations: ElementAnimations | undefined;
+ propertyPath: AnimationPath;
+}): EditableScalarChannels | null {
+ const binding = animations?.bindings[propertyPath];
+ if (!binding) {
+ return null;
+ }
+
+ const channels = binding.components.flatMap((component) => {
+ const channel = animations?.channels[component.channelId];
+ if (!isScalarAnimationChannel(channel)) {
+ return [];
+ }
+
+ return [
+ {
+ propertyPath,
+ componentKey: component.key,
+ channelId: component.channelId,
+ channel,
+ } satisfies ScalarGraphChannel,
+ ];
+ });
+
+ return { binding, channels };
+}
+
+export function getEditableScalarChannel({
+ animations,
+ propertyPath,
+ componentKey,
+}: {
+ animations: ElementAnimations | undefined;
+ propertyPath: AnimationPath;
+ componentKey: string;
+}): ScalarGraphChannel | null {
+ const result = getEditableScalarChannels({ animations, propertyPath });
+ return result?.channels.find((channel) => channel.componentKey === componentKey) ?? null;
+}
+
+export function getScalarKeyframeContext({
+ animations,
+ propertyPath,
+ componentKey,
+ keyframeId,
+}: {
+ animations: ElementAnimations | undefined;
+ propertyPath: AnimationPath;
+ componentKey: string;
+ keyframeId: string;
+}): ScalarGraphKeyframeContext | null {
+ const scalarChannel = getEditableScalarChannel({
+ animations,
+ propertyPath,
+ componentKey,
+ });
+ if (!scalarChannel) {
+ return null;
+ }
+
+ const keyframeIndex = scalarChannel.channel.keys.findIndex(
+ (keyframe) => keyframe.id === keyframeId,
+ );
+ if (keyframeIndex < 0) {
+ return null;
+ }
+
+ return {
+ ...scalarChannel,
+ keyframe: scalarChannel.channel.keys[keyframeIndex],
+ keyframeIndex,
+ previousKey: scalarChannel.channel.keys[keyframeIndex - 1] ?? null,
+ nextKey: scalarChannel.channel.keys[keyframeIndex + 1] ?? null,
+ };
+}
diff --git a/apps/web/src/animation/graphic-param-channel.ts b/apps/web/src/animation/graphic-param-channel.ts
new file mode 100644
index 00000000..ebf0f940
--- /dev/null
+++ b/apps/web/src/animation/graphic-param-channel.ts
@@ -0,0 +1,73 @@
+import type {
+ ElementAnimations,
+ GraphicParamPath,
+} from "@/animation/types";
+import type { ParamValues } from "@/params";
+import {
+ getGraphicDefinition,
+ resolveGraphicParams,
+} from "@/graphics";
+import { resolveAnimationPathValueAtTime } from "./resolve";
+
+export const GRAPHIC_PARAM_PATH_PREFIX = "params.";
+
+export function buildGraphicParamPath({
+ paramKey,
+}: {
+ paramKey: string;
+}): GraphicParamPath {
+ return `${GRAPHIC_PARAM_PATH_PREFIX}${paramKey}`;
+}
+
+export function isGraphicParamPath(
+ 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 });
+ if (!element.animations?.bindings[path]) {
+ continue;
+ }
+
+ resolved[param.key] = resolveAnimationPathValueAtTime({
+ animations: element.animations,
+ propertyPath: path,
+ localTime: Math.max(0, localTime),
+ fallbackValue: baseParams[param.key] ?? param.default,
+ });
+ }
+
+ return resolved;
+}
diff --git a/apps/web/src/animation/index.ts b/apps/web/src/animation/index.ts
new file mode 100644
index 00000000..384f3d17
--- /dev/null
+++ b/apps/web/src/animation/index.ts
@@ -0,0 +1,96 @@
+export {
+ getChannelValueAtTime,
+ getDiscreteChannelValueAtTime,
+ getScalarChannelValueAtTime,
+ getScalarSegmentInterpolation,
+ normalizeChannel,
+} from "./interpolation";
+
+export {
+ clampAnimationsToDuration,
+ cloneAnimations,
+ getChannel,
+ removeElementKeyframe,
+ retimeElementKeyframe,
+ setBindingComponentChannel,
+ setChannel,
+ splitAnimationsAtTime,
+ updateScalarKeyframeCurve,
+ upsertElementKeyframe,
+ upsertPathKeyframe,
+} from "./keyframes";
+
+export {
+ getElementLocalTime,
+ resolveAnimationPathValueAtTime,
+ resolveColorAtTime,
+ resolveNumberAtTime,
+ resolveOpacityAtTime,
+ resolveTransformAtTime,
+} from "./resolve";
+
+export {
+ coerceAnimationValueForProperty,
+ getAnimationPropertyDefinition,
+ getDefaultInterpolationForProperty,
+ getElementBaseValueForProperty,
+ isAnimationPropertyPath,
+ supportsAnimationProperty,
+ type AnimationPropertyDefinition,
+ type NumericSpec,
+ withElementBaseValueForProperty,
+} from "./property-registry";
+
+export {
+ getElementKeyframes,
+ getKeyframeById,
+ getKeyframeAtTime,
+ hasKeyframesForPath,
+} from "./keyframe-query";
+
+export {
+ type EditableScalarChannels,
+ getEditableScalarChannel,
+ getEditableScalarChannels,
+ getScalarKeyframeContext,
+} from "./graph-channels";
+
+export {
+ getCurveHandlesForNormalizedCubicBezier,
+ getNormalizedCubicBezierForScalarSegment,
+} from "./curve-bridge";
+
+export {
+ buildGraphicParamPath,
+ isGraphicParamPath,
+ parseGraphicParamPath,
+ resolveGraphicParamsAtTime,
+} from "./graphic-param-channel";
+
+export {
+ buildEffectParamPath,
+ isEffectParamPath,
+ parseEffectParamPath,
+ removeEffectParamKeyframe,
+ resolveEffectParamsAtTime,
+} from "./effect-param-channel";
+
+export {
+ isAnimationPath,
+ coerceAnimationValueForParam,
+ resolveAnimationTarget,
+ getParamValueKind,
+ getParamDefaultInterpolation,
+ type AnimationPathDescriptor,
+} from "./target-resolver";
+
+export {
+ getGroupKeyframesAtTime,
+ hasGroupKeyframeAtTime,
+ type GroupKeyframeRef,
+} from "./property-groups";
+
+export {
+ type EasingMode,
+ getEasingModeForKind,
+} from "./binding-values";
diff --git a/apps/web/src/animation/interpolation.ts b/apps/web/src/animation/interpolation.ts
new file mode 100644
index 00000000..277bfbb7
--- /dev/null
+++ b/apps/web/src/animation/interpolation.ts
@@ -0,0 +1,375 @@
+import type {
+ AnimationChannel,
+ AnimationInterpolation,
+ AnimationValue,
+ DiscreteAnimationChannel,
+ DiscreteValue,
+ ScalarAnimationChannel,
+ ScalarAnimationKey,
+ ScalarSegmentType,
+} from "@/animation/types";
+import { clamp } from "@/utils/math";
+import {
+ getBezierPoint,
+ getDefaultLeftHandle,
+ getDefaultRightHandle,
+ solveBezierProgressForTime,
+} from "./bezier";
+
+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 <= rightTime;
+}
+
+function lerpNumber({
+ leftValue,
+ rightValue,
+ progress,
+}: {
+ leftValue: number;
+ rightValue: number;
+ progress: number;
+}): number {
+ return leftValue + (rightValue - leftValue) * progress;
+}
+
+function normalizeRightHandle({
+ handle,
+ leftKey,
+ rightKey,
+}: {
+ handle: ScalarAnimationKey["rightHandle"];
+ leftKey: ScalarAnimationKey;
+ rightKey: ScalarAnimationKey;
+}) {
+ if (!handle) {
+ return undefined;
+ }
+
+ const span = Math.max(1, rightKey.time - leftKey.time);
+ return {
+ dt: Math.min(span, Math.max(0, handle.dt)),
+ dv: handle.dv,
+ };
+}
+
+function normalizeLeftHandle({
+ handle,
+ leftKey,
+ rightKey,
+}: {
+ handle: ScalarAnimationKey["leftHandle"];
+ leftKey: ScalarAnimationKey;
+ rightKey: ScalarAnimationKey;
+}) {
+ if (!handle) {
+ return undefined;
+ }
+
+ const span = Math.max(1, rightKey.time - leftKey.time);
+ return {
+ dt: Math.max(-span, Math.min(0, handle.dt)),
+ dv: handle.dv,
+ };
+}
+
+function normalizeScalarKey({
+ key,
+}: {
+ key: ScalarAnimationKey;
+}): ScalarAnimationKey {
+ return {
+ ...key,
+ tangentMode: key.tangentMode ?? "flat",
+ segmentToNext: key.segmentToNext ?? "linear",
+ };
+}
+
+function normalizeScalarChannel({
+ channel,
+}: {
+ channel: ScalarAnimationChannel;
+}): ScalarAnimationChannel {
+ const sortedKeys = [...channel.keys]
+ .map((key) => normalizeScalarKey({ key }))
+ .sort((leftKey, rightKey) =>
+ byTimeAscending({
+ leftTime: leftKey.time,
+ rightTime: rightKey.time,
+ }),
+ );
+ const nextKeys = sortedKeys.map((key, index) => {
+ const previousKey = sortedKeys[index - 1];
+ const nextKey = sortedKeys[index + 1];
+ return {
+ ...key,
+ leftHandle:
+ previousKey != null
+ ? normalizeLeftHandle({
+ handle: key.leftHandle,
+ leftKey: previousKey,
+ rightKey: key,
+ })
+ : undefined,
+ rightHandle:
+ nextKey != null
+ ? normalizeRightHandle({
+ handle: key.rightHandle,
+ leftKey: key,
+ rightKey: nextKey,
+ })
+ : undefined,
+ };
+ });
+
+ return {
+ ...channel,
+ keys: nextKeys,
+ };
+}
+
+export function normalizeChannel<TChannel extends AnimationChannel>({
+ channel,
+}: {
+ channel: TChannel;
+}): TChannel {
+ if (channel.kind === "scalar") {
+ return normalizeScalarChannel({
+ channel,
+ }) as TChannel;
+ }
+
+ return {
+ ...channel,
+ keys: [...channel.keys].sort((leftKeyframe, rightKeyframe) =>
+ byTimeAscending({
+ leftTime: leftKeyframe.time,
+ rightTime: rightKeyframe.time,
+ }),
+ ),
+ } as TChannel;
+}
+
+function extrapolateScalarEdge({
+ mode,
+ edgeKey,
+ neighborKey,
+ time,
+}: {
+ mode: "hold" | "linear";
+ edgeKey: ScalarAnimationKey;
+ neighborKey: ScalarAnimationKey | undefined;
+ time: number;
+}) {
+ if (mode === "hold" || !neighborKey) {
+ return edgeKey.value;
+ }
+
+ const span = neighborKey.time - edgeKey.time;
+ if (span === 0) {
+ return edgeKey.value;
+ }
+
+ return edgeKey.value + ((time - edgeKey.time) / span) * (neighborKey.value - edgeKey.value);
+}
+
+export function getScalarSegmentInterpolation({
+ segment,
+}: {
+ segment: ScalarSegmentType;
+}): AnimationInterpolation {
+ if (segment === "step") {
+ return "hold";
+ }
+
+ return segment === "bezier" ? "bezier" : "linear";
+}
+
+export function getScalarChannelValueAtTime({
+ channel,
+ time,
+ fallbackValue,
+}: {
+ channel: ScalarAnimationChannel | undefined;
+ time: number;
+ fallbackValue: number;
+}): number {
+ if (!channel || channel.keys.length === 0) {
+ return fallbackValue;
+ }
+
+ const normalizedChannel = normalizeChannel({
+ channel,
+ });
+ const firstKey = normalizedChannel.keys[0];
+ const lastKey = normalizedChannel.keys[normalizedChannel.keys.length - 1];
+ if (!firstKey || !lastKey) {
+ return fallbackValue;
+ }
+
+ if (time <= firstKey.time) {
+ if (time < firstKey.time) {
+ return extrapolateScalarEdge({
+ mode: normalizedChannel.extrapolation?.before ?? "hold",
+ edgeKey: firstKey,
+ neighborKey: normalizedChannel.keys[1],
+ time,
+ });
+ }
+
+ return firstKey.value;
+ }
+
+ if (time >= lastKey.time) {
+ if (time > lastKey.time) {
+ return extrapolateScalarEdge({
+ mode: normalizedChannel.extrapolation?.after ?? "hold",
+ edgeKey: lastKey,
+ neighborKey: normalizedChannel.keys[normalizedChannel.keys.length - 2],
+ time,
+ });
+ }
+
+ return lastKey.value;
+ }
+
+ for (
+ let keyIndex = 0;
+ keyIndex < normalizedChannel.keys.length - 1;
+ keyIndex++
+ ) {
+ const leftKey = normalizedChannel.keys[keyIndex];
+ const rightKey = normalizedChannel.keys[keyIndex + 1];
+ if (time === rightKey.time) {
+ return rightKey.value;
+ }
+
+ if (
+ !isWithinTimePair({
+ time,
+ leftTime: leftKey.time,
+ rightTime: rightKey.time,
+ })
+ ) {
+ continue;
+ }
+
+ if (leftKey.segmentToNext === "step") {
+ return leftKey.value;
+ }
+
+ const span = rightKey.time - leftKey.time;
+ if (span === 0) {
+ return rightKey.value;
+ }
+
+ const progress = clamp({
+ value: (time - leftKey.time) / span,
+ min: 0,
+ max: 1,
+ });
+ if (leftKey.segmentToNext === "linear") {
+ return lerpNumber({
+ leftValue: leftKey.value,
+ rightValue: rightKey.value,
+ progress,
+ });
+ }
+
+ const curveProgress = solveBezierProgressForTime({
+ time,
+ leftKey,
+ rightKey,
+ });
+ const rightHandle =
+ leftKey.rightHandle ?? getDefaultRightHandle({ leftKey, rightKey });
+ const leftHandle =
+ rightKey.leftHandle ?? getDefaultLeftHandle({ leftKey, rightKey });
+ return getBezierPoint({
+ progress: curveProgress,
+ p0: leftKey.value,
+ p1: leftKey.value + rightHandle.dv,
+ p2: rightKey.value + leftHandle.dv,
+ p3: rightKey.value,
+ });
+ }
+
+ return lastKey.value;
+}
+
+export function getDiscreteChannelValueAtTime({
+ channel,
+ time,
+ fallbackValue,
+}: {
+ channel: DiscreteAnimationChannel | undefined;
+ time: number;
+ fallbackValue: DiscreteValue;
+}): DiscreteValue {
+ if (!channel || channel.keys.length === 0) {
+ return fallbackValue;
+ }
+
+ const normalizedChannel = normalizeChannel({
+ channel,
+ });
+ let currentValue = fallbackValue;
+ for (const key of normalizedChannel.keys) {
+ if (time < key.time) {
+ break;
+ }
+ currentValue = key.value;
+ }
+ return currentValue;
+}
+
+export function getChannelValueAtTime({
+ channel,
+ time,
+ fallbackValue,
+}: {
+ channel: AnimationChannel | undefined;
+ time: number;
+ fallbackValue: AnimationValue;
+}): AnimationValue {
+ if (!channel || channel.keys.length === 0) {
+ return fallbackValue;
+ }
+
+ if (channel.kind === "scalar") {
+ return typeof fallbackValue === "number"
+ ? getScalarChannelValueAtTime({
+ channel,
+ time,
+ fallbackValue,
+ })
+ : fallbackValue;
+ }
+
+ if (typeof fallbackValue !== "string" && typeof fallbackValue !== "boolean") {
+ return fallbackValue;
+ }
+
+ return getDiscreteChannelValueAtTime({
+ channel,
+ time,
+ fallbackValue,
+ });
+}
diff --git a/apps/web/src/animation/keyframe-query.ts b/apps/web/src/animation/keyframe-query.ts
new file mode 100644
index 00000000..231aa5f2
--- /dev/null
+++ b/apps/web/src/animation/keyframe-query.ts
@@ -0,0 +1,294 @@
+import type {
+ AnimationBindingInstance,
+ AnimationChannel,
+ AnimationPath,
+ ElementAnimations,
+ ElementKeyframe,
+} from "@/animation/types";
+import {
+ type AnimationComponentValue,
+ composeAnimationValue,
+} from "./binding-values";
+import {
+ getChannelValueAtTime,
+ getScalarSegmentInterpolation,
+} from "./interpolation";
+import { isAnimationPath } from "./target-resolver";
+
+function getBindingFallbackValue({
+ channel,
+}: {
+ channel: ElementAnimations["channels"][string];
+}) {
+ if (!channel || channel.keys.length === 0) {
+ return channel?.kind === "discrete" ? false : 0;
+ }
+
+ return channel.keys[0].value;
+}
+
+interface BindingKeyframeMatch {
+ componentIndex: number;
+ channel: AnimationChannel;
+ keyframe: AnimationChannel["keys"][number];
+}
+
+function getBindingKeyframeMatches({
+ animations,
+ binding,
+}: {
+ animations: ElementAnimations;
+ binding: AnimationBindingInstance;
+}): BindingKeyframeMatch[] {
+ return binding.components.flatMap((component, componentIndex) => {
+ const channel = animations.channels[component.channelId];
+ if (!channel || channel.keys.length === 0) {
+ return [];
+ }
+
+ return channel.keys.map((keyframe) => ({
+ componentIndex,
+ channel,
+ keyframe,
+ }));
+ });
+}
+
+function getUniqueBindingKeyframeMatches({
+ animations,
+ binding,
+}: {
+ animations: ElementAnimations;
+ binding: AnimationBindingInstance;
+}): BindingKeyframeMatch[] {
+ const sortedMatches = getBindingKeyframeMatches({
+ animations,
+ binding,
+ }).sort(
+ (leftMatch, rightMatch) =>
+ leftMatch.keyframe.time - rightMatch.keyframe.time ||
+ leftMatch.componentIndex - rightMatch.componentIndex,
+ );
+ const uniqueMatches: BindingKeyframeMatch[] = [];
+
+ for (const match of sortedMatches) {
+ const previousMatch = uniqueMatches[uniqueMatches.length - 1];
+ if (
+ !previousMatch ||
+ previousMatch.keyframe.time !== match.keyframe.time
+ ) {
+ uniqueMatches.push(match);
+ continue;
+ }
+
+ if (
+ previousMatch.componentIndex !== 0 &&
+ match.componentIndex === 0
+ ) {
+ uniqueMatches[uniqueMatches.length - 1] = match;
+ }
+ }
+
+ return uniqueMatches;
+}
+
+function getPreferredBindingKeyframeMatch({
+ matches,
+}: {
+ matches: BindingKeyframeMatch[];
+}): BindingKeyframeMatch | null {
+ return (
+ matches.find((match) => match.componentIndex === 0) ??
+ matches[0] ??
+ null
+ );
+}
+
+function getComposedBindingValueAtTime({
+ animations,
+ binding,
+ time,
+}: {
+ animations: ElementAnimations;
+ binding: AnimationBindingInstance;
+ time: number;
+}) {
+ const componentValues = Object.fromEntries(
+ binding.components.map((component) => {
+ const channel = animations.channels[component.channelId];
+ return [
+ component.key,
+ getChannelValueAtTime({
+ channel,
+ time,
+ fallbackValue: getBindingFallbackValue({ channel }),
+ }),
+ ];
+ }),
+ ) as Record<string, AnimationComponentValue | undefined>;
+
+ return composeAnimationValue({
+ binding,
+ componentValues,
+ });
+}
+
+function getKeyframeInterpolation({
+ channel,
+ keyframe,
+}: {
+ channel: AnimationChannel;
+ keyframe: AnimationChannel["keys"][number];
+}) {
+ return channel.kind === "scalar" && "segmentToNext" in keyframe
+ ? getScalarSegmentInterpolation({ segment: keyframe.segmentToNext })
+ : "hold";
+}
+
+function toElementKeyframe({
+ animations,
+ binding,
+ propertyPath,
+ keyframeMatch,
+}: {
+ animations: ElementAnimations;
+ binding: AnimationBindingInstance;
+ propertyPath: AnimationPath;
+ keyframeMatch: BindingKeyframeMatch;
+}): ElementKeyframe | null {
+ const value = getComposedBindingValueAtTime({
+ animations,
+ binding,
+ time: keyframeMatch.keyframe.time,
+ });
+ if (value === null) {
+ return null;
+ }
+
+ return {
+ propertyPath,
+ id: keyframeMatch.keyframe.id,
+ time: keyframeMatch.keyframe.time,
+ value,
+ interpolation: getKeyframeInterpolation({
+ channel: keyframeMatch.channel,
+ keyframe: keyframeMatch.keyframe,
+ }),
+ };
+}
+
+export function getElementKeyframes({
+ animations,
+}: {
+ animations: ElementAnimations | undefined;
+}): ElementKeyframe[] {
+ if (!animations) {
+ return [];
+ }
+
+ return Object.entries(animations.bindings).flatMap(
+ ([propertyPath, binding]) => {
+ if (!binding || !isAnimationPath(propertyPath)) {
+ return [];
+ }
+
+ return getUniqueBindingKeyframeMatches({
+ animations,
+ binding,
+ }).flatMap((keyframeMatch) => {
+ const keyframe = toElementKeyframe({
+ animations,
+ binding,
+ propertyPath,
+ keyframeMatch,
+ });
+ if (!keyframe) {
+ return [];
+ }
+
+ return [keyframe];
+ });
+ },
+ );
+}
+
+export function hasKeyframesForPath({
+ animations,
+ propertyPath,
+}: {
+ animations: ElementAnimations | undefined;
+ propertyPath: AnimationPath;
+}): boolean {
+ const binding = animations?.bindings[propertyPath];
+ if (!binding) {
+ return false;
+ }
+
+ return binding.components.some((component) =>
+ Boolean(animations?.channels[component.channelId]?.keys.length),
+ );
+}
+
+export function getKeyframeAtTime({
+ animations,
+ propertyPath,
+ time,
+}: {
+ animations: ElementAnimations | undefined;
+ propertyPath: AnimationPath;
+ time: number;
+}): ElementKeyframe | null {
+ const binding = animations?.bindings[propertyPath];
+ if (!binding) {
+ return null;
+ }
+
+ const keyframeMatch = getPreferredBindingKeyframeMatch({
+ matches: getBindingKeyframeMatches({
+ animations,
+ binding,
+ }).filter(({ keyframe }) => keyframe.time === time),
+ });
+ if (!keyframeMatch) {
+ return null;
+ }
+
+ return toElementKeyframe({
+ animations,
+ binding,
+ propertyPath,
+ keyframeMatch,
+ });
+}
+
+export function getKeyframeById({
+ animations,
+ propertyPath,
+ keyframeId,
+}: {
+ animations: ElementAnimations | undefined;
+ propertyPath: AnimationPath;
+ keyframeId: string;
+}): ElementKeyframe | null {
+ const binding = animations?.bindings[propertyPath];
+ if (!binding) {
+ return null;
+ }
+
+ const keyframeMatch = getPreferredBindingKeyframeMatch({
+ matches: getBindingKeyframeMatches({
+ animations,
+ binding,
+ }).filter(({ keyframe }) => keyframe.id === keyframeId),
+ });
+ if (!keyframeMatch) {
+ return null;
+ }
+
+ return toElementKeyframe({
+ animations,
+ binding,
+ propertyPath,
+ keyframeMatch,
+ });
+}
diff --git a/apps/web/src/animation/keyframes.ts b/apps/web/src/animation/keyframes.ts
new file mode 100644
index 00000000..12dc777d
--- /dev/null
+++ b/apps/web/src/animation/keyframes.ts
@@ -0,0 +1,1338 @@
+import type {
+ AnimationBindingInstance,
+ AnimationBindingKind,
+ AnimationChannel,
+ AnimationInterpolation,
+ AnimationPath,
+ AnimationPropertyPath,
+ AnimationValue,
+ DiscreteAnimationChannel,
+ DiscreteAnimationKey,
+ ElementAnimations,
+ ScalarAnimationChannel,
+ ScalarAnimationKey,
+ ScalarCurveKeyframePatch,
+ ScalarSegmentType,
+} from "@/animation/types";
+import { generateUUID } from "@/utils/id";
+import {
+ cloneAnimationBinding,
+ createAnimationBinding,
+ decomposeAnimationValue,
+} from "./binding-values";
+import {
+ getBezierPoint,
+ getDefaultLeftHandle,
+ getDefaultRightHandle,
+ solveBezierProgressForTime,
+} from "./bezier";
+import {
+ getChannelValueAtTime,
+ getScalarSegmentInterpolation,
+ normalizeChannel,
+} from "./interpolation";
+import {
+ coerceAnimationValueForProperty,
+ getAnimationPropertyDefinition,
+} from "./property-registry";
+
+function isNearlySameTime({
+ leftTime,
+ rightTime,
+}: {
+ leftTime: number;
+ rightTime: number;
+}): boolean {
+ return leftTime === rightTime;
+}
+
+function hasChannelKeys({
+ channel,
+}: {
+ channel: AnimationChannel | undefined;
+}): boolean {
+ return Boolean(channel && channel.keys.length > 0);
+}
+
+function toAnimation({
+ animations,
+}: {
+ animations: ElementAnimations;
+}): ElementAnimations | undefined {
+ const nextBindings = Object.fromEntries(
+ Object.entries(animations.bindings).filter(([, binding]) => binding),
+ );
+ const nextChannels = Object.fromEntries(
+ Object.entries(animations.channels).filter(([, channel]) =>
+ hasChannelKeys({ channel }),
+ ),
+ );
+ if (Object.keys(nextBindings).length === 0 || Object.keys(nextChannels).length === 0) {
+ return undefined;
+ }
+
+ return {
+ bindings: nextBindings,
+ channels: nextChannels,
+ };
+}
+
+function cloneAnimationsState({
+ animations,
+}: {
+ animations: ElementAnimations | undefined;
+}): ElementAnimations {
+ return {
+ bindings: { ...(animations?.bindings ?? {}) },
+ channels: { ...(animations?.channels ?? {}) },
+ };
+}
+
+function getBindingChannelKind({
+ kind,
+}: {
+ kind: AnimationBindingKind;
+}): AnimationChannel["kind"] {
+ return kind === "discrete" ? "discrete" : "scalar";
+}
+
+function getPrimaryComponent({
+ binding,
+}: {
+ binding: AnimationBindingInstance;
+}) {
+ return binding.components[0] ?? null;
+}
+
+function getPrimaryChannelId({
+ binding,
+}: {
+ binding: AnimationBindingInstance;
+}) {
+ return getPrimaryComponent({ binding })?.channelId ?? null;
+}
+
+function getScalarSegmentType({
+ interpolation,
+}: {
+ interpolation: AnimationInterpolation;
+}): ScalarSegmentType {
+ if (interpolation === "hold") {
+ return "step";
+ }
+ return interpolation === "bezier" ? "bezier" : "linear";
+}
+
+function getInterpolationForBinding({
+ kind,
+ interpolation,
+}: {
+ kind: AnimationBindingKind;
+ interpolation: AnimationInterpolation | undefined;
+}): AnimationInterpolation {
+ if (kind === "discrete") {
+ return "hold";
+ }
+
+ if (
+ interpolation === "linear" ||
+ interpolation === "hold" ||
+ interpolation === "bezier"
+ ) {
+ return interpolation;
+ }
+
+ return "linear";
+}
+
+function createEmptyChannelForBindingKind({
+ kind,
+}: {
+ kind: AnimationBindingKind;
+}): AnimationChannel {
+ if (kind === "discrete") {
+ return {
+ kind: "discrete",
+ keys: [],
+ } satisfies DiscreteAnimationChannel;
+ }
+
+ return {
+ kind: "scalar",
+ keys: [],
+ } satisfies ScalarAnimationChannel;
+}
+
+function createScalarKey({
+ id,
+ time,
+ value,
+ interpolation,
+ previousKey,
+}: {
+ id: string;
+ time: number;
+ value: number;
+ interpolation?: AnimationInterpolation;
+ previousKey?: ScalarAnimationKey;
+}): ScalarAnimationKey {
+ return {
+ id,
+ time,
+ value,
+ leftHandle: previousKey?.leftHandle,
+ rightHandle: previousKey?.rightHandle,
+ segmentToNext:
+ previousKey?.segmentToNext ??
+ getScalarSegmentType({ interpolation: interpolation ?? "linear" }),
+ tangentMode: previousKey?.tangentMode ?? "flat",
+ };
+}
+
+function createDiscreteKey({
+ id,
+ time,
+ value,
+}: {
+ id: string;
+ time: number;
+ value: string | boolean;
+}): DiscreteAnimationKey {
+ return {
+ id,
+ time,
+ value,
+ };
+}
+
+function getBinding({
+ animations,
+ propertyPath,
+}: {
+ animations: ElementAnimations | undefined;
+ propertyPath: AnimationPath;
+}): AnimationBindingInstance | undefined {
+ return animations?.bindings[propertyPath];
+}
+
+function getChannelById({
+ animations,
+ channelId,
+}: {
+ animations: ElementAnimations | undefined;
+ channelId: string;
+}): AnimationChannel | undefined {
+ return animations?.channels[channelId];
+}
+
+function getBindingComponent({
+ binding,
+ componentKey,
+}: {
+ binding: AnimationBindingInstance;
+ componentKey: string;
+}) {
+ return binding.components.find((component) => component.key === componentKey) ?? null;
+}
+
+function getTargetKeyMetadata({
+ channel,
+ time,
+ keyframeId,
+}: {
+ channel: AnimationChannel | undefined;
+ time: number;
+ keyframeId?: string;
+}) {
+ const normalizedChannel =
+ channel != null ? normalizeChannel({ channel }) : undefined;
+ const keys = normalizedChannel?.keys ?? [];
+ if (keyframeId) {
+ const keyById = keys.find((key) => key.id === keyframeId);
+ if (keyById) {
+ return {
+ id: keyById.id,
+ time,
+ };
+ }
+ }
+
+ const keyAtTime = keys.find((key) =>
+ isNearlySameTime({ leftTime: key.time, rightTime: time }),
+ );
+ if (keyAtTime) {
+ return {
+ id: keyAtTime.id,
+ time: keyAtTime.time,
+ };
+ }
+
+ return {
+ id: keyframeId ?? generateUUID(),
+ time,
+ };
+}
+
+function upsertDiscreteChannelKey({
+ channel,
+ time,
+ value,
+ keyframeId,
+}: {
+ channel: DiscreteAnimationChannel | undefined;
+ time: number;
+ value: string | boolean;
+ keyframeId?: string;
+}): DiscreteAnimationChannel {
+ const normalizedChannel = normalizeChannel({
+ channel: channel ?? { kind: "discrete", keys: [] },
+ });
+ const keys = [...normalizedChannel.keys];
+ if (keyframeId) {
+ const existingIndex = keys.findIndex((key) => key.id === keyframeId);
+ if (existingIndex >= 0) {
+ keys[existingIndex] = createDiscreteKey({
+ id: keys[existingIndex].id,
+ time,
+ value,
+ });
+ return normalizeChannel({
+ channel: { kind: "discrete", keys },
+ });
+ }
+ }
+
+ const existingAtTimeIndex = keys.findIndex((key) =>
+ isNearlySameTime({ leftTime: key.time, rightTime: time }),
+ );
+ if (existingAtTimeIndex >= 0) {
+ keys[existingAtTimeIndex] = createDiscreteKey({
+ id: keys[existingAtTimeIndex].id,
+ time: keys[existingAtTimeIndex].time,
+ value,
+ });
+ return normalizeChannel({
+ channel: { kind: "discrete", keys },
+ });
+ }
+
+ keys.push(
+ createDiscreteKey({
+ id: keyframeId ?? generateUUID(),
+ time,
+ value,
+ }),
+ );
+ return normalizeChannel({
+ channel: { kind: "discrete", keys },
+ });
+}
+
+function upsertScalarChannelKey({
+ channel,
+ time,
+ value,
+ interpolation,
+ defaultInterpolation,
+ keyframeId,
+}: {
+ channel: ScalarAnimationChannel | undefined;
+ time: number;
+ value: number;
+ interpolation?: AnimationInterpolation;
+ defaultInterpolation?: AnimationInterpolation;
+ keyframeId?: string;
+}): ScalarAnimationChannel {
+ const normalizedChannel = normalizeChannel({
+ channel: channel ?? { kind: "scalar", keys: [] },
+ });
+ const keys = [...normalizedChannel.keys];
+ if (keyframeId) {
+ const existingIndex = keys.findIndex((key) => key.id === keyframeId);
+ if (existingIndex >= 0) {
+ keys[existingIndex] = createScalarKey({
+ id: keys[existingIndex].id,
+ time,
+ value,
+ interpolation,
+ previousKey:
+ interpolation != null
+ ? {
+ ...keys[existingIndex],
+ segmentToNext: getScalarSegmentType({ interpolation }),
+ }
+ : keys[existingIndex],
+ });
+ return normalizeChannel({
+ channel: {
+ kind: "scalar",
+ keys,
+ extrapolation: normalizedChannel.extrapolation,
+ },
+ });
+ }
+ }
+
+ const existingAtTimeIndex = keys.findIndex((key) =>
+ isNearlySameTime({ leftTime: key.time, rightTime: time }),
+ );
+ if (existingAtTimeIndex >= 0) {
+ keys[existingAtTimeIndex] = createScalarKey({
+ id: keys[existingAtTimeIndex].id,
+ time: keys[existingAtTimeIndex].time,
+ value,
+ interpolation,
+ previousKey:
+ interpolation != null
+ ? {
+ ...keys[existingAtTimeIndex],
+ segmentToNext: getScalarSegmentType({ interpolation }),
+ }
+ : keys[existingAtTimeIndex],
+ });
+ return normalizeChannel({
+ channel: {
+ kind: "scalar",
+ keys,
+ extrapolation: normalizedChannel.extrapolation,
+ },
+ });
+ }
+
+ keys.push(
+ createScalarKey({
+ id: keyframeId ?? generateUUID(),
+ time,
+ value,
+ interpolation: interpolation ?? defaultInterpolation,
+ }),
+ );
+ return normalizeChannel({
+ channel: {
+ kind: "scalar",
+ keys,
+ extrapolation: normalizedChannel.extrapolation,
+ },
+ });
+}
+
+export function getChannel({
+ animations,
+ propertyPath,
+}: {
+ animations: ElementAnimations | undefined;
+ propertyPath: AnimationPath;
+}): AnimationChannel | undefined {
+ const binding = getBinding({ animations, propertyPath });
+ const primaryChannelId =
+ binding != null ? getPrimaryChannelId({ binding }) : null;
+ return primaryChannelId ? animations?.channels[primaryChannelId] : undefined;
+}
+
+export function upsertPathKeyframe({
+ animations,
+ propertyPath,
+ time,
+ value,
+ interpolation,
+ keyframeId,
+ kind,
+ defaultInterpolation,
+ coerceValue,
+}: {
+ animations: ElementAnimations | undefined;
+ propertyPath: AnimationPath;
+ time: number;
+ value: AnimationValue;
+ interpolation?: AnimationInterpolation;
+ keyframeId?: string;
+ kind: AnimationBindingKind;
+ defaultInterpolation: AnimationInterpolation;
+ coerceValue: ({ value }: { value: AnimationValue }) => AnimationValue | null;
+}): ElementAnimations | undefined {
+ const coercedValue = coerceValue({ value });
+ if (coercedValue === null) {
+ return animations;
+ }
+
+ const nextAnimations = cloneAnimationsState({ animations });
+ const existingBinding = getBinding({
+ animations,
+ propertyPath,
+ });
+ const binding =
+ existingBinding && existingBinding.kind === kind
+ ? cloneAnimationBinding({ binding: existingBinding })
+ : createAnimationBinding({ path: propertyPath, kind });
+ const primaryChannel = getChannel({
+ animations,
+ propertyPath,
+ });
+ const targetKey = getTargetKeyMetadata({
+ channel: primaryChannel,
+ time,
+ keyframeId,
+ });
+ const componentValues = decomposeAnimationValue({
+ kind,
+ value: coercedValue,
+ });
+ if (!componentValues) {
+ return animations;
+ }
+
+ const explicitInterpolation =
+ interpolation != null
+ ? getInterpolationForBinding({ kind, interpolation })
+ : undefined;
+ const validatedDefaultInterpolation = getInterpolationForBinding({
+ kind,
+ interpolation: defaultInterpolation,
+ });
+ nextAnimations.bindings[propertyPath] = binding;
+ for (const component of binding.components) {
+ const nextValue = componentValues[component.key];
+ if (nextValue == null) {
+ continue;
+ }
+
+ const currentChannel = getChannelById({
+ animations,
+ channelId: component.channelId,
+ });
+ const targetChannel =
+ currentChannel?.kind === getBindingChannelKind({ kind })
+ ? currentChannel
+ : createEmptyChannelForBindingKind({ kind });
+ nextAnimations.channels[component.channelId] =
+ targetChannel.kind === "discrete"
+ ? upsertDiscreteChannelKey({
+ channel: targetChannel,
+ time: targetKey.time,
+ value: nextValue as string | boolean,
+ keyframeId: targetKey.id,
+ })
+ : upsertScalarChannelKey({
+ channel: targetChannel,
+ time: targetKey.time,
+ value: nextValue as number,
+ interpolation: explicitInterpolation,
+ defaultInterpolation: validatedDefaultInterpolation,
+ keyframeId: targetKey.id,
+ });
+ }
+
+ return toAnimation({
+ animations: nextAnimations,
+ });
+}
+
+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,
+ kind: propertyDefinition.kind,
+ defaultInterpolation: propertyDefinition.defaultInterpolation,
+ coerceValue: ({ value: nextValue }) =>
+ coerceAnimationValueForProperty({
+ propertyPath,
+ value: nextValue,
+ }),
+ });
+}
+
+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;
+ }
+
+ if (channel.kind === "discrete") {
+ if (typeof value !== "string" && typeof value !== "boolean") {
+ return channel;
+ }
+
+ return upsertDiscreteChannelKey({
+ channel,
+ time,
+ value,
+ keyframeId,
+ });
+ }
+
+ if (typeof value !== "number") {
+ return channel;
+ }
+
+ return upsertScalarChannelKey({
+ channel,
+ time,
+ value,
+ interpolation,
+ keyframeId,
+ });
+}
+
+export function removeKeyframe({
+ channel,
+ keyframeId,
+}: {
+ channel: AnimationChannel | undefined;
+ keyframeId: string;
+}): AnimationChannel | undefined {
+ if (!channel) {
+ return undefined;
+ }
+
+ const nextKeys = channel.keys.filter((keyframe) => keyframe.id !== keyframeId);
+ if (nextKeys.length === 0) {
+ return undefined;
+ }
+
+ return normalizeChannel({
+ channel: {
+ ...channel,
+ keys: nextKeys,
+ } as AnimationChannel,
+ });
+}
+
+export function retimeKeyframe({
+ channel,
+ keyframeId,
+ time,
+}: {
+ channel: AnimationChannel | undefined;
+ keyframeId: string;
+ time: number;
+}): AnimationChannel | undefined {
+ if (!channel) {
+ return undefined;
+ }
+
+ const keyframeByIdIndex = channel.keys.findIndex(
+ (keyframe) => keyframe.id === keyframeId,
+ );
+ if (keyframeByIdIndex < 0) {
+ return channel;
+ }
+
+ const nextKeys = [...channel.keys];
+ nextKeys[keyframeByIdIndex] = {
+ ...nextKeys[keyframeByIdIndex],
+ time,
+ };
+
+ return normalizeChannel({
+ channel: {
+ ...channel,
+ keys: nextKeys,
+ } as AnimationChannel,
+ });
+}
+
+export function setChannel({
+ animations,
+ propertyPath,
+ channel,
+}: {
+ animations: ElementAnimations | undefined;
+ propertyPath: AnimationPath;
+ channel: AnimationChannel | undefined;
+}): ElementAnimations | undefined {
+ const binding = getBinding({ animations, propertyPath });
+ if (!binding) {
+ return animations;
+ }
+
+ if (binding.components.length !== 1) {
+ throw new Error(
+ `setChannel only supports single-component bindings. Received "${propertyPath}" with ${binding.components.length} components.`,
+ );
+ }
+
+ const primaryComponent = getPrimaryComponent({ binding });
+ if (!primaryComponent) {
+ return animations;
+ }
+
+ return setBindingComponentChannel({
+ animations,
+ propertyPath,
+ componentKey: primaryComponent.key,
+ channel,
+ });
+}
+
+export function setBindingComponentChannel({
+ animations,
+ propertyPath,
+ componentKey,
+ channel,
+}: {
+ animations: ElementAnimations | undefined;
+ propertyPath: AnimationPath;
+ componentKey: string;
+ channel: AnimationChannel | undefined;
+}): ElementAnimations | undefined {
+ const binding = getBinding({ animations, propertyPath });
+ if (!binding) {
+ return animations;
+ }
+
+ const component = getBindingComponent({
+ binding,
+ componentKey,
+ });
+ if (!component) {
+ return animations;
+ }
+
+ const nextAnimations = cloneAnimationsState({ animations });
+ if (!channel || !hasChannelKeys({ channel })) {
+ delete nextAnimations.channels[component.channelId];
+ const hasRemainingKeys = binding.components.some((candidate) =>
+ hasChannelKeys({
+ channel: nextAnimations.channels[candidate.channelId],
+ }),
+ );
+ if (!hasRemainingKeys) {
+ delete nextAnimations.bindings[propertyPath];
+ }
+ return toAnimation({
+ animations: nextAnimations,
+ });
+ }
+
+ nextAnimations.channels[component.channelId] = normalizeChannel({
+ channel,
+ });
+ return toAnimation({
+ animations: nextAnimations,
+ });
+}
+
+export function updateScalarKeyframeCurve({
+ animations,
+ propertyPath,
+ componentKey,
+ keyframeId,
+ patch,
+}: {
+ animations: ElementAnimations | undefined;
+ propertyPath: AnimationPath;
+ componentKey: string;
+ keyframeId: string;
+ patch: ScalarCurveKeyframePatch;
+}): ElementAnimations | undefined {
+ const binding = getBinding({ animations, propertyPath });
+ if (!binding) {
+ return animations;
+ }
+
+ const component = getBindingComponent({
+ binding,
+ componentKey,
+ });
+ if (!component) {
+ return animations;
+ }
+
+ const channel = getChannelById({
+ animations,
+ channelId: component.channelId,
+ });
+ if (channel?.kind !== "scalar") {
+ return animations;
+ }
+
+ const keyframeIndex = channel.keys.findIndex((keyframe) => keyframe.id === keyframeId);
+ if (keyframeIndex < 0) {
+ return animations;
+ }
+
+ const nextKeys = [...channel.keys];
+ const currentKey = nextKeys[keyframeIndex];
+ nextKeys[keyframeIndex] = {
+ ...currentKey,
+ leftHandle:
+ patch.leftHandle === undefined
+ ? currentKey.leftHandle
+ : patch.leftHandle ?? undefined,
+ rightHandle:
+ patch.rightHandle === undefined
+ ? currentKey.rightHandle
+ : patch.rightHandle ?? undefined,
+ segmentToNext: patch.segmentToNext ?? currentKey.segmentToNext,
+ tangentMode: patch.tangentMode ?? currentKey.tangentMode,
+ };
+
+ return setBindingComponentChannel({
+ animations,
+ propertyPath,
+ componentKey,
+ channel: {
+ kind: "scalar",
+ keys: nextKeys,
+ extrapolation: channel.extrapolation,
+ },
+ });
+}
+
+export function cloneAnimations({
+ animations,
+ shouldRegenerateKeyframeIds = false,
+}: {
+ animations: ElementAnimations | undefined;
+ shouldRegenerateKeyframeIds?: boolean;
+}): ElementAnimations | undefined {
+ if (!animations) {
+ return undefined;
+ }
+
+ const nextAnimations = cloneAnimationsState({ animations });
+ nextAnimations.bindings = Object.fromEntries(
+ Object.entries(animations.bindings).map(([path, binding]) => [
+ path,
+ binding ? cloneAnimationBinding({ binding }) : binding,
+ ]),
+ );
+ nextAnimations.channels = {};
+
+ for (const binding of Object.values(nextAnimations.bindings)) {
+ if (!binding) {
+ continue;
+ }
+
+ const primaryChannel = getChannelById({
+ animations,
+ channelId: getPrimaryChannelId({ binding }) ?? "",
+ });
+ const keyIdMap = new Map<string, string>();
+ if (primaryChannel) {
+ for (const key of primaryChannel.keys) {
+ keyIdMap.set(
+ key.id,
+ shouldRegenerateKeyframeIds ? generateUUID() : key.id,
+ );
+ }
+ }
+
+ for (const component of binding.components) {
+ const currentChannel = getChannelById({
+ animations,
+ channelId: component.channelId,
+ });
+ if (!currentChannel) {
+ continue;
+ }
+
+ nextAnimations.channels[component.channelId] = normalizeChannel({
+ channel: {
+ ...currentChannel,
+ keys: currentChannel.keys.map((key) => ({
+ ...key,
+ id: keyIdMap.get(key.id) ?? key.id,
+ })),
+ } as AnimationChannel,
+ });
+ }
+ }
+
+ return toAnimation({
+ animations: nextAnimations,
+ });
+}
+
+export function clampAnimationsToDuration({
+ animations,
+ duration,
+}: {
+ animations: ElementAnimations | undefined;
+ duration: number;
+}): ElementAnimations | undefined {
+ if (!animations || duration <= 0) {
+ return undefined;
+ }
+
+ return splitAnimationsAtTime({
+ animations,
+ splitTime: duration,
+ shouldIncludeSplitBoundary: true,
+ }).leftAnimations;
+}
+
+function lerpPoint({
+ left,
+ right,
+ progress,
+}: {
+ left: { x: number; y: number };
+ right: { x: number; y: number };
+ progress: number;
+}) {
+ return {
+ x: left.x + (right.x - left.x) * progress,
+ y: left.y + (right.y - left.y) * progress,
+ };
+}
+
+function splitDiscreteChannelAtTime({
+ channel,
+ splitTime,
+ leftBoundaryId,
+ rightBoundaryId,
+ shouldIncludeSplitBoundary,
+}: {
+ channel: DiscreteAnimationChannel | undefined;
+ splitTime: number;
+ leftBoundaryId: string;
+ rightBoundaryId: string;
+ shouldIncludeSplitBoundary: boolean;
+}) {
+ if (!channel || channel.keys.length === 0) {
+ return {
+ leftChannel: undefined,
+ rightChannel: undefined,
+ };
+ }
+
+ const normalizedChannel = normalizeChannel({ channel });
+ let leftKeys = normalizedChannel.keys.filter((key) => key.time <= splitTime);
+ let rightKeys = normalizedChannel.keys
+ .filter((key) => key.time >= splitTime)
+ .map((key) => ({ ...key, time: key.time - splitTime }));
+
+ if (shouldIncludeSplitBoundary) {
+ const hasBoundaryOnLeft = leftKeys.some((key) =>
+ isNearlySameTime({ leftTime: key.time, rightTime: splitTime }),
+ );
+ const hasBoundaryOnRight = rightKeys.some((key) =>
+ isNearlySameTime({ leftTime: key.time, rightTime: 0 }),
+ );
+ const boundaryValue = getChannelValueAtTime({
+ channel: normalizedChannel,
+ time: splitTime,
+ fallbackValue: normalizedChannel.keys[0].value,
+ });
+ if (!hasBoundaryOnLeft) {
+ leftKeys = [
+ ...leftKeys,
+ createDiscreteKey({
+ id: leftBoundaryId,
+ time: splitTime,
+ value: boundaryValue as string | boolean,
+ }),
+ ];
+ }
+ if (!hasBoundaryOnRight) {
+ rightKeys = [
+ createDiscreteKey({
+ id: rightBoundaryId,
+ time: 0,
+ value: boundaryValue as string | boolean,
+ }),
+ ...rightKeys,
+ ];
+ }
+ }
+
+ return {
+ leftChannel: leftKeys.length
+ ? normalizeChannel({ channel: { kind: "discrete", keys: leftKeys } })
+ : undefined,
+ rightChannel: rightKeys.length
+ ? normalizeChannel({ channel: { kind: "discrete", keys: rightKeys } })
+ : undefined,
+ };
+}
+
+function splitScalarChannelAtTime({
+ channel,
+ splitTime,
+ leftBoundaryId,
+ rightBoundaryId,
+ shouldIncludeSplitBoundary,
+}: {
+ channel: ScalarAnimationChannel | undefined;
+ splitTime: number;
+ leftBoundaryId: string;
+ rightBoundaryId: string;
+ shouldIncludeSplitBoundary: boolean;
+}) {
+ if (!channel || channel.keys.length === 0) {
+ return {
+ leftChannel: undefined,
+ rightChannel: undefined,
+ };
+ }
+
+ const normalizedChannel = normalizeChannel({ channel });
+ let leftKeys = normalizedChannel.keys.filter((key) => key.time <= splitTime);
+ let rightKeys = normalizedChannel.keys
+ .filter((key) => key.time >= splitTime)
+ .map((key) => ({ ...key, time: key.time - splitTime }));
+
+ const hasBoundaryOnLeft = leftKeys.some((key) =>
+ isNearlySameTime({ leftTime: key.time, rightTime: splitTime }),
+ );
+ const hasBoundaryOnRight = rightKeys.some((key) =>
+ isNearlySameTime({ leftTime: key.time, rightTime: 0 }),
+ );
+ if (!shouldIncludeSplitBoundary || (hasBoundaryOnLeft && hasBoundaryOnRight)) {
+ return {
+ leftChannel: leftKeys.length
+ ? normalizeChannel({
+ channel: {
+ kind: "scalar",
+ keys: leftKeys,
+ extrapolation: normalizedChannel.extrapolation,
+ },
+ })
+ : undefined,
+ rightChannel: rightKeys.length
+ ? normalizeChannel({
+ channel: {
+ kind: "scalar",
+ keys: rightKeys,
+ extrapolation: normalizedChannel.extrapolation,
+ },
+ })
+ : undefined,
+ };
+ }
+
+ for (let keyIndex = 0; keyIndex < normalizedChannel.keys.length - 1; keyIndex++) {
+ const leftKey = normalizedChannel.keys[keyIndex];
+ const rightKey = normalizedChannel.keys[keyIndex + 1];
+ if (
+ !(
+ splitTime > leftKey.time &&
+ splitTime < rightKey.time
+ )
+ ) {
+ continue;
+ }
+
+ const boundaryValue = getChannelValueAtTime({
+ channel: normalizedChannel,
+ time: splitTime,
+ fallbackValue: leftKey.value,
+ }) as number;
+
+ if (leftKey.segmentToNext === "bezier") {
+ const rightHandle =
+ leftKey.rightHandle ?? getDefaultRightHandle({ leftKey, rightKey });
+ const leftHandle =
+ rightKey.leftHandle ?? getDefaultLeftHandle({ leftKey, rightKey });
+ const progress = solveBezierProgressForTime({
+ time: splitTime,
+ leftKey,
+ rightKey,
+ });
+ const p0 = { x: leftKey.time, y: leftKey.value };
+ const p1 = {
+ x: leftKey.time + rightHandle.dt,
+ y: leftKey.value + rightHandle.dv,
+ };
+ const p2 = {
+ x: rightKey.time + leftHandle.dt,
+ y: rightKey.value + leftHandle.dv,
+ };
+ const p3 = { x: rightKey.time, y: rightKey.value };
+ const q0 = lerpPoint({ left: p0, right: p1, progress });
+ const q1 = lerpPoint({ left: p1, right: p2, progress });
+ const q2 = lerpPoint({ left: p2, right: p3, progress });
+ const r0 = lerpPoint({ left: q0, right: q1, progress });
+ const r1 = lerpPoint({ left: q1, right: q2, progress });
+ const splitPoint = lerpPoint({ left: r0, right: r1, progress });
+ leftKeys = [
+ ...normalizedChannel.keys.filter((key) => key.time < splitTime),
+ {
+ ...leftKey,
+ rightHandle: {
+ dt: q0.x - p0.x,
+ dv: q0.y - p0.y,
+ },
+ },
+ {
+ id: leftBoundaryId,
+ time: splitTime,
+ value: boundaryValue,
+ leftHandle: {
+ dt: r0.x - splitPoint.x,
+ dv: r0.y - splitPoint.y,
+ },
+ segmentToNext: leftKey.segmentToNext,
+ tangentMode: leftKey.tangentMode,
+ },
+ ];
+ rightKeys = [
+ {
+ id: rightBoundaryId,
+ time: 0,
+ value: boundaryValue,
+ rightHandle: {
+ dt: r1.x - splitPoint.x,
+ dv: r1.y - splitPoint.y,
+ },
+ segmentToNext: "bezier",
+ tangentMode: leftKey.tangentMode,
+ },
+ {
+ ...rightKey,
+ time: rightKey.time - splitTime,
+ leftHandle: {
+ dt: q2.x - p3.x,
+ dv: q2.y - p3.y,
+ },
+ },
+ ...normalizedChannel.keys
+ .filter((key) => key.time > rightKey.time)
+ .map((key) => ({
+ ...key,
+ time: key.time - splitTime,
+ })),
+ ];
+ } else {
+ leftKeys = [
+ ...leftKeys,
+ createScalarKey({
+ id: leftBoundaryId,
+ time: splitTime,
+ value: boundaryValue,
+ interpolation: "linear",
+ }),
+ ];
+ rightKeys = [
+ createScalarKey({
+ id: rightBoundaryId,
+ time: 0,
+ value: boundaryValue,
+ interpolation: getScalarSegmentInterpolation({
+ segment: leftKey.segmentToNext,
+ }),
+ }),
+ ...rightKeys,
+ ];
+ }
+
+ return {
+ leftChannel: normalizeChannel({
+ channel: {
+ kind: "scalar",
+ keys: leftKeys,
+ extrapolation: normalizedChannel.extrapolation,
+ },
+ }),
+ rightChannel: normalizeChannel({
+ channel: {
+ kind: "scalar",
+ keys: rightKeys,
+ extrapolation: normalizedChannel.extrapolation,
+ },
+ }),
+ };
+ }
+
+ return {
+ leftChannel: leftKeys.length
+ ? normalizeChannel({
+ channel: {
+ kind: "scalar",
+ keys: leftKeys,
+ extrapolation: normalizedChannel.extrapolation,
+ },
+ })
+ : undefined,
+ rightChannel: rightKeys.length
+ ? normalizeChannel({
+ channel: {
+ kind: "scalar",
+ keys: rightKeys,
+ extrapolation: normalizedChannel.extrapolation,
+ },
+ })
+ : undefined,
+ };
+}
+
+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 leftAnimations = cloneAnimationsState({ animations: undefined });
+ const rightAnimations = cloneAnimationsState({ animations: undefined });
+
+ for (const [propertyPath, binding] of Object.entries(animations.bindings)) {
+ if (!binding) {
+ continue;
+ }
+
+ const leftBinding = cloneAnimationBinding({ binding });
+ const rightBinding = cloneAnimationBinding({ binding });
+ const leftBoundaryId = generateUUID();
+ const rightBoundaryId = generateUUID();
+ let hasLeftKeys = false;
+ let hasRightKeys = false;
+
+ for (const component of binding.components) {
+ const channel = getChannelById({
+ animations,
+ channelId: component.channelId,
+ });
+ const splitResult =
+ channel?.kind === "discrete"
+ ? splitDiscreteChannelAtTime({
+ channel,
+ splitTime,
+ leftBoundaryId,
+ rightBoundaryId,
+ shouldIncludeSplitBoundary,
+ })
+ : splitScalarChannelAtTime({
+ channel: channel as ScalarAnimationChannel | undefined,
+ splitTime,
+ leftBoundaryId,
+ rightBoundaryId,
+ shouldIncludeSplitBoundary,
+ });
+ if (splitResult.leftChannel) {
+ leftAnimations.channels[component.channelId] = splitResult.leftChannel;
+ hasLeftKeys = true;
+ }
+ if (splitResult.rightChannel) {
+ rightAnimations.channels[component.channelId] = splitResult.rightChannel;
+ hasRightKeys = true;
+ }
+ }
+
+ if (hasLeftKeys) {
+ leftAnimations.bindings[propertyPath] = leftBinding;
+ }
+ if (hasRightKeys) {
+ rightAnimations.bindings[propertyPath] = rightBinding;
+ }
+ }
+
+ return {
+ leftAnimations: toAnimation({ animations: leftAnimations }),
+ rightAnimations: toAnimation({ animations: rightAnimations }),
+ };
+}
+
+export function removeElementKeyframe({
+ animations,
+ propertyPath,
+ keyframeId,
+}: {
+ animations: ElementAnimations | undefined;
+ propertyPath: AnimationPath;
+ keyframeId: string;
+}): ElementAnimations | undefined {
+ const binding = getBinding({ animations, propertyPath });
+ if (!binding) {
+ return animations;
+ }
+
+ const nextAnimations = cloneAnimationsState({ animations });
+ for (const component of binding.components) {
+ nextAnimations.channels[component.channelId] = removeKeyframe({
+ channel: nextAnimations.channels[component.channelId],
+ keyframeId,
+ });
+ }
+ const hasRemainingKeys = binding.components.some((component) =>
+ hasChannelKeys({
+ channel: nextAnimations.channels[component.channelId],
+ }),
+ );
+ if (!hasRemainingKeys) {
+ delete nextAnimations.bindings[propertyPath];
+ for (const component of binding.components) {
+ delete nextAnimations.channels[component.channelId];
+ }
+ }
+ return toAnimation({
+ animations: nextAnimations,
+ });
+}
+
+export function retimeElementKeyframe({
+ animations,
+ propertyPath,
+ keyframeId,
+ time,
+}: {
+ animations: ElementAnimations | undefined;
+ propertyPath: AnimationPath;
+ keyframeId: string;
+ time: number;
+}): ElementAnimations | undefined {
+ const binding = getBinding({ animations, propertyPath });
+ if (!binding) {
+ return animations;
+ }
+
+ const nextAnimations = cloneAnimationsState({ animations });
+ for (const component of binding.components) {
+ nextAnimations.channels[component.channelId] = retimeKeyframe({
+ channel: nextAnimations.channels[component.channelId],
+ keyframeId,
+ time,
+ });
+ }
+ return toAnimation({
+ animations: nextAnimations,
+ });
+}
diff --git a/apps/web/src/animation/property-groups.ts b/apps/web/src/animation/property-groups.ts
new file mode 100644
index 00000000..7b71af15
--- /dev/null
+++ b/apps/web/src/animation/property-groups.ts
@@ -0,0 +1,39 @@
+import type {
+ AnimationPropertyGroup,
+ AnimationPropertyPath,
+ ElementAnimations,
+} from "@/animation/types";
+import { ANIMATION_PROPERTY_GROUPS } from "@/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/animation/property-registry.ts b/apps/web/src/animation/property-registry.ts
new file mode 100644
index 00000000..aacb9972
--- /dev/null
+++ b/apps/web/src/animation/property-registry.ts
@@ -0,0 +1,390 @@
+import type {
+ AnimationBindingKind,
+ AnimationInterpolation,
+ AnimationPropertyPath,
+ AnimationValue,
+} from "@/animation/types";
+import { parseColorToLinearRgba } from "./binding-values";
+import type { TimelineElement } from "@/timeline";
+import { MIN_TRANSFORM_SCALE } from "@/animation/transform";
+import {
+ CORNER_RADIUS_MAX,
+ CORNER_RADIUS_MIN,
+} from "@/text/background";
+import {
+ canElementHaveAudio,
+ isVisualElement,
+} from "@/timeline/element-utils";
+import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/timeline/audio-constants";
+import { DEFAULTS } from "@/timeline/defaults";
+import { snapToStep } from "@/utils/math";
+
+export interface NumericSpec {
+ min?: number;
+ max?: number;
+ step?: number;
+}
+
+export interface AnimationPropertyDefinition {
+ kind: AnimationBindingKind;
+ defaultInterpolation: AnimationInterpolation;
+ numericRanges?: Partial<Record<string, NumericSpec>>;
+ supportsElement: ({ element }: { element: TimelineElement }) => boolean;
+ getValue: ({ element }: { element: TimelineElement }) => AnimationValue | null;
+ coerceValue: ({ value }: { value: AnimationValue }) => AnimationValue | null;
+ setValue: ({
+ element,
+ value,
+ }: {
+ element: TimelineElement;
+ value: AnimationValue;
+ }) => TimelineElement;
+}
+
+function applyNumericSpec({
+ value,
+ numericRange,
+}: {
+ value: number;
+ 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, steppedValue));
+}
+
+function coerceNumberValue({
+ value,
+ numericRange,
+}: {
+ value: AnimationValue;
+ numericRange?: NumericSpec;
+}): number | null {
+ if (typeof value !== "number" || Number.isNaN(value)) {
+ return null;
+ }
+
+ return applyNumericSpec({ value, numericRange });
+}
+
+function coerceColorValue({
+ value,
+}: {
+ value: AnimationValue;
+}): string | null {
+ return typeof value === "string" && parseColorToLinearRgba({ color: value })
+ ? value
+ : null;
+}
+
+function createNumberPropertyDefinition({
+ numericRange,
+ supportsElement,
+ getValue,
+ setValue,
+}: {
+ numericRange?: NumericSpec;
+ supportsElement: AnimationPropertyDefinition["supportsElement"];
+ getValue: AnimationPropertyDefinition["getValue"];
+ setValue: AnimationPropertyDefinition["setValue"];
+}): AnimationPropertyDefinition {
+ return {
+ kind: "number",
+ defaultInterpolation: "linear",
+ numericRanges: numericRange ? { value: numericRange } : undefined,
+ supportsElement,
+ getValue,
+ coerceValue: ({ value }) =>
+ coerceNumberValue({
+ value,
+ numericRange,
+ }),
+ setValue,
+ };
+}
+
+const ANIMATION_PROPERTY_REGISTRY: Record<
+ AnimationPropertyPath,
+ AnimationPropertyDefinition
+> = {
+ "transform.positionX": createNumberPropertyDefinition({
+ numericRange: { step: 1 },
+ 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 as number },
+ },
+ }
+ : element,
+ }),
+ "transform.positionY": createNumberPropertyDefinition({
+ numericRange: { step: 1 },
+ supportsElement: ({ element }) => isVisualElement(element),
+ getValue: ({ element }) =>
+ isVisualElement(element) ? element.transform.position.y : null,
+ setValue: ({ element, value }) =>
+ isVisualElement(element)
+ ? {
+ ...element,
+ transform: {
+ ...element.transform,
+ position: { ...element.transform.position, y: value as number },
+ },
+ }
+ : element,
+ }),
+ "transform.scaleX": createNumberPropertyDefinition({
+ numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 },
+ supportsElement: ({ element }) => isVisualElement(element),
+ getValue: ({ element }) =>
+ isVisualElement(element) ? element.transform.scaleX : null,
+ setValue: ({ element, value }) =>
+ isVisualElement(element)
+ ? {
+ ...element,
+ transform: { ...element.transform, scaleX: value as number },
+ }
+ : element,
+ }),
+ "transform.scaleY": createNumberPropertyDefinition({
+ numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 },
+ supportsElement: ({ element }) => isVisualElement(element),
+ getValue: ({ element }) =>
+ isVisualElement(element) ? element.transform.scaleY : null,
+ setValue: ({ element, value }) =>
+ isVisualElement(element)
+ ? {
+ ...element,
+ transform: { ...element.transform, scaleY: value as number },
+ }
+ : element,
+ }),
+ "transform.rotate": createNumberPropertyDefinition({
+ numericRange: { min: -360, max: 360, step: 1 },
+ supportsElement: ({ element }) => isVisualElement(element),
+ getValue: ({ element }) =>
+ isVisualElement(element) ? element.transform.rotate : null,
+ setValue: ({ element, value }) =>
+ isVisualElement(element)
+ ? {
+ ...element,
+ transform: { ...element.transform, rotate: value as number },
+ }
+ : element,
+ }),
+ opacity: createNumberPropertyDefinition({
+ numericRange: { min: 0, max: 1, step: 0.01 },
+ supportsElement: ({ element }) => isVisualElement(element),
+ getValue: ({ element }) =>
+ isVisualElement(element) ? element.opacity : null,
+ setValue: ({ element, value }) =>
+ isVisualElement(element)
+ ? { ...element, opacity: value as number }
+ : element,
+ }),
+ volume: createNumberPropertyDefinition({
+ numericRange: { min: VOLUME_DB_MIN, max: VOLUME_DB_MAX, step: 0.01 },
+ supportsElement: ({ element }) => canElementHaveAudio(element),
+ getValue: ({ element }) =>
+ canElementHaveAudio(element) ? element.volume ?? 0 : null,
+ setValue: ({ element, value }) =>
+ canElementHaveAudio(element)
+ ? { ...element, volume: value as number }
+ : element,
+ }),
+ color: {
+ kind: "color",
+ defaultInterpolation: "linear",
+ supportsElement: ({ element }) => element.type === "text",
+ getValue: ({ element }) => (element.type === "text" ? element.color : null),
+ coerceValue: ({ value }) => coerceColorValue({ value }),
+ setValue: ({ element, value }) =>
+ element.type === "text"
+ ? { ...element, color: value as string }
+ : element,
+ },
+ "background.color": {
+ kind: "color",
+ defaultInterpolation: "linear",
+ supportsElement: ({ element }) => element.type === "text",
+ getValue: ({ element }) =>
+ element.type === "text" ? element.background.color : null,
+ coerceValue: ({ value }) => coerceColorValue({ value }),
+ setValue: ({ element, value }) =>
+ element.type === "text"
+ ? {
+ ...element,
+ background: { ...element.background, color: value as string },
+ }
+ : element,
+ },
+ "background.paddingX": createNumberPropertyDefinition({
+ numericRange: { min: 0, step: 1 },
+ supportsElement: ({ element }) => element.type === "text",
+ getValue: ({ element }) =>
+ element.type === "text"
+ ? (element.background.paddingX ?? DEFAULTS.text.background.paddingX)
+ : null,
+ setValue: ({ element, value }) =>
+ element.type === "text"
+ ? {
+ ...element,
+ background: { ...element.background, paddingX: value as number },
+ }
+ : element,
+ }),
+ "background.paddingY": createNumberPropertyDefinition({
+ numericRange: { min: 0, step: 1 },
+ supportsElement: ({ element }) => element.type === "text",
+ getValue: ({ element }) =>
+ element.type === "text"
+ ? (element.background.paddingY ?? DEFAULTS.text.background.paddingY)
+ : null,
+ setValue: ({ element, value }) =>
+ element.type === "text"
+ ? {
+ ...element,
+ background: { ...element.background, paddingY: value as number },
+ }
+ : element,
+ }),
+ "background.offsetX": createNumberPropertyDefinition({
+ numericRange: { step: 1 },
+ supportsElement: ({ element }) => element.type === "text",
+ getValue: ({ element }) =>
+ element.type === "text"
+ ? (element.background.offsetX ?? DEFAULTS.text.background.offsetX)
+ : null,
+ setValue: ({ element, value }) =>
+ element.type === "text"
+ ? {
+ ...element,
+ background: { ...element.background, offsetX: value as number },
+ }
+ : element,
+ }),
+ "background.offsetY": createNumberPropertyDefinition({
+ numericRange: { step: 1 },
+ supportsElement: ({ element }) => element.type === "text",
+ getValue: ({ element }) =>
+ element.type === "text"
+ ? (element.background.offsetY ?? DEFAULTS.text.background.offsetY)
+ : null,
+ setValue: ({ element, value }) =>
+ element.type === "text"
+ ? {
+ ...element,
+ background: { ...element.background, offsetY: value as number },
+ }
+ : element,
+ }),
+ "background.cornerRadius": createNumberPropertyDefinition({
+ numericRange: {
+ min: CORNER_RADIUS_MIN,
+ max: CORNER_RADIUS_MAX,
+ step: 1,
+ },
+ supportsElement: ({ element }) => element.type === "text",
+ getValue: ({ element }) =>
+ element.type === "text"
+ ? (element.background.cornerRadius ?? CORNER_RADIUS_MIN)
+ : null,
+ setValue: ({ element, value }) =>
+ element.type === "text"
+ ? {
+ ...element,
+ background: { ...element.background, cornerRadius: value as number },
+ }
+ : element,
+ }),
+};
+
+export function isAnimationPropertyPath(
+ propertyPath: string,
+): propertyPath is AnimationPropertyPath {
+ return Object.hasOwn(ANIMATION_PROPERTY_REGISTRY, propertyPath);
+}
+
+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 definition = getAnimationPropertyDefinition({ propertyPath });
+ const coercedValue = definition.coerceValue({ value });
+ if (coercedValue === null || !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;
+}
+
+export function coerceAnimationValueForProperty({
+ propertyPath,
+ value,
+}: {
+ propertyPath: AnimationPropertyPath;
+ value: AnimationValue;
+}): AnimationValue | null {
+ const propertyDefinition = getAnimationPropertyDefinition({ propertyPath });
+ return propertyDefinition.coerceValue({ value });
+}
diff --git a/apps/web/src/animation/resolve.ts b/apps/web/src/animation/resolve.ts
new file mode 100644
index 00000000..1b51c126
--- /dev/null
+++ b/apps/web/src/animation/resolve.ts
@@ -0,0 +1,184 @@
+import type {
+ AnimationColorPropertyPath,
+ AnimationNumericPropertyPath,
+ AnimationPath,
+ AnimationPropertyPath,
+ AnimationValueForPath,
+ ElementAnimations,
+} from "@/animation/types";
+import type { Transform } from "@/rendering";
+import {
+ type AnimationComponentValue,
+ composeAnimationValue,
+ decomposeAnimationValue,
+} from "./binding-values";
+import {
+ getChannelValueAtTime,
+} from "./interpolation";
+
+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: resolveAnimationPathValueAtTime({
+ animations,
+ propertyPath: "transform.positionX",
+ localTime: safeLocalTime,
+ fallbackValue: baseTransform.position.x,
+ }),
+ y: resolveAnimationPathValueAtTime({
+ animations,
+ propertyPath: "transform.positionY",
+ localTime: safeLocalTime,
+ fallbackValue: baseTransform.position.y,
+ }),
+ },
+ scaleX: resolveAnimationPathValueAtTime({
+ animations,
+ propertyPath: "transform.scaleX",
+ localTime: safeLocalTime,
+ fallbackValue: baseTransform.scaleX,
+ }),
+ scaleY: resolveAnimationPathValueAtTime({
+ animations,
+ propertyPath: "transform.scaleY",
+ localTime: safeLocalTime,
+ fallbackValue: baseTransform.scaleY,
+ }),
+ rotate: resolveAnimationPathValueAtTime({
+ animations,
+ propertyPath: "transform.rotate",
+ localTime: safeLocalTime,
+ fallbackValue: baseTransform.rotate,
+ }),
+ };
+}
+
+export function resolveOpacityAtTime({
+ baseOpacity,
+ animations,
+ localTime,
+}: {
+ baseOpacity: number;
+ animations: ElementAnimations | undefined;
+ localTime: number;
+}): number {
+ return resolveAnimationPathValueAtTime({
+ animations,
+ propertyPath: "opacity",
+ localTime: Math.max(0, localTime),
+ fallbackValue: baseOpacity,
+ });
+}
+
+export function resolveNumberAtTime({
+ baseValue,
+ animations,
+ propertyPath,
+ localTime,
+}: {
+ baseValue: number;
+ animations: ElementAnimations | undefined;
+ propertyPath: AnimationNumericPropertyPath;
+ localTime: number;
+}): number {
+ return resolveAnimationPathValueAtTime({
+ animations,
+ propertyPath,
+ localTime: Math.max(0, localTime),
+ fallbackValue: baseValue,
+ });
+}
+
+export function resolveColorAtTime({
+ baseColor,
+ animations,
+ propertyPath,
+ localTime,
+}: {
+ baseColor: string;
+ animations: ElementAnimations | undefined;
+ propertyPath: AnimationColorPropertyPath;
+ localTime: number;
+}): string {
+ return resolveAnimationPathValueAtTime({
+ animations,
+ propertyPath,
+ localTime: Math.max(0, localTime),
+ fallbackValue: baseColor,
+ });
+}
+
+export function resolveAnimationPathValueAtTime<TPath extends AnimationPath>({
+ animations,
+ propertyPath,
+ localTime,
+ fallbackValue,
+}: {
+ animations: ElementAnimations | undefined;
+ propertyPath: TPath;
+ localTime: number;
+ fallbackValue: AnimationValueForPath<TPath>;
+}): AnimationValueForPath<TPath> {
+ const binding = animations?.bindings[propertyPath];
+ if (!binding) {
+ return fallbackValue;
+ }
+
+ const fallbackComponents = decomposeAnimationValue({
+ kind: binding.kind,
+ value: fallbackValue,
+ });
+ if (!fallbackComponents) {
+ return fallbackValue;
+ }
+
+ const componentValues = Object.fromEntries(
+ binding.components.map((component) => {
+ const channel = animations?.channels[component.channelId];
+ return [
+ component.key,
+ getChannelValueAtTime({
+ channel,
+ time: localTime,
+ fallbackValue:
+ fallbackComponents[component.key] ??
+ (channel?.kind === "discrete" ? false : 0),
+ }),
+ ];
+ }),
+ ) as Record<string, AnimationComponentValue | undefined>;
+ return (composeAnimationValue({
+ binding,
+ componentValues,
+ }) ?? fallbackValue) as AnimationValueForPath<TPath>;
+}
diff --git a/apps/web/src/animation/target-resolver.ts b/apps/web/src/animation/target-resolver.ts
new file mode 100644
index 00000000..32e7917c
--- /dev/null
+++ b/apps/web/src/animation/target-resolver.ts
@@ -0,0 +1,285 @@
+import type {
+ AnimationBindingKind,
+ AnimationInterpolation,
+ AnimationPath,
+ AnimationValue,
+} from "@/animation/types";
+import {
+ parseEffectParamPath,
+ isEffectParamPath,
+} from "@/animation/effect-param-channel";
+import {
+ isGraphicParamPath,
+ parseGraphicParamPath,
+} from "@/animation/graphic-param-channel";
+import type { ParamDefinition } from "@/params";
+import { effectsRegistry, registerDefaultEffects } from "@/effects";
+import { getGraphicDefinition } from "@/graphics";
+import type { TimelineElement } from "@/timeline";
+import { isVisualElement } from "@/timeline/element-utils";
+import { snapToStep } from "@/utils/math";
+import {
+ coerceAnimationValueForProperty,
+ getAnimationPropertyDefinition,
+ getElementBaseValueForProperty,
+ isAnimationPropertyPath,
+ type NumericSpec,
+ withElementBaseValueForProperty,
+} from "./property-registry";
+import { parseColorToLinearRgba } from "./binding-values";
+
+export interface AnimationPathDescriptor {
+ kind: AnimationBindingKind;
+ defaultInterpolation: AnimationInterpolation;
+ numericRanges?: Partial<Record<string, NumericSpec>>;
+ coerceValue: ({ value }: { value: AnimationValue }) => AnimationValue | null;
+ getBaseValue: () => AnimationValue | null;
+ setBaseValue: ({ value }: { value: AnimationValue }) => TimelineElement;
+}
+
+export function getParamValueKind({
+ param,
+}: {
+ param: ParamDefinition;
+}): AnimationBindingKind {
+ 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;
+}): Partial<Record<string, NumericSpec>> | undefined {
+ if (param.type !== "number") {
+ return undefined;
+ }
+
+ return {
+ value: {
+ min: param.min,
+ max: param.max,
+ step: param.step,
+ },
+ };
+}
+
+export function coerceAnimationValueForParam({
+ 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 {
+ kind: getParamValueKind({ param }),
+ defaultInterpolation: getParamDefaultInterpolation({ param }),
+ numericRanges: getParamNumericRange({ param }),
+ coerceValue: ({ value }) => coerceAnimationValueForParam({ param, value }),
+ getBaseValue: () => element.params[param.key] ?? param.default,
+ setBaseValue: ({ value }) => {
+ const coercedValue = coerceAnimationValueForParam({ 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 {
+ kind: getParamValueKind({ param }),
+ defaultInterpolation: getParamDefaultInterpolation({ param }),
+ numericRanges: getParamNumericRange({ param }),
+ coerceValue: ({ value }) => coerceAnimationValueForParam({ param, value }),
+ getBaseValue: () => effect.params[param.key] ?? param.default,
+ setBaseValue: ({ value }) => {
+ const coercedValue = coerceAnimationValueForParam({ 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: string,
+): propertyPath is AnimationPath {
+ return (
+ isAnimationPropertyPath(propertyPath) ||
+ isGraphicParamPath(propertyPath) ||
+ isEffectParamPath(propertyPath)
+ );
+}
+
+export function resolveAnimationTarget({
+ element,
+ path,
+}: {
+ element: TimelineElement;
+ path: AnimationPath;
+}): AnimationPathDescriptor | null {
+ if (isAnimationPropertyPath(path)) {
+ const propertyDefinition = getAnimationPropertyDefinition({
+ propertyPath: path,
+ });
+ if (!propertyDefinition.supportsElement({ element })) {
+ return null;
+ }
+
+ return {
+ kind: propertyDefinition.kind,
+ defaultInterpolation: propertyDefinition.defaultInterpolation,
+ numericRanges: propertyDefinition.numericRanges,
+ coerceValue: ({ value }) =>
+ coerceAnimationValueForProperty({
+ propertyPath: path,
+ value,
+ }),
+ getBaseValue: () =>
+ getElementBaseValueForProperty({
+ element,
+ propertyPath: path,
+ }),
+ setBaseValue: ({ value }) => {
+ const coercedValue = propertyDefinition.coerceValue({ 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/animation/timeline-snap-points.ts b/apps/web/src/animation/timeline-snap-points.ts
new file mode 100644
index 00000000..f7bf549a
--- /dev/null
+++ b/apps/web/src/animation/timeline-snap-points.ts
@@ -0,0 +1,35 @@
+import { getElementKeyframes } from "@/animation";
+import type { SceneTracks } from "@/timeline";
+import type { SnapPoint } from "@/timeline/snapping";
+
+export function getAnimationKeyframeSnapPointsForTimeline({
+ tracks,
+ excludeElementIds,
+}: {
+ tracks: SceneTracks;
+ excludeElementIds?: Set<string>;
+}): SnapPoint[] {
+ const snapPoints: SnapPoint[] = [];
+ const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio];
+
+ for (const track of orderedTracks) {
+ for (const element of track.elements) {
+ if (excludeElementIds?.has(element.id)) {
+ continue;
+ }
+
+ for (const keyframe of getElementKeyframes({
+ animations: element.animations,
+ })) {
+ snapPoints.push({
+ time: element.startTime + keyframe.time,
+ type: "keyframe",
+ elementId: element.id,
+ trackId: track.id,
+ });
+ }
+ }
+ }
+
+ return snapPoints;
+}
diff --git a/apps/web/src/animation/transform.ts b/apps/web/src/animation/transform.ts
new file mode 100644
index 00000000..ab932ea2
--- /dev/null
+++ b/apps/web/src/animation/transform.ts
@@ -0,0 +1 @@
+export const MIN_TRANSFORM_SCALE = 0.01;
diff --git a/apps/web/src/animation/types.ts b/apps/web/src/animation/types.ts
new file mode 100644
index 00000000..3afddfa1
--- /dev/null
+++ b/apps/web/src/animation/types.ts
@@ -0,0 +1,222 @@
+import type { ParamValues } from "@/params";
+
+export const ANIMATION_PROPERTY_PATHS = [
+ "transform.positionX",
+ "transform.positionY",
+ "transform.scaleX",
+ "transform.scaleY",
+ "transform.rotate",
+ "opacity",
+ "volume",
+ "color",
+ "background.color",
+ "background.paddingX",
+ "background.paddingY",
+ "background.offsetX",
+ "background.offsetY",
+ "background.cornerRadius",
+] as const;
+
+export type AnimationPropertyPath = (typeof ANIMATION_PROPERTY_PATHS)[number];
+export type GraphicParamPath = `params.${string}`;
+export type EffectParamPath = `effects.${string}.params.${string}`;
+export type AnimationPath =
+ | AnimationPropertyPath
+ | GraphicParamPath
+ | EffectParamPath;
+
+export const ANIMATION_PROPERTY_GROUPS = {
+ "transform.scale": ["transform.scaleX", "transform.scaleY"],
+} as const satisfies Record<string, ReadonlyArray<AnimationPropertyPath>>;
+
+export type AnimationPropertyGroup = keyof typeof ANIMATION_PROPERTY_GROUPS;
+
+export type VectorValue = { x: number; y: number };
+export type DiscreteValue = boolean | string;
+export type AnimationValue = number | string | boolean | VectorValue;
+export interface AnimationPropertyValueMap {
+ "transform.positionX": number;
+ "transform.positionY": number;
+ "transform.scaleX": number;
+ "transform.scaleY": number;
+ "transform.rotate": number;
+ opacity: number;
+ volume: number;
+ color: string;
+ "background.color": string;
+ "background.paddingX": number;
+ "background.paddingY": number;
+ "background.offsetX": number;
+ "background.offsetY": number;
+ "background.cornerRadius": number;
+}
+export type DynamicAnimationPathValue = ParamValues[string];
+export type AnimationValueForPath<TPath extends AnimationPath> =
+ TPath extends AnimationPropertyPath
+ ? AnimationPropertyValueMap[TPath]
+ : TPath extends GraphicParamPath | EffectParamPath
+ ? DynamicAnimationPathValue
+ : never;
+export type AnimationNumericPropertyPath = {
+ [K in AnimationPropertyPath]: AnimationValueForPath<K> extends number ? K : never;
+}[AnimationPropertyPath];
+export type AnimationColorPropertyPath = {
+ [K in AnimationPropertyPath]: AnimationValueForPath<K> extends string ? K : never;
+}[AnimationPropertyPath];
+
+export type ContinuousKeyframeInterpolation = "linear" | "hold" | "bezier";
+export type DiscreteKeyframeInterpolation = "hold";
+export type AnimationInterpolation =
+ | ContinuousKeyframeInterpolation
+ | DiscreteKeyframeInterpolation;
+
+export type PrimitiveAnimationChannelKind = "scalar" | "discrete";
+export type AnimationBindingKind = "number" | "vector2" | "color" | "discrete";
+export type ScalarSegmentType = "step" | "linear" | "bezier";
+export type TangentMode = "auto" | "aligned" | "broken" | "flat";
+export type ChannelExtrapolationMode = "hold" | "linear";
+
+export interface CurveHandle {
+ dt: number;
+ dv: number;
+}
+
+interface BaseAnimationKeyframe<TValue extends number | DiscreteValue> {
+ id: string;
+ time: number; // relative to element start time
+ value: TValue;
+}
+
+export interface ScalarAnimationKey extends BaseAnimationKeyframe<number> {
+ leftHandle?: CurveHandle;
+ rightHandle?: CurveHandle;
+ segmentToNext: ScalarSegmentType;
+ tangentMode: TangentMode;
+}
+
+export interface DiscreteAnimationKey
+ extends BaseAnimationKeyframe<DiscreteValue> {}
+
+export type AnimationKeyframe = ScalarAnimationKey | DiscreteAnimationKey;
+
+export interface ScalarAnimationChannel {
+ kind: "scalar";
+ keys: ScalarAnimationKey[];
+ extrapolation?: {
+ before: ChannelExtrapolationMode;
+ after: ChannelExtrapolationMode;
+ };
+}
+
+export interface DiscreteAnimationChannel {
+ kind: "discrete";
+ keys: DiscreteAnimationKey[];
+}
+
+export type AnimationChannel =
+ | ScalarAnimationChannel
+ | DiscreteAnimationChannel;
+
+export type ElementAnimationChannelMap = Record<
+ string,
+ AnimationChannel | undefined
+>;
+
+export interface AnimationBindingComponent<TKey extends string = string> {
+ key: TKey;
+ channelId: string;
+}
+
+interface BaseAnimationBinding<
+ TKind extends AnimationBindingKind,
+ TComponentKey extends string,
+> {
+ path: AnimationPath;
+ kind: TKind;
+ components: AnimationBindingComponent<TComponentKey>[];
+}
+
+export interface NumberAnimationBinding
+ extends BaseAnimationBinding<"number", "value"> {}
+
+export interface Vector2AnimationBinding
+ extends BaseAnimationBinding<"vector2", "x" | "y"> {}
+
+export interface ColorAnimationBinding
+ extends BaseAnimationBinding<"color", "r" | "g" | "b" | "a"> {
+ colorSpace: "srgb-linear";
+}
+
+export interface DiscreteAnimationBinding
+ extends BaseAnimationBinding<"discrete", "value"> {}
+
+export type AnimationBindingInstance =
+ | NumberAnimationBinding
+ | Vector2AnimationBinding
+ | ColorAnimationBinding
+ | DiscreteAnimationBinding;
+
+export interface AnimationBindingByKind {
+ number: NumberAnimationBinding;
+ vector2: Vector2AnimationBinding;
+ color: ColorAnimationBinding;
+ discrete: DiscreteAnimationBinding;
+}
+
+export type AnimationBindingOfKind<TKind extends AnimationBindingKind> =
+ AnimationBindingByKind[TKind];
+
+export type ElementAnimationBindingMap = Record<
+ string,
+ AnimationBindingInstance | undefined
+>;
+
+export interface ElementAnimations {
+ bindings: ElementAnimationBindingMap;
+ channels: ElementAnimationChannelMap;
+}
+
+export type NormalizedCubicBezier = [number, number, number, number];
+
+export interface ScalarGraphChannelTarget {
+ propertyPath: AnimationPath;
+ componentKey: string;
+ channelId: string;
+}
+
+export interface ScalarGraphChannel extends ScalarGraphChannelTarget {
+ channel: ScalarAnimationChannel;
+}
+
+export interface ScalarGraphKeyframeRef extends ScalarGraphChannelTarget {
+ keyframeId: string;
+}
+
+export interface ScalarGraphKeyframeContext extends ScalarGraphChannel {
+ keyframe: ScalarAnimationKey;
+ keyframeIndex: number;
+ previousKey: ScalarAnimationKey | null;
+ nextKey: ScalarAnimationKey | null;
+}
+
+export interface ScalarCurveKeyframePatch {
+ leftHandle?: CurveHandle | null;
+ rightHandle?: CurveHandle | null;
+ segmentToNext?: ScalarSegmentType;
+ tangentMode?: TangentMode;
+}
+
+export interface ElementKeyframe {
+ propertyPath: AnimationPath;
+ id: string;
+ time: number;
+ value: AnimationValue;
+ interpolation: AnimationInterpolation;
+}
+
+export interface SelectedKeyframeRef {
+ trackId: string;
+ elementId: string;
+ propertyPath: AnimationPath;
+ keyframeId: string;
+}