diff --git a/README.md b/README.md index 9d6bcd5..980a5a2 100644 --- a/README.md +++ b/README.md @@ -520,6 +520,82 @@ $video More details about concatenation in FFMPEG can be found [here](https://trac.ffmpeg.org/wiki/Concatenate), [here](https://ffmpeg.org/ffmpeg-formats.html#concat-1) and [here](https://ffmpeg.org/ffmpeg.html#Stream-copy). +### AdvancedMedia +AdvancedMedia may have multiple inputs and multiple outputs. + +This class has been developed primarily to use with `-filter_complex`. + +So, its `filters()` method accepts only filters that can be used inside `-filter_complex` command. +AdvancedMedia already contains some built-in filters. + +#### Base usage +For example: + +```php +$advancedMedia = $ffmpeg->openAdvanced(array('video_1.mp4', 'video_2.mp4')); +$advancedMedia->filters() + ->custom('[0:v][1:v]', 'hstack', '[v]'); +$advancedMedia + ->map(array('0:a', '[v]'), new X264('aac', 'libx264'), 'output.mp4') + ->save(); +``` + +This code takes 2 input videos, stacks they horizontally in 1 output video and adds to this new video the audio from the first video. +(It is impossible with simple filtergraph that has only 1 input and only 1 output). + + +#### Complicated example +A more difficult example of possibilities of the AdvancedMedia. Consider all input videos already have the same resolution and duration. ("xstack" filter has been added in the 4.1 version of the ffmpeg). + +```php +$inputs = array( + 'video_1.mp4', + 'video_2.mp4', + 'video_3.mp4', + 'video_4.mp4', +); + +$advancedMedia = $ffmpeg->openAdvanced($inputs); +$advancedMedia->filters() + ->custom('[0:v]', 'negate', '[v0negate]') + ->custom('[1:v]', 'edgedetect', '[v1edgedetect]') + ->custom('[2:v]', 'hflip', '[v2hflip]') + ->custom('[3:v]', 'vflip', '[v3vflip]') + ->xStack('[v0negate][v1edgedetect][v2hflip][v3vflip]', XStackFilter::LAYOUT_2X2, 4, '[resultv]'); +$advancedMedia + ->map(array('0:a'), new Mp3(), 'video_1.mp3') + ->map(array('1:a'), new Flac(), 'video_2.flac') + ->map(array('2:a'), new Wav(), 'video_3.wav') + ->map(array('3:a'), new Aac(), 'video_4.aac') + ->map(array('[resultv]'), new X264('aac', 'libx264'), 'output.mp4') + ->save(); +``` + +This code takes 4 input videos, then the negates the first video, stores result in `[v0negate]` stream, detects edges in the second video, stores result in `[v1edgedetect]` stream, horizontally flips the third video, stores result in `[v2hflip]` stream, vertically flips the fourth video, stores result in `[v3vflip]` stream, then takes this 4 generated streams ans combine them in one 2x2 collage video. +Then saves audios from the original videos into the 4 different formats and saves the generated collage video into the separate file. + +As you can see, you can take multiple input sources, perform the complicated processing for them and produce multiple output files in the same time, in the one ffmpeg command. + +#### Just give me a map! +You do not have to use `-filter_complex`. You can use only `-map` options. For example, just extract the audio from the video: + +```php +$advancedMedia = $ffmpeg->openAdvanced(array('video.mp4')); +$advancedMedia + ->map(array('0:a'), new Mp3(), 'output.mp3') + ->save(); +``` + +#### Customisation +If you need you can extra customize the result ffmpeg command of the AdvancedMedia: + +```php +$advancedMedia = $ffmpeg->openAdvanced($inputs); +$advancedMedia + ->setInitialParameters(array('the', 'params', 'that', 'will', 'be', 'added', 'before', '-i', 'part', 'of', 'the', 'command')) + ->setAdditionalParameters(array('the', 'params', 'that', 'will', 'be', 'added', 'at', 'the', 'end', 'of', 'the', 'command')); +``` + #### Formats A format implements `FFMpeg\Format\FormatInterface`. To save to a video file, diff --git a/src/FFMpeg/Driver/FFMpegDriver.php b/src/FFMpeg/Driver/FFMpegDriver.php index 62aae29..a0de7be 100644 --- a/src/FFMpeg/Driver/FFMpegDriver.php +++ b/src/FFMpeg/Driver/FFMpegDriver.php @@ -16,6 +16,7 @@ use Alchemy\BinaryDriver\Configuration; use Alchemy\BinaryDriver\ConfigurationInterface; use Alchemy\BinaryDriver\Exception\ExecutableNotFoundException as BinaryDriverExecutableNotFound; use FFMpeg\Exception\ExecutableNotFoundException; +use FFMpeg\Exception\RuntimeException; use Psr\Log\LoggerInterface; class FFMpegDriver extends AbstractBinary @@ -54,4 +55,20 @@ class FFMpegDriver extends AbstractBinary throw new ExecutableNotFoundException('Unable to load FFMpeg', $e->getCode(), $e); } } + + /** + * Get ffmpeg version. + * + * @return string + * @throws RuntimeException + */ + public function getVersion() + { + preg_match('#version\s(\S+)#', $this->command('-version'), $version); + if (!isset($version[1])) { + throw new RuntimeException('Cannot to parse the ffmpeg version!'); + } + + return $version[1]; + } } diff --git a/src/FFMpeg/FFMpeg.php b/src/FFMpeg/FFMpeg.php index 91ce361..16a7ff1 100644 --- a/src/FFMpeg/FFMpeg.php +++ b/src/FFMpeg/FFMpeg.php @@ -16,6 +16,7 @@ use FFMpeg\Driver\FFMpegDriver; use FFMpeg\Exception\InvalidArgumentException; use FFMpeg\Exception\RuntimeException; use FFMpeg\Media\Audio; +use FFMpeg\Media\AdvancedMedia; use FFMpeg\Media\Video; use Psr\Log\LoggerInterface; @@ -59,6 +60,8 @@ class FFMpeg /** * Sets the ffmpeg driver. * + * @param FFMpegDriver $ffmpeg + * * @return FFMpeg */ public function setFFMpegDriver(FFMpegDriver $ffmpeg) @@ -102,6 +105,18 @@ class FFMpeg throw new InvalidArgumentException('Unable to detect file format, only audio and video supported'); } + /** + * Opens multiple input sources. + * + * @param string[] $inputs Array of files to be opened. + * + * @return AdvancedMedia + */ + public function openAdvanced($inputs) + { + return new AdvancedMedia($inputs, $this->driver, $this->ffprobe); + } + /** * Creates a new FFMpeg instance. * diff --git a/src/FFMpeg/Filters/AdvancedMedia/ANullSrcFilter.php b/src/FFMpeg/Filters/AdvancedMedia/ANullSrcFilter.php new file mode 100644 index 0000000..ca575b1 --- /dev/null +++ b/src/FFMpeg/Filters/AdvancedMedia/ANullSrcFilter.php @@ -0,0 +1,71 @@ +channelLayout = $channelLayout; + $this->sampleRate = $sampleRate; + $this->nbSamples = $nbSamples; + } + + /** + * Get name of the filter. + * + * @return string + */ + public function getName() + { + return 'anullsrc'; + } + + /** + * {@inheritdoc} + */ + public function applyComplex(AdvancedMedia $media) + { + return array( + '-filter_complex', + $this->getName() . $this->buildFilterOptions(array( + 'channel_layout' => $this->channelLayout, + 'sample_rate' => $this->sampleRate, + 'nb_samples' => $this->nbSamples, + )) + ); + } +} diff --git a/src/FFMpeg/Filters/AdvancedMedia/AbstractComplexFilter.php b/src/FFMpeg/Filters/AdvancedMedia/AbstractComplexFilter.php new file mode 100644 index 0000000..f5a6b20 --- /dev/null +++ b/src/FFMpeg/Filters/AdvancedMedia/AbstractComplexFilter.php @@ -0,0 +1,62 @@ +priority = $priority; + } + + /** + * {@inheritdoc} + */ + public function getPriority() + { + return $this->priority; + } + + /** + * Get minimal version of ffmpeg starting with which this filter is supported. + * + * @return string + */ + public function getMinimalFFMpegVersion() + { + return '0.3'; + } + + /** + * Generate the config of the filter. + * + * @param array $params Associative array of filter options. The options may be null. + * + * @return string The string of the form "=name1=value1:name2=value2" or empty string. + */ + protected function buildFilterOptions(array $params) + { + $config = array(); + foreach ($params as $paramName => $paramValue) { + if ($paramValue !== null) { + $config[] = $paramName . '=' . $paramValue; + } + } + + if (!empty($config)) { + return '=' . implode(':', $config); + } + + return ''; + } +} diff --git a/src/FFMpeg/Filters/AdvancedMedia/ComplexCompatibleFilter.php b/src/FFMpeg/Filters/AdvancedMedia/ComplexCompatibleFilter.php new file mode 100644 index 0000000..c967d95 --- /dev/null +++ b/src/FFMpeg/Filters/AdvancedMedia/ComplexCompatibleFilter.php @@ -0,0 +1,35 @@ +priority = $baseFilter->getPriority(); + $this->inLabels = $inLabels; + $this->baseFilter = $baseFilter; + $this->outLabels = $outLabels; + } + + /** + * Returns the priority of the filter. + * + * @return integer + */ + public function getPriority() + { + return $this->priority; + } + + /** + * @return string + */ + public function getInLabels() + { + return $this->inLabels; + } + + /** + * @return string + */ + public function getOutLabels() + { + return $this->outLabels; + } + + /** + * Get name of the filter. + * + * @return string + */ + public function getName() + { + return $this->baseFilter->getName(); + } + + /** + * Get minimal version of ffmpeg starting with which this filter is supported. + * + * @return string + */ + public function getMinimalFFMpegVersion() + { + return $this->baseFilter->getMinimalFFMpegVersion(); + } + + /** + * {@inheritdoc} + */ + public function applyComplex(AdvancedMedia $media) + { + return $this->baseFilter->applyComplex($media); + } +} diff --git a/src/FFMpeg/Filters/AdvancedMedia/ComplexFilterInterface.php b/src/FFMpeg/Filters/AdvancedMedia/ComplexFilterInterface.php new file mode 100644 index 0000000..58568d6 --- /dev/null +++ b/src/FFMpeg/Filters/AdvancedMedia/ComplexFilterInterface.php @@ -0,0 +1,19 @@ +media = $media; + } + + /** + * @param string $in + * @param string $parameters + * @param string $out + * + * @return ComplexFilters + */ + public function custom($in, $parameters, $out) + { + $this->media->addFilter($in, new CustomComplexFilter($parameters), $out); + return $this; + } + + /** + * Adds padding (black bars) to a video. + * + * @param string $in + * @param Dimension $dimension + * @param string $out + * + * @return ComplexFilters + */ + public function pad($in, Dimension $dimension, $out) + { + $this->media->addFilter($in, new PadFilter($dimension), $out); + return $this; + } + + /** + * Adds a watermark image to a video. + * + * @param string $in + * @param string $imagePath + * @param string $out + * @param array $coordinates + * + * @return $this + */ + public function watermark($in, $imagePath, $out, array $coordinates = array()) + { + $this->media->addFilter($in, new WatermarkFilter($imagePath, $coordinates), $out); + return $this; + } + + /** + * Apply "xstack" filter. + * Warning: this filter is supported starting from 4.1 ffmpeg version. + * + * @param string $in + * @param string $layout + * @param int $inputsCount + * @param string $out + * + * @return ComplexFilters + * @see https://ffmpeg.org/ffmpeg-filters.html#xstack + */ + public function xStack($in, $layout, $inputsCount, $out) + { + $this->media->addFilter($in, new XStackFilter($layout, $inputsCount), $out); + return $this; + } + + /** + * This filter build various types of computed inputs. + * + * @param string $out + * @param string|null $type + * @param string|null $size + * @param string|null $duration + * @param string|null $sar + * @param string|null $rate + * @param string|null $level + * @param string|null $color + * @param int|null $alpha + * @param float|null $decimals + * + * @return ComplexFilters + * @see https://ffmpeg.org/ffmpeg-filters.html#allrgb_002c-allyuv_002c-color_002c-haldclutsrc_002c-nullsrc_002c-pal75bars_002c-pal100bars_002c-rgbtestsrc_002c-smptebars_002c-smptehdbars_002c-testsrc_002c-testsrc2_002c-yuvtestsrc + */ + public function testSrc( + $out, + $type = TestSrcFilter::TESTSRC, + $size = '320x240', + $duration = null, + $sar = null, + $rate = null, + $level = null, + $color = null, + $alpha = null, + $decimals = null + ) { + $this->media->addFilter('', + new TestSrcFilter($type, $size, $duration, $sar, $rate, $level, $color, $alpha, $decimals), $out); + return $this; + } + + /** + * Apply "anullsrc" filter. + * + * @param string $out + * @param string|null $channelLayout + * @param int|null $sampleRate + * @param int|null $nbSamples + * + * @return ComplexFilters + * @see https://ffmpeg.org/ffmpeg-filters.html#anullsrc + */ + public function aNullSrc( + $out, + $channelLayout = null, + $sampleRate = null, + $nbSamples = null + ) { + $this->media->addFilter('', new ANullSrcFilter($channelLayout, $sampleRate, $nbSamples), $out); + return $this; + } + + /** + * Apply "sine" filter. + * + * @param $out + * @param string $duration + * @param int|null $frequency + * @param string|null $beep_factor + * @param int|null $sample_rate + * @param string|null $samples_per_frame + * + * @return $this + * @see https://ffmpeg.org/ffmpeg-filters.html#sine + */ + public function sine( + $out, + $duration, + $frequency = null, + $beep_factor = null, + $sample_rate = null, + $samples_per_frame = null + ) { + $this->media->addFilter('', + new SineFilter($duration, $frequency, $beep_factor, $sample_rate, $samples_per_frame), $out); + return $this; + } +} diff --git a/src/FFMpeg/Filters/AdvancedMedia/CustomComplexFilter.php b/src/FFMpeg/Filters/AdvancedMedia/CustomComplexFilter.php new file mode 100644 index 0000000..25e0d71 --- /dev/null +++ b/src/FFMpeg/Filters/AdvancedMedia/CustomComplexFilter.php @@ -0,0 +1,43 @@ +filter = $filter; + } + + /** + * Get name of the filter. + * + * @return string + */ + public function getName() + { + return 'custom_filter'; + } + + /** + * {@inheritdoc} + */ + public function applyComplex(AdvancedMedia $media) + { + return array('-filter_complex', $this->filter); + } +} diff --git a/src/FFMpeg/Filters/AdvancedMedia/SineFilter.php b/src/FFMpeg/Filters/AdvancedMedia/SineFilter.php new file mode 100644 index 0000000..cdfe4ed --- /dev/null +++ b/src/FFMpeg/Filters/AdvancedMedia/SineFilter.php @@ -0,0 +1,97 @@ +duration = $duration; + $this->frequency = $frequency; + $this->beep_factor = $beep_factor; + $this->sample_rate = $sample_rate; + $this->samples_per_frame = $samples_per_frame; + } + + /** + * Get name of the filter. + * + * @return string + */ + public function getName() + { + return 'sine'; + } + + /** + * Get minimal version of ffmpeg starting with which this filter is supported. + * + * @return string + */ + public function getMinimalFFMpegVersion() + { + return '2.0'; + } + + /** + * Apply the complex filter to the given media. + * + * @param AdvancedMedia $media + * + * @return string[] An array of arguments. + */ + public function applyComplex(AdvancedMedia $media) + { + return array( + '-filter_complex', + $this->getName() . $this->buildFilterOptions(array( + 'frequency' => $this->frequency, + 'beep_factor' => $this->beep_factor, + 'sample_rate' => $this->sample_rate, + 'duration' => $this->duration, + 'samples_per_frame' => $this->samples_per_frame, + )) + ); + } +} diff --git a/src/FFMpeg/Filters/AdvancedMedia/TestSrcFilter.php b/src/FFMpeg/Filters/AdvancedMedia/TestSrcFilter.php new file mode 100644 index 0000000..9dfe77e --- /dev/null +++ b/src/FFMpeg/Filters/AdvancedMedia/TestSrcFilter.php @@ -0,0 +1,246 @@ +type = $type; + $this->level = $level; + $this->color = $color; + $this->size = $size; + $this->rate = $rate; + $this->duration = $duration; + $this->sar = $sar; + $this->alpha = $alpha; + $this->decimals = $decimals; + } + + /** + * Get name of the filter. + * + * @return string + */ + public function getName() + { + return $this->type; + } + + /** + * Get minimal version of ffmpeg starting with which this filter is supported. + * + * @return string + */ + public function getMinimalFFMpegVersion() + { + switch ($this->type) { + case self::PAL75BARS: + case self::PAL100BARS: + return '4.1'; + case self::YUVTESTSRC: + return '3.2'; + case self::ALLRGB: + case self::ALLYUV: + return '2.8'; + case self::SMPTEHDBARS: + return '2.0'; + case self::SMPTEBARS: + return '1.0'; + default: + return '0.3'; + } + } + + /** + * {@inheritdoc} + */ + public function applyComplex(AdvancedMedia $media) + { + return array( + '-filter_complex', + $this->type . $this->buildFilterOptions(array( + 'level' => $this->level, + 'color' => $this->color, + 'size' => $this->size, + 'rate' => $this->rate, + 'duration' => $this->duration, + 'sar' => $this->sar, + 'alpha' => $this->alpha, + 'decimals' => $this->decimals + )) + ); + } +} diff --git a/src/FFMpeg/Filters/AdvancedMedia/XStackFilter.php b/src/FFMpeg/Filters/AdvancedMedia/XStackFilter.php new file mode 100644 index 0000000..3058a5e --- /dev/null +++ b/src/FFMpeg/Filters/AdvancedMedia/XStackFilter.php @@ -0,0 +1,93 @@ +layout = $layout; + $this->inputsCount = $inputsCount; + } + + /** + * @param int $count + * + * @return string + */ + public static function getInputByCount($count) + { + $result = ''; + for ($i = 0; $i < $count; $i++) { + $result .= '[' . $i . ':v]'; + } + return $result; + } + + /** + * Get name of the filter. + * + * @return string + */ + public function getName() + { + return 'xstack'; + } + + /** + * Get minimal version of ffmpeg starting with which this filter is supported. + * + * @return string + */ + public function getMinimalFFMpegVersion() + { + return '4.1'; + } + + /** + * {@inheritdoc} + */ + public function applyComplex(AdvancedMedia $media) + { + return array( + '-filter_complex', + $this->getName() . $this->buildFilterOptions(array( + 'inputs' => $this->inputsCount, + 'layout' => $this->layout + )) + ); + } +} diff --git a/src/FFMpeg/Filters/Video/PadFilter.php b/src/FFMpeg/Filters/Video/PadFilter.php index a6dcf2d..2d045c1 100644 --- a/src/FFMpeg/Filters/Video/PadFilter.php +++ b/src/FFMpeg/Filters/Video/PadFilter.php @@ -12,10 +12,12 @@ namespace FFMpeg\Filters\Video; use FFMpeg\Coordinate\Dimension; -use FFMpeg\Media\Video; +use FFMpeg\Filters\AdvancedMedia\ComplexCompatibleFilter; use FFMpeg\Format\VideoInterface; +use FFMpeg\Media\AdvancedMedia; +use FFMpeg\Media\Video; -class PadFilter implements VideoFilterInterface +class PadFilter implements VideoFilterInterface, ComplexCompatibleFilter { /** @var Dimension */ private $dimension; @@ -44,15 +46,54 @@ class PadFilter implements VideoFilterInterface return $this->dimension; } + /** + * Get name of the filter. + * + * @return string + */ + public function getName() + { + return 'pad'; + } + + /** + * Get minimal version of ffmpeg starting with which this filter is supported. + * + * @return string + */ + public function getMinimalFFMpegVersion() + { + return '0.4.9'; + } + /** * {@inheritdoc} */ public function apply(Video $video, VideoInterface $format) + { + return $this->getCommands(); + } + + /** + * {@inheritdoc} + */ + public function applyComplex(AdvancedMedia $media) + { + return $this->getCommands(); + } + + /** + * @return array + */ + protected function getCommands() { $commands = array(); $commands[] = '-vf'; - $commands[] = 'scale=iw*min(' . $this->dimension->getWidth() . '/iw\,' . $this->dimension->getHeight() .'/ih):ih*min(' . $this->dimension->getWidth() . '/iw\,' . $this->dimension->getHeight() .'/ih),pad=' . $this->dimension->getWidth() . ':' . $this->dimension->getHeight() . ':(' . $this->dimension->getWidth() . '-iw)/2:(' . $this->dimension->getHeight() .'-ih)/2'; + $commands[] = 'scale=iw*min(' . $this->dimension->getWidth() . '/iw\,' . $this->dimension->getHeight() + . '/ih):ih*min(' . $this->dimension->getWidth() . '/iw\,' . $this->dimension->getHeight() . '/ih),pad=' + . $this->dimension->getWidth() . ':' . $this->dimension->getHeight() . ':(' . $this->dimension->getWidth() + . '-iw)/2:(' . $this->dimension->getHeight() . '-ih)/2'; return $commands; } diff --git a/src/FFMpeg/Filters/Video/WatermarkFilter.php b/src/FFMpeg/Filters/Video/WatermarkFilter.php index 38183ad..98d21c1 100644 --- a/src/FFMpeg/Filters/Video/WatermarkFilter.php +++ b/src/FFMpeg/Filters/Video/WatermarkFilter.php @@ -12,10 +12,12 @@ namespace FFMpeg\Filters\Video; use FFMpeg\Exception\InvalidArgumentException; +use FFMpeg\Filters\AdvancedMedia\ComplexCompatibleFilter; use FFMpeg\Format\VideoInterface; +use FFMpeg\Media\AdvancedMedia; use FFMpeg\Media\Video; -class WatermarkFilter implements VideoFilterInterface +class WatermarkFilter implements VideoFilterInterface, ComplexCompatibleFilter { /** @var string */ private $watermarkPath; @@ -43,10 +45,46 @@ class WatermarkFilter implements VideoFilterInterface return $this->priority; } + /** + * Get name of the filter. + * + * @return string + */ + public function getName() + { + return 'watermark'; + } + + /** + * Get minimal version of ffmpeg starting with which this filter is supported. + * + * @return string + */ + public function getMinimalFFMpegVersion() + { + return '0.8'; + } + /** * {@inheritdoc} */ public function apply(Video $video, VideoInterface $format) + { + return $this->getCommands(); + } + + /** + * {@inheritdoc} + */ + public function applyComplex(AdvancedMedia $media) + { + return $this->getCommands(); + } + + /** + * @return array + */ + protected function getCommands() { $position = isset($this->coordinates['position']) ? $this->coordinates['position'] : 'absolute'; @@ -55,7 +93,7 @@ class WatermarkFilter implements VideoFilterInterface if (isset($this->coordinates['top'])) { $y = $this->coordinates['top']; } elseif (isset($this->coordinates['bottom'])) { - $y = sprintf('main_h - %d - overlay_h', $this->coordinates['bottom']); + $y = 'main_h - ' . $this->coordinates['bottom'] . ' - overlay_h'; } else { $y = 0; } @@ -63,7 +101,7 @@ class WatermarkFilter implements VideoFilterInterface if (isset($this->coordinates['left'])) { $x = $this->coordinates['left']; } elseif (isset($this->coordinates['right'])) { - $x = sprintf('main_w - %d - overlay_w', $this->coordinates['right']); + $x = 'main_w - ' . $this->coordinates['right'] . ' - overlay_w'; } else { $x = 0; } @@ -75,6 +113,9 @@ class WatermarkFilter implements VideoFilterInterface break; } - return array('-vf', sprintf('movie=%s [watermark]; [in][watermark] overlay=%s:%s [out]', $this->watermarkPath, $x, $y)); + return array( + '-vf', + 'movie=' . $this->watermarkPath . ' [watermark]; [in][watermark] overlay=' . $x . ':' . $y . ' [out]', + ); } } diff --git a/src/FFMpeg/Media/AdvancedMedia.php b/src/FFMpeg/Media/AdvancedMedia.php new file mode 100644 index 0000000..8524f49 --- /dev/null +++ b/src/FFMpeg/Media/AdvancedMedia.php @@ -0,0 +1,424 @@ + 0) { + $pathfile = $inputs[$inputsKeys[0]]; + } + + parent::__construct($pathfile, $driver, $ffprobe); + $this->filters = new FiltersCollection(); + $this->inputs = $inputs; + $this->initialParameters = array(); + $this->additionalParameters = array(); + $this->mapCommands = array(); + $this->listeners = array(); + } + + /** + * Returns the available filters. + * + * @return ComplexFilters + */ + public function filters() + { + return new ComplexFilters($this); + } + + /** + * Add complex filter. + * + * @param string $in + * @param ComplexCompatibleFilter $filter + * @param string $out + * + * @return $this + */ + public function addFilter($in, ComplexCompatibleFilter $filter, $out) + { + $this->filters->add(new ComplexFilterContainer($in, $filter, $out)); + return $this; + } + + /** + * @inheritDoc + */ + public function setFiltersCollection(FiltersCollection $filters) + { + foreach ($filters as $filter) { + if (!($filter instanceof ComplexFilterInterface)) { + throw new RuntimeException ('For AdvancedMedia you can set filters collection' + . ' contains only objects that implement ComplexFilterInterface!'); + } + } + + return parent::setFiltersCollection($filters); + } + + /** + * @return string[] + */ + public function getInitialParameters() + { + return $this->initialParameters; + } + + /** + * @param string[] $initialParameters + * + * @return AdvancedMedia + */ + public function setInitialParameters(array $initialParameters) + { + $this->initialParameters = $initialParameters; + return $this; + } + + /** + * @return string[] + */ + public function getAdditionalParameters() + { + return $this->additionalParameters; + } + + /** + * @param string[] $additionalParameters + * + * @return AdvancedMedia + */ + public function setAdditionalParameters(array $additionalParameters) + { + $this->additionalParameters = $additionalParameters; + return $this; + } + + /** + * @return string[] + */ + public function getInputs() + { + return $this->inputs; + } + + /** + * @return int + */ + public function getInputsCount() + { + return count($this->inputs); + } + + /** + * @return string + */ + public function getFinalCommand() + { + return implode(' ', $this->buildCommand()); + } + + /** + * Select the streams for output. + * + * @param string[] $outs Output labels of the -filter_complex part. + * @param FormatInterface $format Format of the output file. + * @param string $outputFilename Output filename. + * @param bool $forceDisableAudio + * @param bool $forceDisableVideo + * + * @return $this + * @see https://ffmpeg.org/ffmpeg.html#Manual-stream-selection + */ + public function map( + array $outs, + FormatInterface $format, + $outputFilename, + $forceDisableAudio = false, + $forceDisableVideo = false + ) { + $commands = array(); + foreach ($outs as $label) { + $commands[] = '-map'; + $commands[] = $label; + } + + // Apply format params. + $commands = array_merge($commands, + $this->applyFormatParams($format, $forceDisableAudio, $forceDisableVideo)); + + // Set output file. + $commands[] = $outputFilename; + + // Create a listener. + if ($format instanceof ProgressableInterface) { + $listener = $format->createProgressListener($this, $this->ffprobe, 1, 1, 0); + $this->listeners = array_merge($this->listeners, $listener); + } + + $this->mapCommands = array_merge($this->mapCommands, $commands); + return $this; + } + + /** + * Apply added filters and execute ffmpeg command. + * + * @return void + * @throws RuntimeException + */ + public function save() + { + $this->assertFiltersAreCompatibleToCurrentFFMpegVersion(); + $command = $this->buildCommand(); + + try { + $this->driver->command($command, false, $this->listeners); + } catch (ExecutionFailureException $e) { + throw new RuntimeException('Encoding failed', $e->getCode(), $e); + } + } + + /** + * @param FormatInterface $format + * @param bool $forceDisableAudio + * @param bool $forceDisableVideo + * + * @return array + */ + private function applyFormatParams( + FormatInterface $format, + $forceDisableAudio = false, + $forceDisableVideo = false + ) { + // Set format params. + $commands = array(); + if (!$forceDisableVideo && $format instanceof VideoInterface) { + if ($format->getVideoCodec() !== null) { + $commands[] = '-vcodec'; + $commands[] = $format->getVideoCodec(); + } + // If the user passed some additional format parameters. + if ($format->getAdditionalParameters() !== null) { + $commands = array_merge($commands, $format->getAdditionalParameters()); + } + } + if (!$forceDisableAudio && $format instanceof AudioInterface) { + if ($format->getAudioCodec() !== null) { + $commands[] = '-acodec'; + $commands[] = $format->getAudioCodec(); + } + if ($format->getAudioKiloBitrate() !== null) { + $commands[] = '-b:a'; + $commands[] = $format->getAudioKiloBitrate() . 'k'; + } + if ($format->getAudioChannels() !== null) { + $commands[] = '-ac'; + $commands[] = $format->getAudioChannels(); + } + } + + // If the user passed some extra parameters. + if ($format->getExtraParams()) { + $commands = array_merge($commands, $format->getExtraParams()); + } + + return $commands; + } + + /** + * @param ComplexFilterInterface $filter + * + * @return string + */ + private function applyComplexFilter(ComplexFilterInterface $filter) + { + /** @var $format VideoInterface */ + $filterCommands = $filter->applyComplex($this); + foreach ($filterCommands as $index => $command) { + if ($command === '-vf' || $command === '-filter:v' || $command === '-filter_complex') { + unset ($filterCommands[$index]); + } + } + + $strCommand = implode(' ', $filterCommands); + + // Compatibility with the some existed filters: + // If the command contains [in], just replace it to inLabel. If not - to add it manually. + if (stripos($strCommand, '[in]') !== false) { + $strCommand = str_replace('[in]', $filter->getInLabels(), $strCommand); + $in = ''; + } else { + $in = $filter->getInLabels(); + } + + // If the command contains [out], just replace it to outLabel. If not - to add it manually. + if (stripos($strCommand, '[out]') !== false) { + $strCommand = str_replace('[out]', $filter->getOutLabels(), $strCommand); + $out = ''; + } else { + $out = $filter->getOutLabels(); + } + + return $in . $strCommand . $out; + } + + /** + * @return void + * @throws RuntimeException + */ + protected function assertFiltersAreCompatibleToCurrentFFMpegVersion() + { + $messages = array(); + $currentVersion = $this->getFFMpegDriver()->getVersion(); + /** @var ComplexFilterInterface $filter */ + foreach ($this->filters as $filter) { + if (version_compare($currentVersion, $filter->getMinimalFFMpegVersion(), '<')) { + $messages[] = $filter->getName() . ' filter is supported starting from ' + . $filter->getMinimalFFMpegVersion() . ' ffmpeg version'; + } + } + + if (!empty($messages)) { + throw new RuntimeException(implode('; ', $messages) + . '; your ffmpeg version is ' . $currentVersion); + } + } + + /** + * @return array + */ + protected function buildCommand() + { + $globalOptions = array('threads', 'filter_threads', 'filter_complex_threads'); + return array_merge(array('-y'), + $this->buildConfiguredGlobalOptions($globalOptions), + $this->getInitialParameters(), + $this->buildInputsPart($this->inputs), + $this->buildComplexFilterPart($this->filters), + $this->mapCommands, + $this->getAdditionalParameters() + ); + } + + /** + * @param string[] $optionNames + * + * @return array + */ + private function buildConfiguredGlobalOptions($optionNames) + { + $commands = array(); + foreach ($optionNames as $optionName) { + if (!$this->driver->getConfiguration()->has('ffmpeg.' . $optionName)) { + continue; + } + + $commands[] = '-' . $optionName; + $commands[] = $this->driver->getConfiguration()->get('ffmpeg.' . $optionName); + } + + return $commands; + } + + /** + * Build inputs part of the ffmpeg command. + * + * @param string[] $inputs + * + * @return array + */ + private function buildInputsPart(array $inputs) + { + $commands = array(); + foreach ($inputs as $input) { + $commands[] = '-i'; + $commands[] = $input; + } + + return $commands; + } + + /** + * Build "-filter_complex" part of the ffmpeg command. + * + * @param FiltersCollection $complexFilters + * + * @return array + */ + private function buildComplexFilterPart(FiltersCollection $complexFilters) + { + $commands = array(); + /** @var ComplexFilterInterface $filter */ + foreach ($complexFilters as $filter) { + $filterCommand = $this->applyComplexFilter($filter); + $commands[] = $filterCommand; + } + + if (empty($commands)) { + return array(); + } + return array('-filter_complex', implode(';', $commands)); + } +} diff --git a/tests/BaseTestCase.php b/tests/BaseTestCase.php index 75f8877..f32ff49 100644 --- a/tests/BaseTestCase.php +++ b/tests/BaseTestCase.php @@ -8,7 +8,8 @@ use PHPUnit\Framework\TestCase; * This is a BC Layer to support phpunit 4.8 needed for php <= 5.5. */ if (class_exists('PHPUnit_Runner_Version') - && version_compare(\PHPUnit_Runner_Version::id(), '5', '<')) { + && version_compare(\PHPUnit_Runner_Version::id(), '5', '<') +) { class BaseTestCase extends TestCase { public static function assertScalar($value, $message = '') @@ -40,6 +41,16 @@ if (class_exists('PHPUnit_Runner_Version') { $this->setExpectedException($exception, $message); } + + public static function assertStringContainsString($needle, $haystack, $message = '') + { + self::assertContains($needle, $haystack, $message); + } + + public static function assertStringNotContainsString($needle, $haystack, $message = '') + { + self::assertNotContains($needle, $haystack, $message); + } } } else { class BaseTestCase extends TestCase diff --git a/tests/Functional/AdvancedMediaTest.php b/tests/Functional/AdvancedMediaTest.php new file mode 100644 index 0000000..cb6d904 --- /dev/null +++ b/tests/Functional/AdvancedMediaTest.php @@ -0,0 +1,268 @@ +getFFMpeg(); + $inputs = array(realpath(__DIR__ . '/../files/Test.ogv')); + $format = new Mp3(); + $output = __DIR__ . '/' . self::OUTPUT_PATH_PREFIX . 'extracted_with_map.mp3'; + + // You can run it without -filter_complex, just using -map. + $advancedMedia = $ffmpeg->openAdvanced($inputs); + $advancedMedia + ->map(array('0:a'), $format, $output) + ->save(); + + $this->assertFileExists($output); + $this->assertEquals('MP2/3 (MPEG audio layer 2/3)', + $ffmpeg->open($output)->getFormat()->get('format_long_name')); + unlink($output); + } + + public function testAudio() + { + $ffmpeg = $this->getFFMpeg(); + $inputs = array(realpath(__DIR__ . '/../files/Audio.mp3')); + $format = new Mp3(); + $format->setAudioKiloBitrate(30); + $output = __DIR__ . '/' . self::OUTPUT_PATH_PREFIX . 'audio_test.mp3'; + + $advancedMedia = $ffmpeg->openAdvanced($inputs); + $advancedMedia + ->map(array('0:a'), $format, $output) + ->save(); + + $this->assertFileExists($output); + $this->assertEquals('MP2/3 (MPEG audio layer 2/3)', + $ffmpeg->open($output)->getFormat()->get('format_long_name')); + unlink($output); + } + + public function testMultipleInputs() + { + $ffmpeg = $this->getFFMpeg(); + $inputs = array( + realpath(__DIR__ . '/../files/portrait.MOV'), + realpath(__DIR__ . '/../files/portrait.MOV') + ); + $format = new X264('aac', 'libx264'); + $output = __DIR__ . '/' . self::OUTPUT_PATH_PREFIX . 'multiple_inputs_test.mp4'; + + $advancedMedia = $ffmpeg->openAdvanced($inputs); + $advancedMedia->filters() + ->custom('[0:v][1:v]', 'hstack', '[v]'); + $advancedMedia + ->map(array('0:a', '[v]'), $format, $output) + ->save(); + + $this->assertFileExists($output); + $this->assertEquals('QuickTime / MOV', + $ffmpeg->open($output)->getFormat()->get('format_long_name')); + unlink($output); + } + + /** + * @covers \FFMpeg\Media\AdvancedMedia::map + */ + public function testMultipleOutputsTestAbsenceOfInputs() + { + $ffmpeg = $this->getFFMpeg(); + // in this test we use only computed inputs + // and can ignore -i part of the command, pass empty inputs array. + $inputs = array(); + $formatX264 = new X264('aac', 'libx264'); + $formatMp3 = new Mp3(); + + $outputMp3 = __DIR__ . '/' . self::OUTPUT_PATH_PREFIX . 'test_multiple_outputs.mp3'; + $outputVideo1 = __DIR__ . '/' . self::OUTPUT_PATH_PREFIX . 'test_multiple_outputs_v1.mp4'; + $outputVideo2 = __DIR__ . '/' . self::OUTPUT_PATH_PREFIX . 'test_multiple_outputs_v2.mp4'; + + $advancedMedia = $ffmpeg->openAdvanced($inputs); + $advancedMedia->filters() + ->sine('[a]', 5) + ->testSrc('[v1]', TestSrcFilter::TESTSRC, '160x120', 5) + ->testSrc('[v2]', TestSrcFilter::TESTSRC, '160x120', 5) + ->custom('[v1]', 'negate', '[v1negate]') + ->custom('[v2]', 'edgedetect', '[v2edgedetect]'); + $advancedMedia + ->map(array('[a]'), $formatMp3, $outputMp3) + ->map(array('[v1negate]'), $formatX264, $outputVideo1) + ->map(array('[v2edgedetect]'), $formatX264, $outputVideo2) + ->save(); + + + $this->assertFileExists($outputMp3); + $this->assertEquals('MP2/3 (MPEG audio layer 2/3)', + $ffmpeg->open($outputMp3)->getFormat()->get('format_long_name')); + unlink($outputMp3); + + $this->assertFileExists($outputVideo1); + $this->assertEquals('QuickTime / MOV', + $ffmpeg->open($outputVideo1)->getFormat()->get('format_long_name')); + unlink($outputVideo1); + + $this->assertFileExists($outputVideo2); + $this->assertEquals('QuickTime / MOV', + $ffmpeg->open($outputVideo2)->getFormat()->get('format_long_name')); + unlink($outputVideo2); + } + + /** + * @covers \FFMpeg\Filters\AdvancedMedia\TestSrcFilter + * @covers \FFMpeg\Filters\AdvancedMedia\SineFilter + */ + public function testTestSrcFilterTestSineFilter() + { + $ffmpeg = $this->getFFMpeg(); + $inputs = array(realpath(__DIR__ . '/../files/Test.ogv')); + $format = new X264('aac', 'libx264'); + $output = __DIR__ . '/' . self::OUTPUT_PATH_PREFIX . 'testsrc.mp4'; + + $advancedMedia = $ffmpeg->openAdvanced($inputs); + $advancedMedia->filters() + ->sine('[a]', 10) + ->testSrc('[v]', TestSrcFilter::TESTSRC, '160x120', 10); + $advancedMedia + ->map(array('[a]', '[v]'), $format, $output) + ->save(); + + $this->assertFileExists($output); + $this->assertEquals('QuickTime / MOV', + $ffmpeg->open($output)->getFormat()->get('format_long_name')); + unlink($output); + } + + /** + * XStack filter is supported starting from 4.1 ffmpeg version. + * + * @covers \FFMpeg\Filters\AdvancedMedia\XStackFilter + * @covers \FFMpeg\Filters\AdvancedMedia\SineFilter + */ + public function testXStackFilter() + { + $xStack = new XStackFilter('', 0); + $ffmpeg = $this->getFFMpeg(); + $ffmpegVersion = $ffmpeg->getFFMpegDriver()->getVersion(); + if (version_compare($ffmpegVersion, $xStack->getMinimalFFMpegVersion(), '<')) { + $this->markTestSkipped('XStack filter is supported starting from ffmpeg version ' + . $xStack->getMinimalFFMpegVersion() . ', your version is ' + . $ffmpegVersion); + return; + } + + $inputs = array(realpath(__DIR__ . '/../files/Test.ogv')); + $format = new X264('aac', 'libx264'); + $output = __DIR__ . '/' . self::OUTPUT_PATH_PREFIX . 'xstack_test.mp4'; + + $advancedMedia = $ffmpeg->openAdvanced($inputs); + $advancedMedia->filters() + ->sine('[a]', 5) + ->testSrc('[v1]', TestSrcFilter::TESTSRC, '160x120', 5) + ->testSrc('[v2]', TestSrcFilter::TESTSRC, '160x120', 5) + ->testSrc('[v3]', TestSrcFilter::TESTSRC, '160x120', 5) + ->testSrc('[v4]', TestSrcFilter::TESTSRC, '160x120', 5) + ->xStack('[v1][v2][v3][v4]', + XStackFilter::LAYOUT_2X2, 4, '[v]'); + $advancedMedia + ->map(array('[a]', '[v]'), $format, $output) + ->save(); + + $this->assertFileExists($output); + $this->assertEquals('QuickTime / MOV', + $ffmpeg->open($output)->getFormat()->get('format_long_name')); + unlink($output); + } + + public function testOfCompatibilityWithExistedFilters() + { + $ffmpeg = $this->getFFMpeg(); + $inputs = array(realpath(__DIR__ . '/../files/Test.ogv')); + $watermark = realpath(__DIR__ . '/../files/watermark.png'); + $format = new X264('aac', 'libx264'); + $output = __DIR__ . '/' . self::OUTPUT_PATH_PREFIX . 'test_of_compatibility_with_existed_filters.mp4'; + + $advancedMedia = $ffmpeg->openAdvanced($inputs); + $advancedMedia->filters() + // For unknown reasons WatermarkFilter produce an error on Windows, + // because the path to the watermark becomes corrupted. + // This behaviour related with Alchemy\BinaryDriver\AbstractBinary::command(). + // The path inside filter becomes like + // "D:ServerswwwPHP-FFMpegtestsfileswatermark.png" (without slashes). + // But on Linux systems filter works as expected. + //->watermark('[0:v]', $watermark, '[v]') + ->pad('[0:v]', new Dimension(300, 100), '[v]'); + $advancedMedia + ->map(array('0:a', '[v]'), $format, $output) + ->save(); + + $this->assertFileExists($output); + $this->assertEquals('QuickTime / MOV', + $ffmpeg->open($output)->getFormat()->get('format_long_name')); + unlink($output); + } + + public function testForceDisableAudio() + { + $ffmpeg = $this->getFFMpeg(); + $format = new X264(); + + $advancedMedia1 = $ffmpeg->openAdvanced(array(__FILE__)); + $advancedMedia1 + ->map(array('test'), $format, 'outputFile.mp4', false); + $this->assertStringContainsString('acodec', $advancedMedia1->getFinalCommand()); + + $advancedMedia2 = $ffmpeg->openAdvanced(array(__FILE__)); + $advancedMedia2 + ->map(array('test'), $format, 'outputFile.mp4', true); + $this->assertStringNotContainsString('acodec', $advancedMedia2->getFinalCommand()); + } + + public function testForceDisableVideo() + { + $ffmpeg = $this->getFFMpeg(); + $format = new X264(); + + $advancedMedia1 = $ffmpeg->openAdvanced(array(__FILE__)); + $advancedMedia1->map(array('test'), $format, + 'outputFile.mp4', false, false); + $this->assertStringContainsString('vcodec', $advancedMedia1->getFinalCommand()); + + $advancedMedia2 = $ffmpeg->openAdvanced(array(__FILE__)); + $advancedMedia2->map(array('test'), $format, + 'outputFile.mp4', false, true); + $this->assertStringNotContainsString('vcodec', $advancedMedia2->getFinalCommand()); + } + + public function testGlobalOptions() + { + $configuration = array( + 'ffmpeg.threads' => 3, + 'ffmpeg.filter_threads' => 13, + 'ffmpeg.filter_complex_threads' => 24, + ); + + $ffmpeg = $this->getFFMpeg($configuration); + $advancedMedia = $ffmpeg->openAdvanced(array(__FILE__)); + $command = $advancedMedia->getFinalCommand(); + + foreach ($configuration as $optionName => $optionValue) { + $optionName = str_replace('ffmpeg.', '', $optionName); + $this->assertStringContainsString('-' . $optionName . ' ' . $optionValue, $command); + } + } +} diff --git a/tests/Functional/FunctionalTestCase.php b/tests/Functional/FunctionalTestCase.php index 15fcc26..6a2656a 100644 --- a/tests/Functional/FunctionalTestCase.php +++ b/tests/Functional/FunctionalTestCase.php @@ -8,10 +8,12 @@ use Tests\FFMpeg\BaseTestCase; abstract class FunctionalTestCase extends BaseTestCase { /** + * @param array $configuration + * * @return FFMpeg */ - public function getFFMpeg() + public function getFFMpeg($configuration = array()) { - return FFMpeg::create(array('timeout' => 300)); + return FFMpeg::create(array_merge(array('timeout' => 300), $configuration)); } } diff --git a/tests/Unit/Media/AdvancedMediaTest.php b/tests/Unit/Media/AdvancedMediaTest.php new file mode 100644 index 0000000..3310e12 --- /dev/null +++ b/tests/Unit/Media/AdvancedMediaTest.php @@ -0,0 +1,35 @@ +getFFMpegDriverMock(); + $ffprobe = $this->getFFProbeMock(); + + $advancedMedia = new AdvancedMedia(array(__FILE__, __FILE__), $driver, $ffprobe); + $this->assertSame(array(__FILE__, __FILE__), $advancedMedia->getInputs()); + } + + public function testGetInputsCount() + { + $driver = $this->getFFMpegDriverMock(); + $ffprobe = $this->getFFProbeMock(); + + $advancedMedia = new AdvancedMedia(array(__FILE__, __FILE__), $driver, $ffprobe); + $this->assertEquals(2, $advancedMedia->getInputsCount()); + } + + public function testFiltersReturnFilters() + { + $driver = $this->getFFMpegDriverMock(); + $ffprobe = $this->getFFProbeMock(); + + $advancedMedia = new AdvancedMedia(array(__FILE__, __FILE__), $driver, $ffprobe); + $this->assertInstanceOf('FFMpeg\Filters\AdvancedMedia\ComplexFilters', $advancedMedia->filters()); + } +}