$extra */ function stream(string $type, array $extra = []): array { return ['codec_type' => $type, ...$extra]; } // The rules, without ffmpeg. check('4K fits into 1080p', VideoFormat::fit(3840, 2160) === [1920, 1080], VideoFormat::fit(3840, 2160)); check('a standing 4K fits standing up', VideoFormat::fit(2160, 3840) === [1080, 1920], VideoFormat::fit(2160, 3840)); check('4:3 is bound by its height', VideoFormat::fit(4000, 3000) === [1440, 1080], VideoFormat::fit(4000, 3000)); check('1080p stays', VideoFormat::fit(1920, 1080) === [1920, 1080]); check('a small one is not made larger', VideoFormat::fit(640, 360) === [640, 360]); check('odd sides become even, never larger', VideoFormat::fit(641, 361) === [640, 360], VideoFormat::fit(641, 361)); check('an ultra-wide one keeps its shape', VideoFormat::fit(5120, 1440) === [1920, 540], VideoFormat::fit(5120, 1440)); $phone = [ 'streams' => [ stream('video', ['width' => 1920, 'height' => 1080, 'avg_frame_rate' => '30000/1001', 'side_data_list' => [['rotation' => -90]]]), stream('audio'), ], 'format' => ['duration' => '12.5'], ]; $plan = VideoFormat::plan($phone); check('a phone video turned on its side is planned standing', [$plan['width'], $plan['height']] === [1080, 1920], $plan); check('its frame rate stays a fraction', $plan['fps'] === '30000/1001', $plan['fps']); check('its sound is kept', $plan['audio'] === true); check('its length comes from the container', $plan['duration'] === 12.5, $plan['duration']); check('an old rotate tag counts too', VideoFormat::plan(['streams' => [stream('video', ['width' => 640, 'height' => 480, 'tags' => ['rotate' => '90']])]])['width'] === 480); check('120 fps is written at 60', VideoFormat::fps(['avg_frame_rate' => '120/1']) === '60'); check('an unknown average falls back to the stream rate', VideoFormat::fps(['avg_frame_rate' => '0/0', 'r_frame_rate' => '25/1']) === '25'); check('and with none at all to 30', VideoFormat::fps([]) === '30'); check('30000/1001 is 29.97 frames a second', abs(VideoFormat::fpsValue('30000/1001') - 29.97) < 0.001); $silent = VideoFormat::plan(['streams' => [stream('video', ['width' => 320, 'height' => 240, 'avg_frame_rate' => '24/1'])]]); check('a file without sound has none', $silent['audio'] === false); check('and is converted without', in_array('-an', VideoFormat::convertArgs('in', 'out', $silent), true) && !in_array('0:a:0', VideoFormat::convertArgs('in', 'out', $silent), true)); $noPicture = static function (array $probe): bool { try { VideoFormat::plan($probe); return false; } catch (InvalidArgumentException) { return true; } }; check('a sound alone is no video', $noPicture(['streams' => [stream('audio')]])); check('nor is a song with a cover picture', $noPicture(['streams' => [stream('audio'), stream('video', ['width' => 600, 'height' => 600, 'disposition' => ['attached_pic' => 1]])]])); check('a progress line says how far', VideoFormat::progress("out_time_us=2500000\n", 10.0) === 0.25); check('other lines say nothing', VideoFormat::progress('frame=12', 10.0) === null && VideoFormat::progress('out_time_us=1', 0.0) === null); check('progress never passes the end', VideoFormat::progress('out_time_us=99000000', 10.0) === 1.0); // The store and its queue. $data = sys_get_temp_dir() . '/game-maker-php-videos-' . bin2hex(random_bytes(4)); $store = new VideoStore("{$data}/videos"); $folder = 'a1b2c3d4e5f6'; $rejected = static function (string $bytes) use ($store, $folder): bool { try { $store->store($bytes, 'x', $folder); return false; } catch (InvalidVideoException) { return true; } }; check('a sound is turned away', $rejected('RIFF' . pack('V', 36) . 'WAVE' . str_repeat("\0", 32))); check('so is text', $rejected(str_repeat('hello ', 10))); check('so is something too short to be anything', $rejected("\0\0\0\x18ftyp")); $fake = "\0\0\0\x18ftypisom" . str_repeat("\0", 64) . 'not really a video'; $hash = hash('sha256', $fake); $record = $store->store($fake, 'holiday.mov', $folder); check('an upload is named after its bytes, before it is converted', $record['file'] === "{$folder}/{$hash}.mp4", $record); check('and waits', $record['status'] === 'queued' && $record['name'] === 'holiday.mov', $record); check('the upload is hidden from the web server', (fileperms($store->queue()->source($folder, $hash)) & 0777) === 0640); check('it is there for the picker', array_column($store->list($folder, false, [$folder => 'ann']), 'status') === ['queued']); check('but not for another account', $store->list('ffffffffffff', false, []) === []); check('an administrator sees it with its owner', $store->list('', true, [$folder => 'ann'])[0]['owner'] === 'ann'); check('it counts as there for deleting', $store->exists("{$folder}/{$hash}.mp4")); check('deleting it drops the job and the upload', $store->delete("{$folder}/{$hash}.mp4") && $store->queue()->jobs() === [] && !is_file($store->queue()->source($folder, $hash))); check('and its name', !is_file("{$data}/videos/{$folder}/{$hash}.mp4.json")); $store->store($fake, 'holiday.mov', $folder); $store->queue()->update($folder, $hash, ['state' => VideoQueue::CONVERTING, 'progress' => 0.4]); check('a job cut off by a stopped worker is queued again', $store->queue()->requeueInterrupted() === 1 && $store->queue()->job($folder, $hash)['state'] === 'queued'); // An upload straight from the request, as the API takes it: to disk, never more than allowed. $streamOf = static function (string $bytes) { $in = fopen('php://memory', 'r+b'); fwrite($in, $bytes); rewind($in); return $in; }; $tooLarge = static function (string $bytes, int $max) use ($store, $folder, $streamOf): bool { try { $store->storeStream($streamOf($bytes), $max, 'x', $folder); return false; } catch (VideoTooLargeException) { return true; } }; $streamed = $fake . 'streamed'; check('one byte over the ceiling is turned away', $tooLarge($streamed, strlen($streamed) - 1)); $record = $store->storeStream($streamOf($streamed), strlen($streamed), 'streamed.mp4', $folder); check('right at the ceiling it is taken, named after its bytes', $record['file'] === "{$folder}/" . hash('sha256', $streamed) . '.mp4' && $record['status'] === 'queued', $record); check('the streamed upload is hidden as well', (fileperms($store->queue()->source($folder, hash('sha256', $streamed))) & 0777) === 0640); check('text from a stream is turned away too', $rejected(str_repeat('hello ', 10))); check('no received upload is left lying around', glob("{$data}/videos/queue/upload-*") === []); $store->delete($record['file']); exec('command -v ffmpeg && command -v ffprobe', $found, $code); check('ffmpeg and ffprobe are on the PATH', $code === 0); if ($code !== 0) { exec('rm -rf ' . escapeshellarg($data)); echo "{$failed} FAILED, {$passed} passed\n"; exit(1); } $worker = new VideoWorker($store, "{$data}/videos"); check('the worker takes the oldest job', $worker->runOnce()); $broken = $store->record("{$folder}/{$hash}.mp4"); check('bytes ffmpeg cannot read fail the job, with its words', $broken['status'] === 'failed' && str_starts_with($broken['error'], 'ffmpeg cannot read this file'), $broken); check('the upload goes, the job stays to say why', !is_file($store->queue()->source($folder, $hash)) && $store->queue()->job($folder, $hash) !== null); check('nothing is left half written', glob("{$data}/videos/{$folder}/*.tmp.*") === []); check('nothing waits any more', $worker->runOnce() === false); /** A test picture with a tone, written by ffmpeg itself. */ function makeVideo(string $path, string $size, int $rate, float $seconds, bool $sound): void { $args = ['ffmpeg', '-v', 'error', '-y', '-f', 'lavfi', '-i', "testsrc2=size={$size}:rate={$rate}:duration={$seconds}"]; if ($sound) { $args = [...$args, '-f', 'lavfi', '-i', "sine=frequency=440:duration={$seconds}", '-c:a', 'pcm_s16le']; } exec(implode(' ', array_map('escapeshellarg', [...$args, '-c:v', 'mjpeg', '-q:v', '5', '-shortest', $path])), $out, $code); if ($code !== 0) { throw new RuntimeException("cannot make {$path}"); } } $tmp = "{$data}/in"; mkdir($tmp, 0775, true); makeVideo("{$tmp}/big.mov", '3840x2160', 25, 1.2, true); makeVideo("{$tmp}/small.mov", '320x240', 50, 1.0, false); $big = $store->store((string) file_get_contents("{$tmp}/big.mov"), 'big.mov', $folder); $small = $store->store((string) file_get_contents("{$tmp}/small.mov"), 'small.mov', $folder); $started = microtime(true); check('the worker converts both, one after the other', $worker->runOnce() && $worker->runOnce() && !$worker->runOnce()); echo sprintf(" (converted in %.1f s)\n", microtime(true) - $started); $done = $store->record($big['file']); check('a converted video is ready', $done['status'] === 'ready', $done); check('4K came out as 1080p', [$done['width'], $done['height']] === [1920, 1080], $done); check('it says its length and frame rate', abs($done['duration'] - 1.2) < 0.05 && $done['fps'] === 25.0, $done); check('and that it has sound', $done['audio'] === true); check('its poster lies beside it, named like the file', $done['poster'] === substr($big['file'], 0, -4) . '.jpg' && is_file("{$data}/videos/{$done['poster']}"), $done); check('both are served as they are', (fileperms("{$data}/videos/{$big['file']}") & 0777) === 0664 && (fileperms("{$data}/videos/{$done['poster']}") & 0777) === 0664); check('the job and the upload are gone', $store->queue()->job($folder, basename($big['file'], '.mp4')) === null && !is_file($store->queue()->source($folder, basename($big['file'], '.mp4')))); $probe = json_decode((string) shell_exec('ffprobe -v error -print_format json -show_streams ' . escapeshellarg("{$data}/videos/{$big['file']}")), true); $codecs = array_column($probe['streams'], 'codec_name'); check('it is H.264 with AAC', $codecs === ['h264', 'aac'], $codecs); check('in yuv420p', $probe['streams'][0]['pix_fmt'] === 'yuv420p', $probe['streams'][0]['pix_fmt']); $packets = trim((string) shell_exec('ffprobe -v error -select_streams v -show_entries packet=flags -of csv=p=0 ' . escapeshellarg("{$data}/videos/{$big['file']}"))); $keyframes = array_keys(array_filter(explode("\n", $packets), static fn(string $flags): bool => str_contains($flags, 'K'))); check('with a keyframe every 15 frames', $keyframes === [0, 15], $keyframes); $bytes = (string) file_get_contents("{$data}/videos/{$big['file']}"); check('and its index up front, so it starts before it is loaded', strpos($bytes, 'moov') < strpos($bytes, 'mdat')); $quiet = $store->record($small['file']); check('a small one stays small', [$quiet['width'], $quiet['height']] === [320, 240], $quiet); check('a silent one says so', $quiet['audio'] === false && $quiet['fps'] === 50.0, $quiet); $again = $store->store((string) file_get_contents("{$tmp}/big.mov"), 'big again.mov', $folder); check('the same upload again is the converted file, without a job', $again['status'] === 'ready' && $store->queue()->job($folder, basename($big['file'], '.mp4')) === null, $again); check('under its new name', $again['name'] === 'big again.mov'); $listed = array_column($store->list($folder, false, [$folder => 'ann']), null, 'file'); check('the picker lists ready and failed ones', $listed[$big['file']]['status'] === 'ready' && $listed["{$folder}/{$hash}.mp4"]['status'] === 'failed', array_column($listed, 'status', 'file')); $fakeAgain = $store->store($fake, 'holiday.mov', $folder); check('a failed upload sent again is tried again', $fakeAgain['status'] === 'queued', $fakeAgain); check('deleting a converted video takes poster and details with it', $store->delete($big['file']) && glob("{$data}/videos/{$folder}/" . basename($big['file'], '.mp4') . '*') === []); check('deleting a file that is not there says so', $store->delete($big['file']) === false); check('a path out of the store is no file to delete', $store->delete('../queue/x.mp4') === false); exec('rm -rf ' . escapeshellarg($data)); echo $failed === 0 ? "ALL PASSED – {$passed} checks\n" : "{$failed} FAILED, {$passed} passed\n"; exit($failed === 0 ? 0 : 1);