From 2c614a9e7ef7b62292c6cad013080ce05d199fc1 Mon Sep 17 00:00:00 2001 From: EmoSaru Date: Sun, 12 Apr 2026 21:42:46 -0700 Subject: [PATCH 1/5] Refactor image resolution to async background worker with priority queue Replace synchronous on-demand image resolution with a background worker thread that processes a priority-sorted work queue. Images are queued automatically during pack load via ImageReference.OnImageReferenceCreated callback, and UI bindings use path-through ResolvedImage property for automatic updates when images resolve asynchronously. Key changes: - ImageReferenceService: background Thread with priority queue (SortedDictionary + ConcurrentDictionary cache), ManualResetEventSlim for sleep/wake, priority boosting for UI-requested images - ImageReference: added ResolvedImage property and static OnImageReferenceCreated callback for auto-queuing during pack load - XAML bindings: migrated from IValueConverter to path-through binding (e.g. Icon.ResolvedImage) across 5 control files (15 bindings) - UI gets 1x1 transparent placeholder immediately, replaced dynamically when background resolution completes via Dispatcher.UIThread.Post Co-Authored-By: Claude Opus 4.6 --- EmoTracker.Data/Media/ImageReference.cs | 227 +++++++------- EmoTracker.Data/Tracker.cs | 4 - .../Converters/GamePackageConverters.cs | 2 +- .../Converters/ImageReferenceConverter.cs | 56 ++-- EmoTracker.UI/Media/ImageReferenceService.cs | 295 +++++++++++++++--- EmoTracker/App.axaml.cs | 1 + EmoTracker/ApplicationModel.cs | 10 +- EmoTracker/UI/CapturableItemControl.axaml | 4 +- EmoTracker/UI/LayoutControl.axaml | 8 +- EmoTracker/UI/LocationControl.axaml | 10 +- EmoTracker/UI/LocationMapControl.axaml | 6 +- EmoTracker/UI/TrackableItemControl.axaml | 2 +- 12 files changed, 429 insertions(+), 196 deletions(-) diff --git a/EmoTracker.Data/Media/ImageReference.cs b/EmoTracker.Data/Media/ImageReference.cs index 1c8b1df..dacbaec 100644 --- a/EmoTracker.Data/Media/ImageReference.cs +++ b/EmoTracker.Data/Media/ImageReference.cs @@ -1,112 +1,115 @@ -using EmoTracker.Core; -using System; -using System.Collections.Generic; - -namespace EmoTracker.Data.Media -{ - public abstract class ImageReference : ObservableObject - { - static bool sTracking; - static List sTrackedReferences; - - public static List LastCollectedReferences { get; private set; } - - public static void BeginTrackingCreatedReferences() - { - sTrackedReferences = new List(); - sTracking = true; - } - - public static List EndTrackingCreatedReferences() - { - sTracking = false; - LastCollectedReferences = sTrackedReferences; - var result = sTrackedReferences; - sTrackedReferences = null; - return result; - } - - public static ImageReference FromPackRelativePath(string path, string filter = null) - { - return FromPackRelativePath(Tracker.Instance.ActiveGamePackage, path, filter); - } - - public static ImageReference FromPackRelativePath(IGamePackage package, string path, string filter = null) - { - if (string.IsNullOrWhiteSpace(path)) - return null; - - path = path.Trim(); - path = path.TrimStart(',', '/', '\\'); - - if (package == null || !package.Exists(path)) - return null; - - if (!path.StartsWith("gamepackage://")) - { - path = "gamepackage://" + path; - } - - var result = new ConcreteImageReference() - { - URI = new Uri(path), - Filter = filter - }; - - if (sTracking) - sTrackedReferences?.Add(result); - - return result; - } - - public static ImageReference FromImageReference(ImageReference existingReference, string filter = null) - { - if (existingReference == null) - return null; - - if (string.IsNullOrWhiteSpace(filter)) - return existingReference; - - var result = new FilterImageReference() - { - Reference = existingReference, - Filter = filter - }; - - if (sTracking) - sTrackedReferences?.Add(result); - - return result; - } - - public static ImageReference FromExternalURI(Uri uri, string filter = null) - { - return new ConcreteImageReference() - { - URI = uri, - Filter = filter - }; - } - - public static ImageReference FromLayeredImageReferences(params ImageReference[] layers) - { - LayeredImageReference instance = new LayeredImageReference(); - - foreach (ImageReference layer in layers) - { - if (layer != null) - instance.Layers.Add(layer); - } - - if (instance.Layers.Count > 0) - { - if (sTracking) - sTrackedReferences?.Add(instance); - - return instance; - } - - return null; - } - } -} +using EmoTracker.Core; +using System; +using System.Collections.Generic; + +namespace EmoTracker.Data.Media +{ + public abstract class ImageReference : ObservableObject + { + /// + /// The resolved display-ready image for this reference. Set by the image + /// resolution service on the UI thread once background generation completes. + /// XAML bindings should bind to Icon.ResolvedImage (etc.) so that the + /// UI updates automatically when the image becomes available. + /// + object mResolvedImage; + public object ResolvedImage + { + get { return mResolvedImage; } + set { SetProperty(ref mResolvedImage, value); } + } + + /// + /// Optional callback invoked whenever a new ImageReference is created via + /// a factory method. Set by the image resolution service at startup so + /// that newly-created references are automatically queued for background + /// resolution. + /// + public static Action OnImageReferenceCreated { get; set; } + + static void NotifyCreated(ImageReference imageRef) + { + OnImageReferenceCreated?.Invoke(imageRef); + } + + public static ImageReference FromPackRelativePath(string path, string filter = null) + { + return FromPackRelativePath(Tracker.Instance.ActiveGamePackage, path, filter); + } + + public static ImageReference FromPackRelativePath(IGamePackage package, string path, string filter = null) + { + if (string.IsNullOrWhiteSpace(path)) + return null; + + path = path.Trim(); + path = path.TrimStart(',', '/', '\\'); + + if (package == null || !package.Exists(path)) + return null; + + if (!path.StartsWith("gamepackage://")) + { + path = "gamepackage://" + path; + } + + var result = new ConcreteImageReference() + { + URI = new Uri(path), + Filter = filter + }; + + NotifyCreated(result); + + return result; + } + + public static ImageReference FromImageReference(ImageReference existingReference, string filter = null) + { + if (existingReference == null) + return null; + + if (string.IsNullOrWhiteSpace(filter)) + return existingReference; + + var result = new FilterImageReference() + { + Reference = existingReference, + Filter = filter + }; + + NotifyCreated(result); + + return result; + } + + public static ImageReference FromExternalURI(Uri uri, string filter = null) + { + return new ConcreteImageReference() + { + URI = uri, + Filter = filter + }; + } + + public static ImageReference FromLayeredImageReferences(params ImageReference[] layers) + { + LayeredImageReference instance = new LayeredImageReference(); + + foreach (ImageReference layer in layers) + { + if (layer != null) + instance.Layers.Add(layer); + } + + if (instance.Layers.Count > 0) + { + NotifyCreated(instance); + + return instance; + } + + return null; + } + } +} diff --git a/EmoTracker.Data/Tracker.cs b/EmoTracker.Data/Tracker.cs index 7d80d74..64590d4 100644 --- a/EmoTracker.Data/Tracker.cs +++ b/EmoTracker.Data/Tracker.cs @@ -483,8 +483,6 @@ public void Reload() LoadPackageSettings(); - ImageReference.BeginTrackingCreatedReferences(); - ScriptManager.Instance.Load(mActiveGamePackage); // Legacy loads - should this be contingent on a flag in the manifest @@ -497,8 +495,6 @@ public void Reload() } finally { - ImageReference.EndTrackingCreatedReferences(); - AccessibilityRule.ClearCaches(); mbReloadInProgress = false; diff --git a/EmoTracker.UI/Converters/GamePackageConverters.cs b/EmoTracker.UI/Converters/GamePackageConverters.cs index 3890760..be53f88 100644 --- a/EmoTracker.UI/Converters/GamePackageConverters.cs +++ b/EmoTracker.UI/Converters/GamePackageConverters.cs @@ -38,7 +38,7 @@ public object Convert(object value, Type targetType, object parameter, CultureIn var game = PackageManager.Instance.FindGame(name); if (game != null) - return Media.ImageReferenceService.Instance.ResolveImageReference(game.Image); + return Media.ImageReferenceService.Instance.RequestImage(game.Image); return null; } diff --git a/EmoTracker.UI/Converters/ImageReferenceConverter.cs b/EmoTracker.UI/Converters/ImageReferenceConverter.cs index b099ec8..d49eaa5 100644 --- a/EmoTracker.UI/Converters/ImageReferenceConverter.cs +++ b/EmoTracker.UI/Converters/ImageReferenceConverter.cs @@ -1,23 +1,33 @@ -using EmoTracker.Core; -using EmoTracker.Data.Media; -using EmoTracker.UI.Media; -using System; -using System.Globalization; - -using Avalonia.Data.Converters; - -namespace EmoTracker.UI.Converters -{ - public class ImageReferenceConverter : Singleton, IValueConverter - { - public object Convert(object value, Type targetType, object parameter, CultureInfo culture) - { - return ImageReferenceService.Instance.ResolveImageReference(value as ImageReference); - } - - public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) - { - throw new NotImplementedException(); - } - } -} +using EmoTracker.Core; +using EmoTracker.Data.Media; +using EmoTracker.UI.Media; +using System; +using System.Globalization; + +using Avalonia.Data.Converters; + +namespace EmoTracker.UI.Converters +{ + public class ImageReferenceConverter : Singleton, IValueConverter + { + /// + /// Returns the cached image for the given , + /// or the placeholder if the image is still being resolved in the + /// background. Boosts the reference to immediate priority so it is + /// resolved as soon as possible. + /// + /// Note: This converter is used by bindings that have not yet been + /// migrated to the path-through pattern (e.g. {Binding Icon.ResolvedImage}). + /// Bindings using the path-through pattern do not need a converter. + /// + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + return ImageReferenceService.Instance.RequestImage(value as ImageReference); + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } + } +} diff --git a/EmoTracker.UI/Media/ImageReferenceService.cs b/EmoTracker.UI/Media/ImageReferenceService.cs index 3585e62..d71b4b8 100644 --- a/EmoTracker.UI/Media/ImageReferenceService.cs +++ b/EmoTracker.UI/Media/ImageReferenceService.cs @@ -6,25 +6,184 @@ using System.Collections.Generic; using System.Linq; using System.Threading; -using System.Threading.Tasks; using Avalonia.Media; +using Avalonia.Media.Imaging; +using Avalonia.Platform; +using Avalonia.Threading; namespace EmoTracker.UI.Media { + /// + /// Priority levels for image resolution work items. + /// Lower numeric value = higher priority. + /// + public enum ImagePriority + { + /// The UI is waiting to display this image right now. + Immediate = 0, + /// Standard pre-cache priority during pack load. + Normal = 100, + } + public class ImageReferenceService : ObservableSingleton { - ConcurrentDictionary mCache = new ConcurrentDictionary(); + // ── Cache ─────────────────────────────────────────────────────── + readonly ConcurrentDictionary mCache = new ConcurrentDictionary(); readonly object mResolutionLock = new object(); - CancellationTokenSource mPreCacheCts; + // ── Placeholder ───────────────────────────────────────────────── + static IImage sPlaceholder; + + /// + /// Returns a tiny transparent placeholder image used while the real + /// image is being resolved on the background thread. + /// + public static IImage Placeholder + { + get + { + if (sPlaceholder == null) + { + // 1×1 transparent PNG – lightweight and compatible with all Image controls + var bmp = new WriteableBitmap( + new Avalonia.PixelSize(1, 1), + new Avalonia.Vector(96, 96), + Avalonia.Platform.PixelFormat.Bgra8888, + AlphaFormat.Premul); + sPlaceholder = bmp; + } + return sPlaceholder; + } + } + + // ── Priority Queue ────────────────────────────────────────────── + readonly object mQueueLock = new object(); + readonly SortedDictionary<(int Priority, long Order), ImageReference> mQueue = new SortedDictionary<(int, long), ImageReference>(); + readonly Dictionary mQueueIndex = new Dictionary(); + long mInsertionOrder; + readonly ManualResetEventSlim mQueueSignal = new ManualResetEventSlim(false); + + // ── Worker Thread ─────────────────────────────────────────────── + Thread mWorkerThread; + volatile bool mShutdown; + + // ── Public API ────────────────────────────────────────────────── + + /// + /// Starts the background worker thread and wires up the + /// callback + /// so that newly-created references are automatically queued. + /// Call once at application startup. + /// + public void Start() + { + if (mWorkerThread != null) + return; + + mShutdown = false; + + ImageReference.OnImageReferenceCreated = (imageRef) => + { + QueueResolution(imageRef, ImagePriority.Normal); + }; + + mWorkerThread = new Thread(WorkerLoop) + { + Name = "ImageReferenceService Worker", + IsBackground = true, + Priority = ThreadPriority.BelowNormal + }; + mWorkerThread.Start(); + } + + /// + /// Signals the background worker to stop and waits for it to finish. + /// Call at application shutdown. + /// + public void Stop() + { + mShutdown = true; + mQueueSignal.Set(); + + ImageReference.OnImageReferenceCreated = null; + + // Don't block indefinitely – the thread is IsBackground so it + // will be torn down if the process exits. + mWorkerThread?.Join(2000); + mWorkerThread = null; + } + + /// + /// Clears all cached images and drains the work queue. + /// Called on pack unload so stale images are discarded. + /// public void ClearImageCache() { - mPreCacheCts?.Cancel(); - mPreCacheCts = null; + lock (mQueueLock) + { + mQueue.Clear(); + mQueueIndex.Clear(); + mQueueSignal.Reset(); + } mCache.Clear(); } + /// + /// Adds an to the background work queue + /// at the specified priority. If the reference is already queued at a + /// lower priority (higher numeric value), it is boosted. + /// Thread-safe; may be called from any thread. + /// + public void QueueResolution(ImageReference imageRef, ImagePriority priority) + { + if (imageRef == null) + return; + + // Already resolved – nothing to do. + if (mCache.ContainsKey(imageRef)) + return; + + int pri = (int)priority; + + lock (mQueueLock) + { + if (mQueueIndex.TryGetValue(imageRef, out var existing)) + { + if (pri >= existing.Priority) + return; // already at same or higher priority + + // Remove old entry and re-insert at higher priority + mQueue.Remove(existing); + mQueueIndex.Remove(imageRef); + } + + var key = (pri, Interlocked.Increment(ref mInsertionOrder)); + mQueue[key] = imageRef; + mQueueIndex[imageRef] = key; + mQueueSignal.Set(); + } + } + + /// + /// Returns the cached image for the given reference, or null + /// if it has not been resolved yet. + /// + public IImage GetCachedImage(ImageReference imageRef) + { + if (imageRef == null) + return null; + mCache.TryGetValue(imageRef, out IImage cached); + return cached; + } + + /// + /// Synchronously resolves an image reference. Used by composite + /// resolvers (Filter, Layered) that need resolved sub-images + /// during background resolution. Not intended for UI-thread callers – + /// those should read or call + /// instead. + /// public IImage ResolveImageReference(ImageReference imageRef) { if (imageRef == null) @@ -37,7 +196,7 @@ public IImage ResolveImageReference(ImageReference imageRef) // Slow path: acquire lock for resolution lock (mResolutionLock) { - // Double-check after acquiring lock (background may have resolved it) + // Double-check after acquiring lock if (mCache.TryGetValue(imageRef, out cachedSrc)) return cachedSrc; @@ -45,6 +204,26 @@ public IImage ResolveImageReference(ImageReference imageRef) } } + /// + /// Called by UI-thread code (converters, bindings) when an image is + /// needed for display. Returns the cached image if available, otherwise + /// boosts the reference to and + /// returns the placeholder. + /// + public IImage RequestImage(ImageReference imageRef) + { + if (imageRef == null) + return null; + + if (mCache.TryGetValue(imageRef, out IImage cached)) + return cached; + + QueueResolution(imageRef, ImagePriority.Immediate); + return Placeholder; + } + + // ── Internal Resolution ───────────────────────────────────────── + IImage ResolveAndCache(ImageReference imageRef) { foreach (ImageReferenceResolver entry in TypedObjectRegistry.SupportRegistry) @@ -61,49 +240,95 @@ IImage ResolveAndCache(ImageReference imageRef) return null; } - public Task PreCacheImagesAsync(List refs) - { - mPreCacheCts?.Cancel(); + // ── Background Worker ─────────────────────────────────────────── - var cts = new CancellationTokenSource(); - mPreCacheCts = cts; - var token = cts.Token; + void WorkerLoop() + { + while (!mShutdown) + { + // Wait for work or shutdown signal + mQueueSignal.Wait(); - // Sort: ConcreteImageReference first (base images), then Filter, then Layered - // This avoids redundant recursive resolution during pre-cache - var sorted = refs - .OrderBy(r => r is ConcreteImageReference ? 0 : r is FilterImageReference ? 1 : 2) - .ToList(); + if (mShutdown) + break; - return Task.Run(() => - { - foreach (var imageRef in sorted) + // Process items until the queue is drained + while (TryDequeueNext(out var imageRef)) { - if (token.IsCancellationRequested) + if (mShutdown) break; - // Skip if already cached (lock-free read) + // Skip if already resolved (may have been resolved by a + // recursive call from a Filter/Layered resolver). if (mCache.ContainsKey(imageRef)) + { + PostResolvedImage(imageRef, mCache[imageRef]); continue; + } - lock (mResolutionLock) + try { - // Double-check after lock - if (mCache.ContainsKey(imageRef)) - continue; - - try - { - ResolveAndCache(imageRef); - } - catch + IImage resolved; + lock (mResolutionLock) { - // Swallow individual failures during pre-cache; - // they will surface if the UI requests them later. + // Double-check under lock + if (mCache.TryGetValue(imageRef, out resolved)) + { + PostResolvedImage(imageRef, resolved); + continue; + } + + resolved = ResolveAndCache(imageRef); } + + PostResolvedImage(imageRef, resolved); + } + catch + { + // Individual resolution failures are silently ignored; + // the UI will continue showing the placeholder. } } - }, token); + } + } + + bool TryDequeueNext(out ImageReference imageRef) + { + lock (mQueueLock) + { + if (mQueue.Count == 0) + { + mQueueSignal.Reset(); + imageRef = null; + return false; + } + + // SortedDictionary enumerator yields items in key order + // (lowest priority value first, then lowest insertion order). + using (var enumerator = mQueue.GetEnumerator()) + { + enumerator.MoveNext(); + var key = enumerator.Current.Key; + imageRef = enumerator.Current.Value; + mQueue.Remove(key); + mQueueIndex.Remove(imageRef); + } + + return true; + } + } + + void PostResolvedImage(ImageReference imageRef, IImage resolved) + { + if (resolved == null) + return; + + // ResolvedImage must be set on the UI thread so that + // PropertyChanged fires there and Avalonia bindings update. + Dispatcher.UIThread.Post(() => + { + imageRef.ResolvedImage = resolved; + }); } } } diff --git a/EmoTracker/App.axaml.cs b/EmoTracker/App.axaml.cs index 6f770ab..c9f217c 100644 --- a/EmoTracker/App.axaml.cs +++ b/EmoTracker/App.axaml.cs @@ -62,6 +62,7 @@ public override void OnFrameworkInitializationCompleted() { try { + UI.Media.ImageReferenceService.Instance.Stop(); UpdateService.Instance.Dispose(); if (e.ApplicationExitCode == 0) diff --git a/EmoTracker/ApplicationModel.cs b/EmoTracker/ApplicationModel.cs index 56beae5..8d9ef89 100644 --- a/EmoTracker/ApplicationModel.cs +++ b/EmoTracker/ApplicationModel.cs @@ -6,7 +6,6 @@ using EmoTracker.Data.JSON; using EmoTracker.Data.Layout; using EmoTracker.Data.Locations; -using EmoTracker.Data.Media; using EmoTracker.Data.Packages; using EmoTracker.Data.Scripting; using EmoTracker.Extensions; @@ -175,6 +174,10 @@ private void OnHttpImageLoaded(object? sender, EventArgs e) public void Initialize() { + // Start the background image resolution service so that images + // created during pack load are queued and resolved concurrently. + ImageReferenceService.Instance.Start(); + // Load and start extensions Extensions.ExtensionManager.CreateInstance(); Extensions.ExtensionManager.Instance.Start(); @@ -699,11 +702,6 @@ private void Tracker_OnPackageLoadComplete(object sender, EventArgs e) OpenPackageDocumentationCommand.RaiseCanExecuteChanged(); - // Kick off background pre-caching of all image references collected during pack load - var collectedRefs = ImageReference.LastCollectedReferences; - if (collectedRefs != null && collectedRefs.Count > 0) - _ = ImageReferenceService.Instance.PreCacheImagesAsync(collectedRefs); - WindowService.Instance.FocusMainWindow(); } public void AcquireLayouts() diff --git a/EmoTracker/UI/CapturableItemControl.axaml b/EmoTracker/UI/CapturableItemControl.axaml index ccac496..6d3162a 100644 --- a/EmoTracker/UI/CapturableItemControl.axaml +++ b/EmoTracker/UI/CapturableItemControl.axaml @@ -34,7 +34,7 @@ - @@ -46,7 +46,7 @@ - diff --git a/EmoTracker/UI/LayoutControl.axaml b/EmoTracker/UI/LayoutControl.axaml index 975a26b..36b5654 100644 --- a/EmoTracker/UI/LayoutControl.axaml +++ b/EmoTracker/UI/LayoutControl.axaml @@ -305,7 +305,7 @@ - - + @@ -600,9 +600,9 @@ IsVisible="{Binding Style, Converter={x:Static converters:ObjectEqualsConverter.Instance}, ConverterParameter=Settings}" /> - - diff --git a/EmoTracker/UI/LocationControl.axaml b/EmoTracker/UI/LocationControl.axaml index fff7e0c..d8e4ca0 100644 --- a/EmoTracker/UI/LocationControl.axaml +++ b/EmoTracker/UI/LocationControl.axaml @@ -112,7 +112,7 @@ diff --git a/EmoTracker/UI/LocationMapControl.axaml b/EmoTracker/UI/LocationMapControl.axaml index 81ff31d..9bef16c 100644 --- a/EmoTracker/UI/LocationMapControl.axaml +++ b/EmoTracker/UI/LocationMapControl.axaml @@ -47,7 +47,7 @@ - @@ -70,7 +70,7 @@ + Source="{Binding Image.ResolvedImage}"/> @@ -151,7 +151,7 @@ StretchDirection="Both"> + Source="{Binding ResolvedImage}" /> diff --git a/EmoTracker/UI/TrackableItemControl.axaml b/EmoTracker/UI/TrackableItemControl.axaml index 3201e6c..ced788d 100644 --- a/EmoTracker/UI/TrackableItemControl.axaml +++ b/EmoTracker/UI/TrackableItemControl.axaml @@ -23,7 +23,7 @@ + Source="{Binding Icon.ResolvedImage}"> From 90db9ecd6f6cec39bf9fc2a3175030daa3897fbf Mon Sep 17 00:00:00 2001 From: EmoSaru Date: Sun, 12 Apr 2026 22:04:01 -0700 Subject: [PATCH 2/5] Add --no-async-images flag and image queue MCP diagnostics Add --no-async-images command-line flag that disables the background image worker and forces synchronous on-demand resolution (pre-refactor behavior) for A/B comparison testing of layout issues. - ApplicationSettings: parse --no-async-images flag - ImageReferenceService: SyncMode property gates worker thread startup; sync mode hooks OnImageReferenceCreated to resolve immediately; ResolveImageReference sets ResolvedImage in sync mode for path-through bindings; QueueCount/CacheCount properties exposed - MCP: get_image_queue_status tool reports syncMode, queueCount, cacheCount for runtime diagnostics Co-Authored-By: Claude Opus 4.6 --- EmoTracker.Data/ApplicationSettings.cs | 17 +++++++ EmoTracker.UI/Media/ImageReferenceService.cs | 46 ++++++++++++++++++- EmoTracker/ApplicationModel.cs | 5 +- .../McpServer/McpServerExtension.cs | 1 + .../McpServer/Tools/ImageCacheTools.cs | 29 ++++++++++++ 5 files changed, 95 insertions(+), 3 deletions(-) create mode 100644 EmoTracker/Extensions/McpServer/Tools/ImageCacheTools.cs diff --git a/EmoTracker.Data/ApplicationSettings.cs b/EmoTracker.Data/ApplicationSettings.cs index c5b1772..92aaf25 100644 --- a/EmoTracker.Data/ApplicationSettings.cs +++ b/EmoTracker.Data/ApplicationSettings.cs @@ -99,6 +99,7 @@ public bool FastToolTips string mLastActivePackageVariant; string mCommandLinePackage; string mCommandLinePackageVariant; + bool mNoAsyncImages; ObservableCollection mPackageRepositories = new ObservableCollection(); @@ -180,6 +181,17 @@ public string CommandLinePackageVariant set { SetProperty(ref mCommandLinePackageVariant, value); } } + /// + /// When true, disables async background image pre-caching and forces + /// synchronous image resolution on the UI thread (the pre-refactor + /// behavior). Set via the --no-async-images command-line flag. + /// + public bool NoAsyncImages + { + get { return mNoAsyncImages; } + set { SetProperty(ref mNoAsyncImages, value); } + } + public string TwitchChannelName { get { return mTwitchChannelName; } @@ -317,6 +329,11 @@ private void LoadSettings() } + if (String.Equals(cargs[n], "--no-async-images")) + { + NoAsyncImages = true; + } + } } catch diff --git a/EmoTracker.UI/Media/ImageReferenceService.cs b/EmoTracker.UI/Media/ImageReferenceService.cs index d71b4b8..a11d654 100644 --- a/EmoTracker.UI/Media/ImageReferenceService.cs +++ b/EmoTracker.UI/Media/ImageReferenceService.cs @@ -68,6 +68,13 @@ public static IImage Placeholder Thread mWorkerThread; volatile bool mShutdown; + /// + /// When true, resolves synchronously on the + /// calling thread (the pre-refactor behavior). The background worker + /// thread is not started. Set before calling . + /// + public bool SyncMode { get; set; } + // ── Public API ────────────────────────────────────────────────── /// @@ -83,6 +90,18 @@ public void Start() mShutdown = false; + if (SyncMode) + { + // In sync mode, resolve images immediately on creation so + // path-through bindings ({Binding Icon.ResolvedImage}) see + // the resolved image right away. + ImageReference.OnImageReferenceCreated = (imageRef) => + { + ResolveImageReference(imageRef); + }; + return; + } + ImageReference.OnImageReferenceCreated = (imageRef) => { QueueResolution(imageRef, ImagePriority.Normal); @@ -194,14 +213,23 @@ public IImage ResolveImageReference(ImageReference imageRef) return cachedSrc; // Slow path: acquire lock for resolution + IImage result; lock (mResolutionLock) { // Double-check after acquiring lock if (mCache.TryGetValue(imageRef, out cachedSrc)) return cachedSrc; - return ResolveAndCache(imageRef); + result = ResolveAndCache(imageRef); } + + // In sync mode, set ResolvedImage directly so path-through + // bindings see the image immediately. This is safe because + // sync-mode callers are on the UI thread. + if (result != null && SyncMode) + imageRef.ResolvedImage = result; + + return result; } /// @@ -218,10 +246,26 @@ public IImage RequestImage(ImageReference imageRef) if (mCache.TryGetValue(imageRef, out IImage cached)) return cached; + if (SyncMode) + return ResolveImageReference(imageRef); + QueueResolution(imageRef, ImagePriority.Immediate); return Placeholder; } + /// + /// Returns the number of items currently in the background work queue. + /// + public int QueueCount + { + get { lock (mQueueLock) { return mQueue.Count; } } + } + + /// + /// Returns the number of resolved images in the cache. + /// + public int CacheCount => mCache.Count; + // ── Internal Resolution ───────────────────────────────────────── IImage ResolveAndCache(ImageReference imageRef) diff --git a/EmoTracker/ApplicationModel.cs b/EmoTracker/ApplicationModel.cs index 8d9ef89..660c04f 100644 --- a/EmoTracker/ApplicationModel.cs +++ b/EmoTracker/ApplicationModel.cs @@ -174,8 +174,9 @@ private void OnHttpImageLoaded(object? sender, EventArgs e) public void Initialize() { - // Start the background image resolution service so that images - // created during pack load are queued and resolved concurrently. + // Start the image resolution service. When --no-async-images is + // set, resolution falls back to synchronous on-demand behaviour. + ImageReferenceService.Instance.SyncMode = Data.ApplicationSettings.Instance.NoAsyncImages; ImageReferenceService.Instance.Start(); // Load and start extensions diff --git a/EmoTracker/Extensions/McpServer/McpServerExtension.cs b/EmoTracker/Extensions/McpServer/McpServerExtension.cs index 30418db..5f480e3 100644 --- a/EmoTracker/Extensions/McpServer/McpServerExtension.cs +++ b/EmoTracker/Extensions/McpServer/McpServerExtension.cs @@ -116,6 +116,7 @@ private async Task StartServerAsync() .WithTools() .WithTools() .WithTools() + .WithTools() .WithHttpTransport(); mApp = builder.Build(); diff --git a/EmoTracker/Extensions/McpServer/Tools/ImageCacheTools.cs b/EmoTracker/Extensions/McpServer/Tools/ImageCacheTools.cs new file mode 100644 index 0000000..664e0f2 --- /dev/null +++ b/EmoTracker/Extensions/McpServer/Tools/ImageCacheTools.cs @@ -0,0 +1,29 @@ +using Avalonia.Threading; +using EmoTracker.UI.Media; +using ModelContextProtocol.Server; +using System.ComponentModel; +using System.Text.Json; +using System.Threading.Tasks; + +namespace EmoTracker.Extensions.McpServer.Tools +{ + [McpServerToolType] + public class ImageCacheTools + { + [McpServerTool(Name = "get_image_queue_status")] + [Description("Get the current status of the async image resolution queue, including queue depth, cache size, and whether sync mode is active")] + public static async Task GetImageQueueStatus() + { + return await Dispatcher.UIThread.InvokeAsync(() => + { + var service = ImageReferenceService.Instance; + return JsonSerializer.Serialize(new + { + syncMode = service.SyncMode, + queueCount = service.QueueCount, + cacheCount = service.CacheCount + }); + }); + } + } +} From 87b4d9454060f0615ce570d7a2d853382384bdbf Mon Sep 17 00:00:00 2001 From: EmoSaru Date: Sun, 12 Apr 2026 22:14:59 -0700 Subject: [PATCH 3/5] Fix layout errors with sized placeholders and add source image cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sized placeholders: Read image dimensions from PNG/BMP/JPEG headers at ImageReference creation time and store as SourceWidth/SourceHeight. The image service creates correctly-sized transparent placeholder bitmaps (cached by dimension) instead of the universal 1×1 placeholder, so Avalonia's layout system measures controls at the right size before real images resolve. FilterImageReference inherits dimensions from its source; LayeredImageReference inherits from its first layer. Source image cache: ConcreteImageReferenceResolver now caches decoded base images (before filter application) in a WeakReference dictionary keyed by pack-relative path. Multiple ConcreteImageReferences pointing to the same source file with different filters share the decoded base image, avoiding redundant zip extraction and decoding. Cache is cleared on pack unload. Layout invalidation: When the background worker drains its queue, it posts a layout invalidation to the main window at Background priority so any controls that were measured with placeholder dimensions get a chance to re-measure with final image sizes. Co-Authored-By: Claude Opus 4.6 --- EmoTracker.Data/Media/ImageReference.cs | 205 ++++++++++++++++++ EmoTracker.UI/Media/ImageReferenceService.cs | 82 +++++-- .../ConcreteImageReferenceResolver.cs | 148 +++++++++---- 3 files changed, 373 insertions(+), 62 deletions(-) diff --git a/EmoTracker.Data/Media/ImageReference.cs b/EmoTracker.Data/Media/ImageReference.cs index dacbaec..d342924 100644 --- a/EmoTracker.Data/Media/ImageReference.cs +++ b/EmoTracker.Data/Media/ImageReference.cs @@ -1,6 +1,7 @@ using EmoTracker.Core; using System; using System.Collections.Generic; +using System.IO; namespace EmoTracker.Data.Media { @@ -19,6 +20,19 @@ public object ResolvedImage set { SetProperty(ref mResolvedImage, value); } } + /// + /// Source image width in pixels, read from the image header at creation time. + /// Used to create correctly-sized placeholder images so Avalonia's layout + /// system can measure controls before the real image is resolved. + /// Zero if dimensions could not be determined. + /// + public int SourceWidth { get; set; } + + /// + /// Source image height in pixels. See . + /// + public int SourceHeight { get; set; } + /// /// Optional callback invoked whenever a new ImageReference is created via /// a factory method. Set by the image resolution service at startup so @@ -32,6 +46,187 @@ static void NotifyCreated(ImageReference imageRef) OnImageReferenceCreated?.Invoke(imageRef); } + /// + /// Reads the width and height from a PNG or BMP image stream header + /// without fully decoding the image. Returns (0, 0) if the format + /// is not recognised or the stream is too short. + /// + internal static (int width, int height) ReadImageDimensions(Stream stream) + { + if (stream == null || !stream.CanRead) + return (0, 0); + + try + { + byte[] header = new byte[26]; + int bytesRead = 0; + while (bytesRead < header.Length) + { + int n = stream.Read(header, bytesRead, header.Length - bytesRead); + if (n == 0) break; + bytesRead += n; + } + + // PNG: signature(8) + IHDR length(4) + "IHDR"(4) + width(4) + height(4) + if (bytesRead >= 24 && + header[0] == 137 && header[1] == 80 && header[2] == 78 && header[3] == 71) + { + int w = (header[16] << 24) | (header[17] << 16) | (header[18] << 8) | header[19]; + int h = (header[20] << 24) | (header[21] << 16) | (header[22] << 8) | header[23]; + return (w, h); + } + + // BMP: "BM" + filesize(4) + reserved(4) + offset(4) + headersize(4) + width(4) + height(4) + if (bytesRead >= 26 && header[0] == (byte)'B' && header[1] == (byte)'M') + { + int w = header[18] | (header[19] << 8) | (header[20] << 16) | (header[21] << 24); + int h = header[22] | (header[23] << 8) | (header[24] << 16) | (header[25] << 24); + if (h < 0) h = -h; // top-down BMP uses negative height + return (w, h); + } + + // JPEG: SOI marker (0xFF 0xD8), then scan for SOF0/SOF2 frame marker + if (bytesRead >= 2 && header[0] == 0xFF && header[1] == 0xD8) + { + return ReadJpegDimensions(stream, header, bytesRead); + } + } + catch + { + // Swallow – dimensions are best-effort + } + + return (0, 0); + } + + /// + /// Scans JPEG markers to find a Start-Of-Frame (SOF) segment and reads + /// the image dimensions from it. The stream position is after the initial + /// header bytes that were already read into . + /// + static (int width, int height) ReadJpegDimensions(Stream stream, byte[] header, int bytesRead) + { + // We've consumed the first 'bytesRead' bytes into header[]. + // Continue reading the marker stream from the current position. + // JPEG markers are 0xFF followed by a marker type byte. + // SOF markers: 0xC0 (baseline), 0xC1 (extended), 0xC2 (progressive). + // SOF payload: length(2) + precision(1) + height(2) + width(2) + + // First, process any remaining bytes in the header buffer starting + // after the 2-byte SOI marker. + int pos = 2; + byte[] buf = new byte[2]; + + while (true) + { + // Read marker: 0xFF + type + int b1, b2; + + if (pos + 1 < bytesRead) + { + b1 = header[pos]; + b2 = header[pos + 1]; + pos += 2; + } + else + { + b1 = stream.ReadByte(); + b2 = stream.ReadByte(); + } + + if (b1 < 0 || b2 < 0) + return (0, 0); // unexpected end of stream + + if (b1 != 0xFF) + return (0, 0); // not a valid marker + + // Skip padding 0xFF bytes + while (b2 == 0xFF) + { + b2 = stream.ReadByte(); + if (b2 < 0) return (0, 0); + } + + // SOF0 (0xC0), SOF1 (0xC1), SOF2 (0xC2) contain dimensions + if (b2 >= 0xC0 && b2 <= 0xC2) + { + // Read: length(2) + precision(1) + height(2) + width(2) + byte[] sof = new byte[7]; + int sofRead = 0; + while (sofRead < 7) + { + int n = stream.Read(sof, sofRead, 7 - sofRead); + if (n == 0) return (0, 0); + sofRead += n; + } + + int height = (sof[3] << 8) | sof[4]; + int width = (sof[5] << 8) | sof[6]; + return (width, height); + } + + // Not a SOF marker – skip this segment + // Read segment length (2 bytes, big-endian, includes the length bytes) + int len1 = stream.ReadByte(); + int len2 = stream.ReadByte(); + if (len1 < 0 || len2 < 0) return (0, 0); + + int segLen = (len1 << 8) | len2; + if (segLen < 2) return (0, 0); + + // Skip the rest of the segment + int toSkip = segLen - 2; + if (stream.CanSeek) + { + stream.Position += toSkip; + } + else + { + byte[] skipBuf = new byte[Math.Min(toSkip, 4096)]; + while (toSkip > 0) + { + int n = stream.Read(skipBuf, 0, Math.Min(toSkip, skipBuf.Length)); + if (n == 0) return (0, 0); + toSkip -= n; + } + } + + // Safety: don't scan forever + if (stream.CanSeek && stream.Position > 65536) + return (0, 0); + } + } + + /// + /// Reads image dimensions from the package for the given path and stores + /// them on the reference. The stream is opened, header is read, and + /// the stream is disposed immediately. + /// + static void PopulateDimensions(ImageReference result, IGamePackage package, string rawPath) + { + try + { + // rawPath is the gamepackage:// URI – extract the actual file path + string filePath = rawPath; + if (filePath.StartsWith("gamepackage://")) + filePath = filePath.Substring("gamepackage://".Length); + + using (Stream s = package.Open(filePath)) + { + if (s != null) + { + var (w, h) = ReadImageDimensions(s); + result.SourceWidth = w; + result.SourceHeight = h; + } + } + } + catch + { + // Dimensions are best-effort; leave as 0×0 + } + } + public static ImageReference FromPackRelativePath(string path, string filter = null) { return FromPackRelativePath(Tracker.Instance.ActiveGamePackage, path, filter); @@ -59,6 +254,7 @@ public static ImageReference FromPackRelativePath(IGamePackage package, string p Filter = filter }; + PopulateDimensions(result, package, path); NotifyCreated(result); return result; @@ -78,6 +274,10 @@ public static ImageReference FromImageReference(ImageReference existingReference Filter = filter }; + // Filters don't change dimensions – inherit from the source + result.SourceWidth = existingReference.SourceWidth; + result.SourceHeight = existingReference.SourceHeight; + NotifyCreated(result); return result; @@ -104,6 +304,11 @@ public static ImageReference FromLayeredImageReferences(params ImageReference[] if (instance.Layers.Count > 0) { + // Layered images composite at the first layer's dimensions + var firstLayer = instance.Layers[0]; + instance.SourceWidth = firstLayer.SourceWidth; + instance.SourceHeight = firstLayer.SourceHeight; + NotifyCreated(instance); return instance; diff --git a/EmoTracker.UI/Media/ImageReferenceService.cs b/EmoTracker.UI/Media/ImageReferenceService.cs index a11d654..47f7cf7 100644 --- a/EmoTracker.UI/Media/ImageReferenceService.cs +++ b/EmoTracker.UI/Media/ImageReferenceService.cs @@ -32,31 +32,51 @@ public class ImageReferenceService : ObservableSingleton readonly ConcurrentDictionary mCache = new ConcurrentDictionary(); readonly object mResolutionLock = new object(); - // ── Placeholder ───────────────────────────────────────────────── - static IImage sPlaceholder; + // ── Sized Placeholders ────────────────────────────────────────── + // Cache of transparent placeholder bitmaps keyed by (width, height). + // Shared across all references with the same source dimensions so + // we don't create thousands of identical bitmaps. + static readonly Dictionary<(int w, int h), IImage> sPlaceholderCache = new Dictionary<(int, int), IImage>(); + static readonly object sPlaceholderLock = new object(); /// - /// Returns a tiny transparent placeholder image used while the real - /// image is being resolved on the background thread. + /// Returns a transparent placeholder image of the specified size. + /// Reuses cached instances for identical dimensions. + /// Falls back to 1×1 if width or height is zero. /// - public static IImage Placeholder + public static IImage GetPlaceholder(int width, int height) { - get + if (width <= 0 || height <= 0) + width = height = 1; + + lock (sPlaceholderLock) { - if (sPlaceholder == null) - { - // 1×1 transparent PNG – lightweight and compatible with all Image controls - var bmp = new WriteableBitmap( - new Avalonia.PixelSize(1, 1), - new Avalonia.Vector(96, 96), - Avalonia.Platform.PixelFormat.Bgra8888, - AlphaFormat.Premul); - sPlaceholder = bmp; - } - return sPlaceholder; + var key = (width, height); + if (sPlaceholderCache.TryGetValue(key, out IImage existing)) + return existing; + + var bmp = new WriteableBitmap( + new Avalonia.PixelSize(width, height), + new Avalonia.Vector(96, 96), + Avalonia.Platform.PixelFormat.Bgra8888, + AlphaFormat.Premul); + sPlaceholderCache[key] = bmp; + return bmp; } } + /// + /// Returns a correctly-sized transparent placeholder for the given + /// image reference, using its + /// and to determine size. + /// + public static IImage GetPlaceholder(ImageReference imageRef) + { + if (imageRef == null) + return GetPlaceholder(1, 1); + return GetPlaceholder(imageRef.SourceWidth, imageRef.SourceHeight); + } + // ── Priority Queue ────────────────────────────────────────────── readonly object mQueueLock = new object(); readonly SortedDictionary<(int Priority, long Order), ImageReference> mQueue = new SortedDictionary<(int, long), ImageReference>(); @@ -104,6 +124,15 @@ public void Start() ImageReference.OnImageReferenceCreated = (imageRef) => { + // Set a correctly-sized placeholder as ResolvedImage immediately + // so Avalonia's layout system measures controls at the right size + // before the real image is resolved on the background thread. + if (imageRef.ResolvedImage == null && + imageRef.SourceWidth > 0 && imageRef.SourceHeight > 0) + { + imageRef.ResolvedImage = GetPlaceholder(imageRef); + } + QueueResolution(imageRef, ImagePriority.Normal); }; @@ -146,6 +175,9 @@ public void ClearImageCache() mQueueSignal.Reset(); } mCache.Clear(); + + // Clear the source image cache used by ConcreteImageReferenceResolver + ConcreteImageReferenceResolver.ClearSourceCache(); } /// @@ -236,7 +268,7 @@ public IImage ResolveImageReference(ImageReference imageRef) /// Called by UI-thread code (converters, bindings) when an image is /// needed for display. Returns the cached image if available, otherwise /// boosts the reference to and - /// returns the placeholder. + /// returns a correctly-sized placeholder. /// public IImage RequestImage(ImageReference imageRef) { @@ -250,7 +282,7 @@ public IImage RequestImage(ImageReference imageRef) return ResolveImageReference(imageRef); QueueResolution(imageRef, ImagePriority.Immediate); - return Placeholder; + return GetPlaceholder(imageRef); } /// @@ -333,6 +365,18 @@ void WorkerLoop() // the UI will continue showing the placeholder. } } + + // All Immediate-priority items have been resolved. Force + // the UI to re-measure layouts so controls that were sized + // for placeholders adopt the final image dimensions. + Dispatcher.UIThread.Post(() => + { + if (Avalonia.Application.Current?.ApplicationLifetime + is Avalonia.Controls.ApplicationLifetimes.IClassicDesktopStyleApplicationLifetime desktop) + { + desktop.MainWindow?.InvalidateMeasure(); + } + }, DispatcherPriority.Background); } } diff --git a/EmoTracker.UI/Media/Resolvers/ConcreteImageReferenceResolver.cs b/EmoTracker.UI/Media/Resolvers/ConcreteImageReferenceResolver.cs index e691b6a..9311306 100644 --- a/EmoTracker.UI/Media/Resolvers/ConcreteImageReferenceResolver.cs +++ b/EmoTracker.UI/Media/Resolvers/ConcreteImageReferenceResolver.cs @@ -1,43 +1,105 @@ -using EmoTracker.Data; -using EmoTracker.Data.Media; -using System; -using System.IO; - -using Avalonia.Media; - -namespace EmoTracker.UI.Media.Resolvers -{ - public class ConcreteImageReferenceResolver : ImageReferenceResolver - { - public override bool CanResolveReference(ImageReference imageRef) - { - return imageRef as ConcreteImageReference != null; - } - - public override IImage ResolveReference(ImageReference imageRef) - { - ConcreteImageReference concreteRef = imageRef as ConcreteImageReference; - if (concreteRef == null) - return null; - - if (concreteRef.URI == null) - return null; - - if (concreteRef.URI.Scheme.Equals("gamepackage", StringComparison.OrdinalIgnoreCase)) - { - if (Tracker.Instance.ActiveGamePackage == null) - return null; - - using (Stream s = Tracker.Instance.ActiveGamePackage.Open(string.Format("{0}{1}", Uri.UnescapeDataString(concreteRef.URI.Host), Uri.UnescapeDataString(concreteRef.URI.AbsolutePath)))) - { - if (s == null) - return null; - - return Utility.IconUtility.ApplyFilterSpecToImage(Tracker.Instance.ActiveGamePackage, Utility.IconUtility.GetImage(s), concreteRef.Filter); - } - } - - return Utility.IconUtility.GetImageRaw(concreteRef.URI); - } - } -} +using EmoTracker.Data; +using EmoTracker.Data.Media; +using System; +using System.Collections.Generic; +using System.IO; + +using Avalonia.Media; + +namespace EmoTracker.UI.Media.Resolvers +{ + public class ConcreteImageReferenceResolver : ImageReferenceResolver + { + /// + /// Weak-reference cache of decoded base images keyed by the + /// pack-relative file path (the gamepackage:// URI path component). + /// Multiple instances that point + /// to the same source image but with different filters will share the + /// decoded base image as long as at least one reference is alive. + /// + static readonly Dictionary> sSourceCache + = new Dictionary>(StringComparer.OrdinalIgnoreCase); + + /// + /// Clears the source image cache. Called by + /// on pack unload. + /// + public static void ClearSourceCache() + { + lock (sSourceCache) + { + sSourceCache.Clear(); + } + } + + public override bool CanResolveReference(ImageReference imageRef) + { + return imageRef as ConcreteImageReference != null; + } + + public override IImage ResolveReference(ImageReference imageRef) + { + ConcreteImageReference concreteRef = imageRef as ConcreteImageReference; + if (concreteRef == null) + return null; + + if (concreteRef.URI == null) + return null; + + if (concreteRef.URI.Scheme.Equals("gamepackage", StringComparison.OrdinalIgnoreCase)) + { + if (Tracker.Instance.ActiveGamePackage == null) + return null; + + string filePath = string.Format("{0}{1}", + Uri.UnescapeDataString(concreteRef.URI.Host), + Uri.UnescapeDataString(concreteRef.URI.AbsolutePath)); + + // Try to reuse a previously decoded base image for the same path + IImage baseImage = GetCachedSource(filePath); + if (baseImage == null) + { + using (Stream s = Tracker.Instance.ActiveGamePackage.Open(filePath)) + { + if (s == null) + return null; + + baseImage = Utility.IconUtility.GetImage(s); + } + + if (baseImage != null) + PutCachedSource(filePath, baseImage); + } + + return Utility.IconUtility.ApplyFilterSpecToImage( + Tracker.Instance.ActiveGamePackage, baseImage, concreteRef.Filter); + } + + return Utility.IconUtility.GetImageRaw(concreteRef.URI); + } + + static IImage GetCachedSource(string filePath) + { + lock (sSourceCache) + { + if (sSourceCache.TryGetValue(filePath, out var weakRef) && + weakRef.TryGetTarget(out IImage image)) + { + return image; + } + + // Entry expired or not present – clean up stale entry + sSourceCache.Remove(filePath); + return null; + } + } + + static void PutCachedSource(string filePath, IImage image) + { + lock (sSourceCache) + { + sSourceCache[filePath] = new WeakReference(image); + } + } + } +} From f371437b2d246ce6636f91114516f029c70b041a Mon Sep 17 00:00:00 2001 From: EmoSaru Date: Sun, 12 Apr 2026 22:22:10 -0700 Subject: [PATCH 4/5] Suppress binding errors when image reference properties are null Add FallbackValue={x:Null} to all multi-segment .ResolvedImage bindings (e.g. Icon.ResolvedImage, Image.ResolvedImage). When the intermediate property (Icon, Image, Thumbnail, etc.) is null on some data items, Avalonia logs a binding error because it can't traverse the path. FallbackValue silently returns null instead, matching the previous converter-based behavior. Co-Authored-By: Claude Opus 4.6 --- EmoTracker/UI/CapturableItemControl.axaml | 4 ++-- EmoTracker/UI/LayoutControl.axaml | 8 ++++---- EmoTracker/UI/LocationControl.axaml | 10 +++++----- EmoTracker/UI/LocationMapControl.axaml | 2 +- EmoTracker/UI/TrackableItemControl.axaml | 2 +- 5 files changed, 13 insertions(+), 13 deletions(-) diff --git a/EmoTracker/UI/CapturableItemControl.axaml b/EmoTracker/UI/CapturableItemControl.axaml index 6d3162a..23c0d2b 100644 --- a/EmoTracker/UI/CapturableItemControl.axaml +++ b/EmoTracker/UI/CapturableItemControl.axaml @@ -34,7 +34,7 @@ - @@ -46,7 +46,7 @@ - diff --git a/EmoTracker/UI/LayoutControl.axaml b/EmoTracker/UI/LayoutControl.axaml index 36b5654..f4ba294 100644 --- a/EmoTracker/UI/LayoutControl.axaml +++ b/EmoTracker/UI/LayoutControl.axaml @@ -305,7 +305,7 @@ - - + @@ -600,9 +600,9 @@ IsVisible="{Binding Style, Converter={x:Static converters:ObjectEqualsConverter.Instance}, ConverterParameter=Settings}" /> - - diff --git a/EmoTracker/UI/LocationControl.axaml b/EmoTracker/UI/LocationControl.axaml index d8e4ca0..623196f 100644 --- a/EmoTracker/UI/LocationControl.axaml +++ b/EmoTracker/UI/LocationControl.axaml @@ -112,7 +112,7 @@ diff --git a/EmoTracker/UI/LocationMapControl.axaml b/EmoTracker/UI/LocationMapControl.axaml index 9bef16c..6f9c7a1 100644 --- a/EmoTracker/UI/LocationMapControl.axaml +++ b/EmoTracker/UI/LocationMapControl.axaml @@ -70,7 +70,7 @@ + Source="{Binding Image.ResolvedImage, FallbackValue={x:Null}}"/> diff --git a/EmoTracker/UI/TrackableItemControl.axaml b/EmoTracker/UI/TrackableItemControl.axaml index ced788d..ec8f246 100644 --- a/EmoTracker/UI/TrackableItemControl.axaml +++ b/EmoTracker/UI/TrackableItemControl.axaml @@ -23,7 +23,7 @@ + Source="{Binding Icon.ResolvedImage, FallbackValue={x:Null}}"> From d33459c46097949a4341acbd68324afec5665fa5 Mon Sep 17 00:00:00 2001 From: EmoSaru Date: Mon, 13 Apr 2026 00:04:26 -0700 Subject: [PATCH 5/5] Fix image pipeline: SKBitmap filters, thread safety, SuspendRefresh ref-counting, and missing images MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Image pipeline overhaul: - Replace per-pixel GetPixel/SetPixel filter loops with Skia color matrix operations (grayscale, dim, brightness) for ~10-50x speedup - Eliminate PNG encode/decode round-trips between filter steps by keeping images as SKBitmap through the entire chain - Add unsafe pixel buffer access for color-key and alpha mask computation - Fix ApplyColorMatrix: clear destination bitmap and use SKBlendMode.Src to avoid corrupting semi-transparent pixels via SrcOver on uninitialized data - Fix BT601 grayscale weights: original divided by 8 (>>3), not 6 - Add strong-reference source image cache in ConcreteImageReferenceResolver Thread safety and missing images: - Add lock around ZipPackageSource.Open/Files for concurrent archive access - Convert sAlphaMasks/sPngCache to ConcurrentDictionary for background worker writes - Fix duplicate ImageReference instances: add instance tracking (mPendingInstances) so PostResolvedImage fans out ResolvedImage to ALL objects sharing an equality key - Add FromExternalURI NotifyCreated call so HTTP images enter the resolution pipeline - Fix InputMaskingImage: default to click-through when alpha mask unavailable SuspendRefresh ref-counting: - Replace boolean SuspendRefresh with int ref-count (Push/Pop pattern) - SuspendRefreshScope increments on open, decrements on close; refresh fires at zero - Add underflow detection with error logging and debug assert - Convert all 6 callsites from direct toggle to scope/Push/Pop - Fix click handler UI spike: LuaItem.OnLeftClick previously set SuspendRefresh=false inside outer LeftClickCommand scope, releasing suspend before transaction callbacks fired (~97 full RefreshAccessibility walks per click → 1 batched refresh) Package Manager game images: - Bind game banner to Game.Image.ResolvedImage instead of one-shot converter - Add Game property to PackageGroup for live binding support - Bridge HTTP image downloads with ResolvedImage in OnHttpImageLoaded Co-Authored-By: Claude Opus 4.6 --- EmoTracker.Data/ItemDatabase.cs | 25 +- EmoTracker.Data/LocationDatabase.cs | 54 +- EmoTracker.Data/Media/ImageReference.cs | 6 +- .../Packages/Sources/ZipPackageSource.cs | 31 +- EmoTracker.Data/ScriptManager.cs | 2 - EmoTracker.Data/Scripting/LuaItem.cs | 14 +- EmoTracker.UI/Controls/InputMaskingImage.cs | 6 +- EmoTracker.UI/EmoTracker.UI.csproj | 1 + EmoTracker.UI/Media/ImageReferenceService.cs | 96 +- .../ConcreteImageReferenceResolver.cs | 58 +- .../Resolvers/FilterImageReferenceResolver.cs | 63 +- .../LayeredImageReferenceResolver.cs | 92 +- EmoTracker.UI/Media/Utility/IconUtility.cs | 1256 ++++++++++------- EmoTracker/ApplicationModel.cs | 27 +- EmoTracker/UI/PackageManagerWindow.axaml | 3 +- EmoTracker/UI/TrackableItemControl.axaml.cs | 16 +- 16 files changed, 1068 insertions(+), 682 deletions(-) diff --git a/EmoTracker.Data/ItemDatabase.cs b/EmoTracker.Data/ItemDatabase.cs index 536e8d0..a81bed8 100644 --- a/EmoTracker.Data/ItemDatabase.cs +++ b/EmoTracker.Data/ItemDatabase.cs @@ -64,29 +64,26 @@ public bool IncrementalLoad(string path, IGamePackage package, bool bLegacy = fa { try { - LocationDatabase.Instance.SuspendRefresh = true; - - using (StreamReader reader = new StreamReader(package.Open(path))) + using (new LocationDatabase.SuspendRefreshScope()) { - JArray items = (JArray)JToken.ReadFrom(new JsonTextReader(reader)); - foreach (JObject item in items) + using (StreamReader reader = new StreamReader(package.Open(path))) { - ITrackableItem instance = ItemBase.CreateItem(item, package); - if (instance != null) - mItems.Add(instance); + JArray items = (JArray)JToken.ReadFrom(new JsonTextReader(reader)); + foreach (JObject item in items) + { + ITrackableItem instance = ItemBase.CreateItem(item, package); + if (instance != null) + mItems.Add(instance); + } } - } - bSuccess = true; + bSuccess = true; + } } catch (Exception e) { ScriptManager.Instance.OutputException(e); } - finally - { - LocationDatabase.Instance.SuspendRefresh = false; - } } return bSuccess; diff --git a/EmoTracker.Data/LocationDatabase.cs b/EmoTracker.Data/LocationDatabase.cs index 4458e55..b747a73 100644 --- a/EmoTracker.Data/LocationDatabase.cs +++ b/EmoTracker.Data/LocationDatabase.cs @@ -16,17 +16,14 @@ public class LocationDatabase : ObservableSingleton, ICodeProv { public class SuspendRefreshScope : IDisposable { - bool mbSuspend; - public SuspendRefreshScope() { - mbSuspend = LocationDatabase.Instance.SuspendRefresh; - LocationDatabase.Instance.SuspendRefresh = true; + LocationDatabase.Instance.PushSuspendRefresh(); } public virtual void Dispose() { - LocationDatabase.Instance.SuspendRefresh = mbSuspend; + LocationDatabase.Instance.PopSuspendRefresh(); } } @@ -39,13 +36,37 @@ public virtual void Dispose() public bool SuspendRefresh { - get { return mbSuspendRefresh; } + get { return mSuspendRefreshCount > 0; } set { - if (SetProperty(ref mbSuspendRefresh, value) && !mbSuspendRefresh) - { - RefeshAccessibility(bPendingOnly: true); - } + // Legacy compatibility: direct assignment is discouraged. + // Prefer SuspendRefreshScope for reentrant-safe scoping. + if (value) + PushSuspendRefresh(); + else + PopSuspendRefresh(); + } + } + + internal void PushSuspendRefresh() + { + ++mSuspendRefreshCount; + } + + internal void PopSuspendRefresh() + { + if (mSuspendRefreshCount <= 0) + { + ScriptManager.Instance.OutputError("PopSuspendRefresh called with no matching Push — possible over-close bug"); + System.Diagnostics.Debug.Fail("PopSuspendRefresh: underflow — more Pops than Pushes"); + return; + } + + --mSuspendRefreshCount; + + if (mSuspendRefreshCount == 0) + { + RefeshAccessibility(bPendingOnly: true); } } @@ -141,7 +162,7 @@ internal bool IncrementalLoad(string path, IGamePackage package, bool bLegacy = { try { - mbSuspendRefresh = true; + PushSuspendRefresh(); using (Stream s = package.Open(path)) { @@ -170,7 +191,7 @@ internal bool IncrementalLoad(string path, IGamePackage package, bool bLegacy = } finally { - mbSuspendRefresh = false; + PopSuspendRefresh(); } RefeshAccessibility(); @@ -279,13 +300,13 @@ public void UnpinLocation(Location location) mPinnedLocations.Remove(location); } - bool mbSuspendRefresh = false; + int mSuspendRefreshCount = 0; bool mbInRefresh = false; uint mPendingRefreshCount = 0; internal void RefeshAccessibility(bool bPendingOnly = false) { - if (!mbSuspendRefresh) + if (mSuspendRefreshCount == 0) { if (!bPendingOnly) ++mPendingRefreshCount; @@ -595,10 +616,9 @@ internal void Save(JObject root) internal bool Load(JObject root) { + PushSuspendRefresh(); try { - SuspendRefresh = true; - JObject locationDatabaseData = root.GetValue("location_database"); if (locationDatabaseData == null) return true; @@ -665,7 +685,7 @@ internal bool Load(JObject root) } finally { - SuspendRefresh = false; + PopSuspendRefresh(); } } diff --git a/EmoTracker.Data/Media/ImageReference.cs b/EmoTracker.Data/Media/ImageReference.cs index d342924..c54f863 100644 --- a/EmoTracker.Data/Media/ImageReference.cs +++ b/EmoTracker.Data/Media/ImageReference.cs @@ -285,11 +285,15 @@ public static ImageReference FromImageReference(ImageReference existingReference public static ImageReference FromExternalURI(Uri uri, string filter = null) { - return new ConcreteImageReference() + var result = new ConcreteImageReference() { URI = uri, Filter = filter }; + + NotifyCreated(result); + + return result; } public static ImageReference FromLayeredImageReferences(params ImageReference[] layers) diff --git a/EmoTracker.Data/Packages/Sources/ZipPackageSource.cs b/EmoTracker.Data/Packages/Sources/ZipPackageSource.cs index 83a62f5..f25ed88 100644 --- a/EmoTracker.Data/Packages/Sources/ZipPackageSource.cs +++ b/EmoTracker.Data/Packages/Sources/ZipPackageSource.cs @@ -7,6 +7,7 @@ namespace EmoTracker.Data.Packages public class ZipPackageSource : IGamePackageSource { private ZipArchive mArchive; + private readonly object mArchiveLock = new object(); private string mPath; public string ArchivePath @@ -22,10 +23,13 @@ public IEnumerable Files if (mArchive != null) { - foreach (ZipArchiveEntry entry in mArchive.Entries) + lock (mArchiveLock) { - if (!string.IsNullOrWhiteSpace(entry.FullName) && !entry.FullName.EndsWith("/")) - files.Add(entry.FullName); + foreach (ZipArchiveEntry entry in mArchive.Entries) + { + if (!string.IsNullOrWhiteSpace(entry.FullName) && !entry.FullName.EndsWith("/")) + files.Add(entry.FullName); + } } files.Sort(FileListSort); @@ -61,15 +65,22 @@ public Stream Open(string path) { if (mArchive != null && !string.IsNullOrWhiteSpace(path)) { - ZipArchiveEntry entry = mArchive.GetEntry(path); - if (entry != null) + // ZipArchive is NOT thread-safe for concurrent reads. The async + // image pre-cache worker resolves images on a background thread + // while the UI thread may also open streams (e.g. PopulateDimensions + // during pack load). Serialize all access to prevent corruption. + lock (mArchiveLock) { - using (Stream src = entry.Open()) + ZipArchiveEntry entry = mArchive.GetEntry(path); + if (entry != null) { - MemoryStream stream = new MemoryStream(); - src.CopyTo(stream); - stream.Seek(0, SeekOrigin.Begin); - return stream; + using (Stream src = entry.Open()) + { + MemoryStream stream = new MemoryStream(); + src.CopyTo(stream); + stream.Seek(0, SeekOrigin.Begin); + return stream; + } } } } diff --git a/EmoTracker.Data/ScriptManager.cs b/EmoTracker.Data/ScriptManager.cs index b62d9c0..a9d3f62 100644 --- a/EmoTracker.Data/ScriptManager.cs +++ b/EmoTracker.Data/ScriptManager.cs @@ -681,8 +681,6 @@ public IMemorySegment AddMemoryWatch(string name, ulong startAddress, ulong leng { using (new LocationDatabase.SuspendRefreshScope()) { - LocationDatabase.Instance.SuspendRefresh = true; - if (callback != null) { object[] results = callback.Call(segment); diff --git a/EmoTracker.Data/Scripting/LuaItem.cs b/EmoTracker.Data/Scripting/LuaItem.cs index 97171c0..4240dbe 100644 --- a/EmoTracker.Data/Scripting/LuaItem.cs +++ b/EmoTracker.Data/Scripting/LuaItem.cs @@ -183,15 +183,10 @@ public override void OnLeftClick() { if (OnLeftClickFunc != null) { - LocationDatabase.Instance.SuspendRefresh = true; - try + using (new LocationDatabase.SuspendRefreshScope()) { OnLeftClickFunc.Call(this); } - finally - { - LocationDatabase.Instance.SuspendRefresh = false; - } } } catch (Exception e) @@ -206,15 +201,10 @@ public override void OnRightClick() { if (OnRightClickFunc != null) { - LocationDatabase.Instance.SuspendRefresh = true; - try + using (new LocationDatabase.SuspendRefreshScope()) { OnRightClickFunc.Call(this); } - finally - { - LocationDatabase.Instance.SuspendRefresh = false; - } } } catch (Exception e) diff --git a/EmoTracker.UI/Controls/InputMaskingImage.cs b/EmoTracker.UI/Controls/InputMaskingImage.cs index 9256ade..f281414 100644 --- a/EmoTracker.UI/Controls/InputMaskingImage.cs +++ b/EmoTracker.UI/Controls/InputMaskingImage.cs @@ -21,7 +21,11 @@ private bool HitTestAlphaMask(Avalonia.Point point) if (Source == null) return false; var maskEntry = IconUtility.GetAlphaMask(Source); - if (maskEntry == null) return true; + // No mask → treat as fully transparent (click-through). + // This is critical for the async image pipeline: placeholder images + // don't have alpha masks, and returning true here would make them + // block ALL input until the real image loads. + if (maskEntry == null) return false; var (mask, maskW, maskH) = maskEntry.Value; diff --git a/EmoTracker.UI/EmoTracker.UI.csproj b/EmoTracker.UI/EmoTracker.UI.csproj index 985f5ff..5a2a4fa 100644 --- a/EmoTracker.UI/EmoTracker.UI.csproj +++ b/EmoTracker.UI/EmoTracker.UI.csproj @@ -7,6 +7,7 @@ EmoTracker.UI net8.0 false + true diff --git a/EmoTracker.UI/Media/ImageReferenceService.cs b/EmoTracker.UI/Media/ImageReferenceService.cs index 47f7cf7..5b1aa8a 100644 --- a/EmoTracker.UI/Media/ImageReferenceService.cs +++ b/EmoTracker.UI/Media/ImageReferenceService.cs @@ -32,6 +32,16 @@ public class ImageReferenceService : ObservableSingleton readonly ConcurrentDictionary mCache = new ConcurrentDictionary(); readonly object mResolutionLock = new object(); + // ── Instance Tracking ─────────────────────────────────────────── + // Multiple distinct ImageReference objects can share the same equality + // key (e.g. 10 items that all use "items/small_key.png" each create + // their own ConcreteImageReference). Only one object per equality key + // enters the work queue, but ALL objects need their ResolvedImage set + // when resolution completes. This dictionary tracks every unresolved + // instance keyed by equality so PostResolvedImage can fan out to all. + readonly Dictionary> mPendingInstances = new Dictionary>(); + readonly object mInstancesLock = new object(); + // ── Sized Placeholders ────────────────────────────────────────── // Cache of transparent placeholder bitmaps keyed by (width, height). // Shared across all references with the same source dimensions so @@ -124,6 +134,22 @@ public void Start() ImageReference.OnImageReferenceCreated = (imageRef) => { + // Multiple ImageReference objects can share the same equality key + // (same URI + filter) while being distinct object instances (e.g. + // 10 items that use the same small-key icon each create their own + // ConcreteImageReference). If the image is already cached (first + // instance was resolved), set the result immediately. + IImage cached = GetCachedImage(imageRef); + if (cached != null) + { + imageRef.ResolvedImage = cached; + return; + } + + // Register this instance so that when resolution completes for + // any equal key, ALL instances get their ResolvedImage updated. + RegisterPendingInstance(imageRef); + // Set a correctly-sized placeholder as ResolvedImage immediately // so Avalonia's layout system measures controls at the right size // before the real image is resolved on the background thread. @@ -176,6 +202,11 @@ public void ClearImageCache() } mCache.Clear(); + lock (mInstancesLock) + { + mPendingInstances.Clear(); + } + // Clear the source image cache used by ConcreteImageReferenceResolver ConcreteImageReferenceResolver.ClearSourceCache(); } @@ -240,9 +271,16 @@ public IImage ResolveImageReference(ImageReference imageRef) if (imageRef == null) return null; - // Fast path: lock-free cache read + // Fast path: lock-free cache read. + // In sync mode, also set ResolvedImage on the requesting object so + // duplicate ImageReference instances (same URI+filter, different object) + // get the resolved image immediately. if (mCache.TryGetValue(imageRef, out IImage cachedSrc)) + { + if (SyncMode && imageRef.ResolvedImage as IImage != cachedSrc) + imageRef.ResolvedImage = cachedSrc; return cachedSrc; + } // Slow path: acquire lock for resolution IImage result; @@ -250,7 +288,11 @@ public IImage ResolveImageReference(ImageReference imageRef) { // Double-check after acquiring lock if (mCache.TryGetValue(imageRef, out cachedSrc)) + { + if (SyncMode && imageRef.ResolvedImage as IImage != cachedSrc) + imageRef.ResolvedImage = cachedSrc; return cachedSrc; + } result = ResolveAndCache(imageRef); } @@ -298,6 +340,41 @@ public int QueueCount /// public int CacheCount => mCache.Count; + // ── Instance Tracking ─────────────────────────────────────────── + + /// + /// Registers an ImageReference object so that when an equal key is + /// resolved, this specific object's ResolvedImage gets set too. + /// + void RegisterPendingInstance(ImageReference imageRef) + { + lock (mInstancesLock) + { + if (!mPendingInstances.TryGetValue(imageRef, out var list)) + { + list = new List(); + mPendingInstances[imageRef] = list; + } + list.Add(imageRef); + } + } + + /// + /// Removes and returns all pending instances for the given equality key. + /// + List TakePendingInstances(ImageReference imageRef) + { + lock (mInstancesLock) + { + if (mPendingInstances.TryGetValue(imageRef, out var list)) + { + mPendingInstances.Remove(imageRef); + return list; + } + return null; + } + } + // ── Internal Resolution ───────────────────────────────────────── IImage ResolveAndCache(ImageReference imageRef) @@ -411,11 +488,26 @@ void PostResolvedImage(ImageReference imageRef, IImage resolved) if (resolved == null) return; + // Collect ALL object instances that share this equality key. + // Only one instance enters the queue, but many may exist (e.g. + // 10 items using the same small-key icon). Every instance's + // ResolvedImage must be set so their bindings update. + var instances = TakePendingInstances(imageRef); + // ResolvedImage must be set on the UI thread so that // PropertyChanged fires there and Avalonia bindings update. Dispatcher.UIThread.Post(() => { - imageRef.ResolvedImage = resolved; + if (instances != null) + { + foreach (var instance in instances) + instance.ResolvedImage = resolved; + } + else + { + // Fallback: set on the specific object that was dequeued + imageRef.ResolvedImage = resolved; + } }); } } diff --git a/EmoTracker.UI/Media/Resolvers/ConcreteImageReferenceResolver.cs b/EmoTracker.UI/Media/Resolvers/ConcreteImageReferenceResolver.cs index 9311306..fe3f7a4 100644 --- a/EmoTracker.UI/Media/Resolvers/ConcreteImageReferenceResolver.cs +++ b/EmoTracker.UI/Media/Resolvers/ConcreteImageReferenceResolver.cs @@ -5,20 +5,20 @@ using System.IO; using Avalonia.Media; +using SkiaSharp; namespace EmoTracker.UI.Media.Resolvers { public class ConcreteImageReferenceResolver : ImageReferenceResolver { /// - /// Weak-reference cache of decoded base images keyed by the - /// pack-relative file path (the gamepackage:// URI path component). - /// Multiple instances that point - /// to the same source image but with different filters will share the - /// decoded base image as long as at least one reference is alive. + /// Cache of decoded base SKBitmaps keyed by the pack-relative file path. + /// Multiple instances that point to + /// the same source image but with different filters share the decoded + /// base bitmap. Cleared on pack unload via . /// - static readonly Dictionary> sSourceCache - = new Dictionary>(StringComparer.OrdinalIgnoreCase); + static readonly Dictionary sSourceCache + = new Dictionary(StringComparer.OrdinalIgnoreCase); /// /// Clears the source image cache. Called by @@ -28,6 +28,8 @@ public static void ClearSourceCache() { lock (sSourceCache) { + foreach (var kvp in sSourceCache) + kvp.Value?.Dispose(); sSourceCache.Clear(); } } @@ -55,50 +57,54 @@ public override IImage ResolveReference(ImageReference imageRef) Uri.UnescapeDataString(concreteRef.URI.Host), Uri.UnescapeDataString(concreteRef.URI.AbsolutePath)); - // Try to reuse a previously decoded base image for the same path - IImage baseImage = GetCachedSource(filePath); - if (baseImage == null) + // Get the decoded base SKBitmap from cache, or decode it + SKBitmap baseSK = GetCachedSource(filePath); + if (baseSK == null) { using (Stream s = Tracker.Instance.ActiveGamePackage.Open(filePath)) { if (s == null) return null; - baseImage = Utility.IconUtility.GetImage(s); + baseSK = Utility.IconUtility.DecodeSKBitmap(s); } - if (baseImage != null) - PutCachedSource(filePath, baseImage); + if (baseSK == null) + return null; + + PutCachedSource(filePath, baseSK); } - return Utility.IconUtility.ApplyFilterSpecToImage( - Tracker.Instance.ActiveGamePackage, baseImage, concreteRef.Filter); + // Clone the base bitmap so filter operations don't mutate the + // cached original, then run the entire filter chain in SKBitmap + // space (no intermediate PNG round-trips). + SKBitmap working = baseSK.Copy(); + working = Utility.IconUtility.ApplyFilterSpecToSKBitmap( + Tracker.Instance.ActiveGamePackage, working, concreteRef.Filter); + + // Convert to Avalonia IImage once at the end, computing the + // alpha mask for InputMaskingImage hit-testing. + return Utility.IconUtility.FinalizeToAvalonia(working); } return Utility.IconUtility.GetImageRaw(concreteRef.URI); } - static IImage GetCachedSource(string filePath) + static SKBitmap GetCachedSource(string filePath) { lock (sSourceCache) { - if (sSourceCache.TryGetValue(filePath, out var weakRef) && - weakRef.TryGetTarget(out IImage image)) - { - return image; - } - - // Entry expired or not present – clean up stale entry - sSourceCache.Remove(filePath); + if (sSourceCache.TryGetValue(filePath, out SKBitmap bmp)) + return bmp; return null; } } - static void PutCachedSource(string filePath, IImage image) + static void PutCachedSource(string filePath, SKBitmap bmp) { lock (sSourceCache) { - sSourceCache[filePath] = new WeakReference(image); + sSourceCache[filePath] = bmp; } } } diff --git a/EmoTracker.UI/Media/Resolvers/FilterImageReferenceResolver.cs b/EmoTracker.UI/Media/Resolvers/FilterImageReferenceResolver.cs index 9f8960b..5940a6d 100644 --- a/EmoTracker.UI/Media/Resolvers/FilterImageReferenceResolver.cs +++ b/EmoTracker.UI/Media/Resolvers/FilterImageReferenceResolver.cs @@ -1,25 +1,38 @@ -using EmoTracker.Data; -using EmoTracker.Data.Media; - -using Avalonia.Media; - -namespace EmoTracker.UI.Media.Resolvers -{ - class FilterImageReferenceResolver : ImageReferenceResolver - { - public override bool CanResolveReference(ImageReference imageRef) - { - return imageRef as FilterImageReference != null; - } - - public override IImage ResolveReference(ImageReference imageRef) - { - FilterImageReference concreteRef = imageRef as FilterImageReference; - if (concreteRef == null) - return null; - - var baseImg = ImageReferenceService.Instance.ResolveImageReference(concreteRef.Reference); - return Utility.IconUtility.ApplyFilterSpecToImage(Tracker.Instance.ActiveGamePackage, baseImg, concreteRef.Filter); - } - } -} +using EmoTracker.Data; +using EmoTracker.Data.Media; + +using Avalonia.Media; +using SkiaSharp; + +namespace EmoTracker.UI.Media.Resolvers +{ + class FilterImageReferenceResolver : ImageReferenceResolver + { + public override bool CanResolveReference(ImageReference imageRef) + { + return imageRef as FilterImageReference != null; + } + + public override IImage ResolveReference(ImageReference imageRef) + { + FilterImageReference concreteRef = imageRef as FilterImageReference; + if (concreteRef == null) + return null; + + var baseImg = ImageReferenceService.Instance.ResolveImageReference(concreteRef.Reference); + if (baseImg == null) + return null; + + // Convert the resolved base IImage to SKBitmap, apply the filter + // chain entirely in SKBitmap space, then convert back once. + SKBitmap baseSK = Utility.IconUtility.ToSkBitmapForFilter(baseImg); + if (baseSK == null) + return Utility.IconUtility.ApplyFilterSpecToImage(Tracker.Instance.ActiveGamePackage, baseImg, concreteRef.Filter); + + baseSK = Utility.IconUtility.ApplyFilterSpecToSKBitmap( + Tracker.Instance.ActiveGamePackage, baseSK, concreteRef.Filter); + + return Utility.IconUtility.FinalizeToAvalonia(baseSK); + } + } +} diff --git a/EmoTracker.UI/Media/Resolvers/LayeredImageReferenceResolver.cs b/EmoTracker.UI/Media/Resolvers/LayeredImageReferenceResolver.cs index 86f5906..8b14494 100644 --- a/EmoTracker.UI/Media/Resolvers/LayeredImageReferenceResolver.cs +++ b/EmoTracker.UI/Media/Resolvers/LayeredImageReferenceResolver.cs @@ -1,34 +1,58 @@ -using EmoTracker.Data.Media; - -using Avalonia.Media; - -namespace EmoTracker.UI.Media.Resolvers -{ - class LayeredImageReferenceResolver : ImageReferenceResolver - { - public override bool CanResolveReference(ImageReference imageRef) - { - return imageRef as LayeredImageReference != null; - } - - public override IImage ResolveReference(ImageReference imageRef) - { - LayeredImageReference concreteRef = imageRef as LayeredImageReference; - if (concreteRef == null) - return null; - - if (concreteRef.Layers.Count == 0) - return null; - - IImage img = null; - foreach (ImageReference layerRef in concreteRef.Layers) - { - var layerImg = ImageReferenceService.Instance.ResolveImageReference(layerRef); - img = Utility.IconUtility.ApplyOverlayImage(img, layerImg); - } - - - return img; - } - } -} +using EmoTracker.Data.Media; + +using Avalonia.Media; +using SkiaSharp; + +namespace EmoTracker.UI.Media.Resolvers +{ + class LayeredImageReferenceResolver : ImageReferenceResolver + { + public override bool CanResolveReference(ImageReference imageRef) + { + return imageRef as LayeredImageReference != null; + } + + public override IImage ResolveReference(ImageReference imageRef) + { + LayeredImageReference concreteRef = imageRef as LayeredImageReference; + if (concreteRef == null) + return null; + + if (concreteRef.Layers.Count == 0) + return null; + + // Composite all layers in SKBitmap space — one PNG conversion at the end + // instead of N round-trips through PNG encode→decode per layer. + SKBitmap composite = null; + foreach (ImageReference layerRef in concreteRef.Layers) + { + var layerImg = ImageReferenceService.Instance.ResolveImageReference(layerRef); + if (layerImg == null) + continue; + + SKBitmap layerSK = Utility.IconUtility.ToSkBitmapForFilter(layerImg); + if (layerSK == null) + continue; + + if (composite == null) + { + composite = layerSK; + } + else + { + var prev = composite; + composite = Utility.IconUtility.ApplyOverlaySK(composite, layerSK); + // ApplyOverlaySK disposes overlay (layerSK) and may return a new + // bitmap; dispose the old composite if it changed. + if (composite != prev) + prev.Dispose(); + } + } + + if (composite == null) + return null; + + return Utility.IconUtility.FinalizeToAvalonia(composite); + } + } +} diff --git a/EmoTracker.UI/Media/Utility/IconUtility.cs b/EmoTracker.UI/Media/Utility/IconUtility.cs index 2fe95a1..aefd175 100644 --- a/EmoTracker.UI/Media/Utility/IconUtility.cs +++ b/EmoTracker.UI/Media/Utility/IconUtility.cs @@ -1,518 +1,738 @@ -#nullable enable annotations -using EmoTracker.Core; -using EmoTracker.Data; -using System; -using System.IO; -using System.Linq; - -using Avalonia.Media; -using Avalonia.Media.Imaging; -using SkiaSharp; -using System.Collections.Generic; - -namespace EmoTracker.UI.Media.Utility -{ - public class IconUtility : ObservableSingleton - { - private bool mbEnableDpiConversion = true; - - public bool EnableDpiConversion - { - get { return mbEnableDpiConversion; } - set { SetProperty(ref mbEnableDpiConversion, value); } - } - - public enum LuminanceMode - { - Avg, - Average, - Max, - Blue, - Green, - Red, - BT709, - BT601 - } - - public static string[] GetArgs(string filterCommand) - { - string[] args = filterCommand.Split(new char[] { '|' }, StringSplitOptions.RemoveEmptyEntries); - for (int i = 0; i < args.Length; ++i) - args[i] = args[i].Trim(); - return args; - } - - // ── Avalonia / SkiaSharp image pipeline ────────────────────────────────── - - // Alpha masks keyed by IImage: bool[] of length (width * height), true = opaque - private static readonly Dictionary sAlphaMasks = new(); - - // Cached Skia-encoded PNG bytes for each IImage produced by SkToAvalonia. - // Used by ToSkBitmap to bypass Avalonia's Bitmap.Save(Stream), which may strip the - // alpha channel on some platforms — causing overlay compositing to treat every pixel - // as fully opaque and completely hide the base layer. - private static readonly Dictionary sPngCache = new(); - - // HTTP/HTTPS image download cache. null value means "download in progress". - private static readonly System.Collections.Concurrent.ConcurrentDictionary sHttpCache = new(); - private static readonly System.Net.Http.HttpClient sHttpClient = new(); - - /// - /// Raised on the UI thread after an HTTP image finishes downloading. - /// Subscribers (e.g. ApplicationModel) can use this to refresh bindings. - /// - public static event EventHandler? HttpImageLoaded; - - /// Returns the precomputed alpha mask for an image, or null if not available. - public static (bool[] mask, int w, int h)? GetAlphaMask(IImage image) - { - if (image != null && sAlphaMasks.TryGetValue(image, out var entry)) - return entry; - return null; - } - - /// Convert an Avalonia IImage back to an SKBitmap for pixel processing. - /// - /// Uses the Skia-encoded PNG bytes cached by when available, - /// avoiding Bitmap.Save(Stream) which may drop the alpha channel on some platforms - /// (causing all pixels to appear fully opaque and breaking overlay compositing). - /// Always returns a Bgra8888 bitmap so downstream pixel loops can read and write - /// alpha correctly regardless of the source image's original colour type. - /// - private static SKBitmap ToSkBitmap(IImage image) - { - if (image is not Avalonia.Media.Imaging.Bitmap avBitmap) - return null; - try - { - Stream pngStream; - if (sPngCache.TryGetValue(avBitmap, out byte[] cachedBytes)) - { - // Use the exact bytes that Skia encoded — guaranteed to have correct alpha. - pngStream = new MemoryStream(cachedBytes, writable: false); - } - else - { - // Fallback for images not created by SkToAvalonia (e.g. from GetImageRaw). - var ms = new MemoryStream(); - avBitmap.Save(ms); - ms.Position = 0; - pngStream = ms; - } - - using (pngStream) - { - SKBitmap decoded = SKBitmap.Decode(pngStream); - if (decoded == null) return null; - - // Promote to Bgra8888/Premul so pixel operations can read and write alpha correctly. - // Rgb888x forces alpha to 255; AlphaType.Opaque causes SKImage.FromBitmap to encode - // as RGB PNG (no alpha channel), so transparent pixels from the color-key pass are - // lost when the image is round-tripped through sPngCache. - if (decoded.ColorType != SKColorType.Bgra8888 || decoded.AlphaType == SKAlphaType.Opaque) - { - var targetInfo = new SKImageInfo(decoded.Width, decoded.Height, SKColorType.Bgra8888, SKAlphaType.Premul); - SKBitmap promoted = new SKBitmap(targetInfo); - using (var cvs = new SKCanvas(promoted)) - { - cvs.Clear(SKColors.Transparent); - cvs.DrawBitmap(decoded, 0, 0); - } - decoded.Dispose(); - return promoted; - } - return decoded; - } - } - catch { return null; } - } - - /// Convert an SKBitmap to an Avalonia IImage, optionally caching its alpha mask. - private static IImage SkToAvalonia(SKBitmap bmp, bool storeMask = false) - { - using var skImg = SKImage.FromBitmap(bmp); - using var encoded = skImg.Encode(SKEncodedImageFormat.Png, 100); - byte[] pngBytes = encoded.ToArray(); - var avBitmap = new Avalonia.Media.Imaging.Bitmap(new MemoryStream(pngBytes)); - - // Always cache the raw PNG bytes so ToSkBitmap can round-trip back to SKBitmap - // without going through Bitmap.Save(Stream), which may strip the alpha channel. - sPngCache[avBitmap] = pngBytes; - - if (storeMask) - { - var mask = new bool[bmp.Width * bmp.Height]; - for (int y = 0; y < bmp.Height; y++) - for (int x = 0; x < bmp.Width; x++) - mask[y * bmp.Width + x] = bmp.GetPixel(x, y).Alpha >= 10; - sAlphaMasks[avBitmap] = (mask, bmp.Width, bmp.Height); - } - - return avBitmap; - } - - /// - /// Translates a WPF pack://application:,,,/AssemblyName;component/path URI - /// to the Avalonia equivalent avares://AssemblyName/path. - /// Returns the original URI unchanged for all other schemes. - /// - private static Uri TranslatePackUri(Uri uri) - { - const string packPrefix = "pack://application:,,,/"; - string orig = uri.OriginalString; - if (!orig.StartsWith(packPrefix, StringComparison.OrdinalIgnoreCase)) - return uri; - string rest = orig.Substring(packPrefix.Length); - int compIdx = rest.IndexOf(";component/", StringComparison.OrdinalIgnoreCase); - if (compIdx < 0) return uri; - string assembly = rest.Substring(0, compIdx); - string path = rest.Substring(compIdx + ";component".Length); // includes leading / - return new Uri($"avares://{assembly}{path}"); - } - - public static IImage GetImageRaw(Uri uri) - { - try - { - Uri resolved = TranslatePackUri(uri); - if (resolved.Scheme == "avares") - { - using var stream = Avalonia.Platform.AssetLoader.Open(resolved); - return new Avalonia.Media.Imaging.Bitmap(stream); - } - if (resolved.IsFile) - return new Avalonia.Media.Imaging.Bitmap(resolved.LocalPath); - if (resolved.Scheme == "http" || resolved.Scheme == "https") - return GetImageFromHttp(resolved); - return null; - } - catch { return null; } - } - - private static IImage? GetImageFromHttp(Uri uri) - { - string key = uri.AbsoluteUri; - if (sHttpCache.TryGetValue(key, out IImage? cached)) - return cached; // null if still loading, non-null if loaded - - // Mark as loading to avoid duplicate downloads - sHttpCache[key] = null; - - // Download in background; raise HttpImageLoaded when done so callers can refresh - _ = System.Threading.Tasks.Task.Run(async () => - { - try - { - byte[] bytes = await sHttpClient.GetByteArrayAsync(uri).ConfigureAwait(false); - using var ms = new System.IO.MemoryStream(bytes); - sHttpCache[key] = new Avalonia.Media.Imaging.Bitmap(ms); - } - catch - { - sHttpCache.TryRemove(key, out _); // allow retry on next call - } - await Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(() => - HttpImageLoaded?.Invoke(null, EventArgs.Empty)); - }); - - return null; - } - - public static IImage GetImage(Uri uri) - { - try - { - Uri resolved = TranslatePackUri(uri); - if (resolved.Scheme == "avares") - { - using var stream = Avalonia.Platform.AssetLoader.Open(resolved); - return GetImage(stream); - } - if (resolved.IsFile) - { - using var stream = File.OpenRead(resolved.LocalPath); - return GetImage(stream); - } - return null; - } - catch { return null; } - } - - public static IImage GetImage(Stream stream) - { - if (stream == null) - return null; - try - { - SKBitmap decoded = SKBitmap.Decode(stream); - if (decoded == null) return null; - - // Promote to Bgra8888/Premul before the color-key pass. - // SKBitmap.Decode may return Rgb888x (no alpha channel, e.g. RGB PNG or 24-bit BMP). - // Even if the color type is already Bgra8888, AlphaType.Opaque causes SKImage.FromBitmap - // to encode as RGB PNG — dropping all alpha — so transparent pixels set by the color-key - // pass are lost when the image is round-tripped through sPngCache for filter operations. - SKBitmap bmp; - if (decoded.ColorType != SKColorType.Bgra8888 || decoded.AlphaType == SKAlphaType.Opaque) - { - var targetInfo = new SKImageInfo(decoded.Width, decoded.Height, SKColorType.Bgra8888, SKAlphaType.Premul); - bmp = new SKBitmap(targetInfo); - using (var cvs = new SKCanvas(bmp)) - { - cvs.Clear(SKColors.Transparent); - cvs.DrawBitmap(decoded, 0, 0); - } - decoded.Dispose(); - } - else - { - bmp = decoded; - } - - // Apply color key: magenta (R=255, G=0, B=255) → transparent - for (int y = 0; y < bmp.Height; y++) - { - for (int x = 0; x < bmp.Width; x++) - { - SKColor c = bmp.GetPixel(x, y); - if (c.Red == 255 && c.Green == 0 && c.Blue == 255) - bmp.SetPixel(x, y, SKColors.Transparent); - } - } - - var result = SkToAvalonia(bmp, storeMask: true); - bmp.Dispose(); - return result; - } - catch { return null; } - } - - public static IImage ApplyOverlayImage(IGamePackage package, IImage image, params string[] args) - { - if (package == null) - return image; - - if (args.Length >= 1) - { - IImage overlay = GetImage(package.Open(args[0])); - if (overlay != null) - return ApplyOverlayImage(image, overlay); - } - - return image; - } - - public static IImage ApplyOverlayImage(IImage image, IImage overlay) - { - if (overlay == null) return image; - if (image == null) return overlay; - - try - { - SKBitmap baseBmp = ToSkBitmap(image); - SKBitmap overlayBmp = ToSkBitmap(overlay); - - if (baseBmp == null) return image; - if (overlayBmp == null) { baseBmp.Dispose(); return image; } - - if (baseBmp.Width != overlayBmp.Width || baseBmp.Height != overlayBmp.Height) - { - baseBmp.Dispose(); - overlayBmp.Dispose(); - ScriptManager.Instance.OutputError("Not applying overlay to base image because dimensions don't match."); - return image; - } - - for (int y = 0; y < baseBmp.Height; y++) - { - for (int x = 0; x < baseBmp.Width; x++) - { - SKColor b = baseBmp.GetPixel(x, y); - SKColor o = overlayBmp.GetPixel(x, y); - - float alpha = o.Alpha / 255.0f; - float invAlpha = 1.0f - alpha; - - byte r = (byte)Math.Clamp((int)(o.Red * alpha + b.Red * invAlpha), 0, 255); - byte g = (byte)Math.Clamp((int)(o.Green * alpha + b.Green * invAlpha), 0, 255); - byte bl = (byte)Math.Clamp((int)(o.Blue * alpha + b.Blue * invAlpha), 0, 255); - byte a = Math.Max(b.Alpha, o.Alpha); - - baseBmp.SetPixel(x, y, new SKColor(r, g, bl, a)); - } - } - - overlayBmp.Dispose(); - var result = SkToAvalonia(baseBmp, storeMask: true); - baseBmp.Dispose(); - return result; - } - catch { return image; } - } - - private static byte Lerp(byte a, byte b, float factor) - { - if (factor <= 0.0f) return a; - if (factor >= 1.0f) return b; - return (byte)(((float)a * (1.0f - factor)) + ((float)b * factor) + 0.5f); - } - - public static IImage MakeImageGrayscale(IImage image, LuminanceMode mode = LuminanceMode.Average, float saturation = 0.0f) - { - if (image == null) return null; - - SKBitmap bmp = ToSkBitmap(image); - if (bmp == null) return image; - - for (int y = 0; y < bmp.Height; y++) - { - for (int x = 0; x < bmp.Width; x++) - { - SKColor c = bmp.GetPixel(x, y); - byte r = c.Red, g = c.Green, b = c.Blue; - byte ro = r, go = g, bo = b; - - switch (mode) - { - case LuminanceMode.Avg: - case LuminanceMode.Average: r = g = b = (byte)((r + g + b) / 3.0); break; - case LuminanceMode.Max: r = g = b = Math.Max(Math.Max(r, g), b); break; - case LuminanceMode.Blue: r = g = b; break; - case LuminanceMode.Green: b = r = g; break; - case LuminanceMode.Red: b = g = r; break; - case LuminanceMode.BT709: r = g = b = (byte)(((uint)r + (uint)r + (uint)b + (uint)g + (uint)g + (uint)g) / 6); break; - case LuminanceMode.BT601: r = g = b = (byte)(((uint)r + (uint)r + (uint)b + (uint)g + (uint)g + (uint)g) >> 3); break; - } - - b = Lerp(b, bo, saturation); - g = Lerp(g, go, saturation); - r = Lerp(r, ro, saturation); - - bmp.SetPixel(x, y, new SKColor(r, g, b, c.Alpha)); - } - } - - var result = SkToAvalonia(bmp, storeMask: true); - bmp.Dispose(); - return result; - } - - public static IImage AdjustSaturation(IGamePackage package, IImage image, params string[] args) - { - if (image == null) return null; - - if (args.Length >= 1) - { - float saturation; - if (float.TryParse(args[0], System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out saturation)) - { - LuminanceMode mode = LuminanceMode.Average; - if (args.Length >= 2) - Enum.TryParse(args[1], true, out mode); - saturation = Math.Min(Math.Max(saturation, 0.0f), 1.0f); - return MakeImageGrayscale(image, mode, saturation); - } - } - - return image; - } - - public static IImage AdjustBrightness(IGamePackage package, IImage image, params string[] args) - { - if (image == null) return null; - - if (args.Length >= 1) - { - float brightness; - if (float.TryParse(args[0], System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out brightness)) - { - brightness = Math.Max(brightness, 0.0f); - - SKBitmap bmp = ToSkBitmap(image); - if (bmp == null) return image; - - for (int y = 0; y < bmp.Height; y++) - { - for (int x = 0; x < bmp.Width; x++) - { - SKColor c = bmp.GetPixel(x, y); - byte r = (byte)Math.Clamp(c.Red * brightness, 0, 255); - byte g = (byte)Math.Clamp(c.Green * brightness, 0, 255); - byte b = (byte)Math.Clamp(c.Blue * brightness, 0, 255); - bmp.SetPixel(x, y, new SKColor(r, g, b, c.Alpha)); - } - } - - var result = SkToAvalonia(bmp, storeMask: true); - bmp.Dispose(); - return result; - } - } - - return image; - } - - public static IImage MakeImageDim(IImage image, int divisor = 2) - { - if (image == null) return null; - - SKBitmap bmp = ToSkBitmap(image); - if (bmp == null) return image; - - for (int y = 0; y < bmp.Height; y++) - { - for (int x = 0; x < bmp.Width; x++) - { - SKColor c = bmp.GetPixel(x, y); - byte r = (byte)(c.Red - c.Red / divisor); - byte g = (byte)(c.Green - c.Green / divisor); - byte b = (byte)(c.Blue - c.Blue / divisor); - bmp.SetPixel(x, y, new SKColor(r, g, b, c.Alpha)); - } - } - - var result = SkToAvalonia(bmp, storeMask: true); - bmp.Dispose(); - return result; - } - - public static IImage ApplyFilterSpecToImage(IGamePackage package, IImage image, string filterSpec) - { - if (image == null) - return null; - - if (!string.IsNullOrWhiteSpace(filterSpec)) - { - string[] mods = filterSpec.Split(','); - foreach (string modRaw in mods) - { - string[] tokens = GetArgs(modRaw); - if (tokens.Length >= 1) - { - string mod = tokens[0]; - string[] args = tokens.Skip(1).ToArray(); - - if (mod.StartsWith("grayscale", StringComparison.OrdinalIgnoreCase)) - image = IconUtility.MakeImageGrayscale(image); - else if (mod.StartsWith("dim", StringComparison.OrdinalIgnoreCase)) - image = IconUtility.MakeImageDim(image); - else if (mod.StartsWith("halfdim", StringComparison.OrdinalIgnoreCase)) - image = IconUtility.MakeImageDim(image, 4); - else if (mod.StartsWith("quarterdim", StringComparison.OrdinalIgnoreCase)) - image = IconUtility.MakeImageDim(image, 8); - else if (mod.StartsWith("brightness", StringComparison.OrdinalIgnoreCase)) - image = IconUtility.AdjustBrightness(package, image, args); - else if (mod.StartsWith("overlay", StringComparison.OrdinalIgnoreCase)) - image = IconUtility.ApplyOverlayImage(package, image, args); - else if (mod.StartsWith("saturation", StringComparison.OrdinalIgnoreCase)) - image = IconUtility.AdjustSaturation(package, image, args); - else if (mod.StartsWith("@disabled", StringComparison.OrdinalIgnoreCase)) - image = IconUtility.ApplyFilterSpecToImage(package, image, Tracker.Instance.DisabledImageFilterSpec); - } - } - } - - return image; - } - } -} +#nullable enable annotations +using EmoTracker.Core; +using EmoTracker.Data; +using System; +using System.IO; +using System.Linq; + +using Avalonia.Media; +using Avalonia.Media.Imaging; +using SkiaSharp; +using System.Collections.Generic; + +namespace EmoTracker.UI.Media.Utility +{ + public class IconUtility : ObservableSingleton + { + private bool mbEnableDpiConversion = true; + + public bool EnableDpiConversion + { + get { return mbEnableDpiConversion; } + set { SetProperty(ref mbEnableDpiConversion, value); } + } + + public enum LuminanceMode + { + Avg, + Average, + Max, + Blue, + Green, + Red, + BT709, + BT601 + } + + public static string[] GetArgs(string filterCommand) + { + string[] args = filterCommand.Split(new char[] { '|' }, StringSplitOptions.RemoveEmptyEntries); + for (int i = 0; i < args.Length; ++i) + args[i] = args[i].Trim(); + return args; + } + + // ── Avalonia / SkiaSharp image pipeline ────────────────────────────────── + + // Alpha masks keyed by IImage: bool[] of length (width * height), true = opaque. + // ConcurrentDictionary because the background image worker writes masks while + // the UI thread reads them (InputMaskingImage.HitTest via GetAlphaMask). + private static readonly System.Collections.Concurrent.ConcurrentDictionary sAlphaMasks = new(); + + // Cached Skia-encoded PNG bytes for each IImage produced by SkToAvalonia. + // Used by ToSkBitmap to bypass Avalonia's Bitmap.Save(Stream), which may strip the + // alpha channel on some platforms — causing overlay compositing to treat every pixel + // as fully opaque and completely hide the base layer. + // ConcurrentDictionary because the background image worker writes entries while + // filter resolution may read them concurrently. + private static readonly System.Collections.Concurrent.ConcurrentDictionary sPngCache = new(); + + // HTTP/HTTPS image download cache. null value means "download in progress". + private static readonly System.Collections.Concurrent.ConcurrentDictionary sHttpCache = new(); + private static readonly System.Net.Http.HttpClient sHttpClient = new(); + + /// + /// Raised on the UI thread after an HTTP image finishes downloading. + /// Subscribers (e.g. ApplicationModel) can use this to refresh bindings. + /// + public static event EventHandler? HttpImageLoaded; + + /// Returns the precomputed alpha mask for an image, or null if not available. + public static (bool[] mask, int w, int h)? GetAlphaMask(IImage image) + { + if (image != null && sAlphaMasks.TryGetValue(image, out var entry)) + return entry; + return null; + } + + // ── SKBitmap decode / promote ─────────────────────────────────────────── + + /// + /// Decode a stream to an SKBitmap, promote to Bgra8888/Premul, and apply + /// the magenta colour-key. This is the internal entry point for the + /// SKBitmap filter pipeline — callers get an SKBitmap they can pass through + /// without any PNG round-trips. + /// + internal static SKBitmap DecodeSKBitmap(Stream stream) + { + if (stream == null) + return null; + try + { + SKBitmap decoded = SKBitmap.Decode(stream); + if (decoded == null) return null; + + // Promote to Bgra8888/Premul. + SKBitmap bmp; + if (decoded.ColorType != SKColorType.Bgra8888 || decoded.AlphaType == SKAlphaType.Opaque) + { + var targetInfo = new SKImageInfo(decoded.Width, decoded.Height, SKColorType.Bgra8888, SKAlphaType.Premul); + bmp = new SKBitmap(targetInfo); + using (var cvs = new SKCanvas(bmp)) + { + cvs.Clear(SKColors.Transparent); + cvs.DrawBitmap(decoded, 0, 0); + } + decoded.Dispose(); + } + else + { + bmp = decoded; + } + + // Apply color key: magenta (R=255, G=0, B=255) → transparent. + // Uses direct pixel buffer access for ~10-50× speedup over GetPixel/SetPixel. + ApplyColorKey(bmp); + + return bmp; + } + catch { return null; } + } + + /// + /// Replace magenta (255, 0, 255) pixels with transparent using direct pixel + /// buffer access. The bitmap must be Bgra8888. + /// + private static unsafe void ApplyColorKey(SKBitmap bmp) + { + int pixelCount = bmp.Width * bmp.Height; + uint* pixels = (uint*)bmp.GetPixels().ToPointer(); + + for (int i = 0; i < pixelCount; i++) + { + uint c = pixels[i]; + // BGRA8888 little-endian: B=byte0, G=byte1, R=byte2, A=byte3 + // As uint32: 0xAARRGGBB + byte blue = (byte)(c); + byte green = (byte)(c >> 8); + byte red = (byte)(c >> 16); + + if (red == 255 && green == 0 && blue == 255) + pixels[i] = 0; // fully transparent + } + } + + /// + /// Compute the alpha mask from an SKBitmap using direct pixel buffer access. + /// + private static unsafe bool[] ComputeAlphaMask(SKBitmap bmp) + { + int pixelCount = bmp.Width * bmp.Height; + var mask = new bool[pixelCount]; + uint* pixels = (uint*)bmp.GetPixels().ToPointer(); + + for (int i = 0; i < pixelCount; i++) + { + byte alpha = (byte)(pixels[i] >> 24); + mask[i] = alpha >= 10; + } + + return mask; + } + + // ── IImage ↔ SKBitmap conversion ──────────────────────────────────────── + + /// + /// Convert an Avalonia IImage to an SKBitmap for use in the filter pipeline. + /// Returns null if conversion fails. The caller owns the returned bitmap. + /// + internal static SKBitmap ToSkBitmapForFilter(IImage image) => ToSkBitmap(image); + + /// Convert an Avalonia IImage back to an SKBitmap for pixel processing. + /// + /// Uses the Skia-encoded PNG bytes cached by when available, + /// avoiding Bitmap.Save(Stream) which may drop the alpha channel on some platforms. + /// Always returns a Bgra8888 bitmap. + /// + private static SKBitmap ToSkBitmap(IImage image) + { + if (image is not Avalonia.Media.Imaging.Bitmap avBitmap) + return null; + try + { + Stream pngStream; + if (sPngCache.TryGetValue(avBitmap, out byte[] cachedBytes)) + { + pngStream = new MemoryStream(cachedBytes, writable: false); + } + else + { + var ms = new MemoryStream(); + avBitmap.Save(ms); + ms.Position = 0; + pngStream = ms; + } + + using (pngStream) + { + SKBitmap decoded = SKBitmap.Decode(pngStream); + if (decoded == null) return null; + + if (decoded.ColorType != SKColorType.Bgra8888 || decoded.AlphaType == SKAlphaType.Opaque) + { + var targetInfo = new SKImageInfo(decoded.Width, decoded.Height, SKColorType.Bgra8888, SKAlphaType.Premul); + SKBitmap promoted = new SKBitmap(targetInfo); + using (var cvs = new SKCanvas(promoted)) + { + cvs.Clear(SKColors.Transparent); + cvs.DrawBitmap(decoded, 0, 0); + } + decoded.Dispose(); + return promoted; + } + return decoded; + } + } + catch { return null; } + } + + /// + /// Convert an SKBitmap to an Avalonia IImage, computing and caching its + /// alpha mask for InputMaskingImage hit-testing. Disposes the input bitmap. + /// This is the single conversion point at the END of the SKBitmap pipeline. + /// + internal static IImage FinalizeToAvalonia(SKBitmap bmp) + { + if (bmp == null) return null; + try + { + var mask = ComputeAlphaMask(bmp); + var avBitmap = SkToAvaloniaCore(bmp); + sAlphaMasks[avBitmap] = (mask, bmp.Width, bmp.Height); + bmp.Dispose(); + return avBitmap; + } + catch + { + bmp?.Dispose(); + return null; + } + } + + /// Convert an SKBitmap to an Avalonia IImage, optionally caching its alpha mask. + private static IImage SkToAvalonia(SKBitmap bmp, bool storeMask = false) + { + var avBitmap = SkToAvaloniaCore(bmp); + + if (storeMask) + { + var mask = ComputeAlphaMask(bmp); + sAlphaMasks[avBitmap] = (mask, bmp.Width, bmp.Height); + } + + return avBitmap; + } + + /// + /// Core SKBitmap → Avalonia Bitmap conversion. Encodes as PNG and caches + /// the PNG bytes so ToSkBitmap can round-trip without Bitmap.Save. + /// + private static Avalonia.Media.Imaging.Bitmap SkToAvaloniaCore(SKBitmap bmp) + { + using var skImg = SKImage.FromBitmap(bmp); + using var encoded = skImg.Encode(SKEncodedImageFormat.Png, 100); + byte[] pngBytes = encoded.ToArray(); + var avBitmap = new Avalonia.Media.Imaging.Bitmap(new MemoryStream(pngBytes)); + + sPngCache[avBitmap] = pngBytes; + + return avBitmap; + } + + // ── SKBitmap-based filter operations ──────────────────────────────────── + // + // These operate entirely in SKBitmap space. Skia's colour-matrix filter + // runs natively (potentially SIMD-optimised) and is orders of magnitude + // faster than per-pixel GetPixel/SetPixel loops. + // + // The caller is responsible for disposing the INPUT bitmap if it differs + // from the returned bitmap (the ApplyFilterSpecToSKBitmap method does this + // automatically for chained filters). + + /// Apply a 4×5 colour matrix to an SKBitmap via Skia's native path. + private static SKBitmap ApplyColorMatrix(SKBitmap src, float[] matrix) + { + var result = new SKBitmap(src.Info); + using var canvas = new SKCanvas(result); + canvas.Clear(SKColors.Transparent); + using var filter = SKColorFilter.CreateColorMatrix(matrix); + using var paint = new SKPaint + { + ColorFilter = filter, + BlendMode = SKBlendMode.Src // overwrite destination, don't composite + }; + canvas.DrawBitmap(src, 0, 0, paint); + return result; + } + + /// + /// Build the 4×5 colour matrix for a grayscale+saturation operation. + /// When is 0 the result is fully desaturated; + /// when 1 the image is unchanged. + /// + private static float[] ComputeGrayscaleMatrix(LuminanceMode mode, float saturation) + { + // Luminance weights per mode — determines how RGB channels + // contribute to the grayscale value. + float wr, wg, wb; + switch (mode) + { + case LuminanceMode.Max: + // Max-luminance can't be expressed as a linear matrix. + // Fall back to equal weights as a reasonable approximation. + wr = wg = wb = 1f / 3f; + break; + case LuminanceMode.Blue: + wr = 0; wg = 0; wb = 1; + break; + case LuminanceMode.Green: + wr = 0; wg = 1; wb = 0; + break; + case LuminanceMode.Red: + wr = 1; wg = 0; wb = 0; + break; + case LuminanceMode.BT709: + // The original code uses (2R+3G+B)/6 which is closer to BT.601 + wr = 2f / 6f; wg = 3f / 6f; wb = 1f / 6f; + break; + case LuminanceMode.BT601: + // Original uses (2R+3G+B)>>3 — divides by 8, not 6. + // Weights intentionally sum to 0.75, producing a dimmer result. + wr = 2f / 8f; wg = 3f / 8f; wb = 1f / 8f; + break; + default: // Average, Avg + wr = wg = wb = 1f / 3f; + break; + } + + // Saturation matrix: lerp between grayscale and identity. + // out_r = r * (wr*(1-s) + s) + g * wg*(1-s) + b * wb*(1-s) + // (and analogously for g, b) + float s = saturation; + float inv = 1f - s; + + return new float[] + { + wr * inv + s, wg * inv, wb * inv, 0, 0, + wr * inv, wg * inv + s, wb * inv, 0, 0, + wr * inv, wg * inv, wb * inv + s, 0, 0, + 0, 0, 0, 1, 0 + }; + } + + internal static SKBitmap MakeGrayscaleSK(SKBitmap src, LuminanceMode mode = LuminanceMode.Average, float saturation = 0.0f) + { + return ApplyColorMatrix(src, ComputeGrayscaleMatrix(mode, saturation)); + } + + internal static SKBitmap MakeDimSK(SKBitmap src, int divisor = 2) + { + // Original: r = r - r/divisor → factor = 1 - 1/divisor + float factor = 1.0f - 1.0f / divisor; + float[] matrix = + { + factor, 0, 0, 0, 0, + 0, factor, 0, 0, 0, + 0, 0, factor, 0, 0, + 0, 0, 0, 1, 0 + }; + return ApplyColorMatrix(src, matrix); + } + + internal static SKBitmap AdjustBrightnessSK(SKBitmap src, float brightness) + { + float[] matrix = + { + brightness, 0, 0, 0, 0, + 0, brightness, 0, 0, 0, + 0, 0, brightness, 0, 0, + 0, 0, 0, 1, 0 + }; + return ApplyColorMatrix(src, matrix); + } + + /// + /// Composite an overlay bitmap on top of a base bitmap using Skia's + /// built-in SrcOver blend mode. Disposes the overlay; returns a new bitmap. + /// The base bitmap is NOT disposed (caller manages it). + /// + internal static SKBitmap ApplyOverlaySK(SKBitmap baseBmp, SKBitmap overlay) + { + if (overlay == null) return baseBmp; + if (baseBmp == null) return overlay; + + if (baseBmp.Width != overlay.Width || baseBmp.Height != overlay.Height) + { + ScriptManager.Instance.OutputError("Not applying overlay to base image because dimensions don't match."); + overlay.Dispose(); + return baseBmp; + } + + // Clone base and draw overlay on top with SrcOver blend + var result = baseBmp.Copy(); + using var canvas = new SKCanvas(result); + using var paint = new SKPaint { BlendMode = SKBlendMode.SrcOver }; + canvas.DrawBitmap(overlay, 0, 0, paint); + overlay.Dispose(); + return result; + } + + /// + /// Apply the full filter specification, staying in SKBitmap space for the + /// entire chain. Each filter step produces a new SKBitmap; intermediates + /// are disposed automatically. The INPUT bitmap is consumed (may be + /// disposed or returned). + /// + internal static SKBitmap ApplyFilterSpecToSKBitmap(IGamePackage package, SKBitmap bmp, string filterSpec) + { + if (bmp == null) + return null; + + if (!string.IsNullOrWhiteSpace(filterSpec)) + { + string[] mods = filterSpec.Split(','); + foreach (string modRaw in mods) + { + string[] tokens = GetArgs(modRaw); + if (tokens.Length >= 1) + { + string mod = tokens[0]; + string[] args = tokens.Skip(1).ToArray(); + + SKBitmap prev = bmp; + + if (mod.StartsWith("grayscale", StringComparison.OrdinalIgnoreCase)) + bmp = MakeGrayscaleSK(bmp); + else if (mod.StartsWith("dim", StringComparison.OrdinalIgnoreCase)) + bmp = MakeDimSK(bmp); + else if (mod.StartsWith("halfdim", StringComparison.OrdinalIgnoreCase)) + bmp = MakeDimSK(bmp, 4); + else if (mod.StartsWith("quarterdim", StringComparison.OrdinalIgnoreCase)) + bmp = MakeDimSK(bmp, 8); + else if (mod.StartsWith("brightness", StringComparison.OrdinalIgnoreCase)) + { + if (args.Length >= 1 && + float.TryParse(args[0], System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, out float brightness)) + { + brightness = Math.Max(brightness, 0.0f); + bmp = AdjustBrightnessSK(bmp, brightness); + } + } + else if (mod.StartsWith("overlay", StringComparison.OrdinalIgnoreCase)) + { + if (package != null && args.Length >= 1) + { + SKBitmap overlay = DecodeSKBitmap(package.Open(args[0])); + if (overlay != null) + bmp = ApplyOverlaySK(bmp, overlay); + } + } + else if (mod.StartsWith("saturation", StringComparison.OrdinalIgnoreCase)) + { + if (args.Length >= 1 && + float.TryParse(args[0], System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, out float saturation)) + { + LuminanceMode mode = LuminanceMode.Average; + if (args.Length >= 2) + Enum.TryParse(args[1], true, out mode); + saturation = Math.Min(Math.Max(saturation, 0.0f), 1.0f); + bmp = MakeGrayscaleSK(bmp, mode, saturation); + } + } + else if (mod.StartsWith("@disabled", StringComparison.OrdinalIgnoreCase)) + bmp = ApplyFilterSpecToSKBitmap(package, bmp, Tracker.Instance.DisabledImageFilterSpec); + + // Dispose the intermediate bitmap if a new one was produced + if (bmp != prev) + prev.Dispose(); + } + } + } + + return bmp; + } + + // ── Legacy IImage-based API (backward compat) ─────────────────────────── + + /// + /// Translates a WPF pack://application:,,,/AssemblyName;component/path URI + /// to the Avalonia equivalent avares://AssemblyName/path. + /// Returns the original URI unchanged for all other schemes. + /// + private static Uri TranslatePackUri(Uri uri) + { + const string packPrefix = "pack://application:,,,/"; + string orig = uri.OriginalString; + if (!orig.StartsWith(packPrefix, StringComparison.OrdinalIgnoreCase)) + return uri; + string rest = orig.Substring(packPrefix.Length); + int compIdx = rest.IndexOf(";component/", StringComparison.OrdinalIgnoreCase); + if (compIdx < 0) return uri; + string assembly = rest.Substring(0, compIdx); + string path = rest.Substring(compIdx + ";component".Length); // includes leading / + return new Uri($"avares://{assembly}{path}"); + } + + public static IImage GetImageRaw(Uri uri) + { + try + { + Uri resolved = TranslatePackUri(uri); + if (resolved.Scheme == "avares") + { + using var stream = Avalonia.Platform.AssetLoader.Open(resolved); + return new Avalonia.Media.Imaging.Bitmap(stream); + } + if (resolved.IsFile) + return new Avalonia.Media.Imaging.Bitmap(resolved.LocalPath); + if (resolved.Scheme == "http" || resolved.Scheme == "https") + return GetImageFromHttp(resolved); + return null; + } + catch { return null; } + } + + private static IImage? GetImageFromHttp(Uri uri) + { + string key = uri.AbsoluteUri; + if (sHttpCache.TryGetValue(key, out IImage? cached)) + return cached; // null if still loading, non-null if loaded + + sHttpCache[key] = null; + + _ = System.Threading.Tasks.Task.Run(async () => + { + try + { + byte[] bytes = await sHttpClient.GetByteArrayAsync(uri).ConfigureAwait(false); + using var ms = new System.IO.MemoryStream(bytes); + sHttpCache[key] = new Avalonia.Media.Imaging.Bitmap(ms); + } + catch + { + sHttpCache.TryRemove(key, out _); + } + await Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(() => + HttpImageLoaded?.Invoke(null, EventArgs.Empty)); + }); + + return null; + } + + public static IImage GetImage(Uri uri) + { + try + { + Uri resolved = TranslatePackUri(uri); + if (resolved.Scheme == "avares") + { + using var stream = Avalonia.Platform.AssetLoader.Open(resolved); + return GetImage(stream); + } + if (resolved.IsFile) + { + using var stream = File.OpenRead(resolved.LocalPath); + return GetImage(stream); + } + return null; + } + catch { return null; } + } + + /// + /// Decode a stream to an Avalonia IImage with colour-key and alpha mask. + /// Kept for backward compatibility; new resolver code should prefer + /// + . + /// + public static IImage GetImage(Stream stream) + { + SKBitmap bmp = DecodeSKBitmap(stream); + if (bmp == null) return null; + + var result = SkToAvalonia(bmp, storeMask: true); + bmp.Dispose(); + return result; + } + + // ── Legacy IImage filter wrappers ─────────────────────────────────────── + // These are kept for any call sites that still pass IImage. They convert + // to SKBitmap, apply the fast SKBitmap filter, convert back. + + public static IImage ApplyOverlayImage(IGamePackage package, IImage image, params string[] args) + { + if (package == null) + return image; + + if (args.Length >= 1) + { + IImage overlay = GetImage(package.Open(args[0])); + if (overlay != null) + return ApplyOverlayImage(image, overlay); + } + + return image; + } + + public static IImage ApplyOverlayImage(IImage image, IImage overlay) + { + if (overlay == null) return image; + if (image == null) return overlay; + + try + { + SKBitmap baseBmp = ToSkBitmap(image); + SKBitmap overlayBmp = ToSkBitmap(overlay); + + if (baseBmp == null) return image; + if (overlayBmp == null) { baseBmp.Dispose(); return image; } + + var result = ApplyOverlaySK(baseBmp, overlayBmp); + + // overlayBmp disposed by ApplyOverlaySK; baseBmp is not + if (result != baseBmp) + baseBmp.Dispose(); + + var avResult = SkToAvalonia(result, storeMask: true); + result.Dispose(); + return avResult; + } + catch { return image; } + } + + public static IImage MakeImageGrayscale(IImage image, LuminanceMode mode = LuminanceMode.Average, float saturation = 0.0f) + { + if (image == null) return null; + SKBitmap bmp = ToSkBitmap(image); + if (bmp == null) return image; + SKBitmap result = MakeGrayscaleSK(bmp, mode, saturation); + bmp.Dispose(); + var avResult = SkToAvalonia(result, storeMask: true); + result.Dispose(); + return avResult; + } + + public static IImage AdjustSaturation(IGamePackage package, IImage image, params string[] args) + { + if (image == null) return null; + if (args.Length >= 1) + { + if (float.TryParse(args[0], System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out float saturation)) + { + LuminanceMode mode = LuminanceMode.Average; + if (args.Length >= 2) + Enum.TryParse(args[1], true, out mode); + saturation = Math.Min(Math.Max(saturation, 0.0f), 1.0f); + return MakeImageGrayscale(image, mode, saturation); + } + } + return image; + } + + public static IImage AdjustBrightness(IGamePackage package, IImage image, params string[] args) + { + if (image == null) return null; + if (args.Length >= 1) + { + if (float.TryParse(args[0], System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out float brightness)) + { + brightness = Math.Max(brightness, 0.0f); + SKBitmap bmp = ToSkBitmap(image); + if (bmp == null) return image; + SKBitmap result = AdjustBrightnessSK(bmp, brightness); + bmp.Dispose(); + var avResult = SkToAvalonia(result, storeMask: true); + result.Dispose(); + return avResult; + } + } + return image; + } + + public static IImage MakeImageDim(IImage image, int divisor = 2) + { + if (image == null) return null; + SKBitmap bmp = ToSkBitmap(image); + if (bmp == null) return image; + SKBitmap result = MakeDimSK(bmp, divisor); + bmp.Dispose(); + var avResult = SkToAvalonia(result, storeMask: true); + result.Dispose(); + return avResult; + } + + /// + /// Legacy IImage-based filter chain. Kept for backward compatibility. + /// Resolvers should prefer the SKBitmap pipeline. + /// + public static IImage ApplyFilterSpecToImage(IGamePackage package, IImage image, string filterSpec) + { + if (image == null) + return null; + + if (!string.IsNullOrWhiteSpace(filterSpec)) + { + string[] mods = filterSpec.Split(','); + foreach (string modRaw in mods) + { + string[] tokens = GetArgs(modRaw); + if (tokens.Length >= 1) + { + string mod = tokens[0]; + string[] args = tokens.Skip(1).ToArray(); + + if (mod.StartsWith("grayscale", StringComparison.OrdinalIgnoreCase)) + image = IconUtility.MakeImageGrayscale(image); + else if (mod.StartsWith("dim", StringComparison.OrdinalIgnoreCase)) + image = IconUtility.MakeImageDim(image); + else if (mod.StartsWith("halfdim", StringComparison.OrdinalIgnoreCase)) + image = IconUtility.MakeImageDim(image, 4); + else if (mod.StartsWith("quarterdim", StringComparison.OrdinalIgnoreCase)) + image = IconUtility.MakeImageDim(image, 8); + else if (mod.StartsWith("brightness", StringComparison.OrdinalIgnoreCase)) + image = IconUtility.AdjustBrightness(package, image, args); + else if (mod.StartsWith("overlay", StringComparison.OrdinalIgnoreCase)) + image = IconUtility.ApplyOverlayImage(package, image, args); + else if (mod.StartsWith("saturation", StringComparison.OrdinalIgnoreCase)) + image = IconUtility.AdjustSaturation(package, image, args); + else if (mod.StartsWith("@disabled", StringComparison.OrdinalIgnoreCase)) + image = IconUtility.ApplyFilterSpecToImage(package, image, Tracker.Instance.DisabledImageFilterSpec); + } + } + } + + return image; + } + } +} diff --git a/EmoTracker/ApplicationModel.cs b/EmoTracker/ApplicationModel.cs index 660c04f..17a8007 100644 --- a/EmoTracker/ApplicationModel.cs +++ b/EmoTracker/ApplicationModel.cs @@ -167,8 +167,21 @@ private void OnHttpImageLoaded(object? sender, EventArgs e) Avalonia.Threading.Dispatcher.UIThread.Post(() => { _httpRefreshScheduled = false; - EmoTracker.UI.Media.ImageReferenceService.Instance.ClearImageCache(); - NotifyPropertyChanged(nameof(AvailablePackagesGroupedView)); + + // Resolve game banner images into ResolvedImage so that + // bindings ({Binding Game.Image.ResolvedImage}) update. + // HTTP images bypass the ImageReferenceService pipeline + // (they download asynchronously into IconUtility.sHttpCache), + // so we bridge the two systems here. + foreach (var game in PackageManager.Instance.AvailableGames) + { + if (game.Image != null && game.Image.ResolvedImage == null) + { + var resolved = EmoTracker.UI.Media.ImageReferenceService.Instance.ResolveImageReference(game.Image); + if (resolved != null) + game.Image.ResolvedImage = resolved; + } + } }, Avalonia.Threading.DispatcherPriority.Background); } @@ -859,7 +872,11 @@ public IEnumerable AvailablePackagesGroupedView var game = PackageManager.Instance.FindGame(e.Game); return game?.Name ?? e.Game; }) - .Select(g => new PackageGroup(g.Key, g)); + .Select(g => + { + var game = PackageManager.Instance.FindGame(g.Key); + return new PackageGroup(g.Key, g, game); + }); } } @@ -939,10 +956,12 @@ public class PackageGroup { public string Name { get; } public IEnumerable Items { get; } - public PackageGroup(string name, IEnumerable items) + public PackageManager.Game Game { get; } + public PackageGroup(string name, IEnumerable items, PackageManager.Game game = null) { Name = name; Items = items; + Game = game; } } diff --git a/EmoTracker/UI/PackageManagerWindow.axaml b/EmoTracker/UI/PackageManagerWindow.axaml index 3bbd7f4..b9c223a 100644 --- a/EmoTracker/UI/PackageManagerWindow.axaml +++ b/EmoTracker/UI/PackageManagerWindow.axaml @@ -171,8 +171,7 @@ + Source="{Binding Game.Image.ResolvedImage, FallbackValue={x:Null}}"/>