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.dualFrame")}
</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 === "dual-frame") ||
(!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
25 changes: 25 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,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();
});
});
62 changes: 44 additions & 18 deletions src/components/video-editor/projectPersistence.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -66,6 +66,26 @@ function isFiniteNumber(value: unknown): value is number {
return typeof value === "number" && Number.isFinite(value);
}

function computeNormalizedWebcamLayoutPreset(
webcamLayoutPreset: Partial<ProjectEditorState>["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));
}
Expand Down Expand Up @@ -173,6 +193,26 @@ export function resolveProjectMedia(

export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): ProjectEditorState {
const validAspectRatios = new Set<AspectRatio>(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
Expand Down Expand Up @@ -349,30 +389,16 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): 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" ||
editor.webcamMaskShape === "square" ||
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
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",
"dualFrame": "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",
"dualFrame": "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": "垂直堆叠",
"dualFrame": "双画框",
"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: "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 },
Expand Down
Loading