From 8d887343f5512ea861ce2178536f98d11271ea2d Mon Sep 17 00:00:00 2001 From: John Koster Date: Tue, 3 Jun 2025 22:03:50 -0500 Subject: [PATCH 01/10] 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 d86e2e78e4..2a83b20c79 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 6f06264a30..cef6e55547 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 4bf35a9b57..ee2c17bc96 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 49273a9e72..788e33ede5 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 0000000000..9a54870933 --- /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/10] 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 85d59445e1..38016dffd9 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/10] 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 5a275385e6..c0bea6e4be 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 5e1c014c69..b38f4645b9 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/10] 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 2a83b20c79..8b895b6e75 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 38016dffd9..1aeab82bca 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/10] 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 6c8e8fb5b2..1ebe8818b7 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 788e33ede5..959e87c566 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 9a54870933..14f5f8ff25 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/10] 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 1aeab82bca..3529e9cf7a 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/10] 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 1ebe8818b7..8dc057a1ec 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 14f5f8ff25..ce42b3c0e0 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/10] 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 959e87c566..832b58390d 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/10] 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 8dc057a1ec..6c8e8fb5b2 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/10] 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 6c8e8fb5b2..f7aca8337e 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()), ]; }