From 12a2ec59fd4eef9792dcfcf15bc77ea398420c3f Mon Sep 17 00:00:00 2001 From: DevloperAmanSingh Date: Thu, 26 Jun 2025 23:41:01 +0530 Subject: [PATCH] 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({
); } -