diff --git a/README.md b/README.md index fc49519..0e19012 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,8 @@ It supports fetching, optimizing, caching and serving images dynamically while e - **Domain Restriction:** Allows defining authorized domains via an environment variable (_ALLOWED_DOMAINS_). - **On-the-Fly Image Processing:** Fetches images from a URL, compresses them lossless, and caches them. -- **WebP Support:** Converts images to WebP format if supported by the requesting client. +- **WebP Support:** Converts images to WebP format if supported by the requesting client (GIFs are always served as GIF to preserve animation). +- **Animated GIF Support:** Preserves all frames of animated GIFs through resize operations. - **Configurable Storage:** Supports both local filesystem and S3-compatible storage. - **Dynamic Image Resizing:** Resize images via query parameters: - `w` (width) diff --git a/src/Cdn.php b/src/Cdn.php index 26d889a..3e67978 100644 --- a/src/Cdn.php +++ b/src/Cdn.php @@ -63,7 +63,7 @@ public function handleRequest(Request $request): Response $pathProcessor = new PathProcessor($decoder); - $supportWebp = (true === str_contains((string) $request->headers->get('Accept'), 'image/webp')); + $supportWebp = $this->supportsWebp($request); $cachedPath = $pathProcessor->getPath($supportWebp); $force = $this->resolveForce($request); @@ -80,7 +80,7 @@ public function handleRequest(Request $request): Response } try { - $processedImage = $this->imageProcessor->process($originalPath, $decoder->getParams()); + $processedImage = $this->imageProcessor->process($originalPath, $decoder->getParams(), $supportWebp); $this->storage->save($cachedPath, $this->storage->read($processedImage)); } catch (\Throwable $e) { $this->logger->error( @@ -96,6 +96,11 @@ public function handleRequest(Request $request): Response return $this->cache->createResponse($cachedPath, $supportWebp, $request); } + private function supportsWebp(Request $request): bool + { + return true === str_contains((string) $request->headers->get('Accept'), 'image/webp'); + } + private function resolveForce(Request $request): bool { if (false === $request->query->getBoolean('force')) { diff --git a/src/Processor/ImageProcessor.php b/src/Processor/ImageProcessor.php index 15caf4d..773b43e 100644 --- a/src/Processor/ImageProcessor.php +++ b/src/Processor/ImageProcessor.php @@ -15,6 +15,7 @@ use BaBeuloula\CdnPhp\Dto\QueryParams; use BaBeuloula\CdnPhp\Flysystem\Adapter\UrlFilesystemAdapter; +use League\Flysystem\Config; use League\Flysystem\Filesystem; use League\Flysystem\FilesystemAdapter; use League\Glide\ServerFactory; @@ -30,8 +31,19 @@ public function __construct( ) { } - public function process(string $path, QueryParams $params): string + public function process(string $path, QueryParams $params, bool $outputWebp = false): string { + $extension = strtolower(pathinfo($path, PATHINFO_EXTENSION)); + + if ('gif' === $extension) { + return $this->processAnimated($path, $params, $outputWebp); + } + + // Animated WebP sources always stay WebP regardless of the Accept header + if ('webp' === $extension) { + return $this->processAnimated($path, $params, true); + } + $server = ServerFactory::create( [ 'source' => new Filesystem($this->adapter), @@ -53,4 +65,52 @@ public function process(string $path, QueryParams $params): string return $server->makeImage(basename($path), [...$params->toArray(), 'q' => $this->imageCompression]); } + + private function processAnimated(string $path, QueryParams $params, bool $outputWebp = false): string + { + $content = $this->adapter->read($path); + + $imagick = new \Imagick(); + $imagick->readImageBlob($content); + $animation = $imagick->coalesceImages(); + + if ($params->width > 0 || null !== $params->height) { + $width = $params->width > 0 ? $params->width : 0; + $height = $params->height ?? 0; + // bestfit only makes sense when both dimensions are constrained + $bestfit = false; + if ($width > 0 && $height > 0) { + $bestfit = true; + } + + foreach ($animation as $frame) { + $frame->thumbnailImage($width, $height, $bestfit); + } + + $animation = $animation->deconstructImages(); + } + + if (true === $outputWebp) { + $animation->setFormat('WEBP'); + } + + $blob = $animation->getImagesBlob(); + $animation->clear(); + $imagick->clear(); + + $outputExtension = true === $outputWebp ? 'webp' : 'gif'; + $basename = pathinfo($path, PATHINFO_FILENAME) . '.' . $outputExtension; + $cachePath = \dirname(\dirname($path)) . '/cache/' . $basename; + $this->adapter->write($cachePath, $blob, new Config()); + + $this->logger->info( + 'Process animated image: {path} with params {params}', + [ + 'path' => $path, + 'params' => json_encode($params->toArray(), flags: JSON_THROW_ON_ERROR), + ] + ); + + return $cachePath; + } } diff --git a/tests/Cdn/CdnTest.php b/tests/Cdn/CdnTest.php index 718f780..48c5b19 100644 --- a/tests/Cdn/CdnTest.php +++ b/tests/Cdn/CdnTest.php @@ -208,4 +208,40 @@ public function cantHandleRequestWithCorruptImage(): void static::assertSame(Response::HTTP_INTERNAL_SERVER_ERROR, $response->getStatusCode()); } + + #[Test] + public function canHandleGifRequest(): void + { + $request = Request::create('http://mycdn.com/' . static::TEST_GIF_BASE_URI); + + $response = $this->cdn->handleRequest($request); + + static::assertSame(Response::HTTP_OK, $response->getStatusCode()); + static::assertSame('image/gif', $response->headers->get('Content-Type')); + } + + #[Test] + public function canHandleGifRequestWithWebpAccept(): void + { + $request = Request::create('http://mycdn.com/' . static::TEST_GIF_BASE_URI); + $request->headers->set('Accept', 'image/webp,*/*'); + + $response = $this->cdn->handleRequest($request); + + // Animated GIFs are converted to animated WebP when the client supports it + static::assertSame(Response::HTTP_OK, $response->getStatusCode()); + static::assertSame('image/webp', $response->headers->get('Content-Type')); + } + + #[Test] + public function canHandleAnimatedWebpRequest(): void + { + $request = Request::create('http://mycdn.com/' . static::TEST_ANIMATED_WEBP_BASE_URI); + + $response = $this->cdn->handleRequest($request); + + // Animated WebP sources must preserve animation and stay as WebP + static::assertSame(Response::HTTP_OK, $response->getStatusCode()); + static::assertSame('image/webp', $response->headers->get('Content-Type')); + } } diff --git a/tests/Processor/ImageProcessorTest.php b/tests/Processor/ImageProcessorTest.php index 5dd4a83..d4586b6 100644 --- a/tests/Processor/ImageProcessorTest.php +++ b/tests/Processor/ImageProcessorTest.php @@ -18,6 +18,7 @@ use BaBeuloula\CdnPhp\Tests\TestCase; use League\Flysystem\Config; use League\Flysystem\FilesystemAdapter; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; class ImageProcessorTest extends TestCase @@ -46,4 +47,106 @@ public function canProcessImage(): void $imageProcessor->process('image.jpg', QueryParams::fromArray([])) ); } + + #[Test] + public function canProcessAnimatedGif(): void + { + /** @var FilesystemAdapter $adapter */ + $adapter = $this->getContainer(FilesystemAdapter::class); + $adapter->write( + static::TEST_GIF_FILENAME, + static::getTestAnimatedGifContent(), + new Config(), + ); + + /** @var ImageProcessor $imageProcessor */ + $imageProcessor = $this->getContainer(ImageProcessor::class); + + $resultPath = $imageProcessor->process(static::TEST_GIF_FILENAME, QueryParams::fromArray([])); + + static::assertSame(static::TEST_GIF_CACHE_PATH, $resultPath); + + $imagick = new \Imagick(); + $imagick->readImageBlob($adapter->read($resultPath)); + static::assertGreaterThan(1, $imagick->getNumberImages(), 'Animated GIF must preserve all frames'); + $imagick->clear(); + } + + #[Test] + public function canProcessAnimatedGifWithResize(): void + { + /** @var FilesystemAdapter $adapter */ + $adapter = $this->getContainer(FilesystemAdapter::class); + $adapter->write( + static::TEST_GIF_FILENAME, + static::getTestAnimatedGifContent(), + new Config(), + ); + + /** @var ImageProcessor $imageProcessor */ + $imageProcessor = $this->getContainer(ImageProcessor::class); + + $resultPath = $imageProcessor->process( + static::TEST_GIF_FILENAME, + QueryParams::fromArray(['w' => 5]) + ); + + static::assertSame(static::TEST_GIF_CACHE_PATH, $resultPath); + + $imagick = new \Imagick(); + $imagick->readImageBlob($adapter->read($resultPath)); + static::assertGreaterThan(1, $imagick->getNumberImages(), 'Animated GIF must preserve all frames after resize'); + static::assertLessThanOrEqual(5, $imagick->getImageWidth(), 'Width must be resized'); + $imagick->clear(); + } + + #[Test] + public function canProcessAnimatedGifAsWebp(): void + { + /** @var FilesystemAdapter $adapter */ + $adapter = $this->getContainer(FilesystemAdapter::class); + $adapter->write( + static::TEST_GIF_FILENAME, + static::getTestAnimatedGifContent(), + new Config(), + ); + + /** @var ImageProcessor $imageProcessor */ + $imageProcessor = $this->getContainer(ImageProcessor::class); + + $resultPath = $imageProcessor->process(static::TEST_GIF_FILENAME, QueryParams::fromArray([]), true); + + static::assertSame(static::TEST_GIF_WEBP_CACHE_PATH, $resultPath); + + $imagick = new \Imagick(); + $imagick->readImageBlob($adapter->read($resultPath)); + static::assertGreaterThan(1, $imagick->getNumberImages(), 'Animated WebP must preserve all frames'); + static::assertSame('WEBP', $imagick->getImageFormat()); + $imagick->clear(); + } + + #[Test] + public function canProcessAnimatedWebp(): void + { + /** @var FilesystemAdapter $adapter */ + $adapter = $this->getContainer(FilesystemAdapter::class); + $adapter->write( + static::TEST_ANIMATED_WEBP_FILENAME, + static::getTestAnimatedWebpContent(), + new Config(), + ); + + /** @var ImageProcessor $imageProcessor */ + $imageProcessor = $this->getContainer(ImageProcessor::class); + + $resultPath = $imageProcessor->process(static::TEST_ANIMATED_WEBP_FILENAME, QueryParams::fromArray([])); + + static::assertSame(static::TEST_ANIMATED_WEBP_CACHE_PATH, $resultPath); + + $imagick = new \Imagick(); + $imagick->readImageBlob($adapter->read($resultPath)); + static::assertGreaterThan(1, $imagick->getNumberImages(), 'Animated WebP must preserve all frames'); + static::assertSame('WEBP', $imagick->getImageFormat()); + $imagick->clear(); + } } diff --git a/tests/TestCase.php b/tests/TestCase.php index afde3a5..2fad008 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -27,6 +27,10 @@ class TestCase extends BaseTestCase protected const string TEST_WATERMARK_URL = 'https://example.com/watermark.jpg'; protected const string TEST_TOO_LARGE_URI = 'https://example.com/too-large.jpg'; protected const string TEST_CORRUPT_IMAGE_URI = 'https://example.com/corrupt.jpg'; + protected const string TEST_GIF_BASE_URI = 'https://example.com/image.gif'; + protected const string TEST_ANIMATED_WEBP_BASE_URI = 'https://example.com/animated.webp'; + protected const string TEST_ANIMATED_WEBP_FILENAME = 'animated.webp'; + protected const string TEST_ANIMATED_WEBP_CACHE_PATH = './cache/animated.webp'; protected const string TEST_FORCE_TOKEN = 'test-force-token'; protected const string TEST_DOMAIN = 'example.com'; protected const string TEST_DOMAIN_ALIAS = 'ex_ample'; @@ -35,6 +39,9 @@ class TestCase extends BaseTestCase protected const string TEST_EXTENSION = 'jpg'; protected const string TEST_ORIGINAL_PATH = 'example.com/original/' . self::TEST_FILENAME_MD5; protected const string TEST_CACHE_PATH = './cache/image.jpg/4be3b730cab4c047525c594c7560cbf0'; + protected const string TEST_GIF_FILENAME = 'image.gif'; + protected const string TEST_GIF_CACHE_PATH = './cache/image.gif'; + protected const string TEST_GIF_WEBP_CACHE_PATH = './cache/image.webp'; private Container $container; @@ -52,6 +59,14 @@ static function (string $url) { return static::getTestImageContent(); } + if (static::TEST_GIF_BASE_URI === $url) { + return static::getTestAnimatedGifContent(); + } + + if (static::TEST_ANIMATED_WEBP_BASE_URI === $url) { + return static::getTestAnimatedWebpContent(); + } + if (static::TEST_CORRUPT_IMAGE_URI === $url) { return 'not-an-image-data'; } @@ -93,4 +108,42 @@ protected static function getTestImageContent(): string { return (string) file_get_contents(__DIR__ . '/fixtures/image.jpg'); } + + protected static function getTestAnimatedGifContent(): string + { + $animation = new \Imagick(); + + foreach (['red', 'blue'] as $color) { + $frame = new \Imagick(); + $frame->newImage(10, 10, new \ImagickPixel($color), 'gif'); + $frame->setImageDelay(10); + $animation->addImage($frame); + $frame->clear(); + } + + $animation->setFormat('gif'); + $content = $animation->getImagesBlob(); + $animation->clear(); + + return $content; + } + + protected static function getTestAnimatedWebpContent(): string + { + $animation = new \Imagick(); + + foreach (['red', 'blue'] as $color) { + $frame = new \Imagick(); + $frame->newImage(10, 10, new \ImagickPixel($color), 'webp'); + $frame->setImageDelay(10); + $animation->addImage($frame); + $frame->clear(); + } + + $animation->setFormat('WEBP'); + $content = $animation->getImagesBlob(); + $animation->clear(); + + return $content; + } }