diff --git a/README.md b/README.md
index af46df56..79bcf310 100644
--- a/README.md
+++ b/README.md
@@ -1,27 +1,13 @@
-
-
-
-
- |
-
- OpenCut
- A free, open-source video editor for web, desktop, and mobile.
- |
-
-
+
+| | |
+| --- | ---------------------------------------------------------------------- |
+| | OpenCutA free, open-source video editor for web, desktop, and mobile. |
+
## Sponsors
Thanks to [Vercel](https://vercel.com?utm_source=github-opencut&utm_campaign=oss) and [fal.ai](https://fal.ai?utm_source=github-opencut&utm_campaign=oss) for their support of open-source software.
-
-
-
-
-
-
-
-
## Why?
- **Privacy**: Your videos stay on your device
@@ -47,29 +33,23 @@ Thanks to [Vercel](https://vercel.com?utm_source=github-opencut&utm_campaign=oss
### Setup
1. Fork and clone the repository
-
2. Copy the environment file:
-
- ```bash
+ ```bash
# Unix/Linux/Mac
cp apps/web/.env.example apps/web/.env.local
# Windows PowerShell
Copy-Item apps/web/.env.example apps/web/.env.local
- ```
-
+ ```
3. Start the database and Redis:
-
- ```bash
+ ```bash
docker compose up -d db redis serverless-redis-http
- ```
-
+ ```
4. Install dependencies and start the dev server:
-
- ```bash
+ ```bash
bun install
bun dev:web
- ```
+ ```
The application will be available at [http://localhost:3000](http://localhost:3000).
@@ -79,7 +59,7 @@ The `.env.example` has sensible defaults that match the Docker Compose config
Desktop is opt-in. If you're only working on the web app, skip this entirely.
-If you want to get ready for `apps/desktop`, see [`apps/desktop/README.md`](apps/desktop/README.md). It's a two-step setup: Rust toolchain first, then desktop native dependencies.
+If you want to get ready for `apps/desktop`, see `[apps/desktop/README.md](apps/desktop/README.md)`. It's a two-step setup: Rust toolchain first, then desktop native dependencies.
### Local WASM development
@@ -99,30 +79,23 @@ cargo install cargo-watch
```
1. Build the package once from the repo root:
-
- ```bash
+ ```bash
bun run build:wasm
- ```
-
+ ```
2. Register the generated package for linking:
-
- ```bash
+ ```bash
cd rust/wasm/pkg
bun link
- ```
-
+ ```
3. Link `apps/web` to the local package:
-
- ```bash
+ ```bash
cd apps/web
bun link opencut-wasm
- ```
-
+ ```
4. Rebuild on changes while you work:
-
- ```bash
+ ```bash
bun dev:wasm
- ```
+ ```
To switch `apps/web` back to the published package, run:
@@ -155,7 +128,7 @@ See our [Contributing Guide](.github/CONTRIBUTING.md) for detailed setup instruc
- Fork the repo and clone locally
- Follow the setup instructions in CONTRIBUTING.md
-- Working on `apps/desktop`? See [`apps/desktop/README.md`](apps/desktop/README.md) for setup
+- Working on `apps/desktop`? See `[apps/desktop/README.md](apps/desktop/README.md)` for setup
- Create a feature branch and submit a PR
## License
@@ -164,4 +137,4 @@ See our [Contributing Guide](.github/CONTRIBUTING.md) for detailed setup instruc
---
-
+Star History Chart
\ No newline at end of file
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 {
- 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({
- path,
- key,
-}: {
- path: AnimationPath;
- key: TKey;
-}): AnimationBindingComponent {
- return {
- key,
- channelId: buildBindingChannelId({ path, componentKey: key }),
- };
-}
-
-function cloneBindingComponents({
- components,
-}: {
- components: AnimationBindingComponent[];
-}): AnimationBindingComponent[] {
- 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;
-};
-
-export function createAnimationBinding({
- path,
- kind,
-}: {
- path: AnimationPath;
- kind: TKind;
-}): AnimationBindingOfKind;
-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;
- }) => AnimationBindingOfKind;
-};
-
-export function cloneAnimationBinding({
- binding,
-}: {
- binding: AnimationBindingOfKind;
-}): AnimationBindingOfKind;
-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 | 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;
-}): 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 {
+ 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
@@ -64,11 +64,6 @@ export {
type GroupKeyframeRef,
} from "./property-groups";
-export {
- type EasingMode,
- getEasingModeForKind,
-} from "./binding-values";
-
export {
isAnimationPath,
isAnimationPropertyPath,
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({
+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({
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 | 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 | 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) {
@@ -349,6 +372,24 @@ export function getDiscreteChannelValueAtTime({
return currentValue;
}
+export function getChannelValueAtTime({
+ channel,
+ time,
+ fallbackValue,
+}: {
+ channel: Channel | undefined;
+ time: number;
+ fallbackValue: number;
+}): number;
+export function getChannelValueAtTime({
+ channel,
+ time,
+ fallbackValue,
+}: {
+ channel: DiscreteAnimationChannel | undefined;
+ time: number;
+ fallbackValue: TValue;
+}): TValue;
export function getChannelValueAtTime({
channel,
time,
@@ -356,14 +397,14 @@ export function getChannelValueAtTime({
}: {
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;
-
- 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;
+
+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 | 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({
+function isDiscreteChannel(
+ channel: AnimationChannel | undefined,
+): channel is DiscreteAnimationChannel {
+ return channel != null && !isScalarChannel(channel);
+}
+
+function getChannelData({
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;
+}): 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;
+}): 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();
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({
+export function resolveAnimationPathValueAtTime({
animations,
propertyPath,
localTime,
fallbackValue,
}: {
animations: ElementAnimations | undefined;
- propertyPath: TPath;
+ propertyPath: AnimationPath;
localTime: number;
- fallbackValue: AnimationValueForPath;
-}): AnimationValueForPath {
- const binding = animations?.bindings[propertyPath];
- if (!binding) {
+ 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: AnimationPath;
+ localTime: number;
+ 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 (
+ typeof fallbackValue !== "string" ||
+ !("r" in data) ||
+ !("g" in data) ||
+ !("b" in data) ||
+ !("a" in data)
+ ) {
+ return fallbackValue;
+ }
+
+ 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,
+ }),
+ },
});
- 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;
- return (composeAnimationValue({
- binding,
- componentValues,
- }) ?? fallbackValue) as AnimationValueForPath;
}
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 AnimationPropertyPath
- ? AnimationPropertyValueMap[TPath]
- : TPath extends GraphicParamPath | EffectParamPath
- ? DynamicAnimationPathValue
- : DynamicAnimationPathValue;
-export type AnimationNumericPropertyPath = {
- [K in AnimationPropertyPath]: AnimationValueForPath extends number ? K : never;
-}[AnimationPropertyPath];
-export type AnimationColorPropertyPath = {
- [K in AnimationPropertyPath]: AnimationValueForPath 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 {
+interface BaseAnimationKeyframe {
id: string;
time: MediaTime; // relative to element start time
value: TValue;
@@ -97,13 +73,16 @@ export interface ScalarAnimationKey extends BaseAnimationKeyframe {
tangentMode: TangentMode;
}
-export interface DiscreteAnimationKey
- extends BaseAnimationKeyframe {}
+export type DiscreteAnimationKey = BaseAnimationKeyframe;
-export type AnimationKeyframe = ScalarAnimationKey | DiscreteAnimationKey;
+export type Keyframe =
+ 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 Channel =
+ TValue extends number
+ ? ScalarChannel
+ : TValue extends DiscreteValue
+ ? DiscreteChannel
+ : never;
-export type ElementAnimationChannelMap = Record<
- string,
- AnimationChannel | undefined
->;
+export type ScalarAnimationChannel = Channel;
+export type DiscreteAnimationChannel = Channel;
+export type AnimationChannel = Channel;
-export interface AnimationBindingComponent {
- key: TKey;
- channelId: string;
-}
-
-interface BaseAnimationBinding<
- TKind extends AnimationBindingKind,
- TComponentKey extends string,
-> {
- path: AnimationPath;
- kind: TKind;
- components: AnimationBindingComponent[];
-}
-
-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 =
- AnimationBindingByKind[TKind];
-
-export type ElementAnimationBindingMap = Record<
- string,
- AnimationBindingInstance | undefined
->;
+export type CompositeChannelData = Record;
+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;
}
diff --git a/apps/web/src/clipboard/handlers/keyframes.ts b/apps/web/src/clipboard/handlers/keyframes.ts
index 06879e3e..dde8384f 100644
--- a/apps/web/src/clipboard/handlers/keyframes.ts
+++ b/apps/web/src/clipboard/handlers/keyframes.ts
@@ -1,4 +1,6 @@
import { getKeyframeById } from "@/animation";
+import { getChannelEntriesFromData } from "@/animation/channel-data";
+import { isScalarChannel } from "@/animation/interpolation";
import type { SelectedKeyframeRef } from "@/animation/types";
import type { TimelineElement } from "@/timeline";
import { PasteKeyframesCommand } from "@/commands/timeline";
@@ -41,14 +43,13 @@ function getCurvePatches({
propertyPath: KeyframeClipboardItem["propertyPath"];
keyframeId: string;
}): KeyframeClipboardCurvePatch[] {
- const binding = element.animations?.bindings[propertyPath];
- if (!binding) {
+ const data = element.animations?.[propertyPath];
+ if (!data) {
return [];
}
- return binding.components.flatMap((component) => {
- const channel = element.animations?.channels[component.channelId];
- if (channel?.kind !== "scalar") {
+ return getChannelEntriesFromData({ data }).flatMap(([componentKey, channel]) => {
+ if (!channel || !isScalarChannel(channel)) {
return [];
}
@@ -61,7 +62,7 @@ function getCurvePatches({
return [
{
- componentKey: component.key,
+ componentKey,
patch: {
leftHandle: keyframe.leftHandle ?? null,
rightHandle: keyframe.rightHandle ?? null,
diff --git a/apps/web/src/clipboard/types.ts b/apps/web/src/clipboard/types.ts
index 1d469a30..24ca9105 100644
--- a/apps/web/src/clipboard/types.ts
+++ b/apps/web/src/clipboard/types.ts
@@ -2,10 +2,10 @@ import type { EditorCore } from "@/core";
import type {
AnimationInterpolation,
AnimationPath,
- AnimationValue,
ScalarCurveKeyframePatch,
SelectedKeyframeRef,
} from "@/animation/types";
+import type { ParamValue } from "@/params";
import type { Command } from "@/commands/base-command";
import type {
CreateTimelineElement,
@@ -28,7 +28,7 @@ export interface KeyframeClipboardCurvePatch {
export interface KeyframeClipboardItem {
propertyPath: AnimationPath;
timeOffset: MediaTime;
- value: AnimationValue;
+ value: ParamValue;
interpolation: AnimationInterpolation;
curvePatches: KeyframeClipboardCurvePatch[];
}
diff --git a/apps/web/src/commands/timeline/clipboard/paste-keyframes.ts b/apps/web/src/commands/timeline/clipboard/paste-keyframes.ts
index 8d970f4d..a587ef14 100644
--- a/apps/web/src/commands/timeline/clipboard/paste-keyframes.ts
+++ b/apps/web/src/commands/timeline/clipboard/paste-keyframes.ts
@@ -52,8 +52,7 @@ function pasteKeyframesIntoElement({
value: item.value,
interpolation: item.interpolation,
keyframeId: generateUUID(),
- kind: target.kind,
- defaultInterpolation: target.defaultInterpolation,
+ channelLayout: target.channelLayout,
coerceValue: target.coerceValue,
});
const pastedKeyframe = getKeyframeAtTime({
diff --git a/apps/web/src/commands/timeline/element/keyframes/remove-keyframe.ts b/apps/web/src/commands/timeline/element/keyframes/remove-keyframe.ts
index 07abe2be..ad12e680 100644
--- a/apps/web/src/commands/timeline/element/keyframes/remove-keyframe.ts
+++ b/apps/web/src/commands/timeline/element/keyframes/remove-keyframe.ts
@@ -5,7 +5,8 @@ import {
} from "@/animation";
import { Command, type CommandResult } from "@/commands/base-command";
import { updateElementInSceneTracks } from "@/timeline";
-import type { AnimationPath, AnimationValue } from "@/animation/types";
+import type { AnimationPath } from "@/animation/types";
+import type { ParamValue } from "@/params";
import type { SceneTracks, TimelineElement } from "@/timeline";
import { resolveAnimationTarget } from "@/timeline/animation-targets";
@@ -18,7 +19,7 @@ function removeKeyframeAndPersist({
element: TimelineElement;
propertyPath: AnimationPath;
keyframeId: string;
- valueAtPlayhead: AnimationValue | null;
+ valueAtPlayhead: ParamValue | null;
}): TimelineElement {
const target = resolveAnimationTarget({ element, path: propertyPath });
if (!target) {
@@ -52,7 +53,7 @@ export class RemoveKeyframeCommand extends Command {
private readonly elementId: string;
private readonly propertyPath: AnimationPath;
private readonly keyframeId: string;
- private readonly valueAtPlayhead: AnimationValue | null;
+ private readonly valueAtPlayhead: ParamValue | null;
constructor({
trackId,
@@ -65,7 +66,7 @@ export class RemoveKeyframeCommand extends Command {
elementId: string;
propertyPath: AnimationPath;
keyframeId: string;
- valueAtPlayhead: AnimationValue | null;
+ valueAtPlayhead: ParamValue | null;
}) {
super();
this.trackId = trackId;
diff --git a/apps/web/src/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts b/apps/web/src/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts
index 6eae0847..babe1ad0 100644
--- a/apps/web/src/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts
+++ b/apps/web/src/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts
@@ -90,8 +90,7 @@ export class UpsertEffectParamKeyframeCommand extends Command {
value: this.value,
interpolation: this.interpolation,
keyframeId: this.keyframeId,
- kind: target.kind,
- defaultInterpolation: target.defaultInterpolation,
+ channelLayout: target.channelLayout,
coerceValue: target.coerceValue,
});
return { ...element, animations };
diff --git a/apps/web/src/commands/timeline/element/keyframes/upsert-keyframe.ts b/apps/web/src/commands/timeline/element/keyframes/upsert-keyframe.ts
index e1b78f33..8ba13a11 100644
--- a/apps/web/src/commands/timeline/element/keyframes/upsert-keyframe.ts
+++ b/apps/web/src/commands/timeline/element/keyframes/upsert-keyframe.ts
@@ -7,8 +7,8 @@ import { resolveAnimationTarget } from "@/timeline/animation-targets";
import type {
AnimationPath,
AnimationInterpolation,
- AnimationValue,
} from "@/animation/types";
+import type { ParamValue } from "@/params";
import {
type MediaTime,
maxMediaTime,
@@ -22,7 +22,7 @@ export class UpsertKeyframeCommand extends Command {
private readonly elementId: string;
private readonly propertyPath: AnimationPath;
private readonly time: MediaTime;
- private readonly value: AnimationValue;
+ private readonly value: ParamValue;
private readonly interpolation: AnimationInterpolation | undefined;
private readonly keyframeId: string | undefined;
@@ -39,7 +39,7 @@ export class UpsertKeyframeCommand extends Command {
elementId: string;
propertyPath: AnimationPath;
time: MediaTime;
- value: AnimationValue;
+ value: ParamValue;
interpolation?: AnimationInterpolation;
keyframeId?: string;
}) {
@@ -83,8 +83,7 @@ export class UpsertKeyframeCommand extends Command {
value: this.value,
interpolation: this.interpolation,
keyframeId: this.keyframeId,
- kind: target.kind,
- defaultInterpolation: target.defaultInterpolation,
+ channelLayout: target.channelLayout,
coerceValue: target.coerceValue,
}),
};
diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts
index dc1ad9ca..0a0f7db0 100644
--- a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts
+++ b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts
@@ -13,8 +13,7 @@ import type {
} from "@/animation/types";
import {
coerceParamValue,
- getParamDefaultInterpolation,
- getParamValueKind,
+ getParamChannelLayout,
type ParamDefinition,
} from "@/params";
import type { TimelineElement } from "@/timeline";
@@ -86,10 +85,7 @@ export function useKeyframedParamProperty({
propertyPath: resolvedPropertyPath,
time: localTime,
value,
- kind: getParamValueKind({ param }),
- defaultInterpolation: getParamDefaultInterpolation({
- param,
- }),
+ channelLayout: getParamChannelLayout({ param }),
coerceValue: ({ value: nextValue }) =>
coerceParamValue({
param,
diff --git a/apps/web/src/core/managers/timeline-manager.ts b/apps/web/src/core/managers/timeline-manager.ts
index 5531f437..dfabca01 100644
--- a/apps/web/src/core/managers/timeline-manager.ts
+++ b/apps/web/src/core/managers/timeline-manager.ts
@@ -20,10 +20,9 @@ import { isElementMuted } from "@/timeline/audio-state";
import type {
AnimationPath,
AnimationInterpolation,
- AnimationValue,
- AnimationValueForPath,
ScalarCurveKeyframePatch,
} from "@/animation/types";
+import type { ParamValue } from "@/params";
import {
getElementLocalTime,
resolveAnimationPathValueAtTime,
@@ -487,7 +486,7 @@ export class TimelineManager {
elementId: string;
propertyPath: AnimationPath;
time: MediaTime;
- value: AnimationValue;
+ value: ParamValue;
interpolation?: AnimationInterpolation;
keyframeId?: string;
}>;
@@ -538,7 +537,7 @@ export class TimelineManager {
// Pre-sample values at playhead for each (element, property) pair.
// This preserves "what you see is what you get" when all keyframes are deleted.
const playheadTime = this.editor.playback.getCurrentTime();
- const valueAtPlayheadMap = new Map();
+ const valueAtPlayheadMap = new Map();
for (const { trackId, elementId, propertyPath } of keyframes) {
const key = `${elementId}:${propertyPath}`;
@@ -559,9 +558,7 @@ export class TimelineManager {
});
const target = resolveAnimationTarget({ element, path: propertyPath });
- const baseValue =
- (target?.getBaseValue() as AnimationValueForPath | null) ??
- null;
+ const baseValue = target?.getBaseValue() ?? null;
if (baseValue === null) {
valueAtPlayheadMap.set(key, null);
continue;
diff --git a/apps/web/src/params/__tests__/channel-layout.test.ts b/apps/web/src/params/__tests__/channel-layout.test.ts
new file mode 100644
index 00000000..e058418d
--- /dev/null
+++ b/apps/web/src/params/__tests__/channel-layout.test.ts
@@ -0,0 +1,10 @@
+import { describe, expect, test } from "bun:test";
+import { formatLinearRgba } from "@/params";
+
+describe("channel layout", () => {
+ test("formats linear animated colors as hex", () => {
+ expect(formatLinearRgba({ color: { r: 1, g: 0, b: 0, a: 1 } })).toBe(
+ "#ff0000",
+ );
+ });
+});
diff --git a/apps/web/src/params/index.ts b/apps/web/src/params/index.ts
index bb57a1c4..fa2550f8 100644
--- a/apps/web/src/params/index.ts
+++ b/apps/web/src/params/index.ts
@@ -1,10 +1,177 @@
-import { snapToStep } from "@/utils/math";
+import { converter, formatHex, formatHex8, parse } from "culori";
+import { clamp, snapToStep } from "@/utils/math";
export type ParamValue = number | string | boolean;
export type ParamValues = Record;
export type ParamGroup = "stroke";
+export type ChannelValueKind = "scalar" | "discrete";
+export type ChannelEasingMode = "independent" | "shared";
+
+export interface LinearRgba {
+ r: number;
+ g: number;
+ b: number;
+ a: number;
+}
+
+export interface ChannelComponentDefinition {
+ key: TKey;
+ valueKind: ChannelValueKind;
+ defaultInterpolation: "linear" | "hold";
+}
+
+export interface LeafChannelLayout {
+ kind: "leaf";
+ component: ChannelComponentDefinition<"value">;
+ easingMode: "independent";
+ decompose: (value: TValue) => { value: TValue };
+ compose: (components: { value?: TValue }) => TValue | null;
+}
+
+export interface CompositeChannelLayout<
+ TValue extends ParamValue = ParamValue,
+ TComponents extends object = Record,
+> {
+ kind: "composite";
+ components: Array>;
+ easingMode: ChannelEasingMode;
+ decompose: (value: TValue) => TComponents | null;
+ compose: (components: Partial) => TValue | null;
+}
+
+export type ChannelLayout<
+ TValue extends ParamValue = ParamValue,
+ TComponents extends object = Record,
+> = LeafChannelLayout | CompositeChannelLayout;
+
+export type ParamChannelLayout =
+ | LeafChannelLayout
+ | LeafChannelLayout
+ | LeafChannelLayout
+ | CompositeChannelLayout;
+
+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;
+}
+
+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: number;
+ g: number;
+ b: number;
+ alpha: number;
+ } = {
+ 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 }),
+ };
+ return rgb.alpha < 1 ? formatHex8(rgb) : formatHex(rgb);
+}
+
+function createLeafChannelLayout({
+ valueKind,
+ defaultInterpolation,
+}: {
+ valueKind: ChannelValueKind;
+ defaultInterpolation: "linear" | "hold";
+}): LeafChannelLayout {
+ return {
+ kind: "leaf",
+ component: {
+ key: "value",
+ valueKind,
+ defaultInterpolation,
+ },
+ easingMode: "independent",
+ decompose: (value) => ({ value }),
+ compose: ({ value }) => value ?? null,
+ };
+}
+
+export const NUMBER_CHANNEL_LAYOUT: LeafChannelLayout =
+ createLeafChannelLayout({
+ valueKind: "scalar",
+ defaultInterpolation: "linear",
+ });
+
+export const BOOLEAN_CHANNEL_LAYOUT: LeafChannelLayout =
+ createLeafChannelLayout({
+ valueKind: "discrete",
+ defaultInterpolation: "hold",
+ });
+
+export const STRING_CHANNEL_LAYOUT: LeafChannelLayout =
+ createLeafChannelLayout({
+ valueKind: "discrete",
+ defaultInterpolation: "hold",
+ });
+
+const colorComponent = (
+ key: keyof LinearRgba,
+): ChannelComponentDefinition => ({
+ key,
+ valueKind: "scalar",
+ defaultInterpolation: "linear",
+});
+
+export const COLOR_CHANNEL_LAYOUT: CompositeChannelLayout = {
+ kind: "composite",
+ components: [
+ colorComponent("r"),
+ colorComponent("g"),
+ colorComponent("b"),
+ colorComponent("a"),
+ ],
+ easingMode: "shared",
+ decompose: (value) => parseColorToLinearRgba({ color: value }),
+ compose: ({ r, g, b, a }) =>
+ typeof r === "number" &&
+ typeof g === "number" &&
+ typeof b === "number" &&
+ typeof a === "number"
+ ? formatLinearRgba({ color: { r, g, b, a } })
+ : null,
+};
+
interface BaseParamDefinition {
key: TKey;
label: string;
@@ -17,6 +184,7 @@ export interface NumberParamDefinition
extends BaseParamDefinition {
type: "number";
default: number;
+ channels?: LeafChannelLayout;
min: number;
max?: number;
step: number;
@@ -32,18 +200,21 @@ export interface BooleanParamDefinition
extends BaseParamDefinition {
type: "boolean";
default: boolean;
+ channels?: LeafChannelLayout;
}
export interface ColorParamDefinition
extends BaseParamDefinition {
type: "color";
default: string;
+ channels?: ChannelLayout;
}
export interface SelectParamDefinition
extends BaseParamDefinition {
type: "select";
default: string;
+ channels?: LeafChannelLayout;
options: Array<{ value: string; label: string }>;
}
@@ -51,12 +222,14 @@ export interface TextParamDefinition
extends BaseParamDefinition {
type: "text";
default: string;
+ channels?: LeafChannelLayout;
}
export interface FontParamDefinition
extends BaseParamDefinition {
type: "font";
default: string;
+ channels?: LeafChannelLayout;
}
export type ParamDefinition =
@@ -67,17 +240,41 @@ export type ParamDefinition =
| TextParamDefinition
| FontParamDefinition;
+export function getParamChannelLayout({
+ param,
+}: {
+ param: ParamDefinition;
+}): ParamChannelLayout {
+ switch (param.type) {
+ case "number":
+ return param.channels ?? NUMBER_CHANNEL_LAYOUT;
+ case "boolean":
+ return param.channels ?? BOOLEAN_CHANNEL_LAYOUT;
+ case "color":
+ return param.channels ?? COLOR_CHANNEL_LAYOUT;
+ case "select":
+ case "text":
+ case "font":
+ return param.channels ?? STRING_CHANNEL_LAYOUT;
+ default: {
+ const exhaustive: never = param;
+ return exhaustive;
+ }
+ }
+}
+
export function getParamValueKind({
param,
}: {
param: ParamDefinition;
}): "number" | "color" | "discrete" {
- if (param.type === "number") {
- return "number";
- }
- if (param.type === "color") {
+ const layout = getParamChannelLayout({ param });
+ if (layout.kind === "composite") {
return "color";
}
+ if (layout.component.valueKind === "scalar") {
+ return "number";
+ }
return "discrete";
}
@@ -86,7 +283,11 @@ export function getParamDefaultInterpolation({
}: {
param: ParamDefinition;
}): "linear" | "hold" {
- return param.type === "number" || param.type === "color" ? "linear" : "hold";
+ const layout = getParamChannelLayout({ param });
+ if (layout.kind === "leaf") {
+ return layout.component.defaultInterpolation;
+ }
+ return layout.components[0]?.defaultInterpolation ?? "linear";
}
export function getParamNumericRange({
@@ -112,27 +313,30 @@ export function coerceParamValue({
param: ParamDefinition;
value: unknown;
}): ParamValue | null {
- if (param.type === "number") {
- if (typeof value !== "number" || Number.isNaN(value)) {
- return null;
+ switch (param.type) {
+ case "number": {
+ if (typeof value !== "number" || Number.isNaN(value)) {
+ return null;
+ }
+
+ const steppedValue = snapToStep({ value, step: param.step });
+ const maxValue = param.max ?? Number.POSITIVE_INFINITY;
+ return Math.min(maxValue, Math.max(param.min, steppedValue));
+ }
+ case "boolean":
+ return typeof value === "boolean" ? value : null;
+ case "color":
+ case "text":
+ case "font":
+ return typeof value === "string" ? value : null;
+ case "select":
+ return typeof value === "string" &&
+ param.options.some((option) => option.value === value)
+ ? value
+ : null;
+ default: {
+ const exhaustive: never = param;
+ return exhaustive;
}
-
- const steppedValue = snapToStep({ value, step: param.step });
- const maxValue = param.max ?? Number.POSITIVE_INFINITY;
- return Math.min(maxValue, Math.max(param.min, steppedValue));
}
-
- if (param.type === "boolean") {
- return typeof value === "boolean" ? value : null;
- }
-
- if (param.type === "color" || param.type === "text" || param.type === "font") {
- return typeof value === "string" ? value : null;
- }
-
- if (typeof value !== "string") {
- return null;
- }
-
- return param.options.some((option) => option.value === value) ? value : null;
}
diff --git a/apps/web/src/services/storage/migrations/AGENTS.md b/apps/web/src/services/storage/migrations/AGENTS.md
new file mode 100644
index 00000000..5aed00a1
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/AGENTS.md
@@ -0,0 +1,10 @@
+# Agents.md
+
+## Migration Data Policy
+
+Migrations are additive only.
+
+- Do not delete, rename, or replace persisted data in a migration.
+- When a new storage shape is needed, add the new fields alongside the old fields.
+- Runtime code may prefer or ignore old fields, but the migration must preserve them for future down migrations.
+- If old data should ever be removed, that is a separate explicit cleanup task, not part of a version migration.
\ No newline at end of file
diff --git a/apps/web/src/services/storage/migrations/__tests__/v29-to-v30.test.ts b/apps/web/src/services/storage/migrations/__tests__/v29-to-v30.test.ts
new file mode 100644
index 00000000..c68f8dce
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/__tests__/v29-to-v30.test.ts
@@ -0,0 +1,139 @@
+import { describe, expect, test } from "bun:test";
+import { transformProjectV29ToV30 } from "../transformers/v29-to-v30";
+import { asRecord, asRecordArray } from "./helpers";
+
+describe("V29 to V30 Migration", () => {
+ test("flattens animation bindings and channels by path", () => {
+ const result = transformProjectV29ToV30({
+ project: {
+ id: "project-v29-animations",
+ version: 29,
+ scenes: [
+ {
+ id: "scene-1",
+ tracks: {
+ main: {
+ id: "main",
+ type: "video",
+ elements: [
+ {
+ id: "text-1",
+ type: "text",
+ animations: {
+ bindings: {
+ opacity: {
+ path: "opacity",
+ kind: "number",
+ components: [
+ { key: "value", channelId: "opacity:value" },
+ ],
+ },
+ color: {
+ path: "color",
+ kind: "color",
+ colorSpace: "srgb-linear",
+ components: [
+ { key: "r", channelId: "color:r" },
+ { key: "g", channelId: "color:g" },
+ { key: "b", channelId: "color:b" },
+ { key: "a", channelId: "color:a" },
+ ],
+ },
+ "params.label": {
+ path: "params.label",
+ kind: "discrete",
+ components: [
+ {
+ key: "value",
+ channelId: "params.label:value",
+ },
+ ],
+ },
+ },
+ channels: {
+ "opacity:value": {
+ kind: "scalar",
+ keys: [{ id: "o1", time: 0, value: 0.5 }],
+ },
+ "color:r": {
+ kind: "scalar",
+ keys: [{ id: "c1", time: 0, value: 1 }],
+ },
+ "color:g": {
+ kind: "scalar",
+ keys: [{ id: "c1", time: 0, value: 0.5 }],
+ },
+ "color:b": {
+ kind: "scalar",
+ keys: [{ id: "c1", time: 0, value: 0 }],
+ },
+ "color:a": {
+ kind: "scalar",
+ keys: [{ id: "c1", time: 0, value: 1 }],
+ },
+ "params.label:value": {
+ kind: "discrete",
+ keys: [{ id: "d1", time: 0, value: "Title" }],
+ },
+ },
+ },
+ },
+ ],
+ },
+ overlay: [],
+ audio: [],
+ },
+ },
+ ],
+ },
+ });
+
+ expect(result.skipped).toBe(false);
+ expect(result.project.version).toBe(30);
+ const scene = asRecordArray(result.project.scenes)[0];
+ const tracks = asRecord(scene.tracks);
+ const main = asRecord(tracks.main);
+ const element = asRecordArray(main.elements)[0];
+ const animations = asRecord(element.animations);
+ expect(animations.bindings).toBeDefined();
+ expect(animations.channels).toBeDefined();
+ expect(animations).toMatchObject({
+ opacity: {
+ keys: [{ id: "o1", time: 0, value: 0.5 }],
+ },
+ color: {
+ r: { keys: [{ id: "c1", time: 0, value: 1 }] },
+ g: { keys: [{ id: "c1", time: 0, value: 0.5 }] },
+ b: { keys: [{ id: "c1", time: 0, value: 0 }] },
+ a: { keys: [{ id: "c1", time: 0, value: 1 }] },
+ },
+ "params.label": {
+ keys: [{ id: "d1", time: 0, value: "Title" }],
+ },
+ });
+ });
+
+ test("leaves elements without animations unchanged", () => {
+ const result = transformProjectV29ToV30({
+ project: {
+ id: "project-v29-no-animations",
+ version: 29,
+ scenes: [
+ {
+ tracks: {
+ main: {
+ elements: [{ id: "image-1", type: "image" }],
+ },
+ },
+ },
+ ],
+ },
+ });
+
+ const scene = asRecordArray(result.project.scenes)[0];
+ const tracks = asRecord(scene.tracks);
+ const main = asRecord(tracks.main);
+ const element = asRecordArray(main.elements)[0];
+ expect(element).toEqual({ id: "image-1", type: "image" });
+ });
+});
diff --git a/apps/web/src/services/storage/migrations/index.ts b/apps/web/src/services/storage/migrations/index.ts
index 2e2c83bc..03b5ed0c 100644
--- a/apps/web/src/services/storage/migrations/index.ts
+++ b/apps/web/src/services/storage/migrations/index.ts
@@ -28,10 +28,11 @@ import { V25toV26Migration } from "./v25-to-v26";
import { V26toV27Migration } from "./v26-to-v27";
import { V27toV28Migration } from "./v27-to-v28";
import { V28toV29Migration } from "./v28-to-v29";
+import { V29toV30Migration } from "./v29-to-v30";
export { runStorageMigrations } from "./runner";
export type { MigrationProgress } from "./runner";
-export const CURRENT_PROJECT_VERSION = 29;
+export const CURRENT_PROJECT_VERSION = 30;
export const migrations = [
new V0toV1Migration(),
@@ -63,4 +64,5 @@ export const migrations = [
new V26toV27Migration(),
new V27toV28Migration(),
new V28toV29Migration(),
+ new V29toV30Migration(),
];
diff --git a/apps/web/src/services/storage/migrations/transformers/v29-to-v30.ts b/apps/web/src/services/storage/migrations/transformers/v29-to-v30.ts
new file mode 100644
index 00000000..f3050742
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/transformers/v29-to-v30.ts
@@ -0,0 +1,138 @@
+import type { MigrationResult, ProjectRecord } from "./types";
+import { getProjectId, isRecord } from "./utils";
+
+export function transformProjectV29ToV30({
+ project,
+}: {
+ project: ProjectRecord;
+}): MigrationResult {
+ if (!getProjectId({ project })) {
+ return { project, skipped: true, reason: "no project id" };
+ }
+
+ const version = project.version;
+ if (typeof version !== "number") {
+ return { project, skipped: true, reason: "invalid version" };
+ }
+ if (version >= 30) {
+ return { project, skipped: true, reason: "already v30" };
+ }
+ if (version !== 29) {
+ return { project, skipped: true, reason: "not v29" };
+ }
+
+ return {
+ project: {
+ ...migrateProject({ project }),
+ version: 30,
+ },
+ skipped: false,
+ };
+}
+
+function migrateProject({ project }: { project: ProjectRecord }): ProjectRecord {
+ const nextProject = { ...project };
+ if (Array.isArray(project.scenes)) {
+ nextProject.scenes = project.scenes.map((scene) => migrateScene({ scene }));
+ }
+ return nextProject;
+}
+
+function migrateScene({ scene }: { scene: unknown }): unknown {
+ if (!isRecord(scene)) {
+ return scene;
+ }
+
+ const nextScene = { ...scene };
+ if (isRecord(scene.tracks)) {
+ nextScene.tracks = migrateTracks({ tracks: scene.tracks });
+ }
+ return nextScene;
+}
+
+function migrateTracks({ tracks }: { tracks: ProjectRecord }): ProjectRecord {
+ const nextTracks = { ...tracks };
+ if (isRecord(tracks.main)) {
+ nextTracks.main = migrateTrack({ track: tracks.main });
+ }
+ if (Array.isArray(tracks.overlay)) {
+ nextTracks.overlay = tracks.overlay.map((track) => migrateTrack({ track }));
+ }
+ if (Array.isArray(tracks.audio)) {
+ nextTracks.audio = tracks.audio.map((track) => migrateTrack({ track }));
+ }
+ return nextTracks;
+}
+
+function migrateTrack({ track }: { track: unknown }): unknown {
+ if (!isRecord(track) || !Array.isArray(track.elements)) {
+ return track;
+ }
+
+ return {
+ ...track,
+ elements: track.elements.map((element) => migrateElement({ element })),
+ };
+}
+
+function migrateElement({ element }: { element: unknown }): unknown {
+ if (!isRecord(element) || !isRecord(element.animations)) {
+ return element;
+ }
+
+ return {
+ ...element,
+ animations: migrateAnimations({ animations: element.animations }),
+ };
+}
+
+function migrateAnimations({
+ animations,
+}: {
+ animations: ProjectRecord;
+}): ProjectRecord {
+ if (!isRecord(animations.bindings) || !isRecord(animations.channels)) {
+ return animations;
+ }
+
+ const channels = animations.channels;
+ const nextAnimations: ProjectRecord = { ...animations };
+ for (const binding of Object.values(animations.bindings)) {
+ if (!isRecord(binding) || typeof binding.path !== "string") {
+ continue;
+ }
+ if (!Array.isArray(binding.components)) {
+ continue;
+ }
+
+ const componentEntries = binding.components.flatMap((component) => {
+ if (
+ !isRecord(component) ||
+ typeof component.key !== "string" ||
+ typeof component.channelId !== "string"
+ ) {
+ return [];
+ }
+ const channel = channels[component.channelId];
+ if (!isRecord(channel)) {
+ return [];
+ }
+ return [[component.key, migrateChannel({ channel })] as const];
+ });
+ if (componentEntries.length === 0) {
+ continue;
+ }
+
+ nextAnimations[binding.path] =
+ componentEntries.length === 1 && componentEntries[0]?.[0] === "value"
+ ? componentEntries[0][1]
+ : Object.fromEntries(componentEntries);
+ }
+
+ return nextAnimations;
+}
+
+function migrateChannel({ channel }: { channel: ProjectRecord }): ProjectRecord {
+ const { kind: _kind, ...nextChannel } = channel;
+ return nextChannel;
+}
diff --git a/apps/web/src/services/storage/migrations/v29-to-v30.ts b/apps/web/src/services/storage/migrations/v29-to-v30.ts
new file mode 100644
index 00000000..0b9c23b9
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/v29-to-v30.ts
@@ -0,0 +1,14 @@
+import { StorageMigration, type StorageMigrationRunArgs } from "./base";
+import type { MigrationResult, ProjectRecord } from "./transformers/types";
+import { transformProjectV29ToV30 } from "./transformers/v29-to-v30";
+
+export class V29toV30Migration extends StorageMigration {
+ from = 29;
+ to = 30;
+
+ async run({
+ project,
+ }: StorageMigrationRunArgs): Promise> {
+ return transformProjectV29ToV30({ project });
+ }
+}
diff --git a/apps/web/src/timeline/animation-targets.ts b/apps/web/src/timeline/animation-targets.ts
index fcdb2915..4ee764b6 100644
--- a/apps/web/src/timeline/animation-targets.ts
+++ b/apps/web/src/timeline/animation-targets.ts
@@ -1,8 +1,6 @@
import type {
- AnimationBindingKind,
AnimationInterpolation,
AnimationPath,
- AnimationValue,
NumericSpec,
} from "@/animation/types";
import {
@@ -16,9 +14,11 @@ import { getGraphicDefinition } from "@/graphics";
import {
coerceParamValue,
getParamDefaultInterpolation,
+ getParamChannelLayout,
getParamNumericRange,
- getParamValueKind,
+ type ParamChannelLayout,
type ParamDefinition,
+ type ParamValue,
type ParamValues,
} from "@/params";
import {
@@ -28,17 +28,16 @@ import type { TimelineElement } from "@/timeline";
import { isVisualElement } from "@/timeline/element-utils";
export interface AnimationPathDescriptor {
- kind: AnimationBindingKind;
+ channelLayout: ParamChannelLayout;
defaultInterpolation: AnimationInterpolation;
numericRanges?: Partial>;
- coerceValue: ({ value }: { value: AnimationValue }) => AnimationValue | null;
- getBaseValue: () => AnimationValue | null;
- setBaseValue: ({ value }: { value: AnimationValue }) => TimelineElement;
+ coerceValue: ({ value }: { value: ParamValue }) => ParamValue | null;
+ getBaseValue: () => ParamValue | null;
+ setBaseValue: ({ value }: { value: ParamValue }) => TimelineElement;
}
-// Number/discrete bindings expose a single component named "value"
-// (see binding-values.ts). Multi-component kinds (vector2, color) don't carry
-// numeric ranges yet — revisit when one does.
+// Leaf params expose a single component named "value". Composite params don't
+// carry numeric ranges yet — revisit when one does.
function paramNumericRanges({
param,
}: {
@@ -62,7 +61,7 @@ function buildParamDescriptor({
}
return {
- kind: getParamValueKind({ param }),
+ channelLayout: getParamChannelLayout({ param }),
defaultInterpolation: getParamDefaultInterpolation({ param }),
numericRanges: paramNumericRanges({ param }),
coerceValue: ({ value }) => coerceParamValue({ param, value }),
diff --git a/apps/web/src/timeline/audio-separation/index.ts b/apps/web/src/timeline/audio-separation/index.ts
index dd56df5f..6917378c 100644
--- a/apps/web/src/timeline/audio-separation/index.ts
+++ b/apps/web/src/timeline/audio-separation/index.ts
@@ -121,28 +121,13 @@ function cloneVolumeAnimations({
}: {
animations: ElementAnimations | undefined;
}): ElementAnimations | undefined {
- const volumeBinding = animations?.bindings.volume;
- if (!volumeBinding) {
- return undefined;
- }
-
- const subsetChannels = Object.fromEntries(
- volumeBinding.components.flatMap((component) => {
- const channel = animations?.channels[component.channelId];
- return channel ? [[component.channelId, channel] as const] : [];
- }),
- );
- if (Object.keys(subsetChannels).length === 0) {
+ const volumeData = animations?.volume;
+ if (!volumeData) {
return undefined;
}
return cloneAnimations({
- animations: {
- bindings: {
- volume: volumeBinding,
- },
- channels: subsetChannels,
- },
+ animations: { volume: volumeData },
shouldRegenerateKeyframeIds: true,
});
}
diff --git a/apps/web/src/timeline/components/graph-editor/session.ts b/apps/web/src/timeline/components/graph-editor/session.ts
index d7c6b001..c5d1c133 100644
--- a/apps/web/src/timeline/components/graph-editor/session.ts
+++ b/apps/web/src/timeline/components/graph-editor/session.ts
@@ -1,11 +1,12 @@
import {
getCurveHandlesForNormalizedCubicBezier,
getEditableScalarChannels,
- getEasingModeForKind,
getNormalizedCubicBezierForScalarSegment,
getScalarKeyframeContext,
updateScalarKeyframeCurve,
} from "@/animation";
+import { getChannelsFromData } from "@/animation/channel-data";
+import { isScalarChannel } from "@/animation/interpolation";
import type {
AnimationPath,
ElementAnimations,
@@ -143,12 +144,9 @@ function findKeyframeTime({
propertyPath: AnimationPath;
keyframeId: string;
}): number | null {
- const binding = animations.bindings[propertyPath];
- if (!binding) return null;
-
- for (const component of binding.components) {
- const channel = animations.channels[component.channelId];
- if (channel?.kind !== "scalar") continue;
+ const data = animations[propertyPath];
+ for (const channel of getChannelsFromData({ data })) {
+ if (!channel || !isScalarChannel(channel)) continue;
const key = channel.keys.find((k) => k.id === keyframeId);
if (key !== undefined) return key.time;
}
@@ -300,8 +298,7 @@ function resolvePropertySelection({
}
}
- const { binding: resolvedBinding, channels: scalarChannels } = scalarResult;
- const easingMode = getEasingModeForKind(resolvedBinding.kind);
+ const { easingMode, channels: scalarChannels } = scalarResult;
const contexts = scalarChannels.flatMap((channel) => {
const context = getScalarKeyframeContext({
animations: element.animations,
diff --git a/apps/web/src/timeline/components/timeline-element.tsx b/apps/web/src/timeline/components/timeline-element.tsx
index 74f026dc..1b549a15 100644
--- a/apps/web/src/timeline/components/timeline-element.tsx
+++ b/apps/web/src/timeline/components/timeline-element.tsx
@@ -52,7 +52,7 @@ import { getTimelinePixelsPerSecond } from "@/timeline";
import { buildWaveformSourceKey } from "@/media/waveform-summary";
import { addMediaTime, type MediaTime, TICKS_PER_SECOND } from "@/wasm";
import {
- getActionDefaultShortcuts,
+ getActionDefinition,
type TAction,
type TActionWithOptionalArgs,
invokeAction,
@@ -186,7 +186,7 @@ export function getKeyframeIndicators({
}
export function getDisplayShortcut({ action }: { action: TAction }) {
- const defaultShortcuts = getActionDefaultShortcuts({ action });
+ const defaultShortcuts = getActionDefinition({ action }).defaultShortcuts;
if (!defaultShortcuts?.length) {
return "";
}