diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 05564d9b..a705a9f8 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -318,6 +318,7 @@ interface Window { offsetY: number; backgroundColor: string; backgroundImagePath?: string | null; + backgroundBlurPx?: number; borderRadius?: number; shadowIntensity?: number; webcamInputPath?: string | null; diff --git a/electron/ipc/export/native-video.test.ts b/electron/ipc/export/native-video.test.ts index 250622b6..6dae5fda 100644 --- a/electron/ipc/export/native-video.test.ts +++ b/electron/ipc/export/native-video.test.ts @@ -49,6 +49,7 @@ vi.mock("node:child_process", () => ({ import { app } from "electron"; import { buildExperimentalNvidiaCudaStaticLayoutArgs, + buildExperimentalWindowsGpuStaticLayoutArgs, buildNativeStaticLayoutTimelineSegments, buildNativeVideoAudioMuxArgs, getExperimentalNvidiaCudaExportSkipReason, @@ -348,9 +349,7 @@ describe("resolveExperimentalNvidiaCudaExportScriptPath", () => { throw new Error(`missing ${candidate}`); }); - expect(await resolveExperimentalNvidiaCudaExportScriptPath()).toBe( - unpackedScriptPath, - ); + expect(await resolveExperimentalNvidiaCudaExportScriptPath()).toBe(unpackedScriptPath); } finally { if (originalEnv === undefined) { delete process.env[envName]; @@ -416,6 +415,19 @@ describe("buildExperimentalNvidiaCudaStaticLayoutArgs", () => { expect(args).toEqual(expect.arrayContaining(["--timeline-map", "timeline-map.csv"])); }); + it("passes background blur to the CUDA wrapper", () => { + const args = buildExperimentalNvidiaCudaStaticLayoutArgs( + createNvidiaCudaSkipOptions({ + backgroundImagePath: "wallpaper.jpg", + backgroundBlurPx: 36, + }), + "output.mp4", + "work", + ); + + expect(args).toEqual(expect.arrayContaining(["--background-blur", "36"])); + }); + it("passes webcam source-time controls to the CUDA wrapper", () => { const args = buildExperimentalNvidiaCudaStaticLayoutArgs( createNvidiaCudaSkipOptions({ @@ -470,6 +482,20 @@ describe("buildExperimentalNvidiaCudaStaticLayoutArgs", () => { }); }); +describe("buildExperimentalWindowsGpuStaticLayoutArgs", () => { + it("passes background blur to the D3D11 compositor", () => { + const args = buildExperimentalWindowsGpuStaticLayoutArgs( + createNvidiaCudaSkipOptions({ + backgroundImagePath: "wallpaper.jpg", + backgroundBlurPx: 36, + }), + "output.mp4", + ); + + expect(args).toEqual(expect.arrayContaining(["--background-blur", "36"])); + }); +}); + describe("buildNativeStaticLayoutTimelineSegments", () => { it("derives contiguous output timeline ranges from edited-track source segments", () => { expect( diff --git a/electron/ipc/export/native-video.ts b/electron/ipc/export/native-video.ts index ede5be0c..264925bb 100644 --- a/electron/ipc/export/native-video.ts +++ b/electron/ipc/export/native-video.ts @@ -99,6 +99,7 @@ export interface NativeStaticLayoutExportOptions { offsetY: number; backgroundColor: string; backgroundImagePath?: string | null; + backgroundBlurPx?: number; borderRadius?: number; shadowIntensity?: number; webcamInputPath?: string | null; @@ -1699,11 +1700,12 @@ function getNvidiaCudaBitrateMbps(options: NativeStaticLayoutExportOptions) { return Math.max(1, Math.round(options.bitrate / 1_000_000)); } -function buildExperimentalWindowsGpuStaticLayoutArgs( +export function buildExperimentalWindowsGpuStaticLayoutArgs( options: NativeStaticLayoutExportOptions, outputPath: string, ) { const shadowPixels = Math.round(clampUnit(options.shadowIntensity ?? 0) * 64); + const backgroundBlurPx = Math.max(0, options.backgroundBlurPx ?? 0); const pixelCount = options.width * options.height; const surfacePoolSize = pixelCount <= 1920 * 1080 ? 12 : 8; const args = [ @@ -1743,6 +1745,9 @@ function buildExperimentalWindowsGpuStaticLayoutArgs( if (options.backgroundImagePath) { args.push("--background-image", options.backgroundImagePath); } + if (backgroundBlurPx > 0) { + args.push("--background-blur", formatCliNumber(backgroundBlurPx)); + } if (options.webcamInputPath) { const webcamShadowPixels = Math.round(clampUnit(options.webcamShadowIntensity ?? 0) * 64); args.push( @@ -2085,6 +2090,7 @@ export function buildExperimentalNvidiaCudaStaticLayoutArgs( workDir: string, ) { const background = convertHexColorToNv12(options.backgroundColor); + const backgroundBlurPx = Math.max(0, options.backgroundBlurPx ?? 0); const shadowIntensityPct = Math.round(clampUnit(options.shadowIntensity ?? 0) * 100); const shadowOffsetY = shadowIntensityPct > 0 ? Math.max(1, Math.round(options.height * 0.012)) : 0; @@ -2128,6 +2134,9 @@ export function buildExperimentalNvidiaCudaStaticLayoutArgs( if (options.backgroundImagePath) { args.push("--background-image", options.backgroundImagePath); } + if (backgroundBlurPx > 0) { + args.push("--background-blur", formatCliNumber(backgroundBlurPx)); + } if (shadowOffsetY > 0 && shadowIntensityPct > 0) { args.push( "--shadow-offset-y", @@ -2572,6 +2581,7 @@ export async function exportNativeStaticLayoutVideo( offsetY: options.offsetY, backgroundColor: options.backgroundColor, backgroundImagePath: options.backgroundImagePath, + backgroundBlurPx: options.backgroundBlurPx, borderRadius: options.borderRadius, shadowIntensity: options.shadowIntensity, durationSec: options.durationSec, diff --git a/electron/ipc/nativeVideoExport.test.ts b/electron/ipc/nativeVideoExport.test.ts index 2f5ab12e..5992cadf 100644 --- a/electron/ipc/nativeVideoExport.test.ts +++ b/electron/ipc/nativeVideoExport.test.ts @@ -205,6 +205,19 @@ describe("native static layout command builders", () => { expect(args).toEqual(expect.arrayContaining(["-frames:v", "1", "background.png"])); }); + it("pre-blurs image wallpapers for native fallback static backgrounds", () => { + const args = buildNativeStaticBackgroundRenderArgs({ + ...baseConfig, + outputPath: "background.png", + backgroundImagePath: "wallpaper.jpg", + backgroundBlurPx: 36, + }); + const filterComplex = args[args.indexOf("-filter_complex") + 1]; + + expect(filterComplex).toContain("[bg0]gblur=sigma=36:steps=2[bg_blur]"); + expect(filterComplex).toContain("[bg_blur]format=rgba[out]"); + }); + it("builds a precomposited static layout command with a squircle alpha mask", () => { const args = buildNativePrecompositedStaticLayoutArgs({ ...baseConfig, diff --git a/electron/ipc/nativeVideoExport.ts b/electron/ipc/nativeVideoExport.ts index c2c3d5ed..21eb12fd 100644 --- a/electron/ipc/nativeVideoExport.ts +++ b/electron/ipc/nativeVideoExport.ts @@ -77,6 +77,7 @@ export interface NativeStaticLayoutExportArgsConfig { offsetY: number; backgroundColor: string; backgroundImagePath?: string | null; + backgroundBlurPx?: number; staticBackgroundPath?: string | null; maskPath?: string | null; borderRadius?: number; @@ -185,16 +186,22 @@ function clampUnitInterval(value: number): number { } function formatFfmpegNumber(value: number): string { - return Number.isInteger(value) ? String(value) : value.toFixed(6).replace(/0+$/, "").replace(/\.$/, ""); + return Number.isInteger(value) + ? String(value) + : value.toFixed(6).replace(/0+$/, "").replace(/\.$/, ""); } function isPointInsidePolygon(x: number, y: number, points: Array<{ x: number; y: number }>) { let inside = false; - for (let index = 0, previousIndex = points.length - 1; index < points.length; previousIndex = index++) { + for ( + let index = 0, previousIndex = points.length - 1; + index < points.length; + previousIndex = index++ + ) { const current = points[index]; const previous = points[previousIndex]; const intersects = - (current.y > y) !== (previous.y > y) && + current.y > y !== previous.y > y && x < ((previous.x - current.x) * (y - current.y)) / (previous.y - current.y) + current.x; if (intersects) { @@ -370,6 +377,7 @@ export function buildNativeStaticBackgroundRenderArgs( config: NativeStaticLayoutExportArgsConfig, ): string[] { const backgroundColor = formatFfmpegColor(config.backgroundColor); + const backgroundBlurPx = Math.max(0, config.backgroundBlurPx ?? 0); const args = ["-y", "-hide_banner", "-loglevel", "error"]; if (config.backgroundImagePath) { args.push("-i", config.backgroundImagePath); @@ -402,6 +410,14 @@ export function buildNativeStaticBackgroundRenderArgs( : "[0:v]format=rgba[bg0]", ]; let currentBackgroundLabel = "bg0"; + if (backgroundBlurPx > 0 && config.backgroundImagePath) { + filterParts.push( + `[${currentBackgroundLabel}]gblur=sigma=${formatFfmpegNumber( + Math.min(96, backgroundBlurPx), + )}:steps=2[bg_blur]`, + ); + currentBackgroundLabel = "bg_blur"; + } if (shadowLayers.length > 0) { filterParts.push( @@ -494,9 +510,7 @@ 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 maskFilter = useMask - ? ";[2:v]format=gray[mask];[fgbase][mask]alphamerge[fg]" - : ""; + 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]`; @@ -521,10 +535,7 @@ export function buildNativePrecompositedStaticLayoutArgs( return args; } -export function buildNativeConcatArgs(config: { - listPath: string; - outputPath: string; -}): string[] { +export function buildNativeConcatArgs(config: { listPath: string; outputPath: string }): string[] { return [ "-y", "-hide_banner", @@ -554,7 +565,11 @@ export function buildNativeStaticLayoutChunks( const safeChunkDuration = Math.max(1, Math.min(300, Math.floor(chunkDurationSec))); const chunks: NativeStaticLayoutChunk[] = []; - for (let startSec = 0, index = 0; startSec < durationSec; startSec += safeChunkDuration, index++) { + for ( + let startSec = 0, index = 0; + startSec < durationSec; + startSec += safeChunkDuration, index++ + ) { chunks.push({ index, startSec, diff --git a/electron/native/bin/win32-x64/helpers-manifest.json b/electron/native/bin/win32-x64/helpers-manifest.json index 3061bf01..047625ee 100644 --- a/electron/native/bin/win32-x64/helpers-manifest.json +++ b/electron/native/bin/win32-x64/helpers-manifest.json @@ -1,35 +1,35 @@ { - "version": 1, - "platform": "win32", - "arch": "x64", - "helpers": { - "wgc-capture": { - "binaryName": "wgc-capture.exe", - "binarySha256": "a0e2f0b98a903f89ab4706a56623f141604c3a311102e8914c2780745e94c635", - "sourceDir": "electron/native/wgc-capture", - "sourceFingerprint": "820d13741c659a0357d4d7944f3dbf4ce778b9716725b47ec088f573db0e7311", - "updatedAt": "2026-05-06T13:15:19.804Z" - }, - "cursor-monitor": { - "binaryName": "cursor-monitor.exe", - "binarySha256": "6ae6d91103b6e891a851e8ea5791e1c1f9aaab700134c18bc4c46cfffd7fdd12", - "sourceDir": "electron/native/cursor-monitor", - "sourceFingerprint": "6ad1b8b50bb336f2a48937b06f5ec56d90b6ab4a3e56a4bca278cf67a5d3e52e", - "updatedAt": "2026-05-05T18:42:26.007Z" - }, - "recordly-gpu-export": { - "binaryName": "recordly-gpu-export.exe", - "binarySha256": "e79cb823dd2cfade857db67696ae6456ae0ba3ae9381b6792c948ac648521bd4", - "sourceDir": "electron/native/gpu-export-probe", - "sourceFingerprint": "45aaa4c0cbeb45ecaa9ff0a87b870c33bd0d5b09fb5b3acef45b246970a12e7f", - "updatedAt": "2026-05-05T18:41:54.966Z" - }, - "recordly-nvidia-cuda-compositor": { - "binaryName": "recordly-nvidia-cuda-compositor.exe", - "binarySha256": "2913d78e2c1114d32c92fb4238749f7ff46131ec92cf3ad0b55af910c7cdbb07", - "sourceDir": "electron/native/nvidia-cuda-compositor", - "sourceFingerprint": "a60538981da7dd3d9645f59926d42f4f2f0f1f0663d3905e0416fdff8becb314", - "updatedAt": "2026-05-05T18:42:20.123Z" - } - } + "version": 1, + "platform": "win32", + "arch": "x64", + "helpers": { + "wgc-capture": { + "binaryName": "wgc-capture.exe", + "binarySha256": "a0e2f0b98a903f89ab4706a56623f141604c3a311102e8914c2780745e94c635", + "sourceDir": "electron/native/wgc-capture", + "sourceFingerprint": "820d13741c659a0357d4d7944f3dbf4ce778b9716725b47ec088f573db0e7311", + "updatedAt": "2026-05-06T13:15:19.804Z" + }, + "cursor-monitor": { + "binaryName": "cursor-monitor.exe", + "binarySha256": "6ae6d91103b6e891a851e8ea5791e1c1f9aaab700134c18bc4c46cfffd7fdd12", + "sourceDir": "electron/native/cursor-monitor", + "sourceFingerprint": "6ad1b8b50bb336f2a48937b06f5ec56d90b6ab4a3e56a4bca278cf67a5d3e52e", + "updatedAt": "2026-05-05T18:42:26.007Z" + }, + "recordly-gpu-export": { + "binaryName": "recordly-gpu-export.exe", + "binarySha256": "f147901f01dd5b410f67a5c0feae5064ea7556a878b333aa493e799498abb5e5", + "sourceDir": "electron/native/gpu-export-probe", + "sourceFingerprint": "056a4c41113c471379d0ec6095249c46a79b05fac5fc0e52ce0bfdf6de4d91e1", + "updatedAt": "2026-05-06T13:51:01.756Z" + }, + "recordly-nvidia-cuda-compositor": { + "binaryName": "recordly-nvidia-cuda-compositor.exe", + "binarySha256": "ec594808577011407fe40ea4e610a6b6fd2cbc7c5dde4627f48351a8a5a980c3", + "sourceDir": "electron/native/nvidia-cuda-compositor", + "sourceFingerprint": "6dd2a015326ac984c0a88581c809d628592f174e02a6813d24356c22e7384f1a", + "updatedAt": "2026-05-06T13:51:30.019Z" + } + } } diff --git a/electron/native/bin/win32-x64/recordly-gpu-export.exe b/electron/native/bin/win32-x64/recordly-gpu-export.exe index 5b1662d1..462f7c3e 100644 Binary files a/electron/native/bin/win32-x64/recordly-gpu-export.exe and b/electron/native/bin/win32-x64/recordly-gpu-export.exe differ diff --git a/electron/native/bin/win32-x64/recordly-nvidia-cuda-compositor.exe b/electron/native/bin/win32-x64/recordly-nvidia-cuda-compositor.exe index 9443ee8e..b3183623 100644 Binary files a/electron/native/bin/win32-x64/recordly-nvidia-cuda-compositor.exe and b/electron/native/bin/win32-x64/recordly-nvidia-cuda-compositor.exe differ diff --git a/electron/native/gpu-export-probe/src/main.cpp b/electron/native/gpu-export-probe/src/main.cpp index 9f34bdda..fd842ed2 100644 --- a/electron/native/gpu-export-probe/src/main.cpp +++ b/electron/native/gpu-export-probe/src/main.cpp @@ -60,6 +60,7 @@ struct Options { float backgroundR = 0.035f; float backgroundG = 0.035f; float backgroundB = 0.045f; + float backgroundBlurPx = 0.0f; std::wstring backgroundImagePath; std::wstring webcamInputPath; LONG webcamLeft = -1; @@ -124,9 +125,9 @@ struct ShaderConstants { float cursorAtlasAnchorY; float cursorAtlasAspect; float cursorBounceScale; - float cursorPadding0; - float cursorPadding1; - float cursorPadding2; + float backgroundBlurPx; + float backgroundBlurPadding0; + float backgroundBlurPadding1; float zoomEnabled; float zoomScale; float zoomX; @@ -380,6 +381,9 @@ Options parseOptions(int argc, wchar_t** argv) { options.radius = parseFloatArg(args, L"--radius", options.radius); options.shadow = parseFloatArg(args, L"--shadow", options.shadow); options.padding = parseFloatArg(args, L"--padding", options.padding); + options.backgroundBlurPx = std::max( + 0.0f, + parseFloatArg(args, L"--background-blur", options.backgroundBlurPx)); options.contentLeft = parseLongArg(args, L"--content-left", options.contentLeft); options.contentTop = parseLongArg(args, L"--content-top", options.contentTop); options.contentWidth = parseLongArg(args, L"--content-width", options.contentWidth); @@ -1498,9 +1502,9 @@ cbuffer CompositorConstants : register(b0) { float cursorAtlasAnchorY; float cursorAtlasAspect; float cursorBounceScale; - float cursorPadding0; - float cursorPadding1; - float cursorPadding2; + float backgroundBlurPx; + float backgroundBlurPadding0; + float backgroundBlurPadding1; float zoomEnabled; float zoomScale; float zoomX; @@ -1583,6 +1587,48 @@ float sampleCursorAtlasShadow(float2 cursorLocal, float cursorWidth, float curso return saturate(alpha); } +float2 getBackgroundCoverUv(float2 uv) { + float2 backgroundUv = uv; + float outputAspect = outputWidth / outputHeight; + float backgroundAspect = backgroundImageWidth / backgroundImageHeight; + if (backgroundAspect > outputAspect) { + backgroundUv.x = 0.5 + ((backgroundUv.x - 0.5) * (outputAspect / backgroundAspect)); + } else { + backgroundUv.y = 0.5 + ((backgroundUv.y - 0.5) * (backgroundAspect / outputAspect)); + } + return saturate(backgroundUv); +} + +float4 sampleBackground(float2 uv) { + if (backgroundImageEnabled <= 0.5) { + return float4(backgroundR, backgroundG, backgroundB, backgroundA); + } + + float2 backgroundUv = getBackgroundCoverUv(uv); + float safeBlur = min(max(backgroundBlurPx, 0.0), 96.0); + if (safeBlur <= 0.001) { + return backgroundTexture.Sample(linearSampler, backgroundUv); + } + + float2 texel = float2(1.0 / max(outputWidth, 1.0), 1.0 / max(outputHeight, 1.0)); + float2 r1 = texel * safeBlur * 0.35; + float2 r2 = texel * safeBlur * 0.70; + float4 color = backgroundTexture.Sample(linearSampler, backgroundUv) * 0.20; + color += backgroundTexture.Sample(linearSampler, saturate(backgroundUv + float2( r1.x, 0.0))) * 0.10; + color += backgroundTexture.Sample(linearSampler, saturate(backgroundUv + float2(-r1.x, 0.0))) * 0.10; + color += backgroundTexture.Sample(linearSampler, saturate(backgroundUv + float2(0.0, r1.y))) * 0.10; + color += backgroundTexture.Sample(linearSampler, saturate(backgroundUv + float2(0.0, -r1.y))) * 0.10; + color += backgroundTexture.Sample(linearSampler, saturate(backgroundUv + float2( r2.x, r2.y))) * 0.05; + color += backgroundTexture.Sample(linearSampler, saturate(backgroundUv + float2(-r2.x, r2.y))) * 0.05; + color += backgroundTexture.Sample(linearSampler, saturate(backgroundUv + float2( r2.x, -r2.y))) * 0.05; + color += backgroundTexture.Sample(linearSampler, saturate(backgroundUv + float2(-r2.x, -r2.y))) * 0.05; + color += backgroundTexture.Sample(linearSampler, saturate(backgroundUv + float2( r2.x, 0.0))) * 0.05; + color += backgroundTexture.Sample(linearSampler, saturate(backgroundUv + float2(-r2.x, 0.0))) * 0.05; + color += backgroundTexture.Sample(linearSampler, saturate(backgroundUv + float2(0.0, r2.y))) * 0.05; + color += backgroundTexture.Sample(linearSampler, saturate(backgroundUv + float2(0.0, -r2.y))) * 0.05; + return color; +} + float4 main(PSIn input) : SV_Target { float2 outputSize = float2(outputWidth, outputHeight); float2 pixel = input.uv * outputSize; @@ -1604,18 +1650,7 @@ float4 main(PSIn input) : SV_Target { outsideAlpha * shadowA; - float4 background = float4(backgroundR, backgroundG, backgroundB, backgroundA); - if (backgroundImageEnabled > 0.5) { - float2 backgroundUv = input.uv; - float outputAspect = outputWidth / outputHeight; - float backgroundAspect = backgroundImageWidth / backgroundImageHeight; - if (backgroundAspect > outputAspect) { - backgroundUv.x = 0.5 + ((backgroundUv.x - 0.5) * (outputAspect / backgroundAspect)); - } else { - backgroundUv.y = 0.5 + ((backgroundUv.y - 0.5) * (backgroundAspect / outputAspect)); - } - background = backgroundTexture.Sample(linearSampler, saturate(backgroundUv)); - } + float4 background = sampleBackground(input.uv); float4 shadow = float4(shadowR, shadowG, shadowB, shadowAlpha); float4 content = contentTexture.Sample(linearSampler, saturate(contentPixel / outputSize)); @@ -2761,7 +2796,7 @@ float4 main(PSIn input) : SV_Target { cursorAtlasEnabled ? cursorAtlasEntry->anchorY : 0.0f, cursorAtlasEnabled ? cursorAtlasEntry->aspectRatio : 1.0f, cursorEnabled ? cursor.bounceScale : 1.0f, - 0.0f, + hasBackgroundImage_ ? options_.backgroundBlurPx : 0.0f, 0.0f, 0.0f, zoomEnabled ? 1.0f : 0.0f, diff --git a/electron/native/nvidia-cuda-compositor/run-mp4-pipeline.mjs b/electron/native/nvidia-cuda-compositor/run-mp4-pipeline.mjs index af9ced72..a9a5ae73 100644 --- a/electron/native/nvidia-cuda-compositor/run-mp4-pipeline.mjs +++ b/electron/native/nvidia-cuda-compositor/run-mp4-pipeline.mjs @@ -680,13 +680,7 @@ function ffprobeJson(args) { } async function ffprobeCsvAsync(args) { - const result = await runAsync(ffprobeCommand, [ - "-v", - "error", - ...args, - "-of", - "csv=p=0", - ]); + const result = await runAsync(ffprobeCommand, ["-v", "error", ...args, "-of", "csv=p=0"]); return result.stdout; } @@ -865,15 +859,23 @@ function roundedRectMaskExpression({ x, y, width, height, radius }) { return `${centerBand}+${middleBand}+${topLeft}+${topRight}+${bottomLeft}+${bottomRight}`; } -function createBackgroundFilter(videoInfo, shadowOptions) { - const base = `[0:v]scale=${videoInfo.width}:${videoInfo.height}:force_original_aspect_ratio=increase,crop=${videoInfo.width}:${videoInfo.height},format=rgba[bg]`; +function createBackgroundFilter(videoInfo, shadowOptions, blurPx = 0) { + const safeBlurPx = Math.max(0, Math.min(96, Math.round(Number.isFinite(blurPx) ? blurPx : 0))); + const scaled = `[0:v]scale=${videoInfo.width}:${videoInfo.height}:force_original_aspect_ratio=increase,crop=${videoInfo.width}:${videoInfo.height},format=rgba[bg_scaled]`; + const blurFilter = + safeBlurPx > 0 + ? `;[bg_scaled]boxblur=luma_radius=${safeBlurPx}:luma_power=1:chroma_radius=${safeBlurPx}:chroma_power=1:alpha_radius=${safeBlurPx}:alpha_power=1[bg]` + : ";[bg_scaled]null[bg]"; if (!shadowOptions) { return { filterArgs: [ - "-vf", - `scale=${videoInfo.width}:${videoInfo.height}:force_original_aspect_ratio=increase,crop=${videoInfo.width}:${videoInfo.height},format=nv12`, + "-filter_complex", + `${scaled}${blurFilter};[bg]format=nv12[out]`, + "-map", + "[out]", ], bakedShadow: false, + backgroundBlur: safeBlurPx, }; } @@ -894,13 +896,14 @@ function createBackgroundFilter(videoInfo, shadowOptions) { "-i", `color=c=black@0.0:s=${videoInfo.width}x${videoInfo.height}:d=1`, "-filter_complex", - `${base};${shadow};[bg][shadow]overlay=format=auto,format=nv12[out]`, + `${scaled}${blurFilter};${shadow};[bg][shadow]overlay=format=auto,format=nv12[out]`, "-map", "[out]", ], bakedShadow: true, shadowAlpha, shadowBlur, + backgroundBlur: safeBlurPx, }; } @@ -949,6 +952,7 @@ const backgroundY = Math.round(getNonNegativeNumberArg("--background-y", 16)); const backgroundU = Math.round(getNonNegativeNumberArg("--background-u", 128)); const backgroundV = Math.round(getNonNegativeNumberArg("--background-v", 128)); const backgroundImage = getArg("--background-image", ""); +const backgroundBlurPx = getNonNegativeNumberArg("--background-blur", 0); const backgroundNv12 = getArg("--background-nv12", ""); const shadowOffsetY = Math.round(getNonNegativeNumberArg("--shadow-offset-y", 0)); const shadowIntensityPct = Math.round(getNonNegativeNumberArg("--shadow-intensity-pct", 0)); @@ -1020,9 +1024,14 @@ const shouldBakeStaticShadow = contentHeight > 0 && shadowOffsetY > 0 && shadowIntensityPct > 0; -const backgroundSuffix = shouldBakeStaticShadow - ? `.shadow-${shadowOffsetY}-${shadowIntensityPct}` - : ""; +const backgroundSuffixParts = []; +if (shouldBakeStaticShadow) { + backgroundSuffixParts.push(`shadow-${shadowOffsetY}-${shadowIntensityPct}`); +} +if (backgroundBlurPx > 0) { + backgroundSuffixParts.push(`blur-${Math.round(backgroundBlurPx)}`); +} +const backgroundSuffix = backgroundSuffixParts.length ? `.${backgroundSuffixParts.join(".")}` : ""; const generatedBackgroundNv12Path = join(workDir, `${baseName}${backgroundSuffix}.background.nv12`); const generatedWebcamNv12Path = join( workDir, @@ -1098,6 +1107,7 @@ const backgroundFilter = createBackgroundFilter( intensityPct: shadowIntensityPct, } : null, + backgroundBlurPx, ); const backgroundConvertPromise = diff --git a/electron/preload.ts b/electron/preload.ts index 146beacc..b47ec5a1 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -215,6 +215,7 @@ contextBridge.exposeInMainWorld("electronAPI", { offsetY: number; backgroundColor: string; backgroundImagePath?: string | null; + backgroundBlurPx?: number; borderRadius?: number; shadowIntensity?: number; webcamInputPath?: string | null; diff --git a/src/lib/exporter/modernVideoExporter.nativeStaticLayout.test.ts b/src/lib/exporter/modernVideoExporter.nativeStaticLayout.test.ts index 539159d2..c97af08a 100644 --- a/src/lib/exporter/modernVideoExporter.nativeStaticLayout.test.ts +++ b/src/lib/exporter/modernVideoExporter.nativeStaticLayout.test.ts @@ -175,7 +175,7 @@ describe("ModernVideoExporter native static-layout eligibility", () => { ).toBe("unsupported-frame-overlay"); }); - it("reports background blur as the remaining native overlay blocker", () => { + it("allows native static-layout with background blur", () => { const exporter = createExporter({ backgroundBlur: 12 }); expect( @@ -187,7 +187,7 @@ describe("ModernVideoExporter native static-layout eligibility", () => { videoInfo, 60, ), - ).toBe("unsupported-background-blur"); + ).toBeNull(); }); it("allows non-tail trim timelines with native static-layout", () => { diff --git a/src/lib/exporter/modernVideoExporter.ts b/src/lib/exporter/modernVideoExporter.ts index 6d4eaa39..0ccb0702 100644 --- a/src/lib/exporter/modernVideoExporter.ts +++ b/src/lib/exporter/modernVideoExporter.ts @@ -1257,10 +1257,6 @@ export class ModernVideoExporter { return "unsupported-frame-overlay"; } - if (this.config.backgroundBlur > 0) { - return "unsupported-background-blur"; - } - return this.isDefaultCropRegion() ? null : "non-default-crop"; } @@ -1895,6 +1891,7 @@ export class ModernVideoExporter { offsetY, backgroundColor: background.backgroundColor, backgroundImagePath: background.backgroundImagePath ?? null, + backgroundBlurPx: Math.max(0, (this.config.backgroundBlur ?? 0) * 3), borderRadius, shadowIntensity, webcamInputPath: webcamOverlay?.inputPath ?? null,