refactor(core): switch to scene-based architecture

This commit is contained in:
Maze Winther 2025-09-01 16:17:06 +02:00
parent 2b700b7474
commit 350b55081c
15 changed files with 793 additions and 164 deletions

View File

@ -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

View File

@ -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>
);
}

View File

@ -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">

View File

@ -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"

View File

@ -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;
}

View File

@ -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}

View File

@ -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";
}
}

View File

@ -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);

View File

@ -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[];
};

View File

@ -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);

View File

@ -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);

View File

@ -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,
});
},
}));

View File

@ -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) => ({

View File

@ -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);
}

View File

@ -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";