Skip to content

Commit 21893f0

Browse files
Merge pull request #288 from gulivan/feature/webcam-mask-shapes
Add webcam mask shape support
2 parents 763c187 + 9d0ccf3 commit 21893f0

17 files changed

Lines changed: 330 additions & 55 deletions

src/components/video-editor/SettingsPanel.tsx

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ import type {
5151
FigureData,
5252
PlaybackSpeed,
5353
WebcamLayoutPreset,
54+
WebcamMaskShape,
5455
ZoomDepth,
5556
ZoomFocusMode,
5657
} from "./types";
@@ -147,6 +148,8 @@ interface SettingsPanelProps {
147148
hasWebcam?: boolean;
148149
webcamLayoutPreset?: WebcamLayoutPreset;
149150
onWebcamLayoutPresetChange?: (preset: WebcamLayoutPreset) => void;
151+
webcamMaskShape?: import("./types").WebcamMaskShape;
152+
onWebcamMaskShapeChange?: (shape: import("./types").WebcamMaskShape) => void;
150153
}
151154

152155
export default SettingsPanel;
@@ -218,6 +221,8 @@ export function SettingsPanel({
218221
hasWebcam = false,
219222
webcamLayoutPreset = "picture-in-picture",
220223
onWebcamLayoutPresetChange,
224+
webcamMaskShape = "rectangle",
225+
onWebcamMaskShapeChange,
221226
}: SettingsPanelProps) {
222227
const t = useScopedT("settings");
223228
const [wallpaperPaths, setWallpaperPaths] = useState<string[]>([]);
@@ -665,6 +670,87 @@ export function SettingsPanel({
665670
</SelectContent>
666671
</Select>
667672
</div>
673+
{webcamLayoutPreset === "picture-in-picture" && (
674+
<div className="mt-2 p-2 rounded-lg bg-white/5 border border-white/5">
675+
<div className="text-[10px] font-medium text-slate-300 mb-1.5">
676+
{t("layout.webcamShape")}
677+
</div>
678+
<div className="grid grid-cols-4 gap-1.5">
679+
{(
680+
[
681+
{ value: "rectangle", label: "Rect" },
682+
{ value: "circle", label: "Circle" },
683+
{ value: "square", label: "Square" },
684+
{ value: "rounded", label: "Rounded" },
685+
] as Array<{ value: WebcamMaskShape; label: string }>
686+
).map((shape) => (
687+
<button
688+
key={shape.value}
689+
type="button"
690+
onClick={() => onWebcamMaskShapeChange?.(shape.value)}
691+
className={cn(
692+
"h-10 rounded-lg border flex flex-col items-center justify-center gap-0.5 transition-all",
693+
webcamMaskShape === shape.value
694+
? "bg-[#34B27B] border-[#34B27B] text-white"
695+
: "bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20 text-slate-400",
696+
)}
697+
>
698+
<svg
699+
width="16"
700+
height="16"
701+
viewBox="0 0 16 16"
702+
fill="none"
703+
xmlns="http://www.w3.org/2000/svg"
704+
>
705+
{shape.value === "rectangle" && (
706+
<rect
707+
x="1"
708+
y="3"
709+
width="14"
710+
height="10"
711+
rx="2"
712+
stroke="currentColor"
713+
strokeWidth="1.5"
714+
/>
715+
)}
716+
{shape.value === "circle" && (
717+
<circle
718+
cx="8"
719+
cy="8"
720+
r="6.5"
721+
stroke="currentColor"
722+
strokeWidth="1.5"
723+
/>
724+
)}
725+
{shape.value === "square" && (
726+
<rect
727+
x="2"
728+
y="2"
729+
width="12"
730+
height="12"
731+
rx="1"
732+
stroke="currentColor"
733+
strokeWidth="1.5"
734+
/>
735+
)}
736+
{shape.value === "rounded" && (
737+
<rect
738+
x="1"
739+
y="3"
740+
width="14"
741+
height="10"
742+
rx="5"
743+
stroke="currentColor"
744+
strokeWidth="1.5"
745+
/>
746+
)}
747+
</svg>
748+
<span className="text-[8px] leading-none">{shape.label}</span>
749+
</button>
750+
))}
751+
</div>
752+
</div>
753+
)}
668754
</AccordionContent>
669755
</AccordionItem>
670756
)}

src/components/video-editor/VideoEditor.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ export default function VideoEditor() {
8585
padding,
8686
aspectRatio,
8787
webcamLayoutPreset,
88+
webcamMaskShape,
8889
webcamPosition,
8990
} = editorState;
9091

@@ -196,6 +197,7 @@ export default function VideoEditor() {
196197
annotationRegions: normalizedEditor.annotationRegions,
197198
aspectRatio: normalizedEditor.aspectRatio,
198199
webcamLayoutPreset: normalizedEditor.webcamLayoutPreset,
200+
webcamMaskShape: normalizedEditor.webcamMaskShape,
199201
webcamPosition: normalizedEditor.webcamPosition,
200202
});
201203
setExportQuality(normalizedEditor.exportQuality);
@@ -265,6 +267,7 @@ export default function VideoEditor() {
265267
annotationRegions,
266268
aspectRatio,
267269
webcamLayoutPreset,
270+
webcamMaskShape,
268271
webcamPosition,
269272
exportQuality,
270273
exportFormat,
@@ -288,6 +291,7 @@ export default function VideoEditor() {
288291
annotationRegions,
289292
aspectRatio,
290293
webcamLayoutPreset,
294+
webcamMaskShape,
291295
webcamPosition,
292296
exportQuality,
293297
exportFormat,
@@ -381,6 +385,7 @@ export default function VideoEditor() {
381385
annotationRegions,
382386
aspectRatio,
383387
webcamLayoutPreset,
388+
webcamMaskShape,
384389
webcamPosition,
385390
exportQuality,
386391
exportFormat,
@@ -435,6 +440,7 @@ export default function VideoEditor() {
435440
annotationRegions,
436441
aspectRatio,
437442
webcamLayoutPreset,
443+
webcamMaskShape,
438444
webcamPosition,
439445
exportQuality,
440446
exportFormat,
@@ -1103,6 +1109,7 @@ export default function VideoEditor() {
11031109
cropRegion,
11041110
annotationRegions,
11051111
webcamLayoutPreset,
1112+
webcamMaskShape,
11061113
webcamPosition,
11071114
previewWidth,
11081115
previewHeight,
@@ -1235,6 +1242,7 @@ export default function VideoEditor() {
12351242
cropRegion,
12361243
annotationRegions,
12371244
webcamLayoutPreset,
1245+
webcamMaskShape,
12381246
webcamPosition,
12391247
previewWidth,
12401248
previewHeight,
@@ -1304,6 +1312,7 @@ export default function VideoEditor() {
13041312
isPlaying,
13051313
aspectRatio,
13061314
webcamLayoutPreset,
1315+
webcamMaskShape,
13071316
webcamPosition,
13081317
exportQuality,
13091318
handleExportSaved,
@@ -1489,6 +1498,7 @@ export default function VideoEditor() {
14891498
videoPath={videoPath || ""}
14901499
webcamVideoPath={webcamVideoPath || undefined}
14911500
webcamLayoutPreset={webcamLayoutPreset}
1501+
webcamMaskShape={webcamMaskShape}
14921502
webcamPosition={webcamPosition}
14931503
onWebcamPositionChange={(pos) => updateState({ webcamPosition: pos })}
14941504
onWebcamPositionDragEnd={commitState}
@@ -1637,6 +1647,8 @@ export default function VideoEditor() {
16371647
webcamPosition: preset === "vertical-stack" ? null : webcamPosition,
16381648
})
16391649
}
1650+
webcamMaskShape={webcamMaskShape}
1651+
onWebcamMaskShapeChange={(shape) => pushState({ webcamMaskShape: shape })}
16401652
videoElement={videoPlaybackRef.current?.video || null}
16411653
exportQuality={exportQuality}
16421654
onExportQualityChange={setExportQuality}

src/components/video-editor/VideoPlayback.tsx

Lines changed: 46 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
type StyledRenderRect,
2626
type WebcamLayoutPreset,
2727
} from "@/lib/compositeLayout";
28+
import { getCssClipPath } from "@/lib/webcamMaskShapes";
2829
import {
2930
type AspectRatio,
3031
formatAspectRatioForCSS,
@@ -67,6 +68,7 @@ interface VideoPlaybackProps {
6768
videoPath: string;
6869
webcamVideoPath?: string;
6970
webcamLayoutPreset: WebcamLayoutPreset;
71+
webcamMaskShape?: import("./types").WebcamMaskShape;
7072
webcamPosition?: { cx: number; cy: number } | null;
7173
onWebcamPositionChange?: (position: { cx: number; cy: number }) => void;
7274
onWebcamPositionDragEnd?: () => void;
@@ -116,6 +118,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
116118
videoPath,
117119
webcamVideoPath,
118120
webcamLayoutPreset,
121+
webcamMaskShape,
119122
webcamPosition,
120123
onWebcamPositionChange,
121124
onWebcamPositionDragEnd,
@@ -281,6 +284,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
281284
webcamDimensions,
282285
webcamLayoutPreset,
283286
webcamPosition,
287+
webcamMaskShape,
284288
});
285289

286290
if (result) {
@@ -311,6 +315,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
311315
webcamDimensions,
312316
webcamLayoutPreset,
313317
webcamPosition,
318+
webcamMaskShape,
314319
]);
315320

316321
useEffect(() => {
@@ -1215,31 +1220,47 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
12151220
: "none",
12161221
}}
12171222
/>
1218-
{webcamVideoPath && (
1219-
<video
1220-
ref={webcamVideoRef}
1221-
src={webcamVideoPath}
1222-
className={`absolute object-cover ${webcamLayoutPreset === "picture-in-picture" ? "cursor-grab active:cursor-grabbing" : "pointer-events-none"}`}
1223-
style={{
1224-
left: webcamLayout?.x ?? 0,
1225-
top: webcamLayout?.y ?? 0,
1226-
width: webcamLayout?.width ?? 0,
1227-
height: webcamLayout?.height ?? 0,
1228-
borderRadius: webcamLayout?.borderRadius ?? 0,
1229-
boxShadow: webcamCssBoxShadow,
1230-
zIndex: 20,
1231-
opacity: webcamLayout ? 1 : 0,
1232-
backgroundColor: "#000",
1233-
}}
1234-
onPointerDown={handleWebcamPointerDown}
1235-
onPointerMove={handleWebcamPointerMove}
1236-
onPointerUp={handleWebcamPointerUp}
1237-
onPointerLeave={handleWebcamPointerUp}
1238-
muted
1239-
preload="metadata"
1240-
playsInline
1241-
/>
1242-
)}
1223+
{webcamVideoPath &&
1224+
(() => {
1225+
const clipPath = getCssClipPath(webcamLayout?.maskShape ?? "rectangle");
1226+
const useClipPath = !!clipPath;
1227+
return (
1228+
<div
1229+
className="absolute"
1230+
style={{
1231+
left: webcamLayout?.x ?? 0,
1232+
top: webcamLayout?.y ?? 0,
1233+
width: webcamLayout?.width ?? 0,
1234+
height: webcamLayout?.height ?? 0,
1235+
zIndex: 20,
1236+
opacity: webcamLayout ? 1 : 0,
1237+
filter:
1238+
useClipPath && webcamCssBoxShadow !== "none"
1239+
? `drop-shadow(${webcamCssBoxShadow})`
1240+
: undefined,
1241+
}}
1242+
>
1243+
<video
1244+
ref={webcamVideoRef}
1245+
src={webcamVideoPath}
1246+
className={`w-full h-full object-cover ${webcamLayoutPreset === "picture-in-picture" ? "cursor-grab active:cursor-grabbing" : "pointer-events-none"}`}
1247+
style={{
1248+
borderRadius: useClipPath ? 0 : (webcamLayout?.borderRadius ?? 0),
1249+
clipPath: clipPath ?? undefined,
1250+
boxShadow: useClipPath ? "none" : webcamCssBoxShadow,
1251+
backgroundColor: "#000",
1252+
}}
1253+
onPointerDown={handleWebcamPointerDown}
1254+
onPointerMove={handleWebcamPointerMove}
1255+
onPointerUp={handleWebcamPointerUp}
1256+
onPointerLeave={handleWebcamPointerUp}
1257+
muted
1258+
preload="metadata"
1259+
playsInline
1260+
/>
1261+
</div>
1262+
);
1263+
})()}
12431264
{/* Only render overlay after PIXI and video are fully initialized */}
12441265
{pixiReady && videoReady && (
12451266
<div

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, expect, it } from "vitest";
22
import {
33
createProjectData,
4+
normalizeProjectEditor,
45
PROJECT_VERSION,
56
resolveProjectMedia,
67
validateProjectData,
@@ -40,6 +41,7 @@ describe("projectPersistence media compatibility", () => {
4041
annotationRegions: [],
4142
aspectRatio: "16:9",
4243
webcamLayoutPreset: "picture-in-picture",
44+
webcamMaskShape: "circle",
4345
exportQuality: "good",
4446
exportFormat: "mp4",
4547
gifFrameRate: 15,
@@ -55,4 +57,11 @@ describe("projectPersistence media compatibility", () => {
5557
});
5658
expect(validateProjectData(project)).toBe(true);
5759
});
60+
61+
it("normalizes webcam mask shape values safely", () => {
62+
expect(normalizeProjectEditor({ webcamMaskShape: "rounded" }).webcamMaskShape).toBe("rounded");
63+
expect(
64+
normalizeProjectEditor({ webcamMaskShape: "not-a-real-shape" as never }).webcamMaskShape,
65+
).toBe("rectangle");
66+
});
5867
});

src/components/video-editor/projectPersistence.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,13 @@ import {
1212
DEFAULT_FIGURE_DATA,
1313
DEFAULT_PLAYBACK_SPEED,
1414
DEFAULT_WEBCAM_LAYOUT_PRESET,
15+
DEFAULT_WEBCAM_MASK_SHAPE,
1516
DEFAULT_WEBCAM_POSITION,
1617
DEFAULT_ZOOM_DEPTH,
1718
type SpeedRegion,
1819
type TrimRegion,
1920
type WebcamLayoutPreset,
21+
type WebcamMaskShape,
2022
type WebcamPosition,
2123
type ZoomRegion,
2224
} from "./types";
@@ -44,6 +46,7 @@ export interface ProjectEditorState {
4446
annotationRegions: AnnotationRegion[];
4547
aspectRatio: AspectRatio;
4648
webcamLayoutPreset: WebcamLayoutPreset;
49+
webcamMaskShape: WebcamMaskShape;
4750
webcamPosition: WebcamPosition | null;
4851
exportQuality: ExportQuality;
4952
exportFormat: ExportFormat;
@@ -353,6 +356,13 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
353356
editor.webcamLayoutPreset === "picture-in-picture"
354357
? editor.webcamLayoutPreset
355358
: DEFAULT_WEBCAM_LAYOUT_PRESET,
359+
webcamMaskShape:
360+
editor.webcamMaskShape === "rectangle" ||
361+
editor.webcamMaskShape === "circle" ||
362+
editor.webcamMaskShape === "square" ||
363+
editor.webcamMaskShape === "rounded"
364+
? editor.webcamMaskShape
365+
: DEFAULT_WEBCAM_MASK_SHAPE,
356366
webcamPosition:
357367
editor.webcamPosition &&
358368
typeof editor.webcamPosition === "object" &&

src/components/video-editor/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ export type { WebcamLayoutPreset };
66

77
export const DEFAULT_WEBCAM_LAYOUT_PRESET: WebcamLayoutPreset = "picture-in-picture";
88

9+
export type WebcamMaskShape = "rectangle" | "circle" | "square" | "rounded";
10+
11+
export const DEFAULT_WEBCAM_MASK_SHAPE: WebcamMaskShape = "rectangle";
12+
913
export interface WebcamPosition {
1014
cx: number; // normalized horizontal center (0-1)
1115
cy: number; // normalized vertical center (0-1)

0 commit comments

Comments
 (0)