The creator's corrected Starting artwork (the in-app onboarding screen: sign in → stream details → go live) replaces yesterday's version. 2724x1536 resampled to the 1920x1080 master, q3 (432 KB). The previous Gemini render is archived beside the original art. Also fixes the two reasons the replacement would not have appeared on an existing profile: - **The first heal iterated `AllBackgrounds()`**, which returns only the LIVE scene's capture rows, so it cleared nothing and then saved the version anyway — permanently skipping the heal. It now walks every scene's background. Version bumped to 4 so profiles stamped 2 or 3 heal. - **The pack URI was unqualified**, so it resolved against the ENTRY assembly: LlamaCasty.exe in production, the test host under `dotnet test`, where the seed silently did nothing. It is now `/LlamaCasty;component/Assets/…`, which works in both. Test: `BackgroundDefaultArtTests` seeds a profile, plants an "old art" asset on the Starting background, marks the layout as an older art vintage, relaunches and asserts the shipped art is re-stamped. 45 background tests pass.
539 lines
23 KiB
C#
539 lines
23 KiB
C#
using System.Collections.ObjectModel;
|
|
using System.IO;
|
|
using System.Windows;
|
|
using System.Windows.Input;
|
|
using System.Windows.Media;
|
|
using System.Windows.Media.Imaging;
|
|
using ytLive.Helpers;
|
|
using ytLive.Models;
|
|
using ytLive.Services;
|
|
|
|
namespace ytLive.ViewModels;
|
|
|
|
public partial class MainViewModel
|
|
{
|
|
// Screen background: a permanent live capture (desktop/game) that every scene
|
|
// shows at the bottom layer. One shared capture session per key — the
|
|
// ScreenCaptureManager refcounts by key, mirroring CameraManager.
|
|
private readonly IFullScreenDetector _fullScreenDetector;
|
|
private readonly ScreenCaptureManager _screenCaptureManager;
|
|
private readonly ScreenCaptureSourceFactory _screenCaptureFactory;
|
|
private readonly IWindowEnumerator _windowEnumerator;
|
|
|
|
/// <summary>Test seam (mirrors CameraEnumeratorOverride): a real-Win32 test
|
|
/// swaps in a fake enumerator to drive the "window dies -> re-heal" flow.</summary>
|
|
internal static IWindowEnumerator? WindowEnumeratorOverride { get; set; }
|
|
|
|
/// <summary>Test seam for the launch focus decision: a fake detector simulates
|
|
/// a full-screen game owning the foreground without touching any real window,
|
|
/// so the <c>ShowActivated = !FullscreenGameRunningNow</c> rule (desktop-"lock"
|
|
/// fix 2026-10-10) is pinned without Win32 state.</summary>
|
|
internal static IFullScreenDetector? FullScreenDetectorOverride { get; set; }
|
|
|
|
private ImageSource? _activeBackgroundImage;
|
|
private ImageSource? _backgroundImage;
|
|
private bool _backgroundSizeMismatch;
|
|
private string _backgroundSizeMismatchText = "";
|
|
private HashSet<string> _liveCaptureKeys = new();
|
|
|
|
public ObservableCollection<DisplayInfo> Displays { get; } = new();
|
|
|
|
public ImageSource? ActiveBackgroundImage
|
|
{
|
|
get => _activeBackgroundImage;
|
|
private set => SetProperty(ref _activeBackgroundImage, value);
|
|
}
|
|
|
|
/// <summary>The active scene's live-capture background frame (rendered below the
|
|
/// static background). Set from the shared capture bitmap as it arrives.</summary>
|
|
public ImageSource? BackgroundImage
|
|
{
|
|
get => _backgroundImage;
|
|
private set
|
|
{
|
|
if (SetProperty(ref _backgroundImage, value))
|
|
{
|
|
OnPropertyChanged(nameof(ShowPreviewPlaceholder));
|
|
OnPropertyChanged(nameof(BackgroundVisible));
|
|
}
|
|
}
|
|
}
|
|
|
|
public bool BackgroundVisible => StagedScene?.Elements.OfType<Source>().FirstOrDefault(s => s.IsBackground)?.IsVisible == true;
|
|
|
|
public bool BackgroundSizeMismatch
|
|
{
|
|
get => _backgroundSizeMismatch;
|
|
private set => SetProperty(ref _backgroundSizeMismatch, value);
|
|
}
|
|
|
|
public string BackgroundSizeMismatchText
|
|
{
|
|
get => _backgroundSizeMismatchText;
|
|
private set => SetProperty(ref _backgroundSizeMismatchText, value);
|
|
}
|
|
|
|
public ICommand ChangeCaptureCommand { get; }
|
|
public ICommand RefreshCaptureCommand { get; }
|
|
public ICommand SetBackgroundDisplayCommand { get; }
|
|
public ICommand ToggleShowDesktopCommand { get; }
|
|
public ICommand BrowseBackgroundCommand { get; }
|
|
|
|
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)
|
|
: null;
|
|
OnPropertyChanged(nameof(ShowPreviewPlaceholder));
|
|
}
|
|
|
|
private void UpdateBackgroundImage()
|
|
{
|
|
var scene = StagedScene;
|
|
var background = scene?.Elements.OfType<Source>().FirstOrDefault(s => s.IsBackground);
|
|
BackgroundImage = background?.DisplaySource;
|
|
|
|
if (background != null && !background.UseDefaultBackground
|
|
&& !string.IsNullOrWhiteSpace(background.CustomBackgroundPath)
|
|
&& System.IO.File.Exists(background.CustomBackgroundPath))
|
|
{
|
|
try
|
|
{
|
|
var bmi = new System.Windows.Media.Imaging.BitmapImage();
|
|
bmi.BeginInit();
|
|
bmi.UriSource = new System.Uri(background.CustomBackgroundPath);
|
|
bmi.CacheOption = System.Windows.Media.Imaging.BitmapCacheOption.OnLoad;
|
|
bmi.EndInit();
|
|
bmi.Freeze();
|
|
|
|
double expectedRatio = 1920.0 / 1080.0;
|
|
double imageRatio = (double)bmi.PixelWidth / bmi.PixelHeight;
|
|
double ratioDiff = Math.Abs(imageRatio - expectedRatio) / expectedRatio;
|
|
|
|
if (ratioDiff > 0.05)
|
|
{
|
|
BackgroundSizeMismatch = true;
|
|
BackgroundSizeMismatchText =
|
|
$"Image is {bmi.PixelWidth}\u00d7{bmi.PixelHeight} \u2014 stream expects 1920\u00d71080 (16:9)";
|
|
}
|
|
else
|
|
{
|
|
BackgroundSizeMismatch = false;
|
|
BackgroundSizeMismatchText = "";
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
BackgroundSizeMismatch = false;
|
|
BackgroundSizeMismatchText = "";
|
|
}
|
|
}
|
|
else
|
|
{
|
|
BackgroundSizeMismatch = false;
|
|
BackgroundSizeMismatchText = "";
|
|
}
|
|
}
|
|
|
|
private void RefreshSnapshotsAsync()
|
|
{
|
|
foreach (var scene in Scenes)
|
|
{
|
|
// 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)
|
|
img = LoadBackgroundImage(scene);
|
|
if (img != null)
|
|
scene.Snapshot = img;
|
|
else if (scene.Snapshot == 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
|
|
// the window steals focus) and on focus regain (RefreshBackgroundAutoCapture).
|
|
// When a background has ShowDesktop=true, returns the primary monitor instead
|
|
// of null so the desktop is always visible.
|
|
private string? ResolveAutoCaptureKey()
|
|
{
|
|
var gameMonitor = _fullScreenDetector.GetForegroundFullScreenMonitorIndex();
|
|
if (gameMonitor != null)
|
|
return $"{MonitorKeyPrefix}{gameMonitor}";
|
|
|
|
if (AllBackgrounds().Any(b => b.ShowDesktop))
|
|
return $"{MonitorKeyPrefix}{_fullScreenDetector.PrimaryMonitorIndex()}";
|
|
return null;
|
|
}
|
|
|
|
/// <summary>True when a full-screen game/app owns the foreground right now.
|
|
/// MainWindow reads this at launch to decide whether to steal focus: opening
|
|
/// LlamaCasty while the creator is playing an exclusive-fullscreen game would
|
|
/// minimize that game out of fullscreen — the "desktop is locked and I can't
|
|
/// get back into the game" bug (2026-10-10) — so the window opens without
|
|
/// activating instead.</summary>
|
|
public bool FullscreenGameRunningNow
|
|
=> _fullScreenDetector.GetForegroundFullScreenMonitorIndex() != null;
|
|
|
|
// After a layout load / file open: make sure every background-enabled scene
|
|
// has a background, give any background without a persisted key the auto-detected
|
|
// one, then acquire one capture per unique background key. ScreenCaptureManager
|
|
// refcounts by key, so every scene pointing at the same monitor shares one
|
|
// session. Captures no longer referenced by any scene are released.
|
|
private void ReacquireScreenCaptures()
|
|
{
|
|
HealBackgrounds();
|
|
|
|
// Transient keys ("picker:" picks and "window:" pins) are session-scoped
|
|
// (see ScreenCaptureSourceFactory): a persisted HWND can be recycled to an
|
|
// unrelated window after restart, so a reload never resurrects them — the
|
|
// background falls back to auto-detection instead.
|
|
foreach (var transient in AllBackgrounds()
|
|
.Where(b => IsTransientCaptureKey(b.CaptureKey)))
|
|
transient.CaptureKey = null;
|
|
|
|
var auto = ResolveAutoCaptureKey();
|
|
if (auto == null)
|
|
{
|
|
foreach (var background in AllBackgrounds())
|
|
background.CaptureKey = null;
|
|
}
|
|
else
|
|
{
|
|
foreach (var background in AllBackgrounds())
|
|
if (string.IsNullOrWhiteSpace(background.CaptureKey))
|
|
background.CaptureKey = auto;
|
|
}
|
|
|
|
var keys = AllBackgrounds()
|
|
.Select(b => b.CaptureKey!)
|
|
.Where(k => !string.IsNullOrWhiteSpace(k))
|
|
.ToHashSet();
|
|
|
|
// A layout reload replaces the scenes; stop captures that are no longer
|
|
// referenced (mirrors ReacquireWebcam's device swap).
|
|
foreach (var stale in _liveCaptureKeys.Except(keys))
|
|
_ = _screenCaptureManager.ReleaseAllAsync(stale);
|
|
_liveCaptureKeys = keys;
|
|
|
|
foreach (var key in keys)
|
|
_ = _screenCaptureManager.AcquireAsync(key);
|
|
|
|
_fullScreenDetector.StartWatching();
|
|
}
|
|
|
|
// ScreenCaptureManager creates the shared WriteableBitmap on the UI thread;
|
|
// every background pointing at that key picks it up.
|
|
private void OnScreenPreviewBitmapChanged(string key, WriteableBitmap bitmap)
|
|
{
|
|
var activeBackground = StagedScene?.Elements.OfType<Source>().FirstOrDefault(s => s.IsBackground);
|
|
if (activeBackground?.CaptureKey == key)
|
|
BackgroundImage = bitmap;
|
|
|
|
foreach (var background in AllBackgrounds().Where(b => b.CaptureKey == key))
|
|
background.VideoImageSource = bitmap;
|
|
}
|
|
|
|
// EVENT_SYSTEM_FOREGROUND fires whenever the foreground window changes.
|
|
// When a full-screen game appears on a different monitor than the current
|
|
// background, auto-redesignate. When ShowDesktop is enabled and no fullscreen
|
|
// game is detected, fall back to the primary monitor (desktop view).
|
|
private void OnFullscreenMonitorChanged(int? monitor)
|
|
{
|
|
// A live window pin outranks the fullscreen-game detector: while the
|
|
// pinned window still enumerates, keep it as the backdrop (the creator's
|
|
// explicit choice beats auto-detection). Only when the window is gone
|
|
// does auto-fallback (game → desktop → static) reclaim the background.
|
|
var current = AllBackgrounds().FirstOrDefault();
|
|
if (current != null && IsAliveWindowPin(current.CaptureKey)) return;
|
|
|
|
if (monitor != null)
|
|
{
|
|
var newKey = $"{MonitorKeyPrefix}{monitor}";
|
|
if (current?.CaptureKey == newKey) return;
|
|
_ = RedesignateBackgroundAsync(newKey);
|
|
}
|
|
else if (AllBackgrounds().Any(b => b.ShowDesktop))
|
|
{
|
|
var desktopKey = $"{MonitorKeyPrefix}{_fullScreenDetector.PrimaryMonitorIndex()}";
|
|
if (current?.CaptureKey == desktopKey) return;
|
|
_ = RedesignateBackgroundAsync(desktopKey);
|
|
}
|
|
}
|
|
|
|
// Manual refresh: the "Refresh Capture" context menu item. Reads the
|
|
// current foreground directly (no caching needed — the hook handles
|
|
// automatic detection).
|
|
public void RefreshBackgroundAutoCapture()
|
|
{
|
|
var detected = _fullScreenDetector.GetForegroundFullScreenMonitorIndex();
|
|
if (detected == null) return;
|
|
_ = RedesignateBackgroundAsync($"{MonitorKeyPrefix}{detected}");
|
|
}
|
|
|
|
/// <summary>Whether the active background is in Show Desktop mode.
|
|
/// Bindable IsChecked property for the context menu toggle.</summary>
|
|
public bool BackgroundShowDesktop
|
|
{
|
|
get => StagedScene?.Elements.OfType<Source>().FirstOrDefault(s => s.IsBackground)?.ShowDesktop ?? false;
|
|
set
|
|
{
|
|
var background = StagedScene?.Elements.OfType<Source>().FirstOrDefault(s => s.IsBackground);
|
|
if (background == null || background.ShowDesktop == value) return;
|
|
background.ShowDesktop = value;
|
|
OnPropertyChanged(nameof(BackgroundShowDesktop));
|
|
ScheduleSave();
|
|
if (value)
|
|
{
|
|
_ = RedesignateBackgroundAsync($"{MonitorKeyPrefix}{_fullScreenDetector.PrimaryMonitorIndex()}");
|
|
}
|
|
else
|
|
{
|
|
var gameMonitor = _fullScreenDetector.GetForegroundFullScreenMonitorIndex();
|
|
if (gameMonitor != null)
|
|
_ = RedesignateBackgroundAsync($"{MonitorKeyPrefix}{gameMonitor}");
|
|
else
|
|
_ = ClearBackgroundCaptureAsync();
|
|
}
|
|
}
|
|
}
|
|
|
|
private void ToggleBackgroundShowDesktop() => BackgroundShowDesktop = !BackgroundShowDesktop;
|
|
|
|
private void BrowseBackground()
|
|
{
|
|
var scene = StagedScene;
|
|
if (scene == null) return;
|
|
var bg = scene.Elements.OfType<Source>().FirstOrDefault(s => s.IsBackground);
|
|
if (bg == null) return;
|
|
|
|
var dlg = new Microsoft.Win32.OpenFileDialog
|
|
{
|
|
Title = "Select background image",
|
|
Filter = "Image files (*.jpg;*.jpeg;*.png;*.bmp)|*.jpg;*.jpeg;*.png;*.bmp|All files (*.*)|*.*",
|
|
};
|
|
if (dlg.ShowDialog() != true) return;
|
|
|
|
bg.CustomBackgroundPath = dlg.FileName;
|
|
bg.UseDefaultBackground = false;
|
|
try
|
|
{
|
|
using var fs = new System.IO.FileStream(dlg.FileName, System.IO.FileMode.Open, System.IO.FileAccess.Read);
|
|
var bytes = new byte[fs.Length];
|
|
fs.Read(bytes, 0, bytes.Length);
|
|
var assetId = AddAsset(bytes);
|
|
if (assetId != null)
|
|
bg.AssetId = assetId;
|
|
}
|
|
catch { /* non-critical */ }
|
|
|
|
UpdateActiveBackground();
|
|
RefreshSnapshotsAsync();
|
|
ScheduleSave();
|
|
}
|
|
|
|
/// <summary>Release the background capture so the static placeholder image shows.</summary>
|
|
private async Task ClearBackgroundCaptureAsync()
|
|
{
|
|
var oldKeys = AllBackgrounds()
|
|
.Select(b => b.CaptureKey!)
|
|
.Where(k => !string.IsNullOrWhiteSpace(k))
|
|
.ToHashSet();
|
|
|
|
foreach (var background in AllBackgrounds())
|
|
{
|
|
background.CaptureKey = null;
|
|
background.VideoImageSource = null;
|
|
}
|
|
|
|
foreach (var key in oldKeys)
|
|
await _screenCaptureManager.ReleaseAllAsync(key);
|
|
|
|
_liveCaptureKeys.Clear();
|
|
UpdateBackgroundImage();
|
|
ScheduleSave();
|
|
}
|
|
|
|
private async Task RedesignateBackgroundAsync(string newKey)
|
|
{
|
|
var oldKeys = AllBackgrounds()
|
|
.Select(b => b.CaptureKey!)
|
|
.Where(k => !string.IsNullOrWhiteSpace(k))
|
|
.ToHashSet();
|
|
|
|
foreach (var background in AllBackgrounds())
|
|
background.CaptureKey = newKey;
|
|
|
|
foreach (var oldKey in oldKeys.Where(k => k != newKey))
|
|
await _screenCaptureManager.ReleaseAllAsync(oldKey);
|
|
if (!oldKeys.Contains(newKey))
|
|
await _screenCaptureManager.AcquireAsync(newKey);
|
|
_liveCaptureKeys = new HashSet<string> { newKey };
|
|
UpdateBackgroundImage();
|
|
ScheduleSave();
|
|
}
|
|
|
|
// "Change Capture…": the OS GraphicsCapturePicker designates the target.
|
|
// Picks are transient (see ScreenCaptureSourceFactory) — a reload falls
|
|
// back to auto-detection.
|
|
public async Task ChangeBackgroundCaptureAsync()
|
|
{
|
|
var key = await _screenCaptureFactory.PickAsync();
|
|
if (key == null) return;
|
|
await RedesignateBackgroundAsync(key);
|
|
}
|
|
|
|
// In-app display picker: point every background at a specific monitor.
|
|
private void SetBackgroundCapture(DisplayInfo? display)
|
|
{
|
|
if (display == null) return;
|
|
_ = RedesignateBackgroundAsync($"{MonitorKeyPrefix}{display.Index}");
|
|
}
|
|
|
|
// ─── 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. ───
|
|
|
|
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>
|
|
/// <summary>Bump when a bundled background image is replaced. Layouts stamped with an older version
|
|
/// re-seed their DEFAULT art on next load; a creator's own custom background is never touched.</summary>
|
|
private const int CurrentDefaultArtVersion = 4;
|
|
|
|
/// <summary>Re-stamp the shipped art for scenes still using the default. Without this, a replaced
|
|
/// <c>starting-background.jpg</c> reaches new installs only: the asset is hash-keyed into the layout
|
|
/// database on first seed, so an existing profile keeps showing the old image forever.</summary>
|
|
private void HealDefaultArtVersion()
|
|
{
|
|
if (_layoutStore.LoadDefaultArtVersion() >= CurrentDefaultArtVersion) return;
|
|
|
|
// Every scene's background, not AllBackgrounds(): that helper only returns the LIVE scene's
|
|
// capture-flavoured rows, which is why version 2 healed nothing (2026-11-06).
|
|
var cleared = 0;
|
|
foreach (var scene in Scenes)
|
|
foreach (var background in scene.Elements.OfType<Source>().Where(element => element.IsBackground))
|
|
{
|
|
if (!background.UseDefaultBackground && !string.IsNullOrWhiteSpace(background.CustomBackgroundPath))
|
|
continue; // the creator chose their own art
|
|
background.AssetId = null; // the seed below re-stamps the bundled art
|
|
cleared++;
|
|
}
|
|
|
|
AppLog.Write($"Default art version {CurrentDefaultArtVersion}: re-seeding {cleared} background(s)");
|
|
_layoutStore.SaveDefaultArtVersion(CurrentDefaultArtVersion);
|
|
}
|
|
|
|
private void HealBackgrounds()
|
|
{
|
|
NormalizeBackgrounds(Scenes);
|
|
HealDefaultArtVersion();
|
|
foreach (var scene in Scenes)
|
|
{
|
|
_sceneGraph.InvalidateBake(scene);
|
|
var background = scene.Elements.OfType<Source>().FirstOrDefault(s => s.IsBackground);
|
|
if (background != null)
|
|
StampDefaultBackgroundAsset(background, scene.Name);
|
|
}
|
|
SeedOnAirImage();
|
|
EnsureBrandLogos();
|
|
EnsureScoreboards();
|
|
OnPropertyChanged(nameof(ShowSourcesEmptyHint));
|
|
OnPropertyChanged(nameof(LayerCount));
|
|
}
|
|
|
|
// ─── 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);
|
|
|
|
// ─── 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 = string.Empty;
|
|
|
|
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)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(background.AssetId)) return;
|
|
if (!background.UseDefaultBackground && !string.IsNullOrWhiteSpace(background.CustomBackgroundPath)) return;
|
|
var asset = DefaultBackgroundAsset(sceneName);
|
|
if (asset == null) return;
|
|
|
|
try
|
|
{
|
|
// Assembly-qualified on purpose: the unqualified form resolves against the ENTRY assembly,
|
|
// which is LlamaCasty.exe in production but the test host under `dotnet test` — so the seed
|
|
// silently did nothing there (and the art test could not see the bundled image).
|
|
var uri = new Uri($"pack://application:,,,/LlamaCasty;component/Assets/{asset}", UriKind.Absolute);
|
|
using var stream = Application.GetResourceStream(uri)?.Stream;
|
|
if (stream == null) return;
|
|
using var ms = new MemoryStream();
|
|
stream.CopyTo(ms);
|
|
background.AssetId = AddAsset(ms.ToArray());
|
|
}
|
|
catch { /* non-critical — the app works without default art */ }
|
|
}
|
|
}
|