From 2b5b15f3e8566b8af9bfa63ee727435ce4d077ca Mon Sep 17 00:00:00 2001 From: Siddharth Date: Thu, 27 Nov 2025 16:35:21 -0700 Subject: [PATCH] basic trim setup --- src/components/video-editor/VideoEditor.tsx | 59 ++++- src/components/video-editor/timeline/Item.tsx | 44 +++- .../timeline/ItemGlass.module.css | 26 ++ src/components/video-editor/timeline/Row.tsx | 2 +- .../video-editor/timeline/TimelineEditor.tsx | 233 ++++++++++++++---- src/components/video-editor/types.ts | 6 + 6 files changed, 316 insertions(+), 54 deletions(-) diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index fc4a041..e9b2d1f 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -19,6 +19,7 @@ import { type ZoomDepth, type ZoomFocus, type ZoomRegion, + type TrimRegion, type CropRegion, } from "./types"; import { VideoExporter, type ExportProgress } from "@/lib/exporter"; @@ -39,6 +40,8 @@ export default function VideoEditor() { const [cropRegion, setCropRegion] = useState(DEFAULT_CROP_REGION); const [zoomRegions, setZoomRegions] = useState([]); const [selectedZoomId, setSelectedZoomId] = useState(null); + const [trimRegions, setTrimRegions] = useState([]); + const [selectedTrimId, setSelectedTrimId] = useState(null); const [isExporting, setIsExporting] = useState(false); const [exportProgress, setExportProgress] = useState(null); const [exportError, setExportError] = useState(null); @@ -46,6 +49,7 @@ export default function VideoEditor() { const videoPlaybackRef = useRef(null); const nextZoomIdRef = useRef(1); + const nextTrimIdRef = useRef(1); const exporterRef = useRef(null); // Helper to convert file path to proper file:// URL @@ -104,6 +108,12 @@ export default function VideoEditor() { const handleSelectZoom = useCallback((id: string | null) => { setSelectedZoomId(id); + if (id) setSelectedTrimId(null); + }, []); + + const handleSelectTrim = useCallback((id: string | null) => { + setSelectedTrimId(id); + if (id) setSelectedZoomId(null); }, []); const handleZoomAdded = useCallback((span: Span) => { @@ -118,6 +128,20 @@ export default function VideoEditor() { console.log('Zoom region added:', newRegion); setZoomRegions((prev) => [...prev, newRegion]); setSelectedZoomId(id); + setSelectedTrimId(null); + }, []); + + const handleTrimAdded = useCallback((span: Span) => { + const id = `trim-${nextTrimIdRef.current++}`; + const newRegion: TrimRegion = { + id, + startMs: Math.round(span.start), + endMs: Math.round(span.end), + }; + console.log('Trim region added:', newRegion); + setTrimRegions((prev) => [...prev, newRegion]); + setSelectedTrimId(id); + setSelectedZoomId(null); }, []); const handleZoomSpanChange = useCallback((id: string, span: Span) => { @@ -135,6 +159,21 @@ export default function VideoEditor() { ); }, []); + const handleTrimSpanChange = useCallback((id: string, span: Span) => { + console.log('Trim span changed:', { id, start: Math.round(span.start), end: Math.round(span.end) }); + setTrimRegions((prev) => + prev.map((region) => + region.id === id + ? { + ...region, + startMs: Math.round(span.start), + endMs: Math.round(span.end), + } + : region, + ), + ); + }, []); + const handleZoomFocusChange = useCallback((id: string, focus: ZoomFocus) => { setZoomRegions((prev) => prev.map((region) => @@ -171,7 +210,13 @@ export default function VideoEditor() { } }, [selectedZoomId]); - + const handleTrimDelete = useCallback((id: string) => { + console.log('Trim region deleted:', id); + setTrimRegions((prev) => prev.filter((region) => region.id !== id)); + if (selectedTrimId === id) { + setSelectedTrimId(null); + } + }, [selectedTrimId]); useEffect(() => { if (selectedZoomId && !zoomRegions.some((region) => region.id === selectedZoomId)) { @@ -179,6 +224,12 @@ export default function VideoEditor() { } }, [selectedZoomId, zoomRegions]); + useEffect(() => { + if (selectedTrimId && !trimRegions.some((region) => region.id === selectedTrimId)) { + setSelectedTrimId(null); + } + }, [selectedTrimId, trimRegions]); + const handleExport = useCallback(async () => { if (!videoPath) { toast.error('No video loaded'); @@ -374,6 +425,12 @@ export default function VideoEditor() { onZoomDelete={handleZoomDelete} selectedZoomId={selectedZoomId} onSelectZoom={handleSelectZoom} + trimRegions={trimRegions} + onTrimAdded={handleTrimAdded} + onTrimSpanChange={handleTrimSpanChange} + onTrimDelete={handleTrimDelete} + selectedTrimId={selectedTrimId} + onSelectTrim={handleSelectTrim} /> diff --git a/src/components/video-editor/timeline/Item.tsx b/src/components/video-editor/timeline/Item.tsx index 66c10cb..da185fb 100644 --- a/src/components/video-editor/timeline/Item.tsx +++ b/src/components/video-editor/timeline/Item.tsx @@ -1,7 +1,7 @@ import { useItem } from "dnd-timeline"; import type { Span } from "dnd-timeline"; import { cn } from "@/lib/utils"; -import { ZoomIn } from "lucide-react"; +import { ZoomIn, Scissors } from "lucide-react"; import glassStyles from "./ItemGlass.module.css"; interface ItemProps { @@ -11,7 +11,8 @@ interface ItemProps { children: React.ReactNode; isSelected?: boolean; onSelect?: () => void; - zoomDepth: number; + zoomDepth?: number; + variant?: 'zoom' | 'trim'; } // Map zoom depth to multiplier labels @@ -23,13 +24,25 @@ const ZOOM_LABELS: Record = { 5: "3.5×", }; -export default function Item({ id, span, rowId, isSelected = false, onSelect, zoomDepth }: ItemProps) { +export default function Item({ + id, + span, + rowId, + isSelected = false, + onSelect, + zoomDepth = 1, + variant = 'zoom' +}: ItemProps) { const { setNodeRef, attributes, listeners, itemStyle, itemContentStyle } = useItem({ id, span, data: { rowId }, }); + const isZoom = variant === 'zoom'; + const glassClass = isZoom ? glassStyles.glassGreen : glassStyles.glassRed; + const endCapColor = isZoom ? '#21916A' : '#ef4444'; + return (
{/* Content */}
- - - {ZOOM_LABELS[zoomDepth] || `${zoomDepth}×`} - + {isZoom ? ( + <> + + + {ZOOM_LABELS[zoomDepth] || `${zoomDepth}×`} + + + ) : ( + <> + + + Trim + + + )}
diff --git a/src/components/video-editor/timeline/ItemGlass.module.css b/src/components/video-editor/timeline/ItemGlass.module.css index adef673..2a6e45f 100644 --- a/src/components/video-editor/timeline/ItemGlass.module.css +++ b/src/components/video-editor/timeline/ItemGlass.module.css @@ -24,6 +24,32 @@ z-index: 10; } +.glassRed { + position: relative; + border-radius: 8px; + -corner-smoothing: antialiased; + background: rgba(239, 68, 68, 0.15); + border: 1px solid rgba(239, 68, 68, 0.3); + box-shadow: 0 2px 12px 0 rgba(239, 68, 68, 0.1) inset; + margin: 2px 0; + backdrop-filter: blur(4px); + -webkit-backdrop-filter: blur(4px); + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); +} + +.glassRed:hover { + background: rgba(239, 68, 68, 0.25); + border-color: rgba(239, 68, 68, 0.5); + box-shadow: 0 4px 20px 0 rgba(239, 68, 68, 0.2) inset; +} + +.glassRed.selected { + background: rgba(239, 68, 68, 0.35); + border-color: #ef4444; + box-shadow: 0 0 0 1px #ef4444, 0 4px 20px 0 rgba(239, 68, 68, 0.3) inset; + z-index: 10; +} + .zoomEndCap { position: absolute; top: 0; diff --git a/src/components/video-editor/timeline/Row.tsx b/src/components/video-editor/timeline/Row.tsx index 3456b5a..18989d5 100644 --- a/src/components/video-editor/timeline/Row.tsx +++ b/src/components/video-editor/timeline/Row.tsx @@ -11,7 +11,7 @@ export default function Row({ id, children }: RowProps) { return (
{children} diff --git a/src/components/video-editor/timeline/TimelineEditor.tsx b/src/components/video-editor/timeline/TimelineEditor.tsx index 6049799..14688ab 100644 --- a/src/components/video-editor/timeline/TimelineEditor.tsx +++ b/src/components/video-editor/timeline/TimelineEditor.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { useTimelineContext } from "dnd-timeline"; import { Button } from "@/components/ui/button"; -import { Plus } from "lucide-react"; +import { Plus, Scissors, ZoomIn } from "lucide-react"; import { toast } from "sonner"; import { cn } from "@/lib/utils"; import TimelineWrapper from "./TimelineWrapper"; @@ -9,10 +9,11 @@ import Row from "./Row"; import Item from "./Item"; import KeyframeMarkers from "./KeyframeMarkers"; import type { Range, Span } from "dnd-timeline"; -import type { ZoomRegion } from "../types"; +import type { ZoomRegion, TrimRegion } from "../types"; import { v4 as uuidv4 } from 'uuid'; -const ROW_ID = "row-1"; +const ZOOM_ROW_ID = "row-zoom"; +const TRIM_ROW_ID = "row-trim"; const FALLBACK_RANGE_MS = 1000; const TARGET_MARKER_COUNT = 12; @@ -26,6 +27,13 @@ interface TimelineEditorProps { onZoomDelete: (id: string) => void; selectedZoomId: string | null; onSelectZoom: (id: string | null) => void; + // Trim props + trimRegions?: TrimRegion[]; + onTrimAdded?: (span: Span) => void; + onTrimSpanChange?: (id: string, span: Span) => void; + onTrimDelete?: (id: string) => void; + selectedTrimId?: string | null; + onSelectTrim?: (id: string | null) => void; } interface TimelineScaleConfig { @@ -41,7 +49,8 @@ interface TimelineRenderItem { rowId: string; span: Span; label: string; - zoomDepth: number; + zoomDepth?: number; + variant: 'zoom' | 'trim'; } const SCALE_CANDIDATES = [ @@ -299,7 +308,9 @@ function Timeline({ currentTimeMs, onSeek, onSelectZoom, + onSelectTrim, selectedZoomId, + selectedTrimId, }: { items: TimelineRenderItem[]; videoDurationMs: number; @@ -307,13 +318,19 @@ function Timeline({ currentTimeMs: number; onSeek?: (time: number) => void; onSelectZoom?: (id: string | null) => void; + onSelectTrim?: (id: string | null) => void; selectedZoomId: string | null; + selectedTrimId?: string | null; }) { const { setTimelineRef, style, sidebarWidth, range, pixelsToValue } = useTimelineContext(); const handleTimelineClick = useCallback((e: React.MouseEvent) => { if (!onSeek || videoDurationMs <= 0) return; + + // Only clear selection if clicking on empty space (not on items) + // This is handled by event propagation - items stop propagation onSelectZoom?.(null); + onSelectTrim?.(null); const rect = e.currentTarget.getBoundingClientRect(); const clickX = e.clientX - rect.left - sidebarWidth; @@ -325,7 +342,10 @@ function Timeline({ const timeInSeconds = absoluteMs / 1000; onSeek(timeInSeconds); - }, [onSeek, onSelectZoom, videoDurationMs, sidebarWidth, range.start, pixelsToValue]); + }, [onSeek, onSelectZoom, onSelectTrim, videoDurationMs, sidebarWidth, range.start, pixelsToValue]); + + const zoomItems = items.filter(item => item.rowId === ZOOM_ROW_ID); + const trimItems = items.filter(item => item.rowId === TRIM_ROW_ID); return (
- - {items.map((item) => ( + + + {zoomItems.map((item) => ( onSelectZoom?.(item.id)} zoomDepth={item.zoomDepth} + variant="zoom" + > + {item.label} + + ))} + + + + {trimItems.map((item) => ( + onSelectTrim?.(item.id)} + variant="trim" > {item.label} @@ -366,6 +404,12 @@ export default function TimelineEditor({ onZoomDelete, selectedZoomId, onSelectZoom, + trimRegions = [], + onTrimAdded, + onTrimSpanChange, + onTrimDelete, + selectedTrimId, + onSelectTrim, }: TimelineEditorProps) { const totalMs = useMemo(() => Math.max(0, Math.round(videoDuration * 1000)), [videoDuration]); const currentTimeMs = useMemo(() => Math.round(currentTime * 1000), [currentTime]); @@ -401,6 +445,13 @@ export default function TimelineEditor({ onSelectZoom(null); }, [selectedZoomId, onZoomDelete, onSelectZoom]); + // Delete selected trim item + const deleteSelectedTrim = useCallback(() => { + if (!selectedTrimId || !onTrimDelete || !onSelectTrim) return; + onTrimDelete(selectedTrimId); + onSelectTrim(null); + }, [selectedTrimId, onTrimDelete, onSelectTrim]); + useEffect(() => { setRange(createInitialRange(totalMs)); }, [totalMs]); @@ -421,26 +472,53 @@ export default function TimelineEditor({ onZoomSpanChange(region.id, { start: normalizedStart, end: normalizedEnd }); } }); - }, [zoomRegions, totalMs, safeMinDurationMs, onZoomSpanChange]); + + trimRegions.forEach((region) => { + const clampedStart = Math.max(0, Math.min(region.startMs, totalMs)); + const minEnd = clampedStart + safeMinDurationMs; + const clampedEnd = Math.min(totalMs, Math.max(minEnd, region.endMs)); + const normalizedStart = Math.max(0, Math.min(clampedStart, totalMs - safeMinDurationMs)); + const normalizedEnd = Math.max(minEnd, Math.min(clampedEnd, totalMs)); + + if (normalizedStart !== region.startMs || normalizedEnd !== region.endMs) { + onTrimSpanChange?.(region.id, { start: normalizedStart, end: normalizedEnd }); + } + }); + }, [zoomRegions, trimRegions, totalMs, safeMinDurationMs, onZoomSpanChange, onTrimSpanChange]); const hasOverlap = useCallback((newSpan: Span, excludeId?: string): boolean => { - // Snap if gap is 2ms or less - return zoomRegions.some((region) => { - if (region.id === excludeId) return false; - const gapBefore = newSpan.start - region.endMs; - const gapAfter = region.startMs - newSpan.end; - if (gapBefore > 0 && gapBefore <= 2) return true; - if (gapAfter > 0 && gapAfter <= 2) return true; - return !(newSpan.end <= region.startMs || newSpan.start >= region.endMs); - }); - }, [zoomRegions]); + // Determine which row the item belongs to + const isZoomItem = zoomRegions.some(r => r.id === excludeId); + const isTrimItem = trimRegions.some(r => r.id === excludeId); + + // Helper to check overlap against a specific set of regions + const checkOverlap = (regions: (ZoomRegion | TrimRegion)[]) => { + return regions.some((region) => { + if (region.id === excludeId) return false; + const gapBefore = newSpan.start - region.endMs; + const gapAfter = region.startMs - newSpan.end; + // Snap if gap is 2ms or less + if (gapBefore > 0 && gapBefore <= 2) return true; + if (gapAfter > 0 && gapAfter <= 2) return true; + return !(newSpan.end <= region.startMs || newSpan.start >= region.endMs); + }); + }; + + if (isZoomItem) { + return checkOverlap(zoomRegions); + } + + if (isTrimItem) { + return checkOverlap(trimRegions); + } + return false; + }, [zoomRegions, trimRegions]); const handleAddZoom = useCallback(() => { if (!videoDuration || videoDuration === 0 || totalMs === 0) { return; } - const defaultDuration = Math.min(1000, totalMs); if (defaultDuration <= 0) { return; @@ -466,26 +544,66 @@ export default function TimelineEditor({ onZoomAdded({ start: startPos, end: startPos + actualDuration }); }, [videoDuration, totalMs, currentTimeMs, zoomRegions, onZoomAdded]); - // Listen for F key to add keyframe, Z key to add zoom, Ctrl+D to remove selected keyframe or zoom item + const handleAddTrim = useCallback(() => { + if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onTrimAdded) { + return; + } + + const defaultDuration = Math.min(1000, totalMs); + if (defaultDuration <= 0) { + return; + } + + // Always place trim at playhead + const startPos = Math.max(0, Math.min(currentTimeMs, totalMs)); + // Find the next trim region after the playhead + const sorted = [...trimRegions].sort((a, b) => a.startMs - b.startMs); + const nextRegion = sorted.find(region => region.startMs > startPos); + const gapToNext = nextRegion ? nextRegion.startMs - startPos : totalMs - startPos; + + // Check if playhead is inside any trim region + const isOverlapping = sorted.some(region => startPos >= region.startMs && startPos < region.endMs); + if (isOverlapping || gapToNext <= 0) { + toast.error("Cannot place trim here", { + description: "Trim already exists at this location or not enough space available.", + }); + return; + } + + const actualDuration = Math.min(1000, gapToNext); + onTrimAdded({ start: startPos, end: startPos + actualDuration }); + }, [videoDuration, totalMs, currentTimeMs, trimRegions, onTrimAdded]); + + // Listen for F key to add keyframe, Z key to add zoom, T key to add trim, Ctrl+D to remove selected keyframe or zoom item useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { + // Ignore if typing in an input + if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) { + return; + } + if (e.key === 'f' || e.key === 'F') { addKeyframe(); } if (e.key === 'z' || e.key === 'Z') { handleAddZoom(); } + if (e.key === 't' || e.key === 'T') { + handleAddTrim(); + } if ((e.key === 'd' || e.key === 'D') && (e.ctrlKey || e.metaKey)) { if (selectedKeyframeId) { deleteSelectedKeyframe(); } else if (selectedZoomId) { deleteSelectedZoom(); + } else if (selectedTrimId) { + deleteSelectedTrim(); } } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); - }, [addKeyframe, handleAddZoom, deleteSelectedKeyframe, deleteSelectedZoom, selectedKeyframeId, selectedZoomId]); + }, [addKeyframe, handleAddZoom, handleAddTrim, deleteSelectedKeyframe, deleteSelectedZoom, deleteSelectedTrim, selectedKeyframeId, selectedZoomId, selectedTrimId]); const clampedRange = useMemo(() => { if (totalMs === 0) { @@ -499,16 +617,34 @@ export default function TimelineEditor({ }, [range, totalMs]); const timelineItems = useMemo(() => { - return [...zoomRegions] - .sort((a, b) => a.startMs - b.startMs) - .map((region, index) => ({ - id: region.id, - rowId: ROW_ID, - span: { start: region.startMs, end: region.endMs }, - label: `Zoom ${index + 1}`, - zoomDepth: region.depth, - })); - }, [zoomRegions]); + const zooms: TimelineRenderItem[] = zoomRegions.map((region, index) => ({ + id: region.id, + rowId: ZOOM_ROW_ID, + span: { start: region.startMs, end: region.endMs }, + label: `Zoom ${index + 1}`, + zoomDepth: region.depth, + variant: 'zoom', + })); + + const trims: TimelineRenderItem[] = trimRegions.map((region, index) => ({ + id: region.id, + rowId: TRIM_ROW_ID, + span: { start: region.startMs, end: region.endMs }, + label: `Trim ${index + 1}`, + variant: 'trim', + })); + + return [...zooms, ...trims]; + }, [zoomRegions, trimRegions]); + + const handleItemSpanChange = useCallback((id: string, span: Span) => { + // Check if it's a zoom or trim item + if (zoomRegions.some(r => r.id === id)) { + onZoomSpanChange(id, span); + } else if (trimRegions.some(r => r.id === id)) { + onTrimSpanChange?.(id, span); + } + }, [zoomRegions, trimRegions, onZoomSpanChange, onTrimSpanChange]); if (!videoDuration || videoDuration === 0) { return ( @@ -526,16 +662,27 @@ export default function TimelineEditor({ return (
-
- +
+
+ + +
@@ -559,7 +706,7 @@ export default function TimelineEditor({ minItemDurationMs={timelineScale.minItemDurationMs} minVisibleRangeMs={timelineScale.minVisibleRangeMs} gridSizeMs={timelineScale.gridMs} - onItemSpanChange={onZoomSpanChange} + onItemSpanChange={handleItemSpanChange} >
diff --git a/src/components/video-editor/types.ts b/src/components/video-editor/types.ts index e75c965..273a4ad 100644 --- a/src/components/video-editor/types.ts +++ b/src/components/video-editor/types.ts @@ -13,6 +13,12 @@ export interface ZoomRegion { focus: ZoomFocus; } +export interface TrimRegion { + id: string; + startMs: number; + endMs: number; +} + export interface CropRegion { x: number; // 0-1 normalized y: number; // 0-1 normalized