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 { 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);
+198 -64
View File
@@ -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>
);
}
@@ -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
@@ -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>
@@ -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(),
};
+1 -1
View File
@@ -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:
+2 -2
View File
@@ -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) => {
+1 -1
View File
@@ -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",