summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2025-07-09 21:03:56 +0200
committerMaze Winther <mazewinther@gmail.com>2025-07-09 21:03:56 +0200
commitc02f276303e00f4d8c66d8d1c804c4af9c719afb (patch)
treeaac3a38fd5f673cc30a9bbe1aef9eb197b430c9e
parentdd35c91f395eacf37f96dff8c9cec2340c9bb2b1 (diff)
downloadopencut-c02f276303e00f4d8c66d8d1c804c4af9c719afb.tar.gz
refactor: fresh properties panel
-rw-r--r--apps/web/src/components/editor/preview-panel.tsx54
-rw-r--r--apps/web/src/components/editor/properties-panel.tsx229
-rw-r--r--apps/web/src/components/editor/timeline-element.tsx39
-rw-r--r--apps/web/src/hooks/use-aspect-ratio.ts92
-rw-r--r--apps/web/src/stores/editor-store.ts18
-rw-r--r--apps/web/src/stores/timeline-store.ts103
6 files changed, 280 insertions, 255 deletions
diff --git a/apps/web/src/components/editor/preview-panel.tsx b/apps/web/src/components/editor/preview-panel.tsx
index 89ffa34e..834fac51 100644
--- a/apps/web/src/components/editor/preview-panel.tsx
+++ b/apps/web/src/components/editor/preview-panel.tsx
@@ -2,13 +2,10 @@
import { useTimelineStore } from "@/stores/timeline-store";
import { TimelineElement, TimelineTrack } from "@/types/timeline";
-import {
- useMediaStore,
- type MediaItem,
- getMediaAspectRatio,
-} from "@/stores/media-store";
+import { useMediaStore, type MediaItem } from "@/stores/media-store";
import { usePlaybackStore } from "@/stores/playback-store";
import { useEditorStore } from "@/stores/editor-store";
+import { useAspectRatio } from "@/hooks/use-aspect-ratio";
import { VideoPlayer } from "@/components/ui/video-player";
import { Button } from "@/components/ui/button";
import {
@@ -269,54 +266,25 @@ export function PreviewPanel() {
function PreviewToolbar({ hasAnyElements }: { hasAnyElements: boolean }) {
const { isPlaying, toggle, currentTime } = usePlaybackStore();
+ const { setCanvasSize, setCanvasSizeToOriginal } = useEditorStore();
+ const { getTotalDuration } = useTimelineStore();
const {
- canvasSize,
+ currentPreset,
+ isOriginal,
+ getOriginalAspectRatio,
+ getDisplayName,
canvasPresets,
- setCanvasSize,
- setCanvasSizeFromAspectRatio,
- } = useEditorStore();
- const { mediaItems } = useMediaStore();
- const { tracks, getTotalDuration } = useTimelineStore();
-
- // Find the current preset based on canvas size
- const currentPreset = canvasPresets.find(
- (preset) =>
- preset.width === canvasSize.width && preset.height === canvasSize.height
- );
+ } = useAspectRatio();
const handlePresetSelect = (preset: { width: number; height: number }) => {
setCanvasSize({ width: preset.width, height: preset.height });
};
- // Get the first video/image media item to determine original aspect ratio
- const getOriginalAspectRatio = () => {
- // Find first video or image in timeline
- for (const track of tracks) {
- for (const element of track.elements) {
- if (element.type === "media") {
- const mediaItem = mediaItems.find(
- (item) => item.id === element.mediaId
- );
- if (
- mediaItem &&
- (mediaItem.type === "video" || mediaItem.type === "image")
- ) {
- return getMediaAspectRatio(mediaItem);
- }
- }
- }
- }
- return 16 / 9; // Default aspect ratio
- };
-
const handleOriginalSelect = () => {
const aspectRatio = getOriginalAspectRatio();
- setCanvasSizeFromAspectRatio(aspectRatio);
+ setCanvasSizeToOriginal(aspectRatio);
};
- // Check if current size is "Original" (not matching any preset)
- const isOriginal = !currentPreset;
-
return (
<div
data-toolbar
@@ -353,7 +321,7 @@ function PreviewToolbar({ hasAnyElements }: { hasAnyElements: boolean }) {
className="!bg-background text-foreground/85 text-xs h-auto rounded-none border border-muted-foreground px-0.5 py-0 font-light"
disabled={!hasAnyElements}
>
- {currentPreset?.name || "Ratio"}
+ {getDisplayName()}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
diff --git a/apps/web/src/components/editor/properties-panel.tsx b/apps/web/src/components/editor/properties-panel.tsx
index 0de78988..a51f6f2a 100644
--- a/apps/web/src/components/editor/properties-panel.tsx
+++ b/apps/web/src/components/editor/properties-panel.tsx
@@ -1,220 +1,37 @@
"use client";
-import { Input } from "../ui/input";
+import { useProjectStore } from "@/stores/project-store";
+import { useAspectRatio } from "@/hooks/use-aspect-ratio";
import { Label } from "../ui/label";
-import { Slider } from "../ui/slider";
import { ScrollArea } from "../ui/scroll-area";
-import { Separator } from "../ui/separator";
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "../ui/select";
-import { useTimelineStore } from "@/stores/timeline-store";
-import { useMediaStore } from "@/stores/media-store";
-import { ImageTimelineTreatment } from "@/components/ui/image-timeline-treatment";
-import { useState } from "react";
-import { SpeedControl } from "./speed-control";
-import type { BackgroundType } from "@/types/editor";
export function PropertiesPanel() {
- const { tracks } = useTimelineStore();
- const { mediaItems } = useMediaStore();
- const [backgroundType, setBackgroundType] = useState<BackgroundType>("blur");
- const [backgroundColor, setBackgroundColor] = useState("#000000");
-
- // Get the first video element for preview (simplified)
- const firstVideoElement = tracks
- .flatMap((track) => track.elements)
- .find((element) => {
- if (element.type !== "media") return false;
- const mediaItem = mediaItems.find((item) => item.id === element.mediaId);
- return mediaItem?.type === "video";
- });
-
- const firstVideoItem = firstVideoElement && firstVideoElement.type === "media"
- ? mediaItems.find((item) => item.id === firstVideoElement.mediaId)
- : null;
-
- const firstImageElement = tracks
- .flatMap((track) => track.elements)
- .find((element) => {
- if (element.type !== "media") return false;
- const mediaItem = mediaItems.find((item) => item.id === element.mediaId);
- return mediaItem?.type === "image";
- });
-
- const firstImageItem = firstImageElement && firstImageElement.type === "media"
- ? mediaItems.find((item) => item.id === firstImageElement.mediaId)
- : null;
+ const { activeProject } = useProjectStore();
+ const { getDisplayName, canvasSize } = useAspectRatio();
return (
<ScrollArea className="h-full">
- <div className="space-y-6 p-5">
- {/* Image Treatment - only show if an image is selected */}
- {firstImageItem && (
- <>
- <div className="space-y-4">
- <h3 className="text-sm font-medium">Image Treatment</h3>
- <div className="space-y-4">
- {/* Preview */}
- <div className="space-y-2">
- <Label>Preview</Label>
- <div className="w-full aspect-video max-w-48">
- <ImageTimelineTreatment
- src={firstImageItem.url!}
- alt={firstImageItem.name}
- targetAspectRatio={16 / 9}
- className="rounded-sm border"
- backgroundType={backgroundType}
- backgroundColor={backgroundColor}
- />
- </div>
- </div>
-
- {/* Background Type */}
- <div className="space-y-2">
- <Label htmlFor="bg-type">Background Type</Label>
- <Select
- value={backgroundType}
- onValueChange={(value: BackgroundType) =>
- setBackgroundType(value)
- }
- >
- <SelectTrigger>
- <SelectValue placeholder="Select background type" />
- </SelectTrigger>
- <SelectContent>
- <SelectItem value="blur">Blur</SelectItem>
- <SelectItem value="mirror">Mirror</SelectItem>
- <SelectItem value="color">Solid Color</SelectItem>
- </SelectContent>
- </Select>
- </div>
-
- {/* Background Color - only show for color type */}
- {backgroundType === "color" && (
- <div className="space-y-2">
- <Label htmlFor="bg-color">Background Color</Label>
- <div className="flex gap-2">
- <Input
- id="bg-color"
- type="color"
- value={backgroundColor}
- onChange={(e) => setBackgroundColor(e.target.value)}
- className="w-16 h-10 p-1"
- />
- <Input
- value={backgroundColor}
- onChange={(e) => setBackgroundColor(e.target.value)}
- placeholder="#000000"
- className="flex-1"
- />
- </div>
- </div>
- )}
- </div>
- </div>
-
- <Separator />
- </>
- )}
-
- {/* Video Controls - only show if a video is selected */}
- {firstVideoItem && (
- <>
- <SpeedControl />
- <Separator />
- </>
- )}
-
- {/* Transform */}
- <div className="space-y-4">
- <h3 className="text-sm font-medium">Transform</h3>
- <div className="space-y-2">
- <div className="grid grid-cols-2 gap-2">
- <div className="space-y-1">
- <Label htmlFor="x">X Position</Label>
- <Input id="x" type="number" defaultValue="0" />
- </div>
- <div className="space-y-1">
- <Label htmlFor="y">Y Position</Label>
- <Input id="y" type="number" defaultValue="0" />
- </div>
- </div>
- <div className="space-y-1">
- <Label htmlFor="rotation">Rotation</Label>
- <Slider
- id="rotation"
- max={360}
- step={1}
- defaultValue={[0]}
- className="mt-2"
- />
- </div>
- </div>
- </div>
-
- <Separator />
-
- {/* Effects */}
- <div className="space-y-4">
- <h3 className="text-sm font-medium">Effects</h3>
- <div className="space-y-4">
- <div className="space-y-1">
- <Label htmlFor="opacity">Opacity</Label>
- <Slider
- id="opacity"
- max={100}
- step={1}
- defaultValue={[100]}
- className="mt-2"
- />
- </div>
- <div className="space-y-1">
- <Label htmlFor="blur">Blur</Label>
- <Slider
- id="blur"
- max={20}
- step={0.5}
- defaultValue={[0]}
- className="mt-2"
- />
- </div>
- </div>
- </div>
-
- <Separator />
-
- {/* Timing */}
- <div className="space-y-4">
- <h3 className="text-sm font-medium">Timing</h3>
- <div className="space-y-2">
- <div className="space-y-1">
- <Label htmlFor="duration">Duration (seconds)</Label>
- <Input
- id="duration"
- type="number"
- min="0"
- step="0.1"
- defaultValue="5"
- />
- </div>
- <div className="space-y-1">
- <Label htmlFor="delay">Delay (seconds)</Label>
- <Input
- id="delay"
- type="number"
- min="0"
- step="0.1"
- defaultValue="0"
- />
- </div>
- </div>
+ <div className="space-y-4 p-5">
+ {/* Media Properties */}
+ <div className="flex flex-col gap-3">
+ <PropertyItem label="Name:" value={activeProject?.name || ""} />
+ <PropertyItem label="Aspect ratio:" value={getDisplayName()} />
+ <PropertyItem
+ label="Resolution:"
+ value={`${canvasSize.width} × ${canvasSize.height}`}
+ />
+ <PropertyItem label="Frame rate:" value="30.00fps" />
</div>
</div>
</ScrollArea>
);
}
+
+function PropertyItem({ label, value }: { label: string; value: string }) {
+ return (
+ <div className="flex justify-between">
+ <Label className="text-xs text-muted-foreground">{label}</Label>
+ <span className="text-xs text-right">{value}</span>
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/timeline-element.tsx b/apps/web/src/components/editor/timeline-element.tsx
index 1e1674bf..c1c8b12b 100644
--- a/apps/web/src/components/editor/timeline-element.tsx
+++ b/apps/web/src/components/editor/timeline-element.tsx
@@ -12,6 +12,7 @@ import {
ChevronLeft,
Type,
Copy,
+ RefreshCw,
} from "lucide-react";
import { useMediaStore } from "@/stores/media-store";
import { useTimelineStore } from "@/stores/timeline-store";
@@ -61,6 +62,7 @@ export function TimelineElement({
splitAndKeepRight,
separateAudio,
addElementToTrack,
+ replaceElementMedia,
} = useTimelineStore();
const { currentTime } = usePlaybackStore();
@@ -213,6 +215,37 @@ export function TimelineElement({
removeElementFromTrack(track.id, element.id);
};
+ const handleReplaceClip = () => {
+ if (element.type !== "media") {
+ toast.error("Replace is only available for media clips");
+ return;
+ }
+
+ // Create a file input to select replacement media
+ const input = document.createElement("input");
+ input.type = "file";
+ input.accept = "video/*,audio/*,image/*";
+ input.onchange = async (e) => {
+ const file = (e.target as HTMLInputElement).files?.[0];
+ if (!file) return;
+
+ try {
+ const success = await replaceElementMedia(track.id, element.id, file);
+ if (success) {
+ toast.success("Clip replaced successfully");
+ } else {
+ toast.error("Failed to replace clip");
+ }
+ } catch (error) {
+ toast.error("Failed to replace clip");
+ console.log(
+ JSON.stringify({ error: "Failed to replace clip", details: error })
+ );
+ }
+ };
+ input.click();
+ };
+
const renderElementContent = () => {
if (element.type === "text") {
return (
@@ -350,6 +383,12 @@ export function TimelineElement({
<Copy className="h-4 w-4 mr-2" />
Duplicate {element.type === "text" ? "text" : "clip"}
</ContextMenuItem>
+ {element.type === "media" && (
+ <ContextMenuItem onClick={handleReplaceClip}>
+ <RefreshCw className="h-4 w-4 mr-2" />
+ Replace clip
+ </ContextMenuItem>
+ )}
<ContextMenuSeparator />
<ContextMenuItem
onClick={handleElementDeleteContext}
diff --git a/apps/web/src/hooks/use-aspect-ratio.ts b/apps/web/src/hooks/use-aspect-ratio.ts
new file mode 100644
index 00000000..40de8454
--- /dev/null
+++ b/apps/web/src/hooks/use-aspect-ratio.ts
@@ -0,0 +1,92 @@
+import { useEditorStore } from "@/stores/editor-store";
+import { useMediaStore, getMediaAspectRatio } from "@/stores/media-store";
+import { useTimelineStore } from "@/stores/timeline-store";
+
+export function useAspectRatio() {
+ const { canvasSize, canvasMode, canvasPresets } = useEditorStore();
+ const { mediaItems } = useMediaStore();
+ const { tracks } = useTimelineStore();
+
+ // Find the current preset based on canvas size
+ const currentPreset = canvasPresets.find(
+ (preset) =>
+ preset.width === canvasSize.width && preset.height === canvasSize.height
+ );
+
+ // Get the original aspect ratio from the first video/image in timeline
+ const getOriginalAspectRatio = (): number => {
+ // Find first video or image in timeline
+ for (const track of tracks) {
+ for (const element of track.elements) {
+ if (element.type === "media") {
+ const mediaItem = mediaItems.find(
+ (item) => item.id === element.mediaId
+ );
+ if (
+ mediaItem &&
+ (mediaItem.type === "video" || mediaItem.type === "image")
+ ) {
+ return getMediaAspectRatio(mediaItem);
+ }
+ }
+ }
+ }
+ return 16 / 9; // Default aspect ratio
+ };
+
+ // Get current aspect ratio
+ const getCurrentAspectRatio = (): number => {
+ return canvasSize.width / canvasSize.height;
+ };
+
+ // Format aspect ratio as a readable string
+ const formatAspectRatio = (aspectRatio: number): string => {
+ // Check if it matches a common aspect ratio
+ const ratios = [
+ { ratio: 16 / 9, label: "16:9" },
+ { ratio: 9 / 16, label: "9:16" },
+ { ratio: 1, label: "1:1" },
+ { ratio: 4 / 3, label: "4:3" },
+ { ratio: 3 / 4, label: "3:4" },
+ { ratio: 21 / 9, label: "21:9" },
+ ];
+
+ for (const { ratio, label } of ratios) {
+ if (Math.abs(aspectRatio - ratio) < 0.01) {
+ return label;
+ }
+ }
+
+ // If not a common ratio, format as decimal
+ return aspectRatio.toFixed(2);
+ };
+
+ // Check if current mode is "Original"
+ const isOriginal = canvasMode === "original";
+
+ // Get display name for current aspect ratio
+ const getDisplayName = (): string => {
+ // If explicitly set to original mode, always show "Original"
+ if (canvasMode === "original") {
+ return "Original";
+ }
+
+ if (currentPreset) {
+ return currentPreset.name;
+ }
+
+ return formatAspectRatio(getCurrentAspectRatio());
+ };
+
+ return {
+ currentPreset,
+ canvasMode,
+ isOriginal,
+ getCurrentAspectRatio,
+ getOriginalAspectRatio,
+ formatAspectRatio,
+ getDisplayName,
+ canvasSize,
+ canvasPresets,
+ };
+}
diff --git a/apps/web/src/stores/editor-store.ts b/apps/web/src/stores/editor-store.ts
index 5382c3e2..c24929a2 100644
--- a/apps/web/src/stores/editor-store.ts
+++ b/apps/web/src/stores/editor-store.ts
@@ -1,6 +1,8 @@
import { create } from "zustand";
import { CanvasSize, CanvasPreset } from "@/types/editor";
+type CanvasMode = "preset" | "original" | "custom";
+
interface EditorState {
// Loading states
isInitializing: boolean;
@@ -8,6 +10,7 @@ interface EditorState {
// Canvas/Project settings
canvasSize: CanvasSize;
+ canvasMode: CanvasMode;
canvasPresets: CanvasPreset[];
// Actions
@@ -15,6 +18,7 @@ interface EditorState {
setPanelsReady: (ready: boolean) => void;
initializeApp: () => Promise<void>;
setCanvasSize: (size: CanvasSize) => void;
+ setCanvasSizeToOriginal: (aspectRatio: number) => void;
setCanvasSizeFromAspectRatio: (aspectRatio: number) => void;
}
@@ -65,6 +69,7 @@ export const useEditorStore = create<EditorState>((set, get) => ({
isInitializing: true,
isPanelsReady: false,
canvasSize: { width: 1920, height: 1080 }, // Default 16:9 HD
+ canvasMode: "preset" as CanvasMode,
canvasPresets: DEFAULT_CANVAS_PRESETS,
// Actions
@@ -85,15 +90,16 @@ export const useEditorStore = create<EditorState>((set, get) => ({
},
setCanvasSize: (size) => {
- set({ canvasSize: size });
+ set({ canvasSize: size, canvasMode: "preset" });
+ },
+
+ setCanvasSizeToOriginal: (aspectRatio) => {
+ const newCanvasSize = findBestCanvasPreset(aspectRatio);
+ set({ canvasSize: newCanvasSize, canvasMode: "original" });
},
setCanvasSizeFromAspectRatio: (aspectRatio) => {
const newCanvasSize = findBestCanvasPreset(aspectRatio);
- console.log(
- `Setting canvas size based on aspect ratio ${aspectRatio}:`,
- newCanvasSize
- );
- set({ canvasSize: newCanvasSize });
+ set({ canvasSize: newCanvasSize, canvasMode: "custom" });
},
}));
diff --git a/apps/web/src/stores/timeline-store.ts b/apps/web/src/stores/timeline-store.ts
index 6de24c84..7431e085 100644
--- a/apps/web/src/stores/timeline-store.ts
+++ b/apps/web/src/stores/timeline-store.ts
@@ -117,6 +117,13 @@ interface TimelineStore {
) => void;
separateAudio: (trackId: string, elementId: string) => string | null;
+ // Replace media for an element
+ replaceElementMedia: (
+ trackId: string,
+ elementId: string,
+ newFile: File
+ ) => Promise<boolean>;
+
// Computed values
getTotalDuration: () => number;
@@ -677,6 +684,102 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
return audioElementId;
},
+ // Replace media for an element
+ replaceElementMedia: async (trackId, elementId, newFile) => {
+ const { _tracks } = get();
+ const track = _tracks.find((t) => t.id === trackId);
+ const element = track?.elements.find((c) => c.id === elementId);
+
+ if (!element || element.type !== "media") return false;
+
+ try {
+ const mediaStore = useMediaStore.getState();
+ const projectStore = useProjectStore.getState();
+
+ if (!projectStore.activeProject) return false;
+
+ // Import required media processing functions
+ const {
+ getFileType,
+ getImageDimensions,
+ generateVideoThumbnail,
+ getMediaDuration,
+ } = await import("./media-store");
+
+ const fileType = getFileType(newFile);
+ if (!fileType) return false;
+
+ // Process the new media file
+ let mediaData: any = {
+ name: newFile.name,
+ type: fileType,
+ file: newFile,
+ url: URL.createObjectURL(newFile),
+ };
+
+ // Get media-specific metadata
+ if (fileType === "image") {
+ const { width, height } = await getImageDimensions(newFile);
+ mediaData.width = width;
+ mediaData.height = height;
+ } else if (fileType === "video") {
+ const [duration, { thumbnailUrl, width, height }] = await Promise.all(
+ [getMediaDuration(newFile), generateVideoThumbnail(newFile)]
+ );
+ mediaData.duration = duration;
+ mediaData.thumbnailUrl = thumbnailUrl;
+ mediaData.width = width;
+ mediaData.height = height;
+ } else if (fileType === "audio") {
+ mediaData.duration = await getMediaDuration(newFile);
+ }
+
+ // Add new media item to store
+ await mediaStore.addMediaItem(projectStore.activeProject.id, mediaData);
+
+ // Find the newly created media item
+ const newMediaItem = mediaStore.mediaItems.find(
+ (item) => item.file === newFile
+ );
+
+ if (!newMediaItem) return false;
+
+ get().pushHistory();
+
+ // Update the timeline element to reference the new media
+ updateTracksAndSave(
+ _tracks.map((track) =>
+ track.id === trackId
+ ? {
+ ...track,
+ elements: track.elements.map((c) =>
+ c.id === elementId
+ ? {
+ ...c,
+ mediaId: newMediaItem.id,
+ name: newMediaItem.name,
+ // Update duration if the new media has a different duration
+ duration: newMediaItem.duration || c.duration,
+ }
+ : c
+ ),
+ }
+ : track
+ )
+ );
+
+ return true;
+ } catch (error) {
+ console.log(
+ JSON.stringify({
+ error: "Failed to replace element media",
+ details: error,
+ })
+ );
+ return false;
+ }
+ },
+
getTotalDuration: () => {
const { _tracks } = get();
if (_tracks.length === 0) return 0;