diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 32ed313e..6f6ff2ab 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -675,6 +675,7 @@ interface Window { options?: { preserveProjectPath?: boolean; hideOverlayCursorByDefault?: boolean; + nativeCaptureUnavailable?: boolean; }, ) => Promise<{ success: boolean; webcamPath: string | null }>; setCurrentRecordingSession: ( @@ -683,6 +684,7 @@ interface Window { webcamPath?: string | null; timeOffsetMs?: number; hideOverlayCursorByDefault?: boolean; + nativeCaptureUnavailable?: boolean; }, options?: { preserveProjectPath?: boolean }, ) => Promise<{ success: boolean }>; @@ -693,6 +695,7 @@ interface Window { webcamPath?: string | null; timeOffsetMs?: number; hideOverlayCursorByDefault?: boolean; + nativeCaptureUnavailable?: boolean; }; }>; getCurrentVideoPath: () => Promise<{ success: boolean; path?: string }>; @@ -838,7 +841,11 @@ interface Window { /** Returns the app version from package.json */ getAppVersion: () => Promise; /** Hide the OS cursor before browser capture starts. */ - hideOsCursor: () => Promise<{ success: boolean }>; + hideOsCursor: () => Promise<{ + success: boolean; + unsupported?: boolean; + platform?: string; + }>; /** Recording preferences (mic, system audio) */ getRecordingPreferences: () => Promise<{ success: boolean; 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/project.ts b/electron/ipc/register/project.ts index a65fd907..7af983b0 100644 --- a/electron/ipc/register/project.ts +++ b/electron/ipc/register/project.ts @@ -533,7 +533,7 @@ export function registerProjectHandlers() { return { success: false, error: String(error), message: 'Failed to open projects folder.' } } }) - ipcMain.handle('set-current-video-path', async (_, path: string, options?: { preserveProjectPath?: boolean; hideOverlayCursorByDefault?: boolean }) => { + ipcMain.handle('set-current-video-path', async (_, path: string, options?: { preserveProjectPath?: boolean; hideOverlayCursorByDefault?: boolean; nativeCaptureUnavailable?: boolean }) => { setCurrentVideoPath(normalizeVideoSourcePath(path) ?? path) approveUserPath(currentVideoPath) const resolvedSession = await resolveRecordingSession(currentVideoPath) @@ -548,6 +548,10 @@ export function registerProjectHandlers() { hideOverlayCursorByDefault: normalizeBoolean(options?.hideOverlayCursorByDefault) || normalizeBoolean(resolvedSession.hideOverlayCursorByDefault), + nativeCaptureUnavailable: + normalizeBoolean( + options?.nativeCaptureUnavailable ?? resolvedSession.nativeCaptureUnavailable, + ), } setCurrentRecordingSession(nextSession) @@ -573,7 +577,7 @@ export function registerProjectHandlers() { return { success: true, webcamPath: nextSession.webcamPath ?? null } }) - ipcMain.handle('set-current-recording-session', async (_, session: { videoPath: string; webcamPath?: string | null; timeOffsetMs?: number; hideOverlayCursorByDefault?: boolean }, options?: { preserveProjectPath?: boolean }) => { + ipcMain.handle('set-current-recording-session', async (_, session: { videoPath: string; webcamPath?: string | null; timeOffsetMs?: number; hideOverlayCursorByDefault?: boolean; nativeCaptureUnavailable?: boolean }, options?: { preserveProjectPath?: boolean }) => { const normalizedVideoPath = normalizeVideoSourcePath(session.videoPath) ?? session.videoPath setCurrentVideoPath(normalizedVideoPath) setCurrentRecordingSession({ @@ -581,6 +585,7 @@ export function registerProjectHandlers() { webcamPath: normalizeVideoSourcePath(session.webcamPath ?? null), timeOffsetMs: normalizeRecordingTimeOffsetMs(session.timeOffsetMs), hideOverlayCursorByDefault: normalizeBoolean(session.hideOverlayCursorByDefault), + nativeCaptureUnavailable: normalizeBoolean(session.nativeCaptureUnavailable), }); await rememberApprovedLocalReadPath(currentRecordingSession!.videoPath) await rememberApprovedLocalReadPath(currentRecordingSession!.webcamPath) diff --git a/electron/ipc/register/recording.ts b/electron/ipc/register/recording.ts index e88f6f17..b13453c7 100644 --- a/electron/ipc/register/recording.ts +++ b/electron/ipc/register/recording.ts @@ -147,7 +147,7 @@ import { parseJsonWithByteOrderMark, parseWindowId, } from "../utils"; -import { resolveWindowsCaptureDisplay } from "../windowsCaptureSelection"; +import { resolveWindowsCaptureTarget } from "../windowsCaptureSelection"; const execFileAsync = promisify(execFile); @@ -442,15 +442,13 @@ export function registerRecordingHandlers( const browserMicFallbackRequested = shouldStartWindowsBrowserMicrophoneFallback(options); - const windowId = parseWindowId(source?.id); - const isWindowCapture = Boolean(windowId && source?.id?.startsWith("window:")); - - const resolvedDisplay = resolveWindowsCaptureDisplay( + const captureTarget = resolveWindowsCaptureTarget( source, getScreen().getAllDisplays(), getScreen().getPrimaryDisplay(), ); - const displayBounds = resolvedDisplay.bounds; + const displayBounds = + captureTarget.kind === "display" ? captureTarget.bounds : null; setWindowsOrphanedMicAudioPath(null); const config: Record = { @@ -458,29 +456,37 @@ export function registerRecordingHandlers( fps: 60, }; - if (isWindowCapture) { - config.windowHandle = windowId; + if (captureTarget.kind === "invalid-window") { + return { + success: false, + message: + "Selected window is no longer available. Please choose the window again.", + }; + } + + if (captureTarget.kind === "window") { + config.windowHandle = captureTarget.windowHandle; } else { // Windows Graphics Capture (WGC) requires a raw HMONITOR handle. // We attempt to resolve the handle by matching the physical coordinates of the target display. const monitors = getMonitorHandles(); const matchedMonitor = monitors.find( (monitor) => - monitor.x === Math.round(displayBounds.x) && - monitor.y === Math.round(displayBounds.y), + monitor.x === Math.round(captureTarget.bounds.x) && + monitor.y === Math.round(captureTarget.bounds.y), ); if (matchedMonitor) { config.displayId = matchedMonitor.handle; } else { // Fallback to coordinate-based matching if handle resolution fails - config.displayId = resolvedDisplay.displayId; + config.displayId = captureTarget.displayId; } - config.displayX = Math.round(resolvedDisplay.bounds.x); - config.displayY = Math.round(resolvedDisplay.bounds.y); - config.displayW = Math.round(resolvedDisplay.bounds.width); - config.displayH = Math.round(resolvedDisplay.bounds.height); + config.displayX = Math.round(captureTarget.bounds.x); + config.displayY = Math.round(captureTarget.bounds.y); + config.displayW = Math.round(captureTarget.bounds.width); + config.displayH = Math.round(captureTarget.bounds.height); } if (options?.capturesSystemAudio) { diff --git a/electron/ipc/register/settings.ts b/electron/ipc/register/settings.ts index cecabb5f..5b4498a8 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: false, unsupported: true, platform: process.platform }; + } - 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/ipc/types.ts b/electron/ipc/types.ts index 58f5425b..9680da73 100644 --- a/electron/ipc/types.ts +++ b/electron/ipc/types.ts @@ -48,6 +48,7 @@ export type RecordingSessionData = { webcamPath?: string | null; timeOffsetMs?: number; hideOverlayCursorByDefault?: boolean; + nativeCaptureUnavailable?: boolean; }; export type PauseSegment = { diff --git a/electron/ipc/windowsCaptureSelection.test.ts b/electron/ipc/windowsCaptureSelection.test.ts index de6a36cf..17039d4d 100644 --- a/electron/ipc/windowsCaptureSelection.test.ts +++ b/electron/ipc/windowsCaptureSelection.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "vitest"; -import { resolveWindowsCaptureDisplay } from "./windowsCaptureSelection"; +import { + resolveWindowsCaptureDisplay, + resolveWindowsCaptureTarget, +} from "./windowsCaptureSelection"; describe("resolveWindowsCaptureDisplay", () => { const primaryDisplay = { @@ -62,3 +65,64 @@ describe("resolveWindowsCaptureDisplay", () => { }); }); }); + +describe("resolveWindowsCaptureTarget", () => { + const primaryDisplay = { + id: 101, + bounds: { + x: 0, + y: 0, + width: 1920, + height: 1080, + }, + }; + + const secondaryDisplay = { + id: 202, + bounds: { + x: 1920, + y: -40, + width: 2560, + height: 1440, + }, + }; + + it("uses a window handle when a Windows window source is selected", () => { + const resolved = resolveWindowsCaptureTarget( + { id: "window:123456:0", sourceType: "window" }, + [primaryDisplay, secondaryDisplay], + primaryDisplay, + ); + + expect(resolved).toEqual({ + kind: "window", + windowHandle: 123456, + }); + }); + + it("does not silently turn an invalid window source into display capture", () => { + const resolved = resolveWindowsCaptureTarget( + { id: "window:0:0", sourceType: "window", display_id: String(secondaryDisplay.id) }, + [primaryDisplay, secondaryDisplay], + primaryDisplay, + ); + + expect(resolved).toEqual({ + kind: "invalid-window", + }); + }); + + it("keeps display capture behavior for selected screens", () => { + const resolved = resolveWindowsCaptureTarget( + { id: "screen:202:0", sourceType: "screen", display_id: String(secondaryDisplay.id) }, + [primaryDisplay, secondaryDisplay], + primaryDisplay, + ); + + expect(resolved).toEqual({ + kind: "display", + displayId: secondaryDisplay.id, + bounds: secondaryDisplay.bounds, + }); + }); +}); diff --git a/electron/ipc/windowsCaptureSelection.ts b/electron/ipc/windowsCaptureSelection.ts index 4250c12f..3fdf3c00 100644 --- a/electron/ipc/windowsCaptureSelection.ts +++ b/electron/ipc/windowsCaptureSelection.ts @@ -1,5 +1,7 @@ export type WindowsCaptureSourceLike = { + id?: string; display_id?: string; + sourceType?: string; }; export type WindowsCaptureDisplayBounds = { @@ -19,6 +21,38 @@ export type ResolvedWindowsCaptureDisplay = { bounds: WindowsCaptureDisplayBounds; }; +export type ResolvedWindowsCaptureTarget = + | { + kind: "window"; + windowHandle: number; + } + | { + kind: "display"; + displayId: number; + bounds: WindowsCaptureDisplayBounds; + } + | { + kind: "invalid-window"; + }; + +function parseDesktopCapturerWindowHandle(sourceId?: string) { + if (!sourceId) { + return null; + } + + const match = sourceId.match(/^window:(\d+)/); + if (!match) { + return null; + } + + const handle = Number.parseInt(match[1], 10); + return Number.isFinite(handle) && handle > 0 ? handle : null; +} + +function isWindowCaptureSource(source: WindowsCaptureSourceLike | null | undefined) { + return source?.sourceType === "window" || source?.id?.startsWith("window:") === true; +} + export function resolveWindowsCaptureDisplay( source: WindowsCaptureSourceLike | null | undefined, allDisplays: WindowsCaptureDisplayLike[], @@ -40,3 +74,29 @@ export function resolveWindowsCaptureDisplay( bounds: matchedDisplay.bounds, }; } + +export function resolveWindowsCaptureTarget( + source: WindowsCaptureSourceLike | null | undefined, + allDisplays: WindowsCaptureDisplayLike[], + primaryDisplay: WindowsCaptureDisplayLike, +): ResolvedWindowsCaptureTarget { + if (isWindowCaptureSource(source)) { + const windowHandle = parseDesktopCapturerWindowHandle(source?.id); + if (windowHandle !== null) { + return { + kind: "window", + windowHandle, + }; + } + + return { + kind: "invalid-window", + }; + } + + const resolvedDisplay = resolveWindowsCaptureDisplay(source, allDisplays, primaryDisplay); + return { + kind: "display", + ...resolvedDisplay, + }; +} 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..c5080b66 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": "250a3f8cac7c6ea38a873434d23d4b2be7d6555e42cc0b405aa26f774169159c", + "sourceDir": "electron/native/nvidia-cuda-compositor", + "sourceFingerprint": "de1219228ce326e96d1f4815a3763b10d5f235cc1286bc6542c99707a85d5947", + "updatedAt": "2026-05-27T11:29:32.957Z" + } + } } 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..c23c14f9 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..43324b05 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,17 @@ const sourcePtsPath = join(workDir, `${baseName}.source-pts.csv`); const videoInfo = getVideoInfo(inputPath); const webcamInfo = webcamInput ? getVideoInfo(webcamInput) : null; +if (requestedOutputWidth > 0 !== requestedOutputHeight > 0) { + fail("--width and --height must be specified together"); +} +if ( + requestedOutputWidth > 0 && + (requestedOutputWidth % 2 !== 0 || requestedOutputHeight % 2 !== 0) +) { + fail("--width and --height must be even numbers for NV12 encoding"); +} +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 +1118,7 @@ let webcamSourceWindowFrames = webcamInfo : 0; const backgroundNv12Path = backgroundImage ? generatedBackgroundNv12Path : backgroundNv12; const backgroundFilter = createBackgroundFilter( - videoInfo, + { width: outputWidth, height: outputHeight }, shouldBakeStaticShadow ? { x: contentX, @@ -1275,6 +1288,9 @@ const encodeArgs = [ "--chunk-mb", String(chunkMb), ]; +if (requestedOutputWidth > 0 && requestedOutputHeight > 0) { + encodeArgs.push("--width", String(outputWidth), "--height", String(outputHeight)); +} if (sourcePts.path && sourcePts.frames >= sourceWindowFrames) { encodeArgs.push("--source-pts", sourcePts.path); } diff --git a/electron/native/nvidia-cuda-compositor/src/main.cu b/electron/native/nvidia-cuda-compositor/src/main.cu index 6adcbcf7..28c72123 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] " @@ -296,6 +302,12 @@ Options parseOptions(int argc, char** argv) { if (options.inputPath.empty()) { fail("--input is required"); } + if ((options.width > 0) != (options.height > 0)) { + fail("--width and --height must be specified together"); + } + if (options.width > 0 && (options.width % 2 != 0 || options.height % 2 != 0)) { + fail("--width and --height must be even numbers for NV12 encoding"); + } return options; } @@ -320,6 +332,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 +2886,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 +3126,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 +3172,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 +3245,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/preload.ts b/electron/preload.ts index 932db07b..2cfb155b 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -687,6 +687,7 @@ contextBridge.exposeInMainWorld("electronAPI", { options?: { preserveProjectPath?: boolean; hideOverlayCursorByDefault?: boolean; + nativeCaptureUnavailable?: boolean; }, ) => { return ipcRenderer.invoke("set-current-video-path", path, options); @@ -697,6 +698,7 @@ contextBridge.exposeInMainWorld("electronAPI", { webcamPath?: string | null; timeOffsetMs?: number; hideOverlayCursorByDefault?: boolean; + nativeCaptureUnavailable?: boolean; }, options?: { preserveProjectPath?: boolean }, ) => { diff --git a/electron/windows.ts b/electron/windows.ts index dbdb4692..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,21 +275,43 @@ function setHudOverlayFallbackExpanded(expanded: boolean) { } } -ipcMain.on("hud-overlay-set-ignore-mouse", (_event, ignore: boolean) => { - if (hudOverlayWindow && !hudOverlayWindow.isDestroyed()) { - if (!isHudOverlayMousePassthroughSupported()) { - 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. @@ -424,7 +452,7 @@ export function createHudOverlayWindow(): BrowserWindow { win.setIgnoreMouseEvents(false); setTimeout(() => { if (!win.isDestroyed()) { - win.setIgnoreMouseEvents(true, { forward: true }); + setHudOverlayMousePassthrough(hudOverlayIgnoringMouse); } }, 50); } @@ -435,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 @@ -450,7 +484,7 @@ export function createHudOverlayWindow(): BrowserWindow { win.setIgnoreMouseEvents(false); setTimeout(() => { if (!win.isDestroyed()) { - win.setIgnoreMouseEvents(true, { forward: true }); + setHudOverlayMousePassthrough(hudOverlayIgnoringMouse); } }, 50); } @@ -564,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/package-lock.json b/package-lock.json index 5841b3d8..586fce89 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "recordly", - "version": "1.3.1", + "version": "1.3.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "recordly", - "version": "1.3.1", + "version": "1.3.2", "hasInstallScript": true, "dependencies": { "@phosphor-icons/react": "^2.1.10", diff --git a/package.json b/package.json index 3bbf0de6..2e832388 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "url": "https://github.com/webadderallorg/Recordly/issues" }, "private": true, - "version": "1.3.1", + "version": "1.3.2", "type": "module", "scripts": { "dev": "vite --config vite.config.ts", 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/KeyboardShortcutsHelp.tsx b/src/components/video-editor/KeyboardShortcutsHelp.tsx index 4c99ff57..4071d3ad 100644 --- a/src/components/video-editor/KeyboardShortcutsHelp.tsx +++ b/src/components/video-editor/KeyboardShortcutsHelp.tsx @@ -10,13 +10,13 @@ export function KeyboardShortcutsHelp() { const t = useScopedT("editor"); const [scrollLabels, setScrollLabels] = useState({ - pan: "Shift + Ctrl + Scroll", + pan: "Shift + Scroll", zoom: "Ctrl + Scroll", }); useEffect(() => { Promise.all([ - formatShortcut(["shift", "mod", "Scroll"]), + formatShortcut(["shift", "Scroll"]), formatShortcut(["mod", "Scroll"]), ]).then(([pan, zoom]) => setScrollLabels({ pan, zoom })); }, []); diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index c2030025..34333206 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -3545,6 +3545,7 @@ export function SettingsPanel({ @@ -3553,11 +3554,20 @@ export function SettingsPanel({ + {nativeCaptureUnavailableSession ? ( +
+ {tSettings( + "effects.cursorOverlayUnavailable", + "Cursor overlay is unavailable for this recording because the captured video already contains the system cursor.", + )} +
+ ) : null}
{ Promise.all([ - formatShortcut(["shift", "mod", "Scroll"]), + formatShortcut(["shift", "Scroll"]), formatShortcut(["mod", "Scroll"]), ]).then(([pan, zoom]) => setScrollLabels({ pan, zoom })); }, []); diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 0a6d46ca..b4d7b51c 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, @@ -1253,10 +1256,18 @@ export default function VideoEditor() { setExportProgress(resolveSavingExportProgress); }, []); - const handleShowCursorChange = useCallback((nextShowCursor: boolean) => { - setSessionShowCursorOverride(null); - setShowCursor(nextShowCursor); - }, []); + const handleShowCursorChange = useCallback( + (nextShowCursor: boolean) => { + if (nextShowCursor && sessionNativeCaptureUnavailable) { + setNativeCaptureUnavailableModalOpen(true); + return; + } + + setSessionShowCursorOverride(null); + setShowCursor(nextShowCursor); + }, + [sessionNativeCaptureUnavailable], + ); const remountPreview = useCallback(() => { setIsPreviewReady(false); @@ -3401,6 +3412,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 || @@ -3459,6 +3487,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/components/wrapper/TimelineWrapper.tsx b/src/components/video-editor/timeline/components/wrapper/TimelineWrapper.tsx index f09b4001..58e55c3a 100644 --- a/src/components/video-editor/timeline/components/wrapper/TimelineWrapper.tsx +++ b/src/components/video-editor/timeline/components/wrapper/TimelineWrapper.tsx @@ -11,11 +11,7 @@ import { TimelineContext } from "dnd-timeline"; import type { Dispatch, ReactNode, SetStateAction } from "react"; import { useCallback, useRef } from "react"; import type { TimelineRegionSpan } from "../../core/timelineTypes"; -import { - clampRange, - resolveDragEnd, - resolveResizeEnd, -} from "../../dnd/engine"; +import { clampRange, resolveDragEnd, resolveResizeEnd } from "../../dnd/engine"; interface TimelineWrapperProps { children: ReactNode; @@ -162,9 +158,10 @@ export default function TimelineWrapper({ ? (event.activatorEvent as PointerEvent).clientX + (event.delta?.x ?? 0) : undefined; if (span) showTooltip(span, screenX); - onLiveSpanPreviewChange?.(event.active.id as string, span ?? null); + // dnd-timeline mutates the active item's DOM during resize; React preview + // renders here can reset that inline width/edge position and make trims stutter. }, - [onLiveSpanPreviewChange, showTooltip], + [showTooltip], ); const hideTooltip = useCallback(() => showTooltip(null), [showTooltip]); diff --git a/src/components/video-editor/timeline/hooks/useTimelineRange.test.ts b/src/components/video-editor/timeline/hooks/useTimelineRange.test.ts new file mode 100644 index 00000000..bff6a651 --- /dev/null +++ b/src/components/video-editor/timeline/hooks/useTimelineRange.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; + +import { resolveTimelineWheelPanDeltaPx } from "./useTimelineRange"; + +describe("resolveTimelineWheelPanDeltaPx", () => { + it("uses trackpad horizontal wheel movement for timeline panning", () => { + expect( + resolveTimelineWheelPanDeltaPx({ + deltaX: 24, + deltaY: 0, + deltaMode: 0, + }), + ).toBe(24); + }); + + it("uses shifted vertical wheel movement for timeline panning", () => { + expect( + resolveTimelineWheelPanDeltaPx({ + deltaX: 0, + deltaY: 3, + deltaMode: 1, + shiftKey: true, + }), + ).toBe(48); + }); + + it("keeps ctrl wheel available for timeline zoom unless shift is also held", () => { + expect( + resolveTimelineWheelPanDeltaPx({ + deltaX: 0, + deltaY: 3, + deltaMode: 1, + ctrlKey: true, + }), + ).toBe(0); + expect( + resolveTimelineWheelPanDeltaPx({ + deltaX: 0, + deltaY: 3, + deltaMode: 1, + ctrlKey: true, + shiftKey: true, + }), + ).toBe(48); + }); + + it("uses regular wheel movement when the timeline has no vertical overflow", () => { + expect( + resolveTimelineWheelPanDeltaPx({ + deltaX: 0, + deltaY: 20, + deltaMode: 0, + canScrollVertically: false, + }), + ).toBe(20); + }); +}); diff --git a/src/components/video-editor/timeline/hooks/useTimelineRange.ts b/src/components/video-editor/timeline/hooks/useTimelineRange.ts index e3d5ff59..c72b2fd9 100644 --- a/src/components/video-editor/timeline/hooks/useTimelineRange.ts +++ b/src/components/video-editor/timeline/hooks/useTimelineRange.ts @@ -7,6 +7,40 @@ interface UseTimelineRangeParams { timelineContainerRef: RefObject; } +export interface TimelineWheelPanDeltaInput { + deltaX: number; + deltaY: number; + deltaMode: number; + shiftKey?: boolean; + ctrlKey?: boolean; + metaKey?: boolean; + canScrollVertically?: boolean; +} + +export function resolveTimelineWheelPanDeltaPx({ + deltaX, + deltaY, + deltaMode, + shiftKey = false, + ctrlKey = false, + metaKey = false, + canScrollVertically = true, +}: TimelineWheelPanDeltaInput) { + if ((ctrlKey || metaKey) && !shiftKey) { + return 0; + } + + if (Math.abs(deltaX) > 0) { + return normalizeWheelDeltaToPixels(deltaX, deltaMode); + } + + if ((shiftKey || !canScrollVertically) && Math.abs(deltaY) > 0) { + return normalizeWheelDeltaToPixels(deltaY, deltaMode); + } + + return 0; +} + export function useTimelineRange({ totalMs, timelineContainerRef }: UseTimelineRangeParams) { const [range, setRange] = useState(() => createInitialRange(totalMs)); @@ -42,29 +76,34 @@ export function useTimelineRange({ totalMs, timelineContainerRef }: UseTimelineR const handleTimelineWheel = useCallback( (event: WheelEvent) => { - if (event.ctrlKey || event.metaKey || totalMs <= 0) { + if (((event.ctrlKey || event.metaKey) && !event.shiftKey) || totalMs <= 0) { return; } - const rawHorizontalDelta = - Math.abs(event.deltaX) > 0 - ? event.deltaX - : event.shiftKey && Math.abs(event.deltaY) > 0 - ? event.deltaY - : 0; + const container = timelineContainerRef.current; + const horizontalDeltaPx = resolveTimelineWheelPanDeltaPx({ + deltaX: event.deltaX, + deltaY: event.deltaY, + deltaMode: event.deltaMode, + shiftKey: event.shiftKey, + ctrlKey: event.ctrlKey, + metaKey: event.metaKey, + canScrollVertically: container + ? container.scrollHeight > container.clientHeight + 1 + : true, + }); - if (rawHorizontalDelta === 0) { + if (horizontalDeltaPx === 0) { return; } - const containerWidth = timelineContainerRef.current?.clientWidth ?? 0; + const containerWidth = container?.clientWidth ?? 0; const visibleRangeMs = clampedRange.end - clampedRange.start; if (containerWidth <= 0 || visibleRangeMs <= 0) { return; } event.preventDefault(); - const horizontalDeltaPx = normalizeWheelDeltaToPixels(rawHorizontalDelta, event.deltaMode); const deltaMs = (horizontalDeltaPx / containerWidth) * visibleRangeMs; panTimelineRange(deltaMs); }, 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 2c422c4f..509b049b 100644 --- a/src/hooks/useScreenRecorder.test.ts +++ b/src/hooks/useScreenRecorder.test.ts @@ -1,9 +1,13 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { + createBrowserRecordingOptions, createProcessedMicrophoneConstraints, + getScreenCaptureCursorSetting, normalizeBrowserMicrophoneProfile, resolveBrowserCaptureCursorPolicy, + resolveLinuxPortalCursorPresentation, + shouldUseNativeWindowsCaptureForSource, } from "./useScreenRecorder"; type RecordingState = "inactive" | "recording" | "paused"; @@ -31,12 +35,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 }, }, @@ -44,13 +48,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 }, }, @@ -102,10 +106,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, + }); }); }); @@ -115,6 +147,7 @@ describe("resolveBrowserCaptureCursorPolicy", () => { streamCursor: "never", hideOsCursorBeforeRecording: true, hideEditorOverlayCursorByDefault: true, + nativeCaptureUnavailable: false, }); }); @@ -125,8 +158,74 @@ describe("resolveBrowserCaptureCursorPolicy", () => { streamCursor: "always", hideOsCursorBeforeRecording: false, hideEditorOverlayCursorByDefault: true, + nativeCaptureUnavailable: true, }); }); + + it("does not fake OS cursor hiding on Linux portal capture", () => { + expect(resolveBrowserCaptureCursorPolicy({ platform: "linux" })).toEqual({ + streamCursor: "never", + hideOsCursorBeforeRecording: false, + hideEditorOverlayCursorByDefault: true, + nativeCaptureUnavailable: true, + }); + }); +}); + +describe("resolveLinuxPortalCursorPresentation", () => { + it("enables the Recordly overlay only when the portal confirms cursor-hidden capture", () => { + expect( + resolveLinuxPortalCursorPresentation({ + requestedCursor: "never", + actualCursor: "never", + }), + ).toEqual({ + hideEditorOverlayCursorByDefault: false, + nativeCaptureUnavailable: false, + }); + }); + + it("keeps the overlay disabled when the portal embeds or omits cursor settings", () => { + expect( + resolveLinuxPortalCursorPresentation({ + requestedCursor: "never", + actualCursor: "always", + }), + ).toEqual({ + hideEditorOverlayCursorByDefault: true, + nativeCaptureUnavailable: true, + }); + expect( + resolveLinuxPortalCursorPresentation({ + requestedCursor: "never", + actualCursor: null, + }), + ).toEqual({ + hideEditorOverlayCursorByDefault: true, + nativeCaptureUnavailable: true, + }); + }); +}); + +describe("getScreenCaptureCursorSetting", () => { + it("normalizes only supported screen-capture cursor settings", () => { + expect(getScreenCaptureCursorSetting({ cursor: "motion" } as MediaTrackSettings)).toBe( + "motion", + ); + expect( + getScreenCaptureCursorSetting({ cursor: "hidden" } as MediaTrackSettings), + ).toBeNull(); + }); +}); + +describe("shouldUseNativeWindowsCaptureForSource", () => { + it("keeps native Windows capture on screen sources", () => { + expect(shouldUseNativeWindowsCaptureForSource({ id: "screen:101:0" })).toBe(true); + }); + + it("routes window sources through browser capture", () => { + expect(shouldUseNativeWindowsCaptureForSource({ id: "window:123456:0" })).toBe(false); + }); }); function stopRecording( diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index cb34670c..b6f34be9 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; @@ -47,12 +46,14 @@ export type BrowserMicrophoneProfile = | "no-noise-suppression" | "raw"; type BrowserCaptureCursorMode = "always" | "never"; +type BrowserCaptureCursorSetting = BrowserCaptureCursorMode | "motion"; export type BrowserCaptureCursorPolicy = { streamCursor: BrowserCaptureCursorMode; hideOsCursorBeforeRecording: boolean; hideEditorOverlayCursorByDefault: boolean; + nativeCaptureUnavailable: 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", @@ -191,8 +192,10 @@ export function normalizeBrowserMicrophoneProfile(value?: string | null): Browse export function resolveBrowserCaptureCursorPolicy({ nativeWindowsCaptureStartFailed = false, + platform, }: { nativeWindowsCaptureStartFailed?: boolean; + platform?: string; } = {}): BrowserCaptureCursorPolicy { if (nativeWindowsCaptureStartFailed) { // If WGC already failed, avoid the telemetry overlay path that can lag on @@ -201,6 +204,19 @@ export function resolveBrowserCaptureCursorPolicy({ streamCursor: "always", hideOsCursorBeforeRecording: false, hideEditorOverlayCursorByDefault: true, + nativeCaptureUnavailable: true, + }; + } + + if (platform === "linux") { + // Linux screen capture runs through xdg-desktop-portal/PipeWire. Ask the + // portal to omit the cursor, but do not pretend we can globally hide the + // OS cursor from Electron when the portal/compositor ignores that request. + return { + streamCursor: "never", + hideOsCursorBeforeRecording: false, + hideEditorOverlayCursorByDefault: true, + nativeCaptureUnavailable: true, }; } @@ -208,9 +224,46 @@ export function resolveBrowserCaptureCursorPolicy({ streamCursor: "never", hideOsCursorBeforeRecording: true, hideEditorOverlayCursorByDefault: true, + nativeCaptureUnavailable: false, }; } +export function getScreenCaptureCursorSetting( + settings: MediaTrackSettings | null | undefined, +): BrowserCaptureCursorSetting | null { + const cursor = (settings as { cursor?: unknown } | null | undefined)?.cursor; + return cursor === "always" || cursor === "never" || cursor === "motion" ? cursor : null; +} + +export function resolveLinuxPortalCursorPresentation({ + actualCursor, + requestedCursor, +}: { + actualCursor: BrowserCaptureCursorSetting | null; + requestedCursor: BrowserCaptureCursorMode; +}): Pick< + BrowserCaptureCursorPolicy, + "hideEditorOverlayCursorByDefault" | "nativeCaptureUnavailable" +> { + if (requestedCursor === "never" && actualCursor === "never") { + return { + hideEditorOverlayCursorByDefault: false, + nativeCaptureUnavailable: false, + }; + } + + return { + hideEditorOverlayCursorByDefault: true, + nativeCaptureUnavailable: true, + }; +} + +export function shouldUseNativeWindowsCaptureForSource( + source: Pick | null | undefined, +): boolean { + return source?.id?.startsWith("screen:") === true; +} + export function createProcessedMicrophoneConstraints( microphoneDeviceId?: string, profile: BrowserMicrophoneProfile = DEFAULT_BROWSER_MICROPHONE_PROFILE, @@ -232,6 +285,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 { @@ -342,6 +420,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { ); const requestedBrowserMicrophoneProfile = useRef(null); const hideEditorOverlayCursorByDefault = useRef(false); + const nativeCaptureUnavailableForCursorOverlay = useRef(false); const notifyRecordingFinalizationFailure = useCallback(async (message: string) => { setFinalizing(false); @@ -650,6 +729,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { const start = performance.now(); console.log("[PERF:RENDERER] Finalize Session & Switch to Editor: STARTED"); const shouldHideOverlayCursor = hideEditorOverlayCursorByDefault.current; + const nativeCaptureUnavailable = nativeCaptureUnavailableForCursorOverlay.current; try { if (webcamPath) { await window.electronAPI.setCurrentRecordingSession({ @@ -657,10 +737,12 @@ export function useScreenRecorder(): UseScreenRecorderReturn { webcamPath, timeOffsetMs: webcamTimeOffsetMs.current, hideOverlayCursorByDefault: shouldHideOverlayCursor, + nativeCaptureUnavailable, }); } else { await window.electronAPI.setCurrentVideoPath(videoPath, { hideOverlayCursorByDefault: shouldHideOverlayCursor, + nativeCaptureUnavailable, }); } } catch (error) { @@ -669,6 +751,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { try { await window.electronAPI.setCurrentVideoPath(videoPath, { hideOverlayCursorByDefault: shouldHideOverlayCursor, + nativeCaptureUnavailable, }); } catch (fallbackError) { console.error("Failed to persist fallback video path:", fallbackError); @@ -895,7 +978,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(); } @@ -1100,52 +1183,62 @@ 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, + nativeCaptureUnavailable: + nativeCaptureUnavailableForCursorOverlay.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; } @@ -1326,6 +1419,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { try { const platform = await window.electronAPI.getPlatform(); hideEditorOverlayCursorByDefault.current = false; + nativeCaptureUnavailableForCursorOverlay.current = false; const existingSource = await window.electronAPI.getSelectedSource(); const selectedSource = existingSource ?? (platform === "linux" ? LINUX_PORTAL_SOURCE : null); @@ -1362,8 +1456,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { let nativeWindowsCaptureStartFailed = false; if ( platform === "win32" && - (selectedSource.id?.startsWith("screen:") || - selectedSource.id?.startsWith("window:")) && + shouldUseNativeWindowsCaptureForSource(selectedSource) && typeof window.electronAPI.isNativeWindowsCaptureAvailable === "function" ) { try { @@ -1526,9 +1619,12 @@ export function useScreenRecorder(): UseScreenRecorderReturn { const browserCursorPolicy = resolveBrowserCaptureCursorPolicy({ nativeWindowsCaptureStartFailed, + platform, }); hideEditorOverlayCursorByDefault.current = browserCursorPolicy.hideEditorOverlayCursorByDefault; + nativeCaptureUnavailableForCursorOverlay.current = + browserCursorPolicy.nativeCaptureUnavailable; const wantsAudioCapture = microphoneEnabled || systemAudioEnabled; const browserCaptureSource = await resolveBrowserCaptureSource(selectedSource); @@ -1546,7 +1642,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."); @@ -1709,6 +1808,27 @@ export function useScreenRecorder(): UseScreenRecorderReturn { throw new Error("Media stream is not available."); } + if (useLinuxPortal) { + const actualCursor = getScreenCaptureCursorSetting(videoTrack.getSettings()); + const cursorPresentation = resolveLinuxPortalCursorPresentation({ + actualCursor, + requestedCursor: browserCursorPolicy.streamCursor, + }); + hideEditorOverlayCursorByDefault.current = + cursorPresentation.hideEditorOverlayCursorByDefault; + nativeCaptureUnavailableForCursorOverlay.current = + cursorPresentation.nativeCaptureUnavailable; + if (cursorPresentation.nativeCaptureUnavailable) { + console.warn( + "Linux portal did not confirm cursor-hidden capture; disabling Recordly cursor overlay for this recording.", + { + actualCursor, + requestedCursor: browserCursorPolicy.streamCursor, + }, + ); + } + } + try { await videoTrack.applyConstraints({ frameRate: { ideal: TARGET_FRAME_RATE, max: TARGET_FRAME_RATE }, @@ -1742,17 +1862,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) => { @@ -1774,10 +1896,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, @@ -1808,14 +1932,19 @@ export function useScreenRecorder(): UseScreenRecorderReturn { videoPath: finalVideoPath, webcamPath, timeOffsetMs: webcamTimeOffsetMs.current, - hideOverlayCursorByDefault: hideEditorOverlayCursorByDefault.current, + hideOverlayCursorByDefault: + hideEditorOverlayCursorByDefault.current, + nativeCaptureUnavailable: + nativeCaptureUnavailableForCursorOverlay.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/i18n/locales/fr/editor.json b/src/i18n/locales/fr/editor.json index d3494ed1..cb4b4d13 100644 --- a/src/i18n/locales/fr/editor.json +++ b/src/i18n/locales/fr/editor.json @@ -113,6 +113,11 @@ "project": { "untitled": "Sans titre" }, + "nativeCaptureUnavailable": { + "title": "Rien n'est cassé, mais nous ne pourrons pas afficher une superposition animée du curseur.", + "description": "Votre appareil ne prend pas en charge la capture native. Cela peut arriver pour plusieurs raisons que nous n'avons pas encore identifiées. Recordly continuera de fonctionner, mais le lissage du curseur sera impossible.", + "confirm": "D'accord" + }, "exportStatus": { "exporting": "Exportation", "renderingFile": "Rendu de votre fichier.", diff --git a/src/i18n/locales/ko/editor.json b/src/i18n/locales/ko/editor.json index 56eb6bc5..49678dee 100644 --- a/src/i18n/locales/ko/editor.json +++ b/src/i18n/locales/ko/editor.json @@ -114,6 +114,11 @@ "project": { "untitled": "제목 없음" }, + "nativeCaptureUnavailable": { + "title": "문제가 생긴 것은 아니지만, 애니메이션 커서 오버레이를 렌더링할 수 없습니다.", + "description": "이 장치는 네이티브 캡처를 지원하지 않습니다. 아직 확인하지 못한 여러 이유가 있을 수 있습니다. Recordly는 계속 작동하지만 커서 스무딩은 사용할 수 없습니다.", + "confirm": "확인" + }, "exportStatus": { "exporting": "내보내는 중", "renderingFile": "파일을 렌더링하고 있습니다.", diff --git a/src/i18n/locales/nl/editor.json b/src/i18n/locales/nl/editor.json index b174ca98..d58b9e52 100644 --- a/src/i18n/locales/nl/editor.json +++ b/src/i18n/locales/nl/editor.json @@ -114,6 +114,11 @@ "project": { "untitled": "Naamloos" }, + "nativeCaptureUnavailable": { + "title": "Er is niets kapot, maar we kunnen geen geanimeerde cursor-overlay renderen.", + "description": "Je apparaat ondersteunt geen native capture. Dit kan verschillende oorzaken hebben die we nog niet hebben achterhaald. Recordly blijft werken, maar cursor smoothing is dan niet mogelijk.", + "confirm": "Oké" + }, "exportStatus": { "exporting": "Exporteren", "renderingFile": "Je bestand wordt gerenderd.", diff --git a/src/i18n/locales/pt-BR/editor.json b/src/i18n/locales/pt-BR/editor.json index 175bf674..702939fd 100644 --- a/src/i18n/locales/pt-BR/editor.json +++ b/src/i18n/locales/pt-BR/editor.json @@ -113,6 +113,11 @@ "project": { "untitled": "Sem título" }, + "nativeCaptureUnavailable": { + "title": "Nada está quebrado, mas não poderemos renderizar uma sobreposição animada do cursor.", + "description": "Seu dispositivo não oferece suporte à captura nativa. Isso pode acontecer por vários motivos que ainda não identificamos. O Recordly continuará funcionando, mas a suavização do cursor ficará indisponível.", + "confirm": "Entendi" + }, "exportStatus": { "exporting": "Exportando", "renderingFile": "Renderizando seu arquivo.", diff --git a/src/i18n/locales/zh-CN/editor.json b/src/i18n/locales/zh-CN/editor.json index 4a0c4a5d..1bf3a608 100644 --- a/src/i18n/locales/zh-CN/editor.json +++ b/src/i18n/locales/zh-CN/editor.json @@ -113,6 +113,11 @@ "project": { "untitled": "未命名" }, + "nativeCaptureUnavailable": { + "title": "没有出错,但我们无法渲染动画光标叠加层。", + "description": "你的设备不支持原生捕获。这可能是由我们尚未确定的多种原因造成的。Recordly 仍可继续运行,但无法进行光标平滑处理。", + "confirm": "好的" + }, "exportStatus": { "exporting": "正在导出", "renderingFile": "正在渲染你的文件。", diff --git a/src/i18n/locales/zh-TW/editor.json b/src/i18n/locales/zh-TW/editor.json index e37768b3..6f2b0f82 100644 --- a/src/i18n/locales/zh-TW/editor.json +++ b/src/i18n/locales/zh-TW/editor.json @@ -113,6 +113,11 @@ "project": { "untitled": "未命名" }, + "nativeCaptureUnavailable": { + "title": "沒有出錯,但我們無法轉譯動畫游標覆蓋層。", + "description": "你的裝置不支援原生擷取。這可能是由我們尚未釐清的多種原因造成的。Recordly 仍可繼續運作,但無法進行游標平滑處理。", + "confirm": "好的" + }, "exportStatus": { "exporting": "正在匯出", "renderingFile": "正在渲染你的檔案。", @@ -134,4 +139,4 @@ "collapse": "摺疊時間軸" }, "openRecordingsFolder": "打開錄製資料夾" -} \ No newline at end of file +} diff --git a/src/lib/exporter/exportBitrate.test.ts b/src/lib/exporter/exportBitrate.test.ts index 972e0156..b113afdc 100644 --- a/src/lib/exporter/exportBitrate.test.ts +++ b/src/lib/exporter/exportBitrate.test.ts @@ -34,6 +34,28 @@ describe("export bitrate policy", () => { expect(bitrate60).toBeGreaterThan(bitrate30); }); + 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({ @@ -57,6 +79,29 @@ describe("export bitrate policy", () => { ).toBe(27_000_000); }); + it("scales modern native static-layout source exports at 60fps", () => { + const sharedOptions = { + width: 1920, + height: 1080, + quality: "source" as const, + encodingMode: "quality" as const, + useModernNativeStaticLayout: true, + }; + + const thirtyFpsBitrate = getMp4ExportBitrate({ + ...sharedOptions, + frameRate: 30, + }); + const sixtyFpsBitrate = getMp4ExportBitrate({ + ...sharedOptions, + frameRate: 60, + }); + + expect(thirtyFpsBitrate).toBe(27_000_000); + expect(sixtyFpsBitrate).toBeGreaterThan(thirtyFpsBitrate); + expect(sixtyFpsBitrate).toBe(38_183_766); + }); + it("does not raise fast exports when the requested bitrate is already lower than the cap", () => { expect( getMp4ExportBitrate({ diff --git a/src/lib/exporter/exportBitrate.ts b/src/lib/exporter/exportBitrate.ts index 04c66c14..84d87133 100644 --- a/src/lib/exporter/exportBitrate.ts +++ b/src/lib/exporter/exportBitrate.ts @@ -42,8 +42,12 @@ function getBaseMp4ExportBitrate(width: number, height: number, quality: ExportQ return 30_000_000; } -function getFrameRateBitrateScale(frameRate: ExportMp4FrameRate): number { - return Math.sqrt(Math.max(frameRate, REFERENCE_FRAME_RATE) / REFERENCE_FRAME_RATE); +function getFrameRateBitrateMultiplier(frameRate: ExportMp4FrameRate): number { + // This only scales requestedBitrate above REFERENCE_FRAME_RATE, so 24fps + // and 30fps share the same multiplier. useModernNativeStaticLayout can + // still change the final bitrate because pixelRateScale uses frameRate + // against REFERENCE_PIXEL_RATE for the native layout floor/cap. + return Math.sqrt(Math.max(1, frameRate / REFERENCE_FRAME_RATE)); } function getModernNativeStaticLayoutBitrateCap( @@ -92,8 +96,8 @@ export function getMp4ExportBitrate(options: { }): number { const requestedBitrate = Math.round( getBaseMp4ExportBitrate(options.width, options.height, options.quality) * - getEncodingModeBitrateMultiplier(options.encodingMode) * - getFrameRateBitrateScale(options.frameRate), + getFrameRateBitrateMultiplier(options.frameRate) * + getEncodingModeBitrateMultiplier(options.encodingMode), ); const nativeStaticLayoutBitrate = options.useModernNativeStaticLayout && options.encodingMode !== "fast" diff --git a/src/lib/shortcuts.ts b/src/lib/shortcuts.ts index d2d9828c..d4384693 100644 --- a/src/lib/shortcuts.ts +++ b/src/lib/shortcuts.ts @@ -37,7 +37,7 @@ export const FIXED_SHORTCUTS: FixedShortcut[] = [ display: "Del / ⌫", bindings: [{ key: "delete" }, { key: "backspace" }], }, - { label: "Pan Timeline", display: "Shift + Ctrl + Scroll", bindings: [] }, + { label: "Pan Timeline", display: "Shift + Scroll", bindings: [] }, { label: "Zoom Timeline", display: "Ctrl + Scroll", bindings: [] }, ];