refactor: type-safety & separate concerns

This commit is contained in:
Maze Winther
2025-08-26 15:04:25 +02:00
parent 2f7cd11ca1
commit b459b46802
22 changed files with 430 additions and 542 deletions
@@ -6,6 +6,7 @@ import { useState, useRef, useEffect } from "react";
import { extractTimelineAudio } from "@/lib/mediabunny-utils"; import { extractTimelineAudio } from "@/lib/mediabunny-utils";
import { encryptWithRandomKey, arrayBufferToBase64 } from "@/lib/zk-encryption"; import { encryptWithRandomKey, arrayBufferToBase64 } from "@/lib/zk-encryption";
import { useTimelineStore } from "@/stores/timeline-store"; import { useTimelineStore } from "@/stores/timeline-store";
import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants";
import { Loader2, Shield, Trash2, Upload } from "lucide-react"; import { Loader2, Shield, Trash2, Upload } from "lucide-react";
import { import {
Dialog, Dialog,
@@ -162,24 +163,13 @@ export function Captions() {
// Add all caption elements to the same track // Add all caption elements to the same track
shortCaptions.forEach((caption, index) => { shortCaptions.forEach((caption, index) => {
addElementToTrack(captionTrackId, { addElementToTrack(captionTrackId, {
type: "text", ...DEFAULT_TEXT_ELEMENT,
name: `Caption ${index + 1}`, name: `Caption ${index + 1}`,
content: caption.text, content: caption.text,
duration: caption.duration, duration: caption.duration,
startTime: caption.startTime, startTime: caption.startTime,
trimStart: 0, fontSize: 65, // Larger for captions
trimEnd: 0, fontWeight: "bold", // Bold for captions
fontSize: 65,
fontFamily: "Arial",
color: "#ffffff",
textAlign: "center",
fontWeight: "bold",
fontStyle: "normal",
textDecoration: "none",
x: 0,
y: 0,
rotation: 0,
opacity: 1,
} as TextElement); } as TextElement);
}); });
@@ -2,7 +2,8 @@
import { useDragDrop } from "@/hooks/use-drag-drop"; import { useDragDrop } from "@/hooks/use-drag-drop";
import { processMediaFiles } from "@/lib/media-processing"; 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 { import {
ArrowDown01, ArrowDown01,
CloudUpload, CloudUpload,
@@ -13,7 +14,7 @@ import {
Music, Music,
Video, Video,
} from "lucide-react"; } from "lucide-react";
import { useRef, useState, useMemo, useEffect } from "react"; import { useRef, useState, useMemo } from "react";
import { useHighlightScroll } from "@/hooks/use-highlight-scroll"; import { useHighlightScroll } from "@/hooks/use-highlight-scroll";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -47,7 +48,7 @@ function MediaItemWithContextMenu({
children, children,
onRemove, onRemove,
}: { }: {
item: MediaItem; item: MediaFile;
children: React.ReactNode; children: React.ReactNode;
onRemove: (e: React.MouseEvent, id: string) => Promise<void>; onRemove: (e: React.MouseEvent, id: string) => Promise<void>;
}) { }) {
@@ -68,14 +69,12 @@ function MediaItemWithContextMenu({
} }
export function MediaView() { export function MediaView() {
const { mediaItems, addMediaItem, removeMediaItem } = useMediaStore(); const { mediaFiles, addMediaFile, removeMediaFile } = useMediaStore();
const { activeProject } = useProjectStore(); const { activeProject } = useProjectStore();
const { mediaViewMode, setMediaViewMode } = usePanelStore(); const { mediaViewMode, setMediaViewMode } = usePanelStore();
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const [isProcessing, setIsProcessing] = useState(false); const [isProcessing, setIsProcessing] = useState(false);
const [progress, setProgress] = useState(0); const [progress, setProgress] = useState(0);
const [searchQuery, setSearchQuery] = useState("");
const [mediaFilter, setMediaFilter] = useState("all");
const [sortBy, setSortBy] = useState<"name" | "type" | "duration" | "size">( const [sortBy, setSortBy] = useState<"name" | "type" | "duration" | "size">(
"name" "name"
); );
@@ -96,16 +95,13 @@ export function MediaView() {
setIsProcessing(true); setIsProcessing(true);
setProgress(0); setProgress(0);
try { try {
// Process files (extract metadata, generate thumbnails, etc.)
const processedItems = await processMediaFiles(files, (p) => const processedItems = await processMediaFiles(files, (p) =>
setProgress(p) setProgress(p)
); );
// Add each processed media item to the store
for (const item of processedItems) { for (const item of processedItems) {
await addMediaItem(activeProject.id, item); await addMediaFile(activeProject.id, item);
} }
} catch (error) { } catch (error) {
// Show error toast if processing fails
console.error("Error processing files:", error); console.error("Error processing files:", error);
toast.error("Failed to process files"); toast.error("Failed to process files");
} finally { } finally {
@@ -122,13 +118,11 @@ export function MediaView() {
const handleFileSelect = () => fileInputRef.current?.click(); // Open file picker const handleFileSelect = () => fileInputRef.current?.click(); // Open file picker
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
// When files are selected via file picker, process them
if (e.target.files) processFiles(e.target.files); if (e.target.files) processFiles(e.target.files);
e.target.value = ""; // Reset input e.target.value = ""; // Reset input
}; };
const handleRemove = async (e: React.MouseEvent, id: string) => { const handleRemove = async (e: React.MouseEvent, id: string) => {
// Remove a media item from the store
e.stopPropagation(); e.stopPropagation();
if (!activeProject) { if (!activeProject) {
@@ -136,8 +130,7 @@ export function MediaView() {
return; return;
} }
// Media store now handles cascade deletion automatically await removeMediaFile(activeProject.id, id);
await removeMediaItem(activeProject.id, id);
}; };
const formatDuration = (duration: number) => { const formatDuration = (duration: number) => {
@@ -147,28 +140,15 @@ export function MediaView() {
return `${min}:${sec.toString().padStart(2, "0")}`; return `${min}:${sec.toString().padStart(2, "0")}`;
}; };
const [filteredMediaItems, setFilteredMediaItems] = useState(mediaItems); const filteredMediaItems = useMemo(() => {
let filtered = mediaFiles.filter((item) => {
useEffect(() => {
let filtered = mediaItems.filter((item) => {
if (item.ephemeral) return false; 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; return true;
}); });
filtered.sort((a, b) => { filtered.sort((a, b) => {
let valueA: any; let valueA: string | number;
let valueB: any; let valueB: string | number;
switch (sortBy) { switch (sortBy) {
case "name": case "name":
@@ -196,8 +176,8 @@ export function MediaView() {
return 0; return 0;
}); });
setFilteredMediaItems(filtered); return filtered;
}, [mediaItems, mediaFilter, searchQuery, sortBy, sortOrder]); }, [mediaFiles, sortBy, sortOrder]);
const previewComponents = useMemo(() => { const previewComponents = useMemo(() => {
const previews = new Map<string, React.ReactNode>(); const previews = new Map<string, React.ReactNode>();
@@ -276,7 +256,7 @@ export function MediaView() {
return previews; return previews;
}, [filteredMediaItems]); }, [filteredMediaItems]);
const renderPreview = (item: MediaItem) => previewComponents.get(item.id); const renderPreview = (item: MediaFile) => previewComponents.get(item.id);
return ( return (
<> <>
@@ -481,12 +461,14 @@ function GridView({
highlightedId, highlightedId,
registerElement, registerElement,
}: { }: {
filteredMediaItems: MediaItem[]; filteredMediaItems: MediaFile[];
renderPreview: (item: MediaItem) => React.ReactNode; renderPreview: (item: MediaFile) => React.ReactNode;
handleRemove: (e: React.MouseEvent, id: string) => Promise<void>; handleRemove: (e: React.MouseEvent, id: string) => Promise<void>;
highlightedId: string | null; highlightedId: string | null;
registerElement: (id: string, element: HTMLElement | null) => void; registerElement: (id: string, element: HTMLElement | null) => void;
}) { }) {
const { addElementAtTime } = useTimelineStore();
return ( return (
<div <div
className="grid gap-2" className="grid gap-2"
@@ -507,7 +489,7 @@ function GridView({
}} }}
showPlusOnDrag={false} showPlusOnDrag={false}
onAddToTimeline={(currentTime) => onAddToTimeline={(currentTime) =>
useTimelineStore.getState().addMediaAtTime(item, currentTime) addElementAtTime(item, currentTime)
} }
rounded={false} rounded={false}
variant="card" variant="card"
@@ -527,12 +509,14 @@ function ListView({
highlightedId, highlightedId,
registerElement, registerElement,
}: { }: {
filteredMediaItems: MediaItem[]; filteredMediaItems: MediaFile[];
renderPreview: (item: MediaItem) => React.ReactNode; renderPreview: (item: MediaFile) => React.ReactNode;
handleRemove: (e: React.MouseEvent, id: string) => Promise<void>; handleRemove: (e: React.MouseEvent, id: string) => Promise<void>;
highlightedId: string | null; highlightedId: string | null;
registerElement: (id: string, element: HTMLElement | null) => void; registerElement: (id: string, element: HTMLElement | null) => void;
}) { }) {
const { addElementAtTime } = useTimelineStore();
return ( return (
<div className="space-y-1"> <div className="space-y-1">
{filteredMediaItems.map((item) => ( {filteredMediaItems.map((item) => (
@@ -548,7 +532,7 @@ function ListView({
}} }}
showPlusOnDrag={false} showPlusOnDrag={false}
onAddToTimeline={(currentTime) => onAddToTimeline={(currentTime) =>
useTimelineStore.getState().addMediaAtTime(item, currentTime) addElementAtTime(item, currentTime)
} }
variant="compact" variant="compact"
isHighlighted={highlightedId === item.id} isHighlighted={highlightedId === item.id}
@@ -35,10 +35,10 @@ import {
ICONIFY_HOSTS, ICONIFY_HOSTS,
POPULAR_COLLECTIONS, POPULAR_COLLECTIONS,
} from "@/lib/iconify-api"; } from "@/lib/iconify-api";
import { cn, generateUUID } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import Image from "next/image"; 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 { DraggableMediaItem } from "@/components/ui/draggable-item";
import { InputWithBack } from "@/components/ui/input-with-back"; import { InputWithBack } from "@/components/ui/input-with-back";
import { StickerCategory } from "@/stores/stickers-store"; import { StickerCategory } from "@/stores/stickers-store";
@@ -186,9 +186,9 @@ function EmptyView({ message }: { message: string }) {
function StickersContentView({ category }: { category: StickerCategory }) { function StickersContentView({ category }: { category: StickerCategory }) {
const { activeProject } = useProjectStore(); const { activeProject } = useProjectStore();
const { addMediaAtTime } = useTimelineStore(); const { addElementAtTime } = useTimelineStore();
const { currentTime } = usePlaybackStore(); const { currentTime } = usePlaybackStore();
const { addMediaItem } = useMediaStore(); const { addMediaFile } = useMediaStore();
const { const {
searchQuery, searchQuery,
selectedCollection, selectedCollection,
@@ -287,7 +287,7 @@ function StickersContentView({ category }: { category: StickerCategory }) {
throw new Error("Failed to download sticker"); throw new Error("Failed to download sticker");
} }
const mediaItem: Omit<MediaItem, "id"> = { const mediaItem: Omit<MediaFile, "id"> = {
name: iconName.replace(":", "-"), name: iconName.replace(":", "-"),
type: "image", type: "image",
file, file,
@@ -298,16 +298,16 @@ function StickersContentView({ category }: { category: StickerCategory }) {
ephemeral: false, ephemeral: false,
}; };
await addMediaItem(activeProject.id, mediaItem); await addMediaFile(activeProject.id, mediaItem);
const added = useMediaStore const added = useMediaStore
.getState() .getState()
.mediaItems.find( .mediaFiles.find(
(m) => m.url === mediaItem.url && m.name === mediaItem.name (m) => m.url === mediaItem.url && m.name === mediaItem.name
); );
if (!added) throw new Error("Sticker not in media store"); if (!added) throw new Error("Sticker not in media store");
addMediaAtTime(added, currentTime); addElementAtTime(added, currentTime);
toast.success(`Added "${iconName}" to timeline`); toast.success(`Added "${iconName}" to timeline`);
} catch (error) { } catch (error) {
@@ -640,8 +640,8 @@ function StickerItem({
name={displayName} name={displayName}
preview={preview} preview={preview}
dragData={{ dragData={{
type: "sticker", id: "sticker-placeholder",
iconName: iconName, type: "image",
name: displayName, name: displayName,
}} }}
onAddToTimeline={() => onAdd(iconName)} onAddToTimeline={() => onAdd(iconName)}
@@ -1,31 +1,7 @@
import { DraggableMediaItem } from "@/components/ui/draggable-item"; import { DraggableMediaItem } from "@/components/ui/draggable-item";
import { BaseView } from "./base-view"; import { BaseView } from "./base-view";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { useTimelineStore } from "@/stores/timeline-store"; import { useTimelineStore } from "@/stores/timeline-store";
import { type TextElement } from "@/types/timeline"; import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants";
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,
};
export function TextView() { export function TextView() {
return ( return (
@@ -38,14 +14,20 @@ export function TextView() {
</div> </div>
} }
dragData={{ dragData={{
id: textData.id, id: "temp-text-id",
type: textData.type, type: DEFAULT_TEXT_ELEMENT.type,
name: textData.name, name: DEFAULT_TEXT_ELEMENT.name,
content: textData.content, content: DEFAULT_TEXT_ELEMENT.content,
}} }}
aspectRatio={1} aspectRatio={1}
onAddToTimeline={(currentTime) => onAddToTimeline={(currentTime) =>
useTimelineStore.getState().addTextAtTime(textData, currentTime) useTimelineStore.getState().addElementAtTime(
{
...DEFAULT_TEXT_ELEMENT,
id: "temp-text-id",
},
currentTime
)
} }
showLabel={false} showLabel={false}
/> />
@@ -2,13 +2,13 @@
import { useTimelineStore } from "@/stores/timeline-store"; import { useTimelineStore } from "@/stores/timeline-store";
import { TimelineElement, TimelineTrack } from "@/types/timeline"; 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 { usePlaybackStore } from "@/stores/playback-store";
import { useEditorStore } from "@/stores/editor-store"; import { useEditorStore } from "@/stores/editor-store";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Play, Pause, Expand, SkipBack, SkipForward } from "lucide-react"; import { Play, Pause, Expand, SkipBack, SkipForward } from "lucide-react";
import { useState, useRef, useEffect, useCallback } from "react"; import { useState, useRef, useEffect, useCallback } from "react";
import { VideoSampleSink } from "mediabunny";
import { renderTimelineFrame } from "@/lib/timeline-renderer"; import { renderTimelineFrame } from "@/lib/timeline-renderer";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { formatTimeCode } from "@/lib/time"; import { formatTimeCode } from "@/lib/time";
@@ -33,12 +33,12 @@ import { PLATFORM_LAYOUTS, type PlatformLayout } from "@/stores/editor-store";
interface ActiveElement { interface ActiveElement {
element: TimelineElement; element: TimelineElement;
track: TimelineTrack; track: TimelineTrack;
mediaItem: MediaItem | null; mediaItem: MediaFile | null;
} }
export function PreviewPanel() { export function PreviewPanel() {
const { tracks, getTotalDuration, updateTextElement } = useTimelineStore(); const { tracks, getTotalDuration, updateTextElement } = useTimelineStore();
const { mediaItems } = useMediaStore(); const { mediaFiles } = useMediaStore();
const { currentTime, toggle, setCurrentTime } = usePlaybackStore(); const { currentTime, toggle, setCurrentTime } = usePlaybackStore();
const { isPlaying, volume, muted } = usePlaybackStore(); const { isPlaying, volume, muted } = usePlaybackStore();
const { activeProject } = useProjectStore(); const { activeProject } = useProjectStore();
@@ -264,7 +264,7 @@ export function PreviewPanel() {
mediaItem = mediaItem =
element.mediaId === "test" element.mediaId === "test"
? null ? null
: mediaItems.find((item) => item.id === element.mediaId) || : mediaFiles.find((item) => item.id === element.mediaId) ||
null; null;
} }
activeElements.push({ element, track, mediaItem }); activeElements.push({ element, track, mediaItem });
@@ -334,7 +334,7 @@ export function PreviewPanel() {
const gain = audioGainRef.current!; const gain = audioGainRef.current!;
const tracksSnapshot = useTimelineStore.getState().tracks; 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 idToMedia = new Map(mediaList.map((m) => [m.id, m] as const));
const playbackNow = usePlaybackStore.getState().currentTime; const playbackNow = usePlaybackStore.getState().currentTime;
@@ -447,7 +447,7 @@ export function PreviewPanel() {
} }
playingSourcesRef.current.clear(); playingSourcesRef.current.clear();
}; };
}, [isPlaying, volume, muted, mediaItems]); }, [isPlaying, volume, muted, mediaFiles]);
// Canvas: draw current frame for visible elements using offscreen compositing // Canvas: draw current frame for visible elements using offscreen compositing
useEffect(() => { useEffect(() => {
@@ -533,13 +533,11 @@ export function PreviewPanel() {
canvasWidth: displayWidth, canvasWidth: displayWidth,
canvasHeight: displayHeight, canvasHeight: displayHeight,
tracks, tracks,
mediaItems, mediaFiles,
backgroundColor: backgroundColor:
activeProject?.backgroundType === "blur" activeProject?.backgroundType === "blur"
? "transparent" ? "transparent"
: activeProject?.backgroundColor || "#000000", : activeProject?.backgroundColor || "#000000",
useCache: false,
fps: activeProject?.fps || DEFAULT_FPS,
}); });
// Blit offscreen to visible canvas // Blit offscreen to visible canvas
@@ -10,7 +10,7 @@ import { SquareSlashIcon } from "lucide-react";
export function PropertiesPanel() { export function PropertiesPanel() {
const { selectedElements, tracks } = useTimelineStore(); const { selectedElements, tracks } = useTimelineStore();
const { mediaItems } = useMediaStore(); const { mediaFiles } = useMediaStore();
return ( return (
<> <>
@@ -28,11 +28,11 @@ export function PropertiesPanel() {
); );
} }
if (element?.type === "media") { if (element?.type === "media") {
const mediaItem = mediaItems.find( const mediaFile = mediaFiles.find(
(item) => item.id === element.mediaId (file) => file.id === element.mediaId
); );
if (mediaItem?.type === "audio") { if (mediaFile?.type === "audio") {
return <AudioProperties key={elementId} element={element} />; return <AudioProperties key={elementId} element={element} />;
} }
@@ -82,13 +82,11 @@ export function Timeline() {
toggleTrackMute, toggleTrackMute,
dragState, dragState,
} = useTimelineStore(); } = useTimelineStore();
const { mediaItems, addMediaItem } = useMediaStore(); const { mediaFiles, addMediaFile } = useMediaStore();
const { activeProject } = useProjectStore(); const { activeProject } = useProjectStore();
const { currentTime, duration, seek, setDuration, isPlaying, toggle } = const { currentTime, duration, seek, setDuration } = usePlaybackStore();
usePlaybackStore();
const [isDragOver, setIsDragOver] = useState(false); const [isDragOver, setIsDragOver] = useState(false);
const [isProcessing, setIsProcessing] = useState(false); const { addElementToNewTrack } = useTimelineStore();
const [progress, setProgress] = useState(0);
const dragCounterRef = useRef(0); const dragCounterRef = useRef(0);
const timelineRef = useRef<HTMLDivElement>(null); const timelineRef = useRef<HTMLDivElement>(null);
const rulerRef = useRef<HTMLDivElement>(null); const rulerRef = useRef<HTMLDivElement>(null);
@@ -456,10 +454,10 @@ export function Timeline() {
if (dragData.type === "text") { if (dragData.type === "text") {
// Always create new text track to avoid overlaps // Always create new text track to avoid overlaps
useTimelineStore.getState().addTextToNewTrack(dragData); addElementToNewTrack(dragData);
} else { } else {
// Handle media items // Handle media items
const mediaItem = mediaItems.find( const mediaItem = mediaFiles.find(
(item: any) => item.id === dragData.id (item: any) => item.id === dragData.id
); );
if (!mediaItem) { if (!mediaItem) {
@@ -467,7 +465,7 @@ export function Timeline() {
return; return;
} }
useTimelineStore.getState().addMediaToNewTrack(mediaItem); addElementToNewTrack(mediaItem);
} }
} catch (error) { } catch (error) {
console.error("Error parsing dropped item data:", error); console.error("Error parsing dropped item data:", error);
@@ -480,17 +478,12 @@ export function Timeline() {
return; return;
} }
setIsProcessing(true);
setProgress(0);
try { try {
const processedItems = await processMediaFiles( const processedItems = await processMediaFiles(e.dataTransfer.files);
e.dataTransfer.files,
(p) => setProgress(p)
);
for (const processedItem of processedItems) { for (const processedItem of processedItems) {
await addMediaItem(activeProject.id, processedItem); await addMediaFile(activeProject.id, processedItem);
const currentMediaItems = useMediaStore.getState().mediaItems; const currentMediaFiles = mediaFiles;
const addedItem = currentMediaItems.find( const addedItem = currentMediaFiles.find(
(item) => (item) =>
item.name === processedItem.name && item.url === processedItem.url item.name === processedItem.name && item.url === processedItem.url
); );
@@ -516,9 +509,6 @@ export function Timeline() {
// Show error if file processing fails // Show error if file processing fails
console.error("Error processing external files:", error); console.error("Error processing external files:", error);
toast.error("Failed to process dropped files"); toast.error("Failed to process dropped files");
} finally {
setIsProcessing(false);
setProgress(0);
} }
} }
}; };
@@ -40,10 +40,8 @@ export function TimelineElement({
onElementMouseDown, onElementMouseDown,
onElementClick, onElementClick,
}: TimelineElementProps) { }: TimelineElementProps) {
const { mediaItems } = useMediaStore(); const { mediaFiles } = useMediaStore();
const { const {
updateElementTrim,
updateElementDuration,
removeElementFromTrack, removeElementFromTrack,
removeElementFromTrackWithRipple, removeElementFromTrackWithRipple,
dragState, dragState,
@@ -58,7 +56,7 @@ export function TimelineElement({
const mediaItem = const mediaItem =
element.type === "media" element.type === "media"
? mediaItems.find((item) => item.id === element.mediaId) ? mediaFiles.find((file) => file.id === element.mediaId)
: null; : null;
const hasAudio = mediaItem?.type === "audio" || mediaItem?.type === "video"; const hasAudio = mediaItem?.type === "audio" || mediaItem?.type === "video";
@@ -67,8 +65,6 @@ export function TimelineElement({
element, element,
track, track,
zoomLevel, zoomLevel,
onUpdateTrim: updateElementTrim,
onUpdateDuration: updateElementDuration,
}); });
const { requestRevealMedia } = useMediaPanelStore.getState(); const { requestRevealMedia } = useMediaPanelStore.getState();
@@ -190,7 +186,7 @@ export function TimelineElement({
} }
// Render media element -> // Render media element ->
const mediaItem = mediaItems.find((item) => item.id === element.mediaId); const mediaItem = mediaFiles.find((file) => file.id === element.mediaId);
if (!mediaItem) { if (!mediaItem) {
return ( return (
<span className="text-xs text-foreground/80 truncate"> <span className="text-xs text-foreground/80 truncate">
@@ -12,6 +12,7 @@ import {
canElementGoOnTrack, canElementGoOnTrack,
} from "@/types/timeline"; } from "@/types/timeline";
import { usePlaybackStore } from "@/stores/playback-store"; import { usePlaybackStore } from "@/stores/playback-store";
import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants";
import type { import type {
TimelineElement as TimelineElementType, TimelineElement as TimelineElementType,
DragData, DragData,
@@ -33,7 +34,7 @@ export function TimelineTrackContent({
zoomLevel: number; zoomLevel: number;
onSnapPointChange?: (snapPoint: SnapPoint | null) => void; onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
}) { }) {
const { mediaItems } = useMediaStore(); const { mediaFiles } = useMediaStore();
const { const {
tracks, tracks,
addTrack, addTrack,
@@ -537,7 +538,7 @@ export function TimelineTrackContent({
}); });
} else { } else {
// Media elements // Media elements
const mediaItem = mediaItems.find( const mediaItem = mediaFiles.find(
(item) => item.id === dragData.id (item) => item.id === dragData.id
); );
if (mediaItem) { if (mediaItem) {
@@ -890,29 +891,14 @@ export function TimelineTrackContent({
} }
addElementToTrack(targetTrackId, { addElementToTrack(targetTrackId, {
type: "text", ...DEFAULT_TEXT_ELEMENT,
name: dragData.name || "Text", name: dragData.name || DEFAULT_TEXT_ELEMENT.name,
content: dragData.content || "Default Text", content: dragData.content || DEFAULT_TEXT_ELEMENT.content,
duration: TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION,
startTime: textSnappedTime, 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 { } else {
// Handle media items // Handle media items
const mediaItem = mediaItems.find((item) => item.id === dragData.id); const mediaItem = mediaFiles.find((item) => item.id === dragData.id);
if (!mediaItem) { if (!mediaItem) {
toast.error("Media item not found"); toast.error("Media item not found");
@@ -1054,7 +1040,7 @@ export function TimelineTrackContent({
} else if (hasFiles) { } else if (hasFiles) {
// External file drops // External file drops
const { activeProject } = useProjectStore.getState(); const { activeProject } = useProjectStore.getState();
const { addMediaItem } = useMediaStore.getState(); const { addMediaFile } = useMediaStore.getState();
const { addElementToTrack } = useTimelineStore.getState(); const { addElementToTrack } = useTimelineStore.getState();
if (!activeProject) { if (!activeProject) {
@@ -1066,9 +1052,9 @@ export function TimelineTrackContent({
processMediaFiles(e.dataTransfer.files) processMediaFiles(e.dataTransfer.files)
.then(async (processedItems) => { .then(async (processedItems) => {
for (const processedItem of processedItems) { for (const processedItem of processedItems) {
await addMediaItem(activeProject.id, processedItem); await addMediaFile(activeProject.id, processedItem);
const currentMediaItems = useMediaStore.getState().mediaItems; const currentMediaFiles = mediaFiles;
const addedItem = currentMediaItems.find( const addedItem = currentMediaFiles.find(
(item) => (item) =>
item.name === processedItem.name && item.name === processedItem.name &&
item.url === processedItem.url item.url === processedItem.url
@@ -12,11 +12,12 @@ import { createPortal } from "react-dom";
import { Plus } from "lucide-react"; import { Plus } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { usePlaybackStore } from "@/stores/playback-store"; import { usePlaybackStore } from "@/stores/playback-store";
import { DragData } from "@/types/timeline";
export interface DraggableMediaItemProps { export interface DraggableMediaItemProps {
name: string; name: string;
preview: ReactNode; preview: ReactNode;
dragData: Record<string, any>; dragData: DragData;
onDragStart?: (e: React.DragEvent) => void; onDragStart?: (e: React.DragEvent) => void;
onAddToTimeline?: (currentTime: number) => void; onAddToTimeline?: (currentTime: number) => void;
aspectRatio?: number; aspectRatio?: number;
+27
View File
@@ -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,
};
+6 -6
View File
@@ -6,7 +6,7 @@ import { DEFAULT_CANVAS_SIZE, useProjectStore } from "@/stores/project-store";
export function useAspectRatio() { export function useAspectRatio() {
const { canvasPresets } = useEditorStore(); const { canvasPresets } = useEditorStore();
const { activeProject } = useProjectStore(); const { activeProject } = useProjectStore();
const { mediaItems } = useMediaStore(); const { mediaFiles } = useMediaStore();
const { tracks } = useTimelineStore(); const { tracks } = useTimelineStore();
const canvasSize = activeProject?.canvasSize || DEFAULT_CANVAS_SIZE; const canvasSize = activeProject?.canvasSize || DEFAULT_CANVAS_SIZE;
@@ -24,14 +24,14 @@ export function useAspectRatio() {
for (const track of tracks) { for (const track of tracks) {
for (const element of track.elements) { for (const element of track.elements) {
if (element.type === "media") { if (element.type === "media") {
const mediaItem = mediaItems.find( const mediaFile = mediaFiles.find(
(item) => item.id === element.mediaId (file) => file.id === element.mediaId
); );
if ( if (
mediaItem && mediaFile &&
(mediaItem.type === "video" || mediaItem.type === "image") (mediaFile.type === "video" || mediaFile.type === "image")
) { ) {
return getMediaAspectRatio(mediaItem); return getMediaAspectRatio(mediaFile);
} }
} }
} }
@@ -9,28 +9,15 @@ interface UseTimelineElementResizeProps {
element: TimelineElement; element: TimelineElement;
track: TimelineTrack; track: TimelineTrack;
zoomLevel: number; zoomLevel: number;
onUpdateTrim: (
trackId: string,
elementId: string,
trimStart: number,
trimEnd: number
) => void;
onUpdateDuration: (
trackId: string,
elementId: string,
duration: number
) => void;
} }
export function useTimelineElementResize({ export function useTimelineElementResize({
element, element,
track, track,
zoomLevel, zoomLevel,
onUpdateTrim,
onUpdateDuration,
}: UseTimelineElementResizeProps) { }: UseTimelineElementResizeProps) {
const [resizing, setResizing] = useState<ResizeState | null>(null); const [resizing, setResizing] = useState<ResizeState | null>(null);
const { mediaItems } = useMediaStore(); const { mediaFiles } = useMediaStore();
const { const {
updateElementStartTime, updateElementStartTime,
updateElementTrim, updateElementTrim,
@@ -88,11 +75,11 @@ export function useTimelineElementResize({
// Media elements - check the media type // Media elements - check the media type
if (element.type === "media") { if (element.type === "media") {
const mediaItem = mediaItems.find((item) => item.id === element.mediaId); const mediaFile = mediaFiles.find((file) => file.id === element.mediaId);
if (!mediaItem) return false; if (!mediaFile) return false;
// Images can be extended (static content) // Images can be extended (static content)
if (mediaItem.type === "image") { if (mediaFile.type === "image") {
return true; return true;
} }
+2 -3
View File
@@ -1,14 +1,13 @@
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
getFileType, getFileType,
generateVideoThumbnail,
getMediaDuration, getMediaDuration,
getImageDimensions, getImageDimensions,
type MediaItem,
} from "@/stores/media-store"; } from "@/stores/media-store";
import { MediaFile } from "@/types/media";
import { generateThumbnail, getVideoInfo } from "./mediabunny-utils"; import { generateThumbnail, getVideoInfo } from "./mediabunny-utils";
export interface ProcessedMediaItem extends Omit<MediaItem, "id"> {} export interface ProcessedMediaItem extends Omit<MediaFile, "id"> {}
export async function processMediaFiles( export async function processMediaFiles(
files: FileList | File[], files: FileList | File[],
+4 -4
View File
@@ -151,14 +151,14 @@ export const extractTimelineAudio = async (
for (const element of track.elements) { for (const element of track.elements) {
if (element.type === "media") { if (element.type === "media") {
const mediaItem = mediaStore.mediaItems.find( const mediaFile = mediaStore.mediaFiles.find(
(m) => m.id === element.mediaId (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({ audioElements.push({
file: mediaItem.file, file: mediaFile.file,
startTime: element.startTime, startTime: element.startTime,
duration: element.duration, duration: element.duration,
trimStart: element.trimStart, trimStart: element.trimStart,
+9 -9
View File
@@ -1,5 +1,5 @@
import { TProject } from "@/types/project"; import { TProject } from "@/types/project";
import { MediaItem } from "@/stores/media-store"; import { MediaFile } from "@/types/media";
import { IndexedDBAdapter } from "./indexeddb-adapter"; import { IndexedDBAdapter } from "./indexeddb-adapter";
import { OPFSAdapter } from "./opfs-adapter"; import { OPFSAdapter } from "./opfs-adapter";
import { import {
@@ -124,8 +124,8 @@ class StorageService {
await this.projectsAdapter.remove(id); await this.projectsAdapter.remove(id);
} }
// Media operations - now project-specific // Media operations
async saveMediaItem(projectId: string, mediaItem: MediaItem): Promise<void> { async saveMediaFile(projectId: string, mediaItem: MediaFile): Promise<void> {
const { mediaMetadataAdapter, mediaFilesAdapter } = const { mediaMetadataAdapter, mediaFilesAdapter } =
this.getProjectMediaAdapters(projectId); this.getProjectMediaAdapters(projectId);
@@ -148,10 +148,10 @@ class StorageService {
await mediaMetadataAdapter.set(mediaItem.id, metadata); await mediaMetadataAdapter.set(mediaItem.id, metadata);
} }
async loadMediaItem( async loadMediaFile(
projectId: string, projectId: string,
id: string id: string
): Promise<MediaItem | null> { ): Promise<MediaFile | null> {
const { mediaMetadataAdapter, mediaFilesAdapter } = const { mediaMetadataAdapter, mediaFilesAdapter } =
this.getProjectMediaAdapters(projectId); this.getProjectMediaAdapters(projectId);
@@ -192,14 +192,14 @@ class StorageService {
}; };
} }
async loadAllMediaItems(projectId: string): Promise<MediaItem[]> { async loadAllMediaFiles(projectId: string): Promise<MediaFile[]> {
const { mediaMetadataAdapter } = this.getProjectMediaAdapters(projectId); const { mediaMetadataAdapter } = this.getProjectMediaAdapters(projectId);
const mediaIds = await mediaMetadataAdapter.list(); const mediaIds = await mediaMetadataAdapter.list();
const mediaItems: MediaItem[] = []; const mediaItems: MediaFile[] = [];
for (const id of mediaIds) { for (const id of mediaIds) {
const item = await this.loadMediaItem(projectId, id); const item = await this.loadMediaFile(projectId, id);
if (item) { if (item) {
mediaItems.push(item); mediaItems.push(item);
} }
@@ -208,7 +208,7 @@ class StorageService {
return mediaItems; return mediaItems;
} }
async deleteMediaItem(projectId: string, id: string): Promise<void> { async deleteMediaFile(projectId: string, id: string): Promise<void> {
const { mediaMetadataAdapter, mediaFilesAdapter } = const { mediaMetadataAdapter, mediaFilesAdapter } =
this.getProjectMediaAdapters(projectId); this.getProjectMediaAdapters(projectId);
+6 -26
View File
@@ -1,6 +1,6 @@
import { Input, ALL_FORMATS, BlobSource, VideoSampleSink } from "mediabunny"; import { Input, ALL_FORMATS, BlobSource, VideoSampleSink } from "mediabunny";
import type { TimelineTrack } from "@/types/timeline"; import type { TimelineTrack } from "@/types/timeline";
import type { MediaItem } from "@/stores/media-store"; import type { MediaFile } from "@/types/media";
export interface RenderContext { export interface RenderContext {
ctx: CanvasRenderingContext2D; ctx: CanvasRenderingContext2D;
@@ -8,25 +8,8 @@ export interface RenderContext {
canvasWidth: number; canvasWidth: number;
canvasHeight: number; canvasHeight: number;
tracks: TimelineTrack[]; tracks: TimelineTrack[];
mediaItems: MediaItem[]; mediaFiles: MediaFile[];
backgroundColor?: string; 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({ export async function renderTimelineFrame({
@@ -35,11 +18,8 @@ export async function renderTimelineFrame({
canvasWidth, canvasWidth,
canvasHeight, canvasHeight,
tracks, tracks,
mediaItems, mediaFiles,
backgroundColor, backgroundColor,
useCache = true,
cache,
fps,
}: RenderContext): Promise<void> { }: RenderContext): Promise<void> {
// Background // Background
ctx.clearRect(0, 0, canvasWidth, canvasHeight); ctx.clearRect(0, 0, canvasWidth, canvasHeight);
@@ -50,11 +30,11 @@ export async function renderTimelineFrame({
const scaleX = 1; const scaleX = 1;
const scaleY = 1; const scaleY = 1;
const idToMedia = new Map(mediaItems.map((m) => [m.id, m] as const)); const idToMedia = new Map(mediaFiles.map((m) => [m.id, m] as const));
const active: Array<{ const active: Array<{
track: TimelineTrack; track: TimelineTrack;
element: TimelineTrack["elements"][number]; element: TimelineTrack["elements"][number];
mediaItem: MediaItem | null; mediaItem: MediaFile | null;
}> = []; }> = [];
for (let t = tracks.length - 1; t >= 0; t -= 1) { for (let t = tracks.length - 1; t >= 0; t -= 1) {
@@ -66,7 +46,7 @@ export async function renderTimelineFrame({
element.startTime + element.startTime +
(element.duration - element.trimStart - element.trimEnd); (element.duration - element.trimStart - element.trimEnd);
if (time >= elementStart && time < elementEnd) { if (time >= elementStart && time < elementEnd) {
let mediaItem: MediaItem | null = null; let mediaItem: MediaFile | null = null;
if (element.type === "media") { if (element.type === "media") {
mediaItem = mediaItem =
element.mediaId === "test" element.mediaId === "test"
+27 -51
View File
@@ -2,44 +2,21 @@ import { create } from "zustand";
import { storageService } from "@/lib/storage/storage-service"; import { storageService } from "@/lib/storage/storage-service";
import { useTimelineStore } from "./timeline-store"; import { useTimelineStore } from "./timeline-store";
import { generateUUID } from "@/lib/utils"; import { generateUUID } from "@/lib/utils";
import { MediaType, MediaFile } from "@/types/media";
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
}
interface MediaStore { interface MediaStore {
mediaItems: MediaItem[]; mediaFiles: MediaFile[];
isLoading: boolean; isLoading: boolean;
// Actions - now require projectId // Actions
addMediaItem: ( addMediaFile: (
projectId: string, projectId: string,
item: Omit<MediaItem, "id"> file: Omit<MediaFile, "id">
) => Promise<void>; ) => Promise<void>;
removeMediaItem: (projectId: string, id: string) => Promise<void>; removeMediaFile: (projectId: string, id: string) => Promise<void>;
loadProjectMedia: (projectId: string) => Promise<void>; loadProjectMedia: (projectId: string) => Promise<void>;
clearProjectMedia: (projectId: string) => Promise<void>; clearProjectMedia: (projectId: string) => Promise<void>;
clearAllMedia: () => void; // Clear local state only clearAllMedia: () => void;
} }
// Helper function to determine file type // Helper function to determine file type
@@ -150,8 +127,7 @@ export const getMediaDuration = (file: File): Promise<number> => {
}); });
}; };
// Helper to get aspect ratio from MediaItem export const getMediaAspectRatio = (item: MediaFile): number => {
export const getMediaAspectRatio = (item: MediaItem): number => {
if (item.width && item.height) { if (item.width && item.height) {
return item.width / item.height; return item.width / item.height;
} }
@@ -159,35 +135,35 @@ export const getMediaAspectRatio = (item: MediaItem): number => {
}; };
export const useMediaStore = create<MediaStore>((set, get) => ({ export const useMediaStore = create<MediaStore>((set, get) => ({
mediaItems: [], mediaFiles: [],
isLoading: false, isLoading: false,
addMediaItem: async (projectId, item) => { addMediaFile: async (projectId, file) => {
const newItem: MediaItem = { const newItem: MediaFile = {
...item, ...file,
id: generateUUID(), id: generateUUID(),
}; };
// Add to local state immediately for UI responsiveness // Add to local state immediately for UI responsiveness
set((state) => ({ set((state) => ({
mediaItems: [...state.mediaItems, newItem], mediaFiles: [...state.mediaFiles, newItem],
})); }));
// Save to persistent storage in background // Save to persistent storage in background
try { try {
await storageService.saveMediaItem(projectId, newItem); await storageService.saveMediaFile(projectId, newItem);
} catch (error) { } catch (error) {
console.error("Failed to save media item:", error); console.error("Failed to save media item:", error);
// Remove from local state if save failed // Remove from local state if save failed
set((state) => ({ 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 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 // Cleanup object URLs to prevent memory leaks
if (item?.url) { if (item?.url) {
@@ -199,7 +175,7 @@ export const useMediaStore = create<MediaStore>((set, get) => ({
// 1) Remove from local state immediately // 1) Remove from local state immediately
set((state) => ({ 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 // 2) Cascade into the timeline: remove any elements using this media ID
@@ -238,7 +214,7 @@ export const useMediaStore = create<MediaStore>((set, get) => ({
// 3) Remove from persistent storage // 3) Remove from persistent storage
try { try {
await storageService.deleteMediaItem(projectId, id); await storageService.deleteMediaFile(projectId, id);
} catch (error) { } catch (error) {
console.error("Failed to delete media item:", error); console.error("Failed to delete media item:", error);
} }
@@ -248,7 +224,7 @@ export const useMediaStore = create<MediaStore>((set, get) => ({
set({ isLoading: true }); set({ isLoading: true });
try { try {
const mediaItems = await storageService.loadAllMediaItems(projectId); const mediaItems = await storageService.loadAllMediaFiles(projectId);
// Regenerate thumbnails for video items // Regenerate thumbnails for video items
const updatedMediaItems = await Promise.all( const updatedMediaItems = await Promise.all(
@@ -275,7 +251,7 @@ export const useMediaStore = create<MediaStore>((set, get) => ({
}) })
); );
set({ mediaItems: updatedMediaItems }); set({ mediaFiles: updatedMediaItems });
} catch (error) { } catch (error) {
console.error("Failed to load media items:", error); console.error("Failed to load media items:", error);
} finally { } finally {
@@ -287,7 +263,7 @@ export const useMediaStore = create<MediaStore>((set, get) => ({
const state = get(); const state = get();
// Cleanup all object URLs // Cleanup all object URLs
state.mediaItems.forEach((item) => { state.mediaFiles.forEach((item) => {
if (item.url) { if (item.url) {
URL.revokeObjectURL(item.url); URL.revokeObjectURL(item.url);
} }
@@ -297,13 +273,13 @@ export const useMediaStore = create<MediaStore>((set, get) => ({
}); });
// Clear local state // Clear local state
set({ mediaItems: [] }); set({ mediaFiles: [] });
// Clear persistent storage // Clear persistent storage
try { try {
const mediaIds = state.mediaItems.map((item) => item.id); const mediaIds = state.mediaFiles.map((item) => item.id);
await Promise.all( await Promise.all(
mediaIds.map((id) => storageService.deleteMediaItem(projectId, id)) mediaIds.map((id) => storageService.deleteMediaFile(projectId, id))
); );
} catch (error) { } catch (error) {
console.error("Failed to clear media items from storage:", error); console.error("Failed to clear media items from storage:", error);
@@ -314,7 +290,7 @@ export const useMediaStore = create<MediaStore>((set, get) => ({
const state = get(); const state = get();
// Cleanup all object URLs // Cleanup all object URLs
state.mediaItems.forEach((item) => { state.mediaFiles.forEach((item) => {
if (item.url) { if (item.url) {
URL.revokeObjectURL(item.url); URL.revokeObjectURL(item.url);
} }
@@ -324,6 +300,6 @@ export const useMediaStore = create<MediaStore>((set, get) => ({
}); });
// Clear local state // Clear local state
set({ mediaItems: [] }); set({ mediaFiles: [] });
}, },
})); }));
+3 -3
View File
@@ -247,7 +247,7 @@ export const useSoundsStore = create<SoundsStore>((set, get) => ({
type: "audio/mpeg", type: "audio/mpeg",
}); });
await useMediaStore.getState().addMediaItem(activeProject.id, { await useMediaStore.getState().addMediaFile(activeProject.id, {
name: sound.name, name: sound.name,
type: "audio", type: "audio",
file, file,
@@ -257,12 +257,12 @@ export const useSoundsStore = create<SoundsStore>((set, get) => ({
const mediaItem = useMediaStore const mediaItem = useMediaStore
.getState() .getState()
.mediaItems.find((item) => item.file === file); .mediaFiles.find((item) => item.file === file);
if (!mediaItem) throw new Error("Failed to create media item"); if (!mediaItem) throw new Error("Failed to create media item");
const success = useTimelineStore const success = useTimelineStore
.getState() .getState()
.addMediaAtTime(mediaItem, usePlaybackStore.getState().currentTime); .addElementAtTime(mediaItem, usePlaybackStore.getState().currentTime);
if (success) { if (success) {
return true; return true;
+83 -108
View File
@@ -10,17 +10,15 @@ import {
ensureMainTrack, ensureMainTrack,
validateElementTrackCompatibility, validateElementTrackCompatibility,
} from "@/types/timeline"; } from "@/types/timeline";
import { import { useMediaStore, getMediaAspectRatio } from "./media-store";
useMediaStore, import { MediaFile } from "@/types/media";
getMediaAspectRatio,
type MediaItem,
} from "./media-store";
import { findBestCanvasPreset } from "@/lib/editor-utils"; import { findBestCanvasPreset } from "@/lib/editor-utils";
import { storageService } from "@/lib/storage/storage-service"; import { storageService } from "@/lib/storage/storage-service";
import { useProjectStore } from "./project-store"; import { useProjectStore } from "./project-store";
import { generateUUID } from "@/lib/utils"; import { generateUUID } from "@/lib/utils";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { checkElementOverlaps, resolveElementOverlaps } from "@/lib/timeline"; import { checkElementOverlaps, resolveElementOverlaps } from "@/lib/timeline";
import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants";
// Helper function to manage element naming with suffixes // Helper function to manage element naming with suffixes
const getElementNameWithSuffix = ( const getElementNameWithSuffix = (
@@ -207,10 +205,11 @@ interface TimelineStore {
excludeElementId?: string excludeElementId?: string
) => boolean; ) => boolean;
findOrCreateTrack: (trackType: TrackType) => string; findOrCreateTrack: (trackType: TrackType) => string;
addMediaAtTime: (item: MediaItem, currentTime?: number) => boolean; addElementAtTime: (
addTextAtTime: (item: TextElement, currentTime?: number) => boolean; item: MediaFile | TextElement,
addMediaToNewTrack: (item: MediaItem) => boolean; currentTime?: number
addTextToNewTrack: (item: TextElement | DragData) => boolean; ) => boolean;
addElementToNewTrack: (item: MediaFile | TextElement | DragData) => boolean;
} }
export const useTimelineStore = create<TimelineStore>((set, get) => { export const useTimelineStore = create<TimelineStore>((set, get) => {
@@ -502,7 +501,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
if (isFirstElement && newElement.type === "media") { if (isFirstElement && newElement.type === "media") {
const mediaStore = useMediaStore.getState(); const mediaStore = useMediaStore.getState();
const mediaItem = mediaStore.mediaItems.find( const mediaItem = mediaStore.mediaFiles.find(
(item) => item.id === newElement.mediaId (item) => item.id === newElement.mediaId
); );
@@ -1144,7 +1143,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
} }
try { try {
await mediaStore.addMediaItem( await mediaStore.addMediaFile(
projectStore.activeProject.id, projectStore.activeProject.id,
mediaData mediaData
); );
@@ -1155,7 +1154,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
}; };
} }
const newMediaItem = mediaStore.mediaItems.find( const newMediaItem = mediaStore.mediaFiles.find(
(item) => item.file === newFile (item) => item.file === newFile
); );
@@ -1219,7 +1218,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
getProjectThumbnail: async (projectId) => { getProjectThumbnail: async (projectId) => {
try { try {
const tracks = await storageService.loadTimeline(projectId); const tracks = await storageService.loadTimeline(projectId);
const mediaItems = await storageService.loadAllMediaItems(projectId); const mediaItems = await storageService.loadAllMediaFiles(projectId);
if (!tracks || !mediaItems.length) return null; if (!tracks || !mediaItems.length) return null;
@@ -1230,20 +1229,20 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
if (!firstMediaElement) return null; if (!firstMediaElement) return null;
const mediaItem = mediaItems.find( const mediaFile = mediaItems.find(
(item) => item.id === firstMediaElement.mediaId (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( const { generateVideoThumbnail } = await import(
"@/stores/media-store" "@/stores/media-store"
); );
const { thumbnailUrl } = await generateVideoThumbnail(mediaItem.file); const { thumbnailUrl } = await generateVideoThumbnail(mediaFile.file);
return thumbnailUrl; return thumbnailUrl;
} }
if (mediaItem.type === "image" && mediaItem.url) { if (mediaFile.type === "image" && mediaFile.url) {
return mediaItem.url; return mediaFile.url;
} }
return null; return null;
@@ -1401,30 +1400,24 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
return get().addTrack(trackType); return get().addTrack(trackType);
}, },
addMediaAtTime: (item, currentTime = 0) => { addElementAtTime: (item: MediaFile | TextElement, currentTime = 0) => {
const trackType = item.type === "audio" ? "audio" : "media"; if (item.type === "text") {
const duration = const targetTrackId = get().insertTrackAt("text", 0);
item.duration || TIMELINE_CONSTANTS.DEFAULT_IMAGE_DURATION; get().addElementToTrack(
targetTrackId,
const tracks = get()._tracks.filter((t) => t.type === trackType); buildTextElement(item, currentTime)
);
let targetTrackId = null; return true;
for (const track of tracks) {
if (!get().checkElementOverlap(track.id, currentTime, duration)) {
targetTrackId = track.id;
break;
}
}
if (!targetTrackId) {
targetTrackId = get().addTrack(trackType);
} }
const media = item as MediaFile;
const trackType = media.type === "audio" ? "audio" : "media";
const targetTrackId = get().insertTrackAt(trackType, 0);
get().addElementToTrack(targetTrackId, { get().addElementToTrack(targetTrackId, {
type: "media", type: "media",
mediaId: item.id, mediaId: media.id,
name: item.name, name: media.name,
duration, duration: media.duration || TIMELINE_CONSTANTS.DEFAULT_IMAGE_DURATION,
startTime: currentTime, startTime: currentTime,
trimStart: 0, trimStart: 0,
trimEnd: 0, trimEnd: 0,
@@ -1433,42 +1426,24 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
return true; return true;
}, },
addTextAtTime: (item, currentTime = 0) => { addElementToNewTrack: (item) => {
const targetTrackId = get().insertTrackAt("text", 0); if (item.type === "text") {
const targetTrackId = get().insertTrackAt("text", 0);
get().addElementToTrack(targetTrackId, { get().addElementToTrack(
type: "text", targetTrackId,
name: item.name || "Text", buildTextElement(item as TextElement | DragData, 0)
content: item.content || "Default Text", );
duration: item.duration || TIMELINE_CONSTANTS.DEFAULT_TEXT_DURATION, return true;
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);
const media = item as MediaFile;
const trackType = media.type === "audio" ? "audio" : "media";
const targetTrackId = get().insertTrackAt(trackType, 0);
get().addElementToTrack(targetTrackId, { get().addElementToTrack(targetTrackId, {
type: "media", type: "media",
mediaId: item.id, mediaId: media.id,
name: item.name, name: media.name,
duration: item.duration || TIMELINE_CONSTANTS.DEFAULT_IMAGE_DURATION, duration: media.duration || TIMELINE_CONSTANTS.DEFAULT_IMAGE_DURATION,
startTime: 0, startTime: 0,
trimStart: 0, trimStart: 0,
trimEnd: 0, trimEnd: 0,
@@ -1476,41 +1451,41 @@ export const useTimelineStore = create<TimelineStore>((set, get) => {
}); });
return true; 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<TextElement>;
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,
};
}
+17
View File
@@ -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;
}
+1 -1
View File
@@ -1,4 +1,4 @@
import { MediaType } from "@/stores/media-store"; import { MediaType } from "@/types/media";
import { generateUUID } from "@/lib/utils"; import { generateUUID } from "@/lib/utils";
export type TrackType = "media" | "text" | "audio"; export type TrackType = "media" | "text" | "audio";