Skip to content

Commit bb79091

Browse files
authored
TIKA-4777: Expose presentation start of delayed QuickTime timed metadata tracks (#2936)
* [TIKA-4777] Expose presentation start of delayed QuickTime timed metadata tracks QuickTime timed metadata tracks (mebx sample descriptions, ISO 14496-12 boxed metadata) declare their key names in the file, and the track's presentation start is defined by its edit list: a leading empty edit delays the media by its duration. Parse the mebx key declarations (keys/keyd) and emit, for every declared key of a delayed track, the presentation start in microseconds as <key name>.track-start-us. Undelayed tracks (start 0) are not reported, their start carries no information. This is deliberately generic, no key names are hardcoded. The main use case are Apple Live Photos, which mark the moment the paired still image was captured as the single one-tick sample of the still-image-time track, shifted to that moment by an empty edit. The sample value itself is a constant -1 marker, so the moov boxes alone are sufficient and mdat is never touched. The handler stays active for timed metadata tracks (handler type 'meta') instead of switching to the extraction-free Mp4MetaHandler, which would keep it from seeing the track's stsd. Verified against a real Live Photo video (iPhone 15 Pro, iOS 18.5): com.apple.quicktime.still-image-time.track-start-us = 1233333 (1.2333s, matching the empty edit of 740/600s), likewise the transform keys of the same track; the undelayed video-orientation and live-photo-info tracks are not reported. * [TIKA-4777] Address review feedback: reuse Mp4MetaHandler, fix edit list handling Architecture: instead of suppressing the base handler's Mp4MetaHandler and accepting minf/stbl/stsd globally (which also slurped the sample descriptions of unrelated track types), return a TikaMp4MetaHandler subclass from the hdlr branch. The base Mp4MediaHandler already routes the track's stsd and stts; the subclass fills in the empty processSampleDescription/processTimeToSample overrides. This also keeps Mp4MetaDirectory alive. The redundant processMovieTimescale is replaced by the movie timescale the base handler already stores (Mp4Directory.TAG_TIME_SCALE). Edit list correctness: only leading empty edits delay a track, so the scan stops at the first normal entry and sums consecutive leading empty edits instead of picking the first -1 entry anywhere. The per-track state is now reset when the trak container starts rather than at tkhd, making it independent of box order, and the microsecond conversion is guarded against overflow. Sample count invariant: the track start is only the time of the still sample because the track has exactly one sample, so stts is now read and tracks with more than one sample are not reported (previously the delay filter only correlated with this). Tests: the crafted fixture now has realistic track plumbing (gmhd, dhlr/alis data handler, dinf/dref, full stsd/stts/stsc/stsz/stco) and three additional timed metadata tracks covering a version-1 edit list, a non-leading empty edit directly after the delayed track (state leak probe), and a delayed multi-sample track. Emission uses add() instead of set() so distinct tracks declaring the same key do not silently overwrite. * [TIKA-4777] Replace per-key track-start suffix with quicktime:still-image-time Review outcome: instead of grafting a Tika-invented .track-start-us suffix onto Apple's key namespace, the presentation start of the still-image-time track is now a single dedicated property, quicktime:still-image-time (microseconds). It is emitted for any single-sample mebx track declaring com.apple.quicktime.still-image-time, including 0 when the track has no leading empty edit, so "still is the first frame" (0) stays distinguishable from "not a Live Photo" (absent). Foreign mebx keys no longer produce any output. The quicktime: namespace follows the naming used by ExifTool, exiv2 and metadata-extractor, which all spell out QuickTime; the keys are container-level and appear identically in audio-only files, so a video: prefix would be misleading. The unit stays out of the property name per the convention of xmpDM:duration, geo:alt and audio:bitrate. New fixture testMP4_StillImageTimeZero.mov covers the undelayed case (version 1 edit list containing only a media edit). * [TIKA-4777] Reject oversized stsd entry sizes and cover the edit list reset A crafted sample description declaring an entry size like 0xFFFFFFFF passed the < 16 guard, turned negative in the int cast and escaped parse() as a NegativeArraySizeException. Entry sizes beyond the remaining payload are now treated as malformed; the regression test reproduces the escape when the guard is removed. The still-image-time zero fixture now places a delayed foreign-key track before the undelayed track, so an empty edit duration leaking between tracks would surface as a non-zero value in the existing assertion. Also collapses leftover blank lines in TikaMp4BoxHandler. * [TIKA-4777] Fix static import order for spotless
1 parent 39e83a8 commit bb79091

6 files changed

Lines changed: 334 additions & 2 deletions

File tree

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one or more
3+
* contributor license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright ownership.
5+
* The ASF licenses this file to You under the Apache License, Version 2.0
6+
* (the "License"); you may not use this file except in compliance with
7+
* the License. You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
package org.apache.tika.metadata;
18+
19+
/**
20+
* Metadata properties specific to the QuickTime container family (.mov,
21+
* .mp4, .m4a and friends). The container's com.apple.quicktime.* item-list
22+
* keys pass through under their own names; the properties defined here are
23+
* values Tika derives from structures beyond that key list.
24+
*/
25+
public interface QuickTime {
26+
27+
/**
28+
* Presentation start of the com.apple.quicktime.still-image-time timed
29+
* metadata track, in microseconds. In an Apple Live Photo video this is
30+
* the moment the paired still image was captured. A value of 0 means the
31+
* still is the very first frame; the property is absent when the file
32+
* declares no such track. Only set when the track holds exactly one
33+
* sample (the Live Photo shape): the sample marks a point in time, its
34+
* own one-tick duration is filler, so there is deliberately no
35+
* corresponding end property.
36+
*/
37+
Property STILL_IMAGE_TIME = Property.internalReal("quicktime:still-image-time");
38+
}

tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/main/java/org/apache/tika/parser/mp4/TikaMp4BoxHandler.java

Lines changed: 74 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
import com.drew.metadata.Metadata;
3131
import com.drew.metadata.mp4.Mp4BoxHandler;
3232
import com.drew.metadata.mp4.Mp4Context;
33+
import com.drew.metadata.mp4.Mp4Directory;
3334
import org.xml.sax.SAXException;
3435

3536
import org.apache.tika.metadata.TikaCoreProperties;
@@ -49,13 +50,18 @@ public class TikaMp4BoxHandler extends Mp4BoxHandler {
4950
//QuickTime stores location as an ISO 6709 string (e.g. +32.4720-084.9952+073.827/)
5051
private static final String QT_LOCATION_ISO6709 = "com.apple.quicktime.location.ISO6709";
5152

53+
5254
org.apache.tika.metadata.Metadata tikaMetadata;
5355
final XHTMLContentHandler xhtml;
5456

5557
//key names for the current 'meta' box, filled from its 'keys' box and consumed
5658
//by the following 'ilst' box (e.g. com.apple.quicktime.content.identifier)
5759
private final List<String> quickTimeMetadataKeys = new ArrayList<>();
5860

61+
//duration of the current track's leading empty edit(s) ('elst' entries
62+
//with media time -1), in movie timescale units; -1 if the track has none
63+
private long emptyEditDuration = -1;
64+
5965
public TikaMp4BoxHandler(Metadata metadata, org.apache.tika.metadata.Metadata tikaMetadata,
6066
XHTMLContentHandler xhtml) {
6167
super(metadata);
@@ -65,29 +71,52 @@ public TikaMp4BoxHandler(Metadata metadata, org.apache.tika.metadata.Metadata ti
6571

6672
@Override
6773
public boolean shouldAcceptBox(@NotNull String box) {
68-
if (box.equals("udta") || box.equals("keys") || box.equals("ilst")) {
74+
if (box.equals("udta") || box.equals("keys") || box.equals("ilst")
75+
|| box.equals("elst")) {
6976
return true;
7077
}
7178
return super.shouldAcceptBox(box);
7279
}
7380

7481
@Override
7582
public boolean shouldAcceptContainer(@NotNull String box) {
83+
//edts is needed to reach a track's edit list, which the base handler
84+
//skips; the edit list defines the presentation start of the track
85+
if (box.equals("edts")) {
86+
return true;
87+
}
7688
return super.shouldAcceptContainer(box);
7789
}
7890

7991
@Override
8092
public Mp4Handler<?> processBox(@NotNull String box, @Nullable byte[] payload,
8193
long size, Mp4Context context)
8294
throws IOException {
83-
if (box.equals("udta")) {
95+
if (payload == null && box.equals("trak")) {
96+
//a new track starts (containers route through here with a null
97+
//payload); forget the previous track's edit list
98+
emptyEditDuration = -1;
99+
} else if (box.equals("udta")) {
84100
return processUserData(box, payload, context);
85101
} else if (box.equals("keys")) {
86102
processQuickTimeKeys(payload);
87103
return this;
88104
} else if (box.equals("ilst")) {
89105
processQuickTimeItemList(payload);
90106
return this;
107+
} else if (box.equals("elst")) {
108+
processEditList(payload);
109+
return this;
110+
} else if (box.equals("hdlr") && payload != null && payload.length >= 12
111+
&& payload[8] == 'm' && payload[9] == 'e'
112+
&& payload[10] == 't' && payload[11] == 'a') {
113+
//timed metadata track (e.g. the Live Photo still-image-time track):
114+
//hand over to our handler, which extracts the mebx key declarations
115+
//(the base Mp4MetaHandler accepts the track's boxes but extracts
116+
//nothing from them)
117+
Long movieTimescale = directory.getLongObject(Mp4Directory.TAG_TIME_SCALE);
118+
return new TikaMp4MetaHandler(metadata, context, tikaMetadata,
119+
emptyEditDuration, movieTimescale == null ? 0 : movieTimescale);
91120
}
92121

93122
return super.processBox(box, payload, size, context);
@@ -177,6 +206,45 @@ private void processQuickTimeItemList(@Nullable byte[] payload) {
177206
}
178207
}
179208

209+
210+
/**
211+
* Parses an 'elst' edit list and remembers the total duration of the leading
212+
* empty edits (media time -1, expressed in movie timescale units), which is
213+
* what delays the track's presentation start. Empty edits after the first
214+
* normal entry do not delay the track and are ignored. Apple writes the Live
215+
* Photo still moment as such an empty edit shifting the single one-tick
216+
* sample of the still-image-time track.
217+
*/
218+
private void processEditList(@Nullable byte[] payload) {
219+
if (payload == null || payload.length < 8) {
220+
return;
221+
}
222+
int version = payload[0];
223+
long entryCount = readUInt32(payload, 4);
224+
int pos = 8;
225+
int entrySize = version == 1 ? 20 : 12;
226+
long leadingEmptyEdits = 0;
227+
for (long i = 0; i < entryCount && pos + entrySize <= payload.length; i++) {
228+
long segmentDuration;
229+
long mediaTime;
230+
if (version == 1) {
231+
segmentDuration = readInt64(payload, pos);
232+
mediaTime = readInt64(payload, pos + 8);
233+
} else {
234+
segmentDuration = readUInt32(payload, pos);
235+
mediaTime = (int) readUInt32(payload, pos + 4);
236+
}
237+
if (mediaTime != -1) {
238+
break;
239+
}
240+
leadingEmptyEdits += segmentDuration;
241+
pos += entrySize;
242+
}
243+
if (leadingEmptyEdits > 0) {
244+
emptyEditDuration = leadingEmptyEdits;
245+
}
246+
}
247+
180248
/**
181249
* Maps an ISO 6709 location string (latitude, longitude, optional altitude) to the
182250
* standard {@code geo:lat}/{@code geo:long}/{@code geo:alt} properties, in addition to
@@ -225,4 +293,8 @@ private static long readUInt32(byte[] b, int off) {
225293
return ((b[off] & 0xFFL) << 24) | ((b[off + 1] & 0xFFL) << 16)
226294
| ((b[off + 2] & 0xFFL) << 8) | (b[off + 3] & 0xFFL);
227295
}
296+
297+
private static long readInt64(byte[] b, int off) {
298+
return (readUInt32(b, off) << 32) | readUInt32(b, off + 4);
299+
}
228300
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one or more
3+
* contributor license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright ownership.
5+
* The ASF licenses this file to You under the Apache License, Version 2.0
6+
* (the "License"); you may not use this file except in compliance with
7+
* the License. You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
package org.apache.tika.parser.mp4;
18+
19+
import java.io.IOException;
20+
import java.nio.charset.StandardCharsets;
21+
import java.util.ArrayList;
22+
import java.util.List;
23+
24+
import com.drew.lang.SequentialReader;
25+
import com.drew.metadata.Metadata;
26+
import com.drew.metadata.mp4.Mp4Context;
27+
import com.drew.metadata.mp4.media.Mp4MetaHandler;
28+
29+
import org.apache.tika.metadata.QuickTime;
30+
31+
/**
32+
* Handles a QuickTime timed metadata track (handler type 'meta'). The base
33+
* {@link Mp4MetaHandler} accepts the track's 'stsd' and 'stts' boxes but
34+
* extracts nothing from them; this subclass reads the key names declared by
35+
* 'mebx' sample descriptions (ISO 14496-12 boxed metadata) and, for a
36+
* single-sample track declaring {@code com.apple.quicktime.still-image-time},
37+
* emits the presentation time of that sample as
38+
* {@link QuickTime#STILL_IMAGE_TIME}.
39+
* <p>
40+
* Apple Live Photo videos mark the moment the paired still image was
41+
* captured this way: a leading empty edit delays the track's single one-tick
42+
* sample to the still moment. Without a leading empty edit the sample
43+
* presents at 0, so 0 is emitted (the still is the first frame); with more
44+
* than one sample the track's start is not the time of any single sample and
45+
* nothing is emitted. The sample value itself is a constant -1 marker, so
46+
* the moov boxes alone are sufficient and mdat is never read. See TIKA-4777.
47+
*/
48+
class TikaMp4MetaHandler extends Mp4MetaHandler {
49+
50+
static final String STILL_IMAGE_TIME_KEY = "com.apple.quicktime.still-image-time";
51+
52+
private final org.apache.tika.metadata.Metadata tikaMetadata;
53+
//duration of the track's leading empty edit in movie timescale units,
54+
//or -1 if the track is not delayed
55+
private final long emptyEditDuration;
56+
private final long movieTimescale;
57+
private final List<String> keyNames = new ArrayList<>();
58+
private long sampleCount = -1;
59+
60+
TikaMp4MetaHandler(Metadata metadata, Mp4Context context,
61+
org.apache.tika.metadata.Metadata tikaMetadata,
62+
long emptyEditDuration, long movieTimescale) {
63+
super(metadata, context);
64+
this.tikaMetadata = tikaMetadata;
65+
this.emptyEditDuration = emptyEditDuration;
66+
this.movieTimescale = movieTimescale;
67+
}
68+
69+
@Override
70+
protected void processSampleDescription(SequentialReader reader) throws IOException {
71+
reader.skip(4); //1 byte version + 3 bytes flags
72+
long entryCount = reader.getUInt32();
73+
for (long i = 0; i < entryCount; i++) {
74+
long entrySize = reader.getUInt32();
75+
String format = reader.getString(4, StandardCharsets.ISO_8859_1);
76+
//also reject sizes beyond the remaining payload: a crafted size
77+
//like 0xFFFFFFFF would turn negative in the int cast below
78+
if (entrySize < 16 || entrySize - 8 > reader.available()) {
79+
return;
80+
}
81+
byte[] entry = reader.getBytes((int) entrySize - 8);
82+
if ("mebx".equals(format)) {
83+
//6 bytes reserved + 2 bytes data reference index, then the boxes
84+
parseMebxKeyNames(entry, 8, entry.length, keyNames);
85+
}
86+
}
87+
maybeEmit();
88+
}
89+
90+
@Override
91+
protected void processTimeToSample(SequentialReader reader, Mp4Context context)
92+
throws IOException {
93+
reader.skip(4); //1 byte version + 3 bytes flags
94+
long entryCount = reader.getUInt32();
95+
long samples = 0;
96+
for (long i = 0; i < entryCount; i++) {
97+
samples += reader.getUInt32(); //sample count
98+
reader.skip(4); //sample delta
99+
}
100+
sampleCount = samples;
101+
maybeEmit();
102+
}
103+
104+
/**
105+
* Emits once the sample description and the time-to-sample table have both
106+
* been seen (their order within 'stbl' is not fixed), and only when the
107+
* conditions from the class comment hold.
108+
*/
109+
private void maybeEmit() {
110+
if (sampleCount != 1 || !keyNames.contains(STILL_IMAGE_TIME_KEY)) {
111+
return;
112+
}
113+
if (emptyEditDuration <= 0) {
114+
//no leading empty edit: the sample presents at 0, the still is
115+
//the first frame
116+
tikaMetadata.set(QuickTime.STILL_IMAGE_TIME, 0L);
117+
} else if (movieTimescale > 0
118+
&& emptyEditDuration <= Long.MAX_VALUE / 1_000_000L) {
119+
tikaMetadata.set(QuickTime.STILL_IMAGE_TIME,
120+
emptyEditDuration * 1_000_000L / movieTimescale);
121+
}
122+
keyNames.clear();
123+
}
124+
125+
/**
126+
* Extracts the key names declared by a 'mebx' sample entry: a 'keys' box
127+
* containing one child box per key (typed by the local key id), each of
128+
* which holds a 'keyd' key declaration of namespace plus key name.
129+
*/
130+
private static void parseMebxKeyNames(byte[] b, int start, int end, List<String> keyNames) {
131+
int pos = start;
132+
while (pos + 8 <= end) {
133+
long size = readUInt32(b, pos);
134+
if (size < 8 || pos + size > end) {
135+
break;
136+
}
137+
if ("keys".equals(boxType(b, pos + 4))) {
138+
int keyPos = pos + 8;
139+
int keysEnd = (int) (pos + size);
140+
while (keyPos + 8 <= keysEnd) {
141+
long keySize = readUInt32(b, keyPos);
142+
if (keySize < 8 || keyPos + keySize > keysEnd) {
143+
break;
144+
}
145+
int declPos = keyPos + 8;
146+
int keyEnd = (int) (keyPos + keySize);
147+
while (declPos + 8 <= keyEnd) {
148+
long declSize = readUInt32(b, declPos);
149+
if (declSize < 8 || declPos + declSize > keyEnd) {
150+
break;
151+
}
152+
//'keyd' payload: 4 bytes namespace (e.g. mdta), then the name
153+
if ("keyd".equals(boxType(b, declPos + 4)) && declSize > 12) {
154+
keyNames.add(new String(b, declPos + 12,
155+
(int) declSize - 12, StandardCharsets.UTF_8));
156+
}
157+
declPos += (int) declSize;
158+
}
159+
keyPos += (int) keySize;
160+
}
161+
}
162+
pos += (int) size;
163+
}
164+
}
165+
166+
private static String boxType(byte[] b, int off) {
167+
return new String(b, off, 4, StandardCharsets.ISO_8859_1);
168+
}
169+
170+
private static long readUInt32(byte[] b, int off) {
171+
return ((b[off] & 0xFFL) << 24) | ((b[off + 1] & 0xFFL) << 16)
172+
| ((b[off + 2] & 0xFFL) << 8) | (b[off + 3] & 0xFFL);
173+
}
174+
}

0 commit comments

Comments
 (0)