diff --git a/config/assets.php b/config/assets.php index b91ecb9104e..4e94f608a81 100644 --- a/config/assets.php +++ b/config/assets.php @@ -148,8 +148,9 @@ | Control Panel Video Thumbnails |-------------------------------------------------------------------------- | - | When enabled, Statamic will generate thumbnails for videos. - | Generated thumbnails are displayed in the Control Panel. + | When enabled, Statamic will generate thumbnails for videos when FFmpeg + | is available. Generated thumbnails are displayed in the Control Panel. + | Without FFmpeg, videos fall back to a filetype icon. | */ @@ -272,6 +273,7 @@ | | 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. + | The configured binary must exist and be executable. | */ diff --git a/resources/js/components/assets/Browser/Grid.vue b/resources/js/components/assets/Browser/Grid.vue index f832956c56a..63aa8f79e8a 100644 --- a/resources/js/components/assets/Browser/Grid.vue +++ b/resources/js/components/assets/Browser/Grid.vue @@ -128,6 +128,7 @@ 'w-full p-4': asset.extension === 'svg', 'rounded-lg p-1': asset.orientation === 'square', }" + @error="asset.thumbnail = null" /> diff --git a/resources/js/components/assets/Browser/Thumbnail.vue b/resources/js/components/assets/Browser/Thumbnail.vue index 50c49093e1b..345f3003327 100644 --- a/resources/js/components/assets/Browser/Thumbnail.vue +++ b/resources/js/components/assets/Browser/Thumbnail.vue @@ -7,6 +7,7 @@ loading="lazy" :draggable="false" :class="{ 'h-8 w-8 object-cover': square }" + @error="asset.thumbnail = null" /> diff --git a/resources/js/components/fieldtypes/assets/AssetTile.vue b/resources/js/components/fieldtypes/assets/AssetTile.vue index bfab7cdd54d..e8cf4cc7c99 100644 --- a/resources/js/components/fieldtypes/assets/AssetTile.vue +++ b/resources/js/components/fieldtypes/assets/AssetTile.vue @@ -36,7 +36,7 @@ diff --git a/src/Console/Processes/Ffmpeg.php b/src/Console/Processes/Ffmpeg.php index 696f35b1845..aefbd2c1c9b 100644 --- a/src/Console/Processes/Ffmpeg.php +++ b/src/Console/Processes/Ffmpeg.php @@ -8,6 +8,10 @@ class Ffmpeg extends Process { protected string $startTimestamp = '00:00:00'; + private static bool $binaryResolved = false; + + private static ?string $resolvedBinary = null; + public function startTimestamp(string $startTimestamp): self { $this->startTimestamp = $startTimestamp; @@ -49,10 +53,26 @@ private function buildCommand(string $ffmpegBinary, string $path, string $output ])->join(' '); } + public function available(): bool + { + return filled($this->ffmpegBinary()); + } + public function ffmpegBinary(): ?string + { + if (static::$binaryResolved) { + return static::$resolvedBinary; + } + + static::$binaryResolved = true; + + return static::$resolvedBinary = $this->resolveFfmpegBinary(); + } + + private function resolveFfmpegBinary(): ?string { if ($binary = config('statamic.assets.ffmpeg.binary')) { - return $binary; + return is_executable($binary) ? $binary : null; } $output = $this->run($this->isWindows() ? 'where ffmpeg' : 'which ffmpeg'); @@ -66,8 +86,20 @@ public function ffmpegBinary(): ?string return null; } - return str(StringUtilities::normalizeLineEndings(trim($output))) + $resolved = str(StringUtilities::normalizeLineEndings(trim($output))) ->explode("\n") ->first(); + + if (! filled($resolved) || ! is_executable($resolved)) { + return null; + } + + return $resolved; + } + + public static function clearBinaryCache(): void + { + static::$binaryResolved = false; + static::$resolvedBinary = null; } } diff --git a/src/Http/Controllers/CP/Assets/ThumbnailController.php b/src/Http/Controllers/CP/Assets/ThumbnailController.php index 6e529e1bd88..f05812d56c4 100644 --- a/src/Http/Controllers/CP/Assets/ThumbnailController.php +++ b/src/Http/Controllers/CP/Assets/ThumbnailController.php @@ -71,9 +71,15 @@ public function show($asset, $size = null, $orientation = null) return $placeholder; } + $path = $this->generate(); + + if (! $path) { + return $this->getUnavailableThumbnailResponse(); + } + return $this->server->getResponseFactory()->create( $this->server->getCache(), - $this->generate() + $path ); } @@ -189,4 +195,18 @@ private function getPlaceholderResponse() return response(Statamic::svg('filetypes/picture'))->header('Content-Type', 'image/svg+xml'); } + + /** + * When thumbnail generation fails (e.g. FFmpeg missing for videos), show a filetype icon. + * + * @return \Illuminate\Http\Response + */ + private function getUnavailableThumbnailResponse() + { + $svg = $this->asset->isVideo() + ? Statamic::svg('filetypes/video') + : Statamic::svg('filetypes/picture'); + + return response($svg)->header('Content-Type', 'image/svg+xml'); + } } diff --git a/src/Http/Resources/CP/Assets/HasThumbnails.php b/src/Http/Resources/CP/Assets/HasThumbnails.php index 76a08f3d33d..a0ad4cfe4b2 100644 --- a/src/Http/Resources/CP/Assets/HasThumbnails.php +++ b/src/Http/Resources/CP/Assets/HasThumbnails.php @@ -3,6 +3,7 @@ namespace Statamic\Http\Resources\CP\Assets; use Illuminate\Support\Fluent; +use Statamic\Imaging\ThumbnailExtractor; use Statamic\Support\Traits\Hookable; trait HasThumbnails @@ -13,7 +14,7 @@ private function thumbnails(): array { $data = match (true) { $this->isImage() || $this->isSvg() => $this->getImageThumbnail(), - $this->isVideo() && config('statamic.assets.video_thumbnails', true) => $this->getVideoThumbnail(), + $this->isVideo() && ThumbnailExtractor::available() => $this->getVideoThumbnail(), default => ['thumbnail' => null], }; diff --git a/src/Imaging/ImageGenerator.php b/src/Imaging/ImageGenerator.php index c419010fc51..17727960c62 100644 --- a/src/Imaging/ImageGenerator.php +++ b/src/Imaging/ImageGenerator.php @@ -155,10 +155,14 @@ public function generateVideoThumbnail($asset, array $params) */ public function generateByAsset($asset, array $params) { - if (ThumbnailExtractor::enabled() && $asset->isVideo()) { + if (ThumbnailExtractor::available() && $asset->isVideo()) { return $this->generateVideoThumbnail($asset, $params); } + if ($asset->isVideo()) { + return ''; + } + $manipulationCacheKey = 'asset::'.$asset->id().'::'.md5(json_encode($params)); $manifestCacheKey = static::assetCacheManifestKey($asset); diff --git a/src/Imaging/ThumbnailExtractor.php b/src/Imaging/ThumbnailExtractor.php index 06cf028bb2d..62e748b199d 100644 --- a/src/Imaging/ThumbnailExtractor.php +++ b/src/Imaging/ThumbnailExtractor.php @@ -20,6 +20,11 @@ public static function enabled() ); } + public static function available() + { + return static::enabled() && app(Ffmpeg::class)->available(); + } + public static function cachePath() { return config( diff --git a/src/Providers/AppServiceProvider.php b/src/Providers/AppServiceProvider.php index 507d81d19cc..05bb28b501f 100644 --- a/src/Providers/AppServiceProvider.php +++ b/src/Providers/AppServiceProvider.php @@ -8,9 +8,11 @@ use Illuminate\Foundation\Http\Middleware\TrimStrings; use Illuminate\Http\Request; use Illuminate\Routing\Router; +use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Session; use Illuminate\Support\ServiceProvider; use Illuminate\Support\Str; +use Statamic\Console\Processes\Ffmpeg; use Statamic\CP\CarbonAsVueComponent; use Statamic\Facades; use Statamic\Facades\Addon; @@ -49,6 +51,10 @@ public function boot() $this->loadRoutesFrom("{$this->root}/routes/routes.php"); }); + if (class_exists(\Laravel\Octane\Events\RequestReceived::class)) { + Event::listen(\Laravel\Octane\Events\RequestReceived::class, fn () => Ffmpeg::clearBinaryCache()); + } + $this->app[\Illuminate\Contracts\Http\Kernel::class] ->pushMiddleware(\Statamic\Http\Middleware\PoweredByHeader::class) ->pushMiddleware(\Statamic\Http\Middleware\CheckComposerJsonScripts::class) diff --git a/tests/Console/FfmpegTest.php b/tests/Console/FfmpegTest.php index 46bddcf5668..4ea37cae9cf 100644 --- a/tests/Console/FfmpegTest.php +++ b/tests/Console/FfmpegTest.php @@ -8,6 +8,13 @@ class FfmpegTest extends TestCase { + public function tearDown(): void + { + Ffmpeg::clearBinaryCache(); + + parent::tearDown(); + } + #[Test] public function it_builds_a_thumbnail_command_that_only_writes_errors_to_stderr() { @@ -20,6 +27,62 @@ public function it_builds_a_thumbnail_command_that_only_writes_errors_to_stderr( $this->assertStringNotContainsString('-vframes', $command); } + #[Test] + public function it_ignores_a_configured_binary_that_is_not_executable() + { + Ffmpeg::clearBinaryCache(); + config(['statamic.assets.ffmpeg.binary' => storage_path('missing-ffmpeg-binary')]); + + $this->assertNull((new Ffmpeg)->ffmpegBinary()); + $this->assertFalse((new Ffmpeg)->available()); + } + + #[Test] + public function it_memoizes_binary_resolution_across_instances() + { + Ffmpeg::clearBinaryCache(); + config(['statamic.assets.ffmpeg.binary' => PHP_BINARY]); + + $resolved = (new Ffmpeg)->ffmpegBinary(); + + config(['statamic.assets.ffmpeg.binary' => storage_path('missing-ffmpeg-binary')]); + + $this->assertSame($resolved, (new Ffmpeg)->ffmpegBinary()); + + Ffmpeg::clearBinaryCache(); + + $this->assertNull((new Ffmpeg)->ffmpegBinary()); + } + + #[Test] + public function it_ignores_a_path_discovered_binary_that_is_not_executable() + { + Ffmpeg::clearBinaryCache(); + config(['statamic.assets.ffmpeg.binary' => null]); + + $path = storage_path('non-executable-ffmpeg'); + file_put_contents($path, ''); + chmod($path, 0644); + + $ffmpeg = new class($path) extends Ffmpeg + { + public function __construct(private string $discoveredPath) + { + parent::__construct(); + } + + public function run($command, $cacheKey = null) + { + return $this->discoveredPath; + } + }; + + $this->assertNull($ffmpeg->ffmpegBinary()); + $this->assertFalse($ffmpeg->available()); + + @unlink($path); + } + private function buildCommand(...$arguments) { $method = (new \ReflectionClass(Ffmpeg::class))->getMethod('buildCommand'); diff --git a/tests/Feature/Assets/VideoThumbnailTest.php b/tests/Feature/Assets/VideoThumbnailTest.php new file mode 100644 index 00000000000..9cc2c1f83e1 --- /dev/null +++ b/tests/Feature/Assets/VideoThumbnailTest.php @@ -0,0 +1,115 @@ + [ + 'driver' => 'local', + 'root' => $this->tempDir = __DIR__.'/tmp', + ]]); + } + + public function tearDown(): void + { + Ffmpeg::clearBinaryCache(); + + app('files')->deleteDirectory($this->tempDir); + + parent::tearDown(); + } + + #[Test] + public function it_omits_thumbnail_url_from_asset_payload_when_ffmpeg_is_unavailable() + { + $this->withoutFfmpeg(); + $this->actingAs(tap(User::make()->makeSuper())->save()); + + $asset = $this->createVideoAsset(); + + $payload = (new AssetsFieldtypeAsset($asset))->resolve()['data']; + + $this->assertNull($payload['thumbnail']); + } + + #[Test] + public function it_returns_a_video_filetype_icon_when_video_thumbnail_cannot_be_generated() + { + $this->withoutFfmpeg(); + + $asset = $this->createVideoAsset(); + + $this->setTestRoles(['test' => ['access cp', 'view test assets']]); + $user = User::make()->assignRole('test')->save(); + + $this + ->actingAs($user) + ->get('/cp/thumbnails/'.base64_encode($asset->id()).'/small') + ->assertSuccessful() + ->assertHeader('Content-Type', 'image/svg+xml') + ->assertSee(Statamic::svg('filetypes/video'), false); + } + + #[Test] + public function it_returns_a_video_filetype_icon_when_video_thumbnails_are_disabled() + { + config(['statamic.assets.video_thumbnails' => false]); + Ffmpeg::clearBinaryCache(); + + $asset = $this->createVideoAsset(); + + $this->setTestRoles(['test' => ['access cp', 'view test assets']]); + $user = User::make()->assignRole('test')->save(); + + $this + ->actingAs($user) + ->get('/cp/thumbnails/'.base64_encode($asset->id()).'/small') + ->assertSuccessful() + ->assertHeader('Content-Type', 'image/svg+xml') + ->assertSee(Statamic::svg('filetypes/video'), false); + } + + private function createVideoAsset() + { + $container = AssetContainer::make('test')->disk('test')->save(); + + return $container + ->makeAsset('clip.mp4') + ->upload(UploadedFile::fake()->create('clip.mp4', 100, 'video/mp4')); + } + + private function withoutFfmpeg() + { + config(['statamic.assets.ffmpeg.binary' => null]); + + $this->mock(Ffmpeg::class, function ($mock) { + $mock->shouldReceive('available')->andReturn(false); + $mock->shouldReceive('ffmpegBinary')->andReturn(null); + $mock->shouldReceive('extractThumbnail')->andReturn(null); + }); + + Ffmpeg::clearBinaryCache(); + } +}