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:
@@ -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)
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user