summaryrefslogtreecommitdiff
path: root/apps/web/src
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2025-09-01 22:15:32 +0200
committerMaze Winther <mazewinther@gmail.com>2025-09-01 22:15:32 +0200
commitd8f446580c2636e87aa39c00b1565406c56f9343 (patch)
treed538f50f10d114bc6d5843efb27cc38cd4d39328 /apps/web/src
parentb84e699e9c4f0a56685697f46f52af86479a1265 (diff)
downloadopencut-d8f446580c2636e87aa39c00b1565406c56f9343.tar.gz
feat: remove default background scene and add scene management
Diffstat (limited to 'apps/web/src')
-rw-r--r--apps/web/src/components/editor/preview-panel.tsx22
-rw-r--r--apps/web/src/components/editor/scenes-view.tsx262
-rw-r--r--apps/web/src/components/editor/timeline/timeline-cache-indicator.tsx14
-rw-r--r--apps/web/src/components/editor/timeline/timeline-toolbar.tsx6
-rw-r--r--apps/web/src/components/providers/migrators/scenes-migrator.tsx12
-rw-r--r--apps/web/src/components/ui/button.tsx2
-rw-r--r--apps/web/src/components/ui/dialog.tsx4
-rw-r--r--apps/web/src/components/ui/split-button.tsx2
-rw-r--r--apps/web/src/hooks/use-frame-cache.ts60
-rw-r--r--apps/web/src/lib/storage/storage-service.ts2
-rw-r--r--apps/web/src/stores/project-store.ts8
-rw-r--r--apps/web/src/stores/scene-store.ts113
-rw-r--r--apps/web/src/types/project.ts1
13 files changed, 364 insertions, 144 deletions
diff --git a/apps/web/src/components/editor/preview-panel.tsx b/apps/web/src/components/editor/preview-panel.tsx
index 8b1abd81..36f6a2ba 100644
--- a/apps/web/src/components/editor/preview-panel.tsx
+++ b/apps/web/src/components/editor/preview-panel.tsx
@@ -14,6 +14,7 @@ import { cn } from "@/lib/utils";
import { formatTimeCode } from "@/lib/time";
import { EditableTimecode } from "@/components/ui/editable-timecode";
import { useFrameCache } from "@/hooks/use-frame-cache";
+import { useSceneStore } from "@/stores/scene-store";
import {
DEFAULT_CANVAS_SIZE,
DEFAULT_FPS,
@@ -43,6 +44,7 @@ export function PreviewPanel() {
const { currentTime, toggle, setCurrentTime } = usePlaybackStore();
const { isPlaying, volume, muted } = usePlaybackStore();
const { activeProject } = useProjectStore();
+ const { currentScene } = useSceneStore();
const previewRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const { getCachedFrame, cacheFrame, invalidateCache, preRenderNearbyFrames } =
@@ -219,11 +221,10 @@ export function PreviewPanel() {
};
}, [dragState, previewDimensions, canvasSize, updateTextElement]);
- // Invalidate cache when timeline changes
+ // Clear the frame cache when background settings change since they affect rendering
useEffect(() => {
invalidateCache();
}, [
- tracks,
mediaFiles,
activeProject?.backgroundColor,
activeProject?.backgroundType,
@@ -494,7 +495,8 @@ export function PreviewPanel() {
currentTime,
tracks,
mediaFiles,
- activeProject
+ activeProject,
+ currentScene?.id
);
if (cachedFrame) {
mainCtx.putImageData(cachedFrame, 0, 0);
@@ -532,7 +534,9 @@ export function PreviewPanel() {
});
return tempCtx.getImageData(0, 0, displayWidth, displayHeight);
- }
+ },
+ currentScene?.id,
+ 3
);
} else {
// Small lookahead while playing
@@ -567,6 +571,7 @@ export function PreviewPanel() {
return tempCtx.getImageData(0, 0, displayWidth, displayHeight);
},
+ currentScene?.id,
1
);
}
@@ -644,7 +649,14 @@ export function PreviewPanel() {
displayWidth,
displayHeight
);
- cacheFrame(currentTime, imageData, tracks, mediaFiles, activeProject);
+ cacheFrame(
+ currentTime,
+ imageData,
+ tracks,
+ mediaFiles,
+ activeProject,
+ currentScene?.id
+ );
// Blit offscreen to visible canvas
mainCtx.clearRect(0, 0, displayWidth, displayHeight);
diff --git a/apps/web/src/components/editor/scenes-view.tsx b/apps/web/src/components/editor/scenes-view.tsx
index f1df32e9..b5432b53 100644
--- a/apps/web/src/components/editor/scenes-view.tsx
+++ b/apps/web/src/components/editor/scenes-view.tsx
@@ -1,64 +1,198 @@
-"use client";
-
-import {
- Sheet,
- SheetContent,
- SheetDescription,
- SheetHeader,
- SheetTitle,
- SheetTrigger,
-} from "@/components/ui/sheet";
-import { Button } from "@/components/ui/button";
-import { useSceneStore } from "@/stores/scene-store";
-import { Check } from "lucide-react";
-
-export function ScenesView({ children }: { children: React.ReactNode }) {
- const { scenes, currentScene, switchToScene } = useSceneStore();
-
- const handleSceneSwitch = async (sceneId: string) => {
- try {
- await switchToScene({ sceneId });
- } catch (error) {
- console.error("Failed to switch scene:", error);
- }
- };
-
- return (
- <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>
- );
-}
+"use client";
+
+import {
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetHeader,
+ SheetTitle,
+ SheetTrigger,
+} from "@/components/ui/sheet";
+import { Button } from "@/components/ui/button";
+import { useSceneStore } from "@/stores/scene-store";
+import { Check, ListCheck, Trash2 } from "lucide-react";
+import { cn } from "@/lib/utils";
+import { useState } from "react";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+ DialogFooter,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+
+export function ScenesView({ children }: { children: React.ReactNode }) {
+ const { scenes, currentScene, switchToScene, deleteScene } = useSceneStore();
+ const [isSelectMode, setIsSelectMode] = useState(false);
+ const [selectedScenes, setSelectedScenes] = useState<Set<string>>(new Set());
+
+ const handleSceneSwitch = async (sceneId: string) => {
+ if (isSelectMode) {
+ toggleSceneSelection(sceneId);
+ return;
+ }
+
+ try {
+ await switchToScene({ sceneId });
+ } catch (error) {
+ console.error("Failed to switch scene:", error);
+ }
+ };
+
+ const toggleSceneSelection = (sceneId: string) => {
+ setSelectedScenes((prev) => {
+ const newSet = new Set(prev);
+ if (newSet.has(sceneId)) {
+ newSet.delete(sceneId);
+ } else {
+ newSet.add(sceneId);
+ }
+ return newSet;
+ });
+ };
+
+ const handleSelectMode = () => {
+ setIsSelectMode(!isSelectMode);
+ setSelectedScenes(new Set());
+ };
+
+ const handleDeleteSelected = async () => {
+ for (const sceneId of selectedScenes) {
+ const scene = scenes.find((s) => s.id === sceneId);
+ if (scene && !scene.isMain) {
+ try {
+ await deleteScene({ sceneId });
+ } catch (error) {
+ console.error("Failed to delete scene:", error);
+ }
+ }
+ }
+ setSelectedScenes(new Set());
+ setIsSelectMode(false);
+ };
+
+ return (
+ <Sheet>
+ <SheetTrigger asChild>{children}</SheetTrigger>
+ <SheetContent>
+ <SheetHeader>
+ <SheetTitle>
+ {isSelectMode ? `Select scenes (${selectedScenes.size})` : "Scenes"}
+ </SheetTitle>
+ <SheetDescription>
+ {isSelectMode
+ ? "Select scenes to delete"
+ : "Switch between scenes in your project"}
+ </SheetDescription>
+ </SheetHeader>
+ <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 ? (
+ <div className="text-sm text-muted-foreground">
+ No scenes available
+ </div>
+ ) : (
+ <div className="space-y-2">
+ {scenes.map((scene) => (
+ <Button
+ key={scene.id}
+ variant="outline"
+ className={cn(
+ "w-full justify-between font-normal",
+ currentScene?.id === scene.id &&
+ !isSelectMode &&
+ "border-primary !text-primary",
+ isSelectMode &&
+ selectedScenes.has(scene.id) &&
+ "bg-accent border-foreground/30"
+ )}
+ onClick={() => handleSceneSwitch(scene.id)}
+ >
+ <span>{scene.name}</span>
+ <div className="flex items-center gap-2">
+ {((isSelectMode && selectedScenes.has(scene.id)) ||
+ (!isSelectMode && currentScene?.id === scene.id)) && (
+ <Check className="h-4 w-4" />
+ )}
+ </div>
+ </Button>
+ ))}
+ </div>
+ )}
+ </div>
+ </SheetContent>
+ </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>
+ );
+}
diff --git a/apps/web/src/components/editor/timeline/timeline-cache-indicator.tsx b/apps/web/src/components/editor/timeline/timeline-cache-indicator.tsx
index ef667d67..7def2985 100644
--- a/apps/web/src/components/editor/timeline/timeline-cache-indicator.tsx
+++ b/apps/web/src/components/editor/timeline/timeline-cache-indicator.tsx
@@ -5,6 +5,7 @@ import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { TimelineTrack } from "@/types/timeline";
import { MediaFile } from "@/types/media";
import { TProject } from "@/types/project";
+import { useSceneStore } from "@/stores/scene-store";
interface CacheSegment {
startTime: number;
@@ -22,7 +23,8 @@ interface TimelineCacheIndicatorProps {
time: number,
tracks: TimelineTrack[],
mediaFiles: MediaFile[],
- activeProject: TProject | null
+ activeProject: TProject | null,
+ sceneId?: string
) => "cached" | "not-cached";
}
@@ -34,6 +36,8 @@ export function TimelineCacheIndicator({
activeProject,
getRenderStatus,
}: TimelineCacheIndicatorProps) {
+ const { currentScene } = useSceneStore();
+
// Calculate cache segments by sampling the timeline
const calculateCacheSegments = (): CacheSegment[] => {
const segments: CacheSegment[] = [];
@@ -49,7 +53,13 @@ export function TimelineCacheIndicator({
for (let i = 0; i <= totalSamples; i++) {
const time = i / sampleRate;
const cached =
- getRenderStatus(time, tracks, mediaFiles, activeProject) === "cached";
+ getRenderStatus(
+ time,
+ tracks,
+ mediaFiles,
+ activeProject,
+ currentScene?.id
+ ) === "cached";
if (!currentSegment) {
// Start first segment
diff --git a/apps/web/src/components/editor/timeline/timeline-toolbar.tsx b/apps/web/src/components/editor/timeline/timeline-toolbar.tsx
index 7eeb84e4..79b5d5b2 100644
--- a/apps/web/src/components/editor/timeline/timeline-toolbar.tsx
+++ b/apps/web/src/components/editor/timeline/timeline-toolbar.tsx
@@ -67,7 +67,7 @@ export function TimelineToolbar({
} = useTimelineStore();
const { currentTime, duration, isPlaying, toggle, seek } = usePlaybackStore();
const { toggleBookmark, isBookmarked, activeProject } = useProjectStore();
- const { currentScene } = useSceneStore();
+ const { scenes, currentScene } = useSceneStore();
const handleSplitSelected = () => {
if (selectedElements.length === 0) return;
@@ -359,11 +359,11 @@ export function TimelineToolbar({
</TooltipProvider>
</div>
<div>
- <SplitButton>
+ <SplitButton className="border border-foreground/10">
<SplitButtonLeft>{currentScene?.name || "No Scene"}</SplitButtonLeft>
<SplitButtonSeparator />
<ScenesView>
- <SplitButtonRight onClick={() => {}}>
+ <SplitButtonRight disabled={scenes.length === 1} onClick={() => {}}>
<LayersIcon />
</SplitButtonRight>
</ScenesView>
diff --git a/apps/web/src/components/providers/migrators/scenes-migrator.tsx b/apps/web/src/components/providers/migrators/scenes-migrator.tsx
index 521e24af..b984a7f4 100644
--- a/apps/web/src/components/providers/migrators/scenes-migrator.tsx
+++ b/apps/web/src/components/providers/migrators/scenes-migrator.tsx
@@ -92,23 +92,13 @@ export function ScenesMigrator({ children }: { children: React.ReactNode }) {
id: generateUUID(),
name: "Main Scene",
isMain: true,
- isBackground: false,
- createdAt: new Date(),
- updatedAt: new Date(),
- };
-
- const backgroundScene: Scene = {
- id: generateUUID(),
- name: "Background",
- isMain: false,
- isBackground: true,
createdAt: new Date(),
updatedAt: new Date(),
};
const migratedProject: TProject = {
...project,
- scenes: [mainScene, backgroundScene],
+ scenes: [mainScene],
currentSceneId: mainScene.id,
updatedAt: new Date(),
};
diff --git a/apps/web/src/components/ui/button.tsx b/apps/web/src/components/ui/button.tsx
index 3d17c042..56a10f44 100644
--- a/apps/web/src/components/ui/button.tsx
+++ b/apps/web/src/components/ui/button.tsx
@@ -16,7 +16,7 @@ const buttonVariants = cva(
"primary-gradient":
"bg-gradient-to-r from-cyan-400 to-blue-500 text-white hover:opacity-85 transition-opacity",
destructive:
- "bg-destructive text-destructive-foreground shadow-xs hover:bg-destructive/90",
+ "bg-destructive/0 border border-destructive/25 text-destructive shadow-xs hover:bg-destructive hover:text-destructive-foreground",
outline:
"border border-input bg-transparent shadow-xs hover:opacity-75 transition-opacity hover:text-accent-foreground",
secondary:
diff --git a/apps/web/src/components/ui/dialog.tsx b/apps/web/src/components/ui/dialog.tsx
index ad7e825b..42d0f80d 100644
--- a/apps/web/src/components/ui/dialog.tsx
+++ b/apps/web/src/components/ui/dialog.tsx
@@ -22,7 +22,7 @@ const DialogOverlay = React.forwardRef<
<DialogPrimitive.Overlay
ref={ref}
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
)}
{...props}
@@ -39,7 +39,7 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content
ref={ref}
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
)}
onCloseAutoFocus={(e) => {
diff --git a/apps/web/src/components/ui/split-button.tsx b/apps/web/src/components/ui/split-button.tsx
index 1ef29500..ca3b518c 100644
--- a/apps/web/src/components/ui/split-button.tsx
+++ b/apps/web/src/components/ui/split-button.tsx
@@ -39,7 +39,7 @@ const SplitButtonSide = forwardRef<
ref={ref}
variant="text"
className={cn(
- "h-full rounded-none bg-panel-accent !opacity-100 border-0 gap-0 font-normal transition-colors",
+ "h-full rounded-none bg-panel-accent !opacity-100 border-0 gap-0 font-normal transition-colors disabled:text-muted-foreground",
onClick
? "hover:bg-foreground/10 hover:opacity-100 cursor-pointer"
: "cursor-default select-text",
diff --git a/apps/web/src/hooks/use-frame-cache.ts b/apps/web/src/hooks/use-frame-cache.ts
index 2abfb912..cce187f1 100644
--- a/apps/web/src/hooks/use-frame-cache.ts
+++ b/apps/web/src/hooks/use-frame-cache.ts
@@ -37,7 +37,8 @@ export function useFrameCache(options: FrameCacheOptions = {}) {
time: number,
tracks: TimelineTrack[],
mediaFiles: MediaFile[],
- activeProject: TProject | null
+ activeProject: TProject | null,
+ sceneId?: string
): string => {
// Get elements that are active at this time
const activeElements: Array<{
@@ -117,11 +118,13 @@ export function useFrameCache(options: FrameCacheOptions = {}) {
canvasSize: activeProject?.canvasSize,
};
- return JSON.stringify({
+ const hash = {
activeElements,
projectState,
- time: Math.floor(time * cacheResolution) / cacheResolution, // Quantize time
- });
+ sceneId,
+ time: Math.floor(time * cacheResolution) / cacheResolution,
+ };
+ return JSON.stringify(hash);
},
[cacheResolution]
);
@@ -132,7 +135,8 @@ export function useFrameCache(options: FrameCacheOptions = {}) {
time: number,
tracks: TimelineTrack[],
mediaFiles: MediaFile[],
- activeProject: TProject | null
+ activeProject: TProject | null,
+ sceneId?: string
): boolean => {
const frameKey = Math.floor(time * cacheResolution);
const cached = frameCacheRef.current.get(frameKey);
@@ -143,7 +147,8 @@ export function useFrameCache(options: FrameCacheOptions = {}) {
time,
tracks,
mediaFiles,
- activeProject
+ activeProject,
+ sceneId
);
return cached.timelineHash === currentHash;
},
@@ -156,21 +161,33 @@ export function useFrameCache(options: FrameCacheOptions = {}) {
time: number,
tracks: TimelineTrack[],
mediaFiles: MediaFile[],
- activeProject: TProject | null
+ activeProject: TProject | null,
+ sceneId?: string
): ImageData | null => {
const frameKey = Math.floor(time * cacheResolution);
const cached = frameCacheRef.current.get(frameKey);
- if (!cached) return null;
+ if (!cached) {
+ return null;
+ }
const currentHash = getTimelineHash(
time,
tracks,
mediaFiles,
- activeProject
+ activeProject,
+ sceneId
);
+ console.log(cached.timelineHash === currentHash);
if (cached.timelineHash !== currentHash) {
// Cache is stale, remove it
+ console.log(
+ "Cache miss - hash mismatch:",
+ JSON.stringify({
+ cachedHash: cached.timelineHash.slice(0, 100),
+ currentHash: currentHash.slice(0, 100),
+ })
+ );
frameCacheRef.current.delete(frameKey);
return null;
}
@@ -187,14 +204,16 @@ export function useFrameCache(options: FrameCacheOptions = {}) {
imageData: ImageData,
tracks: TimelineTrack[],
mediaFiles: MediaFile[],
- activeProject: TProject | null
+ activeProject: TProject | null,
+ sceneId?: string
): void => {
const frameKey = Math.floor(time * cacheResolution);
const timelineHash = getTimelineHash(
time,
tracks,
mediaFiles,
- activeProject
+ activeProject,
+ sceneId
);
// Enforce cache size limit (LRU eviction)
@@ -230,9 +249,10 @@ export function useFrameCache(options: FrameCacheOptions = {}) {
time: number,
tracks: TimelineTrack[],
mediaFiles: MediaFile[],
- activeProject: TProject | null
+ activeProject: TProject | null,
+ sceneId?: string
): "cached" | "not-cached" => {
- return isFrameCached(time, tracks, mediaFiles, activeProject)
+ return isFrameCached(time, tracks, mediaFiles, activeProject, sceneId)
? "cached"
: "not-cached";
},
@@ -247,6 +267,7 @@ export function useFrameCache(options: FrameCacheOptions = {}) {
mediaFiles: MediaFile[],
activeProject: TProject | null,
renderFunction: (time: number) => Promise<ImageData>,
+ sceneId?: string,
range: number = 3 // seconds
) => {
const framesToPreRender: number[] = [];
@@ -260,7 +281,7 @@ export function useFrameCache(options: FrameCacheOptions = {}) {
const time = currentTime + offset;
if (time < 0) continue;
- if (!isFrameCached(time, tracks, mediaFiles, activeProject)) {
+ if (!isFrameCached(time, tracks, mediaFiles, activeProject, sceneId)) {
framesToPreRender.push(time);
}
}
@@ -276,7 +297,7 @@ export function useFrameCache(options: FrameCacheOptions = {}) {
for (let k = 0; k < cacheResolution; k++) {
const t = s + k / cacheResolution;
if (t < 0) continue;
- if (!isFrameCached(t, tracks, mediaFiles, activeProject)) {
+ if (!isFrameCached(t, tracks, mediaFiles, activeProject, sceneId)) {
expandedTimes.push(t);
}
}
@@ -298,7 +319,14 @@ export function useFrameCache(options: FrameCacheOptions = {}) {
requestIdleCallback(async () => {
try {
const imageData = await renderFunction(time);
- cacheFrame(time, imageData, tracks, mediaFiles, activeProject);
+ cacheFrame(
+ time,
+ imageData,
+ tracks,
+ mediaFiles,
+ activeProject,
+ sceneId
+ );
} catch (error) {
console.warn(`Pre-render failed for time ${time}:`, error);
}
diff --git a/apps/web/src/lib/storage/storage-service.ts b/apps/web/src/lib/storage/storage-service.ts
index 112d09b6..2688f6e8 100644
--- a/apps/web/src/lib/storage/storage-service.ts
+++ b/apps/web/src/lib/storage/storage-service.ts
@@ -78,7 +78,6 @@ class StorageService {
id: scene.id,
name: scene.name,
isMain: scene.isMain,
- isBackground: scene.isBackground,
createdAt: scene.createdAt.toISOString(),
updatedAt: scene.updatedAt.toISOString(),
}));
@@ -114,7 +113,6 @@ class StorageService {
id: scene.id,
name: scene.name,
isMain: scene.isMain,
- isBackground: scene.isBackground || false, // Default for legacy scenes
createdAt: new Date(scene.createdAt),
updatedAt: new Date(scene.updatedAt),
})) || [];
diff --git a/apps/web/src/stores/project-store.ts b/apps/web/src/stores/project-store.ts
index 0c67be9f..c58f7309 100644
--- a/apps/web/src/stores/project-store.ts
+++ b/apps/web/src/stores/project-store.ts
@@ -4,7 +4,7 @@ import { storageService } from "@/lib/storage/storage-service";
import { toast } from "sonner";
import { useMediaStore } from "./media-store";
import { useTimelineStore } from "./timeline-store";
-import { createBackgroundScene, useSceneStore } from "./scene-store";
+import { useSceneStore } from "./scene-store";
import { generateUUID } from "@/lib/utils";
import { CanvasSize, CanvasMode } from "@/types/editor";
@@ -14,9 +14,8 @@ export const DEFAULT_FPS = 30;
export function createMainScene(): Scene {
return {
id: generateUUID(),
- name: "Main scene",
+ name: "Main Scene",
isMain: true,
- isBackground: false,
createdAt: new Date(),
updatedAt: new Date(),
};
@@ -24,7 +23,6 @@ export function createMainScene(): Scene {
const createDefaultProject = (name: string): TProject => {
const mainScene = createMainScene();
- const backgroundScene = createBackgroundScene();
return {
id: generateUUID(),
@@ -32,7 +30,7 @@ const createDefaultProject = (name: string): TProject => {
thumbnail: "",
createdAt: new Date(),
updatedAt: new Date(),
- scenes: [mainScene, backgroundScene],
+ scenes: [mainScene],
currentSceneId: mainScene.id,
backgroundColor: "#000000",
backgroundType: "color",
diff --git a/apps/web/src/stores/scene-store.ts b/apps/web/src/stores/scene-store.ts
index 1b789427..a9f65a49 100644
--- a/apps/web/src/stores/scene-store.ts
+++ b/apps/web/src/stores/scene-store.ts
@@ -9,29 +9,17 @@ export function getMainScene({ scenes }: { scenes: Scene[] }): Scene | null {
return scenes.find((scene) => scene.isMain) || null;
}
-export function getBackgroundScene({
- scenes,
-}: {
- scenes: Scene[];
-}): Scene | null {
- return scenes.find((scene) => scene.isBackground) || null;
-}
-
-export function createBackgroundScene(): Scene {
- return {
- id: generateUUID(),
- name: "Background",
- isMain: false,
- isBackground: true,
- createdAt: new Date(),
- updatedAt: new Date(),
- };
-}
-
-function ensureBackgroundScene({ scenes }: { scenes: Scene[] }): Scene[] {
- const hasBackground = scenes.some((scene) => scene.isBackground);
- if (!hasBackground) {
- return [...scenes, createBackgroundScene()];
+function ensureMainScene(scenes: Scene[]): Scene[] {
+ const hasMain = scenes.some((scene) => scene.isMain);
+ if (!hasMain) {
+ const mainScene: Scene = {
+ id: generateUUID(),
+ name: "Main scene",
+ isMain: true,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ };
+ return [mainScene, ...scenes];
}
return scenes;
}
@@ -49,6 +37,7 @@ interface SceneStore {
name: string;
isMain: boolean;
}) => Promise<string>;
+ deleteScene: ({ sceneId }: { sceneId: string }) => Promise<void>;
renameScene: ({
sceneId,
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 }) => {
const { scenes } = get();
const updatedScenes = scenes.map((scene) =>
@@ -226,7 +271,7 @@ export const useSceneStore = create<SceneStore>((set, get) => ({
scenes: Scene[];
currentSceneId?: string;
}) => {
- const ensuredScenes = ensureBackgroundScene({ scenes });
+ const ensuredScenes = ensureMainScene(scenes);
const currentScene = currentSceneId
? ensuredScenes.find((s) => s.id === currentSceneId)
: null;
@@ -241,19 +286,25 @@ export const useSceneStore = create<SceneStore>((set, get) => ({
if (ensuredScenes.length > scenes.length) {
const projectStore = useProjectStore.getState();
const { activeProject } = projectStore;
-
+
if (activeProject) {
const updatedProject = {
...activeProject,
scenes: ensuredScenes,
updatedAt: new Date(),
};
-
- storageService.saveProject({ project: updatedProject }).then(() => {
- useProjectStore.setState({ activeProject: updatedProject });
- }).catch(error => {
- console.error("Failed to save project with background scene:", error);
- });
+
+ storageService
+ .saveProject({ project: updatedProject })
+ .then(() => {
+ useProjectStore.setState({ activeProject: updatedProject });
+ })
+ .catch((error) => {
+ console.error(
+ "Failed to save project with background scene:",
+ error
+ );
+ });
}
}
},
diff --git a/apps/web/src/types/project.ts b/apps/web/src/types/project.ts
index 7fa53c69..f182653d 100644
--- a/apps/web/src/types/project.ts
+++ b/apps/web/src/types/project.ts
@@ -6,7 +6,6 @@ export interface Scene {
id: string;
name: string;
isMain: boolean;
- isBackground: boolean;
createdAt: Date;
updatedAt: Date;
}