info: action to convert motion photo to still image
This commit is contained in:
parent
79843d8a9a
commit
c26e6bcbcf
17 changed files with 430 additions and 82 deletions
|
@ -7,8 +7,9 @@ All notable changes to this project will be documented in this file.
|
|||
### Added
|
||||
|
||||
- Info: improved GeoTIFF section
|
||||
- GeoTIFF: locating from GeoTIFF metadata (requires rescan, limited to some projections)
|
||||
- GeoTIFF: overlay on map (limited to some projections)
|
||||
- Cataloguing: locating from GeoTIFF metadata (requires rescan, limited to some projections)
|
||||
- Info: action to overlay GeoTIFF on map (limited to some projections)
|
||||
- Info: action to convert motion photo to still image
|
||||
- Italian translation (thanks glemco)
|
||||
|
||||
### Changed
|
||||
|
|
|
@ -8,7 +8,9 @@
|
|||
So we request `WRITE_EXTERNAL_STORAGE` until Q (29), and enable `requestLegacyExternalStorage`
|
||||
-->
|
||||
|
||||
<!-- TODO TLAD [tiramisu] need notification permission? -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<!-- TODO TLAD [tiramisu] READ_MEDIA_IMAGE, READ_MEDIA_VIDEO instead of READ_EXTERNAL_STORAGE? -->
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||
<uses-permission
|
||||
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
|
|
|
@ -10,7 +10,10 @@ import deckers.thibault.aves.model.provider.ImageProviderFactory.getProvider
|
|||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class MetadataEditHandler(private val activity: Activity) : MethodCallHandler {
|
||||
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
@ -21,6 +24,7 @@ class MetadataEditHandler(private val activity: Activity) : MethodCallHandler {
|
|||
"flip" -> ioScope.launch { safe(call, result, ::flip) }
|
||||
"editDate" -> ioScope.launch { safe(call, result, ::editDate) }
|
||||
"editMetadata" -> ioScope.launch { safe(call, result, ::editMetadata) }
|
||||
"removeTrailerVideo" -> ioScope.launch { safe(call, result, ::removeTrailerVideo) }
|
||||
"removeTypes" -> ioScope.launch { safe(call, result, ::removeTypes) }
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
|
@ -101,7 +105,8 @@ class MetadataEditHandler(private val activity: Activity) : MethodCallHandler {
|
|||
private fun editMetadata(call: MethodCall, result: MethodChannel.Result) {
|
||||
val metadata = call.argument<FieldMap>("metadata")
|
||||
val entryMap = call.argument<FieldMap>("entry")
|
||||
if (entryMap == null || metadata == null) {
|
||||
val autoCorrectTrailerOffset = call.argument<Boolean>("autoCorrectTrailerOffset")
|
||||
if (entryMap == null || metadata == null || autoCorrectTrailerOffset == null) {
|
||||
result.error("editMetadata-args", "failed because of missing arguments", null)
|
||||
return
|
||||
}
|
||||
|
@ -120,12 +125,39 @@ class MetadataEditHandler(private val activity: Activity) : MethodCallHandler {
|
|||
return
|
||||
}
|
||||
|
||||
provider.editMetadata(activity, path, uri, mimeType, metadata, callback = object : ImageOpCallback {
|
||||
provider.editMetadata(activity, path, uri, mimeType, metadata, autoCorrectTrailerOffset, callback = object : ImageOpCallback {
|
||||
override fun onSuccess(fields: FieldMap) = result.success(fields)
|
||||
override fun onFailure(throwable: Throwable) = result.error("editMetadata-failure", "failed to edit metadata for mimeType=$mimeType uri=$uri", throwable.message)
|
||||
})
|
||||
}
|
||||
|
||||
private fun removeTrailerVideo(call: MethodCall, result: MethodChannel.Result) {
|
||||
val entryMap = call.argument<FieldMap>("entry")
|
||||
if (entryMap == null) {
|
||||
result.error("removeTrailerVideo-args", "failed because of missing arguments", null)
|
||||
return
|
||||
}
|
||||
|
||||
val uri = (entryMap["uri"] as String?)?.let { Uri.parse(it) }
|
||||
val path = entryMap["path"] as String?
|
||||
val mimeType = entryMap["mimeType"] as String?
|
||||
if (uri == null || path == null || mimeType == null) {
|
||||
result.error("removeTrailerVideo-args", "failed because entry fields are missing", null)
|
||||
return
|
||||
}
|
||||
|
||||
val provider = getProvider(uri)
|
||||
if (provider == null) {
|
||||
result.error("removeTrailerVideo-provider", "failed to find provider for uri=$uri", null)
|
||||
return
|
||||
}
|
||||
|
||||
provider.removeTrailerVideo(activity, path, uri, mimeType, object : ImageOpCallback {
|
||||
override fun onSuccess(fields: FieldMap) = result.success(fields)
|
||||
override fun onFailure(throwable: Throwable) = result.error("removeTrailerVideo-failure", "failed to remove trailer video for mimeType=$mimeType uri=$uri", throwable.message)
|
||||
})
|
||||
}
|
||||
|
||||
private fun removeTypes(call: MethodCall, result: MethodChannel.Result) {
|
||||
val types = call.argument<List<String>>("types")
|
||||
val entryMap = call.argument<FieldMap>("entry")
|
||||
|
|
|
@ -21,7 +21,6 @@ import kotlinx.coroutines.CoroutineScope
|
|||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.FileOutputStream
|
||||
|
||||
// starting activity to give access with the native dialog
|
||||
// breaks the regular `MethodChannel` so we use a stream channel instead
|
||||
|
@ -124,10 +123,8 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any?
|
|||
MainActivity.pendingStorageAccessResultHandlers[MainActivity.CREATE_FILE_REQUEST] = PendingStorageAccessResultHandler(null, { uri ->
|
||||
ioScope.launch {
|
||||
try {
|
||||
activity.contentResolver.openOutputStream(uri)?.use { output ->
|
||||
output as FileOutputStream
|
||||
// truncate is necessary when overwriting a longer file
|
||||
output.channel.truncate(0)
|
||||
// truncate is necessary when overwriting a longer file
|
||||
activity.contentResolver.openOutputStream(uri, "wt")?.use { output ->
|
||||
output.write(bytes)
|
||||
}
|
||||
success(true)
|
||||
|
|
|
@ -409,6 +409,7 @@ abstract class ImageProvider {
|
|||
uri: Uri,
|
||||
mimeType: String,
|
||||
callback: ImageOpCallback,
|
||||
autoCorrectTrailerOffset: Boolean = true,
|
||||
trailerDiff: Int = 0,
|
||||
edit: (exif: ExifInterface) -> Unit,
|
||||
): Boolean {
|
||||
|
@ -464,7 +465,7 @@ abstract class ImageProvider {
|
|||
// copy the edited temporary file back to the original
|
||||
copyTo(context, mimeType, sourceFile = editableFile, targetUri = uri, targetPath = path)
|
||||
|
||||
if (!checkTrailerOffset(context, path, uri, mimeType, videoSize, editableFile, callback)) {
|
||||
if (autoCorrectTrailerOffset && !checkTrailerOffset(context, path, uri, mimeType, videoSize, editableFile, callback)) {
|
||||
return false
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
|
@ -481,6 +482,7 @@ abstract class ImageProvider {
|
|||
uri: Uri,
|
||||
mimeType: String,
|
||||
callback: ImageOpCallback,
|
||||
autoCorrectTrailerOffset: Boolean = true,
|
||||
trailerDiff: Int = 0,
|
||||
iptc: List<FieldMap>?,
|
||||
): Boolean {
|
||||
|
@ -550,7 +552,7 @@ abstract class ImageProvider {
|
|||
// copy the edited temporary file back to the original
|
||||
copyTo(context, mimeType, sourceFile = editableFile, targetUri = uri, targetPath = path)
|
||||
|
||||
if (!checkTrailerOffset(context, path, uri, mimeType, videoSize, editableFile, callback)) {
|
||||
if (autoCorrectTrailerOffset && !checkTrailerOffset(context, path, uri, mimeType, videoSize, editableFile, callback)) {
|
||||
return false
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
|
@ -569,6 +571,7 @@ abstract class ImageProvider {
|
|||
uri: Uri,
|
||||
mimeType: String,
|
||||
callback: ImageOpCallback,
|
||||
autoCorrectTrailerOffset: Boolean = true,
|
||||
trailerDiff: Int = 0,
|
||||
coreXmp: String? = null,
|
||||
extendedXmp: String? = null,
|
||||
|
@ -624,7 +627,7 @@ abstract class ImageProvider {
|
|||
// copy the edited temporary file back to the original
|
||||
copyTo(context, mimeType, sourceFile = editableFile, targetUri = uri, targetPath = path)
|
||||
|
||||
if (!checkTrailerOffset(context, path, uri, mimeType, videoSize, editableFile, callback)) {
|
||||
if (autoCorrectTrailerOffset && !checkTrailerOffset(context, path, uri, mimeType, videoSize, editableFile, callback)) {
|
||||
return false
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
|
@ -812,12 +815,20 @@ abstract class ImageProvider {
|
|||
uri: Uri,
|
||||
mimeType: String,
|
||||
modifier: FieldMap,
|
||||
autoCorrectTrailerOffset: Boolean,
|
||||
callback: ImageOpCallback,
|
||||
) {
|
||||
if (modifier.containsKey("exif")) {
|
||||
val fields = modifier["exif"] as Map<*, *>?
|
||||
if (fields != null && fields.isNotEmpty()) {
|
||||
if (!editExif(context, path, uri, mimeType, callback) { exif ->
|
||||
if (!editExif(
|
||||
context = context,
|
||||
path = path,
|
||||
uri = uri,
|
||||
mimeType = mimeType,
|
||||
callback = callback,
|
||||
autoCorrectTrailerOffset = autoCorrectTrailerOffset,
|
||||
) { exif ->
|
||||
var setLocation = false
|
||||
fields.forEach { kv ->
|
||||
val tag = kv.key as String?
|
||||
|
@ -859,7 +870,8 @@ abstract class ImageProvider {
|
|||
}
|
||||
}
|
||||
exif.saveAttributes()
|
||||
}) return
|
||||
}
|
||||
) return
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -871,6 +883,7 @@ abstract class ImageProvider {
|
|||
uri = uri,
|
||||
mimeType = mimeType,
|
||||
callback = callback,
|
||||
autoCorrectTrailerOffset = autoCorrectTrailerOffset,
|
||||
iptc = iptc,
|
||||
)
|
||||
) return
|
||||
|
@ -887,6 +900,7 @@ abstract class ImageProvider {
|
|||
uri = uri,
|
||||
mimeType = mimeType,
|
||||
callback = callback,
|
||||
autoCorrectTrailerOffset = autoCorrectTrailerOffset,
|
||||
coreXmp = coreXmp,
|
||||
extendedXmp = extendedXmp,
|
||||
)
|
||||
|
@ -898,6 +912,58 @@ abstract class ImageProvider {
|
|||
scanPostMetadataEdit(context, path, uri, mimeType, newFields, callback)
|
||||
}
|
||||
|
||||
fun removeTrailerVideo(
|
||||
context: Context,
|
||||
path: String,
|
||||
uri: Uri,
|
||||
mimeType: String,
|
||||
callback: ImageOpCallback,
|
||||
) {
|
||||
val originalFileSize = File(path).length()
|
||||
val videoSize = MultiPage.getMotionPhotoOffset(context, uri, mimeType, originalFileSize)?.toInt()
|
||||
if (videoSize == null) {
|
||||
callback.onFailure(Exception("failed to get trailer video size"))
|
||||
return
|
||||
}
|
||||
val bytesToCopy = originalFileSize - videoSize
|
||||
|
||||
val editableFile = File.createTempFile("aves", null).apply {
|
||||
deleteOnExit()
|
||||
try {
|
||||
outputStream().use { output ->
|
||||
// reopen input to read from start
|
||||
StorageUtils.openInputStream(context, uri)?.use { input ->
|
||||
// partial copy
|
||||
var bytesRemaining: Long = bytesToCopy
|
||||
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||
var bytes = input.read(buffer)
|
||||
while (bytes >= 0 && bytesRemaining > 0) {
|
||||
val len = if (bytes > bytesRemaining) bytesRemaining.toInt() else bytes
|
||||
output.write(buffer, 0, len)
|
||||
bytesRemaining -= len
|
||||
bytes = input.read(buffer)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.d(LOG_TAG, "failed to remove trailer video", e)
|
||||
callback.onFailure(e)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// copy the edited temporary file back to the original
|
||||
copyTo(context, mimeType, sourceFile = editableFile, targetUri = uri, targetPath = path)
|
||||
} catch (e: IOException) {
|
||||
callback.onFailure(e)
|
||||
return
|
||||
}
|
||||
|
||||
val newFields = HashMap<String, Any?>()
|
||||
scanPostMetadataEdit(context, path, uri, mimeType, newFields, callback)
|
||||
}
|
||||
|
||||
fun removeMetadataTypes(
|
||||
context: Context,
|
||||
path: String,
|
||||
|
@ -952,12 +1018,17 @@ abstract class ImageProvider {
|
|||
targetUri: Uri,
|
||||
targetPath: String
|
||||
) {
|
||||
if (isMediaUriPermissionGranted(context, targetUri, mimeType)) {
|
||||
val targetStream = StorageUtils.openOutputStream(context, targetUri, mimeType) ?: throw Exception("failed to open output stream for uri=$targetUri")
|
||||
DocumentFileCompat.fromFile(sourceFile).copyTo(targetStream)
|
||||
} else {
|
||||
val targetDocumentFile = StorageUtils.getDocumentFile(context, targetPath, targetUri) ?: throw Exception("failed to get document file for path=$targetPath, uri=$targetUri")
|
||||
DocumentFileCompat.fromFile(sourceFile).copyTo(targetDocumentFile)
|
||||
sourceFile.inputStream().use { input ->
|
||||
// truncate is necessary when overwriting a longer file
|
||||
val targetStream = if (isMediaUriPermissionGranted(context, targetUri, mimeType)) {
|
||||
StorageUtils.openOutputStream(context, targetUri, mimeType, "wt") ?: throw Exception("failed to open output stream for uri=$targetUri")
|
||||
} else {
|
||||
val documentUri = StorageUtils.getDocumentFile(context, targetPath, targetUri)?.uri ?: throw Exception("failed to get document file for path=$targetPath, uri=$targetUri")
|
||||
context.contentResolver.openOutputStream(documentUri, "wt") ?: throw Exception("failed to open output stream from documentUri=$documentUri for path=$targetPath, uri=$targetUri")
|
||||
}
|
||||
targetStream.use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -579,14 +579,14 @@ object StorageUtils {
|
|||
}
|
||||
}
|
||||
|
||||
fun openOutputStream(context: Context, uri: Uri, mimeType: String): OutputStream? {
|
||||
fun openOutputStream(context: Context, uri: Uri, mimeType: String, mode: String): OutputStream? {
|
||||
val effectiveUri = getMediaStoreScopedStorageSafeUri(uri, mimeType)
|
||||
return try {
|
||||
context.contentResolver.openOutputStream(effectiveUri)
|
||||
context.contentResolver.openOutputStream(effectiveUri, mode)
|
||||
} catch (e: Exception) {
|
||||
// among various other exceptions,
|
||||
// opening a file marked pending and owned by another package throws an `IllegalStateException`
|
||||
Log.w(LOG_TAG, "failed to open output stream for uri=$uri effectiveUri=$effectiveUri", e)
|
||||
Log.w(LOG_TAG, "failed to open output stream for uri=$uri effectiveUri=$effectiveUri mode=$mode", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,7 +6,7 @@ buildscript {
|
|||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:7.1.2'
|
||||
classpath 'com.android.tools.build:gradle:7.1.3'
|
||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
||||
// GMS & Firebase Crashlytics are not actually used by all flavors
|
||||
classpath 'com.google.gms:google-services:4.3.10'
|
||||
|
|
|
@ -87,7 +87,8 @@
|
|||
"entryActionShare": "Share",
|
||||
"entryActionViewSource": "View source",
|
||||
"entryActionShowGeoTiffOnMap": "Show as map overlay",
|
||||
"entryActionViewMotionPhotoVideo": "Open Motion Photo",
|
||||
"entryActionConvertMotionPhotoToStillImage": "Convert to still image",
|
||||
"entryActionViewMotionPhotoVideo": "Open video",
|
||||
"entryActionEdit": "Edit",
|
||||
"entryActionOpen": "Open with",
|
||||
"entryActionSetAs": "Set as",
|
||||
|
@ -370,6 +371,7 @@
|
|||
"removeEntryMetadataDialogMore": "More",
|
||||
|
||||
"removeEntryMetadataMotionPhotoXmpWarningDialogMessage": "XMP is required to play the video inside a motion photo.\n\nAre you sure you want to remove it?",
|
||||
"convertMotionPhotoToStillImageWarningDialogMessage": "Are you sure?",
|
||||
|
||||
"videoSpeedDialogLabel": "Playback speed",
|
||||
|
||||
|
|
|
@ -13,19 +13,24 @@ enum EntryInfoAction {
|
|||
// GeoTIFF
|
||||
showGeoTiffOnMap,
|
||||
// motion photo
|
||||
convertMotionPhotoToStillImage,
|
||||
viewMotionPhotoVideo,
|
||||
// debug
|
||||
debug,
|
||||
}
|
||||
|
||||
class EntryInfoActions {
|
||||
static const all = [
|
||||
static const common = [
|
||||
EntryInfoAction.editDate,
|
||||
EntryInfoAction.editLocation,
|
||||
EntryInfoAction.editRating,
|
||||
EntryInfoAction.editTags,
|
||||
EntryInfoAction.removeMetadata,
|
||||
];
|
||||
|
||||
static const formatSpecific = [
|
||||
EntryInfoAction.showGeoTiffOnMap,
|
||||
EntryInfoAction.convertMotionPhotoToStillImage,
|
||||
EntryInfoAction.viewMotionPhotoVideo,
|
||||
];
|
||||
}
|
||||
|
@ -48,6 +53,8 @@ extension ExtraEntryInfoAction on EntryInfoAction {
|
|||
case EntryInfoAction.showGeoTiffOnMap:
|
||||
return context.l10n.entryActionShowGeoTiffOnMap;
|
||||
// motion photo
|
||||
case EntryInfoAction.convertMotionPhotoToStillImage:
|
||||
return context.l10n.entryActionConvertMotionPhotoToStillImage;
|
||||
case EntryInfoAction.viewMotionPhotoVideo:
|
||||
return context.l10n.entryActionViewMotionPhotoVideo;
|
||||
// debug
|
||||
|
@ -87,8 +94,10 @@ extension ExtraEntryInfoAction on EntryInfoAction {
|
|||
case EntryInfoAction.showGeoTiffOnMap:
|
||||
return AIcons.map;
|
||||
// motion photo
|
||||
case EntryInfoAction.convertMotionPhotoToStillImage:
|
||||
return AIcons.convertToStillImage;
|
||||
case EntryInfoAction.viewMotionPhotoVideo:
|
||||
return AIcons.motionPhoto;
|
||||
return AIcons.openVideo;
|
||||
// debug
|
||||
case EntryInfoAction.debug:
|
||||
return AIcons.debug;
|
||||
|
|
|
@ -63,6 +63,7 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry {
|
|||
editCreateDateXmp(descriptions, null);
|
||||
break;
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
};
|
||||
final newFields = await metadataEditService.editMetadata(this, metadata);
|
||||
|
@ -156,10 +157,11 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry {
|
|||
|
||||
if (canEditXmp) {
|
||||
metadata[MetadataType.xmp] = await _editXmp((descriptions) {
|
||||
if (missingDate != null) {
|
||||
final modified = editTagsXmp(descriptions, tags);
|
||||
if (modified && missingDate != null) {
|
||||
editCreateDateXmp(descriptions, missingDate);
|
||||
}
|
||||
editTagsXmp(descriptions, tags);
|
||||
return modified;
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -185,10 +187,11 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry {
|
|||
|
||||
if (canEditXmp) {
|
||||
metadata[MetadataType.xmp] = await _editXmp((descriptions) {
|
||||
if (missingDate != null) {
|
||||
final modified = editRatingXmp(descriptions, rating);
|
||||
if (modified && missingDate != null) {
|
||||
editCreateDateXmp(descriptions, missingDate);
|
||||
}
|
||||
editRatingXmp(descriptions, rating);
|
||||
return modified;
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -199,6 +202,36 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry {
|
|||
return dataTypes;
|
||||
}
|
||||
|
||||
// remove:
|
||||
// - trailer video
|
||||
// - XMP / Container:Directory
|
||||
// - XMP / GCamera:MicroVideo*
|
||||
// - XMP / GCamera:MotionPhoto*
|
||||
Future<Set<EntryDataType>> removeTrailerVideo() async {
|
||||
final Set<EntryDataType> dataTypes = {};
|
||||
final Map<MetadataType, dynamic> metadata = {};
|
||||
|
||||
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 newFields = await metadataEditService.removeTypes(this, types);
|
||||
return newFields.isEmpty
|
||||
|
@ -232,8 +265,8 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry {
|
|||
}
|
||||
|
||||
@visibleForTesting
|
||||
static void editTagsXmp(List<XmlNode> descriptions, Set<String> tags) {
|
||||
XMP.setStringBag(
|
||||
static bool editTagsXmp(List<XmlNode> descriptions, Set<String> tags) {
|
||||
return XMP.setStringBag(
|
||||
descriptions,
|
||||
XMP.dcSubject,
|
||||
tags,
|
||||
|
@ -243,21 +276,55 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry {
|
|||
}
|
||||
|
||||
@visibleForTesting
|
||||
static void editRatingXmp(List<XmlNode> descriptions, int? rating) {
|
||||
XMP.setAttribute(
|
||||
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,
|
||||
);
|
||||
XMP.setAttribute(
|
||||
|
||||
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.container,
|
||||
);
|
||||
|
||||
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
|
||||
|
@ -328,7 +395,7 @@ extension ExtraAvesEntryMetadataEdition on AvesEntry {
|
|||
}
|
||||
}
|
||||
|
||||
Future<Map<String, String?>> _editXmp(void Function(List<XmlNode> descriptions) apply) async {
|
||||
Future<Map<String, String?>> _editXmp(bool Function(List<XmlNode> descriptions) apply) async {
|
||||
final xmp = await metadataFetchService.getXmp(this);
|
||||
final xmpString = xmp?.xmpString;
|
||||
final extendedXmpString = xmp?.extendedXmpString;
|
||||
|
|
|
@ -15,7 +15,9 @@ abstract class MetadataEditService {
|
|||
|
||||
Future<Map<String, dynamic>> editExifDate(AvesEntry entry, DateModifier modifier);
|
||||
|
||||
Future<Map<String, dynamic>> editMetadata(AvesEntry entry, Map<MetadataType, dynamic> modifier);
|
||||
Future<Map<String, dynamic>> editMetadata(AvesEntry entry, Map<MetadataType, dynamic> modifier, {bool autoCorrectTrailerOffset = true});
|
||||
|
||||
Future<Map<String, dynamic>> removeTrailerVideo(AvesEntry entry);
|
||||
|
||||
Future<Map<String, dynamic>> removeTypes(AvesEntry entry, Set<MetadataType> types);
|
||||
}
|
||||
|
@ -90,11 +92,31 @@ class PlatformMetadataEditService implements MetadataEditService {
|
|||
}
|
||||
|
||||
@override
|
||||
Future<Map<String, dynamic>> editMetadata(AvesEntry entry, Map<MetadataType, dynamic> metadata) async {
|
||||
Future<Map<String, dynamic>> editMetadata(
|
||||
AvesEntry entry,
|
||||
Map<MetadataType, dynamic> metadata, {
|
||||
bool autoCorrectTrailerOffset = true,
|
||||
}) async {
|
||||
try {
|
||||
final result = await platform.invokeMethod('editMetadata', <String, dynamic>{
|
||||
'entry': _toPlatformEntryMap(entry),
|
||||
'metadata': metadata.map((type, value) => MapEntry(_toPlatformMetadataType(type), value)),
|
||||
'autoCorrectTrailerOffset': autoCorrectTrailerOffset,
|
||||
});
|
||||
if (result != null) return (result as Map).cast<String, dynamic>();
|
||||
} on PlatformException catch (e, stack) {
|
||||
if (!entry.isMissingAtPath) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Map<String, dynamic>> removeTrailerVideo(AvesEntry entry) async {
|
||||
try {
|
||||
final result = await platform.invokeMethod('removeTrailerVideo', <String, dynamic>{
|
||||
'entry': _toPlatformEntryMap(entry),
|
||||
});
|
||||
if (result != null) return (result as Map).cast<String, dynamic>();
|
||||
} on PlatformException catch (e, stack) {
|
||||
|
|
|
@ -53,6 +53,7 @@ class AIcons {
|
|||
static const IconData clear = Icons.clear_outlined;
|
||||
static const IconData clipboard = Icons.content_copy_outlined;
|
||||
static const IconData convert = Icons.transform_outlined;
|
||||
static const IconData convertToStillImage = MdiIcons.movieRemoveOutline;
|
||||
static const IconData copy = Icons.file_copy_outlined;
|
||||
static const IconData debug = Icons.whatshot_outlined;
|
||||
static const IconData delete = Icons.delete_outlined;
|
||||
|
@ -80,6 +81,7 @@ class AIcons {
|
|||
static const IconData name = Icons.abc_outlined;
|
||||
static const IconData newTier = Icons.fiber_new_outlined;
|
||||
static const IconData openOutside = Icons.open_in_new_outlined;
|
||||
static const IconData openVideo = MdiIcons.moviePlayOutline;
|
||||
static const IconData pin = Icons.push_pin_outlined;
|
||||
static const IconData unpin = MdiIcons.pinOffOutline;
|
||||
static const IconData play = Icons.play_arrow;
|
||||
|
|
|
@ -2,7 +2,9 @@ import 'package:intl/intl.dart';
|
|||
import 'package:xml/xml.dart';
|
||||
|
||||
class Namespaces {
|
||||
static const container = 'http://ns.google.com/photos/1.0/container/';
|
||||
static const dc = 'http://purl.org/dc/elements/1.1/';
|
||||
static const gCamera = 'http://ns.google.com/photos/1.0/camera/';
|
||||
static const microsoftPhoto = 'http://ns.microsoft.com/photo/1.0/';
|
||||
static const rdf = 'http://www.w3.org/1999/02/22-rdf-syntax-ns#';
|
||||
static const x = 'adobe:ns:meta/';
|
||||
|
@ -10,7 +12,9 @@ class Namespaces {
|
|||
static const xmpNote = 'http://ns.adobe.com/xmp/note/';
|
||||
|
||||
static final defaultPrefixes = {
|
||||
container: 'Container',
|
||||
dc: 'dc',
|
||||
gCamera: 'GCamera',
|
||||
microsoftPhoto: 'MicrosoftPhoto',
|
||||
rdf: 'rdf',
|
||||
x: 'x',
|
||||
|
@ -30,6 +34,7 @@ class XMP {
|
|||
static const xXmpmeta = 'xmpmeta';
|
||||
static const rdfRoot = 'RDF';
|
||||
static const rdfDescription = 'Description';
|
||||
static const containerDirectory = 'Directory';
|
||||
static const dcSubject = 'subject';
|
||||
static const msPhotoRating = 'Rating';
|
||||
static const xmpRating = 'Rating';
|
||||
|
@ -37,6 +42,13 @@ class XMP {
|
|||
// attributes
|
||||
static const xXmptk = 'xmptk';
|
||||
static const rdfAbout = 'about';
|
||||
static const gCameraMicroVideo = 'MicroVideo';
|
||||
static const gCameraMicroVideoVersion = 'MicroVideoVersion';
|
||||
static const gCameraMicroVideoOffset = 'MicroVideoOffset';
|
||||
static const gCameraMicroVideoPresentationTimestampUs = 'MicroVideoPresentationTimestampUs';
|
||||
static const gCameraMotionPhoto = 'MotionPhoto';
|
||||
static const gCameraMotionPhotoVersion = 'MotionPhotoVersion';
|
||||
static const gCameraMotionPhotoPresentationTimestampUs = 'MotionPhotoPresentationTimestampUs';
|
||||
static const xmpCreateDate = 'CreateDate';
|
||||
static const xmpMetadataDate = 'MetadataDate';
|
||||
static const xmpModifyDate = 'ModifyDate';
|
||||
|
@ -97,7 +109,7 @@ class XMP {
|
|||
static void _addNamespaces(XmlNode node, Map<String, String> namespaces) => namespaces.forEach((uri, prefix) => node.setAttribute('$xmlnsPrefix:$prefix', uri));
|
||||
|
||||
// remove elements and attributes
|
||||
static bool _removeElements(List<XmlNode> nodes, String name, String namespace) {
|
||||
static bool removeElements(List<XmlNode> nodes, String name, String namespace) {
|
||||
var removed = false;
|
||||
nodes.forEach((node) {
|
||||
final elements = node.findElements(name, namespace: namespace).toSet();
|
||||
|
@ -115,17 +127,18 @@ class XMP {
|
|||
}
|
||||
|
||||
// remove attribute/element from all nodes, and set attribute with new value, if any, in the first node
|
||||
static void setAttribute(
|
||||
static bool setAttribute(
|
||||
List<XmlNode> nodes,
|
||||
String name,
|
||||
String? value, {
|
||||
required String namespace,
|
||||
required XmpEditStrategy strat,
|
||||
}) {
|
||||
final removed = _removeElements(nodes, name, namespace);
|
||||
final removed = removeElements(nodes, name, namespace);
|
||||
|
||||
if (value == null) return;
|
||||
if (value == null) return removed;
|
||||
|
||||
bool modified = removed;
|
||||
if (strat == XmpEditStrategy.always || (strat == XmpEditStrategy.updateIfPresent && removed)) {
|
||||
final node = nodes.first;
|
||||
_addNamespaces(node, {namespace: prefixOf(namespace)});
|
||||
|
@ -133,7 +146,10 @@ class XMP {
|
|||
// use qualified name, otherwise the namespace prefix is not added
|
||||
final qualifiedName = '${prefixOf(namespace)}$propNamespaceSeparator$name';
|
||||
node.setAttribute(qualifiedName, value);
|
||||
modified = true;
|
||||
}
|
||||
|
||||
return modified;
|
||||
}
|
||||
|
||||
// remove attribute/element from all nodes, and create element with new value, if any, in the first node
|
||||
|
@ -144,7 +160,7 @@ class XMP {
|
|||
required String namespace,
|
||||
required XmpEditStrategy strat,
|
||||
}) {
|
||||
final removed = _removeElements(nodes, name, namespace);
|
||||
final removed = removeElements(nodes, name, namespace);
|
||||
|
||||
if (value == null) return;
|
||||
|
||||
|
@ -162,7 +178,7 @@ class XMP {
|
|||
}
|
||||
|
||||
// remove bag from all nodes, and create bag with new values, if any, in the first node
|
||||
static void setStringBag(
|
||||
static bool setStringBag(
|
||||
List<XmlNode> nodes,
|
||||
String name,
|
||||
Set<String> values, {
|
||||
|
@ -170,10 +186,11 @@ class XMP {
|
|||
required XmpEditStrategy strat,
|
||||
}) {
|
||||
// remove existing
|
||||
final removed = _removeElements(nodes, name, namespace);
|
||||
final removed = removeElements(nodes, name, namespace);
|
||||
|
||||
if (values.isEmpty) return;
|
||||
if (values.isEmpty) return removed;
|
||||
|
||||
bool modified = removed;
|
||||
if (strat == XmpEditStrategy.always || (strat == XmpEditStrategy.updateIfPresent && removed)) {
|
||||
final node = nodes.first;
|
||||
_addNamespaces(node, {namespace: prefixOf(namespace)});
|
||||
|
@ -192,13 +209,16 @@ class XMP {
|
|||
});
|
||||
});
|
||||
node.children.last.children.add(bagBuilder.buildFragment());
|
||||
modified = true;
|
||||
}
|
||||
|
||||
return modified;
|
||||
}
|
||||
|
||||
static Future<String?> edit(
|
||||
String? xmpString,
|
||||
Future<String> Function() toolkit,
|
||||
void Function(List<XmlNode> descriptions) apply, {
|
||||
bool Function(List<XmlNode> descriptions) apply, {
|
||||
DateTime? modifyDate,
|
||||
}) async {
|
||||
XmlDocument? xmpDoc;
|
||||
|
@ -244,7 +264,7 @@ class XMP {
|
|||
// get element because doc fragment cannot be used to edit
|
||||
descriptions.add(rdf.getElement(rdfDescription, namespace: Namespaces.rdf)!);
|
||||
}
|
||||
apply(descriptions);
|
||||
final modified = apply(descriptions);
|
||||
|
||||
// clean description nodes with no children
|
||||
descriptions.where((v) => !_hasMeaningfulChildren(v)).forEach((v) => v.children.clear());
|
||||
|
@ -253,10 +273,12 @@ class XMP {
|
|||
rdf.children.removeWhere((v) => !_hasMeaningfulChildren(v) && !_hasMeaningfulAttributes(v));
|
||||
|
||||
if (rdf.children.isNotEmpty) {
|
||||
_addNamespaces(descriptions.first, {Namespaces.xmp: prefixOf(Namespaces.xmp)});
|
||||
final xmpDate = toXmpDate(modifyDate ?? DateTime.now());
|
||||
setAttribute(descriptions, xmpMetadataDate, xmpDate, namespace: Namespaces.xmp, strat: XmpEditStrategy.always);
|
||||
setAttribute(descriptions, xmpModifyDate, xmpDate, namespace: Namespaces.xmp, strat: XmpEditStrategy.always);
|
||||
if (modified) {
|
||||
_addNamespaces(descriptions.first, {Namespaces.xmp: prefixOf(Namespaces.xmp)});
|
||||
final xmpDate = toXmpDate(modifyDate ?? DateTime.now());
|
||||
setAttribute(descriptions, xmpMetadataDate, xmpDate, namespace: Namespaces.xmp, strat: XmpEditStrategy.always);
|
||||
setAttribute(descriptions, xmpModifyDate, xmpDate, namespace: Namespaces.xmp, strat: XmpEditStrategy.always);
|
||||
}
|
||||
} else {
|
||||
// clear XMP if there are no attributes or elements worth preserving
|
||||
xmpDoc = null;
|
||||
|
|
|
@ -10,6 +10,8 @@ import 'package:aves/services/common/services.dart';
|
|||
import 'package:aves/widgets/common/action_mixins/entry_editor.dart';
|
||||
import 'package:aves/widgets/common/action_mixins/feedback.dart';
|
||||
import 'package:aves/widgets/common/action_mixins/permission_aware.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:aves/widgets/dialogs/aves_dialog.dart';
|
||||
import 'package:aves/widgets/map/map_page.dart';
|
||||
import 'package:aves/widgets/viewer/action/single_entry_editor.dart';
|
||||
import 'package:aves/widgets/viewer/debug/debug_page.dart';
|
||||
|
@ -41,6 +43,7 @@ class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin, EntryEdi
|
|||
case EntryInfoAction.showGeoTiffOnMap:
|
||||
return entry.isGeotiff;
|
||||
// motion photo
|
||||
case EntryInfoAction.convertMotionPhotoToStillImage:
|
||||
case EntryInfoAction.viewMotionPhotoVideo:
|
||||
return entry.isMotionPhoto;
|
||||
// debug
|
||||
|
@ -66,6 +69,8 @@ class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin, EntryEdi
|
|||
case EntryInfoAction.showGeoTiffOnMap:
|
||||
return true;
|
||||
// motion photo
|
||||
case EntryInfoAction.convertMotionPhotoToStillImage:
|
||||
return entry.canEdit;
|
||||
case EntryInfoAction.viewMotionPhotoVideo:
|
||||
return true;
|
||||
// debug
|
||||
|
@ -98,6 +103,9 @@ class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin, EntryEdi
|
|||
await _showGeoTiffOnMap(context);
|
||||
break;
|
||||
// motion photo
|
||||
case EntryInfoAction.convertMotionPhotoToStillImage:
|
||||
await _convertMotionPhotoToStillImage(context);
|
||||
break;
|
||||
case EntryInfoAction.viewMotionPhotoVideo:
|
||||
OpenEmbeddedDataNotification.motionPhotoVideo().dispatch(context);
|
||||
break;
|
||||
|
@ -148,6 +156,30 @@ class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin, EntryEdi
|
|||
await edit(context, () => entry.removeMetadata(types));
|
||||
}
|
||||
|
||||
Future<void> _convertMotionPhotoToStillImage(BuildContext context) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AvesDialog(
|
||||
content: Text(context.l10n.convertMotionPhotoToStillImageWarningDialogMessage),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(MaterialLocalizations.of(context).cancelButtonLabel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: Text(context.l10n.applyButtonLabel),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
if (confirmed == null || !confirmed) return;
|
||||
|
||||
await edit(context, entry.removeTrailerVideo);
|
||||
}
|
||||
|
||||
Future<void> _showGeoTiffOnMap(BuildContext context) async {
|
||||
final info = await metadataFetchService.getGeoTiffInfo(entry);
|
||||
if (info == null) return;
|
||||
|
|
|
@ -29,7 +29,8 @@ class InfoAppBar extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final menuActions = EntryInfoActions.all.where(actionDelegate.isVisible);
|
||||
final commonActions = EntryInfoActions.common.where(actionDelegate.isVisible);
|
||||
final formatSpecificActions = EntryInfoActions.formatSpecific.where(actionDelegate.isVisible);
|
||||
|
||||
return SliverAppBar(
|
||||
leading: IconButton(
|
||||
|
@ -55,7 +56,11 @@ class InfoAppBar extends StatelessWidget {
|
|||
MenuIconTheme(
|
||||
child: PopupMenuButton<EntryInfoAction>(
|
||||
itemBuilder: (context) => [
|
||||
...menuActions.map((action) => _toMenuItem(context, action, enabled: actionDelegate.canApply(action))),
|
||||
...commonActions.map((action) => _toMenuItem(context, action, enabled: actionDelegate.canApply(action))),
|
||||
if (formatSpecificActions.isNotEmpty) ...[
|
||||
const PopupMenuDivider(),
|
||||
...formatSpecificActions.map((action) => _toMenuItem(context, action, enabled: actionDelegate.canApply(action))),
|
||||
],
|
||||
if (!kReleaseMode) ...[
|
||||
const PopupMenuDivider(),
|
||||
_toMenuItem(context, EntryInfoAction.debug, enabled: true),
|
||||
|
|
|
@ -86,6 +86,50 @@ void main() {
|
|||
</rdf:Description>
|
||||
</rdf:RDF>
|
||||
</x:xmpmeta>
|
||||
''';
|
||||
const inMotionPhotoMicroVideo = '''
|
||||
<x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="Adobe XMP Core 5.1.0-jc003">
|
||||
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
|
||||
<rdf:Description rdf:about=""
|
||||
xmlns:GCamera="http://ns.google.com/photos/1.0/camera/"
|
||||
GCamera:MicroVideo="1"
|
||||
GCamera:MicroVideoVersion="1"
|
||||
GCamera:MicroVideoOffset="1228513"
|
||||
GCamera:MicroVideoPresentationTimestampUs="233246"/>
|
||||
</rdf:RDF>
|
||||
</x:xmpmeta>
|
||||
''';
|
||||
const inMotionPhotoContainer = '''
|
||||
<x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="Adobe XMP Core 5.1.0-jc003">
|
||||
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
|
||||
<rdf:Description rdf:about=""
|
||||
xmlns:GCamera="http://ns.google.com/photos/1.0/camera/"
|
||||
xmlns:Container="http://ns.google.com/photos/1.0/container/"
|
||||
xmlns:Item="http://ns.google.com/photos/1.0/container/item/"
|
||||
GCamera:MotionPhoto="1"
|
||||
GCamera:MotionPhotoVersion="1"
|
||||
GCamera:MotionPhotoPresentationTimestampUs="2306056">
|
||||
<Container:Directory>
|
||||
<rdf:Seq>
|
||||
<rdf:li rdf:parseType="Resource">
|
||||
<Container:Item
|
||||
Item:Mime="image/jpeg"
|
||||
Item:Semantic="Primary"
|
||||
Item:Length="0"
|
||||
Item:Padding="59"/>
|
||||
</rdf:li>
|
||||
<rdf:li rdf:parseType="Resource">
|
||||
<Container:Item
|
||||
Item:Mime="video/mp4"
|
||||
Item:Semantic="MotionPhoto"
|
||||
Item:Length="3491777"
|
||||
Item:Padding="0"/>
|
||||
</rdf:li>
|
||||
</rdf:Seq>
|
||||
</Container:Directory>
|
||||
</rdf:Description>
|
||||
</rdf:RDF>
|
||||
</x:xmpmeta>
|
||||
''';
|
||||
|
||||
test('Get string', () async {
|
||||
|
@ -362,7 +406,6 @@ void main() {
|
|||
|
||||
test('Remove rating from XMP with subjects only', () async {
|
||||
final modifyDate = DateTime.now();
|
||||
final xmpDate = XMP.toXmpDate(modifyDate);
|
||||
|
||||
expect(
|
||||
_toExpect(await XMP.edit(
|
||||
|
@ -371,23 +414,46 @@ void main() {
|
|||
(descriptions) => ExtraAvesEntryMetadataEdition.editRatingXmp(descriptions, null),
|
||||
modifyDate: modifyDate,
|
||||
)),
|
||||
_toExpect('''
|
||||
<x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="Adobe XMP Core Test.SNAPSHOT">
|
||||
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
|
||||
<rdf:Description rdf:about=""
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:xmp="http://ns.adobe.com/xap/1.0/"
|
||||
xmp:MetadataDate="$xmpDate"
|
||||
xmp:ModifyDate="$xmpDate">
|
||||
<dc:subject>
|
||||
<rdf:Bag>
|
||||
<rdf:li>the king</rdf:li>
|
||||
</rdf:Bag>
|
||||
</dc:subject>
|
||||
</rdf:Description>
|
||||
</rdf:RDF>
|
||||
</x:xmpmeta>
|
||||
'''));
|
||||
_toExpect(inSubjects));
|
||||
});
|
||||
|
||||
test('Remove trailer media info from XMP with micro video', () async {
|
||||
final modifyDate = DateTime.now();
|
||||
|
||||
expect(
|
||||
_toExpect(await XMP.edit(
|
||||
inMotionPhotoMicroVideo,
|
||||
() async => toolkit,
|
||||
ExtraAvesEntryMetadataEdition.removeContainerXmp,
|
||||
modifyDate: modifyDate,
|
||||
)),
|
||||
_toExpect(null));
|
||||
});
|
||||
|
||||
test('Remove trailer media info from XMP with container', () async {
|
||||
final modifyDate = DateTime.now();
|
||||
|
||||
expect(
|
||||
_toExpect(await XMP.edit(
|
||||
inMotionPhotoContainer,
|
||||
() async => toolkit,
|
||||
ExtraAvesEntryMetadataEdition.removeContainerXmp,
|
||||
modifyDate: modifyDate,
|
||||
)),
|
||||
_toExpect(null));
|
||||
});
|
||||
|
||||
test('Remove trailer media info from XMP with no related metadata', () async {
|
||||
final modifyDate = DateTime.now();
|
||||
|
||||
expect(
|
||||
_toExpect(await XMP.edit(
|
||||
inSubjects,
|
||||
() async => toolkit,
|
||||
ExtraAvesEntryMetadataEdition.removeContainerXmp,
|
||||
modifyDate: modifyDate,
|
||||
)),
|
||||
_toExpect(inSubjects));
|
||||
});
|
||||
|
||||
test('Remove rating from XMP with ratings (multiple descriptions)', () async {
|
||||
|
|
|
@ -1,38 +1,55 @@
|
|||
{
|
||||
"de": [
|
||||
"entryActionShowGeoTiffOnMap"
|
||||
"entryActionShowGeoTiffOnMap",
|
||||
"entryActionConvertMotionPhotoToStillImage",
|
||||
"convertMotionPhotoToStillImageWarningDialogMessage"
|
||||
],
|
||||
|
||||
"es": [
|
||||
"entryActionShowGeoTiffOnMap"
|
||||
"entryActionShowGeoTiffOnMap",
|
||||
"entryActionConvertMotionPhotoToStillImage",
|
||||
"convertMotionPhotoToStillImageWarningDialogMessage"
|
||||
],
|
||||
|
||||
"fr": [
|
||||
"entryActionShowGeoTiffOnMap"
|
||||
"entryActionShowGeoTiffOnMap",
|
||||
"entryActionConvertMotionPhotoToStillImage",
|
||||
"convertMotionPhotoToStillImageWarningDialogMessage"
|
||||
],
|
||||
|
||||
"id": [
|
||||
"entryActionShowGeoTiffOnMap"
|
||||
"entryActionShowGeoTiffOnMap",
|
||||
"entryActionConvertMotionPhotoToStillImage",
|
||||
"convertMotionPhotoToStillImageWarningDialogMessage"
|
||||
],
|
||||
|
||||
"it": [
|
||||
"entryActionShowGeoTiffOnMap"
|
||||
"entryActionShowGeoTiffOnMap",
|
||||
"entryActionConvertMotionPhotoToStillImage",
|
||||
"convertMotionPhotoToStillImageWarningDialogMessage"
|
||||
],
|
||||
|
||||
"ja": [
|
||||
"entryActionShowGeoTiffOnMap"
|
||||
"entryActionShowGeoTiffOnMap",
|
||||
"entryActionConvertMotionPhotoToStillImage",
|
||||
"convertMotionPhotoToStillImageWarningDialogMessage"
|
||||
],
|
||||
|
||||
"ko": [
|
||||
"entryActionShowGeoTiffOnMap"
|
||||
"entryActionShowGeoTiffOnMap",
|
||||
"entryActionConvertMotionPhotoToStillImage",
|
||||
"convertMotionPhotoToStillImageWarningDialogMessage"
|
||||
],
|
||||
|
||||
"pt": [
|
||||
"entryActionShowGeoTiffOnMap"
|
||||
"entryActionShowGeoTiffOnMap",
|
||||
"entryActionConvertMotionPhotoToStillImage",
|
||||
"convertMotionPhotoToStillImageWarningDialogMessage"
|
||||
],
|
||||
|
||||
"ru": [
|
||||
"entryActionShowGeoTiffOnMap",
|
||||
"entryActionConvertMotionPhotoToStillImage",
|
||||
"displayRefreshRatePreferHighest",
|
||||
"displayRefreshRatePreferLowest",
|
||||
"themeBrightnessLight",
|
||||
|
@ -47,6 +64,7 @@
|
|||
"renameProcessorCounter",
|
||||
"renameProcessorName",
|
||||
"editEntryDateDialogCopyItem",
|
||||
"convertMotionPhotoToStillImageWarningDialogMessage",
|
||||
"collectionRenameFailureFeedback",
|
||||
"collectionRenameSuccessFeedback",
|
||||
"settingsConfirmationDialogMoveUndatedItems",
|
||||
|
|
Loading…
Reference in a new issue