Switch to use exiv2 for client side exif

This commit is contained in:
Ming Ming 2024-12-21 14:33:56 +08:00
parent f1704cc37d
commit cebabdc6a8
9 changed files with 190 additions and 140 deletions

View file

@ -1,9 +1,9 @@
import 'package:equatable/equatable.dart';
import 'package:exifdart/exifdart.dart';
import 'package:intl/intl.dart';
import 'package:logging/logging.dart';
import 'package:np_codegen/np_codegen.dart';
import 'package:np_common/type.dart';
import 'package:np_exiv2/np_exiv2.dart';
part 'exif.g.dart';
@ -47,9 +47,9 @@ class Exif with EquatableMixin {
.map((e) {
dynamic jsonValue;
if (e.value is Rational) {
jsonValue = e.value.toJson();
jsonValue = (e.value as Rational).toJson();
} else if (e.value is List) {
jsonValue = e.value.map((e) {
jsonValue = (e.value as List).map((e) {
if (e is Rational) {
return e.toJson();
} else {
@ -69,11 +69,12 @@ class Exif with EquatableMixin {
json.entries.map((e) {
dynamic exifValue;
if (e.value is Map) {
exifValue = Rational.fromJson(e.value.cast<String, dynamic>());
exifValue =
_rationalFromJson((e.value as Map).cast<String, dynamic>());
} else if (e.value is List) {
exifValue = e.value.map((e) {
exifValue = (e.value as List).map((e) {
if (e is Map) {
return Rational.fromJson(e.cast<String, dynamic>());
return _rationalFromJson(e.cast<String, dynamic>());
} else {
return e;
}
@ -187,3 +188,15 @@ class Exif with EquatableMixin {
static final dateTimeFormat = DateFormat("yyyy:MM:dd HH:mm:ss");
}
extension on Rational {
Map<String, int> toJson() => {
"n": numerator,
"d": denominator,
};
}
Rational _rationalFromJson(Map<String, dynamic> json) {
return Rational(
json["n"] ?? json["numerator"], json["d"] ?? json["denominator"]);
}

View file

@ -1,6 +1,6 @@
import 'package:exifdart/exifdart.dart';
import 'package:flutter/foundation.dart';
import 'package:nc_photos/entity/exif.dart';
import 'package:np_exiv2/np_exiv2.dart';
extension ExifExtension on Exif {
double? get gpsLatitudeDeg {

View file

@ -140,5 +140,8 @@ final supportedVideoFormatMimes =
const _metadataSupportedFormatMimes = [
"image/jpeg",
"image/png",
"image/webp",
"image/heic",
"image/gif",
];

View file

@ -1,19 +1,15 @@
import 'dart:io' as io;
import 'dart:typed_data';
import 'package:exifdart/exifdart.dart' as exifdart;
import 'package:exifdart/exifdart_io.dart';
import 'package:exifdart/exifdart_memory.dart';
import 'package:image_size_getter/image_size_getter.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/debug_util.dart';
import 'package:nc_photos/entity/exif.dart';
import 'package:nc_photos/entity/file.dart' as app;
import 'package:nc_photos/entity/file_util.dart' as file_util;
import 'package:nc_photos/file_extension.dart';
import 'package:nc_photos/image_size_getter_util.dart';
import 'package:np_codegen/np_codegen.dart';
import 'package:np_collection/np_collection.dart';
import 'package:np_exiv2/np_exiv2.dart' as exiv2;
part 'load_metadata.g.dart';
@ -24,8 +20,7 @@ class LoadMetadata {
Account account, app.File file, Uint8List binary) {
return _loadMetadata(
mime: file.contentType ?? "",
exifdartReaderBuilder: () => MemoryBlobReader(binary),
imageSizeGetterInputBuilder: () => AsyncMemoryInput(binary),
reader: () => exiv2.readBuffer(binary),
filename: file.path,
);
}
@ -37,90 +32,70 @@ class LoadMetadata {
mime = mime ?? await file.readMime();
return _loadMetadata(
mime: mime ?? "",
exifdartReaderBuilder: () => FileReader(file),
imageSizeGetterInputBuilder: () => AsyncFileInput(file),
reader: () => exiv2.readFile(file.path),
filename: file.path,
);
}
Future<app.Metadata> _loadMetadata({
required String mime,
required exifdart.AbstractBlobReader Function() exifdartReaderBuilder,
required AsyncImageInput Function() imageSizeGetterInputBuilder,
required exiv2.ReadResult Function() reader,
String? filename,
}) async {
var metadata = exifdart.Metadata();
if (file_util.isMetadataSupportedMime(mime)) {
final exiv2.ReadResult result;
try {
metadata = await exifdart.readMetadata(exifdartReaderBuilder());
result = reader();
} catch (e, stacktrace) {
_log.shout(
"[_loadMetadata] Failed while readMetadata for $mime file: ${logFilename(filename)}",
e,
stacktrace);
// ignore exif
rethrow;
}
}
int imageWidth = 0, imageHeight = 0;
if (metadata.imageWidth == null || metadata.imageHeight == null) {
final metadata = {
...result.iptcData
.map((e) {
try {
final resolution =
await AsyncImageSizeGetter.getSize(imageSizeGetterInputBuilder());
// image size getter doesn't handle exif orientation
if (metadata.exif?.containsKey("Orientation") == true &&
metadata.exif!["Orientation"] >= 5 &&
metadata.exif!["Orientation"] <= 8) {
// 90 deg CW/CCW
imageWidth = resolution.height;
imageHeight = resolution.width;
} else {
imageWidth = resolution.width;
imageHeight = resolution.height;
}
} catch (e, stacktrace) {
// is this even an image file?
return MapEntry(e.tagKey, e.value.asTyped());
} catch (_) {
_log.shout(
"[_loadMetadata] Failed while getSize for $mime file: ${logFilename(filename)}",
e,
stacktrace);
"[_loadMetadata] Unable to convert IPTC tag: ${e.tagKey}, ${e.value.toDebugString()}");
return null;
}
} else {
if (metadata.rotateAngleCcw != null &&
metadata.rotateAngleCcw! % 180 != 0) {
imageWidth = metadata.imageHeight!;
imageHeight = metadata.imageWidth!;
} else {
imageWidth = metadata.imageWidth!;
imageHeight = metadata.imageHeight!;
})
.nonNulls
.toMap(),
...result.exifData
.map((e) {
try {
return MapEntry(e.tagKey, e.value.asTyped());
} catch (_) {
_log.shout(
"[_loadMetadata] Unable to convert EXIF tag: ${e.tagKey}, ${e.value.toDebugString()}");
return null;
}
}
final map = {
if (metadata.exif != null) "exif": metadata.exif,
if (imageWidth > 0 && imageHeight > 0)
"resolution": {
"width": imageWidth,
"height": imageHeight,
},
})
.nonNulls
.toMap(),
};
return _buildMetadata(map);
var imageWidth = 0, imageHeight = 0;
// exiv2 doesn't handle exif orientation
if (metadata.containsKey("Orientation") &&
metadata["Orientation"] as int >= 5 &&
metadata["Orientation"] as int <= 8) {
// 90 deg CW/CCW
imageWidth = result.height;
imageHeight = result.width;
} else {
imageWidth = result.width;
imageHeight = result.height;
}
app.Metadata _buildMetadata(Map<String, dynamic> map) {
int? imageWidth, imageHeight;
Exif? exif;
if (map.containsKey("resolution")) {
imageWidth = map["resolution"]["width"];
imageHeight = map["resolution"]["height"];
}
if (map.containsKey("exif")) {
exif = Exif(map["exif"]);
}
return app.Metadata(
imageWidth: imageWidth,
imageHeight: imageHeight,
exif: exif,
exif: metadata.isNotEmpty ? Exif(metadata) : null,
);
}
}

View file

@ -397,6 +397,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.0.5"
euc:
dependency: transitive
description:
name: euc
sha256: "569b21c71ee5a3aa3e96f70512cd10d1fad7b438429fade65ec2a50038a09dc5"
url: "https://pub.dev"
source: hosted
version: "1.0.6+8"
event_bus:
dependency: "direct main"
description:
@ -405,15 +413,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.0.1"
exifdart:
dependency: "direct main"
description:
path: "."
ref: "1.3.0"
resolved-ref: c91db71fbb3d3cee1c79716502dd37eadc8327ff
url: "https://gitlab.com/nc-photos/exifdart.git"
source: git
version: "1.3.0"
fake_async:
dependency: transitive
description:
@ -1148,6 +1147,20 @@ packages:
relative: true
source: path
version: "1.0.0"
np_exiv2:
dependency: "direct main"
description:
path: "../np_exiv2"
relative: true
source: path
version: "1.0.0"
np_exiv2_lib:
dependency: "direct main"
description:
path: "../np_exiv2_lib"
relative: true
source: path
version: "0.0.1"
np_geocoder:
dependency: "direct main"
description:

View file

@ -56,10 +56,6 @@ dependencies:
dynamic_color: ^1.7.0
equatable: ^2.0.5
event_bus: ^2.0.0
exifdart:
git:
url: https://gitlab.com/nc-photos/exifdart.git
ref: 1.3.0
flex_seed_scheme: ^1.5.0
fluttertoast: ^8.2.5
flutter_background_service:
@ -107,6 +103,10 @@ dependencies:
path: ../np_datetime
np_db:
path: ../np_db
np_exiv2:
path: ../np_exiv2
np_exiv2_lib:
path: ../np_exiv2_lib
np_geocoder:
path: ../np_geocoder
np_gps_map:

View file

@ -1,8 +1,8 @@
import 'dart:typed_data';
import 'package:equatable/equatable.dart';
import 'package:exifdart/exifdart.dart';
import 'package:nc_photos/entity/exif.dart';
import 'package:np_exiv2/np_exiv2.dart';
import 'package:test/test.dart';
void main() {
@ -96,7 +96,7 @@ void main() {
test("Rational", () {
final exif = Exif(<String, dynamic>{
"XResolution": Rational(72, 1),
"XResolution": const Rational(72, 1),
});
expect(exif.toJson(), <String, dynamic>{
"XResolution": {"n": 72, "d": 1},
@ -114,7 +114,11 @@ void main() {
test("List<Rational>", () {
final exif = Exif(<String, dynamic>{
"GPSLatitude": [Rational(2, 1), Rational(3, 1), Rational(4, 100)],
"GPSLatitude": [
const Rational(2, 1),
const Rational(3, 1),
const Rational(4, 100),
],
});
expect(exif.toJson(), <String, dynamic>{
"GPSLatitude": [
@ -164,12 +168,20 @@ void main() {
}));
});
test("Rational", () {
test("Rational (legacy)", () {
final json = <String, dynamic>{
"XResolution": {"numerator": 72, "denominator": 1},
};
final Rational exif = Exif.fromJson(json)["XResolution"];
expect(exif.makeComparable(), _Rational(72, 1));
expect(exif.makeComparable(), const _Rational(72, 1));
});
test("Rational", () {
final json = <String, dynamic>{
"XResolution": {"n": 72, "d": 1},
};
final Rational exif = Exif.fromJson(json)["XResolution"];
expect(exif.makeComparable(), const _Rational(72, 1));
});
test("List<int>", () {
@ -193,8 +205,11 @@ void main() {
};
final List<Rational> exif =
Exif.fromJson(json)["GPSLatitude"].cast<Rational>();
expect(exif.map((e) => e.makeComparable()).toList(),
[_Rational(2, 1), _Rational(3, 1), _Rational(4, 100)]);
expect(exif.map((e) => e.makeComparable()).toList(), [
const _Rational(2, 1),
const _Rational(3, 1),
const _Rational(4, 100)
]);
});
});
@ -212,6 +227,21 @@ void main() {
expect(exif.dateTimeOriginal, null);
});
});
group("from Nextcloud", () {
test("Rational", () {
final exif = Exif({
"ExposureTime": "123/456",
});
expect(exif.exposureTime?.makeComparable(), const _Rational(123, 456));
});
test("int", () {
final exif = Exif({
"ISOSpeedRatings": "1234",
});
expect(exif.isoSpeedRatings, 1234);
});
});
});
}
@ -220,7 +250,7 @@ extension on Rational {
}
class _Rational extends Rational with EquatableMixin {
_Rational(super.numerator, super.denominator);
const _Rational(super.numerator, super.denominator);
factory _Rational.of(Rational r) {
return _Rational(r.numerator, r.denominator);

View file

@ -1,5 +1,5 @@
import 'package:exifdart/exifdart.dart';
import 'package:nc_photos/entity/exif_util.dart';
import 'package:np_exiv2/np_exiv2.dart';
import 'package:test/test.dart';
void main() {
@ -8,14 +8,14 @@ void main() {
test("United Nations HQ", () {
// 40° 44 58 N, 73° 58 5 W
final lat = gpsDmsToDouble([
Rational(40, 1),
Rational(44, 1),
Rational(58, 1),
const Rational(40, 1),
const Rational(44, 1),
const Rational(58, 1),
]);
final lng = gpsDmsToDouble([
Rational(73, 1),
Rational(58, 1),
Rational(5, 1),
const Rational(73, 1),
const Rational(58, 1),
const Rational(5, 1),
]);
expect(lat, closeTo(40.749444, .00001));
expect(lng, closeTo(73.968056, .00001));
@ -24,14 +24,14 @@ void main() {
test("East Cape Lighthouse", () {
// 37° 41 20.2 S, 178° 32 53.3 E
final lat = gpsDmsToDouble([
Rational(37, 1),
Rational(41, 1),
Rational(202, 10),
const Rational(37, 1),
const Rational(41, 1),
const Rational(202, 10),
]);
final lng = gpsDmsToDouble([
Rational(178, 1),
Rational(32, 1),
Rational(533, 10),
const Rational(178, 1),
const Rational(32, 1),
const Rational(533, 10),
]);
expect(lat, closeTo(37.688944, .00001));
expect(lng, closeTo(178.548139, .00001));
@ -46,17 +46,17 @@ void main() {
expect(
lat.map((e) => e.toString()),
[
Rational(40, 1).toString(),
Rational(44, 1).toString(),
Rational(5799, 100).toString(),
const Rational(40, 1).toString(),
const Rational(44, 1).toString(),
const Rational(5799, 100).toString(),
],
);
expect(
lng.map((e) => e.toString()),
[
Rational(73, 1).toString(),
Rational(58, 1).toString(),
Rational(500, 100).toString(),
const Rational(73, 1).toString(),
const Rational(58, 1).toString(),
const Rational(500, 100).toString(),
],
);
});
@ -68,17 +68,17 @@ void main() {
expect(
lat.map((e) => e.toString()),
[
Rational(37, 1).toString(),
Rational(41, 1).toString(),
Rational(2019, 100).toString(),
const Rational(37, 1).toString(),
const Rational(41, 1).toString(),
const Rational(2019, 100).toString(),
],
);
expect(
lng.map((e) => e.toString()),
[
Rational(178, 1).toString(),
Rational(32, 1).toString(),
Rational(5330, 100).toString(),
const Rational(178, 1).toString(),
const Rational(32, 1).toString(),
const Rational(5330, 100).toString(),
],
);
});
@ -88,21 +88,21 @@ void main() {
test("<1000", () {
expect(
doubleToRational(123.456789123).toString(),
Rational(12345678, 100000).toString(),
const Rational(12345678, 100000).toString(),
);
});
test(">1000 <100000", () {
expect(
doubleToRational(12345.6789123).toString(),
Rational(12345678, 1000).toString(),
const Rational(12345678, 1000).toString(),
);
});
test(">100000", () {
expect(
doubleToRational(12345678.9123).toString(),
Rational(12345678, 1).toString(),
const Rational(12345678, 1).toString(),
);
});
});

View file

@ -1,10 +1,10 @@
import 'package:clock/clock.dart';
import 'package:exifdart/exifdart.dart' hide Metadata;
import 'package:flutter/foundation.dart';
import 'package:nc_photos/entity/exif.dart';
import 'package:nc_photos/entity/file.dart';
import 'package:nc_photos/entity/file_descriptor.dart';
import 'package:np_common/or_null.dart';
import 'package:np_exiv2/np_exiv2.dart';
import 'package:np_string/np_string.dart';
import 'package:test/test.dart';
@ -1266,11 +1266,19 @@ void _fromApiGpsPlace1() {
expect(
actual?.exif,
_MetadataGpsMatcher(Exif({
"GPSLatitude": [Rational(40, 1), Rational(44, 1), Rational(5799, 100)],
"GPSLatitude": [
const Rational(40, 1),
const Rational(44, 1),
const Rational(5799, 100),
],
"GPSLatitudeRef": "N",
"GPSLongitude": [Rational(73, 1), Rational(58, 1), Rational(500, 100)],
"GPSLongitude": [
const Rational(73, 1),
const Rational(58, 1),
const Rational(500, 100),
],
"GPSLongitudeRef": "W",
"GPSAltitude": Rational(1234567, 100000),
"GPSAltitude": const Rational(1234567, 100000),
"GPSAltitudeRef": 0,
})),
);
@ -1292,11 +1300,19 @@ void _fromApiGpsPlace2() {
expect(
actual?.exif,
_MetadataGpsMatcher(Exif({
"GPSLatitude": [Rational(37, 1), Rational(41, 1), Rational(2019, 100)],
"GPSLatitude": [
const Rational(37, 1),
const Rational(41, 1),
const Rational(2019, 100),
],
"GPSLatitudeRef": "S",
"GPSLongitude": [Rational(178, 1), Rational(32, 1), Rational(5330, 100)],
"GPSLongitude": [
const Rational(178, 1),
const Rational(32, 1),
const Rational(5330, 100),
],
"GPSLongitudeRef": "E",
"GPSAltitude": Rational(1234567, 100000),
"GPSAltitude": const Rational(1234567, 100000),
"GPSAltitudeRef": 1,
})),
);