feat: add dual frame webcam layout preset

This commit is contained in:
Shreyas
2026-04-05 17:38:42 +01:00
committed by Siddharth
parent 68295b21ec
commit c55f462f1c
11 changed files with 175 additions and 15 deletions
@@ -354,6 +354,7 @@ export function SettingsPanel({
const cropSnapshotRef = useRef<CropRegion | null>(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({
<SelectValue placeholder={t("layout.selectPreset")} />
</SelectTrigger>
<SelectContent>
{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) => (
<SelectItem key={preset.value} value={preset.value} className="text-xs">
{preset.value === "picture-in-picture"
? t("layout.pictureInPicture")
: t("layout.verticalStack")}
: preset.value === "vertical-stack"
? t("layout.verticalStack")
: t("layout.dualFrame")}
</SelectItem>
))}
</SelectContent>
+3 -2
View File
@@ -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}
@@ -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", () => {
@@ -400,6 +400,7 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): 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,
@@ -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 });
+1
View File
@@ -26,6 +26,7 @@
"selectPreset": "Select preset",
"pictureInPicture": "Picture in Picture",
"verticalStack": "Vertical Stack",
"dualFrame": "Dual Frame",
"webcamShape": "Camera Shape",
"webcamSize": "Webcam Size"
},
+1
View File
@@ -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"
},
+1
View File
@@ -26,6 +26,7 @@
"selectPreset": "选择预设",
"pictureInPicture": "画中画",
"verticalStack": "垂直堆叠",
"dualFrame": "双画框",
"webcamShape": "摄像头形状",
"webcamSize": "摄像头大小"
},
+23
View File
@@ -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 },
+102 -5
View File
@@ -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 (1050). */
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<WebcamLayoutPreset, WebcamLayoutPresetDef
},
shadow: null,
},
"dual-frame": {
label: "Dual Frame",
transform: {
type: "split",
gapFraction: 0.02,
minGap: 12,
screenUnits: 2,
webcamUnits: 1,
},
borderRadius: {
max: MAX_BORDER_RADIUS,
min: 12,
fraction: 0.06,
},
shadow: null,
},
};
export const WEBCAM_LAYOUT_PRESETS = Object.entries(WEBCAM_LAYOUT_PRESET_MAP).map(
@@ -193,6 +218,69 @@ export function computeCompositeLayout(params: {
};
}
if (preset.transform.type === "split") {
const screenRect = centerRect({
canvasSize,
size: screenSize,
maxSize: maxContentSize,
});
if (!webcamWidth || !webcamHeight || webcamWidth <= 0 || webcamHeight <= 0) {
return { screenRect, webcamRect: null };
}
const contentWidth = Math.min(canvasWidth, Math.max(1, Math.round(maxContentSize.width)));
const contentHeight = Math.min(canvasHeight, Math.max(1, Math.round(maxContentSize.height)));
const contentX = Math.max(0, Math.floor((canvasWidth - contentWidth) / 2));
const contentY = Math.max(0, Math.floor((canvasHeight - contentHeight) / 2));
const gap = Math.max(
preset.transform.minGap,
Math.round(contentWidth * preset.transform.gapFraction),
);
const totalUnits = preset.transform.screenUnits + preset.transform.webcamUnits;
const availableWidth = Math.max(1, contentWidth - gap);
const screenSlotWidth = Math.max(
1,
Math.round((availableWidth * preset.transform.screenUnits) / totalUnits),
);
const webcamSlotWidth = Math.max(1, availableWidth - screenSlotWidth);
const screenSlot = {
x: contentX,
y: contentY,
width: screenSlotWidth,
height: contentHeight,
};
const webcamSlot = {
x: contentX + screenSlotWidth + gap,
y: contentY,
width: webcamSlotWidth,
height: contentHeight,
};
const webcamBorderRadius = Math.min(
preset.borderRadius.max,
Math.max(
preset.borderRadius.min,
Math.round(Math.min(webcamSlot.width, webcamSlot.height) * preset.borderRadius.fraction),
),
);
return {
screenRect: screenSlot,
screenBorderRadius: webcamBorderRadius,
webcamRect: {
x: webcamSlot.x,
y: webcamSlot.y,
width: webcamSlot.width,
height: webcamSlot.height,
borderRadius: webcamBorderRadius,
maskShape: "rectangle",
},
screenCover: true,
};
}
const transform = preset.transform;
const screenRect = centerRect({
canvasSize,
@@ -271,7 +359,16 @@ export function computeCompositeLayout(params: {
function centerRect(params: { canvasSize: Size; size: Size; maxSize: Size }): RenderRect {
const { canvasSize, size, maxSize } = params;
const { width: canvasWidth, height: canvasHeight } = canvasSize;
return centerRectInBounds({
bounds: { x: 0, y: 0, width: canvasSize.width, height: canvasSize.height },
size,
maxSize,
});
}
function centerRectInBounds(params: { bounds: RenderRect; size: Size; maxSize: Size }): RenderRect {
const { bounds, size, maxSize } = params;
const { x: boundsX, y: boundsY, width: boundsWidth, height: boundsHeight } = bounds;
const { width, height } = size;
const { width: maxWidth, height: maxHeight } = maxSize;
const scale = Math.min(maxWidth / width, maxHeight / height, 1);
@@ -279,8 +376,8 @@ function centerRect(params: { canvasSize: Size; size: Size; maxSize: Size }): Re
const resolvedHeight = Math.round(height * scale);
return {
x: Math.max(0, Math.floor((canvasWidth - resolvedWidth) / 2)),
y: Math.max(0, Math.floor((canvasHeight - resolvedHeight) / 2)),
x: boundsX + Math.max(0, Math.floor((boundsWidth - resolvedWidth) / 2)),
y: boundsY + Math.max(0, Math.floor((boundsHeight - resolvedHeight) / 2)),
width: resolvedWidth,
height: resolvedHeight,
};
+26 -1
View File
@@ -507,7 +507,12 @@ export class FrameRenderer {
const previewWidth = this.config.previewWidth || 1920;
const previewHeight = this.config.previewHeight || 1080;
const canvasScaleFactor = Math.min(width / previewWidth, height / previewHeight);
const scaledBorderRadius = compositeLayout.screenCover ? 0 : borderRadius * canvasScaleFactor;
const scaledBorderRadius =
compositeLayout.screenBorderRadius != null
? compositeLayout.screenBorderRadius * canvasScaleFactor
: compositeLayout.screenCover
? 0
: borderRadius * canvasScaleFactor;
this.maskGraphics.clear();
this.maskGraphics.roundRect(0, 0, screenRect.width, screenRect.height, scaledBorderRadius);
@@ -784,6 +789,22 @@ export class FrameRenderer {
if (webcamFrame && webcamRect) {
const preset = getWebcamLayoutPresetDefinition(this.config.webcamLayoutPreset);
const shape = webcamRect.maskShape ?? this.config.webcamMaskShape ?? "rectangle";
const sourceWidth =
("displayWidth" in webcamFrame && webcamFrame.displayWidth > 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,