mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
refactor: rewrite animation system to use bindings and scalar channels
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user