diff --git a/electron/hudOverlayWindowOptions.test.ts b/electron/hudOverlayWindowOptions.test.ts new file mode 100644 index 000000000..08f8c6531 --- /dev/null +++ b/electron/hudOverlayWindowOptions.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { getHudOverlayTaskbarOptions } from "./hudOverlayWindowOptions"; + +describe("getHudOverlayTaskbarOptions", () => { + it("keeps a focusable HUD in the Windows taskbar", () => { + expect(getHudOverlayTaskbarOptions("win32")).toEqual({ + skipTaskbar: false, + focusable: true, + }); + }); + + it.each([ + "darwin", + "linux", + ] as const)("keeps the HUD non-focusable and out of the taskbar on %s", (platform) => { + expect(getHudOverlayTaskbarOptions(platform)).toEqual({ + skipTaskbar: true, + focusable: false, + }); + }); +}); diff --git a/electron/hudOverlayWindowOptions.ts b/electron/hudOverlayWindowOptions.ts new file mode 100644 index 000000000..27fcd2dea --- /dev/null +++ b/electron/hudOverlayWindowOptions.ts @@ -0,0 +1,12 @@ +export interface HudOverlayTaskbarOptions { + skipTaskbar: boolean; + focusable: boolean; +} + +export function getHudOverlayTaskbarOptions(platform: NodeJS.Platform): HudOverlayTaskbarOptions { + const showInWindowsTaskbar = platform === "win32"; + return { + skipTaskbar: !showInWindowsTaskbar, + focusable: showInWindowsTaskbar, + }; +} diff --git a/electron/ipc/nativeVideoExport.test.ts b/electron/ipc/nativeVideoExport.test.ts index 5992cadf3..b9b7ba1a5 100644 --- a/electron/ipc/nativeVideoExport.test.ts +++ b/electron/ipc/nativeVideoExport.test.ts @@ -8,8 +8,10 @@ import { buildNativePrecompositedStaticLayoutArgs, buildNativeStaticBackgroundRenderArgs, buildNativeStaticLayoutChunks, + buildNativeVideoExportArgs, buildTrimmedSourceAudioFilter, createNativeSquircleMaskPgmBuffer, + FFMPEG_BT709_VIDEO_COLOR_ARGS, isNativeCudaOutOfMemory, } from "./nativeVideoExport"; @@ -150,14 +152,44 @@ describe("native static layout command builders", () => { expect(args).toContain("-filter_complex"); expect(args).toContain( - "color=c=0x101010:s=1920x1080:r=60:d=60.000,format=nv12,hwupload_cuda[bg];" + - "[0:v]scale_cuda=w=1536:h=864:format=nv12,fps=60[fg];" + + "color=c=0x101010:s=1920x1080:r=60:d=60.000,format=nv12,setrange=limited,hwupload_cuda[bg];" + + "[0:v]scale_cuda=w=1536:h=864:format=nv12:passthrough=0,hwdownload,format=nv12,scale=in_range=auto:out_range=tv,fps=60,hwupload_cuda[fg];" + "[bg][fg]overlay_cuda=192:108:shortest=0:repeatlast=1:eof_action=repeat,trim=duration=60.000,setpts=PTS-STARTPTS[out]", ); expect(args).toContain("h264_nvenc"); expect(args).toContain("p1"); expect(args).not.toContain("yuv420p"); expect(args).toEqual(expect.arrayContaining(["-ss", "120.000", "-t", "60.000"])); + expect(args).toEqual(expect.arrayContaining([...FFMPEG_BT709_VIDEO_COLOR_ARGS])); + }); + + it("converts full-range canvas pixels and tags native H.264 as BT.709 video range", () => { + const args = buildNativeVideoExportArgs( + "h264_videotoolbox", + { + width: 1920, + height: 1080, + frameRate: 30, + bitrate: 30_000_000, + encodingMode: "quality", + }, + "out.mp4", + ); + + expect(args).toEqual( + expect.arrayContaining([ + "-vf", + "vflip,scale=in_range=full:out_range=tv", + "-colorspace", + "bt709", + "-color_primaries", + "bt709", + "-color_trc", + "bt709", + "-color_range", + "tv", + ]), + ); }); it("builds the stable CUDA scale plus CPU pad fallback command", () => { @@ -166,12 +198,13 @@ describe("native static layout command builders", () => { expect(args).toEqual( expect.arrayContaining([ "-vf", - "scale_cuda=w=1536:h=864:format=nv12:passthrough=0,hwdownload,format=nv12,fps=60,pad=w=1920:h=1080:x=192:y=108:color=0x101010", + "scale_cuda=w=1536:h=864:format=nv12:passthrough=0,hwdownload,format=nv12,scale=in_range=auto:out_range=tv,fps=60,pad=w=1920:h=1080:x=192:y=108:color=0x101010", "-map", "0:v:0", "-an", ]), ); + expect(args).toEqual(expect.arrayContaining([...FFMPEG_BT709_VIDEO_COLOR_ARGS])); }); it("sanitizes unsupported background colors to the safe dark fallback", () => { @@ -181,7 +214,7 @@ describe("native static layout command builders", () => { }); expect(args).toContain( - "scale_cuda=w=1536:h=864:format=nv12:passthrough=0,hwdownload,format=nv12,fps=60,pad=w=1920:h=1080:x=192:y=108:color=0x101010", + "scale_cuda=w=1536:h=864:format=nv12:passthrough=0,hwdownload,format=nv12,scale=in_range=auto:out_range=tv,fps=60,pad=w=1920:h=1080:x=192:y=108:color=0x101010", ); }); @@ -229,12 +262,14 @@ describe("native static layout command builders", () => { expect(args).toEqual(expect.arrayContaining(["-i", "background.png", "-i", "mask.pgm"])); expect(filterComplex).toContain( - "scale_cuda=w=1536:h=864:format=nv12:passthrough=0,hwdownload,format=nv12,fps=60,format=rgba", + "scale_cuda=w=1536:h=864:format=nv12:passthrough=0,hwdownload,format=nv12,scale=in_range=auto:out_range=full,fps=60,format=rgba", ); expect(filterComplex).toContain("[fgbase][mask]alphamerge[fg]"); expect(filterComplex).toContain("overlay=x=192:y=108:format=auto"); + expect(filterComplex).toContain("scale=in_range=full:out_range=tv,format=yuv420p[out]"); expect(args).toContain("h264_nvenc"); expect(args).toEqual(expect.arrayContaining(["-pix_fmt", "yuv420p"])); + expect(args).toEqual(expect.arrayContaining([...FFMPEG_BT709_VIDEO_COLOR_ARGS])); }); it("creates an opaque PGM mask for square video corners and a partial mask for radius", () => { diff --git a/electron/ipc/nativeVideoExport.ts b/electron/ipc/nativeVideoExport.ts index 07aa64a91..c8bacce1e 100644 --- a/electron/ipc/nativeVideoExport.ts +++ b/electron/ipc/nativeVideoExport.ts @@ -9,6 +9,21 @@ const NATIVE_EXPORT_INPUT_BYTES_PER_PIXEL = 4; const MIN_EDITED_TRACK_TEMPO_SPEED = 0.5; const MAX_EDITED_TRACK_TEMPO_SPEED = 2; +export const FFMPEG_BT709_VIDEO_COLOR_ARGS = [ + "-colorspace", + "bt709", + "-color_primaries", + "bt709", + "-color_trc", + "bt709", + "-color_range", + "tv", +] as const; + +const FFMPEG_AUTO_TO_VIDEO_RANGE_FILTER = "scale=in_range=auto:out_range=tv"; +const FFMPEG_AUTO_TO_FULL_RANGE_FILTER = "scale=in_range=auto:out_range=full"; +const FFMPEG_FULL_TO_VIDEO_RANGE_FILTER = "scale=in_range=full:out_range=tv"; + export type NativeExportEncodingMode = "fast" | "balanced" | "quality"; export type NativeVideoExportAudioMode = "none" | "copy-source" | "trim-source" | "edited-track"; @@ -296,7 +311,7 @@ export function buildNativeVideoExportArgs( "-i", "pipe:0", "-vf", - "vflip", + "vflip,scale=in_range=full:out_range=tv", "-an", "-c:v", encoder, @@ -309,7 +324,14 @@ export function buildNativeVideoExportArgs( args.push(...getLibx264ModeArgs(options.encodingMode)); } - args.push("-pix_fmt", "yuv420p", "-movflags", "+faststart", outputPath); + args.push( + "-pix_fmt", + "yuv420p", + ...FFMPEG_BT709_VIDEO_COLOR_ARGS, + "-movflags", + "+faststart", + outputPath, + ); return args; } @@ -328,7 +350,7 @@ export function buildNativeCudaOverlayStaticLayoutArgs( "-i", config.inputPath, "-filter_complex", - `color=c=${backgroundColor}:s=${config.width}x${config.height}:r=${config.frameRate}:d=${durationSec},format=nv12,hwupload_cuda[bg];[0:v]scale_cuda=w=${config.contentWidth}:h=${config.contentHeight}:format=nv12,fps=${config.frameRate}[fg];[bg][fg]overlay_cuda=${config.offsetX}:${config.offsetY}:shortest=0:repeatlast=1:eof_action=repeat,trim=duration=${durationSec},setpts=PTS-STARTPTS[out]`, + `color=c=${backgroundColor}:s=${config.width}x${config.height}:r=${config.frameRate}:d=${durationSec},format=nv12,setrange=limited,hwupload_cuda[bg];[0:v]scale_cuda=w=${config.contentWidth}:h=${config.contentHeight}:format=nv12:passthrough=0,hwdownload,format=nv12,${FFMPEG_AUTO_TO_VIDEO_RANGE_FILTER},fps=${config.frameRate},hwupload_cuda[fg];[bg][fg]overlay_cuda=${config.offsetX}:${config.offsetY}:shortest=0:repeatlast=1:eof_action=repeat,trim=duration=${durationSec},setpts=PTS-STARTPTS[out]`, "-map", "[out]", "-an", @@ -338,6 +360,7 @@ export function buildNativeCudaOverlayStaticLayoutArgs( "h264_nvenc", ...getNvencStaticLayoutModeArgs(config.encodingMode), ...getBitrateArgs(config.bitrate), + ...FFMPEG_BT709_VIDEO_COLOR_ARGS, "-movflags", "+faststart", config.outputPath, @@ -359,7 +382,7 @@ export function buildNativeCudaScaleCpuPadStaticLayoutArgs( "-i", config.inputPath, "-vf", - `scale_cuda=w=${config.contentWidth}:h=${config.contentHeight}:format=nv12:passthrough=0,hwdownload,format=nv12,fps=${config.frameRate},pad=w=${config.width}:h=${config.height}:x=${config.offsetX}:y=${config.offsetY}:color=${backgroundColor}`, + `scale_cuda=w=${config.contentWidth}:h=${config.contentHeight}:format=nv12:passthrough=0,hwdownload,format=nv12,${FFMPEG_AUTO_TO_VIDEO_RANGE_FILTER},fps=${config.frameRate},pad=w=${config.width}:h=${config.height}:x=${config.offsetX}:y=${config.offsetY}:color=${backgroundColor}`, "-map", "0:v:0", "-an", @@ -371,6 +394,7 @@ export function buildNativeCudaScaleCpuPadStaticLayoutArgs( ...getBitrateArgs(config.bitrate), "-pix_fmt", "yuv420p", + ...FFMPEG_BT709_VIDEO_COLOR_ARGS, "-movflags", "+faststart", config.outputPath, @@ -514,10 +538,10 @@ export function buildNativePrecompositedStaticLayoutArgs( ); } - const foregroundFilter = `[0:v]scale_cuda=w=${config.contentWidth}:h=${config.contentHeight}:format=nv12:passthrough=0,hwdownload,format=nv12,fps=${config.frameRate},format=rgba[fgbase]`; + const foregroundFilter = `[0:v]scale_cuda=w=${config.contentWidth}:h=${config.contentHeight}:format=nv12:passthrough=0,hwdownload,format=nv12,${FFMPEG_AUTO_TO_FULL_RANGE_FILTER},fps=${config.frameRate},format=rgba[fgbase]`; const maskFilter = useMask ? ";[2:v]format=gray[mask];[fgbase][mask]alphamerge[fg]" : ""; const foregroundLabel = useMask ? "fg" : "fgbase"; - const filterComplex = `${foregroundFilter}${maskFilter};[1:v]format=rgba[bg];[bg][${foregroundLabel}]overlay=x=${config.offsetX}:y=${config.offsetY}:format=auto,trim=duration=${durationSec},setpts=PTS-STARTPTS,format=yuv420p[out]`; + const filterComplex = `${foregroundFilter}${maskFilter};[1:v]format=rgba[bg];[bg][${foregroundLabel}]overlay=x=${config.offsetX}:y=${config.offsetY}:format=auto,trim=duration=${durationSec},setpts=PTS-STARTPTS,${FFMPEG_FULL_TO_VIDEO_RANGE_FILTER},format=yuv420p[out]`; args.push( "-filter_complex", @@ -533,6 +557,7 @@ export function buildNativePrecompositedStaticLayoutArgs( ...getBitrateArgs(config.bitrate), "-pix_fmt", "yuv420p", + ...FFMPEG_BT709_VIDEO_COLOR_ARGS, "-movflags", "+faststart", config.outputPath, diff --git a/electron/main.ts b/electron/main.ts index 38f4333ff..470fc8243 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -528,6 +528,12 @@ function createTray() { tray.on("double-click", () => focusOrCreateMainWindow()); } +function shouldUseTray() { + // macOS and Windows expose Recordly through their Dock/taskbar. Keep the + // tray entry only on Linux, where it remains the primary app entry point. + return process.platform === "linux"; +} + function getPublicAssetPath(filename: string) { return path.join(process.env.VITE_PUBLIC || RENDERER_DIST, filename); } @@ -1002,9 +1008,14 @@ app.whenReady().then(async () => { } }, 100); }); + if (process.platform === "darwin" && app.dock) { + await app.dock.show(); + } syncDockIcon(); - createTray(); - updateTrayMenu(); + if (shouldUseTray()) { + createTray(); + updateTrayMenu(); + } setupApplicationMenu(); // Ensure recordings directory exists await ensureRecordingsDir(); @@ -1031,8 +1042,10 @@ app.whenReady().then(async () => { (recording: boolean, sourceName: string) => { selectedSourceName = sourceName; setHudOverlayRecordingActive(recording); - if (!tray) createTray(); - updateTrayMenu(recording); + if (shouldUseTray()) { + if (!tray) createTray(); + updateTrayMenu(recording); + } if (recording) { reassertHudOverlayMouseState(); } diff --git a/electron/windows.ts b/electron/windows.ts index 8a10981f2..cccb9e243 100644 --- a/electron/windows.ts +++ b/electron/windows.ts @@ -10,6 +10,7 @@ import { resizeHudOverlayFallbackBounds, shouldExpandHudOverlayFallback, } from "./hudOverlayBounds"; +import { getHudOverlayTaskbarOptions } from "./hudOverlayWindowOptions"; import { getPackagedRendererBaseUrl } from "./rendererServer"; const electronWindowsDir = path.dirname(fileURLToPath(import.meta.url)); @@ -464,10 +465,11 @@ export function createHudOverlayWindow(): BrowserWindow { backgroundColor: "#00000000", resizable: false, alwaysOnTop: true, - skipTaskbar: true, + // The HUD is Recordly's persistent top-level window, so it owns the + // Windows taskbar entry while auxiliary overlays stay hidden there. + ...getHudOverlayTaskbarOptions(process.platform), hasShadow: false, show: false, - focusable: false, webPreferences: { preload: path.join(electronWindowsDir, "preload.mjs"), nodeIntegration: false, @@ -482,7 +484,13 @@ export function createHudOverlayWindow(): BrowserWindow { return; } hasShownHudWindow = true; - win.show(); + if (process.platform === "win32") { + // A focusable window is required for a Windows taskbar entry, but the + // always-on-top HUD must not steal focus when Recordly starts. + win.showInactive(); + } else { + win.show(); + } win.moveTop(); if (process.platform === "win32" && isHudOverlayMousePassthroughSupported()) { win.setIgnoreMouseEvents(false); @@ -693,7 +701,12 @@ export function createUpdateToastWindow(): BrowserWindow { win.setAlwaysOnTop(true, "status"); } - win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }); + win.setVisibleOnAllWorkspaces(true, { + visibleOnFullScreen: true, + // Keep Recordly a foreground application so macOS does not temporarily + // remove its Dock icon while showing an overlay window. + skipTransformProcessType: process.platform === "darwin", + }); updateToastWindow = win; win.on("closed", () => { @@ -1001,7 +1014,12 @@ export function createCountdownWindow(): BrowserWindow { countdownWindow = win; - win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }); + win.setVisibleOnAllWorkspaces(true, { + visibleOnFullScreen: true, + // Keep Recordly a foreground application so macOS does not temporarily + // remove its Dock icon while showing the countdown. + skipTransformProcessType: process.platform === "darwin", + }); win.webContents.on("did-finish-load", () => { if (!win.isDestroyed()) { diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx index 7490f454e..52e9fa43c 100644 --- a/src/components/video-editor/VideoPlayback.tsx +++ b/src/components/video-editor/VideoPlayback.tsx @@ -171,9 +171,7 @@ import { SNAP_TO_EDGES_RATIO_AUTO, } from "./videoPlayback/cursorFollowCamera"; import { clampFocusToStage as clampFocusToStageUtil } from "./videoPlayback/focusUtils"; -import { - layoutVideoContent as layoutVideoContentUtil, -} from "./videoPlayback/layoutUtils"; +import { layoutVideoContent as layoutVideoContentUtil } from "./videoPlayback/layoutUtils"; import { updateOverlayIndicator } from "./videoPlayback/overlayUtils"; import { createVideoEventHandlers } from "./videoPlayback/videoEventHandlers"; import { getWebcamMediaTargetTimeSeconds, shouldSeekWebcamMedia } from "./videoPlayback/webcamSync"; @@ -1084,6 +1082,7 @@ const VideoPlayback = forwardRef( motionBlurFilter.resolution = filterResolution; zoomBlurFilter.resolution = filterResolution; + cursorOverlayRef.current?.setFilterResolution(filterResolution); videoEffectsContainer.filterArea = new Rectangle(0, 0, stageWidth, stageHeight); }, []); @@ -1908,53 +1907,6 @@ const VideoPlayback = forwardRef( }); }, [pixiReady, videoReady, layoutVideoContent, cropRegion]); - useEffect(() => { - const previewFrame = previewFrameRef.current; - if (!previewFrame) { - return; - } - let frameId: number | null = null; - - const applyPreviewFrameSquircle = () => { - const width = previewFrame.offsetWidth; - const height = previewFrame.offsetHeight; - if (width <= 0 || height <= 0) { - return; - } - - const squirclePath = getSquircleSvgPath({ - x: 0, - y: 0, - width, - height, - radius: 12, - }); - previewFrame.style.clipPath = `path('${squirclePath}')`; - previewFrame.style.setProperty("-webkit-clip-path", `path('${squirclePath}')`); - }; - - applyPreviewFrameSquircle(); - - if (typeof ResizeObserver === "undefined") { - return; - } - - const observer = new ResizeObserver(() => { - if (frameId !== null) { - cancelAnimationFrame(frameId); - } - frameId = requestAnimationFrame(applyPreviewFrameSquircle); - }); - - observer.observe(previewFrame); - return () => { - if (frameId !== null) { - cancelAnimationFrame(frameId); - } - observer.disconnect(); - }; - }, []); - useEffect(() => { if (!pixiReady || !videoReady) return; const container = containerRef.current; @@ -2173,6 +2125,9 @@ const VideoPlayback = forwardRef( sway: cursorSwayRef.current, }); cursorOverlayRef.current = cursorOverlay; + cursorOverlay.setFilterResolution( + app.renderer.resolution || window.devicePixelRatio || 1, + ); cursorContainer.addChild(cursorOverlay.container); } else { cursorOverlayRef.current = null; @@ -2951,6 +2906,9 @@ const VideoPlayback = forwardRef( : resolvedWallpaperKind === "video" ? {} : { background: resolvedWallpaper || "" }; + // Overscan blurred wallpaper layers so the browser never samples transparent + // pixels beyond the preview bounds, which otherwise looks like a vignette. + const backgroundBlurOverscan = backgroundBlur > 0 ? Math.ceil(backgroundBlur * 2) : 0; const fallbackVideoClassName = pixiRendererError ? "absolute inset-0 h-full w-full object-cover" : "pointer-events-none absolute left-0 top-0 h-px w-px opacity-0"; @@ -2981,7 +2939,8 @@ const VideoPlayback = forwardRef( style={{ width: "100%", aspectRatio: formatAspectRatioForCSS(aspectRatio, nativeAspectRatio), - borderRadius: "12px", + borderRadius: 0, + clipPath: "none", }} > {/* Background layer */} @@ -2989,13 +2948,16 @@ const VideoPlayback = forwardRef(