Skip to content

Commit 9d0ccf3

Browse files
committed
Add webcam mask shape support
1 parent 2f36160 commit 9d0ccf3

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
} from "./types";
5657
import { SPEED_OPTIONS } from "./types";
@@ -143,6 +144,8 @@ interface SettingsPanelProps {
143144
hasWebcam?: boolean;
144145
webcamLayoutPreset?: WebcamLayoutPreset;
145146
onWebcamLayoutPresetChange?: (preset: WebcamLayoutPreset) => void;
147+
webcamMaskShape?: import("./types").WebcamMaskShape;
148+
onWebcamMaskShapeChange?: (shape: import("./types").WebcamMaskShape) => void;
146149
}
147150

148151
export default SettingsPanel;
@@ -211,6 +214,8 @@ export function SettingsPanel({
211214
hasWebcam = false,
212215
webcamLayoutPreset = "picture-in-picture",
213216
onWebcamLayoutPresetChange,
217+
webcamMaskShape = "rectangle",
218+
onWebcamMaskShapeChange,
214219
}: SettingsPanelProps) {
215220
const t = useScopedT("settings");
216221
const [wallpaperPaths, setWallpaperPaths] = useState<string[]>([]);
@@ -623,6 +628,87 @@ export function SettingsPanel({
623628
</SelectContent>
624629
</Select>
625630
</div>
631+
{webcamLayoutPreset === "picture-in-picture" && (
632+
<div className="mt-2 p-2 rounded-lg bg-white/5 border border-white/5">
633+
<div className="text-[10px] font-medium text-slate-300 mb-1.5">
634+
{t("layout.webcamShape")}
635+
</div>
636+
<div className="grid grid-cols-4 gap-1.5">
637+
{(
638+
[
639+
{ value: "rectangle", label: "Rect" },
640+
{ value: "circle", label: "Circle" },
641+
{ value: "square", label: "Square" },
642+
{ value: "rounded", label: "Rounded" },
643+
] as Array<{ value: WebcamMaskShape; label: string }>
644+
).map((shape) => (
645+
<button
646+
key={shape.value}
647+
type="button"
648+
onClick={() => onWebcamMaskShapeChange?.(shape.value)}
649+
className={cn(
650+
"h-10 rounded-lg border flex flex-col items-center justify-center gap-0.5 transition-all",
651+
webcamMaskShape === shape.value
652+
? "bg-[#34B27B] border-[#34B27B] text-white"
653+
: "bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20 text-slate-400",
654+
)}
655+
>
656+
<svg
657+
width="16"
658+
height="16"
659+
viewBox="0 0 16 16"
660+
fill="none"
661+
xmlns="http://www.w3.org/2000/svg"
662+
>
663+
{shape.value === "rectangle" && (
664+
<rect
665+
x="1"
666+
y="3"
667+
width="14"
668+
height="10"
669+
rx="2"
670+
stroke="currentColor"
671+
strokeWidth="1.5"
672+
/>
673+
)}
674+
{shape.value === "circle" && (
675+
<circle
676+
cx="8"
677+
cy="8"
678+
r="6.5"
679+
stroke="currentColor"
680+
strokeWidth="1.5"
681+
/>
682+
)}
683+
{shape.value === "square" && (
684+
<rect
685+
x="2"
686+
y="2"
687+
width="12"
688+
height="12"
689+
rx="1"
690+
stroke="currentColor"
691+
strokeWidth="1.5"
692+
/>
693+
)}
694+
{shape.value === "rounded" && (
695+
<rect
696+
x="1"
697+
y="3"
698+
width="14"
699+
height="10"
700+
rx="5"
701+
stroke="currentColor"
702+
strokeWidth="1.5"
703+
/>
704+
)}
705+
</svg>
706+
<span className="text-[8px] leading-none">{shape.label}</span>
707+
</button>
708+
))}
709+
</div>
710+
</div>
711+
)}
626712
</AccordionContent>
627713
</AccordionItem>
628714
)}

src/components/video-editor/VideoEditor.tsx

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

@@ -195,6 +196,7 @@ export default function VideoEditor() {
195196
annotationRegions: normalizedEditor.annotationRegions,
196197
aspectRatio: normalizedEditor.aspectRatio,
197198
webcamLayoutPreset: normalizedEditor.webcamLayoutPreset,
199+
webcamMaskShape: normalizedEditor.webcamMaskShape,
198200
webcamPosition: normalizedEditor.webcamPosition,
199201
});
200202
setExportQuality(normalizedEditor.exportQuality);
@@ -264,6 +266,7 @@ export default function VideoEditor() {
264266
annotationRegions,
265267
aspectRatio,
266268
webcamLayoutPreset,
269+
webcamMaskShape,
267270
webcamPosition,
268271
exportQuality,
269272
exportFormat,
@@ -287,6 +290,7 @@ export default function VideoEditor() {
287290
annotationRegions,
288291
aspectRatio,
289292
webcamLayoutPreset,
293+
webcamMaskShape,
290294
webcamPosition,
291295
exportQuality,
292296
exportFormat,
@@ -380,6 +384,7 @@ export default function VideoEditor() {
380384
annotationRegions,
381385
aspectRatio,
382386
webcamLayoutPreset,
387+
webcamMaskShape,
383388
webcamPosition,
384389
exportQuality,
385390
exportFormat,
@@ -434,6 +439,7 @@ export default function VideoEditor() {
434439
annotationRegions,
435440
aspectRatio,
436441
webcamLayoutPreset,
442+
webcamMaskShape,
437443
webcamPosition,
438444
exportQuality,
439445
exportFormat,
@@ -1090,6 +1096,7 @@ export default function VideoEditor() {
10901096
cropRegion,
10911097
annotationRegions,
10921098
webcamLayoutPreset,
1099+
webcamMaskShape,
10931100
webcamPosition,
10941101
previewWidth,
10951102
previewHeight,
@@ -1221,6 +1228,7 @@ export default function VideoEditor() {
12211228
cropRegion,
12221229
annotationRegions,
12231230
webcamLayoutPreset,
1231+
webcamMaskShape,
12241232
webcamPosition,
12251233
previewWidth,
12261234
previewHeight,
@@ -1289,6 +1297,7 @@ export default function VideoEditor() {
12891297
isPlaying,
12901298
aspectRatio,
12911299
webcamLayoutPreset,
1300+
webcamMaskShape,
12921301
webcamPosition,
12931302
exportQuality,
12941303
handleExportSaved,
@@ -1473,6 +1482,7 @@ export default function VideoEditor() {
14731482
videoPath={videoPath || ""}
14741483
webcamVideoPath={webcamVideoPath || undefined}
14751484
webcamLayoutPreset={webcamLayoutPreset}
1485+
webcamMaskShape={webcamMaskShape}
14761486
webcamPosition={webcamPosition}
14771487
onWebcamPositionChange={(pos) => updateState({ webcamPosition: pos })}
14781488
onWebcamPositionDragEnd={commitState}
@@ -1613,6 +1623,8 @@ export default function VideoEditor() {
16131623
webcamPosition: preset === "vertical-stack" ? null : webcamPosition,
16141624
})
16151625
}
1626+
webcamMaskShape={webcamMaskShape}
1627+
onWebcamMaskShapeChange={(shape) => pushState({ webcamMaskShape: shape })}
16161628
videoElement={videoPlaybackRef.current?.video || null}
16171629
exportQuality={exportQuality}
16181630
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,
@@ -63,6 +64,7 @@ interface VideoPlaybackProps {
6364
videoPath: string;
6465
webcamVideoPath?: string;
6566
webcamLayoutPreset: WebcamLayoutPreset;
67+
webcamMaskShape?: import("./types").WebcamMaskShape;
6668
webcamPosition?: { cx: number; cy: number } | null;
6769
onWebcamPositionChange?: (position: { cx: number; cy: number }) => void;
6870
onWebcamPositionDragEnd?: () => void;
@@ -111,6 +113,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
111113
videoPath,
112114
webcamVideoPath,
113115
webcamLayoutPreset,
116+
webcamMaskShape,
114117
webcamPosition,
115118
onWebcamPositionChange,
116119
onWebcamPositionDragEnd,
@@ -272,6 +275,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
272275
webcamDimensions,
273276
webcamLayoutPreset,
274277
webcamPosition,
278+
webcamMaskShape,
275279
});
276280

277281
if (result) {
@@ -302,6 +306,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
302306
webcamDimensions,
303307
webcamLayoutPreset,
304308
webcamPosition,
309+
webcamMaskShape,
305310
]);
306311

307312
useEffect(() => {
@@ -1154,31 +1159,47 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
11541159
: "none",
11551160
}}
11561161
/>
1157-
{webcamVideoPath && (
1158-
<video
1159-
ref={webcamVideoRef}
1160-
src={webcamVideoPath}
1161-
className={`absolute object-cover ${webcamLayoutPreset === "picture-in-picture" ? "cursor-grab active:cursor-grabbing" : "pointer-events-none"}`}
1162-
style={{
1163-
left: webcamLayout?.x ?? 0,
1164-
top: webcamLayout?.y ?? 0,
1165-
width: webcamLayout?.width ?? 0,
1166-
height: webcamLayout?.height ?? 0,
1167-
borderRadius: webcamLayout?.borderRadius ?? 0,
1168-
boxShadow: webcamCssBoxShadow,
1169-
zIndex: 20,
1170-
opacity: webcamLayout ? 1 : 0,
1171-
backgroundColor: "#000",
1172-
}}
1173-
onPointerDown={handleWebcamPointerDown}
1174-
onPointerMove={handleWebcamPointerMove}
1175-
onPointerUp={handleWebcamPointerUp}
1176-
onPointerLeave={handleWebcamPointerUp}
1177-
muted
1178-
preload="metadata"
1179-
playsInline
1180-
/>
1181-
)}
1162+
{webcamVideoPath &&
1163+
(() => {
1164+
const clipPath = getCssClipPath(webcamLayout?.maskShape ?? "rectangle");
1165+
const useClipPath = !!clipPath;
1166+
return (
1167+
<div
1168+
className="absolute"
1169+
style={{
1170+
left: webcamLayout?.x ?? 0,
1171+
top: webcamLayout?.y ?? 0,
1172+
width: webcamLayout?.width ?? 0,
1173+
height: webcamLayout?.height ?? 0,
1174+
zIndex: 20,
1175+
opacity: webcamLayout ? 1 : 0,
1176+
filter:
1177+
useClipPath && webcamCssBoxShadow !== "none"
1178+
? `drop-shadow(${webcamCssBoxShadow})`
1179+
: undefined,
1180+
}}
1181+
>
1182+
<video
1183+
ref={webcamVideoRef}
1184+
src={webcamVideoPath}
1185+
className={`w-full h-full object-cover ${webcamLayoutPreset === "picture-in-picture" ? "cursor-grab active:cursor-grabbing" : "pointer-events-none"}`}
1186+
style={{
1187+
borderRadius: useClipPath ? 0 : (webcamLayout?.borderRadius ?? 0),
1188+
clipPath: clipPath ?? undefined,
1189+
boxShadow: useClipPath ? "none" : webcamCssBoxShadow,
1190+
backgroundColor: "#000",
1191+
}}
1192+
onPointerDown={handleWebcamPointerDown}
1193+
onPointerMove={handleWebcamPointerMove}
1194+
onPointerUp={handleWebcamPointerUp}
1195+
onPointerLeave={handleWebcamPointerUp}
1196+
muted
1197+
preload="metadata"
1198+
playsInline
1199+
/>
1200+
</div>
1201+
);
1202+
})()}
11821203
{/* Only render overlay after PIXI and video are fully initialized */}
11831204
{pixiReady && videoReady && (
11841205
<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;
@@ -352,6 +355,13 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
352355
editor.webcamLayoutPreset === "picture-in-picture"
353356
? editor.webcamLayoutPreset
354357
: DEFAULT_WEBCAM_LAYOUT_PRESET,
358+
webcamMaskShape:
359+
editor.webcamMaskShape === "rectangle" ||
360+
editor.webcamMaskShape === "circle" ||
361+
editor.webcamMaskShape === "square" ||
362+
editor.webcamMaskShape === "rounded"
363+
? editor.webcamMaskShape
364+
: DEFAULT_WEBCAM_MASK_SHAPE,
355365
webcamPosition:
356366
editor.webcamPosition &&
357367
typeof editor.webcamPosition === "object" &&

src/components/video-editor/types.ts

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

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

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

0 commit comments

Comments
 (0)