feat: automatically downscale small covers (#1012)

This commit is contained in:
Kyrch
2025-11-28 22:07:11 -03:00
committed by GitHub
parent ee7549f9c6
commit 01eb8c175d
7 changed files with 167 additions and 59 deletions
@@ -19,30 +19,48 @@ use Illuminate\Support\Str;
class OptimizeImageAction
{
public function __construct(protected readonly Image $image, protected readonly string $extension = 'avif') {}
public function __construct(
protected readonly Image $image,
protected readonly ?string $extension = null,
protected readonly ?int $width = null,
protected readonly ?int $height = null,
) {}
/**
* @throws Exception
*/
public function handle(): ActionResult
{
if ($this->extension === null && $this->width === null && $this->height === null) {
return new ActionResult(
ActionStatus::SKIPPED,
'No optimization parameters provided, nothing to process.'
);
}
try {
DB::beginTransaction();
$directory = File::dirname($this->image->path);
$optimizedImage = $this->convertImage();
$optimizedImage = $this->handleFFmpeg();
if ($optimizedImage === null) {
DB::rollBack();
return new ActionResult(
ActionStatus::FAILED,
"Failed to convert image '{$this->image->path}' to '{$this->extension}'.",
"Failed to optimize image '{$this->image->path}'.",
);
}
$path = $this->uploadImage($optimizedImage, $directory);
$path = $this->uploadImage(
Storage::disk('local')->path($optimizedImage),
$directory
);
// Delete the old image from the bucket.
Storage::disk(Config::get(ImageConstants::DISKS_QUALIFIED))->delete($this->image->path);
$this->image->update([
Image::ATTRIBUTE_PATH => $path,
@@ -60,7 +78,7 @@ class OptimizeImageAction
return new ActionResult(ActionStatus::PASSED);
}
protected function convertImage(): ?string
protected function handleFFmpeg(): ?string
{
try {
Storage::disk('local')->put(
@@ -68,15 +86,15 @@ class OptimizeImageAction
Storage::disk(Config::get(ImageConstants::DISKS_QUALIFIED))->get($this->image->path),
);
[$command, $imagePath] = match ($this->extension) {
'avif' => static::getAvifCommand($this->image),
default => throw new Exception("Unsupported image extension: {$this->extension}"),
};
$imagePath = $this->image->path;
Process::run($command)->throw();
if ($this->extension !== null) {
$imagePath = $this->convertImage($imagePath);
}
// Delete the old image from the bucket.
Storage::disk(Config::get(ImageConstants::DISKS_QUALIFIED))->delete($this->image->path);
if ($this->width !== null && $this->height !== null) {
$imagePath = $this->downscaleImage($imagePath);
}
return $imagePath;
} catch (Exception $e) {
@@ -88,6 +106,40 @@ class OptimizeImageAction
return null;
}
/**
* Convert the image to given extension.
*
* @throws Exception
*/
protected function convertImage(string $path): string
{
[$command, $imagePath] = match ($this->extension) {
'avif' => static::getAvifCommand($path),
default => throw new Exception("Unsupported image extension: {$this->extension}"),
};
Process::run($command)->throw();
return $imagePath;
}
/**
* Downscale the image to given width and height.
*
* @throws Exception
*/
protected function downscaleImage(string $path): string
{
[$command, $imagePath] = static::getDownscaleCommand($path, $this->width, $this->height);
Process::run($command)->throw();
return $imagePath;
}
/**
* Upload the image to the bucket.
*/
protected function uploadImage(string $image, string $directory): string
{
/** @var \Illuminate\Filesystem\FilesystemAdapter $fs */
@@ -103,17 +155,17 @@ class OptimizeImageAction
/**
* @return array{0:array<int, string>, 1:string}
*/
public static function getAvifCommand(Image $image): array
public static function getAvifCommand(string $path): array
{
$imagePath = Storage::disk('local')->path(
Str::replaceLast(File::extension($image->path), 'avif', $image->path)
$newPath = Str::replaceLast(File::extension($path), 'avif', $path)
);
return [
[
'ffmpeg',
'-i',
Storage::disk('local')->path($image->path),
Storage::disk('local')->path($path),
'-c:v',
'libaom-av1',
'-crf',
@@ -122,7 +174,30 @@ class OptimizeImageAction
'yuv420p',
$imagePath,
],
$imagePath,
$newPath,
];
}
/**
* @return array{0:array<int, string>, 1:string}
*/
public static function getDownscaleCommand(string $path, int $width = 100, int $height = 150): array
{
$tempPath = Storage::disk('local')->path(
$newPath = Str::replaceLast('.', '_scaled.', $path)
);
return [
[
'ffmpeg',
'-y',
'-i',
Storage::disk('local')->path($path),
'-vf',
"scale={$width}:{$height}",
$tempPath,
],
$newPath,
];
}
}
+1 -1
View File
@@ -91,7 +91,7 @@ trait CanCreateImage
protected function optimize(Image $image): void
{
if ($image->facet === ImageFacet::SMALL_COVER) {
OptimizeImageJob::dispatch($image)
OptimizeImageJob::dispatch($image, 'avif', 100, 150)
->onQueue('optimize-image')
->afterCommit();
}
@@ -6,8 +6,12 @@ namespace App\Filament\Actions\Models\Wiki\Image;
use App\Actions\Models\Wiki\Image\OptimizeImageAction as OptimizeImage;
use App\Filament\Actions\BaseAction;
use App\Filament\Components\Fields\Select;
use App\Filament\Components\Fields\TextInput;
use App\Models\Wiki\Image;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Auth;
class OptimizeImageAction extends BaseAction
@@ -27,12 +31,16 @@ class OptimizeImageAction extends BaseAction
$this->visible(fn (Image $record) => Auth::user()->can('update', $record));
$this->action(fn (Image $record) => $this->handle($record));
$this->action(fn (Image $record, array $data) => $this->handle($record, $data));
}
public function handle(Image $image): void
public function handle(Image $image, array $fields): void
{
$action = new OptimizeImage($image);
$extension = Arr::get($fields, 'extension');
$width = ($value = Arr::get($fields, 'width')) !== null ? (int) $value : null;
$height = ($value = Arr::get($fields, 'height')) !== null ? (int) $value : null;
$action = new OptimizeImage($image, $extension, $width, $height);
$actionResult = $action->handle();
@@ -40,4 +48,29 @@ class OptimizeImageAction extends BaseAction
$this->failedLog($actionResult->getMessage());
}
}
public function getSchema(Schema $schema): Schema
{
return $schema
->components([
Select::make('extension')
->label(__('filament.actions.models.wiki.optimize_image.extension.name'))
->helperText(__('filament.actions.models.wiki.optimize_image.extension.help'))
->options([
'avif' => 'avif',
])
->default('avif'),
TextInput::make('width')
->label(__('filament.actions.models.wiki.optimize_image.width.name'))
->helperText(__('filament.actions.models.wiki.optimize_image.width.help'))
->integer(),
TextInput::make('height')
->label(__('filament.actions.models.wiki.optimize_image.height.name'))
->helperText(__('filament.actions.models.wiki.optimize_image.height.help'))
->integer(),
])
->columns(1);
}
}
+7 -2
View File
@@ -17,11 +17,16 @@ class OptimizeImageJob implements ShouldQueue
use InteractsWithQueue;
use Queueable;
public function __construct(public readonly Image $image) {}
public function __construct(
public readonly Image $image,
public readonly string $extension = 'avif',
public readonly ?int $width = null,
public readonly ?int $height = null,
) {}
public function handle(): void
{
$action = new OptimizeImageAction($this->image, 'avif');
$action = new OptimizeImageAction($this->image, $this->extension, $this->width, $this->height);
$action->handle();
}
@@ -1,35 +0,0 @@
<?php
declare(strict_types=1);
namespace Database\Seeders\Wiki\Image;
use App\Actions\Models\Wiki\Image\OptimizeImageAction;
use App\Enums\Models\Wiki\ImageFacet;
use App\Models\Wiki\Image;
use Exception;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
use Illuminate\Support\Collection;
class ConvertImagesSeeder extends Seeder
{
use WithoutModelEvents;
/**
* Run the database seeds.
*
* @throws Exception
*/
public function run(): void
{
Image::query()
->where(Image::ATTRIBUTE_FACET, ImageFacet::SMALL_COVER->value)
->where(Image::ATTRIBUTE_PATH, 'not like', '%.avif')
->chunkById(100, fn (Collection $images) => $images->each(function (Image $image): void {
$action = new OptimizeImageAction($image, 'avif');
$action->handle();
}));
}
}
+12
View File
@@ -291,6 +291,18 @@ return [
],
'optimize_image' => [
'name' => 'Optimize Image',
'extension' => [
'help' => 'The output image format to convert to.',
'name' => 'Extension',
],
'height' => [
'help' => 'The target height in pixels when downscaling the image.',
'name' => 'Height',
],
'width' => [
'help' => 'The target width in pixels when downscaling the image.',
'name' => 'Width',
],
],
'upload_image' => [
'name' => 'Upload Image',
@@ -13,6 +13,24 @@ use Illuminate\Support\Str;
uses(Illuminate\Foundation\Testing\WithFaker::class);
test('skipped', function () {
$fs = Storage::fake(Config::get(ImageConstants::DISKS_QUALIFIED));
$file = File::fake()->image(fake()->word().'.jpg');
$fsFile = $fs->putFile('', $file);
$image = Image::factory()->createOne([
Image::ATTRIBUTE_PATH => $fsFile,
]);
$action = new OptimizeImageAction($image);
$result = $action->handle();
$this->assertTrue($result->getStatus() === ActionStatus::SKIPPED);
$this->assertDatabaseCount(Image::class, 1);
$this->assertTrue($image->exists());
});
test('converts to avif', function () {
$fs = Storage::fake(Config::get(ImageConstants::DISKS_QUALIFIED));
$file = File::fake()->image(fake()->word().'.jpg');
@@ -32,7 +50,7 @@ test('converts to avif', function () {
$this->assertTrue($image->exists());
});
test('passes', function () {
test('downscale', function () {
$fs = Storage::fake(Config::get(ImageConstants::DISKS_QUALIFIED));
$file = File::fake()->image(fake()->word().'.jpg');
$fsFile = $fs->putFile('', $file);
@@ -41,7 +59,7 @@ test('passes', function () {
Image::ATTRIBUTE_PATH => $fsFile,
]);
$action = new OptimizeImageAction($image);
$action = new OptimizeImageAction($image, null, fake()->randomDigitNotNull(), fake()->randomDigitNotNull());
$result = $action->handle();