diff options
| author | Maze Winther <mazewinther@gmail.com> | 2026-03-25 16:47:22 +0100 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2026-03-29 15:48:22 +0200 |
| commit | 8db3bead13441a5882d8689dfc6a9f09c82375d9 (patch) | |
| tree | e9d0cafa93904ffcbec3188c37961bcc16c6422d /apps/web/src/services/storage | |
| parent | 39ea298a9ccba3104c549c0ca079c231981811f5 (diff) | |
| download | opencut-8db3bead13441a5882d8689dfc6a9f09c82375d9.tar.gz | |
feat: masks, properties refactor, shaders, storage migrations, and more
Diffstat (limited to 'apps/web/src/services/storage')
31 files changed, 1751 insertions, 41 deletions
diff --git a/apps/web/src/services/storage/migrations/__tests__/v15-to-v16.test.ts b/apps/web/src/services/storage/migrations/__tests__/v15-to-v16.test.ts new file mode 100644 index 00000000..f2a4908f --- /dev/null +++ b/apps/web/src/services/storage/migrations/__tests__/v15-to-v16.test.ts @@ -0,0 +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"); + }); +}); diff --git a/apps/web/src/services/storage/migrations/__tests__/v16-to-v17.test.ts b/apps/web/src/services/storage/migrations/__tests__/v16-to-v17.test.ts new file mode 100644 index 00000000..e246943e --- /dev/null +++ b/apps/web/src/services/storage/migrations/__tests__/v16-to-v17.test.ts @@ -0,0 +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"); + }); +}); diff --git a/apps/web/src/services/storage/migrations/__tests__/v18-to-v19.test.ts b/apps/web/src/services/storage/migrations/__tests__/v18-to-v19.test.ts new file mode 100644 index 00000000..7524a8d4 --- /dev/null +++ b/apps/web/src/services/storage/migrations/__tests__/v18-to-v19.test.ts @@ -0,0 +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"); + }); +}); diff --git a/apps/web/src/services/storage/migrations/index.ts b/apps/web/src/services/storage/migrations/index.ts index bffacb66..1a855287 100644 --- a/apps/web/src/services/storage/migrations/index.ts +++ b/apps/web/src/services/storage/migrations/index.ts @@ -8,10 +8,20 @@ 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 = 9; +export const CURRENT_PROJECT_VERSION = 19; export const migrations = [ new V0toV1Migration(), @@ -23,4 +33,14 @@ export const migrations = [ 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(), ]; diff --git a/apps/web/src/services/storage/migrations/transformers/index.ts b/apps/web/src/services/storage/migrations/transformers/index.ts index 8878d6ba..c1ae894f 100644 --- a/apps/web/src/services/storage/migrations/transformers/index.ts +++ b/apps/web/src/services/storage/migrations/transformers/index.ts @@ -3,4 +3,5 @@ export { transformProjectV1ToV2 } from "./v1-to-v2"; export { transformProjectV2ToV3 } from "./v2-to-v3"; export { transformProjectV3ToV4 } from "./v3-to-v4"; export { transformProjectV4ToV5 } from "./v4-to-v5"; +export { transformProjectV17ToV18 } from "./v17-to-v18"; export type { MigrationResult, ProjectRecord } from "./types"; diff --git a/apps/web/src/services/storage/migrations/transformers/v0-to-v1.ts b/apps/web/src/services/storage/migrations/transformers/v0-to-v1.ts index a26feb3b..b1fcf00b 100644 --- a/apps/web/src/services/storage/migrations/transformers/v0-to-v1.ts +++ b/apps/web/src/services/storage/migrations/transformers/v0-to-v1.ts @@ -1,8 +1,17 @@ import { generateUUID } from "@/utils/id"; -import type { SerializedScene } from "@/services/storage/types"; import type { MigrationResult, ProjectRecord } from "./types"; import { isRecord } from "./utils"; +interface V1Scene { + id: string; + name: string; + isMain: boolean; + tracks: unknown[]; + bookmarks: unknown[]; + createdAt: string; + updatedAt: string; +} + export interface TransformV0ToV1Options { now?: Date; } @@ -25,7 +34,7 @@ export function transformProjectV0ToV1({ const sceneCreatedAt = now.toISOString(); const sceneUpdatedAt = now.toISOString(); - const mainScene: SerializedScene = { + const mainScene: V1Scene = { id: sceneId, name: "Main scene", isMain: true, diff --git a/apps/web/src/services/storage/migrations/transformers/v1-to-v2.ts b/apps/web/src/services/storage/migrations/transformers/v1-to-v2.ts index 297d1c58..52701cd5 100644 --- a/apps/web/src/services/storage/migrations/transformers/v1-to-v2.ts +++ b/apps/web/src/services/storage/migrations/transformers/v1-to-v2.ts @@ -6,14 +6,6 @@ import { } from "@/constants/project-constants"; import { IndexedDBAdapter } from "@/services/storage/indexeddb-adapter"; import type { MediaAssetData } from "@/services/storage/types"; -import type { - AudioElement, - ImageElement, - TextElement, - TimelineTrack, - Transform, - VideoElement, -} from "@/types/timeline"; import type { MigrationResult, ProjectRecord } from "./types"; import { getProjectId, isRecord } from "./utils"; @@ -50,6 +42,112 @@ interface LegacyMediaTrack { [key: string]: unknown; } +interface V2Transform { + scale: number; + position: { x: number; y: number }; + rotate: number; +} + +interface V2VideoElement { + id: string; + name: string; + type: "video"; + mediaId: string; + muted: boolean; + hidden: boolean; + transform: V2Transform; + opacity: number; + duration: number; + startTime: number; + trimStart: number; + trimEnd: number; +} + +interface V2ImageElement { + id: string; + name: string; + type: "image"; + mediaId: string; + duration: number; + startTime: number; + trimStart: number; + trimEnd: number; + hidden: boolean; + transform: V2Transform; + opacity: number; +} + +interface V2TextElement { + id: string; + name: string; + type: "text"; + content: string; + fontSize: number; + fontFamily: string; + color: string; + background: { + enabled: boolean; + color: string; + cornerRadius: number; + paddingX: number; + paddingY: number; + offsetX: number; + offsetY: number; + }; + textAlign: "left" | "center" | "right"; + fontWeight: "normal" | "bold"; + fontStyle: "normal" | "italic"; + textDecoration: "none" | "underline" | "line-through"; + hidden: boolean; + transform: V2Transform; + opacity: number; + duration: number; + startTime: number; + trimStart: number; + trimEnd: number; +} + +interface V2AudioElement { + id: string; + name: string; + type: "audio"; + sourceType: "upload"; + mediaId: string; + volume: number; + duration: number; + startTime: number; + trimStart: number; + trimEnd: number; +} + +interface V2VideoTrack { + id: string; + name: string; + type: "video"; + elements: (V2VideoElement | V2ImageElement)[]; + isMain: boolean; + muted: boolean; + hidden: boolean; +} + +interface V2TextTrack { + id: string; + name: string; + type: "text"; + elements: V2TextElement[]; + hidden: boolean; +} + +interface V2AudioTrack { + id: string; + name: string; + type: "audio"; + elements: V2AudioElement[]; + muted: boolean; +} + +type V2TimelineTrack = V2VideoTrack | V2TextTrack | V2AudioTrack; + export interface TransformV1ToV2Options { loadMediaAsset?: ({ mediaId, @@ -259,13 +357,13 @@ async function transformTracks({ }: { mediaId: string; }) => Promise<MediaAssetData | null>; -}): Promise<TimelineTrack[]> { +}): Promise<V2TimelineTrack[]> { if (!Array.isArray(tracks)) { return []; } let isFirstVideoTrackFound = false; - const transformedTracks: (TimelineTrack | null)[] = []; + const transformedTracks: (V2TimelineTrack | null)[] = []; for (const track of tracks) { if (!isRecord(track)) { @@ -299,7 +397,7 @@ async function transformTracks({ } return transformedTracks.filter( - (track): track is TimelineTrack => track !== null, + (track): track is V2TimelineTrack => track !== null, ); } @@ -315,7 +413,7 @@ async function transformMediaTrack({ mediaId: string; }) => Promise<MediaAssetData | null>; isMain: boolean; -}): Promise<TimelineTrack> { +}): Promise<V2VideoTrack> { const elements = Array.isArray(track.elements) ? track.elements : []; const transformedElements = await Promise.all( @@ -338,7 +436,7 @@ async function transformMediaTrack({ } } - const defaultTransform: Transform = { + const defaultTransform: V2Transform = { scale: 1, position: { x: 0, y: 0 }, rotate: 0, @@ -347,7 +445,7 @@ async function transformMediaTrack({ const muted = mediaElement.muted === true; if (mediaType === "image") { - const imageElement: ImageElement = { + const imageElement: V2ImageElement = { id: getStringValue({ value: element.id, fallback: "" }), name: getStringValue({ value: element.name, fallback: "" }), type: "image", @@ -369,7 +467,7 @@ async function transformMediaTrack({ return imageElement; } - const videoElement: VideoElement = { + const videoElement: V2VideoElement = { id: getStringValue({ value: element.id, fallback: "" }), name: getStringValue({ value: element.name, fallback: "" }), type: "video", @@ -388,7 +486,7 @@ async function transformMediaTrack({ ); const validElements = transformedElements.filter( - (element): element is VideoElement | ImageElement => element !== null, + (element): element is V2VideoElement | V2ImageElement => element !== null, ); return { @@ -406,11 +504,11 @@ function transformTextTrack({ track, }: { track: Record<string, unknown>; -}): TimelineTrack { +}): V2TextTrack { const elements = Array.isArray(track.elements) ? track.elements : []; const transformedElements = elements - .map((element): TextElement | null => { + .map((element): V2TextElement | null => { if (!isRecord(element) || element.type !== "text") { return null; } @@ -427,7 +525,7 @@ function transformTextTrack({ fallback: 1, }); - const transform: Transform = { + const transform: V2Transform = { scale: 1, position: { x, y }, rotate: rotation, @@ -487,7 +585,7 @@ function transformTextTrack({ trimEnd: getNumberValue({ value: element.trimEnd, fallback: 0 }), }; }) - .filter((element): element is TextElement => element !== null); + .filter((element): element is V2TextElement => element !== null); return { id: getStringValue({ value: track.id, fallback: "" }), @@ -502,11 +600,11 @@ function transformAudioTrack({ track, }: { track: Record<string, unknown>; -}): TimelineTrack { +}): V2AudioTrack { const elements = Array.isArray(track.elements) ? track.elements : []; const transformedElements = elements - .map((element): AudioElement | null => { + .map((element): V2AudioElement | null => { if (!isRecord(element) || element.type !== "audio") { return null; } @@ -530,7 +628,7 @@ function transformAudioTrack({ trimEnd: getNumberValue({ value: element.trimEnd, fallback: 0 }), }; }) - .filter((element): element is AudioElement => element !== null); + .filter((element): element is V2AudioElement => element !== null); return { id: getStringValue({ value: track.id, fallback: "" }), diff --git a/apps/web/src/services/storage/migrations/transformers/v10-to-v11.ts b/apps/web/src/services/storage/migrations/transformers/v10-to-v11.ts new file mode 100644 index 00000000..b48f5e88 --- /dev/null +++ b/apps/web/src/services/storage/migrations/transformers/v10-to-v11.ts @@ -0,0 +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; +} diff --git a/apps/web/src/services/storage/migrations/transformers/v11-to-v12.ts b/apps/web/src/services/storage/migrations/transformers/v11-to-v12.ts new file mode 100644 index 00000000..fa1bd371 --- /dev/null +++ b/apps/web/src/services/storage/migrations/transformers/v11-to-v12.ts @@ -0,0 +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; +} diff --git a/apps/web/src/services/storage/migrations/transformers/v12-to-v13.ts b/apps/web/src/services/storage/migrations/transformers/v12-to-v13.ts new file mode 100644 index 00000000..59b29732 --- /dev/null +++ b/apps/web/src/services/storage/migrations/transformers/v12-to-v13.ts @@ -0,0 +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; +} diff --git a/apps/web/src/services/storage/migrations/transformers/v13-to-v14.ts b/apps/web/src/services/storage/migrations/transformers/v13-to-v14.ts new file mode 100644 index 00000000..01a3cc3e --- /dev/null +++ b/apps/web/src/services/storage/migrations/transformers/v13-to-v14.ts @@ -0,0 +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 }, + }; +} diff --git a/apps/web/src/services/storage/migrations/transformers/v14-to-v15.ts b/apps/web/src/services/storage/migrations/transformers/v14-to-v15.ts new file mode 100644 index 00000000..beef05d4 --- /dev/null +++ b/apps/web/src/services/storage/migrations/transformers/v14-to-v15.ts @@ -0,0 +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 }; +} diff --git a/apps/web/src/services/storage/migrations/transformers/v15-to-v16.ts b/apps/web/src/services/storage/migrations/transformers/v15-to-v16.ts new file mode 100644 index 00000000..15e2f3bb --- /dev/null +++ b/apps/web/src/services/storage/migrations/transformers/v15-to-v16.ts @@ -0,0 +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, + }; +} diff --git a/apps/web/src/services/storage/migrations/transformers/v16-to-v17.ts b/apps/web/src/services/storage/migrations/transformers/v16-to-v17.ts new file mode 100644 index 00000000..7fec70e9 --- /dev/null +++ b/apps/web/src/services/storage/migrations/transformers/v16-to-v17.ts @@ -0,0 +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, + }; +} diff --git a/apps/web/src/services/storage/migrations/transformers/v17-to-v18.ts b/apps/web/src/services/storage/migrations/transformers/v17-to-v18.ts new file mode 100644 index 00000000..31a60121 --- /dev/null +++ b/apps/web/src/services/storage/migrations/transformers/v17-to-v18.ts @@ -0,0 +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)); +} diff --git a/apps/web/src/services/storage/migrations/transformers/v18-to-v19.ts b/apps/web/src/services/storage/migrations/transformers/v18-to-v19.ts new file mode 100644 index 00000000..1946b829 --- /dev/null +++ b/apps/web/src/services/storage/migrations/transformers/v18-to-v19.ts @@ -0,0 +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, + }; +} diff --git a/apps/web/src/services/storage/migrations/transformers/v2-to-v3.ts b/apps/web/src/services/storage/migrations/transformers/v2-to-v3.ts index 094b8bd8..0807d4fb 100644 --- a/apps/web/src/services/storage/migrations/transformers/v2-to-v3.ts +++ b/apps/web/src/services/storage/migrations/transformers/v2-to-v3.ts @@ -1,5 +1,3 @@ -import { getProjectDurationFromScenes } from "@/lib/scenes"; -import type { TScene } from "@/types/timeline"; import type { MigrationResult, ProjectRecord } from "./types"; import { getProjectId, isRecord } from "./utils"; @@ -18,7 +16,7 @@ export function transformProjectV2ToV3({ } const scenes = getScenes({ project }); - const duration = getProjectDurationFromScenes({ scenes }); + const duration = getDurationFromScenes({ scenes }); const metadataValue = project.metadata; const metadata = isRecord(metadataValue) @@ -36,13 +34,42 @@ export function transformProjectV2ToV3({ export { getProjectId } from "./utils"; -function getScenes({ project }: { project: ProjectRecord }): TScene[] { +function getScenes({ project }: { project: ProjectRecord }): unknown[] { const scenesValue = project.scenes; if (!Array.isArray(scenesValue)) { return []; } - return scenesValue.filter(isRecord) as unknown as TScene[]; + return scenesValue.filter(isRecord); +} + +function getDurationFromScenes({ scenes }: { scenes: unknown[] }): number { + const mainScene = + scenes.find( + (s): s is Record<string, unknown> => + isRecord(s) && s.isMain === true, + ) ?? + scenes.find(isRecord) ?? + null; + + if (!mainScene || !Array.isArray(mainScene.tracks)) { + return 0; + } + + let maxEnd = 0; + for (const track of mainScene.tracks) { + if (!isRecord(track) || !Array.isArray(track.elements)) continue; + for (const element of track.elements) { + if (!isRecord(element)) continue; + const startTime = + typeof element.startTime === "number" ? element.startTime : 0; + const duration = + typeof element.duration === "number" ? element.duration : 0; + maxEnd = Math.max(maxEnd, startTime + duration); + } + } + + return maxEnd; } function isV3Project({ project }: { project: ProjectRecord }): boolean { diff --git a/apps/web/src/services/storage/migrations/transformers/v9-to-v10.ts b/apps/web/src/services/storage/migrations/transformers/v9-to-v10.ts new file mode 100644 index 00000000..52d13295 --- /dev/null +++ b/apps/web/src/services/storage/migrations/transformers/v9-to-v10.ts @@ -0,0 +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, + }; +} diff --git a/apps/web/src/services/storage/migrations/v10-to-v11.ts b/apps/web/src/services/storage/migrations/v10-to-v11.ts new file mode 100644 index 00000000..68439fbe --- /dev/null +++ b/apps/web/src/services/storage/migrations/v10-to-v11.ts @@ -0,0 +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 }); + } +} diff --git a/apps/web/src/services/storage/migrations/v11-to-v12.ts b/apps/web/src/services/storage/migrations/v11-to-v12.ts new file mode 100644 index 00000000..5efedbef --- /dev/null +++ b/apps/web/src/services/storage/migrations/v11-to-v12.ts @@ -0,0 +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 }); + } +} diff --git a/apps/web/src/services/storage/migrations/v12-to-v13.ts b/apps/web/src/services/storage/migrations/v12-to-v13.ts new file mode 100644 index 00000000..5c6e05fc --- /dev/null +++ b/apps/web/src/services/storage/migrations/v12-to-v13.ts @@ -0,0 +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 }); + } +} diff --git a/apps/web/src/services/storage/migrations/v13-to-v14.ts b/apps/web/src/services/storage/migrations/v13-to-v14.ts new file mode 100644 index 00000000..fbc9fc5f --- /dev/null +++ b/apps/web/src/services/storage/migrations/v13-to-v14.ts @@ -0,0 +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 }); + } +} diff --git a/apps/web/src/services/storage/migrations/v14-to-v15.ts b/apps/web/src/services/storage/migrations/v14-to-v15.ts new file mode 100644 index 00000000..1d18423f --- /dev/null +++ b/apps/web/src/services/storage/migrations/v14-to-v15.ts @@ -0,0 +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 }); + } +} diff --git a/apps/web/src/services/storage/migrations/v15-to-v16.ts b/apps/web/src/services/storage/migrations/v15-to-v16.ts new file mode 100644 index 00000000..800c9c07 --- /dev/null +++ b/apps/web/src/services/storage/migrations/v15-to-v16.ts @@ -0,0 +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 }); + } +} diff --git a/apps/web/src/services/storage/migrations/v16-to-v17.ts b/apps/web/src/services/storage/migrations/v16-to-v17.ts new file mode 100644 index 00000000..f4029db6 --- /dev/null +++ b/apps/web/src/services/storage/migrations/v16-to-v17.ts @@ -0,0 +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 }); + } +} diff --git a/apps/web/src/services/storage/migrations/v17-to-v18.ts b/apps/web/src/services/storage/migrations/v17-to-v18.ts new file mode 100644 index 00000000..75beb36b --- /dev/null +++ b/apps/web/src/services/storage/migrations/v17-to-v18.ts @@ -0,0 +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 }); + } +} diff --git a/apps/web/src/services/storage/migrations/v18-to-v19.ts b/apps/web/src/services/storage/migrations/v18-to-v19.ts new file mode 100644 index 00000000..166d9855 --- /dev/null +++ b/apps/web/src/services/storage/migrations/v18-to-v19.ts @@ -0,0 +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 }); + } +} diff --git a/apps/web/src/services/storage/migrations/v9-to-v10.ts b/apps/web/src/services/storage/migrations/v9-to-v10.ts new file mode 100644 index 00000000..801096fe --- /dev/null +++ b/apps/web/src/services/storage/migrations/v9-to-v10.ts @@ -0,0 +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 }); + } +} diff --git a/apps/web/src/services/storage/quota.ts b/apps/web/src/services/storage/quota.ts new file mode 100644 index 00000000..1eb6028e --- /dev/null +++ b/apps/web/src/services/storage/quota.ts @@ -0,0 +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") + ); +} diff --git a/apps/web/src/services/storage/service.ts b/apps/web/src/services/storage/service.ts index aac152da..3a014894 100644 --- a/apps/web/src/services/storage/service.ts +++ b/apps/web/src/services/storage/service.ts @@ -1,20 +1,27 @@ -import type { TProject, TProjectMetadata } from "@/types/project"; +import type { TProject, TProjectMetadata } from "@/lib/project/types"; import { getProjectDurationFromScenes } from "@/lib/scenes"; -import type { MediaAsset } from "@/types/assets"; +import type { MediaAsset } from "@/lib/media/types"; import { IndexedDBAdapter } from "./indexeddb-adapter"; import { OPFSAdapter } from "./opfs-adapter"; +import { + type StorageCapacityCheckResult, + StorageQuotaExceededError, + evaluateStorageCapacity, + isStorageQuotaExceededError, + readStorageQuotaStatus, +} from "./quota"; import type { MediaAssetData, StorageConfig, SerializedProject, SerializedScene, } from "./types"; -import type { SavedSoundsData, SavedSound, SoundEffect } from "@/types/sounds"; +import type { SavedSoundsData, SavedSound, SoundEffect } from "@/lib/sounds/types"; import { migrations, runStorageMigrations, } from "@/services/storage/migrations"; -import type { Bookmark, TimelineTrack, TScene } from "@/types/timeline"; +import type { Bookmark, TimelineTrack, TScene } from "@/lib/timeline"; function normalizeBookmarks({ raw }: { raw: unknown }): Bookmark[] { if (!Array.isArray(raw)) return []; @@ -90,6 +97,22 @@ class StorageService { return { mediaMetadataAdapter, mediaAssetsAdapter }; } + async canStoreFile({ + size, + }: { + size: number; + }): Promise<StorageCapacityCheckResult> { + const quotaStatus = await readStorageQuotaStatus(); + return evaluateStorageCapacity({ + requiredBytes: size, + quotaStatus, + }); + } + + isQuotaExceededError({ error }: { error: unknown }): boolean { + return isStorageQuotaExceededError({ error }); + } + private stripAudioBuffers({ tracks, }: { @@ -238,8 +261,6 @@ class StorageService { const { mediaMetadataAdapter, mediaAssetsAdapter } = this.getProjectMediaAdapters({ projectId }); - await mediaAssetsAdapter.set(mediaAsset.id, mediaAsset.file); - const metadata: MediaAssetData = { id: mediaAsset.id, name: mediaAsset.name, @@ -253,7 +274,24 @@ class StorageService { ephemeral: mediaAsset.ephemeral, }; - await mediaMetadataAdapter.set(mediaAsset.id, metadata); + try { + await mediaAssetsAdapter.set(mediaAsset.id, mediaAsset.file); + await mediaMetadataAdapter.set(mediaAsset.id, metadata); + } catch (error) { + try { + await mediaAssetsAdapter.remove(mediaAsset.id); + } catch { + // Ignore cleanup failures so the original storage error is preserved. + } + + if (this.isQuotaExceededError({ error })) { + throw new StorageQuotaExceededError({ + requiredBytes: mediaAsset.file.size, + }); + } + + throw error; + } } async loadMediaAsset({ diff --git a/apps/web/src/services/storage/types.ts b/apps/web/src/services/storage/types.ts index 7481c73c..1dcf6545 100644 --- a/apps/web/src/services/storage/types.ts +++ b/apps/web/src/services/storage/types.ts @@ -1,10 +1,10 @@ -import type { MediaType } from "@/types/assets"; +import type { MediaType } from "@/lib/media/types"; import type { TProject, TProjectMetadata, TTimelineViewState, -} from "@/types/project"; -import type { TScene } from "@/types/timeline"; +} from "@/lib/project/types"; +import type { TScene } from "@/lib/timeline"; export interface StorageAdapter<T> { get(key: string): Promise<T | null>; @@ -24,6 +24,7 @@ export interface MediaAssetData { height?: number; duration?: number; fps?: number; + hasAudio?: boolean; ephemeral?: boolean; thumbnailUrl?: string; } |
