Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions electron/ipc/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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" };
Expand All @@ -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);
Expand Down
34 changes: 33 additions & 1 deletion electron/native/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ Current V2 JSON shape:
"videoWidth": 1920,
"videoHeight": 1080,
"fps": 60,
"preferSoftwareEncoder": false,
"captureSystemAudio": false,
"captureMic": false,
"microphoneDeviceId": "default",
Expand All @@ -83,19 +84,50 @@ 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
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
Expand Down
29 changes: 26 additions & 3 deletions electron/native/wgc-capture/src/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<DWORD>(sizeof(injectDefaultSinkWriterFailure)));
const bool injectDefaultSinkWriterFailureOnce =
injectDefaultSinkWriterFailureLength == 1 &&
injectDefaultSinkWriterFailure[0] == '1';

std::cout << "{\"event\":\"ready\",\"schemaVersion\":2}" << std::endl;

WgcSession session;
Expand Down Expand Up @@ -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),
Expand All @@ -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(
Expand All @@ -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;
}
Expand Down
Loading
Loading