diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index 9bd84535..ebb373f4 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -81,6 +81,7 @@ import type { ZoomFocusMode, } from "./types"; import { + DEFAULT_WEBCAM_MIRRORED, MAX_ZOOM_SCALE, MIN_ZOOM_SCALE, ROTATION_3D_PRESET_ORDER, @@ -316,6 +317,8 @@ interface SettingsPanelProps { onWebcamLayoutPresetChange?: (preset: WebcamLayoutPreset) => void; webcamMaskShape?: import("./types").WebcamMaskShape; onWebcamMaskShapeChange?: (shape: import("./types").WebcamMaskShape) => void; + webcamMirrored?: boolean; + onWebcamMirroredChange?: (mirrored: boolean) => void; webcamSizePreset?: WebcamSizePreset; onWebcamSizePresetChange?: (size: WebcamSizePreset) => void; onWebcamSizePresetCommit?: () => void; @@ -445,6 +448,8 @@ export function SettingsPanel({ onWebcamLayoutPresetChange, webcamMaskShape = DEFAULT_WEBCAM_SETTINGS.maskShape, onWebcamMaskShapeChange, + webcamMirrored = DEFAULT_WEBCAM_MIRRORED, + onWebcamMirroredChange, webcamSizePreset = DEFAULT_WEBCAM_SETTINGS.sizePreset, onWebcamSizePresetChange, onWebcamSizePresetCommit, @@ -1234,6 +1239,19 @@ export function SettingsPanel({ + {webcamLayoutPreset !== "no-webcam" && ( +
+
+ {t("layout.mirrorWebcam")} +
+ +
+ )} {webcamLayoutPreset === "picture-in-picture" && (
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, + ); +}