refactor: animate params over time

This commit is contained in:
Maze Winther 2026-04-29 10:37:39 +02:00
parent b8d08df244
commit 401cc35743
32 changed files with 1421 additions and 1136 deletions

View File

@ -1,27 +1,13 @@
<table width="100%">
<tr> | | |
<td align="left" width="120"> | --- | ---------------------------------------------------------------------- |
<img src="apps/web/public/logos/opencut/icon.svg" alt="OpenCut Logo" width="100" /> | | OpenCutA free, open-source video editor for web, desktop, and mobile. |
</td>
<td align="right">
<h1>OpenCut</h1>
<h3 style="margin-top: -10px;">A free, open-source video editor for web, desktop, and mobile.</h3>
</td>
</tr>
</table>
## Sponsors ## 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. 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.
<a href="https://vercel.com/oss">
<img alt="Vercel OSS Program" src="https://vercel.com/oss/program-badge.svg" />
</a>
<a href="https://fal.ai">
<img alt="Powered by fal.ai" src="https://img.shields.io/badge/Powered%20by-fal.ai-000000?style=flat&logo=data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTEyIDJMMTMuMDkgOC4yNkwyMCAxMEwxMy4wOSAxNS43NEwxMiAyMkwxMC45MSAxNS43NEw0IDEwTDEwLjkxIDguMjZMMTIgMloiIGZpbGw9IndoaXRlIi8+Cjwvc3ZnPgo=" />
</a>
## Why? ## Why?
- **Privacy**: Your videos stay on your device - **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 ### Setup
1. Fork and clone the repository 1. Fork and clone the repository
2. Copy the environment file: 2. Copy the environment file:
```bash
```bash
# Unix/Linux/Mac # Unix/Linux/Mac
cp apps/web/.env.example apps/web/.env.local cp apps/web/.env.example apps/web/.env.local
# Windows PowerShell # Windows PowerShell
Copy-Item apps/web/.env.example apps/web/.env.local Copy-Item apps/web/.env.example apps/web/.env.local
``` ```
3. Start the database and Redis: 3. Start the database and Redis:
```bash
```bash
docker compose up -d db redis serverless-redis-http docker compose up -d db redis serverless-redis-http
``` ```
4. Install dependencies and start the dev server: 4. Install dependencies and start the dev server:
```bash
```bash
bun install bun install
bun dev:web bun dev:web
``` ```
The application will be available at [http://localhost:3000](http://localhost:3000). 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. 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 ### Local WASM development
@ -99,30 +79,23 @@ cargo install cargo-watch
``` ```
1. Build the package once from the repo root: 1. Build the package once from the repo root:
```bash
```bash
bun run build:wasm bun run build:wasm
``` ```
2. Register the generated package for linking: 2. Register the generated package for linking:
```bash
```bash
cd rust/wasm/pkg cd rust/wasm/pkg
bun link bun link
``` ```
3. Link `apps/web` to the local package: 3. Link `apps/web` to the local package:
```bash
```bash
cd apps/web cd apps/web
bun link opencut-wasm bun link opencut-wasm
``` ```
4. Rebuild on changes while you work: 4. Rebuild on changes while you work:
```bash
```bash
bun dev:wasm bun dev:wasm
``` ```
To switch `apps/web` back to the published package, run: 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 - Fork the repo and clone locally
- Follow the setup instructions in CONTRIBUTING.md - 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 - Create a feature branch and submit a PR
## License ## 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

View File

@ -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");
});
});

View File

@ -1,344 +0,0 @@
import { converter, formatHex, formatHex8, parse } from "culori";
import type {
AnimationBindingComponent,
AnimationBindingOfKind,
AnimationBindingInstance,
AnimationBindingKind,
ColorAnimationBinding,
DiscreteAnimationBinding,
NumberAnimationBinding,
AnimationPath,
AnimationValue,
DiscreteValue,
Vector2AnimationBinding,
VectorValue,
} from "@/animation/types";
import { clamp } from "@/utils/math";
interface LinearRgba {
r: number;
g: number;
b: number;
a: number;
}
export type AnimationComponentValue = number | DiscreteValue;
const toRgb = converter("rgb");
function srgbToLinear({ value }: { value: number }): number {
return value <= 0.04045
? value / 12.92
: Math.pow((value + 0.055) / 1.055, 2.4);
}
function linearToSrgb({ value }: { value: number }): number {
const clamped = clamp({ value, min: 0, max: 1 });
return clamped <= 0.0031308
? clamped * 12.92
: 1.055 * Math.pow(clamped, 1 / 2.4) - 0.055;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
export function isVectorValue(value: unknown): value is VectorValue {
return isRecord(value) && typeof value.x === "number" && typeof value.y === "number";
}
export type EasingMode = "independent" | "shared";
/**
* Declares how easing curves apply to a binding's components.
* "shared" means all components always use the same curve (e.g. color you never
* want to ease R independently from G/B/A). "independent" means each component
* can have its own curve.
*/
export function getEasingModeForKind(kind: AnimationBindingKind): EasingMode {
return kind === "color" ? "shared" : "independent";
}
export function getBindingComponentKeys({
kind,
}: {
kind: AnimationBindingKind;
}): string[] {
if (kind === "vector2") {
return ["x", "y"];
}
if (kind === "color") {
return ["r", "g", "b", "a"];
}
return ["value"];
}
export function buildBindingChannelId({
path,
componentKey,
}: {
path: AnimationPath;
componentKey: string;
}): string {
return `${path}:${componentKey}`;
}
function createBindingComponent<TKey extends string>({
path,
key,
}: {
path: AnimationPath;
key: TKey;
}): AnimationBindingComponent<TKey> {
return {
key,
channelId: buildBindingChannelId({ path, componentKey: key }),
};
}
function cloneBindingComponents<TKey extends string>({
components,
}: {
components: AnimationBindingComponent<TKey>[];
}): AnimationBindingComponent<TKey>[] {
return components.map((component) => ({ ...component }));
}
const animationBindingFactories = {
color: ({ path }: { path: AnimationPath }): ColorAnimationBinding => ({
path,
kind: "color",
colorSpace: "srgb-linear",
components: [
createBindingComponent({ path, key: "r" }),
createBindingComponent({ path, key: "g" }),
createBindingComponent({ path, key: "b" }),
createBindingComponent({ path, key: "a" }),
],
}),
vector2: ({ path }: { path: AnimationPath }): Vector2AnimationBinding => ({
path,
kind: "vector2",
components: [
createBindingComponent({ path, key: "x" }),
createBindingComponent({ path, key: "y" }),
],
}),
number: ({ path }: { path: AnimationPath }): NumberAnimationBinding => ({
path,
kind: "number",
components: [createBindingComponent({ path, key: "value" })],
}),
discrete: ({ path }: { path: AnimationPath }): DiscreteAnimationBinding => ({
path,
kind: "discrete",
components: [createBindingComponent({ path, key: "value" })],
}),
} satisfies {
[K in AnimationBindingKind]: ({
path,
}: {
path: AnimationPath;
}) => AnimationBindingOfKind<K>;
};
export function createAnimationBinding<TKind extends AnimationBindingKind>({
path,
kind,
}: {
path: AnimationPath;
kind: TKind;
}): AnimationBindingOfKind<TKind>;
export function createAnimationBinding({
path,
kind,
}: {
path: AnimationPath;
kind: AnimationBindingKind;
}): AnimationBindingInstance {
return animationBindingFactories[kind]({ path });
}
const animationBindingCloners = {
color: ({ binding }: { binding: ColorAnimationBinding }): ColorAnimationBinding => ({
...binding,
components: cloneBindingComponents({
components: binding.components,
}),
}),
vector2: ({
binding,
}: {
binding: Vector2AnimationBinding;
}): Vector2AnimationBinding => ({
...binding,
components: cloneBindingComponents({
components: binding.components,
}),
}),
number: ({
binding,
}: {
binding: NumberAnimationBinding;
}): NumberAnimationBinding => ({
...binding,
components: cloneBindingComponents({
components: binding.components,
}),
}),
discrete: ({
binding,
}: {
binding: DiscreteAnimationBinding;
}): DiscreteAnimationBinding => ({
...binding,
components: cloneBindingComponents({
components: binding.components,
}),
}),
} satisfies {
[K in AnimationBindingKind]: ({
binding,
}: {
binding: AnimationBindingOfKind<K>;
}) => AnimationBindingOfKind<K>;
};
export function cloneAnimationBinding<TKind extends AnimationBindingKind>({
binding,
}: {
binding: AnimationBindingOfKind<TKind>;
}): AnimationBindingOfKind<TKind>;
export function cloneAnimationBinding({
binding,
}: {
binding: AnimationBindingInstance;
}): AnimationBindingInstance {
switch (binding.kind) {
case "color":
return animationBindingCloners.color({ binding });
case "vector2":
return animationBindingCloners.vector2({ binding });
case "number":
return animationBindingCloners.number({ binding });
case "discrete":
return animationBindingCloners.discrete({ binding });
}
}
export function parseColorToLinearRgba({
color,
}: {
color: string;
}): LinearRgba | null {
const parsed = parse(color);
const rgb = parsed ? toRgb(parsed) : null;
if (!rgb) {
return null;
}
return {
r: srgbToLinear({ value: rgb.r ?? 0 }),
g: srgbToLinear({ value: rgb.g ?? 0 }),
b: srgbToLinear({ value: rgb.b ?? 0 }),
a: clamp({ value: rgb.alpha ?? 1, min: 0, max: 1 }),
};
}
export function formatLinearRgba({
color,
}: {
color: LinearRgba;
}): string {
const rgb = {
mode: "rgb",
r: linearToSrgb({ value: color.r }),
g: linearToSrgb({ value: color.g }),
b: linearToSrgb({ value: color.b }),
alpha: clamp({ value: color.a, min: 0, max: 1 }),
} as const;
return rgb.alpha < 1 ? formatHex8(rgb) : formatHex(rgb);
}
export function decomposeAnimationValue({
kind,
value,
}: {
kind: AnimationBindingKind;
value: AnimationValue;
}): Record<string, AnimationComponentValue> | null {
if (kind === "number") {
return typeof value === "number" ? { value } : null;
}
if (kind === "vector2") {
return isVectorValue(value) ? { x: value.x, y: value.y } : null;
}
if (kind === "color") {
if (typeof value !== "string") {
return null;
}
const parsed = parseColorToLinearRgba({ color: value });
if (!parsed) {
return null;
}
return {
r: parsed.r,
g: parsed.g,
b: parsed.b,
a: parsed.a,
};
}
return typeof value === "string" || typeof value === "boolean"
? { value }
: null;
}
export function composeAnimationValue({
binding,
componentValues,
}: {
binding: AnimationBindingInstance;
componentValues: Record<string, AnimationComponentValue | undefined>;
}): AnimationValue | null {
if (binding.kind === "number") {
const value = componentValues.value;
return typeof value === "number" ? value : null;
}
if (binding.kind === "vector2") {
const x = componentValues.x;
const y = componentValues.y;
return typeof x === "number" && typeof y === "number" ? { x, y } : null;
}
if (binding.kind === "color") {
const r = componentValues.r;
const g = componentValues.g;
const b = componentValues.b;
const a = componentValues.a;
if (
typeof r !== "number" ||
typeof g !== "number" ||
typeof b !== "number" ||
typeof a !== "number"
) {
return null;
}
return formatLinearRgba({
color: {
r,
g,
b,
a,
},
});
}
const value = componentValues.value;
return typeof value === "string" || typeof value === "boolean" ? value : null;
}

View File

@ -0,0 +1,57 @@
import type {
AnimationChannel,
ChannelData,
CompositeChannelData,
} from "@/animation/types";
const LEGACY_ANIMATION_STORAGE_KEYS = new Set(["bindings", "channels"]);
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
export function isLeafChannelData(
data: ChannelData | undefined,
): data is AnimationChannel {
return isRecord(data) && Array.isArray(data.keys);
}
export function isCompositeChannelData(
data: ChannelData | undefined,
): data is CompositeChannelData {
return isRecord(data) && !Array.isArray(data.keys);
}
export function getChannelsFromData({
data,
}: {
data: ChannelData | undefined;
}): AnimationChannel[] {
if (isLeafChannelData(data)) {
return [data];
}
if (!isCompositeChannelData(data)) {
return [];
}
return Object.values(data).filter(isLeafChannelData);
}
export function getChannelEntriesFromData({
data,
}: {
data: ChannelData | undefined;
}): Array<[string, AnimationChannel]> {
if (isLeafChannelData(data)) {
return [["value", data]];
}
if (!isCompositeChannelData(data)) {
return [];
}
return Object.entries(data).flatMap(([componentKey, channel]) =>
isLeafChannelData(channel) ? [[componentKey, channel]] : [],
);
}
export function isAnimationStorageKey({ key }: { key: string }): boolean {
return !LEGACY_ANIMATION_STORAGE_KEYS.has(key);
}

View File

@ -70,7 +70,7 @@ export function resolveEffectParamsAtTime({
for (const [paramKey, staticValue] of Object.entries(params)) { for (const [paramKey, staticValue] of Object.entries(params)) {
const path = buildEffectParamPath({ effectId, paramKey }); const path = buildEffectParamPath({ effectId, paramKey });
resolved[paramKey] = animations?.bindings[path] resolved[paramKey] = animations?.[path]
? resolveAnimationPathValueAtTime({ ? resolveAnimationPathValueAtTime({
animations, animations,
propertyPath: path, propertyPath: path,

View File

@ -1,21 +1,35 @@
import type { import type {
AnimationBindingInstance,
AnimationPath, AnimationPath,
ElementAnimations, ElementAnimations,
ChannelData,
ScalarAnimationChannel, ScalarAnimationChannel,
ScalarGraphChannel, ScalarGraphChannel,
ScalarGraphKeyframeContext, ScalarGraphKeyframeContext,
} from "@/animation/types"; } from "@/animation/types";
import type { ChannelEasingMode } from "@/params";
import { isCompositeChannelData, isLeafChannelData } from "./channel-data";
import { isScalarChannel } from "./interpolation";
export interface EditableScalarChannels { export interface EditableScalarChannels {
binding: AnimationBindingInstance; easingMode: ChannelEasingMode;
channels: ScalarGraphChannel[]; channels: ScalarGraphChannel[];
} }
function isScalarAnimationChannel( function isScalarAnimationChannel(
channel: ElementAnimations["channels"][string], channel: ChannelData | undefined,
): channel is ScalarAnimationChannel { ): 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({ export function getEditableScalarChannels({
@ -25,13 +39,15 @@ export function getEditableScalarChannels({
animations: ElementAnimations | undefined; animations: ElementAnimations | undefined;
propertyPath: AnimationPath; propertyPath: AnimationPath;
}): EditableScalarChannels | null { }): EditableScalarChannels | null {
const binding = animations?.bindings[propertyPath]; const data = animations?.[propertyPath];
if (!binding) { if (!data) {
return null; return null;
} }
const channels = binding.components.flatMap((component) => { const channelEntries = isLeafChannelData(data)
const channel = animations?.channels[component.channelId]; ? [["value", data] as const]
: Object.entries(data);
const channels = channelEntries.flatMap(([componentKey, channel]) => {
if (!isScalarAnimationChannel(channel)) { if (!isScalarAnimationChannel(channel)) {
return []; return [];
} }
@ -39,14 +55,13 @@ export function getEditableScalarChannels({
return [ return [
{ {
propertyPath, propertyPath,
componentKey: component.key, componentKey,
channelId: component.channelId,
channel, channel,
} satisfies ScalarGraphChannel, } satisfies ScalarGraphChannel,
]; ];
}); });
return { binding, channels }; return { easingMode: getEasingModeForChannelData({ data }), channels };
} }
export function getEditableScalarChannel({ export function getEditableScalarChannel({

View File

@ -49,7 +49,7 @@ export function resolveGraphicParamsAtTime({
for (const param of definitions) { for (const param of definitions) {
const path = buildGraphicParamPath({ paramKey: param.key }); const path = buildGraphicParamPath({ paramKey: param.key });
if (!animations?.bindings[path]) { if (!animations?.[path]) {
continue; continue;
} }

View File

@ -64,11 +64,6 @@ export {
type GroupKeyframeRef, type GroupKeyframeRef,
} from "./property-groups"; } from "./property-groups";
export {
type EasingMode,
getEasingModeForKind,
} from "./binding-values";
export { export {
isAnimationPath, isAnimationPath,
isAnimationPropertyPath, isAnimationPropertyPath,

View File

@ -1,13 +1,14 @@
import type { import type {
AnimationChannel, AnimationChannel,
AnimationInterpolation, AnimationInterpolation,
AnimationValue, Channel,
DiscreteAnimationChannel, DiscreteAnimationChannel,
DiscreteValue, DiscreteValue,
ScalarAnimationChannel, ScalarAnimationChannel,
ScalarAnimationKey, ScalarAnimationKey,
ScalarSegmentType, ScalarSegmentType,
} from "@/animation/types"; } from "@/animation/types";
import type { ParamValue } from "@/params";
import { mediaTime } from "@/wasm"; import { mediaTime } from "@/wasm";
import { import {
getBezierPoint, getBezierPoint,
@ -111,7 +112,7 @@ function normalizeScalarKey({
}; };
} }
function normalizeScalarChannel({ export function normalizeScalarChannel({
channel, channel,
}: { }: {
channel: ScalarAnimationChannel; channel: ScalarAnimationChannel;
@ -154,17 +155,11 @@ function normalizeScalarChannel({
}; };
} }
export function normalizeChannel<TChannel extends AnimationChannel>({ export function normalizeDiscreteChannel({
channel, channel,
}: { }: {
channel: TChannel; channel: DiscreteAnimationChannel;
}): TChannel { }): DiscreteAnimationChannel {
if (channel.kind === "scalar") {
return normalizeScalarChannel({
channel,
}) as TChannel;
}
return { return {
...channel, ...channel,
keys: [...channel.keys].sort((leftKeyframe, rightKeyframe) => keys: [...channel.keys].sort((leftKeyframe, rightKeyframe) =>
@ -173,7 +168,39 @@ export function normalizeChannel<TChannel extends AnimationChannel>({
rightTime: rightKeyframe.time, 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({ function extrapolateScalarEdge({
@ -216,7 +243,7 @@ export function getScalarChannelValueAtTime({
time, time,
fallbackValue, fallbackValue,
}: { }: {
channel: ScalarAnimationChannel | undefined; channel: Channel<number> | undefined;
time: number; time: number;
fallbackValue: number; fallbackValue: number;
}): number { }): number {
@ -224,9 +251,7 @@ export function getScalarChannelValueAtTime({
return fallbackValue; return fallbackValue;
} }
const normalizedChannel = normalizeChannel({ const normalizedChannel = normalizeScalarChannel({ channel });
channel,
});
const firstKey = normalizedChannel.keys[0]; const firstKey = normalizedChannel.keys[0];
const lastKey = normalizedChannel.keys[normalizedChannel.keys.length - 1]; const lastKey = normalizedChannel.keys[normalizedChannel.keys.length - 1];
if (!firstKey || !lastKey) { if (!firstKey || !lastKey) {
@ -328,7 +353,7 @@ export function getDiscreteChannelValueAtTime({
time, time,
fallbackValue, fallbackValue,
}: { }: {
channel: DiscreteAnimationChannel | undefined; channel: Channel<DiscreteValue> | undefined;
time: number; time: number;
fallbackValue: DiscreteValue; fallbackValue: DiscreteValue;
}): DiscreteValue { }): DiscreteValue {
@ -336,9 +361,7 @@ export function getDiscreteChannelValueAtTime({
return fallbackValue; return fallbackValue;
} }
const normalizedChannel = normalizeChannel({ const normalizedChannel = normalizeDiscreteChannel({ channel });
channel,
});
let currentValue = fallbackValue; let currentValue = fallbackValue;
for (const key of normalizedChannel.keys) { for (const key of normalizedChannel.keys) {
if (time < key.time) { if (time < key.time) {
@ -349,6 +372,24 @@ export function getDiscreteChannelValueAtTime({
return currentValue; return currentValue;
} }
export function getChannelValueAtTime({
channel,
time,
fallbackValue,
}: {
channel: Channel<number> | undefined;
time: number;
fallbackValue: number;
}): number;
export function getChannelValueAtTime<TValue extends DiscreteValue>({
channel,
time,
fallbackValue,
}: {
channel: DiscreteAnimationChannel | undefined;
time: number;
fallbackValue: TValue;
}): TValue;
export function getChannelValueAtTime({ export function getChannelValueAtTime({
channel, channel,
time, time,
@ -356,14 +397,14 @@ export function getChannelValueAtTime({
}: { }: {
channel: AnimationChannel | undefined; channel: AnimationChannel | undefined;
time: number; time: number;
fallbackValue: AnimationValue; fallbackValue: ParamValue;
}): AnimationValue { }): ParamValue {
if (!channel || channel.keys.length === 0) { if (!channel || channel.keys.length === 0) {
return fallbackValue; return fallbackValue;
} }
if (channel.kind === "scalar") { if (typeof fallbackValue === "number") {
return typeof fallbackValue === "number" return isScalarChannel(channel)
? getScalarChannelValueAtTime({ ? getScalarChannelValueAtTime({
channel, channel,
time, time,
@ -377,7 +418,7 @@ export function getChannelValueAtTime({
} }
return getDiscreteChannelValueAtTime({ return getDiscreteChannelValueAtTime({
channel, channel: isScalarChannel(channel) ? undefined : channel,
time, time,
fallbackValue, fallbackValue,
}); });

View File

@ -1,75 +1,75 @@
import type { import type {
AnimationBindingInstance,
AnimationChannel, AnimationChannel,
AnimationPath, AnimationPath,
ChannelData,
ElementAnimations, ElementAnimations,
ElementKeyframe, ElementKeyframe,
} from "@/animation/types"; } from "@/animation/types";
import { formatLinearRgba } from "@/params";
import { import {
type AnimationComponentValue, getChannelEntriesFromData,
composeAnimationValue, isAnimationStorageKey,
} from "./binding-values"; } from "./channel-data";
import { import {
getChannelValueAtTime, getChannelValueAtTime,
getScalarSegmentInterpolation, getScalarSegmentInterpolation,
isScalarChannel,
} from "./interpolation"; } from "./interpolation";
import { isAnimationPath } from "./path"; import { isAnimationPath } from "./path";
function getBindingFallbackValue({ function getChannelFallbackValue({
channel, channel,
}: { }: {
channel: ElementAnimations["channels"][string]; channel: AnimationChannel;
}) { }) {
if (!channel || channel.keys.length === 0) { if (channel.keys.length === 0) {
return channel?.kind === "discrete" ? false : 0; return isScalarChannel(channel) ? 0 : false;
} }
return channel.keys[0].value; return channel.keys[0].value;
} }
interface BindingKeyframeMatch { interface ChannelKeyframeMatch {
componentKey: string;
componentIndex: number; componentIndex: number;
channel: AnimationChannel; channel: AnimationChannel;
keyframe: AnimationChannel["keys"][number]; keyframe: AnimationChannel["keys"][number];
} }
function getBindingKeyframeMatches({ function getChannelKeyframeMatches({
animations, data,
binding,
}: { }: {
animations: ElementAnimations; data: ChannelData | undefined;
binding: AnimationBindingInstance; }): ChannelKeyframeMatch[] {
}): BindingKeyframeMatch[] { return getChannelEntriesFromData({ data }).flatMap(
return binding.components.flatMap((component, componentIndex) => { ([componentKey, channel], componentIndex) => {
const channel = animations.channels[component.channelId]; if (channel.keys.length === 0) {
if (!channel || channel.keys.length === 0) { return [];
return []; }
}
return channel.keys.map((keyframe) => ({ return channel.keys.map((keyframe) => ({
componentIndex, componentKey,
channel, componentIndex,
keyframe, channel,
})); keyframe,
}); }));
},
);
} }
function getUniqueBindingKeyframeMatches({ function getUniqueChannelKeyframeMatches({
animations, data,
binding,
}: { }: {
animations: ElementAnimations; data: ChannelData | undefined;
binding: AnimationBindingInstance; }): ChannelKeyframeMatch[] {
}): BindingKeyframeMatch[] { const sortedMatches = getChannelKeyframeMatches({
const sortedMatches = getBindingKeyframeMatches({ data,
animations,
binding,
}).sort( }).sort(
(leftMatch, rightMatch) => (leftMatch, rightMatch) =>
leftMatch.keyframe.time - rightMatch.keyframe.time || leftMatch.keyframe.time - rightMatch.keyframe.time ||
leftMatch.componentIndex - rightMatch.componentIndex, leftMatch.componentIndex - rightMatch.componentIndex,
); );
const uniqueMatches: BindingKeyframeMatch[] = []; const uniqueMatches: ChannelKeyframeMatch[] = [];
for (const match of sortedMatches) { for (const match of sortedMatches) {
const previousMatch = uniqueMatches[uniqueMatches.length - 1]; const previousMatch = uniqueMatches[uniqueMatches.length - 1];
@ -92,11 +92,11 @@ function getUniqueBindingKeyframeMatches({
return uniqueMatches; return uniqueMatches;
} }
function getPreferredBindingKeyframeMatch({ function getPreferredChannelKeyframeMatch({
matches, matches,
}: { }: {
matches: BindingKeyframeMatch[]; matches: ChannelKeyframeMatch[];
}): BindingKeyframeMatch | null { }): ChannelKeyframeMatch | null {
return ( return (
matches.find((match) => match.componentIndex === 0) ?? matches.find((match) => match.componentIndex === 0) ??
matches[0] ?? matches[0] ??
@ -104,35 +104,67 @@ function getPreferredBindingKeyframeMatch({
); );
} }
function getComposedBindingValueAtTime({ function getChannelValue({
animations, channel,
binding,
time, time,
}: { }: {
animations: ElementAnimations; channel: AnimationChannel;
binding: AnimationBindingInstance;
time: number; time: number;
}) { }) {
const componentValues = Object.fromEntries( const fallbackValue = getChannelFallbackValue({ channel });
binding.components.map((component) => { if (typeof fallbackValue === "number") {
const channel = animations.channels[component.channelId]; return getChannelValueAtTime({
return [ channel: isScalarChannel(channel) ? channel : undefined,
component.key, time,
getChannelValueAtTime({ fallbackValue,
channel, });
time, }
fallbackValue: getBindingFallbackValue({ channel }), return getChannelValueAtTime({
}), channel: !isScalarChannel(channel) ? channel : undefined,
]; time,
}), fallbackValue,
) as Record<string, AnimationComponentValue | undefined>;
return composeAnimationValue({
binding,
componentValues,
}); });
} }
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({ function getKeyframeInterpolation({
channel, channel,
keyframe, keyframe,
@ -140,25 +172,22 @@ function getKeyframeInterpolation({
channel: AnimationChannel; channel: AnimationChannel;
keyframe: AnimationChannel["keys"][number]; keyframe: AnimationChannel["keys"][number];
}) { }) {
return channel.kind === "scalar" && "segmentToNext" in keyframe return isScalarChannel(channel) && "segmentToNext" in keyframe
? getScalarSegmentInterpolation({ segment: keyframe.segmentToNext }) ? getScalarSegmentInterpolation({ segment: keyframe.segmentToNext })
: "hold"; : "hold";
} }
function toElementKeyframe({ function toElementKeyframe({
animations, data,
binding,
propertyPath, propertyPath,
keyframeMatch, keyframeMatch,
}: { }: {
animations: ElementAnimations; data: ChannelData | undefined;
binding: AnimationBindingInstance;
propertyPath: AnimationPath; propertyPath: AnimationPath;
keyframeMatch: BindingKeyframeMatch; keyframeMatch: ChannelKeyframeMatch;
}): ElementKeyframe | null { }): ElementKeyframe | null {
const value = getComposedBindingValueAtTime({ const value = getComposedChannelDataValueAtTime({
animations, data,
binding,
time: keyframeMatch.keyframe.time, time: keyframeMatch.keyframe.time,
}); });
if (value === null) { if (value === null) {
@ -186,19 +215,19 @@ export function getElementKeyframes({
return []; return [];
} }
return Object.entries(animations.bindings).flatMap( return Object.entries(animations).filter(([key]) =>
([propertyPath, binding]) => { isAnimationStorageKey({ key }),
if (!binding || !isAnimationPath(propertyPath)) { ).flatMap(
([propertyPath, data]) => {
if (!data || !isAnimationPath(propertyPath)) {
return []; return [];
} }
return getUniqueBindingKeyframeMatches({ return getUniqueChannelKeyframeMatches({
animations, data,
binding,
}).flatMap((keyframeMatch) => { }).flatMap((keyframeMatch) => {
const keyframe = toElementKeyframe({ const keyframe = toElementKeyframe({
animations, data,
binding,
propertyPath, propertyPath,
keyframeMatch, keyframeMatch,
}); });
@ -219,13 +248,8 @@ export function hasKeyframesForPath({
animations: ElementAnimations | undefined; animations: ElementAnimations | undefined;
propertyPath: AnimationPath; propertyPath: AnimationPath;
}): boolean { }): boolean {
const binding = animations?.bindings[propertyPath]; return getChannelEntriesFromData({ data: animations?.[propertyPath] }).some(
if (!binding) { ([, channel]) => channel.keys.length > 0,
return false;
}
return binding.components.some((component) =>
Boolean(animations?.channels[component.channelId]?.keys.length),
); );
} }
@ -238,24 +262,22 @@ export function getKeyframeAtTime({
propertyPath: AnimationPath; propertyPath: AnimationPath;
time: number; time: number;
}): ElementKeyframe | null { }): ElementKeyframe | null {
const binding = animations?.bindings[propertyPath]; const data = animations?.[propertyPath];
if (!binding) { if (!data) {
return null; return null;
} }
const keyframeMatch = getPreferredBindingKeyframeMatch({ const keyframeMatch = getPreferredChannelKeyframeMatch({
matches: getBindingKeyframeMatches({ matches: getChannelKeyframeMatches({ data }).filter(
animations, ({ keyframe }) => keyframe.time === time,
binding, ),
}).filter(({ keyframe }) => keyframe.time === time),
}); });
if (!keyframeMatch) { if (!keyframeMatch) {
return null; return null;
} }
return toElementKeyframe({ return toElementKeyframe({
animations, data,
binding,
propertyPath, propertyPath,
keyframeMatch, keyframeMatch,
}); });
@ -270,24 +292,22 @@ export function getKeyframeById({
propertyPath: AnimationPath; propertyPath: AnimationPath;
keyframeId: string; keyframeId: string;
}): ElementKeyframe | null { }): ElementKeyframe | null {
const binding = animations?.bindings[propertyPath]; const data = animations?.[propertyPath];
if (!binding) { if (!data) {
return null; return null;
} }
const keyframeMatch = getPreferredBindingKeyframeMatch({ const keyframeMatch = getPreferredChannelKeyframeMatch({
matches: getBindingKeyframeMatches({ matches: getChannelKeyframeMatches({ data }).filter(
animations, ({ keyframe }) => keyframe.id === keyframeId,
binding, ),
}).filter(({ keyframe }) => keyframe.id === keyframeId),
}); });
if (!keyframeMatch) { if (!keyframeMatch) {
return null; return null;
} }
return toElementKeyframe({ return toElementKeyframe({
animations, data,
binding,
propertyPath, propertyPath,
keyframeMatch, keyframeMatch,
}); });

File diff suppressed because it is too large Load Diff

View File

@ -1,15 +1,13 @@
import type { import type {
AnimationPath, AnimationPath,
AnimationValueForPath,
ElementAnimations, ElementAnimations,
} from "@/animation/types"; } from "@/animation/types";
import { import { formatLinearRgba, parseColorToLinearRgba } from "@/params";
type AnimationComponentValue, import type { ParamValue } from "@/params";
composeAnimationValue, import { isCompositeChannelData, isLeafChannelData } from "./channel-data";
decomposeAnimationValue,
} from "./binding-values";
import { import {
getChannelValueAtTime, getChannelValueAtTime,
isScalarChannel,
} from "./interpolation"; } from "./interpolation";
export function getElementLocalTime({ export function getElementLocalTime({
@ -33,47 +31,123 @@ export function getElementLocalTime({
return localTime; return localTime;
} }
export function resolveAnimationPathValueAtTime<TPath extends AnimationPath>({ export function resolveAnimationPathValueAtTime({
animations, animations,
propertyPath, propertyPath,
localTime, localTime,
fallbackValue, fallbackValue,
}: { }: {
animations: ElementAnimations | undefined; animations: ElementAnimations | undefined;
propertyPath: TPath; propertyPath: AnimationPath;
localTime: number; localTime: number;
fallbackValue: AnimationValueForPath<TPath>; fallbackValue: number;
}): AnimationValueForPath<TPath> { }): number;
const binding = animations?.bindings[propertyPath]; export function resolveAnimationPathValueAtTime({
if (!binding) { 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; return fallbackValue;
} }
const fallbackComponents = decomposeAnimationValue({ if (
kind: binding.kind, typeof fallbackValue !== "string" ||
value: fallbackValue, !("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<string, AnimationComponentValue | undefined>;
return (composeAnimationValue({
binding,
componentValues,
}) ?? fallbackValue) as AnimationValueForPath<TPath>;
} }

View File

@ -1,4 +1,5 @@
import type { MediaTime } from "@/wasm"; import type { MediaTime } from "@/wasm";
import type { ParamValue } from "@/params";
export const ANIMATION_PROPERTY_PATHS = [ export const ANIMATION_PROPERTY_PATHS = [
"transform.positionX", "transform.positionX",
@ -28,44 +29,21 @@ export const ANIMATION_PROPERTY_GROUPS = {
export type AnimationPropertyGroup = keyof typeof 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 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 { export interface NumericSpec {
min?: number; min?: number;
max?: number; max?: number;
step?: number; step?: number;
} }
export type AnimationValueForPath<TPath extends AnimationPath> = export type AnimationColorPropertyPath = Extract<
TPath extends AnimationPropertyPath AnimationPropertyPath,
? AnimationPropertyValueMap[TPath] "color" | "background.color"
: TPath extends GraphicParamPath | EffectParamPath >;
? DynamicAnimationPathValue export type AnimationNumericPropertyPath = Exclude<
: DynamicAnimationPathValue; AnimationPropertyPath,
export type AnimationNumericPropertyPath = { AnimationColorPropertyPath
[K in AnimationPropertyPath]: AnimationValueForPath<K> extends number ? K : never; >;
}[AnimationPropertyPath];
export type AnimationColorPropertyPath = {
[K in AnimationPropertyPath]: AnimationValueForPath<K> extends string ? K : never;
}[AnimationPropertyPath];
export type ContinuousKeyframeInterpolation = "linear" | "hold" | "bezier"; export type ContinuousKeyframeInterpolation = "linear" | "hold" | "bezier";
export type DiscreteKeyframeInterpolation = "hold"; export type DiscreteKeyframeInterpolation = "hold";
@ -73,8 +51,6 @@ export type AnimationInterpolation =
| ContinuousKeyframeInterpolation | ContinuousKeyframeInterpolation
| DiscreteKeyframeInterpolation; | DiscreteKeyframeInterpolation;
export type PrimitiveAnimationChannelKind = "scalar" | "discrete";
export type AnimationBindingKind = "number" | "vector2" | "color" | "discrete";
export type ScalarSegmentType = "step" | "linear" | "bezier"; export type ScalarSegmentType = "step" | "linear" | "bezier";
export type TangentMode = "auto" | "aligned" | "broken" | "flat"; export type TangentMode = "auto" | "aligned" | "broken" | "flat";
export type ChannelExtrapolationMode = "hold" | "linear"; export type ChannelExtrapolationMode = "hold" | "linear";
@ -84,7 +60,7 @@ export interface CurveHandle {
dv: number; dv: number;
} }
interface BaseAnimationKeyframe<TValue extends number | DiscreteValue> { interface BaseAnimationKeyframe<TValue extends ParamValue> {
id: string; id: string;
time: MediaTime; // relative to element start time time: MediaTime; // relative to element start time
value: TValue; value: TValue;
@ -97,13 +73,16 @@ export interface ScalarAnimationKey extends BaseAnimationKeyframe<number> {
tangentMode: TangentMode; tangentMode: TangentMode;
} }
export interface DiscreteAnimationKey export type DiscreteAnimationKey = BaseAnimationKeyframe<DiscreteValue>;
extends BaseAnimationKeyframe<DiscreteValue> {}
export type AnimationKeyframe = ScalarAnimationKey | DiscreteAnimationKey; export type Keyframe<TValue extends ParamValue = ParamValue> =
TValue extends number
? ScalarAnimationKey
: TValue extends DiscreteValue
? DiscreteAnimationKey
: never;
export interface ScalarAnimationChannel { export interface ScalarChannel {
kind: "scalar";
keys: ScalarAnimationKey[]; keys: ScalarAnimationKey[];
extrapolation?: { extrapolation?: {
before: ChannelExtrapolationMode; before: ChannelExtrapolationMode;
@ -111,72 +90,26 @@ export interface ScalarAnimationChannel {
}; };
} }
export interface DiscreteAnimationChannel { export interface DiscreteChannel {
kind: "discrete";
keys: DiscreteAnimationKey[]; keys: DiscreteAnimationKey[];
} }
export type AnimationChannel = export type Channel<TValue extends ParamValue = ParamValue> =
| ScalarAnimationChannel TValue extends number
| DiscreteAnimationChannel; ? ScalarChannel
: TValue extends DiscreteValue
? DiscreteChannel
: never;
export type ElementAnimationChannelMap = Record< export type ScalarAnimationChannel = Channel<number>;
string, export type DiscreteAnimationChannel = Channel<DiscreteValue>;
AnimationChannel | undefined export type AnimationChannel = Channel;
>;
export interface AnimationBindingComponent<TKey extends string = string> { export type CompositeChannelData = Record<string, AnimationChannel | undefined>;
key: TKey; export type ChannelData = AnimationChannel | CompositeChannelData;
channelId: string;
}
interface BaseAnimationBinding<
TKind extends AnimationBindingKind,
TComponentKey extends string,
> {
path: AnimationPath;
kind: TKind;
components: AnimationBindingComponent<TComponentKey>[];
}
export interface NumberAnimationBinding
extends BaseAnimationBinding<"number", "value"> {}
export interface Vector2AnimationBinding
extends BaseAnimationBinding<"vector2", "x" | "y"> {}
export interface ColorAnimationBinding
extends BaseAnimationBinding<"color", "r" | "g" | "b" | "a"> {
colorSpace: "srgb-linear";
}
export interface DiscreteAnimationBinding
extends BaseAnimationBinding<"discrete", "value"> {}
export type AnimationBindingInstance =
| NumberAnimationBinding
| Vector2AnimationBinding
| ColorAnimationBinding
| DiscreteAnimationBinding;
export interface AnimationBindingByKind {
number: NumberAnimationBinding;
vector2: Vector2AnimationBinding;
color: ColorAnimationBinding;
discrete: DiscreteAnimationBinding;
}
export type AnimationBindingOfKind<TKind extends AnimationBindingKind> =
AnimationBindingByKind[TKind];
export type ElementAnimationBindingMap = Record<
string,
AnimationBindingInstance | undefined
>;
export interface ElementAnimations { export interface ElementAnimations {
bindings: ElementAnimationBindingMap; [propertyPath: AnimationPath]: ChannelData | undefined;
channels: ElementAnimationChannelMap;
} }
export type NormalizedCubicBezier = [number, number, number, number]; export type NormalizedCubicBezier = [number, number, number, number];
@ -184,7 +117,6 @@ export type NormalizedCubicBezier = [number, number, number, number];
export interface ScalarGraphChannelTarget { export interface ScalarGraphChannelTarget {
propertyPath: AnimationPath; propertyPath: AnimationPath;
componentKey: string; componentKey: string;
channelId: string;
} }
export interface ScalarGraphChannel extends ScalarGraphChannelTarget { export interface ScalarGraphChannel extends ScalarGraphChannelTarget {
@ -213,7 +145,7 @@ export interface ElementKeyframe {
propertyPath: AnimationPath; propertyPath: AnimationPath;
id: string; id: string;
time: MediaTime; time: MediaTime;
value: AnimationValue; value: ParamValue;
interpolation: AnimationInterpolation; interpolation: AnimationInterpolation;
} }

View File

@ -1,4 +1,6 @@
import { getKeyframeById } from "@/animation"; import { getKeyframeById } from "@/animation";
import { getChannelEntriesFromData } from "@/animation/channel-data";
import { isScalarChannel } from "@/animation/interpolation";
import type { SelectedKeyframeRef } from "@/animation/types"; import type { SelectedKeyframeRef } from "@/animation/types";
import type { TimelineElement } from "@/timeline"; import type { TimelineElement } from "@/timeline";
import { PasteKeyframesCommand } from "@/commands/timeline"; import { PasteKeyframesCommand } from "@/commands/timeline";
@ -41,14 +43,13 @@ function getCurvePatches({
propertyPath: KeyframeClipboardItem["propertyPath"]; propertyPath: KeyframeClipboardItem["propertyPath"];
keyframeId: string; keyframeId: string;
}): KeyframeClipboardCurvePatch[] { }): KeyframeClipboardCurvePatch[] {
const binding = element.animations?.bindings[propertyPath]; const data = element.animations?.[propertyPath];
if (!binding) { if (!data) {
return []; return [];
} }
return binding.components.flatMap((component) => { return getChannelEntriesFromData({ data }).flatMap(([componentKey, channel]) => {
const channel = element.animations?.channels[component.channelId]; if (!channel || !isScalarChannel(channel)) {
if (channel?.kind !== "scalar") {
return []; return [];
} }
@ -61,7 +62,7 @@ function getCurvePatches({
return [ return [
{ {
componentKey: component.key, componentKey,
patch: { patch: {
leftHandle: keyframe.leftHandle ?? null, leftHandle: keyframe.leftHandle ?? null,
rightHandle: keyframe.rightHandle ?? null, rightHandle: keyframe.rightHandle ?? null,

View File

@ -2,10 +2,10 @@ import type { EditorCore } from "@/core";
import type { import type {
AnimationInterpolation, AnimationInterpolation,
AnimationPath, AnimationPath,
AnimationValue,
ScalarCurveKeyframePatch, ScalarCurveKeyframePatch,
SelectedKeyframeRef, SelectedKeyframeRef,
} from "@/animation/types"; } from "@/animation/types";
import type { ParamValue } from "@/params";
import type { Command } from "@/commands/base-command"; import type { Command } from "@/commands/base-command";
import type { import type {
CreateTimelineElement, CreateTimelineElement,
@ -28,7 +28,7 @@ export interface KeyframeClipboardCurvePatch {
export interface KeyframeClipboardItem { export interface KeyframeClipboardItem {
propertyPath: AnimationPath; propertyPath: AnimationPath;
timeOffset: MediaTime; timeOffset: MediaTime;
value: AnimationValue; value: ParamValue;
interpolation: AnimationInterpolation; interpolation: AnimationInterpolation;
curvePatches: KeyframeClipboardCurvePatch[]; curvePatches: KeyframeClipboardCurvePatch[];
} }

View File

@ -52,8 +52,7 @@ function pasteKeyframesIntoElement({
value: item.value, value: item.value,
interpolation: item.interpolation, interpolation: item.interpolation,
keyframeId: generateUUID(), keyframeId: generateUUID(),
kind: target.kind, channelLayout: target.channelLayout,
defaultInterpolation: target.defaultInterpolation,
coerceValue: target.coerceValue, coerceValue: target.coerceValue,
}); });
const pastedKeyframe = getKeyframeAtTime({ const pastedKeyframe = getKeyframeAtTime({

View File

@ -5,7 +5,8 @@ import {
} from "@/animation"; } from "@/animation";
import { Command, type CommandResult } from "@/commands/base-command"; import { Command, type CommandResult } from "@/commands/base-command";
import { updateElementInSceneTracks } from "@/timeline"; 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 type { SceneTracks, TimelineElement } from "@/timeline";
import { resolveAnimationTarget } from "@/timeline/animation-targets"; import { resolveAnimationTarget } from "@/timeline/animation-targets";
@ -18,7 +19,7 @@ function removeKeyframeAndPersist({
element: TimelineElement; element: TimelineElement;
propertyPath: AnimationPath; propertyPath: AnimationPath;
keyframeId: string; keyframeId: string;
valueAtPlayhead: AnimationValue | null; valueAtPlayhead: ParamValue | null;
}): TimelineElement { }): TimelineElement {
const target = resolveAnimationTarget({ element, path: propertyPath }); const target = resolveAnimationTarget({ element, path: propertyPath });
if (!target) { if (!target) {
@ -52,7 +53,7 @@ export class RemoveKeyframeCommand extends Command {
private readonly elementId: string; private readonly elementId: string;
private readonly propertyPath: AnimationPath; private readonly propertyPath: AnimationPath;
private readonly keyframeId: string; private readonly keyframeId: string;
private readonly valueAtPlayhead: AnimationValue | null; private readonly valueAtPlayhead: ParamValue | null;
constructor({ constructor({
trackId, trackId,
@ -65,7 +66,7 @@ export class RemoveKeyframeCommand extends Command {
elementId: string; elementId: string;
propertyPath: AnimationPath; propertyPath: AnimationPath;
keyframeId: string; keyframeId: string;
valueAtPlayhead: AnimationValue | null; valueAtPlayhead: ParamValue | null;
}) { }) {
super(); super();
this.trackId = trackId; this.trackId = trackId;

View File

@ -90,8 +90,7 @@ export class UpsertEffectParamKeyframeCommand extends Command {
value: this.value, value: this.value,
interpolation: this.interpolation, interpolation: this.interpolation,
keyframeId: this.keyframeId, keyframeId: this.keyframeId,
kind: target.kind, channelLayout: target.channelLayout,
defaultInterpolation: target.defaultInterpolation,
coerceValue: target.coerceValue, coerceValue: target.coerceValue,
}); });
return { ...element, animations }; return { ...element, animations };

View File

@ -7,8 +7,8 @@ import { resolveAnimationTarget } from "@/timeline/animation-targets";
import type { import type {
AnimationPath, AnimationPath,
AnimationInterpolation, AnimationInterpolation,
AnimationValue,
} from "@/animation/types"; } from "@/animation/types";
import type { ParamValue } from "@/params";
import { import {
type MediaTime, type MediaTime,
maxMediaTime, maxMediaTime,
@ -22,7 +22,7 @@ export class UpsertKeyframeCommand extends Command {
private readonly elementId: string; private readonly elementId: string;
private readonly propertyPath: AnimationPath; private readonly propertyPath: AnimationPath;
private readonly time: MediaTime; private readonly time: MediaTime;
private readonly value: AnimationValue; private readonly value: ParamValue;
private readonly interpolation: AnimationInterpolation | undefined; private readonly interpolation: AnimationInterpolation | undefined;
private readonly keyframeId: string | undefined; private readonly keyframeId: string | undefined;
@ -39,7 +39,7 @@ export class UpsertKeyframeCommand extends Command {
elementId: string; elementId: string;
propertyPath: AnimationPath; propertyPath: AnimationPath;
time: MediaTime; time: MediaTime;
value: AnimationValue; value: ParamValue;
interpolation?: AnimationInterpolation; interpolation?: AnimationInterpolation;
keyframeId?: string; keyframeId?: string;
}) { }) {
@ -83,8 +83,7 @@ export class UpsertKeyframeCommand extends Command {
value: this.value, value: this.value,
interpolation: this.interpolation, interpolation: this.interpolation,
keyframeId: this.keyframeId, keyframeId: this.keyframeId,
kind: target.kind, channelLayout: target.channelLayout,
defaultInterpolation: target.defaultInterpolation,
coerceValue: target.coerceValue, coerceValue: target.coerceValue,
}), }),
}; };

View File

@ -13,8 +13,7 @@ import type {
} from "@/animation/types"; } from "@/animation/types";
import { import {
coerceParamValue, coerceParamValue,
getParamDefaultInterpolation, getParamChannelLayout,
getParamValueKind,
type ParamDefinition, type ParamDefinition,
} from "@/params"; } from "@/params";
import type { TimelineElement } from "@/timeline"; import type { TimelineElement } from "@/timeline";
@ -86,10 +85,7 @@ export function useKeyframedParamProperty({
propertyPath: resolvedPropertyPath, propertyPath: resolvedPropertyPath,
time: localTime, time: localTime,
value, value,
kind: getParamValueKind({ param }), channelLayout: getParamChannelLayout({ param }),
defaultInterpolation: getParamDefaultInterpolation({
param,
}),
coerceValue: ({ value: nextValue }) => coerceValue: ({ value: nextValue }) =>
coerceParamValue({ coerceParamValue({
param, param,

View File

@ -20,10 +20,9 @@ import { isElementMuted } from "@/timeline/audio-state";
import type { import type {
AnimationPath, AnimationPath,
AnimationInterpolation, AnimationInterpolation,
AnimationValue,
AnimationValueForPath,
ScalarCurveKeyframePatch, ScalarCurveKeyframePatch,
} from "@/animation/types"; } from "@/animation/types";
import type { ParamValue } from "@/params";
import { import {
getElementLocalTime, getElementLocalTime,
resolveAnimationPathValueAtTime, resolveAnimationPathValueAtTime,
@ -487,7 +486,7 @@ export class TimelineManager {
elementId: string; elementId: string;
propertyPath: AnimationPath; propertyPath: AnimationPath;
time: MediaTime; time: MediaTime;
value: AnimationValue; value: ParamValue;
interpolation?: AnimationInterpolation; interpolation?: AnimationInterpolation;
keyframeId?: string; keyframeId?: string;
}>; }>;
@ -538,7 +537,7 @@ export class TimelineManager {
// Pre-sample values at playhead for each (element, property) pair. // Pre-sample values at playhead for each (element, property) pair.
// This preserves "what you see is what you get" when all keyframes are deleted. // This preserves "what you see is what you get" when all keyframes are deleted.
const playheadTime = this.editor.playback.getCurrentTime(); const playheadTime = this.editor.playback.getCurrentTime();
const valueAtPlayheadMap = new Map<string, AnimationValue | null>(); const valueAtPlayheadMap = new Map<string, ParamValue | null>();
for (const { trackId, elementId, propertyPath } of keyframes) { for (const { trackId, elementId, propertyPath } of keyframes) {
const key = `${elementId}:${propertyPath}`; const key = `${elementId}:${propertyPath}`;
@ -559,9 +558,7 @@ export class TimelineManager {
}); });
const target = resolveAnimationTarget({ element, path: propertyPath }); const target = resolveAnimationTarget({ element, path: propertyPath });
const baseValue = const baseValue = target?.getBaseValue() ?? null;
(target?.getBaseValue() as AnimationValueForPath<AnimationPath> | null) ??
null;
if (baseValue === null) { if (baseValue === null) {
valueAtPlayheadMap.set(key, null); valueAtPlayheadMap.set(key, null);
continue; continue;

View File

@ -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",
);
});
});

View File

@ -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 ParamValue = number | string | boolean;
export type ParamValues = Record<string, ParamValue>; export type ParamValues = Record<string, ParamValue>;
export type ParamGroup = "stroke"; 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<TKey extends string = string> {
key: TKey;
valueKind: ChannelValueKind;
defaultInterpolation: "linear" | "hold";
}
export interface LeafChannelLayout<TValue extends ParamValue = ParamValue> {
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<string, ParamValue>,
> {
kind: "composite";
components: Array<ChannelComponentDefinition<keyof TComponents & string>>;
easingMode: ChannelEasingMode;
decompose: (value: TValue) => TComponents | null;
compose: (components: Partial<TComponents>) => TValue | null;
}
export type ChannelLayout<
TValue extends ParamValue = ParamValue,
TComponents extends object = Record<string, ParamValue>,
> = LeafChannelLayout<TValue> | CompositeChannelLayout<TValue, TComponents>;
export type ParamChannelLayout =
| LeafChannelLayout<number>
| LeafChannelLayout<boolean>
| LeafChannelLayout<string>
| CompositeChannelLayout<string, LinearRgba>;
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<TValue extends ParamValue>({
valueKind,
defaultInterpolation,
}: {
valueKind: ChannelValueKind;
defaultInterpolation: "linear" | "hold";
}): LeafChannelLayout<TValue> {
return {
kind: "leaf",
component: {
key: "value",
valueKind,
defaultInterpolation,
},
easingMode: "independent",
decompose: (value) => ({ value }),
compose: ({ value }) => value ?? null,
};
}
export const NUMBER_CHANNEL_LAYOUT: LeafChannelLayout<number> =
createLeafChannelLayout<number>({
valueKind: "scalar",
defaultInterpolation: "linear",
});
export const BOOLEAN_CHANNEL_LAYOUT: LeafChannelLayout<boolean> =
createLeafChannelLayout<boolean>({
valueKind: "discrete",
defaultInterpolation: "hold",
});
export const STRING_CHANNEL_LAYOUT: LeafChannelLayout<string> =
createLeafChannelLayout<string>({
valueKind: "discrete",
defaultInterpolation: "hold",
});
const colorComponent = (
key: keyof LinearRgba,
): ChannelComponentDefinition<keyof LinearRgba> => ({
key,
valueKind: "scalar",
defaultInterpolation: "linear",
});
export const COLOR_CHANNEL_LAYOUT: CompositeChannelLayout<string, LinearRgba> = {
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<TKey extends string = string> { interface BaseParamDefinition<TKey extends string = string> {
key: TKey; key: TKey;
label: string; label: string;
@ -17,6 +184,7 @@ export interface NumberParamDefinition<TKey extends string = string>
extends BaseParamDefinition<TKey> { extends BaseParamDefinition<TKey> {
type: "number"; type: "number";
default: number; default: number;
channels?: LeafChannelLayout<number>;
min: number; min: number;
max?: number; max?: number;
step: number; step: number;
@ -32,18 +200,21 @@ export interface BooleanParamDefinition<TKey extends string = string>
extends BaseParamDefinition<TKey> { extends BaseParamDefinition<TKey> {
type: "boolean"; type: "boolean";
default: boolean; default: boolean;
channels?: LeafChannelLayout<boolean>;
} }
export interface ColorParamDefinition<TKey extends string = string> export interface ColorParamDefinition<TKey extends string = string>
extends BaseParamDefinition<TKey> { extends BaseParamDefinition<TKey> {
type: "color"; type: "color";
default: string; default: string;
channels?: ChannelLayout<string, LinearRgba>;
} }
export interface SelectParamDefinition<TKey extends string = string> export interface SelectParamDefinition<TKey extends string = string>
extends BaseParamDefinition<TKey> { extends BaseParamDefinition<TKey> {
type: "select"; type: "select";
default: string; default: string;
channels?: LeafChannelLayout<string>;
options: Array<{ value: string; label: string }>; options: Array<{ value: string; label: string }>;
} }
@ -51,12 +222,14 @@ export interface TextParamDefinition<TKey extends string = string>
extends BaseParamDefinition<TKey> { extends BaseParamDefinition<TKey> {
type: "text"; type: "text";
default: string; default: string;
channels?: LeafChannelLayout<string>;
} }
export interface FontParamDefinition<TKey extends string = string> export interface FontParamDefinition<TKey extends string = string>
extends BaseParamDefinition<TKey> { extends BaseParamDefinition<TKey> {
type: "font"; type: "font";
default: string; default: string;
channels?: LeafChannelLayout<string>;
} }
export type ParamDefinition<TKey extends string = string> = export type ParamDefinition<TKey extends string = string> =
@ -67,17 +240,41 @@ export type ParamDefinition<TKey extends string = string> =
| TextParamDefinition<TKey> | TextParamDefinition<TKey>
| FontParamDefinition<TKey>; | FontParamDefinition<TKey>;
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({ export function getParamValueKind({
param, param,
}: { }: {
param: ParamDefinition; param: ParamDefinition;
}): "number" | "color" | "discrete" { }): "number" | "color" | "discrete" {
if (param.type === "number") { const layout = getParamChannelLayout({ param });
return "number"; if (layout.kind === "composite") {
}
if (param.type === "color") {
return "color"; return "color";
} }
if (layout.component.valueKind === "scalar") {
return "number";
}
return "discrete"; return "discrete";
} }
@ -86,7 +283,11 @@ export function getParamDefaultInterpolation({
}: { }: {
param: ParamDefinition; param: ParamDefinition;
}): "linear" | "hold" { }): "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({ export function getParamNumericRange({
@ -112,27 +313,30 @@ export function coerceParamValue({
param: ParamDefinition; param: ParamDefinition;
value: unknown; value: unknown;
}): ParamValue | null { }): ParamValue | null {
if (param.type === "number") { switch (param.type) {
if (typeof value !== "number" || Number.isNaN(value)) { case "number": {
return null; 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;
} }

View File

@ -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.

View File

@ -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" });
});
});

View File

@ -28,10 +28,11 @@ import { V25toV26Migration } from "./v25-to-v26";
import { V26toV27Migration } from "./v26-to-v27"; import { V26toV27Migration } from "./v26-to-v27";
import { V27toV28Migration } from "./v27-to-v28"; import { V27toV28Migration } from "./v27-to-v28";
import { V28toV29Migration } from "./v28-to-v29"; import { V28toV29Migration } from "./v28-to-v29";
import { V29toV30Migration } from "./v29-to-v30";
export { runStorageMigrations } from "./runner"; export { runStorageMigrations } from "./runner";
export type { MigrationProgress } from "./runner"; export type { MigrationProgress } from "./runner";
export const CURRENT_PROJECT_VERSION = 29; export const CURRENT_PROJECT_VERSION = 30;
export const migrations = [ export const migrations = [
new V0toV1Migration(), new V0toV1Migration(),
@ -63,4 +64,5 @@ export const migrations = [
new V26toV27Migration(), new V26toV27Migration(),
new V27toV28Migration(), new V27toV28Migration(),
new V28toV29Migration(), new V28toV29Migration(),
new V29toV30Migration(),
]; ];

View File

@ -0,0 +1,138 @@
import type { MigrationResult, ProjectRecord } from "./types";
import { getProjectId, isRecord } from "./utils";
export function transformProjectV29ToV30({
project,
}: {
project: ProjectRecord;
}): MigrationResult<ProjectRecord> {
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;
}

View File

@ -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<MigrationResult<ProjectRecord>> {
return transformProjectV29ToV30({ project });
}
}

View File

@ -1,8 +1,6 @@
import type { import type {
AnimationBindingKind,
AnimationInterpolation, AnimationInterpolation,
AnimationPath, AnimationPath,
AnimationValue,
NumericSpec, NumericSpec,
} from "@/animation/types"; } from "@/animation/types";
import { import {
@ -16,9 +14,11 @@ import { getGraphicDefinition } from "@/graphics";
import { import {
coerceParamValue, coerceParamValue,
getParamDefaultInterpolation, getParamDefaultInterpolation,
getParamChannelLayout,
getParamNumericRange, getParamNumericRange,
getParamValueKind, type ParamChannelLayout,
type ParamDefinition, type ParamDefinition,
type ParamValue,
type ParamValues, type ParamValues,
} from "@/params"; } from "@/params";
import { import {
@ -28,17 +28,16 @@ import type { TimelineElement } from "@/timeline";
import { isVisualElement } from "@/timeline/element-utils"; import { isVisualElement } from "@/timeline/element-utils";
export interface AnimationPathDescriptor { export interface AnimationPathDescriptor {
kind: AnimationBindingKind; channelLayout: ParamChannelLayout;
defaultInterpolation: AnimationInterpolation; defaultInterpolation: AnimationInterpolation;
numericRanges?: Partial<Record<string, NumericSpec>>; numericRanges?: Partial<Record<string, NumericSpec>>;
coerceValue: ({ value }: { value: AnimationValue }) => AnimationValue | null; coerceValue: ({ value }: { value: ParamValue }) => ParamValue | null;
getBaseValue: () => AnimationValue | null; getBaseValue: () => ParamValue | null;
setBaseValue: ({ value }: { value: AnimationValue }) => TimelineElement; setBaseValue: ({ value }: { value: ParamValue }) => TimelineElement;
} }
// Number/discrete bindings expose a single component named "value" // Leaf params expose a single component named "value". Composite params don't
// (see binding-values.ts). Multi-component kinds (vector2, color) don't carry // carry numeric ranges yet — revisit when one does.
// numeric ranges yet — revisit when one does.
function paramNumericRanges({ function paramNumericRanges({
param, param,
}: { }: {
@ -62,7 +61,7 @@ function buildParamDescriptor({
} }
return { return {
kind: getParamValueKind({ param }), channelLayout: getParamChannelLayout({ param }),
defaultInterpolation: getParamDefaultInterpolation({ param }), defaultInterpolation: getParamDefaultInterpolation({ param }),
numericRanges: paramNumericRanges({ param }), numericRanges: paramNumericRanges({ param }),
coerceValue: ({ value }) => coerceParamValue({ param, value }), coerceValue: ({ value }) => coerceParamValue({ param, value }),

View File

@ -121,28 +121,13 @@ function cloneVolumeAnimations({
}: { }: {
animations: ElementAnimations | undefined; animations: ElementAnimations | undefined;
}): ElementAnimations | undefined { }): ElementAnimations | undefined {
const volumeBinding = animations?.bindings.volume; const volumeData = animations?.volume;
if (!volumeBinding) { if (!volumeData) {
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) {
return undefined; return undefined;
} }
return cloneAnimations({ return cloneAnimations({
animations: { animations: { volume: volumeData },
bindings: {
volume: volumeBinding,
},
channels: subsetChannels,
},
shouldRegenerateKeyframeIds: true, shouldRegenerateKeyframeIds: true,
}); });
} }

View File

@ -1,11 +1,12 @@
import { import {
getCurveHandlesForNormalizedCubicBezier, getCurveHandlesForNormalizedCubicBezier,
getEditableScalarChannels, getEditableScalarChannels,
getEasingModeForKind,
getNormalizedCubicBezierForScalarSegment, getNormalizedCubicBezierForScalarSegment,
getScalarKeyframeContext, getScalarKeyframeContext,
updateScalarKeyframeCurve, updateScalarKeyframeCurve,
} from "@/animation"; } from "@/animation";
import { getChannelsFromData } from "@/animation/channel-data";
import { isScalarChannel } from "@/animation/interpolation";
import type { import type {
AnimationPath, AnimationPath,
ElementAnimations, ElementAnimations,
@ -143,12 +144,9 @@ function findKeyframeTime({
propertyPath: AnimationPath; propertyPath: AnimationPath;
keyframeId: string; keyframeId: string;
}): number | null { }): number | null {
const binding = animations.bindings[propertyPath]; const data = animations[propertyPath];
if (!binding) return null; for (const channel of getChannelsFromData({ data })) {
if (!channel || !isScalarChannel(channel)) continue;
for (const component of binding.components) {
const channel = animations.channels[component.channelId];
if (channel?.kind !== "scalar") continue;
const key = channel.keys.find((k) => k.id === keyframeId); const key = channel.keys.find((k) => k.id === keyframeId);
if (key !== undefined) return key.time; if (key !== undefined) return key.time;
} }
@ -300,8 +298,7 @@ function resolvePropertySelection({
} }
} }
const { binding: resolvedBinding, channels: scalarChannels } = scalarResult; const { easingMode, channels: scalarChannels } = scalarResult;
const easingMode = getEasingModeForKind(resolvedBinding.kind);
const contexts = scalarChannels.flatMap((channel) => { const contexts = scalarChannels.flatMap((channel) => {
const context = getScalarKeyframeContext({ const context = getScalarKeyframeContext({
animations: element.animations, animations: element.animations,

View File

@ -52,7 +52,7 @@ import { getTimelinePixelsPerSecond } from "@/timeline";
import { buildWaveformSourceKey } from "@/media/waveform-summary"; import { buildWaveformSourceKey } from "@/media/waveform-summary";
import { addMediaTime, type MediaTime, TICKS_PER_SECOND } from "@/wasm"; import { addMediaTime, type MediaTime, TICKS_PER_SECOND } from "@/wasm";
import { import {
getActionDefaultShortcuts, getActionDefinition,
type TAction, type TAction,
type TActionWithOptionalArgs, type TActionWithOptionalArgs,
invokeAction, invokeAction,
@ -186,7 +186,7 @@ export function getKeyframeIndicators({
} }
export function getDisplayShortcut({ action }: { action: TAction }) { export function getDisplayShortcut({ action }: { action: TAction }) {
const defaultShortcuts = getActionDefaultShortcuts({ action }); const defaultShortcuts = getActionDefinition({ action }).defaultShortcuts;
if (!defaultShortcuts?.length) { if (!defaultShortcuts?.length) {
return ""; return "";
} }