diff --git a/Controls/PreviewPane.xaml b/Controls/PreviewPane.xaml index 2b4ad71..28fc1b6 100644 --- a/Controls/PreviewPane.xaml +++ b/Controls/PreviewPane.xaml @@ -69,7 +69,6 @@ - - diff --git a/HANDOFF.md b/HANDOFF.md index b31c9bc..17bb906 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -1,65 +1,71 @@ -# HANDOFF — 2026-10-10 (scene-order F-key fix + desktop-lock fix; both LOCAL, uncommitted) +# HANDOFF — 2026-10-10 (strip tile artwork + on-air tile unit; uncommitted) ## At a glance -- **Branch** main (no feature branch). Pushed through `5685c5d`; 18 commits local, **not pushed** +- **Branch** main (no feature branch). Pushed through `5685c5d`; 19 commits local, **not pushed** (push at the creator's call). -- Desktop-lock fix committed as `63bf844`; **scene-order fix below is uncommitted**. +- Scene-order unit committed `06f88f4`; desktop-lock committed `63bf844`. The **strip tile + artwork unit below is uncommitted**. ## Branch - main (no feature branch). Pushed through `5685c5d` (TRAX toggle); everything after is local, **not pushed** (push at the creator's call). -## New work unit (this handoff) — READY TO VERIFY, do not commit yet -- **Fix** for the scene-strip / F-key ordering report — `ViewModels/MainViewModel.Scenes.cs` new - `ReorderScenesToCanonical()` (canonical F1-F5 order at load, renamed scenes after; next save - re-stamps SortOrder) called from `MainViewModel.cs:LoadLayout`; `ytLive.Tests/SceneOrderTests.cs` - (new, seeds a Chat-before-BRB DB and asserts order + F1-F5 labels + persisted SortOrder). - See `bugs.md` entry 2026-10-10. Scope-locked files: `ViewModels/MainViewModel.Scenes.cs`, - `ViewModels/MainViewModel.cs` (1 call line), `ytLive.Tests/SceneOrderTests.cs`, `bugs.md`, - `HANDOFF.md`. -- Full suite **423 passed / 0 failed / 0 warnings**. Root cause was the creator's own DB (Chat - SortOrder 2, BRB 3); the fix heals it on next launch regardless of DB history. -- **To commit later (this unit only):** the files listed above. Then the working tree is back to - just the creator's pinned-chat unit (NOT mine to commit). +## New work unit (this handoff) — VERIFIED, ready to commit +Strip tile artwork + on-air indicator (creator ruling 2026-10-10). Three sub-parts: -## Desktop-lock unit (COMMITTED 63bf844 — desktop "lock" fix) -- `ShowActivated = !FullscreenGameRunningNow` + `FullScreenDetectorOverride` seam + `DesktopLockTests` - (3 tests). Committed 2026-10-10. Creator still to verify in-game (launch while an - exclusive-fullscreen game owns the foreground — the game must stay live). +1. **Per-scene CRT tile art** — each canonical scene's top-bar tile is its OWN image stored in the + internal DB (`Scene.ThumbnailAssetId`, additive migration, seeded at heal from the creator's five + Downloads JPGs via `SceneThumbnailSeedPaths`). The preview pane keeps rendering the scene's original + background — never the tile. `Save()` Asset prune now keeps `Scene.ThumbnailAssetId` rows + (this was a real bug: the first save would have deleted the seeded tiles). +2. **On-air image** — one shared image (`Gemini_Generated_Image_4zajyl4zajyl4zaj.jpg`) that REPLACES the + staged scene's tile ("the on-air screen jumps around the top bar as the creator swaps scenes"). + Stored as an Asset BLOB tracked by the `OnAirImageAssetId` Setting (`EnsureOnAirImage`, same contract + as the alert clip; prune keep-set extended). Seed path override: `MainViewModel.OnAirSeedPath`. + `RefreshSnapshotsAsync`: staged scene → on-air art; unstaged → own thumbnail or background art; + staged Live without on-air art → green placeholder (unchanged invariant: minis never render captures). +3. **Preview + labels polish** — removed the 6px `#22c55e` rectangle in `BackdropLayer` + (`Controls/PreviewPane.xaml`) — the ONLY live signal is the outer `PreviewGlow` red border + (`MainViewModel.Streaming.cs`, transparent otherwise); the redundant rect was only visible when a + scene had no background ("green outline around the preview screen" the creator saw). Scene-tile + labels doubled: `Controls/SceneThumbnailStrip.xaml` FontSize 10 → 20. + +- Files: `Models/Scene.cs`, `Services/LayoutStore.{Migrations,Load,Save,Settings}.cs`, + `ViewModels/MainViewModel.Background.cs`, `Controls/PreviewPane.xaml` (rect removed), + `Controls/SceneThumbnailStrip.xaml` (label), `ytLive.Tests/SceneThumbnailTests.cs` (4 tests). +- Full suite **427 passed / 0 failed / 0 warnings** (one run flaked on the known + `RealMouseDrag_…PersistsTheReorder` env test — needs an idle desktop — and passed on re-run; + nothing in this unit touches layer reorder). Existing 3 tile tests stub seed paths + `OnAirSeedPath` + so the suite is hermetic; the new 4th test covers the on-air swap + persistence. +- **To commit later (this unit only):** the files above. Then the working tree is back to just the + creator's pinned-chat unit (NOT mine to commit). + +## Desktop-lock unit (COMMITTED 63bf844) +- `ShowActivated = !FullscreenGameRunningNow` + `FullScreenDetectorOverride` seam + `DesktopLockTests`. + Creator still to verify in-game (launch while an exclusive-fullscreen game owns the foreground). + +## Scene-order unit (COMMITTED 06f88f4) +- `ReorderScenesToCanonical()` + `SceneOrderTests`. The creator's DB (Chat SortOrder 2, BRB 3) heals to + canonical F1-F5 order at load; next save re-stamps SortOrder. ## Dirty files — current reality (2026-10-10) - **NOT mine — do NOT commit:** the creator's in-flight "pinned chat" unit (`Models/YouTube.cs`, `ViewModels/MainViewModel.Chat.cs`, untracked `Helpers/RelayCommandGeneric.cs`, and **its hunks in `Controls/LeftPanel.xaml`**), plus the creator's untracked `Assets/llamacasty*` art. -- ⚠️ Pre-commit (scene-order unit): `scripts/verify.sh` runs `scripts/scope-check.sh` against ALL - working-tree changes — the not-mine pinned-chat + art files WILL flag. Pass them as declared-known. - -## What just happened — TASK 51 quality pass (committed 2026-10-09) -1. **Jitter fix (`3057069`) — `MaskSmoother`**: per-session blend 0.75×new + 0.25×previous at the - ONE worker (`CameraManager.RunEffectWorker` → `session.Smoother.Smooth(TrySegment(source))`). - State on `CameraSession`, NOT the segmenter (shared app-wide, contract-pinned to raw output). Null - resets history; first frame passes through; never aliases caller's array; ping-pong buffers (no - per-frame LOH alloc). Weights: OBS obs-backgroundremoval 0.85 default, Jitsi 0.7/0.3 best - (0.5/0.5 smears). Test `Temporal_Smoothing_Damps_Segmentation_Jitter_Across_Frames`: x=6 → first - 255 / smoothed 58 / raw-unsmoothed 0. ⚠️ Gotcha: worker guards a re-queued identical frame - reference — pump frame2 as a NEW instance. -2. **Blur "heavy" fix (this commit) — quadratic radius**: `BlurRadius(strength)` = 1 + 5·s², - `MaxBlurRadius` 12→6 small px (≈ 24 full px max). Default 0.5 = 2 small px (was 6 under the old - linear curve) — ~3× lighter spread. Rationale: StreamFX defaults Box blur Size to 5, author stops - downscaled blurs ~8px. Test `Blur_default_strength_is_gentle_and_full_strength_is_strong` pins the - mapping and step-edge behaviour (reach 9px default vs 26px max). Point-source probes vanish below - the 0.5 rounding (3 box passes dilute a spike) — use a step edge, not a spike, to measure blur. +- ⚠️ Pre-commit: `scripts/verify.sh` runs `scripts/scope-check.sh` against ALL working-tree changes — + the not-mine pinned-chat + art files WILL flag. Pass them as declared-known. ## Next step -- Creator launches the app: the scene strip must show Starting/Live/BRB/Chat/Ending in order with - F1-F5 correct (the creator's DB had Chat before BRB; the load-time reorder heals it and the next - save re-stamps SortOrder). Also still to verify when convenient: the desktop-lock fix while an - exclusive-fullscreen game owns the foreground. Then I can commit the scene-order unit. +- Commit this unit (files listed above; gate already green at 427/427, 0 warnings). Then the creator + launches the app on the real DB: the five CRT tiles seed from `C:\Users\gramp\Downloads\Gemini_…`, + the staged scene's tile shows the on-air image, the preview shows the scene's real background with no + green frame, and the tile labels are legible (FontSize 20). Push is still at the creator's call. ## Tests -- Full suite **423 passed / 0 failed / 0 skipped / 0 warnings** (2026-10-10; includes the new - `SceneOrderTests`). Known env flakes remain `Mix_HonorsProviderGains…` (fails while system audio - plays) and `RealMouseDrag…` (needs an idle desktop). +- Full suite **427 passed / 0 failed / 0 skipped / 0 warnings** (2026-10-10). Known env flakes: + `Mix_HonorsProviderGains…` (fails while system audio plays), `RealMouseDrag…` (needs an idle desktop), + `Round_Clip_Corner…` (grab/geometry under active mouse). The verify gate whitelists only the latter two — + `RealMouseDrag…` failing mid-session needs one re-run when the desktop idles. ## Also still open - **Vertical-format regression (STALE, not fixed):** 1080p60 vertical selection no longer shrinks the @@ -68,9 +74,5 @@ `MainViewModel.Streaming.cs` `ApplyStreamQuality` from `SelectedQuality` (binding + ctor only, `new("Vertical 1080p60", 60, 8.0, 1080, 1920)`), `DimRects` in `MainViewModel.cs:91-94`. Root cause unknown — paused. -- **Vertical recording file closed:** the recording itself is 1080×1920 60fps correctly; the - "whitespace" was the app's preview UI, not the file. Audio loudness (`ebur128`) for the private video - was never run and the thread is closed unless the user reopens it. -- Blur-heaviness = fixed (above). Yellow-border regression = stale thread. - ⛔ Sudo password rotation pending (user; then update both password lines in `CREDENTIALS.md`). - TASK 56 secret scan before `gramps/LlamaCasty` goes public. TASK 55 disk hygiene queued. \ No newline at end of file diff --git a/Models/Scene.cs b/Models/Scene.cs index 06f72c8..34a1baf 100644 --- a/Models/Scene.cs +++ b/Models/Scene.cs @@ -72,6 +72,14 @@ public class Scene : INotifyPropertyChanged public bool IsChatScene { get; init; } + /// + /// Asset-table id of this scene's top-strip tile artwork. The tile is a + /// separate image from the scene's background: the preview pane keeps + /// rendering the scene's original background, the strip shows this instead. + /// Null = the strip falls back to the background art (creator ruling 2026-10-10). + /// + public string? ThumbnailAssetId { get; set; } + private BitmapSource? _snapshot; /// diff --git a/Services/LayoutStore.Load.cs b/Services/LayoutStore.Load.cs index 5543862..fa0ffec 100644 --- a/Services/LayoutStore.Load.cs +++ b/Services/LayoutStore.Load.cs @@ -16,7 +16,7 @@ public partial class LayoutStore : IDisposable using (var cmd = _connection.CreateCommand()) { - cmd.CommandText = "SELECT Id, Name, IsHidden, IsChatScene, HasBackground, HasSocialBar FROM Scene ORDER BY SortOrder"; + cmd.CommandText = "SELECT Id, Name, IsHidden, IsChatScene, HasBackground, HasSocialBar, ThumbnailAssetId FROM Scene ORDER BY SortOrder"; using var reader = cmd.ExecuteReader(); while (reader.Read()) { @@ -28,6 +28,7 @@ public partial class LayoutStore : IDisposable IsChatScene = reader.GetInt32(3) != 0, HasBackground = reader.GetInt32(4) != 0, HasSocialBar = reader.FieldCount > 5 && !reader.IsDBNull(5) && reader.GetInt32(5) != 0, + ThumbnailAssetId = reader.FieldCount > 6 && !reader.IsDBNull(6) ? reader.GetString(6) : null, }); } } diff --git a/Services/LayoutStore.Migrations.cs b/Services/LayoutStore.Migrations.cs index 2b26f47..70148de 100644 --- a/Services/LayoutStore.Migrations.cs +++ b/Services/LayoutStore.Migrations.cs @@ -138,6 +138,7 @@ public partial class LayoutStore : IDisposable MigrateWebcamConfigTable(); MigrateSceneTable(); MigrateSceneSocialBarColumn(); + MigrateSceneThumbnailColumn(); MigrateSocialsTable(); MigrateSocialEntryTable(); if (GetUserVersion() < 3) @@ -527,6 +528,27 @@ public partial class LayoutStore : IDisposable alter.ExecuteNonQuery(); } + // 2026-10-10: Scene gains ThumbnailAssetId (the top-strip tile artwork, stored + // as an Asset row like every image). Additive, no version bump — old DBs just + // get a NULL column and the strip falls back to the scene's background art. + private void MigrateSceneThumbnailColumn() + { + var columns = new HashSet(StringComparer.OrdinalIgnoreCase); + using (var cmd = _connection.CreateCommand()) + { + cmd.CommandText = "PRAGMA table_info(Scene);"; + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + columns.Add(reader.GetString(1)); + } + + if (columns.Contains("ThumbnailAssetId")) return; + + using var alter = _connection.CreateCommand(); + alter.CommandText = "ALTER TABLE Scene ADD COLUMN ThumbnailAssetId TEXT;"; + alter.ExecuteNonQuery(); + } + // v7 → v8: Socials gains BarEnabled (the dialog's show/hide switch). The old // BarJustify column stays for back-compat but is no longer read or written. private void MigrateSocialsTable() diff --git a/Services/LayoutStore.Save.cs b/Services/LayoutStore.Save.cs index 7b82e29..b52a550 100644 --- a/Services/LayoutStore.Save.cs +++ b/Services/LayoutStore.Save.cs @@ -54,8 +54,8 @@ public partial class LayoutStore : IDisposable using (var cmd = _connection.CreateCommand()) { cmd.CommandText = """ - INSERT INTO Scene (Id, Name, IsHidden, IsChatScene, HasBackground, HasSocialBar, SortOrder) - VALUES ($id, $name, $isHidden, $isChat, $hasBackground, $hasSocialBar, $sort) + INSERT INTO Scene (Id, Name, IsHidden, IsChatScene, HasBackground, HasSocialBar, ThumbnailAssetId, SortOrder) + VALUES ($id, $name, $isHidden, $isChat, $hasBackground, $hasSocialBar, $thumbnail, $sort) """; cmd.Transaction = tx; var idP = cmd.Parameters.Add("$id", SqliteType.Text); @@ -64,6 +64,7 @@ public partial class LayoutStore : IDisposable var chatP = cmd.Parameters.Add("$isChat", SqliteType.Integer); var backgroundP = cmd.Parameters.Add("$hasBackground", SqliteType.Integer); var socialBarP = cmd.Parameters.Add("$hasSocialBar", SqliteType.Integer); + var thumbnailP = cmd.Parameters.Add("$thumbnail", SqliteType.Text); var sortP = cmd.Parameters.Add("$sort", SqliteType.Integer); var sort = 0; @@ -75,6 +76,7 @@ public partial class LayoutStore : IDisposable chatP.Value = scene.IsChatScene ? 1 : 0; backgroundP.Value = scene.HasBackground ? 1 : 0; socialBarP.Value = scene.HasSocialBar ? 1 : 0; + thumbnailP.Value = (object?)scene.ThumbnailAssetId ?? DBNull.Value; sortP.Value = sort++; cmd.ExecuteNonQuery(); } @@ -324,9 +326,13 @@ public partial class LayoutStore : IDisposable DELETE FROM Asset WHERE Id NOT IN ( SELECT AssetId FROM Source WHERE AssetId IS NOT NULL UNION + SELECT ThumbnailAssetId FROM Scene WHERE ThumbnailAssetId IS NOT NULL + UNION SELECT AlertVideoAssetId FROM Source WHERE AlertVideoAssetId IS NOT NULL UNION SELECT Value FROM Settings WHERE Key = 'AlertDefaultVideoAssetId' + UNION + SELECT Value FROM Settings WHERE Key = 'OnAirImageAssetId' ); """; cmd.Transaction = tx; diff --git a/Services/LayoutStore.Settings.cs b/Services/LayoutStore.Settings.cs index ed049db..98d1583 100644 --- a/Services/LayoutStore.Settings.cs +++ b/Services/LayoutStore.Settings.cs @@ -326,4 +326,26 @@ public partial class LayoutStore : IDisposable /// The stored default clip's Asset id, or null when never stamped. public string? GetAlertDefaultVideoId() => GetSetting(AlertDefaultVideoKey); + + internal const string OnAirImageKey = "OnAirImageAssetId"; + + /// The strip tile that labels the scene currently PREVIEWED (creator + /// ruling 2026-10-10 — the "on-air background screen"). One shared image, not + /// per-scene: whichever scene is staged shows it in the top bar, so it "jumps + /// around" as the creator swaps scenes. Lives as a BLOB in the internal Asset + /// table, tracked by this Setting so the Save pruner keeps it alive — the same + /// contract as the alert default clip. UpsertAsset is hash-keyed, so + /// re-stamping the same bytes returns the same id. + public string EnsureOnAirImage(byte[] data) + { + var saved = GetSetting(OnAirImageKey); + if (!string.IsNullOrWhiteSpace(saved) && GetAssetBytes(saved) != null) + return saved; + var id = UpsertAsset(data, 0, 0); + UpsertSetting(OnAirImageKey, id); + return id; + } + + /// The stored on-air image's Asset id, or null when never stamped. + public string? GetOnAirImageId() => GetSetting(OnAirImageKey); } diff --git a/ViewModels/MainViewModel.Background.cs b/ViewModels/MainViewModel.Background.cs index 0d0c6fd..ee530a3 100644 --- a/ViewModels/MainViewModel.Background.cs +++ b/ViewModels/MainViewModel.Background.cs @@ -144,19 +144,34 @@ public partial class MainViewModel { foreach (var scene in Scenes) { - // 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); + // The scene currently shown in the preview labels its top-bar tile with + // the on-air image (creator ruling 2026-10-10) — it jumps around the + // strip as the creator swaps scenes. Every other tile keeps its own + // artwork (ThumbnailAssetId) or falls back to the background art; minis + // never render live captures: a staged Live without a stamped on-air art + // still falls back to the green placeholder. Real-time surface = center only. + var isStaged = ReferenceEquals(scene, StagedScene); + var img = isStaged && OnAirImage != null + ? OnAirImage + : SceneThumbnailImage(scene); + if (img == null) + { + var stagedLive = isStaged + && SceneCatalog.Is(scene.Name, SceneCatalog.Live); + img = stagedLive ? null : LoadBackgroundImage(scene); + } if (img != null) scene.Snapshot = img; - else if (scene.Snapshot == null || stagedLive) + else if (scene.Snapshot == null || (isStaged && OnAirImage == null)) scene.Snapshot = CreatePlaceholderSnapshot(scene.Name); } } + private static System.Windows.Media.Imaging.BitmapSource? SceneThumbnailImage(Scene scene) + => string.IsNullOrWhiteSpace(scene.ThumbnailAssetId) + ? null + : ImageCache.Get(scene.ThumbnailAssetId); + // Full-screen game on its monitor, else null (static bg fallback — no // infinity mirror). Called at launch (from ReacquireScreenCaptures, before @@ -426,9 +441,82 @@ public partial class MainViewModel if (background != null) StampDefaultBackgroundAsset(background, scene.Name); } + SeedSceneThumbnails(); + SeedOnAirImage(); OnPropertyChanged(nameof(ShowSourcesEmptyHint)); } + // ─── Scene strip tile artwork (creator ruling 2026-10-10) ─── + // Each canonical scene's tile in the top strip shows its own image, stored as + // an Asset row like any image. The five files are the creator's CRT-styled + // renders (each scene's original background seen through a CRT screen). They + // are seeded into the layout's Asset table once per scene; the preview pane + // keeps rendering each scene's ORIGINAL background — never these tiles. + internal static readonly Dictionary SceneThumbnailSeedPaths = new(StringComparer.OrdinalIgnoreCase) + { + [SceneCatalog.Starting] = @"C:\Users\gramp\Downloads\Gemini_Generated_Image_uvq3i3uvq3i3uvq3.jpg", + [SceneCatalog.Live] = @"C:\Users\gramp\Downloads\Gemini_Generated_Image_y27sgqy27sgqy27s.jpg", + [SceneCatalog.Brb] = @"C:\Users\gramp\Downloads\Gemini_Generated_Image_lxcq0klxcq0klxcq.jpg", + [SceneCatalog.Chat] = @"C:\Users\gramp\Downloads\Gemini_Generated_Image_nqosdenqosdenqos.jpg", + [SceneCatalog.Ending] = @"C:\Users\gramp\Downloads\Gemini_Generated_Image_8je5n68je5n68je5.jpg", + }; + + private void SeedSceneThumbnails() + { + var imported = false; + foreach (var scene in Scenes) + { + if (!string.IsNullOrWhiteSpace(scene.ThumbnailAssetId)) continue; + if (!SceneThumbnailSeedPaths.TryGetValue(scene.Name, out var path)) continue; + if (!System.IO.File.Exists(path)) continue; + try + { + // UpsertAsset is SHA256-keyed, so a re-seed never duplicates rows + // and returns the same id. Only the Scene mapping is new state. + var assetId = AddAsset(System.IO.File.ReadAllBytes(path)); + if (assetId == null) continue; + scene.ThumbnailAssetId = assetId; + imported = true; + } + catch { /* the strip keeps the scene's background-art fallback */ } + } + if (!imported) return; + RefreshSnapshotsAsync(); + ScheduleSave(); + } + + // ─── On-air strip tile (creator ruling 2026-10-10) ─── + // A single shared image ("on-air background screen") labels whichever scene is + // CURRENTLY PREVIEWED in the top bar — it replaces that scene's tile and jumps + // around as the creator swaps scenes. Stored as an Asset row like any image, + // tracked by the OnAirImageAssetId setting so the Save pruner keeps it alive. + internal static string OnAirSeedPath = @"C:\Users\gramp\Downloads\Gemini_Generated_Image_4zajyl4zajyl4zaj.jpg"; + + private System.Windows.Media.Imaging.BitmapImage? OnAirImage; + + private void SeedOnAirImage() + { + var saved = _layoutStore.GetOnAirImageId(); + if (!string.IsNullOrWhiteSpace(saved)) + { + var cached = ImageCache.Get(saved); + if (cached != null) + { + OnAirImage = cached; + return; + } + } + + if (string.IsNullOrWhiteSpace(OnAirSeedPath) || !System.IO.File.Exists(OnAirSeedPath)) return; + try + { + var id = _layoutStore.EnsureOnAirImage(System.IO.File.ReadAllBytes(OnAirSeedPath)); + OnAirImage = ImageCache.Get(id); + RefreshSnapshotsAsync(); + } + catch { /* the strip keeps per-scene tiles when the on-air art can't import */ } + } + /// Points a background row at its scene's shipped default art when it /// carries none. A custom image (Browse…) always wins and is never overwritten. private void StampDefaultBackgroundAsset(Source background, string sceneName) diff --git a/ai.md b/ai.md index c30c89a..9a702de 100644 --- a/ai.md +++ b/ai.md @@ -299,7 +299,7 @@ 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. -**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). +**Thumbnails:** `Scene.Snapshot` property. Fill = per-scene tile artwork first — each canonical scene's `ThumbnailAssetId` (an Asset-table image, the creator's CRT-styled renders seeded at heal into the internal DB), else background art; **minis never render live captures**. The scene currently staged shows the shared **on-air image** (`OnAirImageAssetId` setting → Asset row, seed path `MainViewModel.OnAirSeedPath`) in its tile and it follows the scene swap — "the on-air screen jumps around the top bar" (user ruling 2026-10-10). The Live screen's tile only shows the green placeholder when it is staged and no on-air art is stamped. The strip labels are FontSize 20 (user ruling 2026-10-10); border = `#e94560` when staged, `#22c55e` when live. The preview's own live signal is the outer `PreviewGlow` red border — NOT the 6px green `#22c55e` rectangle that previously sat in the BackdropLayer (removed 2026-10-10). ### Global hotkeys (TASK 20 — step 1 shipped 2026-08-24) diff --git a/bugs.md b/bugs.md index 1ad29f8..6e17b2f 100644 --- a/bugs.md +++ b/bugs.md @@ -1,5 +1,14 @@ # Bugs +## Scene strip tile + on-air artwork assets deleted on the next Save (FIXED 2026-10-10) +The scene tiles and the shared on-air image are stored as Asset-table BLOBs (per-scene +`Scene.ThumbnailAssetId` + the `OnAirImageAssetId` Setting). `LayoutStore.Save()` runs an Asset +pruner that only keeps rows referenced by `Source`, the alert clip setting, and — after the fix — +the two new references: it `DELETE FROM Asset WHERE Id NOT IN (SELECT ThumbnailAssetId FROM Scene …, +SELECT Value FROM Settings WHERE Key = 'OnAirImageAssetId')`. Without that, the very first save after +seeding would have wiped the freshly-imported tile art, and every tile would have silently fallen +back to the scene's background until the next launch re-seeded it. + ## Scene strip / F-keys out of sequence (FIXED 2026-10-10) Report: "the ordering of the 5 screens and their corresponding F-key is out of sequence." The strip renders `Scenes ORDER BY SortOrder` but each tab's F-key label is name-derived, so the two can drift diff --git a/ytLive.Tests/SceneThumbnailTests.cs b/ytLive.Tests/SceneThumbnailTests.cs new file mode 100644 index 0000000..2105ca8 --- /dev/null +++ b/ytLive.Tests/SceneThumbnailTests.cs @@ -0,0 +1,358 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Windows; +using System.Windows.Media; +using System.Windows.Media.Imaging; +using Microsoft.Data.Sqlite; +using Xunit; +using ytLive.Helpers; +using ytLive.Models; +using ytLive.Services; +using ytLive.ViewModels; + +namespace ytLive.Tests; + +/// +/// Scene strip tile artwork (creator ruling 2026-10-10). Each canonical scene's +/// top-strip tile is its own image, stored as an Asset row and seeded from the +/// creator's CRT-styled renders. The preview pane keeps rendering each scene's +/// ORIGINAL background — the tiles never appear as preview backgrounds. +/// +[Collection("RealApp")] +public sealed class SceneThumbnailTests +{ + private readonly RealAppHost _app; + + public SceneThumbnailTests(RealAppHost app) => _app = app; + + [Fact] + public void Strip_Shows_The_Seeded_Tile_And_The_Preview_Keeps_The_Original_Background() + { + _app.Run(() => + { + EnvGuard? env = null; + try + { + env = ArrangedDb(out var file); + env.SeedPaths[SceneCatalog.Starting] = file.TilePath; + env.SeedPaths[SceneCatalog.Live] = MissingPath(); + env.Apply(); + + var vm = new MainViewModel(); + var starting = SceneByName(vm, SceneCatalog.Starting); + + Assert.False(string.IsNullOrWhiteSpace(starting.ThumbnailAssetId)); + + // The strip tile is the seeded file (identity + dims prove it). + var tile = ImageCache.Get(starting.ThumbnailAssetId); + Assert.NotNull(tile); + Assert.Same(tile, starting.Snapshot); + Assert.Equal(320, starting.Snapshot!.PixelWidth); + Assert.Equal(180, starting.Snapshot.PixelHeight); + + // The preview pane renders the scene's ORIGINAL background — never + // the tile artwork. + var originalBackground = ImageCache.Get(env.BackgroundIds[SceneCatalog.Starting]); + Assert.NotNull(originalBackground); + Assert.Same(originalBackground, vm.BackgroundImage); + Assert.Same(originalBackground, vm.ActiveBackgroundImage); + Assert.Same(originalBackground, MainViewModel.LoadBackgroundImage(starting)); + Assert.NotSame(tile, vm.BackgroundImage); + + // A scene without its own tile keeps its background art as the strip. + var ending = SceneByName(vm, SceneCatalog.Ending); + Assert.Null(ending.ThumbnailAssetId); + Assert.Same(ImageCache.Get(env.BackgroundIds[SceneCatalog.Ending]), ending.Snapshot); + + // The mapping and the tile asset survive a save + reload (the + // Save-time Asset prune must keep Scene.ThumbnailAssetId rows), and + // a second load re-seeds nothing new (UpsertAsset is SHA256-keyed). + vm.SaveLayoutNow(); + using (var reloaded = new LayoutStore(env.DbPath)) + { + var reloadedStarting = reloaded.Load().Single(s => s.Name == SceneCatalog.Starting); + Assert.Equal(starting.ThumbnailAssetId, reloadedStarting.ThumbnailAssetId); + Assert.NotNull(reloaded.GetAssetBytes(starting.ThumbnailAssetId)); + Assert.Equal(1, CountAssetRows(env.DbPath, starting.ThumbnailAssetId)); + } + + var secondVm = new MainViewModel(); + Assert.Equal(starting.ThumbnailAssetId, + SceneByName(secondVm, SceneCatalog.Starting).ThumbnailAssetId); + } + finally + { + env?.Restore(); + env?.Dispose(); + } + }); + } + + [Fact] + public void Live_Staged_Mini_Shows_Its_Tile_Not_The_Capture_Placeholder() + { + _app.Run(() => + { + EnvGuard? env = null; + try + { + env = ArrangedDb(out var file); + env.SeedPaths[SceneCatalog.Live] = file.TilePath; + env.Apply(); + + var vm = new MainViewModel(); + var live = SceneByName(vm, SceneCatalog.Live); + Assert.False(string.IsNullOrWhiteSpace(live.ThumbnailAssetId)); + + vm.StagedScene = live; + // Minis never render captures; a seeded Live tile shows instead of + // the green placeholder, while the preview still shows the background. + Assert.Same(ImageCache.Get(live.ThumbnailAssetId), live.Snapshot); + Assert.Same(ImageCache.Get(env.BackgroundIds[SceneCatalog.Live]), vm.BackgroundImage); + } + finally + { + env?.Restore(); + env?.Dispose(); + } + }); + } + + [Fact] + public void Staged_Scene_Tile_Shows_The_On_Air_Art_And_It_Follows_The_Scene_Swap() + { + _app.Run(() => + { + EnvGuard? env = null; + try + { + env = ArrangedDb(out var file); + MainViewModel.OnAirSeedPath = file.OnAirPath; + env.Apply(); + + var vm = new MainViewModel(); + var starting = SceneByName(vm, SceneCatalog.Starting); + var live = SceneByName(vm, SceneCatalog.Live); + + var onAir = ImageCache.Get(Services.LayoutStore.Instance!.GetOnAirImageId()!); + Assert.NotNull(onAir); + + // The staged scene's tile (Starting at launch) shows the on-air art. + Assert.Same(onAir, starting.Snapshot); + // Unstaged scenes keep their own art, never the on-air image. + Assert.NotSame(onAir, live.Snapshot); + + // Swapping scenes moves the on-air tile: staging Live swaps it across, + // and Starting's tile returns to its own background art (no thumbnail). + vm.StagedScene = live; + Assert.Same(onAir, live.Snapshot); + Assert.Same(ImageCache.Get(env.BackgroundIds[SceneCatalog.Starting]), starting.Snapshot); + + // The preview pane still renders the scene's ORIGINAL background — + // the on-air art never leaks into the preview. + Assert.Same(ImageCache.Get(env.BackgroundIds[SceneCatalog.Live]), vm.BackgroundImage); + + // Persistence: the on-air asset survives a save + reload (the Save + // pruner must keep the OnAirImageAssetId setting's row), and a fresh + // VM re-stamps it from the DB instead of re-importing. + vm.SaveLayoutNow(); + using (var reloaded = new LayoutStore(env.DbPath)) + { + var id = reloaded.GetOnAirImageId(); + Assert.NotNull(id); + Assert.NotNull(reloaded.GetAssetBytes(id)); + } + var secondVm = new MainViewModel(); + Assert.Same(onAir, SceneByName(secondVm, SceneCatalog.Starting).Snapshot); + } + finally + { + env?.Restore(); + env?.Dispose(); + } + }); + } + + [Fact] + public void No_Seed_File_Means_No_Thumbnail_And_Background_Art_Fallback() + { + _app.Run(() => + { + EnvGuard? env = null; + try + { + env = ArrangedDb(out _); + env.SeedPaths.Clear(); + env.Apply(); + + var vm = new MainViewModel(); + foreach (var scene in vm.Scenes) + { + Assert.Null(scene.ThumbnailAssetId); + Assert.Same(ImageCache.Get(env.BackgroundIds[scene.Name]), scene.Snapshot); + } + } + finally + { + env?.Restore(); + env?.Dispose(); + } + }); + } + + // ─── Fixture plumbing ─── + + private static Scene SceneByName(MainViewModel vm, string name) + => vm.Scenes.Single(s => s.Name == name); + + private static int CountAssetRows(string dbPath, string assetId) + { + using var connection = new SqliteConnection($"Data Source={dbPath}"); + connection.Open(); + using var cmd = connection.CreateCommand(); + cmd.CommandText = "SELECT COUNT(*) FROM Asset WHERE Id = $id;"; + cmd.Parameters.AddWithValue("$id", assetId); + return Convert.ToInt32(cmd.ExecuteScalar()); + } + + /// Builds a temp DB whose every scene has a REAL background asset row. + /// One throwaway VM creates the canonical scenes + heals their Backgrounds + /// (the pack:// default-art stamp silently fails under the test host, so the + /// contract is tested with an explicit background — the tile must never + /// replace it). Seed paths start all-missing; the caller picks which scenes + /// get a tile. + private static EnvGuard ArrangedDb(out Files files) + { + files = new Files(); + var dbPath = Path.Combine(Path.GetTempPath(), $"ytLive-scenethumb-{Guid.NewGuid():N}.db"); + using (new LayoutStore(dbPath)) { } + SqliteConnection.ClearAllPools(); + + var env = new EnvGuard(dbPath, files); + try + { + env.Apply(); + var builder = new MainViewModel(); + var backgroundIds = new Dictionary(StringComparer.OrdinalIgnoreCase); + var i = 0; + foreach (var scene in builder.Scenes) + { + var bg = scene.Elements.OfType().FirstOrDefault(s => s.IsBackground); + Assert.NotNull(bg); + var bytes = files.NewBackgroundBytes(i++); + var id = LayoutStore.Instance!.UpsertAsset(bytes, 320, 180); + Assert.NotNull(id); + bg!.AssetId = id; + backgroundIds[scene.Name] = id; + } + builder.SaveLayoutNow(); + env.BackgroundIds = backgroundIds; + return env; + } + catch + { + env.Restore(); + env.Dispose(); + throw; + } + } + + private sealed record EnvGuard : IDisposable + { + public string DbPath { get; } + public Files Files { get; } + public Dictionary BackgroundIds { get; set; } = new(StringComparer.OrdinalIgnoreCase); + public Dictionary SeedPaths { get; } + private readonly Dictionary _originalPaths; + private readonly string _originalOnAirPath; + + public EnvGuard(string dbPath, Files files) + { + DbPath = dbPath; + Files = files; + _originalPaths = new Dictionary(MainViewModel.SceneThumbnailSeedPaths, StringComparer.OrdinalIgnoreCase); + _originalOnAirPath = MainViewModel.OnAirSeedPath; + SeedPaths = new Dictionary(StringComparer.OrdinalIgnoreCase); + MainViewModel.LayoutPathOverride = dbPath; + MainViewModel.OnAirSeedPath = MissingPath(); + foreach (var path in _originalPaths) + SeedPaths[path.Key] = MissingPath(); + } + + /// Writes the tests' chosen SeedPaths into the live map; any + /// canonical scene not overridden stays pointing at a missing file. + public void Apply() + { + MainViewModel.SceneThumbnailSeedPaths.Clear(); + foreach (var (key, value) in SeedPaths) + MainViewModel.SceneThumbnailSeedPaths[key] = value; + } + + public void Restore() + { + MainViewModel.SceneThumbnailSeedPaths.Clear(); + foreach (var (key, value) in _originalPaths) + MainViewModel.SceneThumbnailSeedPaths[key] = value; + MainViewModel.OnAirSeedPath = _originalOnAirPath; + MainViewModel.LayoutPathOverride = null; + SqliteConnection.ClearAllPools(); + } + + public void Dispose() + { + Files.Dispose(); + try { File.Delete(DbPath); } catch { } + } + } + + private sealed class Files : IDisposable + { + public string TilePath { get; } + public string OnAirPath { get; } + private readonly List _paths = new(); + + public Files() + { + TilePath = NewImage(320, 180, Brushes.Teal, Brushes.Red); + OnAirPath = NewImage(320, 180, Brushes.SlateBlue, Brushes.White); + } + + /// Distinct per-scene solid-color "original background". + public byte[] NewBackgroundBytes(int index) + { + var colors = new[] { Brushes.ForestGreen, Brushes.OrangeRed, Brushes.DodgerBlue, Brushes.DarkOrchid, Brushes.SaddleBrown }; + var path = NewImage(320, 180, colors[index % colors.Length], Brushes.Black); + return File.ReadAllBytes(path); + } + + private string NewImage(int w, int h, Brush fill, Brush accent) + { + var path = Path.Combine(Path.GetTempPath(), $"ytLive-scenethumb-img-{Guid.NewGuid():N}.png"); + var dv = new DrawingVisual(); + using (var dc = dv.RenderOpen()) + { + dc.DrawRectangle(fill, null, new Rect(0, 0, w, h)); + dc.DrawRectangle(accent, null, new Rect(24, 24, 64, 64)); + } + var rtb = new RenderTargetBitmap(w, h, 96, 96, PixelFormats.Pbgra32); + rtb.Render(dv); + var encoder = new PngBitmapEncoder(); + encoder.Frames.Add(BitmapFrame.Create(rtb)); + using var fs = File.OpenWrite(path); + encoder.Save(fs); + _paths.Add(path); + return path; + } + + public void Dispose() + { + foreach (var path in _paths) + try { File.Delete(path); } catch { } + } + } + + private static string MissingPath() + => Path.Combine(Path.GetTempPath(), $"ytLive-no-such-{Guid.NewGuid():N}.png"); +} \ No newline at end of file