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 { 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);
|
||||||
|
|||||||
@@ -1,64 +1,198 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Sheet,
|
Sheet,
|
||||||
SheetContent,
|
SheetContent,
|
||||||
SheetDescription,
|
SheetDescription,
|
||||||
SheetHeader,
|
SheetHeader,
|
||||||
SheetTitle,
|
SheetTitle,
|
||||||
SheetTrigger,
|
SheetTrigger,
|
||||||
} 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";
|
||||||
export function ScenesView({ children }: { children: React.ReactNode }) {
|
import { useState } from "react";
|
||||||
const { scenes, currentScene, switchToScene } = useSceneStore();
|
import {
|
||||||
|
Dialog,
|
||||||
const handleSceneSwitch = async (sceneId: string) => {
|
DialogContent,
|
||||||
try {
|
DialogHeader,
|
||||||
await switchToScene({ sceneId });
|
DialogTitle,
|
||||||
} catch (error) {
|
DialogDescription,
|
||||||
console.error("Failed to switch scene:", error);
|
DialogFooter,
|
||||||
}
|
DialogTrigger,
|
||||||
};
|
} from "@/components/ui/dialog";
|
||||||
|
|
||||||
return (
|
export function ScenesView({ children }: { children: React.ReactNode }) {
|
||||||
<Sheet>
|
const { scenes, currentScene, switchToScene, deleteScene } = useSceneStore();
|
||||||
<SheetTrigger asChild>{children}</SheetTrigger>
|
const [isSelectMode, setIsSelectMode] = useState(false);
|
||||||
<SheetContent>
|
const [selectedScenes, setSelectedScenes] = useState<Set<string>>(new Set());
|
||||||
<SheetHeader>
|
|
||||||
<SheetTitle>Scenes</SheetTitle>
|
const handleSceneSwitch = async (sceneId: string) => {
|
||||||
<SheetDescription>
|
if (isSelectMode) {
|
||||||
Switch between scenes in your project
|
toggleSceneSelection(sceneId);
|
||||||
</SheetDescription>
|
return;
|
||||||
</SheetHeader>
|
}
|
||||||
<div className="py-4">
|
|
||||||
{scenes.length === 0 ? (
|
try {
|
||||||
<div className="text-sm text-muted-foreground">
|
await switchToScene({ sceneId });
|
||||||
No scenes available
|
} catch (error) {
|
||||||
</div>
|
console.error("Failed to switch scene:", error);
|
||||||
) : (
|
}
|
||||||
<div className="space-y-2">
|
};
|
||||||
{scenes.map((scene) => (
|
|
||||||
<Button
|
const toggleSceneSelection = (sceneId: string) => {
|
||||||
key={scene.id}
|
setSelectedScenes((prev) => {
|
||||||
variant={
|
const newSet = new Set(prev);
|
||||||
currentScene?.id === scene.id ? "default" : "outline"
|
if (newSet.has(sceneId)) {
|
||||||
}
|
newSet.delete(sceneId);
|
||||||
className="w-full justify-between"
|
} else {
|
||||||
onClick={() => handleSceneSwitch(scene.id)}
|
newSet.add(sceneId);
|
||||||
>
|
}
|
||||||
<span>{scene.name}</span>
|
return newSet;
|
||||||
{currentScene?.id === scene.id && (
|
});
|
||||||
<Check className="h-4 w-4" />
|
};
|
||||||
)}
|
|
||||||
</Button>
|
const handleSelectMode = () => {
|
||||||
))}
|
setIsSelectMode(!isSelectMode);
|
||||||
</div>
|
setSelectedScenes(new Set());
|
||||||
)}
|
};
|
||||||
</div>
|
|
||||||
</SheetContent>
|
const handleDeleteSelected = async () => {
|
||||||
</Sheet>
|
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 { 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(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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),
|
||||||
})) || [];
|
})) || [];
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -241,19 +286,25 @@ export const useSceneStore = create<SceneStore>((set, get) => ({
|
|||||||
if (ensuredScenes.length > scenes.length) {
|
if (ensuredScenes.length > scenes.length) {
|
||||||
const projectStore = useProjectStore.getState();
|
const projectStore = useProjectStore.getState();
|
||||||
const { activeProject } = projectStore;
|
const { activeProject } = projectStore;
|
||||||
|
|
||||||
if (activeProject) {
|
if (activeProject) {
|
||||||
const updatedProject = {
|
const updatedProject = {
|
||||||
...activeProject,
|
...activeProject,
|
||||||
scenes: ensuredScenes,
|
scenes: ensuredScenes,
|
||||||
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
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user