mirror of
https://github.com/immich-app/immich.git
synced 2025-12-05 20:40:29 -08:00
feat: asset dimensions in asset table
This commit is contained in:
@@ -1,10 +1,8 @@
|
||||
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
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';
|
||||
|
||||
class AssetService {
|
||||
final RemoteAssetRepository _remoteAssetRepository;
|
||||
@@ -58,22 +56,11 @@ class AssetService {
|
||||
}
|
||||
|
||||
Future<double> getAspectRatio(BaseAsset asset) async {
|
||||
bool isFlipped;
|
||||
double? width;
|
||||
double? height;
|
||||
|
||||
if (asset.hasRemote) {
|
||||
final exif = await getExif(asset);
|
||||
isFlipped = ExifDtoConverter.isOrientationFlipped(exif?.orientation);
|
||||
width = asset.width?.toDouble();
|
||||
height = asset.height?.toDouble();
|
||||
} else if (asset is LocalAsset) {
|
||||
isFlipped = CurrentPlatform.isAndroid && (asset.orientation == 90 || asset.orientation == 270);
|
||||
width = asset.width?.toDouble();
|
||||
height = asset.height?.toDouble();
|
||||
} else {
|
||||
isFlipped = false;
|
||||
}
|
||||
width = asset.width?.toDouble();
|
||||
height = asset.height?.toDouble();
|
||||
|
||||
if (width == null || height == null) {
|
||||
if (asset.hasRemote) {
|
||||
@@ -89,10 +76,8 @@ class AssetService {
|
||||
}
|
||||
}
|
||||
|
||||
final orientedWidth = isFlipped ? height : width;
|
||||
final orientedHeight = isFlipped ? width : height;
|
||||
if (orientedWidth != null && orientedHeight != null && orientedHeight > 0) {
|
||||
return orientedWidth / orientedHeight;
|
||||
if (width != null && height != null && height > 0) {
|
||||
return width / height;
|
||||
}
|
||||
|
||||
return 1.0;
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
34
mobile/openapi/lib/model/asset_response_dto.dart
generated
34
mobile/openapi/lib/model/asset_response_dto.dart
generated
@@ -23,6 +23,7 @@ class AssetResponseDto {
|
||||
required this.fileCreatedAt,
|
||||
required this.fileModifiedAt,
|
||||
required this.hasMetadata,
|
||||
required this.height,
|
||||
required this.id,
|
||||
required this.isArchived,
|
||||
required this.isFavorite,
|
||||
@@ -45,6 +46,7 @@ class AssetResponseDto {
|
||||
this.unassignedFaces = const [],
|
||||
required this.updatedAt,
|
||||
required this.visibility,
|
||||
required this.width,
|
||||
});
|
||||
|
||||
/// base64 encoded sha1 hash
|
||||
@@ -77,6 +79,8 @@ class AssetResponseDto {
|
||||
|
||||
bool hasMetadata;
|
||||
|
||||
num? height;
|
||||
|
||||
String id;
|
||||
|
||||
bool isArchived;
|
||||
@@ -141,6 +145,8 @@ class AssetResponseDto {
|
||||
|
||||
AssetVisibility visibility;
|
||||
|
||||
num? width;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is AssetResponseDto &&
|
||||
other.checksum == checksum &&
|
||||
@@ -153,6 +159,7 @@ class AssetResponseDto {
|
||||
other.fileCreatedAt == fileCreatedAt &&
|
||||
other.fileModifiedAt == fileModifiedAt &&
|
||||
other.hasMetadata == hasMetadata &&
|
||||
other.height == height &&
|
||||
other.id == id &&
|
||||
other.isArchived == isArchived &&
|
||||
other.isFavorite == isFavorite &&
|
||||
@@ -174,7 +181,8 @@ class AssetResponseDto {
|
||||
other.type == type &&
|
||||
_deepEquality.equals(other.unassignedFaces, unassignedFaces) &&
|
||||
other.updatedAt == updatedAt &&
|
||||
other.visibility == visibility;
|
||||
other.visibility == visibility &&
|
||||
other.width == width;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
@@ -189,6 +197,7 @@ class AssetResponseDto {
|
||||
(fileCreatedAt.hashCode) +
|
||||
(fileModifiedAt.hashCode) +
|
||||
(hasMetadata.hashCode) +
|
||||
(height == null ? 0 : height!.hashCode) +
|
||||
(id.hashCode) +
|
||||
(isArchived.hashCode) +
|
||||
(isFavorite.hashCode) +
|
||||
@@ -210,10 +219,11 @@ class AssetResponseDto {
|
||||
(type.hashCode) +
|
||||
(unassignedFaces.hashCode) +
|
||||
(updatedAt.hashCode) +
|
||||
(visibility.hashCode);
|
||||
(visibility.hashCode) +
|
||||
(width == null ? 0 : width!.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'AssetResponseDto[checksum=$checksum, createdAt=$createdAt, deviceAssetId=$deviceAssetId, deviceId=$deviceId, duplicateId=$duplicateId, duration=$duration, exifInfo=$exifInfo, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, hasMetadata=$hasMetadata, id=$id, isArchived=$isArchived, isFavorite=$isFavorite, isOffline=$isOffline, isTrashed=$isTrashed, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, originalMimeType=$originalMimeType, originalPath=$originalPath, owner=$owner, ownerId=$ownerId, people=$people, resized=$resized, stack=$stack, tags=$tags, thumbhash=$thumbhash, type=$type, unassignedFaces=$unassignedFaces, updatedAt=$updatedAt, visibility=$visibility]';
|
||||
String toString() => 'AssetResponseDto[checksum=$checksum, createdAt=$createdAt, deviceAssetId=$deviceAssetId, deviceId=$deviceId, duplicateId=$duplicateId, duration=$duration, exifInfo=$exifInfo, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, hasMetadata=$hasMetadata, height=$height, id=$id, isArchived=$isArchived, isFavorite=$isFavorite, isOffline=$isOffline, isTrashed=$isTrashed, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, originalMimeType=$originalMimeType, originalPath=$originalPath, owner=$owner, ownerId=$ownerId, people=$people, resized=$resized, stack=$stack, tags=$tags, thumbhash=$thumbhash, type=$type, unassignedFaces=$unassignedFaces, updatedAt=$updatedAt, visibility=$visibility, width=$width]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
@@ -235,6 +245,11 @@ class AssetResponseDto {
|
||||
json[r'fileCreatedAt'] = this.fileCreatedAt.toUtc().toIso8601String();
|
||||
json[r'fileModifiedAt'] = this.fileModifiedAt.toUtc().toIso8601String();
|
||||
json[r'hasMetadata'] = this.hasMetadata;
|
||||
if (this.height != null) {
|
||||
json[r'height'] = this.height;
|
||||
} else {
|
||||
// json[r'height'] = null;
|
||||
}
|
||||
json[r'id'] = this.id;
|
||||
json[r'isArchived'] = this.isArchived;
|
||||
json[r'isFavorite'] = this.isFavorite;
|
||||
@@ -285,6 +300,11 @@ class AssetResponseDto {
|
||||
json[r'unassignedFaces'] = this.unassignedFaces;
|
||||
json[r'updatedAt'] = this.updatedAt.toUtc().toIso8601String();
|
||||
json[r'visibility'] = this.visibility;
|
||||
if (this.width != null) {
|
||||
json[r'width'] = this.width;
|
||||
} else {
|
||||
// json[r'width'] = null;
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
@@ -307,6 +327,9 @@ class AssetResponseDto {
|
||||
fileCreatedAt: mapDateTime(json, r'fileCreatedAt', r'')!,
|
||||
fileModifiedAt: mapDateTime(json, r'fileModifiedAt', r'')!,
|
||||
hasMetadata: mapValueOfType<bool>(json, r'hasMetadata')!,
|
||||
height: json[r'height'] == null
|
||||
? null
|
||||
: num.parse('${json[r'height']}'),
|
||||
id: mapValueOfType<String>(json, r'id')!,
|
||||
isArchived: mapValueOfType<bool>(json, r'isArchived')!,
|
||||
isFavorite: mapValueOfType<bool>(json, r'isFavorite')!,
|
||||
@@ -329,6 +352,9 @@ class AssetResponseDto {
|
||||
unassignedFaces: AssetFaceWithoutPersonResponseDto.listFromJson(json[r'unassignedFaces']),
|
||||
updatedAt: mapDateTime(json, r'updatedAt', r'')!,
|
||||
visibility: AssetVisibility.fromJson(json[r'visibility'])!,
|
||||
width: json[r'width'] == null
|
||||
? null
|
||||
: num.parse('${json[r'width']}'),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
@@ -384,6 +410,7 @@ class AssetResponseDto {
|
||||
'fileCreatedAt',
|
||||
'fileModifiedAt',
|
||||
'hasMetadata',
|
||||
'height',
|
||||
'id',
|
||||
'isArchived',
|
||||
'isFavorite',
|
||||
@@ -397,6 +424,7 @@ class AssetResponseDto {
|
||||
'type',
|
||||
'updatedAt',
|
||||
'visibility',
|
||||
'width',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
30
mobile/openapi/lib/model/sync_asset_v1.dart
generated
30
mobile/openapi/lib/model/sync_asset_v1.dart
generated
@@ -18,6 +18,7 @@ class SyncAssetV1 {
|
||||
required this.duration,
|
||||
required this.fileCreatedAt,
|
||||
required this.fileModifiedAt,
|
||||
required this.height,
|
||||
required this.id,
|
||||
required this.isFavorite,
|
||||
required this.libraryId,
|
||||
@@ -29,6 +30,7 @@ class SyncAssetV1 {
|
||||
required this.thumbhash,
|
||||
required this.type,
|
||||
required this.visibility,
|
||||
required this.width,
|
||||
});
|
||||
|
||||
String checksum;
|
||||
@@ -41,6 +43,8 @@ class SyncAssetV1 {
|
||||
|
||||
DateTime? fileModifiedAt;
|
||||
|
||||
int? height;
|
||||
|
||||
String id;
|
||||
|
||||
bool isFavorite;
|
||||
@@ -63,6 +67,8 @@ class SyncAssetV1 {
|
||||
|
||||
AssetVisibility visibility;
|
||||
|
||||
int? width;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is SyncAssetV1 &&
|
||||
other.checksum == checksum &&
|
||||
@@ -70,6 +76,7 @@ class SyncAssetV1 {
|
||||
other.duration == duration &&
|
||||
other.fileCreatedAt == fileCreatedAt &&
|
||||
other.fileModifiedAt == fileModifiedAt &&
|
||||
other.height == height &&
|
||||
other.id == id &&
|
||||
other.isFavorite == isFavorite &&
|
||||
other.libraryId == libraryId &&
|
||||
@@ -80,7 +87,8 @@ class SyncAssetV1 {
|
||||
other.stackId == stackId &&
|
||||
other.thumbhash == thumbhash &&
|
||||
other.type == type &&
|
||||
other.visibility == visibility;
|
||||
other.visibility == visibility &&
|
||||
other.width == width;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
@@ -90,6 +98,7 @@ class SyncAssetV1 {
|
||||
(duration == null ? 0 : duration!.hashCode) +
|
||||
(fileCreatedAt == null ? 0 : fileCreatedAt!.hashCode) +
|
||||
(fileModifiedAt == null ? 0 : fileModifiedAt!.hashCode) +
|
||||
(height == null ? 0 : height!.hashCode) +
|
||||
(id.hashCode) +
|
||||
(isFavorite.hashCode) +
|
||||
(libraryId == null ? 0 : libraryId!.hashCode) +
|
||||
@@ -100,10 +109,11 @@ class SyncAssetV1 {
|
||||
(stackId == null ? 0 : stackId!.hashCode) +
|
||||
(thumbhash == null ? 0 : thumbhash!.hashCode) +
|
||||
(type.hashCode) +
|
||||
(visibility.hashCode);
|
||||
(visibility.hashCode) +
|
||||
(width == null ? 0 : width!.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'SyncAssetV1[checksum=$checksum, deletedAt=$deletedAt, duration=$duration, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, id=$id, isFavorite=$isFavorite, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, ownerId=$ownerId, stackId=$stackId, thumbhash=$thumbhash, type=$type, visibility=$visibility]';
|
||||
String toString() => 'SyncAssetV1[checksum=$checksum, deletedAt=$deletedAt, duration=$duration, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, height=$height, id=$id, isFavorite=$isFavorite, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, ownerId=$ownerId, stackId=$stackId, thumbhash=$thumbhash, type=$type, visibility=$visibility, width=$width]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
@@ -127,6 +137,11 @@ class SyncAssetV1 {
|
||||
json[r'fileModifiedAt'] = this.fileModifiedAt!.toUtc().toIso8601String();
|
||||
} else {
|
||||
// json[r'fileModifiedAt'] = null;
|
||||
}
|
||||
if (this.height != null) {
|
||||
json[r'height'] = this.height;
|
||||
} else {
|
||||
// json[r'height'] = null;
|
||||
}
|
||||
json[r'id'] = this.id;
|
||||
json[r'isFavorite'] = this.isFavorite;
|
||||
@@ -159,6 +174,11 @@ class SyncAssetV1 {
|
||||
}
|
||||
json[r'type'] = this.type;
|
||||
json[r'visibility'] = this.visibility;
|
||||
if (this.width != null) {
|
||||
json[r'width'] = this.width;
|
||||
} else {
|
||||
// json[r'width'] = null;
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
@@ -176,6 +196,7 @@ class SyncAssetV1 {
|
||||
duration: mapValueOfType<String>(json, r'duration'),
|
||||
fileCreatedAt: mapDateTime(json, r'fileCreatedAt', r''),
|
||||
fileModifiedAt: mapDateTime(json, r'fileModifiedAt', r''),
|
||||
height: mapValueOfType<int>(json, r'height'),
|
||||
id: mapValueOfType<String>(json, r'id')!,
|
||||
isFavorite: mapValueOfType<bool>(json, r'isFavorite')!,
|
||||
libraryId: mapValueOfType<String>(json, r'libraryId'),
|
||||
@@ -187,6 +208,7 @@ class SyncAssetV1 {
|
||||
thumbhash: mapValueOfType<String>(json, r'thumbhash'),
|
||||
type: AssetTypeEnum.fromJson(json[r'type'])!,
|
||||
visibility: AssetVisibility.fromJson(json[r'visibility'])!,
|
||||
width: mapValueOfType<int>(json, r'width'),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
@@ -239,6 +261,7 @@ class SyncAssetV1 {
|
||||
'duration',
|
||||
'fileCreatedAt',
|
||||
'fileModifiedAt',
|
||||
'height',
|
||||
'id',
|
||||
'isFavorite',
|
||||
'libraryId',
|
||||
@@ -250,6 +273,7 @@ class SyncAssetV1 {
|
||||
'thumbhash',
|
||||
'type',
|
||||
'visibility',
|
||||
'width',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
185
mobile/test/domain/repositories/sync_stream_repository_test.dart
Normal file
185
mobile/test/domain/repositories/sync_stream_repository_test.dart
Normal file
@@ -0,0 +1,185 @@
|
||||
import 'package:drift/drift.dart' as drift;
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
SyncUserV1 _createUser({String id = 'user-1'}) {
|
||||
return SyncUserV1(
|
||||
id: id,
|
||||
name: 'Test User',
|
||||
email: 'test@test.com',
|
||||
deletedAt: null,
|
||||
avatarColor: null,
|
||||
hasProfileImage: false,
|
||||
profileChangedAt: DateTime(2024, 1, 1),
|
||||
);
|
||||
}
|
||||
|
||||
SyncAssetV1 _createAsset({
|
||||
required String id,
|
||||
required String checksum,
|
||||
required String fileName,
|
||||
String ownerId = 'user-1',
|
||||
int? width,
|
||||
int? height,
|
||||
}) {
|
||||
return SyncAssetV1(
|
||||
id: id,
|
||||
checksum: checksum,
|
||||
originalFileName: fileName,
|
||||
type: AssetTypeEnum.IMAGE,
|
||||
ownerId: ownerId,
|
||||
isFavorite: false,
|
||||
fileCreatedAt: DateTime(2024, 1, 1),
|
||||
fileModifiedAt: DateTime(2024, 1, 1),
|
||||
localDateTime: DateTime(2024, 1, 1),
|
||||
visibility: AssetVisibility.timeline,
|
||||
width: width,
|
||||
height: height,
|
||||
deletedAt: null,
|
||||
duration: null,
|
||||
libraryId: null,
|
||||
livePhotoVideoId: null,
|
||||
stackId: null,
|
||||
thumbhash: null,
|
||||
);
|
||||
}
|
||||
|
||||
SyncAssetExifV1 _createExif({
|
||||
required String assetId,
|
||||
required int width,
|
||||
required int height,
|
||||
required String orientation,
|
||||
}) {
|
||||
return SyncAssetExifV1(
|
||||
assetId: assetId,
|
||||
exifImageWidth: width,
|
||||
exifImageHeight: height,
|
||||
orientation: orientation,
|
||||
city: null,
|
||||
country: null,
|
||||
dateTimeOriginal: null,
|
||||
description: null,
|
||||
exposureTime: null,
|
||||
fNumber: null,
|
||||
fileSizeInByte: null,
|
||||
focalLength: null,
|
||||
fps: null,
|
||||
iso: null,
|
||||
latitude: null,
|
||||
lensModel: null,
|
||||
longitude: null,
|
||||
make: null,
|
||||
model: null,
|
||||
modifyDate: null,
|
||||
profileDescription: null,
|
||||
projectionType: null,
|
||||
rating: null,
|
||||
state: null,
|
||||
timeZone: null,
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
late Drift db;
|
||||
late SyncStreamRepository sut;
|
||||
|
||||
setUp(() async {
|
||||
db = Drift(drift.DatabaseConnection(NativeDatabase.memory(), closeStreamsSynchronously: true));
|
||||
sut = SyncStreamRepository(db);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await db.close();
|
||||
});
|
||||
|
||||
group('SyncStreamRepository - Dimension swapping based on orientation', () {
|
||||
test('swaps dimensions for asset with rotated orientation', () async {
|
||||
final flippedOrientations = ['5', '6', '7', '8', '90', '-90'];
|
||||
|
||||
for (final orientation in flippedOrientations) {
|
||||
final assetId = 'asset-$orientation-degrees';
|
||||
|
||||
await sut.updateUsersV1([_createUser()]);
|
||||
|
||||
final asset = _createAsset(
|
||||
id: assetId,
|
||||
checksum: 'checksum-$orientation',
|
||||
fileName: 'rotated_$orientation.jpg',
|
||||
);
|
||||
await sut.updateAssetsV1([asset]);
|
||||
|
||||
final exif = _createExif(
|
||||
assetId: assetId,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
orientation: orientation, // EXIF orientation value for 90 degrees CW
|
||||
);
|
||||
await sut.updateAssetsExifV1([exif]);
|
||||
|
||||
final query = db.remoteAssetEntity.select()..where((tbl) => tbl.id.equals(assetId));
|
||||
final result = await query.getSingle();
|
||||
|
||||
expect(result.width, equals(1080));
|
||||
expect(result.height, equals(1920));
|
||||
}
|
||||
});
|
||||
|
||||
test('does not swap dimensions for asset with normal orientation', () async {
|
||||
final nonFlippedOrientations = ['1', '2', '3', '4'];
|
||||
for (final orientation in nonFlippedOrientations) {
|
||||
final assetId = 'asset-$orientation-degrees';
|
||||
|
||||
await sut.updateUsersV1([_createUser()]);
|
||||
|
||||
final asset = _createAsset(id: assetId, checksum: 'checksum-$orientation', fileName: 'normal_$orientation.jpg');
|
||||
await sut.updateAssetsV1([asset]);
|
||||
|
||||
final exif = _createExif(
|
||||
assetId: assetId,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
orientation: orientation, // EXIF orientation value for normal
|
||||
);
|
||||
await sut.updateAssetsExifV1([exif]);
|
||||
|
||||
final query = db.remoteAssetEntity.select()..where((tbl) => tbl.id.equals(assetId));
|
||||
final result = await query.getSingle();
|
||||
|
||||
expect(result.width, equals(1920));
|
||||
expect(result.height, equals(1080));
|
||||
}
|
||||
});
|
||||
|
||||
test('does not update dimensions if asset already has width and height', () async {
|
||||
const assetId = 'asset-with-dimensions';
|
||||
const existingWidth = 1920;
|
||||
const existingHeight = 1080;
|
||||
const exifWidth = 3840;
|
||||
const exifHeight = 2160;
|
||||
|
||||
await sut.updateUsersV1([_createUser()]);
|
||||
|
||||
final asset = _createAsset(
|
||||
id: assetId,
|
||||
checksum: 'checksum-with-dims',
|
||||
fileName: 'with_dimensions.jpg',
|
||||
width: existingWidth,
|
||||
height: existingHeight,
|
||||
);
|
||||
await sut.updateAssetsV1([asset]);
|
||||
|
||||
final exif = _createExif(assetId: assetId, width: exifWidth, height: exifHeight, orientation: '6');
|
||||
await sut.updateAssetsExifV1([exif]);
|
||||
|
||||
// Verify the asset still has original dimensions (not updated from EXIF)
|
||||
final query = db.remoteAssetEntity.select()..where((tbl) => tbl.id.equals(assetId));
|
||||
final result = await query.getSingle();
|
||||
|
||||
expect(result.width, equals(existingWidth), reason: 'Width should remain as originally set');
|
||||
expect(result.height, equals(existingHeight), reason: 'Height should remain as originally set');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/domain/models/exif.model.dart';
|
||||
import 'package:immich_mobile/domain/services/asset.service.dart';
|
||||
@@ -22,42 +21,6 @@ void main() {
|
||||
});
|
||||
|
||||
group('getAspectRatio', () {
|
||||
test('flips dimensions on Android for 90° and 270° orientations', () async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
||||
addTearDown(() => debugDefaultTargetPlatformOverride = null);
|
||||
|
||||
for (final orientation in [90, 270]) {
|
||||
final localAsset = TestUtils.createLocalAsset(
|
||||
id: 'local-$orientation',
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
orientation: orientation,
|
||||
);
|
||||
|
||||
final result = await sut.getAspectRatio(localAsset);
|
||||
|
||||
expect(result, 1080 / 1920, reason: 'Orientation $orientation should flip on Android');
|
||||
}
|
||||
});
|
||||
|
||||
test('does not flip dimensions on iOS regardless of orientation', () async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
addTearDown(() => debugDefaultTargetPlatformOverride = null);
|
||||
|
||||
for (final orientation in [0, 90, 270]) {
|
||||
final localAsset = TestUtils.createLocalAsset(
|
||||
id: 'local-$orientation',
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
orientation: orientation,
|
||||
);
|
||||
|
||||
final result = await sut.getAspectRatio(localAsset);
|
||||
|
||||
expect(result, 1920 / 1080, reason: 'iOS should never flip dimensions');
|
||||
}
|
||||
});
|
||||
|
||||
test('fetches dimensions from remote repository when missing from asset', () async {
|
||||
final remoteAsset = TestUtils.createRemoteAsset(id: 'remote-1', width: null, height: null);
|
||||
|
||||
@@ -112,54 +75,23 @@ void main() {
|
||||
expect(result, 1.0);
|
||||
});
|
||||
|
||||
test('handles local asset with remoteId and uses exif from remote', () async {
|
||||
test('handles local asset with remoteId and uses remote dimensions', () async {
|
||||
final localAsset = TestUtils.createLocalAsset(
|
||||
id: 'local-1',
|
||||
remoteId: 'remote-1',
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
width: null,
|
||||
height: null,
|
||||
orientation: 0,
|
||||
);
|
||||
|
||||
final exif = const ExifInfo(orientation: '6');
|
||||
|
||||
when(() => mockRemoteAssetRepository.getExif('remote-1')).thenAnswer((_) async => exif);
|
||||
when(
|
||||
() => mockRemoteAssetRepository.get('remote-1'),
|
||||
).thenAnswer((_) async => TestUtils.createRemoteAsset(id: 'remote-1', width: 1920, height: 1080));
|
||||
|
||||
final result = await sut.getAspectRatio(localAsset);
|
||||
verify(() => mockRemoteAssetRepository.get('remote-1')).called(1);
|
||||
|
||||
expect(result, 1080 / 1920);
|
||||
});
|
||||
|
||||
test('handles various flipped EXIF orientations correctly', () async {
|
||||
final flippedOrientations = ['5', '6', '7', '8', '90', '-90'];
|
||||
|
||||
for (final orientation in flippedOrientations) {
|
||||
final remoteAsset = TestUtils.createRemoteAsset(id: 'remote-$orientation', width: 1920, height: 1080);
|
||||
|
||||
final exif = ExifInfo(orientation: orientation);
|
||||
|
||||
when(() => mockRemoteAssetRepository.getExif('remote-$orientation')).thenAnswer((_) async => exif);
|
||||
|
||||
final result = await sut.getAspectRatio(remoteAsset);
|
||||
|
||||
expect(result, 1080 / 1920, reason: 'Orientation $orientation should flip dimensions');
|
||||
}
|
||||
});
|
||||
|
||||
test('handles various non-flipped EXIF orientations correctly', () async {
|
||||
final nonFlippedOrientations = ['1', '2', '3', '4'];
|
||||
|
||||
for (final orientation in nonFlippedOrientations) {
|
||||
final remoteAsset = TestUtils.createRemoteAsset(id: 'remote-$orientation', width: 1920, height: 1080);
|
||||
|
||||
final exif = ExifInfo(orientation: orientation);
|
||||
|
||||
when(() => mockRemoteAssetRepository.getExif('remote-$orientation')).thenAnswer((_) async => exif);
|
||||
|
||||
final result = await sut.getAspectRatio(remoteAsset);
|
||||
|
||||
expect(result, 1920 / 1080, reason: 'Orientation $orientation should NOT flip dimensions');
|
||||
}
|
||||
expect(result, 1920 / 1080);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
22
mobile/test/fixtures/sync_stream.stub.dart
vendored
22
mobile/test/fixtures/sync_stream.stub.dart
vendored
@@ -94,25 +94,11 @@ abstract final class SyncStreamStub {
|
||||
required String ack,
|
||||
DateTime? trashedAt,
|
||||
}) {
|
||||
return _assetV1(
|
||||
id: id,
|
||||
checksum: checksum,
|
||||
deletedAt: trashedAt ?? DateTime(2025, 1, 1),
|
||||
ack: ack,
|
||||
);
|
||||
return _assetV1(id: id, checksum: checksum, deletedAt: trashedAt ?? DateTime(2025, 1, 1), ack: ack);
|
||||
}
|
||||
|
||||
static SyncEvent assetModified({
|
||||
required String id,
|
||||
required String checksum,
|
||||
required String ack,
|
||||
}) {
|
||||
return _assetV1(
|
||||
id: id,
|
||||
checksum: checksum,
|
||||
deletedAt: null,
|
||||
ack: ack,
|
||||
);
|
||||
static SyncEvent assetModified({required String id, required String checksum, required String ack}) {
|
||||
return _assetV1(id: id, checksum: checksum, deletedAt: null, ack: ack);
|
||||
}
|
||||
|
||||
static SyncEvent _assetV1({
|
||||
@@ -140,6 +126,8 @@ abstract final class SyncStreamStub {
|
||||
thumbhash: null,
|
||||
type: AssetTypeEnum.IMAGE,
|
||||
visibility: AssetVisibility.timeline,
|
||||
width: null,
|
||||
height: null,
|
||||
),
|
||||
ack: ack,
|
||||
);
|
||||
|
||||
@@ -45,5 +45,17 @@ void main() {
|
||||
addDefault(value, keys, defaultValue);
|
||||
expect(value['alpha']['beta'], 'gamma');
|
||||
});
|
||||
|
||||
test('addDefault with null', () {
|
||||
dynamic value = jsonDecode("""
|
||||
{
|
||||
"download": {
|
||||
"archiveSize": 4294967296,
|
||||
"includeEmbeddedVideos": false
|
||||
}
|
||||
}
|
||||
""");
|
||||
expect(value['download']['unknownKey'], isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -15706,6 +15706,10 @@
|
||||
"hasMetadata": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"height": {
|
||||
"nullable": true,
|
||||
"type": "number"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -15826,6 +15830,10 @@
|
||||
"$ref": "#/components/schemas/AssetVisibility"
|
||||
}
|
||||
]
|
||||
},
|
||||
"width": {
|
||||
"nullable": true,
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -15837,6 +15845,7 @@
|
||||
"fileCreatedAt",
|
||||
"fileModifiedAt",
|
||||
"hasMetadata",
|
||||
"height",
|
||||
"id",
|
||||
"isArchived",
|
||||
"isFavorite",
|
||||
@@ -15849,7 +15858,8 @@
|
||||
"thumbhash",
|
||||
"type",
|
||||
"updatedAt",
|
||||
"visibility"
|
||||
"visibility",
|
||||
"width"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
@@ -20624,6 +20634,10 @@
|
||||
"nullable": true,
|
||||
"type": "string"
|
||||
},
|
||||
"height": {
|
||||
"nullable": true,
|
||||
"type": "integer"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -20670,6 +20684,10 @@
|
||||
"$ref": "#/components/schemas/AssetVisibility"
|
||||
}
|
||||
]
|
||||
},
|
||||
"width": {
|
||||
"nullable": true,
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -20678,6 +20696,7 @@
|
||||
"duration",
|
||||
"fileCreatedAt",
|
||||
"fileModifiedAt",
|
||||
"height",
|
||||
"id",
|
||||
"isFavorite",
|
||||
"libraryId",
|
||||
@@ -20688,7 +20707,8 @@
|
||||
"stackId",
|
||||
"thumbhash",
|
||||
"type",
|
||||
"visibility"
|
||||
"visibility",
|
||||
"width"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
|
||||
@@ -349,6 +349,7 @@ export type AssetResponseDto = {
|
||||
/** The UTC timestamp when the file was last modified on the filesystem. This reflects the last time the physical file was changed, which may be different from when the photo was originally taken. */
|
||||
fileModifiedAt: string;
|
||||
hasMetadata: boolean;
|
||||
height: number | null;
|
||||
id: string;
|
||||
isArchived: boolean;
|
||||
isFavorite: boolean;
|
||||
@@ -373,6 +374,7 @@ export type AssetResponseDto = {
|
||||
/** The UTC timestamp when the asset record was last updated in the database. This is automatically maintained by the database and reflects when any field in the asset was last modified. */
|
||||
updatedAt: string;
|
||||
visibility: AssetVisibility;
|
||||
width: number | null;
|
||||
};
|
||||
export type ContributorCountResponseDto = {
|
||||
assetCount: number;
|
||||
|
||||
@@ -340,6 +340,8 @@ export const columns = {
|
||||
'asset.originalPath',
|
||||
'asset.ownerId',
|
||||
'asset.type',
|
||||
'asset.width',
|
||||
'asset.height',
|
||||
],
|
||||
assetFiles: ['asset_file.id', 'asset_file.path', 'asset_file.type'],
|
||||
authUser: ['user.id', 'user.name', 'user.email', 'user.isAdmin', 'user.quotaUsageInBytes', 'user.quotaSizeInBytes'],
|
||||
@@ -390,6 +392,8 @@ export const columns = {
|
||||
'asset.livePhotoVideoId',
|
||||
'asset.stackId',
|
||||
'asset.libraryId',
|
||||
'asset.width',
|
||||
'asset.height',
|
||||
],
|
||||
syncAlbumUser: ['album_user.albumId as albumId', 'album_user.userId as userId', 'album_user.role'],
|
||||
syncStack: ['stack.id', 'stack.createdAt', 'stack.updatedAt', 'stack.primaryAssetId', 'stack.ownerId'],
|
||||
|
||||
@@ -34,6 +34,8 @@ export class SanitizedAssetResponseDto {
|
||||
duration!: string;
|
||||
livePhotoVideoId?: string | null;
|
||||
hasMetadata!: boolean;
|
||||
width!: number | null;
|
||||
height!: number | null;
|
||||
}
|
||||
|
||||
export class AssetResponseDto extends SanitizedAssetResponseDto {
|
||||
@@ -129,6 +131,8 @@ export type MapAsset = {
|
||||
tags?: Tag[];
|
||||
thumbhash: Buffer<ArrayBufferLike> | null;
|
||||
type: AssetType;
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
};
|
||||
|
||||
export class AssetStackResponseDto {
|
||||
@@ -190,6 +194,8 @@ export function mapAsset(entity: MapAsset, options: AssetMapOptions = {}): Asset
|
||||
duration: entity.duration ?? '0:00:00.00000',
|
||||
livePhotoVideoId: entity.livePhotoVideoId,
|
||||
hasMetadata: false,
|
||||
width: entity.width,
|
||||
height: entity.height,
|
||||
};
|
||||
return sanitizedAssetResponse as AssetResponseDto;
|
||||
}
|
||||
@@ -227,5 +233,7 @@ export function mapAsset(entity: MapAsset, options: AssetMapOptions = {}): Asset
|
||||
hasMetadata: true,
|
||||
duplicateId: entity.duplicateId,
|
||||
resized: true,
|
||||
width: entity.width,
|
||||
height: entity.height,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -118,6 +118,10 @@ export class SyncAssetV1 {
|
||||
livePhotoVideoId!: string | null;
|
||||
stackId!: string | null;
|
||||
libraryId!: string | null;
|
||||
@ApiProperty({ type: 'integer' })
|
||||
width!: number | null;
|
||||
@ApiProperty({ type: 'integer' })
|
||||
height!: number | null;
|
||||
}
|
||||
|
||||
@ExtraModel()
|
||||
|
||||
@@ -345,14 +345,10 @@ with
|
||||
"asset_exif"."projectionType",
|
||||
coalesce(
|
||||
case
|
||||
when asset_exif."exifImageHeight" = 0
|
||||
or asset_exif."exifImageWidth" = 0 then 1
|
||||
when "asset_exif"."orientation" in ('5', '6', '7', '8', '-90', '90') then round(
|
||||
asset_exif."exifImageHeight"::numeric / asset_exif."exifImageWidth"::numeric,
|
||||
3
|
||||
)
|
||||
when asset."height" = 0
|
||||
or asset."width" = 0 then 1
|
||||
else round(
|
||||
asset_exif."exifImageWidth"::numeric / asset_exif."exifImageHeight"::numeric,
|
||||
asset."width"::numeric / asset."height"::numeric,
|
||||
3
|
||||
)
|
||||
end,
|
||||
|
||||
@@ -69,6 +69,8 @@ select
|
||||
"asset"."livePhotoVideoId",
|
||||
"asset"."stackId",
|
||||
"asset"."libraryId",
|
||||
"asset"."width",
|
||||
"asset"."height",
|
||||
"album_asset"."updateId"
|
||||
from
|
||||
"album_asset" as "album_asset"
|
||||
@@ -99,6 +101,8 @@ select
|
||||
"asset"."livePhotoVideoId",
|
||||
"asset"."stackId",
|
||||
"asset"."libraryId",
|
||||
"asset"."width",
|
||||
"asset"."height",
|
||||
"asset"."updateId"
|
||||
from
|
||||
"asset" as "asset"
|
||||
@@ -134,7 +138,9 @@ select
|
||||
"asset"."duration",
|
||||
"asset"."livePhotoVideoId",
|
||||
"asset"."stackId",
|
||||
"asset"."libraryId"
|
||||
"asset"."libraryId",
|
||||
"asset"."width",
|
||||
"asset"."height"
|
||||
from
|
||||
"album_asset" as "album_asset"
|
||||
inner join "asset" on "asset"."id" = "album_asset"."assetId"
|
||||
@@ -448,6 +454,8 @@ select
|
||||
"asset"."livePhotoVideoId",
|
||||
"asset"."stackId",
|
||||
"asset"."libraryId",
|
||||
"asset"."width",
|
||||
"asset"."height",
|
||||
"asset"."updateId"
|
||||
from
|
||||
"asset" as "asset"
|
||||
@@ -740,6 +748,8 @@ select
|
||||
"asset"."livePhotoVideoId",
|
||||
"asset"."stackId",
|
||||
"asset"."libraryId",
|
||||
"asset"."width",
|
||||
"asset"."height",
|
||||
"asset"."updateId"
|
||||
from
|
||||
"asset" as "asset"
|
||||
@@ -789,6 +799,8 @@ select
|
||||
"asset"."livePhotoVideoId",
|
||||
"asset"."stackId",
|
||||
"asset"."libraryId",
|
||||
"asset"."width",
|
||||
"asset"."height",
|
||||
"asset"."updateId"
|
||||
from
|
||||
"asset" as "asset"
|
||||
|
||||
@@ -632,11 +632,9 @@ export class AssetRepository {
|
||||
.coalesce(
|
||||
eb
|
||||
.case()
|
||||
.when(sql`asset_exif."exifImageHeight" = 0 or asset_exif."exifImageWidth" = 0`)
|
||||
.when(sql`asset."height" = 0 or asset."width" = 0`)
|
||||
.then(eb.lit(1))
|
||||
.when('asset_exif.orientation', 'in', sql<string>`('5', '6', '7', '8', '-90', '90')`)
|
||||
.then(sql`round(asset_exif."exifImageHeight"::numeric / asset_exif."exifImageWidth"::numeric, 3)`)
|
||||
.else(sql`round(asset_exif."exifImageWidth"::numeric / asset_exif."exifImageHeight"::numeric, 3)`)
|
||||
.else(sql`round(asset."width"::numeric / asset."height"::numeric, 3)`)
|
||||
.end(),
|
||||
eb.lit(1),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await sql`ALTER TABLE "asset" ADD COLUMN "width" integer;`.execute(db);
|
||||
await sql`ALTER TABLE "asset" ADD COLUMN "height" integer;`.execute(db);
|
||||
|
||||
// Populate width and height from exif data with orientation-aware swapping
|
||||
await sql`
|
||||
UPDATE "asset"
|
||||
SET
|
||||
"width" = CASE
|
||||
WHEN "asset_exif"."orientation" IN ('5', '6', '7', '8', '-90', '90') THEN "asset_exif"."exifImageHeight"
|
||||
ELSE "asset_exif"."exifImageWidth"
|
||||
END,
|
||||
"height" = CASE
|
||||
WHEN "asset_exif"."orientation" IN ('5', '6', '7', '8', '-90', '90') THEN "asset_exif"."exifImageWidth"
|
||||
ELSE "asset_exif"."exifImageHeight"
|
||||
END
|
||||
FROM "asset_exif"
|
||||
WHERE "asset"."id" = "asset_exif"."assetId"
|
||||
AND ("asset_exif"."exifImageWidth" IS NOT NULL OR "asset_exif"."exifImageHeight" IS NOT NULL)
|
||||
`.execute(db);
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await sql`ALTER TABLE "asset" DROP COLUMN "width";`.execute(db);
|
||||
await sql`ALTER TABLE "asset" DROP COLUMN "height";`.execute(db);
|
||||
}
|
||||
@@ -137,4 +137,10 @@ export class AssetTable {
|
||||
|
||||
@Column({ enum: asset_visibility_enum, default: AssetVisibility.Timeline })
|
||||
visibility!: Generated<AssetVisibility>;
|
||||
|
||||
@Column({ type: 'integer', nullable: true })
|
||||
width!: number | null;
|
||||
|
||||
@Column({ type: 'integer', nullable: true })
|
||||
height!: number | null;
|
||||
}
|
||||
|
||||
@@ -141,6 +141,8 @@ export class JobService extends BaseService {
|
||||
livePhotoVideoId: asset.livePhotoVideoId,
|
||||
stackId: asset.stackId,
|
||||
libraryId: asset.libraryId,
|
||||
width: asset.width,
|
||||
height: asset.height,
|
||||
},
|
||||
exif: {
|
||||
assetId: exif.assetId,
|
||||
|
||||
@@ -221,6 +221,8 @@ describe(MetadataService.name, () => {
|
||||
fileCreatedAt: fileModifiedAt,
|
||||
fileModifiedAt,
|
||||
localDateTime: fileModifiedAt,
|
||||
width: null,
|
||||
height: null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -245,6 +247,8 @@ describe(MetadataService.name, () => {
|
||||
fileCreatedAt,
|
||||
fileModifiedAt,
|
||||
localDateTime: fileCreatedAt,
|
||||
width: null,
|
||||
height: null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -288,6 +292,8 @@ describe(MetadataService.name, () => {
|
||||
fileCreatedAt: assetStub.image.fileCreatedAt,
|
||||
fileModifiedAt: assetStub.image.fileCreatedAt,
|
||||
localDateTime: assetStub.image.fileCreatedAt,
|
||||
width: null,
|
||||
height: null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -317,6 +323,8 @@ describe(MetadataService.name, () => {
|
||||
fileCreatedAt: assetStub.withLocation.fileCreatedAt,
|
||||
fileModifiedAt: assetStub.withLocation.fileModifiedAt,
|
||||
localDateTime: new Date('2023-02-22T05:06:29.716Z'),
|
||||
width: null,
|
||||
height: null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -346,6 +354,8 @@ describe(MetadataService.name, () => {
|
||||
fileCreatedAt: assetStub.withLocation.fileCreatedAt,
|
||||
fileModifiedAt: assetStub.withLocation.fileModifiedAt,
|
||||
localDateTime: new Date('2023-02-22T05:06:29.716Z'),
|
||||
width: null,
|
||||
height: null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1517,6 +1527,49 @@ describe(MetadataService.name, () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should properly set width/height for normal images', async () => {
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
|
||||
mockReadTags({ ImageWidth: 1000, ImageHeight: 2000 });
|
||||
|
||||
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
||||
expect(mocks.asset.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
width: 1000,
|
||||
height: 2000,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should properly swap asset width/height for rotated images', async () => {
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
|
||||
mockReadTags({ ImageWidth: 1000, ImageHeight: 2000, Orientation: 6 });
|
||||
|
||||
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
||||
expect(mocks.asset.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
width: 2000,
|
||||
height: 1000,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not overwrite existing width/height if they already exist', async () => {
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue({
|
||||
...assetStub.image,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
});
|
||||
mockReadTags({ ImageWidth: 1280, ImageHeight: 720 });
|
||||
|
||||
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
||||
expect(mocks.asset.update).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
width: 1280,
|
||||
height: 720,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleQueueSidecar', () => {
|
||||
|
||||
@@ -195,6 +195,15 @@ export class MetadataService extends BaseService {
|
||||
await this.eventRepository.emit('AssetHide', { assetId: motionAsset.id, userId: motionAsset.ownerId });
|
||||
}
|
||||
|
||||
private isOrientationSidewards(orientation: ExifOrientation | number): boolean {
|
||||
return [
|
||||
ExifOrientation.MirrorHorizontalRotate270CW,
|
||||
ExifOrientation.Rotate90CW,
|
||||
ExifOrientation.MirrorHorizontalRotate90CW,
|
||||
ExifOrientation.Rotate270CW,
|
||||
].includes(orientation);
|
||||
}
|
||||
|
||||
@OnJob({ name: JobName.AssetExtractMetadataQueueAll, queue: QueueName.MetadataExtraction })
|
||||
async handleQueueMetadataExtraction(job: JobOf<JobName.AssetExtractMetadataQueueAll>): Promise<JobStatus> {
|
||||
const { force } = job;
|
||||
@@ -288,6 +297,10 @@ export class MetadataService extends BaseService {
|
||||
autoStackId: this.getAutoStackId(exifTags),
|
||||
};
|
||||
|
||||
const isSidewards = exifTags.Orientation && this.isOrientationSidewards(exifTags.Orientation);
|
||||
const assetWidth = isSidewards ? validate(height) : validate(width);
|
||||
const assetHeight = isSidewards ? validate(width) : validate(height);
|
||||
|
||||
const promises: Promise<unknown>[] = [
|
||||
this.assetRepository.upsertExif(exifData),
|
||||
this.assetRepository.update({
|
||||
@@ -296,6 +309,11 @@ export class MetadataService extends BaseService {
|
||||
localDateTime: dates.localDateTime,
|
||||
fileCreatedAt: dates.dateTimeOriginal ?? undefined,
|
||||
fileModifiedAt: stats.mtime,
|
||||
|
||||
// only update the dimensions if they don't already exist
|
||||
// we don't want to overwrite width/height that are modified by edits
|
||||
width: asset.width == null ? assetWidth : undefined,
|
||||
height: asset.height == null ? assetHeight : undefined,
|
||||
}),
|
||||
this.applyTagList(asset, exifTags),
|
||||
];
|
||||
@@ -698,12 +716,7 @@ export class MetadataService extends BaseService {
|
||||
return regionInfo;
|
||||
}
|
||||
|
||||
const isSidewards = [
|
||||
ExifOrientation.MirrorHorizontalRotate270CW,
|
||||
ExifOrientation.Rotate90CW,
|
||||
ExifOrientation.MirrorHorizontalRotate90CW,
|
||||
ExifOrientation.Rotate270CW,
|
||||
].includes(orientation);
|
||||
const isSidewards = this.isOrientationSidewards(orientation);
|
||||
|
||||
// swap image dimensions in AppliedToDimensions if orientation is sidewards
|
||||
const adjustedAppliedToDimensions = isSidewards
|
||||
@@ -949,9 +962,17 @@ export class MetadataService extends BaseService {
|
||||
private async getVideoTags(originalPath: string) {
|
||||
const { videoStreams, format } = await this.mediaRepository.probe(originalPath);
|
||||
|
||||
const tags: Pick<ImmichTags, 'Duration' | 'Orientation'> = {};
|
||||
const tags: Pick<ImmichTags, 'Duration' | 'Orientation' | 'ImageWidth' | 'ImageHeight'> = {};
|
||||
|
||||
if (videoStreams[0]) {
|
||||
// Set video dimensions
|
||||
if (videoStreams[0].width) {
|
||||
tags.ImageWidth = videoStreams[0].width;
|
||||
}
|
||||
if (videoStreams[0].height) {
|
||||
tags.ImageHeight = videoStreams[0].height;
|
||||
}
|
||||
|
||||
switch (videoStreams[0].rotation) {
|
||||
case -90: {
|
||||
tags.Orientation = ExifOrientation.Rotate90CW;
|
||||
|
||||
48
server/test/fixtures/asset.stub.ts
vendored
48
server/test/fixtures/asset.stub.ts
vendored
@@ -101,6 +101,8 @@ export const assetStub = {
|
||||
stackId: null,
|
||||
updateId: '42',
|
||||
visibility: AssetVisibility.Timeline,
|
||||
width: null,
|
||||
height: null,
|
||||
}),
|
||||
|
||||
noWebpPath: Object.freeze({
|
||||
@@ -139,6 +141,8 @@ export const assetStub = {
|
||||
stackId: null,
|
||||
updateId: '42',
|
||||
visibility: AssetVisibility.Timeline,
|
||||
width: null,
|
||||
height: null,
|
||||
}),
|
||||
|
||||
noThumbhash: Object.freeze({
|
||||
@@ -174,6 +178,8 @@ export const assetStub = {
|
||||
stackId: null,
|
||||
updateId: '42',
|
||||
visibility: AssetVisibility.Timeline,
|
||||
width: null,
|
||||
height: null,
|
||||
}),
|
||||
|
||||
primaryImage: Object.freeze({
|
||||
@@ -219,6 +225,8 @@ export const assetStub = {
|
||||
updateId: '42',
|
||||
libraryId: null,
|
||||
visibility: AssetVisibility.Timeline,
|
||||
width: null,
|
||||
height: null,
|
||||
}),
|
||||
|
||||
image: Object.freeze({
|
||||
@@ -261,8 +269,8 @@ export const assetStub = {
|
||||
stack: null,
|
||||
orientation: '',
|
||||
projectionType: null,
|
||||
height: 3840,
|
||||
width: 2160,
|
||||
height: null,
|
||||
width: null,
|
||||
visibility: AssetVisibility.Timeline,
|
||||
}),
|
||||
|
||||
@@ -304,6 +312,8 @@ export const assetStub = {
|
||||
stackId: null,
|
||||
updateId: '42',
|
||||
visibility: AssetVisibility.Timeline,
|
||||
width: null,
|
||||
height: null,
|
||||
}),
|
||||
|
||||
trashedOffline: Object.freeze({
|
||||
@@ -344,6 +354,8 @@ export const assetStub = {
|
||||
stackId: null,
|
||||
updateId: '42',
|
||||
visibility: AssetVisibility.Timeline,
|
||||
width: null,
|
||||
height: null,
|
||||
}),
|
||||
archived: Object.freeze({
|
||||
id: 'asset-id',
|
||||
@@ -383,6 +395,8 @@ export const assetStub = {
|
||||
stackId: null,
|
||||
updateId: '42',
|
||||
visibility: AssetVisibility.Timeline,
|
||||
width: null,
|
||||
height: null,
|
||||
}),
|
||||
|
||||
external: Object.freeze({
|
||||
@@ -422,6 +436,8 @@ export const assetStub = {
|
||||
stackId: null,
|
||||
stack: null,
|
||||
visibility: AssetVisibility.Timeline,
|
||||
width: null,
|
||||
height: null,
|
||||
}),
|
||||
|
||||
image1: Object.freeze({
|
||||
@@ -461,6 +477,8 @@ export const assetStub = {
|
||||
libraryId: null,
|
||||
stack: null,
|
||||
visibility: AssetVisibility.Timeline,
|
||||
width: null,
|
||||
height: null,
|
||||
}),
|
||||
|
||||
imageFrom2015: Object.freeze({
|
||||
@@ -499,6 +517,8 @@ export const assetStub = {
|
||||
duplicateId: null,
|
||||
isOffline: false,
|
||||
visibility: AssetVisibility.Timeline,
|
||||
width: null,
|
||||
height: null,
|
||||
}),
|
||||
|
||||
video: Object.freeze({
|
||||
@@ -539,6 +559,8 @@ export const assetStub = {
|
||||
libraryId: null,
|
||||
stackId: null,
|
||||
visibility: AssetVisibility.Timeline,
|
||||
width: null,
|
||||
height: null,
|
||||
}),
|
||||
|
||||
livePhotoMotionAsset: Object.freeze({
|
||||
@@ -556,6 +578,8 @@ export const assetStub = {
|
||||
files: [] as AssetFile[],
|
||||
libraryId: null,
|
||||
visibility: AssetVisibility.Hidden,
|
||||
width: null,
|
||||
height: null,
|
||||
} as MapAsset & { faces: AssetFace[]; files: AssetFile[]; exifInfo: Exif }),
|
||||
|
||||
livePhotoStillAsset: Object.freeze({
|
||||
@@ -574,6 +598,8 @@ export const assetStub = {
|
||||
files,
|
||||
faces: [] as AssetFace[],
|
||||
visibility: AssetVisibility.Timeline,
|
||||
width: null,
|
||||
height: null,
|
||||
} as MapAsset & { faces: AssetFace[]; files: AssetFile[] }),
|
||||
|
||||
livePhotoWithOriginalFileName: Object.freeze({
|
||||
@@ -594,6 +620,8 @@ export const assetStub = {
|
||||
libraryId: null,
|
||||
faces: [] as AssetFace[],
|
||||
visibility: AssetVisibility.Timeline,
|
||||
width: null,
|
||||
height: null,
|
||||
} as MapAsset & { faces: AssetFace[]; files: AssetFile[] }),
|
||||
|
||||
withLocation: Object.freeze({
|
||||
@@ -638,6 +666,8 @@ export const assetStub = {
|
||||
isOffline: false,
|
||||
tags: [],
|
||||
visibility: AssetVisibility.Timeline,
|
||||
width: null,
|
||||
height: null,
|
||||
}),
|
||||
|
||||
sidecar: Object.freeze({
|
||||
@@ -673,6 +703,8 @@ export const assetStub = {
|
||||
libraryId: null,
|
||||
stackId: null,
|
||||
visibility: AssetVisibility.Timeline,
|
||||
width: null,
|
||||
height: null,
|
||||
}),
|
||||
|
||||
sidecarWithoutExt: Object.freeze({
|
||||
@@ -705,6 +737,8 @@ export const assetStub = {
|
||||
duplicateId: null,
|
||||
isOffline: false,
|
||||
visibility: AssetVisibility.Timeline,
|
||||
width: null,
|
||||
height: null,
|
||||
}),
|
||||
|
||||
hasEncodedVideo: Object.freeze({
|
||||
@@ -744,6 +778,8 @@ export const assetStub = {
|
||||
stackId: null,
|
||||
stack: null,
|
||||
visibility: AssetVisibility.Timeline,
|
||||
width: null,
|
||||
height: null,
|
||||
}),
|
||||
|
||||
hasFileExtension: Object.freeze({
|
||||
@@ -780,6 +816,8 @@ export const assetStub = {
|
||||
duplicateId: null,
|
||||
isOffline: false,
|
||||
visibility: AssetVisibility.Timeline,
|
||||
width: null,
|
||||
height: null,
|
||||
}),
|
||||
|
||||
imageDng: Object.freeze({
|
||||
@@ -820,6 +858,8 @@ export const assetStub = {
|
||||
libraryId: null,
|
||||
stackId: null,
|
||||
visibility: AssetVisibility.Timeline,
|
||||
width: null,
|
||||
height: null,
|
||||
}),
|
||||
|
||||
imageHif: Object.freeze({
|
||||
@@ -860,6 +900,8 @@ export const assetStub = {
|
||||
libraryId: null,
|
||||
stackId: null,
|
||||
visibility: AssetVisibility.Timeline,
|
||||
width: null,
|
||||
height: null,
|
||||
}),
|
||||
panoramaTif: Object.freeze({
|
||||
id: 'asset-id',
|
||||
@@ -899,5 +941,7 @@ export const assetStub = {
|
||||
libraryId: null,
|
||||
stackId: null,
|
||||
visibility: AssetVisibility.Timeline,
|
||||
width: null,
|
||||
height: null,
|
||||
}),
|
||||
};
|
||||
|
||||
6
server/test/fixtures/shared-link.stub.ts
vendored
6
server/test/fixtures/shared-link.stub.ts
vendored
@@ -72,6 +72,8 @@ const assetResponse: AssetResponseDto = {
|
||||
libraryId: 'library-id',
|
||||
hasMetadata: true,
|
||||
visibility: AssetVisibility.Timeline,
|
||||
width: null,
|
||||
height: null,
|
||||
};
|
||||
|
||||
const assetResponseWithoutMetadata = {
|
||||
@@ -83,6 +85,8 @@ const assetResponseWithoutMetadata = {
|
||||
duration: '0:00:00.00000',
|
||||
livePhotoVideoId: null,
|
||||
hasMetadata: false,
|
||||
width: 500,
|
||||
height: 500,
|
||||
} as AssetResponseDto;
|
||||
|
||||
const albumResponse: AlbumResponseDto = {
|
||||
@@ -257,6 +261,8 @@ export const sharedLinkStub = {
|
||||
libraryId: null,
|
||||
stackId: null,
|
||||
visibility: AssetVisibility.Timeline,
|
||||
width: 500,
|
||||
height: 500,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -52,6 +52,8 @@ describe(SyncRequestType.AlbumAssetsV1, () => {
|
||||
livePhotoVideoId: null,
|
||||
stackId: null,
|
||||
libraryId: null,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
});
|
||||
const { album } = await ctx.newAlbum({ ownerId: user2.id });
|
||||
await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id });
|
||||
@@ -79,6 +81,8 @@ describe(SyncRequestType.AlbumAssetsV1, () => {
|
||||
livePhotoVideoId: asset.livePhotoVideoId,
|
||||
stackId: asset.stackId,
|
||||
libraryId: asset.libraryId,
|
||||
width: asset.width,
|
||||
height: asset.height,
|
||||
},
|
||||
type: SyncEntityType.AlbumAssetCreateV1,
|
||||
},
|
||||
|
||||
@@ -37,6 +37,8 @@ describe(SyncEntityType.AssetV1, () => {
|
||||
deletedAt: null,
|
||||
duration: '0:10:00.00000',
|
||||
libraryId: null,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
});
|
||||
|
||||
const response = await ctx.syncStream(auth, [SyncRequestType.AssetsV1]);
|
||||
@@ -60,6 +62,8 @@ describe(SyncEntityType.AssetV1, () => {
|
||||
stackId: null,
|
||||
livePhotoVideoId: null,
|
||||
libraryId: asset.libraryId,
|
||||
width: asset.width,
|
||||
height: asset.height,
|
||||
},
|
||||
type: 'AssetV1',
|
||||
},
|
||||
|
||||
@@ -66,6 +66,8 @@ describe(SyncRequestType.PartnerAssetsV1, () => {
|
||||
stackId: null,
|
||||
livePhotoVideoId: null,
|
||||
libraryId: asset.libraryId,
|
||||
width: null,
|
||||
height: null,
|
||||
},
|
||||
type: SyncEntityType.PartnerAssetV1,
|
||||
},
|
||||
|
||||
@@ -249,6 +249,8 @@ const assetFactory = (asset: Partial<MapAsset> = {}) => ({
|
||||
thumbhash: null,
|
||||
type: AssetType.Image,
|
||||
visibility: AssetVisibility.Timeline,
|
||||
width: null,
|
||||
height: null,
|
||||
...asset,
|
||||
});
|
||||
|
||||
|
||||
@@ -28,6 +28,8 @@ export const assetFactory = Sync.makeFactory<AssetResponseDto>({
|
||||
isOffline: Sync.each(() => faker.datatype.boolean()),
|
||||
hasMetadata: Sync.each(() => faker.datatype.boolean()),
|
||||
visibility: AssetVisibility.Timeline,
|
||||
width: faker.number.int({ min: 100, max: 1000 }),
|
||||
height: faker.number.int({ min: 100, max: 1000 }),
|
||||
});
|
||||
|
||||
export const timelineAssetFactory = Sync.makeFactory<TimelineAsset>({
|
||||
|
||||
Reference in New Issue
Block a user