typeFor($file, $collection); $uuid = (string) Str::uuid(); $directory = "event-media/{$event->user_id}/{$event->client_id}"; $attributes = match ($type) { self::TYPE_IMAGE => $this->storeImage($file, $directory, $uuid), self::TYPE_VIDEO => $this->storeVideo($file, $directory, $uuid), default => $this->storeAudio($file, $directory, $uuid), }; if ($collection === 'key_image') { $this->deleteExistingInCollection($event, 'key_image'); } $media = $event->media()->create([ 'uuid' => $uuid, 'user_id' => $event->user_id, 'collection' => $collection, 'type' => $type, 'caption' => $caption !== null && $caption !== '' ? $caption : null, 'name' => $file->getClientOriginalName(), 'disk' => 'local', ...$attributes, ]); if ($collection === 'key_image') { $event->update(['image' => "/event-media/{$media->id}/thumb"]); } return $media; } public function typeFor(UploadedFile $file, string $collection): string { if ($collection === 'voice_note') { return self::TYPE_AUDIO; } $mime = (string) ($file->getMimeType() ?: $file->getClientMimeType()); return str_starts_with($mime, 'video/') ? self::TYPE_VIDEO : self::TYPE_IMAGE; } /** * @return array */ private function storeImage(UploadedFile $file, string $directory, string $uuid): array { $processed = $this->processor->process($file); $path = "{$directory}/{$uuid}.jpg"; $previewPath = "{$directory}/{$uuid}_preview.jpg"; $thumbnailPath = "{$directory}/{$uuid}_thumb.jpg"; Storage::disk('local')->put($path, $processed['original']); Storage::disk('local')->put($previewPath, $processed['preview']); Storage::disk('local')->put($thumbnailPath, $processed['thumbnail']); return [ 'mime_type' => $processed['mime_type'], 'path' => $path, 'thumbnail_path' => $thumbnailPath, 'preview_path' => $previewPath, 'size' => $processed['size'], 'duration_ms' => null, 'width' => $processed['width'], 'height' => $processed['height'], 'thumbnail_width' => $processed['thumbnail_width'], 'thumbnail_height' => $processed['thumbnail_height'], 'preview_width' => $processed['preview_width'], 'preview_height' => $processed['preview_height'], ]; } /** * @return array */ private function storeVideo(UploadedFile $file, string $directory, string $uuid): array { $extension = $this->extensionFor($file, 'mp4'); $path = "{$directory}/{$uuid}.{$extension}"; $realPath = (string) $file->getRealPath(); Storage::disk('local')->putFileAs($directory, $file, "{$uuid}.{$extension}"); $durationMs = $this->probe->durationMs($realPath); $poster = $this->posterFor($realPath, $durationMs); $attributes = [ 'mime_type' => $file->getMimeType() ?: ($file->getClientMimeType() ?: 'video/mp4'), 'path' => $path, 'thumbnail_path' => null, 'preview_path' => null, 'size' => $file->getSize(), 'duration_ms' => $durationMs, 'width' => null, 'height' => null, 'thumbnail_width' => null, 'thumbnail_height' => null, 'preview_width' => null, 'preview_height' => null, ]; if ($poster !== null) { $previewPath = "{$directory}/{$uuid}_preview.jpg"; $thumbnailPath = "{$directory}/{$uuid}_thumb.jpg"; Storage::disk('local')->put($previewPath, $poster['preview']); Storage::disk('local')->put($thumbnailPath, $poster['thumbnail']); $attributes = [ ...$attributes, 'thumbnail_path' => $thumbnailPath, 'preview_path' => $previewPath, 'width' => $poster['width'], 'height' => $poster['height'], 'thumbnail_width' => $poster['thumbnail_width'], 'thumbnail_height' => $poster['thumbnail_height'], 'preview_width' => $poster['preview_width'], 'preview_height' => $poster['preview_height'], ]; } return $attributes; } /** * @return array */ private function storeAudio(UploadedFile $file, string $directory, string $uuid): array { $transcoded = $this->transcodedAudio($file); if ($transcoded !== null) { $path = "{$directory}/{$uuid}.m4a"; Storage::disk('local')->put($path, (string) file_get_contents($transcoded)); $durationMs = $this->probe->durationMs($transcoded); $size = (int) filesize($transcoded); @unlink($transcoded); return $this->audioAttributes($path, 'audio/mp4', $size, $durationMs); } // Without ffmpeg the recording is kept as delivered. $extension = $this->extensionFor($file, 'webm'); $path = "{$directory}/{$uuid}.{$extension}"; Storage::disk('local')->putFileAs($directory, $file, "{$uuid}.{$extension}"); // Browsers record audio into containers libmagic reports as video // (WebM, MP4). The client's own type is the honest one here. $clientMime = (string) $file->getClientMimeType(); $mime = str_starts_with($clientMime, 'audio/') ? $clientMime : ($file->getMimeType() ?: 'audio/webm'); return $this->audioAttributes( $path, $mime, (int) $file->getSize(), $this->probe->durationMs((string) $file->getRealPath()), ); } /** * Path of a temporary AAC/MP4 rendition of the upload, or null when * ffmpeg is unavailable or the input could not be decoded. */ private function transcodedAudio(UploadedFile $file): ?string { $target = tempnam(sys_get_temp_dir(), 'voice_').'.m4a'; if ($this->probe->transcodeAudioToM4a((string) $file->getRealPath(), $target)) { @unlink(substr($target, 0, -4)); return $target; } @unlink($target); @unlink(substr($target, 0, -4)); return null; } /** * @return array */ private function audioAttributes(string $path, string $mime, int $size, ?int $durationMs): array { return [ 'mime_type' => $mime, 'path' => $path, 'thumbnail_path' => null, 'preview_path' => null, 'size' => $size, 'duration_ms' => $durationMs, 'width' => null, 'height' => null, 'thumbnail_width' => null, 'thumbnail_height' => null, 'preview_width' => null, 'preview_height' => null, ]; } /** * Poster frame of a video, run through the image pipeline so it gets the * same preview and thumbnail sizes as an uploaded photo. * * @return array|null */ private function posterFor(string $videoPath, ?int $durationMs): ?array { $posterPath = tempnam(sys_get_temp_dir(), 'poster_').'.jpg'; try { if (! $this->probe->posterFrame($videoPath, $posterPath, $durationMs)) { return null; } $poster = new UploadedFile($posterPath, 'poster.jpg', 'image/jpeg', null, true); return $this->processor->process($poster); } catch (\Throwable) { return null; } finally { @unlink($posterPath); @unlink(substr($posterPath, 0, -4)); } } private function extensionFor(UploadedFile $file, string $fallback): string { $extension = strtolower((string) ($file->getClientOriginalExtension() ?: $file->guessExtension())); $extension = preg_replace('/[^a-z0-9]/', '', $extension) ?? ''; return $extension !== '' ? $extension : $fallback; } private function deleteExistingInCollection(Event $event, string $collection): void { foreach ($event->media()->where('collection', $collection)->get() as $media) { $this->mediaCleaner->purgeMedia($media); $media->delete(); } } }