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:equatable/equatable.dart';
import 'package:exifdart/exifdart.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:np_codegen/np_codegen.dart'; import 'package:np_codegen/np_codegen.dart';
import 'package:np_common/type.dart'; import 'package:np_common/type.dart';
import 'package:np_exiv2/np_exiv2.dart';
part 'exif.g.dart'; part 'exif.g.dart';
@ -47,9 +47,9 @@ class Exif with EquatableMixin {
.map((e) { .map((e) {
dynamic jsonValue; dynamic jsonValue;
if (e.value is Rational) { if (e.value is Rational) {
jsonValue = e.value.toJson(); jsonValue = (e.value as Rational).toJson();
} else if (e.value is List) { } else if (e.value is List) {
jsonValue = e.value.map((e) { jsonValue = (e.value as List).map((e) {
if (e is Rational) { if (e is Rational) {
return e.toJson(); return e.toJson();
} else { } else {
@ -69,11 +69,12 @@ class Exif with EquatableMixin {
json.entries.map((e) { json.entries.map((e) {
dynamic exifValue; dynamic exifValue;
if (e.value is Map) { 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) { } else if (e.value is List) {
exifValue = e.value.map((e) { exifValue = (e.value as List).map((e) {
if (e is Map) { if (e is Map) {
return Rational.fromJson(e.cast<String, dynamic>()); return _rationalFromJson(e.cast<String, dynamic>());
} else { } else {
return e; return e;
} }
@ -187,3 +188,15 @@ class Exif with EquatableMixin {
static final dateTimeFormat = DateFormat("yyyy:MM:dd HH:mm:ss"); 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:flutter/foundation.dart';
import 'package:nc_photos/entity/exif.dart'; import 'package:nc_photos/entity/exif.dart';
import 'package:np_exiv2/np_exiv2.dart';
extension ExifExtension on Exif { extension ExifExtension on Exif {
double? get gpsLatitudeDeg { double? get gpsLatitudeDeg {

View file

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

View file

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

View file

@ -397,6 +397,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.5" 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: event_bus:
dependency: "direct main" dependency: "direct main"
description: description:
@ -405,15 +413,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.1" 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: fake_async:
dependency: transitive dependency: transitive
description: description:
@ -1148,6 +1147,20 @@ packages:
relative: true relative: true
source: path source: path
version: "1.0.0" 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: np_geocoder:
dependency: "direct main" dependency: "direct main"
description: description:

View file

@ -56,10 +56,6 @@ dependencies:
dynamic_color: ^1.7.0 dynamic_color: ^1.7.0
equatable: ^2.0.5 equatable: ^2.0.5
event_bus: ^2.0.0 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 flex_seed_scheme: ^1.5.0
fluttertoast: ^8.2.5 fluttertoast: ^8.2.5
flutter_background_service: flutter_background_service:
@ -107,6 +103,10 @@ dependencies:
path: ../np_datetime path: ../np_datetime
np_db: np_db:
path: ../np_db path: ../np_db
np_exiv2:
path: ../np_exiv2
np_exiv2_lib:
path: ../np_exiv2_lib
np_geocoder: np_geocoder:
path: ../np_geocoder path: ../np_geocoder
np_gps_map: np_gps_map:

View file

@ -1,8 +1,8 @@
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:exifdart/exifdart.dart';
import 'package:nc_photos/entity/exif.dart'; import 'package:nc_photos/entity/exif.dart';
import 'package:np_exiv2/np_exiv2.dart';
import 'package:test/test.dart'; import 'package:test/test.dart';
void main() { void main() {
@ -96,7 +96,7 @@ void main() {
test("Rational", () { test("Rational", () {
final exif = Exif(<String, dynamic>{ final exif = Exif(<String, dynamic>{
"XResolution": Rational(72, 1), "XResolution": const Rational(72, 1),
}); });
expect(exif.toJson(), <String, dynamic>{ expect(exif.toJson(), <String, dynamic>{
"XResolution": {"n": 72, "d": 1}, "XResolution": {"n": 72, "d": 1},
@ -114,7 +114,11 @@ void main() {
test("List<Rational>", () { test("List<Rational>", () {
final exif = Exif(<String, dynamic>{ 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>{ expect(exif.toJson(), <String, dynamic>{
"GPSLatitude": [ "GPSLatitude": [
@ -164,12 +168,20 @@ void main() {
})); }));
}); });
test("Rational", () { test("Rational (legacy)", () {
final json = <String, dynamic>{ final json = <String, dynamic>{
"XResolution": {"numerator": 72, "denominator": 1}, "XResolution": {"numerator": 72, "denominator": 1},
}; };
final Rational exif = Exif.fromJson(json)["XResolution"]; 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>", () { test("List<int>", () {
@ -193,8 +205,11 @@ void main() {
}; };
final List<Rational> exif = final List<Rational> exif =
Exif.fromJson(json)["GPSLatitude"].cast<Rational>(); Exif.fromJson(json)["GPSLatitude"].cast<Rational>();
expect(exif.map((e) => e.makeComparable()).toList(), expect(exif.map((e) => e.makeComparable()).toList(), [
[_Rational(2, 1), _Rational(3, 1), _Rational(4, 100)]); const _Rational(2, 1),
const _Rational(3, 1),
const _Rational(4, 100)
]);
}); });
}); });
@ -212,6 +227,21 @@ void main() {
expect(exif.dateTimeOriginal, null); 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 { class _Rational extends Rational with EquatableMixin {
_Rational(super.numerator, super.denominator); const _Rational(super.numerator, super.denominator);
factory _Rational.of(Rational r) { factory _Rational.of(Rational r) {
return _Rational(r.numerator, r.denominator); 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:nc_photos/entity/exif_util.dart';
import 'package:np_exiv2/np_exiv2.dart';
import 'package:test/test.dart'; import 'package:test/test.dart';
void main() { void main() {
@ -8,14 +8,14 @@ void main() {
test("United Nations HQ", () { test("United Nations HQ", () {
// 40° 44 58 N, 73° 58 5 W // 40° 44 58 N, 73° 58 5 W
final lat = gpsDmsToDouble([ final lat = gpsDmsToDouble([
Rational(40, 1), const Rational(40, 1),
Rational(44, 1), const Rational(44, 1),
Rational(58, 1), const Rational(58, 1),
]); ]);
final lng = gpsDmsToDouble([ final lng = gpsDmsToDouble([
Rational(73, 1), const Rational(73, 1),
Rational(58, 1), const Rational(58, 1),
Rational(5, 1), const Rational(5, 1),
]); ]);
expect(lat, closeTo(40.749444, .00001)); expect(lat, closeTo(40.749444, .00001));
expect(lng, closeTo(73.968056, .00001)); expect(lng, closeTo(73.968056, .00001));
@ -24,14 +24,14 @@ void main() {
test("East Cape Lighthouse", () { test("East Cape Lighthouse", () {
// 37° 41 20.2 S, 178° 32 53.3 E // 37° 41 20.2 S, 178° 32 53.3 E
final lat = gpsDmsToDouble([ final lat = gpsDmsToDouble([
Rational(37, 1), const Rational(37, 1),
Rational(41, 1), const Rational(41, 1),
Rational(202, 10), const Rational(202, 10),
]); ]);
final lng = gpsDmsToDouble([ final lng = gpsDmsToDouble([
Rational(178, 1), const Rational(178, 1),
Rational(32, 1), const Rational(32, 1),
Rational(533, 10), const Rational(533, 10),
]); ]);
expect(lat, closeTo(37.688944, .00001)); expect(lat, closeTo(37.688944, .00001));
expect(lng, closeTo(178.548139, .00001)); expect(lng, closeTo(178.548139, .00001));
@ -46,17 +46,17 @@ void main() {
expect( expect(
lat.map((e) => e.toString()), lat.map((e) => e.toString()),
[ [
Rational(40, 1).toString(), const Rational(40, 1).toString(),
Rational(44, 1).toString(), const Rational(44, 1).toString(),
Rational(5799, 100).toString(), const Rational(5799, 100).toString(),
], ],
); );
expect( expect(
lng.map((e) => e.toString()), lng.map((e) => e.toString()),
[ [
Rational(73, 1).toString(), const Rational(73, 1).toString(),
Rational(58, 1).toString(), const Rational(58, 1).toString(),
Rational(500, 100).toString(), const Rational(500, 100).toString(),
], ],
); );
}); });
@ -68,17 +68,17 @@ void main() {
expect( expect(
lat.map((e) => e.toString()), lat.map((e) => e.toString()),
[ [
Rational(37, 1).toString(), const Rational(37, 1).toString(),
Rational(41, 1).toString(), const Rational(41, 1).toString(),
Rational(2019, 100).toString(), const Rational(2019, 100).toString(),
], ],
); );
expect( expect(
lng.map((e) => e.toString()), lng.map((e) => e.toString()),
[ [
Rational(178, 1).toString(), const Rational(178, 1).toString(),
Rational(32, 1).toString(), const Rational(32, 1).toString(),
Rational(5330, 100).toString(), const Rational(5330, 100).toString(),
], ],
); );
}); });
@ -88,21 +88,21 @@ void main() {
test("<1000", () { test("<1000", () {
expect( expect(
doubleToRational(123.456789123).toString(), doubleToRational(123.456789123).toString(),
Rational(12345678, 100000).toString(), const Rational(12345678, 100000).toString(),
); );
}); });
test(">1000 <100000", () { test(">1000 <100000", () {
expect( expect(
doubleToRational(12345.6789123).toString(), doubleToRational(12345.6789123).toString(),
Rational(12345678, 1000).toString(), const Rational(12345678, 1000).toString(),
); );
}); });
test(">100000", () { test(">100000", () {
expect( expect(
doubleToRational(12345678.9123).toString(), 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:clock/clock.dart';
import 'package:exifdart/exifdart.dart' hide Metadata;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:nc_photos/entity/exif.dart'; import 'package:nc_photos/entity/exif.dart';
import 'package:nc_photos/entity/file.dart'; import 'package:nc_photos/entity/file.dart';
import 'package:nc_photos/entity/file_descriptor.dart'; import 'package:nc_photos/entity/file_descriptor.dart';
import 'package:np_common/or_null.dart'; import 'package:np_common/or_null.dart';
import 'package:np_exiv2/np_exiv2.dart';
import 'package:np_string/np_string.dart'; import 'package:np_string/np_string.dart';
import 'package:test/test.dart'; import 'package:test/test.dart';
@ -1266,11 +1266,19 @@ void _fromApiGpsPlace1() {
expect( expect(
actual?.exif, actual?.exif,
_MetadataGpsMatcher(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", "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", "GPSLongitudeRef": "W",
"GPSAltitude": Rational(1234567, 100000), "GPSAltitude": const Rational(1234567, 100000),
"GPSAltitudeRef": 0, "GPSAltitudeRef": 0,
})), })),
); );
@ -1292,11 +1300,19 @@ void _fromApiGpsPlace2() {
expect( expect(
actual?.exif, actual?.exif,
_MetadataGpsMatcher(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", "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", "GPSLongitudeRef": "E",
"GPSAltitude": Rational(1234567, 100000), "GPSAltitude": const Rational(1234567, 100000),
"GPSAltitudeRef": 1, "GPSAltitudeRef": 1,
})), })),
); );