mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-27 08:15:42 +00:00
Merge pull request #851 from webadderallorg/codex/mac-recording-webcam-fixes
Fix Mac recording colours and webcam overlay behavior
This commit is contained in:
@@ -94,7 +94,9 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate {
|
||||
let requestedFPS = max(targetCaptureFPS, config.fps ?? targetCaptureFPS)
|
||||
streamConfig.minimumFrameInterval = CMTime(value: 1, timescale: CMTimeScale(requestedFPS))
|
||||
streamConfig.queueDepth = 6
|
||||
streamConfig.pixelFormat = kCVPixelFormatType_32BGRA
|
||||
streamConfig.pixelFormat = kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange
|
||||
streamConfig.colorSpaceName = CGColorSpace.sRGB
|
||||
streamConfig.colorMatrix = CGDisplayStream.yCbCrMatrix_ITU_R_709_2
|
||||
streamConfig.showsCursor = false
|
||||
streamConfig.capturesAudio = capturesSystemAudio || capturesMicrophone
|
||||
streamConfig.sampleRate = 48000
|
||||
@@ -168,11 +170,14 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate {
|
||||
throw NSError(domain: "RecordlyCapture", code: 5, userInfo: [NSLocalizedDescriptionKey: "Unable to create output settings assistant"])
|
||||
}
|
||||
|
||||
assistant.sourceVideoFormat = try CMVideoFormatDescription(
|
||||
videoCodecType: .h264,
|
||||
let sourceVideoFormat = try CMVideoFormatDescription(
|
||||
videoCodecType: CMFormatDescription.MediaSubType(
|
||||
rawValue: kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange
|
||||
),
|
||||
width: outputWidth,
|
||||
height: outputHeight
|
||||
)
|
||||
assistant.sourceVideoFormat = sourceVideoFormat
|
||||
|
||||
guard var outputSettings = assistant.videoSettings else {
|
||||
throw NSError(domain: "RecordlyCapture", code: 6, userInfo: [NSLocalizedDescriptionKey: "Output settings unavailable"])
|
||||
@@ -186,7 +191,11 @@ final class ScreenCaptureRecorder: NSObject, SCStreamOutput, SCStreamDelegate {
|
||||
AVVideoYCbCrMatrixKey: AVVideoYCbCrMatrix_ITU_R_709_2,
|
||||
]
|
||||
|
||||
let videoInput = AVAssetWriterInput(mediaType: .video, outputSettings: outputSettings)
|
||||
let videoInput = AVAssetWriterInput(
|
||||
mediaType: .video,
|
||||
outputSettings: outputSettings,
|
||||
sourceFormatHint: sourceVideoFormat
|
||||
)
|
||||
videoInput.expectsMediaDataInRealTime = true
|
||||
|
||||
guard let assetWriter = assetWriter, assetWriter.canAdd(videoInput) else {
|
||||
|
||||
@@ -42,6 +42,21 @@ describe("ScreenCaptureKitRecorder resume timing", () => {
|
||||
});
|
||||
|
||||
describe("ScreenCaptureKitRecorder colour metadata", () => {
|
||||
it("asks ScreenCaptureKit for BT.709-compatible video-range frames", () => {
|
||||
expect(recorderSource).toContain(
|
||||
"streamConfig.pixelFormat = kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange",
|
||||
);
|
||||
expect(recorderSource).toContain("streamConfig.colorSpaceName = CGColorSpace.sRGB");
|
||||
expect(recorderSource).toContain(
|
||||
"streamConfig.colorMatrix = CGDisplayStream.yCbCrMatrix_ITU_R_709_2",
|
||||
);
|
||||
expect(recorderSource).toContain(
|
||||
"rawValue: kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange",
|
||||
);
|
||||
expect(recorderSource).toContain("sourceFormatHint: sourceVideoFormat");
|
||||
expect(recorderSource).not.toContain("videoCodecType: .h264");
|
||||
});
|
||||
|
||||
it("tags recordings as BT.709", () => {
|
||||
expect(recorderSource).toContain("AVVideoColorPropertiesKey");
|
||||
expect(recorderSource).toContain("AVVideoColorPrimaries_ITU_R_709_2");
|
||||
|
||||
Binary file not shown.
Binary file not shown.
+9
-1
@@ -184,7 +184,7 @@ function getHudOverlayBounds() {
|
||||
});
|
||||
return getHudOverlayWindowBounds(
|
||||
workArea,
|
||||
isHudOverlayMousePassthroughSupported() && !hudOverlayRecordingActive,
|
||||
isHudOverlayMousePassthroughSupported(),
|
||||
fallbackExpanded,
|
||||
);
|
||||
}
|
||||
@@ -457,6 +457,14 @@ export function createHudOverlayWindow(): BrowserWindow {
|
||||
backgroundThrottling: false,
|
||||
},
|
||||
});
|
||||
// Keep the recording controls and webcam above normal and full-screen apps.
|
||||
// Transparent regions remain click-through via setIgnoreMouseEvents().
|
||||
win.setAlwaysOnTop(true, "screen-saver");
|
||||
if (process.platform === "darwin") {
|
||||
win.setVisibleOnAllWorkspaces(true, {
|
||||
visibleOnFullScreen: true,
|
||||
});
|
||||
}
|
||||
|
||||
const showHudWindow = () => {
|
||||
if (hasShownHudWindow || win.isDestroyed()) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useRef, useState, type PointerEvent } from "react";
|
||||
import { type PointerEvent, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { canShowFloatingWebcamPreview } from "../floatingWebcamPreview";
|
||||
|
||||
const WEBCAM_PREVIEW_DRAG_THRESHOLD = 6;
|
||||
@@ -118,8 +118,10 @@ export function useWebcamPreviewOverlay({
|
||||
|
||||
const latestDeltaX = pointer.clientX - latestDragState.startX;
|
||||
const latestDeltaY = pointer.clientY - latestDragState.startY;
|
||||
const viewportWidth = Math.max(window.innerWidth, window.screen?.width ?? 0);
|
||||
const viewportHeight = Math.max(window.innerHeight, window.screen?.height ?? 0);
|
||||
// Pointer coordinates and getBoundingClientRect() are relative to the HUD
|
||||
// viewport. `screen.width` can use a different display/DPI coordinate space.
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
const unclampedLeft = latestDragState.initialLeft + latestDeltaX;
|
||||
const unclampedTop = latestDragState.initialTop + latestDeltaY;
|
||||
const clampedLeft = Math.min(
|
||||
|
||||
@@ -77,12 +77,12 @@ import {
|
||||
DEFAULT_CURSOR_STYLE,
|
||||
DEFAULT_CURSOR_SWAY,
|
||||
DEFAULT_PADDING,
|
||||
DEFAULT_WEBCAM_CORNER_RADIUS,
|
||||
DEFAULT_WEBCAM_MARGIN,
|
||||
DEFAULT_WEBCAM_POSITION_PRESET,
|
||||
DEFAULT_WEBCAM_POSITION_X,
|
||||
DEFAULT_WEBCAM_POSITION_Y,
|
||||
DEFAULT_WEBCAM_REACT_TO_ZOOM,
|
||||
DEFAULT_WEBCAM_ROUNDNESS,
|
||||
DEFAULT_WEBCAM_SHADOW,
|
||||
DEFAULT_WEBCAM_SIZE,
|
||||
DEFAULT_ZOOM_IN_DURATION_MS,
|
||||
@@ -3658,14 +3658,14 @@ export function SettingsPanel({
|
||||
/>
|
||||
<SliderControl
|
||||
label={tSettings("effects.webcamRoundness")}
|
||||
value={webcam?.cornerRadius ?? DEFAULT_WEBCAM_CORNER_RADIUS}
|
||||
defaultValue={DEFAULT_WEBCAM_CORNER_RADIUS}
|
||||
value={webcam?.roundness ?? DEFAULT_WEBCAM_ROUNDNESS}
|
||||
defaultValue={DEFAULT_WEBCAM_ROUNDNESS}
|
||||
min={0}
|
||||
max={160}
|
||||
max={100}
|
||||
step={1}
|
||||
onChange={(v) => updateWebcam({ cornerRadius: v })}
|
||||
formatValue={(v) => `${Math.round(v)}px`}
|
||||
parseInput={(text) => parseFloat(text.replace(/px$/, ""))}
|
||||
onChange={(v) => updateWebcam({ roundness: v })}
|
||||
formatValue={(v) => `${Math.round(v)}%`}
|
||||
parseInput={(text) => parseFloat(text.replace(/%$/, ""))}
|
||||
/>
|
||||
<SliderControl
|
||||
label={tSettings("effects.webcamShadow")}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
useState,
|
||||
} from "react";
|
||||
import { getAssetPath, getRenderableAssetUrl, getRenderableVideoUrl } from "@/lib/assetPath";
|
||||
import { getWebcamShadowFilter } from "@/lib/exporter/shadowProfile";
|
||||
import {
|
||||
clampMediaTimeToDuration,
|
||||
enablePitchPreservingPlayback,
|
||||
@@ -89,8 +90,8 @@ import {
|
||||
DEFAULT_CURSOR_STYLE,
|
||||
DEFAULT_CURSOR_SWAY,
|
||||
DEFAULT_PADDING,
|
||||
DEFAULT_WEBCAM_CORNER_RADIUS,
|
||||
DEFAULT_WEBCAM_REACT_TO_ZOOM,
|
||||
DEFAULT_WEBCAM_ROUNDNESS,
|
||||
DEFAULT_WEBCAM_SHADOW,
|
||||
DEFAULT_WEBCAM_SIZE,
|
||||
DEFAULT_ZOOM_IN_DURATION_MS,
|
||||
@@ -123,9 +124,11 @@ import {
|
||||
} from "./videoPlayback/zoomTransform";
|
||||
import {
|
||||
getCropMatchedWebcamHeightPercent,
|
||||
getWebcamCornerRadiusPx,
|
||||
getWebcamCropSourceRect,
|
||||
getWebcamOverlayDimensionsPx,
|
||||
getWebcamOverlayPosition,
|
||||
scaleWebcamOverlayPixels,
|
||||
} from "./webcamOverlay";
|
||||
|
||||
type PlaybackAnimationState = {
|
||||
@@ -791,7 +794,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
const webcamPositionX = webcam?.positionX ?? 1;
|
||||
const webcamPositionY = webcam?.positionY ?? 1;
|
||||
const webcamCorner = webcam?.corner ?? "bottom-right";
|
||||
const webcamCornerRadius = webcam?.cornerRadius ?? DEFAULT_WEBCAM_CORNER_RADIUS;
|
||||
const webcamRoundness = webcam?.roundness ?? DEFAULT_WEBCAM_ROUNDNESS;
|
||||
const webcamShadow = webcam?.shadow ?? DEFAULT_WEBCAM_SHADOW;
|
||||
const webcamTimeOffsetMs = webcam?.timeOffsetMs;
|
||||
const webcamCropRegion = webcam?.cropRegion;
|
||||
@@ -841,22 +844,28 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
}
|
||||
return;
|
||||
}
|
||||
const scaledMargin = scaleWebcamOverlayPixels(webcamMargin, overlay.clientWidth);
|
||||
|
||||
const scaledDimensions = getWebcamOverlayDimensionsPx({
|
||||
containerWidth: overlay.clientWidth,
|
||||
containerHeight: overlay.clientHeight,
|
||||
widthPercent: webcamWidth,
|
||||
heightPercent: webcamHeight,
|
||||
margin: webcamMargin,
|
||||
margin: scaledMargin,
|
||||
zoomScale,
|
||||
reactToZoom: webcamReactToZoom,
|
||||
});
|
||||
const scaledRadius = getWebcamCornerRadiusPx(
|
||||
webcamRoundness,
|
||||
scaledDimensions.width,
|
||||
scaledDimensions.height,
|
||||
);
|
||||
const { x, y } = getWebcamOverlayPosition({
|
||||
containerWidth: overlay.clientWidth,
|
||||
containerHeight: overlay.clientHeight,
|
||||
width: scaledDimensions.width,
|
||||
height: scaledDimensions.height,
|
||||
margin: webcamMargin,
|
||||
margin: scaledMargin,
|
||||
positionPreset: webcamPositionPreset,
|
||||
positionX: webcamPositionX,
|
||||
positionY: webcamPositionY,
|
||||
@@ -874,12 +883,10 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
y: 0,
|
||||
width: scaledDimensions.width,
|
||||
height: scaledDimensions.height,
|
||||
radius: webcamCornerRadius,
|
||||
radius: scaledRadius,
|
||||
});
|
||||
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.filter = getWebcamShadowFilter(shadowSize, webcamShadow);
|
||||
bubble.style.borderRadius = "0px";
|
||||
bubble.style.boxShadow = "none";
|
||||
|
||||
@@ -891,7 +898,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
},
|
||||
[
|
||||
webcamCorner,
|
||||
webcamCornerRadius,
|
||||
webcamRoundness,
|
||||
webcamEnabled,
|
||||
webcamMargin,
|
||||
webcamPositionPreset,
|
||||
|
||||
@@ -43,4 +43,30 @@ describe("normalizeProjectEditor", () => {
|
||||
linked: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("migrates legacy webcam radius pixels to percentage roundness", () => {
|
||||
const editor = normalizeProjectEditor({
|
||||
webcam: {
|
||||
cornerRadius: 90,
|
||||
width: 40,
|
||||
height: 40,
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(editor.webcam.roundness).toBeCloseTo(17.36, 1);
|
||||
expect(editor.webcam.cornerRadius).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses the legacy webcam size when migrating radius pixels", () => {
|
||||
const editor = normalizeProjectEditor({
|
||||
webcam: {
|
||||
cornerRadius: 90,
|
||||
size: 80,
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(editor.webcam.width).toBe(80);
|
||||
expect(editor.webcam.height).toBe(80);
|
||||
expect(editor.webcam.roundness).toBeCloseTo(4.34, 1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -52,13 +52,13 @@ import {
|
||||
DEFAULT_FIGURE_DATA,
|
||||
DEFAULT_PADDING,
|
||||
DEFAULT_PLAYBACK_SPEED,
|
||||
DEFAULT_WEBCAM_CORNER_RADIUS,
|
||||
DEFAULT_WEBCAM_MARGIN,
|
||||
DEFAULT_WEBCAM_OVERLAY,
|
||||
DEFAULT_WEBCAM_POSITION_PRESET,
|
||||
DEFAULT_WEBCAM_POSITION_X,
|
||||
DEFAULT_WEBCAM_POSITION_Y,
|
||||
DEFAULT_WEBCAM_REACT_TO_ZOOM,
|
||||
DEFAULT_WEBCAM_ROUNDNESS,
|
||||
DEFAULT_WEBCAM_SHADOW,
|
||||
DEFAULT_WEBCAM_SIZE,
|
||||
DEFAULT_WEBCAM_TIME_OFFSET_MS,
|
||||
@@ -80,7 +80,7 @@ import {
|
||||
type ZoomRegion,
|
||||
type ZoomTransitionEasing,
|
||||
} from "./types";
|
||||
import { normalizeWebcamCropRegion } from "./webcamOverlay";
|
||||
import { convertLegacyWebcamRadiusToRoundness, normalizeWebcamCropRegion } from "./webcamOverlay";
|
||||
|
||||
export const PROJECT_VERSION = 1;
|
||||
|
||||
@@ -1047,9 +1047,23 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
|
||||
: legacyZoomScaleEffect != null
|
||||
? legacyZoomScaleEffect > 0
|
||||
: DEFAULT_WEBCAM_REACT_TO_ZOOM,
|
||||
cornerRadius: isFiniteNumber(webcam.cornerRadius)
|
||||
? clamp(webcam.cornerRadius, 0, 160)
|
||||
: DEFAULT_WEBCAM_CORNER_RADIUS,
|
||||
roundness: isFiniteNumber(webcam.roundness)
|
||||
? clamp(webcam.roundness, 0, 100)
|
||||
: isFiniteNumber(webcam.cornerRadius)
|
||||
? convertLegacyWebcamRadiusToRoundness(
|
||||
webcam.cornerRadius,
|
||||
isFiniteNumber(webcam.width)
|
||||
? webcam.width
|
||||
: isFiniteNumber(webcam.size)
|
||||
? webcam.size
|
||||
: DEFAULT_WEBCAM_SIZE,
|
||||
isFiniteNumber(webcam.height)
|
||||
? webcam.height
|
||||
: isFiniteNumber(webcam.size)
|
||||
? webcam.size
|
||||
: DEFAULT_WEBCAM_SIZE,
|
||||
)
|
||||
: DEFAULT_WEBCAM_ROUNDNESS,
|
||||
shadow: isFiniteNumber(webcam.shadow)
|
||||
? clamp(webcam.shadow, 0, 1)
|
||||
: DEFAULT_WEBCAM_SHADOW,
|
||||
|
||||
@@ -147,7 +147,9 @@ export interface WebcamOverlaySettings {
|
||||
width: number;
|
||||
height: number;
|
||||
reactToZoom: boolean;
|
||||
cornerRadius: number;
|
||||
roundness: number;
|
||||
/** Legacy pixel value retained only while loading older projects. */
|
||||
cornerRadius?: number;
|
||||
shadow: number;
|
||||
margin: number;
|
||||
}
|
||||
@@ -189,7 +191,7 @@ export const DEFAULT_ZOOM_OUT_EASING: ZoomTransitionEasing = "recordly";
|
||||
export const DEFAULT_CONNECTED_ZOOM_EASING: ZoomTransitionEasing = "glide";
|
||||
export const DEFAULT_WEBCAM_SIZE = 40;
|
||||
export const DEFAULT_WEBCAM_REACT_TO_ZOOM = true;
|
||||
export const DEFAULT_WEBCAM_CORNER_RADIUS = 90;
|
||||
export const DEFAULT_WEBCAM_ROUNDNESS = 42;
|
||||
export const DEFAULT_WEBCAM_SHADOW = 0.67;
|
||||
export const DEFAULT_WEBCAM_MARGIN = 24;
|
||||
export const DEFAULT_WEBCAM_POSITION_PRESET: WebcamPositionPreset = "bottom-right";
|
||||
@@ -211,7 +213,7 @@ export const DEFAULT_WEBCAM_OVERLAY: WebcamOverlaySettings = {
|
||||
width: DEFAULT_WEBCAM_SIZE,
|
||||
height: DEFAULT_WEBCAM_SIZE,
|
||||
reactToZoom: DEFAULT_WEBCAM_REACT_TO_ZOOM,
|
||||
cornerRadius: DEFAULT_WEBCAM_CORNER_RADIUS,
|
||||
roundness: DEFAULT_WEBCAM_ROUNDNESS,
|
||||
shadow: DEFAULT_WEBCAM_SHADOW,
|
||||
margin: DEFAULT_WEBCAM_MARGIN,
|
||||
};
|
||||
|
||||
@@ -1,13 +1,37 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
convertLegacyWebcamRadiusToRoundness,
|
||||
getCropMatchedWebcamHeightPercent,
|
||||
getWebcamCornerRadiusPx,
|
||||
getWebcamCropSourceRect,
|
||||
getWebcamOverlayDimensionsPx,
|
||||
getWebcamOverlayPosition,
|
||||
isWebcamCropRegionDefault,
|
||||
normalizeWebcamCropRegion,
|
||||
scaleWebcamOverlayPixels,
|
||||
} from "./webcamOverlay";
|
||||
|
||||
describe("webcam roundness", () => {
|
||||
it("uses a stronger response curve while preserving the endpoints", () => {
|
||||
expect(getWebcamCornerRadiusPx(0, 640, 360)).toBe(0);
|
||||
expect(getWebcamCornerRadiusPx(25, 640, 360)).toBe(90);
|
||||
expect(getWebcamCornerRadiusPx(50, 640, 360)).toBeCloseTo(127.28, 1);
|
||||
expect(getWebcamCornerRadiusPx(100, 640, 360)).toBe(180);
|
||||
});
|
||||
|
||||
it("converts legacy reference pixels without changing the approximate shape", () => {
|
||||
expect(convertLegacyWebcamRadiusToRoundness(90, 40, 40)).toBeCloseTo(17.36, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("scaleWebcamOverlayPixels", () => {
|
||||
it("keeps webcam pixel controls proportional between preview and export", () => {
|
||||
expect(scaleWebcamOverlayPixels(24, 960)).toBe(12);
|
||||
expect(scaleWebcamOverlayPixels(24, 1920)).toBe(24);
|
||||
expect(scaleWebcamOverlayPixels(24, 3840)).toBe(48);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeWebcamCropRegion", () => {
|
||||
it("defaults to the full webcam frame", () => {
|
||||
expect(normalizeWebcamCropRegion()).toEqual({ x: 0, y: 0, width: 1, height: 1 });
|
||||
|
||||
@@ -1,11 +1,41 @@
|
||||
import type { CropRegion, WebcamCorner, WebcamPositionPreset } from "./types";
|
||||
|
||||
const MIN_WEBCAM_OVERLAY_SIZE_PX = 56;
|
||||
export const WEBCAM_REFERENCE_VIEWPORT_WIDTH = 1920;
|
||||
const WEBCAM_REFERENCE_VIEWPORT_HEIGHT = 1080;
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
export function scaleWebcamOverlayPixels(value: number, containerWidth: number): number {
|
||||
const safeValue = Number.isFinite(value) ? Math.max(0, value) : 0;
|
||||
const safeWidth = Number.isFinite(containerWidth) ? Math.max(0, containerWidth) : 0;
|
||||
return safeValue * (safeWidth / WEBCAM_REFERENCE_VIEWPORT_WIDTH);
|
||||
}
|
||||
|
||||
export function getWebcamCornerRadiusPx(
|
||||
roundnessPercent: number,
|
||||
width: number,
|
||||
height: number,
|
||||
): number {
|
||||
const safeRoundness = Number.isFinite(roundnessPercent) ? clamp(roundnessPercent, 0, 100) : 0;
|
||||
const strengthenedRoundness = Math.sqrt(safeRoundness / 100);
|
||||
return (Math.max(0, Math.min(width, height)) / 2) * strengthenedRoundness;
|
||||
}
|
||||
|
||||
export function convertLegacyWebcamRadiusToRoundness(
|
||||
radiusPixels: number,
|
||||
widthPercent: number,
|
||||
heightPercent: number,
|
||||
): number {
|
||||
const referenceShortSide =
|
||||
WEBCAM_REFERENCE_VIEWPORT_HEIGHT *
|
||||
(clamp(Math.min(widthPercent, heightPercent), 10, 100) / 100);
|
||||
const normalizedRadius = clamp(Math.max(0, radiusPixels) / (referenceShortSide / 2), 0, 1);
|
||||
return normalizedRadius ** 2 * 100;
|
||||
}
|
||||
|
||||
export function getWebcamPositionForPreset(preset: WebcamPositionPreset): { x: number; y: number } {
|
||||
switch (preset) {
|
||||
case "top-left":
|
||||
|
||||
@@ -19,6 +19,7 @@ import type {
|
||||
import {
|
||||
BASE_PREVIEW_HEIGHT,
|
||||
BASE_PREVIEW_WIDTH,
|
||||
DEFAULT_WEBCAM_ROUNDNESS,
|
||||
ZOOM_DEPTH_SCALES,
|
||||
} from "@/components/video-editor/types";
|
||||
import { DEFAULT_FOCUS } from "@/components/video-editor/videoPlayback/constants";
|
||||
@@ -51,11 +52,14 @@ import {
|
||||
} from "@/components/video-editor/videoPlayback/zoomTransform";
|
||||
import {
|
||||
getCropMatchedWebcamHeightPercent,
|
||||
getWebcamCornerRadiusPx,
|
||||
getWebcamCropSourceRect,
|
||||
getWebcamOverlayDimensionsPx,
|
||||
getWebcamOverlayPosition,
|
||||
scaleWebcamOverlayPixels,
|
||||
} from "@/components/video-editor/webcamOverlay";
|
||||
import { getAssetPath, getExportableVideoUrl, getRenderableAssetUrl } from "@/lib/assetPath";
|
||||
import { getWebcamShadowFilter } from "@/lib/exporter/shadowProfile";
|
||||
import { drawSquircleOnCanvas, drawSquircleOnGraphics } from "@/lib/geometry/squircle";
|
||||
import {
|
||||
clampMediaTimeToDuration,
|
||||
@@ -2067,7 +2071,7 @@ export class FrameRenderer {
|
||||
: "videoHeight" in webcamFrameSource
|
||||
? webcamFrameSource.videoHeight
|
||||
: webcamFrameSource.height) || sourceWidth;
|
||||
const margin = webcam.margin ?? 24;
|
||||
const margin = scaleWebcamOverlayPixels(webcam.margin ?? 24, width);
|
||||
const widthPercent = webcam.width ?? webcam.size ?? 50;
|
||||
const heightPercent = getCropMatchedWebcamHeightPercent(
|
||||
widthPercent,
|
||||
@@ -2096,7 +2100,11 @@ export class FrameRenderer {
|
||||
positionY: webcam.positionY ?? 1,
|
||||
legacyCorner: webcam.corner,
|
||||
});
|
||||
const radius = Math.max(0, webcam.cornerRadius ?? 18);
|
||||
const radius = getWebcamCornerRadiusPx(
|
||||
webcam.roundness ?? DEFAULT_WEBCAM_ROUNDNESS,
|
||||
dimensions.width,
|
||||
dimensions.height,
|
||||
);
|
||||
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) {
|
||||
@@ -2158,10 +2166,9 @@ export class FrameRenderer {
|
||||
bubbleCtx.restore();
|
||||
|
||||
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(shadowSize * 0.06)}px ${Math.round(shadowSize * 0.22)}px rgba(0,0,0,${shadow}))`;
|
||||
ctx.filter = getWebcamShadowFilter(shadowSize, webcam.shadow);
|
||||
ctx.drawImage(bubbleCanvas, x, y, dimensions.width, dimensions.height);
|
||||
ctx.restore();
|
||||
return;
|
||||
|
||||
@@ -26,7 +26,11 @@ import type {
|
||||
ZoomRegion,
|
||||
ZoomTransitionEasing,
|
||||
} from "@/components/video-editor/types";
|
||||
import { getDefaultCaptionFontFamily, ZOOM_DEPTH_SCALES } from "@/components/video-editor/types";
|
||||
import {
|
||||
DEFAULT_WEBCAM_ROUNDNESS,
|
||||
getDefaultCaptionFontFamily,
|
||||
ZOOM_DEPTH_SCALES,
|
||||
} from "@/components/video-editor/types";
|
||||
import { DEFAULT_FOCUS } from "@/components/video-editor/videoPlayback/constants";
|
||||
import {
|
||||
type CursorFollowCameraState,
|
||||
@@ -60,10 +64,12 @@ import {
|
||||
} from "@/components/video-editor/videoPlayback/zoomTransform";
|
||||
import {
|
||||
getCropMatchedWebcamHeightPercent,
|
||||
getWebcamCornerRadiusPx,
|
||||
getWebcamCropSourceRect,
|
||||
getWebcamOverlayDimensionsPx,
|
||||
getWebcamOverlayPosition,
|
||||
isWebcamCropRegionDefault,
|
||||
scaleWebcamOverlayPixels,
|
||||
} from "@/components/video-editor/webcamOverlay";
|
||||
import { getAssetPath, getExportableVideoUrl, getRenderableAssetUrl } from "@/lib/assetPath";
|
||||
import { drawSquircleOnCanvas, drawSquircleOnGraphics } from "@/lib/geometry/squircle";
|
||||
@@ -86,6 +92,7 @@ import { ForwardFrameSource } from "./forwardFrameSource";
|
||||
import { resolveMediaElementSource } from "./localMediaSource";
|
||||
import {
|
||||
getShadowFilterPadding,
|
||||
getWebcamShadowStrength,
|
||||
VIDEO_SHADOW_LAYER_PROFILES,
|
||||
WEBCAM_SHADOW_LAYER_PROFILES,
|
||||
} from "./shadowProfile";
|
||||
@@ -2825,7 +2832,7 @@ export class FrameRenderer {
|
||||
return;
|
||||
}
|
||||
|
||||
const margin = webcam.margin ?? 24;
|
||||
const margin = scaleWebcamOverlayPixels(webcam.margin ?? 24, this.config.width);
|
||||
const widthPercent = webcam.width ?? webcam.size ?? 50;
|
||||
const aspectSourceWidth =
|
||||
liveSourceDimensions.width > 0
|
||||
@@ -2862,8 +2869,12 @@ export class FrameRenderer {
|
||||
positionY: webcam.positionY ?? 1,
|
||||
legacyCorner: webcam.corner,
|
||||
});
|
||||
const radius = Math.max(0, webcam.cornerRadius ?? 18);
|
||||
const shadowStrength = clampUnitInterval(webcam.shadow ?? 0);
|
||||
const radius = getWebcamCornerRadiusPx(
|
||||
webcam.roundness ?? DEFAULT_WEBCAM_ROUNDNESS,
|
||||
dimensions.width,
|
||||
dimensions.height,
|
||||
);
|
||||
const shadowStrength = getWebcamShadowStrength(webcam.shadow ?? 0);
|
||||
|
||||
this.webcamRootContainer.visible = true;
|
||||
|
||||
|
||||
@@ -387,6 +387,7 @@ describe("ModernVideoExporter native static-layout eligibility", () => {
|
||||
"unsupported-background-video",
|
||||
"unsupported-annotation-overlay",
|
||||
"unsupported-caption-overlay",
|
||||
"native-webcam-style-mismatch",
|
||||
"unsupported-webcam-source",
|
||||
]);
|
||||
});
|
||||
@@ -676,7 +677,7 @@ describe("ModernVideoExporter native static-layout eligibility", () => {
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("allows slow-speed webcam timelines through native source-time mapping", () => {
|
||||
it("uses the shared renderer for slow-speed webcam timelines", () => {
|
||||
const speedRegions: SpeedRegion[] = [
|
||||
{ id: "speed-1", startMs: 1_000, endMs: 4_000, speed: 0.5 },
|
||||
];
|
||||
@@ -697,10 +698,10 @@ describe("ModernVideoExporter native static-layout eligibility", () => {
|
||||
videoInfo,
|
||||
63,
|
||||
),
|
||||
).toBeNull();
|
||||
).toBe("native-webcam-style-mismatch");
|
||||
});
|
||||
|
||||
it("skips native static layout for rectangular webcam overlays", () => {
|
||||
it("uses the shared renderer for rectangular webcam overlays", () => {
|
||||
const exporter = createExporter({
|
||||
webcam: {
|
||||
enabled: true,
|
||||
@@ -719,10 +720,10 @@ describe("ModernVideoExporter native static-layout eligibility", () => {
|
||||
videoInfo,
|
||||
60,
|
||||
),
|
||||
).toBe("unsupported-rectangular-webcam-overlay");
|
||||
).toBe("native-webcam-style-mismatch");
|
||||
});
|
||||
|
||||
it("allows native speed timelines with a resolvable webcam source", () => {
|
||||
it("uses the shared renderer for native speed timelines with a webcam", () => {
|
||||
const speedRegions: SpeedRegion[] = [
|
||||
{ id: "speed-1", startMs: 1_000, endMs: 4_000, speed: 1.5 },
|
||||
];
|
||||
@@ -750,6 +751,6 @@ describe("ModernVideoExporter native static-layout eligibility", () => {
|
||||
videoInfo,
|
||||
59,
|
||||
),
|
||||
).toBeNull();
|
||||
).toBe("native-webcam-style-mismatch");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,7 +17,7 @@ import type {
|
||||
ZoomRegion,
|
||||
ZoomTransitionEasing,
|
||||
} from "@/components/video-editor/types";
|
||||
import { ZOOM_DEPTH_SCALES } from "@/components/video-editor/types";
|
||||
import { DEFAULT_WEBCAM_ROUNDNESS, ZOOM_DEPTH_SCALES } from "@/components/video-editor/types";
|
||||
import { DEFAULT_FOCUS } from "@/components/video-editor/videoPlayback/constants";
|
||||
import {
|
||||
computeCursorFollowFocus,
|
||||
@@ -40,6 +40,7 @@ import { getCursorStyleSizeMultiplier } from "@/components/video-editor/videoPla
|
||||
import { findDominantRegion } from "@/components/video-editor/videoPlayback/zoomRegionUtils";
|
||||
import { computeZoomTransform } from "@/components/video-editor/videoPlayback/zoomTransform";
|
||||
import {
|
||||
getWebcamCornerRadiusPx,
|
||||
getWebcamOverlayPosition,
|
||||
getWebcamOverlaySizePx,
|
||||
isWebcamCropRegionDefault,
|
||||
@@ -80,6 +81,7 @@ import {
|
||||
import { VideoMuxer } from "./muxer";
|
||||
import { roundNativeStaticLayoutContentSize } from "./nativeStaticLayoutGeometry";
|
||||
import { buildNativeStaticLayoutCursorTelemetry } from "./nativeStaticLayoutTelemetry";
|
||||
import { getWebcamShadowStrength } from "./shadowProfile";
|
||||
import { resolveSourceAudioFallbackPaths } from "./sourceAudioFallback";
|
||||
import { type DecodedVideoInfo, StreamingVideoDecoder } from "./streamingDecoder";
|
||||
import type {
|
||||
@@ -1561,6 +1563,11 @@ export class ModernVideoExporter {
|
||||
if ((this.config.autoCaptions ?? []).length > 0) {
|
||||
reasons.push("unsupported-caption-overlay");
|
||||
}
|
||||
if (this.config.webcam?.enabled) {
|
||||
// Native GPU compositors use a different corner and shadow model.
|
||||
// Keep webcam exports on the shared renderer used by preview.
|
||||
reasons.push("native-webcam-style-mismatch");
|
||||
}
|
||||
|
||||
if (this.config.webcam?.enabled && !this.getNativeWebcamSourcePath()) {
|
||||
reasons.push("unsupported-webcam-source");
|
||||
@@ -2047,8 +2054,12 @@ export class ModernVideoExporter {
|
||||
left: Math.round(position.x),
|
||||
top: Math.round(position.y),
|
||||
size,
|
||||
radius: Math.max(0, webcam.cornerRadius ?? 18),
|
||||
shadowIntensity: Math.min(1, Math.max(0, webcam.shadow ?? 0)),
|
||||
radius: getWebcamCornerRadiusPx(
|
||||
webcam.roundness ?? DEFAULT_WEBCAM_ROUNDNESS,
|
||||
size,
|
||||
size,
|
||||
),
|
||||
shadowIntensity: getWebcamShadowStrength(webcam.shadow ?? 0),
|
||||
mirror: webcam.mirror !== false,
|
||||
timeOffsetMs: Number.isFinite(webcam.timeOffsetMs) ? webcam.timeOffsetMs : 0,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getWebcamShadowFilter,
|
||||
getWebcamShadowStrength,
|
||||
WEBCAM_SHADOW_LAYER_PROFILES,
|
||||
} from "./shadowProfile";
|
||||
|
||||
describe("getWebcamShadowStrength", () => {
|
||||
it("doubles the webcam shadow response and clamps it", () => {
|
||||
expect(getWebcamShadowStrength(0)).toBe(0);
|
||||
expect(getWebcamShadowStrength(0.25)).toBe(0.5);
|
||||
expect(getWebcamShadowStrength(0.5)).toBe(1);
|
||||
expect(getWebcamShadowStrength(1)).toBe(1);
|
||||
});
|
||||
|
||||
it("adds a tighter contact shadow so strength also increases density", () => {
|
||||
expect(WEBCAM_SHADOW_LAYER_PROFILES).toHaveLength(2);
|
||||
expect(getWebcamShadowFilter(100, 0)).toBe("none");
|
||||
const filter = getWebcamShadowFilter(100, 0.5);
|
||||
expect(filter.match(/drop-shadow/g)).toHaveLength(2);
|
||||
expect(filter).toContain("rgba(0, 0, 0, 0.9)");
|
||||
expect(filter).toContain("rgba(0, 0, 0, 0.8)");
|
||||
});
|
||||
});
|
||||
@@ -11,9 +11,28 @@ export const VIDEO_SHADOW_LAYER_PROFILES: ReadonlyArray<ShadowLayerProfile> = Ob
|
||||
]);
|
||||
|
||||
export const WEBCAM_SHADOW_LAYER_PROFILES: ReadonlyArray<ShadowLayerProfile> = Object.freeze([
|
||||
{ offsetScale: 0.06, alphaScale: 1, blurScale: 0.22 },
|
||||
{ offsetScale: 0.06, alphaScale: 0.9, blurScale: 0.22 },
|
||||
{ offsetScale: 0.025, alphaScale: 0.8, blurScale: 0.07 },
|
||||
]);
|
||||
|
||||
export function getWebcamShadowStrength(value: number): number {
|
||||
return Math.min(1, Math.max(0, Number.isFinite(value) ? value * 2 : 0));
|
||||
}
|
||||
|
||||
export function getWebcamShadowFilter(shadowSize: number, value: number): string {
|
||||
const strength = getWebcamShadowStrength(value);
|
||||
if (strength <= 0) {
|
||||
return "none";
|
||||
}
|
||||
|
||||
return WEBCAM_SHADOW_LAYER_PROFILES.map((profile) => {
|
||||
const offset = Math.max(0, shadowSize) * profile.offsetScale * strength;
|
||||
const blur = Math.max(0, shadowSize) * profile.blurScale * strength;
|
||||
const alpha = Math.min(1, profile.alphaScale * strength);
|
||||
return `drop-shadow(0 ${offset}px ${blur}px rgba(0, 0, 0, ${alpha}))`;
|
||||
}).join(" ");
|
||||
}
|
||||
|
||||
export function getShadowFilterPadding(blur: number, offsetY: number): number {
|
||||
return Math.ceil(Math.max(0, blur * 2 + Math.abs(offsetY)));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user