summaryrefslogtreecommitdiff
path: root/apps/web/src
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2025-09-01 16:17:06 +0200
committerMaze Winther <mazewinther@gmail.com>2025-09-01 16:17:06 +0200
commit350b55081cde3a4332c982ea5c53b7d359029f6b (patch)
treef63b66635d2a13a0a43c622a9bea25d9b9822960 /apps/web/src
parent2b700b7474ffc728f5318c178244e035c93716b5 (diff)
downloadopencut-350b55081cde3a4332c982ea5c53b7d359029f6b.tar.gz
refactor(core): switch to scene-based architecture
Diffstat (limited to 'apps/web/src')
-rw-r--r--apps/web/src/app/layout.tsx5
-rw-r--r--apps/web/src/components/editor/scenes-view.tsx64
-rw-r--r--apps/web/src/components/editor/timeline/timeline-toolbar.tsx13
-rw-r--r--apps/web/src/components/footer.tsx17
-rw-r--r--apps/web/src/components/providers/migrators/scenes-migrator.tsx176
-rw-r--r--apps/web/src/components/ui/sheet.tsx12
-rw-r--r--apps/web/src/lib/fetch-github-stars.ts29
-rw-r--r--apps/web/src/lib/storage/storage-service.ts159
-rw-r--r--apps/web/src/lib/storage/types.ts20
-rw-r--r--apps/web/src/stores/media-store.ts8
-rw-r--r--apps/web/src/stores/project-store.ts145
-rw-r--r--apps/web/src/stores/scene-store.ts219
-rw-r--r--apps/web/src/stores/sounds-store.ts4
-rw-r--r--apps/web/src/stores/timeline-store.ts76
-rw-r--r--apps/web/src/types/project.ts10
15 files changed, 793 insertions, 164 deletions
diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx
index ee0bdbd2..7e32ea22 100644
--- a/apps/web/src/app/layout.tsx
+++ b/apps/web/src/app/layout.tsx
@@ -5,6 +5,7 @@ import "./globals.css";
import { Toaster } from "../components/ui/sonner";
import { TooltipProvider } from "../components/ui/tooltip";
import { StorageProvider } from "../components/storage-provider";
+import { ScenesMigrator } from "../components/providers/migrators/scenes-migrator";
import { baseMetaData } from "./metadata";
import { defaultFont } from "../lib/font-config";
import { BotIdClient } from "botid/client";
@@ -36,7 +37,9 @@ export default function RootLayout({
<body className={`${defaultFont.className} font-sans antialiased`}>
<ThemeProvider attribute="class" defaultTheme="dark">
<TooltipProvider>
- <StorageProvider>{children}</StorageProvider>
+ <StorageProvider>
+ <ScenesMigrator>{children}</ScenesMigrator>
+ </StorageProvider>
<Analytics />
<Toaster />
<Script
diff --git a/apps/web/src/components/editor/scenes-view.tsx b/apps/web/src/components/editor/scenes-view.tsx
new file mode 100644
index 00000000..f1df32e9
--- /dev/null
+++ b/apps/web/src/components/editor/scenes-view.tsx
@@ -0,0 +1,64 @@
+"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 (
+ <Sheet>
+ <SheetTrigger asChild>{children}</SheetTrigger>
+ <SheetContent>
+ <SheetHeader>
+ <SheetTitle>Scenes</SheetTitle>
+ <SheetDescription>
+ Switch between scenes in your project
+ </SheetDescription>
+ </SheetHeader>
+ <div className="py-4">
+ {scenes.length === 0 ? (
+ <div className="text-sm text-muted-foreground">
+ No scenes available
+ </div>
+ ) : (
+ <div className="space-y-2">
+ {scenes.map((scene) => (
+ <Button
+ key={scene.id}
+ variant={
+ currentScene?.id === scene.id ? "default" : "outline"
+ }
+ className="w-full justify-between"
+ onClick={() => handleSceneSwitch(scene.id)}
+ >
+ <span>{scene.name}</span>
+ {currentScene?.id === scene.id && (
+ <Check className="h-4 w-4" />
+ )}
+ </Button>
+ ))}
+ </div>
+ )}
+ </div>
+ </SheetContent>
+ </Sheet>
+ );
+}
diff --git a/apps/web/src/components/editor/timeline/timeline-toolbar.tsx b/apps/web/src/components/editor/timeline/timeline-toolbar.tsx
index cc9190e2..2daf1ccc 100644
--- a/apps/web/src/components/editor/timeline/timeline-toolbar.tsx
+++ b/apps/web/src/components/editor/timeline/timeline-toolbar.tsx
@@ -1,6 +1,7 @@
import { usePlaybackStore } from "@/stores/playback-store";
import { useProjectStore } from "@/stores/project-store";
import { useTimelineStore } from "@/stores/timeline-store";
+import { useSceneStore } from "@/stores/scene-store";
import { toast } from "sonner";
import {
TooltipProvider,
@@ -38,6 +39,7 @@ import { DEFAULT_FPS } from "@/stores/project-store";
import { formatTimeCode } from "@/lib/time";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { EditableTimecode } from "@/components/ui/editable-timecode";
+import { ScenesView } from "../scenes-view";
export function TimelineToolbar({
zoomLevel,
@@ -65,6 +67,7 @@ export function TimelineToolbar({
} = useTimelineStore();
const { currentTime, duration, isPlaying, toggle, seek } = usePlaybackStore();
const { toggleBookmark, isBookmarked, activeProject } = useProjectStore();
+ const { currentScene } = useSceneStore();
const handleSplitSelected = () => {
if (selectedElements.length === 0) return;
@@ -357,11 +360,13 @@ export function TimelineToolbar({
</div>
<div>
<SplitButton>
- <SplitButtonLeft>Main scene</SplitButtonLeft>
+ <SplitButtonLeft>{currentScene?.name || "No Scene"}</SplitButtonLeft>
<SplitButtonSeparator />
- <SplitButtonRight onClick={() => {}}>
- <LayersIcon />
- </SplitButtonRight>
+ <ScenesView>
+ <SplitButtonRight onClick={() => {}}>
+ <LayersIcon />
+ </SplitButtonRight>
+ </ScenesView>
</SplitButton>
</div>
<div className="flex items-center gap-1">
diff --git a/apps/web/src/components/footer.tsx b/apps/web/src/components/footer.tsx
index 0087502e..176452cd 100644
--- a/apps/web/src/components/footer.tsx
+++ b/apps/web/src/components/footer.tsx
@@ -2,28 +2,11 @@
import { motion } from "motion/react";
import Link from "next/link";
-import { useEffect, useState } from "react";
import { RiDiscordFill, RiTwitterXLine } from "react-icons/ri";
import { FaGithub } from "react-icons/fa6";
-import { getStars } from "@/lib/fetch-github-stars";
import Image from "next/image";
export function Footer() {
- const [star, setStar] = useState<string>();
-
- useEffect(() => {
- const fetchStars = async () => {
- try {
- const data = await getStars();
- setStar(data);
- } catch (err) {
- console.error("Failed to fetch GitHub stars", err);
- }
- };
-
- fetchStars();
- }, []);
-
return (
<motion.footer
className="bg-background border-t"
diff --git a/apps/web/src/components/providers/migrators/scenes-migrator.tsx b/apps/web/src/components/providers/migrators/scenes-migrator.tsx
new file mode 100644
index 00000000..77640327
--- /dev/null
+++ b/apps/web/src/components/providers/migrators/scenes-migrator.tsx
@@ -0,0 +1,176 @@
+"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";
+
+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<MigrationProgress>({
+ 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,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ };
+
+ 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 (
+ <Dialog open={true}>
+ <DialogContent
+ className="sm:max-w-md"
+ onPointerDownOutside={(e) => e.preventDefault()}
+ >
+ <DialogHeader>
+ <DialogTitle>Updating Projects</DialogTitle>
+ <DialogDescription>
+ We're adding scene support to your projects. This will only take a
+ moment.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="space-y-4">
+ <div className="space-y-2">
+ <div className="flex justify-between text-sm">
+ <span>Progress</span>
+ <span>
+ {progress.current} of {progress.total}
+ </span>
+ </div>
+ <Progress value={progressPercent} className="w-full" />
+ </div>
+
+ {progress.currentProjectName && (
+ <div className="text-sm text-muted-foreground">
+ {progress.current < progress.total
+ ? `Updating: ${progress.currentProjectName}`
+ : progress.currentProjectName}
+ </div>
+ )}
+ </div>
+ </DialogContent>
+ </Dialog>
+ );
+ }
+
+ return children;
+}
diff --git a/apps/web/src/components/ui/sheet.tsx b/apps/web/src/components/ui/sheet.tsx
index 2c5522c1..f97bd438 100644
--- a/apps/web/src/components/ui/sheet.tsx
+++ b/apps/web/src/components/ui/sheet.tsx
@@ -21,7 +21,7 @@ const SheetOverlay = React.forwardRef<
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
- "fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
+ "fixed inset-0 z-200 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
@@ -31,7 +31,7 @@ const SheetOverlay = React.forwardRef<
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
const sheetVariants = cva(
- "fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease data-[state=closed]:duration-250 data-[state=open]:duration-250 data-[state=open]:animate-in data-[state=closed]:animate-out",
+ "fixed z-250 gap-4 bg-background p-6 shadow-lg transition ease data-[state=closed]:duration-250 data-[state=open]:duration-250 data-[state=open]:animate-in data-[state=closed]:animate-out",
{
variants: {
side: {
@@ -62,10 +62,14 @@ const SheetContent = React.forwardRef<
<SheetPrimitive.Content
ref={ref}
className={cn(sheetVariants({ side }), className)}
+ onOpenAutoFocus={(e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ }}
{...props}
>
- <SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
- <X className="h-4 w-4" />
+ <SheetPrimitive.Close className="absolute cursor-pointer right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
+ <X className="size-5" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
{children}
diff --git a/apps/web/src/lib/fetch-github-stars.ts b/apps/web/src/lib/fetch-github-stars.ts
deleted file mode 100644
index 0372f25b..00000000
--- a/apps/web/src/lib/fetch-github-stars.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-export async function getStars(): Promise<string> {
- try {
- const res = await fetch(
- "https://api.github.com/repos/OpenCut-app/OpenCut",
- {
- next: { revalidate: 3600 },
- }
- );
-
- if (!res.ok) {
- throw new Error(`GitHub API error: ${res.status} ${res.statusText}`);
- }
- const data = (await res.json()) as { stargazers_count: number };
- const count = data.stargazers_count;
-
- if (typeof count !== "number") {
- throw new Error("Invalid stargazers_count from GitHub API");
- }
-
- if (count >= 1_000_000)
- return (count / 1_000_000).toFixed(1).replace(/\.0$/, "") + "M";
- if (count >= 1000)
- return (count / 1000).toFixed(1).replace(/\.0$/, "") + "k";
- return count.toString();
- } catch (error) {
- console.error("Failed to fetch GitHub stars:", error);
- return "1.5k";
- }
-}
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<MediaFileData>(
`${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<TimelineData>(
- `${this.config.timelineDb}-${projectId}`,
+ dbName,
"timeline",
this.config.version
);
}
// Project operations
- async saveProject(project: TProject): Promise<void> {
+ async saveProject({ project }: { project: TProject }): Promise<void> {
// 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<TProject | null> {
+ async loadProject({ id }: { id: string }): Promise<TProject | null> {
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<void> {
+ async deleteProject({ id }: { id: string }): Promise<void> {
await this.projectsAdapter.remove(id);
}
// Media operations
- async saveMediaFile(projectId: string, mediaItem: MediaFile): Promise<void> {
+ async saveMediaFile({
+ projectId,
+ mediaItem,
+ }: {
+ projectId: string;
+ mediaItem: MediaFile;
+ }): Promise<void> {
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<MediaFile | null> {
+ async loadMediaFile({
+ projectId,
+ id,
+ }: {
+ projectId: string;
+ id: string;
+ }): Promise<MediaFile | null> {
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<MediaFile[]> {
- const { mediaMetadataAdapter } = this.getProjectMediaAdapters(projectId);
+ async loadAllMediaFiles({
+ projectId,
+ }: {
+ projectId: string;
+ }): Promise<MediaFile[]> {
+ 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<void> {
+ async deleteMediaFile({
+ projectId,
+ id,
+ }: {
+ projectId: string;
+ id: string;
+ }): Promise<void> {
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<void> {
+ async deleteProjectMedia({
+ projectId,
+ }: {
+ projectId: string;
+ }): Promise<void> {
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<void> {
- 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<void> {
+ 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<TimelineTrack[] | null> {
- const timelineAdapter = this.getProjectTimelineAdapter(projectId);
+ async loadTimeline({
+ projectId,
+ sceneId,
+ }: {
+ projectId: string;
+ sceneId?: string;
+ }): Promise<TimelineTrack[] | null> {
+ const timelineAdapter = this.getProjectTimelineAdapter({
+ projectId,
+ sceneId,
+ });
const timelineData = await timelineAdapter.get("timeline");
return timelineData ? timelineData.tracks : null;
}
- async deleteProjectTimeline(projectId: string): Promise<void> {
- const timelineAdapter = this.getProjectTimelineAdapter(projectId);
+ async deleteProjectTimeline({
+ projectId,
+ }: {
+ projectId: string;
+ }): Promise<void> {
+ 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<void> {
+ async saveSoundEffect({
+ soundEffect,
+ }: {
+ soundEffect: SoundEffect;
+ }): Promise<void> {
try {
const currentData = await this.loadSavedSounds();
@@ -340,7 +425,7 @@ class StorageService {
}
}
- async removeSavedSound(soundId: number): Promise<void> {
+ async removeSavedSound({ soundId }: { soundId: number }): Promise<void> {
try {
const currentData = await this.loadSavedSounds();
@@ -356,7 +441,7 @@ class StorageService {
}
}
- async isSoundSaved(soundId: number): Promise<boolean> {
+ async isSoundSaved({ soundId }: { soundId: number }): Promise<boolean> {
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<T> {
@@ -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<Scene, "createdAt" | "updatedAt"> & {
+ 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<TProject, "createdAt" | "updatedAt"> & {
+export type SerializedProject = Omit<
+ TProject,
+ "createdAt" | "updatedAt" | "scenes"
+> & {
createdAt: string;
updatedAt: string;
+ scenes: SerializedScene[];
bookmarks?: number[];
};
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<MediaStore>((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<MediaStore>((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<MediaStore>((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<MediaStore>((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<ProjectStore>((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<ProjectStore>((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<ProjectStore>((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<ProjectStore>((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<ProjectStore>((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<ProjectStore>((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<ProjectStore>((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<ProjectStore>((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<ProjectStore>((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<ProjectStore>((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<ProjectStore>((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<ProjectStore>((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<ProjectStore>((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<ProjectStore>((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<ProjectStore>((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<ProjectStore>((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<string>;
+ renameScene: ({
+ sceneId,
+ name,
+ }: {
+ sceneId: string;
+ name: string;
+ }) => Promise<void>;
+ switchToScene: ({ sceneId }: { sceneId: string }) => Promise<void>;
+
+ // Scene utilities
+ getMainScene: () => Scene | null;
+ getCurrentScene: () => Scene | null;
+
+ // Project integration
+ loadProjectScenes: ({ projectId }: { projectId: string }) => Promise<void>;
+ initializeScenes: ({
+ scenes,
+ currentSceneId,
+ }: {
+ scenes: Scene[];
+ currentSceneId?: string;
+ }) => void;
+ clearScenes: () => void;
+}
+
+export const useSceneStore = create<SceneStore>((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<SoundsStore>((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<SoundsStore>((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<void>;
- saveProjectTimeline: (projectId: string) => Promise<void>;
+ loadProjectTimeline: ({
+ projectId,
+ sceneId,
+ }: {
+ projectId: string;
+ sceneId?: string;
+ }) => Promise<void>;
+ saveProjectTimeline: ({
+ projectId,
+ sceneId,
+ }: {
+ projectId: string;
+ sceneId?: string;
+ }) => Promise<void>;
clearTimeline: () => void;
// Clipboard actions
@@ -235,9 +248,15 @@ export const useTimelineStore = create<TimelineStore>((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<TimelineStore>((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<TimelineStore>((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<TimelineStore>((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);
}
diff --git a/apps/web/src/types/project.ts b/apps/web/src/types/project.ts
index 66de858a..f182653d 100644
--- a/apps/web/src/types/project.ts
+++ b/apps/web/src/types/project.ts
@@ -2,12 +2,22 @@ import { CanvasSize } from "./editor";
export type BlurIntensity = 4 | 8 | 18;
+export interface Scene {
+ id: string;
+ name: string;
+ isMain: boolean;
+ createdAt: Date;
+ updatedAt: Date;
+}
+
export interface TProject {
id: string;
name: string;
thumbnail: string;
createdAt: Date;
updatedAt: Date;
+ scenes: Scene[];
+ currentSceneId: string;
mediaItems?: string[];
backgroundColor?: string;
backgroundType?: "color" | "blur";