Files
OpenCut/apps/web/src/core/managers/project-manager.ts
T

671 lines
16 KiB
TypeScript
Raw Normal View History

2026-01-31 00:20:04 +01:00
import type { EditorCore } from "@/core";
import type {
TProject,
TProjectMetadata,
TProjectSortKey,
TProjectSortOption,
TProjectSettings,
TTimelineViewState,
} from "@/types/project";
import type { ExportOptions, ExportResult, ExportState } from "@/types/export";
2026-01-31 22:19:11 +01:00
import { storageService } from "@/services/storage/service";
2026-01-31 00:20:04 +01:00
import { toast } from "sonner";
import { generateUUID } from "@/utils/id";
import { UpdateProjectSettingsCommand } from "@/lib/commands/project";
import {
DEFAULT_FPS,
DEFAULT_CANVAS_SIZE,
DEFAULT_COLOR,
} from "@/constants/project-constants";
import { buildDefaultScene, getProjectDurationFromScenes } from "@/lib/scenes";
import { buildScene } from "@/services/renderer/scene-builder";
import { CanvasRenderer } from "@/services/renderer/canvas-renderer";
import {
CURRENT_PROJECT_VERSION,
2026-01-31 00:20:04 +01:00
migrations,
runStorageMigrations,
2026-02-05 14:28:16 +01:00
type MigrationProgress,
2026-01-31 00:20:04 +01:00
} from "@/services/storage/migrations";
import { DEFAULT_TIMELINE_VIEW_STATE } from "@/constants/timeline-constants";
import { loadFonts } from "@/lib/fonts/google-fonts";
import { collectFontFamilies } from "@/lib/timeline/element-utils";
2026-01-31 00:20:04 +01:00
export interface MigrationState {
isMigrating: boolean;
fromVersion: number | null;
toVersion: number | null;
projectName: string | null;
}
export class ProjectManager {
private active: TProject | null = null;
private savedProjects: TProjectMetadata[] = [];
private isLoading = true;
private isInitialized = false;
private invalidProjectIds = new Set<string>();
private storageMigrationPromise: Promise<void> | null = null;
private listeners = new Set<() => void>();
private migrationState: MigrationState = {
isMigrating: false,
fromVersion: null,
toVersion: null,
projectName: null,
};
private exportState: ExportState = {
isExporting: false,
progress: 0,
result: null,
};
private exportCancelRequested = false;
2026-01-31 00:20:04 +01:00
constructor(private editor: EditorCore) {}
private async ensureStorageMigrations(): Promise<void> {
if (this.storageMigrationPromise) {
await this.storageMigrationPromise;
return;
}
this.storageMigrationPromise = (async () => {
2026-02-05 14:28:16 +01:00
await runStorageMigrations({
migrations,
onProgress: (progress: MigrationProgress) => {
this.migrationState = progress;
this.notify();
},
});
2026-01-31 00:20:04 +01:00
})();
await this.storageMigrationPromise;
}
async createNewProject({ name }: { name: string }): Promise<string> {
const mainScene = buildDefaultScene({ name: "Main scene", isMain: true });
const newProject: TProject = {
metadata: {
id: generateUUID(),
name,
duration: getProjectDurationFromScenes({ scenes: [mainScene] }),
createdAt: new Date(),
updatedAt: new Date(),
},
scenes: [mainScene],
currentSceneId: mainScene.id,
settings: {
fps: DEFAULT_FPS,
canvasSize: DEFAULT_CANVAS_SIZE,
originalCanvasSize: null,
background: {
type: "color",
color: DEFAULT_COLOR,
},
},
version: CURRENT_PROJECT_VERSION,
2026-01-31 00:20:04 +01:00
};
this.active = newProject;
this.notify();
this.editor.media.clearAllAssets();
this.editor.scenes.initializeScenes({
scenes: newProject.scenes,
currentSceneId: newProject.currentSceneId,
});
try {
await storageService.saveProject({ project: newProject });
this.updateMetadata(newProject);
return newProject.metadata.id;
} catch (error) {
toast.error("Failed to save new project");
throw error;
}
}
async loadProject({ id }: { id: string }): Promise<void> {
if (!this.isInitialized) {
this.isLoading = true;
this.notify();
}
this.editor.save.pause();
await this.ensureStorageMigrations();
this.editor.media.clearAllAssets();
this.editor.scenes.clearScenes();
try {
const result = await storageService.loadProject({ id });
if (!result) {
throw new Error(`Project with id ${id} not found`);
}
const project = result.project;
this.active = project;
this.notify();
if (project.scenes && project.scenes.length > 0) {
this.editor.scenes.initializeScenes({
scenes: project.scenes,
currentSceneId: project.currentSceneId,
});
}
await this.editor.media.loadProjectMedia({ projectId: id });
const allTracks = (project.scenes ?? []).flatMap((scene) => scene.tracks);
await loadFonts({ families: collectFontFamilies({ tracks: allTracks }) });
2026-01-31 00:20:04 +01:00
if (!project.metadata.thumbnail) {
const didUpdateThumbnail = await this.updateThumbnailFromTimeline();
if (didUpdateThumbnail) {
await this.saveCurrentProject();
}
}
} catch (error) {
console.error("Failed to load project:", error);
throw error;
} finally {
this.isLoading = false;
this.notify();
this.editor.save.resume();
}
}
async saveCurrentProject(): Promise<void> {
if (!this.active) return;
try {
const scenes = this.editor.scenes.getScenes();
const updatedProject = {
...this.active,
scenes,
metadata: {
...this.active.metadata,
duration: getProjectDurationFromScenes({ scenes }),
updatedAt: new Date(),
},
};
await storageService.saveProject({ project: updatedProject });
this.active = updatedProject;
this.updateMetadata(updatedProject);
} catch (error) {
console.error("Failed to save project:", error);
}
}
async export({ options }: { options: ExportOptions }): Promise<ExportResult> {
this.exportCancelRequested = false;
this.exportState = { isExporting: true, progress: 0, result: null };
this.notify();
const result = await this.editor.renderer.exportProject({
options,
onProgress: ({ progress }) => {
this.exportState = { ...this.exportState, progress };
this.notify();
},
onCancel: () => this.exportCancelRequested,
});
this.exportState = {
isExporting: false,
progress: this.exportState.progress,
result,
};
this.notify();
return result;
}
cancelExport(): void {
this.exportCancelRequested = true;
}
clearExportState(): void {
this.exportState = { isExporting: false, progress: 0, result: null };
this.notify();
}
getExportState(): ExportState {
return this.exportState;
2026-01-31 00:20:04 +01:00
}
async loadAllProjects(): Promise<void> {
if (!this.isInitialized) {
this.isLoading = true;
this.notify();
}
await this.ensureStorageMigrations();
try {
const metadata = await storageService.loadAllProjectsMetadata();
this.savedProjects = metadata;
this.notify();
} catch (error) {
console.error("Failed to load projects:", error);
} finally {
this.isLoading = false;
this.isInitialized = true;
this.notify();
}
}
async deleteProjects({ ids }: { ids: string[] }): Promise<void> {
const uniqueIds = Array.from(new Set(ids));
if (uniqueIds.length === 0) return;
try {
await Promise.all(
uniqueIds.map((id) =>
Promise.all([
storageService.deleteProjectMedia({ projectId: id }),
storageService.deleteProject({ id }),
]),
),
);
const idSet = new Set(uniqueIds);
this.savedProjects = this.savedProjects.filter(
(project) => !idSet.has(project.id),
);
const shouldClearActive =
this.active && idSet.has(this.active.metadata.id);
if (shouldClearActive) {
this.active = null;
this.editor.media.clearAllAssets();
this.editor.scenes.clearScenes();
}
this.notify();
} catch (error) {
console.error("Failed to delete projects:", error);
}
}
closeProject(): void {
this.active = null;
this.notify();
this.editor.media.clearAllAssets();
this.editor.scenes.clearScenes();
}
async renameProject({
id,
name,
}: {
id: string;
name: string;
}): Promise<void> {
try {
const result = await storageService.loadProject({ id });
if (!result) {
toast.error("Project not found", {
description: "Please try again",
});
return;
}
const updatedProject: TProject = {
...result.project,
metadata: {
...result.project.metadata,
name,
updatedAt: new Date(),
},
};
await storageService.saveProject({ project: updatedProject });
if (this.active?.metadata.id === id) {
this.active = updatedProject;
this.notify();
}
this.updateMetadata(updatedProject);
} catch (error) {
console.error("Failed to rename project:", error);
toast.error("Failed to rename project", {
description:
error instanceof Error ? error.message : "Please try again",
});
}
}
async duplicateProjects({ ids }: { ids: string[] }): Promise<string[]> {
const uniqueIds = Array.from(new Set(ids));
if (uniqueIds.length === 0) return [];
try {
const getDuplicateBaseName = ({ name }: { name: string }) => {
const match = name.match(/^\((\d+)\)\s+(.+)$/);
const number = match ? Number.parseInt(match[1], 10) : null;
const baseName = match ? match[2] : name;
return { baseName, number };
};
const loadResults = await Promise.all(
uniqueIds.map(async (projectId) => {
const result = await storageService.loadProject({ id: projectId });
return { projectId, project: result?.project ?? null };
}),
);
const missingProjectIds = loadResults
.filter((result) => !result.project)
.map((result) => result.projectId);
if (missingProjectIds.length > 0) {
toast.error(
missingProjectIds.length === 1
? "Project not found"
: "Projects not found",
{
description:
missingProjectIds.length === 1
? "Please try again"
: "Some projects could not be found",
},
);
throw new Error(`Projects not found: ${missingProjectIds.join(", ")}`);
}
const projectsToDuplicate = loadResults.flatMap((result) =>
result.project ? [result.project] : [],
);
const maxNumberByBaseName = new Map<string, number>();
for (const project of this.savedProjects) {
const { baseName, number } = getDuplicateBaseName({
name: project.name,
});
if (number === null) continue;
const currentMax = maxNumberByBaseName.get(baseName);
if (currentMax === undefined || number > currentMax) {
maxNumberByBaseName.set(baseName, number);
}
}
const nextNumberByBaseName = new Map<string, number>();
for (const [baseName, maxNumber] of maxNumberByBaseName) {
nextNumberByBaseName.set(baseName, maxNumber + 1);
}
const duplicationPlans = projectsToDuplicate.map((project) => {
const { baseName } = getDuplicateBaseName({
name: project.metadata.name,
});
const nextNumber = nextNumberByBaseName.get(baseName) ?? 1;
nextNumberByBaseName.set(baseName, nextNumber + 1);
const newProjectId = generateUUID();
const newProject: TProject = {
...project,
metadata: {
...project.metadata,
id: newProjectId,
name: `(${nextNumber}) ${baseName}`,
createdAt: new Date(),
updatedAt: new Date(),
},
};
return {
newProjectId,
newProject,
sourceProjectId: project.metadata.id,
};
});
await Promise.all(
duplicationPlans.map(({ newProject }) =>
storageService.saveProject({ project: newProject }),
),
);
await Promise.all(
duplicationPlans.map(async ({ sourceProjectId, newProjectId }) => {
const sourceMediaAssets = await storageService.loadAllMediaAssets({
projectId: sourceProjectId,
});
await Promise.all(
sourceMediaAssets.map((mediaAsset) =>
storageService.saveMediaAsset({
projectId: newProjectId,
mediaAsset,
}),
),
);
}),
);
for (const { newProject } of duplicationPlans) {
this.updateMetadata(newProject);
}
return duplicationPlans.map((plan) => plan.newProjectId);
} catch (error) {
console.error("Failed to duplicate projects:", error);
toast.error("Failed to duplicate projects", {
description:
error instanceof Error ? error.message : "Please try again",
});
throw error;
}
}
async updateSettings({
settings,
pushHistory = true,
}: {
settings: Partial<TProjectSettings>;
pushHistory?: boolean;
}): Promise<void> {
if (!this.active) return;
const command = new UpdateProjectSettingsCommand(settings);
if (pushHistory) {
this.editor.command.execute({ command });
return;
}
command.execute();
}
async updateThumbnail({ thumbnail }: { thumbnail: string }): Promise<void> {
if (!this.active) return;
const updatedProject: TProject = {
...this.active,
metadata: { ...this.active.metadata, thumbnail, updatedAt: new Date() },
};
this.active = updatedProject;
this.notify();
this.updateMetadata(updatedProject);
this.editor.save.markDirty();
}
async prepareExit(): Promise<void> {
if (!this.active) return;
try {
const didUpdateThumbnail = await this.updateThumbnailFromTimeline();
if (didUpdateThumbnail) {
await this.editor.save.flush();
}
} catch (error) {
console.error("Failed to generate project thumbnail on exit:", error);
}
}
getFilteredAndSortedProjects({
searchQuery,
sortOption,
}: {
searchQuery: string;
sortOption: TProjectSortOption;
}): TProjectMetadata[] {
const filteredProjects = this.savedProjects.filter((project) =>
project.name.toLowerCase().includes(searchQuery.toLowerCase()),
);
const [key, order] = sortOption.split("-") as [
TProjectSortKey,
"asc" | "desc",
];
const sortedProjects = [...filteredProjects].sort((a, b) => {
const aValue = a[key];
const bValue = b[key];
if (order === "asc") {
if (aValue < bValue) return -1;
if (aValue > bValue) return 1;
return 0;
}
if (aValue > bValue) return -1;
if (aValue < bValue) return 1;
return 0;
});
return sortedProjects;
}
isInvalidProjectId({ id }: { id: string }): boolean {
return this.invalidProjectIds.has(id);
}
markProjectIdAsInvalid({ id }: { id: string }): void {
this.invalidProjectIds.add(id);
this.notify();
}
clearInvalidProjectIds(): void {
this.invalidProjectIds.clear();
this.notify();
}
getActive(): TProject {
if (!this.active) {
throw new Error("No active project");
}
return this.active;
}
/**
* for agents:
* in most cases, the project is guaranteed to be active, in which getActive() should be used instead.
* for very rare cases, this function may be used.
*/
getActiveOrNull(): TProject | null {
return this.active;
}
getTimelineViewState(): TTimelineViewState {
return this.active?.timelineViewState ?? DEFAULT_TIMELINE_VIEW_STATE;
}
setTimelineViewState({ viewState }: { viewState: TTimelineViewState }): void {
if (!this.active) return;
this.active = {
...this.active,
timelineViewState: viewState ?? undefined,
};
this.editor.save.markDirty();
}
getSavedProjects(): TProjectMetadata[] {
return this.savedProjects;
}
getIsLoading(): boolean {
return this.isLoading;
}
getIsInitialized(): boolean {
return this.isInitialized;
}
getMigrationState(): MigrationState {
return this.migrationState;
}
setActiveProject({ project }: { project: TProject }): void {
this.active = project;
this.notify();
}
subscribe(listener: () => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
private async updateThumbnailFromTimeline(): Promise<boolean> {
if (!this.active) return false;
const tracks = this.editor.timeline.getTracks();
const mediaAssets = this.editor.media.getAssets();
const duration = this.editor.timeline.getTotalDuration();
if (duration === 0) return false;
const { canvasSize, background } = this.active.settings;
const scene = buildScene({
tracks,
mediaAssets,
duration,
canvasSize,
background,
});
const renderer = new CanvasRenderer({
width: canvasSize.width,
height: canvasSize.height,
fps: this.active.settings.fps,
});
const tempCanvas = document.createElement("canvas");
tempCanvas.width = canvasSize.width;
tempCanvas.height = canvasSize.height;
await renderer.renderToCanvas({
node: scene,
time: 0,
targetCanvas: tempCanvas,
});
const thumbnailDataUrl = tempCanvas.toDataURL("image/png");
await this.updateThumbnail({ thumbnail: thumbnailDataUrl });
return true;
}
private updateMetadata(project: TProject): void {
const index = this.savedProjects.findIndex(
(p) => p.id === project.metadata.id,
);
if (index !== -1) {
this.savedProjects[index] = project.metadata;
} else {
this.savedProjects = [project.metadata, ...this.savedProjects];
}
this.notify();
}
private notify(): void {
this.listeners.forEach((fn) => fn());
}
}