shipping this slop

This commit is contained in:
Maze Winther
2026-01-20 22:14:49 +01:00
parent 7117f17ece
commit 6e4069b42f
97 changed files with 3357 additions and 1858 deletions
+214
View File
@@ -0,0 +1,214 @@
import {
Input,
ALL_FORMATS,
BlobSource,
CanvasSink,
WrappedCanvas,
} from "mediabunny";
interface VideoSinkData {
sink: CanvasSink;
iterator: AsyncGenerator<WrappedCanvas, void, unknown> | null;
currentFrame: WrappedCanvas | null;
lastTime: number;
}
export class VideoCache {
private sinks = new Map<string, VideoSinkData>();
private initPromises = new Map<string, Promise<void>>();
async getFrameAt({
mediaId,
file,
time,
}: {
mediaId: string;
file: File;
time: number;
}): Promise<WrappedCanvas | null> {
await this.ensureSink({ mediaId, file });
const sinkData = this.sinks.get(mediaId);
if (!sinkData) return null;
if (
sinkData.currentFrame &&
this.isFrameValid({ frame: sinkData.currentFrame, time })
) {
return sinkData.currentFrame;
}
if (
sinkData.iterator &&
sinkData.currentFrame &&
time >= sinkData.lastTime &&
time < sinkData.lastTime + 2.0
) {
const frame = await this.iterateToTime({ sinkData, targetTime: time });
if (frame) return frame;
}
return await this.seekToTime({ sinkData, time });
}
private isFrameValid({
frame,
time,
}: {
frame: WrappedCanvas;
time: number;
}): boolean {
return time >= frame.timestamp && time < frame.timestamp + frame.duration;
}
private async iterateToTime({
sinkData,
targetTime,
}: {
sinkData: VideoSinkData;
targetTime: number;
}): Promise<WrappedCanvas | null> {
if (!sinkData.iterator) return null;
try {
while (true) {
const { value: frame, done } = await sinkData.iterator.next();
if (done || !frame) break;
sinkData.currentFrame = frame;
sinkData.lastTime = frame.timestamp;
if (this.isFrameValid({ frame, time: targetTime })) {
return frame;
}
if (frame.timestamp > targetTime + 1.0) break;
}
} catch (error) {
console.warn("Iterator failed, will restart:", error);
sinkData.iterator = null;
}
return null;
}
private async seekToTime({
sinkData,
time,
}: {
sinkData: VideoSinkData;
time: number;
}): Promise<WrappedCanvas | null> {
try {
if (sinkData.iterator) {
await sinkData.iterator.return();
sinkData.iterator = null;
}
sinkData.iterator = sinkData.sink.canvases(time);
sinkData.lastTime = time;
const { value: frame } = await sinkData.iterator.next();
if (frame) {
sinkData.currentFrame = frame;
return frame;
}
} catch (error) {
console.warn("Failed to seek video:", error);
}
return null;
}
private async ensureSink({
mediaId,
file,
}: {
mediaId: string;
file: File;
}): Promise<void> {
if (this.sinks.has(mediaId)) return;
if (this.initPromises.has(mediaId)) {
await this.initPromises.get(mediaId);
return;
}
const initPromise = this.initializeSink({ mediaId, file });
this.initPromises.set(mediaId, initPromise);
try {
await initPromise;
} finally {
this.initPromises.delete(mediaId);
}
}
private async initializeSink({
mediaId,
file,
}: {
mediaId: string;
file: File;
}): Promise<void> {
try {
const input = new Input({
source: new BlobSource(file),
formats: ALL_FORMATS,
});
const videoTrack = await input.getPrimaryVideoTrack();
if (!videoTrack) {
throw new Error("No video track found");
}
const canDecode = await videoTrack.canDecode();
if (!canDecode) {
throw new Error("Video codec not supported for decoding");
}
const sink = new CanvasSink(videoTrack, {
poolSize: 3,
fit: "contain",
});
this.sinks.set(mediaId, {
sink,
iterator: null,
currentFrame: null,
lastTime: -1,
});
} catch (error) {
console.error(`Failed to initialize video sink for ${mediaId}:`, error);
throw error;
}
}
clearVideo({ mediaId }: { mediaId: string }): void {
const sinkData = this.sinks.get(mediaId);
if (sinkData) {
if (sinkData.iterator) {
sinkData.iterator.return();
}
this.sinks.delete(mediaId);
}
this.initPromises.delete(mediaId);
}
clearAll(): void {
for (const [mediaId] of this.sinks) {
this.clearVideo({ mediaId });
}
}
getStats() {
return {
totalSinks: this.sinks.size,
activeSinks: Array.from(this.sinks.values()).filter((s) => s.iterator)
.length,
cachedFrames: Array.from(this.sinks.values()).filter(
(s) => s.currentFrame
).length,
};
}
}
export const videoCache = new VideoCache();
@@ -32,7 +32,7 @@ export class BlurBackgroundNode extends BaseNode<BlurBackgroundNodeParams> {
offscreen = new OffscreenCanvas(renderer.width, renderer.height);
const ctx = offscreen.getContext("2d");
if (!ctx) {
throw new Error("Failed to get offscreen canvas context");
throw new Error("failed to get offscreen canvas context");
}
offscreenCtx = ctx;
} catch {
@@ -41,7 +41,7 @@ export class BlurBackgroundNode extends BaseNode<BlurBackgroundNodeParams> {
offscreen.height = renderer.height;
const ctx = offscreen.getContext("2d");
if (!ctx) {
throw new Error("Failed to get canvas context");
throw new Error("failed to get canvas context");
}
offscreenCtx = ctx;
}
@@ -55,30 +55,21 @@ export class BlurBackgroundNode extends BaseNode<BlurBackgroundNodeParams> {
renderer.context = originalContext;
const zoomScale = 1.4;
const scaledWidth = renderer.width * zoomScale;
const scaledHeight = renderer.height * zoomScale;
const offsetX = (renderer.width - scaledWidth) / 2;
const offsetY = (renderer.height - scaledHeight) / 2;
renderer.context.save();
renderer.context.filter = `blur(${this.blurIntensity}px)`;
const scale = Math.max(
renderer.width / offscreen.width,
renderer.height / offscreen.height,
renderer.context.drawImage(
offscreen as CanvasImageSource,
offsetX,
offsetY,
scaledWidth,
scaledHeight,
);
const scaledWidth = offscreen.width * scale;
const scaledHeight = offscreen.height * scale;
const x = (renderer.width - scaledWidth) / 2;
const y = (renderer.height - scaledHeight) / 2;
if (offscreen instanceof OffscreenCanvas) {
renderer.context.drawImage(
offscreen as unknown as CanvasImageSource,
x,
y,
scaledWidth,
scaledHeight,
);
} else {
renderer.context.drawImage(offscreen, x, y, scaledWidth, scaledHeight);
}
renderer.context.restore();
}
}
@@ -1,3 +1,4 @@
import { drawCssBackground } from "@/lib/gradients";
import { CanvasRenderer } from "../canvas-renderer";
import { BaseNode } from "./base-node";
@@ -14,6 +15,16 @@ export class ColorNode extends BaseNode<ColorNodeParams> {
}
async render({ renderer }: { renderer: CanvasRenderer }) {
if (/gradient\(/i.test(this.color)) {
drawCssBackground({
ctx: renderer.context,
width: renderer.width,
height: renderer.height,
css: this.color,
});
return;
}
renderer.context.fillStyle = this.color;
renderer.context.fillRect(0, 0, renderer.width, renderer.height);
}
+80 -66
View File
@@ -1,4 +1,4 @@
import type { TimelineTrack, TimelineElement } from "@/types/timeline";
import type { TimelineTrack } from "@/types/timeline";
import { MediaAsset } from "@/types/assets";
import { RootNode } from "./nodes/root-node";
import { VideoNode } from "./nodes/video-node";
@@ -9,7 +9,7 @@ import { ColorNode } from "./nodes/color-node";
import { BlurBackgroundNode } from "./nodes/blur-background-node";
import { TBackground, TCanvasSize } from "@/types/project";
import { DEFAULT_BLUR_INTENSITY } from "@/constants/project-constants";
import { canTracktHaveAudio } from "@/lib/timeline";
import { isMainTrack } from "@/lib/timeline";
export type BuildSceneParams = {
canvasSize: TCanvasSize;
@@ -25,68 +25,81 @@ export function buildScene(params: BuildSceneParams) {
const rootNode = new RootNode({ duration });
const mediaMap = new Map(mediaAssets.map((m) => [m.id, m]));
const elements = tracks
.slice()
.reverse()
.filter((track) => !("hidden" in track && track.hidden))
.flatMap((track): TimelineElement[] =>
track.elements.filter((element) => !("hidden" in element && element.hidden)),
);
const visibleTracks = tracks.filter(
(track) => !("hidden" in track && track.hidden),
);
const mainTrack = visibleTracks.find((track) => isMainTrack(track)) ?? null;
const orderedTracksTopToBottom = [
...visibleTracks.filter((track) => !isMainTrack(track)),
...(mainTrack ? [mainTrack] : []),
];
const orderedTracksBottomToTop = orderedTracksTopToBottom.slice().reverse();
const contentNodes = [];
for (const element of elements) {
if (element.type === "video" || element.type === "image") {
const mediaAsset = mediaMap.get(element.mediaId);
if (mediaAsset && mediaAsset.file) {
if (mediaAsset.type === "video") {
contentNodes.push(
new VideoNode({
file: mediaAsset.file,
duration: element.duration,
timeOffset: element.startTime,
trimStart: element.trimStart,
trimEnd: element.trimEnd,
}),
);
} else if (mediaAsset.type === "image") {
contentNodes.push(
new ImageNode({
file: mediaAsset.file,
duration: element.duration,
timeOffset: element.startTime,
trimStart: element.trimStart,
trimEnd: element.trimEnd,
}),
);
for (const track of orderedTracksBottomToTop) {
const elements = track.elements
.filter((element) => !("hidden" in element && element.hidden))
.slice()
.sort((a, b) => {
if (a.startTime !== b.startTime) return a.startTime - b.startTime;
return a.id.localeCompare(b.id);
});
for (const element of elements) {
if (element.type === "video" || element.type === "image") {
const mediaAsset = mediaMap.get(element.mediaId);
if (mediaAsset && mediaAsset.file) {
if (mediaAsset.type === "video") {
contentNodes.push(
new VideoNode({
file: mediaAsset.file,
duration: element.duration,
timeOffset: element.startTime,
trimStart: element.trimStart,
trimEnd: element.trimEnd,
}),
);
} else if (mediaAsset.type === "image") {
contentNodes.push(
new ImageNode({
file: mediaAsset.file,
duration: element.duration,
timeOffset: element.startTime,
trimStart: element.trimStart,
trimEnd: element.trimEnd,
}),
);
}
}
// TODO: Add AudioNode for audio files
}
}
if (element.type === "text") {
contentNodes.push(
new TextNode({
...element,
canvasCenter: { x: canvasSize.width / 2, y: canvasSize.height / 2 },
textBaseline: "middle",
}),
);
}
if (element.type === "text") {
contentNodes.push(
new TextNode({
...element,
canvasCenter: { x: canvasSize.width / 2, y: canvasSize.height / 2 },
textBaseline: "middle",
}),
);
}
if (element.type === "sticker") {
contentNodes.push(
new StickerNode({
iconName: element.iconName,
duration: element.duration,
timeOffset: element.startTime,
trimStart: element.trimStart,
trimEnd: element.trimEnd,
transform: element.transform,
opacity: element.opacity,
color: element.color,
}),
);
if (element.type === "sticker") {
contentNodes.push(
new StickerNode({
iconName: element.iconName,
duration: element.duration,
timeOffset: element.startTime,
trimStart: element.trimStart,
trimEnd: element.trimEnd,
transform: element.transform,
opacity: element.opacity,
color: element.color,
}),
);
}
}
}
@@ -97,15 +110,16 @@ export function buildScene(params: BuildSceneParams) {
contentNodes,
}),
);
} else if (
background.type === "color" &&
background.color !== "transparent"
) {
rootNode.add(new ColorNode({ color: background.color }));
}
for (const node of contentNodes) {
rootNode.add(node);
for (const node of contentNodes) {
rootNode.add(node);
}
} else {
if (background.type === "color" && background.color !== "transparent") {
rootNode.add(new ColorNode({ color: background.color }));
}
for (const node of contentNodes) {
rootNode.add(node);
}
}
return rootNode;
@@ -0,0 +1,101 @@
import { StorageAdapter } from "./types";
export class IndexedDBAdapter<T> implements StorageAdapter<T> {
private dbName: string;
private storeName: string;
private version: number;
constructor(dbName: string, storeName: string, version = 1) {
this.dbName = dbName;
this.storeName = storeName;
this.version = version;
}
private async getDB(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, this.version);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result);
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
if (!db.objectStoreNames.contains(this.storeName)) {
db.createObjectStore(this.storeName, { keyPath: "id" });
}
};
});
}
async get(key: string): Promise<T | null> {
const db = await this.getDB();
const transaction = db.transaction([this.storeName], "readonly");
const store = transaction.objectStore(this.storeName);
return new Promise((resolve, reject) => {
const request = store.get(key);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result || null);
});
}
async set(key: string, value: T): Promise<void> {
const db = await this.getDB();
const transaction = db.transaction([this.storeName], "readwrite");
const store = transaction.objectStore(this.storeName);
return new Promise((resolve, reject) => {
const request = store.put({ id: key, ...value });
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve();
});
}
async remove(key: string): Promise<void> {
const db = await this.getDB();
const transaction = db.transaction([this.storeName], "readwrite");
const store = transaction.objectStore(this.storeName);
return new Promise((resolve, reject) => {
const request = store.delete(key);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve();
});
}
async list(): Promise<string[]> {
const db = await this.getDB();
const transaction = db.transaction([this.storeName], "readonly");
const store = transaction.objectStore(this.storeName);
return new Promise((resolve, reject) => {
const request = store.getAllKeys();
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result as string[]);
});
}
async getAll(): Promise<T[]> {
const db = await this.getDB();
const transaction = db.transaction([this.storeName], "readonly");
const store = transaction.objectStore(this.storeName);
return new Promise((resolve, reject) => {
const request = store.getAll();
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result || []);
});
}
async clear(): Promise<void> {
const db = await this.getDB();
const transaction = db.transaction([this.storeName], "readwrite");
const store = transaction.objectStore(this.storeName);
return new Promise((resolve, reject) => {
const request = store.clear();
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve();
});
}
}
@@ -0,0 +1,5 @@
export abstract class StorageMigration {
abstract from: number;
abstract to: number;
abstract run(): Promise<void>;
}
@@ -0,0 +1,9 @@
export { StorageMigration } from "./base";
export { StorageVersionManager } from "./version-manager";
export { runStorageMigrations } from "./runner";
import { V0toV1Migration } from "./v0-to-v1";
import { V1toV2Migration } from "./v1-to-v2";
export const CURRENT_STORAGE_VERSION = 2;
export const migrations = [new V0toV1Migration(), new V1toV2Migration()];
@@ -0,0 +1,134 @@
import { IndexedDBAdapter } from "@/services/storage/indexeddb-adapter";
import { StorageMigration } from "./base";
import { StorageVersionManager } from "./version-manager";
export interface StorageMigrationResult {
fromVersion: number;
toVersion: number;
migrated: boolean;
}
export type StorageMigrationCallbacks = {
onMigrationStart?: ({
fromVersion,
toVersion,
}: {
fromVersion: number;
toVersion: number;
}) => void;
onMigrationComplete?: ({
fromVersion,
toVersion,
}: {
fromVersion: number;
toVersion: number;
}) => void;
};
type ProjectRecord = Record<string, unknown>;
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;
}
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,
});
}
return {
fromVersion,
toVersion: currentVersion,
migrated: currentVersion !== fromVersion,
};
}
async function inferStorageVersionFromProjects(): Promise<number> {
const projectsAdapter = new IndexedDBAdapter<unknown>(
"video-editor-projects",
"projects",
1,
);
const projects = await projectsAdapter.getAll();
if (projects.length === 0) {
return 0;
}
let lowestVersion = Number.POSITIVE_INFINITY;
for (const project of projects) {
const projectVersion = checkProjectVersion({ project });
if (projectVersion < lowestVersion) {
lowestVersion = projectVersion;
}
}
if (lowestVersion === Number.POSITIVE_INFINITY) {
return 0;
}
return lowestVersion;
}
function checkProjectVersion({ project }: { project: unknown }): number {
if (!isRecord(project)) {
return 0;
}
const versionValue = project.version;
if (typeof versionValue === "number") {
return versionValue;
}
const scenesValue = project.scenes;
if (Array.isArray(scenesValue) && scenesValue.length > 0) {
return 1;
}
return 0;
}
function isRecord(value: unknown): value is ProjectRecord {
return typeof value === "object" && value !== null;
}
@@ -0,0 +1,93 @@
import { IndexedDBAdapter } from "@/services/storage/indexeddb-adapter";
import { SerializedScene } from "@/services/storage/types";
import { buildDefaultScene } from "@/lib/scene-utils";
import { TScene } from "@/types/timeline";
import { StorageMigration } from "./base";
type ProjectRecord = Record<string, unknown>;
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 (!isRecord(project)) {
continue;
}
const scenesValue = project.scenes;
if (Array.isArray(scenesValue) && scenesValue.length > 0) {
continue;
}
const mainScene = buildDefaultScene({ isMain: true, name: "Main scene" });
const serializedScene = serializeScene({ scene: mainScene });
const updatedProject: ProjectRecord = {
...project,
scenes: [serializedScene],
currentSceneId: mainScene.id,
version: 1,
};
const updatedAt = new Date().toISOString();
if (isRecord(project.metadata)) {
updatedProject.metadata = {
...project.metadata,
updatedAt,
};
} else {
updatedProject.updatedAt = updatedAt;
}
const projectId = getProjectId({ project: updatedProject });
if (!projectId) {
continue;
}
await projectsAdapter.set(projectId, updatedProject);
}
}
}
function serializeScene({ scene }: { scene: TScene }): SerializedScene {
return {
id: scene.id,
name: scene.name,
isMain: scene.isMain,
tracks: scene.tracks,
bookmarks: scene.bookmarks,
createdAt: scene.createdAt.toISOString(),
updatedAt: scene.updatedAt.toISOString(),
};
}
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;
}
@@ -0,0 +1,339 @@
import {
DEFAULT_BLUR_INTENSITY,
DEFAULT_CANVAS_SIZE,
DEFAULT_COLOR,
DEFAULT_FPS,
} from "@/constants/project-constants";
import { IndexedDBAdapter } from "@/services/storage/indexeddb-adapter";
import { StorageMigration } from "./base";
type ProjectRecord = Record<string, unknown>;
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 (!isRecord(project)) {
continue;
}
const projectId = getProjectId({ project });
if (!projectId) {
continue;
}
if (isV2Project({ project })) {
continue;
}
const migratedProject = migrateProject({ project, projectId });
await projectsAdapter.set(projectId, migratedProject);
}
}
}
function migrateProject({
project,
projectId,
}: {
project: ProjectRecord;
projectId: string;
}): ProjectRecord {
const createdAt = normalizeDateString({ value: project.createdAt });
const updatedAt = normalizeDateString({ value: project.updatedAt });
const metadataValue = project.metadata;
const metadata = isRecord(metadataValue)
? {
id: getStringValue({ value: metadataValue.id, fallback: projectId }),
name: getStringValue({ value: metadataValue.name, fallback: "" }),
thumbnail: getStringValue({ value: metadataValue.thumbnail }),
createdAt: normalizeDateString({ value: metadataValue.createdAt }),
updatedAt: normalizeDateString({ value: metadataValue.updatedAt }),
}
: {
id: projectId,
name: getStringValue({ value: project.name, fallback: "" }),
thumbnail: getStringValue({ value: project.thumbnail }),
createdAt,
updatedAt,
};
const scenesValue = project.scenes;
const scenes = Array.isArray(scenesValue) ? scenesValue : [];
const legacyBookmarks = Array.isArray(project.bookmarks)
? project.bookmarks
: null;
const normalizedScenes = applyLegacyBookmarks({
scenes,
legacyBookmarks,
});
const settingsValue = project.settings;
const settings = isRecord(settingsValue)
? {
fps: getNumberValue({
value: settingsValue.fps,
fallback: DEFAULT_FPS,
}),
canvasSize: getCanvasSizeValue({
value: settingsValue.canvasSize,
fallback: DEFAULT_CANVAS_SIZE,
}),
background: getBackgroundValue({
value: settingsValue.background,
}),
}
: {
fps: getNumberValue({ value: project.fps, fallback: DEFAULT_FPS }),
canvasSize: getCanvasSizeValue({
value: project.canvasSize,
fallback: DEFAULT_CANVAS_SIZE,
}),
background: getBackgroundValue({
value: project.background,
backgroundType: project.backgroundType,
backgroundColor: project.backgroundColor,
blurIntensity: project.blurIntensity,
}),
};
const currentSceneId = getCurrentSceneId({
value: project.currentSceneId,
scenes: normalizedScenes,
});
return {
...project,
metadata,
scenes: normalizedScenes,
currentSceneId,
settings,
version: 2,
};
}
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 getCurrentSceneId({
value,
scenes,
}: {
value: unknown;
scenes: unknown[];
}): string {
if (typeof value === "string" && value.length > 0) {
return value;
}
const mainSceneId = findMainSceneId({ scenes });
if (mainSceneId) {
return mainSceneId;
}
return "";
}
function findMainSceneId({ scenes }: { scenes: unknown[] }): string | null {
for (const scene of scenes) {
if (!isRecord(scene)) {
continue;
}
if (scene.isMain === true && typeof scene.id === "string") {
return scene.id;
}
}
for (const scene of scenes) {
if (!isRecord(scene)) {
continue;
}
if (typeof scene.id === "string") {
return scene.id;
}
}
return null;
}
function applyLegacyBookmarks({
scenes,
legacyBookmarks,
}: {
scenes: unknown[];
legacyBookmarks: unknown[] | null;
}): unknown[] {
if (!legacyBookmarks || legacyBookmarks.length === 0) {
return scenes;
}
const mainSceneId = findMainSceneId({ scenes });
return scenes.map((scene) => {
if (!isRecord(scene)) {
return scene;
}
if (mainSceneId && scene.id !== mainSceneId) {
return scene;
}
if (Array.isArray(scene.bookmarks) && scene.bookmarks.length > 0) {
return scene;
}
return {
...scene,
bookmarks: legacyBookmarks,
};
});
}
function getBackgroundValue({
value,
backgroundType,
backgroundColor,
blurIntensity,
}: {
value: unknown;
backgroundType?: unknown;
backgroundColor?: unknown;
blurIntensity?: unknown;
}): {
type: "color" | "blur";
color?: string;
blurIntensity?: number;
} {
if (isRecord(value)) {
const typeValue = value.type;
if (typeValue === "blur") {
return {
type: "blur",
blurIntensity: getNumberValue({
value: value.blurIntensity,
fallback: DEFAULT_BLUR_INTENSITY,
}),
};
}
return {
type: "color",
color: getStringValue({ value: value.color, fallback: DEFAULT_COLOR }),
};
}
if (backgroundType === "blur") {
return {
type: "blur",
blurIntensity: getNumberValue({
value: blurIntensity,
fallback: DEFAULT_BLUR_INTENSITY,
}),
};
}
return {
type: "color",
color: getStringValue({ value: backgroundColor, fallback: DEFAULT_COLOR }),
};
}
function getCanvasSizeValue({
value,
fallback,
}: {
value: unknown;
fallback: { width: number; height: number };
}): { width: number; height: number } {
if (isRecord(value)) {
const width = getNumberValue({
value: value.width,
fallback: fallback.width,
});
const height = getNumberValue({
value: value.height,
fallback: fallback.height,
});
return { width, height };
}
return fallback;
}
function getNumberValue({
value,
fallback,
}: {
value: unknown;
fallback: number;
}): number {
return typeof value === "number" ? value : fallback;
}
function getStringValue({
value,
fallback,
}: {
value: unknown;
fallback?: string;
}): string | undefined {
if (typeof value === "string") {
return value;
}
return fallback;
}
function normalizeDateString({ value }: { value: unknown }): string {
if (value instanceof Date) {
return value.toISOString();
}
if (typeof value === "string") {
return value;
}
return new Date().toISOString();
}
function isV2Project({ project }: { project: ProjectRecord }): boolean {
const versionValue = project.version;
if (typeof versionValue === "number" && versionValue >= 2) {
return true;
}
return isRecord(project.metadata) && isRecord(project.settings);
}
function isRecord(value: unknown): value is ProjectRecord {
return typeof value === "object" && value !== null;
}
@@ -0,0 +1,73 @@
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 });
}
}
@@ -0,0 +1,73 @@
import { StorageAdapter } from "./types";
export class OPFSAdapter implements StorageAdapter<File> {
private directoryName: string;
constructor(directoryName = "media") {
this.directoryName = directoryName;
}
private async getDirectory(): Promise<FileSystemDirectoryHandle> {
const opfsRoot = await navigator.storage.getDirectory();
return await opfsRoot.getDirectoryHandle(this.directoryName, {
create: true,
});
}
async get(key: string): Promise<File | null> {
try {
const directory = await this.getDirectory();
const fileHandle = await directory.getFileHandle(key);
return await fileHandle.getFile();
} catch (error) {
if ((error as Error).name === "NotFoundError") {
return null;
}
throw error;
}
}
async set(key: string, file: File): Promise<void> {
const directory = await this.getDirectory();
const fileHandle = await directory.getFileHandle(key, { create: true });
const writable = await fileHandle.createWritable();
await writable.write(file);
await writable.close();
}
async remove(key: string): Promise<void> {
try {
const directory = await this.getDirectory();
await directory.removeEntry(key);
} catch (error) {
if ((error as Error).name !== "NotFoundError") {
throw error;
}
}
}
async list(): Promise<string[]> {
const directory = await this.getDirectory();
const keys: string[] = [];
for await (const name of directory.keys()) {
keys.push(name);
}
return keys;
}
async clear(): Promise<void> {
const directory = await this.getDirectory();
for await (const name of directory.keys()) {
await directory.removeEntry(name);
}
}
// Helper method to check OPFS support
static isSupported(): boolean {
return "storage" in navigator && "getDirectory" in navigator.storage;
}
}
@@ -0,0 +1,457 @@
import { TProject, TProjectMetadata } from "@/types/project";
import { MediaAsset } from "@/types/assets";
import { IndexedDBAdapter } from "./indexeddb-adapter";
import { OPFSAdapter } from "./opfs-adapter";
import type {
MediaAssetData,
StorageConfig,
SerializedProject,
SerializedScene,
} from "./types";
import { SavedSoundsData, SavedSound, SoundEffect } from "@/types/sounds";
import { migrations, runStorageMigrations } from "@/services/storage/migrations";
import { TimelineTrack } from "@/types/timeline";
class StorageService {
private projectsAdapter: IndexedDBAdapter<SerializedProject>;
private savedSoundsAdapter: IndexedDBAdapter<SavedSoundsData>;
private config: StorageConfig;
private migrationsPromise: Promise<void> | null = null;
constructor() {
this.config = {
projectsDb: "video-editor-projects",
mediaDb: "video-editor-media",
savedSoundsDb: "video-editor-saved-sounds",
version: 1,
};
this.projectsAdapter = new IndexedDBAdapter<SerializedProject>(
this.config.projectsDb,
"projects",
this.config.version,
);
this.savedSoundsAdapter = new IndexedDBAdapter<SavedSoundsData>(
this.config.savedSoundsDb,
"saved-sounds",
this.config.version,
);
}
private async ensureMigrations(): Promise<void> {
if (this.migrationsPromise) {
await this.migrationsPromise;
return;
}
this.migrationsPromise = runStorageMigrations({ migrations }).then(
() => undefined,
);
await this.migrationsPromise;
}
private getProjectMediaAdapters({ projectId }: { projectId: string }) {
const mediaMetadataAdapter = new IndexedDBAdapter<MediaAssetData>(
`${this.config.mediaDb}-${projectId}`,
"media-metadata",
this.config.version,
);
const mediaAssetsAdapter = new OPFSAdapter(`media-files-${projectId}`);
return { mediaMetadataAdapter, mediaAssetsAdapter };
}
private stripAudioBuffers({
tracks,
}: {
tracks: TimelineTrack[];
}): TimelineTrack[] {
return tracks.map((track) => {
if (track.type !== "audio") return track;
return {
...track,
elements: track.elements.map((element) => {
const { buffer: _buffer, ...rest } = element;
return rest;
}),
};
});
}
async saveProject({ project }: { project: TProject }): Promise<void> {
const serializedScenes: SerializedScene[] = project.scenes.map((scene) => ({
id: scene.id,
name: scene.name,
isMain: scene.isMain,
tracks: this.stripAudioBuffers({ tracks: scene.tracks }),
bookmarks: scene.bookmarks,
createdAt: scene.createdAt.toISOString(),
updatedAt: scene.updatedAt.toISOString(),
}));
const serializedProject: SerializedProject = {
metadata: {
id: project.metadata.id,
name: project.metadata.name,
thumbnail: project.metadata.thumbnail,
createdAt: project.metadata.createdAt.toISOString(),
updatedAt: project.metadata.updatedAt.toISOString(),
},
scenes: serializedScenes,
currentSceneId: project.currentSceneId,
settings: project.settings,
version: project.version,
};
await this.projectsAdapter.set(project.metadata.id, serializedProject);
}
async loadProject({
id,
}: {
id: string;
}): Promise<{ project: TProject } | null> {
await this.ensureMigrations();
const serializedProject = await this.projectsAdapter.get(id);
if (!serializedProject) return null;
const scenes =
serializedProject.scenes?.map((scene) => ({
id: scene.id,
name: scene.name,
isMain: scene.isMain,
tracks: (scene.tracks ?? []).map((track) =>
track.type === "video"
? { ...track, isMain: track.isMain ?? false } // legacy: isMain was optional
: track,
),
bookmarks: scene.bookmarks ?? [],
createdAt: new Date(scene.createdAt),
updatedAt: new Date(scene.updatedAt),
})) ?? [];
const project: TProject = {
metadata: {
id: serializedProject.metadata.id,
name: serializedProject.metadata.name,
thumbnail: serializedProject.metadata.thumbnail,
createdAt: new Date(serializedProject.metadata.createdAt),
updatedAt: new Date(serializedProject.metadata.updatedAt),
},
scenes,
currentSceneId: serializedProject.currentSceneId || "",
settings: serializedProject.settings,
version: serializedProject.version,
};
return { project };
}
async loadAllProjects(): Promise<TProject[]> {
const projectIds = await this.projectsAdapter.list();
const projects: TProject[] = [];
for (const id of projectIds) {
const result = await this.loadProject({ id });
if (result?.project) {
projects.push(result.project);
}
}
return projects.sort(
(a, b) => b.metadata.updatedAt.getTime() - a.metadata.updatedAt.getTime(),
);
}
async loadAllProjectsMetadata(): Promise<TProjectMetadata[]> {
await this.ensureMigrations();
const serializedProjects = await this.projectsAdapter.getAll();
const metadata = serializedProjects.map((serializedProject) => ({
id: serializedProject.metadata.id,
name: serializedProject.metadata.name,
thumbnail: serializedProject.metadata.thumbnail,
createdAt: new Date(serializedProject.metadata.createdAt),
updatedAt: new Date(serializedProject.metadata.updatedAt),
}));
return metadata.sort(
(a, b) => b.updatedAt.getTime() - a.updatedAt.getTime(),
);
}
async deleteProject({ id }: { id: string }): Promise<void> {
await this.projectsAdapter.remove(id);
}
async saveMediaAsset({
projectId,
mediaAsset,
}: {
projectId: string;
mediaAsset: MediaAsset;
}): Promise<void> {
const { mediaMetadataAdapter, mediaAssetsAdapter } =
this.getProjectMediaAdapters({ projectId });
await mediaAssetsAdapter.set(mediaAsset.id, mediaAsset.file);
const metadata: MediaAssetData = {
id: mediaAsset.id,
name: mediaAsset.name,
type: mediaAsset.type,
size: mediaAsset.file.size,
lastModified: mediaAsset.file.lastModified,
width: mediaAsset.width,
height: mediaAsset.height,
duration: mediaAsset.duration,
thumbnailUrl: mediaAsset.thumbnailUrl,
ephemeral: mediaAsset.ephemeral,
};
await mediaMetadataAdapter.set(mediaAsset.id, metadata);
}
async loadMediaAsset({
projectId,
id,
}: {
projectId: string;
id: string;
}): Promise<MediaAsset | null> {
const { mediaMetadataAdapter, mediaAssetsAdapter } =
this.getProjectMediaAdapters({ projectId });
const [file, metadata] = await Promise.all([
mediaAssetsAdapter.get(id),
mediaMetadataAdapter.get(id),
]);
if (!file || !metadata) return null;
let url: string;
if (metadata.type === "image" && (!file.type || file.type === "")) {
try {
const text = await file.text();
if (text.trim().startsWith("<svg")) {
const svgBlob = new Blob([text], { type: "image/svg+xml" });
url = URL.createObjectURL(svgBlob);
} else {
url = URL.createObjectURL(file);
}
} catch {
url = URL.createObjectURL(file);
}
} else {
url = URL.createObjectURL(file);
}
return {
id: metadata.id,
name: metadata.name,
type: metadata.type,
file,
url,
width: metadata.width,
height: metadata.height,
duration: metadata.duration,
thumbnailUrl: metadata.thumbnailUrl,
ephemeral: metadata.ephemeral,
};
}
async loadAllMediaAssets({
projectId,
}: {
projectId: string;
}): Promise<MediaAsset[]> {
const { mediaMetadataAdapter } = this.getProjectMediaAdapters({
projectId,
});
const mediaIds = await mediaMetadataAdapter.list();
const mediaItems: MediaAsset[] = [];
for (const id of mediaIds) {
const item = await this.loadMediaAsset({ projectId, id });
if (item) {
mediaItems.push(item);
}
}
return mediaItems;
}
async deleteMediaAsset({
projectId,
id,
}: {
projectId: string;
id: string;
}): Promise<void> {
const { mediaMetadataAdapter, mediaAssetsAdapter } =
this.getProjectMediaAdapters({ projectId });
await Promise.all([
mediaAssetsAdapter.remove(id),
mediaMetadataAdapter.remove(id),
]);
}
async deleteProjectMedia({
projectId,
}: {
projectId: string;
}): Promise<void> {
const { mediaMetadataAdapter, mediaAssetsAdapter } =
this.getProjectMediaAdapters({ projectId });
await Promise.all([
mediaMetadataAdapter.clear(),
mediaAssetsAdapter.clear(),
]);
}
// Utility methods
async clearAllData(): Promise<void> {
// Clear all projects
await this.projectsAdapter.clear();
// Note: Project-specific media and timelines will be cleaned up when projects are deleted
}
async getStorageInfo(): Promise<{
projects: number;
isOPFSSupported: boolean;
isIndexedDBSupported: boolean;
}> {
const projectIds = await this.projectsAdapter.list();
return {
projects: projectIds.length,
isOPFSSupported: this.isOPFSSupported(),
isIndexedDBSupported: this.isIndexedDBSupported(),
};
}
async getProjectStorageInfo({ projectId }: { projectId: string }): Promise<{
mediaItems: number;
}> {
const { mediaMetadataAdapter } = this.getProjectMediaAdapters({
projectId,
});
const mediaIds = await mediaMetadataAdapter.list();
return {
mediaItems: mediaIds.length,
};
}
async loadSavedSounds(): Promise<SavedSoundsData> {
try {
const savedSoundsData = await this.savedSoundsAdapter.get("user-sounds");
return (
savedSoundsData || {
sounds: [],
lastModified: new Date().toISOString(),
}
);
} catch (error) {
console.error("Failed to load saved sounds:", error);
return { sounds: [], lastModified: new Date().toISOString() };
}
}
async saveSoundEffect({
soundEffect,
}: {
soundEffect: SoundEffect;
}): Promise<void> {
try {
const currentData = await this.loadSavedSounds();
// Check if sound is already saved
if (currentData.sounds.some((sound) => sound.id === soundEffect.id)) {
return; // Already saved
}
const savedSound: SavedSound = {
id: soundEffect.id,
name: soundEffect.name,
username: soundEffect.username,
previewUrl: soundEffect.previewUrl,
downloadUrl: soundEffect.downloadUrl,
duration: soundEffect.duration,
tags: soundEffect.tags,
license: soundEffect.license,
savedAt: new Date().toISOString(),
};
const updatedData: SavedSoundsData = {
sounds: [...currentData.sounds, savedSound],
lastModified: new Date().toISOString(),
};
await this.savedSoundsAdapter.set("user-sounds", updatedData);
} catch (error) {
console.error("Failed to save sound effect:", error);
throw error;
}
}
async removeSavedSound({ soundId }: { soundId: number }): Promise<void> {
try {
const currentData = await this.loadSavedSounds();
const updatedData: SavedSoundsData = {
sounds: currentData.sounds.filter((sound) => sound.id !== soundId),
lastModified: new Date().toISOString(),
};
await this.savedSoundsAdapter.set("user-sounds", updatedData);
} catch (error) {
console.error("Failed to remove saved sound:", error);
throw error;
}
}
async isSoundSaved({ soundId }: { soundId: number }): Promise<boolean> {
try {
const currentData = await this.loadSavedSounds();
return currentData.sounds.some((sound) => sound.id === soundId);
} catch (error) {
console.error("Failed to check if sound is saved:", error);
return false;
}
}
async clearSavedSounds(): Promise<void> {
try {
await this.savedSoundsAdapter.remove("user-sounds");
} catch (error) {
console.error("Failed to clear saved sounds:", error);
throw error;
}
}
// Check browser support
isOPFSSupported(): boolean {
return OPFSAdapter.isSupported();
}
isIndexedDBSupported(): boolean {
return "indexedDB" in window;
}
isFullySupported(): boolean {
return this.isIndexedDBSupported() && this.isOPFSSupported();
}
}
// Export singleton instance
export const storageService = new StorageService();
export { StorageService };
+61
View File
@@ -0,0 +1,61 @@
import { MediaType } from "@/types/assets";
import { TProject, TProjectMetadata } from "@/types/project";
import { TScene } from "@/types/timeline";
export interface StorageAdapter<T> {
get(key: string): Promise<T | null>;
set(key: string, value: T): Promise<void>;
remove(key: string): Promise<void>;
list(): Promise<string[]>;
clear(): Promise<void>;
}
export interface MediaAssetData {
id: string;
name: string;
type: MediaType;
size: number;
lastModified: number;
width?: number;
height?: number;
duration?: number;
fps?: number;
ephemeral?: boolean;
thumbnailUrl?: string;
sourceStickerIconName?: string;
}
export type SerializedScene = Omit<TScene, "createdAt" | "updatedAt"> & {
createdAt: string;
updatedAt: string;
};
export type SerializedProjectMetadata = Omit<
TProjectMetadata,
"createdAt" | "updatedAt"
> & {
createdAt: string;
updatedAt: string;
};
export type SerializedProject = Omit<TProject, "metadata" | "scenes"> & {
metadata: SerializedProjectMetadata;
scenes: SerializedScene[];
};
export interface StorageConfig {
projectsDb: string;
mediaDb: string;
savedSoundsDb: string;
version: number;
}
// TypeScript type augmentation to add async iterator methods to FileSystemDirectoryHandle
// These methods are part of the File System Access API spec but may not be in all type definitions
declare global {
interface FileSystemDirectoryHandle {
keys(): AsyncIterableIterator<string>;
values(): AsyncIterableIterator<FileSystemHandle>;
entries(): AsyncIterableIterator<[string, FileSystemHandle]>;
}
}