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" />
</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>
| | |
| --- | ---------------------------------------------------------------------- |
| | 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.
<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?
- **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

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)) {
const path = buildEffectParamPath({ effectId, paramKey });
resolved[paramKey] = animations?.bindings[path]
resolved[paramKey] = animations?.[path]
? resolveAnimationPathValueAtTime({
animations,
propertyPath: path,

View File

@ -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({

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@ -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[];
}

View File

@ -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({

View File

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

View File

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

View File

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

View File

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

View File

@ -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<string, AnimationValue | null>();
const valueAtPlayheadMap = new Map<string, ParamValue | null>();
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<AnimationPath> | null) ??
null;
const baseValue = target?.getBaseValue() ?? null;
if (baseValue === null) {
valueAtPlayheadMap.set(key, null);
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 ParamValues = Record<string, ParamValue>;
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> {
key: TKey;
label: string;
@ -17,6 +184,7 @@ export interface NumberParamDefinition<TKey extends string = string>
extends BaseParamDefinition<TKey> {
type: "number";
default: number;
channels?: LeafChannelLayout<number>;
min: number;
max?: number;
step: number;
@ -32,18 +200,21 @@ export interface BooleanParamDefinition<TKey extends string = string>
extends BaseParamDefinition<TKey> {
type: "boolean";
default: boolean;
channels?: LeafChannelLayout<boolean>;
}
export interface ColorParamDefinition<TKey extends string = string>
extends BaseParamDefinition<TKey> {
type: "color";
default: string;
channels?: ChannelLayout<string, LinearRgba>;
}
export interface SelectParamDefinition<TKey extends string = string>
extends BaseParamDefinition<TKey> {
type: "select";
default: string;
channels?: LeafChannelLayout<string>;
options: Array<{ value: string; label: string }>;
}
@ -51,12 +222,14 @@ export interface TextParamDefinition<TKey extends string = string>
extends BaseParamDefinition<TKey> {
type: "text";
default: string;
channels?: LeafChannelLayout<string>;
}
export interface FontParamDefinition<TKey extends string = string>
extends BaseParamDefinition<TKey> {
type: "font";
default: string;
channels?: LeafChannelLayout<string>;
}
export type ParamDefinition<TKey extends string = string> =
@ -67,17 +240,41 @@ export type ParamDefinition<TKey extends string = string> =
| TextParamDefinition<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({
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;
}

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 { 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(),
];

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 {
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<Record<string, NumericSpec>>;
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 }),

View File

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

View File

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

View File

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