Add native WGC screen capture for Windows

Uses Windows.Graphics.Capture to record the screen without the OS cursor
or the yellow capture border. Falls back to Electron capture on older Windows
or when the helper exe isn't available.
This commit is contained in:
tokenflow1m
2026-03-14 23:02:29 +09:00
parent 9332255beb
commit 7b26ec3a9e
15 changed files with 1369 additions and 11 deletions
+4 -1
View File
@@ -27,4 +27,7 @@ release/**
*.kiro/
# npx electron-builder --mac --win
.tmp/
.history/
.history/
# WGC native capture build artifacts
electron/native/wgc-capture/build/
+2
View File
@@ -78,6 +78,8 @@ interface Window {
hudOverlayClose: () => void;
setHasUnsavedChanges: (hasChanges: boolean) => void
onRequestSaveBeforeClose: (callback: () => Promise<void>) => () => void
isWgcAvailable: () => Promise<{ available: boolean }>
storeWgcAudio: (audioData: ArrayBuffer, type: 'system' | 'mic') => Promise<{ success: boolean; path?: string; error?: string }>
/** Hide the OS cursor before browser capture starts. */
hideOsCursor: () => Promise<{ success: boolean }>
}
+347
View File
@@ -59,6 +59,13 @@ let nativeCaptureStopRequested = false
let nativeCaptureMicrophonePath: string | null = null
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 wgcSystemAudioPath: string | null = null
let wgcMicAudioPath: string | null = null
let ffmpegScreenRecordingActive = false
let ffmpegCaptureProcess: ChildProcessWithoutNullStreams | null = null
let ffmpegCaptureOutputBuffer = ''
@@ -678,6 +685,197 @@ async function buildFfmpegCaptureArgs(source: SelectedSource, outputPath: string
throw new Error(`FFmpeg capture is not supported on ${process.platform}`)
}
function getWgcCaptureExePath() {
return resolveUnpackedAppPath('electron', 'native', 'wgc-capture', 'build', 'Release', 'wgc-capture.exe')
}
async function isWgcCaptureAvailable(): Promise<boolean> {
if (process.platform !== 'win32') return false
try {
await fs.access(getWgcCaptureExePath(), 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
}
function waitForWgcCaptureStart(proc: ChildProcessWithoutNullStreams) {
return new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
cleanup()
reject(new Error('Timed out waiting for WGC capture to start'))
}, 12000)
const onStdout = (chunk: Buffer) => {
const text = chunk.toString()
if (text.includes('Recording started')) {
cleanup()
resolve()
}
}
const onError = (error: Error) => {
cleanup()
reject(error)
}
const onExit = (code: number | null) => {
cleanup()
reject(new Error(wgcCaptureOutputBuffer.trim() || `WGC capture exited before recording started (code ${code ?? 'unknown'})`))
}
const cleanup = () => {
clearTimeout(timer)
proc.stdout.off('data', onStdout)
proc.off('error', onError)
proc.off('exit', onExit)
}
proc.stdout.on('data', onStdout)
proc.once('error', onError)
proc.once('exit', onExit)
})
}
function waitForWgcCaptureStop(proc: ChildProcessWithoutNullStreams) {
return new Promise<string>((resolve, reject) => {
const onClose = (code: number | null) => {
cleanup()
const match = wgcCaptureOutputBuffer.match(/Recording stopped\. Output path: (.+)/)
if (match?.[1]) {
resolve(match[1].trim())
return
}
if (code === 0 && wgcCaptureTargetPath) {
resolve(wgcCaptureTargetPath)
return
}
reject(new Error(wgcCaptureOutputBuffer.trim() || `WGC capture exited with code ${code ?? 'unknown'}`))
}
const onError = (error: Error) => {
cleanup()
reject(error)
}
const cleanup = () => {
proc.off('close', onClose)
proc.off('error', onError)
}
proc.once('close', onClose)
proc.once('error', onError)
})
}
function attachWgcCaptureLifecycle(proc: ChildProcessWithoutNullStreams) {
proc.once('close', () => {
const wasActive = wgcScreenRecordingActive
wgcCaptureProcess = null
if (!wasActive || wgcCaptureStopRequested) {
return
}
wgcScreenRecordingActive = false
wgcCaptureTargetPath = null
wgcCaptureStopRequested = false
const sourceName = selectedSource?.name ?? 'Screen'
BrowserWindow.getAllWindows().forEach((window) => {
if (!window.isDestroyed()) {
window.webContents.send('recording-state-changed', {
recording: false,
sourceName,
})
}
})
emitRecordingInterrupted('capture-stopped', 'Recording stopped unexpectedly.')
})
}
async function muxWgcVideoWithAudio(videoPath: string, systemAudioPath: string | null, micAudioPath: string | null) {
const ffmpegPath = getFfmpegBinaryPath()
const inputs: string[] = ['-i', videoPath]
const audioInputs: string[] = []
if (systemAudioPath) {
try {
await fs.access(systemAudioPath)
inputs.push('-i', systemAudioPath)
audioInputs.push('system')
} catch {
// system audio file not available
}
}
if (micAudioPath) {
try {
await fs.access(micAudioPath)
inputs.push('-i', micAudioPath)
audioInputs.push('mic')
} catch {
// mic audio file not available
}
}
if (audioInputs.length === 0) return
const mixedOutputPath = `${videoPath}.muxed.mp4`
if (audioInputs.length === 2) {
// Both system + mic audio: mix them
await execFileAsync(
ffmpegPath,
[
'-y',
...inputs,
'-filter_complex', '[1:a][2:a]amix=inputs=2:duration=longest:normalize=0[aout]',
'-map', '0:v:0',
'-map', '[aout]',
'-c:v', 'copy',
'-c:a', 'aac',
'-b:a', '192k',
'-shortest',
mixedOutputPath,
],
{ timeout: 120000, maxBuffer: 10 * 1024 * 1024 },
)
} else {
// Single audio track
await execFileAsync(
ffmpegPath,
[
'-y',
...inputs,
'-map', '0:v:0',
'-map', '1:a:0',
'-c:v', 'copy',
'-c:a', 'aac',
'-b:a', '192k',
'-shortest',
mixedOutputPath,
],
{ timeout: 120000, maxBuffer: 10 * 1024 * 1024 },
)
}
await moveFileWithOverwrite(mixedOutputPath, videoPath)
// Clean up audio files
for (const audioPath of [systemAudioPath, micAudioPath]) {
if (audioPath) {
await fs.rm(audioPath, { force: true }).catch(() => {})
}
}
}
function waitForNativeCaptureStart(process: ChildProcessWithoutNullStreams) {
return new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
@@ -1492,6 +1690,74 @@ export function registerIpcHandlers(
})
ipcMain.handle('start-native-screen-recording', async (_, source: SelectedSource, options?: NativeMacRecordingOptions) => {
// Windows WGC path
if (process.platform === 'win32') {
const wgcAvailable = await isWgcCaptureAvailable()
if (!wgcAvailable) {
return { success: false, message: 'WGC capture is not available on this system.' }
}
if (wgcCaptureProcess && !wgcScreenRecordingActive) {
try { wgcCaptureProcess.kill() } catch { /* ignore */ }
wgcCaptureProcess = null
wgcCaptureTargetPath = null
wgcCaptureStopRequested = false
}
if (wgcCaptureProcess) {
return { success: false, message: 'A WGC screen recording is already active.' }
}
try {
const exePath = getWgcCaptureExePath()
const outputPath = path.join(RECORDINGS_DIR, `recording-${Date.now()}.mp4`)
const screenId = Number(source?.display_id)
const displayId = Number.isFinite(screenId) && screenId > 0
? screenId
: Number(getScreen().getPrimaryDisplay().id)
const config: Record<string, unknown> = {
displayId,
outputPath,
fps: 60,
}
wgcCaptureOutputBuffer = ''
wgcCaptureTargetPath = outputPath
wgcCaptureStopRequested = false
wgcCaptureProcess = spawn(exePath, [JSON.stringify(config)], {
cwd: RECORDINGS_DIR,
stdio: ['pipe', 'pipe', 'pipe'],
})
attachWgcCaptureLifecycle(wgcCaptureProcess)
wgcCaptureProcess.stdout.on('data', (chunk: Buffer) => {
wgcCaptureOutputBuffer += chunk.toString()
})
wgcCaptureProcess.stderr.on('data', (chunk: Buffer) => {
wgcCaptureOutputBuffer += chunk.toString()
})
await waitForWgcCaptureStart(wgcCaptureProcess)
wgcScreenRecordingActive = true
nativeScreenRecordingActive = true
return { success: true }
} catch (error) {
console.error('Failed to start WGC capture:', error)
try { wgcCaptureProcess?.kill() } catch { /* ignore */ }
wgcScreenRecordingActive = false
nativeScreenRecordingActive = false
wgcCaptureProcess = null
wgcCaptureTargetPath = null
wgcCaptureStopRequested = false
return {
success: false,
message: 'Failed to start WGC capture',
error: String(error),
}
}
}
if (process.platform !== 'darwin') {
return { success: false, message: 'Native screen recording is only available on macOS.' }
}
@@ -1599,6 +1865,68 @@ export function registerIpcHandlers(
})
ipcMain.handle('stop-native-screen-recording', async () => {
// Windows WGC stop path
if (process.platform === 'win32' && wgcScreenRecordingActive) {
try {
if (!wgcCaptureProcess) {
throw new Error('WGC capture process is not running')
}
const proc = wgcCaptureProcess
const preferredVideoPath = wgcCaptureTargetPath
wgcCaptureStopRequested = true
proc.stdin.write('stop\n')
const tempVideoPath = await waitForWgcCaptureStop(proc)
wgcCaptureProcess = null
wgcScreenRecordingActive = false
nativeScreenRecordingActive = false
wgcCaptureTargetPath = null
wgcCaptureStopRequested = false
const finalVideoPath = preferredVideoPath ?? tempVideoPath
if (tempVideoPath !== finalVideoPath) {
await moveFileWithOverwrite(tempVideoPath, finalVideoPath)
}
if (wgcSystemAudioPath || wgcMicAudioPath) {
try {
await muxWgcVideoWithAudio(finalVideoPath, wgcSystemAudioPath, wgcMicAudioPath)
} catch (muxError) {
console.warn('Failed to mux WGC audio:', muxError)
}
wgcSystemAudioPath = null
wgcMicAudioPath = null
}
return await finalizeStoredVideo(finalVideoPath)
} catch (error) {
console.error('Failed to stop WGC capture:', error)
const fallbackPath = wgcCaptureTargetPath
wgcScreenRecordingActive = false
nativeScreenRecordingActive = false
wgcCaptureProcess = null
wgcCaptureTargetPath = null
wgcCaptureStopRequested = false
wgcSystemAudioPath = null
wgcMicAudioPath = null
if (fallbackPath) {
try {
await fs.access(fallbackPath)
return await finalizeStoredVideo(fallbackPath)
} catch {
// File doesn't exist
}
}
return {
success: false,
message: 'Failed to stop WGC capture',
error: String(error),
}
}
}
if (process.platform !== 'darwin') {
return { success: false, message: 'Native screen recording is only available on macOS.' }
}
@@ -1676,6 +2004,25 @@ export function registerIpcHandlers(
}
})
ipcMain.handle('is-wgc-available', async () => {
return { available: await isWgcCaptureAvailable() }
})
ipcMain.handle('store-wgc-audio', async (_, audioData: ArrayBuffer, type: 'system' | 'mic') => {
try {
const audioPath = path.join(RECORDINGS_DIR, `recording-${Date.now()}.${type}.webm`)
await fs.writeFile(audioPath, Buffer.from(audioData))
if (type === 'system') {
wgcSystemAudioPath = audioPath
} else {
wgcMicAudioPath = audioPath
}
return { success: true, path: audioPath }
} catch (error) {
return { success: false, error: String(error) }
}
})
ipcMain.handle('start-ffmpeg-recording', async (_, source: SelectedSource) => {
if (ffmpegCaptureProcess) {
return { success: false, message: 'An FFmpeg recording is already active.' }
@@ -0,0 +1,26 @@
cmake_minimum_required(VERSION 3.20)
project(wgc-capture LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
add_executable(wgc-capture
src/main.cpp
src/wgc_session.cpp
src/mf_encoder.cpp
src/monitor_utils.cpp
)
target_compile_options(wgc-capture PRIVATE /EHsc /W3 /utf-8)
target_link_libraries(wgc-capture PRIVATE
windowsapp
d3d11
dxgi
mfplat
mfreadwrite
mf
mfuuid
ole32
shcore
)
+196
View File
@@ -0,0 +1,196 @@
#include "wgc_session.h"
#include "mf_encoder.h"
#include "monitor_utils.h"
#include <winrt/Windows.Foundation.h>
#include <winrt/Windows.System.h>
#include <iostream>
#include <string>
#include <thread>
#include <atomic>
#include <mutex>
#include <condition_variable>
static std::atomic<bool> g_stopRequested{false};
static std::mutex g_stopMutex;
static std::condition_variable g_stopCv;
struct CaptureConfig {
int displayId = 0;
std::string outputPath;
int fps = 60;
int width = 0;
int height = 0;
};
static bool parseSimpleJson(const std::string& json, CaptureConfig& config) {
auto findInt = [&](const std::string& key) -> int {
auto pos = json.find("\"" + key + "\"");
if (pos == std::string::npos) return -1;
pos = json.find(':', pos);
if (pos == std::string::npos) return -1;
pos++;
while (pos < json.size() && (json[pos] == ' ' || json[pos] == '\t')) pos++;
try {
return std::stoi(json.substr(pos));
} catch (...) {
return -1;
}
};
auto findString = [&](const std::string& key) -> std::string {
auto pos = json.find("\"" + key + "\"");
if (pos == std::string::npos) return "";
pos = json.find(':', pos);
if (pos == std::string::npos) return "";
pos++;
while (pos < json.size() && (json[pos] == ' ' || json[pos] == '\t')) pos++;
if (pos >= json.size() || json[pos] != '"') return "";
pos++;
std::string result;
while (pos < json.size() && json[pos] != '"') {
if (json[pos] == '\\' && pos + 1 < json.size()) {
pos++;
if (json[pos] == 'n') result += '\n';
else if (json[pos] == 't') result += '\t';
else if (json[pos] == '\\') result += '\\';
else if (json[pos] == '"') result += '"';
else if (json[pos] == '/') result += '/';
else result += json[pos];
} else {
result += json[pos];
}
pos++;
}
return result;
};
config.outputPath = findString("outputPath");
if (config.outputPath.empty()) return false;
int displayId = findInt("displayId");
if (displayId >= 0) config.displayId = displayId;
int fps = findInt("fps");
if (fps > 0) config.fps = fps;
int width = findInt("width");
if (width > 0) config.width = width;
int height = findInt("height");
if (height > 0) config.height = height;
return true;
}
static std::wstring utf8ToWide(const std::string& str) {
if (str.empty()) return L"";
int len = MultiByteToWideChar(CP_UTF8, 0, str.c_str(), static_cast<int>(str.size()), nullptr, 0);
std::wstring wstr(len, L'\0');
MultiByteToWideChar(CP_UTF8, 0, str.c_str(), static_cast<int>(str.size()), &wstr[0], len);
return wstr;
}
static void stdinListenerThread() {
std::string line;
while (std::getline(std::cin, line)) {
// Trim whitespace
while (!line.empty() && (line.back() == '\r' || line.back() == '\n' || line.back() == ' ')) {
line.pop_back();
}
if (line == "stop") {
g_stopRequested = true;
g_stopCv.notify_all();
return;
}
}
// stdin closed (parent process died)
g_stopRequested = true;
g_stopCv.notify_all();
}
int main(int argc, char* argv[]) {
if (argc < 2) {
std::cerr << "ERROR: Missing JSON config argument" << std::endl;
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;
}
// Resolve monitor
HMONITOR monitor = findMonitorByDisplayId(config.displayId);
if (!monitor) {
std::cerr << "ERROR: Could not find monitor for displayId " << config.displayId << std::endl;
return 1;
}
// Initialize WGC session
WgcSession session;
if (!session.initialize(monitor, config.fps)) {
std::cerr << "ERROR: Failed to initialize WGC capture session" << std::endl;
return 1;
}
int captureWidth = config.width > 0 ? config.width : session.captureWidth();
int captureHeight = config.height > 0 ? config.height : session.captureHeight();
// Ensure even dimensions for H.264
captureWidth = (captureWidth / 2) * 2;
captureHeight = (captureHeight / 2) * 2;
// Initialize encoder
MFEncoder encoder;
std::wstring outputPathW = utf8ToWide(config.outputPath);
if (!encoder.initialize(outputPathW, captureWidth, captureHeight, config.fps,
session.device(), session.context())) {
std::cerr << "ERROR: Failed to initialize Media Foundation encoder" << std::endl;
return 1;
}
// Set up frame callback
std::atomic<int64_t> frameCount{0};
session.setFrameCallback([&](ID3D11Texture2D* texture, int64_t timestampHns) {
if (g_stopRequested) return;
if (encoder.writeFrame(texture, timestampHns)) {
frameCount++;
}
});
// Start stdin listener
std::thread stdinThread(stdinListenerThread);
stdinThread.detach();
// Start capture
if (!session.startCapture()) {
std::cerr << "ERROR: Failed to start WGC capture" << std::endl;
return 1;
}
std::cout << "Recording started" << std::endl;
std::cout.flush();
// Wait for stop signal
{
std::unique_lock<std::mutex> lock(g_stopMutex);
g_stopCv.wait(lock, [] { return g_stopRequested.load(); });
}
// Stop capture and finalize
session.stopCapture();
encoder.finalize();
std::cout << "Recording stopped. Output path: " << config.outputPath << std::endl;
std::cout.flush();
// Fast exit to avoid WinRT/COM teardown crashes during apartment cleanup
ExitProcess(0);
}
@@ -0,0 +1,200 @@
#include "mf_encoder.h"
#include <mfapi.h>
#include <mferror.h>
#include <codecapi.h>
#include <iostream>
#include <cstring>
#pragma comment(lib, "mfplat.lib")
#pragma comment(lib, "mfreadwrite.lib")
#pragma comment(lib, "mf.lib")
#pragma comment(lib, "mfuuid.lib")
static int clampByte(int v) {
return v < 0 ? 0 : (v > 255 ? 255 : v);
}
MFEncoder::MFEncoder() {}
MFEncoder::~MFEncoder() {
finalize();
}
bool MFEncoder::initialize(const std::wstring& outputPath, int width, int height, int fps,
ID3D11Device* device, ID3D11DeviceContext* context) {
if (initialized_) return false;
width_ = width;
height_ = height;
fps_ = fps;
device_ = device;
context_ = context;
HRESULT hr = MFStartup(MF_VERSION);
if (FAILED(hr)) {
std::cerr << "ERROR: MFStartup failed: 0x" << std::hex << hr << std::endl;
return false;
}
// Output media type (H.264)
ComPtr<IMFMediaType> outputType;
hr = MFCreateMediaType(&outputType);
if (FAILED(hr)) return false;
outputType->SetGUID(MF_MT_MAJOR_TYPE, MFMediaType_Video);
outputType->SetGUID(MF_MT_SUBTYPE, MFVideoFormat_H264);
outputType->SetUINT32(MF_MT_AVG_BITRATE, 20000000);
MFSetAttributeSize(outputType.Get(), MF_MT_FRAME_SIZE, width_, height_);
MFSetAttributeRatio(outputType.Get(), MF_MT_FRAME_RATE, fps_, 1);
MFSetAttributeRatio(outputType.Get(), MF_MT_PIXEL_ASPECT_RATIO, 1, 1);
outputType->SetUINT32(MF_MT_INTERLACE_MODE, MFVideoInterlace_Progressive);
// Input media type (NV12)
ComPtr<IMFMediaType> inputType;
hr = MFCreateMediaType(&inputType);
if (FAILED(hr)) return false;
inputType->SetGUID(MF_MT_MAJOR_TYPE, MFMediaType_Video);
inputType->SetGUID(MF_MT_SUBTYPE, MFVideoFormat_NV12);
MFSetAttributeSize(inputType.Get(), MF_MT_FRAME_SIZE, width_, height_);
MFSetAttributeRatio(inputType.Get(), MF_MT_FRAME_RATE, fps_, 1);
MFSetAttributeRatio(inputType.Get(), MF_MT_PIXEL_ASPECT_RATIO, 1, 1);
inputType->SetUINT32(MF_MT_INTERLACE_MODE, MFVideoInterlace_Progressive);
// Create SinkWriter with MPEG4 container
ComPtr<IMFAttributes> writerAttrs;
hr = MFCreateAttributes(&writerAttrs, 1);
if (FAILED(hr)) return false;
writerAttrs->SetUINT32(MF_READWRITE_ENABLE_HARDWARE_TRANSFORMS, TRUE);
hr = MFCreateSinkWriterFromURL(outputPath.c_str(), nullptr, writerAttrs.Get(), &sinkWriter_);
if (FAILED(hr)) {
std::cerr << "ERROR: MFCreateSinkWriterFromURL failed: 0x" << std::hex << hr << std::endl;
return false;
}
hr = sinkWriter_->AddStream(outputType.Get(), &streamIndex_);
if (FAILED(hr)) {
std::cerr << "ERROR: AddStream failed: 0x" << std::hex << hr << std::endl;
return false;
}
hr = sinkWriter_->SetInputMediaType(streamIndex_, inputType.Get(), nullptr);
if (FAILED(hr)) {
std::cerr << "ERROR: SetInputMediaType failed: 0x" << std::hex << hr << std::endl;
return false;
}
hr = sinkWriter_->BeginWriting();
if (FAILED(hr)) {
std::cerr << "ERROR: BeginWriting failed: 0x" << std::hex << hr << std::endl;
return false;
}
// Pre-allocate staging texture
D3D11_TEXTURE2D_DESC stagingDesc = {};
stagingDesc.Width = width_;
stagingDesc.Height = height_;
stagingDesc.MipLevels = 1;
stagingDesc.ArraySize = 1;
stagingDesc.Format = DXGI_FORMAT_B8G8R8A8_UNORM;
stagingDesc.SampleDesc.Count = 1;
stagingDesc.Usage = D3D11_USAGE_STAGING;
stagingDesc.CPUAccessFlags = D3D11_CPU_ACCESS_READ;
hr = device_->CreateTexture2D(&stagingDesc, nullptr, &stagingTexture_);
if (FAILED(hr)) {
std::cerr << "ERROR: Failed to create staging texture: 0x" << std::hex << hr << std::endl;
return false;
}
// Pre-allocate NV12 buffer
const int ySize = width_ * height_;
const int uvSize = (width_ / 2) * (height_ / 2) * 2;
nv12Buffer_.resize(ySize + uvSize);
initialized_ = true;
return true;
}
bool MFEncoder::writeFrame(ID3D11Texture2D* texture, int64_t timestampHns) {
if (!initialized_ || !sinkWriter_) return false;
context_->CopyResource(stagingTexture_.Get(), texture);
D3D11_MAPPED_SUBRESOURCE mapped;
HRESULT hr = context_->Map(stagingTexture_.Get(), 0, D3D11_MAP_READ, 0, &mapped);
if (FAILED(hr)) return false;
// Convert BGRA → NV12
const uint8_t* bgra = static_cast<const uint8_t*>(mapped.pData);
const int bgraPitch = static_cast<int>(mapped.RowPitch);
// Y plane
for (int y = 0; y < height_; y++) {
for (int x = 0; x < width_; x++) {
const uint8_t* pixel = bgra + y * bgraPitch + x * 4;
uint8_t b = pixel[0], g = pixel[1], r = pixel[2];
int yVal = ((66 * r + 129 * g + 25 * b + 128) >> 8) + 16;
nv12Buffer_[y * width_ + x] = static_cast<uint8_t>(clampByte(yVal));
}
}
// UV plane (interleaved, subsampled 2x2)
const int ySize = width_ * height_;
uint8_t* uvPlane = nv12Buffer_.data() + ySize;
for (int y = 0; y < height_; y += 2) {
for (int x = 0; x < width_; x += 2) {
const uint8_t* pixel = bgra + y * bgraPitch + x * 4;
uint8_t b = pixel[0], g = pixel[1], r = pixel[2];
int u = ((-38 * r - 74 * g + 112 * b + 128) >> 8) + 128;
int v = ((112 * r - 94 * g - 18 * b + 128) >> 8) + 128;
int uvIdx = (y / 2) * width_ + (x / 2) * 2;
uvPlane[uvIdx] = static_cast<uint8_t>(clampByte(u));
uvPlane[uvIdx + 1] = static_cast<uint8_t>(clampByte(v));
}
}
context_->Unmap(stagingTexture_.Get(), 0);
// Create MF sample
DWORD bufferSize = static_cast<DWORD>(nv12Buffer_.size());
ComPtr<IMFMediaBuffer> buffer;
hr = MFCreateMemoryBuffer(bufferSize, &buffer);
if (FAILED(hr)) return false;
BYTE* bufferData = nullptr;
hr = buffer->Lock(&bufferData, nullptr, nullptr);
if (FAILED(hr)) return false;
std::memcpy(bufferData, nv12Buffer_.data(), bufferSize);
buffer->Unlock();
buffer->SetCurrentLength(bufferSize);
ComPtr<IMFSample> sample;
hr = MFCreateSample(&sample);
if (FAILED(hr)) return false;
sample->AddBuffer(buffer.Get());
sample->SetSampleTime(timestampHns);
sample->SetSampleDuration(10000000LL / fps_);
hr = sinkWriter_->WriteSample(streamIndex_, sample.Get());
return SUCCEEDED(hr);
}
bool MFEncoder::finalize() {
if (!initialized_) return false;
initialized_ = false;
stagingTexture_.Reset();
nv12Buffer_.clear();
nv12Buffer_.shrink_to_fit();
if (!sinkWriter_) return false;
HRESULT hr = sinkWriter_->Finalize();
sinkWriter_.Reset();
MFShutdown();
return SUCCEEDED(hr);
}
@@ -0,0 +1,35 @@
#pragma once
#include <windows.h>
#include <mfapi.h>
#include <mfidl.h>
#include <mfreadwrite.h>
#include <d3d11.h>
#include <wrl/client.h>
#include <string>
#include <vector>
using Microsoft::WRL::ComPtr;
class MFEncoder {
public:
MFEncoder();
~MFEncoder();
bool initialize(const std::wstring& outputPath, int width, int height, int fps,
ID3D11Device* device, ID3D11DeviceContext* context);
bool writeFrame(ID3D11Texture2D* texture, int64_t timestampHns);
bool finalize();
private:
ComPtr<IMFSinkWriter> sinkWriter_;
ID3D11Device* device_ = nullptr;
ID3D11DeviceContext* context_ = nullptr;
ComPtr<ID3D11Texture2D> stagingTexture_;
std::vector<uint8_t> nv12Buffer_;
DWORD streamIndex_ = 0;
int width_ = 0;
int height_ = 0;
int fps_ = 60;
bool initialized_ = false;
};
@@ -0,0 +1,61 @@
#include "monitor_utils.h"
#include <ShellScalingApi.h>
static BOOL CALLBACK enumMonitorCallback(HMONITOR hMonitor, HDC, LPRECT, LPARAM lParam) {
auto* monitors = reinterpret_cast<std::vector<MonitorInfo>*>(lParam);
MONITORINFOEXW mi = {};
mi.cbSize = sizeof(mi);
if (GetMonitorInfoW(hMonitor, &mi)) {
MonitorInfo info;
info.handle = hMonitor;
info.x = mi.rcMonitor.left;
info.y = mi.rcMonitor.top;
info.width = mi.rcMonitor.right - mi.rcMonitor.left;
info.height = mi.rcMonitor.bottom - mi.rcMonitor.top;
info.deviceName = mi.szDevice;
monitors->push_back(info);
}
return TRUE;
}
std::vector<MonitorInfo> enumerateMonitors() {
std::vector<MonitorInfo> monitors;
EnumDisplayMonitors(nullptr, nullptr, enumMonitorCallback, reinterpret_cast<LPARAM>(&monitors));
return monitors;
}
// Electron uses the HMONITOR handle value cast to a number as the display ID.
HMONITOR findMonitorByDisplayId(int displayId) {
auto monitors = enumerateMonitors();
for (const auto& m : monitors) {
if (static_cast<int>(reinterpret_cast<intptr_t>(m.handle)) == displayId) {
return m.handle;
}
}
if (!monitors.empty()) {
return monitors[0].handle;
}
return MonitorFromPoint({0, 0}, MONITOR_DEFAULTTOPRIMARY);
}
MonitorInfo getMonitorInfo(HMONITOR monitor) {
MonitorInfo info;
info.handle = monitor;
MONITORINFOEXW mi = {};
mi.cbSize = sizeof(mi);
if (GetMonitorInfoW(monitor, &mi)) {
info.x = mi.rcMonitor.left;
info.y = mi.rcMonitor.top;
info.width = mi.rcMonitor.right - mi.rcMonitor.left;
info.height = mi.rcMonitor.bottom - mi.rcMonitor.top;
info.deviceName = mi.szDevice;
}
return info;
}
@@ -0,0 +1,18 @@
#pragma once
#include <windows.h>
#include <string>
#include <vector>
struct MonitorInfo {
HMONITOR handle;
int x;
int y;
int width;
int height;
std::wstring deviceName;
};
std::vector<MonitorInfo> enumerateMonitors();
HMONITOR findMonitorByDisplayId(int displayId);
MonitorInfo getMonitorInfo(HMONITOR monitor);
@@ -0,0 +1,196 @@
#include "wgc_session.h"
#include <windows.graphics.capture.interop.h>
#include <Windows.Graphics.Capture.h>
#include <inspectable.h>
#include <winrt/Windows.Foundation.h>
#include <winrt/Windows.System.h>
#include <iostream>
#include <chrono>
// 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<IDXGIDevice> dxgiDevice;
HRESULT hr = d3dDevice_.As(&dxgiDevice);
if (FAILED(hr)) return nullptr;
winrt::com_ptr<IInspectable> inspectable;
hr = CreateDirect3D11DeviceFromDXGIDevice(dxgiDevice.Get(), inspectable.put());
if (FAILED(hr)) return nullptr;
return inspectable.as<winrt::Windows::Graphics::DirectX::Direct3D11::IDirect3DDevice>();
}
winrt::Windows::Graphics::Capture::GraphicsCaptureItem WgcSession::createCaptureItemForMonitor(HMONITOR monitor) {
auto factory = winrt::get_activation_factory<
winrt::Windows::Graphics::Capture::GraphicsCaptureItem>();
auto interop = factory.as<IGraphicsCaptureItemInterop>();
winrt::Windows::Graphics::Capture::GraphicsCaptureItem item{nullptr};
HRESULT hr = interop->CreateForMonitor(
monitor,
winrt::guid_of<ABI::Windows::Graphics::Capture::IGraphicsCaptureItem>(),
winrt::put_abi(item));
if (FAILED(hr)) {
std::cerr << "ERROR: CreateForMonitor failed: 0x" << std::hex << hr << std::endl;
return nullptr;
}
return item;
}
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);
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;
}
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<std::chrono::duration<int64_t, std::ratio<1, 10000000>>>(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<IDirect3DDxgiInterfaceAccess> access;
try {
access = surface.as<IDirect3DDxgiInterfaceAccess>();
} catch (...) {
frame.Close();
return;
}
ComPtr<ID3D11Texture2D> texture;
HRESULT hr = access->GetInterface(IID_PPV_ARGS(&texture));
if (SUCCEEDED(hr) && texture && frameCallback_) {
frameCallback_(texture.Get(), frameTimeHns);
}
frame.Close();
}
@@ -0,0 +1,59 @@
#pragma once
#include <windows.h>
#include <d3d11.h>
#include <dxgi1_2.h>
#include <wrl/client.h>
#include <winrt/Windows.Foundation.h>
#include <winrt/Windows.Graphics.Capture.h>
#include <winrt/Windows.Graphics.DirectX.h>
#include <winrt/Windows.Graphics.DirectX.Direct3D11.h>
#include <functional>
#include <atomic>
#include <string>
using Microsoft::WRL::ComPtr;
class WgcSession {
public:
using FrameCallback = std::function<void(ID3D11Texture2D*, int64_t timestampHns)>;
WgcSession();
~WgcSession();
bool initialize(HMONITOR monitor, 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<ID3D11Device> d3dDevice_;
ComPtr<ID3D11DeviceContext> 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<bool> 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);
void onFrameArrived(
winrt::Windows::Graphics::Capture::Direct3D11CaptureFramePool const& sender,
winrt::Windows::Foundation::IInspectable const& args);
};
+2
View File
@@ -177,6 +177,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'),
storeWgcAudio: (audioData: ArrayBuffer, type: 'system' | 'mic') => ipcRenderer.invoke('store-wgc-audio', audioData, type),
// Cursor visibility control for cursor-free browser capture fallback
hideOsCursor: () => ipcRenderer.invoke('hide-cursor'),
})
+2 -1
View File
@@ -24,8 +24,9 @@
"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:mac": "npm run build:native-helpers && tsc && vite build && electron-builder --mac",
"build:win": "tsc && vite build && electron-builder --win",
"build:win": "npm run build:wgc-capture && tsc && vite build && electron-builder --win",
"build:linux": "tsc && vite build && electron-builder --linux",
"i18n:check": "node scripts/i18n-check.mjs",
"test": "vitest --run",
+90
View File
@@ -0,0 +1,90 @@
import { execSync } from 'node:child_process';
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 buildDir = path.join(sourceDir, 'build');
if (process.platform !== 'win32') {
console.log('[build-wgc-capture] Skipping: 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);
process.exit(1);
}
function findCmake() {
// Check PATH first
try {
execSync('cmake --version', { stdio: 'pipe' });
return 'cmake';
} catch {
// not on PATH
}
// VS 2022 bundled CMake
const vsEditions = ['Community', 'Professional', 'Enterprise', 'BuildTools'];
for (const edition of vsEditions) {
const cmakePath = path.join(
'C:', 'Program Files', 'Microsoft Visual Studio', '2022', edition,
'Common7', 'IDE', 'CommonExtensions', 'Microsoft', 'CMake', 'CMake', 'bin', 'cmake.exe'
);
if (existsSync(cmakePath)) {
return `"${cmakePath}"`;
}
}
return null;
}
const cmake = findCmake();
if (!cmake) {
console.error('[build-wgc-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...');
try {
execSync(`${cmake} .. -G "Visual Studio 17 2022" -A x64`, {
cwd: buildDir,
stdio: 'inherit',
timeout: 120000,
});
} catch {
console.log('[build-wgc-capture] VS 2022 generator not found, trying VS 2019...');
try {
execSync(`${cmake} .. -G "Visual Studio 16 2019" -A x64`, {
cwd: buildDir,
stdio: 'inherit',
timeout: 120000,
});
} catch (innerError) {
console.error('[build-wgc-capture] CMake configure failed:', innerError.message);
process.exit(1);
}
}
console.log('[build-wgc-capture] Building...');
try {
execSync(`${cmake} --build . --config Release`, {
cwd: buildDir,
stdio: 'inherit',
timeout: 300000,
});
} catch (error) {
console.error('[build-wgc-capture] Build failed:', error.message);
process.exit(1);
}
const exePath = path.join(buildDir, 'Release', 'wgc-capture.exe');
if (existsSync(exePath)) {
console.log(`[build-wgc-capture] Built successfully: ${exePath}`);
} else {
console.error('[build-wgc-capture] Expected exe not found at', exePath);
process.exit(1);
}
+131 -9
View File
@@ -54,6 +54,8 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
const chunks = useRef<Blob[]>([]);
const startTime = useRef<number>(0);
const nativeScreenRecording = useRef(false);
const wgcAudioRecorders = useRef<MediaRecorder[]>([]);
const wgcAudioChunks = useRef<Map<string, Blob[]>>(new Map());
const startInFlight = useRef(false);
const hasPromptedForReselect = useRef(false);
@@ -126,6 +128,95 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
return Math.round(BITRATE_BASE * highFrameRateBoost);
};
const stopWgcAudioCapture = useCallback(async () => {
for (const recorder of wgcAudioRecorders.current) {
if (recorder.state === "recording") {
recorder.stop();
}
}
// Wait briefly for onstop callbacks to fire
await new Promise((resolve) => setTimeout(resolve, 200));
for (const [type, chunks] of wgcAudioChunks.current.entries()) {
if (chunks.length > 0) {
const blob = new Blob(chunks, { type: "audio/webm" });
const buffer = await blob.arrayBuffer();
try {
await window.electronAPI.storeWgcAudio(buffer, type as "system" | "mic");
} catch (error) {
console.warn(`Failed to store WGC ${type} audio:`, error);
}
}
}
wgcAudioRecorders.current = [];
wgcAudioChunks.current = new Map();
}, []);
const startWgcAudioCapture = async (
source: { id?: string; display_id?: string },
captureSystemAudio: boolean,
captureMicrophone: boolean,
micDeviceId?: string,
) => {
wgcAudioRecorders.current = [];
wgcAudioChunks.current = new Map();
if (captureSystemAudio) {
try {
const systemAudioStream = await (navigator.mediaDevices as any).getUserMedia({
audio: {
mandatory: {
chromeMediaSource: CHROME_MEDIA_SOURCE,
chromeMediaSourceId: source.id,
},
},
video: false,
});
const systemChunks: Blob[] = [];
wgcAudioChunks.current.set("system", systemChunks);
const recorder = new MediaRecorder(systemAudioStream, {
mimeType: "audio/webm",
audioBitsPerSecond: AUDIO_BITRATE_SYSTEM,
});
recorder.ondataavailable = (e) => {
if (e.data.size > 0) systemChunks.push(e.data);
};
recorder.start(RECORDER_TIMESLICE_MS);
wgcAudioRecorders.current.push(recorder);
} catch (error) {
console.warn("WGC: System audio capture failed:", error);
}
}
if (captureMicrophone) {
try {
const micStream = await navigator.mediaDevices.getUserMedia({
audio: micDeviceId
? { deviceId: { exact: micDeviceId }, echoCancellation: true, noiseSuppression: true }
: { echoCancellation: true, noiseSuppression: true },
video: false,
});
const micChunks: Blob[] = [];
wgcAudioChunks.current.set("mic", micChunks);
const recorder = new MediaRecorder(micStream, {
mimeType: "audio/webm",
audioBitsPerSecond: AUDIO_BITRATE_VOICE,
});
recorder.ondataavailable = (e) => {
if (e.data.size > 0) micChunks.push(e.data);
};
recorder.start(RECORDER_TIMESLICE_MS);
wgcAudioRecorders.current.push(recorder);
} catch (error) {
console.warn("WGC: Microphone capture failed:", error);
}
}
};
const cleanupCapturedMedia = useCallback(() => {
if (stream.current) {
stream.current.getTracks().forEach((track) => track.stop());
@@ -154,6 +245,11 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
setRecording(false);
void (async () => {
// Stop WGC audio recorders first so audio data is stored before stop
if (wgcAudioRecorders.current.length > 0) {
await stopWgcAudioCapture();
}
const result = await window.electronAPI.stopNativeScreenRecording();
window.electronAPI?.setRecordingState(false);
@@ -256,23 +352,49 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
(selectedSource.id?.startsWith("screen:") || selectedSource.id?.startsWith("window:")) &&
typeof window.electronAPI.startNativeScreenRecording === "function";
if (useNativeMacScreenCapture) {
let useWgcCapture = false;
if (
platform === "win32" &&
selectedSource.id?.startsWith("screen:") &&
typeof window.electronAPI.isWgcAvailable === "function"
) {
try {
const wgcResult = await window.electronAPI.isWgcAvailable();
useWgcCapture = wgcResult.available;
} catch {
useWgcCapture = false;
}
}
if (useNativeMacScreenCapture || useWgcCapture) {
const nativeResult = await window.electronAPI.startNativeScreenRecording(selectedSource, {
capturesSystemAudio: systemAudioEnabled,
capturesMicrophone: microphoneEnabled,
microphoneDeviceId,
});
if (!nativeResult.success) {
throw new Error(
nativeResult.error ?? nativeResult.message ?? "Failed to start native screen recording",
);
if (useWgcCapture) {
console.warn("WGC capture failed, falling back to browser capture:", nativeResult.error ?? nativeResult.message);
} else {
throw new Error(
nativeResult.error ?? nativeResult.message ?? "Failed to start native screen recording",
);
}
}
nativeScreenRecording.current = true;
startTime.current = Date.now();
setRecording(true);
window.electronAPI?.setRecordingState(true);
return;
if (nativeResult.success) {
nativeScreenRecording.current = true;
startTime.current = Date.now();
setRecording(true);
window.electronAPI?.setRecordingState(true);
// WGC: start audio-only MediaRecorders in parallel
if (useWgcCapture && (systemAudioEnabled || microphoneEnabled)) {
void startWgcAudioCapture(selectedSource, systemAudioEnabled, microphoneEnabled, microphoneDeviceId);
}
return;
}
}
const wantsAudioCapture = microphoneEnabled || systemAudioEnabled;