From 3224dd974aa0a4906cae5642e8479a3618c49c03 Mon Sep 17 00:00:00 2001 From: DevloperAmanSingh Date: Thu, 26 Jun 2025 23:40:50 +0530 Subject: [PATCH 01/12] feat: add clip splitting and audio separation functionality to timeline store --- apps/web/src/stores/timeline-store.ts | 209 +++++++++++++++++++++++++- 1 file changed, 202 insertions(+), 7 deletions(-) diff --git a/apps/web/src/stores/timeline-store.ts b/apps/web/src/stores/timeline-store.ts index 3b068d7..a6da4e2 100644 --- a/apps/web/src/stores/timeline-store.ts +++ b/apps/web/src/stores/timeline-store.ts @@ -74,10 +74,28 @@ interface TimelineStore { ) => void; toggleTrackMute: (trackId: string) => void; + // Split operations + splitClip: ( + trackId: string, + clipId: string, + splitTime: number + ) => string | null; + splitAndKeepLeft: ( + trackId: string, + clipId: string, + splitTime: number + ) => void; + splitAndKeepRight: ( + trackId: string, + clipId: string, + splitTime: number + ) => void; + separateAudio: (trackId: string, clipId: string) => string | null; + // Computed values getTotalDuration: () => number; - // New actions + // History actions undo: () => void; redo: () => void; pushHistory: () => void; @@ -91,10 +109,9 @@ export const useTimelineStore = create((set, get) => ({ pushHistory: () => { const { tracks, history, redoStack } = get(); - // Deep copy tracks set({ history: [...history, JSON.parse(JSON.stringify(tracks))], - redoStack: [], // Clear redo stack when new action is performed + redoStack: [], }); }, @@ -105,7 +122,7 @@ export const useTimelineStore = create((set, get) => ({ set({ tracks: prev, history: history.slice(0, -1), - redoStack: [...redoStack, JSON.parse(JSON.stringify(tracks))], // Add current state to redo stack + redoStack: [...redoStack, JSON.parse(JSON.stringify(tracks))], }); }, @@ -115,7 +132,6 @@ export const useTimelineStore = create((set, get) => ({ (c) => c.trackId === trackId && c.clipId === clipId ); if (multi) { - // Toggle selection return exists ? { selectedClips: state.selectedClips.filter( @@ -128,6 +144,7 @@ export const useTimelineStore = create((set, get) => ({ } }); }, + deselectClip: (trackId, clipId) => { set((state) => ({ selectedClips: state.selectedClips.filter( @@ -135,6 +152,7 @@ export const useTimelineStore = create((set, get) => ({ ), })); }, + clearSelectedClips: () => { set({ selectedClips: [] }); }, @@ -194,7 +212,6 @@ export const useTimelineStore = create((set, get) => ({ } : track ) - // Remove track if it becomes empty .filter((track) => track.clips.length > 0), })); }, @@ -223,7 +240,6 @@ export const useTimelineStore = create((set, get) => ({ } return track; }) - // Remove track if it becomes empty .filter((track) => track.clips.length > 0), }; }); @@ -270,6 +286,185 @@ export const useTimelineStore = create((set, get) => ({ })); }, + splitClip: (trackId, clipId, splitTime) => { + const { tracks } = get(); + const track = tracks.find((t) => t.id === trackId); + const clip = track?.clips.find((c) => c.id === clipId); + + if (!clip) return null; + + const effectiveStart = clip.startTime; + const effectiveEnd = + clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd); + + if (splitTime <= effectiveStart || splitTime >= effectiveEnd) return null; + + get().pushHistory(); + + const relativeTime = splitTime - clip.startTime; + const firstDuration = relativeTime; + const secondDuration = + clip.duration - clip.trimStart - clip.trimEnd - relativeTime; + + const secondClipId = crypto.randomUUID(); + + set((state) => ({ + tracks: state.tracks.map((track) => + track.id === trackId + ? { + ...track, + clips: track.clips.flatMap((c) => + c.id === clipId + ? [ + { + ...c, + trimEnd: c.trimEnd + secondDuration, + name: c.name + " (left)", + }, + { + ...c, + id: secondClipId, + startTime: splitTime, + trimStart: c.trimStart + firstDuration, + name: c.name + " (right)", + }, + ] + : [c] + ), + } + : track + ), + })); + + return secondClipId; + }, + + splitAndKeepLeft: (trackId, clipId, splitTime) => { + const { tracks } = get(); + const track = tracks.find((t) => t.id === trackId); + const clip = track?.clips.find((c) => c.id === clipId); + + if (!clip) return; + + const effectiveStart = clip.startTime; + const effectiveEnd = + clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd); + + if (splitTime <= effectiveStart || splitTime >= effectiveEnd) return; + + get().pushHistory(); + + const relativeTime = splitTime - clip.startTime; + const durationToRemove = + clip.duration - clip.trimStart - clip.trimEnd - relativeTime; + + set((state) => ({ + tracks: state.tracks.map((track) => + track.id === trackId + ? { + ...track, + clips: track.clips.map((c) => + c.id === clipId + ? { + ...c, + trimEnd: c.trimEnd + durationToRemove, + name: c.name + " (left)", + } + : c + ), + } + : track + ), + })); + }, + + splitAndKeepRight: (trackId, clipId, splitTime) => { + const { tracks } = get(); + const track = tracks.find((t) => t.id === trackId); + const clip = track?.clips.find((c) => c.id === clipId); + + if (!clip) return; + + const effectiveStart = clip.startTime; + const effectiveEnd = + clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd); + + if (splitTime <= effectiveStart || splitTime >= effectiveEnd) return; + + get().pushHistory(); + + const relativeTime = splitTime - clip.startTime; + + set((state) => ({ + tracks: state.tracks.map((track) => + track.id === trackId + ? { + ...track, + clips: track.clips.map((c) => + c.id === clipId + ? { + ...c, + startTime: splitTime, + trimStart: c.trimStart + relativeTime, + name: c.name + " (right)", + } + : c + ), + } + : track + ), + })); + }, + + separateAudio: (trackId, clipId) => { + const { tracks } = get(); + const track = tracks.find((t) => t.id === trackId); + const clip = track?.clips.find((c) => c.id === clipId); + + if (!clip || track?.type !== "video") return null; + + get().pushHistory(); + + let audioTrackId = tracks.find((t) => t.type === "audio")?.id; + + if (!audioTrackId) { + audioTrackId = crypto.randomUUID(); + const newAudioTrack: TimelineTrack = { + id: audioTrackId, + name: "Audio Track", + type: "audio", + clips: [], + muted: false, + }; + + set((state) => ({ + tracks: [...state.tracks, newAudioTrack], + })); + } + + const audioClipId = crypto.randomUUID(); + + set((state) => ({ + tracks: state.tracks.map((track) => + track.id === audioTrackId + ? { + ...track, + clips: [ + ...track.clips, + { + ...clip, + id: audioClipId, + name: clip.name + " (audio)", + }, + ], + } + : track + ), + })); + + return audioClipId; + }, + getTotalDuration: () => { const { tracks } = get(); if (tracks.length === 0) return 0; From b799615654eb5225365726fd736eeb0fbd5c776e Mon Sep 17 00:00:00 2001 From: DevloperAmanSingh Date: Thu, 26 Jun 2025 23:40:56 +0530 Subject: [PATCH 02/12] feat: enhance playback controls with keyboard shortcuts for clip manipulation and audio separation --- apps/web/src/hooks/use-playback-controls.ts | 176 ++++++++++++++++++-- 1 file changed, 166 insertions(+), 10 deletions(-) diff --git a/apps/web/src/hooks/use-playback-controls.ts b/apps/web/src/hooks/use-playback-controls.ts index 5742e27..23bffe1 100644 --- a/apps/web/src/hooks/use-playback-controls.ts +++ b/apps/web/src/hooks/use-playback-controls.ts @@ -1,18 +1,174 @@ import { useEffect } from "react"; import { usePlaybackStore } from "@/stores/playback-store"; +import { useTimelineStore } from "@/stores/timeline-store"; +import { toast } from "sonner"; -export function usePlaybackControls() { - const { toggle } = usePlaybackStore(); +export const usePlaybackControls = () => { + const { isPlaying, currentTime, play, pause, seek } = usePlaybackStore(); + + const { + selectedClips, + tracks, + splitClip, + splitAndKeepLeft, + splitAndKeepRight, + separateAudio, + } = useTimelineStore(); useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if (e.code === "Space" && e.target === document.body) { - e.preventDefault(); - toggle(); + const handleKeyPress = (e: KeyboardEvent) => { + if ( + e.target instanceof HTMLInputElement || + e.target instanceof HTMLTextAreaElement + ) { + return; + } + + switch (e.key) { + case " ": + e.preventDefault(); + if (isPlaying) { + pause(); + } else { + play(); + } + break; + + case "s": + if (e.ctrlKey || e.metaKey) { + e.preventDefault(); + handleSplitSelectedClip(); + } + break; + + case "q": + if (e.ctrlKey || e.metaKey) { + e.preventDefault(); + handleSplitAndKeepLeft(); + } + break; + + case "w": + if (e.ctrlKey || e.metaKey) { + e.preventDefault(); + handleSplitAndKeepRight(); + } + break; + + case "d": + if (e.ctrlKey || e.metaKey) { + e.preventDefault(); + handleSeparateAudio(); + } + break; } }; - document.addEventListener("keydown", handleKeyDown); - return () => document.removeEventListener("keydown", handleKeyDown); - }, [toggle]); -} \ No newline at end of file + const handleSplitSelectedClip = () => { + if (selectedClips.length !== 1) { + toast.error("Select exactly one clip to split"); + return; + } + + const { trackId, clipId } = selectedClips[0]; + const track = tracks.find((t) => t.id === trackId); + const clip = track?.clips.find((c) => c.id === clipId); + + if (!clip) return; + + const effectiveStart = clip.startTime; + const effectiveEnd = + clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd); + + if (currentTime <= effectiveStart || currentTime >= effectiveEnd) { + toast.error("Playhead must be within selected clip"); + return; + } + + splitClip(trackId, clipId, currentTime); + toast.success("Clip split at playhead"); + }; + + const handleSplitAndKeepLeft = () => { + if (selectedClips.length !== 1) { + toast.error("Select exactly one clip"); + return; + } + + const { trackId, clipId } = selectedClips[0]; + const track = tracks.find((t) => t.id === trackId); + const clip = track?.clips.find((c) => c.id === clipId); + + if (!clip) return; + + const effectiveStart = clip.startTime; + const effectiveEnd = + clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd); + + if (currentTime <= effectiveStart || currentTime >= effectiveEnd) { + toast.error("Playhead must be within selected clip"); + return; + } + + splitAndKeepLeft(trackId, clipId, currentTime); + toast.success("Split and kept left portion"); + }; + + const handleSplitAndKeepRight = () => { + if (selectedClips.length !== 1) { + toast.error("Select exactly one clip"); + return; + } + + const { trackId, clipId } = selectedClips[0]; + const track = tracks.find((t) => t.id === trackId); + const clip = track?.clips.find((c) => c.id === clipId); + + if (!clip) return; + + const effectiveStart = clip.startTime; + const effectiveEnd = + clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd); + + if (currentTime <= effectiveStart || currentTime >= effectiveEnd) { + toast.error("Playhead must be within selected clip"); + return; + } + + splitAndKeepRight(trackId, clipId, currentTime); + toast.success("Split and kept right portion"); + }; + + const handleSeparateAudio = () => { + if (selectedClips.length !== 1) { + toast.error("Select exactly one video clip to separate audio"); + return; + } + + const { trackId, clipId } = selectedClips[0]; + const track = tracks.find((t) => t.id === trackId); + + if (!track || track.type !== "video") { + toast.error("Select a video clip to separate audio"); + return; + } + + separateAudio(trackId, clipId); + toast.success("Audio separated to audio track"); + }; + + document.addEventListener("keydown", handleKeyPress); + return () => document.removeEventListener("keydown", handleKeyPress); + }, [ + isPlaying, + currentTime, + selectedClips, + tracks, + play, + pause, + splitClip, + splitAndKeepLeft, + splitAndKeepRight, + separateAudio, + ]); +}; From 12a2ec59fd4eef9792dcfcf15bc77ea398420c3f Mon Sep 17 00:00:00 2001 From: DevloperAmanSingh Date: Thu, 26 Jun 2025 23:41:01 +0530 Subject: [PATCH 03/12] feat: implement clip splitting and audio separation features with keyboard shortcuts in timeline component --- apps/web/src/components/editor/timeline.tsx | 233 ++++++++++++++------ 1 file changed, 166 insertions(+), 67 deletions(-) diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx index ae6b98d..b1eac6f 100644 --- a/apps/web/src/components/editor/timeline.tsx +++ b/apps/web/src/components/editor/timeline.tsx @@ -39,7 +39,6 @@ import { import AudioWaveform from "./audio-waveform"; - export function Timeline() { // Timeline shows all tracks (video, audio, effects) and their clips. // You can drag media here to add it to your project. @@ -58,6 +57,10 @@ export function Timeline() { updateClipTrim, undo, redo, + splitClip, + splitAndKeepLeft, + splitAndKeepRight, + separateAudio, } = useTimelineStore(); const { mediaItems, addMediaItem } = useMediaStore(); const { @@ -217,7 +220,7 @@ export function Timeline() { const bx2 = clamp(x2, 0, rect.width); const by1 = clamp(y1, 0, rect.height); const by2 = clamp(y2, 0, rect.height); - let newSelection: { trackId: string; clipId: string; }[] = []; + let newSelection: { trackId: string; clipId: string }[] = []; tracks.forEach((track, trackIdx) => { track.clips.forEach((clip) => { const effectiveDuration = clip.duration - clip.trimStart - clip.trimEnd; @@ -453,33 +456,28 @@ export function Timeline() { toast.error("No clips selected"); return; } + + let splitCount = 0; selectedClips.forEach(({ trackId, clipId }) => { const track = tracks.find((t) => t.id === trackId); const clip = track?.clips.find((c) => c.id === clipId); if (clip && track) { - const splitTime = currentTime; const effectiveStart = clip.startTime; const effectiveEnd = clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd); - if (splitTime > effectiveStart && splitTime < effectiveEnd) { - updateClipTrim( - track.id, - clip.id, - clip.trimStart, - clip.trimEnd + (effectiveEnd - splitTime) - ); - addClipToTrack(track.id, { - mediaId: clip.mediaId, - name: clip.name + " (split)", - duration: clip.duration, - startTime: splitTime, - trimStart: clip.trimStart + (splitTime - effectiveStart), - trimEnd: clip.trimEnd, - }); + + if (currentTime > effectiveStart && currentTime < effectiveEnd) { + const newClipId = splitClip(trackId, clipId, currentTime); + if (newClipId) splitCount++; } } }); - toast.success("Split selected clip(s)"); + + if (splitCount > 0) { + toast.success(`Split ${splitCount} clip(s) at playhead`); + } else { + toast.error("Playhead must be within selected clips to split"); + } }; const handleDuplicateSelected = () => { @@ -530,6 +528,94 @@ export function Timeline() { toast.success("Freeze frame added for selected clip(s)"); }; + const handleSplitAndKeepLeft = () => { + if (selectedClips.length === 0) { + toast.error("No clips selected"); + return; + } + + let splitCount = 0; + selectedClips.forEach(({ trackId, clipId }) => { + const track = tracks.find((t) => t.id === trackId); + const clip = track?.clips.find((c) => c.id === clipId); + if (clip && track) { + const effectiveStart = clip.startTime; + const effectiveEnd = + clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd); + + if (currentTime > effectiveStart && currentTime < effectiveEnd) { + splitAndKeepLeft(trackId, clipId, currentTime); + splitCount++; + } + } + }); + + if (splitCount > 0) { + toast.success(`Split and kept left portion of ${splitCount} clip(s)`); + } else { + toast.error("Playhead must be within selected clips"); + } + }; + + const handleSplitAndKeepRight = () => { + if (selectedClips.length === 0) { + toast.error("No clips selected"); + return; + } + + let splitCount = 0; + selectedClips.forEach(({ trackId, clipId }) => { + const track = tracks.find((t) => t.id === trackId); + const clip = track?.clips.find((c) => c.id === clipId); + if (clip && track) { + const effectiveStart = clip.startTime; + const effectiveEnd = + clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd); + + if (currentTime > effectiveStart && currentTime < effectiveEnd) { + splitAndKeepRight(trackId, clipId, currentTime); + splitCount++; + } + } + }); + + if (splitCount > 0) { + toast.success(`Split and kept right portion of ${splitCount} clip(s)`); + } else { + toast.error("Playhead must be within selected clips"); + } + }; + + const handleSeparateAudio = () => { + if (selectedClips.length === 0) { + toast.error("No clips selected"); + return; + } + + let separatedCount = 0; + selectedClips.forEach(({ trackId, clipId }) => { + const track = tracks.find((t) => t.id === trackId); + const clip = track?.clips.find((c) => c.id === clipId); + const mediaItem = mediaItems.find((item) => item.id === clip?.mediaId); + + if ( + clip && + track && + mediaItem?.type === "video" && + track.type === "video" + ) { + const audioClipId = separateAudio(trackId, clipId); + if (audioClipId) separatedCount++; + } + }); + + if (separatedCount > 0) { + toast.success(`Separated audio from ${separatedCount} video clip(s)`); + } else { + toast.error("Select video clips to separate audio"); + } + }; + const handleDeleteSelected = () => { if (selectedClips.length === 0) { toast.error("No clips selected"); @@ -597,8 +683,9 @@ export function Timeline() {
{/* Time Display */} -
{currentTime.toFixed(1)}s / {duration.toFixed(1)}s
@@ -641,34 +728,42 @@ export function Timeline() { - Split clip (S) + Split clip (Ctrl+S) - - Split and keep left (A) + Split and keep left (Ctrl+Q) - - Split and keep right (D) + Split and keep right (Ctrl+W) - - Separate audio (E) + Separate audio (Ctrl+D) @@ -781,17 +876,19 @@ export function Timeline() { return (
{(() => { const formatTime = (seconds: number) => { @@ -852,12 +949,13 @@ export function Timeline() { >
{track.name} @@ -1197,7 +1295,7 @@ function TimelineTrackContent({ }); }; - const updateTrimFromMouseMove = (e: { clientX: number; }) => { + const updateTrimFromMouseMove = (e: { clientX: number }) => { if (!resizing) return; const clip = track.clips.find((c) => c.id === resizing.clipId); @@ -1632,18 +1730,18 @@ function TimelineTrackContent({ } if (mediaItem.type === "audio") { - return ( -
-
- + return ( +
+
+ +
-
- ); - } + ); + } // Fallback for videos without thumbnails return ( @@ -1681,12 +1779,13 @@ function TimelineTrackContent({ return (
{ e.preventDefault(); // Only show track menu if we didn't click on a clip @@ -1710,12 +1809,13 @@ function TimelineTrackContent({
{track.clips.length === 0 ? (
{isDropping ? wouldOverlap @@ -1860,4 +1960,3 @@ function TimelineTrackContent({
); } - From efdd2aa6edd092ff81d6d7cf38e57cc5b58df285 Mon Sep 17 00:00:00 2001 From: DevloperAmanSingh Date: Thu, 26 Jun 2025 23:41:07 +0530 Subject: [PATCH 04/12] feat: add dropdown menu for clip options including split and audio separation in timeline component --- .../src/components/editor/timeline-clip.tsx | 269 ++++++++++++------ 1 file changed, 185 insertions(+), 84 deletions(-) diff --git a/apps/web/src/components/editor/timeline-clip.tsx b/apps/web/src/components/editor/timeline-clip.tsx index 4c0d3b7..b5b8d20 100644 --- a/apps/web/src/components/editor/timeline-clip.tsx +++ b/apps/web/src/components/editor/timeline-clip.tsx @@ -2,7 +2,15 @@ import { useState } from "react"; import { Button } from "../ui/button"; -import { MoreVertical, Scissors, Trash2 } from "lucide-react"; +import { + MoreVertical, + Scissors, + Trash2, + SplitSquareHorizontal, + Music, + ChevronRight, + ChevronLeft, +} from "lucide-react"; import { useMediaStore } from "@/stores/media-store"; import { useTimelineStore } from "@/stores/timeline-store"; import { usePlaybackStore } from "@/stores/playback-store"; @@ -10,6 +18,17 @@ import { useDragClip } from "@/hooks/use-drag-clip"; import AudioWaveform from "./audio-waveform"; import { toast } from "sonner"; import { TimelineClipProps, ResizeState } from "@/types/timeline"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, +} from "../ui/dropdown-menu"; +import { isDragging } from "motion/react"; export function TimelineClip({ clip, @@ -21,8 +40,16 @@ export function TimelineClip({ onClipClick, }: TimelineClipProps) { const { mediaItems } = useMediaStore(); - const { updateClipTrim, addClipToTrack, removeClipFromTrack, dragState } = - useTimelineStore(); + const { + updateClipTrim, + addClipToTrack, + removeClipFromTrack, + dragState, + splitClip, + splitAndKeepLeft, + splitAndKeepRight, + separateAudio, + } = useTimelineStore(); const { currentTime } = usePlaybackStore(); const [resizing, setResizing] = useState(null); @@ -31,7 +58,6 @@ export function TimelineClip({ const effectiveDuration = clip.duration - clip.trimStart - clip.trimEnd; const clipWidth = Math.max(80, effectiveDuration * 50 * zoomLevel); - // Use real-time position during drag, otherwise use stored position const isBeingDragged = dragState.clipId === clip.id; const clipStartTime = isBeingDragged && dragState.isDragging @@ -107,44 +133,85 @@ export function TimelineClip({ const handleDeleteClip = () => { removeClipFromTrack(track.id, clip.id); setClipMenuOpen(false); + toast.success("Clip deleted"); }; const handleSplitClip = () => { - // Use current playback time as split point - const splitTime = currentTime; - // Only split if splitTime is within the clip's effective range const effectiveStart = clip.startTime; const effectiveEnd = clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd); - if (splitTime <= effectiveStart || splitTime >= effectiveEnd) { + if (currentTime <= effectiveStart || currentTime >= effectiveEnd) { toast.error("Playhead must be within clip to split"); return; } - const firstDuration = splitTime - effectiveStart; - const secondDuration = effectiveEnd - splitTime; - - // First part: adjust original clip - updateClipTrim( - track.id, - clip.id, - clip.trimStart, - clip.trimEnd + secondDuration - ); - - // Second part: add new clip after split - addClipToTrack(track.id, { - mediaId: clip.mediaId, - name: clip.name + " (cut)", - duration: clip.duration, - startTime: splitTime, - trimStart: clip.trimStart + firstDuration, - trimEnd: clip.trimEnd, - }); - + const secondClipId = splitClip(track.id, clip.id, currentTime); + if (secondClipId) { + toast.success("Clip split successfully"); + } else { + toast.error("Failed to split clip"); + } setClipMenuOpen(false); - toast.success("Clip split successfully"); + }; + + const handleSplitAndKeepLeft = () => { + const effectiveStart = clip.startTime; + const effectiveEnd = + clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd); + + if (currentTime <= effectiveStart || currentTime >= effectiveEnd) { + toast.error("Playhead must be within clip"); + return; + } + + splitAndKeepLeft(track.id, clip.id, currentTime); + toast.success("Split and kept left portion"); + setClipMenuOpen(false); + }; + + const handleSplitAndKeepRight = () => { + const effectiveStart = clip.startTime; + const effectiveEnd = + clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd); + + if (currentTime <= effectiveStart || currentTime >= effectiveEnd) { + toast.error("Playhead must be within clip"); + return; + } + + splitAndKeepRight(track.id, clip.id, currentTime); + toast.success("Split and kept right portion"); + setClipMenuOpen(false); + }; + + const handleSeparateAudio = () => { + const mediaItem = mediaItems.find((item) => item.id === clip.mediaId); + + if (!mediaItem || mediaItem.type !== "video") { + toast.error("Audio separation only available for video clips"); + return; + } + + const audioClipId = separateAudio(track.id, clip.id); + if (audioClipId) { + toast.success("Audio separated to audio track"); + } else { + toast.error("Failed to separate audio"); + } + setClipMenuOpen(false); + }; + + const canSplitAtPlayhead = () => { + const effectiveStart = clip.startTime; + const effectiveEnd = + clip.startTime + (clip.duration - clip.trimStart - clip.trimEnd); + return currentTime > effectiveStart && currentTime < effectiveEnd; + }; + + const canSeparateAudio = () => { + const mediaItem = mediaItems.find((item) => item.id === clip.mediaId); + return mediaItem?.type === "video" && track.type === "video"; }; const renderClipContent = () => { @@ -201,76 +268,110 @@ export function TimelineClip({ ); } - // Fallback for videos without thumbnails return ( {clip.name} ); }; + const handleClipMouseDown = (e: React.MouseEvent) => { + if (onClipMouseDown) { + onClipMouseDown(e, clip); + } + }; + return (
onClipMouseDown(e, clip)} - onClick={(e) => onClipClick(e, clip)} - onMouseMove={handleResizeMove} - onMouseUp={handleResizeEnd} - onMouseLeave={handleResizeEnd} - tabIndex={0} - onContextMenu={(e) => onContextMenu(e, clip.id)} + className={`absolute top-0 h-full select-none transition-all duration-75 ${ + isBeingDragged ? "z-50" : "z-10" + } ${isSelected ? "ring-2 ring-primary" : ""}`} + style={{ + left: `${clipLeft}px`, + width: `${clipWidth}px`, + }} + onMouseMove={resizing ? handleResizeMove : undefined} + onMouseUp={resizing ? handleResizeEnd : undefined} + onMouseLeave={resizing ? handleResizeEnd : undefined} > - {/* Left trim handle */}
handleResizeStart(e, clip.id, "left")} - /> + className={`relative h-full rounded border cursor-pointer overflow-hidden ${getTrackColor( + track.type + )} ${isSelected ? "ring-2 ring-primary ring-offset-1" : ""}`} + onClick={(e) => onClipClick && onClipClick(e, clip)} + onMouseDown={handleClipMouseDown} + onContextMenu={(e) => onContextMenu && onContextMenu(e, clip.id)} + > +
+ {renderClipContent()} +
- {/* Clip content */} -
- {renderClipContent()} +
handleResizeStart(e, clip.id, "left")} + /> +
handleResizeStart(e, clip.id, "right")} + /> - {/* Clip options menu */} -
- - - {clipMenuOpen && ( -
e.stopPropagation()} - > - - + + + + + + Split + + + + + Split at Playhead + + + + Split and Keep Left + + + + Split and Keep Right + + + + + {canSeparateAudio() && ( + <> + + + + Separate Audio + + + )} + + + - Delete - -
- )} + + Delete Clip + + +
- - {/* Right trim handle */} -
handleResizeStart(e, clip.id, "right")} - />
); } From 7ca5bcfa50e58e4a661072a5561b1e548f1dfed7 Mon Sep 17 00:00:00 2001 From: DevloperAmanSingh Date: Thu, 26 Jun 2025 23:46:33 +0530 Subject: [PATCH 05/12] feat: add comments for drag handling and resizing in timeline clip component --- apps/web/src/components/editor/timeline-clip.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/web/src/components/editor/timeline-clip.tsx b/apps/web/src/components/editor/timeline-clip.tsx index b5b8d20..6b97411 100644 --- a/apps/web/src/components/editor/timeline-clip.tsx +++ b/apps/web/src/components/editor/timeline-clip.tsx @@ -58,6 +58,7 @@ export function TimelineClip({ const effectiveDuration = clip.duration - clip.trimStart - clip.trimEnd; const clipWidth = Math.max(80, effectiveDuration * 50 * zoomLevel); + // Use real-time position during drag, otherwise use stored position const isBeingDragged = dragState.clipId === clip.id; const clipStartTime = isBeingDragged && dragState.isDragging @@ -78,6 +79,7 @@ export function TimelineClip({ } }; + // Resize handles for trimming clips const handleResizeStart = ( e: React.MouseEvent, clipId: string, @@ -329,6 +331,7 @@ export function TimelineClip({ + {/* Split operations - only available when playhead is within clip */} @@ -350,6 +353,7 @@ export function TimelineClip({ + {/* Audio separation - only available for video clips */} {canSeparateAudio() && ( <> From f74bebeb8b95891a6e0ea0cfe6a67b786dc826de Mon Sep 17 00:00:00 2001 From: DevloperAmanSingh Date: Thu, 26 Jun 2025 23:46:38 +0530 Subject: [PATCH 06/12] feat: add comment for clip editing operations in timeline component --- apps/web/src/components/editor/timeline.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/src/components/editor/timeline.tsx b/apps/web/src/components/editor/timeline.tsx index b1eac6f..b20006d 100644 --- a/apps/web/src/components/editor/timeline.tsx +++ b/apps/web/src/components/editor/timeline.tsx @@ -722,6 +722,7 @@ export function Timeline() {
+ {/* Clip editing operations */} @@ -747,11 +734,7 @@ export function Timeline() { - @@ -1021,6 +1004,16 @@ export function Timeline() { y: e.clientY, }); }} + onClick={(e) => { + // If clicking empty area (not on a clip), deselect all clips + if ( + !(e.target as HTMLElement).closest(".timeline-clip") + ) { + const { clearSelectedClips } = + useTimelineStore.getState(); + clearSelectedClips(); + } + }} > )} {isDragOver && ( -
-
Drop media here to add a new track
+
+
+ {isProcessing + ? `Processing ${progress}%` + : "Drop media here to add to timeline"} +
)}
@@ -1081,22 +1072,17 @@ export function Timeline() { setContextMenu(null); }} > - {(() => { - const track = tracks.find( - (t) => t.id === contextMenu.trackId - ); - return track?.muted ? ( - <> - - Unmute Track - - ) : ( - <> - - Mute Track - - ); - })()} + {contextMenu.trackId ? ( +
+ + Unmute Track +
+ ) : ( +
+ + Mute Track +
+ )}
- {clipMenuOpen === clip.id && ( -
- - -
- )} -
-
- {/* Right trim handle */} -
handleResizeStart(e, clip.id, "right")} - /> -
+ clip={clip} + track={track} + zoomLevel={zoomLevel} + isSelected={isSelected} + onContextMenu={handleClipContextMenu} + onClipMouseDown={handleClipMouseDown} + onClipClick={handleClipClick} + /> ); })} - - {/* Drop position indicator */} - {isDraggedOver && dropPosition !== null && ( -
-
-
-
- {wouldOverlap ? "⚠️" : ""} - {dropPosition.toFixed(1)}s -
-
- )} )}