From 1b2260ae5fd519e91b5224fb81337cccc130e6f7 Mon Sep 17 00:00:00 2001 From: surim0n Date: Mon, 15 Jun 2026 12:48:39 -0400 Subject: [PATCH] Add dynamic webcam overlay dimensions --- src/components/video-editor/SettingsPanel.tsx | 35 ++++- src/components/video-editor/VideoEditor.tsx | 8 ++ src/components/video-editor/VideoPlayback.tsx | 50 ++++--- .../video-editor/WebcamCropControl.tsx | 125 +++++------------- .../video-editor/projectPersistence.ts | 10 ++ src/components/video-editor/types.ts | 4 + .../video-editor/webcamOverlay.test.ts | 68 ++++++++++ src/components/video-editor/webcamOverlay.ts | 84 +++++++++++- src/i18n/locales/en/settings.json | 2 + src/i18n/locales/es/settings.json | 2 + src/i18n/locales/fr/settings.json | 2 + src/i18n/locales/it/settings.json | 2 + src/i18n/locales/ko/settings.json | 2 + src/i18n/locales/nl/settings.json | 2 + src/i18n/locales/pt-BR/settings.json | 2 + src/i18n/locales/ru/settings.json | 4 +- src/i18n/locales/zh-CN/settings.json | 2 + src/i18n/locales/zh-TW/settings.json | 2 + src/lib/exporter/frameRenderer.ts | 100 ++++++++------ src/lib/exporter/modernFrameRenderer.ts | 51 ++++--- ...rnVideoExporter.nativeStaticLayout.test.ts | 22 +++ src/lib/exporter/modernVideoExporter.ts | 20 ++- 22 files changed, 416 insertions(+), 183 deletions(-) diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index 1fa526616..f9aeea3b3 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -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({ /> updateWebcam({ size: v })} + onChange={(v) => updateWebcam({ width: v, size: v })} + formatValue={(v) => `${Math.round(v)}%`} + parseInput={(text) => parseFloat(text.replace(/%$/, ""))} + /> + 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, + }) + } />
diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index f18b931f7..d92817152 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -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; diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx index 488bc3a9d..780feee11 100644 --- a/src/components/video-editor/VideoPlayback.tsx +++ b/src/components/video-editor/VideoPlayback.tsx @@ -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( const motionBlurStateRef = useRef(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( 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(() => { if (!webcamVideoDimensions) { return { opacity: 0 }; @@ -789,21 +798,22 @@ const VideoPlayback = forwardRef( 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( 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( 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( 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( webcamPositionY, webcamReactToZoom, webcamShadow, - webcamSize, + webcamHeight, webcamVideoPath, + webcamWidth, ], ); diff --git a/src/components/video-editor/WebcamCropControl.tsx b/src/components/video-editor/WebcamCropControl.tsx index 6cbb3ca97..10e5e6ef1 100644 --- a/src/components/video-editor/WebcamCropControl.tsx +++ b/src/components/video-editor/WebcamCropControl.tsx @@ -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") { - 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 === "ne" || handle === "se") { + right = clamp(right + deltaX, left + MIN_CROP_SIZE, 1); } - - 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 === "nw" || handle === "ne") { + top = clamp(top + deltaY, 0, bottom - MIN_CROP_SIZE); } - - 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; + if (handle === "sw" || handle === "se") { + bottom = clamp(bottom + deltaY, top + MIN_CROP_SIZE, 1); } - 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 ( diff --git a/src/components/video-editor/projectPersistence.ts b/src/components/video-editor/projectPersistence.ts index 704371a6b..b8481f156 100644 --- a/src/components/video-editor/projectPersistence.ts +++ b/src/components/video-editor/projectPersistence.ts @@ -1028,6 +1028,16 @@ export function normalizeProjectEditor(editor: Partial): 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 diff --git a/src/components/video-editor/types.ts b/src/components/video-editor/types.ts index 9bc401d5b..35b0fec26 100644 --- a/src/components/video-editor/types.ts +++ b/src/components/video-editor/types.ts @@ -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, diff --git a/src/components/video-editor/webcamOverlay.test.ts b/src/components/video-editor/webcamOverlay.test.ts index a78b02685..001f3cd67 100644 --- a/src/components/video-editor/webcamOverlay.test.ts +++ b/src/components/video-editor/webcamOverlay.test.ts @@ -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( diff --git a/src/components/video-editor/webcamOverlay.ts b/src/components/video-editor/webcamOverlay.ts index 21cc7dd38..9ce38212f 100644 --- a/src/components/video-editor/webcamOverlay.ts +++ b/src/components/video-editor/webcamOverlay.ts @@ -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 | 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); +} diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index dc24cd97a..0adb90dfc 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -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", diff --git a/src/i18n/locales/es/settings.json b/src/i18n/locales/es/settings.json index 66a05e89c..f0a51b3a0 100644 --- a/src/i18n/locales/es/settings.json +++ b/src/i18n/locales/es/settings.json @@ -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", diff --git a/src/i18n/locales/fr/settings.json b/src/i18n/locales/fr/settings.json index 200ecaa02..2db7d2f0c 100644 --- a/src/i18n/locales/fr/settings.json +++ b/src/i18n/locales/fr/settings.json @@ -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", diff --git a/src/i18n/locales/it/settings.json b/src/i18n/locales/it/settings.json index d9b9a1a18..5b5b9c276 100644 --- a/src/i18n/locales/it/settings.json +++ b/src/i18n/locales/it/settings.json @@ -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", diff --git a/src/i18n/locales/ko/settings.json b/src/i18n/locales/ko/settings.json index c528a98fb..5df1385f0 100644 --- a/src/i18n/locales/ko/settings.json +++ b/src/i18n/locales/ko/settings.json @@ -88,6 +88,8 @@ "webcamFootageAdded": "웹캠 영상을 연결했습니다", "webcamFootageRemoved": "웹캠 영상을 제거했습니다", "webcamSize": "웹캠 크기", + "webcamWidth": "웹캠 너비", + "webcamHeight": "웹캠 높이", "webcamCrop": "웹캠 자르기", "webcamReactToZoom": "확대 시 웹캠 반응", "webcamMirror": "웹캠 미러링", diff --git a/src/i18n/locales/nl/settings.json b/src/i18n/locales/nl/settings.json index d88e4278c..3f9409f02 100644 --- a/src/i18n/locales/nl/settings.json +++ b/src/i18n/locales/nl/settings.json @@ -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", diff --git a/src/i18n/locales/pt-BR/settings.json b/src/i18n/locales/pt-BR/settings.json index a34e6e4d2..f543ce434 100644 --- a/src/i18n/locales/pt-BR/settings.json +++ b/src/i18n/locales/pt-BR/settings.json @@ -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", diff --git a/src/i18n/locales/ru/settings.json b/src/i18n/locales/ru/settings.json index 687f8383b..2768d9950 100644 --- a/src/i18n/locales/ru/settings.json +++ b/src/i18n/locales/ru/settings.json @@ -110,6 +110,8 @@ "webcamFootageAdded": "Видео добавлено", "webcamFootageRemoved": "Видео удалено", "webcamSize": "Размер веб-камеры", + "webcamWidth": "Ширина веб-камеры", + "webcamHeight": "Высота веб-камеры", "webcamCrop": "Обрезка веб-камеры", "webcamReactToZoom": "Веб-камера реагирует на зум", "webcamMirror": "Отразить веб-камеру", @@ -216,4 +218,4 @@ "mixedLabel": "Источник", "deleteRegion": "Удалить аудио" } -} \ No newline at end of file +} diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json index a68d2a04f..1ae88c027 100644 --- a/src/i18n/locales/zh-CN/settings.json +++ b/src/i18n/locales/zh-CN/settings.json @@ -105,6 +105,8 @@ "webcamFootageAdded": "已关联摄像头素材", "webcamFootageRemoved": "已移除摄像头素材", "webcamSize": "摄像头大小", + "webcamWidth": "摄像头宽度", + "webcamHeight": "摄像头高度", "webcamCrop": "摄像头裁剪", "webcamReactToZoom": "摄像头随缩放变化", "webcamMirror": "镜像摄像头", diff --git a/src/i18n/locales/zh-TW/settings.json b/src/i18n/locales/zh-TW/settings.json index db0ea7e04..a2f78ae39 100644 --- a/src/i18n/locales/zh-TW/settings.json +++ b/src/i18n/locales/zh-TW/settings.json @@ -88,6 +88,8 @@ "webcamFootageAdded": "已連結網路攝影機素材", "webcamFootageRemoved": "已移除網路攝影機素材", "webcamSize": "網路攝影機大小", + "webcamWidth": "網路攝影機寬度", + "webcamHeight": "網路攝影機高度", "webcamCrop": "網路攝影機裁剪", "webcamReactToZoom": "網路攝影機跟隨縮放", "webcamMirror": "鏡像網路攝影機", diff --git a/src/lib/exporter/frameRenderer.ts b/src/lib/exporter/frameRenderer.ts index 049c25bbc..2f6b134a6 100644 --- a/src/lib/exporter/frameRenderer.ts +++ b/src/lib/exporter/frameRenderer.ts @@ -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 { diff --git a/src/lib/exporter/modernFrameRenderer.ts b/src/lib/exporter/modernFrameRenderer.ts index 6ab918d8a..769a6ba11 100644 --- a/src/lib/exporter/modernFrameRenderer.ts +++ b/src/lib/exporter/modernFrameRenderer.ts @@ -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, diff --git a/src/lib/exporter/modernVideoExporter.nativeStaticLayout.test.ts b/src/lib/exporter/modernVideoExporter.nativeStaticLayout.test.ts index d32791109..a20e0dd0c 100644 --- a/src/lib/exporter/modernVideoExporter.nativeStaticLayout.test.ts +++ b/src/lib/exporter/modernVideoExporter.nativeStaticLayout.test.ts @@ -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 }, diff --git a/src/lib/exporter/modernVideoExporter.ts b/src/lib/exporter/modernVideoExporter.ts index 5b65882b8..27ac7fc12 100644 --- a/src/lib/exporter/modernVideoExporter.ts +++ b/src/lib/exporter/modernVideoExporter.ts @@ -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,