TikaMp4MetaHandler.java

/*
 * 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.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;

import com.drew.lang.SequentialReader;
import com.drew.metadata.Metadata;
import com.drew.metadata.mp4.Mp4Context;
import com.drew.metadata.mp4.media.Mp4MetaHandler;

import org.apache.tika.metadata.QuickTime;

/**
 * Handles a QuickTime timed metadata track (handler type 'meta'). The base
 * {@link Mp4MetaHandler} accepts the track's 'stsd' and 'stts' boxes but
 * extracts nothing from them; this subclass reads the key names declared by
 * 'mebx' sample descriptions (ISO 14496-12 boxed metadata) and, for a
 * single-sample track declaring {@code com.apple.quicktime.still-image-time},
 * emits the presentation time of that sample as
 * {@link QuickTime#STILL_IMAGE_TIME}.
 * <p>
 * Apple Live Photo videos mark the moment the paired still image was
 * captured this way: a leading empty edit delays the track's single one-tick
 * sample to the still moment. Without a leading empty edit the sample
 * presents at 0, so 0 is emitted (the still is the first frame); with more
 * than one sample the track's start is not the time of any single sample and
 * nothing is emitted. The sample value itself is a constant -1 marker, so
 * the moov boxes alone are sufficient and mdat is never read. See TIKA-4777.
 */
class TikaMp4MetaHandler extends Mp4MetaHandler {

    static final String STILL_IMAGE_TIME_KEY = "com.apple.quicktime.still-image-time";

    private final org.apache.tika.metadata.Metadata tikaMetadata;
    //duration of the track's leading empty edit in movie timescale units,
    //or -1 if the track is not delayed
    private final long emptyEditDuration;
    private final long movieTimescale;
    private final List<String> keyNames = new ArrayList<>();
    private long sampleCount = -1;

    TikaMp4MetaHandler(Metadata metadata, Mp4Context context,
                       org.apache.tika.metadata.Metadata tikaMetadata,
                       long emptyEditDuration, long movieTimescale) {
        super(metadata, context);
        this.tikaMetadata = tikaMetadata;
        this.emptyEditDuration = emptyEditDuration;
        this.movieTimescale = movieTimescale;
    }

    @Override
    protected void processSampleDescription(SequentialReader reader) throws IOException {
        reader.skip(4); //1 byte version + 3 bytes flags
        long entryCount = reader.getUInt32();
        for (long i = 0; i < entryCount; i++) {
            long entrySize = reader.getUInt32();
            String format = reader.getString(4, StandardCharsets.ISO_8859_1);
            //also reject sizes beyond the remaining payload: a crafted size
            //like 0xFFFFFFFF would turn negative in the int cast below
            if (entrySize < 16 || entrySize - 8 > reader.available()) {
                return;
            }
            byte[] entry = reader.getBytes((int) entrySize - 8);
            if ("mebx".equals(format)) {
                //6 bytes reserved + 2 bytes data reference index, then the boxes
                parseMebxKeyNames(entry, 8, entry.length, keyNames);
            }
        }
        maybeEmit();
    }

    @Override
    protected void processTimeToSample(SequentialReader reader, Mp4Context context)
            throws IOException {
        reader.skip(4); //1 byte version + 3 bytes flags
        long entryCount = reader.getUInt32();
        long samples = 0;
        for (long i = 0; i < entryCount; i++) {
            samples += reader.getUInt32(); //sample count
            reader.skip(4);                //sample delta
        }
        sampleCount = samples;
        maybeEmit();
    }

    /**
     * Emits once the sample description and the time-to-sample table have both
     * been seen (their order within 'stbl' is not fixed), and only when the
     * conditions from the class comment hold.
     */
    private void maybeEmit() {
        if (sampleCount != 1 || !keyNames.contains(STILL_IMAGE_TIME_KEY)) {
            return;
        }
        if (emptyEditDuration <= 0) {
            //no leading empty edit: the sample presents at 0, the still is
            //the first frame
            tikaMetadata.set(QuickTime.STILL_IMAGE_TIME, 0L);
        } else if (movieTimescale > 0
                && emptyEditDuration <= Long.MAX_VALUE / 1_000_000L) {
            tikaMetadata.set(QuickTime.STILL_IMAGE_TIME,
                    emptyEditDuration * 1_000_000L / movieTimescale);
        }
        keyNames.clear();
    }

    /**
     * Extracts the key names declared by a 'mebx' sample entry: a 'keys' box
     * containing one child box per key (typed by the local key id), each of
     * which holds a 'keyd' key declaration of namespace plus key name.
     */
    private static void parseMebxKeyNames(byte[] b, int start, int end, List<String> keyNames) {
        int pos = start;
        while (pos + 8 <= end) {
            long size = readUInt32(b, pos);
            if (size < 8 || pos + size > end) {
                break;
            }
            if ("keys".equals(boxType(b, pos + 4))) {
                int keyPos = pos + 8;
                int keysEnd = (int) (pos + size);
                while (keyPos + 8 <= keysEnd) {
                    long keySize = readUInt32(b, keyPos);
                    if (keySize < 8 || keyPos + keySize > keysEnd) {
                        break;
                    }
                    int declPos = keyPos + 8;
                    int keyEnd = (int) (keyPos + keySize);
                    while (declPos + 8 <= keyEnd) {
                        long declSize = readUInt32(b, declPos);
                        if (declSize < 8 || declPos + declSize > keyEnd) {
                            break;
                        }
                        //'keyd' payload: 4 bytes namespace (e.g. mdta), then the name
                        if ("keyd".equals(boxType(b, declPos + 4)) && declSize > 12) {
                            keyNames.add(new String(b, declPos + 12,
                                    (int) declSize - 12, StandardCharsets.UTF_8));
                        }
                        declPos += (int) declSize;
                    }
                    keyPos += (int) keySize;
                }
            }
            pos += (int) size;
        }
    }

    private static String boxType(byte[] b, int off) {
        return new String(b, off, 4, StandardCharsets.ISO_8859_1);
    }

    private static long readUInt32(byte[] b, int off) {
        return ((b[off] & 0xFFL) << 24) | ((b[off + 1] & 0xFFL) << 16)
                | ((b[off + 2] & 0xFFL) << 8) | (b[off + 3] & 0xFFL);
    }
}