diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 8596dfaf..0f20dd56 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -1353,6 +1353,12 @@ function readNativeWindowsEncoderSelection(output: string) { // which is what `salvageNativeWindowsFragmentedCapture` asks. container?: string; preferSoftwareEncoder?: boolean; + // Whether BeginWriting() actually landed on a hardware H.264 MFT, as + // opposed to `video` above, which only says which configuration path + // was tried. "default" plus a software runtime means the machine never + // got hardware acceleration in the first place -- see + // kVideoEncoderRuntime* in mf_encoder.h. + videoEncoderRuntime?: string; }; } catch { return null; @@ -2531,6 +2537,7 @@ export function registerIpcHandlers( path: outputPath, helperPath, videoEncoderSelection: encoderSelection?.video ?? null, + videoEncoderRuntime: encoderSelection?.videoEncoderRuntime ?? null, webcamUnavailable, microphoneDefaulted, }; diff --git a/electron/native/wgc-capture/src/main.cpp b/electron/native/wgc-capture/src/main.cpp index 1479bc84..c64d7970 100644 --- a/electron/native/wgc-capture/src/main.cpp +++ b/electron/native/wgc-capture/src/main.cpp @@ -865,7 +865,14 @@ int main(int argc, char* argv[]) { << "\",\"container\":\"" << encoder.containerFormat() << "\",\"preferSoftwareEncoder\":" << (config.preferSoftwareEncoder ? "true" : "false") - << "}" << std::endl; + // What BeginWriting() actually landed on, not what the "video" + // field above asked for -- see kVideoEncoderRuntime* in + // mf_encoder.h. "default" plus "software" here means the machine + // never got a hardware encoder in the first place, which is a + // different bug report than "default" plus "hardware" stalling + // on stop. + << ",\"videoEncoderRuntime\":\"" << encoder.videoEncoderRuntime() + << "\"}" << std::endl; MFEncoder webcamEncoder; if (writeSeparateWebcam) { MFEncoderOptions webcamEncoderOptions = encoderOptions; diff --git a/electron/native/wgc-capture/src/mf_encoder.cpp b/electron/native/wgc-capture/src/mf_encoder.cpp index 4058ca24..c1e5c2bb 100644 --- a/electron/native/wgc-capture/src/mf_encoder.cpp +++ b/electron/native/wgc-capture/src/mf_encoder.cpp @@ -140,7 +140,7 @@ enum class SinkWriterCreateStage { SoftwareEncoderRegistration, CreateAttributes, DisableHardwareTransforms, - ConfigureDxgiManager, + EnableHardwareTransforms, CreateFile, CreateFragmentedMediaSink, CreateSinkWriter, @@ -248,10 +248,30 @@ HRESULT createSinkWriter( failedStage = SinkWriterCreateStage::DisableHardwareTransforms; return hr; } - } else if (dxgiDeviceManager != nullptr) { - HRESULT hr = MFCreateAttributes(&attributes, 3); + } else { + // Ask for hardware transforms whenever software is not forced -- + // whether or not a DXGI device manager came with the request. + // MF_READWRITE_ENABLE_HARDWARE_TRANSFORMS defaults to FALSE, and + // leaving it unset (the old behaviour on the plain CPU-readback path) + // meant the sink writer never considered a hardware H.264 MFT even + // when one was registered and working: every "default" recording + // landed on the same software encoder forceSoftwareEncoder asks for + // explicitly, on any machine that had not separately opted into + // OPENSCREEN_WGC_ENABLE_DXGI_INPUT (getopenscreen/openscreen#460, + // confirmed by videoEncoderRuntime on real hardware: "default" read + // back "software" until the DXGI path was turned on, on a machine + // whose encoder is hardware-capable either way). + // + // A hardware MFT does not require the D3D manager to accept samples: + // without one it manages its own device and takes system-memory + // samples the same way the software encoder does, which is exactly + // the CPU-readback path this branch also serves. So the attribute is + // set unconditionally here; only the manager itself stays behind the + // null check, since supplying a manager the caller does not have would + // be undefined rather than merely declined. + HRESULT hr = MFCreateAttributes(&attributes, dxgiDeviceManager != nullptr ? 3 : 1); if (FAILED(hr)) { - std::cerr << "ERROR: MFCreateAttributes(DXGI sink writer) failed (hr=0x" + std::cerr << "ERROR: MFCreateAttributes(sink writer) failed (hr=0x" << std::hex << hr << std::dec << ")" << std::endl; failedStage = SinkWriterCreateStage::CreateAttributes; return hr; @@ -260,15 +280,17 @@ HRESULT createSinkWriter( if (FAILED(hr)) { std::cerr << "ERROR: Set MF_READWRITE_ENABLE_HARDWARE_TRANSFORMS(TRUE) failed (hr=0x" << std::hex << hr << std::dec << ")" << std::endl; - failedStage = SinkWriterCreateStage::ConfigureDxgiManager; + failedStage = SinkWriterCreateStage::EnableHardwareTransforms; return hr; } - hr = attributes->SetUnknown(MF_SINK_WRITER_D3D_MANAGER, dxgiDeviceManager); - if (FAILED(hr)) { - std::cerr << "ERROR: Set MF_SINK_WRITER_D3D_MANAGER failed (hr=0x" - << std::hex << hr << std::dec << ")" << std::endl; - failedStage = SinkWriterCreateStage::ConfigureDxgiManager; - return hr; + if (dxgiDeviceManager != nullptr) { + hr = attributes->SetUnknown(MF_SINK_WRITER_D3D_MANAGER, dxgiDeviceManager); + if (FAILED(hr)) { + std::cerr << "ERROR: Set MF_SINK_WRITER_D3D_MANAGER failed (hr=0x" + << std::hex << hr << std::dec << ")" << std::endl; + failedStage = SinkWriterCreateStage::EnableHardwareTransforms; + return hr; + } } } @@ -382,6 +404,68 @@ bool resolveStreamSinkIndex(IMFMediaSink* mediaSink, const GUID& majorType, DWOR return false; } +// Did the video stream's encoder MFT actually land on hardware? +// +// BeginWriting() succeeding says nothing about this: on the "default" path +// (see kVideoEncoderRuntime* in mf_encoder.h) no attribute asked for hardware +// transforms, so Media Foundation is free to hand the sink writer a software +// MFT even when a hardware one is registered and would have worked. The only +// way to know which one it actually picked is to ask the pipeline it built, +// after the fact -- IMFSinkWriterEx::GetTransformForStream walks the MFTs the +// sink writer inserted for a stream, and a hardware MFT instance is required +// to expose MFT_ENUM_HARDWARE_URL_Attribute on its own attribute store (not +// just on the IMFActivate MFTEnumEx returns), which is what distinguishes it +// from a software one at this point. +// +// Every failure path here returns "unknown" rather than guessing: this runs +// after the sink writer is already committed to, so it must never be able to +// fail configureSinkWriterAttempt, and a wrong hardware/software guess in a +// bug report would be worse than an admitted "could not tell." +const char* detectVideoEncoderRuntime(IMFSinkWriter* sinkWriter, DWORD videoStreamIndex) { + Microsoft::WRL::ComPtr sinkWriterEx; + if (FAILED(sinkWriter->QueryInterface(IID_PPV_ARGS(&sinkWriterEx)))) { + return kVideoEncoderRuntimeUnknown; + } + + for (DWORD mftIndex = 0;; mftIndex += 1) { + GUID category{}; + Microsoft::WRL::ComPtr transform; + const HRESULT hr = + sinkWriterEx->GetTransformForStream(videoStreamIndex, mftIndex, &category, &transform); + if (hr == MF_E_INVALIDINDEX) { + // Walked the whole pipeline (converters, the encoder, anything + // else the topology loader inserted) without finding an encoder + // node. Should not happen -- an H.264 stream has to have one -- + // but this is diagnostics code, not the recording path, so an + // unexpected shape is "unknown", not a crash. + return kVideoEncoderRuntimeUnknown; + } + if (FAILED(hr)) { + return kVideoEncoderRuntimeUnknown; + } + if (category != MFT_CATEGORY_VIDEO_ENCODER) { + // A colour converter or similar the sink writer inserted ahead of + // the encoder. Keep walking; the encoder is further down. + continue; + } + + Microsoft::WRL::ComPtr transformAttributes; + if (FAILED(transform->GetAttributes(&transformAttributes))) { + return kVideoEncoderRuntimeUnknown; + } + UINT32 hardwareUrlLength = 0; + const HRESULT hardwareUrlHr = + transformAttributes->GetStringLength(MFT_ENUM_HARDWARE_URL_Attribute, &hardwareUrlLength); + if (SUCCEEDED(hardwareUrlHr)) { + return kVideoEncoderRuntimeHardware; + } + if (hardwareUrlHr == MF_E_ATTRIBUTENOTFOUND) { + return kVideoEncoderRuntimeSoftware; + } + return kVideoEncoderRuntimeUnknown; + } +} + void logSinkWriterCreateFailure( HRESULT sinkWriterHr, const char* createCall, @@ -513,6 +597,10 @@ const char* MFEncoder::videoEncoderSelection() const { return videoEncoderSelection_; } +const char* MFEncoder::videoEncoderRuntime() const { + return videoEncoderRuntime_; +} + const char* MFEncoder::containerFormat() const { return containerFormat_; } @@ -600,6 +688,7 @@ bool MFEncoder::initialize( // encoder, never reaching the software encoder the knob is aimed at. useDxgiInput_ = options.useDxgiInput && !options.injectDefaultSinkWriterFailureOnce; videoEncoderSelection_ = kVideoEncoderSelectionDefault; + videoEncoderRuntime_ = kVideoEncoderRuntimeUnknown; if (!succeeded(MFStartup(MF_VERSION), "MFStartup")) { return false; @@ -689,6 +778,7 @@ bool MFEncoder::initialize( audioStreamIndex_ = 0; hasAudioStream_ = false; videoEncoderSelection_ = kVideoEncoderSelectionDefault; + videoEncoderRuntime_ = kVideoEncoderRuntimeUnknown; containerFormat_ = kContainerFormatMp4; }; @@ -780,7 +870,7 @@ bool MFEncoder::initialize( "SetInputMediaType")) { return false; } - if (useDxgiInput_) { + if (!forceSoftwareEncoder) { applyHardwareRateControl(std::max(1, bitrate)); } if (!succeeded(sinkWriter_->BeginWriting(), "BeginWriting")) { @@ -788,6 +878,7 @@ bool MFEncoder::initialize( } videoEncoderSelection_ = selection; + videoEncoderRuntime_ = detectVideoEncoderRuntime(sinkWriter_.Get(), videoStreamIndex_); containerFormat_ = fragmented ? kContainerFormatFragmentedMp4 : kContainerFormatMp4; return true; }; @@ -1204,12 +1295,17 @@ bool MFEncoder::initializeVideoProcessor() { } void MFEncoder::applyHardwareRateControl(int bitrate) { - // The D3D manager switches the sink writer onto a hardware MFT, and those - // default to constant bitrate: a static desktop then spends the full - // configured budget doing nothing, 16.9 Mbps measured against the 1.95 the - // software encoder the CPU path lands on produced for the same screen. Same - // budget, opposite reading of it. Ask for VBR so the GPU path spends what - // the picture costs, which is what users have been getting all along. + // Enabling MF_READWRITE_ENABLE_HARDWARE_TRANSFORMS can hand the sink + // writer a hardware MFT, and those default to constant bitrate: a static + // desktop then spends the full configured budget doing nothing, 16.9 Mbps + // measured against the 1.95 the software encoder produced for the same + // screen. Same budget, opposite reading of it. Ask for VBR so a hardware + // encoder spends what the picture costs, which is what the software + // encoder was already doing. Called whenever hardware transforms were + // requested, DXGI device manager or not (getopenscreen/openscreen#460) -- + // whether the sink writer actually landed on hardware is not knowable + // until after BeginWriting() (see MFEncoder::videoEncoderRuntime()), and + // this call is a no-op on a software MFT that ignores or lacks the knob. // // Best effort on purpose. An encoder that exposes neither knob still // produces a valid recording, and a bitrate we could not pin down is not diff --git a/electron/native/wgc-capture/src/mf_encoder.h b/electron/native/wgc-capture/src/mf_encoder.h index f8370874..19fac700 100644 --- a/electron/native/wgc-capture/src/mf_encoder.h +++ b/electron/native/wgc-capture/src/mf_encoder.h @@ -42,6 +42,28 @@ constexpr const char* kVideoEncoderSelectionDefault = "default"; constexpr const char* kVideoEncoderSelectionSoftwarePreferred = "software-preferred"; constexpr const char* kVideoEncoderSelectionSoftwareFallback = "software-fallback"; +// Whether BeginWriting() actually landed on a hardware-accelerated H.264 MFT. +// +// videoEncoderSelection() above says which *path* initialize() took -- +// whether the DXGI GPU pipeline was asked for, or software was forced -- but +// none of those labels says what Media Foundation itself picked, and that +// matters even now that createSinkWriter asks for hardware transforms on +// every path but the forced-software one: MF_READWRITE_ENABLE_HARDWARE_TRANSFORMS +// asks, it does not guarantee -- a machine with no hardware H.264 MFT +// registered, or one whose driver refuses it, still lands on software. That +// gap is exactly what this exists to close for a bug report: "default" alone +// cannot tell a real hardware encode apart from software Media Foundation +// picked anyway, which was the whole ambiguity behind a slow-CPU stop timeout +// (getopenscreen/openscreen#460) before this field existed. +constexpr const char* kVideoEncoderRuntimeHardware = "hardware"; +constexpr const char* kVideoEncoderRuntimeSoftware = "software"; +// Introspection itself failed (no IMFSinkWriterEx, no encoder node found in +// the resolved topology, GetAttributes refused). Reported as its own value +// rather than guessed into hardware or software, because a bug report that +// cannot tell "we checked and it's software" from "we couldn't check" would +// draw the wrong conclusion either way. +constexpr const char* kVideoEncoderRuntimeUnknown = "unknown"; + // Which MP4 flavour the recording was actually written in. The fragmented sink // writes a self-describing moof+mdat pair roughly every second, so a helper the // shutdown watchdog force-exits leaves a file that plays up to the last @@ -97,6 +119,9 @@ class MFEncoder { bool writeAudio(const BYTE* data, DWORD byteCount, int64_t timestampHns, int64_t durationHns); bool finalize(); const char* videoEncoderSelection() const; + // Best-effort, read only after initialize() returns true. See the + // kVideoEncoderRuntime* constants above for what each value means. + const char* videoEncoderRuntime() const; // Which container initialize() settled on, which is not necessarily the one // it asked for: the fragmented sink degrades to the plain one rather than // failing a recording. A bug report that cannot tell the two apart cannot @@ -202,5 +227,6 @@ class MFEncoder { bool finalized_ = false; bool useDxgiInput_ = false; const char* videoEncoderSelection_ = kVideoEncoderSelectionDefault; + const char* videoEncoderRuntime_ = kVideoEncoderRuntimeUnknown; const char* containerFormat_ = kContainerFormatMp4; }; diff --git a/scripts/test-windows-wgc-helper.mjs b/scripts/test-windows-wgc-helper.mjs index e1dc4814..1e513c72 100644 --- a/scripts/test-windows-wgc-helper.mjs +++ b/scripts/test-windows-wgc-helper.mjs @@ -554,6 +554,34 @@ if ( `WGC helper encoder selection was ${JSON.stringify(encoderSelection)}, expected ${expectedEncoderSelection} with preferSoftwareEncoder=${WITH_SOFTWARE_ENCODER}: ${result.stdout}`, ); } +// videoEncoderRuntime is separate from `video` above: it is what +// GetTransformForStream found in the sink writer's own resolved pipeline +// after BeginWriting(), not which configuration path was tried. "unknown" +// here on a run that otherwise passed means the introspection itself is +// broken (wrong COM call, wrong category, wrong attribute), not a real +// ambiguity -- a healthy sink writer always has exactly one encoder node. +if (!["hardware", "software", "unknown"].includes(encoderSelection.videoEncoderRuntime)) { + throw new Error( + `WGC helper reported an unrecognised videoEncoderRuntime: ${JSON.stringify(encoderSelection)}`, + ); +} +if (encoderSelection.videoEncoderRuntime === "unknown") { + throw new Error( + `WGC helper could not introspect its own sink writer for videoEncoderRuntime: ${JSON.stringify(encoderSelection)}`, + ); +} +// forceSoftwareEncoder disables hardware transforms explicitly +// (MF_READWRITE_ENABLE_HARDWARE_TRANSFORMS=FALSE), so this is deterministic +// regardless of what the test machine has registered -- unlike the "default" +// path, whose runtime legitimately depends on the machine. +if ( + (WITH_SOFTWARE_ENCODER || WITH_SOFTWARE_FALLBACK) && + encoderSelection.videoEncoderRuntime !== "software" +) { + throw new Error( + `WGC helper forced the software encoder but videoEncoderRuntime was ${encoderSelection.videoEncoderRuntime}, expected software: ${JSON.stringify(encoderSelection)}`, + ); +} // Every fallback path has to stay fragmented, not just the nominal one. The // helper degrades to the plain container rather than failing a recording, so // without this the fix could quietly stop applying and every other assertion diff --git a/src/lib/nativeWindowsRecording.ts b/src/lib/nativeWindowsRecording.ts index e9e8c0c5..5d5d92b3 100644 --- a/src/lib/nativeWindowsRecording.ts +++ b/src/lib/nativeWindowsRecording.ts @@ -47,6 +47,13 @@ export type NativeWindowsRecordingStartResult = { error?: string; /** Helper-reported encoder selection: "default", "software-preferred", or "software-fallback". */ videoEncoderSelection?: string | null; + /** + * Whether the helper actually landed on a hardware H.264 encoder MFT, as + * opposed to `videoEncoderSelection` above, which only says which + * configuration path was tried: "hardware", "software", or "unknown" when + * the helper could not introspect its own sink writer. + */ + videoEncoderRuntime?: string | null; /** * A camera was asked for and the helper could not open it, so this take is * screen and audio only. Still a success — the recording is worth keeping — diff --git a/technical-documentation/architecture/recording.md b/technical-documentation/architecture/recording.md index 1dc63837..bb6e143a 100644 --- a/technical-documentation/architecture/recording.md +++ b/technical-documentation/architecture/recording.md @@ -81,8 +81,9 @@ Cursor samples are persisted as cursor telemetry rather than baked into editable - A window with odd client dimensions can produce black video: H.264 encoding requires even dimensions (`electron/native/wgc-capture/src/wgc_session.cpp:38`). - The Windows helper's frame lock (`electron/native/wgc-capture/src/main.cpp`) is still held across blocking, uninterruptible D3D11 work: the WGC callback's `CopyResource`, and whatever the video writer does with the frame. A driver that stalls inside either one still costs the recording. What no longer happens is a hang: stop detection runs on `CaptureControl::stopMutex`, which no frame thread ever touches, and a shutdown watchdog force-exits the helper when a step overruns its budget, naming the step it died in. Each step gets `OPENSCREEN_WGC_STEP_BUDGET_MS` (8s by default) and that is the bound which normally fires; the whole shutdown is capped by `OPENSCREEN_WGC_STOP_BUDGET_MS` (50s by default), which the encoder-finalize step alone is allowed to spend in full because a long software-encoder finalize legitimately takes seconds (issue #34). Picking the D3D adapter that actually drives the captured monitor instead of adapter 0 is still outstanding. -- The video writer has two ways to get a frame to the encoder. Which one it uses is a setting first and a per-machine outcome second: the GPU path has to be asked for, and is then kept only if the machine supports it. The GPU path (`videoInput: "dxgi-nv12"`) copies the frame across a keyed-mutex bridge to a second D3D11 device, converts BGRA to NV12 with the D3D11 video processor, and hands the hardware H.264 encoder a DXGI sample; it never touches system memory. The CPU path (`videoInput: "cpu-rgb32"`) is the original staging-texture `Map(D3D11_MAP_READ)` readback, and is what a `Map`/`Unmap` that never returns wedges (issue #252: Windows 10, WDDM 2.7, multi-adapter). The GPU path is OFF by default; `OPENSCREEN_WGC_ENABLE_DXGI_INPUT=1` turns it on. Once asked for, it degrades to the CPU path at every check made **during initialization** — no hardware encoder, no NV12 video-processor output, no shared keyed-mutex texture, no DXGI sample allocator — so a machine it does not fit records exactly as it did before it existed. That fallback ends when the first frame arrives: a `captureDxgiSample()` failure after encoding has started stops the recording, because the sink writer is configured for NV12 by then and there is no path left to take. That gap is why the default is off, and it is what cost the reporter in #336 their recording. It is skipped outright for `preferSoftwareEncoder` and for inline webcam PiP, both of which need the frame in system memory, The two paths land on different encoders, so the GPU one asks for VBR explicitly through `ICodecAPI`: hardware MFTs default to constant bitrate and would spend the full configured budget on a static screen (measured 16.9 Mbps against 1.95 for the same desktop). +- The video writer has two ways to get a frame to the encoder. Which one it uses is a setting first and a per-machine outcome second: the GPU path has to be asked for, and is then kept only if the machine supports it. The GPU path (`videoInput: "dxgi-nv12"`) copies the frame across a keyed-mutex bridge to a second D3D11 device, converts BGRA to NV12 with the D3D11 video processor, and hands the hardware H.264 encoder a DXGI sample; it never touches system memory. The CPU path (`videoInput: "cpu-rgb32"`) is the original staging-texture `Map(D3D11_MAP_READ)` readback, and is what a `Map`/`Unmap` that never returns wedges (issue #252: Windows 10, WDDM 2.7, multi-adapter). The GPU path is OFF by default; `OPENSCREEN_WGC_ENABLE_DXGI_INPUT=1` turns it on. Once asked for, it degrades to the CPU path at every check made **during initialization** — no hardware encoder, no NV12 video-processor output, no shared keyed-mutex texture, no DXGI sample allocator — so a machine it does not fit records exactly as it did before it existed. That fallback ends when the first frame arrives: a `captureDxgiSample()` failure after encoding has started stops the recording, because the sink writer is configured for NV12 by then and there is no path left to take. That gap is why the default is off, and it is what cost the reporter in #336 their recording. It is skipped outright for `preferSoftwareEncoder` and for inline webcam PiP, both of which need the frame in system memory. Whichever path lands on a hardware MFT asks for VBR explicitly through `ICodecAPI` (`MFEncoder::applyHardwareRateControl`, called whenever hardware transforms were requested — see the `videoEncoderRuntime` gap below): hardware MFTs default to constant bitrate and would spend the full configured budget on a static screen (measured 16.9 Mbps against 1.95 for the same desktop on the GPU path). VBR keeps that in check but does not make a hardware encoder's output match a software one byte-for-byte — measured back-to-back on the same idle desktop, this machine's hardware MFT still produced roughly 5x the bytes of the software encoder even with VBR correctly engaged (8.7 Mbps vs 1.7 Mbps), which looks like a genuine rate-distortion difference between the two encoder implementations rather than a rate-control bug. Accepted as the cost of the fix below rather than tuned further, since disk is cheap and a stuck recording losing the whole take is not. - Linux/Wayland can produce no usable frames on the `getDisplayMedia` fallback because Chromium initializes Vulkan against the Ozone Wayland backend. The PipeWire helper path is unaffected. - On Linux the compositor's source picker appears on every recording. That is deliberate — see "Why Linux sends no source identity" — but it is an interruption, and there is currently no way to reuse a previous choice without also making it impossible to change. - Holding a portal session across the countdown means the compositor's "screen is being shared" indicator is up before recording begins. That is honest — access really has been granted — but the user can click it to revoke, or close the window they picked. The helper's exit surfaces as a rejected `waitUntilSourceSelected`; the session is not yet subscribed to the portal's `Session::Closed` signal, so a revocation is reported as a failed start rather than a specific message. - `preferSoftwareEncoder` is read when recording starts. The recorder has no UI for setting it; Windows also accepts `OPENSCREEN_WGC_PREFER_SOFTWARE_ENCODER=true` in the helper request path. +- The `encoder-selection` event's `video` field ("default", "software-preferred", "software-fallback") names which *configuration path* `MFEncoder::initialize` took, not which encoder Media Foundation actually picked; `videoEncoderRuntime` ("hardware", "software", or "unknown") answers that, by walking the sink writer's own transform pipeline after `BeginWriting()` for a node that self-identifies as hardware (`MFT_ENUM_HARDWARE_URL_Attribute`). It exists because of what it found (getopenscreen/openscreen#460): `MF_READWRITE_ENABLE_HARDWARE_TRANSFORMS` defaults to FALSE, and until this was fixed, leaving it unset on the "default" (non-DXGI) path meant the sink writer only ever considered software MFTs there, even on a machine with a working hardware H.264 encoder — "default" recording was, in practice, software-encoded on every machine that had not separately opted into the DXGI path above. `createSinkWriter` now sets that attribute to TRUE whenever `forceSoftwareEncoder` is false, DXGI device manager or not, so a hardware encoder is used on the "default" path when one is registered and working — which is most of the point, since it is weak-CPU machines running the software encoder that blow the stop-timeout budget in the first place. `videoEncoderRuntime` stays in the event to catch the cases that don't fit that story: a machine with no real hardware encoder falling back to software regardless, or a hardware encoder that itself turns out to be the slow or unstable one.