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
+78 -15
View File
@@ -86,6 +86,20 @@ public sealed class MediaCaptureFrameSource : ICameraFrameSource
}
}
/// <summary>
/// Ordered reader-output-subtype candidates for a camera whose live media type
/// is <paramref name="activeSubtype"/>. 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.
/// </summary>
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<bool> 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<string>();
foreach (var outputSubtype in ReaderSubtypeCandidates(activeSubtype))
{
var attempt = outputSubtype ?? "<default>";
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)
{