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.
This commit is contained in:
edde746
2026-07-30 01:40:24 +02:00
parent 1bf7aac75b
commit f13f5af6e2
6 changed files with 229 additions and 13 deletions
+5
View File
@@ -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<void> _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');
}
+1
View File
@@ -127,6 +127,7 @@ class _LogsScreenState extends State<LogsScreen> with MountedSetStateMixin {
}
buffer.writeln('Effects: ${DevicePerformance.describeSync()}');
buffer.writeln('Display: ${DevicePerformance.describeDisplay()}');
setStateIfMounted(() => _deviceInfo = buffer.toString().trimRight());
}
+88 -4
View File
@@ -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.132× (#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;
}
}
+19 -9
View File
@@ -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));
@@ -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);
});
});
}
+55
View File
@@ -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);