From 8d887343f5512ea861ce2178536f98d11271ea2d Mon Sep 17 00:00:00 2001 From: John Koster Date: Tue, 3 Jun 2025 22:03:50 -0500 Subject: [PATCH 01/19] Generate thumbnails for video files --- config/assets.php | 28 ++++++++ .../js/components/assets/Browser/Grid.vue | 2 +- .../components/assets/Browser/Thumbnail.vue | 2 +- src/Console/Processes/Ffmpeg.php | 46 ++++++++++++ src/Http/Resources/CP/Assets/FolderAsset.php | 57 ++++++++++++--- src/Imaging/ImageGenerator.php | 57 +++++++++++++-- src/Imaging/ThumbnailExtractor.php | 70 +++++++++++++++++++ 7 files changed, 245 insertions(+), 17 deletions(-) create mode 100644 src/Console/Processes/Ffmpeg.php create mode 100644 src/Imaging/ThumbnailExtractor.php diff --git a/config/assets.php b/config/assets.php index d86e2e78e44..2a83b20c79a 100644 --- a/config/assets.php +++ b/config/assets.php @@ -208,4 +208,32 @@ 'svg_sanitization_on_upload' => true, + /* + |-------------------------------------------------------------------------- + | Generate Video Thumbnails + |-------------------------------------------------------------------------- + | + | When enabled, Statamic will generate thumbnails for videos. + | Generated thumbnails are displayed in the Control Panel. + | + */ + + 'video_thumbnails' => true, + + /* + |-------------------------------------------------------------------------- + | FFmpeg + |-------------------------------------------------------------------------- + | + | FFMpeg is used to extract thumbnails for video assets + | to be displayed within the Control Panel. You may + | adjust the binary location and cache path here. + | + */ + + 'ffmpeg' => [ + 'binary' => 'ffmpeg', + '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 6f06264a302..cef6e555472 100644 --- a/resources/js/components/assets/Browser/Grid.vue +++ b/resources/js/components/assets/Browser/Grid.vue @@ -93,7 +93,7 @@
startTimestamp = $startTimestamp; + + return $this; + } + + public function extractThumbnail(string $path, string $output) + { + $this->run($this->buildCommand($path, $output)); + + if (! file_exists($output)) { + return null; + } + + return $output; + } + + private function buildCommand(string $path, string $output) + { + return collect([ + escapeshellarg($this->ffmpegBinary()), + '-y', + '-ss', + escapeshellarg($this->startTimestamp), + '-i', + escapeshellarg($path), + '-vframes 1', + escapeshellarg($output), + ]) + ->join(' '); + } + + public function ffmpegBinary() + { + return config('statamic.assets.ffmpeg.binary', 'ffmpeg'); + } +} diff --git a/src/Http/Resources/CP/Assets/FolderAsset.php b/src/Http/Resources/CP/Assets/FolderAsset.php index 4bf35a9b571..ee2c17bc967 100644 --- a/src/Http/Resources/CP/Assets/FolderAsset.php +++ b/src/Http/Resources/CP/Assets/FolderAsset.php @@ -3,13 +3,57 @@ 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; + + private function getImageThumbnail() + { + 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() + { + return [ + 'thumbnail' => $this->thumbnailUrl('small'), + ]; + } + + private function thumbnails() + { + if ($this->isImage() || $this->isSvg()) { + return $this->getImageThumbnail(); + } elseif (config('statamic.assets.video_thumbnails', true) && $this->isVideo()) { + return $this->getVideoThumbnail(); + } + + return ['thumbnail' => null]; + } + + private function runAssetHook() + { + $payload = $this->runHooksWith('asset', [ + 'data' => new Fluent, + ]); + + return $payload->data->toArray(); + } + public function toArray($request) { + $hookData = $this->runAssetHook(); + return [ 'id' => $this->id(), 'basename' => $this->basename(), @@ -18,20 +62,13 @@ 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(), - ]; - }), - 'actions' => Action::for($this->resource, [ 'container' => $this->container()->handle(), 'folder' => $this->folder(), ]), + + $this->mergeWhen(! empty($hookData), $hookData), + $this->merge($this->thumbnails()), ]; } } diff --git a/src/Imaging/ImageGenerator.php b/src/Imaging/ImageGenerator.php index 49273a9e72a..788e33ede5c 100644 --- a/src/Imaging/ImageGenerator.php +++ b/src/Imaging/ImageGenerator.php @@ -44,12 +44,15 @@ class ImageGenerator */ private $skip_validation; + private ThumbnailExtractor $thumbnailExtractor; + /** * GlideController constructor. */ - public function __construct(Server $server) + public function __construct(Server $server, ThumbnailExtractor $thumbnailExtractor) { $this->server = $server; + $this->thumbnailExtractor = $thumbnailExtractor; } public function getServer(): Server @@ -87,12 +90,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 +131,39 @@ private function doGenerateByUrl($url, array $params) return $this->generate($parsed['path'].($qs ? '?'.$qs : '')); } + private function canGenerateThumbnail(Asset $asset) + { + $resolvedPath = $asset->resolvedPath(); + + if (file_exists($resolvedPath)) { + return true; + } + + return $asset->container()->accessible(); + } + + /** + * @param \Statamic\Contracts\Assets\Asset $asset + */ + public function generateVideoThumbnail($asset, array $params) + { + if (! $this->canGenerateThumbnail($asset)) { + return null; + } + + if ($path = $this->thumbnailExtractor->generateThumbnail($asset)) { + $this->skip_validation = true; + + return $this->doGenerateByPath( + basename($path), + $params, + config('statamic.assets.ffmpeg.cache_path'), + ); + } + + return null; + } + /** * Generate a manipulated image by an asset. * @@ -136,6 +172,10 @@ private function doGenerateByUrl($url, array $params) */ public function generateByAsset($asset, array $params) { + if (ThumbnailExtractor::enabled() && method_exists($asset, 'isVideo') && $asset->isVideo()) { + return $this->generateVideoThumbnail($asset, $params); + } + $manipulationCacheKey = 'asset::'.$asset->id().'::'.md5(json_encode($params)); $manifestCacheKey = static::assetCacheManifestKey($asset); @@ -310,9 +350,16 @@ private function validateImage() } } - private function pathSourceFilesystem() + private function pathSourceFilesystem($root = null) + { + $root ??= public_path(); + + return Storage::build(['driver' => 'local', 'root' => $root])->getDriver(); + } + + private function videoThumbnailFilesystem() { - return Storage::build(['driver' => 'local', 'root' => public_path()])->getDriver(); + return Storage::build(['driver' => 'local', 'root' => ThumbnailExtractor::cachePath()])->getDriver(); } private function guzzleSourceFilesystem($base) diff --git a/src/Imaging/ThumbnailExtractor.php b/src/Imaging/ThumbnailExtractor.php new file mode 100644 index 00000000000..9a54870933c --- /dev/null +++ b/src/Imaging/ThumbnailExtractor.php @@ -0,0 +1,70 @@ +ffmpeg = $ffmpeg; + } + + public static function enabled() + { + return config( + 'statamic.assets.video_thumbnails', + true + ); + } + + public static function cachePath() + { + return config( + 'statamic.assets.ffmpeg.cache_path', + storage_path('statamic/glide/ffmpeg') + ); + } + + protected function getCachePath(Asset $asset) + { + $fileName = 'thumb_'.md5($asset->id()).'.jpg'; + $cacheDirectory = static::cachePath(); + $finalPath = Path::tidy($cacheDirectory.'/'.$fileName); + + if (! file_exists($cacheDirectory)) { + mkdir($cacheDirectory, 0755, true); + } + + return $finalPath; + } + + public function generateThumbnail(Asset $asset) + { + $cachePath = $this->getCachePath($asset); + + if (file_exists($cachePath)) { + return $cachePath; + } + + $ffmpegInput = null; + + if (file_exists($asset->resolvedPath())) { + $ffmpegInput = $asset->resolvedPath(); + } elseif ($asset->container()->accessible()) { + $ffmpegInput = $asset->absoluteUrl(); + } else { + return null; + } + + return $this->ffmpeg->extractThumbnail( + $asset->absoluteUrl(), + $this->getCachePath($asset) + ); + } +} From 5a897c21697bc5dc22ca87bf2faa379accf989d2 Mon Sep 17 00:00:00 2001 From: John Koster Date: Tue, 3 Jun 2025 22:17:24 -0500 Subject: [PATCH 02/19] Rename to mentally separate from console output --- src/Console/Processes/Ffmpeg.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Console/Processes/Ffmpeg.php b/src/Console/Processes/Ffmpeg.php index 85d59445e1b..38016dffd98 100644 --- a/src/Console/Processes/Ffmpeg.php +++ b/src/Console/Processes/Ffmpeg.php @@ -13,15 +13,15 @@ public function startTimestamp(string $startTimestamp): static return $this; } - public function extractThumbnail(string $path, string $output) + public function extractThumbnail(string $path, string $outputFilePath) { - $this->run($this->buildCommand($path, $output)); + $this->run($this->buildCommand($path, $outputFilePath)); - if (! file_exists($output)) { + if (! file_exists($outputFilePath)) { return null; } - return $output; + return $outputFilePath; } private function buildCommand(string $path, string $output) From c36cc3316d02d7748e55da7b631b4e7dcfb267ff Mon Sep 17 00:00:00 2001 From: John Koster Date: Tue, 3 Jun 2025 22:59:37 -0500 Subject: [PATCH 03/19] Refactor to method on base class --- src/Console/Processes/Composer.php | 2 +- src/Console/Processes/Process.php | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/Console/Processes/Composer.php b/src/Console/Processes/Composer.php index 5a275385e62..c0bea6e4be6 100644 --- a/src/Console/Processes/Composer.php +++ b/src/Console/Processes/Composer.php @@ -323,7 +323,7 @@ private function prepareProcessArguments($parts) */ private function composerBinary(): string { - $isWindows = DIRECTORY_SEPARATOR === '\\'; + $isWindows = $this->isWindows(); $output = $this->run($isWindows ? 'where composer' : 'which composer'); diff --git a/src/Console/Processes/Process.php b/src/Console/Processes/Process.php index 5e1c014c694..b38f4645b99 100644 --- a/src/Console/Processes/Process.php +++ b/src/Console/Processes/Process.php @@ -454,4 +454,9 @@ public function fromParent() return $that; } + + protected function isWindows() + { + return DIRECTORY_SEPARATOR === '\\'; + } } From f2fb5d3dd87c814a04ed109a2e7ff3ce5073d61b Mon Sep 17 00:00:00 2001 From: John Koster Date: Tue, 3 Jun 2025 23:18:55 -0500 Subject: [PATCH 04/19] Auto discover ffmpeg, refactor config --- config/assets.php | 2 +- src/Console/Processes/Ffmpeg.php | 30 ++++++++++++++++++++++++++---- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/config/assets.php b/config/assets.php index 2a83b20c79a..8b895b6e75b 100644 --- a/config/assets.php +++ b/config/assets.php @@ -232,7 +232,7 @@ */ 'ffmpeg' => [ - 'binary' => 'ffmpeg', + 'binary' => null, 'cache_path' => storage_path('statamic/glide/ffmpeg'), ], diff --git a/src/Console/Processes/Ffmpeg.php b/src/Console/Processes/Ffmpeg.php index 38016dffd98..1aeab82bca3 100644 --- a/src/Console/Processes/Ffmpeg.php +++ b/src/Console/Processes/Ffmpeg.php @@ -2,6 +2,8 @@ namespace Statamic\Console\Processes; +use Statamic\View\Antlers\Language\Utilities\StringUtilities; + class Ffmpeg extends Process { protected string $startTimestamp = '00:00:00'; @@ -15,7 +17,13 @@ public function startTimestamp(string $startTimestamp): static public function extractThumbnail(string $path, string $outputFilePath) { - $this->run($this->buildCommand($path, $outputFilePath)); + $ffmpegBinary = $this->ffmpegBinary(); + + if (! $ffmpegBinary) { + return null; + } + + $output = $this->run($this->buildCommand($ffmpegBinary, $path, $outputFilePath)); if (! file_exists($outputFilePath)) { return null; @@ -24,10 +32,10 @@ public function extractThumbnail(string $path, string $outputFilePath) return $outputFilePath; } - private function buildCommand(string $path, string $output) + private function buildCommand(string $ffmpegBinary, string $path, string $output) { return collect([ - escapeshellarg($this->ffmpegBinary()), + escapeshellarg($ffmpegBinary), '-y', '-ss', escapeshellarg($this->startTimestamp), @@ -41,6 +49,20 @@ private function buildCommand(string $path, string $output) public function ffmpegBinary() { - return config('statamic.assets.ffmpeg.binary', 'ffmpeg'); + if ($binary = config('statamic.assets.ffmpeg.binary')) { + return $binary; + } + + $output = $this->run($this->isWindows() ? 'where ffmpeg2' : 'which ffmpeg'); + + if (str($output)->lower()->contains([ + 'could not find files for the given', + ])) { + return null; + } + + return str(StringUtilities::normalizeLineEndings(trim($output))) + ->explode("\n") + ->first(); } } From 18dc53a936583abd784e64330d683b30c58dca36 Mon Sep 17 00:00:00 2001 From: John Koster Date: Tue, 3 Jun 2025 23:19:26 -0500 Subject: [PATCH 05/19] Better handling to ensure fallback SVG icon --- src/Http/Resources/CP/Assets/FolderAsset.php | 5 +++++ src/Imaging/ImageGenerator.php | 4 ++-- src/Imaging/ThumbnailExtractor.php | 11 ++++++++--- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/src/Http/Resources/CP/Assets/FolderAsset.php b/src/Http/Resources/CP/Assets/FolderAsset.php index 6c8e8fb5b24..1ebe8818b7d 100644 --- a/src/Http/Resources/CP/Assets/FolderAsset.php +++ b/src/Http/Resources/CP/Assets/FolderAsset.php @@ -5,6 +5,7 @@ use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Support\Fluent; use Statamic\Facades\Action; +use Statamic\Imaging\ThumbnailExtractor; use Statamic\Support\Str; use Statamic\Support\Traits\Hookable; @@ -42,6 +43,10 @@ private function getImageThumbnail() private function getVideoThumbnail() { + if (! ThumbnailExtractor::hasCachedThumbnail($this->resource)) { + return []; + } + return [ 'thumbnail' => $this->thumbnailUrl('small'), ]; diff --git a/src/Imaging/ImageGenerator.php b/src/Imaging/ImageGenerator.php index 788e33ede5c..959e87c5668 100644 --- a/src/Imaging/ImageGenerator.php +++ b/src/Imaging/ImageGenerator.php @@ -148,7 +148,7 @@ private function canGenerateThumbnail(Asset $asset) public function generateVideoThumbnail($asset, array $params) { if (! $this->canGenerateThumbnail($asset)) { - return null; + return ''; } if ($path = $this->thumbnailExtractor->generateThumbnail($asset)) { @@ -161,7 +161,7 @@ public function generateVideoThumbnail($asset, array $params) ); } - return null; + return ''; } /** diff --git a/src/Imaging/ThumbnailExtractor.php b/src/Imaging/ThumbnailExtractor.php index 9a54870933c..14f5f8ff254 100644 --- a/src/Imaging/ThumbnailExtractor.php +++ b/src/Imaging/ThumbnailExtractor.php @@ -31,7 +31,12 @@ public static function cachePath() ); } - protected function getCachePath(Asset $asset) + public static function hasCachedThumbnail(Asset $asset) + { + return file_exists(static::getCachePath($asset)); + } + + public static function getCachePath(Asset $asset) { $fileName = 'thumb_'.md5($asset->id()).'.jpg'; $cacheDirectory = static::cachePath(); @@ -46,7 +51,7 @@ protected function getCachePath(Asset $asset) public function generateThumbnail(Asset $asset) { - $cachePath = $this->getCachePath($asset); + $cachePath = static::getCachePath($asset); if (file_exists($cachePath)) { return $cachePath; @@ -64,7 +69,7 @@ public function generateThumbnail(Asset $asset) return $this->ffmpeg->extractThumbnail( $asset->absoluteUrl(), - $this->getCachePath($asset) + static::getCachePath($asset) ); } } From 508911087c3ef4d15ef56b2e278c614b55dc9403 Mon Sep 17 00:00:00 2001 From: John Koster Date: Tue, 3 Jun 2025 23:19:57 -0500 Subject: [PATCH 06/19] Update Ffmpeg.php --- src/Console/Processes/Ffmpeg.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Console/Processes/Ffmpeg.php b/src/Console/Processes/Ffmpeg.php index 1aeab82bca3..3529e9cf7ae 100644 --- a/src/Console/Processes/Ffmpeg.php +++ b/src/Console/Processes/Ffmpeg.php @@ -53,7 +53,7 @@ public function ffmpegBinary() return $binary; } - $output = $this->run($this->isWindows() ? 'where ffmpeg2' : 'which ffmpeg'); + $output = $this->run($this->isWindows() ? 'where ffmpeg' : 'which ffmpeg'); if (str($output)->lower()->contains([ 'could not find files for the given', From 064bf1f13420c19a6a100fd9b16503558cb58633 Mon Sep 17 00:00:00 2001 From: John Koster Date: Tue, 3 Jun 2025 23:24:23 -0500 Subject: [PATCH 07/19] Revert this --- src/Http/Resources/CP/Assets/FolderAsset.php | 4 ---- src/Imaging/ThumbnailExtractor.php | 5 ----- 2 files changed, 9 deletions(-) diff --git a/src/Http/Resources/CP/Assets/FolderAsset.php b/src/Http/Resources/CP/Assets/FolderAsset.php index 1ebe8818b7d..8dc057a1ecb 100644 --- a/src/Http/Resources/CP/Assets/FolderAsset.php +++ b/src/Http/Resources/CP/Assets/FolderAsset.php @@ -43,10 +43,6 @@ private function getImageThumbnail() private function getVideoThumbnail() { - if (! ThumbnailExtractor::hasCachedThumbnail($this->resource)) { - return []; - } - return [ 'thumbnail' => $this->thumbnailUrl('small'), ]; diff --git a/src/Imaging/ThumbnailExtractor.php b/src/Imaging/ThumbnailExtractor.php index 14f5f8ff254..ce42b3c0e0f 100644 --- a/src/Imaging/ThumbnailExtractor.php +++ b/src/Imaging/ThumbnailExtractor.php @@ -31,11 +31,6 @@ public static function cachePath() ); } - public static function hasCachedThumbnail(Asset $asset) - { - return file_exists(static::getCachePath($asset)); - } - public static function getCachePath(Asset $asset) { $fileName = 'thumb_'.md5($asset->id()).'.jpg'; From 102e1aa2bbcb744f805709fa5806e0ad9bda2502 Mon Sep 17 00:00:00 2001 From: John Koster Date: Wed, 4 Jun 2025 21:38:49 -0500 Subject: [PATCH 08/19] Update ImageGenerator.php --- src/Imaging/ImageGenerator.php | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/Imaging/ImageGenerator.php b/src/Imaging/ImageGenerator.php index 959e87c5668..832b58390d7 100644 --- a/src/Imaging/ImageGenerator.php +++ b/src/Imaging/ImageGenerator.php @@ -44,15 +44,12 @@ class ImageGenerator */ private $skip_validation; - private ThumbnailExtractor $thumbnailExtractor; - /** * GlideController constructor. */ - public function __construct(Server $server, ThumbnailExtractor $thumbnailExtractor) + public function __construct(Server $server) { $this->server = $server; - $this->thumbnailExtractor = $thumbnailExtractor; } public function getServer(): Server @@ -151,7 +148,7 @@ public function generateVideoThumbnail($asset, array $params) return ''; } - if ($path = $this->thumbnailExtractor->generateThumbnail($asset)) { + if ($path = app(ThumbnailExtractor::class)->generateThumbnail($asset)) { $this->skip_validation = true; return $this->doGenerateByPath( From 6672563b1d30b95cb5007d4d03055b444aba3b94 Mon Sep 17 00:00:00 2001 From: John Koster Date: Wed, 4 Jun 2025 21:48:35 -0500 Subject: [PATCH 09/19] Update FolderAsset.php --- src/Http/Resources/CP/Assets/FolderAsset.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Http/Resources/CP/Assets/FolderAsset.php b/src/Http/Resources/CP/Assets/FolderAsset.php index 8dc057a1ecb..6c8e8fb5b24 100644 --- a/src/Http/Resources/CP/Assets/FolderAsset.php +++ b/src/Http/Resources/CP/Assets/FolderAsset.php @@ -5,7 +5,6 @@ use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Support\Fluent; use Statamic\Facades\Action; -use Statamic\Imaging\ThumbnailExtractor; use Statamic\Support\Str; use Statamic\Support\Traits\Hookable; From d076e714e1cb924882378b25153b44bf3657daa2 Mon Sep 17 00:00:00 2001 From: John Koster Date: Sat, 14 Jun 2025 11:32:04 -0500 Subject: [PATCH 10/19] Adjust ordering of merged data This will allow people to customize the thumbnails much more easily --- src/Http/Resources/CP/Assets/FolderAsset.php | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/Http/Resources/CP/Assets/FolderAsset.php b/src/Http/Resources/CP/Assets/FolderAsset.php index 6c8e8fb5b24..f7aca8337e8 100644 --- a/src/Http/Resources/CP/Assets/FolderAsset.php +++ b/src/Http/Resources/CP/Assets/FolderAsset.php @@ -49,13 +49,18 @@ private function getVideoThumbnail() private function thumbnails() { + $data = ['thumbnail' => null]; + if ($this->isImage() || $this->isSvg()) { - return $this->getImageThumbnail(); + $data = $this->getImageThumbnail(); } elseif (config('statamic.assets.video_thumbnails', true) && $this->isVideo()) { - return $this->getVideoThumbnail(); + $data = $this->getVideoThumbnail(); } - return ['thumbnail' => null]; + return array_merge( + $data, + $this->runAssetHook() ?? [] + ); } private function runAssetHook() @@ -69,8 +74,6 @@ private function runAssetHook() public function toArray($request) { - $hookData = $this->runAssetHook(); - return [ 'id' => $this->id(), 'basename' => $this->basename(), @@ -86,7 +89,6 @@ public function toArray($request) 'folder' => $this->folder(), ]), - $this->mergeWhen(! empty($hookData), $hookData), $this->merge($this->thumbnails()), ]; } From a3d81ab749b6297eee50845d6e40ffaa138c3bfb Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Tue, 22 Jul 2025 12:20:58 +0100 Subject: [PATCH 11/19] Move private methods to the bottom --- src/Http/Resources/CP/Assets/FolderAsset.php | 86 ++++++++++---------- 1 file changed, 43 insertions(+), 43 deletions(-) diff --git a/src/Http/Resources/CP/Assets/FolderAsset.php b/src/Http/Resources/CP/Assets/FolderAsset.php index f7aca8337e8..a52dff20384 100644 --- a/src/Http/Resources/CP/Assets/FolderAsset.php +++ b/src/Http/Resources/CP/Assets/FolderAsset.php @@ -29,49 +29,6 @@ public function columns($columns) return $this; } - private function getImageThumbnail() - { - 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() - { - return [ - 'thumbnail' => $this->thumbnailUrl('small'), - ]; - } - - private function thumbnails() - { - $data = ['thumbnail' => null]; - - if ($this->isImage() || $this->isSvg()) { - $data = $this->getImageThumbnail(); - } elseif (config('statamic.assets.video_thumbnails', true) && $this->isVideo()) { - $data = $this->getVideoThumbnail(); - } - - return array_merge( - $data, - $this->runAssetHook() ?? [] - ); - } - - private function runAssetHook() - { - $payload = $this->runHooksWith('asset', [ - 'data' => new Fluent, - ]); - - return $payload->data->toArray(); - } - public function toArray($request) { return [ @@ -113,4 +70,47 @@ protected function values($extra = []) return [$key => $value]; }); } + + private function thumbnails(): array + { + $data = ['thumbnail' => null]; + + if ($this->isImage() || $this->isSvg()) { + $data = $this->getImageThumbnail(); + } elseif ($this->isVideo() && config('statamic.assets.video_thumbnails', true)) { + $data = $this->getVideoThumbnail(); + } + + 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(); + } } From 7db2abb67c3763f5d09f3fdb0d6c86c81547f47b Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Tue, 22 Jul 2025 12:23:01 +0100 Subject: [PATCH 12/19] Use a match statement here instead --- src/Http/Resources/CP/Assets/FolderAsset.php | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/Http/Resources/CP/Assets/FolderAsset.php b/src/Http/Resources/CP/Assets/FolderAsset.php index a52dff20384..1e72c91c521 100644 --- a/src/Http/Resources/CP/Assets/FolderAsset.php +++ b/src/Http/Resources/CP/Assets/FolderAsset.php @@ -73,13 +73,11 @@ protected function values($extra = []) private function thumbnails(): array { - $data = ['thumbnail' => null]; - - if ($this->isImage() || $this->isSvg()) { - $data = $this->getImageThumbnail(); - } elseif ($this->isVideo() && config('statamic.assets.video_thumbnails', true)) { - $data = $this->getVideoThumbnail(); - } + $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, From 32b7e4da4915f4460c02e7a1fac16cbb664eebe1 Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Tue, 22 Jul 2025 12:23:23 +0100 Subject: [PATCH 13/19] A couple of nitpicky changes --- src/Console/Processes/Ffmpeg.php | 11 ++++------- src/Http/Resources/CP/Assets/FolderAsset.php | 5 +---- src/Imaging/ThumbnailExtractor.php | 5 +---- 3 files changed, 6 insertions(+), 15 deletions(-) diff --git a/src/Console/Processes/Ffmpeg.php b/src/Console/Processes/Ffmpeg.php index 3529e9cf7ae..8f8316bf8fc 100644 --- a/src/Console/Processes/Ffmpeg.php +++ b/src/Console/Processes/Ffmpeg.php @@ -8,7 +8,7 @@ class Ffmpeg extends Process { protected string $startTimestamp = '00:00:00'; - public function startTimestamp(string $startTimestamp): static + public function startTimestamp(string $startTimestamp): self { $this->startTimestamp = $startTimestamp; @@ -23,7 +23,7 @@ public function extractThumbnail(string $path, string $outputFilePath) return null; } - $output = $this->run($this->buildCommand($ffmpegBinary, $path, $outputFilePath)); + $this->run($this->buildCommand($ffmpegBinary, $path, $outputFilePath)); if (! file_exists($outputFilePath)) { return null; @@ -43,8 +43,7 @@ private function buildCommand(string $ffmpegBinary, string $path, string $output escapeshellarg($path), '-vframes 1', escapeshellarg($output), - ]) - ->join(' '); + ])->join(' '); } public function ffmpegBinary() @@ -55,9 +54,7 @@ public function ffmpegBinary() $output = $this->run($this->isWindows() ? 'where ffmpeg' : 'which ffmpeg'); - if (str($output)->lower()->contains([ - 'could not find files for the given', - ])) { + if (str($output)->lower()->contains('could not find files for the given')) { return null; } diff --git a/src/Http/Resources/CP/Assets/FolderAsset.php b/src/Http/Resources/CP/Assets/FolderAsset.php index 1e72c91c521..142b7b264d7 100644 --- a/src/Http/Resources/CP/Assets/FolderAsset.php +++ b/src/Http/Resources/CP/Assets/FolderAsset.php @@ -79,10 +79,7 @@ private function thumbnails(): array default => ['thumbnail' => null], }; - return array_merge( - $data, - $this->runAssetHook() ?? [] - ); + return array_merge($data, $this->runAssetHook() ?? []); } private function getImageThumbnail(): array diff --git a/src/Imaging/ThumbnailExtractor.php b/src/Imaging/ThumbnailExtractor.php index ce42b3c0e0f..d45d139e435 100644 --- a/src/Imaging/ThumbnailExtractor.php +++ b/src/Imaging/ThumbnailExtractor.php @@ -8,11 +8,8 @@ class ThumbnailExtractor { - private Ffmpeg $ffmpeg; - - public function __construct(Ffmpeg $ffmpeg) + public function __construct(private Ffmpeg $ffmpeg) { - $this->ffmpeg = $ffmpeg; } public static function enabled() From c98e923cf0446d0de83b15d6b8161a700ec0efd0 Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Tue, 22 Jul 2025 12:30:39 +0100 Subject: [PATCH 14/19] Move `video_thumbnails` config next to the other thumbnail-related options --- config/assets.php | 29 ++++++++++++++--------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/config/assets.php b/config/assets.php index 2625361bd02..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,26 +220,13 @@ 'svg_sanitization_on_upload' => true, - /* - |-------------------------------------------------------------------------- - | Generate Video Thumbnails - |-------------------------------------------------------------------------- - | - | When enabled, Statamic will generate thumbnails for videos. - | Generated thumbnails are displayed in the Control Panel. - | - */ - - 'video_thumbnails' => true, - /* |-------------------------------------------------------------------------- | FFmpeg |-------------------------------------------------------------------------- | - | FFMpeg is used to extract thumbnails for video assets - | to be displayed within the Control Panel. You may - | adjust the binary location and cache path here. + | 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. | */ From ebb8e9a24971f415d7b903167d3d079bb477f2b2 Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Tue, 22 Jul 2025 14:38:02 +0100 Subject: [PATCH 15/19] return types --- src/Console/Processes/Ffmpeg.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Console/Processes/Ffmpeg.php b/src/Console/Processes/Ffmpeg.php index 8f8316bf8fc..2c2e5901e0a 100644 --- a/src/Console/Processes/Ffmpeg.php +++ b/src/Console/Processes/Ffmpeg.php @@ -15,7 +15,7 @@ public function startTimestamp(string $startTimestamp): self return $this; } - public function extractThumbnail(string $path, string $outputFilePath) + public function extractThumbnail(string $path, string $outputFilePath): ?string { $ffmpegBinary = $this->ffmpegBinary(); @@ -32,7 +32,7 @@ public function extractThumbnail(string $path, string $outputFilePath) return $outputFilePath; } - private function buildCommand(string $ffmpegBinary, string $path, string $output) + private function buildCommand(string $ffmpegBinary, string $path, string $output): string { return collect([ escapeshellarg($ffmpegBinary), @@ -46,7 +46,7 @@ private function buildCommand(string $ffmpegBinary, string $path, string $output ])->join(' '); } - public function ffmpegBinary() + public function ffmpegBinary(): ?string { if ($binary = config('statamic.assets.ffmpeg.binary')) { return $binary; From 463d61d3560802da68a8223445fb50bed0d89431 Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Tue, 22 Jul 2025 17:43:44 +0100 Subject: [PATCH 16/19] Laravel Herd doesn't inherit the user's PATH, so we need to check the Homebrew path manually --- src/Console/Processes/Ffmpeg.php | 5 +++++ src/Console/Processes/Process.php | 7 ++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/Console/Processes/Ffmpeg.php b/src/Console/Processes/Ffmpeg.php index 2c2e5901e0a..ace1a142733 100644 --- a/src/Console/Processes/Ffmpeg.php +++ b/src/Console/Processes/Ffmpeg.php @@ -54,6 +54,11 @@ public function ffmpegBinary(): ?string $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; } diff --git a/src/Console/Processes/Process.php b/src/Console/Processes/Process.php index b38f4645b99..dd302629734 100644 --- a/src/Console/Processes/Process.php +++ b/src/Console/Processes/Process.php @@ -457,6 +457,11 @@ public function fromParent() protected function isWindows() { - return DIRECTORY_SEPARATOR === '\\'; + return PHP_OS_FAMILY === 'Windows'; + } + + protected function isMac() + { + return PHP_OS_FAMILY === 'Darwin'; } } From 1d5585e83466dea2e8d5645c797fade3dd2590d0 Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Tue, 22 Jul 2025 17:45:52 +0100 Subject: [PATCH 17/19] Convert `if` statements to a `match` statement --- src/Imaging/ThumbnailExtractor.php | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/src/Imaging/ThumbnailExtractor.php b/src/Imaging/ThumbnailExtractor.php index d45d139e435..329516e247f 100644 --- a/src/Imaging/ThumbnailExtractor.php +++ b/src/Imaging/ThumbnailExtractor.php @@ -49,15 +49,12 @@ public function generateThumbnail(Asset $asset) return $cachePath; } - $ffmpegInput = null; + $ffmpegInput = match (true) { + file_exists($asset->resolvedPath()) => $asset->resolvedPath(), + $asset->container()->accessible() => $asset->absoluteUrl(), + default => null, + }; - if (file_exists($asset->resolvedPath())) { - $ffmpegInput = $asset->resolvedPath(); - } elseif ($asset->container()->accessible()) { - $ffmpegInput = $asset->absoluteUrl(); - } else { - return null; - } return $this->ffmpeg->extractThumbnail( $asset->absoluteUrl(), From 9b78d62710aa05c0119c08a554c0605e6934c1f5 Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Tue, 22 Jul 2025 17:49:21 +0100 Subject: [PATCH 18/19] Formatting --- src/Imaging/ThumbnailExtractor.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Imaging/ThumbnailExtractor.php b/src/Imaging/ThumbnailExtractor.php index 329516e247f..6b6c5b73fd0 100644 --- a/src/Imaging/ThumbnailExtractor.php +++ b/src/Imaging/ThumbnailExtractor.php @@ -55,7 +55,6 @@ public function generateThumbnail(Asset $asset) default => null, }; - return $this->ffmpeg->extractThumbnail( $asset->absoluteUrl(), static::getCachePath($asset) From cfd3c8a97f19154dea23b3e98b261149fa2eb742 Mon Sep 17 00:00:00 2001 From: John Koster Date: Thu, 24 Jul 2025 19:55:52 -0500 Subject: [PATCH 19/19] =?UTF-8?q?Code=20cleanup=20=F0=9F=A7=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/Imaging/ImageGenerator.php | 22 +--------------------- src/Imaging/ThumbnailExtractor.php | 17 ++++++++++++++++- 2 files changed, 17 insertions(+), 22 deletions(-) diff --git a/src/Imaging/ImageGenerator.php b/src/Imaging/ImageGenerator.php index 832b58390d7..c7df83e6b5f 100644 --- a/src/Imaging/ImageGenerator.php +++ b/src/Imaging/ImageGenerator.php @@ -128,26 +128,11 @@ private function doGenerateByUrl($url, array $params) return $this->generate($parsed['path'].($qs ? '?'.$qs : '')); } - private function canGenerateThumbnail(Asset $asset) - { - $resolvedPath = $asset->resolvedPath(); - - if (file_exists($resolvedPath)) { - return true; - } - - return $asset->container()->accessible(); - } - /** * @param \Statamic\Contracts\Assets\Asset $asset */ public function generateVideoThumbnail($asset, array $params) { - if (! $this->canGenerateThumbnail($asset)) { - return ''; - } - if ($path = app(ThumbnailExtractor::class)->generateThumbnail($asset)) { $this->skip_validation = true; @@ -169,7 +154,7 @@ public function generateVideoThumbnail($asset, array $params) */ public function generateByAsset($asset, array $params) { - if (ThumbnailExtractor::enabled() && method_exists($asset, 'isVideo') && $asset->isVideo()) { + if (ThumbnailExtractor::enabled() && $asset->isVideo()) { return $this->generateVideoThumbnail($asset, $params); } @@ -354,11 +339,6 @@ private function pathSourceFilesystem($root = null) return Storage::build(['driver' => 'local', 'root' => $root])->getDriver(); } - private function videoThumbnailFilesystem() - { - return Storage::build(['driver' => 'local', 'root' => ThumbnailExtractor::cachePath()])->getDriver(); - } - private function guzzleSourceFilesystem($base) { $guzzleClient = app('statamic.imaging.guzzle'); diff --git a/src/Imaging/ThumbnailExtractor.php b/src/Imaging/ThumbnailExtractor.php index 6b6c5b73fd0..06cf028bb2d 100644 --- a/src/Imaging/ThumbnailExtractor.php +++ b/src/Imaging/ThumbnailExtractor.php @@ -41,8 +41,23 @@ public static function getCachePath(Asset $asset) 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)) { @@ -56,7 +71,7 @@ public function generateThumbnail(Asset $asset) }; return $this->ffmpeg->extractThumbnail( - $asset->absoluteUrl(), + $ffmpegInput, static::getCachePath($asset) ); }