From f97ab0311164e6e8695fa03d8510daceaf119b6e Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sun, 5 Jul 2026 09:10:20 +0200 Subject: [PATCH] fix(images): bound artwork decodes on both axes and never fetch unsized originals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- lib/utils/media_image_helper.dart | 37 +++++--- lib/widgets/optimized_media_image.dart | 13 ++- lib/widgets/tv_spotlight_background.dart | 19 ++-- test/utils/media_image_helper_test.dart | 105 +++++++++++++++++++++++ 4 files changed, 147 insertions(+), 27 deletions(-) diff --git a/lib/utils/media_image_helper.dart b/lib/utils/media_image_helper.dart index 6272eb91..82dbefc7 100644 --- a/lib/utils/media_image_helper.dart +++ b/lib/utils/media_image_helper.dart @@ -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; diff --git a/lib/widgets/optimized_media_image.dart b/lib/widgets/optimized_media_image.dart index 642328c9..3cdf4ab8 100644 --- a/lib/widgets/optimized_media_image.dart +++ b/lib/widgets/optimized_media_image.dart @@ -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) { diff --git a/lib/widgets/tv_spotlight_background.dart b/lib/widgets/tv_spotlight_background.dart index ec0f4792..d81c7759 100644 --- a/lib/widgets/tv_spotlight_background.dart +++ b/lib/widgets/tv_spotlight_background.dart @@ -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) { diff --git a/test/utils/media_image_helper_test.dart b/test/utils/media_image_helper_test.dart index bc73ecc0..6526af42 100644 --- a/test/utils/media_image_helper_test.dart +++ b/test/utils/media_image_helper_test.dart @@ -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()); + 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)); + }); + }); }