diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 9a8060b9..9f8288bd 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -848,7 +848,7 @@ async function buildFfmpegCaptureArgs(source: SelectedSource, outputPath: string } function getWindowsCaptureExePath() { - return resolveUnpackedAppPath('electron', 'native', 'windows-capture', 'build', 'Release', 'windows-capture.exe') + return resolveUnpackedAppPath('electron', 'native', 'wgc-capture', 'build', 'Release', 'wgc-capture.exe') } function getCursorMonitorExePath() { @@ -865,8 +865,8 @@ async function isNativeWindowsCaptureAvailable(): Promise { } const os = await import('node:os') - const [major, minor] = os.release().split('.').map(Number) - return major > 6 || (major === 6 && minor >= 2) + const [major, , build] = os.release().split('.').map(Number) + return major >= 10 && build >= 19041 } function waitForWindowsCaptureStart(proc: ChildProcessWithoutNullStreams) { diff --git a/electron/native/wgc-capture/CMakeLists.txt b/electron/native/wgc-capture/CMakeLists.txt new file mode 100644 index 00000000..6b6c07ae --- /dev/null +++ b/electron/native/wgc-capture/CMakeLists.txt @@ -0,0 +1,27 @@ +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 + src/wasapi_loopback.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 +) diff --git a/electron/native/wgc-capture/src/main.cpp b/electron/native/wgc-capture/src/main.cpp new file mode 100644 index 00000000..8dd4f930 --- /dev/null +++ b/electron/native/wgc-capture/src/main.cpp @@ -0,0 +1,329 @@ +#include "wgc_session.h" +#include "mf_encoder.h" +#include "monitor_utils.h" +#include "wasapi_loopback.h" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +static std::atomic g_stopRequested{false}; +static std::atomic g_pauseRequested{false}; +static std::atomic g_resumePending{false}; +static std::atomic g_lastFrameTimestampHns{0}; +static std::atomic g_pauseStartTimestampHns{0}; +static std::atomic g_accumulatedPausedHns{0}; +static std::mutex g_stopMutex; +static std::condition_variable g_stopCv; + +struct CaptureConfig { + int displayId = 0; + int64_t windowHandle = 0; + std::string outputPath; + std::string audioOutputPath; + std::string micOutputPath; + std::string micDeviceName; + int fps = 60; + int width = 0; + int height = 0; + bool captureSystemAudio = false; + bool captureMic = false; +}; + +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 findInt64 = [&](const std::string& key) -> int64_t { + 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::stoll(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; + + int64_t windowHandle = findInt64("windowHandle"); + if (windowHandle > 0) config.windowHandle = windowHandle; + + 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; + + config.audioOutputPath = findString("audioOutputPath"); + config.micOutputPath = findString("micOutputPath"); + config.micDeviceName = findString("micDeviceName"); + + auto findBool = [&](const std::string& key) -> bool { + auto pos = json.find("\"" + key + "\""); + if (pos == std::string::npos) return false; + auto colonPos = json.find(':', pos); + if (colonPos == std::string::npos) return false; + auto valStart = json.find_first_not_of(" \t", colonPos + 1); + return valStart != std::string::npos && json.substr(valStart, 4) == "true"; + }; + + config.captureSystemAudio = findBool("captureSystemAudio"); + config.captureMic = findBool("captureMic"); + + 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(str.size()), nullptr, 0); + std::wstring wstr(len, L'\0'); + MultiByteToWideChar(CP_UTF8, 0, str.c_str(), static_cast(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 == "pause") { + g_pauseRequested = true; + g_pauseStartTimestampHns = g_lastFrameTimestampHns.load(); + continue; + } + + if (line == "resume") { + g_pauseRequested = false; + g_resumePending = true; + continue; + } + + 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; + } + + WgcSession session; + + if (config.windowHandle > 0) { + HWND hwnd = reinterpret_cast(static_cast(config.windowHandle)); + if (!IsWindow(hwnd)) { + std::cerr << "ERROR: Invalid window handle " << config.windowHandle << std::endl; + return 1; + } + if (!session.initialize(hwnd, config.fps)) { + std::cerr << "ERROR: Failed to initialize WGC window capture session" << std::endl; + return 1; + } + } else { + HMONITOR monitor = findMonitorByDisplayId(config.displayId); + if (!monitor) { + std::cerr << "ERROR: Could not find monitor for displayId " << config.displayId << std::endl; + return 1; + } + 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 frameCount{0}; + session.setFrameCallback([&](ID3D11Texture2D* texture, int64_t timestampHns) { + g_lastFrameTimestampHns = timestampHns; + if (g_stopRequested) return; + + if (g_pauseRequested) return; + + int64_t adjustedTimestampHns = timestampHns; + if (g_resumePending.exchange(false)) { + const int64_t pauseStart = g_pauseStartTimestampHns.load(); + if (pauseStart > 0 && timestampHns > pauseStart) { + g_accumulatedPausedHns += (timestampHns - pauseStart); + } + } + + adjustedTimestampHns -= g_accumulatedPausedHns.load(); + if (adjustedTimestampHns < 0) { + adjustedTimestampHns = 0; + } + + if (encoder.writeFrame(texture, adjustedTimestampHns)) { + frameCount++; + } + }); + + // Start stdin listener + std::thread stdinThread(stdinListenerThread); + stdinThread.detach(); + + // Initialize WASAPI captures (but don't start yet) + WasapiCapture loopback; + WasapiCapture micCapture; + bool audioActive = false; + bool audioInitialized = false; + bool micActive = false; + bool micInitialized = false; + + if (config.captureSystemAudio && !config.audioOutputPath.empty()) { + audioInitialized = loopback.initializeLoopback(config.audioOutputPath); + if (!audioInitialized) { + std::cerr << "WARNING: Failed to initialize WASAPI loopback" << std::endl; + } + } + + if (config.captureMic && !config.micOutputPath.empty()) { + micInitialized = micCapture.initializeMic(config.micOutputPath, config.micDeviceName); + if (!micInitialized) { + std::cerr << "WARNING: Failed to initialize WASAPI mic capture" << std::endl; + } + } + + // Start video capture, then audio immediately after for sync + if (!session.startCapture()) { + std::cerr << "ERROR: Failed to start WGC capture" << std::endl; + return 1; + } + + if (audioInitialized) { + audioActive = loopback.start(); + } + if (micInitialized) { + micActive = micCapture.start(); + } + + std::cout << "Recording started" << std::endl; + std::cout.flush(); + + // Wait for stop signal while pausing/resuming audio tracks in lockstep. + while (!g_stopRequested) { + if (g_pauseRequested) { + if (audioActive) loopback.pause(); + if (micActive) micCapture.pause(); + } else { + if (audioActive) loopback.resume(); + if (micActive) micCapture.resume(); + } + + std::unique_lock lock(g_stopMutex); + g_stopCv.wait_for(lock, std::chrono::milliseconds(20), [] { return g_stopRequested.load(); }); + } + + // Stop capture and finalize + session.stopCapture(); + if (audioActive) loopback.stop(); + if (micActive) micCapture.stop(); + encoder.finalize(); + + std::cout << "Recording stopped. Output path: " << config.outputPath << std::endl; + if (audioActive) { + std::cout << "Audio path: " << config.audioOutputPath << std::endl; + } + if (micActive) { + std::cout << "Mic path: " << config.micOutputPath << std::endl; + } + std::cout.flush(); + + // Allow pipe buffers to drain before forceful exit + Sleep(100); + + // Fast exit to avoid WinRT/COM teardown crashes during apartment cleanup + ExitProcess(0); +} diff --git a/electron/native/wgc-capture/src/mf_encoder.cpp b/electron/native/wgc-capture/src/mf_encoder.cpp new file mode 100644 index 00000000..a1474c20 --- /dev/null +++ b/electron/native/wgc-capture/src/mf_encoder.cpp @@ -0,0 +1,205 @@ +#include "mf_encoder.h" +#include +#include +#include +#include +#include + +#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; + + if (width % 2 != 0 || height % 2 != 0) { + std::cerr << "ERROR: Encoder dimensions must be even, got " << width << "x" << height << std::endl; + 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 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 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 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(mapped.pData); + const int bgraPitch = static_cast(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(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(clampByte(u)); + uvPlane[uvIdx + 1] = static_cast(clampByte(v)); + } + } + + context_->Unmap(stagingTexture_.Get(), 0); + + // Create MF sample + DWORD bufferSize = static_cast(nv12Buffer_.size()); + ComPtr 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 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); +} diff --git a/electron/native/wgc-capture/src/mf_encoder.h b/electron/native/wgc-capture/src/mf_encoder.h new file mode 100644 index 00000000..7eb8e642 --- /dev/null +++ b/electron/native/wgc-capture/src/mf_encoder.h @@ -0,0 +1,35 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +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 sinkWriter_; + ID3D11Device* device_ = nullptr; + ID3D11DeviceContext* context_ = nullptr; + ComPtr stagingTexture_; + std::vector nv12Buffer_; + DWORD streamIndex_ = 0; + int width_ = 0; + int height_ = 0; + int fps_ = 60; + bool initialized_ = false; +}; diff --git a/electron/native/wgc-capture/src/monitor_utils.cpp b/electron/native/wgc-capture/src/monitor_utils.cpp new file mode 100644 index 00000000..25203ebf --- /dev/null +++ b/electron/native/wgc-capture/src/monitor_utils.cpp @@ -0,0 +1,61 @@ +#include "monitor_utils.h" +#include + +static BOOL CALLBACK enumMonitorCallback(HMONITOR hMonitor, HDC, LPRECT, LPARAM lParam) { + auto* monitors = reinterpret_cast*>(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 enumerateMonitors() { + std::vector monitors; + EnumDisplayMonitors(nullptr, nullptr, enumMonitorCallback, reinterpret_cast(&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(reinterpret_cast(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; +} diff --git a/electron/native/wgc-capture/src/monitor_utils.h b/electron/native/wgc-capture/src/monitor_utils.h new file mode 100644 index 00000000..513e3105 --- /dev/null +++ b/electron/native/wgc-capture/src/monitor_utils.h @@ -0,0 +1,18 @@ +#pragma once + +#include +#include +#include + +struct MonitorInfo { + HMONITOR handle; + int x; + int y; + int width; + int height; + std::wstring deviceName; +}; + +std::vector enumerateMonitors(); +HMONITOR findMonitorByDisplayId(int displayId); +MonitorInfo getMonitorInfo(HMONITOR monitor); diff --git a/electron/native/wgc-capture/src/wasapi_loopback.cpp b/electron/native/wgc-capture/src/wasapi_loopback.cpp new file mode 100644 index 00000000..af9b5bc3 --- /dev/null +++ b/electron/native/wgc-capture/src/wasapi_loopback.cpp @@ -0,0 +1,242 @@ +#include "wasapi_loopback.h" +#include +#include +#include + +#pragma comment(lib, "ole32.lib") + +static const CLSID CLSID_MMDeviceEnumerator_ = __uuidof(MMDeviceEnumerator); +static const IID IID_IMMDeviceEnumerator_ = __uuidof(IMMDeviceEnumerator); +static const IID IID_IAudioClient_ = __uuidof(IAudioClient); +static const IID IID_IAudioCaptureClient_ = __uuidof(IAudioCaptureClient); + +WasapiCapture::WasapiCapture() {} + +WasapiCapture::~WasapiCapture() { + stop(); + if (mixFormat_) CoTaskMemFree(mixFormat_); + if (captureClient_) captureClient_->Release(); + if (audioClient_) audioClient_->Release(); + if (device_) device_->Release(); + if (enumerator_) enumerator_->Release(); +} + +static std::wstring utf8ToWide(const std::string& str) { + if (str.empty()) return L""; + int len = MultiByteToWideChar(CP_UTF8, 0, str.c_str(), static_cast(str.size()), nullptr, 0); + std::wstring wstr(len, L'\0'); + MultiByteToWideChar(CP_UTF8, 0, str.c_str(), static_cast(str.size()), &wstr[0], len); + return wstr; +} + +IMMDevice* WasapiCapture::findCaptureDeviceByName(const std::wstring& targetName) { + IMMDeviceCollection* collection = nullptr; + HRESULT hr = enumerator_->EnumAudioEndpoints(eCapture, DEVICE_STATE_ACTIVE, &collection); + if (FAILED(hr)) return nullptr; + + UINT count = 0; + collection->GetCount(&count); + + for (UINT i = 0; i < count; i++) { + IMMDevice* dev = nullptr; + collection->Item(i, &dev); + + IPropertyStore* store = nullptr; + dev->OpenPropertyStore(STGM_READ, &store); + PROPVARIANT pv; + PropVariantInit(&pv); + store->GetValue(PKEY_Device_FriendlyName, &pv); + std::wstring name = pv.pwszVal ? pv.pwszVal : L""; + PropVariantClear(&pv); + store->Release(); + + if (name.find(targetName) != std::wstring::npos || targetName.find(name) != std::wstring::npos) { + collection->Release(); + return dev; + } + dev->Release(); + } + + collection->Release(); + return nullptr; +} + +bool WasapiCapture::initializeLoopback(const std::string& outputPath) { + outputPath_ = outputPath; + streamFlags_ = AUDCLNT_STREAMFLAGS_LOOPBACK; + + HRESULT hr = CoCreateInstance( + CLSID_MMDeviceEnumerator_, nullptr, CLSCTX_ALL, + IID_IMMDeviceEnumerator_, reinterpret_cast(&enumerator_)); + if (FAILED(hr)) return false; + + hr = enumerator_->GetDefaultAudioEndpoint(eRender, eConsole, &device_); + if (FAILED(hr)) return false; + + return initializeCommon(); +} + +bool WasapiCapture::initializeMic(const std::string& outputPath, const std::string& deviceName) { + outputPath_ = outputPath; + streamFlags_ = 0; + + HRESULT hr = CoCreateInstance( + CLSID_MMDeviceEnumerator_, nullptr, CLSCTX_ALL, + IID_IMMDeviceEnumerator_, reinterpret_cast(&enumerator_)); + if (FAILED(hr)) return false; + + if (!deviceName.empty()) { + device_ = findCaptureDeviceByName(utf8ToWide(deviceName)); + } + if (!device_) { + hr = enumerator_->GetDefaultAudioEndpoint(eCapture, eCommunications, &device_); + if (FAILED(hr)) { + hr = enumerator_->GetDefaultAudioEndpoint(eCapture, eConsole, &device_); + if (FAILED(hr)) return false; + } + } + + return initializeCommon(); +} + +bool WasapiCapture::initializeCommon() { + HRESULT hr = device_->Activate(IID_IAudioClient_, CLSCTX_ALL, nullptr, + reinterpret_cast(&audioClient_)); + if (FAILED(hr)) return false; + + hr = audioClient_->GetMixFormat(&mixFormat_); + if (FAILED(hr)) return false; + + REFERENCE_TIME bufferDuration = 200000; // 20ms + hr = audioClient_->Initialize( + AUDCLNT_SHAREMODE_SHARED, + streamFlags_, + bufferDuration, 0, mixFormat_, nullptr); + if (FAILED(hr)) return false; + + hr = audioClient_->GetBufferSize(&bufferFrameCount_); + if (FAILED(hr)) return false; + + hr = audioClient_->GetService(IID_IAudioCaptureClient_, + reinterpret_cast(&captureClient_)); + if (FAILED(hr)) return false; + + return true; +} + +bool WasapiCapture::start() { + HRESULT hr = audioClient_->Start(); + if (FAILED(hr)) return false; + + capturing_ = true; + thread_ = std::thread(&WasapiCapture::captureThread, this); + return true; +} + +void WasapiCapture::stop() { + if (!capturing_) return; + capturing_ = false; + if (thread_.joinable()) thread_.join(); + if (audioClient_) audioClient_->Stop(); +} + +static int16_t floatToInt16(float v) { + v = v < -1.0f ? -1.0f : (v > 1.0f ? 1.0f : v); + return static_cast(v * 32767.0f); +} + +bool WasapiCapture::writeWavHeader(HANDLE file, DWORD dataSize) { + WORD channels = static_cast(mixFormat_->nChannels); + DWORD sampleRate = mixFormat_->nSamplesPerSec; + WORD bitsPerSample = 16; + WORD blockAlign = channels * (bitsPerSample / 8); + DWORD byteRate = sampleRate * blockAlign; + + DWORD written; + WriteFile(file, "RIFF", 4, &written, nullptr); + DWORD chunkSize = 36 + dataSize; + WriteFile(file, &chunkSize, 4, &written, nullptr); + WriteFile(file, "WAVE", 4, &written, nullptr); + WriteFile(file, "fmt ", 4, &written, nullptr); + DWORD fmtSize = 16; + WriteFile(file, &fmtSize, 4, &written, nullptr); + WORD audioFormat = 1; + WriteFile(file, &audioFormat, 2, &written, nullptr); + WriteFile(file, &channels, 2, &written, nullptr); + WriteFile(file, &sampleRate, 4, &written, nullptr); + WriteFile(file, &byteRate, 4, &written, nullptr); + WriteFile(file, &blockAlign, 2, &written, nullptr); + WriteFile(file, &bitsPerSample, 2, &written, nullptr); + WriteFile(file, "data", 4, &written, nullptr); + WriteFile(file, &dataSize, 4, &written, nullptr); + return true; +} + +void WasapiCapture::captureThread() { + WORD channels = static_cast(mixFormat_->nChannels); + bool isFloat = (mixFormat_->wFormatTag == WAVE_FORMAT_IEEE_FLOAT) || + (mixFormat_->wFormatTag == WAVE_FORMAT_EXTENSIBLE && + reinterpret_cast(mixFormat_)->SubFormat == KSDATAFORMAT_SUBTYPE_IEEE_FLOAT); + + std::vector pcmBuffer; + HANDLE tmpFile = CreateFileA( + outputPath_.c_str(), GENERIC_WRITE, 0, nullptr, + CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr); + + if (tmpFile == INVALID_HANDLE_VALUE) { + std::cerr << "ERROR: Cannot create audio output file" << std::endl; + return; + } + + writeWavHeader(tmpFile, 0); + DWORD totalDataBytes = 0; + + DWORD sleepMs = static_cast((static_cast(bufferFrameCount_) / mixFormat_->nSamplesPerSec) * 500.0); + if (sleepMs < 5) sleepMs = 5; + + while (capturing_) { + Sleep(sleepMs); + + UINT32 packetLength = 0; + HRESULT hr = captureClient_->GetNextPacketSize(&packetLength); + if (FAILED(hr)) break; + + while (packetLength > 0) { + BYTE* data = nullptr; + UINT32 numFrames = 0; + DWORD flags = 0; + + hr = captureClient_->GetBuffer(&data, &numFrames, &flags, nullptr, nullptr); + if (FAILED(hr)) break; + + UINT32 totalSamples = numFrames * channels; + + if (flags & AUDCLNT_BUFFERFLAGS_SILENT) { + pcmBuffer.assign(totalSamples, 0); + } else if (isFloat) { + pcmBuffer.resize(totalSamples); + const float* src = reinterpret_cast(data); + for (UINT32 i = 0; i < totalSamples; i++) { + pcmBuffer[i] = floatToInt16(src[i]); + } + } else { + pcmBuffer.resize(totalSamples); + std::memcpy(pcmBuffer.data(), data, totalSamples * sizeof(int16_t)); + } + + captureClient_->ReleaseBuffer(numFrames); + + DWORD bytesToWrite = totalSamples * sizeof(int16_t); + DWORD written; + WriteFile(tmpFile, pcmBuffer.data(), bytesToWrite, &written, nullptr); + totalDataBytes += written; + + hr = captureClient_->GetNextPacketSize(&packetLength); + if (FAILED(hr)) break; + } + } + + SetFilePointer(tmpFile, 0, nullptr, FILE_BEGIN); + writeWavHeader(tmpFile, totalDataBytes); + CloseHandle(tmpFile); +} diff --git a/electron/native/wgc-capture/src/wasapi_loopback.h b/electron/native/wgc-capture/src/wasapi_loopback.h new file mode 100644 index 00000000..3b2b76df --- /dev/null +++ b/electron/native/wgc-capture/src/wasapi_loopback.h @@ -0,0 +1,39 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +class WasapiCapture { +public: + WasapiCapture(); + ~WasapiCapture(); + + bool initializeLoopback(const std::string& outputPath); + bool initializeMic(const std::string& outputPath, const std::string& deviceName = ""); + bool start(); + void stop(); + +private: + bool initializeCommon(); + void captureThread(); + bool writeWavHeader(HANDLE file, DWORD dataSize); + IMMDevice* findCaptureDeviceByName(const std::wstring& name); + + std::string outputPath_; + std::thread thread_; + std::atomic capturing_{false}; + + IMMDeviceEnumerator* enumerator_ = nullptr; + IMMDevice* device_ = nullptr; + IAudioClient* audioClient_ = nullptr; + IAudioCaptureClient* captureClient_ = nullptr; + WAVEFORMATEX* mixFormat_ = nullptr; + DWORD streamFlags_ = 0; + + UINT32 bufferFrameCount_ = 0; +}; diff --git a/electron/native/wgc-capture/src/wgc_session.cpp b/electron/native/wgc-capture/src/wgc_session.cpp new file mode 100644 index 00000000..7e59d140 --- /dev/null +++ b/electron/native/wgc-capture/src/wgc_session.cpp @@ -0,0 +1,236 @@ +#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 new file mode 100644 index 00000000..5ca6be34 --- /dev/null +++ b/electron/native/wgc-capture/src/wgc_session.h @@ -0,0 +1,62 @@ +#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/scripts/build-windows-capture.mjs b/scripts/build-windows-capture.mjs index bfdfa4dd..8af1ac9a 100644 --- a/scripts/build-windows-capture.mjs +++ b/scripts/build-windows-capture.mjs @@ -3,7 +3,7 @@ import { mkdirSync, existsSync } from 'node:fs'; import path from 'node:path'; const projectRoot = process.cwd(); -const sourceDir = path.join(projectRoot, 'electron', 'native', 'windows-capture'); +const sourceDir = path.join(projectRoot, 'electron', 'native', 'wgc-capture'); const buildDir = path.join(sourceDir, 'build'); if (process.platform !== 'win32') { @@ -81,7 +81,7 @@ try { process.exit(1); } -const exePath = path.join(buildDir, 'Release', 'windows-capture.exe'); +const exePath = path.join(buildDir, 'Release', 'wgc-capture.exe'); if (existsSync(exePath)) { console.log(`[build-windows-capture] Built successfully: ${exePath}`); } else {