refactor: animate params over time
This commit is contained in:
parent
b8d08df244
commit
401cc35743
71
README.md
71
README.md
|
|
@ -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
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -64,11 +64,6 @@ export {
|
|||
type GroupKeyframeRef,
|
||||
} from "./property-groups";
|
||||
|
||||
export {
|
||||
type EasingMode,
|
||||
getEasingModeForKind,
|
||||
} from "./binding-values";
|
||||
|
||||
export {
|
||||
isAnimationPath,
|
||||
isAnimationPropertyPath,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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" });
|
||||
});
|
||||
});
|
||||
|
|
@ -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(),
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 "";
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue