From cdc71ca49bc935214129d4c58125dedf3a621954 Mon Sep 17 00:00:00 2001 From: Shreyas Date: Sun, 5 Apr 2026 17:38:42 +0100 Subject: [PATCH 1/6] 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 7e556b85..e21e064f 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -268,6 +268,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; @@ -656,15 +657,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.twoTimer")} ))} diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 549aa37c..e1a6b95d 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -1630,7 +1630,8 @@ export default function VideoEditor() { pushState({ aspectRatio: ar, webcamLayoutPreset: - !isPortraitAspectRatio(ar) && webcamLayoutPreset === "vertical-stack" + (isPortraitAspectRatio(ar) && webcamLayoutPreset === "two-timer") || + (!isPortraitAspectRatio(ar) && webcamLayoutPreset === "vertical-stack") ? "picture-in-picture" : webcamLayoutPreset, }) @@ -1683,7 +1684,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 3243acab..5df90ff9 100644 --- a/src/components/video-editor/projectPersistence.test.ts +++ b/src/components/video-editor/projectPersistence.test.ts @@ -42,6 +42,7 @@ describe("projectPersistence media compatibility", () => { aspectRatio: "16:9", webcamLayoutPreset: "picture-in-picture", webcamMaskShape: "circle", + webcamPosition: null, exportQuality: "good", exportFormat: "mp4", gifFrameRate: 15, @@ -64,4 +65,10 @@ 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: "two-timer" }).webcamLayoutPreset).toBe( + "two-timer", + ); + }); }); diff --git a/src/components/video-editor/projectPersistence.ts b/src/components/video-editor/projectPersistence.ts index d7111b14..49a095d3 100644 --- a/src/components/video-editor/projectPersistence.ts +++ b/src/components/video-editor/projectPersistence.ts @@ -353,6 +353,7 @@ export function normalizeProjectEditor(editor: Partial): Pro editor.aspectRatio && validAspectRatios.has(editor.aspectRatio) ? editor.aspectRatio : "16:9", webcamLayoutPreset: editor.webcamLayoutPreset === "vertical-stack" || + editor.webcamLayoutPreset === "two-timer" || 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 2444c39b..eaac59d0 100644 --- a/src/components/video-editor/videoPlayback/layoutUtils.ts +++ b/src/components/video-editor/videoPlayback/layoutUtils.ts @@ -136,7 +136,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 632a569e..226fd145 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -24,6 +24,7 @@ "selectPreset": "Select preset", "pictureInPicture": "Picture in Picture", "verticalStack": "Vertical Stack", + "twoTimer": "Dual Frame", "webcamShape": "Camera Shape" }, "effects": { diff --git a/src/i18n/locales/es/settings.json b/src/i18n/locales/es/settings.json index 586e840a..5914035d 100644 --- a/src/i18n/locales/es/settings.json +++ b/src/i18n/locales/es/settings.json @@ -24,6 +24,7 @@ "selectPreset": "Seleccionar predefinido", "pictureInPicture": "Imagen en imagen", "verticalStack": "Apilado vertical", + "twoTimer": "Marco dual", "webcamShape": "Forma de cámara" }, "effects": { diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json index ab0d41bb..7ac42729 100644 --- a/src/i18n/locales/zh-CN/settings.json +++ b/src/i18n/locales/zh-CN/settings.json @@ -24,6 +24,7 @@ "selectPreset": "选择预设", "pictureInPicture": "画中画", "verticalStack": "垂直堆叠", + "twoTimer": "双画框", "webcamShape": "摄像头形状" }, "effects": { diff --git a/src/lib/compositeLayout.test.ts b/src/lib/compositeLayout.test.ts index 93ce2c5e..3f187529 100644 --- a/src/lib/compositeLayout.test.ts +++ b/src/lib/compositeLayout.test.ts @@ -78,6 +78,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: "two-timer", + }); + + 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 a3c84e87..56d1c2b4 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" | "two-timer"; export interface WebcamLayoutShadow { color: string; @@ -43,9 +43,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; } @@ -53,6 +61,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; } @@ -95,6 +104,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, @@ -756,6 +777,10 @@ export class FrameRenderer { ctx.clip(); ctx.drawImage( webcamFrame as unknown as CanvasImageSource, + sourceCropX, + sourceCropY, + sourceCropWidth, + sourceCropHeight, webcamRect.x, webcamRect.y, webcamRect.width, From a27a0d302ea46f07396b2627523fcfbf0779e60b Mon Sep 17 00:00:00 2001 From: Shreyas Date: Sun, 5 Apr 2026 17:43:43 +0100 Subject: [PATCH 2/6] refactor: rename dual frame preset identifier --- src/components/video-editor/SettingsPanel.tsx | 2 +- src/components/video-editor/VideoEditor.tsx | 2 +- src/components/video-editor/projectPersistence.test.ts | 4 ++-- src/components/video-editor/projectPersistence.ts | 2 +- src/i18n/locales/en/settings.json | 2 +- src/i18n/locales/es/settings.json | 2 +- src/i18n/locales/zh-CN/settings.json | 2 +- src/lib/compositeLayout.test.ts | 2 +- src/lib/compositeLayout.ts | 4 ++-- 9 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index e21e064f..f11dca92 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -667,7 +667,7 @@ export function SettingsPanel({ ? t("layout.pictureInPicture") : preset.value === "vertical-stack" ? t("layout.verticalStack") - : t("layout.twoTimer")} + : t("layout.dualFrame")} ))} diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index e1a6b95d..7d3c9ecf 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -1630,7 +1630,7 @@ export default function VideoEditor() { pushState({ aspectRatio: ar, webcamLayoutPreset: - (isPortraitAspectRatio(ar) && webcamLayoutPreset === "two-timer") || + (isPortraitAspectRatio(ar) && webcamLayoutPreset === "dual-frame") || (!isPortraitAspectRatio(ar) && webcamLayoutPreset === "vertical-stack") ? "picture-in-picture" : webcamLayoutPreset, diff --git a/src/components/video-editor/projectPersistence.test.ts b/src/components/video-editor/projectPersistence.test.ts index 5df90ff9..cd216406 100644 --- a/src/components/video-editor/projectPersistence.test.ts +++ b/src/components/video-editor/projectPersistence.test.ts @@ -67,8 +67,8 @@ describe("projectPersistence media compatibility", () => { }); it("accepts the dual frame webcam layout preset", () => { - expect(normalizeProjectEditor({ webcamLayoutPreset: "two-timer" }).webcamLayoutPreset).toBe( - "two-timer", + expect(normalizeProjectEditor({ webcamLayoutPreset: "dual-frame" }).webcamLayoutPreset).toBe( + "dual-frame", ); }); }); diff --git a/src/components/video-editor/projectPersistence.ts b/src/components/video-editor/projectPersistence.ts index 49a095d3..bb2935e9 100644 --- a/src/components/video-editor/projectPersistence.ts +++ b/src/components/video-editor/projectPersistence.ts @@ -353,7 +353,7 @@ export function normalizeProjectEditor(editor: Partial): Pro editor.aspectRatio && validAspectRatios.has(editor.aspectRatio) ? editor.aspectRatio : "16:9", webcamLayoutPreset: editor.webcamLayoutPreset === "vertical-stack" || - editor.webcamLayoutPreset === "two-timer" || + editor.webcamLayoutPreset === "dual-frame" || editor.webcamLayoutPreset === "picture-in-picture" ? editor.webcamLayoutPreset : DEFAULT_WEBCAM_LAYOUT_PRESET, diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index 226fd145..a3f1d0a6 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -24,7 +24,7 @@ "selectPreset": "Select preset", "pictureInPicture": "Picture in Picture", "verticalStack": "Vertical Stack", - "twoTimer": "Dual Frame", + "dualFrame": "Dual Frame", "webcamShape": "Camera Shape" }, "effects": { diff --git a/src/i18n/locales/es/settings.json b/src/i18n/locales/es/settings.json index 5914035d..47f62507 100644 --- a/src/i18n/locales/es/settings.json +++ b/src/i18n/locales/es/settings.json @@ -24,7 +24,7 @@ "selectPreset": "Seleccionar predefinido", "pictureInPicture": "Imagen en imagen", "verticalStack": "Apilado vertical", - "twoTimer": "Marco dual", + "dualFrame": "Marco dual", "webcamShape": "Forma de cámara" }, "effects": { diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json index 7ac42729..d6fdaa5f 100644 --- a/src/i18n/locales/zh-CN/settings.json +++ b/src/i18n/locales/zh-CN/settings.json @@ -24,7 +24,7 @@ "selectPreset": "选择预设", "pictureInPicture": "画中画", "verticalStack": "垂直堆叠", - "twoTimer": "双画框", + "dualFrame": "双画框", "webcamShape": "摄像头形状" }, "effects": { diff --git a/src/lib/compositeLayout.test.ts b/src/lib/compositeLayout.test.ts index 3f187529..3155b560 100644 --- a/src/lib/compositeLayout.test.ts +++ b/src/lib/compositeLayout.test.ts @@ -84,7 +84,7 @@ describe("computeCompositeLayout", () => { maxContentSize: { width: 1536, height: 864 }, screenSize: { width: 1920, height: 1080 }, webcamSize: { width: 1280, height: 720 }, - layoutPreset: "two-timer", + layoutPreset: "dual-frame", }); expect(layout).not.toBeNull(); diff --git a/src/lib/compositeLayout.ts b/src/lib/compositeLayout.ts index 56d1c2b4..0db3a26c 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" | "two-timer"; +export type WebcamLayoutPreset = "picture-in-picture" | "vertical-stack" | "dual-frame"; export interface WebcamLayoutShadow { color: string; @@ -104,7 +104,7 @@ const WEBCAM_LAYOUT_PRESET_MAP: Record Date: Sun, 5 Apr 2026 17:48:07 +0100 Subject: [PATCH 3/6] 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 d18f0880..ae73647c 100644 --- a/src/lib/exporter/frameRenderer.ts +++ b/src/lib/exporter/frameRenderer.ts @@ -496,7 +496,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 feab9416c4a2613eeedbf55112a105c0125c0814 Mon Sep 17 00:00:00 2001 From: Shreyas Date: Sun, 5 Apr 2026 17:51:32 +0100 Subject: [PATCH 4/6] 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 cd216406..9798b3ef 100644 --- a/src/components/video-editor/projectPersistence.test.ts +++ b/src/components/video-editor/projectPersistence.test.ts @@ -71,4 +71,13 @@ 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"); + }); }); diff --git a/src/components/video-editor/projectPersistence.ts b/src/components/video-editor/projectPersistence.ts index bb2935e9..cf890f82 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, @@ -173,6 +173,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 @@ -349,14 +366,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 a30526e337284088f6fddea273d2fc9bc9c9d2fc Mon Sep 17 00:00:00 2001 From: Shreyas Date: Sun, 5 Apr 2026 17:56:06 +0100 Subject: [PATCH 5/6] 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 9798b3ef..df3f9697 100644 --- a/src/components/video-editor/projectPersistence.test.ts +++ b/src/components/video-editor/projectPersistence.test.ts @@ -80,4 +80,13 @@ 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(); + }); }); diff --git a/src/components/video-editor/projectPersistence.ts b/src/components/video-editor/projectPersistence.ts index cf890f82..c9f70687 100644 --- a/src/components/video-editor/projectPersistence.ts +++ b/src/components/video-editor/projectPersistence.ts @@ -190,6 +190,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 @@ -375,16 +386,7 @@ export function normalizeProjectEditor(editor: Partial): Pro editor.webcamMaskShape === "rounded" ? editor.webcamMaskShape : DEFAULT_WEBCAM_MASK_SHAPE, - 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 e2147c4c72eeb74dc5c62611ef3f110bcc4ae444 Mon Sep 17 00:00:00 2001 From: Shreyas Date: Mon, 6 Apr 2026 13:18:22 +0100 Subject: [PATCH 6/6] 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 c9f70687..e0cd6681 100644 --- a/src/components/video-editor/projectPersistence.ts +++ b/src/components/video-editor/projectPersistence.ts @@ -66,6 +66,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)); } @@ -178,18 +198,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 &&