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