Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
9 changes: 7 additions & 2 deletions src/Cdn.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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(
Expand All @@ -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')) {
Expand Down
62 changes: 61 additions & 1 deletion src/Processor/ImageProcessor.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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),
Expand All @@ -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;
}
}
36 changes: 36 additions & 0 deletions tests/Cdn/CdnTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
}
}
103 changes: 103 additions & 0 deletions tests/Processor/ImageProcessorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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();
}
}
53 changes: 53 additions & 0 deletions tests/TestCase.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;

Expand All @@ -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';
}
Expand Down Expand Up @@ -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;
}
}
Loading