diff --git a/app/Actions/Models/Wiki/Image/OptimizeImageAction.php b/app/Actions/Models/Wiki/Image/OptimizeImageAction.php index 170e9aaea..3ed65b8cb 100644 --- a/app/Actions/Models/Wiki/Image/OptimizeImageAction.php +++ b/app/Actions/Models/Wiki/Image/OptimizeImageAction.php @@ -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, 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, 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, ]; } } diff --git a/app/Concerns/Models/CanCreateImage.php b/app/Concerns/Models/CanCreateImage.php index c1b6eaf6f..67fadb74b 100644 --- a/app/Concerns/Models/CanCreateImage.php +++ b/app/Concerns/Models/CanCreateImage.php @@ -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(); } diff --git a/app/Filament/Actions/Models/Wiki/Image/OptimizeImageAction.php b/app/Filament/Actions/Models/Wiki/Image/OptimizeImageAction.php index b98c14d0d..a3415d1c0 100644 --- a/app/Filament/Actions/Models/Wiki/Image/OptimizeImageAction.php +++ b/app/Filament/Actions/Models/Wiki/Image/OptimizeImageAction.php @@ -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); + } } diff --git a/app/Jobs/Wiki/Image/OptimizeImageJob.php b/app/Jobs/Wiki/Image/OptimizeImageJob.php index a2c71fb18..6fd531ed9 100644 --- a/app/Jobs/Wiki/Image/OptimizeImageJob.php +++ b/app/Jobs/Wiki/Image/OptimizeImageJob.php @@ -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(); } diff --git a/database/seeders/Wiki/Image/ConvertImagesSeeder.php b/database/seeders/Wiki/Image/ConvertImagesSeeder.php deleted file mode 100644 index 90a89a771..000000000 --- a/database/seeders/Wiki/Image/ConvertImagesSeeder.php +++ /dev/null @@ -1,35 +0,0 @@ -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(); - })); - } -} diff --git a/lang/en/filament.php b/lang/en/filament.php index b58ae2bc3..88d702635 100644 --- a/lang/en/filament.php +++ b/lang/en/filament.php @@ -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', diff --git a/tests/Feature/Actions/Models/Wiki/Image/OptimizeImageTest.php b/tests/Feature/Actions/Models/Wiki/Image/OptimizeImageTest.php index 7c8958966..026fe4a84 100644 --- a/tests/Feature/Actions/Models/Wiki/Image/OptimizeImageTest.php +++ b/tests/Feature/Actions/Models/Wiki/Image/OptimizeImageTest.php @@ -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();