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();
+ }
+}