feat: add selectable webcam layout presets

This commit is contained in:
Marcus Schiesser
2026-03-19 13:05:42 +08:00
parent 45636410fe
commit a0682e6716
12 changed files with 300 additions and 16 deletions
@@ -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<string[]>([]);
const [customImages, setCustomImages] = useState<string[]>([]);
@@ -586,6 +601,28 @@ export function SettingsPanel({
/>
</div>
</div>
{hasWebcam && (
<div className="mb-3 p-2 rounded-lg bg-white/5 border border-white/5">
<div className="text-[10px] font-medium text-slate-300 mb-1.5">Webcam Layout</div>
<Select
value={webcamLayoutPreset}
onValueChange={(value: WebcamLayoutPreset) =>
onWebcamLayoutPresetChange?.(value)
}
>
<SelectTrigger className="h-8 bg-black/20 border-white/10 text-xs">
<SelectValue placeholder="Select layout" />
</SelectTrigger>
<SelectContent>
{WEBCAM_LAYOUT_PRESETS.map((preset) => (
<SelectItem key={preset.value} value={preset.value} className="text-xs">
{preset.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div className="grid grid-cols-2 gap-2">
<div className="p-2 rounded-lg bg-white/5 border border-white/5">
@@ -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}
+26 -5
View File
@@ -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<VideoPlaybackRef, VideoPlaybackProps>(
{
videoPath,
webcamVideoPath,
webcamLayoutPreset,
onDurationChange,
onTimeUpdate,
currentTime,
@@ -149,6 +156,10 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
width: number;
height: number;
} | null>(null);
const [screenVideoDimensions, setScreenVideoDimensions] = useState<{
width: number;
height: number;
} | null>(null);
const currentTimeRef = useRef(0);
const zoomRegionsRef = useRef<ZoomRegion[]>([]);
const selectedZoomIdRef = useRef<string | null>(null);
@@ -609,6 +620,9 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
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<VideoPlaybackRef, VideoPlaybackProps>(
};
const [resolvedWallpaper, setResolvedWallpaper] = useState<string | null>(null);
const webcamCssBoxShadow = useMemo(
() => getWebcamLayoutCssBoxShadow(webcamLayoutPreset),
[webcamLayoutPreset],
);
useEffect(() => {
const webcamVideo = webcamVideoRef.current;
@@ -936,7 +954,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
useEffect(() => {
const stage = stageRef.current;
if (!stage || !webcamDimensions) {
if (!stage || !webcamDimensions || !screenVideoDimensions) {
setWebcamLayout(null);
return;
}
@@ -947,6 +965,9 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
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<VideoPlaybackRef, VideoPlaybackProps>(
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<VideoPlaybackRef, VideoPlaybackProps>(
: "none",
}}
/>
{webcamVideoPath && (
{webcamVideoPath && screenVideoDimensions && (
<video
ref={webcamVideoRef}
src={webcamVideoPath}
@@ -1120,7 +1141,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
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",
@@ -39,6 +39,7 @@ describe("projectPersistence media compatibility", () => {
speedRegions: [],
annotationRegions: [],
aspectRatio: "16:9",
webcamLayoutPreset: "picture-in-picture",
exportQuality: "good",
exportFormat: "mp4",
gifFrameRate: 15,
@@ -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<ProjectEditorState>): 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
+5
View File
@@ -1,4 +1,9 @@
import type { WebcamLayoutPreset } from "@/lib/webcamOverlay";
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)
+4 -1
View File
@@ -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<EditorState> | ((prev: EditorState) => Partial<EditorState>);
+13 -4
View File
@@ -12,6 +12,7 @@ import type {
AnnotationRegion,
CropRegion,
SpeedRegion,
WebcamLayoutPreset,
ZoomDepth,
ZoomRegion,
} from "@/components/video-editor/types";
@@ -30,7 +31,7 @@ import {
createMotionBlurState,
type MotionBlurState,
} from "@/components/video-editor/videoPlayback/zoomTransform";
import { computeWebcamOverlayLayout } from "@/lib/webcamOverlay";
import { computeWebcamOverlayLayout, getWebcamLayoutPresetDefinition } from "@/lib/webcamOverlay";
import { renderAnnotations } from "./annotationRenderer";
interface FrameRenderConfig {
@@ -49,6 +50,7 @@ interface FrameRenderConfig {
videoHeight: number;
webcamWidth?: number;
webcamHeight?: number;
webcamLayoutPreset?: WebcamLayoutPreset;
annotationRegions?: AnnotationRegion[];
speedRegions?: SpeedRegion[];
previewWidth?: number;
@@ -634,16 +636,23 @@ export class FrameRenderer {
stageHeight: h,
videoWidth: this.config.webcamWidth,
videoHeight: this.config.webcamHeight,
layoutPreset: this.config.webcamLayoutPreset,
screenVideoWidth: this.config.videoWidth,
screenVideoHeight: this.config.videoHeight,
});
if (layout) {
const preset = getWebcamLayoutPresetDefinition(this.config.webcamLayoutPreset);
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;
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();
+3
View File
@@ -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;
@@ -138,6 +140,7 @@ export class GifExporter {
videoHeight: videoInfo.height,
webcamWidth: webcamInfo?.width,
webcamHeight: webcamInfo?.height,
webcamLayoutPreset: this.config.webcamLayoutPreset,
annotationRegions: this.config.annotationRegions,
speedRegions: this.config.speedRegions,
previewWidth: this.config.previewWidth,
+3
View File
@@ -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;
@@ -87,6 +89,7 @@ export class VideoExporter {
videoHeight: videoInfo.height,
webcamWidth: webcamInfo?.width,
webcamHeight: webcamInfo?.height,
webcamLayoutPreset: this.config.webcamLayoutPreset,
annotationRegions: this.config.annotationRegions,
speedRegions: this.config.speedRegions,
previewWidth: this.config.previewWidth,
+30
View File
@@ -30,4 +30,34 @@ describe("computeWebcamOverlayLayout", () => {
expect(layout!.height).toBeLessThanOrEqual(Math.round(720 * 0.18) + 1);
expect(Math.abs(layout!.width * 1080 - layout!.height * 1920)).toBeLessThanOrEqual(1920);
});
it("places the webcam directly below the screencast in vertical stack mode", () => {
const layout = computeWebcamOverlayLayout({
stageWidth: 1920,
stageHeight: 1080,
videoWidth: 1280,
videoHeight: 720,
screenVideoWidth: 1920,
screenVideoHeight: 1080,
layoutPreset: "vertical-stack",
});
expect(layout).not.toBeNull();
expect(layout?.y).toBe(648);
expect(layout?.height).toBe(432);
expect(layout?.width).toBe(768);
expect(layout?.borderRadius).toBe(0);
});
it("returns null for vertical stack until screen dimensions are available", () => {
const layout = computeWebcamOverlayLayout({
stageWidth: 1920,
stageHeight: 1080,
videoWidth: 1280,
videoHeight: 720,
layoutPreset: "vertical-stack",
});
expect(layout).toBeNull();
});
});
+157 -6
View File
@@ -7,26 +7,174 @@ export interface WebcamOverlayLayout {
borderRadius: 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;
}
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<WebcamLayoutPreset, WebcamLayoutPresetDefinition> = {
"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 computeWebcamOverlayLayout(params: {
stageWidth: number;
stageHeight: number;
videoWidth: number;
videoHeight: number;
layoutPreset?: WebcamLayoutPreset;
screenVideoWidth?: number;
screenVideoHeight?: number;
}): WebcamOverlayLayout | null {
const { stageWidth, stageHeight, videoWidth, videoHeight } = params;
const {
stageWidth,
stageHeight,
videoWidth,
videoHeight,
layoutPreset = "picture-in-picture",
screenVideoWidth,
screenVideoHeight,
} = params;
const preset = getWebcamLayoutPresetDefinition(layoutPreset);
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);
if (preset.transform.type === "stack") {
if (
!screenVideoWidth ||
!screenVideoHeight ||
screenVideoWidth <= 0 ||
screenVideoHeight <= 0
) {
return null;
}
const gap = preset.transform.gap;
const scale = Math.min(
stageWidth / Math.max(screenVideoWidth, videoWidth),
stageHeight / (screenVideoHeight + gap + videoHeight),
);
const clampedScale = Number.isFinite(scale) && scale > 0 ? scale : 1;
const screenHeight = Math.round(screenVideoHeight * clampedScale);
const webcamHeight = Math.round(videoHeight * clampedScale);
const webcamWidth = Math.round(videoWidth * clampedScale);
const scaledGap = Math.round(gap * clampedScale);
const contentHeight = screenHeight + scaledGap + webcamHeight;
const topOffset = Math.max(0, Math.floor((stageHeight - contentHeight) / 2));
return {
x: Math.max(0, Math.floor((stageWidth - webcamWidth) / 2)),
y: Math.max(0, topOffset + screenHeight + scaledGap),
width: webcamWidth,
height: webcamHeight,
margin: 0,
borderRadius: Math.min(
preset.borderRadius.max,
Math.max(
preset.borderRadius.min,
Math.round(Math.min(webcamWidth, webcamHeight) * preset.borderRadius.fraction),
),
),
};
}
const transform = preset.transform;
const margin = Math.max(
transform.minMargin,
Math.round(Math.min(stageWidth, stageHeight) * transform.marginFraction),
);
const maxWidth = Math.max(transform.minSize, stageWidth * transform.maxStageFraction);
const maxHeight = Math.max(transform.minSize, stageHeight * transform.maxStageFraction);
const scale = Math.min(maxWidth / videoWidth, maxHeight / videoHeight);
const width = Math.round(videoWidth * scale);
const height = Math.round(videoHeight * scale);
@@ -38,8 +186,11 @@ export function computeWebcamOverlayLayout(params: {
height,
margin,
borderRadius: Math.min(
MAX_BORDER_RADIUS,
Math.max(12, Math.round(Math.min(width, height) * 0.12)),
preset.borderRadius.max,
Math.max(
preset.borderRadius.min,
Math.round(Math.min(width, height) * preset.borderRadius.fraction),
),
),
};
}