<?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));
}
}