feat(webcam): resource lifecycle startup slice — poll-on-start, single-cam lock, persistent Layers alert

The creator couldn't add a webcam to Live (grayed app-wide) and nothing in the
app explained why. Ground truth from the live DB: one Webcam identity AND one
WebcamSceneConfig in the Chat scene — so the gray was the single-identity rule
working, but the reason was unobservable. This slice makes the webcam a
resource the app validates and locks, mirroring how OBS reserves its devices.

At startup we enumerate the OS once (ValidateWebcamResourceStartupAsync, fired
after LoadLayout, stored as WebcamStartupValidationTask for tests to await):
- 0 webcams -> app runs on, layer inactive, no alarm
- exactly 1 -> attempt CameraManager.AcquireAsync as an app-wide lock; on
  failure show a persistent red alert at the bottom of the Layers panel
  (WebcamLockAlert + Retry) that re-polls every 5s and clears itself the
  moment the camera locks, or on any first real frame
- >=2 -> deliberately no auto-lock; camera selection belongs to the App
  Settings dialog (gear) — next slice

CameraManager.IsRunning(deviceId) tells the pass a session already exists
(started OR still starting) so loaded identity configs count as the lock and
the pass never double-acquires. Test seams mirror LayoutPathOverride:
CameraEnumeratorOverride / CameraFrameSourceFactoryOverride so the startup
probe never touches real hardware under test.

Good Dog test: WebcamStartupResourceTests x3 — single-cam locked + app runs on,
zero-cams no-alarm/no-lock, lock-fails -> red alert -> Retry -> clears. Full
suite 304/304, build 0 warnings, scope-check passed.

Docs in-commit: TASKS Open items + ai.md Webcam section + HANDOFF rewrite.
Also corrects the record: 'NVIDIA Broadcast opens the webcam exclusively' was a
suspect-list claim (CameraConflictProbe reads process names only, no handles)
— not restated as fact.
This commit is contained in:
2026-09-17 08:03:58 -07:00
parent 94a934ffa9
commit 1e4017df03
8 changed files with 405 additions and 75 deletions
+89
View File
@@ -1,7 +1,10 @@
using System.Collections.Generic;
using System.Windows;
using System.Windows.Input;
using System.Windows.Media.Imaging;
using System.Windows.Threading;
using ytLive;
using ytLive.Helpers;
using ytLive.Models;
using ytLive.Services;
@@ -16,6 +19,8 @@ public partial class MainViewModel
private readonly CameraManager _cameraManager;
private Webcam? _webcam;
private string? _webcamError;
private string? _webcamLockAlert;
private DispatcherTimer? _webcamLockPollTimer;
/// <summary>The Add → Webcam menu item: enabled only while no webcam exists anywhere —
/// one camera identity app-wide; re-adding always opens the picker.</summary>
@@ -45,6 +50,37 @@ public partial class MainViewModel
private set => SetProperty(ref _webcamError, value);
}
// Startup resource validation keeps its task for tests to await (the real
// app fires it fire-and-forget from the ctor).
internal Task? WebcamStartupValidationTask { get; private set; }
/// <summary>
/// Red alert shown at the bottom of the Layers panel when the one-and-only
/// detected webcam could NOT be locked at startup. Persistent: while set, a
/// light re-poll re-runs the lock check so the moment the camera frees up
/// (another app closed) the alert clears itself; a Retry button does it now.
/// </summary>
public string? WebcamLockAlert
{
get => _webcamLockAlert;
private set
{
if (!SetProperty(ref _webcamLockAlert, value)) return;
if (value == null)
{
_webcamLockPollTimer?.Stop();
}
else if (_webcamLockPollTimer == null)
{
_webcamLockPollTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(5) };
_webcamLockPollTimer.Tick += (_, _) => _ = ValidateWebcamResourceStartupAsync();
_webcamLockPollTimer.Start();
}
}
}
public ICommand RetryWebcamLockCommand { get; }
// Webcam size safeguard: no more than half the frame in any dimension
// (960x540 over the 1920x1080 master), and no less than 10% of it
// (192x108). Enforced at resize and on layout load. The Chat scene is
@@ -135,6 +171,7 @@ public partial class MainViewModel
{
if (_webcam?.DeviceId != deviceId) return;
WebcamError = null;
WebcamLockAlert = null; // the camera is provably alive — alert resolved.
foreach (var scene in Scenes)
foreach (var config in scene.Elements.OfType<WebcamSceneConfig>())
config.VideoImageSource = bitmap;
@@ -148,6 +185,58 @@ public partial class MainViewModel
WebcamError = $"Webcam offline: {message}";
}
/// <summary>
/// Runs exactly once at startup (and on Retry): enumerate the OS webcams,
/// then tri-state:
/// 0 → no alarm, layer inactive.
/// 1 → try to lock; failure → persistent red alert (re-poll every 5 s).
/// ≥2 → leave to the settings-dialog selector (next slice).
/// Re-running after a failure re-enumerates then retries the lock; a real
/// frame arriving (first-frame proof) also clears the alert.
/// </summary>
internal async Task ValidateWebcamResourceStartupAsync()
{
IReadOnlyList<CameraDeviceInfo> cameras;
try
{
cameras = await _cameraEnumerator.GetCamerasAsync();
}
catch (Exception ex)
{
AppLog.Write($"WebcamResource: enumeration failed: {ex.Message}");
WebcamLockAlert = "Web Cam unavailable — couldn't enumerate cameras.";
return;
}
var count = cameras?.Count ?? 0;
if (count != 1)
{
// 0: no camera; ≥2: settings-dialog selector (next slice).
WebcamLockAlert = null;
return;
}
var device = cameras![0];
// A session already started/starting for this device (loaded identity)
// means we already hold the lock — nothing to do.
if (_cameraManager.IsRunning(device.Id))
{
WebcamLockAlert = null;
return;
}
var started = await _cameraManager.AcquireAsync(device.Id);
if (started)
{
WebcamLockAlert = null;
return;
}
var reason = WebcamError ?? "the camera could not be locked.";
WebcamLockAlert = $"Web Cam unavailable: {reason}";
}
// Adds the webcam to the active scene. The creator ALWAYS picks from the
// cameras Windows has registered — never silently resurrects the previous
// camera (which is what happened after deleting one scene's webcam while
+14 -2
View File
@@ -215,6 +215,7 @@ public partial class MainViewModel : ViewModelBase
EndStreamCommand = new RelayCommand(_ => StopStream(), _ => IsLive || IsRecording);
ChooseRecordFolderCommand = new RelayCommand(_ => ChooseRecordFolder());
ResetRecordFolderCommand = new RelayCommand(_ => ResetRecordFolder());
RetryWebcamLockCommand = new RelayCommand(_ => _ = ValidateWebcamResourceStartupAsync());
ActivateLicenseCommand = new RelayCommand(_ => _ = ActivateLicenseAsync(), _ => !IsLicenseValidating);
OpenLicenseEntryCommand = new RelayCommand(_ => IsLicenseEntryOpen = !IsLicenseEntryOpen);
OpenHotkeyConfigCommand = new RelayCommand(_ => OpenHotkeyConfig());
@@ -240,10 +241,10 @@ public partial class MainViewModel : ViewModelBase
MicSourceName = _layoutStore.LoadMicSourceName();
AudioSyncOffsetMs = _layoutStore.LoadAudioSyncOffsetMs();
_cameraEnumerator = new MediaCaptureCameraEnumerator();
_cameraEnumerator = CameraEnumeratorOverride ?? new MediaCaptureCameraEnumerator();
_cameraManager = new CameraManager(
_cameraEnumerator,
id => new MediaCaptureFrameSource(id),
CameraFrameSourceFactoryOverride ?? (id => new MediaCaptureFrameSource(id)),
System.Windows.Application.Current?.Dispatcher);
_cameraManager.PreviewBitmapChanged += OnCameraPreviewBitmapChanged;
_cameraManager.CameraFailed += OnCameraFailed;
@@ -314,6 +315,11 @@ public partial class MainViewModel : ViewModelBase
_framePump.HealthUpdated += OnFramePumpHealthUpdated;
LoadLayout();
// Resource validation runs as soon as the layout is up: enumerate the
// OS once, and if exactly one webcam exists, lock it for the session
// (the saved identity's scene configs may already hold it — refcounted).
// Failures surface as a persistent red alert in the Layers panel.
WebcamStartupValidationTask = ValidateWebcamResourceStartupAsync();
// The cached reusable stream gives the pump its RTMP URL the moment go-live
// starts (the pump reads the URL once at startup — no waiting on YouTube).
_reusableStream = _layoutStore.LoadReusableStream();
@@ -333,6 +339,12 @@ public partial class MainViewModel : ViewModelBase
// this to a temp path before constructing MainWindow and reset it after.
internal static string? LayoutPathOverride { get; set; }
// Test seams (same pattern): startup resource validation must never touch a
// real camera while under test. Tests inject a fake enumerator / frame-source
// factory before constructing MainWindow and reset them after.
internal static ICameraEnumerator? CameraEnumeratorOverride { get; set; }
internal static Func<string, ICameraFrameSource>? CameraFrameSourceFactoryOverride { get; set; }
private void LoadLayout()
{
_isLoading = true;