Add webcam crop and mirror controls

This commit is contained in:
wizardAEI
2026-04-30 19:06:23 +08:00
parent 05f6f65c66
commit 4152f27f2e
17 changed files with 712 additions and 70 deletions
+60 -13
View File
@@ -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"
/>
</div>
<div className="flex items-center justify-between rounded-lg bg-foreground/[0.03] px-2.5 py-1.5">
<span className="text-[10px] text-muted-foreground">
{tSettings("effects.webcamMirror", "Mirror webcam")}
</span>
<Switch
checked={webcam?.mirror ?? true}
onCheckedChange={(mirror) => updateWebcam({ mirror })}
className="data-[state=checked]:bg-[#2563EB] scale-75"
/>
</div>
<SliderControl
label={tSettings("effects.webcamSize")}
value={webcam?.size ?? DEFAULT_WEBCAM_SIZE}
@@ -2874,6 +2896,27 @@ export function SettingsPanel({
formatValue={(v) => `${Math.round(v)}%`}
parseInput={(text) => parseFloat(text.replace(/%$/, ""))}
/>
<div className="rounded-lg bg-foreground/[0.03] px-2.5 py-2">
<div className="mb-2 flex items-center justify-between gap-2">
<div className="text-[10px] text-muted-foreground">
{tSettings("effects.webcamCrop", "Crop")}
</div>
<button
type="button"
onClick={() =>
updateWebcam({ cropRegion: DEFAULT_CROP_REGION })
}
className="text-[10px] text-[#2563EB] transition-opacity hover:opacity-80"
>
{t("common.actions.reset", "Reset")}
</button>
</div>
<WebcamCropControl
cropRegion={webcamCrop}
mirrored={webcam?.mirror ?? true}
onCropChange={(cropRegion) => updateWebcam({ cropRegion })}
/>
</div>
<div className="rounded-lg bg-foreground/[0.03] px-2.5 py-2">
<div className="mb-2 text-[10px] text-muted-foreground">
{tSettings("effects.webcamPosition", "Position")}
@@ -2988,37 +3031,41 @@ export function SettingsPanel({
parseInput={(text) => parseFloat(text.replace(/%$/, "")) / 100}
/>
<div className="rounded-lg bg-foreground/[0.03] px-2.5 py-2">
<div className="flex items-center justify-between gap-2">
<div>
<div className="flex flex-col gap-2">
<div className="min-w-0">
<div className="text-[10px] text-muted-foreground">
{tSettings("effects.webcamFootage")}
</div>
<div className="mt-0.5 text-[10px] text-muted-foreground/70">
<div className="mt-0.5 break-all text-[10px] leading-4 text-muted-foreground/70">
{webcamFileName ??
tSettings("effects.webcamFootageDescription")}
</div>
</div>
<div className="flex items-center gap-1.5">
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-2">
<Button
type="button"
variant="outline"
onClick={onUploadWebcam}
className="h-7 gap-1.5 border-foreground/10 bg-foreground/5 px-2 text-[10px] text-foreground hover:bg-foreground/10 hover:text-foreground"
className="h-7 min-w-0 gap-1.5 border-foreground/10 bg-foreground/5 px-2 text-[10px] text-foreground hover:bg-foreground/10 hover:text-foreground"
>
<Upload className="h-3 w-3" />
{webcam?.sourcePath
? tSettings("effects.replaceWebcamFootage")
: tSettings("effects.uploadWebcamFootage")}
<span className="min-w-0 truncate">
{webcam?.sourcePath
? tSettings("effects.replaceWebcamFootage")
: tSettings("effects.uploadWebcamFootage")}
</span>
</Button>
{webcam?.sourcePath ? (
<Button
type="button"
variant="outline"
onClick={onClearWebcam}
className="h-7 gap-1.5 border-foreground/10 bg-foreground/5 px-2 text-[10px] text-foreground hover:bg-foreground/10 hover:text-foreground"
className="h-7 min-w-0 gap-1.5 border-foreground/10 bg-foreground/5 px-2 text-[10px] text-foreground hover:bg-foreground/10 hover:text-foreground"
>
<Trash2 className="h-3 w-3" />
{tSettings("effects.removeWebcamFootage")}
<span className="min-w-0 truncate">
{tSettings("effects.removeWebcamFootage")}
</span>
</Button>
) : null}
</div>
+213 -26
View File
@@ -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<VideoPlaybackRef, VideoPlaybackProps>(
const overlayRef = useRef<HTMLDivElement | null>(null);
const focusIndicatorRef = useRef<HTMLDivElement | null>(null);
const webcamVideoRef = useRef<HTMLVideoElement | null>(null);
const webcamCanvasRef = useRef<HTMLCanvasElement | null>(null);
const webcamBubbleRef = useRef<HTMLDivElement | null>(null);
const webcamBubbleInnerRef = useRef<HTMLDivElement | null>(null);
const [webcamFrameReady, setWebcamFrameReady] = useState(false);
const [webcamSourceReady, setWebcamSourceReady] = useState(false);
const webcamFrameReadyRef = useRef(false);
const captionBoxRef = useRef<HTMLDivElement | null>(null);
const currentTimeRef = useRef(0);
const zoomRegionsRef = useRef<ZoomRegion[]>([]);
@@ -509,37 +517,49 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
return () => cancelAnimationFrame(frame);
}, [activeCaptionLayout, autoCaptionSettings]);
const motionBlurStateRef = useRef<MotionBlurState>(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<VideoPlaybackRef, VideoPlaybackProps>(
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<VideoPlaybackRef, VideoPlaybackProps>(
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<VideoPlaybackRef, VideoPlaybackProps>(
: 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<VideoPlaybackRef, VideoPlaybackProps>(
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<VideoPlaybackRef, VideoPlaybackProps>(
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<VideoPlaybackRef, VideoPlaybackProps>(
}
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<VideoPlaybackRef, VideoPlaybackProps>(
style={{
display: webcam.enabled ? "block" : "none",
pointerEvents: "none",
visibility:
webcamFrameReady || webcamSourceReady
? "visible"
: "hidden",
}}
>
<div
ref={webcamBubbleInnerRef}
className="h-full w-full overflow-hidden bg-black/80"
className="relative h-full w-full overflow-hidden"
>
<video
ref={webcamVideoRef}
src={webcamVideoPath}
className="h-full w-full object-cover"
className="pointer-events-none absolute inset-0 h-full w-full object-cover"
muted
playsInline
preload="auto"
aria-hidden="true"
style={{
transform: webcam.mirror ? "scaleX(-1)" : undefined,
opacity: webcamFrameReady ? 0 : 1,
transform: webcamMirror ? "scaleX(-1)" : undefined,
}}
onLoadedMetadata={handleWebcamMediaReady}
onLoadedData={handleWebcamMediaReady}
/>
<canvas
ref={webcamCanvasRef}
className="absolute inset-0 block h-full w-full"
style={{
opacity: webcamFrameReady ? 1 : 0,
}}
/>
</div>
@@ -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<CropHandle, "move">;
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<HTMLDivElement | null>(null);
const dragStateRef = useRef<DragState | null>(null);
const maskId = `webcam-crop-mask-${useId().replace(/:/g, "")}`;
const [activeHandle, setActiveHandle] = useState<CropHandle | null>(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<HTMLDivElement>) => {
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<HTMLDivElement>, 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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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 (
<div
ref={containerRef}
className="relative w-full touch-none select-none overflow-hidden rounded-lg border border-foreground/10 bg-editor-dialog-alt"
style={{ aspectRatio: 1 }}
onPointerMove={handlePointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
>
<div className="absolute inset-0 bg-editor-dialog-alt" />
<div className="absolute inset-0 bg-[linear-gradient(hsl(var(--foreground)/0.12)_1px,transparent_1px),linear-gradient(90deg,hsl(var(--foreground)/0.12)_1px,transparent_1px)] bg-[size:12.5%_12.5%]" />
<div className="absolute inset-0 bg-[linear-gradient(135deg,hsl(var(--foreground)/0.06),transparent_46%,hsl(var(--foreground)/0.04))]" />
<svg className="pointer-events-none absolute inset-0 h-full w-full">
<defs>
<mask id={maskId}>
<rect width="100%" height="100%" fill="white" />
<rect
x={`${cropLeft}%`}
y={`${cropTop}%`}
width={`${cropWidth}%`}
height={`${cropHeight}%`}
fill="black"
/>
</mask>
</defs>
<rect
width="100%"
height="100%"
fill="black"
fillOpacity="0.58"
mask={`url(#${maskId})`}
/>
</svg>
<div
className={cn(
"absolute border border-white shadow-[0_0_0_1px_rgba(37,99,235,0.9),0_8px_24px_rgba(0,0,0,0.25)]",
activeHandle === "move" ? "cursor-grabbing" : "cursor-move",
)}
style={{
left: `${cropLeft}%`,
top: `${cropTop}%`,
width: `${cropWidth}%`,
height: `${cropHeight}%`,
}}
onPointerDown={(event) => handlePointerDown(event, "move")}
>
<div className="pointer-events-none absolute left-1/3 top-0 h-full w-px bg-white/45" />
<div className="pointer-events-none absolute left-2/3 top-0 h-full w-px bg-white/45" />
<div className="pointer-events-none absolute left-0 top-1/3 h-px w-full bg-white/45" />
<div className="pointer-events-none absolute left-0 top-2/3 h-px w-full bg-white/45" />
{RESIZE_HANDLES.map((handle) => (
<div
key={handle.handle}
className={cn(
"absolute z-10 h-3.5 w-3.5 rounded-[3px] border-2 border-white bg-[#2563EB] shadow-sm",
handle.className,
handle.cursorClassName,
activeHandle === handle.handle && "scale-110",
)}
onPointerDown={(event) => handlePointerDown(event, handle.handle)}
/>
))}
</div>
</div>
);
}
@@ -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<ProjectEditorState>): 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" ||
+2
View File
@@ -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,
@@ -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,
});
});
});
+42 -1
View File
@@ -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<CropRegion> | 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<CropRegion> | null): boolean {
const crop = normalizeWebcamCropRegion(cropRegion);
return crop.x <= 0 && crop.y <= 0 && crop.width >= 1 && crop.height >= 1;
}
export function getWebcamCropSourceRect(
cropRegion: Partial<CropRegion> | 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 };
}
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -85,7 +85,9 @@
"webcamFootageAdded": "웹캠 영상을 연결했습니다",
"webcamFootageRemoved": "웹캠 영상을 제거했습니다",
"webcamSize": "웹캠 크기",
"webcamCrop": "웹캠 자르기",
"webcamReactToZoom": "확대 시 웹캠 반응",
"webcamMirror": "웹캠 미러링",
"webcamRoundness": "웹캠 둥글기",
"webcamShadow": "웹캠 그림자",
"shadow": "그림자",
+2
View File
@@ -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",
+2
View File
@@ -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",
+2
View File
@@ -85,7 +85,9 @@
"webcamFootageAdded": "已关联摄像头素材",
"webcamFootageRemoved": "已移除摄像头素材",
"webcamSize": "摄像头大小",
"webcamCrop": "摄像头裁剪",
"webcamReactToZoom": "摄像头随缩放变化",
"webcamMirror": "镜像摄像头",
"webcamRoundness": "摄像头圆角",
"webcamShadow": "摄像头阴影",
"shadow": "阴影",
+22 -22
View File
@@ -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 按讚"
}
}
}
+31 -5
View File
@@ -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();
+18 -3
View File
@@ -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,