Choose size, rate and what follows when recording the editor

The editor recording had fixed settings: the canvas size at 60 fps,
always converted and imported. Recording in 4K needs a choice, and the
server shrank every video to 1080p anyway. Start recording... now opens
a dialog, and a recording always goes to the server as it is.

- The dialog holds name, resolution (Screen, 1080p, 1440p, 4K), fps
  (60 or 30), mouse pointer, sound, Convert and Import after
  conversion. Import is greyed out while Convert is off. The browser
  keeps the last choice, not the name.
- A larger resolution makes the editor draw finer while it records,
  so 4K is sharp, not stretched. It is never below the screen's own
  resolution and goes back after Stop.
- Stop always uploads. Without Convert the file only stays on the
  server. Without Import it is converted but no asset is added. Both
  are found in the video picker.
- The server keeps the original next to each MP4 for every upload, so
  Download in the video tab and the picker gives the original. Videos
  from before have none, they still download their MP4.
- A file left unconverted shows in the picker as not converted, with
  a Convert button. POST videos/{file}/convert queues it, and upload
  takes convert=0.
- The worker now caps a video at 3840x2160 instead of 1920x1080. Videos
  converted before stay as they are.

Tests:
- PHP videos.test.php: 81 checks green, with the original, convert=0,
  a later Convert and the 4K cap.
- Unit recording: 5 new, green. Red without the rule that a recording
  is never below the screen's resolution.
- e2e 67e-ii: the video tab downloads the .source.mkv, a Matroska
  file. 67f: 1440p from a 1280x800 window gives 2304x1440 at 30 fps
  without sound, kept and not imported, then converted in the picker.
  Both red with the MP4 download and without the finer drawing.
- The README names 2045 e2e checks.
- Gate on melektaus-atx: 304 files, 4699 tests green.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-12 17:01:55 +02:00
1 parent 93190e185c
commit ba01d146e9
26 files changed
+1011 -129

No files matched your search

+33 -12
View File
@@ -38,13 +38,14 @@ function stream(string $type, array $extra = []): array
}
// 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('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(5120, 1440) === [1920, 540], VideoFormat::fit(5120, 1440));
check('an ultra-wide one keeps its shape', VideoFormat::fit(10240, 2880) === [3840, 1080], VideoFormat::fit(10240, 2880));
$phone = [
'streams' => [
@@ -101,12 +102,14 @@ $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('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($store->queue()->source($folder, $hash)));
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);
@@ -132,7 +135,7 @@ $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('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']);
@@ -149,7 +152,7 @@ $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('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);
@@ -178,12 +181,12 @@ 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('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 and the upload are gone', $store->queue()->job($folder, basename($big['file'], '.mp4')) === null && !is_file($store->queue()->source($folder, basename($big['file'], '.mp4'))));
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');
@@ -208,7 +211,25 @@ check('the picker lists ready and failed ones', $listed[$big['file']]['status']
$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'])
// 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);