diff --git a/composer.json b/composer.json index 881c9b4a0..6ff2145d1 100644 --- a/composer.json +++ b/composer.json @@ -27,7 +27,7 @@ "laravel/prompts": "0.*", "league/commonmark": "^2.4", "masterminds/html5": "^2.8", - "spatie/image-optimizer": "^1.6", + "spatie/laravel-image-optimizer": "^1.8", "symfony/html-sanitizer": "^7.3", "tightenco/ziggy": "^2.0" }, diff --git a/demo/composer.json b/demo/composer.json index f60888a46..9f4df6f65 100644 --- a/demo/composer.json +++ b/demo/composer.json @@ -15,7 +15,7 @@ "laravel/tinker": "^3.0", "masterminds/html5": "^2.9", "pragmarx/google2fa": "^8.0", - "spatie/image-optimizer": "^1.7", + "spatie/laravel-image-optimizer": "^1.8", "spatie/laravel-passkeys": "^1.6", "spatie/laravel-translatable": "^6.13", "symfony/html-sanitizer": "^7.3", diff --git a/demo/composer.lock b/demo/composer.lock index 5994d0950..884e28db3 100644 --- a/demo/composer.lock +++ b/demo/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "2221840f079c1be1388507f2013756dc", + "content-hash": "b7d5c71c9fed55cf367da4f828439458", "packages": [ { "name": "bacon/bacon-qr-code", @@ -4405,6 +4405,74 @@ }, "time": "2025-11-26T10:57:19+00:00" }, + { + "name": "spatie/laravel-image-optimizer", + "version": "1.8.3", + "source": { + "type": "git", + "url": "https://github.com/spatie/laravel-image-optimizer.git", + "reference": "abc476add8b41d10185a07377ce7f64657b3ed91" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/laravel-image-optimizer/zipball/abc476add8b41d10185a07377ce7f64657b3ed91", + "reference": "abc476add8b41d10185a07377ce7f64657b3ed91", + "shasum": "" + }, + "require": { + "laravel/framework": "^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "php": "^8.0", + "spatie/image-optimizer": "^1.2.0" + }, + "require-dev": { + "orchestra/testbench": "^6.23|^7.0|^8.0|^9.0|^10.0|^11.0", + "phpunit/phpunit": "^9.4|^10.5|^11.5.3|^12.5.12" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "ImageOptimizer": "Spatie\\LaravelImageOptimizer\\Facades\\ImageOptimizer" + }, + "providers": [ + "Spatie\\LaravelImageOptimizer\\ImageOptimizerServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Spatie\\LaravelImageOptimizer\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Freek Van der Herten", + "email": "freek@spatie.be", + "homepage": "https://spatie.be", + "role": "Developer" + } + ], + "description": "Optimize images in your Laravel app", + "homepage": "https://github.com/spatie/laravel-image-optimizer", + "keywords": [ + "laravel-image-optimizer", + "spatie" + ], + "support": { + "source": "https://github.com/spatie/laravel-image-optimizer/tree/1.8.3" + }, + "funding": [ + { + "url": "https://spatie.be/open-source/support-us", + "type": "custom" + } + ], + "time": "2026-02-21T21:35:45+00:00" + }, { "name": "spatie/laravel-package-tools", "version": "1.93.0", @@ -6269,16 +6337,16 @@ }, { "name": "symfony/polyfill-php80", - "version": "v1.33.0", + "version": "v1.37.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php80.git", - "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608" + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/0cc9dd0f17f61d8131e7df6b84bd344899fe2608", - "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411", "shasum": "" }, "require": { @@ -6329,7 +6397,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php80/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-php80/tree/v1.37.0" }, "funding": [ { @@ -6349,7 +6417,7 @@ "type": "tidelift" } ], - "time": "2025-01-02T08:10:11+00:00" + "time": "2026-04-10T16:19:22+00:00" }, { "name": "symfony/polyfill-php84", @@ -6596,16 +6664,16 @@ }, { "name": "symfony/process", - "version": "v8.0.5", + "version": "v8.0.11", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "b5f3aa6762e33fd95efbaa2ec4f4bc9fdd16d674" + "reference": "26d89e459f037d2873300605d0a07e7a8ef84db0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/b5f3aa6762e33fd95efbaa2ec4f4bc9fdd16d674", - "reference": "b5f3aa6762e33fd95efbaa2ec4f4bc9fdd16d674", + "url": "https://api.github.com/repos/symfony/process/zipball/26d89e459f037d2873300605d0a07e7a8ef84db0", + "reference": "26d89e459f037d2873300605d0a07e7a8ef84db0", "shasum": "" }, "require": { @@ -6637,7 +6705,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v8.0.5" + "source": "https://github.com/symfony/process/tree/v8.0.11" }, "funding": [ { @@ -6657,7 +6725,7 @@ "type": "tidelift" } ], - "time": "2026-01-26T15:08:38+00:00" + "time": "2026-05-11T16:56:32+00:00" }, { "name": "symfony/property-access", diff --git a/demo/config/image-optimizer.php b/demo/config/image-optimizer.php new file mode 100644 index 000000000..35196d607 --- /dev/null +++ b/demo/config/image-optimizer.php @@ -0,0 +1,66 @@ + [ + + Jpegoptim::class => [ + '-m85', // set maximum quality to 85% + '--strip-all', // this strips out all text information such as comments and EXIF data + '--all-progressive', // this will make sure the resulting image is a progressive one + ], + + Pngquant::class => [ + '--force', // required parameter for this package + ], + + Optipng::class => [ + '-i0', // this will result in a non-interlaced, progressive scanned image + '-o2', // this set the optimization level to two (multiple IDAT compression trials) + '-quiet', // required parameter for this package + ], + + Svgo::class => [ + '--disable=cleanupIDs', // disabling because it is know to cause troubles + ], + + Gifsicle::class => [ + '-b', // required parameter for this package + '-O3', // this produces the slowest but best results + ], + + Cwebp::class => [ + '-m 6', // for the slowest compression method in order to get the best compression. + '-pass 10', // for maximizing the amount of analysis pass. + '-mt', // multithreading for some speed improvements. + '-q 90', // quality factor that brings the least noticeable changes. + ], + ], + + /* + * The directory where your binaries are stored. + * Only use this when you binaries are not accessible in the global environment. + */ + 'binary_path' => env('IMAGE_OPTIMIZER_PATH', ''), + + /* + * The maximum time in seconds each optimizer is allowed to run separately. + */ + 'timeout' => 60, + + /* + * If set to `true` all output of the optimizer binaries will be appended to the default log. + * You can also set this to a class that implements `Psr\Log\LoggerInterface`. + */ + 'log_optimizer_activity' => true, +]; diff --git a/src/Http/Jobs/HandleUploadedFileJob.php b/src/Http/Jobs/HandleUploadedFileJob.php index 7710d6c9c..73809c2d0 100644 --- a/src/Http/Jobs/HandleUploadedFileJob.php +++ b/src/Http/Jobs/HandleUploadedFileJob.php @@ -8,7 +8,6 @@ use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Support\Facades\Storage; -use Spatie\ImageOptimizer\OptimizerChainFactory; class HandleUploadedFileJob implements ShouldQueue { @@ -36,11 +35,10 @@ public function handle(): void ); if ($this->shouldOptimizeImage) { - // We do not need to check for exception nor file format because - // the package will not throw any errors and just operate silently. - app(OptimizerChainFactory::class) - ->create() - ->optimize(Storage::disk($tmpDisk)->path($tmpFilePath)); + OptimizeImageJob::dispatchSync( + disk: $tmpDisk, + filePath: $tmpFilePath, + ); } if ($this->transformFilters) { @@ -48,14 +46,14 @@ public function handle(): void HandleTransformedFileJob::dispatchSync( disk: $tmpDisk, filePath: $tmpFilePath, - transformFilters: $this->transformFilters + transformFilters: $this->transformFilters, ); } if ($this->shouldSanitizeSvg && Storage::disk($tmpDisk)->mimeType($tmpFilePath) === 'image/svg+xml') { SanitizeSvgJob::dispatchSync( disk: $tmpDisk, - filePath: $tmpFilePath + filePath: $tmpFilePath, ); } diff --git a/src/Http/Jobs/OptimizeImageJob.php b/src/Http/Jobs/OptimizeImageJob.php new file mode 100644 index 000000000..78449040f --- /dev/null +++ b/src/Http/Jobs/OptimizeImageJob.php @@ -0,0 +1,56 @@ +getOptimizers())->whereInstanceOf(Jpegoptim::class)->first()) { + $jpegOptim->options[] = '--keep-exif'; + } + + if ($pngquant = collect($chain->getOptimizers())->whereInstanceOf(Pngquant::class)->first()) { + if (! collect($pngquant->options)->some(fn ($option) => str_starts_with($option, '--quality'))) { + $pngquant->options[] = '--quality=85'; + } + } + + if (! collect($chain->getOptimizers())->whereInstanceOf(Avifenc::class)->first()) { + $chain->addOptimizer(new Avifenc([ + '-a cq-level=23', + '-j all', + '--min 0', + '--max 63', + '--minalpha 0', + '--maxalpha 63', + '-a end-usage=q', + '-a tune=ssim', + ])); + } + + $chain->optimize(Storage::disk($this->disk)->path($this->filePath)); + } +} diff --git a/tests/Http/Jobs/HandleUploadedFileJobTest.php b/tests/Http/Jobs/HandleUploadedFileJobTest.php index f648ac1a9..5cc222440 100644 --- a/tests/Http/Jobs/HandleUploadedFileJobTest.php +++ b/tests/Http/Jobs/HandleUploadedFileJobTest.php @@ -2,9 +2,10 @@ use Code16\Sharp\Exceptions\Form\SharpFormUpdateException; use Code16\Sharp\Http\Jobs\HandleUploadedFileJob; +use Code16\Sharp\Http\Jobs\OptimizeImageJob; use Illuminate\Http\UploadedFile; +use Illuminate\Support\Facades\Bus; use Illuminate\Support\Facades\Storage; -use Spatie\ImageOptimizer\OptimizerChainFactory; beforeEach(function () { Storage::fake('local'); @@ -56,32 +57,7 @@ ->throws(SharpFormUpdateException::class); it('optimizes uploaded images if configured', function () { - $optimizer = new class() - { - public bool $wasOptimized = false; - - public function optimize(): bool - { - $this->wasOptimized = true; - - return true; - } - }; - - app()->bind(OptimizerChainFactory::class, fn () => new class($optimizer) - { - private $optimizer; - - public function __construct(&$optimizer) - { - $this->optimizer = $optimizer; - } - - public function create() - { - return $this->optimizer; - } - }); + Bus::fake([OptimizeImageJob::class]); UploadedFile::fake() ->image('image.jpg') @@ -94,8 +70,9 @@ public function create() shouldOptimizeImage: true, ); - Storage::disk('local')->assertExists('data/image.jpg'); - expect($optimizer->wasOptimized)->toBeTrue(); + Bus::assertDispatchedSync(function (OptimizeImageJob $job) { + return $job->disk === 'local' && $job->filePath === 'tmp/image.jpg'; + }); }); it('handles image transformations on a newly uploaded file if isTransformOriginal is configured', function () { diff --git a/tests/Http/Jobs/OptimizeImageJobTest.php b/tests/Http/Jobs/OptimizeImageJobTest.php new file mode 100644 index 000000000..a3b45c182 --- /dev/null +++ b/tests/Http/Jobs/OptimizeImageJobTest.php @@ -0,0 +1,46 @@ +singleton(OptimizerChain::class, fn () => new class() extends OptimizerChain + { + public bool $optimizedPathToImage = false; + + public function __construct() + { + parent::__construct(); + + foreach (config('image-optimizer.optimizers') as $optimizer => $optimizerConfig) { + $this->addOptimizer(new $optimizer($optimizerConfig)); + } + } + + public function optimize(string $pathToImage, ?string $pathToOutput = null): bool + { + $this->optimizedPathToImage = true; + + return true; + } + }); + + $path = UploadedFile::fake() + ->image('image.jpg') + ->storeAs('/tmp', 'image.jpg', ['disk' => 'local']); + + OptimizeImageJob::dispatch( + disk: 'local', + filePath: 'data/image.jpg', + ); + + expect(collect(app(OptimizerChain::class)->getOptimizers())->whereInstanceOf(Jpegoptim::class)->first()->options) + ->toContain('--keep-exif'); + expect(collect(app(OptimizerChain::class)->getOptimizers())->whereInstanceOf(Avifenc::class)) + ->not->toBeEmpty(); + + expect(app(OptimizerChain::class)->optimizedPathToImage)->toEqual($path); +}); diff --git a/tests/TestCase.php b/tests/TestCase.php index 1f2cf072b..632ac2fc9 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -8,6 +8,7 @@ use Code16\Sharp\SharpInternalServiceProvider; use Orchestra\Testbench\Pest\WithPest; use Orchestra\Testbench\TestCase as Orchestra; +use Spatie\LaravelImageOptimizer\ImageOptimizerServiceProvider; class TestCase extends Orchestra { @@ -26,6 +27,7 @@ protected function getPackageProviders($app): array SharpInternalServiceProvider::class, ContentRendererServiceProvider::class, BladeIconsServiceProvider::class, + ImageOptimizerServiceProvider::class, ]; }