refactor: rewrite animation system to use bindings and scalar channels

This commit is contained in:
Maze
2026-04-05 13:15:58 +02:00
parent 69da5bee0c
commit 1bd9fac203
35 changed files with 5242 additions and 3235 deletions
@@ -0,0 +1,241 @@
import { describe, expect, test } from "bun:test";
import { transformProjectV21ToV22 } from "../transformers/v21-to-v22";
describe("V21 to V22 Migration", () => {
test("migrates legacy animation channels to bindings and component channels", () => {
const result = transformProjectV21ToV22({
project: {
id: "project-v21-animations",
version: 21,
scenes: [
{
id: "scene-1",
tracks: [
{
id: "track-1",
elements: [
{
id: "element-1",
type: "text",
animations: {
channels: {
opacity: {
valueKind: "number",
keyframes: [
{
id: "opacity-1",
time: 1,
value: 0.5,
interpolation: "linear",
},
],
},
"transform.position": {
valueKind: "vector",
keyframes: [
{
id: "position-1",
time: 2,
value: { x: 10, y: 20 },
interpolation: "hold",
},
],
},
color: {
valueKind: "color",
keyframes: [
{
id: "color-1",
time: 3,
value: "#ff0000",
interpolation: "linear",
},
],
},
"effects.effect-1.params.enabled": {
valueKind: "discrete",
keyframes: [
{
id: "enabled-1",
time: 4,
value: true,
interpolation: "hold",
},
],
},
},
},
},
],
},
],
},
],
},
});
expect(result.skipped).toBe(false);
expect(result.project.version).toBe(22);
const scenes = result.project.scenes as Array<Record<string, unknown>>;
const tracks = scenes[0].tracks as Array<Record<string, unknown>>;
const elements = tracks[0].elements as Array<Record<string, unknown>>;
const animations = elements[0].animations as Record<string, unknown>;
const bindings = animations.bindings as Record<string, Record<string, unknown>>;
const channels = animations.channels as Record<string, Record<string, unknown>>;
expect(bindings.opacity).toEqual({
path: "opacity",
kind: "number",
components: [{ key: "value", channelId: "opacity:value" }],
});
expect(bindings["transform.position"]).toEqual({
path: "transform.position",
kind: "vector2",
components: [
{ key: "x", channelId: "transform.position:x" },
{ key: "y", channelId: "transform.position:y" },
],
});
expect(bindings.color).toEqual({
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" },
],
});
expect(bindings["effects.effect-1.params.enabled"]).toEqual({
path: "effects.effect-1.params.enabled",
kind: "discrete",
components: [
{
key: "value",
channelId: "effects.effect-1.params.enabled:value",
},
],
});
expect(channels["opacity:value"]).toEqual({
kind: "scalar",
keys: [
{
id: "opacity-1",
time: 1,
value: 0.5,
segmentToNext: "linear",
tangentMode: "flat",
},
],
});
expect(channels["transform.position:x"]).toEqual({
kind: "scalar",
keys: [
{
id: "position-1",
time: 2,
value: 10,
segmentToNext: "step",
tangentMode: "flat",
},
],
});
expect(channels["transform.position:y"]).toEqual({
kind: "scalar",
keys: [
{
id: "position-1",
time: 2,
value: 20,
segmentToNext: "step",
tangentMode: "flat",
},
],
});
expect(channels["color:r"]).toEqual({
kind: "scalar",
keys: [
{
id: "color-1",
time: 3,
value: 1,
segmentToNext: "linear",
tangentMode: "flat",
},
],
});
expect(channels["color:g"]).toEqual({
kind: "scalar",
keys: [
{
id: "color-1",
time: 3,
value: 0,
segmentToNext: "linear",
tangentMode: "flat",
},
],
});
expect(channels["color:b"]).toEqual({
kind: "scalar",
keys: [
{
id: "color-1",
time: 3,
value: 0,
segmentToNext: "linear",
tangentMode: "flat",
},
],
});
expect(channels["color:a"]).toEqual({
kind: "scalar",
keys: [
{
id: "color-1",
time: 3,
value: 1,
segmentToNext: "linear",
tangentMode: "flat",
},
],
});
expect(channels["effects.effect-1.params.enabled:value"]).toEqual({
kind: "discrete",
keys: [
{
id: "enabled-1",
time: 4,
value: true,
},
],
});
});
test("skips projects already on v22", () => {
const result = transformProjectV21ToV22({
project: {
id: "project-v22",
version: 22,
},
});
expect(result.skipped).toBe(true);
expect(result.reason).toBe("already v22");
});
test("skips projects not on v21", () => {
const result = transformProjectV21ToV22({
project: {
id: "project-v20",
version: 20,
},
});
expect(result.skipped).toBe(true);
expect(result.reason).toBe("not v21");
});
});
@@ -1,92 +1,94 @@
import { describe, expect, test } from "bun:test";
import { transformProjectV5ToV6 } from "../transformers/v5-to-v6";
import { v5Project } from "./fixtures";
describe("V5 to V6 Migration", () => {
test("converts number bookmarks to Bookmark objects", async () => {
const result = transformProjectV5ToV6({
project: v5Project as Parameters<
typeof transformProjectV5ToV6
>[0]["project"],
});
expect(result.skipped).toBe(false);
expect(result.project.version).toBe(6);
const mainScene = (
result.project.scenes as Array<{ bookmarks: unknown[] }>
)[0];
expect(mainScene.bookmarks).toEqual([
{ time: 2.0 },
{ time: 5.5 },
{ time: 12.0 },
]);
const introScene = (
result.project.scenes as Array<{ bookmarks: unknown[] }>
)[1];
expect(introScene.bookmarks).toEqual([]);
});
test("skips projects that are already v6", () => {
const result = transformProjectV5ToV6({
project: {
...v5Project,
version: 6,
scenes: [
{
...(v5Project as { scenes: unknown[] }).scenes[0],
bookmarks: [{ time: 2 }, { time: 5 }],
},
],
} as Parameters<typeof transformProjectV5ToV6>[0]["project"],
});
expect(result.skipped).toBe(true);
expect(result.reason).toBe("already v6");
});
test("skips projects with no id", () => {
const result = transformProjectV5ToV6({
project: {
version: 5,
scenes: [],
} as Parameters<typeof transformProjectV5ToV6>[0]["project"],
});
expect(result.skipped).toBe(true);
expect(result.reason).toBe("no project id");
});
test("preserves existing Bookmark objects with note, color, duration", () => {
const projectWithRichBookmarks = {
...v5Project,
version: 5,
scenes: [
{
...(v5Project as { scenes: Array<Record<string, unknown>> })
.scenes[0],
bookmarks: [
{ time: 1, note: "Intro", color: "#ef4444" },
{ time: 5.5, duration: 2 },
],
},
],
};
const result = transformProjectV5ToV6({
project: projectWithRichBookmarks as Parameters<
typeof transformProjectV5ToV6
>[0]["project"],
});
expect(result.skipped).toBe(false);
const mainScene = (
result.project.scenes as Array<{ bookmarks: unknown[] }>
)[0];
expect(mainScene.bookmarks).toEqual([
{ time: 1, note: "Intro", color: "#ef4444" },
{ time: 5.5, duration: 2 },
]);
});
});
import { describe, expect, test } from "bun:test";
import { transformProjectV5ToV6 } from "../transformers/v5-to-v6";
import { v5Project } from "./fixtures";
describe("V5 to V6 Migration", () => {
test("converts number bookmarks to Bookmark objects", async () => {
const result = transformProjectV5ToV6({
project: v5Project as Parameters<
typeof transformProjectV5ToV6
>[0]["project"],
});
expect(result.skipped).toBe(false);
expect(result.project.version).toBe(6);
const mainScene = (
result.project.scenes as Array<{ bookmarks: unknown[] }>
)[0];
expect(mainScene.bookmarks).toEqual([
{ time: 2.0 },
{ time: 5.5 },
{ time: 12.0 },
]);
const introScene = (
result.project.scenes as Array<{ bookmarks: unknown[] }>
)[1];
expect(introScene.bookmarks).toEqual([]);
});
test("skips projects that are already v6", () => {
const firstScene = (v5Project as { scenes: Array<Record<string, unknown>> })
.scenes[0];
const result = transformProjectV5ToV6({
project: {
...v5Project,
version: 6,
scenes: [
{
...firstScene,
bookmarks: [{ time: 2 }, { time: 5 }],
},
],
} as Parameters<typeof transformProjectV5ToV6>[0]["project"],
});
expect(result.skipped).toBe(true);
expect(result.reason).toBe("already v6");
});
test("skips projects with no id", () => {
const result = transformProjectV5ToV6({
project: {
version: 5,
scenes: [],
} as Parameters<typeof transformProjectV5ToV6>[0]["project"],
});
expect(result.skipped).toBe(true);
expect(result.reason).toBe("no project id");
});
test("preserves existing Bookmark objects with note, color, duration", () => {
const projectWithRichBookmarks = {
...v5Project,
version: 5,
scenes: [
{
...(v5Project as { scenes: Array<Record<string, unknown>> })
.scenes[0],
bookmarks: [
{ time: 1, note: "Intro", color: "#ef4444" },
{ time: 5.5, duration: 2 },
],
},
],
};
const result = transformProjectV5ToV6({
project: projectWithRichBookmarks as Parameters<
typeof transformProjectV5ToV6
>[0]["project"],
});
expect(result.skipped).toBe(false);
const mainScene = (
result.project.scenes as Array<{ bookmarks: unknown[] }>
)[0];
expect(mainScene.bookmarks).toEqual([
{ time: 1, note: "Intro", color: "#ef4444" },
{ time: 5.5, duration: 2 },
]);
});
});
@@ -20,10 +20,11 @@ import { V17toV18Migration } from "./v17-to-v18";
import { V18toV19Migration } from "./v18-to-v19";
import { V19toV20Migration } from "./v19-to-v20";
import { V20toV21Migration } from "./v20-to-v21";
import { V21toV22Migration } from "./v21-to-v22";
export { runStorageMigrations } from "./runner";
export type { MigrationProgress } from "./runner";
export const CURRENT_PROJECT_VERSION = 21;
export const CURRENT_PROJECT_VERSION = 22;
export const migrations = [
new V0toV1Migration(),
@@ -47,4 +48,5 @@ export const migrations = [
new V18toV19Migration(),
new V19toV20Migration(),
new V20toV21Migration(),
new V21toV22Migration(),
];
@@ -0,0 +1,511 @@
import { parseColorToLinearRgba } from "@/lib/animation/binding-values";
import type { MigrationResult, ProjectRecord } from "./types";
import { getProjectId, isRecord } from "./utils";
const COLOR_COMPONENT_KEYS = ["r", "g", "b", "a"] as const;
type LegacyInterpolation = "linear" | "hold";
interface LegacyScalarKeyframe {
id: string;
time: number;
value: number;
interpolation: LegacyInterpolation;
}
interface LegacyDiscreteKeyframe {
id: string;
time: number;
value: string | boolean;
}
interface LegacyVectorValue {
x: number;
y: number;
}
interface LegacyVectorKeyframe {
id: string;
time: number;
value: LegacyVectorValue;
interpolation: LegacyInterpolation;
}
interface MigratedAnimationChannel {
binding: ProjectRecord;
channels: Record<string, ProjectRecord>;
}
export function transformProjectV21ToV22({
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 >= 22) {
return { project, skipped: true, reason: "already v22" };
}
if (version !== 21) {
return { project, skipped: true, reason: "not v21" };
}
return {
project: {
...migrateProjectAnimations({ project }),
version: 22,
},
skipped: false,
};
}
function migrateProjectAnimations({
project,
}: {
project: ProjectRecord;
}): ProjectRecord {
const scenes = project.scenes;
if (!Array.isArray(scenes)) {
return project;
}
return {
...project,
scenes: scenes.map((scene) => migrateSceneAnimations({ scene })),
};
}
function migrateSceneAnimations({ scene }: { scene: unknown }): unknown {
if (!isRecord(scene)) {
return scene;
}
const tracks = scene.tracks;
if (!Array.isArray(tracks)) {
return scene;
}
return {
...scene,
tracks: tracks.map((track) => migrateTrackAnimations({ track })),
};
}
function migrateTrackAnimations({ track }: { track: unknown }): unknown {
if (!isRecord(track)) {
return track;
}
const elements = track.elements;
if (!Array.isArray(elements)) {
return track;
}
return {
...track,
elements: elements.map((element) => migrateElementAnimations({ element })),
};
}
function migrateElementAnimations({ element }: { element: unknown }): unknown {
if (!isRecord(element)) {
return element;
}
const animations = element.animations;
if (!isRecord(animations)) {
return element;
}
if (isRecord(animations.bindings)) {
return element;
}
const migratedAnimations = migrateLegacyAnimations({ animations });
if (!migratedAnimations) {
const { animations: _unusedAnimations, ...elementWithoutAnimations } = element;
return elementWithoutAnimations;
}
return {
...element,
animations: migratedAnimations,
};
}
function migrateLegacyAnimations({
animations,
}: {
animations: ProjectRecord;
}): ProjectRecord | null {
const legacyChannels = animations.channels;
if (!isRecord(legacyChannels)) {
return null;
}
const nextBindings: Record<string, ProjectRecord> = {};
const nextChannels: Record<string, ProjectRecord> = {};
for (const [propertyPath, channel] of Object.entries(legacyChannels)) {
const migratedChannel = migrateLegacyChannel({
propertyPath,
channel,
});
if (!migratedChannel) {
continue;
}
nextBindings[propertyPath] = migratedChannel.binding;
Object.assign(nextChannels, migratedChannel.channels);
}
if (Object.keys(nextBindings).length === 0) {
return null;
}
return {
bindings: nextBindings,
channels: nextChannels,
};
}
function migrateLegacyChannel({
propertyPath,
channel,
}: {
propertyPath: string;
channel: unknown;
}): MigratedAnimationChannel | null {
if (!isRecord(channel)) {
return null;
}
switch (channel.valueKind) {
case "number":
return migrateNumberChannel({ propertyPath, channel });
case "discrete":
return migrateDiscreteChannel({ propertyPath, channel });
case "vector":
return migrateVectorChannel({ propertyPath, channel });
case "color":
return migrateColorChannel({ propertyPath, channel });
default:
return null;
}
}
function migrateNumberChannel({
propertyPath,
channel,
}: {
propertyPath: string;
channel: ProjectRecord;
}): MigratedAnimationChannel | null {
const legacyKeys = getLegacyScalarKeyframes({
channel,
isValidValue: (value): value is number =>
typeof value === "number" && Number.isFinite(value),
});
if (legacyKeys.length === 0) {
return null;
}
return {
binding: {
path: propertyPath,
kind: "number",
components: [
{
key: "value",
channelId: buildChannelId({
propertyPath,
componentKey: "value",
}),
},
],
},
channels: {
[buildChannelId({ propertyPath, componentKey: "value" })]: {
kind: "scalar",
keys: legacyKeys.map((keyframe) => toScalarKeyframe({ keyframe })),
},
},
};
}
function migrateDiscreteChannel({
propertyPath,
channel,
}: {
propertyPath: string;
channel: ProjectRecord;
}): MigratedAnimationChannel | null {
const legacyKeys = getLegacyDiscreteKeyframes({ channel });
if (legacyKeys.length === 0) {
return null;
}
return {
binding: {
path: propertyPath,
kind: "discrete",
components: [
{
key: "value",
channelId: buildChannelId({
propertyPath,
componentKey: "value",
}),
},
],
},
channels: {
[buildChannelId({ propertyPath, componentKey: "value" })]: {
kind: "discrete",
keys: legacyKeys.map((keyframe) => ({
id: keyframe.id,
time: keyframe.time,
value: keyframe.value,
})),
},
},
};
}
function migrateVectorChannel({
propertyPath,
channel,
}: {
propertyPath: string;
channel: ProjectRecord;
}): MigratedAnimationChannel | null {
const legacyKeys = getLegacyScalarKeyframes({
channel,
isValidValue: isLegacyVectorValue,
});
if (legacyKeys.length === 0) {
return null;
}
const xChannelId = buildChannelId({ propertyPath, componentKey: "x" });
const yChannelId = buildChannelId({ propertyPath, componentKey: "y" });
return {
binding: {
path: propertyPath,
kind: "vector2",
components: [
{ key: "x", channelId: xChannelId },
{ key: "y", channelId: yChannelId },
],
},
channels: {
[xChannelId]: {
kind: "scalar",
keys: legacyKeys.map((keyframe) =>
toScalarKeyframe({
keyframe: {
...keyframe,
value: keyframe.value.x,
},
}),
),
},
[yChannelId]: {
kind: "scalar",
keys: legacyKeys.map((keyframe) =>
toScalarKeyframe({
keyframe: {
...keyframe,
value: keyframe.value.y,
},
}),
),
},
},
};
}
function migrateColorChannel({
propertyPath,
channel,
}: {
propertyPath: string;
channel: ProjectRecord;
}): MigratedAnimationChannel | null {
const legacyKeys = getLegacyScalarKeyframes({
channel,
isValidValue: (value): value is string => typeof value === "string",
});
if (legacyKeys.length === 0) {
return null;
}
const colorKeys = legacyKeys.flatMap((keyframe) => {
const linearRgba = parseColorToLinearRgba({ color: keyframe.value });
if (!linearRgba) {
return [];
}
return [
{
id: keyframe.id,
time: keyframe.time,
interpolation: keyframe.interpolation,
values: linearRgba,
},
];
});
if (colorKeys.length === 0) {
return null;
}
const channels = Object.fromEntries(
COLOR_COMPONENT_KEYS.map((componentKey) => [
buildChannelId({ propertyPath, componentKey }),
{
kind: "scalar",
keys: colorKeys.map((keyframe) =>
toScalarKeyframe({
keyframe: {
id: keyframe.id,
time: keyframe.time,
value: keyframe.values[componentKey],
interpolation: keyframe.interpolation,
},
}),
),
},
]),
);
return {
binding: {
path: propertyPath,
kind: "color",
colorSpace: "srgb-linear",
components: COLOR_COMPONENT_KEYS.map((componentKey) => ({
key: componentKey,
channelId: buildChannelId({ propertyPath, componentKey }),
})),
},
channels,
};
}
function getLegacyScalarKeyframes<TValue>({
channel,
isValidValue,
}: {
channel: ProjectRecord;
isValidValue: (value: unknown) => value is TValue;
}): Array<{
id: string;
time: number;
value: TValue;
interpolation: LegacyInterpolation;
}> {
const keyframes = channel.keyframes;
if (!Array.isArray(keyframes)) {
return [];
}
return keyframes.flatMap((keyframe) => {
if (!isRecord(keyframe)) {
return [];
}
if (
typeof keyframe.id !== "string" ||
typeof keyframe.time !== "number" ||
!Number.isFinite(keyframe.time) ||
!isValidValue(keyframe.value)
) {
return [];
}
return [
{
id: keyframe.id,
time: keyframe.time,
value: keyframe.value,
interpolation:
keyframe.interpolation === "hold" ? "hold" : "linear",
},
];
});
}
function getLegacyDiscreteKeyframes({
channel,
}: {
channel: ProjectRecord;
}): LegacyDiscreteKeyframe[] {
const keyframes = channel.keyframes;
if (!Array.isArray(keyframes)) {
return [];
}
return keyframes.flatMap((keyframe) => {
if (!isRecord(keyframe)) {
return [];
}
if (
typeof keyframe.id !== "string" ||
typeof keyframe.time !== "number" ||
!Number.isFinite(keyframe.time) ||
(typeof keyframe.value !== "string" && typeof keyframe.value !== "boolean")
) {
return [];
}
return [
{
id: keyframe.id,
time: keyframe.time,
value: keyframe.value,
},
];
});
}
function isLegacyVectorValue(value: unknown): value is LegacyVectorValue {
return (
isRecord(value) &&
typeof value.x === "number" &&
Number.isFinite(value.x) &&
typeof value.y === "number" &&
Number.isFinite(value.y)
);
}
function toScalarKeyframe({
keyframe,
}: {
keyframe: LegacyScalarKeyframe;
}): ProjectRecord {
return {
id: keyframe.id,
time: keyframe.time,
value: keyframe.value,
segmentToNext:
keyframe.interpolation === "hold" ? "step" : "linear",
tangentMode: "flat",
};
}
function buildChannelId({
propertyPath,
componentKey,
}: {
propertyPath: string;
componentKey: string;
}) {
return `${propertyPath}:${componentKey}`;
}
@@ -0,0 +1,16 @@
import { StorageMigration } from "./base";
import type { ProjectRecord } from "./transformers/types";
import { transformProjectV21ToV22 } from "./transformers/v21-to-v22";
export class V21toV22Migration extends StorageMigration {
from = 21;
to = 22;
async transform(project: ProjectRecord): Promise<{
project: ProjectRecord;
skipped: boolean;
reason?: string;
}> {
return transformProjectV21ToV22({ project });
}
}