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>
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 `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);
|