From 4ca0ddd533ab21f8274d2079e57ce4479dfeebdd Mon Sep 17 00:00:00 2001 From: Cam Kemshal-Bell Date: Mon, 16 Sep 2024 23:28:09 +1000 Subject: [PATCH 1/9] feat(image-serivce): create the image service class --- app/Services/ImageOptimizer.php | 107 ++++++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 app/Services/ImageOptimizer.php diff --git a/app/Services/ImageOptimizer.php b/app/Services/ImageOptimizer.php new file mode 100644 index 000000000..19b45fba3 --- /dev/null +++ b/app/Services/ImageOptimizer.php @@ -0,0 +1,107 @@ +image = Storage::disk('public')->path($this->path); + $this->imagick = $this->imagick ?? 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 = $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); + } + + if ($this->imagick === null) { + return; + } + + $this->imagick->autoOrient(); + + $this->imagick->resizeImage( + $this->width, + $this->height, + Imagick::FILTER_LANCZOS, + 1, + true + ); + + $this->imagick->stripImage(); + + $this->imagick->setImageCompressionQuality($this->quality); + $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 + { + if ($this->imagick === null) { + return; + } + $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); + } +} From 6dfaa75e9112c88a241963663e743919a2ebed30 Mon Sep 17 00:00:00 2001 From: Cam Kemshal-Bell Date: Mon, 16 Sep 2024 23:29:03 +1000 Subject: [PATCH 2/9] feat(image-serivce): create the image service class --- app/Livewire/Questions/Create.php | 28 +++++++--------------------- 1 file changed, 7 insertions(+), 21 deletions(-) diff --git a/app/Livewire/Questions/Create.php b/app/Livewire/Questions/Create.php index d454c05b9..dd673daf5 100644 --- a/app/Livewire/Questions/Create.php +++ b/app/Livewire/Questions/Create.php @@ -9,13 +9,13 @@ use App\Rules\MaxUploads; use App\Rules\NoBlankCharacters; use Closure; +use App\Services\ImageOptimizer; use Illuminate\Http\Request; use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\Storage; 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,25 +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); - - $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. */ From 5a7ed21e3f6065c527b1c8c1a3cda23e3093d711 Mon Sep 17 00:00:00 2001 From: Cam Kemshal-Bell Date: Mon, 16 Sep 2024 23:30:29 +1000 Subject: [PATCH 3/9] feat(image-serivce): create the image service class --- app/Jobs/UpdateUserAvatar.php | 32 +++++++++++++------------------- 1 file changed, 13 insertions(+), 19 deletions(-) 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(), - ); - } } From 1f60322145253ba9ba1ffbb7af1d73ac1155c7d7 Mon Sep 17 00:00:00 2001 From: Cam Kemshal-Bell Date: Mon, 16 Sep 2024 23:30:51 +1000 Subject: [PATCH 4/9] test: add test coverage for the image optimizer service --- tests/Services/ImageOptimizerTest.php | 140 ++++++++++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 tests/Services/ImageOptimizerTest.php diff --git a/tests/Services/ImageOptimizerTest.php b/tests/Services/ImageOptimizerTest.php new file mode 100644 index 000000000..0a4ab9cb8 --- /dev/null +++ b/tests/Services/ImageOptimizerTest.php @@ -0,0 +1,140 @@ +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('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, + imagick: $imagickMock + ); + + $imagickMock->shouldHaveReceived('resizeImage'); + $imagickMock->shouldHaveReceived('autoOrient'); + $imagickMock->shouldHaveReceived('stripImage'); + $imagickMock->shouldHaveReceived('setImageCompressionQuality'); + $imagickMock->shouldHaveReceived('writeImage'); + $imagickMock->shouldHaveReceived('clear'); + $imagickMock->shouldHaveReceived('destroy'); +}); + +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('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, + imagick: $imagickMock + ); + + $imagickMock->shouldHaveReceived('getImageWidth'); + $imagickMock->shouldHaveReceived('getImageHeight'); + $imagickMock->shouldHaveReceived('cropImage'); + $imagickMock->shouldHaveReceived('setImagePage'); + $imagickMock->shouldHaveReceived('resizeImage'); + $imagickMock->shouldHaveReceived('autoOrient'); + $imagickMock->shouldHaveReceived('stripImage'); + $imagickMock->shouldHaveReceived('setImageCompressionQuality'); + $imagickMock->shouldHaveReceived('writeImage'); + $imagickMock->shouldHaveReceived('clear'); + $imagickMock->shouldHaveReceived('destroy'); +}); From fecb7ca36051384dd0db09459d5720b82b189ddd Mon Sep 17 00:00:00 2001 From: Cam Kemshal-Bell Date: Mon, 16 Sep 2024 23:31:05 +1000 Subject: [PATCH 5/9] chore: remove intervention/image dep --- composer.json | 1 - 1 file changed, 1 deletion(-) diff --git a/composer.json b/composer.json index d6575b906..5aa985f7d 100644 --- a/composer.json +++ b/composer.json @@ -4,7 +4,6 @@ "require": { "php": "^8.3", "filament/filament": "^3.2.111", - "intervention/image": "^3.8.0", "laravel/fortify": "^1.21.1", "laravel/framework": "^11.23.2", "laravel/pennant": "^1.11.0", From 25ecf85d2190c187110edc681e8492e9436ba1b9 Mon Sep 17 00:00:00 2001 From: Cam Kemshal-Bell Date: Mon, 16 Sep 2024 23:34:33 +1000 Subject: [PATCH 6/9] test: remove obsolete test --- app/Livewire/Questions/Create.php | 2 +- tests/Unit/Livewire/Questions/CreateTest.php | 29 -------------------- 2 files changed, 1 insertion(+), 30 deletions(-) diff --git a/app/Livewire/Questions/Create.php b/app/Livewire/Questions/Create.php index dd673daf5..8b7b35c01 100644 --- a/app/Livewire/Questions/Create.php +++ b/app/Livewire/Questions/Create.php @@ -8,8 +8,8 @@ use App\Models\User; use App\Rules\MaxUploads; use App\Rules\NoBlankCharacters; -use Closure; use App\Services\ImageOptimizer; +use Closure; use Illuminate\Http\Request; use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\Storage; diff --git a/tests/Unit/Livewire/Questions/CreateTest.php b/tests/Unit/Livewire/Questions/CreateTest.php index 8e55bf8bf..4a681b4ee 100644 --- a/tests/Unit/Livewire/Questions/CreateTest.php +++ b/tests/Unit/Livewire/Questions/CreateTest.php @@ -493,35 +493,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(); From d5a33503a8d9add9089a31dfa1bd535bc194cb34 Mon Sep 17 00:00:00 2001 From: Cam Kemshal-Bell Date: Mon, 16 Sep 2024 23:45:47 +1000 Subject: [PATCH 7/9] chore: pint & rector --- app/Services/ImageOptimizer.php | 30 +++++++++----------- tests/Services/ImageOptimizerTest.php | 4 +-- tests/Unit/Livewire/Questions/CreateTest.php | 1 - 3 files changed, 16 insertions(+), 19 deletions(-) diff --git a/app/Services/ImageOptimizer.php b/app/Services/ImageOptimizer.php index 19b45fba3..673df80ee 100644 --- a/app/Services/ImageOptimizer.php +++ b/app/Services/ImageOptimizer.php @@ -7,26 +7,31 @@ use Illuminate\Support\Facades\Storage; use Imagick; -final class ImageOptimizer +final readonly class ImageOptimizer { /** * The image path. */ private string $image; + /** + * The Imagick instance. + */ + private Imagick $imagick; + /** * Create a new ImageOptimizer instance. */ public function __construct( - private readonly string $path, - private readonly int $width, - private readonly int $height, - private readonly int $quality, - private readonly bool $isThumbnail, - private ?Imagick $imagick = null, + private string $path, + private int $width, + private int $height, + private int $quality, + private bool $isThumbnail, + private ?Imagick $instance = null, ) { $this->image = Storage::disk('public')->path($this->path); - $this->imagick = $this->imagick ?? new Imagick($this->image); + $this->imagick = $this->instance ?? new Imagick($this->image); $this->optimizeImage(); } @@ -40,7 +45,7 @@ public static function optimize( ?int $quality = null, bool $isThumbnail = false, ): void { - $quality = $quality ?? ($isThumbnail ? 100 : 80); + $quality ??= $isThumbnail ? 100 : 80; new self($path, $width, $height, $quality, $isThumbnail); } @@ -53,10 +58,6 @@ private function optimizeImage(): void $this->coverDown($this->width, $this->height); } - if ($this->imagick === null) { - return; - } - $this->imagick->autoOrient(); $this->imagick->resizeImage( @@ -81,9 +82,6 @@ private function optimizeImage(): void */ private function coverDown(int $width, int $height): void { - if ($this->imagick === null) { - return; - } $originalWidth = $this->imagick->getImageWidth(); $originalHeight = $this->imagick->getImageHeight(); diff --git a/tests/Services/ImageOptimizerTest.php b/tests/Services/ImageOptimizerTest.php index 0a4ab9cb8..04b4f785d 100644 --- a/tests/Services/ImageOptimizerTest.php +++ b/tests/Services/ImageOptimizerTest.php @@ -91,7 +91,7 @@ height: 200, quality: 80, isThumbnail: false, - imagick: $imagickMock + instance: $imagickMock ); $imagickMock->shouldHaveReceived('resizeImage'); @@ -123,7 +123,7 @@ height: 150, quality: 80, isThumbnail: true, - imagick: $imagickMock + instance: $imagickMock ); $imagickMock->shouldHaveReceived('getImageWidth'); diff --git a/tests/Unit/Livewire/Questions/CreateTest.php b/tests/Unit/Livewire/Questions/CreateTest.php index 4a681b4ee..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; From 58b26f1a69db440e078c8cbf475bcaff8e64daad Mon Sep 17 00:00:00 2001 From: Cam Kemshal-Bell Date: Tue, 17 Sep 2024 00:00:47 +1000 Subject: [PATCH 8/9] test: add mutation testing --- tests/Services/ImageOptimizerTest.php | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/Services/ImageOptimizerTest.php b/tests/Services/ImageOptimizerTest.php index 04b4f785d..0b45caa6a 100644 --- a/tests/Services/ImageOptimizerTest.php +++ b/tests/Services/ImageOptimizerTest.php @@ -11,6 +11,8 @@ use Imagick; use Mockery; +covers(ImageOptimizer::class); + beforeEach(function () { Storage::fake('public'); $this->image = UploadedFile::fake()->image( @@ -103,6 +105,22 @@ $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); From 1fb10778997abafa4f47e2b086df58742396395b Mon Sep 17 00:00:00 2001 From: Cam Kemshal-Bell Date: Fri, 20 Sep 2024 13:12:06 +1000 Subject: [PATCH 9/9] fix: add in changes from the PR that supports animated images --- app/Services/ImageOptimizer.php | 37 +++++++++++++++++++-------- tests/Services/ImageOptimizerTest.php | 5 +++- 2 files changed, 31 insertions(+), 11 deletions(-) diff --git a/app/Services/ImageOptimizer.php b/app/Services/ImageOptimizer.php index 673df80ee..58eb07935 100644 --- a/app/Services/ImageOptimizer.php +++ b/app/Services/ImageOptimizer.php @@ -60,18 +60,19 @@ private function optimizeImage(): void $this->imagick->autoOrient(); - $this->imagick->resizeImage( - $this->width, - $this->height, - Imagick::FILTER_LANCZOS, - 1, - true - ); + if ($this->imagick->getNumberImages() > 1) { + $frames = $this->imagick->coalesceImages(); - $this->imagick->stripImage(); + foreach ($frames as $frame) { + $this->resizeStripAndCompressImage($frame); + } - $this->imagick->setImageCompressionQuality($this->quality); - $this->imagick->writeImage($this->image); + $imagick = $frames->deconstructImages(); + $imagick->writeImages($this->image, true); + } else { + $this->resizeStripAndCompressImage($this->imagick); + $this->imagick->writeImage($this->image); + } $this->imagick->clear(); $this->imagick->destroy(); @@ -102,4 +103,20 @@ private function coverDown(int $width, int $height): void $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/tests/Services/ImageOptimizerTest.php b/tests/Services/ImageOptimizerTest.php index 0b45caa6a..6e08a7c06 100644 --- a/tests/Services/ImageOptimizerTest.php +++ b/tests/Services/ImageOptimizerTest.php @@ -81,6 +81,7 @@ $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(); @@ -98,6 +99,7 @@ $imagickMock->shouldHaveReceived('resizeImage'); $imagickMock->shouldHaveReceived('autoOrient'); + $imagickMock->shouldHaveReceived('getNumberImages'); $imagickMock->shouldHaveReceived('stripImage'); $imagickMock->shouldHaveReceived('setImageCompressionQuality'); $imagickMock->shouldHaveReceived('writeImage'); @@ -120,7 +122,6 @@ ->and($imagick->getImageHeight())->toBe(100); }); - test('it optimizes a thumbnail image', function () { $imagickMock = Mockery::mock(Imagick::class); $imagickMock->shouldReceive('getImageWidth')->andReturn(300); @@ -128,6 +129,7 @@ $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(); @@ -148,6 +150,7 @@ $imagickMock->shouldHaveReceived('getImageHeight'); $imagickMock->shouldHaveReceived('cropImage'); $imagickMock->shouldHaveReceived('setImagePage'); + $imagickMock->shouldHaveReceived('getNumberImages'); $imagickMock->shouldHaveReceived('resizeImage'); $imagickMock->shouldHaveReceived('autoOrient'); $imagickMock->shouldHaveReceived('stripImage');