summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2025-08-26 15:04:25 +0200
committerMaze Winther <mazewinther@gmail.com>2025-08-26 15:04:25 +0200
commitb459b46802a760a4a81ce91fdf24390e07375fb0 (patch)
treee0070578d2b57acfba5f119cbf1abc6c9db5e741
parent2f7cd11ca123884969f90a94e59593d355518839 (diff)
downloadopencut-b459b46802a760a4a81ce91fdf24390e07375fb0.tar.gz
refactor: type-safety & separate concerns
-rw-r--r--apps/web/src/components/editor/media-panel/views/captions.tsx18
-rw-r--r--apps/web/src/components/editor/media-panel/views/media.tsx64
-rw-r--r--apps/web/src/components/editor/media-panel/views/stickers.tsx20
-rw-r--r--apps/web/src/components/editor/media-panel/views/text.tsx42
-rw-r--r--apps/web/src/components/editor/preview-panel.tsx18
-rw-r--r--apps/web/src/components/editor/properties-panel/index.tsx8
-rw-r--r--apps/web/src/components/editor/timeline/index.tsx30
-rw-r--r--apps/web/src/components/editor/timeline/timeline-element.tsx10
-rw-r--r--apps/web/src/components/editor/timeline/timeline-track.tsx36
-rw-r--r--apps/web/src/components/ui/draggable-item.tsx3
-rw-r--r--apps/web/src/constants/text-constants.ts27
-rw-r--r--apps/web/src/hooks/use-aspect-ratio.ts12
-rw-r--r--apps/web/src/hooks/use-timeline-element-resize.ts21
-rw-r--r--apps/web/src/lib/media-processing.ts5
-rw-r--r--apps/web/src/lib/mediabunny-utils.ts8
-rw-r--r--apps/web/src/lib/storage/storage-service.ts18
-rw-r--r--apps/web/src/lib/timeline-renderer.ts338
-rw-r--r--apps/web/src/stores/media-store.ts78
-rw-r--r--apps/web/src/stores/sounds-store.ts6
-rw-r--r--apps/web/src/stores/timeline-store.ts189
-rw-r--r--apps/web/src/types/media.ts17
-rw-r--r--apps/web/src/types/timeline.ts2
22 files changed, 429 insertions, 541 deletions
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<void>;
}) {
@@ -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<HTMLInputElement>(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<HTMLInputElement>) => {
- // 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<string, React.ReactNode>();
@@ -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<void>;
highlightedId: string | null;
registerElement: (id: string, element: HTMLElement | null) => void;
}) {
+ const { addElementAtTime } = useTimelineStore();
+
return (
<div
className="grid gap-2"
@@ -507,7 +489,7 @@ function GridView({
}}
showPlusOnDrag={false}
onAddToTimeline={(currentTime) =>
- 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<void>;
highlightedId: string | null;
registerElement: (id: string, element: HTMLElement | null) => void;
}) {
+ const { addElementAtTime } = useTimelineStore();
+
return (
<div className="space-y-1">
{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<MediaItem, "id"> = {
+ const mediaItem: Omit<MediaFile, "id"> = {
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() {
</div>
}
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 <AudioProperties key={elementId} element={element} />;
}
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<HTMLDivElement>(null);
const rulerRef = useRef<HTMLDivElement>(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 (
<span className="text-xs text-foreground/80 truncate">
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<string, any>;
+ 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<ResizeState | null>(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<MediaItem, "id"> {}
+export interface ProcessedMediaItem extends Omit<MediaFile, "id"> {}
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<void> {
+ // Media operations
+ async saveMediaFile(projectId: string, mediaItem: MediaFile): Promise<void> {
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<MediaItem | null> {
+ ): Promise<MediaFile | null> {
const { mediaMetadataAdapter, mediaFilesAdapter } =
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 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<void> {
+ async deleteMediaFile(projectId: string, id: string): Promise<void> {
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<void> {
- // 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<void>((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<void> {
+ // 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<void>((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<MediaItem, "id">
+ file: Omit<MediaFile, "id">
) => Promise<void>;
- removeMediaItem: (projectId: string, id: string) => Promise<void>;
+ removeMediaFile: (projectId: string, id: string) => Promise<void>;
loadProjectMedia: (projectId: string) => Promise<void>;
clearProjectMedia: (projectId: string) => Promise<void>;
- clearAllMedia: () => void; // Clear local state only
+ clearAllMedia: () => void;
}
// 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: 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<MediaStore>((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<MediaStore>((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<MediaStore>((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<MediaStore>((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<MediaStore>((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<MediaStore>((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<MediaStore>((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<MediaStore>((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<MediaStore>((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<SoundsStore>((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<SoundsStore>((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<TimelineStore>((set, get) => {
@@ -502,7 +501,7 @@ export const useTimelineStore = create<TimelineStore>((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<TimelineStore>((set, get) => {
}
try {
- await mediaStore.addMediaItem(
+ await mediaStore.addMediaFile(
projectStore.activeProject.id,
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
);
@@ -1219,7 +1218,7 @@ export const useTimelineStore = create<TimelineStore>((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<TimelineStore>((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<TimelineStore>((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<TimelineStore>((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<TimelineStore>((set, get) => {
});
return true;
},
+ };
+});
- addTextToNewTrack: (item) => {
- const targetTrackId = get().insertTrackAt("text", 0);
+function buildTextElement(
+ raw: TextElement | DragData,
+ startTime: number
+): CreateTimelineElement {
+ const t = raw as Partial<TextElement>;
- 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;
- },
+ 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";