summaryrefslogtreecommitdiff
path: root/apps/web/src/core/managers/project-manager.ts
diff options
context:
space:
mode:
authorMaze <167211895+mazeincoding@users.noreply.github.com>2026-01-31 00:20:04 +0100
committerGitHub <noreply@github.com>2026-01-31 00:20:04 +0100
commit7bf0984698e43b9aa2dd879f65951318ed410390 (patch)
treeccf2cd590a47d550152afc07129cb55c71a9ec35 /apps/web/src/core/managers/project-manager.ts
parent0173db99448d1c4c6415dcf15272e17551b48b04 (diff)
downloadopencut-7bf0984698e43b9aa2dd879f65951318ed410390.tar.gz
codebase overhaul (#697)
Diffstat (limited to 'apps/web/src/core/managers/project-manager.ts')
-rw-r--r--apps/web/src/core/managers/project-manager.ts648
1 files changed, 648 insertions, 0 deletions
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<string>();
+ private storageMigrationPromise: Promise<void> | 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<void> {
+ 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<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_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<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 });
+
+ 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> {
+ return this.editor.renderer.exportProject({ options });
+ }
+
+ 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;
+ }
+
+ private setMigrationState(state: Partial<MigrationState>): 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<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());
+ }
+}