From 7bf0984698e43b9aa2dd879f65951318ed410390 Mon Sep 17 00:00:00 2001 From: Maze <167211895+mazeincoding@users.noreply.github.com> Date: Sat, 31 Jan 2026 00:20:04 +0100 Subject: codebase overhaul (#697) --- apps/web/src/core/managers/project-manager.ts | 648 ++++++++++++++++++++++++++ 1 file changed, 648 insertions(+) create mode 100644 apps/web/src/core/managers/project-manager.ts (limited to 'apps/web/src/core/managers/project-manager.ts') diff --git a/apps/web/src/core/managers/project-manager.ts b/apps/web/src/core/managers/project-manager.ts new file mode 100644 index 00000000..db9e3fad --- /dev/null +++ b/apps/web/src/core/managers/project-manager.ts @@ -0,0 +1,648 @@ +import type { EditorCore } from "@/core"; +import type { + TProject, + TProjectMetadata, + TProjectSortKey, + TProjectSortOption, + TProjectSettings, + TTimelineViewState, +} from "@/types/project"; +import type { ExportOptions, ExportResult } from "@/types/export"; +import { storageService } from "@/services/storage/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_STORAGE_VERSION, + migrations, + runStorageMigrations, +} from "@/services/storage/migrations"; +import { DEFAULT_TIMELINE_VIEW_STATE } from "@/constants/timeline-constants"; + +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, + }; + + constructor(private editor: EditorCore) {} + + private async ensureStorageMigrations(): Promise { + if (this.storageMigrationPromise) { + await this.storageMigrationPromise; + return; + } + + 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 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, + originalCanvasSize: null, + background: { + type: "color", + color: DEFAULT_COLOR, + }, + }, + version: CURRENT_STORAGE_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 }); + + 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 { + return this.editor.renderer.exportProject({ options }); + } + + async loadAllProjects(): Promise { + 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 { + 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(); + } + + 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 { + 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; + } + + private setMigrationState(state: Partial): void { + this.migrationState = { ...this.migrationState, ...state }; + this.notify(); + } + + 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(); + + 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()); + } +} -- cgit v1.3.1