Stage 3 of the rename. The repo now lives at meleki/feuerwerk, the runners carry feuerwerk labels and the WSL distro on melektaus-atx is called feuerwerk-ci. The code has to follow, or CI finds no runner and the suite no longer picks the GPU path. - ci.yml: workflow name feuerwerk, jobs on feuerwerk-ci (gate, stein2) and feuerwerk-wsl (suite, melektaus-atx). - GAME_MAKER_* variables are now FEUERWERK_*. This includes FEUERWERK_BROWSER=wsl, which the runner config on melektaus-atx sets. - The video worker runs as container feuerwerk-video with the image feuerwerk-video and works on /srv/feuerwerk. - e2e.sh starts the unit feuerwerk-e2e, the CI image keeps its paths under the new name. - README and repo links point to git.nockal.com/meleki/feuerwerk. The old URLs still redirect with 301. Both runners keep their old labels until the first CI run with this ci.yml is through. Then they go, together with the image tag game-maker-ci:latest and GAME_MAKER_BROWSER in the atx config. Tests: - Gate on melektaus-atx through the renamed distro: CHECK EXIT 0, 307 files, 4756 tests. - e2e 60 on melektaus-atx: ALL PASSED, 20 checks. Renderer is the RTX 4070 through D3D12, so FEUERWERK_BROWSER reaches the GPU path. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
241 lines
16 KiB
PHP
241 lines
16 KiB
PHP
<?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);
|
||
|
||
// 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);
|
||
|
||
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);
|