basic trim setup

This commit is contained in:
Siddharth
2025-11-27 16:35:21 -07:00
parent e549850b75
commit 2b5b15f3e8
6 changed files with 316 additions and 54 deletions
+58 -1
View File
@@ -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<CropRegion>(DEFAULT_CROP_REGION);
const [zoomRegions, setZoomRegions] = useState<ZoomRegion[]>([]);
const [selectedZoomId, setSelectedZoomId] = useState<string | null>(null);
const [trimRegions, setTrimRegions] = useState<TrimRegion[]>([]);
const [selectedTrimId, setSelectedTrimId] = useState<string | null>(null);
const [isExporting, setIsExporting] = useState(false);
const [exportProgress, setExportProgress] = useState<ExportProgress | null>(null);
const [exportError, setExportError] = useState<string | null>(null);
@@ -46,6 +49,7 @@ export default function VideoEditor() {
const videoPlaybackRef = useRef<VideoPlaybackRef>(null);
const nextZoomIdRef = useRef(1);
const nextTrimIdRef = useRef(1);
const exporterRef = useRef<VideoExporter | null>(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}
/>
</div>
</Panel>
+34 -10
View File
@@ -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<number, string> = {
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 (
<div
ref={setNodeRef}
@@ -42,7 +55,7 @@ export default function Item({ id, span, rowId, isSelected = false, onSelect, zo
<div style={itemContentStyle}>
<div
className={cn(
glassStyles.glassGreen,
glassClass,
"w-full h-full overflow-hidden flex items-center justify-center gap-1.5 cursor-grab active:cursor-grabbing relative",
isSelected && glassStyles.selected
)}
@@ -54,20 +67,31 @@ export default function Item({ id, span, rowId, isSelected = false, onSelect, zo
>
<div
className={cn(glassStyles.zoomEndCap, glassStyles.left)}
style={{ cursor: 'col-resize', pointerEvents: 'auto', width: 8, opacity: 0.9, background: '#21916A' }}
style={{ cursor: 'col-resize', pointerEvents: 'auto', width: 8, opacity: 0.9, background: endCapColor }}
title="Resize left"
/>
<div
className={cn(glassStyles.zoomEndCap, glassStyles.right)}
style={{ cursor: 'col-resize', pointerEvents: 'auto', width: 8, opacity: 0.9, background: '#21916A' }}
style={{ cursor: 'col-resize', pointerEvents: 'auto', width: 8, opacity: 0.9, background: endCapColor }}
title="Resize right"
/>
{/* Content */}
<div className="relative z-10 flex items-center gap-1.5 text-white/90 opacity-80 group-hover:opacity-100 transition-opacity select-none">
<ZoomIn className="w-3.5 h-3.5" />
<span className="text-[11px] font-semibold tracking-tight">
{ZOOM_LABELS[zoomDepth] || `${zoomDepth}×`}
</span>
{isZoom ? (
<>
<ZoomIn className="w-3.5 h-3.5" />
<span className="text-[11px] font-semibold tracking-tight">
{ZOOM_LABELS[zoomDepth] || `${zoomDepth}×`}
</span>
</>
) : (
<>
<Scissors className="w-3.5 h-3.5" />
<span className="text-[11px] font-semibold tracking-tight">
Trim
</span>
</>
)}
</div>
</div>
</div>
@@ -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;
+1 -1
View File
@@ -11,7 +11,7 @@ export default function Row({ id, children }: RowProps) {
return (
<div
className="border-b border-[#18181b] bg-[#18181b]"
style={{ ...rowWrapperStyle, minHeight: 88 }}
style={{ ...rowWrapperStyle, minHeight: 60 }}
>
<div ref={setNodeRef} style={rowStyle}>
{children}
@@ -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<HTMLDivElement>) => {
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 (
<div
@@ -337,8 +357,9 @@ function Timeline({
<div className="absolute inset-0 bg-[linear-gradient(to_right,#ffffff03_1px,transparent_1px)] bg-[length:20px_100%] pointer-events-none" />
<TimelineAxis intervalMs={intervalMs} videoDurationMs={videoDurationMs} currentTimeMs={currentTimeMs} />
<PlaybackCursor currentTimeMs={currentTimeMs} videoDurationMs={videoDurationMs} />
<Row id={ROW_ID}>
{items.map((item) => (
<Row id={ZOOM_ROW_ID}>
{zoomItems.map((item) => (
<Item
id={item.id}
key={item.id}
@@ -347,6 +368,23 @@ function Timeline({
isSelected={item.id === selectedZoomId}
onSelect={() => onSelectZoom?.(item.id)}
zoomDepth={item.zoomDepth}
variant="zoom"
>
{item.label}
</Item>
))}
</Row>
<Row id={TRIM_ROW_ID}>
{trimItems.map((item) => (
<Item
id={item.id}
key={item.id}
rowId={item.rowId}
span={item.span}
isSelected={item.id === selectedTrimId}
onSelect={() => onSelectTrim?.(item.id)}
variant="trim"
>
{item.label}
</Item>
@@ -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<Range>(() => {
if (totalMs === 0) {
@@ -499,16 +617,34 @@ export default function TimelineEditor({
}, [range, totalMs]);
const timelineItems = useMemo<TimelineRenderItem[]>(() => {
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 (
<div className="flex-1 flex flex-col bg-[#09090b] overflow-hidden">
<div className="flex items-center gap-3 px-4 py-2 border-b border-white/5 bg-[#09090b]">
<Button
onClick={handleAddZoom}
variant="outline"
size="sm"
className="gap-2 h-7 px-3 text-xs bg-white/5 border-white/10 text-slate-200 hover:bg-[#34B27B] hover:text-white hover:border-[#34B27B] transition-all"
>
<Plus className="w-3.5 h-3.5" />
Add Zoom
</Button>
<div className="flex items-center gap-2 px-4 py-2 border-b border-white/5 bg-[#09090b]">
<div className="flex items-center gap-1">
<Button
onClick={handleAddZoom}
variant="ghost"
size="icon"
className="h-7 w-7 text-slate-400 hover:text-[#34B27B] hover:bg-[#34B27B]/10 transition-all"
title="Add Zoom (Z)"
>
<ZoomIn className="w-4 h-4" />
</Button>
<Button
onClick={handleAddTrim}
variant="ghost"
size="icon"
className="h-7 w-7 text-slate-400 hover:text-[#ef4444] hover:bg-[#ef4444]/10 transition-all"
title="Add Trim (T)"
>
<Scissors className="w-4 h-4" />
</Button>
</div>
<div className="flex-1" />
<div className="flex items-center gap-4 text-[10px] text-slate-500 font-medium">
<span className="flex items-center gap-1.5">
@@ -559,7 +706,7 @@ export default function TimelineEditor({
minItemDurationMs={timelineScale.minItemDurationMs}
minVisibleRangeMs={timelineScale.minVisibleRangeMs}
gridSizeMs={timelineScale.gridMs}
onItemSpanChange={onZoomSpanChange}
onItemSpanChange={handleItemSpanChange}
>
<KeyframeMarkers
keyframes={keyframes}
@@ -573,7 +720,9 @@ export default function TimelineEditor({
currentTimeMs={currentTimeMs}
onSeek={onSeek}
onSelectZoom={onSelectZoom}
onSelectTrim={onSelectTrim}
selectedZoomId={selectedZoomId}
selectedTrimId={selectedTrimId}
/>
</TimelineWrapper>
</div>
+6
View File
@@ -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