diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx
index 5f8ece52..330a8b8a 100644
--- a/src/components/video-editor/SettingsPanel.tsx
+++ b/src/components/video-editor/SettingsPanel.tsx
@@ -83,7 +83,12 @@ import {
cursorSetAssets,
getCursorStyleSizeMultiplier,
} from "./videoPlayback/uploadedCursorAssets";
-import { getWebcamPositionForPreset, resolveWebcamCorner } from "./webcamOverlay";
+import { WebcamCropControl } from "./WebcamCropControl";
+import {
+ getWebcamPositionForPreset,
+ normalizeWebcamCropRegion,
+ resolveWebcamCorner,
+} from "./webcamOverlay";
const tahoeCursorUrl = cursorSetAssets.tahoe.arrow.url;
const BUILTIN_CURSOR_PREVIEW_SIZE = 28;
@@ -1263,6 +1268,7 @@ export function SettingsPanel({
const webcamPositionPreset = webcam?.positionPreset ?? DEFAULT_WEBCAM_POSITION_PRESET;
const webcamPositionX = webcam?.positionX ?? DEFAULT_WEBCAM_POSITION_X;
const webcamPositionY = webcam?.positionY ?? DEFAULT_WEBCAM_POSITION_Y;
+ const webcamCrop = normalizeWebcamCropRegion(webcam?.cropRegion);
const getWallpaperTileState = (candidateValue: string, previewPath?: string) => {
if (!selected) return false;
@@ -1902,8 +1908,14 @@ export function SettingsPanel({
className="text-[10px] text-[#2563EB] transition-opacity hover:opacity-80"
title={
padding.linked === false
- ? tSettings("effects.paddingAdvancedHide", "Hide advanced padding controls")
- : tSettings("effects.paddingAdvancedShow", "Show advanced padding controls")
+ ? tSettings(
+ "effects.paddingAdvancedHide",
+ "Hide advanced padding controls",
+ )
+ : tSettings(
+ "effects.paddingAdvancedShow",
+ "Show advanced padding controls",
+ )
}
>
{tSettings("effects.paddingAdvanced", "Advanced")}
@@ -2863,6 +2875,16 @@ export function SettingsPanel({
className="data-[state=checked]:bg-[#2563EB] scale-75"
/>
+
+
+ {tSettings("effects.webcamMirror", "Mirror webcam")}
+
+ updateWebcam({ mirror })}
+ className="data-[state=checked]:bg-[#2563EB] scale-75"
+ />
+
`${Math.round(v)}%`}
parseInput={(text) => parseFloat(text.replace(/%$/, ""))}
/>
+
+
+
+ {tSettings("effects.webcamCrop", "Crop")}
+
+
+
+
updateWebcam({ cropRegion })}
+ />
+
{tSettings("effects.webcamPosition", "Position")}
@@ -2988,37 +3031,41 @@ export function SettingsPanel({
parseInput={(text) => parseFloat(text.replace(/%$/, "")) / 100}
/>
-
-
+
+
{tSettings("effects.webcamFootage")}
-
+
{webcamFileName ??
tSettings("effects.webcamFootageDescription")}
-
+
{webcam?.sourcePath ? (
) : null}
diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx
index c6c50f0b..ddca6db8 100644
--- a/src/components/video-editor/VideoPlayback.tsx
+++ b/src/components/video-editor/VideoPlayback.tsx
@@ -133,7 +133,11 @@ import {
createMotionBlurState,
type MotionBlurState,
} from "./videoPlayback/zoomTransform";
-import { getWebcamOverlayPosition, getWebcamOverlaySizePx } from "./webcamOverlay";
+import {
+ getWebcamCropSourceRect,
+ getWebcamOverlayPosition,
+ getWebcamOverlaySizePx,
+} from "./webcamOverlay";
type PlaybackAnimationState = {
scale: number;
@@ -356,8 +360,12 @@ const VideoPlayback = forwardRef
(
const overlayRef = useRef(null);
const focusIndicatorRef = useRef(null);
const webcamVideoRef = useRef(null);
+ const webcamCanvasRef = useRef(null);
const webcamBubbleRef = useRef(null);
const webcamBubbleInnerRef = useRef(null);
+ const [webcamFrameReady, setWebcamFrameReady] = useState(false);
+ const [webcamSourceReady, setWebcamSourceReady] = useState(false);
+ const webcamFrameReadyRef = useRef(false);
const captionBoxRef = useRef(null);
const currentTimeRef = useRef(0);
const zoomRegionsRef = useRef([]);
@@ -509,37 +517,49 @@ const VideoPlayback = forwardRef(
return () => cancelAnimationFrame(frame);
}, [activeCaptionLayout, autoCaptionSettings]);
const motionBlurStateRef = useRef(createMotionBlurState());
+ const webcamEnabled = webcam?.enabled ?? false;
+ const webcamMargin = webcam?.margin ?? 24;
+ const webcamSize = webcam?.size ?? DEFAULT_WEBCAM_SIZE;
+ const webcamReactToZoom = webcam?.reactToZoom ?? DEFAULT_WEBCAM_REACT_TO_ZOOM;
+ const webcamPositionPreset = webcam?.positionPreset ?? webcam?.corner ?? "bottom-right";
+ const webcamPositionX = webcam?.positionX ?? 1;
+ const webcamPositionY = webcam?.positionY ?? 1;
+ const webcamCorner = webcam?.corner ?? "bottom-right";
+ const webcamCornerRadius = webcam?.cornerRadius ?? DEFAULT_WEBCAM_CORNER_RADIUS;
+ const webcamShadow = webcam?.shadow ?? DEFAULT_WEBCAM_SHADOW;
+ const webcamTimeOffsetMs = webcam?.timeOffsetMs;
+ const webcamCropRegion = webcam?.cropRegion;
+ const webcamMirror = webcam?.mirror ?? false;
const applyWebcamBubbleLayout = useCallback(
(zoomScale: number) => {
const bubble = webcamBubbleRef.current;
const bubbleInner = webcamBubbleInnerRef.current;
const overlay = overlayRef.current;
- if (!bubble || !bubbleInner || !overlay || !webcam?.enabled || !webcamVideoPath) {
+ if (!bubble || !bubbleInner || !overlay || !webcamEnabled || !webcamVideoPath) {
if (bubble) {
bubble.style.display = "none";
}
return;
}
- const margin = webcam.margin ?? 24;
const scaledSize = getWebcamOverlaySizePx({
containerWidth: overlay.clientWidth,
containerHeight: overlay.clientHeight,
- sizePercent: webcam.size ?? DEFAULT_WEBCAM_SIZE,
- margin,
+ sizePercent: webcamSize,
+ margin: webcamMargin,
zoomScale,
- reactToZoom: webcam.reactToZoom ?? DEFAULT_WEBCAM_REACT_TO_ZOOM,
+ reactToZoom: webcamReactToZoom,
});
const { x, y } = getWebcamOverlayPosition({
containerWidth: overlay.clientWidth,
containerHeight: overlay.clientHeight,
size: scaledSize,
- margin,
- positionPreset: webcam.positionPreset ?? webcam.corner,
- positionX: webcam.positionX ?? 1,
- positionY: webcam.positionY ?? 1,
- legacyCorner: webcam.corner,
+ margin: webcamMargin,
+ positionPreset: webcamPositionPreset,
+ positionX: webcamPositionX,
+ positionY: webcamPositionY,
+ legacyCorner: webcamCorner,
});
bubble.style.display = "block";
@@ -552,11 +572,11 @@ const VideoPlayback = forwardRef(
y: 0,
width: scaledSize,
height: scaledSize,
- radius: webcam.cornerRadius ?? DEFAULT_WEBCAM_CORNER_RADIUS,
+ radius: webcamCornerRadius,
});
bubble.style.filter = `drop-shadow(0 ${Math.round(scaledSize * 0.06)}px ${Math.round(
scaledSize * 0.22,
- )}px rgba(0, 0, 0, ${webcam.shadow ?? DEFAULT_WEBCAM_SHADOW}))`;
+ )}px rgba(0, 0, 0, ${webcamShadow}))`;
bubble.style.borderRadius = "0px";
bubble.style.boxShadow = "none";
@@ -564,7 +584,19 @@ const VideoPlayback = forwardRef(
bubbleInner.style.clipPath = `path('${squirclePath}')`;
bubbleInner.style.setProperty("-webkit-clip-path", `path('${squirclePath}')`);
},
- [webcam, webcamVideoPath],
+ [
+ webcamCorner,
+ webcamCornerRadius,
+ webcamEnabled,
+ webcamMargin,
+ webcamPositionPreset,
+ webcamPositionX,
+ webcamPositionY,
+ webcamReactToZoom,
+ webcamShadow,
+ webcamSize,
+ webcamVideoPath,
+ ],
);
const clampFocusToStage = useCallback((focus: ZoomFocus, depth: ZoomDepth) => {
@@ -1020,7 +1052,8 @@ const VideoPlayback = forwardRef(
: clampMediaTimeToDuration(clipTimelineTime, videoDuration);
const activeSpeedRegion = speedRegionsRef.current.find(
- (region) => currentTime * 1000 >= region.startMs && currentTime * 1000 < region.endMs,
+ (region) =>
+ currentTime * 1000 >= region.startMs && currentTime * 1000 < region.endMs,
);
const targetPlaybackRate = activeSpeedRegion ? activeSpeedRegion.speed : 1;
const syncedPlaybackRate = getMediaSyncPlaybackRate({
@@ -1270,19 +1303,26 @@ const VideoPlayback = forwardRef(
useEffect(() => {
if (!pixiReady || !videoReady) return;
applyWebcamBubbleLayout(animationStateRef.current.appliedScale || 1);
- }, [applyWebcamBubbleLayout, pixiReady, videoReady, webcam, webcamVideoPath]);
+ }, [applyWebcamBubbleLayout, pixiReady, videoReady]);
- useEffect(() => {
+ const syncWebcamMedia = useCallback(() => {
const webcamVideo = webcamVideoRef.current;
- if (!webcamVideo || !webcam?.enabled || !webcamVideoPath) {
+ if (!webcamVideo || !webcamEnabled || !webcamVideoPath) {
return;
}
+ const webcamDuration = Number.isFinite(webcamVideo.duration)
+ ? webcamVideo.duration
+ : null;
const targetTime = getWebcamMediaTargetTimeSeconds({
currentTime,
- webcamDuration: Number.isFinite(webcamVideo.duration) ? webcamVideo.duration : null,
- timeOffsetMs: webcam.timeOffsetMs,
+ webcamDuration,
+ timeOffsetMs: webcamTimeOffsetMs,
});
+ const mediaTargetTime =
+ targetTime <= 0 && webcamDuration !== null && webcamDuration > 0
+ ? Math.min(1 / 60, webcamDuration)
+ : targetTime;
const activeSpeedRegion = speedRegionsRef.current.find(
(region) => targetTime * 1000 >= region.startMs && targetTime * 1000 < region.endMs,
@@ -1296,9 +1336,12 @@ const VideoPlayback = forwardRef(
const timelineJumped =
previousTimelineTime === null || Math.abs(targetTime - previousTimelineTime) > 0.25;
const driftThreshold = isPlaying ? 0.35 : 0.01;
- if (timelineJumped || Math.abs(webcamVideo.currentTime - targetTime) > driftThreshold) {
+ if (
+ timelineJumped ||
+ Math.abs(webcamVideo.currentTime - mediaTargetTime) > driftThreshold
+ ) {
try {
- webcamVideo.currentTime = targetTime;
+ webcamVideo.currentTime = mediaTargetTime;
} catch {
// no-op
}
@@ -1314,12 +1357,141 @@ const VideoPlayback = forwardRef(
}
lastWebcamSyncTimeRef.current = targetTime;
- }, [currentTime, isPlaying, webcam, webcamVideoPath]);
+ }, [currentTime, isPlaying, webcamEnabled, webcamTimeOffsetMs, webcamVideoPath]);
+
+ const handleWebcamMediaReady = useCallback(() => {
+ setWebcamSourceReady(true);
+ syncWebcamMedia();
+ }, [syncWebcamMedia]);
useEffect(() => {
+ syncWebcamMedia();
+ }, [syncWebcamMedia]);
+
+ useEffect(() => {
+ webcamFrameReadyRef.current = false;
+ setWebcamFrameReady(false);
+ setWebcamSourceReady(false);
lastWebcamSyncTimeRef.current = null;
}, [webcamVideoPath]);
+ useEffect(() => {
+ const webcamVideo = webcamVideoRef.current;
+ const webcamCanvas = webcamCanvasRef.current;
+ if (!webcamVideo || !webcamCanvas || !webcamEnabled || !webcamVideoPath) {
+ return;
+ }
+
+ const ctx = webcamCanvas.getContext("2d");
+ if (!ctx) {
+ return;
+ }
+
+ let animationFrame: number | null = null;
+ let videoFrameCallback: number | null = null;
+ const markWebcamFrameReady = () => {
+ if (webcamFrameReadyRef.current) {
+ return;
+ }
+ webcamFrameReadyRef.current = true;
+ setWebcamFrameReady(true);
+ };
+ const drawWebcamFrame = (markReady = false): boolean => {
+ const cssWidth = Math.max(1, webcamCanvas.clientWidth);
+ const cssHeight = Math.max(1, webcamCanvas.clientHeight);
+ const deviceScale = window.devicePixelRatio || 1;
+ const canvasWidth = Math.max(1, Math.round(cssWidth * deviceScale));
+ const canvasHeight = Math.max(1, Math.round(cssHeight * deviceScale));
+
+ if (webcamCanvas.width !== canvasWidth || webcamCanvas.height !== canvasHeight) {
+ webcamCanvas.width = canvasWidth;
+ webcamCanvas.height = canvasHeight;
+ }
+
+ if (
+ webcamVideo.readyState < HTMLMediaElement.HAVE_CURRENT_DATA ||
+ webcamVideo.videoWidth <= 0 ||
+ webcamVideo.videoHeight <= 0
+ ) {
+ return false;
+ }
+
+ ctx.clearRect(0, 0, canvasWidth, canvasHeight);
+ ctx.imageSmoothingEnabled = true;
+ ctx.imageSmoothingQuality = "high";
+
+ const { sx, sy, sw, sh } = getWebcamCropSourceRect(
+ webcamCropRegion,
+ webcamVideo.videoWidth,
+ webcamVideo.videoHeight,
+ );
+ const coverScale = Math.max(canvasWidth / sw, canvasHeight / sh);
+ const drawWidth = sw * coverScale;
+ const drawHeight = sh * coverScale;
+ const drawX = (canvasWidth - drawWidth) / 2;
+ const drawY = (canvasHeight - drawHeight) / 2;
+ if (webcamMirror) {
+ ctx.save();
+ ctx.translate(canvasWidth, 0);
+ ctx.scale(-1, 1);
+ ctx.drawImage(webcamVideo, sx, sy, sw, sh, drawX, drawY, drawWidth, drawHeight);
+ ctx.restore();
+ } else {
+ ctx.drawImage(webcamVideo, sx, sy, sw, sh, drawX, drawY, drawWidth, drawHeight);
+ }
+ if (markReady) {
+ markWebcamFrameReady();
+ }
+ return true;
+ };
+
+ const hasVideoFrameCallback =
+ typeof webcamVideo.requestVideoFrameCallback === "function" &&
+ typeof webcamVideo.cancelVideoFrameCallback === "function";
+
+ const queueVideoFrameCallback = () => {
+ if (!hasVideoFrameCallback) {
+ return;
+ }
+ videoFrameCallback = webcamVideo.requestVideoFrameCallback(() => {
+ videoFrameCallback = null;
+ drawWebcamFrame(true);
+ queueVideoFrameCallback();
+ });
+ };
+
+ const drawOnAnimationFrame = () => {
+ drawWebcamFrame(!hasVideoFrameCallback);
+ animationFrame = requestAnimationFrame(drawOnAnimationFrame);
+ };
+
+ const handleDrawableMediaEvent = () => {
+ syncWebcamMedia();
+ if (animationFrame !== null) {
+ cancelAnimationFrame(animationFrame);
+ }
+ animationFrame = requestAnimationFrame(drawOnAnimationFrame);
+ };
+
+ webcamVideo.addEventListener("loadeddata", handleDrawableMediaEvent);
+ webcamVideo.addEventListener("canplay", handleDrawableMediaEvent);
+ webcamVideo.addEventListener("seeked", handleDrawableMediaEvent);
+ queueVideoFrameCallback();
+ drawOnAnimationFrame();
+
+ return () => {
+ if (animationFrame !== null) {
+ cancelAnimationFrame(animationFrame);
+ }
+ if (videoFrameCallback !== null && hasVideoFrameCallback) {
+ webcamVideo.cancelVideoFrameCallback(videoFrameCallback);
+ }
+ webcamVideo.removeEventListener("loadeddata", handleDrawableMediaEvent);
+ webcamVideo.removeEventListener("canplay", handleDrawableMediaEvent);
+ webcamVideo.removeEventListener("seeked", handleDrawableMediaEvent);
+ };
+ }, [syncWebcamMedia, webcamCropRegion, webcamEnabled, webcamMirror, webcamVideoPath]);
+
useEffect(() => {
lastBackgroundSyncTimeRef.current = null;
}, [wallpaper]);
@@ -2331,21 +2503,36 @@ const VideoPlayback = forwardRef(
style={{
display: webcam.enabled ? "block" : "none",
pointerEvents: "none",
+ visibility:
+ webcamFrameReady || webcamSourceReady
+ ? "visible"
+ : "hidden",
}}
>
+
diff --git a/src/components/video-editor/WebcamCropControl.tsx b/src/components/video-editor/WebcamCropControl.tsx
new file mode 100644
index 00000000..3f50c130
--- /dev/null
+++ b/src/components/video-editor/WebcamCropControl.tsx
@@ -0,0 +1,274 @@
+import { useId, useRef, useState } from "react";
+import { cn } from "@/lib/utils";
+import type { CropRegion } from "./types";
+import { normalizeWebcamCropRegion } from "./webcamOverlay";
+
+type CropHandle = "move" | "nw" | "ne" | "sw" | "se";
+
+interface WebcamCropControlProps {
+ cropRegion: CropRegion;
+ mirrored?: boolean;
+ onCropChange: (cropRegion: CropRegion) => void;
+}
+
+interface DragState {
+ handle: CropHandle;
+ startX: number;
+ startY: number;
+ initialCrop: CropRegion;
+}
+
+const MIN_CROP_SIZE = 0.08;
+
+const RESIZE_HANDLES: Array<{
+ handle: Exclude;
+ className: string;
+ cursorClassName: string;
+}> = [
+ {
+ handle: "nw",
+ className: "left-0 top-0 -translate-x-1/2 -translate-y-1/2",
+ cursorClassName: "cursor-nwse-resize",
+ },
+ {
+ handle: "ne",
+ className: "right-0 top-0 translate-x-1/2 -translate-y-1/2",
+ cursorClassName: "cursor-nesw-resize",
+ },
+ {
+ handle: "se",
+ className: "bottom-0 right-0 translate-x-1/2 translate-y-1/2",
+ cursorClassName: "cursor-nwse-resize",
+ },
+ {
+ handle: "sw",
+ className: "bottom-0 left-0 -translate-x-1/2 translate-y-1/2",
+ cursorClassName: "cursor-nesw-resize",
+ },
+];
+
+function clamp(value: number, min: number, max: number): number {
+ return Math.min(max, Math.max(min, value));
+}
+
+function normalizeSquareCropRegion(cropRegion: CropRegion): CropRegion {
+ const crop = normalizeWebcamCropRegion(cropRegion);
+ const size = clamp(Math.min(crop.width, crop.height), MIN_CROP_SIZE, 1);
+ const x = clamp(crop.x, 0, 1 - size);
+ const y = clamp(crop.y, 0, 1 - size);
+
+ return { x, y, width: size, height: size };
+}
+
+function flipCropHorizontally(cropRegion: CropRegion): CropRegion {
+ const crop = normalizeSquareCropRegion(cropRegion);
+ return {
+ ...crop,
+ x: clamp(1 - crop.x - crop.width, 0, 1 - crop.width),
+ };
+}
+
+function resizeCrop(cropRegion: CropRegion, handle: CropHandle, deltaX: number, deltaY: number) {
+ const crop = normalizeSquareCropRegion(cropRegion);
+
+ if (handle === "move") {
+ return normalizeSquareCropRegion({
+ ...crop,
+ x: clamp(crop.x + deltaX, 0, 1 - crop.width),
+ y: clamp(crop.y + deltaY, 0, 1 - crop.height),
+ });
+ }
+
+ let left = crop.x;
+ let top = crop.y;
+ let right = crop.x + crop.width;
+ let bottom = crop.y + crop.height;
+
+ if (handle === "nw") {
+ const delta = Math.max(deltaX, deltaY);
+ const nextSize = clamp(crop.width - delta, MIN_CROP_SIZE, Math.min(right, bottom));
+ left = right - nextSize;
+ top = bottom - nextSize;
+ }
+
+ if (handle === "ne") {
+ const delta = Math.max(deltaX, -deltaY);
+ const nextSize = clamp(crop.width + delta, MIN_CROP_SIZE, Math.min(1 - left, bottom));
+ right = left + nextSize;
+ top = bottom - nextSize;
+ }
+
+ if (handle === "sw") {
+ const delta = Math.max(-deltaX, deltaY);
+ const nextSize = clamp(crop.width + delta, MIN_CROP_SIZE, Math.min(right, 1 - top));
+ left = right - nextSize;
+ bottom = top + nextSize;
+ }
+
+ if (handle === "se") {
+ const delta = Math.max(deltaX, deltaY);
+ const nextSize = clamp(crop.width + delta, MIN_CROP_SIZE, Math.min(1 - left, 1 - top));
+ right = left + nextSize;
+ bottom = top + nextSize;
+ }
+
+ return normalizeSquareCropRegion({
+ x: left,
+ y: top,
+ width: right - left,
+ height: bottom - top,
+ });
+}
+
+export function WebcamCropControl({
+ cropRegion,
+ mirrored = false,
+ onCropChange,
+}: WebcamCropControlProps) {
+ const containerRef = useRef(null);
+ const dragStateRef = useRef(null);
+ const maskId = `webcam-crop-mask-${useId().replace(/:/g, "")}`;
+ const [activeHandle, setActiveHandle] = useState(null);
+ const sourceCrop = normalizeSquareCropRegion(cropRegion);
+ const crop = mirrored ? flipCropHorizontally(sourceCrop) : sourceCrop;
+ const cropLeft = crop.x * 100;
+ const cropTop = crop.y * 100;
+ const cropWidth = crop.width * 100;
+ const cropHeight = crop.height * 100;
+
+ const getPointerPosition = (event: React.PointerEvent) => {
+ const rect = containerRef.current?.getBoundingClientRect();
+ if (!rect || rect.width <= 0 || rect.height <= 0) {
+ return null;
+ }
+
+ return {
+ x: clamp((event.clientX - rect.left) / rect.width, 0, 1),
+ y: clamp((event.clientY - rect.top) / rect.height, 0, 1),
+ };
+ };
+
+ const handlePointerDown = (event: React.PointerEvent, handle: CropHandle) => {
+ const pointer = getPointerPosition(event);
+ if (!pointer) {
+ return;
+ }
+
+ event.preventDefault();
+ event.stopPropagation();
+ dragStateRef.current = {
+ handle,
+ startX: pointer.x,
+ startY: pointer.y,
+ initialCrop: crop,
+ };
+ setActiveHandle(handle);
+
+ try {
+ containerRef.current?.setPointerCapture(event.pointerId);
+ } catch {
+ /* Pointer capture can fail if the drag started outside the control. */
+ }
+ };
+
+ const handlePointerMove = (event: React.PointerEvent) => {
+ const dragState = dragStateRef.current;
+ if (!dragState) {
+ return;
+ }
+
+ const pointer = getPointerPosition(event);
+ if (!pointer) {
+ return;
+ }
+
+ const nextVisualCrop = resizeCrop(
+ dragState.initialCrop,
+ dragState.handle,
+ pointer.x - dragState.startX,
+ pointer.y - dragState.startY,
+ );
+ onCropChange(mirrored ? flipCropHorizontally(nextVisualCrop) : nextVisualCrop);
+ };
+
+ const endDrag = (event: React.PointerEvent) => {
+ if (dragStateRef.current) {
+ try {
+ containerRef.current?.releasePointerCapture(event.pointerId);
+ } catch {
+ /* Pointer capture may already be released while ending the drag. */
+ }
+ }
+ dragStateRef.current = null;
+ setActiveHandle(null);
+ };
+
+ return (
+
+
+
+
+
+
+
+
handlePointerDown(event, "move")}
+ >
+
+
+
+
+
+ {RESIZE_HANDLES.map((handle) => (
+
handlePointerDown(event, handle.handle)}
+ />
+ ))}
+
+
+ );
+}
diff --git a/src/components/video-editor/projectPersistence.ts b/src/components/video-editor/projectPersistence.ts
index 5ebfa011..421e4340 100644
--- a/src/components/video-editor/projectPersistence.ts
+++ b/src/components/video-editor/projectPersistence.ts
@@ -64,6 +64,7 @@ import {
type ZoomRegion,
type ZoomTransitionEasing,
} from "./types";
+import { normalizeWebcamCropRegion } from "./webcamOverlay";
export const PROJECT_VERSION = 1;
@@ -812,6 +813,7 @@ export function normalizeProjectEditor(editor: Partial
): Pro
sourcePath: webcamSourcePath,
mirror:
typeof webcam.mirror === "boolean" ? webcam.mirror : DEFAULT_WEBCAM_OVERLAY.mirror,
+ cropRegion: normalizeWebcamCropRegion(webcam.cropRegion),
positionPreset:
webcam.positionPreset === "top-left" ||
webcam.positionPreset === "top-center" ||
diff --git a/src/components/video-editor/types.ts b/src/components/video-editor/types.ts
index ec29d812..20b7568c 100644
--- a/src/components/video-editor/types.ts
+++ b/src/components/video-editor/types.ts
@@ -83,6 +83,7 @@ export interface WebcamOverlaySettings {
sourcePath: string | null;
timeOffsetMs: number;
mirror: boolean;
+ cropRegion: CropRegion;
corner: WebcamCorner;
positionPreset: WebcamPositionPreset;
positionX: number;
@@ -124,6 +125,7 @@ export const DEFAULT_WEBCAM_OVERLAY: WebcamOverlaySettings = {
sourcePath: null,
timeOffsetMs: DEFAULT_WEBCAM_TIME_OFFSET_MS,
mirror: true,
+ cropRegion: { x: 0, y: 0, width: 1, height: 1 },
corner: "bottom-right",
positionPreset: DEFAULT_WEBCAM_POSITION_PRESET,
positionX: DEFAULT_WEBCAM_POSITION_X,
diff --git a/src/components/video-editor/webcamOverlay.test.ts b/src/components/video-editor/webcamOverlay.test.ts
new file mode 100644
index 00000000..a78b0268
--- /dev/null
+++ b/src/components/video-editor/webcamOverlay.test.ts
@@ -0,0 +1,34 @@
+import { describe, expect, it } from "vitest";
+import {
+ getWebcamCropSourceRect,
+ isWebcamCropRegionDefault,
+ normalizeWebcamCropRegion,
+} from "./webcamOverlay";
+
+describe("normalizeWebcamCropRegion", () => {
+ it("defaults to the full webcam frame", () => {
+ expect(normalizeWebcamCropRegion()).toEqual({ x: 0, y: 0, width: 1, height: 1 });
+ expect(isWebcamCropRegionDefault()).toBe(true);
+ });
+
+ it("clamps crop dimensions inside the source frame", () => {
+ const crop = normalizeWebcamCropRegion({ x: 0.8, y: -1, width: 0.5, height: 2 });
+ expect(crop.x).toBe(0.8);
+ expect(crop.y).toBe(0);
+ expect(crop.width).toBeCloseTo(0.2);
+ expect(crop.height).toBe(1);
+ });
+});
+
+describe("getWebcamCropSourceRect", () => {
+ it("converts normalized crop settings to source pixels", () => {
+ expect(
+ getWebcamCropSourceRect({ x: 0.25, y: 0.1, width: 0.5, height: 0.75 }, 1920, 1080),
+ ).toEqual({
+ sx: 480,
+ sy: 108,
+ sw: 960,
+ sh: 810,
+ });
+ });
+});
diff --git a/src/components/video-editor/webcamOverlay.ts b/src/components/video-editor/webcamOverlay.ts
index 5144231a..21cc7dd3 100644
--- a/src/components/video-editor/webcamOverlay.ts
+++ b/src/components/video-editor/webcamOverlay.ts
@@ -1,4 +1,4 @@
-import type { WebcamCorner, WebcamPositionPreset } from "./types";
+import type { CropRegion, WebcamCorner, WebcamPositionPreset } from "./types";
const MIN_WEBCAM_OVERLAY_SIZE_PX = 56;
@@ -110,3 +110,44 @@ export function getWebcamOverlayPosition({
y: safeMargin + availableHeight * presetPosition.y,
};
}
+
+export function normalizeWebcamCropRegion(cropRegion?: Partial | null): CropRegion {
+ const candidate = cropRegion ?? {};
+ const rawX = Number.isFinite(candidate.x) ? (candidate.x as number) : 0;
+ const rawY = Number.isFinite(candidate.y) ? (candidate.y as number) : 0;
+ const x = clamp(rawX, 0, 0.99);
+ const y = clamp(rawY, 0, 0.99);
+ const width = clamp(
+ Number.isFinite(candidate.width) ? (candidate.width as number) : 1,
+ 0.01,
+ 1 - x,
+ );
+ const height = clamp(
+ Number.isFinite(candidate.height) ? (candidate.height as number) : 1,
+ 0.01,
+ 1 - y,
+ );
+
+ return { x, y, width, height };
+}
+
+export function isWebcamCropRegionDefault(cropRegion?: Partial | null): boolean {
+ const crop = normalizeWebcamCropRegion(cropRegion);
+ return crop.x <= 0 && crop.y <= 0 && crop.width >= 1 && crop.height >= 1;
+}
+
+export function getWebcamCropSourceRect(
+ cropRegion: Partial | null | undefined,
+ sourceWidth: number,
+ sourceHeight: number,
+): { sx: number; sy: number; sw: number; sh: number } {
+ const crop = normalizeWebcamCropRegion(cropRegion);
+ const safeWidth = Math.max(1, sourceWidth);
+ const safeHeight = Math.max(1, sourceHeight);
+ const sx = clamp(crop.x * safeWidth, 0, safeWidth - 1);
+ const sy = clamp(crop.y * safeHeight, 0, safeHeight - 1);
+ const sw = clamp(crop.width * safeWidth, 1, safeWidth - sx);
+ const sh = clamp(crop.height * safeHeight, 1, safeHeight - sy);
+
+ return { sx, sy, sw, sh };
+}
diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json
index c788b132..4fc43579 100644
--- a/src/i18n/locales/en/settings.json
+++ b/src/i18n/locales/en/settings.json
@@ -85,7 +85,9 @@
"webcamFootageAdded": "Webcam footage linked",
"webcamFootageRemoved": "Webcam footage removed",
"webcamSize": "Webcam Size",
+ "webcamCrop": "Webcam Crop",
"webcamReactToZoom": "Webcam Reacts To Zoom",
+ "webcamMirror": "Mirror webcam",
"webcamRoundness": "Webcam Roundness",
"webcamShadow": "Webcam Shadow",
"shadow": "Shadow",
diff --git a/src/i18n/locales/es/settings.json b/src/i18n/locales/es/settings.json
index ab686b30..29be4f8e 100644
--- a/src/i18n/locales/es/settings.json
+++ b/src/i18n/locales/es/settings.json
@@ -85,7 +85,9 @@
"webcamFootageAdded": "Metraje de cámara vinculado",
"webcamFootageRemoved": "Metraje de cámara eliminado",
"webcamSize": "Tamaño de cámara",
+ "webcamCrop": "Recorte de cámara",
"webcamReactToZoom": "La cámara reacciona al zoom",
+ "webcamMirror": "Reflejar cámara",
"webcamRoundness": "Redondez de cámara",
"webcamShadow": "Sombra de cámara",
"shadow": "Sombra",
diff --git a/src/i18n/locales/fr/settings.json b/src/i18n/locales/fr/settings.json
index c4748e3d..afaeb509 100644
--- a/src/i18n/locales/fr/settings.json
+++ b/src/i18n/locales/fr/settings.json
@@ -85,7 +85,9 @@
"webcamFootageAdded": "Vidéo de webcam liée",
"webcamFootageRemoved": "Vidéo de webcam supprimée",
"webcamSize": "Taille de la webcam",
+ "webcamCrop": "Recadrage de la webcam",
"webcamReactToZoom": "La webcam réagit au zoom",
+ "webcamMirror": "Miroir webcam",
"webcamRoundness": "Arrondi de la webcam",
"webcamShadow": "Ombre de la webcam",
"shadow": "Ombre",
diff --git a/src/i18n/locales/ko/settings.json b/src/i18n/locales/ko/settings.json
index 62f6f8fd..3ee3c1e1 100644
--- a/src/i18n/locales/ko/settings.json
+++ b/src/i18n/locales/ko/settings.json
@@ -85,7 +85,9 @@
"webcamFootageAdded": "웹캠 영상을 연결했습니다",
"webcamFootageRemoved": "웹캠 영상을 제거했습니다",
"webcamSize": "웹캠 크기",
+ "webcamCrop": "웹캠 자르기",
"webcamReactToZoom": "확대 시 웹캠 반응",
+ "webcamMirror": "웹캠 미러링",
"webcamRoundness": "웹캠 둥글기",
"webcamShadow": "웹캠 그림자",
"shadow": "그림자",
diff --git a/src/i18n/locales/nl/settings.json b/src/i18n/locales/nl/settings.json
index 2aaa43e9..811bb5d9 100644
--- a/src/i18n/locales/nl/settings.json
+++ b/src/i18n/locales/nl/settings.json
@@ -85,7 +85,9 @@
"webcamFootageAdded": "Webcambeelden gekoppeld",
"webcamFootageRemoved": "Webcambeelden verwijderd",
"webcamSize": "Webcamgrootte",
+ "webcamCrop": "Webcam bijsnijden",
"webcamReactToZoom": "Webcam reageert op zoom",
+ "webcamMirror": "Webcam spiegelen",
"webcamRoundness": "Webcam-afronding",
"webcamShadow": "Webcamschaduw",
"shadow": "Schaduw",
diff --git a/src/i18n/locales/pt-BR/settings.json b/src/i18n/locales/pt-BR/settings.json
index 86e969d5..21e94745 100644
--- a/src/i18n/locales/pt-BR/settings.json
+++ b/src/i18n/locales/pt-BR/settings.json
@@ -85,7 +85,9 @@
"webcamFootageAdded": "Filmagem da webcam vinculada",
"webcamFootageRemoved": "Filmagem da webcam removida",
"webcamSize": "Tamanho da webcam",
+ "webcamCrop": "Corte da webcam",
"webcamReactToZoom": "Webcam reage ao zoom",
+ "webcamMirror": "Espelhar webcam",
"webcamRoundness": "Arredondamento da webcam",
"webcamShadow": "Sombra da webcam",
"shadow": "Sombra",
diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json
index dae55a79..963a0493 100644
--- a/src/i18n/locales/zh-CN/settings.json
+++ b/src/i18n/locales/zh-CN/settings.json
@@ -85,7 +85,9 @@
"webcamFootageAdded": "已关联摄像头素材",
"webcamFootageRemoved": "已移除摄像头素材",
"webcamSize": "摄像头大小",
+ "webcamCrop": "摄像头裁剪",
"webcamReactToZoom": "摄像头随缩放变化",
+ "webcamMirror": "镜像摄像头",
"webcamRoundness": "摄像头圆角",
"webcamShadow": "摄像头阴影",
"shadow": "阴影",
diff --git a/src/i18n/locales/zh-TW/settings.json b/src/i18n/locales/zh-TW/settings.json
index b2185b3c..717ded13 100644
--- a/src/i18n/locales/zh-TW/settings.json
+++ b/src/i18n/locales/zh-TW/settings.json
@@ -29,15 +29,16 @@
"loopCursor": "游標循環",
"cursorStyle": "游標樣式",
"cursorStyleOptions": {
- "tahoe": "Tahoe",
- "dot": "圓點",
- "figma": "極簡",
- "mono": "反相",
- "lavender": "Lavender",
- "parched": "Parched",
- "chooper": "Chooper",
- "amongus": "Among Us",
- "turtle": "Turtle"
+ "macos": "macOS",
+ "tahoe": "Tahoe",
+ "tahoe-inverted": "Tahoe 反相",
+ "dot": "圓點",
+ "figma": "極簡",
+ "lavender": "Lavender",
+ "parched": "Parched",
+ "chooper": "Chooper",
+ "amongus": "Among Us",
+ "turtle": "Turtle"
},
"backgroundBlur": "背景模糊",
"zoomMotionBlur": "縮放動態模糊",
@@ -62,11 +63,11 @@
"connectedZoomDuration": "連接縮放時間",
"connectedZoomEasing": "連接平移曲線",
"zoomEasingOptions": {
- "recordly": "Recordly",
- "glide": "滑行",
- "smooth": "平滑",
- "snappy": "俐落",
- "linear": "線性"
+ "recordly": "Recordly",
+ "glide": "滑行",
+ "smooth": "平滑",
+ "snappy": "俐落",
+ "linear": "線性"
},
"cursorSize": "游標大小",
"cursorSmoothing": "游標平滑",
@@ -84,16 +85,15 @@
"webcamFootageAdded": "已連結網路攝影機素材",
"webcamFootageRemoved": "已移除網路攝影機素材",
"webcamSize": "網路攝影機大小",
+ "webcamCrop": "網路攝影機裁剪",
"webcamReactToZoom": "網路攝影機跟隨縮放",
+ "webcamMirror": "鏡像網路攝影機",
"webcamRoundness": "網路攝影機圓角",
"webcamShadow": "網路攝影機陰影",
"shadow": "陰影",
"radius": "半徑",
"roundness": "圓角",
"padding": "內距",
- "paddingAdvanced": "進階",
- "paddingAdvancedShow": "顯示進階內距控制",
- "paddingAdvancedHide": "隱藏進階內距控制",
"paddingLinked": "連動(等距)",
"paddingUnlinked": "不連動(不對稱)",
"paddingTop": "上",
@@ -163,10 +163,10 @@
"mp4": "MP4",
"gif": "GIF",
"quality": {
- "low": "低",
- "medium": "中",
- "high": "高",
- "original": "原始"
+ "low": "低",
+ "medium": "中",
+ "high": "高",
+ "original": "原始"
},
"fpsTitle": "FPS",
"loop": "循環",
@@ -180,4 +180,4 @@
"reportBug": "回報錯誤",
"starOnGithub": "在 GitHub 按讚"
}
-}
\ No newline at end of file
+}
diff --git a/src/lib/exporter/frameRenderer.ts b/src/lib/exporter/frameRenderer.ts
index 5f9b49bb..67a7d41d 100644
--- a/src/lib/exporter/frameRenderer.ts
+++ b/src/lib/exporter/frameRenderer.ts
@@ -48,6 +48,7 @@ import {
type MotionBlurState,
} from "@/components/video-editor/videoPlayback/zoomTransform";
import {
+ getWebcamCropSourceRect,
getWebcamOverlayPosition,
getWebcamOverlaySizePx,
} from "@/components/video-editor/webcamOverlay";
@@ -1842,9 +1843,14 @@ export class FrameRenderer {
: "videoHeight" in webcamFrameSource
? webcamFrameSource.videoHeight
: webcamFrameSource.height) || size;
- const coverScale = Math.max(size / sourceWidth, size / sourceHeight);
- const drawWidth = sourceWidth * coverScale;
- const drawHeight = sourceHeight * coverScale;
+ const { sx, sy, sw, sh } = getWebcamCropSourceRect(
+ webcam.cropRegion,
+ sourceWidth,
+ sourceHeight,
+ );
+ const coverScale = Math.max(size / sw, size / sh);
+ const drawWidth = sw * coverScale;
+ const drawHeight = sh * coverScale;
const drawX = (size - drawWidth) / 2;
const drawY = (size - drawHeight) / 2;
@@ -1855,10 +1861,30 @@ export class FrameRenderer {
bubbleCtx.save();
bubbleCtx.translate(size, 0);
bubbleCtx.scale(-1, 1);
- bubbleCtx.drawImage(webcamFrameSource, drawX, drawY, drawWidth, drawHeight);
+ bubbleCtx.drawImage(
+ webcamFrameSource,
+ sx,
+ sy,
+ sw,
+ sh,
+ drawX,
+ drawY,
+ drawWidth,
+ drawHeight,
+ );
bubbleCtx.restore();
} else {
- bubbleCtx.drawImage(webcamFrameSource, drawX, drawY, drawWidth, drawHeight);
+ bubbleCtx.drawImage(
+ webcamFrameSource,
+ sx,
+ sy,
+ sw,
+ sh,
+ drawX,
+ drawY,
+ drawWidth,
+ drawHeight,
+ );
}
bubbleCtx.restore();
diff --git a/src/lib/exporter/modernFrameRenderer.ts b/src/lib/exporter/modernFrameRenderer.ts
index ee986bf2..bf3c3f2c 100644
--- a/src/lib/exporter/modernFrameRenderer.ts
+++ b/src/lib/exporter/modernFrameRenderer.ts
@@ -54,8 +54,10 @@ import {
type MotionBlurState,
} from "@/components/video-editor/videoPlayback/zoomTransform";
import {
+ getWebcamCropSourceRect,
getWebcamOverlayPosition,
getWebcamOverlaySizePx,
+ isWebcamCropRegionDefault,
} from "@/components/video-editor/webcamOverlay";
import { getAssetPath, getExportableVideoUrl, getRenderableAssetUrl } from "@/lib/assetPath";
import { extensionHost } from "@/lib/extensions";
@@ -1810,8 +1812,9 @@ export class FrameRenderer {
}
private shouldRefreshWebcamFrameCache(width: number, height: number): boolean {
- const targetWidth = Math.max(1, Math.ceil(width));
- const targetHeight = Math.max(1, Math.ceil(height));
+ const sourceRect = getWebcamCropSourceRect(this.config.webcam?.cropRegion, width, height);
+ const targetWidth = Math.max(1, Math.ceil(sourceRect.sw));
+ const targetHeight = Math.max(1, Math.ceil(sourceRect.sh));
if (
!this.webcamFrameCacheCanvas ||
@@ -1856,7 +1859,8 @@ export class FrameRenderer {
width: number,
height: number,
): boolean {
- if (!this.ensureWebcamFrameCache(width, height)) {
+ const sourceRect = getWebcamCropSourceRect(this.config.webcam?.cropRegion, width, height);
+ if (!this.ensureWebcamFrameCache(sourceRect.sw, sourceRect.sh)) {
return false;
}
@@ -1872,6 +1876,10 @@ export class FrameRenderer {
);
this.webcamFrameCacheCtx.drawImage(
source,
+ sourceRect.sx,
+ sourceRect.sy,
+ sourceRect.sw,
+ sourceRect.sh,
0,
0,
this.webcamFrameCacheCanvas.width,
@@ -1908,6 +1916,13 @@ export class FrameRenderer {
if (this.shouldRefreshWebcamFrameCache(liveSourceWidth, liveSourceHeight)) {
this.refreshWebcamFrameCache(liveSource, liveSourceWidth, liveSourceHeight);
}
+ if (!isWebcamCropRegionDefault(this.config.webcam?.cropRegion)) {
+ const cachedSource = this.getCachedWebcamRenderSource();
+ if (cachedSource) {
+ this.setWebcamRenderMode("live");
+ return cachedSource;
+ }
+ }
this.setWebcamRenderMode("live");
return {
source: liveSource,