From cdc71ca49bc935214129d4c58125dedf3a621954 Mon Sep 17 00:00:00 2001 From: Shreyas Date: Sun, 5 Apr 2026 17:38:42 +0100 Subject: [PATCH] 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,