Skip to content
Merged
Show file tree
Hide file tree
Changes from 17 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
28 changes: 28 additions & 0 deletions config/assets.php
Original file line number Diff line number Diff line change
Expand Up @@ -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' => 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
68 changes: 68 additions & 0 deletions src/Console/Processes/Ffmpeg.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
<?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): static
{
$this->startTimestamp = $startTimestamp;

return $this;
}

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

if (! $ffmpegBinary) {
return null;
}

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

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

return $outputFilePath;
}

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

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

$output = $this->run($this->isWindows() ? 'where ffmpeg' : '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();
}
}
5 changes: 5 additions & 0 deletions src/Console/Processes/Process.php
Original file line number Diff line number Diff line change
Expand Up @@ -454,4 +454,9 @@ public function fromParent()

return $that;
}

protected function isWindows()
{
return DIRECTORY_SEPARATOR === '\\';
}
}
59 changes: 49 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 All @@ -25,6 +29,49 @@ public function columns($columns)
return $this;
}

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()
{
$data = ['thumbnail' => null];

if ($this->isImage() || $this->isSvg()) {
$data = $this->getImageThumbnail();
} elseif (config('statamic.assets.video_thumbnails', true) && $this->isVideo()) {
$data = $this->getVideoThumbnail();
}

return array_merge(
$data,
$this->runAssetHook() ?? []
);
}

private function runAssetHook()
{
$payload = $this->runHooksWith('asset', [
'data' => new Fluent,
]);

return $payload->data->toArray();
}

public function toArray($request)
{
return [
Expand All @@ -35,22 +82,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 Down
52 changes: 48 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,39 @@ private function doGenerateByUrl($url, array $params)
return $this->generate($parsed['path'].($qs ? '?'.$qs : ''));
}

private function canGenerateThumbnail(Asset $asset)
Comment thread
JohnathonKoster marked this conversation as resolved.
Outdated
{
$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 '';
}

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 +169,10 @@ private function doGenerateByUrl($url, array $params)
*/
public function generateByAsset($asset, array $params)
{
if (ThumbnailExtractor::enabled() && method_exists($asset, 'isVideo') && $asset->isVideo()) {
Comment thread
JohnathonKoster marked this conversation as resolved.
Outdated
return $this->generateVideoThumbnail($asset, $params);
}

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

Expand Down Expand Up @@ -310,9 +347,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()
Comment thread
JohnathonKoster marked this conversation as resolved.
Outdated
{
return Storage::build(['driver' => 'local', 'root' => public_path()])->getDriver();
return Storage::build(['driver' => 'local', 'root' => ThumbnailExtractor::cachePath()])->getDriver();
}

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

namespace Statamic\Imaging;

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

class ThumbnailExtractor
{
private Ffmpeg $ffmpeg;

public function __construct(Ffmpeg $ffmpeg)
{
$this->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 function generateThumbnail(Asset $asset)
{
$cachePath = static::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(),
Comment thread
JohnathonKoster marked this conversation as resolved.
Outdated
static::getCachePath($asset)
);
}
}