Stabilize webcam crop preview

This commit is contained in:
wizardAEI
2026-05-01 09:55:52 +08:00
parent bb34721677
commit 27754223cc
2 changed files with 118 additions and 187 deletions
+68 -182
View File
@@ -360,11 +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 webcamFrameReadyRef = useRef(false);
const [webcamVideoDimensions, setWebcamVideoDimensions] = useState<{
width: number;
height: number;
} | null>(null);
const captionBoxRef = useRef<HTMLDivElement | null>(null);
const currentTimeRef = useRef(0);
const zoomRegionsRef = useRef<ZoomRegion[]>([]);
@@ -529,6 +530,31 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
const webcamTimeOffsetMs = webcam?.timeOffsetMs;
const webcamCropRegion = webcam?.cropRegion;
const webcamMirror = webcam?.mirror ?? false;
const webcamCropPreviewContentStyle = useMemo<React.CSSProperties>(() => {
if (!webcamVideoDimensions) {
return { opacity: 0 };
}
const { sx, sy, sw, sh } = getWebcamCropSourceRect(
webcamCropRegion,
webcamVideoDimensions.width,
webcamVideoDimensions.height,
);
const coverScale = Math.max(1 / sw, 1 / sh);
const drawWidth = webcamVideoDimensions.width * coverScale;
const drawHeight = webcamVideoDimensions.height * coverScale;
const drawX = (1 - sw * coverScale) / 2 - sx * coverScale;
const drawY = (1 - sh * coverScale) / 2 - sy * coverScale;
return {
left: `${drawX * 100}%`,
top: `${drawY * 100}%`,
width: `${drawWidth * 100}%`,
height: `${drawHeight * 100}%`,
maxWidth: "none",
willChange: "left, top, width, height",
};
}, [webcamCropRegion, webcamVideoDimensions]);
const applyWebcamBubbleLayout = useCallback(
(zoomScale: number) => {
@@ -566,6 +592,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
bubble.style.top = `${y}px`;
bubble.style.width = `${scaledSize}px`;
bubble.style.height = `${scaledSize}px`;
bubble.style.aspectRatio = "1 / 1";
const squirclePath = getSquircleSvgPath({
x: 0,
y: 0,
@@ -579,7 +606,9 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
bubble.style.borderRadius = "0px";
bubble.style.boxShadow = "none";
bubbleInner.style.borderRadius = "0px";
bubbleInner.style.borderRadius = `${webcamCornerRadius}px`;
bubbleInner.style.overflow = "hidden";
bubbleInner.style.contain = "paint";
bubbleInner.style.clipPath = `path('${squirclePath}')`;
bubbleInner.style.setProperty("-webkit-clip-path", `path('${squirclePath}')`);
},
@@ -1358,174 +1387,29 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
lastWebcamSyncTimeRef.current = targetTime;
}, [currentTime, isPlaying, webcamEnabled, webcamTimeOffsetMs, webcamVideoPath]);
const handleWebcamMediaReady = useCallback(() => {
syncWebcamMedia();
}, [syncWebcamMedia]);
const handleWebcamMediaReady = useCallback(
(event: React.SyntheticEvent<HTMLVideoElement>) => {
const video = event.currentTarget;
if (video.videoWidth > 0 && video.videoHeight > 0) {
setWebcamVideoDimensions({
width: video.videoWidth,
height: video.videoHeight,
});
}
syncWebcamMedia();
},
[syncWebcamMedia],
);
useEffect(() => {
syncWebcamMedia();
}, [syncWebcamMedia]);
useEffect(() => {
webcamFrameReadyRef.current = false;
setWebcamFrameReady(false);
setWebcamVideoDimensions(null);
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 pendingOneOffDrawFrame: number | null = null;
let oneOffDrawAttempts = 0;
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;
}
if (videoFrameCallback !== null) {
return;
}
videoFrameCallback = webcamVideo.requestVideoFrameCallback(() => {
videoFrameCallback = null;
drawWebcamFrame(true);
queueVideoFrameCallback();
});
};
const scheduleOneOffDraw = () => {
if (pendingOneOffDrawFrame !== null) {
return;
}
pendingOneOffDrawFrame = requestAnimationFrame(() => {
pendingOneOffDrawFrame = null;
const didDraw = drawWebcamFrame(true);
if (!didDraw && !webcamFrameReadyRef.current && oneOffDrawAttempts < 30) {
oneOffDrawAttempts += 1;
scheduleOneOffDraw();
return;
}
oneOffDrawAttempts = 0;
});
};
const drawOnAnimationFrame = () => {
drawWebcamFrame(true);
animationFrame = requestAnimationFrame(drawOnAnimationFrame);
};
const handleDrawableMediaEvent = () => {
syncWebcamMedia();
if (hasVideoFrameCallback) {
if (animationFrame !== null) {
cancelAnimationFrame(animationFrame);
animationFrame = null;
}
scheduleOneOffDraw();
queueVideoFrameCallback();
return;
}
if (animationFrame !== null) {
cancelAnimationFrame(animationFrame);
}
animationFrame = requestAnimationFrame(drawOnAnimationFrame);
};
webcamVideo.addEventListener("loadeddata", handleDrawableMediaEvent);
webcamVideo.addEventListener("canplay", handleDrawableMediaEvent);
webcamVideo.addEventListener("seeked", handleDrawableMediaEvent);
if (hasVideoFrameCallback) {
scheduleOneOffDraw();
queueVideoFrameCallback();
} else {
drawOnAnimationFrame();
}
return () => {
if (animationFrame !== null) {
cancelAnimationFrame(animationFrame);
}
if (pendingOneOffDrawFrame !== null) {
cancelAnimationFrame(pendingOneOffDrawFrame);
}
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]);
@@ -2543,28 +2427,30 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
ref={webcamBubbleInnerRef}
className="relative h-full w-full overflow-hidden"
>
<video
ref={webcamVideoRef}
src={webcamVideoPath}
className="pointer-events-none absolute inset-0 h-full w-full object-cover"
muted
playsInline
preload="auto"
aria-hidden="true"
<div
className="pointer-events-none absolute inset-0 overflow-hidden"
style={{
opacity: 0,
opacity: webcamVideoDimensions ? 1 : 0,
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
className="pointer-events-none absolute"
style={webcamCropPreviewContentStyle}
>
<video
ref={webcamVideoRef}
src={webcamVideoPath}
className="pointer-events-none absolute inset-0 block h-full w-full object-fill"
muted
playsInline
preload="auto"
aria-hidden="true"
onLoadedMetadata={handleWebcamMediaReady}
onLoadedData={handleWebcamMediaReady}
/>
</div>
</div>
</div>
</div>
) : null}
@@ -1,4 +1,4 @@
import { useId, useRef, useState } from "react";
import { useEffect, useId, useRef, useState } from "react";
import { cn } from "@/lib/utils";
import type { CropRegion } from "./types";
import { normalizeWebcamCropRegion } from "./webcamOverlay";
@@ -137,17 +137,59 @@ export function WebcamCropControl({
}: WebcamCropControlProps) {
const containerRef = useRef<HTMLDivElement | null>(null);
const dragStateRef = useRef<DragState | null>(null);
const pendingCropRef = useRef<CropRegion | null>(null);
const pendingFrameRef = useRef<number | null>(null);
const maskId = `webcam-crop-mask-${useId().replace(/:/g, "")}`;
const [activeHandle, setActiveHandle] = useState<CropHandle | null>(null);
const [draftVisualCrop, setDraftVisualCrop] = useState<CropRegion | null>(null);
const sourceCrop = normalizeSquareCropRegion(cropRegion);
const crop = mirrored ? flipCropHorizontally(sourceCrop) : sourceCrop;
const propVisualCrop = mirrored ? flipCropHorizontally(sourceCrop) : sourceCrop;
const crop = draftVisualCrop ?? propVisualCrop;
const cropLeft = crop.x * 100;
const cropTop = crop.y * 100;
const cropWidth = crop.width * 100;
const cropHeight = crop.height * 100;
const commitVisualCrop = (nextVisualCrop: CropRegion) => {
onCropChange(mirrored ? flipCropHorizontally(nextVisualCrop) : nextVisualCrop);
const cancelPendingCommit = () => {
if (pendingFrameRef.current !== null) {
cancelAnimationFrame(pendingFrameRef.current);
pendingFrameRef.current = null;
}
};
const flushPendingCommit = () => {
const nextCrop = pendingCropRef.current;
if (!nextCrop) {
return;
}
cancelPendingCommit();
pendingCropRef.current = null;
onCropChange(nextCrop);
};
const commitVisualCrop = (nextVisualCrop: CropRegion, immediate = false) => {
const nextCrop = mirrored ? flipCropHorizontally(nextVisualCrop) : nextVisualCrop;
if (immediate) {
cancelPendingCommit();
pendingCropRef.current = null;
onCropChange(nextCrop);
return;
}
pendingCropRef.current = nextCrop;
if (pendingFrameRef.current !== null) {
return;
}
pendingFrameRef.current = requestAnimationFrame(() => {
pendingFrameRef.current = null;
flushPendingCommit();
});
};
useEffect(() => {
return () => {
if (pendingFrameRef.current !== null) {
cancelAnimationFrame(pendingFrameRef.current);
}
};
}, []);
const getPointerPosition = (event: React.PointerEvent<HTMLDivElement>) => {
const rect = containerRef.current?.getBoundingClientRect();
@@ -201,6 +243,7 @@ export function WebcamCropControl({
pointer.x - dragState.startX,
pointer.y - dragState.startY,
);
setDraftVisualCrop(nextVisualCrop);
commitVisualCrop(nextVisualCrop);
};
@@ -212,7 +255,9 @@ export function WebcamCropControl({
/* Pointer capture may already be released while ending the drag. */
}
}
flushPendingCommit();
dragStateRef.current = null;
setDraftVisualCrop(null);
setActiveHandle(null);
};
@@ -240,7 +285,7 @@ export function WebcamCropControl({
}
event.preventDefault();
event.stopPropagation();
commitVisualCrop(resizeCrop(crop, handle, delta.x, delta.y));
commitVisualCrop(resizeCrop(crop, handle, delta.x, delta.y), true);
};
return (