Merge pull request #851 from webadderallorg/codex/mac-recording-webcam-fixes

Fix Mac recording colours and webcam overlay behavior
This commit is contained in:
webadderall
2026-09-01 20:35:37 +10:00
committed by GitHub
19 changed files with 260 additions and 50 deletions
+13 -4
View File
@@ -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");
+9 -1
View File
@@ -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")}
+16 -9
View File
@@ -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,
+5 -3
View File
@@ -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":
+11 -4
View File
@@ -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;
+15 -4
View File
@@ -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");
});
});
+14 -3
View File
@@ -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,
};
+24
View File
@@ -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)");
});
});
+20 -1
View File
@@ -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)));
}