diff --git a/frontend/app/lib/videoStorage.ts b/frontend/app/lib/videoStorage.ts index 7cb5fd7..a024b9c 100644 --- a/frontend/app/lib/videoStorage.ts +++ b/frontend/app/lib/videoStorage.ts @@ -89,6 +89,24 @@ export async function clearVideos(): Promise { }); } +export async function addVideo(video: StoredVideo): Promise { + const db = await openDB(); + const transaction = db.transaction(STORE_NAME, "readwrite"); + const store = transaction.objectStore(STORE_NAME); + store.put(video); + + return new Promise((resolve, reject) => { + transaction.oncomplete = () => { + db.close(); + resolve(); + }; + transaction.onerror = () => { + db.close(); + reject(transaction.error); + }; + }); +} + const THUMBNAIL_WIDTH = 80; const THUMBNAIL_HEIGHT = 60; diff --git a/frontend/app/video-editor/page.tsx b/frontend/app/video-editor/page.tsx index eb967c4..0c9ff4b 100644 --- a/frontend/app/video-editor/page.tsx +++ b/frontend/app/video-editor/page.tsx @@ -8,6 +8,8 @@ import { loadProcessedVideo, saveProcessedVideo, saveSessionContext, + addVideo, + generateThumbnails, ProcessedVideoInfo, TranscriptSegment, type StoredColorGradeOverride, @@ -98,6 +100,7 @@ interface SnapPoint { interface DragState { clipId: string; channelId: string; + targetChannelId: string; // Channel the clip will be dropped into (can be different from source) clip: TimelineClip; originalIndex: number; offsetX: number; @@ -764,8 +767,11 @@ export default function VideoEditor() { const transitionVideoRef = useRef(null); const previewCanvasRef = useRef(null); const timelineRef = useRef(null); + const rulerScrollRef = useRef(null); + const tracksScrollRef = useRef(null); const seekTimeRef = useRef(0); const highlightInputRef = useRef(null); + const importInputRef = useRef(null); const canvasRef = useRef(null); const exportVideoRef = useRef(null); const animationFrameRef = useRef(null); @@ -880,6 +886,81 @@ export default function VideoEditor() { [channels] ); + // Handle importing additional videos + const handleImportVideos = useCallback(async (e: React.ChangeEvent) => { + const files = e.target.files; + if (!files || files.length === 0) return; + + for (const file of Array.from(files)) { + // Check file type + if (!file.type.startsWith("video/")) { + console.warn(`Skipping non-video file: ${file.name}`); + continue; + } + + try { + // Generate thumbnails and get duration + const { thumbnails, duration } = await generateThumbnails(file); + + // Create unique ID + const id = `imported-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`; + + // Save to IndexedDB + await addVideo({ + id, + name: file.name, + file, + thumbnails, + duration, + }); + + // Create object URL for playback + const localUrl = URL.createObjectURL(file); + + // Add to uploadedVideos state + const newVideo: UploadedVideo = { + id, + name: file.name, + url: localUrl, + duration, + thumbnails, + }; + + setUploadedVideos(prev => [...prev, newVideo]); + + // Add as a new clip at the end of the timeline + const videoChannel = channels.find(ch => ch.type === "video"); + if (videoChannel) { + const lastClip = videoChannel.clips[videoChannel.clips.length - 1]; + const newStartTime = lastClip ? lastClip.startTime + lastClip.duration : 0; + + const newClip: TimelineClip = { + id: `clip-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`, + videoId: id, + name: file.name, + duration, + startTime: newStartTime, + sourceStart: 0, + }; + + setChannels(prev => prev.map(ch => { + if (ch.id === videoChannel.id) { + return { ...ch, clips: [...ch.clips, newClip] }; + } + return ch; + })); + } + + console.log(`[Import] Added video: ${file.name}`); + } catch (error) { + console.error(`[Import] Failed to import ${file.name}:`, error); + } + } + + // Clear the input so the same file can be imported again + e.target.value = ""; + }, [channels]); + // Generate transition layers from clips const generateTransitionLayersFromClips = useCallback( (clips: TimelineClip[]): TransitionLayerData[] => { @@ -1959,27 +2040,26 @@ export default function VideoEditor() { if (!isDraggingPlayhead) return; const handleMouseMove = (e: MouseEvent) => { + // Use timelineRef like clip dragging does - getBoundingClientRect already accounts for scroll if (!timelineRef.current) return; const rect = timelineRef.current.getBoundingClientRect(); const x = e.clientX - rect.left; const newTime = Math.max(0, Math.min(x / PIXELS_PER_SECOND, totalDuration)); - seekTimeRef.current = newTime; - setCurrentTime(newTime); - // Update video preview in real-time - const clipAtTime = findClipAtTime(newTime); - if (clipAtTime) { - const clipOffset = newTime - clipAtTime.startTime; - const primaryVideo = useTransitionAsPrimaryRef.current ? transitionVideoRef.current : videoRef.current; - if (activeClip?.id === clipAtTime.id && primaryVideo) { - primaryVideo.currentTime = clipOffset + (clipAtTime.sourceStart || 0); - } else { - setActiveClip(clipAtTime); - } - } + // Update both playhead position and video preview + // onTimeUpdate is disabled during drag so this won't cause feedback loop + setCurrentTime(newTime); + setSeekTime(newTime); }; - const handleMouseUp = () => { + const handleMouseUp = (e: MouseEvent) => { + // On mouse up, do a proper seek to sync video position + if (timelineRef.current) { + const rect = timelineRef.current.getBoundingClientRect(); + const x = e.clientX - rect.left; + const finalTime = Math.max(0, Math.min(x / PIXELS_PER_SECOND, totalDuration)); + setSeekTime(finalTime); + } setIsDraggingPlayhead(false); }; @@ -1990,7 +2070,38 @@ export default function VideoEditor() { document.removeEventListener("mousemove", handleMouseMove); document.removeEventListener("mouseup", handleMouseUp); }; - }, [isDraggingPlayhead, totalDuration, findClipAtTime, activeClip?.id]); + }, [isDraggingPlayhead, totalDuration]); + + // Sync horizontal scroll between ruler and tracks + useEffect(() => { + const rulerScroll = rulerScrollRef.current; + const tracksScroll = tracksScrollRef.current; + if (!rulerScroll || !tracksScroll) return; + + let isSyncing = false; + + const syncRulerToTracks = () => { + if (isSyncing) return; + isSyncing = true; + tracksScroll.scrollLeft = rulerScroll.scrollLeft; + requestAnimationFrame(() => { isSyncing = false; }); + }; + + const syncTracksToRuler = () => { + if (isSyncing) return; + isSyncing = true; + rulerScroll.scrollLeft = tracksScroll.scrollLeft; + requestAnimationFrame(() => { isSyncing = false; }); + }; + + rulerScroll.addEventListener('scroll', syncRulerToTracks); + tracksScroll.addEventListener('scroll', syncTracksToRuler); + + return () => { + rulerScroll.removeEventListener('scroll', syncRulerToTracks); + tracksScroll.removeEventListener('scroll', syncTracksToRuler); + }; + }, []); // Clip dragging handlers const handleClipMouseDown = ( @@ -2007,6 +2118,7 @@ export default function VideoEditor() { setDraggingClip({ clipId: clip.id, channelId, + targetChannelId: channelId, clip: { ...clip }, originalIndex, offsetX, @@ -2033,6 +2145,24 @@ export default function VideoEditor() { const hasMoved = draggingClip.hasMoved || Math.abs(rawX - draggingClip.startX) > DRAG_THRESHOLD; + // Detect which channel the mouse is over + const sourceChannel = channels.find(c => c.id === draggingClip.channelId); + let targetChannelId = draggingClip.targetChannelId; + + // Find track elements and check which one contains the mouse Y position + const trackElements = document.querySelectorAll('[data-channel-id]'); + trackElements.forEach((el) => { + const trackRect = el.getBoundingClientRect(); + if (e.clientY >= trackRect.top && e.clientY <= trackRect.bottom) { + const channelId = el.getAttribute('data-channel-id'); + const channelType = el.getAttribute('data-channel-type'); + // Only allow dropping on channels of the same type + if (channelId && channelType === sourceChannel?.type) { + targetChannelId = channelId; + } + } + }); + if (hasMoved) { // Always snap (hold Alt to temporarily disable) const tempDisableSnap = e.altKey; @@ -2050,40 +2180,75 @@ export default function VideoEditor() { // Clamp ghost time to valid range ghostTime = Math.max(0, ghostTime); - // Calculate drop index based on ghost position + // Calculate drop index based on ghost position in the TARGET channel const dropIndex = calculateDropIndex( - draggingClip.channelId, + targetChannelId, ghostTime, draggingClip.clip.duration, draggingClip.clipId, draggingClip.originalIndex ); - // If drop index changed, immediately reorder the clips - if (dropIndex !== draggingClip.dropIndex) { + // If drop index or target channel changed, update the clips + const targetChanged = targetChannelId !== draggingClip.targetChannelId; + const dropIndexChanged = dropIndex !== draggingClip.dropIndex; + + if (targetChanged || dropIndexChanged) { setChannels((prevChannels) => { - return prevChannels.map((channel) => { - if (channel.id !== draggingClip.channelId) return channel; + // If moving to a different channel + if (targetChannelId !== draggingClip.channelId) { + return prevChannels.map((channel) => { + if (channel.id === draggingClip.channelId) { + // Remove from source channel + const remainingClips = channel.clips.filter(c => c.id !== draggingClip.clipId); + let currentStartTime = 0; + const updatedClips = remainingClips.map(clip => { + const updated = { ...clip, startTime: currentStartTime }; + currentStartTime += clip.duration; + return updated; + }); + return { ...channel, clips: updatedClips }; + } + if (channel.id === targetChannelId) { + // Add to target channel + const draggedClip = { ...draggingClip.clip }; + const newClips = [...channel.clips]; + newClips.splice(dropIndex, 0, draggedClip); + let currentStartTime = 0; + const updatedClips = newClips.map(clip => { + const updated = { ...clip, startTime: currentStartTime }; + currentStartTime += clip.duration; + return updated; + }); + return { ...channel, clips: updatedClips }; + } + return channel; + }); + } else { + // Same channel reordering + return prevChannels.map((channel) => { + if (channel.id !== draggingClip.channelId) return channel; - const otherClips = channel.clips.filter(c => c.id !== draggingClip.clipId); - const draggedClip = channel.clips.find(c => c.id === draggingClip.clipId); + const otherClips = channel.clips.filter(c => c.id !== draggingClip.clipId); + const draggedClip = channel.clips.find(c => c.id === draggingClip.clipId); - if (!draggedClip) return channel; + if (!draggedClip) return channel; - // Insert at new drop index - const reorderedClips = [...otherClips]; - reorderedClips.splice(dropIndex, 0, draggedClip); + // Insert at new drop index + const reorderedClips = [...otherClips]; + reorderedClips.splice(dropIndex, 0, draggedClip); - // Recalculate start times sequentially - let currentStartTime = 0; - const updatedClips = reorderedClips.map(clip => { - const updated = { ...clip, startTime: currentStartTime }; - currentStartTime += clip.duration; - return updated; - }); + // Recalculate start times sequentially + let currentStartTime = 0; + const updatedClips = reorderedClips.map(clip => { + const updated = { ...clip, startTime: currentStartTime }; + currentStartTime += clip.duration; + return updated; + }); - return { ...channel, clips: updatedClips }; - }); + return { ...channel, clips: updatedClips }; + }); + } }); } @@ -2094,9 +2259,10 @@ export default function VideoEditor() { ghostTime, snappedTo, dropIndex, + targetChannelId, } : null); } else { - setDraggingClip(prev => prev ? { ...prev, currentX: rawX } : null); + setDraggingClip(prev => prev ? { ...prev, currentX: rawX, targetChannelId } : null); } }; @@ -3122,31 +3288,53 @@ export default function VideoEditor() { {/* Video Preview Area */}
- {/* Export Button - Top Right */} + {/* Hidden file input for importing videos */} + + + {/* Export & Import Buttons - Top Right */}
- +
+ + +
Color grade {colorGradeLabel} @@ -3176,7 +3364,7 @@ export default function VideoEditor() { isPlaying={isPlaying} colorGradeFilter={colorGradeFilter} seekTime={seekTime} - onTimeUpdate={setCurrentTime} + onTimeUpdate={isDraggingPlayhead ? undefined : setCurrentTime} onClipChange={(clip) => setActiveClip(clip)} onPlaybackEnd={() => setIsPlaying(false)} /> @@ -3269,133 +3457,132 @@ export default function VideoEditor() {
{/* Timeline Section */} -
-
- {/* Fixed track labels column */} -
- {/* Ruler spacer */} -
- {/* Track labels */} -
- {channels.map((channel) => ( +
+ {/* Fixed header row: ruler spacer + time ruler */} +
+ {/* Ruler spacer (aligned with track labels) */} +
+ {/* Time ruler (scrolls horizontally with tracks) */} +
+
+ {/* Time markers */} + {timeMarkers.map((t) => (
-
- {channel.type === "video" && ( - - - - )} - {channel.type === "audio" && ( - - - - )} - {channel.type === "subtitle" && ( - - - - )} - {channel.type === "transition" && ( - - - - )} - {channel.name} -
- {(channel.type !== "video" || channels.filter(c => c.type === "video").length > 1) && ( - - )} +
+ + {Math.floor(t)} +
))} -
-
- - {/* Scrollable timeline content (ruler + tracks scroll together) */} -
-
- {/* Playhead - spans entire timeline height */} + {/* Playhead triangle in ruler */}
-
+
+
+
+
+
- {/* Time ruler */} + {/* Scrollable tracks area (labels + content scroll together vertically) */} +
+ {/* Fixed track labels column */} +
+ {channels.map((channel) => (
- {/* Time markers */} - {timeMarkers.map((t) => ( -
+ {channel.type === "video" && ( + + + + )} + {channel.type === "audio" && ( + + + + )} + {channel.type === "subtitle" && ( + + + + )} + {channel.type === "transition" && ( + + + + )} + {channel.name} +
+ {(channel.type !== "video" || channels.filter(c => c.type === "video").length > 1) && ( + + )} +
+ ))} +
+ + {/* Scrollable timeline tracks content - overflow-y-clip prevents independent vertical scrolling */} +
+
+ {/* Playhead - spans entire tracks height */} +
+ {/* Hover highlight */} +
+ {/* Visible yellow line */} +
{/* Tracks */} - {channels.map((channel) => ( + {channels.map((channel) => { + const sourceChannel = draggingClip ? channels.find(c => c.id === draggingClip.channelId) : null; + const isValidDropTarget = draggingClip && draggingClip.hasMoved && + sourceChannel?.type === channel.type && + channel.id !== draggingClip.channelId; + const isCurrentTarget = draggingClip?.targetChannelId === channel.id; + + return (
- {/* Drop zone indicators */} - {draggingClip && draggingClip.hasMoved && draggingClip.channelId === channel.id && (() => { + {/* Drop zone indicators - show on target channel */} + {draggingClip && draggingClip.hasMoved && draggingClip.targetChannelId === channel.id && (() => { const otherClips = channel.clips.filter(c => c.id !== draggingClip.clipId); const dropZones: { position: number; isActive: boolean }[] = []; @@ -3586,15 +3773,137 @@ export default function VideoEditor() { ); })} + {/* Faded original for cross-channel drag - show in source channel */} + {draggingClip && draggingClip.hasMoved && + draggingClip.channelId === channel.id && + draggingClip.targetChannelId !== channel.id && (() => { + const video = uploadedVideos.find((v) => v.id === draggingClip.clip.videoId); + const clipGap = 4; + return ( +
+
+ {channel.type === "video" && video?.thumbnails && video.thumbnails.length > 0 ? ( +
+ {video.thumbnails.map((thumb, i) => ( + + ))} +
+ ) : ( +
+ {draggingClip.clip.name} +
+ )} +
+
+ ); + })()} + + {/* Ghost preview for cross-channel drag - show in target channel */} + {draggingClip && draggingClip.hasMoved && + draggingClip.targetChannelId === channel.id && + draggingClip.channelId !== channel.id && (() => { + const video = uploadedVideos.find((v) => v.id === draggingClip.clip.videoId); + const clipGap = 4; + return ( +
+
+
+ {channel.type === "video" && video?.thumbnails && video.thumbnails.length > 0 ? ( +
+ {video.thumbnails.map((thumb, i) => ( + + ))} +
+ ) : ( +
+ {draggingClip.clip.name} +
+ )} +
+ + {/* Snap indicator */} + {draggingClip.snappedTo && ( +
+ {draggingClip.snappedTo.type === "playhead" ? "Playhead" : + draggingClip.snappedTo.type === "clip-start" ? "Clip Start" : + draggingClip.snappedTo.type === "clip-end" ? "Clip End" : + `${draggingClip.snappedTo.time.toFixed(1)}s`} +
+ )} + + {/* Duration badge */} +
+ {formatTimeShort(draggingClip.clip.duration)} +
+
+ + {/* Snap line */} + {draggingClip.snappedTo && ( +
+ )} +
+ ); + })()} + {/* Clips */} {channel.clips.map((clip) => { const video = uploadedVideos.find((v) => v.id === clip.videoId); const clipGap = 4; // Gap between clips const isDragging = draggingClip?.clipId === clip.id && draggingClip.hasMoved; + const isSameChannelDrag = isDragging && draggingClip.targetChannelId === draggingClip.channelId; - // If this clip is being dragged, show it semi-transparent at original position + // If this clip is being dragged within the same channel, show it semi-transparent at original position // and show ghost at new position - if (isDragging) { + if (isSameChannelDrag) { return (
{/* Original position (faded) */} @@ -3856,7 +4165,8 @@ export default function VideoEditor() { })}
- ))} + ); + })}