diff --git a/Neos.Media/Classes/Domain/Model/Adjustment/ImageDimensionCalculationHelperThingy.php b/Neos.Media/Classes/Domain/Model/Adjustment/ImageDimensionCalculationHelperThingy.php index 50f84153fd5..5ae7851115a 100644 --- a/Neos.Media/Classes/Domain/Model/Adjustment/ImageDimensionCalculationHelperThingy.php +++ b/Neos.Media/Classes/Domain/Model/Adjustment/ImageDimensionCalculationHelperThingy.php @@ -14,7 +14,11 @@ */ use Imagine\Image\BoxInterface; +use Imagine\Image\Point; +use Imagine\Image\PointInterface; +use Neos\Media\Domain\Model\Dto\PreliminaryCropSpecification; use Neos\Media\Domain\Model\ImageInterface; +use Neos\Media\Domain\ValueObject\Configuration\AspectRatio; use Neos\Media\Imagine\Box; class ImageDimensionCalculationHelperThingy @@ -185,4 +189,48 @@ public static function calculateFinalDimensions(BoxInterface $imageSize, BoxInte } return $requestedDimensions; } + + public static function calculatePreliminaryCropSpecification( + BoxInterface $originalDimensions, + PointInterface $originalFocalPoint, + BoxInterface $requestedDimensions, + ): PreliminaryCropSpecification { + $originalAspect = new AspectRatio($originalDimensions->getWidth(), $originalDimensions->getHeight()); + $finalAspect = new AspectRatio($requestedDimensions->getWidth(), $requestedDimensions->getHeight()); + + if ($originalAspect->getRatio() >= $finalAspect->getRatio()) { + // leading dimension = height, width is cropped + $factor = $requestedDimensions->getHeight() / $originalDimensions->getHeight(); + $cropBox = new \Imagine\Image\Box((int)$requestedDimensions->getWidth() / $factor, $requestedDimensions->getHeight() / $factor); + $cropX = $originalFocalPoint->getX() - (int)($cropBox->getWidth() / 2); + $cropXMax = $originalDimensions->getWidth() - $cropBox->getWidth(); + if ($cropX < 0) { + $cropX = 0; + } elseif ($cropX > $cropXMax) { + $cropX = $cropXMax; + } + $cropOffset = new Point($cropX, 0); + } else { + // leading dimension = width, height is cropped + $factor = $requestedDimensions->getWidth() / $originalDimensions->getWidth(); + $cropBox = new Box((int)$requestedDimensions->getWidth() / $factor, $requestedDimensions->getHeight() / $factor); + $cropY = $originalFocalPoint->getY() - (int)($cropBox->getHeight() / 2); + $cropYMax = $originalDimensions->getHeight() - $cropBox->getHeight(); + if ($cropY < 0) { + $cropY = 0; + } elseif ($cropY > $cropYMax) { + $cropY = $cropYMax; + } + $cropOffset = new Point(0, $cropY); + } + + return new PreliminaryCropSpecification( + $cropOffset, + $cropBox, + new Point( + (int)round(($originalFocalPoint->getX() - $cropOffset->getX()) * $factor), + (int)round(($originalFocalPoint->getY() - $cropOffset->getY()) * $factor) + ) + ); + } } diff --git a/Neos.Media/Classes/Domain/Model/Adjustment/MarkPointAdjustment.php b/Neos.Media/Classes/Domain/Model/Adjustment/MarkPointAdjustment.php new file mode 100644 index 00000000000..0c48e691b9e --- /dev/null +++ b/Neos.Media/Classes/Domain/Model/Adjustment/MarkPointAdjustment.php @@ -0,0 +1,94 @@ +x = $x; + } + + public function setY(int $y): void + { + $this->y = $y; + } + + public function setRadius(int $radius): void + { + $this->radius = $radius; + } + + public function setThickness(int $thickness): void + { + $this->thickness = $thickness; + } + + public function setColor(string $color): void + { + $this->color = $color; + } + + + public function applyToImage(ImagineImageInterface $image) + { + $palette = new Palette\RGB(); + $color = $palette->color($this->color); + $image->draw() + ->circle( + new Point($this->x, $this->y), + $this->radius, + $color, + false, + $this->thickness + ) + ; + + return $image; + } + + public function canBeApplied(ImagineImageInterface $image) + { + if (is_null($this->x) || is_null($this->y) || is_null($this->radius)) { + return false; + } + return true; + } +} diff --git a/Neos.Media/Classes/Domain/Model/Dto/PreliminaryCropSpecification.php b/Neos.Media/Classes/Domain/Model/Dto/PreliminaryCropSpecification.php new file mode 100644 index 00000000000..49b3a97e175 --- /dev/null +++ b/Neos.Media/Classes/Domain/Model/Dto/PreliminaryCropSpecification.php @@ -0,0 +1,27 @@ +focalPointY = $y; } + + public function hasFocalPoint(): bool + { + if ($this->focalPointX !== null && $this->focalPointY !== null) { + return true; + } + return false; + } + + public function getFocalPoint(): ?PointInterface + { + if ($this->hasFocalPoint()) { + return new Point($this->focalPointX, $this->focalPointY); + } + return null; + } } diff --git a/Neos.Media/Classes/Domain/Model/ThumbnailGenerator/ImageThumbnailGenerator.php b/Neos.Media/Classes/Domain/Model/ThumbnailGenerator/ImageThumbnailGenerator.php index 692646a9be6..8e8f8710fb8 100644 --- a/Neos.Media/Classes/Domain/Model/ThumbnailGenerator/ImageThumbnailGenerator.php +++ b/Neos.Media/Classes/Domain/Model/ThumbnailGenerator/ImageThumbnailGenerator.php @@ -12,12 +12,18 @@ */ use Neos\Flow\Annotations as Flow; +use Neos\Media\Domain\Model\Adjustment\CropImageAdjustment; +use Neos\Media\Domain\Model\Adjustment\ImageDimensionCalculationHelperThingy; +use Neos\Media\Domain\Model\Adjustment\MarkPointAdjustment; use Neos\Media\Domain\Model\Adjustment\QualityImageAdjustment; use Neos\Media\Domain\Model\Adjustment\ResizeImageAdjustment; +use Neos\Media\Domain\Model\Dto\PreliminaryCropSpecification; +use Neos\Media\Domain\Model\FocalPointSupportInterface; use Neos\Media\Domain\Model\ImageInterface; use Neos\Media\Domain\Model\Thumbnail; use Neos\Media\Domain\Service\ImageService; use Neos\Media\Exception; +use Neos\Media\Imagine\Box; /** * A system-generated preview version of an Image @@ -57,11 +63,6 @@ public function canRefresh(Thumbnail $thumbnail) public function refresh(Thumbnail $thumbnail) { try { - /** - * @todo ... add additional crop to ensure that the focal point is in view - * in view after resizing ... needs common understanding wit - * the thumbnail service here: Packages/Neos/Neos.Media/Classes/Domain/Service/ThumbnailService.php:151 - */ $adjustments = [ new ResizeImageAdjustment( [ @@ -80,6 +81,61 @@ public function refresh(Thumbnail $thumbnail) ) ]; + $asset = $thumbnail->getOriginalAsset(); + /** + * @var $preliminaryCropSpecification PreliminaryCropSpecification|null + */ + $preliminaryCropSpecification = null; + if ($asset instanceof FocalPointSupportInterface && $asset->hasFocalPoint()) { + // in case we have a focal point we calculate the target dimension and add an + // additional crop to ensure that the focal point stays inside the final image + + $originalFocalPoint = $asset->getFocalPoint(); + $originalDimensions = new Box($asset->getWidth(), $asset->getHeight()); + $requestedDimensions = ImageDimensionCalculationHelperThingy::calculateRequestedDimensions( + originalDimensions: $originalDimensions, + width: $thumbnail->getConfigurationValue('width'), + maximumWidth: $thumbnail->getConfigurationValue('maximumWidth'), + height: $thumbnail->getConfigurationValue('height'), + maximumHeight: $thumbnail->getConfigurationValue('maximumHeight'), + ratioMode: $thumbnail->getConfigurationValue('ratioMode'), + allowUpScaling: $thumbnail->getConfigurationValue('allowUpScaling'), + ); + + $preliminaryCropSpecification = ImageDimensionCalculationHelperThingy::calculatePreliminaryCropSpecification( + originalDimensions: $originalDimensions, + originalFocalPoint: $originalFocalPoint, + requestedDimensions: $requestedDimensions, + ); + + $adjustments = array_merge( + [ + new CropImageAdjustment( + [ + 'x' => $preliminaryCropSpecification->cropOffset->getX(), + 'y' => $preliminaryCropSpecification->cropOffset->getY(), + 'width' => $preliminaryCropSpecification->cropDimensions->getWidth(), + 'height' => $preliminaryCropSpecification->cropDimensions->getHeight(), + ] + ) + ], + $adjustments, + [ + // this is for debugging purposes only + // @todo remove before merging + new MarkPointAdjustment( + [ + 'x' => $preliminaryCropSpecification['focalPoint']->getX(), + 'y' => $preliminaryCropSpecification['focalPoint']->getY(), + 'radius' => 5, + 'color' => '#0f0', + 'thickness' => 4 + ] + ), + ] + ); + } + $targetFormat = $thumbnail->getConfigurationValue('format'); $processedImageInfo = $this->imageService->processImage($thumbnail->getOriginalAsset()->getResource(), $adjustments, $targetFormat); @@ -87,6 +143,11 @@ public function refresh(Thumbnail $thumbnail) $thumbnail->setWidth($processedImageInfo['width']); $thumbnail->setHeight($processedImageInfo['height']); $thumbnail->setQuality($processedImageInfo['quality']); + + if ($preliminaryCropSpecification instanceof PreliminaryCropSpecification) { + $thumbnail->setFocalPointX($preliminaryCropSpecification->focalPoint->getX()); + $thumbnail->setFocalPointY($preliminaryCropSpecification->focalPoint->getY()); + } } catch (\Exception $exception) { $message = sprintf('Unable to generate thumbnail for the given image (filename: %s, SHA1: %s)', $thumbnail->getOriginalAsset()->getResource()->getFilename(), $thumbnail->getOriginalAsset()->getResource()->getSha1()); throw new Exception\NoThumbnailAvailableException($message, 1433109654, $exception); diff --git a/Neos.Media/Classes/Domain/Service/ThumbnailService.php b/Neos.Media/Classes/Domain/Service/ThumbnailService.php index 55e5a141bd9..b758cc7ed54 100644 --- a/Neos.Media/Classes/Domain/Service/ThumbnailService.php +++ b/Neos.Media/Classes/Domain/Service/ThumbnailService.php @@ -18,6 +18,7 @@ use Neos\Flow\Persistence\Exception\IllegalObjectTypeException; use Neos\Flow\Persistence\PersistenceManagerInterface; use Neos\Flow\ResourceManagement\ResourceManager; +use Neos\Media\Domain\Model\Adjustment\ImageDimensionCalculationHelperThingy; use Neos\Media\Domain\Model\AssetInterface; use Neos\Media\Domain\Model\FocalPointSupportInterface; use Neos\Media\Domain\Model\ImageInterface; @@ -25,6 +26,7 @@ use Neos\Media\Domain\Model\ThumbnailConfiguration; use Neos\Media\Domain\Repository\ThumbnailRepository; use Neos\Media\Exception\ThumbnailServiceException; +use Neos\Media\Imagine\Box; use Neos\Utility\Arrays; use Neos\Utility\MediaTypes; use Psr\Log\LoggerInterface; @@ -85,6 +87,12 @@ class ThumbnailService */ protected $throwableStorage; + /** + * @var ImageDimensionCalculationHelperThingy + * @Flow\Inject + */ + protected $imageDimensionCalculationHelperThingy; + /** * Returns a thumbnail of the given asset * @@ -148,15 +156,37 @@ public function getThumbnail(AssetInterface $asset, ThumbnailConfiguration $conf if ($thumbnail === null) { $thumbnail = new Thumbnail($asset, $configuration); - if ($asset instanceof FocalPointSupportInterface) { - // @todo: needs common understanding of dimension change with resize adjustment - // - if a focal point was set - // - calculate target dimensions here - // - calculate new focalPointAfter transformation - // - store focal point in new image - // has to work closely with: Packages/Neos/Neos.Media/Classes/Domain/Model/ThumbnailGenerator/ImageThumbnailGenerator.php:58 - $thumbnail->setFocalPointX($asset->getFocalPointX() ? $asset->getFocalPointX() + 1 : null); - $thumbnail->setFocalPointY($asset->getFocalPointY() ? $asset->getFocalPointY() + 1 : null); + // predict dimensions async image thumbnails, this is not needed for immediately calculated images as those + // values are stored again after calculating + if ($async === true && $asset instanceof ImageInterface) { + $originalDimensions = new Box($asset->getWidth(), $asset->getHeight()); + + $requestedDimensions = ImageDimensionCalculationHelperThingy::calculateRequestedDimensions( + originalDimensions: $originalDimensions, + width: $configuration->getWidth(), + maximumWidth: $configuration->getMaximumWidth(), + height: $configuration->getHeight(), + maximumHeight: $configuration->getMaximumHeight(), + ratioMode: $configuration->getRatioMode(), + allowUpScaling: $configuration->isUpScalingAllowed() + ); + + $thumbnail->setWidth($requestedDimensions->getWidth()); + $thumbnail->setHeight($requestedDimensions->getHeight()); + + // calculate focal point for new thumbnails + if ($asset instanceof FocalPointSupportInterface && $asset->hasFocalPoint()) { + $originalFocalPoint = $asset->getFocalPoint(); + + $preliminaryCropSpecification = ImageDimensionCalculationHelperThingy::calculatePreliminaryCropSpecification( + originalDimensions: $originalDimensions, + originalFocalPoint: $originalFocalPoint, + requestedDimensions: $requestedDimensions, + ); + + $thumbnail->setFocalPointX($preliminaryCropSpecification->focalPoint->getX()); + $thumbnail->setFocalPointY($preliminaryCropSpecification->focalPoint->getY()); + } } // If the thumbnail strategy failed to generate a valid thumbnail diff --git a/Neos.Media/Tests/Unit/Domain/Model/Adjustment/ImageDimensionCalculationHelperThingyTest.php b/Neos.Media/Tests/Unit/Domain/Model/Adjustment/ImageDimensionCalculationHelperThingyTest.php index afbfcbe0d4a..7a54301b60b 100644 --- a/Neos.Media/Tests/Unit/Domain/Model/Adjustment/ImageDimensionCalculationHelperThingyTest.php +++ b/Neos.Media/Tests/Unit/Domain/Model/Adjustment/ImageDimensionCalculationHelperThingyTest.php @@ -11,6 +11,9 @@ * source code. */ +use Imagine\Image\BoxInterface; +use Imagine\Image\Point; +use Imagine\Image\PointInterface; use Neos\Media\Domain\Model\Adjustment\ImageDimensionCalculationHelperThingy; use Neos\Media\Imagine\Box; use Neos\Flow\Tests\UnitTestCase; @@ -174,4 +177,115 @@ public function combinationsOfMaximumAndMinimumWidthAndHeightAreCalculatedCorrec ) ); } + + public static function calculateCropConfigurationCentersFocalPointDataProvider(): \Generator + { + yield 'square to square' => [ + new \Imagine\Image\Box(400, 400), + new Point(200, 200), + new Box(200, 200), + + new Point(0, 0), + new Box(400, 400), + new Point(100, 100), + ]; + + yield 'portrait to portrait' => [ + new Box(800, 400), + new Point(400, 200), + new Box(400, 200), + + new Point(0, 0), + new Box(800, 400), + new Point(200, 100), + ]; + + yield 'portrait to square fp left' => [ + new Box(800, 400), + new Point(50, 200), + new Box(400, 400), + + new Point(0, 0), + new Box(400, 400), + new Point(50, 200), + ]; + + yield 'portrait to square fp center' => [ + new Box(800, 400), + new Point(400, 200), + new Box(400, 400), + + new Point(200, 0), + new Box(400, 400), + new Point(200, 200), + ]; + + yield 'portrait to square fp right' => [ + new Box(800, 400), + new Point(700, 100), + new Box(400, 400), + + new Point(400, 0), + new Box(400, 400), + new Point(300, 100), + ]; + + yield 'landscape to square fp center' => [ + new Box(400, 800), + new Point(200, 400), + new Box(400, 400), + + new Point(0, 200), + new Box(400, 400), + new Point(200, 200), + ]; + + yield 'landscape to square fp top' => [ + new Box(400, 800), + new Point(350, 50), + new Box(400, 400), + + new Point(0, 0), + new Box(400, 400), + new Point(350, 50), + ]; + + yield 'landscape to square fp bottom' => [ + new Box(400, 800), + new Point(300, 750), + new Box(200, 200), + + new Point(0, 400), + new Box(400, 400), + new Point(150, 175), + ]; + } + + /** + * @dataProvider calculateCropConfigurationCentersFocalPointDataProvider + * @test + */ + public function calculateCropConfigurationCentersFocalPoint( + BoxInterface $originalDimensions, + PointInterface $originalFocalPoint, + BoxInterface $requestedDimensions, + PointInterface $expectedCropOffset, + BoxInterface $expectedCropDimensions, + PointInterface $expectedCroppedFocalPoint + ): void { + $preliminaryCropSpecification = ImageDimensionCalculationHelperThingy::calculatePreliminaryCropSpecification( + $originalDimensions, + $originalFocalPoint, + $requestedDimensions + ); + + $this->assertEquals($expectedCropOffset->getX(), $preliminaryCropSpecification->cropOffset->getX()); + $this->assertEquals($expectedCropOffset->getY(), $preliminaryCropSpecification->cropOffset->getY()); + + $this->assertEquals($expectedCropDimensions->getWidth(), $preliminaryCropSpecification->cropDimensions->getWidth()); + $this->assertEquals($expectedCropDimensions->getWidth(), $preliminaryCropSpecification->cropDimensions->getWidth()); + + $this->assertEquals($expectedCroppedFocalPoint->getX(), $preliminaryCropSpecification->focalPoint->getX()); + $this->assertEquals($expectedCroppedFocalPoint->getY(), $preliminaryCropSpecification->focalPoint->getY()); + } }