From c55f462f1c6d7b5b0f0728abcdc4ba16d112f70f Mon Sep 17 00:00:00 2001 From: Shreyas Date: Sun, 5 Apr 2026 17:38:42 +0100 Subject: [PATCH 1/5] feat: add dual frame webcam layout preset --- src/components/video-editor/SettingsPanel.tsx | 15 ++- src/components/video-editor/VideoEditor.tsx | 5 +- .../video-editor/projectPersistence.test.ts | 7 ++ .../video-editor/projectPersistence.ts | 1 + .../video-editor/videoPlayback/layoutUtils.ts | 2 +- src/i18n/locales/en/settings.json | 1 + src/i18n/locales/es/settings.json | 1 + src/i18n/locales/zh-CN/settings.json | 1 + src/lib/compositeLayout.test.ts | 23 ++++ src/lib/compositeLayout.ts | 107 +++++++++++++++++- src/lib/exporter/frameRenderer.ts | 27 ++++- 11 files changed, 175 insertions(+), 15 deletions(-) diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index b1cd78d..9c8086e 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -354,6 +354,7 @@ export function SettingsPanel({ const cropSnapshotRef = useRef(null); const [cropAspectLocked, setCropAspectLocked] = useState(false); const [cropAspectRatio, setCropAspectRatio] = useState(""); + const isPortraitCanvas = isPortraitAspectRatio(aspectRatio); const videoWidth = videoElement?.videoWidth || 1920; const videoHeight = videoElement?.videoHeight || 1080; @@ -779,15 +780,17 @@ export function SettingsPanel({ - {WEBCAM_LAYOUT_PRESETS.filter( - (preset) => - preset.value === "picture-in-picture" || - isPortraitAspectRatio(aspectRatio), - ).map((preset) => ( + {WEBCAM_LAYOUT_PRESETS.filter((preset) => { + if (preset.value === "picture-in-picture") return true; + if (preset.value === "vertical-stack") return isPortraitCanvas; + return !isPortraitCanvas; + }).map((preset) => ( {preset.value === "picture-in-picture" ? t("layout.pictureInPicture") - : t("layout.verticalStack")} + : preset.value === "vertical-stack" + ? t("layout.verticalStack") + : t("layout.dualFrame")} ))} diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index ffe3add..0321f43 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -1884,7 +1884,8 @@ export default function VideoEditor() { pushState({ aspectRatio: ar, webcamLayoutPreset: - !isPortraitAspectRatio(ar) && webcamLayoutPreset === "vertical-stack" + (isPortraitAspectRatio(ar) && webcamLayoutPreset === "dual-frame") || + (!isPortraitAspectRatio(ar) && webcamLayoutPreset === "vertical-stack") ? "picture-in-picture" : webcamLayoutPreset, }) @@ -1937,7 +1938,7 @@ export default function VideoEditor() { onWebcamLayoutPresetChange={(preset) => pushState({ webcamLayoutPreset: preset, - webcamPosition: preset === "vertical-stack" ? null : webcamPosition, + webcamPosition: preset === "picture-in-picture" ? webcamPosition : null, }) } webcamMaskShape={webcamMaskShape} diff --git a/src/components/video-editor/projectPersistence.test.ts b/src/components/video-editor/projectPersistence.test.ts index fdf5f66..9651fb4 100644 --- a/src/components/video-editor/projectPersistence.test.ts +++ b/src/components/video-editor/projectPersistence.test.ts @@ -44,6 +44,7 @@ describe("projectPersistence media compatibility", () => { aspectRatio: "16:9", webcamLayoutPreset: "picture-in-picture", webcamMaskShape: "circle", + webcamPosition: null, exportQuality: "good", exportFormat: "mp4", gifFrameRate: 15, @@ -66,6 +67,12 @@ describe("projectPersistence media compatibility", () => { normalizeProjectEditor({ webcamMaskShape: "not-a-real-shape" as never }).webcamMaskShape, ).toBe("rectangle"); }); + + it("accepts the dual frame webcam layout preset", () => { + expect(normalizeProjectEditor({ webcamLayoutPreset: "dual-frame" }).webcamLayoutPreset).toBe( + "dual-frame", + ); + }); }); it("creates stable snapshots for identical project state", () => { diff --git a/src/components/video-editor/projectPersistence.ts b/src/components/video-editor/projectPersistence.ts index ce0c751..00dd1b9 100644 --- a/src/components/video-editor/projectPersistence.ts +++ b/src/components/video-editor/projectPersistence.ts @@ -400,6 +400,7 @@ export function normalizeProjectEditor(editor: Partial): Pro editor.aspectRatio && validAspectRatios.has(editor.aspectRatio) ? editor.aspectRatio : "16:9", webcamLayoutPreset: editor.webcamLayoutPreset === "vertical-stack" || + editor.webcamLayoutPreset === "dual-frame" || editor.webcamLayoutPreset === "picture-in-picture" ? editor.webcamLayoutPreset : DEFAULT_WEBCAM_LAYOUT_PRESET, diff --git a/src/components/video-editor/videoPlayback/layoutUtils.ts b/src/components/video-editor/videoPlayback/layoutUtils.ts index 5059ccb..4b713cf 100644 --- a/src/components/video-editor/videoPlayback/layoutUtils.ts +++ b/src/components/video-editor/videoPlayback/layoutUtils.ts @@ -140,7 +140,7 @@ export function layoutVideoContent(params: LayoutParams): LayoutResult | null { screenRect.y, screenRect.width, screenRect.height, - compositeLayout.screenCover ? 0 : borderRadius, + compositeLayout.screenBorderRadius ?? (compositeLayout.screenCover ? 0 : borderRadius), ); maskGraphics.fill({ color: 0xffffff }); diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index d0559f5..f861fd5 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -26,6 +26,7 @@ "selectPreset": "Select preset", "pictureInPicture": "Picture in Picture", "verticalStack": "Vertical Stack", + "dualFrame": "Dual Frame", "webcamShape": "Camera Shape", "webcamSize": "Webcam Size" }, diff --git a/src/i18n/locales/es/settings.json b/src/i18n/locales/es/settings.json index 7cfa02c..1a16d84 100644 --- a/src/i18n/locales/es/settings.json +++ b/src/i18n/locales/es/settings.json @@ -26,6 +26,7 @@ "selectPreset": "Seleccionar predefinido", "pictureInPicture": "Imagen en imagen", "verticalStack": "Apilado vertical", + "dualFrame": "Marco dual", "webcamShape": "Forma de cámara", "webcamSize": "Tamaño de cámara" }, diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json index 9fa87d5..299483a 100644 --- a/src/i18n/locales/zh-CN/settings.json +++ b/src/i18n/locales/zh-CN/settings.json @@ -26,6 +26,7 @@ "selectPreset": "选择预设", "pictureInPicture": "画中画", "verticalStack": "垂直堆叠", + "dualFrame": "双画框", "webcamShape": "摄像头形状", "webcamSize": "摄像头大小" }, diff --git a/src/lib/compositeLayout.test.ts b/src/lib/compositeLayout.test.ts index 90883b1..596eb75 100644 --- a/src/lib/compositeLayout.test.ts +++ b/src/lib/compositeLayout.test.ts @@ -169,6 +169,29 @@ describe("computeCompositeLayout", () => { expect(layout?.screenCover).toBe(true); }); + it("uses a 2:1 split layout in dual frame 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: "dual-frame", + }); + + expect(layout).not.toBeNull(); + expect(layout?.webcamRect).not.toBeNull(); + expect(layout?.screenRect.y).toBe(108); + expect(layout?.screenRect.height).toBe(864); + expect(layout?.screenBorderRadius).toBe(layout?.webcamRect?.borderRadius); + expect(layout?.webcamRect?.y).toBe(108); + expect(layout?.webcamRect?.height).toBe(864); + expect(layout?.webcamRect?.x).toBeGreaterThan(layout?.screenRect.x ?? 0); + expect( + Math.abs((layout?.screenRect.width ?? 0) - 2 * (layout?.webcamRect?.width ?? 0)), + ).toBeLessThanOrEqual(1); + expect(layout?.screenCover).toBe(true); + }); + it("forces circular and square masks to use square dimensions", () => { const circularLayout = computeCompositeLayout({ canvasSize: { width: 1920, height: 1080 }, diff --git a/src/lib/compositeLayout.ts b/src/lib/compositeLayout.ts index 48583a3..e6db733 100644 --- a/src/lib/compositeLayout.ts +++ b/src/lib/compositeLayout.ts @@ -15,7 +15,7 @@ export interface Size { height: number; } -export type WebcamLayoutPreset = "picture-in-picture" | "vertical-stack"; +export type WebcamLayoutPreset = "picture-in-picture" | "vertical-stack" | "dual-frame"; /** Webcam size as a percentage of the canvas reference dimension (10–50). */ export type WebcamSizePreset = number; @@ -44,9 +44,17 @@ interface StackTransform { gap: number; } +interface SplitTransform { + type: "split"; + gapFraction: number; + minGap: number; + screenUnits: number; + webcamUnits: number; +} + export interface WebcamLayoutPresetDefinition { label: string; - transform: OverlayTransform | StackTransform; + transform: OverlayTransform | StackTransform | SplitTransform; borderRadius: BorderRadiusRule; shadow: WebcamLayoutShadow | null; } @@ -54,6 +62,7 @@ export interface WebcamLayoutPresetDefinition { export interface WebcamCompositeLayout { screenRect: RenderRect; webcamRect: StyledRenderRect | null; + screenBorderRadius?: number; /** When true, the video should be scaled to cover screenRect (cropping overflow). */ screenCover?: boolean; } @@ -101,6 +110,22 @@ const WEBCAM_LAYOUT_PRESET_MAP: Record 0 + ? webcamFrame.displayWidth + : webcamFrame.codedWidth) || webcamRect.width; + const sourceHeight = + ("displayHeight" in webcamFrame && webcamFrame.displayHeight > 0 + ? webcamFrame.displayHeight + : webcamFrame.codedHeight) || webcamRect.height; + const sourceAspect = sourceWidth / sourceHeight; + const targetAspect = webcamRect.width / webcamRect.height; + const sourceCropWidth = + sourceAspect > targetAspect ? Math.round(sourceHeight * targetAspect) : sourceWidth; + const sourceCropHeight = + sourceAspect > targetAspect ? sourceHeight : Math.round(sourceWidth / targetAspect); + const sourceCropX = Math.max(0, Math.round((sourceWidth - sourceCropWidth) / 2)); + const sourceCropY = Math.max(0, Math.round((sourceHeight - sourceCropHeight) / 2)); ctx.save(); drawCanvasClipPath( ctx, @@ -805,6 +826,10 @@ export class FrameRenderer { ctx.clip(); ctx.drawImage( webcamFrame as unknown as CanvasImageSource, + sourceCropX, + sourceCropY, + sourceCropWidth, + sourceCropHeight, webcamRect.x, webcamRect.y, webcamRect.width, From 16cba73cb2380300ac5e149d2f4234c16cb31a85 Mon Sep 17 00:00:00 2001 From: Shreyas Date: Sun, 5 Apr 2026 17:48:07 +0100 Subject: [PATCH 2/5] fix: avoid double-scaling dual frame export radius --- src/lib/exporter/frameRenderer.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/exporter/frameRenderer.ts b/src/lib/exporter/frameRenderer.ts index 5c471f6..3c0fd3c 100644 --- a/src/lib/exporter/frameRenderer.ts +++ b/src/lib/exporter/frameRenderer.ts @@ -509,7 +509,7 @@ export class FrameRenderer { const canvasScaleFactor = Math.min(width / previewWidth, height / previewHeight); const scaledBorderRadius = compositeLayout.screenBorderRadius != null - ? compositeLayout.screenBorderRadius * canvasScaleFactor + ? compositeLayout.screenBorderRadius : compositeLayout.screenCover ? 0 : borderRadius * canvasScaleFactor; From 24b4b4254aa7eb42e57e52c8ea090e0e13718410 Mon Sep 17 00:00:00 2001 From: Shreyas Date: Sun, 5 Apr 2026 17:51:32 +0100 Subject: [PATCH 3/5] fix: normalize dual frame preset for portrait projects --- .../video-editor/projectPersistence.test.ts | 9 ++++++ .../video-editor/projectPersistence.ts | 29 +++++++++++++------ 2 files changed, 29 insertions(+), 9 deletions(-) diff --git a/src/components/video-editor/projectPersistence.test.ts b/src/components/video-editor/projectPersistence.test.ts index 9651fb4..fd2cb43 100644 --- a/src/components/video-editor/projectPersistence.test.ts +++ b/src/components/video-editor/projectPersistence.test.ts @@ -73,6 +73,15 @@ describe("projectPersistence media compatibility", () => { "dual-frame", ); }); + + it("falls back from dual frame to picture in picture for portrait aspect ratios", () => { + expect( + normalizeProjectEditor({ + aspectRatio: "9:16", + webcamLayoutPreset: "dual-frame", + }).webcamLayoutPreset, + ).toBe("picture-in-picture"); + }); }); it("creates stable snapshots for identical project state", () => { diff --git a/src/components/video-editor/projectPersistence.ts b/src/components/video-editor/projectPersistence.ts index 00dd1b9..1d78f29 100644 --- a/src/components/video-editor/projectPersistence.ts +++ b/src/components/video-editor/projectPersistence.ts @@ -1,7 +1,7 @@ import type { ExportFormat, ExportQuality, GifFrameRate, GifSizePreset } from "@/lib/exporter"; import type { ProjectMedia } from "@/lib/recordingSession"; import { normalizeProjectMedia } from "@/lib/recordingSession"; -import { ASPECT_RATIOS, type AspectRatio } from "@/utils/aspectRatioUtils"; +import { ASPECT_RATIOS, type AspectRatio, isPortraitAspectRatio } from "@/utils/aspectRatioUtils"; import { type AnnotationRegion, type CropRegion, @@ -185,6 +185,23 @@ export function resolveProjectMedia( export function normalizeProjectEditor(editor: Partial): ProjectEditorState { const validAspectRatios = new Set(ASPECT_RATIOS); + const normalizedAspectRatio: AspectRatio = validAspectRatios.has( + editor.aspectRatio as AspectRatio, + ) + ? (editor.aspectRatio as AspectRatio) + : "16:9"; + const normalizedWebcamLayoutPreset: WebcamLayoutPreset = + editor.webcamLayoutPreset === "picture-in-picture" + ? editor.webcamLayoutPreset + : editor.webcamLayoutPreset === "vertical-stack" + ? isPortraitAspectRatio(normalizedAspectRatio) + ? editor.webcamLayoutPreset + : DEFAULT_WEBCAM_LAYOUT_PRESET + : editor.webcamLayoutPreset === "dual-frame" + ? isPortraitAspectRatio(normalizedAspectRatio) + ? DEFAULT_WEBCAM_LAYOUT_PRESET + : editor.webcamLayoutPreset + : DEFAULT_WEBCAM_LAYOUT_PRESET; const normalizedZoomRegions: ZoomRegion[] = Array.isArray(editor.zoomRegions) ? editor.zoomRegions @@ -396,14 +413,8 @@ export function normalizeProjectEditor(editor: Partial): Pro trimRegions: normalizedTrimRegions, speedRegions: normalizedSpeedRegions, annotationRegions: normalizedAnnotationRegions, - aspectRatio: - editor.aspectRatio && validAspectRatios.has(editor.aspectRatio) ? editor.aspectRatio : "16:9", - webcamLayoutPreset: - editor.webcamLayoutPreset === "vertical-stack" || - editor.webcamLayoutPreset === "dual-frame" || - editor.webcamLayoutPreset === "picture-in-picture" - ? editor.webcamLayoutPreset - : DEFAULT_WEBCAM_LAYOUT_PRESET, + aspectRatio: normalizedAspectRatio, + webcamLayoutPreset: normalizedWebcamLayoutPreset, webcamMaskShape: editor.webcamMaskShape === "rectangle" || editor.webcamMaskShape === "circle" || From bce19575050b489dc7aba1a2ade43b223a66248e Mon Sep 17 00:00:00 2001 From: Shreyas Date: Sun, 5 Apr 2026 17:56:06 +0100 Subject: [PATCH 4/5] fix: clear webcam position for non-pip layouts --- .../video-editor/projectPersistence.test.ts | 9 ++++++++ .../video-editor/projectPersistence.ts | 22 ++++++++++--------- 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/src/components/video-editor/projectPersistence.test.ts b/src/components/video-editor/projectPersistence.test.ts index fd2cb43..9a99ef7 100644 --- a/src/components/video-editor/projectPersistence.test.ts +++ b/src/components/video-editor/projectPersistence.test.ts @@ -82,6 +82,15 @@ describe("projectPersistence media compatibility", () => { }).webcamLayoutPreset, ).toBe("picture-in-picture"); }); + + it("clears webcamPosition when the normalized preset is not picture in picture", () => { + expect( + normalizeProjectEditor({ + webcamLayoutPreset: "dual-frame", + webcamPosition: { cx: 0.2, cy: 0.8 }, + }).webcamPosition, + ).toBeNull(); + }); }); it("creates stable snapshots for identical project state", () => { diff --git a/src/components/video-editor/projectPersistence.ts b/src/components/video-editor/projectPersistence.ts index 1d78f29..7a4881a 100644 --- a/src/components/video-editor/projectPersistence.ts +++ b/src/components/video-editor/projectPersistence.ts @@ -202,6 +202,17 @@ export function normalizeProjectEditor(editor: Partial): Pro ? DEFAULT_WEBCAM_LAYOUT_PRESET : editor.webcamLayoutPreset : DEFAULT_WEBCAM_LAYOUT_PRESET; + const normalizedWebcamPosition: WebcamPosition | null = + normalizedWebcamLayoutPreset === "picture-in-picture" && + 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; const normalizedZoomRegions: ZoomRegion[] = Array.isArray(editor.zoomRegions) ? editor.zoomRegions @@ -426,16 +437,7 @@ export function normalizeProjectEditor(editor: Partial): Pro typeof editor.webcamSizePreset === "number" && isFiniteNumber(editor.webcamSizePreset) ? Math.max(10, Math.min(50, editor.webcamSizePreset)) : DEFAULT_WEBCAM_SIZE_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, + webcamPosition: normalizedWebcamPosition, exportQuality: editor.exportQuality === "medium" || editor.exportQuality === "source" ? editor.exportQuality From b1a1f45e931b603b54a8465c638d8ae24383d196 Mon Sep 17 00:00:00 2001 From: Shreyas Date: Mon, 6 Apr 2026 13:18:22 +0100 Subject: [PATCH 5/5] refactor: simplify dual frame preset normalization --- .../video-editor/projectPersistence.ts | 36 ++++++++++++------- 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/src/components/video-editor/projectPersistence.ts b/src/components/video-editor/projectPersistence.ts index 7a4881a..a8362c8 100644 --- a/src/components/video-editor/projectPersistence.ts +++ b/src/components/video-editor/projectPersistence.ts @@ -78,6 +78,26 @@ function isFiniteNumber(value: unknown): value is number { return typeof value === "number" && Number.isFinite(value); } +function computeNormalizedWebcamLayoutPreset( + webcamLayoutPreset: Partial["webcamLayoutPreset"], + normalizedAspectRatio: AspectRatio, +): WebcamLayoutPreset { + switch (webcamLayoutPreset) { + case "picture-in-picture": + return webcamLayoutPreset; + case "vertical-stack": + return isPortraitAspectRatio(normalizedAspectRatio) + ? webcamLayoutPreset + : DEFAULT_WEBCAM_LAYOUT_PRESET; + case "dual-frame": + return isPortraitAspectRatio(normalizedAspectRatio) + ? DEFAULT_WEBCAM_LAYOUT_PRESET + : webcamLayoutPreset; + default: + return DEFAULT_WEBCAM_LAYOUT_PRESET; + } +} + function clamp(value: number, min: number, max: number) { return Math.min(max, Math.max(min, value)); } @@ -190,18 +210,10 @@ export function normalizeProjectEditor(editor: Partial): Pro ) ? (editor.aspectRatio as AspectRatio) : "16:9"; - const normalizedWebcamLayoutPreset: WebcamLayoutPreset = - editor.webcamLayoutPreset === "picture-in-picture" - ? editor.webcamLayoutPreset - : editor.webcamLayoutPreset === "vertical-stack" - ? isPortraitAspectRatio(normalizedAspectRatio) - ? editor.webcamLayoutPreset - : DEFAULT_WEBCAM_LAYOUT_PRESET - : editor.webcamLayoutPreset === "dual-frame" - ? isPortraitAspectRatio(normalizedAspectRatio) - ? DEFAULT_WEBCAM_LAYOUT_PRESET - : editor.webcamLayoutPreset - : DEFAULT_WEBCAM_LAYOUT_PRESET; + const normalizedWebcamLayoutPreset = computeNormalizedWebcamLayoutPreset( + editor.webcamLayoutPreset, + normalizedAspectRatio, + ); const normalizedWebcamPosition: WebcamPosition | null = normalizedWebcamLayoutPreset === "picture-in-picture" && editor.webcamPosition &&