diff --git a/electron/native/ScreenCaptureKitRecorder.swift b/electron/native/ScreenCaptureKitRecorder.swift index 0d039dfb..29253a75 100644 --- a/electron/native/ScreenCaptureKitRecorder.swift +++ b/electron/native/ScreenCaptureKitRecorder.swift @@ -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 { diff --git a/electron/native/ScreenCaptureKitRecorder.test.ts b/electron/native/ScreenCaptureKitRecorder.test.ts index b2de7e59..0956bf6f 100644 --- a/electron/native/ScreenCaptureKitRecorder.test.ts +++ b/electron/native/ScreenCaptureKitRecorder.test.ts @@ -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"); diff --git a/electron/native/bin/darwin-arm64/recordly-screencapturekit-helper b/electron/native/bin/darwin-arm64/recordly-screencapturekit-helper index f5dd25b4..233cfd17 100755 Binary files a/electron/native/bin/darwin-arm64/recordly-screencapturekit-helper and b/electron/native/bin/darwin-arm64/recordly-screencapturekit-helper differ diff --git a/electron/native/bin/darwin-x64/recordly-screencapturekit-helper b/electron/native/bin/darwin-x64/recordly-screencapturekit-helper index 7349cdef..8982873f 100755 Binary files a/electron/native/bin/darwin-x64/recordly-screencapturekit-helper and b/electron/native/bin/darwin-x64/recordly-screencapturekit-helper differ diff --git a/electron/windows.ts b/electron/windows.ts index f9a25aa1..bb3d6d07 100644 --- a/electron/windows.ts +++ b/electron/windows.ts @@ -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()) { diff --git a/src/components/launch/hooks/useWebcamPreviewOverlay.ts b/src/components/launch/hooks/useWebcamPreviewOverlay.ts index 50d5c60e..680e3dac 100644 --- a/src/components/launch/hooks/useWebcamPreviewOverlay.ts +++ b/src/components/launch/hooks/useWebcamPreviewOverlay.ts @@ -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( diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index 1f998e98..b3c4037d 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -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({ /> 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(/%$/, ""))} /> ( 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( } 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( 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( }, [ webcamCorner, - webcamCornerRadius, + webcamRoundness, webcamEnabled, webcamMargin, webcamPositionPreset, diff --git a/src/components/video-editor/projectPersistence.test.ts b/src/components/video-editor/projectPersistence.test.ts index 575c3b67..82a7fbcb 100644 --- a/src/components/video-editor/projectPersistence.test.ts +++ b/src/components/video-editor/projectPersistence.test.ts @@ -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); + }); }); diff --git a/src/components/video-editor/projectPersistence.ts b/src/components/video-editor/projectPersistence.ts index 93292297..00193bb9 100644 --- a/src/components/video-editor/projectPersistence.ts +++ b/src/components/video-editor/projectPersistence.ts @@ -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): 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, diff --git a/src/components/video-editor/types.ts b/src/components/video-editor/types.ts index 0b75c0e4..7ffff4a2 100644 --- a/src/components/video-editor/types.ts +++ b/src/components/video-editor/types.ts @@ -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, }; diff --git a/src/components/video-editor/webcamOverlay.test.ts b/src/components/video-editor/webcamOverlay.test.ts index 001f3cd6..d5b6381b 100644 --- a/src/components/video-editor/webcamOverlay.test.ts +++ b/src/components/video-editor/webcamOverlay.test.ts @@ -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 }); diff --git a/src/components/video-editor/webcamOverlay.ts b/src/components/video-editor/webcamOverlay.ts index 9ce38212..d84d99e1 100644 --- a/src/components/video-editor/webcamOverlay.ts +++ b/src/components/video-editor/webcamOverlay.ts @@ -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": diff --git a/src/lib/exporter/frameRenderer.ts b/src/lib/exporter/frameRenderer.ts index a4b73f83..1d1fc19f 100644 --- a/src/lib/exporter/frameRenderer.ts +++ b/src/lib/exporter/frameRenderer.ts @@ -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; diff --git a/src/lib/exporter/modernFrameRenderer.ts b/src/lib/exporter/modernFrameRenderer.ts index b9eefea1..fa0778ae 100644 --- a/src/lib/exporter/modernFrameRenderer.ts +++ b/src/lib/exporter/modernFrameRenderer.ts @@ -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; diff --git a/src/lib/exporter/modernVideoExporter.nativeStaticLayout.test.ts b/src/lib/exporter/modernVideoExporter.nativeStaticLayout.test.ts index a1783d8b..50f0e3ba 100644 --- a/src/lib/exporter/modernVideoExporter.nativeStaticLayout.test.ts +++ b/src/lib/exporter/modernVideoExporter.nativeStaticLayout.test.ts @@ -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"); }); }); diff --git a/src/lib/exporter/modernVideoExporter.ts b/src/lib/exporter/modernVideoExporter.ts index 73df81ee..35f185de 100644 --- a/src/lib/exporter/modernVideoExporter.ts +++ b/src/lib/exporter/modernVideoExporter.ts @@ -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, }; diff --git a/src/lib/exporter/shadowProfile.test.ts b/src/lib/exporter/shadowProfile.test.ts new file mode 100644 index 00000000..7c8ce8c8 --- /dev/null +++ b/src/lib/exporter/shadowProfile.test.ts @@ -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)"); + }); +}); diff --git a/src/lib/exporter/shadowProfile.ts b/src/lib/exporter/shadowProfile.ts index 61f55536..18bf0812 100644 --- a/src/lib/exporter/shadowProfile.ts +++ b/src/lib/exporter/shadowProfile.ts @@ -11,9 +11,28 @@ export const VIDEO_SHADOW_LAYER_PROFILES: ReadonlyArray = Ob ]); export const WEBCAM_SHADOW_LAYER_PROFILES: ReadonlyArray = 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))); }