diff --git a/apps/web/src/components/editor/media-panel/views/captions.tsx b/apps/web/src/components/editor/media-panel/views/captions.tsx index 6e18781c..393481a5 100644 --- a/apps/web/src/components/editor/media-panel/views/captions.tsx +++ b/apps/web/src/components/editor/media-panel/views/captions.tsx @@ -6,6 +6,7 @@ import { useState, useRef, useEffect } from "react"; import { extractTimelineAudio } from "@/lib/mediabunny-utils"; import { encryptWithRandomKey, arrayBufferToBase64 } from "@/lib/zk-encryption"; import { useTimelineStore } from "@/stores/timeline-store"; +import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants"; import { Loader2, Shield, Trash2, Upload } from "lucide-react"; import { Dialog, @@ -162,24 +163,13 @@ export function Captions() { // Add all caption elements to the same track shortCaptions.forEach((caption, index) => { addElementToTrack(captionTrackId, { - type: "text", + ...DEFAULT_TEXT_ELEMENT, name: `Caption ${index + 1}`, content: caption.text, duration: caption.duration, startTime: caption.startTime, - trimStart: 0, - trimEnd: 0, - fontSize: 65, - fontFamily: "Arial", - color: "#ffffff", - textAlign: "center", - fontWeight: "bold", - fontStyle: "normal", - textDecoration: "none", - x: 0, - y: 0, - rotation: 0, - opacity: 1, + fontSize: 65, // Larger for captions + fontWeight: "bold", // Bold for captions } as TextElement); }); diff --git a/apps/web/src/components/editor/media-panel/views/media.tsx b/apps/web/src/components/editor/media-panel/views/media.tsx index c84dde70..07a44a7c 100644 --- a/apps/web/src/components/editor/media-panel/views/media.tsx +++ b/apps/web/src/components/editor/media-panel/views/media.tsx @@ -2,7 +2,8 @@ import { useDragDrop } from "@/hooks/use-drag-drop"; import { processMediaFiles } from "@/lib/media-processing"; -import { useMediaStore, type MediaItem } from "@/stores/media-store"; +import { useMediaStore } from "@/stores/media-store"; +import { MediaFile } from "@/types/media"; import { ArrowDown01, CloudUpload, @@ -13,7 +14,7 @@ import { Music, Video, } from "lucide-react"; -import { useRef, useState, useMemo, useEffect } from "react"; +import { useRef, useState, useMemo } from "react"; import { useHighlightScroll } from "@/hooks/use-highlight-scroll"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; @@ -47,7 +48,7 @@ function MediaItemWithContextMenu({ children, onRemove, }: { - item: MediaItem; + item: MediaFile; children: React.ReactNode; onRemove: (e: React.MouseEvent, id: string) => Promise; }) { @@ -68,14 +69,12 @@ function MediaItemWithContextMenu({ } export function MediaView() { - const { mediaItems, addMediaItem, removeMediaItem } = useMediaStore(); + const { mediaFiles, addMediaFile, removeMediaFile } = useMediaStore(); const { activeProject } = useProjectStore(); const { mediaViewMode, setMediaViewMode } = usePanelStore(); const fileInputRef = useRef(null); const [isProcessing, setIsProcessing] = useState(false); const [progress, setProgress] = useState(0); - const [searchQuery, setSearchQuery] = useState(""); - const [mediaFilter, setMediaFilter] = useState("all"); const [sortBy, setSortBy] = useState<"name" | "type" | "duration" | "size">( "name" ); @@ -96,16 +95,13 @@ export function MediaView() { setIsProcessing(true); setProgress(0); try { - // Process files (extract metadata, generate thumbnails, etc.) const processedItems = await processMediaFiles(files, (p) => setProgress(p) ); - // Add each processed media item to the store for (const item of processedItems) { - await addMediaItem(activeProject.id, item); + await addMediaFile(activeProject.id, item); } } catch (error) { - // Show error toast if processing fails console.error("Error processing files:", error); toast.error("Failed to process files"); } finally { @@ -122,13 +118,11 @@ export function MediaView() { const handleFileSelect = () => fileInputRef.current?.click(); // Open file picker const handleFileChange = (e: React.ChangeEvent) => { - // When files are selected via file picker, process them if (e.target.files) processFiles(e.target.files); e.target.value = ""; // Reset input }; const handleRemove = async (e: React.MouseEvent, id: string) => { - // Remove a media item from the store e.stopPropagation(); if (!activeProject) { @@ -136,8 +130,7 @@ export function MediaView() { return; } - // Media store now handles cascade deletion automatically - await removeMediaItem(activeProject.id, id); + await removeMediaFile(activeProject.id, id); }; const formatDuration = (duration: number) => { @@ -147,28 +140,15 @@ export function MediaView() { return `${min}:${sec.toString().padStart(2, "0")}`; }; - const [filteredMediaItems, setFilteredMediaItems] = useState(mediaItems); - - useEffect(() => { - let filtered = mediaItems.filter((item) => { + const filteredMediaItems = useMemo(() => { + let filtered = mediaFiles.filter((item) => { if (item.ephemeral) return false; - if (mediaFilter && mediaFilter !== "all" && item.type !== mediaFilter) { - return false; - } - - if ( - searchQuery && - !item.name.toLowerCase().includes(searchQuery.toLowerCase()) - ) { - return false; - } - return true; }); filtered.sort((a, b) => { - let valueA: any; - let valueB: any; + let valueA: string | number; + let valueB: string | number; switch (sortBy) { case "name": @@ -196,8 +176,8 @@ export function MediaView() { return 0; }); - setFilteredMediaItems(filtered); - }, [mediaItems, mediaFilter, searchQuery, sortBy, sortOrder]); + return filtered; + }, [mediaFiles, sortBy, sortOrder]); const previewComponents = useMemo(() => { const previews = new Map(); @@ -276,7 +256,7 @@ export function MediaView() { return previews; }, [filteredMediaItems]); - const renderPreview = (item: MediaItem) => previewComponents.get(item.id); + const renderPreview = (item: MediaFile) => previewComponents.get(item.id); return ( <> @@ -481,12 +461,14 @@ function GridView({ highlightedId, registerElement, }: { - filteredMediaItems: MediaItem[]; - renderPreview: (item: MediaItem) => React.ReactNode; + filteredMediaItems: MediaFile[]; + renderPreview: (item: MediaFile) => React.ReactNode; handleRemove: (e: React.MouseEvent, id: string) => Promise; highlightedId: string | null; registerElement: (id: string, element: HTMLElement | null) => void; }) { + const { addElementAtTime } = useTimelineStore(); + return (
- useTimelineStore.getState().addMediaAtTime(item, currentTime) + addElementAtTime(item, currentTime) } rounded={false} variant="card" @@ -527,12 +509,14 @@ function ListView({ highlightedId, registerElement, }: { - filteredMediaItems: MediaItem[]; - renderPreview: (item: MediaItem) => React.ReactNode; + filteredMediaItems: MediaFile[]; + renderPreview: (item: MediaFile) => React.ReactNode; handleRemove: (e: React.MouseEvent, id: string) => Promise; highlightedId: string | null; registerElement: (id: string, element: HTMLElement | null) => void; }) { + const { addElementAtTime } = useTimelineStore(); + return (
{filteredMediaItems.map((item) => ( @@ -548,7 +532,7 @@ function ListView({ }} showPlusOnDrag={false} onAddToTimeline={(currentTime) => - useTimelineStore.getState().addMediaAtTime(item, currentTime) + addElementAtTime(item, currentTime) } variant="compact" isHighlighted={highlightedId === item.id} diff --git a/apps/web/src/components/editor/media-panel/views/stickers.tsx b/apps/web/src/components/editor/media-panel/views/stickers.tsx index 7e05e889..41afe285 100644 --- a/apps/web/src/components/editor/media-panel/views/stickers.tsx +++ b/apps/web/src/components/editor/media-panel/views/stickers.tsx @@ -35,10 +35,10 @@ import { ICONIFY_HOSTS, POPULAR_COLLECTIONS, } from "@/lib/iconify-api"; -import { cn, generateUUID } from "@/lib/utils"; +import { cn } from "@/lib/utils"; import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; import Image from "next/image"; -import type { MediaItem } from "@/stores/media-store"; +import type { MediaFile } from "@/types/media"; import { DraggableMediaItem } from "@/components/ui/draggable-item"; import { InputWithBack } from "@/components/ui/input-with-back"; import { StickerCategory } from "@/stores/stickers-store"; @@ -186,9 +186,9 @@ function EmptyView({ message }: { message: string }) { function StickersContentView({ category }: { category: StickerCategory }) { const { activeProject } = useProjectStore(); - const { addMediaAtTime } = useTimelineStore(); + const { addElementAtTime } = useTimelineStore(); const { currentTime } = usePlaybackStore(); - const { addMediaItem } = useMediaStore(); + const { addMediaFile } = useMediaStore(); const { searchQuery, selectedCollection, @@ -287,7 +287,7 @@ function StickersContentView({ category }: { category: StickerCategory }) { throw new Error("Failed to download sticker"); } - const mediaItem: Omit = { + const mediaItem: Omit = { name: iconName.replace(":", "-"), type: "image", file, @@ -298,16 +298,16 @@ function StickersContentView({ category }: { category: StickerCategory }) { ephemeral: false, }; - await addMediaItem(activeProject.id, mediaItem); + await addMediaFile(activeProject.id, mediaItem); const added = useMediaStore .getState() - .mediaItems.find( + .mediaFiles.find( (m) => m.url === mediaItem.url && m.name === mediaItem.name ); if (!added) throw new Error("Sticker not in media store"); - addMediaAtTime(added, currentTime); + addElementAtTime(added, currentTime); toast.success(`Added "${iconName}" to timeline`); } catch (error) { @@ -640,8 +640,8 @@ function StickerItem({ name={displayName} preview={preview} dragData={{ - type: "sticker", - iconName: iconName, + id: "sticker-placeholder", + type: "image", name: displayName, }} onAddToTimeline={() => onAdd(iconName)} diff --git a/apps/web/src/components/editor/media-panel/views/text.tsx b/apps/web/src/components/editor/media-panel/views/text.tsx index 49eacbc7..5d522c84 100644 --- a/apps/web/src/components/editor/media-panel/views/text.tsx +++ b/apps/web/src/components/editor/media-panel/views/text.tsx @@ -1,31 +1,7 @@ import { DraggableMediaItem } from "@/components/ui/draggable-item"; import { BaseView } from "./base-view"; -import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; import { useTimelineStore } from "@/stores/timeline-store"; -import { type TextElement } from "@/types/timeline"; - -const textData: TextElement = { - id: "default-text", - type: "text", - name: "Default text", - content: "Default text", - fontSize: 48, - fontFamily: "Arial", - color: "#ffffff", - backgroundColor: "transparent", - textAlign: "center" as const, - fontWeight: "normal" as const, - fontStyle: "normal" as const, - textDecoration: "none" as const, - x: 0, - y: 0, - rotation: 0, - opacity: 1, - duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION, - startTime: 0, - trimStart: 0, - trimEnd: 0, -}; +import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants"; export function TextView() { return ( @@ -38,14 +14,20 @@ export function TextView() {
} dragData={{ - id: textData.id, - type: textData.type, - name: textData.name, - content: textData.content, + id: "temp-text-id", + type: DEFAULT_TEXT_ELEMENT.type, + name: DEFAULT_TEXT_ELEMENT.name, + content: DEFAULT_TEXT_ELEMENT.content, }} aspectRatio={1} onAddToTimeline={(currentTime) => - useTimelineStore.getState().addTextAtTime(textData, currentTime) + useTimelineStore.getState().addElementAtTime( + { + ...DEFAULT_TEXT_ELEMENT, + id: "temp-text-id", + }, + currentTime + ) } showLabel={false} /> diff --git a/apps/web/src/components/editor/preview-panel.tsx b/apps/web/src/components/editor/preview-panel.tsx index 99c4edef..d988e7fb 100644 --- a/apps/web/src/components/editor/preview-panel.tsx +++ b/apps/web/src/components/editor/preview-panel.tsx @@ -2,13 +2,13 @@ import { useTimelineStore } from "@/stores/timeline-store"; import { TimelineElement, TimelineTrack } from "@/types/timeline"; -import { useMediaStore, type MediaItem } from "@/stores/media-store"; +import { useMediaStore } from "@/stores/media-store"; +import { MediaFile } from "@/types/media"; import { usePlaybackStore } from "@/stores/playback-store"; import { useEditorStore } from "@/stores/editor-store"; import { Button } from "@/components/ui/button"; import { Play, Pause, Expand, SkipBack, SkipForward } from "lucide-react"; import { useState, useRef, useEffect, useCallback } from "react"; -import { VideoSampleSink } from "mediabunny"; import { renderTimelineFrame } from "@/lib/timeline-renderer"; import { cn } from "@/lib/utils"; import { formatTimeCode } from "@/lib/time"; @@ -33,12 +33,12 @@ import { PLATFORM_LAYOUTS, type PlatformLayout } from "@/stores/editor-store"; interface ActiveElement { element: TimelineElement; track: TimelineTrack; - mediaItem: MediaItem | null; + mediaItem: MediaFile | null; } export function PreviewPanel() { const { tracks, getTotalDuration, updateTextElement } = useTimelineStore(); - const { mediaItems } = useMediaStore(); + const { mediaFiles } = useMediaStore(); const { currentTime, toggle, setCurrentTime } = usePlaybackStore(); const { isPlaying, volume, muted } = usePlaybackStore(); const { activeProject } = useProjectStore(); @@ -264,7 +264,7 @@ export function PreviewPanel() { mediaItem = element.mediaId === "test" ? null - : mediaItems.find((item) => item.id === element.mediaId) || + : mediaFiles.find((item) => item.id === element.mediaId) || null; } activeElements.push({ element, track, mediaItem }); @@ -334,7 +334,7 @@ export function PreviewPanel() { const gain = audioGainRef.current!; const tracksSnapshot = useTimelineStore.getState().tracks; - const mediaList = useMediaStore.getState().mediaItems; + const mediaList = mediaFiles; const idToMedia = new Map(mediaList.map((m) => [m.id, m] as const)); const playbackNow = usePlaybackStore.getState().currentTime; @@ -447,7 +447,7 @@ export function PreviewPanel() { } playingSourcesRef.current.clear(); }; - }, [isPlaying, volume, muted, mediaItems]); + }, [isPlaying, volume, muted, mediaFiles]); // Canvas: draw current frame for visible elements using offscreen compositing useEffect(() => { @@ -533,13 +533,11 @@ export function PreviewPanel() { canvasWidth: displayWidth, canvasHeight: displayHeight, tracks, - mediaItems, + mediaFiles, backgroundColor: activeProject?.backgroundType === "blur" ? "transparent" : activeProject?.backgroundColor || "#000000", - useCache: false, - fps: activeProject?.fps || DEFAULT_FPS, }); // Blit offscreen to visible canvas diff --git a/apps/web/src/components/editor/properties-panel/index.tsx b/apps/web/src/components/editor/properties-panel/index.tsx index 6c79327e..150f9512 100644 --- a/apps/web/src/components/editor/properties-panel/index.tsx +++ b/apps/web/src/components/editor/properties-panel/index.tsx @@ -10,7 +10,7 @@ import { SquareSlashIcon } from "lucide-react"; export function PropertiesPanel() { const { selectedElements, tracks } = useTimelineStore(); - const { mediaItems } = useMediaStore(); + const { mediaFiles } = useMediaStore(); return ( <> @@ -28,11 +28,11 @@ export function PropertiesPanel() { ); } if (element?.type === "media") { - const mediaItem = mediaItems.find( - (item) => item.id === element.mediaId + const mediaFile = mediaFiles.find( + (file) => file.id === element.mediaId ); - if (mediaItem?.type === "audio") { + if (mediaFile?.type === "audio") { return ; } diff --git a/apps/web/src/components/editor/timeline/index.tsx b/apps/web/src/components/editor/timeline/index.tsx index 284384eb..49769989 100644 --- a/apps/web/src/components/editor/timeline/index.tsx +++ b/apps/web/src/components/editor/timeline/index.tsx @@ -82,13 +82,11 @@ export function Timeline() { toggleTrackMute, dragState, } = useTimelineStore(); - const { mediaItems, addMediaItem } = useMediaStore(); + const { mediaFiles, addMediaFile } = useMediaStore(); const { activeProject } = useProjectStore(); - const { currentTime, duration, seek, setDuration, isPlaying, toggle } = - usePlaybackStore(); + const { currentTime, duration, seek, setDuration } = usePlaybackStore(); const [isDragOver, setIsDragOver] = useState(false); - const [isProcessing, setIsProcessing] = useState(false); - const [progress, setProgress] = useState(0); + const { addElementToNewTrack } = useTimelineStore(); const dragCounterRef = useRef(0); const timelineRef = useRef(null); const rulerRef = useRef(null); @@ -456,10 +454,10 @@ export function Timeline() { if (dragData.type === "text") { // Always create new text track to avoid overlaps - useTimelineStore.getState().addTextToNewTrack(dragData); + addElementToNewTrack(dragData); } else { // Handle media items - const mediaItem = mediaItems.find( + const mediaItem = mediaFiles.find( (item: any) => item.id === dragData.id ); if (!mediaItem) { @@ -467,7 +465,7 @@ export function Timeline() { return; } - useTimelineStore.getState().addMediaToNewTrack(mediaItem); + addElementToNewTrack(mediaItem); } } catch (error) { console.error("Error parsing dropped item data:", error); @@ -480,17 +478,12 @@ export function Timeline() { return; } - setIsProcessing(true); - setProgress(0); try { - const processedItems = await processMediaFiles( - e.dataTransfer.files, - (p) => setProgress(p) - ); + const processedItems = await processMediaFiles(e.dataTransfer.files); for (const processedItem of processedItems) { - await addMediaItem(activeProject.id, processedItem); - const currentMediaItems = useMediaStore.getState().mediaItems; - const addedItem = currentMediaItems.find( + await addMediaFile(activeProject.id, processedItem); + const currentMediaFiles = mediaFiles; + const addedItem = currentMediaFiles.find( (item) => item.name === processedItem.name && item.url === processedItem.url ); @@ -516,9 +509,6 @@ export function Timeline() { // Show error if file processing fails console.error("Error processing external files:", error); toast.error("Failed to process dropped files"); - } finally { - setIsProcessing(false); - setProgress(0); } } }; diff --git a/apps/web/src/components/editor/timeline/timeline-element.tsx b/apps/web/src/components/editor/timeline/timeline-element.tsx index a1a33837..300c4c7b 100644 --- a/apps/web/src/components/editor/timeline/timeline-element.tsx +++ b/apps/web/src/components/editor/timeline/timeline-element.tsx @@ -40,10 +40,8 @@ export function TimelineElement({ onElementMouseDown, onElementClick, }: TimelineElementProps) { - const { mediaItems } = useMediaStore(); + const { mediaFiles } = useMediaStore(); const { - updateElementTrim, - updateElementDuration, removeElementFromTrack, removeElementFromTrackWithRipple, dragState, @@ -58,7 +56,7 @@ export function TimelineElement({ const mediaItem = element.type === "media" - ? mediaItems.find((item) => item.id === element.mediaId) + ? mediaFiles.find((file) => file.id === element.mediaId) : null; const hasAudio = mediaItem?.type === "audio" || mediaItem?.type === "video"; @@ -67,8 +65,6 @@ export function TimelineElement({ element, track, zoomLevel, - onUpdateTrim: updateElementTrim, - onUpdateDuration: updateElementDuration, }); const { requestRevealMedia } = useMediaPanelStore.getState(); @@ -190,7 +186,7 @@ export function TimelineElement({ } // Render media element -> - const mediaItem = mediaItems.find((item) => item.id === element.mediaId); + const mediaItem = mediaFiles.find((file) => file.id === element.mediaId); if (!mediaItem) { return ( diff --git a/apps/web/src/components/editor/timeline/timeline-track.tsx b/apps/web/src/components/editor/timeline/timeline-track.tsx index 5a037710..ad3ca9b7 100644 --- a/apps/web/src/components/editor/timeline/timeline-track.tsx +++ b/apps/web/src/components/editor/timeline/timeline-track.tsx @@ -12,6 +12,7 @@ import { canElementGoOnTrack, } from "@/types/timeline"; import { usePlaybackStore } from "@/stores/playback-store"; +import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants"; import type { TimelineElement as TimelineElementType, DragData, @@ -33,7 +34,7 @@ export function TimelineTrackContent({ zoomLevel: number; onSnapPointChange?: (snapPoint: SnapPoint | null) => void; }) { - const { mediaItems } = useMediaStore(); + const { mediaFiles } = useMediaStore(); const { tracks, addTrack, @@ -537,7 +538,7 @@ export function TimelineTrackContent({ }); } else { // Media elements - const mediaItem = mediaItems.find( + const mediaItem = mediaFiles.find( (item) => item.id === dragData.id ); if (mediaItem) { @@ -890,29 +891,14 @@ export function TimelineTrackContent({ } addElementToTrack(targetTrackId, { - type: "text", - name: dragData.name || "Text", - content: dragData.content || "Default Text", - duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION, + ...DEFAULT_TEXT_ELEMENT, + name: dragData.name || DEFAULT_TEXT_ELEMENT.name, + content: dragData.content || DEFAULT_TEXT_ELEMENT.content, startTime: textSnappedTime, - trimStart: 0, - trimEnd: 0, - fontSize: 48, - fontFamily: "Arial", - color: "#ffffff", - backgroundColor: "transparent", - textAlign: "center", - fontWeight: "normal", - fontStyle: "normal", - textDecoration: "none", - x: 0, - y: 0, - rotation: 0, - opacity: 1, }); } else { // Handle media items - const mediaItem = mediaItems.find((item) => item.id === dragData.id); + const mediaItem = mediaFiles.find((item) => item.id === dragData.id); if (!mediaItem) { toast.error("Media item not found"); @@ -1054,7 +1040,7 @@ export function TimelineTrackContent({ } else if (hasFiles) { // External file drops const { activeProject } = useProjectStore.getState(); - const { addMediaItem } = useMediaStore.getState(); + const { addMediaFile } = useMediaStore.getState(); const { addElementToTrack } = useTimelineStore.getState(); if (!activeProject) { @@ -1066,9 +1052,9 @@ export function TimelineTrackContent({ processMediaFiles(e.dataTransfer.files) .then(async (processedItems) => { for (const processedItem of processedItems) { - await addMediaItem(activeProject.id, processedItem); - const currentMediaItems = useMediaStore.getState().mediaItems; - const addedItem = currentMediaItems.find( + await addMediaFile(activeProject.id, processedItem); + const currentMediaFiles = mediaFiles; + const addedItem = currentMediaFiles.find( (item) => item.name === processedItem.name && item.url === processedItem.url diff --git a/apps/web/src/components/ui/draggable-item.tsx b/apps/web/src/components/ui/draggable-item.tsx index 9e29c83b..c238b33c 100644 --- a/apps/web/src/components/ui/draggable-item.tsx +++ b/apps/web/src/components/ui/draggable-item.tsx @@ -12,11 +12,12 @@ import { createPortal } from "react-dom"; import { Plus } from "lucide-react"; import { cn } from "@/lib/utils"; import { usePlaybackStore } from "@/stores/playback-store"; +import { DragData } from "@/types/timeline"; export interface DraggableMediaItemProps { name: string; preview: ReactNode; - dragData: Record; + dragData: DragData; onDragStart?: (e: React.DragEvent) => void; onAddToTimeline?: (currentTime: number) => void; aspectRatio?: number; diff --git a/apps/web/src/constants/text-constants.ts b/apps/web/src/constants/text-constants.ts new file mode 100644 index 00000000..e3fcce38 --- /dev/null +++ b/apps/web/src/constants/text-constants.ts @@ -0,0 +1,27 @@ +import { TextElement } from "@/types/timeline"; +import { TIMELINE_CONSTANTS } from "./timeline-constants"; + +export const DEFAULT_TEXT_ELEMENT: Omit< + TextElement, + "id" +> = { + type: "text", + name: "Text", + content: "Default Text", + fontSize: 48, + fontFamily: "Arial", + color: "#ffffff", + backgroundColor: "transparent", + textAlign: "center", + fontWeight: "normal", + fontStyle: "normal", + textDecoration: "none", + x: 0, + y: 0, + rotation: 0, + opacity: 1, + duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION, + startTime: 0, + trimStart: 0, + trimEnd: 0, +}; diff --git a/apps/web/src/hooks/use-aspect-ratio.ts b/apps/web/src/hooks/use-aspect-ratio.ts index c3fe20c6..b721b74e 100644 --- a/apps/web/src/hooks/use-aspect-ratio.ts +++ b/apps/web/src/hooks/use-aspect-ratio.ts @@ -6,7 +6,7 @@ import { DEFAULT_CANVAS_SIZE, useProjectStore } from "@/stores/project-store"; export function useAspectRatio() { const { canvasPresets } = useEditorStore(); const { activeProject } = useProjectStore(); - const { mediaItems } = useMediaStore(); + const { mediaFiles } = useMediaStore(); const { tracks } = useTimelineStore(); const canvasSize = activeProject?.canvasSize || DEFAULT_CANVAS_SIZE; @@ -24,14 +24,14 @@ export function useAspectRatio() { for (const track of tracks) { for (const element of track.elements) { if (element.type === "media") { - const mediaItem = mediaItems.find( - (item) => item.id === element.mediaId + const mediaFile = mediaFiles.find( + (file) => file.id === element.mediaId ); if ( - mediaItem && - (mediaItem.type === "video" || mediaItem.type === "image") + mediaFile && + (mediaFile.type === "video" || mediaFile.type === "image") ) { - return getMediaAspectRatio(mediaItem); + return getMediaAspectRatio(mediaFile); } } } diff --git a/apps/web/src/hooks/use-timeline-element-resize.ts b/apps/web/src/hooks/use-timeline-element-resize.ts index 2875185c..4812459a 100644 --- a/apps/web/src/hooks/use-timeline-element-resize.ts +++ b/apps/web/src/hooks/use-timeline-element-resize.ts @@ -9,28 +9,15 @@ interface UseTimelineElementResizeProps { element: TimelineElement; track: TimelineTrack; zoomLevel: number; - onUpdateTrim: ( - trackId: string, - elementId: string, - trimStart: number, - trimEnd: number - ) => void; - onUpdateDuration: ( - trackId: string, - elementId: string, - duration: number - ) => void; } export function useTimelineElementResize({ element, track, zoomLevel, - onUpdateTrim, - onUpdateDuration, }: UseTimelineElementResizeProps) { const [resizing, setResizing] = useState(null); - const { mediaItems } = useMediaStore(); + const { mediaFiles } = useMediaStore(); const { updateElementStartTime, updateElementTrim, @@ -88,11 +75,11 @@ export function useTimelineElementResize({ // Media elements - check the media type if (element.type === "media") { - const mediaItem = mediaItems.find((item) => item.id === element.mediaId); - if (!mediaItem) return false; + const mediaFile = mediaFiles.find((file) => file.id === element.mediaId); + if (!mediaFile) return false; // Images can be extended (static content) - if (mediaItem.type === "image") { + if (mediaFile.type === "image") { return true; } diff --git a/apps/web/src/lib/media-processing.ts b/apps/web/src/lib/media-processing.ts index 15c71626..7a31466f 100644 --- a/apps/web/src/lib/media-processing.ts +++ b/apps/web/src/lib/media-processing.ts @@ -1,14 +1,13 @@ import { toast } from "sonner"; import { getFileType, - generateVideoThumbnail, getMediaDuration, getImageDimensions, - type MediaItem, } from "@/stores/media-store"; +import { MediaFile } from "@/types/media"; import { generateThumbnail, getVideoInfo } from "./mediabunny-utils"; -export interface ProcessedMediaItem extends Omit {} +export interface ProcessedMediaItem extends Omit {} export async function processMediaFiles( files: FileList | File[], diff --git a/apps/web/src/lib/mediabunny-utils.ts b/apps/web/src/lib/mediabunny-utils.ts index 28e6b97f..fcb3da0f 100644 --- a/apps/web/src/lib/mediabunny-utils.ts +++ b/apps/web/src/lib/mediabunny-utils.ts @@ -151,14 +151,14 @@ export const extractTimelineAudio = async ( for (const element of track.elements) { if (element.type === "media") { - const mediaItem = mediaStore.mediaItems.find( + const mediaFile = mediaStore.mediaFiles.find( (m) => m.id === element.mediaId ); - if (!mediaItem) continue; + if (!mediaFile) continue; - if (mediaItem.type === "video" || mediaItem.type === "audio") { + if (mediaFile.type === "video" || mediaFile.type === "audio") { audioElements.push({ - file: mediaItem.file, + file: mediaFile.file, startTime: element.startTime, duration: element.duration, trimStart: element.trimStart, diff --git a/apps/web/src/lib/storage/storage-service.ts b/apps/web/src/lib/storage/storage-service.ts index b3376a5f..3aadf883 100644 --- a/apps/web/src/lib/storage/storage-service.ts +++ b/apps/web/src/lib/storage/storage-service.ts @@ -1,5 +1,5 @@ import { TProject } from "@/types/project"; -import { MediaItem } from "@/stores/media-store"; +import { MediaFile } from "@/types/media"; import { IndexedDBAdapter } from "./indexeddb-adapter"; import { OPFSAdapter } from "./opfs-adapter"; import { @@ -124,8 +124,8 @@ class StorageService { await this.projectsAdapter.remove(id); } - // Media operations - now project-specific - async saveMediaItem(projectId: string, mediaItem: MediaItem): Promise { + // Media operations + async saveMediaFile(projectId: string, mediaItem: MediaFile): Promise { const { mediaMetadataAdapter, mediaFilesAdapter } = this.getProjectMediaAdapters(projectId); @@ -148,10 +148,10 @@ class StorageService { await mediaMetadataAdapter.set(mediaItem.id, metadata); } - async loadMediaItem( + async loadMediaFile( projectId: string, id: string - ): Promise { + ): Promise { const { mediaMetadataAdapter, mediaFilesAdapter } = this.getProjectMediaAdapters(projectId); @@ -192,14 +192,14 @@ class StorageService { }; } - async loadAllMediaItems(projectId: string): Promise { + async loadAllMediaFiles(projectId: string): Promise { const { mediaMetadataAdapter } = this.getProjectMediaAdapters(projectId); const mediaIds = await mediaMetadataAdapter.list(); - const mediaItems: MediaItem[] = []; + const mediaItems: MediaFile[] = []; for (const id of mediaIds) { - const item = await this.loadMediaItem(projectId, id); + const item = await this.loadMediaFile(projectId, id); if (item) { mediaItems.push(item); } @@ -208,7 +208,7 @@ class StorageService { return mediaItems; } - async deleteMediaItem(projectId: string, id: string): Promise { + async deleteMediaFile(projectId: string, id: string): Promise { const { mediaMetadataAdapter, mediaFilesAdapter } = this.getProjectMediaAdapters(projectId); diff --git a/apps/web/src/lib/timeline-renderer.ts b/apps/web/src/lib/timeline-renderer.ts index 1c1eafcd..06825896 100644 --- a/apps/web/src/lib/timeline-renderer.ts +++ b/apps/web/src/lib/timeline-renderer.ts @@ -1,179 +1,159 @@ -import { Input, ALL_FORMATS, BlobSource, VideoSampleSink } from "mediabunny"; -import type { TimelineTrack } from "@/types/timeline"; -import type { MediaItem } from "@/stores/media-store"; - -export interface RenderContext { - ctx: CanvasRenderingContext2D; - time: number; - canvasWidth: number; - canvasHeight: number; - tracks: TimelineTrack[]; - mediaItems: MediaItem[]; - backgroundColor?: string; - useCache?: boolean; - cache?: Map< - string, - Map< - number, - { - draw: ( - c: CanvasRenderingContext2D, - x: number, - y: number, - w: number, - h: number - ) => void; - } - > - >; - fps: number; -} - -export async function renderTimelineFrame({ - ctx, - time, - canvasWidth, - canvasHeight, - tracks, - mediaItems, - backgroundColor, - useCache = true, - cache, - fps, -}: RenderContext): Promise { - // Background - ctx.clearRect(0, 0, canvasWidth, canvasHeight); - if (backgroundColor && backgroundColor !== "transparent") { - ctx.fillStyle = backgroundColor; - ctx.fillRect(0, 0, canvasWidth, canvasHeight); - } - - const scaleX = 1; - const scaleY = 1; - const idToMedia = new Map(mediaItems.map((m) => [m.id, m] as const)); - const active: Array<{ - track: TimelineTrack; - element: TimelineTrack["elements"][number]; - mediaItem: MediaItem | null; - }> = []; - - for (let t = tracks.length - 1; t >= 0; t -= 1) { - const track = tracks[t]; - for (const element of track.elements) { - if ((element as any).hidden) continue; - const elementStart = element.startTime; - const elementEnd = - element.startTime + - (element.duration - element.trimStart - element.trimEnd); - if (time >= elementStart && time < elementEnd) { - let mediaItem: MediaItem | null = null; - if (element.type === "media") { - mediaItem = - element.mediaId === "test" - ? null - : idToMedia.get(element.mediaId) || null; - } - active.push({ track, element, mediaItem }); - } - } - } - - for (const { element, mediaItem } of active) { - if (element.type === "media" && mediaItem) { - if (mediaItem.type === "video") { - const input = new Input({ - source: new BlobSource(mediaItem.file), - formats: ALL_FORMATS, - }); - const track = await input.getPrimaryVideoTrack(); - if (!track) continue; - const decodable = await track.canDecode(); - if (!decodable) continue; - const sink = new VideoSampleSink(track); - - const localTime = time - element.startTime + element.trimStart; - const sample = await sink.getSample(localTime); - if (!sample) continue; - - const mediaW = Math.max(1, mediaItem.width || canvasWidth); - const mediaH = Math.max(1, mediaItem.height || canvasHeight); - const containScale = Math.min( - canvasWidth / mediaW, - canvasHeight / mediaH - ); - const drawW = mediaW * containScale; - const drawH = mediaH * containScale; - const drawX = (canvasWidth - drawW) / 2; - const drawY = (canvasHeight - drawH) / 2; - sample.draw(ctx, drawX, drawY, drawW, drawH); - } - if (mediaItem.type === "image") { - const img = new Image(); - await new Promise((resolve, reject) => { - img.onload = () => resolve(); - img.onerror = () => reject(new Error("Image load failed")); - img.src = mediaItem.url || URL.createObjectURL(mediaItem.file); - }); - const mediaW = Math.max( - 1, - mediaItem.width || img.naturalWidth || canvasWidth - ); - const mediaH = Math.max( - 1, - mediaItem.height || img.naturalHeight || canvasHeight - ); - const containScale = Math.min( - canvasWidth / mediaW, - canvasHeight / mediaH - ); - const drawW = mediaW * containScale; - const drawH = mediaH * containScale; - const drawX = (canvasWidth - drawW) / 2; - const drawY = (canvasHeight - drawH) / 2; - ctx.drawImage(img, drawX, drawY, drawW, drawH); - } - } - if (element.type === "text") { - const posX = canvasWidth / 2 + (element as any).x * scaleX; - const posY = canvasHeight / 2 + (element as any).y * scaleY; - ctx.save(); - ctx.translate(posX, posY); - ctx.rotate(((element as any).rotation * Math.PI) / 180); - ctx.globalAlpha = Math.max(0, Math.min(1, (element as any).opacity)); - const px = (element as any).fontSize * scaleX; - const weight = (element as any).fontWeight === "bold" ? "bold " : ""; - const style = (element as any).fontStyle === "italic" ? "italic " : ""; - ctx.font = `${style}${weight}${px}px ${(element as any).fontFamily}`; - ctx.fillStyle = (element as any).color; - ctx.textAlign = (element as any).textAlign; - ctx.textBaseline = "middle"; - const metrics = ctx.measureText((element as any).content); - const ascent = - (metrics as unknown as { actualBoundingBoxAscent?: number }) - .actualBoundingBoxAscent ?? px * 0.8; - const descent = - (metrics as unknown as { actualBoundingBoxDescent?: number }) - .actualBoundingBoxDescent ?? px * 0.2; - const textW = metrics.width; - const textH = ascent + descent; - const padX = 8 * scaleX; - const padY = 4 * scaleX; - if ((element as any).backgroundColor) { - ctx.save(); - ctx.fillStyle = (element as any).backgroundColor; - let bgLeft = -textW / 2; - if (ctx.textAlign === "left") bgLeft = 0; - if (ctx.textAlign === "right") bgLeft = -textW; - ctx.fillRect( - bgLeft - padX, - -textH / 2 - padY, - textW + padX * 2, - textH + padY * 2 - ); - ctx.restore(); - } - ctx.fillText((element as any).content, 0, 0); - ctx.restore(); - } - } -} +import { Input, ALL_FORMATS, BlobSource, VideoSampleSink } from "mediabunny"; +import type { TimelineTrack } from "@/types/timeline"; +import type { MediaFile } from "@/types/media"; + +export interface RenderContext { + ctx: CanvasRenderingContext2D; + time: number; + canvasWidth: number; + canvasHeight: number; + tracks: TimelineTrack[]; + mediaFiles: MediaFile[]; + backgroundColor?: string; +} + +export async function renderTimelineFrame({ + ctx, + time, + canvasWidth, + canvasHeight, + tracks, + mediaFiles, + backgroundColor, +}: RenderContext): Promise { + // Background + ctx.clearRect(0, 0, canvasWidth, canvasHeight); + if (backgroundColor && backgroundColor !== "transparent") { + ctx.fillStyle = backgroundColor; + ctx.fillRect(0, 0, canvasWidth, canvasHeight); + } + + const scaleX = 1; + const scaleY = 1; + const idToMedia = new Map(mediaFiles.map((m) => [m.id, m] as const)); + const active: Array<{ + track: TimelineTrack; + element: TimelineTrack["elements"][number]; + mediaItem: MediaFile | null; + }> = []; + + for (let t = tracks.length - 1; t >= 0; t -= 1) { + const track = tracks[t]; + for (const element of track.elements) { + if ((element as any).hidden) continue; + const elementStart = element.startTime; + const elementEnd = + element.startTime + + (element.duration - element.trimStart - element.trimEnd); + if (time >= elementStart && time < elementEnd) { + let mediaItem: MediaFile | null = null; + if (element.type === "media") { + mediaItem = + element.mediaId === "test" + ? null + : idToMedia.get(element.mediaId) || null; + } + active.push({ track, element, mediaItem }); + } + } + } + + for (const { element, mediaItem } of active) { + if (element.type === "media" && mediaItem) { + if (mediaItem.type === "video") { + const input = new Input({ + source: new BlobSource(mediaItem.file), + formats: ALL_FORMATS, + }); + const track = await input.getPrimaryVideoTrack(); + if (!track) continue; + const decodable = await track.canDecode(); + if (!decodable) continue; + const sink = new VideoSampleSink(track); + + const localTime = time - element.startTime + element.trimStart; + const sample = await sink.getSample(localTime); + if (!sample) continue; + + const mediaW = Math.max(1, mediaItem.width || canvasWidth); + const mediaH = Math.max(1, mediaItem.height || canvasHeight); + const containScale = Math.min( + canvasWidth / mediaW, + canvasHeight / mediaH + ); + const drawW = mediaW * containScale; + const drawH = mediaH * containScale; + const drawX = (canvasWidth - drawW) / 2; + const drawY = (canvasHeight - drawH) / 2; + sample.draw(ctx, drawX, drawY, drawW, drawH); + } + if (mediaItem.type === "image") { + const img = new Image(); + await new Promise((resolve, reject) => { + img.onload = () => resolve(); + img.onerror = () => reject(new Error("Image load failed")); + img.src = mediaItem.url || URL.createObjectURL(mediaItem.file); + }); + const mediaW = Math.max( + 1, + mediaItem.width || img.naturalWidth || canvasWidth + ); + const mediaH = Math.max( + 1, + mediaItem.height || img.naturalHeight || canvasHeight + ); + const containScale = Math.min( + canvasWidth / mediaW, + canvasHeight / mediaH + ); + const drawW = mediaW * containScale; + const drawH = mediaH * containScale; + const drawX = (canvasWidth - drawW) / 2; + const drawY = (canvasHeight - drawH) / 2; + ctx.drawImage(img, drawX, drawY, drawW, drawH); + } + } + if (element.type === "text") { + const posX = canvasWidth / 2 + (element as any).x * scaleX; + const posY = canvasHeight / 2 + (element as any).y * scaleY; + ctx.save(); + ctx.translate(posX, posY); + ctx.rotate(((element as any).rotation * Math.PI) / 180); + ctx.globalAlpha = Math.max(0, Math.min(1, (element as any).opacity)); + const px = (element as any).fontSize * scaleX; + const weight = (element as any).fontWeight === "bold" ? "bold " : ""; + const style = (element as any).fontStyle === "italic" ? "italic " : ""; + ctx.font = `${style}${weight}${px}px ${(element as any).fontFamily}`; + ctx.fillStyle = (element as any).color; + ctx.textAlign = (element as any).textAlign; + ctx.textBaseline = "middle"; + const metrics = ctx.measureText((element as any).content); + const ascent = + (metrics as unknown as { actualBoundingBoxAscent?: number }) + .actualBoundingBoxAscent ?? px * 0.8; + const descent = + (metrics as unknown as { actualBoundingBoxDescent?: number }) + .actualBoundingBoxDescent ?? px * 0.2; + const textW = metrics.width; + const textH = ascent + descent; + const padX = 8 * scaleX; + const padY = 4 * scaleX; + if ((element as any).backgroundColor) { + ctx.save(); + ctx.fillStyle = (element as any).backgroundColor; + let bgLeft = -textW / 2; + if (ctx.textAlign === "left") bgLeft = 0; + if (ctx.textAlign === "right") bgLeft = -textW; + ctx.fillRect( + bgLeft - padX, + -textH / 2 - padY, + textW + padX * 2, + textH + padY * 2 + ); + ctx.restore(); + } + ctx.fillText((element as any).content, 0, 0); + ctx.restore(); + } + } +} diff --git a/apps/web/src/stores/media-store.ts b/apps/web/src/stores/media-store.ts index 95676d75..2b7cb62e 100644 --- a/apps/web/src/stores/media-store.ts +++ b/apps/web/src/stores/media-store.ts @@ -2,44 +2,21 @@ import { create } from "zustand"; import { storageService } from "@/lib/storage/storage-service"; import { useTimelineStore } from "./timeline-store"; import { generateUUID } from "@/lib/utils"; - -export type MediaType = "image" | "video" | "audio"; - -export interface MediaItem { - id: string; - name: string; - type: MediaType; - file: File; - url?: string; // Object URL for preview - thumbnailUrl?: string; // For video thumbnails - duration?: number; // For video/audio duration - width?: number; // For video/image width - height?: number; // For video/image height - fps?: number; // For video frame rate - // Ephemeral items are used by timeline directly and should not appear in the media library or be persisted - ephemeral?: boolean; - // Text-specific properties - content?: string; // Text content - fontSize?: number; // Font size - fontFamily?: string; // Font family - color?: string; // Text color - backgroundColor?: string; // Background color - textAlign?: "left" | "center" | "right"; // Text alignment -} +import { MediaType, MediaFile } from "@/types/media"; interface MediaStore { - mediaItems: MediaItem[]; + mediaFiles: MediaFile[]; isLoading: boolean; - // Actions - now require projectId - addMediaItem: ( + // Actions + addMediaFile: ( projectId: string, - item: Omit + file: Omit ) => Promise; - removeMediaItem: (projectId: string, id: string) => Promise; + removeMediaFile: (projectId: string, id: string) => Promise; loadProjectMedia: (projectId: string) => Promise; clearProjectMedia: (projectId: string) => Promise; - clearAllMedia: () => void; // Clear local state only + clearAllMedia: () => void; } // Helper function to determine file type @@ -150,8 +127,7 @@ export const getMediaDuration = (file: File): Promise => { }); }; -// Helper to get aspect ratio from MediaItem -export const getMediaAspectRatio = (item: MediaItem): number => { +export const getMediaAspectRatio = (item: MediaFile): number => { if (item.width && item.height) { return item.width / item.height; } @@ -159,35 +135,35 @@ export const getMediaAspectRatio = (item: MediaItem): number => { }; export const useMediaStore = create((set, get) => ({ - mediaItems: [], + mediaFiles: [], isLoading: false, - addMediaItem: async (projectId, item) => { - const newItem: MediaItem = { - ...item, + addMediaFile: async (projectId, file) => { + const newItem: MediaFile = { + ...file, id: generateUUID(), }; // Add to local state immediately for UI responsiveness set((state) => ({ - mediaItems: [...state.mediaItems, newItem], + mediaFiles: [...state.mediaFiles, newItem], })); // Save to persistent storage in background try { - await storageService.saveMediaItem(projectId, newItem); + await storageService.saveMediaFile(projectId, newItem); } catch (error) { console.error("Failed to save media item:", error); // Remove from local state if save failed set((state) => ({ - mediaItems: state.mediaItems.filter((media) => media.id !== newItem.id), + mediaFiles: state.mediaFiles.filter((media) => media.id !== newItem.id), })); } }, - removeMediaItem: async (projectId: string, id: string) => { + removeMediaFile: async (projectId: string, id: string) => { const state = get(); - const item = state.mediaItems.find((media) => media.id === id); + const item = state.mediaFiles.find((media) => media.id === id); // Cleanup object URLs to prevent memory leaks if (item?.url) { @@ -199,7 +175,7 @@ export const useMediaStore = create((set, get) => ({ // 1) Remove from local state immediately set((state) => ({ - mediaItems: state.mediaItems.filter((media) => media.id !== id), + mediaFiles: state.mediaFiles.filter((media) => media.id !== id), })); // 2) Cascade into the timeline: remove any elements using this media ID @@ -238,7 +214,7 @@ export const useMediaStore = create((set, get) => ({ // 3) Remove from persistent storage try { - await storageService.deleteMediaItem(projectId, id); + await storageService.deleteMediaFile(projectId, id); } catch (error) { console.error("Failed to delete media item:", error); } @@ -248,7 +224,7 @@ export const useMediaStore = create((set, get) => ({ set({ isLoading: true }); try { - const mediaItems = await storageService.loadAllMediaItems(projectId); + const mediaItems = await storageService.loadAllMediaFiles(projectId); // Regenerate thumbnails for video items const updatedMediaItems = await Promise.all( @@ -275,7 +251,7 @@ export const useMediaStore = create((set, get) => ({ }) ); - set({ mediaItems: updatedMediaItems }); + set({ mediaFiles: updatedMediaItems }); } catch (error) { console.error("Failed to load media items:", error); } finally { @@ -287,7 +263,7 @@ export const useMediaStore = create((set, get) => ({ const state = get(); // Cleanup all object URLs - state.mediaItems.forEach((item) => { + state.mediaFiles.forEach((item) => { if (item.url) { URL.revokeObjectURL(item.url); } @@ -297,13 +273,13 @@ export const useMediaStore = create((set, get) => ({ }); // Clear local state - set({ mediaItems: [] }); + set({ mediaFiles: [] }); // Clear persistent storage try { - const mediaIds = state.mediaItems.map((item) => item.id); + const mediaIds = state.mediaFiles.map((item) => item.id); await Promise.all( - mediaIds.map((id) => storageService.deleteMediaItem(projectId, id)) + mediaIds.map((id) => storageService.deleteMediaFile(projectId, id)) ); } catch (error) { console.error("Failed to clear media items from storage:", error); @@ -314,7 +290,7 @@ export const useMediaStore = create((set, get) => ({ const state = get(); // Cleanup all object URLs - state.mediaItems.forEach((item) => { + state.mediaFiles.forEach((item) => { if (item.url) { URL.revokeObjectURL(item.url); } @@ -324,6 +300,6 @@ export const useMediaStore = create((set, get) => ({ }); // Clear local state - set({ mediaItems: [] }); + set({ mediaFiles: [] }); }, })); diff --git a/apps/web/src/stores/sounds-store.ts b/apps/web/src/stores/sounds-store.ts index 215253fb..6dffa198 100644 --- a/apps/web/src/stores/sounds-store.ts +++ b/apps/web/src/stores/sounds-store.ts @@ -247,7 +247,7 @@ export const useSoundsStore = create((set, get) => ({ type: "audio/mpeg", }); - await useMediaStore.getState().addMediaItem(activeProject.id, { + await useMediaStore.getState().addMediaFile(activeProject.id, { name: sound.name, type: "audio", file, @@ -257,12 +257,12 @@ export const useSoundsStore = create((set, get) => ({ const mediaItem = useMediaStore .getState() - .mediaItems.find((item) => item.file === file); + .mediaFiles.find((item) => item.file === file); if (!mediaItem) throw new Error("Failed to create media item"); const success = useTimelineStore .getState() - .addMediaAtTime(mediaItem, usePlaybackStore.getState().currentTime); + .addElementAtTime(mediaItem, usePlaybackStore.getState().currentTime); if (success) { return true; diff --git a/apps/web/src/stores/timeline-store.ts b/apps/web/src/stores/timeline-store.ts index 3eada6de..884db23e 100644 --- a/apps/web/src/stores/timeline-store.ts +++ b/apps/web/src/stores/timeline-store.ts @@ -10,17 +10,15 @@ import { ensureMainTrack, validateElementTrackCompatibility, } from "@/types/timeline"; -import { - useMediaStore, - getMediaAspectRatio, - type MediaItem, -} from "./media-store"; +import { useMediaStore, getMediaAspectRatio } from "./media-store"; +import { MediaFile } from "@/types/media"; import { findBestCanvasPreset } from "@/lib/editor-utils"; import { storageService } from "@/lib/storage/storage-service"; import { useProjectStore } from "./project-store"; import { generateUUID } from "@/lib/utils"; import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; import { checkElementOverlaps, resolveElementOverlaps } from "@/lib/timeline"; +import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants"; // Helper function to manage element naming with suffixes const getElementNameWithSuffix = ( @@ -207,10 +205,11 @@ interface TimelineStore { excludeElementId?: string ) => boolean; findOrCreateTrack: (trackType: TrackType) => string; - addMediaAtTime: (item: MediaItem, currentTime?: number) => boolean; - addTextAtTime: (item: TextElement, currentTime?: number) => boolean; - addMediaToNewTrack: (item: MediaItem) => boolean; - addTextToNewTrack: (item: TextElement | DragData) => boolean; + addElementAtTime: ( + item: MediaFile | TextElement, + currentTime?: number + ) => boolean; + addElementToNewTrack: (item: MediaFile | TextElement | DragData) => boolean; } export const useTimelineStore = create((set, get) => { @@ -502,7 +501,7 @@ export const useTimelineStore = create((set, get) => { if (isFirstElement && newElement.type === "media") { const mediaStore = useMediaStore.getState(); - const mediaItem = mediaStore.mediaItems.find( + const mediaItem = mediaStore.mediaFiles.find( (item) => item.id === newElement.mediaId ); @@ -1144,7 +1143,7 @@ export const useTimelineStore = create((set, get) => { } try { - await mediaStore.addMediaItem( + await mediaStore.addMediaFile( projectStore.activeProject.id, mediaData ); @@ -1155,7 +1154,7 @@ export const useTimelineStore = create((set, get) => { }; } - const newMediaItem = mediaStore.mediaItems.find( + const newMediaItem = mediaStore.mediaFiles.find( (item) => item.file === newFile ); @@ -1219,7 +1218,7 @@ export const useTimelineStore = create((set, get) => { getProjectThumbnail: async (projectId) => { try { const tracks = await storageService.loadTimeline(projectId); - const mediaItems = await storageService.loadAllMediaItems(projectId); + const mediaItems = await storageService.loadAllMediaFiles(projectId); if (!tracks || !mediaItems.length) return null; @@ -1230,20 +1229,20 @@ export const useTimelineStore = create((set, get) => { if (!firstMediaElement) return null; - const mediaItem = mediaItems.find( + const mediaFile = mediaItems.find( (item) => item.id === firstMediaElement.mediaId ); - if (!mediaItem) return null; + if (!mediaFile) return null; - if (mediaItem.type === "video" && mediaItem.file) { + if (mediaFile.type === "video" && mediaFile.file) { const { generateVideoThumbnail } = await import( "@/stores/media-store" ); - const { thumbnailUrl } = await generateVideoThumbnail(mediaItem.file); + const { thumbnailUrl } = await generateVideoThumbnail(mediaFile.file); return thumbnailUrl; } - if (mediaItem.type === "image" && mediaItem.url) { - return mediaItem.url; + if (mediaFile.type === "image" && mediaFile.url) { + return mediaFile.url; } return null; @@ -1401,30 +1400,24 @@ export const useTimelineStore = create((set, get) => { return get().addTrack(trackType); }, - addMediaAtTime: (item, currentTime = 0) => { - const trackType = item.type === "audio" ? "audio" : "media"; - const duration = - item.duration || TIMELINE_CONSTANTS.DEFAULT_IMAGE_DURATION; - - const tracks = get()._tracks.filter((t) => t.type === trackType); - - let targetTrackId = null; - for (const track of tracks) { - if (!get().checkElementOverlap(track.id, currentTime, duration)) { - targetTrackId = track.id; - break; - } - } - - if (!targetTrackId) { - targetTrackId = get().addTrack(trackType); + addElementAtTime: (item: MediaFile | TextElement, currentTime = 0) => { + if (item.type === "text") { + const targetTrackId = get().insertTrackAt("text", 0); + get().addElementToTrack( + targetTrackId, + buildTextElement(item, currentTime) + ); + return true; } + const media = item as MediaFile; + const trackType = media.type === "audio" ? "audio" : "media"; + const targetTrackId = get().insertTrackAt(trackType, 0); get().addElementToTrack(targetTrackId, { type: "media", - mediaId: item.id, - name: item.name, - duration, + mediaId: media.id, + name: media.name, + duration: media.duration || TIMELINE_CONSTANTS.DEFAULT_IMAGE_DURATION, startTime: currentTime, trimStart: 0, trimEnd: 0, @@ -1433,42 +1426,24 @@ export const useTimelineStore = create((set, get) => { return true; }, - addTextAtTime: (item, currentTime = 0) => { - const targetTrackId = get().insertTrackAt("text", 0); - - get().addElementToTrack(targetTrackId, { - type: "text", - name: item.name || "Text", - content: item.content || "Default Text", - duration: item.duration || TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION, - startTime: currentTime, - trimStart: 0, - trimEnd: 0, - fontSize: item.fontSize || 48, - fontFamily: item.fontFamily || "Arial", - color: item.color || "#ffffff", - backgroundColor: item.backgroundColor || "transparent", - textAlign: item.textAlign || "center", - fontWeight: item.fontWeight || "normal", - fontStyle: item.fontStyle || "normal", - textDecoration: item.textDecoration || "none", - x: item.x || 0, - y: item.y || 0, - rotation: item.rotation || 0, - opacity: item.opacity !== undefined ? item.opacity : 1, - }); - return true; - }, - - addMediaToNewTrack: (item) => { - const trackType = item.type === "audio" ? "audio" : "media"; - const targetTrackId = get().findOrCreateTrack(trackType); + addElementToNewTrack: (item) => { + if (item.type === "text") { + const targetTrackId = get().insertTrackAt("text", 0); + get().addElementToTrack( + targetTrackId, + buildTextElement(item as TextElement | DragData, 0) + ); + return true; + } + const media = item as MediaFile; + const trackType = media.type === "audio" ? "audio" : "media"; + const targetTrackId = get().insertTrackAt(trackType, 0); get().addElementToTrack(targetTrackId, { type: "media", - mediaId: item.id, - name: item.name, - duration: item.duration || TIMELINE_CONSTANTS.DEFAULT_IMAGE_DURATION, + mediaId: media.id, + name: media.name, + duration: media.duration || TIMELINE_CONSTANTS.DEFAULT_IMAGE_DURATION, startTime: 0, trimStart: 0, trimEnd: 0, @@ -1476,41 +1451,41 @@ export const useTimelineStore = create((set, get) => { }); return true; }, - - addTextToNewTrack: (item) => { - const targetTrackId = get().insertTrackAt("text", 0); - - get().addElementToTrack(targetTrackId, { - type: "text", - name: item.name || "Text", - content: - ("content" in item ? item.content : "Default Text") || "Default Text", - duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION, - startTime: 0, - trimStart: 0, - trimEnd: 0, - fontSize: ("fontSize" in item ? item.fontSize : 48) || 48, - fontFamily: - ("fontFamily" in item ? item.fontFamily : "Arial") || "Arial", - color: ("color" in item ? item.color : "#ffffff") || "#ffffff", - backgroundColor: - ("backgroundColor" in item ? item.backgroundColor : "transparent") || - "transparent", - textAlign: - ("textAlign" in item ? item.textAlign : "center") || "center", - fontWeight: - ("fontWeight" in item ? item.fontWeight : "normal") || "normal", - fontStyle: - ("fontStyle" in item ? item.fontStyle : "normal") || "normal", - textDecoration: - ("textDecoration" in item ? item.textDecoration : "none") || "none", - x: ("x" in item ? item.x : 0) || 0, - y: ("y" in item ? item.y : 0) || 0, - rotation: ("rotation" in item ? item.rotation : 0) || 0, - opacity: - "opacity" in item && item.opacity !== undefined ? item.opacity : 1, - }); - return true; - }, }; }); + +function buildTextElement( + raw: TextElement | DragData, + startTime: number +): CreateTimelineElement { + const t = raw as Partial; + + return { + type: "text", + name: t.name ?? DEFAULT_TEXT_ELEMENT.name, + content: t.content ?? DEFAULT_TEXT_ELEMENT.content, + duration: t.duration ?? TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION, + startTime, + trimStart: 0, + trimEnd: 0, + fontSize: + typeof t.fontSize === "number" + ? t.fontSize + : DEFAULT_TEXT_ELEMENT.fontSize, + fontFamily: t.fontFamily ?? DEFAULT_TEXT_ELEMENT.fontFamily, + color: t.color ?? DEFAULT_TEXT_ELEMENT.color, + backgroundColor: t.backgroundColor ?? DEFAULT_TEXT_ELEMENT.backgroundColor, + textAlign: t.textAlign ?? DEFAULT_TEXT_ELEMENT.textAlign, + fontWeight: t.fontWeight ?? DEFAULT_TEXT_ELEMENT.fontWeight, + fontStyle: t.fontStyle ?? DEFAULT_TEXT_ELEMENT.fontStyle, + textDecoration: t.textDecoration ?? DEFAULT_TEXT_ELEMENT.textDecoration, + x: typeof t.x === "number" ? t.x : DEFAULT_TEXT_ELEMENT.x, + y: typeof t.y === "number" ? t.y : DEFAULT_TEXT_ELEMENT.y, + rotation: + typeof t.rotation === "number" + ? t.rotation + : DEFAULT_TEXT_ELEMENT.rotation, + opacity: + typeof t.opacity === "number" ? t.opacity : DEFAULT_TEXT_ELEMENT.opacity, + }; +} diff --git a/apps/web/src/types/media.ts b/apps/web/src/types/media.ts new file mode 100644 index 00000000..6e94fcff --- /dev/null +++ b/apps/web/src/types/media.ts @@ -0,0 +1,17 @@ +export type MediaType = "image" | "video" | "audio"; + +// What's stored in media library +export interface MediaFile { + id: string; + name: string; + type: MediaType; + file: File; + url?: string; // Object URL for preview + thumbnailUrl?: string; // For video thumbnails + duration?: number; // For video/audio duration + width?: number; // For video/image width + height?: number; // For video/image height + fps?: number; // For video frame rate + // Ephemeral items are used by timeline directly and should not appear in the media library or be persisted + ephemeral?: boolean; +} diff --git a/apps/web/src/types/timeline.ts b/apps/web/src/types/timeline.ts index cf946453..61ba6af0 100644 --- a/apps/web/src/types/timeline.ts +++ b/apps/web/src/types/timeline.ts @@ -1,4 +1,4 @@ -import { MediaType } from "@/stores/media-store"; +import { MediaType } from "@/types/media"; import { generateUUID } from "@/lib/utils"; export type TrackType = "media" | "text" | "audio";