Skip to content

[6.x] Asset video thumbnail generation #11841

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 15 commits into
base: ui
Choose a base branch
from
Open
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
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 @@ -111,7 +111,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)
{
$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()) {
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()
{
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(),
static::getCachePath($asset)
);
}
}
Loading