318 lines
12 KiB
C#
318 lines
12 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Threading.Tasks;
|
|
using System.Windows;
|
|
using System.Windows.Media;
|
|
using System.Windows.Media.Imaging;
|
|
using System.Windows.Threading;
|
|
using ytLive.Helpers;
|
|
|
|
namespace ytLive.Services;
|
|
|
|
/// <summary>
|
|
/// Owns webcam capture sessions app-wide, refcounted by DeviceId. One device =
|
|
/// at most one capture, one shared WriteableBitmap; the last release stops and
|
|
/// disposes the source. Frames arrive on a worker thread and are coalesced onto
|
|
/// the UI dispatcher (at most one pending copy per session, using the latest
|
|
/// frame) so a 60fps device doesn't drown the render thread.
|
|
///
|
|
/// Allocation is proven, never assumed: <see cref="AcquireAsync"/> treats a
|
|
/// started reader as success only once the first frame actually arrives (within
|
|
/// <paramref name="firstFrameTimeout"/>), and an async <see cref="SourceFailed"/>
|
|
/// tears the session down and surfaces <see cref="CameraFailed"/> — no silent
|
|
/// empty box.
|
|
/// </summary>
|
|
public sealed class CameraManager : IDisposable
|
|
{
|
|
private sealed class CameraSession
|
|
{
|
|
public string DeviceId { get; }
|
|
public ICameraFrameSource Source { get; }
|
|
public Action<VideoFrame>? FrameHandler;
|
|
public Action<string>? FailureHandler;
|
|
public int RefCount;
|
|
public bool Started;
|
|
public WriteableBitmap? PreviewBitmap;
|
|
public VideoFrame? LatestFrame;
|
|
public bool FramePending;
|
|
public TaskCompletionSource<bool>? FirstFrame;
|
|
|
|
public CameraSession(string deviceId, ICameraFrameSource source)
|
|
{
|
|
DeviceId = deviceId;
|
|
Source = source;
|
|
RefCount = 1;
|
|
}
|
|
}
|
|
|
|
private readonly ICameraEnumerator _enumerator;
|
|
private readonly Func<string, ICameraFrameSource> _frameSourceFactory;
|
|
private readonly Dispatcher? _uiDispatcher;
|
|
private readonly TimeSpan _firstFrameTimeout;
|
|
private readonly Dictionary<string, CameraSession> _sessions = new();
|
|
private readonly object _gate = new();
|
|
|
|
/// <summary>Raised on the UI thread when a camera's shared preview bitmap is first created.</summary>
|
|
public event Action<string, WriteableBitmap>? PreviewBitmapChanged;
|
|
|
|
/// <summary>
|
|
/// Raised when a capture fails: device in use, access denied, no preview source,
|
|
/// or a started reader that never delivers a first frame within the timeout.
|
|
/// </summary>
|
|
public event Action<string, string>? CameraFailed;
|
|
|
|
public CameraManager(ICameraEnumerator enumerator, Func<string, ICameraFrameSource> frameSourceFactory,
|
|
Dispatcher? uiDispatcher = null, TimeSpan? firstFrameTimeout = null)
|
|
{
|
|
_enumerator = enumerator;
|
|
_frameSourceFactory = frameSourceFactory;
|
|
_uiDispatcher = uiDispatcher;
|
|
_firstFrameTimeout = firstFrameTimeout ?? TimeSpan.FromSeconds(4);
|
|
}
|
|
|
|
public ICameraEnumerator Enumerator => _enumerator;
|
|
|
|
/// <summary>Increments the refcount for a device, starting capture the first time.</summary>
|
|
public async Task<bool> AcquireAsync(string deviceId)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(deviceId)) return false;
|
|
|
|
CameraSession session;
|
|
bool shouldStart;
|
|
lock (_gate)
|
|
{
|
|
if (_sessions.TryGetValue(deviceId, out var existing))
|
|
{
|
|
existing.RefCount++;
|
|
shouldStart = false;
|
|
session = existing;
|
|
}
|
|
else
|
|
{
|
|
session = new CameraSession(deviceId, _frameSourceFactory(deviceId));
|
|
session.FrameHandler = frame => OnFrameAvailable(session, frame);
|
|
session.Source.FrameAvailable += session.FrameHandler;
|
|
session.FailureHandler = message => OnSourceFailed(session, message);
|
|
session.Source.SourceFailed += session.FailureHandler;
|
|
_sessions[deviceId] = session;
|
|
shouldStart = true;
|
|
}
|
|
}
|
|
|
|
if (!shouldStart) return session.Started;
|
|
|
|
session.FirstFrame = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
try
|
|
{
|
|
await session.Source.StartAsync();
|
|
|
|
// First-frame proof: "the reader started" is not "the stream is live".
|
|
// Success requires an actual frame within the timeout, else the session
|
|
// is rolled back and reported — never left as a silent empty preview.
|
|
var proven = _firstFrameTimeout <= TimeSpan.Zero
|
|
|| await WaitForFirstFrameAsync(session, _firstFrameTimeout);
|
|
if (!proven)
|
|
{
|
|
var reason = $"Camera '{deviceId}' started but produced no frames within {_firstFrameTimeout.TotalSeconds:0.#}s.";
|
|
RollbackSession(session, reason);
|
|
return false;
|
|
}
|
|
|
|
// Keep the SourceFailed handler subscribed: an async failure after a
|
|
// successful start (device lost, stream state Failed) must still surface.
|
|
session.FirstFrame = null;
|
|
session.Started = true;
|
|
return true;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
RollbackSession(session, ex.Message);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// <summary>Decrements the refcount; stops and disposes the source at zero.</summary>
|
|
public async Task ReleaseAsync(string deviceId)
|
|
{
|
|
CameraSession? toStop = null;
|
|
lock (_gate)
|
|
{
|
|
if (!_sessions.TryGetValue(deviceId, out var session)) return;
|
|
if (--session.RefCount > 0) return;
|
|
_sessions.Remove(deviceId);
|
|
toStop = session;
|
|
}
|
|
|
|
if (toStop == null) return;
|
|
toStop.Source.FrameAvailable -= toStop.FrameHandler;
|
|
await SafeStopAsync(toStop.Source);
|
|
toStop.PreviewBitmap = null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Releases a device unconditionally (every ref), regardless of how many scenes
|
|
/// hold it. Used on identity changes (webcam swap, layout reload) where the old
|
|
/// device's refcount isn't known after the scenes are replaced.
|
|
/// </summary>
|
|
public async Task ReleaseAllAsync(string deviceId)
|
|
{
|
|
CameraSession? toStop = null;
|
|
lock (_gate)
|
|
{
|
|
if (!_sessions.TryGetValue(deviceId, out var session)) return;
|
|
session.RefCount = 0;
|
|
_sessions.Remove(deviceId);
|
|
toStop = session;
|
|
}
|
|
|
|
if (toStop == null) return;
|
|
toStop.Source.FrameAvailable -= toStop.FrameHandler;
|
|
await SafeStopAsync(toStop.Source);
|
|
toStop.PreviewBitmap = null;
|
|
}
|
|
|
|
public VideoFrame? GetLatestFrame(string deviceId)
|
|
{
|
|
lock (_gate)
|
|
return _sessions.TryGetValue(deviceId, out var session) ? session.LatestFrame : null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// The shared WriteableBitmap for a running session, or null when the camera
|
|
/// hasn't produced its first frame yet. <see cref="PreviewBitmapChanged"/> only
|
|
/// fires once (first frame), so configs created after the session started must
|
|
/// pick the bitmap up from here.
|
|
/// </summary>
|
|
public WriteableBitmap? GetPreviewBitmap(string deviceId)
|
|
{
|
|
lock (_gate)
|
|
return _sessions.TryGetValue(deviceId, out var session) ? session.PreviewBitmap : null;
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
List<CameraSession> sessions;
|
|
lock (_gate)
|
|
{
|
|
sessions = new List<CameraSession>(_sessions.Values);
|
|
_sessions.Clear();
|
|
}
|
|
|
|
foreach (var session in sessions)
|
|
{
|
|
session.Source.FrameAvailable -= session.FrameHandler;
|
|
if (session.FailureHandler != null)
|
|
session.Source.SourceFailed -= session.FailureHandler;
|
|
_ = SafeStopAsync(session.Source);
|
|
}
|
|
}
|
|
|
|
private static async Task<bool> WaitForFirstFrameAsync(CameraSession session, TimeSpan timeout)
|
|
{
|
|
var first = session.FirstFrame;
|
|
if (first == null) return true;
|
|
var completed = await Task.WhenAny(first.Task, Task.Delay(timeout));
|
|
return ReferenceEquals(completed, first.Task) && first.Task.Result;
|
|
}
|
|
|
|
private void RollbackSession(CameraSession session, string message)
|
|
{
|
|
lock (_gate)
|
|
{
|
|
if (TryGetActiveSession(session))
|
|
_sessions.Remove(session.DeviceId);
|
|
}
|
|
session.Source.FrameAvailable -= session.FrameHandler;
|
|
if (session.FailureHandler != null)
|
|
session.Source.SourceFailed -= session.FailureHandler;
|
|
session.FirstFrame?.TrySetResult(false);
|
|
|
|
var enriched = message;
|
|
var suspects = CameraConflictProbe.GetRunningCameraApps();
|
|
if (suspects.Count > 0)
|
|
enriched += $" Other camera apps running: {string.Join(", ", suspects)}.";
|
|
|
|
AppLog.Write($"CameraManager: camera '{session.DeviceId}' failed: {enriched}");
|
|
CameraFailed?.Invoke(session.DeviceId, enriched);
|
|
_ = SafeStopAsync(session.Source);
|
|
}
|
|
|
|
private void OnSourceFailed(CameraSession session, string message)
|
|
{
|
|
RollbackSession(session, message);
|
|
}
|
|
|
|
private static async Task SafeStopAsync(ICameraFrameSource source)
|
|
{
|
|
try
|
|
{
|
|
await source.StopAsync();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
AppLog.Write($"CameraManager: stopping camera failed: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
private bool TryGetActiveSession(CameraSession session)
|
|
{
|
|
lock (_gate)
|
|
return _sessions.TryGetValue(session.DeviceId, out var current) && ReferenceEquals(current, session);
|
|
}
|
|
|
|
private void OnFrameAvailable(CameraSession session, VideoFrame frame)
|
|
{
|
|
if (!TryGetActiveSession(session)) return;
|
|
session.LatestFrame = frame;
|
|
session.FirstFrame?.TrySetResult(true);
|
|
|
|
if (session.PreviewBitmap == null)
|
|
{
|
|
if (_uiDispatcher == null) return;
|
|
if (_uiDispatcher.CheckAccess())
|
|
EnsurePreviewBitmap(session);
|
|
else
|
|
_uiDispatcher.BeginInvoke(() =>
|
|
{
|
|
if (TryGetActiveSession(session))
|
|
EnsurePreviewBitmap(session);
|
|
}, DispatcherPriority.Render);
|
|
return;
|
|
}
|
|
|
|
if (_uiDispatcher == null || _uiDispatcher.CheckAccess())
|
|
{
|
|
CopyFrame(session);
|
|
return;
|
|
}
|
|
|
|
if (session.FramePending) return;
|
|
session.FramePending = true;
|
|
_uiDispatcher.BeginInvoke(() =>
|
|
{
|
|
session.FramePending = false;
|
|
if (TryGetActiveSession(session) && session.PreviewBitmap != null)
|
|
CopyFrame(session);
|
|
}, DispatcherPriority.Render);
|
|
}
|
|
|
|
private void EnsurePreviewBitmap(CameraSession session)
|
|
{
|
|
if (session.PreviewBitmap != null || session.LatestFrame == null) return;
|
|
var frame = session.LatestFrame;
|
|
var bitmap = new WriteableBitmap(frame.Width, frame.Height, 96, 96, PixelFormats.Bgra32, null);
|
|
bitmap.WritePixels(new Int32Rect(0, 0, frame.Width, frame.Height), frame.BgraPixels, frame.Stride, 0);
|
|
session.PreviewBitmap = bitmap;
|
|
PreviewBitmapChanged?.Invoke(session.DeviceId, bitmap);
|
|
}
|
|
|
|
private void CopyFrame(CameraSession session)
|
|
{
|
|
var bitmap = session.PreviewBitmap;
|
|
var frame = session.LatestFrame;
|
|
if (bitmap == null || frame == null) return;
|
|
if (frame.Width != bitmap.PixelWidth || frame.Height != bitmap.PixelHeight) return;
|
|
bitmap.WritePixels(new Int32Rect(0, 0, frame.Width, frame.Height), frame.BgraPixels, frame.Stride, 0);
|
|
}
|
|
}
|