/ src / library / av_processing / Mp4Extractor.php
<?php
/**
 * SeekQuarry/Yioop --
 * Open Source Pure PHP Search Engine, Crawler, and Indexer
 *
 * Copyright (C) 2009 - 2026  Chris Pollett chris@pollett.org
 *
 * LICENSE:
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
 *
 * END LICENSE
 *
 * @author Chris Pollett chris@pollett.org
 * @license https://www.gnu.org/licenses/ GPL3
 * @link https://www.seekquarry.com/
 * @copyright 2009 - 2026
 * @filesource
 *
 * This class reads an MP4, an MP4 carrying video alone or QuickTime file. It
 * walks the tables that
 * say where each frame sits and which frames stand on their own.
 */
namespace seekquarry\yioop\library\av_processing;
/**
 * Mp4Exception kept as its own type for callers that catch it by name.
 */
class Mp4Exception extends VideoException {}

/**
 * Mp4VideoTrack one video track of an MP4 or QuickTime file: where its samples
 * sit, how long each is shown, which of them can be decoded on their own, and
 * the codec setup that goes with them.
 */
final class Mp4VideoTrack
{
    /**
     * $codec_name stores the four letters naming the codec of this track, such
     * as avc1 for H.264. A reader looks at it to decide which decoder to hand a
     * frame to, and reports it when asked to describe the file.
     * @var string
     */
    public string $codec_name = '';
    /**
     * $clock_units_per_second stores how many units of the track's own clock
     * make one second. Every time in the tables below is in those units, so a
     * moment in seconds is turned into them before a sample is looked up.
     * @var int
     */
    public int $clock_units_per_second = 1;
    /**
     * $track_length stores how long the track runs, in the units of that clock.
     * @var int
     */
    public int $track_length = 0;
    /**
     * $frame_width stores how wide a frame is, in pixels, as the track's header
     * says. A thumbnail is scaled against it without decoding anything.
     * @var int
     */
    public int $frame_width = 0;
    /**
     * $frame_height stores how tall a frame is, in pixels, as the
     * file's own header gives it. A thumbnail keeps the shape of
     * the frame, so its height follows from this and the width.
     * @var int
     */
    public int $frame_height = 0;
    /**
     * $sample_lengths stores how long each sample lasts, written as runs of
     * samples that share a length. Walking it turns a moment into the sample
     * showing then.
     * @var array
     */
    public array $sample_lengths = [];
    /**
     * $self_contained_samples stores which samples stand on their own, by
     * number. Only these can be decoded without the frames before them, so a
     * thumbnail is taken from the nearest one at or before the moment asked
     * for.
     * @var array
     */
    public array $self_contained_samples = [];
    /**
     * $samples_per_chunk stores how many samples sit in each chunk of the file,
     * written as runs of chunks that hold the same number. It is what turns a
     * sample number into a place in the file.
     * @var array
     */
    public array $samples_per_chunk = [];
    /**
     * $sample_sizes stores how many bytes each sample takes, where they differ
     * from one another.
     * @var array
     */
    public array $sample_sizes = [];
    /**
     * $one_size_for_all stores how many bytes every sample takes, where they
     * are all the same. The list above is then left empty.
     * @var int
     */
    public int $one_size_for_all = 0;
    /**
     * $sample_count stores how many samples the track holds altogether.
     * @var int
     */
    public int $sample_count = 0;
    /**
     * $chunk_offsets stores where each chunk begins in the file. With the
     * samples-per-chunk table above, this gives the place of any sample.
     * @var array
     */
    public array $chunk_offsets = [];
    /**
     * $sequence_settings stores the sequence settings, or nothing where there
     * is none.
     * @var string
     */
    public ?string $sequence_settings = null;
    /**
     * $picture_settings stores the picture settings, or nothing where there is
     * none.
     * @var string
     */
    public ?string $picture_settings = null;
    /**
     * $parameter_sets stores parameter sets of an H.265 track, as the setup
     * record stores them.
     * @var array
     */
    public array $parameter_sets = [];
    /**
     * $stream_unit_length_size stores how many bytes each unit's length
     * field takes. An MP4 writes a length in front of every unit of
     * the stream, and a decoder cannot find the next unit without
     * knowing the width of that field.
     * @var int
     */
    public int $stream_unit_length_size = 4;
    /**
     * $showing_offsets stores how far each sample's showing time sits from its
     * decoding time, where the two differ. Frames may be stored out of order,
     * and this puts them back.
     * @var array
     */
    public array $showing_offsets = [];
    /**
     * $edit_media_start stores media time the first edit starts at, in media
     * timescale units.
     * @var int
     */
    public int $edit_media_start = 0;
    /**
     * $edit_delay stores empty edit at the head of the timeline, in seconds.
     * @var float
     */
    public float $edit_delay = 0.0;
    /**
     * $edit_duration stores presentation duration from the edit list, in
     * seconds, 0 when absent.
     * @var float
     */
    public float $edit_duration = 0.0;
    /**
     * compositionOffset says how far a sample's showing time sits from its
     * decoding time. Frames may be stored out of the order they are shown in,
     * and this offset puts them back, so a thumbnail asked for at a moment
     * lands on the right frame. zero.
     *
     * @param int $sample Which sample of the track, counting from
     * @return int How many units of the track's clock to add.
     */
    public function compositionOffset(int $sample): int
    {
        if ($this->showing_offsets === []) {
            return 0;
        }
        $at = 0;
        foreach ($this->showing_offsets as [$count, $offset]) {
            if ($sample < $at + $count) {
                return $offset;
            }
            $at += $count;
        }
        return 0;
    }
    /**
     * presentationTime presentation time of a sample in seconds, edit list
     * applied
     *
     * @param int $sample which sample of the track
     * @return float what was read
     */
    public function presentationTime(int $sample): float
    {
        $media = $this->timeOfSample($sample) * $this->clock_units_per_second
            + $this->compositionOffset($sample);
        return ($media - $this->edit_media_start) / $this
            ->clock_units_per_second
            + $this->edit_delay;
    }
    /**
     * sizeOfSample the stored length of one sample.
     *
     * @param int $sample the stored bytes of one sample
     * @return int what was read
     */
    public function sizeOfSample(int $sample): int
    {
        if ($this->one_size_for_all > 0) {
            return $this->one_size_for_all;
        }
        if (!isset($this->sample_sizes[$sample])) {
            throw new Mp4Exception("No size for sample $sample");
        }
        return $this->sample_sizes[$sample];
    }
    /**
     * timeOfSample decode time of a 0-based sample, in seconds
     *
     * @param int $sample which sample of the track
     * @return float what was read
     */
    public function timeOfSample(int $sample): float
    {
        $track = 0;
        $at = 0;
        foreach ($this->sample_lengths as [$count, $delta]) {
            if ($sample < $at + $count) {
                return ($track
                    + ($sample - $at) * $delta) / $this->clock_units_per_second;
            }
            $track += $count * $delta;
            $at += $count;
        }
        return $track / $this->clock_units_per_second;
    }
}

/**
 * Mp4Extractor reads an MP4 file, one carrying video alone, or a
 * QuickTime file. It walks the tables that say where each frame sits
 * and which frames stand on their own.
 */
final class Mp4Extractor extends VideoExtractor
{
    /**
     * $hevc stores the H.265 decoder, made when the first such frame is asked
     * for.
     * @var HevcDecoder
     */
    private ?HevcDecoder $hevc = null;
    use ByteSource;
    /**
     * $track stores the track, or nothing where there is none.
     * @var Mp4VideoTrack
     */
    private ?Mp4VideoTrack $track = null;
    /**
     * $movie_clock_units_per_second stores how many units of the file's own
     * clock make one second, which is not always the track's. The edit list is
     * written in these units.
     * @var int
     */
    private int $movie_clock_units_per_second = 1000;
    /**
     * $file_brand stores the four letters the file names itself by, such as the
     * name an MP4 gives itself or qt. It is reported when the file is
     * described, and tells an MP4 from an older QuickTime file.
     * @var string
     */
    private string $file_brand = '';
    /**
     * __construct opens an MP4, an MP4 carrying video alone or QuickTime file
     * and indexes its video track.
     *
     * @param string $path file to read
     */
    public function __construct(string $path)
    {
        $this->openSource($path);
        $head = $this->readBytesAt(0, 12);
        if (strlen($head) === 12 && substr($head, 4, 4) === 'ftyp') {
            $this->file_brand = substr($head, 8, 4);
        }
    }
    /**
     * children immediate children of the byte range [$start, $end). offset
     *
     * @return array start = payload
     * @param int $start where it starts
     * @param int $end where it ends
     */
    private function children(int $start, int $end): array
    {
        $written = [];
        $position = $start;
        while ($position + 8 <= $end) {
            $header = $this->readExact($position, 8);
            $size = unpack('N', substr($header, 0, 4))[1];
            $type = substr($header, 4, 4);
            $header_length = 8;
            if ($size === 1) {
                $size = unpack('J', $this->readExact($position + 8, 8))[1];
                $header_length = 16;
            } elseif ($size === 0) {
                $size = $end - $position;
            }
            if ($size < $header_length || $position + $size > $end) {
                /* truncated or garbage; stop rather than guess */
                break;
            }
            $written[] = ['type' => $type,
                'start' => $position + $header_length,
                'end'
                => $position + $size];
            $position += $size;
        }
        return $written;
    }
    /**
     * chooseTable to look through
     *
     * @param array $boxes the boxes
     * @param string $type which kind
     * @return array what was read
     */
    private static function chooseTable(array $boxes, string $type): ?array
    {
        foreach ($boxes as $bits) {
            if ($bits['type'] === $type) {
                return $bits;
            }
        }
        return null;
    }
    /**
     * descend finds a box by following a path of box names from a parent.
     *
     * @param int $start where it starts
     * @param int $end where it ends
     * @param string ... $path file to read
     * @return array what was read
     */
    private function descend(int $start, int $end, string ...$path): ?array
    {
        $current = ['start' => $start, 'end' => $end];
        foreach ($path as $type) {
            $hit
                = self::chooseTable($this->children($current['start'],
                    $current['end']),
                    $type);
            if ($hit === null) {
                return null;
            }
            $current = $hit;
        }
        return $current;
    }
    /**
     * readFourBytes reads a four byte number stored most significant byte
     * first.
     *
     * @param string $source the file or bytes being read
     * @param int $origin where the reading started
     * @return int what was read
     */
    private static function readFourBytes(string $source, int $origin): int
    {
        return unpack('N', substr($source, $origin, 4))[1];
    }
    /**
     * readTwoBytes reads a two byte number stored most significant byte first.
     *
     * @param string $source the file or bytes being read
     * @param int $origin where the reading started
     * @return int what was read
     */
    private static function readTwoBytes(string $source, int $origin): int
    {
        return unpack('n', substr($source, $origin, 2))[1];
    }
    /**
     * readEightBytes reads an eight byte number stored most significant byte
     * first.
     *
     * @param string $source the file or bytes being read
     * @param int $origin where the reading started
     * @return int what was read
     */
    private static function readEightBytes(string $source, int $origin): int
    {
        return unpack('J', substr($source, $origin, 8))[1];
    }
    /**
     * videoTrack the video track, read from the file the first time it is asked
     * for.
     *
     * @return Mp4VideoTrack what was read
     */
    public function videoTrack(): Mp4VideoTrack
    {
        if ($this->track !== null) {
            return $this->track;
        }
        $movie_box = self::chooseTable($this->children(0, $this->sourceSize()),
            'moov');
        if ($movie_box === null) {
            throw new Mp4Exception(
                'No moov box (fragmented or streaming-only file?)');
        }
        $movie_box_kids = $this->children($movie_box['start'],
            $movie_box['end']);
        $movie_header = self::chooseTable($movie_box_kids, 'mvhd');
        if ($movie_header !== null) {
            $payload = $this->readExact($movie_header['start'], min(24,
                $movie_header['end']
                - $movie_header['start']));
            if (strlen($payload) >= 20) {
                $ts = (ord($payload[0]) === 1)
                    ? self::readFourBytes($payload, 20)
                    : self::readFourBytes($payload, 12);
                if ($ts > 0) {
                    $this->movie_clock_units_per_second = $ts;
                }
            }
        }
        foreach ($movie_box_kids as $box) {
            if ($box['type'] !== 'trak') {
                continue;
            }
            $handler_box = $this->descend($box['start'], $box['end'],
                'mdia', 'hdlr');
            if ($handler_box === null) {
                continue;
            }
            $header = $this->readExact($handler_box['start'], min(12,
                $handler_box['end']
                - $handler_box['start']));
            if (strlen($header) < 12 || substr($header, 8, 4) !== 'vide') {
                continue;
            }
            $this->track = $this->parseTrack($box['start'], $box['end']);
            return $this->track;
        }
        throw new Mp4Exception('No video track found');
    }
    /**
     * parseTrack reads one track and keeps it if it carries video.
     *
     * @param int $start where it starts
     * @param int $end where it ends
     * @return Mp4VideoTrack what was read
     */
    private function parseTrack(int $start, int $end): Mp4VideoTrack
    {
        $track = new Mp4VideoTrack();
        $media_header = $this->descend($start, $end, 'mdia', 'mdhd');
        if ($media_header === null) {
            throw new Mp4Exception('Missing mdhd');
        }
        $payload
            = $this->readExact($media_header['start'],
                $media_header['end'] - $media_header['start']);
        $ver = ord($payload[0]);
        if ($ver === 1) {
            $track->clock_units_per_second = self::readFourBytes($payload, 20);
            $track->track_length  = self::readEightBytes($payload, 24);
        } else {
            $track->clock_units_per_second = self::readFourBytes($payload, 12);
            $track->track_length  = self::readFourBytes($payload, 16);
        }
        if ($track->clock_units_per_second <= 0) {
            throw new Mp4Exception('Bad media timescale');
        }
        $sample_table = $this->descend($start, $end, 'mdia', 'minf', 'stbl');
        if ($sample_table === null) {
            throw new Mp4Exception('Missing stbl');
        }
        $tables = $this->children($sample_table['start'], $sample_table['end']);
        $this->readSampleDescription($tables, $track);
        $this->readSampleLengths($tables, $track);
        $this->readSelfContainedSamples($tables, $track);
        $this->readSamplesPerChunk($tables, $track);
        $this->readSampleSizes($tables, $track);
        $this->parseChunkOffsets($tables, $track);
        $this->readShowingOffsets($tables, $track);
        $this->parseEditList($start, $end, $track);
        return $track;
    }
    /**
     * readSampleDescription reads the sample description, which names the codec
     * and gives the picture size.
     *
     * @param array $tables the tables the format fixes
     * @param Mp4VideoTrack $track the track being read
     */
    private function readSampleDescription(array $tables,
        Mp4VideoTrack $track): void
    {
        $sample_kinds = self::chooseTable($tables, 'stsd');
        if ($sample_kinds === null) {
            throw new Mp4Exception('Missing stsd');
        }
        $entries = $this->children($sample_kinds['start']
            /* skip version/flags + entry_count */
            + 8, $sample_kinds['end']);
        if ($entries === []) {
            throw new Mp4Exception('Empty stsd');
        }
        $entry = $entries[0];
        $track->codec_name = $entry['type'];
        /* VisualSampleEntry body: width at +24, height at +26 relative to
          payload start */
        $body
            = $this->readExact($entry['start'], min(78, $entry['end']
                - $entry['start']));
        if (strlen($body) >= 28) {
            $track->frame_width  = self::readTwoBytes($body, 24);
            $track->frame_height = self::readTwoBytes($body, 26);
        }
        /* child boxes of the sample entry start 78 bytes into the payload */
        $child_start = $entry['start'] + 78;
        if ($child_start >= $entry['end']) {
            return;
        }
        foreach ($this->children($child_start, $entry['end']) as $chunk) {
            if ($chunk['type'] === 'avcC') {
                $this->readH264SetupRecord($this->readExact($chunk['start'],
                    $chunk['end'] - $chunk['start']), $track);
            }
            if ($chunk['type'] === 'hvcC') {
                $this->readHevcSetupRecord($this->readExact($chunk['start'],
                    $chunk['end'] - $chunk['start']), $track);
            }
        }
    }
    /**
     * Reads the H.264 setup record, which holds the parameter sets
     * and says how long each unit's length field is.
     *
     * @param string $payload the bytes the element carries
     * @param Mp4VideoTrack $track the track being read
     */
    /**
     * readHevcSetupRecord reads the H.265 setup record, which holds the
     * parameter sets and says how long each unit's length field is.
     *
     * @param string $record the record's bytes
     * @param Mp4VideoTrack $track track the record belongs to
     */
    private function readHevcSetupRecord(string $record,
        Mp4VideoTrack $track): void
    {
        if (strlen($record) < 23) {
            throw new Mp4Exception('hvcC record is too short');
        }
        $track->stream_unit_length_size = (ord($record[21]) & 3) + 1;
        $arrays = ord($record[22]);
        $position = 23;
        $sets = [];
        for ($group = 0; $group < $arrays; $group++) {
            if ($position + 3 > strlen($record)) {
                break;
            }
            $count = (ord($record[$position + 1]) << 8)
                | ord($record[$position + 2]);
            $position += 3;
            for ($entry = 0; $entry < $count; $entry++) {
                if ($position + 2 > strlen($record)) {
                    break 2;
                }
                $length = (ord($record[$position]) << 8)
                    | ord($record[$position + 1]);
                $position += 2;
                $sets[] = substr($record, $position, $length);
                $position += $length;
            }
        }
        $track->parameter_sets = $sets;
    }
    /**
     * readH264SetupRecord reads the H.264 setup record, which holds the
     * parameter sets and says how long each unit's length field is.
     *
     * @param string $payload the record's bytes
     * @param Mp4VideoTrack $track track the record belongs to
     */
    private function readH264SetupRecord(string $payload,
        Mp4VideoTrack $track): void
    {
        if (strlen($payload) < 7) {
            return;
        }
        $track->stream_unit_length_size = (ord($payload[4]) & 0x03) + 1;
        $sequence_setting_count = ord($payload[5]) & 0x1F;
        $position = 6;
        for ($i = 0; $i < $sequence_setting_count && $position + 2 <=
            strlen($payload); $i++) {
            $length = self::readTwoBytes($payload, $position);
            $position += 2;
            if ($i === 0) {
                $track->sequence_settings = substr($payload, $position,
                    $length);
            }
            $position += $length;
        }
        if ($position >= strlen($payload)) {
            return;
        }
        $picture_setting_count = ord($payload[$position]);
        $position++;
        for ($i = 0; $i < $picture_setting_count && $position + 2 <=
            strlen($payload); $i++) {
            $length = self::readTwoBytes($payload, $position);
            $position += 2;
            if ($i === 0) {
                $track->picture_settings = substr($payload, $position, $length);
            }
            $position += $length;
        }
    }
    /**
     * readSampleLengths reads how long each sample is shown.
     *
     * @param array $tables the tables the format fixes
     * @param Mp4VideoTrack $track the track being read
     */
    private function readSampleLengths(array $tables,
        Mp4VideoTrack $track): void
    {
        $bits = self::chooseTable($tables, 'stts');
        if ($bits === null) {
            throw new Mp4Exception('Missing stts');
        }
        $payload
            = $this->readExact($bits['start'], $bits['end'] - $bits['start']);
        $total = self::readFourBytes($payload, 4);
        for ($i = 0; $i < $total; $i++) {
            $origin = 8 + $i * 8;
            if ($origin + 8 > strlen($payload)) {
                break;
            }
            $track->sample_lengths[]
                = [self::readFourBytes($payload, $origin),
                    self::readFourBytes($payload, $origin + 4)];
        }
    }
    /**
     * readSelfContainedSamples reads which samples can be decoded on their own.
     *
     * @param array $tables the tables the format fixes
     * @param Mp4VideoTrack $track the track being read
     */
    private function readSelfContainedSamples(array $tables,
        Mp4VideoTrack $track): void
    {
        $bits = self::chooseTable($tables, 'stss');
        if ($bits === null) {
            /* absent => every sample is a sync sample */
            return;
        }
        $payload
            = $this->readExact($bits['start'], $bits['end'] - $bits['start']);
        $total = self::readFourBytes($payload, 4);
        for ($i = 0; $i < $total; $i++) {
            $origin = 8 + $i * 4;
            if ($origin + 4 > strlen($payload)) {
                break;
            }
            $track->self_contained_samples[] = self::readFourBytes($payload,
                $origin);
        }
    }
    /**
     * readSamplesPerChunk reads how samples are grouped into chunks.
     *
     * @param array $tables the tables the format fixes
     * @param Mp4VideoTrack $track the track being read
     */
    private function readSamplesPerChunk(array $tables,
        Mp4VideoTrack $track): void
    {
        $bits = self::chooseTable($tables, 'stsc');
        if ($bits === null) {
            throw new Mp4Exception('Missing stsc');
        }
        $payload
            = $this->readExact($bits['start'], $bits['end'] - $bits['start']);
        $total = self::readFourBytes($payload, 4);
        for ($i = 0; $i < $total; $i++) {
            $origin = 8 + $i * 12;
            if ($origin + 12 > strlen($payload)) {
                break;
            }
            $track->samples_per_chunk[]
                = [self::readFourBytes($payload, $origin),
                    self::readFourBytes($payload, $origin + 4),
                        self::readFourBytes($payload, $origin
                    + 8)];
        }
    }
    /**
     * readSampleSizes reads the stored length of every sample.
     *
     * @param array $tables the tables the format fixes
     * @param Mp4VideoTrack $track the track being read
     */
    private function readSampleSizes(array $tables, Mp4VideoTrack $track): void
    {
        $bits = self::chooseTable($tables, 'stsz');
        if ($bits === null) {
            throw new Mp4Exception('Missing stsz');
        }
        $payload
            = $this->readExact($bits['start'], $bits['end'] - $bits['start']);
        $uniform = self::readFourBytes($payload, 4);
        $track->sample_count = self::readFourBytes($payload, 8);
        if ($uniform > 0) {
            $track->one_size_for_all = $uniform;
            return;
        }
        for ($i = 0; $i < $track->sample_count; $i++) {
            $origin = 12 + $i * 4;
            if ($origin + 4 > strlen($payload)) {
                break;
            }
            $track->sample_sizes[] = self::readFourBytes($payload, $origin);
        }
    }
    /**
     * readShowingOffsets composition offsets; present whenever decode and
     * display order differ
     *
     * @param array $tables the tables the format fixes
     * @param Mp4VideoTrack $track the track being read
     */
    private function readShowingOffsets(array $tables,
        Mp4VideoTrack $track): void
    {
        $bits = self::chooseTable($tables, 'ctts');
        if ($bits === null) {
            return;
        }
        $payload
            = $this->readExact($bits['start'], $bits['end'] - $bits['start']);
        $version = ord($payload[0]);
        $total = self::readFourBytes($payload, 4);
        for ($i = 0; $i < $total; $i++) {
            $origin = 8 + $i * 8;
            if ($origin + 8 > strlen($payload)) {
                break;
            }
            $count = self::readFourBytes($payload, $origin);
            $offset = self::readFourBytes($payload, $origin + 4);
            if ($version === 1 && $offset >= 0x80000000) {
                /* version 1 offsets are signed */
                $offset -= 0x100000000;
            }
            $track->showing_offsets[] = [$count, $offset];
        }
    }
    /**
     * parseEditList quickTime and MP4 both allow an edit list, and QuickTime
     * writers use it far more often. A leading empty edit delays the timeline;
     * the first real edit says which media time the presentation starts at,
     * which is how a stream with reordered frames avoids negative timestamps.
     *
     * @param int $start where it starts
     * @param int $end where it ends
     * @param Mp4VideoTrack $track the track being read
     */
    private function parseEditList(int $start, int $end,
        Mp4VideoTrack $track): void
    {
        $edit_list = $this->descend($start, $end, 'edts', 'elst');
        if ($edit_list === null) {
            return;
        }
        $payload
            = $this->readExact($edit_list['start'], $edit_list['end'] -
                $edit_list['start']);
        if (strlen($payload) < 8) {
            return;
        }
        $version = ord($payload[0]);
        $total = self::readFourBytes($payload, 4);
        $entry_size = ($version === 1) ? 20 : 12;
        $position = 8;
        $total_duration = 0;
        $started = false;
        for ($i = 0; $i < $total && $position
            + $entry_size <= strlen($payload); $i++, $position += $entry_size) {
            if ($version === 1) {
                $segment = self::readEightBytes($payload, $position);
                $media_time = self::readEightBytes($payload, $position + 8);
                if ($media_time >= 0x8000000000000000) {
                    $media_time = -1;
                }
            } else {
                $segment = self::readFourBytes($payload, $position);
                $media_time = self::readFourBytes($payload, $position + 4);
                if ($media_time >= 0x80000000) {
                    $media_time -= 0x100000000;
                }
            }
            $total_duration += $segment;
            if ($media_time < 0) {
                if (!$started) {
                    $track->edit_delay += $segment /
                        $this->movie_clock_units_per_second;
                }
                continue;
            }
            if (!$started) {
                $track->edit_media_start = $media_time;
                $started = true;
            }
        }
        if ($total_duration > 0) {
            $track->edit_duration = $total_duration /
                $this->movie_clock_units_per_second;
        }
    }
    /**
     * parseChunkOffsets reads where each chunk sits in the file, from either of
     * the two tables that can carry it.
     *
     * @param array $tables the tables the format fixes
     * @param Mp4VideoTrack $track the track being read
     */
    private function parseChunkOffsets(
        array $tables, Mp4VideoTrack $track): void
    {
        $bits = self::chooseTable($tables, 'stco');
        $wide = false;
        if ($bits === null) {
            $bits = self::chooseTable($tables, 'co64');
            $wide = true;
        }
        if ($bits === null) {
            throw new Mp4Exception('Missing stco/co64');
        }
        $payload
            = $this->readExact($bits['start'], $bits['end'] - $bits['start']);
        $total = self::readFourBytes($payload, 4);
        $step = $wide ? 8 : 4;
        for ($i = 0; $i < $total; $i++) {
            $origin = 8 + $i * $step;
            if ($origin + $step > strlen($payload)) {
                break;
            }
            $track->chunk_offsets[] = $wide ? self::readEightBytes($payload,
                $origin)
                : self::readFourBytes($payload, $origin);
        }
    }
    /**
     * findBox finds a named box inside a stretch of the file. An MP4 is built
     * of boxes inside boxes, so a reader walks down to the one it wants.
     *
     * @return array [absolute offset, size]
     * @param Mp4VideoTrack $track the track being read
     * @param int $sample which sample of the track
     */
    private function findBox(Mp4VideoTrack $track, int $sample): array
    {
        $n_chunks = count($track->chunk_offsets);
        $seen = 0;
        $total = count($track->samples_per_chunk);
        for ($entry = 0; $entry < $total; $entry++) {
            [$first_chunk, $per_chunk] = $track->samples_per_chunk[$entry];
            $last_chunk = ($entry + 1 < $total)
                ? $track->samples_per_chunk[$entry + 1][0] - 1 : $n_chunks;
            $run_chunks = $last_chunk - $first_chunk + 1;
            if ($run_chunks <= 0 || $per_chunk <= 0) {
                continue;
            }
            $run_samples = $run_chunks * $per_chunk;
            if ($sample < $seen + $run_samples) {
                $within       = $sample - $seen;
                /* 1-based */
                $chunk_position     = $first_chunk + intdiv($within,
                    $per_chunk);
                $sample_in_chunk = $within % $per_chunk;
                if (!isset($track->chunk_offsets[$chunk_position - 1])) {
                    throw new Mp4Exception("chunk $chunk_position "
                        . "is past the end of the file");
                }
                $offset = $track->chunk_offsets[$chunk_position - 1];
                for ($source = $sample
                    - $sample_in_chunk; $source < $sample; $source++) {
                    $offset += $track->sizeOfSample($source);
                }
                return [$offset, $track->sizeOfSample($sample)];
            }
            $seen += $run_samples;
        }
        throw new Mp4Exception("Sample $sample not covered by stsc");
    }
    /**
     * toPlainStream convert a stored H.264 sample to an Annex-B, the form an
     * H.264 stream takes on its own stream, with the parameter sets from the
     * avcC (the box holding an MP4's H.264 settings) record in front.
     *
     * @param string $sample which sample of the track
     * @return string what was read
     */
    public function toPlainStream(string $sample): string
    {
        $track = $this->videoTrack();
        $settings = new AvcConfig();
        $settings->sequence_settings = $track->sequence_settings;
        $settings->picture_settings = $track->picture_settings;
        $settings->stream_unit_length_size = $track->stream_unit_length_size;
        return self::plainStreamFrom($settings, $sample);
    }
    /**
     * durationSeconds works out how long the video runs.
     *
     * @return float what was read
     */
    public function durationSeconds(): float
    {
        $track = $this->videoTrack();
        if ($track->edit_duration > 0.0) {
            return $track->edit_duration;
        }
        return $track->clock_units_per_second > 0
            ? $track->track_length / $track->clock_units_per_second : 0.0;
    }
    /**
     * syncSamples 0-based indices of the sync samples, in decode order.
     *
     * @return array what was read
     */
    public function syncSamples(): array
    {
        $track = $this->videoTrack();
        if ($track->self_contained_samples !== []) {
            return array_map(static fn(int $total): int => $total
                - 1, $track->self_contained_samples);
        }
        return $track->sample_count > 0
            ? range(0, $track->sample_count - 1) : [];
    }
    /**
     * sampleTime works out when a sample is shown, in seconds from the start.
     * Edit lists and composition offsets are both taken into account, so this
     * agrees with what a player shows.
     *
     * @param int $index position of the sample in decode order
     * @return float what was read
     */
    public function sampleTime(int $index): float
    {
        return $this->videoTrack()->presentationTime($index);
    }
    /**
     * sampleData the stored bytes of one sample.
     *
     * @param int $index position of the sample in decode order
     * @return string what was read
     */
    public function sampleData(int $index): string
    {
        $track = $this->videoTrack();
        [$offset, $size] = $this->findBox($track, $index);
        return $this->readExact($offset, $size);
    }
    /**
     * codecName the codec name as the file spells it.
     *
     * @return string what was read
     */
    public function codecName(): string
    {
        return $this->videoTrack()->codec_name;
    }
    /**
     * containerName quickTime when the file says so in its brand, MP4
     * otherwise.
     *
     * @return string what was read
     */
    public function containerName(): string
    {
        return $this->file_brand === 'qt  ' ? 'QuickTime' : 'MP4';
    }
    /**
     * codecKind works out which decoder handles this track.
     *
     * @return string what was read
     */
    public function codecKind(): string
    {
        $chunk = $this->videoTrack()->codec_name;
        if ($chunk === 'avc1' || $chunk === 'avc3') {
            return 'h264';
        }
        if (in_array($chunk, ['hvc1', 'hev1', 'dvh1', 'dvhe'], true)) {
            return 'hevc';
        }
        /* MJPEG in MP4 and QuickTime goes under several sample entry names */
        if (in_array($chunk, ['jpeg', 'mjpa', 'mjpb', 'AVDJ', 'dmb1'], true)) {
            return 'jpeg';
        }
        return $chunk;
    }
    /**
     * frameWidth width of the picture in samples.
     *
     * @return int what was read
     */
    public function frameWidth(): int
    {
        return $this->videoTrack()->frame_width;
    }
    /**
     * frameHeight height of the picture in samples.
     *
     * @return int what was read
     */
    public function frameHeight(): int
    {
        return $this->videoTrack()->frame_height;
    }
    /**
     * settingsUnits the H.264 parameter sets the setup record carries.
     *
     * @return array what was read
     */
    public function settingsUnits(): array
    {
        if ($this->codecKind() === 'hevc') {
            return $this->videoTrack()->parameter_sets;
        }
        $track = $this->videoTrack();
        /* The box called avcC keeps whole units of the H.264 stream,
           header byte included */
        return array_values(array_filter([$track->sequence_settings,
            $track->picture_settings]));
    }
    /**
     * decodeHevc decodes one H.265 keyframe into a picture.
     *
     * @param string $frame the stored bytes of one frame
     * @return VideoPicture the decoded picture
     */
    public function decodeHevc(string $frame): VideoPicture
    {
        if ($this->hevc === null) {
            $this->hevc = new HevcDecoder();
            $this->hevc->consumeStored($this->settingsUnits());
        }
        return $this->hevc->decodeKeyframe($this->toPlainStream($frame));
    }
}
X