diff --git a/electron/ipc/export/native-video.test.ts b/electron/ipc/export/native-video.test.ts index 1c82a8d4..ee31dcdd 100644 --- a/electron/ipc/export/native-video.test.ts +++ b/electron/ipc/export/native-video.test.ts @@ -406,7 +406,9 @@ describe("getNativeExportCapabilities", () => { expect(capabilities.nvidiaCuda.available).toBe(process.platform === "win32"); expect(capabilities.nvidiaCuda.hasWrapper).toBe(process.platform === "win32"); - expect(capabilities.nvidiaCuda.hasNvidiaGpu).toBe(process.platform === "win32" ? true : null); + expect(capabilities.nvidiaCuda.hasNvidiaGpu).toBe( + process.platform === "win32" ? true : null, + ); }); }); @@ -600,6 +602,16 @@ describe("resolveExperimentalNvidiaCudaExportScriptPath", () => { }); describe("buildExperimentalNvidiaCudaStaticLayoutArgs", () => { + it("passes output canvas dimensions to the CUDA wrapper", () => { + const args = buildExperimentalNvidiaCudaStaticLayoutArgs( + createNvidiaCudaSkipOptions({ width: 1020, height: 572 }), + "output.mp4", + "work", + ); + + expect(args).toEqual(expect.arrayContaining(["--width", "1020", "--height", "572"])); + }); + it("keeps explicit copy-source CUDA audio inline by default", () => { const args = buildExperimentalNvidiaCudaStaticLayoutArgs( createNvidiaCudaSkipOptions({ diff --git a/electron/ipc/export/native-video.ts b/electron/ipc/export/native-video.ts index ad884651..16b2993e 100644 --- a/electron/ipc/export/native-video.ts +++ b/electron/ipc/export/native-video.ts @@ -1990,9 +1990,7 @@ function isNvidiaCudaForceVideoOnlyEnabled() { return process.env[NVIDIA_CUDA_FORCE_VIDEO_ONLY_ENV] === "1"; } -export function getNvidiaCudaAutoStallTimeoutMs( - autoCandidateActive = false, -) { +export function getNvidiaCudaAutoStallTimeoutMs(autoCandidateActive = false) { if (!autoCandidateActive && !isExplicitNvidiaCudaExportEnabled()) { return null; } @@ -2664,6 +2662,10 @@ export function buildExperimentalNvidiaCudaStaticLayoutArgs( outputPath, "--work-dir", workDir, + "--width", + String(options.width), + "--height", + String(options.height), "--fps", String(Math.max(1, Math.round(options.frameRate))), "--bitrate-mbps", diff --git a/electron/ipc/register/settings.ts b/electron/ipc/register/settings.ts index cecabb5f..e84f6317 100644 --- a/electron/ipc/register/settings.ts +++ b/electron/ipc/register/settings.ts @@ -22,7 +22,7 @@ import { import { parseJsonWithByteOrderMark } from "../utils"; const BROWSER_MICROPHONE_PROFILE_ENV = "RECORDLY_BROWSER_MIC_PROFILE"; -const DEFAULT_BROWSER_MICROPHONE_PROFILE = "no-agc"; +const DEFAULT_BROWSER_MICROPHONE_PROFILE = "processed"; const BROWSER_MICROPHONE_PROFILES = new Set([ "processed", "no-agc", @@ -60,14 +60,18 @@ function writeAppSettingsStore(store: Record) { writeFileSync(APP_SETTINGS_FILE, JSON.stringify(store, null, 2), "utf-8"); } -export function registerSettingsHandlers() { - ipcMain.handle('app:getVersion', () => { - return app.getVersion() - }) +function hasAppSetting(store: Record, key: string): boolean { + return Reflect.getOwnPropertyDescriptor(store, key) !== undefined; +} - ipcMain.handle('get-platform', () => { - return process.platform; - }); +export function registerSettingsHandlers() { + ipcMain.handle("app:getVersion", () => { + return app.getVersion(); + }); + + ipcMain.handle("get-platform", () => { + return process.platform; + }); ipcMain.on("app-settings:get", (event, key: unknown) => { try { @@ -79,7 +83,7 @@ export function registerSettingsHandlers() { const store = readAppSettingsStore(); event.returnValue = { success: true, - value: Object.prototype.hasOwnProperty.call(store, key) ? store[key] : null, + value: hasAppSetting(store, key) ? store[key] : null, }; } catch (error) { console.error("Failed to read app setting:", error); @@ -104,173 +108,201 @@ export function registerSettingsHandlers() { } }); - // --------------------------------------------------------------------------- - // Cursor hiding for the browser-capture fallback. - // The IPC promise resolves only after the cursor hide attempt completes. - // --------------------------------------------------------------------------- - ipcMain.handle('hide-cursor', () => { - if (process.platform !== 'win32') { - return { success: true } - } + // --------------------------------------------------------------------------- + // Cursor hiding for the browser-capture fallback. + // The IPC promise resolves only after the cursor hide attempt completes. + // --------------------------------------------------------------------------- + ipcMain.handle("hide-cursor", () => { + if (process.platform !== "win32") { + return { success: true }; + } - return { success: hideCursor() } - }) + return { success: hideCursor() }; + }); - ipcMain.handle('get-shortcuts', async () => { - try { - const data = await fs.readFile(SHORTCUTS_FILE, 'utf-8'); - return parseJsonWithByteOrderMark(data); - } catch { - return null; - } - }); + ipcMain.handle("get-shortcuts", async () => { + try { + const data = await fs.readFile(SHORTCUTS_FILE, "utf-8"); + return parseJsonWithByteOrderMark(data); + } catch { + return null; + } + }); - ipcMain.handle('save-shortcuts', async (_, shortcuts: unknown) => { - try { - await fs.writeFile(SHORTCUTS_FILE, JSON.stringify(shortcuts, null, 2), 'utf-8'); - return { success: true }; - } catch (error) { - console.error('Failed to save shortcuts:', error); - return { success: false, error: String(error) }; - } - }); + ipcMain.handle("save-shortcuts", async (_, shortcuts: unknown) => { + try { + await fs.writeFile(SHORTCUTS_FILE, JSON.stringify(shortcuts, null, 2), "utf-8"); + return { success: true }; + } catch (error) { + console.error("Failed to save shortcuts:", error); + return { success: false, error: String(error) }; + } + }); - // --------------------------------------------------------------------------- - // Countdown timer before recording - // --------------------------------------------------------------------------- - ipcMain.handle('get-recording-preferences', async () => { - try { - const content = await fs.readFile(RECORDINGS_SETTINGS_FILE, 'utf-8') - const parsed = parseJsonWithByteOrderMark>(content) - return { - success: true, - microphoneEnabled: parsed.microphoneEnabled === true, - microphoneDeviceId: typeof parsed.microphoneDeviceId === 'string' ? parsed.microphoneDeviceId : undefined, - systemAudioEnabled: parsed.systemAudioEnabled === true, - } - } catch { - return { success: true, microphoneEnabled: false, microphoneDeviceId: undefined, systemAudioEnabled: false } - } - }) + // --------------------------------------------------------------------------- + // Countdown timer before recording + // --------------------------------------------------------------------------- + ipcMain.handle("get-recording-preferences", async () => { + try { + const content = await fs.readFile(RECORDINGS_SETTINGS_FILE, "utf-8"); + const parsed = parseJsonWithByteOrderMark>(content); + return { + success: true, + microphoneEnabled: parsed.microphoneEnabled === true, + microphoneDeviceId: + typeof parsed.microphoneDeviceId === "string" + ? parsed.microphoneDeviceId + : undefined, + systemAudioEnabled: parsed.systemAudioEnabled === true, + }; + } catch { + return { + success: true, + microphoneEnabled: false, + microphoneDeviceId: undefined, + systemAudioEnabled: false, + }; + } + }); - ipcMain.handle('get-recording-audio-lab-config', () => { - return getBrowserMicrophoneProfileFromEnv() - }) + ipcMain.handle("get-recording-audio-lab-config", () => { + return getBrowserMicrophoneProfileFromEnv(); + }); - ipcMain.handle('set-recording-preferences', async (_, prefs: { microphoneEnabled?: boolean; microphoneDeviceId?: string; systemAudioEnabled?: boolean }) => { - try { - let existing: Record = {} - try { - const content = await fs.readFile(RECORDINGS_SETTINGS_FILE, 'utf-8') - existing = parseJsonWithByteOrderMark>(content) - } catch { - // file doesn't exist yet - } - const merged = { ...existing, ...prefs } - await fs.writeFile(RECORDINGS_SETTINGS_FILE, JSON.stringify(merged, null, 2), 'utf-8') - return { success: true } - } catch (error) { - console.error('Failed to save recording preferences:', error) - return { success: false, error: String(error) } - } - }) + ipcMain.handle( + "set-recording-preferences", + async ( + _, + prefs: { + microphoneEnabled?: boolean; + microphoneDeviceId?: string; + systemAudioEnabled?: boolean; + }, + ) => { + try { + let existing: Record = {}; + try { + const content = await fs.readFile(RECORDINGS_SETTINGS_FILE, "utf-8"); + existing = parseJsonWithByteOrderMark>(content); + } catch { + // file doesn't exist yet + } + const merged = { ...existing, ...prefs }; + await fs.writeFile( + RECORDINGS_SETTINGS_FILE, + JSON.stringify(merged, null, 2), + "utf-8", + ); + return { success: true }; + } catch (error) { + console.error("Failed to save recording preferences:", error); + return { success: false, error: String(error) }; + } + }, + ); - ipcMain.handle('get-countdown-delay', async () => { - try { - const content = await fs.readFile(COUNTDOWN_SETTINGS_FILE, 'utf-8') - const parsed = parseJsonWithByteOrderMark<{ delay?: number }>(content) - return { success: true, delay: parsed.delay ?? 3 } - } catch { - return { success: true, delay: 3 } - } - }) + ipcMain.handle("get-countdown-delay", async () => { + try { + const content = await fs.readFile(COUNTDOWN_SETTINGS_FILE, "utf-8"); + const parsed = parseJsonWithByteOrderMark<{ delay?: number }>(content); + return { success: true, delay: parsed.delay ?? 3 }; + } catch { + return { success: true, delay: 3 }; + } + }); - ipcMain.handle('set-countdown-delay', async (_, delay: number) => { - try { - await fs.writeFile(COUNTDOWN_SETTINGS_FILE, JSON.stringify({ delay }, null, 2), 'utf-8') - return { success: true } - } catch (error) { - console.error('Failed to save countdown delay:', error) - return { success: false, error: String(error) } - } - }) + ipcMain.handle("set-countdown-delay", async (_, delay: number) => { + try { + await fs.writeFile( + COUNTDOWN_SETTINGS_FILE, + JSON.stringify({ delay }, null, 2), + "utf-8", + ); + return { success: true }; + } catch (error) { + console.error("Failed to save countdown delay:", error); + return { success: false, error: String(error) }; + } + }); - ipcMain.handle('start-countdown', async (_, seconds: number) => { - if (countdownInProgress) { - return { success: false, error: 'Countdown already in progress' } - } + ipcMain.handle("start-countdown", async (_, seconds: number) => { + if (countdownInProgress) { + return { success: false, error: "Countdown already in progress" }; + } - setCountdownInProgress(true) - setCountdownCancelled(false) - setCountdownRemaining(seconds) + setCountdownInProgress(true); + setCountdownCancelled(false); + setCountdownRemaining(seconds); - const countdownWin = createCountdownWindow() + const countdownWin = createCountdownWindow(); - if (countdownWin.webContents.isLoadingMainFrame()) { - await new Promise((resolve) => { - countdownWin.webContents.once('did-finish-load', () => { - resolve() - }) - }) - } + if (countdownWin.webContents.isLoadingMainFrame()) { + await new Promise((resolve) => { + countdownWin.webContents.once("did-finish-load", () => { + resolve(); + }); + }); + } - return new Promise<{ success: boolean; cancelled?: boolean }>((resolve) => { - let remaining = seconds - setCountdownRemaining(remaining) + return new Promise<{ success: boolean; cancelled?: boolean }>((resolve) => { + let remaining = seconds; + setCountdownRemaining(remaining); - countdownWin.webContents.send('countdown-tick', remaining) + countdownWin.webContents.send("countdown-tick", remaining); - setCountdownTimer(setInterval(() => { - if (countdownCancelled) { - if (countdownTimer) { - clearInterval(countdownTimer) - setCountdownTimer(null) - } - closeCountdownWindow() - setCountdownInProgress(false) - setCountdownRemaining(null) - resolve({ success: false, cancelled: true }) - return - } + setCountdownTimer( + setInterval(() => { + if (countdownCancelled) { + if (countdownTimer) { + clearInterval(countdownTimer); + setCountdownTimer(null); + } + closeCountdownWindow(); + setCountdownInProgress(false); + setCountdownRemaining(null); + resolve({ success: false, cancelled: true }); + return; + } - remaining-- - setCountdownRemaining(remaining) + remaining--; + setCountdownRemaining(remaining); - if (remaining <= 0) { - if (countdownTimer) { - clearInterval(countdownTimer) - setCountdownTimer(null) - } - closeCountdownWindow() - setCountdownInProgress(false) - setCountdownRemaining(null) - resolve({ success: true }) - } else { - const win = getCountdownWindow() - if (win && !win.isDestroyed()) { - win.webContents.send('countdown-tick', remaining) - } - } - }, 1000)) - }) - }) + if (remaining <= 0) { + if (countdownTimer) { + clearInterval(countdownTimer); + setCountdownTimer(null); + } + closeCountdownWindow(); + setCountdownInProgress(false); + setCountdownRemaining(null); + resolve({ success: true }); + } else { + const win = getCountdownWindow(); + if (win && !win.isDestroyed()) { + win.webContents.send("countdown-tick", remaining); + } + } + }, 1000), + ); + }); + }); - ipcMain.handle('cancel-countdown', () => { - setCountdownCancelled(true) - setCountdownInProgress(false) - setCountdownRemaining(null) - if (countdownTimer) { - clearInterval(countdownTimer) - setCountdownTimer(null) - } - closeCountdownWindow() - return { success: true } - }) + ipcMain.handle("cancel-countdown", () => { + setCountdownCancelled(true); + setCountdownInProgress(false); + setCountdownRemaining(null); + if (countdownTimer) { + clearInterval(countdownTimer); + setCountdownTimer(null); + } + closeCountdownWindow(); + return { success: true }; + }); - ipcMain.handle('get-active-countdown', () => { - return { - success: true, - seconds: countdownInProgress ? countdownRemaining : null, - } - }) + ipcMain.handle("get-active-countdown", () => { + return { + success: true, + seconds: countdownInProgress ? countdownRemaining : null, + }; + }); } diff --git a/electron/main.ts b/electron/main.ts index 0a50b6f9..ed05ffeb 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -49,6 +49,8 @@ import { getUpdateToastWindow, hideUpdateToastWindow, isHudOverlayMousePassthroughSupported, + reassertHudOverlayMousePassthrough as reassertHudOverlayMouseState, + setHudOverlayRecordingActive, showUpdateToastWindow, } from "./windows"; @@ -337,32 +339,6 @@ function focusOrCreateMainWindow() { } } -/** - * On Windows 10, focus changes and native notifications can break - * {@link BrowserWindow.setIgnoreMouseEvents} forwarding on the transparent HUD - * overlay, causing it to become permanently click-through. Call this after any - * operation that may alter focus or z-order so that hover detection keeps working. - */ -function reassertHudOverlayMouseState() { - if (process.platform !== "win32" || !isHudOverlayMousePassthroughSupported()) { - return; - } - - const hud = getHudOverlayWindow(); - if (!hud) { - return; - } - - // Toggle off then back on so the native WS_EX_TRANSPARENT flag is fully - // re-initialised rather than merely re-asserted in a potentially broken state. - hud.setIgnoreMouseEvents(false); - setTimeout(() => { - if (!hud.isDestroyed()) { - hud.setIgnoreMouseEvents(true, { forward: true }); - } - }, 50); -} - function isEditorWindow(window: BrowserWindow) { return window.webContents.getURL().includes("windowType=editor"); } @@ -803,7 +779,7 @@ function createEditorWindowWrapper() { const previousWindow = mainWindow; if (previousWindow && !previousWindow.isDestroyed()) { const closingEditorWindow = isEditorWindow(previousWindow); - + if (closingEditorWindow) { closeEditorWindowBypassingUnsavedPrompt(previousWindow); } else { @@ -985,6 +961,7 @@ app.whenReady().then(async () => { () => sourceSelectorWindow, (recording: boolean, sourceName: string) => { selectedSourceName = sourceName; + setHudOverlayRecordingActive(recording); if (!tray) createTray(); updateTrayMenu(recording); if (recording) { diff --git a/electron/native/bin/win32-x64/helpers-manifest.json b/electron/native/bin/win32-x64/helpers-manifest.json index 2df0f4e5..3a9f7dfa 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": "298b41f371c3881046061048b466e12ed70dd93fa761bade2fd57d1ccddf3cb9", - "sourceDir": "electron/native/wgc-capture", - "sourceFingerprint": "6ee457080c27dc939ff4b61965f86b6d73995e40200440a1dc44865f9708d39f", - "updatedAt": "2026-05-24T19:49:15.077Z" - }, - "cursor-monitor": { - "binaryName": "cursor-monitor.exe", - "binarySha256": "6ae6d91103b6e891a851e8ea5791e1c1f9aaab700134c18bc4c46cfffd7fdd12", - "sourceDir": "electron/native/cursor-monitor", - "sourceFingerprint": "6ad1b8b50bb336f2a48937b06f5ec56d90b6ab4a3e56a4bca278cf67a5d3e52e", - "updatedAt": "2026-05-07T15:22:18.173Z" - }, - "recordly-gpu-export": { - "binaryName": "recordly-gpu-export.exe", - "binarySha256": "4cb3a293fd36f718af55906820d9b3fd78babc855888c2e248f0b918ec1aff3c", - "sourceDir": "electron/native/gpu-export-probe", - "sourceFingerprint": "743b386a5f1bbcc99cec5465c3de228d2b045061dead31dfcbf25cf6a1e61de5", - "updatedAt": "2026-05-07T20:13:48.585Z" - }, - "recordly-nvidia-cuda-compositor": { - "binaryName": "recordly-nvidia-cuda-compositor.exe", - "binarySha256": "a787531c07142de7c292d1726e0339c97dbce5073d9a0853d539a725265fd945", - "sourceDir": "electron/native/nvidia-cuda-compositor", - "sourceFingerprint": "528b599e9d576d81ec087d0d4dc93a79af1bbf30fdb969f44f773bef90146135", - "updatedAt": "2026-05-07T20:14:13.794Z" - } - } + "version": 1, + "platform": "win32", + "arch": "x64", + "helpers": { + "wgc-capture": { + "binaryName": "wgc-capture.exe", + "binarySha256": "298b41f371c3881046061048b466e12ed70dd93fa761bade2fd57d1ccddf3cb9", + "sourceDir": "electron/native/wgc-capture", + "sourceFingerprint": "6ee457080c27dc939ff4b61965f86b6d73995e40200440a1dc44865f9708d39f", + "updatedAt": "2026-05-24T19:49:15.077Z" + }, + "cursor-monitor": { + "binaryName": "cursor-monitor.exe", + "binarySha256": "6ae6d91103b6e891a851e8ea5791e1c1f9aaab700134c18bc4c46cfffd7fdd12", + "sourceDir": "electron/native/cursor-monitor", + "sourceFingerprint": "6ad1b8b50bb336f2a48937b06f5ec56d90b6ab4a3e56a4bca278cf67a5d3e52e", + "updatedAt": "2026-05-07T15:22:18.173Z" + }, + "recordly-gpu-export": { + "binaryName": "recordly-gpu-export.exe", + "binarySha256": "4cb3a293fd36f718af55906820d9b3fd78babc855888c2e248f0b918ec1aff3c", + "sourceDir": "electron/native/gpu-export-probe", + "sourceFingerprint": "743b386a5f1bbcc99cec5465c3de228d2b045061dead31dfcbf25cf6a1e61de5", + "updatedAt": "2026-05-07T20:13:48.585Z" + }, + "recordly-nvidia-cuda-compositor": { + "binaryName": "recordly-nvidia-cuda-compositor.exe", + "binarySha256": "e9e062d1177075ac36eecf97e5934722a7fb782809e29ab60da54b396feb95b9", + "sourceDir": "electron/native/nvidia-cuda-compositor", + "sourceFingerprint": "021ea1abd83606b589f694ddedb3af8035e2e08e2e130cf4fff6d6a3f7d64833", + "updatedAt": "2026-05-27T10:56:40.171Z" + } + } } 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 0fd5f273..3e6ebd57 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/nvidia-cuda-compositor/run-mp4-pipeline.mjs b/electron/native/nvidia-cuda-compositor/run-mp4-pipeline.mjs index d1443497..255c98aa 100644 --- a/electron/native/nvidia-cuda-compositor/run-mp4-pipeline.mjs +++ b/electron/native/nvidia-cuda-compositor/run-mp4-pipeline.mjs @@ -860,9 +860,9 @@ function roundedRectMaskExpression({ x, y, width, height, radius }) { return `${centerBand}+${middleBand}+${topLeft}+${topRight}+${bottomLeft}+${bottomRight}`; } -function createBackgroundFilter(videoInfo, shadowOptions, blurPx = 0) { +function createBackgroundFilter(outputSize, 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 scaled = `[0:v]scale=${outputSize.width}:${outputSize.height}:force_original_aspect_ratio=increase,crop=${outputSize.width}:${outputSize.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]` @@ -895,7 +895,7 @@ function createBackgroundFilter(videoInfo, shadowOptions, blurPx = 0) { "-f", "lavfi", "-i", - `color=c=black@0.0:s=${videoInfo.width}x${videoInfo.height}:d=1`, + `color=c=black@0.0:s=${outputSize.width}x${outputSize.height}:d=1`, "-filter_complex", `${scaled}${blurFilter};${shadow};[bg][shadow]overlay=format=auto,format=nv12[out]`, "-map", @@ -930,6 +930,8 @@ const inputPath = resolve(getArg("--input")); const outputPath = resolve( getArg("--output", join(scriptDir, "recordly-nvdec-nvenc-mp4-output.mp4")), ); +const requestedOutputWidth = Math.round(getNumberArg("--width", 0)); +const requestedOutputHeight = Math.round(getNumberArg("--height", 0)); const fps = Math.round(getNumberArg("--fps", 30)); const bitrateMbps = Math.round(getNumberArg("--bitrate-mbps", 18)); const encodingMode = getArg("--encoding-mode", "balanced"); @@ -1050,6 +1052,8 @@ const sourcePtsPath = join(workDir, `${baseName}.source-pts.csv`); const videoInfo = getVideoInfo(inputPath); const webcamInfo = webcamInput ? getVideoInfo(webcamInput) : null; +const outputWidth = requestedOutputWidth > 0 ? requestedOutputWidth : videoInfo.width; +const outputHeight = requestedOutputHeight > 0 ? requestedOutputHeight : videoInfo.height; const timelineSegments = readTimelineSegments(timelineMap); const timelineOutputDurationSec = timelineSegments.length ? Math.max(...timelineSegments.map((segment) => segment.outputEndMs)) / 1000 @@ -1105,7 +1109,7 @@ let webcamSourceWindowFrames = webcamInfo : 0; const backgroundNv12Path = backgroundImage ? generatedBackgroundNv12Path : backgroundNv12; const backgroundFilter = createBackgroundFilter( - videoInfo, + { width: outputWidth, height: outputHeight }, shouldBakeStaticShadow ? { x: contentX, @@ -1261,6 +1265,10 @@ const encodeArgs = [ annexBPath, "--output", encodedPath, + "--width", + String(outputWidth), + "--height", + String(outputHeight), "--fps", String(fps), "--input-frames", diff --git a/electron/native/nvidia-cuda-compositor/src/main.cu b/electron/native/nvidia-cuda-compositor/src/main.cu index 6adcbcf7..8c5ea9dc 100644 --- a/electron/native/nvidia-cuda-compositor/src/main.cu +++ b/electron/native/nvidia-cuda-compositor/src/main.cu @@ -39,6 +39,8 @@ struct Options { std::string sourcePtsPath; std::string timelineMapPath; std::vector timelineSegments; + int width = 0; + int height = 0; int fps = 30; int maxFrames = 0; int inputFrames = 0; @@ -167,6 +169,10 @@ Options parseOptions(int argc, char** argv) { options.outputPath = requireValue("--output"); } else if (arg == "--source-pts") { options.sourcePtsPath = requireValue("--source-pts"); + } else if (arg == "--width") { + options.width = parsePositiveInt(requireValue("--width"), "--width"); + } else if (arg == "--height") { + options.height = parsePositiveInt(requireValue("--height"), "--height"); } else if (arg == "--timeline-map") { options.timelineMapPath = requireValue("--timeline-map"); } else if (arg == "--fps") { @@ -274,7 +280,7 @@ Options parseOptions(int argc, char** argv) { options.zoomSamplesPath = requireValue("--zoom-samples"); } else if (arg == "--help") { std::cout << "Usage: recordly-nvidia-cuda-compositor --input input.annexb.h264 " - "[--output out.h264] [--source-pts source-pts.csv] [--fps 30] " + "[--output out.h264] [--source-pts source-pts.csv] [--width N --height N] [--fps 30] " "[--max-frames N] [--bitrate-mbps N] [--encoding-mode fast|balanced|quality] " "[--post-select] [--callback-encode] [--stream-sync] [--prewarm-ms N] [--chunk-mb N] " "[--content-x N --content-y N --content-width N --content-height N --radius N] " @@ -320,6 +326,14 @@ bool hasWebcamOverlay(const Options& options) { return (!options.webcamNv12Path.empty() || !options.webcamAnnexbPath.empty()) && options.webcamSize > 0; } +int outputWidthForSource(const Options& options, int sourceWidth) { + return options.width > 0 ? options.width : sourceWidth; +} + +int outputHeightForSource(const Options& options, int sourceHeight) { + return options.height > 0 ? options.height : sourceHeight; +} + std::vector loadFramePts(const std::string& path) { std::vector timestamps; if (path.empty()) { @@ -2866,8 +2880,8 @@ void encodeMappedDisplayFrame( if (!*state->sink) { *state->sink = std::make_unique( state->context, - width, - height, + outputWidthForSource(*state->options, width), + outputHeightForSource(*state->options, height), state->options->fps, state->bitrate, state->options->outputPath, @@ -3106,8 +3120,8 @@ int main(int argc, char** argv) { if (!sink) { sink = std::make_unique( context, - decoder->GetWidth(), - decoder->GetHeight(), + outputWidthForSource(options, decoder->GetWidth()), + outputHeightForSource(options, decoder->GetHeight()), options.fps, bitrate, options.outputPath, @@ -3152,8 +3166,8 @@ int main(int argc, char** argv) { if (!sink) { sink = std::make_unique( context, - decoder->GetWidth(), - decoder->GetHeight(), + outputWidthForSource(options, decoder->GetWidth()), + outputHeightForSource(options, decoder->GetHeight()), options.fps, bitrate, options.outputPath, @@ -3225,8 +3239,8 @@ int main(int argc, char** argv) { << "\"syncMode\":\"" << (options.streamSync ? "stream" : "device") << "\"," << "\"prewarmMs\":" << options.prewarmMs << "," << "\"chunkMb\":" << options.chunkMb << "," - << "\"width\":" << decoder->GetWidth() << "," - << "\"height\":" << decoder->GetHeight() << "," + << "\"width\":" << outputWidthForSource(options, decoder->GetWidth()) << "," + << "\"height\":" << outputHeightForSource(options, decoder->GetHeight()) << "," << "\"fps\":" << options.fps << "," << "\"encodingMode\":\"" << options.encodingMode << "\"," << "\"staticLayout\":" << (hasStaticLayout(options) ? "true" : "false") << "," diff --git a/electron/windows.ts b/electron/windows.ts index a5e6cd01..982fa677 100644 --- a/electron/windows.ts +++ b/electron/windows.ts @@ -5,10 +5,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { app, BrowserWindow, ipcMain } from "electron"; import { USER_DATA_PATH } from "./appPaths"; -import { - getHudOverlayWindowBounds, - resizeHudOverlayFallbackBounds, -} from "./hudOverlayBounds"; +import { getHudOverlayWindowBounds, resizeHudOverlayFallbackBounds } from "./hudOverlayBounds"; import { getPackagedRendererBaseUrl } from "./rendererServer"; const electronWindowsDir = path.dirname(fileURLToPath(import.meta.url)); @@ -17,7 +14,8 @@ const nodeRequire = createRequire(import.meta.url); const APP_ROOT = path.join(electronWindowsDir, ".."); const VITE_DEV_SERVER_URL = process.env["VITE_DEV_SERVER_URL"]; const RENDERER_DIST = path.join(APP_ROOT, "dist"); -const WINDOW_ICON_FILENAME = process.platform === "darwin" ? "recordlymac-512.png" : "recordly-512.png"; +const WINDOW_ICON_FILENAME = + process.platform === "darwin" ? "recordlymac-512.png" : "recordly-512.png"; const WINDOW_ICON_PATH = path.join( process.env.VITE_PUBLIC || RENDERER_DIST, "app-icons", @@ -28,6 +26,9 @@ let hudOverlayWindow: BrowserWindow | null = null; let hudOverlayHiddenFromCapture = true; let hudOverlayCaptureProtectionLoaded = false; let hudOverlayFallbackExpanded = false; +let hudOverlayIgnoringMouse = true; +let hudOverlayMouseReassertTimer: NodeJS.Timeout | null = null; +let hudOverlayRecordingActive = false; let countdownWindow: BrowserWindow | null = null; let updateToastWindow: BrowserWindow | null = null; @@ -190,7 +191,7 @@ function getHudOverlayBounds() { const { workArea } = getHudOverlayDisplay(); return getHudOverlayWindowBounds( workArea, - isHudOverlayMousePassthroughSupported(), + isHudOverlayMousePassthroughSupported() && !hudOverlayRecordingActive, hudOverlayFallbackExpanded, ); } @@ -247,6 +248,11 @@ function positionUpdateToastWindow() { } function setHudOverlayFallbackExpanded(expanded: boolean) { + if (hudOverlayRecordingActive) { + hudOverlayFallbackExpanded = false; + return; + } + hudOverlayFallbackExpanded = expanded; if ( !hudOverlayWindow || @@ -269,23 +275,43 @@ function setHudOverlayFallbackExpanded(expanded: boolean) { } } -ipcMain.on("hud-overlay-set-ignore-mouse", (_event, ignore: boolean) => { - if (hudOverlayWindow && !hudOverlayWindow.isDestroyed()) { - if (!isHudOverlayMousePassthroughSupported()) { - if (process.platform !== "linux") { - setHudOverlayFallbackExpanded(!ignore); - } - hudOverlayWindow.setIgnoreMouseEvents(false); - return; - } +function setHudOverlayMousePassthrough(ignore: boolean) { + hudOverlayIgnoringMouse = hudOverlayRecordingActive ? false : ignore; - if (ignore) { - hudOverlayWindow.setIgnoreMouseEvents(true, { forward: true }); - return; - } - - hudOverlayWindow.setIgnoreMouseEvents(false); + if (hudOverlayMouseReassertTimer) { + clearTimeout(hudOverlayMouseReassertTimer); + hudOverlayMouseReassertTimer = null; } + + if (!hudOverlayWindow || hudOverlayWindow.isDestroyed()) { + return; + } + + if (hudOverlayRecordingActive) { + hudOverlayFallbackExpanded = false; + applyHudOverlayBounds(); + hudOverlayWindow.setIgnoreMouseEvents(false); + return; + } + + if (!isHudOverlayMousePassthroughSupported()) { + if (process.platform !== "linux") { + setHudOverlayFallbackExpanded(!ignore); + } + hudOverlayWindow.setIgnoreMouseEvents(false); + return; + } + + if (ignore) { + hudOverlayWindow.setIgnoreMouseEvents(true, { forward: true }); + return; + } + + hudOverlayWindow.setIgnoreMouseEvents(false); +} + +ipcMain.on("hud-overlay-set-ignore-mouse", (_event, ignore: boolean) => { + setHudOverlayMousePassthrough(Boolean(ignore)); }); // Keep compatibility with existing drag IPC/state. @@ -426,7 +452,7 @@ export function createHudOverlayWindow(): BrowserWindow { win.setIgnoreMouseEvents(false); setTimeout(() => { if (!win.isDestroyed()) { - win.setIgnoreMouseEvents(true, { forward: true }); + setHudOverlayMousePassthrough(hudOverlayIgnoringMouse); } }, 50); } @@ -437,7 +463,13 @@ export function createHudOverlayWindow(): BrowserWindow { } if (isHudOverlayMousePassthroughSupported()) { - win.setIgnoreMouseEvents(true, { forward: true }); + if (hudOverlayRecordingActive) { + hudOverlayIgnoringMouse = false; + win.setIgnoreMouseEvents(false); + } else { + hudOverlayIgnoringMouse = true; + win.setIgnoreMouseEvents(true, { forward: true }); + } } // On Windows 11+, focus changes (e.g. showing a native notification) can break @@ -452,7 +484,7 @@ export function createHudOverlayWindow(): BrowserWindow { win.setIgnoreMouseEvents(false); setTimeout(() => { if (!win.isDestroyed()) { - win.setIgnoreMouseEvents(true, { forward: true }); + setHudOverlayMousePassthrough(hudOverlayIgnoringMouse); } }, 50); } @@ -566,16 +598,32 @@ export function reassertHudOverlayMousePassthrough(): void { return; } + if (hudOverlayRecordingActive) { + hud.setIgnoreMouseEvents(false); + return; + } + // Toggle off then back on so the native WS_EX_TRANSPARENT flag is fully // re-initialised rather than merely re-asserted in a potentially broken state. hud.setIgnoreMouseEvents(false); - setTimeout(() => { + if (hudOverlayMouseReassertTimer) { + clearTimeout(hudOverlayMouseReassertTimer); + } + hudOverlayMouseReassertTimer = setTimeout(() => { + hudOverlayMouseReassertTimer = null; if (!hud.isDestroyed()) { - hud.setIgnoreMouseEvents(true, { forward: true }); + setHudOverlayMousePassthrough(hudOverlayIgnoringMouse); } }, 50); } +export function setHudOverlayRecordingActive(recording: boolean): void { + hudOverlayRecordingActive = Boolean(recording); + hudOverlayFallbackExpanded = false; + applyHudOverlayBounds(); + setHudOverlayMousePassthrough(!hudOverlayRecordingActive); +} + export function createUpdateToastWindow(): BrowserWindow { const initialBounds = getUpdateToastBounds(); const parentWindow = diff --git a/scripts/build-nvidia-cuda-compositor.mjs b/scripts/build-nvidia-cuda-compositor.mjs index cdccbc12..e2959022 100644 --- a/scripts/build-nvidia-cuda-compositor.mjs +++ b/scripts/build-nvidia-cuda-compositor.mjs @@ -1,5 +1,5 @@ import { execSync } from "node:child_process"; -import { copyFileSync, existsSync, mkdirSync, rmSync } from "node:fs"; +import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import path from "node:path"; import { @@ -117,6 +117,110 @@ if (!existsSync(path.join(videoCodecSdkRoot, "Samples", "NvCodec"))) { ); } +function replaceOrThrow(filePath, content, pattern, replacement, label) { + const updated = content.replace(pattern, replacement); + if (updated === content) { + throw new Error(`Unable to patch ${label} in ${filePath}`); + } + return updated; +} + +function patchNvDecoderForRecordlyCallbacks() { + const nvDecoderDir = path.join(videoCodecSdkRoot, "Samples", "NvCodec", "NvDecoder"); + const headerPath = path.join(nvDecoderDir, "NvDecoder.h"); + const sourcePath = path.join(nvDecoderDir, "NvDecoder.cpp"); + + let header = readFileSync(headerPath, "utf8"); + if (!header.includes("RecordlyMappedFrameHandler")) { + header = replaceOrThrow( + headerPath, + header, + /#include "nvcuvid\.h"\r?\n/, + `#include "nvcuvid.h" + +using RecordlyMappedFrameHandler = void (*)(CUdeviceptr, unsigned int, int, int, int, int64_t, void*); +using RecordlyDisplayFramePolicy = bool (*)(int, void*); +`, + "NvDecoder callback aliases", + ); + header = replaceOrThrow( + headerPath, + header, + / {4}int setReconfigParams\(const Rect \* pCropRect, const Dim \* pResizeDim\);\r?\n/, + ` int setReconfigParams(const Rect * pCropRect, const Dim * pResizeDim); + void SetMappedFrameHandler(RecordlyMappedFrameHandler handler, void* userData) { m_recordlyMappedFrameHandler = handler; m_recordlyMappedFrameUserData = userData; } + void SetDisplayFramePolicy(RecordlyDisplayFramePolicy policy, void* userData) { m_recordlyDisplayFramePolicy = policy; m_recordlyDisplayFramePolicyUserData = userData; } + int GetDisplayFrameCount() const { return m_nDisplayFrameCount; } +`, + "NvDecoder public callback methods", + ); + header = replaceOrThrow( + headerPath, + header, + / {4}int m_nDecodedFrame = 0, m_nDecodedFrameReturned = 0;\r?\n/, + ` int m_nDecodedFrame = 0, m_nDecodedFrameReturned = 0; + int m_nDisplayFrameCount = 0; + RecordlyMappedFrameHandler m_recordlyMappedFrameHandler = nullptr; + void* m_recordlyMappedFrameUserData = nullptr; + RecordlyDisplayFramePolicy m_recordlyDisplayFramePolicy = nullptr; + void* m_recordlyDisplayFramePolicyUserData = nullptr; +`, + "NvDecoder callback state", + ); + writeFileSync(headerPath, header); + } + + let source = readFileSync(sourcePath, "utf8"); + if (!source.includes("Recordly mapped frame callback")) { + source = replaceOrThrow( + sourcePath, + source, + / {4}if \(result == CUDA_SUCCESS && \(DecodeStatus\.decodeStatus == cuvidDecodeStatus_Error \|\| DecodeStatus\.decodeStatus == cuvidDecodeStatus_Error_Concealed\)\)\r?\n {4}\{\r?\n {8}printf\("Decode Error occurred for picture %d\\n", m_nPicNumInDecodeOrder\[pDispInfo->picture_index\]\);\r?\n {4}\}\r?\n {4}uint8_t \*pDecodedFrame = nullptr;\r?\n/, + ` if (result == CUDA_SUCCESS && (DecodeStatus.decodeStatus == cuvidDecodeStatus_Error || DecodeStatus.decodeStatus == cuvidDecodeStatus_Error_Concealed)) + { + printf("Decode Error occurred for picture %d\\n", m_nPicNumInDecodeOrder[pDispInfo->picture_index]); + } + + const int displayFrameIndex = m_nDisplayFrameCount++; + if (m_recordlyDisplayFramePolicy && + !m_recordlyDisplayFramePolicy(displayFrameIndex, m_recordlyDisplayFramePolicyUserData)) + { + NVDEC_API_CALL(cuvidUnmapVideoFrame(m_hDecoder, dpSrcFrame)); + return 1; + } + + // Recordly mapped frame callback keeps the CUDA helper from making an + // extra device-to-device copy when the caller can consume mapped NV12. + if (m_recordlyMappedFrameHandler) + { + m_recordlyMappedFrameHandler( + dpSrcFrame, + nSrcPitch, + m_nWidth, + m_nHeight, + m_nSurfaceHeight, + pDispInfo->timestamp, + m_recordlyMappedFrameUserData); + NVDEC_API_CALL(cuvidUnmapVideoFrame(m_hDecoder, dpSrcFrame)); + return 1; + } + + uint8_t *pDecodedFrame = nullptr; +`, + "NvDecoder mapped frame callback hook", + ); + writeFileSync(sourcePath, source); + } +} + +try { + patchNvDecoderForRecordlyCallbacks(); +} catch (error) { + fallbackToBundledHelperOrExit( + `Failed to patch NVIDIA Video Codec SDK samples: ${error instanceof Error ? error.message : String(error)}`, + ); +} + const cmake = findCmake(); if (!cmake) { fallbackToBundledHelperOrExit( diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index b3c92476..d7dc51bc 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -21,9 +21,7 @@ import { useScreenRecorder } from "../../hooks/useScreenRecorder"; import { useVideoDevices } from "../../hooks/useVideoDevices"; import { Button } from "../ui/button"; import { HudInteractionContext } from "./contexts/HudInteractionContext"; -import { - canToggleFloatingWebcamPreview, -} from "./floatingWebcamPreview"; +import { canToggleFloatingWebcamPreview } from "./floatingWebcamPreview"; import { useHudBarDrag } from "./hooks/useHudBarDrag"; import { useLaunchHudInteractionState } from "./hooks/useLaunchHudInteractionState"; import { useLaunchWindowActions } from "./hooks/useLaunchWindowActions"; @@ -32,7 +30,10 @@ import { useRecordingTimer } from "./hooks/useRecordingTimer"; import { useWebcamPreviewOverlay } from "./hooks/useWebcamPreviewOverlay"; import styles from "./LaunchWindow.module.css"; import { CountdownPopover } from "./popovers/CountdownPopover"; -import { LaunchPopoverCoordinatorProvider, useLaunchPopoverCoordinator } from "./popovers/LaunchPopoverCoordinator"; +import { + LaunchPopoverCoordinatorProvider, + useLaunchPopoverCoordinator, +} from "./popovers/LaunchPopoverCoordinator"; import { MicPopover } from "./popovers/MicPopover"; import { MorePopover } from "./popovers/MorePopover"; import { ProjectPopover } from "./popovers/ProjectPopover"; @@ -83,7 +84,6 @@ function LaunchWindowContent() { const hudContentRef = useRef(null); const hudBarRef = useRef(null); - const { selectedSource, hasSelectedSource, @@ -166,12 +166,13 @@ function LaunchWindowContent() { recordingWebcamPreviewContainerRef, }); - const { handleHudMouseEnter, handleHudMouseLeave, beginInteractiveHudAction } = useLaunchHudInteractionState({ - openId, - isHudDraggingRef, - isWebcamPreviewDraggingRef, - webcamPreviewDragStartRef, - }); + const { handleHudMouseEnter, handleHudMouseLeave, beginInteractiveHudAction } = + useLaunchHudInteractionState({ + openId, + isHudDraggingRef, + isWebcamPreviewDraggingRef, + webcamPreviewDragStartRef, + }); useEffect(() => { let mounted = true; @@ -195,7 +196,6 @@ function LaunchWindowContent() { ease: [0.22, 1, 0.36, 1] as const, }; - const recordingControls = ( - {microphoneEnabled ? : } + {microphoneEnabled ? ( + + ) : ( + + )} } /> @@ -282,9 +286,7 @@ function LaunchWindowContent() { hudOverlayMousePassthroughSupported, )} showFloatingWebcamPreview={showFloatingWebcamPreview} - onToggleFloatingPreview={() => - setShowFloatingWebcamPreview((current) => !current) - } + onToggleFloatingPreview={() => setShowFloatingWebcamPreview((current) => !current)} showWebcamControls={showWebcamControls} setWebcamPreviewNode={setWebcamPreviewNode} videoDevices={videoDevices} @@ -307,7 +309,11 @@ function LaunchWindowContent() { } className={webcamEnabled ? styles.ibActive : ""} > - {webcamEnabled ? : } + {webcamEnabled ? ( + + ) : ( + + )} } /> @@ -328,7 +334,6 @@ function LaunchWindowContent() { } /> - } @@ -430,112 +430,113 @@ function LaunchWindowContent() { platform === "linux" || hudOverlayMousePassthroughSupported === false; return ( - +
-
- -
- -
- -
- - - {finalizing - ? finalizingControls - : recording - ? recordingControls - : idleControls} - - -
-
-
- {showRecordingWebcamPreview && (
-
- )} -
+ +
+ +
+
+ + + {finalizing + ? finalizingControls + : recording + ? recordingControls + : idleControls} + + +
+
+
+ {showRecordingWebcamPreview && ( +
+
+ )} +
+ -
); } diff --git a/src/components/launch/hooks/useLaunchHudInteractionState.ts b/src/components/launch/hooks/useLaunchHudInteractionState.ts index 31187e1a..9d0160a7 100644 --- a/src/components/launch/hooks/useLaunchHudInteractionState.ts +++ b/src/components/launch/hooks/useLaunchHudInteractionState.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, type MouseEvent, type RefObject } from "react"; +import { type MouseEvent, type RefObject, useCallback, useEffect, useRef } from "react"; export function useLaunchHudInteractionState({ openId, @@ -32,7 +32,7 @@ export function useLaunchHudInteractionState({ const target = e.target as HTMLElement | null; if (!target) return; const isInteractive = !!target.closest( - ".pointer-events-auto, [data-hud-interactive], [data-radix-popper-content-wrapper]" + ".pointer-events-auto, [data-hud-interactive], [data-radix-popper-content-wrapper]", ); if (isInteractive) { @@ -70,27 +70,30 @@ export function useLaunchHudInteractionState({ window.electronAPI?.hudOverlaySetIgnoreMouse?.(false); }, []); - const handleHudMouseLeave = useCallback((event: MouseEvent) => { - const nextTarget = event.relatedTarget; - if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) { - return; - } - - isMouseOverHudRef.current = false; - - if (timeoutRef.current) clearTimeout(timeoutRef.current); - - timeoutRef.current = setTimeout(() => { - if ( - !isHudDraggingRef.current && - !isWebcamPreviewDraggingRef.current && - !webcamPreviewDragStartRef.current && - !isMouseOverHudRef.current - ) { - window.electronAPI?.hudOverlaySetIgnoreMouse?.(true); + const handleHudMouseLeave = useCallback( + (event: MouseEvent) => { + const nextTarget = event.relatedTarget; + if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) { + return; } - }, 300); - }, [isHudDraggingRef, isWebcamPreviewDraggingRef, webcamPreviewDragStartRef]); + + isMouseOverHudRef.current = false; + + if (timeoutRef.current) clearTimeout(timeoutRef.current); + + timeoutRef.current = setTimeout(() => { + if ( + !isHudDraggingRef.current && + !isWebcamPreviewDraggingRef.current && + !webcamPreviewDragStartRef.current && + !isMouseOverHudRef.current + ) { + window.electronAPI?.hudOverlaySetIgnoreMouse?.(true); + } + }, 300); + }, + [isHudDraggingRef, isWebcamPreviewDraggingRef, webcamPreviewDragStartRef], + ); return { handleHudMouseEnter, diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 4050189e..0893630f 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -163,7 +163,10 @@ import { RECORDLY_ISSUES_URL, } from "./TutorialHelp"; import TimelineEditor, { type TimelineEditorHandle } from "./timeline/TimelineEditor"; -import { normalizeCursorTelemetry } from "./timeline/zoomSuggestionUtils"; +import { + normalizeCursorTelemetry, + shouldAutoApplyFreshRecordingZoomsForSource, +} from "./timeline/zoomSuggestionUtils"; import { type AnnotationRegion, type AudioRegion, @@ -3344,6 +3347,23 @@ export default function VideoEditor() { ); useEffect(() => { + if ( + videoPath && + pendingFreshRecordingAutoZoomPathRef.current === videoPath && + isPreviewReady && + !shouldAutoApplyFreshRecordingZoomsForSource( + videoPlaybackRef.current?.video?.videoWidth, + videoPlaybackRef.current?.video?.videoHeight, + ) + ) { + pendingFreshRecordingAutoZoomPathRef.current = null; + if (pendingFreshRecordingAutoSuggestTimeoutRef.current !== null) { + window.clearTimeout(pendingFreshRecordingAutoSuggestTimeoutRef.current); + pendingFreshRecordingAutoSuggestTimeoutRef.current = null; + } + return; + } + if ( !videoPath || loading || @@ -3402,6 +3422,27 @@ export default function VideoEditor() { zoomRegions, ]); + useEffect(() => { + if ( + !videoPath || + !isPreviewReady || + zoomRegions.length === 0 || + autoSuggestedVideoPathRef.current !== videoPath || + shouldAutoApplyFreshRecordingZoomsForSource( + videoPlaybackRef.current?.video?.videoWidth, + videoPlaybackRef.current?.video?.videoHeight, + ) + ) { + return; + } + + autoSuggestedVideoPathRef.current = null; + setZoomRegions((prev) => { + const next = prev.filter((region) => region.mode !== "auto"); + return next.length === prev.length ? prev : next; + }); + }, [videoPath, isPreviewReady, zoomRegions]); + const handleZoomSpanChange = useCallback((id: string, span: Span) => { setZoomRegions((prev) => prev.map((region) => diff --git a/src/components/video-editor/timeline/zoomSuggestionUtils.test.ts b/src/components/video-editor/timeline/zoomSuggestionUtils.test.ts index 77080bfc..f1a6dd21 100644 --- a/src/components/video-editor/timeline/zoomSuggestionUtils.test.ts +++ b/src/components/video-editor/timeline/zoomSuggestionUtils.test.ts @@ -1,10 +1,11 @@ import { describe, expect, it } from "vitest"; +import type { CursorTelemetryPoint } from "../types"; import { + buildInteractionZoomSuggestions, CLICK_CLUSTER_MERGE_GAP_MS, CLICK_CLUSTER_PAD_MS, - buildInteractionZoomSuggestions, + shouldAutoApplyFreshRecordingZoomsForSource, } from "./zoomSuggestionUtils"; -import type { CursorTelemetryPoint } from "../types"; function makeClick( timeMs: number, @@ -20,19 +21,28 @@ function makeMove(timeMs: number, cx = 0.5, cy = 0.5): CursorTelemetryPoint { } /** Wraps click samples with surrounding move events to mimic real mixed telemetry. */ -function withMoves( - clicks: CursorTelemetryPoint[], - totalMs: number, -): CursorTelemetryPoint[] { - return [ - makeMove(0), - ...clicks, - makeMove(totalMs), - ]; +function withMoves(clicks: CursorTelemetryPoint[], totalMs: number): CursorTelemetryPoint[] { + return [makeMove(0), ...clicks, makeMove(totalMs)]; } const TOTAL_MS = 30_000; +describe("shouldAutoApplyFreshRecordingZoomsForSource", () => { + it("allows automatic fresh-recording zooms for landscape captures", () => { + expect(shouldAutoApplyFreshRecordingZoomsForSource(1920, 1080)).toBe(true); + expect(shouldAutoApplyFreshRecordingZoomsForSource(1280, 960)).toBe(true); + }); + + it("blocks automatic fresh-recording zooms for narrow or near-square captures", () => { + expect(shouldAutoApplyFreshRecordingZoomsForSource(960, 1020)).toBe(false); + expect(shouldAutoApplyFreshRecordingZoomsForSource(1080, 1080)).toBe(false); + }); + + it("does not block when source dimensions are not available yet", () => { + expect(shouldAutoApplyFreshRecordingZoomsForSource()).toBe(true); + }); +}); + describe("buildInteractionZoomSuggestions (click-cluster logic)", () => { it("creates one zoom track for a single isolated click with 500ms padding", () => { const telemetry = withMoves([makeClick(5_000)], TOTAL_MS); @@ -62,23 +72,23 @@ describe("buildInteractionZoomSuggestions (click-cluster logic)", () => { expect(result.suggestions).toHaveLength(1); }); - it.each(["right-click", "middle-click"] as const)( - "accepts %s telemetry like a standard click", - (interactionType) => { - const result = buildInteractionZoomSuggestions({ - cursorTelemetry: withMoves([makeClick(5_000, 0.5, 0.5, interactionType)], TOTAL_MS), - totalMs: TOTAL_MS, - defaultDurationMs: 3_000, - }); + it.each([ + "right-click", + "middle-click", + ] as const)("accepts %s telemetry like a standard click", (interactionType) => { + const result = buildInteractionZoomSuggestions({ + cursorTelemetry: withMoves([makeClick(5_000, 0.5, 0.5, interactionType)], TOTAL_MS), + totalMs: TOTAL_MS, + defaultDurationMs: 3_000, + }); - expect(result.status).toBe("ok"); - expect(result.suggestions).toHaveLength(1); + expect(result.status).toBe("ok"); + expect(result.suggestions).toHaveLength(1); - const [suggestion] = result.suggestions; - expect(suggestion.start).toBe(5_000 - CLICK_CLUSTER_PAD_MS); - expect(suggestion.end).toBe(5_000 + CLICK_CLUSTER_PAD_MS); - }, - ); + const [suggestion] = result.suggestions; + expect(suggestion.start).toBe(5_000 - CLICK_CLUSTER_PAD_MS); + expect(suggestion.end).toBe(5_000 + CLICK_CLUSTER_PAD_MS); + }); it("merges two clicks within 2500ms into one zoom track", () => { const telemetry = withMoves( diff --git a/src/components/video-editor/timeline/zoomSuggestionUtils.ts b/src/components/video-editor/timeline/zoomSuggestionUtils.ts index 189c7604..bf38efa6 100644 --- a/src/components/video-editor/timeline/zoomSuggestionUtils.ts +++ b/src/components/video-editor/timeline/zoomSuggestionUtils.ts @@ -3,6 +3,7 @@ import type { CursorTelemetryPoint, ZoomFocus } from "../types"; export const MIN_DWELL_DURATION_MS = 450; export const MAX_DWELL_DURATION_MS = 2600; export const DWELL_MOVE_THRESHOLD = 0.02; +export const MIN_FRESH_RECORDING_AUTO_ZOOM_SOURCE_ASPECT_RATIO = 1.2; export interface ZoomDwellCandidate { centerTimeMs: number; @@ -39,6 +40,25 @@ export interface InteractionZoomSuggestionResult { suggestions: SuggestedZoomRegion[]; } +export function shouldAutoApplyFreshRecordingZoomsForSource( + sourceWidth?: number, + sourceHeight?: number, +): boolean { + if ( + !Number.isFinite(sourceWidth) || + !Number.isFinite(sourceHeight) || + (sourceWidth ?? 0) <= 0 || + (sourceHeight ?? 0) <= 0 + ) { + return true; + } + + return ( + (sourceWidth as number) / (sourceHeight as number) >= + MIN_FRESH_RECORDING_AUTO_ZOOM_SOURCE_ASPECT_RATIO + ); +} + /** Max gap between consecutive clicks before they are split into separate zoom clusters. */ export const CLICK_CLUSTER_MERGE_GAP_MS = 2500; /** Padding added before the first click and after the last click in a cluster. */ diff --git a/src/hooks/recordingMimeType.test.ts b/src/hooks/recordingMimeType.test.ts index 9f4919f2..79dd6c2d 100644 --- a/src/hooks/recordingMimeType.test.ts +++ b/src/hooks/recordingMimeType.test.ts @@ -7,15 +7,15 @@ import { } from "./recordingMimeType"; describe("selectRecordingMimeType", () => { - it("prefers codecs the editor can play back", () => { + it("keeps browser screen captures in WebM/H.264 when supported", () => { const mimeType = selectRecordingMimeType({ isTypeSupported: () => true, canPlayType: (type) => { - if (type === "video/webm;codecs=vp9") { + if (type === "video/webm;codecs=h264") { return "probably"; } - if (type === "video/webm") { + if (type === "video/webm;codecs=vp9") { return "maybe"; } @@ -23,16 +23,13 @@ describe("selectRecordingMimeType", () => { }, }); - expect(mimeType).toBe("video/webm;codecs=vp9"); + expect(mimeType).toBe("video/webm;codecs=h264"); }); it("skips recorder-only codecs when playback support is missing", () => { const mimeType = selectRecordingMimeType({ isTypeSupported: (type) => - [ - "video/webm;codecs=vp9", - "video/webm;codecs=vp8", - ].includes(type), + ["video/webm;codecs=vp9", "video/webm;codecs=vp8"].includes(type), canPlayType: (type) => (type === "video/webm;codecs=vp8" ? "probably" : ""), }); @@ -42,14 +39,11 @@ describe("selectRecordingMimeType", () => { it("falls back to the first supported codec when playback probing is unavailable", () => { const mimeType = selectRecordingMimeType({ isTypeSupported: (type) => - [ - "video/webm;codecs=av1", - "video/webm;codecs=h264", - ].includes(type), + ["video/webm;codecs=av1", "video/webm;codecs=h264"].includes(type), canPlayType: () => "", }); - expect(mimeType).toBe("video/webm;codecs=av1"); + expect(mimeType).toBe("video/webm;codecs=h264"); }); it("returns undefined when no preferred mime type is supported", () => { @@ -64,9 +58,7 @@ describe("selectRecordingMimeType", () => { it("prefers MP4/H.264 for webcam captures when supported", () => { const mimeType = selectWebcamRecordingMimeType({ isTypeSupported: (type) => - ["video/mp4;codecs=avc1.42E01E", "video/webm;codecs=vp9"].includes( - type, - ), + ["video/mp4;codecs=avc1.42E01E", "video/webm;codecs=vp9"].includes(type), canPlayType: () => "probably", }); @@ -75,8 +67,7 @@ describe("selectRecordingMimeType", () => { it("falls back to WebM webcam capture when MP4 is unavailable", () => { const mimeType = selectWebcamRecordingMimeType({ - isTypeSupported: (type) => - ["video/webm;codecs=vp9", "video/webm"].includes(type), + isTypeSupported: (type) => ["video/webm;codecs=vp9", "video/webm"].includes(type), canPlayType: () => "probably", }); diff --git a/src/hooks/recordingMimeType.ts b/src/hooks/recordingMimeType.ts index 93a5ba82..56930b84 100644 --- a/src/hooks/recordingMimeType.ts +++ b/src/hooks/recordingMimeType.ts @@ -1,9 +1,9 @@ const RECORDING_MIME_TYPE_PREFERENCES = [ + "video/webm;codecs=h264", "video/webm;codecs=vp9", "video/webm", "video/webm;codecs=vp8", "video/webm;codecs=av1", - "video/webm;codecs=h264", ] as const; const WEBCAM_RECORDING_MIME_TYPE_PREFERENCES = [ @@ -38,9 +38,7 @@ function selectMimeTypeFromPreferences( return playableType ?? supportedTypes[0]; } -export function selectRecordingMimeType( - options: MimeTypeSelectorOptions = {}, -): string | undefined { +export function selectRecordingMimeType(options: MimeTypeSelectorOptions = {}): string | undefined { return selectMimeTypeFromPreferences(RECORDING_MIME_TYPE_PREFERENCES, options); } @@ -54,6 +52,8 @@ export function isWebmMimeType(mimeType: string | undefined | null): boolean { return /^video\/webm(?:[;\s]|$)/i.test(mimeType ?? ""); } -export function getVideoExtensionForMimeType(mimeType: string | undefined | null): ".mp4" | ".webm" { +export function getVideoExtensionForMimeType( + mimeType: string | undefined | null, +): ".mp4" | ".webm" { return /^video\/mp4(?:[;\s]|$)/i.test(mimeType ?? "") ? ".mp4" : ".webm"; } diff --git a/src/hooks/useScreenRecorder.test.ts b/src/hooks/useScreenRecorder.test.ts index 00a8a7a9..85811467 100644 --- a/src/hooks/useScreenRecorder.test.ts +++ b/src/hooks/useScreenRecorder.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { + createBrowserRecordingOptions, createProcessedMicrophoneConstraints, normalizeBrowserMicrophoneProfile, resolveBrowserCaptureCursorPolicy, @@ -32,12 +33,12 @@ function createMockMediaRecorder(initialState: RecordingState = "inactive") { } describe("createProcessedMicrophoneConstraints", () => { - it("requests browser voice processing without AGC for the default microphone", () => { + it("requests browser voice processing with AGC for the default microphone", () => { expect(createProcessedMicrophoneConstraints()).toEqual({ audio: { echoCancellation: true, noiseSuppression: true, - autoGainControl: false, + autoGainControl: true, channelCount: { ideal: 1 }, sampleRate: { ideal: 48000 }, }, @@ -45,13 +46,13 @@ describe("createProcessedMicrophoneConstraints", () => { }); }); - it("keeps no-AGC voice processing when a specific microphone is selected", () => { + it("keeps default voice processing when a specific microphone is selected", () => { expect(createProcessedMicrophoneConstraints("device-123")).toMatchObject({ audio: { deviceId: { exact: "device-123" }, echoCancellation: true, noiseSuppression: true, - autoGainControl: false, + autoGainControl: true, channelCount: { ideal: 1 }, sampleRate: { ideal: 48000 }, }, @@ -103,10 +104,38 @@ describe("createProcessedMicrophoneConstraints", () => { }); }); - it("normalizes invalid lab microphone profiles to production no-AGC processing", () => { + it("normalizes invalid lab microphone profiles to production voice processing", () => { expect(normalizeBrowserMicrophoneProfile("RAW")).toBe("raw"); - expect(normalizeBrowserMicrophoneProfile("unknown")).toBe("no-agc"); - expect(normalizeBrowserMicrophoneProfile(null)).toBe("no-agc"); + expect(normalizeBrowserMicrophoneProfile("unknown")).toBe("processed"); + expect(normalizeBrowserMicrophoneProfile(null)).toBe("processed"); + }); +}); + +describe("createBrowserRecordingOptions", () => { + it("sets an aggregate bitrate target for browser screen recordings", () => { + expect( + createBrowserRecordingOptions({ + audioBitsPerSecond: 128_000, + mimeType: "video/webm;codecs=vp9", + videoBitsPerSecond: 30_600_000, + }), + ).toEqual({ + audioBitsPerSecond: 128_000, + bitsPerSecond: 30_728_000, + mimeType: "video/webm;codecs=vp9", + videoBitsPerSecond: 30_600_000, + }); + }); + + it("keeps video-only recordings on the requested video budget", () => { + expect( + createBrowserRecordingOptions({ + videoBitsPerSecond: 30_600_000, + }), + ).toEqual({ + bitsPerSecond: 30_600_000, + videoBitsPerSecond: 30_600_000, + }); }); }); diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index 4da2ae71..30ed7c62 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -29,10 +29,9 @@ const BITS_PER_MEGABIT = 1_000_000; const MIN_FRAME_RATE = 30; const CHROME_MEDIA_SOURCE = "desktop"; const RECORDING_FILE_PREFIX = "recording-"; -const VIDEO_FILE_EXTENSION = ".webm"; const AUDIO_BITRATE_VOICE = 128_000; const AUDIO_BITRATE_SYSTEM = 192_000; -const MIC_GAIN_BOOST = 1; +const MIC_GAIN_BOOST = 1.4; const WEBCAM_BITRATE = 8_000_000; const WEBCAM_WIDTH = 1280; const WEBCAM_HEIGHT = 720; @@ -52,7 +51,7 @@ export type BrowserCaptureCursorPolicy = { hideOsCursorBeforeRecording: boolean; hideEditorOverlayCursorByDefault: boolean; }; -const DEFAULT_BROWSER_MICROPHONE_PROFILE: BrowserMicrophoneProfile = "no-agc"; +const DEFAULT_BROWSER_MICROPHONE_PROFILE: BrowserMicrophoneProfile = "processed"; const BROWSER_MICROPHONE_PROFILES = new Set([ "processed", "no-agc", @@ -238,6 +237,31 @@ export function createProcessedMicrophoneConstraints( return { audio, video: false }; } +export function createBrowserRecordingOptions({ + audioBitsPerSecond, + mimeType, + videoBitsPerSecond, +}: { + audioBitsPerSecond?: number; + mimeType?: string; + videoBitsPerSecond: number; +}): MediaRecorderOptions { + const options: MediaRecorderOptions = { + videoBitsPerSecond, + bitsPerSecond: videoBitsPerSecond + (audioBitsPerSecond ?? 0), + }; + + if (audioBitsPerSecond !== undefined) { + options.audioBitsPerSecond = audioBitsPerSecond; + } + + if (mimeType) { + options.mimeType = mimeType; + } + + return options; +} + function createMicrophoneTrackSettingsSnapshot( stream: MediaStream, ): MicrophoneTrackSettingsSnapshot | null { @@ -901,7 +925,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { const webcamPath = await stopWebcamRecorder(); await storeMicrophoneSidecar(resolvedMicFallbackBlobPromise, result.path, startDelayMs); await finalizeRecordingSession(result.path, webcamPath); - + if (typeof window.electronAPI?.hudOverlayClose === "function") { window.electronAPI.hudOverlayClose(); } @@ -1106,52 +1130,60 @@ export function useScreenRecorder(): UseScreenRecorderReturn { // We pass null for webcamPath initially to avoid blocking on webcam disk writes/muxing. await finalizeRecordingSession(finalPath, null); - // 2. Perform background finalization (webcam, muxing, sidecars) - // We don't await this to keep the UI responsive - void (async () => { - try { - // Await the webcam path in the background - const webcamPath = await webcamPathPromise; - console.log("[useScreenRecorder] Background native processing: webcamPath is", webcamPath); + // 2. Perform background finalization (webcam, muxing, sidecars) + // We don't await this to keep the UI responsive + void (async () => { + try { + // Await the webcam path in the background + const webcamPath = await webcamPathPromise; + console.log( + "[useScreenRecorder] Background native processing: webcamPath is", + webcamPath, + ); - // Store sidecars - await storeMicrophoneSidecar( - micFallbackBlobPromise, - finalPath, - fallbackStartDelayMs, - fallbackTrackSettings, - ); + // Store sidecars + await storeMicrophoneSidecar( + micFallbackBlobPromise, + finalPath, + fallbackStartDelayMs, + fallbackTrackSettings, + ); - // Perform muxing/renaming if on Windows - if (isNativeWindows) { - await window.electronAPI.muxNativeWindowsRecording(expectedDurationMs); - } + // Perform muxing/renaming if on Windows + if (isNativeWindows) { + await window.electronAPI.muxNativeWindowsRecording(expectedDurationMs); + } - console.log("[useScreenRecorder] Emitting setCurrentRecordingSession with:", { finalPath, webcamPath }); + console.log( + "[useScreenRecorder] Emitting setCurrentRecordingSession with:", + { finalPath, webcamPath }, + ); - // Update the session state to notify the editor that all background assets (webcam, mic, etc.) are now ready. - // This broadcasts a 'recording-session-changed' event that the open editor listens to for re-scanning assets. - await window.electronAPI.setCurrentRecordingSession({ - videoPath: finalPath, - webcamPath, - timeOffsetMs: webcamTimeOffsetMs.current, - hideOverlayCursorByDefault: hideEditorOverlayCursorByDefault.current, - }); + // Update the session state to notify the editor that all background assets (webcam, mic, etc.) are now ready. + // This broadcasts a 'recording-session-changed' event that the open editor listens to for re-scanning assets. + await window.electronAPI.setCurrentRecordingSession({ + videoPath: finalPath, + webcamPath, + timeOffsetMs: webcamTimeOffsetMs.current, + hideOverlayCursorByDefault: hideEditorOverlayCursorByDefault.current, + }); - console.log( - `[PERF:RENDERER] Background Stop Sequence: COMPLETED in ${(performance.now() - stopStart).toFixed(2)}ms`, - ); - } catch (bgError) { - console.error("Error in background finalization:", bgError); - } finally { - // After all background tasks are done (webcam, mic sidecars, muxing), - // we can safely close the HUD window to release hardware and resources. - if (typeof window.electronAPI?.hudOverlayClose === "function") { - console.log("[useScreenRecorder] All background tasks finished, closing HUD"); - window.electronAPI.hudOverlayClose(); - } - } - })(); + console.log( + `[PERF:RENDERER] Background Stop Sequence: COMPLETED in ${(performance.now() - stopStart).toFixed(2)}ms`, + ); + } catch (bgError) { + console.error("Error in background finalization:", bgError); + } finally { + // After all background tasks are done (webcam, mic sidecars, muxing), + // we can safely close the HUD window to release hardware and resources. + if (typeof window.electronAPI?.hudOverlayClose === "function") { + console.log( + "[useScreenRecorder] All background tasks finished, closing HUD", + ); + window.electronAPI.hudOverlayClose(); + } + } + })(); })(); return; } @@ -1551,7 +1583,10 @@ export function useScreenRecorder(): UseScreenRecorderReturn { try { const hideCursorResult = await window.electronAPI.hideOsCursor?.(); if (hideCursorResult && !hideCursorResult.success) { - console.warn("Could not hide OS cursor before recording.", hideCursorResult); + console.warn( + "Could not hide OS cursor before recording.", + hideCursorResult, + ); } } catch { console.warn("Could not hide OS cursor before recording."); @@ -1747,17 +1782,19 @@ export function useScreenRecorder(): UseScreenRecorderReturn { chunks.current = []; const hasAudio = stream.current.getAudioTracks().length > 0; - const recorder = new MediaRecorder(stream.current, { - videoBitsPerSecond, - ...(mimeType ? { mimeType } : {}), - ...(hasAudio - ? { - audioBitsPerSecond: systemAudioIncluded - ? AUDIO_BITRATE_SYSTEM - : AUDIO_BITRATE_VOICE, - } - : {}), - }); + const audioBitsPerSecond = hasAudio + ? systemAudioIncluded + ? AUDIO_BITRATE_SYSTEM + : AUDIO_BITRATE_VOICE + : undefined; + const recorder = new MediaRecorder( + stream.current, + createBrowserRecordingOptions({ + audioBitsPerSecond, + mimeType, + videoBitsPerSecond, + }), + ); mediaRecorder.current = recorder; recorder.ondataavailable = (event) => { @@ -1779,10 +1816,12 @@ export function useScreenRecorder(): UseScreenRecorderReturn { ); chunks.current = []; const timestamp = recordingSessionTimestamp.current ?? Date.now(); - const videoFileName = `${RECORDING_FILE_PREFIX}${timestamp}${VIDEO_FILE_EXTENSION}`; + const videoFileName = `${RECORDING_FILE_PREFIX}${timestamp}${getVideoExtensionForMimeType(recordingBlobType)}`; try { - const videoBlob = await fixWebmDuration(buggyBlob, duration); + const videoBlob = isWebmMimeType(recordingBlobType) + ? await fixWebmDuration(buggyBlob, duration) + : buggyBlob; const arrayBuffer = await videoBlob.arrayBuffer(); const videoResult = await window.electronAPI.storeRecordedVideo( arrayBuffer, @@ -1813,14 +1852,17 @@ export function useScreenRecorder(): UseScreenRecorderReturn { videoPath: finalVideoPath, webcamPath, timeOffsetMs: webcamTimeOffsetMs.current, - hideOverlayCursorByDefault: hideEditorOverlayCursorByDefault.current, + hideOverlayCursorByDefault: + hideEditorOverlayCursorByDefault.current, }); } } finally { // After all background tasks are done (webcam), // we can safely close the HUD window to release hardware and resources. if (typeof window.electronAPI?.hudOverlayClose === "function") { - console.log("[useScreenRecorder:browser] All background tasks finished, closing HUD"); + console.log( + "[useScreenRecorder:browser] All background tasks finished, closing HUD", + ); window.electronAPI.hudOverlayClose(); } } diff --git a/src/lib/exporter/exportBitrate.test.ts b/src/lib/exporter/exportBitrate.test.ts index 5b6c5f91..1d46dc09 100644 --- a/src/lib/exporter/exportBitrate.test.ts +++ b/src/lib/exporter/exportBitrate.test.ts @@ -15,6 +15,28 @@ describe("export bitrate policy", () => { ).toBe(27_000_000); }); + it("raises high-resolution 60fps source-quality exports above the 30fps budget", () => { + const sharedOptions = { + width: 2560, + height: 1440, + quality: "source" as const, + encodingMode: "quality" as const, + }; + + const thirtyFpsBitrate = getMp4ExportBitrate({ + ...sharedOptions, + frameRate: 30, + }); + const sixtyFpsBitrate = getMp4ExportBitrate({ + ...sharedOptions, + frameRate: 60, + }); + + expect(thirtyFpsBitrate).toBe(45_000_000); + expect(sixtyFpsBitrate).toBeGreaterThan(thirtyFpsBitrate); + expect(sixtyFpsBitrate).toBe(63_639_610); + }); + it("keeps modern native static-layout source exports high enough for screen text", () => { expect( getMp4ExportBitrate({ diff --git a/src/lib/exporter/exportBitrate.ts b/src/lib/exporter/exportBitrate.ts index 7d3fa240..40110b6f 100644 --- a/src/lib/exporter/exportBitrate.ts +++ b/src/lib/exporter/exportBitrate.ts @@ -2,6 +2,7 @@ import type { ExportEncodingMode, ExportMp4FrameRate, ExportQuality } from "./ty const MIN_MP4_BITRATE = 2_000_000; const REFERENCE_PIXEL_RATE = 1920 * 1080 * 30; +const REFERENCE_FRAME_RATE = 30; export function getEncodingModeBitrateMultiplier(encodingMode: ExportEncodingMode): number { switch (encodingMode) { @@ -41,6 +42,10 @@ function getBaseMp4ExportBitrate(width: number, height: number, quality: ExportQ return 30_000_000; } +function getFrameRateBitrateMultiplier(frameRate: ExportMp4FrameRate): number { + return Math.sqrt(Math.max(1, frameRate / REFERENCE_FRAME_RATE)); +} + function getModernNativeStaticLayoutBitrateCap( width: number, height: number, @@ -87,6 +92,7 @@ export function getMp4ExportBitrate(options: { }): number { const requestedBitrate = Math.round( getBaseMp4ExportBitrate(options.width, options.height, options.quality) * + getFrameRateBitrateMultiplier(options.frameRate) * getEncodingModeBitrateMultiplier(options.encodingMode), ); const nativeStaticLayoutBitrate =