<?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 says what every container reader can be asked for. Its open
* method looks at a file's first bytes and hands back the reader for that
* kind of file.
*/
namespace seekquarry\yioop\library\av_processing;
/**
* Everything the thumbnail logic needs from a container, and the thumbnail
* logic itself.
*
* A container implementation has to answer four questions: how long is the
* video, which samples can be decoded on their own, when does a given sample
* appear, and what are its bytes. Everything above that -- picking a keyframe
* for a timestamp, decoding it, scaling it, assembling an animation -- is
* shared.
*/
/**
* VideoExtractor says what every container reader can be asked for, and its
* open method hands back the reader for whichever kind of file it is given.
*/
abstract class VideoExtractor
{
/**
* durationSeconds presentation length in seconds.
*
* @return float what was read
*/
abstract public function durationSeconds(): float;
/**
* syncSamples indices of samples that can be decoded without any
* predecessor.
*
* @return array what was read
*/
abstract public function syncSamples(): array;
/**
* sampleTime presentation time of a sample, in seconds.
*
* @param int $index the index
* @return float what was read
*/
abstract public function sampleTime(int $index): float;
/**
* sampleData the stored bytes of a sample.
*
* @param int $index the index
* @return string what was read
*/
abstract public function sampleData(int $index): string;
/**
* codecKind 'h264', 'the still picture format', or anything else for a
* codec that cannot be decoded.
*
* @return string what was read
*/
abstract public function codecKind(): string;
/**
* codecName the codec identifier as the container spells it, for error
* messages.
*
* @return string what was read
*/
abstract public function codecName(): string;
/**
* containerName the container format, for reporting.
*
* @return string what was read
*/
abstract public function containerName(): string;
/**
* describeFile everything a caller needs to describe the video without
* decoding it.
*
* @return array what was read
*/
public function describeFile(): array
{
$self_contained_frames = $this->syncSamples();
return [
'container' => $this->containerName(),
'codec' => $this->codecName(),
'width' => $this->frameWidth(),
'height' => $this->frameHeight(),
'duration' => round($this->durationSeconds(), 3),
'keyframes' => count($self_contained_frames),
'decodable' => in_array($this->codecKind(),
['h264', 'hevc', 'vp8', 'vp9', 'theora', 'jpeg'], true),
];
}
/**
* frameWidth says how wide a frame of this video is, in pixels, as the
* file's own header gives it. A caller asks before decoding anything, since
* a thumbnail's height follows from the width and the shape of the frame.
*
* @return int How wide a frame is, in pixels.
*/
abstract public function frameWidth(): int;
/**
* frameHeight says how tall a frame of this video is, in pixels, from the
* same header.
*
* @return int How tall a frame is, in pixels.
*/
abstract public function frameHeight(): int;
/**
* toPlainStream convert one stored H.264 sample into an Annex-B, the form
* an H.264 stream takes on its own stream.
*
* @param string $sample which sample of the track
* @return string what was read
*/
abstract public function toPlainStream(string $sample): string;
/**
* settingsUnits parameter set NAL (a unit an H.264 or HEVC stream is cut
* into), a unit an H.264 or HEVC stream is cut into units carried by the
* container, if any.
*
* @return array what was read
*/
abstract public function settingsUnits(): array;
/**
* decodeTheora decode one Theora packet; only containers carrying Theora
* implement it
*
* @param string $packet the packet read off the stream
* @return VideoPicture what was read
*/
public function decodeTheora(string $packet): VideoPicture
{
throw new VideoException('this container does not carry Theora video');
}
/** extra context a container can add when it recognizes an undecodable
codec */
/**
* 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
{
return '';
}
/**
* decodeVp8 decode one VP8 keyframe; only containers carrying VP8 implement
* it
*
* @param string $frame the frame being built
* @return VideoPicture what was read
*/
public function decodeVp8(string $frame): VideoPicture
{
throw new VideoException('this container does not carry VP8 video');
}
/**
* decodeVp9 decode one VP9 keyframe; only containers carrying VP9 implement
* it
*
* @param string $frame the frame being built
* @return VideoPicture what was read
*/
public function decodeVp9(string $frame): VideoPicture
{
throw new VideoException('this container does not carry VP9 video');
}
/**
* decodeHevc decodes one H.265 keyframe; only containers carrying it
* implement this.
*
* @param string $frame the stored bytes of one frame
* @return VideoPicture the decoded picture
*/
public function decodeHevc(string $frame): VideoPicture
{
throw new VideoException('this container does not carry H.265 video');
}
/**
* keyframeAt the decodable sample at or before $seconds. keyframe is at or
* before that moment
*
* @return array which
* @param float $seconds the moment in the video, in seconds
*/
public function keyframeAt(float $seconds): array
{
$self_contained_frames = $this->syncSamples();
if ($self_contained_frames === []) {
throw new VideoException('no decodable samples in this track');
}
$best = $self_contained_frames[0];
foreach ($self_contained_frames as $source) {
if ($this->sampleTime($source) <= $seconds + 1e-9) {
$best = $source;
} else {
break;
}
}
return [
'sample' => $best,
'time' => $this->sampleTime($best),
'data' => $this->sampleData($best),
'codec' => $this->codecName(),
];
}
/**
* decodeH264 decode one H.264 sample into raw planes.
*
* @param string $sample which sample of the track
* @return H264Picture what was read
*/
public function decodeH264(string $sample): H264Picture
{
$decoder = new H264Decoder();
foreach ($this->settingsUnits() as $stream_unit) {
if ($stream_unit !== '') {
$decoder->addSettingsUnit($stream_unit);
}
}
return $decoder->decodePlainStream($this->toPlainStream($sample));
}
/**
* frameImage decode a specific sample to a GD image. its own
*
* @return GdImage what was read
* @param int $sample_index which sample of the track
* @param int $max_width the widest the picture may be drawn, or null for
*/
public function frameImage(int $sample_index, ?int $max_width = null)
{
$data = $this->sampleData($sample_index);
$kind = $this->codecKind();
if ($kind === 'h264') {
$image = $this->decodeH264($data)->toImage();
} elseif ($kind === 'theora') {
$image = $this->decodeTheora($data)->toImage();
} elseif ($kind === 'vp8') {
$image = $this->decodeVp8($data)->toImage();
} elseif ($kind === 'hevc') {
$image = $this->decodeHevc($data)->toImage();
} elseif ($kind === 'vp9') {
$image = $this->decodeVp9($data)->toImage();
} elseif ($kind === 'jpeg') {
$image = @imagecreatefromstring($data);
if ($image === false) {
throw new VideoException(
'the sample is not a readable JPEG image');
}
} else {
throw new VideoException(
"codec '" . $this->codecName() . "' is not supported; "
. 'this decoder handles H.264, H.265, VP8, VP9, Theora and JPEG'
. $this->unsupportedDetail()
);
}
return self::scaleTo($image, $max_width);
}
/**
* thumbnail still thumbnail from the keyframe at or before $seconds. its
* own
*
* @return GdImage what was read
* @param float $seconds the moment in the video, in seconds
* @param int $max_width the widest the picture may be drawn, or null for
*/
public function thumbnail(float $seconds = 0.0, ?int $max_width = null)
{
$frame = $this->keyframeAt($seconds);
return $this->frameImage($frame['sample'], $max_width);
}
/**
* animatedThumbnail animated WebP built from evenly spaced keyframes. Only
* sync samples can be decoded, so each target time snaps to the nearest
* keyframe. A video with fewer keyframes than $count produces a
* correspondingly shorter animation rather than repeating frames. second
* its own
*
* @return string the animated WebP file contents
* @param int $count how many
* @param int $delay_ms how long each picture is shown, in thousandths of a
* @param int $max_width the widest the picture may be drawn, or null for
* @param int $quality the quantizer the frame was coded at
*/
public function animatedThumbnail(
int $count = 10, int $delay_ms = 1000, ?int $max_width
= null, int $quality = 80
): string {
if ($count < 1) {
throw new VideoException('frame count must be at least 1');
}
$self_contained_frames = $this->syncSamples();
if ($self_contained_frames === []) {
throw new VideoException('no keyframes in this track');
}
$duration = $this->durationSeconds();
$picked = [];
for ($i = 0; $i < $count; $i++) {
$target = $duration * ($i + 0.5) / $count;
$best = $self_contained_frames[0];
$best_gap = INF;
foreach ($self_contained_frames as $source) {
$gap = abs($this->sampleTime($source) - $target);
if ($gap < $best_gap) {
$best_gap = $gap;
$best = $source;
}
}
$picked[$best] = true;
}
$picked = array_keys($picked);
sort($picked);
$animation = new WebpAnimation();
foreach ($picked as $source) {
$image = $this->frameImage($source, $max_width);
$animation->addFrame($image, $delay_ms, $quality);
}
return $animation->encodePicture();
}
/**
* scaleTo its own
*
* @return GdImage what was read
* @param mixed $image the picture being worked on
* @param int $max_width the widest the picture may be drawn, or null for
*/
protected static function scaleTo($image, ?int $max_width)
{
if ($max_width === null || $max_width < 1 || imagesx(
$image) <= $max_width) {
return $image;
}
/* Not every build of the drawing library can scale by the
smooth method named here, and the ones that cannot hand back
false rather than scaling. Falling back to the library's own
method, and then to copying into a canvas of the wanted size,
leaves the picture the size the caller asked for on any
build. */
$wanted_high = (int)round($max_width * imagesy($image) /
imagesx($image));
$scaled = @imagescale($image, $max_width, -1, IMG_BICUBIC);
if ($scaled === false) {
$scaled = @imagescale($image, $max_width);
}
if ($scaled === false) {
$scaled = imagecreatetruecolor($max_width, $wanted_high);
imagecopyresampled($scaled, $image, 0, 0, 0, 0, $max_width,
$wanted_high, imagesx($image), imagesy($image));
}
return $scaled;
}
/**
* plainStreamFrom turn one stored H.264 sample into an Annex-B stream,
* prefixing whatever parameter sets the container supplied. Samples already
* in Annex-B form are passed through behind the same prefix.
*
* @param AvcConfig $settings the settings the container keeps for the
* stream
* @param string $sample which sample of the track
* @return string what was read
*/
protected static function plainStreamFrom(AvcConfig $settings,
string $sample): string
{
$start_code = "\x00\x00\x00\x01";
$prefix = '';
if ($settings->sequence_settings !== null) {
$prefix .= $start_code . $settings->sequence_settings;
}
if ($settings->picture_settings !== null) {
$prefix .= $start_code . $settings->picture_settings;
}
if (self::isPlainStream($sample, $settings->stream_unit_length_size)) {
return $prefix . $sample;
}
return self::lengthPrefixedToPlainStream($sample, $settings
->stream_unit_length_size,
$prefix);
}
/**
* lengthPrefixedToPlainStream annex-B conversion shared by containers that
* store length-prefixed NALs.
*
* @param string $sample which sample of the track
* @param int $length_size how many bytes give the length of a unit
* @param string $prefix the bytes a unit starts with
* @return string what was read
*/
protected static function lengthPrefixedToPlainStream(string $sample,
int $length_size, string $prefix): string
{
$start_code = "\x00\x00\x00\x01";
$written = $prefix;
$position = 0;
$length = strlen($sample);
while ($position + $length_size <= $length) {
$number = 0;
for ($i = 0; $i < $length_size; $i++) {
$number = ($number << 8) | ord($sample[$position + $i]);
}
$position += $length_size;
if ($number <= 0 || $position + $number > $length) {
break;
}
$written .= $start_code . substr($sample, $position, $number);
$position += $number;
}
return $written;
}
/** does the buffer start with an Annex-B start code? */
/**
* isPlainStream says whether a stored sample already uses start codes
* rather than length prefixes. A start code cannot be told apart from a
* length prefix by its first bytes alone: a unit of between 256 and 511
* bytes has the length prefix 00 00 01 xx, which reads as a three byte
* start code. So when a length size is known, walk the sample as length
* prefixed units first and only call it start coded if that walk does not
* land exactly on the end.
*
* @param string $sample one stored sample
* @param int $length_size bytes in each length prefix, 0 if unknown
* @return bool true when the sample uses start codes
*/
protected static function isPlainStream(string $sample,
$length_size = 0): bool
{
$starts = strncmp($sample, "\x00\x00\x01", 3) === 0
|| strncmp($sample, "\x00\x00\x00\x01", 4) === 0;
if (!$starts) {
return false;
}
if ($length_size < 1 || $length_size > 4) {
return true;
}
$total = strlen($sample);
$offset = 0;
while ($offset + $length_size <= $total) {
$size = 0;
for ($i = 0; $i < $length_size; $i++) {
$size = ($size << 8) | ord($sample[$offset + $i]);
}
if ($size < 1) {
return true;
}
$offset += $length_size + $size;
}
return $offset !== $total;
}
/**
* open opens a video by looking at its first bytes, and hands back the
* reader for whichever container it holds: an MP4, an MP4 carrying video
* alone or QuickTime file, an AVI, an Ogg, or a Matroska or WebM.
*
* @param string $path the file to open
* @return VideoExtractor the reader for that kind of file
*/
public static function open(string $path): VideoExtractor
{
$handle = @fopen($path, 'rb');
if ($handle === false) {
throw new VideoException("cannot open: $path");
}
$head = (string) fread($handle, 16);
fclose($handle);
if (strlen($head) >= 12 && substr($head, 0, 4) === 'RIFF'
&& substr($head, 8, 4) === 'AVI ') {
return new AviExtractor($path);
}
if (strlen($head) >= 4 && substr($head, 0, 4) === 'OggS') {
return new OggExtractor($path);
}
if (strlen($head) >= 4 && substr($head, 0, 4) === "\x1A\x45\xDF\xA3") {
/* Built the way Matroska and WebM are, which is called
EBML. */
return new WebmExtractor($path);
}
if (strlen($head) >= 8) {
$type = substr($head, 4, 4);
/* A box named ftyp opens an MP4 and a modern QuickTime
file; the others are boxes older QuickTime writers put
before the header. */
if (in_array($type, ['ftyp', 'moov', 'mdat', 'free', 'skip',
'wide', 'pnot'], true)) {
return new Mp4Extractor($path);
}
}
throw new VideoException(
'unrecognized container; expected MP4, MOV, AVI, Ogg or WebM');
}
}