diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx
index 05034632..926e4153 100644
--- a/src/components/video-editor/VideoEditor.tsx
+++ b/src/components/video-editor/VideoEditor.tsx
@@ -178,6 +178,7 @@ export default function VideoEditor() {
aspectRatio,
webcamLayoutPreset,
webcamMaskShape,
+ webcamMirrored,
webcamSizePreset,
webcamPosition,
} = editorState;
@@ -375,6 +376,7 @@ export default function VideoEditor() {
aspectRatio: normalizedEditor.aspectRatio,
webcamLayoutPreset: normalizedEditor.webcamLayoutPreset,
webcamMaskShape: normalizedEditor.webcamMaskShape,
+ webcamMirrored: normalizedEditor.webcamMirrored,
webcamSizePreset: normalizedEditor.webcamSizePreset,
webcamPosition: normalizedEditor.webcamPosition,
});
@@ -447,6 +449,8 @@ export default function VideoEditor() {
aspectRatio,
webcamLayoutPreset,
webcamMaskShape,
+ webcamMirrored,
+ webcamSizePreset,
webcamPosition,
exportQuality,
exportFormat,
@@ -471,6 +475,8 @@ export default function VideoEditor() {
aspectRatio,
webcamLayoutPreset,
webcamMaskShape,
+ webcamMirrored,
+ webcamSizePreset,
webcamPosition,
exportQuality,
exportFormat,
@@ -595,6 +601,7 @@ export default function VideoEditor() {
aspectRatio,
webcamLayoutPreset,
webcamMaskShape,
+ webcamMirrored,
webcamSizePreset,
webcamPosition,
exportQuality,
@@ -655,6 +662,8 @@ export default function VideoEditor() {
aspectRatio,
webcamLayoutPreset,
webcamMaskShape,
+ webcamMirrored,
+ webcamSizePreset,
webcamPosition,
exportQuality,
exportFormat,
@@ -663,7 +672,6 @@ export default function VideoEditor() {
gifSizePreset,
videoPath,
t,
- webcamSizePreset,
],
);
@@ -1733,6 +1741,7 @@ export default function VideoEditor() {
annotationRegions,
webcamLayoutPreset,
webcamMaskShape,
+ webcamMirrored,
webcamSizePreset,
webcamPosition,
previewWidth,
@@ -1824,6 +1833,7 @@ export default function VideoEditor() {
annotationRegions,
webcamLayoutPreset,
webcamMaskShape,
+ webcamMirrored,
webcamSizePreset,
webcamPosition,
previewWidth,
@@ -1925,6 +1935,7 @@ export default function VideoEditor() {
aspectRatio,
webcamLayoutPreset,
webcamMaskShape,
+ webcamMirrored,
webcamSizePreset,
webcamPosition,
exportQuality,
@@ -2197,6 +2208,7 @@ export default function VideoEditor() {
webcamVideoPath={webcamVideoPath || undefined}
webcamLayoutPreset={webcamLayoutPreset}
webcamMaskShape={webcamMaskShape}
+ webcamMirrored={webcamMirrored}
webcamSizePreset={webcamSizePreset}
webcamPosition={webcamPosition}
onWebcamPositionChange={(pos) => updateState({ webcamPosition: pos })}
@@ -2340,6 +2352,8 @@ export default function VideoEditor() {
}
webcamMaskShape={webcamMaskShape}
onWebcamMaskShapeChange={(shape) => pushState({ webcamMaskShape: shape })}
+ webcamMirrored={webcamMirrored}
+ onWebcamMirroredChange={(mirrored) => pushState({ webcamMirrored: mirrored })}
webcamSizePreset={webcamSizePreset}
onWebcamSizePresetChange={(v) => updateState({ webcamSizePreset: v })}
onWebcamSizePresetCommit={commitState}
diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx
index 9f7d8a17..d5172e62 100644
--- a/src/components/video-editor/VideoPlayback.tsx
+++ b/src/components/video-editor/VideoPlayback.tsx
@@ -103,6 +103,7 @@ interface VideoPlaybackProps {
webcamVideoPath?: string;
webcamLayoutPreset: WebcamLayoutPreset;
webcamMaskShape?: import("./types").WebcamMaskShape;
+ webcamMirrored?: boolean;
webcamSizePreset?: WebcamSizePreset;
webcamPosition?: { cx: number; cy: number } | null;
onWebcamPositionChange?: (position: { cx: number; cy: number }) => void;
@@ -227,6 +228,7 @@ const VideoPlayback = forwardRef
(
webcamVideoPath,
webcamLayoutPreset,
webcamMaskShape,
+ webcamMirrored = false,
webcamSizePreset,
webcamPosition,
onWebcamPositionChange,
@@ -1909,6 +1911,7 @@ const VideoPlayback = forwardRef(
clipPath: clipPath ?? undefined,
boxShadow: useClipPath ? "none" : webcamCssBoxShadow,
backgroundColor: "#000",
+ transform: webcamMirrored ? "scaleX(-1)" : undefined,
}}
onPointerDown={handleWebcamPointerDown}
onPointerMove={handleWebcamPointerMove}
diff --git a/src/components/video-editor/projectPersistence.test.ts b/src/components/video-editor/projectPersistence.test.ts
index d40d1f4f..0671b3fa 100644
--- a/src/components/video-editor/projectPersistence.test.ts
+++ b/src/components/video-editor/projectPersistence.test.ts
@@ -44,6 +44,8 @@ describe("projectPersistence media compatibility", () => {
aspectRatio: "16:9",
webcamLayoutPreset: "picture-in-picture",
webcamMaskShape: "circle",
+ webcamMirrored: true,
+ webcamSizePreset: 25,
webcamPosition: null,
exportQuality: "good",
exportFormat: "mp4",
@@ -68,6 +70,12 @@ describe("projectPersistence media compatibility", () => {
).toBe("rectangle");
});
+ it("normalizes webcam mirroring safely", () => {
+ expect(normalizeProjectEditor({ webcamMirrored: true }).webcamMirrored).toBe(true);
+ expect(normalizeProjectEditor({ webcamMirrored: false }).webcamMirrored).toBe(false);
+ expect(normalizeProjectEditor({ webcamMirrored: "yes" as never }).webcamMirrored).toBe(false);
+ });
+
it("normalizes blur region type and mosaic block size safely", () => {
const editor = normalizeProjectEditor({
annotationRegions: [
diff --git a/src/components/video-editor/projectPersistence.ts b/src/components/video-editor/projectPersistence.ts
index ff59427f..53c39857 100644
--- a/src/components/video-editor/projectPersistence.ts
+++ b/src/components/video-editor/projectPersistence.ts
@@ -25,6 +25,7 @@ import {
DEFAULT_BLUR_INTENSITY,
DEFAULT_FIGURE_DATA,
DEFAULT_PLAYBACK_SPEED,
+ DEFAULT_WEBCAM_MIRRORED,
DEFAULT_ZOOM_DEPTH,
DEFAULT_ZOOM_MOTION_BLUR,
MAX_BLUR_BLOCK_SIZE,
@@ -78,6 +79,7 @@ export interface ProjectEditorState {
aspectRatio: AspectRatio;
webcamLayoutPreset: WebcamLayoutPreset;
webcamMaskShape: WebcamMaskShape;
+ webcamMirrored: boolean;
webcamSizePreset: WebcamSizePreset;
webcamPosition: WebcamPosition | null;
exportQuality: ExportQuality;
@@ -485,6 +487,8 @@ export function normalizeProjectEditor(editor: Partial): Pro
editor.webcamMaskShape === "rounded"
? editor.webcamMaskShape
: DEFAULT_WEBCAM_SETTINGS.maskShape,
+ webcamMirrored:
+ typeof editor.webcamMirrored === "boolean" ? editor.webcamMirrored : DEFAULT_WEBCAM_MIRRORED,
webcamSizePreset:
typeof editor.webcamSizePreset === "number" && isFiniteNumber(editor.webcamSizePreset)
? Math.max(10, Math.min(50, editor.webcamSizePreset))
diff --git a/src/components/video-editor/types.ts b/src/components/video-editor/types.ts
index 0f2267cc..99311b65 100644
--- a/src/components/video-editor/types.ts
+++ b/src/components/video-editor/types.ts
@@ -14,6 +14,8 @@ export type WebcamMaskShape = "rectangle" | "circle" | "square" | "rounded";
export const DEFAULT_WEBCAM_MASK_SHAPE: WebcamMaskShape = "rectangle";
+export const DEFAULT_WEBCAM_MIRRORED = false;
+
export interface WebcamPosition {
cx: number; // normalized horizontal center (0-1)
cy: number; // normalized vertical center (0-1)
diff --git a/src/hooks/useEditorHistory.ts b/src/hooks/useEditorHistory.ts
index 25b6e21a..ca9fa3eb 100644
--- a/src/hooks/useEditorHistory.ts
+++ b/src/hooks/useEditorHistory.ts
@@ -15,7 +15,7 @@ import type {
WebcamSizePreset,
ZoomRegion,
} from "@/components/video-editor/types";
-import { DEFAULT_CROP_REGION } from "@/components/video-editor/types";
+import { DEFAULT_CROP_REGION, DEFAULT_WEBCAM_MIRRORED } from "@/components/video-editor/types";
import type { AspectRatio } from "@/utils/aspectRatioUtils";
// Undoable state — selection IDs are intentionally excluded (undoing a
@@ -36,6 +36,7 @@ export interface EditorState {
aspectRatio: AspectRatio;
webcamLayoutPreset: WebcamLayoutPreset;
webcamMaskShape: WebcamMaskShape;
+ webcamMirrored: boolean;
webcamSizePreset: WebcamSizePreset;
webcamPosition: WebcamPosition | null;
}
@@ -56,6 +57,7 @@ export const INITIAL_EDITOR_STATE: EditorState = {
aspectRatio: DEFAULT_EDITOR_LAYOUT_SETTINGS.aspectRatio,
webcamLayoutPreset: DEFAULT_WEBCAM_SETTINGS.layoutPreset,
webcamMaskShape: DEFAULT_WEBCAM_SETTINGS.maskShape,
+ webcamMirrored: DEFAULT_WEBCAM_MIRRORED,
webcamSizePreset: DEFAULT_WEBCAM_SETTINGS.sizePreset,
webcamPosition: DEFAULT_WEBCAM_SETTINGS.position,
};
diff --git a/src/i18n/locales/ar/settings.json b/src/i18n/locales/ar/settings.json
index 9ddc2ba7..db62bb00 100644
--- a/src/i18n/locales/ar/settings.json
+++ b/src/i18n/locales/ar/settings.json
@@ -45,7 +45,8 @@
"dualFrame": "إطار مزدوج",
"webcamShape": "شكل الكاميرا",
"webcamSize": "حجم كاميرا الويب",
- "noWebcam": "بدون كاميرا"
+ "noWebcam": "بدون كاميرا",
+ "mirrorWebcam": "عكس كاميرا الويب"
},
"effects": {
"title": "تأثيرات الفيديو",
diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json
index 02d44c65..6936a6f8 100644
--- a/src/i18n/locales/en/settings.json
+++ b/src/i18n/locales/en/settings.json
@@ -45,7 +45,8 @@
"dualFrame": "Dual Frame",
"noWebcam": "No Webcam",
"webcamShape": "Camera Shape",
- "webcamSize": "Webcam Size"
+ "webcamSize": "Webcam Size",
+ "mirrorWebcam": "Mirror Webcam"
},
"effects": {
"title": "Video Effects",
diff --git a/src/i18n/locales/es/settings.json b/src/i18n/locales/es/settings.json
index 21295bf8..7ec9fb54 100644
--- a/src/i18n/locales/es/settings.json
+++ b/src/i18n/locales/es/settings.json
@@ -45,7 +45,8 @@
"dualFrame": "Marco dual",
"webcamShape": "Forma de cámara",
"webcamSize": "Tamaño de cámara",
- "noWebcam": "Sin cámara"
+ "noWebcam": "Sin cámara",
+ "mirrorWebcam": "Reflejar cámara"
},
"effects": {
"title": "Efectos de video",
diff --git a/src/i18n/locales/fr/settings.json b/src/i18n/locales/fr/settings.json
index f5224afd..bf700f70 100644
--- a/src/i18n/locales/fr/settings.json
+++ b/src/i18n/locales/fr/settings.json
@@ -45,7 +45,8 @@
"dualFrame": "Double cadre",
"webcamShape": "Forme de la caméra",
"webcamSize": "Taille de la caméra",
- "noWebcam": "Sans webcam"
+ "noWebcam": "Sans webcam",
+ "mirrorWebcam": "Inverser la webcam"
},
"effects": {
"title": "Effets vidéo",
diff --git a/src/i18n/locales/it/settings.json b/src/i18n/locales/it/settings.json
index a1c8c564..fb173471 100644
--- a/src/i18n/locales/it/settings.json
+++ b/src/i18n/locales/it/settings.json
@@ -45,7 +45,8 @@
"dualFrame": "Doppio frame",
"noWebcam": "Nessuna webcam",
"webcamShape": "Forma fotocamera",
- "webcamSize": "Dimensione webcam"
+ "webcamSize": "Dimensione webcam",
+ "mirrorWebcam": "Specchia webcam"
},
"effects": {
"title": "Effetti video",
diff --git a/src/i18n/locales/ja-JP/settings.json b/src/i18n/locales/ja-JP/settings.json
index efecf27e..360e0d85 100644
--- a/src/i18n/locales/ja-JP/settings.json
+++ b/src/i18n/locales/ja-JP/settings.json
@@ -45,7 +45,8 @@
"dualFrame": "デュアルフレーム",
"webcamShape": "カメラの形状",
"webcamSize": "カメラのサイズ",
- "noWebcam": "Webカメラなし"
+ "noWebcam": "Webカメラなし",
+ "mirrorWebcam": "Webカメラを反転"
},
"effects": {
"title": "動画効果",
diff --git a/src/i18n/locales/ko-KR/settings.json b/src/i18n/locales/ko-KR/settings.json
index 5921ca3e..094eda56 100644
--- a/src/i18n/locales/ko-KR/settings.json
+++ b/src/i18n/locales/ko-KR/settings.json
@@ -45,7 +45,8 @@
"webcamShape": "카메라 모양",
"webcamSize": "웹캠 크기",
"dualFrame": "듀얼 프레임",
- "noWebcam": "웹캠 없음"
+ "noWebcam": "웹캠 없음",
+ "mirrorWebcam": "웹캠 미러링"
},
"effects": {
"title": "비디오 효과",
diff --git a/src/i18n/locales/ru/settings.json b/src/i18n/locales/ru/settings.json
index e0868449..70e11168 100644
--- a/src/i18n/locales/ru/settings.json
+++ b/src/i18n/locales/ru/settings.json
@@ -45,7 +45,8 @@
"dualFrame": "Двойной кадр",
"webcamShape": "Форма камеры",
"webcamSize": "Размер веб-камеры",
- "noWebcam": "Без веб-камеры"
+ "noWebcam": "Без веб-камеры",
+ "mirrorWebcam": "Зеркалить веб-камеру"
},
"effects": {
"title": "Видеоэффекты",
diff --git a/src/i18n/locales/tr/settings.json b/src/i18n/locales/tr/settings.json
index 7155a084..6929480b 100644
--- a/src/i18n/locales/tr/settings.json
+++ b/src/i18n/locales/tr/settings.json
@@ -45,7 +45,8 @@
"webcamShape": "Kamera Şekli",
"dualFrame": "Çift Kare",
"webcamSize": "Webcam Boyutu",
- "noWebcam": "Web kamerası yok"
+ "noWebcam": "Web kamerası yok",
+ "mirrorWebcam": "Web kamerasını aynala"
},
"effects": {
"title": "Video Efektleri",
diff --git a/src/i18n/locales/vi/settings.json b/src/i18n/locales/vi/settings.json
index 60139ccb..cfa7e774 100644
--- a/src/i18n/locales/vi/settings.json
+++ b/src/i18n/locales/vi/settings.json
@@ -45,7 +45,8 @@
"dualFrame": "Khung kép",
"webcamShape": "Hình dạng máy ảnh",
"webcamSize": "Kích thước Webcam",
- "noWebcam": "Không có webcam"
+ "noWebcam": "Không có webcam",
+ "mirrorWebcam": "Lật webcam"
},
"effects": {
"title": "Hiệu ứng video",
diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json
index 9455bf58..2ee57cfd 100644
--- a/src/i18n/locales/zh-CN/settings.json
+++ b/src/i18n/locales/zh-CN/settings.json
@@ -45,7 +45,8 @@
"dualFrame": "双画框",
"webcamShape": "摄像头形状",
"webcamSize": "摄像头大小",
- "noWebcam": "无摄像头"
+ "noWebcam": "无摄像头",
+ "mirrorWebcam": "镜像摄像头"
},
"effects": {
"title": "视频效果",
diff --git a/src/i18n/locales/zh-TW/settings.json b/src/i18n/locales/zh-TW/settings.json
index 44058e7b..35b80402 100644
--- a/src/i18n/locales/zh-TW/settings.json
+++ b/src/i18n/locales/zh-TW/settings.json
@@ -46,7 +46,8 @@
"dualFrame": "雙畫框",
"webcamShape": "攝影機形狀",
"webcamSize": "攝影機大小",
- "noWebcam": "無網路攝影機"
+ "noWebcam": "無網路攝影機",
+ "mirrorWebcam": "鏡像攝影機"
},
"effects": {
"title": "影片效果",
diff --git a/src/lib/exporter/frameRenderer.test.ts b/src/lib/exporter/frameRenderer.test.ts
new file mode 100644
index 00000000..16ba6bde
--- /dev/null
+++ b/src/lib/exporter/frameRenderer.test.ts
@@ -0,0 +1,97 @@
+import { describe, expect, it } from "vitest";
+import { drawWebcamFrameImage } from "./webcamFrameDrawing";
+
+type DrawCall =
+ | ["drawImage", unknown, number, number, number, number, number, number, number, number]
+ | ["restore"]
+ | ["save"]
+ | ["scale", number, number]
+ | ["translate", number, number];
+
+function createMockCanvasContext() {
+ const calls: DrawCall[] = [];
+ const ctx = {
+ drawImage: (
+ image: CanvasImageSource,
+ sx: number,
+ sy: number,
+ sw: number,
+ sh: number,
+ dx: number,
+ dy: number,
+ dw: number,
+ dh: number,
+ ) => calls.push(["drawImage", image, sx, sy, sw, sh, dx, dy, dw, dh]),
+ restore: () => calls.push(["restore"]),
+ save: () => calls.push(["save"]),
+ scale: (x: number, y: number) => calls.push(["scale", x, y]),
+ translate: (x: number, y: number) => calls.push(["translate", x, y]),
+ };
+
+ return { calls, ctx };
+}
+
+describe("drawWebcamFrameImage", () => {
+ it("draws the webcam frame into the layout rect by default", () => {
+ const { calls, ctx } = createMockCanvasContext();
+ const frame = {} as CanvasImageSource;
+
+ drawWebcamFrameImage(
+ ctx,
+ frame,
+ { x: 12, y: 8, width: 640, height: 360 },
+ { x: 100, y: 50, width: 320, height: 180 },
+ );
+
+ expect(calls).toEqual([["drawImage", frame, 12, 8, 640, 360, 100, 50, 320, 180]]);
+ });
+
+ it("mirrors around the webcam rect without changing the crop", () => {
+ const { calls, ctx } = createMockCanvasContext();
+ const frame = {} as CanvasImageSource;
+
+ drawWebcamFrameImage(
+ ctx,
+ frame,
+ { x: 12, y: 8, width: 640, height: 360 },
+ { x: 100, y: 50, width: 320, height: 180 },
+ true,
+ );
+
+ expect(calls).toEqual([
+ ["save"],
+ ["translate", 420, 50],
+ ["scale", -1, 1],
+ ["drawImage", frame, 12, 8, 640, 360, 0, 0, 320, 180],
+ ["restore"],
+ ]);
+ });
+
+ it("restores the canvas context if mirrored drawing fails", () => {
+ const { calls, ctx } = createMockCanvasContext();
+ const frame = {} as CanvasImageSource;
+ const error = new Error("draw failed");
+ ctx.drawImage = () => {
+ calls.push(["drawImage", frame, 12, 8, 640, 360, 0, 0, 320, 180]);
+ throw error;
+ };
+
+ expect(() =>
+ drawWebcamFrameImage(
+ ctx,
+ frame,
+ { x: 12, y: 8, width: 640, height: 360 },
+ { x: 100, y: 50, width: 320, height: 180 },
+ true,
+ ),
+ ).toThrow(error);
+
+ expect(calls).toEqual([
+ ["save"],
+ ["translate", 420, 50],
+ ["scale", -1, 1],
+ ["drawImage", frame, 12, 8, 640, 360, 0, 0, 320, 180],
+ ["restore"],
+ ]);
+ });
+});
diff --git a/src/lib/exporter/frameRenderer.ts b/src/lib/exporter/frameRenderer.ts
index 85821581..2ae31a78 100644
--- a/src/lib/exporter/frameRenderer.ts
+++ b/src/lib/exporter/frameRenderer.ts
@@ -74,6 +74,7 @@ import {
resolveLinearGradientAngle,
} from "./gradientParser";
import { createThreeDPass, type ThreeDPass } from "./threeDPass";
+import { drawWebcamFrameImage } from "./webcamFrameDrawing";
interface FrameRenderConfig {
width: number;
@@ -98,6 +99,7 @@ interface FrameRenderConfig {
webcamSize?: Size | null;
webcamLayoutPreset?: WebcamLayoutPreset;
webcamMaskShape?: import("@/components/video-editor/types").WebcamMaskShape;
+ webcamMirrored?: boolean;
webcamSizePreset?: WebcamSizePreset;
webcamPosition?: { cx: number; cy: number } | null;
annotationRegions?: AnnotationRegion[];
@@ -1109,16 +1111,22 @@ export class FrameRenderer {
fgCtx.fillStyle = "#000000";
fgCtx.fill();
fgCtx.clip();
- fgCtx.drawImage(
+ drawWebcamFrameImage(
+ fgCtx,
webcamFrame as unknown as CanvasImageSource,
- sourceCropX,
- sourceCropY,
- sourceCropWidth,
- sourceCropHeight,
- webcamRect.x,
- webcamRect.y,
- webcamRect.width,
- webcamRect.height,
+ {
+ x: sourceCropX,
+ y: sourceCropY,
+ width: sourceCropWidth,
+ height: sourceCropHeight,
+ },
+ {
+ x: webcamRect.x,
+ y: webcamRect.y,
+ width: webcamRect.width,
+ height: webcamRect.height,
+ },
+ this.config.webcamMirrored,
);
fgCtx.restore();
}
diff --git a/src/lib/exporter/gifExporter.ts b/src/lib/exporter/gifExporter.ts
index 7c0d2a66..42cf1da8 100644
--- a/src/lib/exporter/gifExporter.ts
+++ b/src/lib/exporter/gifExporter.ts
@@ -46,6 +46,7 @@ interface GifExporterConfig {
cropRegion: CropRegion;
webcamLayoutPreset?: WebcamLayoutPreset;
webcamMaskShape?: import("@/components/video-editor/types").WebcamMaskShape;
+ webcamMirrored?: boolean;
webcamSizePreset?: WebcamSizePreset;
webcamPosition?: { cx: number; cy: number } | null;
cursorRecordingData?: CursorRecordingData | null;
@@ -168,6 +169,7 @@ export class GifExporter {
webcamSize: webcamInfo ? { width: webcamInfo.width, height: webcamInfo.height } : null,
webcamLayoutPreset: this.config.webcamLayoutPreset,
webcamMaskShape: this.config.webcamMaskShape,
+ webcamMirrored: this.config.webcamMirrored,
webcamSizePreset: this.config.webcamSizePreset,
webcamPosition: this.config.webcamPosition,
annotationRegions: this.config.annotationRegions,
diff --git a/src/lib/exporter/videoExporter.ts b/src/lib/exporter/videoExporter.ts
index 35c3d559..f5ac02d1 100644
--- a/src/lib/exporter/videoExporter.ts
+++ b/src/lib/exporter/videoExporter.ts
@@ -37,6 +37,7 @@ export interface VideoExporterConfig extends ExportConfig {
cropRegion: CropRegion;
webcamLayoutPreset?: WebcamLayoutPreset;
webcamMaskShape?: import("@/components/video-editor/types").WebcamMaskShape;
+ webcamMirrored?: boolean;
webcamSizePreset?: WebcamSizePreset;
webcamPosition?: { cx: number; cy: number } | null;
cursorRecordingData?: CursorRecordingData | null;
@@ -248,6 +249,7 @@ export class VideoExporter {
webcamSize: webcamInfo ? { width: webcamInfo.width, height: webcamInfo.height } : null,
webcamLayoutPreset: this.config.webcamLayoutPreset,
webcamMaskShape: this.config.webcamMaskShape,
+ webcamMirrored: this.config.webcamMirrored,
webcamSizePreset: this.config.webcamSizePreset,
webcamPosition: this.config.webcamPosition,
annotationRegions: this.config.annotationRegions,
diff --git a/src/lib/exporter/webcamFrameDrawing.ts b/src/lib/exporter/webcamFrameDrawing.ts
new file mode 100644
index 00000000..41f98d8f
--- /dev/null
+++ b/src/lib/exporter/webcamFrameDrawing.ts
@@ -0,0 +1,43 @@
+interface WebcamFrameCrop {
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+}
+
+export type WebcamCanvasContext = Pick<
+ CanvasRenderingContext2D,
+ "drawImage" | "restore" | "save" | "scale" | "translate"
+>;
+
+export function drawWebcamFrameImage(
+ ctx: WebcamCanvasContext,
+ image: CanvasImageSource,
+ crop: WebcamFrameCrop,
+ dest: WebcamFrameCrop,
+ mirrored = false,
+) {
+ if (mirrored) {
+ ctx.save();
+ try {
+ ctx.translate(dest.x + dest.width, dest.y);
+ ctx.scale(-1, 1);
+ ctx.drawImage(image, crop.x, crop.y, crop.width, crop.height, 0, 0, dest.width, dest.height);
+ } finally {
+ ctx.restore();
+ }
+ return;
+ }
+
+ ctx.drawImage(
+ image,
+ crop.x,
+ crop.y,
+ crop.width,
+ crop.height,
+ dest.x,
+ dest.y,
+ dest.width,
+ dest.height,
+ );
+}