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);
+ }
}