From 401cc35743aafc4c8bdfb8055501650390bde88b Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Wed, 29 Apr 2026 10:37:39 +0200 Subject: refactor: animate params over time --- apps/web/src/services/storage/migrations/AGENTS.md | 10 ++ .../migrations/__tests__/v29-to-v30.test.ts | 139 +++++++++++++++++++++ apps/web/src/services/storage/migrations/index.ts | 4 +- .../storage/migrations/transformers/v29-to-v30.ts | 138 ++++++++++++++++++++ .../src/services/storage/migrations/v29-to-v30.ts | 14 +++ 5 files changed, 304 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/services/storage/migrations/AGENTS.md create mode 100644 apps/web/src/services/storage/migrations/__tests__/v29-to-v30.test.ts create mode 100644 apps/web/src/services/storage/migrations/transformers/v29-to-v30.ts create mode 100644 apps/web/src/services/storage/migrations/v29-to-v30.ts (limited to 'apps/web/src/services') diff --git a/apps/web/src/services/storage/migrations/AGENTS.md b/apps/web/src/services/storage/migrations/AGENTS.md new file mode 100644 index 00000000..5aed00a1 --- /dev/null +++ b/apps/web/src/services/storage/migrations/AGENTS.md @@ -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. \ No newline at end of file diff --git a/apps/web/src/services/storage/migrations/__tests__/v29-to-v30.test.ts b/apps/web/src/services/storage/migrations/__tests__/v29-to-v30.test.ts new file mode 100644 index 00000000..c68f8dce --- /dev/null +++ b/apps/web/src/services/storage/migrations/__tests__/v29-to-v30.test.ts @@ -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" }); + }); +}); diff --git a/apps/web/src/services/storage/migrations/index.ts b/apps/web/src/services/storage/migrations/index.ts index 2e2c83bc..03b5ed0c 100644 --- a/apps/web/src/services/storage/migrations/index.ts +++ b/apps/web/src/services/storage/migrations/index.ts @@ -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(), ]; diff --git a/apps/web/src/services/storage/migrations/transformers/v29-to-v30.ts b/apps/web/src/services/storage/migrations/transformers/v29-to-v30.ts new file mode 100644 index 00000000..f3050742 --- /dev/null +++ b/apps/web/src/services/storage/migrations/transformers/v29-to-v30.ts @@ -0,0 +1,138 @@ +import type { MigrationResult, ProjectRecord } from "./types"; +import { getProjectId, isRecord } from "./utils"; + +export function transformProjectV29ToV30({ + 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 >= 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; +} diff --git a/apps/web/src/services/storage/migrations/v29-to-v30.ts b/apps/web/src/services/storage/migrations/v29-to-v30.ts new file mode 100644 index 00000000..0b9c23b9 --- /dev/null +++ b/apps/web/src/services/storage/migrations/v29-to-v30.ts @@ -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> { + return transformProjectV29ToV30({ project }); + } +} -- cgit v1.3.1