Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions config/assets.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
|
*/

Expand Down Expand Up @@ -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.
|
*/

Expand Down
1 change: 1 addition & 0 deletions resources/js/components/assets/Browser/Grid.vue
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@
'w-full p-4': asset.extension === 'svg',
'rounded-lg p-1': asset.orientation === 'square',
}"
@error="asset.thumbnail = null"
/>
<file-icon v-else :extension="asset.extension" class="size-1/2" />
</div>
Expand Down
1 change: 1 addition & 0 deletions resources/js/components/assets/Browser/Thumbnail.vue
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
loading="lazy"
:draggable="false"
:class="{ 'h-8 w-8 object-cover': square }"
@error="asset.thumbnail = null"
/>
<img
v-else-if="asset.is_svg"
Expand Down
1 change: 1 addition & 0 deletions resources/js/components/fieldtypes/assets/AssetRow.vue
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
:src="thumbnail"
:alt="asset.basename"
v-if="thumbnail"
@error="asset.thumbnail = null"
/>
<file-icon :extension="asset.extension ?? 'generic'" v-else class="size-7" />
</button>
Expand Down
2 changes: 1 addition & 1 deletion resources/js/components/fieldtypes/assets/AssetTile.vue
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
<img v-if="canShowSvg" :src="asset.url" :title="label" class="p-4 w-full relative" />

<template v-else>
<img :src="thumbnail" v-if="thumbnail" :title="label" class="rounded-md relative" />
<img :src="thumbnail" v-if="thumbnail" :title="label" class="rounded-md relative" @error="asset.thumbnail = null" />

<file-icon v-else :extension="asset.extension ?? 'generic'" class="h-full w-full p-4 relative" />
</template>
Expand Down
36 changes: 34 additions & 2 deletions src/Console/Processes/Ffmpeg.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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');
Expand All @@ -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;
}
}
22 changes: 21 additions & 1 deletion src/Http/Controllers/CP/Assets/ThumbnailController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
}

Expand Down Expand Up @@ -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');
}
}
3 changes: 2 additions & 1 deletion src/Http/Resources/CP/Assets/HasThumbnails.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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],
};

Expand Down
6 changes: 5 additions & 1 deletion src/Imaging/ImageGenerator.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
5 changes: 5 additions & 0 deletions src/Imaging/ThumbnailExtractor.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
6 changes: 6 additions & 0 deletions src/Providers/AppServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)
Expand Down
63 changes: 63 additions & 0 deletions tests/Console/FfmpegTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand All @@ -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');
Expand Down
Loading
Loading