diff --git a/databox/api/composer.lock b/databox/api/composer.lock index abec40b94..b7cbe4152 100644 --- a/databox/api/composer.lock +++ b/databox/api/composer.lock @@ -469,13 +469,14 @@ "dist": { "type": "path", "url": "../../lib/php/rendition-factory", - "reference": "97ad1746b89133329630d9719d635d6c6bd40ac1" + "reference": "358bb075afd9828aa98e7c10bf639761ab72cbe8" }, "require": { "ext-imagick": "^3.7", "imagine/imagine": "^1.3", "liip/imagine-bundle": "^2.13", "php": "^8.2", + "php-ffmpeg/php-ffmpeg": "^1.2", "symfony/console": "^6", "symfony/http-client": "^6.4.11", "symfony/process": "^6.3", @@ -3536,6 +3537,53 @@ }, "time": "2023-04-21T15:31:12+00:00" }, + { + "name": "evenement/evenement", + "version": "v3.0.2", + "source": { + "type": "git", + "url": "https://github.com/igorw/evenement.git", + "reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/igorw/evenement/zipball/0a16b0d71ab13284339abb99d9d2bd813640efbc", + "reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc", + "shasum": "" + }, + "require": { + "php": ">=7.0" + }, + "require-dev": { + "phpunit/phpunit": "^9 || ^6" + }, + "type": "library", + "autoload": { + "psr-4": { + "Evenement\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Igor Wiedler", + "email": "igor@wiedler.ch" + } + ], + "description": "Événement is a very simple event dispatching library for PHP", + "keywords": [ + "event-dispatcher", + "event-emitter" + ], + "support": { + "issues": "https://github.com/igorw/evenement/issues", + "source": "https://github.com/igorw/evenement/tree/v3.0.2" + }, + "time": "2023-08-08T05:53:35+00:00" + }, { "name": "exercise/htmlpurifier-bundle", "version": "4.1.2", @@ -6056,6 +6104,95 @@ }, "time": "2024-04-22T22:05:04+00:00" }, + { + "name": "php-ffmpeg/php-ffmpeg", + "version": "v1.2.0", + "source": { + "type": "git", + "url": "https://github.com/PHP-FFMpeg/PHP-FFMpeg.git", + "reference": "785a5ba05dd88b3b8146f85f18476b259b23917c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHP-FFMpeg/PHP-FFMpeg/zipball/785a5ba05dd88b3b8146f85f18476b259b23917c", + "reference": "785a5ba05dd88b3b8146f85f18476b259b23917c", + "shasum": "" + }, + "require": { + "evenement/evenement": "^3.0", + "php": "^8.0 || ^8.1 || ^8.2 || ^8.3", + "psr/log": "^1.0 || ^2.0 || ^3.0", + "spatie/temporary-directory": "^2.0", + "symfony/cache": "^5.4 || ^6.0 || ^7.0", + "symfony/process": "^5.4 || ^6.0 || ^7.0" + }, + "require-dev": { + "mockery/mockery": "^1.5", + "phpunit/phpunit": "^9.5.10" + }, + "suggest": { + "php-ffmpeg/extras": "A compilation of common audio & video drivers for PHP-FFMpeg" + }, + "type": "library", + "autoload": { + "psr-4": { + "FFMpeg\\": "src/FFMpeg", + "Alchemy\\BinaryDriver\\": "src/Alchemy/BinaryDriver" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Romain Neutron", + "email": "imprec@gmail.com", + "homepage": "http://www.lickmychip.com/" + }, + { + "name": "Phraseanet Team", + "email": "info@alchemy.fr", + "homepage": "http://www.phraseanet.com/" + }, + { + "name": "Patrik Karisch", + "email": "patrik@karisch.guru", + "homepage": "http://www.karisch.guru" + }, + { + "name": "Romain Biard", + "email": "romain.biard@gmail.com", + "homepage": "https://www.strime.io/" + }, + { + "name": "Jens Hausdorf", + "email": "hello@jens-hausdorf.de", + "homepage": "https://jens-hausdorf.de" + }, + { + "name": "Pascal Baljet", + "email": "pascal@protone.media", + "homepage": "https://protone.media" + } + ], + "description": "FFMpeg PHP, an Object Oriented library to communicate with AVconv / ffmpeg", + "keywords": [ + "audio", + "audio processing", + "avconv", + "avprobe", + "ffmpeg", + "ffprobe", + "video", + "video processing" + ], + "support": { + "issues": "https://github.com/PHP-FFMpeg/PHP-FFMpeg/issues", + "source": "https://github.com/PHP-FFMpeg/PHP-FFMpeg/tree/v1.2.0" + }, + "time": "2024-01-02T10:37:01+00:00" + }, { "name": "php-http/client-common", "version": "2.7.1", @@ -8272,6 +8409,67 @@ ], "time": "2024-02-26T09:27:19+00:00" }, + { + "name": "spatie/temporary-directory", + "version": "2.2.1", + "source": { + "type": "git", + "url": "https://github.com/spatie/temporary-directory.git", + "reference": "76949fa18f8e1a7f663fd2eaa1d00e0bcea0752a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/temporary-directory/zipball/76949fa18f8e1a7f663fd2eaa1d00e0bcea0752a", + "reference": "76949fa18f8e1a7f663fd2eaa1d00e0bcea0752a", + "shasum": "" + }, + "require": { + "php": "^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Spatie\\TemporaryDirectory\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Alex Vanderbist", + "email": "alex@spatie.be", + "homepage": "https://spatie.be", + "role": "Developer" + } + ], + "description": "Easily create, use and destroy temporary directories", + "homepage": "https://github.com/spatie/temporary-directory", + "keywords": [ + "php", + "spatie", + "temporary-directory" + ], + "support": { + "issues": "https://github.com/spatie/temporary-directory/issues", + "source": "https://github.com/spatie/temporary-directory/tree/2.2.1" + }, + "funding": [ + { + "url": "https://spatie.be/open-source/support-us", + "type": "custom" + }, + { + "url": "https://github.com/spatie", + "type": "github" + } + ], + "time": "2023-12-25T11:46:58+00:00" + }, { "name": "stof/doctrine-extensions-bundle", "version": "v1.12.0", @@ -14667,53 +14865,6 @@ ], "time": "2024-05-06T16:37:16+00:00" }, - { - "name": "evenement/evenement", - "version": "v3.0.2", - "source": { - "type": "git", - "url": "https://github.com/igorw/evenement.git", - "reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/igorw/evenement/zipball/0a16b0d71ab13284339abb99d9d2bd813640efbc", - "reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc", - "shasum": "" - }, - "require": { - "php": ">=7.0" - }, - "require-dev": { - "phpunit/phpunit": "^9 || ^6" - }, - "type": "library", - "autoload": { - "psr-4": { - "Evenement\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Igor Wiedler", - "email": "igor@wiedler.ch" - } - ], - "description": "Événement is a very simple event dispatching library for PHP", - "keywords": [ - "event-dispatcher", - "event-emitter" - ], - "support": { - "issues": "https://github.com/igorw/evenement/issues", - "source": "https://github.com/igorw/evenement/tree/v3.0.2" - }, - "time": "2023-08-08T05:53:35+00:00" - }, { "name": "fidry/cpu-core-counter", "version": "1.2.0", diff --git a/lib/php/rendition-factory-bundle/Resources/config/services.yaml b/lib/php/rendition-factory-bundle/Resources/config/services.yaml index 22bf0c079..fc977af0c 100644 --- a/lib/php/rendition-factory-bundle/Resources/config/services.yaml +++ b/lib/php/rendition-factory-bundle/Resources/config/services.yaml @@ -24,6 +24,18 @@ services: tags: - { name: !php/const Alchemy\RenditionFactory\Transformer\TransformerModuleInterface::TAG } +# Alchemy\RenditionFactory\Transformer\Video\VideoToAnimatedGifTransformerModule: +# tags: +# - { name: !php/const Alchemy\RenditionFactory\Transformer\TransformerModuleInterface::TAG } +# +# Alchemy\RenditionFactory\Transformer\Video\VideoResizeTransformerModule: +# tags: +# - { name: !php/const Alchemy\RenditionFactory\Transformer\TransformerModuleInterface::TAG } + + Alchemy\RenditionFactory\Transformer\Video\FFMpegTransformerModule: + tags: + - { name: !php/const Alchemy\RenditionFactory\Transformer\TransformerModuleInterface::TAG } + Imagine\Imagick\Imagine: ~ Imagine\Image\ImagineInterface: '@Imagine\Imagick\Imagine' diff --git a/lib/php/rendition-factory/composer.json b/lib/php/rendition-factory/composer.json index c86b819a4..89ae71c1c 100644 --- a/lib/php/rendition-factory/composer.json +++ b/lib/php/rendition-factory/composer.json @@ -29,7 +29,8 @@ "symfony/process": "^6.3", "imagine/imagine": "^1.3", "liip/imagine-bundle": "^2.13", - "symfony/http-client": "^6.4.11" + "symfony/http-client": "^6.4.11", + "php-ffmpeg/php-ffmpeg": "^1.2" }, "require-dev": { "friendsofphp/php-cs-fixer": "^3.17", diff --git a/lib/php/rendition-factory/composer.lock b/lib/php/rendition-factory/composer.lock index f43c2170c..af111e941 100644 --- a/lib/php/rendition-factory/composer.lock +++ b/lib/php/rendition-factory/composer.lock @@ -4,8 +4,55 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "01777922f21dd3f6c6e1296c8700115a", + "content-hash": "2a9685071015bdadcedbff0408f6141e", "packages": [ + { + "name": "evenement/evenement", + "version": "v3.0.2", + "source": { + "type": "git", + "url": "https://github.com/igorw/evenement.git", + "reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/igorw/evenement/zipball/0a16b0d71ab13284339abb99d9d2bd813640efbc", + "reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc", + "shasum": "" + }, + "require": { + "php": ">=7.0" + }, + "require-dev": { + "phpunit/phpunit": "^9 || ^6" + }, + "type": "library", + "autoload": { + "psr-4": { + "Evenement\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Igor Wiedler", + "email": "igor@wiedler.ch" + } + ], + "description": "Événement is a very simple event dispatching library for PHP", + "keywords": [ + "event-dispatcher", + "event-emitter" + ], + "support": { + "issues": "https://github.com/igorw/evenement/issues", + "source": "https://github.com/igorw/evenement/tree/v3.0.2" + }, + "time": "2023-08-08T05:53:35+00:00" + }, { "name": "imagine/imagine", "version": "1.3.5", @@ -174,6 +221,95 @@ }, "time": "2024-09-04T12:55:26+00:00" }, + { + "name": "php-ffmpeg/php-ffmpeg", + "version": "v1.2.0", + "source": { + "type": "git", + "url": "https://github.com/PHP-FFMpeg/PHP-FFMpeg.git", + "reference": "785a5ba05dd88b3b8146f85f18476b259b23917c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHP-FFMpeg/PHP-FFMpeg/zipball/785a5ba05dd88b3b8146f85f18476b259b23917c", + "reference": "785a5ba05dd88b3b8146f85f18476b259b23917c", + "shasum": "" + }, + "require": { + "evenement/evenement": "^3.0", + "php": "^8.0 || ^8.1 || ^8.2 || ^8.3", + "psr/log": "^1.0 || ^2.0 || ^3.0", + "spatie/temporary-directory": "^2.0", + "symfony/cache": "^5.4 || ^6.0 || ^7.0", + "symfony/process": "^5.4 || ^6.0 || ^7.0" + }, + "require-dev": { + "mockery/mockery": "^1.5", + "phpunit/phpunit": "^9.5.10" + }, + "suggest": { + "php-ffmpeg/extras": "A compilation of common audio & video drivers for PHP-FFMpeg" + }, + "type": "library", + "autoload": { + "psr-4": { + "FFMpeg\\": "src/FFMpeg", + "Alchemy\\BinaryDriver\\": "src/Alchemy/BinaryDriver" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Romain Neutron", + "email": "imprec@gmail.com", + "homepage": "http://www.lickmychip.com/" + }, + { + "name": "Phraseanet Team", + "email": "info@alchemy.fr", + "homepage": "http://www.phraseanet.com/" + }, + { + "name": "Patrik Karisch", + "email": "patrik@karisch.guru", + "homepage": "http://www.karisch.guru" + }, + { + "name": "Romain Biard", + "email": "romain.biard@gmail.com", + "homepage": "https://www.strime.io/" + }, + { + "name": "Jens Hausdorf", + "email": "hello@jens-hausdorf.de", + "homepage": "https://jens-hausdorf.de" + }, + { + "name": "Pascal Baljet", + "email": "pascal@protone.media", + "homepage": "https://protone.media" + } + ], + "description": "FFMpeg PHP, an Object Oriented library to communicate with AVconv / ffmpeg", + "keywords": [ + "audio", + "audio processing", + "avconv", + "avprobe", + "ffmpeg", + "ffprobe", + "video", + "video processing" + ], + "support": { + "issues": "https://github.com/PHP-FFMpeg/PHP-FFMpeg/issues", + "source": "https://github.com/PHP-FFMpeg/PHP-FFMpeg/tree/v1.2.0" + }, + "time": "2024-01-02T10:37:01+00:00" + }, { "name": "psr/cache", "version": "3.0.0", @@ -376,6 +512,67 @@ }, "time": "2024-08-21T13:31:24+00:00" }, + { + "name": "spatie/temporary-directory", + "version": "2.2.1", + "source": { + "type": "git", + "url": "https://github.com/spatie/temporary-directory.git", + "reference": "76949fa18f8e1a7f663fd2eaa1d00e0bcea0752a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/temporary-directory/zipball/76949fa18f8e1a7f663fd2eaa1d00e0bcea0752a", + "reference": "76949fa18f8e1a7f663fd2eaa1d00e0bcea0752a", + "shasum": "" + }, + "require": { + "php": "^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Spatie\\TemporaryDirectory\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Alex Vanderbist", + "email": "alex@spatie.be", + "homepage": "https://spatie.be", + "role": "Developer" + } + ], + "description": "Easily create, use and destroy temporary directories", + "homepage": "https://github.com/spatie/temporary-directory", + "keywords": [ + "php", + "spatie", + "temporary-directory" + ], + "support": { + "issues": "https://github.com/spatie/temporary-directory/issues", + "source": "https://github.com/spatie/temporary-directory/tree/2.2.1" + }, + "funding": [ + { + "url": "https://spatie.be/open-source/support-us", + "type": "custom" + }, + { + "url": "https://github.com/spatie", + "type": "github" + } + ], + "time": "2023-12-25T11:46:58+00:00" + }, { "name": "symfony/cache", "version": "v7.1.4", @@ -3434,53 +3631,6 @@ ], "time": "2024-05-06T16:37:16+00:00" }, - { - "name": "evenement/evenement", - "version": "v3.0.2", - "source": { - "type": "git", - "url": "https://github.com/igorw/evenement.git", - "reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/igorw/evenement/zipball/0a16b0d71ab13284339abb99d9d2bd813640efbc", - "reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc", - "shasum": "" - }, - "require": { - "php": ">=7.0" - }, - "require-dev": { - "phpunit/phpunit": "^9 || ^6" - }, - "type": "library", - "autoload": { - "psr-4": { - "Evenement\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Igor Wiedler", - "email": "igor@wiedler.ch" - } - ], - "description": "Événement is a very simple event dispatching library for PHP", - "keywords": [ - "event-dispatcher", - "event-emitter" - ], - "support": { - "issues": "https://github.com/igorw/evenement/issues", - "source": "https://github.com/igorw/evenement/tree/v3.0.2" - }, - "time": "2023-08-08T05:53:35+00:00" - }, { "name": "fidry/cpu-core-counter", "version": "1.2.0", diff --git a/lib/php/rendition-factory/src/Command/CreateCommand.php b/lib/php/rendition-factory/src/Command/CreateCommand.php index fdfeeef93..db323de33 100644 --- a/lib/php/rendition-factory/src/Command/CreateCommand.php +++ b/lib/php/rendition-factory/src/Command/CreateCommand.php @@ -8,6 +8,7 @@ use Alchemy\RenditionFactory\DTO\CreateRenditionOptions; use Alchemy\RenditionFactory\MimeType\MimeTypeGuesser; use Alchemy\RenditionFactory\RenditionCreator; +use Alchemy\RenditionFactory\Transformer\TransformationContext; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; @@ -30,27 +31,39 @@ protected function configure(): void { parent::configure(); - $this->addArgument('src', InputArgument::REQUIRED, 'The source file'); - $this->addArgument('build-config', InputArgument::REQUIRED, 'The build config YAML file'); - $this->addOption('type', null, InputOption::VALUE_REQUIRED, 'The MIME type of file'); + $this->addOption('input', 'i', InputOption::VALUE_REQUIRED, 'The source file'); + $this->addOption('build-config', 'c', InputOption::VALUE_REQUIRED, 'The build config YAML file'); + $this->addOption('type', 't', InputOption::VALUE_REQUIRED, 'The MIME type of file'); + $this->addOption('output', 'o', InputOption::VALUE_REQUIRED, 'The output file name WITHOUT extension'); $this->addOption('working-dir', 'w', InputOption::VALUE_REQUIRED, 'The working directory. Defaults to system temp directory'); + $this->addOption('debug', 'd', InputOption::VALUE_NONE, 'set to debug mode (keep files in working directory)'); + $this->setHelp("Create a rendition from a source file and a build config\n" + . "without --debug, the working directory will be cleaned up after the rendition is created,\n" + . " so to get the final rendition, one must set --output or/and --debug\n" + . "--output will move the final rendition from the working directory to the specified location ; Extension is added accordingly of (last) module).\n" + ); } protected function execute(InputInterface $input, OutputInterface $output): int { + if(!file_exists($input->getOption('input'))) { + $output->writeln(sprintf('Input file not found: %s', $input->getOption('input'))); + return 1; + } + $mimeType = $input->getOption('type'); if ($mimeType === null) { - $mimeType = $this->mimeTypeGuesser->guessMimeTypeFromPath($input->getArgument('src')); + $mimeType = $this->mimeTypeGuesser->guessMimeTypeFromPath($input->getArgument('input')); } - $buildConfig = $this->yamlLoader->load($input->getArgument('build-config')); + $buildConfig = $this->yamlLoader->load($input->getOption('build-config')); $options = new CreateRenditionOptions( - workingDirectory: $input->getOption('working-dir') + workingDirectory: $input->getOption('working-dir'), ); $outputFile = $this->renditionCreator->createRendition( - $input->getArgument('src'), + $input->getOption('input'), $mimeType, $buildConfig, $options @@ -58,6 +71,18 @@ protected function execute(InputInterface $input, OutputInterface $output): int $output->writeln(sprintf('Rendition created: %s', $outputFile->getPath())); + if ( ($outputPath = $input->getOption('output')) ) { + $pi = pathinfo($outputPath); + @mkdir($pi['dirname'], 0777, true); + $outputPath .= '.' . $outputFile->getExtension(); + rename($outputFile->getPath(), $outputPath); + $output->writeln(sprintf('Rendition moved to: %s', $outputPath)); + } + + if(!$input->getOption('debug')) { + $this->renditionCreator->cleanUp(); + } + return 0; } } diff --git a/lib/php/rendition-factory/src/Context/TransformationContextFactory.php b/lib/php/rendition-factory/src/Context/TransformationContextFactory.php index 910aceee7..0707d34c5 100644 --- a/lib/php/rendition-factory/src/Context/TransformationContextFactory.php +++ b/lib/php/rendition-factory/src/Context/TransformationContextFactory.php @@ -5,6 +5,8 @@ use Alchemy\RenditionFactory\DTO\CreateRenditionOptions; use Alchemy\RenditionFactory\MimeType\MimeTypeGuesser; use Alchemy\RenditionFactory\Transformer\TransformationContext; +use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; use Symfony\Component\HttpClient\NativeHttpClient; use Symfony\Contracts\HttpClient\HttpClientInterface; @@ -16,6 +18,7 @@ public function __construct( private MimeTypeGuesser $mimeTypeGuesser, ?string $workingDirectory = null, private ?HttpClientInterface $client = null, + private ?loggerInterface $logger = null ) { $this->workingDirectory = $workingDirectory ?? sys_get_temp_dir(); @@ -40,7 +43,8 @@ public function create( $workingDir, $cacheDir, $this->mimeTypeGuesser, - $this->client ?? new NativeHttpClient() + $this->client ?? new NativeHttpClient(), + $this->logger ?? new NullLogger() ); } } diff --git a/lib/php/rendition-factory/src/DTO/BaseFile.php b/lib/php/rendition-factory/src/DTO/BaseFile.php index 4b5bd4de2..ee3bc365a 100644 --- a/lib/php/rendition-factory/src/DTO/BaseFile.php +++ b/lib/php/rendition-factory/src/DTO/BaseFile.php @@ -6,12 +6,15 @@ abstract readonly class BaseFile implements BaseFileInterface { + private array $pi; + public function __construct( private string $path, private string $type, private FamilyEnum $family, protected ?MetadataContainerInterface $metadata = null, ) { + $this->pi = pathinfo($path); } public function getPath(): string @@ -33,4 +36,9 @@ public function getMetadata(string $name): string|null { return $this->metadata?->getMetadata($name); } + + public function getExtension(): string + { + return $this->pi['extension']; + } } diff --git a/lib/php/rendition-factory/src/RenditionCreator.php b/lib/php/rendition-factory/src/RenditionCreator.php index 5f5714798..bb29d44ce 100644 --- a/lib/php/rendition-factory/src/RenditionCreator.php +++ b/lib/php/rendition-factory/src/RenditionCreator.php @@ -11,19 +11,24 @@ use Alchemy\RenditionFactory\DTO\InputFileInterface; use Alchemy\RenditionFactory\DTO\OutputFile; use Alchemy\RenditionFactory\DTO\OutputFileInterface; +use Alchemy\RenditionFactory\Transformer\TransformationContext; use Alchemy\RenditionFactory\Transformer\TransformerModuleInterface; use Symfony\Component\DependencyInjection\Attribute\TaggedLocator; use Symfony\Component\DependencyInjection\ServiceLocator; -readonly class RenditionCreator +class RenditionCreator { + /** @var TransformationContext[] */ + private array $createdContexts = []; + /** @var OutputFile[] */ + private array $createdOutputFiles = []; public function __construct( - private TransformationContextFactory $contextFactory, - private FileFamilyGuesser $fileFamilyGuesser, + private readonly TransformationContextFactory $contextFactory, + private readonly FileFamilyGuesser $fileFamilyGuesser, /** @var TransformerModuleInterface[] */ #[TaggedLocator(TransformerModuleInterface::TAG, defaultIndexMethod: 'getName')] - private ServiceLocator $transformers, + private readonly ServiceLocator $transformers, ) { } @@ -55,12 +60,14 @@ public function createRendition( $context = $this->contextFactory->create( $options ); + $this->createdContexts[] = $context; $transformationCount = count($transformations); foreach (array_values($transformations) as $i => $transformation) { /** @var TransformerModuleInterface $transformer */ $transformer = $this->transformers->get($transformation->getModule()); $outputFile = $transformer->transform($inputFile, $transformation->getOptions(), $context); + $this->createdOutputFiles[] = $outputFile; if ($i < $transformationCount) { $inputFile = $outputFile->createNextInputFile(); @@ -70,6 +77,16 @@ public function createRendition( return $outputFile; } + public function cleanUp(): void + { + foreach ($this->createdOutputFiles as $outputFile) { + @unlink($outputFile->getPath()); + } + foreach ($this->createdContexts as $context) { + @rmdir($context->getWorkingDirectory()); + } + } + private function createOutputFromInput(InputFileInterface $inputFile): OutputFileInterface { return new OutputFile($inputFile->getPath(), $inputFile->getType(), $inputFile->getFamily()); diff --git a/lib/php/rendition-factory/src/Transformer/Image/ThumbnailImageTransformerModule.php b/lib/php/rendition-factory/src/Transformer/Image/ThumbnailImageTransformerModule.php index 586acf7fa..e46185a16 100644 --- a/lib/php/rendition-factory/src/Transformer/Image/ThumbnailImageTransformerModule.php +++ b/lib/php/rendition-factory/src/Transformer/Image/ThumbnailImageTransformerModule.php @@ -3,7 +3,6 @@ namespace Alchemy\RenditionFactory\Transformer\Image; use Alchemy\RenditionFactory\DTO\FamilyEnum; -use Alchemy\RenditionFactory\DTO\ImagineOutputFile; use Alchemy\RenditionFactory\DTO\InputFileInterface; use Alchemy\RenditionFactory\DTO\OutputFile; use Alchemy\RenditionFactory\DTO\OutputFileInterface; @@ -90,14 +89,14 @@ public function transform(InputFileInterface $inputFile, array $options, Transfo $outputType = 'image/'.$outputType; } - $outputFormat = match ($outputType) { - 'image/jpeg' => 'jpeg', + $outputExtension = match ($outputType) { + 'image/jpeg' => 'jpg', 'image/png' => 'png', 'image/gif' => 'gif', - default => 'jpeg', + default => 'jpg', }; - $newPath = $context->createTmpFilePath($outputFormat); + $newPath = $context->createTmpFilePath($outputExtension); $image->save($newPath, $imagineOptions); unset($image); diff --git a/lib/php/rendition-factory/src/Transformer/TransformationContext.php b/lib/php/rendition-factory/src/Transformer/TransformationContext.php index 5af85833f..646323e9a 100644 --- a/lib/php/rendition-factory/src/Transformer/TransformationContext.php +++ b/lib/php/rendition-factory/src/Transformer/TransformationContext.php @@ -3,6 +3,7 @@ namespace Alchemy\RenditionFactory\Transformer; use Alchemy\RenditionFactory\MimeType\MimeTypeGuesser; +use Psr\Log\LoggerInterface; use Symfony\Contracts\HttpClient\HttpClientInterface; final readonly class TransformationContext @@ -12,6 +13,7 @@ public function __construct( private string $cacheDir, private MimeTypeGuesser $mimeTypeGuesser, private HttpClientInterface $client, + private loggerInterface $logger ) { } @@ -75,4 +77,14 @@ private function download(string $uri, string $dest): void } fclose($fileHandler); } + + public function getWorkingDirectory(): string + { + return $this->workingDirectory; + } + + public function getLogger(): loggerInterface + { + return $this->logger; + } } diff --git a/lib/php/rendition-factory/src/Transformer/Video/FFMpegTransformerModule.php b/lib/php/rendition-factory/src/Transformer/Video/FFMpegTransformerModule.php new file mode 100644 index 000000000..c498a78e6 --- /dev/null +++ b/lib/php/rendition-factory/src/Transformer/Video/FFMpegTransformerModule.php @@ -0,0 +1,202 @@ +open('/path/to/video'); + + if(!($format = $options['format'])) { + throw new \InvalidArgumentException('Missing format'); + } + if(!($extension = $options['extension'])) { + throw new \InvalidArgumentException('Missing extension'); + } + $format = "FFMpeg\\Format\\Video\\" . $format; + if(!class_exists($format)) { + throw new \InvalidArgumentException('Invalid format'); + } + /** @var FFMpeg\Format\VideoInterface $ouputFormat */ + $outputFormat = new $format(); + + $video = $ffmpeg->open( $inputFile->getPath()); + $videoFiltered = $video->filters(); + foreach($options['filters']??[] as $filter) { + if(!method_exists($this, $filter['name'])) { + throw new \InvalidArgumentException(sprintf('Invalid filter: %s', $filter['name'])); + } + $context->getLogger()->info(sprintf('Applying filter: %s', $filter['name'])); + + /** @uses self::resize(), self::rotate(), self::pad(), self::crop(), self::clip(), self::synchronize() + * @uses self::watermark(), self::framerate() + */ + $videoFiltered = call_user_func([$this, $filter['name']], $videoFiltered, $filter, $context); + } + + $outputPath = $context->createTmpFilePath($extension); + + $video->save($outputFormat, $outputPath); + + unset($video); + + return new OutputFile( + $outputPath, + 'application/octet-stream', + FamilyEnum::Unknown + ); + } + + // ---------- filters ---------- + + private function resize(VideoFilters $video, array $options, TransformationContext $context) { + $dimension = $this->getDimension($options, 'resize'); + $mode = $options['mode'] ?? FFMpeg\Filters\Video\ResizeFilter::RESIZEMODE_INSET; + if(!in_array( + $mode, + [ + FFMpeg\Filters\Video\ResizeFilter::RESIZEMODE_INSET, + FFMpeg\Filters\Video\ResizeFilter::RESIZEMODE_FIT, + FFMpeg\Filters\Video\ResizeFilter::RESIZEMODE_SCALE_WIDTH, + FFMpeg\Filters\Video\ResizeFilter::RESIZEMODE_SCALE_HEIGHT + ] + )) { + throw new \InvalidArgumentException('Invalid mode for filter "resize"'); + } + + return $video->resize( + $dimension, + $mode + ); + } + + private function rotate(VideoFilters $video, array $options, TransformationContext $context) { + 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; + if(!array_key_exists($angle, $rotations)) { + throw new \InvalidArgumentException('Invalid rotation, must be 90, 180 or 270 for filter "rotate"'); + } + + return $video->rotate($rotations[$angle]); + } + + private function pad(VideoFilters $video, array $options, TransformationContext $context) + { + $dimension = $this->getDimension($options, 'pad'); + + return $video->pad($dimension); + } + + private function crop(VideoFilters $video, array $options, TransformationContext $context) + { + $point = new FFMpeg\Coordinate\Point($options['x'] ?? 0, $options['y'] ?? 0); + $dimension = $this->getDimension($options, 'crop'); + + return $video->crop($point, $dimension); + } + + private function clip(VideoFilters $video, array $options, TransformationContext $context) + { + $start = $options['start'] ?? 0; + $duration = $options['duration'] ?? null; + + $startAsTimecode = $durationAsTimecode = false; + if(is_string($start)) { + $startAsTimecode = FFMpeg\Coordinate\TimeCode::fromString($start); + } + elseif(is_int($start) && $start >= 0) { + $startAsTimecode = FFMpeg\Coordinate\TimeCode::fromSeconds($start); + } + if($startAsTimecode === false) { + throw new \InvalidArgumentException('Invalid start for filter "clip"'); + } + + if(is_string($duration)) { + $durationAsTimecode = FFMpeg\Coordinate\TimeCode::fromString($duration); + } + elseif(is_int($duration) && $duration > 0) { + $durationAsTimecode = FFMpeg\Coordinate\TimeCode::fromSeconds($duration); + } + if($durationAsTimecode === false) { + throw new \InvalidArgumentException('Invalid duration for filter "clip"'); + } + + return $video->clip($startAsTimecode, $durationAsTimecode); + } + + private function synchronize(VideoFilters $video, array $options, TransformationContext $context) + { + return $video->synchronize(); + } + + private function watermark(VideoFilters $video, array $options, TransformationContext $context) + { + $path = $options['path']??null; + if(!file_exists($path)) { + throw new \InvalidArgumentException('Watermark file for filter "watermark" not found'); + } + $position = $options['position']??'absolute'; + if($position == 'relative') { + $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) + || array_key_exists('right', $coord) && array_key_exists('left', $coord)) { + throw new \InvalidArgumentException('Invalid relative coordinates for filter "watermark", only one of top/bottom or left/right can be set'); + } + // in wm filter, missing coord are set to 0 + } + elseif($position == 'absolute') { + $coord = array_filter($options, fn($k) => in_array($k, ['x', 'y']), ARRAY_FILTER_USE_KEY); + } + else { + throw new \InvalidArgumentException('Invalid position for filter "watermark"'); + } + + return $video->watermark($path, $coord); + } + + private function framerate(VideoFilters $video, array $options, TransformationContext $context) + { + $framerate = $options['framerate']??0; + if($framerate <= 0) { + throw new \InvalidArgumentException('Invalid framerate for filter "framerate"'); + } + $gop = $options['gop']??0; + + return $video->framerate(new FFMpeg\Coordinate\FrameRate($framerate), $gop); + } + + //---------------------------------------------- + + private function getDimension(array $options, string $filterName):FFMpeg\Coordinate\Dimension + { + $width = $options['width'] ?? 0; + $height = $options['height'] ?? 0; + if ($width <= 0 || $height <= 0) { + throw new \InvalidArgumentException(sprintf('Invalid width/height for filter "%s"', $filterName)); + } + return new FFMpeg\Coordinate\Dimension($width, $height); + } +} diff --git a/lib/php/rendition-factory/src/Transformer/VoidTransformerModule.php b/lib/php/rendition-factory/src/Transformer/VoidTransformerModule.php index 615f3bb77..4e1c7605c 100644 --- a/lib/php/rendition-factory/src/Transformer/VoidTransformerModule.php +++ b/lib/php/rendition-factory/src/Transformer/VoidTransformerModule.php @@ -9,7 +9,7 @@ class VoidTransformerModule implements TransformerModuleInterface { public static function getName(): string { - return 'void'; + return 'Void'; } public function transform(InputFileInterface $inputFile, array $options, TransformationContext $context): OutputFile