├── src ├── Video │ ├── Logger │ │ ├── LoggerInterface.php │ │ └── NullLoggerFactory.php │ ├── Info │ │ ├── SubtitleStreamInterface.php │ │ ├── StreamCollectionInterface.php │ │ ├── StreamTypeInterface.php │ │ ├── AudioStreamCollectionInterface.php │ │ ├── VideoStreamCollectionInterface.php │ │ ├── SubtitleStreamCollectionInterface.php │ │ ├── AudioStreamInterface.php │ │ ├── StreamInterface.php │ │ ├── AudioStreamCollection.php │ │ ├── SubtitleStreamCollection.php │ │ ├── SubtitleStream.php │ │ ├── VideoStreamCollection.php │ │ ├── AspectRatio.php │ │ ├── AudioStream.php │ │ ├── VideoStreamInterface.php │ │ └── Util │ │ │ └── MetadataTypeSafeReader.php │ ├── Cache │ │ ├── CacheInterface.php │ │ └── NullCacheFactory.php │ ├── Filter │ │ ├── Type │ │ │ ├── FilterInterface.php │ │ │ ├── VideoDenoiserInterface.php │ │ │ ├── VideoDeinterlacerInterface.php │ │ │ ├── VideoFilterInterface.php │ │ │ └── FFMpegVideoFilterInterface.php │ │ ├── EmptyVideoFilter.php │ │ ├── IdetVideoFilter.php │ │ ├── Hqdn3DVideoFilter.php │ │ ├── NlmeansVideoFilter.php │ │ ├── SelectFilter.php │ │ ├── YadifVideoFilter.php │ │ ├── CropFilter.php │ │ ├── VideoFilterChain.php │ │ └── ScaleFilter.php │ ├── Exception │ │ ├── ParamValidationException.php │ │ ├── ProcessSignaledException.php │ │ ├── ProcessTimedOutException.php │ │ ├── MissingFFMpegBinaryException.php │ │ ├── MissingFFProbeBinaryException.php │ │ ├── MissingTimeException.php │ │ ├── NoStreamException.php │ │ ├── InvalidArgumentException.php │ │ ├── UnexpectedValueException.php │ │ ├── AnalyzerExceptionInterface.php │ │ ├── UnexpectedMetadataException.php │ │ ├── ConverterExceptionInterface.php │ │ ├── InfoReaderExceptionInterface.php │ │ ├── InvalidStreamMetadataException.php │ │ ├── InvalidFFProbeJsonException.php │ │ ├── NoOutputGeneratedException.php │ │ ├── RuntimeReaderException.php │ │ ├── AnalyzerProcessExceptionInterface.php │ │ ├── ConverterProcessExceptionInterface.php │ │ ├── InfoProcessReaderExceptionInterface.php │ │ ├── UnsetParamException.php │ │ ├── InvalidParamException.php │ │ ├── MissingInputFileException.php │ │ └── ProcessFailedException.php │ ├── Process │ │ └── ProcessParams.php │ ├── VideoInfoReaderInterface.php │ ├── Adapter │ │ ├── FFMpegCLIValueInterface.php │ │ ├── ConverterAdapterInterface.php │ │ └── Validator │ │ │ └── FFMpegParamValidator.php │ ├── Config │ │ ├── FFProbeConfigInterface.php │ │ ├── FFMpegConfigInterface.php │ │ ├── FFProbeConfigFactory.php │ │ ├── FFMpegConfigFactory.php │ │ ├── FFProbeConfig.php │ │ ├── FFMpegConfig.php │ │ └── ConfigProvider.php │ ├── VideoThumbGeneratorInterface.php │ ├── VideoAnalyzerFactory.php │ ├── VideoConverterFactory.php │ ├── VideoThumbGeneratorFactory.php │ ├── VideoAnalyzerInterface.php │ ├── VideoInfoReaderFactory.php │ ├── VideoConverterInterface.php │ ├── VideoThumbParamsInterface.php │ ├── VideoAnalyzer.php │ ├── VideoInfoInterface.php │ ├── SeekTime.php │ ├── Detection │ │ ├── InterlaceDetect.php │ │ └── InterlaceDetectGuess.php │ ├── VideoConvertParamsInterface.php │ ├── VideoThumbParams.php │ ├── VideoConverter.php │ └── VideoInfoReader.php └── Common │ ├── Exception │ ├── FileEmptyException.php │ ├── ExceptionInterface.php │ ├── FileNotFoundException.php │ ├── JsonParseException.php │ ├── FileNotReadableException.php │ ├── IOExceptionInterface.php │ ├── InvalidConfigException.php │ ├── MissingBinaryException.php │ ├── IOException.php │ ├── RuntimeException.php │ ├── UnsupportedParamValueException.php │ ├── UnsupportedParamException.php │ ├── InvalidArgumentException.php │ ├── UnexpectedValueException.php │ ├── ProcessExceptionInterface.php │ └── ProcessException.php │ ├── IO │ ├── UnescapedFileInterface.php │ └── PlatformNullFile.php │ ├── Math │ └── NumberConversion.php │ ├── Assert │ ├── BitrateAssertionsTrait.php │ ├── BinaryAssertionsTrait.php │ └── PathAssertionsTrait.php │ ├── Process │ ├── ProcessParamsInterface.php │ ├── ProcessFactory.php │ └── ProcessParams.php │ ├── Service │ └── ActionParamInterface.php │ ├── Config │ ├── ContainerConfigLocator.php │ └── SafeConfigReader.php │ └── Cache │ └── NullCache.php ├── infection.json ├── .editorconfig ├── captainhook.json ├── .github └── workflows │ └── publish-doc.yml ├── LICENSE.md ├── CONTRIBUTING.md ├── phpunit.xml.legacy.dist ├── UPGRADE.md ├── config └── soluble-mediatools.config.php ├── phpcs.xml.dist ├── psalm.xml ├── composer.json └── README.md /src/Video/Logger/LoggerInterface.php: -------------------------------------------------------------------------------- 1 | 0) { 20 | return floor($number * $power) / $power; 21 | } 22 | 23 | return ceil($number * $power) / $power; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Video/Info/AudioStreamInterface.php: -------------------------------------------------------------------------------- 1 | 24 | */ 25 | public function getTags(): array; 26 | 27 | /** 28 | * Return stream bitrate if available (depends on encoder params). 29 | */ 30 | public function getBitRate(): ?int; 31 | } 32 | -------------------------------------------------------------------------------- /src/Video/Filter/Hqdn3DVideoFilter.php: -------------------------------------------------------------------------------- 1 | 36 | */ 37 | public function getStreamMetadata(): array; 38 | } 39 | -------------------------------------------------------------------------------- /src/Video/VideoThumbGeneratorInterface.php: -------------------------------------------------------------------------------- 1 | has(LoggerInterface::class) ? $container->get(LoggerInterface::class) : null; 23 | 24 | return new VideoAnalyzer( 25 | $container->get(FFMpegConfigInterface::class), 26 | $logger 27 | ); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Video/VideoConverterFactory.php: -------------------------------------------------------------------------------- 1 | has(LoggerInterface::class) ? $container->get(LoggerInterface::class) : null; 23 | 24 | return new VideoConverter( 25 | $container->get(FFMpegConfigInterface::class), 26 | $logger 27 | ); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Video/VideoThumbGeneratorFactory.php: -------------------------------------------------------------------------------- 1 | has(LoggerInterface::class) ? $container->get(LoggerInterface::class) : null; 23 | 24 | return new VideoThumbGenerator( 25 | $container->get(FFMpegConfigInterface::class), 26 | $logger 27 | ); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Video/VideoAnalyzerInterface.php: -------------------------------------------------------------------------------- 1 | 33 | */ 34 | public function getEnv(): array; 35 | } 36 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018-2020 Vanvelthem Sébastien 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /src/Video/Config/FFMpegConfigInterface.php: -------------------------------------------------------------------------------- 1 | has(LoggerInterface::class) ? $container->get(LoggerInterface::class) : null; 24 | $cache = $container->has(CacheInterface::class) ? $container->get(CacheInterface::class) : null; 25 | 26 | return new VideoInfoReader( 27 | $container->get(FFProbeConfigInterface::class), 28 | $logger, 29 | $cache 30 | ); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Video/Filter/SelectFilter.php: -------------------------------------------------------------------------------- 1 | expression = $expression; 32 | } 33 | 34 | public function getFFmpegCLIValue(): string 35 | { 36 | return sprintf( 37 | 'select=%s', 38 | str_replace('"', '\"', $this->expression ?? '') 39 | ); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # CONTRIBUTING 2 | 3 | ## Recommended workflow 4 | 5 | ### Step 1: Make your own fork. 6 | 7 | 1. Setup a [GitHub account](https://github.com/), if you haven't yet. 8 | 2. Fork the project (i.e from the github project page). 9 | 3. Clone your newly created fork: 10 | 11 | ```shell 12 | $ git clone https://github.com//soluble-mediatools.git` 13 | ``` 14 | 15 | 4. Install deps 16 | 17 | ```shell 18 | $ composer update 19 | ``` 20 | 21 | ### Step 2: Change code 22 | 23 | 1. Create a new branch from master (i.e. feature/24) 24 | 2. Modify the code... Fix, improve :) 25 | 26 | ### Step 3: Release a P/R (pull request) 27 | 28 | If you have disabled .githooks: 29 | 30 | 1. First ensure the code is clean 31 | 32 | ```shell 33 | $ composer fix 34 | $ composer check 35 | ``` 36 | 2. Commit/Push your pull request. 37 | 38 | 39 | ## Notes 40 | 41 | ### Mkdocs 42 | 43 | If you're working on documentation, please install mkdocs, mkdocs-material...: 44 | 45 | ```shell 46 | $ pip install -r ./requirements.txt --upgrade 47 | ``` 48 | You can serve the doc: 49 | 50 | ```shell 51 | $ mkdocs serve --dev-addr localhost:8081 52 | ``` 53 | 54 | To publish the doc to github: 55 | 56 | ```shell 57 | $ mkdocs gh-deploy 58 | ``` 59 | 60 | 61 | 62 | -------------------------------------------------------------------------------- /phpunit.xml.legacy.dist: -------------------------------------------------------------------------------- 1 | 2 | 12 | 13 | 14 | 15 | ./tests/unit 16 | 17 | 18 | ./tests/functional 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | ./src 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /src/Common/Service/ActionParamInterface.php: -------------------------------------------------------------------------------- 1 | 22 | */ 23 | public function toArray(): array; 24 | 25 | /** 26 | * Test whether a param is built-in or valid. 27 | */ 28 | public function isParamValid(string $paramName): bool; 29 | 30 | /** 31 | * Return a param, throw an exception if the param has not been defined yet or 32 | * use $default if it was set. 33 | * 34 | * @param mixed $default Will return default value instead of throwing exception 35 | * 36 | * @return mixed 37 | * 38 | * @throws UnsetParamException 39 | */ 40 | public function getParam(string $paramName, $default = null); 41 | 42 | public function hasParam(string $paramName): bool; 43 | } 44 | -------------------------------------------------------------------------------- /src/Common/Process/ProcessFactory.php: -------------------------------------------------------------------------------- 1 | command = $command; 31 | $this->processParams = $processParams; 32 | } 33 | 34 | public function __invoke(): Process 35 | { 36 | $process = new Process($this->command); 37 | if ($this->processParams !== null) { 38 | $process->setTimeout($this->processParams->getTimeout()); 39 | $process->setIdleTimeout($this->processParams->getIdleTimeout()); 40 | $process->setEnv($this->processParams->getEnv()); 41 | } 42 | 43 | return $process; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /UPGRADE.md: -------------------------------------------------------------------------------- 1 | ## From 1.0 to 2.0 2 | 3 | - V2 Introduced final keywords for classes that are not covered by semver guarantee. 4 | Users should not be impacted except if you inherit those classes. An upgrade to ^2.0 5 | should be no-pain. 6 | 7 | ## From 0.9 to 1.0 8 | 9 | - `VideoInfo::getVideoBitRate(): int` -> Use `VideoInfo::getVideoStreams()->getFirst()->getBitRate()` instead. 10 | - `VideoInfo::getAudioBitRate(): int` -> Use `VideoInfo::getAudioStreams()->getFirst()->getBitRate()` instead. 11 | - `VideoInfo::getVideoCodecName(): ?string` -> Use `VideoInfo::getVideoStreams()->getFirst()->getCodecName()` instead. 12 | - `VideoInfo::getAudioCodecName(): ?string` -> Use `VideoInfo::getAudioStreams()->getFirst()->getCodecName()` instead. 13 | 14 | 15 | ## From 0.8 to 0.9 16 | 17 | - `VideoInfoInterface::getBitrate()` renamed to `VideoInfoInterface::getVideoBitrate()` 18 | 19 | ## From 0.7 to 0.8 20 | 21 | - `Soluble\MediaTools\Video\Exception\UnsetParamReaderException` renamed into `UnsetParamException`. 22 | - `Soluble\MediaTools\Video\Exception\InvalidReaderParamException` renamed into `InvalidParamException`. 23 | 24 | ## From <= 0.6 to 0.7 25 | 26 | A lot of renaming after code review. 27 | 28 | Search and replace 29 | 30 | - `ConversionService` to `VideoConverter`. 31 | - `ConversionParams` to `VideoConvertParams`. 32 | - `InfoService` to `VideoInfoReader`. 33 | - `Info` to `VideoInfo`. 34 | - `ThumbService` to `VideoThumbGenerator`. 35 | - `DetectionService` to `VideoAnalyzer`. 36 | -------------------------------------------------------------------------------- /src/Common/Assert/BinaryAssertionsTrait.php: -------------------------------------------------------------------------------- 1 | 23 | * 24 | * @throws UnsupportedParamException 25 | * @throws UnsupportedParamValueException 26 | */ 27 | public function getMappedConversionParams(VideoConvertParamsInterface $conversionParams): array; 28 | 29 | /** 30 | * @param array $arguments args that will be added 31 | * @param null|string|UnescapedFileInterface $outputFile 32 | * @param array $prependArguments args that must be added at the beginning of the command 33 | * 34 | * @return array 35 | */ 36 | public function getCliCommand(array $arguments, string $inputFile, $outputFile = null, array $prependArguments = []): array; 37 | 38 | public function getDefaultThreads(): ?int; 39 | } 40 | -------------------------------------------------------------------------------- /src/Common/Config/ContainerConfigLocator.php: -------------------------------------------------------------------------------- 1 | container = $container; 32 | $this->entryName = $entryName; 33 | } 34 | 35 | /** 36 | * @throws InvalidConfigException 37 | */ 38 | public function getConfig(?string $configKey = null): array 39 | { 40 | try { 41 | $containerConfig = $this->container->get($this->entryName); 42 | } catch (\Throwable $e) { 43 | throw new InvalidConfigException( 44 | sprintf('Cannot resolve container entry \'%s\' ($entryName).', $this->entryName) 45 | ); 46 | } 47 | 48 | $config = $configKey === null ? $containerConfig : ($containerConfig[$configKey] ?? null); 49 | 50 | if (!is_array($config)) { 51 | throw new InvalidConfigException( 52 | sprintf('Cannot find a configuration ($entryName=%s found, invalid $configKey=%s).', $this->entryName, $configKey ?? '') 53 | ); 54 | } 55 | 56 | return $config; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Video/Config/FFProbeConfigFactory.php: -------------------------------------------------------------------------------- 1 | entryName = $entryName; 35 | $this->configKey = $configKey; 36 | } 37 | 38 | /** 39 | * @throws InvalidConfigException 40 | */ 41 | public function __invoke(ContainerInterface $container): FFProbeConfigInterface 42 | { 43 | $config = (new ContainerConfigLocator($container, $this->entryName))->getConfig($this->configKey); 44 | 45 | $scr = new SafeConfigReader($config, $this->configKey ?? ''); 46 | 47 | return new FFProbeConfig( 48 | $scr->getNullableString('ffprobe.binary', null), 49 | $scr->getNullableFloat('ffprobe.timeout', FFProbeConfig::DEFAULT_TIMEOUT), 50 | $scr->getNullableFloat('ffprobe.idle_timeout', FFProbeConfig::DEFAULT_IDLE_TIMEOUT), 51 | $scr->getArray('ffprobe.env', FFProbeConfig::DEFAULT_ENV) 52 | ); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/Video/VideoConverterInterface.php: -------------------------------------------------------------------------------- 1 | entryName = $entryName; 35 | $this->configKey = $configKey; 36 | } 37 | 38 | /** 39 | * @throws InvalidConfigException 40 | */ 41 | public function __invoke(ContainerInterface $container): FFMpegConfigInterface 42 | { 43 | $config = (new ContainerConfigLocator($container, $this->entryName))->getConfig($this->configKey); 44 | 45 | $scr = new SafeConfigReader($config, $this->configKey ?? ''); 46 | 47 | return new FFMpegConfig( 48 | $scr->getNullableString('ffmpeg.binary', null), 49 | $scr->getNullableInt('ffmpeg.threads', FFMpegConfig::DEFAULT_THREADS), 50 | $scr->getNullableFloat('ffmpeg.timeout', FFMpegConfig::DEFAULT_TIMEOUT), 51 | $scr->getNullableFloat('ffmpeg.idle_timeout', FFMpegConfig::DEFAULT_IDLE_TIMEOUT), 52 | $scr->getArray('ffmpeg.env', FFMpegConfig::DEFAULT_ENV) 53 | ); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Video/VideoThumbParamsInterface.php: -------------------------------------------------------------------------------- 1 | */ 24 | private $defaultOptions = [ 25 | 'mode' => self::DEFAULT_MODE, 26 | 'parity' => self::DEFAULT_PARITY, 27 | 'deint' => self::DEFAULT_DEINT, 28 | ]; 29 | 30 | /** @var array */ 31 | private $options = []; 32 | 33 | /** 34 | * @param int $mode The interlacing mode to adopt (0, send_frame Output one frame for each frame) 35 | * @param int $parity default=-1 Enable automatic detection of field parity. 0: 36 | * @param int $deint Specify which frames to deinterlace (0: all - Deinterlace all frames.) 37 | */ 38 | public function __construct(int $mode = self::DEFAULT_MODE, int $parity = self::DEFAULT_PARITY, int $deint = self::DEFAULT_DEINT) 39 | { 40 | $this->options = array_merge($this->defaultOptions, [ 41 | 'mode' => $mode, 42 | 'parity' => $parity, 43 | 'deint' => $deint, 44 | ]); 45 | } 46 | 47 | public function getFFmpegCLIValue(): string 48 | { 49 | $yadifArg = sprintf( 50 | 'yadif=mode=%s:parity=%s:deint=%s', 51 | $this->options['mode'], 52 | $this->options['parity'], 53 | $this->options['deint'] 54 | ); 55 | 56 | return $yadifArg; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Video/Info/AudioStreamCollection.php: -------------------------------------------------------------------------------- 1 | */ 13 | private $streamsMetadata; 14 | 15 | /** @var array */ 16 | private $streams; 17 | 18 | /** 19 | * @param array $audioStreamsMetadata 20 | * 21 | * @throws InvalidStreamMetadataException 22 | */ 23 | public function __construct(array $audioStreamsMetadata) 24 | { 25 | $this->streamsMetadata = $audioStreamsMetadata; 26 | $this->loadStreams(); 27 | } 28 | 29 | /** 30 | * @throws NoStreamException 31 | */ 32 | public function getFirst(): AudioStreamInterface 33 | { 34 | if ($this->count() === 0) { 35 | throw new NoStreamException('Unable to get video first stream, none exists'); 36 | } 37 | 38 | return new AudioStream($this->streamsMetadata[0]); 39 | } 40 | 41 | public function count(): int 42 | { 43 | return count($this->streamsMetadata); 44 | } 45 | 46 | /** 47 | * @return \ArrayIterator 48 | */ 49 | public function getIterator(): \ArrayIterator 50 | { 51 | return new \ArrayIterator($this->streams); 52 | } 53 | 54 | /** 55 | * @throws InvalidStreamMetadataException 56 | */ 57 | private function loadStreams(): void 58 | { 59 | $this->streams = []; 60 | foreach ($this->streamsMetadata as $idx => $metadata) { 61 | if (!is_array($metadata)) { 62 | throw new InvalidStreamMetadataException(sprintf( 63 | 'Invalid or unsupported metadata stream received %s', 64 | (string) json_encode($metadata) 65 | )); 66 | } 67 | $this->streams[] = new AudioStream($metadata); 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/Common/Cache/NullCache.php: -------------------------------------------------------------------------------- 1 | . 8 | * 9 | * @see https://github.com/soluble-io/soluble-mediatools for the canonical repository 10 | * 11 | * @copyright Copyright (c) 2018-2020 Sébastien Vanvelthem. (https://github.com/belgattitude) 12 | * @license https://github.com/soluble-io/soluble-mediatools/blob/master/LICENSE.md MIT 13 | */ 14 | 15 | namespace Soluble\MediaTools\Common\Cache; 16 | 17 | use Psr\SimpleCache\CacheInterface; 18 | 19 | /** 20 | * NullCache convenience object taken from symfony/cache. Adapted for 21 | * PHP7.1 strict_types, original author Nicolas Grekas . 22 | */ 23 | final class NullCache implements CacheInterface 24 | { 25 | /** 26 | * {@inheritdoc} 27 | * 28 | * @return mixed 29 | */ 30 | public function get($key, $default = null) 31 | { 32 | return $default; 33 | } 34 | 35 | /** 36 | * {@inheritdoc} 37 | * 38 | * @return iterable 39 | */ 40 | public function getMultiple($keys, $default = null) 41 | { 42 | foreach ($keys as $key) { 43 | yield $key => $default; 44 | } 45 | } 46 | 47 | /** 48 | * {@inheritdoc} 49 | */ 50 | public function has($key): bool 51 | { 52 | return false; 53 | } 54 | 55 | public function clear(): bool 56 | { 57 | return true; 58 | } 59 | 60 | /** 61 | * {@inheritdoc} 62 | */ 63 | public function delete($key): bool 64 | { 65 | return true; 66 | } 67 | 68 | /** 69 | * {@inheritdoc} 70 | */ 71 | public function deleteMultiple($keys): bool 72 | { 73 | return true; 74 | } 75 | 76 | /** 77 | * {@inheritdoc} 78 | */ 79 | public function set($key, $value, $ttl = null): bool 80 | { 81 | return false; 82 | } 83 | 84 | /** 85 | * {@inheritdoc} 86 | */ 87 | public function setMultiple($values, $ttl = null): bool 88 | { 89 | return false; 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/Video/Info/SubtitleStreamCollection.php: -------------------------------------------------------------------------------- 1 | */ 13 | private $streamsMetadata; 14 | 15 | /** @var array */ 16 | private $streams; 17 | 18 | /** 19 | * @param array $subtitleStreamsMetadata 20 | * 21 | * @throws InvalidStreamMetadataException 22 | */ 23 | public function __construct(array $subtitleStreamsMetadata) 24 | { 25 | $this->streamsMetadata = $subtitleStreamsMetadata; 26 | $this->loadStreams(); 27 | } 28 | 29 | /** 30 | * @throws NoStreamException 31 | */ 32 | public function getFirst(): SubtitleStreamInterface 33 | { 34 | if ($this->count() === 0) { 35 | throw new NoStreamException('Unable to get video subtitle stream, none exists'); 36 | } 37 | 38 | return new SubtitleStream($this->streamsMetadata[0]); 39 | } 40 | 41 | public function count(): int 42 | { 43 | return count($this->streamsMetadata); 44 | } 45 | 46 | /** 47 | * @return \ArrayIterator 48 | */ 49 | public function getIterator(): \ArrayIterator 50 | { 51 | return new \ArrayIterator($this->streams); 52 | } 53 | 54 | /** 55 | * @throws InvalidStreamMetadataException 56 | */ 57 | private function loadStreams(): void 58 | { 59 | $this->streams = []; 60 | foreach ($this->streamsMetadata as $idx => $metadata) { 61 | if (!is_array($metadata)) { 62 | throw new InvalidStreamMetadataException(sprintf( 63 | 'Invalid or unsupported metadata stream received %s', 64 | (string) json_encode($metadata) 65 | )); 66 | } 67 | $this->streams[] = new SubtitleStream($metadata); 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/Video/Info/SubtitleStream.php: -------------------------------------------------------------------------------- 1 | */ 13 | private $streamMetadata; 14 | 15 | /** @var MetadataTypeSafeReader */ 16 | private $tsReader; 17 | 18 | /** 19 | * @param array $streamMetadata 20 | */ 21 | public function __construct(array $streamMetadata, ?LoggerInterface $logger = null) 22 | { 23 | $this->streamMetadata = $streamMetadata; 24 | $this->tsReader = new MetadataTypeSafeReader($streamMetadata, $logger); 25 | } 26 | 27 | public function getIndex(): int 28 | { 29 | return $this->tsReader->getKeyIntValue('index'); 30 | } 31 | 32 | public function getCodecType(): string 33 | { 34 | return $this->tsReader->getKeyStringValue('codec_type'); 35 | } 36 | 37 | public function getCodecName(): string 38 | { 39 | return $this->tsReader->getKeyStringValue('codec_name'); 40 | } 41 | 42 | public function getCodecLongName(): ?string 43 | { 44 | return $this->tsReader->getKeyStringOrNullValue('codec_long_name'); 45 | } 46 | 47 | public function getCodecTimeBase(): ?string 48 | { 49 | return $this->tsReader->getKeyStringOrNullValue('codec_time_base'); 50 | } 51 | 52 | public function getCodecTagString(): ?string 53 | { 54 | return $this->tsReader->getKeyStringOrNullValue('codec_tag_string'); 55 | } 56 | 57 | public function getStartTime(): ?float 58 | { 59 | return $this->tsReader->getKeyFloatOrNullValue('start_time'); 60 | } 61 | 62 | public function getTimeBase(): ?string 63 | { 64 | return $this->tsReader->getKeyStringOrNullValue('time_base'); 65 | } 66 | 67 | /** 68 | * Return underlying ffprobe json metadata. 69 | * 70 | * @return array 71 | */ 72 | public function getStreamMetadata(): array 73 | { 74 | return $this->streamMetadata; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/Common/Assert/PathAssertionsTrait.php: -------------------------------------------------------------------------------- 1 | ensureFileExists($file, $ensureFileNotEmpty); 54 | if (!is_readable($file)) { 55 | throw new FileNotReadableException(sprintf( 56 | 'File "%s" is not readable', 57 | $file 58 | )); 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /config/soluble-mediatools.config.php: -------------------------------------------------------------------------------- 1 | [ 9 | /* 10 | |-------------------------------------------------------------------------- 11 | | FFMPEG section 12 | |-------------------------------------------------------------------------- 13 | | 14 | | Options that will be used to create a FFMpegConfig object. 15 | | 16 | | @see \Soluble\MediaTools\Video\Config\FFMpegConfigFactory 17 | | @link https://github.com/soluble-io/soluble-mediatools/blob/master/src/Config/FFMpegConfigFactory.php 18 | */ 19 | 20 | 'ffmpeg.binary' => 'ffmpeg', // Or a complete path /opt/local/ffmpeg/bin/ffmpeg 21 | 'ffmpeg.threads' => null, // : single thread; <0>: number of cores, <1+>: number of threads 22 | 'ffmpeg.timeout' => null, // : no timeout, : number of seconds before timing-out 23 | 'ffmpeg.idle_timeout' => null, // : no idle timeout, : number of seconds of inactivity before timing-out 24 | 'ffmpeg.env' => [], // An array of additional env vars to set when running the ffmpeg conversion process 25 | 26 | 27 | /* 28 | |-------------------------------------------------------------------------- 29 | | FFPROBE section 30 | |-------------------------------------------------------------------------- 31 | | 32 | | Options that will be used to create a FFProbeConfig object. 33 | | 34 | | @see \Soluble\MediaTools\Video\Config\FFProbeConfigFactory 35 | | @link https://github.com/soluble-io/soluble-mediatools/blob/master/src/Config/FFProbeConfigFactory.php 36 | */ 37 | 38 | 'ffprobe.binary' => 'ffprobe', // Or a complete path /opt/local/ffmpeg/bin/ffprobe 39 | 'ffprobe.timeout' => null, // : no timeout, : number of seconds before timing-out 40 | 'ffprobe.idle_timeout' => null, // : no idle timeout, : number of seconds of inactivity before timing-out 41 | 'ffprobe.env' => [], // An array of additional env vars to set when running the ffprobe 42 | 43 | ], 44 | ]; 45 | -------------------------------------------------------------------------------- /phpcs.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | src 38 | tests 39 | tests/tmp 40 | tests/data 41 | 42 | -------------------------------------------------------------------------------- /src/Video/Config/FFProbeConfig.php: -------------------------------------------------------------------------------- 1 | $env An array of additional env vars to set when running the ffprobe process 34 | */ 35 | public function __construct( 36 | ?string $ffprobeBinary = null, 37 | ?float $timeout = self::DEFAULT_TIMEOUT, 38 | ?float $idleTimeout = self::DEFAULT_IDLE_TIMEOUT, 39 | array $env = self::DEFAULT_ENV 40 | ) { 41 | $this->binary = $ffprobeBinary ?? self::getPlatformDefaultBinary(); 42 | 43 | $this->processParams = new ProcessParams( 44 | $timeout, 45 | $idleTimeout, 46 | $env 47 | ); 48 | } 49 | 50 | public static function getPlatformDefaultBinary(): string 51 | { 52 | return DIRECTORY_SEPARATOR === '\\' ? 'ffprobe.exe' : 'ffprobe'; 53 | } 54 | 55 | public function getBinary(): string 56 | { 57 | return $this->binary; 58 | } 59 | 60 | public function getProcessParams(): ProcessParamsInterface 61 | { 62 | return $this->processParams; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/Common/Process/ProcessParams.php: -------------------------------------------------------------------------------- 1 | */ 27 | private $env; 28 | 29 | /** 30 | * @param float|null $timeout max allowed time (in seconds) for symfony process 31 | * @param float|null $idleTimeout max allowed idle time (in seconds) for symfony process 32 | * @param array $env An array of additional env vars to set when running the symfony process 33 | */ 34 | public function __construct( 35 | ?float $timeout = self::DEFAULT_TIMEOUT, 36 | ?float $idleTimeout = self::DEFAULT_IDLE_TIMEOUT, 37 | array $env = self::DEFAULT_ENV 38 | ) { 39 | $this->timeout = $timeout; 40 | $this->idleTimeout = $idleTimeout; 41 | $this->env = $env; 42 | } 43 | 44 | public function getTimeout(): ?float 45 | { 46 | return $this->timeout; 47 | } 48 | 49 | public function setTimeout(?float $timeout): void 50 | { 51 | $this->timeout = $timeout; 52 | } 53 | 54 | public function getIdleTimeout(): ?float 55 | { 56 | return $this->idleTimeout; 57 | } 58 | 59 | public function setIdleTimeout(?float $idleTimeout): void 60 | { 61 | $this->idleTimeout = $idleTimeout; 62 | } 63 | 64 | /** 65 | * @return array 66 | */ 67 | public function getEnv(): array 68 | { 69 | return $this->env; 70 | } 71 | 72 | /** 73 | * @param array $env 74 | */ 75 | public function setEnv(array $env): void 76 | { 77 | $this->env = $env; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/Common/IO/PlatformNullFile.php: -------------------------------------------------------------------------------- 1 | platform = mb_strtoupper($platform); 47 | } 48 | 49 | public static function getCurrentPlatform(): string 50 | { 51 | return defined('PHP_WINDOWS_VERSION_MAJOR') 52 | ? self::PLATFORM_WIN : self::PLATFORM_LINUX; 53 | } 54 | 55 | /** 56 | * Return /dev/null on linux/unix/mac or NUL on windows. 57 | */ 58 | public function getNullFile(?string $platform = null): string 59 | { 60 | $platform = $platform ?? $this->platform; 61 | 62 | switch ($platform) { 63 | case self::PLATFORM_WIN: 64 | return 'NUL'; 65 | // All others for now 66 | case self::PLATFORM_LINUX: 67 | default: 68 | return '/dev/null'; 69 | } 70 | } 71 | 72 | public function getFile(): string 73 | { 74 | return $this->getNullFile(); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/Video/Info/VideoStreamCollection.php: -------------------------------------------------------------------------------- 1 | */ 16 | private $streamsMetadata; 17 | 18 | /** @var array */ 19 | private $streams; 20 | 21 | /** @var LoggerInterface */ 22 | private $logger; 23 | 24 | /** 25 | * @param array $videoStreamsMetadata 26 | * 27 | * @throws InvalidStreamMetadataException 28 | */ 29 | public function __construct(array $videoStreamsMetadata, ?LoggerInterface $logger = null) 30 | { 31 | $this->streamsMetadata = $videoStreamsMetadata; 32 | $this->logger = $logger ?? new NullLogger(); 33 | 34 | $this->loadStreams(); 35 | } 36 | 37 | /** 38 | * @throws NoStreamException 39 | */ 40 | public function getFirst(): VideoStreamInterface 41 | { 42 | if ($this->count() === 0) { 43 | throw new NoStreamException('Unable to get video first stream, none exists'); 44 | } 45 | 46 | return new VideoStream($this->streamsMetadata[0]); 47 | } 48 | 49 | public function count(): int 50 | { 51 | return count($this->streamsMetadata); 52 | } 53 | 54 | /** 55 | * @return \ArrayIterator 56 | */ 57 | public function getIterator(): \ArrayIterator 58 | { 59 | return new \ArrayIterator($this->streams); 60 | } 61 | 62 | /** 63 | * @throws InvalidStreamMetadataException 64 | */ 65 | private function loadStreams(): void 66 | { 67 | $this->streams = []; 68 | 69 | try { 70 | foreach ($this->streamsMetadata as $idx => $metadata) { 71 | if (!is_array($metadata)) { 72 | throw new InvalidStreamMetadataException(sprintf( 73 | 'Invalid or unsupported metadata stream received %s', 74 | (string) json_encode($metadata) 75 | )); 76 | } 77 | $this->streams[] = new VideoStream($metadata); 78 | } 79 | } catch (InvalidStreamMetadataException $e) { 80 | $this->logger->log(LogLevel::ERROR, $e->getMessage()); 81 | throw $e; 82 | } 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/Video/Adapter/Validator/FFMpegParamValidator.php: -------------------------------------------------------------------------------- 1 | params = $conversionParams; 30 | } 31 | 32 | /** 33 | * @throws ParamValidationException 34 | */ 35 | public function validate(): void 36 | { 37 | $this->ensureValidCrf(); 38 | } 39 | 40 | /** 41 | * Ensure that is CRF have been set, values for VP9 and H264 are in valid ranges. 42 | * 43 | * @throws ParamValidationException 44 | */ 45 | private function ensureValidCrf(): void 46 | { 47 | if (!$this->params->hasParam(VideoConvertParamsInterface::PARAM_CRF)) { 48 | return; 49 | } 50 | $crf = $this->params->getParam(VideoConvertParamsInterface::PARAM_CRF); 51 | 52 | // Check allowed values for CRF 53 | $codec = $this->params->getParam(VideoConvertParamsInterface::PARAM_VIDEO_CODEC, ''); 54 | 55 | if (mb_stripos($codec, 'vp9') !== false && ($crf < 0 || $crf > 63)) { 56 | throw new ParamValidationException( 57 | sprintf( 58 | 'Invalid value for CRF, \'%s\' requires a number between 0 and 63: %s given.', 59 | $codec, 60 | $crf 61 | ) 62 | ); 63 | } 64 | 65 | if (mb_stripos($codec, '264') !== false && ($crf < 0 || $crf > 51)) { 66 | throw new ParamValidationException( 67 | sprintf( 68 | 'Invalid value for CRF, \'%s\' requires a number between 0 and 61: %s given.', 69 | $codec, 70 | $crf 71 | ) 72 | ); 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /psalm.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | -------------------------------------------------------------------------------- /src/Video/Info/AspectRatio.php: -------------------------------------------------------------------------------- 1 | x = $x; 26 | $this->y = $y; 27 | $this->separator = $separator; 28 | } 29 | 30 | public function getX(): float 31 | { 32 | return $this->x; 33 | } 34 | 35 | public function getY(): float 36 | { 37 | return $this->y; 38 | } 39 | 40 | /** 41 | * @param string $proportions 42 | * 43 | * @throws InvalidArgumentException 44 | */ 45 | public static function createFromString(string $proportions, string $separator = self::DEFAULT_PROPORTION_SEPARATOR): self 46 | { 47 | if (mb_substr_count($proportions, $separator) !== 1) { 48 | throw new InvalidArgumentException(sprintf( 49 | 'Cannot parse given proportions: \'%s\' with separator \'%s\' (missing or multiple occurences)', 50 | $proportions, 51 | $separator 52 | )); 53 | } 54 | 55 | [$x, $y] = explode($separator, $proportions); 56 | 57 | if (!is_numeric($x) || !is_numeric($y)) { 58 | throw new InvalidArgumentException(sprintf( 59 | 'Cannot parse given proportions: \'%s\', x and y must be valid numerics', 60 | $proportions 61 | )); 62 | } 63 | 64 | return new self((float) $x, (float) $y); 65 | } 66 | 67 | public function getString(?string $separator = null, ?int $maxDecimals = null): string 68 | { 69 | return sprintf( 70 | '%s%s%s', 71 | $this->getFloatAsString($this->x, $maxDecimals), 72 | $separator ?? $this->separator, 73 | $this->getFloatAsString($this->y, $maxDecimals) 74 | ); 75 | } 76 | 77 | public function __toString(): string 78 | { 79 | return $this->getString(); 80 | } 81 | 82 | private function getFloatAsString(float $number, ?int $maxDecimals = null): string 83 | { 84 | $n = (string) $number; 85 | 86 | if ($n === (string) ((int) ($number))) { 87 | return $n; 88 | } 89 | 90 | if ($maxDecimals === null) { 91 | return $n; 92 | } 93 | 94 | return (string) NumberConversion::truncateFloat($number, $maxDecimals); 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/Video/VideoAnalyzer.php: -------------------------------------------------------------------------------- 1 | ffmpegConfig = $ffmpegConfig; 38 | $this->logger = $logger ?? new NullLogger(); 39 | } 40 | 41 | /** 42 | * @param int $maxFramesToAnalyze interlacement detection can be heavy, limit the number of frames to analyze 43 | * 44 | * @throws AnalyzerExceptionInterface 45 | * @throws AnalyzerProcessExceptionInterface 46 | * @throws ProcessFailedException 47 | * @throws MissingInputFileException 48 | * @throws RuntimeReaderException 49 | */ 50 | public function detectInterlacement(string $file, int $maxFramesToAnalyze = InterlaceDetect::DEFAULT_INTERLACE_MAX_FRAMES, ?ProcessParamsInterface $processParams = null): InterlaceDetectGuess 51 | { 52 | $interlaceDetect = new InterlaceDetect($this->ffmpegConfig); 53 | 54 | try { 55 | return $interlaceDetect->guessInterlacing($file, $maxFramesToAnalyze, $processParams); 56 | } catch (\Throwable $e) { 57 | $exceptionNs = explode('\\', get_class($e)); 58 | $this->logger->log( 59 | ($e instanceof MissingInputFileException) ? LogLevel::WARNING : LogLevel::ERROR, 60 | sprintf( 61 | 'VideoAnalyzer %s: \'%s\'. (%s)', 62 | $exceptionNs[count($exceptionNs) - 1], 63 | $file, 64 | $e->getMessage() 65 | ) 66 | ); 67 | throw $e; 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/Common/Exception/ProcessException.php: -------------------------------------------------------------------------------- 1 | getProcess()->getExitCode(); 34 | } else { 35 | $code = self::DEFAULT_EXCEPTION_CODE; 36 | } 37 | 38 | if ($message === null) { 39 | $errOutput = $process->isStarted() ? trim($process->getErrorOutput()) : ''; 40 | 41 | $message = sprintf( 42 | '%s, exit %s: %s (%s)', 43 | $process->getExitCodeText() ?? 'Empty exit code text from process', 44 | $process->getExitCode() ?? '9999', 45 | $process->getCommandLine(), 46 | $errOutput !== '' ? $errOutput : $previousException->getMessage() 47 | ); 48 | } 49 | 50 | parent::__construct( 51 | $message, 52 | is_int($code) ? $code : self::DEFAULT_EXCEPTION_CODE, 53 | $previousException 54 | ); 55 | 56 | $this->process = $process; 57 | } 58 | 59 | /** 60 | * Return symfony process object. 61 | */ 62 | public function getProcess(): Process 63 | { 64 | return $this->process; 65 | } 66 | 67 | public function getErrorOutput(): string 68 | { 69 | return $this->process->getErrorOutput(); 70 | } 71 | 72 | /** 73 | * @return SPException\RuntimeException|SPException\ProcessFailedException|SPException\ProcessSignaledException|SPException\ProcessTimedOutException 74 | */ 75 | public function getSymfonyProcessRuntimeException(): SPException\RuntimeException 76 | { 77 | /** 78 | * @var SPException\RuntimeException $previous 79 | */ 80 | $previous = $this->getPrevious(); 81 | 82 | return $previous; 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/Video/Filter/CropFilter.php: -------------------------------------------------------------------------------- 1 | width = $width; 57 | $this->height = $height; 58 | $this->x = $x; 59 | $this->y = $y; 60 | $this->keepAspect = $keepAspect; 61 | $this->exact = $exact; 62 | } 63 | 64 | public function getFFmpegCLIValue(): string 65 | { 66 | $args = array_filter([ 67 | ($this->width !== null) ? "w={$this->width}" : false, 68 | ($this->height !== null) ? "h={$this->height}" : false, 69 | ($this->x !== null) ? "x={$this->x}" : false, 70 | ($this->y !== null) ? "y={$this->y}" : false, 71 | ($this->keepAspect) ? 'keep_aspect=1' : false, 72 | ($this->exact) ? 'exact=1' : false, 73 | ]); 74 | 75 | return sprintf( 76 | 'crop=%s', 77 | implode(':', $args) 78 | ); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/Video/Info/AudioStream.php: -------------------------------------------------------------------------------- 1 | */ 13 | private $streamMetadata; 14 | 15 | /** @var MetadataTypeSafeReader */ 16 | private $tsReader; 17 | 18 | /** 19 | * @param array $streamMetadata 20 | */ 21 | public function __construct(array $streamMetadata, ?LoggerInterface $logger = null) 22 | { 23 | $this->streamMetadata = $streamMetadata; 24 | $this->tsReader = new MetadataTypeSafeReader($streamMetadata, $logger); 25 | } 26 | 27 | public function getIndex(): int 28 | { 29 | return $this->tsReader->getKeyIntValue('index'); 30 | } 31 | 32 | public function getCodecType(): string 33 | { 34 | return $this->tsReader->getKeyStringValue('codec_type'); 35 | } 36 | 37 | public function getCodecName(): string 38 | { 39 | return $this->tsReader->getKeyStringValue('codec_name'); 40 | } 41 | 42 | public function getCodecLongName(): ?string 43 | { 44 | return $this->tsReader->getKeyStringOrNullValue('codec_long_name'); 45 | } 46 | 47 | public function getCodecTimeBase(): ?string 48 | { 49 | return $this->tsReader->getKeyStringOrNullValue('codec_time_base'); 50 | } 51 | 52 | public function getCodecTagString(): ?string 53 | { 54 | return $this->tsReader->getKeyStringOrNullValue('codec_tag_string'); 55 | } 56 | 57 | public function getStartTime(): ?float 58 | { 59 | return $this->tsReader->getKeyFloatOrNullValue('start_time'); 60 | } 61 | 62 | public function getTimeBase(): ?string 63 | { 64 | return $this->tsReader->getKeyStringOrNullValue('time_base'); 65 | } 66 | 67 | public function getDurationTs(): ?int 68 | { 69 | return $this->tsReader->getKeyIntOrNullValue('duration_ts'); 70 | } 71 | 72 | public function getDuration(): float 73 | { 74 | return $this->tsReader->getKeyFloatValue('duration'); 75 | } 76 | 77 | public function getProfile(): ?string 78 | { 79 | return $this->tsReader->getKeyStringOrNullValue('profile'); 80 | } 81 | 82 | public function getBitRate(): ?int 83 | { 84 | return $this->tsReader->getKeyIntOrNullValue('bit_rate'); 85 | } 86 | 87 | /** 88 | * @return array 89 | */ 90 | public function getTags(): array 91 | { 92 | return $this->streamMetadata['tags'] ?? []; 93 | } 94 | 95 | /** 96 | * Return underlying ffprobe json metadata. 97 | * 98 | * @return array 99 | */ 100 | public function getStreamMetadata(): array 101 | { 102 | return $this->streamMetadata; 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/Video/Info/VideoStreamInterface.php: -------------------------------------------------------------------------------- 1 | 98 | */ 99 | public function getDimensions(): array; 100 | 101 | /** 102 | * Return tags attached to this stream. 103 | * 104 | * @return array 105 | */ 106 | public function getTags(): array; 107 | } 108 | -------------------------------------------------------------------------------- /src/Video/Config/FFMpegConfig.php: -------------------------------------------------------------------------------- 1 | $env An array of additional env vars to set when running the ffmpeg conversion process 44 | */ 45 | public function __construct( 46 | ?string $ffmpegBinary = null, 47 | ?int $threads = self::DEFAULT_THREADS, 48 | ?float $timeout = self::DEFAULT_TIMEOUT, 49 | ?float $idleTimeout = self::DEFAULT_IDLE_TIMEOUT, 50 | array $env = self::DEFAULT_ENV 51 | ) { 52 | $this->binary = $ffmpegBinary ?? self::getPlatformDefaultBinary(); 53 | 54 | $this->threads = $threads; 55 | 56 | $this->processParams = new ProcessParams( 57 | $timeout, 58 | $idleTimeout, 59 | $env 60 | ); 61 | } 62 | 63 | public static function getPlatformDefaultBinary(): string 64 | { 65 | return (DIRECTORY_SEPARATOR === '\\') ? 'ffmpeg.exe' : 'ffmpeg'; 66 | } 67 | 68 | public function getBinary(): string 69 | { 70 | return $this->binary; 71 | } 72 | 73 | public function getThreads(): ?int 74 | { 75 | return $this->threads; 76 | } 77 | 78 | public function getProcessParams(): ProcessParamsInterface 79 | { 80 | return $this->processParams; 81 | } 82 | 83 | /** 84 | * @return FFMpegAdapter 85 | */ 86 | public function getAdapter(): ConverterAdapterInterface 87 | { 88 | if ($this->ffmpegAdapter === null) { 89 | $this->ffmpegAdapter = new FFMpegAdapter($this); 90 | } 91 | 92 | return $this->ffmpegAdapter; 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/Video/VideoInfoInterface.php: -------------------------------------------------------------------------------- 1 | 76 | */ 77 | public function getMetadata(): array; 78 | 79 | /** 80 | * Return underlying ffprobe audio metadata. 81 | * 82 | * @return array 83 | */ 84 | public function getAudioStreamsMetadata(): array; 85 | 86 | /** 87 | * Return underlying ffprobe video metadata. 88 | * 89 | * @return array 90 | */ 91 | public function getVideoStreamsMetadata(): array; 92 | 93 | /** 94 | * @throws InvalidArgumentException 95 | * 96 | * @param string $streamType 'audio'|'video'|'data'|'subtitle' (StreamTypeInterface::AUDIO, StreamTypeInterface::VIDEO, StreamTypeInterface::DATA) 97 | * 98 | * @see self::SUPPORTED_STREAM_TYPES 99 | * 100 | * @return array> 101 | */ 102 | public function getStreamsMetadataByType(string $streamType): array; 103 | } 104 | -------------------------------------------------------------------------------- /src/Video/Filter/VideoFilterChain.php: -------------------------------------------------------------------------------- 1 | addFilters($filters); 36 | } 37 | 38 | /** 39 | * @return VideoFilterInterface[] 40 | */ 41 | public function getFilters(): array 42 | { 43 | return $this->filters; 44 | } 45 | 46 | /** 47 | * Will append the filter, if the filter is a VideoFilterChain 48 | * it will be merged at the end. 49 | * 50 | * @param VideoFilterInterface $filter filter to add 51 | */ 52 | public function addFilter(VideoFilterInterface $filter): void 53 | { 54 | if ($filter instanceof self) { 55 | if ($filter->count() > 0) { 56 | $this->filters = array_merge($this->filters, $filter->getFilters()); 57 | } 58 | } else { 59 | $this->filters[] = $filter; 60 | } 61 | } 62 | 63 | public function count(): int 64 | { 65 | return count($this->filters); 66 | } 67 | 68 | /** 69 | * @param VideoFilterInterface[] $filters 70 | * 71 | * @throws InvalidArgumentException 72 | */ 73 | public function addFilters(array $filters): void 74 | { 75 | foreach ($filters as $filter) { 76 | if (!$filter instanceof VideoFilterInterface) { 77 | throw new InvalidArgumentException(sprintf( 78 | 'Cannot add filter \'%s\', it must not implement %s', 79 | gettype($filter) === 'object' ? get_class($filter) : gettype($filter), 80 | VideoFilterInterface::class 81 | )); 82 | } 83 | $this->addFilter($filter); 84 | } 85 | } 86 | 87 | /** 88 | * @throws UnsupportedParamValueException 89 | */ 90 | public function getFFmpegCLIValue(): ?string 91 | { 92 | $values = []; 93 | foreach ($this->filters as $filter) { 94 | if (!$filter instanceof FFMpegVideoFilterInterface) { 95 | throw new UnsupportedParamValueException( 96 | sprintf( 97 | 'Filter \'%s\' have not been made compatible with FFMpeg', 98 | get_class($filter) 99 | ) 100 | ); 101 | } 102 | $val = $filter->getFFmpegCLIValue(); 103 | if ($val === '') { 104 | continue; 105 | } 106 | 107 | $values[] = $val; 108 | } 109 | 110 | if (count($values) === 0) { 111 | return null; 112 | } 113 | 114 | return implode(',', $values); 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/Video/SeekTime.php: -------------------------------------------------------------------------------- 1 | time = $seconds; 28 | } 29 | 30 | /** 31 | * @param string $hmsmTime 'HOURS:MM:SS.MILLISECONDS' like in '01:23:45.678' 32 | * 33 | * @throws InvalidArgumentException 34 | */ 35 | public static function createFromHMS(string $hmsmTime): self 36 | { 37 | return new self(self::convertHMSmToSeconds($hmsmTime)); 38 | } 39 | 40 | /** 41 | * Convert 'HOURS:MM:SS.MILLISECONDS' format to seconds with milli 42 | * Note: FFMpeg refer to this format as 'sexagesimal'. 43 | * 44 | * @param string $hmsmTime 'HOURS:MM:SS.MILLISECONDS' like in '01:23:45.678' 45 | * 46 | * @return float i.e 123.642 47 | * 48 | * @throws InvalidArgumentException 49 | */ 50 | public static function convertHMSmToSeconds(string $hmsmTime): float 51 | { 52 | [ $secondsWithMilli, 53 | $minutes, 54 | $hours, 55 | ] = array_merge(array_reverse(explode(':', $hmsmTime)), [0, 0, 0]); 56 | 57 | if (!is_numeric($secondsWithMilli) || $secondsWithMilli < 0 || $secondsWithMilli >= 60.0) { 58 | throw new InvalidArgumentException(sprintf( 59 | 'Seconds \'%s\' are incorrect in \'%s\'', 60 | $secondsWithMilli, 61 | $hmsmTime 62 | )); 63 | } 64 | 65 | if (!is_numeric($minutes) || $minutes < 0 || $minutes >= 60.0) { 66 | throw new InvalidArgumentException(sprintf( 67 | 'Minutes \'%s\' are incorrect in \'%s\'', 68 | $minutes, 69 | $hmsmTime 70 | )); 71 | } 72 | 73 | if (!is_numeric($hours) || $hours < 0) { 74 | throw new InvalidArgumentException(sprintf( 75 | 'Hours \'%s\' are incorrect in \'%s\'', 76 | $hours, 77 | $hmsmTime 78 | )); 79 | } 80 | 81 | return (float) $secondsWithMilli + ((int) $minutes) * 60 + ((int) $hours) * 3600; 82 | } 83 | 84 | /** 85 | * @throws InvalidArgumentException 86 | */ 87 | public static function convertSecondsToHMSs(float $secondsWithMilli): string 88 | { 89 | if ($secondsWithMilli < 0) { 90 | throw new InvalidArgumentException(sprintf( 91 | "Cannot convert negative time to HMSs: \'%s\'", 92 | (string) $secondsWithMilli 93 | )); 94 | } 95 | 96 | [$time, $milli] = array_merge(explode('.', (string) $secondsWithMilli), [0, 0]); 97 | 98 | return sprintf( 99 | '%d:%02d:%02d.%d', 100 | ((int) $time / 3600), 101 | ((int) $time % 3600 / 60), 102 | ((int) $time % 3600 % 60), 103 | $milli 104 | ); 105 | } 106 | 107 | /** 108 | * Return time in seconds with milli. 109 | * 110 | * @return float 111 | */ 112 | public function getTime(): float 113 | { 114 | return $this->time; 115 | } 116 | 117 | public function getFFmpegCLIValue(): string 118 | { 119 | return self::convertSecondsToHMSs($this->time); 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "soluble/mediatools", 3 | "description": "FFMpeg video/audio/subs conversions, thumbnails, audio extraction, query...", 4 | "license": "MIT", 5 | "keywords": [ 6 | "video", 7 | "multimedia", 8 | "video processing", 9 | "video transcoding", 10 | "transcoding", 11 | "ffmpeg", 12 | "ffprobe", 13 | "thumbnail", 14 | "thumbnailing", 15 | "conversion", 16 | "audio", 17 | "audio extraction", 18 | "subtitle", 19 | "webvtt", 20 | "extraction" 21 | ], 22 | "homepage": "https://github.com/soluble-io/soluble-mediatools", 23 | "type": "library", 24 | "authors": [ 25 | { 26 | "name": "Sébastien Vanvelthem", 27 | "homepage": "https://github.com/belgattitude" 28 | } 29 | ], 30 | "require": { 31 | "php": "^7.1 || ^8.0", 32 | "ext-json": "*", 33 | "ext-pcre": "*", 34 | "psr/container": "^1.0", 35 | "psr/log": "^1.0", 36 | "psr/simple-cache": "^1.0", 37 | "symfony/polyfill-mbstring": "^v1.18.1", 38 | "symfony/process": "^3.3 || ^4.0 || ^5.0" 39 | }, 40 | "require-dev" : { 41 | "captainhook/captainhook": "5.4.3", 42 | "captainhook/plugin-composer": "5.2.2", 43 | "consistence/coding-standard": "^3.10.1", 44 | "fig/http-message-util": "^1.1.4", 45 | "friendsofphp/php-cs-fixer": "^v2.16.7", 46 | "infection/infection": "^0.13 || ^0.14 || ^0.15", 47 | "jangregor/phpstan-prophecy": "^0.6.2 || ^0.8.1", 48 | "laminas/laminas-servicemanager": "^3.4.1", 49 | "mikey179/vfsstream": "^v1.6.8", 50 | "monolog/monolog": "^1.23 | ^2.0", 51 | "phpspec/prophecy": "^1.9.0 || ^1.11.1", 52 | "phpstan/phpstan": "0.12.54", 53 | "phpstan/phpstan-phpunit": "0.12.16", 54 | "phpstan/phpstan-strict-rules": "0.12.5", 55 | "phpunit/phpunit": "^7.4 || ^8.0 || ^9.0", 56 | "roave/security-advisories": "dev-master", 57 | "slevomat/coding-standard": "^6.4.1", 58 | "squizlabs/php_codesniffer": "^3.4 || ^3.5", 59 | "symfony/cache": "^4.3", 60 | "vimeo/psalm": "3.18.2" 61 | }, 62 | "config": { 63 | "optimize-autoloader": true, 64 | "sort-packages": true 65 | }, 66 | "autoload": { 67 | "psr-4": { 68 | "Soluble\\MediaTools\\Common\\": "src/Common/", 69 | "Soluble\\MediaTools\\Video\\": "src/Video/" 70 | } 71 | }, 72 | "autoload-dev": { 73 | "psr-4": { 74 | "MediaToolsTest\\": "tests/unit", 75 | "MediaToolsTest\\Util\\": "tests/util", 76 | "MediaToolsTest\\Functional\\": "tests/functional" 77 | } 78 | }, 79 | "scripts": { 80 | "check": [ 81 | "@cs-check", 82 | "@phpstan", 83 | "@psalm", 84 | "@test:unit" 85 | ], 86 | "fix": [ 87 | "@cs-fix" 88 | ], 89 | "test": "vendor/bin/phpunit", 90 | "test:unit": "vendor/bin/phpunit --testsuite=unit", 91 | "test:mutation": "vendor/bin/infection --configuration=infection.json --test-framework=phpunit --test-framework-options='--testsuite=unit' --min-msi=50 --min-covered-msi=70 --threads=4", 92 | "cs-check": "vendor/bin/php-cs-fixer --diff --dry-run -v fix --using-cache=false", 93 | "cs-fix": "vendor/bin/php-cs-fixer -v fix --using-cache=false", 94 | "cs-lint-fix": "vendor/bin/phpcbf; vendor/bin/php-cs-fixer -v fix", 95 | "phpstan": "vendor/bin/phpstan analyse -l 7 -c phpstan.neon src tests", 96 | "psalm": "vendor/bin/psalm --show-info=false", 97 | "no-leaks": "vendor/bin/roave-no-leaks", 98 | "doc:install": "pip install -r requirements.txt --upgrade", 99 | "doc:build": "mkdocs build", 100 | "doc:serve": "mkdocs serve --dev-addr localhost:8094", 101 | "doc:deploy": "mkdocs gh-deploy" 102 | }, 103 | "suggest": { 104 | "monolog/monolog": "PSR-3 compatible logger", 105 | "symfony/cache": "PSR-6/16 compatible cache", 106 | "cache/simple-cache-bridge": "Useful if you already have a PSR-6 implementation" 107 | }, 108 | "archive": { 109 | "exclude": [".travis", "infection.json", ".sami.php", "phpstan.neon", "tests", "docs", ".travis", ".travis.yml", ".codeclimate.yml", ".coveralls.yml", ".scrutinizer.yml", ".php_cs", ".gitignore", "phpcs.xml"] 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/Video/Config/ConfigProvider.php: -------------------------------------------------------------------------------- 1 | >> 35 | */ 36 | public function __invoke(): array 37 | { 38 | return [ 39 | 'dependencies' => $this->getDependencies(), 40 | ]; 41 | } 42 | 43 | /** 44 | * @return array> 45 | */ 46 | public function getDependencies(): array 47 | { 48 | return [ 49 | 'aliases' => $this->getAliases(), 50 | 'factories' => $this->getFactories(), 51 | ]; 52 | } 53 | 54 | /** 55 | * Return concrete implementation aliases if needed. 56 | * 57 | * @return array 58 | */ 59 | public function getAliases(): array 60 | { 61 | return [ 62 | // Configuration holders 63 | FFMpegConfig::class => FFMpegConfigInterface::class, 64 | FFProbeConfig::class => FFProbeConfigInterface::class, 65 | 66 | // Services 67 | VideoConverter::class => VideoConverterInterface::class, 68 | VideoInfoReader::class => VideoInfoReaderInterface::class, 69 | VideoAnalyzer::class => VideoAnalyzerInterface::class, 70 | VideoThumbGenerator::class => VideoThumbGeneratorInterface::class, 71 | ]; 72 | } 73 | 74 | /** 75 | * Return interface based factories. 76 | * 77 | * @return array 78 | */ 79 | public function getFactories(): array 80 | { 81 | return [ 82 | // Configuration holders 83 | FFMpegConfigInterface::class => FFMpegConfigFactory::class, 84 | FFProbeConfigInterface::class => FFProbeConfigFactory::class, 85 | 86 | // Services classes 87 | VideoConverterInterface::class => VideoConverterFactory::class, 88 | VideoInfoReaderInterface::class => VideoInfoReaderFactory::class, 89 | VideoAnalyzerInterface::class => VideoAnalyzerFactory::class, 90 | VideoThumbGeneratorInterface::class => VideoThumbGeneratorFactory::class, 91 | 92 | // Infrastructure 93 | LoggerInterface::class => NullLoggerFactory::class, 94 | CacheInterface::class => NullCacheFactory::class, 95 | ]; 96 | } 97 | 98 | /** 99 | * @throws \RuntimeException 100 | * 101 | * @return array> 102 | */ 103 | public static function getDefaultConfiguration(): array 104 | { 105 | $baseDir = static::getBaseDir(); 106 | $config = implode(DIRECTORY_SEPARATOR, [$baseDir, 'config', 'soluble-mediatools.config.php']); 107 | if (!is_file($config) || !is_readable($config)) { 108 | throw new \RuntimeException(sprintf('Missing project default configuration: %s', $config)); 109 | } 110 | 111 | return require $config; 112 | } 113 | 114 | /** 115 | * Get soluble-mediatools base directory. 116 | * 117 | * @throws \RuntimeException 118 | */ 119 | public static function getBaseDir(): string 120 | { 121 | $baseDir = dirname(__DIR__, 3); 122 | if (!is_dir($baseDir)) { 123 | throw new \RuntimeException(sprintf( 124 | 'Cannot locate library directory: %s', 125 | $baseDir 126 | )); 127 | } 128 | 129 | return $baseDir; 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /src/Video/Detection/InterlaceDetect.php: -------------------------------------------------------------------------------- 1 | ffmpegConfig = $ffmpegConfig; 43 | } 44 | 45 | /** 46 | * @throws AnalyzerExceptionInterface 47 | * @throws AnalyzerProcessExceptionInterface 48 | * @throws ProcessFailedException 49 | * @throws MissingInputFileException 50 | * @throws RuntimeReaderException 51 | */ 52 | public function guessInterlacing(string $file, int $maxFramesToAnalyze = self::DEFAULT_INTERLACE_MAX_FRAMES, ?ProcessParamsInterface $processParams = null): InterlaceDetectGuess 53 | { 54 | $adapter = $this->ffmpegConfig->getAdapter(); 55 | $params = (new VideoConvertParams()) 56 | ->withVideoFilter(new IdetVideoFilter()) // detect interlaced frames :) 57 | ->withVideoFrames($maxFramesToAnalyze) 58 | ->withNoAudio() // speed up the thing 59 | ->withOutputFormat('rawvideo') 60 | ->withOverwrite(); 61 | 62 | try { 63 | $this->ensureFileReadable($file, true); 64 | 65 | $arguments = $adapter->getMappedConversionParams($params); 66 | $ffmpegCmd = $adapter->getCliCommand($arguments, $file, new PlatformNullFile()); 67 | 68 | $pp = $processParams ?? $this->ffmpegConfig->getProcessParams(); 69 | 70 | $process = (new ProcessFactory($ffmpegCmd, $pp))->__invoke(); 71 | $process->mustRun(); 72 | } catch (FileNotFoundException | FileNotReadableException | FileEmptyException $e) { 73 | throw new MissingInputFileException($e->getMessage()); 74 | } catch (SPException\ProcessFailedException | SPException\ProcessTimedOutException | SPException\ProcessSignaledException $e) { 75 | throw new ProcessFailedException($e->getProcess(), $e); 76 | } catch (SPException\RuntimeException $e) { 77 | throw new RuntimeReaderException($e->getMessage()); 78 | } 79 | 80 | $stdErr = preg_split("/(\r\n|\n|\r)/", $process->getErrorOutput()); 81 | 82 | // Counted frames 83 | $interlaced_tff = 0; 84 | $interlaced_bff = 0; 85 | $progressive = 0; 86 | $undetermined = 0; 87 | $total_frames = 0; 88 | 89 | if ($stdErr !== false) { 90 | foreach ($stdErr as $line) { 91 | if (mb_substr($line, 0, 12) !== '[Parsed_idet') { 92 | continue; 93 | } 94 | 95 | $unspaced = sprintf('%s', preg_replace('/( )+/', '', $line)); 96 | $matches = []; 97 | if (preg_match_all('/TFF:(\d+)BFF:(\d+)Progressive:(\d+)Undetermined:(\d+)/i', $unspaced, $matches) < 1) { 98 | continue; 99 | } 100 | 101 | //$type = strpos(strtolower($unspaced), 'single') ? 'single' : 'multi'; 102 | $interlaced_tff += (int) $matches[1][0]; 103 | $interlaced_bff += (int) $matches[2][0]; 104 | $progressive += (int) $matches[3][0]; 105 | $undetermined += (int) $matches[4][0]; 106 | $total_frames += ((int) $matches[1][0] + (int) $matches[2][0] + (int) $matches[3][0] + (int) $matches[4][0]); 107 | } 108 | } 109 | 110 | return new InterlaceDetectGuess($interlaced_tff, $interlaced_bff, $progressive, $undetermined); 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /src/Video/VideoConvertParamsInterface.php: -------------------------------------------------------------------------------- 1 | 115 | */ 116 | public function toArray(): array; 117 | 118 | /** 119 | * Return a param, throw an exception if the param has not been defined yet or 120 | * use $default if it was set. 121 | * 122 | * @param mixed $default Will return default value instead of throwing exception 123 | * 124 | * @return mixed 125 | * 126 | * @throws UnsetParamException 127 | */ 128 | public function getParam(string $paramName, $default = null); 129 | 130 | /** 131 | * Return a new object with (extra) params added (they will be merged). 132 | * 133 | * @return VideoConvertParamsInterface 134 | */ 135 | public function withConvertParams(self $extraParams): self; 136 | } 137 | -------------------------------------------------------------------------------- /src/Video/Info/Util/MetadataTypeSafeReader.php: -------------------------------------------------------------------------------- 1 | */ 14 | private $streamMetadata; 15 | 16 | /** @var LoggerInterface */ 17 | private $logger; 18 | 19 | /** 20 | * @param array $streamMetadata 21 | */ 22 | public function __construct(array $streamMetadata, ?LoggerInterface $logger = null) 23 | { 24 | $this->streamMetadata = $streamMetadata; 25 | $this->logger = $logger ?? new NullLogger(); 26 | } 27 | 28 | /** 29 | * @param string $key metadata key 30 | * 31 | * @throws UnexpectedMetadataException 32 | */ 33 | public function getKeyIntValue(string $key): int 34 | { 35 | $value = $this->streamMetadata[$key] ?? ''; 36 | if (filter_var($value, FILTER_VALIDATE_INT) === false) { 37 | $msg = sprintf( 38 | "The ffprobe/videoInfo metadata '$key' is expected to be an integer. Got: %s (%s)", 39 | gettype($value), 40 | (string) $value 41 | ); 42 | $this->logger->notice($msg); 43 | throw new UnexpectedMetadataException($msg); 44 | } 45 | 46 | return (int) $this->streamMetadata[$key]; 47 | } 48 | 49 | /** 50 | * @param string $key metadata key 51 | * 52 | * @throws UnexpectedMetadataException 53 | */ 54 | public function getKeyIntOrNullValue(string $key): ?int 55 | { 56 | $value = $this->streamMetadata[$key] ?? null; 57 | if ($value !== null && filter_var($value, FILTER_VALIDATE_INT) === false) { 58 | $msg = sprintf( 59 | "The ffprobe/videoInfo metadata '$key' is expected to be an integer or null. Got: %s (%s)", 60 | gettype($value), 61 | (string) $value 62 | ); 63 | $this->logger->notice($msg); 64 | throw new UnexpectedMetadataException($msg); 65 | } 66 | if (isset($this->streamMetadata[$key])) { 67 | return (int) $this->streamMetadata[$key]; 68 | } 69 | 70 | return null; 71 | } 72 | 73 | /** 74 | * @param string $key metadata key 75 | * 76 | * @throws UnexpectedMetadataException 77 | */ 78 | public function getKeyFloatValue(string $key): float 79 | { 80 | $value = $this->streamMetadata[$key] ?? ''; 81 | if (!is_numeric($value)) { 82 | $msg = sprintf( 83 | "The ffprobe/videoInfo metadata '$key' is expected to be a float. Got: %s (%s)", 84 | gettype($value), 85 | (string) $value 86 | ); 87 | $this->logger->notice($msg); 88 | 89 | throw new UnexpectedMetadataException($msg); 90 | } 91 | 92 | return (float) $this->streamMetadata[$key]; 93 | } 94 | 95 | /** 96 | * @param string $key metadata key 97 | * 98 | * @throws UnexpectedMetadataException 99 | */ 100 | public function getKeyFloatOrNullValue(string $key): ?float 101 | { 102 | $value = $this->streamMetadata[$key] ?? null; 103 | if ($value !== null && !is_numeric($value)) { 104 | $msg = sprintf( 105 | "The ffprobe/videoInfo metadata '$key' is expected to be a float or null. Got: %s (%s)", 106 | gettype($value), 107 | (string) $value 108 | ); 109 | $this->logger->notice($msg); 110 | 111 | throw new UnexpectedMetadataException($msg); 112 | } 113 | 114 | if (isset($this->streamMetadata[$key])) { 115 | return (float) $this->streamMetadata[$key]; 116 | } 117 | 118 | return null; 119 | } 120 | 121 | /** 122 | * @param string $key metadata key 123 | * 124 | * @throws UnexpectedMetadataException 125 | */ 126 | public function getKeyStringOrNullValue(string $key): ?string 127 | { 128 | $value = $this->streamMetadata[$key] ?? null; 129 | if ($value !== null && !is_scalar($value)) { 130 | $msg = sprintf( 131 | "The ffprobe/videoInfo metadata '$key' is expected to be a string or null. Got: %s", 132 | gettype($value) 133 | ); 134 | $this->logger->notice($msg); 135 | throw new UnexpectedMetadataException($msg); 136 | } 137 | 138 | if (isset($this->streamMetadata[$key])) { 139 | return (string) $this->streamMetadata[$key]; 140 | } 141 | 142 | return null; 143 | } 144 | 145 | /** 146 | * @param string $key metadata key 147 | * 148 | * @throws UnexpectedMetadataException 149 | */ 150 | public function getKeyStringValue(string $key): string 151 | { 152 | $value = $this->streamMetadata[$key] ?? null; 153 | if (!is_scalar($value)) { 154 | $msg = sprintf( 155 | "The ffprobe/videoInfo metadata '$key' is expected to be a string. Got: %s", 156 | gettype($value) 157 | ); 158 | $this->logger->notice($msg); 159 | throw new UnexpectedMetadataException($msg); 160 | } 161 | 162 | return (string) $this->streamMetadata[$key]; 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /src/Video/Detection/InterlaceDetectGuess.php: -------------------------------------------------------------------------------- 1 | */ 39 | private $detected_frames; 40 | 41 | /** @var array */ 42 | private $percent_frames; 43 | 44 | /** 45 | * @param float $detection_threshold in percent: i.e 0.8, 0.6... 46 | */ 47 | public function __construct( 48 | int $nb_frames_interlaced_tff, 49 | int $nb_frames_interlaced_bff, 50 | int $nb_frames_progressive, 51 | int $nb_frames_undetermined, 52 | float $detection_threshold = self::DEFAULT_DETECTION_THRESHOLD 53 | ) { 54 | $this->detection_threshold = $detection_threshold; 55 | $detected_frames = [ 56 | self::MODE_INTERLACED_TFF => $nb_frames_interlaced_tff, 57 | self::MODE_INTERLACED_BFF => $nb_frames_interlaced_bff, 58 | self::MODE_PROGRESSIVE => $nb_frames_progressive, 59 | self::MODE_UNDETERMINED => $nb_frames_undetermined, 60 | ]; 61 | arsort($detected_frames, SORT_NUMERIC); 62 | $this->detected_frames = $detected_frames; 63 | $this->total_frames = (int) array_sum(array_values($this->detected_frames)); 64 | $this->percent_frames = []; 65 | foreach ($this->detected_frames as $key => $value) { 66 | $this->percent_frames[$key] = $value / $this->total_frames; 67 | } 68 | } 69 | 70 | /** 71 | * @return float[] 72 | */ 73 | public function getStats(): array 74 | { 75 | return $this->percent_frames; 76 | } 77 | 78 | public function getBestGuess(?float $threshold = null): string 79 | { 80 | $min_pct = $threshold !== null ? $threshold : $this->detection_threshold; 81 | reset($this->detected_frames); 82 | $bestGuessKey = (string) key($this->detected_frames); 83 | if ($this->percent_frames[$bestGuessKey] >= $min_pct) { 84 | return $bestGuessKey; 85 | } 86 | 87 | return self::MODE_UNDETERMINED; 88 | } 89 | 90 | /** 91 | * Whether the video seems to be interlaced in TFF (top field first) 92 | * within a certain probability threshold. 93 | * 94 | * @param float|null $threshold 95 | * 96 | * @return bool 97 | */ 98 | public function isInterlacedTff(?float $threshold = null): bool 99 | { 100 | $min_pct = $threshold !== null ? $threshold : $this->detection_threshold; 101 | 102 | return $this->percent_frames[self::MODE_INTERLACED_TFF] >= $min_pct; 103 | } 104 | 105 | /** 106 | * Whether the video seems to be interlaced in BFF (bottom field first) 107 | * within a certain probability threshold. 108 | * 109 | * @param float|null $threshold 110 | * 111 | * @return bool 112 | */ 113 | public function isInterlacedBff(?float $threshold = null): bool 114 | { 115 | $min_pct = $threshold !== null ? $threshold : $this->detection_threshold; 116 | 117 | return $this->percent_frames[self::MODE_INTERLACED_BFF] >= $min_pct; 118 | } 119 | 120 | /** 121 | * Whether the video seems to be interlaced either in BFF (bottom field first) 122 | * or TFF (top field first) within a certain probability threshold. 123 | * 124 | * @param float|null $threshold 125 | * 126 | * @return bool 127 | */ 128 | public function isInterlaced(?float $threshold = null): bool 129 | { 130 | return $this->isInterlacedBff($threshold) || $this->isInterlacedTff($threshold); 131 | } 132 | 133 | public function isProgressive(?float $threshold = null): bool 134 | { 135 | $min_pct = $threshold !== null ? $threshold : $this->detection_threshold; 136 | 137 | return $this->percent_frames[self::MODE_PROGRESSIVE] >= $min_pct; 138 | } 139 | 140 | public function isUndetermined(?float $threshold = null): bool 141 | { 142 | $min_pct = $threshold !== null ? $threshold : $this->detection_threshold; 143 | 144 | return $this->percent_frames[self::MODE_UNDETERMINED] >= $min_pct; 145 | } 146 | 147 | /** 148 | * @see https://ffmpeg.org/ffmpeg-filters.html (section yadif) 149 | * @see https://askubuntu.com/a/867203 150 | * 151 | * @param float|null $threshold 152 | * 153 | * @return EmptyVideoFilter|YadifVideoFilter 154 | */ 155 | public function getDeinterlaceVideoFilter(?float $threshold = null): VideoFilterInterface 156 | { 157 | if (!$this->isInterlaced($threshold)) { 158 | return new EmptyVideoFilter(); 159 | } 160 | $parity = YadifVideoFilter::DEFAULT_PARITY; 161 | if ($this->isInterlacedBff($threshold)) { 162 | // parity=1, bff - Assume the bottom field is first. 163 | $parity = 1; 164 | } elseif ($this->isInterlacedTff($threshold)) { 165 | // parity=0, tff - Assume the top field is first. 166 | $parity = 0; 167 | } 168 | 169 | return new YadifVideoFilter(YadifVideoFilter::DEFAULT_MODE, $parity, YadifVideoFilter::DEFAULT_DEINT); 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /src/Video/Filter/ScaleFilter.php: -------------------------------------------------------------------------------- 1 | forceOriginalAspectRatio = $forceOriginalAspectRatio; 115 | 116 | $this->width = $width; 117 | $this->height = $height; 118 | $this->eval = $eval; 119 | $this->interl = $interl; 120 | $this->flags = $flags; 121 | $this->param0 = $param0; 122 | $this->param1 = $param1; 123 | $this->size = $size; 124 | $this->inColorMatrix = $inColorMatrix; 125 | $this->outColorMatrix = $outColorMatrix; 126 | $this->inRange = $inRange; 127 | $this->outRange = $outRange; 128 | } 129 | 130 | public function getFFmpegCLIValue(): string 131 | { 132 | $args = array_filter([ 133 | ($this->width !== null) ? "w={$this->width}" : false, 134 | ($this->height !== null) ? "h={$this->height}" : false, 135 | ($this->forceOriginalAspectRatio !== null) ? "force_original_aspect_ratio={$this->forceOriginalAspectRatio}" : false, 136 | ($this->eval !== null) ? "eval={$this->eval}" : false, 137 | ($this->interl !== null) ? "interl={$this->interl}" : false, 138 | ($this->flags !== null) ? "flags={$this->flags}" : false, 139 | ($this->param0 !== null) ? "param0={$this->param0}" : false, 140 | ($this->param1 !== null) ? "param1={$this->param1}" : false, 141 | ($this->size !== null) ? "size={$this->size}" : false, 142 | ($this->inColorMatrix !== null) ? "in_color_matrix={$this->inColorMatrix}" : false, 143 | ($this->outColorMatrix !== null) ? "out_color_matrix={$this->outColorMatrix}" : false, 144 | ($this->inRange !== null) ? "in_range={$this->inRange}" : false, 145 | ($this->outRange !== null) ? "out_range={$this->outRange}" : false, 146 | ]); 147 | 148 | return sprintf( 149 | 'scale=%s', 150 | implode(':', $args) 151 | ); 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /src/Video/VideoThumbParams.php: -------------------------------------------------------------------------------- 1 | */ 23 | private $params = []; 24 | 25 | /** 26 | * @param array $params 27 | * 28 | * @throws InvalidParamException in case of unsupported param 29 | */ 30 | public function __construct(array $params = []) 31 | { 32 | $this->ensureSupportedParams($params); 33 | $this->params = $params; 34 | } 35 | 36 | /** 37 | * @param float $time time in seconds, decimals are milli 38 | * 39 | * @return VideoThumbParams 40 | */ 41 | public function withTime(float $time): self 42 | { 43 | return new self(array_merge($this->params, [ 44 | self::PARAM_SEEK_TIME => new SeekTime($time), 45 | ])); 46 | } 47 | 48 | public function withSeekTime(SeekTime $seekTime): self 49 | { 50 | return new self(array_merge($this->params, [ 51 | self::PARAM_SEEK_TIME => $seekTime, 52 | ])); 53 | } 54 | 55 | public function withVideoFilter(VideoFilterInterface $videoFilter): self 56 | { 57 | return new self(array_merge($this->params, [ 58 | self::PARAM_VIDEO_FILTER => $videoFilter, 59 | ])); 60 | } 61 | 62 | /** 63 | * Set the underlying encoder quality scale. (-qscale:v , alias to -q:v ). 64 | * 65 | * @param int $qualityScale a number interpreted by the encoder, generally 1-5 66 | */ 67 | public function withQualityScale(int $qualityScale): self 68 | { 69 | return new self(array_merge($this->params, [ 70 | self::PARAM_QUALITY_SCALE => $qualityScale, 71 | ])); 72 | } 73 | 74 | /** 75 | * @param int $frame take this frame 76 | */ 77 | public function withFrame(int $frame): self 78 | { 79 | return new self(array_merge($this->params, [ 80 | self::PARAM_WITH_FRAME => $frame, 81 | ])); 82 | } 83 | 84 | /** 85 | * Add with overwrite option (default). 86 | * 87 | * @see self::withNoOverwrite() 88 | */ 89 | public function withOverwrite(): self 90 | { 91 | return new self(array_merge($this->params, [ 92 | self::PARAM_OVERWRITE => true, 93 | ])); 94 | } 95 | 96 | /** 97 | * Add protection against output file overwriting. 98 | * 99 | * @see self::witoOverwrite() 100 | */ 101 | public function withNoOverwrite(): self 102 | { 103 | return new self(array_merge($this->params, [ 104 | self::PARAM_OVERWRITE => false, 105 | ])); 106 | } 107 | 108 | public function withOutputFormat(string $outputFormat): self 109 | { 110 | return new self(array_merge($this->params, [ 111 | self::PARAM_OUTPUT_FORMAT => $outputFormat, 112 | ])); 113 | } 114 | 115 | /** 116 | * Set a built-in param... 117 | * 118 | * @param bool|string|int|VideoFilterInterface|FFMpegCLIValueInterface $paramValue 119 | * 120 | * @throws InvalidArgumentException in case of unsupported builtin param 121 | * 122 | * @return self (static analysis the trick is to return 'self' instead of interface) 123 | */ 124 | public function withBuiltInParam(string $paramName, $paramValue): VideoThumbParamsInterface 125 | { 126 | return new self(array_merge($this->params, [ 127 | $paramName => $paramValue, 128 | ])); 129 | } 130 | 131 | /** 132 | * @return self (For static analysis the trick is to return 'self' instead of interface) 133 | */ 134 | public function withoutParam(string $paramName): VideoThumbParamsInterface 135 | { 136 | $ao = (new \ArrayObject($this->params)); 137 | if ($ao->offsetExists($paramName)) { 138 | $ao->offsetUnset($paramName); 139 | } 140 | 141 | return new self($ao->getArrayCopy()); 142 | } 143 | 144 | /** 145 | * Return the internal array holding params. 146 | * 147 | * @return array 148 | */ 149 | public function toArray(): array 150 | { 151 | return $this->params; 152 | } 153 | 154 | public function isParamValid(string $paramName): bool 155 | { 156 | return in_array($paramName, self::BUILTIN_PARAMS, true); 157 | } 158 | 159 | /** 160 | * Return a param, throw an exception if the param has not been defined yet or 161 | * use $default if it was set. 162 | * 163 | * @param mixed $default Will return default value instead of throwing exception 164 | * 165 | * @return bool|string|int|VideoFilterInterface|FFMpegCLIValueInterface|null 166 | * 167 | * @throws UnsetParamException 168 | */ 169 | public function getParam(string $paramName, $default = null) 170 | { 171 | if (!$this->hasParam($paramName)) { 172 | if ($default !== null) { 173 | return $default; 174 | } 175 | 176 | throw new UnsetParamException(sprintf( 177 | 'Cannot get param \'%s\', it has not been set', 178 | $paramName 179 | )); 180 | } 181 | 182 | return $this->params[$paramName]; 183 | } 184 | 185 | public function hasParam(string $paramName): bool 186 | { 187 | return array_key_exists($paramName, $this->params); 188 | } 189 | 190 | /** 191 | * Ensure that all params are supported. 192 | * 193 | * @param array $params 194 | * 195 | * @throws InvalidParamException in case of unsupported param 196 | */ 197 | private function ensureSupportedParams(array $params): void 198 | { 199 | foreach ($params as $paramName => $paramValue) { 200 | if (!$this->isParamValid($paramName)) { 201 | throw new InvalidParamException( 202 | sprintf('Unsupported param "%s" given.', $paramName) 203 | ); 204 | } 205 | } 206 | } 207 | } 208 | -------------------------------------------------------------------------------- /src/Video/VideoConverter.php: -------------------------------------------------------------------------------- 1 | ffmpegConfig = $ffmpegConfig; 49 | 50 | $this->logger = $logger ?? new NullLogger(); 51 | } 52 | 53 | /** 54 | * Return ready-to-run symfony process object that you can use 55 | * to `run()` or `start()` programmatically. Useful if you want to make 56 | * things async... 57 | * 58 | * @param null|string|UnescapedFileInterface $outputFile 59 | * 60 | * @see https://symfony.com/doc/current/components/process.html 61 | * 62 | * @throws CommonException\UnsupportedParamException 63 | * @throws CommonException\UnsupportedParamValueException 64 | * @throws InvalidArgumentException 65 | */ 66 | public function getSymfonyProcess(string $inputFile, $outputFile, VideoConvertParamsInterface $convertParams, ?ProcessParamsInterface $processParams = null): Process 67 | { 68 | $adapter = $this->ffmpegConfig->getAdapter(); 69 | 70 | if (!$convertParams->hasParam(VideoConvertParamsInterface::PARAM_THREADS) 71 | && $adapter->getDefaultThreads() !== null) { 72 | $convertParams = $convertParams->withBuiltInParam( 73 | VideoConvertParamsInterface::PARAM_THREADS, 74 | $adapter->getDefaultThreads() 75 | ); 76 | } 77 | 78 | $arguments = $adapter->getMappedConversionParams($convertParams); 79 | 80 | try { 81 | $ffmpegCmd = $adapter->getCliCommand($arguments, $inputFile, $outputFile); 82 | } catch (CommonException\InvalidArgumentException $e) { 83 | throw new InvalidArgumentException($e->getMessage(), (int) $e->getCode(), $e); 84 | } 85 | 86 | $pp = $processParams ?? $this->ffmpegConfig->getProcessParams(); 87 | 88 | return (new ProcessFactory($ffmpegCmd, $pp))(); 89 | } 90 | 91 | /** 92 | * Run a conversion, throw exception on error. 93 | * 94 | * @param null|string|UnescapedFileInterface $outputFile 95 | * @param callable|null $callback A PHP callback to run whenever there is some 96 | * tmp available on STDOUT or STDERR 97 | * 98 | * @throws ConverterExceptionInterface Base exception class for conversion exceptions 99 | * @throws ConverterProcessExceptionInterface Base exception class for process conversion exceptions 100 | * @throws MissingInputFileException 101 | * @throws MissingFFMpegBinaryException 102 | * @throws ProcessTimedOutException 103 | * @throws ProcessFailedException 104 | * @throws ProcessSignaledException 105 | * @throws InvalidParamException 106 | * @throws RuntimeReaderException 107 | */ 108 | public function convert(string $inputFile, $outputFile, VideoConvertParamsInterface $convertParams, ?callable $callback = null, ?ProcessParamsInterface $processParams = null): void 109 | { 110 | try { 111 | try { 112 | $this->ensureFileReadable($inputFile, true); 113 | $process = $this->getSymfonyProcess($inputFile, $outputFile, $convertParams, $processParams); 114 | $process->mustRun($callback); 115 | } catch (CommonException\FileNotFoundException | CommonException\FileNotReadableException | CommonException\FileEmptyException $e) { 116 | throw new MissingInputFileException($e->getMessage()); 117 | } catch (CommonException\UnsupportedParamValueException | CommonException\UnsupportedParamException $e) { 118 | throw new InvalidParamException($e->getMessage()); 119 | } catch (SPException\ProcessSignaledException $e) { 120 | throw new ProcessSignaledException($e->getProcess(), $e); 121 | } catch (SPException\ProcessTimedOutException $e) { 122 | throw new ProcessTimedOutException($e->getProcess(), $e); 123 | } catch (SPException\ProcessFailedException $e) { 124 | $process = $e->getProcess(); 125 | if ($process->getExitCode() === 127 || 126 | mb_strpos(mb_strtolower($process->getExitCodeText()), 'command not found') !== false) { 127 | throw new MissingFFMpegBinaryException($process, $e); 128 | } 129 | throw new ProcessFailedException($process, $e); 130 | } catch (SPException\RuntimeException $e) { 131 | throw new RuntimeReaderException($e->getMessage()); 132 | } 133 | } catch (\Throwable $e) { 134 | $exceptionNs = explode('\\', get_class($e)); 135 | $this->logger->log( 136 | ($e instanceof MissingInputFileException) ? LogLevel::WARNING : LogLevel::ERROR, 137 | sprintf( 138 | 'VideoConverter %s: \'%s\' to \'%s\'. (%s)', 139 | $exceptionNs[count($exceptionNs) - 1], 140 | $inputFile, 141 | $outputFile instanceof UnescapedFileInterface ? $outputFile->getFile() : $outputFile, 142 | $e->getMessage() 143 | ) 144 | ); 145 | throw $e; 146 | } 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /src/Video/VideoInfoReader.php: -------------------------------------------------------------------------------- 1 | ffprobeConfig = $ffProbeConfig; 53 | $this->logger = $logger ?? new NullLogger(); 54 | $this->cache = $cache ?? new NullCache(); 55 | } 56 | 57 | /** 58 | * Return ready-to-run symfony process object that you can use 59 | * to `run()` or `start()` programmatically. Useful if you want to make 60 | * things your way... 61 | * 62 | * @see https://symfony.com/doc/current/components/process.html 63 | */ 64 | public function getSymfonyProcess(string $inputFile, ?ProcessParamsInterface $processParams = null): Process 65 | { 66 | $ffprobeCmd = [ 67 | $this->ffprobeConfig->getBinary(), 68 | '-v', 69 | 'warning', 70 | '-print_format', 71 | 'json', 72 | '-show_format', 73 | '-show_streams', 74 | '-i', 75 | $inputFile, 76 | ]; 77 | 78 | $pp = $processParams ?? $this->ffprobeConfig->getProcessParams(); 79 | 80 | return (new ProcessFactory($ffprobeCmd, $pp))(); 81 | } 82 | 83 | /** 84 | * @throws InfoReaderExceptionInterface 85 | * @throws InfoProcessReaderExceptionInterface 86 | * @throws ProcessFailedException 87 | * @throws InvalidFFProbeJsonException 88 | * @throws MissingInputFileException 89 | * @throws MissingFFProbeBinaryException 90 | * @throws RuntimeReaderException 91 | */ 92 | public function getInfo(string $file, ?CacheInterface $cache = null): VideoInfo 93 | { 94 | $cache = $cache ?? $this->cache; 95 | 96 | // global try/catch to call logger 97 | 98 | try { 99 | try { 100 | $this->ensureFileReadable($file, true); 101 | } catch (FileNotFoundException | FileNotReadableException | FileEmptyException $e) { 102 | throw new MissingInputFileException($e->getMessage()); 103 | } 104 | 105 | $process = $this->getSymfonyProcess($file); 106 | 107 | try { 108 | $key = $this->getCacheKey($process, $file); 109 | $videoInfo = $this->loadVideoInfoFromCacheKey($file, $cache, $key); 110 | if ($videoInfo === null) { 111 | // cache failure or corrupted, let's fallback to running ffprobe 112 | $cache->set($key, null); 113 | $process->mustRun(); 114 | $output = $process->getOutput(); 115 | $videoInfo = VideoInfo::createFromFFProbeJson($file, $output, $this->logger); 116 | $cache->set($key, $output); 117 | } 118 | 119 | // Exception mapping 120 | } catch (JsonParseException $e) { 121 | throw new InvalidFFProbeJsonException($e->getMessage()); 122 | } catch (SPException\ProcessFailedException $e) { 123 | $process = $e->getProcess(); 124 | if ($process->getExitCode() === 127 || 125 | mb_strpos(mb_strtolower($process->getExitCodeText()), 'command not found') !== false) { 126 | throw new MissingFFProbeBinaryException($process, $e); 127 | } 128 | throw new ProcessFailedException($process, $e); 129 | } catch (SPException\ProcessTimedOutException | SPException\ProcessSignaledException $e) { 130 | throw new ProcessFailedException($e->getProcess(), $e); 131 | } catch (SPException\RuntimeException $e) { 132 | throw new RuntimeReaderException($e->getMessage()); 133 | } 134 | } catch (\Throwable $e) { 135 | $this->logException($e, $file); 136 | throw $e; 137 | } 138 | 139 | return $videoInfo; 140 | } 141 | 142 | private function loadVideoInfoFromCacheKey(string $file, CacheInterface $cache, string $cacheKey): ?VideoInfo 143 | { 144 | $output = $cache->get($cacheKey, null); 145 | if ($output !== null && $output !== '') { 146 | try { 147 | return VideoInfo::createFromFFProbeJson($file, $output, $this->logger); 148 | } catch (\Throwable $e) { 149 | return null; 150 | } 151 | } 152 | 153 | return null; 154 | } 155 | 156 | private function logException(\Throwable $e, string $file): void 157 | { 158 | $exceptionNs = explode('\\', get_class($e)); 159 | $this->logger->log( 160 | ($e instanceof MissingInputFileException) ? LogLevel::WARNING : LogLevel::ERROR, 161 | sprintf( 162 | 'VideoInfoReader %s: \'%s\'. (%s)', 163 | $exceptionNs[count($exceptionNs) - 1], 164 | $file, 165 | $e->getMessage() 166 | ) 167 | ); 168 | } 169 | 170 | private function getCacheKey(Process $process, string $file): string 171 | { 172 | return sha1(sprintf( 173 | '%s | %s | %s | %s | %s', 174 | __METHOD__, 175 | $process->getCommandLine(), 176 | $file, 177 | (string) filesize($file), 178 | (string) filemtime($file) 179 | )); 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Logo](./docs/assets/images/mediatools.png) 2 | 3 | [![PHP 7.1+](https://img.shields.io/badge/php-7.1+-ff69b4.svg)](https://packagist.org/packages/soluble/mediatools) 4 | [![Build Status](https://travis-ci.org/soluble-io/soluble-mediatools.svg?branch=master)](https://travis-ci.org/soluble-io/soluble-mediatools) 5 | [![Coverage](https://codecov.io/gh/soluble-io/soluble-mediatools/branch/master/graph/badge.svg)](https://codecov.io/gh/soluble-io/soluble-mediatools) 6 | [![Code Quality](https://scrutinizer-ci.com/g/soluble-io/soluble-mediatools/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/soluble-io/soluble-mediatools/?branch=master) 7 | [![Latest Stable Version](https://poser.pugx.org/soluble/mediatools/v/stable.svg)](https://packagist.org/packages/soluble/mediatools) 8 | [![Total Downloads](https://poser.pugx.org/soluble/mediatools/downloads.png)](https://packagist.org/packages/soluble/mediatools) 9 | ![PHPStan](https://img.shields.io/badge/style-level%207-brightgreen.svg?style=flat-square&label=phpstan) 10 | [![License](https://poser.pugx.org/soluble/mediatools/license.png)](https://packagist.org/packages/soluble/mediatools) 11 | 12 | Flexible audio/video conversions and thumbnailing for hi*php*ies. 13 | Wraps around [ffmpeg](https://www.ffmpeg.org/) and [ffprobe](https://www.ffmpeg.org/ffprobe.html) 14 | and exposes most of their features, like scaling, clipping, filters, transcoding, audio extraction 15 | and much more. 16 | 17 | To prevent limitations, the API rather focus on providing developer fine-tuned parameters 18 | than giving ready-made recipes. Transcoding and conversions generally 19 | requires specific processing, [judge by yourself](https://soluble-io.github.io/soluble-mediatools/video-conversion-service/#notes). 20 | To help starting, the documentation includes a lot of examples and snippets you 21 | can easily try and tune later. Check also [alternatives](./README.md#alternatives) wrappers 22 | for ffmpeg, they are good and sometimes offer more magic if you're looking for it. 23 | 24 | On another side, it likes [PSR](https://www.php-fig.org/psr/) (psr-log, psr-container, psr-simplecache), tastes php 7.1 in strict mode, tries to fail as early as possible 25 | with clear exception messages and ensure that substitution is possible when you need to customize 26 | *(SOLID friendly)*. 27 | 28 | Under the hood, it relies on the battle-tested [symfony/process](https://symfony.com/doc/current/components/process.html), its only dependency. 29 | 30 | ## Documentation 31 | 32 | All is here: **[https://soluble-io.github.io/soluble-mediatools/](https://soluble-io.github.io/soluble-mediatools/)** 33 | 34 | ## Requirements 35 | 36 | - PHP 7.1+ 37 | - FFmpeg/FFProbe 3.4+, 4.0+. 38 | 39 | ## Features 40 | 41 | > Check the [doc](https://soluble-io.github.io/soluble-mediatools/) to get a more detailed overview !!! 42 | 43 | ### Implemented services 44 | 45 | #### VideoConverter 46 | 47 | > Full doc: [here](https://soluble-io.github.io/soluble-mediatools/video-conversion-service/) 48 | 49 | ```php 50 | withVideoCodec('libx264') 59 | ->withStreamable(true) 60 | ->withCrf(24); 61 | 62 | try { 63 | $converter->convert( 64 | '/path/inputFile.mov', 65 | '/path/outputFile.mp4', 66 | $params 67 | ); 68 | } catch(ConverterExceptionInterface $e) { 69 | // See chapter about exception !!! 70 | } 71 | 72 | ``` 73 | 74 | #### VideoInfoReader 75 | 76 | > Full doc: [here](https://soluble-io.github.io/soluble-mediatools/video-info-service/) 77 | 78 | ```php 79 | getInfo('/path/video.mp4'); 91 | } catch (InfoReaderExceptionInterface $e) { 92 | // not a valid video (see exception) 93 | } 94 | 95 | $duration = $info->getDuration(); // total duration 96 | $format = $info->getFormatName(); // container format: mkv, mp4 97 | 98 | // Step 2: Media streams info (video, subtitle, audio, data). 99 | 100 | // Example with first video stream (streams are iterable) 101 | 102 | try { 103 | $video = $info->getVideoStreams()->getFirst(); 104 | } catch (\Soluble\MediaTools\Video\Exception\NoStreamException $e) { 105 | // No video stream, 106 | } 107 | 108 | $codec = $video->getCodecName(); // i.e: vp9 109 | $fps = $video->getFps($decimals=0); // i.e: 24 110 | $width = $video->getWidth(); // i.e: 1080 111 | $ratio = $video->getAspectRatio(); 112 | 113 | // Alternate example 114 | 115 | if ($info->countStreams(VideoInfo::STREAM_TYPE_SUBTITLE) > 0) { 116 | $sub = $info->getSubtitleStreams()->getFirst(); 117 | $sub->getCodecName(); // webvtt 118 | } 119 | 120 | ``` 121 | 122 | #### VideoThumbGenerator 123 | 124 | > Full doc: [here](https://soluble-io.github.io/soluble-mediatools/video-thumb-service/) 125 | 126 | ```php 127 | withTime(1.25); 136 | 137 | try { 138 | $generator->makeThumbnail( 139 | '/path/inputFile.mov', 140 | '/path/outputFile.jpg', 141 | $params 142 | ); 143 | } catch(ConverterExceptionInterface $e) { 144 | // See chapter about exception !!! 145 | } 146 | 147 | ``` 148 | 149 | #### VideoAnalyzer 150 | 151 | > Full doc: [here](https://soluble-io.github.io/soluble-mediatools/video-detection-service/) 152 | 153 | ```php 154 | detectInterlacement( 163 | '/path/input.mov', 164 | // Optional: 165 | // $maxFramesToAnalyze, default: 1000 166 | $maxFramesToAnalyze = 200 167 | ); 168 | 169 | } catch(AnalyzerExceptionInterface $e) { 170 | // See chapter about exception !!! 171 | } 172 | 173 | $interlaced = $interlaceGuess->isInterlaced( 174 | // Optional: 175 | // $threshold, default 0.25 (if >=25% interlaced frames, then true) 176 | 0.25 177 | ); 178 | 179 | ``` 180 | 181 | ## Alternatives 182 | 183 | - [https://github.com/PHP-FFMpeg/PHP-FFMpeg](https://github.com/PHP-FFMpeg/PHP-FFMpeg) 184 | - [https://github.com/char0n/ffmpeg-php](https://github.com/char0n/ffmpeg-php) 185 | 186 | ## Coding standards and interop 187 | 188 | * [PSR 4 Autoloader](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-4-autoloader.md) 189 | * [PSR 3 Logger interface](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-3-logger-interface.md) 190 | * [PSR 2 Coding Style Guide](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-2-coding-style-guide.md) 191 | 192 | 193 | -------------------------------------------------------------------------------- /src/Common/Config/SafeConfigReader.php: -------------------------------------------------------------------------------- 1 | */ 22 | private $config; 23 | 24 | /** 25 | * @param array $config 26 | */ 27 | public function __construct(array $config, ?string $configKey = null) 28 | { 29 | $this->config = $config; 30 | $this->configKey = $configKey; 31 | } 32 | 33 | /** 34 | * @return int|null 35 | * 36 | * @throws InvalidConfigException 37 | */ 38 | public function getNullableInt(string $key, ?int $default = null): ?int 39 | { 40 | $value = $this->getValueOrDefault($key, $default); 41 | if (!($value === null) && !is_int($value)) { 42 | $this->throwInvalidConfigException(sprintf( 43 | 'Param \'%s\' must be int, \'%s\' given', 44 | $key, 45 | gettype($value) 46 | ), $key); 47 | } 48 | 49 | return $value; 50 | } 51 | 52 | /** 53 | * @return float|null 54 | * 55 | * @throws InvalidConfigException 56 | */ 57 | public function getNullableFloat(string $key, ?float $default = null, bool $tolerateInt = true): ?float 58 | { 59 | $value = $this->getValueOrDefault($key, $default); 60 | if (!($value === null) && !is_float($value)) { 61 | if ($tolerateInt && is_int($value)) { 62 | $value = (float) $value; 63 | } else { 64 | $this->throwInvalidConfigException(sprintf( 65 | 'Param \'%s\' must be int, \'%s\' given', 66 | $key, 67 | gettype($value) 68 | ), $key); 69 | } 70 | } 71 | 72 | return $value; 73 | } 74 | 75 | /** 76 | * Check strict float. 77 | * 78 | * @throws InvalidConfigException 79 | */ 80 | public function getFloat(string $key, ?float $default = null, bool $tolerateInt = true): float 81 | { 82 | $value = $this->getNullableFloat($key, $default, $tolerateInt); 83 | $this->ensureNotNull($value, $key); 84 | 85 | return (float) $value; 86 | } 87 | 88 | /** 89 | * Check strict int. 90 | * 91 | * @throws InvalidConfigException 92 | */ 93 | public function getInt(string $key, ?int $default = null): int 94 | { 95 | $value = $this->getNullableInt($key, $default); 96 | $this->ensureNotNull($value, $key); 97 | 98 | return (int) $value; 99 | } 100 | 101 | /** 102 | * Check strict array. 103 | * 104 | * @throws InvalidConfigException 105 | */ 106 | public function getArray(string $key, ?array $default = null): array 107 | { 108 | $value = $this->getNullableArray($key, $default); 109 | $this->ensureNotNull($value, $key); 110 | 111 | return (array) $value; 112 | } 113 | 114 | /** 115 | * @return array|null 116 | * 117 | * @throws InvalidConfigException 118 | */ 119 | public function getNullableArray(string $key, ?array $default = null): ?array 120 | { 121 | $value = $this->getValueOrDefault($key, $default); 122 | if (!($value === null) && !is_array($value)) { 123 | $this->throwInvalidConfigException(sprintf( 124 | 'Param \'%s\' must be array, \'%s\' given', 125 | $key, 126 | gettype($value) 127 | ), $key); 128 | } 129 | 130 | return $value; 131 | } 132 | 133 | /** 134 | * Check strict string. 135 | * 136 | * @throws InvalidConfigException 137 | */ 138 | public function getString(string $key, ?string $default = null): string 139 | { 140 | $value = $this->getNullableString($key, $default); 141 | $this->ensureNotNull($value, $key); 142 | 143 | return (string) $value; 144 | } 145 | 146 | /** 147 | * @throws InvalidConfigException 148 | */ 149 | public function getNullableString(string $key, ?string $default = null): ?string 150 | { 151 | $value = $this->getValueOrDefault($key, $default); 152 | 153 | if (!($value === null) && !is_string($value)) { 154 | $this->throwInvalidConfigException(sprintf( 155 | 'Param \'%s\' must be string, \'%s\' given', 156 | $key, 157 | gettype($value) 158 | ), $key); 159 | } 160 | 161 | return $value; 162 | } 163 | 164 | /** 165 | * Check strict bool. 166 | * 167 | * @throws InvalidConfigException 168 | */ 169 | public function getBool(string $key, ?bool $default = null): bool 170 | { 171 | $value = $this->getNullableBool($key, $default); 172 | $this->ensureNotNull($value, $key); 173 | 174 | return (bool) $value; 175 | } 176 | 177 | /** 178 | * @throws InvalidConfigException 179 | */ 180 | public function getNullableBool(string $key, ?bool $default = null): ?bool 181 | { 182 | $value = $this->getValueOrDefault($key, $default); 183 | if (!($value === null) && !is_bool($value)) { 184 | $this->throwInvalidConfigException(sprintf( 185 | 'Param \'%s\' must be bool, \'%s\' given', 186 | $key, 187 | gettype($value) 188 | ), $key); 189 | } 190 | 191 | return $value; 192 | } 193 | 194 | /** 195 | * @param mixed|null $default 196 | * 197 | * @return mixed|null 198 | */ 199 | private function getValueOrDefault(string $key, $default) 200 | { 201 | return $this->keyExists($key) ? $this->config[$key] : $default; 202 | } 203 | 204 | public function keyExists(string $key): bool 205 | { 206 | return array_key_exists($key, $this->config); 207 | } 208 | 209 | /** 210 | * @throws InvalidConfigException 211 | */ 212 | public function ensureKeyExists(string $key): void 213 | { 214 | if ($this->keyExists($key)) { 215 | return; 216 | } 217 | 218 | $this->throwInvalidConfigException( 219 | sprintf( 220 | 'Required param [\'%s\'] is missing.', 221 | $key 222 | ), 223 | $key 224 | ); 225 | } 226 | 227 | /** 228 | * @param mixed $value 229 | * 230 | * @throws InvalidConfigException 231 | */ 232 | private function ensureNotNull($value, string $key): void 233 | { 234 | if ($value !== null) { 235 | return; 236 | } 237 | 238 | $this->throwInvalidConfigException( 239 | sprintf( 240 | 'Param \'%s\' cannot be null.', 241 | $key 242 | ), 243 | $key 244 | ); 245 | } 246 | 247 | private function throwInvalidConfigException(string $msg, string $key): void 248 | { 249 | throw new InvalidConfigException( 250 | sprintf( 251 | '%s (check your config entry %s[\'%s\'])', 252 | $msg, 253 | $this->configKey === null ? '' : '[' . $this->configKey . ']', 254 | $key 255 | ) 256 | ); 257 | } 258 | } 259 | --------------------------------------------------------------------------------