fix(images): bound artwork decodes on both axes and never fetch unsized originals

The decode clamp was height-only, so ultra-wide originals could decode
far past the display budget, and two small-slot fallbacks skipped
server-side resizing entirely — handing multi-megapixel originals to
the decoder behind tiny slots. The TV spotlight background's offline
branch had no decode bound at all. All artwork now flows through a
shared both-axes bound (ResizeImagePolicy.fit: aspect-preserving,
scale-down only), and the reduced tier gets matching thumb/poster
decode caps.

Ref #1349
This commit is contained in:
edde746
2026-07-05 09:10:20 +02:00
parent 3981fac0db
commit f97ab03111
4 changed files with 147 additions and 27 deletions
+26 -11
View File
@@ -201,15 +201,10 @@ class MediaImageHelper {
imageType: imageType,
);
// For very small targets, skip server-side resizing — the cost of the
// transcode round-trip outweighs the savings.
if (maxWidth < 80 || maxHeight < 120) {
return client.thumbnailUrl(basePath);
}
if (width <= _minTranscodedWidth * 1.2 && height <= _minTranscodedHeight * 1.2) {
return client.thumbnailUrl(basePath);
}
// Always request a sized transcode — even tiny slots. An unsized URL
// hands the full original to the decoder, and a multi-megapixel
// original behind a 40px avatar is exactly the decode spike that OOMs
// low-RAM devices. The floor is 160×240 via [roundDimensions].
return client.thumbnailUrl(basePath, width: width, height: height);
}
@@ -232,10 +227,13 @@ class MediaImageHelper {
final bucketedHeight = _bucketUp(displayHeight * scaleFactor, _heightRoundingFactor);
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.thumb when DevicePerformance.isReduced => (640, 360),
ImageType.thumb => (960, 540),
// Match the reduced-tier fetch cap so oversized originals (failed
// transcodes, external images) can't decode past the art budget.
ImageType.art when DevicePerformance.isReduced => (_reducedMaxArtWidth, _reducedMaxArtHeight),
ImageType.art => (1920, 1080),
ImageType.logo => (600, 300),
@@ -245,6 +243,23 @@ class MediaImageHelper {
return (bucketedWidth.clamp(120, maxW), bucketedHeight.clamp(180, maxH));
}
/// Wraps [provider] so the decode is bounded on **both** axes.
///
/// `fit` policy keeps aspect ratio and never upscales, so an over-generous
/// bound is harmless — but an oversized original (failed server transcode,
/// local artwork file, ultra-wide banner) can no longer decode past the
/// display budget the way a single-axis bound allows.
static ImageProvider boundedDecode(
ImageProvider provider, {
required int memWidth,
required int memHeight,
}) {
final width = memWidth > 0 ? memWidth : null;
final height = memHeight > 0 ? memHeight : null;
if (width == null && height == null) return provider;
return ResizeImage(provider, width: width, height: height, policy: ResizeImagePolicy.fit);
}
/// Determines if an image path is suitable for transcoding
static bool shouldTranscode(String? imagePath) {
if (imagePath == null || imagePath.isEmpty) return false;
+5 -8
View File
@@ -235,19 +235,16 @@ class OptimizedMediaImage extends StatelessWidget {
final dpr = MediaImageHelper.effectiveDevicePixelRatio(context);
final scaledWidth = effectiveWidth * dpr;
final scaledHeight = effectiveHeight * dpr;
final (_, memHeight) = MediaImageHelper.getMemCacheDimensions(
final (memWidth, memHeight) = MediaImageHelper.getMemCacheDimensions(
displayWidth: scaledWidth.isFinite && scaledWidth > 0 ? scaledWidth.round() : 0,
displayHeight: scaledHeight.isFinite && scaledHeight > 0 ? scaledHeight.round() : 0,
imageType: imageType,
);
return Image.file(
file,
return Image(
image: MediaImageHelper.boundedDecode(FileImage(file), memWidth: memWidth, memHeight: memHeight),
width: width,
height: height,
// Only cacheHeight: leaving cacheWidth null preserves decode aspect
// ratio, mirroring the network branch's ResizeImage wrapper.
cacheHeight: memHeight > 0 ? memHeight : null,
// Artwork is decorative: the enclosing card exposes one merged node
// with the title, and a per-image node just grows the semantics tree
// the TV a11y services make Flutter rebuild every frame.
@@ -296,7 +293,7 @@ class OptimizedMediaImage extends StatelessWidget {
final scaledWidth = effectiveWidth * devicePixelRatio;
final scaledHeight = effectiveHeight * devicePixelRatio;
final (_, memHeight) = MediaImageHelper.getMemCacheDimensions(
final (memWidth, memHeight) = MediaImageHelper.getMemCacheDimensions(
displayWidth: scaledWidth.isFinite && scaledWidth > 0 ? scaledWidth.round() : 0,
displayHeight: scaledHeight.isFinite && scaledHeight > 0 ? scaledHeight.round() : 0,
imageType: imageType,
@@ -311,7 +308,7 @@ class OptimizedMediaImage extends StatelessWidget {
headers: const {'User-Agent': 'Plezy'},
);
final resizedProvider = ResizeImage.resizeIfNeeded(null, memHeight > 0 ? memHeight : null, provider);
final resizedProvider = MediaImageHelper.boundedDecode(provider, memWidth: memWidth, memHeight: memHeight);
// Reduced tier: swap in directly, no fade machinery at all.
if (DevicePerformance.isReduced) {
+11 -8
View File
@@ -144,10 +144,19 @@ class TvSpotlightBackground extends StatelessWidget {
media.backgroundSquarePath,
media.thumbPath,
];
final (memWidth, memHeight) = MediaImageHelper.getMemCacheDimensions(
displayWidth: (size.width * dpr).round(),
displayHeight: (size.height * dpr).round(),
imageType: ImageType.art,
);
for (final candidate in artCandidates) {
final localPath = localArtworkPathResolver?.call(candidate);
if (localPath != null && File(localPath).existsSync()) {
return FileImage(File(localPath));
// Local originals skipped the server transcode entirely, so the
// decode bound is the only thing between a full-resolution art file
// and the GPU on a low-RAM TV.
return MediaImageHelper.boundedDecode(FileImage(File(localPath)), memWidth: memWidth, memHeight: memHeight);
}
}
@@ -164,14 +173,8 @@ class TvSpotlightBackground extends StatelessWidget {
if (imageUrl.isEmpty) return null;
final (_, memHeight) = MediaImageHelper.getMemCacheDimensions(
displayWidth: (size.width * dpr).round(),
displayHeight: (size.height * dpr).round(),
imageType: ImageType.art,
);
final provider = CachedNetworkImageProvider(imageUrl, cacheManager: PlexImageCacheManager.instance);
return ResizeImage.resizeIfNeeded(null, memHeight > 0 ? memHeight : null, provider);
return MediaImageHelper.boundedDecode(provider, memWidth: memWidth, memHeight: memHeight);
}
Widget _buildHorizontalScrim(Color bgColor) {
+105
View File
@@ -1,6 +1,19 @@
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/media/media_server_client.dart';
import 'package:plezy/services/device_performance.dart';
import 'package:plezy/utils/media_image_helper.dart';
/// Only [thumbnailUrl] is exercised; everything else throws via noSuchMethod.
class _SizedUrlFakeClient implements MediaServerClient {
@override
String thumbnailUrl(String? path, {int? width, int? height}) =>
(width == null && height == null) ? 'unsized:$path' : 'sized:$path?w=$width&h=$height';
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
void main() {
group('MediaImageHelper.getOptimizedImageUrl', () {
test('adds size hints to absolute Jellyfin artwork URLs', () {
@@ -59,4 +72,96 @@ void main() {
expect(url, original);
});
});
group('MediaImageHelper.getOptimizedImageUrl sized transcodes', () {
// Unsized URLs hand the full original to the decoder — a multi-megapixel
// original behind a tiny slot is the decode spike that OOMs low-RAM
// devices, so every card-sized request must carry dimensions.
final client = _SizedUrlFakeClient();
test('tiny slots still request a sized transcode (min bucket)', () {
final url = MediaImageHelper.getOptimizedImageUrl(
client: client,
thumbPath: '/library/metadata/1/thumb/2',
maxWidth: 40,
maxHeight: 60,
devicePixelRatio: 1,
);
expect(url, startsWith('sized:'));
expect(url, contains('w=160'));
expect(url, contains('h=240'));
});
test('near-minimum slots request a sized transcode', () {
final url = MediaImageHelper.getOptimizedImageUrl(
client: client,
thumbPath: '/library/metadata/1/thumb/2',
maxWidth: 96,
maxHeight: 144,
devicePixelRatio: 1,
);
expect(url, startsWith('sized:'));
});
test('regular slots request DPR-scaled dimensions', () {
final url = MediaImageHelper.getOptimizedImageUrl(
client: client,
thumbPath: '/library/metadata/1/thumb/2',
maxWidth: 200,
maxHeight: 300,
devicePixelRatio: 2,
);
expect(url, 'sized:/library/metadata/1/thumb/2?w=400&h=600');
});
});
group('MediaImageHelper.getMemCacheDimensions tier caps', () {
tearDown(DevicePerformance.debugReset);
test('full tier caps thumb and poster decodes', () {
DevicePerformance.debugReset(autoReduced: false, override: VisualEffectsSetting.auto);
expect(
MediaImageHelper.getMemCacheDimensions(displayWidth: 4000, displayHeight: 4000, imageType: ImageType.thumb),
(960, 540),
);
expect(
MediaImageHelper.getMemCacheDimensions(displayWidth: 4000, displayHeight: 4000, imageType: ImageType.poster),
(720, 1080),
);
});
test('reduced tier tightens thumb and poster caps', () {
DevicePerformance.debugReset(autoReduced: true, override: VisualEffectsSetting.auto);
expect(
MediaImageHelper.getMemCacheDimensions(displayWidth: 4000, displayHeight: 4000, imageType: ImageType.thumb),
(640, 360),
);
expect(
MediaImageHelper.getMemCacheDimensions(displayWidth: 4000, displayHeight: 4000, imageType: ImageType.poster),
(480, 720),
);
});
});
group('MediaImageHelper.boundedDecode', () {
test('bounds both axes with fit policy (no distortion, no upscale)', () {
const base = NetworkImage('https://example/img');
final bounded = MediaImageHelper.boundedDecode(base, memWidth: 640, memHeight: 360);
expect(bounded, isA<ResizeImage>());
final resize = bounded as ResizeImage;
expect(resize.width, 640);
expect(resize.height, 360);
expect(resize.policy, ResizeImagePolicy.fit);
expect(resize.allowUpscaling, isFalse);
});
test('passes the provider through when no bound is known', () {
const base = NetworkImage('https://example/img');
expect(MediaImageHelper.boundedDecode(base, memWidth: 0, memHeight: 0), same(base));
});
});
}