feat: image editing (#24155)

This commit is contained in:
Brandon Wees
2026-01-09 17:59:52 -05:00
committed by GitHub
parent 76241a7b2b
commit e8c80d88a5
141 changed files with 7836 additions and 1634 deletions
@@ -4,7 +4,6 @@ import 'package:immich_mobile/domain/models/exif.model.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
import 'package:immich_mobile/infrastructure/utils/exif.converter.dart';
typedef _AssetVideoDimension = ({double? width, double? height, bool isFlipped});
@@ -99,9 +98,7 @@ class AssetService {
height = fetched?.height?.toDouble();
}
final exif = await getExif(asset);
final isFlipped = ExifDtoConverter.isOrientationFlipped(exif?.orientation);
return (width: width, height: height, isFlipped: isFlipped);
return (width: width, height: height, isFlipped: false);
}
Future<List<(String, String)>> getPlaces(String userId) {
@@ -22,6 +22,7 @@ import 'package:immich_mobile/infrastructure/entities/stack.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/user.entity.drift.dart';
import 'package:immich_mobile/infrastructure/entities/user_metadata.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/utils/exif.converter.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart' as api show AssetVisibility, AlbumUserRole, UserMetadataKey;
import 'package:openapi/api.dart' hide AssetVisibility, AlbumUserRole, UserMetadataKey;
@@ -194,6 +195,8 @@ class SyncStreamRepository extends DriftDatabaseRepository {
livePhotoVideoId: Value(asset.livePhotoVideoId),
stackId: Value(asset.stackId),
libraryId: Value(asset.libraryId),
width: Value(asset.width),
height: Value(asset.height),
);
batch.insert(
@@ -245,10 +248,21 @@ class SyncStreamRepository extends DriftDatabaseRepository {
await _db.batch((batch) {
for (final exif in data) {
int? width;
int? height;
if (ExifDtoConverter.isOrientationFlipped(exif.orientation)) {
width = exif.exifImageHeight;
height = exif.exifImageWidth;
} else {
width = exif.exifImageWidth;
height = exif.exifImageHeight;
}
batch.update(
_db.remoteAssetEntity,
RemoteAssetEntityCompanion(width: Value(exif.exifImageWidth), height: Value(exif.exifImageHeight)),
where: (row) => row.id.equals(exif.assetId),
RemoteAssetEntityCompanion(width: Value(width), height: Value(height)),
where: (row) => row.id.equals(exif.assetId) & row.width.isNull() & row.height.isNull(),
);
}
});
@@ -370,6 +370,7 @@ class _MapWithMarker extends StatelessWidget {
? PositionedAssetMarkerIcon(
point: value.point,
assetRemoteId: value.marker.assetRemoteId,
assetThumbhash: '',
durationInMilliseconds: value.shouldAnimate ? 100 : 0,
onTap: onMarkerTapped,
)
@@ -68,7 +68,7 @@ class _SheetLocationDetailsState extends ConsumerState<SheetLocationDetails> {
return const SizedBox.shrink();
}
final remoteId = asset is LocalAsset ? asset.remoteId : (asset as RemoteAsset).id;
final remoteAsset = asset as RemoteAsset;
final locationName = _getLocationName(exifInfo);
final coordinates = "${exifInfo?.latitude?.toStringAsFixed(4)}, ${exifInfo?.longitude?.toStringAsFixed(4)}";
@@ -92,7 +92,12 @@ class _SheetLocationDetailsState extends ConsumerState<SheetLocationDetails> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ExifMap(exifInfo: exifInfo!, markerId: remoteId, onMapCreated: _onMapCreated),
ExifMap(
exifInfo: exifInfo!,
markerId: remoteAsset.id,
markerAssetThumbhash: remoteAsset.thumbHash,
onMapCreated: _onMapCreated,
),
const SizedBox(height: 16),
if (locationName != null)
Padding(
@@ -10,6 +10,7 @@ import 'package:immich_mobile/presentation/widgets/images/one_frame_multi_image_
import 'package:immich_mobile/providers/image/cache/remote_image_cache_manager.dart';
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> {
@@ -93,7 +94,7 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
final headers = ApiService.getRequestHeaders();
final request = this.request = RemoteImageRequest(
uri: getPreviewUrlForRemoteId(key.assetId),
uri: getThumbnailUrlForRemoteId(key.assetId, type: AssetMediaSize.preview),
headers: headers,
cacheManager: cacheManager,
);
+21 -19
View File
@@ -1,4 +1,3 @@
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/album.entity.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
@@ -10,14 +9,18 @@ String getThumbnailUrl(final Asset asset, {AssetMediaSize type = AssetMediaSize.
}
String getThumbnailCacheKey(final Asset asset, {AssetMediaSize type = AssetMediaSize.thumbnail}) {
return getThumbnailCacheKeyForRemoteId(asset.remoteId!, type: type);
return getThumbnailCacheKeyForRemoteId(asset.remoteId!, asset.thumbhash!, type: type);
}
String getThumbnailCacheKeyForRemoteId(final String id, {AssetMediaSize type = AssetMediaSize.thumbnail}) {
String getThumbnailCacheKeyForRemoteId(
final String id,
final String thumbhash, {
AssetMediaSize type = AssetMediaSize.thumbnail,
}) {
if (type == AssetMediaSize.thumbnail) {
return 'thumbnail-image-$id';
return 'thumbnail-image-$id-$thumbhash';
} else {
return '${id}_previewStage';
return '${id}_${thumbhash}_previewStage';
}
}
@@ -32,26 +35,25 @@ String getAlbumThumbNailCacheKey(final Album album, {AssetMediaSize type = Asset
if (album.thumbnail.value?.remoteId == null) {
return '';
}
return getThumbnailCacheKeyForRemoteId(album.thumbnail.value!.remoteId!, type: type);
return getThumbnailCacheKeyForRemoteId(
album.thumbnail.value!.remoteId!,
album.thumbnail.value!.thumbhash!,
type: type,
);
}
String getOriginalUrlForRemoteId(final String id) {
return '${Store.get(StoreKey.serverEndpoint)}/assets/$id/original';
String getOriginalUrlForRemoteId(final String id, {bool edited = true}) {
return '${Store.get(StoreKey.serverEndpoint)}/assets/$id/original?edited=$edited';
}
String getImageCacheKey(final Asset asset) {
// Assets from response DTOs do not have an isar id, querying which would give us the default autoIncrement id
final isFromDto = asset.id == noDbId;
return '${isFromDto ? asset.remoteId : asset.id}_fullStage';
String getThumbnailUrlForRemoteId(
final String id, {
AssetMediaSize type = AssetMediaSize.thumbnail,
bool edited = true,
}) {
return '${Store.get(StoreKey.serverEndpoint)}/assets/$id/thumbnail?size=${type.value}&edited=$edited';
}
String getThumbnailUrlForRemoteId(final String id, {AssetMediaSize type = AssetMediaSize.thumbnail}) {
return '${Store.get(StoreKey.serverEndpoint)}/assets/$id/thumbnail?size=${type.value}';
}
String getPreviewUrlForRemoteId(final String id) =>
'${Store.get(StoreKey.serverEndpoint)}/assets/$id/thumbnail?size=${AssetMediaSize.preview}';
String getPlaybackUrlForRemoteId(final String id) {
return '${Store.get(StoreKey.serverEndpoint)}/assets/$id/video/playback?';
}
@@ -74,7 +74,7 @@ class AssetLocation extends HookConsumerWidget {
],
),
asset.isRemote ? const SizedBox.shrink() : const SizedBox(height: 16),
ExifMap(exifInfo: exifInfo!, markerId: asset.remoteId),
ExifMap(exifInfo: exifInfo!, markerId: asset.remoteId, markerAssetThumbhash: asset.thumbhash),
const SizedBox(height: 16),
getLocationName(),
Text(
@@ -10,10 +10,20 @@ import 'package:url_launcher/url_launcher.dart';
class ExifMap extends StatelessWidget {
final ExifInfo exifInfo;
// TODO: Pass in a BaseAsset instead of the ID and thumbhash when removing old timeline
// This is currently structured this way because of the old timeline implementation
// reusing this component
final String? markerId;
final String? markerAssetThumbhash;
final MapCreatedCallback? onMapCreated;
const ExifMap({super.key, required this.exifInfo, this.markerId = 'marker', this.onMapCreated});
const ExifMap({
super.key,
required this.exifInfo,
this.markerAssetThumbhash,
this.markerId = 'marker',
this.onMapCreated,
});
@override
Widget build(BuildContext context) {
@@ -61,6 +71,7 @@ class ExifMap extends StatelessWidget {
width: constraints.maxWidth,
zoom: 12.0,
assetMarkerRemoteId: markerId,
assetThumbhash: markerAssetThumbhash,
onTap: (tapPosition, latLong) async {
Uri? uri = await createCoordinatesUri();
+9 -2
View File
@@ -19,6 +19,7 @@ class MapThumbnail extends HookConsumerWidget {
final Function(Point<double>, LatLng)? onTap;
final LatLng centre;
final String? assetMarkerRemoteId;
final String? assetThumbhash;
final bool showMarkerPin;
final double zoom;
final double height;
@@ -35,6 +36,7 @@ class MapThumbnail extends HookConsumerWidget {
this.onTap,
this.zoom = 8,
this.assetMarkerRemoteId,
this.assetThumbhash,
this.showMarkerPin = false,
this.themeMode,
this.showAttribution = true,
@@ -109,8 +111,13 @@ class MapThumbnail extends HookConsumerWidget {
),
ValueListenableBuilder(
valueListenable: position,
builder: (_, value, __) => value != null && assetMarkerRemoteId != null
? PositionedAssetMarkerIcon(size: height / 2, point: value, assetRemoteId: assetMarkerRemoteId!)
builder: (_, value, __) => value != null && assetMarkerRemoteId != null && assetThumbhash != null
? PositionedAssetMarkerIcon(
size: height / 2,
point: value,
assetRemoteId: assetMarkerRemoteId!,
assetThumbhash: assetThumbhash!,
)
: const SizedBox.shrink(),
),
],
@@ -10,6 +10,7 @@ import 'package:immich_mobile/utils/image_url_builder.dart';
class PositionedAssetMarkerIcon extends StatelessWidget {
final Point<num> point;
final String assetRemoteId;
final String assetThumbhash;
final double size;
final int durationInMilliseconds;
@@ -18,6 +19,7 @@ class PositionedAssetMarkerIcon extends StatelessWidget {
const PositionedAssetMarkerIcon({
required this.point,
required this.assetRemoteId,
required this.assetThumbhash,
this.size = 100,
this.durationInMilliseconds = 100,
this.onTap,
@@ -35,7 +37,7 @@ class PositionedAssetMarkerIcon extends StatelessWidget {
onTap: () => onTap?.call(),
child: SizedBox.square(
dimension: size,
child: _AssetMarkerIcon(id: assetRemoteId, key: Key(assetRemoteId)),
child: _AssetMarkerIcon(id: assetRemoteId, thumbhash: assetThumbhash, key: Key(assetRemoteId)),
),
),
);
@@ -43,14 +45,15 @@ class PositionedAssetMarkerIcon extends StatelessWidget {
}
class _AssetMarkerIcon extends StatelessWidget {
const _AssetMarkerIcon({required this.id, super.key});
const _AssetMarkerIcon({required this.id, required this.thumbhash, super.key});
final String id;
final String thumbhash;
@override
Widget build(BuildContext context) {
final imageUrl = getThumbnailUrlForRemoteId(id);
final cacheKey = getThumbnailCacheKeyForRemoteId(id);
final cacheKey = getThumbnailCacheKeyForRemoteId(id, thumbhash);
return LayoutBuilder(
builder: (context, constraints) {
return Stack(