Backgrounds: one locked Background per screen + mini rule (TASK 25)

This commit is contained in:
2026-08-23 13:33:33 -07:00
parent b400ab59c5
commit 7dd140a4c0
13 changed files with 554 additions and 309 deletions
+43 -25
View File
@@ -92,7 +92,9 @@ LayoutStore background roundtrip, LayoutStore HasBackground roundtrip + v5→v6
ScreenCaptureManager refcount + shared-bitmap + coalescing
(fake `IScreenCaptureSource` + a real background-STA `Dispatcher`), BackgroundTests (EnsureBackground
insert/idempotent/heal + HasBackground gate, IsLiveCapture, DisplaySource, INPC), SceneCatalogTests
(the five canonical scenes, Live-only background policy, EnforceBackgroundPolicy), WebcamSafeguardTests
(the five canonical scenes; NormalizeBackgrounds heal-to-locked-model + non-canonical strip;
EnsureBackground per-scene flavor), the TASK 25 dirty-layout heal integration test
(RealApp + seeded temp DB: dedupe/flavor/rename/index-0 + orphaned settings purge), WebcamSafeguardTests
(the per-scene size clamp incl. the Chat half-screen-area cap), SceneCompositorTests (the full-scene
composite integration test: background + round webcam + mirrored/bordered images + flash; the vertical
tier 1080×1920 crop/scale), StretchMathTests (UniformToFill cover-crop + bilinear), FfmpegLocatorTests
@@ -180,28 +182,44 @@ The app is a **director's control surface**, not an editor. This replaces the OB
**Thumbnails:** `Scene.Snapshot` property. `SceneCompositor.Render()` at 320×180 → `WriteableBitmap`. Only staged scene has live backdrop render; other 4 = static snapshots.
### Screen backdrop capture (TASK 3 ship task #1)
**Thumbnails:** `Scene.Snapshot` property. `RefreshSnapshotsAsync` fills minis from each scene's background art (`LoadBackgroundImage`); **minis never render live captures** — while the Live screen is staged its mini shows the green placeholder, unstaged it shows `live-background.jpg` (real-time surface = center monitor only; user decision 2026-08-23).
The backdrop is the **live desktop/game capture as a permanent, non-deletable bottom layer**
rendered in every scene — the "Screen" source from the minimal set, done as content-swap
instead of a normal draggable source.
### Screen backdrop capture (TASK 3 ship task #1) — model reworked by TASK 25
- **Model (schema v6):** `Source.IsBackdrop` (persisted) marks the one backdrop per scene; `Source.CaptureKey`
(persisted) names the target — `monitor:<n>`, `window:<hwnd>`, or `picker:<displayname>`. The backdrop is
a real `Source` of `Type DisplayCapture`, inserted **first** (`MainViewModel.EnsureBackdrop(scene)`,
internal static — runs on layout load + every `AddScene`, healing any scene missing one), fixed at
X=0/Y=0/1920×1080, and excluded from drag/hit-test/remove/reorder (remove is guarded in `RemoveElement`;
`IsDraggableElement` never matches live types; the element template sets `IsHitTestVisible=false` for
backdrops; the row's remove button and "Remove Source" menu item are hidden). **`Scene.HasBackdrop`
(persisted, default off) is the Live-only policy flag** — the backdrop belongs to the canonical Live
scene alone (see `SceneCatalog`). `EnsureBackdrop` returns null for a flag-less scene, so
Starting/BRB/Chat/Ending compose their own layers. The one-time v5→v6 backfill turns those four scenes
off and drops their backdrop sources, and **`EnforceBackdropPolicy` (internal static, runs after every
load)** re-normalizes the flag by scene name and strips any backdrop that lingers in a non-Live scene —
the flag is owned by policy, never the user. There is no scene-list "Backdrop" checkbox anymore
(the old `ToggleSceneBackdropCommand` is gone); "Change Capture…"/"Refresh Capture"/"Capture Display"
only show in the Live scene's preview menu (`CanChangeBackdrop`). The static Background, if any,
renders **above** the backdrop.
**The Background is a locked per-screen layer (TASK 25): exactly one per canonical
scene, always named "Background", pinned at index 0, never addable/removable/reorderable,
absent from the (+) menu. Flavor is decided by scene: Live = `DisplayCapture` (the real-time
desktop/game surface with Show Desktop / monitor switch / fullscreen-game detect, falling back
to `live-background.jpg`); Starting/BRB/Chat/Ending = static `Background` art layers.**
`MainViewModel.NormalizeBackgrounds(scenes)` (internal static) heals any loaded layout to this
model on every load: keeps the correctly-flavored row (or converts a survivor in place — note
`Source.Type`'s setter derives `IsBackground`, so conversions must re-assert the flag), drops
duplicates/stale rows, seeds missing ones via `CreateBackground(name)`, renames, moves to index 0;
non-canonical scenes get `HasBackground=false` and lose any backgrounds. `HealBackgrounds()`
(instance) runs Normalize + stamps default art (`StampDefaultBackgroundAsset` reads
`Assets/{scene}-background.jpg` pack resources through `AddAsset`; custom Browse art wins).
This replaces the old five-seeder cluster (`Seed{Starting,Brb,Ending,Chat}Background`,
`SeedLiveBackgroundAsset`, `EnsureDefaultBackground`) and `EnforceBackgroundPolicy`.
- **Model (schema v6):** `Source.IsBackground` (persisted) marks the one Background per scene; `Source.CaptureKey`
(persisted) names the target — `monitor:<n>`, `window:<hwnd>`, or `picker:<displayname>`. The Live row is
a real `Source` of `Type DisplayCapture`, inserted **first** (`MainViewModel.EnsureBackground(scene)`,
internal static — also used by the `StagedScene` setter so a scene switch can never expose a
missing Background), fixed at X=0/Y=0/1920×1080, and excluded from drag/hit-test/remove/reorder (remove is guarded in `RemoveElement`;
the element template sets `IsHitTestVisible=false` for
backgrounds; the row's remove button and "Remove Source" menu item are hidden). **`Scene.HasBackground`
(persisted) is true for all five canonical scenes** (see `SceneCatalog.HasBackground`). Capture controls —
"Change Capture…"/"Refresh Capture"/"Capture Display"/"Show Desktop" — exist only while the **Live** scene is
staged (`CanChangeBackground` = staged scene is Live; the preview CanvasGrid menu binds it directly, the layer-row
menu MultiBindings it with the row's own `IsBackground` via `Helpers/AllTrueToVisibilityConverter`). Non-Live
screens keep the properties panel pill ("Use default background") + Browse for custom art.
- **Settings hygiene:** `LayoutStore.Save` purges orphaned `BackgroundUseDefault_{id}` /
`BackgroundPath_{id}` Settings keys whose id is no longer a live Source row (TASK 25 heal drops duplicates; their keys must not accumulate).
- **Layering invariant (regression lesson, 2026-08-23):** `ActiveBackgroundImage` renders *above*
`BackgroundImage`, so it is fed by **static-art rows only** (`Type == Background`). The capture-flavored
Live row must never populate it or it permanently covers the desktop/game capture; Live's canvas shows
`DisplaySource` (capture, falling back to its AssetId art). Flavor-blind `IsBackground` lookups are for
persistence/heal/minis — not this overlay.
- **Detection (event-driven):** `Win32FullScreenDetector` hooks `SetWinEventHook(EVENT_SYSTEM_FOREGROUND)`
— fires on the UI thread whenever the foreground window changes. The callback runs the same
`GetForegroundWindow` + `DwmGetWindowAttribute(DWMWA_EXTENDED_FRAME_BOUNDS)` + `MonitorFromWindow` +
@@ -211,8 +229,8 @@ instead of a normal draggable source.
`IFullScreenDetector` also exposes `GetDisplays()` (`DisplayInfo`: index/name/resolution/bounds/`IsPrimary`,
friendly name via `EnumDisplayDevices`) + `PrimaryMonitorIndex()` for the in-app "Capture Display"
submenu + `FullscreenMonitorChanged` event + `StartWatching()`/`StopWatching()`. At launch
`ReacquireScreenCaptures` seeds the static fallback image (`Assets/live-backdrop.jpg`) onto the
backdrop's `AssetId` via `SeedLiveBackdropAsset()`, then keys the backdrop to the full-screen game's
`ReacquireScreenCaptures` heals backgrounds (`HealBackgrounds()`), then keys the Live background to the
full-screen game's
monitor; when **no game is detected**, `ResolveAutoCaptureKey()` returns `null` — stale `CaptureKey`
values are cleared, no capture session is started, and `Source.DisplaySource` falls back to
`_imageSource` (the static asset on the backdrop element). `StartWatching()` is called at the end of
@@ -260,8 +278,8 @@ instead of a normal draggable source.
WPF presents the window. Recorded 2026-08-13; no mitigation attempted yet (deferred by user decision).
- **GPU posture:** same as webcam — CPU frames, WPF hardware-presents; D3DImage GPU compositing deferred
to the encoder task.
- **Preview watermark:** the "Preview" placeholder hides while a backdrop renders —
`ShowPreviewPlaceholder` now also checks `BackdropImage` (raised on backdrop change), so a scene with
- **Preview watermark:** the "Preview" placeholder hides while a background capture renders —
`ShowPreviewPlaceholder` also checks `BackgroundImage` (raised on change), so a scene with
live capture shows the feed instead of the "nothing here" label.
### Webcam capture (TASK 3 milestone 1)