Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
8d88734
Generate thumbnails for video files
JohnathonKoster Jun 4, 2025
13dc58b
Merge branch 'ui' into asset-video-thumbnail-generation
JohnathonKoster Jun 4, 2025
5a897c2
Rename to mentally separate from console output
JohnathonKoster Jun 4, 2025
c36cc33
Refactor to method on base class
JohnathonKoster Jun 4, 2025
f2fb5d3
Auto discover ffmpeg, refactor config
JohnathonKoster Jun 4, 2025
18dc53a
Better handling to ensure fallback SVG icon
JohnathonKoster Jun 4, 2025
5089110
Update Ffmpeg.php
JohnathonKoster Jun 4, 2025
064bf1f
Revert this
JohnathonKoster Jun 4, 2025
fce2ac1
Merge branch 'ui' into asset-video-thumbnail-generation
JohnathonKoster Jun 4, 2025
102e1aa
Update ImageGenerator.php
JohnathonKoster Jun 5, 2025
6672563
Update FolderAsset.php
JohnathonKoster Jun 5, 2025
a90c7d2
Merge branch 'ui' into asset-video-thumbnail-generation
JohnathonKoster Jun 5, 2025
7084330
Merge branch 'ui' into asset-video-thumbnail-generation
JohnathonKoster Jun 14, 2025
d076e71
Adjust ordering of merged data
JohnathonKoster Jun 14, 2025
ec559bc
Merge branch 'ui' into pr/11841
duncanmcclean Jun 18, 2025
36dac14
Merge branch 'master' into pr/11841
duncanmcclean Jul 10, 2025
953d732
Merge branch 'master' into pr/11841
duncanmcclean Jul 22, 2025
a3d81ab
Move private methods to the bottom
duncanmcclean Jul 22, 2025
7db2abb
Use a match statement here instead
duncanmcclean Jul 22, 2025
32b7e4d
A couple of nitpicky changes
duncanmcclean Jul 22, 2025
c98e923
Move `video_thumbnails` config next to the other thumbnail-related op…
duncanmcclean Jul 22, 2025
ebb8e9a
return types
duncanmcclean Jul 22, 2025
463d61d
Laravel Herd doesn't inherit the user's PATH, so we need to check the…
duncanmcclean Jul 22, 2025
1d5585e
Convert `if` statements to a `match` statement
duncanmcclean Jul 22, 2025
9b78d62
Formatting
duncanmcclean Jul 22, 2025
f4883fc
Merge branch 'master' into pr/11841
duncanmcclean Jul 23, 2025
cfd3c8a
Code cleanup 🧹
JohnathonKoster Jul 25, 2025
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
27 changes: 27 additions & 0 deletions config/assets.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -208,4 +220,19 @@

'svg_sanitization_on_upload' => true,

/*
|--------------------------------------------------------------------------
| FFmpeg
|--------------------------------------------------------------------------
|
| 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.
|
*/

'ffmpeg' => [
'binary' => null,
'cache_path' => storage_path('statamic/glide/ffmpeg'),
],

];
2 changes: 1 addition & 1 deletion resources/js/components/assets/Browser/Grid.vue
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@
<div class="relative flex aspect-square size-full items-center justify-center">
<div class="asset-thumb">
<img
v-if="asset.is_image"
v-if="asset.thumbnail"
:src="asset.thumbnail"
loading="lazy"
:draggable="false"
Expand Down
2 changes: 1 addition & 1 deletion resources/js/components/assets/Browser/Thumbnail.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<template>
<div class="">
<img
v-if="asset.is_image"
v-if="asset.thumbnail"
:src="asset.thumbnail"
class="asset-thumbnail mx-auto max-h-8 max-w-full rounded-sm"
loading="lazy"
Expand Down
2 changes: 1 addition & 1 deletion src/Console/Processes/Composer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down
70 changes: 70 additions & 0 deletions src/Console/Processes/Ffmpeg.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<?php

namespace Statamic\Console\Processes;

use Statamic\View\Antlers\Language\Utilities\StringUtilities;

class Ffmpeg extends Process
{
protected string $startTimestamp = '00:00:00';

public function startTimestamp(string $startTimestamp): self
{
$this->startTimestamp = $startTimestamp;

return $this;
}

public function extractThumbnail(string $path, string $outputFilePath): ?string
{
$ffmpegBinary = $this->ffmpegBinary();

if (! $ffmpegBinary) {
return null;
}

$this->run($this->buildCommand($ffmpegBinary, $path, $outputFilePath));

if (! file_exists($outputFilePath)) {
return null;
}

return $outputFilePath;
}

private function buildCommand(string $ffmpegBinary, string $path, string $output): string
{
return collect([
escapeshellarg($ffmpegBinary),
'-y',
'-ss',
escapeshellarg($this->startTimestamp),
'-i',
escapeshellarg($path),
'-vframes 1',
escapeshellarg($output),
])->join(' ');
}

public function ffmpegBinary(): ?string
{
if ($binary = config('statamic.assets.ffmpeg.binary')) {
return $binary;
}

$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;
}

return str(StringUtilities::normalizeLineEndings(trim($output)))
->explode("\n")
->first();
}
}
10 changes: 10 additions & 0 deletions src/Console/Processes/Process.php
Original file line number Diff line number Diff line change
Expand Up @@ -454,4 +454,14 @@ public function fromParent()

return $that;
}

protected function isWindows()
{
return PHP_OS_FAMILY === 'Windows';
}

protected function isMac()
{
return PHP_OS_FAMILY === 'Darwin';
}
}
54 changes: 44 additions & 10 deletions src/Http/Resources/CP/Assets/FolderAsset.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,15 @@
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;

protected $blueprint;
protected $columns;

Expand Down Expand Up @@ -35,22 +39,14 @@ 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(),
];
}),

$this->merge($this->values()),

'actions' => Action::for($this->resource, [
'container' => $this->container()->handle(),
'folder' => $this->folder(),
]),

$this->merge($this->thumbnails()),
];
}

Expand All @@ -74,4 +70,42 @@ protected function values($extra = [])
return [$key => $value];
});
}

private function thumbnails(): array
{
$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, $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();
}
}
32 changes: 28 additions & 4 deletions src/Imaging/ImageGenerator.php
Original file line number Diff line number Diff line change
Expand Up @@ -87,12 +87,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');

Expand Down Expand Up @@ -128,6 +128,24 @@ private function doGenerateByUrl($url, array $params)
return $this->generate($parsed['path'].($qs ? '?'.$qs : ''));
}

/**
* @param \Statamic\Contracts\Assets\Asset $asset
*/
public function generateVideoThumbnail($asset, array $params)
{
if ($path = app(ThumbnailExtractor::class)->generateThumbnail($asset)) {
$this->skip_validation = true;

return $this->doGenerateByPath(
basename($path),
$params,
config('statamic.assets.ffmpeg.cache_path'),
);
}

return '';
}

/**
* Generate a manipulated image by an asset.
*
Expand All @@ -136,6 +154,10 @@ private function doGenerateByUrl($url, array $params)
*/
public function generateByAsset($asset, array $params)
{
if (ThumbnailExtractor::enabled() && $asset->isVideo()) {
return $this->generateVideoThumbnail($asset, $params);
}

$manipulationCacheKey = 'asset::'.$asset->id().'::'.md5(json_encode($params));
$manifestCacheKey = static::assetCacheManifestKey($asset);

Expand Down Expand Up @@ -310,9 +332,11 @@ private function validateImage()
}
}

private function pathSourceFilesystem()
private function pathSourceFilesystem($root = null)
{
return Storage::build(['driver' => 'local', 'root' => public_path()])->getDriver();
$root ??= public_path();

return Storage::build(['driver' => 'local', 'root' => $root])->getDriver();
}

private function guzzleSourceFilesystem($base)
Expand Down
78 changes: 78 additions & 0 deletions src/Imaging/ThumbnailExtractor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
<?php

namespace Statamic\Imaging;

use Statamic\Console\Processes\Ffmpeg;
use Statamic\Contracts\Assets\Asset;
use Statamic\Facades\Path;

class ThumbnailExtractor
{
public function __construct(private 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')
);
}

public static 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 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)) {
return $cachePath;
}

$ffmpegInput = match (true) {
file_exists($asset->resolvedPath()) => $asset->resolvedPath(),
$asset->container()->accessible() => $asset->absoluteUrl(),
default => null,
};

return $this->ffmpeg->extractThumbnail(
$ffmpegInput,
static::getCachePath($asset)
);
}
}