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. * * @return array * @throws InvalidVideoException when the bytes cannot be a video */ public function store(string $bytes, string $originalName, string $folder): array { $in = fopen('php://memory', 'r+b'); fwrite($in, $bytes); rewind($in); try { return $this->storeStream($in, strlen($bytes), $originalName, $folder); } 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): array { $upload = $this->queue->receive($in, $maxBytes); try { if (!self::looksLikeVideo((string) file_get_contents($upload, false, null, 0, 256))) { 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 (!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)); } } return $this->record("{$folder}/{$hash}.mp4"); } finally { if (is_file($upload)) { unlink($upload); } } } /** * 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); return $job === null ? null : $this->pending($job); } /** A video is there for deleting while it is converted or failed, too. */ public function exists(string $file): bool { return parent::exists($file) || $this->jobOf($file) !== null; } /** Lists the converted files like every store does, and the ones on their way after them. */ 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, ]; } } 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); 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); 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', ]; } /** @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, ]; } 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 { }