/ src / library / av_processing / WebpAnimation.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 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);
    }
}
X