From 350b55081cde3a4332c982ea5c53b7d359029f6b Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Mon, 1 Sep 2025 16:17:06 +0200 Subject: refactor(core): switch to scene-based architecture --- apps/web/src/stores/media-store.ts | 8 +- apps/web/src/stores/project-store.ts | 145 +++++++++++++--------- apps/web/src/stores/scene-store.ts | 219 ++++++++++++++++++++++++++++++++++ apps/web/src/stores/sounds-store.ts | 4 +- apps/web/src/stores/timeline-store.ts | 76 ++++++++++-- 5 files changed, 382 insertions(+), 70 deletions(-) create mode 100644 apps/web/src/stores/scene-store.ts (limited to 'apps/web/src/stores') diff --git a/apps/web/src/stores/media-store.ts b/apps/web/src/stores/media-store.ts index c9e29fff..e00cffd5 100644 --- a/apps/web/src/stores/media-store.ts +++ b/apps/web/src/stores/media-store.ts @@ -152,7 +152,7 @@ export const useMediaStore = create((set, get) => ({ // Save to persistent storage in background try { - await storageService.saveMediaFile(projectId, newItem); + await storageService.saveMediaFile({ projectId, mediaItem: newItem }); } catch (error) { console.error("Failed to save media item:", error); // Remove from local state if save failed @@ -217,7 +217,7 @@ export const useMediaStore = create((set, get) => ({ // 3) Remove from persistent storage try { - await storageService.deleteMediaFile(projectId, id); + await storageService.deleteMediaFile({ projectId, id }); } catch (error) { console.error("Failed to delete media item:", error); } @@ -227,7 +227,7 @@ export const useMediaStore = create((set, get) => ({ set({ isLoading: true }); try { - const mediaItems = await storageService.loadAllMediaFiles(projectId); + const mediaItems = await storageService.loadAllMediaFiles({ projectId }); // Regenerate thumbnails for video items const updatedMediaItems = await Promise.all( @@ -282,7 +282,7 @@ export const useMediaStore = create((set, get) => ({ try { const mediaIds = state.mediaFiles.map((item) => item.id); await Promise.all( - mediaIds.map((id) => storageService.deleteMediaFile(projectId, id)) + mediaIds.map((id) => storageService.deleteMediaFile({ projectId, id })) ); } catch (error) { console.error("Failed to clear media items from storage:", error); diff --git a/apps/web/src/stores/project-store.ts b/apps/web/src/stores/project-store.ts index ed5eda73..d04c23d5 100644 --- a/apps/web/src/stores/project-store.ts +++ b/apps/web/src/stores/project-store.ts @@ -1,28 +1,44 @@ -import { TProject, BlurIntensity } from "@/types/project"; +import { TProject, BlurIntensity, Scene } from "@/types/project"; import { create } from "zustand"; import { storageService } from "@/lib/storage/storage-service"; import { toast } from "sonner"; import { useMediaStore } from "./media-store"; import { useTimelineStore } from "./timeline-store"; +import { useSceneStore } from "./scene-store"; import { generateUUID } from "@/lib/utils"; import { CanvasSize, CanvasMode } from "@/types/editor"; export const DEFAULT_CANVAS_SIZE: CanvasSize = { width: 1920, height: 1080 }; export const DEFAULT_FPS = 30; -const DEFAULT_PROJECT: TProject = { - id: generateUUID(), - name: "Untitled", - thumbnail: "", - createdAt: new Date(), - updatedAt: new Date(), - backgroundColor: "#000000", - backgroundType: "color", - blurIntensity: 8, - bookmarks: [], - fps: DEFAULT_FPS, - canvasSize: DEFAULT_CANVAS_SIZE, - canvasMode: "preset", +function createMainScene(): Scene { + return { + id: generateUUID(), + name: "Main Scene", + isMain: true, + createdAt: new Date(), + updatedAt: new Date(), + }; +} + +const createDefaultProject = (name: string): TProject => { + const mainScene = createMainScene(); + return { + id: generateUUID(), + name, + thumbnail: "", + createdAt: new Date(), + updatedAt: new Date(), + scenes: [mainScene], + currentSceneId: mainScene.id, + backgroundColor: "#000000", + backgroundType: "color", + blurIntensity: 8, + bookmarks: [], + fps: DEFAULT_FPS, + canvasSize: DEFAULT_CANVAS_SIZE, + canvasMode: "preset", + }; }; interface ProjectStore { @@ -104,7 +120,7 @@ export const useProjectStore = create((set, get) => ({ }; try { - await storageService.saveProject(updatedProject); + await storageService.saveProject({ project: updatedProject }); set({ activeProject: updatedProject }); await get().loadAllProjects(); // Refresh the list } catch (error) { @@ -152,7 +168,7 @@ export const useProjectStore = create((set, get) => ({ }; try { - await storageService.saveProject(updatedProject); + await storageService.saveProject({ project: updatedProject }); set({ activeProject: updatedProject }); await get().loadAllProjects(); // Refresh the list } catch (error) { @@ -164,23 +180,24 @@ export const useProjectStore = create((set, get) => ({ }, createNewProject: async (name: string) => { - const newProject: TProject = { - ...DEFAULT_PROJECT, - id: generateUUID(), - name, - createdAt: new Date(), - updatedAt: new Date(), - }; + const newProject = createDefaultProject(name); set({ activeProject: newProject }); const mediaStore = useMediaStore.getState(); const timelineStore = useTimelineStore.getState(); + const sceneStore = useSceneStore.getState(); + mediaStore.clearAllMedia(); timelineStore.clearTimeline(); + sceneStore.initializeScenes({ + scenes: newProject.scenes, + currentSceneId: newProject.currentSceneId, + }); + try { - await storageService.saveProject(newProject); + await storageService.saveProject({ project: newProject }); // Reload all projects to update the list await get().loadAllProjects(); return newProject.id; @@ -195,21 +212,35 @@ export const useProjectStore = create((set, get) => ({ set({ isLoading: true }); } - // Clear media and timeline immediately to prevent flickering when switching projects + // Prevent flicker when switching projects - clear all stores const mediaStore = useMediaStore.getState(); const timelineStore = useTimelineStore.getState(); + const sceneStore = useSceneStore.getState(); + mediaStore.clearAllMedia(); timelineStore.clearTimeline(); + sceneStore.clearScenes(); try { - const project = await storageService.loadProject(id); + const project = await storageService.loadProject({ id }); if (project) { set({ activeProject: project }); - // Load project-specific data in parallel + if (project.scenes && project.scenes.length > 0) { + sceneStore.initializeScenes({ + scenes: project.scenes, + currentSceneId: project.currentSceneId, + }); + } + + const currentScene = sceneStore.currentScene; + await Promise.all([ mediaStore.loadProjectMedia(id), - timelineStore.loadProjectTimeline(id), + timelineStore.loadProjectTimeline({ + projectId: id, + sceneId: currentScene?.id, + }), ]); } else { throw new Error(`Project with id ${id} not found`); @@ -227,11 +258,16 @@ export const useProjectStore = create((set, get) => ({ if (!activeProject) return; try { - // Save project metadata and timeline data in parallel const timelineStore = useTimelineStore.getState(); + const sceneStore = useSceneStore.getState(); + const currentScene = sceneStore.currentScene; + await Promise.all([ - storageService.saveProject(activeProject), - timelineStore.saveProjectTimeline(activeProject.id), + storageService.saveProject({ project: activeProject }), + timelineStore.saveProjectTimeline({ + projectId: activeProject.id, + sceneId: currentScene?.id, + }), ]); await get().loadAllProjects(); // Refresh the list } catch (error) { @@ -256,22 +292,24 @@ export const useProjectStore = create((set, get) => ({ deleteProject: async (id: string) => { try { - // Delete project data in parallel await Promise.all([ - storageService.deleteProjectMedia(id), - storageService.deleteProjectTimeline(id), - storageService.deleteProject(id), + storageService.deleteProjectMedia({ projectId: id }), + storageService.deleteProjectTimeline({ projectId: id }), + storageService.deleteProject({ id }), ]); await get().loadAllProjects(); // Refresh the list - // If we deleted the active project, close it and clear data + // If deleted active project, close it and clear data const { activeProject } = get(); if (activeProject?.id === id) { set({ activeProject: null }); const mediaStore = useMediaStore.getState(); const timelineStore = useTimelineStore.getState(); + const sceneStore = useSceneStore.getState(); + mediaStore.clearAllMedia(); timelineStore.clearTimeline(); + sceneStore.clearScenes(); } } catch (error) { console.error("Failed to delete project:", error); @@ -281,11 +319,13 @@ export const useProjectStore = create((set, get) => ({ closeProject: () => { set({ activeProject: null }); - // Clear data from stores when closing project const mediaStore = useMediaStore.getState(); const timelineStore = useTimelineStore.getState(); + const sceneStore = useSceneStore.getState(); + mediaStore.clearAllMedia(); timelineStore.clearTimeline(); + sceneStore.clearScenes(); }, renameProject: async (id: string, name: string) => { @@ -307,12 +347,11 @@ export const useProjectStore = create((set, get) => ({ }; try { - // Save to storage - await storageService.saveProject(updatedProject); + await storageService.saveProject({ project: updatedProject }); await get().loadAllProjects(); - // Update activeProject if it's the same project + // Update activeProject if same project const { activeProject } = get(); if (activeProject?.id === id) { set({ activeProject: updatedProject }); @@ -328,7 +367,7 @@ export const useProjectStore = create((set, get) => ({ duplicateProject: async (projectId: string) => { try { - const project = await storageService.loadProject(projectId); + const project = await storageService.loadProject({ id: projectId }); if (!project) { toast.error("Project not found", { description: "Please try again", @@ -355,14 +394,14 @@ export const useProjectStore = create((set, get) => ({ existingNumbers.length > 0 ? Math.max(...existingNumbers) + 1 : 1; const newProject: TProject = { - ...project, // Copy all properties from the original project + ...project, id: generateUUID(), name: `(${nextNumber}) ${baseName}`, createdAt: new Date(), updatedAt: new Date(), }; - await storageService.saveProject(newProject); + await storageService.saveProject({ project: newProject }); await get().loadAllProjects(); return newProject.id; } catch (error) { @@ -386,9 +425,9 @@ export const useProjectStore = create((set, get) => ({ }; try { - await storageService.saveProject(updatedProject); + await storageService.saveProject({ project: updatedProject }); set({ activeProject: updatedProject }); - await get().loadAllProjects(); // Refresh the list + await get().loadAllProjects(); } catch (error) { console.error("Failed to update project background:", error); toast.error("Failed to update background", { @@ -417,9 +456,9 @@ export const useProjectStore = create((set, get) => ({ }; try { - await storageService.saveProject(updatedProject); + await storageService.saveProject({ project: updatedProject }); set({ activeProject: updatedProject }); - await get().loadAllProjects(); // Refresh the list + await get().loadAllProjects(); } catch (error) { console.error("Failed to update background type:", error); toast.error("Failed to update background", { @@ -439,9 +478,9 @@ export const useProjectStore = create((set, get) => ({ }; try { - await storageService.saveProject(updatedProject); + await storageService.saveProject({ project: updatedProject }); set({ activeProject: updatedProject }); - await get().loadAllProjects(); // Refresh the list + await get().loadAllProjects(); } catch (error) { console.error("Failed to update project FPS:", error); toast.error("Failed to update project FPS", { @@ -462,9 +501,9 @@ export const useProjectStore = create((set, get) => ({ }; try { - await storageService.saveProject(updatedProject); + await storageService.saveProject({ project: updatedProject }); set({ activeProject: updatedProject }); - await get().loadAllProjects(); // Refresh the list + await get().loadAllProjects(); } catch (error) { console.error("Failed to update canvas size:", error); toast.error("Failed to update canvas size", { @@ -476,12 +515,10 @@ export const useProjectStore = create((set, get) => ({ getFilteredAndSortedProjects: (searchQuery: string, sortOption: string) => { const { savedProjects } = get(); - // Filter projects by search query const filteredProjects = savedProjects.filter((project) => project.name.toLowerCase().includes(searchQuery.toLowerCase()) ); - // Sort filtered projects const sortedProjects = [...filteredProjects].sort((a, b) => { const [key, order] = sortOption.split("-"); @@ -508,7 +545,7 @@ export const useProjectStore = create((set, get) => ({ return sortedProjects; }, - // Global invalid project ID tracking implementation + // Global invalid project ID tracking isInvalidProjectId: (id: string) => { const invalidIds = get().invalidProjectIds || new Set(); return invalidIds.has(id); diff --git a/apps/web/src/stores/scene-store.ts b/apps/web/src/stores/scene-store.ts new file mode 100644 index 00000000..e16185e9 --- /dev/null +++ b/apps/web/src/stores/scene-store.ts @@ -0,0 +1,219 @@ +import { create } from "zustand"; +import { Scene } from "@/types/project"; +import { useProjectStore } from "./project-store"; +import { useTimelineStore } from "./timeline-store"; +import { storageService } from "@/lib/storage/storage-service"; +import { generateUUID } from "@/lib/utils"; + +export function getMainScene({ scenes }: { scenes: Scene[] }): Scene | null { + return scenes.find((scene) => scene.isMain) || null; +} + +interface SceneStore { + // Current scene state + currentScene: Scene | null; + scenes: Scene[]; + + // Scene management + createScene: ({ + name, + isMain, + }: { + name: string; + isMain: boolean; + }) => Promise; + renameScene: ({ + sceneId, + name, + }: { + sceneId: string; + name: string; + }) => Promise; + switchToScene: ({ sceneId }: { sceneId: string }) => Promise; + + // Scene utilities + getMainScene: () => Scene | null; + getCurrentScene: () => Scene | null; + + // Project integration + loadProjectScenes: ({ projectId }: { projectId: string }) => Promise; + initializeScenes: ({ + scenes, + currentSceneId, + }: { + scenes: Scene[]; + currentSceneId?: string; + }) => void; + clearScenes: () => void; +} + +export const useSceneStore = create((set, get) => ({ + currentScene: null, + scenes: [], + + createScene: async ({ name, isMain = false }) => { + const { scenes } = get(); + + const newScene = { + id: generateUUID(), + name, + isMain, + createdAt: new Date(), + updatedAt: new Date(), + }; + const updatedScenes = [...scenes, newScene]; + + const projectStore = useProjectStore.getState(); + const { activeProject } = projectStore; + + if (!activeProject) { + throw new Error("No active project"); + } + + const updatedProject = { + ...activeProject, + scenes: updatedScenes, + updatedAt: new Date(), + }; + + try { + await storageService.saveProject({ project: updatedProject }); + useProjectStore.setState({ activeProject: updatedProject }); + set({ scenes: updatedScenes }); + return newScene.id; + } catch (error) { + console.error("Failed to create scene:", error); + throw error; + } + }, + + renameScene: async ({ sceneId, name }: { sceneId: string; name: string }) => { + const { scenes } = get(); + const updatedScenes = scenes.map((scene) => + scene.id === sceneId ? { ...scene, name, updatedAt: new Date() } : scene + ); + + // Update project + const projectStore = useProjectStore.getState(); + const { activeProject } = projectStore; + + if (!activeProject) { + throw new Error("No active project"); + } + + const updatedProject = { + ...activeProject, + scenes: updatedScenes, + updatedAt: new Date(), + }; + + try { + await storageService.saveProject({ project: updatedProject }); + useProjectStore.setState({ activeProject: updatedProject }); + set({ + scenes: updatedScenes, + currentScene: updatedScenes.find((s) => s.id === sceneId) || null, + }); + } catch (error) { + console.error("Failed to rename scene:", error); + throw error; + } + }, + + switchToScene: async ({ sceneId }: { sceneId: string }) => { + const { scenes } = get(); + const targetScene = scenes.find((s) => s.id === sceneId); + + if (!targetScene) { + throw new Error("Scene not found"); + } + + const timelineStore = useTimelineStore.getState(); + const projectStore = useProjectStore.getState(); + const { activeProject } = projectStore; + const { currentScene } = get(); + + if (activeProject && currentScene) { + await timelineStore.saveProjectTimeline({ + projectId: activeProject.id, + sceneId: currentScene.id, + }); + } + + if (activeProject) { + await timelineStore.loadProjectTimeline({ + projectId: activeProject.id, + sceneId, + }); + + const updatedProject = { + ...activeProject, + currentSceneId: sceneId, + updatedAt: new Date(), + }; + + await storageService.saveProject({ project: updatedProject }); + useProjectStore.setState({ activeProject: updatedProject }); + } + + set({ currentScene: targetScene }); + }, + + getMainScene: () => { + const { scenes } = get(); + return scenes.find((scene) => scene.isMain) || null; + }, + + getCurrentScene: () => { + return get().currentScene; + }, + + loadProjectScenes: async ({ projectId }: { projectId: string }) => { + try { + const project = await storageService.loadProject({ id: projectId }); + if (project?.scenes) { + const ensuredScenes = project.scenes.map((scene) => ({ + ...scene, + isMain: scene.isMain || false, + })); + const currentScene = + ensuredScenes.find((s) => s.id === project.currentSceneId) || + ensuredScenes[0]; + + set({ + scenes: ensuredScenes, + currentScene, + }); + } + } catch (error) { + console.error("Failed to load project scenes:", error); + set({ scenes: [], currentScene: null }); + } + }, + + initializeScenes: ({ + scenes, + currentSceneId, + }: { + scenes: Scene[]; + currentSceneId?: string; + }) => { + const currentScene = currentSceneId + ? scenes.find((s) => s.id === currentSceneId) + : null; + + const fallbackScene = getMainScene({ scenes }); + + set({ + scenes, + currentScene: currentScene || fallbackScene, + }); + }, + + clearScenes: () => { + set({ + scenes: [], + currentScene: null, + }); + }, +})); diff --git a/apps/web/src/stores/sounds-store.ts b/apps/web/src/stores/sounds-store.ts index 6dffa198..579eee7a 100644 --- a/apps/web/src/stores/sounds-store.ts +++ b/apps/web/src/stores/sounds-store.ts @@ -162,7 +162,7 @@ export const useSoundsStore = create((set, get) => ({ saveSoundEffect: async (soundEffect: SoundEffect) => { try { - await storageService.saveSoundEffect(soundEffect); + await storageService.saveSoundEffect({ soundEffect }); // Refresh saved sounds const savedSoundsData = await storageService.loadSavedSounds(); @@ -178,7 +178,7 @@ export const useSoundsStore = create((set, get) => ({ removeSavedSound: async (soundId: number) => { try { - await storageService.removeSavedSound(soundId); + await storageService.removeSavedSound({ soundId }); // Update local state immediately set((state) => ({ diff --git a/apps/web/src/stores/timeline-store.ts b/apps/web/src/stores/timeline-store.ts index f0bae00c..8caa71a7 100644 --- a/apps/web/src/stores/timeline-store.ts +++ b/apps/web/src/stores/timeline-store.ts @@ -15,6 +15,7 @@ import { MediaFile } from "@/types/media"; import { findBestCanvasPreset } from "@/lib/editor-utils"; import { storageService } from "@/lib/storage/storage-service"; import { useProjectStore } from "./project-store"; +import { useSceneStore } from "./scene-store"; import { generateUUID } from "@/lib/utils"; import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; import { checkElementOverlaps, resolveElementOverlaps } from "@/lib/timeline"; @@ -178,8 +179,20 @@ interface TimelineStore { pushHistory: () => void; // Persistence actions - loadProjectTimeline: (projectId: string) => Promise; - saveProjectTimeline: (projectId: string) => Promise; + loadProjectTimeline: ({ + projectId, + sceneId, + }: { + projectId: string; + sceneId?: string; + }) => Promise; + saveProjectTimeline: ({ + projectId, + sceneId, + }: { + projectId: string; + sceneId?: string; + }) => Promise; clearTimeline: () => void; // Clipboard actions @@ -235,9 +248,15 @@ export const useTimelineStore = create((set, get) => { // Helper to auto-save timeline changes const autoSaveTimeline = async () => { const activeProject = useProjectStore.getState().activeProject; - if (activeProject) { + const currentScene = useSceneStore.getState().currentScene; + + if (activeProject && currentScene) { try { - await storageService.saveTimeline(activeProject.id, get()._tracks); + await storageService.saveTimeline({ + projectId: activeProject.id, + tracks: get()._tracks, + sceneId: currentScene.id, + }); } catch (error) { console.error("Failed to auto-save timeline:", error); } @@ -1229,8 +1248,24 @@ export const useTimelineStore = create((set, get) => { getProjectThumbnail: async (projectId) => { try { - const tracks = await storageService.loadTimeline(projectId); - const mediaItems = await storageService.loadAllMediaFiles(projectId); + const project = await storageService.loadProject({ id: projectId }); + if (!project) return null; + + // For scene-based projects, use main scene timeline + // For legacy projects, use legacy timeline format + let sceneId: string | undefined; + if (project.scenes && project.scenes.length > 0) { + const mainScene = project.scenes.find((s) => s.isMain); + sceneId = mainScene?.id; + } + + const tracks = await storageService.loadTimeline({ + projectId, + sceneId, + }); + const mediaItems = await storageService.loadAllMediaFiles({ + projectId, + }); if (!tracks || !mediaItems.length) return null; @@ -1330,9 +1365,19 @@ export const useTimelineStore = create((set, get) => { }); }, - loadProjectTimeline: async (projectId) => { + loadProjectTimeline: async ({ + projectId, + sceneId, + }: { + projectId: string; + sceneId?: string; + }) => { try { - const tracks = await storageService.loadTimeline(projectId); + const tracks = await storageService.loadTimeline({ + projectId, + sceneId, + }); + if (tracks) { updateTracks(tracks); } else { @@ -1348,9 +1393,20 @@ export const useTimelineStore = create((set, get) => { } }, - saveProjectTimeline: async (projectId) => { + saveProjectTimeline: async ({ + projectId, + sceneId, + }: { + projectId: string; + sceneId?: string; + }) => { + const { _tracks } = get(); try { - await storageService.saveTimeline(projectId, get()._tracks); + await storageService.saveTimeline({ + projectId, + tracks: _tracks, + sceneId, + }); } catch (error) { console.error("Failed to save timeline:", error); } -- cgit v1.3.1