Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions src/components/video-editor/SettingsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import type {
FigureData,
PlaybackSpeed,
WebcamLayoutPreset,
WebcamMaskShape,
ZoomDepth,
} from "./types";
import { SPEED_OPTIONS } from "./types";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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[]>([]);
Expand Down Expand Up @@ -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>
)}
Comment on lines +631 to +711
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Localize shape labels and expose selected state to assistive tech.

The option labels are hardcoded in English, so they won’t translate in es / zh-CN. Also, this single-select button group should expose pressed state (aria-pressed) for better accessibility.

💡 Suggested patch
-											{(
-												[
-													{ value: "rectangle", label: "Rect" },
-													{ value: "circle", label: "Circle" },
-													{ value: "square", label: "Square" },
-													{ value: "rounded", label: "Rounded" },
-												] as Array<{ value: WebcamMaskShape; label: string }>
-											).map((shape) => (
+											{(
+												[
+													{ value: "rectangle", label: t("layout.webcamShapeRectangle") },
+													{ value: "circle", label: t("layout.webcamShapeCircle") },
+													{ value: "square", label: t("layout.webcamShapeSquare") },
+													{ value: "rounded", label: t("layout.webcamShapeRounded") },
+												] as Array<{ value: WebcamMaskShape; label: string }>
+											).map((shape) => (
 												<button
 													key={shape.value}
 													type="button"
 													onClick={() => onWebcamMaskShapeChange?.(shape.value)}
+													aria-pressed={webcamMaskShape === shape.value}
 													className={cn(
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
{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>
)}
{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: t("layout.webcamShapeRectangle") },
{ value: "circle", label: t("layout.webcamShapeCircle") },
{ value: "square", label: t("layout.webcamShapeSquare") },
{ value: "rounded", label: t("layout.webcamShapeRounded") },
] as Array<{ value: WebcamMaskShape; label: string }>
).map((shape) => (
<button
key={shape.value}
type="button"
onClick={() => onWebcamMaskShapeChange?.(shape.value)}
aria-pressed={webcamMaskShape === 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>
)}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/video-editor/SettingsPanel.tsx` around lines 631 - 711, The
shape option labels are hardcoded and the button group doesn't expose pressed
state; update the shape list used in the webcamLayoutPreset ===
"picture-in-picture" block (the array mapped to render buttons) to use
localization keys via t(...) instead of hardcoded
"Rect"/"Circle"/"Square"/"Rounded", and add aria-pressed={webcamMaskShape ===
shape.value} to each button rendered by the map so assistive tech can detect the
selected state; ensure the onClick still calls
onWebcamMaskShapeChange(shape.value) and keep existing className logic using
webcamMaskShape for visual selection.

</AccordionContent>
</AccordionItem>
)}
Expand Down
12 changes: 12 additions & 0 deletions src/components/video-editor/VideoEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ export default function VideoEditor() {
padding,
aspectRatio,
webcamLayoutPreset,
webcamMaskShape,
webcamPosition,
} = editorState;

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -264,6 +266,7 @@ export default function VideoEditor() {
annotationRegions,
aspectRatio,
webcamLayoutPreset,
webcamMaskShape,
webcamPosition,
exportQuality,
exportFormat,
Expand All @@ -287,6 +290,7 @@ export default function VideoEditor() {
annotationRegions,
aspectRatio,
webcamLayoutPreset,
webcamMaskShape,
webcamPosition,
exportQuality,
exportFormat,
Expand Down Expand Up @@ -380,6 +384,7 @@ export default function VideoEditor() {
annotationRegions,
aspectRatio,
webcamLayoutPreset,
webcamMaskShape,
webcamPosition,
exportQuality,
exportFormat,
Expand Down Expand Up @@ -434,6 +439,7 @@ export default function VideoEditor() {
annotationRegions,
aspectRatio,
webcamLayoutPreset,
webcamMaskShape,
webcamPosition,
exportQuality,
exportFormat,
Expand Down Expand Up @@ -1090,6 +1096,7 @@ export default function VideoEditor() {
cropRegion,
annotationRegions,
webcamLayoutPreset,
webcamMaskShape,
webcamPosition,
previewWidth,
previewHeight,
Expand Down Expand Up @@ -1221,6 +1228,7 @@ export default function VideoEditor() {
cropRegion,
annotationRegions,
webcamLayoutPreset,
webcamMaskShape,
webcamPosition,
previewWidth,
previewHeight,
Expand Down Expand Up @@ -1289,6 +1297,7 @@ export default function VideoEditor() {
isPlaying,
aspectRatio,
webcamLayoutPreset,
webcamMaskShape,
webcamPosition,
exportQuality,
handleExportSaved,
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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}
Expand Down
71 changes: 46 additions & 25 deletions src/components/video-editor/VideoPlayback.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
type StyledRenderRect,
type WebcamLayoutPreset,
} from "@/lib/compositeLayout";
import { getCssClipPath } from "@/lib/webcamMaskShapes";
import {
type AspectRatio,
formatAspectRatioForCSS,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -111,6 +113,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
videoPath,
webcamVideoPath,
webcamLayoutPreset,
webcamMaskShape,
webcamPosition,
onWebcamPositionChange,
onWebcamPositionDragEnd,
Expand Down Expand Up @@ -272,6 +275,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
webcamDimensions,
webcamLayoutPreset,
webcamPosition,
webcamMaskShape,
});

if (result) {
Expand Down Expand Up @@ -302,6 +306,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
webcamDimensions,
webcamLayoutPreset,
webcamPosition,
webcamMaskShape,
]);

useEffect(() => {
Expand Down Expand Up @@ -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={{
Comment on lines +1167 to +1169
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Disable pointer capture on webcam wrapper in non-PiP mode

The new wrapper <div> around the webcam video defaults to pointer-events: auto, so in vertical-stack mode (where the inner <video> is explicitly pointer-events-none) this wrapper still becomes the hit target and swallows clicks/drags over the webcam area. That blocks underlying editor interactions in that region (e.g. timeline/annotation manipulations routed through lower layers) whenever a webcam track is present. Add pointer-events: none on the wrapper (and re-enable on the video for PiP drag) to preserve the prior pass-through behavior.

Useful? React with 👍 / 👎.

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
Expand Down
9 changes: 9 additions & 0 deletions src/components/video-editor/projectPersistence.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import {
createProjectData,
normalizeProjectEditor,
PROJECT_VERSION,
resolveProjectMedia,
validateProjectData,
Expand Down Expand Up @@ -40,6 +41,7 @@ describe("projectPersistence media compatibility", () => {
annotationRegions: [],
aspectRatio: "16:9",
webcamLayoutPreset: "picture-in-picture",
webcamMaskShape: "circle",
exportQuality: "good",
exportFormat: "mp4",
gifFrameRate: 15,
Expand All @@ -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");
});
});
10 changes: 10 additions & 0 deletions src/components/video-editor/projectPersistence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -44,6 +46,7 @@ export interface ProjectEditorState {
annotationRegions: AnnotationRegion[];
aspectRatio: AspectRatio;
webcamLayoutPreset: WebcamLayoutPreset;
webcamMaskShape: WebcamMaskShape;
webcamPosition: WebcamPosition | null;
exportQuality: ExportQuality;
exportFormat: ExportFormat;
Expand Down Expand Up @@ -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" &&
Expand Down
4 changes: 4 additions & 0 deletions src/components/video-editor/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading