make storage versioning per-project + few other refactors

This commit is contained in:
Maze Winther 2026-02-05 12:53:56 +01:00
parent 490fc170c0
commit 2257ca2e6f
17 changed files with 301 additions and 540 deletions

View File

@ -454,115 +454,115 @@ rate-limit.ts
scenes.ts
export function getMainScene({ scenes }: { scenes: TScene[] }): TScene | null
export function ensureMainScene({ scenes }: { scenes: TScene[] }): TScene[]
export function buildDefaultScene({
name,
isMain,
}: {
name: string;
isMain: boolean;
export function buildDefaultScene({
name,
isMain,
}: {
name: string;
isMain: boolean;
}): TScene
export function canDeleteScene({ scene }: { scene: TScene }): {
canDelete: boolean;
reason?: string;
export function canDeleteScene({ scene }: { scene: TScene }): {
canDelete: boolean;
reason?: string;
}
export function getFallbackSceneAfterDelete({
scenes,
deletedSceneId,
currentSceneId,
}: {
scenes: TScene[];
deletedSceneId: string;
currentSceneId: string | null;
export function getFallbackSceneAfterDelete({
scenes,
deletedSceneId,
currentSceneId,
}: {
scenes: TScene[];
deletedSceneId: string;
currentSceneId: string | null;
}): TScene | null
export function findCurrentScene({
scenes,
currentSceneId,
}: {
scenes: TScene[];
currentSceneId: string;
export function findCurrentScene({
scenes,
currentSceneId,
}: {
scenes: TScene[];
currentSceneId: string;
}): TScene | null
export function getProjectDurationFromScenes({
scenes,
}: {
scenes: TScene[];
export function getProjectDurationFromScenes({
scenes,
}: {
scenes: TScene[];
}): number
export function updateSceneInArray({
scenes,
sceneId,
updates,
}: {
scenes: TScene[];
sceneId: string;
updates: Partial<TScene>;
export function updateSceneInArray({
scenes,
sceneId,
updates,
}: {
scenes: TScene[];
sceneId: string;
updates: Partial<TScene>;
}): TScene[]
time.ts
export function roundToFrame({
time,
fps,
}: {
time: number;
fps: number;
export function roundToFrame({
time,
fps,
}: {
time: number;
fps: number;
}): number
export function formatTimeCode({
timeInSeconds,
format = "HH:MM:SS:CS",
fps,
}: {
timeInSeconds: number;
format?: TTimeCode;
fps?: number;
export function formatTimeCode({
timeInSeconds,
format = "HH:MM:SS:CS",
fps,
}: {
timeInSeconds: number;
format?: TTimeCode;
fps?: number;
}): string
export function parseTimeCode({
timeCode,
format = "HH:MM:SS:CS",
fps,
}: {
timeCode: string;
format?: TTimeCode;
fps: number;
export function parseTimeCode({
timeCode,
format = "HH:MM:SS:CS",
fps,
}: {
timeCode: string;
format?: TTimeCode;
fps: number;
}): number | null
export function guessTimeCodeFormat({
timeCode,
}: {
timeCode: string;
export function guessTimeCodeFormat({
timeCode,
}: {
timeCode: string;
}): TTimeCode | null
export function timeToFrame({
time,
fps,
}: {
time: number;
fps: number;
export function timeToFrame({
time,
fps,
}: {
time: number;
fps: number;
}): number
export function frameToTime({
frame,
fps,
}: {
frame: number;
fps: number;
export function frameToTime({
frame,
fps,
}: {
frame: number;
fps: number;
}): number
export function snapTimeToFrame({
time,
fps,
}: {
time: number;
fps: number;
export function snapTimeToFrame({
time,
fps,
}: {
time: number;
fps: number;
}): number
export function getSnappedSeekTime({
rawTime,
duration,
fps,
}: {
rawTime: number;
duration: number;
fps: number;
export function getSnappedSeekTime({
rawTime,
duration,
fps,
}: {
rawTime: number;
duration: number;
fps: number;
}): number
export function getLastFrameTime({
duration,
fps,
}: {
duration: number;
fps: number;
export function getLastFrameTime({
duration,
fps,
}: {
duration: number;
fps: number;
}): number
## apps/web/src/lib/actions
@ -689,28 +689,27 @@ parser.ts
## apps/web/src/lib/media
audio.ts
export type CollectedAudioElement = Omit<
AudioElement,
"type" | "mediaId" | "volume" | "id" | "name" | "sourceType" | "sourceUrl"
...
export type CollectedAudioElement = Omit<
AudioElement,
"type" | "mediaId" | "volume" | "id" | "name" | "sourceType" | "sourceUrl...
export function createAudioContext(): AudioContext
export interface DecodedAudio {
samples: Float32Array
sampleRate: number
}
export function decodeAudioToFloat32({
audioBlob,
}: {
audioBlob: Blob;
export function decodeAudioToFloat32({
audioBlob,
}: {
audioBlob: Blob;
}): Promise<DecodedAudio>
export function collectAudioElements({
tracks,
mediaAssets,
audioContext,
}: {
tracks: TimelineTrack[];
mediaAssets: MediaAsset[];
audioContext: AudioContext;
export function collectAudioElements({
tracks,
mediaAssets,
audioContext,
}: {
tracks: TimelineTrack[];
mediaAssets: MediaAsset[];
audioContext: AudioContext;
}): Promise<CollectedAudioElement[]>
export interface AudioClipSource {
id: string
@ -722,32 +721,32 @@ audio.ts
trimEnd: number
muted: boolean
}
export function collectAudioMixSources({
tracks,
mediaAssets,
}: {
tracks: TimelineTrack[];
mediaAssets: MediaAsset[];
export function collectAudioMixSources({
tracks,
mediaAssets,
}: {
tracks: TimelineTrack[];
mediaAssets: MediaAsset[];
}): Promise<AudioMixSource[]>
export function collectAudioClips({
tracks,
mediaAssets,
}: {
tracks: TimelineTrack[];
mediaAssets: MediaAsset[];
export function collectAudioClips({
tracks,
mediaAssets,
}: {
tracks: TimelineTrack[];
mediaAssets: MediaAsset[];
}): Promise<AudioClipSource[]>
export function createTimelineAudioBuffer({
tracks,
mediaAssets,
duration,
sampleRate = 44100,
audioContext,
}: {
tracks: TimelineTrack[];
mediaAssets: MediaAsset[];
duration: number;
sampleRate?: number;
audioContext?: AudioContext;
export function createTimelineAudioBuffer({
tracks,
mediaAssets,
duration,
sampleRate = 44100,
audioContext,
}: {
tracks: TimelineTrack[];
mediaAssets: MediaAsset[];
duration: number;
sampleRate?: number;
audioContext?: AudioContext;
}): Promise<AudioBuffer | null>
media-utils.ts
@ -977,10 +976,10 @@ element-utils.ts
}): { trackId: string; elementId: string }[]
index.ts
export function calculateTotalDuration({
tracks,
}: {
tracks: TimelineTrack[];
export function calculateTotalDuration({
tracks,
}: {
tracks: TimelineTrack[];
}): number
ruler-utils.ts
@ -1220,6 +1219,11 @@ indexeddb-adapter.ts
async getAll(): Promise<T[]>
async clear(): Promise<void>
}
export function deleteDatabase({
dbName,
}: {
dbName: string;
}): Promise<void>
opfs-adapter.ts
export class OPFSAdapter implements StorageAdapter<File> {
@ -1256,7 +1260,6 @@ types.ts
fps?: number
ephemeral?: boolean
thumbnailUrl?: string
sourceStickerIconName?: string
}
export type SerializedScene = Omit<TScene, "createdAt" | "updatedAt"> & {
createdAt: string;

View File

@ -21,7 +21,7 @@ import { buildDefaultScene, getProjectDurationFromScenes } from "@/lib/scenes";
import { buildScene } from "@/services/renderer/scene-builder";
import { CanvasRenderer } from "@/services/renderer/canvas-renderer";
import {
CURRENT_STORAGE_VERSION,
CURRENT_PROJECT_VERSION,
migrations,
runStorageMigrations,
} from "@/services/storage/migrations";
@ -58,31 +58,7 @@ export class ProjectManager {
}
this.storageMigrationPromise = (async () => {
let hasShownState = false;
await runStorageMigrations({
migrations,
callbacks: {
onMigrationStart: ({ fromVersion, toVersion }) => {
hasShownState = true;
this.setMigrationState({
isMigrating: true,
fromVersion,
toVersion,
projectName: null,
});
},
},
});
if (hasShownState) {
this.setMigrationState({
isMigrating: false,
fromVersion: null,
toVersion: null,
projectName: null,
});
}
await runStorageMigrations({ migrations });
})();
await this.storageMigrationPromise;
@ -109,7 +85,7 @@ export class ProjectManager {
color: DEFAULT_COLOR,
},
},
version: CURRENT_STORAGE_VERSION,
version: CURRENT_PROJECT_VERSION,
};
this.active = newProject;
@ -572,11 +548,6 @@ export class ProjectManager {
return this.migrationState;
}
private setMigrationState(state: Partial<MigrationState>): void {
this.migrationState = { ...this.migrationState, ...state };
this.notify();
}
setActiveProject({ project }: { project: TProject }): void {
this.active = project;
this.notify();

View File

@ -1,10 +1,20 @@
import type { CanvasRenderer } from "../canvas-renderer";
import { BaseNode } from "./base-node";
import type { BaseMediaNodeParams } from "./video-node";
const IMAGE_EPSILON = 1 / 1000;
export type ImageNodeParams = BaseMediaNodeParams;
export interface ImageNodeParams {
url: string;
duration: number;
timeOffset: number;
trimStart: number;
trimEnd: number;
x?: number;
y?: number;
width?: number;
height?: number;
opacity?: number;
}
export class ImageNode extends BaseNode<ImageNodeParams> {
private image?: HTMLImageElement;
@ -18,15 +28,12 @@ export class ImageNode extends BaseNode<ImageNodeParams> {
private async load() {
const image = new Image();
this.image = image;
const url = URL.createObjectURL(this.params.file);
await new Promise<void>((resolve, reject) => {
image.onload = () => resolve();
image.onerror = () => reject(new Error("Image load failed"));
image.src = url;
image.src = this.params.url;
});
URL.revokeObjectURL(url);
}
private getImageTime(time: number) {

View File

@ -4,8 +4,10 @@ import { videoCache } from "@/services/video-cache/service";
const VIDEO_EPSILON = 1 / 1000;
export interface BaseMediaNodeParams {
export interface VideoNodeParams {
url: string;
file: File;
mediaId: string;
duration: number;
timeOffset: number;
trimStart: number;
@ -17,10 +19,6 @@ export interface BaseMediaNodeParams {
opacity?: number;
}
export interface VideoNodeParams extends BaseMediaNodeParams {
mediaId: string;
}
export class VideoNode extends BaseNode<VideoNodeParams> {
private getVideoTime(time: number) {
return time - this.params.timeOffset + this.params.trimStart;

View File

@ -51,7 +51,7 @@ export function buildScene(params: BuildSceneParams) {
for (const element of elements) {
if (element.type === "video" || element.type === "image") {
const mediaAsset = mediaMap.get(element.mediaId);
if (!mediaAsset?.file) {
if (!mediaAsset?.file || !mediaAsset?.url) {
continue;
}
@ -59,6 +59,7 @@ export function buildScene(params: BuildSceneParams) {
contentNodes.push(
new VideoNode({
mediaId: mediaAsset.id,
url: mediaAsset.url,
file: mediaAsset.file,
duration: element.duration,
timeOffset: element.startTime,
@ -70,7 +71,7 @@ export function buildScene(params: BuildSceneParams) {
if (mediaAsset.type === "image") {
contentNodes.push(
new ImageNode({
file: mediaAsset.file,
url: mediaAsset.url,
duration: element.duration,
timeOffset: element.startTime,
trimStart: element.trimStart,

View File

@ -1,5 +1,9 @@
import type { MigrationResult, ProjectRecord } from "./transformers/types";
export abstract class StorageMigration {
abstract from: number;
abstract to: number;
abstract run(): Promise<void>;
abstract transform(
project: ProjectRecord,
): Promise<MigrationResult<ProjectRecord>>;
}

View File

@ -1,11 +1,10 @@
export { StorageMigration } from "./base";
export { StorageVersionManager } from "./version-manager";
import { V0toV1Migration } from "./v0-to-v1";
import { V1toV2Migration } from "./v1-to-v2";
import { V2toV3Migration } from "./v2-to-v3";
export { runStorageMigrations } from "./runner";
export const CURRENT_STORAGE_VERSION = 3;
export const CURRENT_PROJECT_VERSION = 3;
export const migrations = [
new V0toV1Migration(),

View File

@ -1,138 +1,94 @@
import { IndexedDBAdapter } from "@/services/storage/indexeddb-adapter";
import {
IndexedDBAdapter,
deleteDatabase,
} from "@/services/storage/indexeddb-adapter";
import type { StorageMigration } from "./base";
import { StorageVersionManager } from "./version-manager";
import type { ProjectRecord } from "./transformers/types";
import { getProjectId } from "./transformers/utils";
export interface StorageMigrationResult {
fromVersion: number;
toVersion: number;
migrated: boolean;
migratedCount: number;
}
export type StorageMigrationCallbacks = {
onMigrationStart?: ({
fromVersion,
toVersion,
}: {
fromVersion: number;
toVersion: number;
}) => void;
onMigrationComplete?: ({
fromVersion,
toVersion,
}: {
fromVersion: number;
toVersion: number;
}) => void;
};
type ProjectRecord = Record<string, unknown>;
let hasCleanedUpMetaDb = false;
export async function runStorageMigrations({
migrations,
versionManager = new StorageVersionManager(),
callbacks,
}: {
migrations: StorageMigration[];
versionManager?: StorageVersionManager;
callbacks?: StorageMigrationCallbacks;
}): Promise<StorageMigrationResult> {
const versionRecord = await versionManager.getVersionRecord();
const inferredVersion = versionRecord
? null
: await inferStorageVersionFromProjects();
const fromVersion =
versionRecord?.inProgress?.from ??
versionRecord?.version ??
inferredVersion ??
0;
if (!versionRecord) {
await versionManager.setVersion({ version: fromVersion });
}
const orderedMigrations = [...migrations].sort((a, b) => a.from - b.from);
let currentVersion = fromVersion;
for (const migration of orderedMigrations) {
if (migration.from !== currentVersion) {
continue;
// 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
}
await versionManager.setInProgress({
from: migration.from,
to: migration.to,
});
callbacks?.onMigrationStart?.({
fromVersion: migration.from,
toVersion: migration.to,
});
await migration.run();
currentVersion = migration.to;
await versionManager.setVersion({ version: currentVersion });
await versionManager.clearInProgress();
callbacks?.onMigrationComplete?.({
fromVersion: migration.from,
toVersion: migration.to,
});
hasCleanedUpMetaDb = true;
}
return {
fromVersion,
toVersion: currentVersion,
migrated: currentVersion !== fromVersion,
};
}
async function inferStorageVersionFromProjects(): Promise<number> {
const projectsAdapter = new IndexedDBAdapter<unknown>(
const projectsAdapter = new IndexedDBAdapter<ProjectRecord>(
"video-editor-projects",
"projects",
1,
);
const projects = await projectsAdapter.getAll();
if (projects.length === 0) {
return 0;
}
let lowestVersion = Number.POSITIVE_INFINITY;
const orderedMigrations = [...migrations].sort((a, b) => a.from - b.from);
let migratedCount = 0;
for (const project of projects) {
const projectVersion = checkProjectVersion({ project });
if (projectVersion < lowestVersion) {
lowestVersion = projectVersion;
if (typeof project !== "object" || project === null) {
continue;
}
let projectRecord = project as ProjectRecord;
let currentVersion = getProjectVersion({ project: projectRecord });
// Apply migrations sequentially until project is up to date
for (const migration of orderedMigrations) {
if (migration.from !== currentVersion) {
continue;
}
const result = await migration.transform(projectRecord);
if (result.skipped) {
break; // Project is already at this version or higher
}
// Update project with migrated version
const projectId = getProjectId({ project: result.project });
if (!projectId) {
break; // Can't save without ID
}
await projectsAdapter.set(projectId, result.project);
migratedCount++;
currentVersion = migration.to;
// Use migrated project for next iteration
projectRecord = result.project;
}
}
if (lowestVersion === Number.POSITIVE_INFINITY) {
return 0;
}
return lowestVersion;
return { migratedCount };
}
function checkProjectVersion({ project }: { project: unknown }): number {
if (!isRecord(project)) {
return 0;
}
function getProjectVersion({ project }: { project: ProjectRecord }): number {
const versionValue = project.version;
// v2 and up
// v2 and up - has explicit version field
if (typeof versionValue === "number") {
return versionValue;
}
// v1 (got scenes)
// v1 - has scenes array
const scenesValue = project.scenes;
if (Array.isArray(scenesValue) && scenesValue.length > 0) {
return 1;
}
// v0 (didn't have scenes)
// v0 - no scenes
return 0;
}
function isRecord(value: unknown): value is ProjectRecord {
return typeof value === "object" && value !== null;
}

View File

@ -0,0 +1,28 @@
import type { ProjectRecord } from "./types";
export function isRecord(value: unknown): value is ProjectRecord {
return typeof value === "object" && value !== null;
}
export function getProjectId({
project,
}: {
project: ProjectRecord;
}): string | null {
const idValue = project.id;
if (typeof idValue === "string" && idValue.length > 0) {
return idValue;
}
const metadataValue = project.metadata;
if (!isRecord(metadataValue)) {
return null;
}
const metadataId = metadataValue.id;
if (typeof metadataId === "string" && metadataId.length > 0) {
return metadataId;
}
return null;
}

View File

@ -1,6 +1,7 @@
import { generateUUID } from "@/utils/id";
import type { SerializedScene } from "@/services/storage/types";
import type { MigrationResult, ProjectRecord } from "./types";
import { getProjectId, isRecord } from "./utils";
export interface TransformV0ToV1Options {
now?: Date;
@ -54,29 +55,4 @@ export function transformProjectV0ToV1({
return { project: updatedProject, skipped: false };
}
export function getProjectId({
project,
}: {
project: ProjectRecord;
}): string | null {
const idValue = project.id;
if (typeof idValue === "string" && idValue.length > 0) {
return idValue;
}
const metadataValue = project.metadata;
if (!isRecord(metadataValue)) {
return null;
}
const metadataId = metadataValue.id;
if (typeof metadataId === "string" && metadataId.length > 0) {
return metadataId;
}
return null;
}
function isRecord(value: unknown): value is ProjectRecord {
return typeof value === "object" && value !== null;
}
export { getProjectId } from "./utils";

View File

@ -15,6 +15,7 @@ import type {
VideoElement,
} from "@/types/timeline";
import type { MigrationResult, ProjectRecord } from "./types";
import { getProjectId, isRecord } from "./utils";
interface LegacyTimelineData {
tracks: unknown[];
@ -51,10 +52,8 @@ interface LegacyMediaTrack {
export interface TransformV1ToV2Options {
loadMediaAsset?: ({
projectId,
mediaId,
}: {
projectId: string;
mediaId: string;
}) => Promise<MediaAssetData | null>;
}
@ -91,10 +90,8 @@ async function migrateProject({
project: ProjectRecord;
projectId: string;
loadMediaAsset?: ({
projectId,
mediaId,
}: {
projectId: string;
mediaId: string;
}) => Promise<MediaAssetData | null>;
}): Promise<ProjectRecord> {
@ -261,10 +258,8 @@ async function transformTracks({
tracks: unknown[];
projectId: string;
loadMediaAsset?: ({
projectId,
mediaId,
}: {
projectId: string;
mediaId: string;
}) => Promise<MediaAssetData | null>;
}): Promise<TimelineTrack[]> {
@ -318,10 +313,8 @@ async function transformMediaTrack({
track: LegacyMediaTrack;
projectId: string;
loadMediaAsset?: ({
projectId,
mediaId,
}: {
projectId: string;
mediaId: string;
}) => Promise<MediaAssetData | null>;
isMain: boolean;
@ -342,7 +335,7 @@ async function transformMediaTrack({
let mediaType: "video" | "image" = "video";
if (loadMediaAsset) {
const mediaAsset = await loadMediaAsset({ projectId, mediaId });
const mediaAsset = await loadMediaAsset({ mediaId });
if (mediaAsset) {
mediaType = mediaAsset.type === "image" ? "image" : "video";
}
@ -543,28 +536,7 @@ function transformAudioTrack({
};
}
export function getProjectId({
project,
}: {
project: ProjectRecord;
}): string | null {
const idValue = project.id;
if (typeof idValue === "string" && idValue.length > 0) {
return idValue;
}
const metadataValue = project.metadata;
if (!isRecord(metadataValue)) {
return null;
}
const metadataId = metadataValue.id;
if (typeof metadataId === "string" && metadataId.length > 0) {
return metadataId;
}
return null;
}
export { getProjectId } from "./utils";
function getCurrentSceneId({
value,
@ -772,7 +744,3 @@ function isV2Project({ project }: { project: ProjectRecord }): boolean {
return isRecord(project.metadata) && isRecord(project.settings);
}
function isRecord(value: unknown): value is ProjectRecord {
return typeof value === "object" && value !== null;
}

View File

@ -1,6 +1,7 @@
import { getProjectDurationFromScenes } from "@/lib/scenes";
import type { TScene } from "@/types/timeline";
import type { MigrationResult, ProjectRecord } from "./types";
import { getProjectId, isRecord } from "./utils";
export function transformProjectV2ToV3({
project,
@ -33,28 +34,7 @@ export function transformProjectV2ToV3({
return { project: migratedProject, skipped: false };
}
export function getProjectId({
project,
}: {
project: ProjectRecord;
}): string | null {
const idValue = project.id;
if (typeof idValue === "string" && idValue.length > 0) {
return idValue;
}
const metadataValue = project.metadata;
if (!isRecord(metadataValue)) {
return null;
}
const metadataId = metadataValue.id;
if (typeof metadataId === "string" && metadataId.length > 0) {
return metadataId;
}
return null;
}
export { getProjectId } from "./utils";
function getScenes({ project }: { project: ProjectRecord }): TScene[] {
const scenesValue = project.scenes;
@ -75,7 +55,3 @@ function isV3Project({ project }: { project: ProjectRecord }): boolean {
isRecord(project.metadata) && typeof project.metadata.duration === "number"
);
}
function isRecord(value: unknown): value is ProjectRecord {
return typeof value === "object" && value !== null;
}

View File

@ -4,41 +4,19 @@
* Sets currentSceneId to the new scene's id
*/
import { IndexedDBAdapter } from "@/services/storage/indexeddb-adapter";
import { StorageMigration } from "./base";
import { getProjectId, transformProjectV0ToV1 } from "./transformers/v0-to-v1";
import type { ProjectRecord } from "./transformers/types";
import { transformProjectV0ToV1 } from "./transformers/v0-to-v1";
export class V0toV1Migration extends StorageMigration {
from = 0;
to = 1;
async run(): Promise<void> {
const projectsAdapter = new IndexedDBAdapter<unknown>(
"video-editor-projects",
"projects",
1,
);
const projects = await projectsAdapter.getAll();
for (const project of projects) {
if (typeof project !== "object" || project === null) {
continue;
}
const result = transformProjectV0ToV1({
project: project as Record<string, unknown>,
});
if (result.skipped) {
continue;
}
const projectId = getProjectId({ project: result.project });
if (!projectId) {
continue;
}
await projectsAdapter.set(projectId, result.project);
}
async transform(project: ProjectRecord): Promise<{
project: ProjectRecord;
skipped: boolean;
reason?: string;
}> {
return transformProjectV0ToV1({ project });
}
}

View File

@ -4,6 +4,7 @@ import {
} from "@/services/storage/indexeddb-adapter";
import type { MediaAssetData } from "@/services/storage/types";
import { StorageMigration } from "./base";
import type { ProjectRecord } from "./transformers/types";
import {
getProjectId,
transformProjectV1ToV2,
@ -14,41 +15,32 @@ export class V1toV2Migration extends StorageMigration {
from = 1;
to = 2;
async run(): Promise<void> {
const projectsAdapter = new IndexedDBAdapter<unknown>(
"video-editor-projects",
"projects",
1,
);
const projects = await projectsAdapter.getAll();
for (const project of projects) {
if (typeof project !== "object" || project === null) {
continue;
}
const projectId = getProjectId({
project: project as Record<string, unknown>,
});
if (!projectId) {
continue;
}
const loadMediaAsset = createMediaAssetLoader({ projectId });
const result = await transformProjectV1ToV2({
project: project as Record<string, unknown>,
options: { loadMediaAsset },
});
if (result.skipped) {
continue;
}
await projectsAdapter.set(projectId, result.project);
await cleanupLegacyTimelineDBs({ projectId, project: result.project });
async transform(project: ProjectRecord): Promise<{
project: ProjectRecord;
skipped: boolean;
reason?: string;
}> {
const projectId = getProjectId({ project });
if (!projectId) {
return { project, skipped: true, reason: "no project id" };
}
const loadMediaAsset = createMediaAssetLoader({ projectId });
const result = await transformProjectV1ToV2({
project,
options: { loadMediaAsset },
});
if (!result.skipped) {
// cleanup legacy timeline DBs after successful transformation
await cleanupLegacyTimelineDBs({
projectId,
project: result.project,
});
}
return result;
}
}
@ -73,7 +65,7 @@ async function cleanupLegacyTimelineDBs({
project,
}: {
projectId: string;
project: Record<string, unknown>;
project: ProjectRecord;
}): Promise<void> {
const scenes = project.scenes;
if (!Array.isArray(scenes)) {

View File

@ -2,41 +2,19 @@
* Adds a "duration" field to each project's metadata
*/
import { IndexedDBAdapter } from "@/services/storage/indexeddb-adapter";
import { StorageMigration } from "./base";
import { getProjectId, transformProjectV2ToV3 } from "./transformers/v2-to-v3";
import type { ProjectRecord } from "./transformers/types";
import { transformProjectV2ToV3 } from "./transformers/v2-to-v3";
export class V2toV3Migration extends StorageMigration {
from = 2;
to = 3;
async run(): Promise<void> {
const projectsAdapter = new IndexedDBAdapter<unknown>(
"video-editor-projects",
"projects",
1,
);
const projects = await projectsAdapter.getAll();
for (const project of projects) {
if (typeof project !== "object" || project === null) {
continue;
}
const result = transformProjectV2ToV3({
project: project as Record<string, unknown>,
});
if (result.skipped) {
continue;
}
const projectId = getProjectId({ project: result.project });
if (!projectId) {
continue;
}
await projectsAdapter.set(projectId, result.project);
}
async transform(project: ProjectRecord): Promise<{
project: ProjectRecord;
skipped: boolean;
reason?: string;
}> {
return transformProjectV2ToV3({ project });
}
}

View File

@ -1,73 +0,0 @@
import { IndexedDBAdapter } from "@/services/storage/indexeddb-adapter";
type StorageVersionRecord = {
version: number;
inProgress?: {
from: number;
to: number;
};
};
const DEFAULT_DB_NAME = "video-editor-meta";
const DEFAULT_STORE_NAME = "storage-version";
const DEFAULT_DB_VERSION = 1;
const STORAGE_VERSION_KEY = "storage-version";
export class StorageVersionManager {
private adapter: IndexedDBAdapter<StorageVersionRecord>;
constructor({
dbName = DEFAULT_DB_NAME,
storeName = DEFAULT_STORE_NAME,
version = DEFAULT_DB_VERSION,
}: {
dbName?: string;
storeName?: string;
version?: number;
} = {}) {
this.adapter = new IndexedDBAdapter<StorageVersionRecord>(
dbName,
storeName,
version,
);
}
async getVersion(): Promise<number> {
const record = await this.adapter.get(STORAGE_VERSION_KEY);
return record?.version ?? 0;
}
async getVersionRecord(): Promise<StorageVersionRecord | null> {
return this.adapter.get(STORAGE_VERSION_KEY);
}
async setVersion({ version }: { version: number }): Promise<void> {
const record = await this.getVersionRecord();
const inProgress = record?.inProgress;
await this.adapter.set(STORAGE_VERSION_KEY, {
version,
...(inProgress ? { inProgress } : {}),
});
}
async setInProgress({
from,
to,
}: {
from: number;
to: number;
}): Promise<void> {
const record = await this.getVersionRecord();
const version = record?.version ?? 0;
await this.adapter.set(STORAGE_VERSION_KEY, {
version,
inProgress: { from, to },
});
}
async clearInProgress(): Promise<void> {
const record = await this.getVersionRecord();
const version = record?.version ?? 0;
await this.adapter.set(STORAGE_VERSION_KEY, { version });
}
}

View File

@ -26,7 +26,6 @@ export interface MediaAssetData {
fps?: number;
ephemeral?: boolean;
thumbnailUrl?: string;
sourceStickerIconName?: string;
}
export type SerializedScene = Omit<TScene, "createdAt" | "updatedAt"> & {