From 8f740f7faf36aca2c611588dfb5edc6f78d7c0f9 Mon Sep 17 00:00:00 2001 From: My-Denia <176143450+My-Denia@users.noreply.github.com> Date: Mon, 6 Jul 2026 04:49:33 +0100 Subject: [PATCH 1/6] feat(wgc-capture): fall back to software H.264 encoder Refs #18 --- electron/ipc/handlers.ts | 12 + electron/native/README.md | 6 +- electron/native/wgc-capture/src/main.cpp | 17 +- .../native/wgc-capture/src/mf_encoder.cpp | 220 +++++++++++++----- electron/native/wgc-capture/src/mf_encoder.h | 9 +- scripts/test-windows-wgc-helper.mjs | 17 ++ src/hooks/useScreenRecorder.ts | 2 + src/lib/nativeWindowsRecording.ts | 1 + src/lib/userPreferences.test.ts | 15 ++ src/lib/userPreferences.ts | 7 + 10 files changed, 244 insertions(+), 62 deletions(-) diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index d8a1ee55a..4cf19f8bf 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -1621,9 +1621,17 @@ export function registerIpcHandlers( : null; const cursorCaptureMode = normalizeCursorCaptureMode(request.cursor?.mode) ?? "editable-overlay"; + const envPreferSoftwareEncoder = ( + process.env.OPENSCREEN_WGC_PREFER_SOFTWARE_ENCODER ?? "" + ).toLowerCase(); + const preferSoftwareEncoder = + request.preferSoftwareEncoder === true || + envPreferSoftwareEncoder === "true" || + envPreferSoftwareEncoder === "1"; const config = { schemaVersion: 2, recordingId, + preferSoftwareEncoder, outputPath, sourceType: request.source.type, sourceId: request.source.sourceId, @@ -1665,6 +1673,9 @@ export function registerIpcHandlers( video: request.video, audio: request.audio, webcam: request.webcam, + encoder: { + preferSoftwareEncoder, + }, cursor: { mode: cursorCaptureMode, }, @@ -1675,6 +1686,7 @@ export function registerIpcHandlers( source: request.source, audio: request.audio, webcam: request.webcam, + encoder: { preferSoftwareEncoder }, cursor: { mode: cursorCaptureMode }, bounds, sourceId: selectedSource?.id ?? null, diff --git a/electron/native/README.md b/electron/native/README.md index cda6c9f55..8306a2458 100644 --- a/electron/native/README.md +++ b/electron/native/README.md @@ -64,6 +64,7 @@ Current V2 JSON shape: "videoWidth": 1920, "videoHeight": 1080, "fps": 60, + "preferSoftwareEncoder": false, "captureSystemAudio": false, "captureMic": false, "microphoneDeviceId": "default", @@ -83,12 +84,15 @@ Current V2 JSON shape: The current helper implementation supports display/window video capture, system audio loopback, selected-microphone capture, Media Foundation webcam capture, and a DirectShow webcam fallback for virtual cameras that are not exposed through Media Foundation. Webcam frames are currently composed into the primary MP4 as a bottom-right picture-in-picture overlay. Browser `deviceId` values do not always map to Media Foundation symbolic links or WASAPI endpoint IDs, so the renderer passes both browser IDs and user-visible device names. For microphones, the helper tries the requested WASAPI endpoint ID first, then resolves an active capture endpoint by `microphoneDeviceName`, then falls back to the default endpoint. For webcams, Electron resolves a matching DirectShow filter CLSID for the selected label; the helper uses Media Foundation first, then that exact DirectShow filter when the requested camera is absent from Media Foundation. -Encoder diagnostic on sink-writer failure: when `MFCreateSinkWriterFromURL` fails, the helper logs the registered H.264 video encoder MFT count (via `MFTEnumEx`), the registered AAC encoder count when audio was requested, and the hex HRESULT. If no H.264 encoder is registered, it additionally emits the four-bullet actionable error (missing Media Feature Pack / GPU driver registration / empty `HKLM:\SOFTWARE\Microsoft\Windows Media Foundation\Transforms` / reboot). If an H.264 encoder IS registered but the sink writer still failed, it logs a hint pointing at invalid output path, missing MP4 mux, or GPU driver incompatibility. The diagnostic runs only on the failure path — there is no pre-flight gating check, because `MFTEnumEx` and `MFCreateSinkWriterFromURL` can disagree about which H.264 encoders are "available" in non-interactive / Session 0 contexts, and a pre-flight zero-count would block a recording the sink writer can otherwise complete. +Encoder selection: by default the helper keeps the existing sink-writer path first. If that path fails while setting up H.264, it retries with the Microsoft software H.264 encoder (`mfh264enc.dll`) registered locally in the helper process and hardware transforms disabled for that retry. Set `preferSoftwareEncoder: true` in the helper JSON, or set `OPENSCREEN_WGC_PREFER_SOFTWARE_ENCODER=true` before launching Electron, to force the software path from the first attempt. + +Encoder diagnostic on final sink-writer failure: when the final `MFCreateSinkWriterFromURL` attempt fails, the helper logs the registered H.264 video encoder MFT count (via `MFTEnumEx`), the registered AAC encoder count when audio was requested, and the hex HRESULT. If no H.264 encoder is registered, it additionally emits the four-bullet actionable error (missing Media Feature Pack / GPU driver registration / empty `HKLM:\SOFTWARE\Microsoft\Windows Media Foundation\Transforms` / reboot). If an H.264 encoder IS registered but the sink writer still failed, it logs a hint pointing at invalid output path, missing MP4 mux, or GPU driver incompatibility. There is still no fail-fast pre-flight gate because `MFTEnumEx` and `MFCreateSinkWriterFromURL` can disagree about which H.264 encoders are available in non-interactive / Session 0 contexts. Smoke-test the helper with: ```powershell npm run test:wgc-helper:win +npm run test:wgc-helper:win -- --software-encoder npm run test:wgc-window:win npm run test:wgc-audio:win npm run test:wgc-mic:win diff --git a/electron/native/wgc-capture/src/main.cpp b/electron/native/wgc-capture/src/main.cpp index af73b26fc..95d9dcc74 100644 --- a/electron/native/wgc-capture/src/main.cpp +++ b/electron/native/wgc-capture/src/main.cpp @@ -40,6 +40,7 @@ struct CaptureConfig { bool captureMic = false; bool captureCursor = false; bool webcamEnabled = false; + bool preferSoftwareEncoder = false; std::string microphoneDeviceId; std::string microphoneDeviceName; double microphoneGain = 1.0; @@ -338,6 +339,7 @@ bool parseConfig(const std::string& json, CaptureConfig& config) { config.captureMic = findBool(json, "captureMic", false); config.captureCursor = findBool(json, "captureCursor", false); config.webcamEnabled = findBool(json, "webcamEnabled", false); + config.preferSoftwareEncoder = findBool(json, "preferSoftwareEncoder", false); config.microphoneDeviceId = findString(json, "microphoneDeviceId"); config.microphoneDeviceName = findString(json, "microphoneDeviceName"); config.microphoneGain = findDouble(json, "microphoneGain", 1.0); @@ -502,6 +504,9 @@ int main(int argc, char* argv[]) { << "}" << std::endl; } + MFEncoderOptions encoderOptions{}; + encoderOptions.preferSoftwareEncoder = config.preferSoftwareEncoder; + MFEncoder encoder; if (!encoder.initialize( utf8ToWide(config.outputPath), @@ -511,11 +516,16 @@ int main(int argc, char* argv[]) { bitrate, session.device(), session.context(), - audioFormat ? &encoderAudioFormat : nullptr)) { + audioFormat ? &encoderAudioFormat : nullptr, + encoderOptions)) { std::cerr << "ERROR: Failed to initialize Media Foundation encoder" << std::endl; return 1; } - + std::cout << "{\"event\":\"encoder-selection\",\"schemaVersion\":2,\"video\":\"" + << encoder.videoEncoderSelection() + << "\",\"preferSoftwareEncoder\":" + << (config.preferSoftwareEncoder ? "true" : "false") + << "}" << std::endl; MFEncoder webcamEncoder; if (writeSeparateWebcam) { const int webcamPixels = std::max(1, webcamCapture.width()) * std::max(1, webcamCapture.height()); @@ -528,7 +538,8 @@ int main(int argc, char* argv[]) { webcamBitrate, session.device(), session.context(), - nullptr)) { + nullptr, + encoderOptions)) { std::cerr << "ERROR: Failed to initialize native webcam 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 738a29b58..d1c779b96 100644 --- a/electron/native/wgc-capture/src/mf_encoder.cpp +++ b/electron/native/wgc-capture/src/mf_encoder.cpp @@ -123,6 +123,99 @@ void logMissingH264EncoderError() { << std::endl; } +constexpr const char* kVideoEncoderSelectionDefault = "default"; +constexpr const char* kVideoEncoderSelectionSoftwarePreferred = "software-preferred"; +constexpr const char* kVideoEncoderSelectionSoftwareFallback = "software-fallback"; + +HRESULT ensureSoftwareH264EncoderRegisteredForProcess() { + static std::mutex registrationMutex; + static bool attempted = false; + static HRESULT result = E_FAIL; + + std::scoped_lock lock(registrationMutex); + if (attempted) { + return result; + } + attempted = true; + + MFT_REGISTER_TYPE_INFO inputType{}; + inputType.guidMajorType = MFMediaType_Video; + inputType.guidSubtype = GUID_NULL; + + MFT_REGISTER_TYPE_INFO outputType{}; + outputType.guidMajorType = MFMediaType_Video; + outputType.guidSubtype = MFVideoFormat_H264; + + result = MFTRegisterLocalByCLSID( + CLSID_MSH264EncoderMFT, + MFT_CATEGORY_VIDEO_ENCODER, + L"Microsoft H.264 Encoder MFT (software)", + MFT_ENUM_FLAG_SYNCMFT, + 1, + &inputType, + 1, + &outputType); + if (FAILED(result)) { + std::cerr << "ERROR: MFTRegisterLocalByCLSID(CLSID_MSH264EncoderMFT) failed (hr=0x" + << std::hex << result << std::dec << ")" << std::endl; + } + return result; +} + +HRESULT createSinkWriterFromUrl( + const std::wstring& outputPath, + bool forceSoftwareEncoder, + Microsoft::WRL::ComPtr& sinkWriter) { + Microsoft::WRL::ComPtr attributes; + if (forceSoftwareEncoder) { + const HRESULT registerHr = ensureSoftwareH264EncoderRegisteredForProcess(); + if (FAILED(registerHr)) { + return registerHr; + } + + HRESULT hr = MFCreateAttributes(&attributes, 1); + if (FAILED(hr)) { + std::cerr << "ERROR: MFCreateAttributes(sink writer) failed (hr=0x" + << std::hex << hr << std::dec << ")" << std::endl; + return hr; + } + hr = attributes->SetUINT32(MF_READWRITE_ENABLE_HARDWARE_TRANSFORMS, FALSE); + if (FAILED(hr)) { + std::cerr << "ERROR: Set MF_READWRITE_ENABLE_HARDWARE_TRANSFORMS failed (hr=0x" + << std::hex << hr << std::dec << ")" << std::endl; + return hr; + } + } + + return MFCreateSinkWriterFromURL( + outputPath.c_str(), nullptr, attributes.Get(), &sinkWriter); +} + +void logSinkWriterCreateFailure(HRESULT sinkWriterHr, const AudioInputFormat* audioFormat) { + const UINT32 h264EncoderCount = countRegisteredH264VideoEncoders(); + const UINT32 aacEncoderCount = (audioFormat != nullptr) + ? countRegisteredAacAudioEncoders() + : 0; + std::cerr << "ERROR: MFCreateSinkWriterFromURL failed (hr=0x" + << std::hex << sinkWriterHr << std::dec << ")" << std::endl; + std::cerr << " Registered H.264 video encoder MFTs: " << h264EncoderCount + << std::endl; + if (audioFormat != nullptr) { + std::cerr << " Registered AAC audio encoder MFTs: " << aacEncoderCount + << std::endl; + } + if (h264EncoderCount == 0) { + logMissingH264EncoderError(); + } else { + std::cerr + << " An H.264 encoder MFT is registered but the sink writer " + << "still failed. Possible causes: invalid output path or " + << "permissions, no MP4 mux configured, or GPU driver " + << "incompatibility with this Media Foundation build." + << std::endl; + } +} + void setFrameSize(IMFMediaType* type, UINT32 width, UINT32 height) { MFSetAttributeSize(type, MF_MT_FRAME_SIZE, width, height); } @@ -184,6 +277,10 @@ MFEncoder::~MFEncoder() { finalize(); } +const char* MFEncoder::videoEncoderSelection() const { + return videoEncoderSelection_; +} + bool MFEncoder::initialize( const std::wstring& outputPath, int width, @@ -192,21 +289,14 @@ bool MFEncoder::initialize( int bitrate, ID3D11Device* device, ID3D11DeviceContext* context, - const AudioInputFormat* audioFormat) { + const AudioInputFormat* audioFormat, + MFEncoderOptions options) { width_ = (std::max(2, width) / 2) * 2; height_ = (std::max(2, height) / 2) * 2; fps_ = std::max(1, fps); device_ = device; context_ = context; - - // No H.264 encoder pre-flight check here. MFTEnumEx and - // MFCreateSinkWriterFromURL can disagree about which H.264 encoders are - // "available" in non-interactive / Session 0 contexts (NVENC et al. are - // registered but their COM server may not fully activate from a service - // session). A pre-flight that fails fast on a zero MFTEnumEx count would - // therefore break a recording that the sink writer can complete - // successfully. The diagnostic dump below runs only on the failure path, - // so a healthy MFTEnumEx result never blocks a working recording. + videoEncoderSelection_ = kVideoEncoderSelectionDefault; if (!succeeded(MFStartup(MF_VERSION), "MFStartup")) { return false; @@ -224,47 +314,6 @@ bool MFEncoder::initialize( setFrameRate(outputType.Get(), static_cast(fps_)); setPixelAspectRatio(outputType.Get()); - HRESULT sinkWriterHr = MFCreateSinkWriterFromURL( - outputPath.c_str(), nullptr, nullptr, &sinkWriter_); - if (FAILED(sinkWriterHr)) { - // The HRESULT alone is not actionable. Tell the user whether an H.264 - // encoder is registered at all (no H.264 == the MFP missing-MFT path), - // whether AAC is registered (only when audio is requested), and the - // hex HRESULT so the exact failure is greppable. Encoder counts are - // computed lazily here, only on the failure path, so a healthy - // recording does not pay for an MFTEnumEx call. - const UINT32 h264EncoderCount = countRegisteredH264VideoEncoders(); - const UINT32 aacEncoderCount = (audioFormat != nullptr) - ? countRegisteredAacAudioEncoders() - : 0; - std::cerr << "ERROR: MFCreateSinkWriterFromURL failed (hr=0x" - << std::hex << sinkWriterHr << std::dec << ")" << std::endl; - std::cerr << " Registered H.264 video encoder MFTs: " << h264EncoderCount - << std::endl; - if (audioFormat != nullptr) { - std::cerr << " Registered AAC audio encoder MFTs: " << aacEncoderCount - << std::endl; - } - if (h264EncoderCount == 0) { - logMissingH264EncoderError(); - } else { - std::cerr - << " An H.264 encoder MFT is registered but the sink writer " - << "still failed. Possible causes: invalid output path or " - << "permissions, no MP4 mux configured, or GPU driver " - << "incompatibility with this Media Foundation build." - << std::endl; - } - return false; - } - if (!succeeded(sinkWriter_->AddStream(outputType.Get(), &videoStreamIndex_), "AddStream")) { - return false; - } - - if (audioFormat && !configureAudioStream(*audioFormat)) { - return false; - } - Microsoft::WRL::ComPtr inputType; if (!succeeded(MFCreateMediaType(&inputType), "MFCreateMediaType(input)")) { return false; @@ -277,15 +326,72 @@ bool MFEncoder::initialize( setFrameRate(inputType.Get(), static_cast(fps_)); setPixelAspectRatio(inputType.Get()); - if (!succeeded(sinkWriter_->SetInputMediaType(videoStreamIndex_, inputType.Get(), nullptr), - "SetInputMediaType")) { - return false; + auto resetSinkWriterAttempt = [&]() { + sinkWriter_.Reset(); + videoStreamIndex_ = 0; + audioStreamIndex_ = 0; + hasAudioStream_ = false; + videoEncoderSelection_ = kVideoEncoderSelectionDefault; + }; + + auto configureSinkWriterAttempt = [&, audioFormat]( + bool forceSoftwareEncoder, + const char* selection, + bool logCreateFailure) { + resetSinkWriterAttempt(); + + const HRESULT sinkWriterHr = createSinkWriterFromUrl( + outputPath, + forceSoftwareEncoder, + sinkWriter_); + if (FAILED(sinkWriterHr)) { + if (logCreateFailure) { + logSinkWriterCreateFailure(sinkWriterHr, audioFormat); + } else { + std::cerr << "WARNING: Default MFCreateSinkWriterFromURL failed (hr=0x" + << std::hex << sinkWriterHr << std::dec << ")" << std::endl; + } + return false; + } + if (!succeeded(sinkWriter_->AddStream(outputType.Get(), &videoStreamIndex_), "AddStream")) { + return false; + } + + if (audioFormat && !configureAudioStream(*audioFormat)) { + return false; + } + + if (!succeeded(sinkWriter_->SetInputMediaType(videoStreamIndex_, inputType.Get(), nullptr), + "SetInputMediaType")) { + return false; + } + if (!succeeded(sinkWriter_->BeginWriting(), "BeginWriting")) { + return false; + } + + videoEncoderSelection_ = selection; + return true; + }; + + if (options.preferSoftwareEncoder) { + return configureSinkWriterAttempt( + true, + kVideoEncoderSelectionSoftwarePreferred, + true); } - if (!succeeded(sinkWriter_->BeginWriting(), "BeginWriting")) { - return false; + + if (configureSinkWriterAttempt(false, kVideoEncoderSelectionDefault, false)) { + return true; } - return true; + std::cerr + << "WARNING: Default Media Foundation H.264 encoder setup failed; " + << "retrying with the Microsoft software H.264 encoder." + << std::endl; + return configureSinkWriterAttempt( + true, + kVideoEncoderSelectionSoftwareFallback, + true); } bool MFEncoder::configureAudioStream(const AudioInputFormat& audioFormat) { diff --git a/electron/native/wgc-capture/src/mf_encoder.h b/electron/native/wgc-capture/src/mf_encoder.h index e7821e910..22d676bb9 100644 --- a/electron/native/wgc-capture/src/mf_encoder.h +++ b/electron/native/wgc-capture/src/mf_encoder.h @@ -26,6 +26,10 @@ struct AudioInputFormat { UINT32 avgBytesPerSec = 0; }; +struct MFEncoderOptions { + bool preferSoftwareEncoder = false; +}; + class MFEncoder { public: MFEncoder() = default; @@ -42,11 +46,13 @@ class MFEncoder { int bitrate, ID3D11Device* device, ID3D11DeviceContext* context, - const AudioInputFormat* audioFormat = nullptr); + const AudioInputFormat* audioFormat = nullptr, + MFEncoderOptions options = {}); bool writeFrame(ID3D11Texture2D* texture, int64_t timestampHns, const BgraFrameView* webcamFrame = nullptr); bool writeBgraFrame(const BgraFrameView& frame, int64_t timestampHns); bool writeAudio(const BYTE* data, DWORD byteCount, int64_t timestampHns, int64_t durationHns); bool finalize(); + const char* videoEncoderSelection() const; private: bool ensureStagingTexture(ID3D11Texture2D* texture); @@ -72,4 +78,5 @@ class MFEncoder { int64_t firstTimestampHns_ = -1; int64_t lastTimestampHns_ = -1; bool finalized_ = false; + const char* videoEncoderSelection_ = "default"; }; diff --git a/scripts/test-windows-wgc-helper.mjs b/scripts/test-windows-wgc-helper.mjs index 5dd2dccf2..d5ba55409 100644 --- a/scripts/test-windows-wgc-helper.mjs +++ b/scripts/test-windows-wgc-helper.mjs @@ -26,6 +26,9 @@ const WITH_WEBCAM = const CAPTURE_CURSOR = process.env.OPENSCREEN_WGC_TEST_CAPTURE_CURSOR === "true" || process.argv.includes("--capture-cursor"); +const WITH_SOFTWARE_ENCODER = + process.env.OPENSCREEN_WGC_TEST_SOFTWARE_ENCODER === "true" || + process.argv.includes("--software-encoder"); function runHelper(config) { return new Promise((resolve, reject) => { @@ -237,6 +240,7 @@ const fixtureWindow = WITH_WINDOW ? await startFixtureWindow() : null; const config = { schemaVersion: 2, recordingId: Date.now(), + preferSoftwareEncoder: WITH_SOFTWARE_ENCODER, outputPath, sourceType: fixtureWindow ? "window" : "display", sourceId: fixtureWindow ? fixtureWindow.sourceId : "screen:0:0", @@ -314,6 +318,10 @@ const cursorCaptureLine = result.stdout .split(/\r?\n/) .find((line) => line.includes('"event":"cursor-capture"')); const cursorCapture = cursorCaptureLine ? JSON.parse(cursorCaptureLine) : null; +const encoderSelectionLine = result.stdout + .split(/\r?\n/) + .find((line) => line.includes('"event":"encoder-selection"')); +const encoderSelection = encoderSelectionLine ? JSON.parse(encoderSelectionLine) : null; const nativeWebcamDiagnostics = result.stderr .split(/\r?\n/) .filter((line) => line.includes("Native webcam candidate")); @@ -339,6 +347,14 @@ if ( `WGC helper did not apply requested cursor capture mode (${CAPTURE_CURSOR}): ${result.stdout}`, ); } +if ( + WITH_SOFTWARE_ENCODER && + (!encoderSelection || + typeof encoderSelection.video !== "string" || + !encoderSelection.video.startsWith("software")) +) { + throw new Error(`WGC helper did not use the requested software encoder path: ${result.stdout}`); +} if ((WITH_SYSTEM_AUDIO || WITH_MICROPHONE) && !hasAudio) { throw new Error(`WGC helper output has no audio stream: ${outputPath}`); } @@ -375,6 +391,7 @@ console.log( duration: stream.duration, })), cursorCapture, + encoderSelection, selectedMicrophoneDeviceName: audioFormat?.microphoneDeviceName, selectedWebcamDeviceName: webcamFormat?.deviceName, nativeMicrophoneDiagnostics, diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index ce0e0b77f..b81017890 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -13,6 +13,7 @@ import { } from "@/lib/nativeWindowsRecording"; import type { CursorCaptureMode, RecordedVideoAssetInput } from "@/lib/recordingSession"; import { requestCameraAccess } from "@/lib/requestCameraAccess"; +import { loadUserPreferences } from "@/lib/userPreferences"; import { createRecorderHandle, type RecorderHandle } from "./recorderHandle"; const TARGET_FRAME_RATE = 60; @@ -827,6 +828,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { } const request: NativeWindowsRecordingRequest = { recordingId: activeRecordingId, + preferSoftwareEncoder: loadUserPreferences().preferSoftwareEncoder, source: { type: sourceType, sourceId: selectedSource.id, diff --git a/src/lib/nativeWindowsRecording.ts b/src/lib/nativeWindowsRecording.ts index 5e0685171..dd668b010 100644 --- a/src/lib/nativeWindowsRecording.ts +++ b/src/lib/nativeWindowsRecording.ts @@ -2,6 +2,7 @@ export type NativeWindowsSourceType = "display" | "window"; export type NativeWindowsRecordingRequest = { recordingId?: number; + preferSoftwareEncoder?: boolean; source: { type: NativeWindowsSourceType; sourceId: string; diff --git a/src/lib/userPreferences.test.ts b/src/lib/userPreferences.test.ts index 8a64295c5..ac3d901a9 100644 --- a/src/lib/userPreferences.test.ts +++ b/src/lib/userPreferences.test.ts @@ -103,4 +103,19 @@ describe("user preferences", () => { expect(loadUserPreferences().trayLayout).toBe("horizontal"); }); + + it("persists the software encoder preference", () => { + saveUserPreferences({ preferSoftwareEncoder: true }); + + expect(loadUserPreferences().preferSoftwareEncoder).toBe(true); + }); + + it("falls back to the default software encoder preference for invalid stored values", () => { + localStorage.setItem( + "openscreen_user_preferences", + JSON.stringify({ preferSoftwareEncoder: "yes" }), + ); + + expect(loadUserPreferences().preferSoftwareEncoder).toBe(false); + }); }); diff --git a/src/lib/userPreferences.ts b/src/lib/userPreferences.ts index 66c5a66a4..09860bd3f 100644 --- a/src/lib/userPreferences.ts +++ b/src/lib/userPreferences.ts @@ -33,6 +33,8 @@ export interface UserPreferences { projectFolder: string | null; /** Recording HUD control layout */ trayLayout: "horizontal" | "vertical"; + /** Force the Windows native recorder to use the software H.264 encoder */ + preferSoftwareEncoder: boolean; } export const DEFAULT_PREFS: UserPreferences = { @@ -43,6 +45,7 @@ export const DEFAULT_PREFS: UserPreferences = { exportFolder: null, projectFolder: null, trayLayout: "horizontal", + preferSoftwareEncoder: false, }; /** Parses stored preferences without throwing on malformed JSON. */ @@ -99,6 +102,10 @@ export function loadUserPreferences(): UserPreferences { raw.trayLayout === "horizontal" || raw.trayLayout === "vertical" ? raw.trayLayout : DEFAULT_PREFS.trayLayout, + preferSoftwareEncoder: + typeof raw.preferSoftwareEncoder === "boolean" + ? raw.preferSoftwareEncoder + : DEFAULT_PREFS.preferSoftwareEncoder, }; } From acefe3dc715d534d4e19be088f37d367c5957402 Mon Sep 17 00:00:00 2001 From: My-Denia <176143450+My-Denia@users.noreply.github.com> Date: Mon, 6 Jul 2026 15:50:34 +0100 Subject: [PATCH 2/6] fix(wgc-capture): attribute encoder-setup failures to the correct stage createSinkWriterFromUrl can fail before MFCreateSinkWriterFromURL is ever reached: local software H.264 MFT registration, MFCreateAttributes, or SetUINT32(MF_READWRITE_ENABLE_HARDWARE_TRANSFORMS), each of which already logs its own specific error. configureSinkWriterAttempt still routed every failure through logSinkWriterCreateFailure, which prints "MFCreateSinkWriterFromURL failed" and runs the H.264/AAC MFT-enumeration diagnostics, misattributing those early-stage failures on exactly the VM / headless / broken-driver systems the software fallback targets. Return the failing stage from createSinkWriterFromUrl via a SinkWriterCreateStage out-param and only run the sink-writer diagnostics when the MFCreateSinkWriterFromURL call itself failed; the earlier stages keep their own specific logs. Also clarify (code comment + README) that the software fallback works by registering the Microsoft software H.264 encoder in-process via MFTRegisterLocalByCLSID, and that clearing MF_READWRITE_ENABLE_HARDWARE_TRANSFORMS is only a secondary guard, not the primary fallback mechanism. Refs #18 --- electron/native/README.md | 2 +- .../native/wgc-capture/src/mf_encoder.cpp | 56 ++++++++++++++++--- 2 files changed, 50 insertions(+), 8 deletions(-) diff --git a/electron/native/README.md b/electron/native/README.md index 8306a2458..2ac0ce96f 100644 --- a/electron/native/README.md +++ b/electron/native/README.md @@ -84,7 +84,7 @@ Current V2 JSON shape: The current helper implementation supports display/window video capture, system audio loopback, selected-microphone capture, Media Foundation webcam capture, and a DirectShow webcam fallback for virtual cameras that are not exposed through Media Foundation. Webcam frames are currently composed into the primary MP4 as a bottom-right picture-in-picture overlay. Browser `deviceId` values do not always map to Media Foundation symbolic links or WASAPI endpoint IDs, so the renderer passes both browser IDs and user-visible device names. For microphones, the helper tries the requested WASAPI endpoint ID first, then resolves an active capture endpoint by `microphoneDeviceName`, then falls back to the default endpoint. For webcams, Electron resolves a matching DirectShow filter CLSID for the selected label; the helper uses Media Foundation first, then that exact DirectShow filter when the requested camera is absent from Media Foundation. -Encoder selection: by default the helper keeps the existing sink-writer path first. If that path fails while setting up H.264, it retries with the Microsoft software H.264 encoder (`mfh264enc.dll`) registered locally in the helper process and hardware transforms disabled for that retry. Set `preferSoftwareEncoder: true` in the helper JSON, or set `OPENSCREEN_WGC_PREFER_SOFTWARE_ENCODER=true` before launching Electron, to force the software path from the first attempt. +Encoder selection: by default the helper keeps the existing sink-writer path first. If that path fails while setting up H.264, it retries with the Microsoft software H.264 encoder (`mfh264enc.dll`). The key of this retry is registering that encoder locally in the helper process via `MFTRegisterLocalByCLSID`, which makes a software H.264 encoder available even when the machine's hardware encoders are missing or broken; hardware transforms are disabled for the retry only as a secondary guard so the sink writer prefers the locally registered software encoder, not as the fallback mechanism itself. Set `preferSoftwareEncoder: true` in the helper JSON, or set `OPENSCREEN_WGC_PREFER_SOFTWARE_ENCODER=true` before launching Electron, to force the software path from the first attempt. Encoder diagnostic on final sink-writer failure: when the final `MFCreateSinkWriterFromURL` attempt fails, the helper logs the registered H.264 video encoder MFT count (via `MFTEnumEx`), the registered AAC encoder count when audio was requested, and the hex HRESULT. If no H.264 encoder is registered, it additionally emits the four-bullet actionable error (missing Media Feature Pack / GPU driver registration / empty `HKLM:\SOFTWARE\Microsoft\Windows Media Foundation\Transforms` / reboot). If an H.264 encoder IS registered but the sink writer still failed, it logs a hint pointing at invalid output path, missing MP4 mux, or GPU driver incompatibility. There is still no fail-fast pre-flight gate because `MFTEnumEx` and `MFCreateSinkWriterFromURL` can disagree about which H.264 encoders are available in non-interactive / Session 0 contexts. diff --git a/electron/native/wgc-capture/src/mf_encoder.cpp b/electron/native/wgc-capture/src/mf_encoder.cpp index d1c779b96..0c567ecb7 100644 --- a/electron/native/wgc-capture/src/mf_encoder.cpp +++ b/electron/native/wgc-capture/src/mf_encoder.cpp @@ -127,6 +127,20 @@ constexpr const char* kVideoEncoderSelectionDefault = "default"; constexpr const char* kVideoEncoderSelectionSoftwarePreferred = "software-preferred"; constexpr const char* kVideoEncoderSelectionSoftwareFallback = "software-fallback"; +// Which step of createSinkWriterFromUrl produced a failing HRESULT. Only a +// CreateSinkWriter failure means MFCreateSinkWriterFromURL itself failed and +// warrants the encoder-enumeration diagnostics in logSinkWriterCreateFailure. +// The earlier software-path setup steps each log their own specific error, so +// routing them through the sink-writer diagnostics would misattribute the +// failure (e.g. reporting a local MFT registration failure as a sink-writer +// failure on the VM / headless / broken-driver systems this path targets). +enum class SinkWriterCreateStage { + SoftwareEncoderRegistration, + CreateAttributes, + DisableHardwareTransforms, + CreateSinkWriter, +}; + HRESULT ensureSoftwareH264EncoderRegisteredForProcess() { static std::mutex registrationMutex; static bool attempted = false; @@ -165,11 +179,26 @@ HRESULT ensureSoftwareH264EncoderRegisteredForProcess() { HRESULT createSinkWriterFromUrl( const std::wstring& outputPath, bool forceSoftwareEncoder, - Microsoft::WRL::ComPtr& sinkWriter) { + Microsoft::WRL::ComPtr& sinkWriter, + SinkWriterCreateStage& failedStage) { + // Default to the sink-writer creation step; the software-path steps below + // overwrite this only when one of them returns early with a failure. + failedStage = SinkWriterCreateStage::CreateSinkWriter; + Microsoft::WRL::ComPtr attributes; if (forceSoftwareEncoder) { + // The software fallback works by registering the Microsoft software + // H.264 encoder MFT *in this helper process* via MFTRegisterLocalByCLSID + // (see ensureSoftwareH264EncoderRegisteredForProcess). That in-process + // registration is the key mechanism: it makes a software H.264 encoder + // available even when the machine's registered hardware encoders are + // missing or broken. Clearing MF_READWRITE_ENABLE_HARDWARE_TRANSFORMS is + // only a secondary guard so the sink writer prefers the locally + // registered software encoder over a hardware transform; it is not + // itself the fallback mechanism. const HRESULT registerHr = ensureSoftwareH264EncoderRegisteredForProcess(); if (FAILED(registerHr)) { + failedStage = SinkWriterCreateStage::SoftwareEncoderRegistration; return registerHr; } @@ -177,16 +206,19 @@ HRESULT createSinkWriterFromUrl( if (FAILED(hr)) { std::cerr << "ERROR: MFCreateAttributes(sink writer) failed (hr=0x" << std::hex << hr << std::dec << ")" << std::endl; + failedStage = SinkWriterCreateStage::CreateAttributes; return hr; } hr = attributes->SetUINT32(MF_READWRITE_ENABLE_HARDWARE_TRANSFORMS, FALSE); if (FAILED(hr)) { std::cerr << "ERROR: Set MF_READWRITE_ENABLE_HARDWARE_TRANSFORMS failed (hr=0x" << std::hex << hr << std::dec << ")" << std::endl; + failedStage = SinkWriterCreateStage::DisableHardwareTransforms; return hr; } } + failedStage = SinkWriterCreateStage::CreateSinkWriter; return MFCreateSinkWriterFromURL( outputPath.c_str(), nullptr, attributes.Get(), &sinkWriter); } @@ -340,16 +372,26 @@ bool MFEncoder::initialize( bool logCreateFailure) { resetSinkWriterAttempt(); + SinkWriterCreateStage failedStage = SinkWriterCreateStage::CreateSinkWriter; const HRESULT sinkWriterHr = createSinkWriterFromUrl( outputPath, forceSoftwareEncoder, - sinkWriter_); + sinkWriter_, + failedStage); if (FAILED(sinkWriterHr)) { - if (logCreateFailure) { - logSinkWriterCreateFailure(sinkWriterHr, audioFormat); - } else { - std::cerr << "WARNING: Default MFCreateSinkWriterFromURL failed (hr=0x" - << std::hex << sinkWriterHr << std::dec << ")" << std::endl; + // Only a genuine MFCreateSinkWriterFromURL failure gets the + // sink-writer / encoder-enumeration diagnostics. The earlier + // software-path steps (local MFT registration, attribute creation, + // hardware-transform flag) already logged their own specific error + // inside createSinkWriterFromUrl, so logging here as well would + // misattribute those failures to MFCreateSinkWriterFromURL. + if (failedStage == SinkWriterCreateStage::CreateSinkWriter) { + if (logCreateFailure) { + logSinkWriterCreateFailure(sinkWriterHr, audioFormat); + } else { + std::cerr << "WARNING: Default MFCreateSinkWriterFromURL failed (hr=0x" + << std::hex << sinkWriterHr << std::dec << ")" << std::endl; + } } return false; } From 9d37377c904ddbbe5341e1bfcafc5877750a6c80 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Tue, 7 Jul 2026 12:17:29 +0200 Subject: [PATCH 3/6] fix: address review on wgc software encoder fallback - Drop dead-weight encoder.preferSoftwareEncoder from helper config; C++ parseConfig already reads it from the top level. - Move kVideoEncoderSelection* constexprs to mf_encoder.h so the inline default for videoEncoderSelection_ shares one source of truth. - Register CLSID_MSH264EncoderMFT with SYNCMFT|ASYNCMFT; the MFT supports both modes. --- electron/ipc/handlers.ts | 3 --- electron/native/wgc-capture/src/mf_encoder.cpp | 6 +----- electron/native/wgc-capture/src/mf_encoder.h | 6 +++++- 3 files changed, 6 insertions(+), 9 deletions(-) diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index b3b2d81ce..2eb36ec7e 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -1673,9 +1673,6 @@ export function registerIpcHandlers( video: request.video, audio: request.audio, webcam: request.webcam, - encoder: { - preferSoftwareEncoder, - }, cursor: { mode: cursorCaptureMode, }, diff --git a/electron/native/wgc-capture/src/mf_encoder.cpp b/electron/native/wgc-capture/src/mf_encoder.cpp index 0c567ecb7..cf067150f 100644 --- a/electron/native/wgc-capture/src/mf_encoder.cpp +++ b/electron/native/wgc-capture/src/mf_encoder.cpp @@ -123,10 +123,6 @@ void logMissingH264EncoderError() { << std::endl; } -constexpr const char* kVideoEncoderSelectionDefault = "default"; -constexpr const char* kVideoEncoderSelectionSoftwarePreferred = "software-preferred"; -constexpr const char* kVideoEncoderSelectionSoftwareFallback = "software-fallback"; - // Which step of createSinkWriterFromUrl produced a failing HRESULT. Only a // CreateSinkWriter failure means MFCreateSinkWriterFromURL itself failed and // warrants the encoder-enumeration diagnostics in logSinkWriterCreateFailure. @@ -164,7 +160,7 @@ HRESULT ensureSoftwareH264EncoderRegisteredForProcess() { CLSID_MSH264EncoderMFT, MFT_CATEGORY_VIDEO_ENCODER, L"Microsoft H.264 Encoder MFT (software)", - MFT_ENUM_FLAG_SYNCMFT, + MFT_ENUM_FLAG_SYNCMFT | MFT_ENUM_FLAG_ASYNCMFT, 1, &inputType, 1, diff --git a/electron/native/wgc-capture/src/mf_encoder.h b/electron/native/wgc-capture/src/mf_encoder.h index 22d676bb9..502ba5cf2 100644 --- a/electron/native/wgc-capture/src/mf_encoder.h +++ b/electron/native/wgc-capture/src/mf_encoder.h @@ -30,6 +30,10 @@ struct MFEncoderOptions { bool preferSoftwareEncoder = false; }; +constexpr const char* kVideoEncoderSelectionDefault = "default"; +constexpr const char* kVideoEncoderSelectionSoftwarePreferred = "software-preferred"; +constexpr const char* kVideoEncoderSelectionSoftwareFallback = "software-fallback"; + class MFEncoder { public: MFEncoder() = default; @@ -78,5 +82,5 @@ class MFEncoder { int64_t firstTimestampHns_ = -1; int64_t lastTimestampHns_ = -1; bool finalized_ = false; - const char* videoEncoderSelection_ = "default"; + const char* videoEncoderSelection_ = kVideoEncoderSelectionDefault; }; From 4f78c7286675daaf13276453937b0a55b850e71c Mon Sep 17 00:00:00 2001 From: My-Denia <176143450+My-Denia@users.noreply.github.com> Date: Tue, 7 Jul 2026 19:46:20 +0100 Subject: [PATCH 4/6] feat(recording): surface automatic software-encoder fallback in the HUD Parse the Windows helper encoder-selection event in the main process and return the selected encoder from start-native-windows-recording. When the helper reports "software-fallback" (the default GPU encoder failed and the helper switched on its own), the recording HUD shows a small dismissible notice with a "Don't show again" option persisted in user preferences. An explicitly preferred software encoder ("software-preferred") and the normal default path show no notice, and the encoder-selection event stays available for diagnostics. Refs #18 --- electron/ipc/handlers.ts | 22 ++++++++ electron/native/README.md | 2 + src/components/launch/LaunchWindow.test.tsx | 58 +++++++++++++++++++++ src/components/launch/LaunchWindow.tsx | 53 +++++++++++++++++++ src/hooks/useScreenRecorder.ts | 22 +++++++- src/i18n/locales/ar/launch.json | 6 +++ src/i18n/locales/en/launch.json | 6 +++ src/i18n/locales/es/launch.json | 6 +++ src/i18n/locales/fr/launch.json | 6 +++ src/i18n/locales/it/launch.json | 6 +++ src/i18n/locales/ja-JP/launch.json | 6 +++ src/i18n/locales/ko-KR/launch.json | 6 +++ src/i18n/locales/pt-BR/launch.json | 6 +++ src/i18n/locales/ru/launch.json | 6 +++ src/i18n/locales/tr/launch.json | 6 +++ src/i18n/locales/vi/launch.json | 6 +++ src/i18n/locales/zh-CN/launch.json | 6 +++ src/i18n/locales/zh-TW/launch.json | 6 +++ src/lib/nativeWindowsRecording.ts | 2 + src/lib/userPreferences.test.ts | 15 ++++++ src/lib/userPreferences.ts | 7 +++ 21 files changed, 258 insertions(+), 1 deletion(-) diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 2eb36ec7e..4002de9b7 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -1040,6 +1040,25 @@ function readNativeWindowsWebcamFormat(output: string) { } } +function readNativeWindowsEncoderSelection(output: string) { + const lines = output + .split(/\r?\n/) + .filter((line) => line.includes('"event":"encoder-selection"')); + const lastLine = lines.at(-1); + if (!lastLine) { + return null; + } + + try { + return JSON.parse(lastLine) as { + video?: string; + preferSoftwareEncoder?: boolean; + }; + } catch { + return null; + } +} + function tryParseNativeHelperEvent(line: string) { try { const parsed = JSON.parse(line); @@ -1729,10 +1748,12 @@ export function registerIpcHandlers( ? Math.max(0, captureStartedAtMs - cursorStartTimeMs) : 0; const webcamFormat = readNativeWindowsWebcamFormat(nativeWindowsCaptureOutput); + const encoderSelection = readNativeWindowsEncoderSelection(nativeWindowsCaptureOutput); console.info("[native-wgc] capture started", { captureStartedAtMs, cursorOffsetMs: nativeWindowsCursorOffsetMs, webcamFormat, + encoderSelection, }); const source = selectedSource || { name: "Screen" }; @@ -1745,6 +1766,7 @@ export function registerIpcHandlers( recordingId, path: outputPath, helperPath, + videoEncoderSelection: encoderSelection?.video ?? null, }; } catch (error) { console.error("Failed to start native Windows recording:", error); diff --git a/electron/native/README.md b/electron/native/README.md index 2ac0ce96f..0d76b1451 100644 --- a/electron/native/README.md +++ b/electron/native/README.md @@ -86,6 +86,8 @@ The current helper implementation supports display/window video capture, system Encoder selection: by default the helper keeps the existing sink-writer path first. If that path fails while setting up H.264, it retries with the Microsoft software H.264 encoder (`mfh264enc.dll`). The key of this retry is registering that encoder locally in the helper process via `MFTRegisterLocalByCLSID`, which makes a software H.264 encoder available even when the machine's hardware encoders are missing or broken; hardware transforms are disabled for the retry only as a secondary guard so the sink writer prefers the locally registered software encoder, not as the fallback mechanism itself. Set `preferSoftwareEncoder: true` in the helper JSON, or set `OPENSCREEN_WGC_PREFER_SOFTWARE_ENCODER=true` before launching Electron, to force the software path from the first attempt. +The helper reports the outcome through the `encoder-selection` stdout event (`video` is `default`, `software-preferred`, or `software-fallback`). When the app sees `software-fallback` — the default encoder failed and the helper switched on its own — it shows a small dismissible notice in the recording HUD with a "Don't show again" option, because software encoding can raise CPU usage. An explicit `software-preferred` selection shows no notice, and the event stays available for diagnostics either way. + Encoder diagnostic on final sink-writer failure: when the final `MFCreateSinkWriterFromURL` attempt fails, the helper logs the registered H.264 video encoder MFT count (via `MFTEnumEx`), the registered AAC encoder count when audio was requested, and the hex HRESULT. If no H.264 encoder is registered, it additionally emits the four-bullet actionable error (missing Media Feature Pack / GPU driver registration / empty `HKLM:\SOFTWARE\Microsoft\Windows Media Foundation\Transforms` / reboot). If an H.264 encoder IS registered but the sink writer still failed, it logs a hint pointing at invalid output path, missing MP4 mux, or GPU driver incompatibility. There is still no fail-fast pre-flight gate because `MFTEnumEx` and `MFCreateSinkWriterFromURL` can disagree about which H.264 encoders are available in non-interactive / Session 0 contexts. Smoke-test the helper with: diff --git a/src/components/launch/LaunchWindow.test.tsx b/src/components/launch/LaunchWindow.test.tsx index 8f9a508c4..154feef73 100644 --- a/src/components/launch/LaunchWindow.test.tsx +++ b/src/components/launch/LaunchWindow.test.tsx @@ -55,6 +55,8 @@ const recorderState = vi.hoisted(() => ({ setSystemAudioEnabled: vi.fn(), cursorCaptureMode: "editable-overlay", setCursorCaptureMode: vi.fn(), + softwareEncoderFallbackNoticeVisible: false, + dismissSoftwareEncoderFallbackNotice: vi.fn(), }, })); @@ -145,6 +147,11 @@ vi.mock("@/contexts/I18nContext", () => ({ "We detected English as your system language. Do you want to switch OpenScreen to English?", "systemLanguagePrompt.keepDefault": "Keep current language", "systemLanguagePrompt.switch": "Switch to English", + "softwareEncoderFallback.title": "Switched to software encoding", + "softwareEncoderFallback.description": + "The default GPU encoder failed to start, so OpenScreen fell back to software H.264 encoding. Recording should continue as normal, but CPU usage may be higher.", + "softwareEncoderFallback.dismiss": "Got it", + "softwareEncoderFallback.dontShowAgain": "Don't show again", }; return translations[key] ?? key; }, @@ -223,6 +230,8 @@ function emitSourceSelectorClosed() { function resetLaunchMocks() { vi.stubGlobal("ResizeObserver", StubResizeObserver); recorderState.value.toggleRecording.mockClear(); + recorderState.value.softwareEncoderFallbackNoticeVisible = false; + recorderState.value.dismissSoftwareEncoderFallbackNotice.mockClear(); selectedSourceChangedListeners = []; sourceSelectorClosedListeners = []; i18nState.value.systemLocaleSuggestion = null; @@ -450,3 +459,52 @@ describe("LaunchWindow system language prompt", () => { expect(height).toBeLessThan(viewportHeight + 24); }); }); + +describe("LaunchWindow software encoder fallback notice", () => { + beforeEach(() => { + platformState.value = "darwin"; + resetLaunchMocks(); + }); + + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + }); + + it("stays hidden while the recorder reports no fallback", () => { + renderLaunchWindow(); + + expect(screen.queryByText("Switched to software encoding")).not.toBeInTheDocument(); + }); + + it("shows the notice when the recorder reports a software fallback", async () => { + recorderState.value.softwareEncoderFallbackNoticeVisible = true; + + renderLaunchWindow(); + + expect(await screen.findByText("Switched to software encoding")).toBeInTheDocument(); + expect(screen.getByText(/fell back to software H\.264 encoding/)).toBeInTheDocument(); + }); + + it("dismisses the notice without persisting when Got it is clicked", async () => { + recorderState.value.softwareEncoderFallbackNoticeVisible = true; + + renderLaunchWindow(); + + fireEvent.click(await screen.findByRole("button", { name: "Got it" })); + + expect(recorderState.value.dismissSoftwareEncoderFallbackNotice).toHaveBeenCalledTimes(1); + expect(recorderState.value.dismissSoftwareEncoderFallbackNotice).toHaveBeenCalledWith(); + }); + + it("persists the suppression when Don't show again is clicked", async () => { + recorderState.value.softwareEncoderFallbackNoticeVisible = true; + + renderLaunchWindow(); + + fireEvent.click(await screen.findByRole("button", { name: "Don't show again" })); + + expect(recorderState.value.dismissSoftwareEncoderFallbackNotice).toHaveBeenCalledTimes(1); + expect(recorderState.value.dismissSoftwareEncoderFallbackNotice).toHaveBeenCalledWith(true); + }); +}); diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index b8cf24f22..70acc7ff0 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -136,6 +136,8 @@ export function LaunchWindow() { setWebcamDeviceName, cursorCaptureMode, setCursorCaptureMode, + softwareEncoderFallbackNoticeVisible, + dismissSoftwareEncoderFallbackNotice, } = useScreenRecorder(); const showMicControls = microphoneEnabled && !recording; @@ -159,6 +161,7 @@ export function LaunchWindow() { const hudBarRef = useRef(null); const deviceSelectorRef = useRef(null); const systemLocalePromptRef = useRef(null); + const softwareFallbackNoticeRef = useRef(null); // Measured bar height, anchors the popups above the tall vertical tray so they don't overlap it. const [hudBarHeight, setHudBarHeight] = useState(0); const [languageMenuStyle, setLanguageMenuStyle] = useState<{ @@ -372,6 +375,17 @@ export function LaunchWindow() { halfWidth = Math.max(halfWidth, centerX - rect.left, rect.right - centerX); } + // The software-encoder fallback notice shares the prompt's fixed top-8 slot and needs + // the same treatment so its buttons stay clickable. + if (softwareFallbackNoticeRef.current) { + const rect = softwareFallbackNoticeRef.current.getBoundingClientRect(); + const noticeHeight = rect.height || softwareFallbackNoticeRef.current.scrollHeight; + if (noticeHeight > 0) { + topFromBottom = Math.max(topFromBottom, rect.top + noticeHeight); + } + halfWidth = Math.max(halfWidth, centerX - rect.left, rect.right - centerX); + } + setHudBarHeight((prev) => { const next = Math.round(barEl.scrollHeight); return Math.abs(prev - next) > 1 ? next : prev; @@ -396,6 +410,7 @@ export function LaunchWindow() { if (deviceSelectorRef.current) observer.observe(deviceSelectorRef.current); // Backfill refs set before the observer existed (e.g. the prompt or language menu). if (systemLocalePromptRef.current) observer.observe(systemLocalePromptRef.current); + if (softwareFallbackNoticeRef.current) observer.observe(softwareFallbackNoticeRef.current); if (languageMenuPanelRef.current) observer.observe(languageMenuPanelRef.current); measureHudSize(); return () => { @@ -430,6 +445,10 @@ export function LaunchWindow() { (el: HTMLDivElement | null) => observeHudElement(el, systemLocalePromptRef), [observeHudElement], ); + const setSoftwareFallbackNoticeEl = useCallback( + (el: HTMLDivElement | null) => observeHudElement(el, softwareFallbackNoticeRef), + [observeHudElement], + ); const hudIgnoreMouseEventsRef = useRef(undefined); const setHudMouseEventsEnabled = useCallback( @@ -688,6 +707,40 @@ export function LaunchWindow() { )} + {softwareEncoderFallbackNoticeVisible && ( +
+
+ {t("softwareEncoderFallback.title")} +
+
+ {t("softwareEncoderFallback.description")} +
+
+ + +
+
+ )} + {/* Device selectors, fixed above HUD bar, viewport-relative, never clipped */} {(showMicControls || showWebcamControls) && (
Promise; cursorCaptureMode: CursorCaptureMode; setCursorCaptureMode: (mode: CursorCaptureMode) => void; + softwareEncoderFallbackNoticeVisible: boolean; + dismissSoftwareEncoderFallbackNotice: (dontShowAgain?: boolean) => void; }; type NativeWindowsRecordingHandle = { @@ -103,6 +105,8 @@ export function useScreenRecorder(): UseScreenRecorderReturn { const [systemAudioEnabled, setSystemAudioEnabled] = useState(false); const [webcamEnabled, setWebcamEnabledState] = useState(false); const [cursorCaptureMode, setCursorCaptureMode] = useState("editable-overlay"); + const [softwareEncoderFallbackNoticeVisible, setSoftwareEncoderFallbackNoticeVisible] = + useState(false); const screenRecorder = useRef(null); const webcamRecorder = useRef(null); const nativeWindowsRecording = useRef(null); @@ -891,6 +895,13 @@ export function useScreenRecorder(): UseScreenRecorderReturn { throw new Error(result.error ?? "Native Windows capture failed."); } + // Tell the user when the helper silently switched away from the default + // GPU encoder; an explicit software-preferred selection needs no notice. + setSoftwareEncoderFallbackNoticeVisible( + result.videoEncoderSelection === "software-fallback" && + !loadUserPreferences().hideSoftwareEncoderFallbackNotice, + ); + recordingId.current = result.recordingId; nativeWindowsRecording.current = { recordingId: result.recordingId, @@ -1682,6 +1693,13 @@ export function useScreenRecorder(): UseScreenRecorderReturn { } }; + const dismissSoftwareEncoderFallbackNotice = (dontShowAgain = false) => { + if (dontShowAgain) { + saveUserPreferences({ hideSoftwareEncoderFallbackNotice: true }); + } + setSoftwareEncoderFallbackNoticeVisible(false); + }; + return { recording, paused, @@ -1708,5 +1726,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { setWebcamEnabled, cursorCaptureMode, setCursorCaptureMode, + softwareEncoderFallbackNoticeVisible, + dismissSoftwareEncoderFallbackNotice, }; } diff --git a/src/i18n/locales/ar/launch.json b/src/i18n/locales/ar/launch.json index e3a855e65..5dde6a8f9 100644 --- a/src/i18n/locales/ar/launch.json +++ b/src/i18n/locales/ar/launch.json @@ -57,5 +57,11 @@ "cursor": { "useEditableCursor": "استخدام مؤشر قابل للتحرير", "useSystemCursor": "استخدام مؤشر النظام" + }, + "softwareEncoderFallback": { + "title": "تم التبديل إلى الترميز البرمجي", + "description": "تعذّر تشغيل مرمّز GPU الافتراضي، لذا تحوّل OpenScreen إلى ترميز H.264 البرمجي. سيستمر التسجيل كالمعتاد، لكن استخدام المعالج قد يكون أعلى.", + "dismiss": "حسنًا", + "dontShowAgain": "عدم الإظهار مرة أخرى" } } diff --git a/src/i18n/locales/en/launch.json b/src/i18n/locales/en/launch.json index 0066a3fc9..87929ef90 100644 --- a/src/i18n/locales/en/launch.json +++ b/src/i18n/locales/en/launch.json @@ -61,5 +61,11 @@ "description": "We detected {{language}} as your system language. Do you want to switch OpenScreen to {{language}}?", "switch": "Switch to {{language}}", "keepDefault": "Keep current language" + }, + "softwareEncoderFallback": { + "title": "Switched to software encoding", + "description": "The default GPU encoder failed to start, so OpenScreen fell back to software H.264 encoding. Recording should continue as normal, but CPU usage may be higher.", + "dismiss": "Got it", + "dontShowAgain": "Don't show again" } } diff --git a/src/i18n/locales/es/launch.json b/src/i18n/locales/es/launch.json index 90ef56865..6b903087c 100644 --- a/src/i18n/locales/es/launch.json +++ b/src/i18n/locales/es/launch.json @@ -57,5 +57,11 @@ "description": "Detectamos {{language}} como idioma de tu sistema. ¿Quieres cambiar OpenScreen a {{language}}?", "switch": "Cambiar a {{language}}", "keepDefault": "Mantener idioma actual" + }, + "softwareEncoderFallback": { + "title": "Se cambió a codificación por software", + "description": "El codificador GPU predeterminado no pudo iniciarse, por lo que OpenScreen pasó a la codificación H.264 por software. La grabación debería continuar con normalidad, pero el uso de CPU puede ser mayor.", + "dismiss": "Entendido", + "dontShowAgain": "No volver a mostrar" } } diff --git a/src/i18n/locales/fr/launch.json b/src/i18n/locales/fr/launch.json index 8c0b59e59..0d6a820b3 100644 --- a/src/i18n/locales/fr/launch.json +++ b/src/i18n/locales/fr/launch.json @@ -57,5 +57,11 @@ "description": "Nous avons détecté {{language}} comme langue système. Voulez-vous passer OpenScreen en {{language}} ?", "switch": "Passer en {{language}}", "keepDefault": "Conserver la langue actuelle" + }, + "softwareEncoderFallback": { + "title": "Passage à l'encodage logiciel", + "description": "L'encodeur GPU par défaut n'a pas pu démarrer, OpenScreen est donc passé à l'encodage H.264 logiciel. L'enregistrement devrait continuer normalement, mais l'utilisation du processeur peut être plus élevée.", + "dismiss": "Compris", + "dontShowAgain": "Ne plus afficher" } } diff --git a/src/i18n/locales/it/launch.json b/src/i18n/locales/it/launch.json index b46adbd86..8e7f013bf 100644 --- a/src/i18n/locales/it/launch.json +++ b/src/i18n/locales/it/launch.json @@ -57,5 +57,11 @@ "description": "Abbiamo rilevato {{language}} come lingua del sistema. Vuoi passare OpenScreen a {{language}}?", "switch": "Passa a {{language}}", "keepDefault": "Mantieni la lingua corrente" + }, + "softwareEncoderFallback": { + "title": "Passaggio alla codifica software", + "description": "L'encoder GPU predefinito non si è avviato, quindi OpenScreen è passato alla codifica H.264 software. La registrazione dovrebbe continuare normalmente, ma l'uso della CPU potrebbe essere più alto.", + "dismiss": "Ho capito", + "dontShowAgain": "Non mostrare più" } } diff --git a/src/i18n/locales/ja-JP/launch.json b/src/i18n/locales/ja-JP/launch.json index a9291b02b..676cd272c 100644 --- a/src/i18n/locales/ja-JP/launch.json +++ b/src/i18n/locales/ja-JP/launch.json @@ -57,5 +57,11 @@ "description": "システム言語として{{language}}が検出されました。OpenScreenを{{language}}に切り替えますか?", "switch": "{{language}}に切り替え", "keepDefault": "現在の言語を保持" + }, + "softwareEncoderFallback": { + "title": "ソフトウェアエンコードに切り替えました", + "description": "既定のGPUエンコーダーを開始できなかったため、OpenScreenはソフトウェアH.264エンコードに切り替えました。録画はそのまま続行されますが、CPU使用率が高くなる場合があります。", + "dismiss": "OK", + "dontShowAgain": "今後表示しない" } } diff --git a/src/i18n/locales/ko-KR/launch.json b/src/i18n/locales/ko-KR/launch.json index 3c20f2236..645a1a10f 100644 --- a/src/i18n/locales/ko-KR/launch.json +++ b/src/i18n/locales/ko-KR/launch.json @@ -57,5 +57,11 @@ "description": "시스템 언어가 {{language}}(으)로 감지되었습니다. OpenScreen을 {{language}}(으)로 전환하시겠습니까?", "switch": "{{language}}(으)로 전환", "keepDefault": "현재 언어 유지" + }, + "softwareEncoderFallback": { + "title": "소프트웨어 인코딩으로 전환됨", + "description": "기본 GPU 인코더를 시작할 수 없어 OpenScreen이 소프트웨어 H.264 인코딩으로 전환했습니다. 녹화는 정상적으로 계속되지만 CPU 사용량이 높아질 수 있습니다.", + "dismiss": "확인", + "dontShowAgain": "다시 표시하지 않음" } } diff --git a/src/i18n/locales/pt-BR/launch.json b/src/i18n/locales/pt-BR/launch.json index 79d44fc61..1df01ff79 100644 --- a/src/i18n/locales/pt-BR/launch.json +++ b/src/i18n/locales/pt-BR/launch.json @@ -55,5 +55,11 @@ "description": "Detectamos {{language}} como o idioma do seu sistema. Deseja mudar o OpenScreen para {{language}}?", "switch": "Mudar para {{language}}", "keepDefault": "Manter idioma atual" + }, + "softwareEncoderFallback": { + "title": "Mudou para codificação por software", + "description": "O codificador de GPU padrão não pôde ser iniciado, então o OpenScreen passou a usar a codificação H.264 por software. A gravação deve continuar normalmente, mas o uso da CPU pode ser maior.", + "dismiss": "Entendi", + "dontShowAgain": "Não mostrar novamente" } } diff --git a/src/i18n/locales/ru/launch.json b/src/i18n/locales/ru/launch.json index 00a227ca4..b41f07bab 100644 --- a/src/i18n/locales/ru/launch.json +++ b/src/i18n/locales/ru/launch.json @@ -57,5 +57,11 @@ "cursor": { "useEditableCursor": "Использовать редактируемый курсор", "useSystemCursor": "Использовать системный курсор" + }, + "softwareEncoderFallback": { + "title": "Выполнен переход на программное кодирование", + "description": "Стандартный GPU-кодировщик не запустился, поэтому OpenScreen перешёл на программное кодирование H.264. Запись продолжится как обычно, но нагрузка на процессор может быть выше.", + "dismiss": "Понятно", + "dontShowAgain": "Больше не показывать" } } diff --git a/src/i18n/locales/tr/launch.json b/src/i18n/locales/tr/launch.json index b670178a7..d30ddd0bc 100644 --- a/src/i18n/locales/tr/launch.json +++ b/src/i18n/locales/tr/launch.json @@ -57,5 +57,11 @@ "description": "Sistem diliniz {{language}} olarak algılandı. OpenScreen i {{language}} diline geçirmek ister misiniz?", "switch": "{{language}} diline geç", "keepDefault": "Mevcut dili koru" + }, + "softwareEncoderFallback": { + "title": "Yazılım kodlamasına geçildi", + "description": "Varsayılan GPU kodlayıcı başlatılamadığı için OpenScreen yazılım tabanlı H.264 kodlamasına geçti. Kayıt normal şekilde devam eder, ancak CPU kullanımı daha yüksek olabilir.", + "dismiss": "Anladım", + "dontShowAgain": "Bir daha gösterme" } } diff --git a/src/i18n/locales/vi/launch.json b/src/i18n/locales/vi/launch.json index 203f2b414..c9f3c3bf4 100644 --- a/src/i18n/locales/vi/launch.json +++ b/src/i18n/locales/vi/launch.json @@ -57,5 +57,11 @@ "cursor": { "useEditableCursor": "Dùng con trỏ có thể chỉnh sửa", "useSystemCursor": "Dùng con trỏ hệ thống" + }, + "softwareEncoderFallback": { + "title": "Đã chuyển sang mã hóa bằng phần mềm", + "description": "Bộ mã hóa GPU mặc định không khởi động được, nên OpenScreen đã chuyển sang mã hóa H.264 bằng phần mềm. Quá trình ghi vẫn tiếp tục bình thường, nhưng mức sử dụng CPU có thể cao hơn.", + "dismiss": "Đã hiểu", + "dontShowAgain": "Không hiển thị lại" } } diff --git a/src/i18n/locales/zh-CN/launch.json b/src/i18n/locales/zh-CN/launch.json index f78a611c0..b20f4546c 100644 --- a/src/i18n/locales/zh-CN/launch.json +++ b/src/i18n/locales/zh-CN/launch.json @@ -57,5 +57,11 @@ "description": "我们检测到你的系统语言是{{language}}。是否将 OpenScreen 切换为{{language}}?", "switch": "切换到{{language}}", "keepDefault": "保持当前语言" + }, + "softwareEncoderFallback": { + "title": "已切换到软件编码", + "description": "默认 GPU 编码器启动失败,OpenScreen 已改用软件 H.264 编码。录制会正常继续,但 CPU 占用可能更高。", + "dismiss": "知道了", + "dontShowAgain": "不再提示" } } diff --git a/src/i18n/locales/zh-TW/launch.json b/src/i18n/locales/zh-TW/launch.json index 6887546d9..f20f6b61c 100644 --- a/src/i18n/locales/zh-TW/launch.json +++ b/src/i18n/locales/zh-TW/launch.json @@ -57,5 +57,11 @@ "description": "偵測到系統語言為 {{language}}。要將 OpenScreen 切換為 {{language}} 嗎?", "switch": "切換為 {{language}}", "keepDefault": "保持目前語言" + }, + "softwareEncoderFallback": { + "title": "已切換至軟體編碼", + "description": "預設 GPU 編碼器啟動失敗,OpenScreen 已改用軟體 H.264 編碼。錄製會照常繼續,但 CPU 使用率可能較高。", + "dismiss": "知道了", + "dontShowAgain": "不再顯示" } } diff --git a/src/lib/nativeWindowsRecording.ts b/src/lib/nativeWindowsRecording.ts index dd668b010..313eff9b5 100644 --- a/src/lib/nativeWindowsRecording.ts +++ b/src/lib/nativeWindowsRecording.ts @@ -45,6 +45,8 @@ export type NativeWindowsRecordingStartResult = { path?: string; helperPath?: string; error?: string; + /** Helper-reported encoder selection: "default", "software-preferred", or "software-fallback". */ + videoEncoderSelection?: string | null; }; export function parseWindowHandleFromSourceId(sourceId?: string | null) { diff --git a/src/lib/userPreferences.test.ts b/src/lib/userPreferences.test.ts index ac3d901a9..ca66c53d5 100644 --- a/src/lib/userPreferences.test.ts +++ b/src/lib/userPreferences.test.ts @@ -118,4 +118,19 @@ describe("user preferences", () => { expect(loadUserPreferences().preferSoftwareEncoder).toBe(false); }); + + it("persists the software encoder fallback notice suppression", () => { + saveUserPreferences({ hideSoftwareEncoderFallbackNotice: true }); + + expect(loadUserPreferences().hideSoftwareEncoderFallbackNotice).toBe(true); + }); + + it("falls back to showing the software encoder fallback notice for invalid stored values", () => { + localStorage.setItem( + "openscreen_user_preferences", + JSON.stringify({ hideSoftwareEncoderFallbackNotice: "yes" }), + ); + + expect(loadUserPreferences().hideSoftwareEncoderFallbackNotice).toBe(false); + }); }); diff --git a/src/lib/userPreferences.ts b/src/lib/userPreferences.ts index 09860bd3f..f166d5e5e 100644 --- a/src/lib/userPreferences.ts +++ b/src/lib/userPreferences.ts @@ -35,6 +35,8 @@ export interface UserPreferences { trayLayout: "horizontal" | "vertical"; /** Force the Windows native recorder to use the software H.264 encoder */ preferSoftwareEncoder: boolean; + /** Stop showing the notice that recording fell back to software encoding */ + hideSoftwareEncoderFallbackNotice: boolean; } export const DEFAULT_PREFS: UserPreferences = { @@ -46,6 +48,7 @@ export const DEFAULT_PREFS: UserPreferences = { projectFolder: null, trayLayout: "horizontal", preferSoftwareEncoder: false, + hideSoftwareEncoderFallbackNotice: false, }; /** Parses stored preferences without throwing on malformed JSON. */ @@ -106,6 +109,10 @@ export function loadUserPreferences(): UserPreferences { typeof raw.preferSoftwareEncoder === "boolean" ? raw.preferSoftwareEncoder : DEFAULT_PREFS.preferSoftwareEncoder, + hideSoftwareEncoderFallbackNotice: + typeof raw.hideSoftwareEncoderFallbackNotice === "boolean" + ? raw.hideSoftwareEncoderFallbackNotice + : DEFAULT_PREFS.hideSoftwareEncoderFallbackNotice, }; } From 980742b2be5627d057132bb46256eb5c9120aee8 Mon Sep 17 00:00:00 2001 From: My-Denia <176143450+My-Denia@users.noreply.github.com> Date: Wed, 8 Jul 2026 19:35:27 +0100 Subject: [PATCH 5/6] fix: address CodeRabbit review on fallback notice and env override - Stack the software-encoder fallback notice and the system-locale prompt in one fixed top-center column so simultaneous banners no longer overlap and cover each other. - Trim OPENSCREEN_WGC_PREFER_SOFTWARE_ENCODER before comparing so trailing whitespace from shell scripts/CI env files still enables the override. --- electron/ipc/handlers.ts | 6 +- src/components/launch/LaunchWindow.tsx | 137 +++++++++++++------------ 2 files changed, 74 insertions(+), 69 deletions(-) diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 4002de9b7..852250ec4 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -1640,9 +1640,9 @@ export function registerIpcHandlers( : null; const cursorCaptureMode = normalizeCursorCaptureMode(request.cursor?.mode) ?? "editable-overlay"; - const envPreferSoftwareEncoder = ( - process.env.OPENSCREEN_WGC_PREFER_SOFTWARE_ENCODER ?? "" - ).toLowerCase(); + const envPreferSoftwareEncoder = (process.env.OPENSCREEN_WGC_PREFER_SOFTWARE_ENCODER ?? "") + .trim() + .toLowerCase(); const preferSoftwareEncoder = request.preferSoftwareEncoder === true || envPreferSoftwareEncoder === "true" || diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 70acc7ff0..1136d8537 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -669,75 +669,80 @@ export function LaunchWindow() { } }} > - {systemLocaleSuggestion && ( -
-
- {t("systemLanguagePrompt.title")} -
-
- {t("systemLanguagePrompt.description", { - language: suggestedLanguageName, - })} -
-
- - -
-
- )} +
+ {t("systemLanguagePrompt.title")} +
+
+ {t("systemLanguagePrompt.description", { + language: suggestedLanguageName, + })} +
+
+ + +
+
+ )} - {softwareEncoderFallbackNoticeVisible && ( -
-
- {t("softwareEncoderFallback.title")} -
-
- {t("softwareEncoderFallback.description")} -
-
- - -
+
+ {t("softwareEncoderFallback.title")} +
+
+ {t("softwareEncoderFallback.description")} +
+
+ + +
+
+ )} )} From 28812c856424d0c89a3f995826257666c136b29e Mon Sep 17 00:00:00 2001 From: My-Denia <176143450+My-Denia@users.noreply.github.com> Date: Sun, 12 Jul 2026 00:52:33 +0100 Subject: [PATCH 6/6] test(wgc-capture): exercise automatic software fallback --- electron/native/README.md | 26 +++++ electron/native/wgc-capture/src/main.cpp | 14 ++- .../native/wgc-capture/src/mf_encoder.cpp | 29 +++++- electron/native/wgc-capture/src/mf_encoder.h | 1 + scripts/test-windows-wgc-helper.mjs | 97 +++++++++++++++++-- 5 files changed, 158 insertions(+), 9 deletions(-) diff --git a/electron/native/README.md b/electron/native/README.md index 0d76b1451..79399d46b 100644 --- a/electron/native/README.md +++ b/electron/native/README.md @@ -95,6 +95,7 @@ Smoke-test the helper with: ```powershell npm run test:wgc-helper:win npm run test:wgc-helper:win -- --software-encoder +npm run test:wgc-helper:win -- --software-fallback npm run test:wgc-window:win npm run test:wgc-audio:win npm run test:wgc-mic:win @@ -102,6 +103,31 @@ npm run test:wgc-mixed-audio:win npm run test:wgc-webcam:win ``` +`--software-encoder` keeps testing the explicit `software-preferred` path with +`preferSoftwareEncoder: true`. `--software-fallback` keeps +`preferSoftwareEncoder: false` and sets +`OPENSCREEN_WGC_TEST_INJECT_DEFAULT_SINK_WRITER_FAILURE_ONCE=1` only for the helper +child process. At the first default/non-software `MFCreateSinkWriterFromURL` call, the +helper returns `HRESULT_FROM_WIN32(ERROR_PATH_NOT_FOUND)` (`0x80070003`) exactly once. +The existing retry then performs the real local Microsoft software H.264 MFT registration, +creates the real fallback sink writer, captures and encodes real frames, and must report +`software-fallback`. + +For a full-application test, set the same test-only variable before launching Electron and +remove it afterward: + +```powershell +$env:OPENSCREEN_WGC_TEST_INJECT_DEFAULT_SINK_WRITER_FAILURE_ONCE = "1" +npm run dev +Remove-Item Env:OPENSCREEN_WGC_TEST_INJECT_DEFAULT_SINK_WRITER_FAILURE_ONCE +``` + +Only the exact value `1` enables this native test hook. It is not a preference or UI option, +and it is inert when absent. This is deterministic fault injection at the original +sink-writer failure boundary. It proves the application's automatic fallback behavior after +that controlled failure; it does **not** claim validation on a naturally affected machine +with a genuinely broken GPU driver or naturally missing hardware H.264 MFT. + To validate a specific native webcam manually: ```powershell diff --git a/electron/native/wgc-capture/src/main.cpp b/electron/native/wgc-capture/src/main.cpp index 95d9dcc74..30a3069d6 100644 --- a/electron/native/wgc-capture/src/main.cpp +++ b/electron/native/wgc-capture/src/main.cpp @@ -396,6 +396,15 @@ int main(int argc, char* argv[]) { return 1; } + char injectDefaultSinkWriterFailure[2]{}; + const DWORD injectDefaultSinkWriterFailureLength = GetEnvironmentVariableA( + "OPENSCREEN_WGC_TEST_INJECT_DEFAULT_SINK_WRITER_FAILURE_ONCE", + injectDefaultSinkWriterFailure, + static_cast(sizeof(injectDefaultSinkWriterFailure))); + const bool injectDefaultSinkWriterFailureOnce = + injectDefaultSinkWriterFailureLength == 1 && + injectDefaultSinkWriterFailure[0] == '1'; + std::cout << "{\"event\":\"ready\",\"schemaVersion\":2}" << std::endl; WgcSession session; @@ -506,6 +515,7 @@ int main(int argc, char* argv[]) { MFEncoderOptions encoderOptions{}; encoderOptions.preferSoftwareEncoder = config.preferSoftwareEncoder; + encoderOptions.injectDefaultSinkWriterFailureOnce = injectDefaultSinkWriterFailureOnce; MFEncoder encoder; if (!encoder.initialize( @@ -528,6 +538,8 @@ int main(int argc, char* argv[]) { << "}" << std::endl; MFEncoder webcamEncoder; if (writeSeparateWebcam) { + MFEncoderOptions webcamEncoderOptions = encoderOptions; + webcamEncoderOptions.injectDefaultSinkWriterFailureOnce = false; const int webcamPixels = std::max(1, webcamCapture.width()) * std::max(1, webcamCapture.height()); const int webcamBitrate = webcamPixels >= 1280 * 720 ? 8'000'000 : 4'000'000; if (!webcamEncoder.initialize( @@ -539,7 +551,7 @@ int main(int argc, char* argv[]) { session.device(), session.context(), nullptr, - encoderOptions)) { + webcamEncoderOptions)) { std::cerr << "ERROR: Failed to initialize native webcam 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 cf067150f..42b708910 100644 --- a/electron/native/wgc-capture/src/mf_encoder.cpp +++ b/electron/native/wgc-capture/src/mf_encoder.cpp @@ -168,6 +168,10 @@ HRESULT ensureSoftwareH264EncoderRegisteredForProcess() { if (FAILED(result)) { std::cerr << "ERROR: MFTRegisterLocalByCLSID(CLSID_MSH264EncoderMFT) failed (hr=0x" << std::hex << result << std::dec << ")" << std::endl; + } else { + std::cerr + << "INFO: Registered the Microsoft software H.264 MFT locally for this helper process." + << std::endl; } return result; } @@ -175,6 +179,8 @@ HRESULT ensureSoftwareH264EncoderRegisteredForProcess() { HRESULT createSinkWriterFromUrl( const std::wstring& outputPath, bool forceSoftwareEncoder, + bool injectDefaultSinkWriterFailureOnce, + bool& injectedDefaultSinkWriterFailure, Microsoft::WRL::ComPtr& sinkWriter, SinkWriterCreateStage& failedStage) { // Default to the sink-writer creation step; the software-path steps below @@ -215,8 +221,25 @@ HRESULT createSinkWriterFromUrl( } failedStage = SinkWriterCreateStage::CreateSinkWriter; - return MFCreateSinkWriterFromURL( + if ( + !forceSoftwareEncoder && + injectDefaultSinkWriterFailureOnce && + !injectedDefaultSinkWriterFailure) { + injectedDefaultSinkWriterFailure = true; + std::cerr + << "TEST-ONLY: Injected default MFCreateSinkWriterFromURL failure " + << "(hr=0x80070003); injection consumed exactly once." + << std::endl; + return HRESULT_FROM_WIN32(ERROR_PATH_NOT_FOUND); + } + + const HRESULT sinkWriterHr = MFCreateSinkWriterFromURL( outputPath.c_str(), nullptr, attributes.Get(), &sinkWriter); + if (SUCCEEDED(sinkWriterHr) && forceSoftwareEncoder) { + std::cerr << "INFO: Created the real software H.264 sink writer successfully." + << std::endl; + } + return sinkWriterHr; } void logSinkWriterCreateFailure(HRESULT sinkWriterHr, const AudioInputFormat* audioFormat) { @@ -354,6 +377,8 @@ bool MFEncoder::initialize( setFrameRate(inputType.Get(), static_cast(fps_)); setPixelAspectRatio(inputType.Get()); + bool injectedDefaultSinkWriterFailure = false; + auto resetSinkWriterAttempt = [&]() { sinkWriter_.Reset(); videoStreamIndex_ = 0; @@ -372,6 +397,8 @@ bool MFEncoder::initialize( const HRESULT sinkWriterHr = createSinkWriterFromUrl( outputPath, forceSoftwareEncoder, + options.injectDefaultSinkWriterFailureOnce, + injectedDefaultSinkWriterFailure, sinkWriter_, failedStage); if (FAILED(sinkWriterHr)) { diff --git a/electron/native/wgc-capture/src/mf_encoder.h b/electron/native/wgc-capture/src/mf_encoder.h index 502ba5cf2..8f5787481 100644 --- a/electron/native/wgc-capture/src/mf_encoder.h +++ b/electron/native/wgc-capture/src/mf_encoder.h @@ -28,6 +28,7 @@ struct AudioInputFormat { struct MFEncoderOptions { bool preferSoftwareEncoder = false; + bool injectDefaultSinkWriterFailureOnce = false; }; constexpr const char* kVideoEncoderSelectionDefault = "default"; diff --git a/scripts/test-windows-wgc-helper.mjs b/scripts/test-windows-wgc-helper.mjs index d5ba55409..c6c69441e 100644 --- a/scripts/test-windows-wgc-helper.mjs +++ b/scripts/test-windows-wgc-helper.mjs @@ -29,10 +29,26 @@ const CAPTURE_CURSOR = const WITH_SOFTWARE_ENCODER = process.env.OPENSCREEN_WGC_TEST_SOFTWARE_ENCODER === "true" || process.argv.includes("--software-encoder"); +const WITH_SOFTWARE_FALLBACK = + process.env.OPENSCREEN_WGC_TEST_SOFTWARE_FALLBACK === "true" || + process.argv.includes("--software-fallback"); +const INJECT_DEFAULT_SINK_WRITER_FAILURE_ENV = + "OPENSCREEN_WGC_TEST_INJECT_DEFAULT_SINK_WRITER_FAILURE_ONCE"; +const INJECTION_MARKER = "TEST-ONLY: Injected default MFCreateSinkWriterFromURL failure"; -function runHelper(config) { +if (WITH_SOFTWARE_ENCODER && WITH_SOFTWARE_FALLBACK) { + throw new Error("--software-encoder and --software-fallback are mutually exclusive"); +} + +function runHelper(config, { injectDefaultSinkWriterFailure = false } = {}) { return new Promise((resolve, reject) => { + const env = { ...process.env }; + delete env[INJECT_DEFAULT_SINK_WRITER_FAILURE_ENV]; + if (injectDefaultSinkWriterFailure) { + env[INJECT_DEFAULT_SINK_WRITER_FAILURE_ENV] = "1"; + } const child = spawn(HELPER_PATH, [JSON.stringify(config)], { + env, stdio: ["pipe", "pipe", "pipe"], windowsHide: true, }); @@ -276,7 +292,9 @@ const config = { let result; try { - result = await runHelper(config); + result = await runHelper(config, { + injectDefaultSinkWriterFailure: WITH_SOFTWARE_FALLBACK, + }); } finally { if (fixtureWindow) { fixtureWindow.child.kill(); @@ -306,6 +324,7 @@ const webcamStreams = webcamOutputPath && fs.existsSync(webcamOutputPath) ? probeStreams(webcamOutputPath) : []; const hasVideo = streams.some((stream) => stream.codec_type === "video"); const hasAudio = streams.some((stream) => stream.codec_type === "audio"); +const videoStream = streams.find((stream) => stream.codec_type === "video"); const webcamFormatLine = result.stdout .split(/\r?\n/) .find((line) => line.includes('"event":"webcam-format"')); @@ -335,6 +354,26 @@ const nativeMicrophoneDiagnostics = result.stderr if (!hasVideo) { throw new Error(`WGC helper output has no video stream: ${outputPath}`); } +if (videoStream.codec_name !== "h264") { + throw new Error( + `WGC helper output video codec is ${videoStream.codec_name ?? "unknown"}, expected h264: ${outputPath}`, + ); +} +const videoDurationSeconds = Number(videoStream.duration); +const minimumPlausibleDurationSeconds = Math.max(0.5, (DURATION_MS / 1000) * 0.5); +const maximumPlausibleDurationSeconds = Math.max( + minimumPlausibleDurationSeconds, + (DURATION_MS / 1000) * 2 + 2, +); +if ( + !Number.isFinite(videoDurationSeconds) || + videoDurationSeconds < minimumPlausibleDurationSeconds || + videoDurationSeconds > maximumPlausibleDurationSeconds +) { + throw new Error( + `WGC helper output duration ${videoStream.duration ?? "unknown"}s is not plausible for a ${DURATION_MS}ms recording: ${outputPath}`, + ); +} if (WITH_WEBCAM && !webcamStreams.some((stream) => stream.codec_type === "video")) { throw new Error(`WGC helper webcam output has no video stream: ${webcamOutputPath}`); } @@ -347,13 +386,56 @@ if ( `WGC helper did not apply requested cursor capture mode (${CAPTURE_CURSOR}): ${result.stdout}`, ); } +const expectedEncoderSelection = WITH_SOFTWARE_FALLBACK + ? "software-fallback" + : WITH_SOFTWARE_ENCODER + ? "software-preferred" + : "default"; if ( - WITH_SOFTWARE_ENCODER && - (!encoderSelection || - typeof encoderSelection.video !== "string" || - !encoderSelection.video.startsWith("software")) + encoderSelection?.video !== expectedEncoderSelection || + encoderSelection.preferSoftwareEncoder !== WITH_SOFTWARE_ENCODER ) { - throw new Error(`WGC helper did not use the requested software encoder path: ${result.stdout}`); + throw new Error( + `WGC helper encoder selection was ${JSON.stringify(encoderSelection)}, expected ${expectedEncoderSelection} with preferSoftwareEncoder=${WITH_SOFTWARE_ENCODER}: ${result.stdout}`, + ); +} + +const combinedHelperOutput = `${result.stdout}\n${result.stderr}`; +const helperDiagnosticLines = combinedHelperOutput.split(/\r?\n/).filter(Boolean); +const injectionLines = helperDiagnosticLines.filter((line) => line.includes(INJECTION_MARKER)); +const fallbackDiagnosticPatterns = [ + INJECTION_MARKER, + "WARNING: Default MFCreateSinkWriterFromURL failed (hr=0x80070003)", + "retrying with the Microsoft software H.264 encoder.", + "INFO: Registered the Microsoft software H.264 MFT locally for this helper process.", + "INFO: Created the real software H.264 sink writer successfully.", +]; +const fallbackDiagnostics = WITH_SOFTWARE_FALLBACK + ? helperDiagnosticLines.filter((line) => + fallbackDiagnosticPatterns.some((pattern) => line.includes(pattern)), + ) + : []; +if (WITH_SOFTWARE_FALLBACK) { + if ( + injectionLines.length !== 1 || + !injectionLines[0].includes("hr=0x80070003") || + !injectionLines[0].includes("consumed exactly once") + ) { + throw new Error( + `Expected exactly one consumed 0x80070003 test-only injection, found ${injectionLines.length}: ${combinedHelperOutput}`, + ); + } + for (const pattern of fallbackDiagnosticPatterns.slice(1)) { + if (!helperDiagnosticLines.some((line) => line.includes(pattern))) { + throw new Error( + `WGC helper fallback diagnostics are missing ${JSON.stringify(pattern)}: ${combinedHelperOutput}`, + ); + } + } +} else if (injectionLines.length !== 0) { + throw new Error( + `WGC helper unexpectedly injected a default sink-writer failure: ${combinedHelperOutput}`, + ); } if ((WITH_SYSTEM_AUDIO || WITH_MICROPHONE) && !hasAudio) { throw new Error(`WGC helper output has no audio stream: ${outputPath}`); @@ -396,6 +478,7 @@ console.log( selectedWebcamDeviceName: webcamFormat?.deviceName, nativeMicrophoneDiagnostics, nativeWebcamDiagnostics, + fallbackDiagnostics, firstFrameLuma: frameLuma, }, null,