Multi-scene webcam (schema v3/v4): singleton Webcam + per-scene WebcamSceneConfig, right-click OBS-style border/context menu, persisted round-to-rect restore + legacy-square 16:9 heal, dark MenuItem template, tests (25 passing)

This commit is contained in:
2026-08-07 08:24:18 -07:00
parent f31ce9fdb7
commit e037ba027b
23 changed files with 1405 additions and 287 deletions
+63 -19
View File
@@ -28,11 +28,18 @@ default response style above stays in effect unless invoked.
## Run
```bash
dotnet build # Windows only — WPF requires Windows target
dotnet run
# From WSL, ALWAYS use the Windows dotnet host — never Linux `dotnet` for this project:
"/mnt/c/Program Files/dotnet/dotnet.exe" build "C:\Users\gramp\Documents\Code\projects\ytLive\ytLive.csproj"
"/mnt/c/Program Files/dotnet/dotnet.exe" run
```
Note: `EnableWindowsTargeting=true` is set in `ytLive.csproj`, so the project can be restored/built from WSL, but running requires Windows.
`EnableWindowsTargeting=true` in `ytLive.csproj` lets a cold restore work from WSL, but a Linux
`dotnet run`/`build` re-downloads 100M+ of `windowsdesktop.app.*` packs into the Linux NuGet cache
(which lacks them) over the slow 9p `/mnt/c` bridge — twice, because the WPF `_wpftmp` generated
project triggers a second restore (203s observed). The Windows cache has the SQLite packages and the
packs resolve from `C:\Program Files\dotnet\packs`, so the Windows host never re-downloads.
Never use `--no-restore` right after an interrupted restore — the stale `project.assets.json`
produces misleading `NETSDK1064` "package not found" errors. Running requires Windows anyway.
## Tests
@@ -46,7 +53,8 @@ dotnet.exe vstest "C:\...\ytLive.Tests\bin\Debug\net8.0-windows10.0.19041.0\ytLi
Good Dog Rule: ONE integration test per branch, ONE test per PR. Current: TokenStore DPAPI
roundtrip/corrupt/missing/clear, OAuth exchange/refresh/ClearSession, CameraManager refcount +
frame pump + failure handling (fakes for the WinRT seams), real-`MainWindow` round-clip
interaction test, LayoutStore delete roundtrip — 13 passing.
interaction test, LayoutStore delete roundtrip, LayoutStore pre-round-rect-dims roundtrip —
25 passing.
### Real-MainWindow tests MUST be hermetic (DB pollution bug)
@@ -61,8 +69,8 @@ test's fake `test-camera`). Rule: a test that constructs `MainWindow` MUST first
`ytLive.csproj` has `InternalsVisibleTo("ytLive.Tests")`.
The layout DB is a **full rewrite per save** (delete all, re-insert from memory), so
save/load round trips are exact: a source removed in the UI (`RemoveSource`
`scene.Sources.Remove``OnSourcesChanged` → debounced `ScheduleSave`, plus `Shutdown` on
save/load round trips are exact: an element removed in the UI (`RemoveElement`
`scene.Elements.Remove``OnElementsChanged` → debounced `ScheduleSave`, plus `Shutdown` on
close) does **not** come back after reload (`LayoutStorePersistenceTests` guards this).
## Architecture
@@ -92,7 +100,7 @@ C# / WPF (.NET 8) following MVVM:
### Current limitations / TODOs
- `Helpers/OAuthCredentials.cs` contains the real ClientId/ClientSecret. Auth is complete and the session **persists via Windows DPAPI** (`Helpers/TokenStore.cs``%APPDATA%\ytLlive\ytLlive.auth`, CurrentUser scope), reloaded best-effort at startup with a proactive refresh of a near-expiry access token. Sign-in/Change Account lives **inside the Start Stream dialog** (two-state flow — no separate Connect button). A **graceful End Livestream signs out**: `StopStream()` clears the session + token, so the next go-live needs a fresh sign-in; a crash never runs End, so the token survives and the creator stays signed in. `YouTubeAuthService` takes an optional `HttpClient` + `sessionChanged` callback (test seam + save hook; services are still constructed in `MainViewModel`)
- Scene/source/asset layout persists (SQLite, schema v2); the OAuth session persists (DPAPI); the paid-unlock state does not (yet — itch.io key verification pending)
- Scene/source/asset layout persists (SQLite, schema v4); the OAuth session persists (DPAPI); the paid-unlock state does not (yet — itch.io key verification pending)
- `YouTubeStreamService` uses hardcoded `1080p`/`60fps` and per-broadcast streams — must switch to the v3 `variable` reusable stream
- Webcam capture is shipped (milestone 1); **screen capture, scene compositing/encoding, RTMP are next**
- `StreamConfig` defaults (`TargetBitrate=6000`, `Resolution="1920x1080"`) are stale — the live dropdown drives `StreamHealth.CurrentBitrate`/`FPS` instead
@@ -118,26 +126,62 @@ C# / WPF (.NET 8) following MVVM:
- **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 camera app-wide:** `CameraManager` refcounts sessions by `DeviceId` (a session is created with
`RefCount = 1`; repeat acquire bumps it; the last release stops + disposes). The Add menu greys Webcam
out once a webcam source exists anywhere (`CanAddWebcam`); the "OBS time" story is a one-camera limit.
- **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). The Add Webcam menu greys out when the **active** scene already has a
config (`CanAddWebcamToActiveScene`); showing a hidden webcam reuses the existing config
(`CanShowWebcamInActiveScene` / empty-canvas right-click "Show Webcam"). **Removing the last webcam
config anywhere clears the identity** (`_webcam = null`), so re-adding opens the picker again
instead of resurrecting the old camera.
- **Round→rect restores the aspect (persisted, schema v4):** `SceneElement.ToggleClipShape()`
snapshots the rectangular Width/Height into public `RectWidth`/`RectHeight` before going Round and
restores them when switching back — otherwise the Round resize lock (square) would leave a square
behind. The rect dims are **persisted** (`WebcamSceneConfig.RectWidth`/`RectHeight`, nullable), so a
reloaded Round webcam still restores its pre-Round aspect instead of staying square. A one-time
`HealLegacySquareRect` (load only) widens a pre-v4 `Traditional` config that ended up square to 16:9
(keeps height; Round and explicit rect dims are untouched).
- **Device swap / layout reload:** `ChangeWebcamAsync` (picker) and `ReacquireWebcam` (after load)
release the old device with `ReleaseAllAsync` — a forced full drop that zeroes the refcount and
stops the source regardless of how many scenes held it (the per-config count isn't known once the
scenes are replaced) — then `AcquireAsync` the new device once per config.
- **Shared bitmap, coalesced updates:** one `WriteableBitmap` per active camera, created on the UI thread
at the device's frame size (first frame), forwarded to the single webcam `Source.VideoImageSource` via
at the device's frame size (first frame), forwarded to every `WebcamSceneConfig.VideoImageSource` via
`PreviewBitmapChanged`. Frames arrive on a worker thread; `CameraManager` coalesces onto the dispatcher
(at most one pending copy per session, at `Render` priority, always copying the latest frame) so a 60fps
device never drowns the render thread.
- **Clip/mirror:** per-Source `ClipShape` (Traditional rectangle / Round ellipse) + `IsMirrored`
(`ScaleX = -1`). Rendered in the preview DataTemplate (Image for Traditional, `ImageBrush` inside an
`Ellipse` for Round); toggled from the source chip; persisted in the layout DB.
- **Clip/mirror/border:** per-element `ClipShape` (Traditional rectangle / Round ellipse) + `IsMirrored`
(`ScaleX = -1`) + the OSB-standard static border (`BorderColor` `#RRGGBB` or `""`=none, `BorderOpacity`
01, `BorderWidth` 020, `BorderAnimation` `None|Pulse|Chase|Rainbow|Shimmer|MarchingAnts|Glow|
Electricity|Sparkles`). Rendered in the preview DataTemplate; toggled from the element's right-click
context menu (webcam menu: Change Webcam…, Border Effect submenu — all 9 items enabled, values persist,
rendering stays static until the animation tier ships — Border Color, Opacity/Thickness sliders, Hide in
this scene, Remove); persisted in the layout DB. The Add menu shows when no webcam exists; the empty
preview canvas has its own Show Webcam entry.
- The Round `Ellipse` is wrapped in a `Viewbox Stretch="Uniform"` holding a `1x1` Grid, so it renders
as a true circle (diameter = the shorter source dimension) instead of an oval stretched to the
source rect — and the traditional `Image` keeps `UniformToFill` over the full rect.
as a true circle (diameter = the shorter element dimension) instead of an oval stretched to the
element rect — and the traditional `Image` keeps `UniformToFill` over the full rect. The Round
border is a centered `Ellipse` at `Width/Height = RoundBorderSize`.
- Resizing locks to a square (`_resizeAspect = 1`) while `ClipShape == Round`.
- **Webcam size clamp:** `ClampWebcamToBounds` (internal — test seam) enforces 50% of the 1920×1080
master per dimension (960×540 max) and no less than 10% (192×108) at resize + load;
`RoundBorderSize` follows the clamped height. `WebcamSafeguardTests` guards the clamp.
- **Hit-testing:** a `Grid` without `Background` only hit-tests where its children draw, so clicks in
the empty corners of a round clip fell through to `Window_PreviewMouseLeftButtonDown` and deselected
the source — making the corner handle ungrabbable. The source Grid carries `Background="Transparent"`
(whole rect draggable) and the `SelectionOverlay` (dashed border + corner dot) is
`IsHitTestVisible="False"` so it never intercepts the click.
the element — making the corner handle ungrabbable. The element Grid carries
`Background="Transparent"` (whole rect draggable; the empty canvas Grid uses the same trick for
right-click Show Webcam) and the `SelectionOverlay` (dashed border + corner dot) is
`IsHitTestVisible="False"` so it never intercepts the click. Two things make the webcam menu work:
(1) the `ContextMenu` pins its own `DataContext` to `PlacementTarget.DataContext` — a `ContextMenu`
isn't in the visual tree, so without it the Click-handler `DataContext:` patterns (and the
IsChecked/slider bindings) silently fail; (2) `Themes/Controls.xaml` ships a full dark `MenuItem`
template — `PART_Popup` (submenu popups), a popup `ItemsPresenter` (the Border Opacity/Thickness
sliders live in Items, so they render in a hover flyout), a `` checkmark column, and a `` arrow
driven by `HasItems`. An earlier bare `Border + Header` template dropped all three: submenus never
opened, sliders never rendered, checkmarks never showed — the menu looked dead even though the
Click handlers were fine.
- **GPU posture:** webcam frames are CPU (GPU-agnostic; WPF hardware-presents the preview anyway). Hardware
encoders (NVENC/AMF/QSV) matter for the encoder task, not capture. D3DImage GPU compositing is deferred
to the encoder task.