Scene strip tile artwork + on-air indicator (creator ruling 2026-10-10). Top-bar tiles now carry their own art: each canonical scene's tile is a per-scene CRT-styled image stored in the internal DB (Scene.ThumbnailAssetId - additive migration, seeded at heal from the creator's five Downloads JPGs via SceneThumbnailSeedPaths), and the preview pane keeps rendering the scene's ORIGINAL background - never the tiles. One shared on-air image (Gemini 4zajyl...jpg) replaces the staged scene's tile and follows the scene swap ('the on-air screen jumps around the top bar as the user swaps scenes'): stored as an Asset BLOB tracked by the OnAirImageAssetId Setting (EnsureOnAirImage, same contract as the alert clip) with MainViewModel.OnAirSeedPath override; RefreshSnapshotsAsync shows it for the staged scene, otherwise thumbnail-or-background art, and a staged Live with no on-air art still falls back to the green placeholder (minis never render live captures). Removed the 6px #22c55e rectangle in PreviewPane's backdrop ('green outline around the preview screen' that surfaced when a scene had no background) - the outer PreviewGlow red border is the real live signal. Doubled the scene-tile labels (FontSize 10 -> 20). Fixed a real bug this surfaced: Save()'s Asset pruner only kept Source/alert-clip rows, so the first save would have DELETED the seeded tile + on-air assets - the keep-set now includes Scene.ThumbnailAssetId and the OnAirImageAssetId Setting. New Settings/Load/Save migrations + SceneThumbnailTests (4 tests: seeded tile in strip, preview keeps original bg, Live-staged tile, no-seed fallback, on-air swap + persistence). 427 pass, 0 build warnings. (Unit: strip tiles + on-air)

This commit is contained in:
2026-10-10 13:27:26 -07:00
parent 06f88f4142
commit 8e1d6f44db
12 changed files with 576 additions and 61 deletions
-1
View File
@@ -69,7 +69,6 @@
<Image Source="{Binding BackgroundImage}" Stretch="UniformToFill"
Visibility="{Binding BackgroundVisible, Converter={StaticResource BoolToVis}}"/>
<Image Source="{Binding ActiveBackgroundImage}" Stretch="UniformToFill"/>
<Rectangle Stroke="#22c55e" StrokeThickness="6"/>
</Grid>
<Canvas x:Name="OverlayCanvas" Width="1920" Height="1080">
<ItemsControl ItemsSource="{Binding StagedScene.Elements}"
+1 -1
View File
@@ -39,7 +39,7 @@
Stretch="UniformToFill"
RenderOptions.BitmapScalingMode="LowQuality"/>
</Border>
<TextBlock Grid.Row="1" Foreground="#a0a0b0" FontSize="10"
<TextBlock Grid.Row="1" Foreground="#a0a0b0" FontSize="20"
HorizontalAlignment="Center"
Margin="0,2,0,0"
TextTrimming="CharacterEllipsis">
+50 -48
View File
@@ -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.
+8
View File
@@ -72,6 +72,14 @@ public class Scene : INotifyPropertyChanged
public bool IsChatScene { get; init; }
/// <summary>
/// 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).
/// </summary>
public string? ThumbnailAssetId { get; set; }
private BitmapSource? _snapshot;
/// <summary>
+2 -1
View File
@@ -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,
});
}
}
+22
View File
@@ -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<string>(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()
+8 -2
View File
@@ -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;
+22
View File
@@ -326,4 +326,26 @@ public partial class LayoutStore : IDisposable
/// <summary>The stored default clip's Asset id, or null when never stamped.</summary>
public string? GetAlertDefaultVideoId() => GetSetting(AlertDefaultVideoKey);
internal const string OnAirImageKey = "OnAirImageAssetId";
/// <summary>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. <c>UpsertAsset</c> is hash-keyed, so
/// re-stamping the same bytes returns the same id.</summary>
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;
}
/// <summary>The stored on-air image's Asset id, or null when never stamped.</summary>
public string? GetOnAirImageId() => GetSetting(OnAirImageKey);
}
+94 -6
View File
@@ -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)
// 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);
var img = stagedLive ? null : LoadBackgroundImage(scene);
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<string, string> 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 */ }
}
/// <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)
+1 -1
View File
@@ -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)
+9
View File
@@ -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
+358
View File
@@ -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;
/// <summary>
/// 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.
/// </summary>
[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());
}
/// <summary>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.</summary>
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<string, string>(StringComparer.OrdinalIgnoreCase);
var i = 0;
foreach (var scene in builder.Scenes)
{
var bg = scene.Elements.OfType<Source>().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<string, string> BackgroundIds { get; set; } = new(StringComparer.OrdinalIgnoreCase);
public Dictionary<string, string> SeedPaths { get; }
private readonly Dictionary<string, string> _originalPaths;
private readonly string _originalOnAirPath;
public EnvGuard(string dbPath, Files files)
{
DbPath = dbPath;
Files = files;
_originalPaths = new Dictionary<string, string>(MainViewModel.SceneThumbnailSeedPaths, StringComparer.OrdinalIgnoreCase);
_originalOnAirPath = MainViewModel.OnAirSeedPath;
SeedPaths = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
MainViewModel.LayoutPathOverride = dbPath;
MainViewModel.OnAirSeedPath = MissingPath();
foreach (var path in _originalPaths)
SeedPaths[path.Key] = MissingPath();
}
/// <summary>Writes the tests' chosen SeedPaths into the live map; any
/// canonical scene not overridden stays pointing at a missing file.</summary>
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<string> _paths = new();
public Files()
{
TilePath = NewImage(320, 180, Brushes.Teal, Brushes.Red);
OnAirPath = NewImage(320, 180, Brushes.SlateBlue, Brushes.White);
}
/// <summary>Distinct per-scene solid-color "original background".</summary>
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");
}