feat: add native Windows recorder helper

This commit is contained in:
EtienneLescot
2026-05-05 16:07:07 +02:00
parent d21e5eb34c
commit 062cf2a87c
27 changed files with 2873 additions and 139 deletions
+50
View File
@@ -0,0 +1,50 @@
# Native capture helpers
Windows native recording is resolved from one of these locations:
1. `OPENSCREEN_WGC_CAPTURE_EXE`, for local development and diagnostics.
2. `electron/native/wgc-capture/build/wgc-capture.exe`, for a locally built Ninja helper.
3. `electron/native/wgc-capture/build/Release/wgc-capture.exe`, for a locally built multi-config helper.
4. `electron/native/bin/win32-x64/wgc-capture.exe` or `electron/native/bin/win32-arm64/wgc-capture.exe`, for packaged prebuilt helpers.
Build the Windows helper with:
```powershell
npm run build:native:win
```
The build writes the CMake output to `electron/native/wgc-capture/build/wgc-capture.exe` and copies the redistributable binary to `electron/native/bin/win32-x64/wgc-capture.exe`.
The helper contract is process-based: the app starts the process with one JSON argument and sends commands on stdin. `stop\n` finalizes the recording. During migration the helper prints both newline-delimited JSON events and the legacy text messages `Recording started` / `Recording stopped. Output path: <path>`.
Current V2 JSON shape:
```json
{
"schemaVersion": 2,
"recordingId": 123,
"sourceType": "display",
"sourceId": "screen:0:0",
"displayId": 1,
"outputPath": "C:\\path\\recording-123.mp4",
"videoWidth": 1920,
"videoHeight": 1080,
"fps": 60,
"captureSystemAudio": false,
"captureMic": false,
"webcamEnabled": false,
"outputs": {
"screenPath": "C:\\path\\recording-123.mp4",
"webcamPath": "C:\\path\\recording-123-webcam.mp4"
}
}
```
The current helper implementation supports display video capture and system audio loopback. Microphone, webcam, and window capture now fail explicitly in the helper rather than silently falling back to Electron capture on Windows. See `docs/engineering/windows-native-recorder-roadmap.md` for the phased implementation plan.
Smoke-test the helper with:
```powershell
npm run test:wgc-helper:win
npm run test:wgc-audio:win
```
@@ -0,0 +1,45 @@
cmake_minimum_required(VERSION 3.20)
# The local Windows SDK image used by some contributors can miss gdi32.lib,
# while CMake's default MSVC console template links it unconditionally. This
# helper does not use GDI, so keep the standard library set minimal and explicit.
set(CMAKE_CXX_STANDARD_LIBRARIES
"kernel32.lib user32.lib shell32.lib ole32.lib oleaut32.lib uuid.lib comdlg32.lib advapi32.lib"
CACHE STRING "" FORCE)
project(openscreen-wgc-capture LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
add_executable(wgc-capture
src/main.cpp
src/mf_encoder.cpp
src/mf_encoder.h
src/monitor_utils.cpp
src/monitor_utils.h
src/wasapi_loopback_capture.cpp
src/wasapi_loopback_capture.h
src/wgc_session.cpp
src/wgc_session.h
)
target_compile_definitions(wgc-capture PRIVATE
NOMINMAX
WIN32_LEAN_AND_MEAN
_WIN32_WINNT=0x0A00
)
target_compile_options(wgc-capture PRIVATE /EHsc /W4 /utf-8)
target_link_libraries(wgc-capture PRIVATE
d3d11
dxgi
mf
mfplat
mfreadwrite
mfuuid
runtimeobject
windowsapp
)
+433
View File
@@ -0,0 +1,433 @@
#include "mf_encoder.h"
#include "monitor_utils.h"
#include "wasapi_loopback_capture.h"
#include "wgc_session.h"
#include <winrt/Windows.Foundation.h>
#include <algorithm>
#include <atomic>
#include <chrono>
#include <condition_variable>
#include <cctype>
#include <cstdint>
#include <iostream>
#include <mutex>
#include <string>
#include <thread>
namespace {
struct CaptureConfig {
int schemaVersion = 1;
int64_t displayId = 0;
int64_t recordingId = 0;
std::string sourceType = "display";
std::string sourceId;
std::string windowHandle;
std::string outputPath;
int fps = 60;
int width = 0;
int height = 0;
MonitorBounds bounds{};
bool hasDisplayBounds = false;
bool captureSystemAudio = false;
bool captureMic = false;
bool webcamEnabled = false;
std::string microphoneDeviceId;
double microphoneGain = 1.0;
std::string webcamDeviceId;
int webcamWidth = 0;
int webcamHeight = 0;
int webcamFps = 0;
};
std::wstring utf8ToWide(const std::string& value) {
if (value.empty()) {
return {};
}
const int size = MultiByteToWideChar(CP_UTF8, 0, value.data(), static_cast<int>(value.size()), nullptr, 0);
std::wstring result(static_cast<size_t>(size), L'\0');
MultiByteToWideChar(CP_UTF8, 0, value.data(), static_cast<int>(value.size()), result.data(), size);
return result;
}
std::string jsonEscape(const std::string& value) {
std::string result;
result.reserve(value.size());
for (const char c : value) {
switch (c) {
case '\\':
result += "\\\\";
break;
case '"':
result += "\\\"";
break;
case '\n':
result += "\\n";
break;
case '\r':
result += "\\r";
break;
case '\t':
result += "\\t";
break;
default:
result.push_back(c);
break;
}
}
return result;
}
bool findBool(const std::string& json, const std::string& key, bool fallback) {
auto pos = json.find("\"" + key + "\"");
if (pos == std::string::npos) {
return fallback;
}
pos = json.find(':', pos);
if (pos == std::string::npos) {
return fallback;
}
pos += 1;
while (pos < json.size() && std::isspace(static_cast<unsigned char>(json[pos]))) {
pos += 1;
}
if (json.compare(pos, 4, "true") == 0) {
return true;
}
if (json.compare(pos, 5, "false") == 0) {
return false;
}
return fallback;
}
int64_t findInt64(const std::string& json, const std::string& key, int64_t fallback) {
auto pos = json.find("\"" + key + "\"");
if (pos == std::string::npos) {
return fallback;
}
pos = json.find(':', pos);
if (pos == std::string::npos) {
return fallback;
}
pos += 1;
while (pos < json.size() && std::isspace(static_cast<unsigned char>(json[pos]))) {
pos += 1;
}
try {
return std::stoll(json.substr(pos));
} catch (...) {
return fallback;
}
}
int findInt(const std::string& json, const std::string& key, int fallback) {
return static_cast<int>(findInt64(json, key, fallback));
}
double findDouble(const std::string& json, const std::string& key, double fallback) {
auto pos = json.find("\"" + key + "\"");
if (pos == std::string::npos) {
return fallback;
}
pos = json.find(':', pos);
if (pos == std::string::npos) {
return fallback;
}
pos += 1;
while (pos < json.size() && std::isspace(static_cast<unsigned char>(json[pos]))) {
pos += 1;
}
try {
return std::stod(json.substr(pos));
} catch (...) {
return fallback;
}
}
std::string findString(const std::string& json, const std::string& key) {
auto pos = json.find("\"" + key + "\"");
if (pos == std::string::npos) {
return {};
}
pos = json.find(':', pos);
if (pos == std::string::npos) {
return {};
}
pos += 1;
while (pos < json.size() && std::isspace(static_cast<unsigned char>(json[pos]))) {
pos += 1;
}
if (pos >= json.size() || json[pos] != '"') {
return {};
}
pos += 1;
std::string result;
while (pos < json.size()) {
const char c = json[pos++];
if (c == '"') {
break;
}
if (c == '\\' && pos < json.size()) {
const char escaped = json[pos++];
switch (escaped) {
case '\\':
case '"':
case '/':
result.push_back(escaped);
break;
case 'n':
result.push_back('\n');
break;
case 'r':
result.push_back('\r');
break;
case 't':
result.push_back('\t');
break;
default:
result.push_back(escaped);
break;
}
continue;
}
result.push_back(c);
}
return result;
}
bool parseConfig(const std::string& json, CaptureConfig& config) {
config.schemaVersion = findInt(json, "schemaVersion", 1);
config.outputPath = findString(json, "screenPath");
if (config.outputPath.empty()) {
config.outputPath = findString(json, "outputPath");
}
if (config.outputPath.empty()) {
return false;
}
config.recordingId = findInt64(json, "recordingId", 0);
config.sourceType = findString(json, "sourceType");
if (config.sourceType.empty()) {
config.sourceType = "display";
}
config.sourceId = findString(json, "sourceId");
config.windowHandle = findString(json, "windowHandle");
config.displayId = findInt64(json, "displayId", 0);
config.fps = std::clamp(findInt(json, "fps", 60), 1, 120);
config.width = findInt(json, "videoWidth", findInt(json, "width", 0));
config.height = findInt(json, "videoHeight", findInt(json, "height", 0));
config.bounds.x = findInt(json, "displayX", 0);
config.bounds.y = findInt(json, "displayY", 0);
config.bounds.width = findInt(json, "displayW", 0);
config.bounds.height = findInt(json, "displayH", 0);
config.hasDisplayBounds = findBool(json, "hasDisplayBounds", false);
config.captureSystemAudio = findBool(json, "captureSystemAudio", false);
config.captureMic = findBool(json, "captureMic", false);
config.webcamEnabled = findBool(json, "webcamEnabled", false);
config.microphoneDeviceId = findString(json, "microphoneDeviceId");
config.microphoneGain = findDouble(json, "microphoneGain", 1.0);
config.webcamDeviceId = findString(json, "webcamDeviceId");
config.webcamWidth = findInt(json, "webcamWidth", 0);
config.webcamHeight = findInt(json, "webcamHeight", 0);
config.webcamFps = findInt(json, "webcamFps", 0);
return true;
}
void readStopCommands(std::atomic<bool>& stopRequested, std::condition_variable& cv) {
std::string line;
while (std::getline(std::cin, line)) {
if (line == "stop" || line == "q" || line == "quit") {
stopRequested = true;
cv.notify_all();
return;
}
}
stopRequested = true;
cv.notify_all();
}
} // namespace
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 (!parseConfig(argv[1], config)) {
std::cerr << "ERROR: Failed to parse config JSON" << std::endl;
return 1;
}
std::cout << "{\"event\":\"ready\",\"schemaVersion\":2}" << std::endl;
if (config.sourceType != "display") {
std::cerr << "ERROR: Native window capture is not implemented yet" << std::endl;
return 1;
}
if (config.captureMic) {
std::cerr << "ERROR: Microphone capture is not implemented in this helper yet" << std::endl;
return 1;
}
if (config.webcamEnabled) {
std::cerr << "ERROR: Native webcam capture is not implemented in this helper yet" << std::endl;
return 1;
}
HMONITOR monitor = findMonitorForCapture(
config.displayId,
config.hasDisplayBounds ? &config.bounds : nullptr);
if (!monitor) {
std::cerr << "ERROR: Could not resolve monitor" << std::endl;
return 1;
}
WgcSession session;
if (!session.initialize(monitor, config.fps)) {
std::cerr << "ERROR: Failed to initialize WGC session" << std::endl;
return 1;
}
// WGC owns the captured texture size. Encoding must use that exact size
// until a dedicated GPU scaling pass is introduced; CopyResource requires
// matching resource dimensions.
int width = session.captureWidth();
int height = session.captureHeight();
width = (std::max(2, width) / 2) * 2;
height = (std::max(2, height) / 2) * 2;
const int pixels = width * height;
const int bitrate = pixels >= 3840 * 2160 ? 45'000'000 : pixels >= 2560 * 1440 ? 28'000'000 : 18'000'000;
WasapiLoopbackCapture loopbackCapture;
const AudioInputFormat* audioFormat = nullptr;
if (config.captureSystemAudio) {
if (!loopbackCapture.initialize()) {
std::cerr << "ERROR: Failed to initialize WASAPI loopback capture" << std::endl;
return 1;
}
audioFormat = &loopbackCapture.inputFormat();
std::cout << "{\"event\":\"audio-format\",\"schemaVersion\":2,\"sampleRate\":"
<< audioFormat->sampleRate << ",\"channels\":" << audioFormat->channels
<< ",\"bitsPerSample\":" << audioFormat->bitsPerSample << "}" << std::endl;
}
MFEncoder encoder;
if (!encoder.initialize(
utf8ToWide(config.outputPath),
width,
height,
config.fps,
bitrate,
session.device(),
session.context(),
audioFormat)) {
std::cerr << "ERROR: Failed to initialize Media Foundation encoder" << std::endl;
return 1;
}
std::mutex mutex;
std::condition_variable cv;
std::atomic<bool> stopRequested = false;
std::atomic<bool> firstFrameWritten = false;
std::atomic<bool> encodeFailed = false;
session.setFrameCallback([&](ID3D11Texture2D* texture, int64_t timestampHns) {
if (stopRequested) {
return;
}
std::scoped_lock lock(mutex);
if (!encoder.writeFrame(texture, timestampHns)) {
encodeFailed = true;
stopRequested = true;
cv.notify_all();
return;
}
if (!firstFrameWritten.exchange(true)) {
cv.notify_all();
}
});
if (config.captureSystemAudio) {
if (!loopbackCapture.start([&](const BYTE* data, DWORD byteCount, int64_t timestampHns, int64_t durationHns) {
if (stopRequested) {
return;
}
if (!encoder.writeAudio(data, byteCount, timestampHns, durationHns)) {
encodeFailed = true;
stopRequested = true;
cv.notify_all();
}
})) {
std::cerr << "ERROR: Failed to start WASAPI loopback capture" << std::endl;
return 1;
}
}
if (!session.start()) {
loopbackCapture.stop();
std::cerr << "ERROR: Failed to start WGC session" << std::endl;
return 1;
}
std::thread stdinThread(readStopCommands, std::ref(stopRequested), std::ref(cv));
{
std::unique_lock lock(mutex);
const bool started = cv.wait_for(lock, std::chrono::seconds(10), [&] {
return firstFrameWritten.load() || stopRequested.load();
});
if (!started || !firstFrameWritten) {
stopRequested = true;
cv.notify_all();
if (stdinThread.joinable()) {
stdinThread.detach();
}
loopbackCapture.stop();
std::cerr << "ERROR: Timed out waiting for first WGC frame" << std::endl;
return 1;
}
}
std::cout << "{\"event\":\"recording-started\",\"schemaVersion\":2}" << std::endl;
std::cout << "Recording started" << std::endl;
{
std::unique_lock lock(mutex);
cv.wait(lock, [&] {
return stopRequested.load();
});
}
loopbackCapture.stop();
session.stop();
{
std::scoped_lock lock(mutex);
encoder.finalize();
}
if (stdinThread.joinable()) {
stdinThread.join();
}
if (encodeFailed) {
std::cerr << "ERROR: Failed to encode WGC frame" << std::endl;
return 1;
}
std::cout << "{\"event\":\"recording-stopped\",\"schemaVersion\":2,\"screenPath\":\""
<< jsonEscape(config.outputPath) << "\"}" << std::endl;
std::cout << "Recording stopped. Output path: " << config.outputPath << std::endl;
return 0;
}
@@ -0,0 +1,317 @@
#include "mf_encoder.h"
#include <mfapi.h>
#include <mferror.h>
#include <propvarutil.h>
#include <algorithm>
#include <cstring>
#include <iostream>
namespace {
bool succeeded(HRESULT hr, const char* label) {
if (SUCCEEDED(hr)) {
return true;
}
std::cerr << "ERROR: " << label << " failed (hr=0x" << std::hex << hr << std::dec << ")"
<< std::endl;
return false;
}
void setFrameSize(IMFMediaType* type, UINT32 width, UINT32 height) {
MFSetAttributeSize(type, MF_MT_FRAME_SIZE, width, height);
}
void setFrameRate(IMFMediaType* type, UINT32 fps) {
MFSetAttributeRatio(type, MF_MT_FRAME_RATE, fps, 1);
}
void setPixelAspectRatio(IMFMediaType* type) {
MFSetAttributeRatio(type, MF_MT_PIXEL_ASPECT_RATIO, 1, 1);
}
void setAudioFormat(IMFMediaType* type, UINT32 channels, UINT32 sampleRate, UINT32 bitsPerSample) {
type->SetUINT32(MF_MT_AUDIO_NUM_CHANNELS, channels);
type->SetUINT32(MF_MT_AUDIO_SAMPLES_PER_SECOND, sampleRate);
type->SetUINT32(MF_MT_AUDIO_BITS_PER_SAMPLE, bitsPerSample);
}
} // namespace
MFEncoder::~MFEncoder() {
finalize();
}
bool MFEncoder::initialize(
const std::wstring& outputPath,
int width,
int height,
int fps,
int bitrate,
ID3D11Device* device,
ID3D11DeviceContext* context,
const AudioInputFormat* audioFormat) {
width_ = (std::max(2, width) / 2) * 2;
height_ = (std::max(2, height) / 2) * 2;
fps_ = std::max(1, fps);
device_ = device;
context_ = context;
if (!succeeded(MFStartup(MF_VERSION), "MFStartup")) {
return false;
}
Microsoft::WRL::ComPtr<IMFMediaType> outputType;
if (!succeeded(MFCreateMediaType(&outputType), "MFCreateMediaType(output)")) {
return false;
}
outputType->SetGUID(MF_MT_MAJOR_TYPE, MFMediaType_Video);
outputType->SetGUID(MF_MT_SUBTYPE, MFVideoFormat_H264);
outputType->SetUINT32(MF_MT_AVG_BITRATE, static_cast<UINT32>(std::max(1, bitrate)));
outputType->SetUINT32(MF_MT_INTERLACE_MODE, MFVideoInterlace_Progressive);
setFrameSize(outputType.Get(), static_cast<UINT32>(width_), static_cast<UINT32>(height_));
setFrameRate(outputType.Get(), static_cast<UINT32>(fps_));
setPixelAspectRatio(outputType.Get());
if (!succeeded(MFCreateSinkWriterFromURL(outputPath.c_str(), nullptr, nullptr, &sinkWriter_),
"MFCreateSinkWriterFromURL")) {
return false;
}
if (!succeeded(sinkWriter_->AddStream(outputType.Get(), &videoStreamIndex_), "AddStream")) {
return false;
}
if (audioFormat && !configureAudioStream(*audioFormat)) {
return false;
}
Microsoft::WRL::ComPtr<IMFMediaType> inputType;
if (!succeeded(MFCreateMediaType(&inputType), "MFCreateMediaType(input)")) {
return false;
}
inputType->SetGUID(MF_MT_MAJOR_TYPE, MFMediaType_Video);
inputType->SetGUID(MF_MT_SUBTYPE, MFVideoFormat_RGB32);
inputType->SetUINT32(MF_MT_INTERLACE_MODE, MFVideoInterlace_Progressive);
inputType->SetUINT32(MF_MT_DEFAULT_STRIDE, static_cast<UINT32>(width_ * 4));
setFrameSize(inputType.Get(), static_cast<UINT32>(width_), static_cast<UINT32>(height_));
setFrameRate(inputType.Get(), static_cast<UINT32>(fps_));
setPixelAspectRatio(inputType.Get());
if (!succeeded(sinkWriter_->SetInputMediaType(videoStreamIndex_, inputType.Get(), nullptr),
"SetInputMediaType")) {
return false;
}
if (!succeeded(sinkWriter_->BeginWriting(), "BeginWriting")) {
return false;
}
return true;
}
bool MFEncoder::configureAudioStream(const AudioInputFormat& audioFormat) {
if (!sinkWriter_) {
return false;
}
if (audioFormat.sampleRate == 0 || audioFormat.channels == 0 || audioFormat.blockAlign == 0) {
std::cerr << "ERROR: Invalid audio input format" << std::endl;
return false;
}
const UINT32 bitsPerSample = std::max<UINT32>(8, audioFormat.bitsPerSample);
const UINT32 aacBytesPerSecond = 24'000;
Microsoft::WRL::ComPtr<IMFMediaType> outputType;
if (!succeeded(MFCreateMediaType(&outputType), "MFCreateMediaType(audio output)")) {
return false;
}
outputType->SetGUID(MF_MT_MAJOR_TYPE, MFMediaType_Audio);
outputType->SetGUID(MF_MT_SUBTYPE, MFAudioFormat_AAC);
setAudioFormat(outputType.Get(), audioFormat.channels, audioFormat.sampleRate, 16);
outputType->SetUINT32(MF_MT_AUDIO_AVG_BYTES_PER_SECOND, aacBytesPerSecond);
outputType->SetUINT32(MF_MT_AAC_PAYLOAD_TYPE, 0);
if (!succeeded(sinkWriter_->AddStream(outputType.Get(), &audioStreamIndex_), "AddStream(audio)")) {
return false;
}
Microsoft::WRL::ComPtr<IMFMediaType> inputType;
if (!succeeded(MFCreateMediaType(&inputType), "MFCreateMediaType(audio input)")) {
return false;
}
inputType->SetGUID(MF_MT_MAJOR_TYPE, MFMediaType_Audio);
inputType->SetGUID(MF_MT_SUBTYPE, audioFormat.subtype);
setAudioFormat(inputType.Get(), audioFormat.channels, audioFormat.sampleRate, bitsPerSample);
inputType->SetUINT32(MF_MT_AUDIO_BLOCK_ALIGNMENT, audioFormat.blockAlign);
inputType->SetUINT32(MF_MT_AUDIO_AVG_BYTES_PER_SECOND, audioFormat.avgBytesPerSec);
inputType->SetUINT32(MF_MT_ALL_SAMPLES_INDEPENDENT, TRUE);
if (!succeeded(sinkWriter_->SetInputMediaType(audioStreamIndex_, inputType.Get(), nullptr),
"SetInputMediaType(audio)")) {
return false;
}
hasAudioStream_ = true;
return true;
}
bool MFEncoder::ensureStagingTexture(ID3D11Texture2D* texture) {
if (stagingTexture_) {
return true;
}
D3D11_TEXTURE2D_DESC desc{};
texture->GetDesc(&desc);
desc.Width = static_cast<UINT>(width_);
desc.Height = static_cast<UINT>(height_);
desc.MipLevels = 1;
desc.ArraySize = 1;
desc.Format = DXGI_FORMAT_B8G8R8A8_UNORM;
desc.SampleDesc.Count = 1;
desc.SampleDesc.Quality = 0;
desc.Usage = D3D11_USAGE_STAGING;
desc.BindFlags = 0;
desc.CPUAccessFlags = D3D11_CPU_ACCESS_READ;
desc.MiscFlags = 0;
return succeeded(device_->CreateTexture2D(&desc, nullptr, &stagingTexture_),
"CreateTexture2D(staging)");
}
bool MFEncoder::copyFrameToBuffer(ID3D11Texture2D* texture, BYTE* destination, DWORD destinationSize) {
if (!ensureStagingTexture(texture)) {
return false;
}
context_->CopyResource(stagingTexture_.Get(), texture);
D3D11_MAPPED_SUBRESOURCE mapped{};
if (!succeeded(context_->Map(stagingTexture_.Get(), 0, D3D11_MAP_READ, 0, &mapped), "Map")) {
return false;
}
const DWORD rowBytes = static_cast<DWORD>(width_ * 4);
const DWORD requiredBytes = rowBytes * static_cast<DWORD>(height_);
if (destinationSize < requiredBytes) {
context_->Unmap(stagingTexture_.Get(), 0);
std::cerr << "ERROR: Media Foundation buffer is too small" << std::endl;
return false;
}
auto* source = static_cast<const BYTE*>(mapped.pData);
for (int y = 0; y < height_; y += 1) {
std::memcpy(destination + rowBytes * y, source + mapped.RowPitch * y, rowBytes);
}
context_->Unmap(stagingTexture_.Get(), 0);
return true;
}
bool MFEncoder::writeFrame(ID3D11Texture2D* texture, int64_t timestampHns) {
std::scoped_lock writerLock(writerMutex_);
if (!sinkWriter_ || finalized_) {
return false;
}
if (firstTimestampHns_ < 0) {
firstTimestampHns_ = timestampHns;
}
int64_t sampleTime = timestampHns - firstTimestampHns_;
if (sampleTime <= lastTimestampHns_) {
sampleTime = lastTimestampHns_ + (10'000'000LL / fps_);
}
const int64_t sampleDuration = 10'000'000LL / fps_;
lastTimestampHns_ = sampleTime;
Microsoft::WRL::ComPtr<IMFMediaBuffer> buffer;
const DWORD frameBytes = static_cast<DWORD>(width_ * height_ * 4);
if (!succeeded(MFCreateMemoryBuffer(frameBytes, &buffer), "MFCreateMemoryBuffer")) {
return false;
}
BYTE* data = nullptr;
DWORD maxLength = 0;
DWORD currentLength = 0;
if (!succeeded(buffer->Lock(&data, &maxLength, &currentLength), "IMFMediaBuffer::Lock")) {
return false;
}
const bool copied = copyFrameToBuffer(texture, data, maxLength);
buffer->Unlock();
if (!copied) {
return false;
}
buffer->SetCurrentLength(frameBytes);
Microsoft::WRL::ComPtr<IMFSample> sample;
if (!succeeded(MFCreateSample(&sample), "MFCreateSample")) {
return false;
}
sample->AddBuffer(buffer.Get());
sample->SetSampleTime(sampleTime);
sample->SetSampleDuration(sampleDuration);
return succeeded(sinkWriter_->WriteSample(videoStreamIndex_, sample.Get()), "WriteSample");
}
bool MFEncoder::writeAudio(const BYTE* data, DWORD byteCount, int64_t timestampHns, int64_t durationHns) {
std::scoped_lock writerLock(writerMutex_);
if (!sinkWriter_ || finalized_ || !hasAudioStream_) {
return false;
}
if (!data || byteCount == 0 || durationHns <= 0) {
return true;
}
Microsoft::WRL::ComPtr<IMFMediaBuffer> buffer;
if (!succeeded(MFCreateMemoryBuffer(byteCount, &buffer), "MFCreateMemoryBuffer(audio)")) {
return false;
}
BYTE* destination = nullptr;
DWORD maxLength = 0;
DWORD currentLength = 0;
if (!succeeded(buffer->Lock(&destination, &maxLength, &currentLength),
"IMFMediaBuffer::Lock(audio)")) {
return false;
}
if (maxLength < byteCount) {
buffer->Unlock();
std::cerr << "ERROR: Media Foundation audio buffer is too small" << std::endl;
return false;
}
std::memcpy(destination, data, byteCount);
buffer->Unlock();
buffer->SetCurrentLength(byteCount);
Microsoft::WRL::ComPtr<IMFSample> sample;
if (!succeeded(MFCreateSample(&sample), "MFCreateSample(audio)")) {
return false;
}
sample->AddBuffer(buffer.Get());
sample->SetSampleTime(std::max<int64_t>(0, timestampHns));
sample->SetSampleDuration(durationHns);
return succeeded(sinkWriter_->WriteSample(audioStreamIndex_, sample.Get()), "WriteSample(audio)");
}
bool MFEncoder::finalize() {
std::scoped_lock writerLock(writerMutex_);
if (finalized_) {
return true;
}
finalized_ = true;
bool ok = true;
if (sinkWriter_) {
ok = succeeded(sinkWriter_->Finalize(), "SinkWriter::Finalize");
sinkWriter_.Reset();
}
stagingTexture_.Reset();
context_.Reset();
device_.Reset();
MFShutdown();
return ok;
}
@@ -0,0 +1,63 @@
#pragma once
#include <Windows.h>
#include <d3d11.h>
#include <mfapi.h>
#include <mfidl.h>
#include <mfreadwrite.h>
#include <wrl/client.h>
#include <cstdint>
#include <mutex>
#include <string>
struct AudioInputFormat {
GUID subtype = MFAudioFormat_PCM;
UINT32 sampleRate = 0;
UINT32 channels = 0;
UINT32 bitsPerSample = 0;
UINT32 blockAlign = 0;
UINT32 avgBytesPerSec = 0;
};
class MFEncoder {
public:
MFEncoder() = default;
~MFEncoder();
MFEncoder(const MFEncoder&) = delete;
MFEncoder& operator=(const MFEncoder&) = delete;
bool initialize(
const std::wstring& outputPath,
int width,
int height,
int fps,
int bitrate,
ID3D11Device* device,
ID3D11DeviceContext* context,
const AudioInputFormat* audioFormat = nullptr);
bool writeFrame(ID3D11Texture2D* texture, int64_t timestampHns);
bool writeAudio(const BYTE* data, DWORD byteCount, int64_t timestampHns, int64_t durationHns);
bool finalize();
private:
bool ensureStagingTexture(ID3D11Texture2D* texture);
bool copyFrameToBuffer(ID3D11Texture2D* texture, BYTE* destination, DWORD destinationSize);
bool configureAudioStream(const AudioInputFormat& audioFormat);
Microsoft::WRL::ComPtr<IMFSinkWriter> sinkWriter_;
Microsoft::WRL::ComPtr<ID3D11Device> device_;
Microsoft::WRL::ComPtr<ID3D11DeviceContext> context_;
Microsoft::WRL::ComPtr<ID3D11Texture2D> stagingTexture_;
std::mutex writerMutex_;
DWORD videoStreamIndex_ = 0;
DWORD audioStreamIndex_ = 0;
bool hasAudioStream_ = false;
int width_ = 0;
int height_ = 0;
int fps_ = 60;
int64_t firstTimestampHns_ = -1;
int64_t lastTimestampHns_ = -1;
bool finalized_ = false;
};
@@ -0,0 +1,88 @@
#include "monitor_utils.h"
#include <algorithm>
#include <cmath>
#include <vector>
namespace {
struct MonitorCandidate {
HMONITOR monitor = nullptr;
RECT rect{};
};
std::vector<MonitorCandidate> enumerateMonitors() {
std::vector<MonitorCandidate> monitors;
EnumDisplayMonitors(
nullptr,
nullptr,
[](HMONITOR monitor, HDC, LPRECT rect, LPARAM userData) -> BOOL {
auto* result = reinterpret_cast<std::vector<MonitorCandidate>*>(userData);
result->push_back({monitor, *rect});
return TRUE;
},
reinterpret_cast<LPARAM>(&monitors));
return monitors;
}
bool rectMatchesBounds(const RECT& rect, const MonitorBounds& bounds) {
return rect.left == bounds.x &&
rect.top == bounds.y &&
(rect.right - rect.left) == bounds.width &&
(rect.bottom - rect.top) == bounds.height;
}
int64_t overlapArea(const RECT& rect, const MonitorBounds& bounds) {
const LONG left = std::max<LONG>(rect.left, bounds.x);
const LONG top = std::max<LONG>(rect.top, bounds.y);
const LONG right = std::min<LONG>(rect.right, bounds.x + bounds.width);
const LONG bottom = std::min<LONG>(rect.bottom, bounds.y + bounds.height);
if (right <= left || bottom <= top) {
return 0;
}
return static_cast<int64_t>(right - left) * static_cast<int64_t>(bottom - top);
}
} // namespace
HMONITOR findMonitorForCapture(int64_t displayId, const MonitorBounds* bounds) {
const auto monitors = enumerateMonitors();
if (monitors.empty()) {
return MonitorFromPoint({0, 0}, MONITOR_DEFAULTTOPRIMARY);
}
// Electron's display_id is not stable across all Windows capture backends.
// Bounds are the most reliable contract because they come from Electron's
// selected display and match the WGC monitor coordinate space.
if (bounds && bounds->width > 0 && bounds->height > 0) {
for (const auto& candidate : monitors) {
if (rectMatchesBounds(candidate.rect, *bounds)) {
return candidate.monitor;
}
}
HMONITOR bestMonitor = nullptr;
int64_t bestArea = 0;
for (const auto& candidate : monitors) {
const int64_t area = overlapArea(candidate.rect, *bounds);
if (area > bestArea) {
bestArea = area;
bestMonitor = candidate.monitor;
}
}
if (bestMonitor) {
return bestMonitor;
}
}
// Best-effort fallback for helpers invoked without bounds. Some callers pass
// zero-based ids while Win32 monitor handles are pointer values, so only use
// this when it exactly matches the HMONITOR value.
for (const auto& candidate : monitors) {
if (reinterpret_cast<int64_t>(candidate.monitor) == displayId) {
return candidate.monitor;
}
}
return MonitorFromPoint({0, 0}, MONITOR_DEFAULTTOPRIMARY);
}
@@ -0,0 +1,14 @@
#pragma once
#include <Windows.h>
#include <cstdint>
struct MonitorBounds {
int x = 0;
int y = 0;
int width = 0;
int height = 0;
};
HMONITOR findMonitorForCapture(int64_t displayId, const MonitorBounds* bounds);
@@ -0,0 +1,205 @@
#include "wasapi_loopback_capture.h"
#include <ksmedia.h>
#include <algorithm>
#include <chrono>
#include <iostream>
namespace {
constexpr REFERENCE_TIME BufferDurationHns = 10'000'000;
constexpr int64_t HnsPerSecond = 10'000'000;
bool succeeded(HRESULT hr, const char* label) {
if (SUCCEEDED(hr)) {
return true;
}
std::cerr << "ERROR: " << label << " failed (hr=0x" << std::hex << hr << std::dec << ")"
<< std::endl;
return false;
}
GUID audioSubtypeFromFormat(WAVEFORMATEX* format) {
if (format->wFormatTag == WAVE_FORMAT_IEEE_FLOAT) {
return MFAudioFormat_Float;
}
if (format->wFormatTag == WAVE_FORMAT_PCM) {
return MFAudioFormat_PCM;
}
if (format->wFormatTag == WAVE_FORMAT_EXTENSIBLE &&
format->cbSize >= sizeof(WAVEFORMATEXTENSIBLE) - sizeof(WAVEFORMATEX)) {
auto* extensible = reinterpret_cast<WAVEFORMATEXTENSIBLE*>(format);
if (extensible->SubFormat == KSDATAFORMAT_SUBTYPE_IEEE_FLOAT) {
return MFAudioFormat_Float;
}
if (extensible->SubFormat == KSDATAFORMAT_SUBTYPE_PCM) {
return MFAudioFormat_PCM;
}
}
return GUID_NULL;
}
} // namespace
WasapiLoopbackCapture::~WasapiLoopbackCapture() {
stop();
if (mixFormat_) {
CoTaskMemFree(mixFormat_);
mixFormat_ = nullptr;
}
}
bool WasapiLoopbackCapture::initialize() {
HRESULT hr = CoCreateInstance(
__uuidof(MMDeviceEnumerator),
nullptr,
CLSCTX_ALL,
IID_PPV_ARGS(&deviceEnumerator_));
if (!succeeded(hr, "CoCreateInstance(MMDeviceEnumerator)")) {
return false;
}
hr = deviceEnumerator_->GetDefaultAudioEndpoint(eRender, eConsole, &device_);
if (!succeeded(hr, "GetDefaultAudioEndpoint(render)")) {
return false;
}
hr = device_->Activate(__uuidof(IAudioClient), CLSCTX_ALL, nullptr, &audioClient_);
if (!succeeded(hr, "IMMDevice::Activate(IAudioClient)")) {
return false;
}
hr = audioClient_->GetMixFormat(&mixFormat_);
if (!succeeded(hr, "IAudioClient::GetMixFormat") || !mixFormat_) {
return false;
}
if (!resolveInputFormat(mixFormat_)) {
std::cerr << "ERROR: Unsupported WASAPI loopback mix format" << std::endl;
return false;
}
hr = audioClient_->Initialize(
AUDCLNT_SHAREMODE_SHARED,
AUDCLNT_STREAMFLAGS_LOOPBACK,
BufferDurationHns,
0,
mixFormat_,
nullptr);
if (!succeeded(hr, "IAudioClient::Initialize(loopback)")) {
return false;
}
hr = audioClient_->GetService(IID_PPV_ARGS(&captureClient_));
if (!succeeded(hr, "IAudioClient::GetService(IAudioCaptureClient)")) {
return false;
}
return true;
}
bool WasapiLoopbackCapture::resolveInputFormat(WAVEFORMATEX* mixFormat) {
const GUID subtype = audioSubtypeFromFormat(mixFormat);
if (subtype == GUID_NULL) {
return false;
}
inputFormat_.subtype = subtype;
inputFormat_.sampleRate = mixFormat->nSamplesPerSec;
inputFormat_.channels = mixFormat->nChannels;
inputFormat_.bitsPerSample = mixFormat->wBitsPerSample;
inputFormat_.blockAlign = mixFormat->nBlockAlign;
inputFormat_.avgBytesPerSec = mixFormat->nAvgBytesPerSec;
return inputFormat_.sampleRate > 0 && inputFormat_.channels > 0 && inputFormat_.blockAlign > 0;
}
bool WasapiLoopbackCapture::start(AudioCallback callback) {
if (!audioClient_ || !captureClient_ || !callback) {
return false;
}
callback_ = std::move(callback);
stopRequested_ = false;
writtenFrames_ = 0;
HRESULT hr = audioClient_->Start();
if (!succeeded(hr, "IAudioClient::Start")) {
return false;
}
thread_ = std::thread([this] {
captureLoop();
});
return true;
}
void WasapiLoopbackCapture::stop() {
stopRequested_ = true;
if (thread_.joinable()) {
thread_.join();
}
if (audioClient_) {
audioClient_->Stop();
}
}
const AudioInputFormat& WasapiLoopbackCapture::inputFormat() const {
return inputFormat_;
}
void WasapiLoopbackCapture::captureLoop() {
while (!stopRequested_) {
UINT32 packetFrames = 0;
HRESULT hr = captureClient_->GetNextPacketSize(&packetFrames);
if (FAILED(hr)) {
std::cerr << "ERROR: IAudioCaptureClient::GetNextPacketSize failed (hr=0x" << std::hex
<< hr << std::dec << ")" << std::endl;
break;
}
while (packetFrames > 0 && !stopRequested_) {
BYTE* data = nullptr;
UINT32 framesAvailable = 0;
DWORD flags = 0;
hr = captureClient_->GetBuffer(&data, &framesAvailable, &flags, nullptr, nullptr);
if (FAILED(hr)) {
std::cerr << "ERROR: IAudioCaptureClient::GetBuffer failed (hr=0x" << std::hex
<< hr << std::dec << ")" << std::endl;
break;
}
const DWORD byteCount = framesAvailable * inputFormat_.blockAlign;
const int64_t timestampHns =
static_cast<int64_t>((writtenFrames_ * HnsPerSecond) / inputFormat_.sampleRate);
const int64_t durationHns =
static_cast<int64_t>((static_cast<uint64_t>(framesAvailable) * HnsPerSecond) /
inputFormat_.sampleRate);
if (byteCount > 0) {
if ((flags & AUDCLNT_BUFFERFLAGS_SILENT) != 0 || !data) {
silenceBuffer_.assign(byteCount, 0);
callback_(silenceBuffer_.data(), byteCount, timestampHns, durationHns);
} else {
callback_(data, byteCount, timestampHns, durationHns);
}
}
writtenFrames_ += framesAvailable;
captureClient_->ReleaseBuffer(framesAvailable);
hr = captureClient_->GetNextPacketSize(&packetFrames);
if (FAILED(hr)) {
std::cerr << "ERROR: IAudioCaptureClient::GetNextPacketSize failed (hr=0x"
<< std::hex << hr << std::dec << ")" << std::endl;
packetFrames = 0;
break;
}
}
std::this_thread::sleep_for(std::chrono::milliseconds(5));
}
}
@@ -0,0 +1,47 @@
#pragma once
#include "mf_encoder.h"
#include <Windows.h>
#include <audioclient.h>
#include <mmdeviceapi.h>
#include <wrl/client.h>
#include <atomic>
#include <cstdint>
#include <functional>
#include <thread>
#include <vector>
class WasapiLoopbackCapture {
public:
using AudioCallback = std::function<void(const BYTE* data, DWORD byteCount, int64_t timestampHns, int64_t durationHns)>;
WasapiLoopbackCapture() = default;
~WasapiLoopbackCapture();
WasapiLoopbackCapture(const WasapiLoopbackCapture&) = delete;
WasapiLoopbackCapture& operator=(const WasapiLoopbackCapture&) = delete;
bool initialize();
bool start(AudioCallback callback);
void stop();
const AudioInputFormat& inputFormat() const;
private:
void captureLoop();
bool resolveInputFormat(WAVEFORMATEX* mixFormat);
Microsoft::WRL::ComPtr<IMMDeviceEnumerator> deviceEnumerator_;
Microsoft::WRL::ComPtr<IMMDevice> device_;
Microsoft::WRL::ComPtr<IAudioClient> audioClient_;
Microsoft::WRL::ComPtr<IAudioCaptureClient> captureClient_;
WAVEFORMATEX* mixFormat_ = nullptr;
AudioInputFormat inputFormat_{};
AudioCallback callback_;
std::thread thread_;
std::atomic<bool> stopRequested_ = false;
std::vector<BYTE> silenceBuffer_;
uint64_t writtenFrames_ = 0;
};
@@ -0,0 +1,223 @@
#include "wgc_session.h"
#include <Windows.Graphics.Capture.Interop.h>
#include <dxgi1_2.h>
#include <inspectable.h>
#include <winrt/base.h>
#include <iostream>
namespace wf = winrt::Windows::Foundation;
namespace wgcap = winrt::Windows::Graphics::Capture;
namespace wgdx = winrt::Windows::Graphics::DirectX;
namespace wgd3d = winrt::Windows::Graphics::DirectX::Direct3D11;
extern "C" HRESULT __stdcall CreateDirect3D11DeviceFromDXGIDevice(
::IDXGIDevice* dxgiDevice,
::IInspectable** graphicsDevice);
namespace {
bool succeeded(HRESULT hr, const char* label) {
if (SUCCEEDED(hr)) {
return true;
}
std::cerr << "ERROR: " << label << " failed (hr=0x" << std::hex << hr << std::dec << ")"
<< std::endl;
return false;
}
int64_t timeSpanToHns(wf::TimeSpan const& value) {
return value.count();
}
} // namespace
WgcSession::~WgcSession() {
stop();
}
bool WgcSession::createD3DDevice() {
UINT flags = D3D11_CREATE_DEVICE_BGRA_SUPPORT;
#if defined(_DEBUG)
flags |= D3D11_CREATE_DEVICE_DEBUG;
#endif
D3D_FEATURE_LEVEL featureLevels[] = {
D3D_FEATURE_LEVEL_11_1,
D3D_FEATURE_LEVEL_11_0,
D3D_FEATURE_LEVEL_10_1,
D3D_FEATURE_LEVEL_10_0,
};
D3D_FEATURE_LEVEL featureLevel{};
HRESULT hr = D3D11CreateDevice(
nullptr,
D3D_DRIVER_TYPE_HARDWARE,
nullptr,
flags,
featureLevels,
ARRAYSIZE(featureLevels),
D3D11_SDK_VERSION,
&d3dDevice_,
&featureLevel,
&d3dContext_);
#if defined(_DEBUG)
if (FAILED(hr)) {
flags &= ~D3D11_CREATE_DEVICE_DEBUG;
hr = D3D11CreateDevice(
nullptr,
D3D_DRIVER_TYPE_HARDWARE,
nullptr,
flags,
featureLevels,
ARRAYSIZE(featureLevels),
D3D11_SDK_VERSION,
&d3dDevice_,
&featureLevel,
&d3dContext_);
}
#endif
if (!succeeded(hr, "D3D11CreateDevice")) {
return false;
}
Microsoft::WRL::ComPtr<IDXGIDevice> dxgiDevice;
if (!succeeded(d3dDevice_.As(&dxgiDevice), "Query IDXGIDevice")) {
return false;
}
winrt::com_ptr<::IInspectable> inspectableDevice;
if (!succeeded(CreateDirect3D11DeviceFromDXGIDevice(dxgiDevice.Get(), inspectableDevice.put()),
"CreateDirect3D11DeviceFromDXGIDevice")) {
return false;
}
winrtDevice_ = inspectableDevice.as<wgd3d::IDirect3DDevice>();
return true;
}
bool WgcSession::createCaptureItem(HMONITOR monitor) {
auto factory = winrt::get_activation_factory<wgcap::GraphicsCaptureItem>();
auto interop = factory.as<IGraphicsCaptureItemInterop>();
wgcap::GraphicsCaptureItem item{nullptr};
HRESULT hr = interop->CreateForMonitor(
monitor,
winrt::guid_of<wgcap::GraphicsCaptureItem>(),
reinterpret_cast<void**>(winrt::put_abi(item)));
if (!succeeded(hr, "CreateForMonitor")) {
return false;
}
item_ = item;
const auto size = item_.Size();
width_ = static_cast<int>(size.Width);
height_ = static_cast<int>(size.Height);
return width_ > 0 && height_ > 0;
}
bool WgcSession::initialize(HMONITOR monitor, int fps) {
fps_ = fps > 0 ? fps : 60;
if (!createD3DDevice()) {
return false;
}
if (!createCaptureItem(monitor)) {
return false;
}
framePool_ = wgcap::Direct3D11CaptureFramePool::CreateFreeThreaded(
winrtDevice_,
wgdx::DirectXPixelFormat::B8G8R8A8UIntNormalized,
2,
item_.Size());
session_ = framePool_.CreateCaptureSession(item_);
try {
session_.IsCursorCaptureEnabled(false);
} catch (...) {
// Older WGC builds can omit this property; callers still overlay their own cursor.
}
frameArrivedToken_ = framePool_.FrameArrived({this, &WgcSession::onFrameArrived});
return true;
}
void WgcSession::setFrameCallback(FrameCallback callback) {
std::scoped_lock lock(callbackMutex_);
frameCallback_ = std::move(callback);
}
bool WgcSession::start() {
if (!session_) {
return false;
}
session_.StartCapture();
started_ = true;
return true;
}
void WgcSession::stop() {
if (framePool_) {
framePool_.FrameArrived(frameArrivedToken_);
}
if (session_) {
session_.Close();
session_ = nullptr;
}
if (framePool_) {
framePool_.Close();
framePool_ = nullptr;
}
item_ = nullptr;
winrtDevice_ = nullptr;
d3dContext_.Reset();
d3dDevice_.Reset();
started_ = false;
}
void WgcSession::onFrameArrived(
wgcap::Direct3D11CaptureFramePool const& sender,
wf::IInspectable const&) {
auto frame = sender.TryGetNextFrame();
if (!frame) {
return;
}
auto surface = frame.Surface();
auto access = surface.as<::Windows::Graphics::DirectX::Direct3D11::IDirect3DDxgiInterfaceAccess>();
Microsoft::WRL::ComPtr<ID3D11Texture2D> texture;
HRESULT hr = access->GetInterface(__uuidof(ID3D11Texture2D), reinterpret_cast<void**>(texture.GetAddressOf()));
if (FAILED(hr) || !texture) {
return;
}
FrameCallback callback;
{
std::scoped_lock lock(callbackMutex_);
callback = frameCallback_;
}
if (callback) {
callback(texture.Get(), timeSpanToHns(frame.SystemRelativeTime()));
}
}
int WgcSession::captureWidth() const {
return width_;
}
int WgcSession::captureHeight() const {
return height_;
}
ID3D11Device* WgcSession::device() const {
return d3dDevice_.Get();
}
ID3D11DeviceContext* WgcSession::context() const {
return d3dContext_.Get();
}
@@ -0,0 +1,55 @@
#pragma once
#include <Windows.h>
#include <d3d11.h>
#include <windows.graphics.capture.h>
#include <windows.graphics.directx.direct3d11.interop.h>
#include <winrt/Windows.Foundation.h>
#include <winrt/Windows.Graphics.Capture.h>
#include <winrt/Windows.Graphics.DirectX.Direct3D11.h>
#include <wrl/client.h>
#include <functional>
#include <mutex>
class WgcSession {
public:
using FrameCallback = std::function<void(ID3D11Texture2D*, int64_t)>;
WgcSession() = default;
~WgcSession();
WgcSession(const WgcSession&) = delete;
WgcSession& operator=(const WgcSession&) = delete;
bool initialize(HMONITOR monitor, int fps);
void setFrameCallback(FrameCallback callback);
bool start();
void stop();
int captureWidth() const;
int captureHeight() const;
ID3D11Device* device() const;
ID3D11DeviceContext* context() const;
private:
bool createD3DDevice();
bool createCaptureItem(HMONITOR monitor);
void onFrameArrived(
winrt::Windows::Graphics::Capture::Direct3D11CaptureFramePool const& sender,
winrt::Windows::Foundation::IInspectable const&);
Microsoft::WRL::ComPtr<ID3D11Device> d3dDevice_;
Microsoft::WRL::ComPtr<ID3D11DeviceContext> d3dContext_;
winrt::Windows::Graphics::DirectX::Direct3D11::IDirect3DDevice winrtDevice_{nullptr};
winrt::Windows::Graphics::Capture::GraphicsCaptureItem item_{nullptr};
winrt::Windows::Graphics::Capture::Direct3D11CaptureFramePool framePool_{nullptr};
winrt::Windows::Graphics::Capture::GraphicsCaptureSession session_{nullptr};
winrt::event_token frameArrivedToken_{};
FrameCallback frameCallback_;
std::mutex callbackMutex_;
int width_ = 0;
int height_ = 0;
int fps_ = 60;
bool started_ = false;
};