![]()
isWindows();
$output = $this->run($isWindows ? 'where composer' : 'which composer');
diff --git a/src/Console/Processes/Ffmpeg.php b/src/Console/Processes/Ffmpeg.php
new file mode 100644
index 00000000000..ace1a142733
--- /dev/null
+++ b/src/Console/Processes/Ffmpeg.php
@@ -0,0 +1,70 @@
+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();
+ }
+}
diff --git a/src/Console/Processes/Process.php b/src/Console/Processes/Process.php
index 5e1c014c694..dd302629734 100644
--- a/src/Console/Processes/Process.php
+++ b/src/Console/Processes/Process.php
@@ -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';
+ }
}
diff --git a/src/Http/Resources/CP/Assets/FolderAsset.php b/src/Http/Resources/CP/Assets/FolderAsset.php
index d00d09f5913..142b7b264d7 100644
--- a/src/Http/Resources/CP/Assets/FolderAsset.php
+++ b/src/Http/Resources/CP/Assets/FolderAsset.php
@@ -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;
@@ -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()),
];
}
@@ -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();
+ }
}
diff --git a/src/Imaging/ImageGenerator.php b/src/Imaging/ImageGenerator.php
index 49273a9e72a..c7df83e6b5f 100644
--- a/src/Imaging/ImageGenerator.php
+++ b/src/Imaging/ImageGenerator.php
@@ -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');
@@ -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.
*
@@ -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);
@@ -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)
diff --git a/src/Imaging/ThumbnailExtractor.php b/src/Imaging/ThumbnailExtractor.php
new file mode 100644
index 00000000000..06cf028bb2d
--- /dev/null
+++ b/src/Imaging/ThumbnailExtractor.php
@@ -0,0 +1,78 @@
+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)
+ );
+ }
+}