From f85edf91686cecd0d19bfa1195d7f414552ecd38 Mon Sep 17 00:00:00 2001 From: David Buchmann Date: Wed, 19 Jun 2024 09:14:43 +0200 Subject: [PATCH] initial draft --- src/Domain/ImageReference.php | 12 ++ src/Domain/ImageReferenceFile.php | 8 + src/Domain/Imagine/Filter/FilterExecutor.php | 22 +++ src/Domain/ImagineTransformer.php | 53 +++++ src/Domain/LiipImagineTransformer.php | 54 +++++ src/Domain/PostProcessor/PostProcessor.php | 29 +++ src/Domain/Stack/LiipTransformerStack.php | 31 +++ .../Stack/LiipTransformerStackExecutor.php | 186 ++++++++++++++++++ src/Domain/Stack/TransformerStackExecutor.php | 15 ++ src/Domain/Storage/ImageLoader.php | 14 ++ src/Domain/Storage/ImageStore.php | 28 +++ 11 files changed, 452 insertions(+) create mode 100644 src/Domain/ImageReference.php create mode 100644 src/Domain/ImageReferenceFile.php create mode 100644 src/Domain/Imagine/Filter/FilterExecutor.php create mode 100644 src/Domain/ImagineTransformer.php create mode 100644 src/Domain/LiipImagineTransformer.php create mode 100644 src/Domain/PostProcessor/PostProcessor.php create mode 100644 src/Domain/Stack/LiipTransformerStack.php create mode 100644 src/Domain/Stack/LiipTransformerStackExecutor.php create mode 100644 src/Domain/Stack/TransformerStackExecutor.php create mode 100644 src/Domain/Storage/ImageLoader.php create mode 100644 src/Domain/Storage/ImageStore.php diff --git a/src/Domain/ImageReference.php b/src/Domain/ImageReference.php new file mode 100644 index 00000000..222a8f0f --- /dev/null +++ b/src/Domain/ImageReference.php @@ -0,0 +1,12 @@ +imageStore->supportsOnDemandCreation() + || $this->imageStore->exists($sourceImageId, $stackName, $targetFormat) + ) { + return $this->imageStore->getUrl($sourceImageId, $stackName, $targetFormat); + } + + $this->warmupCache($sourceImageId, [$stackName], [$targetFormat]); + + return $this->imageStore->getUrl($sourceImageId, $stackName, $targetFormat); + } + + public function warmupCache(string $sourceImageId, array $stackNames, array $targetFormats): void + { + if (0 === count($stackNames)) { + throw new \Exception('TODO: implement determining all stack names'); + } + $sourceImage = $this->sourceImageLoader->loadImage($sourceImageId); + foreach ($stackNames as $stackName) { + foreach ($targetFormats as $targetFormat) { + // TODO: if we would separate stack executor creation and execution, we could build the stack only once and apply it for each target format + $transformedImage = $this->transformerStackExecutor->apply($stackName, $sourceImage); + $this->imageStore->store($transformedImage, $sourceImageId, $stackName, $targetFormat); + } + } + } + + public function invalidateCache(string $sourceImageId, array $stackNames = []): void + { + $this->imageStore->delete($sourceImageId, $stackNames); + } +} diff --git a/src/Domain/PostProcessor/PostProcessor.php b/src/Domain/PostProcessor/PostProcessor.php new file mode 100644 index 00000000..3deb2d01 --- /dev/null +++ b/src/Domain/PostProcessor/PostProcessor.php @@ -0,0 +1,29 @@ + + */ +interface PostProcessor +{ + /** + * Allows processing a BinaryInterface, with run-time options, so PostProcessors remain stateless. + * + * @param array $options Operation-specific options + */ + public function process(ImageReference $binary, array $options = []): ImageReference; +} diff --git a/src/Domain/Stack/LiipTransformerStack.php b/src/Domain/Stack/LiipTransformerStack.php new file mode 100644 index 00000000..5d8373e0 --- /dev/null +++ b/src/Domain/Stack/LiipTransformerStack.php @@ -0,0 +1,31 @@ +filters as $filter) { + $image = $filter->applyTo($image); + } + + foreach ($this->postProcessors as $postProcessor) { + $image = $postProcessor->process($image); + } + + return $image; + } +} diff --git a/src/Domain/Stack/LiipTransformerStackExecutor.php b/src/Domain/Stack/LiipTransformerStackExecutor.php new file mode 100644 index 00000000..d82c9400 --- /dev/null +++ b/src/Domain/Stack/LiipTransformerStackExecutor.php @@ -0,0 +1,186 @@ + + */ + private array $filters = []; + + /** + * @var array + */ + private array $postProcessors = []; + + public function __construct( + private FilterConfiguration $filterConfiguration, + private ImagineInterface $imagine, + private MimeTypeGuesserInterface $mimeTypeGuesser, + ) {} + + /** + * Adds a loader to handle the given filter. + */ + public function addFilter(string $filterName, FilterExecutor $executor): void + { + $this->filters[$filterName] = $executor; + } + + /** + * Adds a post-processor to handle binaries. + */ + public function addPostProcessor(string $name, PostProcessor $postProcessor): void + { + $this->postProcessors[$name] = $postProcessor; + } + + /** + * Apply the stack to the image. + */ + public function apply(string $stackName, ImageReference $sourceImageReference): ImageReference + { + $config = $this->filterConfiguration->get($stackName); + + $config += [ + 'quality' => 100, + 'animated' => false, + ]; + + if ($sourceImageReference instanceof ImageReferenceFile) { + $image = $this->imagine->open($sourceImageReference->getPath()); + } else { + $image = $this->imagine->load($sourceImageReference->getContent()); + } + + $image = $this->applyFilters($image, $config); + $resultImageReference = $this->exportConfiguredImageBinary($sourceImageReference, $image, $config); + + return $this->applyPostProcessors($resultImageReference, $config); + } + + public function applyFilters(ImageInterface $image, array $config): ImageInterface + { + foreach ($this->sanitizeFilters($config['filters'] ?? []) as $name => $options) { + $prior = $image; + $image = $this->filters[$name]->load($image, $options); + + if ($prior !== $image) { + $this->destroyImage($prior); + } + } + + return $image; + } + + public function applyPostProcessors(ImageReference $image, array $config): ImageReference + { + foreach ($this->sanitizePostProcessors($config['post_processors'] ?? []) as $name => $options) { + $image = $this->postProcessors[$name]->process($image, $options); + } + + return $image; + } + + private function exportConfiguredImageBinary(ImageReference $imageReference, ImageInterface $image, array $config): ImageReference + { + // TODO: this is for now literal copy-paste from FilterManager + $options = [ + 'quality' => $config['quality'], + ]; + + if (\array_key_exists('jpeg_quality', $config)) { + $options['jpeg_quality'] = $config['jpeg_quality']; + } + if (\array_key_exists('png_compression_level', $config)) { + $options['png_compression_level'] = $config['png_compression_level']; + } + if (\array_key_exists('png_compression_filter', $config)) { + $options['png_compression_filter'] = $config['png_compression_filter']; + } + + if ('gif' === $imageReference->getFormat() && $config['animated']) { + $options['animated'] = $config['animated']; + } + + $filteredFormat = $config['format'] ?? $imageReference->getFormat(); + try { + $filteredString = $image->get($filteredFormat, $options); + } catch (\Exception $exception) { + // TODO: why only a problem for webp but not png/jpg? and should the stack handle this + // we don't support converting an animated gif into webp. + // we can't efficiently check the input data, therefore we retry with target format gif in case of an error. + if ('webp' !== $filteredFormat || !\array_key_exists('animated', $options) || true !== $options['animated']) { + throw $exception; + } + $filteredFormat = 'gif'; + $filteredString = $image->get($filteredFormat, $options); + } + + $this->destroyImage($image); + + return new ImageReferenceInstance( // TODO + $filteredString, + $filteredFormat === $imageReference->getFormat() ? $imageReference->getMimeType() : $this->mimeTypeGuesser->guess($filteredString), + $filteredFormat + ); + } + + /** + * Report all non-existing filters from the configuration. + * + * This is better than a simple array_key_exists check, which would report the issues only one at a time. + */ + private function sanitizeFilters(array $filters): array + { + // TODO: is this much better than something like? + // if ($missing = array_diff(array_keys($filters), array_keys($this->filters))) { throw new \InvalidArgumentException('missing'.implode(',', $missing)); } + + $sanitized = array_filter($filters, function (string $name): bool { + return \array_key_exists($name, $this->filters); + }, ARRAY_FILTER_USE_KEY); + + if (\count($filters) !== \count($sanitized)) { + throw new \InvalidArgumentException(sprintf('Could not find filter(s): %s', implode(', ', array_map(function (string $name): string { return sprintf('"%s"', $name); }, array_diff(array_keys($filters), array_keys($sanitized)))))); + } + + return $sanitized; + } + + /** + * Report all non-existing post processors from the configuration. + */ + private function sanitizePostProcessors(array $processors): array + { + $sanitized = array_filter($processors, function (string $name): bool { + return \array_key_exists($name, $this->postProcessors); + }, ARRAY_FILTER_USE_KEY); + + if (\count($processors) !== \count($sanitized)) { + throw new \InvalidArgumentException(sprintf('Could not find post processor(s): %s', implode(', ', array_map(function (string $name): string { return sprintf('"%s"', $name); }, array_diff(array_keys($processors), array_keys($sanitized)))))); + } + + return $sanitized; + } + + /** + * We are done with the image object so we can destruct the this because imagick keeps consuming memory if we don't. + * See https://github.com/liip/LiipImagineBundle/pull/682 + */ + private function destroyImage(ImageInterface $image): void + { + if (method_exists($image, '__destruct')) { + $image->__destruct(); + } + } +} diff --git a/src/Domain/Stack/TransformerStackExecutor.php b/src/Domain/Stack/TransformerStackExecutor.php new file mode 100644 index 00000000..6ae2bbca --- /dev/null +++ b/src/Domain/Stack/TransformerStackExecutor.php @@ -0,0 +1,15 @@ +