import type { EditorCore } from "@/core"; import type { TProject, TProjectMetadata, TProjectSortKey, TProjectSortOption, TProjectSettings, TTimelineViewState, } from "@/lib/project/types"; import type { ExportOptions, ExportResult, ExportState } from "@/lib/export"; import { storageService } from "@/services/storage/service"; 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, migrations, runStorageMigrations, type MigrationProgress, } from "@/services/storage/migrations"; import { loadFonts } from "@/lib/fonts/google-fonts"; import { DEFAULTS } from "@/lib/timeline/defaults"; import { getElementFontFamilies } from "@/lib/timeline/element-utils"; import { getRaisedProjectFpsForImportedMedia } from "@/lib/project/fps"; import type { MediaAsset } from "@/lib/media/types"; 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(); private storageMigrationPromise: Promise | 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; constructor(private editor: EditorCore) {} private async ensureStorageMigrations(): Promise { if (this.storageMigrationPromise) { await this.storageMigrationPromise; return; } this.storageMigrationPromise = (async () => { await runStorageMigrations({ migrations, onProgress: (progress: MigrationProgress) => { this.migrationState = progress; this.notify(); }, }); })(); await this.storageMigrationPromise; } async createNewProject({ name }: { name: string }): Promise { 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, canvasSizeMode: "preset", lastCustomCanvasSize: null, originalCanvasSize: null, background: { type: "color", color: DEFAULT_COLOR, }, }, version: CURRENT_PROJECT_VERSION, }; 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 { 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: getElementFontFamilies({ tracks: allTracks }), }); 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 { 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 { 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; } async loadAllProjects(): Promise { if (!this.isInitialized) { this.isLoading = true; this.notify(); } try { 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(); } } catch (error) { console.error("Failed to run migrations:", error); this.isLoading = false; this.isInitialized = true; this.notify(); } } async deleteProjects({ ids }: { ids: string[] }): Promise { 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 { 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 { 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(); 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(); 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; pushHistory?: boolean; }): Promise { if (!this.active) return; const command = new UpdateProjectSettingsCommand(settings); if (pushHistory) { this.editor.command.execute({ command }); return; } command.execute(); } ratchetFpsForImportedMedia({ importedAssets, }: { importedAssets: Array>; }): number | null { if (!this.active) return null; const nextFps = getRaisedProjectFpsForImportedMedia({ currentFps: this.active.settings.fps, importedAssets, }); if (nextFps === null) return null; new UpdateProjectSettingsCommand({ fps: nextFps }).execute(); return nextFps; } async updateThumbnail({ thumbnail }: { thumbnail: string }): Promise { 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 { console.log("prepareExit", this.active); if (!this.active) return; try { const didUpdateThumbnail = await this.updateThumbnailFromTimeline(); console.log("didUpdateThumbnail", didUpdateThumbnail); 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 ?? DEFAULTS.timeline.viewState; } setTimelineViewState({ viewState }: { viewState: TTimelineViewState }): void { if (!this.active) return; this.active = { ...this.active, timelineViewState: viewState ?? undefined, }; this.editor.save.markDirty(); this.notify(); } 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 { if (!this.active) return false; const tracks = this.editor.timeline.getTracks(); const mediaAssets = this.editor.media.getAssets(); const duration = this.editor.timeline.getTotalDuration(); const { canvasSize, background } = this.active.settings; const scene = buildScene({ tracks, mediaAssets, duration: duration || 1, 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 = this.savedProjects.with(index, project.metadata); } else { this.savedProjects = [project.metadata, ...this.savedProjects]; } this.notify(); } private notify(): void { this.listeners.forEach((fn) => { fn(); }); } }