fix(webcam): offer the Web Cam row only when a camera is attainable (live lock), not merely selected
Creator refinement: 'offered iff there's not one already configured & attainable'. CanAddWebcam now requires IsWebcamAttainable = identity present AND a RUNNING session (CameraManager.IsRunning) — an identity whose camera was unplugged or whose lock keeps failing leaves the row greyed with reason 'No webcam is currently available…', and it un-greys the moment a session is live. Gate re-raised at every attainability flip: staging, removal, startup lock success, first frame, camera failure, identity swap. Root cause the old test surfaced: the startup pass skipped acquiring when the loaded identity's configs already held the session, so there was no independent app base ref — removing the last placement dropped RefCount to 0 and killed the session. The single-camera branch now ALWAYS acquires (a running session just bumps), laying the app-wide base hold so the default outlives the scenes. Good Dog: WebcamMenuGateTests second fact — identity loaded, session can't start → row NOT offered + 'No webcam is currently available…' tooltip. Positive fact waits for WebcamStartupValidationTask to make the IsRunning read deterministic. Docs same commit (ai.md gate + base-lock, TASKS.md, HANDOFF.md incl. proven pre-existing audio flake). 305 tests (304 pass + known flake), 0 warnings.
This commit is contained in:
@@ -418,12 +418,16 @@ This replaces the old five-seeder cluster (`Seed{Starting,Brb,Ending,Chat}Backgr
|
||||
DIRECTLY (no picker); the Windows camera picker runs only for the initial selection
|
||||
(`_webcam == null`). Choosing a different camera in the picker swaps it app-wide via
|
||||
`SwapWebcamIdentityAsync` (same path "Change Webcam…" uses). **Per-scene gate, not app-wide**
|
||||
(TASK 26 superseded by creator directive 2026-09-17): `CanAddWebcam` = `StagedScene is { WebcamConfig: null } && _webcam != null`
|
||||
(TASK 26 superseded by creator directive 2026-09-17): `CanAddWebcam` = `StagedScene is { WebcamConfig: null } && IsWebcamAttainable`
|
||||
— a scene holding its own placement greys the row (one webcam per stream), but ANOTHER scene
|
||||
holding one does not, so Chat's camera never greys Live; the greyed-row tooltip carries the reason
|
||||
(`WebcamAddToolTip`, incl. the gentle "a second camera means you've graduated to OBS" line).
|
||||
holding one does not, so Chat's camera never greys Live. **Attainable means a live lock, not just a
|
||||
saved identity:** `IsWebcamAttainable` = `_webcam != null && CameraManager.IsRunning(_webcam.DeviceId)`
|
||||
— an identity whose device was unplugged, or whose session can't start, leaves the row greyed with
|
||||
`WebcamAddToolTip` = "No webcam is currently available…" (identity alone no longer suffices). The
|
||||
max-1 reason gets the gentle "a second camera means you've graduated to OBS" line.
|
||||
Removing a scene's placement **keeps the identity** (it's the app default; `_webcam` is never
|
||||
nulled by removal) — Add stays offered for the same camera.
|
||||
nulled by removal) — Add stays offered for the same camera, because the app holds its own base lock
|
||||
(below).
|
||||
- **Startup resource lifecycle (2026-09-17):** the webcam is a resource the app *allocates and
|
||||
locks*, validated at startup — this answers "why is the WebCam row greyed" from inside the app
|
||||
instead of a DB spelunk. `MainViewModel.ValidateWebcamResourceStartupAsync` (fired fire-and-forget
|
||||
@@ -433,14 +437,16 @@ This replaces the old five-seeder cluster (`Seed{Starting,Brb,Ending,Chat}Backgr
|
||||
(`WebcamLockAlert`, bottom of the Layers panel with a Retry button) re-polls every 5 s
|
||||
(`_webcamLockPollTimer`) until a lock succeeds, and any first real frame also clears it; **≥2** →
|
||||
deliberately no auto-lock — selection belongs to the App Settings dialog (gear) **next slice**.
|
||||
A session already running for the device (loaded identity's configs) counts as the lock, so the
|
||||
pass never double-acquires: `CameraManager.IsRunning(deviceId)` = a session exists (started OR
|
||||
still starting — a mid-start session is not "locked yet", it's in flight), and a rolled-back
|
||||
(failed) session leaves the dictionary so the pass can retry. After a successful single-cam lock,
|
||||
the pass **adopts the camera as the app default** if no identity exists yet (fresh layout) — the
|
||||
Web Cam layer is then offered in Live/Chat immediately (`CanAddWebcam` true); `IsRunning` skip
|
||||
means a loaded identity whose session is already up never overwrites it. Test seams mirror
|
||||
`LayoutPathOverride`:
|
||||
The single-camera branch **always acquires** (it no longer skips when configs already hold the
|
||||
session): that acquire is the app's own BASE ref, so removing every scene's placement leaves
|
||||
`RefCount = 1` and the session alive — the gate stays satisfied and the Web Cam row stays offered,
|
||||
i.e. the app default outlives the scenes that render it. Acquire on a running session just bumps
|
||||
the refcount. `CameraManager.IsRunning(deviceId)` = a session exists (started OR still starting —
|
||||
a mid-start session is not "locked yet", it's in flight), and a rolled-back (failed) session leaves
|
||||
the dictionary so the pass can retry. After a successful single-cam lock, the pass **adopts the
|
||||
camera as the app default** if no identity exists yet (fresh layout) — the Web Cam layer is then
|
||||
offered in Live/Chat immediately (`CanAddWebcam` true); an already-loaded identity is never
|
||||
overwritten. Test seams mirror `LayoutPathOverride`:
|
||||
`CameraEnumeratorOverride`/`CameraFrameSourceFactoryOverride` let `WebcamStartupResourceTests`
|
||||
drive the probe without real hardware (0-cam no-alarm, 1-cam locked → identity adopted, lock-fail
|
||||
→ alert → clears on retry). **Attribution correction:** the "NVIDIA Broadcast opens the webcam exclusively" failure
|
||||
|
||||
Reference in New Issue
Block a user