fix(webcam): reader-output-subtype ladder + record WinRT per-call wrap rule

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]
This commit is contained in:
2026-09-15 19:25:39 -07:00
parent 64a5a6d06f
commit 94a934ffa9
5 changed files with 223 additions and 64 deletions
@@ -0,0 +1,50 @@
using Windows.Media.MediaProperties;
using Xunit;
using ytLive.Services;
namespace ytLive.Tests;
/// <summary>
/// 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.
/// </summary>
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]);
}
}