diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index 7e556b85..f11dca92 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.dualFrame")} ))} diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 549aa37c..7d3c9ecf 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 === "dual-frame") || + (!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..df3f9697 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,28 @@ 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("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("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 d7111b14..e0cd6681 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, @@ -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)); } @@ -173,6 +193,26 @@ 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 = computeNormalizedWebcamLayoutPreset( + editor.webcamLayoutPreset, + normalizedAspectRatio, + ); + 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 @@ -349,13 +389,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 === "picture-in-picture" - ? editor.webcamLayoutPreset - : DEFAULT_WEBCAM_LAYOUT_PRESET, + aspectRatio: normalizedAspectRatio, + webcamLayoutPreset: normalizedWebcamLayoutPreset, webcamMaskShape: editor.webcamMaskShape === "rectangle" || editor.webcamMaskShape === "circle" || @@ -363,16 +398,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 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..a3f1d0a6 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", + "dualFrame": "Dual Frame", "webcamShape": "Camera Shape" }, "effects": { diff --git a/src/i18n/locales/es/settings.json b/src/i18n/locales/es/settings.json index 586e840a..47f62507 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", + "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 ab0d41bb..d6fdaa5f 100644 --- a/src/i18n/locales/zh-CN/settings.json +++ b/src/i18n/locales/zh-CN/settings.json @@ -24,6 +24,7 @@ "selectPreset": "选择预设", "pictureInPicture": "画中画", "verticalStack": "垂直堆叠", + "dualFrame": "双画框", "webcamShape": "摄像头形状" }, "effects": { diff --git a/src/lib/compositeLayout.test.ts b/src/lib/compositeLayout.test.ts index 93ce2c5e..3155b560 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: "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 a3c84e87..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"; +export type WebcamLayoutPreset = "picture-in-picture" | "vertical-stack" | "dual-frame"; 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,