Files
feuerwerk/tests/php/videos.test.php
T
adminandClaude Opus 5.5 043c224cc3 Rename Game Maker to Feuerwerk and serve it at /feuerwerk/ (#422)
The editor gets a name of its own that fits beside Brandmauer,
Glutspeicher and Feuerwehr. This is stage 1, 2 and 4 of #422: the
name, the URL and the stored state in the browser. Repository, CI
runners, containers and the GAME_MAKER_* variables follow in stage 3.

- All paths come from SITE_PATH in src/core/site.ts: the API endpoint,
  the data folders, the play page and the base in vite.config.ts. A
  later move is one line instead of fifteen.
- The window title, the sign-in screen, the playground and the README
  say Feuerwerk. The package, __feuerwerkPlay, the take worklet and the
  writing app in Ogg and Matroska follow.
- The session cookie is now feuerwerk_session, so everyone signs in
  once more.
- localStorage keys are now feuerwerk.*. moveOldStoredKeys copies the
  old game-maker.* keys over on the first start, so settings, panel
  sizes and save games survive.
- tools/ci/serve.sh serves the build under /feuerwerk/. The variable
  GAME_MAKER_BASE_URL keeps its name until stage 3.

Outside the repository, nockal.com/feuerwerk/ is a second symlink to
the same tree. nginx sends the bare /game-maker/ folder there with a
301. Everything else under /game-maker/ is still served directly, so
shared play links and the API keep working.

Finding: when nginx redirects a path without a trailing slash or with
a query, the Brandmauer answers with its own 307 to the same URL plus
a slash, placed after the query. A play link would lose its token that
way, which is why only the bare folder is redirected.

Tests:
- Gate on melektaus-atx: CHECK EXIT 0, 307 files, 4756 tests
  (4 new in site.test.ts).
- Counter-check: when the migration misses the old prefix, 3 of the
  4 new tests fail.
- e2e 60 under /feuerwerk/: ALL PASSED, 20 checks.
- From outside: /feuerwerk/ 200, /game-maker/ 301, an old play
  link 200.

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

241 lines
16 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 `game-maker-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);