Fix: derive IsBackground from Type on layout load (legacy rows healed)

This commit is contained in:
2026-08-23 07:25:54 -07:00
parent 46da9954ad
commit 809c066184
5 changed files with 88 additions and 23 deletions
+37 -19
View File
@@ -1,31 +1,49 @@
# HANDOFF — Session State
## Branch
`main` — clean, HEAD `d2114c7`, 0 warnings. Not pushed.
`main` — **dirty (2 files, uncommitted)**, HEAD `46da995`. Not pushed.
## What's in commit d2114c7 (this session)
1. **All-five-scenes backgrounds** (prior session work, was uncommitted): `Scene.UseDefaultBackground` + `CustomBackgroundPath` on `SceneElement` superclass, LayoutStore persistence (`BackgroundPath_{sourceId}` / `BackgroundUseDefault_{sourceId}` in Settings), `SceneCatalog.HasBackground` true for all five scenes, `EnforceBackgroundPolicy` no longer strips non-Live backgrounds, tests updated.
2. **UI polish (this session):**
- "Use default background:" label + pill toggle on ONE horizontal line (MainWindow.xaml ~612)
- PillToggle resized 48×24 → 36×18 (thumb 18→12, travel 24→18) to match 11px label (Themes/Controls.xaml)
- New `SubHeader` style (11px SemiBold #a0a0b0, indented 4px); "BACKGROUND" → "Background" sub-title; Background section contents indented under it — PROPERTIES stays the only title-styled text
## Uncommitted work (this session): IsBackground load-time derivation fix
The user-reported bug "Background properties show for only Starting and Live" is FIXED:
1. **Root cause confirmed:** `Source.Type`'s setter derives `IsBackground` (`Type == Background`),
but `LayoutStore.Load()`'s object initializer assigned the DB **column** afterward — rows saved
before b74f15f carry `IsBackground=0` for `Type='Background'` and stayed false after load, so the
properties panel (`SelectedElement.IsBackground`) stayed hidden in BRB/Chat/Ending.
2. **Fix** (`Services/LayoutStore.cs` ~601): hoist the `Type` parse, then
`IsBackground = type == SourceType.Background || <column>`. The OR keeps the persisted bit for
legacy DisplayCapture backdrops (column=1 must survive so the live-capture layer stays flagged).
First save after load self-heals the columns.
3. **Test** (this branch's ONE integration test):
`LayoutStorePersistenceTests.Legacy_IsBackground_Zero_Rows_Derive_From_Type_On_Load` — saves a BRB
scene holding DisplayCapture + Background + Image, zeroes the legacy columns via raw SQL, reloads,
asserts Background→true / DisplayCapture→true / Image→false.
4. Build **0 warnings**; **212 total, 209 pass** (3 pre-existing failures, below).
## ⚠️ Landmines
- **User verdict (2026-08-23):** "The AI wrote fucked up code after repeatedly and intentionally misunderstanding instructions." This extends the ai.md 2026-08-22 audit flag — trust nothing from those sessions without manual verification. User switched models.
- **1 pre-existing test failure:** AudioPipeline gain-math (210/211 pass).
- **User verdict (2026-08-23):** "The AI wrote fucked up code after repeatedly and intentionally
misunderstanding instructions." This extends the ai.md 2026-08-22 audit flag — trust nothing from
those sessions without manual verification. User switched models.
- **Pre-existing test failures are THREE, not one (handoff correction — verified by stashing and
running clean HEAD):**
1. `AudioPipelineTests.Mix_HonorsProviderGains_AndGameMute_KillsTheLoopback` (gain-math)
2. `SourceNamingTests.Duplicate_Sources_Get_Next_Free_Numbered_Name` — asserts StagedScene names ==
["Text","Text2","Text3"], but StagedScene now carries a seeded "Game Capture" element (d2114c7
made all five scenes background-enabled); the test must filter the background out.
3. `SceneCatalogTests.EnforceBackgroundPolicy_Sets_Flag_For_All_Scenes` — calls `EnsureBackground`
on scenes with `HasBackground=false`; that returns null **by design**, so it asserts against
elements never inserted.
All three are fallout of the audit-flagged sessions' background-policy churn. NOT fixed here
(Good Dog Rule — this branch carries its one test only).
- **Real-MainWindow tests MUST set `LayoutPathOverride`** (see ai.md) — DB pollution wiped real data once.
## 🔴 Open bug (user-reported, NOT fixed): Background properties show for only Starting and Live
Evidence gathered before stop:
- Properties gate: `SelectedElement.IsBackground` (MainWindow.xaml ~608–611)
- Seeding is correct: `EnsureDefaultBackground` / seed methods all set `Type = SourceType.Background`
- **Root cause suspect:** `LayoutStore.cs:616` loads `IsBackground` from the DB **column**, not derived from `Type`. Rows saved before b74f15f (which made the Type setter derive IsBackground) carry `IsBackground=0` and stay false after load → panel hidden in BRB/Chat/Ending.
- **Likely fix:** derive `IsBackground = Type == SourceType.Background` on load (or backfill migration). Not attempted.
## Next steps
1. Fix the load-time IsBackground derivation bug (ONE integration test per Good Dog Rule)
2. Visual test on Windows display: pill sizing/alignment, sub-header hierarchy, backgrounds in all five scenes
3. Then: thumbnail drag-reorder → real compositor snapshots → transition dropdown UI (per previous plan)
1. Commit this fix (code + docs, one commit).
2. Fix the two stale background tests (tiny, separate commit each or together — they are unit-level repairs, not features).
3. Visual test on Windows display: pill sizing/alignment, sub-header hierarchy, Background properties panel in all five scenes (BRB/Chat/Ending should show it again).
4. Then: thumbnail drag-reorder → real compositor snapshots → transition dropdown UI (per previous plan).
## Build
```bash
+6 -2
View File
@@ -598,11 +598,12 @@ public class LayoutStore : IDisposable
while (reader.Read())
{
var sceneId = reader.GetString(1);
var type = Enum.Parse<SourceType>(reader.GetString(3));
var source = new Source
{
Id = reader.GetString(0),
AssetId = reader.IsDBNull(2) ? null : reader.GetString(2),
Type = Enum.Parse<SourceType>(reader.GetString(3)),
Type = type,
Name = reader.GetString(4),
IsEnabled = reader.GetInt32(5) != 0,
X = reader.GetDouble(6),
@@ -613,7 +614,10 @@ public class LayoutStore : IDisposable
MonitorIndex = reader.IsDBNull(11) ? null : reader.GetInt32(11),
ClipShape = Enum.TryParse<ClipShape>(reader.GetString(12), out var clip) ? clip : ClipShape.Traditional,
IsMirrored = reader.GetInt32(13) != 0,
IsBackground = reader.GetInt32(14) != 0,
// Rows saved before the Type setter derived the flag carry
// IsBackground=0; derive from Type, keeping the persisted bit
// for legacy DisplayCapture backdrops.
IsBackground = type == SourceType.Background || reader.GetInt32(14) != 0,
CaptureKey = reader.IsDBNull(15) ? null : reader.GetString(15),
ShowDesktop = reader.GetInt32(16) != 0,
ChatFontSize = reader.IsDBNull(17) ? 16 : reader.GetDouble(17),
+2 -1
View File
@@ -552,7 +552,8 @@ the validator → persisted), compositor bar overlay (top/bottom + above-flash),
3. ✅ File-model save/open — the active layout file is tracked (default is the AppData DB); **Save Layout As… / Open Layout…** switch the active file; auto-save writes to whatever is active
4. ✅ Auto-save (invisible) — ~1.5s debounce on scene/source add/remove/reorder/rename/hide + any source transform change; flush on window close
5. ✅ Startup — load the active file; seed the five canonical scenes only when the DB is empty; (+) re-adds a missing canonical scene and is hidden once all five are present; adding beyond the five is rejected
6. ✅ Schema v1 → v8 — webcam columns (v2), singleton `Webcam` + per-scene `WebcamSceneConfig` (v3), `RectWidth`/`RectHeight` round-to-rect restore (v4), `Source.IsBackdrop` + `Source.CaptureKey` (v5), `Scene.HasBackdrop` — backdrop **Live-only by policy** (v6, one-time backfill + `EnforceBackdropPolicy` on every load), `Scene.HasSocialBar` (v7, dropped per-scene toggle — column back-compat, unread), `Socials.BarEnabled` (v8); the `SocialEntry.Software` fediverse-software column is a **column-presence migration** (commented v8→v9, no version bump — `user_version` stays 8); `WindowHandle` stays in-memory (per-session); save = transactional rewrite; orphaned assets pruned. **v9 lands in the TASK 8 audio milestone** (single-row `Music` — `TrackPath`/`IsEnabled`)
6. ✅ Schema v1 → v9 — webcam columns (v2), singleton `Webcam` + per-scene `WebcamSceneConfig` (v3), `RectWidth`/`RectHeight` round-to-rect restore (v4), `Source.IsBackdrop` + `Source.CaptureKey` (v5), `Scene.HasBackdrop` — backdrop **Live-only by policy** (v6, one-time backfill + `EnforceBackdropPolicy` on every load), `Scene.HasSocialBar` (v7, dropped per-scene toggle — column back-compat, unread), `Socials.BarEnabled` (v8); the `SocialEntry.Software` fediverse-software column is a **column-presence migration** (commented v8→v9, no version bump — `user_version` stays 8); v9 = single-row `Music`. **Load-time rule:** sources load with `IsBackground` derived from `Type` (OR'd with the persisted column so legacy DisplayCapture backdrops keep their flag) — rows saved before the Type setter derived the flag heal on load
7. ✅ WindowHandle stays in-memory (per-session); save = transactional rewrite; orphaned assets pruned. **v9 lands in the TASK 8 audio milestone** (single-row `Music` — `TrackPath`/`IsEnabled`)
### Design decisions
+1 -1
View File
@@ -137,7 +137,7 @@ C# / WPF (.NET 8) following MVVM:
- **Audio is KISS by rule** — the whole of audio is *one knob*: **desktop/game audio is automatic** (WASAPI loopback from the default output at unity, zero UI — "it just is"); the **mic is the creator's only audio control** — sound meter + mute button + volume slider (`MicVolume`, defaults to 0.8) all sit together on the footer's top line, CENTERED beneath the preview panel. Meter: 288px, muted slate track (`#3a3b52`) with ruler graduations and muted yellow/red zone tints at 60%/80%; fill = green → yellow → red via `MeterFillWidth`/`MeterBrush`; the meter is a **READ-ONLY realtime level display** — it shows the live input level scaled by the volume (raising the volume moves ambient noise up the bar), NOT the volume setting: the fill is `Math.Min(1, AudioLevelMeter.ToDisplay(AudioLevel) * MicVolume)` — `ToDisplay` maps the raw linear RMS onto a −60..0 dBFS display scale, because real speech sits around −40..−20 dBFS (0.01..0.1 linear) which would leave a flat scale dead (`AudioLevel` is fed by the audio mixer once capture lands, 0 with no input) and 0 while muted. While the volume slider is being dragged the bar previews the slider position (`SetVolumeAdjusting`, from `PreviewMouseLeftButtonDown/Up` + `LostMouseCapture` handlers) so the creator sees where they're setting it; on release it returns to the live level — with no input it bounces back to 0, exactly as it does today. Clicking the meter does nothing; **clicking the MIC label opens the mic picker** (`OpenMicPickerCommand`), and the picked voice source name (`MicSourceName`) is shown left-justified INSIDE the meter bar (FontSize 10, ellipsized to the bar) — the fill runs at 75% opacity so the text and the ruler markings stay visible through it. Mute (`ToggleMicMuteCommand`/`MicMuted`) is a plain clickable speaker icon (`MicSpeaker_MouseLeftButtonUp` code-behind handler — not a Button, `Stretch="Uniform"` so the glyph is never clipped) that swaps to a red do-not-symbol (slashed speaker) when muted. **The slider and the speaker can never disagree:** `MicMuted` is read-only, derived from `MicVolume == 0` — sliding the volume off flips the speaker to muted (storing the prior level in `_volumeBeforeMute`), sliding it up from 0 clears the mute indicator (and the stored level); the speaker button just runs the volume to 0 or restores it (default 0.8 if unknown). Muting zeroes the meter; **unmuting flashes the meter to the restored position for ~300ms** (`BeginVolumeFlash`/`EndVolumeFlash` on a DispatcherTimer, cancelled if the slider is grabbed) before it returns to the live level. Line 2 of the footer holds everything else: stream stats (bitrate/fps/dropped/duration/health) on the left, quality dropdown + gear on the right. The slider is a slim dimensional style in `Themes/Controls.xaml` (gradient track, beveled green fill on a 5px pill, gloss-sphere thumb with drop shadow — deliberately NOT flat). No device pickers (never show device names — no "install a device you didn't know existed"), no filter stacks, no monitoring, no routing — OBS's confusion (dynamic mixer, unintuitive names, four required filters) is deliberately absent. A production-ready mic chain (high-pass → noise gate → compressor) will be applied invisibly in the mixer, unconfigurable. Capture runs only while live (privacy indicator stays off otherwise). Capture pipeline = `IAudioSource` seam + NAudio `WasapiCapture`/`WasapiLoopbackCapture` + `AudioMixer` (pending — the UI is in place now). **Mic mute icon (2026-08-13):** a second 16px clickable glyph — a microphone, red + slash when muted — sits **between the meter and the speaker** (both mutes adjacent, spacing between the icons) and reuses the same `MicSpeaker_MouseLeftButtonUp` → `ToggleMicMuteCommand` handler. **REC sign (2026-08-13):** the top-center indicator is an **always-visible REC chip** (`RecDotBrush`/`RecTextBrush`/`RecDotOpacity`/`IsLivePrivate`) — dark gray dot + dim "REC" offline, bright red (#e94560) while live, darker red (#8f1f1f) when live with a **private** stream (driven by the dialog's chosen `StreamVisibility`; the stream service still forces public — see TASK 4 ship step 7); the dot pulses while live, and the elapsed timer shows only when live. The connected account's avatar/name shows in the top bar next to Start Stream (`AccountAvatarUrl`/`AccountDisplayName` via `SyncConnectedAccount`), so the creator always sees WHICH account will go live.
- ViewModels are constructed in XAML (`<vm:MainViewModel/>` as DataContext)
- Services are currently instantiated in MainViewModel's constructor — no DI container yet
- Layout persists to SQLite (`Microsoft.Data.Sqlite`); scenes/sources/asset bytes stored in the DB, asset identity is a SHA-256 content hash (1:M reuse, no file paths — assets are always available)
- Layout persists to SQLite (`Microsoft.Data.Sqlite`); scenes/sources/asset bytes stored in the DB, asset identity is a SHA-256 content hash (1:M reuse, no file paths — assets are always available). Loaded sources always derive `IsBackground` from `Type` (OR'd with the persisted column, so legacy DisplayCapture backdrops keep their flag) — pre-derivation rows with `IsBackground=0` heal on load
- **Five-scene catalog (`Models/SceneCatalog.cs`):** the product is exactly Starting/Live/BRB/Chat/Ending — work with less, never more (the escape hatch for "more" is OBS). Scenes are matched **by name** (`SceneCatalog.Is`, case-insensitive trim). Empty DBs seed all five; the scenes-header "+" (`ShowAddScene`/`MissingScenes` on `MainViewModel`) only appears while ≥1 canonical scene is missing and its menu lists only the missing ones, re-adding them by name (`AddSceneCommand`). Renaming a canonical scene makes it missing again; `AddScene` rejects non-canonical names.
- Theming: all custom styles live in `Themes/Controls.xaml`, merged in `App.xaml` — never duplicate styles per-window (dialog duplicates were consolidated into this dictionary)
- Resolution tiers (bottom bar): 1080p60@8 (default) → 1080p30@8 → 720p60@6 → 720p30@6 → **Vertical 1080p60@8 (9:16, 1080×1920)**. The composition master frame is **always 1920×1080** — a tier is an output rect + target resolution over that master, so source geometry is never rewritten (no rounding drift). 16:9 tiers use the full frame; the vertical tier uses a centered **607×1080** window and the preview dims the cropped side strips at 55% black with an accent outline (semi-crop — the cut area stays visible). A resolution badge in the preview corner shows the active tier; the bottom bar shows bitrate/FPS. A **tooltip** explains finding upload bandwidth — an in-app speed test was deliberately dropped (unreliable). The future encoder crops the master to the rect and scales to the tier's Width×Height
@@ -120,6 +120,48 @@ public class LayoutStorePersistenceTests
}
}
// Rows saved before the Type setter derived IsBackground carry IsBackground=0
// for Type='Background', which used to load false — hiding the Background
// properties panel in BRB/Chat/Ending. Load must derive from Type while
// keeping the persisted bit for legacy DisplayCapture backdrops.
[Fact]
public void Legacy_IsBackground_Zero_Rows_Derive_From_Type_On_Load()
{
var path = Path.Combine(Path.GetTempPath(), $"ytLlive-layout-{Guid.NewGuid():N}.db");
try
{
using (var store = new LayoutStore(path))
{
var scene = new Scene { Name = "BRB", HasBackground = true };
scene.Elements.Add(new Source { Name = "Game Capture", Type = SourceType.DisplayCapture, IsBackground = true });
scene.Elements.Add(new Source { Name = "Background", Type = SourceType.Background });
scene.Elements.Add(new Source { Name = "Logo", Type = SourceType.Image });
store.Save(new[] { scene }, null, null);
using var conn = new SqliteConnection($"Data Source={path}");
conn.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = "UPDATE Source SET IsBackground = 0 WHERE Name IN ('Background', 'Logo');";
cmd.ExecuteNonQuery();
}
using var reopened = new LayoutStore(path);
var reloaded = reopened.Load();
var sources = Assert.Single(reloaded).Elements.OfType<Source>().ToList();
Assert.True(sources.Single(s => s.Type == SourceType.Background).IsBackground,
"A legacy IsBackground=0 row with Type=Background must derive the flag on load");
Assert.True(sources.Single(s => s.Type == SourceType.DisplayCapture).IsBackground,
"The persisted bit must survive for legacy DisplayCapture backdrops");
Assert.False(sources.Single(s => s.Type == SourceType.Image).IsBackground);
}
finally
{
SqliteConnection.ClearAllPools();
try { File.Delete(path); } catch { /* best-effort cleanup */ }
}
}
// Per-scene background switch (schema v6): a scene whose background was turned
// off must stay off across a save + reload.
[Fact]