mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-25 23:35:43 +00:00
Add dynamic webcam overlay dimensions
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -88,6 +88,8 @@
|
||||
"webcamFootageAdded": "웹캠 영상을 연결했습니다",
|
||||
"webcamFootageRemoved": "웹캠 영상을 제거했습니다",
|
||||
"webcamSize": "웹캠 크기",
|
||||
"webcamWidth": "웹캠 너비",
|
||||
"webcamHeight": "웹캠 높이",
|
||||
"webcamCrop": "웹캠 자르기",
|
||||
"webcamReactToZoom": "확대 시 웹캠 반응",
|
||||
"webcamMirror": "웹캠 미러링",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -110,6 +110,8 @@
|
||||
"webcamFootageAdded": "Видео добавлено",
|
||||
"webcamFootageRemoved": "Видео удалено",
|
||||
"webcamSize": "Размер веб-камеры",
|
||||
"webcamWidth": "Ширина веб-камеры",
|
||||
"webcamHeight": "Высота веб-камеры",
|
||||
"webcamCrop": "Обрезка веб-камеры",
|
||||
"webcamReactToZoom": "Веб-камера реагирует на зум",
|
||||
"webcamMirror": "Отразить веб-камеру",
|
||||
@@ -216,4 +218,4 @@
|
||||
"mixedLabel": "Источник",
|
||||
"deleteRegion": "Удалить аудио"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,6 +105,8 @@
|
||||
"webcamFootageAdded": "已关联摄像头素材",
|
||||
"webcamFootageRemoved": "已移除摄像头素材",
|
||||
"webcamSize": "摄像头大小",
|
||||
"webcamWidth": "摄像头宽度",
|
||||
"webcamHeight": "摄像头高度",
|
||||
"webcamCrop": "摄像头裁剪",
|
||||
"webcamReactToZoom": "摄像头随缩放变化",
|
||||
"webcamMirror": "镜像摄像头",
|
||||
|
||||
@@ -88,6 +88,8 @@
|
||||
"webcamFootageAdded": "已連結網路攝影機素材",
|
||||
"webcamFootageRemoved": "已移除網路攝影機素材",
|
||||
"webcamSize": "網路攝影機大小",
|
||||
"webcamWidth": "網路攝影機寬度",
|
||||
"webcamHeight": "網路攝影機高度",
|
||||
"webcamCrop": "網路攝影機裁剪",
|
||||
"webcamReactToZoom": "網路攝影機跟隨縮放",
|
||||
"webcamMirror": "鏡像網路攝影機",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user