Webcam resource validation + first-frame proof + CameraConflictProbe: MediaCaptureFrameSource validates post-init (VideoDeviceId match, stream properties, reader StartAsync status), subscribes Failed/CameraStreamStateChanged → SourceFailed; CameraManager.AcquireAsync requires first-frame proof (4s timeout) — silent empty box impossible; MainViewModel surfaces WebcamError chip + MessageBox with suspect-app names; fallback ladder VideoPreview → VideoRecord; 19041 SDK projection gaps documented; AGENTS.md + schema.md + HANDOFF.md memory-map rules added; 81 tests passing, 0 warnings

This commit is contained in:
2026-08-12 07:21:15 -07:00
parent 60259c08c2
commit 3f7f1880c5
11 changed files with 203 additions and 25 deletions
+73 -8
View File
@@ -15,6 +15,12 @@ namespace ytLive.Services;
/// 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
{
@@ -23,11 +29,13 @@ public sealed class CameraManager : IDisposable
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)
{
@@ -40,21 +48,26 @@ public sealed class CameraManager : IDisposable
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 to start (device in use, access denied, no preview source).</summary>
/// <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)
Dispatcher? uiDispatcher = null, TimeSpan? firstFrameTimeout = null)
{
_enumerator = enumerator;
_frameSourceFactory = frameSourceFactory;
_uiDispatcher = uiDispatcher;
_firstFrameTimeout = firstFrameTimeout ?? TimeSpan.FromSeconds(4);
}
public ICameraEnumerator Enumerator => _enumerator;
@@ -79,6 +92,8 @@ public sealed class CameraManager : IDisposable
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;
}
@@ -86,20 +101,32 @@ public sealed class CameraManager : IDisposable
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)
{
lock (_gate)
_sessions.Remove(deviceId);
session.Source.FrameAvailable -= session.FrameHandler;
AppLog.Write($"CameraManager: failed to start camera '{deviceId}': {ex.Message}");
CameraFailed?.Invoke(deviceId, ex.Message);
await SafeStopAsync(session.Source);
RollbackSession(session, ex.Message);
return false;
}
}
@@ -174,10 +201,47 @@ public sealed class CameraManager : IDisposable
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
@@ -200,6 +264,7 @@ public sealed class CameraManager : IDisposable
{
if (!TryGetActiveSession(session)) return;
session.LatestFrame = frame;
session.FirstFrame?.TrySetResult(true);
if (session.PreviewBitmap == null)
{