make storage versioning per-project + few other refactors
This commit is contained in:
parent
490fc170c0
commit
2257ca2e6f
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -26,7 +26,6 @@ export interface MediaAssetData {
|
|||
fps?: number;
|
||||
ephemeral?: boolean;
|
||||
thumbnailUrl?: string;
|
||||
sourceStickerIconName?: string;
|
||||
}
|
||||
|
||||
export type SerializedScene = Omit<TScene, "createdAt" | "updatedAt"> & {
|
||||
|
|
|
|||
Loading…
Reference in New Issue