Backgrounds: one locked Background per screen + mini rule (TASK 25)
This commit is contained in:
+38
-14
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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) |
|
||||
|
||||
@@ -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
@@ -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
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user