From 48b901f67cb1003d685d20683071099c16a9b890 Mon Sep 17 00:00:00 2001 From: jygaulier Date: Mon, 4 Nov 2024 19:25:59 +0100 Subject: [PATCH] PS-670-rendition-video_enhance2 (#472) * WIP twig * allow dynamic options (twig), e.g. `width: "{{ input.width / 5 }}"` * cs * use existing TemplateResolver ; Avoid compiling "not-twig" templates * cleanup ; filter now logs resolved options values * commit forgotten files... * commit forgotten files... * remove state from ModuleOptionsResolver * call template resolver only for _strings_ containing "{" * resolve options one by one ; factorize common code * remove state from service(s) ; set "projection" * add log ; check input file family * turn base class abstract * rename classes --- .../src/Asset/Attribute/TemplateResolver.php | 6 +- .../Resources/config/services.yaml | 1 + .../src/Config/ModuleOptionsResolver.php | 17 ++ .../Video/AbstractVideoTransformer.php | 16 ++ .../src/Transformer/Video/FFMpegHelper.php | 39 ++- .../Video/FFMpegTransformerModule.php | 262 ++++++++++-------- .../Transformer/Video/ModuleCommonArgs.php | 68 +++++ .../Video/VideoSummaryTransformerModule.php | 64 ++--- .../VideoToAnimationTransformerModule.php | 104 +++---- .../Video/VideoToFrameTransformerModule.php | 52 ++-- 10 files changed, 382 insertions(+), 247 deletions(-) create mode 100644 lib/php/rendition-factory/src/Config/ModuleOptionsResolver.php create mode 100644 lib/php/rendition-factory/src/Transformer/Video/AbstractVideoTransformer.php create mode 100644 lib/php/rendition-factory/src/Transformer/Video/ModuleCommonArgs.php diff --git a/databox/api/src/Asset/Attribute/TemplateResolver.php b/databox/api/src/Asset/Attribute/TemplateResolver.php index a2214521e..86e5f0a7f 100644 --- a/databox/api/src/Asset/Attribute/TemplateResolver.php +++ b/databox/api/src/Asset/Attribute/TemplateResolver.php @@ -19,8 +19,10 @@ public function __construct() public function resolve(string $template, array $values): string { - $template = $this->twig->createTemplate($template); + if (str_contains($template, '{')) { + return $this->twig->createTemplate($template)->render($values); + } - return $this->twig->render($template, $values); + return $template; } } diff --git a/lib/php/rendition-factory-bundle/Resources/config/services.yaml b/lib/php/rendition-factory-bundle/Resources/config/services.yaml index 68ca31ddc..16dbfd75c 100644 --- a/lib/php/rendition-factory-bundle/Resources/config/services.yaml +++ b/lib/php/rendition-factory-bundle/Resources/config/services.yaml @@ -88,3 +88,4 @@ services: Alchemy\RenditionFactory\MimeType\MimeTypeGuesser: ~ Alchemy\RenditionFactory\Format\FormatGuesser: ~ Alchemy\RenditionFactory\Format\FormatFactory: ~ + Alchemy\RenditionFactory\Config\ModuleOptionsResolver: ~ diff --git a/lib/php/rendition-factory/src/Config/ModuleOptionsResolver.php b/lib/php/rendition-factory/src/Config/ModuleOptionsResolver.php new file mode 100644 index 000000000..40ac48df5 --- /dev/null +++ b/lib/php/rendition-factory/src/Config/ModuleOptionsResolver.php @@ -0,0 +1,17 @@ +templateResolver->resolve($option, $context) : $option; + } +} diff --git a/lib/php/rendition-factory/src/Transformer/Video/AbstractVideoTransformer.php b/lib/php/rendition-factory/src/Transformer/Video/AbstractVideoTransformer.php new file mode 100644 index 000000000..bc55d1c62 --- /dev/null +++ b/lib/php/rendition-factory/src/Transformer/Video/AbstractVideoTransformer.php @@ -0,0 +1,16 @@ +getLogger()); + return FFMpeg\FFMpeg::create($ffmpegOptions, $options['logger'] ?? null); + } + + public static function pointAsText(FFMpeg\Coordinate\Point $point): string + { + return sprintf('(%d, %d)', $point->getX(), $point->getY()); + } + + public static function dimensionAsText(FFMpeg\Coordinate\Dimension $dimension): string + { + return sprintf('%d x %d', $dimension->getWidth(), $dimension->getHeight()); + } + + public static function coordAsText(array $coord): string + { + $s = []; + foreach ($coord as $k => $v) { + $s[] = sprintf('%s=%d', $k, $v); + } + + return '['.implode(', ', $s).']'; } } diff --git a/lib/php/rendition-factory/src/Transformer/Video/FFMpegTransformerModule.php b/lib/php/rendition-factory/src/Transformer/Video/FFMpegTransformerModule.php index 99a63fdb9..854d593e7 100644 --- a/lib/php/rendition-factory/src/Transformer/Video/FFMpegTransformerModule.php +++ b/lib/php/rendition-factory/src/Transformer/Video/FFMpegTransformerModule.php @@ -8,21 +8,14 @@ use Alchemy\RenditionFactory\DTO\OutputFile; use Alchemy\RenditionFactory\DTO\OutputFileInterface; use Alchemy\RenditionFactory\Transformer\TransformerModuleInterface; -use Alchemy\RenditionFactory\Transformer\Video\FFMpeg\Format\FormatInterface; use FFMpeg; use FFMpeg\Coordinate\TimeCode; use FFMpeg\Format\FormatInterface as FFMpegFormatInterface; use FFMpeg\Media\Clip; use FFMpeg\Media\Video; -use Symfony\Component\DependencyInjection\Attribute\AutowireLocator; -use Symfony\Component\DependencyInjection\ServiceLocator; -final readonly class FFMpegTransformerModule implements TransformerModuleInterface +final readonly class FFMpegTransformerModule extends AbstractVideoTransformer implements TransformerModuleInterface { - public function __construct(#[AutowireLocator(FormatInterface::TAG, defaultIndexMethod: 'getFormat')] private ServiceLocator $formats) - { - } - public static function getName(): string { return 'ffmpeg'; @@ -30,83 +23,77 @@ public static function getName(): string public function transform(InputFileInterface $inputFile, array $options, TransformationContextInterface $context): OutputFileInterface { - if (!($format = $options['format'])) { - throw new \InvalidArgumentException('Missing format'); - } + $context->log("Applying '".self::getName()."' module"); - if (!$this->formats->has($format)) { - throw new \InvalidArgumentException(sprintf('Invalid format %s', $format)); + if (FamilyEnum::Video !== $inputFile->getFamily()) { + throw new \InvalidArgumentException('Invalid input file family, should be video'); } - /** @var FormatInterface $outputFormat */ - $outputFormat = $this->formats->get($format); - if (null != ($extension = $options['extension'] ?? null)) { - if (!in_array($extension, $outputFormat->getAllowedExtensions())) { - throw new \InvalidArgumentException(sprintf('Invalid extension %s for format %s', $extension, $format)); - } - } else { - $extension = $outputFormat->getAllowedExtensions()[0]; - } - - if (FamilyEnum::Video !== $outputFormat->getFamily()) { - throw new \InvalidArgumentException(sprintf('Invalid format %s, only video formats supported', $format)); - } + $commonArgs = new ModuleCommonArgs($this->formats, $options, $context, $this->optionsResolver); - if (FamilyEnum::Video === $outputFormat->getFamily()) { - return $this->doVideo($outputFormat, $extension, $inputFile, $options, $context); + if (FamilyEnum::Video === $commonArgs->getOutputFormat()->getFamily()) { + return $this->doVideo($options, $inputFile, $context, $commonArgs); } - if (FamilyEnum::Audio === $outputFormat->getFamily()) { - return $this->doAudio($outputFormat, $extension, $inputFile, $options, $context); + if (FamilyEnum::Audio === $commonArgs->getOutputFormat()->getFamily()) { + return $this->doAudio($options, $inputFile, $context, $commonArgs); } - throw new \InvalidArgumentException(sprintf('Invalid format %s, only video or audio format supported', $format)); + throw new \InvalidArgumentException(sprintf('Invalid format %s, only video or audio format supported', $commonArgs->getOutputFormat()->getFormat())); } - private function doVideo(FormatInterface $ouputFormat, string $extension, InputFileInterface $inputFile, array $options, TransformationContextInterface $context): OutputFileInterface + private function doVideo(array $options, InputFileInterface $inputFile, TransformationContextInterface $transformationContext, ModuleCommonArgs $commonArgs): OutputFileInterface { - $format = $ouputFormat->getFormat(); - if (!method_exists($ouputFormat, 'getFFMpegFormat')) { + $outputFormat = $commonArgs->getOutputFormat(); + $format = $outputFormat->getFormat(); + + if (!method_exists($outputFormat, 'getFFMpegFormat')) { throw new \InvalidArgumentException('format %s does not declare FFMpeg format', $format); } + /** @var FFMpegFormatInterface $FFMpegFormat */ - $FFMpegFormat = $ouputFormat->getFFMpegFormat(); + $FFMpegFormat = $outputFormat->getFFMpegFormat(); - if ($videoCodec = $options['video_codec'] ?? null) { + /** @var Video $video */ + $video = $commonArgs->getFFMpeg()->open($inputFile->getPath()); + + $resolverContext = [ + 'metadata' => $transformationContext->getTemplatingContext(), + 'input' => $video->getStreams()->videos()->first()->all(), + ]; + + if ($videoCodec = $this->optionsResolver->resolveOption($options['video_codec'] ?? null, $resolverContext)) { if (!in_array($videoCodec, $FFMpegFormat->getAvailableVideoCodecs())) { throw new \InvalidArgumentException(sprintf('Invalid video codec %s for format %s', $videoCodec, $format)); } $FFMpegFormat->setVideoCodec($videoCodec); } - if ($audioCodec = $options['audio_codec'] ?? null) { + if ($audioCodec = $this->optionsResolver->resolveOption($options['audio_codec'] ?? null, $resolverContext)) { if (!in_array($audioCodec, $FFMpegFormat->getAvailableAudioCodecs())) { throw new \InvalidArgumentException(sprintf('Invalid audio codec %s for format %s', $audioCodec, $format)); } $FFMpegFormat->setAudioCodec($audioCodec); } - if (null !== ($videoKilobitrate = $options['video_kilobitrate'] ?? null)) { + if (null !== ($videoKilobitrate = $this->optionsResolver->resolveOption($options['video_kilobitrate'] ?? null, $resolverContext))) { + $videoKilobitrate = (int) $videoKilobitrate; if (!method_exists($FFMpegFormat, 'setKiloBitrate')) { throw new \InvalidArgumentException(sprintf('format %s does not support video_kilobitrate', $format)); } - if (!is_int($videoKilobitrate)) { - throw new \InvalidArgumentException('Invalid video kilobitrate'); - } $FFMpegFormat->setKiloBitrate($videoKilobitrate); } - if (null !== ($audioKilobitrate = $options['audio_kilobitrate'] ?? null)) { + if (null !== ($audioKilobitrate = $this->optionsResolver->resolveOption($options['audio_kilobitrate'] ?? null, $resolverContext))) { + $audioKilobitrate = (int) $audioKilobitrate; if (!method_exists($FFMpegFormat, 'setAudioKiloBitrate')) { throw new \InvalidArgumentException(sprintf('format %s does not support audio_kilobitrate', $format)); } - if (!is_int($audioKilobitrate)) { - throw new \InvalidArgumentException('Invalid audio kilobitrate'); - } $FFMpegFormat->setAudioKiloBitrate($audioKilobitrate); } - if (null !== ($passes = $options['passes'] ?? null)) { + if (null !== ($passes = $this->optionsResolver->resolveOption($options['passes'] ?? null, $resolverContext))) { + $passes = (int) $passes; if (!method_exists($FFMpegFormat, 'setPasses')) { throw new \InvalidArgumentException(sprintf('format %s does not support passes', $format)); } - if (!is_int($passes) || $passes < 1) { + if ($passes < 1) { throw new \InvalidArgumentException('Invalid passes count'); } if (0 === $videoKilobitrate) { @@ -115,21 +102,17 @@ private function doVideo(FormatInterface $ouputFormat, string $extension, InputF $FFMpegFormat->setPasses($passes); } - $ffmpeg = FFMpegHelper::createFFMpeg($options, $context); - - /** @var Video $video */ - $video = $ffmpeg->open($inputFile->getPath()); - $filters = array_values(array_filter($options['filters'] ?? [], - function ($filter) { - return $filter['enabled'] ?? true; + function ($filter) use ($resolverContext) { + return $this->optionsResolver->resolveOption($filter['enabled'] ?? true, $resolverContext); })); + $isProjection = true; + // first, turn the video into a clip if (!empty($filters) && 'pre_clip' === $filters[0]['name']) { $filter = array_shift($filters); - $context->log(sprintf('Applying filter: %s', $filter['name'])); - $clip = $this->preClip($video, $filter, $context); + $clip = $this->preClip($video, $filter, $resolverContext, $transformationContext, $isProjection); } else { $clip = $video->clip(TimeCode::fromSeconds(0), TimeCode::fromString('01:00:00:00.00')); } @@ -141,42 +124,45 @@ function ($filter) { if (!method_exists($this, $filter['name'])) { throw new \InvalidArgumentException(sprintf('Invalid filter: %s', $filter['name'])); } - $context->log(sprintf('Applying filter: %s', $filter['name'])); /* @uses self::resize(), self::rotate(), self::pad(), self::crop(), self::clip(), self::synchronize() * @uses self::watermark(), self::framerate(), self::remove_audio() */ - call_user_func([$this, $filter['name']], $clip, $filter, $context); + $this->{$filter['name']}($clip, $filter, $resolverContext, $transformationContext, $isProjection); } - $outputPath = $context->createTmpFilePath($extension); + $outputPath = $transformationContext->createTmpFilePath($commonArgs->getExtension()); $clip->save($FFMpegFormat, $outputPath); - unset($clip, $video, $ffmpeg); + unset($clip, $video); gc_collect_cycles(); return new OutputFile( $outputPath, - $ouputFormat->getMimeType(), - $ouputFormat->getFamily(), - false // TODO implement projection + $outputFormat->getMimeType(), + $outputFormat->getFamily(), + $isProjection ); } /** * todo: implement audio filters. */ - private function doAudio(FormatInterface $ouputFormat, string $extension, InputFileInterface $inputFile, array $options, TransformationContextInterface $context): OutputFileInterface + private function doAudio(array $options, InputFileInterface $inputFile, TransformationContextInterface $context, ModuleCommonArgs $commonArgs): OutputFileInterface { - $format = $ouputFormat->getFormat(); - if (!method_exists($ouputFormat, 'getFFMpegFormat')) { + $resolverContext = [ + 'metadata' => $context->getTemplatingContext(), + ]; + + $format = $commonArgs->getOutputFormat()->getFormat(); + if (!method_exists($commonArgs->getOutputFormat(), 'getFFMpegFormat')) { throw new \InvalidArgumentException('format %s does not declare FFMpeg format', $format); } /** @var FFMpegFormatInterface $FFMpegFormat */ - $FFMpegFormat = $ouputFormat->getFFMpegFormat(); + $FFMpegFormat = $commonArgs->getOutputFormat()->getFFMpegFormat(); - if ($audioCodec = $options['audio_codec'] ?? null) { + if ($audioCodec = $this->optionsResolver->resolveOption($options['audio_codec'] ?? null, $resolverContext)) { if (!in_array($audioCodec, $FFMpegFormat->getAvailableAudioCodecs())) { throw new \InvalidArgumentException(sprintf('Invalid audio codec %s for format %s', $audioCodec, $format)); } @@ -186,43 +172,54 @@ private function doAudio(FormatInterface $ouputFormat, string $extension, InputF throw new \InvalidArgumentException('Audio transformation not implemented'); } - private function preClip(Video $video, array $options, TransformationContextInterface $context): Clip + private function preClip(Video $video, array $options, array $resolverContext, TransformationContextInterface $transformationContext, bool &$isProjection): Clip { - $start = $options['start'] ?? 0; - $duration = $options['duration'] ?? null; + $start = $this->optionsResolver->resolveOption($options['start'] ?? 0, $resolverContext); + $duration = $this->optionsResolver->resolveOption($options['duration'] ?? null, $resolverContext); - $startAsTimecode = $durationAsTimecode = false; - if (is_string($start)) { - $startAsTimecode = TimeCode::fromString($start); - } elseif (is_int($start) && $start >= 0) { + $startAsTimecode = false; + $durationAsTimecode = null; + + 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 (is_string($duration)) { - $durationAsTimecode = TimeCode::fromString($duration); - } elseif (is_int($duration) && $duration > 0) { - $durationAsTimecode = TimeCode::fromSeconds($duration); + if ($startAsTimecode->toSeconds() > 0) { + $isProjection = false; } - if (false === $durationAsTimecode) { - throw new \InvalidArgumentException('Invalid duration for filter "clip"'); + + 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"'); + } + $isProjection = false; } + $transformationContext->log(sprintf(" Applying 'pre_clip' filter: start=%s, duration=%s", $startAsTimecode, $durationAsTimecode)); + return $video->clip($startAsTimecode, $durationAsTimecode); } - private function remove_audio(Clip $clip, array $options, TransformationContextInterface $context): void + private function remove_audio(Clip $clip, array $options, array $resolverContext, TransformationContextInterface $transformationContext, bool &$isProjection): void { $customFilter = '-an'; + $transformationContext->log(" Applying 'remove_audio' filter"); $clip->addFilter(new FFMpeg\Filters\Audio\SimpleFilter([$customFilter])); } - private function resize(Clip $clip, array $options, TransformationContextInterface $context): void + private function resize(Clip $clip, array $options, array $resolverContext, TransformationContextInterface $transformationContext, bool &$isProjection): void { - $dimension = $this->getDimension($options, 'resize'); - $mode = $options['mode'] ?? FFMpeg\Filters\Video\ResizeFilter::RESIZEMODE_INSET; + $dimension = $this->getDimension($options, $resolverContext, 'resize'); + $mode = $this->optionsResolver->resolveOption($options['mode'] ?? FFMpeg\Filters\Video\ResizeFilter::RESIZEMODE_INSET, $resolverContext); if (!in_array( $mode, [ @@ -235,81 +232,108 @@ private function resize(Clip $clip, array $options, TransformationContextInterfa throw new \InvalidArgumentException('Invalid mode for filter "resize"'); } + $transformationContext->log(sprintf(" Applying 'resize' filter: dimension=[width=%s, height=%s], mode=%s", $dimension->getWidth(), $dimension->getHeight(), $mode)); $clip->filters()->resize( $dimension, $mode ); + + $isProjection = false; } - private function rotate(Clip $clip, array $options, TransformationContextInterface $context): void + private function rotate(Clip $clip, array $options, array $resolverContext, TransformationContextInterface $transformationContext, bool &$isProjection): void { static $rotations = [ 90 => FFMpeg\Filters\Video\RotateFilter::ROTATE_90, 180 => FFMpeg\Filters\Video\RotateFilter::ROTATE_180, 270 => FFMpeg\Filters\Video\RotateFilter::ROTATE_270, ]; - $angle = $options['angle'] ?? 0; + $angle = (int) $this->optionsResolver->resolveOption($options['angle'] ?? 0, $resolverContext); if (!array_key_exists($angle, $rotations)) { throw new \InvalidArgumentException('Invalid rotation, must be 90, 180 or 270 for filter "rotate"'); } + $transformationContext->log(sprintf(" Applying 'rotate' filter: angle=%d", $angle)); $clip->filters()->rotate($rotations[$angle]); + + $isProjection = false; } - private function pad(Clip $clip, array $options, TransformationContextInterface $context): void + private function pad(Clip $clip, array $options, array $resolverContext, TransformationContextInterface $transformationContext, bool &$isProjection): void { - $dimension = $this->getDimension($options, 'pad'); + $dimension = $this->getDimension($options, $resolverContext, 'pad'); + $transformationContext->log(sprintf(" Applying 'pad' filter: dimension=%s", FFMpegHelper::dimensionAsText($dimension))); $clip->filters()->pad($dimension); + + $isProjection = false; } - private function crop(Clip $clip, array $options, TransformationContextInterface $context): void + private function crop(Clip $clip, array $options, array $resolverContext, TransformationContextInterface $transformationContext, bool &$isProjection): void { - $point = new FFMpeg\Coordinate\Point($options['x'] ?? 0, $options['y'] ?? 0); - $dimension = $this->getDimension($options, 'crop'); + $x = $this->optionsResolver->resolveOption($options['x'] ?? 0, $resolverContext); + $y = $this->optionsResolver->resolveOption($options['y'] ?? 0, $resolverContext); + if (!is_numeric($x) || !is_numeric($y)) { + throw new \InvalidArgumentException('Invalid x/y for filter "crop"'); + } + $point = new FFMpeg\Coordinate\Point((int) $x, (int) $y); + $dimension = $this->getDimension($options, $resolverContext, 'crop'); + $transformationContext->log(sprintf(" Applying 'crop' filter: point=%s, dimension=%s", FFMpegHelper::pointAsText($point), FFMpegHelper::dimensionAsText($dimension))); $clip->filters()->crop($point, $dimension); + + $isProjection = false; } - private function clip(Clip $clip, array $options, TransformationContextInterface $context): void + private function clip(Clip $clip, array $options, array $resolverContext, TransformationContextInterface $transformationContext, bool &$isProjection): void { - $start = $options['start'] ?? 0; - $duration = $options['duration'] ?? null; + $start = $this->optionsResolver->resolveOption($options['start'] ?? 0, $resolverContext); + $duration = $this->optionsResolver->resolveOption($options['duration'] ?? null, $resolverContext); - $startAsTimecode = $durationAsTimecode = false; - if (is_string($start)) { - $startAsTimecode = TimeCode::fromString($start); - } elseif (is_int($start) && $start >= 0) { + $startAsTimecode = false; + $durationAsTimecode = null; + + 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 (is_string($duration)) { - $durationAsTimecode = TimeCode::fromString($duration); - } elseif (is_int($duration) && $duration > 0) { - $durationAsTimecode = TimeCode::fromSeconds($duration); + if ($startAsTimecode->toSeconds() > 0) { + $isProjection = false; } - if (false === $durationAsTimecode) { - throw new \InvalidArgumentException('Invalid duration for filter "clip"'); + + 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"'); + } + $isProjection = false; } + $transformationContext->log(sprintf(" Applying 'clip' filter: start=%s, duration=%s", $startAsTimecode, $durationAsTimecode)); $clip->filters()->clip($startAsTimecode, $durationAsTimecode); } - private function synchronize(Clip $clip, array $options, TransformationContextInterface $context): void + private function synchronize(Clip $clip, array $options, array $resolverContext, TransformationContextInterface $transformationContext, bool &$isProjection): void { + $transformationContext->log(" Applying 'synchronize' filter"); $clip->filters()->synchronize(); } - private function watermark(Clip $clip, array $options, TransformationContextInterface $context): void + private function watermark(Clip $clip, array $options, array $resolverContext, TransformationContextInterface $transformationContext, bool &$isProjection): void { - $path = $options['path'] ?? null; + $path = $this->optionsResolver->resolveOption($options['path'] ?? null, $resolverContext); if (!file_exists($path)) { throw new \InvalidArgumentException('Watermark file for filter "watermark" not found'); } - $position = $options['position'] ?? 'absolute'; + $position = $this->optionsResolver->resolveOption($options['position'] ?? 'absolute', $resolverContext); if ('relative' == $position) { $coord = array_filter($options, fn ($k) => in_array($k, ['bottom', 'right', 'top', 'left']), ARRAY_FILTER_USE_KEY); if (array_key_exists('bottom', $coord) && array_key_exists('top', $coord) @@ -323,24 +347,28 @@ private function watermark(Clip $clip, array $options, TransformationContextInte throw new \InvalidArgumentException('Invalid position for filter "watermark"'); } + array_walk($coord, fn (&$v) => $v = (int) $this->optionsResolver->resolveOption($v, $resolverContext)); + + $transformationContext->log(sprintf(" Applying 'watermark' filter: path=%s, coord=%s", $path, FFMpegHelper::coordAsText($coord))); $clip->filters()->watermark($path, $coord); } - private function framerate(Clip $clip, array $options, TransformationContextInterface $context): void + private function framerate(Clip $clip, array $options, array $resolverContext, TransformationContextInterface $transformationContext, bool &$isProjection): void { - $framerate = $options['framerate'] ?? 0; + $framerate = (int) $this->optionsResolver->resolveOption($options['framerate'] ?? 0, $resolverContext); if ($framerate <= 0) { throw new \InvalidArgumentException('Invalid framerate for filter "framerate"'); } - $gop = $options['gop'] ?? 0; + $gop = (int) ($options['gop'] ?? 0); + $transformationContext->log(sprintf(" Applying 'framerate' filter: framerate=%d, gop=%d", $framerate, $gop)); $clip->filters()->framerate(new FFMpeg\Coordinate\FrameRate($framerate), $gop); } - private function getDimension(array $options, string $filterName): FFMpeg\Coordinate\Dimension + private function getDimension(array $options, array $resolverContext, string $filterName): FFMpeg\Coordinate\Dimension { - $width = $options['width'] ?? 0; - $height = $options['height'] ?? 0; + $width = (int) $this->optionsResolver->resolveOption($options['width'] ?? 0, $resolverContext); + $height = (int) $this->optionsResolver->resolveOption($options['height'] ?? 0, $resolverContext); if ($width <= 0 || $height <= 0) { throw new \InvalidArgumentException(sprintf('Invalid width/height for filter "%s"', $filterName)); } diff --git a/lib/php/rendition-factory/src/Transformer/Video/ModuleCommonArgs.php b/lib/php/rendition-factory/src/Transformer/Video/ModuleCommonArgs.php new file mode 100644 index 000000000..c9a275563 --- /dev/null +++ b/lib/php/rendition-factory/src/Transformer/Video/ModuleCommonArgs.php @@ -0,0 +1,68 @@ + $context->getTemplatingContext(), + ]; + + $format = $optionsResolver->resolveOption($options['format'] ?? null, $resolverContext); + if (!$format) { + throw new \InvalidArgumentException('Missing format'); + } + if (!$formats->has($format)) { + throw new \InvalidArgumentException(sprintf('Invalid format %s', $format)); + } + + $this->outputFormat = $formats->get($format); + + $extension = $optionsResolver->resolveOption($options['extension'] ?? null, $resolverContext); + if (null !== $extension) { + if (!in_array($extension, $this->outputFormat->getAllowedExtensions())) { + throw new \InvalidArgumentException(sprintf('Invalid extension %s for format %s', $extension, $format)); + } + } else { + $extension = $this->outputFormat->getAllowedExtensions()[0]; + } + $this->extension = $extension; + + $ffmpegOptions = [ + 'timeout' => $optionsResolver->resolveOption($options['timeout'] ?? null, $resolverContext), + 'threads' => $optionsResolver->resolveOption($options['threads'] ?? null, $resolverContext), + ]; + $this->ffmpeg = FFMpegHelper::createFFMpeg($ffmpegOptions); + } + + public function getOutputFormat(): FormatInterface + { + return $this->outputFormat; + } + + public function getExtension(): string + { + return $this->extension; + } + + public function getFFMpeg(): FFMpeg\FFMpeg + { + return $this->ffmpeg; + } +} diff --git a/lib/php/rendition-factory/src/Transformer/Video/VideoSummaryTransformerModule.php b/lib/php/rendition-factory/src/Transformer/Video/VideoSummaryTransformerModule.php index 1010b211d..afb172340 100644 --- a/lib/php/rendition-factory/src/Transformer/Video/VideoSummaryTransformerModule.php +++ b/lib/php/rendition-factory/src/Transformer/Video/VideoSummaryTransformerModule.php @@ -8,21 +8,14 @@ use Alchemy\RenditionFactory\DTO\OutputFile; use Alchemy\RenditionFactory\DTO\OutputFileInterface; use Alchemy\RenditionFactory\Transformer\TransformerModuleInterface; -use Alchemy\RenditionFactory\Transformer\Video\FFMpeg\Format\FormatInterface; use FFMpeg; use FFMpeg\Coordinate\TimeCode; use FFMpeg\Format\VideoInterface; use Psr\Container\ContainerExceptionInterface; use Psr\Container\NotFoundExceptionInterface; -use Symfony\Component\DependencyInjection\Attribute\AutowireLocator; -use Symfony\Component\DependencyInjection\ServiceLocator; -final readonly class VideoSummaryTransformerModule implements TransformerModuleInterface +final readonly class VideoSummaryTransformerModule extends AbstractVideoTransformer implements TransformerModuleInterface { - public function __construct(#[AutowireLocator(FormatInterface::TAG, defaultIndexMethod: 'getFormat')] private ServiceLocator $formats) - { - } - public static function getName(): string { return 'video_summary'; @@ -34,46 +27,44 @@ public static function getName(): string */ public function transform(InputFileInterface $inputFile, array $options, TransformationContextInterface $context): OutputFileInterface { - if (!($format = $options['format'] ?? null)) { - throw new \InvalidArgumentException('Missing format'); - } + $context->log("Applying '".self::getName()."' module"); - if (!$this->formats->has($format)) { - throw new \InvalidArgumentException(sprintf('Invalid format %s', $format)); + if (FamilyEnum::Video !== $inputFile->getFamily()) { + throw new \InvalidArgumentException('Invalid input file family, should be video'); } - /** @var FormatInterface $outputFormat */ - $outputFormat = $this->formats->get($format); - if (FamilyEnum::Video !== $outputFormat->getFamily()) { - throw new \InvalidArgumentException(sprintf('Invalid format %s, only video formats supported', $format)); - } + $commonArgs = new ModuleCommonArgs($this->formats, $options, $context, $this->optionsResolver); + $outputFormat = $commonArgs->getOutputFormat(); + $format = $outputFormat->getFormat(); - if (null != ($extension = $options['extension'] ?? null)) { - if (!in_array($extension, $outputFormat->getAllowedExtensions())) { - throw new \InvalidArgumentException(sprintf('Invalid extension %s for format %s', $extension, $format)); - } - } else { - $extension = $outputFormat->getAllowedExtensions()[0]; - } + /** @var FFMpeg\Media\Video $video */ + $video = $commonArgs->getFFMpeg()->open($inputFile->getPath()); + + $resolverContext = [ + 'metadata' => $context->getTemplatingContext(), + 'input' => $video->getStreams()->videos()->first()->all(), + ]; - $period = $options['period'] ?? 0; + $period = $this->optionsResolver->resolveOption($options['period'] ?? 0, $resolverContext); if ($period <= 0) { throw new \InvalidArgumentException(sprintf('Invalid period for module "%s"', self::getName())); } - $clipDuration = $options['duration'] ?? 0; + $clipDuration = $this->optionsResolver->resolveOption($options['duration'] ?? 0, $resolverContext); if ($clipDuration <= 0 || $clipDuration >= $period) { throw new \InvalidArgumentException(sprintf('Invalid duration for module "%s"', self::getName())); } + $context->log(sprintf(' period: %d, duration: %d', $period, $clipDuration)); + /** @var VideoInterface $FFMpegOutputFormat */ $FFMpegOutputFormat = $outputFormat->getFFMpegFormat(); - if ($videoCodec = $options['video_codec'] ?? null) { + 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); } - if ($audioCodec = $options['audio_codec'] ?? null) { + 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)); } @@ -82,17 +73,12 @@ public function transform(InputFileInterface $inputFile, array $options, Transfo $clipsExtension = $outputFormat->getAllowedExtensions()[0]; - $ffmpeg = FFMpegHelper::createFFMpeg($options, $context); - $clipsFiles = []; try { - /** @var FFMpeg\Media\Video $video */ - $video = $ffmpeg->open($inputFile->getPath()); - $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)); + $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) { @@ -106,15 +92,15 @@ public function transform(InputFileInterface $inputFile, array $options, Transfo } unset($removeAudioFilter, $video); - $outVideo = $ffmpeg->open($clipsFiles[0]); + $outVideo = $commonArgs->getFFMpeg()->open($clipsFiles[0]); - $outputPath = $context->createTmpFilePath($extension); + $outputPath = $context->createTmpFilePath($commonArgs->getExtension()); $outVideo ->concat($clipsFiles) ->saveFromSameCodecs($outputPath, true); - unset($outVideo, $ffmpeg); + unset($outVideo); } finally { foreach ($clipsFiles as $clipFile) { @unlink($clipFile); @@ -131,7 +117,7 @@ public function transform(InputFileInterface $inputFile, array $options, Transfo $outputPath, $outputFormat->getMimeType(), $outputFormat->getFamily(), - false // TODO implement projection + false, ); } } diff --git a/lib/php/rendition-factory/src/Transformer/Video/VideoToAnimationTransformerModule.php b/lib/php/rendition-factory/src/Transformer/Video/VideoToAnimationTransformerModule.php index 595339b54..1eefdc96b 100644 --- a/lib/php/rendition-factory/src/Transformer/Video/VideoToAnimationTransformerModule.php +++ b/lib/php/rendition-factory/src/Transformer/Video/VideoToAnimationTransformerModule.php @@ -8,17 +8,11 @@ use Alchemy\RenditionFactory\DTO\OutputFile; use Alchemy\RenditionFactory\DTO\OutputFileInterface; use Alchemy\RenditionFactory\Transformer\TransformerModuleInterface; -use Alchemy\RenditionFactory\Transformer\Video\FFMpeg\Format\FormatInterface; use FFMpeg; -use Symfony\Component\DependencyInjection\Attribute\AutowireLocator; -use Symfony\Component\DependencyInjection\ServiceLocator; +use FFMpeg\Coordinate\TimeCode; -final readonly class VideoToAnimationTransformerModule implements TransformerModuleInterface +final readonly class VideoToAnimationTransformerModule extends AbstractVideoTransformer implements TransformerModuleInterface { - public function __construct(#[AutowireLocator(FormatInterface::TAG, defaultIndexMethod: 'getFormat')] private ServiceLocator $formats) - { - } - public static function getName(): string { return 'video_to_animation'; @@ -26,45 +20,61 @@ public static function getName(): string public function transform(InputFileInterface $inputFile, array $options, TransformationContextInterface $context): OutputFileInterface { - if (!($format = $options['format'] ?? null)) { - throw new \InvalidArgumentException('Missing format'); - } + $context->log("Applying '".self::getName()."' module"); - if (!$this->formats->has($format)) { - throw new \InvalidArgumentException(sprintf('Invalid format %s', $format)); - } - /** @var FormatInterface $outputFormat */ - $outputFormat = $this->formats->get($format); - if (FamilyEnum::Animation !== $outputFormat->getFamily()) { - throw new \InvalidArgumentException(sprintf('Invalid format %s, only animation formats supported', $format)); + if (FamilyEnum::Video !== $inputFile->getFamily()) { + throw new \InvalidArgumentException('Invalid input file family, should be video'); } - if (null != ($extension = $options['extension'] ?? null)) { - if (!in_array($extension, $outputFormat->getAllowedExtensions())) { - throw new \InvalidArgumentException(sprintf('Invalid extension %s for format %s', $extension, $format)); - } - } else { - $extension = $outputFormat->getAllowedExtensions()[0]; + $commonArgs = new ModuleCommonArgs($this->formats, $options, $context, $this->optionsResolver); + $outputFormat = $commonArgs->getOutputFormat(); + + /** @var FFMpeg\Media\Video $video */ + $video = $commonArgs->getFFMpeg()->open($inputFile->getPath()); + + $resolverContext = [ + 'metadata' => $context->getTemplatingContext(), + 'input' => $video->getStreams()->videos()->first()->all(), + ]; + + $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) { + throw new \InvalidArgumentException('Invalid start.'); } + $start = $startAsTimecode->toSeconds(); - $fromSeconds = FFMpeg\Coordinate\TimeCode::fromSeconds($options['from_seconds'] ?? 0); - $duration = $options['duration'] ?? null; - if (null !== $duration && ($duration = (int) $duration) <= 0) { - throw new \InvalidArgumentException('Invalid duration'); + $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) { + throw new \InvalidArgumentException('Invalid duration for filter "clip"'); + } + $duration = $durationAsTimecode->toSeconds(); } - if (($fps = (int) ($options['fps'] ?? 1)) <= 0) { + if (($fps = (int) $this->optionsResolver->resolveOption($options['fps'] ?? 1, $resolverContext)) <= 0) { throw new \InvalidArgumentException('Invalid fps'); } - $width = $options['width'] ?? null; - $height = $options['height'] ?? null; + $width = $this->optionsResolver->resolveOption($options['width'] ?? null, $resolverContext); + $height = $this->optionsResolver->resolveOption($options['height'] ?? null, $resolverContext); if ((null !== $width && ($width = (int) $width) <= 0) || (null !== $height && ($height = (int) $height) <= 0)) { throw new \InvalidArgumentException('Invalid width or height'); } - $mode = $options['mode'] ?? FFMpeg\Filters\Video\ResizeFilter::RESIZEMODE_INSET; + $mode = $this->optionsResolver->resolveOption($options['mode'] ?? FFMpeg\Filters\Video\ResizeFilter::RESIZEMODE_INSET, $resolverContext); if (!in_array( $mode, [ @@ -78,52 +88,55 @@ public function transform(InputFileInterface $inputFile, array $options, Transfo } switch ($mode) { case FFMpeg\Filters\Video\ResizeFilter::RESIZEMODE_INSET: - list($width, $height) = $this->getDimensionsInset($inputFile->getPath(), $width, $height); + [$width, $height] = $this->getDimensionsInset($video, $width, $height); break; // other modes not implemented default: throw new \InvalidArgumentException('Invalid resize mode'); } + $context->log(sprintf(' start=%s, duration=%s, fps=%s, width=%d, height=%d', $start, $duration, $fps, $width, $height)); + $commands = [ '-i', $inputFile->getPath(), '-ss', - $fromSeconds, + $start, ]; if (null !== $duration) { $commands[] = '-t'; $commands[] = $duration; } $commands[] = '-vf'; - $commands[] = 'fps='.$fps.',scale='.$width.':'.$height.':flags=lanczos,split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse'; + + $c = 'fps='.$fps; + if (-1 !== $width || -1 !== $height) { + $c .= ',scale='.$width.':'.$height.':flags=lanczos'; + } + $c .= ',split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse'; + $commands[] = $c; $commands[] = '-loop'; $commands[] = '0'; - $outputPath = $context->createTmpFilePath($extension); + $outputPath = $context->createTmpFilePath($commonArgs->getExtension()); $commands[] = $outputPath; - $ffmpeg = FFMpegHelper::createFFMpeg($options, $context); - - $ffmpeg->getFFMpegDriver()->command($commands); + $commonArgs->getFFMpeg()->getFFMpegDriver()->command($commands); if (!file_exists($outputPath)) { throw new \RuntimeException('Failed to create animated gif'); } - unset($ffmpeg); - gc_collect_cycles(); - return new OutputFile( $outputPath, $outputFormat->getMimeType(), $outputFormat->getFamily(), - false // TODO implement projection + false, ); } - private function getDimensionsInset($path, $width, $height): array + private function getDimensionsInset(FFMpeg\Media\Video $video, $width, $height): array { if (null === $width && null === $height) { return [-1, -1]; @@ -134,8 +147,6 @@ private function getDimensionsInset($path, $width, $height): array if (null === $height) { return [$width, -1]; } - $ffmpeg = FFMpeg\FFMpeg::create(); - $video = $ffmpeg->open($path); $dimensions = null; foreach ($video->getStreams() as $stream) { if ($stream->isVideo()) { @@ -147,7 +158,6 @@ private function getDimensionsInset($path, $width, $height): array } } } - unset($video, $ffmpeg); if ($dimensions) { $wRatio = $width ? ($dimensions->getWidth() / $width) : 0; $hRatio = $height ? ($dimensions->getHeight() / $height) : 0; diff --git a/lib/php/rendition-factory/src/Transformer/Video/VideoToFrameTransformerModule.php b/lib/php/rendition-factory/src/Transformer/Video/VideoToFrameTransformerModule.php index 62d462af6..a8dedc215 100644 --- a/lib/php/rendition-factory/src/Transformer/Video/VideoToFrameTransformerModule.php +++ b/lib/php/rendition-factory/src/Transformer/Video/VideoToFrameTransformerModule.php @@ -8,17 +8,11 @@ use Alchemy\RenditionFactory\DTO\OutputFile; use Alchemy\RenditionFactory\DTO\OutputFileInterface; use Alchemy\RenditionFactory\Transformer\TransformerModuleInterface; -use Alchemy\RenditionFactory\Transformer\Video\FFMpeg\Format\FormatInterface; use FFMpeg; -use Symfony\Component\DependencyInjection\Attribute\AutowireLocator; -use Symfony\Component\DependencyInjection\ServiceLocator; +use FFMpeg\Media\Video; -final readonly class VideoToFrameTransformerModule implements TransformerModuleInterface +final readonly class VideoToFrameTransformerModule extends AbstractVideoTransformer implements TransformerModuleInterface { - public function __construct(#[AutowireLocator(FormatInterface::TAG, defaultIndexMethod: 'getFormat')] private ServiceLocator $formats) - { - } - public static function getName(): string { return 'video_to_frame'; @@ -26,46 +20,40 @@ public static function getName(): string public function transform(InputFileInterface $inputFile, array $options, TransformationContextInterface $context): OutputFileInterface { - if (!($format = $options['format'] ?? null)) { - throw new \InvalidArgumentException('Missing format'); - } + $context->log("Applying '".self::getName()."' module"); - if (!$this->formats->has($format)) { - throw new \InvalidArgumentException(sprintf('Invalid format %s', $format)); + if (FamilyEnum::Video !== $inputFile->getFamily()) { + throw new \InvalidArgumentException('Invalid input file family, should be video'); } - /** @var FormatInterface $outputFormat */ - $outputFormat = $this->formats->get($format); - if (FamilyEnum::Image !== $outputFormat->getFamily()) { - throw new \InvalidArgumentException(sprintf('Invalid format %s, only image formats supported', $format)); - } + $commonArgs = new ModuleCommonArgs($this->formats, $options, $context, $this->optionsResolver); + $outputFormat = $commonArgs->getOutputFormat(); - if (null != ($extension = $options['extension'] ?? null)) { - if (!in_array($extension, $outputFormat->getAllowedExtensions())) { - throw new \InvalidArgumentException(sprintf('Invalid extension %s for format %s', $extension, $format)); - } - } else { - $extension = $outputFormat->getAllowedExtensions()[0]; - } + /** @var Video $video */ + $video = $commonArgs->getFFMpeg()->open($inputFile->getPath()); + + $resolverContext = [ + 'metadata' => $context->getTemplatingContext(), + 'input' => $video->getStreams()->videos()->first()->all(), + ]; - $ffmpeg = FFMpegHelper::createFFMpeg($options, $context); + $from = FFMpeg\Coordinate\TimeCode::fromSeconds($this->optionsResolver->resolveOption($options['from_seconds'] ?? 0, $resolverContext)); - $fromSeconds = $options['from_seconds'] ?? 0; + $context->log(sprintf(' from=%s', $from)); - $video = $ffmpeg->open($inputFile->getPath()); - $frame = $video->frame(FFMpeg\Coordinate\TimeCode::fromSeconds($fromSeconds)); - $outputPath = $context->createTmpFilePath($extension); + $frame = $video->frame($from); + $outputPath = $context->createTmpFilePath($commonArgs->getExtension()); $frame->save($outputPath); - unset($frame, $video, $ffmpeg); + unset($frame, $video); gc_collect_cycles(); return new OutputFile( $outputPath, $outputFormat->getMimeType(), $outputFormat->getFamily(), - false // TODO implement projection + false, ); } }