mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
feat: major editor overhaul (assets, properties, timeline, fonts) (#709)
* feat: major editor overhaul (assets, properties, timeline, fonts) Refactor editor core systems to standardize UI architecture and improve performance. Assets & Properties: - Replace monolithic property items with composable `Section` architecture. - Add specialized sections for Transform, Blending, and Text. - Implement `NumberField` with scrubbing and math evaluation. - Add new ColorPicker with EyeDropper and multiple format support. - Standardize asset panels using new `PanelView` layout. Fonts & Stickers: - Implement custom font atlas/sprite system for high-performance previews. - Add virtualized FontPicker with search and favorites. - Refactor stickers to use a provider-based architecture (icons, emoji, flags, shapes). - Standardize sticker IDs to `provider:value` format. Timeline & Interaction: - Convert bookmarks to rich objects with notes, colors, and duration. - Refactor drag-and-drop to use Command pattern (enabling proper undo/redo). - Add Shift modifier to disable snapping during moves/resizes. - Add new overlays for layout guides and text editing. Renderer: - Add support for multi-line text, custom line-height, and letter-spacing. - Implement global composite operation (blend modes). - Update sticker node to resolve dynamic provider IDs. Infrastructure: - Add storage migrations (v3->v6) for text weights, sticker IDs, and bookmarks. - Update global styles and core UI components (Button, Input, Popover). * add ts-nocheck directive to settings-legacy.tsx to suppress TypeScript errors * fix: correct global composite operation assignment in TextNode to ensure proper blend mode handling * deleted shadcn components with errors * formatting * fix linter issues * migrate from next middleware to proxy * add missing component back * add breadcrumb back * chore: add @radix-ui/react-primitive deps * chore: more deps * chore: add missing env vars to bun-ci * next env
This commit is contained in:
@@ -7,3 +7,4 @@ export * from "./v0";
|
||||
export * from "./v1";
|
||||
export * from "./v2";
|
||||
export * from "./v3";
|
||||
export * from "./v5";
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
export const v5Project = {
|
||||
id: "project-v5-456",
|
||||
version: 5,
|
||||
metadata: {
|
||||
id: "project-v5-456",
|
||||
name: "My V5 Project",
|
||||
thumbnail: "data:image/png;base64,abc123",
|
||||
duration: 30,
|
||||
createdAt: "2024-06-01T10:00:00.000Z",
|
||||
updatedAt: "2024-06-01T14:00:00.000Z",
|
||||
},
|
||||
settings: {
|
||||
fps: 30,
|
||||
canvasSize: { width: 1920, height: 1080 },
|
||||
background: { type: "color", color: "#000000" },
|
||||
},
|
||||
currentSceneId: "scene-main",
|
||||
scenes: [
|
||||
{
|
||||
id: "scene-main",
|
||||
name: "Main scene",
|
||||
isMain: true,
|
||||
tracks: [
|
||||
{
|
||||
id: "track-1",
|
||||
type: "video",
|
||||
name: "Video Track",
|
||||
isMain: true,
|
||||
elements: [],
|
||||
},
|
||||
],
|
||||
bookmarks: [2.0, 5.5, 12.0],
|
||||
createdAt: "2024-06-01T10:00:00.000Z",
|
||||
updatedAt: "2024-06-01T14:00:00.000Z",
|
||||
},
|
||||
{
|
||||
id: "scene-intro",
|
||||
name: "Intro",
|
||||
isMain: false,
|
||||
tracks: [
|
||||
{
|
||||
id: "track-2",
|
||||
type: "video",
|
||||
name: "Video Track",
|
||||
isMain: true,
|
||||
elements: [],
|
||||
},
|
||||
],
|
||||
bookmarks: [],
|
||||
createdAt: "2024-06-01T10:00:00.000Z",
|
||||
updatedAt: "2024-06-01T14:00:00.000Z",
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,140 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { transformProjectV3ToV4 } from "../transformers/v3-to-v4";
|
||||
import { v3Project } from "./fixtures";
|
||||
|
||||
describe("V3 to V4 Migration", () => {
|
||||
test("normalizes legacy text fontWeight values", () => {
|
||||
const projectWithLegacyTextWeight = {
|
||||
...v3Project,
|
||||
scenes: [
|
||||
{
|
||||
...v3Project.scenes[0],
|
||||
tracks: [
|
||||
{
|
||||
id: "track-text",
|
||||
type: "text",
|
||||
name: "Text Track",
|
||||
hidden: false,
|
||||
elements: [
|
||||
{
|
||||
id: "text-1",
|
||||
type: "text",
|
||||
name: "Title",
|
||||
content: "Hello",
|
||||
duration: 5,
|
||||
startTime: 0,
|
||||
trimStart: 0,
|
||||
trimEnd: 0,
|
||||
fontSize: 64,
|
||||
fontFamily: "Inter",
|
||||
color: "#ffffff",
|
||||
backgroundColor: "transparent",
|
||||
textAlign: "center",
|
||||
fontWeight: "bold",
|
||||
fontStyle: "normal",
|
||||
textDecoration: "none",
|
||||
transform: {
|
||||
scale: 1,
|
||||
position: { x: 0, y: 0 },
|
||||
rotate: 0,
|
||||
},
|
||||
opacity: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = transformProjectV3ToV4({
|
||||
project: projectWithLegacyTextWeight,
|
||||
});
|
||||
|
||||
expect(result.skipped).toBe(false);
|
||||
expect(result.project.version).toBe(4);
|
||||
|
||||
const migratedScene = (
|
||||
result.project.scenes as Array<Record<string, unknown>>
|
||||
)[0];
|
||||
const migratedTrack = (
|
||||
migratedScene.tracks as Array<Record<string, unknown>>
|
||||
)[0];
|
||||
const migratedElement = (
|
||||
migratedTrack.elements as Array<Record<string, unknown>>
|
||||
)[0];
|
||||
|
||||
expect(migratedElement.fontWeight).toBe("700");
|
||||
});
|
||||
|
||||
test("does not mutate non-text tracks", () => {
|
||||
const projectWithoutTextTrack = {
|
||||
...v3Project,
|
||||
scenes: [
|
||||
{
|
||||
...v3Project.scenes[0],
|
||||
tracks: [
|
||||
{
|
||||
id: "track-sticker",
|
||||
type: "sticker",
|
||||
name: "Sticker Track",
|
||||
hidden: false,
|
||||
elements: [
|
||||
{
|
||||
id: "sticker-1",
|
||||
type: "sticker",
|
||||
name: "Flag",
|
||||
iconName: "mdi:home",
|
||||
duration: 5,
|
||||
startTime: 0,
|
||||
trimStart: 0,
|
||||
trimEnd: 0,
|
||||
transform: {
|
||||
scale: 1,
|
||||
position: { x: 0, y: 0 },
|
||||
rotate: 0,
|
||||
},
|
||||
opacity: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = transformProjectV3ToV4({ project: projectWithoutTextTrack });
|
||||
const migratedScene = (
|
||||
result.project.scenes as Array<Record<string, unknown>>
|
||||
)[0];
|
||||
const migratedTrack = (
|
||||
migratedScene.tracks as Array<Record<string, unknown>>
|
||||
)[0];
|
||||
const migratedElement = (
|
||||
migratedTrack.elements as Array<Record<string, unknown>>
|
||||
)[0];
|
||||
|
||||
expect(migratedElement.iconName).toBe("mdi:home");
|
||||
});
|
||||
|
||||
test("skips projects that are already v4", () => {
|
||||
const result = transformProjectV3ToV4({
|
||||
project: { ...v3Project, version: 4 },
|
||||
});
|
||||
|
||||
expect(result.skipped).toBe(true);
|
||||
expect(result.reason).toBe("already v4");
|
||||
});
|
||||
|
||||
test("skips projects with no id", () => {
|
||||
const result = transformProjectV3ToV4({
|
||||
project: {
|
||||
version: 3,
|
||||
scenes: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.skipped).toBe(true);
|
||||
expect(result.reason).toBe("no project id");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { transformProjectV4ToV5 } from "../transformers/v4-to-v5";
|
||||
import { v3Project } from "./fixtures";
|
||||
|
||||
describe("V4 to V5 Migration", () => {
|
||||
test("migrates sticker iconName to stickerId and removes legacy color", () => {
|
||||
const projectWithLegacySticker = {
|
||||
...v3Project,
|
||||
version: 4,
|
||||
scenes: [
|
||||
{
|
||||
...v3Project.scenes[0],
|
||||
tracks: [
|
||||
{
|
||||
id: "track-sticker",
|
||||
type: "sticker",
|
||||
name: "Sticker Track",
|
||||
hidden: false,
|
||||
elements: [
|
||||
{
|
||||
id: "sticker-1",
|
||||
type: "sticker",
|
||||
name: "Home",
|
||||
iconName: "mdi:home",
|
||||
color: "#ff0000",
|
||||
duration: 5,
|
||||
startTime: 0,
|
||||
trimStart: 0,
|
||||
trimEnd: 0,
|
||||
transform: {
|
||||
scale: 1,
|
||||
position: { x: 0, y: 0 },
|
||||
rotate: 0,
|
||||
},
|
||||
opacity: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = transformProjectV4ToV5({
|
||||
project: projectWithLegacySticker,
|
||||
});
|
||||
|
||||
expect(result.skipped).toBe(false);
|
||||
expect(result.project.version).toBe(5);
|
||||
|
||||
const migratedScene = (
|
||||
result.project.scenes as Array<Record<string, unknown>>
|
||||
)[0];
|
||||
const migratedTrack = (
|
||||
migratedScene.tracks as Array<Record<string, unknown>>
|
||||
)[0];
|
||||
const migratedElement = (
|
||||
migratedTrack.elements as Array<Record<string, unknown>>
|
||||
)[0];
|
||||
|
||||
expect(migratedElement.stickerId).toBe("icons:mdi:home");
|
||||
expect("iconName" in migratedElement).toBe(false);
|
||||
expect("color" in migratedElement).toBe(false);
|
||||
});
|
||||
|
||||
test("keeps provider-prefixed stickerId values unchanged", () => {
|
||||
const projectWithStickerId = {
|
||||
...v3Project,
|
||||
version: 4,
|
||||
scenes: [
|
||||
{
|
||||
...v3Project.scenes[0],
|
||||
tracks: [
|
||||
{
|
||||
id: "track-sticker",
|
||||
type: "sticker",
|
||||
name: "Sticker Track",
|
||||
hidden: false,
|
||||
elements: [
|
||||
{
|
||||
id: "sticker-1",
|
||||
type: "sticker",
|
||||
name: "Flag",
|
||||
stickerId: "flags:AD",
|
||||
duration: 5,
|
||||
startTime: 0,
|
||||
trimStart: 0,
|
||||
trimEnd: 0,
|
||||
transform: {
|
||||
scale: 1,
|
||||
position: { x: 0, y: 0 },
|
||||
rotate: 0,
|
||||
},
|
||||
opacity: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = transformProjectV4ToV5({ project: projectWithStickerId });
|
||||
const migratedScene = (
|
||||
result.project.scenes as Array<Record<string, unknown>>
|
||||
)[0];
|
||||
const migratedTrack = (
|
||||
migratedScene.tracks as Array<Record<string, unknown>>
|
||||
)[0];
|
||||
const migratedElement = (
|
||||
migratedTrack.elements as Array<Record<string, unknown>>
|
||||
)[0];
|
||||
|
||||
expect(migratedElement.stickerId).toBe("flags:AD");
|
||||
});
|
||||
|
||||
test("skips projects that are already v5", () => {
|
||||
const result = transformProjectV4ToV5({
|
||||
project: { ...v3Project, version: 5 },
|
||||
});
|
||||
|
||||
expect(result.skipped).toBe(true);
|
||||
expect(result.reason).toBe("already v5");
|
||||
});
|
||||
|
||||
test("skips projects with no id", () => {
|
||||
const result = transformProjectV4ToV5({
|
||||
project: {
|
||||
version: 4,
|
||||
scenes: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.skipped).toBe(true);
|
||||
expect(result.reason).toBe("no project id");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -2,13 +2,19 @@ export { StorageMigration } from "./base";
|
||||
import { V0toV1Migration } from "./v0-to-v1";
|
||||
import { V1toV2Migration } from "./v1-to-v2";
|
||||
import { V2toV3Migration } from "./v2-to-v3";
|
||||
import { V3toV4Migration } from "./v3-to-v4";
|
||||
import { V4toV5Migration } from "./v4-to-v5";
|
||||
import { V5toV6Migration } from "./v5-to-v6";
|
||||
export { runStorageMigrations } from "./runner";
|
||||
export type { MigrationProgress } from "./runner";
|
||||
|
||||
export const CURRENT_PROJECT_VERSION = 3;
|
||||
export const CURRENT_PROJECT_VERSION = 6;
|
||||
|
||||
export const migrations = [
|
||||
new V0toV1Migration(),
|
||||
new V1toV2Migration(),
|
||||
new V2toV3Migration(),
|
||||
new V3toV4Migration(),
|
||||
new V4toV5Migration(),
|
||||
new V5toV6Migration(),
|
||||
];
|
||||
|
||||
@@ -136,7 +136,11 @@ function getProjectVersion({ project }: { project: ProjectRecord }): number {
|
||||
return 0;
|
||||
}
|
||||
|
||||
function getProjectName({ project }: { project: ProjectRecord }): string | null {
|
||||
function getProjectName({
|
||||
project,
|
||||
}: {
|
||||
project: ProjectRecord;
|
||||
}): string | null {
|
||||
const metadata = project.metadata;
|
||||
if (isRecord(metadata) && typeof metadata.name === "string") {
|
||||
return metadata.name;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export { transformProjectV0ToV1 } from "./v0-to-v1";
|
||||
export { transformProjectV1ToV2 } from "./v1-to-v2";
|
||||
export { transformProjectV2ToV3 } from "./v2-to-v3";
|
||||
export { transformProjectV3ToV4 } from "./v3-to-v4";
|
||||
export { transformProjectV4ToV5 } from "./v4-to-v5";
|
||||
export type { MigrationResult, ProjectRecord } from "./types";
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
import type { MigrationResult, ProjectRecord } from "./types";
|
||||
import { getProjectId, isRecord } from "./utils";
|
||||
|
||||
const LEGACY_FONT_WEIGHT_MAP = {
|
||||
normal: "400",
|
||||
bold: "700",
|
||||
} as const;
|
||||
|
||||
const VALID_NUMERIC_FONT_WEIGHTS = new Set([
|
||||
"100",
|
||||
"200",
|
||||
"300",
|
||||
"400",
|
||||
"500",
|
||||
"600",
|
||||
"700",
|
||||
"800",
|
||||
"900",
|
||||
]);
|
||||
|
||||
export function transformProjectV3ToV4({
|
||||
project,
|
||||
}: {
|
||||
project: ProjectRecord;
|
||||
}): MigrationResult<ProjectRecord> {
|
||||
const projectId = getProjectId({ project });
|
||||
if (!projectId) {
|
||||
return { project, skipped: true, reason: "no project id" };
|
||||
}
|
||||
|
||||
if (isV4Project({ project })) {
|
||||
return { project, skipped: true, reason: "already v4" };
|
||||
}
|
||||
|
||||
const migratedProject = normalizeProjectTextFontWeights({ project });
|
||||
|
||||
return {
|
||||
project: {
|
||||
...migratedProject,
|
||||
version: 4,
|
||||
},
|
||||
skipped: false,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeProjectTextFontWeights({
|
||||
project,
|
||||
}: {
|
||||
project: ProjectRecord;
|
||||
}): ProjectRecord {
|
||||
const scenesValue = project.scenes;
|
||||
if (!Array.isArray(scenesValue)) {
|
||||
return project;
|
||||
}
|
||||
|
||||
let hasSceneChanges = false;
|
||||
const normalizedScenes = scenesValue.map((scene) => {
|
||||
const normalizedScene = normalizeSceneTextFontWeights({ scene });
|
||||
if (normalizedScene !== scene) {
|
||||
hasSceneChanges = true;
|
||||
}
|
||||
return normalizedScene;
|
||||
});
|
||||
|
||||
if (!hasSceneChanges) {
|
||||
return project;
|
||||
}
|
||||
|
||||
return {
|
||||
...project,
|
||||
scenes: normalizedScenes,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSceneTextFontWeights({ scene }: { scene: unknown }): unknown {
|
||||
if (!isRecord(scene)) {
|
||||
return scene;
|
||||
}
|
||||
|
||||
const tracksValue = scene.tracks;
|
||||
if (!Array.isArray(tracksValue)) {
|
||||
return scene;
|
||||
}
|
||||
|
||||
let hasTrackChanges = false;
|
||||
const normalizedTracks = tracksValue.map((track) => {
|
||||
const normalizedTrack = normalizeTrackTextFontWeights({ track });
|
||||
if (normalizedTrack !== track) {
|
||||
hasTrackChanges = true;
|
||||
}
|
||||
return normalizedTrack;
|
||||
});
|
||||
|
||||
if (!hasTrackChanges) {
|
||||
return scene;
|
||||
}
|
||||
|
||||
return {
|
||||
...scene,
|
||||
tracks: normalizedTracks,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTrackTextFontWeights({ track }: { track: unknown }): unknown {
|
||||
if (!isRecord(track)) {
|
||||
return track;
|
||||
}
|
||||
|
||||
if (track.type !== "text") {
|
||||
return track;
|
||||
}
|
||||
|
||||
const elementsValue = track.elements;
|
||||
if (!Array.isArray(elementsValue)) {
|
||||
return track;
|
||||
}
|
||||
|
||||
let hasElementChanges = false;
|
||||
const normalizedElements = elementsValue.map((element) => {
|
||||
const normalizedElement = normalizeTextElementFontWeight({ element });
|
||||
if (normalizedElement !== element) {
|
||||
hasElementChanges = true;
|
||||
}
|
||||
return normalizedElement;
|
||||
});
|
||||
|
||||
if (!hasElementChanges) {
|
||||
return track;
|
||||
}
|
||||
|
||||
return {
|
||||
...track,
|
||||
elements: normalizedElements,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTextElementFontWeight({
|
||||
element,
|
||||
}: {
|
||||
element: unknown;
|
||||
}): unknown {
|
||||
if (!isRecord(element) || element.type !== "text") {
|
||||
return element;
|
||||
}
|
||||
|
||||
const normalizedWeight = normalizeFontWeight({ value: element.fontWeight });
|
||||
if (normalizedWeight === element.fontWeight) {
|
||||
return element;
|
||||
}
|
||||
|
||||
return {
|
||||
...element,
|
||||
fontWeight: normalizedWeight,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeFontWeight({ value }: { value: unknown }): unknown {
|
||||
if (typeof value === "number") {
|
||||
const numericWeight = String(value);
|
||||
if (VALID_NUMERIC_FONT_WEIGHTS.has(numericWeight)) {
|
||||
return numericWeight;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value !== "string") {
|
||||
return value;
|
||||
}
|
||||
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (normalized in LEGACY_FONT_WEIGHT_MAP) {
|
||||
return LEGACY_FONT_WEIGHT_MAP[
|
||||
normalized as keyof typeof LEGACY_FONT_WEIGHT_MAP
|
||||
];
|
||||
}
|
||||
|
||||
if (VALID_NUMERIC_FONT_WEIGHTS.has(normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
export { getProjectId } from "./utils";
|
||||
|
||||
function isV4Project({ project }: { project: ProjectRecord }): boolean {
|
||||
const versionValue = project.version;
|
||||
return typeof versionValue === "number" && versionValue >= 4;
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import type { MigrationResult, ProjectRecord } from "./types";
|
||||
import { getProjectId, isRecord } from "./utils";
|
||||
|
||||
const KNOWN_STICKER_PROVIDER_IDS = new Set([
|
||||
"icons",
|
||||
"emoji",
|
||||
"flags",
|
||||
"shapes",
|
||||
]);
|
||||
|
||||
export function transformProjectV4ToV5({
|
||||
project,
|
||||
}: {
|
||||
project: ProjectRecord;
|
||||
}): MigrationResult<ProjectRecord> {
|
||||
const projectId = getProjectId({ project });
|
||||
if (!projectId) {
|
||||
return { project, skipped: true, reason: "no project id" };
|
||||
}
|
||||
|
||||
if (isV5Project({ project })) {
|
||||
return { project, skipped: true, reason: "already v5" };
|
||||
}
|
||||
|
||||
const migratedProject = migrateProjectStickerElements({ project });
|
||||
|
||||
return {
|
||||
project: {
|
||||
...migratedProject,
|
||||
version: 5,
|
||||
},
|
||||
skipped: false,
|
||||
};
|
||||
}
|
||||
|
||||
function migrateProjectStickerElements({
|
||||
project,
|
||||
}: {
|
||||
project: ProjectRecord;
|
||||
}): ProjectRecord {
|
||||
const scenesValue = project.scenes;
|
||||
if (!Array.isArray(scenesValue)) {
|
||||
return project;
|
||||
}
|
||||
|
||||
let hasSceneChanges = false;
|
||||
const migratedScenes = scenesValue.map((scene) => {
|
||||
const migratedScene = migrateSceneStickerElements({ scene });
|
||||
if (migratedScene !== scene) {
|
||||
hasSceneChanges = true;
|
||||
}
|
||||
return migratedScene;
|
||||
});
|
||||
|
||||
if (!hasSceneChanges) {
|
||||
return project;
|
||||
}
|
||||
|
||||
return {
|
||||
...project,
|
||||
scenes: migratedScenes,
|
||||
};
|
||||
}
|
||||
|
||||
function migrateSceneStickerElements({ scene }: { scene: unknown }): unknown {
|
||||
if (!isRecord(scene)) {
|
||||
return scene;
|
||||
}
|
||||
|
||||
const tracksValue = scene.tracks;
|
||||
if (!Array.isArray(tracksValue)) {
|
||||
return scene;
|
||||
}
|
||||
|
||||
let hasTrackChanges = false;
|
||||
const migratedTracks = tracksValue.map((track) => {
|
||||
const migratedTrack = migrateTrackStickerElements({ track });
|
||||
if (migratedTrack !== track) {
|
||||
hasTrackChanges = true;
|
||||
}
|
||||
return migratedTrack;
|
||||
});
|
||||
|
||||
if (!hasTrackChanges) {
|
||||
return scene;
|
||||
}
|
||||
|
||||
return {
|
||||
...scene,
|
||||
tracks: migratedTracks,
|
||||
};
|
||||
}
|
||||
|
||||
function migrateTrackStickerElements({ track }: { track: unknown }): unknown {
|
||||
if (!isRecord(track)) {
|
||||
return track;
|
||||
}
|
||||
|
||||
const elementsValue = track.elements;
|
||||
if (!Array.isArray(elementsValue)) {
|
||||
return track;
|
||||
}
|
||||
|
||||
let hasElementChanges = false;
|
||||
const migratedElements = elementsValue.map((element) => {
|
||||
const migratedElement = migrateStickerElement({ element });
|
||||
if (migratedElement !== element) {
|
||||
hasElementChanges = true;
|
||||
}
|
||||
return migratedElement;
|
||||
});
|
||||
|
||||
if (!hasElementChanges) {
|
||||
return track;
|
||||
}
|
||||
|
||||
return {
|
||||
...track,
|
||||
elements: migratedElements,
|
||||
};
|
||||
}
|
||||
|
||||
function migrateStickerElement({ element }: { element: unknown }): unknown {
|
||||
if (!isRecord(element) || element.type !== "sticker") {
|
||||
return element;
|
||||
}
|
||||
|
||||
const existingStickerId =
|
||||
typeof element.stickerId === "string" ? element.stickerId : null;
|
||||
const legacyIconName =
|
||||
typeof element.iconName === "string" ? element.iconName : null;
|
||||
|
||||
const normalizedStickerId =
|
||||
normalizeStickerId({
|
||||
value: existingStickerId ?? legacyIconName,
|
||||
}) ?? null;
|
||||
|
||||
const hasLegacyIconName = "iconName" in element;
|
||||
const hasLegacyColor = "color" in element;
|
||||
const shouldUpdateStickerId = normalizedStickerId !== existingStickerId;
|
||||
|
||||
if (!hasLegacyIconName && !hasLegacyColor && !shouldUpdateStickerId) {
|
||||
return element;
|
||||
}
|
||||
|
||||
const {
|
||||
iconName: _legacyIconName,
|
||||
color: _legacyColor,
|
||||
...remaining
|
||||
} = element;
|
||||
return normalizedStickerId
|
||||
? { ...remaining, stickerId: normalizedStickerId }
|
||||
: remaining;
|
||||
}
|
||||
|
||||
function normalizeStickerId({ value }: { value: unknown }): string | null {
|
||||
if (typeof value !== "string" || value.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const separatorIndex = value.indexOf(":");
|
||||
if (separatorIndex === -1) {
|
||||
return `icons:${value}`;
|
||||
}
|
||||
|
||||
const maybeProvider = value.slice(0, separatorIndex);
|
||||
if (KNOWN_STICKER_PROVIDER_IDS.has(maybeProvider)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return `icons:${value}`;
|
||||
}
|
||||
|
||||
function isV5Project({ project }: { project: ProjectRecord }): boolean {
|
||||
const versionValue = project.version;
|
||||
return typeof versionValue === "number" && versionValue >= 5;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { MigrationResult, ProjectRecord } from "./types";
|
||||
import { getProjectId, isRecord } from "./utils";
|
||||
|
||||
export function transformProjectV5ToV6({
|
||||
project,
|
||||
}: {
|
||||
project: ProjectRecord;
|
||||
}): MigrationResult<ProjectRecord> {
|
||||
const projectId = getProjectId({ project });
|
||||
if (!projectId) {
|
||||
return { project, skipped: true, reason: "no project id" };
|
||||
}
|
||||
|
||||
if (isV6Project({ project })) {
|
||||
return { project, skipped: true, reason: "already v6" };
|
||||
}
|
||||
|
||||
const migratedProject = migrateProjectBookmarks({ project });
|
||||
|
||||
return {
|
||||
project: {
|
||||
...migratedProject,
|
||||
version: 6,
|
||||
},
|
||||
skipped: false,
|
||||
};
|
||||
}
|
||||
|
||||
function migrateProjectBookmarks({
|
||||
project,
|
||||
}: {
|
||||
project: ProjectRecord;
|
||||
}): ProjectRecord {
|
||||
const scenesValue = project.scenes;
|
||||
if (!Array.isArray(scenesValue)) {
|
||||
return project;
|
||||
}
|
||||
|
||||
let hasSceneChanges = false;
|
||||
const migratedScenes = scenesValue.map((scene) => {
|
||||
const migratedScene = migrateSceneBookmarks({ scene });
|
||||
if (migratedScene !== scene) {
|
||||
hasSceneChanges = true;
|
||||
}
|
||||
return migratedScene;
|
||||
});
|
||||
|
||||
if (!hasSceneChanges) {
|
||||
return project;
|
||||
}
|
||||
|
||||
return {
|
||||
...project,
|
||||
scenes: migratedScenes,
|
||||
};
|
||||
}
|
||||
|
||||
function migrateSceneBookmarks({ scene }: { scene: unknown }): unknown {
|
||||
if (!isRecord(scene)) {
|
||||
return scene;
|
||||
}
|
||||
|
||||
const bookmarksValue = scene.bookmarks;
|
||||
if (!Array.isArray(bookmarksValue)) {
|
||||
return scene;
|
||||
}
|
||||
|
||||
const needsMigration = bookmarksValue.some(
|
||||
(bookmark) => typeof bookmark === "number",
|
||||
);
|
||||
if (!needsMigration) {
|
||||
return scene;
|
||||
}
|
||||
|
||||
const migratedBookmarks = bookmarksValue.map((bookmark) =>
|
||||
typeof bookmark === "number" ? { time: bookmark } : bookmark,
|
||||
);
|
||||
|
||||
return {
|
||||
...scene,
|
||||
bookmarks: migratedBookmarks,
|
||||
};
|
||||
}
|
||||
|
||||
function isV6Project({ project }: { project: ProjectRecord }): boolean {
|
||||
const versionValue = project.version;
|
||||
return typeof versionValue === "number" && versionValue >= 6;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { StorageMigration } from "./base";
|
||||
import type { ProjectRecord } from "./transformers/types";
|
||||
import { transformProjectV3ToV4 } from "./transformers/v3-to-v4";
|
||||
|
||||
export class V3toV4Migration extends StorageMigration {
|
||||
from = 3;
|
||||
to = 4;
|
||||
|
||||
async transform(project: ProjectRecord): Promise<{
|
||||
project: ProjectRecord;
|
||||
skipped: boolean;
|
||||
reason?: string;
|
||||
}> {
|
||||
return transformProjectV3ToV4({ project });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { StorageMigration } from "./base";
|
||||
import type { ProjectRecord } from "./transformers/types";
|
||||
import { transformProjectV4ToV5 } from "./transformers/v4-to-v5";
|
||||
|
||||
export class V4toV5Migration extends StorageMigration {
|
||||
from = 4;
|
||||
to = 5;
|
||||
|
||||
async transform(project: ProjectRecord): Promise<{
|
||||
project: ProjectRecord;
|
||||
skipped: boolean;
|
||||
reason?: string;
|
||||
}> {
|
||||
return transformProjectV4ToV5({ project });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { StorageMigration } from "./base";
|
||||
import type { ProjectRecord } from "./transformers/types";
|
||||
import { transformProjectV5ToV6 } from "./transformers/v5-to-v6";
|
||||
|
||||
export class V5toV6Migration extends StorageMigration {
|
||||
from = 5;
|
||||
to = 6;
|
||||
|
||||
async transform(project: ProjectRecord): Promise<{
|
||||
project: ProjectRecord;
|
||||
skipped: boolean;
|
||||
reason?: string;
|
||||
}> {
|
||||
return transformProjectV5ToV6({ project });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user