From 401cc35743aafc4c8bdfb8055501650390bde88b Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Wed, 29 Apr 2026 10:37:39 +0200 Subject: [PATCH] refactor: animate params over time --- README.md | 71 +- .../__tests__/binding-values.test.ts | 26 - apps/web/src/animation/binding-values.ts | 344 --------- apps/web/src/animation/channel-data.ts | 57 ++ .../web/src/animation/effect-param-channel.ts | 2 +- apps/web/src/animation/graph-channels.ts | 37 +- .../src/animation/graphic-param-channel.ts | 2 +- apps/web/src/animation/index.ts | 5 - apps/web/src/animation/interpolation.ts | 91 ++- apps/web/src/animation/keyframe-query.ts | 226 +++--- apps/web/src/animation/keyframes.ts | 720 ++++++++++-------- apps/web/src/animation/resolve.ts | 150 +++- apps/web/src/animation/types.ts | 132 +--- apps/web/src/clipboard/handlers/keyframes.ts | 13 +- apps/web/src/clipboard/types.ts | 4 +- .../timeline/clipboard/paste-keyframes.ts | 3 +- .../element/keyframes/remove-keyframe.ts | 9 +- .../keyframes/upsert-effect-param-keyframe.ts | 3 +- .../element/keyframes/upsert-keyframe.ts | 9 +- .../hooks/use-keyframed-param-property.ts | 8 +- .../web/src/core/managers/timeline-manager.ts | 11 +- .../params/__tests__/channel-layout.test.ts | 10 + apps/web/src/params/index.ts | 258 ++++++- .../src/services/storage/migrations/AGENTS.md | 10 + .../migrations/__tests__/v29-to-v30.test.ts | 139 ++++ .../src/services/storage/migrations/index.ts | 4 +- .../migrations/transformers/v29-to-v30.ts | 138 ++++ .../services/storage/migrations/v29-to-v30.ts | 14 + apps/web/src/timeline/animation-targets.ts | 21 +- .../src/timeline/audio-separation/index.ts | 21 +- .../components/graph-editor/session.ts | 15 +- .../timeline/components/timeline-element.tsx | 4 +- 32 files changed, 1421 insertions(+), 1136 deletions(-) delete mode 100644 apps/web/src/animation/__tests__/binding-values.test.ts delete mode 100644 apps/web/src/animation/binding-values.ts create mode 100644 apps/web/src/animation/channel-data.ts create mode 100644 apps/web/src/params/__tests__/channel-layout.test.ts create mode 100644 apps/web/src/services/storage/migrations/AGENTS.md create mode 100644 apps/web/src/services/storage/migrations/__tests__/v29-to-v30.test.ts create mode 100644 apps/web/src/services/storage/migrations/transformers/v29-to-v30.ts create mode 100644 apps/web/src/services/storage/migrations/v29-to-v30.ts diff --git a/README.md b/README.md index af46df56..79bcf310 100644 --- a/README.md +++ b/README.md @@ -1,27 +1,13 @@ - - - - - -
- OpenCut Logo - -

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. - - Vercel OSS Program - - - - Powered by fal.ai - - ## 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](https://api.star-history.com/svg?repos=opencut-app/opencut&type=Date) +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 ""; }