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 * @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 * @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|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|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 */ 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|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|null */ private function jobOf(string $file): ?array { $folder = self::folderOf($file); return $folder === '' ? null : $this->queue->job($folder, basename($file, '.mp4')); } /** * @param array $job * @return array */ 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 { }