diff --git a/electron/hudOverlayWindowOptions.test.ts b/electron/hudOverlayWindowOptions.test.ts new file mode 100644 index 00000000..08f8c653 --- /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 00000000..27fcd2de --- /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 957450ca..b9b7ba1a 100644 --- a/electron/ipc/nativeVideoExport.test.ts +++ b/electron/ipc/nativeVideoExport.test.ts @@ -11,6 +11,7 @@ import { buildNativeVideoExportArgs, buildTrimmedSourceAudioFilter, createNativeSquircleMaskPgmBuffer, + FFMPEG_BT709_VIDEO_COLOR_ARGS, isNativeCudaOutOfMemory, } from "./nativeVideoExport"; @@ -151,17 +152,15 @@ 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(["-colorspace", "bt709", "-color_range", "tv"]), - ); + 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", () => { @@ -199,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", () => { @@ -214,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", ); }); @@ -262,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 f15e35d0..c8bacce1 100644 --- a/electron/ipc/nativeVideoExport.ts +++ b/electron/ipc/nativeVideoExport.ts @@ -20,6 +20,10 @@ export const FFMPEG_BT709_VIDEO_COLOR_ARGS = [ "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"; @@ -346,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", @@ -378,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", @@ -534,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", diff --git a/electron/windows.ts b/electron/windows.ts index d7b38be3..cccb9e24 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)); @@ -466,10 +467,9 @@ export function createHudOverlayWindow(): BrowserWindow { alwaysOnTop: true, // The HUD is Recordly's persistent top-level window, so it owns the // Windows taskbar entry while auxiliary overlays stay hidden there. - skipTaskbar: process.platform !== "win32", + ...getHudOverlayTaskbarOptions(process.platform), hasShadow: false, show: false, - focusable: false, webPreferences: { preload: path.join(electronWindowsDir, "preload.mjs"), nodeIntegration: false, @@ -484,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); diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx index de81b01f..52e9fa43 100644 --- a/src/components/video-editor/VideoPlayback.tsx +++ b/src/components/video-editor/VideoPlayback.tsx @@ -2948,7 +2948,7 @@ const VideoPlayback = forwardRef(