diff --git a/mobile/lib/presentation/widgets/images/local_image_provider.dart b/mobile/lib/presentation/widgets/images/local_image_provider.dart index 8e53f5afae..806d59fb8e 100644 --- a/mobile/lib/presentation/widgets/images/local_image_provider.dart +++ b/mobile/lib/presentation/widgets/images/local_image_provider.dart @@ -10,8 +10,8 @@ import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; import 'package:immich_mobile/presentation/widgets/images/one_frame_multi_image_stream_completer.dart'; import 'package:immich_mobile/presentation/widgets/timeline/constants.dart'; -// iphone's max gpu texture size. images longer than this squish in the preview. -const _kMaxTextureSize = 16384; +// iOS GPU textures max out at 16384px; larger images squish. +const _kMaxPixelSize = 16384; class LocalThumbProvider extends CancellableImageProvider with CancellableImageProviderMixin { @@ -79,7 +79,7 @@ class LocalFullImageProvider extends CancellableImageProvider previewTargetSize(size.width * dpr, size.height * dpr, width, height, previewIsFinal: previewIsFinal); - // long images squish on aspectFill; use an aspect-correct target (full detail if final, else light). + // Use an aspect-correct target when aspectFill would exceed the texture limit. @visibleForTesting static Size previewTargetSize(double boxW, double boxH, int? width, int? height, {required bool previewIsFinal}) { if (width == null || height == null || width <= 0 || height <= 0) { @@ -87,12 +87,12 @@ class LocalFullImageProvider extends CancellableImageProvider[ + ('normal', box, 4032, 3024, true, box), + ('missing dimensions', box, null, null, true, box), + ('invalid dimensions', box, 1000, 0, true, box), + ('long final preview', box, 1000, 30000, true, Size(16384 / 30, 16384)), + ('long first preview', box, 30000, 1000, false, Size(2556, 2556 / 30)), + ('ultra-thin preview', box, 10, 50000, false, Size(1, 2556)), + ('small source', box, 50, 2000, true, Size(50, 2000)), + ('at limit', Size(16384, 100), 1000, 1000, true, Size(16384, 100)), + ('over limit', Size(16385, 100), 1000, 1000, true, Size(1000, 1000)), + ]; - test('normal landscape keeps the exact old box (no preview regression)', () { - // 4:3, cover long edge ~3407 < 16384 -> not gated, regardless of previewIsFinal. - expect(LocalFullImageProvider.previewTargetSize(boxW, boxH, 4032, 3024, previewIsFinal: true), oldBox); - expect(LocalFullImageProvider.previewTargetSize(boxW, boxH, 4032, 3024, previewIsFinal: false), oldBox); - }); - - test('normal portrait keeps the exact old box', () { - expect(LocalFullImageProvider.previewTargetSize(boxW, boxH, 3024, 4032, previewIsFinal: true), oldBox); - }); - - test('null or zero dims fall back to the old box', () { - expect(LocalFullImageProvider.previewTargetSize(boxW, boxH, null, null, previewIsFinal: true), oldBox); - expect(LocalFullImageProvider.previewTargetSize(boxW, boxH, 0, 0, previewIsFinal: true), oldBox); - expect(LocalFullImageProvider.previewTargetSize(boxW, boxH, 1000, 0, previewIsFinal: true), oldBox); - }); - - test('extreme panorama, FINAL preview (load-original off): capped at the texture limit, ratio kept', () { - final t = LocalFullImageProvider.previewTargetSize(boxW, boxH, 1000, 30000, previewIsFinal: true); - expect(t, isNot(oldBox)); - expect(t.longestSide, closeTo(16384, 1)); - expect(t.width / t.height, closeTo(1000 / 30000, 1e-6)); - }); - - test('extreme panorama, NON-final preview (load-original on): light, capped at screen long edge, ratio kept', () { - final t = LocalFullImageProvider.previewTargetSize(boxW, boxH, 1000, 30000, previewIsFinal: false); - expect(t, isNot(oldBox)); - expect(t.longestSide, closeTo(boxH, 1)); // screen long edge = max(boxW, boxH), the original follows at 16384 - expect(t.width / t.height, closeTo(1000 / 30000, 1e-6)); - }); - - test('extreme but small source is never upscaled', () { - // ratio 40 -> gated, but the source long edge (2000) < texture limit so scale clamps to 1.0. - expect( - LocalFullImageProvider.previewTargetSize(boxW, boxH, 50, 2000, previewIsFinal: true), - const Size(50, 2000), - ); - }); - - test('narrow image is gated in but not upscaled (long edge already under the texture limit)', () { - // ratio 100: cover overshoots 16384 so it's gated, but the source long edge (10000) < 16384 -> scale clamps to 1.0. - expect( - LocalFullImageProvider.previewTargetSize(boxW, boxH, 100, 10000, previewIsFinal: true), - const Size(100, 10000), - ); - }); - - test('gate boundary: at the texture limit keeps the old box, just over switches to the fix', () { - // square source -> coverLong == the box long edge. - expect( - LocalFullImageProvider.previewTargetSize(16384, 100, 1000, 1000, previewIsFinal: true), - const Size(16384, 100), - ); - final over = LocalFullImageProvider.previewTargetSize(16385, 100, 1000, 1000, previewIsFinal: true); - expect(over, isNot(const Size(16385, 100))); - expect(over, const Size(1000, 1000)); - }); + for (final (name, box, width, height, previewIsFinal, expected) in cases) { + test(name, () { + final actual = LocalFullImageProvider.previewTargetSize( + box.width, + box.height, + width, + height, + previewIsFinal: previewIsFinal, + ); + expect(actual.width, closeTo(expected.width, 1e-6)); + expect(actual.height, closeTo(expected.height, 1e-6)); + }); + } }); group('LocalFullImageProvider equality', () { @@ -78,15 +43,12 @@ void main() { height: height, ); - test('same id/size/isAnimated but different w/h are not equal (distinct cache entries)', () { - expect(make(width: 1000, height: 30000) == make(width: 4032, height: 3024), isFalse); - }); - - test('identical config is equal and shares a hashCode', () { + test('uses dimensions in the cache key', () { final a = make(width: 100, height: 200); final b = make(width: 100, height: 200); expect(a, b); expect(a.hashCode, b.hashCode); + expect(a == make(width: 200, height: 100), isFalse); }); }); }