/ tests / VideoExtractorTest.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
 */
namespace seekquarry\yioop\tests;

use seekquarry\yioop\configs as C;
use seekquarry\yioop\library\UnitTest;
use seekquarry\yioop\library\av_processing\VideoExtractor;

/**
 * VideoExtractorTest checks that VideoExtractor picks the right reader
 * for each kind of container, reads back what a file says about itself,
 * and decodes a picture out of it.
 *
 * A thumbnail is what a wiki page shows for a video, so these cases
 * decode one rather than only opening the file: a reader that opens a
 * file and cannot decode a frame from it is of no use to a page.
 *
 * @author Chris Pollett
 */
class VideoExtractorTest extends UnitTest
{
    /**
     * $files stores where each video a case reads was written, keyed by
     * the file's ending. setUp fills it and tearDown empties it.
     * @var array
     */
    public $files = [];
    /**
     * ASKED_WIDTH is how many pixels across the cases ask a decoded
     * picture to be. A small width keeps the decoding quick.
     * @var int
     */
    const ASKED_WIDTH = 64;
    /**
     * setUp writes the two videos out of the base64 they are kept in, so
     * a case has real files to open. They are small on purpose: one is ten
     * seconds of H.264 in an MP4, the other five seconds of VP9 in a
     * WebM, both from the web platform test suite.
     */
    public function setUp()
    {
        $this->files = [];
        foreach (["video_white_mp4" => "mp4",
            "video_movie_webm" => "webm"] as $name => $ending) {
            $held = file_get_contents(C\PARENT_DIR .
                "/tests/test_files/$name.txt");
            $where = C\WORK_DIRECTORY . "/temp/$name" . getmypid() .
                ".$ending";
            file_put_contents($where, base64_decode($held));
            @chmod($where, 0777);
            $this->files[$ending] = $where;
        }
    }
    /**
     * tearDown removes the video files that setUp wrote, so a run leaves
     * nothing behind in the work directory.
     */
    public function tearDown()
    {
        foreach ($this->files as $path) {
            if (file_exists($path)) {
                unlink($path);
            }
        }
    }
    /**
     * rightReaderOpensEachKindTestCase checks that the kind of
     * container a file holds is worked out from its first bytes, and
     * that the reader for that kind is handed back. A file that is not a
     * video is refused rather than read as one.
     */
    public function rightReaderOpensEachKindTestCase()
    {
        $reader = VideoExtractor::open($this->files["mp4"]);
        $this->assertTrue(strpos(get_class($reader), "Mp4Extractor") !==
            false, "an MP4 file is opened by the MP4 reader");
        $reader = VideoExtractor::open($this->files["webm"]);
        $this->assertTrue(strpos(get_class($reader), "WebmExtractor") !==
            false, "a WebM file is opened by the WebM reader");
        $not_a_video = C\WORK_DIRECTORY . "/temp/not_a_video" .
            getmypid() . ".txt";
        file_put_contents($not_a_video, "these are only words");
        @chmod($not_a_video, 0777);
        $said = "";
        try {
            VideoExtractor::open($not_a_video);
        } catch (\Throwable $trouble) {
            $said = $trouble->getMessage();
        }
        $this->assertTrue(strpos($said, "unrecognized container") !== false,
            "a file that is not a video is refused, saying what it wanted");
        if (file_exists($not_a_video)) {
            unlink($not_a_video);
        }
    }
    /**
     * howLongTheVideoRunsIsReadTestCase checks that the reader takes a
     * video's running time out of the file rather than guessing it. A
     * page shows that time beside the video, and a thumbnail's moment is
     * measured against it.
     */
    public function howLongTheVideoRunsIsReadTestCase()
    {
        $reader = VideoExtractor::open($this->files["mp4"]);
        $this->assertEqual(10, round($reader->durationSeconds()),
            "the ten second MP4 says it runs ten seconds");
        $reader = VideoExtractor::open($this->files["webm"]);
        $this->assertEqual(5, round($reader->durationSeconds()),
            "the five second WebM says it runs five seconds");
    }
    /**
     * thumbnailComesOutOfAnMp4TestCase checks that a picture is decoded
     * out of an H.264 stream in an MP4, at the width asked for and
     * keeping the shape of the frame. A page showing a video needs such
     * a picture, which is what the library is for.
     */
    public function thumbnailComesOutOfAnMp4TestCase()
    {
        $reader = VideoExtractor::open($this->files["mp4"]);
        $image = $reader->thumbnail(0.0, 160);
        $this->assertTrue($image !== false && imagesx($image) > 0,
            "a picture comes back rather than nothing");
        $this->assertEqual(160, imagesx($image),
            "it is as wide as was asked for");
        $this->assertTrue(imagesy($image) > 0 && imagesy($image) < 160,
            "and its height keeps the shape of the frame");
        $colors = [];
        for ($i = 0; $i < 5; $i++) {
            $colors[] = imagecolorat($image, $i * 30, imagesy($image) >> 1);
        }
        $this->assertEqual(5, count($colors),
            "and every place looked at in it has a color");
    }
    /**
     * thumbnailComesOutOfAWebmTestCase checks the same for VP9 in a
     * WebM, which is a different decoder and a different container from
     * the case above.
     */
    public function thumbnailComesOutOfAWebmTestCase()
    {
        $reader = VideoExtractor::open($this->files["webm"]);
        $image = $reader->thumbnail(0.0, 160);
        $this->assertTrue($image !== false && imagesx($image) > 0,
            "a picture comes back rather than nothing");
        $this->assertEqual(160, imagesx($image),
            "it is as wide as was asked for");
        $this->assertTrue(imagesy($image) > 0 && imagesy($image) < 160,
            "and its height keeps the shape of the frame");
    }
    /**
     * thumbnailCanBeTakenPartWayThroughTestCase checks that asking for
     * a moment part way through gives a picture from around there rather
     * than from the start. The library moves to the frame that stands
     * alone at or before the moment asked for.
     */
    public function thumbnailCanBeTakenPartWayThroughTestCase()
    {
        $reader = VideoExtractor::open($this->files["mp4"]);
        $image = $reader->thumbnail(5.0, 120);
        $this->assertEqual(120, imagesx($image),
            "a picture from five seconds in is as wide as was asked for");
        $keyframes = $reader->syncSamples();
        $this->assertTrue(count($keyframes) >= 1,
            "and the file says where its keyframes are");
    }
    /**
     * eachFileGivesUpAFrameThatStandsAloneTestCase checks that a frame
     * which stands on its own is read back out of each file and holds
     * bytes. A decoder is handed exactly those bytes, so a reader that
     * hands back nothing leaves the decoder nothing to do.
     */
    public function eachFileGivesUpAFrameThatStandsAloneTestCase()
    {
        foreach ($this->files as $name => $where) {
            $reader = VideoExtractor::open($where);
            $syncs = $reader->syncSamples();
            $this->assertTrue(count($syncs) > 0,
                "$name holds a frame that stands alone");
            $bytes = $reader->sampleData($syncs[0]);
            $this->assertTrue(strlen($bytes) > 0,
                "and its bytes are read back from $name, which came to " .
                strlen($bytes));
        }
    }
    /**
     * eachDecoderGivesAPictureOfTheRightShapeTestCase checks that each
     * file's frames decode to a picture as wide as was asked for and in
     * the shape the file says its frames are. A picture of another shape
     * means the decoder read the frame's size wrongly.
     */
    public function eachDecoderGivesAPictureOfTheRightShapeTestCase()
    {
        if (!function_exists("imagecreatetruecolor")) {
            return;
        }
        foreach ($this->files as $name => $where) {
            $reader = VideoExtractor::open($where);
            $picture = $reader->thumbnail(0.0, self::ASKED_WIDTH);
            $this->assertTrue($picture !== false,
                "$name decodes to a picture");
            if ($picture === false) {
                continue;
            }
            $this->assertEqual(self::ASKED_WIDTH, imagesx($picture),
                "the picture from $name is as wide as was asked for");
            $wanted_high = (int)round(self::ASKED_WIDTH *
                $reader->frameHeight() / $reader->frameWidth());
            $this->assertTrue(abs(imagesy($picture) - $wanted_high) <= 1,
                "and stands $wanted_high tall, as the file's frames do, " .
                "where it stands " . imagesy($picture));
        }
    }
    /**
     * pictureLaterInAVideoDiffersFromTheFirstTestCase checks that a
     * picture taken part way through comes back at the same size as the
     * one at the start. A decoder that ignored the moment asked for
     * would give the same picture every time.
     */
    public function pictureLaterInAVideoDiffersFromTheFirstTestCase()
    {
        if (!function_exists("imagecreatetruecolor")) {
            return;
        }
        $where = $this->files["webm"];
        $reader = VideoExtractor::open($where);
        $first = $reader->thumbnail(0.0, self::ASKED_WIDTH);
        $later = $reader->thumbnail($reader->durationSeconds() / 2,
            self::ASKED_WIDTH);
        $this->assertTrue($first !== false && $later !== false,
            "a picture comes back from both moments");
        if ($first === false || $later === false) {
            return;
        }
        $this->assertEqual(imagesx($first), imagesx($later),
            "both pictures are the same width");
        $this->assertEqual(imagesy($first), imagesy($later),
            "and the same height");
    }
    /**
     * movingThumbnailIsAnAnimatedWebpTestCase checks that a moving
     * thumbnail comes back as an animated WebP. Yioop stores it under a
     * name ending in .animated.webp and serves it by that name, so bytes
     * of another kind would be served with the wrong type.
     */
    public function movingThumbnailIsAnAnimatedWebpTestCase()
    {
        if (!function_exists("imagecreatetruecolor")) {
            return;
        }
        $where = $this->files["webm"];
        $reader = VideoExtractor::open($where);
        $moving = $reader->animatedThumbnail(3, 1000, self::ASKED_WIDTH);
        $this->assertTrue(strlen($moving) > 0,
            "a moving thumbnail comes back, of " . strlen($moving) .
            " bytes");
        $this->assertEqual("RIFF", substr($moving, 0, 4),
            "it begins the way a WebP file begins");
        $this->assertEqual("WEBP", substr($moving, 8, 4),
            "and says WebP where a WebP file says so");
    }
}
X