diff --git a/electron/native/bin/win32-x64/helpers-manifest.json b/electron/native/bin/win32-x64/helpers-manifest.json index 0172a0c30..a97363217 100644 --- a/electron/native/bin/win32-x64/helpers-manifest.json +++ b/electron/native/bin/win32-x64/helpers-manifest.json @@ -5,10 +5,10 @@ "helpers": { "wgc-capture": { "binaryName": "wgc-capture.exe", - "binarySha256": "a5a9c0c417144a834af3206b60bae32d0c21d55deb8a3f5446831621cfdfa7b3", + "binarySha256": "fa1c53503258771d7fea948f148c2cd603644073e04fa1d48ae93f0d47d720b8", "sourceDir": "electron/native/wgc-capture", - "sourceFingerprint": "c65b6eb2230be7db9b49aac48eba52a9eac469028ebb242e10c16c51c86b3220", - "updatedAt": "2026-09-05T02:09:18.163Z" + "sourceFingerprint": "7a9d0b5b3bd1d06350b5523090bbdb278ce1f46817e85547d17b1eb13afd278f", + "updatedAt": "2026-09-13T09:53:42.113Z" }, "cursor-monitor": { "binaryName": "cursor-monitor.exe", diff --git a/electron/native/bin/win32-x64/wgc-capture.exe b/electron/native/bin/win32-x64/wgc-capture.exe index efa45c37d..41342b91b 100644 Binary files a/electron/native/bin/win32-x64/wgc-capture.exe and b/electron/native/bin/win32-x64/wgc-capture.exe differ diff --git a/electron/native/wgc-capture/src/main.cpp b/electron/native/wgc-capture/src/main.cpp index 68a408efe..2240cc43b 100644 --- a/electron/native/wgc-capture/src/main.cpp +++ b/electron/native/wgc-capture/src/main.cpp @@ -419,7 +419,7 @@ int main(int argc, char* argv[]) { captureSetupComplete = true; // Wait for stop signal while pausing/resuming audio tracks in lockstep. - while (!g_stopRequested && !session.hasFatalError()) { + while (!g_stopRequested && !session.hasFatalError() && !encoder.hasFatalError()) { if (g_pauseRequested) { if (audioActive) loopback.pause(); if (micActive) micCapture.pause(); @@ -438,8 +438,8 @@ int main(int argc, char* argv[]) { if (audioActive) loopback.stop(); if (micActive) micCapture.stop(); - if (session.hasFatalError()) { - std::cerr << "ERROR: WGC capture session failed during recording" << std::endl; + if (session.hasFatalError() || encoder.hasFatalError()) { + std::cerr << "ERROR: WGC capture or encoder session failed during recording" << std::endl; encoder.finalize(); DeleteFileW(outputPathW.c_str()); if (!config.audioOutputPath.empty()) { @@ -480,6 +480,9 @@ int main(int argc, char* argv[]) { std::cerr << "WARNING: Failed to extend the last video frame to the stop timestamp" << std::endl; } + std::cerr << "Encoder queue dropped " << encoder.droppedFrameCount() + << " stale frames to preserve capture responsiveness" << std::endl; + if (!encoder.finalize()) { std::cerr << "ERROR: Failed to finalize Media Foundation encoder" << std::endl; return 1; diff --git a/electron/native/wgc-capture/src/mf_encoder.cpp b/electron/native/wgc-capture/src/mf_encoder.cpp index 17b11effa..2879eae2a 100644 --- a/electron/native/wgc-capture/src/mf_encoder.cpp +++ b/electron/native/wgc-capture/src/mf_encoder.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include "../../common/bt709_video.h" #pragma comment(lib, "mfplat.lib") @@ -60,6 +61,14 @@ bool MFEncoder::initialize(const std::wstring& outputPath, int width, int height device_ = device; context_ = context; + // FrameArrived is raised on WGC's worker thread while encoding runs on our + // own worker. Protect the immediate context so short GPU copies can run + // independently of staging readback and Media Foundation work. + ComPtr multithread; + if (SUCCEEDED(context_->QueryInterface(IID_PPV_ARGS(&multithread)))) { + multithread->SetMultithreadProtected(TRUE); + } + HRESULT hr = MFStartup(MF_VERSION); if (FAILED(hr)) { std::cerr << "ERROR: MFStartup failed: 0x" << std::hex << hr << std::endl; @@ -171,6 +180,23 @@ bool MFEncoder::initialize(const std::wstring& outputPath, int width, int height return false; } + // Keep a tiny latest-frame queue. WGC surfaces are owned by the frame pool + // and cannot outlive FrameArrived, so copy them to private GPU textures and + // return immediately. If encoding falls behind, discard stale pending + // frames instead of blocking capture and producing visible jitter. + D3D11_TEXTURE2D_DESC queuedFrameDesc = compositeDesc; + queuedFrameDesc.BindFlags = 0; + for (int i = 0; i < 3; ++i) { + ComPtr queuedFrameTexture; + hr = device_->CreateTexture2D(&queuedFrameDesc, nullptr, &queuedFrameTexture); + if (FAILED(hr)) { + std::cerr << "ERROR: Failed to create queued frame texture: 0x" + << std::hex << hr << std::endl; + return false; + } + freeFrameTextures_.push_back(queuedFrameTexture); + } + // Pre-allocate NV12 buffer const int ySize = width_ * height_; const int uvSize = (width_ / 2) * (height_ / 2) * 2; @@ -178,24 +204,48 @@ bool MFEncoder::initialize(const std::wstring& outputPath, int width, int height lastFrameBuffer_.clear(); firstSampleTimeHns_ = -1; lastSampleTimeHns_ = -1; + workerStopping_ = false; + workerBusy_ = false; + workerFailed_ = false; + droppedFrameCount_ = 0; initialized_ = true; + encoderWorker_ = std::thread(&MFEncoder::encoderWorkerLoop, this); return true; } bool MFEncoder::writeFrame(ID3D11Texture2D* texture, int64_t timestampHns) { - std::lock_guard lock(mutex_); - - if (!initialized_ || !sinkWriter_) return false; + if (!texture || !initialized_ || workerFailed_.load()) return false; + + std::lock_guard queueLock(queueMutex_); + if (workerStopping_) return false; + + ComPtr destination; + if (!freeFrameTextures_.empty()) { + destination = freeFrameTextures_.front(); + freeFrameTextures_.pop_front(); + } else if (!pendingFrames_.empty()) { + destination = pendingFrames_.front().texture; + pendingFrames_.pop_front(); + droppedFrameCount_.fetch_add(1); + } else { + // The worker owns every texture. Dropping this frame is safer than + // blocking the WGC callback and starving the frame pool. + droppedFrameCount_.fetch_add(1); + return true; + } D3D11_TEXTURE2D_DESC sourceDesc = {}; texture->GetDesc(&sourceDesc); if (sourceDesc.Width == static_cast(width_) && sourceDesc.Height == static_cast(height_)) { - context_->CopyResource(stagingTexture_.Get(), texture); + context_->CopyResource(destination.Get(), texture); } else { - if (!resizeCompositeTexture_ || !resizeCompositeView_) return false; + if (!resizeCompositeTexture_ || !resizeCompositeView_) { + freeFrameTextures_.push_back(destination); + return false; + } const FLOAT clearColor[4] = {0.0f, 0.0f, 0.0f, 1.0f}; context_->ClearRenderTargetView(resizeCompositeView_.Get(), clearColor); @@ -208,7 +258,10 @@ bool MFEncoder::writeFrame(ID3D11Texture2D* texture, int64_t timestampHns) { sourceBox.bottom = (std::min)(sourceDesc.Height, static_cast(height_)); sourceBox.back = 1; - if (sourceBox.right == 0 || sourceBox.bottom == 0) return false; + if (sourceBox.right == 0 || sourceBox.bottom == 0) { + freeFrameTextures_.push_back(destination); + return false; + } context_->CopySubresourceRegion( resizeCompositeTexture_.Get(), @@ -219,9 +272,19 @@ bool MFEncoder::writeFrame(ID3D11Texture2D* texture, int64_t timestampHns) { texture, 0, &sourceBox); - context_->CopyResource(stagingTexture_.Get(), resizeCompositeTexture_.Get()); + context_->CopyResource(destination.Get(), resizeCompositeTexture_.Get()); } + pendingFrames_.push_back({destination, timestampHns}); + queueCv_.notify_one(); + return true; +} + +bool MFEncoder::processFrameLocked(ID3D11Texture2D* texture, int64_t timestampHns) { + if (!initialized_ || !sinkWriter_ || !texture) 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; @@ -233,15 +296,9 @@ bool MFEncoder::writeFrame(ID3D11Texture2D* texture, int64_t timestampHns) { context_->Unmap(stagingTexture_.Get(), 0); - // WGC may stop delivering frames while the scene is static; keep the MP4 - // timeline continuous by repeating the previous frame before writing a new one. int64_t normalizedTimestampHns = 0; normalizeWriteTimestampHnsLocked(timestampHns, normalizedTimestampHns); - if (!lastFrameBuffer_.empty() && !extendLastFrameToLocked(normalizedTimestampHns)) { - return false; - } - bool wroteSample = writeNv12SampleLocked(nv12Buffer_, normalizedTimestampHns); if (wroteSample) { lastFrameBuffer_ = nv12Buffer_; @@ -250,7 +307,61 @@ bool MFEncoder::writeFrame(ID3D11Texture2D* texture, int64_t timestampHns) { return wroteSample; } +void MFEncoder::encoderWorkerLoop() { + for (;;) { + PendingFrame frame; + { + std::unique_lock queueLock(queueMutex_); + queueCv_.wait(queueLock, [this] { + return workerStopping_ || !pendingFrames_.empty(); + }); + if (workerStopping_ && pendingFrames_.empty()) break; + frame = std::move(pendingFrames_.front()); + pendingFrames_.pop_front(); + workerBusy_ = true; + } + + bool wroteFrame = false; + { + std::lock_guard encoderLock(mutex_); + wroteFrame = processFrameLocked(frame.texture.Get(), frame.timestampHns); + } + if (!wroteFrame) { + workerFailed_ = true; + } + + { + std::lock_guard queueLock(queueMutex_); + freeFrameTextures_.push_back(std::move(frame.texture)); + workerBusy_ = false; + if (pendingFrames_.empty()) queueDrainedCv_.notify_all(); + } + } + + std::lock_guard queueLock(queueMutex_); + workerBusy_ = false; + queueDrainedCv_.notify_all(); +} + +bool MFEncoder::flushPendingFrames() { + std::unique_lock queueLock(queueMutex_); + queueDrainedCv_.wait(queueLock, [this] { + return (pendingFrames_.empty() && !workerBusy_) || workerFailed_.load(); + }); + return !workerFailed_.load(); +} + +void MFEncoder::stopEncoderWorker() { + { + std::lock_guard queueLock(queueMutex_); + workerStopping_ = true; + } + queueCv_.notify_all(); + if (encoderWorker_.joinable()) encoderWorker_.join(); +} + bool MFEncoder::extendLastFrameTo(int64_t timestampHns) { + if (!flushPendingFrames()) return false; std::lock_guard lock(mutex_); int64_t normalizedTimestampHns = 0; @@ -297,15 +408,14 @@ bool MFEncoder::extendLastFrameToLocked(int64_t timestampHns) { return true; } - int64_t nextSampleTimeHns = lastSampleTimeHns_ + frameDurationHns; - while (nextSampleTimeHns + frameDurationHns <= timestampHns) { - if (!writeNv12SampleLocked(lastFrameBuffer_, nextSampleTimeHns)) { - return false; - } - lastSampleTimeHns_ = nextSampleTimeHns; - nextSampleTimeHns += frameDurationHns; - } - + // A timestamp gap naturally holds the preceding video sample on screen. + // Write one tail sample near the stop timestamp instead of synthesizing + // every missing frame. The previous implementation could enqueue tens of + // thousands of duplicate frames and exceed the parent's stop timeout. + const int64_t tailSampleTimeHns = timestampHns - frameDurationHns; + if (tailSampleTimeHns <= lastSampleTimeHns_) return true; + if (!writeNv12SampleLocked(lastFrameBuffer_, tailSampleTimeHns)) return false; + lastSampleTimeHns_ = tailSampleTimeHns; return true; } @@ -346,6 +456,8 @@ bool MFEncoder::writeNv12SampleLocked(const std::vector& frameBuffer, i } bool MFEncoder::finalize() { + flushPendingFrames(); + stopEncoderWorker(); std::lock_guard lock(mutex_); if (!initialized_) return false; @@ -361,6 +473,8 @@ bool MFEncoder::finalize() { stagingTexture_.Reset(); resizeCompositeView_.Reset(); resizeCompositeTexture_.Reset(); + freeFrameTextures_.clear(); + pendingFrames_.clear(); nv12Buffer_.clear(); lastFrameBuffer_.clear(); nv12Buffer_.shrink_to_fit(); diff --git a/electron/native/wgc-capture/src/mf_encoder.h b/electron/native/wgc-capture/src/mf_encoder.h index 3217389af..9b0d000ff 100644 --- a/electron/native/wgc-capture/src/mf_encoder.h +++ b/electron/native/wgc-capture/src/mf_encoder.h @@ -6,8 +6,13 @@ #include #include #include +#include +#include +#include +#include #include #include +#include #include using Microsoft::WRL::ComPtr; @@ -22,8 +27,19 @@ class MFEncoder { bool writeFrame(ID3D11Texture2D* texture, int64_t timestampHns); bool extendLastFrameTo(int64_t timestampHns); bool finalize(); + bool hasFatalError() const { return workerFailed_.load(); } + uint64_t droppedFrameCount() const { return droppedFrameCount_.load(); } private: + struct PendingFrame { + ComPtr texture; + int64_t timestampHns = 0; + }; + + bool processFrameLocked(ID3D11Texture2D* texture, int64_t timestampHns); + void encoderWorkerLoop(); + bool flushPendingFrames(); + void stopEncoderWorker(); void normalizeWriteTimestampHnsLocked(int64_t timestampHns, int64_t& normalizedTimestampHns); bool normalizeTimelineTimestampHnsLocked(int64_t timestampHns, int64_t& normalizedTimestampHns) const; bool extendLastFrameToLocked(int64_t timestampHns); @@ -35,6 +51,8 @@ class MFEncoder { ComPtr stagingTexture_; ComPtr resizeCompositeTexture_; ComPtr resizeCompositeView_; + std::deque> freeFrameTextures_; + std::deque pendingFrames_; std::vector nv12Buffer_; std::vector lastFrameBuffer_; DWORD streamIndex_ = 0; @@ -45,4 +63,12 @@ class MFEncoder { int64_t lastSampleTimeHns_ = -1; bool initialized_ = false; std::mutex mutex_; + std::mutex queueMutex_; + std::condition_variable queueCv_; + std::condition_variable queueDrainedCv_; + std::thread encoderWorker_; + bool workerStopping_ = false; + bool workerBusy_ = false; + std::atomic workerFailed_{false}; + std::atomic droppedFrameCount_{0}; }; diff --git a/electron/native/wgc-capture/src/wgc_session.cpp b/electron/native/wgc-capture/src/wgc_session.cpp index 1dde80815..6e7c6c0fe 100644 --- a/electron/native/wgc-capture/src/wgc_session.cpp +++ b/electron/native/wgc-capture/src/wgc_session.cpp @@ -335,8 +335,9 @@ void WgcSession::onFrameArrived( 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 rate limiting: tolerate a little timestamp jitter without allowing + // bursts substantially above the requested rate. + if (lastFrameTimeHns_ > 0 && (frameTimeHns - lastFrameTimeHns_) < (frameIntervalHns_ * 9 / 10)) { frame.Close(); return; }