refactor(core): switch to scene-based architecture
This commit is contained in:
parent
2b700b7474
commit
350b55081c
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
|
@ -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) => ({
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
Loading…
Reference in New Issue