Compare commits

..

6 Commits

Author SHA1 Message Date
mertalev
a04159687b formatting 2026-01-31 01:28:05 -05:00
mertalev
75be0acb08 remove thumb provider 2026-01-31 01:17:38 -05:00
mertalev
7c9e9b5205 linting 2026-01-30 23:54:33 -05:00
mertalev
9d7a23ce13 formatting 2026-01-30 23:40:08 -05:00
mertalev
425b4bc63e remove cached_network_image 2026-01-30 23:31:26 -05:00
mertalev
679db1dfb2 remote url image provider 2026-01-30 23:13:43 -05:00
34 changed files with 117 additions and 575 deletions

View File

@@ -2,9 +2,10 @@ import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/images/local_image_provider.dart';
import 'package:immich_mobile/providers/backup/error_backup_list.provider.dart';
import 'package:immich_mobile/providers/image/immich_local_thumbnail_provider.dart';
import 'package:intl/intl.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart' as base_asset;
@RoutePage()
class FailedBackupStatusPage extends HookConsumerWidget {
@@ -58,7 +59,7 @@ class FailedBackupStatusPage extends HookConsumerWidget {
clipBehavior: Clip.hardEdge,
child: Image(
fit: BoxFit.cover,
image: ImmichLocalThumbnailProvider(asset: errorAsset.asset, height: 512, width: 512),
image: LocalThumbProvider(id: errorAsset.asset.localId!, assetType: base_asset.AssetType.video),
),
),
),

View File

@@ -1,9 +1,9 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_stack.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
import 'package:immich_mobile/providers/image/immich_remote_image_provider.dart';
class GalleryStackedChildren extends HookConsumerWidget {
final ValueNotifier<int> stackIndex;
@@ -70,7 +70,7 @@ class GalleryStackedChildren extends HookConsumerWidget {
borderRadius: const BorderRadius.all(Radius.circular(4)),
child: Image(
fit: BoxFit.cover,
image: ImmichRemoteImageProvider(assetId: assetId),
image: RemoteImageProvider.thumbnail(assetId: assetId, thumbhash: asset.thumbhash ?? ""),
),
),
),

View File

@@ -11,7 +11,7 @@ import 'package:immich_mobile/providers/partner.provider.dart';
import 'package:immich_mobile/providers/search/people.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:immich_mobile/widgets/album/album_thumbnail_card.dart';
import 'package:immich_mobile/widgets/common/immich_app_bar.dart';
@@ -221,12 +221,7 @@ class PeopleCollectionCard extends ConsumerWidget {
mainAxisSpacing: 8,
physics: const NeverScrollableScrollPhysics(),
children: people.take(4).map((person) {
return CircleAvatar(
backgroundImage: NetworkImage(
getFaceThumbnailUrl(person.id),
headers: ApiService.getRequestHeaders(),
),
);
return CircleAvatar(backgroundImage: RemoteImageProvider(url: getFaceThumbnailUrl(person.id)));
}).toList(),
);
},

View File

@@ -5,8 +5,8 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/search/people.provider.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:immich_mobile/widgets/common/search_field.dart';
import 'package:immich_mobile/widgets/search/person_name_edit_form.dart';
@@ -17,7 +17,6 @@ class PeopleCollectionPage extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final people = ref.watch(getAllPeopleProvider);
final headers = ApiService.getRequestHeaders();
final formFocus = useFocusNode();
final ValueNotifier<String?> search = useState(null);
@@ -88,7 +87,7 @@ class PeopleCollectionPage extends HookConsumerWidget {
elevation: 3,
child: CircleAvatar(
maxRadius: isTablet ? 120 / 2 : 96 / 2,
backgroundImage: NetworkImage(getFaceThumbnailUrl(person.id), headers: headers),
backgroundImage: RemoteImageProvider(url: getFaceThumbnailUrl(person.id)),
),
),
),

View File

@@ -1,5 +1,4 @@
import 'package:auto_route/auto_route.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
@@ -10,9 +9,10 @@ import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/models/search/search_filter.model.dart';
import 'package:immich_mobile/pages/common/large_leading_tile.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
import 'package:immich_mobile/providers/search/search_page_state.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/widgets/common/search_field.dart';
import 'package:immich_mobile/widgets/map/map_thumbnail.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
@@ -125,13 +125,10 @@ class PlaceTile extends StatelessWidget {
title: Text(name, style: context.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w500)),
leading: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(20)),
child: CachedNetworkImage(
child: SizedBox(
width: 80,
height: 80,
fit: BoxFit.cover,
imageUrl: thumbnailUrl,
httpHeaders: ApiService.getRequestHeaders(),
errorWidget: (context, url, error) => const Icon(Icons.image_not_supported_outlined),
child: Thumbnail(imageProvider: RemoteImageProvider(url: thumbnailUrl)),
),
),
);

View File

@@ -4,8 +4,8 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
import 'package:immich_mobile/providers/search/people.provider.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/widgets/search/person_name_edit_form.dart';
import 'package:immich_mobile/widgets/asset_grid/multiselect_grid.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
@@ -88,10 +88,7 @@ class PersonResultPage extends HookConsumerWidget {
padding: const EdgeInsets.only(left: 8.0, top: 24),
child: Row(
children: [
CircleAvatar(
radius: 36,
backgroundImage: NetworkImage(getFaceThumbnailUrl(personId), headers: ApiService.getRequestHeaders()),
),
CircleAvatar(radius: 36, backgroundImage: RemoteImageProvider(url: getFaceThumbnailUrl(personId))),
Expanded(
child: Padding(padding: const EdgeInsets.only(left: 16.0, right: 16.0), child: buildTitleBlock()),
),

View File

@@ -12,8 +12,8 @@ import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/partner.provider.dart';
import 'package:immich_mobile/providers/infrastructure/people.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:immich_mobile/widgets/common/immich_sliver_app_bar.dart';
import 'package:immich_mobile/widgets/map/map_thumbnail.dart';
@@ -179,12 +179,7 @@ class _PeopleCollectionCard extends ConsumerWidget {
mainAxisSpacing: 8,
physics: const NeverScrollableScrollPhysics(),
children: people.take(4).map((person) {
return CircleAvatar(
backgroundImage: NetworkImage(
getFaceThumbnailUrl(person.id),
headers: ApiService.getRequestHeaders(),
),
);
return CircleAvatar(backgroundImage: RemoteImageProvider(url: getFaceThumbnailUrl(person.id)));
}).toList(),
);
},

View File

@@ -4,8 +4,8 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/infrastructure/people.provider.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:immich_mobile/utils/people.utils.dart';
import 'package:immich_mobile/widgets/common/search_field.dart';
@@ -31,7 +31,6 @@ class _DriftPeopleCollectionPageState extends ConsumerState<DriftPeopleCollectio
@override
Widget build(BuildContext context) {
final people = ref.watch(driftGetAllPeopleProvider);
final headers = ApiService.getRequestHeaders();
return LayoutBuilder(
builder: (context, constraints) {
@@ -90,7 +89,7 @@ class _DriftPeopleCollectionPageState extends ConsumerState<DriftPeopleCollectio
elevation: 3,
child: CircleAvatar(
maxRadius: isTablet ? 100 / 2 : 96 / 2,
backgroundImage: NetworkImage(getFaceThumbnailUrl(person.id), headers: headers),
backgroundImage: RemoteImageProvider(url: getFaceThumbnailUrl(person.id)),
),
),
),

View File

@@ -10,8 +10,8 @@ import 'package:immich_mobile/presentation/widgets/people/person_edit_name_modal
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/people.provider.dart';
import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:immich_mobile/utils/people.utils.dart';
@@ -108,8 +108,6 @@ class _PeopleAvatar extends StatelessWidget {
@override
Widget build(BuildContext context) {
final headers = ApiService.getRequestHeaders();
return ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 96),
child: Padding(
@@ -127,7 +125,7 @@ class _PeopleAvatar extends StatelessWidget {
elevation: 3,
child: CircleAvatar(
maxRadius: imageSize / 2,
backgroundImage: NetworkImage(getFaceThumbnailUrl(person.id), headers: headers),
backgroundImage: RemoteImageProvider(url: getFaceThumbnailUrl(person.id)),
),
),
),

View File

@@ -134,7 +134,7 @@ ImageProvider? getThumbnailImageProvider(BaseAsset asset, {Size size = kThumbnai
final assetId = asset is RemoteAsset ? asset.id : (asset as LocalAsset).remoteId;
final thumbhash = asset is RemoteAsset ? asset.thumbHash ?? "" : "";
return assetId != null ? RemoteThumbProvider(assetId: assetId, thumbhash: thumbhash) : null;
return assetId != null ? RemoteImageProvider.thumbnail(assetId: assetId, thumbhash: thumbhash) : null;
}
bool _shouldUseLocalAsset(BaseAsset asset) =>

View File

@@ -10,50 +10,48 @@ import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:openapi/api.dart';
class RemoteThumbProvider extends CancellableImageProvider<RemoteThumbProvider>
with CancellableImageProviderMixin<RemoteThumbProvider> {
final String assetId;
final String thumbhash;
class RemoteImageProvider extends CancellableImageProvider<RemoteImageProvider>
with CancellableImageProviderMixin<RemoteImageProvider> {
final String url;
RemoteThumbProvider({required this.assetId, required this.thumbhash});
RemoteImageProvider({required this.url});
RemoteImageProvider.thumbnail({required String assetId, required String thumbhash})
: url = getThumbnailUrlForRemoteId(assetId, thumbhash: thumbhash);
@override
Future<RemoteThumbProvider> obtainKey(ImageConfiguration configuration) {
Future<RemoteImageProvider> obtainKey(ImageConfiguration configuration) {
return SynchronousFuture(this);
}
@override
ImageStreamCompleter loadImage(RemoteThumbProvider key, ImageDecoderCallback decode) {
ImageStreamCompleter loadImage(RemoteImageProvider key, ImageDecoderCallback decode) {
return OneFramePlaceholderImageStreamCompleter(
_codec(key, decode),
informationCollector: () => <DiagnosticsNode>[
DiagnosticsProperty<ImageProvider>('Image provider', this),
DiagnosticsProperty<String>('Asset Id', key.assetId),
DiagnosticsProperty<String>('URL', key.url),
],
onDispose: cancel,
);
}
Stream<ImageInfo> _codec(RemoteThumbProvider key, ImageDecoderCallback decode) {
final request = this.request = RemoteImageRequest(
uri: getThumbnailUrlForRemoteId(key.assetId, thumbhash: key.thumbhash),
headers: ApiService.getRequestHeaders(),
);
Stream<ImageInfo> _codec(RemoteImageProvider key, ImageDecoderCallback decode) {
final request = this.request = RemoteImageRequest(uri: key.url, headers: ApiService.getRequestHeaders());
return loadRequest(request, decode);
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
if (other is RemoteThumbProvider) {
return assetId == other.assetId && thumbhash == other.thumbhash;
if (other is RemoteImageProvider) {
return url == other.url;
}
return false;
}
@override
int get hashCode => assetId.hashCode ^ thumbhash.hashCode;
int get hashCode => url.hashCode;
}
class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImageProvider>
@@ -73,7 +71,7 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
ImageStreamCompleter loadImage(RemoteFullImageProvider key, ImageDecoderCallback decode) {
return OneFramePlaceholderImageStreamCompleter(
_codec(key, decode),
initialImage: getInitialImage(RemoteThumbProvider(assetId: key.assetId, thumbhash: key.thumbhash)),
initialImage: getInitialImage(RemoteImageProvider.thumbnail(assetId: key.assetId, thumbhash: key.thumbhash)),
informationCollector: () => <DiagnosticsNode>[
DiagnosticsProperty<ImageProvider>('Image provider', this),
DiagnosticsProperty<String>('Asset Id', key.assetId),

View File

@@ -27,7 +27,7 @@ class Thumbnail extends StatefulWidget {
this.fit = BoxFit.cover,
Size size = kThumbnailResolution,
super.key,
}) : imageProvider = RemoteThumbProvider(assetId: remoteId, thumbhash: thumbhash),
}) : imageProvider = RemoteImageProvider.thumbnail(assetId: remoteId, thumbhash: thumbhash),
thumbhashProvider = null;
Thumbnail.fromAsset({

View File

@@ -1,10 +1,9 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
class PartnerUserAvatar extends StatelessWidget {
const PartnerUserAvatar({super.key, required this.partner});
@@ -18,11 +17,7 @@ class PartnerUserAvatar extends StatelessWidget {
return CircleAvatar(
radius: 16,
backgroundColor: context.primaryColor.withAlpha(50),
foregroundImage: CachedNetworkImageProvider(
url,
headers: ApiService.getRequestHeaders(),
cacheKey: "user-${partner.id}-profile",
),
foregroundImage: RemoteImageProvider(url: url),
// silence errors if user has no profile image, use initials as fallback
onForegroundImageError: (exception, stackTrace) {},
child: Text(nameFirstLetter.toUpperCase()),

View File

@@ -1,94 +0,0 @@
import 'dart:async';
import 'dart:io';
import 'dart:ui' as ui;
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/painting.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:logging/logging.dart';
import 'package:photo_manager/photo_manager.dart' show ThumbnailSize;
/// The local image provider for an asset
class ImmichLocalImageProvider extends ImageProvider<ImmichLocalImageProvider> {
final Asset asset;
// only used for videos
final double width;
final double height;
final Logger log = Logger('ImmichLocalImageProvider');
ImmichLocalImageProvider({required this.asset, required this.width, required this.height})
: assert(asset.local != null, 'Only usable when asset.local is set');
/// Converts an [ImageProvider]'s settings plus an [ImageConfiguration] to a key
/// that describes the precise image to load.
@override
Future<ImmichLocalImageProvider> obtainKey(ImageConfiguration configuration) {
return SynchronousFuture(this);
}
@override
ImageStreamCompleter loadImage(ImmichLocalImageProvider key, ImageDecoderCallback decode) {
final chunkEvents = StreamController<ImageChunkEvent>();
return MultiImageStreamCompleter(
codec: _codec(key.asset, decode, chunkEvents),
scale: 1.0,
chunkEvents: chunkEvents.stream,
informationCollector: () sync* {
yield ErrorDescription(asset.fileName);
},
);
}
// Streams in each stage of the image as we ask for it
Stream<ui.Codec> _codec(
Asset asset,
ImageDecoderCallback decode,
StreamController<ImageChunkEvent> chunkEvents,
) async* {
try {
final local = asset.local;
if (local == null) {
throw StateError('Asset ${asset.fileName} has no local data');
}
switch (asset.type) {
case AssetType.image:
final File? file = await local.originFile;
if (file == null) {
throw StateError("Opening file for asset ${asset.fileName} failed");
}
final buffer = await ui.ImmutableBuffer.fromFilePath(file.path);
yield await decode(buffer);
break;
case AssetType.video:
final size = ThumbnailSize(width.ceil(), height.ceil());
final thumbBytes = await local.thumbnailDataWithSize(size);
if (thumbBytes == null) {
throw StateError("Failed to load preview for ${asset.fileName}");
}
final buffer = await ui.ImmutableBuffer.fromUint8List(thumbBytes);
yield await decode(buffer);
break;
default:
throw StateError('Unsupported asset type ${asset.type}');
}
} catch (error, stack) {
log.severe('Error loading local image ${asset.fileName}', error, stack);
} finally {
unawaited(chunkEvents.close());
}
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
if (other is ImmichLocalImageProvider) {
return asset.id == other.asset.id && asset.localId == other.asset.localId;
}
return false;
}
@override
int get hashCode => Object.hash(asset.id, asset.localId);
}

View File

@@ -1,88 +0,0 @@
import 'dart:async';
import 'dart:ui' as ui;
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:immich_mobile/providers/image/cache/thumbnail_image_cache_manager.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/painting.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:photo_manager/photo_manager.dart' show ThumbnailSize;
import 'package:logging/logging.dart';
/// The local image provider for an asset
/// Only viable
class ImmichLocalThumbnailProvider extends ImageProvider<ImmichLocalThumbnailProvider> {
final Asset asset;
final int height;
final int width;
final CacheManager? cacheManager;
final Logger log = Logger("ImmichLocalThumbnailProvider");
final String? userId;
ImmichLocalThumbnailProvider({
required this.asset,
this.height = 256,
this.width = 256,
this.cacheManager,
this.userId,
}) : assert(asset.local != null, 'Only usable when asset.local is set');
/// Converts an [ImageProvider]'s settings plus an [ImageConfiguration] to a key
/// that describes the precise image to load.
@override
Future<ImmichLocalThumbnailProvider> obtainKey(ImageConfiguration configuration) {
return SynchronousFuture(this);
}
@override
ImageStreamCompleter loadImage(ImmichLocalThumbnailProvider key, ImageDecoderCallback decode) {
final cache = cacheManager ?? ThumbnailImageCacheManager();
return MultiImageStreamCompleter(
codec: _codec(key.asset, cache, decode),
scale: 1.0,
informationCollector: () sync* {
yield ErrorDescription(key.asset.fileName);
},
);
}
// Streams in each stage of the image as we ask for it
Stream<ui.Codec> _codec(Asset assetData, CacheManager cache, ImageDecoderCallback decode) async* {
final cacheKey = '$userId${assetData.localId}${assetData.checksum}$width$height';
final fileFromCache = await cache.getFileFromCache(cacheKey);
if (fileFromCache != null) {
try {
final buffer = await ui.ImmutableBuffer.fromFilePath(fileFromCache.file.path);
final codec = await decode(buffer);
yield codec;
return;
} catch (error) {
log.severe('Found thumbnail in cache, but loading it failed', error);
}
}
final thumbnailBytes = await assetData.local?.thumbnailDataWithSize(ThumbnailSize(width, height), quality: 80);
if (thumbnailBytes == null) {
throw StateError("Loading thumb for local photo ${assetData.fileName} failed");
}
final buffer = await ui.ImmutableBuffer.fromUint8List(thumbnailBytes);
final codec = await decode(buffer);
yield codec;
await cache.putFile(cacheKey, thumbnailBytes);
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
if (other is ImmichLocalThumbnailProvider) {
return asset.id == other.asset.id && asset.localId == other.asset.localId;
}
return false;
}
@override
int get hashCode => Object.hash(asset.id, asset.localId);
}

View File

@@ -1,82 +0,0 @@
import 'dart:async';
import 'dart:ui' as ui;
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:immich_mobile/providers/image/cache/image_loader.dart';
import 'package:immich_mobile/providers/image/cache/remote_image_cache_manager.dart';
import 'package:openapi/api.dart' as api;
import 'package:flutter/foundation.dart';
import 'package:flutter/painting.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
/// The remote image provider for full size remote images
class ImmichRemoteImageProvider extends ImageProvider<ImmichRemoteImageProvider> {
/// The [Asset.remoteId] of the asset to fetch
final String assetId;
/// The image cache manager
final CacheManager? cacheManager;
const ImmichRemoteImageProvider({required this.assetId, this.cacheManager});
/// Converts an [ImageProvider]'s settings plus an [ImageConfiguration] to a key
/// that describes the precise image to load.
@override
Future<ImmichRemoteImageProvider> obtainKey(ImageConfiguration configuration) {
return SynchronousFuture(this);
}
@override
ImageStreamCompleter loadImage(ImmichRemoteImageProvider key, ImageDecoderCallback decode) {
final cache = cacheManager ?? RemoteImageCacheManager();
final chunkEvents = StreamController<ImageChunkEvent>();
return MultiImageStreamCompleter(
codec: _codec(key, cache, decode, chunkEvents),
scale: 1.0,
chunkEvents: chunkEvents.stream,
);
}
/// Whether to show the original file or load a compressed version
bool get _useOriginal => Store.get(AppSettingsEnum.loadOriginal.storeKey, AppSettingsEnum.loadOriginal.defaultValue);
// Streams in each stage of the image as we ask for it
Stream<ui.Codec> _codec(
ImmichRemoteImageProvider key,
CacheManager cache,
ImageDecoderCallback decode,
StreamController<ImageChunkEvent> chunkEvents,
) async* {
// Load the higher resolution version of the image
final url = getThumbnailUrlForRemoteId(key.assetId, type: api.AssetMediaSize.preview);
final codec = await ImageLoader.loadImageFromCache(url, cache: cache, decode: decode, chunkEvents: chunkEvents);
yield codec;
// Load the final remote image
if (_useOriginal) {
// Load the original image
final url = getOriginalUrlForRemoteId(key.assetId);
final codec = await ImageLoader.loadImageFromCache(url, cache: cache, decode: decode, chunkEvents: chunkEvents);
yield codec;
}
await chunkEvents.close();
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
if (other is ImmichRemoteImageProvider) {
return assetId == other.assetId;
}
return false;
}
@override
int get hashCode => assetId.hashCode;
}

View File

@@ -1,61 +0,0 @@
import 'dart:async';
import 'dart:ui' as ui;
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:immich_mobile/providers/image/cache/image_loader.dart';
import 'package:immich_mobile/providers/image/cache/thumbnail_image_cache_manager.dart';
import 'package:openapi/api.dart' as api;
import 'package:flutter/foundation.dart';
import 'package:flutter/painting.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
/// The remote image provider
class ImmichRemoteThumbnailProvider extends ImageProvider<ImmichRemoteThumbnailProvider> {
/// The [Asset.remoteId] of the asset to fetch
final String assetId;
final int? height;
final int? width;
/// The image cache manager
final CacheManager? cacheManager;
const ImmichRemoteThumbnailProvider({required this.assetId, this.height, this.width, this.cacheManager});
/// Converts an [ImageProvider]'s settings plus an [ImageConfiguration] to a key
/// that describes the precise image to load.
@override
Future<ImmichRemoteThumbnailProvider> obtainKey(ImageConfiguration configuration) {
return SynchronousFuture(this);
}
@override
ImageStreamCompleter loadImage(ImmichRemoteThumbnailProvider key, ImageDecoderCallback decode) {
final cache = cacheManager ?? ThumbnailImageCacheManager();
return MultiImageStreamCompleter(codec: _codec(key, cache, decode), scale: 1.0);
}
// Streams in each stage of the image as we ask for it
Stream<ui.Codec> _codec(ImmichRemoteThumbnailProvider key, CacheManager cache, ImageDecoderCallback decode) async* {
// Load a preview to the chunk events
final preview = getThumbnailUrlForRemoteId(key.assetId, type: api.AssetMediaSize.thumbnail);
yield await ImageLoader.loadImageFromCache(preview, cache: cache, decode: decode);
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
if (other is ImmichRemoteThumbnailProvider) {
return assetId == other.assetId;
}
return false;
}
@override
int get hashCode => assetId.hashCode;
}

View File

@@ -2,10 +2,6 @@ import 'package:flutter/painting.dart';
import 'package:immich_mobile/presentation/widgets/images/local_image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/thumb_hash_provider.dart';
import 'package:immich_mobile/providers/image/immich_local_image_provider.dart';
import 'package:immich_mobile/providers/image/immich_local_thumbnail_provider.dart';
import 'package:immich_mobile/providers/image/immich_remote_image_provider.dart';
import 'package:immich_mobile/providers/image/immich_remote_thumbnail_provider.dart';
/// [ImageCache] that uses two caches for small and large images
/// so that a single large image does not evict all small images
@@ -39,14 +35,9 @@ final class CustomImageCache implements ImageCache {
}
/// Gets the cache for the given key
/// [_large] is used for [ImmichLocalImageProvider] and [ImmichRemoteImageProvider]
/// [_small] is used for [ImmichLocalThumbnailProvider] and [ImmichRemoteThumbnailProvider]
ImageCache _cacheForKey(Object key) {
return switch (key) {
ImmichLocalImageProvider() ||
ImmichRemoteImageProvider() ||
LocalFullImageProvider() ||
RemoteFullImageProvider() => _large,
LocalFullImageProvider() || RemoteFullImageProvider() => _large,
ThumbHashProvider() => _thumbhash,
_ => _small,
};

View File

@@ -4,8 +4,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/datetime_extensions.dart';
import 'package:immich_mobile/models/activities/activity.model.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
import 'package:immich_mobile/providers/activity_service.provider.dart';
import 'package:immich_mobile/providers/image/immich_remote_thumbnail_provider.dart';
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/widgets/common/user_circle_avatar.dart';
@@ -102,7 +102,7 @@ class _ActivityAssetThumbnail extends StatelessWidget {
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(4)),
image: DecorationImage(
image: ImmichRemoteThumbnailProvider(assetId: assetId),
image: RemoteImageProvider.thumbnail(assetId: assetId, thumbhash: ""),
fit: BoxFit.cover,
),
),

View File

@@ -4,9 +4,9 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/datetime_extensions.dart';
import 'package:immich_mobile/models/activities/activity.model.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
import 'package:immich_mobile/providers/activity.provider.dart';
import 'package:immich_mobile/providers/activity_service.provider.dart';
import 'package:immich_mobile/providers/image/immich_remote_thumbnail_provider.dart';
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/widgets/activities/dismissible_activity.dart';
@@ -56,7 +56,7 @@ class CommentBubble extends ConsumerWidget {
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(10)),
child: Image(
image: ImmichRemoteThumbnailProvider(assetId: activity.assetId!),
image: RemoteImageProvider.thumbnail(assetId: activity.assetId!, thumbhash: ""),
fit: BoxFit.cover,
),
),

View File

@@ -1,12 +1,12 @@
import 'package:auto_route/auto_route.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/entities/album.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:openapi/api.dart';
@@ -32,15 +32,12 @@ class AlbumThumbnailListTile extends StatelessWidget {
}
buildAlbumThumbnail() {
return CachedNetworkImage(
return SizedBox(
width: cardSize,
height: cardSize,
fit: BoxFit.cover,
fadeInDuration: const Duration(milliseconds: 200),
imageUrl: getAlbumThumbnailUrl(album, type: AssetMediaSize.thumbnail),
httpHeaders: ApiService.getRequestHeaders(),
cacheKey: getAlbumThumbNailCacheKey(album, type: AssetMediaSize.thumbnail),
errorWidget: (context, url, error) => const Icon(Icons.image_not_supported_outlined),
child: Thumbnail(
imageProvider: RemoteImageProvider(url: getAlbumThumbnailUrl(album, type: AssetMediaSize.thumbnail)),
),
);
}

View File

@@ -1,10 +1,11 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart' as base_asset;
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/image/immich_local_image_provider.dart';
import 'package:immich_mobile/providers/image/immich_remote_image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/local_image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
import 'package:immich_mobile/widgets/asset_grid/thumbnail_placeholder.dart';
import 'package:octo_image/octo_image.dart';
@@ -34,13 +35,21 @@ class ImmichImage extends StatelessWidget {
}
if (asset == null) {
return ImmichRemoteImageProvider(assetId: assetId!);
return RemoteFullImageProvider(assetId: assetId!, thumbhash: '', assetType: base_asset.AssetType.video);
}
if (useLocal(asset)) {
return ImmichLocalImageProvider(asset: asset, width: width, height: height);
return LocalFullImageProvider(
id: asset.localId!,
assetType: base_asset.AssetType.video,
size: Size(width, height),
);
} else {
return ImmichRemoteImageProvider(assetId: asset.remoteId!);
return RemoteFullImageProvider(
assetId: asset.remoteId!,
thumbhash: asset.thumbhash ?? '',
assetType: base_asset.AssetType.video,
);
}
}

View File

@@ -2,15 +2,15 @@ import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/providers/image/immich_local_thumbnail_provider.dart';
import 'package:immich_mobile/providers/image/immich_remote_thumbnail_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/local_image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/utils/hooks/blurhash_hook.dart';
import 'package:immich_mobile/utils/thumbnail_utils.dart';
import 'package:immich_mobile/widgets/common/immich_image.dart';
import 'package:immich_mobile/widgets/common/thumbhash_placeholder.dart';
import 'package:octo_image/octo_image.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart' as base_asset;
class ImmichThumbnail extends HookConsumerWidget {
const ImmichThumbnail({this.asset, this.width = 250, this.height = 250, this.fit = BoxFit.cover, super.key});
@@ -24,26 +24,29 @@ class ImmichThumbnail extends HookConsumerWidget {
/// either by using the asset ID or the asset itself
/// [asset] is the Asset to request, or else use [assetId] to get a remote
/// image provider
static ImageProvider imageProvider({Asset? asset, String? assetId, String? userId, int thumbnailSize = 256}) {
static ImageProvider imageProvider({Asset? asset, String? assetId, int thumbnailSize = 256}) {
if (asset == null && assetId == null) {
throw Exception('Must supply either asset or assetId');
}
if (asset == null) {
return ImmichRemoteThumbnailProvider(assetId: assetId!);
return RemoteImageProvider.thumbnail(assetId: assetId!, thumbhash: "");
}
if (ImmichImage.useLocal(asset)) {
return ImmichLocalThumbnailProvider(asset: asset, height: thumbnailSize, width: thumbnailSize, userId: userId);
return LocalThumbProvider(
id: asset.localId!,
assetType: base_asset.AssetType.video,
size: Size(thumbnailSize.toDouble(), thumbnailSize.toDouble()),
);
} else {
return ImmichRemoteThumbnailProvider(assetId: asset.remoteId!, height: thumbnailSize, width: thumbnailSize);
return RemoteImageProvider.thumbnail(assetId: asset.remoteId!, thumbhash: asset.thumbhash ?? "");
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
Uint8List? blurhash = useBlurHashRef(asset).value;
final userId = ref.watch(currentUserProvider)?.id;
if (asset == null) {
return Container(
@@ -56,7 +59,7 @@ class ImmichThumbnail extends HookConsumerWidget {
final assetAltText = getAltText(asset!.exifInfo, asset!.fileCreatedAt, asset!.type, []);
final thumbnailProviderInstance = ImmichThumbnail.imageProvider(asset: asset, userId: userId);
final thumbnailProviderInstance = ImmichThumbnail.imageProvider(asset: asset);
customErrorBuilder(BuildContext ctx, Object error, StackTrace? stackTrace) {
thumbnailProviderInstance.evict();

View File

@@ -14,8 +14,8 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/people.utils.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
@@ -230,10 +230,7 @@ class _ExpandedBackgroundState extends ConsumerState<_ExpandedBackground> with S
elevation: 3,
child: CircleAvatar(
maxRadius: 84 / 2,
backgroundImage: NetworkImage(
getFaceThumbnailUrl(widget.person.id),
headers: ApiService.getRequestHeaders(),
),
backgroundImage: RemoteImageProvider(url: getFaceThumbnailUrl(widget.person.id)),
),
),
),

View File

@@ -1,10 +1,9 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
Widget userAvatar(BuildContext context, UserDto u, {double? radius}) {
final url = "${Store.get(StoreKey.serverEndpoint)}/users/${u.id}/profile-image";
@@ -12,11 +11,7 @@ Widget userAvatar(BuildContext context, UserDto u, {double? radius}) {
return CircleAvatar(
radius: radius,
backgroundColor: context.primaryColor.withAlpha(50),
foregroundImage: CachedNetworkImageProvider(
url,
headers: ApiService.getRequestHeaders(),
cacheKey: "user-${u.id}-profile",
),
foregroundImage: RemoteImageProvider(url: url),
// silence errors if user has no profile image, use initials as fallback
onForegroundImageError: (exception, stackTrace) {},
child: Text(nameFirstLetter.toUpperCase()),

View File

@@ -1,13 +1,11 @@
import 'dart:math';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/widgets/common/transparent_image.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
// ignore: must_be_immutable
class UserCircleAvatar extends ConsumerWidget {
@@ -46,16 +44,12 @@ class UserCircleAvatar extends ConsumerWidget {
child: user.hasProfileImage
? ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(50)),
child: CachedNetworkImage(
child: Image(
fit: BoxFit.cover,
cacheKey: '${user.id}-${user.profileChangedAt.toIso8601String()}',
width: size,
height: size,
placeholder: (_, __) => Image.memory(kTransparentImage),
imageUrl: profileImageUrl,
httpHeaders: ApiService.getRequestHeaders(),
fadeInDuration: const Duration(milliseconds: 300),
errorWidget: (context, error, stackTrace) => textIcon,
image: RemoteImageProvider(url: profileImageUrl),
errorBuilder: (context, error, stackTrace) => textIcon,
),
)
: textIcon,

View File

@@ -1,10 +1,9 @@
import 'dart:io';
import 'dart:math';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
class PositionedAssetMarkerIcon extends StatelessWidget {
@@ -53,7 +52,6 @@ class _AssetMarkerIcon extends StatelessWidget {
@override
Widget build(BuildContext context) {
final imageUrl = getThumbnailUrlForRemoteId(id);
final cacheKey = getThumbnailCacheKeyForRemoteId(id, thumbhash);
return LayoutBuilder(
builder: (context, constraints) {
return Stack(
@@ -79,12 +77,7 @@ class _AssetMarkerIcon extends StatelessWidget {
backgroundColor: context.colorScheme.onSurface,
child: CircleAvatar(
radius: constraints.maxHeight * 0.37,
backgroundImage: CachedNetworkImageProvider(
imageUrl,
cacheKey: cacheKey,
headers: ApiService.getRequestHeaders(),
errorListener: (_) => const Icon(Icons.image_not_supported_outlined),
),
backgroundImage: RemoteImageProvider(url: imageUrl),
),
),
),

View File

@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/models/search/search_curated_content.model.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
class CuratedPeopleRow extends StatelessWidget {
@@ -29,7 +29,6 @@ class CuratedPeopleRow extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: List.generate(content.length, (index) {
final person = content[index];
final headers = ApiService.getRequestHeaders();
return Padding(
padding: const EdgeInsets.only(right: 16.0),
child: Column(
@@ -44,7 +43,7 @@ class CuratedPeopleRow extends StatelessWidget {
elevation: 3,
child: CircleAvatar(
maxRadius: imageSize / 2,
backgroundImage: NetworkImage(getFaceThumbnailUrl(person.id), headers: headers),
backgroundImage: RemoteImageProvider(url: getFaceThumbnailUrl(person.id)),
),
),
),

View File

@@ -6,8 +6,8 @@ import 'package:immich_mobile/domain/models/person.model.dart';
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/pages/common/large_leading_tile.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
import 'package:immich_mobile/providers/search/people.provider.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:immich_mobile/widgets/common/search_field.dart';
@@ -23,7 +23,6 @@ class PeoplePicker extends HookConsumerWidget {
final imageSize = 60.0;
final searchQuery = useState('');
final people = ref.watch(getAllPeopleProvider);
final headers = ApiService.getRequestHeaders();
final selectedPeople = useState<Set<PersonDto>>(filter ?? {});
return Column(
@@ -75,7 +74,7 @@ class PeoplePicker extends HookConsumerWidget {
elevation: 3,
child: CircleAvatar(
maxRadius: imageSize / 2,
backgroundImage: NetworkImage(getFaceThumbnailUrl(person.id), headers: headers),
backgroundImage: RemoteImageProvider(url: getFaceThumbnailUrl(person.id)),
),
),
),

View File

@@ -1,8 +1,8 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
import 'package:immich_mobile/widgets/search/thumbnail_with_info_container.dart';
import 'package:immich_mobile/services/api.service.dart';
class ThumbnailWithInfo extends StatelessWidget {
const ThumbnailWithInfo({
@@ -30,14 +30,7 @@ class ThumbnailWithInfo extends StatelessWidget {
child: imageUrl != null
? ClipRRect(
borderRadius: BorderRadius.circular(borderRadius),
child: CachedNetworkImage(
width: double.infinity,
height: double.infinity,
fit: BoxFit.cover,
imageUrl: imageUrl!,
httpHeaders: ApiService.getRequestHeaders(),
errorWidget: (context, url, error) => const Icon(Icons.image_not_supported_outlined),
),
child: Thumbnail(imageProvider: RemoteImageProvider(url: imageUrl!)),
)
: Center(child: Icon(noImageIcon ?? Icons.not_listed_location, color: textAndIconColor)),
);

View File

@@ -201,30 +201,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "8.9.5"
cached_network_image:
dependency: "direct main"
description:
name: cached_network_image
sha256: "7c1183e361e5c8b0a0f21a28401eecdbde252441106a9816400dd4c2b2424916"
url: "https://pub.dev"
source: hosted
version: "3.4.1"
cached_network_image_platform_interface:
dependency: transitive
description:
name: cached_network_image_platform_interface
sha256: "35814b016e37fbdc91f7ae18c8caf49ba5c88501813f73ce8a07027a395e2829"
url: "https://pub.dev"
source: hosted
version: "4.1.1"
cached_network_image_web:
dependency: transitive
description:
name: cached_network_image_web
sha256: "980842f4e8e2535b8dbd3d5ca0b1f0ba66bf61d14cc3a17a9b4788a3685ba062"
url: "https://pub.dev"
source: hosted
version: "1.3.1"
cancellation_token:
dependency: transitive
description:
@@ -1249,10 +1225,10 @@ packages:
dependency: transitive
description:
name: meta
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
url: "https://pub.dev"
source: hosted
version: "1.16.0"
version: "1.17.0"
mime:
dependency: transitive
description:
@@ -1942,10 +1918,10 @@ packages:
dependency: transitive
description:
name: test_api
sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00"
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
url: "https://pub.dev"
source: hosted
version: "0.7.6"
version: "0.7.7"
thumbhash:
dependency: "direct main"
description:

View File

@@ -12,7 +12,6 @@ dependencies:
async: ^2.13.0
auto_route: ^9.2.0
background_downloader: ^9.3.0
cached_network_image: ^3.4.1
cancellation_token_http: ^2.1.0
cast: ^2.1.0
collection: ^1.19.1

View File

@@ -44,7 +44,6 @@ import { getDimensions } from 'src/utils/asset.util';
import { ImmichFileResponse } from 'src/utils/file';
import { mimeTypes } from 'src/utils/mime-types';
import { isFacialRecognitionEnabled } from 'src/utils/misc';
import { Point, transformPoints } from 'src/utils/transform';
@Injectable()
export class PersonService extends BaseService {
@@ -635,50 +634,15 @@ export class PersonService extends BaseService {
this.requireAccess({ auth, permission: Permission.PersonRead, ids: [dto.personId] }),
]);
const asset = await this.assetRepository.getById(dto.assetId, { edits: true, exifInfo: true });
if (!asset) {
throw new NotFoundException('Asset not found');
}
const edits = asset.edits || [];
let p1: Point = { x: dto.x, y: dto.y };
let p2: Point = { x: dto.x + dto.width, y: dto.y + dto.height };
// the coordinates received from the client are based on the edited preview image
// we need to convert them to the coordinate space of the original unedited image
if (edits.length > 0) {
if (!asset.width || !asset.height || !asset.exifInfo?.exifImageWidth || !asset.exifInfo?.exifImageHeight) {
throw new BadRequestException('Asset does not have valid dimensions');
}
// convert from preview to full dimensions
const scaleFactor = asset.width / dto.imageWidth;
p1 = { x: p1.x * scaleFactor, y: p1.y * scaleFactor };
p2 = { x: p2.x * scaleFactor, y: p2.y * scaleFactor };
const {
points: [invertedP1, invertedP2],
} = transformPoints([p1, p2], edits, { width: asset.width, height: asset.height }, { inverse: true });
// make sure p1 is top-left and p2 is bottom-right
p1 = { x: Math.min(invertedP1.x, invertedP2.x), y: Math.min(invertedP1.y, invertedP2.y) };
p2 = { x: Math.max(invertedP1.x, invertedP2.x), y: Math.max(invertedP1.y, invertedP2.y) };
// now coordinates are in original image space
dto.imageHeight = asset.exifInfo.exifImageHeight;
dto.imageWidth = asset.exifInfo.exifImageWidth;
}
await this.personRepository.createAssetFace({
personId: dto.personId,
assetId: dto.assetId,
imageHeight: dto.imageHeight,
imageWidth: dto.imageWidth,
boundingBoxX1: Math.round(p1.x),
boundingBoxX2: Math.round(p2.x),
boundingBoxY1: Math.round(p1.y),
boundingBoxY2: Math.round(p2.y),
boundingBoxX1: dto.x,
boundingBoxX2: dto.x + dto.width,
boundingBoxY1: dto.y,
boundingBoxY2: dto.y + dto.height,
sourceType: SourceType.Manual,
});
}

View File

@@ -61,7 +61,7 @@ export const createAffineMatrix = (
);
};
export type Point = { x: number; y: number };
type Point = { x: number; y: number };
type TransformState = {
points: Point[];
@@ -73,33 +73,29 @@ type TransformState = {
* Transforms an array of points through a series of edit operations (crop, rotate, mirror).
* Points should be in absolute pixel coordinates relative to the starting dimensions.
*/
export const transformPoints = (
const transformPoints = (
points: Point[],
edits: AssetEditActionItem[],
startingDimensions: ImageDimensions,
{ inverse = false } = {},
): TransformState => {
let currentWidth = startingDimensions.width;
let currentHeight = startingDimensions.height;
let transformedPoints = [...points];
// Handle crop first if not inverting
if (!inverse) {
const crop = edits.find((edit) => edit.action === 'crop');
if (crop) {
const { x: cropX, y: cropY, width: cropWidth, height: cropHeight } = crop.parameters;
transformedPoints = transformedPoints.map((p) => ({
x: p.x - cropX,
y: p.y - cropY,
}));
currentWidth = cropWidth;
currentHeight = cropHeight;
}
// Handle crop first
const crop = edits.find((edit) => edit.action === 'crop');
if (crop) {
const { x: cropX, y: cropY, width: cropWidth, height: cropHeight } = crop.parameters;
transformedPoints = transformedPoints.map((p) => ({
x: p.x - cropX,
y: p.y - cropY,
}));
currentWidth = cropWidth;
currentHeight = cropHeight;
}
// Apply rotate and mirror transforms
const editSequence = inverse ? edits.toReversed() : edits;
for (const edit of editSequence) {
for (const edit of edits) {
let matrix: Matrix = identity();
if (edit.action === 'rotate') {
const angleDegrees = edit.parameters.angle;
@@ -109,7 +105,7 @@ export const transformPoints = (
matrix = compose(
translate(newWidth / 2, newHeight / 2),
rotate(inverse ? -angleRadians : angleRadians),
rotate(angleRadians),
translate(-currentWidth / 2, -currentHeight / 2),
);
@@ -129,18 +125,6 @@ export const transformPoints = (
transformedPoints = transformedPoints.map((p) => applyToPoint(matrix, p));
}
// Handle crop last if inverting
if (inverse) {
const crop = edits.find((edit) => edit.action === 'crop');
if (crop) {
const { x: cropX, y: cropY } = crop.parameters;
transformedPoints = transformedPoints.map((p) => ({
x: p.x + cropX,
y: p.y + cropY,
}));
}
}
return {
points: transformedPoints,
currentWidth,