<?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");
}
}