Files
feuerwerk/tests/php/videos.test.php
T
adminandClaude Opus 5.5 31ce6480b2
feuerwerk / gate (push) Successful in 4m30s
feuerwerk / suite (push) Successful in 18m12s
Give video posters the colours the video shows (#411)
The worker made the poster with a plain scale=320:-2. ffmpeg then
read every picture as BT.601 in the limited range, so a red of 255 in
a BT.709 video came out as 229 in the JPEG. The picker showed paler
posters than the video in the editor.

- VideoFormat::colour reads matrix and range of the converted MP4.
  When the file says nothing, it follows Chrome: BT.709 from 720 lines
  up, BT.601 below. That guess was measured in Chromium with a red
  H.264 video without colour tags.
- posterArgs turns the picture from that matrix into BT.601 in the
  full range, the way a JPEG is read. accurate_rnd and
  full_chroma_int keep dark greys from landing two steps too low.
- The search for the first moving video stream is now one helper,
  used by plan and colour.
- Posters made before this change stay as they are.

Tests:
- videos.test.php in the worker image: ALL PASSED, 90 checks. Three
  red videos go through the real worker: BT.709 tagged, 720p without
  tags and 360p in BT.601. Red comes out as 254.
- Counter-check with the old filter: 3 checks red, red is 229 again.
- php -l clean on all three files. The gate was not run, the change
  has no TypeScript.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 16:45:20 +02:00

282 lines
18 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
/**
* Videos (#402): the conversion rules, the store and its queue, and the worker with the real
* ffmpeg. Plain PHP like files.test.php; CI runs it with `php tests/php/videos.test.php`. It needs
* ffmpeg and ffprobe on the PATH, which the CI image and the container `feuerwerk-video` have.
*/
require __DIR__ . '/../../api/lib/AtomicFile.php';
require __DIR__ . '/../../api/lib/OriginalName.php';
require __DIR__ . '/../../api/lib/UserStore.php';
require __DIR__ . '/../../api/lib/HashedFileStore.php';
require __DIR__ . '/../../api/lib/VideoFormat.php';
require __DIR__ . '/../../api/lib/VideoQueue.php';
require __DIR__ . '/../../api/lib/VideoStore.php';
require __DIR__ . '/../../api/lib/VideoWorker.php';
$failed = 0;
$passed = 0;
function check(string $name, bool $ok, mixed $got = null): void
{
global $failed, $passed;
if ($ok) {
$passed++;
echo "PASS {$name}\n";
return;
}
$failed++;
echo "FAIL {$name}", $got === null ? '' : ' — got ' . json_encode($got, JSON_UNESCAPED_UNICODE), "\n";
}
/** @param array<string,mixed> $extra */
function stream(string $type, array $extra = []): array
{
return ['codec_type' => $type, ...$extra];
}
// The rules, without ffmpeg.
check('8K fits into 4K', VideoFormat::fit(7680, 4320) === [3840, 2160], VideoFormat::fit(7680, 4320));
check('a standing 8K fits standing up', VideoFormat::fit(4320, 7680) === [2160, 3840], VideoFormat::fit(4320, 7680));
check('4:3 is bound by its height', VideoFormat::fit(4000, 3000) === [2880, 2160], VideoFormat::fit(4000, 3000));
check('4K stays', VideoFormat::fit(3840, 2160) === [3840, 2160]);
check('so does an editor recorded at 4K in a wider window', VideoFormat::fit(3840, 1958) === [3840, 1958]);
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(10240, 2880) === [3840, 1080], VideoFormat::fit(10240, 2880));
$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);
$colourOf = static fn(array $video): array => VideoFormat::colour(['streams' => [stream('video', $video)]]);
check('a poster reads a video as the video says', $colourOf(['color_space' => 'bt709', 'height' => 360, 'color_range' => 'tv']) === ['matrix' => 'bt709', 'range' => 'tv']);
check('BT.601 by either of its names', $colourOf(['color_space' => 'smpte170m', 'height' => 1080])['matrix'] === 'bt601' && $colourOf(['color_space' => 'bt470bg'])['matrix'] === 'bt601');
check('without a word, as Chrome guesses: BT.709 from 720 lines', $colourOf(['height' => 720])['matrix'] === 'bt709' && $colourOf(['color_space' => 'unknown', 'height' => 1080])['matrix'] === 'bt709');
check('and BT.601 below', $colourOf(['height' => 719])['matrix'] === 'bt601', $colourOf(['height' => 719]));
check('the full range only when it says so', $colourOf(['color_range' => 'pc'])['range'] === 'pc' && $colourOf([])['range'] === 'tv');
check('the poster turns it into what a JPEG is read as', str_contains(VideoFormat::posterArgs('in', 'out', 2.0, ['matrix' => 'bt709', 'range' => 'tv'])[13], 'in_color_matrix=bt709:in_range=tv:out_color_matrix=bt601:out_range=pc'));
// The store and its queue.
$data = sys_get_temp_dir() . '/feuerwerk-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 kept as it came, named after its bytes and its name', $record['original'] === "{$folder}/{$hash}.source.mov", $record);
check('it is what the worker reads', $store->queue()->source($folder, $hash) === "{$data}/videos/{$record['original']}");
check('and is served, for downloading it', (fileperms("{$data}/videos/{$record['original']}") & 0777) === 0664);
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("{$data}/videos/{$record['original']}"));
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 kept as well, with the ending of its name', $record['original'] === "{$folder}/" . hash('sha256', $streamed) . '.source.mp4', $record);
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 stays for downloading, the job stays to say why', is_file("{$data}/videos/{$folder}/{$hash}.source.mov") && $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 stays 4K', [$done['width'], $done['height']] === [3840, 2160], $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 is gone, the upload stays as it came', $store->queue()->job($folder, basename($big['file'], '.mp4')) === null && $done['original'] === substr($big['file'], 0, -4) . '.source.mov' && file_get_contents("{$data}/videos/{$done['original']}") === file_get_contents("{$tmp}/big.mov"), $done);
$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);
// An upload kept without being converted, converted later.
$kept = $store->store((string) file_get_contents("{$tmp}/small.mov"), 'kept.mkv', $folder, false);
check('without converting the upload is the converted file it already is', $kept['status'] === 'ready', $kept);
makeVideo("{$tmp}/later.mov", '320x180', 30, 0.5, false);
$later = $store->store((string) file_get_contents("{$tmp}/later.mov"), 'later.mkv', $folder, false);
$laterHash = basename($later['file'], '.mp4');
check('a new upload without converting is only kept', $later['status'] === 'unconverted' && $later['original'] === "{$folder}/{$laterHash}.source.mkv" && $later['bytes'] === filesize("{$tmp}/later.mov"), $later);
check('no job waits for it', $store->queue()->job($folder, $laterHash) === null && $worker->runOnce() === true && $store->record($later['file'])['status'] === 'unconverted');
$listed = array_column($store->list($folder, false, [$folder => 'ann']), null, 'file');
check('the picker lists it once, as not converted, with its owner', ($listed[$later['file']]['status'] ?? '') === 'unconverted' && $listed[$later['file']]['owner'] === 'ann' && $listed[$later['file']]['deletable'] === true, $listed[$later['file']] ?? null);
check('but not for another account', !in_array($later['file'], array_column($store->list('ffffffffffff', false, []), 'file'), true));
check('it counts as there for deleting', $store->exists($later['file']));
$asked = $store->convert($later['file']);
check('asked to, it is queued, under its name', $asked['status'] === 'queued' && $store->queue()->job($folder, $laterHash)['name'] === 'later.mkv', $asked);
check('asking again queues it once', $store->convert($later['file'])['status'] === 'queued' && count(array_filter($store->queue()->jobs(), static fn(array $j): bool => $j['hash'] === $laterHash)) === 1);
check('the worker converts it from the kept upload', $worker->runOnce() && $store->record($later['file'])['status'] === 'ready' && $store->record($later['file'])['original'] === $later['original']);
check('a video not there cannot be converted', $store->convert("{$folder}/" . str_repeat('0', 64) . '.mp4') === null && $store->convert('../x.mp4') === null);
// The poster is as red as the video (#411): tagged BT.709, and untagged on both sides of 720 lines.
/** A red picture in H.264, its colours turned with `$matrix`, said in the file only when `$tagged`. */
function makeRed(string $path, string $size, string $matrix, bool $tagged): void
{
$tags = $tagged ? ['-colorspace', $matrix, '-color_primaries', $matrix, '-color_trc', $matrix] : [];
$args = [
'ffmpeg', '-v', 'error', '-y', '-f', 'lavfi', '-i', "color=0xff0000:size={$size}:rate=25:duration=0.6",
'-vf', "scale=out_color_matrix={$matrix}:out_range=tv", '-pix_fmt', 'yuv420p', '-c:v', 'libx264', '-qp', '0',
...$tags, $path,
];
exec(implode(' ', array_map('escapeshellarg', $args)), $out, $code);
if ($code !== 0) {
throw new RuntimeException("cannot make {$path}");
}
}
/** @return list<int> red, green, blue in the middle of a JPEG */
function middleOf(string $jpeg): array
{
$rgb = (string) shell_exec('ffmpeg -v error -i ' . escapeshellarg($jpeg) . ' -vf format=rgb24,crop=1:1:iw/2:ih/2 -f rawvideo -pix_fmt rgb24 -');
return array_values(unpack('C3', $rgb) ?: []);
}
foreach ([['1280x720', 'bt709', true], ['1280x720', 'bt709', false], ['640x360', 'smpte170m', false]] as [$size, $matrix, $tagged]) {
makeRed("{$tmp}/red.mp4", $size, $matrix, $tagged);
$red = $store->store((string) file_get_contents("{$tmp}/red.mp4"), 'red.mp4', $folder);
while ($worker->runOnce()) {
}
$rgb = middleOf("{$data}/videos/" . $store->record($red['file'])['poster']);
$said = $tagged ? 'saying so' : 'saying nothing';
check("a red {$size} video in {$matrix}, {$said}, has a red poster", count($rgb) === 3 && $rgb[0] >= 245 && $rgb[1] <= 10 && $rgb[2] <= 10, $rgb);
$store->delete($red['file']);
}
check('deleting a converted video takes poster, details and the upload 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);