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)
{
+9
View File
@@ -8,7 +8,16 @@ namespace ytLive.Services;
public interface ICameraFrameSource
{
string DeviceId { get; }
/// <summary>Normalized BGRA frames from a worker thread; callers marshal to the UI thread.</summary>
event Action<VideoFrame>? FrameAvailable;
/// <summary>
/// Raised when the capture session fails asynchronously after a successful start
/// (device lost, stream state Failed, media capture failure). Carries the reason.
/// </summary>
event Action<string>? SourceFailed;
Task StartAsync();
Task StopAsync();
}
+3 -3
View File
@@ -12,13 +12,13 @@ External-facing logic: YouTube API, persistence. See
| `VideoFrame.cs` | Normalized CPU frame seam (`Width`/`Height`/tightly-packed BGRA `byte[]`) — the only pixel type the rest of the app knows about; future capture sources (screen, background-removed webcam) feed the same seam |
| `CameraDeviceInfo.cs` | `(Id, DisplayName)` for a physical capture device |
| `ICameraEnumerator.cs` | `GetCamerasAsync()` — seam so the picker/`CameraManager` never touch WinRT (tests inject fakes) |
| `ICameraFrameSource.cs` | `StartAsync`/`StopAsync`/`FrameAvailable(VideoFrame)` — seam for a running capture source |
| `ICameraFrameSource.cs` | `StartAsync`/`StopAsync`/`FrameAvailable(VideoFrame)` + **`SourceFailed(string)`** — seam for a running capture source (tests inject fakes) |
| `MediaCaptureCameraEnumerator.cs` | WinRT enumeration via `DeviceInformation.FindAllAsync(DeviceClass.VideoCapture)` |
| `MicrophoneDeviceInfo.cs` | `(Id, DisplayName)` for an audio capture (mic) device |
| `IMicrophoneEnumerator.cs` | `GetMicrophonesAsync()` — seam so the mic picker never touches WinRT (tests inject fakes) |
| `WinRtMicrophoneEnumerator.cs` | WinRT mic enumeration via `DeviceInformation.FindAllAsync(DeviceClass.AudioCapture)` (no NAudio needed — capture libs stay deferred to the audio pipeline milestone) |
| `MediaCaptureFrameSource.cs` | CPU-first MediaCapture source (`MemoryPreference = Cpu`, BGRA8 via `CreateFrameReaderAsync`); frames arrive on a worker thread |
| `CameraManager.cs` | Webcam capture ownership: refcounted by `DeviceId`, one shared `WriteableBitmap` per camera, dispatcher-coalesced ~render-rate UI updates (latest-frame drop); `PreviewBitmapChanged`/`CameraFailed` events. `ReleaseAsync` decrements a ref (stops at zero); `ReleaseAllAsync` force-drops every ref + stops the source (webcam swap / layout reload). `GetPreviewBitmap(deviceId)` returns the current shared bitmap so a `WebcamSceneConfig` added mid-session (after the first frame already created the bitmap) still receives the live frames |
| `MediaCaptureFrameSource.cs` | CPU-first MediaCapture source (`MemoryPreference = Cpu`, BGRA8 via `CreateFrameReaderAsync`). **Validated, not trusted:** post-init checks (`VideoDeviceId` match, `FrameSources` non-empty, `VideoDeviceController.GetAvailableMediaStreamProperties` ≥ 1); `reader.StartAsync()` status read (throws on non-`Success`); `capture.Failed` + `CameraStreamStateChanged` subscribed → `SourceFailed` event. Fallback ladder: VideoPreview → VideoRecord retry. `SharingMode.Exclusive` + `DeviceLost` not in 19041 SDK projection; `CameraStreamState.Failed` compared by `(int)2` (projection omits member names) |
| `CameraManager.cs` | Webcam capture ownership: refcounted by `DeviceId`, one shared `WriteableBitmap` per camera, dispatcher-coalesced ~render-rate UI updates (latest-frame drop); `PreviewBitmapChanged`/`CameraFailed` events. **First-frame proof** (4s timeout, configurable via ctor `TimeSpan?`): `AcquireAsync` returns true only after a real frame arrives — a reader that starts but never delivers (locked, suspended, dead) fails and surfaces `CameraFailed` with suspect-app names (`CameraConflictProbe`) instead of a silent empty box. `SourceFailed` forwarded from the frame source as async death signal |
| `IFullScreenDetector.cs` | Seam for the win32 full-screen detector: `int? GetForegroundFullScreenMonitorIndex()`, `int PrimaryMonitorIndex()`, `IReadOnlyList<DisplayInfo> GetDisplays()` |
| `Win32FullScreenDetector.cs` | `GetForegroundWindow` + `DwmGetWindowAttribute(DWMWA_EXTENDED_FRAME_BOUNDS)` + `MonitorFromWindow` + `GetMonitorInfo`; monitor covers all four edges → full-screen; own process excluded; monitor order = `EnumDisplayMonitors` order (static `GetMonitorHandle(int)` maps index→HMONITOR for the capture factory). `GetDisplays()` returns `DisplayInfo` (index/name/resolution/bounds/`IsPrimary`, friendly name via `EnumDisplayDevices`) for the in-app "Capture Display" picker; `PrimaryMonitorIndex()` is the auto-key fallback when no full-screen game is detected |
| `IScreenCaptureSource.cs` | `Key` + `StartAsync`/`StopAsync`/`FrameAvailable(VideoFrame)` — seam so `ScreenCaptureManager` never touches WinRT (tests inject fakes) |