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