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
+56 -39
View File
@@ -1,11 +1,12 @@
# HANDOFF — 2026-09-17 (webcam resource lifecycle, startup slice; committed locally — no push)
# HANDOFF — 2026-09-17 (webcam app-default gate slice; committed locally — no push)
## Branch / Commit State
`main` HEAD = **`94a934f` webcam reader-ladder fix** (local). This work unit sits un-pushed on top
as a NEW commit (same bundle of web/A/V work — stays commit-local until greenlight). Below: slice-18
C4 cache (`64a5a6d`), slice-17, slice-16, slice-15, `c01206f`, `b22d08e` (signed audio-sync, pushed).
No push after this commit either — still awaiting the device take + user greenlight.
`main` HEAD = **`1e4017d` webcam startup resource lifecycle** (local). The gate slice lands
on top as a NEW commit (stays commit-local until greenlight). Below: `94a934f` (reader-ladder),
`64a5a6d` (slice-18 C4 cache), slice-17, slice-16, slice-15, `c01206f`, `b22d08e` (signed
audio-sync, pushed). No push after this commit either — still awaiting the device take + user
greenlight.
## ⚠️ Branding (2026-09-14, creator-corrected): product = **llamacasty**, internals = ytLive
@@ -13,36 +14,48 @@ Product is **llamacasty**; repo path, csproj `AssemblyName`/`RootNamespace`, DB/
(`%APPDATA%\ytLlive\...`), most code names are legacy **ytLive/ytLlive**. User-facing text:
"llamacasty" / UI labels use the catalog names ("Web Cam", "YouTube Chat", "Countdown Timer"…).
## ✅ Committed locally — webcam resource lifecycle (startup slice)
## ✅ Committed — webcam app-default gate slice (creator-visible goal: Web Cam offered when switching to Live)
**Incident review (the reason this exists):** the creator couldn't add a webcam to the Live scene
(WebCam row grayed app-wide). Ground truth from `%APPDATA%\ytLlive\ytLlive.db` (NOT `layout.db`,
which is a 0-byte legacy file): one `Webcam` identity (C920 `\?\USB#VID_046D&PID_082D…GLOBAL`) AND
one `WebcamSceneConfig` — in the **Chat** scene. Under the single-identity rule that means Live's
"+" menu is gray *by design*, but the app couldn't *say why*. Combined with the earlier crash/failure
chain, the fix is a proper resource lifecycle:
**Background:** the DB truth (`%APPDATA%\ytLlive\ytLlive.db`; NOT `layout.db`, a 0-byte legacy
file) put the single `WebcamSceneConfig` in **Chat**, so the old TASK 26 app-wide rule grayed
Live's "+" → Web Cam even though the camera was available and in use. The creator ruled (rigorous
resource model): **the webcam is an app-level resource — ONE app-wide default selection, usable in
every scene that can host it; it is NOT blocked by other scenes' placements.** Scope of this slice:
the availability layer. TASK 26's app-wide gate is **superseded** by creator directive.
- **Startup poll + tri-state** (`MainViewModel.ValidateWebcamResourceStartupAsync`, fired
fire-and-forget right after `LoadLayout`): 0 webcams → app runs on, layer inactive, no alarm;
**1 → attempt `CameraManager.AcquireAsync` as an app-wide LOCK**; **≥2 → no auto-lock** — selection
belongs to the App Settings dialog (gear) — that's the next slice.
- **Persistent red alert** (`WebcamLockAlert`, bottom of the Layers panel, + Retry button): shown when
the one detected camera can't be locked; **re-polls every 5 s** (`_webcamLockPollTimer`) and clears
the moment a lock succeeds, or on any first real frame (`OnCameraPreviewBitmapChanged`).
- **`CameraManager.IsRunning(deviceId)`** — new accessor (session exists, started OR starting) so the
pass never double-acquires a lock the loaded identity's configs already hold; a rolled-back (failed)
session leaves the dictionary, so the pass retries cleanly.
- **Test seams mirroring `LayoutPathOverride`:** `CameraEnumeratorOverride` /
`CameraFrameSourceFactoryOverride` — the startup probe must NEVER touch real hardware under test
(the reason the 09:15 tests never asserted camera state). **Good Dog test**
`WebcamStartupResourceTests` x3: single-cam locked + app runs on; zero-cams no-alarm no-lock;
lock-fails → red alert → Retry → clears. **304/304 green, build 0 warnings.**
- **Attribution correction committed to `ai.md`:** "NVIDIA Broadcast opens the webcam
exclusively" is a *suspect-list* claim — `CameraConflictProbe` reads running process NAMES only, no
device handles; the contention symptoms fit shared-mode/bandwidth just as well. Do not restate it as
fact (creator called this out).
- **Per-scene gate:** `CanAddWebcam` = `StagedScene is { WebcamConfig: null } && _webcam != null`.
A scene already hosting the webcam stays gray (one webcam per stream); another scene holding it
does NOT gray the row elsewhere.
- **Add places the default directly:** when `_webcam != null`, "Add Webcam" creates the placement
with NO picker; the Windows picker runs only for the initial selection (`_webcam == null`;
choosing there swaps app-wide via the existing `SwapWebcamIdentityAsync`).
- **Identity survives last-placement removal:** `RemoveElement` no longer nulls `_webcam` (that was
the TASK 26 rule). `LayoutStore` writes the singleton whenever it's non-null, so it persists.
- **Startup adopts a solo camera as the app default** (in `ValidateWebcamResourceStartupAsync`,
after the successful single-cam lock): a clean/empty layout now offers the Web Cam layer in
Live/Chat immediately; `CameraManager.IsRunning` skip means a loaded identity already holding a
session is never overwritten.
- **Dynamic why-gray tooltip:** `WebcamAddToolTip` (raised alongside the gates) — "Already in this
scene — one webcam per stream. A second camera means you've graduated to OBS." / "No webcam
detected…" / "Adds the app default webcam to this scene."
- **Good Dog integration test** `WebcamMenuGateTests` rewritten for the new model (real app + temp
DB + camera seams): Chat's identity does NOT gray Live; Add in Live places the same `wc-1`
without a picker; scene-with-placement stays gray; identity survives both removals (DB row count
keeps 1). `WebcamStartupResourceTests` test 1 strengthened: after startup the single camera is
adopted → `CanAddWebcam` true. **304/304 green, build 0 warnings.**
**Task docs:** `TASKS.md` Open items + `ai.md` Webcam section updated to match.
**Task docs:** `TASKS.md` Open items + `ai.md` Webcam section updated (supersession recorded).
## ✅ Earlier committed — webcam resource lifecycle (startup slice, `1e4017d`)
Startup poll + tri-state (`ValidateWebcamResourceStartupAsync` after `LoadLayout`): 0 → run on,
no alarm; **1 → `AcquireAsync` as app-wide lock**; **≥2 → no auto-lock** (App Settings selector
next). Lock failure → **persistent red alert** (`WebcamLockAlert` in the Layers panel + Retry),
re-polls every 5 s (`_webcamLockPollTimer`), clears on lock success or any first real frame.
`CameraManager.IsRunning(deviceId)` = session exists (started OR starting). Camera test seams
`CameraEnumeratorOverride`/`CameraFrameSourceFactoryOverride` (mirror `LayoutPathOverride`).
Attribution correction committed: "NVIDIA Broadcast opens the webcam exclusively" is a *suspect-list*
claim — `CameraConflictProbe` reads process names only, no device handles; do not restate as fact.
## ⚠️ Open items
@@ -51,18 +64,20 @@ chain, the fix is a proper resource lifecycle:
reader+capture while the frame thread sits in `TryAcquireLatestFrame`/`Marshal.Copy` (outside the
frame's try/catch). No repro, no integration test, native race not catchable — deferred per
spin-guard. Record a second occurrence's pattern.
- **Multi-camera slice (user-queued):** when ≥2 webcams exist, add the web-cam selection text +
- **Multi-camera selector slice (user-queued):** when ≥2 webcams exist, add web-cam selection text +
control to the **App Settings** dialog (gear icon, `OverlayHost` — left-click opens settings,
right-click the context menu). Selecting + successfully allocating/locking activates the WebCam
layer. Currently ≥2 cameras → nothing shows in settings.
layer. **Currently ≥2 cameras → nothing shows in settings.** Also: the "Change Webcam" picker
should carry the OBS one-liner (one webcam per stream — a second means OBS).
- **Static (+) catalog slice (user-queued):** all catalog rows always listed (Background, "Web Cam",
"Countdown Timer", "Web Resource", "YouTube Chat", "YouTube Event"…), gray only for (1) system-level
unavailability (persistent alert), (2) max already allocated, (3) not available for the current
scene; tooltips must name the reason (e.g. "already in Chat"). Static labels only, no new code-behind.
scene; tooltips must name the reason. Static labels only, no new code-behind.
- **Device re-verify (one take session):** 1. slice-18 cache verdicts (NR/WH hits, stalls, band
~60); 2. webcam: fresh launch with the browser holding the camera must show the webcam or a NAMED
chip (now also the persistent red alert, and the alert must clear after the browser closes + retry);
3. verify the Chat scene still owns the webcam (so Live's + is intentionally gray with a reason).
3. verify Live now OFFERS the Web Cam layer even while Chat holds the camera (the exact creator
goal: "webcam offered in the layer stack when I switch over to the Live view").
- **No push yet** — after the take verdict, re-measure the clap offset (`/tmp/opencode/avsync.py`),
then decide push with the user.
@@ -103,6 +118,8 @@ chain, the fix is a proper resource lifecycle:
## Next step
The multi-camera App Settings selector slice (+ static catalog rows after), then the creator's take
on this build. The alert is the new expected behavior to eyeball on the take: browser holds the
camera → fresh launch → red alert in Layers (unless Chat's config grabbed it) that clears after a
Retry once the browser closes.
on this build. The webcam gate slice answers the creator's "go" ask — switching to the Live view
must now offer Web Cam in the layer stack (Chat holding the camera no longer blocks it). The red
Layers alert remains the new expected behavior to eyeball on the take: browser holds the camera →
fresh launch → alert (unless Chat's config grabbed it) that clears after a Retry once the browser
closes.