diff --git a/CHANGELOG.md b/CHANGELOG.md
index e2d604789..a9a5c1075 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -22,6 +22,7 @@ All notable changes to this project will be documented in this file.
- editing TIFF metadata increasing file size
- region decoding for some RAW files
- incorrect video size or orientation as reported by Media Store
+- corrupting image when removing video from motion photo with incorrect metadata
## [v1.12.2] - 2025-01-13
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/EmbeddedDataHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/EmbeddedDataHandler.kt
index 39efd8585..46238f70e 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/EmbeddedDataHandler.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/EmbeddedDataHandler.kt
@@ -186,7 +186,7 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
return
}
- MultiPage.getMotionPhotoOffset(context, uri, mimeType, sizeBytes)?.let { videoSizeBytes ->
+ MultiPage.getMotionPhotoVideoSize(context, uri, mimeType, sizeBytes)?.let { videoSizeBytes ->
val imageSizeBytes = sizeBytes - videoSizeBytes
StorageUtils.openInputStream(context, uri)?.let { input ->
copyEmbeddedBytes(result, mimeType, displayName, input, imageSizeBytes)
@@ -207,7 +207,7 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
return
}
- MultiPage.getMotionPhotoOffset(context, uri, mimeType, sizeBytes)?.let { videoSizeBytes ->
+ MultiPage.getMotionPhotoVideoSize(context, uri, mimeType, sizeBytes)?.let { videoSizeBytes ->
val videoStartOffset = sizeBytes - videoSizeBytes
StorageUtils.openInputStream(context, uri)?.let { input ->
input.skip(videoStartOffset)
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MediaMetadataRetrieverHelper.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MediaMetadataRetrieverHelper.kt
index 2c2616177..fd840b932 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MediaMetadataRetrieverHelper.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MediaMetadataRetrieverHelper.kt
@@ -111,20 +111,25 @@ object MediaMetadataRetrieverHelper {
// format
MediaMetadataRetriever.METADATA_KEY_IMAGE_ROTATION,
MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION -> "$value°"
+
MediaMetadataRetriever.METADATA_KEY_IMAGE_HEIGHT, MediaMetadataRetriever.METADATA_KEY_IMAGE_WIDTH,
MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT, MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH -> "$value pixels"
+
MediaMetadataRetriever.METADATA_KEY_BITRATE -> {
val bitrate = value.toLongOrNull() ?: 0
if (bitrate > 0) formatBitrate(bitrate) else null
}
+
MediaMetadataRetriever.METADATA_KEY_CAPTURE_FRAMERATE -> {
val framerate = value.toDoubleOrNull() ?: 0.0
if (framerate > 0.0) "$framerate" else null
}
+
MediaMetadataRetriever.METADATA_KEY_DURATION -> {
val dateMillis = value.toLongOrNull() ?: 0
if (dateMillis > 0) durationFormat.format(Date(dateMillis)) else null
}
+
MediaMetadataRetriever.METADATA_KEY_COLOR_RANGE -> {
when (value.toIntOrNull()) {
MediaFormat.COLOR_RANGE_FULL -> "Full"
@@ -132,6 +137,7 @@ object MediaMetadataRetrieverHelper {
else -> value
}
}
+
MediaMetadataRetriever.METADATA_KEY_COLOR_STANDARD -> {
when (value.toIntOrNull()) {
MediaFormat.COLOR_STANDARD_BT709 -> "BT.709"
@@ -141,6 +147,7 @@ object MediaMetadataRetrieverHelper {
else -> value
}
}
+
MediaMetadataRetriever.METADATA_KEY_COLOR_TRANSFER -> {
when (value.toIntOrNull()) {
MediaFormat.COLOR_TRANSFER_LINEAR -> "Linear"
@@ -154,6 +161,7 @@ object MediaMetadataRetrieverHelper {
MediaMetadataRetriever.METADATA_KEY_COMPILATION,
MediaMetadataRetriever.METADATA_KEY_DISC_NUMBER,
MediaMetadataRetriever.METADATA_KEY_YEAR -> if (value != "0") value else null
+
MediaMetadataRetriever.METADATA_KEY_CD_TRACK_NUMBER -> if (value != "0/0") value else null
MediaMetadataRetriever.METADATA_KEY_DATE -> {
val dateMillis = Metadata.parseVideoMetadataDate(value)
@@ -168,4 +176,12 @@ object MediaMetadataRetrieverHelper {
}?.let { save(it) }
}
}
+
+ fun MediaFormat.getSafeInt(key: String, save: (value: Int) -> Unit) {
+ if (this.containsKey(key)) save(this.getInteger(key))
+ }
+
+ fun MediaFormat.getSafeLong(key: String, save: (value: Long) -> Unit) {
+ if (this.containsKey(key)) save(this.getLong(key))
+ }
}
\ No newline at end of file
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MultiPage.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MultiPage.kt
index 5aec4ed9c..a0d98a966 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MultiPage.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/MultiPage.kt
@@ -15,6 +15,8 @@ import com.drew.metadata.exif.ExifDirectoryBase
import com.drew.metadata.exif.ExifIFD0Directory
import com.drew.metadata.xmp.XmpDirectory
import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeInt
+import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeInt
+import deckers.thibault.aves.metadata.MediaMetadataRetrieverHelper.getSafeLong
import deckers.thibault.aves.metadata.metadataextractor.Helper
import deckers.thibault.aves.metadata.metadataextractor.Helper.getSafeInt
import deckers.thibault.aves.metadata.metadataextractor.mpf.MpEntry
@@ -47,14 +49,6 @@ object MultiPage {
private const val KEY_ROTATION_DEGREES = "rotationDegrees"
fun getHeicTracks(context: Context, uri: Uri): ArrayList {
- fun MediaFormat.getSafeInt(key: String, save: (value: Int) -> Unit) {
- if (this.containsKey(key)) save(this.getInteger(key))
- }
-
- fun MediaFormat.getSafeLong(key: String, save: (value: Long) -> Unit) {
- if (this.containsKey(key)) save(this.getLong(key))
- }
-
val tracks = ArrayList()
val extractor = MediaExtractor()
extractor.setDataSource(context, uri, null)
@@ -250,70 +244,41 @@ object MultiPage {
}
fun getMotionPhotoPages(context: Context, uri: Uri, mimeType: String, sizeBytes: Long): ArrayList {
- fun MediaFormat.getSafeInt(key: String, save: (value: Int) -> Unit) {
- if (this.containsKey(key)) save(this.getInteger(key))
- }
-
- fun MediaFormat.getSafeLong(key: String, save: (value: Long) -> Unit) {
- if (this.containsKey(key)) save(this.getLong(key))
- }
-
val pages = ArrayList()
- val extractor = MediaExtractor()
- var pfd: ParcelFileDescriptor? = null
- try {
- getMotionPhotoOffset(context, uri, mimeType, sizeBytes)?.let { videoSizeBytes ->
- val videoStartOffset = sizeBytes - videoSizeBytes
- pfd = context.contentResolver.openFileDescriptor(uri, "r")
- pfd?.fileDescriptor?.let { fd ->
- extractor.setDataSource(fd, videoStartOffset, videoSizeBytes)
- // set the original image as the first and default track
- var pageIndex = 0
- pages.add(
- hashMapOf(
- KEY_PAGE to pageIndex++,
- KEY_MIME_TYPE to mimeType,
- KEY_IS_DEFAULT to true,
- )
+ getMotionPhotoVideoSize(context, uri, mimeType, sizeBytes)?.let { videoSizeBytes ->
+ getTrailerVideoInfo(context, uri, fileSizeBytes = sizeBytes, videoSizeBytes = videoSizeBytes)?.let { videoInfo ->
+ // set the original image as the first and default track
+ var pageIndex = 0
+ pages.add(
+ hashMapOf(
+ KEY_PAGE to pageIndex++,
+ KEY_MIME_TYPE to mimeType,
+ KEY_IS_DEFAULT to true,
)
- // add video tracks from the appended video
- if (extractor.trackCount > 0) {
- // only consider the first track to represent the appended video
- val trackIndex = 0
- try {
- val format = extractor.getTrackFormat(trackIndex)
- format.getString(MediaFormat.KEY_MIME)?.let { mime ->
- if (MimeTypes.isVideo(mime)) {
- val page: FieldMap = hashMapOf(
- KEY_PAGE to pageIndex++,
- KEY_MIME_TYPE to MimeTypes.MP4,
- KEY_IS_DEFAULT to false,
- )
- format.getSafeInt(MediaFormat.KEY_WIDTH) { page[KEY_WIDTH] = it }
- format.getSafeInt(MediaFormat.KEY_HEIGHT) { page[KEY_HEIGHT] = it }
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
- format.getSafeInt(MediaFormat.KEY_ROTATION) { page[KEY_ROTATION_DEGREES] = it }
- }
- format.getSafeLong(MediaFormat.KEY_DURATION) { page[KEY_DURATION] = it / 1000 }
- pages.add(page)
- }
- }
- } catch (e: Exception) {
- Log.w(LOG_TAG, "failed to get motion photo track information for uri=$uri, track num=$trackIndex", e)
+ )
+ // add video tracks from the appended video
+ videoInfo.getString(MediaFormat.KEY_MIME)?.let { mime ->
+ if (MimeTypes.isVideo(mime)) {
+ val page: FieldMap = hashMapOf(
+ KEY_PAGE to pageIndex++,
+ KEY_MIME_TYPE to MimeTypes.MP4,
+ KEY_IS_DEFAULT to false,
+ )
+ videoInfo.getSafeInt(MediaFormat.KEY_WIDTH) { page[KEY_WIDTH] = it }
+ videoInfo.getSafeInt(MediaFormat.KEY_HEIGHT) { page[KEY_HEIGHT] = it }
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ videoInfo.getSafeInt(MediaFormat.KEY_ROTATION) { page[KEY_ROTATION_DEGREES] = it }
}
+ videoInfo.getSafeLong(MediaFormat.KEY_DURATION) { page[KEY_DURATION] = it / 1000 }
+ pages.add(page)
}
}
}
- } catch (e: Exception) {
- Log.w(LOG_TAG, "failed to open motion photo for uri=$uri", e)
- } finally {
- extractor.release()
- pfd?.close()
}
return pages
}
- fun getMotionPhotoOffset(context: Context, uri: Uri, mimeType: String, sizeBytes: Long): Long? {
+ fun getMotionPhotoVideoSize(context: Context, uri: Uri, mimeType: String, sizeBytes: Long): Long? {
if (MimeTypes.isHeic(mimeType)) {
// XMP in HEIC motion photos (as taken with a Samsung Camera v12.0.01.50) indicates an `Item:Length` of 68 bytes for the video.
// This item does not contain the video itself, but only some kind of metadata (no doc, no spec),
@@ -360,6 +325,34 @@ object MultiPage {
return offsetFromEnd
}
+ fun getTrailerVideoInfo(context: Context, uri: Uri, fileSizeBytes: Long, videoSizeBytes: Long): MediaFormat? {
+ var format: MediaFormat? = null
+ val extractor = MediaExtractor()
+ var pfd: ParcelFileDescriptor? = null
+ try {
+ val videoStartOffset = fileSizeBytes - videoSizeBytes
+ pfd = context.contentResolver.openFileDescriptor(uri, "r")
+ pfd?.fileDescriptor?.let { fd ->
+ extractor.setDataSource(fd, videoStartOffset, videoSizeBytes)
+ if (extractor.trackCount > 0) {
+ // only consider the first track to represent the appended video
+ val trackIndex = 0
+ try {
+ format = extractor.getTrackFormat(trackIndex)
+ } catch (e: Exception) {
+ Log.w(LOG_TAG, "failed to get motion photo track information for uri=$uri, track num=$trackIndex", e)
+ }
+ }
+ }
+ } catch (e: Exception) {
+ Log.w(LOG_TAG, "failed to open motion photo for uri=$uri", e)
+ } finally {
+ extractor.release()
+ pfd?.close()
+ }
+ return format
+ }
+
fun getTiffPages(context: Context, uri: Uri): ArrayList {
fun toMap(pageIndex: Int, options: TiffBitmapFactory.Options): FieldMap {
return hashMapOf(
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/xmp/GoogleXMP.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/xmp/GoogleXMP.kt
index cf662e1bb..f5f42d383 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/xmp/GoogleXMP.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/xmp/GoogleXMP.kt
@@ -183,7 +183,7 @@ object GoogleXMP {
return offsetFromEnd
}
- fun updateTrailingVideoOffset(xmp: String, oldOffset: Int, newOffset: Int): String {
+ fun updateTrailingVideoOffset(xmp: String, oldOffset: Number, newOffset: Number): String {
return xmp.replace(
// GCamera motion photo
"${GCAMERA_VIDEO_OFFSET_PROP_NAME}=\"$oldOffset\"",
@@ -195,7 +195,6 @@ object GoogleXMP {
)
}
-
fun getDeviceContainer(meta: XMPMeta): GoogleDeviceContainer? {
return if (meta.doesPropPathExist(listOf(GDEVICE_CONTAINER_PROP_NAME, GDEVICE_CONTAINER_DIRECTORY_PROP_NAME))) {
GoogleDeviceContainer().apply { findItems(meta) }
diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt
index 51e4d5e66..fc46edd70 100644
--- a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt
+++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt
@@ -646,19 +646,21 @@ abstract class ImageProvider {
}
val originalFileSize = File(path).length()
- val videoSize = MultiPage.getMotionPhotoOffset(context, uri, mimeType, originalFileSize)?.let { it.toInt() + trailerDiff }
- var videoBytes: ByteArray? = null
+ var trailerVideoBytes: ByteArray? = null
val editableFile = StorageUtils.createTempFile(context).apply {
+ val videoSize = MultiPage.getMotionPhotoVideoSize(context, uri, mimeType, originalFileSize)?.let { it + trailerDiff }
+ val isTrailerVideoValid = videoSize != null && MultiPage.getTrailerVideoInfo(context, uri, originalFileSize, videoSize) != null
try {
- if (videoSize != null) {
+ if (videoSize != null && isTrailerVideoValid) {
// handle motion photo and embedded video separately
val imageSize = (originalFileSize - videoSize).toInt()
- videoBytes = ByteArray(videoSize)
+ val videoByteSize = videoSize.toInt()
+ trailerVideoBytes = ByteArray(videoByteSize)
StorageUtils.openInputStream(context, uri)?.let { input ->
val imageBytes = ByteArray(imageSize)
input.read(imageBytes, 0, imageSize)
- input.read(videoBytes, 0, videoSize)
+ input.read(trailerVideoBytes, 0, videoByteSize)
// copy only the image to a temporary file for editing
// video will be appended after metadata modification
@@ -693,15 +695,15 @@ abstract class ImageProvider {
ImageDecoder.decodeBitmap(ImageDecoder.createSource(editableFile))
}
- if (videoBytes != null) {
+ if (trailerVideoBytes != null) {
// append trailer video, if any
- editableFile.appendBytes(videoBytes!!)
+ editableFile.appendBytes(trailerVideoBytes!!)
}
// copy the edited temporary file back to the original
editableFile.transferTo(outputStream(context, mimeType, uri, path))
- if (autoCorrectTrailerOffset && !checkTrailerOffset(context, path, uri, mimeType, videoSize, editableFile, callback)) {
+ if (autoCorrectTrailerOffset && !checkTrailerOffset(context, path, uri, mimeType, trailerVideoBytes?.size, editableFile, callback)) {
return false
}
editableFile.delete()
@@ -729,19 +731,21 @@ abstract class ImageProvider {
}
val originalFileSize = File(path).length()
- val videoSize = MultiPage.getMotionPhotoOffset(context, uri, mimeType, originalFileSize)?.let { it.toInt() + trailerDiff }
- var videoBytes: ByteArray? = null
+ var trailerVideoBytes: ByteArray? = null
val editableFile = StorageUtils.createTempFile(context).apply {
+ val videoSize = MultiPage.getMotionPhotoVideoSize(context, uri, mimeType, originalFileSize)?.let { it + trailerDiff }
+ val isTrailerVideoValid = videoSize != null && MultiPage.getTrailerVideoInfo(context, uri, originalFileSize, videoSize) != null
try {
- if (videoSize != null) {
+ if (videoSize != null && isTrailerVideoValid) {
// handle motion photo and embedded video separately
val imageSize = (originalFileSize - videoSize).toInt()
- videoBytes = ByteArray(videoSize)
+ val videoByteSize = videoSize.toInt()
+ trailerVideoBytes = ByteArray(videoByteSize)
StorageUtils.openInputStream(context, uri)?.let { input ->
val imageBytes = ByteArray(imageSize)
input.read(imageBytes, 0, imageSize)
- input.read(videoBytes, 0, videoSize)
+ input.read(trailerVideoBytes, 0, videoByteSize)
// copy only the image to a temporary file for editing
// video will be appended after metadata modification
@@ -777,15 +781,15 @@ abstract class ImageProvider {
}
}
- if (videoBytes != null) {
+ if (trailerVideoBytes != null) {
// append trailer video, if any
- editableFile.appendBytes(videoBytes!!)
+ editableFile.appendBytes(trailerVideoBytes!!)
}
// copy the edited temporary file back to the original
editableFile.transferTo(outputStream(context, mimeType, uri, path))
- if (autoCorrectTrailerOffset && !checkTrailerOffset(context, path, uri, mimeType, videoSize, editableFile, callback)) {
+ if (autoCorrectTrailerOffset && !checkTrailerOffset(context, path, uri, mimeType, trailerVideoBytes?.size, editableFile, callback)) {
return false
}
editableFile.delete()
@@ -895,7 +899,7 @@ abstract class ImageProvider {
}
val originalFileSize = File(path).length()
- val videoSize = MultiPage.getMotionPhotoOffset(context, uri, mimeType, originalFileSize)?.let { it.toInt() + trailerDiff }
+ val videoSize = MultiPage.getMotionPhotoVideoSize(context, uri, mimeType, originalFileSize)?.let { it.toInt() + trailerDiff }
val editableFile = StorageUtils.createTempFile(context).apply {
try {
editXmpWithPixy(
@@ -978,7 +982,7 @@ abstract class ImageProvider {
path: String,
uri: Uri,
mimeType: String,
- trailerOffset: Int?,
+ trailerOffset: Number?,
editedFile: File,
callback: ImageOpCallback,
): Boolean {
@@ -993,7 +997,7 @@ abstract class ImageProvider {
LOG_TAG, "Edited file length=$expectedLength does not match final document file length=$actualLength. " +
"We need to edit XMP to adjust trailer video offset by $diff bytes."
)
- val newTrailerOffset = trailerOffset + diff
+ val newTrailerOffset = trailerOffset.toLong() + diff
return editXmp(context, path, uri, mimeType, callback, trailerDiff = diff, editCoreXmp = { xmp ->
GoogleXMP.updateTrailingVideoOffset(xmp, trailerOffset, newTrailerOffset)
})
@@ -1258,12 +1262,18 @@ abstract class ImageProvider {
callback: ImageOpCallback,
) {
val originalFileSize = File(path).length()
- val videoSize = MultiPage.getMotionPhotoOffset(context, uri, mimeType, originalFileSize)?.toInt()
+ val videoSize = MultiPage.getMotionPhotoVideoSize(context, uri, mimeType, originalFileSize)
if (videoSize == null) {
callback.onFailure(Exception("failed to get trailer video size"))
return
}
+ val isTrailerVideoValid = MultiPage.getTrailerVideoInfo(context, uri, fileSizeBytes = originalFileSize, videoSizeBytes = videoSize) != null
+ if (!isTrailerVideoValid) {
+ callback.onFailure(Exception("failed to open trailer video with size=$videoSize"))
+ return
+ }
+
val editableFile = StorageUtils.createTempFile(context).apply {
try {
val inputStream = StorageUtils.openInputStream(context, uri)
@@ -1303,7 +1313,8 @@ abstract class ImageProvider {
}
val originalFileSize = File(path).length()
- val videoSize = MultiPage.getMotionPhotoOffset(context, uri, mimeType, originalFileSize)?.toInt()
+ val videoSize = MultiPage.getMotionPhotoVideoSize(context, uri, mimeType, originalFileSize)
+ val isTrailerVideoValid = videoSize != null && MultiPage.getTrailerVideoInfo(context, uri, originalFileSize, videoSize) != null
val editableFile = StorageUtils.createTempFile(context).apply {
try {
outputStream().use { output ->
@@ -1323,7 +1334,7 @@ abstract class ImageProvider {
// copy the edited temporary file back to the original
editableFile.transferTo(outputStream(context, mimeType, uri, path))
- if (!types.contains(TYPE_XMP) && !checkTrailerOffset(context, path, uri, mimeType, videoSize, editableFile, callback)) {
+ if (!types.contains(TYPE_XMP) && isTrailerVideoValid && !checkTrailerOffset(context, path, uri, mimeType, videoSize, editableFile, callback)) {
return
}
editableFile.delete()