Files
feuerwerk/api/lib/VideoStore.php
T
adminandClaude Opus 5.5 ba01d146e9 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>
2026-10-12 17:01:55 +02:00

360 lines
14 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): `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.
*
* 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)
{
parent::__construct($root);
$this->queue = new VideoQueue("{$root}/queue");
}
protected static function filePattern(): string
{
return '[a-f0-9]{64}\.mp4';
}
public function queue(): VideoQueue
{
return $this->queue;
}
/**
* Takes an upload: a file already converted answers as it is, anything else waits in the
* 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, bool $convert = true): array
{
$in = fopen('php://memory', 'r+b');
fwrite($in, $bytes);
rewind($in);
try {
return $this->storeStream($in, strlen($bytes), $originalName, $folder, $convert);
} finally {
fclose($in);
}
}
/**
* Like store(), straight from the request: the bytes go to disk as they come, and only their
* first ones and their hash are looked at, so a video of 2 GB needs no 2 GB of memory.
*
* @param resource $in
* @return array<string,mixed>
* @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, bool $convert = true): array
{
$upload = $this->queue->receive($in, $maxBytes);
try {
$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)) {
throw new InvalidArgumentException("Invalid folder: {$folder}");
}
$hash = hash_file('sha256', $upload);
$dir = "{$this->root}/{$folder}";
if (!is_dir($dir) && !mkdir($dir, 0775, true) && !is_dir($dir)) {
throw new RuntimeException("Cannot create directory {$dir}");
}
OriginalName::write($dir, "{$hash}.mp4", $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)) {
unlink($upload);
}
}
}
/**
* 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.
*
* @return array<string,mixed>|null
*/
public function record(string $file): ?array
{
if (!self::isValidName($file)) {
return null;
}
if (is_file("{$this->root}/{$file}")) {
$folder = self::folderOf($file);
$dir = $folder === '' ? $this->root : "{$this->root}/{$folder}";
return [
'file' => $file,
'name' => OriginalName::read($dir, basename($file)),
...$this->details("{$this->root}/{$file}"),
'bytes' => (int) filesize("{$this->root}/{$file}"),
];
}
$job = $this->jobOf($file);
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, failed or only kept, too. */
public function exists(string $file): bool
{
return $this->record($file) !== null;
}
/**
* 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);
foreach ($this->queue->jobs() as $job) {
if ($seesAll || $job['folder'] === $folder) {
$items[] = $this->pending($job) + [
'updatedAt' => (string) $job['createdAt'],
'owner' => $owners[$job['folder']] ?? '',
'deletable' => true,
];
}
}
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;
}
public function delete(string $file): bool
{
if (!self::isValidName($file)) {
return false;
}
$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}";
foreach (["{$dir}/{$hash}.jpg", "{$dir}/{$hash}.meta.json"] as $path) {
if (is_file($path)) {
unlink($path);
}
}
} elseif ($removed) {
OriginalName::delete("{$this->root}/{$folder}", "{$hash}.mp4");
}
return $removed;
}
/** Called by the worker once the MP4 is in place: what the editor reads instead of probing. */
public static function writeDetails(string $dir, string $hash, array $details): void
{
$json = json_encode($details, JSON_UNESCAPED_SLASHES);
AtomicFile::write("{$dir}/{$hash}.meta.json", (string) $json, AtomicFile::SERVED, 'video details');
}
/** @return array<string,mixed> */
protected function details(string $path): array
{
$meta = substr($path, 0, -strlen('.mp4')) . '.meta.json';
$raw = is_file($meta) ? file_get_contents($meta) : false;
$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),
'height' => (int) ($details['height'] ?? 0),
'duration' => (float) ($details['duration'] ?? 0),
'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
{
$folder = self::folderOf($file);
return $folder === '' ? null : $this->queue->job($folder, basename($file, '.mp4'));
}
/**
* @param array<string,mixed> $job
* @return array<string,mixed>
*/
private function pending(array $job): array
{
return [
'file' => "{$job['folder']}/{$job['hash']}.mp4",
'name' => (string) $job['name'],
'status' => (string) $job['state'],
'progress' => (float) $job['progress'],
'error' => (string) $job['error'],
'bytes' => 0,
'original' => $this->sourceOf((string) $job['folder'], (string) $job['hash']),
];
}
private static function looksLikeVideo(string $bytes): bool
{
if (strlen($bytes) < 16) {
return false;
}
return substr($bytes, 4, 4) === 'ftyp'
|| str_starts_with($bytes, "\x1A\x45\xDF\xA3")
|| (str_starts_with($bytes, 'RIFF') && substr($bytes, 8, 4) === 'AVI ')
|| ($bytes[0] === "\x47" && strlen($bytes) > 188 && $bytes[188] === "\x47");
}
}
/** Thrown by VideoStore::store when the uploaded bytes cannot be a video. */
final class InvalidVideoException extends RuntimeException
{
}
/** Thrown by VideoQueue::receive when an upload is bigger than the API takes. */
final class VideoTooLargeException extends RuntimeException
{
}