Fix CameraStreamState.Failed compile error (19041 projection omits enum member names — compare by int); fix first-frame proof test (no dispatcher = no bitmap, assert LatestFrame instead)

This commit is contained in:
2026-08-12 07:11:54 -07:00
parent cc3b1c9234
commit 60259c08c2
2 changed files with 235 additions and 63 deletions
+148 -40
View File
@@ -1,4 +1,5 @@
using System; using System;
using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using System.Runtime.InteropServices.WindowsRuntime; using System.Runtime.InteropServices.WindowsRuntime;
@@ -6,6 +7,7 @@ using System.Threading.Tasks;
using Windows.Graphics.Imaging; using Windows.Graphics.Imaging;
using Windows.Media.Capture; using Windows.Media.Capture;
using Windows.Media.Capture.Frames; using Windows.Media.Capture.Frames;
using Windows.Media.Devices;
using Windows.Media.MediaProperties; using Windows.Media.MediaProperties;
using ytLive.Helpers; using ytLive.Helpers;
@@ -16,17 +18,28 @@ namespace ytLive.Services;
/// preview source; the capture pipeline does any format conversion, so every /// preview source; the capture pipeline does any format conversion, so every
/// frame arrives as a normalized <see cref="VideoFrame"/>. Frames arrive on a /// frame arrives as a normalized <see cref="VideoFrame"/>. Frames arrive on a
/// worker thread — marshal before touching WPF. /// worker thread — marshal before touching WPF.
///
/// Allocation is validated, never taken on faith: after <c>InitializeAsync</c>
/// 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 (<c>Failed</c>, <c>CameraStreamStateChanged</c>) are subscribed
/// so an async death surfaces as <see cref="SourceFailed"/> instead of a silent
/// empty preview.
/// </summary> /// </summary>
public sealed class MediaCaptureFrameSource : ICameraFrameSource public sealed class MediaCaptureFrameSource : ICameraFrameSource
{ {
private readonly string _deviceId; private readonly string _deviceId;
private MediaCapture? _capture; private MediaCapture? _capture;
private MediaFrameReader? _frameReader; private MediaFrameReader? _frameReader;
private string? _lastError;
private bool _isFailed;
public string DeviceId => _deviceId; public string DeviceId => _deviceId;
public event Action<VideoFrame>? FrameAvailable; public event Action<VideoFrame>? FrameAvailable;
public event Action<string>? SourceFailed;
public MediaCaptureFrameSource(string deviceId) public MediaCaptureFrameSource(string deviceId)
{ {
_deviceId = deviceId; _deviceId = deviceId;
@@ -34,47 +47,15 @@ public sealed class MediaCaptureFrameSource : ICameraFrameSource
public async Task StartAsync() public async Task StartAsync()
{ {
var capture = new MediaCapture(); // Fallback ladder: prefer the camera's VideoPreview stream, but if the
MediaFrameReader? reader = null; // reader refuses to start there (NoVideoFrameAvailable), retry against
try // 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 if (!await TryStartAsync(preferVideoRecord: true))
{
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());
throw new InvalidOperationException( throw new InvalidOperationException(
$"Camera '{_deviceId}' exposes no video source (found: {kinds}). " + _lastError ?? $"Camera '{_deviceId}' could not be started.");
"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;
} }
} }
@@ -98,7 +79,134 @@ public sealed class MediaCaptureFrameSource : ICameraFrameSource
var capture = _capture; var capture = _capture;
_capture = null; _capture = null;
capture?.Dispose(); if (capture != null)
{
UnsubscribeCaptureEvents(capture);
capture.Dispose();
}
}
private async Task<bool> 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<IMediaEncodingProperties>? 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) private void OnFrameArrived(MediaFrameReader sender, MediaFrameArrivedEventArgs args)
+87 -23
View File
@@ -1,3 +1,6 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Xunit; using Xunit;
using ytLive.Services; using ytLive.Services;
@@ -9,20 +12,28 @@ public class CameraManagerTests
{ {
private readonly List<string> _started; private readonly List<string> _started;
private readonly List<string> _stopped; private readonly List<string> _stopped;
private readonly VideoFrame? _pumpOnStart;
private readonly string? _failOnStart;
public string DeviceId { get; } public string DeviceId { get; }
public event Action<VideoFrame>? FrameAvailable; public event Action<VideoFrame>? FrameAvailable;
public event Action<string>? SourceFailed;
public FakeFrameSource(string deviceId, List<string> started, List<string> stopped) public FakeFrameSource(string deviceId, List<string> started, List<string> stopped,
VideoFrame? pumpOnStart = null, string? failOnStart = null)
{ {
DeviceId = deviceId; DeviceId = deviceId;
_started = started; _started = started;
_stopped = stopped; _stopped = stopped;
_pumpOnStart = pumpOnStart;
_failOnStart = failOnStart;
} }
public Task StartAsync() public Task StartAsync()
{ {
if (_failOnStart != null) throw new InvalidOperationException(_failOnStart);
_started.Add(DeviceId); _started.Add(DeviceId);
if (_pumpOnStart != null) FrameAvailable?.Invoke(_pumpOnStart);
return Task.CompletedTask; return Task.CompletedTask;
} }
@@ -33,29 +44,15 @@ public class CameraManagerTests
} }
public void Pump(VideoFrame frame) => FrameAvailable?.Invoke(frame); public void Pump(VideoFrame frame) => FrameAvailable?.Invoke(frame);
public void Fail(string message) => SourceFailed?.Invoke(message);
} }
private sealed class FailingFrameSource : ICameraFrameSource // 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);
public string DeviceId { get; } // the proof itself is exercised by the dedicated tests below.
public event Action<VideoFrame>? 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);
}
private static CameraManager CreateManager(List<string> started, List<string> stopped) private static CameraManager CreateManager(List<string> started, List<string> 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 private sealed class FakeEnumerator : ICameraEnumerator
{ {
@@ -93,7 +90,8 @@ public class CameraManagerTests
FakeFrameSource? captured = null; FakeFrameSource? captured = null;
var manager = new CameraManager( var manager = new CameraManager(
new FakeEnumerator(), new FakeEnumerator(),
id => captured = new FakeFrameSource(id, started, stopped)); id => captured = new FakeFrameSource(id, started, stopped),
null, TimeSpan.Zero);
await manager.AcquireAsync("dev1"); await manager.AcquireAsync("dev1");
var frame = new VideoFrame(2, 2, new byte[16]); var frame = new VideoFrame(2, 2, new byte[16]);
@@ -105,7 +103,10 @@ public class CameraManagerTests
[Fact] [Fact]
public async Task Acquire_FailingCamera_ReturnsFalseAndStops() 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<string>(), new List<string>(), failOnStart: "camera in use"),
null, TimeSpan.Zero);
string? failedDevice = null; string? failedDevice = null;
manager.CameraFailed += (device, _) => failedDevice = device; manager.CameraFailed += (device, _) => failedDevice = device;
@@ -119,4 +120,67 @@ public class CameraManagerTests
var manager = CreateManager(new List<string>(), new List<string>()); var manager = CreateManager(new List<string>(), new List<string>());
Assert.False(await manager.AcquireAsync(" ")); 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<string>();
var manager = new CameraManager(
new FakeEnumerator(),
id => new FakeFrameSource(id, new List<string>(), 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<string>(), new List<string>(),
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<string>();
FakeFrameSource? source = null;
var manager = new CameraManager(
new FakeEnumerator(),
id => source = new FakeFrameSource(id, new List<string>(), 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);
}
} }