Add webcam mask shape support
This commit is contained in:
@@ -51,6 +51,7 @@ import type {
|
||||
FigureData,
|
||||
PlaybackSpeed,
|
||||
WebcamLayoutPreset,
|
||||
WebcamMaskShape,
|
||||
ZoomDepth,
|
||||
} from "./types";
|
||||
import { SPEED_OPTIONS } from "./types";
|
||||
@@ -143,6 +144,8 @@ interface SettingsPanelProps {
|
||||
hasWebcam?: boolean;
|
||||
webcamLayoutPreset?: WebcamLayoutPreset;
|
||||
onWebcamLayoutPresetChange?: (preset: WebcamLayoutPreset) => void;
|
||||
webcamMaskShape?: import("./types").WebcamMaskShape;
|
||||
onWebcamMaskShapeChange?: (shape: import("./types").WebcamMaskShape) => void;
|
||||
}
|
||||
|
||||
export default SettingsPanel;
|
||||
@@ -211,6 +214,8 @@ export function SettingsPanel({
|
||||
hasWebcam = false,
|
||||
webcamLayoutPreset = "picture-in-picture",
|
||||
onWebcamLayoutPresetChange,
|
||||
webcamMaskShape = "rectangle",
|
||||
onWebcamMaskShapeChange,
|
||||
}: SettingsPanelProps) {
|
||||
const t = useScopedT("settings");
|
||||
const [wallpaperPaths, setWallpaperPaths] = useState<string[]>([]);
|
||||
@@ -623,6 +628,87 @@ export function SettingsPanel({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{webcamLayoutPreset === "picture-in-picture" && (
|
||||
<div className="mt-2 p-2 rounded-lg bg-white/5 border border-white/5">
|
||||
<div className="text-[10px] font-medium text-slate-300 mb-1.5">
|
||||
{t("layout.webcamShape")}
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-1.5">
|
||||
{(
|
||||
[
|
||||
{ value: "rectangle", label: "Rect" },
|
||||
{ value: "circle", label: "Circle" },
|
||||
{ value: "square", label: "Square" },
|
||||
{ value: "rounded", label: "Rounded" },
|
||||
] as Array<{ value: WebcamMaskShape; label: string }>
|
||||
).map((shape) => (
|
||||
<button
|
||||
key={shape.value}
|
||||
type="button"
|
||||
onClick={() => onWebcamMaskShapeChange?.(shape.value)}
|
||||
className={cn(
|
||||
"h-10 rounded-lg border flex flex-col items-center justify-center gap-0.5 transition-all",
|
||||
webcamMaskShape === shape.value
|
||||
? "bg-[#34B27B] border-[#34B27B] text-white"
|
||||
: "bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20 text-slate-400",
|
||||
)}
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
{shape.value === "rectangle" && (
|
||||
<rect
|
||||
x="1"
|
||||
y="3"
|
||||
width="14"
|
||||
height="10"
|
||||
rx="2"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
)}
|
||||
{shape.value === "circle" && (
|
||||
<circle
|
||||
cx="8"
|
||||
cy="8"
|
||||
r="6.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
)}
|
||||
{shape.value === "square" && (
|
||||
<rect
|
||||
x="2"
|
||||
y="2"
|
||||
width="12"
|
||||
height="12"
|
||||
rx="1"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
)}
|
||||
{shape.value === "rounded" && (
|
||||
<rect
|
||||
x="1"
|
||||
y="3"
|
||||
width="14"
|
||||
height="10"
|
||||
rx="5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
<span className="text-[8px] leading-none">{shape.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
)}
|
||||
|
||||
@@ -84,6 +84,7 @@ export default function VideoEditor() {
|
||||
padding,
|
||||
aspectRatio,
|
||||
webcamLayoutPreset,
|
||||
webcamMaskShape,
|
||||
webcamPosition,
|
||||
} = editorState;
|
||||
|
||||
@@ -195,6 +196,7 @@ export default function VideoEditor() {
|
||||
annotationRegions: normalizedEditor.annotationRegions,
|
||||
aspectRatio: normalizedEditor.aspectRatio,
|
||||
webcamLayoutPreset: normalizedEditor.webcamLayoutPreset,
|
||||
webcamMaskShape: normalizedEditor.webcamMaskShape,
|
||||
webcamPosition: normalizedEditor.webcamPosition,
|
||||
});
|
||||
setExportQuality(normalizedEditor.exportQuality);
|
||||
@@ -264,6 +266,7 @@ export default function VideoEditor() {
|
||||
annotationRegions,
|
||||
aspectRatio,
|
||||
webcamLayoutPreset,
|
||||
webcamMaskShape,
|
||||
webcamPosition,
|
||||
exportQuality,
|
||||
exportFormat,
|
||||
@@ -287,6 +290,7 @@ export default function VideoEditor() {
|
||||
annotationRegions,
|
||||
aspectRatio,
|
||||
webcamLayoutPreset,
|
||||
webcamMaskShape,
|
||||
webcamPosition,
|
||||
exportQuality,
|
||||
exportFormat,
|
||||
@@ -380,6 +384,7 @@ export default function VideoEditor() {
|
||||
annotationRegions,
|
||||
aspectRatio,
|
||||
webcamLayoutPreset,
|
||||
webcamMaskShape,
|
||||
webcamPosition,
|
||||
exportQuality,
|
||||
exportFormat,
|
||||
@@ -434,6 +439,7 @@ export default function VideoEditor() {
|
||||
annotationRegions,
|
||||
aspectRatio,
|
||||
webcamLayoutPreset,
|
||||
webcamMaskShape,
|
||||
webcamPosition,
|
||||
exportQuality,
|
||||
exportFormat,
|
||||
@@ -1090,6 +1096,7 @@ export default function VideoEditor() {
|
||||
cropRegion,
|
||||
annotationRegions,
|
||||
webcamLayoutPreset,
|
||||
webcamMaskShape,
|
||||
webcamPosition,
|
||||
previewWidth,
|
||||
previewHeight,
|
||||
@@ -1221,6 +1228,7 @@ export default function VideoEditor() {
|
||||
cropRegion,
|
||||
annotationRegions,
|
||||
webcamLayoutPreset,
|
||||
webcamMaskShape,
|
||||
webcamPosition,
|
||||
previewWidth,
|
||||
previewHeight,
|
||||
@@ -1289,6 +1297,7 @@ export default function VideoEditor() {
|
||||
isPlaying,
|
||||
aspectRatio,
|
||||
webcamLayoutPreset,
|
||||
webcamMaskShape,
|
||||
webcamPosition,
|
||||
exportQuality,
|
||||
handleExportSaved,
|
||||
@@ -1473,6 +1482,7 @@ export default function VideoEditor() {
|
||||
videoPath={videoPath || ""}
|
||||
webcamVideoPath={webcamVideoPath || undefined}
|
||||
webcamLayoutPreset={webcamLayoutPreset}
|
||||
webcamMaskShape={webcamMaskShape}
|
||||
webcamPosition={webcamPosition}
|
||||
onWebcamPositionChange={(pos) => updateState({ webcamPosition: pos })}
|
||||
onWebcamPositionDragEnd={commitState}
|
||||
@@ -1613,6 +1623,8 @@ export default function VideoEditor() {
|
||||
webcamPosition: preset === "vertical-stack" ? null : webcamPosition,
|
||||
})
|
||||
}
|
||||
webcamMaskShape={webcamMaskShape}
|
||||
onWebcamMaskShapeChange={(shape) => pushState({ webcamMaskShape: shape })}
|
||||
videoElement={videoPlaybackRef.current?.video || null}
|
||||
exportQuality={exportQuality}
|
||||
onExportQualityChange={setExportQuality}
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
type StyledRenderRect,
|
||||
type WebcamLayoutPreset,
|
||||
} from "@/lib/compositeLayout";
|
||||
import { getCssClipPath } from "@/lib/webcamMaskShapes";
|
||||
import {
|
||||
type AspectRatio,
|
||||
formatAspectRatioForCSS,
|
||||
@@ -63,6 +64,7 @@ interface VideoPlaybackProps {
|
||||
videoPath: string;
|
||||
webcamVideoPath?: string;
|
||||
webcamLayoutPreset: WebcamLayoutPreset;
|
||||
webcamMaskShape?: import("./types").WebcamMaskShape;
|
||||
webcamPosition?: { cx: number; cy: number } | null;
|
||||
onWebcamPositionChange?: (position: { cx: number; cy: number }) => void;
|
||||
onWebcamPositionDragEnd?: () => void;
|
||||
@@ -111,6 +113,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
videoPath,
|
||||
webcamVideoPath,
|
||||
webcamLayoutPreset,
|
||||
webcamMaskShape,
|
||||
webcamPosition,
|
||||
onWebcamPositionChange,
|
||||
onWebcamPositionDragEnd,
|
||||
@@ -272,6 +275,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
webcamDimensions,
|
||||
webcamLayoutPreset,
|
||||
webcamPosition,
|
||||
webcamMaskShape,
|
||||
});
|
||||
|
||||
if (result) {
|
||||
@@ -302,6 +306,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
webcamDimensions,
|
||||
webcamLayoutPreset,
|
||||
webcamPosition,
|
||||
webcamMaskShape,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1154,31 +1159,47 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
: "none",
|
||||
}}
|
||||
/>
|
||||
{webcamVideoPath && (
|
||||
<video
|
||||
ref={webcamVideoRef}
|
||||
src={webcamVideoPath}
|
||||
className={`absolute object-cover ${webcamLayoutPreset === "picture-in-picture" ? "cursor-grab active:cursor-grabbing" : "pointer-events-none"}`}
|
||||
style={{
|
||||
left: webcamLayout?.x ?? 0,
|
||||
top: webcamLayout?.y ?? 0,
|
||||
width: webcamLayout?.width ?? 0,
|
||||
height: webcamLayout?.height ?? 0,
|
||||
borderRadius: webcamLayout?.borderRadius ?? 0,
|
||||
boxShadow: webcamCssBoxShadow,
|
||||
zIndex: 20,
|
||||
opacity: webcamLayout ? 1 : 0,
|
||||
backgroundColor: "#000",
|
||||
}}
|
||||
onPointerDown={handleWebcamPointerDown}
|
||||
onPointerMove={handleWebcamPointerMove}
|
||||
onPointerUp={handleWebcamPointerUp}
|
||||
onPointerLeave={handleWebcamPointerUp}
|
||||
muted
|
||||
preload="metadata"
|
||||
playsInline
|
||||
/>
|
||||
)}
|
||||
{webcamVideoPath &&
|
||||
(() => {
|
||||
const clipPath = getCssClipPath(webcamLayout?.maskShape ?? "rectangle");
|
||||
const useClipPath = !!clipPath;
|
||||
return (
|
||||
<div
|
||||
className="absolute"
|
||||
style={{
|
||||
left: webcamLayout?.x ?? 0,
|
||||
top: webcamLayout?.y ?? 0,
|
||||
width: webcamLayout?.width ?? 0,
|
||||
height: webcamLayout?.height ?? 0,
|
||||
zIndex: 20,
|
||||
opacity: webcamLayout ? 1 : 0,
|
||||
filter:
|
||||
useClipPath && webcamCssBoxShadow !== "none"
|
||||
? `drop-shadow(${webcamCssBoxShadow})`
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
<video
|
||||
ref={webcamVideoRef}
|
||||
src={webcamVideoPath}
|
||||
className={`w-full h-full object-cover ${webcamLayoutPreset === "picture-in-picture" ? "cursor-grab active:cursor-grabbing" : "pointer-events-none"}`}
|
||||
style={{
|
||||
borderRadius: useClipPath ? 0 : (webcamLayout?.borderRadius ?? 0),
|
||||
clipPath: clipPath ?? undefined,
|
||||
boxShadow: useClipPath ? "none" : webcamCssBoxShadow,
|
||||
backgroundColor: "#000",
|
||||
}}
|
||||
onPointerDown={handleWebcamPointerDown}
|
||||
onPointerMove={handleWebcamPointerMove}
|
||||
onPointerUp={handleWebcamPointerUp}
|
||||
onPointerLeave={handleWebcamPointerUp}
|
||||
muted
|
||||
preload="metadata"
|
||||
playsInline
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
{/* Only render overlay after PIXI and video are fully initialized */}
|
||||
{pixiReady && videoReady && (
|
||||
<div
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createProjectData,
|
||||
normalizeProjectEditor,
|
||||
PROJECT_VERSION,
|
||||
resolveProjectMedia,
|
||||
validateProjectData,
|
||||
@@ -40,6 +41,7 @@ describe("projectPersistence media compatibility", () => {
|
||||
annotationRegions: [],
|
||||
aspectRatio: "16:9",
|
||||
webcamLayoutPreset: "picture-in-picture",
|
||||
webcamMaskShape: "circle",
|
||||
exportQuality: "good",
|
||||
exportFormat: "mp4",
|
||||
gifFrameRate: 15,
|
||||
@@ -55,4 +57,11 @@ describe("projectPersistence media compatibility", () => {
|
||||
});
|
||||
expect(validateProjectData(project)).toBe(true);
|
||||
});
|
||||
|
||||
it("normalizes webcam mask shape values safely", () => {
|
||||
expect(normalizeProjectEditor({ webcamMaskShape: "rounded" }).webcamMaskShape).toBe("rounded");
|
||||
expect(
|
||||
normalizeProjectEditor({ webcamMaskShape: "not-a-real-shape" as never }).webcamMaskShape,
|
||||
).toBe("rectangle");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,11 +12,13 @@ import {
|
||||
DEFAULT_FIGURE_DATA,
|
||||
DEFAULT_PLAYBACK_SPEED,
|
||||
DEFAULT_WEBCAM_LAYOUT_PRESET,
|
||||
DEFAULT_WEBCAM_MASK_SHAPE,
|
||||
DEFAULT_WEBCAM_POSITION,
|
||||
DEFAULT_ZOOM_DEPTH,
|
||||
type SpeedRegion,
|
||||
type TrimRegion,
|
||||
type WebcamLayoutPreset,
|
||||
type WebcamMaskShape,
|
||||
type WebcamPosition,
|
||||
type ZoomRegion,
|
||||
} from "./types";
|
||||
@@ -44,6 +46,7 @@ export interface ProjectEditorState {
|
||||
annotationRegions: AnnotationRegion[];
|
||||
aspectRatio: AspectRatio;
|
||||
webcamLayoutPreset: WebcamLayoutPreset;
|
||||
webcamMaskShape: WebcamMaskShape;
|
||||
webcamPosition: WebcamPosition | null;
|
||||
exportQuality: ExportQuality;
|
||||
exportFormat: ExportFormat;
|
||||
@@ -352,6 +355,13 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
|
||||
editor.webcamLayoutPreset === "picture-in-picture"
|
||||
? editor.webcamLayoutPreset
|
||||
: DEFAULT_WEBCAM_LAYOUT_PRESET,
|
||||
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" &&
|
||||
|
||||
@@ -5,6 +5,10 @@ export type { WebcamLayoutPreset };
|
||||
|
||||
export const DEFAULT_WEBCAM_LAYOUT_PRESET: WebcamLayoutPreset = "picture-in-picture";
|
||||
|
||||
export type WebcamMaskShape = "rectangle" | "circle" | "square" | "rounded";
|
||||
|
||||
export const DEFAULT_WEBCAM_MASK_SHAPE: WebcamMaskShape = "rectangle";
|
||||
|
||||
export interface WebcamPosition {
|
||||
cx: number; // normalized horizontal center (0-1)
|
||||
cy: number; // normalized vertical center (0-1)
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
type StyledRenderRect,
|
||||
type WebcamLayoutPreset,
|
||||
} from "@/lib/compositeLayout";
|
||||
import type { CropRegion } from "../types";
|
||||
import type { CropRegion, WebcamMaskShape } from "../types";
|
||||
|
||||
interface LayoutParams {
|
||||
container: HTMLDivElement;
|
||||
@@ -21,6 +21,7 @@ interface LayoutParams {
|
||||
webcamDimensions?: Size | null;
|
||||
webcamLayoutPreset?: WebcamLayoutPreset;
|
||||
webcamPosition?: { cx: number; cy: number } | null;
|
||||
webcamMaskShape?: WebcamMaskShape;
|
||||
}
|
||||
|
||||
interface LayoutResult {
|
||||
@@ -47,6 +48,7 @@ export function layoutVideoContent(params: LayoutParams): LayoutResult | null {
|
||||
webcamDimensions,
|
||||
webcamLayoutPreset,
|
||||
webcamPosition,
|
||||
webcamMaskShape,
|
||||
} = params;
|
||||
|
||||
const videoWidth = lockedVideoDimensions?.width || videoElement.videoWidth;
|
||||
@@ -94,6 +96,7 @@ export function layoutVideoContent(params: LayoutParams): LayoutResult | null {
|
||||
webcamSize: webcamDimensions,
|
||||
layoutPreset: webcamLayoutPreset,
|
||||
webcamPosition,
|
||||
webcamMaskShape,
|
||||
});
|
||||
|
||||
if (!compositeLayout) {
|
||||
|
||||
@@ -5,12 +5,14 @@ import type {
|
||||
SpeedRegion,
|
||||
TrimRegion,
|
||||
WebcamLayoutPreset,
|
||||
WebcamMaskShape,
|
||||
WebcamPosition,
|
||||
ZoomRegion,
|
||||
} from "@/components/video-editor/types";
|
||||
import {
|
||||
DEFAULT_CROP_REGION,
|
||||
DEFAULT_WEBCAM_LAYOUT_PRESET,
|
||||
DEFAULT_WEBCAM_MASK_SHAPE,
|
||||
DEFAULT_WEBCAM_POSITION,
|
||||
} from "@/components/video-editor/types";
|
||||
import type { AspectRatio } from "@/utils/aspectRatioUtils";
|
||||
@@ -31,6 +33,7 @@ export interface EditorState {
|
||||
padding: number;
|
||||
aspectRatio: AspectRatio;
|
||||
webcamLayoutPreset: WebcamLayoutPreset;
|
||||
webcamMaskShape: WebcamMaskShape;
|
||||
webcamPosition: WebcamPosition | null;
|
||||
}
|
||||
|
||||
@@ -48,6 +51,7 @@ export const INITIAL_EDITOR_STATE: EditorState = {
|
||||
padding: 50,
|
||||
aspectRatio: "16:9",
|
||||
webcamLayoutPreset: DEFAULT_WEBCAM_LAYOUT_PRESET,
|
||||
webcamMaskShape: DEFAULT_WEBCAM_MASK_SHAPE,
|
||||
webcamPosition: DEFAULT_WEBCAM_POSITION,
|
||||
};
|
||||
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
"preset": "Preset",
|
||||
"selectPreset": "Select preset",
|
||||
"pictureInPicture": "Picture in Picture",
|
||||
"verticalStack": "Vertical Stack"
|
||||
"verticalStack": "Vertical Stack",
|
||||
"webcamShape": "Camera Shape"
|
||||
},
|
||||
"effects": {
|
||||
"title": "Video Effects",
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
"preset": "Predefinido",
|
||||
"selectPreset": "Seleccionar predefinido",
|
||||
"pictureInPicture": "Imagen en imagen",
|
||||
"verticalStack": "Apilado vertical"
|
||||
"verticalStack": "Apilado vertical",
|
||||
"webcamShape": "Forma de cámara"
|
||||
},
|
||||
"effects": {
|
||||
"title": "Efectos de video",
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
"preset": "预设",
|
||||
"selectPreset": "选择预设",
|
||||
"pictureInPicture": "画中画",
|
||||
"verticalStack": "垂直堆叠"
|
||||
"verticalStack": "垂直堆叠",
|
||||
"webcamShape": "摄像头形状"
|
||||
},
|
||||
"effects": {
|
||||
"title": "视频效果",
|
||||
|
||||
@@ -33,7 +33,7 @@ describe("computeCompositeLayout", () => {
|
||||
).toBeLessThanOrEqual(1920);
|
||||
});
|
||||
|
||||
it("centers the combined screen and webcam stack in vertical stack mode", () => {
|
||||
it("uses cover-style full-width stacking in vertical stack mode", () => {
|
||||
const layout = computeCompositeLayout({
|
||||
canvasSize: { width: 1920, height: 1080 },
|
||||
maxContentSize: { width: 1536, height: 864 },
|
||||
@@ -44,21 +44,22 @@ describe("computeCompositeLayout", () => {
|
||||
|
||||
expect(layout).not.toBeNull();
|
||||
expect(layout?.screenRect).toEqual({
|
||||
x: 576,
|
||||
y: 108,
|
||||
width: 768,
|
||||
height: 432,
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 1920,
|
||||
height: 0,
|
||||
});
|
||||
expect(layout?.webcamRect).toEqual({
|
||||
x: 576,
|
||||
y: 540,
|
||||
width: 768,
|
||||
height: 432,
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
borderRadius: 0,
|
||||
});
|
||||
expect(layout?.screenCover).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the screen centered and omits the webcam when dimensions are unavailable", () => {
|
||||
it("fills the canvas with the screen when vertical stack has no webcam", () => {
|
||||
const layout = computeCompositeLayout({
|
||||
canvasSize: { width: 1920, height: 1080 },
|
||||
maxContentSize: { width: 1536, height: 864 },
|
||||
@@ -68,11 +69,56 @@ describe("computeCompositeLayout", () => {
|
||||
|
||||
expect(layout).not.toBeNull();
|
||||
expect(layout?.screenRect).toEqual({
|
||||
x: 192,
|
||||
y: 108,
|
||||
width: 1536,
|
||||
height: 864,
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
});
|
||||
expect(layout?.webcamRect).toBeNull();
|
||||
expect(layout?.screenCover).toBe(true);
|
||||
});
|
||||
|
||||
it("forces circular and square masks to use square dimensions", () => {
|
||||
const circularLayout = computeCompositeLayout({
|
||||
canvasSize: { width: 1920, height: 1080 },
|
||||
screenSize: { width: 1920, height: 1080 },
|
||||
webcamSize: { width: 1280, height: 720 },
|
||||
webcamMaskShape: "circle",
|
||||
});
|
||||
const squareLayout = computeCompositeLayout({
|
||||
canvasSize: { width: 1920, height: 1080 },
|
||||
screenSize: { width: 1920, height: 1080 },
|
||||
webcamSize: { width: 1280, height: 720 },
|
||||
webcamMaskShape: "square",
|
||||
});
|
||||
|
||||
expect(circularLayout?.webcamRect).not.toBeNull();
|
||||
expect(squareLayout?.webcamRect).not.toBeNull();
|
||||
expect(circularLayout?.webcamRect?.width).toBe(circularLayout?.webcamRect?.height);
|
||||
expect(squareLayout?.webcamRect?.width).toBe(squareLayout?.webcamRect?.height);
|
||||
expect(circularLayout?.webcamRect?.maskShape).toBe("circle");
|
||||
expect(squareLayout?.webcamRect?.maskShape).toBe("square");
|
||||
});
|
||||
|
||||
it("applies larger rounding for the rounded webcam mask", () => {
|
||||
const roundedLayout = computeCompositeLayout({
|
||||
canvasSize: { width: 1920, height: 1080 },
|
||||
screenSize: { width: 1920, height: 1080 },
|
||||
webcamSize: { width: 1280, height: 720 },
|
||||
webcamMaskShape: "rounded",
|
||||
});
|
||||
const rectangleLayout = computeCompositeLayout({
|
||||
canvasSize: { width: 1920, height: 1080 },
|
||||
screenSize: { width: 1920, height: 1080 },
|
||||
webcamSize: { width: 1280, height: 720 },
|
||||
webcamMaskShape: "rectangle",
|
||||
});
|
||||
|
||||
expect(roundedLayout?.webcamRect).not.toBeNull();
|
||||
expect(rectangleLayout?.webcamRect).not.toBeNull();
|
||||
expect(roundedLayout?.webcamRect?.borderRadius).toBeGreaterThan(
|
||||
rectangleLayout?.webcamRect?.borderRadius ?? 0,
|
||||
);
|
||||
expect(roundedLayout?.webcamRect?.maskShape).toBe("rounded");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface RenderRect {
|
||||
|
||||
export interface StyledRenderRect extends RenderRect {
|
||||
borderRadius: number;
|
||||
maskShape?: import("@/components/video-editor/types").WebcamMaskShape;
|
||||
}
|
||||
|
||||
export interface Size {
|
||||
@@ -125,6 +126,7 @@ export function computeCompositeLayout(params: {
|
||||
webcamSize?: Size | null;
|
||||
layoutPreset?: WebcamLayoutPreset;
|
||||
webcamPosition?: { cx: number; cy: number } | null;
|
||||
webcamMaskShape?: import("@/components/video-editor/types").WebcamMaskShape;
|
||||
}): WebcamCompositeLayout | null {
|
||||
const {
|
||||
canvasSize,
|
||||
@@ -133,6 +135,7 @@ export function computeCompositeLayout(params: {
|
||||
webcamSize,
|
||||
layoutPreset = "picture-in-picture",
|
||||
webcamPosition,
|
||||
webcamMaskShape = "rectangle",
|
||||
} = params;
|
||||
const { width: canvasWidth, height: canvasHeight } = canvasSize;
|
||||
const { width: screenWidth, height: screenHeight } = screenSize;
|
||||
@@ -198,8 +201,15 @@ export function computeCompositeLayout(params: {
|
||||
const maxWidth = Math.max(transform.minSize, canvasWidth * transform.maxStageFraction);
|
||||
const maxHeight = Math.max(transform.minSize, canvasHeight * transform.maxStageFraction);
|
||||
const scale = Math.min(maxWidth / webcamWidth, maxHeight / webcamHeight);
|
||||
const width = Math.round(webcamWidth * scale);
|
||||
const height = Math.round(webcamHeight * scale);
|
||||
let width = Math.round(webcamWidth * scale);
|
||||
let height = Math.round(webcamHeight * scale);
|
||||
|
||||
// Shape-specific dimension adjustments
|
||||
if (webcamMaskShape === "circle" || webcamMaskShape === "square") {
|
||||
const side = Math.min(width, height);
|
||||
width = side;
|
||||
height = side;
|
||||
}
|
||||
|
||||
let webcamX: number;
|
||||
let webcamY: number;
|
||||
@@ -217,6 +227,22 @@ export function computeCompositeLayout(params: {
|
||||
webcamY = Math.max(0, Math.round(canvasHeight - margin - height));
|
||||
}
|
||||
|
||||
// Shape-specific border radius
|
||||
let borderRadius: number;
|
||||
if (webcamMaskShape === "rounded") {
|
||||
borderRadius = Math.round(Math.min(width, height) * 0.3);
|
||||
} else if (webcamMaskShape === "circle") {
|
||||
borderRadius = Math.round(Math.min(width, height) / 2);
|
||||
} else {
|
||||
borderRadius = Math.min(
|
||||
preset.borderRadius.max,
|
||||
Math.max(
|
||||
preset.borderRadius.min,
|
||||
Math.round(Math.min(width, height) * preset.borderRadius.fraction),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
screenRect,
|
||||
webcamRect: {
|
||||
@@ -224,13 +250,8 @@ export function computeCompositeLayout(params: {
|
||||
y: webcamY,
|
||||
width,
|
||||
height,
|
||||
borderRadius: Math.min(
|
||||
preset.borderRadius.max,
|
||||
Math.max(
|
||||
preset.borderRadius.min,
|
||||
Math.round(Math.min(width, height) * preset.borderRadius.fraction),
|
||||
),
|
||||
),
|
||||
borderRadius,
|
||||
maskShape: webcamMaskShape,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
type Size,
|
||||
type StyledRenderRect,
|
||||
} from "@/lib/compositeLayout";
|
||||
import { drawCanvasClipPath } from "@/lib/webcamMaskShapes";
|
||||
import { renderAnnotations } from "./annotationRenderer";
|
||||
import {
|
||||
getLinearGradientPoints,
|
||||
@@ -61,6 +62,7 @@ interface FrameRenderConfig {
|
||||
videoHeight: number;
|
||||
webcamSize?: Size | null;
|
||||
webcamLayoutPreset?: WebcamLayoutPreset;
|
||||
webcamMaskShape?: import("@/components/video-editor/types").WebcamMaskShape;
|
||||
webcamPosition?: { cx: number; cy: number } | null;
|
||||
annotationRegions?: AnnotationRegion[];
|
||||
speedRegions?: SpeedRegion[];
|
||||
@@ -441,6 +443,7 @@ export class FrameRenderer {
|
||||
webcamSize: webcamFrame ? this.config.webcamSize : null,
|
||||
layoutPreset: this.config.webcamLayoutPreset,
|
||||
webcamPosition: this.config.webcamPosition,
|
||||
webcamMaskShape: this.config.webcamMaskShape,
|
||||
});
|
||||
if (!compositeLayout) return;
|
||||
|
||||
@@ -668,16 +671,17 @@ export class FrameRenderer {
|
||||
const webcamRect = this.layoutCache?.webcamRect ?? null;
|
||||
if (webcamFrame && webcamRect) {
|
||||
const preset = getWebcamLayoutPresetDefinition(this.config.webcamLayoutPreset);
|
||||
const shape = webcamRect.maskShape ?? this.config.webcamMaskShape ?? "rectangle";
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(
|
||||
drawCanvasClipPath(
|
||||
ctx,
|
||||
webcamRect.x,
|
||||
webcamRect.y,
|
||||
webcamRect.width,
|
||||
webcamRect.height,
|
||||
shape,
|
||||
webcamRect.borderRadius,
|
||||
);
|
||||
ctx.closePath();
|
||||
if (preset.shadow) {
|
||||
ctx.shadowColor = preset.shadow.color;
|
||||
ctx.shadowBlur = preset.shadow.blur;
|
||||
|
||||
@@ -41,6 +41,7 @@ interface GifExporterConfig {
|
||||
videoPadding?: number;
|
||||
cropRegion: CropRegion;
|
||||
webcamLayoutPreset?: WebcamLayoutPreset;
|
||||
webcamMaskShape?: import("@/components/video-editor/types").WebcamMaskShape;
|
||||
webcamPosition?: { cx: number; cy: number } | null;
|
||||
annotationRegions?: AnnotationRegion[];
|
||||
previewWidth?: number;
|
||||
@@ -141,6 +142,7 @@ export class GifExporter {
|
||||
videoHeight: videoInfo.height,
|
||||
webcamSize: webcamInfo ? { width: webcamInfo.width, height: webcamInfo.height } : null,
|
||||
webcamLayoutPreset: this.config.webcamLayoutPreset,
|
||||
webcamMaskShape: this.config.webcamMaskShape,
|
||||
webcamPosition: this.config.webcamPosition,
|
||||
annotationRegions: this.config.annotationRegions,
|
||||
speedRegions: this.config.speedRegions,
|
||||
|
||||
@@ -32,6 +32,7 @@ interface VideoExporterConfig extends ExportConfig {
|
||||
videoPadding?: number;
|
||||
cropRegion: CropRegion;
|
||||
webcamLayoutPreset?: WebcamLayoutPreset;
|
||||
webcamMaskShape?: import("@/components/video-editor/types").WebcamMaskShape;
|
||||
webcamPosition?: { cx: number; cy: number } | null;
|
||||
annotationRegions?: AnnotationRegion[];
|
||||
previewWidth?: number;
|
||||
@@ -134,6 +135,7 @@ export class VideoExporter {
|
||||
videoHeight: videoInfo.height,
|
||||
webcamSize: webcamInfo ? { width: webcamInfo.width, height: webcamInfo.height } : null,
|
||||
webcamLayoutPreset: this.config.webcamLayoutPreset,
|
||||
webcamMaskShape: this.config.webcamMaskShape,
|
||||
webcamPosition: this.config.webcamPosition,
|
||||
annotationRegions: this.config.annotationRegions,
|
||||
speedRegions: this.config.speedRegions,
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { WebcamMaskShape } from "@/components/video-editor/types";
|
||||
|
||||
/**
|
||||
* Returns a CSS clip-path value for the given shape, or null if borderRadius alone suffices.
|
||||
*/
|
||||
export function getCssClipPath(shape: WebcamMaskShape): string | null {
|
||||
switch (shape) {
|
||||
case "circle":
|
||||
return "circle(50% at 50% 50%)";
|
||||
case "rectangle":
|
||||
case "rounded":
|
||||
case "square":
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws a Canvas 2D clip path for the given webcam mask shape.
|
||||
* Call ctx.beginPath() is handled internally; caller should call ctx.clip() after.
|
||||
*/
|
||||
export function drawCanvasClipPath(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
w: number,
|
||||
h: number,
|
||||
shape: WebcamMaskShape,
|
||||
borderRadius: number,
|
||||
): void {
|
||||
ctx.beginPath();
|
||||
switch (shape) {
|
||||
case "circle": {
|
||||
const cx = x + w / 2;
|
||||
const cy = y + h / 2;
|
||||
const r = Math.min(w, h) / 2;
|
||||
ctx.arc(cx, cy, r, 0, Math.PI * 2);
|
||||
break;
|
||||
}
|
||||
case "rectangle":
|
||||
case "rounded":
|
||||
case "square":
|
||||
default:
|
||||
ctx.roundRect(x, y, w, h, borderRadius);
|
||||
break;
|
||||
}
|
||||
ctx.closePath();
|
||||
}
|
||||
Reference in New Issue
Block a user