diff options
| author | Maze Winther <mazewinther@gmail.com> | 2025-07-09 21:03:56 +0200 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2025-07-09 21:03:56 +0200 |
| commit | c02f276303e00f4d8c66d8d1c804c4af9c719afb (patch) | |
| tree | aac3a38fd5f673cc30a9bbe1aef9eb197b430c9e /apps/web/src/components/editor | |
| parent | dd35c91f395eacf37f96dff8c9cec2340c9bb2b1 (diff) | |
| download | opencut-c02f276303e00f4d8c66d8d1c804c4af9c719afb.tar.gz | |
refactor: fresh properties panel
Diffstat (limited to 'apps/web/src/components/editor')
| -rw-r--r-- | apps/web/src/components/editor/preview-panel.tsx | 54 | ||||
| -rw-r--r-- | apps/web/src/components/editor/properties-panel.tsx | 229 | ||||
| -rw-r--r-- | apps/web/src/components/editor/timeline-element.tsx | 39 |
3 files changed, 73 insertions, 249 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} |
