/ src / library / av_processing / AviExtractor.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 AVI file. It uses the file's index where that can be
 * trusted, and walks the movie list where it cannot.
 */
namespace seekquarry\yioop\library\av_processing;
/**
 * AviExtractor reads an AVI file. It uses the file's own index where that
 * can be trusted, and walks the movie list where it cannot.
 */
final class AviExtractor extends VideoExtractor
{
    use ByteSource;
    /**
     * $stream_index stores which stream of the file carries the video. An AVI
     * may hold several streams, and every chunk of the movie list names the
     * stream it belongs to, so this says which chunks to keep. Set by
     * walkChunks() while it reads the file's headers.
     * @var int
     */
    private int $stream_index = -1;
    /**
     * $compression_name stores the four letters the stream header gives for the
     * codec, such as H264 or motion the still picture format, where each frame
     * is a still picture. codecKind() reads it to decide which decoder to hand
     * a frame to.
     * @var string
     */
    private string $compression_name = '';
    /**
     * $handler_name stores the four letters the stream's handler is named by,
     * which some writers fill in where the compression field is left empty.
     * codecKind() falls back to it.
     * @var string
     */
    private string $handler_name = '';
    /**
     * $frame_width stores how wide a frame is, in pixels, as the file says. A
     * thumbnail is scaled against it, so a reader can ask before decoding.
     * @var int
     */
    private int $frame_width = 0;
    /**
     * $frame_height stores how tall a frame is, in pixels, as the file says.
     * Used with the width to keep a thumbnail's shape.
     * @var int
     */
    private int $frame_height = 0;
    /**
     * $frame_rate_lower stores the lower half of the frame rate the file gives,
     * as a fraction of rate over scale. durationSeconds() and sampleTime()
     * divide by it to turn a frame number into a moment in the video.
     * @var int
     */
    private int $frame_rate_lower = 1;
    /**
     * $frame_rate_upper stores the upper half of that frame rate. Twenty-five
     * stands until the file says otherwise, so a file with no rate still gives
     * times.
     * @var int
     */
    private int $frame_rate_upper = 25;
    /**
     * $claimed_frame_count stores how many frames the file's header claims to
     * hold. It is not trusted for seeking, since writers often leave it wrong,
     * but it says whether the index is worth reading.
     * @var int
     */
    private int $claimed_frame_count = 0;
    /**
     * $codec_setup_bytes stores the bytes the stream header carries after its
     * own fields, which for H.264 hold the settings a decoder needs.
     * avcConfig() reads them before any frame is decoded.
     * @var string
     */
    private string $codec_setup_bytes = '';
    /**
     * $frame_table stores where every frame sits in the file, how long it is,
     * and whether it stands on its own. This is what a seek walks, so it is
     * built once when the file is opened.
     * @var array
     */
    private array $frame_table = [];
    /**
     * $self_contained_frames stores which of those frames stand on their own,
     * by their place in the list above. A thumbnail can only be decoded from
     * one of these, so asking for a moment picks the nearest one before it.
     * @var array
     */
    private array $self_contained_frames = [];
    /**
     * $h264_settings stores the H.264 settings read out of the stream header,
     * kept after the first reading so a second frame does not read them again.
     * Nothing where the file carries none.
     * @var AvcConfig
     */
    private ?AvcConfig $h264_settings = null;
    /**
     * $has_been_read stores whether the file's headers and index have been
     * read. The reading is done once, the first time anything is asked of the
     * file.
     * @var bool
     */
    private bool $has_been_read = false;
    /**
     * __construct opens an AVI file and indexes its video stream.
     *
     * @param string $path file to read
     */
    public function __construct(string $path)
    {
        $this->openSource($path);
        $this->readSettings();
    }
    /**
     * readFourBytes reads a four byte number stored least 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('V', substr($source, $origin, 4))[1];
    }
    /**
     * readSettings walks the file's chunks and gathers the stream and its
     * frames.
     */
    private function readSettings(): void
    {
        if ($this->has_been_read) {
            return;
        }
        $this->has_been_read = true;
        $head = $this->readBytesAt(0, 12);
        if (strlen($head) < 12 || substr($head, 0, 4) !== 'RIFF'
            || substr($head, 8, 4) !== 'AVI ') {
            throw new VideoException('not an AVI file');
        }
        $movie_list_parts = [];
        $chunk_index = null;
        /* walk every top-level RIFF segment: 'AVI ' then any 'AVIX' extensions
          */
        $position = 0;
        while ($position + 12 <= $this->sourceSize()) {
            $header = $this->readBytesAt($position, 12);
            if (strlen($header) < 12 || substr($header, 0, 4) !== 'RIFF') {
                break;
            }
            $chunked_file_size = self::readFourBytes($header, 4);
            $end = min($this->sourceSize(), $position + 8 + $chunked_file_size);
            $this->walkChunks($position + 12, $end, $movie_list_parts,
                $chunk_index);
            $position =
                $position + 8 + $chunked_file_size + ($chunked_file_size & 1);
        }
        if ($this->stream_index < 0) {
            throw new VideoException('no video stream in this AVI file');
        }
        if ($movie_list_parts === []) {
            throw new VideoException("no 'movi' list found");
        }
        $this->buildFrameIndex($movie_list_parts, $chunk_index);
        if ($this->frame_table === []) {
            throw new VideoException('no video frames found');
        }
        $this->deriveSyncSamples();
    }
    /**
     * walkChunks reads the file from one place to another, gathering
     * where the movie list sits and where the index of frames sits. An
     * AVI is built of named chunks, some of which hold further chunks,
     * so this calls itself for each of those and stops at six levels
     * down, which is deeper than any real file goes.
     *
     * @param int $start Where in the file to start reading.
     * @param int $end Where to stop.
     * @param array $movie_list_parts Filled in with where each part of
     *     the movie list starts and ends.
     * @param array $chunk_index Filled in with where each frame sits in
     *     the file and how long it is, where the file carries an index.
     * @param int $depth How many levels of chunks deep the reading has
     *     gone, which stops it following a damaged file forever.
     */
    private function walkChunks(int $start, int $end,
        array &$movie_list_parts, &$chunk_index, int $depth = 0): void
    {
        if ($depth > 6) {
            return;
        }
        $position = $start;
        $stream_count = 0;
        while ($position + 8 <= $end) {
            $header = $this->readBytesAt($position, 8);
            if (strlen($header) < 8) {
                break;
            }
            $codec_name = substr($header, 0, 4);
            $size = self::readFourBytes($header, 4);
            $payload = $position + 8;
            if ($payload + $size > $end + 8) {
                $size = max(0, $end - $payload);
            }
            if ($codec_name === 'LIST' || $codec_name === 'RIFF') {
                $list_type = $this->readBytesAt($payload, 4);
                if ($list_type === 'movi') {
                    $movie_list_parts[] = [$payload, $payload + $size];
                } else {
                    $this->walkChunks($payload + 4, $payload
                        + $size, $movie_list_parts, $chunk_index, $depth + 1);
                }
            } elseif ($codec_name === 'strh') {
                $delta = $this->readBytesAt($payload, min(56, $size));
                if (strlen($delta) >= 40 && substr($delta, 0, 4) === 'vids'
                    && $this->stream_index < 0) {
                    $this->stream_index = $this->streams_named_so_far;
                    $this->handler_name = substr($delta, 4, 4);
                    $this->frame_rate_lower = max(1,
                        self::readFourBytes($delta, 20));
                    $this->frame_rate_upper = max(1,
                        self::readFourBytes($delta, 24));
                    $this->claimed_frame_count = self::readFourBytes($delta,
                        32);
                    $this->reading_video_format = true;
                }
                $this->streams_named_so_far++;
            } elseif ($codec_name === 'strf' && $this->reading_video_format) {
                $this->reading_video_format = false;
                $delta = $this->readBytesAt($payload, $size);
                if (strlen($delta) >= 40) {
                    $block_size = self::readFourBytes($delta, 0);
                    $this->frame_width = self::readFourBytes($delta, 4);
                    $header = self::readFourBytes($delta, 8);
                    /* a negative height means a top-down bitmap; only the */
                    /* magnitude matters here */
                    $this->frame_height = ($header >= 0x80000000) ? 0x100000000
                        - $header : $header;
                    $this->compression_name = substr($delta, 16, 4);
                    if ($block_size >= 40 && strlen($delta) > $block_size) {
                        $this->codec_setup_bytes = substr($delta, $block_size);
                    }
                }
            } elseif ($codec_name === 'idx1') {
                $chunk_index = [$payload, $size];
            }
            $position = $payload + $size + ($size & 1);
        }
    }
    /**
     * $streams_named_so_far stores how many streams the header has named so far
     * while it is being walked. A stream's number is its place in that order,
     * which is what the chunks in the movie list refer to.
     * @var int
     */
    private int $streams_named_so_far = 0;
    /**
     * $reading_video_format stores whether the stream now being walked is the
     * video one, so that the format chunk which follows its header is read
     * rather than passed over.
     * @var bool
     */
    private bool $reading_video_format = false;
    /**
     * chunkIdPrefix the two digit stream number a chunk name starts with.
     *
     * @return string what was read
     */
    private function chunkIdPrefix(): string
    {
        return sprintf('%02d', $this->stream_index);
    }
    /**
     * isVideoChunkId says whether a chunk name marks video rather than sound.
     *
     * @param string $id which one, by the number the format gives it
     * @param string $prefix the bytes a unit starts with
     * @return bool what was read
     */
    private static function isVideoChunkId(string $id, string $prefix): bool
    {
        if (substr($id, 0, 2) !== $prefix) {
            return false;
        }
        $kind = substr($id, 2, 2);
        return $kind === 'dc' || $kind === 'db';
    }
    /**
     * buildFrameIndex parts sit in the file chunks, where it has one
     *
     * @param array $movie_list_parts where the movie list's
     * @param array $chunk_index the index the file keeps of its
     */
    private function buildFrameIndex(array $movie_list_parts,
        ?array $chunk_index): void
    {
        if ($chunk_index !== null && count($movie_list_parts) === 1) {
            $frame_table = $this->framesFromIndex($chunk_index,
                $movie_list_parts[0][0]);
            if ($frame_table !== []) {
                $this->frame_table = $frame_table;
                return;
            }
        }
        foreach ($movie_list_parts as [$codec_name_position, $end]) {
            $this->scanMovi($codec_name_position + 4, $end);
        }
        if ($chunk_index !== null) {
            $this->applyIndexFlags($chunk_index);
        }
    }
    /**
     * framesFromIndex the chunk index offsets are normally measured from the
     * 'movi (the list an AVI keeps its frames in)' four-character code, but
     * some writers store absolute file offsets. The base is chosen by checking
     * which one lands on the chunk the entry claims to describe. at sit
     *
     * @return array the frames the index points
     * @param array $chunk_index the index the file keeps of its chunks
     * @param int $movie_list_name_position where the movie list's four letters
     */
    private function framesFromIndex(array $chunk_index,
        int $movie_list_name_position): array
    {
        [$offset, $size] = $chunk_index;
        $count = intdiv($size, 16);
        if ($count === 0) {
            return [];
        }
        $data = $this->readBytesAt($offset, $count * 16);
        if (strlen($data) < 16) {
            return [];
        }
        $prefix = $this->chunkIdPrefix();
        /* find the first entry belonging to the video stream to test the base
          */
        $first = null;
        for ($i = 0; $i < $count; $i++) {
            $id = substr($data, $i * 16, 4);
            if (self::isVideoChunkId($id, $prefix)) {
                $first = $i;
                break;
            }
        }
        if ($first === null) {
            return [];
        }
        $first_offset = self::readFourBytes($data, $first * 16 + 8);
        $first_id = substr($data, $first * 16, 4);
        $base = null;
        foreach ([$movie_list_name_position, 0, $movie_list_name_position -
            4] as $candidate) {
            if ($this->readBytesAt($candidate + $first_offset,
                4) === $first_id) {
                $base = $candidate;
                break;
            }
        }
        if ($base === null) {
            return [];
        }
        $frame_table = [];
        for ($i = 0; $i < $count; $i++) {
            $record = $i * 16;
            $id = substr($data, $record, 4);
            if (!self::isVideoChunkId($id, $prefix)) {
                continue;
            }
            $flags = self::readFourBytes($data, $record + 4);
            $chunk_offset = self::readFourBytes($data, $record + 8);
            $length = self::readFourBytes($data, $record + 12);
            $data_offset = $base + $chunk_offset + 8;
            if ($length === 0 || $data_offset + $length > $this->sourceSize()) {
                continue;
            }
            $frame_table[] = [$data_offset, $length, ($flags & 0x10) !== 0];
        }
        return $frame_table;
    }
    /**
     * scanMovi walk the movi (the list an AVI keeps its frames in) list
     * directly, descending into 'rec ' groupings
     *
     * @param int $start where it starts
     * @param int $end where it ends
     * @param int $depth how deep in the tree
     */
    private function scanMovi(int $start, int $end, int $depth = 0): void
    {
        $prefix = $this->chunkIdPrefix();
        $position = $start;
        while ($position + 8 <= $end) {
            $header = $this->readBytesAt($position, 8);
            if (strlen($header) < 8) {
                break;
            }
            $id = substr($header, 0, 4);
            $size = self::readFourBytes($header, 4);
            $payload = $position + 8;
            if ($id === 'LIST' && $depth < 3) {
                $this->scanMovi($payload
                    + 4, min($end, $payload + $size), $depth + 1);
            } elseif (self::isVideoChunkId($id, $prefix) && $size > 0) {
                $this->frame_table[] = [$payload, $size, false];
            }
            $position = $payload + $size + ($size & 1);
        }
    }
    /**
     * applyIndexFlags attach keyframe flags from the chunk index to a frame
     * list built by scanning
     *
     * @param array $chunk_index the index the file keeps of its chunks
     */
    private function applyIndexFlags(array $chunk_index): void
    {
        [$offset, $size] = $chunk_index;
        $count = intdiv($size, 16);
        $data = $this->readBytesAt($offset, $count * 16);
        $prefix = $this->chunkIdPrefix();
        $i = 0;
        for ($k = 0; $k < $count && $i < count($this->frame_table); $k++) {
            $id = substr($data, $k * 16, 4);
            if (!self::isVideoChunkId($id, $prefix)) {
                continue;
            }
            $flags = self::readFourBytes($data, $k * 16 + 4);
            $this->frame_table[$i][2] = ($flags & 0x10) !== 0;
            $i++;
        }
    }
    /**
     * deriveSyncSamples works out which frames can be decoded on their own.
     * An AVI may mark each frame that stands on its own, and those
     * marks are trusted where the file carries them. Where it marks
     * nothing, every frame of a motion still picture stream stands
     * on its own, and an H.264 stream is read far enough to find
     * the frames that do.
     */
    private function deriveSyncSamples(): void
    {
        $this->self_contained_frames = [];
        foreach ($this->frame_table as $i => $frame) {
            if ($frame[2]) {
                $this->self_contained_frames[] = $i;
            }
        }
        if ($this->self_contained_frames !== []) {
            return;
        }
        $kind = $this->codecKind();
        if ($kind === 'jpeg') {
            $this->self_contained_frames = array_keys($this->frame_table);
            return;
        }
        if ($kind !== 'h264') {
            return;
        }
        foreach ($this->frame_table as $i => $frame) {
            if ($this->holdsSelfContainedPicture($this->readBytesAt($frame[0],
                min($frame[1], 65536)))) {
                $this->self_contained_frames[] = $i;
            }
        }
    }
    /**
     * holdsSelfContainedPicture says whether a frame holds a picture that can
     * be decoded on its own.
     *
     * @param string $data the bytes to read
     * @return bool what was read
     */
    private function holdsSelfContainedPicture(string $data): bool
    {
        if (self::isPlainStream($data)) {
            $total = strlen($data);
            for ($i = 0; $i + 4 < $total; $i++) {
                if ($data[$i] === "\x00" && $data[$i + 1] === "\x00"
                    && $data[$i + 2] === "\x01") {
                    $type = ord($data[$i + 3]) & 0x1F;
                    if ($type === 5) {
                        return true;
                    }
                }
            }
            return false;
        }
        $level_scale = $this->avcConfig()->stream_unit_length_size;
        $position = 0;
        $total = strlen($data);
        while ($position + $level_scale < $total) {
            $length = 0;
            for ($i = 0; $i < $level_scale; $i++) {
                $length = ($length << 8) | ord($data[$position + $i]);
            }
            $position += $level_scale;
            if ($length <= 0 || $position >= $total) {
                break;
            }
            if ((ord($data[$position]) & 0x1F) === 5) {
                return true;
            }
            $position += $length;
        }
        return false;
    }
    /**
     * avcConfig the H.264 setup record the stream header carries, parsed once.
     *
     * @return AvcConfig what was read
     */
    private function avcConfig(): AvcConfig
    {
        if ($this->h264_settings === null) {
            $this->h264_settings
                = ($this->codec_setup_bytes !== '' && ord($this
                    ->codec_setup_bytes[0]) === 1)
                ? AvcConfig::readSettings($this->codec_setup_bytes)
                : new AvcConfig();
        }
        return $this->h264_settings;
    }
    /**
     * durationSeconds works out how long the video runs.
     *
     * @return float what was read
     */
    public function durationSeconds(): float
    {
        return count($this->frame_table) * $this->frame_rate_lower / $this
            ->frame_rate_upper;
    }
    /**
     * syncSamples positions of the frames that can be decoded on their own.
     *
     * @return array what was read
     */
    public function syncSamples(): array
    {
        return $this->self_contained_frames;
    }
    /**
     * sampleTime works out when a frame is shown, in seconds from the start.
     * AVI stores no times, so this is the frame position divided by the rate.
     *
     * @param int $index position of the sample in decode order
     * @return float what was read
     */
    public function sampleTime(int $index): float
    {
        return $index * $this->frame_rate_lower / $this->frame_rate_upper;
    }
    /**
     * sampleData the stored bytes of one frame.
     *
     * @param int $index position of the sample in decode order
     * @return string what was read
     */
    public function sampleData(int $index): string
    {
        if (!isset($this->frame_table[$index])) {
            throw new VideoException("no frame at index $index");
        }
        [$offset, $size] = $this->frame_table[$index];
        return $this->readBytesAt($offset, $size);
    }
    /**
     * containerName the container format this reader handles.
     *
     * @return string what was read
     */
    public function containerName(): string
    {
        return 'AVI';
    }
    /**
     * codecName the codec name as the file spells it.
     *
     * @return string what was read
     */
    public function codecName(): string
    {
        $chunk = trim($this->compression_name);
        return $chunk !== '' ? $chunk : trim($this->handler_name);
    }
    /**
     * codecKind works out which decoder handles this stream.
     *
     * @return string what was read
     */
    public function codecKind(): string
    {
        $chunk = strtoupper(trim($this->compression_name));
        if ($chunk === '' || $chunk === "\x00\x00\x00\x00") {
            $chunk = strtoupper(trim($this->handler_name));
        }
        if (in_array($chunk,
            ['H264', 'X264', 'AVC1', 'DAVC', 'VSSH', 'H.264', 'GAVC'], true)) {
            return 'h264';
        }
        if (in_array($chunk,
            ['MJPG', 'MJPA', 'MJPB', 'JPEG', 'AVRN', 'DMB1', 'ADJV'], true)) {
            return 'jpeg';
        }
        return $this->codecName();
    }
    /**
     * frameWidth width of the picture in samples.
     *
     * @return int what was read
     */
    public function frameWidth(): int
    {
        return $this->frame_width;
    }
    /**
     * frameHeight height of the picture in samples.
     *
     * @return int what was read
     */
    public function frameHeight(): int
    {
        return $this->frame_height;
    }
    /**
     * frameCount works out how many frames the video holds.
     *
     * @return int what was read
     */
    public function frameCount(): int
    {
        return count($this->frame_table);
    }
    /**
     * settingsUnits the H.264 parameter sets the stream header carries.
     *
     * @return array what was read
     */
    public function settingsUnits(): array
    {
        $chunk = $this->avcConfig();
        return array_values(array_filter([$chunk->sequence_settings,
            $chunk->picture_settings]));
    }
    /**
     * toPlainStream turns a stored H.264 frame into a stream of start coded
     * units.
     *
     * @param string $sample the stored bytes of one sample
     * @return string what was read
     */
    public function toPlainStream(string $sample): string
    {
        if ($this->codec_setup_bytes !== '' && self::isPlainStream($this
            ->codec_setup_bytes)) {
            /* Some writers keep the settings as a raw stream in the
               form H.264 takes on its own, which is called Annex-B. */
            return $this->codec_setup_bytes . $sample;
        }
        return self::plainStreamFrom($this->avcConfig(), $sample);
    }
}
X