summaryrefslogtreecommitdiff
path: root/apps/web/src/services
diff options
context:
space:
mode:
authorMaze <mazewinther@gmail.com>2026-04-05 13:15:58 +0200
committerMaze <mazewinther@gmail.com>2026-04-05 13:15:58 +0200
commit1bd9fac203f09736c1225bbbe19e5d7cc31c0b93 (patch)
treed5c65add056209043e627a53e47e6ebbf74e64c6 /apps/web/src/services
parent69da5bee0c50f331ec59a510d0cf0d45779bab31 (diff)
downloadopencut-1bd9fac203f09736c1225bbbe19e5d7cc31c0b93.tar.gz
refactor: rewrite animation system to use bindings and scalar channels
Diffstat (limited to 'apps/web/src/services')
-rw-r--r--apps/web/src/services/storage/migrations/__tests__/v21-to-v22.test.ts241
-rw-r--r--apps/web/src/services/storage/migrations/__tests__/v5-to-v6.test.ts186
-rw-r--r--apps/web/src/services/storage/migrations/index.ts4
-rw-r--r--apps/web/src/services/storage/migrations/transformers/v21-to-v22.ts511
-rw-r--r--apps/web/src/services/storage/migrations/v21-to-v22.ts16
5 files changed, 865 insertions, 93 deletions
diff --git a/apps/web/src/services/storage/migrations/__tests__/v21-to-v22.test.ts b/apps/web/src/services/storage/migrations/__tests__/v21-to-v22.test.ts
new file mode 100644
index 00000000..18b815b0
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/__tests__/v21-to-v22.test.ts
@@ -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");
+ });
+});
diff --git a/apps/web/src/services/storage/migrations/__tests__/v5-to-v6.test.ts b/apps/web/src/services/storage/migrations/__tests__/v5-to-v6.test.ts
index 66e87713..b05d1837 100644
--- a/apps/web/src/services/storage/migrations/__tests__/v5-to-v6.test.ts
+++ b/apps/web/src/services/storage/migrations/__tests__/v5-to-v6.test.ts
@@ -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 },
+ ]);
+ });
+});
diff --git a/apps/web/src/services/storage/migrations/index.ts b/apps/web/src/services/storage/migrations/index.ts
index ea596435..ef41673e 100644
--- a/apps/web/src/services/storage/migrations/index.ts
+++ b/apps/web/src/services/storage/migrations/index.ts
@@ -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(),
];
diff --git a/apps/web/src/services/storage/migrations/transformers/v21-to-v22.ts b/apps/web/src/services/storage/migrations/transformers/v21-to-v22.ts
new file mode 100644
index 00000000..15fd9edf
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/transformers/v21-to-v22.ts
@@ -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}`;
+}
diff --git a/apps/web/src/services/storage/migrations/v21-to-v22.ts b/apps/web/src/services/storage/migrations/v21-to-v22.ts
new file mode 100644
index 00000000..212109a5
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/v21-to-v22.ts
@@ -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 });
+ }
+}