/ src / library / av_processing / OggExtractor.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 Ogg file. It finds the video stream, gathers its
 * pages, and gives the Theora frames they carry.
 */
namespace seekquarry\yioop\library\av_processing;
/**
 * OggReader ogg container reader. An Ogg file is a sequence of pages, each
 * belonging to a logical stream identified by a serial number. A page carries a
 * segment table; a run of 255-byte segments continues a packet and any shorter
 * segment ends one, so packets can span pages in either direction. Only the
 * byte ranges of each packet are kept, not the bytes themselves, so indexing a
 * long file costs a few integers per frame rather than its payload.
 */
final class OggReader
{
    use ByteSource;
    /**
     * $pages_block_y_stream stores the pages of each stream in the file, kept
     * by the
     * number the file gives that stream. An Ogg may carry sound and video
     * together, so the two are read apart.
     * @var array
     */
    private array $pages_block_y_stream = [];
    /**
     * $stream_numbers_in_order stores the stream numbers in the order they
     * first appear, so the video stream can be found without guessing.
     * @var array
     */
    private array $stream_numbers_in_order = [];
    /**
     * __construct opens an Ogg file and walks its pages so that every packet's
     * byte range is known.
     *
     * @param string $path file to read
     */
    public function __construct(string $path)
    {
        $this->openSource($path);
        $this->scanPages();
    }
    /**
     * serials gives the numbers of the streams the file carries, in the order
     * they first appear.
     *
     * @return array serial numbers, in the order the streams start
     */
    public function serials(): array
    {
        return $this->stream_numbers_in_order;
    }
    /**
     * packets gathers every packet of one stream, in the order the
     * file wrote them. An Ogg file cuts its streams into pages and
     * may spread one packet over several pages, so this puts the
     * pieces back together before a decoder sees them.
     *
     * @param int $video_stream_number The number the file gives the
     *     stream to gather.
     * @return array The packets of that stream, in order.
     */
    public function packets(int $video_stream_number): array
    {
        return $this->pages_block_y_stream[$video_stream_number] ?? [];
    }
    /**
     * packetData gathers a packet from the stretches of the file its parts sit
     * in. An Ogg page may carry part of a packet, so a packet is often spread
     * over several pages
     *
     * @param array $packet the packet read off the stream
     * @return string what was read
     */
    public function packetData(array $packet): string
    {
        $written = '';
        foreach ($packet['ranges'] as [$off, $length]) {
            $written .= $this->readBytesAt($off, $length);
        }
        return $written;
    }
    /**
     * packetPrefix the first bytes of a packet, without reading all of it
     *
     * @param array $packet the packet read off the stream
     * @param int $length how many bytes to read
     * @return string what was read
     */
    public function packetPrefix(array $packet, int $length): string
    {
        $written = '';
        foreach ($packet['ranges'] as [$off, $length]) {
            $written .= $this->readBytesAt($off, min($length, $length -
                strlen($written)));
            if (strlen($written) >= $length) {
                break;
            }
        }
        return $written;
    }
    /**
     * scanPages walks the file's pages and records where each packet sits.
     */
    private function scanPages(): void
    {
        $position = 0;
        /* serial => ranges of the packet still being assembled */
        $partial = [];
        while ($position + 27 <= $this->sourceSize()) {
            $head = $this->readBytesAt($position, 27);
            if (strlen($head) < 27) {
                break;
            }
            if (substr($head, 0, 4) !== 'OggS') {
                /* resynchronise on the next capture pattern */
                $next = $this->findCapture($position + 1);
                if ($next === null) {
                    break;
                }
                $position = $next;
                continue;
            }
            $header_type = ord($head[5]);
            $eight = substr($head, 6, 8);
            $low = unpack('V', substr($eight, 0, 4))[1];
            $high = unpack('V', substr($eight, 4, 4))[1];
            $granule = $low | ($high << 32);
            if ($high & 0x80000000) {
                $granule = $granule - (1 << 64);
            }
            $video_stream_number = unpack('V', substr($head, 14, 4))[1];
            $segment_count = ord($head[26]);
            $table = $this->readBytesAt($position + 27, $segment_count);
            if (strlen($table) < $segment_count) {
                break;
            }
            $data_start = $position + 27 + $segment_count;
            if (!isset($this->pages_block_y_stream[$video_stream_number])) {
                $this->pages_block_y_stream[$video_stream_number] = [];
                $this->stream_numbers_in_order[] = $video_stream_number;
            }
            if (($header_type & 0x01) === 0) {
                /* a fresh page that does not continue a packet */
                $partial[$video_stream_number] = [];
            }
            $offset = $data_start;
            $current = $partial[$video_stream_number] ?? [];
            $page_total = 0;
            for ($i = 0; $i < $segment_count; $i++) {
                $length = ord($table[$i]);
                $page_total += $length;
                if ($length > 0) {
                    $current[] = [$offset, $length];
                    $offset += $length;
                }
                if ($length < 255) {
                    /* a segment shorter than 255 bytes ends the packet */
                    $this->pages_block_y_stream[$video_stream_number][] = [
                        'ranges'  => $current,
                        'granule' => -1,
                    ];
                    $current = [];
                }
            }
            $partial[$video_stream_number] = $current;
            /* the granule position on a page belongs to the last packet that */
            /* finishes on it */
            $pages = $this->pages_block_y_stream[$video_stream_number];
            if ($current === [] && $pages !== []) {
                $last = count($pages) - 1;
                $this->pages_block_y_stream[$video_stream_number][$last]
                    ['granule'] = $granule;
            }
            $position = $data_start + $page_total;
        }
    }
    /**
     * findCapture finds the next page marker in the file, used to pick the
     * stream back up after damage.
     *
     * @param int $from where it is read from
     * @return int what was read
     */
    private function findCapture(int $from): ?int
    {
        $chunk_size = 65536;
        $position = $from;
        while ($position < $this->sourceSize()) {
            $bytes = $this->readBytesAt($position, $chunk_size);
            if ($bytes === '') {
                return null;
            }
            $hit = strpos($bytes, 'OggS');
            if ($hit !== false) {
                return $position + $hit;
            }
            /* keep three bytes of overlap so a split capture pattern is found
              */
            $position += max(1, strlen($bytes) - 3);
        }
        return null;
    }
}

/**
 * OggExtractor reads an Ogg file, gathering the pages of its video stream
 * and the frames they carry.
 */
final class OggExtractor extends VideoExtractor
{
    /**
     * $page_reader stores the reader that walks the file's pages. Every packet
     * handed to the decoder comes through it.
     * @var OggReader
     */
    private OggReader $page_reader;
    /**
     * $theora_decoder stores the Theora decoder, made once the stream's headers
     * have been read. Read by decodeTheora(), frameHeight(), frameRate().
     * @var TheoraDecoder
     */
    private TheoraDecoder $theora_decoder;
    /**
     * $video_stream_number stores which stream of the file the video is, by the
     * number the file gives it. Pages of any other stream are passed over.
     * @var int
     */
    private int $video_stream_number = -1;
    /**
     * $frame_table stores where each frame's packet sits and when it is shown.
     * Read by durationSeconds(), frameCount(), sampleData().
     * @var array
     */
    private array $frame_table = [];
    /**
     * $self_contained_frames stores which of those frames stand on their own. A
     * thumbnail is decoded from one of these, so a moment asked for picks the
     * nearest before it.
     * @var array
     */
    private array $self_contained_frames = [];
    /**
     * __construct opens an Ogg file and indexes its Theora stream. The pages
     * are read once so that the byte range of every packet is known, which
     * frame each packet holds, and which of those are keyframes.
     *
     * @param string $path file to read
     */
    public function __construct(string $path)
    {
        $this->page_reader = new OggReader($path);
        $this->theora_decoder = new TheoraDecoder();
        foreach ($this->page_reader->serials() as $video_stream_number) {
            $packets = $this->page_reader->packets($video_stream_number);
            if ($packets === []) {
                continue;
            }
            if ($this->page_reader->packetPrefix($packets[0],
                7) === "\x80theora") {
                $this->video_stream_number = $video_stream_number;
                break;
            }
        }
        if ($this->video_stream_number < 0) {
            throw new VideoException('no Theora video stream in this Ogg file');
        }
        foreach ($this->page_reader->packets($this
            ->video_stream_number) as $packet) {
            $first = $this->page_reader->packetPrefix($packet, 1);
            if ($first === '') {
                continue;
            }
            if ((ord($first) & 0x80) !== 0) {
                $this->theora_decoder->addHeader($this->page_reader
                    ->packetData($packet));
                continue;
            }
            /* a data packet: bit 6 of the first byte distinguishes a keyframe
              */
            if ((ord($first) & 0x40) === 0) {
                $this->self_contained_frames[] = count($this->frame_table);
            }
            $this->frame_table[] = $packet;
        }
        if (!$this->theora_decoder->isReady()) {
            throw new VideoException(
                'Theora headers are missing or incomplete');
        }
        if ($this->frame_table === []) {
            throw new VideoException('no Theora frames in this file');
        }
    }
    /**
     * frameRate frames a second, taken from the stream's identification header.
     *
     * @return float what was read
     */
    private function frameRate(): float
    {
        $den = $this->theora_decoder->frame_rate_den;
        return $den > 0 ? $this->theora_decoder->frame_rate_count / $den : 25.0;
    }
    /**
     * durationSeconds works out how long the video runs.
     *
     * @return float what was read
     */
    public function durationSeconds(): float
    {
        return count($this->frame_table) / $this->frameRate();
    }
    /**
     * 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.
     *
     * @param int $index position of the sample in decode order
     * @return float what was read
     */
    public function sampleTime(int $index): float
    {
        return $index / $this->frameRate();
    }
    /**
     * 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");
        }
        return $this->page_reader->packetData($this->frame_table[$index]);
    }
    /**
     * codecKind works out which decoder handles this stream.
     *
     * @return string what was read
     */
    public function codecKind(): string
    {
        return 'theora';
    }
    /**
     * codecName the codec name as the file spells it.
     *
     * @return string what was read
     */
    public function codecName(): string
    {
        return 'theora';
    }
    /**
     * containerName the container format this reader handles.
     *
     * @return string what was read
     */
    public function containerName(): string
    {
        return 'Ogg';
    }
    /**
     * frameWidth width of the picture in samples.
     *
     * @return int what was read
     */
    public function frameWidth(): int
    {
        return $this->theora_decoder->picture_w;
    }
    /**
     * frameHeight height of the picture in samples.
     *
     * @return int what was read
     */
    public function frameHeight(): int
    {
        return $this->theora_decoder->picture_h;
    }
    /**
     * frameCount works out how many frames the video holds.
     *
     * @return int what was read
     */
    public function frameCount(): int
    {
        return count($this->frame_table);
    }
    /**
     * settingsUnits nothing, since Theora carries its setup in its own headers.
     *
     * @return array what was read
     */
    public function settingsUnits(): array
    {
        return [];
    }
    /**
     * toPlainStream refuses, since Theora is not an H.264 stream.
     *
     * @param string $sample the stored bytes of one sample
     * @return string what was read
     */
    public function toPlainStream(string $sample): string
    {
        throw new VideoException('Theora is not an H.264 stream');
    }
    /**
     * decodeTheora decodes one Theora keyframe into a picture.
     *
     * @param string $packet the stored bytes of one packet
     * @return VideoPicture what was read
     */
    public function decodeTheora(string $packet): VideoPicture
    {
        return $this->theora_decoder->decodeIntra($packet);
    }
}
X