From a0682e67164141f73f5f28b9434e730f1b89bca3 Mon Sep 17 00:00:00 2001 From: Marcus Schiesser Date: Thu, 19 Mar 2026 13:05:42 +0800 Subject: [PATCH 1/5] 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 4e0c0676..500356b6 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 cbf9b29b..6b2e6fb4 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 85029fcc..f576ae39 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 && (
- - setShowExportDialog(false)} diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index 707b94f7..3b417c81 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -1,6 +1,7 @@ import { fixWebmDuration } from "@fix-webm-duration/fix"; import { useCallback, useEffect, useRef, useState } from "react"; import { toast } from "sonner"; +import { requestCameraAccess } from "@/lib/requestCameraAccess"; const TARGET_FRAME_RATE = 60; const MIN_FRAME_RATE = 30; @@ -157,7 +158,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { return true; } - const accessResult = await window.electronAPI.requestCameraAccess(); + const accessResult = await requestCameraAccess(); if (!accessResult.success) { toast.error("Failed to request camera access."); return false; @@ -168,19 +169,8 @@ export function useScreenRecorder(): UseScreenRecorderReturn { return false; } - try { - const probeStream = await navigator.mediaDevices.getUserMedia({ - audio: false, - video: true, - }); - probeStream.getTracks().forEach((track) => track.stop()); - setWebcamEnabledState(true); - return true; - } catch (error) { - console.warn("Failed to preflight webcam access:", error); - toast.error("Camera access denied. Webcam overlay will stay disabled."); - return false; - } + setWebcamEnabledState(true); + return true; }, []); const finalizeRecording = useCallback( diff --git a/src/lib/requestCameraAccess.ts b/src/lib/requestCameraAccess.ts new file mode 100644 index 00000000..ed18f6f8 --- /dev/null +++ b/src/lib/requestCameraAccess.ts @@ -0,0 +1,48 @@ +export type CameraAccessResult = { + success: boolean; + granted: boolean; + status: string; + error?: string; +}; + +function getDeniedStatus(error: unknown) { + if (error instanceof DOMException) { + return error.name; + } + + return "unknown"; +} + +export async function requestCameraAccess(): Promise { + if (window.electronAPI?.requestCameraAccess) { + const electronResult = await window.electronAPI.requestCameraAccess(); + if (!electronResult.success || !electronResult.granted) { + return electronResult; + } + } + + if (!navigator.mediaDevices?.getUserMedia) { + return { + success: false, + granted: false, + status: "unsupported", + error: "Camera access is not supported in this runtime.", + }; + } + + try { + const stream = await navigator.mediaDevices.getUserMedia({ + audio: false, + video: true, + }); + stream.getTracks().forEach((track) => track.stop()); + return { success: true, granted: true, status: "granted" }; + } catch (error) { + return { + success: true, + granted: false, + status: getDeniedStatus(error), + error: String(error), + }; + } +} From 83a60926d87c6f2155c3a33cbf43233de9d489f2 Mon Sep 17 00:00:00 2001 From: Marcus Schiesser Date: Thu, 19 Mar 2026 17:51:51 +0800 Subject: [PATCH 3/5] fix: center stacked screen and webcam layout --- src/components/video-editor/SettingsPanel.tsx | 66 +++-- src/components/video-editor/VideoPlayback.tsx | 67 ++--- src/components/video-editor/types.ts | 2 +- .../video-editor/videoPlayback/layoutUtils.ts | 60 +++-- src/lib/compositeLayout.test.ts | 78 ++++++ src/lib/compositeLayout.ts | 250 ++++++++++++++++++ src/lib/exporter/frameRenderer.ts | 100 +++---- src/lib/exporter/gifExporter.ts | 3 +- src/lib/exporter/videoExporter.ts | 3 +- src/lib/webcamOverlay.test.ts | 63 ----- src/lib/webcamOverlay.ts | 196 -------------- 11 files changed, 482 insertions(+), 406 deletions(-) create mode 100644 src/lib/compositeLayout.test.ts create mode 100644 src/lib/compositeLayout.ts delete mode 100644 src/lib/webcamOverlay.test.ts delete mode 100644 src/lib/webcamOverlay.ts diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index 500356b6..3d546d0b 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -36,10 +36,10 @@ import { Slider } from "@/components/ui/slider"; import { Switch } from "@/components/ui/switch"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { getAssetPath } from "@/lib/assetPath"; +import { WEBCAM_LAYOUT_PRESETS } from "@/lib/compositeLayout"; 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"; @@ -582,28 +582,25 @@ export function SettingsPanel({ )}
- - - -
- - Video Effects -
-
- -
-
-
Blur BG
- + + {hasWebcam && ( + + +
+ + Layout
-
- {hasWebcam && ( -
-
Webcam Layout
+ + +
+
Preset
- )} +
+ + )} + + + +
+ + Video Effects +
+
+ +
+
+
Blur BG
+ +
+
diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx index f576ae39..24cd1870 100644 --- a/src/components/video-editor/VideoPlayback.tsx +++ b/src/components/video-editor/VideoPlayback.tsx @@ -20,11 +20,11 @@ import { } from "react"; import { getAssetPath } from "@/lib/assetPath"; import { - computeWebcamOverlayLayout, getWebcamLayoutCssBoxShadow, + type Size, + type StyledRenderRect, type WebcamLayoutPreset, - type WebcamOverlayLayout, -} from "@/lib/webcamOverlay"; +} from "@/lib/compositeLayout"; import { type AspectRatio, formatAspectRatioForCSS, @@ -141,7 +141,6 @@ const VideoPlayback = forwardRef( const videoRef = useRef(null); const webcamVideoRef = useRef(null); const containerRef = useRef(null); - const stageRef = useRef(null); const appRef = useRef(null); const videoSpriteRef = useRef(null); const videoContainerRef = useRef(null); @@ -151,15 +150,8 @@ const VideoPlayback = forwardRef( const [videoReady, setVideoReady] = useState(false); const overlayRef = useRef(null); const focusIndicatorRef = useRef(null); - const [webcamLayout, setWebcamLayout] = useState(null); - const [webcamDimensions, setWebcamDimensions] = useState<{ - width: number; - height: number; - } | null>(null); - const [screenVideoDimensions, setScreenVideoDimensions] = useState<{ - width: number; - height: number; - } | null>(null); + const [webcamLayout, setWebcamLayout] = useState(null); + const [webcamDimensions, setWebcamDimensions] = useState(null); const currentTimeRef = useRef(0); const zoomRegionsRef = useRef([]); const selectedZoomIdRef = useRef(null); @@ -269,6 +261,8 @@ const VideoPlayback = forwardRef( lockedVideoDimensions: lockedVideoDimensionsRef.current, borderRadius, padding, + webcamDimensions, + webcamLayoutPreset, }); if (result) { @@ -278,6 +272,7 @@ const VideoPlayback = forwardRef( baseOffsetRef.current = result.baseOffset; baseMaskRef.current = result.maskRect; cropBoundsRef.current = result.cropBounds; + setWebcamLayout(result.webcamRect); // Reset camera container to identity cameraContainer.scale.set(1); @@ -290,7 +285,14 @@ const VideoPlayback = forwardRef( updateOverlayForRegion(activeRegion); } - }, [updateOverlayForRegion, cropRegion, borderRadius, padding]); + }, [ + updateOverlayForRegion, + cropRegion, + borderRadius, + padding, + webcamDimensions, + webcamLayoutPreset, + ]); useEffect(() => { layoutVideoContentRef.current = layoutVideoContent; @@ -620,9 +622,6 @@ 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(() => { @@ -952,37 +951,6 @@ const VideoPlayback = forwardRef( }; }, [webcamVideoPath]); - useEffect(() => { - const stage = stageRef.current; - if (!stage || !webcamDimensions || !screenVideoDimensions) { - setWebcamLayout(null); - return; - } - - const updateLayout = () => { - const layout = computeWebcamOverlayLayout({ - stageWidth: stage.clientWidth, - stageHeight: stage.clientHeight, - videoWidth: webcamDimensions.width, - videoHeight: webcamDimensions.height, - layoutPreset: webcamLayoutPreset, - screenVideoWidth: screenVideoDimensions?.width, - screenVideoHeight: screenVideoDimensions?.height, - }); - setWebcamLayout(layout); - }; - - updateLayout(); - - if (typeof ResizeObserver === "undefined") { - return; - } - - const observer = new ResizeObserver(updateLayout); - observer.observe(stage); - return () => observer.disconnect(); - }, [screenVideoDimensions, webcamDimensions, webcamLayoutPreset]); - useEffect(() => { const webcamVideo = webcamVideoRef.current; if (!webcamVideo || !webcamVideoPath) { @@ -1096,7 +1064,6 @@ const VideoPlayback = forwardRef( return (
( : "none", }} /> - {webcamVideoPath && screenVideoDimensions && ( + {webcamVideoPath && (