diff --git a/app/Jobs/UpdateUserAvatar.php b/app/Jobs/UpdateUserAvatar.php index 308a62c6b..aa13a7975 100644 --- a/app/Jobs/UpdateUserAvatar.php +++ b/app/Jobs/UpdateUserAvatar.php @@ -6,12 +6,11 @@ use App\Models\User; use App\Services\Avatar; +use App\Services\ImageOptimizer; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Queue\Queueable; use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Storage; -use Intervention\Image\Drivers; -use Intervention\Image\ImageManager; use Throwable; final class UpdateUserAvatar implements ShouldQueue @@ -60,16 +59,21 @@ public function handle(): void Storage::disk('public')->put($avatar, $contents, 'public'); - $this->resizer()->read($disk->path($avatar)) - ->coverDown(200, 200) - ->save(); - - $this->user->update([ - 'avatar' => "$avatar", + $updated = $this->user->update([ + 'avatar' => $avatar, 'avatar_updated_at' => now(), 'is_uploaded_avatar' => $this->file !== null, ]); + if ($updated) { + ImageOptimizer::optimize( + path: $avatar, + width: 300, + height: 300, + isThumbnail: true + ); + } + $this->ensureFileIsDeleted(); } @@ -92,18 +96,8 @@ public function failed(?Throwable $exception): void */ private function ensureFileIsDeleted(): void { - if ($this->file !== null) { + if (($this->file !== null) && File::exists($this->file)) { File::delete($this->file); } } - - /** - * Creates a new image resizer. - */ - private function resizer(): ImageManager - { - return new ImageManager( - new Drivers\Gd\Driver(), - ); - } } diff --git a/app/Livewire/Questions/Create.php b/app/Livewire/Questions/Create.php index 9c527e3ee..8b7b35c01 100644 --- a/app/Livewire/Questions/Create.php +++ b/app/Livewire/Questions/Create.php @@ -8,6 +8,7 @@ use App\Models\User; use App\Rules\MaxUploads; use App\Rules\NoBlankCharacters; +use App\Services\ImageOptimizer; use Closure; use Illuminate\Http\Request; use Illuminate\Http\UploadedFile; @@ -15,7 +16,6 @@ use Illuminate\Validation\Rule; use Illuminate\Validation\Rules\File; use Illuminate\View\View; -use Imagick; use Livewire\Attributes\Computed; use Livewire\Attributes\Locked; use Livewire\Attributes\On; @@ -267,9 +267,14 @@ public function uploadImages(): void /** @var string $path */ $path = $image->store("images/{$today}", 'public'); - $this->optimizeImage($path); if ($path) { + ImageOptimizer::optimize( + path: $path, + width: 1000, + height: 1000 + ); + session()->push('images', $path); $this->dispatch( @@ -286,36 +291,6 @@ public function uploadImages(): void $this->reset('images'); } - /** - * Optimize the images. - */ - public function optimizeImage(string $path): void - { - $imagePath = Storage::disk('public')->path($path); - $imagick = new Imagick($imagePath); - - if ($imagick->getNumberImages() > 1) { - $imagick = $imagick->coalesceImages(); - - foreach ($imagick as $frame) { - $frame->resizeImage(1000, 1000, Imagick::FILTER_LANCZOS, 1, true); - $frame->stripImage(); - $frame->setImageCompressionQuality(80); - } - - $imagick = $imagick->deconstructImages(); - $imagick->writeImages($imagePath, true); - } else { - $imagick->resizeImage(1000, 1000, Imagick::FILTER_LANCZOS, 1, true); - $imagick->stripImage(); - $imagick->setImageCompressionQuality(80); - $imagick->writeImage($imagePath); - } - - $imagick->clear(); - $imagick->destroy(); - } - /** * Handle the image deletes. */ diff --git a/app/Services/ImageOptimizer.php b/app/Services/ImageOptimizer.php new file mode 100644 index 000000000..58eb07935 --- /dev/null +++ b/app/Services/ImageOptimizer.php @@ -0,0 +1,122 @@ +image = Storage::disk('public')->path($this->path); + $this->imagick = $this->instance ?? new Imagick($this->image); + $this->optimizeImage(); + } + + /** + * Static factory method to optimize an image. + */ + public static function optimize( + string $path, + int $width, + int $height, + ?int $quality = null, + bool $isThumbnail = false, + ): void { + $quality ??= $isThumbnail ? 100 : 80; + new self($path, $width, $height, $quality, $isThumbnail); + } + + /** + * Run the optimization process. + */ + private function optimizeImage(): void + { + if ($this->isThumbnail) { + $this->coverDown($this->width, $this->height); + } + + $this->imagick->autoOrient(); + + if ($this->imagick->getNumberImages() > 1) { + $frames = $this->imagick->coalesceImages(); + + foreach ($frames as $frame) { + $this->resizeStripAndCompressImage($frame); + } + + $imagick = $frames->deconstructImages(); + $imagick->writeImages($this->image, true); + } else { + $this->resizeStripAndCompressImage($this->imagick); + $this->imagick->writeImage($this->image); + } + + $this->imagick->clear(); + $this->imagick->destroy(); + } + + /** + * Crop the image from the centre, while maintaining the desired aspect ratio. + */ + private function coverDown(int $width, int $height): void + { + $originalWidth = $this->imagick->getImageWidth(); + $originalHeight = $this->imagick->getImageHeight(); + + $targetAspect = $width / $height; + $originalAspect = $originalWidth / $originalHeight; + + if ($originalAspect > $targetAspect) { + $newHeight = $originalHeight; + $newWidth = (int) round($originalHeight * $targetAspect); + } else { + $newWidth = $originalWidth; + $newHeight = (int) round($originalWidth / $targetAspect); + } + + $x = (int) round(($originalWidth - $newWidth) / 2); + $y = (int) round(($originalHeight - $newHeight) / 2); + + $this->imagick->cropImage($newWidth, $newHeight, $x, $y); + $this->imagick->setImagePage($newWidth, $newHeight, 0, 0); + } + + /** + * Resize, strip and compress the image. + */ + private function resizeStripAndCompressImage(Imagick $instance): void + { + $instance->resizeImage( + $this->width, + $this->height, + Imagick::FILTER_LANCZOS, + 1, + true + ); + $instance->stripImage(); + $instance->setImageCompressionQuality($this->quality); + } +} diff --git a/composer.json b/composer.json index 518413715..eba5800b0 100644 --- a/composer.json +++ b/composer.json @@ -4,7 +4,6 @@ "require": { "php": "^8.3", "filament/filament": "^3.2.113", - "intervention/image": "^3.8.0", "laravel/fortify": "^1.21.1", "laravel/framework": "^11.23.5", "laravel/pennant": "^1.11.0", diff --git a/tests/Services/ImageOptimizerTest.php b/tests/Services/ImageOptimizerTest.php new file mode 100644 index 000000000..6e08a7c06 --- /dev/null +++ b/tests/Services/ImageOptimizerTest.php @@ -0,0 +1,161 @@ +image = UploadedFile::fake()->image( + name: 'test.jpg', + width: 1000, + height: 1000, + )->size(6 * 1024); + $this->path = $this->image->store('images', 'public'); + $this->file = Storage::disk('public')->path($this->path); +}); + +test('optimize image', function () { + $sizeBefore = $this->image->getSize(); + + ImageOptimizer::optimize( + path: $this->path, + width: 500, + height: 500, + quality: 80, + ); + + $imagick = new Imagick($this->file); + + expect(file_exists(Storage::disk('public')->path($this->path)))->toBeTrue() + ->and($imagick->getImageWidth())->toBe(500) + ->and($imagick->getImageHeight())->toBe(500) + ->and(File::size($this->file))->toBeLessThan($sizeBefore); +}); + +test('optimize thumbnail', function () { + $sizeBefore = $this->image->getSize(); + + ImageOptimizer::optimize( + path: $this->path, + width: 100, + height: 100, + quality: 80, + isThumbnail: true + ); + + $imagick = new Imagick($this->file); + + expect(File::exists($this->file))->toBeTrue() + ->and($imagick->getImageWidth())->toBe(100) + ->and($imagick->getImageHeight())->toBe(100) + ->and(File::size($this->file))->toBeLessThan($sizeBefore); +}); + +test('ensure orientation is maintained', function () { + $orientationBefore = (new Imagick($this->file))->getImageOrientation(); + + ImageOptimizer::optimize( + path: $this->path, + width: 100, + height: 100, + quality: 80, + isThumbnail: true + ); + + $orientationAfter = (new Imagick($this->file))->getImageOrientation(); + + expect($orientationAfter)->toBe($orientationBefore); +}); + +test('it optimizes an image', function () { + $imagickMock = Mockery::mock(Imagick::class); + $imagickMock->shouldReceive('resizeImage')->once(); + $imagickMock->shouldReceive('autoOrient')->once(); + $imagickMock->shouldReceive('getNumberImages')->andReturn(1); + $imagickMock->shouldReceive('stripImage')->once(); + $imagickMock->shouldReceive('setImageCompressionQuality')->once(); + $imagickMock->shouldReceive('writeImage')->once(); + $imagickMock->shouldReceive('clear')->once(); + $imagickMock->shouldReceive('destroy')->once(); + + new ImageOptimizer( + path: $this->path, + width: 200, + height: 200, + quality: 80, + isThumbnail: false, + instance: $imagickMock + ); + + $imagickMock->shouldHaveReceived('resizeImage'); + $imagickMock->shouldHaveReceived('autoOrient'); + $imagickMock->shouldHaveReceived('getNumberImages'); + $imagickMock->shouldHaveReceived('stripImage'); + $imagickMock->shouldHaveReceived('setImageCompressionQuality'); + $imagickMock->shouldHaveReceived('writeImage'); + $imagickMock->shouldHaveReceived('clear'); + $imagickMock->shouldHaveReceived('destroy'); +}); + +test('where original aspect is greater than the thumbnail aspect', function () { + new ImageOptimizer( + path: $this->path, + width: 50, + height: 100, + quality: 80, + isThumbnail: true + ); + + $imagick = new Imagick($this->file); + + expect($imagick->getImageWidth())->toBe(50) + ->and($imagick->getImageHeight())->toBe(100); +}); + +test('it optimizes a thumbnail image', function () { + $imagickMock = Mockery::mock(Imagick::class); + $imagickMock->shouldReceive('getImageWidth')->andReturn(300); + $imagickMock->shouldReceive('getImageHeight')->andReturn(300); + $imagickMock->shouldReceive('cropImage')->once(); + $imagickMock->shouldReceive('setImagePage')->once(); + $imagickMock->shouldReceive('autoOrient')->once(); + $imagickMock->shouldReceive('getNumberImages')->andReturn(1); + $imagickMock->shouldReceive('resizeImage')->once(); + $imagickMock->shouldReceive('stripImage')->once(); + $imagickMock->shouldReceive('setImageCompressionQuality')->once(); + $imagickMock->shouldReceive('writeImage')->once(); + $imagickMock->shouldReceive('clear')->once(); + $imagickMock->shouldReceive('destroy')->once(); + + new ImageOptimizer( + path: $this->path, + width: 150, + height: 150, + quality: 80, + isThumbnail: true, + instance: $imagickMock + ); + + $imagickMock->shouldHaveReceived('getImageWidth'); + $imagickMock->shouldHaveReceived('getImageHeight'); + $imagickMock->shouldHaveReceived('cropImage'); + $imagickMock->shouldHaveReceived('setImagePage'); + $imagickMock->shouldHaveReceived('getNumberImages'); + $imagickMock->shouldHaveReceived('resizeImage'); + $imagickMock->shouldHaveReceived('autoOrient'); + $imagickMock->shouldHaveReceived('stripImage'); + $imagickMock->shouldHaveReceived('setImageCompressionQuality'); + $imagickMock->shouldHaveReceived('writeImage'); + $imagickMock->shouldHaveReceived('clear'); + $imagickMock->shouldHaveReceived('destroy'); +}); diff --git a/tests/Unit/Livewire/Questions/CreateTest.php b/tests/Unit/Livewire/Questions/CreateTest.php index 8e55bf8bf..9dca71006 100644 --- a/tests/Unit/Livewire/Questions/CreateTest.php +++ b/tests/Unit/Livewire/Questions/CreateTest.php @@ -5,7 +5,6 @@ use App\Livewire\Questions\Create; use App\Models\User; use Illuminate\Http\UploadedFile; -use Intervention\Image\ImageManager; use Livewire\Features\SupportTesting\Testable; use Livewire\Livewire; @@ -493,35 +492,6 @@ Storage::disk('public')->assertMissing($pathAgain); }); -test('optimizeImage method resizes and saves the image', function () { - Storage::fake('public'); - - $user = User::factory()->create(); - $testImage = UploadedFile::fake()->image('test.jpg', 1200, 1200); // Larger than 1000x1000 - $path = $testImage->store('images', 'public'); - - $component = Livewire::actingAs($user)->test(Create::class, [ - 'toId' => $user->id, - ]); - - $component->call('optimizeImage', $path); - - Storage::disk('public')->assertExists($path); - - $optimizedImagePath = Storage::disk('public')->path($path); - - $originalImageSize = filesize($testImage->getPathname()); - $optimizedImageSize = filesize($optimizedImagePath); - - expect($optimizedImageSize)->toBeLessThan($originalImageSize); - - $manager = ImageManager::imagick(); - $image = $manager->read($optimizedImagePath); - - expect($image->width())->toBeLessThanOrEqual(1000) - ->and($image->height())->toBeLessThanOrEqual(1000); -}); - test('maxFileSize and maxImages', function () { $user = User::factory()->create();