From c07750c0efe37dd57f868b6e2051ea4e99d12ccc Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Wed, 11 Nov 2026 21:10:59 +0100 Subject: [PATCH] fix(windows): write a key frame every second in captures The helper left the H.264 GOP at 0, which lets each vendor's encoder choose. Some emit a single key frame at the start of the take. The editor seeks to the key frame before its target and decodes forward, so on such a file every seek, cut and pause decodes from the start: a seek 8 minutes into a 10-minute 1080p60 take measured 78.6 s, against 0.44 s with a key frame every second. Set CODECAPI_AVEncMPVGOPSize to the frame rate, for the screen and the camera. A helper test checks the spacing on both encoders. Refs #1475 --- .../native/wgc-capture/src/mf_encoder.cpp | 9 ++- .../wgc-capture/src/mf_encoder_color_test.cpp | 57 +++++++++++++++++++ 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/electron/native/wgc-capture/src/mf_encoder.cpp b/electron/native/wgc-capture/src/mf_encoder.cpp index 09788bde1f..21a400b420 100644 --- a/electron/native/wgc-capture/src/mf_encoder.cpp +++ b/electron/native/wgc-capture/src/mf_encoder.cpp @@ -991,9 +991,16 @@ bool MFEncoder::initialize( // composition offsets are what broke the fragmented writer on macOS. // Passed as encoding parameters because the encoder only reads this // before its types are set: ICodecAPI::SetValue afterwards is refused. + // + // A key frame every second, set rather than left at the default GOP of 0, + // which lets each vendor's encoder choose; some emit a single key frame + // at the start. The editor seeks to the key frame before the target and + // decodes forward, so on such a file every seek decodes from the start + // of the take: minutes on a 10-minute take (#1475). Microsoft::WRL::ComPtr encodingParameters; - if (SUCCEEDED(MFCreateAttributes(&encodingParameters, 1))) { + if (SUCCEEDED(MFCreateAttributes(&encodingParameters, 2))) { encodingParameters->SetUINT32(CODECAPI_AVEncMPVDefaultBPictureCount, 0); + encodingParameters->SetUINT32(CODECAPI_AVEncMPVGOPSize, static_cast(fps_)); } if (!succeeded( sinkWriter_->SetInputMediaType(videoStreamIndex_, inputType.Get(), encodingParameters.Get()), diff --git a/electron/native/wgc-capture/src/mf_encoder_color_test.cpp b/electron/native/wgc-capture/src/mf_encoder_color_test.cpp index d9278b4066..abae153f33 100644 --- a/electron/native/wgc-capture/src/mf_encoder_color_test.cpp +++ b/electron/native/wgc-capture/src/mf_encoder_color_test.cpp @@ -418,6 +418,61 @@ void checkSampleTimesKept(ID3D11Device* device, ID3D11DeviceContext* context, bo } // namespace +// A key frame at least every second, whatever the encoder's own default GOP: +// the editor seeks to the key frame before its target and decodes forward, so +// a take with a single key frame made every seek decode from its start (#1475). +void checkKeyFrameInterval(ID3D11Device* device, ID3D11DeviceContext* context, bool software) { + const std::string label = software ? "software" : "default"; + char tempDir[MAX_PATH]{}; + GetTempPathA(MAX_PATH, tempDir); + const std::string path = std::string(tempDir) + "openscreen-mf-encoder-gop-" + label + ".mp4"; + DeleteFileA(path.c_str()); + + constexpr int kFps = 30; + constexpr int kFrames = kFps * 4; + { + std::vector bgra(static_cast(kWidth) * kHeight * 4, 0); + const BgraFrameView frame{bgra.data(), kWidth, kHeight}; + MFEncoder encoder; + MFEncoderOptions options; + options.preferSoftwareEncoder = software; + if (!encoder.initialize(widen(path), kWidth, kHeight, kFps, 2'000'000, device, context, nullptr, options)) { + expect("key-frame-interval-initialize-" + label, false, "initialize failed"); + return; + } + bool wrote = true; + for (int i = 0; i < kFrames && wrote; i += 1) { + // A moving picture, so no encoder can treat the take as one still. + std::fill(bgra.begin(), bgra.end(), static_cast((i * 7) % 256)); + Microsoft::WRL::ComPtr sample; + wrote = encoder.captureBgraSample(frame, static_cast(i) * 10'000'000 / kFps, sample) && + encoder.submitVideoSample(sample.Get()); + } + expect("key-frame-interval-writes-" + label, wrote && encoder.finalize(), "write or finalize failed"); + } + + const std::string probe = + run("ffprobe -v error -select_streams v:0 -show_entries packet=flags -of csv=p=0 \"" + path + "\""); + int frames = 0; + int sinceKey = 0; + int longestRun = 0; + for (size_t at = 0; at < probe.size();) { + const size_t end = probe.find_first_of("\r\n", at); + const std::string line = probe.substr(at, end == std::string::npos ? std::string::npos : end - at); + if (!line.empty()) { + frames += 1; + sinceKey = line[0] == 'K' ? 1 : sinceKey + 1; + longestRun = std::max(longestRun, sinceKey); + } + at = end == std::string::npos ? probe.size() : end + 1; + } + std::cout << "GOP " << label << " frames=" << frames << " longest run without a key frame=" << longestRun + << "\n"; + expect("key-frame-interval-" + label, frames == kFrames && longestRun <= kFps, + std::to_string(longestRun) + " frames between key frames"); + DeleteFileA(path.c_str()); +} + int main() { checkConverterAgainstReference(); if (!toolsAvailable()) { @@ -446,6 +501,8 @@ int main() { checkRepeatedFrames(device.Get(), context.Get()); checkSampleTimesKept(device.Get(), context.Get(), true); checkSampleTimesKept(device.Get(), context.Get(), false); + checkKeyFrameInterval(device.Get(), context.Get(), true); + checkKeyFrameInterval(device.Get(), context.Get(), false); std::cout << "ran " << g_ran << " tests\n"; if (g_failed != 0) {