mirror of
https://github.com/immich-app/immich.git
synced 2026-07-02 02:55:01 -07:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 329e1424c6 |
@@ -20,7 +20,6 @@ class LocalImageApiImpl: LocalImageApi {
|
||||
requestOptions.version = .current
|
||||
return requestOptions
|
||||
}()
|
||||
private static let maxOriginalPixelSize: CGFloat = 16384
|
||||
|
||||
private static let registry = RequestRegistry<ImageRequest>()
|
||||
|
||||
@@ -109,18 +108,11 @@ class LocalImageApiImpl: LocalImageApi {
|
||||
]))
|
||||
}
|
||||
|
||||
// PHImageManagerMaximumSize returns a distorted aspect ratio for images too large to render
|
||||
// (extreme panoramas / long screenshots). Bound the long edge to 16384 (the GPU max texture
|
||||
// size) with aspectFit so the true ratio is kept and the buffer stays renderable. .fast resize
|
||||
// is enough, we only need the long edge under the limit.
|
||||
let isOriginal = !(width > 0 && height > 0)
|
||||
var image: UIImage?
|
||||
Self.imageManager.requestImage(
|
||||
for: asset,
|
||||
targetSize: isOriginal
|
||||
? CGSize(width: Self.maxOriginalPixelSize, height: Self.maxOriginalPixelSize)
|
||||
: CGSize(width: Double(width), height: Double(height)),
|
||||
contentMode: isOriginal ? .aspectFit : .aspectFill,
|
||||
targetSize: width > 0 && height > 0 ? CGSize(width: Double(width), height: Double(height)) : PHImageManagerMaximumSize,
|
||||
contentMode: .aspectFill,
|
||||
options: Self.requestOptions,
|
||||
resultHandler: { (_image, info) -> Void in
|
||||
image = _image
|
||||
|
||||
@@ -203,7 +203,13 @@ class ImmichAPI {
|
||||
|
||||
func fetchMemory(for date: Date) async throws -> [MemoryResult] {
|
||||
// get URL
|
||||
let memoryParams = [URLQueryItem(name: "for", value: date.ISO8601Format())]
|
||||
// server buckets memories by UTC day, so send noon UTC of the device's local day
|
||||
var utcCalendar = Calendar(identifier: .gregorian)
|
||||
utcCalendar.timeZone = TimeZone(identifier: "UTC")!
|
||||
var components = Calendar.current.dateComponents([.year, .month, .day], from: date)
|
||||
components.hour = 12
|
||||
let localDay = utcCalendar.date(from: components) ?? date
|
||||
let memoryParams = [URLQueryItem(name: "for", value: localDay.ISO8601Format())]
|
||||
guard
|
||||
let searchURL = buildRequestURL(
|
||||
serverConfig: serverConfig,
|
||||
|
||||
@@ -159,14 +159,7 @@ 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,
|
||||
width: asset.width,
|
||||
height: asset.height,
|
||||
);
|
||||
provider = LocalFullImageProvider(id: id, size: size, assetType: asset.type, isAnimated: asset.isAnimatedImage);
|
||||
} else {
|
||||
final String assetId;
|
||||
final String thumbhash;
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
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';
|
||||
@@ -10,9 +8,6 @@ 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;
|
||||
|
||||
class LocalThumbProvider extends CancellableImageProvider<LocalThumbProvider>
|
||||
with CancellableImageProviderMixin<LocalThumbProvider> {
|
||||
final String id;
|
||||
@@ -64,36 +59,8 @@ 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,
|
||||
this.width,
|
||||
this.height,
|
||||
});
|
||||
|
||||
Size _previewTarget(double dpr, bool previewIsFinal) =>
|
||||
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).
|
||||
@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 <= _kMaxTextureSize) {
|
||||
return Size(boxW, boxH);
|
||||
}
|
||||
final bound = previewIsFinal ? _kMaxTextureSize.toDouble() : math.max(boxW, boxH);
|
||||
final scale = math.min(1.0, bound / imgLong);
|
||||
return Size(width * scale, height * scale);
|
||||
}
|
||||
LocalFullImageProvider({required this.id, required this.assetType, required this.size, required this.isAnimated});
|
||||
|
||||
@override
|
||||
Future<LocalFullImageProvider> obtainKey(ImageConfiguration configuration) {
|
||||
@@ -141,7 +108,7 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
|
||||
final devicePixelRatio = PlatformDispatcher.instance.views.first.devicePixelRatio;
|
||||
var request = this.request = LocalImageRequest(
|
||||
localId: key.id,
|
||||
size: _previewTarget(devicePixelRatio, !loadOriginal),
|
||||
size: Size(size.width * devicePixelRatio, size.height * devicePixelRatio),
|
||||
assetType: key.assetType,
|
||||
);
|
||||
yield* loadRequest(request, decode, isFinal: !loadOriginal);
|
||||
@@ -169,7 +136,7 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
|
||||
final devicePixelRatio = PlatformDispatcher.instance.views.first.devicePixelRatio;
|
||||
final previewRequest = request = LocalImageRequest(
|
||||
localId: key.id,
|
||||
size: _previewTarget(devicePixelRatio, false),
|
||||
size: Size(size.width * devicePixelRatio, size.height * devicePixelRatio),
|
||||
assetType: key.assetType,
|
||||
);
|
||||
yield* loadRequest(previewRequest, decode, isFinal: false);
|
||||
@@ -196,15 +163,11 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
|
||||
return true;
|
||||
}
|
||||
if (other is LocalFullImageProvider) {
|
||||
return id == other.id &&
|
||||
size == other.size &&
|
||||
isAnimated == other.isAnimated &&
|
||||
width == other.width &&
|
||||
height == other.height;
|
||||
return id == other.id && size == other.size && isAnimated == other.isAnimated;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => id.hashCode ^ size.hashCode ^ isAnimated.hashCode ^ width.hashCode ^ height.hashCode;
|
||||
int get hashCode => id.hashCode ^ size.hashCode ^ isAnimated.hashCode;
|
||||
}
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
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', () {
|
||||
// a typical phone preview box (logical size * devicePixelRatio).
|
||||
const boxW = 1179.0;
|
||||
const boxH = 2556.0;
|
||||
const oldBox = Size(boxW, boxH);
|
||||
|
||||
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));
|
||||
});
|
||||
});
|
||||
|
||||
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('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', () {
|
||||
final a = make(width: 100, height: 200);
|
||||
final b = make(width: 100, height: 200);
|
||||
expect(a, b);
|
||||
expect(a.hashCode, b.hashCode);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user