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:
2026-09-17 09:16:56 -07:00
parent 9d00955004
commit 75723acc1b
5 changed files with 135 additions and 75 deletions
+18 -12
View File
@@ -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