summaryrefslogtreecommitdiff
path: root/apps/web/src/animation
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-04-29 10:37:39 +0200
committerMaze Winther <mazewinther@gmail.com>2026-05-02 03:27:23 +0200
commit401cc35743aafc4c8bdfb8055501650390bde88b (patch)
tree98385fb2f252ff6bb227a844d96b664a8fe87835 /apps/web/src/animation
parentb8d08df244b97b1d3131cb2d076166838189eeb0 (diff)
downloadopencut-401cc35743aafc4c8bdfb8055501650390bde88b.tar.gz
refactor: animate params over time
Diffstat (limited to 'apps/web/src/animation')
-rw-r--r--apps/web/src/animation/__tests__/binding-values.test.ts26
-rw-r--r--apps/web/src/animation/binding-values.ts344
-rw-r--r--apps/web/src/animation/channel-data.ts57
-rw-r--r--apps/web/src/animation/effect-param-channel.ts2
-rw-r--r--apps/web/src/animation/graph-channels.ts37
-rw-r--r--apps/web/src/animation/graphic-param-channel.ts2
-rw-r--r--apps/web/src/animation/index.ts5
-rw-r--r--apps/web/src/animation/interpolation.ts91
-rw-r--r--apps/web/src/animation/keyframe-query.ts226
-rw-r--r--apps/web/src/animation/keyframes.ts720
-rw-r--r--apps/web/src/animation/resolve.ts146
-rw-r--r--apps/web/src/animation/types.ts132
12 files changed, 805 insertions, 983 deletions
diff --git a/apps/web/src/animation/__tests__/binding-values.test.ts b/apps/web/src/animation/__tests__/binding-values.test.ts
deleted file mode 100644
index a1f2010c..00000000
--- a/apps/web/src/animation/__tests__/binding-values.test.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-import { describe, expect, test } from "bun:test";
-import {
- composeAnimationValue,
- createAnimationBinding,
-} from "@/animation/binding-values";
-
-describe("binding values", () => {
- test("formats composed animated colors as hex", () => {
- const binding = createAnimationBinding({
- path: "color",
- kind: "color",
- });
-
- expect(
- composeAnimationValue({
- binding,
- componentValues: {
- r: 1,
- g: 0,
- b: 0,
- a: 1,
- },
- }),
- ).toBe("#ff0000");
- });
-});
diff --git a/apps/web/src/animation/binding-values.ts b/apps/web/src/animation/binding-values.ts
deleted file mode 100644
index a46aa9a6..00000000
--- a/apps/web/src/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 "@/animation/types";
-import { clamp } from "@/utils/math";
-
-interface LinearRgba {
- r: number;
- g: number;
- b: number;
- a: number;
-}
-
-export type AnimationComponentValue = number | DiscreteValue;
-
-const toRgb = converter("rgb");
-
-function srgbToLinear({ value }: { value: number }): number {
- return value <= 0.04045
- ? value / 12.92
- : Math.pow((value + 0.055) / 1.055, 2.4);
-}
-
-function linearToSrgb({ value }: { value: number }): number {
- const clamped = clamp({ value, min: 0, max: 1 });
- return clamped <= 0.0031308
- ? clamped * 12.92
- : 1.055 * Math.pow(clamped, 1 / 2.4) - 0.055;
-}
-
-function isRecord(value: unknown): value is Record<string, unknown> {
- return typeof value === "object" && value !== null;
-}
-
-export function isVectorValue(value: unknown): value is VectorValue {
- return isRecord(value) && typeof value.x === "number" && typeof value.y === "number";
-}
-
-export type EasingMode = "independent" | "shared";
-
-/**
- * Declares how easing curves apply to a binding's components.
- * "shared" means all components always use the same curve (e.g. color — you never
- * want to ease R independently from G/B/A). "independent" means each component
- * can have its own curve.
- */
-export function getEasingModeForKind(kind: AnimationBindingKind): EasingMode {
- return kind === "color" ? "shared" : "independent";
-}
-
-export function getBindingComponentKeys({
- kind,
-}: {
- kind: AnimationBindingKind;
-}): string[] {
- if (kind === "vector2") {
- return ["x", "y"];
- }
-
- if (kind === "color") {
- return ["r", "g", "b", "a"];
- }
-
- return ["value"];
-}
-
-export function buildBindingChannelId({
- path,
- componentKey,
-}: {
- path: AnimationPath;
- componentKey: string;
-}): string {
- return `${path}:${componentKey}`;
-}
-
-function createBindingComponent<TKey extends string>({
- path,
- key,
-}: {
- path: AnimationPath;
- key: TKey;
-}): AnimationBindingComponent<TKey> {
- return {
- key,
- channelId: buildBindingChannelId({ path, componentKey: key }),
- };
-}
-
-function cloneBindingComponents<TKey extends string>({
- components,
-}: {
- components: AnimationBindingComponent<TKey>[];
-}): AnimationBindingComponent<TKey>[] {
- return components.map((component) => ({ ...component }));
-}
-
-const animationBindingFactories = {
- color: ({ path }: { path: AnimationPath }): ColorAnimationBinding => ({
- path,
- kind: "color",
- colorSpace: "srgb-linear",
- components: [
- createBindingComponent({ path, key: "r" }),
- createBindingComponent({ path, key: "g" }),
- createBindingComponent({ path, key: "b" }),
- createBindingComponent({ path, key: "a" }),
- ],
- }),
- vector2: ({ path }: { path: AnimationPath }): Vector2AnimationBinding => ({
- path,
- kind: "vector2",
- components: [
- createBindingComponent({ path, key: "x" }),
- createBindingComponent({ path, key: "y" }),
- ],
- }),
- number: ({ path }: { path: AnimationPath }): NumberAnimationBinding => ({
- path,
- kind: "number",
- components: [createBindingComponent({ path, key: "value" })],
- }),
- discrete: ({ path }: { path: AnimationPath }): DiscreteAnimationBinding => ({
- path,
- kind: "discrete",
- components: [createBindingComponent({ path, key: "value" })],
- }),
-} satisfies {
- [K in AnimationBindingKind]: ({
- path,
- }: {
- path: AnimationPath;
- }) => AnimationBindingOfKind<K>;
-};
-
-export function createAnimationBinding<TKind extends AnimationBindingKind>({
- path,
- kind,
-}: {
- path: AnimationPath;
- kind: TKind;
-}): AnimationBindingOfKind<TKind>;
-export function createAnimationBinding({
- path,
- kind,
-}: {
- path: AnimationPath;
- kind: AnimationBindingKind;
-}): AnimationBindingInstance {
- return animationBindingFactories[kind]({ path });
-}
-
-const animationBindingCloners = {
- color: ({ binding }: { binding: ColorAnimationBinding }): ColorAnimationBinding => ({
- ...binding,
- components: cloneBindingComponents({
- components: binding.components,
- }),
- }),
- vector2: ({
- binding,
- }: {
- binding: Vector2AnimationBinding;
- }): Vector2AnimationBinding => ({
- ...binding,
- components: cloneBindingComponents({
- components: binding.components,
- }),
- }),
- number: ({
- binding,
- }: {
- binding: NumberAnimationBinding;
- }): NumberAnimationBinding => ({
- ...binding,
- components: cloneBindingComponents({
- components: binding.components,
- }),
- }),
- discrete: ({
- binding,
- }: {
- binding: DiscreteAnimationBinding;
- }): DiscreteAnimationBinding => ({
- ...binding,
- components: cloneBindingComponents({
- components: binding.components,
- }),
- }),
-} satisfies {
- [K in AnimationBindingKind]: ({
- binding,
- }: {
- binding: AnimationBindingOfKind<K>;
- }) => AnimationBindingOfKind<K>;
-};
-
-export function cloneAnimationBinding<TKind extends AnimationBindingKind>({
- binding,
-}: {
- binding: AnimationBindingOfKind<TKind>;
-}): AnimationBindingOfKind<TKind>;
-export function cloneAnimationBinding({
- binding,
-}: {
- binding: AnimationBindingInstance;
-}): AnimationBindingInstance {
- switch (binding.kind) {
- case "color":
- return animationBindingCloners.color({ binding });
- case "vector2":
- return animationBindingCloners.vector2({ binding });
- case "number":
- return animationBindingCloners.number({ binding });
- case "discrete":
- return animationBindingCloners.discrete({ binding });
- }
-}
-
-export function parseColorToLinearRgba({
- color,
-}: {
- color: string;
-}): LinearRgba | null {
- const parsed = parse(color);
- const rgb = parsed ? toRgb(parsed) : null;
- if (!rgb) {
- return null;
- }
-
- return {
- r: srgbToLinear({ value: rgb.r ?? 0 }),
- g: srgbToLinear({ value: rgb.g ?? 0 }),
- b: srgbToLinear({ value: rgb.b ?? 0 }),
- a: clamp({ value: rgb.alpha ?? 1, min: 0, max: 1 }),
- };
-}
-
-export function formatLinearRgba({
- color,
-}: {
- color: LinearRgba;
-}): string {
- const rgb = {
- mode: "rgb",
- r: linearToSrgb({ value: color.r }),
- g: linearToSrgb({ value: color.g }),
- b: linearToSrgb({ value: color.b }),
- alpha: clamp({ value: color.a, min: 0, max: 1 }),
- } as const;
- return rgb.alpha < 1 ? formatHex8(rgb) : formatHex(rgb);
-}
-
-export function decomposeAnimationValue({
- kind,
- value,
-}: {
- kind: AnimationBindingKind;
- value: AnimationValue;
-}): Record<string, AnimationComponentValue> | null {
- if (kind === "number") {
- return typeof value === "number" ? { value } : null;
- }
-
- if (kind === "vector2") {
- return isVectorValue(value) ? { x: value.x, y: value.y } : null;
- }
-
- if (kind === "color") {
- if (typeof value !== "string") {
- return null;
- }
- const parsed = parseColorToLinearRgba({ color: value });
- if (!parsed) {
- return null;
- }
- return {
- r: parsed.r,
- g: parsed.g,
- b: parsed.b,
- a: parsed.a,
- };
- }
-
- return typeof value === "string" || typeof value === "boolean"
- ? { value }
- : null;
-}
-
-export function composeAnimationValue({
- binding,
- componentValues,
-}: {
- binding: AnimationBindingInstance;
- componentValues: Record<string, AnimationComponentValue | undefined>;
-}): AnimationValue | null {
- if (binding.kind === "number") {
- const value = componentValues.value;
- return typeof value === "number" ? value : null;
- }
-
- if (binding.kind === "vector2") {
- const x = componentValues.x;
- const y = componentValues.y;
- return typeof x === "number" && typeof y === "number" ? { x, y } : null;
- }
-
- if (binding.kind === "color") {
- const r = componentValues.r;
- const g = componentValues.g;
- const b = componentValues.b;
- const a = componentValues.a;
- if (
- typeof r !== "number" ||
- typeof g !== "number" ||
- typeof b !== "number" ||
- typeof a !== "number"
- ) {
- return null;
- }
- return formatLinearRgba({
- color: {
- r,
- g,
- b,
- a,
- },
- });
- }
-
- const value = componentValues.value;
- return typeof value === "string" || typeof value === "boolean" ? value : null;
-}
diff --git a/apps/web/src/animation/channel-data.ts b/apps/web/src/animation/channel-data.ts
new file mode 100644
index 00000000..2fc08b5c
--- /dev/null
+++ b/apps/web/src/animation/channel-data.ts
@@ -0,0 +1,57 @@
+import type {
+ AnimationChannel,
+ ChannelData,
+ CompositeChannelData,
+} from "@/animation/types";
+
+const LEGACY_ANIMATION_STORAGE_KEYS = new Set(["bindings", "channels"]);
+
+function isRecord(value: unknown): value is Record<string, unknown> {
+ return typeof value === "object" && value !== null;
+}
+
+export function isLeafChannelData(
+ data: ChannelData | undefined,
+): data is AnimationChannel {
+ return isRecord(data) && Array.isArray(data.keys);
+}
+
+export function isCompositeChannelData(
+ data: ChannelData | undefined,
+): data is CompositeChannelData {
+ return isRecord(data) && !Array.isArray(data.keys);
+}
+
+export function getChannelsFromData({
+ data,
+}: {
+ data: ChannelData | undefined;
+}): AnimationChannel[] {
+ if (isLeafChannelData(data)) {
+ return [data];
+ }
+ if (!isCompositeChannelData(data)) {
+ return [];
+ }
+ return Object.values(data).filter(isLeafChannelData);
+}
+
+export function getChannelEntriesFromData({
+ data,
+}: {
+ data: ChannelData | undefined;
+}): Array<[string, AnimationChannel]> {
+ if (isLeafChannelData(data)) {
+ return [["value", data]];
+ }
+ if (!isCompositeChannelData(data)) {
+ return [];
+ }
+ return Object.entries(data).flatMap(([componentKey, channel]) =>
+ isLeafChannelData(channel) ? [[componentKey, channel]] : [],
+ );
+}
+
+export function isAnimationStorageKey({ key }: { key: string }): boolean {
+ return !LEGACY_ANIMATION_STORAGE_KEYS.has(key);
+}
diff --git a/apps/web/src/animation/effect-param-channel.ts b/apps/web/src/animation/effect-param-channel.ts
index e1c442f7..3def2109 100644
--- a/apps/web/src/animation/effect-param-channel.ts
+++ b/apps/web/src/animation/effect-param-channel.ts
@@ -70,7 +70,7 @@ export function resolveEffectParamsAtTime({
for (const [paramKey, staticValue] of Object.entries(params)) {
const path = buildEffectParamPath({ effectId, paramKey });
- resolved[paramKey] = animations?.bindings[path]
+ resolved[paramKey] = animations?.[path]
? resolveAnimationPathValueAtTime({
animations,
propertyPath: path,
diff --git a/apps/web/src/animation/graph-channels.ts b/apps/web/src/animation/graph-channels.ts
index d6482602..cef1397a 100644
--- a/apps/web/src/animation/graph-channels.ts
+++ b/apps/web/src/animation/graph-channels.ts
@@ -1,21 +1,35 @@
import type {
- AnimationBindingInstance,
AnimationPath,
ElementAnimations,
+ ChannelData,
ScalarAnimationChannel,
ScalarGraphChannel,
ScalarGraphKeyframeContext,
} from "@/animation/types";
+import type { ChannelEasingMode } from "@/params";
+import { isCompositeChannelData, isLeafChannelData } from "./channel-data";
+import { isScalarChannel } from "./interpolation";
export interface EditableScalarChannels {
- binding: AnimationBindingInstance;
+ easingMode: ChannelEasingMode;
channels: ScalarGraphChannel[];
}
function isScalarAnimationChannel(
- channel: ElementAnimations["channels"][string],
+ channel: ChannelData | undefined,
): channel is ScalarAnimationChannel {
- return channel?.kind === "scalar";
+ return isLeafChannelData(channel) && isScalarChannel(channel);
+}
+
+function getEasingModeForChannelData({
+ data,
+}: {
+ data: ChannelData | undefined;
+}): ChannelEasingMode {
+ return isCompositeChannelData(data) &&
+ ["r", "g", "b", "a"].every((componentKey) => componentKey in data)
+ ? "shared"
+ : "independent";
}
export function getEditableScalarChannels({
@@ -25,13 +39,15 @@ export function getEditableScalarChannels({
animations: ElementAnimations | undefined;
propertyPath: AnimationPath;
}): EditableScalarChannels | null {
- const binding = animations?.bindings[propertyPath];
- if (!binding) {
+ const data = animations?.[propertyPath];
+ if (!data) {
return null;
}
- const channels = binding.components.flatMap((component) => {
- const channel = animations?.channels[component.channelId];
+ const channelEntries = isLeafChannelData(data)
+ ? [["value", data] as const]
+ : Object.entries(data);
+ const channels = channelEntries.flatMap(([componentKey, channel]) => {
if (!isScalarAnimationChannel(channel)) {
return [];
}
@@ -39,14 +55,13 @@ export function getEditableScalarChannels({
return [
{
propertyPath,
- componentKey: component.key,
- channelId: component.channelId,
+ componentKey,
channel,
} satisfies ScalarGraphChannel,
];
});
- return { binding, channels };
+ return { easingMode: getEasingModeForChannelData({ data }), channels };
}
export function getEditableScalarChannel({
diff --git a/apps/web/src/animation/graphic-param-channel.ts b/apps/web/src/animation/graphic-param-channel.ts
index d0bfdded..32f84714 100644
--- a/apps/web/src/animation/graphic-param-channel.ts
+++ b/apps/web/src/animation/graphic-param-channel.ts
@@ -49,7 +49,7 @@ export function resolveGraphicParamsAtTime({
for (const param of definitions) {
const path = buildGraphicParamPath({ paramKey: param.key });
- if (!animations?.bindings[path]) {
+ if (!animations?.[path]) {
continue;
}
diff --git a/apps/web/src/animation/index.ts b/apps/web/src/animation/index.ts
index bed3b796..f3d8aca4 100644
--- a/apps/web/src/animation/index.ts
+++ b/apps/web/src/animation/index.ts
@@ -65,11 +65,6 @@ export {
} from "./property-groups";
export {
- type EasingMode,
- getEasingModeForKind,
-} from "./binding-values";
-
-export {
isAnimationPath,
isAnimationPropertyPath,
} from "./path";
diff --git a/apps/web/src/animation/interpolation.ts b/apps/web/src/animation/interpolation.ts
index 5d5212a6..cdb8bcc9 100644
--- a/apps/web/src/animation/interpolation.ts
+++ b/apps/web/src/animation/interpolation.ts
@@ -1,13 +1,14 @@
import type {
AnimationChannel,
AnimationInterpolation,
- AnimationValue,
+ Channel,
DiscreteAnimationChannel,
DiscreteValue,
ScalarAnimationChannel,
ScalarAnimationKey,
ScalarSegmentType,
} from "@/animation/types";
+import type { ParamValue } from "@/params";
import { mediaTime } from "@/wasm";
import {
getBezierPoint,
@@ -111,7 +112,7 @@ function normalizeScalarKey({
};
}
-function normalizeScalarChannel({
+export function normalizeScalarChannel({
channel,
}: {
channel: ScalarAnimationChannel;
@@ -154,17 +155,11 @@ function normalizeScalarChannel({
};
}
-export function normalizeChannel<TChannel extends AnimationChannel>({
+export function normalizeDiscreteChannel({
channel,
}: {
- channel: TChannel;
-}): TChannel {
- if (channel.kind === "scalar") {
- return normalizeScalarChannel({
- channel,
- }) as TChannel;
- }
-
+ channel: DiscreteAnimationChannel;
+}): DiscreteAnimationChannel {
return {
...channel,
keys: [...channel.keys].sort((leftKeyframe, rightKeyframe) =>
@@ -173,7 +168,39 @@ export function normalizeChannel<TChannel extends AnimationChannel>({
rightTime: rightKeyframe.time,
}),
),
- } as TChannel;
+ };
+}
+
+export function isScalarChannel(channel: AnimationChannel): channel is ScalarAnimationChannel {
+ return (
+ "extrapolation" in channel ||
+ channel.keys.some((keyframe) => "segmentToNext" in keyframe)
+ );
+}
+
+export function normalizeChannel({
+ channel,
+}: {
+ channel: ScalarAnimationChannel;
+}): ScalarAnimationChannel;
+export function normalizeChannel({
+ channel,
+}: {
+ channel: DiscreteAnimationChannel;
+}): DiscreteAnimationChannel;
+export function normalizeChannel({
+ channel,
+}: {
+ channel: AnimationChannel;
+}): AnimationChannel;
+export function normalizeChannel({
+ channel,
+}: {
+ channel: AnimationChannel;
+}): AnimationChannel {
+ return isScalarChannel(channel)
+ ? normalizeScalarChannel({ channel })
+ : normalizeDiscreteChannel({ channel });
}
function extrapolateScalarEdge({
@@ -216,7 +243,7 @@ export function getScalarChannelValueAtTime({
time,
fallbackValue,
}: {
- channel: ScalarAnimationChannel | undefined;
+ channel: Channel<number> | undefined;
time: number;
fallbackValue: number;
}): number {
@@ -224,9 +251,7 @@ export function getScalarChannelValueAtTime({
return fallbackValue;
}
- const normalizedChannel = normalizeChannel({
- channel,
- });
+ const normalizedChannel = normalizeScalarChannel({ channel });
const firstKey = normalizedChannel.keys[0];
const lastKey = normalizedChannel.keys[normalizedChannel.keys.length - 1];
if (!firstKey || !lastKey) {
@@ -328,7 +353,7 @@ export function getDiscreteChannelValueAtTime({
time,
fallbackValue,
}: {
- channel: DiscreteAnimationChannel | undefined;
+ channel: Channel<DiscreteValue> | undefined;
time: number;
fallbackValue: DiscreteValue;
}): DiscreteValue {
@@ -336,9 +361,7 @@ export function getDiscreteChannelValueAtTime({
return fallbackValue;
}
- const normalizedChannel = normalizeChannel({
- channel,
- });
+ const normalizedChannel = normalizeDiscreteChannel({ channel });
let currentValue = fallbackValue;
for (const key of normalizedChannel.keys) {
if (time < key.time) {
@@ -354,16 +377,34 @@ export function getChannelValueAtTime({
time,
fallbackValue,
}: {
+ channel: Channel<number> | undefined;
+ time: number;
+ fallbackValue: number;
+}): number;
+export function getChannelValueAtTime<TValue extends DiscreteValue>({
+ channel,
+ time,
+ fallbackValue,
+}: {
+ channel: DiscreteAnimationChannel | undefined;
+ time: number;
+ fallbackValue: TValue;
+}): TValue;
+export function getChannelValueAtTime({
+ channel,
+ time,
+ fallbackValue,
+}: {
channel: AnimationChannel | undefined;
time: number;
- fallbackValue: AnimationValue;
-}): AnimationValue {
+ fallbackValue: ParamValue;
+}): ParamValue {
if (!channel || channel.keys.length === 0) {
return fallbackValue;
}
- if (channel.kind === "scalar") {
- return typeof fallbackValue === "number"
+ if (typeof fallbackValue === "number") {
+ return isScalarChannel(channel)
? getScalarChannelValueAtTime({
channel,
time,
@@ -377,7 +418,7 @@ export function getChannelValueAtTime({
}
return getDiscreteChannelValueAtTime({
- channel,
+ channel: isScalarChannel(channel) ? undefined : channel,
time,
fallbackValue,
});
diff --git a/apps/web/src/animation/keyframe-query.ts b/apps/web/src/animation/keyframe-query.ts
index e65db881..95f7fcf0 100644
--- a/apps/web/src/animation/keyframe-query.ts
+++ b/apps/web/src/animation/keyframe-query.ts
@@ -1,75 +1,75 @@
import type {
- AnimationBindingInstance,
AnimationChannel,
AnimationPath,
+ ChannelData,
ElementAnimations,
ElementKeyframe,
} from "@/animation/types";
+import { formatLinearRgba } from "@/params";
import {
- type AnimationComponentValue,
- composeAnimationValue,
-} from "./binding-values";
+ getChannelEntriesFromData,
+ isAnimationStorageKey,
+} from "./channel-data";
import {
getChannelValueAtTime,
getScalarSegmentInterpolation,
+ isScalarChannel,
} from "./interpolation";
import { isAnimationPath } from "./path";
-function getBindingFallbackValue({
+function getChannelFallbackValue({
channel,
}: {
- channel: ElementAnimations["channels"][string];
+ channel: AnimationChannel;
}) {
- if (!channel || channel.keys.length === 0) {
- return channel?.kind === "discrete" ? false : 0;
+ if (channel.keys.length === 0) {
+ return isScalarChannel(channel) ? 0 : false;
}
return channel.keys[0].value;
}
-interface BindingKeyframeMatch {
+interface ChannelKeyframeMatch {
+ componentKey: string;
componentIndex: number;
channel: AnimationChannel;
keyframe: AnimationChannel["keys"][number];
}
-function getBindingKeyframeMatches({
- animations,
- binding,
+function getChannelKeyframeMatches({
+ data,
}: {
- animations: ElementAnimations;
- binding: AnimationBindingInstance;
-}): BindingKeyframeMatch[] {
- return binding.components.flatMap((component, componentIndex) => {
- const channel = animations.channels[component.channelId];
- if (!channel || channel.keys.length === 0) {
- return [];
- }
+ data: ChannelData | undefined;
+}): ChannelKeyframeMatch[] {
+ return getChannelEntriesFromData({ data }).flatMap(
+ ([componentKey, channel], componentIndex) => {
+ if (channel.keys.length === 0) {
+ return [];
+ }
- return channel.keys.map((keyframe) => ({
- componentIndex,
- channel,
- keyframe,
- }));
- });
+ return channel.keys.map((keyframe) => ({
+ componentKey,
+ componentIndex,
+ channel,
+ keyframe,
+ }));
+ },
+ );
}
-function getUniqueBindingKeyframeMatches({
- animations,
- binding,
+function getUniqueChannelKeyframeMatches({
+ data,
}: {
- animations: ElementAnimations;
- binding: AnimationBindingInstance;
-}): BindingKeyframeMatch[] {
- const sortedMatches = getBindingKeyframeMatches({
- animations,
- binding,
+ data: ChannelData | undefined;
+}): ChannelKeyframeMatch[] {
+ const sortedMatches = getChannelKeyframeMatches({
+ data,
}).sort(
(leftMatch, rightMatch) =>
leftMatch.keyframe.time - rightMatch.keyframe.time ||
leftMatch.componentIndex - rightMatch.componentIndex,
);
- const uniqueMatches: BindingKeyframeMatch[] = [];
+ const uniqueMatches: ChannelKeyframeMatch[] = [];
for (const match of sortedMatches) {
const previousMatch = uniqueMatches[uniqueMatches.length - 1];
@@ -92,11 +92,11 @@ function getUniqueBindingKeyframeMatches({
return uniqueMatches;
}
-function getPreferredBindingKeyframeMatch({
+function getPreferredChannelKeyframeMatch({
matches,
}: {
- matches: BindingKeyframeMatch[];
-}): BindingKeyframeMatch | null {
+ matches: ChannelKeyframeMatch[];
+}): ChannelKeyframeMatch | null {
return (
matches.find((match) => match.componentIndex === 0) ??
matches[0] ??
@@ -104,35 +104,67 @@ function getPreferredBindingKeyframeMatch({
);
}
-function getComposedBindingValueAtTime({
- animations,
- binding,
+function getChannelValue({
+ channel,
time,
}: {
- animations: ElementAnimations;
- binding: AnimationBindingInstance;
+ channel: AnimationChannel;
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,
+ const fallbackValue = getChannelFallbackValue({ channel });
+ if (typeof fallbackValue === "number") {
+ return getChannelValueAtTime({
+ channel: isScalarChannel(channel) ? channel : undefined,
+ time,
+ fallbackValue,
+ });
+ }
+ return getChannelValueAtTime({
+ channel: !isScalarChannel(channel) ? channel : undefined,
+ time,
+ fallbackValue,
});
}
+function getComposedChannelDataValueAtTime({
+ data,
+ time,
+}: {
+ data: ChannelData | undefined;
+ time: number;
+}) {
+ const entries = getChannelEntriesFromData({ data });
+ if (entries.length === 0) {
+ return null;
+ }
+ if (entries.length === 1 && entries[0]?.[0] === "value") {
+ return getChannelValue({ channel: entries[0][1], time });
+ }
+
+ const componentValues = Object.fromEntries(
+ entries.map(([componentKey, channel]) => [
+ componentKey,
+ getChannelValue({ channel, time }),
+ ]),
+ );
+ if (
+ typeof componentValues.r === "number" &&
+ typeof componentValues.g === "number" &&
+ typeof componentValues.b === "number" &&
+ typeof componentValues.a === "number"
+ ) {
+ return formatLinearRgba({
+ color: {
+ r: componentValues.r,
+ g: componentValues.g,
+ b: componentValues.b,
+ a: componentValues.a,
+ },
+ });
+ }
+ return null;
+}
+
function getKeyframeInterpolation({
channel,
keyframe,
@@ -140,25 +172,22 @@ function getKeyframeInterpolation({
channel: AnimationChannel;
keyframe: AnimationChannel["keys"][number];
}) {
- return channel.kind === "scalar" && "segmentToNext" in keyframe
+ return isScalarChannel(channel) && "segmentToNext" in keyframe
? getScalarSegmentInterpolation({ segment: keyframe.segmentToNext })
: "hold";
}
function toElementKeyframe({
- animations,
- binding,
+ data,
propertyPath,
keyframeMatch,
}: {
- animations: ElementAnimations;
- binding: AnimationBindingInstance;
+ data: ChannelData | undefined;
propertyPath: AnimationPath;
- keyframeMatch: BindingKeyframeMatch;
+ keyframeMatch: ChannelKeyframeMatch;
}): ElementKeyframe | null {
- const value = getComposedBindingValueAtTime({
- animations,
- binding,
+ const value = getComposedChannelDataValueAtTime({
+ data,
time: keyframeMatch.keyframe.time,
});
if (value === null) {
@@ -186,19 +215,19 @@ export function getElementKeyframes({
return [];
}
- return Object.entries(animations.bindings).flatMap(
- ([propertyPath, binding]) => {
- if (!binding || !isAnimationPath(propertyPath)) {
+ return Object.entries(animations).filter(([key]) =>
+ isAnimationStorageKey({ key }),
+ ).flatMap(
+ ([propertyPath, data]) => {
+ if (!data || !isAnimationPath(propertyPath)) {
return [];
}
- return getUniqueBindingKeyframeMatches({
- animations,
- binding,
+ return getUniqueChannelKeyframeMatches({
+ data,
}).flatMap((keyframeMatch) => {
const keyframe = toElementKeyframe({
- animations,
- binding,
+ data,
propertyPath,
keyframeMatch,
});
@@ -219,13 +248,8 @@ export function hasKeyframesForPath({
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),
+ return getChannelEntriesFromData({ data: animations?.[propertyPath] }).some(
+ ([, channel]) => channel.keys.length > 0,
);
}
@@ -238,24 +262,22 @@ export function getKeyframeAtTime({
propertyPath: AnimationPath;
time: number;
}): ElementKeyframe | null {
- const binding = animations?.bindings[propertyPath];
- if (!binding) {
+ const data = animations?.[propertyPath];
+ if (!data) {
return null;
}
- const keyframeMatch = getPreferredBindingKeyframeMatch({
- matches: getBindingKeyframeMatches({
- animations,
- binding,
- }).filter(({ keyframe }) => keyframe.time === time),
+ const keyframeMatch = getPreferredChannelKeyframeMatch({
+ matches: getChannelKeyframeMatches({ data }).filter(
+ ({ keyframe }) => keyframe.time === time,
+ ),
});
if (!keyframeMatch) {
return null;
}
return toElementKeyframe({
- animations,
- binding,
+ data,
propertyPath,
keyframeMatch,
});
@@ -270,24 +292,22 @@ export function getKeyframeById({
propertyPath: AnimationPath;
keyframeId: string;
}): ElementKeyframe | null {
- const binding = animations?.bindings[propertyPath];
- if (!binding) {
+ const data = animations?.[propertyPath];
+ if (!data) {
return null;
}
- const keyframeMatch = getPreferredBindingKeyframeMatch({
- matches: getBindingKeyframeMatches({
- animations,
- binding,
- }).filter(({ keyframe }) => keyframe.id === keyframeId),
+ const keyframeMatch = getPreferredChannelKeyframeMatch({
+ matches: getChannelKeyframeMatches({ data }).filter(
+ ({ keyframe }) => keyframe.id === keyframeId,
+ ),
});
if (!keyframeMatch) {
return null;
}
return toElementKeyframe({
- animations,
- binding,
+ data,
propertyPath,
keyframeMatch,
});
diff --git a/apps/web/src/animation/keyframes.ts b/apps/web/src/animation/keyframes.ts
index d14329c1..c3fd8202 100644
--- a/apps/web/src/animation/keyframes.ts
+++ b/apps/web/src/animation/keyframes.ts
@@ -1,10 +1,8 @@
import type {
- AnimationBindingInstance,
- AnimationBindingKind,
AnimationChannel,
+ ChannelData,
AnimationInterpolation,
AnimationPath,
- AnimationValue,
DiscreteAnimationChannel,
DiscreteAnimationKey,
ElementAnimations,
@@ -13,11 +11,17 @@ import type {
ScalarCurveKeyframePatch,
ScalarSegmentType,
} from "@/animation/types";
+import type {
+ ChannelComponentDefinition,
+ ParamChannelLayout,
+ ParamValue,
+} from "@/params";
import {
- cloneAnimationBinding,
- createAnimationBinding,
- decomposeAnimationValue,
-} from "./binding-values";
+ getChannelsFromData,
+ isCompositeChannelData,
+ isAnimationStorageKey,
+ isLeafChannelData,
+} from "./channel-data";
import {
getDefaultLeftHandle,
getDefaultRightHandle,
@@ -26,7 +30,10 @@ import {
import {
getChannelValueAtTime,
getScalarSegmentInterpolation,
+ isScalarChannel,
normalizeChannel,
+ normalizeDiscreteChannel,
+ normalizeScalarChannel,
} from "./interpolation";
import {
type MediaTime,
@@ -54,27 +61,27 @@ function hasChannelKeys({
return Boolean(channel && channel.keys.length > 0);
}
+function hasChannelData({ data }: { data: ChannelData | undefined }): boolean {
+ return getChannelsFromData({ data }).some((channel) =>
+ hasChannelKeys({ channel }),
+ );
+}
+
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 }),
+ const nextAnimations = Object.fromEntries(
+ Object.entries(animations).filter(
+ ([key, data]) => isAnimationStorageKey({ key }) && hasChannelData({ data }),
),
);
- if (Object.keys(nextBindings).length === 0 || Object.keys(nextChannels).length === 0) {
+ if (Object.keys(nextAnimations).length === 0) {
return undefined;
}
- return {
- bindings: nextBindings,
- channels: nextChannels,
- };
+ return nextAnimations;
}
function cloneAnimationsState({
@@ -82,34 +89,88 @@ function cloneAnimationsState({
}: {
animations: ElementAnimations | undefined;
}): ElementAnimations {
- return {
- bindings: { ...(animations?.bindings ?? {}) },
- channels: { ...(animations?.channels ?? {}) },
- };
+ return { ...(animations ?? {}) };
}
-function getBindingChannelKind({
- kind,
+function getChannelFromData({
+ data,
+ componentKey,
}: {
- kind: AnimationBindingKind;
-}): AnimationChannel["kind"] {
- return kind === "discrete" ? "discrete" : "scalar";
+ data: ChannelData | undefined;
+ componentKey: string;
+}): AnimationChannel | undefined {
+ if (isLeafChannelData(data)) {
+ return componentKey === "value" ? data : undefined;
+ }
+ if (isCompositeChannelData(data)) {
+ return data[componentKey];
+ }
+ return undefined;
}
-function getPrimaryComponent({
- binding,
+type LayoutComponent = ChannelComponentDefinition<string>;
+
+function getLayoutComponents({
+ channelLayout,
}: {
- binding: AnimationBindingInstance;
-}) {
- return binding.components[0] ?? null;
+ channelLayout: ParamChannelLayout;
+}): LayoutComponent[] {
+ return channelLayout.kind === "leaf"
+ ? [channelLayout.component]
+ : channelLayout.components;
}
-function getPrimaryChannelId({
- binding,
+function getPrimaryComponentKey({
+ channelLayout,
}: {
- binding: AnimationBindingInstance;
-}) {
- return getPrimaryComponent({ binding })?.channelId ?? null;
+ channelLayout: ParamChannelLayout;
+}): string {
+ return getLayoutComponents({ channelLayout })[0]?.key ?? "value";
+}
+
+function getPrimaryChannelFromData({
+ data,
+ channelLayout,
+}: {
+ data: ChannelData | undefined;
+ channelLayout: ParamChannelLayout;
+}): AnimationChannel | undefined {
+ return getChannelFromData({
+ data,
+ componentKey: getPrimaryComponentKey({ channelLayout }),
+ });
+}
+
+function setChannelInData({
+ data,
+ componentKey,
+ channel,
+}: {
+ data: ChannelData | undefined;
+ componentKey: string;
+ channel: AnimationChannel | undefined;
+}): ChannelData | undefined {
+ if (componentKey === "value") {
+ return channel;
+ }
+ const components = isCompositeChannelData(data) ? { ...data } : {};
+ if (channel && hasChannelKeys({ channel })) {
+ components[componentKey] = channel;
+ } else {
+ delete components[componentKey];
+ }
+ return Object.keys(components).length > 0 ? components : undefined;
+}
+
+function getChannelDataEntries({
+ data,
+}: {
+ data: ChannelData | undefined;
+}): Array<[string, AnimationChannel | undefined]> {
+ if (isLeafChannelData(data)) {
+ return [["value", data]];
+ }
+ return isCompositeChannelData(data) ? Object.entries(data) : [];
}
function getScalarSegmentType({
@@ -123,14 +184,14 @@ function getScalarSegmentType({
return interpolation === "bezier" ? "bezier" : "linear";
}
-function getInterpolationForBinding({
- kind,
+function getInterpolationForComponent({
+ component,
interpolation,
}: {
- kind: AnimationBindingKind;
+ component: LayoutComponent;
interpolation: AnimationInterpolation | undefined;
}): AnimationInterpolation {
- if (kind === "discrete") {
+ if (component.valueKind === "discrete") {
return "hold";
}
@@ -142,25 +203,25 @@ function getInterpolationForBinding({
return interpolation;
}
- return "linear";
+ return component.defaultInterpolation;
}
-function createEmptyChannelForBindingKind({
- kind,
+function decomposeChannelLayoutValue({
+ channelLayout,
+ value,
}: {
- kind: AnimationBindingKind;
-}): AnimationChannel {
- if (kind === "discrete") {
- return {
- kind: "discrete",
- keys: [],
- } satisfies DiscreteAnimationChannel;
+ channelLayout: ParamChannelLayout;
+ value: ParamValue;
+}): Record<string, ParamValue> | null {
+ if (channelLayout.kind === "leaf") {
+ return { [channelLayout.component.key]: value };
+ }
+ if (typeof value !== "string") {
+ return null;
}
- return {
- kind: "scalar",
- keys: [],
- } satisfies ScalarAnimationChannel;
+ const components = channelLayout.decompose(value);
+ return components ? { ...components } : null;
}
function createScalarKey({
@@ -205,34 +266,20 @@ function createDiscreteKey({
};
}
-function getBinding({
- animations,
- propertyPath,
-}: {
- animations: ElementAnimations | undefined;
- propertyPath: AnimationPath;
-}): AnimationBindingInstance | undefined {
- return animations?.bindings[propertyPath];
+function isDiscreteChannel(
+ channel: AnimationChannel | undefined,
+): channel is DiscreteAnimationChannel {
+ return channel != null && !isScalarChannel(channel);
}
-function getChannelById({
+function getChannelData({
animations,
- channelId,
+ propertyPath,
}: {
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;
+ propertyPath: AnimationPath;
+}): ChannelData | undefined {
+ return animations?.[propertyPath];
}
function getTargetKeyMetadata({
@@ -284,8 +331,8 @@ function upsertDiscreteChannelKey({
value: string | boolean;
keyframeId?: string;
}): DiscreteAnimationChannel {
- const normalizedChannel = normalizeChannel({
- channel: channel ?? { kind: "discrete", keys: [] },
+ const normalizedChannel = normalizeDiscreteChannel({
+ channel: channel ?? { keys: [] },
});
const keys = [...normalizedChannel.keys];
if (keyframeId) {
@@ -296,8 +343,8 @@ function upsertDiscreteChannelKey({
time,
value,
});
- return normalizeChannel({
- channel: { kind: "discrete", keys },
+ return normalizeDiscreteChannel({
+ channel: { keys },
});
}
}
@@ -311,8 +358,8 @@ function upsertDiscreteChannelKey({
time: keys[existingAtTimeIndex].time,
value,
});
- return normalizeChannel({
- channel: { kind: "discrete", keys },
+ return normalizeDiscreteChannel({
+ channel: { keys },
});
}
@@ -323,8 +370,8 @@ function upsertDiscreteChannelKey({
value,
}),
);
- return normalizeChannel({
- channel: { kind: "discrete", keys },
+ return normalizeDiscreteChannel({
+ channel: { keys },
});
}
@@ -343,8 +390,8 @@ function upsertScalarChannelKey({
defaultInterpolation?: AnimationInterpolation;
keyframeId?: string;
}): ScalarAnimationChannel {
- const normalizedChannel = normalizeChannel({
- channel: channel ?? { kind: "scalar", keys: [] },
+ const normalizedChannel = normalizeScalarChannel({
+ channel: channel ?? { keys: [] },
});
const keys = [...normalizedChannel.keys];
if (keyframeId) {
@@ -363,9 +410,8 @@ function upsertScalarChannelKey({
}
: keys[existingIndex],
});
- return normalizeChannel({
+ return normalizeScalarChannel({
channel: {
- kind: "scalar",
keys,
extrapolation: normalizedChannel.extrapolation,
},
@@ -390,9 +436,8 @@ function upsertScalarChannelKey({
}
: keys[existingAtTimeIndex],
});
- return normalizeChannel({
+ return normalizeScalarChannel({
channel: {
- kind: "scalar",
keys,
extrapolation: normalizedChannel.extrapolation,
},
@@ -407,9 +452,8 @@ function upsertScalarChannelKey({
interpolation: interpolation ?? defaultInterpolation,
}),
);
- return normalizeChannel({
+ return normalizeScalarChannel({
channel: {
- kind: "scalar",
keys,
extrapolation: normalizedChannel.extrapolation,
},
@@ -423,10 +467,11 @@ export function getChannel({
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;
+ const data = getChannelData({ animations, propertyPath });
+ if (isLeafChannelData(data)) {
+ return data;
+ }
+ return getChannelsFromData({ data })[0];
}
export function upsertPathKeyframe({
@@ -436,19 +481,17 @@ export function upsertPathKeyframe({
value,
interpolation,
keyframeId,
- kind,
- defaultInterpolation,
+ channelLayout,
coerceValue,
}: {
animations: ElementAnimations | undefined;
propertyPath: AnimationPath;
time: MediaTime;
- value: AnimationValue;
+ value: ParamValue;
interpolation?: AnimationInterpolation;
keyframeId?: string;
- kind: AnimationBindingKind;
- defaultInterpolation: AnimationInterpolation;
- coerceValue: ({ value }: { value: AnimationValue }) => AnimationValue | null;
+ channelLayout: ParamChannelLayout;
+ coerceValue: ({ value }: { value: ParamValue }) => ParamValue | null;
}): ElementAnimations | undefined {
const coercedValue = coerceValue({ value });
if (coercedValue === null) {
@@ -456,71 +499,78 @@ export function upsertPathKeyframe({
}
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 currentData = getChannelData({ animations, propertyPath });
+ const primaryChannel = getPrimaryChannelFromData({
+ data: currentData,
+ channelLayout,
});
const targetKey = getTargetKeyMetadata({
channel: primaryChannel,
time,
keyframeId,
});
- const componentValues = decomposeAnimationValue({
- kind,
+ const componentValues = decomposeChannelLayoutValue({
+ channelLayout,
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];
+ let nextData: ChannelData | undefined = currentData;
+ for (const component of getLayoutComponents({ channelLayout })) {
+ const componentKey = component.key;
+ const nextValue = componentValues[componentKey];
if (nextValue == null) {
continue;
}
- const currentChannel = getChannelById({
- animations,
- channelId: component.channelId,
+ const currentChannel = getChannelFromData({
+ data: currentData,
+ componentKey,
+ });
+ if (component.valueKind === "discrete") {
+ if (typeof nextValue !== "string" && typeof nextValue !== "boolean") {
+ continue;
+ }
+ const nextChannel = upsertDiscreteChannelKey({
+ channel: isDiscreteChannel(currentChannel) ? currentChannel : undefined,
+ time: targetKey.time,
+ value: nextValue,
+ keyframeId: targetKey.id,
+ });
+ nextData = setChannelInData({
+ data: nextData,
+ componentKey,
+ channel: nextChannel,
+ });
+ continue;
+ }
+
+ if (typeof nextValue !== "number") {
+ continue;
+ }
+ const nextChannel = upsertScalarChannelKey({
+ channel:
+ currentChannel != null && isScalarChannel(currentChannel)
+ ? currentChannel
+ : undefined,
+ time: targetKey.time,
+ value: nextValue,
+ interpolation:
+ interpolation != null
+ ? getInterpolationForComponent({ component, interpolation })
+ : undefined,
+ defaultInterpolation: component.defaultInterpolation,
+ keyframeId: targetKey.id,
+ });
+ nextData = setChannelInData({
+ data: nextData,
+ componentKey,
+ channel: nextChannel,
});
- 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,
- });
}
+ nextAnimations[propertyPath] = nextData;
return toAnimation({
animations: nextAnimations,
@@ -536,7 +586,7 @@ export function upsertKeyframe({
}: {
channel: AnimationChannel | undefined;
time: MediaTime;
- value: AnimationValue;
+ value: ParamValue;
interpolation?: AnimationInterpolation;
keyframeId?: string;
}): AnimationChannel | undefined {
@@ -544,13 +594,9 @@ export function upsertKeyframe({
return undefined;
}
- if (channel.kind === "discrete") {
- if (typeof value !== "string" && typeof value !== "boolean") {
- return channel;
- }
-
+ if (typeof value === "string" || typeof value === "boolean") {
return upsertDiscreteChannelKey({
- channel,
+ channel: isDiscreteChannel(channel) ? channel : undefined,
time,
value,
keyframeId,
@@ -562,7 +608,7 @@ export function upsertKeyframe({
}
return upsertScalarChannelKey({
- channel,
+ channel: isScalarChannel(channel) ? channel : undefined,
time,
value,
interpolation,
@@ -581,16 +627,30 @@ export function removeKeyframe({
return undefined;
}
+ if (isScalarChannel(channel)) {
+ const nextKeys = channel.keys.filter((keyframe) => keyframe.id !== keyframeId);
+ if (nextKeys.length === 0) {
+ return undefined;
+ }
+
+ return normalizeScalarChannel({
+ channel: {
+ ...channel,
+ keys: nextKeys,
+ },
+ });
+ }
+
const nextKeys = channel.keys.filter((keyframe) => keyframe.id !== keyframeId);
if (nextKeys.length === 0) {
return undefined;
}
- return normalizeChannel({
+ return normalizeDiscreteChannel({
channel: {
...channel,
keys: nextKeys,
- } as AnimationChannel,
+ },
});
}
@@ -607,6 +667,28 @@ export function retimeKeyframe({
return undefined;
}
+ if (isScalarChannel(channel)) {
+ const keyframeByIdIndex = channel.keys.findIndex(
+ (keyframe) => keyframe.id === keyframeId,
+ );
+ if (keyframeByIdIndex < 0) {
+ return channel;
+ }
+
+ const nextKeys = [...channel.keys];
+ nextKeys[keyframeByIdIndex] = {
+ ...nextKeys[keyframeByIdIndex],
+ time,
+ };
+
+ return normalizeScalarChannel({
+ channel: {
+ ...channel,
+ keys: nextKeys,
+ },
+ });
+ }
+
const keyframeByIdIndex = channel.keys.findIndex(
(keyframe) => keyframe.id === keyframeId,
);
@@ -620,11 +702,11 @@ export function retimeKeyframe({
time,
};
- return normalizeChannel({
+ return normalizeDiscreteChannel({
channel: {
...channel,
keys: nextKeys,
- } as AnimationChannel,
+ },
});
}
@@ -637,26 +719,10 @@ export function setChannel({
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,
+ componentKey: "value",
channel,
});
}
@@ -672,37 +738,14 @@ export function setBindingComponentChannel({
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,
+ nextAnimations[propertyPath] = setChannelInData({
+ data: nextAnimations[propertyPath],
+ componentKey,
+ channel:
+ channel && hasChannelKeys({ channel })
+ ? normalizeChannel({ channel })
+ : undefined,
});
return toAnimation({
animations: nextAnimations,
@@ -722,24 +765,11 @@ export function updateScalarKeyframeCurve({
keyframeId: string;
patch: ScalarCurveKeyframePatch;
}): ElementAnimations | undefined {
- const binding = getBinding({ animations, propertyPath });
- if (!binding) {
- return animations;
- }
-
- const component = getBindingComponent({
- binding,
+ const channel = getChannelFromData({
+ data: getChannelData({ animations, propertyPath }),
componentKey,
});
- if (!component) {
- return animations;
- }
-
- const channel = getChannelById({
- animations,
- channelId: component.channelId,
- });
- if (channel?.kind !== "scalar") {
+ if (!channel || !isScalarChannel(channel)) {
return animations;
}
@@ -769,13 +799,40 @@ export function updateScalarKeyframeCurve({
propertyPath,
componentKey,
channel: {
- kind: "scalar",
keys: nextKeys,
extrapolation: channel.extrapolation,
},
});
}
+function cloneChannelWithKeyIds({
+ channel,
+ keyIdMap,
+}: {
+ channel: AnimationChannel;
+ keyIdMap: Map<string, string>;
+}): AnimationChannel {
+ return isScalarChannel(channel)
+ ? normalizeScalarChannel({
+ channel: {
+ ...channel,
+ keys: channel.keys.map((key) => ({
+ ...key,
+ id: keyIdMap.get(key.id) ?? key.id,
+ })),
+ },
+ })
+ : normalizeDiscreteChannel({
+ channel: {
+ ...channel,
+ keys: channel.keys.map((key) => ({
+ ...key,
+ id: keyIdMap.get(key.id) ?? key.id,
+ })),
+ },
+ });
+}
+
export function cloneAnimations({
animations,
shouldRegenerateKeyframeIds = false,
@@ -788,23 +845,11 @@ export function cloneAnimations({
}
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 }) ?? "",
- });
+ for (const [propertyPath, data] of Object.entries(animations).filter(([key]) =>
+ isAnimationStorageKey({ key }),
+ )) {
+ const channels = getChannelsFromData({ data });
+ const primaryChannel = channels[0];
const keyIdMap = new Map<string, string>();
if (primaryChannel) {
for (const key of primaryChannel.keys) {
@@ -815,24 +860,22 @@ export function cloneAnimations({
}
}
- 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,
+ if (isLeafChannelData(data)) {
+ nextAnimations[propertyPath] = cloneChannelWithKeyIds({
+ channel: data,
+ keyIdMap,
});
+ continue;
+ }
+ if (isCompositeChannelData(data)) {
+ nextAnimations[propertyPath] = Object.fromEntries(
+ Object.entries(data).map(([componentKey, channel]) => [
+ componentKey,
+ channel
+ ? cloneChannelWithKeyIds({ channel, keyIdMap })
+ : undefined,
+ ]),
+ );
}
}
@@ -921,7 +964,7 @@ function splitDiscreteChannelAtTime({
createDiscreteKey({
id: leftBoundaryId,
time: splitTime,
- value: boundaryValue as string | boolean,
+ value: boundaryValue,
}),
];
}
@@ -930,7 +973,7 @@ function splitDiscreteChannelAtTime({
createDiscreteKey({
id: rightBoundaryId,
time: ZERO_MEDIA_TIME,
- value: boundaryValue as string | boolean,
+ value: boundaryValue,
}),
...rightKeys,
];
@@ -939,10 +982,10 @@ function splitDiscreteChannelAtTime({
return {
leftChannel: leftKeys.length
- ? normalizeChannel({ channel: { kind: "discrete", keys: leftKeys } })
+ ? normalizeChannel({ channel: { keys: leftKeys } })
: undefined,
rightChannel: rightKeys.length
- ? normalizeChannel({ channel: { kind: "discrete", keys: rightKeys } })
+ ? normalizeChannel({ channel: { keys: rightKeys } })
: undefined,
};
}
@@ -987,7 +1030,6 @@ function splitScalarChannelAtTime({
leftChannel: leftKeys.length
? normalizeChannel({
channel: {
- kind: "scalar",
keys: leftKeys,
extrapolation: normalizedChannel.extrapolation,
},
@@ -996,7 +1038,6 @@ function splitScalarChannelAtTime({
rightChannel: rightKeys.length
? normalizeChannel({
channel: {
- kind: "scalar",
keys: rightKeys,
extrapolation: normalizedChannel.extrapolation,
},
@@ -1021,7 +1062,7 @@ function splitScalarChannelAtTime({
channel: normalizedChannel,
time: splitTime,
fallbackValue: leftKey.value,
- }) as number;
+ });
if (leftKey.segmentToNext === "bezier") {
const rightHandle =
@@ -1123,14 +1164,12 @@ function splitScalarChannelAtTime({
return {
leftChannel: normalizeChannel({
channel: {
- kind: "scalar",
keys: leftKeys,
extrapolation: normalizedChannel.extrapolation,
},
}),
rightChannel: normalizeChannel({
channel: {
- kind: "scalar",
keys: rightKeys,
extrapolation: normalizedChannel.extrapolation,
},
@@ -1142,7 +1181,6 @@ function splitScalarChannelAtTime({
leftChannel: leftKeys.length
? normalizeChannel({
channel: {
- kind: "scalar",
keys: leftKeys,
extrapolation: normalizedChannel.extrapolation,
},
@@ -1151,7 +1189,6 @@ function splitScalarChannelAtTime({
rightChannel: rightKeys.length
? normalizeChannel({
channel: {
- kind: "scalar",
keys: rightKeys,
extrapolation: normalizedChannel.extrapolation,
},
@@ -1160,6 +1197,37 @@ function splitScalarChannelAtTime({
};
}
+function splitChannelAtTime({
+ channel,
+ splitTime,
+ leftBoundaryId,
+ rightBoundaryId,
+ shouldIncludeSplitBoundary,
+}: {
+ channel: AnimationChannel | undefined;
+ splitTime: MediaTime;
+ leftBoundaryId: string;
+ rightBoundaryId: string;
+ shouldIncludeSplitBoundary: boolean;
+}) {
+ return channel != null && !isScalarChannel(channel)
+ ? splitDiscreteChannelAtTime({
+ channel,
+ splitTime,
+ leftBoundaryId,
+ rightBoundaryId,
+ shouldIncludeSplitBoundary,
+ })
+ : splitScalarChannelAtTime({
+ channel:
+ channel != null && isScalarChannel(channel) ? channel : undefined,
+ splitTime,
+ leftBoundaryId,
+ rightBoundaryId,
+ shouldIncludeSplitBoundary,
+ });
+}
+
export function splitAnimationsAtTime({
animations,
splitTime,
@@ -1179,55 +1247,39 @@ export function splitAnimationsAtTime({
const leftAnimations = cloneAnimationsState({ animations: undefined });
const rightAnimations = cloneAnimationsState({ animations: undefined });
- for (const [propertyPath, binding] of Object.entries(animations.bindings)) {
- if (!binding) {
+ for (const [propertyPath, data] of Object.entries(animations).filter(([key]) =>
+ isAnimationStorageKey({ key }),
+ )) {
+ if (!data) {
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,
+ for (const [componentKey, channel] of getChannelDataEntries({ data })) {
+ const splitResult = splitChannelAtTime({
+ channel,
+ splitTime,
+ leftBoundaryId,
+ rightBoundaryId,
+ shouldIncludeSplitBoundary,
});
- 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;
+ leftAnimations[propertyPath] = setChannelInData({
+ data: leftAnimations[propertyPath],
+ componentKey,
+ channel: splitResult.leftChannel,
+ });
}
if (splitResult.rightChannel) {
- rightAnimations.channels[component.channelId] = splitResult.rightChannel;
- hasRightKeys = true;
+ rightAnimations[propertyPath] = setChannelInData({
+ data: rightAnimations[propertyPath],
+ componentKey,
+ channel: splitResult.rightChannel,
+ });
}
}
-
- if (hasLeftKeys) {
- leftAnimations.bindings[propertyPath] = leftBinding;
- }
- if (hasRightKeys) {
- rightAnimations.bindings[propertyPath] = rightBinding;
- }
}
return {
@@ -1245,28 +1297,24 @@ export function removeElementKeyframe({
propertyPath: AnimationPath;
keyframeId: string;
}): ElementAnimations | undefined {
- const binding = getBinding({ animations, propertyPath });
- if (!binding) {
+ const data = getChannelData({ animations, propertyPath });
+ if (!data) {
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];
+ if (isLeafChannelData(data)) {
+ nextAnimations[propertyPath] = removeKeyframe({ channel: data, keyframeId });
+ } else if (isCompositeChannelData(data)) {
+ let nextData: ChannelData | undefined = data;
+ for (const [componentKey, channel] of Object.entries(data)) {
+ nextData = setChannelInData({
+ data: nextData,
+ componentKey,
+ channel: removeKeyframe({ channel, keyframeId }),
+ });
}
+ nextAnimations[propertyPath] = nextData;
}
return toAnimation({
animations: nextAnimations,
@@ -1284,18 +1332,28 @@ export function retimeElementKeyframe({
keyframeId: string;
time: MediaTime;
}): ElementAnimations | undefined {
- const binding = getBinding({ animations, propertyPath });
- if (!binding) {
+ const data = getChannelData({ animations, propertyPath });
+ if (!data) {
return animations;
}
const nextAnimations = cloneAnimationsState({ animations });
- for (const component of binding.components) {
- nextAnimations.channels[component.channelId] = retimeKeyframe({
- channel: nextAnimations.channels[component.channelId],
+ if (isLeafChannelData(data)) {
+ nextAnimations[propertyPath] = retimeKeyframe({
+ channel: data,
keyframeId,
time,
});
+ } else if (isCompositeChannelData(data)) {
+ let nextData: ChannelData | undefined = data;
+ for (const [componentKey, channel] of Object.entries(data)) {
+ nextData = setChannelInData({
+ data: nextData,
+ componentKey,
+ channel: retimeKeyframe({ channel, keyframeId, time }),
+ });
+ }
+ nextAnimations[propertyPath] = nextData;
}
return toAnimation({
animations: nextAnimations,
diff --git a/apps/web/src/animation/resolve.ts b/apps/web/src/animation/resolve.ts
index 19685cea..331dcbaa 100644
--- a/apps/web/src/animation/resolve.ts
+++ b/apps/web/src/animation/resolve.ts
@@ -1,15 +1,13 @@
import type {
AnimationPath,
- AnimationValueForPath,
ElementAnimations,
} from "@/animation/types";
-import {
- type AnimationComponentValue,
- composeAnimationValue,
- decomposeAnimationValue,
-} from "./binding-values";
+import { formatLinearRgba, parseColorToLinearRgba } from "@/params";
+import type { ParamValue } from "@/params";
+import { isCompositeChannelData, isLeafChannelData } from "./channel-data";
import {
getChannelValueAtTime,
+ isScalarChannel,
} from "./interpolation";
export function getElementLocalTime({
@@ -33,47 +31,123 @@ export function getElementLocalTime({
return localTime;
}
-export function resolveAnimationPathValueAtTime<TPath extends AnimationPath>({
+export function resolveAnimationPathValueAtTime({
+ animations,
+ propertyPath,
+ localTime,
+ fallbackValue,
+}: {
+ animations: ElementAnimations | undefined;
+ propertyPath: AnimationPath;
+ localTime: number;
+ fallbackValue: number;
+}): number;
+export function resolveAnimationPathValueAtTime({
+ animations,
+ propertyPath,
+ localTime,
+ fallbackValue,
+}: {
+ animations: ElementAnimations | undefined;
+ propertyPath: AnimationPath;
+ localTime: number;
+ fallbackValue: string;
+}): string;
+export function resolveAnimationPathValueAtTime({
+ animations,
+ propertyPath,
+ localTime,
+ fallbackValue,
+}: {
+ animations: ElementAnimations | undefined;
+ propertyPath: AnimationPath;
+ localTime: number;
+ fallbackValue: boolean;
+}): boolean;
+export function resolveAnimationPathValueAtTime({
+ animations,
+ propertyPath,
+ localTime,
+ fallbackValue,
+}: {
+ animations: ElementAnimations | undefined;
+ propertyPath: AnimationPath;
+ localTime: number;
+ fallbackValue: ParamValue;
+}): ParamValue;
+export function resolveAnimationPathValueAtTime({
animations,
propertyPath,
localTime,
fallbackValue,
}: {
animations: ElementAnimations | undefined;
- propertyPath: TPath;
+ propertyPath: AnimationPath;
localTime: number;
- fallbackValue: AnimationValueForPath<TPath>;
-}): AnimationValueForPath<TPath> {
- const binding = animations?.bindings[propertyPath];
- if (!binding) {
+ fallbackValue: ParamValue;
+}): ParamValue {
+ const data = animations?.[propertyPath];
+ if (!data) {
+ return fallbackValue;
+ }
+ if (isLeafChannelData(data)) {
+ if (typeof fallbackValue === "number") {
+ return getChannelValueAtTime({
+ channel: isScalarChannel(data) ? data : undefined,
+ time: localTime,
+ fallbackValue,
+ });
+ }
+ if (typeof fallbackValue === "string" || typeof fallbackValue === "boolean") {
+ return getChannelValueAtTime({
+ channel: !isScalarChannel(data) ? data : undefined,
+ time: localTime,
+ fallbackValue,
+ });
+ }
+ return fallbackValue;
+ }
+ if (!isCompositeChannelData(data)) {
return fallbackValue;
}
- const fallbackComponents = decomposeAnimationValue({
- kind: binding.kind,
- value: fallbackValue,
- });
- if (!fallbackComponents) {
+ if (
+ typeof fallbackValue !== "string" ||
+ !("r" in data) ||
+ !("g" in data) ||
+ !("b" in data) ||
+ !("a" in data)
+ ) {
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>;
+ const fallbackComponents = parseColorToLinearRgba({ color: fallbackValue });
+ if (fallbackComponents === null) {
+ return fallbackValue;
+ }
+
+ return formatLinearRgba({
+ color: {
+ r: getChannelValueAtTime({
+ channel: data.r && isScalarChannel(data.r) ? data.r : undefined,
+ time: localTime,
+ fallbackValue: fallbackComponents.r,
+ }),
+ g: getChannelValueAtTime({
+ channel: data.g && isScalarChannel(data.g) ? data.g : undefined,
+ time: localTime,
+ fallbackValue: fallbackComponents.g,
+ }),
+ b: getChannelValueAtTime({
+ channel: data.b && isScalarChannel(data.b) ? data.b : undefined,
+ time: localTime,
+ fallbackValue: fallbackComponents.b,
+ }),
+ a: getChannelValueAtTime({
+ channel: data.a && isScalarChannel(data.a) ? data.a : undefined,
+ time: localTime,
+ fallbackValue: fallbackComponents.a,
+ }),
+ },
+ });
}
diff --git a/apps/web/src/animation/types.ts b/apps/web/src/animation/types.ts
index 08fced74..9b5bd0c7 100644
--- a/apps/web/src/animation/types.ts
+++ b/apps/web/src/animation/types.ts
@@ -1,4 +1,5 @@
import type { MediaTime } from "@/wasm";
+import type { ParamValue } from "@/params";
export const ANIMATION_PROPERTY_PATHS = [
"transform.positionX",
@@ -28,44 +29,21 @@ export const ANIMATION_PROPERTY_GROUPS = {
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 = number | string | boolean;
export interface NumericSpec {
min?: number;
max?: number;
step?: number;
}
-export type AnimationValueForPath<TPath extends AnimationPath> =
- TPath extends AnimationPropertyPath
- ? AnimationPropertyValueMap[TPath]
- : TPath extends GraphicParamPath | EffectParamPath
- ? DynamicAnimationPathValue
- : DynamicAnimationPathValue;
-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 AnimationColorPropertyPath = Extract<
+ AnimationPropertyPath,
+ "color" | "background.color"
+>;
+export type AnimationNumericPropertyPath = Exclude<
+ AnimationPropertyPath,
+ AnimationColorPropertyPath
+>;
export type ContinuousKeyframeInterpolation = "linear" | "hold" | "bezier";
export type DiscreteKeyframeInterpolation = "hold";
@@ -73,8 +51,6 @@ 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";
@@ -84,7 +60,7 @@ export interface CurveHandle {
dv: number;
}
-interface BaseAnimationKeyframe<TValue extends number | DiscreteValue> {
+interface BaseAnimationKeyframe<TValue extends ParamValue> {
id: string;
time: MediaTime; // relative to element start time
value: TValue;
@@ -97,13 +73,16 @@ export interface ScalarAnimationKey extends BaseAnimationKeyframe<number> {
tangentMode: TangentMode;
}
-export interface DiscreteAnimationKey
- extends BaseAnimationKeyframe<DiscreteValue> {}
+export type DiscreteAnimationKey = BaseAnimationKeyframe<DiscreteValue>;
-export type AnimationKeyframe = ScalarAnimationKey | DiscreteAnimationKey;
+export type Keyframe<TValue extends ParamValue = ParamValue> =
+ TValue extends number
+ ? ScalarAnimationKey
+ : TValue extends DiscreteValue
+ ? DiscreteAnimationKey
+ : never;
-export interface ScalarAnimationChannel {
- kind: "scalar";
+export interface ScalarChannel {
keys: ScalarAnimationKey[];
extrapolation?: {
before: ChannelExtrapolationMode;
@@ -111,72 +90,26 @@ export interface ScalarAnimationChannel {
};
}
-export interface DiscreteAnimationChannel {
- kind: "discrete";
+export interface DiscreteChannel {
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 Channel<TValue extends ParamValue = ParamValue> =
+ TValue extends number
+ ? ScalarChannel
+ : TValue extends DiscreteValue
+ ? DiscreteChannel
+ : never;
-export type AnimationBindingInstance =
- | NumberAnimationBinding
- | Vector2AnimationBinding
- | ColorAnimationBinding
- | DiscreteAnimationBinding;
+export type ScalarAnimationChannel = Channel<number>;
+export type DiscreteAnimationChannel = Channel<DiscreteValue>;
+export type AnimationChannel = Channel;
-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 type CompositeChannelData = Record<string, AnimationChannel | undefined>;
+export type ChannelData = AnimationChannel | CompositeChannelData;
export interface ElementAnimations {
- bindings: ElementAnimationBindingMap;
- channels: ElementAnimationChannelMap;
+ [propertyPath: AnimationPath]: ChannelData | undefined;
}
export type NormalizedCubicBezier = [number, number, number, number];
@@ -184,7 +117,6 @@ export type NormalizedCubicBezier = [number, number, number, number];
export interface ScalarGraphChannelTarget {
propertyPath: AnimationPath;
componentKey: string;
- channelId: string;
}
export interface ScalarGraphChannel extends ScalarGraphChannelTarget {
@@ -213,7 +145,7 @@ export interface ElementKeyframe {
propertyPath: AnimationPath;
id: string;
time: MediaTime;
- value: AnimationValue;
+ value: ParamValue;
interpolation: AnimationInterpolation;
}