Add dynamic webcam overlay dimensions

This commit is contained in:
surim0n
2026-06-15 12:48:39 -04:00
parent 8c7d23d828
commit 1b2260ae5f
22 changed files with 419 additions and 186 deletions
+31 -4
View File
@@ -107,6 +107,7 @@ import {
} from "./videoPlayback/uploadedCursorAssets";
import { WebcamCropControl } from "./WebcamCropControl";
import {
getCropMatchedWebcamHeightPercent,
getWebcamPositionForPreset,
normalizeWebcamCropRegion,
resolveWebcamCorner,
@@ -1662,6 +1663,8 @@ 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 webcamWidth = webcam?.width ?? webcam?.size ?? DEFAULT_WEBCAM_SIZE;
const webcamHeight = webcam?.height ?? webcam?.size ?? DEFAULT_WEBCAM_SIZE;
const webcamCrop = normalizeWebcamCropRegion(webcam?.cropRegion);
const getWallpaperTileState = (candidateValue: string, previewPath?: string) => {
@@ -3874,13 +3877,24 @@ export function SettingsPanel({
/>
</div>
<SliderControl
label={tSettings("effects.webcamSize")}
value={webcam?.size ?? DEFAULT_WEBCAM_SIZE}
label={tSettings("effects.webcamWidth", "Webcam Width")}
value={webcamWidth}
defaultValue={DEFAULT_WEBCAM_SIZE}
min={10}
max={100}
step={1}
onChange={(v) => updateWebcam({ size: v })}
onChange={(v) => updateWebcam({ width: v, size: v })}
formatValue={(v) => `${Math.round(v)}%`}
parseInput={(text) => parseFloat(text.replace(/%$/, ""))}
/>
<SliderControl
label={tSettings("effects.webcamHeight", "Webcam Height")}
value={webcamHeight}
defaultValue={DEFAULT_WEBCAM_SIZE}
min={10}
max={100}
step={1}
onChange={(v) => updateWebcam({ height: v })}
formatValue={(v) => `${Math.round(v)}%`}
parseInput={(text) => parseFloat(text.replace(/%$/, ""))}
/>
@@ -3906,7 +3920,20 @@ export function SettingsPanel({
previewCurrentTime={webcamPreviewCurrentTime}
previewPlaying={webcamPreviewPlaying}
previewTimeOffsetMs={webcam?.timeOffsetMs}
onCropChange={(cropRegion) => updateWebcam({ cropRegion })}
onCropChange={(cropRegion, previewFrame) =>
updateWebcam({
cropRegion,
height: previewFrame
? getCropMatchedWebcamHeightPercent(
webcamWidth,
webcamWidth,
previewFrame.width,
previewFrame.height,
cropRegion,
)
: webcamHeight,
})
}
/>
</div>
<div className="rounded-lg bg-foreground/[0.03] px-2.5 py-2">
@@ -2340,6 +2340,14 @@ export default function VideoEditor() {
smokeExportConfig.webcamSize === undefined
? prev.size
: smokeExportConfig.webcamSize,
width:
smokeExportConfig.webcamSize === undefined
? (prev.width ?? prev.size)
: smokeExportConfig.webcamSize,
height:
smokeExportConfig.webcamSize === undefined
? (prev.height ?? prev.size)
: smokeExportConfig.webcamSize,
}));
setError(null);
return;
+32 -18
View File
@@ -182,9 +182,10 @@ import {
type MotionBlurState,
} from "./videoPlayback/zoomTransform";
import {
getCropMatchedWebcamHeightPercent,
getWebcamCropSourceRect,
getWebcamOverlayDimensionsPx,
getWebcamOverlayPosition,
getWebcamOverlaySizePx,
} from "./webcamOverlay";
type PlaybackAnimationState = {
@@ -768,7 +769,8 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
const motionBlurStateRef = useRef<MotionBlurState>(createMotionBlurState());
const webcamEnabled = webcam?.enabled ?? false;
const webcamMargin = webcam?.margin ?? 24;
const webcamSize = webcam?.size ?? DEFAULT_WEBCAM_SIZE;
const webcamWidth = webcam?.width ?? webcam?.size ?? DEFAULT_WEBCAM_SIZE;
const rawWebcamHeight = webcam?.height ?? 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;
@@ -779,6 +781,13 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
const webcamTimeOffsetMs = webcam?.timeOffsetMs;
const webcamCropRegion = webcam?.cropRegion;
const webcamMirror = webcam?.mirror ?? false;
const webcamHeight = getCropMatchedWebcamHeightPercent(
webcamWidth,
rawWebcamHeight,
webcamVideoDimensions?.width,
webcamVideoDimensions?.height,
webcamCropRegion,
);
const webcamCropPreviewContentStyle = useMemo<React.CSSProperties>(() => {
if (!webcamVideoDimensions) {
return { opacity: 0 };
@@ -789,21 +798,22 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
webcamVideoDimensions.width,
webcamVideoDimensions.height,
);
const coverScale = Math.max(1 / sw, 1 / sh);
const targetAspect = Math.max(0.01, webcamWidth) / Math.max(0.01, webcamHeight);
const coverScale = Math.max(targetAspect / sw, 1 / sh);
const drawWidth = webcamVideoDimensions.width * coverScale;
const drawHeight = webcamVideoDimensions.height * coverScale;
const drawX = (1 - sw * coverScale) / 2 - sx * coverScale;
const drawX = (targetAspect - sw * coverScale) / 2 - sx * coverScale;
const drawY = (1 - sh * coverScale) / 2 - sy * coverScale;
return {
left: `${drawX * 100}%`,
left: `${(drawX / targetAspect) * 100}%`,
top: `${drawY * 100}%`,
width: `${drawWidth * 100}%`,
width: `${(drawWidth / targetAspect) * 100}%`,
height: `${drawHeight * 100}%`,
maxWidth: "none",
willChange: "left, top, width, height",
};
}, [webcamCropRegion, webcamVideoDimensions]);
}, [webcamCropRegion, webcamHeight, webcamVideoDimensions, webcamWidth]);
const applyWebcamBubbleLayout = useCallback(
(zoomScale: number) => {
@@ -817,10 +827,11 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
return;
}
const scaledSize = getWebcamOverlaySizePx({
const scaledDimensions = getWebcamOverlayDimensionsPx({
containerWidth: overlay.clientWidth,
containerHeight: overlay.clientHeight,
sizePercent: webcamSize,
widthPercent: webcamWidth,
heightPercent: webcamHeight,
margin: webcamMargin,
zoomScale,
reactToZoom: webcamReactToZoom,
@@ -828,7 +839,8 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
const { x, y } = getWebcamOverlayPosition({
containerWidth: overlay.clientWidth,
containerHeight: overlay.clientHeight,
size: scaledSize,
width: scaledDimensions.width,
height: scaledDimensions.height,
margin: webcamMargin,
positionPreset: webcamPositionPreset,
positionX: webcamPositionX,
@@ -839,18 +851,19 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
bubble.style.display = "block";
bubble.style.left = `${x}px`;
bubble.style.top = `${y}px`;
bubble.style.width = `${scaledSize}px`;
bubble.style.height = `${scaledSize}px`;
bubble.style.aspectRatio = "1 / 1";
bubble.style.width = `${scaledDimensions.width}px`;
bubble.style.height = `${scaledDimensions.height}px`;
bubble.style.aspectRatio = `${scaledDimensions.width} / ${scaledDimensions.height}`;
const squirclePath = getSquircleSvgPath({
x: 0,
y: 0,
width: scaledSize,
height: scaledSize,
width: scaledDimensions.width,
height: scaledDimensions.height,
radius: webcamCornerRadius,
});
bubble.style.filter = `drop-shadow(0 ${Math.round(scaledSize * 0.06)}px ${Math.round(
scaledSize * 0.22,
const shadowSize = Math.min(scaledDimensions.width, scaledDimensions.height);
bubble.style.filter = `drop-shadow(0 ${Math.round(shadowSize * 0.06)}px ${Math.round(
shadowSize * 0.22,
)}px rgba(0, 0, 0, ${webcamShadow}))`;
bubble.style.borderRadius = "0px";
bubble.style.boxShadow = "none";
@@ -871,8 +884,9 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
webcamPositionY,
webcamReactToZoom,
webcamShadow,
webcamSize,
webcamHeight,
webcamVideoPath,
webcamWidth,
],
);
@@ -21,7 +21,7 @@ interface WebcamCropControlProps {
previewCurrentTime?: number;
previewPlaying?: boolean;
previewTimeOffsetMs?: number | null;
onCropChange: (cropRegion: CropRegion) => void;
onCropChange: (cropRegion: CropRegion, previewFrame?: PreviewFrame | null) => void;
}
interface DragState {
@@ -72,51 +72,23 @@ function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
function normalizeAspectCropRegion(cropRegion: CropRegion, displayAspectRatio: number): CropRegion {
function flipCropHorizontally(cropRegion: CropRegion): CropRegion {
const crop = normalizeWebcamCropRegion(cropRegion);
const aspectRatio =
Number.isFinite(displayAspectRatio) && displayAspectRatio > 0 ? displayAspectRatio : 1;
const maxWidth = Math.min(1, 1 / aspectRatio);
const minWidth = Math.min(MIN_CROP_SIZE, maxWidth);
const width = clamp(Math.min(crop.width, crop.height / aspectRatio), minWidth, maxWidth);
const height = width * aspectRatio;
const centerX = crop.x + crop.width / 2;
const centerY = crop.y + crop.height / 2;
const x = clamp(centerX - width / 2, 0, 1 - width);
const y = clamp(centerY - height / 2, 0, 1 - height);
return { x, y, width, height };
}
function flipCropHorizontally(cropRegion: CropRegion, displayAspectRatio: number): CropRegion {
const crop = normalizeAspectCropRegion(cropRegion, displayAspectRatio);
return {
...crop,
x: clamp(1 - crop.x - crop.width, 0, 1 - crop.width),
};
}
function resizeCrop(
cropRegion: CropRegion,
handle: CropHandle,
deltaX: number,
deltaY: number,
displayAspectRatio: number,
) {
const aspectRatio =
Number.isFinite(displayAspectRatio) && displayAspectRatio > 0 ? displayAspectRatio : 1;
const crop = normalizeAspectCropRegion(cropRegion, aspectRatio);
const minWidth = Math.min(MIN_CROP_SIZE, Math.min(1, 1 / aspectRatio));
function resizeCrop(cropRegion: CropRegion, handle: CropHandle, deltaX: number, deltaY: number) {
const crop = normalizeWebcamCropRegion(cropRegion);
if (handle === "move") {
return normalizeAspectCropRegion(
{
...crop,
x: clamp(crop.x + deltaX, 0, 1 - crop.width),
y: clamp(crop.y + deltaY, 0, 1 - crop.height),
},
aspectRatio,
);
return normalizeWebcamCropRegion({
...crop,
x: clamp(crop.x + deltaX, 0, 1 - crop.width),
y: clamp(crop.y + deltaY, 0, 1 - crop.height),
});
}
let left = crop.x;
@@ -124,59 +96,25 @@ function resizeCrop(
let right = crop.x + crop.width;
let bottom = crop.y + crop.height;
if (handle === "nw") {
const delta = Math.max(deltaX, deltaY / aspectRatio);
const nextWidth = clamp(
crop.width - delta,
minWidth,
Math.min(right, bottom / aspectRatio),
);
left = right - nextWidth;
top = bottom - nextWidth * aspectRatio;
if (handle === "nw" || handle === "sw") {
left = clamp(left + deltaX, 0, right - MIN_CROP_SIZE);
}
if (handle === "ne" || handle === "se") {
right = clamp(right + deltaX, left + MIN_CROP_SIZE, 1);
}
if (handle === "nw" || handle === "ne") {
top = clamp(top + deltaY, 0, bottom - MIN_CROP_SIZE);
}
if (handle === "sw" || handle === "se") {
bottom = clamp(bottom + deltaY, top + MIN_CROP_SIZE, 1);
}
if (handle === "ne") {
const delta = Math.max(deltaX, -deltaY / aspectRatio);
const nextWidth = clamp(
crop.width + delta,
minWidth,
Math.min(1 - left, bottom / aspectRatio),
);
right = left + nextWidth;
top = bottom - nextWidth * aspectRatio;
}
if (handle === "sw") {
const delta = Math.max(-deltaX, deltaY / aspectRatio);
const nextWidth = clamp(
crop.width + delta,
minWidth,
Math.min(right, (1 - top) / aspectRatio),
);
left = right - nextWidth;
bottom = top + nextWidth * aspectRatio;
}
if (handle === "se") {
const delta = Math.max(deltaX, deltaY / aspectRatio);
const nextWidth = clamp(
crop.width + delta,
minWidth,
Math.min(1 - left, (1 - top) / aspectRatio),
);
right = left + nextWidth;
bottom = top + nextWidth * aspectRatio;
}
return normalizeAspectCropRegion(
{
x: left,
y: top,
width: right - left,
height: bottom - top,
},
aspectRatio,
);
return normalizeWebcamCropRegion({
x: left,
y: top,
width: right - left,
height: bottom - top,
});
}
export function WebcamCropControl({
@@ -202,10 +140,8 @@ export function WebcamCropControl({
hasPreviewFrame && previewFrame && previewFrame.width > 0 && previewFrame.height > 0
? previewFrame.width / previewFrame.height
: 1;
const sourceCrop = normalizeAspectCropRegion(cropRegion, previewAspectRatio);
const propVisualCrop = mirrored
? flipCropHorizontally(sourceCrop, previewAspectRatio)
: sourceCrop;
const sourceCrop = normalizeWebcamCropRegion(cropRegion);
const propVisualCrop = mirrored ? flipCropHorizontally(sourceCrop) : sourceCrop;
const crop = draftVisualCrop ?? propVisualCrop;
const cropLeft = crop.x * 100;
const cropTop = crop.y * 100;
@@ -224,7 +160,7 @@ export function WebcamCropControl({
}
cancelPendingCommit();
pendingCropRef.current = null;
onCropChange(nextCrop);
onCropChange(nextCrop, previewFrame);
};
const syncPreviewMedia = useCallback(() => {
const video = previewVideoRef.current;
@@ -280,12 +216,12 @@ export function WebcamCropControl({
const commitVisualCrop = (nextVisualCrop: CropRegion, immediate = false) => {
const nextCrop = mirrored
? flipCropHorizontally(nextVisualCrop, previewAspectRatio)
: nextVisualCrop;
? flipCropHorizontally(nextVisualCrop)
: normalizeWebcamCropRegion(nextVisualCrop);
if (immediate) {
cancelPendingCommit();
pendingCropRef.current = null;
onCropChange(nextCrop);
onCropChange(nextCrop, previewFrame);
return;
}
@@ -358,7 +294,6 @@ export function WebcamCropControl({
dragState.handle,
pointer.x - dragState.startX,
pointer.y - dragState.startY,
previewAspectRatio,
);
setDraftVisualCrop(nextVisualCrop);
commitVisualCrop(nextVisualCrop);
@@ -402,7 +337,7 @@ export function WebcamCropControl({
}
event.preventDefault();
event.stopPropagation();
commitVisualCrop(resizeCrop(crop, handle, delta.x, delta.y, previewAspectRatio), true);
commitVisualCrop(resizeCrop(crop, handle, delta.x, delta.y), true);
};
return (
@@ -1028,6 +1028,16 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
? webcam.corner
: DEFAULT_WEBCAM_OVERLAY.corner,
size: isFiniteNumber(webcam.size) ? clamp(webcam.size, 10, 100) : DEFAULT_WEBCAM_SIZE,
width: isFiniteNumber(webcam.width)
? clamp(webcam.width, 10, 100)
: isFiniteNumber(webcam.size)
? clamp(webcam.size, 10, 100)
: DEFAULT_WEBCAM_SIZE,
height: isFiniteNumber(webcam.height)
? clamp(webcam.height, 10, 100)
: isFiniteNumber(webcam.size)
? clamp(webcam.size, 10, 100)
: DEFAULT_WEBCAM_SIZE,
reactToZoom:
typeof webcam.reactToZoom === "boolean"
? webcam.reactToZoom
+4
View File
@@ -136,6 +136,8 @@ export interface WebcamOverlaySettings {
positionX: number;
positionY: number;
size: number;
width: number;
height: number;
reactToZoom: boolean;
cornerRadius: number;
shadow: number;
@@ -198,6 +200,8 @@ export const DEFAULT_WEBCAM_OVERLAY: WebcamOverlaySettings = {
positionX: DEFAULT_WEBCAM_POSITION_X,
positionY: DEFAULT_WEBCAM_POSITION_Y,
size: DEFAULT_WEBCAM_SIZE,
width: DEFAULT_WEBCAM_SIZE,
height: DEFAULT_WEBCAM_SIZE,
reactToZoom: DEFAULT_WEBCAM_REACT_TO_ZOOM,
cornerRadius: DEFAULT_WEBCAM_CORNER_RADIUS,
shadow: DEFAULT_WEBCAM_SHADOW,
@@ -1,6 +1,9 @@
import { describe, expect, it } from "vitest";
import {
getCropMatchedWebcamHeightPercent,
getWebcamCropSourceRect,
getWebcamOverlayDimensionsPx,
getWebcamOverlayPosition,
isWebcamCropRegionDefault,
normalizeWebcamCropRegion,
} from "./webcamOverlay";
@@ -20,6 +23,71 @@ describe("normalizeWebcamCropRegion", () => {
});
});
describe("getWebcamOverlayDimensionsPx", () => {
it("resolves independent width and height percentages", () => {
expect(
getWebcamOverlayDimensionsPx({
containerWidth: 1000,
containerHeight: 800,
widthPercent: 50,
heightPercent: 25,
margin: 0,
zoomScale: 1,
reactToZoom: false,
}),
).toEqual({
width: 400,
height: 200,
});
});
});
describe("getWebcamOverlayPosition", () => {
it("uses rectangular dimensions when anchoring to a preset", () => {
expect(
getWebcamOverlayPosition({
containerWidth: 1000,
containerHeight: 800,
width: 400,
height: 200,
margin: 20,
positionPreset: "bottom-right",
positionX: 1,
positionY: 1,
legacyCorner: "bottom-right",
}),
).toEqual({ x: 580, y: 580 });
});
});
describe("getCropMatchedWebcamHeightPercent", () => {
it("matches height to a non-default crop aspect when width and height are linked", () => {
expect(
getCropMatchedWebcamHeightPercent(60, 60, 1920, 1080, {
x: 0.1,
y: 0.2,
width: 0.6,
height: 0.4,
}),
).toBeCloseTo(22.5);
});
it("preserves manually separated width and height controls", () => {
expect(
getCropMatchedWebcamHeightPercent(60, 45, 1920, 1080, {
x: 0.1,
y: 0.2,
width: 0.6,
height: 0.4,
}),
).toBe(45);
});
it("keeps the default crop square-compatible", () => {
expect(getCropMatchedWebcamHeightPercent(60, 60, 1920, 1080, undefined)).toBe(60);
});
});
describe("getWebcamCropSourceRect", () => {
it("converts normalized crop settings to source pixels", () => {
expect(
+81 -3
View File
@@ -78,10 +78,49 @@ export function getWebcamOverlaySizePx({
return Math.min(maxSize, Math.max(MIN_WEBCAM_OVERLAY_SIZE_PX, scaledSize));
}
export function getWebcamOverlayDimensionsPx({
containerWidth,
containerHeight,
widthPercent,
heightPercent,
margin,
zoomScale,
reactToZoom,
}: {
containerWidth: number;
containerHeight: number;
widthPercent: number;
heightPercent: number;
margin: number;
zoomScale: number;
reactToZoom: boolean;
}): { width: number; height: number } {
return {
width: getWebcamOverlaySizePx({
containerWidth,
containerHeight,
sizePercent: widthPercent,
margin,
zoomScale,
reactToZoom,
}),
height: getWebcamOverlaySizePx({
containerWidth,
containerHeight,
sizePercent: heightPercent,
margin,
zoomScale,
reactToZoom,
}),
};
}
export function getWebcamOverlayPosition({
containerWidth,
containerHeight,
size,
width,
height,
margin,
positionPreset,
positionX,
@@ -90,7 +129,9 @@ export function getWebcamOverlayPosition({
}: {
containerWidth: number;
containerHeight: number;
size: number;
size?: number;
width?: number;
height?: number;
margin: number;
positionPreset: WebcamPositionPreset;
positionX: number;
@@ -98,8 +139,10 @@ export function getWebcamOverlayPosition({
legacyCorner: WebcamCorner;
}): { x: number; y: number } {
const safeMargin = Math.max(0, margin);
const availableWidth = Math.max(0, containerWidth - size - safeMargin * 2);
const availableHeight = Math.max(0, containerHeight - size - safeMargin * 2);
const overlayWidth = Math.max(0, width ?? size ?? 0);
const overlayHeight = Math.max(0, height ?? size ?? overlayWidth);
const availableWidth = Math.max(0, containerWidth - overlayWidth - safeMargin * 2);
const availableHeight = Math.max(0, containerHeight - overlayHeight - safeMargin * 2);
const presetPosition =
positionPreset === "custom"
? { x: clamp(positionX, 0, 1), y: clamp(positionY, 0, 1) }
@@ -151,3 +194,38 @@ export function getWebcamCropSourceRect(
return { sx, sy, sw, sh };
}
export function getCropMatchedWebcamHeightPercent(
widthPercent: number,
heightPercent: number,
sourceWidth: number | null | undefined,
sourceHeight: number | null | undefined,
cropRegion: Partial<CropRegion> | null | undefined,
): number {
const safeWidthPercent = Number.isFinite(widthPercent) ? widthPercent : 40;
const safeHeightPercent = Number.isFinite(heightPercent) ? heightPercent : safeWidthPercent;
if (Math.abs(safeWidthPercent - safeHeightPercent) > 0.001) {
return clamp(safeHeightPercent, 10, 100);
}
const crop = normalizeWebcamCropRegion(cropRegion);
if (crop.x <= 0 && crop.y <= 0 && crop.width >= 1 && crop.height >= 1) {
return clamp(safeHeightPercent, 10, 100);
}
const sourceAspect =
Number.isFinite(sourceWidth) &&
Number.isFinite(sourceHeight) &&
sourceWidth != null &&
sourceHeight != null &&
sourceWidth > 0 &&
sourceHeight > 0
? sourceWidth / sourceHeight
: 1;
const cropAspect = (crop.width * sourceAspect) / Math.max(0.001, crop.height);
if (!Number.isFinite(cropAspect) || cropAspect <= 0) {
return clamp(safeHeightPercent, 10, 100);
}
return clamp(safeWidthPercent / cropAspect, 10, 100);
}
+2
View File
@@ -136,6 +136,8 @@
"webcamFootageAdded": "Webcam footage linked",
"webcamFootageRemoved": "Webcam footage removed",
"webcamSize": "Webcam Size",
"webcamWidth": "Webcam Width",
"webcamHeight": "Webcam Height",
"webcamCrop": "Webcam Crop",
"webcamReactToZoom": "Webcam Reacts To Zoom",
"webcamMirror": "Mirror webcam",
+2
View File
@@ -88,6 +88,8 @@
"webcamFootageAdded": "Metraje de cámara vinculado",
"webcamFootageRemoved": "Metraje de cámara eliminado",
"webcamSize": "Tamaño de cámara",
"webcamWidth": "Ancho de cámara",
"webcamHeight": "Alto de cámara",
"webcamCrop": "Recorte de cámara",
"webcamReactToZoom": "La cámara reacciona al zoom",
"webcamMirror": "Reflejar cámara",
+2
View File
@@ -88,6 +88,8 @@
"webcamFootageAdded": "Vidéo de webcam liée",
"webcamFootageRemoved": "Vidéo de webcam supprimée",
"webcamSize": "Taille de la webcam",
"webcamWidth": "Largeur de la webcam",
"webcamHeight": "Hauteur de la webcam",
"webcamCrop": "Recadrage de la webcam",
"webcamReactToZoom": "La webcam réagit au zoom",
"webcamMirror": "Miroir webcam",
+2
View File
@@ -110,6 +110,8 @@
"webcamFootageAdded": "Filmato webcam collegato",
"webcamFootageRemoved": "Filmato webcam rimosso",
"webcamSize": "Dimensione webcam",
"webcamWidth": "Larghezza webcam",
"webcamHeight": "Altezza webcam",
"webcamCrop": "Ritaglio webcam",
"webcamReactToZoom": "La webcam reagisce allo zoom",
"webcamMirror": "Specchia webcam",
+2
View File
@@ -88,6 +88,8 @@
"webcamFootageAdded": "웹캠 영상을 연결했습니다",
"webcamFootageRemoved": "웹캠 영상을 제거했습니다",
"webcamSize": "웹캠 크기",
"webcamWidth": "웹캠 너비",
"webcamHeight": "웹캠 높이",
"webcamCrop": "웹캠 자르기",
"webcamReactToZoom": "확대 시 웹캠 반응",
"webcamMirror": "웹캠 미러링",
+2
View File
@@ -88,6 +88,8 @@
"webcamFootageAdded": "Webcambeelden gekoppeld",
"webcamFootageRemoved": "Webcambeelden verwijderd",
"webcamSize": "Webcamgrootte",
"webcamWidth": "Webcambreedte",
"webcamHeight": "Webcamhoogte",
"webcamCrop": "Webcam bijsnijden",
"webcamReactToZoom": "Webcam reageert op zoom",
"webcamMirror": "Webcam spiegelen",
+2
View File
@@ -88,6 +88,8 @@
"webcamFootageAdded": "Filmagem da webcam vinculada",
"webcamFootageRemoved": "Filmagem da webcam removida",
"webcamSize": "Tamanho da webcam",
"webcamWidth": "Largura da webcam",
"webcamHeight": "Altura da webcam",
"webcamCrop": "Corte da webcam",
"webcamReactToZoom": "Webcam reage ao zoom",
"webcamMirror": "Espelhar webcam",
+3 -1
View File
@@ -110,6 +110,8 @@
"webcamFootageAdded": "Видео добавлено",
"webcamFootageRemoved": "Видео удалено",
"webcamSize": "Размер веб-камеры",
"webcamWidth": "Ширина веб-камеры",
"webcamHeight": "Высота веб-камеры",
"webcamCrop": "Обрезка веб-камеры",
"webcamReactToZoom": "Веб-камера реагирует на зум",
"webcamMirror": "Отразить веб-камеру",
@@ -216,4 +218,4 @@
"mixedLabel": "Источник",
"deleteRegion": "Удалить аудио"
}
}
}
+2
View File
@@ -105,6 +105,8 @@
"webcamFootageAdded": "已关联摄像头素材",
"webcamFootageRemoved": "已移除摄像头素材",
"webcamSize": "摄像头大小",
"webcamWidth": "摄像头宽度",
"webcamHeight": "摄像头高度",
"webcamCrop": "摄像头裁剪",
"webcamReactToZoom": "摄像头随缩放变化",
"webcamMirror": "镜像摄像头",
+2
View File
@@ -88,6 +88,8 @@
"webcamFootageAdded": "已連結網路攝影機素材",
"webcamFootageRemoved": "已移除網路攝影機素材",
"webcamSize": "網路攝影機大小",
"webcamWidth": "網路攝影機寬度",
"webcamHeight": "網路攝影機高度",
"webcamCrop": "網路攝影機裁剪",
"webcamReactToZoom": "網路攝影機跟隨縮放",
"webcamMirror": "鏡像網路攝影機",
+59 -41
View File
@@ -5,8 +5,8 @@ import type {
AnnotationRegion,
AutoCaptionSettings,
CaptionCue,
CursorClickEffectStyle,
CropRegion,
CursorClickEffectStyle,
CursorStyle,
CursorTelemetryPoint,
Padding,
@@ -51,9 +51,10 @@ import {
type MotionBlurState,
} from "@/components/video-editor/videoPlayback/zoomTransform";
import {
getCropMatchedWebcamHeightPercent,
getWebcamCropSourceRect,
getWebcamOverlayDimensionsPx,
getWebcamOverlayPosition,
getWebcamOverlaySizePx,
} from "@/components/video-editor/webcamOverlay";
import { getAssetPath, getExportableVideoUrl, getRenderableAssetUrl } from "@/lib/assetPath";
import { extensionHost } from "@/lib/extensions";
@@ -2405,33 +2406,7 @@ export class FrameRenderer {
return;
}
const margin = webcam.margin ?? 24;
const size = getWebcamOverlaySizePx({
containerWidth: width,
containerHeight: height,
sizePercent: webcam.size ?? 50,
margin,
zoomScale: this.animationState.appliedScale || 1,
reactToZoom: webcam.reactToZoom ?? true,
});
const { x, y } = getWebcamOverlayPosition({
containerWidth: width,
containerHeight: height,
size,
margin,
positionPreset: webcam.positionPreset ?? webcam.corner,
positionX: webcam.positionX ?? 1,
positionY: webcam.positionY ?? 1,
legacyCorner: webcam.corner,
});
const radius = Math.max(0, webcam.cornerRadius ?? 18);
const bubbleCanvas = this.webcamBubbleCanvas ?? document.createElement("canvas");
const bubbleSize = Math.max(1, Math.ceil(size));
if (bubbleCanvas.width !== bubbleSize || bubbleCanvas.height !== bubbleSize) {
bubbleCanvas.width = bubbleSize;
bubbleCanvas.height = bubbleSize;
}
this.webcamBubbleCanvas = bubbleCanvas;
const bubbleCtx =
this.webcamBubbleCtx ?? configureHighQuality2DContext(bubbleCanvas.getContext("2d"));
@@ -2439,9 +2414,6 @@ export class FrameRenderer {
return;
}
this.webcamBubbleCtx = bubbleCtx;
bubbleCtx.clearRect(0, 0, bubbleCanvas.width, bubbleCanvas.height);
bubbleCtx.imageSmoothingEnabled = true;
bubbleCtx.imageSmoothingQuality = "high";
const expectedWebcamTargetTime = getWebcamMediaTargetTimeSeconds({
currentTime: this.currentVideoTime,
@@ -2507,30 +2479,75 @@ export class FrameRenderer {
? webcamFrameSource.displayWidth
: "videoWidth" in webcamFrameSource
? webcamFrameSource.videoWidth
: webcamFrameSource.width) || size;
: webcamFrameSource.width) || 1;
const sourceHeight =
("displayHeight" in webcamFrameSource
? webcamFrameSource.displayHeight
: "videoHeight" in webcamFrameSource
? webcamFrameSource.videoHeight
: webcamFrameSource.height) || size;
: webcamFrameSource.height) || sourceWidth;
const margin = webcam.margin ?? 24;
const widthPercent = webcam.width ?? webcam.size ?? 50;
const heightPercent = getCropMatchedWebcamHeightPercent(
widthPercent,
webcam.height ?? webcam.size ?? 50,
sourceWidth,
sourceHeight,
webcam.cropRegion,
);
const dimensions = getWebcamOverlayDimensionsPx({
containerWidth: width,
containerHeight: height,
widthPercent,
heightPercent,
margin,
zoomScale: this.animationState.appliedScale || 1,
reactToZoom: webcam.reactToZoom ?? true,
});
const { x, y } = getWebcamOverlayPosition({
containerWidth: width,
containerHeight: height,
width: dimensions.width,
height: dimensions.height,
margin,
positionPreset: webcam.positionPreset ?? webcam.corner,
positionX: webcam.positionX ?? 1,
positionY: webcam.positionY ?? 1,
legacyCorner: webcam.corner,
});
const radius = Math.max(0, webcam.cornerRadius ?? 18);
const bubbleWidth = Math.max(1, Math.ceil(dimensions.width));
const bubbleHeight = Math.max(1, Math.ceil(dimensions.height));
if (bubbleCanvas.width !== bubbleWidth || bubbleCanvas.height !== bubbleHeight) {
bubbleCanvas.width = bubbleWidth;
bubbleCanvas.height = bubbleHeight;
}
bubbleCtx.clearRect(0, 0, bubbleCanvas.width, bubbleCanvas.height);
bubbleCtx.imageSmoothingEnabled = true;
bubbleCtx.imageSmoothingQuality = "high";
const { sx, sy, sw, sh } = getWebcamCropSourceRect(
webcam.cropRegion,
sourceWidth,
sourceHeight,
);
const coverScale = Math.max(size / sw, size / sh);
const coverScale = Math.max(dimensions.width / sw, dimensions.height / sh);
const drawWidth = sw * coverScale;
const drawHeight = sh * coverScale;
const drawX = (size - drawWidth) / 2;
const drawY = (size - drawHeight) / 2;
const drawX = (dimensions.width - drawWidth) / 2;
const drawY = (dimensions.height - drawHeight) / 2;
bubbleCtx.save();
drawSquircleOnCanvas(bubbleCtx, { x: 0, y: 0, width: size, height: size, radius });
drawSquircleOnCanvas(bubbleCtx, {
x: 0,
y: 0,
width: dimensions.width,
height: dimensions.height,
radius,
});
bubbleCtx.clip();
if (webcam.mirror) {
bubbleCtx.save();
bubbleCtx.translate(size, 0);
bubbleCtx.translate(dimensions.width, 0);
bubbleCtx.scale(-1, 1);
bubbleCtx.drawImage(
webcamFrameSource,
@@ -2561,14 +2578,15 @@ export class FrameRenderer {
if ((webcam.shadow ?? 0) > 0) {
const shadow = Math.max(0, Math.min(1, webcam.shadow));
const shadowSize = Math.min(dimensions.width, dimensions.height);
ctx.save();
ctx.filter = `drop-shadow(0 ${Math.round(size * 0.06)}px ${Math.round(size * 0.22)}px rgba(0,0,0,${shadow}))`;
ctx.drawImage(bubbleCanvas, x, y, size, size);
ctx.filter = `drop-shadow(0 ${Math.round(shadowSize * 0.06)}px ${Math.round(shadowSize * 0.22)}px rgba(0,0,0,${shadow}))`;
ctx.drawImage(bubbleCanvas, x, y, dimensions.width, dimensions.height);
ctx.restore();
return;
}
ctx.drawImage(bubbleCanvas, x, y, size, size);
ctx.drawImage(bubbleCanvas, x, y, dimensions.width, dimensions.height);
}
private closeWebcamDecodedFrame(): void {
+33 -18
View File
@@ -15,8 +15,8 @@ import type {
AnnotationRegion,
AutoCaptionSettings,
CaptionCue,
CursorClickEffectStyle,
CropRegion,
CursorClickEffectStyle,
CursorStyle,
CursorTelemetryPoint,
Padding,
@@ -57,9 +57,10 @@ import {
type MotionBlurState,
} from "@/components/video-editor/videoPlayback/zoomTransform";
import {
getCropMatchedWebcamHeightPercent,
getWebcamCropSourceRect,
getWebcamOverlayDimensionsPx,
getWebcamOverlayPosition,
getWebcamOverlaySizePx,
isWebcamCropRegionDefault,
} from "@/components/video-editor/webcamOverlay";
import { getAssetPath, getExportableVideoUrl, getRenderableAssetUrl } from "@/lib/assetPath";
@@ -211,7 +212,8 @@ interface WebcamRenderSource {
interface WebcamLayoutCache {
sourceWidth: number;
sourceHeight: number;
size: number;
width: number;
height: number;
positionX: number;
positionY: number;
radius: number;
@@ -2604,7 +2606,8 @@ export class FrameRenderer {
previousLayout.mirror === nextLayout.mirror &&
areNearlyEqual(previousLayout.sourceWidth, nextLayout.sourceWidth) &&
areNearlyEqual(previousLayout.sourceHeight, nextLayout.sourceHeight) &&
areNearlyEqual(previousLayout.size, nextLayout.size) &&
areNearlyEqual(previousLayout.width, nextLayout.width) &&
areNearlyEqual(previousLayout.height, nextLayout.height) &&
areNearlyEqual(previousLayout.positionX, nextLayout.positionX) &&
areNearlyEqual(previousLayout.positionY, nextLayout.positionY) &&
areNearlyEqual(previousLayout.radius, nextLayout.radius) &&
@@ -2623,10 +2626,10 @@ export class FrameRenderer {
this.webcamSprite,
nextLayout.sourceWidth,
nextLayout.sourceHeight,
nextLayout.size,
nextLayout.size,
nextLayout.size / 2,
nextLayout.size / 2,
nextLayout.width,
nextLayout.height,
nextLayout.width / 2,
nextLayout.height / 2,
nextLayout.mirror,
);
@@ -2634,8 +2637,8 @@ export class FrameRenderer {
drawSquircleOnGraphics(this.webcamMaskGraphics, {
x: 0,
y: 0,
width: nextLayout.size,
height: nextLayout.size,
width: nextLayout.width,
height: nextLayout.height,
radius: nextLayout.radius,
});
this.webcamMaskGraphics.fill({ color: 0xffffff });
@@ -2646,16 +2649,17 @@ export class FrameRenderer {
continue;
}
const offsetY = nextLayout.size * layer.offsetScale * nextLayout.shadowStrength;
const shadowSize = Math.min(nextLayout.width, nextLayout.height);
const offsetY = shadowSize * layer.offsetScale * nextLayout.shadowStrength;
this.rasterizeShadowLayer(layer, {
x: 0,
y: 0,
width: nextLayout.size,
height: nextLayout.size,
width: nextLayout.width,
height: nextLayout.height,
radius: nextLayout.radius,
offsetY,
alpha: layer.alphaScale * nextLayout.shadowStrength,
blur: Math.max(0, nextLayout.size * layer.blurScale * nextLayout.shadowStrength),
blur: Math.max(0, shadowSize * layer.blurScale * nextLayout.shadowStrength),
});
}
@@ -2904,10 +2908,19 @@ export class FrameRenderer {
}
const margin = webcam.margin ?? 24;
const size = getWebcamOverlaySizePx({
const widthPercent = webcam.width ?? webcam.size ?? 50;
const heightPercent = getCropMatchedWebcamHeightPercent(
widthPercent,
webcam.height ?? webcam.size ?? 50,
renderableWebcamSource.width,
renderableWebcamSource.height,
webcam.cropRegion,
);
const dimensions = getWebcamOverlayDimensionsPx({
containerWidth: this.config.width,
containerHeight: this.config.height,
sizePercent: webcam.size ?? 50,
widthPercent,
heightPercent,
margin,
zoomScale: this.animationState.appliedScale || 1,
reactToZoom: webcam.reactToZoom ?? true,
@@ -2915,7 +2928,8 @@ export class FrameRenderer {
const position = getWebcamOverlayPosition({
containerWidth: this.config.width,
containerHeight: this.config.height,
size,
width: dimensions.width,
height: dimensions.height,
margin,
positionPreset: webcam.positionPreset ?? webcam.corner,
positionX: webcam.positionX ?? 1,
@@ -2930,7 +2944,8 @@ export class FrameRenderer {
const nextLayout: WebcamLayoutCache = {
sourceWidth: renderableWebcamSource.width,
sourceHeight: renderableWebcamSource.height,
size,
width: dimensions.width,
height: dimensions.height,
positionX: position.x,
positionY: position.y,
radius,
@@ -708,6 +708,28 @@ describe("ModernVideoExporter native static-layout eligibility", () => {
).toBeNull();
});
it("skips native static layout for rectangular webcam overlays", () => {
const exporter = createExporter({
webcam: {
enabled: true,
sourcePath: "C:\\recordly\\webcam.mp4",
width: 60,
height: 35,
},
});
expect(
exporter.getNativeStaticLayoutSkipReason(
{
audioMode: "edited-track",
strategy: "offline-render-fallback",
},
videoInfo,
60,
),
).toBe("unsupported-rectangular-webcam-overlay");
});
it("allows native speed timelines with a resolvable webcam source", () => {
const speedRegions: SpeedRegion[] = [
{ id: "speed-1", startMs: 1_000, endMs: 4_000, speed: 1.5 },
+17 -3
View File
@@ -41,6 +41,7 @@ import {
import {
getWebcamOverlayPosition,
getWebcamOverlaySizePx,
isWebcamCropRegionDefault,
} from "@/components/video-editor/webcamOverlay";
import { extensionHost } from "@/lib/extensions";
import { getEffectiveVideoStreamDurationSeconds } from "@/lib/mediaTiming";
@@ -1495,6 +1496,17 @@ export class ModernVideoExporter {
}
}
private hasUnsupportedNativeStaticLayoutWebcamShape(): boolean {
const webcam = this.config.webcam;
if (!webcam?.enabled) {
return false;
}
const width = webcam.width ?? webcam.size ?? 40;
const height = webcam.height ?? webcam.size ?? 40;
return Math.abs(width - height) > 0.001 || !isWebcamCropRegionDefault(webcam.cropRegion);
}
private getNativeStaticLayoutSkipReasons(
audioPlan: NativeAudioPlan,
videoInfo: DecodedVideoInfo,
@@ -1560,6 +1572,9 @@ export class ModernVideoExporter {
if (this.config.webcam?.enabled && !this.getNativeWebcamSourcePath()) {
reasons.push("unsupported-webcam-source");
}
if (this.hasUnsupportedNativeStaticLayoutWebcamShape()) {
reasons.push("unsupported-rectangular-webcam-overlay");
}
if (this.config.frame) {
reasons.push("unsupported-frame-overlay");
@@ -2021,7 +2036,7 @@ export class ModernVideoExporter {
const rawSize = getWebcamOverlaySizePx({
containerWidth: this.config.width,
containerHeight: this.config.height,
sizePercent: webcam.size ?? 40,
sizePercent: webcam.width ?? webcam.size ?? 40,
margin,
zoomScale: 1,
reactToZoom: webcam.reactToZoom ?? true,
@@ -2533,8 +2548,7 @@ export class ModernVideoExporter {
timelineSegments,
chunkDurationSec: STATIC_LAYOUT_CHUNK_DURATION_SEC,
experimentalWindowsGpuCompositor: this.config.experimentalNativeExport === true,
experimentalNvidiaCudaExport:
this.config.experimentalNvidiaCudaExport === true,
experimentalNvidiaCudaExport: this.config.experimentalNvidiaCudaExport === true,
audioOptions: {
...audioOptions,
outputDurationSec: effectiveDuration,