This is an automated email from the ASF dual-hosted git repository.
tballison pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tika.git
The following commit(s) were added to refs/heads/main by this push:
new 9cad39e1d5 TIKA-4838: Expose the MP4 audio and video track codecs as
FourCCs (audio:fourcc, video:fourcc) (#3062)
9cad39e1d5 is described below
commit 9cad39e1d58423084d8d0bb0667d7459a2491e7e
Author: Dominik Schmidt <[email protected]>
AuthorDate: Thu Aug 27 12:55:59 2026 +0200
TIKA-4838: Expose the MP4 audio and video track codecs as FourCCs
(audio:fourcc, video:fourcc) (#3062)
---
.../main/java/org/apache/tika/metadata/Audio.java | 12 ++
.../main/java/org/apache/tika/metadata/Video.java | 12 ++
.../apache/tika/metadata/metadata-key-fields.json | 2 +
.../org/apache/tika/metadata/metadata-keys.json | 2 +
.../apache/tika/parser/mp4/Mp4SampleEntries.java | 174 +++++++++++++++++++++
.../tika/parser/mp4/TikaMp4SoundHandler.java | 76 +++++----
.../tika/parser/mp4/TikaMp4VideoHandler.java | 56 +++----
.../org/apache/tika/parser/mp4/MP4ParserTest.java | 47 +++++-
.../tika/parser/mp4/Mp4SampleEntriesTest.java | 172 ++++++++++++++++++++
.../resources/test-documents/testMP4VideoAudio.mp4 | Bin 0 -> 11065 bytes
.../resources/test-documents/testMP4_drm_frma.m4a | Bin 0 -> 664 bytes
.../test/resources/test-documents/testMP4_encv.mp4 | Bin 0 -> 1890 bytes
.../test-documents/testMP4_twoSampleEntries.m4a | Bin 0 -> 550 bytes
13 files changed, 480 insertions(+), 73 deletions(-)
diff --git a/tika-core/src/main/java/org/apache/tika/metadata/Audio.java
b/tika-core/src/main/java/org/apache/tika/metadata/Audio.java
index d3351dfa06..fe233815f7 100644
--- a/tika-core/src/main/java/org/apache/tika/metadata/Audio.java
+++ b/tika-core/src/main/java/org/apache/tika/metadata/Audio.java
@@ -84,6 +84,18 @@ public interface Audio {
*/
Property BITS_PER_SAMPLE =
Property.internalInteger("audio:bits-per-sample");
+ /**
+ * The audio track's codec as a four-character code (FourCC), as used by
+ * QuickTime/MP4 sample descriptions, AVI and other containers: e.g. "mp4a"
+ * for MPEG-4 audio (AAC), "alac", "ac-3". For protected MP4 streams, where
+ * {@link #HAS_DRM} is also set, this is the original codec named by the
+ * protection scheme info ('sinf'/'frma'), or the protected sample entry
format
+ * ("drms"/"enca") if there is none. Trailing padding spaces are trimmed
and
+ * non-printable codes are not exposed. A per-stream value: with several
audio
+ * tracks or sample entries it reflects the last one.
+ */
+ Property FOURCC = Property.internalText("audio:fourcc");
+
/**
* The raw javax.sound encoding name (e.g. "PCM_SIGNED"), as reported by
{@code
* AudioFormat#getEncoding()} (AudioParser). Distinct from {@link
XMPDM#AUDIO_SAMPLE_TYPE}
diff --git a/tika-core/src/main/java/org/apache/tika/metadata/Video.java
b/tika-core/src/main/java/org/apache/tika/metadata/Video.java
index 89143ec5d0..ce4864a70c 100644
--- a/tika-core/src/main/java/org/apache/tika/metadata/Video.java
+++ b/tika-core/src/main/java/org/apache/tika/metadata/Video.java
@@ -37,4 +37,16 @@ public interface Video {
* reflects the last one.
*/
Property BITRATE = Property.internalInteger("video:bitrate");
+
+ /**
+ * The video track's codec as a four-character code (FourCC), as used by
+ * QuickTime/MP4 sample descriptions, AVI and other containers: e.g. "avc1"
+ * for H.264, "hvc1"/"hev1" for HEVC. For protected MP4 streams this is the
+ * original codec named by the protection scheme info ('sinf'/'frma'), or
the
+ * protected sample entry format ("encv"/"drmi") if there is none. Trailing
+ * padding spaces are trimmed and non-printable codes are not exposed. A
+ * per-stream value: with several video tracks or sample entries it
reflects
+ * the last one.
+ */
+ Property FOURCC = Property.internalText("video:fourcc");
}
diff --git
a/tika-metadata-schema/src/main/resources/org/apache/tika/metadata/metadata-key-fields.json
b/tika-metadata-schema/src/main/resources/org/apache/tika/metadata/metadata-key-fields.json
index 3e22709d9b..68cf8d2828 100644
---
a/tika-metadata-schema/src/main/resources/org/apache/tika/metadata/metadata-key-fields.json
+++
b/tika-metadata-schema/src/main/resources/org/apache/tika/metadata/metadata-key-fields.json
@@ -13,6 +13,7 @@
{"class":"org.apache.tika.metadata.Audio","field":"CHANNELS","key":"audio:channels"},
{"class":"org.apache.tika.metadata.Audio","field":"DISC_COUNT","key":"audio:disc-count"},
{"class":"org.apache.tika.metadata.Audio","field":"ENCODING","key":"audio:encoding"},
+
{"class":"org.apache.tika.metadata.Audio","field":"FOURCC","key":"audio:fourcc"},
{"class":"org.apache.tika.metadata.Audio","field":"HAS_DRM","key":"audio:has-drm"},
{"class":"org.apache.tika.metadata.Audio","field":"IS_VARIABLE_BITRATE","key":"audio:is-variable-bitrate"},
{"class":"org.apache.tika.metadata.Audio","field":"RAW_DISC_NUMBER","key":"audio:raw-disc-number"},
@@ -578,6 +579,7 @@
{"class":"org.apache.tika.metadata.TikaPagedText","field":"PAGE_NUMBERS","key":"tk:page:numbers"},
{"class":"org.apache.tika.metadata.TikaPagedText","field":"PAGE_ROTATION","key":"tk:page:rotation"},
{"class":"org.apache.tika.metadata.Video","field":"BITRATE","key":"video:bitrate"},
+
{"class":"org.apache.tika.metadata.Video","field":"FOURCC","key":"video:fourcc"},
{"class":"org.apache.tika.metadata.Video","field":"FRAME_RATE","key":"video:frame-rate"},
{"class":"org.apache.tika.metadata.WARC","field":"WARC_PAYLOAD_CONTENT_TYPE","key":"warc:payload-content-type"},
{"class":"org.apache.tika.metadata.WARC","field":"WARC_RECORD_CONTENT_TYPE","key":"warc:record-content-type"},
diff --git
a/tika-metadata-schema/src/main/resources/org/apache/tika/metadata/metadata-keys.json
b/tika-metadata-schema/src/main/resources/org/apache/tika/metadata/metadata-keys.json
index 2faedf37f4..c2a916af37 100644
---
a/tika-metadata-schema/src/main/resources/org/apache/tika/metadata/metadata-keys.json
+++
b/tika-metadata-schema/src/main/resources/org/apache/tika/metadata/metadata-keys.json
@@ -77,6 +77,7 @@
{"key":"audio:channels","namespace":"audio","valueType":"INTEGER","cardinality":"SIMPLE","module":"tika-core"},
{"key":"audio:disc-count","namespace":"audio","valueType":"INTEGER","cardinality":"SIMPLE","module":"tika-core"},
{"key":"audio:encoding","namespace":"audio","valueType":"TEXT","cardinality":"SIMPLE","module":"tika-core"},
+
{"key":"audio:fourcc","namespace":"audio","valueType":"TEXT","cardinality":"SIMPLE","module":"tika-core"},
{"key":"audio:has-drm","namespace":"audio","valueType":"BOOLEAN","cardinality":"SIMPLE","module":"tika-core"},
{"key":"audio:is-variable-bitrate","namespace":"audio","valueType":"BOOLEAN","cardinality":"SIMPLE","module":"tika-core"},
{"key":"audio:raw-disc-number","namespace":"audio","valueType":"TEXT","cardinality":"SIMPLE","module":"tika-core"},
@@ -624,6 +625,7 @@
{"key":"tk:version-number","namespace":"tk","valueType":"INTEGER","cardinality":"SIMPLE","module":"tika-core"},
{"key":"tk:warn:truncated-metadata","namespace":"tk","valueType":"BOOLEAN","cardinality":"SIMPLE","module":"tika-core"},
{"key":"video:bitrate","namespace":"video","valueType":"INTEGER","cardinality":"SIMPLE","module":"tika-core"},
+
{"key":"video:fourcc","namespace":"video","valueType":"TEXT","cardinality":"SIMPLE","module":"tika-core"},
{"key":"video:frame-rate","namespace":"video","valueType":"REAL","cardinality":"SIMPLE","module":"tika-core"},
{"key":"vlm:completion-tokens","namespace":"vlm","valueType":"INTEGER","cardinality":"SIMPLE","module":"tika-vlm"},
{"key":"vlm:model","namespace":"vlm","valueType":"TEXT","cardinality":"SIMPLE","module":"tika-vlm"},
diff --git
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/mp4/Mp4SampleEntries.java
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/mp4/Mp4SampleEntries.java
new file mode 100644
index 0000000000..91bc9ff2fa
--- /dev/null
+++
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/mp4/Mp4SampleEntries.java
@@ -0,0 +1,174 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tika.parser.mp4;
+
+import java.nio.charset.StandardCharsets;
+
+import org.apache.tika.io.EndianUtils;
+
+/**
+ * Walks the sample entries of a SampleDescriptionBox ('stsd') payload, shared
+ * by the sound and video handlers. The payload is 4 bytes version and flags,
+ * a 4 byte entry count, then one sample entry per count. Each entry is a box:
+ * a 32-bit size and a FourCC, where a size of 1 announces a 64-bit largesize
+ * and a size of 0 extends the entry to the end of the payload (ISO/IEC
+ * 14496-12, 4.2).
+ */
+final class Mp4SampleEntries {
+
+ /**
+ * Size of the SampleEntry fields that follow the box header in every
+ * entry: 6 reserved bytes and the 2 byte data reference index.
+ */
+ static final int SAMPLE_ENTRY_FIELDS = 8;
+
+ interface Visitor {
+ /**
+ * @param fourCC the entry's FourCC, or null if it is not printable
+ * @param b the stsd payload
+ * @param start offset of the first byte after the entry's box header
+ * @param end offset one past the entry's last byte
+ */
+ void entry(String fourCC, byte[] b, int start, int end);
+ }
+
+ private Mp4SampleEntries() {
+ }
+
+ static void walk(byte[] b, Visitor visitor) {
+ if (b.length < 8) {
+ return;
+ }
+ long entryCount = EndianUtils.getUIntBE(b, 4);
+ int pos = 8;
+ for (long i = 0; i < entryCount && pos + 8 <= b.length; i++) {
+ long size = EndianUtils.getUIntBE(b, pos);
+ int header = 8;
+ if (size == 1) {
+ //largesize: the 64-bit size follows the FourCC
+ if (pos + 16 > b.length) {
+ return;
+ }
+ //a value beyond 63 bits goes negative and fails the size
check below
+ size = (EndianUtils.getUIntBE(b, pos + 8) << 32) |
EndianUtils.getUIntBE(b, pos + 12);
+ header = 16;
+ } else if (size == 0) {
+ //the entry extends to the end of the box
+ size = b.length - pos;
+ }
+ if (size < header + SAMPLE_ENTRY_FIELDS || size > b.length - pos) {
+ return;
+ }
+ int end = pos + (int) size;
+ visitor.entry(printableFourCC(b, pos + 4), b, pos + header, end);
+ pos = end;
+ }
+ }
+
+ /**
+ * Returns whether a sample entry FourCC marks a protected stream: 'drms'
+ * and 'drmi' (FairPlay) or 'enca' and 'encv' (ISO common encryption).
+ */
+ static boolean isProtected(String fourCC) {
+ return "drms".equals(fourCC) || "enca".equals(fourCC)
+ || "encv".equals(fourCC) || "drmi".equals(fourCC);
+ }
+
+ /**
+ * Looks up the original (unprotected) format of a protected sample entry:
+ * the ProtectionSchemeInfoBox 'sinf' among the entry's child boxes carries
+ * an OriginalFormatBox 'frma' whose payload is the codec FourCC that the
+ * protected format replaced (ISO/IEC 14496-12, 8.12). Returns null if
+ * there is none or it is not printable.
+ *
+ * @param pos offset of the entry's first child box
+ * @param end offset one past the entry's last byte
+ */
+ static String originalFormat(byte[] b, int pos, int end) {
+ int sinf = findBox(b, pos, end, "sinf");
+ if (sinf < 0) {
+ return null;
+ }
+ int sinfEnd = boxEnd(b, sinf, end);
+ int frma = findBox(b, sinf + 8, sinfEnd, "frma");
+ if (frma < 0 || boxEnd(b, frma, sinfEnd) < frma + 12) {
+ //the box must hold its 4 byte payload, not borrow it from the
next box
+ return null;
+ }
+ return printableFourCC(b, frma + 8);
+ }
+
+ /**
+ * Returns the offset of the first box of the given type among the boxes
+ * in [pos, end), or -1.
+ */
+ private static int findBox(byte[] b, int pos, int end, String type) {
+ while (pos >= 0 && pos + 8 <= end) {
+ int boxEnd = boxEnd(b, pos, end);
+ if (boxEnd < 0) {
+ return -1;
+ }
+ if (type.equals(fourCC(b, pos + 4))) {
+ return pos;
+ }
+ pos = boxEnd;
+ }
+ return -1;
+ }
+
+ /**
+ * Returns the offset one past the box starting at pos, or -1 if its size
+ * is invalid or runs past end.
+ */
+ private static int boxEnd(byte[] b, int pos, int end) {
+ long size = EndianUtils.getUIntBE(b, pos);
+ if (size < 8 || size > end - pos) {
+ return -1;
+ }
+ return pos + (int) size;
+ }
+
+ /**
+ * Reads a FourCC as it is, for comparing against known box types.
+ */
+ static String fourCC(byte[] b, int pos) {
+ return new String(b, pos, 4, StandardCharsets.ISO_8859_1);
+ }
+
+ /**
+ * Reads a FourCC for exposing it as a metadata value: null unless all four
+ * bytes are printable ASCII, with trailing spaces trimmed (QuickTime pads
+ * short codes such as 'raw ' and 'rle ' with spaces). Codes that are blank
+ * after trimming are null as well.
+ */
+ static String printableFourCC(byte[] b, int pos) {
+ int len = 4;
+ while (len > 0 && b[pos + len - 1] == ' ') {
+ len--;
+ }
+ if (len == 0) {
+ return null;
+ }
+ for (int i = 0; i < len; i++) {
+ int c = b[pos + i] & 0xFF;
+ if (c < 0x20 || c > 0x7E) {
+ return null;
+ }
+ }
+ return new String(b, pos, len, StandardCharsets.US_ASCII);
+ }
+}
diff --git
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/mp4/TikaMp4SoundHandler.java
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/mp4/TikaMp4SoundHandler.java
index 96b5ba24dd..b8bdc87458 100644
---
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/mp4/TikaMp4SoundHandler.java
+++
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/mp4/TikaMp4SoundHandler.java
@@ -17,7 +17,6 @@
package org.apache.tika.parser.mp4;
import java.io.IOException;
-import java.nio.charset.StandardCharsets;
import com.drew.imaging.mp4.Mp4Handler;
import com.drew.metadata.Metadata;
@@ -52,55 +51,54 @@ class TikaMp4SoundHandler extends Mp4SoundHandler {
return super.processBox(type, payload, boxSize, context);
}
- /**
- * Walks the sample description entries: 4 bytes version and flags, a
- * 4 byte entry count, then one sample entry per count, each starting with
- * its own size and format fourcc.
- */
private void extractFromSampleDescriptions(byte[] b) {
- if (b.length < 8) {
- return;
+ Mp4SampleEntries.walk(b, this::sampleEntry);
+ }
+
+ private void sampleEntry(String fourCC, byte[] b, int start, int end) {
+ int children = -1;
+ if (start + Mp4SampleEntries.SAMPLE_ENTRY_FIELDS + 2 <= end) {
+ //after the SampleEntry fields come the version-dependent fixed
+ //sound fields, starting with the 2 byte version, then child boxes
+ int version = EndianUtils.getUShortBE(b, start +
Mp4SampleEntries.SAMPLE_ENTRY_FIELDS);
+ children = start + soundEntrySize(version);
}
- long entryCount = EndianUtils.getUIntBE(b, 4);
- int pos = 8;
- for (long i = 0; i < entryCount && pos + 8 <= b.length; i++) {
- long size = EndianUtils.getUIntBE(b, pos);
- if (size < 16 || size > b.length - pos) {
- break;
- }
- int end = pos + (int) size;
- String format = fourCc(b, pos + 4);
- //protected streams replace the codec fourcc with a protected
- //entry format: 'drms' (FairPlay) or 'enca' (ISO common encryption)
- if ("drms".equals(format) || "enca".equals(format)) {
- tikaMetadata.set(Audio.HAS_DRM, true);
- }
- if (pos + 18 <= end) {
- //sample entry: 8 byte header, 6 reserved, 2 data ref index,
- //then version-dependent fixed sound fields before child boxes
- int version = EndianUtils.getUShortBE(b, pos + 16);
- int bitRate = findEsdsAverageBitRate(b, pos +
soundEntrySize(version), end, 0);
- if (bitRate > 0) {
- tikaMetadata.set(Audio.BITRATE, bitRate);
+ //protected streams replace the codec FourCC with a protected sample
+ //entry format, 'drms' (FairPlay) or 'enca' (ISO common encryption),
+ //and keep the original one in a child 'sinf'/'frma' box
+ if (Mp4SampleEntries.isProtected(fourCC)) {
+ tikaMetadata.set(Audio.HAS_DRM, true);
+ if (children >= 0) {
+ String original = Mp4SampleEntries.originalFormat(b, children,
end);
+ if (original != null) {
+ fourCC = original;
}
}
- pos = end;
+ }
+ if (fourCC != null) {
+ tikaMetadata.set(Audio.FOURCC, fourCC);
+ }
+ if (children >= 0) {
+ int bitRate = findEsdsAverageBitRate(b, children, end, 0);
+ if (bitRate > 0) {
+ tikaMetadata.set(Audio.BITRATE, bitRate);
+ }
}
}
/**
- * Size of the fixed part of a sound sample entry, after which the child
- * boxes start: 36 bytes for version 0, 52 for version 1 (four extra
- * 32-bit QuickTime fields), 72 for version 2.
+ * Size of a sound sample entry after its box header, up to where the
+ * child boxes start: 28 bytes for version 0, 44 for version 1 (four extra
+ * 32-bit QuickTime fields), 64 for version 2.
*/
private static int soundEntrySize(int version) {
if (version == 1) {
- return 52;
+ return 44;
}
if (version == 2) {
- return 72;
+ return 64;
}
- return 36;
+ return 28;
}
//real files nest 'wave' at most one level; this only bounds crafted input,
@@ -123,7 +121,7 @@ class TikaMp4SoundHandler extends Mp4SoundHandler {
if (size < 8 || size > end - pos) {
return 0;
}
- String type = fourCc(b, pos + 4);
+ String type = Mp4SampleEntries.fourCC(b, pos + 4);
if ("esds".equals(type)) {
return readEsdsAverageBitRate(b, pos + 8, pos + (int) size);
}
@@ -195,8 +193,4 @@ class TikaMp4SoundHandler extends Mp4SoundHandler {
}
return pos + 1;
}
-
- private static String fourCc(byte[] b, int pos) {
- return new String(b, pos, 4, StandardCharsets.ISO_8859_1);
- }
}
diff --git
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/mp4/TikaMp4VideoHandler.java
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/mp4/TikaMp4VideoHandler.java
index 142dfa18bc..247f41c912 100644
---
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/mp4/TikaMp4VideoHandler.java
+++
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/mp4/TikaMp4VideoHandler.java
@@ -17,7 +17,6 @@
package org.apache.tika.parser.mp4;
import java.io.IOException;
-import java.nio.charset.StandardCharsets;
import com.drew.imaging.mp4.Mp4Handler;
import com.drew.metadata.Metadata;
@@ -35,12 +34,12 @@ import org.apache.tika.metadata.Video;
class TikaMp4VideoHandler extends Mp4VideoHandler {
/**
- * Fixed size of a VisualSampleEntry (ISO/IEC 14496-12) before its child
- * boxes: the 8 byte box header, 8 bytes of SampleEntry (6 reserved, 2 data
- * reference index) and 70 bytes of visual fields ending with the 32 byte
- * compressor name, the depth and a pre-defined field.
+ * Fixed size of a VisualSampleEntry (ISO/IEC 14496-12) after its box
+ * header, up to where the child boxes start: 8 bytes of SampleEntry (6
+ * reserved, 2 data reference index) and 70 bytes of visual fields ending
+ * with the 32 byte compressor name, the depth and a pre-defined field.
*/
- private static final int VISUAL_ENTRY_SIZE = 86;
+ private static final int VISUAL_ENTRY_SIZE = 78;
private final org.apache.tika.metadata.Metadata tikaMetadata;
@@ -59,28 +58,27 @@ class TikaMp4VideoHandler extends Mp4VideoHandler {
return super.processBox(type, payload, boxSize, context);
}
- /**
- * Walks the sample description entries: 4 bytes version and flags, a
- * 4 byte entry count, then one sample entry per count, each starting with
- * its own size and format fourcc.
- */
private void extractFromSampleDescriptions(byte[] b) {
- if (b.length < 8) {
- return;
- }
- long entryCount = EndianUtils.getUIntBE(b, 4);
- int pos = 8;
- for (long i = 0; i < entryCount && pos + 8 <= b.length; i++) {
- long size = EndianUtils.getUIntBE(b, pos);
- if (size < 16 || size > b.length - pos) {
- break;
- }
- int end = pos + (int) size;
- int bitRate = findBtrtAverageBitRate(b, pos + VISUAL_ENTRY_SIZE,
end);
- if (bitRate > 0) {
- tikaMetadata.set(Video.BITRATE, bitRate);
+ Mp4SampleEntries.walk(b, this::sampleEntry);
+ }
+
+ private void sampleEntry(String fourCC, byte[] b, int start, int end) {
+ int children = start + VISUAL_ENTRY_SIZE;
+ //the FourCC is the video codec ('avc1', 'hev1', ...) or, for protected
+ //streams, the protected sample entry format ('encv'/'drmi') with the
+ //original one kept in a child 'sinf'/'frma' box
+ if (Mp4SampleEntries.isProtected(fourCC)) {
+ String original = Mp4SampleEntries.originalFormat(b, children,
end);
+ if (original != null) {
+ fourCC = original;
}
- pos = end;
+ }
+ if (fourCC != null) {
+ tikaMetadata.set(Video.FOURCC, fourCC);
+ }
+ int bitRate = findBtrtAverageBitRate(b, children, end);
+ if (bitRate > 0) {
+ tikaMetadata.set(Video.BITRATE, bitRate);
}
}
@@ -95,7 +93,7 @@ class TikaMp4VideoHandler extends Mp4VideoHandler {
if (size < 8 || size > end - pos) {
return 0;
}
- if ("btrt".equals(fourCc(b, pos + 4)) && pos + 20 <= end) {
+ if ("btrt".equals(Mp4SampleEntries.fourCC(b, pos + 4)) && pos + 20
<= end) {
long averageBitRate = EndianUtils.getUIntBE(b, pos + 16);
return averageBitRate > 0 && averageBitRate <=
Integer.MAX_VALUE
? (int) averageBitRate : 0;
@@ -104,8 +102,4 @@ class TikaMp4VideoHandler extends Mp4VideoHandler {
}
return 0;
}
-
- private static String fourCc(byte[] b, int pos) {
- return new String(b, pos, 4, StandardCharsets.ISO_8859_1);
- }
}
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 ddc619d398..ef8d1f9c33 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
@@ -130,6 +130,7 @@ public class MP4ParserTest extends TikaTest {
assertEquals("Stereo", metadata.get(XMPDM.AUDIO_CHANNEL_TYPE));
assertEquals("2", metadata.get(Audio.CHANNELS));
assertEquals("16", metadata.get(Audio.BITS_PER_SAMPLE));
+ assertEquals("mp4a", metadata.get(Audio.FOURCC));
assertEquals("M4A", metadata.get(XMPDM.AUDIO_COMPRESSOR));
assertEquals("0.07", metadata.get(XMPDM.DURATION));
@@ -197,6 +198,16 @@ public class MP4ParserTest extends TikaTest {
assertEquals("video/mp4", r.metadata.get(HttpHeaders.CONTENT_TYPE));
assertEquals("10.0", r.metadata.get(Video.FRAME_RATE));
assertEquals("6536", r.metadata.get(Video.BITRATE));
+ assertEquals("avc1", r.metadata.get(Video.FOURCC));
+ }
+
+ @Test
+ public void testVideoAudioTrackCodecs() throws Exception {
+ //a 1s clip with an H.264 video track and an AAC audio track
+ XMLResult r = getXML("testMP4VideoAudio.mp4");
+ assertEquals("video/mp4", r.metadata.get(HttpHeaders.CONTENT_TYPE));
+ assertEquals("avc1", r.metadata.get(Video.FOURCC));
+ assertEquals("mp4a", r.metadata.get(Audio.FOURCC));
}
@Test
@@ -352,10 +363,44 @@ public class MP4ParserTest extends TikaTest {
@Test
public void testDrmProtectedM4a() throws Exception {
- //the sample description declares a protected 'drms' sample entry
+ //a bare protected 'drms' sample entry without a 'sinf' box: has-drm is
+ //set and, with no original format to recover, the fourcc is the
+ //protected sample entry format itself
Metadata metadata = new Metadata();
getText("testMP4_drm.m4a", metadata);
assertEquals("true", metadata.get(Audio.HAS_DRM));
+ assertEquals("drms", metadata.get(Audio.FOURCC));
+ }
+
+ @Test
+ public void testDrmProtectedM4aWithOriginalFormat() throws Exception {
+ //a FairPlay 'drms' entry as iTunes writes it: the AAC 'esds' plus a
+ //'sinf' whose 'frma' names the original 'mp4a' (ISO/IEC 14496-12,
8.12)
+ Metadata metadata = new Metadata();
+ getText("testMP4_drm_frma.m4a", metadata);
+ assertEquals("true", metadata.get(Audio.HAS_DRM));
+ assertEquals("mp4a", metadata.get(Audio.FOURCC));
+ assertEquals("128000", metadata.get(Audio.BITRATE));
+ }
+
+ @Test
+ public void testCommonEncryptionVideo() throws Exception {
+ //testMP4Video.mp4 with its 'avc1' entry turned into an 'encv' one, the
+ //'sinf' (frma avc1, schm cenc, schi/tenc) inserted before 'btrt'
+ XMLResult r = getXML("testMP4_encv.mp4");
+ assertEquals("avc1", r.metadata.get(Video.FOURCC));
+ assertEquals("6536", r.metadata.get(Video.BITRATE));
+ }
+
+ @Test
+ public void testLastSampleEntryWins() throws Exception {
+ //a track with two sample entries, AAC then AC-3: like the bitrate, the
+ //fourcc is a per-stream value and reflects the last entry
+ Metadata metadata = new Metadata();
+ getText("testMP4_twoSampleEntries.m4a", metadata);
+ assertEquals("ac-3", metadata.get(Audio.FOURCC));
+ assertEquals("128000", metadata.get(Audio.BITRATE));
+ assertNull(metadata.get(Audio.HAS_DRM));
}
@Test
diff --git
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/java/org/apache/tika/parser/mp4/Mp4SampleEntriesTest.java
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/java/org/apache/tika/parser/mp4/Mp4SampleEntriesTest.java
new file mode 100644
index 0000000000..9b3a249992
--- /dev/null
+++
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/java/org/apache/tika/parser/mp4/Mp4SampleEntriesTest.java
@@ -0,0 +1,172 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tika.parser.mp4;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+import java.io.ByteArrayOutputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+
+public class Mp4SampleEntriesTest {
+
+ @Test
+ public void testClassicEntries() {
+ byte[] stsd = stsd(entry(24, "mp4a", 16), entry(24, "ac-3", 16));
+ List<String> seen = walk(stsd);
+ assertEquals(List.of("mp4a:16:32", "ac-3:40:56"), seen);
+ }
+
+ @Test
+ public void testLargesizeEntry() {
+ //size 1 announces a 64-bit size after the FourCC; the visitor's start
+ //must skip the 16 byte header
+ byte[] stsd = stsd(largeEntry(32, "avc1", 16), entry(24, "mp4a", 16));
+ assertEquals(List.of("avc1:24:40", "mp4a:48:64"), walk(stsd));
+ }
+
+ @Test
+ public void testZeroSizeEntryExtendsToEnd() {
+ byte[] stsd = stsd(entry(0, "hvc1", 16));
+ assertEquals(List.of("hvc1:16:32"), walk(stsd));
+ }
+
+ @Test
+ public void testTruncatedAndUndersizedEntriesStopTheWalk() {
+ //an entry claiming more bytes than the payload has
+ assertEquals(List.of(), walk(stsd(entry(40, "mp4a", 16))));
+ //an entry too small to hold the SampleEntry fields
+ assertEquals(List.of(), walk(stsd(entry(12, "mp4a", 4))));
+ //a largesize header cut off before the 64-bit size
+ byte[] cut = stsd(largeEntry(32, "avc1", 16));
+ byte[] truncated = new byte[8 + 12];
+ System.arraycopy(cut, 0, truncated, 0, truncated.length);
+ assertEquals(List.of(), walk(truncated));
+ //a largesize beyond 63 bits
+ byte[] huge = stsd(largeEntry(32, "avc1", 16));
+ huge[8 + 8] = (byte) 0xFF;
+ assertEquals(List.of(), walk(huge));
+ }
+
+ @Test
+ public void testPrintableFourCC() {
+ assertEquals("mp4a", Mp4SampleEntries.printableFourCC(ascii("mp4a"),
0));
+ //QuickTime pads short codes with spaces
+ assertEquals("raw", Mp4SampleEntries.printableFourCC(ascii("raw "),
0));
+ assertEquals("rle", Mp4SampleEntries.printableFourCC(ascii("rle "),
0));
+ assertNull(Mp4SampleEntries.printableFourCC(ascii(" "), 0));
+ assertNull(Mp4SampleEntries.printableFourCC(new byte[]{0, 1, 2, 3},
0));
+ assertNull(Mp4SampleEntries.printableFourCC(new byte[]{'a', 'v', 'c',
0x7F}, 0));
+ assertNull(Mp4SampleEntries.printableFourCC(new byte[]{(byte) 0xE4,
'v', 'c', '1'}, 0));
+ //an unprintable FourCC reaches the visitor as null but does not stop
the walk
+ byte[] stsd = stsd(entry(24, "\u0001vc1", 16), entry(24, "mp4a", 16));
+ assertEquals(List.of("null:16:32", "mp4a:40:56"), walk(stsd));
+ }
+
+ @Test
+ public void testOriginalFormat() {
+ byte[] sinf = boxOf("sinf", boxOf("frma", ascii("mp4a")),
+ boxOf("schm", new byte[]{0, 0, 0, 0, 'i', 't', 'u', 'n', 0, 1,
0, 0}));
+ byte[] children = concat(boxOf("esds", new byte[4]), sinf,
boxOf("btrt", new byte[12]));
+ assertEquals("mp4a", Mp4SampleEntries.originalFormat(children, 0,
children.length));
+ //no sinf
+ byte[] plain = boxOf("esds", new byte[4]);
+ assertNull(Mp4SampleEntries.originalFormat(plain, 0, plain.length));
+ //sinf without frma
+ byte[] noFrma = boxOf("sinf", boxOf("schi", new byte[0]));
+ assertNull(Mp4SampleEntries.originalFormat(noFrma, 0, noFrma.length));
+ //frma with an unprintable format
+ byte[] bad = boxOf("sinf", boxOf("frma", new byte[]{0, 0, 0, 0}));
+ assertNull(Mp4SampleEntries.originalFormat(bad, 0, bad.length));
+ //a child box claiming to run past the entry stops the scan
+ byte[] truncated = concat(boxOf("esds", new byte[4]), sinf);
+ putInt(truncated, 0, 1000);
+ assertNull(Mp4SampleEntries.originalFormat(truncated, 0,
truncated.length));
+ //frma cut off before its payload
+ byte[] cut = new byte[16];
+ System.arraycopy(sinf, 0, cut, 0, 16);
+ putInt(cut, 0, 16);
+ assertNull(Mp4SampleEntries.originalFormat(cut, 0, cut.length));
+ //a header-only frma (size 8) must not read its format from the next
box
+ byte[] shortFrma = boxOf("sinf", boxOf("frma"), boxOf("mp4a", new
byte[0]));
+ assertNull(Mp4SampleEntries.originalFormat(shortFrma, 0,
shortFrma.length));
+ }
+
+ private static byte[] boxOf(String type, byte[]... payloads) {
+ byte[] payload = concat(payloads);
+ byte[] b = new byte[8 + payload.length];
+ putInt(b, 0, b.length);
+ System.arraycopy(ascii(type), 0, b, 4, 4);
+ System.arraycopy(payload, 0, b, 8, payload.length);
+ return b;
+ }
+
+ private static byte[] concat(byte[]... parts) {
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ for (byte[] part : parts) {
+ out.write(part, 0, part.length);
+ }
+ return out.toByteArray();
+ }
+
+ private static List<String> walk(byte[] stsd) {
+ List<String> seen = new ArrayList<>();
+ Mp4SampleEntries.walk(stsd, (fourCC, b, start, end) ->
+ seen.add(fourCC + ":" + start + ":" + end));
+ return seen;
+ }
+
+ private static byte[] stsd(byte[]... entries) {
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ out.write(new byte[]{0, 0, 0, 0, 0, 0, 0, (byte) entries.length}, 0,
8);
+ for (byte[] entry : entries) {
+ out.write(entry, 0, entry.length);
+ }
+ return out.toByteArray();
+ }
+
+ private static byte[] entry(int size, String fourCC, int bodyLength) {
+ byte[] b = new byte[8 + bodyLength];
+ putInt(b, 0, size);
+ System.arraycopy(ascii(fourCC), 0, b, 4, 4);
+ return b;
+ }
+
+ private static byte[] largeEntry(long size, String fourCC, int bodyLength)
{
+ byte[] b = new byte[16 + bodyLength];
+ putInt(b, 0, 1);
+ System.arraycopy(ascii(fourCC), 0, b, 4, 4);
+ putInt(b, 8, (int) (size >>> 32));
+ putInt(b, 12, (int) size);
+ return b;
+ }
+
+ private static void putInt(byte[] b, int pos, int v) {
+ b[pos] = (byte) (v >>> 24);
+ b[pos + 1] = (byte) (v >>> 16);
+ b[pos + 2] = (byte) (v >>> 8);
+ b[pos + 3] = (byte) v;
+ }
+
+ private static byte[] ascii(String s) {
+ return s.getBytes(StandardCharsets.ISO_8859_1);
+ }
+}
diff --git
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/resources/test-documents/testMP4VideoAudio.mp4
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/resources/test-documents/testMP4VideoAudio.mp4
new file mode 100644
index 0000000000..2f27ba67f4
Binary files /dev/null and
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/resources/test-documents/testMP4VideoAudio.mp4
differ
diff --git
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/resources/test-documents/testMP4_drm_frma.m4a
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/resources/test-documents/testMP4_drm_frma.m4a
new file mode 100644
index 0000000000..b2211d3cb3
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_drm_frma.m4a
differ
diff --git
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/resources/test-documents/testMP4_encv.mp4
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/resources/test-documents/testMP4_encv.mp4
new file mode 100644
index 0000000000..c20754d830
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_encv.mp4
differ
diff --git
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/resources/test-documents/testMP4_twoSampleEntries.m4a
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/resources/test-documents/testMP4_twoSampleEntries.m4a
new file mode 100644
index 0000000000..c5afdc642f
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_twoSampleEntries.m4a
differ