From c80d2d992a1b237f701b252f9187dac5924dade6 Mon Sep 17 00:00:00 2001 From: Alexander Hesse Date: Wed, 23 Oct 2024 13:54:16 +0200 Subject: [PATCH 1/2] WIP - basic imagor support --- .../Adjustment/ImageAdjustmentInterface.php | 3 + .../Classes/Domain/Model/DimensionsTrait.php | 8 +- .../Classes/Domain/Model/ImageInterface.php | 4 +- .../Classes/Domain/Service/AssetService.php | 14 + .../Classes/Domain/Service/ImageService.php | 8 + .../Service/Imagor/ImagorPathBuilder.php | 278 +++++++++++++++++ ...ImagorPathBuilderImageInterfaceAdapter.php | 192 ++++++++++++ .../Imagor/ImagorRendererImplementation.php | 282 ++++++++++++++++++ .../Domain/Service/Imagor/ImagorResult.php | 50 ++++ .../Domain/Service/Imagor/ImagorService.php | 136 +++++++++ .../Service/Imagor/NotSupportedByImagor.php | 7 + Neos.Media/Configuration/Settings.yaml | 13 + 12 files changed, 989 insertions(+), 6 deletions(-) create mode 100644 Neos.Media/Classes/Domain/Service/Imagor/ImagorPathBuilder.php create mode 100644 Neos.Media/Classes/Domain/Service/Imagor/ImagorPathBuilderImageInterfaceAdapter.php create mode 100644 Neos.Media/Classes/Domain/Service/Imagor/ImagorRendererImplementation.php create mode 100644 Neos.Media/Classes/Domain/Service/Imagor/ImagorResult.php create mode 100644 Neos.Media/Classes/Domain/Service/Imagor/ImagorService.php create mode 100644 Neos.Media/Classes/Domain/Service/Imagor/NotSupportedByImagor.php diff --git a/Neos.Media/Classes/Domain/Model/Adjustment/ImageAdjustmentInterface.php b/Neos.Media/Classes/Domain/Model/Adjustment/ImageAdjustmentInterface.php index c599ab25177..53e98b5a016 100644 --- a/Neos.Media/Classes/Domain/Model/Adjustment/ImageAdjustmentInterface.php +++ b/Neos.Media/Classes/Domain/Model/Adjustment/ImageAdjustmentInterface.php @@ -26,6 +26,7 @@ interface ImageAdjustmentInterface extends AdjustmentInterface * * @param ImageInterface $image * @return ImageInterface + * !!! only called when using Imagine, not called when using Imagor (or other image proxies) */ public function applyToImage(ImageInterface $image); @@ -34,6 +35,7 @@ public function applyToImage(ImageInterface $image); * * @param ImageVariant $imageVariant * @return void + * !!! only called when using Imagine, not called when using Imagor (or other image proxies) */ public function setImageVariant(ImageVariant $imageVariant); @@ -42,6 +44,7 @@ public function setImageVariant(ImageVariant $imageVariant); * * @param ImageInterface $image * @return boolean + * !!! only called when using Imagine, not called when using Imagor (or other image proxies) */ public function canBeApplied(ImageInterface $image); } diff --git a/Neos.Media/Classes/Domain/Model/DimensionsTrait.php b/Neos.Media/Classes/Domain/Model/DimensionsTrait.php index 3b8ea628f3c..fbce46021ac 100644 --- a/Neos.Media/Classes/Domain/Model/DimensionsTrait.php +++ b/Neos.Media/Classes/Domain/Model/DimensionsTrait.php @@ -19,13 +19,13 @@ trait DimensionsTrait { /** - * @var integer + * @var integer|null * @ORM\Column(nullable=true) */ protected $width = 0; /** - * @var integer + * @var integer|null * @ORM\Column(nullable=true) */ protected $height = 0; @@ -33,7 +33,7 @@ trait DimensionsTrait /** * Width of the image in pixels * - * @return integer + * @return integer|null */ public function getWidth() { @@ -43,7 +43,7 @@ public function getWidth() /** * Height of the image in pixels * - * @return integer + * @return integer|null */ public function getHeight() { diff --git a/Neos.Media/Classes/Domain/Model/ImageInterface.php b/Neos.Media/Classes/Domain/Model/ImageInterface.php index 110e010c96f..b243340e675 100644 --- a/Neos.Media/Classes/Domain/Model/ImageInterface.php +++ b/Neos.Media/Classes/Domain/Model/ImageInterface.php @@ -35,14 +35,14 @@ interface ImageInterface extends ResourceBasedInterface /** * Width of the image in pixels * - * @return integer + * @return integer|null */ public function getWidth(); /** * Height of the image in pixels * - * @return integer + * @return integer|null */ public function getHeight(); diff --git a/Neos.Media/Classes/Domain/Service/AssetService.php b/Neos.Media/Classes/Domain/Service/AssetService.php index 946f19a3ad7..78c02da45aa 100644 --- a/Neos.Media/Classes/Domain/Service/AssetService.php +++ b/Neos.Media/Classes/Domain/Service/AssetService.php @@ -33,6 +33,7 @@ use Neos\Media\Domain\Model\Thumbnail; use Neos\Media\Domain\Model\ThumbnailConfiguration; use Neos\Media\Domain\Repository\AssetRepository; +use Neos\Media\Domain\Service\Imagor\ImagorService; use Neos\Media\Domain\Strategy\AssetUsageStrategyInterface; use Neos\Media\Exception\AssetServiceException; use Neos\Media\Exception\AssetVariantGeneratorException; @@ -103,6 +104,12 @@ class AssetService */ protected $imageService; + /** + * @Flow\Inject + * @var ImagorService + */ + protected ImagorService $imagorService; + /** * @Flow\Inject * @var AssetVariantGenerator @@ -142,6 +149,13 @@ public function getRepository(AssetInterface $asset): RepositoryInterface */ public function getThumbnailUriAndSizeForAsset(AssetInterface $asset, ThumbnailConfiguration $configuration, ActionRequest $request = null): ?array { + return $this->imagorService->getThumbnailUriAndSize($asset, $configuration); +// return [ +// 'src' => 'https://placehold.co/600x400?text=Hello\nWorld', +// 'width' => 600, +// 'height' => 400, +// ]; + $thumbnailImage = $this->thumbnailService->getThumbnail($asset, $configuration); if (!$thumbnailImage instanceof ImageInterface) { return null; diff --git a/Neos.Media/Classes/Domain/Service/ImageService.php b/Neos.Media/Classes/Domain/Service/ImageService.php index fc4a8e46c5d..21976c8cfa0 100644 --- a/Neos.Media/Classes/Domain/Service/ImageService.php +++ b/Neos.Media/Classes/Domain/Service/ImageService.php @@ -294,6 +294,10 @@ protected function applyAdjustments(ImageInterface $image, array $adjustments, & if (!$adjustment instanceof ImageAdjustmentInterface) { throw new ImageServiceException(sprintf('Could not apply the %s adjustment to image because it does not implement the ImageAdjustmentInterface.', get_class($adjustment)), 1381400362); } + + // TODO this is called when running ./flow site:import!! + // $imagineAdjustmentApplier = self::findImagineAdjustmentApplier($adjustment); + if ($adjustment->canBeApplied($image)) { $image = $adjustment->applyToImage($image); $adjustmentsApplied = true; @@ -303,6 +307,10 @@ protected function applyAdjustments(ImageInterface $image, array $adjustments, & return $image; } + private static function findImagineAdjustmentApplier(ImageAdjustmentInterface $adjustment): ImageAdjustmentInterface + { + } + /** * Detects whether the given GIF image data contains more than one frame * diff --git a/Neos.Media/Classes/Domain/Service/Imagor/ImagorPathBuilder.php b/Neos.Media/Classes/Domain/Service/Imagor/ImagorPathBuilder.php new file mode 100644 index 00000000000..4a387f59de7 --- /dev/null +++ b/Neos.Media/Classes/Domain/Service/Imagor/ImagorPathBuilder.php @@ -0,0 +1,278 @@ +trim = true; + return $this; + } + + /** + * AxB:CxD means manually crop the image at left-top point AxB and right-bottom point CxD. + * Coordinates can also be provided as float values between 0 and 1 (percentage of image dimensions) + * + * @param int $a top left x coordinate + * @param int $b top left y coordinate + * @param int $c bottom right x coordinate + * @param int $d bottom right y coordinate + * @return $this + */ + public function crop(int $a, int $b, int $c, int $d): self + { + $this->crop = sprintf('%dx%d:%dx%d', $a, $b, $c, $d); + return $this; + } + + /** + * fit-in means that the generated image should not be auto-cropped and + * otherwise just fit in an imaginary box specified by ExF + * + * @return $this + */ + public function fitIn(): self + { + $this->fitIn = true; + return $this; + } + + /** + * stretch means resize the image to ExF without keeping its aspect ratios + * + * @return $this + */ + public function stretch(): self + { + $this->stretch = true; + return $this; + } + + /** + * ExF means resize the image to be ExF of width per height size. + * + * @param int $width + * @param int $height + * @return self + */ + public function resize(int $width, int $height): self + { + $this->resizeWidth = $width; + $this->resizeHeight = $height; + return $this; + } + + public function getResizeWidth(): int + { + return $this->resizeWidth; + } + + public function getResizeHeight(): int + { + return $this->resizeHeight; + } + + public function flipHorizontally(): self + { + $this->flipHorizontally = !$this->flipHorizontally; + return $this; + } + + public function flipVertically(): self + { + $this->flipVertically = !$this->flipVertically; + return $this; + } + + /** + * GxH:IxJ add left-top padding GxH and right-bottom padding IxJ + * + * @param int $left + * @param int $top + * @param int $right + * @param int $bottom + * @return self + */ + public function padding(int $left, int $top, int $right, int $bottom): self + { + $this->padding = sprintf('%dx%d:%dx%d', $left, $top, $right, $bottom); + return $this; + } + + /** + * HALIGN is horizontal alignment of crop. Accepts left, right or center, defaults to center + * @param string $hAlign + * @return self + */ + public function hAlign(string $hAlign): self + { + if (!in_array($hAlign, ['left', 'right', 'center'])) { + throw new \RuntimeException('Unsupported hAlign: ' . $hAlign); + } + $this->hAlign = $hAlign; + return $this; + } + + /** + * VALIGN is vertical alignment of crop. Accepts top, bottom or middle, defaults to middle + * @param string $vAlign + * @return self + */ + public function vAlign(string $vAlign): self + { + if (!in_array($vAlign, ['top', 'bottom', 'middle'])) { + throw new \RuntimeException('Unsupported vAlign: ' . $vAlign); + } + $this->vAlign = $vAlign; + return $this; + } + + /** + * smart means using smart detection of focal points + * + * @return $this + */ + public function smart(): self + { + $this->smart = true; + return $this; + } + + /** + * @param string $filterName + * @param mixed ...$args + * @return $this + */ + public function addFilter(string $filterName, ...$args): self + { + $this->filters[] = $filterName . '(' . implode(',', $args) . ')'; + return $this; + } + + public function secret(?string $secret): self + { + $this->secret = $secret; + return $this; + } + + public function signerType(?string $signerType): self + { + $this->signerType = $signerType; + return $this; + } + + public function signerTruncate(?int $signerTruncate): self + { + $this->signerTruncate = $signerTruncate; + return $this; + } + + public function build(string $sourceImage): string + { + $decodedPathSegments = []; + + if ($this->trim) { + $decodedPathSegments[] = 'trim'; + } + if ($this->crop) { + $decodedPathSegments[] = $this->crop; + } + if ($this->fitIn) { + $decodedPathSegments[] = 'fit-in'; + } + if ($this->stretch) { + $decodedPathSegments[] = 'stretch'; + } + if ($this->resizeWidth !== 0 || $this->resizeHeight !== 0 || $this->flipVertically || $this->flipHorizontally) { + $decodedPathSegments[] = sprintf( + '%s%dx%s%d', + $this->flipVertically ? '-' : '', + $this->resizeWidth, + $this->flipHorizontally ? '-' : '', + $this->resizeHeight + ); + } + if ($this->padding) { + $decodedPathSegments[] = $this->padding; + } + if ($this->hAlign) { + $decodedPathSegments[] = $this->hAlign; + } + if ($this->vAlign) { + $decodedPathSegments[] = $this->vAlign; + } + if ($this->smart) { + $decodedPathSegments[] = 'smart'; + } + if (!empty($this->filters)) { + $decodedPathSegments[] = 'filters:' . implode(':', $this->filters); + } + + // eg example.net/kisten-trippel_3_kw%282%29.jpg + $encodedSourcePath = ltrim($sourceImage, '/'); + // eg 30x40%3A100x150%2Ffilters%3Afill%28cyan%29 + $encodedPathSegments = array_map(function ($segment) { + return urlencode($segment); + }, $decodedPathSegments); + $encodedPathSegments[] = $encodedSourcePath; + // eg 30x40%3A100x150%2Ffilters%3Afill%28cyan%29/example.net/kisten-trippel_3_kw%282%29.jpg + $encodedPath = implode('/', $encodedPathSegments); + + // eg example.net/kisten-trippel_3_kw(2).jpg + $sourcePathDecoded = urldecode($encodedSourcePath); + $decodedPathSegments[] = $sourcePathDecoded; + // eg 30x40:100x150/filters:fill(cyan)/example.net/kisten-trippel_3_kw(2).jpg + $decodedPath = implode('/', $decodedPathSegments); + + // eg N…mVw/30x40%3A100x150%2Ffilters%3Afill%28cyan%29/example.net/kisten-trippel_3_kw%282%29.jpg + return $this->hmac($decodedPath) . "/" . $encodedPath; + } + + private function hmac(string $path): string + { + if (empty($this->signerType) || empty($this->secret)) { + return 'unsafe'; + } else { + $hash = strtr( + base64_encode( + hash_hmac( + $this->signerType, + $path, + $this->secret, + true + ) + ), + '/+', + '_-' + ); + if ($this->signerTruncate === null) { + return $hash; + } else { + return substr($hash, 0, $this->signerTruncate); + } + } + } +} diff --git a/Neos.Media/Classes/Domain/Service/Imagor/ImagorPathBuilderImageInterfaceAdapter.php b/Neos.Media/Classes/Domain/Service/Imagor/ImagorPathBuilderImageInterfaceAdapter.php new file mode 100644 index 00000000000..0d36bd4032d --- /dev/null +++ b/Neos.Media/Classes/Domain/Service/Imagor/ImagorPathBuilderImageInterfaceAdapter.php @@ -0,0 +1,192 @@ +builder = $builder; + $this->width = $width; + $this->height = $height; + } + + public function copy() + { + return $this; + } + + public function crop(PointInterface $start, BoxInterface $size) + { + $this->builder->crop( + $start->getX(), + $start->getY(), + $start->getX() + $size->getWidth(), + $start->getY() + $size->getHeight() + ); + return $this; + } + + public function resize(BoxInterface $size, $filter = ImageInterface::FILTER_UNDEFINED) + { + $this->builder->resize( + $size->getWidth(), + $size->getHeight() + ); + return $this; + } + + public function rotate($angle, ColorInterface $background = null) + { + $this->builder->addFilter('rotate', $angle); + return $this; + } + + public function paste(ImageInterface $image, PointInterface $start, $alpha = 100) + { + throw new NotSupportedByImagor(); + } + + public function save($path = null, array $options = array()) + { + return $this; + } + + public function show($format, array $options = array()) + { + return $this; + } + + public function flipHorizontally() + { + $this->builder->flipHorizontally(); + return $this; + } + + public function flipVertically() + { + $this->builder->flipVertically(); + return $this; + } + + public function strip() + { + $this->builder->addFilter('strip_exif'); + $this->builder->addFilter('strip_icc'); + return $this; + } + + public function thumbnail( + BoxInterface $size, + $settings = self::THUMBNAIL_INSET, + $filter = ImageInterface::FILTER_UNDEFINED + ) { + $this->resize($size); + return $this; + } + + public function applyMask(ImageInterface $mask) + { + throw new NotSupportedByImagor(); + } + + public function fill(FillInterface $fill) + { + throw new NotSupportedByImagor(); + } + + public function get($format, array $options = array()) + { + throw new NotSupportedByImagor(); + } + + public function draw() + { + throw new NotSupportedByImagor(); + } + + public function effects() + { + throw new NotSupportedByImagor(); + } + + public function getSize(): BoxInterface + { + if ($this->width === null || $this->height === null) { + // If this happens somewhere in the image processing + // this exceptions is caught in the ImagorRendererImplementation (or earlier) + // and resulting in the URI to be ''. + throw new NotSupportedByImagor(); + } else { + return new Box($this->width, $this->height); + } + } + + public function mask() + { + throw new NotSupportedByImagor(); + } + + public function histogram() + { + throw new NotSupportedByImagor(); + } + + public function getColorAt(PointInterface $point) + { + throw new NotSupportedByImagor(); + } + + public function layers() + { + throw new NotSupportedByImagor(); + } + + public function interlace($scheme) + { + throw new NotSupportedByImagor(); + } + + public function palette() + { + throw new NotSupportedByImagor(); + } + + public function usePalette(PaletteInterface $palette) + { + throw new NotSupportedByImagor(); + } + + public function profile(ProfileInterface $profile) + { + throw new NotSupportedByImagor(); + } + + public function metadata() + { + throw new NotSupportedByImagor(); + } + + public function __toString(): string + { + return "ImagorAdapter"; + } +} diff --git a/Neos.Media/Classes/Domain/Service/Imagor/ImagorRendererImplementation.php b/Neos.Media/Classes/Domain/Service/Imagor/ImagorRendererImplementation.php new file mode 100644 index 00000000000..2d21fa97abb --- /dev/null +++ b/Neos.Media/Classes/Domain/Service/Imagor/ImagorRendererImplementation.php @@ -0,0 +1,282 @@ +fusionValue('asset'); + } + + /** + * MaximumWidth + * + * @return integer | null + */ + public function getMaximumWidth(): ?int + { + return $this->fusionValue('maximumWidth'); + } + + /** + * MaximumHeight + * + * @return integer | null + */ + public function getMaximumHeight(): ?int + { + return $this->fusionValue('maximumHeight'); + } + + /** + * AllowCropping + * + * @return boolean + */ + public function getAllowCropping() + { + return $this->fusionValue('allowCropping'); + } + + /** + * AllowUpScaling + * + * @return boolean + */ + public function getAllowUpScaling() + { + return $this->fusionValue('allowUpScaling'); + } + + /** + * Quality + * + * @return integer + */ + public function getQuality(): int + { + return $this->fusionValue('quality'); + } + + /** + * Async + * + * @return string|null + */ + public function getFormat(): ?string + { + return $this->fusionValue('format'); + } + + /** + * Preset + * + * @return string + */ + public function getPreset(): string + { + return $this->fusionValue('preset'); + } + + // ################# IMPLEMENTATION ################# + + /** + * @Flow\Inject + * @var Environment + */ + protected $environment; + + /** + * @Flow\Inject + * @var ResourceManager + */ + protected $resourceManager; + + /** + * @Flow\InjectConfiguration("imagor.sourceBaseUrl") + * @var string + */ + protected string $imagorSourceBaseUrl; + + /** + * @Flow\InjectConfiguration("imagor.proxyBaseUrl") + * @var string + */ + protected string $imagorProxyBaseUrl; + + /** + * @Flow\InjectConfiguration("imagor.secret") + * @var string | null + */ + protected ?string $imagorSecret; + + /** + * @Flow\InjectConfiguration("imagor.signerType") + * @var string | null + */ + protected ?string $imagorSignerType; + + /** + * @Flow\InjectConfiguration("imagor.signerTruncate") + * @var int | null + */ + protected ?int $imagorSignerTruncate; + + public function evaluate(): string + { + $asset = $this->getAsset(); + if ($asset === null) { + return ''; + } + $originalImage = $asset; + if ($originalImage instanceof Thumbnail) { + $originalImage = $asset->getOriginalAsset(); + } + // might happen that thumbnail -> imageVariant -> Image, that's why we do not do elseif but if (after thumbnail) + if ($originalImage instanceof ImageVariant) { + $originalImage = $asset->getOriginalAsset(); + } + $imageUrl = $this->resourceManager->getPublicPersistentResourceUri($originalImage->getResource()); + if ($imageUrl === '' || $imageUrl === false) { + return ''; + } + $normalizedImageUrl = $this->normalizeUrl(strval($imageUrl)); + if (str_ends_with($normalizedImageUrl, '.svg')) { + // cropping and such is not supported for SVGs neither in Imagor nor in Vips etc + // trying to process SVGs with Imagor leads to black boxes + return $normalizedImageUrl; + } + try { + return $this->buildImageUrl($asset, $originalImage, $normalizedImageUrl); + } catch (\Throwable $t) { + // We catch exceptions and errors here since we had an incident due to a null value in the DB. + // The resulting TypeError lead to an 500 error page. We do not want that. + // TODO - logging! + return ''; + } + } + + public function allowsCallOfMethod(string $methodName): bool + { + return true; + } + + /** + * @param string $sourceImage + * @return string + */ + private function normalizeUrl(string $sourceImage): string + { + if ($this->environment->getContext()->isDevelopment()) { + return str_ireplace('http://localhost:8081', $this->imagorSourceBaseUrl, $sourceImage); + } + return $sourceImage; + } + + private function buildImageUrl(AssetInterface $image, ImageInterface $originalImage, string $sourceUrl): string + { + $imagorBuilder = $this->asImagorPathBuilder($image, $originalImage); + return $this->imagorProxyBaseUrl . "/" . $imagorBuilder->build($sourceUrl); + } + + private function asImagorPathBuilder(AssetInterface $image, ImageInterface $originalImage): ImagorPathBuilder + { + $allowUpScaling = $this->getAllowUpScaling(); + $allowCropping = $this->getAllowCropping(); + $quality = $this->getQuality(); + $format = $this->getFormat(); + // TODO: preset = NULL + + $result = (new ImagorPathBuilder()) + ->secret($this->imagorSecret) + ->signerType($this->imagorSignerType) + ->signerTruncate($this->imagorSignerTruncate) + // (at time of writing) The following line increased the cache expiration in the HTTP response header to 7d. + // The actual time given is ignored (unfortunately) if it exceeds the Imagor service settings: + // -imagor-cache-header-ttl (defaults to 7d) and -imagor-cache-header-swr (defaults to 1d). + ->addFilter('expire', (time() + 31_536_000) * 1000) // TTL is 1y + ->addFilter('quality', $quality); + if (!$allowCropping) { + $result->fitIn(); + } + if (!empty($format)) { + $result->addFilter('format', $format); + } + // !!! despite the types of the getters, width and height might be null !!! + // The DB column is NULLABLE, see DimensionsTrait.php + $originalWidth = $originalImage->getWidth(); + $originalHeight = $originalImage->getHeight(); + $adapter = new ImagorPathBuilderImageInterfaceAdapter($result, $originalWidth, $originalHeight); + if ($image instanceof ImageVariant) { + foreach ($image->getAdjustments() as $adjustment) { + if ($adjustment instanceof AbstractImageAdjustment && $adjustment->canBeApplied($adapter)) { + $adjustment->applyToImage($adapter); + } + } + } + if ( + $allowUpScaling === false && + ($result->getResizeWidth() > $originalWidth || $result->getResizeHeight() > $originalHeight) + ) { + $result->resize(0, 0); + } + $this->limitToMaximalSize($image, $result); + + return $result; + } + + private function limitToMaximalSize(AssetInterface $image, ImagorPathBuilder $result): void + { + $originalWidth = $result->getResizeWidth() !== 0 ? $result->getResizeWidth() : $image->getWidth(); + $originalHeight = $result->getResizeHeight() !== 0 ? $result->getResizeHeight() : $image->getHeight(); + + if ($this->isTooWide($originalWidth) || $this->isTooHigh($originalHeight)) { + $width = $originalWidth; + $height = $originalHeight; + // TODO: what if $allowCropping + if ($this->isTooWide($width)) { + // here the limit cannot be null but Psalm does not realise it, hence the default value + $width = $this->getMaximumWidth() ?? 0; + $height = ($width / $originalHeight) * $originalHeight; + // by setting the height to 0 we keep the aspect ration + $result->resize($width, 0); + } + // too high since we did not limit width OR + // too high although we limited the width + if ($this->isTooHigh($height)) { + $result->resize(0, $height); + } + } + } + + private function isTooHigh(int $height): bool + { + $maximumHeight = $this->getMaximumHeight(); + return $maximumHeight !== null && $maximumHeight !== 0 && $height > $maximumHeight; + } + + private function isTooWide(int $width): bool + { + $maximumWidth = $this->getMaximumWidth(); + return $maximumWidth !== null && $maximumWidth !== 0 && $width > $maximumWidth; + } +} diff --git a/Neos.Media/Classes/Domain/Service/Imagor/ImagorResult.php b/Neos.Media/Classes/Domain/Service/Imagor/ImagorResult.php new file mode 100644 index 00000000000..d04f0e6c891 --- /dev/null +++ b/Neos.Media/Classes/Domain/Service/Imagor/ImagorResult.php @@ -0,0 +1,50 @@ +builder) { + // Case for ImagorResult::empty(). + return null; + } + + + if ($offset === 'src') { + $this->builder->build(); + } + // TODO: Lazy width / height + return null; + // TODO: Implement offsetGet() method. + } + + public function offsetSet(mixed $offset, mixed $value): void + { + throw new \RuntimeException('writing not supported!'); + } + + public function offsetUnset(mixed $offset): void + { + throw new \RuntimeException('writing not supported!'); + } +} diff --git a/Neos.Media/Classes/Domain/Service/Imagor/ImagorService.php b/Neos.Media/Classes/Domain/Service/Imagor/ImagorService.php new file mode 100644 index 00000000000..ab8252a2ef3 --- /dev/null +++ b/Neos.Media/Classes/Domain/Service/Imagor/ImagorService.php @@ -0,0 +1,136 @@ + später auch S3, sonst local file + + $originalImage = $asset; + if ($originalImage instanceof Thumbnail) { + $originalImage = $asset->getOriginalAsset(); + } + // might happen that thumbnail -> imageVariant -> Image, that's why we do not do elseif but if (after thumbnail) + if ($originalImage instanceof ImageVariant) { + $originalImage = $asset->getOriginalAsset(); + } + + $imageUrl = $this->resourceManager->getPublicPersistentResourceUri($originalImage->getResource()); + if ($imageUrl === '' || $imageUrl === false) { + return []; + # return ImagorResult::empty(); + } + + $url = $this->buildImageUrl($asset, $configuration, $originalImage, $imageUrl); + + return [ + 'src' => $url, + ]; + } + + private function buildImageUrl(AssetInterface $image, ThumbnailConfiguration $configuration, ImageInterface $originalImage, string $sourceUrl): string + { + $imagorBuilder = $this->asImagorPathBuilder($image, $configuration, $originalImage); + $result = $imagorBuilder->build($sourceUrl); + return $this->imagorProxyBaseUrl . "/" . $result; + } + + private function asImagorPathBuilder(AssetInterface $image, ThumbnailConfiguration $configuration, ImageInterface $originalImage): ImagorPathBuilder + { + $result = (new ImagorPathBuilder()) + ->secret($this->imagorSecret) + ->signerType($this->imagorSignerType) + ->signerTruncate($this->imagorSignerTruncate) + // (at time of writing) The following line increased the cache expiration in the HTTP response header to 7d. + // The actual time given is ignored (unfortunately) if it exceeds the Imagor service settings: + // -imagor-cache-header-ttl (defaults to 7d) and -imagor-cache-header-swr (defaults to 1d). + ->addFilter('expire', (time() + 31_536_000) * 1000); // TTL is 1y + + if ($configuration->getQuality()) { + $result->addFilter('quality', $configuration->getQuality()); + } + + if (!$configuration->isCroppingAllowed()) { + $result->fitIn(); + } + if ($configuration->getFormat()) { + $result->addFilter('format', $configuration->getFormat()); + } + // !!! despite the types of the getters, width and height might be null !!! + // The DB column is NULLABLE, see DimensionsTrait.php + $originalWidth = $originalImage->getWidth(); + $originalHeight = $originalImage->getHeight(); + $adapter = new ImagorPathBuilderImageInterfaceAdapter($result, $originalWidth, $originalHeight); + if ($image instanceof ImageVariant) { + foreach ($image->getAdjustments() as $adjustment) { + if ($adjustment instanceof AbstractImageAdjustment && $adjustment->canBeApplied($adapter)) { + $adjustment->applyToImage($adapter); + } + } + } + if ( + $configuration->isUpScalingAllowed() === false && + ($result->getResizeWidth() > $originalWidth || $result->getResizeHeight() > $originalHeight) + ) { + $result->resize(0, 0); + } + // TODO: IMPLEMENT LATER: $this->limitToMaximalSize($image, $result); + + return $result; + } +} diff --git a/Neos.Media/Classes/Domain/Service/Imagor/NotSupportedByImagor.php b/Neos.Media/Classes/Domain/Service/Imagor/NotSupportedByImagor.php new file mode 100644 index 00000000000..50c662594f3 --- /dev/null +++ b/Neos.Media/Classes/Domain/Service/Imagor/NotSupportedByImagor.php @@ -0,0 +1,7 @@ + Date: Wed, 13 Nov 2024 11:18:18 +0100 Subject: [PATCH 2/2] WIP - basic imagor support --- .../Classes/Domain/Service/AssetService.php | 26 +- .../Service/Imagor/ImagorPathBuilder.php | 178 ++++++++--- ...ImagorPathBuilderImageInterfaceAdapter.php | 67 ++--- .../Imagor/ImagorRendererImplementation.php | 282 ------------------ .../Domain/Service/Imagor/ImagorResult.php | 50 ---- .../Domain/Service/Imagor/ImagorService.php | 128 +++++--- .../Service/Imagor/NotSupportedByImagor.php | 7 - .../Imagor/NotSupportedByImagorException.php | 11 + Neos.Media/Configuration/Settings.yaml | 34 ++- .../Unit/Domain/Service/ImagorServiceTest.php | 42 +++ 10 files changed, 341 insertions(+), 484 deletions(-) delete mode 100644 Neos.Media/Classes/Domain/Service/Imagor/ImagorRendererImplementation.php delete mode 100644 Neos.Media/Classes/Domain/Service/Imagor/ImagorResult.php delete mode 100644 Neos.Media/Classes/Domain/Service/Imagor/NotSupportedByImagor.php create mode 100644 Neos.Media/Classes/Domain/Service/Imagor/NotSupportedByImagorException.php create mode 100644 Neos.Media/Tests/Unit/Domain/Service/ImagorServiceTest.php diff --git a/Neos.Media/Classes/Domain/Service/AssetService.php b/Neos.Media/Classes/Domain/Service/AssetService.php index 78c02da45aa..75a78a872bf 100644 --- a/Neos.Media/Classes/Domain/Service/AssetService.php +++ b/Neos.Media/Classes/Domain/Service/AssetService.php @@ -38,7 +38,6 @@ use Neos\Media\Exception\AssetServiceException; use Neos\Media\Exception\AssetVariantGeneratorException; use Neos\Media\Exception\ThumbnailServiceException; -use Neos\RedirectHandler\Storage\RedirectStorageInterface; use Neos\Utility\Arrays; use Neos\Utility\MediaTypes; use Psr\Log\LoggerInterface; @@ -106,7 +105,6 @@ class AssetService /** * @Flow\Inject - * @var ImagorService */ protected ImagorService $imagorService; @@ -116,6 +114,11 @@ class AssetService */ protected $assetVariantGenerator; + /** + * @Flow\InjectConfiguration("imagor.enabled") + */ + protected bool $isImagorEnabled = false; + /** * Returns the repository for an asset * @@ -138,23 +141,20 @@ public function getRepository(AssetInterface $asset): RepositoryInterface * Calculates the dimensions of the thumbnail to be generated and returns the thumbnail URI. * In case of Images this is a thumbnail of the image, in case of other assets an icon representation. * - * @param AssetInterface $asset - * @param ThumbnailConfiguration $configuration - * @param ActionRequest $request Request argument must be provided for asynchronous thumbnails + * @param AssetInterface $asset + * @param ThumbnailConfiguration $configuration + * @param ActionRequest|null $request Request argument must be provided for asynchronous thumbnails * @return array|null Array with keys "width", "height" and "src" if the thumbnail generation work or null * @throws AssetServiceException - * @throws ThumbnailServiceException - * @throws MissingActionNameException * @throws HttpException + * @throws MissingActionNameException + * @throws ThumbnailServiceException */ public function getThumbnailUriAndSizeForAsset(AssetInterface $asset, ThumbnailConfiguration $configuration, ActionRequest $request = null): ?array { - return $this->imagorService->getThumbnailUriAndSize($asset, $configuration); -// return [ -// 'src' => 'https://placehold.co/600x400?text=Hello\nWorld', -// 'width' => 600, -// 'height' => 400, -// ]; + if($asset instanceof ImageInterface && $this->isImagorEnabled) { + return $this->imagorService->getThumbnailUriAndSize($asset, $configuration); + } $thumbnailImage = $this->thumbnailService->getThumbnail($asset, $configuration); if (!$thumbnailImage instanceof ImageInterface) { diff --git a/Neos.Media/Classes/Domain/Service/Imagor/ImagorPathBuilder.php b/Neos.Media/Classes/Domain/Service/Imagor/ImagorPathBuilder.php index 4a387f59de7..c48bb7ee220 100644 --- a/Neos.Media/Classes/Domain/Service/Imagor/ImagorPathBuilder.php +++ b/Neos.Media/Classes/Domain/Service/Imagor/ImagorPathBuilder.php @@ -1,9 +1,32 @@ sourceImageUrl = $this->replaceImageHostIfNecessary($this->sourceImageUrl); + } + /** * trim removes surrounding space in images using top-left pixel color * @@ -29,6 +64,7 @@ class ImagorPathBuilder public function trim(): self { $this->trim = true; + return $this; } @@ -36,15 +72,16 @@ public function trim(): self * AxB:CxD means manually crop the image at left-top point AxB and right-bottom point CxD. * Coordinates can also be provided as float values between 0 and 1 (percentage of image dimensions) * - * @param int $a top left x coordinate - * @param int $b top left y coordinate - * @param int $c bottom right x coordinate - * @param int $d bottom right y coordinate + * @param int $a top left x coordinate + * @param int $b top left y coordinate + * @param int $c bottom right x coordinate + * @param int $d bottom right y coordinate * @return $this */ public function crop(int $a, int $b, int $c, int $d): self { $this->crop = sprintf('%dx%d:%dx%d', $a, $b, $c, $d); + return $this; } @@ -57,6 +94,7 @@ public function crop(int $a, int $b, int $c, int $d): self public function fitIn(): self { $this->fitIn = true; + return $this; } @@ -68,20 +106,22 @@ public function fitIn(): self public function stretch(): self { $this->stretch = true; + return $this; } /** * ExF means resize the image to be ExF of width per height size. * - * @param int $width - * @param int $height + * @param int $width + * @param int $height * @return self */ public function resize(int $width, int $height): self { $this->resizeWidth = $width; $this->resizeHeight = $height; + return $this; } @@ -97,56 +137,61 @@ public function getResizeHeight(): int public function flipHorizontally(): self { - $this->flipHorizontally = !$this->flipHorizontally; + $this->flipHorizontally = ! $this->flipHorizontally; + return $this; } public function flipVertically(): self { - $this->flipVertically = !$this->flipVertically; + $this->flipVertically = ! $this->flipVertically; + return $this; } /** * GxH:IxJ add left-top padding GxH and right-bottom padding IxJ * - * @param int $left - * @param int $top - * @param int $right - * @param int $bottom + * @param int $left + * @param int $top + * @param int $right + * @param int $bottom * @return self */ public function padding(int $left, int $top, int $right, int $bottom): self { $this->padding = sprintf('%dx%d:%dx%d', $left, $top, $right, $bottom); + return $this; } /** * HALIGN is horizontal alignment of crop. Accepts left, right or center, defaults to center - * @param string $hAlign + * @param string $hAlign * @return self */ public function hAlign(string $hAlign): self { - if (!in_array($hAlign, ['left', 'right', 'center'])) { - throw new \RuntimeException('Unsupported hAlign: ' . $hAlign); + if (! in_array($hAlign, ['left', 'right', 'center'])) { + throw new RuntimeException('Unsupported hAlign: '.$hAlign); } $this->hAlign = $hAlign; + return $this; } /** * VALIGN is vertical alignment of crop. Accepts top, bottom or middle, defaults to middle - * @param string $vAlign + * @param string $vAlign * @return self */ public function vAlign(string $vAlign): self { - if (!in_array($vAlign, ['top', 'bottom', 'middle'])) { - throw new \RuntimeException('Unsupported vAlign: ' . $vAlign); + if (! in_array($vAlign, ['top', 'bottom', 'middle'])) { + throw new RuntimeException('Unsupported vAlign: '.$vAlign); } $this->vAlign = $vAlign; + return $this; } @@ -157,40 +202,49 @@ public function vAlign(string $vAlign): self */ public function smart(): self { + // todo remove? also vAlign, etc all unused $this->smart = true; + return $this; } /** - * @param string $filterName - * @param mixed ...$args + * @param string $filterName + * @param mixed ...$args * @return $this */ public function addFilter(string $filterName, ...$args): self { - $this->filters[] = $filterName . '(' . implode(',', $args) . ')'; + $this->filters[] = $filterName.'('.implode(',', $args).')'; + return $this; } public function secret(?string $secret): self { $this->secret = $secret; + return $this; } public function signerType(?string $signerType): self { $this->signerType = $signerType; + return $this; } public function signerTruncate(?int $signerTruncate): self { $this->signerTruncate = $signerTruncate; + return $this; } - public function build(string $sourceImage): string + /** + * @throws Exception + */ + public function getSourceUrl(): string { $decodedPathSegments = []; @@ -227,12 +281,12 @@ public function build(string $sourceImage): string if ($this->smart) { $decodedPathSegments[] = 'smart'; } - if (!empty($this->filters)) { - $decodedPathSegments[] = 'filters:' . implode(':', $this->filters); + if (! empty($this->filters)) { + $decodedPathSegments[] = 'filters:'.implode(':', $this->filters); } // eg example.net/kisten-trippel_3_kw%282%29.jpg - $encodedSourcePath = ltrim($sourceImage, '/'); + $encodedSourcePath = ltrim($this->sourceImageUrl, '/'); // eg 30x40%3A100x150%2Ffilters%3Afill%28cyan%29 $encodedPathSegments = array_map(function ($segment) { return urlencode($segment); @@ -248,31 +302,67 @@ public function build(string $sourceImage): string $decodedPath = implode('/', $decodedPathSegments); // eg N…mVw/30x40%3A100x150%2Ffilters%3Afill%28cyan%29/example.net/kisten-trippel_3_kw%282%29.jpg - return $this->hmac($decodedPath) . "/" . $encodedPath; + return $this->getImagorProxyBaseUrl().'/'.$this->hmac($decodedPath).'/'.$encodedPath; + } + + /** + * Normally the imageUrl starts with the neos base url. If the url is reachable by imagor that's fine, but it + * can happen that this is not the case. If the imagorSourceBaseUrl is configured it replaces the neos base url + * in the imageUrl so that imagor can reach the original image. + * @throws Exception + */ + private function replaceImageHostIfNecessary(string $imageUrl): string + { + if ((string)$this->imagorSourceBaseUrl !== '') { + $baseUri = $this->baseUriProvider->getConfiguredBaseUriOrFallbackToCurrentRequest(); + $currentRequestUrl = $baseUri->getScheme().'://'.$baseUri->getHost(); + if ($baseUri->getPort() !== '') { + $currentRequestUrl .= ':'.$baseUri->getPort(); + } + $imageUrl = str_replace($currentRequestUrl, $this->imagorSourceBaseUrl, $imageUrl); + } + + return $imageUrl; + } + + /** + * @throws Exception + */ + private function getImagorProxyBaseUrl(): string + { + $uri = new Uri($this->imagorProxyBaseUrl); + if ($uri::isRelativePathReference($uri)) { + $baseUri = $this->baseUriProvider->getConfiguredBaseUriOrFallbackToCurrentRequest(); + + return (string)$baseUri->withPath($uri->getPath()); + } + + return $this->imagorProxyBaseUrl; } private function hmac(string $path): string { if (empty($this->signerType) || empty($this->secret)) { return 'unsafe'; - } else { - $hash = strtr( - base64_encode( - hash_hmac( - $this->signerType, - $path, - $this->secret, - true - ) - ), - '/+', - '_-' - ); - if ($this->signerTruncate === null) { - return $hash; - } else { - return substr($hash, 0, $this->signerTruncate); - } } + + $hash = strtr( + base64_encode( + hash_hmac( + $this->signerType, + $path, + $this->secret, + true + ) + ), + '/+', + '_-' + ); + + if ($this->signerTruncate === null) { + return $hash; + } + + return substr($hash, 0, $this->signerTruncate); } } diff --git a/Neos.Media/Classes/Domain/Service/Imagor/ImagorPathBuilderImageInterfaceAdapter.php b/Neos.Media/Classes/Domain/Service/Imagor/ImagorPathBuilderImageInterfaceAdapter.php index 0d36bd4032d..b99b4982e92 100644 --- a/Neos.Media/Classes/Domain/Service/Imagor/ImagorPathBuilderImageInterfaceAdapter.php +++ b/Neos.Media/Classes/Domain/Service/Imagor/ImagorPathBuilderImageInterfaceAdapter.php @@ -1,5 +1,7 @@ builder = $builder; $this->width = $width; @@ -42,6 +39,7 @@ public function crop(PointInterface $start, BoxInterface $size) $start->getX() + $size->getWidth(), $start->getY() + $size->getHeight() ); + return $this; } @@ -51,26 +49,28 @@ public function resize(BoxInterface $size, $filter = ImageInterface::FILTER_UNDE $size->getWidth(), $size->getHeight() ); + return $this; } public function rotate($angle, ColorInterface $background = null) { $this->builder->addFilter('rotate', $angle); + return $this; } public function paste(ImageInterface $image, PointInterface $start, $alpha = 100) { - throw new NotSupportedByImagor(); + throw new NotSupportedByImagorException(); } - public function save($path = null, array $options = array()) + public function save($path = null, array $options = []) { return $this; } - public function show($format, array $options = array()) + public function show($format, array $options = []) { return $this; } @@ -78,12 +78,14 @@ public function show($format, array $options = array()) public function flipHorizontally() { $this->builder->flipHorizontally(); + return $this; } public function flipVertically() { $this->builder->flipVertically(); + return $this; } @@ -91,6 +93,7 @@ public function strip() { $this->builder->addFilter('strip_exif'); $this->builder->addFilter('strip_icc'); + return $this; } @@ -100,89 +103,83 @@ public function thumbnail( $filter = ImageInterface::FILTER_UNDEFINED ) { $this->resize($size); + return $this; } public function applyMask(ImageInterface $mask) { - throw new NotSupportedByImagor(); + throw new NotSupportedByImagorException(); } public function fill(FillInterface $fill) { - throw new NotSupportedByImagor(); + throw new NotSupportedByImagorException(); } - public function get($format, array $options = array()) + public function get($format, array $options = []) { - throw new NotSupportedByImagor(); + throw new NotSupportedByImagorException(); } public function draw() { - throw new NotSupportedByImagor(); + throw new NotSupportedByImagorException(); } public function effects() { - throw new NotSupportedByImagor(); + throw new NotSupportedByImagorException(); } public function getSize(): BoxInterface { - if ($this->width === null || $this->height === null) { - // If this happens somewhere in the image processing - // this exceptions is caught in the ImagorRendererImplementation (or earlier) - // and resulting in the URI to be ''. - throw new NotSupportedByImagor(); - } else { - return new Box($this->width, $this->height); - } + return new Box($this->width, $this->height); } public function mask() { - throw new NotSupportedByImagor(); + throw new NotSupportedByImagorException(); } public function histogram() { - throw new NotSupportedByImagor(); + throw new NotSupportedByImagorException(); } public function getColorAt(PointInterface $point) { - throw new NotSupportedByImagor(); + throw new NotSupportedByImagorException(); } public function layers() { - throw new NotSupportedByImagor(); + throw new NotSupportedByImagorException(); } public function interlace($scheme) { - throw new NotSupportedByImagor(); + throw new NotSupportedByImagorException(); } public function palette() { - throw new NotSupportedByImagor(); + throw new NotSupportedByImagorException(); } public function usePalette(PaletteInterface $palette) { - throw new NotSupportedByImagor(); + throw new NotSupportedByImagorException(); } public function profile(ProfileInterface $profile) { - throw new NotSupportedByImagor(); + throw new NotSupportedByImagorException(); } public function metadata() { - throw new NotSupportedByImagor(); + throw new NotSupportedByImagorException(); } public function __toString(): string diff --git a/Neos.Media/Classes/Domain/Service/Imagor/ImagorRendererImplementation.php b/Neos.Media/Classes/Domain/Service/Imagor/ImagorRendererImplementation.php deleted file mode 100644 index 2d21fa97abb..00000000000 --- a/Neos.Media/Classes/Domain/Service/Imagor/ImagorRendererImplementation.php +++ /dev/null @@ -1,282 +0,0 @@ -fusionValue('asset'); - } - - /** - * MaximumWidth - * - * @return integer | null - */ - public function getMaximumWidth(): ?int - { - return $this->fusionValue('maximumWidth'); - } - - /** - * MaximumHeight - * - * @return integer | null - */ - public function getMaximumHeight(): ?int - { - return $this->fusionValue('maximumHeight'); - } - - /** - * AllowCropping - * - * @return boolean - */ - public function getAllowCropping() - { - return $this->fusionValue('allowCropping'); - } - - /** - * AllowUpScaling - * - * @return boolean - */ - public function getAllowUpScaling() - { - return $this->fusionValue('allowUpScaling'); - } - - /** - * Quality - * - * @return integer - */ - public function getQuality(): int - { - return $this->fusionValue('quality'); - } - - /** - * Async - * - * @return string|null - */ - public function getFormat(): ?string - { - return $this->fusionValue('format'); - } - - /** - * Preset - * - * @return string - */ - public function getPreset(): string - { - return $this->fusionValue('preset'); - } - - // ################# IMPLEMENTATION ################# - - /** - * @Flow\Inject - * @var Environment - */ - protected $environment; - - /** - * @Flow\Inject - * @var ResourceManager - */ - protected $resourceManager; - - /** - * @Flow\InjectConfiguration("imagor.sourceBaseUrl") - * @var string - */ - protected string $imagorSourceBaseUrl; - - /** - * @Flow\InjectConfiguration("imagor.proxyBaseUrl") - * @var string - */ - protected string $imagorProxyBaseUrl; - - /** - * @Flow\InjectConfiguration("imagor.secret") - * @var string | null - */ - protected ?string $imagorSecret; - - /** - * @Flow\InjectConfiguration("imagor.signerType") - * @var string | null - */ - protected ?string $imagorSignerType; - - /** - * @Flow\InjectConfiguration("imagor.signerTruncate") - * @var int | null - */ - protected ?int $imagorSignerTruncate; - - public function evaluate(): string - { - $asset = $this->getAsset(); - if ($asset === null) { - return ''; - } - $originalImage = $asset; - if ($originalImage instanceof Thumbnail) { - $originalImage = $asset->getOriginalAsset(); - } - // might happen that thumbnail -> imageVariant -> Image, that's why we do not do elseif but if (after thumbnail) - if ($originalImage instanceof ImageVariant) { - $originalImage = $asset->getOriginalAsset(); - } - $imageUrl = $this->resourceManager->getPublicPersistentResourceUri($originalImage->getResource()); - if ($imageUrl === '' || $imageUrl === false) { - return ''; - } - $normalizedImageUrl = $this->normalizeUrl(strval($imageUrl)); - if (str_ends_with($normalizedImageUrl, '.svg')) { - // cropping and such is not supported for SVGs neither in Imagor nor in Vips etc - // trying to process SVGs with Imagor leads to black boxes - return $normalizedImageUrl; - } - try { - return $this->buildImageUrl($asset, $originalImage, $normalizedImageUrl); - } catch (\Throwable $t) { - // We catch exceptions and errors here since we had an incident due to a null value in the DB. - // The resulting TypeError lead to an 500 error page. We do not want that. - // TODO - logging! - return ''; - } - } - - public function allowsCallOfMethod(string $methodName): bool - { - return true; - } - - /** - * @param string $sourceImage - * @return string - */ - private function normalizeUrl(string $sourceImage): string - { - if ($this->environment->getContext()->isDevelopment()) { - return str_ireplace('http://localhost:8081', $this->imagorSourceBaseUrl, $sourceImage); - } - return $sourceImage; - } - - private function buildImageUrl(AssetInterface $image, ImageInterface $originalImage, string $sourceUrl): string - { - $imagorBuilder = $this->asImagorPathBuilder($image, $originalImage); - return $this->imagorProxyBaseUrl . "/" . $imagorBuilder->build($sourceUrl); - } - - private function asImagorPathBuilder(AssetInterface $image, ImageInterface $originalImage): ImagorPathBuilder - { - $allowUpScaling = $this->getAllowUpScaling(); - $allowCropping = $this->getAllowCropping(); - $quality = $this->getQuality(); - $format = $this->getFormat(); - // TODO: preset = NULL - - $result = (new ImagorPathBuilder()) - ->secret($this->imagorSecret) - ->signerType($this->imagorSignerType) - ->signerTruncate($this->imagorSignerTruncate) - // (at time of writing) The following line increased the cache expiration in the HTTP response header to 7d. - // The actual time given is ignored (unfortunately) if it exceeds the Imagor service settings: - // -imagor-cache-header-ttl (defaults to 7d) and -imagor-cache-header-swr (defaults to 1d). - ->addFilter('expire', (time() + 31_536_000) * 1000) // TTL is 1y - ->addFilter('quality', $quality); - if (!$allowCropping) { - $result->fitIn(); - } - if (!empty($format)) { - $result->addFilter('format', $format); - } - // !!! despite the types of the getters, width and height might be null !!! - // The DB column is NULLABLE, see DimensionsTrait.php - $originalWidth = $originalImage->getWidth(); - $originalHeight = $originalImage->getHeight(); - $adapter = new ImagorPathBuilderImageInterfaceAdapter($result, $originalWidth, $originalHeight); - if ($image instanceof ImageVariant) { - foreach ($image->getAdjustments() as $adjustment) { - if ($adjustment instanceof AbstractImageAdjustment && $adjustment->canBeApplied($adapter)) { - $adjustment->applyToImage($adapter); - } - } - } - if ( - $allowUpScaling === false && - ($result->getResizeWidth() > $originalWidth || $result->getResizeHeight() > $originalHeight) - ) { - $result->resize(0, 0); - } - $this->limitToMaximalSize($image, $result); - - return $result; - } - - private function limitToMaximalSize(AssetInterface $image, ImagorPathBuilder $result): void - { - $originalWidth = $result->getResizeWidth() !== 0 ? $result->getResizeWidth() : $image->getWidth(); - $originalHeight = $result->getResizeHeight() !== 0 ? $result->getResizeHeight() : $image->getHeight(); - - if ($this->isTooWide($originalWidth) || $this->isTooHigh($originalHeight)) { - $width = $originalWidth; - $height = $originalHeight; - // TODO: what if $allowCropping - if ($this->isTooWide($width)) { - // here the limit cannot be null but Psalm does not realise it, hence the default value - $width = $this->getMaximumWidth() ?? 0; - $height = ($width / $originalHeight) * $originalHeight; - // by setting the height to 0 we keep the aspect ration - $result->resize($width, 0); - } - // too high since we did not limit width OR - // too high although we limited the width - if ($this->isTooHigh($height)) { - $result->resize(0, $height); - } - } - } - - private function isTooHigh(int $height): bool - { - $maximumHeight = $this->getMaximumHeight(); - return $maximumHeight !== null && $maximumHeight !== 0 && $height > $maximumHeight; - } - - private function isTooWide(int $width): bool - { - $maximumWidth = $this->getMaximumWidth(); - return $maximumWidth !== null && $maximumWidth !== 0 && $width > $maximumWidth; - } -} diff --git a/Neos.Media/Classes/Domain/Service/Imagor/ImagorResult.php b/Neos.Media/Classes/Domain/Service/Imagor/ImagorResult.php deleted file mode 100644 index d04f0e6c891..00000000000 --- a/Neos.Media/Classes/Domain/Service/Imagor/ImagorResult.php +++ /dev/null @@ -1,50 +0,0 @@ -builder) { - // Case for ImagorResult::empty(). - return null; - } - - - if ($offset === 'src') { - $this->builder->build(); - } - // TODO: Lazy width / height - return null; - // TODO: Implement offsetGet() method. - } - - public function offsetSet(mixed $offset, mixed $value): void - { - throw new \RuntimeException('writing not supported!'); - } - - public function offsetUnset(mixed $offset): void - { - throw new \RuntimeException('writing not supported!'); - } -} diff --git a/Neos.Media/Classes/Domain/Service/Imagor/ImagorService.php b/Neos.Media/Classes/Domain/Service/Imagor/ImagorService.php index ab8252a2ef3..0656944ce29 100644 --- a/Neos.Media/Classes/Domain/Service/Imagor/ImagorService.php +++ b/Neos.Media/Classes/Domain/Service/Imagor/ImagorService.php @@ -5,64 +5,50 @@ namespace Neos\Media\Domain\Service\Imagor; use Neos\Flow\Annotations as Flow; +use Neos\Flow\Http\Exception; use Neos\Flow\ResourceManagement\ResourceManager; use Neos\Media\Domain\Model\Adjustment\AbstractImageAdjustment; -use Neos\Media\Domain\Model\AssetInterface; use Neos\Media\Domain\Model\ImageInterface; use Neos\Media\Domain\Model\ImageVariant; use Neos\Media\Domain\Model\Thumbnail; use Neos\Media\Domain\Model\ThumbnailConfiguration; /** - * TODO - * * @Flow\Scope("singleton") */ class ImagorService { - - /** - * @Flow\InjectConfiguration("imagor.sourceBaseUrl") - * @var string - */ - protected string $imagorSourceBaseUrl; - - /** - * @Flow\InjectConfiguration("imagor.proxyBaseUrl") - * @var string - */ - protected string $imagorProxyBaseUrl; - /** * @Flow\InjectConfiguration("imagor.secret") - * @var string | null */ protected ?string $imagorSecret; /** * @Flow\InjectConfiguration("imagor.signerType") - * @var string | null */ protected ?string $imagorSignerType; /** * @Flow\InjectConfiguration("imagor.signerTruncate") - * @var int | null */ protected ?int $imagorSignerTruncate; /** * @Flow\Inject - * @var ResourceManager */ - protected $resourceManager; + protected ResourceManager $resourceManager; - public function getThumbnailUriAndSize(AssetInterface $asset, ThumbnailConfiguration $configuration): array + public function getThumbnailUriAndSize(ImageInterface $asset, ThumbnailConfiguration $configuration): array { - // TODO: URL ermitteln -> später auch S3, sonst local file + // TODO: URL ermitteln -> später auch S3, sonst local file -> damit direkt von der + // Festplatte gelesen werden kann + + // todo test with secret in config $originalImage = $asset; + // todo test with thumbnail if ($originalImage instanceof Thumbnail) { + // will return either Image (fine) or ImageVariant => need Image $originalImage = $asset->getOriginalAsset(); } // might happen that thumbnail -> imageVariant -> Image, that's why we do not do elseif but if (after thumbnail) @@ -73,26 +59,33 @@ public function getThumbnailUriAndSize(AssetInterface $asset, ThumbnailConfigura $imageUrl = $this->resourceManager->getPublicPersistentResourceUri($originalImage->getResource()); if ($imageUrl === '' || $imageUrl === false) { return []; - # return ImagorResult::empty(); } - $url = $this->buildImageUrl($asset, $configuration, $originalImage, $imageUrl); - - return [ - 'src' => $url, - ]; - } - - private function buildImageUrl(AssetInterface $image, ThumbnailConfiguration $configuration, ImageInterface $originalImage, string $sourceUrl): string - { - $imagorBuilder = $this->asImagorPathBuilder($image, $configuration, $originalImage); - $result = $imagorBuilder->build($sourceUrl); - return $this->imagorProxyBaseUrl . "/" . $result; + try { + // todo with and height calculate + return [ + 'src' => $this->createImagorPathBuilder( + $asset, + $configuration, + $originalImage, + $imageUrl + )->getSourceUrl(), + ]; + } catch (Exception) { + return []; + } } - private function asImagorPathBuilder(AssetInterface $image, ThumbnailConfiguration $configuration, ImageInterface $originalImage): ImagorPathBuilder - { - $result = (new ImagorPathBuilder()) + /** + * @throws Exception + */ + private function createImagorPathBuilder( + ImageInterface $image, + ThumbnailConfiguration $configuration, + ImageInterface $originalImage, + string $sourceImageUrl + ): ImagorPathBuilder { + $result = (new ImagorPathBuilder($sourceImageUrl)) ->secret($this->imagorSecret) ->signerType($this->imagorSignerType) ->signerTruncate($this->imagorSignerTruncate) @@ -105,16 +98,15 @@ private function asImagorPathBuilder(AssetInterface $image, ThumbnailConfigurati $result->addFilter('quality', $configuration->getQuality()); } - if (!$configuration->isCroppingAllowed()) { + if (! $configuration->isCroppingAllowed()) { $result->fitIn(); } if ($configuration->getFormat()) { $result->addFilter('format', $configuration->getFormat()); } - // !!! despite the types of the getters, width and height might be null !!! - // The DB column is NULLABLE, see DimensionsTrait.php - $originalWidth = $originalImage->getWidth(); - $originalHeight = $originalImage->getHeight(); + + $originalWidth = $originalImage->getWidth() ?? 0; + $originalHeight = $originalImage->getHeight() ?? 0; $adapter = new ImagorPathBuilderImageInterfaceAdapter($result, $originalWidth, $originalHeight); if ($image instanceof ImageVariant) { foreach ($image->getAdjustments() as $adjustment) { @@ -124,13 +116,57 @@ private function asImagorPathBuilder(AssetInterface $image, ThumbnailConfigurati } } if ( - $configuration->isUpScalingAllowed() === false && + ! $configuration->isUpScalingAllowed() && ($result->getResizeWidth() > $originalWidth || $result->getResizeHeight() > $originalHeight) ) { $result->resize(0, 0); } - // TODO: IMPLEMENT LATER: $this->limitToMaximalSize($image, $result); + + $this->limitToMaximalSize($configuration, $result, $originalWidth, $originalHeight); return $result; } + + private function limitToMaximalSize( + ThumbnailConfiguration $configuration, + ImagorPathBuilder $result, + int $originalWidth, + int $originalHeight, + ): void { + $actualWidth = $result->getResizeWidth() !== 0 ? $result->getResizeWidth() : $originalWidth; + $actualHeight = $result->getResizeHeight() !== 0 ? $result->getResizeHeight() : $originalHeight; + + if ($this->isTooWide($configuration, $actualWidth) || $this->isTooHigh($configuration, $actualHeight)) { + $width = $actualWidth; + $height = $actualHeight; + + // TODO: what if $allowCropping + if ($this->isTooWide($configuration, $width)) { + // here the limit cannot be null but Psalm does not realise it, hence the default value + $width = $configuration->getMaximumWidth() ?? 0; + $height = intval(round(($width / $actualHeight) * $actualHeight)); + // by setting the height to 0 we keep the aspect ration + $result->resize($width, 0); + } + // too high since we did not limit width OR + // too high although we limited the width + if ($this->isTooHigh($configuration, $height)) { + $result->resize(0, $height); + } + } + } + + private function isTooHigh(ThumbnailConfiguration $configuration, int $height): bool + { + $maximumHeight = $configuration->getMaximumHeight(); + + return $maximumHeight !== null && $maximumHeight !== 0 && $height > $maximumHeight; + } + + private function isTooWide(ThumbnailConfiguration $configuration, int $width): bool + { + $maximumWidth = $configuration->getMaximumWidth(); + + return $maximumWidth !== null && $maximumWidth !== 0 && $width > $maximumWidth; + } } diff --git a/Neos.Media/Classes/Domain/Service/Imagor/NotSupportedByImagor.php b/Neos.Media/Classes/Domain/Service/Imagor/NotSupportedByImagor.php deleted file mode 100644 index 50c662594f3..00000000000 --- a/Neos.Media/Classes/Domain/Service/Imagor/NotSupportedByImagor.php +++ /dev/null @@ -1,7 +0,0 @@ - use current port and host + this path + # proxyBaseUrl: '/imagor' + + # by default, we use imagor in unsecure mode, add shared secret for request signing + # see https://github.com/cshum/imagor?tab=readme-ov-file#security for details secret: ~ signerType: sha256 signerTruncate: 40 # Variant presets - variantPresets: [] + variantPresets: [ ] # Automatically create asset variants for configured presets when assets are added autoCreateImageVariantPresets: true diff --git a/Neos.Media/Tests/Unit/Domain/Service/ImagorServiceTest.php b/Neos.Media/Tests/Unit/Domain/Service/ImagorServiceTest.php new file mode 100644 index 00000000000..6c9e54c2317 --- /dev/null +++ b/Neos.Media/Tests/Unit/Domain/Service/ImagorServiceTest.php @@ -0,0 +1,42 @@ +imagorService = new ImagorService(); + + $this->thumbnailConfigurationMock = $this->createMock(ThumbnailConfiguration::class); + $this->imageMock = $this->createMock(ImageInterface::class); + } + + public function testGetThumbnailUriAndSize(): void + { + $mock = $this->createMock(Thumbnail::class); + + $mock->expects(self::once())->method('getOriginalAsset')->willReturn($this->imageMock); + + $this->imagorService->getThumbnailUriAndSize($mock, $this->thumbnailConfigurationMock); + } +}