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:
1 parent
93190e185c
commit
ba01d146e9
26 files changed
+1011
-129
No files matched your search
+144
-19
@@ -2,22 +2,31 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Videos (#402): `data/videos/{folder}/{hash}.mp4`, beside it `{hash}.jpg` (the poster) and
|
||||
* `{hash}.meta.json` (size, length, frame rate, sound), all served as they are.
|
||||
* Videos (#402): `data/videos/{folder}/{hash}.mp4`, beside it `{hash}.jpg` (the poster),
|
||||
* `{hash}.meta.json` (size, length, frame rate, sound) and `{hash}.source.{ext}`, the upload as it
|
||||
* came, all served as they are.
|
||||
*
|
||||
* Unlike an image or a sound a video is not kept as it came: the upload goes into the VideoQueue,
|
||||
* and the worker writes the MP4 when it is done (see VideoFormat). The hash is the one of the
|
||||
* uploaded bytes, so the name a project stores is known the moment the upload is in, long before
|
||||
* the file exists, and uploading the same file twice converts it once.
|
||||
* What a project plays is the MP4 the worker makes of the upload (see VideoFormat), which waits in
|
||||
* the VideoQueue until then. The upload itself is kept for downloading it, and one sent up without
|
||||
* being converted stays only that until it is asked to be. The hash is the one of the uploaded
|
||||
* bytes, so the name a project stores is known the moment the upload is in, long before the file
|
||||
* exists, and uploading the same file twice converts it once. Videos uploaded before the uploads
|
||||
* were kept have none.
|
||||
*/
|
||||
final class VideoStore extends HashedFileStore
|
||||
{
|
||||
/** An upload kept, waiting for nobody to convert it. */
|
||||
public const UNCONVERTED = 'unconverted';
|
||||
|
||||
/**
|
||||
* What an upload has to start with to be sent to ffmpeg at all: the ISO family (MP4, MOV, M4V,
|
||||
* 3GP), Matroska (MKV, WebM), AVI and MPEG transport streams. ffmpeg has the last word.
|
||||
*/
|
||||
private const SIGNATURE_HINT = 'MP4, MOV, WebM, MKV or AVI';
|
||||
|
||||
/** The endings a kept upload may have, taken from its name; anything else is told by its bytes. */
|
||||
private const SOURCE_EXTENSIONS = ['mp4', 'mov', 'm4v', '3gp', 'mkv', 'webm', 'avi', 'ts', 'mts', 'm2ts'];
|
||||
|
||||
private readonly VideoQueue $queue;
|
||||
|
||||
public function __construct(private readonly string $root)
|
||||
@@ -38,18 +47,18 @@ final class VideoStore extends HashedFileStore
|
||||
|
||||
/**
|
||||
* Takes an upload: a file already converted answers as it is, anything else waits in the
|
||||
* queue, a failed one is tried again.
|
||||
* queue, a failed one is tried again. Without `$convert` it is only kept.
|
||||
*
|
||||
* @return array<string,mixed>
|
||||
* @throws InvalidVideoException when the bytes cannot be a video
|
||||
*/
|
||||
public function store(string $bytes, string $originalName, string $folder): array
|
||||
public function store(string $bytes, string $originalName, string $folder, bool $convert = true): array
|
||||
{
|
||||
$in = fopen('php://memory', 'r+b');
|
||||
fwrite($in, $bytes);
|
||||
rewind($in);
|
||||
try {
|
||||
return $this->storeStream($in, strlen($bytes), $originalName, $folder);
|
||||
return $this->storeStream($in, strlen($bytes), $originalName, $folder, $convert);
|
||||
} finally {
|
||||
fclose($in);
|
||||
}
|
||||
@@ -64,11 +73,12 @@ final class VideoStore extends HashedFileStore
|
||||
* @throws VideoTooLargeException when the stream has more than `$maxBytes`
|
||||
* @throws InvalidVideoException when the bytes cannot be a video
|
||||
*/
|
||||
public function storeStream($in, int $maxBytes, string $originalName, string $folder): array
|
||||
public function storeStream($in, int $maxBytes, string $originalName, string $folder, bool $convert = true): array
|
||||
{
|
||||
$upload = $this->queue->receive($in, $maxBytes);
|
||||
try {
|
||||
if (!self::looksLikeVideo((string) file_get_contents($upload, false, null, 0, 256))) {
|
||||
$head = (string) file_get_contents($upload, false, null, 0, 256);
|
||||
if (!self::looksLikeVideo($head)) {
|
||||
throw new InvalidVideoException('Not a video file – use ' . self::SIGNATURE_HINT);
|
||||
}
|
||||
if (!preg_match('/^' . UserStore::FOLDER_PATTERN . '$/', $folder)) {
|
||||
@@ -80,12 +90,15 @@ final class VideoStore extends HashedFileStore
|
||||
throw new RuntimeException("Cannot create directory {$dir}");
|
||||
}
|
||||
OriginalName::write($dir, "{$hash}.mp4", $originalName);
|
||||
if (!is_file("{$dir}/{$hash}.mp4")) {
|
||||
$job = $this->queue->job($folder, $hash);
|
||||
if ($job === null || $job['state'] === VideoQueue::FAILED) {
|
||||
$this->queue->add($folder, $hash, $upload, OriginalName::sanitize($originalName));
|
||||
if ($this->sourceOf($folder, $hash) === null) {
|
||||
$source = "{$dir}/{$hash}.source." . self::sourceExtension($originalName, $head);
|
||||
if (!chmod($upload, AtomicFile::SERVED) || !rename($upload, $source)) {
|
||||
throw new RuntimeException('Cannot write uploaded video');
|
||||
}
|
||||
}
|
||||
if ($convert) {
|
||||
$this->enqueue($folder, $hash);
|
||||
}
|
||||
return $this->record("{$folder}/{$hash}.mp4");
|
||||
} finally {
|
||||
if (is_file($upload)) {
|
||||
@@ -94,6 +107,35 @@ final class VideoStore extends HashedFileStore
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts an upload that was kept without: it waits in the queue like any other, a failed one
|
||||
* is tried again. Null when there is no such video.
|
||||
*
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
public function convert(string $file): ?array
|
||||
{
|
||||
$folder = self::isValidName($file) ? self::folderOf($file) : '';
|
||||
if ($folder === '') {
|
||||
return null;
|
||||
}
|
||||
$this->enqueue($folder, basename($file, '.mp4'));
|
||||
return $this->record($file);
|
||||
}
|
||||
|
||||
/** Queues a kept upload, unless it is converted or on its way already. */
|
||||
private function enqueue(string $folder, string $hash): void
|
||||
{
|
||||
$source = $this->sourceOf($folder, $hash);
|
||||
if ($source === null || is_file("{$this->root}/{$folder}/{$hash}.mp4")) {
|
||||
return;
|
||||
}
|
||||
$job = $this->queue->job($folder, $hash);
|
||||
if ($job === null || $job['state'] === VideoQueue::FAILED) {
|
||||
$this->queue->add($folder, $hash, $source, OriginalName::read("{$this->root}/{$folder}", "{$hash}.mp4"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One file as the picker shows it, done or not; null when there is neither file nor job.
|
||||
*
|
||||
@@ -115,16 +157,23 @@ final class VideoStore extends HashedFileStore
|
||||
];
|
||||
}
|
||||
$job = $this->jobOf($file);
|
||||
return $job === null ? null : $this->pending($job);
|
||||
if ($job !== null) {
|
||||
return $this->pending($job);
|
||||
}
|
||||
$folder = self::folderOf($file);
|
||||
return $folder === '' ? null : $this->unconverted($folder, basename($file, '.mp4'));
|
||||
}
|
||||
|
||||
/** A video is there for deleting while it is converted or failed, too. */
|
||||
/** A video is there for deleting while it is converted, failed or only kept, too. */
|
||||
public function exists(string $file): bool
|
||||
{
|
||||
return parent::exists($file) || $this->jobOf($file) !== null;
|
||||
return $this->record($file) !== null;
|
||||
}
|
||||
|
||||
/** Lists the converted files like every store does, and the ones on their way after them. */
|
||||
/**
|
||||
* Lists the converted files like every store does, then the ones on their way, then the ones
|
||||
* kept without being converted.
|
||||
*/
|
||||
public function list(string $folder, bool $seesAll, array $owners): array
|
||||
{
|
||||
$items = parent::list($folder, $seesAll, $owners);
|
||||
@@ -137,6 +186,20 @@ final class VideoStore extends HashedFileStore
|
||||
];
|
||||
}
|
||||
}
|
||||
foreach (glob("{$this->root}/*/*.source.*") ?: [] as $path) {
|
||||
$owner = basename(dirname($path));
|
||||
if (!preg_match('/^([a-f0-9]{64})\.source\.[a-z0-9]+$/', basename($path), $m) || (!$seesAll && $owner !== $folder)) {
|
||||
continue;
|
||||
}
|
||||
$record = $this->unconverted($owner, $m[1]);
|
||||
if ($record !== null) {
|
||||
$items[] = $record + [
|
||||
'updatedAt' => gmdate('c', (int) filemtime($path)),
|
||||
'owner' => $owners[$owner] ?? '',
|
||||
'deletable' => true,
|
||||
];
|
||||
}
|
||||
}
|
||||
return $items;
|
||||
}
|
||||
|
||||
@@ -148,6 +211,10 @@ final class VideoStore extends HashedFileStore
|
||||
$folder = self::folderOf($file);
|
||||
$hash = basename($file, '.mp4');
|
||||
$removed = $folder !== '' && $this->queue->remove($folder, $hash);
|
||||
$source = $folder === '' ? null : $this->sourceOf($folder, $hash);
|
||||
if ($source !== null) {
|
||||
$removed = unlink("{$this->root}/{$source}") || $removed;
|
||||
}
|
||||
if (is_file("{$this->root}/{$file}")) {
|
||||
$removed = parent::delete($file) || $removed;
|
||||
$dir = $folder === '' ? $this->root : "{$this->root}/{$folder}";
|
||||
@@ -177,6 +244,7 @@ final class VideoStore extends HashedFileStore
|
||||
$details = $raw === false ? null : json_decode($raw, true);
|
||||
$details = is_array($details) ? $details : [];
|
||||
$file = substr($path, strlen($this->root) + 1);
|
||||
$folder = self::folderOf($file);
|
||||
return [
|
||||
'status' => 'ready',
|
||||
'width' => (int) ($details['width'] ?? 0),
|
||||
@@ -185,9 +253,65 @@ final class VideoStore extends HashedFileStore
|
||||
'fps' => (float) ($details['fps'] ?? 0),
|
||||
'audio' => (bool) ($details['audio'] ?? false),
|
||||
'poster' => substr($file, 0, -strlen('.mp4')) . '.jpg',
|
||||
'original' => $folder === '' ? null : $this->sourceOf($folder, basename($file, '.mp4')),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the upload of a video is kept, `{folder}/{hash}.source.{ext}` as it is served; null for
|
||||
* one uploaded before uploads were kept.
|
||||
*/
|
||||
private function sourceOf(string $folder, string $hash): ?string
|
||||
{
|
||||
foreach (glob("{$this->root}/{$folder}/{$hash}.source.*") ?: [] as $path) {
|
||||
if (preg_match('/^[a-f0-9]{64}\.source\.[a-z0-9]+$/', basename($path))) {
|
||||
return "{$folder}/" . basename($path);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A kept upload that is neither converted nor on its way, as the picker shows it; null when
|
||||
* the video is either, or there is no upload.
|
||||
*
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
private function unconverted(string $folder, string $hash): ?array
|
||||
{
|
||||
$source = $this->sourceOf($folder, $hash);
|
||||
if ($source === null || is_file("{$this->root}/{$folder}/{$hash}.mp4") || $this->queue->job($folder, $hash) !== null) {
|
||||
return null;
|
||||
}
|
||||
return [
|
||||
'file' => "{$folder}/{$hash}.mp4",
|
||||
'name' => OriginalName::read("{$this->root}/{$folder}", "{$hash}.mp4"),
|
||||
'status' => self::UNCONVERTED,
|
||||
'progress' => 0,
|
||||
'error' => '',
|
||||
'bytes' => (int) filesize("{$this->root}/{$source}"),
|
||||
'original' => $source,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The ending a kept upload is served with: the one of its name when that is a video's, else the
|
||||
* one its first bytes tell.
|
||||
*/
|
||||
private static function sourceExtension(string $name, string $head): string
|
||||
{
|
||||
$extension = strtolower(pathinfo($name, PATHINFO_EXTENSION));
|
||||
if (in_array($extension, self::SOURCE_EXTENSIONS, true)) {
|
||||
return $extension;
|
||||
}
|
||||
return match (true) {
|
||||
substr($head, 4, 4) === 'ftyp' => substr($head, 8, 4) === 'qt ' ? 'mov' : 'mp4',
|
||||
str_starts_with($head, "\x1A\x45\xDF\xA3") => str_contains($head, 'webm') ? 'webm' : 'mkv',
|
||||
str_starts_with($head, 'RIFF') => 'avi',
|
||||
default => 'ts',
|
||||
};
|
||||
}
|
||||
|
||||
/** @return array<string,mixed>|null */
|
||||
private function jobOf(string $file): ?array
|
||||
{
|
||||
@@ -208,6 +332,7 @@ final class VideoStore extends HashedFileStore
|
||||
'progress' => (float) $job['progress'],
|
||||
'error' => (string) $job['error'],
|
||||
'bytes' => 0,
|
||||
'original' => $this->sourceOf((string) $job['folder'], (string) $job['hash']),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user