/ tests / Mp4ExtractorTest.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\av_processing\Mp4Extractor;
use seekquarry\yioop\library\UnitTest;

/**
 * Mp4ExtractorTest checks that Mp4Extractor reads an MP4 file
 * correctly: how long the video runs, how large its frames are, what it
 * was coded with, which frames can be decoded on their own, and where in
 * the file each frame's bytes sit. A wiki page asks a video for these
 * before it draws a player or a thumbnail. A reader that gets any of
 * them wrong shows a broken video rather than failing outright, so each
 * case here compares what the reader says against what ffmpeg wrote.
 *
 * @author Chris Pollett
 */
class Mp4ExtractorTest extends UnitTest
{
    /**
     * VIDEO_SECONDS is how many seconds the test video runs, as ffmpeg
     * wrote it. The reader's answer is compared against this.
     * @var float
     */
    const VIDEO_SECONDS = 10.0;
    /**
     * VIDEO_WIDTH is how many pixels across each frame of the test video
     * is, as ffmpeg wrote it.
     * @var int
     */
    const VIDEO_WIDTH = 320;
    /**
     * VIDEO_HEIGHT is how many pixels down each frame of the test video
     * is, as ffmpeg wrote it.
     * @var int
     */
    const VIDEO_HEIGHT = 240;
    /**
     * $video_path stores where setUp wrote the MP4 file that the cases
     * read. Each case opens a reader on this path.
     * @var string
     */
    public $video_path;
    /**
     * $reader stores the Mp4Extractor under test, opened on the file at
     * $video_path.
     * @var Mp4Extractor
     */
    public $reader;
    /**
     * setUp writes the test video out of the base64 it is kept in and
     * opens a reader on it. Every case here needs a real file, since the
     * reader walks a container's own tables. The file is small enough
     * that writing it for each case costs under a hundredth of a second.
     */
    public function setUp()
    {
        $this->video_path = C\WORK_DIRECTORY . "/temp/mp4read" .
            getmypid() . ".mp4";
        $held = file_get_contents(C\PARENT_DIR .
            "/tests/test_files/video_white_mp4.txt");
        file_put_contents($this->video_path, base64_decode(trim($held)));
        @chmod($this->video_path, 0777);
        $this->reader = new Mp4Extractor($this->video_path);
    }
    /**
     * tearDown removes the video file that setUp wrote, so a run leaves
     * nothing behind in the work directory.
     */
    public function tearDown()
    {
        if (file_exists($this->video_path)) {
            unlink($this->video_path);
        }
    }
    /**
     * videosShapeIsReadTestCase checks that the reader gives back how
     * long the video runs, how large its frames are, and which container
     * it came out of. A wiki page sizes its player from these three, so
     * each is compared against what ffmpeg wrote.
     */
    public function videosShapeIsReadTestCase()
    {
        $this->assertTrue(abs($this->reader->durationSeconds() -
            self::VIDEO_SECONDS) < 0.05, "the video runs " .
            self::VIDEO_SECONDS . " seconds, and the reader says " .
            round($this->reader->durationSeconds(), 2));
        $this->assertEqual(self::VIDEO_WIDTH, $this->reader->frameWidth(),
            "each frame is " . self::VIDEO_WIDTH . " pixels across");
        $this->assertEqual(self::VIDEO_HEIGHT,
            $this->reader->frameHeight(),
            "and " . self::VIDEO_HEIGHT . " pixels down");
        $this->assertEqual("MP4", $this->reader->containerName(),
            "and the file says it is an MP4");
    }
    /**
     * whatTheVideoWasCodedWithIsNamedTestCase checks that the reader
     * names what the video was coded with. A caller picks a decoder from
     * that name, so a wrong one sends the frames to a decoder that
     * cannot read them.
     */
    public function whatTheVideoWasCodedWithIsNamedTestCase()
    {
        $this->assertEqual("h264", $this->reader->codecKind(),
            "the video was coded with h264");
        $this->assertEqual("avc1", $this->reader->codecName(),
            "which an MP4 names avc1 in its own table");
    }
    /**
     * framesThatStandAloneAreFoundTestCase checks that the reader finds
     * the frames which can be decoded on their own, and hands back the
     * bytes of one. A thumbnail is taken from such a frame, since any
     * other frame needs the frames before it.
     */
    public function framesThatStandAloneAreFoundTestCase()
    {
        $syncs = $this->reader->syncSamples();
        $this->assertTrue(count($syncs) > 0,
            "the video holds at least one frame that stands alone, and " .
            "holds " . count($syncs));
        $first = $syncs[0];
        $this->assertTrue($this->reader->sampleTime($first) <
            self::VIDEO_SECONDS,
            "the first such frame sits inside the video's length");
        $bytes = $this->reader->sampleData($first);
        $this->assertTrue(strlen($bytes) > 0,
            "and its bytes can be read back, which came to " .
            strlen($bytes));
    }
    /**
     * frameForAMomentIsAtOrBeforeItTestCase checks that asking for
     * the frame at a moment gives one at or before that moment, never
     * after it. A thumbnail asked for three seconds in must not come
     * from the fourth second.
     */
    public function frameForAMomentIsAtOrBeforeItTestCase()
    {
        $syncs = $this->reader->syncSamples();
        $wanted = self::VIDEO_SECONDS / 2;
        $found = -1;
        foreach ($syncs as $one) {
            if ($this->reader->sampleTime($one) <= $wanted) {
                $found = $one;
            }
        }
        $this->assertTrue($found >= 0,
            "a frame at or before the middle of the video is found");
        $this->assertTrue($this->reader->sampleTime($found) <= $wanted,
            "and it does not sit after the moment asked for");
    }
    /**
     * fileThatIsNotAnMp4IsRefusedTestCase checks that a file which is
     * not an MP4 is refused rather than read as one. A reader that
     * carried on would hand its caller frames made of whatever bytes
     * happened to follow.
     */
    public function fileThatIsNotAnMp4IsRefusedTestCase()
    {
        $where = C\WORK_DIRECTORY . "/temp/notmp4" . getmypid() . ".mp4";
        file_put_contents($where, str_repeat("not a video at all", 20));
        @chmod($where, 0777);
        $refused = false;
        try {
            $reader = new Mp4Extractor($where);
            $reader->durationSeconds();
        } catch (\Exception $trouble) {
            $refused = true;
        }
        if (file_exists($where)) {
            unlink($where);
        }
        $this->assertTrue($refused,
            "a file of ordinary text is refused rather than read");
    }
}
X