Skip to content

Commit de75185

Browse files
feat: add dual frame webcam layout preset (#347)
2 parents 68295b2 + b1a1f45 commit de75185

11 files changed

Lines changed: 236 additions & 33 deletions

File tree

src/components/video-editor/SettingsPanel.tsx

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,7 @@ export function SettingsPanel({
354354
const cropSnapshotRef = useRef<CropRegion | null>(null);
355355
const [cropAspectLocked, setCropAspectLocked] = useState(false);
356356
const [cropAspectRatio, setCropAspectRatio] = useState("");
357+
const isPortraitCanvas = isPortraitAspectRatio(aspectRatio);
357358

358359
const videoWidth = videoElement?.videoWidth || 1920;
359360
const videoHeight = videoElement?.videoHeight || 1080;
@@ -779,15 +780,17 @@ export function SettingsPanel({
779780
<SelectValue placeholder={t("layout.selectPreset")} />
780781
</SelectTrigger>
781782
<SelectContent>
782-
{WEBCAM_LAYOUT_PRESETS.filter(
783-
(preset) =>
784-
preset.value === "picture-in-picture" ||
785-
isPortraitAspectRatio(aspectRatio),
786-
).map((preset) => (
783+
{WEBCAM_LAYOUT_PRESETS.filter((preset) => {
784+
if (preset.value === "picture-in-picture") return true;
785+
if (preset.value === "vertical-stack") return isPortraitCanvas;
786+
return !isPortraitCanvas;
787+
}).map((preset) => (
787788
<SelectItem key={preset.value} value={preset.value} className="text-xs">
788789
{preset.value === "picture-in-picture"
789790
? t("layout.pictureInPicture")
790-
: t("layout.verticalStack")}
791+
: preset.value === "vertical-stack"
792+
? t("layout.verticalStack")
793+
: t("layout.dualFrame")}
791794
</SelectItem>
792795
))}
793796
</SelectContent>

src/components/video-editor/VideoEditor.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1884,7 +1884,8 @@ export default function VideoEditor() {
18841884
pushState({
18851885
aspectRatio: ar,
18861886
webcamLayoutPreset:
1887-
!isPortraitAspectRatio(ar) && webcamLayoutPreset === "vertical-stack"
1887+
(isPortraitAspectRatio(ar) && webcamLayoutPreset === "dual-frame") ||
1888+
(!isPortraitAspectRatio(ar) && webcamLayoutPreset === "vertical-stack")
18881889
? "picture-in-picture"
18891890
: webcamLayoutPreset,
18901891
})
@@ -1937,7 +1938,7 @@ export default function VideoEditor() {
19371938
onWebcamLayoutPresetChange={(preset) =>
19381939
pushState({
19391940
webcamLayoutPreset: preset,
1940-
webcamPosition: preset === "vertical-stack" ? null : webcamPosition,
1941+
webcamPosition: preset === "picture-in-picture" ? webcamPosition : null,
19411942
})
19421943
}
19431944
webcamMaskShape={webcamMaskShape}

src/components/video-editor/projectPersistence.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ describe("projectPersistence media compatibility", () => {
4444
aspectRatio: "16:9",
4545
webcamLayoutPreset: "picture-in-picture",
4646
webcamMaskShape: "circle",
47+
webcamPosition: null,
4748
exportQuality: "good",
4849
exportFormat: "mp4",
4950
gifFrameRate: 15,
@@ -66,6 +67,30 @@ describe("projectPersistence media compatibility", () => {
6667
normalizeProjectEditor({ webcamMaskShape: "not-a-real-shape" as never }).webcamMaskShape,
6768
).toBe("rectangle");
6869
});
70+
71+
it("accepts the dual frame webcam layout preset", () => {
72+
expect(normalizeProjectEditor({ webcamLayoutPreset: "dual-frame" }).webcamLayoutPreset).toBe(
73+
"dual-frame",
74+
);
75+
});
76+
77+
it("falls back from dual frame to picture in picture for portrait aspect ratios", () => {
78+
expect(
79+
normalizeProjectEditor({
80+
aspectRatio: "9:16",
81+
webcamLayoutPreset: "dual-frame",
82+
}).webcamLayoutPreset,
83+
).toBe("picture-in-picture");
84+
});
85+
86+
it("clears webcamPosition when the normalized preset is not picture in picture", () => {
87+
expect(
88+
normalizeProjectEditor({
89+
webcamLayoutPreset: "dual-frame",
90+
webcamPosition: { cx: 0.2, cy: 0.8 },
91+
}).webcamPosition,
92+
).toBeNull();
93+
});
6994
});
7095

7196
it("creates stable snapshots for identical project state", () => {

src/components/video-editor/projectPersistence.ts

Lines changed: 44 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { ExportFormat, ExportQuality, GifFrameRate, GifSizePreset } from "@/lib/exporter";
22
import type { ProjectMedia } from "@/lib/recordingSession";
33
import { normalizeProjectMedia } from "@/lib/recordingSession";
4-
import { ASPECT_RATIOS, type AspectRatio } from "@/utils/aspectRatioUtils";
4+
import { ASPECT_RATIOS, type AspectRatio, isPortraitAspectRatio } from "@/utils/aspectRatioUtils";
55
import {
66
type AnnotationRegion,
77
type CropRegion,
@@ -78,6 +78,26 @@ function isFiniteNumber(value: unknown): value is number {
7878
return typeof value === "number" && Number.isFinite(value);
7979
}
8080

81+
function computeNormalizedWebcamLayoutPreset(
82+
webcamLayoutPreset: Partial<ProjectEditorState>["webcamLayoutPreset"],
83+
normalizedAspectRatio: AspectRatio,
84+
): WebcamLayoutPreset {
85+
switch (webcamLayoutPreset) {
86+
case "picture-in-picture":
87+
return webcamLayoutPreset;
88+
case "vertical-stack":
89+
return isPortraitAspectRatio(normalizedAspectRatio)
90+
? webcamLayoutPreset
91+
: DEFAULT_WEBCAM_LAYOUT_PRESET;
92+
case "dual-frame":
93+
return isPortraitAspectRatio(normalizedAspectRatio)
94+
? DEFAULT_WEBCAM_LAYOUT_PRESET
95+
: webcamLayoutPreset;
96+
default:
97+
return DEFAULT_WEBCAM_LAYOUT_PRESET;
98+
}
99+
}
100+
81101
function clamp(value: number, min: number, max: number) {
82102
return Math.min(max, Math.max(min, value));
83103
}
@@ -185,6 +205,26 @@ export function resolveProjectMedia(
185205

186206
export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): ProjectEditorState {
187207
const validAspectRatios = new Set<AspectRatio>(ASPECT_RATIOS);
208+
const normalizedAspectRatio: AspectRatio = validAspectRatios.has(
209+
editor.aspectRatio as AspectRatio,
210+
)
211+
? (editor.aspectRatio as AspectRatio)
212+
: "16:9";
213+
const normalizedWebcamLayoutPreset = computeNormalizedWebcamLayoutPreset(
214+
editor.webcamLayoutPreset,
215+
normalizedAspectRatio,
216+
);
217+
const normalizedWebcamPosition: WebcamPosition | null =
218+
normalizedWebcamLayoutPreset === "picture-in-picture" &&
219+
editor.webcamPosition &&
220+
typeof editor.webcamPosition === "object" &&
221+
isFiniteNumber((editor.webcamPosition as WebcamPosition).cx) &&
222+
isFiniteNumber((editor.webcamPosition as WebcamPosition).cy)
223+
? {
224+
cx: clamp((editor.webcamPosition as WebcamPosition).cx, 0, 1),
225+
cy: clamp((editor.webcamPosition as WebcamPosition).cy, 0, 1),
226+
}
227+
: DEFAULT_WEBCAM_POSITION;
188228

189229
const normalizedZoomRegions: ZoomRegion[] = Array.isArray(editor.zoomRegions)
190230
? editor.zoomRegions
@@ -396,13 +436,8 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
396436
trimRegions: normalizedTrimRegions,
397437
speedRegions: normalizedSpeedRegions,
398438
annotationRegions: normalizedAnnotationRegions,
399-
aspectRatio:
400-
editor.aspectRatio && validAspectRatios.has(editor.aspectRatio) ? editor.aspectRatio : "16:9",
401-
webcamLayoutPreset:
402-
editor.webcamLayoutPreset === "vertical-stack" ||
403-
editor.webcamLayoutPreset === "picture-in-picture"
404-
? editor.webcamLayoutPreset
405-
: DEFAULT_WEBCAM_LAYOUT_PRESET,
439+
aspectRatio: normalizedAspectRatio,
440+
webcamLayoutPreset: normalizedWebcamLayoutPreset,
406441
webcamMaskShape:
407442
editor.webcamMaskShape === "rectangle" ||
408443
editor.webcamMaskShape === "circle" ||
@@ -414,16 +449,7 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
414449
typeof editor.webcamSizePreset === "number" && isFiniteNumber(editor.webcamSizePreset)
415450
? Math.max(10, Math.min(50, editor.webcamSizePreset))
416451
: DEFAULT_WEBCAM_SIZE_PRESET,
417-
webcamPosition:
418-
editor.webcamPosition &&
419-
typeof editor.webcamPosition === "object" &&
420-
isFiniteNumber((editor.webcamPosition as WebcamPosition).cx) &&
421-
isFiniteNumber((editor.webcamPosition as WebcamPosition).cy)
422-
? {
423-
cx: clamp((editor.webcamPosition as WebcamPosition).cx, 0, 1),
424-
cy: clamp((editor.webcamPosition as WebcamPosition).cy, 0, 1),
425-
}
426-
: DEFAULT_WEBCAM_POSITION,
452+
webcamPosition: normalizedWebcamPosition,
427453
exportQuality:
428454
editor.exportQuality === "medium" || editor.exportQuality === "source"
429455
? editor.exportQuality

src/components/video-editor/videoPlayback/layoutUtils.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ export function layoutVideoContent(params: LayoutParams): LayoutResult | null {
140140
screenRect.y,
141141
screenRect.width,
142142
screenRect.height,
143-
compositeLayout.screenCover ? 0 : borderRadius,
143+
compositeLayout.screenBorderRadius ?? (compositeLayout.screenCover ? 0 : borderRadius),
144144
);
145145
maskGraphics.fill({ color: 0xffffff });
146146

src/i18n/locales/en/settings.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
"selectPreset": "Select preset",
2727
"pictureInPicture": "Picture in Picture",
2828
"verticalStack": "Vertical Stack",
29+
"dualFrame": "Dual Frame",
2930
"webcamShape": "Camera Shape",
3031
"webcamSize": "Webcam Size"
3132
},

src/i18n/locales/es/settings.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
"selectPreset": "Seleccionar predefinido",
2727
"pictureInPicture": "Imagen en imagen",
2828
"verticalStack": "Apilado vertical",
29+
"dualFrame": "Marco dual",
2930
"webcamShape": "Forma de cámara",
3031
"webcamSize": "Tamaño de cámara"
3132
},

src/i18n/locales/zh-CN/settings.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
"selectPreset": "选择预设",
2727
"pictureInPicture": "画中画",
2828
"verticalStack": "垂直堆叠",
29+
"dualFrame": "双画框",
2930
"webcamShape": "摄像头形状",
3031
"webcamSize": "摄像头大小"
3132
},

src/lib/compositeLayout.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,29 @@ describe("computeCompositeLayout", () => {
169169
expect(layout?.screenCover).toBe(true);
170170
});
171171

172+
it("uses a 2:1 split layout in dual frame mode", () => {
173+
const layout = computeCompositeLayout({
174+
canvasSize: { width: 1920, height: 1080 },
175+
maxContentSize: { width: 1536, height: 864 },
176+
screenSize: { width: 1920, height: 1080 },
177+
webcamSize: { width: 1280, height: 720 },
178+
layoutPreset: "dual-frame",
179+
});
180+
181+
expect(layout).not.toBeNull();
182+
expect(layout?.webcamRect).not.toBeNull();
183+
expect(layout?.screenRect.y).toBe(108);
184+
expect(layout?.screenRect.height).toBe(864);
185+
expect(layout?.screenBorderRadius).toBe(layout?.webcamRect?.borderRadius);
186+
expect(layout?.webcamRect?.y).toBe(108);
187+
expect(layout?.webcamRect?.height).toBe(864);
188+
expect(layout?.webcamRect?.x).toBeGreaterThan(layout?.screenRect.x ?? 0);
189+
expect(
190+
Math.abs((layout?.screenRect.width ?? 0) - 2 * (layout?.webcamRect?.width ?? 0)),
191+
).toBeLessThanOrEqual(1);
192+
expect(layout?.screenCover).toBe(true);
193+
});
194+
172195
it("forces circular and square masks to use square dimensions", () => {
173196
const circularLayout = computeCompositeLayout({
174197
canvasSize: { width: 1920, height: 1080 },

0 commit comments

Comments
 (0)