<?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 a Matroska or WebM file. It finds the video track and
* the frames that stand on their own.
*/
namespace seekquarry\yioop\library\av_processing;
/**
* WebmExtractor reads a Matroska or WebM file, finding the video track and
* the frames that stand on their own.
*/
final class WebmExtractor 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;
/**
* $clock_units_per_second stores how many thousand-millionths of a second
* one tick
* of the file's clock stands for. Every time in the file is in ticks, so
* this turns them into seconds. Read by durationSeconds(), frameRate(),
* sampleTime().
* @var int
*/
private int $clock_units_per_second = 1000000;
/**
* $file_length_ticks stores how long the file runs, in those ticks, as its
* header says. Where the header leaves it out, the last frame's time is
* used instead.
* @var float
*/
private float $file_length_ticks = 0.0;
/**
* $track_number stores which track of the file carries the video. Every
* block names the track it belongs to, so blocks of any other track are
* passed over.
* @var int
*/
private int $track_number = -1;
/**
* $codec_name stores the name the file gives the codec, such as the one it
* uses for VP9 or for a the MPEG-4 family of formats/ISO/AVC. codecKind()
* reads it to decide which decoder a frame goes to.
* @var string
*/
private string $codec_name = '';
/**
* $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
*/
private 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
*/
private int $frame_height = 0;
/**
* $codec_setup_bytes stores the settings the file keeps beside the track
* for its codec. For H.264 and HEVC a decoder needs them before it can read
* a frame.
* @var string
*/
private string $codec_setup_bytes = '';
/**
* $default_frame_length stores how long a frame lasts where the file does
* not say otherwise, in ticks. frameRate() turns it into frames a second.
* @var float
*/
private float $default_frame_length = 0.0;
/**
* $frame_table stores where each frame sits in the file, when it is shown,
* and whether it stands on its own. This is what a seek walks. Read by
* durationSeconds(), frameCount(), frameRate().
* @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 is decoded from one of
* these.
* @var array
*/
private array $self_contained_frames = [];
/**
* $h264_settings stores the H.264 settings settings, or nothing where there
* is none.
* @var AvcConfig
*/
private ?AvcConfig $h264_settings = null;
/**
* $vp8 stores the vp8, or nothing where there is none.
* @var Vp8Decoder
*/
private ?Vp8Decoder $vp8 = null;
/**
* $vp9 stores the vp9, or nothing where there is none.
* @var Vp9Header
*/
private ?Vp9Header $vp9 = null;
/**
* ID_SEGMENT is the number naming the part of the file that holds
* everything else: the tracks, the timing and the frames.
* @var mixed
*/
private const ID_SEGMENT = 0x18538067;
/**
* ID_INFO is the number naming the part that carries the file's clock and
* how long it runs.
* @var mixed
*/
private const ID_INFO = 0x1549A966;
/**
* ID_TIMECODE_SCALE is the number naming the field that says how long one
* tick of that clock is.
* @var mixed
*/
private const ID_TIMECODE_SCALE = 0x2AD7B1;
/**
* ID_DURATION is the number naming the field that says how many ticks the
* file runs for.
* @var mixed
*/
private const ID_DURATION = 0x4489;
/**
* ID_TRACKS is the number naming the part that lists the file's tracks.
* @var mixed
*/
private const ID_TRACKS = 0x1654AE6B;
/**
* ID_TRACK_ENTRY is the number naming one track within that list.
* @var mixed
*/
private const ID_TRACK_ENTRY = 0xAE;
/**
* ID_TRACK_NUMBER is the number naming the field that gives a track its
* number, which every frame refers back to.
* @var mixed
*/
private const ID_TRACK_NUMBER = 0xD7;
/**
* ID_TRACK_TYPE is the number naming the field that says whether a track
* carries video, sound or something else.
* @var mixed
*/
private const ID_TRACK_TYPE = 0x83;
/**
* ID_CODEC_ID is the number naming the field that says which codec a track
* uses.
* @var mixed
*/
private const ID_CODEC_ID = 0x86;
/**
* ID_CODEC_PRIVATE is the number naming the field that carries the settings
* a codec needs before a frame can be read.
* @var mixed
*/
private const ID_CODEC_PRIVATE = 0x63A2;
/**
* ID_DEFAULT_FRAME_LENGTH is the number naming the field that says how
* long a frame
* lasts where the file does not say otherwise.
* @var mixed
*/
private const ID_DEFAULT_FRAME_LENGTH = 0x23E383;
/**
* ID_VIDEO is the number naming the part of a track that describes its
* picture.
* @var mixed
*/
private const ID_VIDEO = 0xE0;
/**
* ID_PIXEL_WIDTH is the number naming the field that gives a frame's width.
* @var mixed
*/
private const ID_PIXEL_WIDTH = 0xB0;
/**
* ID_PIXEL_HEIGHT is the number naming the field that gives a frame's
* height.
* @var mixed
*/
private const ID_PIXEL_HEIGHT = 0xBA;
/**
* ID_CLUSTER is the number naming a group of frames that share a starting
* time.
* @var mixed
*/
private const ID_CLUSTER = 0x1F43B675;
/**
* ID_TIMECODE is the number naming the field that gives that starting time.
* @var mixed
*/
private const ID_TIMECODE = 0xE7;
/**
* ID_SIMPLE_BLOCK is the number naming a frame written on its own, which is
* how most video frames are stored.
* @var mixed
*/
private const ID_SIMPLE_BLOCK = 0xA3;
/**
* ID_BLOCK_GROUP is the number naming a frame written with extra fields
* around it, such as how long it lasts.
* @var mixed
*/
private const ID_BLOCK_GROUP = 0xA0;
/**
* ID_BLOCK is the number naming the frame inside such a group.
* @var mixed
*/
private const ID_BLOCK = 0xA1;
/**
* __construct opens a Matroska or WebM file and indexes its video track.
* The element tree is walked once so that the byte range, time and keyframe
* flag of every frame are known before anything is decoded.
*
* @param string $path file to read
*/
public function __construct(string $path)
{
$this->openSource($path);
$this->readSettings();
}
/**
* readVariableLength read a variable-length integer. marker]
*
* @return array [value, bytes consumed, all-ones
* @param int $off how far into the file to read from
* @param bool $keep_marker whether the packing markers are left in
*/
private function readVariableLength(int $off, bool $keep_marker): array
{
$first = $this->readBytesAt($off, 1);
if ($first === '') {
throw new VideoException('unexpected end of file in EBML');
}
$right_zero = ord($first);
if ($right_zero === 0) {
throw new VideoException('invalid EBML length');
}
$length = 1;
$mask = 0x80;
while (($right_zero & $mask) === 0) {
$mask >>= 1;
$length++;
}
$bytes = $this->readBytesAt($off, $length);
if (strlen($bytes) < $length) {
throw new VideoException('truncated EBML element');
}
$value = $keep_marker ? $right_zero : ($right_zero & ($mask - 1));
$unknown = !$keep_marker && ($right_zero & ($mask - 1)) === ($mask - 1);
for ($i = 1; $i < $length; $i++) {
$value = ($value << 8) | ord($bytes[$i]);
if (ord($bytes[$i]) !== 0xFF) {
$unknown = false;
}
}
return [$value, $length, $unknown];
}
/**
* readWholeNumberFrom reads a whole number stored most significant byte
* first.
*
* @param string $source the file or bytes being read
* @return int what was read
*/
private static function readWholeNumberFrom(string $source): int
{
$variant = 0;
for ($i = 0, $total = strlen($source); $i < $total; $i++) {
$variant = ($variant << 8) | ord($source[$i]);
}
return $variant;
}
/**
* floatOf reads a floating point number of four or eight bytes.
*
* @param string $source the file or bytes being read
* @return float what was read
*/
private static function floatOf(string $source): float
{
if (strlen($source) === 4) {
return unpack('G', $source)[1];
}
if (strlen($source) === 8) {
return unpack('E', $source)[1];
}
return 0.0;
}
/**
* readSettings walks the file and gathers the track and its frames.
*/
private function readSettings(): void
{
$this->walkChunks(0, $this->sourceSize(), 0);
if ($this->track_number < 0) {
throw new VideoException('no video track in this WebM file');
}
if ($this->frame_table === []) {
throw new VideoException('no video frames in this WebM file');
}
usort($this->frame_table, static fn(array $amount, array $bits): int
=> $amount[2] <=> $bits[2]);
foreach ($this->frame_table as $i => $field) {
if ($field[3]) {
$this->self_contained_frames[] = $i;
}
}
}
/**
* walkChunks walks one level of the element tree, descending where it must.
*
* @param int $start where it starts
* @param int $end where it ends
* @param int $depth how deep in the tree
* @param int $cluster_time when the cluster this block belongs to starts
*/
private function walkChunks(int $start, int $end, int $depth,
int $cluster_time
= 0): void
{
if ($depth > 8) {
return;
}
$position = $start;
while ($position < $end && $position < $this->sourceSize()) {
[$id, $id_length] = $this->readVariableLength($position, true);
[$size, $size_length, $unknown] = $this
->readVariableLength($position + $id_length,
false);
$payload = $position + $id_length + $size_length;
if ($unknown) {
/* an element of unknown length runs to the end of its parent */
$size = $end - $payload;
}
$next = $payload + $size;
if ($size < 0 || $payload > $this->sourceSize()) {
break;
}
switch ($id) {
case self::ID_SEGMENT:
case self::ID_TRACKS:
case self::ID_TRACK_ENTRY:
case self::ID_INFO:
case self::ID_VIDEO:
$this->walkChunks($payload, min($end, $next), $depth + 1);
break;
case self::ID_CLUSTER:
$this->walkCluster($payload, min($end, $next), $depth + 1);
break;
case self::ID_TIMECODE_SCALE:
$this->clock_units_per_second
= max(1, self::readWholeNumberFrom($this
->readBytesAt($payload, $size)));
break;
case self::ID_DURATION:
$this->file_length_ticks
= self::floatOf($this->readBytesAt($payload, $size));
break;
case self::ID_TRACK_NUMBER:
$this->pending_track_number
= self::readWholeNumberFrom($this
->readBytesAt($payload, $size));
break;
case self::ID_TRACK_TYPE:
$this->pending_track_type
= self::readWholeNumberFrom($this
->readBytesAt($payload, $size));
break;
case self::ID_CODEC_ID:
$this->pending_codec_name
= rtrim($this->readBytesAt($payload, $size), "\x00");
break;
case self::ID_CODEC_PRIVATE:
$this->pending_codec_setup_bytes = $this
->readBytesAt($payload,
$size);
break;
case self::ID_DEFAULT_FRAME_LENGTH:
$this->pending_frame_length
= (float) self::readWholeNumberFrom($this
->readBytesAt($payload, $size));
break;
case self::ID_PIXEL_WIDTH:
$this->pending_frame_width
= self::readWholeNumberFrom($this
->readBytesAt($payload, $size));
break;
case self::ID_PIXEL_HEIGHT:
$this->pending_frame_height
= self::readWholeNumberFrom($this
->readBytesAt($payload, $size));
break;
}
/* a TrackEntry ends: keep it if it is the first video track */
if ($id === self::ID_TRACK_ENTRY) {
if ($this->pending_track_type === 1 &&
$this->track_number < 0) {
$this->track_number = $this->pending_track_number;
$this->codec_name = $this->pending_codec_name;
$this->codec_setup_bytes = $this->pending_codec_setup_bytes;
$this->frame_width = $this->pending_frame_width;
$this->frame_height = $this->pending_frame_height;
$this->default_frame_length = $this->pending_frame_length;
}
$this->pending_track_type = 0;
$this->pending_track_number = -1;
$this->pending_codec_name = '';
$this->pending_codec_setup_bytes = '';
$this->pending_frame_width = 0;
$this->pending_frame_height = 0;
$this->pending_frame_length = 0.0;
}
if ($next <= $position) {
break;
}
$position = $next;
}
}
/**
* $pending_track_number stores the track being read while the file's header
* is walked. A track's fields arrive one at a time, so they are gathered
* here and kept only if the track turns out to be the video one.
* @var int
*/
private int $pending_track_number = -1;
/**
* $pending_track_type stores whether the track being walked
* carries video, sound or something else. A track's fields arrive
* one at a time, so they are gathered here and kept only if the
* track turns out to be the video one.
* @var int
*/
private int $pending_track_type = 0;
/**
* $pending_codec_name stores that track's codec name, held until the track
* is known to be the one wanted.
* @var string
*/
private string $pending_codec_name = '';
/**
* $pending_codec_setup_bytes stores the codec settings of the track
* being walked, kept until the track is known to be the video
* one. A track's fields arrive one at a time. The
* same way.
* @var string
*/
private string $pending_codec_setup_bytes = '';
/**
* $pending_frame_width stores the frame width of the track being
* walked, kept until that track is known to be the video one. Way.
* @var int
*/
private int $pending_frame_width = 0;
/**
* $pending_frame_height stores the frame height of the track being
* walked, kept until that track is known to be the video one. S
* way.
* @var int
*/
private int $pending_frame_height = 0;
/**
* $pending_frame_length stores how long a frame of the track being
* walked lasts, kept until that track is known to be the video
* one. S
* way.
* @var float
*/
private float $pending_frame_length = 0.0;
/**
* walkCluster walks a cluster, which holds the frames and their times.
*
* @param int $start where it starts
* @param int $end where it ends
* @param int $depth how deep in the tree
*/
private function walkCluster(int $start, int $end, int $depth): void
{
$cluster_time = 0;
$position = $start;
while ($position < $end && $position < $this->sourceSize()) {
[$id, $id_length] = $this->readVariableLength($position, true);
[$size, $size_length, $unknown] = $this
->readVariableLength($position + $id_length,
false);
$payload = $position + $id_length + $size_length;
if ($unknown) {
$size = $end - $payload;
}
$next = $payload + $size;
if ($id === self::ID_TIMECODE) {
$cluster_time = self::readWholeNumberFrom($this
->readBytesAt($payload, $size));
} elseif ($id === self::ID_SIMPLE_BLOCK) {
$this->readBlock($payload, $size, $cluster_time, null);
} elseif ($id === self::ID_BLOCK_GROUP) {
/* a Block inside a BlockGroup carries no keyframe flag of its
*/
/* own; the absence of a ReferenceBlock is what marks it */
$inner = $payload;
$block_at = null;
$block_size = 0;
$has_reference = false;
while ($inner < min($end, $next)) {
[$item_id, $item_id_length] = $this
->readVariableLength($inner,
true);
[$item_size, $item_size_length] = $this
->readVariableLength($inner + $item_id_length,
false);
$inner_payload = $inner + $item_id_length +
$item_size_length;
if ($item_id === self::ID_BLOCK) {
$block_at = $inner_payload;
$block_size = $item_size;
/* ReferenceBlock */
} elseif ($item_id === 0xFB) {
$has_reference = true;
}
$inner = $inner_payload + $item_size;
if ($item_size < 0) {
break;
}
}
if ($block_at !== null) {
$this->readBlock(
$block_at, $block_size, $cluster_time, !$has_reference);
}
}
if ($next <= $position) {
break;
}
$position = $next;
}
}
/** @param bool|null $keyframe null means take the flag from the block
header */
/**
* readBlock records one frame's byte range, time and keyframe flag.
*
* @param int $offset where in the file to start reading
* @param int $size how many bytes
* @param int $cluster_time when the cluster this block belongs to starts
* @param bool $keyframe whether this frame stands on its own
*/
private function readBlock(int $offset, int $size, int $cluster_time,
?bool $keyframe): void
{
if ($size < 4) {
return;
}
[$track, $track_length] = $this->readVariableLength($offset, false);
if ($track !== $this->track_number) {
return;
}
$head = $this->readBytesAt($offset + $track_length, 3);
if (strlen($head) < 3) {
return;
}
$relative = (ord($head[0]) << 8) | ord($head[1]);
if ($relative >= 0x8000) {
$relative -= 0x10000;
}
$flags = ord($head[2]);
$is_key = $keyframe ?? (($flags & 0x80) !== 0);
$lacing = ($flags >> 1) & 3;
$data_start = $offset + $track_length + 3;
$data_end = $offset + $size;
if ($lacing !== 0) {
/* laced blocks pack several frames together; only the first is */
/* needed for a thumbnail and only keyframes are ever decoded */
$data_start += 1;
}
if ($data_end <= $data_start) {
return;
}
$this->frame_table[]
= [$data_start, $data_end - $data_start, $cluster_time
+ $relative, $is_key];
}
/**
* frameRate frames a second, from the track's stated frame duration if it
* gives one, otherwise from the spread of frame times.
*
* @return float what was read
*/
private function frameRate(): float
{
if ($this->default_frame_length > 0) {
return 1e9 / $this->default_frame_length;
}
if (count($this->frame_table) > 1) {
$span = ($this->frame_table[count($this->frame_table) - 1][2]
- $this->frame_table[0][2]) * $this
->clock_units_per_second / 1e9;
if ($span > 0) {
return (count($this->frame_table) - 1) / $span;
}
}
return 25.0;
}
/**
* durationSeconds works out how long the video runs.
*
* @return float what was read
*/
public function durationSeconds(): float
{
if ($this->file_length_ticks > 0) {
return $this->file_length_ticks * $this
->clock_units_per_second / 1e9;
}
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
{
if (!isset($this->frame_table[$index])) {
return 0.0;
}
return $this->frame_table[$index][2] * $this
->clock_units_per_second / 1e9;
}
/**
* 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->readBytesAt(
$this->frame_table[$index][0], $this->frame_table[$index][1]);
}
/**
* codecName the codec name as the file spells it.
*
* @return string what was read
*/
public function codecName(): string
{
return $this->codec_name;
}
/**
* containerName webM when the track carries a codec that format allows,
* Matroska otherwise.
*
* @return string what was read
*/
public function containerName(): string
{
/* WebM is the subset of Matroska carrying VP8, VP9 or AV1 */
return in_array($this->codec_name, ['V_VP8', 'V_VP9', 'V_AV1'], true)
? 'WebM' : 'Matroska';
}
/**
* vp9Header the VP9 header of the first keyframe, parsed on demand
*
* @return Vp9Header what was read
*/
public function vp9Header(): Vp9Header
{
if ($this->vp9 === null) {
$self_contained_frames = $this->syncSamples();
if ($self_contained_frames === []) {
throw new VideoException('no VP9 keyframe to inspect');
}
$this->vp9 = Vp9Header::readSettings($this
->sampleData($self_contained_frames[0]));
}
return $this->vp9;
}
/**
* codecKind works out which decoder handles this track.
*
* @return string what was read
*/
public function codecKind(): string
{
if ($this->codec_name === 'V_VP8') {
return 'vp8';
}
if ($this->codec_name === 'V_VP9') {
return 'vp9';
}
if ($this->codec_name === 'V_MPEGH/ISO/HEVC') {
return 'hevc';
}
/* Matroska also carries H.264, whose decoder is already here */
if ($this->codec_name === 'V_MPEG4/ISO/AVC') {
return 'h264';
}
return $this->codec_name;
}
/**
* avcConfig the H.264 setup record the container 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;
}
/**
* 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 container carries, if any.
*
* @return array what was read
*/
public function settingsUnits(): array
{
if ($this->codecKind() === 'hevc') {
return HevcParamParser::settingsFromSetupRecord($this
->codecPrivate());
}
$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->codecKind() === 'hevc') {
$settings = new AvcConfig();
$record = $this->codecPrivate();
$settings->stream_unit_length_size = (strlen($record) < 22) ?
4 : (ord($record[21]) & 3) + 1;
return self::plainStreamFrom($settings, $sample);
}
if ($this->codecKind() !== 'h264') {
throw new VideoException(
"'{$this->codec_name}' is not an H.264 stream");
}
return self::plainStreamFrom($this->avcConfig(), $sample);
}
/**
* decodeVp8 decodes one VP8 keyframe into a picture.
*
* @param string $frame the stored bytes of one frame
* @return VideoPicture what was read
*/
public function decodeVp8(string $frame): VideoPicture
{
if ($this->vp8 === null) {
$this->vp8 = new Vp8Decoder();
}
return $this->vp8->decodeKeyframe($frame);
}
/**
* decodeVp9 decodes one VP9 keyframe into a picture.
*
* @param string $frame the stored bytes of one frame
* @return VideoPicture what was read
*/
public function decodeVp9(string $frame): VideoPicture
{
$header = Vp9Header::readSettings($frame);
$decoder = new Vp9Entropy($header, $frame);
$decoder->decodePicture();
return $decoder->toPicture();
}
/**
* unsupportedDetail extra words describing a codec this reader can index
* but not decode, so the refusal names what was found.
*
* @return string what was read
*/
protected function unsupportedDetail(): string
{
if ($this->codec_name !== 'V_VP9') {
return '';
}
try {
return ' (' . $this->vp9Header()->describe() . ')';
} catch (Throwable $entry) {
return '';
}
}
/**
* codecPrivate the codec's private setup data as the container stores it.
*
* @return string what was read
*/
public function codecPrivate(): string
{
return $this->codec_setup_bytes;
}
/**
* 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));
}
}