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/lib/storage/storage-service.ts | 159 +++++++++++++++++++++------- apps/web/src/lib/storage/types.ts | 20 +++- 2 files changed, 140 insertions(+), 39 deletions(-) (limited to 'apps/web/src/lib/storage') diff --git a/apps/web/src/lib/storage/storage-service.ts b/apps/web/src/lib/storage/storage-service.ts index 3aadf883..80f10d3d 100644 --- a/apps/web/src/lib/storage/storage-service.ts +++ b/apps/web/src/lib/storage/storage-service.ts @@ -6,6 +6,7 @@ import { MediaFileData, StorageConfig, SerializedProject, + SerializedScene, TimelineData, } from "./types"; import { TimelineTrack } from "@/types/timeline"; @@ -39,7 +40,7 @@ class StorageService { } // Helper to get project-specific media adapters - private getProjectMediaAdapters(projectId: string) { + private getProjectMediaAdapters({ projectId }: { projectId: string }) { const mediaMetadataAdapter = new IndexedDBAdapter( `${this.config.mediaDb}-${projectId}`, "media-metadata", @@ -52,23 +53,43 @@ class StorageService { } // Helper to get project-specific timeline adapter - private getProjectTimelineAdapter(projectId: string) { + private getProjectTimelineAdapter({ + projectId, + sceneId, + }: { + projectId: string; + sceneId?: string; + }) { + const dbName = sceneId + ? `${this.config.timelineDb}-${projectId}-${sceneId}` + : `${this.config.timelineDb}-${projectId}`; + return new IndexedDBAdapter( - `${this.config.timelineDb}-${projectId}`, + dbName, "timeline", this.config.version ); } // Project operations - async saveProject(project: TProject): Promise { + async saveProject({ project }: { project: TProject }): Promise { // Convert TProject to serializable format + const serializedScenes: SerializedScene[] = project.scenes.map((scene) => ({ + id: scene.id, + name: scene.name, + isMain: scene.isMain, + createdAt: scene.createdAt.toISOString(), + updatedAt: scene.updatedAt.toISOString(), + })); + const serializedProject: SerializedProject = { id: project.id, name: project.name, thumbnail: project.thumbnail, createdAt: project.createdAt.toISOString(), updatedAt: project.updatedAt.toISOString(), + scenes: serializedScenes, + currentSceneId: project.currentSceneId, backgroundColor: project.backgroundColor, backgroundType: project.backgroundType, blurIntensity: project.blurIntensity, @@ -81,11 +102,21 @@ class StorageService { await this.projectsAdapter.set(project.id, serializedProject); } - async loadProject(id: string): Promise { + async loadProject({ id }: { id: string }): Promise { const serializedProject = await this.projectsAdapter.get(id); if (!serializedProject) return null; + // Now convert serialized scenes back to Scene objects + const scenes = + serializedProject.scenes?.map((scene) => ({ + id: scene.id, + name: scene.name, + isMain: scene.isMain, + createdAt: new Date(scene.createdAt), + updatedAt: new Date(scene.updatedAt), + })) || []; + // Convert back to TProject format return { id: serializedProject.id, @@ -93,6 +124,8 @@ class StorageService { thumbnail: serializedProject.thumbnail, createdAt: new Date(serializedProject.createdAt), updatedAt: new Date(serializedProject.updatedAt), + scenes, + currentSceneId: serializedProject.currentSceneId || "", backgroundColor: serializedProject.backgroundColor, backgroundType: serializedProject.backgroundType, blurIntensity: serializedProject.blurIntensity, @@ -108,7 +141,7 @@ class StorageService { const projects: TProject[] = []; for (const id of projectIds) { - const project = await this.loadProject(id); + const project = await this.loadProject({ id }); if (project) { projects.push(project); } @@ -120,14 +153,20 @@ class StorageService { ); } - async deleteProject(id: string): Promise { + async deleteProject({ id }: { id: string }): Promise { await this.projectsAdapter.remove(id); } // Media operations - async saveMediaFile(projectId: string, mediaItem: MediaFile): Promise { + async saveMediaFile({ + projectId, + mediaItem, + }: { + projectId: string; + mediaItem: MediaFile; + }): Promise { const { mediaMetadataAdapter, mediaFilesAdapter } = - this.getProjectMediaAdapters(projectId); + this.getProjectMediaAdapters({ projectId }); // Save file to project-specific OPFS await mediaFilesAdapter.set(mediaItem.id, mediaItem.file); @@ -148,12 +187,15 @@ class StorageService { await mediaMetadataAdapter.set(mediaItem.id, metadata); } - async loadMediaFile( - projectId: string, - id: string - ): Promise { + async loadMediaFile({ + projectId, + id, + }: { + projectId: string; + id: string; + }): Promise { const { mediaMetadataAdapter, mediaFilesAdapter } = - this.getProjectMediaAdapters(projectId); + this.getProjectMediaAdapters({ projectId }); const [file, metadata] = await Promise.all([ mediaFilesAdapter.get(id), @@ -192,14 +234,20 @@ class StorageService { }; } - async loadAllMediaFiles(projectId: string): Promise { - const { mediaMetadataAdapter } = this.getProjectMediaAdapters(projectId); + async loadAllMediaFiles({ + projectId, + }: { + projectId: string; + }): Promise { + const { mediaMetadataAdapter } = this.getProjectMediaAdapters({ + projectId, + }); const mediaIds = await mediaMetadataAdapter.list(); const mediaItems: MediaFile[] = []; for (const id of mediaIds) { - const item = await this.loadMediaFile(projectId, id); + const item = await this.loadMediaFile({ projectId, id }); if (item) { mediaItems.push(item); } @@ -208,9 +256,15 @@ class StorageService { return mediaItems; } - async deleteMediaFile(projectId: string, id: string): Promise { + async deleteMediaFile({ + projectId, + id, + }: { + projectId: string; + id: string; + }): Promise { const { mediaMetadataAdapter, mediaFilesAdapter } = - this.getProjectMediaAdapters(projectId); + this.getProjectMediaAdapters({ projectId }); await Promise.all([ mediaFilesAdapter.remove(id), @@ -218,9 +272,13 @@ class StorageService { ]); } - async deleteProjectMedia(projectId: string): Promise { + async deleteProjectMedia({ + projectId, + }: { + projectId: string; + }): Promise { const { mediaMetadataAdapter, mediaFilesAdapter } = - this.getProjectMediaAdapters(projectId); + this.getProjectMediaAdapters({ projectId }); await Promise.all([ mediaMetadataAdapter.clear(), @@ -228,12 +286,20 @@ class StorageService { ]); } - // Timeline operations - now project-specific - async saveTimeline( - projectId: string, - tracks: TimelineTrack[] - ): Promise { - const timelineAdapter = this.getProjectTimelineAdapter(projectId); + // Timeline operations - supports both legacy and scene-based storage + async saveTimeline({ + projectId, + tracks, + sceneId, + }: { + projectId: string; + tracks: TimelineTrack[]; + sceneId?: string; + }): Promise { + const timelineAdapter = this.getProjectTimelineAdapter({ + projectId, + sceneId, + }); const timelineData: TimelineData = { tracks, lastModified: new Date().toISOString(), @@ -241,14 +307,27 @@ class StorageService { await timelineAdapter.set("timeline", timelineData); } - async loadTimeline(projectId: string): Promise { - const timelineAdapter = this.getProjectTimelineAdapter(projectId); + async loadTimeline({ + projectId, + sceneId, + }: { + projectId: string; + sceneId?: string; + }): Promise { + const timelineAdapter = this.getProjectTimelineAdapter({ + projectId, + sceneId, + }); const timelineData = await timelineAdapter.get("timeline"); return timelineData ? timelineData.tracks : null; } - async deleteProjectTimeline(projectId: string): Promise { - const timelineAdapter = this.getProjectTimelineAdapter(projectId); + async deleteProjectTimeline({ + projectId, + }: { + projectId: string; + }): Promise { + const timelineAdapter = this.getProjectTimelineAdapter({ projectId }); await timelineAdapter.remove("timeline"); } @@ -274,12 +353,14 @@ class StorageService { }; } - async getProjectStorageInfo(projectId: string): Promise<{ + async getProjectStorageInfo({ projectId }: { projectId: string }): Promise<{ mediaItems: number; hasTimeline: boolean; }> { - const { mediaMetadataAdapter } = this.getProjectMediaAdapters(projectId); - const timelineAdapter = this.getProjectTimelineAdapter(projectId); + const { mediaMetadataAdapter } = this.getProjectMediaAdapters({ + projectId, + }); + const timelineAdapter = this.getProjectTimelineAdapter({ projectId }); const [mediaIds, timelineData] = await Promise.all([ mediaMetadataAdapter.list(), @@ -307,7 +388,11 @@ class StorageService { } } - async saveSoundEffect(soundEffect: SoundEffect): Promise { + async saveSoundEffect({ + soundEffect, + }: { + soundEffect: SoundEffect; + }): Promise { try { const currentData = await this.loadSavedSounds(); @@ -340,7 +425,7 @@ class StorageService { } } - async removeSavedSound(soundId: number): Promise { + async removeSavedSound({ soundId }: { soundId: number }): Promise { try { const currentData = await this.loadSavedSounds(); @@ -356,7 +441,7 @@ class StorageService { } } - async isSoundSaved(soundId: number): Promise { + async isSoundSaved({ soundId }: { soundId: number }): Promise { try { const currentData = await this.loadSavedSounds(); return currentData.sounds.some((sound) => sound.id === soundId); diff --git a/apps/web/src/lib/storage/types.ts b/apps/web/src/lib/storage/types.ts index 8f593867..e66239cd 100644 --- a/apps/web/src/lib/storage/types.ts +++ b/apps/web/src/lib/storage/types.ts @@ -1,4 +1,4 @@ -import { TProject } from "@/types/project"; +import { TProject, Scene } from "@/types/project"; import { TimelineTrack } from "@/types/timeline"; export interface StorageAdapter { @@ -23,11 +23,23 @@ export interface MediaFileData { // File will be stored separately in OPFS } +// Legacy timeline data, kept for backward compatibility export interface TimelineData { tracks: TimelineTrack[]; lastModified: string; } +export interface SceneTimelineData { + sceneId: string; + tracks: TimelineTrack[]; + lastModified: string; +} + +export type SerializedScene = Omit & { + createdAt: string; + updatedAt: string; +}; + export interface StorageConfig { projectsDb: string; mediaDb: string; @@ -37,9 +49,13 @@ export interface StorageConfig { } // Helper type for serialization - converts Date objects to strings -export type SerializedProject = Omit & { +export type SerializedProject = Omit< + TProject, + "createdAt" | "updatedAt" | "scenes" +> & { createdAt: string; updatedAt: string; + scenes: SerializedScene[]; bookmarks?: number[]; }; -- cgit v1.3.1 From af1964cf113bc9cbebc59a94631f8d08506923d2 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Mon, 1 Sep 2025 17:18:00 +0200 Subject: fix: scenes now load properly --- .../providers/migrators/scenes-migrator.tsx | 358 +++++++-------- apps/web/src/lib/storage/storage-service.ts | 5 +- apps/web/src/stores/project-store.ts | 15 +- apps/web/src/stores/scene-store.ts | 486 +++++++++++---------- apps/web/src/stores/timeline-store.ts | 9 + apps/web/src/types/project.ts | 1 + 6 files changed, 479 insertions(+), 395 deletions(-) (limited to 'apps/web/src/lib/storage') diff --git a/apps/web/src/components/providers/migrators/scenes-migrator.tsx b/apps/web/src/components/providers/migrators/scenes-migrator.tsx index 3a21faa4..521e24af 100644 --- a/apps/web/src/components/providers/migrators/scenes-migrator.tsx +++ b/apps/web/src/components/providers/migrators/scenes-migrator.tsx @@ -1,171 +1,187 @@ -"use client"; - -import { useEffect, useState } from "react"; -import { usePathname } from "next/navigation"; -import { storageService } from "@/lib/storage/storage-service"; -import { TProject, Scene } from "@/types/project"; -import { generateUUID } from "@/lib/utils"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { Progress } from "@/components/ui/progress"; -import { createMainScene } from "@/stores/project-store"; - -interface MigrationProgress { - current: number; - total: number; - currentProjectName: string; -} - -export function ScenesMigrator({ children }: { children: React.ReactNode }) { - const pathname = usePathname(); - const [isMigrating, setIsMigrating] = useState(false); - const [progress, setProgress] = useState({ - current: 0, - total: 0, - currentProjectName: "", - }); - - const shouldCheckMigration = - pathname.startsWith("/editor") || pathname.startsWith("/projects"); - - useEffect(() => { - if (!shouldCheckMigration) return; - - checkAndMigrateProjects(); - }, [shouldCheckMigration]); - - const checkAndMigrateProjects = async () => { - try { - const projects = await storageService.loadAllProjects(); - const legacyProjects = projects.filter( - (project) => !project.scenes || project.scenes.length === 0 - ); - - if (legacyProjects.length === 0) { - // No migration needed - return; - } - - setIsMigrating(true); - setProgress({ - current: 0, - total: legacyProjects.length, - currentProjectName: "", - }); - - // Migrate each legacy project - for (let i = 0; i < legacyProjects.length; i++) { - const project = legacyProjects[i]; - - setProgress({ - current: i, - total: legacyProjects.length, - currentProjectName: project.name, - }); - - await migrateLegacyProject(project); - } - - setProgress({ - current: legacyProjects.length, - total: legacyProjects.length, - currentProjectName: "Complete!", - }); - - setTimeout(() => { - setIsMigrating(false); - }, 1000); - } catch (error) { - console.error("Migration failed:", error); - setIsMigrating(false); - } - }; - - const migrateLegacyProject = async (project: TProject) => { - try { - const mainScene = createMainScene(); - - const migratedProject: TProject = { - ...project, - scenes: [mainScene], - currentSceneId: mainScene.id, - updatedAt: new Date(), - }; - - // Load existing timeline data (legacy format) - const legacyTimeline = await storageService.loadTimeline({ - projectId: project.id, - }); - - await storageService.saveProject({ project: migratedProject }); - - // If timeline data, migrate it to the main scene - if (legacyTimeline && legacyTimeline.length > 0) { - await storageService.saveTimeline({ - projectId: project.id, - tracks: legacyTimeline, - sceneId: mainScene.id, - }); - } - - // Clean up legacy timeline storage - await storageService.deleteProjectTimeline({ projectId: project.id }); - } catch (error) { - console.error(`Failed to migrate project ${project.name}:`, error); - throw error; - } - }; - - if (!shouldCheckMigration) { - return children; - } - - if (isMigrating) { - const progressPercent = - progress.total > 0 ? (progress.current / progress.total) * 100 : 0; - - return ( - - e.preventDefault()} - > - - Updating Projects - - We're adding scene support to your projects. This will only take a - moment. - - - -
-
-
- Progress - - {progress.current} of {progress.total} - -
- -
- - {progress.currentProjectName && ( -
- {progress.current < progress.total - ? `Updating: ${progress.currentProjectName}` - : progress.currentProjectName} -
- )} -
-
-
- ); - } - - return children; -} +"use client"; + +import { useEffect, useState } from "react"; +import { usePathname } from "next/navigation"; +import { storageService } from "@/lib/storage/storage-service"; +import { TProject, Scene } from "@/types/project"; +import { generateUUID } from "@/lib/utils"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Progress } from "@/components/ui/progress"; +import { createMainScene } from "@/stores/project-store"; + +interface MigrationProgress { + current: number; + total: number; + currentProjectName: string; +} + +export function ScenesMigrator({ children }: { children: React.ReactNode }) { + const pathname = usePathname(); + const [isMigrating, setIsMigrating] = useState(false); + const [progress, setProgress] = useState({ + current: 0, + total: 0, + currentProjectName: "", + }); + + const shouldCheckMigration = + pathname.startsWith("/editor") || pathname.startsWith("/projects"); + + useEffect(() => { + if (!shouldCheckMigration) return; + + checkAndMigrateProjects(); + }, [shouldCheckMigration]); + + const checkAndMigrateProjects = async () => { + try { + const projects = await storageService.loadAllProjects(); + const legacyProjects = projects.filter( + (project) => !project.scenes || project.scenes.length === 0 + ); + + if (legacyProjects.length === 0) { + // No migration needed + return; + } + + setIsMigrating(true); + setProgress({ + current: 0, + total: legacyProjects.length, + currentProjectName: "", + }); + + // Migrate each legacy project + for (let i = 0; i < legacyProjects.length; i++) { + const project = legacyProjects[i]; + + setProgress({ + current: i, + total: legacyProjects.length, + currentProjectName: project.name, + }); + + await migrateLegacyProject(project); + } + + setProgress({ + current: legacyProjects.length, + total: legacyProjects.length, + currentProjectName: "Complete!", + }); + + setTimeout(() => { + setIsMigrating(false); + }, 1000); + } catch (error) { + console.error("Migration failed:", error); + setIsMigrating(false); + } + }; + + const migrateLegacyProject = async (project: TProject) => { + try { + const mainScene: Scene = { + id: generateUUID(), + name: "Main Scene", + isMain: true, + isBackground: false, + createdAt: new Date(), + updatedAt: new Date(), + }; + + const backgroundScene: Scene = { + id: generateUUID(), + name: "Background", + isMain: false, + isBackground: true, + createdAt: new Date(), + updatedAt: new Date(), + }; + + const migratedProject: TProject = { + ...project, + scenes: [mainScene, backgroundScene], + currentSceneId: mainScene.id, + updatedAt: new Date(), + }; + + // Load existing timeline data (legacy format) + const legacyTimeline = await storageService.loadTimeline({ + projectId: project.id, + }); + + await storageService.saveProject({ project: migratedProject }); + + // If timeline data, migrate it to the main scene + if (legacyTimeline && legacyTimeline.length > 0) { + await storageService.saveTimeline({ + projectId: project.id, + tracks: legacyTimeline, + sceneId: mainScene.id, + }); + } + + // Clean up legacy timeline storage + await storageService.deleteProjectTimeline({ projectId: project.id }); + } catch (error) { + console.error(`Failed to migrate project ${project.name}:`, error); + throw error; + } + }; + + if (!shouldCheckMigration) { + return children; + } + + if (isMigrating) { + const progressPercent = + progress.total > 0 ? (progress.current / progress.total) * 100 : 0; + + return ( + + e.preventDefault()} + > + + Updating Projects + + We're adding scene support to your projects. This will only take a + moment. + + + +
+
+
+ Progress + + {progress.current} of {progress.total} + +
+ +
+ + {progress.currentProjectName && ( +
+ {progress.current < progress.total + ? `Updating: ${progress.currentProjectName}` + : progress.currentProjectName} +
+ )} +
+
+
+ ); + } + + return children; +} diff --git a/apps/web/src/lib/storage/storage-service.ts b/apps/web/src/lib/storage/storage-service.ts index 80f10d3d..112d09b6 100644 --- a/apps/web/src/lib/storage/storage-service.ts +++ b/apps/web/src/lib/storage/storage-service.ts @@ -78,6 +78,7 @@ class StorageService { id: scene.id, name: scene.name, isMain: scene.isMain, + isBackground: scene.isBackground, createdAt: scene.createdAt.toISOString(), updatedAt: scene.updatedAt.toISOString(), })); @@ -113,12 +114,13 @@ class StorageService { id: scene.id, name: scene.name, isMain: scene.isMain, + isBackground: scene.isBackground || false, // Default for legacy scenes createdAt: new Date(scene.createdAt), updatedAt: new Date(scene.updatedAt), })) || []; // Convert back to TProject format - return { + const project = { id: serializedProject.id, name: serializedProject.name, thumbnail: serializedProject.thumbnail, @@ -134,6 +136,7 @@ class StorageService { canvasSize: serializedProject.canvasSize, canvasMode: serializedProject.canvasMode, }; + return project; } async loadAllProjects(): Promise { diff --git a/apps/web/src/stores/project-store.ts b/apps/web/src/stores/project-store.ts index 70c11b28..0c67be9f 100644 --- a/apps/web/src/stores/project-store.ts +++ b/apps/web/src/stores/project-store.ts @@ -4,7 +4,7 @@ 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 { createBackgroundScene, useSceneStore } from "./scene-store"; import { generateUUID } from "@/lib/utils"; import { CanvasSize, CanvasMode } from "@/types/editor"; @@ -16,6 +16,7 @@ export function createMainScene(): Scene { id: generateUUID(), name: "Main scene", isMain: true, + isBackground: false, createdAt: new Date(), updatedAt: new Date(), }; @@ -23,13 +24,15 @@ export function createMainScene(): Scene { const createDefaultProject = (name: string): TProject => { const mainScene = createMainScene(); + const backgroundScene = createBackgroundScene(); + return { id: generateUUID(), name, thumbnail: "", createdAt: new Date(), updatedAt: new Date(), - scenes: [mainScene], + scenes: [mainScene, backgroundScene], currentSceneId: mainScene.id, backgroundColor: "#000000", backgroundType: "color", @@ -226,15 +229,19 @@ export const useProjectStore = create((set, get) => ({ if (project) { set({ activeProject: project }); + let currentScene = null; if (project.scenes && project.scenes.length > 0) { sceneStore.initializeScenes({ scenes: project.scenes, currentSceneId: project.currentSceneId, }); + // Get current scene directly from project data (don't rely on store state) + currentScene = + project.scenes.find((s) => s.id === project.currentSceneId) || + project.scenes.find((s) => s.isMain) || + project.scenes[0]; } - const currentScene = sceneStore.currentScene; - await Promise.all([ mediaStore.loadProjectMedia(id), timelineStore.loadProjectTimeline({ diff --git a/apps/web/src/stores/scene-store.ts b/apps/web/src/stores/scene-store.ts index e16185e9..1b789427 100644 --- a/apps/web/src/stores/scene-store.ts +++ b/apps/web/src/stores/scene-store.ts @@ -1,219 +1,267 @@ -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, - }); - }, -})); +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; +} + +export function getBackgroundScene({ + scenes, +}: { + scenes: Scene[]; +}): Scene | null { + return scenes.find((scene) => scene.isBackground) || null; +} + +export function createBackgroundScene(): Scene { + return { + id: generateUUID(), + name: "Background", + isMain: false, + isBackground: true, + createdAt: new Date(), + updatedAt: new Date(), + }; +} + +function ensureBackgroundScene({ scenes }: { scenes: Scene[] }): Scene[] { + const hasBackground = scenes.some((scene) => scene.isBackground); + if (!hasBackground) { + return [...scenes, createBackgroundScene()]; + } + return scenes; +} + +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, + isBackground: false, + 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 ensuredScenes = ensureBackgroundScene({ scenes }); + const currentScene = currentSceneId + ? ensuredScenes.find((s) => s.id === currentSceneId) + : null; + + const fallbackScene = getMainScene({ scenes: ensuredScenes }); + + set({ + scenes: ensuredScenes, + currentScene: currentScene || fallbackScene, + }); + + if (ensuredScenes.length > scenes.length) { + const projectStore = useProjectStore.getState(); + const { activeProject } = projectStore; + + if (activeProject) { + const updatedProject = { + ...activeProject, + scenes: ensuredScenes, + updatedAt: new Date(), + }; + + storageService.saveProject({ project: updatedProject }).then(() => { + useProjectStore.setState({ activeProject: updatedProject }); + }).catch(error => { + console.error("Failed to save project with background scene:", error); + }); + } + } + }, + + clearScenes: () => { + set({ + scenes: [], + currentScene: null, + }); + }, +})); diff --git a/apps/web/src/stores/timeline-store.ts b/apps/web/src/stores/timeline-store.ts index 8caa71a7..24b6eca3 100644 --- a/apps/web/src/stores/timeline-store.ts +++ b/apps/web/src/stores/timeline-store.ts @@ -260,6 +260,15 @@ export const useTimelineStore = create((set, get) => { } catch (error) { console.error("Failed to auto-save timeline:", error); } + } else { + console.warn( + "Auto-save skipped - missing activeProject or currentScene:", + { + hasProject: !!activeProject, + hasScene: !!currentScene, + sceneName: currentScene?.name, + } + ); } }; diff --git a/apps/web/src/types/project.ts b/apps/web/src/types/project.ts index f182653d..7fa53c69 100644 --- a/apps/web/src/types/project.ts +++ b/apps/web/src/types/project.ts @@ -6,6 +6,7 @@ export interface Scene { id: string; name: string; isMain: boolean; + isBackground: boolean; createdAt: Date; updatedAt: Date; } -- cgit v1.3.1 From d8f446580c2636e87aa39c00b1565406c56f9343 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Mon, 1 Sep 2025 22:15:32 +0200 Subject: feat: remove default background scene and add scene management --- apps/web/src/components/editor/preview-panel.tsx | 22 +- apps/web/src/components/editor/scenes-view.tsx | 262 ++++++++++++++++----- .../editor/timeline/timeline-cache-indicator.tsx | 14 +- .../editor/timeline/timeline-toolbar.tsx | 6 +- .../providers/migrators/scenes-migrator.tsx | 12 +- apps/web/src/components/ui/button.tsx | 2 +- apps/web/src/components/ui/dialog.tsx | 4 +- apps/web/src/components/ui/split-button.tsx | 2 +- apps/web/src/hooks/use-frame-cache.ts | 60 +++-- apps/web/src/lib/storage/storage-service.ts | 2 - apps/web/src/stores/project-store.ts | 8 +- apps/web/src/stores/scene-store.ts | 113 ++++++--- apps/web/src/types/project.ts | 1 - 13 files changed, 364 insertions(+), 144 deletions(-) (limited to 'apps/web/src/lib/storage') diff --git a/apps/web/src/components/editor/preview-panel.tsx b/apps/web/src/components/editor/preview-panel.tsx index 8b1abd81..36f6a2ba 100644 --- a/apps/web/src/components/editor/preview-panel.tsx +++ b/apps/web/src/components/editor/preview-panel.tsx @@ -14,6 +14,7 @@ import { cn } from "@/lib/utils"; import { formatTimeCode } from "@/lib/time"; import { EditableTimecode } from "@/components/ui/editable-timecode"; import { useFrameCache } from "@/hooks/use-frame-cache"; +import { useSceneStore } from "@/stores/scene-store"; import { DEFAULT_CANVAS_SIZE, DEFAULT_FPS, @@ -43,6 +44,7 @@ export function PreviewPanel() { const { currentTime, toggle, setCurrentTime } = usePlaybackStore(); const { isPlaying, volume, muted } = usePlaybackStore(); const { activeProject } = useProjectStore(); + const { currentScene } = useSceneStore(); const previewRef = useRef(null); const canvasRef = useRef(null); const { getCachedFrame, cacheFrame, invalidateCache, preRenderNearbyFrames } = @@ -219,11 +221,10 @@ export function PreviewPanel() { }; }, [dragState, previewDimensions, canvasSize, updateTextElement]); - // Invalidate cache when timeline changes + // Clear the frame cache when background settings change since they affect rendering useEffect(() => { invalidateCache(); }, [ - tracks, mediaFiles, activeProject?.backgroundColor, activeProject?.backgroundType, @@ -494,7 +495,8 @@ export function PreviewPanel() { currentTime, tracks, mediaFiles, - activeProject + activeProject, + currentScene?.id ); if (cachedFrame) { mainCtx.putImageData(cachedFrame, 0, 0); @@ -532,7 +534,9 @@ export function PreviewPanel() { }); return tempCtx.getImageData(0, 0, displayWidth, displayHeight); - } + }, + currentScene?.id, + 3 ); } else { // Small lookahead while playing @@ -567,6 +571,7 @@ export function PreviewPanel() { return tempCtx.getImageData(0, 0, displayWidth, displayHeight); }, + currentScene?.id, 1 ); } @@ -644,7 +649,14 @@ export function PreviewPanel() { displayWidth, displayHeight ); - cacheFrame(currentTime, imageData, tracks, mediaFiles, activeProject); + cacheFrame( + currentTime, + imageData, + tracks, + mediaFiles, + activeProject, + currentScene?.id + ); // Blit offscreen to visible canvas mainCtx.clearRect(0, 0, displayWidth, displayHeight); diff --git a/apps/web/src/components/editor/scenes-view.tsx b/apps/web/src/components/editor/scenes-view.tsx index f1df32e9..b5432b53 100644 --- a/apps/web/src/components/editor/scenes-view.tsx +++ b/apps/web/src/components/editor/scenes-view.tsx @@ -1,64 +1,198 @@ -"use client"; - -import { - Sheet, - SheetContent, - SheetDescription, - SheetHeader, - SheetTitle, - SheetTrigger, -} from "@/components/ui/sheet"; -import { Button } from "@/components/ui/button"; -import { useSceneStore } from "@/stores/scene-store"; -import { Check } from "lucide-react"; - -export function ScenesView({ children }: { children: React.ReactNode }) { - const { scenes, currentScene, switchToScene } = useSceneStore(); - - const handleSceneSwitch = async (sceneId: string) => { - try { - await switchToScene({ sceneId }); - } catch (error) { - console.error("Failed to switch scene:", error); - } - }; - - return ( - - {children} - - - Scenes - - Switch between scenes in your project - - -
- {scenes.length === 0 ? ( -
- No scenes available -
- ) : ( -
- {scenes.map((scene) => ( - - ))} -
- )} -
-
-
- ); -} +"use client"; + +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, + SheetTrigger, +} from "@/components/ui/sheet"; +import { Button } from "@/components/ui/button"; +import { useSceneStore } from "@/stores/scene-store"; +import { Check, ListCheck, Trash2 } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { useState } from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, + DialogTrigger, +} from "@/components/ui/dialog"; + +export function ScenesView({ children }: { children: React.ReactNode }) { + const { scenes, currentScene, switchToScene, deleteScene } = useSceneStore(); + const [isSelectMode, setIsSelectMode] = useState(false); + const [selectedScenes, setSelectedScenes] = useState>(new Set()); + + const handleSceneSwitch = async (sceneId: string) => { + if (isSelectMode) { + toggleSceneSelection(sceneId); + return; + } + + try { + await switchToScene({ sceneId }); + } catch (error) { + console.error("Failed to switch scene:", error); + } + }; + + const toggleSceneSelection = (sceneId: string) => { + setSelectedScenes((prev) => { + const newSet = new Set(prev); + if (newSet.has(sceneId)) { + newSet.delete(sceneId); + } else { + newSet.add(sceneId); + } + return newSet; + }); + }; + + const handleSelectMode = () => { + setIsSelectMode(!isSelectMode); + setSelectedScenes(new Set()); + }; + + const handleDeleteSelected = async () => { + for (const sceneId of selectedScenes) { + const scene = scenes.find((s) => s.id === sceneId); + if (scene && !scene.isMain) { + try { + await deleteScene({ sceneId }); + } catch (error) { + console.error("Failed to delete scene:", error); + } + } + } + setSelectedScenes(new Set()); + setIsSelectMode(false); + }; + + return ( + + {children} + + + + {isSelectMode ? `Select scenes (${selectedScenes.size})` : "Scenes"} + + + {isSelectMode + ? "Select scenes to delete" + : "Switch between scenes in your project"} + + +
+
+ + {isSelectMode && ( + scenes.find((s) => s.id === id)?.isMain + )} + > + + + )} +
+ {scenes.length === 0 ? ( +
+ No scenes available +
+ ) : ( +
+ {scenes.map((scene) => ( + + ))} +
+ )} +
+
+
+ ); +} + +function DeleteDialog({ + count, + onDelete, + disabled, + children, +}: { + count: number; + onDelete: () => void; + disabled?: boolean; + children: React.ReactNode; +}) { + const [open, setOpen] = useState(false); + + const handleDelete = () => { + onDelete(); + setOpen(false); + }; + + return ( + + {children} + + + Delete Scenes + + Are you sure you want to delete {count} scene + {count === 1 ? "" : "s"}? This action cannot be undone. + + + + + + + + + ); +} diff --git a/apps/web/src/components/editor/timeline/timeline-cache-indicator.tsx b/apps/web/src/components/editor/timeline/timeline-cache-indicator.tsx index ef667d67..7def2985 100644 --- a/apps/web/src/components/editor/timeline/timeline-cache-indicator.tsx +++ b/apps/web/src/components/editor/timeline/timeline-cache-indicator.tsx @@ -5,6 +5,7 @@ import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; import { TimelineTrack } from "@/types/timeline"; import { MediaFile } from "@/types/media"; import { TProject } from "@/types/project"; +import { useSceneStore } from "@/stores/scene-store"; interface CacheSegment { startTime: number; @@ -22,7 +23,8 @@ interface TimelineCacheIndicatorProps { time: number, tracks: TimelineTrack[], mediaFiles: MediaFile[], - activeProject: TProject | null + activeProject: TProject | null, + sceneId?: string ) => "cached" | "not-cached"; } @@ -34,6 +36,8 @@ export function TimelineCacheIndicator({ activeProject, getRenderStatus, }: TimelineCacheIndicatorProps) { + const { currentScene } = useSceneStore(); + // Calculate cache segments by sampling the timeline const calculateCacheSegments = (): CacheSegment[] => { const segments: CacheSegment[] = []; @@ -49,7 +53,13 @@ export function TimelineCacheIndicator({ for (let i = 0; i <= totalSamples; i++) { const time = i / sampleRate; const cached = - getRenderStatus(time, tracks, mediaFiles, activeProject) === "cached"; + getRenderStatus( + time, + tracks, + mediaFiles, + activeProject, + currentScene?.id + ) === "cached"; if (!currentSegment) { // Start first segment diff --git a/apps/web/src/components/editor/timeline/timeline-toolbar.tsx b/apps/web/src/components/editor/timeline/timeline-toolbar.tsx index 7eeb84e4..79b5d5b2 100644 --- a/apps/web/src/components/editor/timeline/timeline-toolbar.tsx +++ b/apps/web/src/components/editor/timeline/timeline-toolbar.tsx @@ -67,7 +67,7 @@ export function TimelineToolbar({ } = useTimelineStore(); const { currentTime, duration, isPlaying, toggle, seek } = usePlaybackStore(); const { toggleBookmark, isBookmarked, activeProject } = useProjectStore(); - const { currentScene } = useSceneStore(); + const { scenes, currentScene } = useSceneStore(); const handleSplitSelected = () => { if (selectedElements.length === 0) return; @@ -359,11 +359,11 @@ export function TimelineToolbar({
- + {currentScene?.name || "No Scene"} - {}}> + {}}> diff --git a/apps/web/src/components/providers/migrators/scenes-migrator.tsx b/apps/web/src/components/providers/migrators/scenes-migrator.tsx index 521e24af..b984a7f4 100644 --- a/apps/web/src/components/providers/migrators/scenes-migrator.tsx +++ b/apps/web/src/components/providers/migrators/scenes-migrator.tsx @@ -92,23 +92,13 @@ export function ScenesMigrator({ children }: { children: React.ReactNode }) { id: generateUUID(), name: "Main Scene", isMain: true, - isBackground: false, - createdAt: new Date(), - updatedAt: new Date(), - }; - - const backgroundScene: Scene = { - id: generateUUID(), - name: "Background", - isMain: false, - isBackground: true, createdAt: new Date(), updatedAt: new Date(), }; const migratedProject: TProject = { ...project, - scenes: [mainScene, backgroundScene], + scenes: [mainScene], currentSceneId: mainScene.id, updatedAt: new Date(), }; diff --git a/apps/web/src/components/ui/button.tsx b/apps/web/src/components/ui/button.tsx index 3d17c042..56a10f44 100644 --- a/apps/web/src/components/ui/button.tsx +++ b/apps/web/src/components/ui/button.tsx @@ -16,7 +16,7 @@ const buttonVariants = cva( "primary-gradient": "bg-gradient-to-r from-cyan-400 to-blue-500 text-white hover:opacity-85 transition-opacity", destructive: - "bg-destructive text-destructive-foreground shadow-xs hover:bg-destructive/90", + "bg-destructive/0 border border-destructive/25 text-destructive shadow-xs hover:bg-destructive hover:text-destructive-foreground", outline: "border border-input bg-transparent shadow-xs hover:opacity-75 transition-opacity hover:text-accent-foreground", secondary: diff --git a/apps/web/src/components/ui/dialog.tsx b/apps/web/src/components/ui/dialog.tsx index ad7e825b..42d0f80d 100644 --- a/apps/web/src/components/ui/dialog.tsx +++ b/apps/web/src/components/ui/dialog.tsx @@ -22,7 +22,7 @@ const DialogOverlay = React.forwardRef< { diff --git a/apps/web/src/components/ui/split-button.tsx b/apps/web/src/components/ui/split-button.tsx index 1ef29500..ca3b518c 100644 --- a/apps/web/src/components/ui/split-button.tsx +++ b/apps/web/src/components/ui/split-button.tsx @@ -39,7 +39,7 @@ const SplitButtonSide = forwardRef< ref={ref} variant="text" className={cn( - "h-full rounded-none bg-panel-accent !opacity-100 border-0 gap-0 font-normal transition-colors", + "h-full rounded-none bg-panel-accent !opacity-100 border-0 gap-0 font-normal transition-colors disabled:text-muted-foreground", onClick ? "hover:bg-foreground/10 hover:opacity-100 cursor-pointer" : "cursor-default select-text", diff --git a/apps/web/src/hooks/use-frame-cache.ts b/apps/web/src/hooks/use-frame-cache.ts index 2abfb912..cce187f1 100644 --- a/apps/web/src/hooks/use-frame-cache.ts +++ b/apps/web/src/hooks/use-frame-cache.ts @@ -37,7 +37,8 @@ export function useFrameCache(options: FrameCacheOptions = {}) { time: number, tracks: TimelineTrack[], mediaFiles: MediaFile[], - activeProject: TProject | null + activeProject: TProject | null, + sceneId?: string ): string => { // Get elements that are active at this time const activeElements: Array<{ @@ -117,11 +118,13 @@ export function useFrameCache(options: FrameCacheOptions = {}) { canvasSize: activeProject?.canvasSize, }; - return JSON.stringify({ + const hash = { activeElements, projectState, - time: Math.floor(time * cacheResolution) / cacheResolution, // Quantize time - }); + sceneId, + time: Math.floor(time * cacheResolution) / cacheResolution, + }; + return JSON.stringify(hash); }, [cacheResolution] ); @@ -132,7 +135,8 @@ export function useFrameCache(options: FrameCacheOptions = {}) { time: number, tracks: TimelineTrack[], mediaFiles: MediaFile[], - activeProject: TProject | null + activeProject: TProject | null, + sceneId?: string ): boolean => { const frameKey = Math.floor(time * cacheResolution); const cached = frameCacheRef.current.get(frameKey); @@ -143,7 +147,8 @@ export function useFrameCache(options: FrameCacheOptions = {}) { time, tracks, mediaFiles, - activeProject + activeProject, + sceneId ); return cached.timelineHash === currentHash; }, @@ -156,21 +161,33 @@ export function useFrameCache(options: FrameCacheOptions = {}) { time: number, tracks: TimelineTrack[], mediaFiles: MediaFile[], - activeProject: TProject | null + activeProject: TProject | null, + sceneId?: string ): ImageData | null => { const frameKey = Math.floor(time * cacheResolution); const cached = frameCacheRef.current.get(frameKey); - if (!cached) return null; + if (!cached) { + return null; + } const currentHash = getTimelineHash( time, tracks, mediaFiles, - activeProject + activeProject, + sceneId ); + console.log(cached.timelineHash === currentHash); if (cached.timelineHash !== currentHash) { // Cache is stale, remove it + console.log( + "Cache miss - hash mismatch:", + JSON.stringify({ + cachedHash: cached.timelineHash.slice(0, 100), + currentHash: currentHash.slice(0, 100), + }) + ); frameCacheRef.current.delete(frameKey); return null; } @@ -187,14 +204,16 @@ export function useFrameCache(options: FrameCacheOptions = {}) { imageData: ImageData, tracks: TimelineTrack[], mediaFiles: MediaFile[], - activeProject: TProject | null + activeProject: TProject | null, + sceneId?: string ): void => { const frameKey = Math.floor(time * cacheResolution); const timelineHash = getTimelineHash( time, tracks, mediaFiles, - activeProject + activeProject, + sceneId ); // Enforce cache size limit (LRU eviction) @@ -230,9 +249,10 @@ export function useFrameCache(options: FrameCacheOptions = {}) { time: number, tracks: TimelineTrack[], mediaFiles: MediaFile[], - activeProject: TProject | null + activeProject: TProject | null, + sceneId?: string ): "cached" | "not-cached" => { - return isFrameCached(time, tracks, mediaFiles, activeProject) + return isFrameCached(time, tracks, mediaFiles, activeProject, sceneId) ? "cached" : "not-cached"; }, @@ -247,6 +267,7 @@ export function useFrameCache(options: FrameCacheOptions = {}) { mediaFiles: MediaFile[], activeProject: TProject | null, renderFunction: (time: number) => Promise, + sceneId?: string, range: number = 3 // seconds ) => { const framesToPreRender: number[] = []; @@ -260,7 +281,7 @@ export function useFrameCache(options: FrameCacheOptions = {}) { const time = currentTime + offset; if (time < 0) continue; - if (!isFrameCached(time, tracks, mediaFiles, activeProject)) { + if (!isFrameCached(time, tracks, mediaFiles, activeProject, sceneId)) { framesToPreRender.push(time); } } @@ -276,7 +297,7 @@ export function useFrameCache(options: FrameCacheOptions = {}) { for (let k = 0; k < cacheResolution; k++) { const t = s + k / cacheResolution; if (t < 0) continue; - if (!isFrameCached(t, tracks, mediaFiles, activeProject)) { + if (!isFrameCached(t, tracks, mediaFiles, activeProject, sceneId)) { expandedTimes.push(t); } } @@ -298,7 +319,14 @@ export function useFrameCache(options: FrameCacheOptions = {}) { requestIdleCallback(async () => { try { const imageData = await renderFunction(time); - cacheFrame(time, imageData, tracks, mediaFiles, activeProject); + cacheFrame( + time, + imageData, + tracks, + mediaFiles, + activeProject, + sceneId + ); } catch (error) { console.warn(`Pre-render failed for time ${time}:`, error); } diff --git a/apps/web/src/lib/storage/storage-service.ts b/apps/web/src/lib/storage/storage-service.ts index 112d09b6..2688f6e8 100644 --- a/apps/web/src/lib/storage/storage-service.ts +++ b/apps/web/src/lib/storage/storage-service.ts @@ -78,7 +78,6 @@ class StorageService { id: scene.id, name: scene.name, isMain: scene.isMain, - isBackground: scene.isBackground, createdAt: scene.createdAt.toISOString(), updatedAt: scene.updatedAt.toISOString(), })); @@ -114,7 +113,6 @@ class StorageService { id: scene.id, name: scene.name, isMain: scene.isMain, - isBackground: scene.isBackground || false, // Default for legacy scenes createdAt: new Date(scene.createdAt), updatedAt: new Date(scene.updatedAt), })) || []; diff --git a/apps/web/src/stores/project-store.ts b/apps/web/src/stores/project-store.ts index 0c67be9f..c58f7309 100644 --- a/apps/web/src/stores/project-store.ts +++ b/apps/web/src/stores/project-store.ts @@ -4,7 +4,7 @@ import { storageService } from "@/lib/storage/storage-service"; import { toast } from "sonner"; import { useMediaStore } from "./media-store"; import { useTimelineStore } from "./timeline-store"; -import { createBackgroundScene, useSceneStore } from "./scene-store"; +import { useSceneStore } from "./scene-store"; import { generateUUID } from "@/lib/utils"; import { CanvasSize, CanvasMode } from "@/types/editor"; @@ -14,9 +14,8 @@ export const DEFAULT_FPS = 30; export function createMainScene(): Scene { return { id: generateUUID(), - name: "Main scene", + name: "Main Scene", isMain: true, - isBackground: false, createdAt: new Date(), updatedAt: new Date(), }; @@ -24,7 +23,6 @@ export function createMainScene(): Scene { const createDefaultProject = (name: string): TProject => { const mainScene = createMainScene(); - const backgroundScene = createBackgroundScene(); return { id: generateUUID(), @@ -32,7 +30,7 @@ const createDefaultProject = (name: string): TProject => { thumbnail: "", createdAt: new Date(), updatedAt: new Date(), - scenes: [mainScene, backgroundScene], + scenes: [mainScene], currentSceneId: mainScene.id, backgroundColor: "#000000", backgroundType: "color", diff --git a/apps/web/src/stores/scene-store.ts b/apps/web/src/stores/scene-store.ts index 1b789427..a9f65a49 100644 --- a/apps/web/src/stores/scene-store.ts +++ b/apps/web/src/stores/scene-store.ts @@ -9,29 +9,17 @@ export function getMainScene({ scenes }: { scenes: Scene[] }): Scene | null { return scenes.find((scene) => scene.isMain) || null; } -export function getBackgroundScene({ - scenes, -}: { - scenes: Scene[]; -}): Scene | null { - return scenes.find((scene) => scene.isBackground) || null; -} - -export function createBackgroundScene(): Scene { - return { - id: generateUUID(), - name: "Background", - isMain: false, - isBackground: true, - createdAt: new Date(), - updatedAt: new Date(), - }; -} - -function ensureBackgroundScene({ scenes }: { scenes: Scene[] }): Scene[] { - const hasBackground = scenes.some((scene) => scene.isBackground); - if (!hasBackground) { - return [...scenes, createBackgroundScene()]; +function ensureMainScene(scenes: Scene[]): Scene[] { + const hasMain = scenes.some((scene) => scene.isMain); + if (!hasMain) { + const mainScene: Scene = { + id: generateUUID(), + name: "Main scene", + isMain: true, + createdAt: new Date(), + updatedAt: new Date(), + }; + return [mainScene, ...scenes]; } return scenes; } @@ -49,6 +37,7 @@ interface SceneStore { name: string; isMain: boolean; }) => Promise; + deleteScene: ({ sceneId }: { sceneId: string }) => Promise; renameScene: ({ sceneId, name, @@ -115,6 +104,62 @@ export const useSceneStore = create((set, get) => ({ } }, + deleteScene: async ({ sceneId }: { sceneId: string }) => { + const { scenes, currentScene } = get(); + const sceneToDelete = scenes.find((s) => s.id === sceneId); + + if (!sceneToDelete) { + throw new Error("Scene not found"); + } + + if (sceneToDelete.isMain) { + throw new Error("Cannot delete main scene"); + } + + const updatedScenes = scenes.filter((s) => s.id !== sceneId); + + // Determine new current scene if we're deleting the current one + let newCurrentScene = currentScene; + if (currentScene?.id === sceneId) { + newCurrentScene = getMainScene({ scenes: updatedScenes }); + } + + // 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: newCurrentScene, + }); + + // If we switched scenes, load the new scene's timeline + if (newCurrentScene && newCurrentScene.id !== currentScene?.id) { + const timelineStore = useTimelineStore.getState(); + await timelineStore.loadProjectTimeline({ + projectId: activeProject.id, + sceneId: newCurrentScene.id, + }); + } + } catch (error) { + console.error("Failed to delete scene:", error); + throw error; + } + }, + renameScene: async ({ sceneId, name }: { sceneId: string; name: string }) => { const { scenes } = get(); const updatedScenes = scenes.map((scene) => @@ -226,7 +271,7 @@ export const useSceneStore = create((set, get) => ({ scenes: Scene[]; currentSceneId?: string; }) => { - const ensuredScenes = ensureBackgroundScene({ scenes }); + const ensuredScenes = ensureMainScene(scenes); const currentScene = currentSceneId ? ensuredScenes.find((s) => s.id === currentSceneId) : null; @@ -241,19 +286,25 @@ export const useSceneStore = create((set, get) => ({ if (ensuredScenes.length > scenes.length) { const projectStore = useProjectStore.getState(); const { activeProject } = projectStore; - + if (activeProject) { const updatedProject = { ...activeProject, scenes: ensuredScenes, updatedAt: new Date(), }; - - storageService.saveProject({ project: updatedProject }).then(() => { - useProjectStore.setState({ activeProject: updatedProject }); - }).catch(error => { - console.error("Failed to save project with background scene:", error); - }); + + storageService + .saveProject({ project: updatedProject }) + .then(() => { + useProjectStore.setState({ activeProject: updatedProject }); + }) + .catch((error) => { + console.error( + "Failed to save project with background scene:", + error + ); + }); } } }, diff --git a/apps/web/src/types/project.ts b/apps/web/src/types/project.ts index 7fa53c69..f182653d 100644 --- a/apps/web/src/types/project.ts +++ b/apps/web/src/types/project.ts @@ -6,7 +6,6 @@ export interface Scene { id: string; name: string; isMain: boolean; - isBackground: boolean; createdAt: Date; updatedAt: Date; } -- cgit v1.3.1