diff --git a/app/lib/entity/exif.dart b/app/lib/entity/exif.dart index f29368dc..71970c14 100644 --- a/app/lib/entity/exif.dart +++ b/app/lib/entity/exif.dart @@ -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()); + exifValue = + _rationalFromJson((e.value as Map).cast()); } 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()); + return _rationalFromJson(e.cast()); } 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 toJson() => { + "n": numerator, + "d": denominator, + }; +} + +Rational _rationalFromJson(Map json) { + return Rational( + json["n"] ?? json["numerator"], json["d"] ?? json["denominator"]); +} diff --git a/app/lib/entity/exif_util.dart b/app/lib/entity/exif_util.dart index 9f1b0b4e..8fac44e0 100644 --- a/app/lib/entity/exif_util.dart +++ b/app/lib/entity/exif_util.dart @@ -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 { diff --git a/app/lib/entity/file_util.dart b/app/lib/entity/file_util.dart index 2eb5453c..9ee7e640 100644 --- a/app/lib/entity/file_util.dart +++ b/app/lib/entity/file_util.dart @@ -140,5 +140,8 @@ final supportedVideoFormatMimes = const _metadataSupportedFormatMimes = [ "image/jpeg", + "image/png", + "image/webp", "image/heic", + "image/gif", ]; diff --git a/app/lib/use_case/load_metadata.dart b/app/lib/use_case/load_metadata.dart index 0138c2ce..11d1eaad 100644 --- a/app/lib/use_case/load_metadata.dart +++ b/app/lib/use_case/load_metadata.dart @@ -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 _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)) { - try { - metadata = await exifdart.readMetadata(exifdartReaderBuilder()); - } catch (e, stacktrace) { - _log.shout( - "[_loadMetadata] Failed while readMetadata for $mime file: ${logFilename(filename)}", - e, - stacktrace); - // ignore exif - } + final exiv2.ReadResult result; + try { + result = reader(); + } catch (e, stacktrace) { + _log.shout( + "[_loadMetadata] Failed while readMetadata for $mime file: ${logFilename(filename)}", + e, + stacktrace); + rethrow; } - - int imageWidth = 0, imageHeight = 0; - if (metadata.imageWidth == null || metadata.imageHeight == null) { - 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? - _log.shout( - "[_loadMetadata] Failed while getSize for $mime file: ${logFilename(filename)}", - e, - stacktrace); - } - } else { - 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, - }, + final metadata = { + ...result.iptcData + .map((e) { + try { + return MapEntry(e.tagKey, e.value.asTyped()); + } catch (_) { + _log.shout( + "[_loadMetadata] Unable to convert IPTC tag: ${e.tagKey}, ${e.value.toDebugString()}"); + return null; + } + }) + .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; + } + }) + .nonNulls + .toMap(), }; - return _buildMetadata(map); - } - app.Metadata _buildMetadata(Map 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"]); + 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; } + return app.Metadata( imageWidth: imageWidth, imageHeight: imageHeight, - exif: exif, + exif: metadata.isNotEmpty ? Exif(metadata) : null, ); } } diff --git a/app/pubspec.lock b/app/pubspec.lock index 8a8c9a69..6af980fa 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -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: diff --git a/app/pubspec.yaml b/app/pubspec.yaml index b43e9d43..06bb7b3b 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -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: diff --git a/app/test/entity/exif_test.dart b/app/test/entity/exif_test.dart index 6ed41239..adedfdbc 100644 --- a/app/test/entity/exif_test.dart +++ b/app/test/entity/exif_test.dart @@ -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({ - "XResolution": Rational(72, 1), + "XResolution": const Rational(72, 1), }); expect(exif.toJson(), { "XResolution": {"n": 72, "d": 1}, @@ -114,7 +114,11 @@ void main() { test("List", () { final exif = Exif({ - "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(), { "GPSLatitude": [ @@ -164,12 +168,20 @@ void main() { })); }); - test("Rational", () { + test("Rational (legacy)", () { final json = { "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 = { + "XResolution": {"n": 72, "d": 1}, + }; + final Rational exif = Exif.fromJson(json)["XResolution"]; + expect(exif.makeComparable(), const _Rational(72, 1)); }); test("List", () { @@ -193,8 +205,11 @@ void main() { }; final List exif = Exif.fromJson(json)["GPSLatitude"].cast(); - 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); diff --git a/app/test/entity/exif_util_test.dart b/app/test/entity/exif_util_test.dart index bb970479..52ee0e4a 100644 --- a/app/test/entity/exif_util_test.dart +++ b/app/test/entity/exif_util_test.dart @@ -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(), ); }); }); diff --git a/app/test/entity/file_test.dart b/app/test/entity/file_test.dart index a60bb2d0..5fa477a2 100644 --- a/app/test/entity/file_test.dart +++ b/app/test/entity/file_test.dart @@ -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, })), );