From cbbe2d7fbf98e91b255f0d9857b04627d07bd231 Mon Sep 17 00:00:00 2001 From: Siddharth Date: Sat, 21 Mar 2026 22:04:10 -0700 Subject: [PATCH] movable camera pip --- src/components/video-editor/VideoEditor.tsx | 19 +++++- src/components/video-editor/VideoPlayback.tsx | 63 ++++++++++++++++++- .../video-editor/projectPersistence.ts | 13 ++++ src/components/video-editor/types.ts | 7 +++ .../video-editor/videoPlayback/layoutUtils.ts | 3 + src/hooks/useEditorHistory.ts | 9 ++- src/lib/compositeLayout.ts | 22 ++++++- src/lib/exporter/frameRenderer.ts | 2 + src/lib/exporter/gifExporter.ts | 2 + src/lib/exporter/videoExporter.ts | 2 + 10 files changed, 137 insertions(+), 5 deletions(-) diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 2b52689..27968df 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -80,6 +80,7 @@ export default function VideoEditor() { padding, aspectRatio, webcamLayoutPreset, + webcamPosition, } = editorState; // ── Non-undoable state @@ -187,6 +188,7 @@ export default function VideoEditor() { annotationRegions: normalizedEditor.annotationRegions, aspectRatio: normalizedEditor.aspectRatio, webcamLayoutPreset: normalizedEditor.webcamLayoutPreset, + webcamPosition: normalizedEditor.webcamPosition, }); setExportQuality(normalizedEditor.exportQuality); setExportFormat(normalizedEditor.exportFormat); @@ -255,6 +257,7 @@ export default function VideoEditor() { annotationRegions, aspectRatio, webcamLayoutPreset, + webcamPosition, exportQuality, exportFormat, gifFrameRate, @@ -277,6 +280,7 @@ export default function VideoEditor() { annotationRegions, aspectRatio, webcamLayoutPreset, + webcamPosition, exportQuality, exportFormat, gifFrameRate, @@ -369,6 +373,7 @@ export default function VideoEditor() { annotationRegions, aspectRatio, webcamLayoutPreset, + webcamPosition, exportQuality, exportFormat, gifFrameRate, @@ -422,6 +427,7 @@ export default function VideoEditor() { annotationRegions, aspectRatio, webcamLayoutPreset, + webcamPosition, exportQuality, exportFormat, gifFrameRate, @@ -1062,6 +1068,7 @@ export default function VideoEditor() { cropRegion, annotationRegions, webcamLayoutPreset, + webcamPosition, previewWidth, previewHeight, onProgress: (progress: ExportProgress) => { @@ -1192,6 +1199,7 @@ export default function VideoEditor() { cropRegion, annotationRegions, webcamLayoutPreset, + webcamPosition, previewWidth, previewHeight, onProgress: (progress: ExportProgress) => { @@ -1259,6 +1267,7 @@ export default function VideoEditor() { isPlaying, aspectRatio, webcamLayoutPreset, + webcamPosition, exportQuality, handleExportSaved, ], @@ -1435,6 +1444,9 @@ export default function VideoEditor() { videoPath={videoPath || ""} webcamVideoPath={webcamVideoPath || undefined} webcamLayoutPreset={webcamLayoutPreset} + webcamPosition={webcamPosition} + onWebcamPositionChange={(pos) => updateState({ webcamPosition: pos })} + onWebcamPositionDragEnd={commitState} onDurationChange={setDuration} onTimeUpdate={setCurrentTime} currentTime={currentTime} @@ -1556,7 +1568,12 @@ export default function VideoEditor() { aspectRatio={aspectRatio} hasWebcam={Boolean(webcamVideoPath)} webcamLayoutPreset={webcamLayoutPreset} - onWebcamLayoutPresetChange={(preset) => pushState({ webcamLayoutPreset: preset })} + onWebcamLayoutPresetChange={(preset) => + pushState({ + webcamLayoutPreset: preset, + webcamPosition: preset === "vertical-stack" ? null : webcamPosition, + }) + } videoElement={videoPlaybackRef.current?.video || null} exportQuality={exportQuality} onExportQualityChange={setExportQuality} diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx index 24cd187..71030bb 100644 --- a/src/components/video-editor/VideoPlayback.tsx +++ b/src/components/video-editor/VideoPlayback.tsx @@ -63,6 +63,9 @@ interface VideoPlaybackProps { videoPath: string; webcamVideoPath?: string; webcamLayoutPreset: WebcamLayoutPreset; + webcamPosition?: { cx: number; cy: number } | null; + onWebcamPositionChange?: (position: { cx: number; cy: number }) => void; + onWebcamPositionDragEnd?: () => void; onDurationChange: (duration: number) => void; onTimeUpdate: (time: number) => void; currentTime: number; @@ -108,6 +111,9 @@ const VideoPlayback = forwardRef( videoPath, webcamVideoPath, webcamLayoutPreset, + webcamPosition, + onWebcamPositionChange, + onWebcamPositionDragEnd, onDurationChange, onTimeUpdate, currentTime, @@ -167,6 +173,8 @@ const VideoPlayback = forwardRef( const blurFilterRef = useRef(null); const motionBlurFilterRef = useRef(null); const isDraggingFocusRef = useRef(false); + const isDraggingWebcamRef = useRef(false); + const webcamDragOffsetRef = useRef({ dx: 0, dy: 0 }); const stageSizeRef = useRef({ width: 0, height: 0 }); const videoSizeRef = useRef({ width: 0, height: 0 }); const baseScaleRef = useRef(1); @@ -263,6 +271,7 @@ const VideoPlayback = forwardRef( padding, webcamDimensions, webcamLayoutPreset, + webcamPosition, }); if (result) { @@ -292,6 +301,7 @@ const VideoPlayback = forwardRef( padding, webcamDimensions, webcamLayoutPreset, + webcamPosition, ]); useEffect(() => { @@ -401,6 +411,53 @@ const VideoPlayback = forwardRef( endFocusDrag(event); }; + // ── Webcam PiP drag handlers ── + + const handleWebcamPointerDown = (event: React.PointerEvent) => { + if (isPlayingRef.current) return; + if (webcamLayoutPreset !== "picture-in-picture") return; + event.preventDefault(); + event.stopPropagation(); + isDraggingWebcamRef.current = true; + event.currentTarget.setPointerCapture(event.pointerId); + + const webcamEl = event.currentTarget; + const webcamRect = webcamEl.getBoundingClientRect(); + webcamDragOffsetRef.current = { + dx: event.clientX - (webcamRect.left + webcamRect.width / 2), + dy: event.clientY - (webcamRect.top + webcamRect.height / 2), + }; + }; + + const handleWebcamPointerMove = (event: React.PointerEvent) => { + if (!isDraggingWebcamRef.current) return; + event.preventDefault(); + event.stopPropagation(); + + const containerEl = containerRef.current; + if (!containerEl || !onWebcamPositionChange) return; + + const containerRect = containerEl.getBoundingClientRect(); + const cx = clamp01( + (event.clientX - webcamDragOffsetRef.current.dx - containerRect.left) / containerRect.width, + ); + const cy = clamp01( + (event.clientY - webcamDragOffsetRef.current.dy - containerRect.top) / containerRect.height, + ); + onWebcamPositionChange({ cx, cy }); + }; + + const handleWebcamPointerUp = (event: React.PointerEvent) => { + if (!isDraggingWebcamRef.current) return; + isDraggingWebcamRef.current = false; + try { + event.currentTarget.releasePointerCapture(event.pointerId); + } catch { + // Pointer may already be released. + } + onWebcamPositionDragEnd?.(); + }; + useEffect(() => { zoomRegionsRef.current = zoomRegions; }, [zoomRegions]); @@ -1101,7 +1158,7 @@ const VideoPlayback = forwardRef(