From c0ffa7083d31d75710f95f725df1d8b272add142 Mon Sep 17 00:00:00 2001 From: Kyrch Date: Mon, 3 Nov 2025 07:12:25 -0300 Subject: [PATCH] feat: optimize image action (#992) --- .../Models/Wiki/Image/OptimizeImageAction.php | 131 ++++++++++++++++++ app/Concerns/Models/CanCreateImage.php | 19 ++- .../Models/Wiki/Image/OptimizeImageAction.php | 43 ++++++ .../Resources/List/External/ExternalEntry.php | 3 +- .../Resources/List/ExternalProfile.php | 7 +- app/Filament/Resources/List/Playlist.php | 3 +- app/Filament/Resources/Wiki/Anime.php | 6 +- app/Filament/Resources/Wiki/Anime/Synonym.php | 3 +- app/Filament/Resources/Wiki/Anime/Theme.php | 3 +- .../Resources/Wiki/ExternalResource.php | 3 +- app/Filament/Resources/Wiki/Image.php | 14 +- app/Filament/Resources/Wiki/Video.php | 6 +- app/Jobs/Wiki/Image/OptimizeImageJob.php | 28 ++++ .../Wiki/Image/ConvertImagesSeeder.php | 35 +++++ lang/en/filament.php | 3 + .../Models/Wiki/Image/OptimizeImageTest.php | 51 +++++++ tests/Feature/Http/Api/ThrottleTest.php | 2 +- 17 files changed, 345 insertions(+), 15 deletions(-) create mode 100644 app/Actions/Models/Wiki/Image/OptimizeImageAction.php create mode 100644 app/Filament/Actions/Models/Wiki/Image/OptimizeImageAction.php create mode 100644 app/Jobs/Wiki/Image/OptimizeImageJob.php create mode 100644 database/seeders/Wiki/Image/ConvertImagesSeeder.php create mode 100644 tests/Feature/Actions/Models/Wiki/Image/OptimizeImageTest.php diff --git a/app/Actions/Models/Wiki/Image/OptimizeImageAction.php b/app/Actions/Models/Wiki/Image/OptimizeImageAction.php new file mode 100644 index 000000000..1c7092cf0 --- /dev/null +++ b/app/Actions/Models/Wiki/Image/OptimizeImageAction.php @@ -0,0 +1,131 @@ +image->path, DIRECTORY_SEPARATOR); + + $optimizedImage = $this->convertImage(); + + if ($optimizedImage === null) { + DB::rollBack(); + + return new ActionResult( + ActionStatus::FAILED, + "Failed to convert image '{$this->image->path}' to '{$this->extension}'.", + ); + } + + $path = $this->uploadImage($optimizedImage, $directory); + + $this->image->update([ + Image::ATTRIBUTE_PATH => $path, + ]); + + DB::commit(); + } catch (Exception $e) { + DB::rollBack(); + + Log::error($e->getMessage()); + + throw $e; + } + + return new ActionResult(ActionStatus::PASSED); + } + + /** + * @throws Exception + */ + protected function convertImage(): ?string + { + try { + Storage::disk('local')->put( + $this->image->path, + Storage::disk(Config::get(ImageConstants::DISKS_QUALIFIED))->get($this->image->path), + ); + + [$command, $imagePath] = match ($this->extension) { + 'avif' => static::getAvifCommand(), + default => throw new Exception("Unsupported image extension: {$this->extension}"), + }; + + Process::run($command)->throw(); + + // Delete the old image from the bucket. + Storage::disk(Config::get(ImageConstants::DISKS_QUALIFIED))->delete($this->image->path); + + return $imagePath; + } catch (Exception $e) { + Log::error($e->getMessage()); + } finally { + Storage::disk('local')->delete($this->image->path); + } + + return null; + } + + protected function uploadImage(string $image, string $directory): string + { + /** @var \Illuminate\Filesystem\FilesystemAdapter $fs */ + $fs = Storage::disk(Config::get(ImageConstants::DISKS_QUALIFIED)); + + $fsFile = $fs->putFile($directory, $image); + + Log::info("Uploading optimized Image {$fsFile}"); + + return $fsFile; + } + + /** + * @return array{0:array, 1:string} + */ + protected function getAvifCommand(): array + { + $imagePath = Storage::disk('local')->path( + Str::replaceLast(File::extension($this->image->path), 'avif', $this->image->path) + ); + + return [ + [ + 'ffmpeg', + '-i', + Storage::disk('local')->path($this->image->path), + '-c:v', + 'libaom-av1', + '-crf', + '30', + '-pix_fmt', + 'yuv420p', + $imagePath, + ], + $imagePath, + ]; + } +} diff --git a/app/Concerns/Models/CanCreateImage.php b/app/Concerns/Models/CanCreateImage.php index afcb43d29..6e9e40787 100644 --- a/app/Concerns/Models/CanCreateImage.php +++ b/app/Concerns/Models/CanCreateImage.php @@ -4,8 +4,10 @@ declare(strict_types=1); namespace App\Concerns\Models; +use App\Constants\Config\ImageConstants; use App\Contracts\Models\HasImages; use App\Enums\Models\Wiki\ImageFacet; +use App\Jobs\Wiki\Image\OptimizeImageJob; use App\Models\BaseModel; use App\Models\Wiki\Image; use Illuminate\Http\Client\RequestException; @@ -32,7 +34,7 @@ trait CanCreateImage $file = File::createWithContent(basename($url), $image); /** @var \Illuminate\Filesystem\FilesystemAdapter $fs */ - $fs = Storage::disk(Config::get('image.disk')); + $fs = Storage::disk(Config::get(ImageConstants::DISKS_QUALIFIED)); $fsFile = $fs->putFile($this->path($facet, $model), $file); @@ -45,13 +47,15 @@ trait CanCreateImage $this->attachImage($image, $model); + $this->optimize($image); + return $image; } public function createImageFromFile(mixed $image, ImageFacet $facet, (BaseModel&HasImages)|null $model = null): Image { /** @var \Illuminate\Filesystem\FilesystemAdapter $fs */ - $fs = Storage::disk(Config::get('image.disk')); + $fs = Storage::disk(Config::get(ImageConstants::DISKS_QUALIFIED)); $fsFile = $fs->putFile($this->path($facet, $model), $image); @@ -64,6 +68,8 @@ trait CanCreateImage $this->attachImage($image, $model); + $this->optimize($image); + return $image; } @@ -89,4 +95,13 @@ trait CanCreateImage $model->images()->attach($image); } } + + protected function optimize(Image $image): void + { + if ($image->facet === ImageFacet::SMALL_COVER) { + OptimizeImageJob::dispatch($image) + ->onQueue('optimize-image') + ->afterCommit(); + } + } } diff --git a/app/Filament/Actions/Models/Wiki/Image/OptimizeImageAction.php b/app/Filament/Actions/Models/Wiki/Image/OptimizeImageAction.php new file mode 100644 index 000000000..b98c14d0d --- /dev/null +++ b/app/Filament/Actions/Models/Wiki/Image/OptimizeImageAction.php @@ -0,0 +1,43 @@ +label(__('filament.actions.models.wiki.optimize_image.name')); + + $this->icon(Heroicon::ArrowPathRoundedSquare); + + $this->visible(fn (Image $record) => Auth::user()->can('update', $record)); + + $this->action(fn (Image $record) => $this->handle($record)); + } + + public function handle(Image $image): void + { + $action = new OptimizeImage($image); + + $actionResult = $action->handle(); + + if ($actionResult->hasFailed()) { + $this->failedLog($actionResult->getMessage()); + } + } +} diff --git a/app/Filament/Resources/List/External/ExternalEntry.php b/app/Filament/Resources/List/External/ExternalEntry.php index edfee276d..eaa2bf047 100644 --- a/app/Filament/Resources/List/External/ExternalEntry.php +++ b/app/Filament/Resources/List/External/ExternalEntry.php @@ -181,7 +181,8 @@ class ExternalEntry extends BaseResource ->label(__('filament.fields.external_entry.score.name')), SelectConstraint::make(ExternalEntryModel::ATTRIBUTE_WATCH_STATUS) - ->label(__('filament.fields.external_entry.watch_status.name')), + ->label(__('filament.fields.external_entry.watch_status.name')) + ->multiple(), ...parent::getConstraints(), ]), diff --git a/app/Filament/Resources/List/ExternalProfile.php b/app/Filament/Resources/List/ExternalProfile.php index 42d22c04e..5d30cbdd1 100644 --- a/app/Filament/Resources/List/ExternalProfile.php +++ b/app/Filament/Resources/List/ExternalProfile.php @@ -172,10 +172,13 @@ class ExternalProfile extends BaseResource SelectConstraint::make(ExternalProfileModel::ATTRIBUTE_SITE) ->label(__('filament.fields.external_profile.site.name')) - ->options(ExternalProfileSite::class), + ->options(ExternalProfileSite::class) + ->multiple(), SelectConstraint::make(ExternalProfileModel::ATTRIBUTE_VISIBILITY) - ->label(__('filament.fields.external_profile.visibility.name')), + ->label(__('filament.fields.external_profile.visibility.name')) + ->options(ExternalProfileVisibility::class) + ->multiple(), ...parent::getConstraints(), ]), diff --git a/app/Filament/Resources/List/Playlist.php b/app/Filament/Resources/List/Playlist.php index 1231b9f46..70965f968 100644 --- a/app/Filament/Resources/List/Playlist.php +++ b/app/Filament/Resources/List/Playlist.php @@ -220,7 +220,8 @@ class Playlist extends BaseResource SelectConstraint::make(PlaylistModel::ATTRIBUTE_VISIBILITY) ->label(__('filament.fields.playlist.visibility.name')) - ->options(PlaylistVisibility::class), + ->options(PlaylistVisibility::class) + ->multiple(), TextConstraint::make(PlaylistModel::ATTRIBUTE_HASHID) ->label(__('filament.fields.playlist.hashid.name')), diff --git a/app/Filament/Resources/Wiki/Anime.php b/app/Filament/Resources/Wiki/Anime.php index 399ef466b..4a9b29730 100644 --- a/app/Filament/Resources/Wiki/Anime.php +++ b/app/Filament/Resources/Wiki/Anime.php @@ -227,11 +227,13 @@ class Anime extends BaseResource SelectConstraint::make(AnimeModel::ATTRIBUTE_SEASON) ->label(__('filament.fields.anime.season.name')) - ->options(AnimeSeason::class), + ->options(AnimeSeason::class) + ->multiple(), SelectConstraint::make(AnimeModel::ATTRIBUTE_MEDIA_FORMAT) ->label(__('filament.fields.anime.media_format.name')) - ->options(AnimeMediaFormat::class), + ->options(AnimeMediaFormat::class) + ->multiple(), TextConstraint::make(AnimeModel::ATTRIBUTE_SYNOPSIS) ->label(__('filament.fields.anime.synopsis.name')), diff --git a/app/Filament/Resources/Wiki/Anime/Synonym.php b/app/Filament/Resources/Wiki/Anime/Synonym.php index f193f5ca1..2715b7491 100644 --- a/app/Filament/Resources/Wiki/Anime/Synonym.php +++ b/app/Filament/Resources/Wiki/Anime/Synonym.php @@ -163,7 +163,8 @@ class Synonym extends BaseResource SelectConstraint::make(SynonymModel::ATTRIBUTE_TYPE) ->label(__('filament.fields.anime_synonym.type.name')) - ->options(AnimeSynonymType::class), + ->options(AnimeSynonymType::class) + ->multiple(), ...parent::getConstraints(), ]), diff --git a/app/Filament/Resources/Wiki/Anime/Theme.php b/app/Filament/Resources/Wiki/Anime/Theme.php index 1d287d9c4..0e3f2ce7a 100644 --- a/app/Filament/Resources/Wiki/Anime/Theme.php +++ b/app/Filament/Resources/Wiki/Anime/Theme.php @@ -281,7 +281,8 @@ class Theme extends BaseResource ->constraints([ SelectConstraint::make(ThemeModel::ATTRIBUTE_TYPE) ->label(__('filament.fields.anime_theme.type.name')) - ->options(ThemeType::class), + ->options(ThemeType::class) + ->multiple(), NumberConstraint::make(ThemeModel::ATTRIBUTE_SEQUENCE) ->label(__('filament.fields.anime_theme.sequence.name')), diff --git a/app/Filament/Resources/Wiki/ExternalResource.php b/app/Filament/Resources/Wiki/ExternalResource.php index adfab4290..47904f577 100644 --- a/app/Filament/Resources/Wiki/ExternalResource.php +++ b/app/Filament/Resources/Wiki/ExternalResource.php @@ -156,7 +156,8 @@ class ExternalResource extends BaseResource ->constraints([ SelectConstraint::make(ExternalResourceModel::ATTRIBUTE_SITE) ->label(__('filament.fields.external_resource.site.name')) - ->options(ResourceSite::class), + ->options(ResourceSite::class) + ->multiple(), NumberConstraint::make(ExternalResourceModel::ATTRIBUTE_EXTERNAL_ID) ->label(__('filament.fields.external_resource.external_id.name')), diff --git a/app/Filament/Resources/Wiki/Image.php b/app/Filament/Resources/Wiki/Image.php index 470e70ca6..0c3afa721 100644 --- a/app/Filament/Resources/Wiki/Image.php +++ b/app/Filament/Resources/Wiki/Image.php @@ -6,6 +6,7 @@ namespace App\Filament\Resources\Wiki; use App\Enums\Filament\NavigationGroup; use App\Enums\Models\Wiki\ImageFacet; +use App\Filament\Actions\Models\Wiki\Image\OptimizeImageAction; use App\Filament\Actions\Models\Wiki\Image\UploadImageAction; use App\Filament\Components\Columns\TextColumn; use App\Filament\Components\Fields\Select; @@ -142,7 +143,8 @@ class Image extends BaseResource ->constraints([ SelectConstraint::make(ImageModel::ATTRIBUTE_FACET) ->label(__('filament.fields.image.facet.name')) - ->options(ImageFacet::class), + ->options(ImageFacet::class) + ->multiple(), ...parent::getConstraints(), ]), @@ -151,6 +153,16 @@ class Image extends BaseResource ]; } + /** + * @return array + */ + public static function getRecordActions(): array + { + return [ + OptimizeImageAction::make(), + ]; + } + /** * @return array */ diff --git a/app/Filament/Resources/Wiki/Video.php b/app/Filament/Resources/Wiki/Video.php index 5148d6520..9a17550f8 100644 --- a/app/Filament/Resources/Wiki/Video.php +++ b/app/Filament/Resources/Wiki/Video.php @@ -262,11 +262,13 @@ class Video extends BaseResource SelectConstraint::make(VideoModel::ATTRIBUTE_OVERLAP) ->label(__('filament.fields.video.overlap.name')) - ->options(VideoOverlap::class), + ->options(VideoOverlap::class) + ->multiple(), SelectConstraint::make(VideoModel::ATTRIBUTE_SOURCE) ->label(__('filament.fields.video.source.name')) - ->options(VideoSource::class), + ->options(VideoSource::class) + ->multiple(), NumberConstraint::make(VideoModel::ATTRIBUTE_SIZE) ->label(__('filament.fields.video.size.name')), diff --git a/app/Jobs/Wiki/Image/OptimizeImageJob.php b/app/Jobs/Wiki/Image/OptimizeImageJob.php new file mode 100644 index 000000000..a2c71fb18 --- /dev/null +++ b/app/Jobs/Wiki/Image/OptimizeImageJob.php @@ -0,0 +1,28 @@ +image, 'avif'); + + $action->handle(); + } +} diff --git a/database/seeders/Wiki/Image/ConvertImagesSeeder.php b/database/seeders/Wiki/Image/ConvertImagesSeeder.php new file mode 100644 index 000000000..90a89a771 --- /dev/null +++ b/database/seeders/Wiki/Image/ConvertImagesSeeder.php @@ -0,0 +1,35 @@ +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 390364308..adb842944 100644 --- a/lang/en/filament.php +++ b/lang/en/filament.php @@ -289,6 +289,9 @@ return [ 'help' => 'Use 2:3 ratio. Ex: 100x150, 400x600, 720x1080.', 'name' => 'Upload & Attach', ], + 'optimize_image' => [ + 'name' => 'Optimize Image', + ], '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 new file mode 100644 index 000000000..1ddf86e2f --- /dev/null +++ b/tests/Feature/Actions/Models/Wiki/Image/OptimizeImageTest.php @@ -0,0 +1,51 @@ +image(fake()->word().'.jpg'); + $fsFile = $fs->putFile('', $file); + + $image = Image::factory()->createOne([ + Image::ATTRIBUTE_PATH => $fsFile, + ]); + + $action = new OptimizeImageAction($image, 'avif'); + + $result = $action->handle(); + + $this->assertTrue(Str::endsWith(($image->path), '.avif')); + $this->assertTrue($result->getStatus() === ActionStatus::PASSED); + $this->assertDatabaseCount(Image::class, 1); + $this->assertTrue($image->exists()); +}); + +test('passes', 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::PASSED); + $this->assertDatabaseCount(Image::class, 1); + $this->assertTrue($image->exists()); +}); diff --git a/tests/Feature/Http/Api/ThrottleTest.php b/tests/Feature/Http/Api/ThrottleTest.php index 14632bd36..24a9ef2d2 100644 --- a/tests/Feature/Http/Api/ThrottleTest.php +++ b/tests/Feature/Http/Api/ThrottleTest.php @@ -44,4 +44,4 @@ test('user without bypass rate limited', function () { $response->assertHeader('X-RateLimit-Limit'); $response->assertHeader('X-RateLimit-Remaining'); -})->only(); +});