movable camera pip

This commit is contained in:
Siddharth
2026-03-21 22:04:10 -07:00
parent 7aca8b8bc1
commit cbbe2d7fbf
10 changed files with 137 additions and 5 deletions
+18 -1
View File
@@ -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}
+62 -1
View File
@@ -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<VideoPlaybackRef, VideoPlaybackProps>(
videoPath,
webcamVideoPath,
webcamLayoutPreset,
webcamPosition,
onWebcamPositionChange,
onWebcamPositionDragEnd,
onDurationChange,
onTimeUpdate,
currentTime,
@@ -167,6 +173,8 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
const blurFilterRef = useRef<BlurFilter | null>(null);
const motionBlurFilterRef = useRef<MotionBlurFilter | null>(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<VideoPlaybackRef, VideoPlaybackProps>(
padding,
webcamDimensions,
webcamLayoutPreset,
webcamPosition,
});
if (result) {
@@ -292,6 +301,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
padding,
webcamDimensions,
webcamLayoutPreset,
webcamPosition,
]);
useEffect(() => {
@@ -401,6 +411,53 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
endFocusDrag(event);
};
// ── Webcam PiP drag handlers ──
const handleWebcamPointerDown = (event: React.PointerEvent<HTMLVideoElement>) => {
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<HTMLVideoElement>) => {
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<HTMLVideoElement>) => {
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<VideoPlaybackRef, VideoPlaybackProps>(
<video
ref={webcamVideoRef}
src={webcamVideoPath}
className="absolute object-cover pointer-events-none"
className={`absolute object-cover ${webcamLayoutPreset === "picture-in-picture" ? "cursor-grab active:cursor-grabbing" : "pointer-events-none"}`}
style={{
left: webcamLayout?.x ?? 0,
top: webcamLayout?.y ?? 0,
@@ -1113,6 +1170,10 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
opacity: webcamLayout ? 1 : 0,
backgroundColor: "#000",
}}
onPointerDown={handleWebcamPointerDown}
onPointerMove={handleWebcamPointerMove}
onPointerUp={handleWebcamPointerUp}
onPointerLeave={handleWebcamPointerUp}
muted
preload="metadata"
playsInline
@@ -12,10 +12,12 @@ import {
DEFAULT_FIGURE_DATA,
DEFAULT_PLAYBACK_SPEED,
DEFAULT_WEBCAM_LAYOUT_PRESET,
DEFAULT_WEBCAM_POSITION,
DEFAULT_ZOOM_DEPTH,
type SpeedRegion,
type TrimRegion,
type WebcamLayoutPreset,
type WebcamPosition,
type ZoomRegion,
} from "./types";
@@ -42,6 +44,7 @@ export interface ProjectEditorState {
annotationRegions: AnnotationRegion[];
aspectRatio: AspectRatio;
webcamLayoutPreset: WebcamLayoutPreset;
webcamPosition: WebcamPosition | null;
exportQuality: ExportQuality;
exportFormat: ExportFormat;
gifFrameRate: GifFrameRate;
@@ -349,6 +352,16 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
editor.webcamLayoutPreset === "picture-in-picture"
? editor.webcamLayoutPreset
: DEFAULT_WEBCAM_LAYOUT_PRESET,
webcamPosition:
editor.webcamPosition &&
typeof editor.webcamPosition === "object" &&
isFiniteNumber((editor.webcamPosition as WebcamPosition).cx) &&
isFiniteNumber((editor.webcamPosition as WebcamPosition).cy)
? {
cx: clamp((editor.webcamPosition as WebcamPosition).cx, 0, 1),
cy: clamp((editor.webcamPosition as WebcamPosition).cy, 0, 1),
}
: DEFAULT_WEBCAM_POSITION,
exportQuality:
editor.exportQuality === "medium" || editor.exportQuality === "source"
? editor.exportQuality
+7
View File
@@ -5,6 +5,13 @@ export type { WebcamLayoutPreset };
export const DEFAULT_WEBCAM_LAYOUT_PRESET: WebcamLayoutPreset = "picture-in-picture";
export interface WebcamPosition {
cx: number; // normalized horizontal center (0-1)
cy: number; // normalized vertical center (0-1)
}
export const DEFAULT_WEBCAM_POSITION: WebcamPosition | null = null;
export interface ZoomFocus {
cx: number; // normalized horizontal center (0-1)
cy: number; // normalized vertical center (0-1)
@@ -20,6 +20,7 @@ interface LayoutParams {
padding?: number;
webcamDimensions?: Size | null;
webcamLayoutPreset?: WebcamLayoutPreset;
webcamPosition?: { cx: number; cy: number } | null;
}
interface LayoutResult {
@@ -45,6 +46,7 @@ export function layoutVideoContent(params: LayoutParams): LayoutResult | null {
padding = 0,
webcamDimensions,
webcamLayoutPreset,
webcamPosition,
} = params;
const videoWidth = lockedVideoDimensions?.width || videoElement.videoWidth;
@@ -89,6 +91,7 @@ export function layoutVideoContent(params: LayoutParams): LayoutResult | null {
screenSize: { width: croppedVideoWidth, height: croppedVideoHeight },
webcamSize: webcamDimensions,
layoutPreset: webcamLayoutPreset,
webcamPosition,
});
if (!compositeLayout) {
+8 -1
View File
@@ -5,9 +5,14 @@ import type {
SpeedRegion,
TrimRegion,
WebcamLayoutPreset,
WebcamPosition,
ZoomRegion,
} from "@/components/video-editor/types";
import { DEFAULT_CROP_REGION, DEFAULT_WEBCAM_LAYOUT_PRESET } from "@/components/video-editor/types";
import {
DEFAULT_CROP_REGION,
DEFAULT_WEBCAM_LAYOUT_PRESET,
DEFAULT_WEBCAM_POSITION,
} from "@/components/video-editor/types";
import type { AspectRatio } from "@/utils/aspectRatioUtils";
// Undoable state — selection IDs are intentionally excluded (undoing a
@@ -26,6 +31,7 @@ export interface EditorState {
padding: number;
aspectRatio: AspectRatio;
webcamLayoutPreset: WebcamLayoutPreset;
webcamPosition: WebcamPosition | null;
}
export const INITIAL_EDITOR_STATE: EditorState = {
@@ -42,6 +48,7 @@ export const INITIAL_EDITOR_STATE: EditorState = {
padding: 50,
aspectRatio: "16:9",
webcamLayoutPreset: DEFAULT_WEBCAM_LAYOUT_PRESET,
webcamPosition: DEFAULT_WEBCAM_POSITION,
};
type StateUpdate = Partial<EditorState> | ((prev: EditorState) => Partial<EditorState>);
+20 -2
View File
@@ -123,6 +123,7 @@ export function computeCompositeLayout(params: {
screenSize: Size;
webcamSize?: Size | null;
layoutPreset?: WebcamLayoutPreset;
webcamPosition?: { cx: number; cy: number } | null;
}): WebcamCompositeLayout | null {
const {
canvasSize,
@@ -130,6 +131,7 @@ export function computeCompositeLayout(params: {
screenSize,
webcamSize,
layoutPreset = "picture-in-picture",
webcamPosition,
} = params;
const { width: canvasWidth, height: canvasHeight } = canvasSize;
const { width: maxContentWidth, height: maxContentHeight } = maxContentSize;
@@ -214,11 +216,27 @@ export function computeCompositeLayout(params: {
const width = Math.round(webcamWidth * scale);
const height = Math.round(webcamHeight * scale);
let webcamX: number;
let webcamY: number;
if (webcamPosition) {
// Custom position: cx/cy represent the center of the webcam as a fraction of the canvas
webcamX = Math.round(webcamPosition.cx * canvasWidth - width / 2);
webcamY = Math.round(webcamPosition.cy * canvasHeight - height / 2);
// Clamp to stay within canvas bounds
webcamX = Math.max(0, Math.min(canvasWidth - width, webcamX));
webcamY = Math.max(0, Math.min(canvasHeight - height, webcamY));
} else {
// Default: bottom-right with margin
webcamX = Math.max(0, Math.round(canvasWidth - margin - width));
webcamY = Math.max(0, Math.round(canvasHeight - margin - height));
}
return {
screenRect,
webcamRect: {
x: Math.max(0, Math.round(canvasWidth - margin - width)),
y: Math.max(0, Math.round(canvasHeight - margin - height)),
x: webcamX,
y: webcamY,
width,
height,
borderRadius: Math.min(
+2
View File
@@ -61,6 +61,7 @@ interface FrameRenderConfig {
videoHeight: number;
webcamSize?: Size | null;
webcamLayoutPreset?: WebcamLayoutPreset;
webcamPosition?: { cx: number; cy: number } | null;
annotationRegions?: AnnotationRegion[];
speedRegions?: SpeedRegion[];
previewWidth?: number;
@@ -437,6 +438,7 @@ export class FrameRenderer {
screenSize: { width: croppedVideoWidth, height: croppedVideoHeight },
webcamSize: webcamFrame ? this.config.webcamSize : null,
layoutPreset: this.config.webcamLayoutPreset,
webcamPosition: this.config.webcamPosition,
});
if (!compositeLayout) return;
+2
View File
@@ -41,6 +41,7 @@ interface GifExporterConfig {
videoPadding?: number;
cropRegion: CropRegion;
webcamLayoutPreset?: WebcamLayoutPreset;
webcamPosition?: { cx: number; cy: number } | null;
annotationRegions?: AnnotationRegion[];
previewWidth?: number;
previewHeight?: number;
@@ -140,6 +141,7 @@ export class GifExporter {
videoHeight: videoInfo.height,
webcamSize: webcamInfo ? { width: webcamInfo.width, height: webcamInfo.height } : null,
webcamLayoutPreset: this.config.webcamLayoutPreset,
webcamPosition: this.config.webcamPosition,
annotationRegions: this.config.annotationRegions,
speedRegions: this.config.speedRegions,
previewWidth: this.config.previewWidth,
+2
View File
@@ -32,6 +32,7 @@ interface VideoExporterConfig extends ExportConfig {
videoPadding?: number;
cropRegion: CropRegion;
webcamLayoutPreset?: WebcamLayoutPreset;
webcamPosition?: { cx: number; cy: number } | null;
annotationRegions?: AnnotationRegion[];
previewWidth?: number;
previewHeight?: number;
@@ -133,6 +134,7 @@ export class VideoExporter {
videoHeight: videoInfo.height,
webcamSize: webcamInfo ? { width: webcamInfo.width, height: webcamInfo.height } : null,
webcamLayoutPreset: this.config.webcamLayoutPreset,
webcamPosition: this.config.webcamPosition,
annotationRegions: this.config.annotationRegions,
speedRegions: this.config.speedRegions,
previewWidth: this.config.previewWidth,