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
+38 -14
View File
@@ -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
+14
View File
@@ -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();
}
+1
View File
@@ -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
+28 -8
View File
@@ -41,6 +41,8 @@
xmlns:Helpers="clr-namespace:ytLive.Helpers"/>
<Helpers:EnumToBoolConverter x:Key="EnumToBool"
xmlns:Helpers="clr-namespace:ytLive.Helpers"/>
<Helpers:AllTrueToVisibilityConverter x:Key="AllTrueToVis"
xmlns:Helpers="clr-namespace:ytLive.Helpers"/>
@@ -276,8 +278,6 @@
<MenuItem Header="Webcam" Command="{Binding AddWebcamCommand}"
IsEnabled="{Binding CanAddWebcamToStagedScene}"
ToolTip="One webcam at a time — it's already in your stream"/>
<MenuItem Header="Screen" Command="{Binding AddSourceCommand}" CommandParameter="{x:Static models:SourceType.DisplayCapture}"/>
<MenuItem Header="Background" Command="{Binding AddSourceCommand}" CommandParameter="{x:Static models:SourceType.Background}"/>
<MenuItem Header="Image" Command="{Binding AddImageCommand}"/>
<MenuItem Header="Text" Command="{Binding AddSourceCommand}" CommandParameter="{x:Static models:SourceType.TextOverlay}"/>
<MenuItem Header="Countdown" Command="{Binding AddSourceCommand}" CommandParameter="{x:Static models:SourceType.Countdown}"
@@ -313,7 +313,8 @@
</Helpers:FocusPreservingListBox.ItemContainerStyle>
<ListBox.ItemTemplate>
<DataTemplate>
<Grid Margin="0,2">
<Grid Margin="0,2"
Tag="{Binding DataContext.CanChangeBackground, RelativeSource={RelativeSource AncestorType=Window}}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
@@ -323,11 +324,30 @@
<Grid.ContextMenu>
<ContextMenu DataContext="{Binding PlacementTarget.DataContext, RelativeSource={RelativeSource Self}}">
<MenuItem Header="Change Capture…" Click="BackgroundMenu_ChangeCapture"
Visibility="{Binding IsBackground, Converter={StaticResource BoolToVis}}"/>
<MenuItem Header="Refresh Capture" Click="BackgroundMenu_RefreshCapture"
Visibility="{Binding IsBackground, Converter={StaticResource BoolToVis}}"/>
<Separator Visibility="{Binding IsBackground, Converter={StaticResource BoolToVis}}"/>
<MenuItem Header="Change Capture…" Click="BackgroundMenu_ChangeCapture">
<MenuItem.Visibility>
<MultiBinding Converter="{StaticResource AllTrueToVis}">
<Binding Path="IsBackground"/>
<Binding Path="PlacementTarget.Tag" RelativeSource="{RelativeSource AncestorType={x:Type ContextMenu}}"/>
</MultiBinding>
</MenuItem.Visibility>
</MenuItem>
<MenuItem Header="Refresh Capture" Click="BackgroundMenu_RefreshCapture">
<MenuItem.Visibility>
<MultiBinding Converter="{StaticResource AllTrueToVis}">
<Binding Path="IsBackground"/>
<Binding Path="PlacementTarget.Tag" RelativeSource="{RelativeSource AncestorType={x:Type ContextMenu}}"/>
</MultiBinding>
</MenuItem.Visibility>
</MenuItem>
<Separator>
<Separator.Visibility>
<MultiBinding Converter="{StaticResource AllTrueToVis}">
<Binding Path="IsBackground"/>
<Binding Path="PlacementTarget.Tag" RelativeSource="{RelativeSource AncestorType={x:Type ContextMenu}}"/>
</MultiBinding>
</Separator.Visibility>
</Separator>
<MenuItem Header="Remove Layer" Click="SourceMenu_Remove"
Visibility="{Binding IsBackground, Converter={StaticResource InverseBoolToVis}}"/>
</ContextMenu>
+14 -1
View File
@@ -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();
}
+1 -1
View File
@@ -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) |
+49
View File
@@ -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)
+130 -242
View File
@@ -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();
/// <summary>"Change Capture…"/"Refresh Capture" apply to the active scene's background.</summary>
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<Source>().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<Source>().FirstOrDefault(s => s.Type == SourceType.Background);
var bg = scene.Elements.OfType<Source>().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<WebcamSceneConfig>())
@@ -1853,10 +1862,22 @@ public class MainViewModel : ViewModelBase
var background = scene.Elements.OfType<Source>().FirstOrDefault(s => s.IsBackground);
if (background != null) return background;
background = new Source
background = CreateBackground(scene.Name);
scene.Elements.Insert(0, background);
return background;
}
/// <summary>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.</summary>
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<Scene> scenes)
/// <summary>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.</summary>
internal static void NormalizeBackgrounds(IEnumerable<Scene> 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<Source>().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<Source>().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<Source> AllBackgrounds()
=> Scenes.SelectMany(s => s.Elements.OfType<Source>().Where(x => x.IsBackground));
=> Scenes.Where(s => SceneCatalog.Is(s.Name, SceneCatalog.Live))
.SelectMany(s => s.Elements.OfType<Source>().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<Source>().FirstOrDefault(s => s.Type == SourceType.Background);
var bg = scene.Elements.OfType<Source>().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<Source>().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,
};
/// <summary>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.</summary>
private void HealBackgrounds()
{
NormalizeBackgrounds(Scenes);
foreach (var scene in Scenes)
{
var background = scene.Elements.OfType<Source>().FirstOrDefault(s => s.IsBackground);
if (background != null)
StampDefaultBackgroundAsset(background, scene.Name);
}
catch { /* non-critical — stream works fine without a default bg */ }
OnPropertyChanged(nameof(ShowSourcesEmptyHint));
}
/// <summary>
/// When switching to a scene that has no background, seed the default.
/// </summary>
private void EnsureDefaultBackground(Scene? scene)
/// <summary>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.</summary>
private void StampDefaultBackgroundAsset(Source background, string sceneName)
{
if (scene == null) return;
if (scene.Elements.OfType<Source>().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<Source>().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<Source>().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<Source>().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<Source>().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<SourceType>(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<Source>().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
{
+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)
@@ -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;
/// <summary>
/// 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).
/// </summary>
[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<Source>().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<string, long>();
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 */ }
}
}
/// <summary>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.</summary>
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();
}
}
+3 -1
View File
@@ -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);
+49 -15
View File
@@ -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<Source>().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<Source>(), 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);
}
}
+5 -2
View File
@@ -44,14 +44,17 @@ public sealed class SourceNamingTests
vm.AddSourceCommand.Execute(SourceType.TextOverlay);
vm.AddSourceCommand.Execute(SourceType.TextOverlay);
var names = scene.Elements.OfType<Source>().Select(s => s.Name).ToList();
// The locked Background layer is always present but never numbered.
var names = scene.Elements.OfType<Source>().Where(s => !s.IsBackground)
.Select(s => s.Name).ToList();
Assert.Equal(new[] { "Text", "Text2", "Text3" }, names);
var middle = scene.Elements.OfType<Source>().Single(s => s.Name == "Text2");
scene.Elements.Remove(middle);
vm.AddSourceCommand.Execute(SourceType.TextOverlay);
var survivors = scene.Elements.OfType<Source>().Select(s => s.Name).ToList();
var survivors = scene.Elements.OfType<Source>().Where(s => !s.IsBackground)
.Select(s => s.Name).ToList();
Assert.Equal(new[] { "Text", "Text3", "Text2" }, survivors);
}
finally