diff --git a/databox/api/config/services.yaml b/databox/api/config/services.yaml index 4c13a3c69..b00efb993 100644 --- a/databox/api/config/services.yaml +++ b/databox/api/config/services.yaml @@ -131,3 +131,5 @@ services: $decorated: '@.inner' Alchemy\RenditionFactory\Templating\TemplateResolverInterface: '@App\Asset\Attribute\TemplateResolver' + + App\Validator\ValidRenditionDefinitionConstraintValidator: ~ diff --git a/databox/api/src/Command/DocumentationDumperCommand.php b/databox/api/src/Command/DocumentationDumperCommand.php new file mode 100644 index 000000000..32aded42d --- /dev/null +++ b/databox/api/src/Command/DocumentationDumperCommand.php @@ -0,0 +1,31 @@ +writeln('# ' . $this->renditionBuilderConfigurationDocumentation::getName()); + $output->writeln($this->renditionBuilderConfigurationDocumentation->generate()); + + return Command::SUCCESS; + } +} diff --git a/databox/api/src/Entity/Core/RenditionDefinition.php b/databox/api/src/Entity/Core/RenditionDefinition.php index e0231b57c..d0df77b6b 100644 --- a/databox/api/src/Entity/Core/RenditionDefinition.php +++ b/databox/api/src/Entity/Core/RenditionDefinition.php @@ -5,6 +5,8 @@ namespace App\Entity\Core; use Alchemy\CoreBundle\Entity\AbstractUuidEntity; +use Alchemy\CoreBundle\Entity\Traits\CreatedAtTrait; +use Alchemy\CoreBundle\Entity\Traits\UpdatedAtTrait; use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Delete; @@ -16,10 +18,8 @@ use App\Api\Model\Input\RenditionDefinitionInput; use App\Api\Provider\RenditionDefinitionCollectionProvider; use App\Controller\Core\RenditionDefinitionSortAction; - -use Alchemy\CoreBundle\Entity\Traits\CreatedAtTrait; -use Alchemy\CoreBundle\Entity\Traits\UpdatedAtTrait; use App\Entity\Traits\WorkspaceTrait; +use App\Validator as CustomAssert; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection as DoctrineCollection; use Doctrine\DBAL\Types\Types; @@ -153,6 +153,7 @@ class RenditionDefinition extends AbstractUuidEntity implements \Stringable #[Groups([RenditionDefinition::GROUP_LIST, RenditionDefinition::GROUP_READ, RenditionDefinition::GROUP_WRITE])] #[ORM\Column(type: Types::TEXT, nullable: true)] #[ApiProperty(security: self::GRANT_ADMIN_PROP)] + #[CustomAssert\ValidRenditionDefinitionConstraint] private ?string $definition = null; #[Groups([RenditionDefinition::GROUP_READ])] diff --git a/databox/api/src/Validator/ValidRenditionDefinitionConstraint.php b/databox/api/src/Validator/ValidRenditionDefinitionConstraint.php new file mode 100644 index 000000000..ecf01a68c --- /dev/null +++ b/databox/api/src/Validator/ValidRenditionDefinitionConstraint.php @@ -0,0 +1,17 @@ +yamlLoader->parse($value); + $this->validator->validate($config); + } catch (\Exception $e) { + $this->context + ->buildViolation($e->getMessage()) + ->addViolation(); + } + } +} diff --git a/lib/php/rendition-factory-bundle/Resources/config/services.yaml b/lib/php/rendition-factory-bundle/Resources/config/services.yaml index 16dbfd75c..b52b3d83b 100644 --- a/lib/php/rendition-factory-bundle/Resources/config/services.yaml +++ b/lib/php/rendition-factory-bundle/Resources/config/services.yaml @@ -4,6 +4,7 @@ services: autoconfigure: true Alchemy\RenditionFactory\Command\CreateCommand: ~ + Alchemy\RenditionFactory\Command\ConfigurationValidateCommand: ~ Alchemy\RenditionFactory\Context\TransformationContextFactory: ~ Alchemy\RenditionFactory\FileFamilyGuesser: ~ @@ -44,43 +45,56 @@ services: tags: - { name: !php/const Alchemy\RenditionFactory\Transformer\TransformerModuleInterface::TAG } - # FFMpeg "formats" - Alchemy\RenditionFactory\Transformer\Video\FFMpeg\Format\JpegFormat: + # Output "formats" + Alchemy\RenditionFactory\Transformer\Video\Format\JpegFormat: tags: - - { name: !php/const Alchemy\RenditionFactory\Transformer\Video\FFMpeg\Format\FormatInterface::TAG } + - { name: !php/const Alchemy\RenditionFactory\Transformer\Video\Format\FormatInterface::TAG } - Alchemy\RenditionFactory\Transformer\Video\FFMpeg\Format\MkvFormat: + Alchemy\RenditionFactory\Transformer\Video\Format\MkvFormat: tags: - - { name: !php/const Alchemy\RenditionFactory\Transformer\Video\FFMpeg\Format\FormatInterface::TAG } + - { name: !php/const Alchemy\RenditionFactory\Transformer\Video\Format\FormatInterface::TAG } - Alchemy\RenditionFactory\Transformer\Video\FFMpeg\Format\Mpeg4Format: + Alchemy\RenditionFactory\Transformer\Video\Format\Mpeg4Format: tags: - - { name: !php/const Alchemy\RenditionFactory\Transformer\Video\FFMpeg\Format\FormatInterface::TAG } + - { name: !php/const Alchemy\RenditionFactory\Transformer\Video\Format\FormatInterface::TAG } - Alchemy\RenditionFactory\Transformer\Video\FFMpeg\Format\MpegFormat: + Alchemy\RenditionFactory\Transformer\Video\Format\MpegFormat: tags: - - { name: !php/const Alchemy\RenditionFactory\Transformer\Video\FFMpeg\Format\FormatInterface::TAG } + - { name: !php/const Alchemy\RenditionFactory\Transformer\Video\Format\FormatInterface::TAG } - Alchemy\RenditionFactory\Transformer\Video\FFMpeg\Format\QuicktimeFormat: + Alchemy\RenditionFactory\Transformer\Video\Format\QuicktimeFormat: tags: - - { name: !php/const Alchemy\RenditionFactory\Transformer\Video\FFMpeg\Format\FormatInterface::TAG } + - { name: !php/const Alchemy\RenditionFactory\Transformer\Video\Format\FormatInterface::TAG } - Alchemy\RenditionFactory\Transformer\Video\FFMpeg\Format\WebmFormat: + Alchemy\RenditionFactory\Transformer\Video\Format\WebmFormat: tags: - - { name: !php/const Alchemy\RenditionFactory\Transformer\Video\FFMpeg\Format\FormatInterface::TAG } + - { name: !php/const Alchemy\RenditionFactory\Transformer\Video\Format\FormatInterface::TAG } - Alchemy\RenditionFactory\Transformer\Video\FFMpeg\Format\AnimatedGifFormat: + Alchemy\RenditionFactory\Transformer\Video\Format\AnimatedGifFormat: tags: - - { name: !php/const Alchemy\RenditionFactory\Transformer\Video\FFMpeg\Format\FormatInterface::TAG } + - { name: !php/const Alchemy\RenditionFactory\Transformer\Video\Format\FormatInterface::TAG } - Alchemy\RenditionFactory\Transformer\Video\FFMpeg\Format\AnimatedPngFormat: + Alchemy\RenditionFactory\Transformer\Video\Format\AnimatedPngFormat: tags: - - { name: !php/const Alchemy\RenditionFactory\Transformer\Video\FFMpeg\Format\FormatInterface::TAG } + - { name: !php/const Alchemy\RenditionFactory\Transformer\Video\Format\FormatInterface::TAG } - Alchemy\RenditionFactory\Transformer\Video\FFMpeg\Format\AnimatedWebpFormat: + Alchemy\RenditionFactory\Transformer\Video\Format\AnimatedWebpFormat: tags: - - { name: !php/const Alchemy\RenditionFactory\Transformer\Video\FFMpeg\Format\FormatInterface::TAG } + - { name: !php/const Alchemy\RenditionFactory\Transformer\Video\Format\FormatInterface::TAG } + Alchemy\RenditionFactory\Transformer\Video\Format\WavFormat: + tags: + - { name: !php/const Alchemy\RenditionFactory\Transformer\Video\Format\FormatInterface::TAG } + + Alchemy\RenditionFactory\Transformer\Video\Format\AacFormat: + tags: + - { name: !php/const Alchemy\RenditionFactory\Transformer\Video\Format\FormatInterface::TAG } + + Alchemy\RenditionFactory\Transformer\Video\Format\Mp3Format: + tags: + - { name: !php/const Alchemy\RenditionFactory\Transformer\Video\Format\FormatInterface::TAG } + + Alchemy\RenditionFactory\Transformer\Video\Format\OutputFormatsDocumentation: ~ Imagine\Imagick\Imagine: ~ Imagine\Image\ImagineInterface: '@Imagine\Imagick\Imagine' @@ -89,3 +103,7 @@ services: Alchemy\RenditionFactory\Format\FormatGuesser: ~ Alchemy\RenditionFactory\Format\FormatFactory: ~ Alchemy\RenditionFactory\Config\ModuleOptionsResolver: ~ + + Alchemy\RenditionFactory\RenditionBuilderConfigurationDocumentation: ~ + + Alchemy\RenditionFactory\Config\BuildConfigValidator: ~ diff --git a/lib/php/rendition-factory/src/Command/ConfigurationValidateCommand.php b/lib/php/rendition-factory/src/Command/ConfigurationValidateCommand.php new file mode 100644 index 000000000..b52b425bf --- /dev/null +++ b/lib/php/rendition-factory/src/Command/ConfigurationValidateCommand.php @@ -0,0 +1,43 @@ +addArgument('config', InputArgument::REQUIRED, 'A build config YAML file to validate') + ->setHelp('Validate a config file.') + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $config = $this->yamlLoader->load($input->getArgument('config')); + $this->validator->validate($config); + + $output->writeln('Configuration is valid.'); + + return Command::SUCCESS; + } +} diff --git a/lib/php/rendition-factory/src/Command/CreateCommand.php b/lib/php/rendition-factory/src/Command/CreateCommand.php index 2317a9ff1..2e88ffbc8 100644 --- a/lib/php/rendition-factory/src/Command/CreateCommand.php +++ b/lib/php/rendition-factory/src/Command/CreateCommand.php @@ -45,34 +45,38 @@ protected function configure(): void protected function execute(InputInterface $input, OutputInterface $output): int { - $ret = 0; + $ret = Command::SUCCESS; $src = $input->getArgument('src'); if (is_dir($src)) { if (false === ($od = opendir($src))) { $output->writeln(sprintf('Directory "%s" could not be opened.', $src)); - return 1; + return Command::FAILURE; } while ($f = readdir($od)) { if ('.' === $f || '..' === $f) { continue; } - $ret |= $this->doFile($input, $output, $src.'/'.$f); + if(false === $this->doFile($input, $output, $src.'/'.$f)) { + $ret = Command::FAILURE; + } } closedir($od); } else { - $ret = $this->doFile($input, $output, $src); + if(false === $this->doFile($input, $output, $src)) { + $ret = Command::FAILURE; + } } return $ret; } - protected function doFile(InputInterface $input, OutputInterface $output, string $src): int + protected function doFile(InputInterface $input, OutputInterface $output, string $src): bool { if (!file_exists($src)) { $output->writeln(sprintf('File "%s" does not exist.', $src)); - return 1; + return false; } $time = microtime(true); @@ -104,7 +108,7 @@ protected function doFile(InputInterface $input, OutputInterface $output, string } catch (\InvalidArgumentException $e) { $output->writeln(sprintf('%s', $e->getMessage())); - return 1; + return false; } if ($outputPath = $input->getOption('output')) { @@ -121,7 +125,7 @@ protected function doFile(InputInterface $input, OutputInterface $output, string if ($src === $outputFile->getPath()) { $output->writeln('No transformation needed'); - return 1; + return false; } if (!$input->getOption('debug')) { @@ -130,6 +134,6 @@ protected function doFile(InputInterface $input, OutputInterface $output, string $output->writeln(sprintf('Execution time: %0.2f', microtime(true) - $time)); - return 0; + return true; } } diff --git a/lib/php/rendition-factory/src/Config/BuildConfigValidator.php b/lib/php/rendition-factory/src/Config/BuildConfigValidator.php new file mode 100644 index 000000000..fb9acdbf2 --- /dev/null +++ b/lib/php/rendition-factory/src/Config/BuildConfigValidator.php @@ -0,0 +1,57 @@ +transformers; + } + + public function validate(BuildConfig $config): void + { + foreach (FamilyEnum::cases() as $family) { + $familyConfig = $config->getFamily($family); + if (null === $familyConfig) { + continue; + } + foreach ($familyConfig->getTransformations() as $transformation) { + $transformerName = $transformation->getModule(); + + /** @var TransformerModuleInterface $transformer */ + $transformer = $this->transformers->get($transformerName); + + try { + $this->checkTransformerConfiguration($transformer, $transformation->toArray()); + } catch (\Throwable $e) { + $msg = sprintf("Error in module \"%s\"\n%s", $transformerName, $e->getMessage()); + throw new InvalidConfigurationException($msg); + } + } + } + } + + private function checkTransformerConfiguration(TransformerModuleInterface $transformer, array $options): void + { + $documentation = $transformer->getDocumentation(); + $treeBuilder = $documentation->getTreeBuilder(); + + $processor = new Processor(); + $processor->process($treeBuilder->buildTree(), ['root' => $options]); + } +} diff --git a/lib/php/rendition-factory/src/Config/YamlLoader.php b/lib/php/rendition-factory/src/Config/YamlLoader.php index 372df8806..bed285f97 100644 --- a/lib/php/rendition-factory/src/Config/YamlLoader.php +++ b/lib/php/rendition-factory/src/Config/YamlLoader.php @@ -60,9 +60,7 @@ private function parseFamilyConfig(array $data): FamilyBuildConfig $transformations = []; foreach ($data['transformations'] as $transformation) { - if ($transformation['enabled'] ?? true) { - $transformations[] = $this->parseTransformation($transformation); - } + $transformations[] = $this->parseTransformation($transformation); } return new FamilyBuildConfig($transformations, $data['normalization'] ?? []); @@ -72,6 +70,7 @@ private function parseTransformation(array $transformation): Transformation { return new Transformation( $transformation['module'], + $transformation['enabled'] ?? true, $transformation['options'] ?? [], $transformation['description'] ?? null ); diff --git a/lib/php/rendition-factory/src/DTO/BuildConfig/FamilyBuildConfig.php b/lib/php/rendition-factory/src/DTO/BuildConfig/FamilyBuildConfig.php index 9d4293d0b..68829b05a 100644 --- a/lib/php/rendition-factory/src/DTO/BuildConfig/FamilyBuildConfig.php +++ b/lib/php/rendition-factory/src/DTO/BuildConfig/FamilyBuildConfig.php @@ -17,6 +17,13 @@ public function getTransformations(): array return $this->transformations; } + public function getEnabledTransformations(): array + { + return array_filter($this->transformations, function (Transformation $transformation) { + return $transformation->isEnabled(); + }); + } + public function getNormalization(): array { return $this->normalization; diff --git a/lib/php/rendition-factory/src/DTO/BuildConfig/Transformation.php b/lib/php/rendition-factory/src/DTO/BuildConfig/Transformation.php index e4c8b30ce..90fca1916 100644 --- a/lib/php/rendition-factory/src/DTO/BuildConfig/Transformation.php +++ b/lib/php/rendition-factory/src/DTO/BuildConfig/Transformation.php @@ -6,6 +6,7 @@ { public function __construct( private string $module, + private bool $enabled, private array $options, private ?string $description, ) { @@ -25,4 +26,19 @@ public function getOptions(): array { return $this->options; } + + public function isEnabled(): bool + { + return $this->enabled; + } + + public function toArray(): array + { + return [ + 'module' => $this->module, + 'enabled' => $this->enabled, + 'options' => $this->options, + 'description' => $this->description, + ]; + } } diff --git a/lib/php/rendition-factory/src/RenditionBuilderConfigurationDocumentation.php b/lib/php/rendition-factory/src/RenditionBuilderConfigurationDocumentation.php new file mode 100644 index 000000000..5ff25d4fc --- /dev/null +++ b/lib/php/rendition-factory/src/RenditionBuilderConfigurationDocumentation.php @@ -0,0 +1,71 @@ +transformers->getProvidedServices() as $transformerName => $transformerFqcn) { + /** @var TransformerModuleInterface $transformer */ + $transformer = $this->transformers->get($transformerName); + $text .= $this->getTransformerDocumentation($transformerName, $transformer); + } + + return $text; + } + + private function getTransformerDocumentation(string $transformerName, TransformerModuleInterface $transformer): string + { + $docToText = function (Documentation $documentation, int $depth = 0) use (&$docToText): string { + + $text = ''; + if ($t = $documentation->getHeader()) { + $text .= $t."\n"; + } + + $treeBuilder = $documentation->getTreeBuilder(); + $node = $treeBuilder->buildTree(); + $dumper = new YamlReferenceDumper(); + + $t = $dumper->dumpNode($node); + $t = preg_replace("#^root:($|(\s+)\[]$)#m", "-\n", (string) $t); + $t = preg_replace("#\n+#", "\n", $t); + $t = trim($t); + + $text .= "```yaml\n".$t."\n```\n"; + + if ($t = $documentation->getFooter()) { + $text .= $t."\n"; + } + + foreach ($documentation->getChildren() as $child) { + $text .= $docToText($child, $depth + 1); + } + + return $text; + }; + + $documentation = $transformer->getDocumentation(); + + return "## `$transformerName` transformer module\n".$docToText($documentation); + } +} diff --git a/lib/php/rendition-factory/src/RenditionCreator.php b/lib/php/rendition-factory/src/RenditionCreator.php index 86cea27ef..179a6d879 100644 --- a/lib/php/rendition-factory/src/RenditionCreator.php +++ b/lib/php/rendition-factory/src/RenditionCreator.php @@ -43,7 +43,7 @@ public function createRendition( NoBuildConfigException::throwNoFamily($inputFile->getFamily()->value, $mimeType); } - $transformations = $familyBuildConfig->getTransformations(); + $transformations = $familyBuildConfig->getEnabledTransformations(); if (empty($transformations)) { NoBuildConfigException::throwNoTransformation($inputFile->getFamily()->value, $mimeType); } @@ -98,7 +98,7 @@ public function buildHashesDiffer( return true; } - $transformations = $familyBuildConfig->getTransformations(); + $transformations = $familyBuildConfig->getEnabledTransformations(); if (empty($transformations)) { return true; } diff --git a/lib/php/rendition-factory/src/Transformer/Document/DocumentToPdfTransformerModule.php b/lib/php/rendition-factory/src/Transformer/Document/DocumentToPdfTransformerModule.php index e1546d0ea..fb8932ba4 100644 --- a/lib/php/rendition-factory/src/Transformer/Document/DocumentToPdfTransformerModule.php +++ b/lib/php/rendition-factory/src/Transformer/Document/DocumentToPdfTransformerModule.php @@ -8,7 +8,10 @@ use Alchemy\RenditionFactory\DTO\OutputFile; use Alchemy\RenditionFactory\DTO\OutputFileInterface; use Alchemy\RenditionFactory\Transformer\Document\Libreoffice\PdfConverter; +use Alchemy\RenditionFactory\Transformer\Documentation; +use Alchemy\RenditionFactory\Transformer\TransformerConfigHelper; use Alchemy\RenditionFactory\Transformer\TransformerModuleInterface; +use Symfony\Component\Config\Definition\Builder\NodeBuilder; final readonly class DocumentToPdfTransformerModule implements TransformerModuleInterface { @@ -17,6 +20,30 @@ public static function getName(): string return 'document_to_pdf'; } + public function getDocumentation(): Documentation + { + $treeBuilder = TransformerConfigHelper::createBaseTree(self::getName()); + $this->buildConfiguration($treeBuilder->getRootNode()->children()); + + return new Documentation( + $treeBuilder, + <<
arrayNode('options') + ->ignoreExtraKeys(false) + ->end() + ; + // @formatter:on + } + public function transform(InputFileInterface $inputFile, array $options, TransformationContextInterface $context): OutputFileInterface { if ('application/pdf' === $inputFile->getType()) { diff --git a/lib/php/rendition-factory/src/Transformer/Document/PdfToImageTransformerModule.php b/lib/php/rendition-factory/src/Transformer/Document/PdfToImageTransformerModule.php index 0447d9c16..bc03b9013 100644 --- a/lib/php/rendition-factory/src/Transformer/Document/PdfToImageTransformerModule.php +++ b/lib/php/rendition-factory/src/Transformer/Document/PdfToImageTransformerModule.php @@ -7,9 +7,12 @@ use Alchemy\RenditionFactory\DTO\InputFileInterface; use Alchemy\RenditionFactory\DTO\OutputFile; use Alchemy\RenditionFactory\DTO\OutputFileInterface; +use Alchemy\RenditionFactory\Transformer\Documentation; +use Alchemy\RenditionFactory\Transformer\TransformerConfigHelper; use Alchemy\RenditionFactory\Transformer\TransformerModuleInterface; use Spatie\PdfToImage\Enums\OutputFormat; use Spatie\PdfToImage\Pdf; +use Symfony\Component\Config\Definition\Builder\NodeBuilder; final readonly class PdfToImageTransformerModule implements TransformerModuleInterface { @@ -18,6 +21,30 @@ public static function getName(): string return 'pdf_to_image'; } + public function getDocumentation(): Documentation + { + $treeBuilder = TransformerConfigHelper::createBaseTree(self::getName()); + $this->buildConfiguration($treeBuilder->getRootNode()->children()); + + return new Documentation( + $treeBuilder, + <<
arrayNode('options') + ->ignoreExtraKeys(false) + ->end() + ; + // @formatter:on + } + public function transform(InputFileInterface $inputFile, array $options, TransformationContextInterface $context): OutputFileInterface { if ('application/pdf' !== $inputFile->getType()) { diff --git a/lib/php/rendition-factory/src/Transformer/Documentation.php b/lib/php/rendition-factory/src/Transformer/Documentation.php new file mode 100644 index 000000000..68a2fe920 --- /dev/null +++ b/lib/php/rendition-factory/src/Transformer/Documentation.php @@ -0,0 +1,41 @@ +children = []; + } + + public function addChild(Documentation $child): void + { + $this->children[] = $child; + } + + public function getTreeBuilder(): TreeBuilder + { + return $this->treeBuilder; + } + + public function getChildren(): array + { + return $this->children; + } + + public function getHeader(): string + { + return $this->header; + } + + public function getFooter(): string + { + return $this->footer; + } +} diff --git a/lib/php/rendition-factory/src/Transformer/Image/Imagine/ImagineTransformerModule.php b/lib/php/rendition-factory/src/Transformer/Image/Imagine/ImagineTransformerModule.php index e7722e3ef..b5679e802 100644 --- a/lib/php/rendition-factory/src/Transformer/Image/Imagine/ImagineTransformerModule.php +++ b/lib/php/rendition-factory/src/Transformer/Image/Imagine/ImagineTransformerModule.php @@ -9,8 +9,11 @@ use Alchemy\RenditionFactory\DTO\OutputFileInterface; use Alchemy\RenditionFactory\MimeType\ImageFormatGuesser; use Alchemy\RenditionFactory\Transformer\BuildHashDiffInterface; +use Alchemy\RenditionFactory\Transformer\Documentation; +use Alchemy\RenditionFactory\Transformer\TransformerConfigHelper; use Alchemy\RenditionFactory\Transformer\TransformerModuleInterface; use Liip\ImagineBundle\Model\FileBinary; +use Symfony\Component\Config\Definition\Builder\NodeBuilder; final readonly class ImagineTransformerModule implements TransformerModuleInterface, BuildHashDiffInterface { @@ -24,6 +27,30 @@ public static function getName(): string return 'imagine'; } + public function getDocumentation(): Documentation + { + $treeBuilder = TransformerConfigHelper::createBaseTree(self::getName()); + $this->buildConfiguration($treeBuilder->getRootNode()->children()); + + return new Documentation( + $treeBuilder, + <<
arrayNode('options') + ->ignoreExtraKeys(false) + ->end() + ; + // @formatter:on + } + public function transform(InputFileInterface $inputFile, array $options, TransformationContextInterface $context): OutputFileInterface { $inputFormat = ImageFormatGuesser::getFormat($inputFile->getType()); diff --git a/lib/php/rendition-factory/src/Transformer/TransformerConfigHelper.php b/lib/php/rendition-factory/src/Transformer/TransformerConfigHelper.php new file mode 100644 index 000000000..d70de0192 --- /dev/null +++ b/lib/php/rendition-factory/src/Transformer/TransformerConfigHelper.php @@ -0,0 +1,36 @@ +getRootNode(); + // @formatter:off + $rootNode + ->children() + ->scalarNode('module') + ->isRequired() + ->defaultValue($name) + ->end() + ->scalarNode('description') + ->info('Description of the module action') + ->end() + ->scalarNode('enabled') + ->defaultTrue() + ->info('Whether to enable this module') + ->end() + ->end() + ; + // @formatter:on + + return $treeBuilder; + } +} diff --git a/lib/php/rendition-factory/src/Transformer/TransformerModuleInterface.php b/lib/php/rendition-factory/src/Transformer/TransformerModuleInterface.php index 2966c982b..97d703bf3 100644 --- a/lib/php/rendition-factory/src/Transformer/TransformerModuleInterface.php +++ b/lib/php/rendition-factory/src/Transformer/TransformerModuleInterface.php @@ -13,4 +13,6 @@ interface TransformerModuleInterface public static function getName(): string; public function transform(InputFileInterface $inputFile, array $options, TransformationContextInterface $context): OutputFileInterface; + + public function getDocumentation(): Documentation; } diff --git a/lib/php/rendition-factory/src/Transformer/Video/AbstractVideoTransformer.php b/lib/php/rendition-factory/src/Transformer/Video/AbstractVideoTransformer.php deleted file mode 100644 index bc55d1c62..000000000 --- a/lib/php/rendition-factory/src/Transformer/Video/AbstractVideoTransformer.php +++ /dev/null @@ -1,16 +0,0 @@ -priority; + } + + public function getDimension(): Dimension + { + return $this->dimension; + } + + public function getMode(): string + { + return $this->mode; + } + + public function areStandardsForced(): bool + { + return $this->forceStandards; + } + + public function apply(Video $video, VideoInterface $format): array + { + $rotation = 0; + + try { + $command = [ + '-loglevel', 'error', + '-select_streams', 'v:0', + '-print_format', 'json', + '-show_entries', 'stream_side_data=rotation', + '-i', $video->getPathfile(), + ]; + $r = json_decode($video->getFFProbe()->getFFProbeDriver()->command($command), true, 16, JSON_THROW_ON_ERROR); + $rotation = (int) $r['streams'][0]['side_data_list'][0]['rotation']; + } catch (\Exception $e) { + // ignore (failed to get orientation) + } + + $dimensions = null; + $commands = []; + + foreach ($video->getStreams() as $stream) { + if ($stream->isVideo()) { + try { + $dimensions = $stream->getDimensions(); + if (90 === $rotation || -90 === $rotation) { + $dimensions = new Dimension($dimensions->getHeight(), $dimensions->getWidth()); + } + break; + } catch (RuntimeException $e) { + } + } + } + + if (null !== $dimensions) { + $dimensions = $this->getComputedDimensions($dimensions, $format->getModulus()); + + // Using Filter to have ordering + $commands[] = '-vf'; + $commands[] = '[in]scale='.$dimensions->getWidth().':'.$dimensions->getHeight().' [out]'; + } + + return $commands; + } + + private function getComputedDimensions(Dimension $dimension, $modulus): Dimension + { + $originalRatio = $dimension->getRatio($this->forceStandards); + switch ($this->mode) { + case self::RESIZEMODE_SCALE_WIDTH: + $height = $this->dimension->getHeight(); + $width = $originalRatio->calculateWidth($height, $modulus); + break; + case self::RESIZEMODE_SCALE_HEIGHT: + $width = $this->dimension->getWidth(); + $height = $originalRatio->calculateHeight($width, $modulus); + break; + case self::RESIZEMODE_INSET: + $targetRatio = $this->dimension->getRatio($this->forceStandards); + + if ($targetRatio->getValue() > $originalRatio->getValue()) { + $height = $this->dimension->getHeight(); + $width = $originalRatio->calculateWidth($height, $modulus); + } else { + $width = $this->dimension->getWidth(); + $height = $originalRatio->calculateHeight($width, $modulus); + } + break; + case self::RESIZEMODE_FIT: + default: + $width = $this->dimension->getWidth(); + $height = $this->dimension->getHeight(); + break; + } + + return new Dimension($width, $height); + } +} diff --git a/lib/php/rendition-factory/src/Transformer/Video/FFMpegHelper.php b/lib/php/rendition-factory/src/Transformer/Video/FFMpegHelper.php index c232e1bc6..225209b59 100644 --- a/lib/php/rendition-factory/src/Transformer/Video/FFMpegHelper.php +++ b/lib/php/rendition-factory/src/Transformer/Video/FFMpegHelper.php @@ -3,6 +3,7 @@ namespace Alchemy\RenditionFactory\Transformer\Video; use FFMpeg; +use FFMpeg\Coordinate\TimeCode; class FFMpegHelper { @@ -39,9 +40,35 @@ public static function coordAsText(array $coord): string { $s = []; foreach ($coord as $k => $v) { - $s[] = sprintf('%s=%d', $k, $v); + $s[] = sprintf('%s=%s', $k, $v); } return '['.implode(', ', $s).']'; } + + public static function optionAsTimecode($value): ?TimeCode + { + if (is_numeric($value) && $value >= 0.0) { + return TimeCode::fromSeconds($value); + } elseif (is_string($value)) { + return TimeCode::fromString($value); + } + + return null; + } + + public static function timecodeToseconds(TimeCode $timecode): float + { + if (preg_match('/^[0-9]+:[0-9]+:[0-9]+\.[0-9]+$/', (string) $timecode)) { + [$hours, $minutes, $seconds, $frames] = sscanf($timecode, '%d:%d:%d.%d'); + } + $s = 0.0; + + $s += $hours * 60 * 60; + $s += $minutes * 60; + $s += $seconds; + $s += $frames / 100; + + return $s; + } } diff --git a/lib/php/rendition-factory/src/Transformer/Video/FFMpegTransformerModule.php b/lib/php/rendition-factory/src/Transformer/Video/FFMpegTransformerModule.php index 854d593e7..f3a2c2396 100644 --- a/lib/php/rendition-factory/src/Transformer/Video/FFMpegTransformerModule.php +++ b/lib/php/rendition-factory/src/Transformer/Video/FFMpegTransformerModule.php @@ -2,25 +2,358 @@ namespace Alchemy\RenditionFactory\Transformer\Video; +use Alchemy\RenditionFactory\Config\ModuleOptionsResolver; use Alchemy\RenditionFactory\Context\TransformationContextInterface; use Alchemy\RenditionFactory\DTO\FamilyEnum; use Alchemy\RenditionFactory\DTO\InputFileInterface; use Alchemy\RenditionFactory\DTO\OutputFile; use Alchemy\RenditionFactory\DTO\OutputFileInterface; +use Alchemy\RenditionFactory\Transformer\Documentation; +use Alchemy\RenditionFactory\Transformer\TransformerConfigHelper; use Alchemy\RenditionFactory\Transformer\TransformerModuleInterface; +use Alchemy\RenditionFactory\Transformer\Video\FFMpeg\Filter\ResizeFilter; +use Alchemy\RenditionFactory\Transformer\Video\Format\FormatInterface; +use Alchemy\RenditionFactory\Transformer\Video\Format\MkvFormat; +use Alchemy\RenditionFactory\Transformer\Video\Format\Mp3Format; +use Alchemy\RenditionFactory\Transformer\Video\Format\Mpeg4Format; +use Alchemy\RenditionFactory\Transformer\Video\Format\MpegFormat; +use Alchemy\RenditionFactory\Transformer\Video\Format\OutputFormatsDocumentation; +use Alchemy\RenditionFactory\Transformer\Video\Format\QuicktimeFormat; +use Alchemy\RenditionFactory\Transformer\Video\Format\WavFormat; +use Alchemy\RenditionFactory\Transformer\Video\Format\WebmFormat; use FFMpeg; use FFMpeg\Coordinate\TimeCode; use FFMpeg\Format\FormatInterface as FFMpegFormatInterface; use FFMpeg\Media\Clip; use FFMpeg\Media\Video; - -final readonly class FFMpegTransformerModule extends AbstractVideoTransformer implements TransformerModuleInterface +use Imagine\Image\ImagineInterface; +use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition; +use Symfony\Component\Config\Definition\Builder\NodeBuilder; +use Symfony\Component\Config\Definition\Builder\TreeBuilder; +use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException; +use Symfony\Component\Config\Definition\Processor; +use Symfony\Component\DependencyInjection\Attribute\AutowireLocator; +use Symfony\Component\DependencyInjection\ServiceLocator; + +final readonly class FFMpegTransformerModule implements TransformerModuleInterface { + public function __construct(#[AutowireLocator(FormatInterface::TAG, defaultIndexMethod: 'getFormat')] private ServiceLocator $formats, + private ModuleOptionsResolver $optionsResolver, + private ImagineInterface $imagine, + private OutputFormatsDocumentation $outputFormatsDocumentation, + ) { + } + public static function getName(): string { return 'ffmpeg'; } + private static function getSupportedOutputFormats(): array + { + return [ + MkvFormat::getFormat(), + Mpeg4Format::getFormat(), + MpegFormat::getFormat(), + QuicktimeFormat::getFormat(), + WebmFormat::getFormat(), + WavFormat::getFormat(), + Mp3Format::getFormat(), + ]; + } + + public function getDocumentation(): Documentation + { + $treeBuilder = TransformerConfigHelper::createBaseTree(self::getName()); + $this->buildConfiguration($treeBuilder->getRootNode()->children()); + $doc = new Documentation( + $treeBuilder, + <<
outputFormatsDocumentation->listFormats(self::getSupportedOutputFormats()). + <<