mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
feat: remove default background scene and add scene management
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user