summaryrefslogtreecommitdiff
path: root/apps/web/src/lib/animation
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-04-20 11:31:17 +0200
committerMaze Winther <mazewinther@gmail.com>2026-04-20 11:31:17 +0200
commit3e89d2998588f8e35c524e523ba2ee853f0f6475 (patch)
tree57221f96c1259b3b07e3ddf8c1adefc502b9d8eb /apps/web/src/lib/animation
parent729d10592f4e802f91c555c309ff3ba4b119f843 (diff)
downloadopencut-3e89d2998588f8e35c524e523ba2ee853f0f6475.tar.gz
refactor: restructure files to their domains, new preview overlay system, and dep graph
Diffstat (limited to 'apps/web/src/lib/animation')
-rw-r--r--apps/web/src/lib/animation/__tests__/binding-values.test.ts26
-rw-r--r--apps/web/src/lib/animation/bezier.ts90
-rw-r--r--apps/web/src/lib/animation/binding-values.ts344
-rw-r--r--apps/web/src/lib/animation/curve-bridge.ts95
-rw-r--r--apps/web/src/lib/animation/effect-param-channel.ts100
-rw-r--r--apps/web/src/lib/animation/graph-channels.ts99
-rw-r--r--apps/web/src/lib/animation/graphic-param-channel.ts73
-rw-r--r--apps/web/src/lib/animation/index.ts96
-rw-r--r--apps/web/src/lib/animation/interpolation.ts375
-rw-r--r--apps/web/src/lib/animation/keyframe-query.ts294
-rw-r--r--apps/web/src/lib/animation/keyframes.ts1338
-rw-r--r--apps/web/src/lib/animation/property-groups.ts39
-rw-r--r--apps/web/src/lib/animation/property-registry.ts390
-rw-r--r--apps/web/src/lib/animation/resolve.ts184
-rw-r--r--apps/web/src/lib/animation/target-resolver.ts285
-rw-r--r--apps/web/src/lib/animation/transform.ts1
-rw-r--r--apps/web/src/lib/animation/types.ts222
17 files changed, 0 insertions, 4051 deletions
diff --git a/apps/web/src/lib/animation/__tests__/binding-values.test.ts b/apps/web/src/lib/animation/__tests__/binding-values.test.ts
deleted file mode 100644
index c5a354e4..00000000
--- a/apps/web/src/lib/animation/__tests__/binding-values.test.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-import { describe, expect, test } from "bun:test";
-import {
- composeAnimationValue,
- createAnimationBinding,
-} from "@/lib/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/lib/animation/bezier.ts b/apps/web/src/lib/animation/bezier.ts
deleted file mode 100644
index 5a9758ed..00000000
--- a/apps/web/src/lib/animation/bezier.ts
+++ /dev/null
@@ -1,90 +0,0 @@
-import type { ScalarAnimationKey } from "@/lib/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/lib/animation/binding-values.ts b/apps/web/src/lib/animation/binding-values.ts
deleted file mode 100644
index 66fb2b5d..00000000
--- a/apps/web/src/lib/animation/binding-values.ts
+++ /dev/null
@@ -1,344 +0,0 @@
-import { converter, formatHex, formatHex8, parse } from "culori";
-import type {
- AnimationBindingComponent,
- AnimationBindingOfKind,
- AnimationBindingInstance,
- AnimationBindingKind,
- ColorAnimationBinding,
- DiscreteAnimationBinding,
- NumberAnimationBinding,
- AnimationPath,
- AnimationValue,
- DiscreteValue,
- Vector2AnimationBinding,
- VectorValue,
-} from "@/lib/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/lib/animation/curve-bridge.ts b/apps/web/src/lib/animation/curve-bridge.ts
deleted file mode 100644
index 385fb448..00000000
--- a/apps/web/src/lib/animation/curve-bridge.ts
+++ /dev/null
@@ -1,95 +0,0 @@
-import {
- getDefaultLeftHandle,
- getDefaultRightHandle,
-} from "@/lib/animation/bezier";
-import type {
- CurveHandle,
- NormalizedCubicBezier,
- ScalarAnimationKey,
-} from "@/lib/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/lib/animation/effect-param-channel.ts b/apps/web/src/lib/animation/effect-param-channel.ts
deleted file mode 100644
index 3080e6ff..00000000
--- a/apps/web/src/lib/animation/effect-param-channel.ts
+++ /dev/null
@@ -1,100 +0,0 @@
-import type { ParamValues } from "@/lib/params";
-import type { Effect } from "@/lib/effects/types";
-import type {
- ElementAnimations,
- EffectParamPath,
-} from "@/lib/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/lib/animation/graph-channels.ts b/apps/web/src/lib/animation/graph-channels.ts
deleted file mode 100644
index 19d53e27..00000000
--- a/apps/web/src/lib/animation/graph-channels.ts
+++ /dev/null
@@ -1,99 +0,0 @@
-import type {
- AnimationBindingInstance,
- AnimationPath,
- ElementAnimations,
- ScalarAnimationChannel,
- ScalarGraphChannel,
- ScalarGraphKeyframeContext,
-} from "@/lib/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/lib/animation/graphic-param-channel.ts b/apps/web/src/lib/animation/graphic-param-channel.ts
deleted file mode 100644
index de595060..00000000
--- a/apps/web/src/lib/animation/graphic-param-channel.ts
+++ /dev/null
@@ -1,73 +0,0 @@
-import type {
- ElementAnimations,
- GraphicParamPath,
-} from "@/lib/animation/types";
-import type { ParamValues } from "@/lib/params";
-import {
- getGraphicDefinition,
- resolveGraphicParams,
-} from "@/lib/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/lib/animation/index.ts b/apps/web/src/lib/animation/index.ts
deleted file mode 100644
index 384f3d17..00000000
--- a/apps/web/src/lib/animation/index.ts
+++ /dev/null
@@ -1,96 +0,0 @@
-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/lib/animation/interpolation.ts b/apps/web/src/lib/animation/interpolation.ts
deleted file mode 100644
index 1a3c0fed..00000000
--- a/apps/web/src/lib/animation/interpolation.ts
+++ /dev/null
@@ -1,375 +0,0 @@
-import type {
- AnimationChannel,
- AnimationInterpolation,
- AnimationValue,
- DiscreteAnimationChannel,
- DiscreteValue,
- ScalarAnimationChannel,
- ScalarAnimationKey,
- ScalarSegmentType,
-} from "@/lib/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/lib/animation/keyframe-query.ts b/apps/web/src/lib/animation/keyframe-query.ts
deleted file mode 100644
index dbcd041c..00000000
--- a/apps/web/src/lib/animation/keyframe-query.ts
+++ /dev/null
@@ -1,294 +0,0 @@
-import type {
- AnimationBindingInstance,
- AnimationChannel,
- AnimationPath,
- ElementAnimations,
- ElementKeyframe,
-} from "@/lib/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/lib/animation/keyframes.ts b/apps/web/src/lib/animation/keyframes.ts
deleted file mode 100644
index a5efb6ee..00000000
--- a/apps/web/src/lib/animation/keyframes.ts
+++ /dev/null
@@ -1,1338 +0,0 @@
-import type {
- AnimationBindingInstance,
- AnimationBindingKind,
- AnimationChannel,
- AnimationInterpolation,
- AnimationPath,
- AnimationPropertyPath,
- AnimationValue,
- DiscreteAnimationChannel,
- DiscreteAnimationKey,
- ElementAnimations,
- ScalarAnimationChannel,
- ScalarAnimationKey,
- ScalarCurveKeyframePatch,
- ScalarSegmentType,
-} from "@/lib/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/lib/animation/property-groups.ts b/apps/web/src/lib/animation/property-groups.ts
deleted file mode 100644
index 1e475dac..00000000
--- a/apps/web/src/lib/animation/property-groups.ts
+++ /dev/null
@@ -1,39 +0,0 @@
-import type {
- AnimationPropertyGroup,
- AnimationPropertyPath,
- ElementAnimations,
-} from "@/lib/animation/types";
-import { ANIMATION_PROPERTY_GROUPS } from "@/lib/animation/types";
-import { getKeyframeAtTime } from "./keyframe-query";
-
-export interface GroupKeyframeRef {
- propertyPath: AnimationPropertyPath;
- keyframeId: string;
-}
-
-export function getGroupKeyframesAtTime({
- animations,
- group,
- time,
-}: {
- animations: ElementAnimations | undefined;
- group: AnimationPropertyGroup;
- time: number;
-}): GroupKeyframeRef[] {
- return ANIMATION_PROPERTY_GROUPS[group].flatMap((propertyPath) => {
- const keyframe = getKeyframeAtTime({ animations, propertyPath, time });
- return keyframe ? [{ propertyPath, keyframeId: keyframe.id }] : [];
- });
-}
-
-export function hasGroupKeyframeAtTime({
- animations,
- group,
- time,
-}: {
- animations: ElementAnimations | undefined;
- group: AnimationPropertyGroup;
- time: number;
-}): boolean {
- return getGroupKeyframesAtTime({ animations, group, time }).length > 0;
-}
diff --git a/apps/web/src/lib/animation/property-registry.ts b/apps/web/src/lib/animation/property-registry.ts
deleted file mode 100644
index 56340fd8..00000000
--- a/apps/web/src/lib/animation/property-registry.ts
+++ /dev/null
@@ -1,390 +0,0 @@
-import type {
- AnimationBindingKind,
- AnimationInterpolation,
- AnimationPropertyPath,
- AnimationValue,
-} from "@/lib/animation/types";
-import { parseColorToLinearRgba } from "./binding-values";
-import type { TimelineElement } from "@/lib/timeline";
-import { MIN_TRANSFORM_SCALE } from "@/lib/animation/transform";
-import {
- CORNER_RADIUS_MAX,
- CORNER_RADIUS_MIN,
-} from "@/lib/text/background";
-import {
- canElementHaveAudio,
- isVisualElement,
-} from "@/lib/timeline/element-utils";
-import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/lib/timeline/audio-constants";
-import { DEFAULTS } from "@/lib/timeline/defaults";
-import { snapToStep } from "@/utils/math";
-
-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/lib/animation/resolve.ts b/apps/web/src/lib/animation/resolve.ts
deleted file mode 100644
index 42c1c73c..00000000
--- a/apps/web/src/lib/animation/resolve.ts
+++ /dev/null
@@ -1,184 +0,0 @@
-import type {
- AnimationColorPropertyPath,
- AnimationNumericPropertyPath,
- AnimationPath,
- AnimationPropertyPath,
- AnimationValueForPath,
- ElementAnimations,
-} from "@/lib/animation/types";
-import type { Transform } from "@/lib/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/lib/animation/target-resolver.ts b/apps/web/src/lib/animation/target-resolver.ts
deleted file mode 100644
index 64bb8709..00000000
--- a/apps/web/src/lib/animation/target-resolver.ts
+++ /dev/null
@@ -1,285 +0,0 @@
-import type {
- AnimationBindingKind,
- AnimationInterpolation,
- AnimationPath,
- AnimationValue,
-} from "@/lib/animation/types";
-import {
- parseEffectParamPath,
- isEffectParamPath,
-} from "@/lib/animation/effect-param-channel";
-import {
- isGraphicParamPath,
- parseGraphicParamPath,
-} from "@/lib/animation/graphic-param-channel";
-import type { ParamDefinition } from "@/lib/params";
-import { effectsRegistry, registerDefaultEffects } from "@/lib/effects";
-import { getGraphicDefinition } from "@/lib/graphics";
-import type { TimelineElement } from "@/lib/timeline";
-import { isVisualElement } from "@/lib/timeline/element-utils";
-import { snapToStep } from "@/utils/math";
-import {
- coerceAnimationValueForProperty,
- getAnimationPropertyDefinition,
- getElementBaseValueForProperty,
- isAnimationPropertyPath,
- type NumericSpec,
- withElementBaseValueForProperty,
-} from "./property-registry";
-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/lib/animation/transform.ts b/apps/web/src/lib/animation/transform.ts
deleted file mode 100644
index ab932ea2..00000000
--- a/apps/web/src/lib/animation/transform.ts
+++ /dev/null
@@ -1 +0,0 @@
-export const MIN_TRANSFORM_SCALE = 0.01;
diff --git a/apps/web/src/lib/animation/types.ts b/apps/web/src/lib/animation/types.ts
deleted file mode 100644
index 115b4dbf..00000000
--- a/apps/web/src/lib/animation/types.ts
+++ /dev/null
@@ -1,222 +0,0 @@
-import type { ParamValues } from "@/lib/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;
-}