Skip to content

Commit 3cd7a45

Browse files
AmirMohammad CheraghaliAmirMohammad Cheraghali
authored andcommitted
feat: Implement Non-Linear Editing (NLE) with multi-segment support
1 parent 792beaa commit 3cd7a45

4 files changed

Lines changed: 319 additions & 103 deletions

File tree

src/components/RecorderControls.tsx

Lines changed: 71 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { useRef, useState } from 'react';
22
import {
33
Play, Pause, Circle, Square,
4-
Upload, Save, Film, Pencil, Scissors, ChevronDown, Trash2
4+
Upload, Save, Film, Pencil, Scissors, ChevronDown, Trash2, Timer, Gauge
55
} from 'lucide-react';
66
import type { RecordedSession } from '../types';
77
import { VideoTimeline } from './VideoTimeline';
@@ -12,6 +12,8 @@ interface RecorderControlsProps {
1212
recordingTime: number;
1313
playbackTime: number;
1414
session: RecordedSession | null;
15+
segments?: any[]; // Typed as any[] to avoid import cycle for now, or use TimelineSegment
16+
updateSegment?: (id: string, updates: any) => void;
1517
playbackSpeed: number;
1618

1719
startRecording: () => void;
@@ -29,6 +31,7 @@ interface RecorderControlsProps {
2931
deleteEventsByType: (type: string, fromTime?: number, toTime?: number) => void;
3032
deleteEventsByTimeRange: (fromTime: number, toTime: number) => void;
3133
splitSession: (splitTime: number) => { firstSession: any; secondSession: any } | null;
34+
adjustSessionSpeed: (startTime: number, endTime: number, speedFactor: number) => void;
3235

3336
isLightMode: boolean;
3437
cardBg: string;
@@ -42,10 +45,10 @@ const formatTime = (ms: number) => {
4245
};
4346

4447
export const RecorderControls = ({
45-
isRecording, isPlaying, recordingTime, playbackTime, session, playbackSpeed,
48+
isRecording, isPlaying, recordingTime, playbackTime, session, segments, updateSegment, playbackSpeed,
4649
startRecording, stopRecording, play, pause, seek, setPlaybackSpeed,
4750
exportSession, importSession, exportVideo, updateMetadata,
48-
trimSession, deleteEvent, deleteEventsByType, deleteEventsByTimeRange, splitSession,
51+
trimSession, deleteEvent, deleteEventsByType, deleteEventsByTimeRange, splitSession, adjustSessionSpeed,
4952
isLightMode, cardBg
5053
}: RecorderControlsProps) => {
5154

@@ -57,6 +60,8 @@ export const RecorderControls = ({
5760

5861
// Visual trim mode
5962
const [isTrimMode, setIsTrimMode] = useState(false);
63+
const [isSpeedMode, setIsSpeedMode] = useState(false);
64+
const [speedFactor, setSpeedFactor] = useState(2);
6065
const [tempTrimStart, setTempTrimStart] = useState<number>(0);
6166
const [tempTrimEnd, setTempTrimEnd] = useState<number>(0);
6267

@@ -188,6 +193,8 @@ export const RecorderControls = ({
188193
<div className="pt-2">
189194
<VideoTimeline
190195
session={session}
196+
segments={segments}
197+
onSegmentUpdate={updateSegment}
191198
playbackTime={playbackTime}
192199
isPlaying={isPlaying}
193200
onSeek={seek}
@@ -361,11 +368,70 @@ export const RecorderControls = ({
361368
</span>
362369
</div>
363370
</div>
371+
372+
{/* Speed Control (Feature 3) */}
373+
<div className="space-y-2">
374+
<label className={`${styles.text} block`}>Playback Speed FX</label>
375+
<div className="flex gap-2 items-center flex-wrap">
376+
<button
377+
onClick={() => {
378+
if (isTrimMode) setIsTrimMode(false);
379+
setIsSpeedMode(!isSpeedMode);
380+
}}
381+
className={`px-3 py-1.5 rounded text-xs transition-colors flex items-center gap-1 ${isSpeedMode
382+
? 'bg-orange-600 hover:bg-orange-700 text-white'
383+
: 'bg-orange-600/20 hover:bg-orange-600/40 border border-orange-600/30'
384+
}`}
385+
>
386+
<Gauge className="w-3 h-3" />
387+
{isSpeedMode ? 'Select Range' : 'Adjust Speed'}
388+
</button>
389+
390+
{isSpeedMode && (
391+
<>
392+
<div className="flex bg-black/40 rounded p-1 gap-1">
393+
{[0.5, 1.5, 2, 4].map(speed => (
394+
<button
395+
key={speed}
396+
onClick={() => setSpeedFactor(speed)}
397+
className={`px-2 py-0.5 rounded text-xs transition-colors ${speedFactor === speed
398+
? 'bg-white/20 text-white'
399+
: 'hover:bg-white/10 text-white/50'
400+
}`}
401+
>
402+
{speed}x
403+
</button>
404+
))}
405+
</div>
406+
407+
<button
408+
onClick={() => {
409+
adjustSessionSpeed(tempTrimStart, tempTrimEnd, speedFactor);
410+
setIsSpeedMode(false);
411+
setIsEditPanelOpen(false);
412+
}}
413+
className="px-3 py-1.5 bg-purple-600 hover:bg-purple-700 rounded text-xs transition-colors"
414+
>
415+
Apply
416+
</button>
417+
</>
418+
)}
419+
</div>
420+
{isSpeedMode && (
421+
<div className="text-xs opacity-60 flex gap-2 items-center">
422+
<Timer className="w-3 h-3" />
423+
<span>
424+
Applying {speedFactor}x speed from {formatTime(tempTrimStart)} to {formatTime(tempTrimEnd)}
425+
</span>
426+
</div>
427+
)}
428+
</div>
364429
</div>
365430
)}
366431
</div>
367-
)}
368-
</div>
432+
)
433+
}
434+
</div >
369435
);
370436
};
371437

src/components/VideoTimeline.tsx

Lines changed: 99 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import { useState, useRef, useEffect } from 'react';
2-
import type { RecordedSession } from '../types';
2+
import type { RecordedSession, TimelineSegment } from '../types';
33

44
interface VideoTimelineProps {
55
session: RecordedSession | null;
6+
segments?: TimelineSegment[]; // NLE Support
7+
onSegmentUpdate?: (id: string, updates: Partial<TimelineSegment>) => void;
68
playbackTime: number;
79
isPlaying: boolean;
810
onSeek: (time: number) => void;
@@ -17,6 +19,8 @@ interface VideoTimelineProps {
1719

1820
export const VideoTimeline = ({
1921
session,
22+
segments = [],
23+
onSegmentUpdate,
2024
playbackTime,
2125
onSeek,
2226
trimMode = false,
@@ -150,7 +154,7 @@ export const VideoTimeline = ({
150154
return `${m}:${s}`;
151155
};
152156

153-
// Group events into visual blocks
157+
// Group events into visual blocks, respecting segments
154158
const getEventBlocks = () => {
155159
if (!session) return [];
156160

@@ -161,26 +165,52 @@ export const VideoTimeline = ({
161165
count: number;
162166
}> = [];
163167

164-
// Group consecutive events of same type
165-
session.events.forEach((event, idx) => {
166-
const lastBlock = blocks[blocks.length - 1];
167-
const nextEvent = session.events[idx + 1];
168-
const eventEnd = nextEvent ? nextEvent.timestamp : duration;
168+
// Helper to process a list of events
169+
const processEvents = (events: { timestamp: number, type: string }[]) => {
170+
events.sort((a, b) => a.timestamp - b.timestamp).forEach((event, idx) => {
171+
const lastBlock = blocks[blocks.length - 1];
172+
const nextEvent = events[idx + 1];
173+
const eventEnd = nextEvent ? nextEvent.timestamp : duration;
174+
175+
if (lastBlock && lastBlock.type === event.type && event.timestamp - lastBlock.end < 1000) {
176+
// Extend existing block
177+
lastBlock.end = eventEnd;
178+
lastBlock.count++;
179+
} else {
180+
// Create new block
181+
blocks.push({
182+
type: event.type,
183+
start: event.timestamp,
184+
end: Math.min(event.timestamp + 500, eventEnd), // Minimum 500ms block
185+
count: 1
186+
});
187+
}
188+
});
189+
};
169190

170-
if (lastBlock && lastBlock.type === event.type && event.timestamp - lastBlock.end < 1000) {
171-
// Extend existing block
172-
lastBlock.end = eventEnd;
173-
lastBlock.count++;
174-
} else {
175-
// Create new block
176-
blocks.push({
177-
type: event.type,
178-
start: event.timestamp,
179-
end: Math.min(event.timestamp + 500, eventEnd), // Minimum 500ms block
180-
count: 1
191+
if (segments.length > 0) {
192+
// NLE Mode: Map source events to global time via segments
193+
const virtualEvents: { timestamp: number, type: string }[] = [];
194+
195+
segments.forEach(seg => {
196+
session.events.forEach(e => {
197+
// Check if event falls within the source range of this segment
198+
if (e.timestamp >= seg.sourceStartTime && e.timestamp < seg.sourceStartTime + seg.duration) {
199+
// Map to global time
200+
const offset = e.timestamp - seg.sourceStartTime;
201+
virtualEvents.push({
202+
timestamp: seg.startTime + offset,
203+
type: e.type
204+
});
205+
}
181206
});
182-
}
183-
});
207+
});
208+
209+
processEvents(virtualEvents);
210+
} else {
211+
// Legacy Mode (Linear)
212+
processEvents(session.events);
213+
}
184214

185215
return blocks;
186216
};
@@ -237,6 +267,55 @@ export const VideoTimeline = ({
237267
/>
238268
))}
239269

270+
{/* Segment Blocks (Interactive) */}
271+
{segments.length > 0 && segments.map(seg => {
272+
const left = (seg.startTime / duration) * 100;
273+
const width = (seg.duration / duration) * 100;
274+
275+
return (
276+
<div
277+
key={seg.id}
278+
className="absolute top-1 bottom-1 border-x border-white/20 bg-white/10 hover:bg-white/20 cursor-grab active:cursor-grabbing group transition-colors"
279+
style={{
280+
left: `${left}%`,
281+
width: `${width}%`
282+
}}
283+
onMouseDown={(e) => {
284+
e.stopPropagation();
285+
e.preventDefault();
286+
const startX = e.clientX;
287+
const originalStartTime = seg.startTime;
288+
289+
const handleMouseMove = (moveEvent: MouseEvent) => {
290+
if (!timelineRef.current) return;
291+
const rect = timelineRef.current.getBoundingClientRect();
292+
const deltaX = moveEvent.clientX - startX;
293+
const deltaTime = (deltaX / rect.width) * duration;
294+
295+
const newStartTime = Math.max(0, originalStartTime + deltaTime);
296+
297+
if (onSegmentUpdate) {
298+
onSegmentUpdate(seg.id, { startTime: newStartTime });
299+
}
300+
};
301+
302+
const handleMouseUp = () => {
303+
window.removeEventListener('mousemove', handleMouseMove);
304+
window.removeEventListener('mouseup', handleMouseUp);
305+
// Snap to grid or neighbors logic could go here
306+
};
307+
308+
window.addEventListener('mousemove', handleMouseMove);
309+
window.addEventListener('mouseup', handleMouseUp);
310+
}}
311+
title={`Segment: ${formatTime(seg.startTime)} - ${formatTime(seg.startTime + seg.duration)}`}
312+
>
313+
{/* Drag Handle Indicator */}
314+
<div className="absolute inset-x-2 top-1/2 h-0.5 bg-white/20 group-hover:bg-white/40" />
315+
</div>
316+
);
317+
})}
318+
240319
{/* Event Blocks */}
241320
<div className="absolute top-0 left-0 right-0 h-12">
242321
{eventBlocks.map((block, idx) => {

0 commit comments

Comments
 (0)