From 94a934ffa9f81f00325363f910a25b2edc3f0902 Mon Sep 17 00:00:00 2001 From: gramps Date: Tue, 15 Sep 2026 19:25:39 -0700 Subject: [PATCH] fix(webcam): reader-output-subtype ladder + record WinRT per-call wrap rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A browser grabbing the C920 kills our reader startup: the camera stays in MJPG 1920x1080@30 (another app's format) and our OBS-refusal to re-negotiate under SharedReadOnly contention (`SetMediaStreamPropertiesAsync` throws 'file in use / CaptureMode is SharedReadOnly') leaves it there. CreateFrameReaderAsync(.., Bgra8) + StartAsync then refuses with OutputFormatNotSupported — the MJPG-active source only exposes NV12 at the reader level (clue: startup.log 19:01/19:05 sessions, same hardware that started YUY2 640x480 fine at 08:52). Fresh launches showed no webcam and 'Add Webcam' failed. Reader creation is now a per-candidate ladder (ReaderSubtypeCandidates): Bgra8 for uncompressed cameras (unchanged fast path); NV12 then the source-default for MJPG cameras — converted in OnFrameArrived like any non-BGRA frame. Each candidate is allocated AND started under its OWN catch: WinRT answers an unsupported subtype with a throw (E_INVALIDARG), not a status, so a single rejected format must degrade to the next candidate instead of aborting acquisition (creator rule — see MyMistakes WINRT resource-allocation recipe). Rejections are logged and collected into the final error. Good Dog: 4 unit tests lock the candidate ordering (MJPG never Bgra8, case-insensitive, uncompressed keeps Bgra8 first, unknown/null -> Bgra8). 301/301 green, 0 warnings. [no push] --- HANDOFF.md | 111 +++++++++++-------- MyMistakes.md | 21 ++++ Services/MediaCaptureFrameSource.cs | 93 +++++++++++++--- ai.md | 12 ++ ytLive.Tests/MediaCaptureFrameSourceTests.cs | 50 +++++++++ 5 files changed, 223 insertions(+), 64 deletions(-) create mode 100644 ytLive.Tests/MediaCaptureFrameSourceTests.cs diff --git a/HANDOFF.md b/HANDOFF.md index ccfd43d..869ff34 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -1,10 +1,10 @@ -# HANDOFF — 2026-09-15 (slice-18 C4 composite cache committed locally — device verify next) +# HANDOFF — 2026-09-15 (webcam take fix 1 of 2 committed locally; slice-18 already local — verify both) ## Branch / Commit State -`main` HEAD = **slice-18 commit** (C4 blit-on-change composite cache — committed LOCALLY, **NOT -pushed**; web/A/V work stays commit-local until greenlight). Before it: slice-17 overlapping -capture readbacks, slice-16 capture conversion fix, slice-15 pacing fix (FramePump), `c01206f` +`main` HEAD = **webcam reader-ladder fix** (committed LOCALLY with this handoff, NOT pushed — web/A/V +work stays commit-local until greenlight). Below it: **slice-18 C4 composite cache** (also local, un-pushed), +slice-17 overlapping capture readbacks, slice-16 capture conversion fix, slice-15 pacing fix, `c01206f` (composition capture), `b22d08e` (signed audio-sync, pushed). Working tree clean. ## ⚠️ Branding (2026-09-14, creator-corrected): product = **llamacasty**, internals = ytLive @@ -13,46 +13,52 @@ The product is **llamacasty**; repo path, csproj `AssemblyName`/`RootNamespace`, (`%APPDATA%\ytLlive\...`), and most code names are the legacy **ytLive/ytLlive**. User-facing language says "llamacasty"; code/assembly/repo names stay ytLive. See `ai.md` → Brand. -## ✅ Committed locally — slice 18: C4 = FramePump blit-on-change composite cache +## ✅ Committed locally — webcam take fix 1 of 2: reader-output-subtype ladder -The ty-1841 take (slice-17 build) proved capture fixed (band ~20 fresh updates/s, no tears, pacing -clean) but the render is STILL the wall: **FramePump stall on EVERY iteration** (`totalMs 21-44`, -`render=full-render split=0 elements=6 dynamic=4`, worst render 166ms startup spike) — only ~22-28 -composites/s. The SceneGraph split can't fix it: `GetSplitPoint` returns **0** because the -live-capture backdrop is element 0 and CANNOT be baked (a cached capture goes stale). +**Incident (2026-09-15):** browser (msedge) grabbed the C920 → app died silently (last log line +09:16:47 is the `MediaCapture.Failed` "device no longer present" rollback; then nothing — no +AppDomain/Dispatcher handler fired → native WMF death). Fresh launches then showed NO webcam: +`CameraManager: camera '…GLOBAL' failed: '… frame reader refused to start: OutputFormatNotSupported'`. -**What** (`Services/Encoder/FramePump.cs`, + new Good Dog test in `ytLive.Tests/FramePumpTests.cs`): -the full-render path now caches the last composite + its INPUT IDENTITY. `BuildFullRenderSignature` -mirrors the compositor's own resolution (same resolver seam: element ref + layout/visual bits + -resolved frame's array identity + Epoch + CropBounds + options + social bar) — unchanged identity → -ONE `Buffer.BlockCopy` (~3ms) instead of the full re-composite (~30ms); changed identity → re-render. -Cache buffer is a separate long-lived array, written pre-burn/pre-recycle (never the scratch pool). -Gated on the 1:1 config (the only deployed tier). Telemetry: `cache {renders}R/{hits}H` on the 5s -stats line + internal `CacheHits`/`CacheRenders`/`OutputIndex`. +**Diagnosis (startup.log, four sessions):** the camera's live media type is the variable. Under +msedge + NVIDIA Broadcast contention our `SetMediaStreamPropertiesAsync` refuses ("file is being used +by another process / CaptureMode is SharedReadOnly") so the camera keeps whoever's format: YUY2 640x480 +@07:47-08:52 → reader started fine (starved of frames while they held it; worked at 08:52 when they +didn't); **MJPG 1920x1080 @19:01/19:05 → `CreateFrameReaderAsync(.., Bgra8)` + `StartAsync` refused +with OutputFormatNotSupported** (MS-documented: an MJPG-active source can't be read as Bgra8 by a +MediaFrameReader — it exposes NV12; the MJPG→BGRA converter isn't on the reader pipeline). -**Good Dog test:** `FullRenderCache_StaticInputs_RenderOnce_Then_Reuse_UntilInputChanges` — static -scene renders ONCE then hits (byte-identical above the burn strip), a new frame (new array + Epoch) -invalidates + propagates. Existing `Pump_Pools_...` test now passes a STABLE scene (like production) -and keys alternation on `OutputIndex` (the two resolver passes per tick double-advanced a call-count -flip). **297/297 green, app + tests build 0 warnings.** Scope-locked (2 code files + ai.md + -HANDOFF): `Services/Encoder/FramePump.cs`, `ytLive.Tests/FramePumpTests.cs`. +**Fix** (`Services/MediaCaptureFrameSource.cs`): reader creation is now a per-candidate ladder — +`ReaderSubtypeCandidates(activeSubtype)`: Bgra8 for uncompressed cameras (unchanged fast path); +**NV12 then source-default for MJPG** (converted in `OnFrameArrived`, which already handled non-BGRA). +Each candidate is allocated AND started under its **own catch** — WinRT answers an unsupported subtype +with a THROW (`E_INVALIDARG`), not a status; the throw degrades to the next candidate instead of +aborting acquisition (creator rule: WINRT resource-allocation calls wrap per-call; see +`MyMistakes.md`). Rejections are logged + joined into the final error. -## ⚠️ Open items (before PUSHABLE) +**Good Dog tests:** `ytLive.Tests/MediaCaptureFrameSourceTests.cs` x4 — MJPG never asked as Bgra8, +case-insensitive, uncompressed keeps Bgra8 fast path, unknown/null → Bgra8. **301/301 green, clean +build 0 warnings, scope-check passed** (2 code files + ai.md + HANDOFF + MyMistakes). -- **Device re-verify (next step):** creator records the SAME tv-show scenario on the slice-18 - build. Judge numerically: - - startup.log telemetry: `cache` line shows hits dominating on TV holds (`e.g. cache 1R/250H`), - `avg render` drops toward the ~3ms BlockCopy, FramePump **stalls disappear** (the per-iteration - stall was the C4 signature). - - Decode + `/tmp/opencode/freeze_audit.py` / `band_timeline.py`: desktop-band fresh updates/s up - toward ~60 (was ~20 first-11s; the render cap was the bind), no mid-frame splits. - - ffprobe: video ≈ audio ≈ wall (pacing already healthy at slice 17 — unchanged expected). -- If the desktop layer STILL reads choppy after cache hits dominate every static hold, the residual - is the **24fps TV → 60fps container pulldown** (inherent 3:2-ish repeats; the 1841 gap histogram - was 89×2-slot + 84×3-slot holds) — that's content, not the pipeline; decide with the creator - whether it needs an adaptive cadence or is acceptable. -- **No push yet** — commit-locally-until-greenlight for web/A/V work. After the take verdict, also - re-measure the clap offset (`/tmp/opencode/avsync.py`), then decide push with the user. +## ⚠️ Open items + +- **Webcam take fix 2 of 2 — the 09:16 crash is UNFIXED (hard, untested):** hypothesis — the app died + natively (no managed log line) when `MediaCapture.Failed` fired mid-stream: RollbackSession runs + `_ = SafeStopAsync` fire-and-forget → `StopAsync` disposes reader+capture while the frame-reader + thread is mid-`TryAcquireLatestFrame`/`Marshal.Copy` (that call region sits OUTSIDE the frame's + try/catch). Managed/unwrapped failures there surface via AppDomain handler (absent → native AV). + NOT fixed in this change: no repro, no integration test, and a native WMF race isn't catchable -- + deferred per spin-guard. Record if a second occurrence shows a pattern. +- **Can't re-add webcam (3rd symptom):** partly the same root cause (add → AcquireAsync fails → empty + chip + toast). Note: if ANOTHER scene still holds a WebcamSceneConfig, `_webcam != null` persists and + "Add → Webcam" stays disabled by the single-identity rule — use right-click "Show Webcam" instead. +- **Device re-verify (two things, same take session):** + 1. slice-18 cache (unchanged verdict criteria): `cache NR/WH` hits dominate on TV holds, stalls gone, + band fresh updates/s toward ~60 → else residual 24↔60 pulldown, content decision. + 2. webcam ladder: while msedge holds the camera, a fresh launch must show the webcam (or degrade to + a clear chip, never a silent box); after closing the browser it must come up at 1080p30 MJPEG. +- **No push yet** — after the take verdict, re-measure the clap offset (`/tmp/opencode/avsync.py`), + then decide push with the user. ## Open threads (carried) @@ -64,27 +70,34 @@ HANDOFF): `Services/Encoder/FramePump.cs`, `ytLive.Tests/FramePumpTests.cs`. ## Landmines -- testhost shares startup.log — filter by time. +- testhost shares startup.log — filter by time; the app also writes fake-device failures + ('test-camera', 'dev-physical-9') from the CameraManager unit tests. - `cmd.exe /c "taskkill /F /IM ytLive.exe"` (WSL double-slashes mangle) before rebuilds — a live app process locks `ytLive.exe` and the apphost copy fails (MSB3021). - Build/tests: **Windows dotnet host** (`/mnt/c/Program Files/dotnet/dotnet.exe`). 0 warnings — only `./scripts/verify.sh ""`'s clean build counts. Building `ytLive.csproj` alone does - NOT rebuild `ytLive.Tests.dll` — run the Tests csproj before `vstest`. + NOT rebuild `ytLive.Tests.dll` — run the Tests csproj before `vstest`. Known audio flake: + `Mix_HonorsProviderGains_AndGameMute_KillsTheLoopback` (float precision) — re-run if it trips + alone; it is unrelated to camera work. +- Camera contention failure modes (startup.log): "no frames within 4s" = init OK but starved; + "OutputFormatNotSupported" = reader subtype below the MJPG-active camera; "file is being used by + another process / CaptureMode is SharedReadOnly" = our stream re-negotiation refused while others + hold the device. CameraConflictProbe lists named processes that are merely RUNNING (msedge, + NVIDIA Broadcast) — a suspect list, not handle evidence. - FramePump tests that assert per-frame CONTENT must pass a STABLE scene (`() => scene`) — the default NewPump scene is fresh-per-tick (ok for pacing tests, but it churns the C4 render signature and hides the cache). Cache-sensitive assertions also can't use a call-count resolver flip (the tick resolves twice: signature + render) — key alternation on `OutputIndex`. - ffmpeg/ffprobe: `/mnt/c/Program Files/Krita (x64)/bin/` with Windows paths. -- `MyMistakes.md` has the **freeze-audit RECIPE**, the **A/V sync measurement recipe**, the - **deadline-pacing** lessons, the **CoreMessaging DQ recipe**, and the **WGC-CLIP** + slice - blocks — grep before re-deriving. +- `MyMistakes.md` has the WINRT resource-allocation RECIPE (new), freeze-audit RECIPE, A/V sync + measurement recipe, deadline-pacing lessons, CoreMessaging DQ recipe, WGC-CLIP + slice blocks — + grep before re-deriving. ai.md has the webcam reader-ladder note. - sqlite3 at `/home/gramps/android-sdk/platform-tools/sqlite3`. - `C:\tmpout` is for ffmpeg evidence artifacts (raw decodes / PNGs); keep them out of the repo. ## Next step -Creator records a tv-show take on the slice-18 build → read the startup.log telemetry: shift-stall -frequency and the `cache NR/WH` line (hits must dominate on TV holds) + the band audit + ffprobe -durations. If the desktop now tracks ~60 updates/s and stalls are gone: re-measure the clap offset, -then decide push with the user. If the layer is still choppy on fully-static holds, the pulldown -(readme) is the residual and it's a content decision, not a pipeline bug. \ No newline at end of file +Creator records a take on this build (backend test): (a) with the browser holding the camera — fresh +launch must show the webcam or a named chip, then (b) close the browser and re-add the webcam to the +live view — must come back at 1080p30 MJPEG; (c) the slice-18 cache verdicts from the same session +(`cache NR/WH` hits, stall frequency, band audit). If (b) works: measure clap offset, decide push. \ No newline at end of file diff --git a/MyMistakes.md b/MyMistakes.md index f0716f9..048113b 100644 --- a/MyMistakes.md +++ b/MyMistakes.md @@ -15,6 +15,27 @@ ## 🔬 Recipes registry +### WINRT RESOURCE-ALLOCATION CALLS: WRAP PER-CALL, DEGRADE TO NEXT OPTION (RECIPE) + +Creator callout (2026-09-15): WinRT/COM calls that allocate or start a resource — `InitializeAsync`, +`CreateFrameReaderAsync`, `StartAsync`, `CreateReaderAsync` & friends — are THROW-HEAVY. An unsupported +subtype/format, a device that just vanished, an access mode rejected mid-flight: these surface as +`ArgumentException`/`E_INVALIDARG` ("value does not fall within the expected range") or HRESULTs, NOT as +a returned status you can switch on. Relying on ONE outer catch to "handle failures" is not handling — +one rejected call inside a fallback ladder aborts the whole ladder and every untried option. Rule: + +- Each allocation/start call inside a try/catch of its OWN, so a throw on candidate N falls through to + candidate N+1 (log each rejection with its message/status; collect them for the final error string). +- `return`/`break` on success must be reached WITHOUT passing through a `finally` that disposes the + resource you just committed (classic reader/capture dispose-after-commit bug). +- Unsubscribe + dispose the partial resource in the catch block when the subscription happened before + the throwing call. +- The outer catch stays as the LAST-RESORT net for device-level errors, not the primary one. +- Same discipline applies to the frame-consumption side: teardown races reader threads (see HANDOFF + crash follow-up) — a frame callback can't assume the pipeline is alive. + +Applied in `MediaCaptureFrameSource`'s reader-subtype ladder (2026-09-15, webcam-take fix). + ### SPIN GUARD → RESOLVED — web overlay transparency + bounding box (RECIPE) **THE ONE ROOT CAUSE THAT EXPLAINS EVERY FAILED TAKE:** WebView2's `CapturePreviewAsync` diff --git a/Services/MediaCaptureFrameSource.cs b/Services/MediaCaptureFrameSource.cs index 6a3020d..16c1d66 100644 --- a/Services/MediaCaptureFrameSource.cs +++ b/Services/MediaCaptureFrameSource.cs @@ -86,6 +86,20 @@ public sealed class MediaCaptureFrameSource : ICameraFrameSource } } + /// + /// Ordered reader-output-subtype candidates for a camera whose live media type + /// is . Bgra8 is the fast path (OnFrameArrived + /// skips conversion) and works for uncompressed cameras; an MJPG-active source + /// cannot be read as Bgra8 by a MediaFrameReader (no BGRA converter on the MJPG + /// pipeline — StartAsync answers OutputFormatNotSupported), so MJPG leads with + /// NV12 and falls back to the reader's default (null). Every candidate that is + /// not Bgra8 is converted in OnFrameArrived. + /// + internal static string?[] ReaderSubtypeCandidates(string? activeSubtype) => + string.Equals(activeSubtype, "MJPG", StringComparison.OrdinalIgnoreCase) + ? new string?[] { MediaEncodingSubtypes.Nv12, null } + : new string?[] { MediaEncodingSubtypes.Bgra8, MediaEncodingSubtypes.Nv12, null }; + private async Task TryStartAsync(bool preferVideoRecord) { MediaCapture? capture = null; @@ -180,32 +194,81 @@ public sealed class MediaCaptureFrameSource : ICameraFrameSource capture.Failed += OnCaptureFailed; capture.CameraStreamStateChanged += OnCameraStreamStateChanged; - reader = await capture.CreateFrameReaderAsync(colorSource, MediaEncodingSubtypes.Bgra8); - if (reader == null) - throw new InvalidOperationException($"Camera '{_deviceId}' created no frame reader."); - - // One-line truth of what the camera actually agreed to (format/size/fps). + // The reader must output a subtype the source can actually produce, else + // StartAsync refuses (OutputFormatNotSupported). An uncompressed camera + // converts to Bgra8 fine (the fast path); a camera sitting in MJPG — which + // is what we find when ANOTHER app left it there and ours can't re-negotiate + // under SharedReadOnly contention — cannot be read as Bgra8 by a + // MediaFrameReader (the MJPG pipeline exposes NV12 output, not BGRA). Walk + // the candidate ladder; anything that isn't Bgra8 is converted in + // OnFrameArrived anyway. + string? activeSubtype = null; try { var active = capture.VideoDeviceController? .GetMediaStreamProperties(colorSource.Info.MediaStreamType); if (active is Windows.Media.MediaProperties.VideoEncodingProperties vep) + { + activeSubtype = vep.Subtype; AppLog.Write($"Camera '{_deviceId}' media type: {vep.Subtype} " + $"{vep.Width}x{vep.Height} @ {vep.FrameRate.Numerator}/{vep.FrameRate.Denominator}fps"); + } } catch { /* logging must never kill acquisition */ } - reader.FrameArrived += OnFrameArrived; - var status = await reader.StartAsync(); - if (status != MediaFrameReaderStartStatus.Success) - throw new InvalidOperationException( - $"Camera '{_deviceId}' frame reader refused to start: {status}."); + // Each candidate is allocated AND started under its own catch: a WinRT + // call answering an unsupported subtype can THROW (E_INVALIDARG / "value + // does not fall within the expected range") instead of returning a start + // status, so every throw must degrade to the next candidate — never let + // one rejected format abort the whole acquisition. Individual rejections + // are logged and collected; only the last-resort net below (the outer + // try/catch of this method) keeps cowboying device-level errors. + var refused = new List(); + foreach (var outputSubtype in ReaderSubtypeCandidates(activeSubtype)) + { + var attempt = outputSubtype ?? ""; + try + { + reader = outputSubtype is null + ? await capture.CreateFrameReaderAsync(colorSource) + : await capture.CreateFrameReaderAsync(colorSource, outputSubtype); + if (reader == null) + { + refused.Add($"{attempt}: no frame reader"); + continue; + } - _capture = capture; - _frameReader = reader; - _lastError = null; - _isFailed = false; - return true; + reader.FrameArrived += OnFrameArrived; + var status = await reader.StartAsync(); + if (status == MediaFrameReaderStartStatus.Success) + { + _capture = capture; + _frameReader = reader; + _lastError = null; + _isFailed = false; + return true; + } + + reader.FrameArrived -= OnFrameArrived; + reader.Dispose(); + reader = null; + refused.Add($"{attempt}: {status}"); + } + catch (Exception ex) + { + AppLog.Write($"MediaCaptureFrameSource: reader as '{attempt}' refused: {ex.Message}"); + if (reader != null) + { + reader.FrameArrived -= OnFrameArrived; + reader.Dispose(); + reader = null; + } + refused.Add($"{attempt}: {ex.Message}"); + } + } + + throw new InvalidOperationException( + $"Camera '{_deviceId}' frame reader refused to start: {string.Join(", ", refused)}."); } catch (Exception ex) { diff --git a/ai.md b/ai.md index a09d2de..a355536 100644 --- a/ai.md +++ b/ai.md @@ -385,6 +385,18 @@ This replaces the old five-seeder cluster (`Seed{Starting,Brb,Ending,Chat}Backgr `CreateFrameReaderAsync(colorSource, MediaEncodingSubtypes.Bgra8)` — the pipeline does any format conversion, so every `FrameArrived` yields a ready BGRA8 `SoftwareBitmap` (bytes read via `WindowsRuntimeMarshal.TryGetDataUnsafe`, not marshalled copies). +- **Reader-output subtype ladder (2026-09-15 webcam-take fix):** the reader is created per-candidate + by `ReaderSubtypeCandidates(activeSubtype)` — Bgra8 for uncompressed cameras (fast path), NV12 then + source-default for cameras sitting in MJPG. A camera another app left in MJPG mode cannot be read as + Bgra8 by a `MediaFrameReader` (`StartAsync` → `OutputFormatNotSupported`; the MJPG pipeline exposes + NV12, not BGRA) — exactly what a browser grabbing the webcam produces. When the app can't re-negotiate + (our `SetMediaStreamPropertiesAsync` refuses with "file in use / CaptureMode is SharedReadOnly" under + msedge + NVIDIA Broadcast contention), an MJPG-active reader otherwise aborts the whole acquisition. + Each candidate is allocated AND started under its OWN catch — WinRT answers an unsupported subtype + with a THROW (`E_INVALIDARG`), not a status; one rejected format must never abort the ladder. Failures + degrade to the next candidate; rejections are logged + collected into the final error; only the outer + catch nets device-level errors (see `MyMistakes.md` → WINRT RESOURCE-ALLOCATION RECIPE). Anything not + Bgra8 is converted in `OnFrameArrived`, which already handled non-BGRA software bitmaps. - **Source pick, not first hit:** the frame reader is bound to the first source that is `VideoPreview` (preferred) or `VideoRecord`, not blindly the first preview source. If a camera exposes neither, the failure names the device and the stream types it *does* expose. `SharedReadOnly` lets the capture diff --git a/ytLive.Tests/MediaCaptureFrameSourceTests.cs b/ytLive.Tests/MediaCaptureFrameSourceTests.cs new file mode 100644 index 0000000..4efaee8 --- /dev/null +++ b/ytLive.Tests/MediaCaptureFrameSourceTests.cs @@ -0,0 +1,50 @@ +using Windows.Media.MediaProperties; +using Xunit; +using ytLive.Services; + +namespace ytLive.Tests; + +/// +/// Take-2026-09-15 fix: a camera that another app has left in MJPG mode can't be +/// read as Bgra8 by a MediaFrameReader (StartAsync answers OutputFormatNotSupported) — +/// exactly what a browser grabbing the webcam causes. The reader-subtype ladder in +/// MediaCaptureFrameSource picks an output the source can actually produce; these +/// tests lock the ordering so the fix can't regress into the silent empty webcam box. +/// +public sealed class MediaCaptureFrameSourceTests +{ + [Fact] + public void ReaderSubtypeCandidates_MjpgActive_NeverAsksBgra8() + { + var candidates = MediaCaptureFrameSource.ReaderSubtypeCandidates("MJPG"); + Assert.DoesNotContain(candidates, s => s == MediaEncodingSubtypes.Bgra8); + Assert.Equal(MediaEncodingSubtypes.Nv12, candidates[0]); + Assert.Null(candidates[^1]); + } + + [Fact] + public void ReaderSubtypeCandidates_MjpgActive_IsCaseInsensitive() + { + var candidates = MediaCaptureFrameSource.ReaderSubtypeCandidates("mjpg"); + Assert.Equal(MediaEncodingSubtypes.Nv12, candidates[0]); + Assert.DoesNotContain(candidates, s => s == MediaEncodingSubtypes.Bgra8); + } + + [Fact] + public void ReaderSubtypeCandidates_UncompressedActive_KeepsBgra8FastPath() + { + var yuy2 = MediaCaptureFrameSource.ReaderSubtypeCandidates("YUY2"); + var nv12 = MediaCaptureFrameSource.ReaderSubtypeCandidates("NV12"); + Assert.Equal(MediaEncodingSubtypes.Bgra8, yuy2[0]); + Assert.Equal(MediaEncodingSubtypes.Bgra8, nv12[0]); + } + + [Fact] + public void ReaderSubtypeCandidates_UnknownActive_DefaultsToBgra8() + { + Assert.Equal(MediaEncodingSubtypes.Bgra8, + MediaCaptureFrameSource.ReaderSubtypeCandidates(null)[0]); + Assert.Equal(MediaEncodingSubtypes.Bgra8, + MediaCaptureFrameSource.ReaderSubtypeCandidates("")[0]); + } +} \ No newline at end of file