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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user