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..75a78a872bf 100644 --- a/Neos.Media/Classes/Domain/Service/AssetService.php +++ b/Neos.Media/Classes/Domain/Service/AssetService.php @@ -33,11 +33,11 @@ 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; use Neos\Media\Exception\ThumbnailServiceException; -use Neos\RedirectHandler\Storage\RedirectStorageInterface; use Neos\Utility\Arrays; use Neos\Utility\MediaTypes; use Psr\Log\LoggerInterface; @@ -103,12 +103,22 @@ class AssetService */ protected $imageService; + /** + * @Flow\Inject + */ + protected ImagorService $imagorService; + /** * @Flow\Inject * @var AssetVariantGenerator */ protected $assetVariantGenerator; + /** + * @Flow\InjectConfiguration("imagor.enabled") + */ + protected bool $isImagorEnabled = false; + /** * Returns the repository for an asset * @@ -131,17 +141,21 @@ 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 { + if($asset instanceof ImageInterface && $this->isImagorEnabled) { + return $this->imagorService->getThumbnailUriAndSize($asset, $configuration); + } + $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..c48bb7ee220 --- /dev/null +++ b/Neos.Media/Classes/Domain/Service/Imagor/ImagorPathBuilder.php @@ -0,0 +1,368 @@ +sourceImageUrl = $this->replaceImageHostIfNecessary($this->sourceImageUrl); + } + + /** + * trim removes surrounding space in images using top-left pixel color + * + * @return $this + */ + public function trim(): self + { + $this->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 + { + // todo remove? also vAlign, etc all unused + $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; + } + + /** + * @throws Exception + */ + public function getSourceUrl(): 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($this->sourceImageUrl, '/'); + // 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->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'; + } + + $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 new file mode 100644 index 00000000000..b99b4982e92 --- /dev/null +++ b/Neos.Media/Classes/Domain/Service/Imagor/ImagorPathBuilderImageInterfaceAdapter.php @@ -0,0 +1,189 @@ +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 NotSupportedByImagorException(); + } + + public function save($path = null, array $options = []) + { + return $this; + } + + public function show($format, array $options = []) + { + 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 NotSupportedByImagorException(); + } + + public function fill(FillInterface $fill) + { + throw new NotSupportedByImagorException(); + } + + public function get($format, array $options = []) + { + throw new NotSupportedByImagorException(); + } + + public function draw() + { + throw new NotSupportedByImagorException(); + } + + public function effects() + { + throw new NotSupportedByImagorException(); + } + + public function getSize(): BoxInterface + { + return new Box($this->width, $this->height); + } + + public function mask() + { + throw new NotSupportedByImagorException(); + } + + public function histogram() + { + throw new NotSupportedByImagorException(); + } + + public function getColorAt(PointInterface $point) + { + throw new NotSupportedByImagorException(); + } + + public function layers() + { + throw new NotSupportedByImagorException(); + } + + public function interlace($scheme) + { + throw new NotSupportedByImagorException(); + } + + public function palette() + { + throw new NotSupportedByImagorException(); + } + + public function usePalette(PaletteInterface $palette) + { + throw new NotSupportedByImagorException(); + } + + public function profile(ProfileInterface $profile) + { + throw new NotSupportedByImagorException(); + } + + public function metadata() + { + throw new NotSupportedByImagorException(); + } + + public function __toString(): string + { + return "ImagorAdapter"; + } +} 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..0656944ce29 --- /dev/null +++ b/Neos.Media/Classes/Domain/Service/Imagor/ImagorService.php @@ -0,0 +1,172 @@ + 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) + if ($originalImage instanceof ImageVariant) { + $originalImage = $asset->getOriginalAsset(); + } + + $imageUrl = $this->resourceManager->getPublicPersistentResourceUri($originalImage->getResource()); + if ($imageUrl === '' || $imageUrl === false) { + return []; + } + + try { + // todo with and height calculate + return [ + 'src' => $this->createImagorPathBuilder( + $asset, + $configuration, + $originalImage, + $imageUrl + )->getSourceUrl(), + ]; + } catch (Exception) { + return []; + } + } + + /** + * @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) + // (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()); + } + + $originalWidth = $originalImage->getWidth() ?? 0; + $originalHeight = $originalImage->getHeight() ?? 0; + $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() && + ($result->getResizeWidth() > $originalWidth || $result->getResizeHeight() > $originalHeight) + ) { + $result->resize(0, 0); + } + + $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/NotSupportedByImagorException.php b/Neos.Media/Classes/Domain/Service/Imagor/NotSupportedByImagorException.php new file mode 100644 index 00000000000..1f1f10e5df1 --- /dev/null +++ b/Neos.Media/Classes/Domain/Service/Imagor/NotSupportedByImagorException.php @@ -0,0 +1,11 @@ + 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); + } +}