This is an automated email from the ASF dual-hosted git repository.
asf-gitbox-commits pushed a commit to branch branch_3x
in repository https://gitbox.apache.org/repos/asf/tika.git
The following commit(s) were added to refs/heads/branch_3x by this push:
new 93ecf6c904 TIKA-2861: Extract QuickTime GPS location and item-list
metadata in MP4Parser (#2935)
93ecf6c904 is described below
commit 93ecf6c9047fadff51e9414e776b2c049fa6d36f
Author: Dominik Schmidt <[email protected]>
AuthorDate: Thu Jul 9 13:09:09 2026 +0200
TIKA-2861: Extract QuickTime GPS location and item-list metadata in
MP4Parser (#2935)
* [TIKA-2861] Expose QuickTime item-list metadata in MP4Parser
The MP4 handler descends into the moov/meta container but skips the keys
and ilst boxes, so the QuickTime metadata stored there (the
com.apple.quicktime.*
keys, e.g. the Apple Live Photo content identifier and the ISO 6709
location)
was dropped for QuickTime .mov files and any .mp4 carrying it.
Parse the keys/ilst boxes in TikaMp4BoxHandler and expose the UTF-8 text
values
under their key names. This is additive: the udta path and all existing
fields
are untouched.
* [TIKA-2861] Map QuickTime ISO 6709 location to geo:lat/long/alt
In addition to the raw com.apple.quicktime.location.ISO6709 value, parse it
into the standard geo:lat, geo:long and geo:alt properties. This matches the
geo:* output the udta ("(c)xyz") path already produces for other files, and
additionally exposes the altitude, which the udta path drops.
* [TIKA-2861] Expose the udta ISO 6709 altitude as geo:alt
The udta "(c)xyz" box carries an ISO 6709 string whose optional third
component is the altitude (ISO 6709 Annex H). Older iOS versions and
Google Photos write it with altitude, while Android writes lat/long only.
TikaUserDataBox only matched lat/long, so the altitude was dropped.
Capture the optional altitude and set geo:alt directly on the Tika
metadata: the drewnoakes Mp4Directory only defines latitude/longitude
tags, so it cannot carry the altitude to MP4Parser. Lat/long continue
to flow through the directory as before.
The new crafted fixture is the first test coverage for the udta
location path (no existing fixture contained a "(c)xyz" box).
* [TIKA-2861] Handle numeric QuickTime metadata value types
The item-list parser only emitted UTF-8 text values (well-known type 1)
and silently dropped the numeric types. Real iPhone Live Photo videos
carry several of those, e.g. com.apple.quicktime.live-photo.vitality-score
(float32), live-photo.auto (uint8) and
camera.focal_length.35mm_equivalent (int32).
Decode the QTFF well-known types 21 (signed int BE), 22 (unsigned int BE),
23 (float32) and 24 (float64) as well. Integers may be 1 to 8 bytes wide.
Other value types (images, binary plists) are still skipped.
---
.../apache/tika/parser/mp4/TikaMp4BoxHandler.java | 154 ++++++++++++++++++++-
.../tika/parser/mp4/boxes/TikaUserDataBox.java | 7 +-
.../org/apache/tika/parser/mp4/MP4ParserTest.java | 38 +++++
.../test-documents/testMP4_QuickTimeMetadata.mov | Bin 0 -> 611 bytes
.../test-documents/testMP4_udtaLocation.mp4 | Bin 0 -> 74 bytes
5 files changed, 197 insertions(+), 2 deletions(-)
diff --git
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/mp4/TikaMp4BoxHandler.java
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/mp4/TikaMp4BoxHandler.java
index ca399ab8b9..00f85b1037 100644
---
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/mp4/TikaMp4BoxHandler.java
+++
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/mp4/TikaMp4BoxHandler.java
@@ -17,6 +17,14 @@
package org.apache.tika.parser.mp4;
import java.io.IOException;
+import java.math.BigInteger;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
import com.drew.imaging.mp4.Mp4Handler;
import com.drew.lang.annotations.NotNull;
@@ -26,13 +34,31 @@ import com.drew.metadata.mp4.Mp4BoxHandler;
import com.drew.metadata.mp4.Mp4Context;
import org.xml.sax.SAXException;
+import org.apache.tika.metadata.TikaCoreProperties;
import org.apache.tika.parser.mp4.boxes.TikaUserDataBox;
import org.apache.tika.sax.XHTMLContentHandler;
public class TikaMp4BoxHandler extends Mp4BoxHandler {
+ //QTFF "well-known" metadata item value types
+ private static final int QT_TEXT_TYPE = 1;
+ private static final int QT_INT_BE_TYPE = 21;
+ private static final int QT_UINT_BE_TYPE = 22;
+ private static final int QT_FLOAT32_TYPE = 23;
+ private static final int QT_FLOAT64_TYPE = 24;
+
+ //QuickTime stores location as an ISO 6709 string (e.g.
+32.4720-084.9952+073.827/)
+ private static final String QT_LOCATION_ISO6709 =
"com.apple.quicktime.location.ISO6709";
+ private static final Pattern ISO6709_PATTERN =
+
Pattern.compile("([+-]\\d+(?:\\.\\d+)?)([+-]\\d+(?:\\.\\d+)?)([+-]\\d+(?:\\.\\d+)?)?");
+
org.apache.tika.metadata.Metadata tikaMetadata;
final XHTMLContentHandler xhtml;
+
+ //key names for the current 'meta' box, filled from its 'keys' box and
consumed
+ //by the following 'ilst' box (e.g. com.apple.quicktime.content.identifier)
+ private final List<String> quickTimeMetadataKeys = new ArrayList<>();
+
public TikaMp4BoxHandler(Metadata metadata,
org.apache.tika.metadata.Metadata tikaMetadata,
XHTMLContentHandler xhtml) {
super(metadata);
@@ -42,7 +68,7 @@ public class TikaMp4BoxHandler extends Mp4BoxHandler {
@Override
public boolean shouldAcceptBox(@NotNull String box) {
- if (box.equals("udta")) {
+ if (box.equals("udta") || box.equals("keys") || box.equals("ilst")) {
return true;
}
return super.shouldAcceptBox(box);
@@ -59,6 +85,12 @@ public class TikaMp4BoxHandler extends Mp4BoxHandler {
throws IOException {
if (box.equals("udta")) {
return processUserData(box, payload, context);
+ } else if (box.equals("keys")) {
+ processQuickTimeKeys(payload);
+ return this;
+ } else if (box.equals("ilst")) {
+ processQuickTimeItemList(payload);
+ return this;
}
return super.processBox(box, payload, size, context);
@@ -76,4 +108,124 @@ public class TikaMp4BoxHandler extends Mp4BoxHandler {
}
return this;
}
+
+ /**
+ * Parses the QuickTime metadata 'keys' box, which maps 1-based indices to
key
+ * names such as {@code com.apple.quicktime.content.identifier}. The base
MP4
+ * handler descends into the enclosing 'meta' container but skips
'keys'/'ilst',
+ * so this metadata (content identifier, ISO 6709 location, make/model,
...) was
+ * previously dropped for QuickTime .mov (and any .mp4 carrying it).
+ */
+ private void processQuickTimeKeys(@Nullable byte[] payload) {
+ quickTimeMetadataKeys.clear();
+ if (payload == null || payload.length < 8) {
+ return;
+ }
+ //1 byte version + 3 bytes flags, then uint32 entry count
+ int pos = 4;
+ long entryCount = readUInt32(payload, pos);
+ pos += 4;
+ for (long i = 0; i < entryCount && pos + 8 <= payload.length; i++) {
+ long keySize = readUInt32(payload, pos);
+ if (keySize < 8 || pos + keySize > payload.length) {
+ return;
+ }
+ //4 bytes key namespace, then the UTF-8 key name
+ quickTimeMetadataKeys.add(
+ new String(payload, pos + 8, (int) keySize - 8,
StandardCharsets.UTF_8));
+ pos += (int) keySize;
+ }
+ }
+
+ /**
+ * Parses the QuickTime metadata 'ilst' box, whose entries are keyed by the
+ * 1-based index into the preceding 'keys' box. Each entry holds a 'data'
box
+ * with the value. UTF-8 text and the numeric "well-known" value types are
emitted
+ * under their key name; other types (e.g. images, binary plists) are
skipped.
+ */
+ private void processQuickTimeItemList(@Nullable byte[] payload) {
+ if (payload == null) {
+ return;
+ }
+ int pos = 0;
+ while (pos + 8 <= payload.length) {
+ long entrySize = readUInt32(payload, pos);
+ if (entrySize < 8 || pos + entrySize > payload.length) {
+ return;
+ }
+ int index = (int) readUInt32(payload, pos + 4);
+ int entryEnd = (int) (pos + entrySize);
+ int data = pos + 8;
+ //inner 'data' box: size(4) type(4) valueType(4) locale(4) value
+ if (data + 16 <= entryEnd) {
+ long dataSize = readUInt32(payload, data);
+ boolean isData = payload[data + 4] == 'd' && payload[data + 5]
== 'a'
+ && payload[data + 6] == 't' && payload[data + 7] ==
'a';
+ if (isData && dataSize >= 16 && data + dataSize <= entryEnd) {
+ int valueType = (int) readUInt32(payload, data + 8);
+ int valueLength = (int) dataSize - 16;
+ if (index >= 1 && index <= quickTimeMetadataKeys.size()) {
+ String key = quickTimeMetadataKeys.get(index - 1);
+ String value = decodeValue(payload, data + 16,
valueLength, valueType);
+ if (value != null) {
+ tikaMetadata.add(key, value);
+ if (key.equals(QT_LOCATION_ISO6709)) {
+ addLocation(value);
+ }
+ }
+ }
+ }
+ }
+ pos += (int) entrySize;
+ }
+ }
+
+ /**
+ * Maps an ISO 6709 location string (latitude, longitude, optional
altitude) to the
+ * standard {@code geo:lat}/{@code geo:long}/{@code geo:alt} properties,
in addition to
+ * the raw value, so QuickTime location matches the {@code geo:*} output
of the udta path.
+ */
+ private void addLocation(String iso6709) {
+ Matcher matcher = ISO6709_PATTERN.matcher(iso6709);
+ if (matcher.find()) {
+ tikaMetadata.set(TikaCoreProperties.LATITUDE,
Double.parseDouble(matcher.group(1)));
+ tikaMetadata.set(TikaCoreProperties.LONGITUDE,
Double.parseDouble(matcher.group(2)));
+ if (matcher.group(3) != null) {
+ tikaMetadata.set(TikaCoreProperties.ALTITUDE,
Double.parseDouble(matcher.group(3)));
+ }
+ }
+ }
+
+ /**
+ * Decodes a metadata item value of one of the QTFF "well-known" types to
a string,
+ * or returns null for types that are not handled (e.g. images or binary
plists).
+ * Integers may be 1 to 8 bytes wide (e.g. the live-photo.auto flag is a
single byte).
+ */
+ @Nullable
+ private static String decodeValue(byte[] b, int off, int len, int
valueType) {
+ switch (valueType) {
+ case QT_TEXT_TYPE:
+ return new String(b, off, len, StandardCharsets.UTF_8);
+ case QT_INT_BE_TYPE:
+ case QT_UINT_BE_TYPE:
+ if (len < 1 || len > 8) {
+ return null;
+ }
+ byte[] intBytes = Arrays.copyOfRange(b, off, off + len);
+ return valueType == QT_INT_BE_TYPE
+ ? new BigInteger(intBytes).toString()
+ : new BigInteger(1, intBytes).toString();
+ case QT_FLOAT32_TYPE:
+ return len == 4 ? String.valueOf(ByteBuffer.wrap(b, off,
len).getFloat()) : null;
+ case QT_FLOAT64_TYPE:
+ return len == 8 ? String.valueOf(ByteBuffer.wrap(b, off,
len).getDouble()) : null;
+ default:
+ return null;
+ }
+ }
+
+ private static long readUInt32(byte[] b, int off) {
+ return ((b[off] & 0xFFL) << 24) | ((b[off + 1] & 0xFFL) << 16)
+ | ((b[off + 2] & 0xFFL) << 8) | (b[off + 3] & 0xFFL);
+ }
}
diff --git
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/mp4/boxes/TikaUserDataBox.java
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/mp4/boxes/TikaUserDataBox.java
index d75e1c1a3b..4fd264aed3 100644
---
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/mp4/boxes/TikaUserDataBox.java
+++
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/mp4/boxes/TikaUserDataBox.java
@@ -44,7 +44,7 @@ public class TikaUserDataBox {
private static final String HDLR = "hdlr";
private static final String MDIR = "mdir";//apple metadata itunes reader
private static final Pattern COORDINATE_PATTERN =
- Pattern.compile("([+-]\\d+\\.\\d+)([+-]\\d+\\.\\d+)");
+
Pattern.compile("([+-]\\d+\\.\\d+)([+-]\\d+\\.\\d+)([+-]\\d+(?:\\.\\d+)?)?");
@Nullable
private String coordinateString;
@@ -270,6 +270,11 @@ public class TikaUserDataBox {
double longitude = Double.parseDouble(matcher.group(2));
directory.setDouble(8193, latitude);
directory.setDouble(8194, longitude);
+ //Mp4Directory has no altitude tag, so set geo:alt directly
+ if (matcher.group(3) != null) {
+ metadata.set(TikaCoreProperties.ALTITUDE,
+ Double.parseDouble(matcher.group(3)));
+ }
}
}
}
diff --git
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/java/org/apache/tika/parser/mp4/MP4ParserTest.java
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/java/org/apache/tika/parser/mp4/MP4ParserTest.java
index ffda6bd815..f0e5b75300 100644
---
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/java/org/apache/tika/parser/mp4/MP4ParserTest.java
+++
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/java/org/apache/tika/parser/mp4/MP4ParserTest.java
@@ -281,4 +281,42 @@ public class MP4ParserTest extends TikaTest {
}
return vals;
} */
+
+ @Test
+ public void testQuickTimeMetadataKeys() throws Exception {
+ //QuickTime item-list metadata (moov/meta/keys+ilst, the
com.apple.quicktime.*
+ //keys such as the content identifier and ISO 6709 location) was
previously
+ //dropped by the MP4 handler. See TIKA-2861.
+ Metadata metadata = new Metadata();
+ getText("testMP4_QuickTimeMetadata.mov", metadata);
+ assertEquals("TEST-UUID-0001-LIVEPHOTO",
+ metadata.get("com.apple.quicktime.content.identifier"));
+
+ //the raw ISO 6709 location is preserved ...
+ assertEquals("+12.3456-098.7654+010.500/",
+ metadata.get("com.apple.quicktime.location.ISO6709"));
+ //... and also mapped to the standard geo:* properties (incl. altitude)
+ assertEquals(12.3456,
Double.parseDouble(metadata.get(TikaCoreProperties.LATITUDE)), 0.00001);
+ assertEquals(-98.7654,
Double.parseDouble(metadata.get(TikaCoreProperties.LONGITUDE)), 0.00001);
+ assertEquals(10.5,
Double.parseDouble(metadata.get(TikaCoreProperties.ALTITUDE)), 0.00001);
+
+ //numeric well-known value types (uint8, float32, int32, float64)
+ assertEquals("1", metadata.get("com.apple.quicktime.live-photo.auto"));
+ assertEquals("0.75",
metadata.get("com.apple.quicktime.live-photo.vitality-score"));
+ assertEquals("-13",
+
metadata.get("com.apple.quicktime.camera.focal_length.35mm_equivalent"));
+ assertEquals("1.5",
+
metadata.get("com.apple.quicktime.full-frame-rate-playback-intent"));
+ }
+
+ @Test
+ public void testUdtaLocation() throws Exception {
+ //the udta "(c)xyz" ISO 6709 location is mapped to geo:lat/geo:long,
and its
+ //optional altitude, which was previously dropped, to geo:alt. See
TIKA-2861.
+ Metadata metadata = new Metadata();
+ getText("testMP4_udtaLocation.mp4", metadata);
+ assertEquals(12.3456,
Double.parseDouble(metadata.get(TikaCoreProperties.LATITUDE)), 0.00001);
+ assertEquals(-98.7654,
Double.parseDouble(metadata.get(TikaCoreProperties.LONGITUDE)), 0.00001);
+ assertEquals(10.5,
Double.parseDouble(metadata.get(TikaCoreProperties.ALTITUDE)), 0.00001);
+ }
}
diff --git
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/resources/test-documents/testMP4_QuickTimeMetadata.mov
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/resources/test-documents/testMP4_QuickTimeMetadata.mov
new file mode 100644
index 0000000000..5e99deeb08
Binary files /dev/null and
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/resources/test-documents/testMP4_QuickTimeMetadata.mov
differ
diff --git
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/resources/test-documents/testMP4_udtaLocation.mp4
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/resources/test-documents/testMP4_udtaLocation.mp4
new file mode 100644
index 0000000000..c01bac1209
Binary files /dev/null and
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/resources/test-documents/testMP4_udtaLocation.mp4
differ