diff --git a/HANDOFF.md b/HANDOFF.md
index 56c8127..bbc01d0 100644
--- a/HANDOFF.md
+++ b/HANDOFF.md
@@ -1,30 +1,54 @@
# HANDOFF — Session State
## Branch
-`task24-notifications` (from `main` @ `809c066`, which is pushed) — **dirty, uncommitted**, TASK 24 complete and green.
+`task25-backgrounds` (from `task24-notifications` @ `b400ab5`, committed) — **dirty, uncommitted**, TASK 25 complete and green.
-## Uncommitted work: TASK 24 toast notifications — COMPLETE, ready to commit
+## Uncommitted work: TASK 25 background consolidation — COMPLETE, ready to commit
-1. **Library:** `Notification.Wpf` 11.0.0 added to `ytLive.csproj` (MIT; notice #10 in `THIRD-PARTY-NOTICES.txt`). Restore pulls `Notification.CoreUI` 11.0.0 + `Microsoft.Xaml.Behaviors.Wpf` 1.1.142 + DI.Abstractions 10.0.8.
-2. **Seam:** `Services/INotificationService.cs` (`Show(title, message, AppNotificationSeverity)` + Info/Success/Warning/Error extensions); `Services/NotificationService.cs` impl — pure `BuildRequest` mapping + `Cards` tint table, Dispatcher-marshalled Show. Host: `NotificationArea x:Name="ToastArea"` last child of MainWindow root grid (`RowSpan=4`, bottom-right above footer, `Margin="0,0,12,96"`).
-3. **Call sites** (`ViewModels/MainViewModel.cs`): 5 MessageBox migrations (webcam ×2 → Warning, image read → Warning, sign-in unsuccessful → Warning / failed → Error) + 7 log-only promotions (go-live prep ×3 Error, frame-pump death Error, mic-missing Warning, premium-lapse Warning, saved-session sign-out Info). Offline license re-validation skip left log-only on purpose; SocialsDialog slot-delete confirm stays modal. `_notifications` field initialized at declaration (VM built from XAML).
-4. **Tests:** unit `NotificationServiceTests` (8 cases: routing/lifetimes/tints) + the ONE integration `NotificationAreaIntegrationTests.Info_Auto_Dismisses_And_Error_Sticks` (real window-hosted area; visual-tree reads marshalled via `_app.Run`).
-5. Build **0 warnings**; **221 total, 218 pass** — only the 3 pre-existing failures below.
+Locked model (user directive): exactly ONE "Background" per screen at index 0, non-deletable/
+non-reorderable, NOT in the (+) menu; capture context-sensitivity only on the Live screen
+(Show Desktop/monitor switching kept intact); exactly five default images in the DB; stale rows cleaned.
+
+1. **Seeders consolidated** (`ViewModels/MainViewModel.cs`): five clone seeders + `EnforceBackgroundPolicy`
+ deleted. Now: `EnsureBackground` → `CreateBackground(name)` — Live = DisplayCapture row, others =
+ static Background art row, all named "Background". `NormalizeBackgrounds(scenes)` heals any layout:
+ correct-flavor keep (in-place Type conversion re-asserts `IsBackground` because `Source.Type`'s setter
+ derives it), duplicates dropped, missing seeded + INSERTED, index 0, non-canonical stripped.
+ `HealBackgrounds()` = Normalize + `StampDefaultBackgroundAsset` (pack-resource art via `AddAsset`;
+ custom Browse wins). Called from LoadLayout, StagedScene setter, ReacquireScreenCaptures.
+2. **Settings purge:** `LayoutStore.Save` deletes orphaned `BackgroundUseDefault_{id}`/`BackgroundPath_{id}`
+ keys (ids gone from Source). Fixes the real DB's 16 stale keys.
+3. **UI:** (+) menu Screen+Background items removed (`MainWindow.xaml`); `AddSource` refuses capture/background
+ params. `CanChangeBackground` = staged-is-Live → gates preview CanvasGrid menu directly and the layer-row
+ menu via new MultiBinding converter `Helpers/AllTrueToVisibilityConverter.cs` (row Tag carries VM bool).
+4. **Mini rule:** minis never render captures. `LoadBackgroundImage` is flavor-blind (`IsBackground`) — fixes
+ green mini bug (was `Type==Background`, missed Live's DisplayCapture row → placeholder twin at old :951 too);
+ `RefreshSnapshotsAsync` shows green placeholder only while Live is staged, otherwise its stored art.
+5. **Tests:** suite 221/218 → **223 total, 220 pass**. The ONE integration test:
+ `BackgroundHealIntegrationTests.Dirty_Layout_Heals_To_One_Locked_Background_Per_Scene` (seeds dirty temp DB,
+ real window, asserts heal + save purges keys). Unit: SceneCatalogTests rewritten (NormalizeBackgrounds heal +
+ strip + per-scene flavor), BackgroundTests empty-scene flavor updated, SourceNamingTests filters IsBackground.
+ The two stale landmine tests (#2/#3) are healed by this branch — see below.
+6. **Docs same commit:** ai.md (TASK 25 model section replaces backdrop-era text), TASKS.md TASK 25 entry,
+ Services/index.md (LayoutStore purge), Helpers/index.md (new converter), this file.
+7. Build **0 warnings**.
## ⚠️ Landmines
- **User verdict (2026-08-23):** "The AI wrote fucked up code after repeatedly and intentionally misunderstanding instructions." Extends the ai.md 2026-08-22 audit flag — trust nothing from those sessions without manual verification.
-- **Pre-existing test failures are THREE (not caused by this branch):**
+- **Pre-existing test failure is now ONE (not caused by these branches):**
1. `AudioPipelineTests.Mix_HonorsProviderGains_AndGameMute_KillsTheLoopback`
- 2. `SourceNamingTests.Duplicate_Sources_Get_Next_Free_Numbered_Name` (StagedScene seeds a "Game Capture" the test doesn't filter)
- 3. `SceneCatalogTests.EnforceBackgroundPolicy_Sets_Flag_For_All_Scenes` (asserts against elements never inserted)
+ - Former #2 SourceNaming / #3 SceneCatalog policy tests were repaired BY TASK 25 (expectation updates to the locked model) — not skipped, not deleted.
- **Real-MainWindow tests MUST set `LayoutPathOverride`** (ai.md) — DB pollution wiped real data once.
-- Notification.Wpf gotchas (recorded in ai.md/TASKS.md): area routing by XAML `Name`; unknown AreaName silently drops; `NeverExpires()` = `TimeSpan.MaxValue`; `ToHex()` = `#AARRGGBB`.
+- **`Source.Type` setter derives `IsBackground`** (Models/Source.cs:40) — any Type change must re-set IsBackground after.
+- **vstest quirk:** only one `--tests:` param allowed; plain substrings work (`--tests:BackgroundHeal`), alternation regexes silently match nothing.
+- Notification.Wpf gotchas (ai.md/TASKS.md): area routing by XAML `Name`; unknown AreaName silently drops; `NeverExpires()` = `TimeSpan.MaxValue`; `ToHex()` = `#AARRGGBB`.
## Next steps
-1. Commit this branch (code + docs + notices, one commit), then merge/PR per Good Dog Rule (one integration test ✓).
-2. User visual pass on Windows: toasts look right bottom-right, error sticky with close button, no overlap with footer/chat.
-3. Then the stale background tests repair queue (unit-level): SourceNaming filter + SceneCatalog policy test.
+1. Commit this branch (code + docs, one commit), then merge/PR per Good Dog Rule (one integration test ✓).
+2. User visual pass on Windows: minis show art when unstaged (Live included), green placeholder while staged;
+ no Game Capture rows in layer lists; (+) menu trimmed; capture controls only on staged Live.
+3. Then relaunch app once so the user's real `%APPDATA%` DB self-heals on load + first save purges stale keys.
## Build
```bash
diff --git a/Helpers/AllTrueToVisibilityConverter.cs b/Helpers/AllTrueToVisibilityConverter.cs
new file mode 100644
index 0000000..07802f3
--- /dev/null
+++ b/Helpers/AllTrueToVisibilityConverter.cs
@@ -0,0 +1,14 @@
+using System.Globalization;
+using System.Windows;
+using System.Windows.Data;
+
+namespace ytLive.Helpers;
+
+public class AllTrueToVisibilityConverter : IMultiValueConverter
+{
+ public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture)
+ => values.All(v => v is true) ? Visibility.Visible : Visibility.Collapsed;
+
+ public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture)
+ => throw new NotSupportedException();
+}
diff --git a/Helpers/index.md b/Helpers/index.md
index f008edc..0de6cb2 100644
--- a/Helpers/index.md
+++ b/Helpers/index.md
@@ -12,6 +12,7 @@ Cross-cutting utilities. See [`schema.md`](../schema.md) for the memory-map conv
| `TokenStore.cs` | DPAPI-protected OAuth session persistence (`%APPDATA%\ytLlive\ytLlive.auth`, CurrentUser scope); `Save`/`Load`/`Clear` — sign-in survives restarts |
| `ImageCache.cs` | Image byte caching (assets live in the DB) |
| `InverseBoolToVisibilityConverter.cs` / `NotNullToVisibilityConverter.cs` | XAML value converters for visibility bindings |
+| `AllTrueToVisibilityConverter.cs` | Multi-value AND-gate → Visible; gates the layer-row capture menu items on row-IsBackground + staged-scene-is-Live (TASK 25) |
| `CameraConflictProbe.cs` | Best-effort diagnostic: when a camera won't start, enumerates running processes known to hold cameras (OBS, Zoom, Teams, NVIDIA Broadcast, browsers, etc.) — Windows doesn't expose "which process has this device" via any public API, so this is a suspect list, not a verdict |
Related: [`Themes/Controls.xaml`](../Themes/Controls.xaml) styles the lists this
diff --git a/MainWindow.xaml b/MainWindow.xaml
index f9c973a..f67cafc 100644
--- a/MainWindow.xaml
+++ b/MainWindow.xaml
@@ -41,6 +41,8 @@
xmlns:Helpers="clr-namespace:ytLive.Helpers"/>
+
@@ -276,8 +278,6 @@
-
-
-
+
@@ -323,11 +324,30 @@
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Services/LayoutStore.cs b/Services/LayoutStore.cs
index d33a4d1..c80f98f 100644
--- a/Services/LayoutStore.cs
+++ b/Services/LayoutStore.cs
@@ -390,7 +390,7 @@ public class LayoutStore : IDisposable
// v5 → v6: Scene gains HasBackground. Added columns default to true; the
// ONE-TIME backfill (runs only when the column is first added) turns every
// non-Live canonical scene off and drops their background sources — the fix for
- // DBs saved before the Live-only policy. MainViewModel.EnforceBackgroundPolicy
+ // DBs saved before the Live-only policy. MainViewModel.NormalizeBackgrounds
// re-runs the same normalization on every load for DBs that miss the backfill.
private void MigrateSceneTable()
{
@@ -1059,6 +1059,19 @@ public class LayoutStore : IDisposable
UpsertSetting(defaultKey, bg.UseDefaultBackground ? "1" : "0");
}
+ // Purge background settings left behind by deleted/duplicated rows
+ // (TASK 25 heal drops extras; their keys must not accumulate).
+ using (var purge = _connection.CreateCommand())
+ {
+ purge.Transaction = tx;
+ purge.CommandText =
+ @"DELETE FROM Settings
+ WHERE (Key LIKE 'BackgroundUseDefault\_%' ESCAPE '\'
+ OR Key LIKE 'BackgroundPath\_%' ESCAPE '\')
+ AND SUBSTR(Key, INSTR(Key, '_') + 1) NOT IN (SELECT Id FROM Source);";
+ purge.ExecuteNonQuery();
+ }
+
tx.Commit();
}
diff --git a/Services/index.md b/Services/index.md
index 572829f..ea12128 100644
--- a/Services/index.md
+++ b/Services/index.md
@@ -9,7 +9,7 @@ External-facing logic: YouTube API, persistence. See
| `YouTubeStreamService.cs` | Broadcast/stream management via the v3 API. **`CreateBroadcast` always sends `privacyStatus = "private"`** (ship step 7 — private-only until v1) and the one-click v3 flags (`enableAutoStart/Stop`, `enableMonitorStream=false`, `latencyPreference=low`); injectable `HttpClient? http = null` ctor seam so tests can fake the API. **`GetOrCreateReusableStreamAsync` (TASK 9, shipped 2026-08-16)** returns the channel's `ReusableStream` — lists `liveStreams?mine=true` and reuses the existing `cdn.isReusable` stream, inserting once only on first use (`resolution=variable`, `frameRate=variable`, `isReusable=true`); a stream id given to `CreateBroadcast` binds at insert via `contentDetails.boundStreamId`. RTMP URL = `ingestionAddress + "/" + streamName`. **`GetStreamHealthAsync(streamId)` (TASK 9 item 3, shipped 2026-08-16)** polls `liveStreams?part=status` → `StreamHealth` with parsed `configurationIssues[]` |
| `StreamHealthReporter.cs` | Pure report-by-exception decision (TASK 9 item 3): `BannerFor(issues)` → `HealthIssueReport(Text?, IsError)` — null text on good/ok/noData/info-only, first warning/error issue produces its type text, error beats warning for color |
| `YouTubeChatService.cs` | Polls `liveChat/messages`, raises `MessageReceived`; `IDisposable` |
-| `LayoutStore.cs` | SQLite persistence (`Microsoft.Data.Sqlite`) at `%APPDATA%\ytLlive\ytLlive.db`; assets stored as BLOBs keyed by SHA-256 content hash; save/open layout files; schema `user_version` 6 (`Source.ClipShape`/`IsMirrored` via `ALTER TABLE` for pre-v2 DBs; v3 = singleton `Webcam` + per-scene `WebcamSceneConfig`, migrated idempotently **without backfill** — the stale `Source.DeviceId` column remains but is no longer read/written; v4 = `WebcamSceneConfig.RectWidth`/`RectHeight`, the pre-Round rect for the round-to-rect restore; v5 = `Source.IsBackdrop` + `Source.CaptureKey`, the live-capture backdrop; v6 = `Scene.HasBackdrop` — Live-only policy, one-time backfill turns Starting/BRB/Chat/Ending off + drops their backdrop sources; `MainViewModel.EnforceBackdropPolicy` re-normalizes on every load). Settings key/value table holds app state: `MicSourceName`, `ReusableStream` (TASK 9 — the cached reusable stream's id/address/name so the pump has its RTMP URL at startup) |
+| `LayoutStore.cs` | SQLite persistence (`Microsoft.Data.Sqlite`) at `%APPDATA%\ytLlive\ytLlive.db`; assets stored as BLOBs keyed by SHA-256 content hash; save/open layout files; schema `user_version` 6 (`Source.ClipShape`/`IsMirrored` via `ALTER TABLE` for pre-v2 DBs; v3 = singleton `Webcam` + per-scene `WebcamSceneConfig`, migrated idempotently **without backfill** — the stale `Source.DeviceId` column remains but is no longer read/written; v4 = `WebcamSceneConfig.RectWidth`/`RectHeight`, the pre-Round rect for the round-to-rect restore; v5 = `Source.IsBackdrop` + `Source.CaptureKey`, the live-capture backdrop; v6 = `Scene.HasBackdrop` — Live-only policy, one-time backfill turns Starting/BRB/Chat/Ending off + drops their backdrop sources; `MainViewModel.NormalizeBackgrounds` re-normalizes on every load — TASK 25: one locked "Background" per canonical screen (static art everywhere, DisplayCapture on Live), duplicates dropped, index 0, orphaned `BackgroundUseDefault_*`/`BackgroundPath_*` Settings keys purged at save). Settings key/value table holds app state: `MicSourceName`, `ReusableStream` (TASK 9 — the cached reusable stream's id/address/name so the pump has its RTMP URL at startup) |
| `VideoFrame.cs` | Normalized CPU frame seam (`Width`/`Height`/tightly-packed BGRA `byte[]`) — the only pixel type the rest of the app knows about; future capture sources (screen, background-removed webcam) feed the same seam |
| `CameraDeviceInfo.cs` | `(Id, DisplayName)` for a physical capture device |
| `ICameraEnumerator.cs` | `GetCamerasAsync()` — seam so the picker/`CameraManager` never touch WinRT (tests inject fakes) |
diff --git a/TASKS.md b/TASKS.md
index 2072012..2372b1e 100644
--- a/TASKS.md
+++ b/TASKS.md
@@ -1096,6 +1096,55 @@ Deliberately left alone: offline license re-validation skip (log-only — would
---
+## TASK 25 — Background consolidation: one locked Background per screen + mini-view rule
+
+### Status: ✅ Done (shipped 2026-08-23, branch `task25-backgrounds`)
+
+The creator rejected the multi-concept background model ("why are there multiple background
+things?"). Locked model, verbatim intent:
+
+1. **Exactly ONE background per screen**, named "Background", at position 0.
+2. It cannot be re-ordered or deleted; **no Background/Screen item in the (+) menu**.
+3. Context-sensitivity exists **only on the Live screen** (Show Desktop toggle + monitor
+ switching — behavior kept intact).
+4. **Exactly five default background images** live in the DB; stale rows are cleaned up.
+
+### What shipped:
+- **Seeder consolidation:** the five near-clone seeders (`Seed{Starting,Brb,Ending,Chat}Background`,
+ `SeedLiveBackgroundAsset`, `EnsureDefaultBackground`) are gone. One path now:
+ `EnsureBackground(scene)` → `CreateBackground(name)` — Live = `DisplayCapture` row (capture
+ machinery untouched), everything else = static `Background` art row; both named "Background".
+- **Heal on every load:** `NormalizeBackgrounds(scenes)` keeps the correctly-flavored row,
+ converts a wrong-flavor survivor in place (`Source.Type`'s setter derives `IsBackground` —
+ conversions must re-assert the flag), drops duplicates, seeds missing ones, renames, pins to
+ index 0; non-canonical scenes lose backgrounds + flag. `HealBackgrounds()` stamps default art
+ (`Assets/{scene}-background.jpg` via `AddAsset`; custom Browse art wins). This fixed the real DB's
+ rot: four scenes carried a stray "Game Capture" duplicate beside their static row, and Settings
+ held 16 orphaned `BackgroundUseDefault_{guid}` keys.
+- **Settings purge:** `LayoutStore.Save` deletes `BackgroundUseDefault_{id}` / `BackgroundPath_{id}`
+ keys whose id is no longer a Source row.
+- **(+) menu:** Screen + Background items removed → Webcam/Image/Text/Countdown/Web/YouTube Chat;
+ `AddSource` also hard-refuses DisplayCapture/WindowCapture/Background parameters.
+- **Capture controls Live-only:** `CanChangeBackground` = staged scene is Live. The preview
+ CanvasGrid menu (Show Desktop/Capture Desktop/Refresh Desktop) binds it directly; the layer-row
+ context menu MultiBindings it with the row's `IsBackground` through a new
+ `Helpers/AllTrueToVisibilityConverter`. Non-Live screens keep the Use-default pill + Browse.
+- **Mini rule:** minis never render live captures. While Live is staged its mini shows the green
+ placeholder; unstaged it shows `live-background.jpg`. Fixed by making `LoadBackgroundImage`
+ flavor-blind (`IsBackground`) and gating staged-Live to the placeholder in `RefreshSnapshotsAsync`.
+ (Real-time rendering stays center-monitor-only — preview lag during live gameplay is a known
+ unsolved OS-level problem and must not be compounded.)
+
+### Tests (suite went 221/218 → 223/220; the two stale background-policy landmine tests healed here):
+- Integration (the ONE): `BackgroundHealIntegrationTests` — seeds a dirty temp DB (duplicate rows,
+ misnamed Live row, orphaned keys), drives the real window, asserts one Background per scene at
+ index 0 with correct flavor/name, save purges the orphaned keys.
+- Unit: SceneCatalogTests rewritten for NormalizeBackgrounds/EnsureBackground flavors;
+ BackgroundTests empty-scene flavor updated; SourceNamingTests excludes the always-present
+ Background from numbered-name expectations.
+
+---
+
## Backlog (future versions)
1. v1.1 — Stream Deck / Loupedeck integration (requires hotkey foundation from TASK 20)
diff --git a/ViewModels/MainViewModel.cs b/ViewModels/MainViewModel.cs
index 51cbf76..50da029 100644
--- a/ViewModels/MainViewModel.cs
+++ b/ViewModels/MainViewModel.cs
@@ -201,7 +201,12 @@ public class MainViewModel : ViewModelBase
s.IsStaged = s == value;
SelectedElement = null;
- EnsureDefaultBackground(value);
+ if (value != null)
+ {
+ var background = EnsureBackground(value);
+ if (background != null)
+ StampDefaultBackgroundAsset(background, value.Name);
+ }
OnPropertyChanged(nameof(ShowChatInactiveMessage));
OnPropertyChanged(nameof(ShowEmptySceneHint));
OnPropertyChanged(nameof(ShowPreviewPlaceholder));
@@ -904,7 +909,10 @@ public class MainViewModel : ViewModelBase
public bool ShowAddScene => MissingScenes.Any();
/// "Change Capture…"/"Refresh Capture" apply to the active scene's background.
- public bool CanChangeBackground => StagedScene?.HasBackground == true;
+ // Capture controls (Show Desktop / monitor pick / refresh) exist only on
+ // the Live screen's Background. Static-art screens never expose them.
+ public bool CanChangeBackground =>
+ StagedScene != null && SceneCatalog.Is(StagedScene.Name, SceneCatalog.Live);
// Paid unlock flips this off (see ai.md "Monetization"). When disabled the
// cadence timer is stopped and any active flash is hidden immediately.
@@ -935,6 +943,10 @@ public class MainViewModel : ViewModelBase
private void UpdateActiveBackground()
{
+ // Static-art rows only. Capture-flavored rows (Live) must NEVER feed
+ // ActiveBackgroundImage — it renders above BackgroundImage, so it would
+ // permanently cover the desktop/game capture. Live's canvas shows
+ // DisplaySource instead (capture, falling back to live-background.jpg).
var background = StagedScene?.Elements.OfType().FirstOrDefault(s => s.Type == SourceType.Background);
ActiveBackgroundImage = background != null && !string.IsNullOrWhiteSpace(background.AssetId)
? ImageCache.Get(background.AssetId)
@@ -994,17 +1006,22 @@ public class MainViewModel : ViewModelBase
{
foreach (var scene in Scenes)
{
- var img = LoadBackgroundImage(scene);
+ // Minis never render live captures. While the Live screen is staged
+ // (being previewed) its mini shows the green placeholder; unstaged,
+ // it shows the stored default art. Real-time surface = center only.
+ var stagedLive = ReferenceEquals(scene, StagedScene)
+ && SceneCatalog.Is(scene.Name, SceneCatalog.Live);
+ var img = stagedLive ? null : LoadBackgroundImage(scene);
if (img != null)
scene.Snapshot = img;
- else if (scene.Snapshot == null)
+ else if (scene.Snapshot == null || stagedLive)
scene.Snapshot = CreatePlaceholderSnapshot(scene.Name);
}
}
- private static System.Windows.Media.Imaging.BitmapSource? LoadBackgroundImage(Scene scene)
+ internal static System.Windows.Media.Imaging.BitmapSource? LoadBackgroundImage(Scene scene)
{
- var bg = scene.Elements.OfType().FirstOrDefault(s => s.Type == SourceType.Background);
+ var bg = scene.Elements.OfType().FirstOrDefault(s => s.IsBackground);
if (bg != null && !bg.UseDefaultBackground
&& !string.IsNullOrWhiteSpace(bg.CustomBackgroundPath)
&& System.IO.File.Exists(bg.CustomBackgroundPath))
@@ -1746,15 +1763,7 @@ public class MainViewModel : ViewModelBase
foreach (var name in SceneCatalog.All)
AddScene(name);
}
- SeedStartingBackground();
- SeedBrbBackground();
- SeedEndingBackground();
- SeedChatBackground();
-
- // The live background belongs to Live only; a pre-policy DB may have
- // backgrounds lingering in other scenes — drop them, then
- // ReacquireScreenCaptures heals Live's.
- EnforceBackgroundPolicy(Scenes);
+ HealBackgrounds();
foreach (var scene in Scenes)
foreach (var config in scene.Elements.OfType())
@@ -1853,10 +1862,22 @@ public class MainViewModel : ViewModelBase
var background = scene.Elements.OfType().FirstOrDefault(s => s.IsBackground);
if (background != null) return background;
- background = new Source
+ background = CreateBackground(scene.Name);
+ scene.Elements.Insert(0, background);
+ return background;
+ }
+
+ /// The one Background row for a scene. Live gets the capture flavor
+ /// (Show Desktop / monitor switch / fullscreen-game detect) falling back to
+ /// live-background.jpg; every other screen is a static default-art layer.
+ /// Both are named "Background" — the user never sees capture jargon here.
+ internal static Source CreateBackground(string sceneName)
+ {
+ var isLive = SceneCatalog.Is(sceneName, SceneCatalog.Live);
+ return new Source
{
- Name = "Game Capture",
- Type = SourceType.DisplayCapture,
+ Name = "Background",
+ Type = isLive ? SourceType.DisplayCapture : SourceType.Background,
IsBackground = true,
IsEnabled = true,
X = 0,
@@ -1864,19 +1885,64 @@ public class MainViewModel : ViewModelBase
Width = MasterFrameWidth,
Height = MasterFrameHeight,
};
- scene.Elements.Insert(0, background);
- return background;
}
- // Every scene has a background layer. Normalize the flag by scene name.
- internal static void EnforceBackgroundPolicy(IEnumerable scenes)
+ /// Heals any loaded layout to the locked Background model: exactly
+ /// one Background per canonical scene, correct flavor for the scene (static
+ /// art everywhere, DisplayCapture on Live only), at index 0, named
+ /// "Background". Duplicates and stale rows are dropped; missing ones seeded;
+ /// non-canonical scenes get HasBackground=false and lose any backgrounds.
+ internal static void NormalizeBackgrounds(IEnumerable scenes)
{
foreach (var scene in scenes)
- scene.HasBackground = SceneCatalog.HasBackground(scene.Name);
+ {
+ if (!SceneCatalog.HasBackground(scene.Name))
+ {
+ scene.HasBackground = false;
+ foreach (var stale in scene.Elements.OfType().Where(s => s.IsBackground).ToList())
+ scene.Elements.Remove(stale);
+ continue;
+ }
+
+ scene.HasBackground = true;
+ var isLive = SceneCatalog.Is(scene.Name, SceneCatalog.Live);
+ var wantedType = isLive ? SourceType.DisplayCapture : SourceType.Background;
+
+ var candidates = scene.Elements.OfType().Where(s => s.IsBackground).ToList();
+ var keep = candidates.FirstOrDefault(b => b.Type == wantedType) ?? candidates.FirstOrDefault();
+
+ foreach (var extra in candidates.Where(b => !ReferenceEquals(b, keep)))
+ scene.Elements.Remove(extra);
+
+ if (keep == null)
+ {
+ keep = CreateBackground(scene.Name);
+ scene.Elements.Insert(0, keep);
+ }
+
+ // Source.Type's setter derives IsBackground from the enum, so a
+ // flavor correction must re-assert the flag afterwards.
+ if (keep.Type != wantedType)
+ {
+ keep.Type = wantedType;
+ keep.IsBackground = true;
+ }
+
+ keep.Name = "Background";
+ keep.IsEnabled = true;
+
+ var index = scene.Elements.IndexOf(keep);
+ if (index > 0)
+ scene.Elements.Move(index, 0);
+ }
}
+ // Only the Live screen's background is a real-time surface. Every other
+ // lookup below (auto-capture, focus hooks, refresh) must ignore the static
+ // default-art rows so they can never start a desktop capture session.
private IEnumerable AllBackgrounds()
- => Scenes.SelectMany(s => s.Elements.OfType().Where(x => x.IsBackground));
+ => Scenes.Where(s => SceneCatalog.Is(s.Name, SceneCatalog.Live))
+ .SelectMany(s => s.Elements.OfType().Where(x => x.IsBackground));
// Full-screen game on its monitor, else null (static bg fallback — no
// infinity mirror). Called at launch (from ReacquireScreenCaptures, before
@@ -1901,10 +1967,7 @@ public class MainViewModel : ViewModelBase
// session. Captures no longer referenced by any scene are released.
private void ReacquireScreenCaptures()
{
- foreach (var scene in Scenes)
- EnsureBackground(scene);
-
- SeedLiveBackgroundAsset();
+ HealBackgrounds();
var auto = ResolveAutoCaptureKey();
if (auto == null)
@@ -2013,7 +2076,7 @@ public class MainViewModel : ViewModelBase
{
var scene = StagedScene;
if (scene == null) return;
- var bg = scene.Elements.OfType().FirstOrDefault(s => s.Type == SourceType.Background);
+ var bg = scene.Elements.OfType().FirstOrDefault(s => s.IsBackground);
if (bg == null) return;
var dlg = new Microsoft.Win32.OpenFileDialog
@@ -2236,50 +2299,43 @@ public class MainViewModel : ViewModelBase
StagedScene = scene;
}
- private void SeedStartingBackground()
- {
- try
- {
- var starting = Scenes.FirstOrDefault(s => SceneCatalog.Is(s.Name, "Starting"));
- if (starting == null) return;
- if (starting.Elements.OfType().Any(s => s.Type == SourceType.Background)) return;
+ // ─── Background model (TASK 25): exactly ONE Background per screen, index 0,
+ // never removable or re-addable. Static default art on Starting/BRB/Chat/Ending;
+ // on Live it's the capture-flavored row (Show Desktop / monitor switch /
+ // fullscreen-game detect) falling back to live-background.jpg. ───
- var uri = new Uri("pack://application:,,,/Assets/starting-background.jpg", UriKind.Absolute);
- using var stream = Application.GetResourceStream(uri)?.Stream;
- if (stream == null) return;
- using var ms = new MemoryStream();
- stream.CopyTo(ms);
- var bytes = ms.ToArray();
- var assetId = AddAsset(bytes);
- if (assetId == null) return;
- starting.Elements.Add(new Source
- {
- Name = "Background",
- Type = SourceType.Background,
- AssetId = assetId,
- Width = 1920,
- Height = 1080,
- });
+ private static string? DefaultBackgroundAsset(string sceneName) => sceneName switch
+ {
+ SceneCatalog.Starting => "starting-background.jpg",
+ SceneCatalog.Brb => "brb-background.jpg",
+ SceneCatalog.Chat => "chat-background.jpg",
+ SceneCatalog.Ending => "ending-background.jpg",
+ SceneCatalog.Live => "live-background.jpg",
+ _ => null,
+ };
+
+ /// Heals every scene to the locked Background model after a layout
+ /// load: keeps the correct flavor per scene at index 0 named "Background",
+ /// drops duplicates/stale rows, re-seeds missing ones.
+ private void HealBackgrounds()
+ {
+ NormalizeBackgrounds(Scenes);
+ foreach (var scene in Scenes)
+ {
+ var background = scene.Elements.OfType().FirstOrDefault(s => s.IsBackground);
+ if (background != null)
+ StampDefaultBackgroundAsset(background, scene.Name);
}
- catch { /* non-critical — stream works fine without a default bg */ }
+ OnPropertyChanged(nameof(ShowSourcesEmptyHint));
}
- ///
- /// When switching to a scene that has no background, seed the default.
- ///
- private void EnsureDefaultBackground(Scene? scene)
+ /// Points a background row at its scene's shipped default art when it
+ /// carries none. A custom image (Browse…) always wins and is never overwritten.
+ private void StampDefaultBackgroundAsset(Source background, string sceneName)
{
- if (scene == null) return;
- if (scene.Elements.OfType().Any(s => s.Type == SourceType.Background)) return;
-
- string? asset = scene.Name switch
- {
- "Starting" => "starting-background.jpg",
- "BRB" => "brb-background.jpg",
- "Ending" => "ending-background.jpg",
- "Chat" => "chat-background.jpg",
- _ => null,
- };
+ if (!string.IsNullOrWhiteSpace(background.AssetId)) return;
+ if (!background.UseDefaultBackground && !string.IsNullOrWhiteSpace(background.CustomBackgroundPath)) return;
+ var asset = DefaultBackgroundAsset(sceneName);
if (asset == null) return;
try
@@ -2289,158 +2345,9 @@ public class MainViewModel : ViewModelBase
if (stream == null) return;
using var ms = new MemoryStream();
stream.CopyTo(ms);
- var bytes = ms.ToArray();
- var assetId = AddAsset(bytes);
- if (assetId == null) return;
- scene.Elements.Add(new Source
- {
- Name = "Background",
- Type = SourceType.Background,
- AssetId = assetId,
- Width = 1920,
- Height = 1080,
- });
- UpdateActiveBackground();
+ background.AssetId = AddAsset(ms.ToArray());
}
- catch { /* non-critical */ }
- }
-
- private void SeedBrbBackground()
- {
- try
- {
- var brb = Scenes.FirstOrDefault(s => SceneCatalog.Is(s.Name, "BRB"));
- if (brb == null) return;
-
- var uri = new Uri("pack://application:,,,/Assets/brb-background.jpg", UriKind.Absolute);
- using var stream = Application.GetResourceStream(uri)?.Stream;
- if (stream == null) return;
- using var ms = new MemoryStream();
- stream.CopyTo(ms);
- var bytes = ms.ToArray();
- var assetId = AddAsset(bytes);
- if (assetId == null) return;
-
- var existing = brb.Elements.OfType().FirstOrDefault(s => s.Type == SourceType.Background);
- if (existing != null)
- {
- existing.AssetId = assetId;
- existing.Width = 1920;
- existing.Height = 1080;
- }
- else
- {
- brb.Elements.Add(new Source
- {
- Name = "Background",
- Type = SourceType.Background,
- AssetId = assetId,
- Width = 1920,
- Height = 1080,
- });
- }
- }
- catch { /* non-critical — stream works fine without a default bg */ }
- }
-
- private void SeedLiveBackgroundAsset()
- {
- try
- {
- var live = Scenes.FirstOrDefault(s => SceneCatalog.Is(s.Name, "Live"));
- if (live == null) return;
- var background = live.Elements.OfType().FirstOrDefault(s => s.IsBackground);
- if (background == null) return;
-
- var uri = new Uri("pack://application:,,,/Assets/live-background.jpg", UriKind.Absolute);
- using var stream = Application.GetResourceStream(uri)?.Stream;
- if (stream == null) return;
- using var ms = new MemoryStream();
- stream.CopyTo(ms);
- var bytes = ms.ToArray();
- var assetId = AddAsset(bytes);
- if (assetId == null) return;
- background.AssetId = assetId;
- background.Width = 1920;
- background.Height = 1080;
- }
- catch { /* non-critical — stream works fine without a default bg */ }
- }
-
- private void SeedEndingBackground()
- {
- try
- {
- var ending = Scenes.FirstOrDefault(s => SceneCatalog.Is(s.Name, "Ending"));
- if (ending == null) return;
-
- var uri = new Uri("pack://application:,,,/Assets/ending-background.jpg", UriKind.Absolute);
- using var stream = Application.GetResourceStream(uri)?.Stream;
- if (stream == null) return;
- using var ms = new MemoryStream();
- stream.CopyTo(ms);
- var bytes = ms.ToArray();
- var assetId = AddAsset(bytes);
- if (assetId == null) return;
-
- var existing = ending.Elements.OfType().FirstOrDefault(s => s.Type == SourceType.Background);
- if (existing != null)
- {
- existing.AssetId = assetId;
- existing.Width = 1920;
- existing.Height = 1080;
- }
- else
- {
- ending.Elements.Add(new Source
- {
- Name = "Background",
- Type = SourceType.Background,
- AssetId = assetId,
- Width = 1920,
- Height = 1080,
- });
- }
- }
- catch { /* non-critical — stream works fine without a default bg */ }
- }
-
- private void SeedChatBackground()
- {
- try
- {
- var chat = Scenes.FirstOrDefault(s => SceneCatalog.Is(s.Name, "Chat"));
- if (chat == null) return;
-
- var uri = new Uri("pack://application:,,,/Assets/chat-background.jpg", UriKind.Absolute);
- using var stream = Application.GetResourceStream(uri)?.Stream;
- if (stream == null) return;
- using var ms = new MemoryStream();
- stream.CopyTo(ms);
- var bytes = ms.ToArray();
- var assetId = AddAsset(bytes);
- if (assetId == null) return;
-
- var existing = chat.Elements.OfType().FirstOrDefault(s => s.Type == SourceType.Background);
- if (existing != null)
- {
- existing.AssetId = assetId;
- existing.Width = 1920;
- existing.Height = 1080;
- }
- else
- {
- chat.Elements.Add(new Source
- {
- Name = "Background",
- Type = SourceType.Background,
- AssetId = assetId,
- Width = 1920,
- Height = 1080,
- });
- }
- }
- catch { /* non-critical — stream works fine without a default bg */ }
+ catch { /* non-critical — the app works without default art */ }
}
private void BeginEditElement(SceneElement? element)
@@ -2467,9 +2374,6 @@ public class MainViewModel : ViewModelBase
: Enum.TryParse(parameter?.ToString(), true, out var parsed) ? parsed : SourceType.Image;
var baseName = sourceType switch
{
- SourceType.DisplayCapture => "Screen",
- SourceType.WindowCapture => "Window",
- SourceType.Background => "Background",
SourceType.Image => "Image",
SourceType.TextOverlay => "Text",
SourceType.Countdown => "Countdown",
@@ -2478,27 +2382,11 @@ public class MainViewModel : ViewModelBase
_ => "Layer",
};
- if (sourceType == SourceType.Background)
- {
- var bytes = PickImageBytes("Choose a background image");
- if (bytes == null) return;
- var assetId = AddAsset(bytes);
- if (assetId == null) return;
-
- var existing = scene.Elements.OfType().FirstOrDefault(s => s.Type == SourceType.Background);
- if (existing != null)
- {
- existing.AssetId = assetId;
- UpdateActiveBackground();
- return;
- }
-
- scene.Elements.Add(new Source { Name = baseName, Type = SourceType.Background, AssetId = assetId });
- OnPropertyChanged(nameof(ShowEmptySceneHint));
- OnPropertyChanged(nameof(ShowSourcesEmptyHint));
- UpdateActiveBackground();
+ // The (+) menu only adds regular layers. The Background is a locked
+ // per-screen layer managed by EnsureBackground/NormalizeBackgrounds —
+ // never user-created, never deleted.
+ if (sourceType is SourceType.DisplayCapture or SourceType.WindowCapture or SourceType.Background)
return;
- }
var source = new Source
{
diff --git a/ai.md b/ai.md
index 8ae0225..bf9d59d 100644
--- a/ai.md
+++ b/ai.md
@@ -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:`, `window:`, or `picker:`. 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:`, `window:`, or `picker:`. 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)
diff --git a/ytLive.Tests/BackgroundHealIntegrationTests.cs b/ytLive.Tests/BackgroundHealIntegrationTests.cs
new file mode 100644
index 0000000..9628ae2
--- /dev/null
+++ b/ytLive.Tests/BackgroundHealIntegrationTests.cs
@@ -0,0 +1,179 @@
+using System;
+using System.IO;
+using System.Linq;
+using Microsoft.Data.Sqlite;
+using Xunit;
+using ytLive.Models;
+using ytLive.ViewModels;
+
+namespace ytLive.Tests;
+
+///
+/// TASK 25 integration: a pre-consolidation DB (static Background + stray
+/// "Game Capture" duplicates, wrong flavor on Live, orphaned settings keys)
+/// heals to the locked model — exactly one Background per screen, index 0,
+/// right flavor, right name — and a save purges the orphaned keys.
+/// Real-App + temp-DB pattern (see SourceNamingTests).
+///
+[Collection("RealApp")]
+public sealed class BackgroundHealIntegrationTests
+{
+ [Fact]
+ public void Dirty_Layout_Heals_To_One_Locked_Background_Per_Scene()
+ {
+ _app.Run(Run);
+ }
+
+ private readonly RealAppHost _app;
+
+ public BackgroundHealIntegrationTests(RealAppHost app) => _app = app;
+
+ private void Run()
+ {
+ var tempDb = Path.Combine(Path.GetTempPath(), $"ytLlive-bgheal-{Guid.NewGuid():N}.db");
+ MainViewModel.LayoutPathOverride = tempDb;
+ var window = default(MainWindow);
+ try
+ {
+ using (var schema = new ytLive.Services.LayoutStore(tempDb)) { }
+ SqliteConnection.ClearAllPools();
+ SeedDirtyDatabase(tempDb);
+
+ window = new MainWindow();
+ var vm = (MainViewModel)window.DataContext;
+
+ foreach (var name in SceneCatalog.All)
+ {
+ var scene = vm.Scenes.Single(s => SceneCatalog.Is(s.Name, name));
+ Assert.True(scene.HasBackground, $"{name}: HasBackground must be true");
+
+ var background = Assert.Single(
+ scene.Elements.OfType().Where(s => s.IsBackground).ToList(),
+ s => s.IsBackground);
+ Assert.Equal("Background", background.Name);
+ Assert.Equal(0, scene.Elements.IndexOf(background));
+
+ var wantedType = SceneCatalog.Is(name, SceneCatalog.Live)
+ ? SourceType.DisplayCapture
+ : SourceType.Background;
+ Assert.True(background.Type == wantedType,
+ $"{name}: expected {wantedType}, got {background.Type}");
+ }
+
+ vm.SaveLayoutNow();
+
+ using (var connection = new SqliteConnection($"Data Source={tempDb}"))
+ {
+ connection.Open();
+
+ using (var dupes = connection.CreateCommand())
+ {
+ dupes.CommandText =
+ "SELECT COUNT(*) FROM Source WHERE Name = 'Game Capture';";
+ Assert.Equal(0L, (long)dupes.ExecuteScalar()!);
+ }
+
+ using (var perScene = connection.CreateCommand())
+ {
+ perScene.CommandText =
+ @"SELECT s.Name, COUNT(*) FROM Source src
+ JOIN Scene s ON s.Id = src.SceneId
+ WHERE src.IsBackground = 1 GROUP BY s.Name;";
+ using var reader = perScene.ExecuteReader();
+ var counts = new System.Collections.Generic.Dictionary();
+ while (reader.Read())
+ counts[reader.GetString(0)] = reader.GetInt64(1);
+ var live = vm.Scenes.Single(s => SceneCatalog.Is(s.Name, SceneCatalog.Live));
+ vm.StagedScene = live;
+
+ // Layering invariant: the Live screen's background is capture-flavored,
+ // so it must never feed ActiveBackgroundImage (that image renders above
+ // the capture and would cover it permanently).
+ Assert.Null(vm.ActiveBackgroundImage);
+
+ foreach (var name in SceneCatalog.All)
+ Assert.True(counts.TryGetValue(name, out var c) && c == 1,
+ $"{name}: expected exactly 1 background row");
+ }
+
+ using (var staleKeys = connection.CreateCommand())
+ {
+ staleKeys.CommandText =
+ @"SELECT COUNT(*) FROM Settings
+ WHERE (Key LIKE 'BackgroundUseDefault\_%' ESCAPE '\'
+ OR Key LIKE 'BackgroundPath\_%' ESCAPE '\')
+ AND SUBSTR(Key, INSTR(Key, '_') + 1) IN ('bg-stray-1','bg-stray-2');";
+ Assert.Equal(0L, (long)staleKeys.ExecuteScalar()!);
+ }
+ }
+ }
+ finally
+ {
+ window?.Close();
+ MainViewModel.LayoutPathOverride = null;
+ SqliteConnection.ClearAllPools();
+ try { File.Delete(tempDb); } catch { /* best-effort cleanup */ }
+ }
+ }
+
+ /// Recreates the rot observed in the user's real DB: duplicate
+ /// capture-flavored rows beside static ones, a misnamed Live background,
+ /// and settings keys pointing at rows that shouldn't exist.
+ private static void SeedDirtyDatabase(string path)
+ {
+ using var connection = new SqliteConnection($"Data Source={path}");
+ connection.Open();
+
+ var scenes = new (string Id, string Name)[]
+ {
+ ("scene-starting", "Starting"),
+ ("scene-live", "Live"),
+ ("scene-brb", "BRB"),
+ ("scene-chat", "Chat"),
+ ("scene-ending", "Ending"),
+ };
+ foreach (var (id, name) in scenes)
+ {
+ using var cmd = connection.CreateCommand();
+ cmd.CommandText =
+ "INSERT INTO Scene (Id, Name, HasBackground, SortOrder) VALUES ($id, $name, 1, $order);";
+ cmd.Parameters.AddWithValue("$id", id);
+ cmd.Parameters.AddWithValue("$name", name);
+ cmd.Parameters.AddWithValue("$order", Array.IndexOf(scenes, (id, name)));
+ cmd.ExecuteNonQuery();
+ }
+
+ InsertSource(connection, "bg-static-1", "scene-starting", "Background", "Background", 0);
+ InsertSource(connection, "bg-stray-1", "scene-starting", "Game Capture", "DisplayCapture", 1);
+ InsertSource(connection, "bg-live-1", "scene-live", "Game Capture", "DisplayCapture", 0);
+ InsertSource(connection, "bg-static-2", "scene-chat", "Background", "Background", 0);
+ InsertSource(connection, "bg-stray-2", "scene-chat", "Game Capture", "DisplayCapture", 1);
+
+ using (var key1 = connection.CreateCommand())
+ {
+ key1.CommandText = "INSERT INTO Settings (Key, Value) VALUES ('BackgroundUseDefault_bg-stray-1', '0');";
+ key1.ExecuteNonQuery();
+ }
+ using (var key2 = connection.CreateCommand())
+ {
+ key2.CommandText = "INSERT INTO Settings (Key, Value) VALUES ('BackgroundPath_bg-stray-2', 'C:\\\\nope.png');";
+ key2.ExecuteNonQuery();
+ }
+ }
+
+ private static void InsertSource(
+ SqliteConnection connection, string id, string sceneId,
+ string name, string type, int sortOrder)
+ {
+ using var cmd = connection.CreateCommand();
+ cmd.CommandText =
+ @"INSERT INTO Source (Id, SceneId, Type, Name, IsEnabled, X, Y, Width, Height, IsBackground, SortOrder)
+ VALUES ($id, $sceneId, $type, $name, 1, 0, 0, 1920, 1080, 1, $sortOrder);";
+ cmd.Parameters.AddWithValue("$id", id);
+ cmd.Parameters.AddWithValue("$sceneId", sceneId);
+ cmd.Parameters.AddWithValue("$type", type);
+ cmd.Parameters.AddWithValue("$name", name);
+ cmd.Parameters.AddWithValue("$sortOrder", sortOrder);
+ cmd.ExecuteNonQuery();
+ }
+}
diff --git a/ytLive.Tests/BackgroundTests.cs b/ytLive.Tests/BackgroundTests.cs
index e9b55a7..4fde6b9 100644
--- a/ytLive.Tests/BackgroundTests.cs
+++ b/ytLive.Tests/BackgroundTests.cs
@@ -26,7 +26,9 @@ public class BackgroundTests
Assert.Same(background, scene.Elements[0]);
Assert.True(background.IsBackground);
Assert.True(background.IsEnabled);
- Assert.Equal(SourceType.DisplayCapture, background.Type);
+ // Non-Live scenes get the static-art flavor; only Live captures.
+ Assert.Equal(SourceType.Background, background.Type);
+ Assert.Equal("Background", background.Name);
Assert.Equal(0, background.X);
Assert.Equal(0, background.Y);
Assert.Equal(1920, background.Width);
diff --git a/ytLive.Tests/SceneCatalogTests.cs b/ytLive.Tests/SceneCatalogTests.cs
index cdb007b..616da69 100644
--- a/ytLive.Tests/SceneCatalogTests.cs
+++ b/ytLive.Tests/SceneCatalogTests.cs
@@ -46,31 +46,65 @@ public class SceneCatalogTests
}
[Fact]
- public void EnforceBackgroundPolicy_Sets_Flag_For_All_Scenes()
+ public void NormalizeBackgrounds_Heals_Dirty_Layouts_To_The_Locked_Model()
{
- var chat = new Scene { Name = "Chat", HasBackground = false };
+ // Pre-TASK-25 rot: static Background + stray Game Capture duplicates,
+ // wrong names, wrong z-order, stale flags on non-canonical scenes.
+ var starting = new Scene { Name = "Starting", HasBackground = false };
+ var image = new Source { Name = "Image 1", Type = SourceType.Image };
+ starting.Elements.Add(new Source { Name = "Background", Type = SourceType.Background, IsBackground = true, Width = 10 });
+ starting.Elements.Add(image);
+ starting.Elements.Add(new Source { Name = "Game Capture", Type = SourceType.DisplayCapture, IsBackground = true });
+
var live = new Scene { Name = "Live", HasBackground = false };
- var chatBackground = MainViewModel.EnsureBackground(chat)!;
- var liveBackground = MainViewModel.EnsureBackground(live)!;
+ live.Elements.Add(new Source { Name = "Game Capture", Type = SourceType.Background, IsBackground = true });
- MainViewModel.EnforceBackgroundPolicy(new[] { chat, live });
+ MainViewModel.NormalizeBackgrounds(new[] { starting, live });
+
+ Assert.True(starting.HasBackground);
+ var startingBackgrounds = starting.Elements.OfType().Where(s => s.IsBackground).ToList();
+ var kept = Assert.Single(startingBackgrounds);
+ Assert.Equal("Background", kept.Name);
+ Assert.Equal(SourceType.Background, kept.Type); // correct flavor preferred over the stray capture row
+ Assert.Same(image, starting.Elements[1]); // regular layers untouched
+ Assert.Equal(0, starting.Elements.IndexOf(kept));
- Assert.True(chat.HasBackground);
- Assert.Contains(chat.Elements, e => ReferenceEquals(e, chatBackground));
Assert.True(live.HasBackground);
- Assert.Contains(live.Elements, e => ReferenceEquals(e, liveBackground));
+ var liveKept = Assert.Single(live.Elements.OfType(), s => s.IsBackground);
+ Assert.Equal("Background", liveKept.Name);
+ Assert.Equal(SourceType.DisplayCapture, liveKept.Type); // flavor corrected to capture
+ Assert.Equal(0, live.Elements.IndexOf(liveKept));
}
[Fact]
- public void EnforceBackgroundPolicy_Normalizes_All_Canonical_Scenes()
+ public void NormalizeBackgrounds_Strips_Backgrounds_From_NonCanonical_Scenes()
{
- var starting = new Scene { Name = "Starting", HasBackground = false };
- MainViewModel.EnsureBackground(starting);
- var live = new Scene { Name = "Live", HasBackground = false };
+ var custom = new Scene { Name = "My Scene", HasBackground = true };
+ custom.Elements.Add(new Source { Name = "Background", Type = SourceType.Background, IsBackground = true });
+ var layer = new Source { Name = "Image 1", Type = SourceType.Image };
+ custom.Elements.Add(layer);
- MainViewModel.EnforceBackgroundPolicy(new[] { starting, live });
+ MainViewModel.NormalizeBackgrounds(new[] { custom });
- Assert.True(starting.HasBackground);
- Assert.True(live.HasBackground);
+ Assert.False(custom.HasBackground);
+ Assert.DoesNotContain(custom.Elements, e => e is Source { IsBackground: true });
+ Assert.Contains(custom.Elements, e => ReferenceEquals(e, layer));
+ }
+
+ [Fact]
+ public void EnsureBackground_Seeds_The_Right_Flavor_Per_Scene()
+ {
+ var starting = new Scene { Name = "Starting", HasBackground = true };
+ var startingBackground = MainViewModel.EnsureBackground(starting)!;
+ Assert.Equal(SourceType.Background, startingBackground.Type);
+ Assert.Equal("Background", startingBackground.Name);
+
+ var live = new Scene { Name = "Live", HasBackground = true };
+ var liveBackground = MainViewModel.EnsureBackground(live)!;
+ Assert.Equal(SourceType.DisplayCapture, liveBackground.Type);
+
+ var noBackgroundScene = new Scene { Name = "My Scene", HasBackground = false };
+ Assert.Null(MainViewModel.EnsureBackground(noBackgroundScene));
+ Assert.Empty(noBackgroundScene.Elements);
}
}
diff --git a/ytLive.Tests/SourceNamingTests.cs b/ytLive.Tests/SourceNamingTests.cs
index 7f69ae3..fbcbd5d 100644
--- a/ytLive.Tests/SourceNamingTests.cs
+++ b/ytLive.Tests/SourceNamingTests.cs
@@ -44,14 +44,17 @@ public sealed class SourceNamingTests
vm.AddSourceCommand.Execute(SourceType.TextOverlay);
vm.AddSourceCommand.Execute(SourceType.TextOverlay);
- var names = scene.Elements.OfType().Select(s => s.Name).ToList();
+ // The locked Background layer is always present but never numbered.
+ var names = scene.Elements.OfType().Where(s => !s.IsBackground)
+ .Select(s => s.Name).ToList();
Assert.Equal(new[] { "Text", "Text2", "Text3" }, names);
var middle = scene.Elements.OfType().Single(s => s.Name == "Text2");
scene.Elements.Remove(middle);
vm.AddSourceCommand.Execute(SourceType.TextOverlay);
- var survivors = scene.Elements.OfType().Select(s => s.Name).ToList();
+ var survivors = scene.Elements.OfType().Where(s => !s.IsBackground)
+ .Select(s => s.Name).ToList();
Assert.Equal(new[] { "Text", "Text3", "Text2" }, survivors);
}
finally