refactor
This commit is contained in:
parent
5784607130
commit
119968439a
5 changed files with 0 additions and 824 deletions
|
@ -1,79 +0,0 @@
|
||||||
import 'dart:math';
|
|
||||||
|
|
||||||
import 'package:aves/image_providers/region_provider.dart';
|
|
||||||
import 'package:aves/image_providers/thumbnail_provider.dart';
|
|
||||||
import 'package:aves/image_providers/uri_image_provider.dart';
|
|
||||||
import 'package:aves/model/entry.dart';
|
|
||||||
import 'package:aves/model/entry_cache.dart';
|
|
||||||
import 'package:aves/utils/math_utils.dart';
|
|
||||||
import 'package:collection/collection.dart';
|
|
||||||
import 'package:flutter/widgets.dart';
|
|
||||||
|
|
||||||
extension ExtraAvesEntryImages on AvesEntry {
|
|
||||||
bool isThumbnailReady({double extent = 0}) => _isReady(_getThumbnailProviderKey(extent));
|
|
||||||
|
|
||||||
ThumbnailProvider getThumbnail({double extent = 0}) {
|
|
||||||
return ThumbnailProvider(_getThumbnailProviderKey(extent));
|
|
||||||
}
|
|
||||||
|
|
||||||
ThumbnailProviderKey _getThumbnailProviderKey(double extent) {
|
|
||||||
EntryCache.markThumbnailExtent(extent);
|
|
||||||
return ThumbnailProviderKey(
|
|
||||||
uri: uri,
|
|
||||||
mimeType: mimeType,
|
|
||||||
pageId: pageId,
|
|
||||||
rotationDegrees: rotationDegrees,
|
|
||||||
isFlipped: isFlipped,
|
|
||||||
dateModifiedSecs: dateModifiedSecs ?? -1,
|
|
||||||
extent: extent,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
RegionProvider getRegion({int sampleSize = 1, double scale = 1, required Rectangle<num> region}) {
|
|
||||||
return RegionProvider(RegionProviderKey(
|
|
||||||
uri: uri,
|
|
||||||
mimeType: mimeType,
|
|
||||||
pageId: pageId,
|
|
||||||
sizeBytes: sizeBytes,
|
|
||||||
rotationDegrees: rotationDegrees,
|
|
||||||
isFlipped: isFlipped,
|
|
||||||
sampleSize: sampleSize,
|
|
||||||
region: Rectangle(
|
|
||||||
(region.left * scale).round(),
|
|
||||||
(region.top * scale).round(),
|
|
||||||
(region.width * scale).round(),
|
|
||||||
(region.height * scale).round(),
|
|
||||||
),
|
|
||||||
imageSize: Size((width * scale).toDouble(), (height * scale).toDouble()),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
UriImage get uriImage => UriImage(
|
|
||||||
uri: uri,
|
|
||||||
mimeType: mimeType,
|
|
||||||
pageId: pageId,
|
|
||||||
rotationDegrees: rotationDegrees,
|
|
||||||
isFlipped: isFlipped,
|
|
||||||
sizeBytes: sizeBytes,
|
|
||||||
);
|
|
||||||
|
|
||||||
bool _isReady(Object providerKey) => imageCache.statusForKey(providerKey).keepAlive;
|
|
||||||
|
|
||||||
List<ThumbnailProvider> get cachedThumbnails => EntryCache.thumbnailRequestExtents.map(_getThumbnailProviderKey).where(_isReady).map(ThumbnailProvider.new).toList();
|
|
||||||
|
|
||||||
ThumbnailProvider get bestCachedThumbnail {
|
|
||||||
final sizedThumbnailKey = EntryCache.thumbnailRequestExtents.map(_getThumbnailProviderKey).firstWhereOrNull(_isReady);
|
|
||||||
return sizedThumbnailKey != null ? ThumbnailProvider(sizedThumbnailKey) : getThumbnail();
|
|
||||||
}
|
|
||||||
|
|
||||||
// magic number used to derive sample size from scale
|
|
||||||
static const scaleFactor = 2.0;
|
|
||||||
|
|
||||||
static int sampleSizeForScale(double scale) {
|
|
||||||
var sample = 0;
|
|
||||||
if (0 < scale && scale < 1) {
|
|
||||||
sample = highestPowerOf2((1 / scale) / scaleFactor);
|
|
||||||
}
|
|
||||||
return max<int>(1, sample);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,158 +0,0 @@
|
||||||
import 'dart:async';
|
|
||||||
import 'dart:collection';
|
|
||||||
|
|
||||||
import 'package:aves/model/entry.dart';
|
|
||||||
import 'package:aves/model/video/keys.dart';
|
|
||||||
import 'package:aves/model/video/metadata.dart';
|
|
||||||
import 'package:aves/ref/mime_types.dart';
|
|
||||||
import 'package:aves/services/common/services.dart';
|
|
||||||
import 'package:aves/services/metadata/svg_metadata_service.dart';
|
|
||||||
import 'package:aves/theme/colors.dart';
|
|
||||||
import 'package:aves/utils/constants.dart';
|
|
||||||
import 'package:aves/widgets/viewer/info/metadata/metadata_dir.dart';
|
|
||||||
import 'package:collection/collection.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:provider/provider.dart';
|
|
||||||
|
|
||||||
extension ExtraAvesEntryInfo on AvesEntry {
|
|
||||||
// directory names may contain the name of their parent directory (as prefix + '/')
|
|
||||||
// directory names may contain an index (as suffix in '[]')
|
|
||||||
static final directoryNamePattern = RegExp(r'^((?<parent>.*?)/)?(?<name>.*?)(\[(?<index>\d+)\])?$');
|
|
||||||
|
|
||||||
Future<List<MapEntry<String, MetadataDirectory>>> getMetadataDirectories(BuildContext context) async {
|
|
||||||
final rawMetadata = await (isSvg ? SvgMetadataService.getAllMetadata(this) : metadataFetchService.getAllMetadata(this));
|
|
||||||
final directories = rawMetadata.entries.map((dirKV) {
|
|
||||||
var directoryName = dirKV.key as String;
|
|
||||||
|
|
||||||
String? parent;
|
|
||||||
int? index;
|
|
||||||
final match = directoryNamePattern.firstMatch(directoryName);
|
|
||||||
if (match != null) {
|
|
||||||
parent = match.namedGroup('parent');
|
|
||||||
final nameMatch = match.namedGroup('name');
|
|
||||||
if (nameMatch != null) {
|
|
||||||
directoryName = nameMatch;
|
|
||||||
}
|
|
||||||
final indexMatch = match.namedGroup('index');
|
|
||||||
if (indexMatch != null) {
|
|
||||||
index = int.tryParse(indexMatch);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final rawTags = dirKV.value as Map;
|
|
||||||
return MetadataDirectory(
|
|
||||||
directoryName,
|
|
||||||
_toSortedTags(rawTags),
|
|
||||||
parent: parent,
|
|
||||||
index: index,
|
|
||||||
);
|
|
||||||
}).toList();
|
|
||||||
|
|
||||||
if (isVideo || (mimeType == MimeTypes.heif && isMultiPage)) {
|
|
||||||
directories.addAll(await _getStreamDirectories(context));
|
|
||||||
}
|
|
||||||
|
|
||||||
final titledDirectories = directories.map((dir) {
|
|
||||||
var title = dir.name;
|
|
||||||
if (directories.where((dir) => dir.name == title).length > 1 && dir.parent?.isNotEmpty == true) {
|
|
||||||
title = '${dir.parent}/$title';
|
|
||||||
}
|
|
||||||
if (dir.index != null) {
|
|
||||||
title += ' ${dir.index}';
|
|
||||||
}
|
|
||||||
return MapEntry(title, dir);
|
|
||||||
}).toList()
|
|
||||||
..sort((a, b) => compareAsciiUpperCase(a.key, b.key));
|
|
||||||
|
|
||||||
return titledDirectories;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<List<MetadataDirectory>> _getStreamDirectories(BuildContext context) async {
|
|
||||||
final directories = <MetadataDirectory>[];
|
|
||||||
final mediaInfo = await VideoMetadataFormatter.getVideoMetadata(this);
|
|
||||||
|
|
||||||
final formattedMediaTags = VideoMetadataFormatter.formatInfo(mediaInfo);
|
|
||||||
if (formattedMediaTags.isNotEmpty) {
|
|
||||||
// overwrite generic directory found from the platform side
|
|
||||||
directories.add(MetadataDirectory(MetadataDirectory.mediaDirectory, _toSortedTags(formattedMediaTags)));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mediaInfo.containsKey(Keys.streams)) {
|
|
||||||
String getTypeText(Map stream) {
|
|
||||||
final type = stream[Keys.streamType] ?? StreamTypes.unknown;
|
|
||||||
switch (type) {
|
|
||||||
case StreamTypes.attachment:
|
|
||||||
return 'Attachment';
|
|
||||||
case StreamTypes.audio:
|
|
||||||
return 'Audio';
|
|
||||||
case StreamTypes.metadata:
|
|
||||||
return 'Metadata';
|
|
||||||
case StreamTypes.subtitle:
|
|
||||||
case StreamTypes.timedText:
|
|
||||||
return 'Text';
|
|
||||||
case StreamTypes.video:
|
|
||||||
return stream.containsKey(Keys.fpsDen) ? 'Video' : 'Image';
|
|
||||||
case StreamTypes.unknown:
|
|
||||||
default:
|
|
||||||
return 'Unknown';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final allStreams = (mediaInfo[Keys.streams] as List).cast<Map>();
|
|
||||||
final attachmentStreams = allStreams.where((stream) => stream[Keys.streamType] == StreamTypes.attachment).toList();
|
|
||||||
final knownStreams = allStreams.whereNot(attachmentStreams.contains);
|
|
||||||
|
|
||||||
// display known streams as separate directories (e.g. video, audio, subs)
|
|
||||||
if (knownStreams.isNotEmpty) {
|
|
||||||
final indexDigits = knownStreams.length.toString().length;
|
|
||||||
|
|
||||||
final colors = context.read<AvesColorsData>();
|
|
||||||
for (final stream in knownStreams) {
|
|
||||||
final index = (stream[Keys.index] ?? 0) + 1;
|
|
||||||
final typeText = getTypeText(stream);
|
|
||||||
final dirName = [
|
|
||||||
'Stream ${index.toString().padLeft(indexDigits, '0')}',
|
|
||||||
typeText,
|
|
||||||
].join(Constants.separator);
|
|
||||||
final formattedStreamTags = VideoMetadataFormatter.formatInfo(stream);
|
|
||||||
if (formattedStreamTags.isNotEmpty) {
|
|
||||||
final color = colors.fromString(typeText);
|
|
||||||
directories.add(MetadataDirectory(dirName, _toSortedTags(formattedStreamTags), color: color));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// group attachments by format (e.g. TTF fonts)
|
|
||||||
if (attachmentStreams.isNotEmpty) {
|
|
||||||
final formatCount = <String, List<String?>>{};
|
|
||||||
for (final stream in attachmentStreams) {
|
|
||||||
final codec = (stream[Keys.codecName] as String? ?? 'unknown').toUpperCase();
|
|
||||||
if (!formatCount.containsKey(codec)) {
|
|
||||||
formatCount[codec] = [];
|
|
||||||
}
|
|
||||||
formatCount[codec]!.add(stream[Keys.filename]);
|
|
||||||
}
|
|
||||||
if (formatCount.isNotEmpty) {
|
|
||||||
final rawTags = formatCount.map((key, value) {
|
|
||||||
final count = value.length;
|
|
||||||
// remove duplicate names, so number of displayed names may not match displayed count
|
|
||||||
final names = value.whereNotNull().toSet().toList()..sort(compareAsciiUpperCase);
|
|
||||||
return MapEntry(key, '$count items: ${names.join(', ')}');
|
|
||||||
});
|
|
||||||
directories.add(MetadataDirectory('Attachments', _toSortedTags(rawTags)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return directories;
|
|
||||||
}
|
|
||||||
|
|
||||||
SplayTreeMap<String, String> _toSortedTags(Map rawTags) {
|
|
||||||
final tags = SplayTreeMap.of(Map.fromEntries(rawTags.entries.map((tagKV) {
|
|
||||||
var value = (tagKV.value as String? ?? '').trim();
|
|
||||||
if (value.isEmpty) return null;
|
|
||||||
final tagName = tagKV.key as String;
|
|
||||||
return MapEntry(tagName, value);
|
|
||||||
}).whereNotNull()));
|
|
||||||
return tags;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,587 +0,0 @@
|
||||||
import 'dart:convert';
|
|
||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:aves/model/entry.dart';
|
|
||||||
import 'package:aves/model/metadata/date_modifier.dart';
|
|
||||||
import 'package:aves/model/metadata/enums/date_field_source.dart';
|
|
||||||
import 'package:aves/model/metadata/enums/enums.dart';
|
|
||||||
import 'package:aves/model/metadata/fields.dart';
|
|
||||||
import 'package:aves/ref/exif.dart';
|
|
||||||
import 'package:aves/ref/iptc.dart';
|
|
||||||
import 'package:aves/ref/mime_types.dart';
|
|
||||||
import 'package:aves/services/common/services.dart';
|
|
||||||
import 'package:aves/services/metadata/xmp.dart';
|
|
||||||
import 'package:aves/utils/time_utils.dart';
|
|
||||||
import 'package:aves/utils/xmp_utils.dart';
|
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:intl/intl.dart';
|
|
||||||
import 'package:latlong2/latlong.dart';
|
|
||||||
import 'package:package_info_plus/package_info_plus.dart';
|
|
||||||
import 'package:xml/xml.dart';
|
|
||||||
|
|
||||||
extension ExtraAvesEntryMetadataEdition on AvesEntry {
|
|
||||||
Future<Set<EntryDataType>> editDate(DateModifier userModifier) async {
|
|
||||||
final dataTypes = <EntryDataType>{};
|
|
||||||
|
|
||||||
final appliedModifier = await _applyDateModifierToEntry(userModifier);
|
|
||||||
if (appliedModifier == null) {
|
|
||||||
if (!isMissingAtPath && userModifier.action != DateEditAction.copyField) {
|
|
||||||
await reportService.recordError('failed to get date for modifier=$userModifier, entry=$this', null);
|
|
||||||
}
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (canEditExif && appliedModifier.fields.any((v) => v.type == MetadataType.exif)) {
|
|
||||||
final newFields = await metadataEditService.editExifDate(this, appliedModifier);
|
|
||||||
if (newFields.isNotEmpty) {
|
|
||||||
dataTypes.addAll({
|
|
||||||
EntryDataType.basic,
|
|
||||||
EntryDataType.catalog,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (canEditXmp && appliedModifier.fields.any((v) => v.type == MetadataType.xmp)) {
|
|
||||||
final metadata = {
|
|
||||||
MetadataType.xmp: await _editXmp((descriptions) {
|
|
||||||
switch (appliedModifier.action) {
|
|
||||||
case DateEditAction.setCustom:
|
|
||||||
case DateEditAction.copyField:
|
|
||||||
case DateEditAction.copyItem:
|
|
||||||
case DateEditAction.extractFromTitle:
|
|
||||||
editCreateDateXmp(descriptions, appliedModifier.setDateTime);
|
|
||||||
break;
|
|
||||||
case DateEditAction.shift:
|
|
||||||
final xmpDate = XMP.getString(descriptions, XMP.xmpCreateDate, namespace: Namespaces.xmp);
|
|
||||||
if (xmpDate != null) {
|
|
||||||
final date = DateTime.tryParse(xmpDate);
|
|
||||||
if (date != null) {
|
|
||||||
// TODO TLAD [date] DateTime.tryParse converts to UTC time, losing the time zone offset
|
|
||||||
final shiftedDate = date.add(Duration(minutes: appliedModifier.shiftMinutes!));
|
|
||||||
editCreateDateXmp(descriptions, shiftedDate);
|
|
||||||
} else {
|
|
||||||
reportService.recordError('failed to parse XMP date=$xmpDate', null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case DateEditAction.remove:
|
|
||||||
editCreateDateXmp(descriptions, null);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
final newFields = await metadataEditService.editMetadata(this, metadata);
|
|
||||||
if (newFields.isNotEmpty) {
|
|
||||||
dataTypes.addAll({
|
|
||||||
EntryDataType.basic,
|
|
||||||
EntryDataType.catalog,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return dataTypes;
|
|
||||||
}
|
|
||||||
|
|
||||||
static final removalLocation = LatLng(0, 0);
|
|
||||||
|
|
||||||
Future<Set<EntryDataType>> editLocation(LatLng? latLng) async {
|
|
||||||
final dataTypes = <EntryDataType>{};
|
|
||||||
final metadata = <MetadataType, dynamic>{};
|
|
||||||
|
|
||||||
final missingDate = await _missingDateCheckAndExifEdit(dataTypes);
|
|
||||||
|
|
||||||
if (canEditExif) {
|
|
||||||
// clear every GPS field
|
|
||||||
final exifFields = Map<MetadataField, dynamic>.fromEntries(MetadataFields.exifGpsFields.map((k) => MapEntry(k, null)));
|
|
||||||
// add latitude & longitude, if any
|
|
||||||
if (latLng != null && latLng != removalLocation) {
|
|
||||||
final latitude = latLng.latitude;
|
|
||||||
final longitude = latLng.longitude;
|
|
||||||
exifFields.addAll({
|
|
||||||
MetadataField.exifGpsLatitude: latitude.abs(),
|
|
||||||
MetadataField.exifGpsLatitudeRef: latitude >= 0 ? Exif.latitudeNorth : Exif.latitudeSouth,
|
|
||||||
MetadataField.exifGpsLongitude: longitude.abs(),
|
|
||||||
MetadataField.exifGpsLongitudeRef: longitude >= 0 ? Exif.longitudeEast : Exif.longitudeWest,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
metadata[MetadataType.exif] = Map<String, dynamic>.fromEntries(exifFields.entries.map((kv) => MapEntry(kv.key.toPlatform!, kv.value)));
|
|
||||||
|
|
||||||
if (canEditXmp && missingDate != null) {
|
|
||||||
metadata[MetadataType.xmp] = await _editXmp((descriptions) {
|
|
||||||
editCreateDateXmp(descriptions, missingDate);
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mimeType == MimeTypes.mp4) {
|
|
||||||
final mp4Fields = <MetadataField, String?>{};
|
|
||||||
|
|
||||||
String? iso6709String;
|
|
||||||
if (latLng != null && latLng != removalLocation) {
|
|
||||||
final latitude = latLng.latitude;
|
|
||||||
final longitude = latLng.longitude;
|
|
||||||
const locale = 'en_US';
|
|
||||||
final isoLat = '${latitude >= 0 ? '+' : '-'}${NumberFormat('00.0000', locale).format(latitude.abs())}';
|
|
||||||
final isoLon = '${longitude >= 0 ? '+' : '-'}${NumberFormat('000.0000', locale).format(longitude.abs())}';
|
|
||||||
iso6709String = '$isoLat$isoLon/';
|
|
||||||
}
|
|
||||||
mp4Fields[MetadataField.mp4GpsCoordinates] = iso6709String;
|
|
||||||
|
|
||||||
if (missingDate != null) {
|
|
||||||
final xmpParts = await _editXmp((descriptions) {
|
|
||||||
editCreateDateXmp(descriptions, missingDate);
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
mp4Fields[MetadataField.mp4Xmp] = xmpParts[xmpCoreKey];
|
|
||||||
}
|
|
||||||
|
|
||||||
metadata[MetadataType.mp4] = Map<String, String?>.fromEntries(mp4Fields.entries.map((kv) => MapEntry(kv.key.toPlatform!, kv.value)));
|
|
||||||
}
|
|
||||||
|
|
||||||
final newFields = await metadataEditService.editMetadata(this, metadata);
|
|
||||||
if (newFields.isNotEmpty) {
|
|
||||||
dataTypes.addAll({
|
|
||||||
EntryDataType.catalog,
|
|
||||||
EntryDataType.address,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return dataTypes;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<Set<EntryDataType>> _changeExifOrientation(Future<Map<String, dynamic>> Function() apply) async {
|
|
||||||
final dataTypes = <EntryDataType>{};
|
|
||||||
|
|
||||||
await _missingDateCheckAndExifEdit(dataTypes);
|
|
||||||
|
|
||||||
final newFields = await apply();
|
|
||||||
// applying fields is only useful for a smoother visual change,
|
|
||||||
// as proper refreshing and persistence happens at the caller level
|
|
||||||
await applyNewFields(newFields, persist: false);
|
|
||||||
if (newFields.isNotEmpty) {
|
|
||||||
dataTypes.addAll({
|
|
||||||
EntryDataType.basic,
|
|
||||||
EntryDataType.aspectRatio,
|
|
||||||
EntryDataType.catalog,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return dataTypes;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<Set<EntryDataType>> _rotateMp4(int rotationDegrees) async {
|
|
||||||
final dataTypes = <EntryDataType>{};
|
|
||||||
|
|
||||||
final missingDate = await _missingDateCheckAndExifEdit(dataTypes);
|
|
||||||
|
|
||||||
final mp4Fields = <MetadataField, String?>{
|
|
||||||
MetadataField.mp4RotationDegrees: rotationDegrees.toString(),
|
|
||||||
};
|
|
||||||
|
|
||||||
if (missingDate != null) {
|
|
||||||
final xmpParts = await _editXmp((descriptions) {
|
|
||||||
editCreateDateXmp(descriptions, missingDate);
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
mp4Fields[MetadataField.mp4Xmp] = xmpParts[xmpCoreKey];
|
|
||||||
}
|
|
||||||
|
|
||||||
final metadata = <MetadataType, dynamic>{
|
|
||||||
MetadataType.mp4: Map<String, String?>.fromEntries(mp4Fields.entries.map((kv) => MapEntry(kv.key.toPlatform!, kv.value))),
|
|
||||||
};
|
|
||||||
|
|
||||||
final newFields = await metadataEditService.editMetadata(this, metadata);
|
|
||||||
// applying fields is only useful for a smoother visual change,
|
|
||||||
// as proper refreshing and persistence happens at the caller level
|
|
||||||
await applyNewFields(newFields, persist: false);
|
|
||||||
if (newFields.isNotEmpty) {
|
|
||||||
dataTypes.addAll({
|
|
||||||
EntryDataType.basic,
|
|
||||||
EntryDataType.aspectRatio,
|
|
||||||
EntryDataType.catalog,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return dataTypes;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<Set<EntryDataType>> rotate({required bool clockwise}) {
|
|
||||||
if (mimeType == MimeTypes.mp4) {
|
|
||||||
return _rotateMp4((rotationDegrees + (clockwise ? 90 : -90) + 360) % 360);
|
|
||||||
} else {
|
|
||||||
return _changeExifOrientation(() => metadataEditService.rotate(this, clockwise: clockwise));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<Set<EntryDataType>> flip() {
|
|
||||||
return _changeExifOrientation(() => metadataEditService.flip(this));
|
|
||||||
}
|
|
||||||
|
|
||||||
// write title:
|
|
||||||
// - IPTC / object-name, if IPTC exists
|
|
||||||
// - XMP / dc:title
|
|
||||||
// write description:
|
|
||||||
// - Exif / ImageDescription
|
|
||||||
// - IPTC / caption-abstract, if IPTC exists
|
|
||||||
// - XMP / dc:description
|
|
||||||
Future<Set<EntryDataType>> editTitleDescription(Map<DescriptionField, String?> fields) async {
|
|
||||||
final dataTypes = <EntryDataType>{};
|
|
||||||
final metadata = <MetadataType, dynamic>{};
|
|
||||||
|
|
||||||
final missingDate = await _missingDateCheckAndExifEdit(dataTypes);
|
|
||||||
|
|
||||||
final editTitle = fields.keys.contains(DescriptionField.title);
|
|
||||||
final editDescription = fields.keys.contains(DescriptionField.description);
|
|
||||||
final title = fields[DescriptionField.title];
|
|
||||||
final description = fields[DescriptionField.description];
|
|
||||||
|
|
||||||
if (canEditExif && editDescription) {
|
|
||||||
metadata[MetadataType.exif] = {
|
|
||||||
MetadataField.exifImageDescription.toPlatform!: null,
|
|
||||||
MetadataField.exifUserComment.toPlatform!: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (canEditIptc) {
|
|
||||||
final iptc = await metadataFetchService.getIptc(this);
|
|
||||||
if (iptc != null) {
|
|
||||||
if (editTitle) {
|
|
||||||
editIptcValues(iptc, IPTC.applicationRecord, IPTC.objectName, {if (title != null) title});
|
|
||||||
}
|
|
||||||
if (editDescription) {
|
|
||||||
editIptcValues(iptc, IPTC.applicationRecord, IPTC.captionAbstractTag, {if (description != null) description});
|
|
||||||
}
|
|
||||||
metadata[MetadataType.iptc] = iptc;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (canEditXmp) {
|
|
||||||
metadata[MetadataType.xmp] = await _editXmp((descriptions) {
|
|
||||||
var modified = false;
|
|
||||||
if (editTitle) {
|
|
||||||
modified |= XMP.setAttribute(
|
|
||||||
descriptions,
|
|
||||||
XMP.dcTitle,
|
|
||||||
title,
|
|
||||||
namespace: Namespaces.dc,
|
|
||||||
strat: XmpEditStrategy.always,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (editDescription) {
|
|
||||||
modified |= XMP.setAttribute(
|
|
||||||
descriptions,
|
|
||||||
XMP.dcDescription,
|
|
||||||
description,
|
|
||||||
namespace: Namespaces.dc,
|
|
||||||
strat: XmpEditStrategy.always,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (modified && missingDate != null) {
|
|
||||||
editCreateDateXmp(descriptions, missingDate);
|
|
||||||
}
|
|
||||||
return modified;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
final newFields = await metadataEditService.editMetadata(this, metadata);
|
|
||||||
if (newFields.isNotEmpty) {
|
|
||||||
dataTypes.addAll({
|
|
||||||
EntryDataType.basic,
|
|
||||||
EntryDataType.catalog,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return dataTypes;
|
|
||||||
}
|
|
||||||
|
|
||||||
// write:
|
|
||||||
// - IPTC / keywords, if IPTC exists
|
|
||||||
// - XMP / dc:subject
|
|
||||||
Future<Set<EntryDataType>> editTags(Set<String> tags) async {
|
|
||||||
final dataTypes = <EntryDataType>{};
|
|
||||||
final metadata = <MetadataType, dynamic>{};
|
|
||||||
|
|
||||||
final missingDate = await _missingDateCheckAndExifEdit(dataTypes);
|
|
||||||
|
|
||||||
if (canEditIptc) {
|
|
||||||
final iptc = await metadataFetchService.getIptc(this);
|
|
||||||
if (iptc != null) {
|
|
||||||
editIptcValues(iptc, IPTC.applicationRecord, IPTC.keywordsTag, tags);
|
|
||||||
metadata[MetadataType.iptc] = iptc;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (canEditXmp) {
|
|
||||||
metadata[MetadataType.xmp] = await _editXmp((descriptions) {
|
|
||||||
final modified = editTagsXmp(descriptions, tags);
|
|
||||||
if (modified && missingDate != null) {
|
|
||||||
editCreateDateXmp(descriptions, missingDate);
|
|
||||||
}
|
|
||||||
return modified;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
final newFields = await metadataEditService.editMetadata(this, metadata);
|
|
||||||
if (newFields.isNotEmpty) {
|
|
||||||
dataTypes.add(EntryDataType.catalog);
|
|
||||||
}
|
|
||||||
return dataTypes;
|
|
||||||
}
|
|
||||||
|
|
||||||
// write:
|
|
||||||
// - XMP / xmp:Rating
|
|
||||||
// update:
|
|
||||||
// - XMP / MicrosoftPhoto:Rating
|
|
||||||
// ignore (Windows tags, not part of Exif 2.32 spec):
|
|
||||||
// - Exif / Rating
|
|
||||||
// - Exif / RatingPercent
|
|
||||||
Future<Set<EntryDataType>> editRating(int? rating) async {
|
|
||||||
final dataTypes = <EntryDataType>{};
|
|
||||||
final metadata = <MetadataType, dynamic>{};
|
|
||||||
|
|
||||||
final missingDate = await _missingDateCheckAndExifEdit(dataTypes);
|
|
||||||
|
|
||||||
if (canEditXmp) {
|
|
||||||
metadata[MetadataType.xmp] = await _editXmp((descriptions) {
|
|
||||||
final modified = editRatingXmp(descriptions, rating);
|
|
||||||
if (modified && missingDate != null) {
|
|
||||||
editCreateDateXmp(descriptions, missingDate);
|
|
||||||
}
|
|
||||||
return modified;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
final newFields = await metadataEditService.editMetadata(this, metadata);
|
|
||||||
if (newFields.isNotEmpty) {
|
|
||||||
dataTypes.add(EntryDataType.catalog);
|
|
||||||
}
|
|
||||||
return dataTypes;
|
|
||||||
}
|
|
||||||
|
|
||||||
// remove:
|
|
||||||
// - trailer video
|
|
||||||
// - XMP / Container:Directory
|
|
||||||
// - XMP / GCamera:MicroVideo*
|
|
||||||
// - XMP / GCamera:MotionPhoto*
|
|
||||||
Future<Set<EntryDataType>> removeTrailerVideo() async {
|
|
||||||
final dataTypes = <EntryDataType>{};
|
|
||||||
final metadata = <MetadataType, dynamic>{};
|
|
||||||
|
|
||||||
if (!canEditXmp) return dataTypes;
|
|
||||||
|
|
||||||
final missingDate = await _missingDateCheckAndExifEdit(dataTypes);
|
|
||||||
|
|
||||||
final newFields = await metadataEditService.removeTrailerVideo(this);
|
|
||||||
|
|
||||||
metadata[MetadataType.xmp] = await _editXmp((descriptions) {
|
|
||||||
final modified = removeContainerXmp(descriptions);
|
|
||||||
if (modified && missingDate != null) {
|
|
||||||
editCreateDateXmp(descriptions, missingDate);
|
|
||||||
}
|
|
||||||
return modified;
|
|
||||||
});
|
|
||||||
|
|
||||||
newFields.addAll(await metadataEditService.editMetadata(this, metadata, autoCorrectTrailerOffset: false));
|
|
||||||
if (newFields.isNotEmpty) {
|
|
||||||
dataTypes.add(EntryDataType.catalog);
|
|
||||||
}
|
|
||||||
return dataTypes;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<Set<EntryDataType>> removeMetadata(Set<MetadataType> types) async {
|
|
||||||
final dataTypes = <EntryDataType>{};
|
|
||||||
|
|
||||||
final newFields = await metadataEditService.removeTypes(this, types);
|
|
||||||
if (newFields.isNotEmpty) {
|
|
||||||
dataTypes.addAll({
|
|
||||||
EntryDataType.basic,
|
|
||||||
EntryDataType.aspectRatio,
|
|
||||||
EntryDataType.catalog,
|
|
||||||
EntryDataType.address,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return dataTypes;
|
|
||||||
}
|
|
||||||
|
|
||||||
static void editIptcValues(List<Map<String, dynamic>> iptc, int record, int tag, Set<String> values) {
|
|
||||||
iptc.removeWhere((v) => v['record'] == record && v['tag'] == tag);
|
|
||||||
iptc.add({
|
|
||||||
'record': record,
|
|
||||||
'tag': tag,
|
|
||||||
'values': values.map((v) => utf8.encode(v)).toList(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@visibleForTesting
|
|
||||||
static bool editCreateDateXmp(List<XmlNode> descriptions, DateTime? date) {
|
|
||||||
return XMP.setAttribute(
|
|
||||||
descriptions,
|
|
||||||
XMP.xmpCreateDate,
|
|
||||||
date != null ? XMP.toXmpDate(date) : null,
|
|
||||||
namespace: Namespaces.xmp,
|
|
||||||
strat: XmpEditStrategy.always,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@visibleForTesting
|
|
||||||
static bool editTagsXmp(List<XmlNode> descriptions, Set<String> tags) {
|
|
||||||
return XMP.setStringBag(
|
|
||||||
descriptions,
|
|
||||||
XMP.dcSubject,
|
|
||||||
tags,
|
|
||||||
namespace: Namespaces.dc,
|
|
||||||
strat: XmpEditStrategy.always,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@visibleForTesting
|
|
||||||
static bool editRatingXmp(List<XmlNode> descriptions, int? rating) {
|
|
||||||
bool modified = false;
|
|
||||||
|
|
||||||
modified |= XMP.setAttribute(
|
|
||||||
descriptions,
|
|
||||||
XMP.xmpRating,
|
|
||||||
(rating ?? 0) == 0 ? null : '$rating',
|
|
||||||
namespace: Namespaces.xmp,
|
|
||||||
strat: XmpEditStrategy.always,
|
|
||||||
);
|
|
||||||
|
|
||||||
modified |= XMP.setAttribute(
|
|
||||||
descriptions,
|
|
||||||
XMP.msPhotoRating,
|
|
||||||
XMP.toMsPhotoRating(rating),
|
|
||||||
namespace: Namespaces.microsoftPhoto,
|
|
||||||
strat: XmpEditStrategy.updateIfPresent,
|
|
||||||
);
|
|
||||||
|
|
||||||
return modified;
|
|
||||||
}
|
|
||||||
|
|
||||||
@visibleForTesting
|
|
||||||
static bool removeContainerXmp(List<XmlNode> descriptions) {
|
|
||||||
bool modified = false;
|
|
||||||
|
|
||||||
modified |= XMP.removeElements(
|
|
||||||
descriptions,
|
|
||||||
XMP.containerDirectory,
|
|
||||||
Namespaces.gContainer,
|
|
||||||
);
|
|
||||||
|
|
||||||
modified |= [
|
|
||||||
XMP.gCameraMicroVideo,
|
|
||||||
XMP.gCameraMicroVideoVersion,
|
|
||||||
XMP.gCameraMicroVideoOffset,
|
|
||||||
XMP.gCameraMicroVideoPresentationTimestampUs,
|
|
||||||
XMP.gCameraMotionPhoto,
|
|
||||||
XMP.gCameraMotionPhotoVersion,
|
|
||||||
XMP.gCameraMotionPhotoPresentationTimestampUs,
|
|
||||||
].fold<bool>(modified, (prev, name) {
|
|
||||||
return prev |= XMP.removeElements(
|
|
||||||
descriptions,
|
|
||||||
name,
|
|
||||||
Namespaces.gCamera,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
return modified;
|
|
||||||
}
|
|
||||||
|
|
||||||
// convenience methods
|
|
||||||
|
|
||||||
// This method checks whether the item already has a metadata date,
|
|
||||||
// and adds a date (the file modified date) via Exif if possible.
|
|
||||||
// It returns a date if the caller needs to add it via other metadata types (e.g. XMP).
|
|
||||||
Future<DateTime?> _missingDateCheckAndExifEdit(Set<EntryDataType> dataTypes) async {
|
|
||||||
if (path == null) return null;
|
|
||||||
|
|
||||||
// make sure entry is catalogued before we check whether is has a metadata date
|
|
||||||
if (!isCatalogued) {
|
|
||||||
await catalog(background: false, force: false, persist: true);
|
|
||||||
}
|
|
||||||
final dateMillis = catalogMetadata?.dateMillis;
|
|
||||||
if (dateMillis != null && dateMillis > 0) return null;
|
|
||||||
|
|
||||||
late DateTime date;
|
|
||||||
try {
|
|
||||||
date = await File(path!).lastModified();
|
|
||||||
} on FileSystemException catch (_) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (canEditExif) {
|
|
||||||
final newFields = await metadataEditService.editExifDate(this, DateModifier.setCustom(const {MetadataField.exifDateOriginal}, date));
|
|
||||||
if (newFields.isNotEmpty) {
|
|
||||||
dataTypes.addAll({
|
|
||||||
EntryDataType.basic,
|
|
||||||
EntryDataType.catalog,
|
|
||||||
});
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return date;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<DateModifier?> _applyDateModifierToEntry(DateModifier modifier) async {
|
|
||||||
Set<MetadataField> mainMetadataDate() => {canEditExif ? MetadataField.exifDateOriginal : MetadataField.xmpXmpCreateDate};
|
|
||||||
|
|
||||||
switch (modifier.action) {
|
|
||||||
case DateEditAction.copyField:
|
|
||||||
DateTime? date;
|
|
||||||
final source = modifier.copyFieldSource;
|
|
||||||
if (source != null) {
|
|
||||||
switch (source) {
|
|
||||||
case DateFieldSource.fileModifiedDate:
|
|
||||||
try {
|
|
||||||
if (path != null) {
|
|
||||||
final file = File(path!);
|
|
||||||
if (await file.exists()) {
|
|
||||||
date = await file.lastModified();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} on FileSystemException catch (_) {}
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
date = await metadataFetchService.getDate(this, source.toMetadataField()!);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return date != null ? DateModifier.setCustom(mainMetadataDate(), date) : null;
|
|
||||||
case DateEditAction.extractFromTitle:
|
|
||||||
final date = parseUnknownDateFormat(bestTitle);
|
|
||||||
return date != null ? DateModifier.setCustom(mainMetadataDate(), date) : null;
|
|
||||||
case DateEditAction.setCustom:
|
|
||||||
case DateEditAction.copyItem:
|
|
||||||
return DateModifier.setCustom(mainMetadataDate(), modifier.setDateTime!);
|
|
||||||
case DateEditAction.shift:
|
|
||||||
case DateEditAction.remove:
|
|
||||||
return modifier;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static const xmpCoreKey = 'xmp';
|
|
||||||
static const xmpExtendedKey = 'extendedXmp';
|
|
||||||
|
|
||||||
Future<Map<String, String?>> _editXmp(bool Function(List<XmlNode> descriptions) apply) async {
|
|
||||||
final xmp = await metadataFetchService.getXmp(this);
|
|
||||||
if (xmp == null) {
|
|
||||||
throw Exception('failed to get XMP');
|
|
||||||
}
|
|
||||||
|
|
||||||
final xmpString = xmp.xmpString;
|
|
||||||
final extendedXmpString = xmp.extendedXmpString;
|
|
||||||
|
|
||||||
final editedXmpString = await XMP.edit(
|
|
||||||
xmpString,
|
|
||||||
() => PackageInfo.fromPlatform().then((v) => 'Aves v${v.version}'),
|
|
||||||
apply,
|
|
||||||
);
|
|
||||||
|
|
||||||
final editedXmp = AvesXmp(xmpString: editedXmpString, extendedXmpString: extendedXmpString);
|
|
||||||
return {
|
|
||||||
xmpCoreKey: editedXmp.xmpString,
|
|
||||||
xmpExtendedKey: editedXmp.extendedXmpString,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enum DescriptionField { title, description }
|
|
Loading…
Reference in a new issue