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