feat: add dual frame webcam layout preset
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
"selectPreset": "Select preset",
|
||||
"pictureInPicture": "Picture in Picture",
|
||||
"verticalStack": "Vertical Stack",
|
||||
"dualFrame": "Dual Frame",
|
||||
"webcamShape": "Camera Shape",
|
||||
"webcamSize": "Webcam Size"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
"selectPreset": "选择预设",
|
||||
"pictureInPicture": "画中画",
|
||||
"verticalStack": "垂直堆叠",
|
||||
"dualFrame": "双画框",
|
||||
"webcamShape": "摄像头形状",
|
||||
"webcamSize": "摄像头大小"
|
||||
},
|
||||
|
||||
@@ -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
@@ -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<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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user