mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
refactor: type-safety & separate concerns
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
@@ -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,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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[],
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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: [] });
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,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";
|
||||||
|
|||||||
Reference in New Issue
Block a user