From 60259c08c29ff2800c5fae178862cd6736490198 Mon Sep 17 00:00:00 2001 From: gramps Date: Wed, 12 Aug 2026 07:11:54 -0700 Subject: [PATCH] =?UTF-8?q?Fix=20CameraStreamState.Failed=20compile=20erro?= =?UTF-8?q?r=20(19041=20projection=20omits=20enum=20member=20names=20?= =?UTF-8?q?=E2=80=94=20compare=20by=20int);=20fix=20first-frame=20proof=20?= =?UTF-8?q?test=20(no=20dispatcher=20=3D=20no=20bitmap,=20assert=20LatestF?= =?UTF-8?q?rame=20instead)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Services/MediaCaptureFrameSource.cs | 188 ++++++++++++++++++++++------ ytLive.Tests/CameraManagerTests.cs | 110 ++++++++++++---- 2 files changed, 235 insertions(+), 63 deletions(-) diff --git a/Services/MediaCaptureFrameSource.cs b/Services/MediaCaptureFrameSource.cs index 4d96803..72a8163 100644 --- a/Services/MediaCaptureFrameSource.cs +++ b/Services/MediaCaptureFrameSource.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Linq; using System.Runtime.InteropServices; using System.Runtime.InteropServices.WindowsRuntime; @@ -6,6 +7,7 @@ using System.Threading.Tasks; using Windows.Graphics.Imaging; using Windows.Media.Capture; using Windows.Media.Capture.Frames; +using Windows.Media.Devices; using Windows.Media.MediaProperties; using ytLive.Helpers; @@ -16,17 +18,28 @@ namespace ytLive.Services; /// preview source; the capture pipeline does any format conversion, so every /// frame arrives as a normalized . Frames arrive on a /// worker thread — marshal before touching WPF. +/// +/// Allocation is validated, never taken on faith: after InitializeAsync +/// the bound device, its frame sources, and its stream properties are checked, +/// the frame reader's start status is read (not swallowed), and the live +/// state signals (Failed, CameraStreamStateChanged) are subscribed +/// so an async death surfaces as instead of a silent +/// empty preview. /// public sealed class MediaCaptureFrameSource : ICameraFrameSource { private readonly string _deviceId; private MediaCapture? _capture; private MediaFrameReader? _frameReader; + private string? _lastError; + private bool _isFailed; public string DeviceId => _deviceId; public event Action? FrameAvailable; + public event Action? SourceFailed; + public MediaCaptureFrameSource(string deviceId) { _deviceId = deviceId; @@ -34,47 +47,15 @@ public sealed class MediaCaptureFrameSource : ICameraFrameSource public async Task StartAsync() { - var capture = new MediaCapture(); - MediaFrameReader? reader = null; - try + // Fallback ladder: prefer the camera's VideoPreview stream, but if the + // reader refuses to start there (NoVideoFrameAvailable), retry against + // its VideoRecord stream — some devices only deliver through it. + // (MediaCaptureSharingMode.Exclusive is not projected by the 19041 SDK.) + if (!await TryStartAsync(preferVideoRecord: false)) { - var settings = new MediaCaptureInitializationSettings - { - VideoDeviceId = _deviceId, - StreamingCaptureMode = StreamingCaptureMode.Video, - MemoryPreference = MediaCaptureMemoryPreference.Cpu, - SharingMode = MediaCaptureSharingMode.SharedReadOnly, - }; - await capture.InitializeAsync(settings); - - var colorSource = capture.FrameSources.Values - .OrderBy(s => s.Info.MediaStreamType == MediaStreamType.VideoPreview ? 0 : 1) - .FirstOrDefault(s => s.Info.MediaStreamType is MediaStreamType.VideoPreview or MediaStreamType.VideoRecord); - if (colorSource == null) - { - var kinds = string.Join(", ", capture.FrameSources.Values - .Select(s => s.Info.MediaStreamType).Distinct()); + if (!await TryStartAsync(preferVideoRecord: true)) throw new InvalidOperationException( - $"Camera '{_deviceId}' exposes no video source (found: {kinds}). " + - "It may be locked by another app (e.g. NVIDIA Broadcast)."); - } - - reader = await capture.CreateFrameReaderAsync(colorSource, MediaEncodingSubtypes.Bgra8); - reader.FrameArrived += OnFrameArrived; - await reader.StartAsync(); - - _capture = capture; - _frameReader = reader; - } - catch - { - if (reader != null) - { - reader.FrameArrived -= OnFrameArrived; - reader.Dispose(); - } - capture.Dispose(); - throw; + _lastError ?? $"Camera '{_deviceId}' could not be started."); } } @@ -98,7 +79,134 @@ public sealed class MediaCaptureFrameSource : ICameraFrameSource var capture = _capture; _capture = null; - capture?.Dispose(); + if (capture != null) + { + UnsubscribeCaptureEvents(capture); + capture.Dispose(); + } + } + + private async Task TryStartAsync(bool preferVideoRecord) + { + MediaCapture? capture = null; + MediaFrameReader? reader = null; + try + { + capture = new MediaCapture(); + var settings = new MediaCaptureInitializationSettings + { + VideoDeviceId = _deviceId, + StreamingCaptureMode = StreamingCaptureMode.Video, + MemoryPreference = MediaCaptureMemoryPreference.Cpu, + SharingMode = MediaCaptureSharingMode.SharedReadOnly, + }; + await capture.InitializeAsync(settings); + + // Post-init resource validation: the capture must actually be bound to + // the device we asked for and must expose a live video controller. + if (!string.Equals(capture.MediaCaptureSettings?.VideoDeviceId, _deviceId, + StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException( + $"Camera '{_deviceId}' initialized but bound a different device."); + + var colorSource = capture.FrameSources.Values + .OrderBy(s => preferVideoRecord + ? (s.Info.MediaStreamType == MediaStreamType.VideoRecord ? 0 : 1) + : (s.Info.MediaStreamType == MediaStreamType.VideoPreview ? 0 : 1)) + .FirstOrDefault(s => s.Info.MediaStreamType is MediaStreamType.VideoPreview or MediaStreamType.VideoRecord); + if (colorSource == null) + { + var kinds = string.Join(", ", capture.FrameSources.Values + .Select(s => s.Info.MediaStreamType).Distinct()); + throw new InvalidOperationException( + $"Camera '{_deviceId}' exposes no video source (found: {kinds}). " + + "It may be locked by another app (e.g. NVIDIA Broadcast)."); + } + + // The device must answer for its preview stream — a dead, suspended, or + // locked device returns no stream properties even after "successful" init. + IReadOnlyList? previewProps; + try + { + previewProps = capture.VideoDeviceController? + .GetAvailableMediaStreamProperties(MediaStreamType.VideoPreview); + } + catch + { + previewProps = null; + } + if (previewProps == null || previewProps.Count == 0) + throw new InvalidOperationException( + $"Camera '{_deviceId}' answered no video stream properties (offline, suspended, or locked)."); + + // The OS's live state signals: async failures must surface, not vanish. + 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."); + + reader.FrameArrived += OnFrameArrived; + var status = await reader.StartAsync(); + if (status != MediaFrameReaderStartStatus.Success) + throw new InvalidOperationException( + $"Camera '{_deviceId}' frame reader refused to start: {status}."); + + _capture = capture; + _frameReader = reader; + _lastError = null; + _isFailed = false; + return true; + } + catch (Exception ex) + { + _lastError = ex.Message; + if (reader != null) + { + reader.FrameArrived -= OnFrameArrived; + reader.Dispose(); + } + if (capture != null) + { + UnsubscribeCaptureEvents(capture); + capture.Dispose(); + } + return false; + } + } + + private void UnsubscribeCaptureEvents(MediaCapture capture) + { + capture.Failed -= OnCaptureFailed; + capture.CameraStreamStateChanged -= OnCameraStreamStateChanged; + } + + private void OnCaptureFailed(MediaCapture sender, MediaCaptureFailedEventArgs args) + => RaiseFailure($"capture failed ({args.Code}): {args.Message}"); + + private void OnCameraStreamStateChanged(MediaCapture sender, object args) + { + // CameraStreamState enum (Windows.Media.Devices): 0=NotStreaming, 1=Streaming, + // 2=Failed, 3=Shutdown. The 19041 SDK projection omits member names, so + // compare by value — the enum type itself resolves via the property return. + if ((int)sender.CameraStreamState == 2) // Failed + RaiseFailure("camera stream state is Failed"); + } + + private void RaiseFailure(string message) + { + if (_isFailed) return; + _isFailed = true; + AppLog.Write($"MediaCaptureFrameSource: {message}"); + try + { + SourceFailed?.Invoke(message); + } + catch (Exception ex) + { + AppLog.Write($"MediaCaptureFrameSource: SourceFailed handler threw: {ex.Message}"); + } } private void OnFrameArrived(MediaFrameReader sender, MediaFrameArrivedEventArgs args) diff --git a/ytLive.Tests/CameraManagerTests.cs b/ytLive.Tests/CameraManagerTests.cs index 67f2f11..bf89b8b 100644 --- a/ytLive.Tests/CameraManagerTests.cs +++ b/ytLive.Tests/CameraManagerTests.cs @@ -1,3 +1,6 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; using Xunit; using ytLive.Services; @@ -9,20 +12,28 @@ public class CameraManagerTests { private readonly List _started; private readonly List _stopped; + private readonly VideoFrame? _pumpOnStart; + private readonly string? _failOnStart; public string DeviceId { get; } public event Action? FrameAvailable; + public event Action? SourceFailed; - public FakeFrameSource(string deviceId, List started, List stopped) + public FakeFrameSource(string deviceId, List started, List stopped, + VideoFrame? pumpOnStart = null, string? failOnStart = null) { DeviceId = deviceId; _started = started; _stopped = stopped; + _pumpOnStart = pumpOnStart; + _failOnStart = failOnStart; } public Task StartAsync() { + if (_failOnStart != null) throw new InvalidOperationException(_failOnStart); _started.Add(DeviceId); + if (_pumpOnStart != null) FrameAvailable?.Invoke(_pumpOnStart); return Task.CompletedTask; } @@ -33,29 +44,15 @@ public class CameraManagerTests } public void Pump(VideoFrame frame) => FrameAvailable?.Invoke(frame); + public void Fail(string message) => SourceFailed?.Invoke(message); } - private sealed class FailingFrameSource : ICameraFrameSource - { - public string DeviceId { get; } - public event Action? FrameAvailable; - public bool Stopped; - - public FailingFrameSource(string deviceId) => DeviceId = deviceId; - - public Task StartAsync() => throw new InvalidOperationException("camera in use"); - - public Task StopAsync() - { - Stopped = true; - return Task.CompletedTask; - } - - public void Pump(VideoFrame frame) => FrameAvailable?.Invoke(frame); - } - + // The refcount/coalescing tests model a camera whose frame pump is driven by + // the test after acquire, so they skip the first-frame proof (TimeSpan.Zero); + // the proof itself is exercised by the dedicated tests below. private static CameraManager CreateManager(List started, List stopped) - => new(new FakeEnumerator(), id => new FakeFrameSource(id, started, stopped)); + => new(new FakeEnumerator(), id => new FakeFrameSource(id, started, stopped), + null, TimeSpan.Zero); private sealed class FakeEnumerator : ICameraEnumerator { @@ -93,7 +90,8 @@ public class CameraManagerTests FakeFrameSource? captured = null; var manager = new CameraManager( new FakeEnumerator(), - id => captured = new FakeFrameSource(id, started, stopped)); + id => captured = new FakeFrameSource(id, started, stopped), + null, TimeSpan.Zero); await manager.AcquireAsync("dev1"); var frame = new VideoFrame(2, 2, new byte[16]); @@ -105,7 +103,10 @@ public class CameraManagerTests [Fact] public async Task Acquire_FailingCamera_ReturnsFalseAndStops() { - var manager = new CameraManager(new FakeEnumerator(), id => new FailingFrameSource(id)); + var manager = new CameraManager( + new FakeEnumerator(), + id => new FakeFrameSource(id, new List(), new List(), failOnStart: "camera in use"), + null, TimeSpan.Zero); string? failedDevice = null; manager.CameraFailed += (device, _) => failedDevice = device; @@ -119,4 +120,67 @@ public class CameraManagerTests var manager = CreateManager(new List(), new List()); Assert.False(await manager.AcquireAsync(" ")); } + + // ─── First-frame proof (the integration test for this change) ─── + + [Fact] + public async Task Acquire_SilentCamera_NoFirstFrame_FailsAndSurfacesCameraFailed() + { + var stopped = new List(); + var manager = new CameraManager( + new FakeEnumerator(), + id => new FakeFrameSource(id, new List(), stopped), + null, TimeSpan.FromMilliseconds(150)); + string? failedDevice = null; + string? failedMessage = null; + manager.CameraFailed += (device, message) => + { + failedDevice = device; + failedMessage = message; + }; + + // The reader "starts" fine but never delivers a frame — the exact + // silent-empty-box scenario. Must fail, be reported, and be rolled back. + Assert.False(await manager.AcquireAsync("dev1")); + Assert.Equal("dev1", failedDevice); + Assert.Contains("no frames", failedMessage); + Assert.Null(manager.GetLatestFrame("dev1")); + Assert.Contains("dev1", stopped); + } + + [Fact] + public async Task Acquire_FirstFrameProvesAlive_ReturnsTrueWithoutWaitingTimeout() + { + var manager = new CameraManager( + new FakeEnumerator(), + id => new FakeFrameSource(id, new List(), new List(), + pumpOnStart: new VideoFrame(2, 2, new byte[16])), + null, TimeSpan.FromSeconds(10)); + + var started = await manager.AcquireAsync("dev1"); + + Assert.True(started); + Assert.NotNull(manager.GetLatestFrame("dev1")); + } + + [Fact] + public async Task Acquire_SourceFailureAfterStart_SurfacesCameraFailedAndRollsBack() + { + var stopped = new List(); + FakeFrameSource? source = null; + var manager = new CameraManager( + new FakeEnumerator(), + id => source = new FakeFrameSource(id, new List(), stopped, + pumpOnStart: new VideoFrame(2, 2, new byte[16])), + null, TimeSpan.Zero); + string? failedMessage = null; + manager.CameraFailed += (_, message) => failedMessage = message; + + Assert.True(await manager.AcquireAsync("dev1")); + source!.Fail("capture failed (0x8007001F): device not available"); + + Assert.Contains("device not available", failedMessage); + Assert.Null(manager.GetLatestFrame("dev1")); + Assert.Contains("dev1", stopped); + } }