From bb8e4195bdcbee75945759db1960ef5b9893811b Mon Sep 17 00:00:00 2001 From: jygaulier Date: Thu, 14 Nov 2024 16:47:07 +0100 Subject: [PATCH 1/2] WIP DO NOT MERGE - auto doc WIP - more "twigable" options + factorization - almost every "time" options supports float (2.5) or timecode ("00.00.02.50") - video-summary module can generate "phraseanet" animated gif - period sets the gap between frames from the _input video_, e.g. `"{{ input.duration/10 }}"` -> 10 frames extracted. - duration sets the duration in seconds of each frame into the _output animated gif_, e.g. `0.1` -> 10 frames / seconds. - video-summary module supports `start` - ffmpeg module can extract audio: set `format: "audio-wav"` or `audio-mp3`, `audio-aac` ; todo: audio options - ffmpeg module / filter "watermark" supports `path: ` - bypass ffmpeg bugs (timecode / seconds) --- .../Resources/config/services.yaml | 13 + .../src/Command/ConfigCommand.php | 169 +++++++++ .../Video/AbstractVideoTransformer.php | 75 ++++ .../Video/FFMpeg/Format/AacFormat.php | 41 ++ .../Video/FFMpeg/Format/Audio/Aac.php | 25 ++ .../Video/FFMpeg/Format/Mp3Format.php | 41 ++ .../Video/FFMpeg/Format/WavFormat.php | 41 ++ .../src/Transformer/Video/FFMpegHelper.php | 29 +- .../Video/FFMpegTransformerModule.php | 353 +++++++++++++++--- .../Video/VideoSummaryTransformerModule.php | 184 ++++++--- .../VideoToAnimationTransformerModule.php | 89 ++++- .../Video/VideoToFrameTransformerModule.php | 38 +- 12 files changed, 982 insertions(+), 116 deletions(-) create mode 100644 lib/php/rendition-factory/src/Command/ConfigCommand.php create mode 100644 lib/php/rendition-factory/src/Transformer/Video/FFMpeg/Format/AacFormat.php create mode 100644 lib/php/rendition-factory/src/Transformer/Video/FFMpeg/Format/Audio/Aac.php create mode 100644 lib/php/rendition-factory/src/Transformer/Video/FFMpeg/Format/Mp3Format.php create mode 100644 lib/php/rendition-factory/src/Transformer/Video/FFMpeg/Format/WavFormat.php diff --git a/lib/php/rendition-factory-bundle/Resources/config/services.yaml b/lib/php/rendition-factory-bundle/Resources/config/services.yaml index 16dbfd75c..a0d27ad61 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\ConfigCommand: ~ Alchemy\RenditionFactory\Context\TransformationContextFactory: ~ Alchemy\RenditionFactory\FileFamilyGuesser: ~ @@ -81,6 +82,18 @@ services: tags: - { name: !php/const Alchemy\RenditionFactory\Transformer\Video\FFMpeg\Format\FormatInterface::TAG } + Alchemy\RenditionFactory\Transformer\Video\FFMpeg\Format\WavFormat: + tags: + - { name: !php/const Alchemy\RenditionFactory\Transformer\Video\FFMpeg\Format\FormatInterface::TAG } + + Alchemy\RenditionFactory\Transformer\Video\FFMpeg\Format\AacFormat: + tags: + - { name: !php/const Alchemy\RenditionFactory\Transformer\Video\FFMpeg\Format\FormatInterface::TAG } + + Alchemy\RenditionFactory\Transformer\Video\FFMpeg\Format\Mp3Format: + tags: + - { name: !php/const Alchemy\RenditionFactory\Transformer\Video\FFMpeg\Format\FormatInterface::TAG } + Imagine\Imagick\Imagine: ~ Imagine\Image\ImagineInterface: '@Imagine\Imagick\Imagine' diff --git a/lib/php/rendition-factory/src/Command/ConfigCommand.php b/lib/php/rendition-factory/src/Command/ConfigCommand.php new file mode 100644 index 000000000..7ebc66b90 --- /dev/null +++ b/lib/php/rendition-factory/src/Command/ConfigCommand.php @@ -0,0 +1,169 @@ +addArgument('build-config', InputArgument::OPTIONAL, 'A build config YAML file to validate') + ->addOption('module', 'm', InputOption::VALUE_REQUIRED, 'Display optiond for a specific module') + ->setHelp('Display the options for a module, or validate a build-config YAML file.') + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + if ($transformerName = $input->getOption('module')) { + /** @var TransformerModuleInterface $transformer */ + $transformer = $this->transformers->get($transformerName); + $output->writeln($this->getTransformerDocumentation($transformerName, $transformer)); + } + + if ($buildConfigPath = $input->getArgument('build-config')) { + $buildConfig = $this->yamlLoader->load($buildConfigPath); + + foreach (FamilyEnum::cases() as $family) { + $familyConfig = $buildConfig->getFamily($family); + if (null === $familyConfig) { + continue; + } + + $output->writeln(sprintf('Family "%s":', $family->name)); + foreach ($familyConfig->getTransformations() as $transformation) { + $transformerName = $transformation->getModule(); + $output->writeln(sprintf(' - %s', $transformerName)); + + /** @var TransformerModuleInterface $transformer */ + $transformer = $this->transformers->get($transformerName); + $output->writeln($this->getTransformerDocumentation($transformerName, $transformer, $transformation->getOptions())); + } + } + } else { + $transformers = array_flip($this->transformers->getProvidedServices()); + ksort($transformers); + $last_parent = null; + foreach ($transformers as $fqcn => $transformerName) { + /** @var TransformerModuleInterface $transformer */ + $transformer = $this->transformers->get($transformerName); + // $parent = get_parent_class($transformer); + // if ($parent !== $last_parent) { + // if ($last_parent) { + // // $output->writeln("\n\n## parent foot: $last_parent\n"); + // $output->writeln($last_parent::getDocumentationFooter()); + // } + // if ($parent) { + // // $output->writeln("\n\n## parent head: $parent\n"); + // $output->writeln($parent::getDocumentationHeader()); + // } + // $last_parent = $parent; + // } + $output->writeln($this->no_getTransformerDocumentation($fqcn, $transformerName, $transformer)); + } + } + + return 0; + } + + private function getTransformerDocumentation(string $transformerName, TransformerModuleInterface $transformer, array $options): string + { + $doc = "\n\n## $transformerName\n"; + + if (method_exists($transformer, 'getDocumentationHeader')) { + $doc .= $transformer->getDocumentationHeader()."\n"; + } + + if (method_exists($transformer, 'buildConfiguration')) { + $treeBuilder = new TreeBuilder('root'); + $transformer->buildConfiguration($treeBuilder->getRootNode()->children()); + + $node = $treeBuilder->buildTree(); + $dumper = new YamlReferenceDumper(); + + $t = $dumper->dumpNode($node); + $t = preg_replace("#^root:(\n( {4})?|\s+\[])#", "-\n", (string) $t); + $t = str_replace("\n\n", "\n", $t); + $t = str_replace("\n", "\n ", $t); + // $t = preg_replace("#^root:(\n( {4})?|\s+\[])#", '', (string)$t); + // $t = preg_replace("#\n {4}#", "\n", $t); + // $t = preg_replace("#\n\n#", "\n", $t); + // $t = trim(preg_replace("#^\n+#", '', $t)); + + $doc .= "```yaml\n".$t."```\n"; + // var_dump($options); + + $processor = new Processor(); + $processor->process($treeBuilder->buildTree(), ['root' => $options]); + + } + + if (method_exists($transformer, 'getDocumentationFooter')) { + $doc .= $transformer->getDocumentationFooter()."\n"; + } + + return $doc; + } + + private function no_getTransformerDocumentation(string $fqcn, string $transformerName, TransformerModuleInterface $transformer): string + { + $doc = "\n\n## $transformerName\n"; + + $reflectionClass = new \ReflectionClass($fqcn); + if ($reflectionClass->hasMethod('getDocumentationHeader') && $reflectionClass->getMethod('getDocumentationHeader')->class == $fqcn) { + $doc .= $transformer->getDocumentationHeader()."\n"; + } + + if (method_exists($transformer, 'buildConfiguration')) { + $treeBuilder = new TreeBuilder('root'); + $transformer->buildConfiguration($treeBuilder->getRootNode()->children()); + + $node = $treeBuilder->buildTree(); + $dumper = new YamlReferenceDumper(); + + $t = $dumper->dumpNode($node); + $t = preg_replace("#^root:(\n( {4})?|\s+\[])#", "-\n", (string) $t); + $t = str_replace("\n\n", "\n", $t); + $t = str_replace("\n", "\n ", $t); + // $t = preg_replace("#^root:(\n( {4})?|\s+\[])#", '', (string)$t); + // $t = preg_replace("#\n {4}#", "\n", $t); + // $t = preg_replace("#\n\n#", "\n", $t); + // $t = trim(preg_replace("#^\n+#", '', $t)); + + $doc .= "```yaml\n".$t."```\n"; + } + + if ($reflectionClass->hasMethod('getDocumentationFooter') && $reflectionClass->getMethod('getDocumentationFooter')->class == $fqcn) { + $doc .= $transformer->getDocumentationFooter()."\n"; + } + + return $doc; + } +} diff --git a/lib/php/rendition-factory/src/Transformer/Video/AbstractVideoTransformer.php b/lib/php/rendition-factory/src/Transformer/Video/AbstractVideoTransformer.php index bc55d1c62..614c6ab92 100644 --- a/lib/php/rendition-factory/src/Transformer/Video/AbstractVideoTransformer.php +++ b/lib/php/rendition-factory/src/Transformer/Video/AbstractVideoTransformer.php @@ -4,6 +4,7 @@ use Alchemy\RenditionFactory\Config\ModuleOptionsResolver; use Alchemy\RenditionFactory\Transformer\Video\FFMpeg\Format\FormatInterface; +use Imagine\Image\ImagineInterface; use Symfony\Component\DependencyInjection\Attribute\AutowireLocator; use Symfony\Component\DependencyInjection\ServiceLocator; @@ -11,6 +12,80 @@ { public function __construct(#[AutowireLocator(FormatInterface::TAG, defaultIndexMethod: 'getFormat')] protected ServiceLocator $formats, protected ModuleOptionsResolver $optionsResolver, + protected ImagineInterface $imagine, ) { } + + public static function no_getDocumentationHeader(): ?string + { + return <<<_DOC_ + # Rendition Factory for video-input modules (wip) + + ## Common options + + ### `enabled` (optional) + + Used to disable a whole module from the build chain. + + __default__: true + + ### `format` (mandatory) + + A format defines the output file : + - family (image, video, audio, animation, document, unknown) + - mime type (unique mime type for this type of file) + - extension (possible extenstion(s) for this type of file) + + For a specific module, only a subset of formats may be available, e.g.: + Since `video_to_frame` extracts one image from the video, the only supported output format(s) + are ones of family=image. + + see below "Output formats" for the list of available formats. + + -------------------------------------------- + + # Modules + + _DOC_; + } + + public static function no_getDocumentationFooter(): ?string + { + return <<<_DOC_ + -------------------------------------------- + + ## Output formats + + | format | family | mime type | extension(s) | + |-----------------|-----------|------------------|--------------| + | animated-gif | Animation | image/gif | gif | + | animated-png | Animation | image/png | apng, png | + | animated-webp | Animation | image/webp | webp | + | image-jpeg | Image | image/jpeg | jpg, jpeg | + | video-mkv | Video | video/x-matroska | mkv | + | video-mpeg4 | Video | video/mp4 | mp4 | + | video-mpeg | Video | video/mpeg | mpeg | + | video-quicktime | Video | video/quicktime | mov | + | video-webm | Video | video/webm | webm | + + -------------------------------------------- + + ## Resize modes + ### `inset` + The output is garanteed to fit in the requested size (width, height) and the aspect ratio is kept. + - If only one dimension is provided, the other is computed. + - If both dimensions are provided, the output is resize so the biggest dimension fits into the rectangle. + - If no dimension is provided, the output is the same size as the input. + + -------------------------------------------- + + ## twig context + input.width + + input.height + + input.duration + + _DOC_; + } } diff --git a/lib/php/rendition-factory/src/Transformer/Video/FFMpeg/Format/AacFormat.php b/lib/php/rendition-factory/src/Transformer/Video/FFMpeg/Format/AacFormat.php new file mode 100644 index 000000000..3dd5c5300 --- /dev/null +++ b/lib/php/rendition-factory/src/Transformer/Video/FFMpeg/Format/AacFormat.php @@ -0,0 +1,41 @@ +format = new Aac(); + } + + public static function getAllowedExtensions(): array + { + return ['aac', 'm4a']; + } + + public static function getMimeType(): string + { + return 'audio/aac'; + } + + public static function getFormat(): string + { + return 'audio-aac'; + } + + public static function getFamily(): FamilyEnum + { + return FamilyEnum::Audio; + } + + public function getFFMpegFormat(): Aac + { + return $this->format; + } +} diff --git a/lib/php/rendition-factory/src/Transformer/Video/FFMpeg/Format/Audio/Aac.php b/lib/php/rendition-factory/src/Transformer/Video/FFMpeg/Format/Audio/Aac.php new file mode 100644 index 000000000..a263976c5 --- /dev/null +++ b/lib/php/rendition-factory/src/Transformer/Video/FFMpeg/Format/Audio/Aac.php @@ -0,0 +1,25 @@ +audioCodec = 'aac'; + } + + public function getAvailableAudioCodecs() + { + return ['aac']; + } +} diff --git a/lib/php/rendition-factory/src/Transformer/Video/FFMpeg/Format/Mp3Format.php b/lib/php/rendition-factory/src/Transformer/Video/FFMpeg/Format/Mp3Format.php new file mode 100644 index 000000000..6a02758e3 --- /dev/null +++ b/lib/php/rendition-factory/src/Transformer/Video/FFMpeg/Format/Mp3Format.php @@ -0,0 +1,41 @@ +format = new Mp3(); + } + + public static function getAllowedExtensions(): array + { + return ['mp3']; + } + + public static function getMimeType(): string + { + return 'audio/mp3'; + } + + public static function getFormat(): string + { + return 'audio-mp3'; + } + + public static function getFamily(): FamilyEnum + { + return FamilyEnum::Audio; + } + + public function getFFMpegFormat(): Mp3 + { + return $this->format; + } +} diff --git a/lib/php/rendition-factory/src/Transformer/Video/FFMpeg/Format/WavFormat.php b/lib/php/rendition-factory/src/Transformer/Video/FFMpeg/Format/WavFormat.php new file mode 100644 index 000000000..99eec3d9a --- /dev/null +++ b/lib/php/rendition-factory/src/Transformer/Video/FFMpeg/Format/WavFormat.php @@ -0,0 +1,41 @@ +format = new Wav(); + } + + public static function getAllowedExtensions(): array + { + return ['wav']; + } + + public static function getMimeType(): string + { + return 'audio/wav'; + } + + public static function getFormat(): string + { + return 'audio-wav'; + } + + public static function getFamily(): FamilyEnum + { + return FamilyEnum::Audio; + } + + public function getFFMpegFormat(): Wav + { + return $this->format; + } +} 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..615eceb31 100644 --- a/lib/php/rendition-factory/src/Transformer/Video/FFMpegTransformerModule.php +++ b/lib/php/rendition-factory/src/Transformer/Video/FFMpegTransformerModule.php @@ -13,6 +13,11 @@ use FFMpeg\Format\FormatInterface as FFMpegFormatInterface; use FFMpeg\Media\Clip; use FFMpeg\Media\Video; +use Symfony\Component\Config\Definition\Builder\NodeBuilder; +use Symfony\Component\Config\Definition\Builder\TreeBuilder; +use Symfony\Component\Config\Definition\Dumper\YamlReferenceDumper; +use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException; +use Symfony\Component\Config\Definition\Processor; final readonly class FFMpegTransformerModule extends AbstractVideoTransformer implements TransformerModuleInterface { @@ -21,6 +26,274 @@ public static function getName(): string return 'ffmpeg'; } + public function buildConfiguration(NodeBuilder $builder): void + { + // @formatter:off + $builder + ->scalarNode('format') + ->info('output format') + ->end() + ->scalarNode('video_codec') + ->info('Change the default video codec used by the output format') + ->end() + ->scalarNode('audio_codec') + ->info('Change the default audio codec used by the output format') + ->end() + ->scalarNode('video_kilobitrate') + ->info('Change the default video_kilobitrate used by the output format') + ->end() + ->scalarNode('audio_kilobitrate') + ->info('Change the default audio_kilobitrate used by the output format') + ->end() + ->scalarNode('passes') + ->defaultValue(2) + ->info('Change the number of ffmpeg passes') + ->end() + ->arrayNode('filters') + ->info('Filters to apply to the video') + ->arrayPrototype() + ->info('see list of available filters below') + ->validate()->always()->then(function ($x) { + $this->validateFilter($x); + })->end() + ->children() + ->scalarNode('name') + ->isRequired() + ->info('Name of the filter') + ->validate() + ->ifNotInArray(['pre_clip', 'remove_audio', 'resize', 'rotate', 'pad', 'crop', 'clip', 'synchronize', 'watermark', 'framerate']) + ->thenInvalid('Invalid filter') + ->end() + ->end() + ->scalarNode('enabled') + ->defaultTrue() + ->info('Whether to enable the filter') + ->end() + ->end() + // false: (undocumented) ignore extra keys on general validation, but do NOT suppress (so the validate..then() can check them) + ->ignoreExtraKeys(false) + ->end() + ->end() + ->end() + ->end(); + // @formatter:on + } + + private function validateFilter(array $filter): void + { + var_dump($filter); + $name = $filter['name']; + unset($filter['enabled']); + if ($conf = $this->getFiltersConfigurations()[$name] ?? null) { + $node = $conf->buildTree(); + $processor = new Processor(); + $processor->process($node, [$name => $filter]); + } else { + throw new InvalidConfigurationException(sprintf('Unknown filter: %s', $name)); + } + } + + private function getFiltersConfigurations() + { + static $configurations = [ + // @formatter:off + 'pre_clip' => (new TreeBuilder('pre_clip')) + ->getRootNode() + ->info('Clip the video before applying other filters') + ->children() + ->scalarNode('name')->isRequired()->end() + ->scalarNode('enabled')->defaultTrue()->end() + ->scalarNode('start') + ->defaultValue(0) + ->info('Offset of frame in seconds or timecode') + ->example('2.5 ; "00:00:02.500" ; "{{ metadata.start }}"') + ->end() + ->scalarNode('duration') + ->defaultValue(null) + ->info('Duration in seconds or timecode') + ->example('30 ; "00:00:30" ; "{{ input.duration/2 }}"') + ->end() + ->end() + ->end(), + 'clip' => (new TreeBuilder('clip')) + ->getRootNode() + ->info('Clip the video') + ->children() + ->scalarNode('name')->isRequired()->end() + ->scalarNode('enabled')->defaultTrue()->end() + ->scalarNode('start') + ->defaultValue(0) + ->info('Offset of frame in seconds or timecode') + ->example('2.5 ; "00:00:02.500" ; "{{ metadata.start }}"') + ->end() + ->scalarNode('duration') + ->defaultValue(null) + ->info('Duration in seconds or timecode') + ->example('30 ; "00:00:30" ; "{{ input.duration/2 }}"') + ->end() + ->end() + ->end(), + 'remove_audio' => (new TreeBuilder('remove_audio')) + ->getRootNode() + ->info('Remove the audio from the video') + ->children() + ->scalarNode('name')->isRequired()->end() + ->scalarNode('enabled')->defaultTrue()->end() + ->end() + ->end(), + 'resize' => (new TreeBuilder('resize')) + ->getRootNode() + ->info('Resize the video') + ->children() + ->scalarNode('name')->isRequired()->end() + ->scalarNode('enabled')->defaultTrue()->end() + ->scalarNode('width') + ->isRequired() + ->info('Width of the video') + ->end() + ->scalarNode('height') + ->isRequired() + ->info('Height of the video') + ->end() + ->scalarNode('mode') + ->defaultValue(FFMpeg\Filters\Video\ResizeFilter::RESIZEMODE_INSET) + ->info('Resize mode') + ->example('inset') + ->end() + ->end() + ->end(), + 'rotate' => (new TreeBuilder('rotate')) + ->getRootNode() + ->info('Rotate the video') + ->children() + ->scalarNode('name')->isRequired()->end() + ->scalarNode('enabled')->defaultTrue()->end() + ->scalarNode('angle') + ->isRequired() + ->info('Angle of rotation [0 | 90 | 180 | 270]') + ->example('90') + ->end() + ->end() + ->end(), + 'pad' => (new TreeBuilder('pad')) + ->getRootNode() + ->info('Pad the video') + ->children() + ->scalarNode('name')->isRequired()->end() + ->scalarNode('enabled')->defaultTrue()->end() + ->scalarNode('width') + ->isRequired() + ->info('Width of the video') + ->end() + ->scalarNode('height') + ->isRequired() + ->info('Height of the video') + ->end() + ->end() + ->end(), + 'crop' => (new TreeBuilder('crop')) + ->getRootNode() + ->info('Crop the video') + ->children() + ->scalarNode('name')->isRequired()->end() + ->scalarNode('enabled')->defaultTrue()->end() + ->scalarNode('x') + ->isRequired() + ->info('X coordinate') + ->end() + ->scalarNode('y') + ->isRequired() + ->info('Y coordinate') + ->end() + ->scalarNode('width') + ->isRequired() + ->info('Width of the video') + ->end() + ->scalarNode('height') + ->isRequired() + ->info('Height of the video') + ->end() + ->end() + ->end(), + 'watermark' => (new TreeBuilder('watermark')) + ->getRootNode() + ->info('Apply a watermark on the video') + ->children() + ->scalarNode('name')->isRequired()->end() + ->scalarNode('enabled')->defaultTrue()->end() + ->scalarNode('position') + ->isRequired() + ->info('"relative" or "absolute" position') + ->end() + ->scalarNode('path') + ->isRequired() + ->info('Path to the watermark image') + ->end() + ->scalarNode('top') + ->info('top coordinate (only if position is "relative", set top OR bottom)') + ->end() + ->scalarNode('bottom') + ->info('bottom coordinate (only if position is "relative", set top OR bottom)') + ->end() + ->scalarNode('left') + ->info('left coordinate (only if position is "relative", set left OR right)') + ->end() + ->scalarNode('right') + ->info('right coordinate (only if position is "relative", set left OR right)') + ->end() + ->scalarNode('x') + ->info('X coordinate (only if position is "absolute")') + ->end() + ->scalarNode('y') + ->info('Y coordinate (only if position is "absolute")') + ->end() + ->end() + ->end(), + 'framerate' => (new TreeBuilder('framerate')) + ->getRootNode() + ->info('Change the framerate') + ->children() + ->scalarNode('name')->isRequired()->end() + ->scalarNode('enabled')->defaultTrue()->end() + ->scalarNode('framerate') + ->isRequired() + ->info('framerate') + ->end() + ->scalarNode('gop') + ->info('gop') + ->end() + ->end() + ->end(), + 'synchronize' => (new TreeBuilder('synchronize')) + ->getRootNode() + ->info('re-synchronize audio and video') + ->children() + ->scalarNode('name')->isRequired()->end() + ->scalarNode('enabled')->defaultTrue()->end() + ->end() + ->end(), + + // @formatter:on + ]; + + return $configurations; + } + + private function getFiltersDocumentation() + { + /** @var TreeBuilder $treeBuilder */ + foreach ($this->getFiltersConfigurations() as $name => $treeBuilder) { + $node = $treeBuilder->buildTree(); + $dumper = new YamlReferenceDumper(); + $t = $dumper->dumpNode($node); + // $t = preg_replace("#root:(\n( {4})?|\s+\[])#", "-\n", (string)$t); + // $t = preg_replace("#\n {4}#", "\n", $t); + // $t = preg_replace("#\n\n#", "\n", $t); + // $t = trim(preg_replace("#^\n+#", '', $t)); + var_dump($t); + } + } + public function transform(InputFileInterface $inputFile, array $options, TransformationContextInterface $context): OutputFileInterface { $context->log("Applying '".self::getName()."' module"); @@ -36,7 +309,7 @@ public function transform(InputFileInterface $inputFile, array $options, Transfo } if (FamilyEnum::Audio === $commonArgs->getOutputFormat()->getFamily()) { - return $this->doAudio($options, $inputFile, $context, $commonArgs); + return $this->doVideo($options, $inputFile, $context, $commonArgs); } throw new \InvalidArgumentException(sprintf('Invalid format %s, only video or audio format supported', $commonArgs->getOutputFormat()->getFormat())); @@ -48,7 +321,7 @@ private function doVideo(array $options, InputFileInterface $inputFile, Transfor $format = $outputFormat->getFormat(); if (!method_exists($outputFormat, 'getFFMpegFormat')) { - throw new \InvalidArgumentException('format %s does not declare FFMpeg format', $format); + throw new \InvalidArgumentException(sprintf('format %s does not declare FFMpeg format', $format)); } /** @var FFMpegFormatInterface $FFMpegFormat */ @@ -126,7 +399,7 @@ function ($filter) use ($resolverContext) { } /* @uses self::resize(), self::rotate(), self::pad(), self::crop(), self::clip(), self::synchronize() - * @uses self::watermark(), self::framerate(), self::remove_audio() + * @uses self::watermark(), self::framerate(), self::remove_audio() */ $this->{$filter['name']}($clip, $filter, $resolverContext, $transformationContext, $isProjection); } @@ -175,37 +448,29 @@ private function doAudio(array $options, InputFileInterface $inputFile, Transfor private function preClip(Video $video, array $options, array $resolverContext, TransformationContextInterface $transformationContext, bool &$isProjection): Clip { $start = $this->optionsResolver->resolveOption($options['start'] ?? 0, $resolverContext); - $duration = $this->optionsResolver->resolveOption($options['duration'] ?? null, $resolverContext); - - $startAsTimecode = false; - $durationAsTimecode = null; + $startAsTimecode = FFMpegHelper::optionAsTimecode($start); - if (is_numeric($start) && (float) $start >= 0) { - $startAsTimecode = TimeCode::fromSeconds($start); - } elseif (is_string($start)) { - $startAsTimecode = TimeCode::fromString($start); - } - if (false === $startAsTimecode) { - throw new \InvalidArgumentException('Invalid start for filter "clip"'); + if (null === $startAsTimecode) { + throw new \InvalidArgumentException('Invalid start for filter "pre_clip"'); } - if ($startAsTimecode->toSeconds() > 0) { + $start = FFMpegHelper::timecodeToseconds($startAsTimecode); + if ($start > 0.0) { $isProjection = false; } + $duration = $this->optionsResolver->resolveOption($options['duration'] ?? null, $resolverContext); if (null !== $duration) { - if (is_numeric($duration) && (float) $duration > 0) { - $durationAsTimecode = TimeCode::fromSeconds($duration); - } elseif (is_string($duration)) { - $durationAsTimecode = TimeCode::fromString($duration); - } - if (false === $durationAsTimecode) { + $durationAsTimecode = FFMpegHelper::optionAsTimecode($duration); + if (null === $durationAsTimecode) { throw new \InvalidArgumentException('Invalid duration for filter "pre_clip"'); } $isProjection = false; + $transformationContext->log(sprintf(" Applying 'pre_clip' filter: start=%s (%.02f), duration=%s (%.02f)", $startAsTimecode, $start, $durationAsTimecode, $duration)); + } else { + $durationAsTimecode = null; + $transformationContext->log(sprintf(" Applying 'pre_clip' filter: start=%s (%.02f), duration=null", $startAsTimecode, $start)); } - $transformationContext->log(sprintf(" Applying 'pre_clip' filter: start=%s, duration=%s", $startAsTimecode, $durationAsTimecode)); - return $video->clip($startAsTimecode, $durationAsTimecode); } @@ -288,36 +553,29 @@ private function crop(Clip $clip, array $options, array $resolverContext, Transf private function clip(Clip $clip, array $options, array $resolverContext, TransformationContextInterface $transformationContext, bool &$isProjection): void { $start = $this->optionsResolver->resolveOption($options['start'] ?? 0, $resolverContext); - $duration = $this->optionsResolver->resolveOption($options['duration'] ?? null, $resolverContext); - - $startAsTimecode = false; - $durationAsTimecode = null; + $startAsTimecode = FFMpegHelper::optionAsTimecode($start); - if (is_numeric($start) && (float) $start >= 0) { - $startAsTimecode = TimeCode::fromSeconds($start); - } elseif (is_string($start)) { - $startAsTimecode = TimeCode::fromString($start); - } - if (false === $startAsTimecode) { + if (null === $startAsTimecode) { throw new \InvalidArgumentException('Invalid start for filter "clip"'); } - if ($startAsTimecode->toSeconds() > 0) { + $start = FFMpegHelper::timecodeToseconds($startAsTimecode); + if ($start > 0.0) { $isProjection = false; } + $duration = $this->optionsResolver->resolveOption($options['duration'] ?? null, $resolverContext); if (null !== $duration) { - if (is_numeric($duration) && (float) $duration > 0) { - $durationAsTimecode = TimeCode::fromSeconds($duration); - } elseif (is_string($duration)) { - $durationAsTimecode = TimeCode::fromString($duration); - } - if (false === $durationAsTimecode) { - throw new \InvalidArgumentException('Invalid duration for filter "pre_clip"'); + $durationAsTimecode = FFMpegHelper::optionAsTimecode($duration); + if (null === $durationAsTimecode) { + throw new \InvalidArgumentException('Invalid duration for filter "clip"'); } $isProjection = false; + $transformationContext->log(sprintf(" Applying 'clip' filter: start=%s (%.02f), duration=%s (%.02f)", $startAsTimecode, $start, $durationAsTimecode, $duration)); + } else { + $durationAsTimecode = null; + $transformationContext->log(sprintf(" Applying 'clip' filter: start=%s (%.02f), duration=null", $startAsTimecode, $start)); } - $transformationContext->log(sprintf(" Applying 'clip' filter: start=%s, duration=%s", $startAsTimecode, $durationAsTimecode)); $clip->filters()->clip($startAsTimecode, $durationAsTimecode); } @@ -330,6 +588,14 @@ private function synchronize(Clip $clip, array $options, array $resolverContext, private function watermark(Clip $clip, array $options, array $resolverContext, TransformationContextInterface $transformationContext, bool &$isProjection): void { $path = $this->optionsResolver->resolveOption($options['path'] ?? null, $resolverContext); + $path = $transformationContext->getRemoteFile($path); + $wmImage = $this->imagine->open($path); + $wmWidth = $wmImage->getSize()->getWidth(); + $wmHeight = $wmImage->getSize()->getHeight(); + unset($wmImage); + + $resolverContext['watermark'] = ['width' => $wmWidth, 'height' => $wmHeight]; + if (!file_exists($path)) { throw new \InvalidArgumentException('Watermark file for filter "watermark" not found'); } @@ -348,6 +614,7 @@ private function watermark(Clip $clip, array $options, array $resolverContext, T } array_walk($coord, fn (&$v) => $v = (int) $this->optionsResolver->resolveOption($v, $resolverContext)); + $coord['position'] = $position; $transformationContext->log(sprintf(" Applying 'watermark' filter: path=%s, coord=%s", $path, FFMpegHelper::coordAsText($coord))); $clip->filters()->watermark($path, $coord); @@ -359,7 +626,7 @@ private function framerate(Clip $clip, array $options, array $resolverContext, T if ($framerate <= 0) { throw new \InvalidArgumentException('Invalid framerate for filter "framerate"'); } - $gop = (int) ($options['gop'] ?? 0); + $gop = (int) $this->optionsResolver->resolveOption($options['gop'] ?? 0, $resolverContext); $transformationContext->log(sprintf(" Applying 'framerate' filter: framerate=%d, gop=%d", $framerate, $gop)); $clip->filters()->framerate(new FFMpeg\Coordinate\FrameRate($framerate), $gop); diff --git a/lib/php/rendition-factory/src/Transformer/Video/VideoSummaryTransformerModule.php b/lib/php/rendition-factory/src/Transformer/Video/VideoSummaryTransformerModule.php index afb172340..29d6d4947 100644 --- a/lib/php/rendition-factory/src/Transformer/Video/VideoSummaryTransformerModule.php +++ b/lib/php/rendition-factory/src/Transformer/Video/VideoSummaryTransformerModule.php @@ -13,6 +13,7 @@ use FFMpeg\Format\VideoInterface; use Psr\Container\ContainerExceptionInterface; use Psr\Container\NotFoundExceptionInterface; +use Symfony\Component\Config\Definition\Builder\NodeBuilder; final readonly class VideoSummaryTransformerModule extends AbstractVideoTransformer implements TransformerModuleInterface { @@ -21,6 +22,35 @@ public static function getName(): string return 'video_summary'; } + public function buildConfiguration(NodeBuilder $builder): void + { + $builder + ->scalarNode('module') + ->isRequired() + ->defaultValue(self::getName()) + ->end() + ->booleanNode('enabled') + ->defaultTrue() + ->info('Whether to enable this module') + ->end() + ->arrayNode('options') + ->info('Options for the module') + ->children() + ->scalarNode('period') + ->isRequired() + ->info('Extract one video clip every period, in seconds or timecode') + ->example('5 ; "00:00:05.00"') + ->end() + ->scalarNode('duration') + ->isRequired() + ->info('Duration of each clip, in seconds or timecode') + ->example('0.25 ; "00:00:00.25"') + ->end() + ->end() + ->end() + ; + } + /** * @throws ContainerExceptionInterface * @throws NotFoundExceptionInterface @@ -46,64 +76,122 @@ public function transform(InputFileInterface $inputFile, array $options, Transfo ]; $period = $this->optionsResolver->resolveOption($options['period'] ?? 0, $resolverContext); - if ($period <= 0) { + $periodAsTimecode = FFMpegHelper::optionAsTimecode($period); + if (null === $periodAsTimecode || ($period = FFMpegHelper::timecodeToseconds($periodAsTimecode)) <= 0) { throw new \InvalidArgumentException(sprintf('Invalid period for module "%s"', self::getName())); } + + $start = $this->optionsResolver->resolveOption($options['start'] ?? 0, $resolverContext); + $startAsTimecode = FFMpegHelper::optionAsTimecode($start); + if (null === $startAsTimecode || ($start = FFMpegHelper::timecodeToseconds($startAsTimecode)) < 0) { + throw new \InvalidArgumentException('Invalid start'); + } + $clipDuration = $this->optionsResolver->resolveOption($options['duration'] ?? 0, $resolverContext); - if ($clipDuration <= 0 || $clipDuration >= $period) { - throw new \InvalidArgumentException(sprintf('Invalid duration for module "%s"', self::getName())); + $clipDurationAsTimecode = FFMpegHelper::optionAsTimecode($clipDuration); + if (null === $clipDurationAsTimecode || ($clipDuration = FFMpegHelper::timecodeToseconds($clipDurationAsTimecode)) <= 0 || $clipDuration >= $period) { + throw new \InvalidArgumentException('Invalid duration, should be >0 and log(sprintf(' period: %d, duration: %d', $period, $clipDuration)); + $context->log(sprintf(' start=%s (%.02f), period=%s (%.02f), duration=%s (%.02f)', $startAsTimecode, $start, $periodAsTimecode, $period, $clipDurationAsTimecode, $clipDuration)); + + $inputDuration = $video->getFFProbe()->format($inputFile->getPath())->get('duration'); - /** @var VideoInterface $FFMpegOutputFormat */ - $FFMpegOutputFormat = $outputFormat->getFFMpegFormat(); - if ($videoCodec = $this->optionsResolver->resolveOption($options['video_codec'] ?? null, $resolverContext)) { - if (!in_array($videoCodec, $FFMpegOutputFormat->getAvailableVideoCodecs())) { - throw new \InvalidArgumentException(sprintf('Invalid video codec %s for format %s', $videoCodec, $format)); + if (FamilyEnum::Video === $outputFormat->getFamily()) { + /** @var VideoInterface $FFMpegOutputFormat */ + $FFMpegOutputFormat = $outputFormat->getFFMpegFormat(); + if ($videoCodec = $this->optionsResolver->resolveOption($options['video_codec'] ?? null, $resolverContext)) { + if (!in_array($videoCodec, $FFMpegOutputFormat->getAvailableVideoCodecs())) { + throw new \InvalidArgumentException(sprintf('Invalid video codec %s for format %s', $videoCodec, $format)); + } + $FFMpegOutputFormat->setVideoCodec($videoCodec); } - $FFMpegOutputFormat->setVideoCodec($videoCodec); - } - if ($audioCodec = $this->optionsResolver->resolveOption($options['audio_codec'] ?? null, $resolverContext)) { - if (!in_array($audioCodec, $FFMpegOutputFormat->getAvailableAudioCodecs())) { - throw new \InvalidArgumentException(sprintf('Invalid audio codec %s for format %s', $audioCodec, $format)); + if ($audioCodec = $this->optionsResolver->resolveOption($options['audio_codec'] ?? null, $resolverContext)) { + if (!in_array($audioCodec, $FFMpegOutputFormat->getAvailableAudioCodecs())) { + throw new \InvalidArgumentException(sprintf('Invalid audio codec %s for format %s', $audioCodec, $format)); + } + $FFMpegOutputFormat->setAudioCodec($audioCodec); } - $FFMpegOutputFormat->setAudioCodec($audioCodec); - } - $clipsExtension = $outputFormat->getAllowedExtensions()[0]; - - $clipsFiles = []; - try { - $inputDuration = $video->getFFProbe()->format($inputFile->getPath())->get('duration'); - $nClips = ceil($inputDuration / $period); - - $context->log(sprintf(' Duration duration: %s, extracting %d clips of %d seconds', $inputDuration, $nClips, $clipDuration)); - $clipDuration = TimeCode::fromSeconds($clipDuration); - $removeAudioFilter = new FFMpeg\Filters\Audio\SimpleFilter(['-an']); - for ($i = 0; $i < $nClips; ++$i) { - $start = $i * $period; - $clip = $video->clip(TimeCode::fromSeconds($start), $clipDuration); - $clip->addFilter($removeAudioFilter); - $clipPath = $context->createTmpFilePath($clipsExtension); - $clip->save($FFMpegOutputFormat, $clipPath); - unset($clip); - $clipsFiles[] = realpath($clipPath); + // todo: allow to choose other extension + $clipsExtension = $outputFormat->getAllowedExtensions()[0]; + + try { + $clipsFiles = []; + $gap = $period - $clipDuration; + $usableInputDuration = ($inputDuration - $start) + $gap; + $nClips = floor($usableInputDuration / $period); + + $context->log(sprintf(' Video duration=%.02f, extracting %d clips of %.02f seconds from %s', $inputDuration, $nClips, $clipDuration, $startAsTimecode)); + $removeAudioFilter = new FFMpeg\Filters\Audio\SimpleFilter(['-an']); + for ($i = 0; $i < $nClips; ++$i) { + $startAsTimecode = TimeCode::fromSeconds($start); + $context->log(sprintf(' - Extracting clip %d/%d, start=%s (%.02f)', $i + 1, $nClips, $startAsTimecode, $start)); + $clip = $video->clip($startAsTimecode, $clipDurationAsTimecode); + $clip->addFilter($removeAudioFilter); + $clipPath = $context->createTmpFilePath($clipsExtension); + $clip->save($FFMpegOutputFormat, $clipPath); + unset($clip); + $clipsFiles[] = realpath($clipPath); + $start += $period; + } + unset($removeAudioFilter, $video); + + $outVideo = $commonArgs->getFFMpeg()->open($clipsFiles[0]); + + $outputPath = $context->createTmpFilePath($commonArgs->getExtension()); + + $outVideo + ->concat($clipsFiles) + ->saveFromSameCodecs($outputPath, true); + + unset($outVideo); + } finally { + foreach ($clipsFiles as $clipFile) { + @unlink($clipFile); + } } - unset($removeAudioFilter, $video); - - $outVideo = $commonArgs->getFFMpeg()->open($clipsFiles[0]); - - $outputPath = $context->createTmpFilePath($commonArgs->getExtension()); - - $outVideo - ->concat($clipsFiles) - ->saveFromSameCodecs($outputPath, true); - - unset($outVideo); - } finally { - foreach ($clipsFiles as $clipFile) { - @unlink($clipFile); + } elseif (FamilyEnum::Animation === $outputFormat->getFamily()) { + // todo: allow to choose other extension + $clipsExtension = $outputFormat->getAllowedExtensions()[0]; + try { + $clipsFiles = []; + $usableInputDuration = ($inputDuration - $start); + $nClips = floor($usableInputDuration / $period); + + $context->log(sprintf(' Video duration=%.02f, extracting %d frames from %s', $inputDuration, $nClips, $startAsTimecode)); + + for ($i = 0; $i < $nClips; ++$i) { + $startAsTimecode = TimeCode::fromSeconds($start); + $context->log(sprintf(' - Extracting frame %d/%d, start=%s (%.02f)', $i + 1, $nClips, $startAsTimecode, $start)); + + $frame = $video->frame($startAsTimecode); + $clipPath = $context->createTmpFilePath($clipsExtension); + $frame->save($clipPath); + unset($clip); + $clipsFiles[] = realpath($clipPath); + + $start += $period; + } + unset($video); + + $image = $this->imagine->open(array_shift($clipsFiles)); + foreach ($clipsFiles as $file) { + $image->layers()->add($this->imagine->open($file)); + } + + $outputPath = $context->createTmpFilePath($commonArgs->getExtension()); + $delay = (int) ($clipDuration * 1000); + $image->save($outputPath, [ + 'animated' => true, + 'animated.delay' => $delay, + 'animated.loops' => 0, + ]); + + } finally { + foreach ($clipsFiles as $clipFile) { + @unlink($clipFile); + } } } diff --git a/lib/php/rendition-factory/src/Transformer/Video/VideoToAnimationTransformerModule.php b/lib/php/rendition-factory/src/Transformer/Video/VideoToAnimationTransformerModule.php index 1eefdc96b..e83ee21ff 100644 --- a/lib/php/rendition-factory/src/Transformer/Video/VideoToAnimationTransformerModule.php +++ b/lib/php/rendition-factory/src/Transformer/Video/VideoToAnimationTransformerModule.php @@ -9,7 +9,7 @@ use Alchemy\RenditionFactory\DTO\OutputFileInterface; use Alchemy\RenditionFactory\Transformer\TransformerModuleInterface; use FFMpeg; -use FFMpeg\Coordinate\TimeCode; +use Symfony\Component\Config\Definition\Builder\NodeBuilder; final readonly class VideoToAnimationTransformerModule extends AbstractVideoTransformer implements TransformerModuleInterface { @@ -18,6 +18,68 @@ public static function getName(): string return 'video_to_animation'; } + public static function getDocumentationHeader(): ?string + { + return 'Converts a video to an animated gif'; + } + + // public static function getDocumentationFooter(): ?string + // { + // return null; + // } + // + public function buildConfiguration(NodeBuilder $builder): void + { + $builder + ->scalarNode('module') + ->isRequired() + ->defaultValue(self::getName()) + ->end() + ->booleanNode('enabled') + ->defaultTrue() + ->info('Whether to enable this module') + ->end() + ->arrayNode('options') + ->info('Options for the module') + ->children() + ->scalarNode('start') + ->defaultValue(0) + ->info('Start time in seconds or timecode') + ->example('2.5 ; "00:00:02.50" ; "{{ metadata.start }}"') + ->end() + ->scalarNode('duration') + ->defaultValue(null) + ->info('Duration in seconds or timecode') + ->example('30 ; "00:00:30.00" ; "{{ input.duration/2 }}"') + ->end() + ->integerNode('fps') + ->defaultValue(1) + ->info('Frames per second') + ->end() + ->integerNode('width') + ->defaultValue(null) + ->info('Width in pixels') + ->end() + ->integerNode('height') + ->defaultValue(null) + ->info('Height in pixels') + ->end() + ->enumNode('mode') + ->values([ + FFMpeg\Filters\Video\ResizeFilter::RESIZEMODE_INSET, + // todo: implement other modes + // FFMpeg\Filters\Video\ResizeFilter::RESIZEMODE_FIT, + // FFMpeg\Filters\Video\ResizeFilter::RESIZEMODE_SCALE_WIDTH, + // FFMpeg\Filters\Video\ResizeFilter::RESIZEMODE_SCALE_HEIGHT, + ]) + ->defaultValue(FFMpeg\Filters\Video\ResizeFilter::RESIZEMODE_INSET) + ->info('Resize mode') + ->end() + ->end() + ->end() + ; + } + public function transform(InputFileInterface $inputFile, array $options, TransformationContextInterface $context): OutputFileInterface { $context->log("Applying '".self::getName()."' module"); @@ -38,30 +100,19 @@ public function transform(InputFileInterface $inputFile, array $options, Transfo ]; $start = $this->optionsResolver->resolveOption($options['start'] ?? 0, $resolverContext); - $startAsTimecode = false; - if (is_numeric($start) && (float) $start >= 0) { - $startAsTimecode = TimeCode::fromSeconds($start); - } elseif (is_string($start)) { - $startAsTimecode = TimeCode::fromString($start); - } - if (false === $startAsTimecode) { + $startAsTimecode = FFMpegHelper::optionAsTimecode($start); + if (null === $startAsTimecode) { throw new \InvalidArgumentException('Invalid start.'); } - $start = $startAsTimecode->toSeconds(); - + $start = FFMpegHelper::timecodeToseconds($startAsTimecode); $duration = $this->optionsResolver->resolveOption($options['duration'] ?? null, $resolverContext); - $durationAsTimecode = false; - if (is_numeric($duration) && (float) $duration >= 0) { - $durationAsTimecode = TimeCode::fromSeconds($duration); - } elseif (is_string($duration)) { - $durationAsTimecode = TimeCode::fromString($duration); - } - if (null !== $duration ) { - if (false === $durationAsTimecode) { + if (null !== $duration) { + $durationAsTimecode = FFMpegHelper::optionAsTimecode($duration); + if (null === $durationAsTimecode) { throw new \InvalidArgumentException('Invalid duration for filter "clip"'); } - $duration = $durationAsTimecode->toSeconds(); + $duration = FFMpegHelper::timecodeToseconds($durationAsTimecode); } if (($fps = (int) $this->optionsResolver->resolveOption($options['fps'] ?? 1, $resolverContext)) <= 0) { diff --git a/lib/php/rendition-factory/src/Transformer/Video/VideoToFrameTransformerModule.php b/lib/php/rendition-factory/src/Transformer/Video/VideoToFrameTransformerModule.php index a8dedc215..45b92891b 100644 --- a/lib/php/rendition-factory/src/Transformer/Video/VideoToFrameTransformerModule.php +++ b/lib/php/rendition-factory/src/Transformer/Video/VideoToFrameTransformerModule.php @@ -8,8 +8,8 @@ use Alchemy\RenditionFactory\DTO\OutputFile; use Alchemy\RenditionFactory\DTO\OutputFileInterface; use Alchemy\RenditionFactory\Transformer\TransformerModuleInterface; -use FFMpeg; use FFMpeg\Media\Video; +use Symfony\Component\Config\Definition\Builder\NodeBuilder; final readonly class VideoToFrameTransformerModule extends AbstractVideoTransformer implements TransformerModuleInterface { @@ -18,6 +18,30 @@ public static function getName(): string return 'video_to_frame'; } + public function buildConfiguration(NodeBuilder $builder): void + { + $builder + ->scalarNode('module') + ->isRequired() + ->defaultValue(self::getName()) + ->end() + ->booleanNode('enabled') + ->defaultTrue() + ->info('Whether to enable this module') + ->end() + ->arrayNode('options') + ->info('Options for the module') + ->children() + ->scalarNode('start') + ->defaultValue(0) + ->info('Offset of frame in seconds or timecode') + ->example('2.5 ; "00:00:02.50" ; "{{ metadata.start }}"') + ->end() + ->end() + ->end() + ; + } + public function transform(InputFileInterface $inputFile, array $options, TransformationContextInterface $context): OutputFileInterface { $context->log("Applying '".self::getName()."' module"); @@ -37,11 +61,15 @@ public function transform(InputFileInterface $inputFile, array $options, Transfo 'input' => $video->getStreams()->videos()->first()->all(), ]; - $from = FFMpeg\Coordinate\TimeCode::fromSeconds($this->optionsResolver->resolveOption($options['from_seconds'] ?? 0, $resolverContext)); - - $context->log(sprintf(' from=%s', $from)); + $start = $this->optionsResolver->resolveOption($options['start'] ?? 0, $resolverContext); + $startAsTimecode = FFMpegHelper::optionAsTimecode($start); + if (null === $startAsTimecode) { + throw new \InvalidArgumentException('Invalid start.'); + } + $start = FFMpegHelper::timecodeToseconds($startAsTimecode); + $context->log(sprintf(' start=%s (%.02f)', $startAsTimecode, $start)); - $frame = $video->frame($from); + $frame = $video->frame($startAsTimecode); $outputPath = $context->createTmpFilePath($commonArgs->getExtension()); $frame->save($outputPath); From 33f97913b81c26c1ec3ae778035241f343ccb75c Mon Sep 17 00:00:00 2001 From: jygaulier Date: Mon, 18 Nov 2024 19:38:32 +0100 Subject: [PATCH 2/2] WIP DO NOT MERGE - fix attributes access in twig : use `"{{ attr.myfield }}"` where `myfield` is the *slugified* attribute name --- .../src/Transformer/Video/FFMpegTransformerModule.php | 10 +++------- .../src/Transformer/Video/ModuleCommonArgs.php | 4 +--- .../Video/VideoSummaryTransformerModule.php | 8 +++----- .../Video/VideoToAnimationTransformerModule.php | 6 ++---- .../Video/VideoToFrameTransformerModule.php | 6 ++---- 5 files changed, 11 insertions(+), 23 deletions(-) diff --git a/lib/php/rendition-factory/src/Transformer/Video/FFMpegTransformerModule.php b/lib/php/rendition-factory/src/Transformer/Video/FFMpegTransformerModule.php index 615eceb31..9bdacba44 100644 --- a/lib/php/rendition-factory/src/Transformer/Video/FFMpegTransformerModule.php +++ b/lib/php/rendition-factory/src/Transformer/Video/FFMpegTransformerModule.php @@ -330,10 +330,8 @@ private function doVideo(array $options, InputFileInterface $inputFile, Transfor /** @var Video $video */ $video = $commonArgs->getFFMpeg()->open($inputFile->getPath()); - $resolverContext = [ - 'metadata' => $transformationContext->getTemplatingContext(), - 'input' => $video->getStreams()->videos()->first()->all(), - ]; + $resolverContext = $transformationContext->getTemplatingContext(); + $resolverContext['input'] = $video->getStreams()->videos()->first()->all(); if ($videoCodec = $this->optionsResolver->resolveOption($options['video_codec'] ?? null, $resolverContext)) { if (!in_array($videoCodec, $FFMpegFormat->getAvailableVideoCodecs())) { @@ -424,9 +422,7 @@ function ($filter) use ($resolverContext) { */ private function doAudio(array $options, InputFileInterface $inputFile, TransformationContextInterface $context, ModuleCommonArgs $commonArgs): OutputFileInterface { - $resolverContext = [ - 'metadata' => $context->getTemplatingContext(), - ]; + $resolverContext = $context->getTemplatingContext(); $format = $commonArgs->getOutputFormat()->getFormat(); if (!method_exists($commonArgs->getOutputFormat(), 'getFFMpegFormat')) { diff --git a/lib/php/rendition-factory/src/Transformer/Video/ModuleCommonArgs.php b/lib/php/rendition-factory/src/Transformer/Video/ModuleCommonArgs.php index c9a275563..e2fb15e1e 100644 --- a/lib/php/rendition-factory/src/Transformer/Video/ModuleCommonArgs.php +++ b/lib/php/rendition-factory/src/Transformer/Video/ModuleCommonArgs.php @@ -20,9 +20,7 @@ public function __construct( TransformationContextInterface $context, ModuleOptionsResolver $optionsResolver) { - $resolverContext = [ - 'metadata' => $context->getTemplatingContext(), - ]; + $resolverContext = $context->getTemplatingContext(); $format = $optionsResolver->resolveOption($options['format'] ?? null, $resolverContext); if (!$format) { diff --git a/lib/php/rendition-factory/src/Transformer/Video/VideoSummaryTransformerModule.php b/lib/php/rendition-factory/src/Transformer/Video/VideoSummaryTransformerModule.php index 29d6d4947..14a04ade8 100644 --- a/lib/php/rendition-factory/src/Transformer/Video/VideoSummaryTransformerModule.php +++ b/lib/php/rendition-factory/src/Transformer/Video/VideoSummaryTransformerModule.php @@ -70,11 +70,9 @@ public function transform(InputFileInterface $inputFile, array $options, Transfo /** @var FFMpeg\Media\Video $video */ $video = $commonArgs->getFFMpeg()->open($inputFile->getPath()); - $resolverContext = [ - 'metadata' => $context->getTemplatingContext(), - 'input' => $video->getStreams()->videos()->first()->all(), - ]; - + $resolverContext = $context->getTemplatingContext(); + $resolverContext['input'] = $video->getStreams()->videos()->first()->all(); + $period = $this->optionsResolver->resolveOption($options['period'] ?? 0, $resolverContext); $periodAsTimecode = FFMpegHelper::optionAsTimecode($period); if (null === $periodAsTimecode || ($period = FFMpegHelper::timecodeToseconds($periodAsTimecode)) <= 0) { diff --git a/lib/php/rendition-factory/src/Transformer/Video/VideoToAnimationTransformerModule.php b/lib/php/rendition-factory/src/Transformer/Video/VideoToAnimationTransformerModule.php index e83ee21ff..d17e0eaa3 100644 --- a/lib/php/rendition-factory/src/Transformer/Video/VideoToAnimationTransformerModule.php +++ b/lib/php/rendition-factory/src/Transformer/Video/VideoToAnimationTransformerModule.php @@ -94,10 +94,8 @@ public function transform(InputFileInterface $inputFile, array $options, Transfo /** @var FFMpeg\Media\Video $video */ $video = $commonArgs->getFFMpeg()->open($inputFile->getPath()); - $resolverContext = [ - 'metadata' => $context->getTemplatingContext(), - 'input' => $video->getStreams()->videos()->first()->all(), - ]; + $resolverContext = $context->getTemplatingContext(); + $resolverContext['input'] = $video->getStreams()->videos()->first()->all(); $start = $this->optionsResolver->resolveOption($options['start'] ?? 0, $resolverContext); $startAsTimecode = FFMpegHelper::optionAsTimecode($start); diff --git a/lib/php/rendition-factory/src/Transformer/Video/VideoToFrameTransformerModule.php b/lib/php/rendition-factory/src/Transformer/Video/VideoToFrameTransformerModule.php index 45b92891b..b6840f511 100644 --- a/lib/php/rendition-factory/src/Transformer/Video/VideoToFrameTransformerModule.php +++ b/lib/php/rendition-factory/src/Transformer/Video/VideoToFrameTransformerModule.php @@ -56,10 +56,8 @@ public function transform(InputFileInterface $inputFile, array $options, Transfo /** @var Video $video */ $video = $commonArgs->getFFMpeg()->open($inputFile->getPath()); - $resolverContext = [ - 'metadata' => $context->getTemplatingContext(), - 'input' => $video->getStreams()->videos()->first()->all(), - ]; + $resolverContext = $context->getTemplatingContext(); + $resolverContext['input'] = $video->getStreams()->videos()->first()->all(); $start = $this->optionsResolver->resolveOption($options['start'] ?? 0, $resolverContext); $startAsTimecode = FFMpegHelper::optionAsTimecode($start);