chore: normalize line endings

This commit is contained in:
Maze Winther
2026-03-29 15:56:48 +02:00
parent 82817cb50c
commit 4d127a038b
736 changed files with 73880 additions and 73782 deletions
@@ -1,10 +1,10 @@
/**
* Test fixtures for storage migrations.
* These represent real project data shapes from each version.
*/
export * from "./v0";
export * from "./v1";
export * from "./v2";
export * from "./v3";
export * from "./v5";
/**
* Test fixtures for storage migrations.
* These represent real project data shapes from each version.
*/
export * from "./v0";
export * from "./v1";
export * from "./v2";
export * from "./v3";
export * from "./v5";
@@ -1,54 +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",
},
],
};
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",
},
],
};
@@ -1,93 +1,93 @@
import { describe, expect, test } from "bun:test";
import { transformProjectV15ToV16 } from "../transformers/v15-to-v16";
describe("V15 to V16 Migration", () => {
test("renames sticker tracks to graphic tracks", () => {
const result = transformProjectV15ToV16({
project: {
id: "project-v15",
version: 15,
metadata: {
id: "project-v15",
name: "Project",
createdAt: "2024-01-01T00:00:00.000Z",
updatedAt: "2024-01-01T00: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-graphic",
type: "sticker",
name: "Sticker Track",
hidden: false,
elements: [
{
id: "sticker-1",
type: "sticker",
name: "Logo",
stickerId: "icons:mdi:home",
duration: 5,
startTime: 0,
trimStart: 0,
trimEnd: 0,
transform: {
scaleX: 1,
scaleY: 1,
position: { x: 0, y: 0 },
rotate: 0,
},
opacity: 1,
},
],
},
],
bookmarks: [],
createdAt: "2024-01-01T00:00:00.000Z",
updatedAt: "2024-01-01T00:00:00.000Z",
},
],
},
});
expect(result.skipped).toBe(false);
expect(result.project.version).toBe(16);
expect(
(result.project.scenes as Array<{ tracks: Array<{ type: string }> }>)[0]
.tracks[0].type,
).toBe("graphic");
});
test("skips projects already on v16", () => {
const result = transformProjectV15ToV16({
project: {
id: "project-v16",
version: 16,
},
});
expect(result.skipped).toBe(true);
expect(result.reason).toBe("already v16");
});
test("skips projects with no id", () => {
const result = transformProjectV15ToV16({
project: {
version: 15,
scenes: [],
},
});
expect(result.skipped).toBe(true);
expect(result.reason).toBe("no project id");
});
});
import { describe, expect, test } from "bun:test";
import { transformProjectV15ToV16 } from "../transformers/v15-to-v16";
describe("V15 to V16 Migration", () => {
test("renames sticker tracks to graphic tracks", () => {
const result = transformProjectV15ToV16({
project: {
id: "project-v15",
version: 15,
metadata: {
id: "project-v15",
name: "Project",
createdAt: "2024-01-01T00:00:00.000Z",
updatedAt: "2024-01-01T00: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-graphic",
type: "sticker",
name: "Sticker Track",
hidden: false,
elements: [
{
id: "sticker-1",
type: "sticker",
name: "Logo",
stickerId: "icons:mdi:home",
duration: 5,
startTime: 0,
trimStart: 0,
trimEnd: 0,
transform: {
scaleX: 1,
scaleY: 1,
position: { x: 0, y: 0 },
rotate: 0,
},
opacity: 1,
},
],
},
],
bookmarks: [],
createdAt: "2024-01-01T00:00:00.000Z",
updatedAt: "2024-01-01T00:00:00.000Z",
},
],
},
});
expect(result.skipped).toBe(false);
expect(result.project.version).toBe(16);
expect(
(result.project.scenes as Array<{ tracks: Array<{ type: string }> }>)[0]
.tracks[0].type,
).toBe("graphic");
});
test("skips projects already on v16", () => {
const result = transformProjectV15ToV16({
project: {
id: "project-v16",
version: 16,
},
});
expect(result.skipped).toBe(true);
expect(result.reason).toBe("already v16");
});
test("skips projects with no id", () => {
const result = transformProjectV15ToV16({
project: {
version: 15,
scenes: [],
},
});
expect(result.skipped).toBe(true);
expect(result.reason).toBe("no project id");
});
});
@@ -1,132 +1,132 @@
import { describe, expect, test } from "bun:test";
import { transformProjectV16ToV17 } from "../transformers/v16-to-v17";
describe("V16 to V17 Migration", () => {
test("adds center stroke alignment to masks that do not have it", () => {
const result = transformProjectV16ToV17({
project: {
id: "project-v16",
version: 16,
metadata: {
id: "project-v16",
name: "Project",
createdAt: "2024-01-01T00:00:00.000Z",
updatedAt: "2024-01-01T00: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-video",
type: "video",
name: "Video Track",
hidden: false,
elements: [
{
id: "video-1",
type: "video",
name: "Clip",
mediaId: "media-1",
duration: 5,
startTime: 0,
trimStart: 0,
trimEnd: 0,
transform: {
scaleX: 1,
scaleY: 1,
position: { x: 0, y: 0 },
rotate: 0,
},
opacity: 1,
masks: [
{
id: "mask-1",
type: "rectangle",
params: {
feather: 0,
inverted: false,
strokeColor: "#ffffff",
strokeWidth: 8,
centerX: 0,
centerY: 0,
width: 0.6,
height: 0.6,
rotation: 0,
scale: 1,
},
},
{
id: "mask-2",
type: "ellipse",
params: {
feather: 0,
inverted: false,
strokeColor: "#ffffff",
strokeWidth: 8,
strokeAlign: "outside",
centerX: 0,
centerY: 0,
width: 0.6,
height: 0.6,
rotation: 0,
scale: 1,
},
},
],
},
],
},
],
bookmarks: [],
createdAt: "2024-01-01T00:00:00.000Z",
updatedAt: "2024-01-01T00:00:00.000Z",
},
],
},
});
expect(result.skipped).toBe(false);
expect(result.project.version).toBe(17);
const migratedMasks = (
((result.project.scenes as Array<{ tracks: Array<{ elements: Array<{ masks: Array<{ params: Record<string, unknown> }> }> }> }>)[0]
.tracks[0].elements[0].masks)
);
expect(migratedMasks[0].params.strokeAlign).toBe("center");
expect(migratedMasks[1].params.strokeAlign).toBe("outside");
});
test("skips projects already on v17", () => {
const result = transformProjectV16ToV17({
project: {
id: "project-v17",
version: 17,
},
});
expect(result.skipped).toBe(true);
expect(result.reason).toBe("already v17");
});
test("skips projects with no id", () => {
const result = transformProjectV16ToV17({
project: {
version: 16,
scenes: [],
},
});
expect(result.skipped).toBe(true);
expect(result.reason).toBe("no project id");
});
});
import { describe, expect, test } from "bun:test";
import { transformProjectV16ToV17 } from "../transformers/v16-to-v17";
describe("V16 to V17 Migration", () => {
test("adds center stroke alignment to masks that do not have it", () => {
const result = transformProjectV16ToV17({
project: {
id: "project-v16",
version: 16,
metadata: {
id: "project-v16",
name: "Project",
createdAt: "2024-01-01T00:00:00.000Z",
updatedAt: "2024-01-01T00: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-video",
type: "video",
name: "Video Track",
hidden: false,
elements: [
{
id: "video-1",
type: "video",
name: "Clip",
mediaId: "media-1",
duration: 5,
startTime: 0,
trimStart: 0,
trimEnd: 0,
transform: {
scaleX: 1,
scaleY: 1,
position: { x: 0, y: 0 },
rotate: 0,
},
opacity: 1,
masks: [
{
id: "mask-1",
type: "rectangle",
params: {
feather: 0,
inverted: false,
strokeColor: "#ffffff",
strokeWidth: 8,
centerX: 0,
centerY: 0,
width: 0.6,
height: 0.6,
rotation: 0,
scale: 1,
},
},
{
id: "mask-2",
type: "ellipse",
params: {
feather: 0,
inverted: false,
strokeColor: "#ffffff",
strokeWidth: 8,
strokeAlign: "outside",
centerX: 0,
centerY: 0,
width: 0.6,
height: 0.6,
rotation: 0,
scale: 1,
},
},
],
},
],
},
],
bookmarks: [],
createdAt: "2024-01-01T00:00:00.000Z",
updatedAt: "2024-01-01T00:00:00.000Z",
},
],
},
});
expect(result.skipped).toBe(false);
expect(result.project.version).toBe(17);
const migratedMasks = (
((result.project.scenes as Array<{ tracks: Array<{ elements: Array<{ masks: Array<{ params: Record<string, unknown> }> }> }> }>)[0]
.tracks[0].elements[0].masks)
);
expect(migratedMasks[0].params.strokeAlign).toBe("center");
expect(migratedMasks[1].params.strokeAlign).toBe("outside");
});
test("skips projects already on v17", () => {
const result = transformProjectV16ToV17({
project: {
id: "project-v17",
version: 17,
},
});
expect(result.skipped).toBe(true);
expect(result.reason).toBe("already v17");
});
test("skips projects with no id", () => {
const result = transformProjectV16ToV17({
project: {
version: 16,
scenes: [],
},
});
expect(result.skipped).toBe(true);
expect(result.reason).toBe("no project id");
});
});
@@ -1,63 +1,63 @@
import { describe, expect, test } from "bun:test";
import { transformProjectV18ToV19 } from "../transformers/v18-to-v19";
describe("V18 to V19 Migration", () => {
test("adds canvas size mode and empty remembered custom size defaults", () => {
const result = transformProjectV18ToV19({
project: {
id: "project-v18-defaults",
version: 18,
metadata: {
id: "project-v18-defaults",
name: "Project",
createdAt: "2024-01-01T00:00:00.000Z",
updatedAt: "2024-01-01T00:00:00.000Z",
},
settings: {
fps: 30,
canvasSize: { width: 1920, height: 1080 },
originalCanvasSize: { width: 1920, height: 1080 },
background: { type: "color", color: "#000000" },
},
currentSceneId: "scene-main",
scenes: [],
},
});
expect(result.skipped).toBe(false);
expect(result.project.version).toBe(19);
expect(
(result.project.settings as Record<string, unknown>).canvasSizeMode,
).toBe("preset");
expect(
(result.project.settings as Record<string, unknown>).lastCustomCanvasSize,
).toBeNull();
expect(
(result.project.settings as Record<string, unknown>).originalCanvasSize,
).toEqual({ width: 1920, height: 1080 });
});
test("skips projects already on v19", () => {
const result = transformProjectV18ToV19({
project: {
id: "project-v19",
version: 19,
},
});
expect(result.skipped).toBe(true);
expect(result.reason).toBe("already v19");
});
test("skips projects with no id", () => {
const result = transformProjectV18ToV19({
project: {
version: 18,
scenes: [],
},
});
expect(result.skipped).toBe(true);
expect(result.reason).toBe("no project id");
});
});
import { describe, expect, test } from "bun:test";
import { transformProjectV18ToV19 } from "../transformers/v18-to-v19";
describe("V18 to V19 Migration", () => {
test("adds canvas size mode and empty remembered custom size defaults", () => {
const result = transformProjectV18ToV19({
project: {
id: "project-v18-defaults",
version: 18,
metadata: {
id: "project-v18-defaults",
name: "Project",
createdAt: "2024-01-01T00:00:00.000Z",
updatedAt: "2024-01-01T00:00:00.000Z",
},
settings: {
fps: 30,
canvasSize: { width: 1920, height: 1080 },
originalCanvasSize: { width: 1920, height: 1080 },
background: { type: "color", color: "#000000" },
},
currentSceneId: "scene-main",
scenes: [],
},
});
expect(result.skipped).toBe(false);
expect(result.project.version).toBe(19);
expect(
(result.project.settings as Record<string, unknown>).canvasSizeMode,
).toBe("preset");
expect(
(result.project.settings as Record<string, unknown>).lastCustomCanvasSize,
).toBeNull();
expect(
(result.project.settings as Record<string, unknown>).originalCanvasSize,
).toEqual({ width: 1920, height: 1080 });
});
test("skips projects already on v19", () => {
const result = transformProjectV18ToV19({
project: {
id: "project-v19",
version: 19,
},
});
expect(result.skipped).toBe(true);
expect(result.reason).toBe("already v19");
});
test("skips projects with no id", () => {
const result = transformProjectV18ToV19({
project: {
version: 18,
scenes: [],
},
});
expect(result.skipped).toBe(true);
expect(result.reason).toBe("no project id");
});
});
@@ -1,140 +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");
});
});
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");
});
});
@@ -1,137 +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");
});
});
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");
});
});
@@ -1,92 +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 },
]);
});
});
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 },
]);
});
});
@@ -1,175 +1,175 @@
import { describe, expect, test } from "bun:test";
import { transformProjectV8ToV9 } from "../transformers/v8-to-v9";
const v8ProjectWithText = {
id: "project-v8-text",
version: 8,
metadata: {
id: "project-v8-text",
name: "V8 Project with Text",
createdAt: "2024-01-01T00:00:00.000Z",
updatedAt: "2024-01-01T00: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-text",
type: "text",
name: "Text Track",
hidden: false,
elements: [
{
id: "el-1",
type: "text",
content: "With color",
startTime: 0,
duration: 5,
background: {
color: "#ff0000",
cornerRadius: 0,
paddingX: 8,
paddingY: 4,
},
},
{
id: "el-2",
type: "text",
content: "Transparent",
startTime: 5,
duration: 5,
background: {
color: "transparent",
paddingX: 30,
paddingY: 42,
},
},
],
},
],
bookmarks: [],
createdAt: "2024-01-01T00:00:00.000Z",
updatedAt: "2024-01-01T00:00:00.000Z",
},
],
} as Parameters<typeof transformProjectV8ToV9>[0]["project"];
describe("V8 to V9 Migration", () => {
test("adds background.enabled from color (transparent => false, otherwise true)", () => {
const result = transformProjectV8ToV9({ project: v8ProjectWithText });
expect(result.skipped).toBe(false);
expect(result.project.version).toBe(9);
const track = (
result.project.scenes as Array<{ tracks: Array<{ elements: unknown[] }> }>
)[0].tracks[0];
const elements = track.elements as Array<{ background: { enabled: boolean; color: string } }>;
expect(elements[0].background.enabled).toBe(true);
expect(elements[0].background.color).toBe("#ff0000");
expect(elements[1].background.enabled).toBe(false);
expect(elements[1].background.color).toBe("transparent");
});
test("preserves existing background.enabled if already present", () => {
const projectWithEnabled = {
...v8ProjectWithText,
scenes: [
{
...(v8ProjectWithText.scenes as Record<string, unknown>[])[0],
tracks: [
{
id: "track-text",
type: "text",
name: "Text Track",
hidden: false,
elements: [
{
id: "el-1",
type: "text",
content: "Already has enabled",
startTime: 0,
duration: 5,
background: {
enabled: false,
color: "#00ff00",
},
},
],
},
],
},
],
} as Parameters<typeof transformProjectV8ToV9>[0]["project"];
const result = transformProjectV8ToV9({ project: projectWithEnabled });
expect(result.skipped).toBe(false);
const elements = (
result.project.scenes as Array<{ tracks: Array<{ elements: unknown[] }> }>
)[0].tracks[0].elements as Array<{ background: { enabled: boolean } }>;
expect(elements[0].background.enabled).toBe(false);
});
test("skips non-text elements and tracks", () => {
const projectWithVideoOnly = {
...v8ProjectWithText,
scenes: [
{
id: "scene-main",
name: "Main scene",
isMain: true,
tracks: [
{
id: "track-video",
type: "video",
name: "Video Track",
isMain: true,
elements: [],
},
],
bookmarks: [],
createdAt: "2024-01-01T00:00:00.000Z",
updatedAt: "2024-01-01T00:00:00.000Z",
},
],
} as Parameters<typeof transformProjectV8ToV9>[0]["project"];
const result = transformProjectV8ToV9({ project: projectWithVideoOnly });
expect(result.skipped).toBe(false);
expect(result.project.version).toBe(9);
});
test("skips projects that are already v9", () => {
const result = transformProjectV8ToV9({
project: { ...v8ProjectWithText, version: 9 },
});
expect(result.skipped).toBe(true);
expect(result.reason).toBe("already v9");
});
test("skips projects with no id", () => {
const result = transformProjectV8ToV9({
project: {
version: 8,
scenes: [],
} as Parameters<typeof transformProjectV8ToV9>[0]["project"],
});
expect(result.skipped).toBe(true);
expect(result.reason).toBe("no project id");
});
});
import { describe, expect, test } from "bun:test";
import { transformProjectV8ToV9 } from "../transformers/v8-to-v9";
const v8ProjectWithText = {
id: "project-v8-text",
version: 8,
metadata: {
id: "project-v8-text",
name: "V8 Project with Text",
createdAt: "2024-01-01T00:00:00.000Z",
updatedAt: "2024-01-01T00: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-text",
type: "text",
name: "Text Track",
hidden: false,
elements: [
{
id: "el-1",
type: "text",
content: "With color",
startTime: 0,
duration: 5,
background: {
color: "#ff0000",
cornerRadius: 0,
paddingX: 8,
paddingY: 4,
},
},
{
id: "el-2",
type: "text",
content: "Transparent",
startTime: 5,
duration: 5,
background: {
color: "transparent",
paddingX: 30,
paddingY: 42,
},
},
],
},
],
bookmarks: [],
createdAt: "2024-01-01T00:00:00.000Z",
updatedAt: "2024-01-01T00:00:00.000Z",
},
],
} as Parameters<typeof transformProjectV8ToV9>[0]["project"];
describe("V8 to V9 Migration", () => {
test("adds background.enabled from color (transparent => false, otherwise true)", () => {
const result = transformProjectV8ToV9({ project: v8ProjectWithText });
expect(result.skipped).toBe(false);
expect(result.project.version).toBe(9);
const track = (
result.project.scenes as Array<{ tracks: Array<{ elements: unknown[] }> }>
)[0].tracks[0];
const elements = track.elements as Array<{ background: { enabled: boolean; color: string } }>;
expect(elements[0].background.enabled).toBe(true);
expect(elements[0].background.color).toBe("#ff0000");
expect(elements[1].background.enabled).toBe(false);
expect(elements[1].background.color).toBe("transparent");
});
test("preserves existing background.enabled if already present", () => {
const projectWithEnabled = {
...v8ProjectWithText,
scenes: [
{
...(v8ProjectWithText.scenes as Record<string, unknown>[])[0],
tracks: [
{
id: "track-text",
type: "text",
name: "Text Track",
hidden: false,
elements: [
{
id: "el-1",
type: "text",
content: "Already has enabled",
startTime: 0,
duration: 5,
background: {
enabled: false,
color: "#00ff00",
},
},
],
},
],
},
],
} as Parameters<typeof transformProjectV8ToV9>[0]["project"];
const result = transformProjectV8ToV9({ project: projectWithEnabled });
expect(result.skipped).toBe(false);
const elements = (
result.project.scenes as Array<{ tracks: Array<{ elements: unknown[] }> }>
)[0].tracks[0].elements as Array<{ background: { enabled: boolean } }>;
expect(elements[0].background.enabled).toBe(false);
});
test("skips non-text elements and tracks", () => {
const projectWithVideoOnly = {
...v8ProjectWithText,
scenes: [
{
id: "scene-main",
name: "Main scene",
isMain: true,
tracks: [
{
id: "track-video",
type: "video",
name: "Video Track",
isMain: true,
elements: [],
},
],
bookmarks: [],
createdAt: "2024-01-01T00:00:00.000Z",
updatedAt: "2024-01-01T00:00:00.000Z",
},
],
} as Parameters<typeof transformProjectV8ToV9>[0]["project"];
const result = transformProjectV8ToV9({ project: projectWithVideoOnly });
expect(result.skipped).toBe(false);
expect(result.project.version).toBe(9);
});
test("skips projects that are already v9", () => {
const result = transformProjectV8ToV9({
project: { ...v8ProjectWithText, version: 9 },
});
expect(result.skipped).toBe(true);
expect(result.reason).toBe("already v9");
});
test("skips projects with no id", () => {
const result = transformProjectV8ToV9({
project: {
version: 8,
scenes: [],
} as Parameters<typeof transformProjectV8ToV9>[0]["project"],
});
expect(result.skipped).toBe(true);
expect(result.reason).toBe("no project id");
});
});
@@ -1,9 +1,9 @@
import type { MigrationResult, ProjectRecord } from "./transformers/types";
export abstract class StorageMigration {
abstract from: number;
abstract to: number;
abstract transform(
project: ProjectRecord,
): Promise<MigrationResult<ProjectRecord>>;
}
import type { MigrationResult, ProjectRecord } from "./transformers/types";
export abstract class StorageMigration {
abstract from: number;
abstract to: number;
abstract transform(
project: ProjectRecord,
): Promise<MigrationResult<ProjectRecord>>;
}
@@ -1,46 +1,46 @@
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";
import { V6toV7Migration } from "./v6-to-v7";
import { V7toV8Migration } from "./v7-to-v8";
import { V8toV9Migration } from "./v8-to-v9";
import { V9toV10Migration } from "./v9-to-v10";
import { V10toV11Migration } from "./v10-to-v11";
import { V11toV12Migration } from "./v11-to-v12";
import { V12toV13Migration } from "./v12-to-v13";
import { V13toV14Migration } from "./v13-to-v14";
import { V14toV15Migration } from "./v14-to-v15";
import { V15toV16Migration } from "./v15-to-v16";
import { V16toV17Migration } from "./v16-to-v17";
import { V17toV18Migration } from "./v17-to-v18";
import { V18toV19Migration } from "./v18-to-v19";
export { runStorageMigrations } from "./runner";
export type { MigrationProgress } from "./runner";
export const CURRENT_PROJECT_VERSION = 19;
export const migrations = [
new V0toV1Migration(),
new V1toV2Migration(),
new V2toV3Migration(),
new V3toV4Migration(),
new V4toV5Migration(),
new V5toV6Migration(),
new V6toV7Migration(),
new V7toV8Migration(),
new V8toV9Migration(),
new V9toV10Migration(),
new V10toV11Migration(),
new V11toV12Migration(),
new V12toV13Migration(),
new V13toV14Migration(),
new V14toV15Migration(),
new V15toV16Migration(),
new V16toV17Migration(),
new V17toV18Migration(),
new V18toV19Migration(),
];
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";
import { V6toV7Migration } from "./v6-to-v7";
import { V7toV8Migration } from "./v7-to-v8";
import { V8toV9Migration } from "./v8-to-v9";
import { V9toV10Migration } from "./v9-to-v10";
import { V10toV11Migration } from "./v10-to-v11";
import { V11toV12Migration } from "./v11-to-v12";
import { V12toV13Migration } from "./v12-to-v13";
import { V13toV14Migration } from "./v13-to-v14";
import { V14toV15Migration } from "./v14-to-v15";
import { V15toV16Migration } from "./v15-to-v16";
import { V16toV17Migration } from "./v16-to-v17";
import { V17toV18Migration } from "./v17-to-v18";
import { V18toV19Migration } from "./v18-to-v19";
export { runStorageMigrations } from "./runner";
export type { MigrationProgress } from "./runner";
export const CURRENT_PROJECT_VERSION = 19;
export const migrations = [
new V0toV1Migration(),
new V1toV2Migration(),
new V2toV3Migration(),
new V3toV4Migration(),
new V4toV5Migration(),
new V5toV6Migration(),
new V6toV7Migration(),
new V7toV8Migration(),
new V8toV9Migration(),
new V9toV10Migration(),
new V10toV11Migration(),
new V11toV12Migration(),
new V12toV13Migration(),
new V13toV14Migration(),
new V14toV15Migration(),
new V15toV16Migration(),
new V16toV17Migration(),
new V17toV18Migration(),
new V18toV19Migration(),
];
+155 -155
View File
@@ -1,155 +1,155 @@
import {
IndexedDBAdapter,
deleteDatabase,
} from "@/services/storage/indexeddb-adapter";
import type { StorageMigration } from "./base";
import type { ProjectRecord } from "./transformers/types";
import { getProjectId, isRecord } from "./transformers/utils";
export interface StorageMigrationResult {
migratedCount: number;
}
export interface MigrationProgress {
isMigrating: boolean;
fromVersion: number | null;
toVersion: number | null;
projectName: string | null;
}
let hasCleanedUpMetaDb = false;
const MIN_MIGRATION_DISPLAY_MS = 1000;
export async function runStorageMigrations({
migrations,
onProgress,
}: {
migrations: StorageMigration[];
onProgress?: (progress: MigrationProgress) => void;
}): Promise<StorageMigrationResult> {
// One-time cleanup: delete the old global version database
if (!hasCleanedUpMetaDb) {
try {
await deleteDatabase({ dbName: "video-editor-meta" });
} catch {
// Ignore errors - DB might not exist
}
hasCleanedUpMetaDb = true;
}
const projectsAdapter = new IndexedDBAdapter<ProjectRecord>(
"video-editor-projects",
"projects",
1,
);
const projects = await projectsAdapter.getAll();
const orderedMigrations = [...migrations].sort((a, b) => a.from - b.from);
let migratedCount = 0;
let migrationStartTime: number | null = null;
for (const project of projects) {
if (typeof project !== "object" || project === null) {
continue;
}
let projectRecord = project as ProjectRecord;
let currentVersion = getProjectVersion({ project: projectRecord });
const targetVersion = orderedMigrations.at(-1)?.to ?? currentVersion;
if (currentVersion >= targetVersion) {
continue;
}
// Track when we first showed the migration dialog
if (migrationStartTime === null) {
migrationStartTime = Date.now();
}
const projectName = getProjectName({ project: projectRecord });
onProgress?.({
isMigrating: true,
fromVersion: currentVersion,
toVersion: targetVersion,
projectName,
});
for (const migration of orderedMigrations) {
if (migration.from !== currentVersion) {
continue;
}
const result = await migration.transform(projectRecord);
if (result.skipped) {
break;
}
const projectId = getProjectId({ project: result.project });
if (!projectId) {
break;
}
await projectsAdapter.set(projectId, result.project);
migratedCount++;
currentVersion = migration.to;
projectRecord = result.project;
}
}
// Ensure dialog is visible for minimum time so users can see it
if (migrationStartTime !== null) {
const elapsed = Date.now() - migrationStartTime;
if (elapsed < MIN_MIGRATION_DISPLAY_MS) {
await new Promise((resolve) =>
setTimeout(resolve, MIN_MIGRATION_DISPLAY_MS - elapsed),
);
}
}
onProgress?.({
isMigrating: false,
fromVersion: null,
toVersion: null,
projectName: null,
});
return { migratedCount };
}
function getProjectVersion({ project }: { project: ProjectRecord }): number {
const versionValue = project.version;
// v2 and up - has explicit version field
if (typeof versionValue === "number") {
return versionValue;
}
// v1 - has scenes array
const scenesValue = project.scenes;
if (Array.isArray(scenesValue) && scenesValue.length > 0) {
return 1;
}
// v0 - no scenes
return 0;
}
function getProjectName({
project,
}: {
project: ProjectRecord;
}): string | null {
const metadata = project.metadata;
if (isRecord(metadata) && typeof metadata.name === "string") {
return metadata.name;
}
// v0 had name directly on project
if (typeof project.name === "string") {
return project.name;
}
return null;
}
import {
IndexedDBAdapter,
deleteDatabase,
} from "@/services/storage/indexeddb-adapter";
import type { StorageMigration } from "./base";
import type { ProjectRecord } from "./transformers/types";
import { getProjectId, isRecord } from "./transformers/utils";
export interface StorageMigrationResult {
migratedCount: number;
}
export interface MigrationProgress {
isMigrating: boolean;
fromVersion: number | null;
toVersion: number | null;
projectName: string | null;
}
let hasCleanedUpMetaDb = false;
const MIN_MIGRATION_DISPLAY_MS = 1000;
export async function runStorageMigrations({
migrations,
onProgress,
}: {
migrations: StorageMigration[];
onProgress?: (progress: MigrationProgress) => void;
}): Promise<StorageMigrationResult> {
// One-time cleanup: delete the old global version database
if (!hasCleanedUpMetaDb) {
try {
await deleteDatabase({ dbName: "video-editor-meta" });
} catch {
// Ignore errors - DB might not exist
}
hasCleanedUpMetaDb = true;
}
const projectsAdapter = new IndexedDBAdapter<ProjectRecord>(
"video-editor-projects",
"projects",
1,
);
const projects = await projectsAdapter.getAll();
const orderedMigrations = [...migrations].sort((a, b) => a.from - b.from);
let migratedCount = 0;
let migrationStartTime: number | null = null;
for (const project of projects) {
if (typeof project !== "object" || project === null) {
continue;
}
let projectRecord = project as ProjectRecord;
let currentVersion = getProjectVersion({ project: projectRecord });
const targetVersion = orderedMigrations.at(-1)?.to ?? currentVersion;
if (currentVersion >= targetVersion) {
continue;
}
// Track when we first showed the migration dialog
if (migrationStartTime === null) {
migrationStartTime = Date.now();
}
const projectName = getProjectName({ project: projectRecord });
onProgress?.({
isMigrating: true,
fromVersion: currentVersion,
toVersion: targetVersion,
projectName,
});
for (const migration of orderedMigrations) {
if (migration.from !== currentVersion) {
continue;
}
const result = await migration.transform(projectRecord);
if (result.skipped) {
break;
}
const projectId = getProjectId({ project: result.project });
if (!projectId) {
break;
}
await projectsAdapter.set(projectId, result.project);
migratedCount++;
currentVersion = migration.to;
projectRecord = result.project;
}
}
// Ensure dialog is visible for minimum time so users can see it
if (migrationStartTime !== null) {
const elapsed = Date.now() - migrationStartTime;
if (elapsed < MIN_MIGRATION_DISPLAY_MS) {
await new Promise((resolve) =>
setTimeout(resolve, MIN_MIGRATION_DISPLAY_MS - elapsed),
);
}
}
onProgress?.({
isMigrating: false,
fromVersion: null,
toVersion: null,
projectName: null,
});
return { migratedCount };
}
function getProjectVersion({ project }: { project: ProjectRecord }): number {
const versionValue = project.version;
// v2 and up - has explicit version field
if (typeof versionValue === "number") {
return versionValue;
}
// v1 - has scenes array
const scenesValue = project.scenes;
if (Array.isArray(scenesValue) && scenesValue.length > 0) {
return 1;
}
// v0 - no scenes
return 0;
}
function getProjectName({
project,
}: {
project: ProjectRecord;
}): string | null {
const metadata = project.metadata;
if (isRecord(metadata) && typeof metadata.name === "string") {
return metadata.name;
}
// v0 had name directly on project
if (typeof project.name === "string") {
return project.name;
}
return null;
}
@@ -1,113 +1,113 @@
import type { MigrationResult, ProjectRecord } from "./types";
import { getProjectId, isRecord } from "./utils";
export function transformProjectV10ToV11({
project,
}: {
project: ProjectRecord;
}): MigrationResult<ProjectRecord> {
if (!getProjectId({ project })) {
return { project, skipped: true, reason: "no project id" };
}
if (typeof project.version === "number" && project.version >= 11) {
return { project, skipped: true, reason: "already v11" };
}
const migratedProject = migrateProjectScale({ project });
return {
project: { ...migratedProject, version: 11 },
skipped: false,
};
}
function migrateProjectScale({
project,
}: {
project: ProjectRecord;
}): ProjectRecord {
const scenesValue = project.scenes;
if (!Array.isArray(scenesValue)) return project;
const migratedScenes = scenesValue.map((scene) =>
migrateSceneScale({ scene }),
);
return { ...project, scenes: migratedScenes };
}
function migrateSceneScale({ scene }: { scene: unknown }): unknown {
if (!isRecord(scene)) return scene;
const tracksValue = scene.tracks;
if (!Array.isArray(tracksValue)) return scene;
const migratedTracks = tracksValue.map((track) =>
migrateTrackScale({ track }),
);
return { ...scene, tracks: migratedTracks };
}
function migrateTrackScale({ track }: { track: unknown }): unknown {
if (!isRecord(track)) return track;
const elementsValue = track.elements;
if (!Array.isArray(elementsValue)) return track;
const migratedElements = elementsValue.map((element) =>
migrateElementScale({ element }),
);
return { ...track, elements: migratedElements };
}
function migrateElementScale({ element }: { element: unknown }): unknown {
if (!isRecord(element)) return element;
const transform = element.transform;
if (!isRecord(transform)) return element;
const scale = transform.scale;
if (typeof scale !== "number") return element;
const migratedTransform = {
...transform,
scaleX: scale,
scaleY: scale,
};
delete (migratedTransform as Record<string, unknown>).scale;
let migratedElement: ProjectRecord = {
...element,
transform: migratedTransform,
};
const animations = element.animations;
if (isRecord(animations) && isRecord(animations.channels)) {
const channels = animations.channels as Record<string, unknown>;
const scaleChannel = channels["transform.scale"];
if (scaleChannel && isRecord(scaleChannel)) {
const keyframes = (scaleChannel as { keyframes?: unknown[] }).keyframes;
if (Array.isArray(keyframes)) {
const newChannels = { ...channels };
delete newChannels["transform.scale"];
newChannels["transform.scaleX"] = {
...scaleChannel,
keyframes: [...keyframes],
};
newChannels["transform.scaleY"] = {
...scaleChannel,
keyframes: keyframes.map((kf) => (isRecord(kf) ? { ...kf } : kf)),
};
migratedElement = {
...migratedElement,
animations: { ...animations, channels: newChannels },
};
}
}
}
return migratedElement;
}
import type { MigrationResult, ProjectRecord } from "./types";
import { getProjectId, isRecord } from "./utils";
export function transformProjectV10ToV11({
project,
}: {
project: ProjectRecord;
}): MigrationResult<ProjectRecord> {
if (!getProjectId({ project })) {
return { project, skipped: true, reason: "no project id" };
}
if (typeof project.version === "number" && project.version >= 11) {
return { project, skipped: true, reason: "already v11" };
}
const migratedProject = migrateProjectScale({ project });
return {
project: { ...migratedProject, version: 11 },
skipped: false,
};
}
function migrateProjectScale({
project,
}: {
project: ProjectRecord;
}): ProjectRecord {
const scenesValue = project.scenes;
if (!Array.isArray(scenesValue)) return project;
const migratedScenes = scenesValue.map((scene) =>
migrateSceneScale({ scene }),
);
return { ...project, scenes: migratedScenes };
}
function migrateSceneScale({ scene }: { scene: unknown }): unknown {
if (!isRecord(scene)) return scene;
const tracksValue = scene.tracks;
if (!Array.isArray(tracksValue)) return scene;
const migratedTracks = tracksValue.map((track) =>
migrateTrackScale({ track }),
);
return { ...scene, tracks: migratedTracks };
}
function migrateTrackScale({ track }: { track: unknown }): unknown {
if (!isRecord(track)) return track;
const elementsValue = track.elements;
if (!Array.isArray(elementsValue)) return track;
const migratedElements = elementsValue.map((element) =>
migrateElementScale({ element }),
);
return { ...track, elements: migratedElements };
}
function migrateElementScale({ element }: { element: unknown }): unknown {
if (!isRecord(element)) return element;
const transform = element.transform;
if (!isRecord(transform)) return element;
const scale = transform.scale;
if (typeof scale !== "number") return element;
const migratedTransform = {
...transform,
scaleX: scale,
scaleY: scale,
};
delete (migratedTransform as Record<string, unknown>).scale;
let migratedElement: ProjectRecord = {
...element,
transform: migratedTransform,
};
const animations = element.animations;
if (isRecord(animations) && isRecord(animations.channels)) {
const channels = animations.channels as Record<string, unknown>;
const scaleChannel = channels["transform.scale"];
if (scaleChannel && isRecord(scaleChannel)) {
const keyframes = (scaleChannel as { keyframes?: unknown[] }).keyframes;
if (Array.isArray(keyframes)) {
const newChannels = { ...channels };
delete newChannels["transform.scale"];
newChannels["transform.scaleX"] = {
...scaleChannel,
keyframes: [...keyframes],
};
newChannels["transform.scaleY"] = {
...scaleChannel,
keyframes: keyframes.map((kf) => (isRecord(kf) ? { ...kf } : kf)),
};
migratedElement = {
...migratedElement,
animations: { ...animations, channels: newChannels },
};
}
}
}
return migratedElement;
}
@@ -1,196 +1,196 @@
import type { MigrationResult, ProjectRecord } from "./types";
import { getProjectId, isRecord } from "./utils";
export function transformProjectV11ToV12({
project,
}: {
project: ProjectRecord;
}): MigrationResult<ProjectRecord> {
if (!getProjectId({ project })) {
return { project, skipped: true, reason: "no project id" };
}
if (typeof project.version === "number" && project.version >= 12) {
return { project, skipped: true, reason: "already v12" };
}
const migratedProject = migrateProjectPosition({ project });
return {
project: { ...migratedProject, version: 12 },
skipped: false,
};
}
function migrateProjectPosition({
project,
}: {
project: ProjectRecord;
}): ProjectRecord {
const scenesValue = project.scenes;
if (!Array.isArray(scenesValue)) return project;
const migratedScenes = scenesValue.map((scene) =>
migrateScenePosition({ scene }),
);
return { ...project, scenes: migratedScenes };
}
function migrateScenePosition({ scene }: { scene: unknown }): unknown {
if (!isRecord(scene)) return scene;
const tracksValue = scene.tracks;
if (!Array.isArray(tracksValue)) return scene;
const migratedTracks = tracksValue.map((track) =>
migrateTrackPosition({ track }),
);
return { ...scene, tracks: migratedTracks };
}
function migrateTrackPosition({ track }: { track: unknown }): unknown {
if (!isRecord(track)) return track;
const elementsValue = track.elements;
if (!Array.isArray(elementsValue)) return track;
const migratedElements = elementsValue.map((element) =>
migrateElementPosition({ element }),
);
return { ...track, elements: migratedElements };
}
function migrateElementPosition({ element }: { element: unknown }): unknown {
if (!isRecord(element)) return element;
const animations = element.animations;
if (!isRecord(animations) || !isRecord(animations.channels)) return element;
const channels = animations.channels as Record<string, unknown>;
const xChannel = channels["transform.position.x"];
const yChannel = channels["transform.position.y"];
if (!xChannel && !yChannel) return element;
const baseTransform = isRecord(element.transform) ? element.transform : {};
const basePosition = isRecord(baseTransform.position)
? baseTransform.position
: {};
const baseX = typeof basePosition.x === "number" ? basePosition.x : 0;
const baseY = typeof basePosition.y === "number" ? basePosition.y : 0;
const xKeyframes = getKeyframes({ channel: xChannel });
const yKeyframes = getKeyframes({ channel: yChannel });
const allTimes = Array.from(
new Set([
...xKeyframes.map((kf) => kf.time),
...yKeyframes.map((kf) => kf.time),
]),
).sort((a, b) => a - b);
const vectorKeyframes = allTimes.map((time) => {
const xKf = xKeyframes.find((kf) => Math.abs(kf.time - time) < 0.001);
const yKf = yKeyframes.find((kf) => Math.abs(kf.time - time) < 0.001);
const x =
typeof xKf?.value === "number"
? xKf.value
: interpolateScalarAtTime({
keyframes: xKeyframes,
time,
fallback: baseX,
});
const y =
typeof yKf?.value === "number"
? yKf.value
: interpolateScalarAtTime({
keyframes: yKeyframes,
time,
fallback: baseY,
});
const interpolation = xKf?.interpolation ?? yKf?.interpolation ?? "linear";
return {
id: xKf?.id ?? yKf?.id ?? crypto.randomUUID(),
time,
value: { x, y },
interpolation,
};
});
const newChannels = { ...channels };
delete newChannels["transform.position.x"];
delete newChannels["transform.position.y"];
newChannels["transform.position"] = {
valueKind: "vector",
keyframes: vectorKeyframes,
};
return {
...element,
animations: { ...animations, channels: newChannels },
};
}
interface ScalarKeyframe {
id: string;
time: number;
value: number;
interpolation: string;
}
function getKeyframes({ channel }: { channel: unknown }): ScalarKeyframe[] {
if (!isRecord(channel)) return [];
if (!Array.isArray(channel.keyframes)) return [];
return channel.keyframes.flatMap((kf) => {
if (!isRecord(kf)) return [];
if (
typeof kf.id !== "string" ||
typeof kf.time !== "number" ||
typeof kf.value !== "number"
)
return [];
return [
{
id: kf.id,
time: kf.time,
value: kf.value,
interpolation:
typeof kf.interpolation === "string" ? kf.interpolation : "linear",
},
];
});
}
function interpolateScalarAtTime({
keyframes,
time,
fallback,
}: {
keyframes: ScalarKeyframe[];
time: number;
fallback: number;
}): number {
if (keyframes.length === 0) return fallback;
const sorted = [...keyframes].sort((a, b) => a.time - b.time);
const first = sorted[0];
const last = sorted[sorted.length - 1];
if (!first || !last) return fallback;
if (time <= first.time) return first.value;
if (time >= last.time) return last.value;
for (let i = 0; i < sorted.length - 1; i++) {
const left = sorted[i];
const right = sorted[i + 1];
if (time < left.time || time > right.time) continue;
if (left.interpolation === "hold") return left.value;
const t = (time - left.time) / (right.time - left.time);
return left.value + (right.value - left.value) * t;
}
return last.value;
}
import type { MigrationResult, ProjectRecord } from "./types";
import { getProjectId, isRecord } from "./utils";
export function transformProjectV11ToV12({
project,
}: {
project: ProjectRecord;
}): MigrationResult<ProjectRecord> {
if (!getProjectId({ project })) {
return { project, skipped: true, reason: "no project id" };
}
if (typeof project.version === "number" && project.version >= 12) {
return { project, skipped: true, reason: "already v12" };
}
const migratedProject = migrateProjectPosition({ project });
return {
project: { ...migratedProject, version: 12 },
skipped: false,
};
}
function migrateProjectPosition({
project,
}: {
project: ProjectRecord;
}): ProjectRecord {
const scenesValue = project.scenes;
if (!Array.isArray(scenesValue)) return project;
const migratedScenes = scenesValue.map((scene) =>
migrateScenePosition({ scene }),
);
return { ...project, scenes: migratedScenes };
}
function migrateScenePosition({ scene }: { scene: unknown }): unknown {
if (!isRecord(scene)) return scene;
const tracksValue = scene.tracks;
if (!Array.isArray(tracksValue)) return scene;
const migratedTracks = tracksValue.map((track) =>
migrateTrackPosition({ track }),
);
return { ...scene, tracks: migratedTracks };
}
function migrateTrackPosition({ track }: { track: unknown }): unknown {
if (!isRecord(track)) return track;
const elementsValue = track.elements;
if (!Array.isArray(elementsValue)) return track;
const migratedElements = elementsValue.map((element) =>
migrateElementPosition({ element }),
);
return { ...track, elements: migratedElements };
}
function migrateElementPosition({ element }: { element: unknown }): unknown {
if (!isRecord(element)) return element;
const animations = element.animations;
if (!isRecord(animations) || !isRecord(animations.channels)) return element;
const channels = animations.channels as Record<string, unknown>;
const xChannel = channels["transform.position.x"];
const yChannel = channels["transform.position.y"];
if (!xChannel && !yChannel) return element;
const baseTransform = isRecord(element.transform) ? element.transform : {};
const basePosition = isRecord(baseTransform.position)
? baseTransform.position
: {};
const baseX = typeof basePosition.x === "number" ? basePosition.x : 0;
const baseY = typeof basePosition.y === "number" ? basePosition.y : 0;
const xKeyframes = getKeyframes({ channel: xChannel });
const yKeyframes = getKeyframes({ channel: yChannel });
const allTimes = Array.from(
new Set([
...xKeyframes.map((kf) => kf.time),
...yKeyframes.map((kf) => kf.time),
]),
).sort((a, b) => a - b);
const vectorKeyframes = allTimes.map((time) => {
const xKf = xKeyframes.find((kf) => Math.abs(kf.time - time) < 0.001);
const yKf = yKeyframes.find((kf) => Math.abs(kf.time - time) < 0.001);
const x =
typeof xKf?.value === "number"
? xKf.value
: interpolateScalarAtTime({
keyframes: xKeyframes,
time,
fallback: baseX,
});
const y =
typeof yKf?.value === "number"
? yKf.value
: interpolateScalarAtTime({
keyframes: yKeyframes,
time,
fallback: baseY,
});
const interpolation = xKf?.interpolation ?? yKf?.interpolation ?? "linear";
return {
id: xKf?.id ?? yKf?.id ?? crypto.randomUUID(),
time,
value: { x, y },
interpolation,
};
});
const newChannels = { ...channels };
delete newChannels["transform.position.x"];
delete newChannels["transform.position.y"];
newChannels["transform.position"] = {
valueKind: "vector",
keyframes: vectorKeyframes,
};
return {
...element,
animations: { ...animations, channels: newChannels },
};
}
interface ScalarKeyframe {
id: string;
time: number;
value: number;
interpolation: string;
}
function getKeyframes({ channel }: { channel: unknown }): ScalarKeyframe[] {
if (!isRecord(channel)) return [];
if (!Array.isArray(channel.keyframes)) return [];
return channel.keyframes.flatMap((kf) => {
if (!isRecord(kf)) return [];
if (
typeof kf.id !== "string" ||
typeof kf.time !== "number" ||
typeof kf.value !== "number"
)
return [];
return [
{
id: kf.id,
time: kf.time,
value: kf.value,
interpolation:
typeof kf.interpolation === "string" ? kf.interpolation : "linear",
},
];
});
}
function interpolateScalarAtTime({
keyframes,
time,
fallback,
}: {
keyframes: ScalarKeyframe[];
time: number;
fallback: number;
}): number {
if (keyframes.length === 0) return fallback;
const sorted = [...keyframes].sort((a, b) => a.time - b.time);
const first = sorted[0];
const last = sorted[sorted.length - 1];
if (!first || !last) return fallback;
if (time <= first.time) return first.value;
if (time >= last.time) return last.value;
for (let i = 0; i < sorted.length - 1; i++) {
const left = sorted[i];
const right = sorted[i + 1];
if (time < left.time || time > right.time) continue;
if (left.interpolation === "hold") return left.value;
const t = (time - left.time) / (right.time - left.time);
return left.value + (right.value - left.value) * t;
}
return last.value;
}
@@ -1,102 +1,102 @@
import type { MigrationResult, ProjectRecord } from "./types";
import { getProjectId, isRecord } from "./utils";
export function transformProjectV12ToV13({
project,
}: {
project: ProjectRecord;
}): MigrationResult<ProjectRecord> {
if (!getProjectId({ project })) {
return { project, skipped: true, reason: "no project id" };
}
if (typeof project.version === "number" && project.version >= 13) {
return { project, skipped: true, reason: "already v13" };
}
const migratedProject = migrateProjectMasks({ project });
return {
project: { ...migratedProject, version: 13 },
skipped: false,
};
}
function migrateProjectMasks({
project,
}: {
project: ProjectRecord;
}): ProjectRecord {
const scenesValue = project.scenes;
if (!Array.isArray(scenesValue)) return project;
const migratedScenes = scenesValue.map((scene) =>
migrateSceneMasks({ scene }),
);
return { ...project, scenes: migratedScenes };
}
function migrateSceneMasks({ scene }: { scene: unknown }): unknown {
if (!isRecord(scene)) return scene;
const tracksValue = scene.tracks;
if (!Array.isArray(tracksValue)) return scene;
const migratedTracks = tracksValue.map((track) =>
migrateTrackMasks({ track }),
);
return { ...scene, tracks: migratedTracks };
}
function migrateTrackMasks({ track }: { track: unknown }): unknown {
if (!isRecord(track)) return track;
const elementsValue = track.elements;
if (!Array.isArray(elementsValue)) return track;
const migratedElements = elementsValue.map((element) =>
migrateElementMasks({ element }),
);
return { ...track, elements: migratedElements };
}
function migrateElementMasks({ element }: { element: unknown }): unknown {
if (!isRecord(element)) return element;
const masksValue = element.masks;
if (!Array.isArray(masksValue)) return element;
const migratedMasks = masksValue.map((mask) => migrateMask({ mask }));
return { ...element, masks: migratedMasks };
}
function migrateMask({ mask }: { mask: unknown }): unknown {
if (!isRecord(mask)) return mask;
const params = isRecord(mask.params) ? { ...mask.params } : {};
const result = { ...mask };
result.params = {
...params,
feather: typeof mask.feather === "number" ? mask.feather : 0,
inverted: typeof mask.inverted === "boolean" ? mask.inverted : false,
strokeColor:
isRecord(mask.stroke) && typeof mask.stroke.color === "string"
? mask.stroke.color
: "#ffffff",
strokeWidth:
isRecord(mask.stroke) && typeof mask.stroke.width === "number"
? mask.stroke.width
: 0,
};
delete (result as Record<string, unknown>).feather;
delete (result as Record<string, unknown>).inverted;
delete (result as Record<string, unknown>).stroke;
return result;
}
import type { MigrationResult, ProjectRecord } from "./types";
import { getProjectId, isRecord } from "./utils";
export function transformProjectV12ToV13({
project,
}: {
project: ProjectRecord;
}): MigrationResult<ProjectRecord> {
if (!getProjectId({ project })) {
return { project, skipped: true, reason: "no project id" };
}
if (typeof project.version === "number" && project.version >= 13) {
return { project, skipped: true, reason: "already v13" };
}
const migratedProject = migrateProjectMasks({ project });
return {
project: { ...migratedProject, version: 13 },
skipped: false,
};
}
function migrateProjectMasks({
project,
}: {
project: ProjectRecord;
}): ProjectRecord {
const scenesValue = project.scenes;
if (!Array.isArray(scenesValue)) return project;
const migratedScenes = scenesValue.map((scene) =>
migrateSceneMasks({ scene }),
);
return { ...project, scenes: migratedScenes };
}
function migrateSceneMasks({ scene }: { scene: unknown }): unknown {
if (!isRecord(scene)) return scene;
const tracksValue = scene.tracks;
if (!Array.isArray(tracksValue)) return scene;
const migratedTracks = tracksValue.map((track) =>
migrateTrackMasks({ track }),
);
return { ...scene, tracks: migratedTracks };
}
function migrateTrackMasks({ track }: { track: unknown }): unknown {
if (!isRecord(track)) return track;
const elementsValue = track.elements;
if (!Array.isArray(elementsValue)) return track;
const migratedElements = elementsValue.map((element) =>
migrateElementMasks({ element }),
);
return { ...track, elements: migratedElements };
}
function migrateElementMasks({ element }: { element: unknown }): unknown {
if (!isRecord(element)) return element;
const masksValue = element.masks;
if (!Array.isArray(masksValue)) return element;
const migratedMasks = masksValue.map((mask) => migrateMask({ mask }));
return { ...element, masks: migratedMasks };
}
function migrateMask({ mask }: { mask: unknown }): unknown {
if (!isRecord(mask)) return mask;
const params = isRecord(mask.params) ? { ...mask.params } : {};
const result = { ...mask };
result.params = {
...params,
feather: typeof mask.feather === "number" ? mask.feather : 0,
inverted: typeof mask.inverted === "boolean" ? mask.inverted : false,
strokeColor:
isRecord(mask.stroke) && typeof mask.stroke.color === "string"
? mask.stroke.color
: "#ffffff",
strokeWidth:
isRecord(mask.stroke) && typeof mask.stroke.width === "number"
? mask.stroke.width
: 0,
};
delete (result as Record<string, unknown>).feather;
delete (result as Record<string, unknown>).inverted;
delete (result as Record<string, unknown>).stroke;
return result;
}
@@ -1,106 +1,106 @@
import type { MigrationResult, ProjectRecord } from "./types";
import { getProjectId, isRecord } from "./utils";
export function transformProjectV13ToV14({
project,
}: {
project: ProjectRecord;
}): MigrationResult<ProjectRecord> {
if (!getProjectId({ project })) {
return { project, skipped: true, reason: "no project id" };
}
if (typeof project.version === "number" && project.version >= 14) {
return { project, skipped: true, reason: "already v14" };
}
const migratedProject = migrateProjectSplitMasks({ project });
return {
project: { ...migratedProject, version: 14 },
skipped: false,
};
}
function migrateProjectSplitMasks({
project,
}: {
project: ProjectRecord;
}): ProjectRecord {
const scenesValue = project.scenes;
if (!Array.isArray(scenesValue)) return project;
const migratedScenes = scenesValue.map((scene) =>
migrateSceneSplitMasks({ scene }),
);
return { ...project, scenes: migratedScenes };
}
function migrateSceneSplitMasks({ scene }: { scene: unknown }): unknown {
if (!isRecord(scene)) return scene;
const tracksValue = scene.tracks;
if (!Array.isArray(tracksValue)) return scene;
const migratedTracks = tracksValue.map((track) =>
migrateTrackSplitMasks({ track }),
);
return { ...scene, tracks: migratedTracks };
}
function migrateTrackSplitMasks({ track }: { track: unknown }): unknown {
if (!isRecord(track)) return track;
const elementsValue = track.elements;
if (!Array.isArray(elementsValue)) return track;
const migratedElements = elementsValue.map((element) =>
migrateElementSplitMasks({ element }),
);
return { ...track, elements: migratedElements };
}
function migrateElementSplitMasks({ element }: { element: unknown }): unknown {
if (!isRecord(element)) return element;
const masksValue = element.masks;
if (!Array.isArray(masksValue)) return element;
const migratedMasks = masksValue.map((mask) => migrateSplitMask({ mask }));
return { ...element, masks: migratedMasks };
}
/**
* Converts split mask params from position-along-normal to explicit (x, y) anchor.
*
* Old: center = (cx + (position-0.5)*W*cos(rot), cy + (position-0.5)*W*sin(rot))
* New: center = (cx + x*W, cy + y*W)
*
* Conversion: x = (position-0.5)*cos(rot), y = (position-0.5)*sin(rot)
*/
function migrateSplitMask({ mask }: { mask: unknown }): unknown {
if (!isRecord(mask)) return mask;
if (mask.type !== "split") return mask;
const params = isRecord(mask.params) ? mask.params : {};
const position = typeof params.position === "number" ? params.position : 0.5;
const rotation = typeof params.rotation === "number" ? params.rotation : 0;
const angleRad = (rotation * Math.PI) / 180;
const x = (position - 0.5) * Math.cos(angleRad);
const y = (position - 0.5) * Math.sin(angleRad);
const { position: _removed, ...restParams } = params as Record<
string,
unknown
> & { position: unknown };
return {
...mask,
params: { ...restParams, x, y },
};
}
import type { MigrationResult, ProjectRecord } from "./types";
import { getProjectId, isRecord } from "./utils";
export function transformProjectV13ToV14({
project,
}: {
project: ProjectRecord;
}): MigrationResult<ProjectRecord> {
if (!getProjectId({ project })) {
return { project, skipped: true, reason: "no project id" };
}
if (typeof project.version === "number" && project.version >= 14) {
return { project, skipped: true, reason: "already v14" };
}
const migratedProject = migrateProjectSplitMasks({ project });
return {
project: { ...migratedProject, version: 14 },
skipped: false,
};
}
function migrateProjectSplitMasks({
project,
}: {
project: ProjectRecord;
}): ProjectRecord {
const scenesValue = project.scenes;
if (!Array.isArray(scenesValue)) return project;
const migratedScenes = scenesValue.map((scene) =>
migrateSceneSplitMasks({ scene }),
);
return { ...project, scenes: migratedScenes };
}
function migrateSceneSplitMasks({ scene }: { scene: unknown }): unknown {
if (!isRecord(scene)) return scene;
const tracksValue = scene.tracks;
if (!Array.isArray(tracksValue)) return scene;
const migratedTracks = tracksValue.map((track) =>
migrateTrackSplitMasks({ track }),
);
return { ...scene, tracks: migratedTracks };
}
function migrateTrackSplitMasks({ track }: { track: unknown }): unknown {
if (!isRecord(track)) return track;
const elementsValue = track.elements;
if (!Array.isArray(elementsValue)) return track;
const migratedElements = elementsValue.map((element) =>
migrateElementSplitMasks({ element }),
);
return { ...track, elements: migratedElements };
}
function migrateElementSplitMasks({ element }: { element: unknown }): unknown {
if (!isRecord(element)) return element;
const masksValue = element.masks;
if (!Array.isArray(masksValue)) return element;
const migratedMasks = masksValue.map((mask) => migrateSplitMask({ mask }));
return { ...element, masks: migratedMasks };
}
/**
* Converts split mask params from position-along-normal to explicit (x, y) anchor.
*
* Old: center = (cx + (position-0.5)*W*cos(rot), cy + (position-0.5)*W*sin(rot))
* New: center = (cx + x*W, cy + y*W)
*
* Conversion: x = (position-0.5)*cos(rot), y = (position-0.5)*sin(rot)
*/
function migrateSplitMask({ mask }: { mask: unknown }): unknown {
if (!isRecord(mask)) return mask;
if (mask.type !== "split") return mask;
const params = isRecord(mask.params) ? mask.params : {};
const position = typeof params.position === "number" ? params.position : 0.5;
const rotation = typeof params.rotation === "number" ? params.rotation : 0;
const angleRad = (rotation * Math.PI) / 180;
const x = (position - 0.5) * Math.cos(angleRad);
const y = (position - 0.5) * Math.sin(angleRad);
const { position: _removed, ...restParams } = params as Record<
string,
unknown
> & { position: unknown };
return {
...mask,
params: { ...restParams, x, y },
};
}
@@ -1,67 +1,67 @@
import { STICKER_INTRINSIC_SIZE_FALLBACK } from "@/constants/sticker-constants";
import type { MigrationResult, ProjectRecord } from "./types";
import { getProjectId, isRecord } from "./utils";
export function transformProjectV14ToV15({
project,
}: {
project: ProjectRecord;
}): MigrationResult<ProjectRecord> {
if (!getProjectId({ project })) {
return { project, skipped: true, reason: "no project id" };
}
if (typeof project.version === "number" && project.version >= 15) {
return { project, skipped: true, reason: "already v15" };
}
const migratedProject = backfillStickerIntrinsicDimensions({ project });
return {
project: { ...migratedProject, version: 15 },
skipped: false,
};
}
function backfillStickerIntrinsicDimensions({
project,
}: {
project: ProjectRecord;
}): ProjectRecord {
const scenesValue = project.scenes;
if (!Array.isArray(scenesValue)) return project;
const migratedScenes = scenesValue.map((scene) => {
if (!isRecord(scene)) return scene;
const tracksValue = scene.tracks;
if (!Array.isArray(tracksValue)) return scene;
const migratedTracks = tracksValue.map((track) => {
if (!isRecord(track)) return track;
const elementsValue = track.elements;
if (!Array.isArray(elementsValue)) return track;
const migratedElements = elementsValue.map((element) => {
if (!isRecord(element)) return element;
if (element.type !== "sticker") return element;
if (
typeof element.intrinsicWidth === "number" &&
typeof element.intrinsicHeight === "number"
) {
return element;
}
return {
...element,
intrinsicWidth: STICKER_INTRINSIC_SIZE_FALLBACK,
intrinsicHeight: STICKER_INTRINSIC_SIZE_FALLBACK,
};
});
return { ...track, elements: migratedElements };
});
return { ...scene, tracks: migratedTracks };
});
return { ...project, scenes: migratedScenes };
}
import { STICKER_INTRINSIC_SIZE_FALLBACK } from "@/constants/sticker-constants";
import type { MigrationResult, ProjectRecord } from "./types";
import { getProjectId, isRecord } from "./utils";
export function transformProjectV14ToV15({
project,
}: {
project: ProjectRecord;
}): MigrationResult<ProjectRecord> {
if (!getProjectId({ project })) {
return { project, skipped: true, reason: "no project id" };
}
if (typeof project.version === "number" && project.version >= 15) {
return { project, skipped: true, reason: "already v15" };
}
const migratedProject = backfillStickerIntrinsicDimensions({ project });
return {
project: { ...migratedProject, version: 15 },
skipped: false,
};
}
function backfillStickerIntrinsicDimensions({
project,
}: {
project: ProjectRecord;
}): ProjectRecord {
const scenesValue = project.scenes;
if (!Array.isArray(scenesValue)) return project;
const migratedScenes = scenesValue.map((scene) => {
if (!isRecord(scene)) return scene;
const tracksValue = scene.tracks;
if (!Array.isArray(tracksValue)) return scene;
const migratedTracks = tracksValue.map((track) => {
if (!isRecord(track)) return track;
const elementsValue = track.elements;
if (!Array.isArray(elementsValue)) return track;
const migratedElements = elementsValue.map((element) => {
if (!isRecord(element)) return element;
if (element.type !== "sticker") return element;
if (
typeof element.intrinsicWidth === "number" &&
typeof element.intrinsicHeight === "number"
) {
return element;
}
return {
...element,
intrinsicWidth: STICKER_INTRINSIC_SIZE_FALLBACK,
intrinsicHeight: STICKER_INTRINSIC_SIZE_FALLBACK,
};
});
return { ...track, elements: migratedElements };
});
return { ...scene, tracks: migratedTracks };
});
return { ...project, scenes: migratedScenes };
}
@@ -1,60 +1,60 @@
import type { MigrationResult, ProjectRecord } from "./types";
import { getProjectId, isRecord } from "./utils";
export function transformProjectV15ToV16({
project,
}: {
project: ProjectRecord;
}): MigrationResult<ProjectRecord> {
if (!getProjectId({ project })) {
return { project, skipped: true, reason: "no project id" };
}
if (typeof project.version === "number" && project.version >= 16) {
return { project, skipped: true, reason: "already v16" };
}
return {
project: {
...renameStickerTracksToGraphic({ project }),
version: 16,
},
skipped: false,
};
}
function renameStickerTracksToGraphic({
project,
}: {
project: ProjectRecord;
}): ProjectRecord {
const scenesValue = project.scenes;
if (!Array.isArray(scenesValue)) {
return project;
}
const migratedScenes = scenesValue.map((scene) => {
if (!isRecord(scene) || !Array.isArray(scene.tracks)) {
return scene;
}
return {
...scene,
tracks: scene.tracks.map((track) => {
if (!isRecord(track) || track.type !== "sticker") {
return track;
}
return {
...track,
type: "graphic",
};
}),
};
});
return {
...project,
scenes: migratedScenes,
};
}
import type { MigrationResult, ProjectRecord } from "./types";
import { getProjectId, isRecord } from "./utils";
export function transformProjectV15ToV16({
project,
}: {
project: ProjectRecord;
}): MigrationResult<ProjectRecord> {
if (!getProjectId({ project })) {
return { project, skipped: true, reason: "no project id" };
}
if (typeof project.version === "number" && project.version >= 16) {
return { project, skipped: true, reason: "already v16" };
}
return {
project: {
...renameStickerTracksToGraphic({ project }),
version: 16,
},
skipped: false,
};
}
function renameStickerTracksToGraphic({
project,
}: {
project: ProjectRecord;
}): ProjectRecord {
const scenesValue = project.scenes;
if (!Array.isArray(scenesValue)) {
return project;
}
const migratedScenes = scenesValue.map((scene) => {
if (!isRecord(scene) || !Array.isArray(scene.tracks)) {
return scene;
}
return {
...scene,
tracks: scene.tracks.map((track) => {
if (!isRecord(track) || track.type !== "sticker") {
return track;
}
return {
...track,
type: "graphic",
};
}),
};
});
return {
...project,
scenes: migratedScenes,
};
}
@@ -1,107 +1,107 @@
import type { MigrationResult, ProjectRecord } from "./types";
import { getProjectId, isRecord } from "./utils";
export function transformProjectV16ToV17({
project,
}: {
project: ProjectRecord;
}): MigrationResult<ProjectRecord> {
if (!getProjectId({ project })) {
return { project, skipped: true, reason: "no project id" };
}
if (typeof project.version === "number" && project.version >= 17) {
return { project, skipped: true, reason: "already v17" };
}
return {
project: {
...backfillMaskStrokeAlign({ project }),
version: 17,
},
skipped: false,
};
}
function backfillMaskStrokeAlign({
project,
}: {
project: ProjectRecord;
}): ProjectRecord {
const scenesValue = project.scenes;
if (!Array.isArray(scenesValue)) {
return project;
}
const migratedScenes = scenesValue.map((scene) => {
if (!isRecord(scene)) {
return scene;
}
const tracksValue = scene.tracks;
if (!Array.isArray(tracksValue)) {
return scene;
}
const migratedTracks = tracksValue.map((track) => {
if (!isRecord(track)) {
return track;
}
const elementsValue = track.elements;
if (!Array.isArray(elementsValue)) {
return track;
}
const migratedElements = elementsValue.map((element) => {
if (!isRecord(element)) {
return element;
}
const masksValue = element.masks;
if (!Array.isArray(masksValue)) {
return element;
}
const migratedMasks = masksValue.map((mask) => {
if (!isRecord(mask)) {
return mask;
}
const paramsValue = mask.params;
if (!isRecord(paramsValue) || typeof paramsValue.strokeAlign === "string") {
return mask;
}
return {
...mask,
params: {
...paramsValue,
strokeAlign: "center",
},
};
});
return {
...element,
masks: migratedMasks,
};
});
return {
...track,
elements: migratedElements,
};
});
return {
...scene,
tracks: migratedTracks,
};
});
return {
...project,
scenes: migratedScenes,
};
}
import type { MigrationResult, ProjectRecord } from "./types";
import { getProjectId, isRecord } from "./utils";
export function transformProjectV16ToV17({
project,
}: {
project: ProjectRecord;
}): MigrationResult<ProjectRecord> {
if (!getProjectId({ project })) {
return { project, skipped: true, reason: "no project id" };
}
if (typeof project.version === "number" && project.version >= 17) {
return { project, skipped: true, reason: "already v17" };
}
return {
project: {
...backfillMaskStrokeAlign({ project }),
version: 17,
},
skipped: false,
};
}
function backfillMaskStrokeAlign({
project,
}: {
project: ProjectRecord;
}): ProjectRecord {
const scenesValue = project.scenes;
if (!Array.isArray(scenesValue)) {
return project;
}
const migratedScenes = scenesValue.map((scene) => {
if (!isRecord(scene)) {
return scene;
}
const tracksValue = scene.tracks;
if (!Array.isArray(tracksValue)) {
return scene;
}
const migratedTracks = tracksValue.map((track) => {
if (!isRecord(track)) {
return track;
}
const elementsValue = track.elements;
if (!Array.isArray(elementsValue)) {
return track;
}
const migratedElements = elementsValue.map((element) => {
if (!isRecord(element)) {
return element;
}
const masksValue = element.masks;
if (!Array.isArray(masksValue)) {
return element;
}
const migratedMasks = masksValue.map((mask) => {
if (!isRecord(mask)) {
return mask;
}
const paramsValue = mask.params;
if (!isRecord(paramsValue) || typeof paramsValue.strokeAlign === "string") {
return mask;
}
return {
...mask,
params: {
...paramsValue,
strokeAlign: "center",
},
};
});
return {
...element,
masks: migratedMasks,
};
});
return {
...track,
elements: migratedElements,
};
});
return {
...scene,
tracks: migratedTracks,
};
});
return {
...project,
scenes: migratedScenes,
};
}
@@ -1,105 +1,105 @@
import type { MigrationResult, ProjectRecord } from "./types";
import { VOLUME_DB_MIN } from "@/lib/timeline/audio-constants";
import { clampDb } from "@/lib/timeline/audio-state";
import { getProjectId, isRecord } from "./utils";
export function transformProjectV17ToV18({
project,
}: {
project: ProjectRecord;
}): MigrationResult<ProjectRecord> {
if (!getProjectId({ project })) {
return { project, skipped: true, reason: "no project id" };
}
if (typeof project.version === "number" && project.version >= 18) {
return { project, skipped: true, reason: "already v18" };
}
return {
project: {
...migrateElementVolumes({ project }),
version: 18,
},
skipped: false,
};
}
function migrateElementVolumes({
project,
}: {
project: ProjectRecord;
}): ProjectRecord {
const scenesValue = project.scenes;
if (!Array.isArray(scenesValue)) {
return project;
}
const migratedScenes = scenesValue.map((scene) => {
if (!isRecord(scene)) {
return scene;
}
const tracksValue = scene.tracks;
if (!Array.isArray(tracksValue)) {
return scene;
}
const migratedTracks = tracksValue.map((track) => {
if (!isRecord(track)) {
return track;
}
const elementsValue = track.elements;
if (!Array.isArray(elementsValue)) {
return track;
}
const migratedElements = elementsValue.map((element) => {
if (!isRecord(element)) {
return element;
}
if (element.type !== "audio" && element.type !== "video") {
return element;
}
const legacyVolume = element.volume;
return {
...element,
volume:
typeof legacyVolume === "number"
? linearGainToDb(legacyVolume)
: 0,
};
});
return {
...track,
elements: migratedElements,
};
});
return {
...scene,
tracks: migratedTracks,
};
});
return {
...project,
scenes: migratedScenes,
};
}
function linearGainToDb(gain: number): number {
if (!Number.isFinite(gain)) {
return 0;
}
if (gain <= 0) {
return VOLUME_DB_MIN;
}
return clampDb(20 * Math.log10(gain));
}
import type { MigrationResult, ProjectRecord } from "./types";
import { VOLUME_DB_MIN } from "@/lib/timeline/audio-constants";
import { clampDb } from "@/lib/timeline/audio-state";
import { getProjectId, isRecord } from "./utils";
export function transformProjectV17ToV18({
project,
}: {
project: ProjectRecord;
}): MigrationResult<ProjectRecord> {
if (!getProjectId({ project })) {
return { project, skipped: true, reason: "no project id" };
}
if (typeof project.version === "number" && project.version >= 18) {
return { project, skipped: true, reason: "already v18" };
}
return {
project: {
...migrateElementVolumes({ project }),
version: 18,
},
skipped: false,
};
}
function migrateElementVolumes({
project,
}: {
project: ProjectRecord;
}): ProjectRecord {
const scenesValue = project.scenes;
if (!Array.isArray(scenesValue)) {
return project;
}
const migratedScenes = scenesValue.map((scene) => {
if (!isRecord(scene)) {
return scene;
}
const tracksValue = scene.tracks;
if (!Array.isArray(tracksValue)) {
return scene;
}
const migratedTracks = tracksValue.map((track) => {
if (!isRecord(track)) {
return track;
}
const elementsValue = track.elements;
if (!Array.isArray(elementsValue)) {
return track;
}
const migratedElements = elementsValue.map((element) => {
if (!isRecord(element)) {
return element;
}
if (element.type !== "audio" && element.type !== "video") {
return element;
}
const legacyVolume = element.volume;
return {
...element,
volume:
typeof legacyVolume === "number"
? linearGainToDb(legacyVolume)
: 0,
};
});
return {
...track,
elements: migratedElements,
};
});
return {
...scene,
tracks: migratedTracks,
};
});
return {
...project,
scenes: migratedScenes,
};
}
function linearGainToDb(gain: number): number {
if (!Number.isFinite(gain)) {
return 0;
}
if (gain <= 0) {
return VOLUME_DB_MIN;
}
return clampDb(20 * Math.log10(gain));
}
@@ -1,46 +1,46 @@
import type { MigrationResult, ProjectRecord } from "./types";
import { getProjectId, isRecord } from "./utils";
export function transformProjectV18ToV19({
project,
}: {
project: ProjectRecord;
}): MigrationResult<ProjectRecord> {
if (!getProjectId({ project })) {
return { project, skipped: true, reason: "no project id" };
}
if (typeof project.version === "number" && project.version >= 19) {
return { project, skipped: true, reason: "already v19" };
}
return {
project: {
...migrateCanvasSizeSettings({ project }),
version: 19,
},
skipped: false,
};
}
function migrateCanvasSizeSettings({
project,
}: {
project: ProjectRecord;
}): ProjectRecord {
const settingsValue = project.settings;
if (!isRecord(settingsValue)) {
return project;
}
const migratedSettings: Record<string, unknown> = {
...settingsValue,
canvasSizeMode: "preset",
lastCustomCanvasSize: null,
};
return {
...project,
settings: migratedSettings,
};
}
import type { MigrationResult, ProjectRecord } from "./types";
import { getProjectId, isRecord } from "./utils";
export function transformProjectV18ToV19({
project,
}: {
project: ProjectRecord;
}): MigrationResult<ProjectRecord> {
if (!getProjectId({ project })) {
return { project, skipped: true, reason: "no project id" };
}
if (typeof project.version === "number" && project.version >= 19) {
return { project, skipped: true, reason: "already v19" };
}
return {
project: {
...migrateCanvasSizeSettings({ project }),
version: 19,
},
skipped: false,
};
}
function migrateCanvasSizeSettings({
project,
}: {
project: ProjectRecord;
}): ProjectRecord {
const settingsValue = project.settings;
if (!isRecord(settingsValue)) {
return project;
}
const migratedSettings: Record<string, unknown> = {
...settingsValue,
canvasSizeMode: "preset",
lastCustomCanvasSize: null,
};
return {
...project,
settings: migratedSettings,
};
}
@@ -1,189 +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;
}
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;
}
@@ -1,177 +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;
}
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;
}
@@ -1,88 +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;
}
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;
}
@@ -1,106 +1,106 @@
import type { MigrationResult, ProjectRecord } from "./types";
import { getProjectId, isRecord } from "./utils";
export function transformProjectV6ToV7({
project,
}: {
project: ProjectRecord;
}): MigrationResult<ProjectRecord> {
const projectId = getProjectId({ project });
if (!projectId) {
return { project, skipped: true, reason: "no project id" };
}
if (isV7Project({ project })) {
return { project, skipped: true, reason: "already v7" };
}
const migratedProject = migrateProjectTextElements({ project });
return {
project: { ...migratedProject, version: 7 },
skipped: false,
};
}
function migrateProjectTextElements({
project,
}: {
project: ProjectRecord;
}): ProjectRecord {
const scenesValue = project.scenes;
if (!Array.isArray(scenesValue)) return project;
let hasChanges = false;
const migratedScenes = scenesValue.map((scene) => {
const migrated = migrateSceneTextElements({ scene });
if (migrated !== scene) hasChanges = true;
return migrated;
});
if (!hasChanges) return project;
return { ...project, scenes: migratedScenes };
}
function migrateSceneTextElements({ scene }: { scene: unknown }): unknown {
if (!isRecord(scene)) return scene;
const tracksValue = scene.tracks;
if (!Array.isArray(tracksValue)) return scene;
let hasChanges = false;
const migratedTracks = tracksValue.map((track) => {
const migrated = migrateTrackTextElements({ track });
if (migrated !== track) hasChanges = true;
return migrated;
});
if (!hasChanges) return scene;
return { ...scene, tracks: migratedTracks };
}
function migrateTrackTextElements({ track }: { track: unknown }): unknown {
if (!isRecord(track)) return track;
const elementsValue = track.elements;
if (!Array.isArray(elementsValue)) return track;
let hasChanges = false;
const migratedElements = elementsValue.map((element) => {
const migrated = migrateTextElement({ element });
if (migrated !== element) hasChanges = true;
return migrated;
});
if (!hasChanges) return track;
return { ...track, elements: migratedElements };
}
function migrateTextElement({ element }: { element: unknown }): unknown {
if (!isRecord(element)) return element;
if (element.type !== "text") return element;
if (isRecord(element.background)) return element;
const backgroundColor =
typeof element.backgroundColor === "string"
? element.backgroundColor
: "transparent";
const { backgroundColor: _removed, ...rest } = element;
return {
...rest,
background: {
color: backgroundColor,
cornerRadius: 0,
paddingX: 8,
paddingY: 4,
offsetX: 0,
offsetY: 0,
},
};
}
function isV7Project({ project }: { project: ProjectRecord }): boolean {
return typeof project.version === "number" && project.version >= 7;
}
import type { MigrationResult, ProjectRecord } from "./types";
import { getProjectId, isRecord } from "./utils";
export function transformProjectV6ToV7({
project,
}: {
project: ProjectRecord;
}): MigrationResult<ProjectRecord> {
const projectId = getProjectId({ project });
if (!projectId) {
return { project, skipped: true, reason: "no project id" };
}
if (isV7Project({ project })) {
return { project, skipped: true, reason: "already v7" };
}
const migratedProject = migrateProjectTextElements({ project });
return {
project: { ...migratedProject, version: 7 },
skipped: false,
};
}
function migrateProjectTextElements({
project,
}: {
project: ProjectRecord;
}): ProjectRecord {
const scenesValue = project.scenes;
if (!Array.isArray(scenesValue)) return project;
let hasChanges = false;
const migratedScenes = scenesValue.map((scene) => {
const migrated = migrateSceneTextElements({ scene });
if (migrated !== scene) hasChanges = true;
return migrated;
});
if (!hasChanges) return project;
return { ...project, scenes: migratedScenes };
}
function migrateSceneTextElements({ scene }: { scene: unknown }): unknown {
if (!isRecord(scene)) return scene;
const tracksValue = scene.tracks;
if (!Array.isArray(tracksValue)) return scene;
let hasChanges = false;
const migratedTracks = tracksValue.map((track) => {
const migrated = migrateTrackTextElements({ track });
if (migrated !== track) hasChanges = true;
return migrated;
});
if (!hasChanges) return scene;
return { ...scene, tracks: migratedTracks };
}
function migrateTrackTextElements({ track }: { track: unknown }): unknown {
if (!isRecord(track)) return track;
const elementsValue = track.elements;
if (!Array.isArray(elementsValue)) return track;
let hasChanges = false;
const migratedElements = elementsValue.map((element) => {
const migrated = migrateTextElement({ element });
if (migrated !== element) hasChanges = true;
return migrated;
});
if (!hasChanges) return track;
return { ...track, elements: migratedElements };
}
function migrateTextElement({ element }: { element: unknown }): unknown {
if (!isRecord(element)) return element;
if (element.type !== "text") return element;
if (isRecord(element.background)) return element;
const backgroundColor =
typeof element.backgroundColor === "string"
? element.backgroundColor
: "transparent";
const { backgroundColor: _removed, ...rest } = element;
return {
...rest,
background: {
color: backgroundColor,
cornerRadius: 0,
paddingX: 8,
paddingY: 4,
offsetX: 0,
offsetY: 0,
},
};
}
function isV7Project({ project }: { project: ProjectRecord }): boolean {
return typeof project.version === "number" && project.version >= 7;
}
@@ -1,96 +1,96 @@
import type { MigrationResult, ProjectRecord } from "./types";
import { getProjectId, isRecord } from "./utils";
export function transformProjectV7ToV8({
project,
}: {
project: ProjectRecord;
}): MigrationResult<ProjectRecord> {
const projectId = getProjectId({ project });
if (!projectId) {
return { project, skipped: true, reason: "no project id" };
}
if (isV8Project({ project })) {
return { project, skipped: true, reason: "already v8" };
}
const migratedProject = migrateProjectElements({ project });
return {
project: { ...migratedProject, version: 8 },
skipped: false,
};
}
function migrateProjectElements({
project,
}: {
project: ProjectRecord;
}): ProjectRecord {
const scenesValue = project.scenes;
if (!Array.isArray(scenesValue)) return project;
let hasChanges = false;
const migratedScenes = scenesValue.map((scene) => {
const migrated = migrateSceneElements({ scene });
if (migrated !== scene) hasChanges = true;
return migrated;
});
if (!hasChanges) return project;
return { ...project, scenes: migratedScenes };
}
function migrateSceneElements({ scene }: { scene: unknown }): unknown {
if (!isRecord(scene)) return scene;
const tracksValue = scene.tracks;
if (!Array.isArray(tracksValue)) return scene;
let hasChanges = false;
const migratedTracks = tracksValue.map((track) => {
const migrated = migrateTrackElements({ track });
if (migrated !== track) hasChanges = true;
return migrated;
});
if (!hasChanges) return scene;
return { ...scene, tracks: migratedTracks };
}
function migrateTrackElements({ track }: { track: unknown }): unknown {
if (!isRecord(track)) return track;
const elementsValue = track.elements;
if (!Array.isArray(elementsValue)) return track;
let hasChanges = false;
const migratedElements = elementsValue.map((element) => {
const migrated = migrateElement({ element });
if (migrated !== element) hasChanges = true;
return migrated;
});
if (!hasChanges) return track;
return { ...track, elements: migratedElements };
}
function migrateElement({ element }: { element: unknown }): unknown {
if (!isRecord(element)) return element;
if (element.type !== "video" && element.type !== "audio") return element;
if (typeof element.sourceDuration === "number") return element;
const trimStart = typeof element.trimStart === "number" ? element.trimStart : 0;
const duration = typeof element.duration === "number" ? element.duration : 0;
const trimEnd = typeof element.trimEnd === "number" ? element.trimEnd : 0;
return {
...element,
sourceDuration: trimStart + duration + trimEnd,
};
}
function isV8Project({ project }: { project: ProjectRecord }): boolean {
return typeof project.version === "number" && project.version >= 8;
}
import type { MigrationResult, ProjectRecord } from "./types";
import { getProjectId, isRecord } from "./utils";
export function transformProjectV7ToV8({
project,
}: {
project: ProjectRecord;
}): MigrationResult<ProjectRecord> {
const projectId = getProjectId({ project });
if (!projectId) {
return { project, skipped: true, reason: "no project id" };
}
if (isV8Project({ project })) {
return { project, skipped: true, reason: "already v8" };
}
const migratedProject = migrateProjectElements({ project });
return {
project: { ...migratedProject, version: 8 },
skipped: false,
};
}
function migrateProjectElements({
project,
}: {
project: ProjectRecord;
}): ProjectRecord {
const scenesValue = project.scenes;
if (!Array.isArray(scenesValue)) return project;
let hasChanges = false;
const migratedScenes = scenesValue.map((scene) => {
const migrated = migrateSceneElements({ scene });
if (migrated !== scene) hasChanges = true;
return migrated;
});
if (!hasChanges) return project;
return { ...project, scenes: migratedScenes };
}
function migrateSceneElements({ scene }: { scene: unknown }): unknown {
if (!isRecord(scene)) return scene;
const tracksValue = scene.tracks;
if (!Array.isArray(tracksValue)) return scene;
let hasChanges = false;
const migratedTracks = tracksValue.map((track) => {
const migrated = migrateTrackElements({ track });
if (migrated !== track) hasChanges = true;
return migrated;
});
if (!hasChanges) return scene;
return { ...scene, tracks: migratedTracks };
}
function migrateTrackElements({ track }: { track: unknown }): unknown {
if (!isRecord(track)) return track;
const elementsValue = track.elements;
if (!Array.isArray(elementsValue)) return track;
let hasChanges = false;
const migratedElements = elementsValue.map((element) => {
const migrated = migrateElement({ element });
if (migrated !== element) hasChanges = true;
return migrated;
});
if (!hasChanges) return track;
return { ...track, elements: migratedElements };
}
function migrateElement({ element }: { element: unknown }): unknown {
if (!isRecord(element)) return element;
if (element.type !== "video" && element.type !== "audio") return element;
if (typeof element.sourceDuration === "number") return element;
const trimStart = typeof element.trimStart === "number" ? element.trimStart : 0;
const duration = typeof element.duration === "number" ? element.duration : 0;
const trimEnd = typeof element.trimEnd === "number" ? element.trimEnd : 0;
return {
...element,
sourceDuration: trimStart + duration + trimEnd,
};
}
function isV8Project({ project }: { project: ProjectRecord }): boolean {
return typeof project.version === "number" && project.version >= 8;
}
@@ -1,99 +1,99 @@
import type { MigrationResult, ProjectRecord } from "./types";
import { getProjectId, isRecord } from "./utils";
export function transformProjectV8ToV9({
project,
}: {
project: ProjectRecord;
}): MigrationResult<ProjectRecord> {
const projectId = getProjectId({ project });
if (!projectId) {
return { project, skipped: true, reason: "no project id" };
}
if (isV9Project({ project })) {
return { project, skipped: true, reason: "already v9" };
}
const migratedProject = migrateProjectTextElements({ project });
return {
project: { ...migratedProject, version: 9 },
skipped: false,
};
}
function migrateProjectTextElements({
project,
}: {
project: ProjectRecord;
}): ProjectRecord {
const scenesValue = project.scenes;
if (!Array.isArray(scenesValue)) return project;
let hasChanges = false;
const migratedScenes = scenesValue.map((scene) => {
const migrated = migrateSceneTextElements({ scene });
if (migrated !== scene) hasChanges = true;
return migrated;
});
if (!hasChanges) return project;
return { ...project, scenes: migratedScenes };
}
function migrateSceneTextElements({ scene }: { scene: unknown }): unknown {
if (!isRecord(scene)) return scene;
const tracksValue = scene.tracks;
if (!Array.isArray(tracksValue)) return scene;
let hasChanges = false;
const migratedTracks = tracksValue.map((track) => {
const migrated = migrateTrackTextElements({ track });
if (migrated !== track) hasChanges = true;
return migrated;
});
if (!hasChanges) return scene;
return { ...scene, tracks: migratedTracks };
}
function migrateTrackTextElements({ 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 hasChanges = false;
const migratedElements = elementsValue.map((element) => {
const migrated = migrateTextElement({ element });
if (migrated !== element) hasChanges = true;
return migrated;
});
if (!hasChanges) return track;
return { ...track, elements: migratedElements };
}
function migrateTextElement({ element }: { element: unknown }): unknown {
if (!isRecord(element)) return element;
if (element.type !== "text") return element;
const bg = element.background;
if (!isRecord(bg)) return element;
if (typeof bg.enabled === "boolean") return element;
const color = typeof bg.color === "string" ? bg.color : "transparent";
const enabled = color !== "transparent";
return {
...element,
background: { ...bg, enabled },
};
}
function isV9Project({ project }: { project: ProjectRecord }): boolean {
return typeof project.version === "number" && project.version >= 9;
}
import type { MigrationResult, ProjectRecord } from "./types";
import { getProjectId, isRecord } from "./utils";
export function transformProjectV8ToV9({
project,
}: {
project: ProjectRecord;
}): MigrationResult<ProjectRecord> {
const projectId = getProjectId({ project });
if (!projectId) {
return { project, skipped: true, reason: "no project id" };
}
if (isV9Project({ project })) {
return { project, skipped: true, reason: "already v9" };
}
const migratedProject = migrateProjectTextElements({ project });
return {
project: { ...migratedProject, version: 9 },
skipped: false,
};
}
function migrateProjectTextElements({
project,
}: {
project: ProjectRecord;
}): ProjectRecord {
const scenesValue = project.scenes;
if (!Array.isArray(scenesValue)) return project;
let hasChanges = false;
const migratedScenes = scenesValue.map((scene) => {
const migrated = migrateSceneTextElements({ scene });
if (migrated !== scene) hasChanges = true;
return migrated;
});
if (!hasChanges) return project;
return { ...project, scenes: migratedScenes };
}
function migrateSceneTextElements({ scene }: { scene: unknown }): unknown {
if (!isRecord(scene)) return scene;
const tracksValue = scene.tracks;
if (!Array.isArray(tracksValue)) return scene;
let hasChanges = false;
const migratedTracks = tracksValue.map((track) => {
const migrated = migrateTrackTextElements({ track });
if (migrated !== track) hasChanges = true;
return migrated;
});
if (!hasChanges) return scene;
return { ...scene, tracks: migratedTracks };
}
function migrateTrackTextElements({ 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 hasChanges = false;
const migratedElements = elementsValue.map((element) => {
const migrated = migrateTextElement({ element });
if (migrated !== element) hasChanges = true;
return migrated;
});
if (!hasChanges) return track;
return { ...track, elements: migratedElements };
}
function migrateTextElement({ element }: { element: unknown }): unknown {
if (!isRecord(element)) return element;
if (element.type !== "text") return element;
const bg = element.background;
if (!isRecord(bg)) return element;
if (typeof bg.enabled === "boolean") return element;
const color = typeof bg.color === "string" ? bg.color : "transparent";
const enabled = color !== "transparent";
return {
...element,
background: { ...bg, enabled },
};
}
function isV9Project({ project }: { project: ProjectRecord }): boolean {
return typeof project.version === "number" && project.version >= 9;
}
@@ -1,21 +1,21 @@
import type { MigrationResult, ProjectRecord } from "./types";
import { getProjectId } from "./utils";
export function transformProjectV9ToV10({
project,
}: {
project: ProjectRecord;
}): MigrationResult<ProjectRecord> {
if (!getProjectId({ project })) {
return { project, skipped: true, reason: "no project id" };
}
if (typeof project.version === "number" && project.version >= 10) {
return { project, skipped: true, reason: "already v10" };
}
return {
project: { ...project, version: 10 },
skipped: false,
};
}
import type { MigrationResult, ProjectRecord } from "./types";
import { getProjectId } from "./utils";
export function transformProjectV9ToV10({
project,
}: {
project: ProjectRecord;
}): MigrationResult<ProjectRecord> {
if (!getProjectId({ project })) {
return { project, skipped: true, reason: "no project id" };
}
if (typeof project.version === "number" && project.version >= 10) {
return { project, skipped: true, reason: "already v10" };
}
return {
project: { ...project, version: 10 },
skipped: false,
};
}
@@ -1,16 +1,16 @@
import { StorageMigration } from "./base";
import type { ProjectRecord } from "./transformers/types";
import { transformProjectV10ToV11 } from "./transformers/v10-to-v11";
export class V10toV11Migration extends StorageMigration {
from = 10;
to = 11;
async transform(project: ProjectRecord): Promise<{
project: ProjectRecord;
skipped: boolean;
reason?: string;
}> {
return transformProjectV10ToV11({ project });
}
}
import { StorageMigration } from "./base";
import type { ProjectRecord } from "./transformers/types";
import { transformProjectV10ToV11 } from "./transformers/v10-to-v11";
export class V10toV11Migration extends StorageMigration {
from = 10;
to = 11;
async transform(project: ProjectRecord): Promise<{
project: ProjectRecord;
skipped: boolean;
reason?: string;
}> {
return transformProjectV10ToV11({ project });
}
}
@@ -1,16 +1,16 @@
import { StorageMigration } from "./base";
import type { ProjectRecord } from "./transformers/types";
import { transformProjectV11ToV12 } from "./transformers/v11-to-v12";
export class V11toV12Migration extends StorageMigration {
from = 11;
to = 12;
async transform(project: ProjectRecord): Promise<{
project: ProjectRecord;
skipped: boolean;
reason?: string;
}> {
return transformProjectV11ToV12({ project });
}
}
import { StorageMigration } from "./base";
import type { ProjectRecord } from "./transformers/types";
import { transformProjectV11ToV12 } from "./transformers/v11-to-v12";
export class V11toV12Migration extends StorageMigration {
from = 11;
to = 12;
async transform(project: ProjectRecord): Promise<{
project: ProjectRecord;
skipped: boolean;
reason?: string;
}> {
return transformProjectV11ToV12({ project });
}
}
@@ -1,16 +1,16 @@
import { StorageMigration } from "./base";
import type { ProjectRecord } from "./transformers/types";
import { transformProjectV12ToV13 } from "./transformers/v12-to-v13";
export class V12toV13Migration extends StorageMigration {
from = 12;
to = 13;
async transform(project: ProjectRecord): Promise<{
project: ProjectRecord;
skipped: boolean;
reason?: string;
}> {
return transformProjectV12ToV13({ project });
}
}
import { StorageMigration } from "./base";
import type { ProjectRecord } from "./transformers/types";
import { transformProjectV12ToV13 } from "./transformers/v12-to-v13";
export class V12toV13Migration extends StorageMigration {
from = 12;
to = 13;
async transform(project: ProjectRecord): Promise<{
project: ProjectRecord;
skipped: boolean;
reason?: string;
}> {
return transformProjectV12ToV13({ project });
}
}
@@ -1,16 +1,16 @@
import { StorageMigration } from "./base";
import type { ProjectRecord } from "./transformers/types";
import { transformProjectV13ToV14 } from "./transformers/v13-to-v14";
export class V13toV14Migration extends StorageMigration {
from = 13;
to = 14;
async transform(project: ProjectRecord): Promise<{
project: ProjectRecord;
skipped: boolean;
reason?: string;
}> {
return transformProjectV13ToV14({ project });
}
}
import { StorageMigration } from "./base";
import type { ProjectRecord } from "./transformers/types";
import { transformProjectV13ToV14 } from "./transformers/v13-to-v14";
export class V13toV14Migration extends StorageMigration {
from = 13;
to = 14;
async transform(project: ProjectRecord): Promise<{
project: ProjectRecord;
skipped: boolean;
reason?: string;
}> {
return transformProjectV13ToV14({ project });
}
}
@@ -1,16 +1,16 @@
import { StorageMigration } from "./base";
import type { ProjectRecord } from "./transformers/types";
import { transformProjectV14ToV15 } from "./transformers/v14-to-v15";
export class V14toV15Migration extends StorageMigration {
from = 14;
to = 15;
async transform(project: ProjectRecord): Promise<{
project: ProjectRecord;
skipped: boolean;
reason?: string;
}> {
return transformProjectV14ToV15({ project });
}
}
import { StorageMigration } from "./base";
import type { ProjectRecord } from "./transformers/types";
import { transformProjectV14ToV15 } from "./transformers/v14-to-v15";
export class V14toV15Migration extends StorageMigration {
from = 14;
to = 15;
async transform(project: ProjectRecord): Promise<{
project: ProjectRecord;
skipped: boolean;
reason?: string;
}> {
return transformProjectV14ToV15({ project });
}
}
@@ -1,16 +1,16 @@
import { StorageMigration } from "./base";
import type { ProjectRecord } from "./transformers/types";
import { transformProjectV15ToV16 } from "./transformers/v15-to-v16";
export class V15toV16Migration extends StorageMigration {
from = 15;
to = 16;
async transform(project: ProjectRecord): Promise<{
project: ProjectRecord;
skipped: boolean;
reason?: string;
}> {
return transformProjectV15ToV16({ project });
}
}
import { StorageMigration } from "./base";
import type { ProjectRecord } from "./transformers/types";
import { transformProjectV15ToV16 } from "./transformers/v15-to-v16";
export class V15toV16Migration extends StorageMigration {
from = 15;
to = 16;
async transform(project: ProjectRecord): Promise<{
project: ProjectRecord;
skipped: boolean;
reason?: string;
}> {
return transformProjectV15ToV16({ project });
}
}
@@ -1,16 +1,16 @@
import { StorageMigration } from "./base";
import type { ProjectRecord } from "./transformers/types";
import { transformProjectV16ToV17 } from "./transformers/v16-to-v17";
export class V16toV17Migration extends StorageMigration {
from = 16;
to = 17;
async transform(project: ProjectRecord): Promise<{
project: ProjectRecord;
skipped: boolean;
reason?: string;
}> {
return transformProjectV16ToV17({ project });
}
}
import { StorageMigration } from "./base";
import type { ProjectRecord } from "./transformers/types";
import { transformProjectV16ToV17 } from "./transformers/v16-to-v17";
export class V16toV17Migration extends StorageMigration {
from = 16;
to = 17;
async transform(project: ProjectRecord): Promise<{
project: ProjectRecord;
skipped: boolean;
reason?: string;
}> {
return transformProjectV16ToV17({ project });
}
}
@@ -1,16 +1,16 @@
import { StorageMigration } from "./base";
import type { ProjectRecord } from "./transformers/types";
import { transformProjectV17ToV18 } from "./transformers/v17-to-v18";
export class V17toV18Migration extends StorageMigration {
from = 17;
to = 18;
async transform(project: ProjectRecord): Promise<{
project: ProjectRecord;
skipped: boolean;
reason?: string;
}> {
return transformProjectV17ToV18({ project });
}
}
import { StorageMigration } from "./base";
import type { ProjectRecord } from "./transformers/types";
import { transformProjectV17ToV18 } from "./transformers/v17-to-v18";
export class V17toV18Migration extends StorageMigration {
from = 17;
to = 18;
async transform(project: ProjectRecord): Promise<{
project: ProjectRecord;
skipped: boolean;
reason?: string;
}> {
return transformProjectV17ToV18({ project });
}
}
@@ -1,16 +1,16 @@
import { StorageMigration } from "./base";
import type { ProjectRecord } from "./transformers/types";
import { transformProjectV18ToV19 } from "./transformers/v18-to-v19";
export class V18toV19Migration extends StorageMigration {
from = 18;
to = 19;
async transform(project: ProjectRecord): Promise<{
project: ProjectRecord;
skipped: boolean;
reason?: string;
}> {
return transformProjectV18ToV19({ project });
}
}
import { StorageMigration } from "./base";
import type { ProjectRecord } from "./transformers/types";
import { transformProjectV18ToV19 } from "./transformers/v18-to-v19";
export class V18toV19Migration extends StorageMigration {
from = 18;
to = 19;
async transform(project: ProjectRecord): Promise<{
project: ProjectRecord;
skipped: boolean;
reason?: string;
}> {
return transformProjectV18ToV19({ project });
}
}
@@ -1,16 +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 });
}
}
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 });
}
}
@@ -1,16 +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 });
}
}
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 });
}
}
@@ -1,16 +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 });
}
}
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 });
}
}
@@ -1,16 +1,16 @@
import { StorageMigration } from "./base";
import type { ProjectRecord } from "./transformers/types";
import { transformProjectV6ToV7 } from "./transformers/v6-to-v7";
export class V6toV7Migration extends StorageMigration {
from = 6;
to = 7;
async transform(project: ProjectRecord): Promise<{
project: ProjectRecord;
skipped: boolean;
reason?: string;
}> {
return transformProjectV6ToV7({ project });
}
}
import { StorageMigration } from "./base";
import type { ProjectRecord } from "./transformers/types";
import { transformProjectV6ToV7 } from "./transformers/v6-to-v7";
export class V6toV7Migration extends StorageMigration {
from = 6;
to = 7;
async transform(project: ProjectRecord): Promise<{
project: ProjectRecord;
skipped: boolean;
reason?: string;
}> {
return transformProjectV6ToV7({ project });
}
}
@@ -1,16 +1,16 @@
import { StorageMigration } from "./base";
import type { ProjectRecord } from "./transformers/types";
import { transformProjectV7ToV8 } from "./transformers/v7-to-v8";
export class V7toV8Migration extends StorageMigration {
from = 7;
to = 8;
async transform(project: ProjectRecord): Promise<{
project: ProjectRecord;
skipped: boolean;
reason?: string;
}> {
return transformProjectV7ToV8({ project });
}
}
import { StorageMigration } from "./base";
import type { ProjectRecord } from "./transformers/types";
import { transformProjectV7ToV8 } from "./transformers/v7-to-v8";
export class V7toV8Migration extends StorageMigration {
from = 7;
to = 8;
async transform(project: ProjectRecord): Promise<{
project: ProjectRecord;
skipped: boolean;
reason?: string;
}> {
return transformProjectV7ToV8({ project });
}
}
@@ -1,16 +1,16 @@
import { StorageMigration } from "./base";
import type { ProjectRecord } from "./transformers/types";
import { transformProjectV8ToV9 } from "./transformers/v8-to-v9";
export class V8toV9Migration extends StorageMigration {
from = 8;
to = 9;
async transform(project: ProjectRecord): Promise<{
project: ProjectRecord;
skipped: boolean;
reason?: string;
}> {
return transformProjectV8ToV9({ project });
}
}
import { StorageMigration } from "./base";
import type { ProjectRecord } from "./transformers/types";
import { transformProjectV8ToV9 } from "./transformers/v8-to-v9";
export class V8toV9Migration extends StorageMigration {
from = 8;
to = 9;
async transform(project: ProjectRecord): Promise<{
project: ProjectRecord;
skipped: boolean;
reason?: string;
}> {
return transformProjectV8ToV9({ project });
}
}
@@ -1,16 +1,16 @@
import { StorageMigration } from "./base";
import type { ProjectRecord } from "./transformers/types";
import { transformProjectV9ToV10 } from "./transformers/v9-to-v10";
export class V9toV10Migration extends StorageMigration {
from = 9;
to = 10;
async transform(project: ProjectRecord): Promise<{
project: ProjectRecord;
skipped: boolean;
reason?: string;
}> {
return transformProjectV9ToV10({ project });
}
}
import { StorageMigration } from "./base";
import type { ProjectRecord } from "./transformers/types";
import { transformProjectV9ToV10 } from "./transformers/v9-to-v10";
export class V9toV10Migration extends StorageMigration {
from = 9;
to = 10;
async transform(project: ProjectRecord): Promise<{
project: ProjectRecord;
skipped: boolean;
reason?: string;
}> {
return transformProjectV9ToV10({ project });
}
}
+145 -145
View File
@@ -1,145 +1,145 @@
const BYTE_UNITS = ["B", "KB", "MB", "GB", "TB"] as const;
export const STORAGE_HEADROOM_RESERVE_BYTES = 50 * 1024 * 1024;
export interface StorageQuotaStatus {
quotaBytes: number | null;
usageBytes: number | null;
headroomBytes: number | null;
availableBytes: number | null;
}
export interface StorageCapacityCheckResult {
canStore: boolean;
reason: "enough-space" | "insufficient-space" | "estimate-unavailable";
availableBytes: number | null;
}
function normalizeByteValue({ value }: { value: unknown }): number | null {
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
return null;
}
return value;
}
export function formatStorageBytes({ bytes }: { bytes: number }): string {
if (!Number.isFinite(bytes) || bytes <= 0) {
return "0 B";
}
let value = bytes;
let unitIndex = 0;
while (value >= 1024 && unitIndex < BYTE_UNITS.length - 1) {
value /= 1024;
unitIndex += 1;
}
const precision = value >= 10 || unitIndex === 0 ? 0 : 1;
return `${value.toFixed(precision)} ${BYTE_UNITS[unitIndex]}`;
}
export async function readStorageQuotaStatus(): Promise<StorageQuotaStatus> {
if (
typeof navigator === "undefined" ||
!navigator.storage ||
typeof navigator.storage.estimate !== "function"
) {
return {
quotaBytes: null,
usageBytes: null,
headroomBytes: null,
availableBytes: null,
};
}
const estimate = await navigator.storage.estimate();
const quotaBytes = normalizeByteValue({ value: estimate.quota });
const usageBytes = normalizeByteValue({ value: estimate.usage });
if (quotaBytes === null || usageBytes === null) {
return {
quotaBytes,
usageBytes,
headroomBytes: null,
availableBytes: null,
};
}
const headroomBytes = Math.max(quotaBytes - usageBytes, 0);
const availableBytes = Math.max(
headroomBytes - STORAGE_HEADROOM_RESERVE_BYTES,
0,
);
return {
quotaBytes,
usageBytes,
headroomBytes,
availableBytes,
};
}
export function evaluateStorageCapacity({
requiredBytes,
quotaStatus,
}: {
requiredBytes: number;
quotaStatus: StorageQuotaStatus;
}): StorageCapacityCheckResult {
if (quotaStatus.availableBytes === null) {
return {
canStore: true,
reason: "estimate-unavailable",
availableBytes: null,
};
}
if (requiredBytes > quotaStatus.availableBytes) {
return {
canStore: false,
reason: "insufficient-space",
availableBytes: quotaStatus.availableBytes,
};
}
return {
canStore: true,
reason: "enough-space",
availableBytes: quotaStatus.availableBytes,
};
}
export class StorageQuotaExceededError extends Error {
requiredBytes: number;
constructor({ requiredBytes }: { requiredBytes: number }) {
super(
`Not enough browser storage to save a ${formatStorageBytes({ bytes: requiredBytes })} file.`,
);
this.name = "StorageQuotaExceededError";
this.requiredBytes = requiredBytes;
}
}
export function isStorageQuotaExceededError({
error,
}: {
error: unknown;
}): boolean {
if (error instanceof StorageQuotaExceededError) {
return true;
}
if (!(error instanceof Error)) {
return false;
}
return (
error.name === "QuotaExceededError" ||
error.name === "NS_ERROR_DOM_QUOTA_REACHED" ||
error.message.toLowerCase().includes("quota")
);
}
const BYTE_UNITS = ["B", "KB", "MB", "GB", "TB"] as const;
export const STORAGE_HEADROOM_RESERVE_BYTES = 50 * 1024 * 1024;
export interface StorageQuotaStatus {
quotaBytes: number | null;
usageBytes: number | null;
headroomBytes: number | null;
availableBytes: number | null;
}
export interface StorageCapacityCheckResult {
canStore: boolean;
reason: "enough-space" | "insufficient-space" | "estimate-unavailable";
availableBytes: number | null;
}
function normalizeByteValue({ value }: { value: unknown }): number | null {
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
return null;
}
return value;
}
export function formatStorageBytes({ bytes }: { bytes: number }): string {
if (!Number.isFinite(bytes) || bytes <= 0) {
return "0 B";
}
let value = bytes;
let unitIndex = 0;
while (value >= 1024 && unitIndex < BYTE_UNITS.length - 1) {
value /= 1024;
unitIndex += 1;
}
const precision = value >= 10 || unitIndex === 0 ? 0 : 1;
return `${value.toFixed(precision)} ${BYTE_UNITS[unitIndex]}`;
}
export async function readStorageQuotaStatus(): Promise<StorageQuotaStatus> {
if (
typeof navigator === "undefined" ||
!navigator.storage ||
typeof navigator.storage.estimate !== "function"
) {
return {
quotaBytes: null,
usageBytes: null,
headroomBytes: null,
availableBytes: null,
};
}
const estimate = await navigator.storage.estimate();
const quotaBytes = normalizeByteValue({ value: estimate.quota });
const usageBytes = normalizeByteValue({ value: estimate.usage });
if (quotaBytes === null || usageBytes === null) {
return {
quotaBytes,
usageBytes,
headroomBytes: null,
availableBytes: null,
};
}
const headroomBytes = Math.max(quotaBytes - usageBytes, 0);
const availableBytes = Math.max(
headroomBytes - STORAGE_HEADROOM_RESERVE_BYTES,
0,
);
return {
quotaBytes,
usageBytes,
headroomBytes,
availableBytes,
};
}
export function evaluateStorageCapacity({
requiredBytes,
quotaStatus,
}: {
requiredBytes: number;
quotaStatus: StorageQuotaStatus;
}): StorageCapacityCheckResult {
if (quotaStatus.availableBytes === null) {
return {
canStore: true,
reason: "estimate-unavailable",
availableBytes: null,
};
}
if (requiredBytes > quotaStatus.availableBytes) {
return {
canStore: false,
reason: "insufficient-space",
availableBytes: quotaStatus.availableBytes,
};
}
return {
canStore: true,
reason: "enough-space",
availableBytes: quotaStatus.availableBytes,
};
}
export class StorageQuotaExceededError extends Error {
requiredBytes: number;
constructor({ requiredBytes }: { requiredBytes: number }) {
super(
`Not enough browser storage to save a ${formatStorageBytes({ bytes: requiredBytes })} file.`,
);
this.name = "StorageQuotaExceededError";
this.requiredBytes = requiredBytes;
}
}
export function isStorageQuotaExceededError({
error,
}: {
error: unknown;
}): boolean {
if (error instanceof StorageQuotaExceededError) {
return true;
}
if (!(error instanceof Error)) {
return false;
}
return (
error.name === "QuotaExceededError" ||
error.name === "NS_ERROR_DOM_QUOTA_REACHED" ||
error.message.toLowerCase().includes("quota")
);
}