From f13f5af6e20b2c748aaf2f1b4b3a11cbd625ceef Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Thu, 30 Jul 2026 01:36:19 +0200 Subject: [PATCH] fix(images): scale artwork budgets to the physical display Every artwork budget in the image pipeline was tuned for 1080p surfaces: the transcode request clamp (1920x1080), the per-type decode caps (poster 720x1080, thumb 960x540, heroLogo 1000x500, ...) and the TV image-cache bytes. Those numbers are exact on phones and on the many TV boxes that composite the app at 1080p, but a TV compositing at 4K renders every capped image below its slot and GPU-upscales the result: hero backdrops by 2x, hero logos by ~1.8x, wide episode thumbs by ~1.3x, shelf posters by ~1.13x - the softness reported against the official Plex client in #1697, and the class #860's min-2x-DPR fix could not reach. DevicePerformance now latches a display budget factor - the display's shortest physical axis over 1080, capped at 2x - whenever the image cache budget is applied (startup, post-mount, effects-setting changes). The transcode clamp, the full-tier decode caps and the TV cache bytes all scale by it, so a 4K surface fetches and decodes 4K backdrops and proportionally larger cards. The reduced tier stays pinned to 1.0, and sub-2.5GiB hardware holds the factor at 1.5 so full-budget 4K art (~33MB per decode) cannot starve mid-RAM boxes; latching once per session keeps transcode URLs - and with them the disk cache keys - stable across rotation and rebuilds. Whether a given TV composites at 1080p or 4K decides whether any of this can help, and logs never recorded it: the startup banner and the log-upload header now carry a display line (physical, logical, DPR, latched budget) so uploaded logs answer that question directly. The two pre-existing Windows-host test failures (automotive auto-PiP gate, backdrop temp-dir teardown lock) reproduce unchanged on the base commit. --- lib/main.dart | 5 ++ lib/screens/settings/logs_screen.dart | 1 + lib/services/device_performance.dart | 92 +++++++++++++++++++++- lib/utils/media_image_helper.dart | 28 ++++--- test/services/device_performance_test.dart | 61 ++++++++++++++ test/utils/media_image_helper_test.dart | 55 +++++++++++++ 6 files changed, 229 insertions(+), 13 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index 4ecc1a78..1dc0f348 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -499,6 +499,10 @@ void _startNonessentialInitialization(SettingsService settings) { bestEffort('Trakt scrobble', TraktScrobbleService.instance.initialize); bestEffort('Shader licenses', _registerShaderLicenses); + // The startup-gate application can precede the engine's first metrics + // report, which reads as a 1.0 display budget; re-derive it now that the + // tree is mounted and the display is known. + bestEffort('Image cache budget', DevicePerformance.applyImageCacheBudget); bestEffort('Environment diagnostics', _logEnvironmentDiagnostics); } @@ -515,6 +519,7 @@ Future _logEnvironmentDiagnostics() async { 'Plezy v${packageInfo.version}+${packageInfo.buildNumber}$commitSuffix$renderer' ' [effects: ${DevicePerformance.describeSync()}]', ); + appLogger.i('Display: ${DevicePerformance.describeDisplay()}'); if (Platform.isAndroid) { appLogger.i('Startup RSS: ${ProcessInfo.currentRss >> 20}MB'); } diff --git a/lib/screens/settings/logs_screen.dart b/lib/screens/settings/logs_screen.dart index d04b5942..4290cc21 100644 --- a/lib/screens/settings/logs_screen.dart +++ b/lib/screens/settings/logs_screen.dart @@ -127,6 +127,7 @@ class _LogsScreenState extends State with MountedSetStateMixin { } buffer.writeln('Effects: ${DevicePerformance.describeSync()}'); + buffer.writeln('Display: ${DevicePerformance.describeDisplay()}'); setStateIfMounted(() => _deviceInfo = buffer.toString().trimRight()); } diff --git a/lib/services/device_performance.dart b/lib/services/device_performance.dart index 3c5a4304..dede7bac 100644 --- a/lib/services/device_performance.dart +++ b/lib/services/device_performance.dart @@ -1,4 +1,5 @@ import 'dart:io'; +import 'dart:math' as math; import 'package:flutter/foundation.dart'; import 'package:flutter/painting.dart'; @@ -87,6 +88,57 @@ class DevicePerformance { /// [full] on the full tier, [Duration.zero] on the reduced tier. static Duration reducedDuration(Duration full) => isReduced ? Duration.zero : full; + /// ~2.5 GiB: below what 3 GB Shield-class devices report (~2.8 GiB) so they + /// keep the full display budget, above the 2.2 GiB reduced-tier threshold. + static const int _fullDisplayBudgetMemBytes = 2560 << 20; + + static double _displayBudgetFactor = 1.0; + + @visibleForTesting + static double? debugDisplayShortestSideOverride; + + /// Scales the artwork pixel budgets (transcode size clamp, decode caps, + /// image-cache bytes) to the physical display. The 1080p-tuned budgets are + /// exact on phones and 1080p-surface TVs, but a TV compositing the app at + /// 4K re-upscales every capped image by 1.13–2× (#1697), so denser displays + /// raise the budgets proportionally, up to 2× on a 4K surface. + /// + /// Returns the value latched by [applyImageCacheBudget] — image callsites + /// must never probe the display per call, both because URL cache keys + /// derived from the budget have to stay stable for the whole session and + /// because the engine reports no metrics during early startup. + static double displayBudgetFactor() => isReduced ? 1.0 : _displayBudgetFactor; + + /// Derives the display budget from the display's shortest physical axis + /// (orientation-stable, unlike its width). Keeps the previous value while + /// the engine has not reported metrics yet, so the pre-first-frame + /// [applyImageCacheBudget] call cannot latch a false 1.0 for the session. + /// + /// Held at 1.5 on sub-2.5 GiB hardware: full-budget 4K art decodes at + /// ~33 MB per image, which mid-RAM boxes can't spare while 4K video decode + /// buffers are alive. + static void _detectDisplayBudget() { + final shortestSide = debugDisplayShortestSideOverride ?? _displayShortestSide(); + if (shortestSide == null || shortestSide <= 0) return; + var factor = math.min(shortestSide / 1080, 2.0); + final mem = totalMemBytes; + if (mem != null && mem < _fullDisplayBudgetMemBytes) factor = math.min(factor, 1.5); + _displayBudgetFactor = math.max(factor, 1.0); + } + + static double? _displayShortestSide() { + try { + return PlatformDispatcher.instance.implicitView?.display.size.shortestSide; + } catch (_) { + return null; + } + } + + /// Test-only: run the latch that [applyImageCacheBudget] performs without + /// requiring a painting binding. + @visibleForTesting + static void debugDetectDisplayBudget() => _detectDisplayBudget(); + /// Update the user override from the settings screen and re-apply the /// budgets that were computed at boot. static void setOverrideSync(VisualEffectsSetting value) { @@ -97,6 +149,7 @@ class DevicePerformance { /// Flutter image-cache budget per platform/tier — kept modest to leave /// headroom for Skia decode buffers. static void applyImageCacheBudget() { + _detectDisplayBudget(); final cache = PaintingBinding.instance.imageCache; if (PlatformDetector.isDesktopOS()) { cache.maximumSize = 1000; @@ -105,15 +158,43 @@ class DevicePerformance { cache.maximumSize = 400; cache.maximumSizeBytes = 48 << 20; // 48MB } else if (PlatformDetector.isTV()) { - // TV boxes share limited RAM with 4K video decode buffers. + // TV boxes share limited RAM with 4K video decode buffers. The byte + // budget follows the display budget: 4K-surface artwork carries up to + // 2× the pixels per entry (64MB baseline → 128MB at 4K). cache.maximumSize = 500; - cache.maximumSizeBytes = 64 << 20; // 64MB + cache.maximumSizeBytes = ((64 << 20) * displayBudgetFactor()).round(); } else { cache.maximumSize = 800; cache.maximumSizeBytes = 100 << 20; // 100MB } } + /// One-line display summary for the startup log and bug-report headers, + /// e.g. `3840x2160 physical, 960x540 logical @ 4.00x (budget 2.0x)`. + /// + /// This is what tells a 1080p-composited TV apart from a true-4K surface + /// when a user reports soft artwork on a 4K panel: on the former nothing + /// app-side can add sharpness, on the latter the display budget must have + /// engaged. + static String describeDisplay() { + final view = PlatformDispatcher.instance.implicitView; + if (view == null) return 'unknown'; + final physical = view.physicalSize; + final dpr = view.devicePixelRatio; + final logicalWidth = dpr > 0 ? physical.width / dpr : 0; + final logicalHeight = dpr > 0 ? physical.height / dpr : 0; + final display = view.display.size; + final buffer = StringBuffer( + '${physical.width.round()}x${physical.height.round()} physical, ' + '${logicalWidth.round()}x${logicalHeight.round()} logical @ ${dpr.toStringAsFixed(2)}x', + ); + if ((display.width - physical.width).abs() > 1 || (display.height - physical.height).abs() > 1) { + buffer.write(', display ${display.width.round()}x${display.height.round()}'); + } + buffer.write(' (budget ${displayBudgetFactor().toStringAsFixed(1)}x)'); + return buffer.toString(); + } + /// One-line tier summary for the startup log and bug-report headers, e.g. /// `reduced (auto: 32-bit, lowRam, 1.9GiB)` or `full (forced; hw: 64-bit, 2.8GiB)`. /// @@ -135,8 +216,10 @@ class DevicePerformance { } @visibleForTesting - static void debugReset({bool? autoReduced, VisualEffectsSetting? override}) { - if (autoReduced == null && override == null) { + static void debugReset({bool? autoReduced, VisualEffectsSetting? override, int? totalMemBytes}) { + _displayBudgetFactor = 1.0; + debugDisplayShortestSideOverride = null; + if (autoReduced == null && override == null && totalMemBytes == null) { _singleton.debugReset(); return; } @@ -144,5 +227,6 @@ class DevicePerformance { _singleton.debugReset(instance: instance); if (autoReduced != null) instance._autoReduced = autoReduced; if (override != null) instance._override = override; + if (totalMemBytes != null) instance._totalMemBytes = totalMemBytes; } } diff --git a/lib/utils/media_image_helper.dart b/lib/utils/media_image_helper.dart index aa056548..20a688a2 100644 --- a/lib/utils/media_image_helper.dart +++ b/lib/utils/media_image_helper.dart @@ -43,6 +43,8 @@ class MediaImageHelper { static const int _widthRoundingFactor = 40; static const int _heightRoundingFactor = 60; + /// 1080p baseline; scaled by [DevicePerformance.displayBudgetFactor] so + /// 4K-surface displays can fetch up to 3840×2160 instead of upscaling. static const int _maxTranscodedWidth = 1920; static const int _maxTranscodedHeight = 1080; @@ -66,9 +68,10 @@ class MediaImageHelper { /// Rounds dimensions to cache-friendly values to increase cache hit rate static (int width, int height) roundDimensions(double width, double height) { + final budget = DevicePerformance.displayBudgetFactor(); return ( - _bucketUp(width, _widthRoundingFactor).clamp(_minTranscodedWidth, _maxTranscodedWidth), - _bucketUp(height, _heightRoundingFactor).clamp(_minTranscodedHeight, _maxTranscodedHeight), + _bucketUp(width, _widthRoundingFactor).clamp(_minTranscodedWidth, (_maxTranscodedWidth * budget).round()), + _bucketUp(height, _heightRoundingFactor).clamp(_minTranscodedHeight, (_maxTranscodedHeight * budget).round()), ); } @@ -248,23 +251,30 @@ class MediaImageHelper { final bucketedWidth = _bucketUp(displayWidth * scaleFactor, _widthRoundingFactor); final bucketedHeight = _bucketUp(displayHeight * scaleFactor, _heightRoundingFactor); + // Full-tier caps are a 1080p baseline scaled to the display, so slots on + // a 4K surface decode at the resolution they render at instead of being + // GPU-upscaled from phone-sized budgets. Reduced-tier caps stay fixed + // (the factor is pinned to 1.0 there, and the explicit pairs keep the + // low-RAM budget independent of display probing). + final budget = DevicePerformance.displayBudgetFactor(); + int scaled(int cap) => (cap * budget).round(); final (int maxW, int maxH) = switch (imageType) { // Reduced-tier caps match the smaller fetch sizes so oversized // originals (failed transcodes, external images) can't decode past // the tile budget on low-RAM hardware. ImageType.poster when DevicePerformance.isReduced => (480, 720), - ImageType.poster => (720, 1080), + ImageType.poster => (scaled(720), scaled(1080)), // Square music artwork fills the same grid cells as posters, so both // axes cap at the poster width budget. ImageType.square when DevicePerformance.isReduced => (480, 480), - ImageType.square => (720, 720), + ImageType.square => (scaled(720), scaled(720)), ImageType.thumb when DevicePerformance.isReduced => (640, 360), - ImageType.thumb => (960, 540), + ImageType.thumb => (scaled(960), scaled(540)), ImageType.art when DevicePerformance.isReduced => (_reducedMaxArtWidth, _reducedMaxArtHeight), - ImageType.art => (1920, 1080), - ImageType.logo => (600, 300), - ImageType.heroLogo => (1000, 500), - ImageType.avatar => (300, 300), + ImageType.art => (scaled(1920), scaled(1080)), + ImageType.logo => (scaled(600), scaled(300)), + ImageType.heroLogo => (scaled(1000), scaled(500)), + ImageType.avatar => (scaled(300), scaled(300)), }; return (bucketedWidth.clamp(120, maxW), bucketedHeight.clamp(180, maxH)); diff --git a/test/services/device_performance_test.dart b/test/services/device_performance_test.dart index 2557ea91..5dbdf0f0 100644 --- a/test/services/device_performance_test.dart +++ b/test/services/device_performance_test.dart @@ -38,4 +38,65 @@ void main() { final recovered = await DevicePerformance.getInstance(); expect(recovered, isNotNull); }); + + group('displayBudgetFactor', () { + void detectAt(double shortestSide) { + DevicePerformance.debugDisplayShortestSideOverride = shortestSide; + DevicePerformance.debugDetectDisplayBudget(); + } + + test('stays 1.0 until a latch runs', () { + DevicePerformance.debugReset(autoReduced: false, override: VisualEffectsSetting.auto); + expect(DevicePerformance.displayBudgetFactor(), 1.0); + }); + + test('scales with the display shortest side up to 2x', () { + DevicePerformance.debugReset(autoReduced: false, override: VisualEffectsSetting.auto); + + detectAt(1080); + expect(DevicePerformance.displayBudgetFactor(), 1.0); + + detectAt(1440); + expect(DevicePerformance.displayBudgetFactor(), closeTo(1440 / 1080, 0.001)); + + detectAt(2160); + expect(DevicePerformance.displayBudgetFactor(), 2.0); + + // 8K stays at the 2x ceiling. + detectAt(4320); + expect(DevicePerformance.displayBudgetFactor(), 2.0); + }); + + test('sub-1080p displays never shrink the budget below 1.0', () { + DevicePerformance.debugReset(autoReduced: false, override: VisualEffectsSetting.auto); + detectAt(720); + expect(DevicePerformance.displayBudgetFactor(), 1.0); + }); + + test('mid-RAM hardware holds a 4K budget at 1.5x', () { + DevicePerformance.debugReset( + autoReduced: false, + override: VisualEffectsSetting.auto, + totalMemBytes: 2400 << 20, + ); + detectAt(2160); + expect(DevicePerformance.displayBudgetFactor(), 1.5); + }); + + test('high-RAM hardware keeps the full 4K budget', () { + DevicePerformance.debugReset( + autoReduced: false, + override: VisualEffectsSetting.auto, + totalMemBytes: 2870 << 20, + ); + detectAt(2160); + expect(DevicePerformance.displayBudgetFactor(), 2.0); + }); + + test('reduced tier pins the budget to 1.0 even after a 4K latch', () { + DevicePerformance.debugReset(autoReduced: true, override: VisualEffectsSetting.auto); + detectAt(2160); + expect(DevicePerformance.displayBudgetFactor(), 1.0); + }); + }); } diff --git a/test/utils/media_image_helper_test.dart b/test/utils/media_image_helper_test.dart index be9d188a..62e04a52 100644 --- a/test/utils/media_image_helper_test.dart +++ b/test/utils/media_image_helper_test.dart @@ -174,6 +174,61 @@ void main() { }); }); + group('MediaImageHelper display budget scaling (#1697)', () { + tearDown(DevicePerformance.debugReset); + + void latch4kBudget() { + DevicePerformance.debugReset(autoReduced: false, override: VisualEffectsSetting.auto); + DevicePerformance.debugDisplayShortestSideOverride = 2160; + DevicePerformance.debugDetectDisplayBudget(); + } + + test('a 4K display doubles the full-tier decode caps', () { + latch4kBudget(); + expect( + MediaImageHelper.getMemCacheDimensions(displayWidth: 4000, displayHeight: 4000, imageType: ImageType.poster), + (1440, 2160), + ); + expect( + MediaImageHelper.getMemCacheDimensions(displayWidth: 4000, displayHeight: 4000, imageType: ImageType.thumb), + (1920, 1080), + ); + expect( + MediaImageHelper.getMemCacheDimensions(displayWidth: 4000, displayHeight: 4000, imageType: ImageType.art), + (3840, 2160), + ); + expect( + MediaImageHelper.getMemCacheDimensions(displayWidth: 4000, displayHeight: 4000, imageType: ImageType.heroLogo), + (2000, 1000), + ); + }); + + test('a 4K display raises the transcode clamp to the panel size', () { + latch4kBudget(); + // A full-screen 4K backdrop request no longer clamps to 1080p... + expect(MediaImageHelper.roundDimensions(3840, 2160), (3840, 2160)); + // ...while sub-cap requests keep their exact buckets. + expect(MediaImageHelper.roundDimensions(400, 600), (400, 600)); + }); + + test('a 4K display leaves the reduced tier untouched', () { + DevicePerformance.debugReset(autoReduced: true, override: VisualEffectsSetting.auto); + DevicePerformance.debugDisplayShortestSideOverride = 2160; + DevicePerformance.debugDetectDisplayBudget(); + + expect( + MediaImageHelper.getMemCacheDimensions(displayWidth: 4000, displayHeight: 4000, imageType: ImageType.poster), + (480, 720), + ); + expect(MediaImageHelper.roundDimensions(3840, 2160), (1920, 1080)); + }); + + test('without a latch the 1080p clamps still apply', () { + DevicePerformance.debugReset(autoReduced: false, override: VisualEffectsSetting.auto); + expect(MediaImageHelper.roundDimensions(3840, 2160), (1920, 1080)); + }); + }); + group('MediaImageHelper image type budgets', () { tearDown(DevicePerformance.debugReset);