feat: remove default background scene and add scene management

This commit is contained in:
Maze Winther
2025-09-01 22:15:32 +02:00
parent b84e699e9c
commit d8f446580c
13 changed files with 364 additions and 144 deletions
@@ -14,6 +14,7 @@ import { cn } from "@/lib/utils";
import { formatTimeCode } from "@/lib/time"; import { formatTimeCode } from "@/lib/time";
import { EditableTimecode } from "@/components/ui/editable-timecode"; import { EditableTimecode } from "@/components/ui/editable-timecode";
import { useFrameCache } from "@/hooks/use-frame-cache"; import { useFrameCache } from "@/hooks/use-frame-cache";
import { useSceneStore } from "@/stores/scene-store";
import { import {
DEFAULT_CANVAS_SIZE, DEFAULT_CANVAS_SIZE,
DEFAULT_FPS, DEFAULT_FPS,
@@ -43,6 +44,7 @@ export function PreviewPanel() {
const { currentTime, toggle, setCurrentTime } = usePlaybackStore(); const { currentTime, toggle, setCurrentTime } = usePlaybackStore();
const { isPlaying, volume, muted } = usePlaybackStore(); const { isPlaying, volume, muted } = usePlaybackStore();
const { activeProject } = useProjectStore(); const { activeProject } = useProjectStore();
const { currentScene } = useSceneStore();
const previewRef = useRef<HTMLDivElement>(null); const previewRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null);
const { getCachedFrame, cacheFrame, invalidateCache, preRenderNearbyFrames } = const { getCachedFrame, cacheFrame, invalidateCache, preRenderNearbyFrames } =
@@ -219,11 +221,10 @@ export function PreviewPanel() {
}; };
}, [dragState, previewDimensions, canvasSize, updateTextElement]); }, [dragState, previewDimensions, canvasSize, updateTextElement]);
// Invalidate cache when timeline changes // Clear the frame cache when background settings change since they affect rendering
useEffect(() => { useEffect(() => {
invalidateCache(); invalidateCache();
}, [ }, [
tracks,
mediaFiles, mediaFiles,
activeProject?.backgroundColor, activeProject?.backgroundColor,
activeProject?.backgroundType, activeProject?.backgroundType,
@@ -494,7 +495,8 @@ export function PreviewPanel() {
currentTime, currentTime,
tracks, tracks,
mediaFiles, mediaFiles,
activeProject activeProject,
currentScene?.id
); );
if (cachedFrame) { if (cachedFrame) {
mainCtx.putImageData(cachedFrame, 0, 0); mainCtx.putImageData(cachedFrame, 0, 0);
@@ -532,7 +534,9 @@ export function PreviewPanel() {
}); });
return tempCtx.getImageData(0, 0, displayWidth, displayHeight); return tempCtx.getImageData(0, 0, displayWidth, displayHeight);
} },
currentScene?.id,
3
); );
} else { } else {
// Small lookahead while playing // Small lookahead while playing
@@ -567,6 +571,7 @@ export function PreviewPanel() {
return tempCtx.getImageData(0, 0, displayWidth, displayHeight); return tempCtx.getImageData(0, 0, displayWidth, displayHeight);
}, },
currentScene?.id,
1 1
); );
} }
@@ -644,7 +649,14 @@ export function PreviewPanel() {
displayWidth, displayWidth,
displayHeight displayHeight
); );
cacheFrame(currentTime, imageData, tracks, mediaFiles, activeProject); cacheFrame(
currentTime,
imageData,
tracks,
mediaFiles,
activeProject,
currentScene?.id
);
// Blit offscreen to visible canvas // Blit offscreen to visible canvas
mainCtx.clearRect(0, 0, displayWidth, displayHeight); mainCtx.clearRect(0, 0, displayWidth, displayHeight);
+146 -12
View File
@@ -10,12 +10,30 @@ import {
} from "@/components/ui/sheet"; } from "@/components/ui/sheet";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useSceneStore } from "@/stores/scene-store"; import { useSceneStore } from "@/stores/scene-store";
import { Check } from "lucide-react"; import { Check, ListCheck, Trash2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
DialogTrigger,
} from "@/components/ui/dialog";
export function ScenesView({ children }: { children: React.ReactNode }) { export function ScenesView({ children }: { children: React.ReactNode }) {
const { scenes, currentScene, switchToScene } = useSceneStore(); const { scenes, currentScene, switchToScene, deleteScene } = useSceneStore();
const [isSelectMode, setIsSelectMode] = useState(false);
const [selectedScenes, setSelectedScenes] = useState<Set<string>>(new Set());
const handleSceneSwitch = async (sceneId: string) => { const handleSceneSwitch = async (sceneId: string) => {
if (isSelectMode) {
toggleSceneSelection(sceneId);
return;
}
try { try {
await switchToScene({ sceneId }); await switchToScene({ sceneId });
} catch (error) { } catch (error) {
@@ -23,17 +41,78 @@ export function ScenesView({ children }: { children: React.ReactNode }) {
} }
}; };
const toggleSceneSelection = (sceneId: string) => {
setSelectedScenes((prev) => {
const newSet = new Set(prev);
if (newSet.has(sceneId)) {
newSet.delete(sceneId);
} else {
newSet.add(sceneId);
}
return newSet;
});
};
const handleSelectMode = () => {
setIsSelectMode(!isSelectMode);
setSelectedScenes(new Set());
};
const handleDeleteSelected = async () => {
for (const sceneId of selectedScenes) {
const scene = scenes.find((s) => s.id === sceneId);
if (scene && !scene.isMain) {
try {
await deleteScene({ sceneId });
} catch (error) {
console.error("Failed to delete scene:", error);
}
}
}
setSelectedScenes(new Set());
setIsSelectMode(false);
};
return ( return (
<Sheet> <Sheet>
<SheetTrigger asChild>{children}</SheetTrigger> <SheetTrigger asChild>{children}</SheetTrigger>
<SheetContent> <SheetContent>
<SheetHeader> <SheetHeader>
<SheetTitle>Scenes</SheetTitle> <SheetTitle>
{isSelectMode ? `Select scenes (${selectedScenes.size})` : "Scenes"}
</SheetTitle>
<SheetDescription> <SheetDescription>
Switch between scenes in your project {isSelectMode
? "Select scenes to delete"
: "Switch between scenes in your project"}
</SheetDescription> </SheetDescription>
</SheetHeader> </SheetHeader>
<div className="py-4"> <div className="py-4 flex flex-col gap-4">
<div className="flex items-center gap-2">
<Button
className="rounded-md"
variant={isSelectMode ? "default" : "outline"}
size="sm"
onClick={handleSelectMode}
>
<ListCheck />
{isSelectMode ? "Cancel" : "Select"}
</Button>
{isSelectMode && (
<DeleteDialog
count={selectedScenes.size}
onDelete={handleDeleteSelected}
disabled={Array.from(selectedScenes).some(
(id) => scenes.find((s) => s.id === id)?.isMain
)}
>
<Button className="rounded-md" variant="destructive" size="sm">
<Trash2 />
Delete ({selectedScenes.size})
</Button>
</DeleteDialog>
)}
</div>
{scenes.length === 0 ? ( {scenes.length === 0 ? (
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
No scenes available No scenes available
@@ -43,16 +122,25 @@ export function ScenesView({ children }: { children: React.ReactNode }) {
{scenes.map((scene) => ( {scenes.map((scene) => (
<Button <Button
key={scene.id} key={scene.id}
variant={ variant="outline"
currentScene?.id === scene.id ? "default" : "outline" className={cn(
} "w-full justify-between font-normal",
className="w-full justify-between" currentScene?.id === scene.id &&
!isSelectMode &&
"border-primary !text-primary",
isSelectMode &&
selectedScenes.has(scene.id) &&
"bg-accent border-foreground/30"
)}
onClick={() => handleSceneSwitch(scene.id)} onClick={() => handleSceneSwitch(scene.id)}
> >
<span>{scene.name}</span> <span>{scene.name}</span>
{currentScene?.id === scene.id && ( <div className="flex items-center gap-2">
<Check className="h-4 w-4" /> {((isSelectMode && selectedScenes.has(scene.id)) ||
)} (!isSelectMode && currentScene?.id === scene.id)) && (
<Check className="h-4 w-4" />
)}
</div>
</Button> </Button>
))} ))}
</div> </div>
@@ -62,3 +150,49 @@ export function ScenesView({ children }: { children: React.ReactNode }) {
</Sheet> </Sheet>
); );
} }
function DeleteDialog({
count,
onDelete,
disabled,
children,
}: {
count: number;
onDelete: () => void;
disabled?: boolean;
children: React.ReactNode;
}) {
const [open, setOpen] = useState(false);
const handleDelete = () => {
onDelete();
setOpen(false);
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Scenes</DialogTitle>
<DialogDescription>
Are you sure you want to delete {count} scene
{count === 1 ? "" : "s"}? This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleDelete}
disabled={disabled}
>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -5,6 +5,7 @@ import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { TimelineTrack } from "@/types/timeline"; import { TimelineTrack } from "@/types/timeline";
import { MediaFile } from "@/types/media"; import { MediaFile } from "@/types/media";
import { TProject } from "@/types/project"; import { TProject } from "@/types/project";
import { useSceneStore } from "@/stores/scene-store";
interface CacheSegment { interface CacheSegment {
startTime: number; startTime: number;
@@ -22,7 +23,8 @@ interface TimelineCacheIndicatorProps {
time: number, time: number,
tracks: TimelineTrack[], tracks: TimelineTrack[],
mediaFiles: MediaFile[], mediaFiles: MediaFile[],
activeProject: TProject | null activeProject: TProject | null,
sceneId?: string
) => "cached" | "not-cached"; ) => "cached" | "not-cached";
} }
@@ -34,6 +36,8 @@ export function TimelineCacheIndicator({
activeProject, activeProject,
getRenderStatus, getRenderStatus,
}: TimelineCacheIndicatorProps) { }: TimelineCacheIndicatorProps) {
const { currentScene } = useSceneStore();
// Calculate cache segments by sampling the timeline // Calculate cache segments by sampling the timeline
const calculateCacheSegments = (): CacheSegment[] => { const calculateCacheSegments = (): CacheSegment[] => {
const segments: CacheSegment[] = []; const segments: CacheSegment[] = [];
@@ -49,7 +53,13 @@ export function TimelineCacheIndicator({
for (let i = 0; i <= totalSamples; i++) { for (let i = 0; i <= totalSamples; i++) {
const time = i / sampleRate; const time = i / sampleRate;
const cached = const cached =
getRenderStatus(time, tracks, mediaFiles, activeProject) === "cached"; getRenderStatus(
time,
tracks,
mediaFiles,
activeProject,
currentScene?.id
) === "cached";
if (!currentSegment) { if (!currentSegment) {
// Start first segment // Start first segment
@@ -67,7 +67,7 @@ export function TimelineToolbar({
} = useTimelineStore(); } = useTimelineStore();
const { currentTime, duration, isPlaying, toggle, seek } = usePlaybackStore(); const { currentTime, duration, isPlaying, toggle, seek } = usePlaybackStore();
const { toggleBookmark, isBookmarked, activeProject } = useProjectStore(); const { toggleBookmark, isBookmarked, activeProject } = useProjectStore();
const { currentScene } = useSceneStore(); const { scenes, currentScene } = useSceneStore();
const handleSplitSelected = () => { const handleSplitSelected = () => {
if (selectedElements.length === 0) return; if (selectedElements.length === 0) return;
@@ -359,11 +359,11 @@ export function TimelineToolbar({
</TooltipProvider> </TooltipProvider>
</div> </div>
<div> <div>
<SplitButton> <SplitButton className="border border-foreground/10">
<SplitButtonLeft>{currentScene?.name || "No Scene"}</SplitButtonLeft> <SplitButtonLeft>{currentScene?.name || "No Scene"}</SplitButtonLeft>
<SplitButtonSeparator /> <SplitButtonSeparator />
<ScenesView> <ScenesView>
<SplitButtonRight onClick={() => {}}> <SplitButtonRight disabled={scenes.length === 1} onClick={() => {}}>
<LayersIcon /> <LayersIcon />
</SplitButtonRight> </SplitButtonRight>
</ScenesView> </ScenesView>
@@ -92,23 +92,13 @@ export function ScenesMigrator({ children }: { children: React.ReactNode }) {
id: generateUUID(), id: generateUUID(),
name: "Main Scene", name: "Main Scene",
isMain: true, isMain: true,
isBackground: false,
createdAt: new Date(),
updatedAt: new Date(),
};
const backgroundScene: Scene = {
id: generateUUID(),
name: "Background",
isMain: false,
isBackground: true,
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
}; };
const migratedProject: TProject = { const migratedProject: TProject = {
...project, ...project,
scenes: [mainScene, backgroundScene], scenes: [mainScene],
currentSceneId: mainScene.id, currentSceneId: mainScene.id,
updatedAt: new Date(), updatedAt: new Date(),
}; };
+1 -1
View File
@@ -16,7 +16,7 @@ const buttonVariants = cva(
"primary-gradient": "primary-gradient":
"bg-gradient-to-r from-cyan-400 to-blue-500 text-white hover:opacity-85 transition-opacity", "bg-gradient-to-r from-cyan-400 to-blue-500 text-white hover:opacity-85 transition-opacity",
destructive: destructive:
"bg-destructive text-destructive-foreground shadow-xs hover:bg-destructive/90", "bg-destructive/0 border border-destructive/25 text-destructive shadow-xs hover:bg-destructive hover:text-destructive-foreground",
outline: outline:
"border border-input bg-transparent shadow-xs hover:opacity-75 transition-opacity hover:text-accent-foreground", "border border-input bg-transparent shadow-xs hover:opacity-75 transition-opacity hover:text-accent-foreground",
secondary: secondary:
+2 -2
View File
@@ -22,7 +22,7 @@ const DialogOverlay = React.forwardRef<
<DialogPrimitive.Overlay <DialogPrimitive.Overlay
ref={ref} ref={ref}
className={cn( className={cn(
"fixed inset-0 z-150 bg-black/20 backdrop-blur-md 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-250 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 className
)} )}
{...props} {...props}
@@ -39,7 +39,7 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content <DialogPrimitive.Content
ref={ref} ref={ref}
className={cn( className={cn(
"fixed left-[50%] top-[50%] p-6 z-150 grid w-[calc(100%-2rem)] max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-popover shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 rounded-lg", "fixed left-[50%] top-[50%] p-6 z-250 grid w-[calc(100%-2rem)] max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-popover shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 rounded-lg",
className className
)} )}
onCloseAutoFocus={(e) => { onCloseAutoFocus={(e) => {
+1 -1
View File
@@ -39,7 +39,7 @@ const SplitButtonSide = forwardRef<
ref={ref} ref={ref}
variant="text" variant="text"
className={cn( className={cn(
"h-full rounded-none bg-panel-accent !opacity-100 border-0 gap-0 font-normal transition-colors", "h-full rounded-none bg-panel-accent !opacity-100 border-0 gap-0 font-normal transition-colors disabled:text-muted-foreground",
onClick onClick
? "hover:bg-foreground/10 hover:opacity-100 cursor-pointer" ? "hover:bg-foreground/10 hover:opacity-100 cursor-pointer"
: "cursor-default select-text", : "cursor-default select-text",
+44 -16
View File
@@ -37,7 +37,8 @@ export function useFrameCache(options: FrameCacheOptions = {}) {
time: number, time: number,
tracks: TimelineTrack[], tracks: TimelineTrack[],
mediaFiles: MediaFile[], mediaFiles: MediaFile[],
activeProject: TProject | null activeProject: TProject | null,
sceneId?: string
): string => { ): string => {
// Get elements that are active at this time // Get elements that are active at this time
const activeElements: Array<{ const activeElements: Array<{
@@ -117,11 +118,13 @@ export function useFrameCache(options: FrameCacheOptions = {}) {
canvasSize: activeProject?.canvasSize, canvasSize: activeProject?.canvasSize,
}; };
return JSON.stringify({ const hash = {
activeElements, activeElements,
projectState, projectState,
time: Math.floor(time * cacheResolution) / cacheResolution, // Quantize time sceneId,
}); time: Math.floor(time * cacheResolution) / cacheResolution,
};
return JSON.stringify(hash);
}, },
[cacheResolution] [cacheResolution]
); );
@@ -132,7 +135,8 @@ export function useFrameCache(options: FrameCacheOptions = {}) {
time: number, time: number,
tracks: TimelineTrack[], tracks: TimelineTrack[],
mediaFiles: MediaFile[], mediaFiles: MediaFile[],
activeProject: TProject | null activeProject: TProject | null,
sceneId?: string
): boolean => { ): boolean => {
const frameKey = Math.floor(time * cacheResolution); const frameKey = Math.floor(time * cacheResolution);
const cached = frameCacheRef.current.get(frameKey); const cached = frameCacheRef.current.get(frameKey);
@@ -143,7 +147,8 @@ export function useFrameCache(options: FrameCacheOptions = {}) {
time, time,
tracks, tracks,
mediaFiles, mediaFiles,
activeProject activeProject,
sceneId
); );
return cached.timelineHash === currentHash; return cached.timelineHash === currentHash;
}, },
@@ -156,21 +161,33 @@ export function useFrameCache(options: FrameCacheOptions = {}) {
time: number, time: number,
tracks: TimelineTrack[], tracks: TimelineTrack[],
mediaFiles: MediaFile[], mediaFiles: MediaFile[],
activeProject: TProject | null activeProject: TProject | null,
sceneId?: string
): ImageData | null => { ): ImageData | null => {
const frameKey = Math.floor(time * cacheResolution); const frameKey = Math.floor(time * cacheResolution);
const cached = frameCacheRef.current.get(frameKey); const cached = frameCacheRef.current.get(frameKey);
if (!cached) return null; if (!cached) {
return null;
}
const currentHash = getTimelineHash( const currentHash = getTimelineHash(
time, time,
tracks, tracks,
mediaFiles, mediaFiles,
activeProject activeProject,
sceneId
); );
console.log(cached.timelineHash === currentHash);
if (cached.timelineHash !== currentHash) { if (cached.timelineHash !== currentHash) {
// Cache is stale, remove it // Cache is stale, remove it
console.log(
"Cache miss - hash mismatch:",
JSON.stringify({
cachedHash: cached.timelineHash.slice(0, 100),
currentHash: currentHash.slice(0, 100),
})
);
frameCacheRef.current.delete(frameKey); frameCacheRef.current.delete(frameKey);
return null; return null;
} }
@@ -187,14 +204,16 @@ export function useFrameCache(options: FrameCacheOptions = {}) {
imageData: ImageData, imageData: ImageData,
tracks: TimelineTrack[], tracks: TimelineTrack[],
mediaFiles: MediaFile[], mediaFiles: MediaFile[],
activeProject: TProject | null activeProject: TProject | null,
sceneId?: string
): void => { ): void => {
const frameKey = Math.floor(time * cacheResolution); const frameKey = Math.floor(time * cacheResolution);
const timelineHash = getTimelineHash( const timelineHash = getTimelineHash(
time, time,
tracks, tracks,
mediaFiles, mediaFiles,
activeProject activeProject,
sceneId
); );
// Enforce cache size limit (LRU eviction) // Enforce cache size limit (LRU eviction)
@@ -230,9 +249,10 @@ export function useFrameCache(options: FrameCacheOptions = {}) {
time: number, time: number,
tracks: TimelineTrack[], tracks: TimelineTrack[],
mediaFiles: MediaFile[], mediaFiles: MediaFile[],
activeProject: TProject | null activeProject: TProject | null,
sceneId?: string
): "cached" | "not-cached" => { ): "cached" | "not-cached" => {
return isFrameCached(time, tracks, mediaFiles, activeProject) return isFrameCached(time, tracks, mediaFiles, activeProject, sceneId)
? "cached" ? "cached"
: "not-cached"; : "not-cached";
}, },
@@ -247,6 +267,7 @@ export function useFrameCache(options: FrameCacheOptions = {}) {
mediaFiles: MediaFile[], mediaFiles: MediaFile[],
activeProject: TProject | null, activeProject: TProject | null,
renderFunction: (time: number) => Promise<ImageData>, renderFunction: (time: number) => Promise<ImageData>,
sceneId?: string,
range: number = 3 // seconds range: number = 3 // seconds
) => { ) => {
const framesToPreRender: number[] = []; const framesToPreRender: number[] = [];
@@ -260,7 +281,7 @@ export function useFrameCache(options: FrameCacheOptions = {}) {
const time = currentTime + offset; const time = currentTime + offset;
if (time < 0) continue; if (time < 0) continue;
if (!isFrameCached(time, tracks, mediaFiles, activeProject)) { if (!isFrameCached(time, tracks, mediaFiles, activeProject, sceneId)) {
framesToPreRender.push(time); framesToPreRender.push(time);
} }
} }
@@ -276,7 +297,7 @@ export function useFrameCache(options: FrameCacheOptions = {}) {
for (let k = 0; k < cacheResolution; k++) { for (let k = 0; k < cacheResolution; k++) {
const t = s + k / cacheResolution; const t = s + k / cacheResolution;
if (t < 0) continue; if (t < 0) continue;
if (!isFrameCached(t, tracks, mediaFiles, activeProject)) { if (!isFrameCached(t, tracks, mediaFiles, activeProject, sceneId)) {
expandedTimes.push(t); expandedTimes.push(t);
} }
} }
@@ -298,7 +319,14 @@ export function useFrameCache(options: FrameCacheOptions = {}) {
requestIdleCallback(async () => { requestIdleCallback(async () => {
try { try {
const imageData = await renderFunction(time); const imageData = await renderFunction(time);
cacheFrame(time, imageData, tracks, mediaFiles, activeProject); cacheFrame(
time,
imageData,
tracks,
mediaFiles,
activeProject,
sceneId
);
} catch (error) { } catch (error) {
console.warn(`Pre-render failed for time ${time}:`, error); console.warn(`Pre-render failed for time ${time}:`, error);
} }
@@ -78,7 +78,6 @@ class StorageService {
id: scene.id, id: scene.id,
name: scene.name, name: scene.name,
isMain: scene.isMain, isMain: scene.isMain,
isBackground: scene.isBackground,
createdAt: scene.createdAt.toISOString(), createdAt: scene.createdAt.toISOString(),
updatedAt: scene.updatedAt.toISOString(), updatedAt: scene.updatedAt.toISOString(),
})); }));
@@ -114,7 +113,6 @@ class StorageService {
id: scene.id, id: scene.id,
name: scene.name, name: scene.name,
isMain: scene.isMain, isMain: scene.isMain,
isBackground: scene.isBackground || false, // Default for legacy scenes
createdAt: new Date(scene.createdAt), createdAt: new Date(scene.createdAt),
updatedAt: new Date(scene.updatedAt), updatedAt: new Date(scene.updatedAt),
})) || []; })) || [];
+3 -5
View File
@@ -4,7 +4,7 @@ import { storageService } from "@/lib/storage/storage-service";
import { toast } from "sonner"; import { toast } from "sonner";
import { useMediaStore } from "./media-store"; import { useMediaStore } from "./media-store";
import { useTimelineStore } from "./timeline-store"; import { useTimelineStore } from "./timeline-store";
import { createBackgroundScene, useSceneStore } from "./scene-store"; import { useSceneStore } from "./scene-store";
import { generateUUID } from "@/lib/utils"; import { generateUUID } from "@/lib/utils";
import { CanvasSize, CanvasMode } from "@/types/editor"; import { CanvasSize, CanvasMode } from "@/types/editor";
@@ -14,9 +14,8 @@ export const DEFAULT_FPS = 30;
export function createMainScene(): Scene { export function createMainScene(): Scene {
return { return {
id: generateUUID(), id: generateUUID(),
name: "Main scene", name: "Main Scene",
isMain: true, isMain: true,
isBackground: false,
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
}; };
@@ -24,7 +23,6 @@ export function createMainScene(): Scene {
const createDefaultProject = (name: string): TProject => { const createDefaultProject = (name: string): TProject => {
const mainScene = createMainScene(); const mainScene = createMainScene();
const backgroundScene = createBackgroundScene();
return { return {
id: generateUUID(), id: generateUUID(),
@@ -32,7 +30,7 @@ const createDefaultProject = (name: string): TProject => {
thumbnail: "", thumbnail: "",
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
scenes: [mainScene, backgroundScene], scenes: [mainScene],
currentSceneId: mainScene.id, currentSceneId: mainScene.id,
backgroundColor: "#000000", backgroundColor: "#000000",
backgroundType: "color", backgroundType: "color",
+80 -29
View File
@@ -9,29 +9,17 @@ export function getMainScene({ scenes }: { scenes: Scene[] }): Scene | null {
return scenes.find((scene) => scene.isMain) || null; return scenes.find((scene) => scene.isMain) || null;
} }
export function getBackgroundScene({ function ensureMainScene(scenes: Scene[]): Scene[] {
scenes, const hasMain = scenes.some((scene) => scene.isMain);
}: { if (!hasMain) {
scenes: Scene[]; const mainScene: Scene = {
}): Scene | null { id: generateUUID(),
return scenes.find((scene) => scene.isBackground) || null; name: "Main scene",
} isMain: true,
createdAt: new Date(),
export function createBackgroundScene(): Scene { updatedAt: new Date(),
return { };
id: generateUUID(), return [mainScene, ...scenes];
name: "Background",
isMain: false,
isBackground: true,
createdAt: new Date(),
updatedAt: new Date(),
};
}
function ensureBackgroundScene({ scenes }: { scenes: Scene[] }): Scene[] {
const hasBackground = scenes.some((scene) => scene.isBackground);
if (!hasBackground) {
return [...scenes, createBackgroundScene()];
} }
return scenes; return scenes;
} }
@@ -49,6 +37,7 @@ interface SceneStore {
name: string; name: string;
isMain: boolean; isMain: boolean;
}) => Promise<string>; }) => Promise<string>;
deleteScene: ({ sceneId }: { sceneId: string }) => Promise<void>;
renameScene: ({ renameScene: ({
sceneId, sceneId,
name, name,
@@ -115,6 +104,62 @@ export const useSceneStore = create<SceneStore>((set, get) => ({
} }
}, },
deleteScene: async ({ sceneId }: { sceneId: string }) => {
const { scenes, currentScene } = get();
const sceneToDelete = scenes.find((s) => s.id === sceneId);
if (!sceneToDelete) {
throw new Error("Scene not found");
}
if (sceneToDelete.isMain) {
throw new Error("Cannot delete main scene");
}
const updatedScenes = scenes.filter((s) => s.id !== sceneId);
// Determine new current scene if we're deleting the current one
let newCurrentScene = currentScene;
if (currentScene?.id === sceneId) {
newCurrentScene = getMainScene({ scenes: updatedScenes });
}
// Update project
const projectStore = useProjectStore.getState();
const { activeProject } = projectStore;
if (!activeProject) {
throw new Error("No active project");
}
const updatedProject = {
...activeProject,
scenes: updatedScenes,
updatedAt: new Date(),
};
try {
await storageService.saveProject({ project: updatedProject });
useProjectStore.setState({ activeProject: updatedProject });
set({
scenes: updatedScenes,
currentScene: newCurrentScene,
});
// If we switched scenes, load the new scene's timeline
if (newCurrentScene && newCurrentScene.id !== currentScene?.id) {
const timelineStore = useTimelineStore.getState();
await timelineStore.loadProjectTimeline({
projectId: activeProject.id,
sceneId: newCurrentScene.id,
});
}
} catch (error) {
console.error("Failed to delete scene:", error);
throw error;
}
},
renameScene: async ({ sceneId, name }: { sceneId: string; name: string }) => { renameScene: async ({ sceneId, name }: { sceneId: string; name: string }) => {
const { scenes } = get(); const { scenes } = get();
const updatedScenes = scenes.map((scene) => const updatedScenes = scenes.map((scene) =>
@@ -226,7 +271,7 @@ export const useSceneStore = create<SceneStore>((set, get) => ({
scenes: Scene[]; scenes: Scene[];
currentSceneId?: string; currentSceneId?: string;
}) => { }) => {
const ensuredScenes = ensureBackgroundScene({ scenes }); const ensuredScenes = ensureMainScene(scenes);
const currentScene = currentSceneId const currentScene = currentSceneId
? ensuredScenes.find((s) => s.id === currentSceneId) ? ensuredScenes.find((s) => s.id === currentSceneId)
: null; : null;
@@ -249,11 +294,17 @@ export const useSceneStore = create<SceneStore>((set, get) => ({
updatedAt: new Date(), updatedAt: new Date(),
}; };
storageService.saveProject({ project: updatedProject }).then(() => { storageService
useProjectStore.setState({ activeProject: updatedProject }); .saveProject({ project: updatedProject })
}).catch(error => { .then(() => {
console.error("Failed to save project with background scene:", error); useProjectStore.setState({ activeProject: updatedProject });
}); })
.catch((error) => {
console.error(
"Failed to save project with background scene:",
error
);
});
} }
} }
}, },
-1
View File
@@ -6,7 +6,6 @@ export interface Scene {
id: string; id: string;
name: string; name: string;
isMain: boolean; isMain: boolean;
isBackground: boolean;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
} }