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