movable camera pip
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user