diff --git a/apps/web/src/components/editor/preview-panel.tsx b/apps/web/src/components/editor/preview-panel.tsx index 89ffa34..834fac5 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 (
- {currentPreset?.name || "Ratio"} + {getDisplayName()} diff --git a/apps/web/src/components/editor/properties-panel.tsx b/apps/web/src/components/editor/properties-panel.tsx index 0de7898..a51f6f2 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("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 ( -
- {/* Image Treatment - only show if an image is selected */} - {firstImageItem && ( - <> -
-

Image Treatment

-
- {/* Preview */} -
- -
- -
-
- - {/* Background Type */} -
- - -
- - {/* Background Color - only show for color type */} - {backgroundType === "color" && ( -
- -
- setBackgroundColor(e.target.value)} - className="w-16 h-10 p-1" - /> - setBackgroundColor(e.target.value)} - placeholder="#000000" - className="flex-1" - /> -
-
- )} -
-
- - - - )} - - {/* Video Controls - only show if a video is selected */} - {firstVideoItem && ( - <> - - - - )} - - {/* Transform */} -
-

Transform

-
-
-
- - -
-
- - -
-
-
- - -
-
-
- - - - {/* Effects */} -
-

Effects

-
-
- - -
-
- - -
-
-
- - - - {/* Timing */} -
-

Timing

-
-
- - -
-
- - -
-
+
+ {/* Media Properties */} +
+ + + +
); } + +function PropertyItem({ label, value }: { label: string; value: string }) { + return ( +
+ + {value} +
+ ); +} diff --git a/apps/web/src/components/editor/timeline-element.tsx b/apps/web/src/components/editor/timeline-element.tsx index 1e1674b..c1c8b12 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({ Duplicate {element.type === "text" ? "text" : "clip"} + {element.type === "media" && ( + + + Replace clip + + )} + 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 5382c3e..c24929a 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; setCanvasSize: (size: CanvasSize) => void; + setCanvasSizeToOriginal: (aspectRatio: number) => void; setCanvasSizeFromAspectRatio: (aspectRatio: number) => void; } @@ -65,6 +69,7 @@ export const useEditorStore = create((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((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 6de24c8..7431e08 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; + // Computed values getTotalDuration: () => number; @@ -677,6 +684,102 @@ export const useTimelineStore = create((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;