diff --git a/CLAUDE.md b/CLAUDE.md index 0437c208..cc0f9400 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co |---------|-------------| | `npm run dev` | Start Vite dev server + Electron | | `npm run build` | Full production build (native helpers → TypeScript → Vite → Electron Builder) | -| `npm run build:win` | Windows build (includes WGC capture) | +| `npm run build:win` | Windows build (includes native DXGI capture) | | `npm run build:mac` | macOS build | | `npm run build:linux` | Linux build | | `npm run lint` | Biome lint check | @@ -42,7 +42,7 @@ Run a single test file: `npx vitest run src/lib/exporter/gifExporter.test.ts` ### Recording Pipeline (Platform-Specific) - **macOS**: ScreenCaptureKit via compiled Swift helpers (`scripts/build-native-helpers.mjs`) -- **Windows**: WGC (Windows.Graphics.Capture) via C++ CMake build (`scripts/build-wgc-capture.mjs`), fallback to FFmpeg +- **Windows**: DXGI Desktop Duplication via C++ CMake build (`scripts/build-wgc-capture.mjs`), fallback to FFmpeg - **Linux**: Chromium `getDisplayMedia` - Core recording logic: `src/hooks/useScreenRecorder.ts` (60 FPS target, adaptive bitrate) diff --git a/README.md b/README.md index 9887c71f..2fb3e8bf 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ On Windows, builds older than 19041 fall back to Electron capture and the cursor - Microphone or system audio recording - Chromium capture APIs on Windows/Linux - Native **ScreenCaptureKit** capture on macOS -- native WGC recording helper for display and app-window capture on Windows, native WASAPI for system/mic audio, and more +- native DXGI Desktop Duplication recording helper for display and app-window capture on Windows, native WASAPI for system/mic audio, and more ### Smart Motion @@ -250,7 +250,7 @@ Recordly is a **desktop video editor with a renderer-driven motion pipeline and **Capture** - Electron orchestrates recording - macOS uses native helpers for ScreenCaptureKit and cursor telemetry -- Windows uses native WGC for screen capture +- Windows uses a native DXGI Desktop Duplication helper for screen capture **Motion** - Zoom regions diff --git a/README.zh-CN.md b/README.zh-CN.md index 8431668a..c956c27e 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -63,7 +63,7 @@ Recordly 运行于: - 支持麦克风或系统音频录制 - Windows/Linux 使用 Chromium 捕获 API - macOS 使用原生 **ScreenCaptureKit** 捕获 -- Windows 使用原生 WGC 录制辅助程序进行显示器和应用窗口捕获,并通过原生 WASAPI 处理系统/麦克风音频等 +- Windows 使用原生 DXGI Desktop Duplication 录制辅助程序进行显示器和应用窗口捕获,并通过原生 WASAPI 处理系统/麦克风音频等 ### 智能运动 @@ -246,7 +246,7 @@ Recordly 是一个**桌面视频编辑器**,以渲染驱动的运动管线和 **捕获** - Electron 负责录制流程编排 - macOS 使用原生辅助程序处理 ScreenCaptureKit 和光标遥测 -- Windows 使用原生 WGC 进行屏幕捕获 +- Windows 使用原生 DXGI Desktop Duplication 进行屏幕捕获 **运动** - 缩放区域 diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 6c17d47a..4654f55c 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -24,6 +24,13 @@ declare namespace NodeJS { // Used in Renderer process, expose in `preload.ts` interface Window { electronAPI: { + hudOverlayHide: () => void; + hudOverlayClose: () => void; + setHudOverlayExpanded: (expanded: boolean) => void; + getHudOverlayCaptureProtection: () => Promise<{ success: boolean; enabled: boolean }>; + setHudOverlayCaptureProtection: ( + enabled: boolean, + ) => Promise<{ success: boolean; enabled: boolean }>; getSources: (opts: Electron.SourcesOptions) => Promise; switchToEditor: () => Promise; openSourceSelector: () => Promise; @@ -185,16 +192,10 @@ interface Window { }>; getShortcuts: () => Promise | null>; saveShortcuts: (shortcuts: unknown) => Promise<{ success: boolean; error?: string }>; - hudOverlayHide: () => void; - hudOverlayClose: () => void; - getHudOverlayCaptureProtection: () => Promise<{ success: boolean; enabled: boolean }>; - setHudOverlayCaptureProtection: ( - enabled: boolean, - ) => Promise<{ success: boolean; enabled: boolean }>; setHasUnsavedChanges: (hasChanges: boolean) => void; onRequestSaveBeforeClose: (callback: () => Promise) => () => void; - isWgcAvailable: () => Promise<{ available: boolean }>; - muxWgcRecording: () => Promise<{ + isNativeWindowsCaptureAvailable: () => Promise<{ available: boolean }>; + muxNativeWindowsRecording: () => Promise<{ success: boolean; path?: string; message?: string; diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 5f5e4c83..61f1c191 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -79,15 +79,15 @@ let nativeCaptureMicrophonePath: string | null = null let nativeCapturePaused = false let nativeCursorMonitorProcess: ChildProcessWithoutNullStreams | null = null let nativeCursorMonitorOutputBuffer = '' -let wgcCaptureProcess: ChildProcessWithoutNullStreams | null = null -let wgcCaptureOutputBuffer = '' -let wgcCaptureTargetPath: string | null = null -let wgcScreenRecordingActive = false -let wgcCaptureStopRequested = false -let wgcCapturePaused = false -let wgcSystemAudioPath: string | null = null -let wgcMicAudioPath: string | null = null -let wgcPendingVideoPath: string | null = null +let windowsCaptureProcess: ChildProcessWithoutNullStreams | null = null +let windowsCaptureOutputBuffer = '' +let windowsCaptureTargetPath: string | null = null +let windowsNativeCaptureActive = false +let windowsCaptureStopRequested = false +let windowsCapturePaused = false +let windowsSystemAudioPath: string | null = null +let windowsMicAudioPath: string | null = null +let windowsPendingVideoPath: string | null = null let ffmpegScreenRecordingActive = false let ffmpegCaptureProcess: ChildProcessWithoutNullStreams | null = null let ffmpegCaptureOutputBuffer = '' @@ -117,18 +117,18 @@ export function getSelectedSourceId(): string | null { return selectedSource?.id as string | null ?? null } -export function killWgcCaptureProcess() { - if (wgcCaptureProcess) { - try { wgcCaptureProcess.kill() } catch { /* ignore */ } - wgcCaptureProcess = null - wgcCaptureTargetPath = null - wgcScreenRecordingActive = false +export function killWindowsCaptureProcess() { + if (windowsCaptureProcess) { + try { windowsCaptureProcess.kill() } catch { /* ignore */ } + windowsCaptureProcess = null + windowsCaptureTargetPath = null + windowsNativeCaptureActive = false nativeScreenRecordingActive = false - wgcCaptureStopRequested = false - wgcCapturePaused = false - wgcSystemAudioPath = null - wgcMicAudioPath = null - wgcPendingVideoPath = null + windowsCaptureStopRequested = false + windowsCapturePaused = false + windowsSystemAudioPath = null + windowsMicAudioPath = null + windowsPendingVideoPath = null } } @@ -847,7 +847,7 @@ async function buildFfmpegCaptureArgs(source: SelectedSource, outputPath: string throw new Error(`FFmpeg capture is not supported on ${process.platform}`) } -function getWgcCaptureExePath() { +function getWindowsCaptureExePath() { return resolveUnpackedAppPath('electron', 'native', 'wgc-capture', 'build', 'Release', 'wgc-capture.exe') } @@ -855,26 +855,25 @@ function getCursorMonitorExePath() { return resolveUnpackedAppPath('electron', 'native', 'cursor-monitor', 'build', 'Release', 'cursor-monitor.exe') } -async function isWgcCaptureAvailable(): Promise { +async function isNativeWindowsCaptureAvailable(): Promise { if (process.platform !== 'win32') return false try { - await fs.access(getWgcCaptureExePath(), fsConstants.X_OK) + await fs.access(getWindowsCaptureExePath(), fsConstants.X_OK) } catch { return false } - // Windows 10 2004 (Build 19041) minimum for IsCursorCaptureEnabled const os = await import('node:os') - const [major, , build] = os.release().split('.').map(Number) - return major >= 10 && build >= 19041 + const [major, minor] = os.release().split('.').map(Number) + return major > 6 || (major === 6 && minor >= 2) } -function waitForWgcCaptureStart(proc: ChildProcessWithoutNullStreams) { +function waitForWindowsCaptureStart(proc: ChildProcessWithoutNullStreams) { return new Promise((resolve, reject) => { const timer = setTimeout(() => { cleanup() - reject(new Error('Timed out waiting for WGC capture to start')) + reject(new Error('Timed out waiting for native Windows capture to start')) }, 12000) const onStdout = (chunk: Buffer) => { @@ -892,7 +891,7 @@ function waitForWgcCaptureStart(proc: ChildProcessWithoutNullStreams) { const onExit = (code: number | null) => { cleanup() - reject(new Error(wgcCaptureOutputBuffer.trim() || `WGC capture exited before recording started (code ${code ?? 'unknown'})`)) + reject(new Error(windowsCaptureOutputBuffer.trim() || `Native Windows capture exited before recording started (code ${code ?? 'unknown'})`)) } const cleanup = () => { @@ -908,20 +907,20 @@ function waitForWgcCaptureStart(proc: ChildProcessWithoutNullStreams) { }) } -function waitForWgcCaptureStop(proc: ChildProcessWithoutNullStreams) { +function waitForWindowsCaptureStop(proc: ChildProcessWithoutNullStreams) { return new Promise((resolve, reject) => { const onClose = (code: number | null) => { cleanup() - const match = wgcCaptureOutputBuffer.match(/Recording stopped\. Output path: (.+)/) + const match = windowsCaptureOutputBuffer.match(/Recording stopped\. Output path: (.+)/) if (match?.[1]) { resolve(match[1].trim()) return } - if (code === 0 && wgcCaptureTargetPath) { - resolve(wgcCaptureTargetPath) + if (code === 0 && windowsCaptureTargetPath) { + resolve(windowsCaptureTargetPath) return } - reject(new Error(wgcCaptureOutputBuffer.trim() || `WGC capture exited with code ${code ?? 'unknown'}`)) + reject(new Error(windowsCaptureOutputBuffer.trim() || `Native Windows capture exited with code ${code ?? 'unknown'}`)) } const onError = (error: Error) => { @@ -939,18 +938,18 @@ function waitForWgcCaptureStop(proc: ChildProcessWithoutNullStreams) { }) } -function attachWgcCaptureLifecycle(proc: ChildProcessWithoutNullStreams) { +function attachWindowsCaptureLifecycle(proc: ChildProcessWithoutNullStreams) { proc.once('close', () => { - const wasActive = wgcScreenRecordingActive - wgcCaptureProcess = null + const wasActive = windowsNativeCaptureActive + windowsCaptureProcess = null - if (!wasActive || wgcCaptureStopRequested) { + if (!wasActive || windowsCaptureStopRequested) { return } - wgcScreenRecordingActive = false - wgcCaptureTargetPath = null - wgcCaptureStopRequested = false + windowsNativeCaptureActive = false + windowsCaptureTargetPath = null + windowsCaptureStopRequested = false const sourceName = selectedSource?.name ?? 'Screen' BrowserWindow.getAllWindows().forEach((window) => { @@ -966,7 +965,7 @@ function attachWgcCaptureLifecycle(proc: ChildProcessWithoutNullStreams) { }) } -async function muxWgcVideoWithAudio(videoPath: string, systemAudioPath: string | null, micAudioPath: string | null) { +async function muxNativeWindowsVideoWithAudio(videoPath: string, systemAudioPath: string | null, micAudioPath: string | null) { const ffmpegPath = getFfmpegBinaryPath() const inputs: string[] = ['-i', videoPath] const audioInputs: string[] = [] @@ -2120,26 +2119,26 @@ body{background:transparent;overflow:hidden;width:100vw;height:100vh} }) ipcMain.handle('start-native-screen-recording', async (_, source: SelectedSource, options?: NativeMacRecordingOptions) => { - // Windows WGC path + // Windows native capture path if (process.platform === 'win32') { - const wgcAvailable = await isWgcCaptureAvailable() - if (!wgcAvailable) { - return { success: false, message: 'WGC capture is not available on this system.' } + const windowsCaptureAvailable = await isNativeWindowsCaptureAvailable() + if (!windowsCaptureAvailable) { + return { success: false, message: 'Native Windows capture is not available on this system.' } } - if (wgcCaptureProcess && !wgcScreenRecordingActive) { - try { wgcCaptureProcess.kill() } catch { /* ignore */ } - wgcCaptureProcess = null - wgcCaptureTargetPath = null - wgcCaptureStopRequested = false + if (windowsCaptureProcess && !windowsNativeCaptureActive) { + try { windowsCaptureProcess.kill() } catch { /* ignore */ } + windowsCaptureProcess = null + windowsCaptureTargetPath = null + windowsCaptureStopRequested = false } - if (wgcCaptureProcess) { - return { success: false, message: 'A WGC screen recording is already active.' } + if (windowsCaptureProcess) { + return { success: false, message: 'A native Windows screen recording is already active.' } } try { - const exePath = getWgcCaptureExePath() + const exePath = getWindowsCaptureExePath() const recordingsDir = await getRecordingsDir() const timestamp = Date.now() const outputPath = path.join(recordingsDir, `recording-${timestamp}.mp4`) @@ -2153,7 +2152,7 @@ body{background:transparent;overflow:hidden;width:100vw;height:100vh} const audioPath = path.join(recordingsDir, `recording-${timestamp}.system.wav`) config.captureSystemAudio = true config.audioOutputPath = audioPath - wgcSystemAudioPath = audioPath + windowsSystemAudioPath = audioPath } if (options?.capturesMicrophone) { @@ -2163,7 +2162,7 @@ body{background:transparent;overflow:hidden;width:100vw;height:100vh} if (options.microphoneLabel) { config.micDeviceName = options.microphoneLabel } - wgcMicAudioPath = micPath + windowsMicAudioPath = micPath } const windowId = parseWindowId(source?.id) @@ -2176,39 +2175,39 @@ body{background:transparent;overflow:hidden;width:100vw;height:100vh} : Number(getScreen().getPrimaryDisplay().id) } - wgcCaptureOutputBuffer = '' - wgcCaptureTargetPath = outputPath - wgcCaptureStopRequested = false - wgcCapturePaused = false - wgcCaptureProcess = spawn(exePath, [JSON.stringify(config)], { + windowsCaptureOutputBuffer = '' + windowsCaptureTargetPath = outputPath + windowsCaptureStopRequested = false + windowsCapturePaused = false + windowsCaptureProcess = spawn(exePath, [JSON.stringify(config)], { cwd: recordingsDir, stdio: ['pipe', 'pipe', 'pipe'], }) - attachWgcCaptureLifecycle(wgcCaptureProcess) + attachWindowsCaptureLifecycle(windowsCaptureProcess) - wgcCaptureProcess.stdout.on('data', (chunk: Buffer) => { - wgcCaptureOutputBuffer += chunk.toString() + windowsCaptureProcess.stdout.on('data', (chunk: Buffer) => { + windowsCaptureOutputBuffer += chunk.toString() }) - wgcCaptureProcess.stderr.on('data', (chunk: Buffer) => { - wgcCaptureOutputBuffer += chunk.toString() + windowsCaptureProcess.stderr.on('data', (chunk: Buffer) => { + windowsCaptureOutputBuffer += chunk.toString() }) - await waitForWgcCaptureStart(wgcCaptureProcess) - wgcScreenRecordingActive = true + await waitForWindowsCaptureStart(windowsCaptureProcess) + windowsNativeCaptureActive = true nativeScreenRecordingActive = true return { success: true } } catch (error) { - console.error('Failed to start WGC capture:', error) - try { wgcCaptureProcess?.kill() } catch { /* ignore */ } - wgcScreenRecordingActive = false + console.error('Failed to start native Windows capture:', error) + try { windowsCaptureProcess?.kill() } catch { /* ignore */ } + windowsNativeCaptureActive = false nativeScreenRecordingActive = false - wgcCaptureProcess = null - wgcCaptureTargetPath = null - wgcCaptureStopRequested = false - wgcCapturePaused = false + windowsCaptureProcess = null + windowsCaptureTargetPath = null + windowsCaptureStopRequested = false + windowsCapturePaused = false return { success: false, - message: 'Failed to start WGC capture', + message: 'Failed to start native Windows capture', error: String(error), } } @@ -2327,49 +2326,49 @@ body{background:transparent;overflow:hidden;width:100vw;height:100vh} }) ipcMain.handle('stop-native-screen-recording', async () => { - // Windows WGC stop path - if (process.platform === 'win32' && wgcScreenRecordingActive) { + // Windows native capture stop path + if (process.platform === 'win32' && windowsNativeCaptureActive) { try { - if (!wgcCaptureProcess) { - throw new Error('WGC capture process is not running') + if (!windowsCaptureProcess) { + throw new Error('Native Windows capture process is not running') } - const proc = wgcCaptureProcess - const preferredVideoPath = wgcCaptureTargetPath - wgcCaptureStopRequested = true + const proc = windowsCaptureProcess + const preferredVideoPath = windowsCaptureTargetPath + windowsCaptureStopRequested = true proc.stdin.write('stop\n') - const tempVideoPath = await waitForWgcCaptureStop(proc) - wgcCaptureProcess = null - wgcScreenRecordingActive = false + const tempVideoPath = await waitForWindowsCaptureStop(proc) + windowsCaptureProcess = null + windowsNativeCaptureActive = false nativeScreenRecordingActive = false - wgcCaptureTargetPath = null - wgcCaptureStopRequested = false - wgcCapturePaused = false + windowsCaptureTargetPath = null + windowsCaptureStopRequested = false + windowsCapturePaused = false const finalVideoPath = preferredVideoPath ?? tempVideoPath if (tempVideoPath !== finalVideoPath) { await moveFileWithOverwrite(tempVideoPath, finalVideoPath) } - wgcPendingVideoPath = finalVideoPath + windowsPendingVideoPath = finalVideoPath return { success: true, path: finalVideoPath } } catch (error) { - console.error('Failed to stop WGC capture:', error) - const fallbackPath = wgcCaptureTargetPath - wgcScreenRecordingActive = false + console.error('Failed to stop native Windows capture:', error) + const fallbackPath = windowsCaptureTargetPath + windowsNativeCaptureActive = false nativeScreenRecordingActive = false - wgcCaptureProcess = null - wgcCaptureTargetPath = null - wgcCaptureStopRequested = false - wgcCapturePaused = false - wgcSystemAudioPath = null - wgcMicAudioPath = null - wgcPendingVideoPath = null + windowsCaptureProcess = null + windowsCaptureTargetPath = null + windowsCaptureStopRequested = false + windowsCapturePaused = false + windowsSystemAudioPath = null + windowsMicAudioPath = null + windowsPendingVideoPath = null if (fallbackPath) { try { await fs.access(fallbackPath) - wgcPendingVideoPath = fallbackPath + windowsPendingVideoPath = fallbackPath return { success: true, path: fallbackPath } } catch { // File doesn't exist @@ -2378,7 +2377,7 @@ body{background:transparent;overflow:hidden;width:100vw;height:100vh} return { success: false, - message: 'Failed to stop WGC capture', + message: 'Failed to stop native Windows capture', error: String(error), } } @@ -2456,20 +2455,20 @@ body{background:transparent;overflow:hidden;width:100vw;height:100vh} ipcMain.handle('pause-native-screen-recording', async () => { if (process.platform === 'win32') { - if (!wgcScreenRecordingActive || !wgcCaptureProcess) { - return { success: false, message: 'No WGC screen recording is active.' } + if (!windowsNativeCaptureActive || !windowsCaptureProcess) { + return { success: false, message: 'No native Windows screen recording is active.' } } - if (wgcCapturePaused) { + if (windowsCapturePaused) { return { success: true } } try { - wgcCaptureProcess.stdin.write('pause\n') - wgcCapturePaused = true + windowsCaptureProcess.stdin.write('pause\n') + windowsCapturePaused = true return { success: true } } catch (error) { - return { success: false, message: 'Failed to pause WGC capture', error: String(error) } + return { success: false, message: 'Failed to pause native Windows capture', error: String(error) } } } @@ -2496,20 +2495,20 @@ body{background:transparent;overflow:hidden;width:100vw;height:100vh} ipcMain.handle('resume-native-screen-recording', async () => { if (process.platform === 'win32') { - if (!wgcScreenRecordingActive || !wgcCaptureProcess) { - return { success: false, message: 'No WGC screen recording is active.' } + if (!windowsNativeCaptureActive || !windowsCaptureProcess) { + return { success: false, message: 'No native Windows screen recording is active.' } } - if (!wgcCapturePaused) { + if (!windowsCapturePaused) { return { success: true } } try { - wgcCaptureProcess.stdin.write('resume\n') - wgcCapturePaused = false + windowsCaptureProcess.stdin.write('resume\n') + windowsCapturePaused = false return { success: true } } catch (error) { - return { success: false, message: 'Failed to resume WGC capture', error: String(error) } + return { success: false, message: 'Failed to resume native Windows capture', error: String(error) } } } @@ -2543,34 +2542,34 @@ body{background:transparent;overflow:hidden;width:100vw;height:100vh} } }) - ipcMain.handle('is-wgc-available', async () => { - return { available: await isWgcCaptureAvailable() } + ipcMain.handle('is-native-windows-capture-available', async () => { + return { available: await isNativeWindowsCaptureAvailable() } }) - ipcMain.handle('mux-wgc-recording', async () => { - const videoPath = wgcPendingVideoPath - wgcPendingVideoPath = null + ipcMain.handle('mux-native-windows-recording', async () => { + const videoPath = windowsPendingVideoPath + windowsPendingVideoPath = null if (!videoPath) { - return { success: false, message: 'No WGC video pending for mux' } + return { success: false, message: 'No native Windows video pending for mux' } } try { - if (wgcSystemAudioPath || wgcMicAudioPath) { - await muxWgcVideoWithAudio(videoPath, wgcSystemAudioPath, wgcMicAudioPath) - wgcSystemAudioPath = null - wgcMicAudioPath = null + if (windowsSystemAudioPath || windowsMicAudioPath) { + await muxNativeWindowsVideoWithAudio(videoPath, windowsSystemAudioPath, windowsMicAudioPath) + windowsSystemAudioPath = null + windowsMicAudioPath = null } return await finalizeStoredVideo(videoPath) } catch (error) { - console.error('Failed to mux WGC recording:', error) - wgcSystemAudioPath = null - wgcMicAudioPath = null + console.error('Failed to mux native Windows recording:', error) + windowsSystemAudioPath = null + windowsMicAudioPath = null try { return await finalizeStoredVideo(videoPath) } catch { - return { success: false, message: 'Failed to mux WGC recording', error: String(error) } + return { success: false, message: 'Failed to mux native Windows recording', error: String(error) } } } }) diff --git a/electron/main.ts b/electron/main.ts index ff3f51cd..09414938 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -4,7 +4,7 @@ import path from 'node:path' import fs from 'node:fs/promises' import { createHudOverlayWindow, createEditorWindow, createSourceSelectorWindow } from './windows' import { showCursor } from './cursorHider' -import { registerIpcHandlers, getSelectedSourceId, killWgcCaptureProcess } from './ipc/handlers' +import { registerIpcHandlers, getSelectedSourceId, killWindowsCaptureProcess } from './ipc/handlers' const __dirname = path.dirname(fileURLToPath(import.meta.url)) @@ -347,7 +347,7 @@ function createSourceSelectorWindowWrapper() { // On macOS, applications and their menu bar stay active until the user quits // explicitly with Cmd + Q. app.on('before-quit', () => { - killWgcCaptureProcess() + killWindowsCaptureProcess() showCursor() }) diff --git a/electron/native/wgc-capture/CMakeLists.txt b/electron/native/wgc-capture/CMakeLists.txt index 6b6c07ae..59e156e2 100644 --- a/electron/native/wgc-capture/CMakeLists.txt +++ b/electron/native/wgc-capture/CMakeLists.txt @@ -6,7 +6,7 @@ set(CMAKE_CXX_STANDARD_REQUIRED ON) add_executable(wgc-capture src/main.cpp - src/wgc_session.cpp + src/dxgi_session.cpp src/mf_encoder.cpp src/monitor_utils.cpp src/wasapi_loopback.cpp @@ -15,9 +15,9 @@ add_executable(wgc-capture target_compile_options(wgc-capture PRIVATE /EHsc /W3 /utf-8) target_link_libraries(wgc-capture PRIVATE - windowsapp d3d11 dxgi + dwmapi mfplat mfreadwrite mf diff --git a/electron/native/wgc-capture/src/dxgi_session.cpp b/electron/native/wgc-capture/src/dxgi_session.cpp new file mode 100644 index 00000000..76c91402 --- /dev/null +++ b/electron/native/wgc-capture/src/dxgi_session.cpp @@ -0,0 +1,343 @@ +#include "dxgi_session.h" + +#include + +#include +#include +#include + +namespace { + +bool intersectRectChecked(const RECT& lhs, const RECT& rhs, RECT& result) { + return IntersectRect(&result, &lhs, &rhs) != FALSE; +} + +int evenFloor(int value) { + return value > 1 ? (value & ~1) : value; +} + +RECT getExtendedWindowBounds(HWND hwnd) { + RECT bounds{}; + if (SUCCEEDED(DwmGetWindowAttribute(hwnd, DWMWA_EXTENDED_FRAME_BOUNDS, &bounds, sizeof(bounds)))) { + return bounds; + } + + GetWindowRect(hwnd, &bounds); + return bounds; +} + +} // namespace + +DxgiSession::DxgiSession() {} + +DxgiSession::~DxgiSession() { + stopCapture(); +} + +bool DxgiSession::findOutputForMonitor(HMONITOR monitor, ComPtr& adapter, ComPtr& output) { + ComPtr factory; + HRESULT hr = CreateDXGIFactory1(IID_PPV_ARGS(&factory)); + if (FAILED(hr)) { + std::cerr << "ERROR: CreateDXGIFactory1 failed: 0x" << std::hex << hr << std::endl; + return false; + } + + for (UINT adapterIndex = 0;; ++adapterIndex) { + ComPtr candidateAdapter; + hr = factory->EnumAdapters1(adapterIndex, &candidateAdapter); + if (hr == DXGI_ERROR_NOT_FOUND) { + break; + } + if (FAILED(hr)) { + continue; + } + + for (UINT outputIndex = 0;; ++outputIndex) { + ComPtr candidateOutput; + hr = candidateAdapter->EnumOutputs(outputIndex, &candidateOutput); + if (hr == DXGI_ERROR_NOT_FOUND) { + break; + } + if (FAILED(hr)) { + continue; + } + + DXGI_OUTPUT_DESC desc{}; + if (FAILED(candidateOutput->GetDesc(&desc))) { + continue; + } + + if (!desc.AttachedToDesktop || desc.Monitor != monitor) { + continue; + } + + ComPtr output1; + hr = candidateOutput.As(&output1); + if (FAILED(hr)) { + continue; + } + + adapter = candidateAdapter; + output = output1; + outputDesc_ = desc; + return true; + } + } + + return false; +} + +bool DxgiSession::createD3DDevice(IDXGIAdapter1* adapter) { + UINT creationFlags = D3D11_CREATE_DEVICE_BGRA_SUPPORT; + D3D_FEATURE_LEVEL featureLevels[] = { + D3D_FEATURE_LEVEL_11_1, + D3D_FEATURE_LEVEL_11_0, + }; + + HRESULT hr = D3D11CreateDevice( + adapter, + D3D_DRIVER_TYPE_UNKNOWN, + nullptr, + creationFlags, + featureLevels, + ARRAYSIZE(featureLevels), + D3D11_SDK_VERSION, + &d3dDevice_, + nullptr, + &d3dContext_); + + if (FAILED(hr)) { + std::cerr << "ERROR: D3D11CreateDevice failed: 0x" << std::hex << hr << std::endl; + return false; + } + + return true; +} + +bool DxgiSession::createDuplication(IDXGIOutput1* output) { + HRESULT hr = output->DuplicateOutput(d3dDevice_.Get(), &duplication_); + if (FAILED(hr)) { + std::cerr << "ERROR: DuplicateOutput failed: 0x" << std::hex << hr << std::endl; + return false; + } + + return true; +} + +bool DxgiSession::createCaptureTexture() { + if (captureWidth_ <= 0 || captureHeight_ <= 0) { + return false; + } + + D3D11_TEXTURE2D_DESC desc{}; + desc.Width = static_cast(captureWidth_); + desc.Height = static_cast(captureHeight_); + desc.MipLevels = 1; + desc.ArraySize = 1; + desc.Format = DXGI_FORMAT_B8G8R8A8_UNORM; + desc.SampleDesc.Count = 1; + desc.Usage = D3D11_USAGE_DEFAULT; + + HRESULT hr = d3dDevice_->CreateTexture2D(&desc, nullptr, &captureTexture_); + if (FAILED(hr)) { + std::cerr << "ERROR: CreateTexture2D failed: 0x" << std::hex << hr << std::endl; + return false; + } + + return true; +} + +bool DxgiSession::initializeFullMonitorRect() { + const int width = outputDesc_.DesktopCoordinates.right - outputDesc_.DesktopCoordinates.left; + const int height = outputDesc_.DesktopCoordinates.bottom - outputDesc_.DesktopCoordinates.top; + + captureWidth_ = evenFloor(width); + captureHeight_ = evenFloor(height); + if (captureWidth_ <= 0 || captureHeight_ <= 0) { + return false; + } + + captureRect_.left = 0; + captureRect_.top = 0; + captureRect_.right = captureWidth_; + captureRect_.bottom = captureHeight_; + return true; +} + +bool DxgiSession::updateWindowCaptureRect() { + if (!captureWindow_ || !windowHandle_) { + return false; + } + + RECT windowRect = getExtendedWindowBounds(windowHandle_); + RECT monitorRect = outputDesc_.DesktopCoordinates; + RECT clippedRect{}; + if (!intersectRectChecked(windowRect, monitorRect, clippedRect)) { + return false; + } + + int left = clippedRect.left - monitorRect.left; + int top = clippedRect.top - monitorRect.top; + const int monitorWidth = monitorRect.right - monitorRect.left; + const int monitorHeight = monitorRect.bottom - monitorRect.top; + + left = std::clamp(left, 0, std::max(0, monitorWidth - captureWidth_)); + top = std::clamp(top, 0, std::max(0, monitorHeight - captureHeight_)); + + captureRect_.left = left; + captureRect_.top = top; + captureRect_.right = left + captureWidth_; + captureRect_.bottom = top + captureHeight_; + return true; +} + +bool DxgiSession::initializeWindowRect(HWND hwnd) { + windowHandle_ = hwnd; + captureWindow_ = true; + + RECT windowRect = getExtendedWindowBounds(hwnd); + RECT monitorRect = outputDesc_.DesktopCoordinates; + RECT clippedRect{}; + if (!intersectRectChecked(windowRect, monitorRect, clippedRect)) { + return false; + } + + captureWidth_ = evenFloor(clippedRect.right - clippedRect.left); + captureHeight_ = evenFloor(clippedRect.bottom - clippedRect.top); + if (captureWidth_ <= 0 || captureHeight_ <= 0) { + return false; + } + + return updateWindowCaptureRect(); +} + +bool DxgiSession::initializeForMonitor(HMONITOR monitor, int fps) { + fps_ = fps; + frameIntervalHns_ = 10000000LL / fps_; + + ComPtr adapter; + ComPtr output; + if (!findOutputForMonitor(monitor, adapter, output)) { + std::cerr << "ERROR: Failed to find DXGI output for monitor" << std::endl; + return false; + } + + if (!createD3DDevice(adapter.Get())) { + return false; + } + + if (!createDuplication(output.Get())) { + return false; + } + + return true; +} + +bool DxgiSession::initialize(HMONITOR monitor, int fps) { + captureWindow_ = false; + windowHandle_ = nullptr; + + if (!initializeForMonitor(monitor, fps)) { + return false; + } + + return initializeFullMonitorRect() && createCaptureTexture(); +} + +bool DxgiSession::initialize(HWND hwnd, int fps) { + HMONITOR monitor = MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST); + if (!monitor) { + std::cerr << "ERROR: MonitorFromWindow failed" << std::endl; + return false; + } + + if (!initializeForMonitor(monitor, fps)) { + return false; + } + + return initializeWindowRect(hwnd) && createCaptureTexture(); +} + +void DxgiSession::setFrameCallback(FrameCallback callback) { + frameCallback_ = std::move(callback); +} + +bool DxgiSession::startCapture() { + if (!duplication_ || !captureTexture_) { + return false; + } + + capturing_ = true; + lastFrameTimeHns_ = 0; + captureThread_ = std::thread(&DxgiSession::captureLoop, this); + return true; +} + +void DxgiSession::stopCapture() { + capturing_ = false; + + if (captureThread_.joinable()) { + captureThread_.join(); + } + + duplication_.Reset(); + captureTexture_.Reset(); +} + +int64_t DxgiSession::nowHns() const { + return std::chrono::duration_cast>>( + std::chrono::steady_clock::now().time_since_epoch()).count(); +} + +void DxgiSession::captureLoop() { + while (capturing_) { + DXGI_OUTDUPL_FRAME_INFO frameInfo{}; + ComPtr desktopResource; + HRESULT hr = duplication_->AcquireNextFrame(100, &frameInfo, &desktopResource); + + if (hr == DXGI_ERROR_WAIT_TIMEOUT) { + continue; + } + + if (FAILED(hr)) { + if (hr != DXGI_ERROR_ACCESS_LOST) { + std::cerr << "ERROR: AcquireNextFrame failed: 0x" << std::hex << hr << std::endl; + } + break; + } + + ComPtr sourceTexture; + hr = desktopResource.As(&sourceTexture); + if (SUCCEEDED(hr) && sourceTexture) { + if (!captureWindow_ || updateWindowCaptureRect()) { + const int64_t timestampHns = nowHns(); + if (lastFrameTimeHns_ == 0 || (timestampHns - lastFrameTimeHns_) >= (frameIntervalHns_ * 7 / 10)) { + D3D11_BOX sourceBox{}; + sourceBox.left = static_cast(captureRect_.left); + sourceBox.top = static_cast(captureRect_.top); + sourceBox.front = 0; + sourceBox.right = static_cast(captureRect_.right); + sourceBox.bottom = static_cast(captureRect_.bottom); + sourceBox.back = 1; + + d3dContext_->CopySubresourceRegion( + captureTexture_.Get(), + 0, + 0, + 0, + 0, + sourceTexture.Get(), + 0, + &sourceBox); + + if (frameCallback_) { + lastFrameTimeHns_ = timestampHns; + frameCallback_(captureTexture_.Get(), timestampHns); + } + } + } + } + + duplication_->ReleaseFrame(); + } +} \ No newline at end of file diff --git a/electron/native/wgc-capture/src/dxgi_session.h b/electron/native/wgc-capture/src/dxgi_session.h new file mode 100644 index 00000000..37cd04b7 --- /dev/null +++ b/electron/native/wgc-capture/src/dxgi_session.h @@ -0,0 +1,62 @@ +#pragma once + +#include +#include +#include +#include + +#include +#include +#include + +using Microsoft::WRL::ComPtr; + +class DxgiSession { +public: + using FrameCallback = std::function; + + DxgiSession(); + ~DxgiSession(); + + bool initialize(HMONITOR monitor, int fps); + bool initialize(HWND hwnd, int fps); + void setFrameCallback(FrameCallback callback); + bool startCapture(); + void stopCapture(); + + int captureWidth() const { return captureWidth_; } + int captureHeight() const { return captureHeight_; } + ID3D11Device* device() const { return d3dDevice_.Get(); } + ID3D11DeviceContext* context() const { return d3dContext_.Get(); } + +private: + ComPtr d3dDevice_; + ComPtr d3dContext_; + ComPtr duplication_; + ComPtr captureTexture_; + + FrameCallback frameCallback_; + std::atomic capturing_{false}; + std::thread captureThread_; + + DXGI_OUTPUT_DESC outputDesc_{}; + RECT captureRect_{}; + HWND windowHandle_ = nullptr; + bool captureWindow_ = false; + int fps_ = 60; + int captureWidth_ = 0; + int captureHeight_ = 0; + int64_t frameIntervalHns_ = 0; + int64_t lastFrameTimeHns_ = 0; + + bool initializeForMonitor(HMONITOR monitor, int fps); + bool findOutputForMonitor(HMONITOR monitor, ComPtr& adapter, ComPtr& output); + bool createD3DDevice(IDXGIAdapter1* adapter); + bool createDuplication(IDXGIOutput1* output); + bool createCaptureTexture(); + bool initializeFullMonitorRect(); + bool initializeWindowRect(HWND hwnd); + bool updateWindowCaptureRect(); + void captureLoop(); + int64_t nowHns() const; +}; \ No newline at end of file diff --git a/electron/native/wgc-capture/src/main.cpp b/electron/native/wgc-capture/src/main.cpp index a603e944..d5c70bf3 100644 --- a/electron/native/wgc-capture/src/main.cpp +++ b/electron/native/wgc-capture/src/main.cpp @@ -1,11 +1,8 @@ -#include "wgc_session.h" +#include "dxgi_session.h" #include "mf_encoder.h" #include "monitor_utils.h" #include "wasapi_loopback.h" -#include -#include - #include #include #include @@ -176,15 +173,13 @@ int main(int argc, char* argv[]) { return 1; } - winrt::init_apartment(winrt::apartment_type::multi_threaded); - CaptureConfig config; if (!parseSimpleJson(argv[1], config)) { std::cerr << "ERROR: Failed to parse config JSON" << std::endl; return 1; } - WgcSession session; + DxgiSession session; if (config.windowHandle > 0) { HWND hwnd = reinterpret_cast(static_cast(config.windowHandle)); @@ -193,7 +188,7 @@ int main(int argc, char* argv[]) { return 1; } if (!session.initialize(hwnd, config.fps)) { - std::cerr << "ERROR: Failed to initialize WGC window capture session" << std::endl; + std::cerr << "ERROR: Failed to initialize DXGI window capture session" << std::endl; return 1; } } else { @@ -203,7 +198,7 @@ int main(int argc, char* argv[]) { return 1; } if (!session.initialize(monitor, config.fps)) { - std::cerr << "ERROR: Failed to initialize WGC capture session" << std::endl; + std::cerr << "ERROR: Failed to initialize DXGI capture session" << std::endl; return 1; } } @@ -278,7 +273,7 @@ int main(int argc, char* argv[]) { // Start video capture, then audio immediately after for sync if (!session.startCapture()) { - std::cerr << "ERROR: Failed to start WGC capture" << std::endl; + std::cerr << "ERROR: Failed to start DXGI capture" << std::endl; return 1; } diff --git a/electron/native/wgc-capture/src/wgc_session.cpp b/electron/native/wgc-capture/src/wgc_session.cpp deleted file mode 100644 index 7e59d140..00000000 --- a/electron/native/wgc-capture/src/wgc_session.cpp +++ /dev/null @@ -1,236 +0,0 @@ -#include "wgc_session.h" - -#include -#include -#include - -#include -#include - -#include -#include - -// IDirect3DDxgiInterfaceAccess is a COM interface for getting the DXGI interface -// from a WinRT IDirect3DSurface -MIDL_INTERFACE("A9B3D012-3DF2-4EE3-B8D1-8695F457D3C1") -IDirect3DDxgiInterfaceAccess : public IUnknown { - virtual HRESULT STDMETHODCALLTYPE GetInterface(REFIID iid, void** p) = 0; -}; - -// Convert ID3D11Device → IDirect3DDevice (WinRT interop) -extern "C" { - HRESULT __stdcall CreateDirect3D11DeviceFromDXGIDevice( - IDXGIDevice* dxgiDevice, - IInspectable** graphicsDevice); -} - -WgcSession::WgcSession() {} - -WgcSession::~WgcSession() { - stopCapture(); -} - -bool WgcSession::createD3DDevice() { - D3D_FEATURE_LEVEL featureLevels[] = { - D3D_FEATURE_LEVEL_11_1, - D3D_FEATURE_LEVEL_11_0, - }; - - HRESULT hr = D3D11CreateDevice( - nullptr, - D3D_DRIVER_TYPE_HARDWARE, - nullptr, - D3D11_CREATE_DEVICE_BGRA_SUPPORT, - featureLevels, - ARRAYSIZE(featureLevels), - D3D11_SDK_VERSION, - &d3dDevice_, - nullptr, - &d3dContext_); - - if (FAILED(hr)) { - std::cerr << "ERROR: D3D11CreateDevice failed: 0x" << std::hex << hr << std::endl; - return false; - } - - return true; -} - -winrt::Windows::Graphics::DirectX::Direct3D11::IDirect3DDevice WgcSession::createWinRTDevice() { - ComPtr dxgiDevice; - HRESULT hr = d3dDevice_.As(&dxgiDevice); - if (FAILED(hr)) return nullptr; - - winrt::com_ptr inspectable; - hr = CreateDirect3D11DeviceFromDXGIDevice(dxgiDevice.Get(), inspectable.put()); - if (FAILED(hr)) return nullptr; - - return inspectable.as(); -} - -winrt::Windows::Graphics::Capture::GraphicsCaptureItem WgcSession::createCaptureItemForMonitor(HMONITOR monitor) { - auto factory = winrt::get_activation_factory< - winrt::Windows::Graphics::Capture::GraphicsCaptureItem>(); - - auto interop = factory.as(); - - winrt::Windows::Graphics::Capture::GraphicsCaptureItem item{nullptr}; - HRESULT hr = interop->CreateForMonitor( - monitor, - winrt::guid_of(), - winrt::put_abi(item)); - - if (FAILED(hr)) { - std::cerr << "ERROR: CreateForMonitor failed: 0x" << std::hex << hr << std::endl; - return nullptr; - } - - return item; -} - -winrt::Windows::Graphics::Capture::GraphicsCaptureItem WgcSession::createCaptureItemForWindow(HWND hwnd) { - auto factory = winrt::get_activation_factory< - winrt::Windows::Graphics::Capture::GraphicsCaptureItem>(); - - auto interop = factory.as(); - - winrt::Windows::Graphics::Capture::GraphicsCaptureItem item{nullptr}; - HRESULT hr = interop->CreateForWindow( - hwnd, - winrt::guid_of(), - winrt::put_abi(item)); - - if (FAILED(hr)) { - std::cerr << "ERROR: CreateForWindow failed: 0x" << std::hex << hr << std::endl; - return nullptr; - } - - return item; -} - -bool WgcSession::initializeWithItem(int fps) { - if (!captureItem_) return false; - - auto size = captureItem_.Size(); - captureWidth_ = size.Width; - captureHeight_ = size.Height; - - framePool_ = winrt::Windows::Graphics::Capture::Direct3D11CaptureFramePool::CreateFreeThreaded( - winrtDevice_, - winrt::Windows::Graphics::DirectX::DirectXPixelFormat::B8G8R8A8UIntNormalized, - 2, - size); - - session_ = framePool_.CreateCaptureSession(captureItem_); - - session_.IsCursorCaptureEnabled(false); - session_.IsBorderRequired(false); - - return true; -} - -bool WgcSession::initialize(HMONITOR monitor, int fps) { - fps_ = fps; - frameIntervalHns_ = 10000000LL / fps_; - - if (!createD3DDevice()) return false; - - winrtDevice_ = createWinRTDevice(); - if (!winrtDevice_) { - std::cerr << "ERROR: Failed to create WinRT D3D device" << std::endl; - return false; - } - - captureItem_ = createCaptureItemForMonitor(monitor); - return initializeWithItem(fps); -} - -bool WgcSession::initialize(HWND hwnd, int fps) { - fps_ = fps; - frameIntervalHns_ = 10000000LL / fps_; - - if (!createD3DDevice()) return false; - - winrtDevice_ = createWinRTDevice(); - if (!winrtDevice_) { - std::cerr << "ERROR: Failed to create WinRT D3D device" << std::endl; - return false; - } - - captureItem_ = createCaptureItemForWindow(hwnd); - return initializeWithItem(fps); -} - -void WgcSession::setFrameCallback(FrameCallback callback) { - frameCallback_ = std::move(callback); -} - -bool WgcSession::startCapture() { - if (!session_ || !framePool_) return false; - - capturing_ = true; - lastFrameTimeHns_ = 0; - - frameArrivedRevoker_ = framePool_.FrameArrived( - winrt::auto_revoke, - [this](auto const& sender, auto const& args) { - onFrameArrived(sender, args); - }); - - session_.StartCapture(); - return true; -} - -void WgcSession::stopCapture() { - capturing_ = false; - - frameArrivedRevoker_.revoke(); - - if (session_) { - session_.Close(); - session_ = nullptr; - } - if (framePool_) { - framePool_.Close(); - framePool_ = nullptr; - } -} - -void WgcSession::onFrameArrived( - winrt::Windows::Graphics::Capture::Direct3D11CaptureFramePool const& sender, - winrt::Windows::Foundation::IInspectable const&) { - - if (!capturing_) return; - - auto frame = sender.TryGetNextFrame(); - if (!frame) return; - - auto timestamp = frame.SystemRelativeTime(); - int64_t frameTimeHns = std::chrono::duration_cast>>(timestamp).count(); - - // Frame rate limiting: skip frames that arrive too soon - if (lastFrameTimeHns_ > 0 && (frameTimeHns - lastFrameTimeHns_) < (frameIntervalHns_ * 7 / 10)) { - frame.Close(); - return; - } - lastFrameTimeHns_ = frameTimeHns; - - auto surface = frame.Surface(); - - // Get the underlying D3D texture from the WinRT surface via COM interop - winrt::com_ptr access; - try { - access = surface.as(); - } catch (...) { - frame.Close(); - return; - } - ComPtr texture; - HRESULT hr = access->GetInterface(IID_PPV_ARGS(&texture)); - - if (SUCCEEDED(hr) && texture && frameCallback_) { - frameCallback_(texture.Get(), frameTimeHns); - } - - frame.Close(); -} diff --git a/electron/native/wgc-capture/src/wgc_session.h b/electron/native/wgc-capture/src/wgc_session.h deleted file mode 100644 index 5ca6be34..00000000 --- a/electron/native/wgc-capture/src/wgc_session.h +++ /dev/null @@ -1,62 +0,0 @@ -#pragma once - -#include -#include -#include -#include - -#include -#include -#include -#include - -#include -#include -#include - -using Microsoft::WRL::ComPtr; - -class WgcSession { -public: - using FrameCallback = std::function; - - WgcSession(); - ~WgcSession(); - - bool initialize(HMONITOR monitor, int fps); - bool initialize(HWND hwnd, int fps); - void setFrameCallback(FrameCallback callback); - bool startCapture(); - void stopCapture(); - - int captureWidth() const { return captureWidth_; } - int captureHeight() const { return captureHeight_; } - ID3D11Device* device() const { return d3dDevice_.Get(); } - ID3D11DeviceContext* context() const { return d3dContext_.Get(); } - -private: - ComPtr d3dDevice_; - ComPtr d3dContext_; - winrt::Windows::Graphics::DirectX::Direct3D11::IDirect3DDevice winrtDevice_{nullptr}; - winrt::Windows::Graphics::Capture::GraphicsCaptureItem captureItem_{nullptr}; - winrt::Windows::Graphics::Capture::Direct3D11CaptureFramePool framePool_{nullptr}; - winrt::Windows::Graphics::Capture::GraphicsCaptureSession session_{nullptr}; - winrt::Windows::Graphics::Capture::Direct3D11CaptureFramePool::FrameArrived_revoker frameArrivedRevoker_; - - FrameCallback frameCallback_; - std::atomic capturing_{false}; - int fps_ = 60; - int captureWidth_ = 0; - int captureHeight_ = 0; - int64_t frameIntervalHns_ = 0; - int64_t lastFrameTimeHns_ = 0; - - bool createD3DDevice(); - winrt::Windows::Graphics::DirectX::Direct3D11::IDirect3DDevice createWinRTDevice(); - winrt::Windows::Graphics::Capture::GraphicsCaptureItem createCaptureItemForMonitor(HMONITOR monitor); - winrt::Windows::Graphics::Capture::GraphicsCaptureItem createCaptureItemForWindow(HWND hwnd); - bool initializeWithItem(int fps); - void onFrameArrived( - winrt::Windows::Graphics::Capture::Direct3D11CaptureFramePool const& sender, - winrt::Windows::Foundation::IInspectable const& args); -}; diff --git a/electron/preload.ts b/electron/preload.ts index f13bb835..a41cf5a6 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -7,6 +7,9 @@ contextBridge.exposeInMainWorld("electronAPI", { hudOverlayClose: () => { ipcRenderer.send("hud-overlay-close"); }, + setHudOverlayExpanded: (expanded: boolean) => { + ipcRenderer.send("set-hud-overlay-expanded", expanded); + }, getHudOverlayCaptureProtection: () => { return ipcRenderer.invoke("get-hud-overlay-capture-protection"); }, @@ -215,8 +218,8 @@ contextBridge.exposeInMainWorld("electronAPI", { ipcRenderer.on("request-save-before-close", listener); return () => ipcRenderer.removeListener("request-save-before-close", listener); }, - isWgcAvailable: () => ipcRenderer.invoke("is-wgc-available"), - muxWgcRecording: () => ipcRenderer.invoke("mux-wgc-recording"), + isNativeWindowsCaptureAvailable: () => ipcRenderer.invoke("is-native-windows-capture-available"), + muxNativeWindowsRecording: () => ipcRenderer.invoke("mux-native-windows-recording"), hideOsCursor: () => ipcRenderer.invoke("hide-cursor"), getCountdownDelay: () => ipcRenderer.invoke("get-countdown-delay"), setCountdownDelay: (delay: number) => ipcRenderer.invoke("set-countdown-delay", delay), diff --git a/electron/windows.ts b/electron/windows.ts index 4c21241a..f9f047f5 100644 --- a/electron/windows.ts +++ b/electron/windows.ts @@ -27,6 +27,9 @@ const DIP_PER_INCH = 96; const CM_PER_INCH = 2.54; const HUD_EDGE_MARGIN_DIP = 16; const HUD_SHADOW_BLEED_DIP = 20; +const HUD_WINDOW_WIDTH = 560; +const HUD_COMPACT_HEIGHT = 96; +const HUD_EXPANDED_HEIGHT = 520 + HUD_SHADOW_BLEED_DIP; function isHudOverlayCaptureProtectionSupported(): boolean { return process.platform !== "linux"; @@ -72,12 +75,54 @@ function getScreen() { return nodeRequire("electron").screen as typeof import("electron").screen; } +function getHudOverlayBounds(expanded: boolean) { + const primaryDisplay = getScreen().getPrimaryDisplay(); + const { bounds, workArea } = primaryDisplay; + const windowWidth = HUD_WINDOW_WIDTH; + const windowHeight = expanded ? HUD_EXPANDED_HEIGHT : HUD_COMPACT_HEIGHT; + const bottomClearanceDip = Math.round((HUD_BOTTOM_CLEARANCE_CM / CM_PER_INCH) * DIP_PER_INCH); + const screenBottom = bounds.y + bounds.height; + const workAreaBottom = workArea.y + workArea.height; + const preferredBottom = screenBottom - bottomClearanceDip; + const maximumSafeBottom = workAreaBottom - HUD_EDGE_MARGIN_DIP; + const windowBottom = Math.min(preferredBottom, maximumSafeBottom); + + const x = Math.floor(workArea.x + (workArea.width - windowWidth) / 2); + const y = Math.max( + workArea.y + HUD_EDGE_MARGIN_DIP, + Math.floor(windowBottom - windowHeight), + ); + + return { + x, + y, + width: windowWidth, + height: windowHeight, + }; +} + +function applyHudOverlayBounds(expanded: boolean) { + if (!hudOverlayWindow || hudOverlayWindow.isDestroyed()) { + return; + } + + hudOverlayWindow.setBounds(getHudOverlayBounds(expanded), false); + if (!hudOverlayWindow.isVisible()) { + return; + } + hudOverlayWindow.moveTop(); +} + ipcMain.on("hud-overlay-hide", () => { if (hudOverlayWindow && !hudOverlayWindow.isDestroyed()) { hudOverlayWindow.minimize(); } }); +ipcMain.on("set-hud-overlay-expanded", (_event, expanded: boolean) => { + applyHudOverlayBounds(Boolean(expanded)); +}); + ipcMain.handle("get-hud-overlay-capture-protection", () => { const enabled = loadHudOverlayCaptureProtectionSetting(); @@ -108,34 +153,17 @@ ipcMain.handle("set-hud-overlay-capture-protection", (_event, enabled: boolean) export function createHudOverlayWindow(): BrowserWindow { loadHudOverlayCaptureProtectionSetting(); - - const primaryDisplay = getScreen().getPrimaryDisplay(); - const { bounds, workArea } = primaryDisplay; - - const windowWidth = 720; - const windowHeight = 520 + HUD_SHADOW_BLEED_DIP; - const bottomClearanceDip = Math.round((HUD_BOTTOM_CLEARANCE_CM / CM_PER_INCH) * DIP_PER_INCH); - const screenBottom = bounds.y + bounds.height; - const workAreaBottom = workArea.y + workArea.height; - const preferredBottom = screenBottom - bottomClearanceDip; - const maximumSafeBottom = workAreaBottom - HUD_EDGE_MARGIN_DIP; - const windowBottom = Math.min(preferredBottom, maximumSafeBottom); - - const x = Math.floor(workArea.x + (workArea.width - windowWidth) / 2); - const y = Math.max( - workArea.y + HUD_EDGE_MARGIN_DIP, - Math.floor(windowBottom - windowHeight), - ); + const initialBounds = getHudOverlayBounds(false); const win = new BrowserWindow({ - width: windowWidth, - height: windowHeight, - minWidth: 720, - maxWidth: 720, - minHeight: windowHeight, - maxHeight: windowHeight, - x: x, - y: y, + width: initialBounds.width, + height: initialBounds.height, + minWidth: HUD_WINDOW_WIDTH, + maxWidth: HUD_WINDOW_WIDTH, + minHeight: HUD_COMPACT_HEIGHT, + maxHeight: HUD_EXPANDED_HEIGHT, + x: initialBounds.x, + y: initialBounds.y, frame: false, transparent: true, resizable: false, diff --git a/scripts/build-wgc-capture.mjs b/scripts/build-wgc-capture.mjs index 1c200506..49a6a7a7 100644 --- a/scripts/build-wgc-capture.mjs +++ b/scripts/build-wgc-capture.mjs @@ -7,7 +7,7 @@ const sourceDir = path.join(projectRoot, 'electron', 'native', 'wgc-capture'); const buildDir = path.join(sourceDir, 'build'); if (process.platform !== 'win32') { - console.log('[build-wgc-capture] Skipping: host platform is not Windows.'); + console.log('[build-wgc-capture] Skipping native Windows capture build: host platform is not Windows.'); process.exit(0); } @@ -69,7 +69,7 @@ try { } } -console.log('[build-wgc-capture] Building...'); +console.log('[build-wgc-capture] Building native Windows capture helper...'); try { execSync(`${cmake} --build . --config Release`, { cwd: buildDir, diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 0764d282..12590e9d 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -348,6 +348,15 @@ export function LaunchWindow() { return () => { cancelled = true; }; }, []); + useEffect(() => { + const expanded = activeDropdown !== "none"; + window.electronAPI.setHudOverlayExpanded(expanded); + + return () => { + window.electronAPI.setHudOverlayExpanded(false); + }; + }, [activeDropdown]); + useEffect(() => { const handleClick = (e: MouseEvent) => { if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) { @@ -471,14 +480,17 @@ export function LaunchWindow() { return (
- {/* Top area — flex:1 reserves space, menu card sits at bottom of this area */} -
- {activeDropdown !== "none" && ( -
+
+ {/* Only the visible HUD content should become interactive. */} +
+ {activeDropdown !== "none" && ( +
{activeDropdown === "sources" && ( <> {sourcesLoading ? ( @@ -680,13 +692,12 @@ export function LaunchWindow() { ))} )} -
- )} -
+
+ )} +
- {/* Bottom section — fixed height, bar always stays here */} -
-
+
+
@@ -790,6 +801,7 @@ export function LaunchWindow() { )} +
diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index 493457f7..8d0f1e38 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -80,10 +80,10 @@ export function useScreenRecorder(): UseScreenRecorderReturn { const startTime = useRef(0); const recordingSessionTimestamp = useRef(null); const nativeScreenRecording = useRef(false); - const wgcRecording = useRef(false); + const nativeWindowsRecording = useRef(false); const startInFlight = useRef(false); const hasPromptedForReselect = useRef(false); - const hasShownWgcFallbackToast = useRef(false); + const hasShownNativeWindowsFallbackToast = useRef(false); const countdownDelayLoaded = useRef(false); const pendingWebcamPathPromise = useRef | null>(null); const webcamStopPromise = useRef | null>(null); @@ -311,8 +311,8 @@ export function useScreenRecorder(): UseScreenRecorderReturn { void (async () => { const webcamPath = await stopWebcamRecorder(); - const isWgc = wgcRecording.current; - wgcRecording.current = false; + const isNativeWindows = nativeWindowsRecording.current; + nativeWindowsRecording.current = false; const result = await window.electronAPI.stopNativeScreenRecording(); window.electronAPI?.setRecordingState(false); @@ -324,8 +324,8 @@ export function useScreenRecorder(): UseScreenRecorderReturn { let finalPath = result.path; - if (isWgc) { - const muxResult = await window.electronAPI.muxWgcRecording(); + if (isNativeWindows) { + const muxResult = await window.electronAPI.muxNativeWindowsRecording(); finalPath = muxResult?.path ?? result.path; } @@ -450,33 +450,33 @@ export function useScreenRecorder(): UseScreenRecorderReturn { (selectedSource.id?.startsWith("screen:") || selectedSource.id?.startsWith("window:")) && typeof window.electronAPI.startNativeScreenRecording === "function"; - let useWgcCapture = false; + let useNativeWindowsCapture = false; if ( platform === "win32" && (selectedSource.id?.startsWith("screen:") || selectedSource.id?.startsWith("window:")) && - typeof window.electronAPI.isWgcAvailable === "function" + typeof window.electronAPI.isNativeWindowsCaptureAvailable === "function" ) { try { - const wgcResult = await window.electronAPI.isWgcAvailable(); - useWgcCapture = wgcResult.available; - if (!useWgcCapture && !hasShownWgcFallbackToast.current) { - hasShownWgcFallbackToast.current = true; + const nativeWindowsResult = await window.electronAPI.isNativeWindowsCaptureAvailable(); + useNativeWindowsCapture = nativeWindowsResult.available; + if (!useNativeWindowsCapture && !hasShownNativeWindowsFallbackToast.current) { + hasShownNativeWindowsFallbackToast.current = true; toast.info( - "Native Windows capture (WGC) is unavailable. Falling back to browser capture.", + "Native Windows capture is unavailable. Falling back to browser capture.", ); } } catch { - useWgcCapture = false; - if (!hasShownWgcFallbackToast.current) { - hasShownWgcFallbackToast.current = true; + useNativeWindowsCapture = false; + if (!hasShownNativeWindowsFallbackToast.current) { + hasShownNativeWindowsFallbackToast.current = true; toast.info( - "Unable to check native Windows capture (WGC). Falling back to browser capture.", + "Unable to check native Windows capture. Falling back to browser capture.", ); } } } - if (useNativeMacScreenCapture || useWgcCapture) { + if (useNativeMacScreenCapture || useNativeWindowsCapture) { // Resolve the selected mic label for native capture backends. let micLabel: string | undefined; if (microphoneEnabled) { @@ -498,10 +498,10 @@ export function useScreenRecorder(): UseScreenRecorderReturn { microphoneLabel: micLabel, }); if (!nativeResult.success) { - if (useWgcCapture) { - console.warn("WGC capture failed, falling back to browser capture:", nativeResult.error ?? nativeResult.message); - if (!hasShownWgcFallbackToast.current) { - hasShownWgcFallbackToast.current = true; + if (useNativeWindowsCapture) { + console.warn("Native Windows capture failed, falling back to browser capture:", nativeResult.error ?? nativeResult.message); + if (!hasShownNativeWindowsFallbackToast.current) { + hasShownNativeWindowsFallbackToast.current = true; toast.warning( "Native Windows capture failed to start. Falling back to browser capture.", ); @@ -515,7 +515,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { if (nativeResult.success) { nativeScreenRecording.current = true; - wgcRecording.current = useWgcCapture; + nativeWindowsRecording.current = useNativeWindowsCapture; startTime.current = Date.now(); setRecording(true); window.electronAPI?.setRecordingState(true); @@ -817,7 +817,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { if (nativeScreenRecording.current) { nativeScreenRecording.current = false; - wgcRecording.current = false; + nativeWindowsRecording.current = false; setRecording(false); window.electronAPI?.setRecordingState(false); void (async () => {