diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx
index cbf9b29b..08ef575b 100644
--- a/src/components/video-editor/VideoEditor.tsx
+++ b/src/components/video-editor/VideoEditor.tsx
@@ -2,7 +2,6 @@ import type { Span } from "dnd-timeline";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels";
import { toast } from "sonner";
-import { Toaster } from "@/components/ui/sonner";
import { useShortcuts } from "@/contexts/ShortcutsContext";
import { INITIAL_EDITOR_STATE, useEditorHistory } from "@/hooks/useEditorHistory";
import {
@@ -76,6 +75,7 @@ export default function VideoEditor() {
borderRadius,
padding,
aspectRatio,
+ webcamLayoutPreset,
} = editorState;
// ── Non-undoable state
@@ -173,6 +173,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 +241,7 @@ export default function VideoEditor() {
speedRegions,
annotationRegions,
aspectRatio,
+ webcamLayoutPreset,
exportQuality,
exportFormat,
gifFrameRate,
@@ -261,6 +263,7 @@ export default function VideoEditor() {
speedRegions,
annotationRegions,
aspectRatio,
+ webcamLayoutPreset,
exportQuality,
exportFormat,
gifFrameRate,
@@ -352,6 +355,7 @@ export default function VideoEditor() {
speedRegions,
annotationRegions,
aspectRatio,
+ webcamLayoutPreset,
exportQuality,
exportFormat,
gifFrameRate,
@@ -404,6 +408,7 @@ export default function VideoEditor() {
speedRegions,
annotationRegions,
aspectRatio,
+ webcamLayoutPreset,
exportQuality,
exportFormat,
gifFrameRate,
@@ -1021,6 +1026,7 @@ export default function VideoEditor() {
videoPadding: padding,
cropRegion,
annotationRegions,
+ webcamLayoutPreset,
previewWidth,
previewHeight,
onProgress: (progress: ExportProgress) => {
@@ -1148,6 +1154,7 @@ export default function VideoEditor() {
padding,
cropRegion,
annotationRegions,
+ webcamLayoutPreset,
previewWidth,
previewHeight,
onProgress: (progress: ExportProgress) => {
@@ -1212,6 +1219,7 @@ export default function VideoEditor() {
annotationRegions,
isPlaying,
aspectRatio,
+ webcamLayoutPreset,
exportQuality,
handleExportSaved,
],
@@ -1351,6 +1359,7 @@ export default function VideoEditor() {
ref={videoPlaybackRef}
videoPath={videoPath || ""}
webcamVideoPath={webcamVideoPath || undefined}
+ webcamLayoutPreset={webcamLayoutPreset}
onDurationChange={setDuration}
onTimeUpdate={setCurrentTime}
currentTime={currentTime}
@@ -1474,6 +1483,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}
@@ -1521,8 +1533,6 @@ export default function VideoEditor() {
-
-
setShowExportDialog(false)}
diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx
index 85029fcc..24cd1870 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 {
+ getWebcamLayoutCssBoxShadow,
+ type Size,
+ type StyledRenderRect,
+ type WebcamLayoutPreset,
+} from "@/lib/compositeLayout";
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,
@@ -134,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);
@@ -144,11 +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 [webcamLayout, setWebcamLayout] = useState(null);
+ const [webcamDimensions, setWebcamDimensions] = useState(null);
const currentTimeRef = useRef(0);
const zoomRegionsRef = useRef([]);
const selectedZoomIdRef = useRef(null);
@@ -258,6 +261,8 @@ const VideoPlayback = forwardRef(
lockedVideoDimensions: lockedVideoDimensionsRef.current,
borderRadius,
padding,
+ webcamDimensions,
+ webcamLayoutPreset,
});
if (result) {
@@ -267,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);
@@ -279,7 +285,14 @@ const VideoPlayback = forwardRef(
updateOverlayForRegion(activeRegion);
}
- }, [updateOverlayForRegion, cropRegion, borderRadius, padding]);
+ }, [
+ updateOverlayForRegion,
+ cropRegion,
+ borderRadius,
+ padding,
+ webcamDimensions,
+ webcamLayoutPreset,
+ ]);
useEffect(() => {
layoutVideoContentRef.current = layoutVideoContent;
@@ -910,6 +923,10 @@ const VideoPlayback = forwardRef(
};
const [resolvedWallpaper, setResolvedWallpaper] = useState(null);
+ const webcamCssBoxShadow = useMemo(
+ () => getWebcamLayoutCssBoxShadow(webcamLayoutPreset),
+ [webcamLayoutPreset],
+ );
useEffect(() => {
const webcamVideo = webcamVideoRef.current;
@@ -934,34 +951,6 @@ const VideoPlayback = forwardRef(
};
}, [webcamVideoPath]);
- useEffect(() => {
- const stage = stageRef.current;
- if (!stage || !webcamDimensions) {
- setWebcamLayout(null);
- return;
- }
-
- const updateLayout = () => {
- const layout = computeWebcamOverlayLayout({
- stageWidth: stage.clientWidth,
- stageHeight: stage.clientHeight,
- videoWidth: webcamDimensions.width,
- videoHeight: webcamDimensions.height,
- });
- setWebcamLayout(layout);
- };
-
- updateLayout();
-
- if (typeof ResizeObserver === "undefined") {
- return;
- }
-
- const observer = new ResizeObserver(updateLayout);
- observer.observe(stage);
- return () => observer.disconnect();
- }, [webcamDimensions]);
-
useEffect(() => {
const webcamVideo = webcamVideoRef.current;
if (!webcamVideo || !webcamVideoPath) {
@@ -1075,7 +1064,6 @@ const VideoPlayback = forwardRef(
return (
(
width: webcamLayout?.width ?? 0,
height: webcamLayout?.height ?? 0,
borderRadius: webcamLayout?.borderRadius ?? 0,
- boxShadow: "0 12px 36px rgba(0,0,0,0.35), 0 4px 12px rgba(0,0,0,0.22)",
+ boxShadow: webcamCssBoxShadow,
zIndex: 20,
opacity: webcamLayout ? 1 : 0,
backgroundColor: "#000",
diff --git a/src/components/video-editor/projectPersistence.test.ts b/src/components/video-editor/projectPersistence.test.ts
index 239bd883..13ea358f 100644
--- a/src/components/video-editor/projectPersistence.test.ts
+++ b/src/components/video-editor/projectPersistence.test.ts
@@ -39,6 +39,7 @@ describe("projectPersistence media compatibility", () => {
speedRegions: [],
annotationRegions: [],
aspectRatio: "16:9",
+ webcamLayoutPreset: "picture-in-picture",
exportQuality: "good",
exportFormat: "mp4",
gifFrameRate: 15,
diff --git a/src/components/video-editor/projectPersistence.ts b/src/components/video-editor/projectPersistence.ts
index 5bb144df..dd9cc3df 100644
--- a/src/components/video-editor/projectPersistence.ts
+++ b/src/components/video-editor/projectPersistence.ts
@@ -11,9 +11,11 @@ import {
DEFAULT_CROP_REGION,
DEFAULT_FIGURE_DATA,
DEFAULT_PLAYBACK_SPEED,
+ DEFAULT_WEBCAM_LAYOUT_PRESET,
DEFAULT_ZOOM_DEPTH,
type SpeedRegion,
type TrimRegion,
+ type WebcamLayoutPreset,
type ZoomRegion,
} from "./types";
@@ -39,6 +41,7 @@ export interface ProjectEditorState {
speedRegions: SpeedRegion[];
annotationRegions: AnnotationRegion[];
aspectRatio: AspectRatio;
+ webcamLayoutPreset: WebcamLayoutPreset;
exportQuality: ExportQuality;
exportFormat: ExportFormat;
gifFrameRate: GifFrameRate;
@@ -341,6 +344,11 @@ export function normalizeProjectEditor(editor: Partial
): Pro
annotationRegions: normalizedAnnotationRegions,
aspectRatio:
editor.aspectRatio && validAspectRatios.has(editor.aspectRatio) ? editor.aspectRatio : "16:9",
+ webcamLayoutPreset:
+ editor.webcamLayoutPreset === "vertical-stack" ||
+ editor.webcamLayoutPreset === "picture-in-picture"
+ ? editor.webcamLayoutPreset
+ : DEFAULT_WEBCAM_LAYOUT_PRESET,
exportQuality:
editor.exportQuality === "medium" || editor.exportQuality === "source"
? editor.exportQuality
diff --git a/src/components/video-editor/types.ts b/src/components/video-editor/types.ts
index 2b2d9460..5bd8f700 100644
--- a/src/components/video-editor/types.ts
+++ b/src/components/video-editor/types.ts
@@ -1,4 +1,9 @@
+import type { WebcamLayoutPreset } from "@/lib/compositeLayout";
+
export type ZoomDepth = 1 | 2 | 3 | 4 | 5 | 6;
+export type { WebcamLayoutPreset };
+
+export const DEFAULT_WEBCAM_LAYOUT_PRESET: WebcamLayoutPreset = "picture-in-picture";
export interface ZoomFocus {
cx: number; // normalized horizontal center (0-1)
diff --git a/src/components/video-editor/videoPlayback/layoutUtils.ts b/src/components/video-editor/videoPlayback/layoutUtils.ts
index 4daf8a51..166a0b92 100644
--- a/src/components/video-editor/videoPlayback/layoutUtils.ts
+++ b/src/components/video-editor/videoPlayback/layoutUtils.ts
@@ -1,4 +1,11 @@
import { Application, Graphics, Sprite } from "pixi.js";
+import {
+ computeCompositeLayout,
+ type RenderRect,
+ type Size,
+ type StyledRenderRect,
+ type WebcamLayoutPreset,
+} from "@/lib/compositeLayout";
import type { CropRegion } from "../types";
interface LayoutParams {
@@ -11,6 +18,8 @@ interface LayoutParams {
lockedVideoDimensions?: { width: number; height: number } | null;
borderRadius?: number;
padding?: number;
+ webcamDimensions?: Size | null;
+ webcamLayoutPreset?: WebcamLayoutPreset;
}
interface LayoutResult {
@@ -18,7 +27,8 @@ interface LayoutResult {
videoSize: { width: number; height: number };
baseScale: number;
baseOffset: { x: number; y: number };
- maskRect: { x: number; y: number; width: number; height: number };
+ maskRect: RenderRect;
+ webcamRect: StyledRenderRect | null;
cropBounds: { startX: number; endX: number; startY: number; endY: number };
}
@@ -33,6 +43,8 @@ export function layoutVideoContent(params: LayoutParams): LayoutResult | null {
lockedVideoDimensions,
borderRadius = 0,
padding = 0,
+ webcamDimensions,
+ webcamLayoutPreset,
} = params;
const videoWidth = lockedVideoDimensions?.width || videoElement.videoWidth;
@@ -71,11 +83,19 @@ export function layoutVideoContent(params: LayoutParams): LayoutResult | null {
const maxDisplayWidth = width * paddingScale;
const maxDisplayHeight = height * paddingScale;
- const scale = Math.min(
- maxDisplayWidth / croppedVideoWidth,
- maxDisplayHeight / croppedVideoHeight,
- 1,
- );
+ const compositeLayout = computeCompositeLayout({
+ canvasSize: { width, height },
+ maxContentSize: { width: maxDisplayWidth, height: maxDisplayHeight },
+ screenSize: { width: croppedVideoWidth, height: croppedVideoHeight },
+ webcamSize: webcamDimensions,
+ layoutPreset: webcamLayoutPreset,
+ });
+
+ if (!compositeLayout) {
+ return null;
+ }
+
+ const scale = compositeLayout.screenRect.width / croppedVideoWidth;
videoSprite.scale.set(scale);
@@ -84,30 +104,25 @@ export function layoutVideoContent(params: LayoutParams): LayoutResult | null {
const fullVideoDisplayHeight = videoHeight * scale;
// Calculate display size of just the cropped region
- const croppedDisplayWidth = croppedVideoWidth * scale;
- const croppedDisplayHeight = croppedVideoHeight * scale;
-
- // Center the cropped region in the container
- const centerOffsetX = (width - croppedDisplayWidth) / 2;
- const centerOffsetY = (height - croppedDisplayHeight) / 2;
-
// Position the full video sprite so that when we apply the mask,
// the cropped region appears centered
// The crop starts at (crop.x * videoWidth, crop.y * videoHeight) in video coordinates
// In display coordinates, that's (crop.x * fullVideoDisplayWidth, crop.y * fullVideoDisplayHeight)
- // We want that point to be at centerOffsetX, centerOffsetY
- const spriteX = centerOffsetX - crop.x * fullVideoDisplayWidth;
- const spriteY = centerOffsetY - crop.y * fullVideoDisplayHeight;
+ // We want that point to be at screenRect.x, screenRect.y
+ const spriteX = compositeLayout.screenRect.x - crop.x * fullVideoDisplayWidth;
+ const spriteY = compositeLayout.screenRect.y - crop.y * fullVideoDisplayHeight;
videoSprite.position.set(spriteX, spriteY);
- // Create a mask that only shows the cropped region (centered in container)
- const maskX = centerOffsetX;
- const maskY = centerOffsetY;
-
// Apply border radius
maskGraphics.clear();
- maskGraphics.roundRect(maskX, maskY, croppedDisplayWidth, croppedDisplayHeight, borderRadius);
+ maskGraphics.roundRect(
+ compositeLayout.screenRect.x,
+ compositeLayout.screenRect.y,
+ compositeLayout.screenRect.width,
+ compositeLayout.screenRect.height,
+ borderRadius,
+ );
maskGraphics.fill({ color: 0xffffff });
return {
@@ -115,7 +130,8 @@ export function layoutVideoContent(params: LayoutParams): LayoutResult | null {
videoSize: { width: croppedVideoWidth, height: croppedVideoHeight },
baseScale: scale,
baseOffset: { x: spriteX, y: spriteY },
- maskRect: { x: maskX, y: maskY, width: croppedDisplayWidth, height: croppedDisplayHeight },
+ maskRect: compositeLayout.screenRect,
+ webcamRect: compositeLayout.webcamRect,
cropBounds: { startX: cropStartX, endX: cropEndX, startY: cropStartY, endY: cropEndY },
};
}
diff --git a/src/hooks/useEditorHistory.ts b/src/hooks/useEditorHistory.ts
index 5b43a134..4f187495 100644
--- a/src/hooks/useEditorHistory.ts
+++ b/src/hooks/useEditorHistory.ts
@@ -4,9 +4,10 @@ import type {
CropRegion,
SpeedRegion,
TrimRegion,
+ WebcamLayoutPreset,
ZoomRegion,
} from "@/components/video-editor/types";
-import { DEFAULT_CROP_REGION } from "@/components/video-editor/types";
+import { DEFAULT_CROP_REGION, DEFAULT_WEBCAM_LAYOUT_PRESET } from "@/components/video-editor/types";
import type { AspectRatio } from "@/utils/aspectRatioUtils";
// Undoable state — selection IDs are intentionally excluded (undoing a
@@ -24,6 +25,7 @@ export interface EditorState {
borderRadius: number;
padding: number;
aspectRatio: AspectRatio;
+ webcamLayoutPreset: WebcamLayoutPreset;
}
export const INITIAL_EDITOR_STATE: EditorState = {
@@ -39,6 +41,7 @@ export const INITIAL_EDITOR_STATE: EditorState = {
borderRadius: 0,
padding: 50,
aspectRatio: "16:9",
+ webcamLayoutPreset: DEFAULT_WEBCAM_LAYOUT_PRESET,
};
type StateUpdate = Partial | ((prev: EditorState) => Partial);
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/compositeLayout.test.ts b/src/lib/compositeLayout.test.ts
new file mode 100644
index 00000000..5dc5339f
--- /dev/null
+++ b/src/lib/compositeLayout.test.ts
@@ -0,0 +1,78 @@
+import { describe, expect, it } from "vitest";
+import { computeCompositeLayout } from "./compositeLayout";
+
+describe("computeCompositeLayout", () => {
+ it("anchors the overlay in the lower-right corner", () => {
+ const layout = computeCompositeLayout({
+ canvasSize: { width: 1920, height: 1080 },
+ screenSize: { width: 1920, height: 1080 },
+ webcamSize: { width: 1280, height: 720 },
+ });
+
+ expect(layout).not.toBeNull();
+ expect(layout!.webcamRect).not.toBeNull();
+ expect(layout!.webcamRect!.x + layout!.webcamRect!.width).toBeLessThanOrEqual(1920);
+ expect(layout!.webcamRect!.y + layout!.webcamRect!.height).toBeLessThanOrEqual(1080);
+ expect(layout!.webcamRect!.x).toBeGreaterThan(1920 / 2);
+ expect(layout!.webcamRect!.y).toBeGreaterThan(1080 / 2);
+ });
+
+ it("keeps the overlay within the configured stage fraction while preserving aspect ratio", () => {
+ const layout = computeCompositeLayout({
+ canvasSize: { width: 1280, height: 720 },
+ screenSize: { width: 1280, height: 720 },
+ webcamSize: { width: 1920, height: 1080 },
+ });
+
+ expect(layout).not.toBeNull();
+ expect(layout!.webcamRect).not.toBeNull();
+ expect(layout!.webcamRect!.width).toBeLessThanOrEqual(Math.round(1280 * 0.18) + 1);
+ expect(layout!.webcamRect!.height).toBeLessThanOrEqual(Math.round(720 * 0.18) + 1);
+ expect(
+ Math.abs(layout!.webcamRect!.width * 1080 - layout!.webcamRect!.height * 1920),
+ ).toBeLessThanOrEqual(1920);
+ });
+
+ it("centers the combined screen and webcam stack in vertical stack mode", () => {
+ const layout = computeCompositeLayout({
+ canvasSize: { width: 1920, height: 1080 },
+ maxContentSize: { width: 1536, height: 864 },
+ screenSize: { width: 1920, height: 1080 },
+ webcamSize: { width: 1280, height: 720 },
+ layoutPreset: "vertical-stack",
+ });
+
+ expect(layout).not.toBeNull();
+ expect(layout?.screenRect).toEqual({
+ x: 576,
+ y: 108,
+ width: 768,
+ height: 432,
+ });
+ expect(layout?.webcamRect).toEqual({
+ x: 576,
+ y: 540,
+ width: 768,
+ height: 432,
+ borderRadius: 0,
+ });
+ });
+
+ it("keeps the screen centered and omits the webcam when dimensions are unavailable", () => {
+ const layout = computeCompositeLayout({
+ canvasSize: { width: 1920, height: 1080 },
+ maxContentSize: { width: 1536, height: 864 },
+ screenSize: { width: 1920, height: 1080 },
+ layoutPreset: "vertical-stack",
+ });
+
+ expect(layout).not.toBeNull();
+ expect(layout?.screenRect).toEqual({
+ x: 192,
+ y: 108,
+ width: 1536,
+ height: 864,
+ });
+ expect(layout?.webcamRect).toBeNull();
+ });
+});
diff --git a/src/lib/compositeLayout.ts b/src/lib/compositeLayout.ts
new file mode 100644
index 00000000..2b12b030
--- /dev/null
+++ b/src/lib/compositeLayout.ts
@@ -0,0 +1,250 @@
+export interface RenderRect {
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+}
+
+export interface StyledRenderRect extends RenderRect {
+ borderRadius: number;
+}
+
+export interface Size {
+ width: number;
+ height: number;
+}
+
+export type WebcamLayoutPreset = "picture-in-picture" | "vertical-stack";
+
+export interface WebcamLayoutShadow {
+ color: string;
+ blur: number;
+ offsetX: number;
+ offsetY: number;
+}
+
+interface BorderRadiusRule {
+ max: number;
+ min: number;
+ fraction: number;
+}
+
+interface OverlayTransform {
+ type: "overlay";
+ maxStageFraction: number;
+ marginFraction: number;
+ minMargin: number;
+ minSize: number;
+}
+
+interface StackTransform {
+ type: "stack";
+ gap: number;
+}
+
+export interface WebcamLayoutPresetDefinition {
+ label: string;
+ transform: OverlayTransform | StackTransform;
+ borderRadius: BorderRadiusRule;
+ shadow: WebcamLayoutShadow | null;
+}
+
+export interface WebcamCompositeLayout {
+ screenRect: RenderRect;
+ webcamRect: StyledRenderRect | null;
+}
+
+const MAX_STAGE_FRACTION = 0.18;
+const MARGIN_FRACTION = 0.02;
+const MIN_SIZE = 96;
+const MAX_BORDER_RADIUS = 24;
+const WEBCAM_LAYOUT_PRESET_MAP: Record = {
+ "picture-in-picture": {
+ label: "Picture in Picture",
+ transform: {
+ type: "overlay",
+ maxStageFraction: MAX_STAGE_FRACTION,
+ marginFraction: MARGIN_FRACTION,
+ minMargin: 12,
+ minSize: MIN_SIZE,
+ },
+ borderRadius: {
+ max: MAX_BORDER_RADIUS,
+ min: 12,
+ fraction: 0.12,
+ },
+ shadow: {
+ color: "rgba(0,0,0,0.35)",
+ blur: 24,
+ offsetX: 0,
+ offsetY: 10,
+ },
+ },
+ "vertical-stack": {
+ label: "Vertical Stack",
+ transform: {
+ type: "stack",
+ gap: 0,
+ },
+ borderRadius: {
+ max: 0,
+ min: 0,
+ fraction: 0,
+ },
+ shadow: null,
+ },
+};
+
+export const WEBCAM_LAYOUT_PRESETS = Object.entries(WEBCAM_LAYOUT_PRESET_MAP).map(
+ ([value, preset]) => ({
+ value: value as WebcamLayoutPreset,
+ label: preset.label,
+ }),
+);
+
+export function getWebcamLayoutPresetDefinition(
+ preset: WebcamLayoutPreset = "picture-in-picture",
+): WebcamLayoutPresetDefinition {
+ return WEBCAM_LAYOUT_PRESET_MAP[preset];
+}
+
+export function getWebcamLayoutCssBoxShadow(
+ preset: WebcamLayoutPreset = "picture-in-picture",
+): string {
+ const shadow = getWebcamLayoutPresetDefinition(preset).shadow;
+ return shadow
+ ? `${shadow.offsetX}px ${shadow.offsetY}px ${shadow.blur}px ${shadow.color}`
+ : "none";
+}
+
+export function computeCompositeLayout(params: {
+ canvasSize: Size;
+ maxContentSize?: Size;
+ screenSize: Size;
+ webcamSize?: Size | null;
+ layoutPreset?: WebcamLayoutPreset;
+}): WebcamCompositeLayout | null {
+ const {
+ canvasSize,
+ maxContentSize = canvasSize,
+ screenSize,
+ webcamSize,
+ layoutPreset = "picture-in-picture",
+ } = params;
+ const { width: canvasWidth, height: canvasHeight } = canvasSize;
+ const { width: maxContentWidth, height: maxContentHeight } = maxContentSize;
+ const { width: screenWidth, height: screenHeight } = screenSize;
+ const webcamWidth = webcamSize?.width;
+ const webcamHeight = webcamSize?.height;
+ const preset = getWebcamLayoutPresetDefinition(layoutPreset);
+
+ if (canvasWidth <= 0 || canvasHeight <= 0 || screenWidth <= 0 || screenHeight <= 0) {
+ return null;
+ }
+
+ if (preset.transform.type === "stack") {
+ if (!webcamWidth || !webcamHeight || webcamWidth <= 0 || webcamHeight <= 0) {
+ return {
+ screenRect: centerRect({
+ canvasSize,
+ size: screenSize,
+ maxSize: maxContentSize,
+ }),
+ webcamRect: null,
+ };
+ }
+
+ const gap = preset.transform.gap;
+ const normalizedWebcamHeight = webcamHeight * (screenWidth / webcamWidth);
+ const combinedHeight = screenHeight + gap + normalizedWebcamHeight;
+ const scale = Math.min(maxContentWidth / screenWidth, maxContentHeight / combinedHeight, 1);
+ const clampedScale = Number.isFinite(scale) && scale > 0 ? scale : 1;
+ const resolvedScreenHeight = Math.round(screenHeight * clampedScale);
+ const resolvedScreenWidth = Math.round(screenWidth * clampedScale);
+ const resolvedWebcamHeight = Math.round(normalizedWebcamHeight * clampedScale);
+ const resolvedGap = Math.round(gap * clampedScale);
+ const totalHeight = resolvedScreenHeight + resolvedGap + resolvedWebcamHeight;
+ const top = Math.max(0, Math.floor((canvasHeight - totalHeight) / 2));
+ const left = Math.max(0, Math.floor((canvasWidth - resolvedScreenWidth) / 2));
+ const screenRect = {
+ x: left,
+ y: top,
+ width: resolvedScreenWidth,
+ height: resolvedScreenHeight,
+ };
+
+ return {
+ screenRect,
+ webcamRect: {
+ x: left,
+ y: top + resolvedScreenHeight + resolvedGap,
+ width: resolvedScreenWidth,
+ height: resolvedWebcamHeight,
+ borderRadius: Math.min(
+ preset.borderRadius.max,
+ Math.max(
+ preset.borderRadius.min,
+ Math.round(
+ Math.min(resolvedScreenWidth, resolvedWebcamHeight) * preset.borderRadius.fraction,
+ ),
+ ),
+ ),
+ },
+ };
+ }
+
+ const transform = preset.transform;
+ const screenRect = centerRect({
+ canvasSize,
+ size: screenSize,
+ maxSize: maxContentSize,
+ });
+
+ if (!webcamWidth || !webcamHeight || webcamWidth <= 0 || webcamHeight <= 0) {
+ return { screenRect, webcamRect: null };
+ }
+
+ const margin = Math.max(
+ transform.minMargin,
+ Math.round(Math.min(canvasWidth, canvasHeight) * transform.marginFraction),
+ );
+ const maxWidth = Math.max(transform.minSize, canvasWidth * transform.maxStageFraction);
+ const maxHeight = Math.max(transform.minSize, canvasHeight * transform.maxStageFraction);
+ const scale = Math.min(maxWidth / webcamWidth, maxHeight / webcamHeight);
+ const width = Math.round(webcamWidth * scale);
+ const height = Math.round(webcamHeight * scale);
+
+ return {
+ screenRect,
+ webcamRect: {
+ x: Math.max(0, Math.round(canvasWidth - margin - width)),
+ y: Math.max(0, Math.round(canvasHeight - margin - height)),
+ width,
+ height,
+ borderRadius: Math.min(
+ preset.borderRadius.max,
+ Math.max(
+ preset.borderRadius.min,
+ Math.round(Math.min(width, height) * preset.borderRadius.fraction),
+ ),
+ ),
+ },
+ };
+}
+
+function centerRect(params: { canvasSize: Size; size: Size; maxSize: Size }): RenderRect {
+ const { canvasSize, size, maxSize } = params;
+ const { width: canvasWidth, height: canvasHeight } = canvasSize;
+ const { width, height } = size;
+ const { width: maxWidth, height: maxHeight } = maxSize;
+ const scale = Math.min(maxWidth / width, maxHeight / height, 1);
+ const resolvedWidth = Math.round(width * scale);
+ const resolvedHeight = Math.round(height * scale);
+
+ return {
+ x: Math.max(0, Math.floor((canvasWidth - resolvedWidth) / 2)),
+ y: Math.max(0, Math.floor((canvasHeight - resolvedHeight) / 2)),
+ width: resolvedWidth,
+ height: resolvedHeight,
+ };
+}
diff --git a/src/lib/exporter/frameRenderer.ts b/src/lib/exporter/frameRenderer.ts
index c6de0c55..b928df27 100644
--- a/src/lib/exporter/frameRenderer.ts
+++ b/src/lib/exporter/frameRenderer.ts
@@ -12,6 +12,7 @@ import type {
AnnotationRegion,
CropRegion,
SpeedRegion,
+ WebcamLayoutPreset,
ZoomDepth,
ZoomRegion,
} from "@/components/video-editor/types";
@@ -30,7 +31,12 @@ import {
createMotionBlurState,
type MotionBlurState,
} from "@/components/video-editor/videoPlayback/zoomTransform";
-import { computeWebcamOverlayLayout } from "@/lib/webcamOverlay";
+import {
+ computeCompositeLayout,
+ getWebcamLayoutPresetDefinition,
+ type Size,
+ type StyledRenderRect,
+} from "@/lib/compositeLayout";
import { renderAnnotations } from "./annotationRenderer";
interface FrameRenderConfig {
@@ -47,8 +53,8 @@ interface FrameRenderConfig {
cropRegion: CropRegion;
videoWidth: number;
videoHeight: number;
- webcamWidth?: number;
- webcamHeight?: number;
+ webcamSize?: Size | null;
+ webcamLayoutPreset?: WebcamLayoutPreset;
annotationRegions?: AnnotationRegion[];
speedRegions?: SpeedRegion[];
previewWidth?: number;
@@ -71,6 +77,7 @@ interface LayoutCache {
baseScale: number;
baseOffset: { x: number; y: number };
maskRect: { x: number; y: number; width: number; height: number };
+ webcamRect: StyledRenderRect | null;
}
// Renders video frames with all effects (background, zoom, crop, blur, shadow) to an offscreen canvas for export.
@@ -329,7 +336,7 @@ export class FrameRenderer {
}
// Apply layout
- this.updateLayout();
+ this.updateLayout(webcamFrame);
const timeMs = this.currentVideoTime * 1000;
const TICKS_PER_FRAME = 1;
@@ -393,7 +400,7 @@ export class FrameRenderer {
}
}
- private updateLayout(): void {
+ private updateLayout(webcamFrame?: VideoFrame | null): void {
if (!this.app || !this.videoSprite || !this.maskGraphics || !this.videoContainer) return;
const { width, height } = this.config;
@@ -415,7 +422,16 @@ export class FrameRenderer {
const paddingScale = 1.0 - (padding / 100) * 0.4;
const viewportWidth = width * paddingScale;
const viewportHeight = height * paddingScale;
- const scale = Math.min(viewportWidth / croppedVideoWidth, viewportHeight / croppedVideoHeight);
+ const compositeLayout = computeCompositeLayout({
+ canvasSize: { width, height },
+ maxContentSize: { width: viewportWidth, height: viewportHeight },
+ screenSize: { width: croppedVideoWidth, height: croppedVideoHeight },
+ webcamSize: webcamFrame ? this.config.webcamSize : null,
+ layoutPreset: this.config.webcamLayoutPreset,
+ });
+ if (!compositeLayout) return;
+
+ const scale = compositeLayout.screenRect.width / croppedVideoWidth;
// Position video sprite
this.videoSprite.width = videoWidth * scale;
@@ -427,12 +443,10 @@ export class FrameRenderer {
this.videoSprite.y = -cropPixelY;
// Position video container
- const croppedDisplayWidth = croppedVideoWidth * scale;
- const croppedDisplayHeight = croppedVideoHeight * scale;
- const centerOffsetX = (width - croppedDisplayWidth) / 2;
- const centerOffsetY = (height - croppedDisplayHeight) / 2;
- this.videoContainer.x = centerOffsetX;
- this.videoContainer.y = centerOffsetY;
+ const croppedDisplayWidth = compositeLayout.screenRect.width;
+ const croppedDisplayHeight = compositeLayout.screenRect.height;
+ this.videoContainer.x = compositeLayout.screenRect.x;
+ this.videoContainer.y = compositeLayout.screenRect.y;
// scale border radius by export/preview canvas ratio
const previewWidth = this.config.previewWidth || 1920;
@@ -455,8 +469,9 @@ export class FrameRenderer {
stageSize: { width, height },
videoSize: { width: croppedVideoWidth, height: croppedVideoHeight },
baseScale: scale,
- baseOffset: { x: centerOffsetX, y: centerOffsetY },
- maskRect: { x: 0, y: 0, width: croppedDisplayWidth, height: croppedDisplayHeight },
+ baseOffset: { x: compositeLayout.screenRect.x, y: compositeLayout.screenRect.y },
+ maskRect: compositeLayout.screenRect,
+ webcamRect: compositeLayout.webcamRect,
};
}
@@ -628,34 +643,36 @@ export class FrameRenderer {
ctx.drawImage(videoCanvas, 0, 0, w, h);
}
- if (webcamFrame && this.config.webcamWidth && this.config.webcamHeight) {
- const layout = computeWebcamOverlayLayout({
- stageWidth: w,
- stageHeight: h,
- videoWidth: this.config.webcamWidth,
- videoHeight: this.config.webcamHeight,
- });
-
- if (layout) {
- ctx.save();
- ctx.beginPath();
- ctx.roundRect(layout.x, layout.y, layout.width, layout.height, layout.borderRadius);
- ctx.closePath();
- ctx.shadowColor = "rgba(0,0,0,0.35)";
- ctx.shadowBlur = 24;
- ctx.shadowOffsetY = 10;
- ctx.fillStyle = "#000000";
- ctx.fill();
- ctx.clip();
- ctx.drawImage(
- webcamFrame as unknown as CanvasImageSource,
- layout.x,
- layout.y,
- layout.width,
- layout.height,
- );
- ctx.restore();
+ const webcamRect = this.layoutCache?.webcamRect ?? null;
+ if (webcamFrame && webcamRect) {
+ const preset = getWebcamLayoutPresetDefinition(this.config.webcamLayoutPreset);
+ ctx.save();
+ ctx.beginPath();
+ ctx.roundRect(
+ webcamRect.x,
+ webcamRect.y,
+ webcamRect.width,
+ webcamRect.height,
+ webcamRect.borderRadius,
+ );
+ ctx.closePath();
+ if (preset.shadow) {
+ ctx.shadowColor = preset.shadow.color;
+ ctx.shadowBlur = preset.shadow.blur;
+ ctx.shadowOffsetX = preset.shadow.offsetX;
+ ctx.shadowOffsetY = preset.shadow.offsetY;
}
+ ctx.fillStyle = "#000000";
+ ctx.fill();
+ ctx.clip();
+ ctx.drawImage(
+ webcamFrame as unknown as CanvasImageSource,
+ webcamRect.x,
+ webcamRect.y,
+ webcamRect.width,
+ webcamRect.height,
+ );
+ ctx.restore();
}
}
diff --git a/src/lib/exporter/gifExporter.ts b/src/lib/exporter/gifExporter.ts
index b9067567..f19da5b4 100644
--- a/src/lib/exporter/gifExporter.ts
+++ b/src/lib/exporter/gifExporter.ts
@@ -4,6 +4,7 @@ import type {
CropRegion,
SpeedRegion,
TrimRegion,
+ WebcamLayoutPreset,
ZoomRegion,
} from "@/components/video-editor/types";
import { AsyncVideoFrameQueue } from "./asyncVideoFrameQueue";
@@ -39,6 +40,7 @@ interface GifExporterConfig {
padding?: number;
videoPadding?: number;
cropRegion: CropRegion;
+ webcamLayoutPreset?: WebcamLayoutPreset;
annotationRegions?: AnnotationRegion[];
previewWidth?: number;
previewHeight?: number;
@@ -136,8 +138,8 @@ export class GifExporter {
cropRegion: this.config.cropRegion,
videoWidth: videoInfo.width,
videoHeight: videoInfo.height,
- webcamWidth: webcamInfo?.width,
- webcamHeight: webcamInfo?.height,
+ webcamSize: webcamInfo ? { width: webcamInfo.width, height: webcamInfo.height } : null,
+ webcamLayoutPreset: this.config.webcamLayoutPreset,
annotationRegions: this.config.annotationRegions,
speedRegions: this.config.speedRegions,
previewWidth: this.config.previewWidth,
diff --git a/src/lib/exporter/videoExporter.ts b/src/lib/exporter/videoExporter.ts
index c80d4700..aaa4d452 100644
--- a/src/lib/exporter/videoExporter.ts
+++ b/src/lib/exporter/videoExporter.ts
@@ -3,6 +3,7 @@ import type {
CropRegion,
SpeedRegion,
TrimRegion,
+ WebcamLayoutPreset,
ZoomRegion,
} from "@/components/video-editor/types";
import { AsyncVideoFrameQueue } from "./asyncVideoFrameQueue";
@@ -27,6 +28,7 @@ interface VideoExporterConfig extends ExportConfig {
padding?: number;
videoPadding?: number;
cropRegion: CropRegion;
+ webcamLayoutPreset?: WebcamLayoutPreset;
annotationRegions?: AnnotationRegion[];
previewWidth?: number;
previewHeight?: number;
@@ -85,8 +87,8 @@ export class VideoExporter {
cropRegion: this.config.cropRegion,
videoWidth: videoInfo.width,
videoHeight: videoInfo.height,
- webcamWidth: webcamInfo?.width,
- webcamHeight: webcamInfo?.height,
+ webcamSize: webcamInfo ? { width: webcamInfo.width, height: webcamInfo.height } : null,
+ webcamLayoutPreset: this.config.webcamLayoutPreset,
annotationRegions: this.config.annotationRegions,
speedRegions: this.config.speedRegions,
previewWidth: this.config.previewWidth,
diff --git a/src/lib/requestCameraAccess.ts b/src/lib/requestCameraAccess.ts
new file mode 100644
index 00000000..24942240
--- /dev/null
+++ b/src/lib/requestCameraAccess.ts
@@ -0,0 +1,57 @@
+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) {
+ try {
+ const electronResult = await window.electronAPI.requestCameraAccess();
+ if (!electronResult.success || !electronResult.granted) {
+ return electronResult;
+ }
+ } catch (error) {
+ return {
+ success: false,
+ granted: false,
+ status: "error",
+ error: String(error),
+ };
+ }
+ }
+
+ 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),
+ };
+ }
+}
diff --git a/src/lib/webcamOverlay.test.ts b/src/lib/webcamOverlay.test.ts
deleted file mode 100644
index 24e40b34..00000000
--- a/src/lib/webcamOverlay.test.ts
+++ /dev/null
@@ -1,33 +0,0 @@
-import { describe, expect, it } from "vitest";
-import { computeWebcamOverlayLayout } from "./webcamOverlay";
-
-describe("computeWebcamOverlayLayout", () => {
- it("anchors the overlay in the lower-right corner", () => {
- const layout = computeWebcamOverlayLayout({
- stageWidth: 1920,
- stageHeight: 1080,
- videoWidth: 1280,
- videoHeight: 720,
- });
-
- expect(layout).not.toBeNull();
- expect(layout!.x + layout!.width).toBeLessThanOrEqual(1920);
- expect(layout!.y + layout!.height).toBeLessThanOrEqual(1080);
- expect(layout!.x).toBeGreaterThan(1920 / 2);
- expect(layout!.y).toBeGreaterThan(1080 / 2);
- });
-
- it("keeps the overlay within the configured stage fraction while preserving aspect ratio", () => {
- const layout = computeWebcamOverlayLayout({
- stageWidth: 1280,
- stageHeight: 720,
- videoWidth: 1920,
- videoHeight: 1080,
- });
-
- expect(layout).not.toBeNull();
- expect(layout!.width).toBeLessThanOrEqual(Math.round(1280 * 0.18) + 1);
- expect(layout!.height).toBeLessThanOrEqual(Math.round(720 * 0.18) + 1);
- expect(Math.abs(layout!.width * 1080 - layout!.height * 1920)).toBeLessThanOrEqual(1920);
- });
-});
diff --git a/src/lib/webcamOverlay.ts b/src/lib/webcamOverlay.ts
deleted file mode 100644
index 8e877f9e..00000000
--- a/src/lib/webcamOverlay.ts
+++ /dev/null
@@ -1,45 +0,0 @@
-export interface WebcamOverlayLayout {
- x: number;
- y: number;
- width: number;
- height: number;
- margin: number;
- borderRadius: number;
-}
-
-const MAX_STAGE_FRACTION = 0.18;
-const MARGIN_FRACTION = 0.02;
-const MIN_SIZE = 96;
-const MAX_BORDER_RADIUS = 24;
-
-export function computeWebcamOverlayLayout(params: {
- stageWidth: number;
- stageHeight: number;
- videoWidth: number;
- videoHeight: number;
-}): WebcamOverlayLayout | null {
- const { stageWidth, stageHeight, videoWidth, videoHeight } = params;
-
- if (stageWidth <= 0 || stageHeight <= 0 || videoWidth <= 0 || videoHeight <= 0) {
- return null;
- }
-
- const margin = Math.max(12, Math.round(Math.min(stageWidth, stageHeight) * MARGIN_FRACTION));
- const maxWidth = Math.max(MIN_SIZE, stageWidth * MAX_STAGE_FRACTION);
- const maxHeight = Math.max(MIN_SIZE, stageHeight * MAX_STAGE_FRACTION);
- const scale = Math.min(maxWidth / videoWidth, maxHeight / videoHeight);
- const width = Math.round(videoWidth * scale);
- const height = Math.round(videoHeight * scale);
-
- return {
- x: Math.max(0, Math.round(stageWidth - margin - width)),
- y: Math.max(0, Math.round(stageHeight - margin - height)),
- width,
- height,
- margin,
- borderRadius: Math.min(
- MAX_BORDER_RADIUS,
- Math.max(12, Math.round(Math.min(width, height) * 0.12)),
- ),
- };
-}