identify video location from Apple QuickTime metadata, and 3GPP loci atom

This commit is contained in:
Thibault Deckers 2025-02-09 19:47:35 +01:00
parent de0cfb1431
commit 8353064945
3 changed files with 62 additions and 16 deletions

View file

@ -7,6 +7,7 @@ All notable changes to this project will be documented in this file.
### Added ### Added
- support for Samsung HEIC motion photos embedding video in sefd box - support for Samsung HEIC motion photos embedding video in sefd box
- Cataloguing: identify video location from Apple QuickTime metadata, and 3GPP `loci` atom
### Fixed ### Fixed

View file

@ -24,6 +24,7 @@ import com.drew.metadata.exif.GpsDirectory
import com.drew.metadata.file.FileTypeDirectory import com.drew.metadata.file.FileTypeDirectory
import com.drew.metadata.gif.GifAnimationDirectory import com.drew.metadata.gif.GifAnimationDirectory
import com.drew.metadata.iptc.IptcDirectory import com.drew.metadata.iptc.IptcDirectory
import com.drew.metadata.mov.metadata.QuickTimeMetadataDirectory
import com.drew.metadata.mp4.media.Mp4UuidBoxDirectory import com.drew.metadata.mp4.media.Mp4UuidBoxDirectory
import com.drew.metadata.png.PngDirectory import com.drew.metadata.png.PngDirectory
import com.drew.metadata.webp.WebpDirectory import com.drew.metadata.webp.WebpDirectory
@ -102,6 +103,8 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.json.JSONObject import org.json.JSONObject
import org.mp4parser.boxes.threegpp.ts26244.LocationInformationBox
import org.mp4parser.tools.Path
import java.nio.charset.StandardCharsets import java.nio.charset.StandardCharsets
import java.text.DecimalFormat import java.text.DecimalFormat
import java.text.ParseException import java.text.ParseException
@ -450,9 +453,8 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
if (isVideo(mimeType)) { if (isVideo(mimeType)) {
// `metadata-extractor` do not extract custom tags in user data box // `metadata-extractor` do not extract custom tags in user data box
val userDataDir = Mp4ParserHelper.getUserData(context, mimeType, uri) Mp4ParserHelper.getUserDataBox(context, mimeType, uri)?.let { box ->
if (userDataDir.isNotEmpty()) { metadataMap[Metadata.DIR_MP4_USER_DATA] = Mp4ParserHelper.extractBoxFields(box)
metadataMap[Metadata.DIR_MP4_USER_DATA] = userDataDir
} }
// this is used as fallback when the video metadata cannot be found on the Dart side // this is used as fallback when the video metadata cannot be found on the Dart side
@ -544,8 +546,22 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
val metadataMap = HashMap<String, Any>() val metadataMap = HashMap<String, Any>()
getCatalogMetadataByMetadataExtractor(mimeType, uri, path, sizeBytes, metadataMap) getCatalogMetadataByMetadataExtractor(mimeType, uri, path, sizeBytes, metadataMap)
if (isVideo(mimeType) || isHeic(mimeType)) { if (isVideo(mimeType) || isHeic(mimeType)) {
getMultimediaCatalogMetadataByMediaMetadataRetriever(mimeType, uri, metadataMap) getMultimediaCatalogMetadataByMediaMetadataRetriever(mimeType, uri, metadataMap)
// fallback to MP4 `loci` box for location
if (!metadataMap.contains(KEY_LATITUDE) || !metadataMap.contains(KEY_LONGITUDE)) {
Mp4ParserHelper.getUserDataBox(context, mimeType, uri)?.let { userDataBox ->
Path.getPath<LocationInformationBox>(userDataBox, LocationInformationBox.TYPE)?.let { locationBox ->
if (!locationBox.isParsed) {
locationBox.parseDetails()
}
metadataMap[KEY_LATITUDE] = locationBox.latitude
metadataMap[KEY_LONGITUDE] = locationBox.longitude
}
}
}
} }
if (isHeic(mimeType)) { if (isHeic(mimeType)) {
@ -710,6 +726,22 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
} }
} }
if (!metadataMap.containsKey(KEY_LATITUDE) || !metadataMap.containsKey(KEY_LONGITUDE)) {
for (dir in metadata.getDirectoriesOfType(QuickTimeMetadataDirectory::class.java)) {
dir.getSafeString(QuickTimeMetadataDirectory.TAG_LOCATION_ISO6709) { locationString ->
val matcher = Metadata.VIDEO_LOCATION_PATTERN.matcher(locationString)
if (matcher.find() && matcher.groupCount() >= 2) {
val latitude = matcher.group(1)?.toDoubleOrNull()
val longitude = matcher.group(2)?.toDoubleOrNull()
if (latitude != null && longitude != null) {
metadataMap[KEY_LATITUDE] = latitude
metadataMap[KEY_LONGITUDE] = longitude
}
}
}
}
}
when (mimeType) { when (mimeType) {
MimeTypes.PNG -> { MimeTypes.PNG -> {
// date fallback to PNG time chunk // date fallback to PNG time chunk
@ -854,7 +886,7 @@ class MetadataFetchHandler(private val context: Context) : MethodCallHandler {
retriever.getSafeDateMillis(MediaMetadataRetriever.METADATA_KEY_DATE) { metadataMap[KEY_DATE_MILLIS] = it } retriever.getSafeDateMillis(MediaMetadataRetriever.METADATA_KEY_DATE) { metadataMap[KEY_DATE_MILLIS] = it }
} }
if (!metadataMap.containsKey(KEY_LATITUDE)) { if (!metadataMap.containsKey(KEY_LATITUDE) || !metadataMap.containsKey(KEY_LONGITUDE)) {
val locationString = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_LOCATION) val locationString = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_LOCATION)
if (locationString != null) { if (locationString != null) {
val matcher = Metadata.VIDEO_LOCATION_PATTERN.matcher(locationString) val matcher = Metadata.VIDEO_LOCATION_PATTERN.matcher(locationString)

View file

@ -32,6 +32,7 @@ import org.mp4parser.boxes.iso14496.part12.SegmentIndexBox
import org.mp4parser.boxes.iso14496.part12.TrackHeaderBox import org.mp4parser.boxes.iso14496.part12.TrackHeaderBox
import org.mp4parser.boxes.iso14496.part12.UserDataBox import org.mp4parser.boxes.iso14496.part12.UserDataBox
import org.mp4parser.boxes.threegpp.ts26244.AuthorBox import org.mp4parser.boxes.threegpp.ts26244.AuthorBox
import org.mp4parser.boxes.threegpp.ts26244.LocationInformationBox
import org.mp4parser.support.AbstractBox import org.mp4parser.support.AbstractBox
import org.mp4parser.support.Matrix import org.mp4parser.support.Matrix
import org.mp4parser.tools.Path import org.mp4parser.tools.Path
@ -143,6 +144,7 @@ object Mp4ParserHelper {
return false return false
} }
// returns the offset and data of the Samsung maker notes box
fun getSamsungSefd(context: Context, uri: Uri): Pair<Long, ByteArray>? { fun getSamsungSefd(context: Context, uri: Uri): Pair<Long, ByteArray>? {
try { try {
// we can skip uninteresting boxes with a seekable data source // we can skip uninteresting boxes with a seekable data source
@ -315,13 +317,13 @@ object Mp4ParserHelper {
} }
} }
fun getUserData( fun getUserDataBox(
context: Context, context: Context,
mimeType: String, mimeType: String,
uri: Uri, uri: Uri,
): MutableMap<String, String> { ): UserDataBox? {
val fields = HashMap<String, String>() if (mimeType != MimeTypes.MP4) return null
if (mimeType != MimeTypes.MP4) return fields
try { try {
// we can skip uninteresting boxes with a seekable data source // we can skip uninteresting boxes with a seekable data source
val pfd = StorageUtils.openInputFileDescriptor(context, uri) ?: throw Exception("failed to open file descriptor for uri=$uri") val pfd = StorageUtils.openInputFileDescriptor(context, uri) ?: throw Exception("failed to open file descriptor for uri=$uri")
@ -330,10 +332,7 @@ object Mp4ParserHelper {
stream.channel.use { channel -> stream.channel.use { channel ->
// creating `IsoFile` with a `File` or a `File.inputStream()` yields `No such device` // creating `IsoFile` with a `File` or a `File.inputStream()` yields `No such device`
IsoFile(channel, metadataBoxParser()).use { isoFile -> IsoFile(channel, metadataBoxParser()).use { isoFile ->
val userDataBox = Path.getPath<UserDataBox>(isoFile.movieBox, UserDataBox.TYPE) return Path.getPath(isoFile.movieBox, UserDataBox.TYPE)
if (userDataBox != null) {
fields.putAll(extractBoxFields(userDataBox))
}
} }
} }
} }
@ -343,10 +342,10 @@ object Mp4ParserHelper {
} catch (e: Exception) { } catch (e: Exception) {
Log.w(LOG_TAG, "failed to get User Data box by MP4 parser for mimeType=$mimeType uri=$uri", e) Log.w(LOG_TAG, "failed to get User Data box by MP4 parser for mimeType=$mimeType uri=$uri", e)
} }
return fields return null
} }
private fun extractBoxFields(container: Container): HashMap<String, String> { fun extractBoxFields(container: Container): HashMap<String, String> {
val fields = HashMap<String, String>() val fields = HashMap<String, String>()
for (box in container.boxes) { for (box in container.boxes) {
if (box is AbstractBox && !box.isParsed) { if (box is AbstractBox && !box.isParsed) {
@ -360,9 +359,20 @@ object Mp4ParserHelper {
is AppleGPSCoordinatesBox -> fields[key] = box.value is AppleGPSCoordinatesBox -> fields[key] = box.value
is AppleItemListBox -> fields.putAll(extractBoxFields(box)) is AppleItemListBox -> fields.putAll(extractBoxFields(box))
is AppleVariableSignedIntegerBox -> fields[key] = box.value.toString() is AppleVariableSignedIntegerBox -> fields[key] = box.value.toString()
is Utf8AppleDataBox -> fields[key] = box.value
is HandlerBox -> {} is HandlerBox -> {}
is LocationInformationBox -> {
hashMapOf<String, String>(
"Language" to box.language,
"Name" to box.name,
"Role" to box.role.toString(),
"Longitude" to box.longitude.toString(),
"Latitude" to box.latitude.toString(),
"Altitude" to box.altitude.toString(),
"Astronomical Body" to box.astronomicalBody,
"Additional Notes" to box.additionalNotes,
).forEach { (k, v) -> fields["$key/$k"] = v }
}
is MetaBox -> { is MetaBox -> {
val handlerBox = Path.getPath<HandlerBox>(box, HandlerBox.TYPE).apply { parseDetails() } val handlerBox = Path.getPath<HandlerBox>(box, HandlerBox.TYPE).apply { parseDetails() }
when (val handlerType = handlerBox?.handlerType ?: MetaBox.TYPE) { when (val handlerType = handlerBox?.handlerType ?: MetaBox.TYPE) {
@ -387,6 +397,8 @@ object Mp4ParserHelper {
} }
} }
is Utf8AppleDataBox -> fields[key] = box.value
else -> fields[key] = box.toString() else -> fields[key] = box.toString()
} }
} }
@ -399,6 +411,7 @@ object Mp4ParserHelper {
"catg" -> "Category" "catg" -> "Category"
"covr" -> "Cover Art" "covr" -> "Cover Art"
"keyw" -> "Keyword" "keyw" -> "Keyword"
"loci" -> "Location"
"mcvr" -> "Preview Image" "mcvr" -> "Preview Image"
"pcst" -> "Podcast" "pcst" -> "Podcast"
"SDLN" -> "Play Mode" "SDLN" -> "Play Mode"