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

Reply via email to