From 68645696c2745ae5d99754b3e275877e38bc53b4 Mon Sep 17 00:00:00 2001 From: Fred He Date: Sun, 25 Jan 2026 01:04:11 -0500 Subject: [PATCH 1/6] dragging pointer --- frontend/app/video-editor/page.tsx | 46 ++++++++++++++++-------------- 1 file changed, 24 insertions(+), 22 deletions(-) diff --git a/frontend/app/video-editor/page.tsx b/frontend/app/video-editor/page.tsx index eb967c4..19b8217 100644 --- a/frontend/app/video-editor/page.tsx +++ b/frontend/app/video-editor/page.tsx @@ -1959,27 +1959,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 +1989,7 @@ export default function VideoEditor() { document.removeEventListener("mousemove", handleMouseMove); document.removeEventListener("mouseup", handleMouseUp); }; - }, [isDraggingPlayhead, totalDuration, findClipAtTime, activeClip?.id]); + }, [isDraggingPlayhead, totalDuration]); // Clip dragging handlers const handleClipMouseDown = ( @@ -3176,7 +3175,7 @@ export default function VideoEditor() { isPlaying={isPlaying} colorGradeFilter={colorGradeFilter} seekTime={seekTime} - onTimeUpdate={setCurrentTime} + onTimeUpdate={isDraggingPlayhead ? undefined : setCurrentTime} onClipChange={(clip) => setActiveClip(clip)} onPlaybackEnd={() => setIsPlaying(false)} /> @@ -3323,15 +3322,18 @@ export default function VideoEditor() { {/* Scrollable timeline content (ruler + tracks scroll together) */}
- {/* Playhead - spans entire timeline height */} + {/* Playhead - spans entire timeline height, fully draggable */}
-
+ {/* Hover highlight */} +
+ {/* Visible yellow line */} +
+ {/* Triangle handle at top */} +
From d6515fef29daf7a5a50ce3c12a821f77bead6c9c Mon Sep 17 00:00:00 2001 From: Fred He Date: Sun, 25 Jan 2026 01:16:59 -0500 Subject: [PATCH 2/6] timeline good --- frontend/app/lib/videoStorage.ts | 18 ++ frontend/app/video-editor/page.tsx | 366 +++++++++++++++++++---------- 2 files changed, 258 insertions(+), 126 deletions(-) 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 19b8217..4767bce 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, @@ -764,8 +766,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 +885,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[] => { @@ -1991,6 +2071,37 @@ export default function VideoEditor() { }; }, [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 = ( e: React.MouseEvent, @@ -3121,31 +3232,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} @@ -3269,60 +3402,96 @@ 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)} +
))} + {/* Playhead triangle in ruler */} +
+
+
+
+
+
+
+ + {/* Scrollable tracks area (labels + content scroll together vertically) */} +
+ {/* Fixed track labels column */} +
+ {channels.map((channel) => ( +
+
+ {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 content (ruler + tracks scroll together) */} -
+ {/* Scrollable timeline tracks content */} +
- {/* Playhead - spans entire timeline height, fully draggable */} + {/* Playhead - spans entire tracks height */}
{/* Visible yellow line */}
- {/* Triangle handle at top */} -
-
-
-
- - {/* Time ruler */} -
- {/* Time markers */} - {timeMarkers.map((t) => ( -
-
- - {Math.floor(t)} - -
- ))} - - {/* Snap point indicators during drag */} - {draggingClip && draggingClip.hasMoved && (() => { - const snapPoints = getSnapPoints(draggingClip.clipId); - const ghostStart = draggingClip.ghostTime; - const ghostEnd = ghostStart + draggingClip.clip.duration; - const visibleRange = 3; - - return snapPoints - .filter(p => - (Math.abs(p.time - ghostStart) < visibleRange || - Math.abs(p.time - ghostEnd) < visibleRange) && - p.type !== "playhead" - ) - .map((point, i) => ( -
- {draggingClip.snappedTo?.time === point.time && ( -
- )} -
- )); - })()}
{/* Tracks */} {channels.map((channel) => (
{/* Drop zone indicators */} From ff7cb567d966dd595798efbfdedca4bf4fe925f4 Mon Sep 17 00:00:00 2001 From: Fred He Date: Sun, 25 Jan 2026 01:18:40 -0500 Subject: [PATCH 3/6] timeline good --- frontend/app/video-editor/page.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/app/video-editor/page.tsx b/frontend/app/video-editor/page.tsx index 4767bce..a067cae 100644 --- a/frontend/app/video-editor/page.tsx +++ b/frontend/app/video-editor/page.tsx @@ -3488,8 +3488,8 @@ export default function VideoEditor() { ))}
- {/* Scrollable timeline tracks content */} -
+ {/* Scrollable timeline tracks content - overflow-y-clip prevents independent vertical scrolling */} +
{/* Playhead - spans entire tracks height */}
Date: Sun, 25 Jan 2026 11:04:57 -0500 Subject: [PATCH 4/6] subtitle box --- frontend/app/video-editor/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/app/video-editor/page.tsx b/frontend/app/video-editor/page.tsx index a067cae..b81ad2f 100644 --- a/frontend/app/video-editor/page.tsx +++ b/frontend/app/video-editor/page.tsx @@ -3443,7 +3443,7 @@ export default function VideoEditor() {
{/* Scrollable tracks area (labels + content scroll together vertically) */} -
+
{/* Fixed track labels column */}
{channels.map((channel) => ( From fd28a8281930cda9803b1c123ea938b961033eae Mon Sep 17 00:00:00 2001 From: Fred He Date: Sun, 25 Jan 2026 11:23:49 -0500 Subject: [PATCH 5/6] subtitle box --- frontend/app/video-editor/page.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/app/video-editor/page.tsx b/frontend/app/video-editor/page.tsx index b81ad2f..11f2f41 100644 --- a/frontend/app/video-editor/page.tsx +++ b/frontend/app/video-editor/page.tsx @@ -3401,7 +3401,7 @@ export default function VideoEditor() {
{/* Timeline Section */} -
+
{/* Fixed header row: ruler spacer + time ruler */}
{/* Ruler spacer (aligned with track labels) */} @@ -3443,7 +3443,7 @@ export default function VideoEditor() {
{/* Scrollable tracks area (labels + content scroll together vertically) */} -
+
{/* Fixed track labels column */}
{channels.map((channel) => ( From 2c3d40b2823a9748e6132792edbb9a1d42901b1a Mon Sep 17 00:00:00 2001 From: Fred He Date: Sun, 25 Jan 2026 11:28:12 -0500 Subject: [PATCH 6/6] dragging across channel --- frontend/app/video-editor/page.tsx | 252 +++++++++++++++++++++++++---- 1 file changed, 223 insertions(+), 29 deletions(-) diff --git a/frontend/app/video-editor/page.tsx b/frontend/app/video-editor/page.tsx index 11f2f41..0c9ff4b 100644 --- a/frontend/app/video-editor/page.tsx +++ b/frontend/app/video-editor/page.tsx @@ -100,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; @@ -2117,6 +2118,7 @@ export default function VideoEditor() { setDraggingClip({ clipId: clip.id, channelId, + targetChannelId: channelId, clip: { ...clip }, originalIndex, offsetX, @@ -2143,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; @@ -2160,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 }; + }); + } }); } @@ -2204,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); } }; @@ -3504,14 +3560,29 @@ export default function VideoEditor() {
{/* 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 }[] = []; @@ -3702,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) */} @@ -3972,7 +4165,8 @@ export default function VideoEditor() { })}
- ))} + ); + })}