Fix Windows DXGI capture helper flow

This commit is contained in:
webadderall
2026-03-19 00:05:58 +11:00
parent ae521ed9d9
commit f5ccd5b7e0
19 changed files with 48 additions and 122 deletions
+1 -1
View File
@@ -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**: DXGI Desktop Duplication via C++ CMake build (`scripts/build-wgc-capture.mjs`), fallback to FFmpeg
- **Windows**: DXGI Desktop Duplication via C++ CMake build (`scripts/build-windows-capture.mjs`), fallback to FFmpeg
- **Linux**: Chromium `getDisplayMedia`
- Core recording logic: `src/hooks/useScreenRecorder.ts` (60 FPS target, adaptive bitrate)
+2 -2
View File
@@ -160,7 +160,7 @@ xattr -rd com.apple.quarantine /Applications/Recordly.app
| Platform | Minimum version | Notes |
|---|---|---|
| **macOS** | macOS 12.3 (Monterey) | Required for ScreenCaptureKit. Recording and cursor hiding will not work on older versions. |
| **Windows** | Windows 10 20H1 (Build 19041, May 2020) | Required for Windows Graphics Capture (`IsCursorCaptureEnabled`). Older builds fall back to Electron browser capture — cursor will be visible in recordings. |
| **Windows** | Windows 10 20H1 (Build 19041, May 2020) | Required for the native DXGI Desktop Duplication helper. Older builds fall back to Electron browser capture — cursor will be visible in recordings. |
| **Linux** | Any modern distro | Recording works via Electron capture. Cursor is always visible in recordings. System audio requires PipeWire (Ubuntu 22.04+, Fedora 34+). |
> [!IMPORTANT]
@@ -217,7 +217,7 @@ Adjust:
**macOS**: Cursor is excluded from the recording at the ScreenCaptureKit level — always clean.
**Windows**: Cursor is excluded via Windows Graphics Capture (`IsCursorCaptureEnabled(false)`) — requires **Windows 10 Build 19041+**. On older builds the app falls back to Electrons browser capture and the real cursor will be visible in the recording.
**Windows**: Cursor exclusion depends on the native DXGI Desktop Duplication helper plus OS cursor hide/show, and requires **Windows 10 Build 19041+**. On older builds the app falls back to Electrons browser capture and the real cursor will be visible in the recording.
**Linux**: Electrons desktop capture API does not support cursor hiding. The real OS cursor will always be visible in recordings. If you also enable the animated cursor overlay in the editor, you may see **two cursors** in the output.
+2 -2
View File
@@ -156,7 +156,7 @@ xattr -rd com.apple.quarantine /Applications/Recordly.app
| 平台 | 最低版本 | 说明 |
|---|---|---|
| **macOS** | macOS 12.3 (Monterey) | ScreenCaptureKit 的最低要求。更旧版本无法正常进行录制和隐藏光标。 |
| **Windows** | Windows 10 20H1Build 190412020 年 5 月) | Windows Graphics Capture (`IsCursorCaptureEnabled`) 的最低要求。更旧版本会回退到 Electron 浏览器捕获,录制中会显示真实光标。 |
| **Windows** | Windows 10 20H1Build 190412020 年 5 月) | 原生 DXGI Desktop Duplication 录制助手的最低要求。更旧版本会回退到 Electron 浏览器捕获,录制中会显示真实光标。 |
| **Linux** | 任意现代发行版 | 通过 Electron 捕获实现录制。录制中光标始终可见。系统音频需要 PipeWireUbuntu 22.04+、Fedora 34+)。 |
> [!IMPORTANT]
@@ -213,7 +213,7 @@ xattr -rd com.apple.quarantine /Applications/Recordly.app
**macOS**:在 ScreenCaptureKit 层级就会将光标排除,因此始终干净。
**Windows**通过 Windows Graphics Capture 的 `IsCursorCaptureEnabled(false)` 排除光标,需要 **Windows 10 Build 19041+**。在更旧版本中,应用会回退到 Electron 浏览器捕获,真实光标会出现在录制里。
**Windows**光标排除依赖原生 DXGI Desktop Duplication 录制助手以及系统光标隐藏/恢复逻辑,需要 **Windows 10 Build 19041+**。在更旧版本中,应用会回退到 Electron 浏览器捕获,真实光标会出现在录制里。
**Linux**Electron 的桌面捕获 API 不支持隐藏光标。真实系统光标将始终出现在录制中。如果你同时在编辑器中启用动画光标叠加,导出结果中可能会出现**两个光标**。
+1 -1
View File
@@ -848,7 +848,7 @@ async function buildFfmpegCaptureArgs(source: SelectedSource, outputPath: string
}
function getWindowsCaptureExePath() {
return resolveUnpackedAppPath('electron', 'native', 'wgc-capture', 'build', 'Release', 'wgc-capture.exe')
return resolveUnpackedAppPath('electron', 'native', 'windows-capture', 'build', 'Release', 'windows-capture.exe')
}
function getCursorMonitorExePath() {
@@ -1,10 +1,10 @@
cmake_minimum_required(VERSION 3.20)
project(wgc-capture LANGUAGES CXX)
project(windows-capture LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
add_executable(wgc-capture
add_executable(windows-capture
src/main.cpp
src/dxgi_session.cpp
src/mf_encoder.cpp
@@ -12,9 +12,9 @@ add_executable(wgc-capture
src/wasapi_loopback.cpp
)
target_compile_options(wgc-capture PRIVATE /EHsc /W3 /utf-8)
target_compile_options(windows-capture PRIVATE /EHsc /W3 /utf-8)
target_link_libraries(wgc-capture PRIVATE
target_link_libraries(windows-capture PRIVATE
d3d11
dxgi
dwmapi
+7 -6
View File
@@ -16,19 +16,20 @@
"type": "module",
"scripts": {
"dev": "vite",
"postinstall": "npm run rebuild:native",
"build": "npm run build:native-helpers && tsc && vite build && electron-builder",
"postinstall": "npm run rebuild:native && npm run build:platform-native-helpers",
"build": "npm run build:platform-native-helpers && tsc && vite build && electron-builder",
"lint": "biome check .",
"lint:fix": "biome check --write .",
"format": "biome format --write .",
"preview": "vite preview",
"rebuild:native": "electron-rebuild --force --only uiohook-napi",
"build:native-helpers": "node scripts/build-native-helpers.mjs",
"build:wgc-capture": "node scripts/build-wgc-capture.mjs",
"build:platform-native-helpers": "npm run build:native-helpers && npm run build:windows-capture && npm run build:cursor-monitor",
"build:windows-capture": "node scripts/build-windows-capture.mjs",
"build:cursor-monitor": "node scripts/build-cursor-monitor.mjs",
"build:mac": "npm run build:native-helpers && tsc && vite build && electron-builder --mac",
"build:win": "npm run build:wgc-capture && npm run build:cursor-monitor && tsc && vite build && electron-builder --win",
"build:linux": "tsc && vite build && electron-builder --linux",
"build:mac": "npm run build:platform-native-helpers && tsc && vite build && electron-builder --mac",
"build:win": "npm run build:platform-native-helpers && tsc && vite build && electron-builder --win",
"build:linux": "npm run build:platform-native-helpers && tsc && vite build && electron-builder --linux",
"i18n:check": "node scripts/i18n-check.mjs",
"test": "vitest --run",
"test:watch": "vitest"
@@ -3,16 +3,16 @@ import { mkdirSync, existsSync } from 'node:fs';
import path from 'node:path';
const projectRoot = process.cwd();
const sourceDir = path.join(projectRoot, 'electron', 'native', 'wgc-capture');
const sourceDir = path.join(projectRoot, 'electron', 'native', 'windows-capture');
const buildDir = path.join(sourceDir, 'build');
if (process.platform !== 'win32') {
console.log('[build-wgc-capture] Skipping native Windows capture build: host platform is not Windows.');
console.log('[build-windows-capture] Skipping native Windows capture build: host platform is not Windows.');
process.exit(0);
}
if (!existsSync(path.join(sourceDir, 'CMakeLists.txt'))) {
console.error('[build-wgc-capture] CMakeLists.txt not found at', sourceDir);
console.error('[build-windows-capture] CMakeLists.txt not found at', sourceDir);
process.exit(1);
}
@@ -42,13 +42,13 @@ function findCmake() {
const cmake = findCmake();
if (!cmake) {
console.error('[build-wgc-capture] CMake not found. Install Visual Studio with C++ CMake tools or standalone CMake.');
console.error('[build-windows-capture] CMake not found. Install Visual Studio with C++ CMake tools or standalone CMake.');
process.exit(1);
}
mkdirSync(buildDir, { recursive: true });
console.log('[build-wgc-capture] Configuring CMake...');
console.log('[build-windows-capture] Configuring CMake...');
try {
execSync(`${cmake} .. -G "Visual Studio 17 2022" -A x64`, {
cwd: buildDir,
@@ -56,7 +56,7 @@ try {
timeout: 120000,
});
} catch {
console.log('[build-wgc-capture] VS 2022 generator not found, trying VS 2019...');
console.log('[build-windows-capture] VS 2022 generator not found, trying VS 2019...');
try {
execSync(`${cmake} .. -G "Visual Studio 16 2019" -A x64`, {
cwd: buildDir,
@@ -64,12 +64,12 @@ try {
timeout: 120000,
});
} catch (innerError) {
console.error('[build-wgc-capture] CMake configure failed:', innerError.message);
console.error('[build-windows-capture] CMake configure failed:', innerError.message);
process.exit(1);
}
}
console.log('[build-wgc-capture] Building native Windows capture helper...');
console.log('[build-windows-capture] Building native Windows capture helper...');
try {
execSync(`${cmake} --build . --config Release`, {
cwd: buildDir,
@@ -77,14 +77,14 @@ try {
timeout: 300000,
});
} catch (error) {
console.error('[build-wgc-capture] Build failed:', error.message);
console.error('[build-windows-capture] Build failed:', error.message);
process.exit(1);
}
const exePath = path.join(buildDir, 'Release', 'wgc-capture.exe');
const exePath = path.join(buildDir, 'Release', 'windows-capture.exe');
if (existsSync(exePath)) {
console.log(`[build-wgc-capture] Built successfully: ${exePath}`);
console.log(`[build-windows-capture] Built successfully: ${exePath}`);
} else {
console.error('[build-wgc-capture] Expected exe not found at', exePath);
console.error('[build-windows-capture] Expected exe not found at', exePath);
process.exit(1);
}
+4
View File
@@ -731,6 +731,10 @@ export function LaunchWindow() {
<Square size={16} fill="currentColor" strokeWidth={0} />
</IconButton>
<IconButton onClick={() => window.electronAPI?.hudOverlayHide?.()} title={t("recording.hideHud")}>
<Minus size={16} />
</IconButton>
<IconButton onClick={cancelRecording} title={t("recording.cancel")}>
<X size={18} />
</IconButton>
@@ -38,7 +38,6 @@ import {
import { SettingsPanel } from "./SettingsPanel";
import TimelineEditor from "./timeline/TimelineEditor";
import {
detectInteractionCandidates,
normalizeCursorTelemetry,
} from "./timeline/zoomSuggestionUtils";
import {
@@ -189,7 +188,6 @@ export default function VideoEditor() {
const nextAnnotationIdRef = useRef(1);
const nextAnnotationZIndexRef = useRef(1); // Track z-index for stacking order
const exporterRef = useRef<VideoExporter | null>(null);
const autoSuggestedVideoPathRef = useRef<string | null>(null);
const historyPastRef = useRef<EditorHistorySnapshot[]>([]);
const historyFutureRef = useRef<EditorHistorySnapshot[]>([]);
const historyCurrentRef = useRef<EditorHistorySnapshot | null>(null);
@@ -910,84 +908,6 @@ export default function VideoEditor() {
return [...zoomRegions.filter((region) => region.id !== loopEndRegion.id), loopEndRegion];
}, [loopCursor, zoomRegions, displayedTimelineWindow, connectZooms]);
useEffect(() => {
if (
!videoPath ||
duration <= 0 ||
zoomRegions.length > 0 ||
normalizedCursorTelemetry.length < 2
) {
return;
}
if (autoSuggestedVideoPathRef.current === videoPath) {
return;
}
const totalMs = Math.max(0, Math.round(duration * 1000));
if (totalMs <= 0) {
return;
}
const candidates = detectInteractionCandidates(normalizedCursorTelemetry);
if (candidates.length === 0) {
autoSuggestedVideoPathRef.current = videoPath;
return;
}
const DEFAULT_DURATION_MS = 1100;
const MIN_SPACING_MS = 1800;
const sortedCandidates = [...candidates].sort((a, b) => b.strength - a.strength);
const acceptedCenters: number[] = [];
setZoomRegions((prev) => {
if (prev.length > 0) {
return prev;
}
const reservedSpans: Array<{ start: number; end: number }> = [];
const additions: ZoomRegion[] = [];
let nextId = nextZoomIdRef.current;
sortedCandidates.forEach((candidate) => {
const tooCloseToAccepted = acceptedCenters.some(
(center) => Math.abs(center - candidate.centerTimeMs) < MIN_SPACING_MS,
);
if (tooCloseToAccepted) {
return;
}
const centeredStart = Math.round(candidate.centerTimeMs - DEFAULT_DURATION_MS / 2);
const startMs = Math.max(0, Math.min(centeredStart, totalMs - DEFAULT_DURATION_MS));
const endMs = Math.min(totalMs, startMs + DEFAULT_DURATION_MS);
const hasOverlap = reservedSpans.some((span) => endMs > span.start && startMs < span.end);
if (hasOverlap) {
return;
}
additions.push({
id: `zoom-${nextId++}`,
startMs,
endMs,
depth: DEFAULT_ZOOM_DEPTH,
focus: clampFocusToDepth(candidate.focus, DEFAULT_ZOOM_DEPTH),
});
reservedSpans.push({ start: startMs, end: endMs });
acceptedCenters.push(candidate.centerTimeMs);
});
if (additions.length === 0) {
return prev;
}
nextZoomIdRef.current = nextId;
return [...prev, ...additions];
});
autoSuggestedVideoPathRef.current = videoPath;
}, [videoPath, duration, normalizedCursorTelemetry, zoomRegions.length]);
// Initialize default wallpaper with resolved asset path
useEffect(() => {
let mounted = true;
+15 -14
View File
@@ -534,19 +534,20 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
let videoTrack: MediaStreamTrack | undefined;
let systemAudioIncluded = false;
const browserScreenVideoConstraints = {
mandatory: {
chromeMediaSource: CHROME_MEDIA_SOURCE,
chromeMediaSourceId: selectedSource.id,
maxWidth: TARGET_WIDTH,
maxHeight: TARGET_HEIGHT,
maxFrameRate: TARGET_FRAME_RATE,
minFrameRate: MIN_FRAME_RATE,
googCaptureCursor: false,
},
cursor: "never" as const,
};
if (wantsAudioCapture) {
const videoConstraints = {
mandatory: {
chromeMediaSource: CHROME_MEDIA_SOURCE,
chromeMediaSourceId: selectedSource.id,
maxWidth: TARGET_WIDTH,
maxHeight: TARGET_HEIGHT,
maxFrameRate: TARGET_FRAME_RATE,
minFrameRate: MIN_FRAME_RATE,
},
};
let screenMediaStream: MediaStream;
if (systemAudioEnabled) {
@@ -558,20 +559,20 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
chromeMediaSourceId: selectedSource.id,
},
},
video: videoConstraints,
video: browserScreenVideoConstraints,
});
} catch (audioError) {
console.warn("System audio capture failed, falling back to video-only:", audioError);
alert("System audio is not available for this source. Recording will continue without system audio.");
screenMediaStream = await (navigator.mediaDevices as any).getUserMedia({
audio: false,
video: videoConstraints,
video: browserScreenVideoConstraints,
});
}
} else {
screenMediaStream = await (navigator.mediaDevices as any).getUserMedia({
audio: false,
video: videoConstraints,
video: browserScreenVideoConstraints,
});
}