diff --git a/config/assets.php b/config/assets.php index ba2d99ed566..dddedd21257 100644 --- a/config/assets.php +++ b/config/assets.php @@ -130,6 +130,18 @@ 'max_height' => 10000, ], + /* + |-------------------------------------------------------------------------- + | Control Panel Video Thumbnails + |-------------------------------------------------------------------------- + | + | When enabled, Statamic will generate thumbnails for videos. + | Generated thumbnails are displayed in the Control Panel. + | + */ + + 'video_thumbnails' => true, + /* |-------------------------------------------------------------------------- | File Previews with Google Docs @@ -208,4 +220,19 @@ 'svg_sanitization_on_upload' => true, + /* + |-------------------------------------------------------------------------- + | FFmpeg + |-------------------------------------------------------------------------- + | + | Statamic uses FFmpeg to extract thumbnails from videos to be shown in the + | Control Panel. You may adjust the binary location and cache path here. + | + */ + + 'ffmpeg' => [ + 'binary' => null, + 'cache_path' => storage_path('statamic/glide/ffmpeg'), + ], + ]; diff --git a/resources/js/components/assets/Browser/Grid.vue b/resources/js/components/assets/Browser/Grid.vue index 58401652d03..0a6baa7b96c 100644 --- a/resources/js/components/assets/Browser/Grid.vue +++ b/resources/js/components/assets/Browser/Grid.vue @@ -113,7 +113,7 @@
isWindows(); $output = $this->run($isWindows ? 'where composer' : 'which composer'); diff --git a/src/Console/Processes/Ffmpeg.php b/src/Console/Processes/Ffmpeg.php new file mode 100644 index 00000000000..ace1a142733 --- /dev/null +++ b/src/Console/Processes/Ffmpeg.php @@ -0,0 +1,70 @@ +startTimestamp = $startTimestamp; + + return $this; + } + + public function extractThumbnail(string $path, string $outputFilePath): ?string + { + $ffmpegBinary = $this->ffmpegBinary(); + + if (! $ffmpegBinary) { + return null; + } + + $this->run($this->buildCommand($ffmpegBinary, $path, $outputFilePath)); + + if (! file_exists($outputFilePath)) { + return null; + } + + return $outputFilePath; + } + + private function buildCommand(string $ffmpegBinary, string $path, string $output): string + { + return collect([ + escapeshellarg($ffmpegBinary), + '-y', + '-ss', + escapeshellarg($this->startTimestamp), + '-i', + escapeshellarg($path), + '-vframes 1', + escapeshellarg($output), + ])->join(' '); + } + + public function ffmpegBinary(): ?string + { + if ($binary = config('statamic.assets.ffmpeg.binary')) { + return $binary; + } + + $output = $this->run($this->isWindows() ? 'where ffmpeg' : 'which ffmpeg'); + + // Laravel Herd doesn't inherit the user's PATH, so we need to check the Homebrew path manually + if ($this->isMac() && ! $output) { + $output = $this->run('command -v /opt/homebrew/bin/ffmpeg'); + } + + if (str($output)->lower()->contains('could not find files for the given')) { + return null; + } + + return str(StringUtilities::normalizeLineEndings(trim($output))) + ->explode("\n") + ->first(); + } +} diff --git a/src/Console/Processes/Process.php b/src/Console/Processes/Process.php index 5e1c014c694..dd302629734 100644 --- a/src/Console/Processes/Process.php +++ b/src/Console/Processes/Process.php @@ -454,4 +454,14 @@ public function fromParent() return $that; } + + protected function isWindows() + { + return PHP_OS_FAMILY === 'Windows'; + } + + protected function isMac() + { + return PHP_OS_FAMILY === 'Darwin'; + } } diff --git a/src/Http/Resources/CP/Assets/FolderAsset.php b/src/Http/Resources/CP/Assets/FolderAsset.php index d00d09f5913..142b7b264d7 100644 --- a/src/Http/Resources/CP/Assets/FolderAsset.php +++ b/src/Http/Resources/CP/Assets/FolderAsset.php @@ -3,11 +3,15 @@ namespace Statamic\Http\Resources\CP\Assets; use Illuminate\Http\Resources\Json\JsonResource; +use Illuminate\Support\Fluent; use Statamic\Facades\Action; use Statamic\Support\Str; +use Statamic\Support\Traits\Hookable; class FolderAsset extends JsonResource { + use Hookable; + protected $blueprint; protected $columns; @@ -35,22 +39,14 @@ public function toArray($request) 'size_formatted' => Str::fileSizeForHumans($this->size(), 0), 'last_modified_relative' => $this->lastModified()->diffForHumans(), - $this->mergeWhen($this->isImage() || $this->isSvg(), function () { - return [ - 'is_image' => true, - 'thumbnail' => $this->thumbnailUrl('small'), - 'can_be_transparent' => $this->isSvg() || $this->extensionIsOneOf(['svg', 'png', 'webp', 'avif']), - 'alt' => $this->alt, - 'orientation' => $this->orientation(), - ]; - }), - $this->merge($this->values()), 'actions' => Action::for($this->resource, [ 'container' => $this->container()->handle(), 'folder' => $this->folder(), ]), + + $this->merge($this->thumbnails()), ]; } @@ -74,4 +70,42 @@ protected function values($extra = []) return [$key => $value]; }); } + + private function thumbnails(): array + { + $data = match (true) { + $this->isImage() || $this->isSvg() => $this->getImageThumbnail(), + $this->isVideo() && config('statamic.assets.video_thumbnails', true) => $this->getVideoThumbnail(), + default => ['thumbnail' => null], + }; + + return array_merge($data, $this->runAssetHook() ?? []); + } + + private function getImageThumbnail(): array + { + return [ + 'is_image' => true, + 'thumbnail' => $this->thumbnailUrl('small'), + 'can_be_transparent' => $this->isSvg() || $this->extensionIsOneOf(['svg', 'png', 'webp', 'avif']), + 'alt' => $this->alt, + 'orientation' => $this->orientation(), + ]; + } + + private function getVideoThumbnail(): array + { + return [ + 'thumbnail' => $this->thumbnailUrl('small'), + ]; + } + + private function runAssetHook(): array + { + $payload = $this->runHooksWith('asset', [ + 'data' => new Fluent, + ]); + + return $payload->data->toArray(); + } } diff --git a/src/Imaging/ImageGenerator.php b/src/Imaging/ImageGenerator.php index 49273a9e72a..c7df83e6b5f 100644 --- a/src/Imaging/ImageGenerator.php +++ b/src/Imaging/ImageGenerator.php @@ -87,12 +87,12 @@ public function generateByPath($path, array $params) ); } - private function doGenerateByPath($path, array $params) + private function doGenerateByPath($path, array $params, $sourceFilesystemRoot = null) { $this->path = $path; $this->setParams($params); - $this->server->setSource($this->pathSourceFilesystem()); + $this->server->setSource($this->pathSourceFilesystem($sourceFilesystemRoot)); $this->server->setSourcePathPrefix('/'); $this->server->setCachePathPrefix('paths'); @@ -128,6 +128,24 @@ private function doGenerateByUrl($url, array $params) return $this->generate($parsed['path'].($qs ? '?'.$qs : '')); } + /** + * @param \Statamic\Contracts\Assets\Asset $asset + */ + public function generateVideoThumbnail($asset, array $params) + { + if ($path = app(ThumbnailExtractor::class)->generateThumbnail($asset)) { + $this->skip_validation = true; + + return $this->doGenerateByPath( + basename($path), + $params, + config('statamic.assets.ffmpeg.cache_path'), + ); + } + + return ''; + } + /** * Generate a manipulated image by an asset. * @@ -136,6 +154,10 @@ private function doGenerateByUrl($url, array $params) */ public function generateByAsset($asset, array $params) { + if (ThumbnailExtractor::enabled() && $asset->isVideo()) { + return $this->generateVideoThumbnail($asset, $params); + } + $manipulationCacheKey = 'asset::'.$asset->id().'::'.md5(json_encode($params)); $manifestCacheKey = static::assetCacheManifestKey($asset); @@ -310,9 +332,11 @@ private function validateImage() } } - private function pathSourceFilesystem() + private function pathSourceFilesystem($root = null) { - return Storage::build(['driver' => 'local', 'root' => public_path()])->getDriver(); + $root ??= public_path(); + + return Storage::build(['driver' => 'local', 'root' => $root])->getDriver(); } private function guzzleSourceFilesystem($base) diff --git a/src/Imaging/ThumbnailExtractor.php b/src/Imaging/ThumbnailExtractor.php new file mode 100644 index 00000000000..06cf028bb2d --- /dev/null +++ b/src/Imaging/ThumbnailExtractor.php @@ -0,0 +1,78 @@ +id()).'.jpg'; + $cacheDirectory = static::cachePath(); + $finalPath = Path::tidy($cacheDirectory.'/'.$fileName); + + if (! file_exists($cacheDirectory)) { + mkdir($cacheDirectory, 0755, true); + } + + return $finalPath; + } + + public static function canGenerateThumbnail(Asset $asset) + { + $resolvedPath = $asset->resolvedPath(); + + if (file_exists($resolvedPath)) { + return true; + } + + return $asset->container()->accessible(); + } + + public function generateThumbnail(Asset $asset) + { + if (! static::canGenerateThumbnail($asset)) { + return ''; + } + + $cachePath = static::getCachePath($asset); + + if (file_exists($cachePath)) { + return $cachePath; + } + + $ffmpegInput = match (true) { + file_exists($asset->resolvedPath()) => $asset->resolvedPath(), + $asset->container()->accessible() => $asset->absoluteUrl(), + default => null, + }; + + return $this->ffmpeg->extractThumbnail( + $ffmpegInput, + static::getCachePath($asset) + ); + } +}