From a37f3bd3c8549f1f4fc39a5e07bc49d97e023985 Mon Sep 17 00:00:00 2001 From: DariusIII Date: Tue, 14 Jul 2026 22:41:51 +0200 Subject: [PATCH] Use first party image support --- .env.example | 2 + app/Services/ReleaseImageService.php | 93 +++++++++-------- composer.json | 2 +- composer.lock | 40 ++++---- config/image.php | 10 +- tests/Unit/ReleaseImageServiceTest.php | 132 +++++++++++++++++++++++++ 6 files changed, 214 insertions(+), 65 deletions(-) create mode 100644 tests/Unit/ReleaseImageServiceTest.php diff --git a/.env.example b/.env.example index df41a9e35..685b28dfd 100644 --- a/.env.example +++ b/.env.example @@ -218,6 +218,8 @@ TURNSTILE_ENABLED=false ITEMS_PER_PAGE=50 ITEMS_PER_COVER_PAGE=25 +# Image processing driver. Use gd when the Imagick PHP extension is unavailable. +IMAGE_DRIVER=imagick MAX_PAGER_RESULTS=125000 ECHOCLI=true RENAME_PAR2=false diff --git a/app/Services/ReleaseImageService.php b/app/Services/ReleaseImageService.php index e8c2936ca..04798000b 100644 --- a/app/Services/ReleaseImageService.php +++ b/app/Services/ReleaseImageService.php @@ -4,13 +4,12 @@ declare(strict_types=1); namespace App\Services; +use Illuminate\Image\Image as LaravelImage; use Illuminate\Support\Facades\File; +use Illuminate\Support\Facades\Image; use Illuminate\Support\Facades\Log; -use Intervention\Image\Drivers\Imagick\Driver; -use Intervention\Image\Exceptions\NotWritableException; -use Intervention\Image\ImageManager; -use Intervention\Image\Interfaces\ImageInterface; use Spatie\LaravelImageOptimizer\Facades\ImageOptimizer; +use Throwable; /** * Resize/save/delete images to disk. @@ -56,7 +55,7 @@ class ReleaseImageService $this->vidSavePath = storage_path('covers/video/'); } - protected function fetchImage(string $imgLoc): bool|ImageInterface + protected function fetchImage(string $imgLoc): bool|LaravelImage { try { // Create context with timeout settings for file_get_contents @@ -107,9 +106,8 @@ class ReleaseImageService return false; } - $manager = new ImageManager(Driver::class); - $img = $manager->read($imageData); - } catch (\Exception $e) { + return Image::fromBytes($imageData); + } catch (Throwable $e) { if ($e->getCode() === 404) { cli()->notice('Data not available on server'); } elseif ($e->getCode() === 503) { @@ -118,15 +116,9 @@ class ReleaseImageService Log::debug('Exception fetching image from '.$imgLoc.': '.$e->getMessage()); cli()->notice('Unable to fetch image: '.$e->getMessage()); } - - $img = false; - } catch (\Error $e) { - Log::debug('Error fetching image from '.$imgLoc.': '.$e->getMessage()); - cli()->info($e->getMessage()); - $img = false; } - return $img; + return false; } /** @@ -153,34 +145,38 @@ class ReleaseImageService return 0; } - // Check if we need to resize it. - if ($imgMaxWidth !== 0 && $imgMaxHeight !== 0) { - $width = $cover->width(); - $height = $cover->height(); - if ($width !== 0 || $height !== 0) { - $ratio = min($imgMaxHeight / $height, $imgMaxWidth / $width); - // New dimensions - $new_width = (int) ($ratio * $width); - $new_height = (int) ($ratio * $height); - if ($new_width < $width && $new_width > 10 && $new_height > 10) { - $cover->resize($new_width, $new_height); + $coverPath = $imgSavePath.$imgName.'.jpg'; - if ($saveThumb) { - $cover->toJpeg(100)->save($imgSavePath.$imgName.'_thumb.jpg'); - // Optimize the thumbnail. - ImageOptimizer::optimize($imgSavePath.$imgName.'_thumb.jpg'); + try { + $shouldSaveThumb = false; + + // Check if we need to resize it. + if ($imgMaxWidth !== 0 && $imgMaxHeight !== 0) { + $width = $cover->width(); + $height = $cover->height(); + if ($width !== 0 || $height !== 0) { + $ratio = min($imgMaxHeight / $height, $imgMaxWidth / $width); + // New dimensions + $new_width = (int) ($ratio * $width); + $new_height = (int) ($ratio * $height); + if ($new_width < $width && $new_width > 10 && $new_height > 10) { + $cover = $cover->resize($new_width, $new_height); + $shouldSaveThumb = $saveThumb; } } } - } - // Store it on the hard drive. - $coverPath = $imgSavePath.$imgName.'.jpg'; - try { - $cover->toJpeg(100)->save($coverPath); - // Optimize the image. - ImageOptimizer::optimize($coverPath); - } catch (NotWritableException $e) { - Log::debug('NotWritableException saving image to '.$coverPath.': '.$e->getMessage()); + + $jpeg = $cover->toJpeg()->quality(100)->toBytes(); + + if ($shouldSaveThumb && ! $this->writeOptimizedImage($imgSavePath.$imgName.'_thumb.jpg', $jpeg)) { + return 0; + } + + if (! $this->writeOptimizedImage($coverPath, $jpeg)) { + return 0; + } + } catch (Throwable $e) { + Log::debug('Unable to process image '.$imgLoc.' for '.$coverPath.': '.$e->getMessage()); return 0; } @@ -194,6 +190,25 @@ class ReleaseImageService return 1; } + private function writeOptimizedImage(string $path, string $contents): bool + { + if (File::put($path, $contents) === false) { + Log::debug('Unable to write image to '.$path); + + return false; + } + + ImageOptimizer::optimize($path); + + if (! File::isReadable($path)) { + Log::debug('Image was not readable after save: '.$path); + + return false; + } + + return true; + } + /** * Delete images for the release. * diff --git a/composer.json b/composer.json index 9ac3758bb..2305222f5 100644 --- a/composer.json +++ b/composer.json @@ -51,7 +51,7 @@ "dariusiii/tv-maze-php-api": "^2.0.0", "elasticsearch/elasticsearch": "^8.19", "guzzlehttp/guzzle": "^7.9", - "intervention/image": "^3.11", + "intervention/image": "^4.0", "laravel/framework": "^13.0", "laravel/horizon": "^5.45", "laravel/pulse": "^1.7", diff --git a/composer.lock b/composer.lock index 44ea1bd5e..07433b1a7 100644 --- a/composer.lock +++ b/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": "aaf81ade6c4cbf0e3f5d46ebd40b715f", + "content-hash": "8c7133089933c92e5fed429b9bc02822", "packages": [ { "name": "aharen/omdbapi", @@ -1734,26 +1734,26 @@ }, { "name": "intervention/gif", - "version": "4.2.4", + "version": "5.0.1", "source": { "type": "git", "url": "https://github.com/Intervention/gif.git", - "reference": "c3598a16ebe7690cd55640c44144a9df383ea73c" + "reference": "bb395af960deffe64d70c976b4df9283f68e762d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Intervention/gif/zipball/c3598a16ebe7690cd55640c44144a9df383ea73c", - "reference": "c3598a16ebe7690cd55640c44144a9df383ea73c", + "url": "https://api.github.com/repos/Intervention/gif/zipball/bb395af960deffe64d70c976b4df9283f68e762d", + "reference": "bb395af960deffe64d70c976b4df9283f68e762d", "shasum": "" }, "require": { - "php": "^8.1" + "php": "^8.3" }, "require-dev": { "phpstan/phpstan": "^2.1", - "phpunit/phpunit": "^10.0 || ^11.0 || ^12.0", + "phpunit/phpunit": "^12.0", "slevomat/coding-standard": "~8.0", - "squizlabs/php_codesniffer": "^3.8" + "squizlabs/php_codesniffer": "^4" }, "type": "library", "autoload": { @@ -1772,7 +1772,7 @@ "homepage": "https://intervention.io/" } ], - "description": "Native PHP GIF Encoder/Decoder", + "description": "PHP GIF Encoder/Decoder", "homepage": "https://github.com/intervention/gif", "keywords": [ "animation", @@ -1782,7 +1782,7 @@ ], "support": { "issues": "https://github.com/Intervention/gif/issues", - "source": "https://github.com/Intervention/gif/tree/4.2.4" + "source": "https://github.com/Intervention/gif/tree/5.0.1" }, "funding": [ { @@ -1798,31 +1798,31 @@ "type": "ko_fi" } ], - "time": "2026-01-04T09:27:23+00:00" + "time": "2026-05-03T06:04:47+00:00" }, { "name": "intervention/image", - "version": "3.11.8", + "version": "4.2.0", "source": { "type": "git", "url": "https://github.com/Intervention/image.git", - "reference": "cf04c8dd245697f701057c13d4bfe140d584e738" + "reference": "830907fc5397dfc2a51a4e90322d586989fc8364" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Intervention/image/zipball/cf04c8dd245697f701057c13d4bfe140d584e738", - "reference": "cf04c8dd245697f701057c13d4bfe140d584e738", + "url": "https://api.github.com/repos/Intervention/image/zipball/830907fc5397dfc2a51a4e90322d586989fc8364", + "reference": "830907fc5397dfc2a51a4e90322d586989fc8364", "shasum": "" }, "require": { "ext-mbstring": "*", - "intervention/gif": "^4.2", - "php": "^8.1" + "intervention/gif": "^5", + "php": "^8.3" }, "require-dev": { "mockery/mockery": "^1.6", "phpstan/phpstan": "^2.1", - "phpunit/phpunit": "^10.0 || ^11.0 || ^12.0", + "phpunit/phpunit": "^12.0", "slevomat/coding-standard": "~8.0", "squizlabs/php_codesniffer": "^4" }, @@ -1858,7 +1858,7 @@ ], "support": { "issues": "https://github.com/Intervention/image/issues", - "source": "https://github.com/Intervention/image/tree/3.11.8" + "source": "https://github.com/Intervention/image/tree/4.2.0" }, "funding": [ { @@ -1874,7 +1874,7 @@ "type": "ko_fi" } ], - "time": "2026-05-01T08:20:10+00:00" + "time": "2026-07-09T13:07:14+00:00" }, { "name": "jean85/pretty-package-versions", diff --git a/config/image.php b/config/image.php index 32ab36a8f..6b1d300b8 100644 --- a/config/image.php +++ b/config/image.php @@ -4,17 +4,17 @@ return [ /* |-------------------------------------------------------------------------- - | Image Driver + | Default Image Driver |-------------------------------------------------------------------------- | - | Intervention Image supports "GD Library" and "Imagick" to process images - | internally. You may choose one of them according to your PHP - | configuration. By default PHP's "GD Library" implementation is used. + | This option controls the default image processing driver that will be + | used when manipulating or converting images. This driver is always + | utilized unless another driver is explicitly specified instead. | | Supported: "gd", "imagick" | */ - 'driver' => 'imagick', + 'default' => env('IMAGE_DRIVER', 'imagick'), ]; diff --git a/tests/Unit/ReleaseImageServiceTest.php b/tests/Unit/ReleaseImageServiceTest.php new file mode 100644 index 000000000..20b950b98 --- /dev/null +++ b/tests/Unit/ReleaseImageServiceTest.php @@ -0,0 +1,132 @@ + 'gd']); + + $this->temporaryDirectory = sys_get_temp_dir().DIRECTORY_SEPARATOR.'release-image-'.uniqid('', true).DIRECTORY_SEPARATOR; + File::makeDirectory($this->temporaryDirectory, 0777, true); + } + + protected function tearDown(): void + { + File::deleteDirectory($this->temporaryDirectory); + + parent::tearDown(); + } + + public function test_it_converts_and_proportionally_resizes_a_local_image_to_jpeg(): void + { + $source = $this->createPng('source.png', 400, 200); + $destination = $this->temporaryDirectory.'cover.jpg'; + + ImageOptimizer::shouldReceive('optimize')->once()->with($destination); + + $result = (new ReleaseImageService)->saveImage('cover', $source, $this->temporaryDirectory, 100, 100); + + $this->assertSame(1, $result); + $this->assertSame('image/jpeg', File::mimeType($destination)); + $this->assertImageDimensions($destination, 100, 50); + } + + public function test_it_does_not_upscale_or_create_a_thumbnail_when_no_resize_occurs(): void + { + $source = $this->createPng('small.png', 40, 20); + $destination = $this->temporaryDirectory.'cover.jpg'; + + ImageOptimizer::shouldReceive('optimize')->once()->with($destination); + + $result = (new ReleaseImageService)->saveImage('cover', $source, $this->temporaryDirectory, 250, 250, true); + + $this->assertSame(1, $result); + $this->assertImageDimensions($destination, 40, 20); + $this->assertFileDoesNotExist($this->temporaryDirectory.'cover_thumb.jpg'); + } + + public function test_it_preserves_the_existing_small_dimension_resize_threshold(): void + { + $source = $this->createPng('short.png', 400, 20); + $destination = $this->temporaryDirectory.'cover.jpg'; + + ImageOptimizer::shouldReceive('optimize')->once()->with($destination); + + $result = (new ReleaseImageService)->saveImage('cover', $source, $this->temporaryDirectory, 100, 100); + + $this->assertSame(1, $result); + $this->assertImageDimensions($destination, 400, 20); + } + + public function test_it_writes_and_optimizes_the_main_image_and_thumbnail(): void + { + $source = $this->createPng('source.png', 200, 100); + $destination = $this->temporaryDirectory.'cover.jpg'; + $thumbnail = $this->temporaryDirectory.'cover_thumb.jpg'; + + ImageOptimizer::shouldReceive('optimize')->once()->with($thumbnail); + ImageOptimizer::shouldReceive('optimize')->once()->with($destination); + + $result = (new ReleaseImageService)->saveImage('cover', $source, $this->temporaryDirectory, 100, 100, true); + + $this->assertSame(1, $result); + $this->assertImageDimensions($destination, 100, 50); + $this->assertImageDimensions($thumbnail, 100, 50); + } + + public function test_it_returns_zero_for_empty_missing_invalid_and_unwritable_inputs(): void + { + ImageOptimizer::shouldReceive('optimize')->never(); + + $service = new ReleaseImageService; + + $this->assertSame(0, $service->saveImage('empty', '', $this->temporaryDirectory)); + $this->assertSame(0, $service->saveImage('missing', $this->temporaryDirectory.'missing.png', $this->temporaryDirectory)); + + $invalid = $this->temporaryDirectory.'invalid.png'; + File::put($invalid, 'not an image'); + $this->assertSame(0, $service->saveImage('invalid', $invalid, $this->temporaryDirectory)); + + $source = $this->createPng('source.png', 100, 50); + $notDirectory = $this->temporaryDirectory.'not-a-directory'; + File::put($notDirectory, 'file'); + $this->assertSame(0, $service->saveImage('unwritable', $source, $notDirectory.DIRECTORY_SEPARATOR)); + } + + private function createPng(string $filename, int $width, int $height): string + { + $path = $this->temporaryDirectory.$filename; + $image = imagecreatetruecolor($width, $height); + + $this->assertInstanceOf(GdImage::class, $image); + + $color = imagecolorallocate($image, 50, 100, 150); + imagefill($image, 0, 0, $color); + imagepng($image, $path); + + return $path; + } + + private function assertImageDimensions(string $path, int $width, int $height): void + { + $dimensions = getimagesize($path); + + $this->assertIsArray($dimensions); + $this->assertSame($width, $dimensions[0]); + $this->assertSame($height, $dimensions[1]); + } +}