From a0682e67164141f73f5f28b9434e730f1b89bca3 Mon Sep 17 00:00:00 2001 From: Marcus Schiesser Date: Thu, 19 Mar 2026 13:05:42 +0800 Subject: [PATCH] feat: add selectable webcam layout presets --- src/components/video-editor/SettingsPanel.tsx | 37 ++++ src/components/video-editor/VideoEditor.tsx | 13 ++ src/components/video-editor/VideoPlayback.tsx | 31 +++- .../video-editor/projectPersistence.test.ts | 1 + .../video-editor/projectPersistence.ts | 8 + src/components/video-editor/types.ts | 5 + src/hooks/useEditorHistory.ts | 5 +- src/lib/exporter/frameRenderer.ts | 17 +- src/lib/exporter/gifExporter.ts | 3 + src/lib/exporter/videoExporter.ts | 3 + src/lib/webcamOverlay.test.ts | 30 ++++ src/lib/webcamOverlay.ts | 163 +++++++++++++++++- 12 files changed, 300 insertions(+), 16 deletions(-) diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index 4e0c067..500356b 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -25,6 +25,13 @@ import { AccordionTrigger, } from "@/components/ui/accordion"; import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { Slider } from "@/components/ui/slider"; import { Switch } from "@/components/ui/switch"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; @@ -32,6 +39,7 @@ import { getAssetPath } from "@/lib/assetPath"; import type { ExportFormat, ExportQuality, GifFrameRate, GifSizePreset } from "@/lib/exporter"; import { GIF_FRAME_RATES, GIF_SIZE_PRESETS } from "@/lib/exporter"; import { cn } from "@/lib/utils"; +import { WEBCAM_LAYOUT_PRESETS } from "@/lib/webcamOverlay"; import { type AspectRatio } from "@/utils/aspectRatioUtils"; import { getTestId } from "@/utils/getTestId"; import { AnnotationSettingsPanel } from "./AnnotationSettingsPanel"; @@ -43,6 +51,7 @@ import type { CropRegion, FigureData, PlaybackSpeed, + WebcamLayoutPreset, ZoomDepth, } from "./types"; import { SPEED_OPTIONS } from "./types"; @@ -132,6 +141,9 @@ interface SettingsPanelProps { selectedSpeedValue?: PlaybackSpeed | null; onSpeedChange?: (speed: PlaybackSpeed) => void; onSpeedDelete?: (id: string) => void; + hasWebcam?: boolean; + webcamLayoutPreset?: WebcamLayoutPreset; + onWebcamLayoutPresetChange?: (preset: WebcamLayoutPreset) => void; } export default SettingsPanel; @@ -197,6 +209,9 @@ export function SettingsPanel({ selectedSpeedValue, onSpeedChange, onSpeedDelete, + hasWebcam = false, + webcamLayoutPreset = "picture-in-picture", + onWebcamLayoutPresetChange, }: SettingsPanelProps) { const [wallpaperPaths, setWallpaperPaths] = useState([]); const [customImages, setCustomImages] = useState([]); @@ -586,6 +601,28 @@ export function SettingsPanel({ /> + {hasWebcam && ( +
+
Webcam Layout
+ +
+ )}
diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index cbf9b29..6b2e6fb 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -76,6 +76,7 @@ export default function VideoEditor() { borderRadius, padding, aspectRatio, + webcamLayoutPreset, } = editorState; // ── Non-undoable state @@ -173,6 +174,7 @@ export default function VideoEditor() { speedRegions: normalizedEditor.speedRegions, annotationRegions: normalizedEditor.annotationRegions, aspectRatio: normalizedEditor.aspectRatio, + webcamLayoutPreset: normalizedEditor.webcamLayoutPreset, }); setExportQuality(normalizedEditor.exportQuality); setExportFormat(normalizedEditor.exportFormat); @@ -240,6 +242,7 @@ export default function VideoEditor() { speedRegions, annotationRegions, aspectRatio, + webcamLayoutPreset, exportQuality, exportFormat, gifFrameRate, @@ -261,6 +264,7 @@ export default function VideoEditor() { speedRegions, annotationRegions, aspectRatio, + webcamLayoutPreset, exportQuality, exportFormat, gifFrameRate, @@ -352,6 +356,7 @@ export default function VideoEditor() { speedRegions, annotationRegions, aspectRatio, + webcamLayoutPreset, exportQuality, exportFormat, gifFrameRate, @@ -404,6 +409,7 @@ export default function VideoEditor() { speedRegions, annotationRegions, aspectRatio, + webcamLayoutPreset, exportQuality, exportFormat, gifFrameRate, @@ -1021,6 +1027,7 @@ export default function VideoEditor() { videoPadding: padding, cropRegion, annotationRegions, + webcamLayoutPreset, previewWidth, previewHeight, onProgress: (progress: ExportProgress) => { @@ -1148,6 +1155,7 @@ export default function VideoEditor() { padding, cropRegion, annotationRegions, + webcamLayoutPreset, previewWidth, previewHeight, onProgress: (progress: ExportProgress) => { @@ -1212,6 +1220,7 @@ export default function VideoEditor() { annotationRegions, isPlaying, aspectRatio, + webcamLayoutPreset, exportQuality, handleExportSaved, ], @@ -1351,6 +1360,7 @@ export default function VideoEditor() { ref={videoPlaybackRef} videoPath={videoPath || ""} webcamVideoPath={webcamVideoPath || undefined} + webcamLayoutPreset={webcamLayoutPreset} onDurationChange={setDuration} onTimeUpdate={setCurrentTime} currentTime={currentTime} @@ -1474,6 +1484,9 @@ export default function VideoEditor() { cropRegion={cropRegion} onCropChange={(r) => pushState({ cropRegion: r })} aspectRatio={aspectRatio} + hasWebcam={Boolean(webcamVideoPath)} + webcamLayoutPreset={webcamLayoutPreset} + onWebcamLayoutPresetChange={(preset) => pushState({ webcamLayoutPreset: preset })} 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 85029fc..f576ae3 100644 --- a/src/components/video-editor/VideoPlayback.tsx +++ b/src/components/video-editor/VideoPlayback.tsx @@ -19,7 +19,12 @@ import { useState, } from "react"; import { getAssetPath } from "@/lib/assetPath"; -import { computeWebcamOverlayLayout, type WebcamOverlayLayout } from "@/lib/webcamOverlay"; +import { + computeWebcamOverlayLayout, + getWebcamLayoutCssBoxShadow, + type WebcamLayoutPreset, + type WebcamOverlayLayout, +} from "@/lib/webcamOverlay"; import { type AspectRatio, formatAspectRatioForCSS, @@ -57,6 +62,7 @@ import { interface VideoPlaybackProps { videoPath: string; webcamVideoPath?: string; + webcamLayoutPreset: WebcamLayoutPreset; onDurationChange: (duration: number) => void; onTimeUpdate: (time: number) => void; currentTime: number; @@ -101,6 +107,7 @@ const VideoPlayback = forwardRef( { videoPath, webcamVideoPath, + webcamLayoutPreset, onDurationChange, onTimeUpdate, currentTime, @@ -149,6 +156,10 @@ const VideoPlayback = forwardRef( width: number; height: number; } | null>(null); + const [screenVideoDimensions, setScreenVideoDimensions] = useState<{ + width: number; + height: number; + } | null>(null); const currentTimeRef = useRef(0); const zoomRegionsRef = useRef([]); const selectedZoomIdRef = useRef(null); @@ -609,6 +620,9 @@ const VideoPlayback = forwardRef( cancelAnimationFrame(videoReadyRafRef.current); videoReadyRafRef.current = null; } + if (video.videoWidth > 0 && video.videoHeight > 0) { + setScreenVideoDimensions({ width: video.videoWidth, height: video.videoHeight }); + } }, [videoPath]); useEffect(() => { @@ -910,6 +924,10 @@ const VideoPlayback = forwardRef( }; const [resolvedWallpaper, setResolvedWallpaper] = useState(null); + const webcamCssBoxShadow = useMemo( + () => getWebcamLayoutCssBoxShadow(webcamLayoutPreset), + [webcamLayoutPreset], + ); useEffect(() => { const webcamVideo = webcamVideoRef.current; @@ -936,7 +954,7 @@ const VideoPlayback = forwardRef( useEffect(() => { const stage = stageRef.current; - if (!stage || !webcamDimensions) { + if (!stage || !webcamDimensions || !screenVideoDimensions) { setWebcamLayout(null); return; } @@ -947,6 +965,9 @@ const VideoPlayback = forwardRef( stageHeight: stage.clientHeight, videoWidth: webcamDimensions.width, videoHeight: webcamDimensions.height, + layoutPreset: webcamLayoutPreset, + screenVideoWidth: screenVideoDimensions?.width, + screenVideoHeight: screenVideoDimensions?.height, }); setWebcamLayout(layout); }; @@ -960,7 +981,7 @@ const VideoPlayback = forwardRef( const observer = new ResizeObserver(updateLayout); observer.observe(stage); return () => observer.disconnect(); - }, [webcamDimensions]); + }, [screenVideoDimensions, webcamDimensions, webcamLayoutPreset]); useEffect(() => { const webcamVideo = webcamVideoRef.current; @@ -1109,7 +1130,7 @@ const VideoPlayback = forwardRef( : "none", }} /> - {webcamVideoPath && ( + {webcamVideoPath && screenVideoDimensions && (