Compare commits

...

6 Commits

4 changed files with 140 additions and 10 deletions

View File

@@ -20,6 +20,7 @@ class LocalImageApiImpl: LocalImageApi {
requestOptions.version = .current
return requestOptions
}()
private static let maxPixelSize: CGFloat = 16384
private static let registry = RequestRegistry<ImageRequest>()
@@ -108,11 +109,16 @@ class LocalImageApiImpl: LocalImageApi {
]))
}
let isOriginal = !(width > 0 && height > 0)
let targetSize = isOriginal
? CGSize(width: Self.maxPixelSize, height: Self.maxPixelSize)
: CGSize(width: Double(width), height: Double(height))
let contentMode: PHImageContentMode = isOriginal ? .aspectFit : .aspectFill
var image: UIImage?
Self.imageManager.requestImage(
for: asset,
targetSize: width > 0 && height > 0 ? CGSize(width: Double(width), height: Double(height)) : PHImageManagerMaximumSize,
contentMode: .aspectFill,
targetSize: targetSize,
contentMode: contentMode,
options: Self.requestOptions,
resultHandler: { (_image, info) -> Void in
image = _image
@@ -123,12 +129,38 @@ class LocalImageApiImpl: LocalImageApi {
return request.completion(ImageProcessing.cancelledResult)
}
guard let image = image,
let cgImage = image.cgImage else {
guard let fastImage = image,
var cgImage = fastImage.cgImage else {
Self.registry.remove(requestId: requestId)
return request.completion(.failure(PigeonError(code: "", message: "Could not get pixel data for \(assetId)", details: nil)))
}
// .fast can return larger than the target, so retry with .exact to guarantee the bound.
if max(cgImage.width, cgImage.height) > Int(Self.maxPixelSize) {
let exactOptions = Self.requestOptions.copy() as! PHImageRequestOptions
exactOptions.resizeMode = .exact
image = nil
Self.imageManager.requestImage(
for: asset,
targetSize: targetSize,
contentMode: contentMode,
options: exactOptions,
resultHandler: { (_image, info) -> Void in
image = _image
}
)
if request.isCancelled {
return request.completion(ImageProcessing.cancelledResult)
}
guard let exactImage = image?.cgImage else {
Self.registry.remove(requestId: requestId)
return request.completion(.failure(PigeonError(code: "", message: "Could not resize image for \(assetId)", details: nil)))
}
cgImage = exactImage
}
if request.isCancelled {
return request.completion(ImageProcessing.cancelledResult)
}

View File

@@ -159,7 +159,14 @@ ImageProvider getFullImageProvider(
provider = FileImage(File(localFilePath));
} else if (_shouldUseLocalAsset(asset)) {
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!;
provider = LocalFullImageProvider(id: id, size: size, assetType: asset.type, isAnimated: asset.isAnimatedImage);
provider = LocalFullImageProvider(
id: id,
size: size,
assetType: asset.type,
isAnimated: asset.isAnimatedImage,
width: asset.width,
height: asset.height,
);
} else {
final String assetId;
final String thumbhash;

View File

@@ -1,3 +1,5 @@
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
@@ -8,6 +10,9 @@ 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';
// iOS GPU textures max out at 16384px; larger images squish.
const _kMaxPixelSize = 16384;
class LocalThumbProvider extends CancellableImageProvider<LocalThumbProvider>
with CancellableImageProviderMixin<LocalThumbProvider> {
final String id;
@@ -59,8 +64,36 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
final Size size;
final AssetType assetType;
final bool isAnimated;
final int? width;
final int? height;
LocalFullImageProvider({required this.id, required this.assetType, required this.size, required this.isAnimated});
LocalFullImageProvider({
required this.id,
required this.assetType,
required this.size,
required this.isAnimated,
this.width,
this.height,
});
Size _previewTarget(double dpr, bool previewIsFinal) =>
previewTargetSize(size.width * dpr, size.height * dpr, width, height, previewIsFinal: previewIsFinal);
// 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) {
return Size(boxW, boxH);
}
final imgLong = math.max(width, height).toDouble();
final coverLong = imgLong * math.max(boxW / width, boxH / height);
if (coverLong <= _kMaxPixelSize) {
return Size(boxW, boxH);
}
final bound = previewIsFinal ? _kMaxPixelSize.toDouble() : math.max(boxW, boxH);
final scale = math.min(1.0, bound / imgLong);
return Size(math.max(1.0, width * scale), math.max(1.0, height * scale));
}
@override
Future<LocalFullImageProvider> obtainKey(ImageConfiguration configuration) {
@@ -108,7 +141,7 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
final devicePixelRatio = PlatformDispatcher.instance.views.first.devicePixelRatio;
var request = this.request = LocalImageRequest(
localId: key.id,
size: Size(size.width * devicePixelRatio, size.height * devicePixelRatio),
size: _previewTarget(devicePixelRatio, !loadOriginal),
assetType: key.assetType,
);
yield* loadRequest(request, decode, isFinal: !loadOriginal);
@@ -136,7 +169,7 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
final devicePixelRatio = PlatformDispatcher.instance.views.first.devicePixelRatio;
final previewRequest = request = LocalImageRequest(
localId: key.id,
size: Size(size.width * devicePixelRatio, size.height * devicePixelRatio),
size: _previewTarget(devicePixelRatio, false),
assetType: key.assetType,
);
yield* loadRequest(previewRequest, decode, isFinal: false);
@@ -163,11 +196,15 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
return true;
}
if (other is LocalFullImageProvider) {
return id == other.id && size == other.size && isAnimated == other.isAnimated;
return id == other.id &&
size == other.size &&
isAnimated == other.isAnimated &&
width == other.width &&
height == other.height;
}
return false;
}
@override
int get hashCode => id.hashCode ^ size.hashCode ^ isAnimated.hashCode;
int get hashCode => id.hashCode ^ size.hashCode ^ isAnimated.hashCode ^ width.hashCode ^ height.hashCode;
}

View File

@@ -0,0 +1,54 @@
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/presentation/widgets/images/local_image_provider.dart';
void main() {
group('LocalFullImageProvider.previewTargetSize', () {
const box = Size(1179, 2556);
const cases = <(String, Size, int?, int?, bool, Size)>[
('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)),
];
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', () {
LocalFullImageProvider make({int? width, int? height}) => LocalFullImageProvider(
id: 'a',
assetType: AssetType.image,
size: const Size(100, 200),
isAnimated: false,
width: width,
height: height,
);
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);
});
});
}