diff --git a/Neos.Media/Classes/Domain/Model/Adjustment/ImageDimensionCalculationHelperThingy.php b/Neos.Media/Classes/Domain/Model/Adjustment/ResizeDimensionCalculator.php similarity index 70% rename from Neos.Media/Classes/Domain/Model/Adjustment/ImageDimensionCalculationHelperThingy.php rename to Neos.Media/Classes/Domain/Model/Adjustment/ResizeDimensionCalculator.php index 50f84153fd5..3167860b68e 100644 --- a/Neos.Media/Classes/Domain/Model/Adjustment/ImageDimensionCalculationHelperThingy.php +++ b/Neos.Media/Classes/Domain/Model/Adjustment/ResizeDimensionCalculator.php @@ -14,10 +14,22 @@ */ 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 +/** + * Container for static methods to calculate the target dimensions for resizing images + * + * @see: ResizeImageAdjustment, ImageThumbnailGenerator(to calculte a preliminary crop for images with focal point), + * ThumbnailService(to calculate dimensions and focal points for async thumbnails) + * + * @internal + */ +class ResizeDimensionCalculator { /** * @param BoxInterface $originalDimensions @@ -173,7 +185,7 @@ protected static function calculateScalingToHeight(BoxInterface $originalDimensi * @param BoxInterface $requestedDimensions * @return BoxInterface */ - public static function calculateFinalDimensions(BoxInterface $imageSize, BoxInterface $requestedDimensions, string $ratioMode = ImageInterface::RATIOMODE_INSET): BoxInterface + public static function calculateOutboundScalingDimensions(BoxInterface $imageSize, BoxInterface $requestedDimensions, string $ratioMode = ImageInterface::RATIOMODE_INSET): BoxInterface { if ($ratioMode === ImageInterface::RATIOMODE_OUTBOUND) { $ratios = [ @@ -185,4 +197,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/ResizeImageAdjustment.php b/Neos.Media/Classes/Domain/Model/Adjustment/ResizeImageAdjustment.php index 3c07bf5868a..b966174d990 100644 --- a/Neos.Media/Classes/Domain/Model/Adjustment/ResizeImageAdjustment.php +++ b/Neos.Media/Classes/Domain/Model/Adjustment/ResizeImageAdjustment.php @@ -288,7 +288,7 @@ public function setAllowUpScaling(bool $allowUpScaling): void */ public function canBeApplied(ImagineImageInterface $image) { - $expectedDimensions = ImageDimensionCalculationHelperThingy::calculateRequestedDimensions( + $expectedDimensions = ResizeDimensionCalculator::calculateRequestedDimensions( $image->getSize(), $this->width, $this->height, @@ -323,7 +323,7 @@ public function applyToImage(ImagineImageInterface $image) */ protected function calculateDimensions(BoxInterface $originalDimensions): BoxInterface { - return ImageDimensionCalculationHelperThingy::calculateRequestedDimensions( + return ResizeDimensionCalculator::calculateRequestedDimensions( $originalDimensions, $this->width, $this->height, @@ -353,7 +353,7 @@ protected function resize(ImagineImageInterface $image, string $mode = ImageInte $originalDimensions = $image->getSize(); - $requestedDimensions = ImageDimensionCalculationHelperThingy::calculateRequestedDimensions( + $requestedDimensions = ResizeDimensionCalculator::calculateRequestedDimensions( $originalDimensions, $this->width, $this->height, @@ -363,7 +363,7 @@ protected function resize(ImagineImageInterface $image, string $mode = ImageInte $this->ratioMode ?? ImageInterface::RATIOMODE_INSET ); - $finalDimensions = ImageDimensionCalculationHelperThingy::calculateFinalDimensions( + $finalDimensions = ResizeDimensionCalculator::calculateOutboundScalingDimensions( $originalDimensions, $requestedDimensions, $this->ratioMode @@ -393,7 +393,7 @@ protected function resize(ImagineImageInterface $image, string $mode = ImageInte */ protected function calculateOutboundScalingDimensions(BoxInterface $imageSize, BoxInterface $requestedDimensions): BoxInterface { - return ImageDimensionCalculationHelperThingy::calculateFinalDimensions( + return ResizeDimensionCalculator::calculateOutboundScalingDimensions( $imageSize, $requestedDimensions, $this->ratioMode 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..dd29da26a6e --- /dev/null +++ b/Neos.Media/Classes/Domain/Model/Dto/PreliminaryCropSpecification.php @@ -0,0 +1,35 @@ +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..fbe9c2dc8d4 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\ResizeDimensionCalculator; +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,45 @@ public function refresh(Thumbnail $thumbnail) ) ]; + $asset = $thumbnail->getOriginalAsset(); + $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 = ResizeDimensionCalculator::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 = ResizeDimensionCalculator::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 + ); + } + $targetFormat = $thumbnail->getConfigurationValue('format'); $processedImageInfo = $this->imageService->processImage($thumbnail->getOriginalAsset()->getResource(), $adjustments, $targetFormat); @@ -87,6 +127,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..fc5717fe03d 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\ResizeDimensionCalculator; 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 ResizeDimensionCalculator + * @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 = ResizeDimensionCalculator::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 = ResizeDimensionCalculator::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/ResizeDimensionCalculatorTest.php similarity index 56% rename from Neos.Media/Tests/Unit/Domain/Model/Adjustment/ImageDimensionCalculationHelperThingyTest.php rename to Neos.Media/Tests/Unit/Domain/Model/Adjustment/ResizeDimensionCalculatorTest.php index afbfcbe0d4a..64f6c35bf5b 100644 --- a/Neos.Media/Tests/Unit/Domain/Model/Adjustment/ImageDimensionCalculationHelperThingyTest.php +++ b/Neos.Media/Tests/Unit/Domain/Model/Adjustment/ResizeDimensionCalculatorTest.php @@ -11,15 +11,18 @@ * source code. */ -use Neos\Media\Domain\Model\Adjustment\ImageDimensionCalculationHelperThingy; +use Imagine\Image\BoxInterface; +use Imagine\Image\Point; +use Imagine\Image\PointInterface; +use Neos\Media\Domain\Model\Adjustment\ResizeDimensionCalculator; use Neos\Media\Imagine\Box; use Neos\Flow\Tests\UnitTestCase; use Neos\Media\Domain\Model\ImageInterface; /** - * Test case for the ImageDimensionCalculationHelperThingy + * Test case for the ResizeDimensionCalculator */ -class ImageDimensionCalculationHelperThingyTest extends UnitTestCase +class ResizeDimensionCalculatorTest extends UnitTestCase { /** * @test @@ -31,7 +34,7 @@ public function widthAndHeightDeterminedByExplicitlySetWidthAndHeightWithInsetMo self::assertEquals( $expectedDimensions, - ImageDimensionCalculationHelperThingy::calculateRequestedDimensions( + ResizeDimensionCalculator::calculateRequestedDimensions( originalDimensions: $originalDimensions, width: 110, height: 110, @@ -49,7 +52,7 @@ public function widthAndHeightDeterminedByExplicitlySetWidthAndHeightWithOutboun self::assertEquals( $expectedDimensions, - ImageDimensionCalculationHelperThingy::calculateRequestedDimensions( + ResizeDimensionCalculator::calculateRequestedDimensions( originalDimensions: $originalDimensions, width: 110, height: 110, @@ -68,7 +71,7 @@ public function ifWidthIsSetHeightIsDeterminedByTheOriginalAspectRatio() self::assertEquals( $expectedDimensions, - ImageDimensionCalculationHelperThingy::calculateRequestedDimensions( + ResizeDimensionCalculator::calculateRequestedDimensions( originalDimensions: $originalDimensions, width: 110 ) @@ -85,7 +88,7 @@ public function ifHeightIsSetWidthIsDeterminedByTheOriginalAspectRatio() self::assertEquals( $expectedDimensions, - ImageDimensionCalculationHelperThingy::calculateRequestedDimensions( + ResizeDimensionCalculator::calculateRequestedDimensions( originalDimensions: $originalDimensions, height: 95 ) @@ -102,7 +105,7 @@ public function minimumHeightIsGreaterZero() self::assertEquals( $expectedDimensions, - ImageDimensionCalculationHelperThingy::calculateRequestedDimensions( + ResizeDimensionCalculator::calculateRequestedDimensions( originalDimensions: $originalDimensions, maximumWidth: 250, maximumHeight: 250, @@ -121,7 +124,7 @@ public function minimumWidthIsGreaterZero() self::assertEquals( $expectedDimensions, - ImageDimensionCalculationHelperThingy::calculateRequestedDimensions( + ResizeDimensionCalculator::calculateRequestedDimensions( originalDimensions: $originalDimensions, maximumWidth: 250, maximumHeight: 250, @@ -163,7 +166,7 @@ public function combinationsOfMaximumAndMinimumWidthAndHeightAreCalculatedCorrec self::assertEquals( $expectedDimensions, - ImageDimensionCalculationHelperThingy::calculateRequestedDimensions( + ResizeDimensionCalculator::calculateRequestedDimensions( originalDimensions: $originalDimensions, width: $width, height: $height, @@ -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 = ResizeDimensionCalculator::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()); + } }