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
+18
View File
@@ -423,6 +423,24 @@ This replaces the old five-seeder cluster (`Seed{Starting,Brb,Ending,Chat}Backgr
TASK 26; the old per-staged-scene gate let a second picker run from a scene that lacked the config).
**Removing the last webcam config anywhere clears the identity** (`_webcam = null`, raising
`CanAddWebcam`/`CanChangeWebcam`), which also drops "Change Webcam…" and re-enables Add.
- **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
right after `LoadLayout`, stored as `WebcamStartupValidationTask` so tests await it) enumerates the
OS once and tri-states on the count: **0** → app runs on, layer inactive, no alarm; **1** → attempt
`CameraManager.AcquireAsync(device)` as an app-wide lock — on failure a **persistent red alert**
(`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. Test seams mirror `LayoutPathOverride`:
`CameraEnumeratorOverride`/`CameraFrameSourceFactoryOverride` let `WebcamStartupResourceTests`
drive the probe without real hardware (0-cam no-alarm, 1-cam locked, lock-fail → alert → clears on
retry). **Attribution correction:** the "NVIDIA Broadcast opens the webcam exclusively" failure
below is a *suspect-list* claim — `CameraConflictProbe` reads only running process names, no device
handles; contention symptoms fit shared-mode/bandwidth just as well (see `MyMistakes.md`).
The **YouTube Chat layer follows the same one-per-layout rule** (TASK 27):
`CanAddYouTubeChat` greys out the (+) item while any scene carries a ChatBox source (raised on
staging + elements change; `AddSource` refuses a second as defense in depth). Chat layers created