Skip to content

Commit 9ae502f

Browse files
committed
[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.
1 parent 660fef4 commit 9ae502f

3 files changed

Lines changed: 177 additions & 1 deletion

File tree

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: 172 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,13 +52,24 @@ public class TikaMp4BoxHandler extends Mp4BoxHandler {
5252
private static final Pattern ISO6709_PATTERN =
5353
Pattern.compile("([+-]\\d+(?:\\.\\d+)?)([+-]\\d+(?:\\.\\d+)?)([+-]\\d+(?:\\.\\d+)?)?");
5454

55+
//suffix under which the presentation start of a timed metadata track is
56+
//emitted for each of the track's keys, in microseconds (e.g. the Live Photo
57+
//still moment appears as com.apple.quicktime.still-image-time.track-start-us)
58+
private static final String TRACK_START_SUFFIX = ".track-start-us";
59+
5560
org.apache.tika.metadata.Metadata tikaMetadata;
5661
final XHTMLContentHandler xhtml;
5762

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

67+
//movie timescale from 'mvhd'; edit list durations are expressed in it
68+
private long movieTimescale = 0;
69+
//duration of the current track's leading empty edit ('elst' entry with
70+
//media time -1), in movie timescale units; -1 if the track has none
71+
private long emptyEditDuration = -1;
72+
6273
public TikaMp4BoxHandler(Metadata metadata, org.apache.tika.metadata.Metadata tikaMetadata,
6374
XHTMLContentHandler xhtml) {
6475
super(metadata);
@@ -68,14 +79,22 @@ public TikaMp4BoxHandler(Metadata metadata, org.apache.tika.metadata.Metadata ti
6879

6980
@Override
7081
public boolean shouldAcceptBox(@NotNull String box) {
71-
if (box.equals("udta") || box.equals("keys") || box.equals("ilst")) {
82+
if (box.equals("udta") || box.equals("keys") || box.equals("ilst")
83+
|| box.equals("elst") || box.equals("stsd")) {
7284
return true;
7385
}
7486
return super.shouldAcceptBox(box);
7587
}
7688

7789
@Override
7890
public boolean shouldAcceptContainer(@NotNull String box) {
91+
//edts/minf/stbl are needed to reach the edit list and sample description
92+
//of QuickTime timed metadata tracks (handler type 'mdta', for which the
93+
//base handler keeps this handler active). Media tracks switch to their
94+
//own handlers at 'hdlr', so their boxes never reach this handler.
95+
if (box.equals("edts") || box.equals("minf") || box.equals("stbl")) {
96+
return true;
97+
}
7998
return super.shouldAcceptContainer(box);
8099
}
81100

@@ -91,6 +110,27 @@ public Mp4Handler<?> processBox(@NotNull String box, @Nullable byte[] payload,
91110
} else if (box.equals("ilst")) {
92111
processQuickTimeItemList(payload);
93112
return this;
113+
} else if (box.equals("mvhd")) {
114+
processMovieTimescale(payload);
115+
//fall through, the base handler extracts the other mvhd fields
116+
} else if (box.equals("tkhd")) {
117+
//a new track starts; forget the previous track's edit list
118+
emptyEditDuration = -1;
119+
//fall through to the base handler
120+
} else if (box.equals("elst")) {
121+
processEditList(payload);
122+
return this;
123+
} else if (box.equals("stsd")) {
124+
processSampleDescription(payload);
125+
return this;
126+
} else if (box.equals("hdlr") && payload != null && payload.length >= 12
127+
&& payload[8] == 'm' && payload[9] == 'e'
128+
&& payload[10] == 't' && payload[11] == 'a') {
129+
//timed metadata track (e.g. the Live Photo still-image-time track).
130+
//The base handler would switch to Mp4MetaHandler, which extracts
131+
//nothing, but would keep this handler from seeing the track's 'stsd'.
132+
//Stay active instead.
133+
return this;
94134
}
95135

96136
return super.processBox(box, payload, size, context);
@@ -180,6 +220,137 @@ private void processQuickTimeItemList(@Nullable byte[] payload) {
180220
}
181221
}
182222

223+
private void processMovieTimescale(@Nullable byte[] payload) {
224+
if (payload == null || payload.length < 16) {
225+
return;
226+
}
227+
//1 byte version + 3 bytes flags, then creation/modification time,
228+
//which are 4 bytes each in version 0 and 8 bytes each in version 1
229+
int offset = payload[0] == 1 ? 20 : 12;
230+
if (payload.length >= offset + 4) {
231+
movieTimescale = readUInt32(payload, offset);
232+
}
233+
}
234+
235+
/**
236+
* Parses an 'elst' edit list and remembers the duration of a leading empty edit
237+
* (media time -1, expressed in movie timescale units). Apple writes the Live Photo
238+
* still moment as such an empty edit shifting the single one-tick sample of the
239+
* still-image-time track.
240+
*/
241+
private void processEditList(@Nullable byte[] payload) {
242+
if (payload == null || payload.length < 8) {
243+
return;
244+
}
245+
int version = payload[0];
246+
long entryCount = readUInt32(payload, 4);
247+
int pos = 8;
248+
int entrySize = version == 1 ? 20 : 12;
249+
for (long i = 0; i < entryCount && pos + entrySize <= payload.length; i++) {
250+
long segmentDuration;
251+
long mediaTime;
252+
if (version == 1) {
253+
segmentDuration = readInt64(payload, pos);
254+
mediaTime = readInt64(payload, pos + 8);
255+
} else {
256+
segmentDuration = readUInt32(payload, pos);
257+
mediaTime = (int) readUInt32(payload, pos + 4);
258+
}
259+
if (mediaTime == -1) {
260+
emptyEditDuration = segmentDuration;
261+
return;
262+
}
263+
pos += entrySize;
264+
}
265+
}
266+
267+
/**
268+
* Parses an 'stsd' sample description looking for timed metadata entries
269+
* ('mebx', ISO 14496-12 boxed metadata). For every key the entry declares,
270+
* the presentation start of the track is emitted in microseconds under
271+
* {@code <key name>.track-start-us}. Only tracks delayed by a leading empty
272+
* edit are reported; an undelayed track (start 0) carries no information.
273+
* The main use case are Apple Live Photo videos, which place the single
274+
* sample of the {@code com.apple.quicktime.still-image-time} track at the
275+
* moment the paired still image was captured. See TIKA-4777.
276+
*/
277+
private void processSampleDescription(@Nullable byte[] payload) {
278+
if (payload == null || payload.length < 8 || movieTimescale <= 0
279+
|| emptyEditDuration <= 0) {
280+
return;
281+
}
282+
long trackStartUs = emptyEditDuration * 1_000_000L / movieTimescale;
283+
long entryCount = readUInt32(payload, 4);
284+
int pos = 8;
285+
for (long i = 0; i < entryCount && pos + 16 <= payload.length; i++) {
286+
long entrySize = readUInt32(payload, pos);
287+
if (entrySize < 16 || pos + entrySize > payload.length) {
288+
return;
289+
}
290+
if ("mebx".equals(boxType(payload, pos + 4))) {
291+
//the entry body starts after the 8 byte box header and the
292+
//8 byte sample entry header (6 bytes reserved, 2 bytes dref)
293+
for (String key : parseMebxKeyNames(payload, pos + 16,
294+
(int) (pos + entrySize))) {
295+
tikaMetadata.set(key + TRACK_START_SUFFIX,
296+
Long.toString(trackStartUs));
297+
}
298+
}
299+
pos += (int) entrySize;
300+
}
301+
}
302+
303+
/**
304+
* Extracts the key names declared by a 'mebx' sample entry: a 'keys' box
305+
* containing one child box per key (typed by the local key id), each of
306+
* which holds a 'keyd' key declaration of namespace plus key name.
307+
*/
308+
private static List<String> parseMebxKeyNames(byte[] b, int start, int end) {
309+
List<String> keyNames = new ArrayList<>();
310+
int pos = start;
311+
while (pos + 8 <= end) {
312+
long size = readUInt32(b, pos);
313+
if (size < 8 || pos + size > end) {
314+
break;
315+
}
316+
if ("keys".equals(boxType(b, pos + 4))) {
317+
int keyPos = pos + 8;
318+
int keysEnd = (int) (pos + size);
319+
while (keyPos + 8 <= keysEnd) {
320+
long keySize = readUInt32(b, keyPos);
321+
if (keySize < 8 || keyPos + keySize > keysEnd) {
322+
break;
323+
}
324+
int declPos = keyPos + 8;
325+
int keyEnd = (int) (keyPos + keySize);
326+
while (declPos + 8 <= keyEnd) {
327+
long declSize = readUInt32(b, declPos);
328+
if (declSize < 8 || declPos + declSize > keyEnd) {
329+
break;
330+
}
331+
//'keyd' payload: 4 bytes namespace (e.g. mdta), then the name
332+
if ("keyd".equals(boxType(b, declPos + 4)) && declSize > 12) {
333+
keyNames.add(new String(b, declPos + 12,
334+
(int) declSize - 12, StandardCharsets.UTF_8));
335+
}
336+
declPos += (int) declSize;
337+
}
338+
keyPos += (int) keySize;
339+
}
340+
}
341+
pos += (int) size;
342+
}
343+
return keyNames;
344+
}
345+
346+
private static String boxType(byte[] b, int off) {
347+
return new String(b, off, 4, StandardCharsets.ISO_8859_1);
348+
}
349+
350+
private static long readInt64(byte[] b, int off) {
351+
return (readUInt32(b, off) << 32) | readUInt32(b, off + 4);
352+
}
353+
183354
/**
184355
* Maps an ISO 6709 location string (latitude, longitude, optional altitude) to the
185356
* standard {@code geo:lat}/{@code geo:long}/{@code geo:alt} properties, in addition to

tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-audiovideo-module/src/test/java/org/apache/tika/parser/mp4/MP4ParserTest.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,11 @@ public void testQuickTimeMetadataKeys() throws Exception {
307307
metadata.get("com.apple.quicktime.camera.focal_length.35mm_equivalent"));
308308
assertEquals("1.5",
309309
metadata.get("com.apple.quicktime.full-frame-rate-playback-intent"));
310+
311+
//the Live Photo still moment: presentation start of the timed metadata
312+
//track declaring still-image-time (mebx, empty edit of 740/600s). TIKA-4777
313+
assertEquals("1233333",
314+
metadata.get("com.apple.quicktime.still-image-time.track-start-us"));
310315
}
311316

312317
@Test

0 commit comments

Comments
 (0)