diff options
| author | Maze Winther <mazewinther@gmail.com> | 2025-07-07 19:06:36 +0200 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2025-07-07 19:06:36 +0200 |
| commit | bd0c7f22069d1a2e4b2a2d962becaf6b9e942414 (patch) | |
| tree | 595eb9b49f655f7c30b008065c26612077474a82 | |
| parent | 11c0b89bd162fe0cb0633687fe5ad07977956ace (diff) | |
| download | opencut-bd0c7f22069d1a2e4b2a2d962becaf6b9e942414.tar.gz | |
refactor: store media relative to project, add storage for timeline data, and other things
| -rw-r--r-- | apps/web/src/app/editor/[project_id]/page.tsx | 23 | ||||
| -rw-r--r-- | apps/web/src/components/editor/media-panel/views/media.tsx | 16 | ||||
| -rw-r--r-- | apps/web/src/components/editor/timeline-track.tsx | 61 | ||||
| -rw-r--r-- | apps/web/src/components/editor/timeline.tsx | 9 | ||||
| -rw-r--r-- | apps/web/src/components/storage-provider.tsx | 7 | ||||
| -rw-r--r-- | apps/web/src/hooks/use-drag-clip.ts | 237 | ||||
| -rw-r--r-- | apps/web/src/lib/storage/storage-service.ts | 467 | ||||
| -rw-r--r-- | apps/web/src/lib/storage/types.ts | 7 | ||||
| -rw-r--r-- | apps/web/src/stores/media-store.ts | 48 | ||||
| -rw-r--r-- | apps/web/src/stores/project-store.ts | 43 | ||||
| -rw-r--r-- | apps/web/src/stores/timeline-store.ts | 123 | ||||
| -rw-r--r-- | apps/web/src/types/project.ts | 15 | ||||
| -rw-r--r-- | apps/web/src/types/timeline.ts | 31 |
13 files changed, 573 insertions, 514 deletions
diff --git a/apps/web/src/app/editor/[project_id]/page.tsx b/apps/web/src/app/editor/[project_id]/page.tsx index 8eee5e0a..011c0c60 100644 --- a/apps/web/src/app/editor/[project_id]/page.tsx +++ b/apps/web/src/app/editor/[project_id]/page.tsx @@ -1,6 +1,7 @@ "use client"; import { useEffect } from "react"; +import { useParams } from "next/navigation"; import { ResizablePanelGroup, ResizablePanel, @@ -28,15 +29,27 @@ export default function Editor() { setTimeline, } = usePanelStore(); - const { activeProject, createNewProject } = useProjectStore(); + const { activeProject, loadProject, createNewProject } = useProjectStore(); + const params = useParams(); + const projectId = params.project_id as string; usePlaybackControls(); useEffect(() => { - if (!activeProject) { - createNewProject("Untitled Project"); - } - }, [activeProject, createNewProject]); + const initializeProject = async () => { + if (projectId && (!activeProject || activeProject.id !== projectId)) { + try { + await loadProject(projectId); + } catch (error) { + console.error("Failed to load project:", error); + // If project doesn't exist, create a new one + await createNewProject("Untitled Project"); + } + } + }; + + initializeProject(); + }, [projectId, activeProject, loadProject, createNewProject]); return ( <EditorProvider> 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 f975496e..f7da684d 100644 --- a/apps/web/src/components/editor/media-panel/views/media.tsx +++ b/apps/web/src/components/editor/media-panel/views/media.tsx @@ -24,9 +24,11 @@ import { SelectValue,
} from "@/components/ui/select";
import { DraggableMediaItem } from "@/components/ui/draggable-item";
+import { useProjectStore } from "@/stores/project-store";
export function MediaView() {
const { mediaItems, addMediaItem, removeMediaItem } = useMediaStore();
+ const { activeProject } = useProjectStore();
const fileInputRef = useRef<HTMLInputElement>(null);
const [isProcessing, setIsProcessing] = useState(false);
const [progress, setProgress] = useState(0);
@@ -35,6 +37,11 @@ export function MediaView() { const processFiles = async (files: FileList | File[]) => {
if (!files || files.length === 0) return;
+ if (!activeProject) {
+ toast.error("No active project");
+ return;
+ }
+
setIsProcessing(true);
setProgress(0);
try {
@@ -44,7 +51,7 @@ export function MediaView() { );
// Add each processed media item to the store
for (const item of processedItems) {
- await addMediaItem(item);
+ await addMediaItem(activeProject.id, item);
}
} catch (error) {
// Show error toast if processing fails
@@ -73,6 +80,11 @@ export function MediaView() { // Remove a media item from the store
e.stopPropagation();
+ if (!activeProject) {
+ toast.error("No active project");
+ return;
+ }
+
// Remove elements automatically when delete media
const { tracks, removeTrack } = useTimelineStore.getState();
tracks.forEach((track) => {
@@ -92,7 +104,7 @@ export function MediaView() { removeTrack(track.id);
}
});
- await removeMediaItem(id);
+ await removeMediaItem(activeProject.id, id);
};
const formatDuration = (duration: number) => {
diff --git a/apps/web/src/components/editor/timeline-track.tsx b/apps/web/src/components/editor/timeline-track.tsx index eb58e1ee..2751fa5a 100644 --- a/apps/web/src/components/editor/timeline-track.tsx +++ b/apps/web/src/components/editor/timeline-track.tsx @@ -13,11 +13,12 @@ import { ContextMenuSeparator, ContextMenuTrigger, } from "../ui/context-menu"; -import { +import { TimelineTrack, sortTracksByOrder, ensureMainTrack, - getMainTrack + getMainTrack, + canElementGoOnTrack, } from "@/types/timeline"; import { usePlaybackStore } from "@/stores/playback-store"; import type { @@ -572,7 +573,9 @@ export function TimelineTrackContent({ // dropPosition === "on" but track is not text type // Insert above main track if main track exists, otherwise at top if (mainTrack) { - const mainTrackIndex = tracks.findIndex(t => t.id === mainTrack.id); + const mainTrackIndex = tracks.findIndex( + (t) => t.id === mainTrack.id + ); insertIndex = mainTrackIndex; } else { insertIndex = 0; // Top of timeline @@ -648,9 +651,11 @@ export function TimelineTrackContent({ const isVideoOrImage = dragData.type === "video" || dragData.type === "image"; const isAudio = dragData.type === "audio"; - const isCompatible = - (track.type === "media" && isVideoOrImage) || - (track.type === "audio" && isAudio); + const isCompatible = isVideoOrImage + ? canElementGoOnTrack("media", track.type) + : isAudio + ? canElementGoOnTrack("media", track.type) + : false; let targetTrack = tracks.find((t) => t.id === targetTrackId); @@ -662,7 +667,7 @@ export function TimelineTrackContent({ if (isVideoOrImage) { // For video/image, check if we need a main track or additional media track const mainTrack = getMainTrack(tracks); - + if (!mainTrack) { // No main track exists, create it const updatedTracks = ensureMainTrack(tracks); @@ -672,22 +677,32 @@ export function TimelineTrackContent({ targetTrack = newMainTrack; } else { // Main track was created but somehow has elements, create new media track - const mainTrackIndex = updatedTracks.findIndex(t => t.id === newMainTrack?.id); + const mainTrackIndex = updatedTracks.findIndex( + (t) => t.id === newMainTrack?.id + ); targetTrackId = insertTrackAt("media", mainTrackIndex); - const updatedTracksAfterInsert = useTimelineStore.getState().tracks; - const newTargetTrack = updatedTracksAfterInsert.find(t => t.id === targetTrackId); + const updatedTracksAfterInsert = + useTimelineStore.getState().tracks; + const newTargetTrack = updatedTracksAfterInsert.find( + (t) => t.id === targetTrackId + ); if (!newTargetTrack) return; targetTrack = newTargetTrack; } - } else if (mainTrack.elements.length === 0 && dropPosition === "on") { + } else if ( + mainTrack.elements.length === 0 && + dropPosition === "on" + ) { // Main track exists and is empty, use it targetTrackId = mainTrack.id; targetTrack = mainTrack; } else { // Create new media track above main track - const mainTrackIndex = tracks.findIndex(t => t.id === mainTrack.id); + const mainTrackIndex = tracks.findIndex( + (t) => t.id === mainTrack.id + ); let insertIndex: number; - + if (dropPosition === "above") { insertIndex = currentTrackIndex; } else if (dropPosition === "below") { @@ -696,10 +711,12 @@ export function TimelineTrackContent({ // Insert above main track insertIndex = mainTrackIndex; } - + targetTrackId = insertTrackAt("media", insertIndex); const updatedTracks = useTimelineStore.getState().tracks; - const newTargetTrack = updatedTracks.find(t => t.id === targetTrackId); + const newTargetTrack = updatedTracks.find( + (t) => t.id === targetTrackId + ); if (!newTargetTrack) return; targetTrack = newTargetTrack; } @@ -707,7 +724,7 @@ export function TimelineTrackContent({ // Audio tracks go at the bottom const mainTrack = getMainTrack(tracks); let insertIndex: number; - + if (dropPosition === "above") { insertIndex = currentTrackIndex; } else if (dropPosition === "below") { @@ -715,16 +732,20 @@ export function TimelineTrackContent({ } else { // Insert after main track (bottom area) if (mainTrack) { - const mainTrackIndex = tracks.findIndex(t => t.id === mainTrack.id); + const mainTrackIndex = tracks.findIndex( + (t) => t.id === mainTrack.id + ); insertIndex = mainTrackIndex + 1; } else { insertIndex = tracks.length; // Bottom of timeline } } - + targetTrackId = insertTrackAt("audio", insertIndex); const updatedTracks = useTimelineStore.getState().tracks; - const newTargetTrack = updatedTracks.find(t => t.id === targetTrackId); + const newTargetTrack = updatedTracks.find( + (t) => t.id === targetTrackId + ); if (!newTargetTrack) return; targetTrack = newTargetTrack; } @@ -805,7 +826,7 @@ export function TimelineTrackContent({ ? wouldOverlap ? "Cannot drop - would overlap" : "Drop element here" - : "Drop media here"} + : ""} </div> ) : ( <> diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx index 283f6b04..f98ab5ec 100644 --- a/apps/web/src/components/editor/timeline.tsx +++ b/apps/web/src/components/editor/timeline.tsx @@ -31,6 +31,7 @@ import { import { useTimelineStore } from "@/stores/timeline-store"; import { useMediaStore } from "@/stores/media-store"; import { usePlaybackStore } from "@/stores/playback-store"; +import { useProjectStore } from "@/stores/project-store"; import { processMediaFiles } from "@/lib/media-processing"; import { toast } from "sonner"; import { useState, useRef, useEffect, useCallback } from "react"; @@ -65,6 +66,7 @@ export function Timeline() { redo, } = useTimelineStore(); const { mediaItems, addMediaItem } = useMediaStore(); + const { activeProject } = useProjectStore(); const { currentTime, duration, @@ -377,6 +379,11 @@ export function Timeline() { } } else if (e.dataTransfer.files?.length > 0) { // Handle file drops by creating new tracks + if (!activeProject) { + toast.error("No active project"); + return; + } + setIsProcessing(true); setProgress(0); try { @@ -385,7 +392,7 @@ export function Timeline() { (p) => setProgress(p) ); for (const processedItem of processedItems) { - await addMediaItem(processedItem); + await addMediaItem(activeProject.id, processedItem); const currentMediaItems = useMediaStore.getState().mediaItems; const addedItem = currentMediaItems.find( (item) => diff --git a/apps/web/src/components/storage-provider.tsx b/apps/web/src/components/storage-provider.tsx index e007ca96..48423b5c 100644 --- a/apps/web/src/components/storage-provider.tsx +++ b/apps/web/src/components/storage-provider.tsx @@ -36,7 +36,6 @@ export function StorageProvider({ children }: StorageProviderProps) { });
const loadAllProjects = useProjectStore((state) => state.loadAllProjects);
- const loadAllMedia = useMediaStore((state) => state.loadAllMedia);
useEffect(() => {
const initializeStorage = async () => {
@@ -52,8 +51,8 @@ export function StorageProvider({ children }: StorageProviderProps) { );
}
- // Load saved data in parallel
- await Promise.all([loadAllProjects(), loadAllMedia()]);
+ // Load saved projects (media will be loaded when a project is loaded)
+ await loadAllProjects();
setStatus({
isInitialized: true,
@@ -73,7 +72,7 @@ export function StorageProvider({ children }: StorageProviderProps) { };
initializeStorage();
- }, [loadAllProjects, loadAllMedia]);
+ }, [loadAllProjects]);
return (
<StorageContext.Provider value={status}>{children}</StorageContext.Provider>
diff --git a/apps/web/src/hooks/use-drag-clip.ts b/apps/web/src/hooks/use-drag-clip.ts deleted file mode 100644 index 1d38343b..00000000 --- a/apps/web/src/hooks/use-drag-clip.ts +++ /dev/null @@ -1,237 +0,0 @@ -import { useState, useEffect, useCallback, useRef } from "react"; -import { useTimelineStore } from "@/stores/timeline-store"; - -interface DragState { - isDragging: boolean; - elementId: string | null; - trackId: string | null; - startMouseX: number; - startElementTime: number; - clickOffsetTime: number; - currentTime: number; -} - -export function useDragClip(zoomLevel: number) { - const { tracks, updateElementStartTime, moveElementToTrack } = - useTimelineStore(); - - const [dragState, setDragState] = useState<DragState>({ - isDragging: false, - elementId: null, - trackId: null, - startMouseX: 0, - startElementTime: 0, - clickOffsetTime: 0, - currentTime: 0, - }); - - const timelineRef = useRef<HTMLDivElement>(null); - const dragStateRef = useRef(dragState); - - // Keep ref in sync with state - dragStateRef.current = dragState; - - const startDrag = useCallback( - ( - e: React.MouseEvent, - elementId: string, - trackId: string, - elementStartTime: number, - clickOffsetTime: number - ) => { - e.preventDefault(); - e.stopPropagation(); - - setDragState({ - isDragging: true, - elementId, - trackId, - startMouseX: e.clientX, - startElementTime: elementStartTime, - clickOffsetTime, - currentTime: elementStartTime, - }); - }, - [] - ); - - const updateDrag = useCallback( - (e: MouseEvent) => { - if (!dragState.isDragging || !timelineRef.current) { - return; - } - - const timelineRect = timelineRef.current.getBoundingClientRect(); - const mouseX = e.clientX - timelineRect.left; - const mouseTime = Math.max(0, mouseX / (50 * zoomLevel)); - const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime); - const snappedTime = Math.round(adjustedTime * 10) / 10; - - setDragState((prev) => ({ - ...prev, - currentTime: snappedTime, - })); - }, - [dragState.isDragging, dragState.clickOffsetTime, zoomLevel] - ); - - const endDrag = useCallback( - (targetTrackId?: string) => { - if (!dragState.isDragging || !dragState.elementId || !dragState.trackId) - return; - - const finalTrackId = targetTrackId || dragState.trackId; - const finalTime = dragState.currentTime; - - // Check for overlaps - const sourceTrack = tracks.find((t) => t.id === dragState.trackId); - const targetTrack = tracks.find((t) => t.id === finalTrackId); - const movingElement = sourceTrack?.elements.find( - (e) => e.id === dragState.elementId - ); - - if (!movingElement || !targetTrack) { - setDragState((prev) => ({ ...prev, isDragging: false })); - return; - } - - const movingElementDuration = - movingElement.duration - - movingElement.trimStart - - movingElement.trimEnd; - const movingElementEnd = finalTime + movingElementDuration; - - const hasOverlap = targetTrack.elements.some((existingElement) => { - // Skip the element being moved if it's on the same track - if ( - dragState.trackId === finalTrackId && - existingElement.id === dragState.elementId - ) { - return false; - } - - const existingStart = existingElement.startTime; - const existingEnd = - existingElement.startTime + - (existingElement.duration - - existingElement.trimStart - - existingElement.trimEnd); - - return finalTime < existingEnd && movingElementEnd > existingStart; - }); - - if (!hasOverlap) { - if (dragState.trackId === finalTrackId) { - // Moving within same track - updateElementStartTime(finalTrackId, dragState.elementId!, finalTime); - } else { - // Moving to different track - moveElementToTrack( - dragState.trackId!, - finalTrackId, - dragState.elementId! - ); - requestAnimationFrame(() => { - updateElementStartTime( - finalTrackId, - dragState.elementId!, - finalTime - ); - }); - } - } - - setDragState({ - isDragging: false, - elementId: null, - trackId: null, - startMouseX: 0, - startElementTime: 0, - clickOffsetTime: 0, - currentTime: 0, - }); - }, - [dragState, tracks, updateElementStartTime, moveElementToTrack] - ); - - const cancelDrag = useCallback(() => { - setDragState({ - isDragging: false, - elementId: null, - trackId: null, - startMouseX: 0, - startElementTime: 0, - clickOffsetTime: 0, - currentTime: 0, - }); - }, []); - - // Global mouse events - useEffect(() => { - if (!dragState.isDragging) return; - - const handleMouseMove = (e: MouseEvent) => updateDrag(e); - const handleMouseUp = () => endDrag(); - const handleEscape = (e: KeyboardEvent) => { - if (e.key === "Escape") cancelDrag(); - }; - - document.addEventListener("mousemove", handleMouseMove); - document.addEventListener("mouseup", handleMouseUp); - document.addEventListener("keydown", handleEscape); - - return () => { - document.removeEventListener("mousemove", handleMouseMove); - document.removeEventListener("mouseup", handleMouseUp); - document.removeEventListener("keydown", handleEscape); - }; - }, [dragState.isDragging, updateDrag, endDrag, cancelDrag]); - - const getDraggedElementPosition = useCallback( - (elementId: string) => { - // Use ref to get current state, not stale closure - const currentDragState = dragStateRef.current; - const isMatch = - currentDragState.isDragging && currentDragState.elementId === elementId; - - if (isMatch) { - return currentDragState.currentTime; - } - return null; - }, - [] // No dependencies needed since we use ref - ); - - const isValidDropTarget = useCallback( - (trackId: string) => { - if (!dragState.isDragging) return false; - - const sourceTrack = tracks.find((t) => t.id === dragState.trackId); - const targetTrack = tracks.find((t) => t.id === trackId); - - if (!sourceTrack || !targetTrack) return false; - - // For now, allow drops on same track type - return sourceTrack.type === targetTrack.type; - }, - [dragState.isDragging, dragState.trackId, tracks] - ); - - return { - // State - isDragging: dragState.isDragging, - draggedElementId: dragState.elementId, - currentDragTime: dragState.currentTime, - clickOffsetTime: dragState.clickOffsetTime, - - // Methods - startDrag, - endDrag, - cancelDrag, - getDraggedElementPosition, - isValidDropTarget, - - // Refs - timelineRef, - }; -} diff --git a/apps/web/src/lib/storage/storage-service.ts b/apps/web/src/lib/storage/storage-service.ts index 65b3c134..79e932bd 100644 --- a/apps/web/src/lib/storage/storage-service.ts +++ b/apps/web/src/lib/storage/storage-service.ts @@ -1,194 +1,273 @@ -import { TProject } from "@/types/project";
-import { MediaItem } from "@/stores/media-store";
-import { IndexedDBAdapter } from "./indexeddb-adapter";
-import { OPFSAdapter } from "./opfs-adapter";
-import { MediaFileData, StorageConfig, SerializedProject } from "./types";
-
-class StorageService {
- private projectsAdapter: IndexedDBAdapter<SerializedProject>;
- private mediaMetadataAdapter: IndexedDBAdapter<MediaFileData>;
- private mediaFilesAdapter: OPFSAdapter;
- private config: StorageConfig;
-
- constructor() {
- this.config = {
- projectsDb: "video-editor-projects",
- mediaDb: "video-editor-media",
- version: 1,
- };
-
- this.projectsAdapter = new IndexedDBAdapter<SerializedProject>(
- this.config.projectsDb,
- "projects",
- this.config.version
- );
-
- this.mediaMetadataAdapter = new IndexedDBAdapter<MediaFileData>(
- this.config.mediaDb,
- "media-metadata",
- this.config.version
- );
-
- this.mediaFilesAdapter = new OPFSAdapter("media-files");
- }
-
- // Project operations
- async saveProject(project: TProject): Promise<void> {
- // Convert TProject to serializable format
- const serializedProject: SerializedProject = {
- id: project.id,
- name: project.name,
- thumbnail: project.thumbnail,
- createdAt: project.createdAt.toISOString(),
- updatedAt: project.updatedAt.toISOString(),
- };
-
- await this.projectsAdapter.set(project.id, serializedProject);
- }
-
- async loadProject(id: string): Promise<TProject | null> {
- const serializedProject = await this.projectsAdapter.get(id);
-
- if (!serializedProject) return null;
-
- // Convert back to TProject format
- return {
- id: serializedProject.id,
- name: serializedProject.name,
- thumbnail: serializedProject.thumbnail,
- createdAt: new Date(serializedProject.createdAt),
- updatedAt: new Date(serializedProject.updatedAt),
- };
- }
-
- async loadAllProjects(): Promise<TProject[]> {
- const projectIds = await this.projectsAdapter.list();
- const projects: TProject[] = [];
-
- for (const id of projectIds) {
- const project = await this.loadProject(id);
- if (project) {
- projects.push(project);
- }
- }
-
- // Sort by last updated (most recent first)
- return projects.sort(
- (a, b) => b.updatedAt.getTime() - a.updatedAt.getTime()
- );
- }
-
- async deleteProject(id: string): Promise<void> {
- await this.projectsAdapter.remove(id);
- }
-
- // Media operations
- async saveMediaItem(mediaItem: MediaItem): Promise<void> {
- // Save file to OPFS
- await this.mediaFilesAdapter.set(mediaItem.id, mediaItem.file);
-
- // Save metadata to IndexedDB
- const metadata: MediaFileData = {
- id: mediaItem.id,
- name: mediaItem.name,
- type: mediaItem.type,
- size: mediaItem.file.size,
- lastModified: mediaItem.file.lastModified,
- width: mediaItem.width,
- height: mediaItem.height,
- duration: mediaItem.duration,
- };
-
- await this.mediaMetadataAdapter.set(mediaItem.id, metadata);
- }
-
- async loadMediaItem(id: string): Promise<MediaItem | null> {
- const [file, metadata] = await Promise.all([
- this.mediaFilesAdapter.get(id),
- this.mediaMetadataAdapter.get(id),
- ]);
-
- if (!file || !metadata) return null;
-
- // Create new object URL for the file
- const url = URL.createObjectURL(file);
-
- return {
- id: metadata.id,
- name: metadata.name,
- type: metadata.type,
- file,
- url,
- width: metadata.width,
- height: metadata.height,
- duration: metadata.duration,
- // thumbnailUrl would need to be regenerated or cached separately
- };
- }
-
- async loadAllMediaItems(): Promise<MediaItem[]> {
- const mediaIds = await this.mediaMetadataAdapter.list();
- const mediaItems: MediaItem[] = [];
-
- for (const id of mediaIds) {
- const item = await this.loadMediaItem(id);
- if (item) {
- mediaItems.push(item);
- }
- }
-
- return mediaItems;
- }
-
- async deleteMediaItem(id: string): Promise<void> {
- await Promise.all([
- this.mediaFilesAdapter.remove(id),
- this.mediaMetadataAdapter.remove(id),
- ]);
- }
-
- // Utility methods
- async clearAllData(): Promise<void> {
- await Promise.all([
- this.projectsAdapter.clear(),
- this.mediaMetadataAdapter.clear(),
- this.mediaFilesAdapter.clear(),
- ]);
- }
-
- async getStorageInfo(): Promise<{
- projects: number;
- mediaItems: number;
- isOPFSSupported: boolean;
- isIndexedDBSupported: boolean;
- }> {
- const [projectIds, mediaIds] = await Promise.all([
- this.projectsAdapter.list(),
- this.mediaMetadataAdapter.list(),
- ]);
-
- return {
- projects: projectIds.length,
- mediaItems: mediaIds.length,
- isOPFSSupported: this.isOPFSSupported(),
- isIndexedDBSupported: this.isIndexedDBSupported(),
- };
- }
-
- // Check browser support
- isOPFSSupported(): boolean {
- return OPFSAdapter.isSupported();
- }
-
- isIndexedDBSupported(): boolean {
- return "indexedDB" in window;
- }
-
- isFullySupported(): boolean {
- return this.isIndexedDBSupported() && this.isOPFSSupported();
- }
-}
-
-// Export singleton instance
-export const storageService = new StorageService();
-export { StorageService };
+import { TProject } from "@/types/project"; +import { MediaItem } from "@/stores/media-store"; +import { IndexedDBAdapter } from "./indexeddb-adapter"; +import { OPFSAdapter } from "./opfs-adapter"; +import { + MediaFileData, + StorageConfig, + SerializedProject, + TimelineData, +} from "./types"; +import { TimelineTrack } from "@/types/timeline"; + +class StorageService { + private projectsAdapter: IndexedDBAdapter<SerializedProject>; + private config: StorageConfig; + + constructor() { + this.config = { + projectsDb: "video-editor-projects", + mediaDb: "video-editor-media", + timelineDb: "video-editor-timelines", + version: 1, + }; + + this.projectsAdapter = new IndexedDBAdapter<SerializedProject>( + this.config.projectsDb, + "projects", + this.config.version + ); + } + + // Helper to get project-specific media adapters + private getProjectMediaAdapters(projectId: string) { + const mediaMetadataAdapter = new IndexedDBAdapter<MediaFileData>( + `${this.config.mediaDb}-${projectId}`, + "media-metadata", + this.config.version + ); + + const mediaFilesAdapter = new OPFSAdapter(`media-files-${projectId}`); + + return { mediaMetadataAdapter, mediaFilesAdapter }; + } + + // Helper to get project-specific timeline adapter + private getProjectTimelineAdapter(projectId: string) { + return new IndexedDBAdapter<TimelineData>( + `${this.config.timelineDb}-${projectId}`, + "timeline", + this.config.version + ); + } + + // Project operations + async saveProject(project: TProject): Promise<void> { + // Convert TProject to serializable format + const serializedProject: SerializedProject = { + id: project.id, + name: project.name, + thumbnail: project.thumbnail, + createdAt: project.createdAt.toISOString(), + updatedAt: project.updatedAt.toISOString(), + }; + + await this.projectsAdapter.set(project.id, serializedProject); + } + + async loadProject(id: string): Promise<TProject | null> { + const serializedProject = await this.projectsAdapter.get(id); + + if (!serializedProject) return null; + + // Convert back to TProject format + return { + id: serializedProject.id, + name: serializedProject.name, + thumbnail: serializedProject.thumbnail, + createdAt: new Date(serializedProject.createdAt), + updatedAt: new Date(serializedProject.updatedAt), + }; + } + + async loadAllProjects(): Promise<TProject[]> { + const projectIds = await this.projectsAdapter.list(); + const projects: TProject[] = []; + + for (const id of projectIds) { + const project = await this.loadProject(id); + if (project) { + projects.push(project); + } + } + + // Sort by last updated (most recent first) + return projects.sort( + (a, b) => b.updatedAt.getTime() - a.updatedAt.getTime() + ); + } + + async deleteProject(id: string): Promise<void> { + await this.projectsAdapter.remove(id); + } + + // Media operations - now project-specific + async saveMediaItem(projectId: string, mediaItem: MediaItem): Promise<void> { + const { mediaMetadataAdapter, mediaFilesAdapter } = + this.getProjectMediaAdapters(projectId); + + // Save file to project-specific OPFS + await mediaFilesAdapter.set(mediaItem.id, mediaItem.file); + + // Save metadata to project-specific IndexedDB + const metadata: MediaFileData = { + id: mediaItem.id, + name: mediaItem.name, + type: mediaItem.type, + size: mediaItem.file.size, + lastModified: mediaItem.file.lastModified, + width: mediaItem.width, + height: mediaItem.height, + duration: mediaItem.duration, + }; + + await mediaMetadataAdapter.set(mediaItem.id, metadata); + } + + async loadMediaItem( + projectId: string, + id: string + ): Promise<MediaItem | null> { + const { mediaMetadataAdapter, mediaFilesAdapter } = + this.getProjectMediaAdapters(projectId); + + const [file, metadata] = await Promise.all([ + mediaFilesAdapter.get(id), + mediaMetadataAdapter.get(id), + ]); + + if (!file || !metadata) return null; + + // Create new object URL for the file + const url = URL.createObjectURL(file); + + return { + id: metadata.id, + name: metadata.name, + type: metadata.type, + file, + url, + width: metadata.width, + height: metadata.height, + duration: metadata.duration, + // thumbnailUrl would need to be regenerated or cached separately + }; + } + + async loadAllMediaItems(projectId: string): Promise<MediaItem[]> { + const { mediaMetadataAdapter } = this.getProjectMediaAdapters(projectId); + + const mediaIds = await mediaMetadataAdapter.list(); + const mediaItems: MediaItem[] = []; + + for (const id of mediaIds) { + const item = await this.loadMediaItem(projectId, id); + if (item) { + mediaItems.push(item); + } + } + + return mediaItems; + } + + async deleteMediaItem(projectId: string, id: string): Promise<void> { + const { mediaMetadataAdapter, mediaFilesAdapter } = + this.getProjectMediaAdapters(projectId); + + await Promise.all([ + mediaFilesAdapter.remove(id), + mediaMetadataAdapter.remove(id), + ]); + } + + async deleteProjectMedia(projectId: string): Promise<void> { + const { mediaMetadataAdapter, mediaFilesAdapter } = + this.getProjectMediaAdapters(projectId); + + await Promise.all([ + mediaMetadataAdapter.clear(), + mediaFilesAdapter.clear(), + ]); + } + + // Timeline operations - now project-specific + async saveTimeline( + projectId: string, + tracks: TimelineTrack[] + ): Promise<void> { + const timelineAdapter = this.getProjectTimelineAdapter(projectId); + const timelineData: TimelineData = { + tracks, + lastModified: new Date().toISOString(), + }; + await timelineAdapter.set("timeline", timelineData); + } + + async loadTimeline(projectId: string): Promise<TimelineTrack[] | null> { + const timelineAdapter = this.getProjectTimelineAdapter(projectId); + const timelineData = await timelineAdapter.get("timeline"); + return timelineData ? timelineData.tracks : null; + } + + async deleteProjectTimeline(projectId: string): Promise<void> { + const timelineAdapter = this.getProjectTimelineAdapter(projectId); + await timelineAdapter.remove("timeline"); + } + + // Utility methods + async clearAllData(): Promise<void> { + // Clear all projects + await this.projectsAdapter.clear(); + + // Note: Project-specific media and timelines will be cleaned up when projects are deleted + } + + async getStorageInfo(): Promise<{ + projects: number; + isOPFSSupported: boolean; + isIndexedDBSupported: boolean; + }> { + const projectIds = await this.projectsAdapter.list(); + + return { + projects: projectIds.length, + isOPFSSupported: this.isOPFSSupported(), + isIndexedDBSupported: this.isIndexedDBSupported(), + }; + } + + async getProjectStorageInfo(projectId: string): Promise<{ + mediaItems: number; + hasTimeline: boolean; + }> { + const { mediaMetadataAdapter } = this.getProjectMediaAdapters(projectId); + const timelineAdapter = this.getProjectTimelineAdapter(projectId); + + const [mediaIds, timelineData] = await Promise.all([ + mediaMetadataAdapter.list(), + timelineAdapter.get("timeline"), + ]); + + return { + mediaItems: mediaIds.length, + hasTimeline: !!timelineData, + }; + } + + // Check browser support + isOPFSSupported(): boolean { + return OPFSAdapter.isSupported(); + } + + isIndexedDBSupported(): boolean { + return "indexedDB" in window; + } + + isFullySupported(): boolean { + return this.isIndexedDBSupported() && this.isOPFSSupported(); + } +} + +// Export singleton instance +export const storageService = new StorageService(); +export { StorageService }; diff --git a/apps/web/src/lib/storage/types.ts b/apps/web/src/lib/storage/types.ts index 7ad18e19..b662c164 100644 --- a/apps/web/src/lib/storage/types.ts +++ b/apps/web/src/lib/storage/types.ts @@ -1,4 +1,5 @@ import { TProject } from "@/types/project"; +import { TimelineTrack } from "@/types/timeline"; export interface StorageAdapter<T> { get(key: string): Promise<T | null>; @@ -20,9 +21,15 @@ export interface MediaFileData { // File will be stored separately in OPFS } +export interface TimelineData { + tracks: TimelineTrack[]; + lastModified: string; +} + export interface StorageConfig { projectsDb: string; mediaDb: string; + timelineDb: string; version: number; } diff --git a/apps/web/src/stores/media-store.ts b/apps/web/src/stores/media-store.ts index ab413b14..cb69a7e9 100644 --- a/apps/web/src/stores/media-store.ts +++ b/apps/web/src/stores/media-store.ts @@ -1,5 +1,6 @@ import { create } from "zustand"; import { storageService } from "@/lib/storage/storage-service"; +import { useProjectStore } from "./project-store"; export type MediaType = "image" | "video" | "audio"; @@ -26,11 +27,15 @@ interface MediaStore { mediaItems: MediaItem[]; isLoading: boolean; - // Actions - addMediaItem: (item: Omit<MediaItem, "id">) => Promise<void>; - removeMediaItem: (id: string) => Promise<void>; - loadAllMedia: () => Promise<void>; - clearAllMedia: () => Promise<void>; + // Actions - now require projectId + addMediaItem: ( + projectId: string, + item: Omit<MediaItem, "id"> + ) => Promise<void>; + removeMediaItem: (projectId: string, id: string) => Promise<void>; + loadProjectMedia: (projectId: string) => Promise<void>; + clearProjectMedia: (projectId: string) => Promise<void>; + clearAllMedia: () => void; // Clear local state only } // Helper function to determine file type @@ -153,7 +158,7 @@ export const useMediaStore = create<MediaStore>((set, get) => ({ mediaItems: [], isLoading: false, - addMediaItem: async (item) => { + addMediaItem: async (projectId, item) => { const newItem: MediaItem = { ...item, id: crypto.randomUUID(), @@ -166,7 +171,7 @@ export const useMediaStore = create<MediaStore>((set, get) => ({ // Save to persistent storage in background try { - await storageService.saveMediaItem(newItem); + await storageService.saveMediaItem(projectId, newItem); } catch (error) { console.error("Failed to save media item:", error); // Remove from local state if save failed @@ -176,7 +181,7 @@ export const useMediaStore = create<MediaStore>((set, get) => ({ } }, - removeMediaItem: async (id: string) => { + removeMediaItem: async (projectId, id: string) => { const state = get(); const item = state.mediaItems.find((media) => media.id === id); @@ -195,17 +200,17 @@ export const useMediaStore = create<MediaStore>((set, get) => ({ // Remove from persistent storage try { - await storageService.deleteMediaItem(id); + await storageService.deleteMediaItem(projectId, id); } catch (error) { console.error("Failed to delete media item:", error); } }, - loadAllMedia: async () => { + loadProjectMedia: async (projectId) => { set({ isLoading: true }); try { - const mediaItems = await storageService.loadAllMediaItems(); + const mediaItems = await storageService.loadAllMediaItems(projectId); set({ mediaItems }); } catch (error) { console.error("Failed to load media items:", error); @@ -214,7 +219,7 @@ export const useMediaStore = create<MediaStore>((set, get) => ({ } }, - clearAllMedia: async () => { + clearProjectMedia: async (projectId) => { const state = get(); // Cleanup all object URLs @@ -234,10 +239,27 @@ export const useMediaStore = create<MediaStore>((set, get) => ({ try { const mediaIds = state.mediaItems.map((item) => item.id); await Promise.all( - mediaIds.map((id) => storageService.deleteMediaItem(id)) + mediaIds.map((id) => storageService.deleteMediaItem(projectId, id)) ); } catch (error) { console.error("Failed to clear media items from storage:", error); } }, + + clearAllMedia: () => { + const state = get(); + + // Cleanup all object URLs + state.mediaItems.forEach((item) => { + if (item.url) { + URL.revokeObjectURL(item.url); + } + if (item.thumbnailUrl) { + URL.revokeObjectURL(item.thumbnailUrl); + } + }); + + // Clear local state + set({ mediaItems: [] }); + }, })); diff --git a/apps/web/src/stores/project-store.ts b/apps/web/src/stores/project-store.ts index 4fff83c2..9570f4a0 100644 --- a/apps/web/src/stores/project-store.ts +++ b/apps/web/src/stores/project-store.ts @@ -2,6 +2,8 @@ import { TProject } from "@/types/project"; import { create } from "zustand";
import { storageService } from "@/lib/storage/storage-service";
import { toast } from "sonner";
+import { useMediaStore } from "./media-store";
+import { useTimelineStore } from "./timeline-store";
interface ProjectStore {
activeProject: TProject | null;
@@ -53,13 +55,28 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({ set({ isLoading: true });
}
+ // Clear media and timeline immediately to prevent flickering when switching projects
+ const mediaStore = useMediaStore.getState();
+ const timelineStore = useTimelineStore.getState();
+ mediaStore.clearAllMedia();
+ timelineStore.clearTimeline();
+
try {
const project = await storageService.loadProject(id);
if (project) {
set({ activeProject: project });
+
+ // Load project-specific data in parallel
+ await Promise.all([
+ mediaStore.loadProjectMedia(id),
+ timelineStore.loadProjectTimeline(id),
+ ]);
+ } else {
+ throw new Error(`Project with id ${id} not found`);
}
} catch (error) {
console.error("Failed to load project:", error);
+ throw error; // Re-throw so the editor page can handle it
} finally {
set({ isLoading: false });
}
@@ -70,7 +87,12 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({ if (!activeProject) return;
try {
- await storageService.saveProject(activeProject);
+ // Save project metadata and timeline data in parallel
+ const timelineStore = useTimelineStore.getState();
+ await Promise.all([
+ storageService.saveProject(activeProject),
+ timelineStore.saveProjectTimeline(activeProject.id),
+ ]);
await get().loadAllProjects(); // Refresh the list
} catch (error) {
console.error("Failed to save project:", error);
@@ -94,13 +116,22 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({ deleteProject: async (id: string) => {
try {
- await storageService.deleteProject(id);
+ // Delete project data in parallel
+ await Promise.all([
+ storageService.deleteProjectMedia(id),
+ storageService.deleteProjectTimeline(id),
+ storageService.deleteProject(id),
+ ]);
await get().loadAllProjects(); // Refresh the list
- // If we deleted the active project, close it
+ // If we deleted the active project, close it and clear data
const { activeProject } = get();
if (activeProject?.id === id) {
set({ activeProject: null });
+ const mediaStore = useMediaStore.getState();
+ const timelineStore = useTimelineStore.getState();
+ mediaStore.clearAllMedia();
+ timelineStore.clearTimeline();
}
} catch (error) {
console.error("Failed to delete project:", error);
@@ -109,6 +140,12 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({ closeProject: () => {
set({ activeProject: null });
+
+ // Clear data from stores when closing project
+ const mediaStore = useMediaStore.getState();
+ const timelineStore = useTimelineStore.getState();
+ mediaStore.clearAllMedia();
+ timelineStore.clearTimeline();
},
renameProject: async (id: string, name: string) => {
diff --git a/apps/web/src/stores/timeline-store.ts b/apps/web/src/stores/timeline-store.ts index c4e4e0e3..6d1a0bd1 100644 --- a/apps/web/src/stores/timeline-store.ts +++ b/apps/web/src/stores/timeline-store.ts @@ -6,9 +6,12 @@ import { TimelineTrack, sortTracksByOrder, ensureMainTrack, + validateElementTrackCompatibility, } from "@/types/timeline"; import { useEditorStore } from "./editor-store"; import { useMediaStore, getMediaAspectRatio } from "./media-store"; +import { storageService } from "@/lib/storage/storage-service"; +import { useProjectStore } from "./project-store"; // Helper function to manage element naming with suffixes const getElementNameWithSuffix = ( @@ -116,6 +119,11 @@ interface TimelineStore { undo: () => void; redo: () => void; pushHistory: () => void; + + // Persistence actions + loadProjectTimeline: (projectId: string) => Promise<void>; + saveProjectTimeline: (projectId: string) => Promise<void>; + clearTimeline: () => void; } export const useTimelineStore = create<TimelineStore>((set, get) => { @@ -129,6 +137,25 @@ export const useTimelineStore = create<TimelineStore>((set, get) => { }); }; + // Helper to auto-save timeline changes + const autoSaveTimeline = async () => { + const activeProject = useProjectStore.getState().activeProject; + if (activeProject) { + try { + await storageService.saveTimeline(activeProject.id, get()._tracks); + } catch (error) { + console.error("Failed to auto-save timeline:", error); + } + } + }; + + // Helper to update tracks and auto-save + const updateTracksAndSave = (newTracks: TimelineTrack[]) => { + updateTracks(newTracks); + // Auto-save in background + setTimeout(autoSaveTimeline, 100); + }; + // Initialize with proper track ordering const initialTracks = ensureMainTrack([]); const sortedInitialTracks = sortTracksByOrder(initialTracks); @@ -158,7 +185,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => { const { history, redoStack, _tracks } = get(); if (history.length === 0) return; const prev = history[history.length - 1]; - updateTracks(prev); + updateTracksAndSave(prev); set({ history: history.slice(0, -1), redoStack: [...redoStack, JSON.parse(JSON.stringify(_tracks))], @@ -224,7 +251,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => { muted: false, }; - updateTracks([...get()._tracks, newTrack]); + updateTracksAndSave([...get()._tracks, newTrack]); return newTrack.id; }, @@ -251,13 +278,13 @@ export const useTimelineStore = create<TimelineStore>((set, get) => { const newTracks = [...get()._tracks]; newTracks.splice(index, 0, newTrack); - updateTracks(newTracks); + updateTracksAndSave(newTracks); return newTrack.id; }, removeTrack: (trackId) => { get().pushHistory(); - updateTracks(get()._tracks.filter((track) => track.id !== trackId)); + updateTracksAndSave(get()._tracks.filter((track) => track.id !== trackId)); }, addElementToTrack: (trackId, elementData) => { @@ -270,17 +297,10 @@ export const useTimelineStore = create<TimelineStore>((set, get) => { return; } - // Validate element can be added to this track type - if (track.type === "media" && elementData.type !== "media") { - console.error("Media track only accepts media elements"); - return; - } - if (track.type === "text" && elementData.type !== "text") { - console.error("Text track only accepts text elements"); - return; - } - if (track.type === "audio" && elementData.type !== "media") { - console.error("Audio track only accepts media elements"); + // Use utility function for validation + const validation = validateElementTrackCompatibility(elementData, track); + if (!validation.isValid) { + console.error(validation.errorMessage); return; } @@ -331,7 +351,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => { } } - updateTracks( + updateTracksAndSave( get()._tracks.map((track) => track.id === trackId ? { ...track, elements: [...track.elements, newElement] } @@ -342,7 +362,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => { removeElementFromTrack: (trackId, elementId) => { get().pushHistory(); - updateTracks( + updateTracksAndSave( get() ._tracks.map((track) => track.id === trackId @@ -362,11 +382,22 @@ export const useTimelineStore = create<TimelineStore>((set, get) => { get().pushHistory(); const fromTrack = get()._tracks.find((track) => track.id === fromTrackId); + const toTrack = get()._tracks.find((track) => track.id === toTrackId); const elementToMove = fromTrack?.elements.find( (element) => element.id === elementId ); - if (!elementToMove) return; + if (!elementToMove || !toTrack) return; + + // Validate element type compatibility with target track + const validation = validateElementTrackCompatibility( + elementToMove, + toTrack + ); + if (!validation.isValid) { + console.error(validation.errorMessage); + return; + } const newTracks = get() ._tracks.map((track) => { @@ -387,12 +418,12 @@ export const useTimelineStore = create<TimelineStore>((set, get) => { }) .filter((track) => track.elements.length > 0); - updateTracks(newTracks); + updateTracksAndSave(newTracks); }, updateElementTrim: (trackId, elementId, trimStart, trimEnd) => { get().pushHistory(); - updateTracks( + updateTracksAndSave( get()._tracks.map((track) => track.id === trackId ? { @@ -410,7 +441,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => { updateElementStartTime: (trackId, elementId, startTime) => { get().pushHistory(); - updateTracks( + updateTracksAndSave( get()._tracks.map((track) => track.id === trackId ? { @@ -426,7 +457,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => { toggleTrackMute: (trackId) => { get().pushHistory(); - updateTracks( + updateTracksAndSave( get()._tracks.map((track) => track.id === trackId ? { ...track, muted: !track.muted } : track ) @@ -456,7 +487,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => { const secondElementId = crypto.randomUUID(); - updateTracks( + updateTracksAndSave( get()._tracks.map((track) => track.id === trackId ? { @@ -508,7 +539,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => { const durationToRemove = element.duration - element.trimStart - element.trimEnd - relativeTime; - updateTracks( + updateTracksAndSave( get()._tracks.map((track) => track.id === trackId ? { @@ -547,7 +578,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => { const relativeTime = splitTime - element.startTime; - updateTracks( + updateTracksAndSave( get()._tracks.map((track) => track.id === trackId ? { @@ -584,7 +615,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => { if (existingAudioTrack) { // Add audio element to existing audio track - updateTracks( + updateTracksAndSave( get()._tracks.map((track) => track.id === existingAudioTrack.id ? { @@ -617,7 +648,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => { muted: false, }; - updateTracks([...get()._tracks, newAudioTrack]); + updateTracksAndSave([...get()._tracks, newAudioTrack]); } return audioElementId; @@ -645,7 +676,7 @@ export const useTimelineStore = create<TimelineStore>((set, get) => { const { redoStack } = get(); if (redoStack.length === 0) return; const next = redoStack[redoStack.length - 1]; - updateTracks(next); + updateTracksAndSave(next); set({ redoStack: redoStack.slice(0, -1) }); }, @@ -706,5 +737,41 @@ export const useTimelineStore = create<TimelineStore>((set, get) => { }, }); }, + + // Persistence methods + loadProjectTimeline: async (projectId) => { + try { + const tracks = await storageService.loadTimeline(projectId); + if (tracks) { + updateTracks(tracks); + } else { + // No timeline saved yet, initialize with default + const defaultTracks = ensureMainTrack([]); + updateTracks(defaultTracks); + } + // Clear history when loading a project + set({ history: [], redoStack: [] }); + } catch (error) { + console.error("Failed to load timeline:", error); + // Initialize with default on error + const defaultTracks = ensureMainTrack([]); + updateTracks(defaultTracks); + set({ history: [], redoStack: [] }); + } + }, + + saveProjectTimeline: async (projectId) => { + try { + await storageService.saveTimeline(projectId, get()._tracks); + } catch (error) { + console.error("Failed to save timeline:", error); + } + }, + + clearTimeline: () => { + const defaultTracks = ensureMainTrack([]); + updateTracks(defaultTracks); + set({ history: [], redoStack: [], selectedElements: [] }); + }, }; }); diff --git a/apps/web/src/types/project.ts b/apps/web/src/types/project.ts index 8b1eff3f..e208be3a 100644 --- a/apps/web/src/types/project.ts +++ b/apps/web/src/types/project.ts @@ -1,7 +1,8 @@ -export interface TProject {
- id: string;
- name: string;
- thumbnail: string;
- createdAt: Date;
- updatedAt: Date;
-}
+export interface TProject { + id: string; + name: string; + thumbnail: string; + createdAt: Date; + updatedAt: Date; + mediaItems?: string[]; +} diff --git a/apps/web/src/types/timeline.ts b/apps/web/src/types/timeline.ts index 7079f091..a759de21 100644 --- a/apps/web/src/types/timeline.ts +++ b/apps/web/src/types/timeline.ts @@ -123,3 +123,34 @@ export function ensureMainTrack(tracks: TimelineTrack[]): TimelineTrack[] { return tracks; } + +// Timeline validation utilities +export function canElementGoOnTrack( + elementType: "text" | "media", + trackType: TrackType +): boolean { + if (elementType === "text") { + return trackType === "text"; + } else if (elementType === "media") { + return trackType === "media" || trackType === "audio"; + } + return false; +} + +export function validateElementTrackCompatibility( + element: { type: "text" | "media" }, + track: { type: TrackType } +): { isValid: boolean; errorMessage?: string } { + const isValid = canElementGoOnTrack(element.type, track.type); + + if (!isValid) { + const errorMessage = + element.type === "text" + ? "Text elements can only be placed on text tracks" + : "Media elements can only be placed on media or audio tracks"; + + return { isValid: false, errorMessage }; + } + + return { isValid: true }; +} |
