mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-27 16:25:35 +00:00
Add webcam crop and mirror controls
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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" ||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -85,7 +85,9 @@
|
||||
"webcamFootageAdded": "웹캠 영상을 연결했습니다",
|
||||
"webcamFootageRemoved": "웹캠 영상을 제거했습니다",
|
||||
"webcamSize": "웹캠 크기",
|
||||
"webcamCrop": "웹캠 자르기",
|
||||
"webcamReactToZoom": "확대 시 웹캠 반응",
|
||||
"webcamMirror": "웹캠 미러링",
|
||||
"webcamRoundness": "웹캠 둥글기",
|
||||
"webcamShadow": "웹캠 그림자",
|
||||
"shadow": "그림자",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -85,7 +85,9 @@
|
||||
"webcamFootageAdded": "已关联摄像头素材",
|
||||
"webcamFootageRemoved": "已移除摄像头素材",
|
||||
"webcamSize": "摄像头大小",
|
||||
"webcamCrop": "摄像头裁剪",
|
||||
"webcamReactToZoom": "摄像头随缩放变化",
|
||||
"webcamMirror": "镜像摄像头",
|
||||
"webcamRoundness": "摄像头圆角",
|
||||
"webcamShadow": "摄像头阴影",
|
||||
"shadow": "阴影",
|
||||
|
||||
@@ -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 按讚"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user