Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 9 additions & 6 deletions src/components/video-editor/SettingsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,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;
Expand Down Expand Up @@ -656,15 +657,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.twoTimer")}
</SelectItem>
))}
</SelectContent>
Expand Down
5 changes: 3 additions & 2 deletions src/components/video-editor/VideoEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
Expand Down Expand Up @@ -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}
Expand Down
7 changes: 7 additions & 0 deletions src/components/video-editor/projectPersistence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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",
);
});
});
1 change: 1 addition & 0 deletions src/components/video-editor/projectPersistence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,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 === "two-timer" ||
editor.webcamLayoutPreset === "picture-in-picture"
? editor.webcamLayoutPreset
: DEFAULT_WEBCAM_LAYOUT_PRESET,
Expand Down
2 changes: 1 addition & 1 deletion src/components/video-editor/videoPlayback/layoutUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });

Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/en/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"selectPreset": "Select preset",
"pictureInPicture": "Picture in Picture",
"verticalStack": "Vertical Stack",
"twoTimer": "Dual Frame",
"webcamShape": "Camera Shape"
},
"effects": {
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/es/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"selectPreset": "Seleccionar predefinido",
"pictureInPicture": "Imagen en imagen",
"verticalStack": "Apilado vertical",
"twoTimer": "Marco dual",
"webcamShape": "Forma de cámara"
},
"effects": {
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/zh-CN/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"selectPreset": "选择预设",
"pictureInPicture": "画中画",
"verticalStack": "垂直堆叠",
"twoTimer": "双画框",
"webcamShape": "摄像头形状"
},
"effects": {
Expand Down
23 changes: 23 additions & 0 deletions src/lib/compositeLayout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
107 changes: 102 additions & 5 deletions src/lib/compositeLayout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -43,16 +43,25 @@ 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;
}

export interface WebcamCompositeLayout {
screenRect: RenderRect;
webcamRect: StyledRenderRect | null;
screenBorderRadius?: number;
/** When true, the video should be scaled to cover screenRect (cropping overflow). */
screenCover?: boolean;
}
Expand Down Expand Up @@ -95,6 +104,22 @@ const WEBCAM_LAYOUT_PRESET_MAP: Record<WebcamLayoutPreset, WebcamLayoutPresetDef
},
shadow: null,
},
"two-timer": {
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(
Expand Down Expand Up @@ -183,6 +208,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,
Expand Down Expand Up @@ -258,16 +346,25 @@ 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);
const resolvedWidth = Math.round(width * scale);
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,
};
Expand Down
27 changes: 26 additions & 1 deletion src/lib/exporter/frameRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -494,7 +494,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);
Expand Down Expand Up @@ -735,6 +740,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,
Expand All @@ -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,
Expand Down