From 1bd9fac203f09736c1225bbbe19e5d7cc31c0b93 Mon Sep 17 00:00:00 2001 From: Maze Date: Sun, 5 Apr 2026 13:15:58 +0200 Subject: refactor: rewrite animation system to use bindings and scalar channels --- .../migrations/__tests__/v21-to-v22.test.ts | 241 ++++++++++ .../storage/migrations/__tests__/v5-to-v6.test.ts | 186 ++++---- apps/web/src/services/storage/migrations/index.ts | 4 +- .../storage/migrations/transformers/v21-to-v22.ts | 511 +++++++++++++++++++++ .../src/services/storage/migrations/v21-to-v22.ts | 16 + 5 files changed, 865 insertions(+), 93 deletions(-) create mode 100644 apps/web/src/services/storage/migrations/__tests__/v21-to-v22.test.ts create mode 100644 apps/web/src/services/storage/migrations/transformers/v21-to-v22.ts create mode 100644 apps/web/src/services/storage/migrations/v21-to-v22.ts (limited to 'apps/web/src/services') 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>; + const tracks = scenes[0].tracks as Array>; + const elements = tracks[0].elements as Array>; + const animations = elements[0].animations as Record; + const bindings = animations.bindings as Record>; + const channels = animations.channels as Record>; + + 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[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[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> }) - .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> }) + .scenes[0]; + const result = transformProjectV5ToV6({ + project: { + ...v5Project, + version: 6, + scenes: [ + { + ...firstScene, + bookmarks: [{ time: 2 }, { time: 5 }], + }, + ], + } as Parameters[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[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> }) + .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; +} + +export function transformProjectV21ToV22({ + project, +}: { + project: ProjectRecord; +}): MigrationResult { + 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 = {}; + const nextChannels: Record = {}; + + 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({ + 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 }); + } +} -- cgit v1.3.1