<?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 wraps several pictures into one moving picture.
*/
namespace seekquarry\yioop\library\av_processing;
/**
* WebpAnimation wraps several pictures into one moving picture.
*/
final class WebpAnimation
{
/**
* $pictures stores the pictures to show in turn, each already encoded, with
* how long it stays on screen. addFrame() adds to this and buildPicture()
* writes them out as one moving picture. Set by addFrame(). Read by
* encodePicture(), frameCount().
* @var array
*/
private array $pictures = [];
/**
* $picture_width stores how wide the moving picture is, taken from the
* first frame added. Every later frame is expected to match it. Set by
* addFrame(). Read by encodePicture().
* @var int
*/
private int $picture_width = 0;
/**
* $picture_height stores how tall the moving picture is, in pixels,
* taken from the first picture added. Every later picture is
* expected to match it. T
* way. Set by addFrame(). Read by encodePicture().
* @var int
*/
private int $picture_height = 0;
/**
* addFrame adds one picture to the end of the moving picture. A reader
* calls it once for each keyframe it takes out of a video, then asks
* buildPicture for the whole thing. The first picture added fixes the size
* the rest are expected to match. in thousandths of a second. hundred.
*
* @param GdImage $image The picture to add.
* @param int $duration_ms How long the picture stays on screen,
* @param int $quality How much detail to keep, from zero to a
*/
public function addFrame($image, int $duration_ms, int $quality = 80): void
{
$wide = imagesx($image);
$tall = imagesy($image);
if ($this->pictures === []) {
$this->picture_width = $wide;
$this->picture_height = $tall;
} elseif ($wide !== $this->picture_width || $tall !== $this
->picture_height) {
throw new RuntimeException(
'every frame of an animation must be the same size');
}
if ($wide < 1 || $tall < 1 || $wide > 16777216 || $tall > 16777216) {
throw new RuntimeException('frame size out of range for WebP');
}
ob_start();
$ok = imagewebp($image, null, $quality);
$still = (string) ob_get_clean();
if (!$ok || strlen($still) < 20 || substr($still, 0, 4) !== 'RIFF'
|| substr($still, 8, 4) !== 'WEBP') {
throw new RuntimeException(
'GD did not produce a usable WebP frame');
}
/* an ANMF payload carries the frame's image chunks: the optional alpha
*/
/* chunk followed by the VP8 or VP8L bitstream */
$bitstream = '';
$alpha = '';
$position = 12;
$count = strlen($still);
while ($position + 8 <= $count) {
$codec_name = substr($still, $position, 4);
$size = unpack('V', substr($still, $position + 4, 4))[1];
if ($position + 8 + $size > $count) {
break;
}
$payload = substr($still, $position + 8, $size);
if ($codec_name === 'VP8 ' || $codec_name === 'VP8L') {
$bitstream = self::wrapAsChunk($codec_name, $payload);
} elseif ($codec_name === 'ALPH') {
$alpha = self::wrapAsChunk($codec_name, $payload);
}
$position += 8 + $size + ($size & 1);
}
if ($bitstream === '') {
throw new RuntimeException(
'no VP8 bitstream found in the encoded frame');
}
$this->pictures[] = [$alpha . $bitstream, max(0, $duration_ms)];
}
/**
* frameCount works out how many frames have been added so far.
*
* @return int what was read
*/
public function frameCount(): int
{
return count($this->pictures);
}
/**
* encodePicture writes one picture in the form the moving picture keeps its
* frames in.
*
* @param int $loops 0 means loop forever
* @return string what was read
*/
public function encodePicture(int $loops = 0): string
{
if ($this->pictures === []) {
throw new RuntimeException('an animation needs at least one frame');
}
$extended_features = self::wrapAsChunk('VP8X',
/* ANIMATION flag */
chr(0x02)
/* reserved */
. "\x00\x00\x00"
. self::writeThreeBytes($this->picture_width - 1)
. self::writeThreeBytes($this->picture_height - 1)
);
$animation = self::wrapAsChunk('ANIM', "\xFF\xFF\xFF\xFF" . pack('v',
$loops));
$body = $extended_features . $animation;
foreach ($this->pictures as [$data, $duration]) {
/* frame x, in units of two pixels */
$header = self::writeThreeBytes(0)
/* frame y */
. self::writeThreeBytes(0)
. self::writeThreeBytes($this->picture_width - 1)
. self::writeThreeBytes($this->picture_height - 1)
. self::writeThreeBytes($duration)
/* no blending or disposal */
. chr(0);
$body .= self::wrapAsChunk('ANMF', $header . $data);
}
return 'RIFF' . pack('V', 4 + strlen($body)) . 'WEBP' . $body;
}
/**
* wrapAsChunk wraps a block of data in the four letter header the format
* uses.
*
* @param string $codec_name the four letters naming the codec
* @param string $payload the bytes the element carries
* @return string what was read
*/
private static function wrapAsChunk(string $codec_name,
string $payload): string
{
$written = $codec_name . pack('V', strlen($payload)) . $payload;
if (strlen($payload) & 1) {
/* chunks are padded to an even length */
$written .= "\x00";
}
return $written;
}
/**
* writeThreeBytes writes a three byte number, least significant byte first.
*
* @param int $value the value read
* @return string what was read
*/
private static function writeThreeBytes(int $value): string
{
return substr(pack('V', $value), 0, 3);
}
}