OpenCut/apps/web/src/core/managers/scenes-manager.ts

457 lines
12 KiB
TypeScript

import type { EditorCore } from "@/core";
import type { TScene } from "@/types/timeline";
import { storageService } from "@/lib/storage/storage-service";
import { toast } from "sonner";
import {
updateSceneInArray,
getMainScene,
ensureMainScene,
buildDefaultScene,
canDeleteScene,
getFallbackSceneAfterDelete,
findCurrentScene,
} from "@/lib/scene-utils";
import {
getFrameTime,
toggleBookmarkInArray,
removeBookmarkFromArray,
isBookmarkAtTime,
} from "@/lib/timeline/bookmark-utils";
import { ensureMainTrack } from "@/lib/timeline/track-utils";
export class ScenesManager {
private active: TScene | null = null;
private list: TScene[] = [];
private listeners = new Set<() => void>();
constructor(private editor: EditorCore) {}
async createScene({
name,
isMain = false,
}: {
name: string;
isMain: boolean;
}): Promise<string> {
const newScene = buildDefaultScene({ name, isMain });
const updatedScenes = [...this.list, newScene];
try {
await this.updateProjectWithScenes({ updatedScenes });
return newScene.id;
} catch (error) {
console.error("Failed to create scene:", error);
throw error;
}
}
async deleteScene({ sceneId }: { sceneId: string }): Promise<void> {
const sceneToDelete = this.list.find((s) => s.id === sceneId);
if (!sceneToDelete) {
throw new Error("Scene not found");
}
const { canDelete, reason } = canDeleteScene({ scene: sceneToDelete });
if (!canDelete) {
throw new Error(reason);
}
const updatedScenes = this.list.filter((s) => s.id !== sceneId);
const newCurrentScene = getFallbackSceneAfterDelete({
scenes: updatedScenes,
deletedSceneId: sceneId,
currentSceneId: this.active?.id || null,
});
try {
await this.updateProjectWithScenes({
updatedScenes,
updatedSceneId: newCurrentScene?.id,
});
} catch (error) {
console.error("Failed to delete scene:", error);
throw error;
}
}
async renameScene({
sceneId,
name,
}: {
sceneId: string;
name: string;
}): Promise<void> {
const updatedScenes = updateSceneInArray({
scenes: this.list,
sceneId,
updates: { name, updatedAt: new Date() },
});
try {
await this.updateProjectWithScenes({
updatedScenes,
updatedSceneId: sceneId,
});
} catch (error) {
console.error("Failed to rename scene:", error);
throw error;
}
}
async switchToScene({ sceneId }: { sceneId: string }): Promise<void> {
const targetScene = this.list.find((s) => s.id === sceneId);
if (!targetScene) {
throw new Error("Scene not found");
}
const activeProject = this.editor.project.getActive();
if (activeProject) {
const updatedProject = {
...activeProject,
currentSceneId: sceneId,
metadata: {
...activeProject.metadata,
updatedAt: new Date(),
},
};
await storageService.saveProject({ project: updatedProject });
this.editor.project.setActiveProject({ project: updatedProject });
}
this.active = targetScene;
this.notify();
}
async toggleBookmark({ time }: { time: number }): Promise<void> {
const activeScene = this.getActiveScene();
if (!activeScene || !this.active) return;
const activeProject = this.editor.project.getActive();
if (!activeProject) return;
const frameTime = getFrameTime({
time,
fps: activeProject.settings.fps,
});
const updatedBookmarks = toggleBookmarkInArray({
bookmarks: activeScene.bookmarks,
frameTime,
});
const updatedScenes = updateSceneInArray({
scenes: this.list,
sceneId: activeScene.id,
updates: { bookmarks: updatedBookmarks },
});
try {
await this.updateProjectWithScenes({
updatedScenes,
updatedSceneId: activeScene.id,
});
} catch (error) {
console.error("Failed to update scene bookmarks:", error);
toast.error("Failed to update bookmarks", {
description: "Please try again",
});
}
}
isBookmarked({ time }: { time: number }): boolean {
const activeScene = this.getActiveScene();
const activeProject = this.editor.project.getActive();
if (!activeScene || !this.active || !activeProject) return false;
const frameTime = getFrameTime({
time,
fps: activeProject.settings.fps,
});
return isBookmarkAtTime({ bookmarks: activeScene.bookmarks, frameTime });
}
async removeBookmark({ time }: { time: number }): Promise<void> {
const activeScene = this.getActiveScene();
if (!activeScene || !this.active) return;
const activeProject = this.editor.project.getActive();
if (!activeProject) return;
const frameTime = getFrameTime({
time,
fps: activeProject.settings.fps,
});
const updatedBookmarks = removeBookmarkFromArray({
bookmarks: activeScene.bookmarks,
frameTime,
});
if (updatedBookmarks.length === activeScene.bookmarks.length) {
return;
}
const updatedScenes = updateSceneInArray({
scenes: this.list,
sceneId: activeScene.id,
updates: { bookmarks: updatedBookmarks },
});
try {
await this.updateProjectWithScenes({
updatedScenes,
updatedSceneId: activeScene.id,
});
} catch (error) {
console.error("Failed to update scene bookmarks:", error);
toast.error("Failed to remove bookmark", {
description: "Please try again",
});
}
}
async loadProjectScenes({ projectId }: { projectId: string }): Promise<void> {
try {
const result = await storageService.loadProject({ id: projectId });
if (result?.project.scenes) {
const { scenes: ensuredScenes, hasAddedMainTrack } =
this.ensureScenesHaveMainTrack({
scenes: result.project.scenes ?? [],
});
const currentScene = findCurrentScene({
scenes: ensuredScenes,
currentSceneId: result.project.currentSceneId,
});
this.list = ensuredScenes;
this.active = currentScene;
this.notify();
if (hasAddedMainTrack) {
const activeProject = this.editor.project.getActive();
if (activeProject) {
const updatedProject = {
...activeProject,
scenes: ensuredScenes,
metadata: {
...activeProject.metadata,
updatedAt: new Date(),
},
};
await storageService.saveProject({ project: updatedProject });
this.editor.project.setActiveProject({ project: updatedProject });
}
}
}
} catch (error) {
console.error("Failed to load project scenes:", error);
this.list = [];
this.active = null;
this.notify();
}
}
initializeScenes({
scenes,
currentSceneId,
}: {
scenes: TScene[];
currentSceneId?: string;
}): void {
const ensuredScenes = ensureMainScene({ scenes });
const { scenes: scenesWithMainTracks, hasAddedMainTrack } =
this.ensureScenesHaveMainTrack({ scenes: ensuredScenes });
const currentScene = currentSceneId
? scenesWithMainTracks.find((s) => s.id === currentSceneId)
: null;
const fallbackScene = getMainScene({ scenes: scenesWithMainTracks });
this.list = scenesWithMainTracks;
this.active = currentScene || fallbackScene;
this.notify();
const hasAddedMainScene = ensuredScenes.length > scenes.length;
if (hasAddedMainScene || hasAddedMainTrack) {
const activeProject = this.editor.project.getActive();
if (activeProject) {
const updatedProject = {
...activeProject,
scenes: scenesWithMainTracks,
metadata: {
...activeProject.metadata,
updatedAt: new Date(),
},
};
storageService
.saveProject({ project: updatedProject })
.then(() => {
this.editor.project.setActiveProject({ project: updatedProject });
})
.catch((error) => {
console.error(
"Failed to save project with background scene:",
error,
);
});
}
}
}
clearScenes(): void {
this.list = [];
this.active = null;
this.notify();
}
getActiveScene(): TScene {
if (!this.active) {
throw new Error("No active scene.");
}
return this.active;
}
getScenes(): TScene[] {
return this.list;
}
setScenes({
scenes,
activeSceneId,
}: {
scenes: TScene[];
activeSceneId?: string;
}): void {
this.list = scenes;
this.active = activeSceneId
? (scenes.find((s) => s.id === activeSceneId) ?? null)
: this.active;
this.notify();
const activeProject = this.editor.project.getActive();
if (activeProject) {
const updatedProject = {
...activeProject,
scenes,
metadata: {
...activeProject.metadata,
updatedAt: new Date(),
},
};
storageService.saveProject({ project: updatedProject }).catch((error) => {
console.error("Failed to persist scenes:", error);
});
this.editor.project.setActiveProject({ project: updatedProject });
}
}
subscribe(listener: () => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
private notify(): void {
this.listeners.forEach((fn) => fn());
}
updateSceneTracks({
tracks,
}: {
tracks: import("@/types/timeline").TimelineTrack[];
}): void {
if (!this.active) return;
const updatedScene: TScene = {
...this.active,
tracks,
updatedAt: new Date(),
};
this.list = this.list.map((s) =>
s.id === this.active?.id ? updatedScene : s,
);
this.active = updatedScene;
this.notify();
const activeProject = this.editor.project.getActive();
if (activeProject) {
const updatedProject = {
...activeProject,
scenes: this.list,
metadata: {
...activeProject.metadata,
updatedAt: new Date(),
},
};
this.editor.project.setActiveProject({ project: updatedProject });
}
}
private ensureScenesHaveMainTrack({ scenes }: { scenes: TScene[] }): {
scenes: TScene[];
hasAddedMainTrack: boolean;
} {
let hasAddedMainTrack = false;
const ensuredScenes: TScene[] = [];
for (const scene of scenes) {
const existingTracks = scene.tracks ?? [];
const updatedTracks = ensureMainTrack({ tracks: existingTracks });
if (updatedTracks !== existingTracks) {
hasAddedMainTrack = true;
ensuredScenes.push({
...scene,
tracks: updatedTracks,
updatedAt: new Date(),
});
} else {
ensuredScenes.push(scene);
}
}
return { scenes: ensuredScenes, hasAddedMainTrack };
}
private async updateProjectWithScenes({
updatedScenes,
updatedSceneId,
}: {
updatedScenes: TScene[];
updatedSceneId?: string;
}): Promise<void> {
const activeProject = this.editor.project.getActive();
if (!activeProject) {
throw new Error("No active project");
}
const updatedScene = updatedSceneId
? updatedScenes.find((s) => s.id === updatedSceneId)
: this.active;
const updatedProject = {
...activeProject,
scenes: updatedScenes,
metadata: {
...activeProject.metadata,
updatedAt: new Date(),
},
};
await storageService.saveProject({ project: updatedProject });
this.editor.project.setActiveProject({ project: updatedProject });
this.list = updatedScenes;
this.active = updatedScene || null;
this.notify();
}
}