diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index ae8f5525d..852250ec4 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); @@ -1621,9 +1640,17 @@ export function registerIpcHandlers( : null; const cursorCaptureMode = normalizeCursorCaptureMode(request.cursor?.mode) ?? "editable-overlay"; + const envPreferSoftwareEncoder = (process.env.OPENSCREEN_WGC_PREFER_SOFTWARE_ENCODER ?? "") + .trim() + .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, @@ -1675,6 +1702,7 @@ export function registerIpcHandlers( source: request.source, audio: request.audio, webcam: request.webcam, + encoder: { preferSoftwareEncoder }, cursor: { mode: cursorCaptureMode }, bounds, sourceId: selectedSource?.id ?? null, @@ -1720,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" }; @@ -1736,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 cda6c9f55..79399d46b 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,18 @@ 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`). 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: ```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 @@ -96,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 af73b26fc..30a3069d6 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); @@ -394,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; @@ -502,6 +513,10 @@ int main(int argc, char* argv[]) { << "}" << std::endl; } + MFEncoderOptions encoderOptions{}; + encoderOptions.preferSoftwareEncoder = config.preferSoftwareEncoder; + encoderOptions.injectDefaultSinkWriterFailureOnce = injectDefaultSinkWriterFailureOnce; + MFEncoder encoder; if (!encoder.initialize( utf8ToWide(config.outputPath), @@ -511,13 +526,20 @@ 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) { + 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( @@ -528,7 +550,8 @@ int main(int argc, char* argv[]) { webcamBitrate, session.device(), session.context(), - nullptr)) { + nullptr, + 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 738a29b58..42b708910 100644 --- a/electron/native/wgc-capture/src/mf_encoder.cpp +++ b/electron/native/wgc-capture/src/mf_encoder.cpp @@ -123,6 +123,150 @@ void logMissingH264EncoderError() { << std::endl; } +// 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; + 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 | MFT_ENUM_FLAG_ASYNCMFT, + 1, + &inputType, + 1, + &outputType); + 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; +} + +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 + // 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; + } + + HRESULT hr = MFCreateAttributes(&attributes, 1); + 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; + 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) { + 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 +328,10 @@ MFEncoder::~MFEncoder() { finalize(); } +const char* MFEncoder::videoEncoderSelection() const { + return videoEncoderSelection_; +} + bool MFEncoder::initialize( const std::wstring& outputPath, int width, @@ -192,21 +340,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 +365,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 +377,86 @@ bool MFEncoder::initialize( setFrameRate(inputType.Get(), static_cast(fps_)); setPixelAspectRatio(inputType.Get()); - if (!succeeded(sinkWriter_->SetInputMediaType(videoStreamIndex_, inputType.Get(), nullptr), - "SetInputMediaType")) { - return false; + bool injectedDefaultSinkWriterFailure = false; + + auto resetSinkWriterAttempt = [&]() { + sinkWriter_.Reset(); + videoStreamIndex_ = 0; + audioStreamIndex_ = 0; + hasAudioStream_ = false; + videoEncoderSelection_ = kVideoEncoderSelectionDefault; + }; + + auto configureSinkWriterAttempt = [&, audioFormat]( + bool forceSoftwareEncoder, + const char* selection, + bool logCreateFailure) { + resetSinkWriterAttempt(); + + SinkWriterCreateStage failedStage = SinkWriterCreateStage::CreateSinkWriter; + const HRESULT sinkWriterHr = createSinkWriterFromUrl( + outputPath, + forceSoftwareEncoder, + options.injectDefaultSinkWriterFailureOnce, + injectedDefaultSinkWriterFailure, + sinkWriter_, + failedStage); + if (FAILED(sinkWriterHr)) { + // 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; + } + 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..8f5787481 100644 --- a/electron/native/wgc-capture/src/mf_encoder.h +++ b/electron/native/wgc-capture/src/mf_encoder.h @@ -26,6 +26,15 @@ struct AudioInputFormat { UINT32 avgBytesPerSec = 0; }; +struct MFEncoderOptions { + bool preferSoftwareEncoder = false; + bool injectDefaultSinkWriterFailureOnce = false; +}; + +constexpr const char* kVideoEncoderSelectionDefault = "default"; +constexpr const char* kVideoEncoderSelectionSoftwarePreferred = "software-preferred"; +constexpr const char* kVideoEncoderSelectionSoftwareFallback = "software-fallback"; + class MFEncoder { public: MFEncoder() = default; @@ -42,11 +51,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 +83,5 @@ class MFEncoder { int64_t firstTimestampHns_ = -1; int64_t lastTimestampHns_ = -1; bool finalized_ = false; + const char* videoEncoderSelection_ = kVideoEncoderSelectionDefault; }; diff --git a/scripts/test-windows-wgc-helper.mjs b/scripts/test-windows-wgc-helper.mjs index 5dd2dccf2..c6c69441e 100644 --- a/scripts/test-windows-wgc-helper.mjs +++ b/scripts/test-windows-wgc-helper.mjs @@ -26,10 +26,29 @@ 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"); +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, }); @@ -237,6 +256,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", @@ -272,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(); @@ -302,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"')); @@ -314,6 +337,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")); @@ -327,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}`); } @@ -339,6 +386,57 @@ 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 ( + encoderSelection?.video !== expectedEncoderSelection || + encoderSelection.preferSoftwareEncoder !== WITH_SOFTWARE_ENCODER +) { + 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}`); } @@ -375,10 +473,12 @@ console.log( duration: stream.duration, })), cursorCapture, + encoderSelection, selectedMicrophoneDeviceName: audioFormat?.microphoneDeviceName, selectedWebcamDeviceName: webcamFormat?.deviceName, nativeMicrophoneDiagnostics, nativeWebcamDiagnostics, + fallbackDiagnostics, firstFrameLuma: frameLuma, }, null, 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..1136d8537 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( @@ -650,41 +669,80 @@ export function LaunchWindow() { } }} > - {systemLocaleSuggestion && ( -
-
- {t("systemLanguagePrompt.title")} -
-
- {t("systemLanguagePrompt.description", { - language: suggestedLanguageName, - })} -
-
- -
+
+ {t("systemLanguagePrompt.description", { + language: suggestedLanguageName, + })} +
+
+ + +
+
+ )} + + {softwareEncoderFallbackNoticeVisible && ( +
- {t("systemLanguagePrompt.switch", { - language: suggestedLanguageName, - })} - -
+
+ {t("softwareEncoderFallback.title")} +
+
+ {t("softwareEncoderFallback.description")} +
+
+ + +
+ + )} )} diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index 72ddc529b..656509763 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, saveUserPreferences } from "@/lib/userPreferences"; import { createRecorderHandle, type RecorderHandle } from "./recorderHandle"; const TARGET_FRAME_RATE = 60; @@ -73,6 +74,8 @@ type UseScreenRecorderReturn = { setWebcamEnabled: (enabled: boolean) => Promise; cursorCaptureMode: CursorCaptureMode; setCursorCaptureMode: (mode: CursorCaptureMode) => void; + softwareEncoderFallbackNoticeVisible: boolean; + dismissSoftwareEncoderFallbackNotice: (dontShowAgain?: boolean) => void; }; type NativeWindowsRecordingHandle = { @@ -102,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); @@ -844,6 +849,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { } const request: NativeWindowsRecordingRequest = { recordingId: activeRecordingId, + preferSoftwareEncoder: loadUserPreferences().preferSoftwareEncoder, source: { type: sourceType, sourceId: selectedSource.id, @@ -889,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, @@ -1680,6 +1693,13 @@ export function useScreenRecorder(): UseScreenRecorderReturn { } }; + const dismissSoftwareEncoderFallbackNotice = (dontShowAgain = false) => { + if (dontShowAgain) { + saveUserPreferences({ hideSoftwareEncoderFallbackNotice: true }); + } + setSoftwareEncoderFallbackNoticeVisible(false); + }; + return { recording, paused, @@ -1706,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 5e0685171..313eff9b5 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; @@ -44,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 8a64295c5..ca66c53d5 100644 --- a/src/lib/userPreferences.test.ts +++ b/src/lib/userPreferences.test.ts @@ -103,4 +103,34 @@ 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); + }); + + 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 66c5a66a4..f166d5e5e 100644 --- a/src/lib/userPreferences.ts +++ b/src/lib/userPreferences.ts @@ -33,6 +33,10 @@ 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; + /** Stop showing the notice that recording fell back to software encoding */ + hideSoftwareEncoderFallbackNotice: boolean; } export const DEFAULT_PREFS: UserPreferences = { @@ -43,6 +47,8 @@ export const DEFAULT_PREFS: UserPreferences = { exportFolder: null, projectFolder: null, trayLayout: "horizontal", + preferSoftwareEncoder: false, + hideSoftwareEncoderFallbackNotice: false, }; /** Parses stored preferences without throwing on malformed JSON. */ @@ -99,6 +105,14 @@ 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, + hideSoftwareEncoderFallbackNotice: + typeof raw.hideSoftwareEncoderFallbackNotice === "boolean" + ? raw.hideSoftwareEncoderFallbackNotice + : DEFAULT_PREFS.hideSoftwareEncoderFallbackNotice, }; }