feat(webcam): app-default gate slice — per-scene offer, Add places default directly, identity survives removal

Chat's camera no longer greys Web Cam in Live (per-scene max, not app-wide);
TASK 26 superseded by creator directive (app-level resource model). Add Webcam
now places the existing app default without the picker; the picker runs only
for the initial selection. Removing the last placement keeps the identity
(_webcam never nulled) so Add stays offered. Startup pass adopts a solo camera
as the app default, so a clean layout offers the layer in Live/Chat at once.
Dynamic WebcamAddToolTip names the why (incl. the 'graduated to OBS' line).

Good Dog: WebcamMenuGateTests rewritten (real app + temp DB + camera seams) —
identity in Chat does not gray Live; Add in Live places same wc-1 no picker;
scene-with-placement stays gray; identity survives both removals (DB row 1).
WebcamStartupResourceTests single-lock fact asserts CanAddWebcam after adoption.
Docs same commit (ai.md supersession, TASKS.md, HANDOFF.md). 304/304, 0 warnings.
This commit is contained in:
2026-09-17 08:56:04 -07:00
parent 1e4017df03
commit 9d00955004
9 changed files with 210 additions and 114 deletions
+23 -18
View File
@@ -408,21 +408,22 @@ This replaces the old five-seeder cluster (`Seed{Starting,Brb,Ending,Chat}Backgr
- **TFM:** `net8.0-windows10.0.19041.0` (app + tests) pulls the WinRT projection from the SDK reference
packs — no NuGet package, no capability manifest (unpackaged desktop app works; the Windows privacy
camera toggle still applies). `EnableWindowsTargeting` keeps WSL builds working.
- **One webcam, many scenes (schema v3):** a singleton `Webcam` row holds the identity
(`Id`/`DeviceId`/`Name`); each scene gets its own `WebcamSceneConfig` (position/size/clip/mirror/
border/`IsVisible`). `Scene.Elements` holds images (`Source`) and, at most once, the webcam
(`WebcamSceneConfig`); `Scene.WebcamConfig` is the accessor. `CameraManager` refcounts capture
sessions by `DeviceId` (a session starts at `RefCount = 1`; repeat acquire bumps it; the last
release stops + disposes). **"Add Webcam" ALWAYS opens the Windows camera picker** — the creator is
never silently handed the previous camera (which used to happen after deleting one scene's webcam
while another scene still used it; that identity survived, so re-adding bypassed the choice).
Picking a different camera than the current app-wide one swaps it everywhere via
`SwapWebcamIdentityAsync` (the same path "Change Webcam…" uses), keeping the singleton honest;
picking the same one just places the config. The Add Webcam menu item greys out whenever a webcam
exists **anywhere** — one camera identity app-wide (`CanAddWebcam` = `StagedScene != null && _webcam == null`,
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.
- **One webcam, many scenes (schema v3) — app-level default (2026-09-17):** a singleton `Webcam`
row holds the identity (`Id`/`DeviceId`/`Name`); each scene gets its own `WebcamSceneConfig`
(position/size/clip/mirror/border/`IsVisible`). `Scene.Elements` holds images (`Source`) and, at
most once, the webcam (`WebcamSceneConfig`); `Scene.WebcamConfig` is the accessor. `CameraManager`
refcounts capture sessions by `DeviceId` (a session starts at `RefCount = 1`; repeat acquire bumps
it; the last release stops + disposes). **The webcam is an app-level resource — ONE selection (the
app default), usable in every scene that can host it.** "Add Webcam" places the existing default
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`
— 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).
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.
- **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
@@ -435,10 +436,14 @@ This replaces the old five-seeder cluster (`Seed{Starting,Brb,Ending,Chat}Backgr
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`:
(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`:
`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
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
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):