From f20ad8a82e9cdeb503299cb1b6ab5bd968e29f67 Mon Sep 17 00:00:00 2001 From: CaliforniaMountainSnake Date: Tue, 18 Feb 2020 13:49:32 +0300 Subject: [PATCH] Added the ComplexMedia type and the possibility to use -filter_complex and -map features. Added some built-in complex filters. Added the unit and functional tests of the new features. README.md has been updated. --- README.md | 76 ++++ src/FFMpeg/FFMpeg.php | 13 + .../Filters/ComplexMedia/ANullSrcFilter.php | 61 +++ .../ComplexMedia/AbstractComplexFilter.php | 52 +++ .../ComplexMedia/ComplexCompatibleFilter.php | 18 + .../ComplexMedia/ComplexFilterContainer.php | 80 ++++ .../ComplexMedia/ComplexFilterInterface.php | 16 + .../Filters/ComplexMedia/ComplexFilters.php | 169 ++++++++ .../ComplexMedia/CustomComplexFilter.php | 33 ++ .../Filters/ComplexMedia/SineFilter.php | 77 ++++ .../Filters/ComplexMedia/TestSrcFilter.php | 211 ++++++++++ .../Filters/ComplexMedia/XStackFilter.php | 71 ++++ src/FFMpeg/Filters/Video/PadFilter.php | 27 +- src/FFMpeg/Filters/Video/WatermarkFilter.php | 29 +- src/FFMpeg/Media/ComplexMedia.php | 381 ++++++++++++++++++ tests/Functional/ComplexMediaTest.php | 241 +++++++++++ tests/Unit/Media/ComplexMediaTest.php | 35 ++ 17 files changed, 1583 insertions(+), 7 deletions(-) create mode 100644 src/FFMpeg/Filters/ComplexMedia/ANullSrcFilter.php create mode 100644 src/FFMpeg/Filters/ComplexMedia/AbstractComplexFilter.php create mode 100644 src/FFMpeg/Filters/ComplexMedia/ComplexCompatibleFilter.php create mode 100644 src/FFMpeg/Filters/ComplexMedia/ComplexFilterContainer.php create mode 100644 src/FFMpeg/Filters/ComplexMedia/ComplexFilterInterface.php create mode 100644 src/FFMpeg/Filters/ComplexMedia/ComplexFilters.php create mode 100644 src/FFMpeg/Filters/ComplexMedia/CustomComplexFilter.php create mode 100644 src/FFMpeg/Filters/ComplexMedia/SineFilter.php create mode 100644 src/FFMpeg/Filters/ComplexMedia/TestSrcFilter.php create mode 100644 src/FFMpeg/Filters/ComplexMedia/XStackFilter.php create mode 100644 src/FFMpeg/Media/ComplexMedia.php create mode 100644 tests/Functional/ComplexMediaTest.php create mode 100644 tests/Unit/Media/ComplexMediaTest.php diff --git a/README.md b/README.md index 6d2beb8..ae908ec 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). +### ComplexMedia +ComplexMedia 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. +ComplexMedia already contains some built-in filters. + +#### Base usage +For example: + +```php +$complexMedia = $ffmpeg->openComplex(array ('video_1.mp4', 'video_2.mp4')); +$complexMedia->filters() + ->custom('[0:v][1:v]', 'hstack', '[v]'); +$complexMedia + ->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 ComplexMedia. 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', +); + +$complexMedia = $ffmpeg->openComplex($inputs); +$complexMedia->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]'); +$complexMedia + ->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 map! +You do not have to use `-filter_complex`. For example, just extract the audio from the video: + +```php +$complexMedia = $ffmpeg->openComplex(array ('video.mp4')); +$complexMedia + ->map(array('0:a'), new Mp3(), 'output.mp3') + ->save(); +``` + +#### Customisation +If you need you can extra customize the result ffmpeg command of the ComplexMedia: + +```php +$complexMedia = $ffmpeg->openComplex($inputs); +$complexMedia + ->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/FFMpeg.php b/src/FFMpeg/FFMpeg.php index 91ce361..cbf59f0 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\ComplexMedia; use FFMpeg\Media\Video; use Psr\Log\LoggerInterface; @@ -102,6 +103,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 ComplexMedia + */ + public function openComplex($inputs) + { + return new ComplexMedia($inputs, $this->driver, $this->ffprobe); + } + /** * Creates a new FFMpeg instance. * diff --git a/src/FFMpeg/Filters/ComplexMedia/ANullSrcFilter.php b/src/FFMpeg/Filters/ComplexMedia/ANullSrcFilter.php new file mode 100644 index 0000000..f995bc0 --- /dev/null +++ b/src/FFMpeg/Filters/ComplexMedia/ANullSrcFilter.php @@ -0,0 +1,61 @@ +channelLayout = $channelLayout; + $this->sampleRate = $sampleRate; + $this->nbSamples = $nbSamples; + } + + /** + * {@inheritdoc} + */ + public function applyComplex(ComplexMedia $media) + { + return array( + '-filter_complex', + 'anullsrc' . $this->buildFilterOptions(array( + 'channel_layout' => $this->channelLayout, + 'sample_rate' => $this->sampleRate, + 'nb_samples' => $this->nbSamples, + )) + ); + } +} diff --git a/src/FFMpeg/Filters/ComplexMedia/AbstractComplexFilter.php b/src/FFMpeg/Filters/ComplexMedia/AbstractComplexFilter.php new file mode 100644 index 0000000..152d672 --- /dev/null +++ b/src/FFMpeg/Filters/ComplexMedia/AbstractComplexFilter.php @@ -0,0 +1,52 @@ +priority = $priority; + } + + /** + * {@inheritdoc} + */ + public function getPriority() + { + return $this->priority; + } + + /** + * 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/ComplexMedia/ComplexCompatibleFilter.php b/src/FFMpeg/Filters/ComplexMedia/ComplexCompatibleFilter.php new file mode 100644 index 0000000..38a2bb4 --- /dev/null +++ b/src/FFMpeg/Filters/ComplexMedia/ComplexCompatibleFilter.php @@ -0,0 +1,18 @@ +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; + } + + /** + * {@inheritdoc} + */ + public function applyComplex(ComplexMedia $media) + { + return $this->baseFilter->applyComplex($media); + } +} diff --git a/src/FFMpeg/Filters/ComplexMedia/ComplexFilterInterface.php b/src/FFMpeg/Filters/ComplexMedia/ComplexFilterInterface.php new file mode 100644 index 0000000..6276f90 --- /dev/null +++ b/src/FFMpeg/Filters/ComplexMedia/ComplexFilterInterface.php @@ -0,0 +1,16 @@ +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/ComplexMedia/CustomComplexFilter.php b/src/FFMpeg/Filters/ComplexMedia/CustomComplexFilter.php new file mode 100644 index 0000000..89fe626 --- /dev/null +++ b/src/FFMpeg/Filters/ComplexMedia/CustomComplexFilter.php @@ -0,0 +1,33 @@ +filter = $filter; + } + + /** + * {@inheritdoc} + */ + public function applyComplex(ComplexMedia $media) + { + return array('-filter_complex', $this->filter); + } +} diff --git a/src/FFMpeg/Filters/ComplexMedia/SineFilter.php b/src/FFMpeg/Filters/ComplexMedia/SineFilter.php new file mode 100644 index 0000000..4726522 --- /dev/null +++ b/src/FFMpeg/Filters/ComplexMedia/SineFilter.php @@ -0,0 +1,77 @@ +duration = $duration; + $this->frequency = $frequency; + $this->beep_factor = $beep_factor; + $this->sample_rate = $sample_rate; + $this->samples_per_frame = $samples_per_frame; + } + + /** + * Apply the complex filter to the given media. + * + * @param ComplexMedia $media + * + * @return string[] An array of arguments. + */ + public function applyComplex(ComplexMedia $media) + { + return array( + '-filter_complex', + 'sine' . $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/ComplexMedia/TestSrcFilter.php b/src/FFMpeg/Filters/ComplexMedia/TestSrcFilter.php new file mode 100644 index 0000000..88348e3 --- /dev/null +++ b/src/FFMpeg/Filters/ComplexMedia/TestSrcFilter.php @@ -0,0 +1,211 @@ +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; + } + + /** + * {@inheritdoc} + */ + public function applyComplex(ComplexMedia $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/ComplexMedia/XStackFilter.php b/src/FFMpeg/Filters/ComplexMedia/XStackFilter.php new file mode 100644 index 0000000..a17b285 --- /dev/null +++ b/src/FFMpeg/Filters/ComplexMedia/XStackFilter.php @@ -0,0 +1,71 @@ +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; + } + + /** + * {@inheritdoc} + */ + public function applyComplex(ComplexMedia $media) + { + return array( + '-filter_complex', + 'xstack' . $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..501c412 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\ComplexMedia\ComplexCompatibleFilter; use FFMpeg\Format\VideoInterface; +use FFMpeg\Media\ComplexMedia; +use FFMpeg\Media\Video; -class PadFilter implements VideoFilterInterface +class PadFilter implements VideoFilterInterface, ComplexCompatibleFilter { /** @var Dimension */ private $dimension; @@ -48,11 +50,30 @@ class PadFilter implements VideoFilterInterface * {@inheritdoc} */ public function apply(Video $video, VideoInterface $format) + { + return $this->getCommands(); + } + + /** + * {@inheritdoc} + */ + public function applyComplex(ComplexMedia $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..45e3532 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\ComplexMedia\ComplexCompatibleFilter; use FFMpeg\Format\VideoInterface; +use FFMpeg\Media\ComplexMedia; use FFMpeg\Media\Video; -class WatermarkFilter implements VideoFilterInterface +class WatermarkFilter implements VideoFilterInterface, ComplexCompatibleFilter { /** @var string */ private $watermarkPath; @@ -47,6 +49,22 @@ class WatermarkFilter implements VideoFilterInterface * {@inheritdoc} */ public function apply(Video $video, VideoInterface $format) + { + return $this->getCommands(); + } + + /** + * {@inheritdoc} + */ + public function applyComplex(ComplexMedia $media) + { + return $this->getCommands(); + } + + /** + * @return array + */ + protected function getCommands() { $position = isset($this->coordinates['position']) ? $this->coordinates['position'] : 'absolute'; @@ -55,7 +73,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 +81,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 +93,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/ComplexMedia.php b/src/FFMpeg/Media/ComplexMedia.php new file mode 100644 index 0000000..bf1db1d --- /dev/null +++ b/src/FFMpeg/Media/ComplexMedia.php @@ -0,0 +1,381 @@ + 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); + } + + /** + * @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 ComplexMedia you can set filters collection' + . ' contains only objects that implement ComplexFilterInterface!'); + } + } + + return parent::setFiltersCollection($filters); + } + + /** + * @return string[] + * @return void + */ + public function getInitialParameters() + { + return $this->initialParameters; + } + + /** + * @param string[] $initialParameters + * + * @return ComplexMedia + */ + public function setInitialParameters(array $initialParameters) + { + $this->initialParameters = $initialParameters; + return $this; + } + + /** + * @return string[] + * @return void + */ + public function getAdditionalParameters() + { + return $this->additionalParameters; + } + + /** + * @param string[] $additionalParameters + * + * @return ComplexMedia + */ + 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()); + } + + /** + * @param string[] $outs Output labels of the -filter_complex part. + * @param FormatInterface $format + * @param string $outputPathfile + * @param bool $forceDisableAudio + * @param bool $forceDisableVideo + * + * @return $this + */ + public function map( + array $outs, + FormatInterface $format, + $outputPathfile, + $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[] = $outputPathfile; + + // Create 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 ComplexMedia + */ + public function save() + { + $command = $this->buildCommand(); + + try { + $this->driver->command($command, false, $this->listeners); + } catch (ExecutionFailureException $e) { + throw new RuntimeException('Encoding failed', $e->getCode(), $e); + } + + return $this; + } + + /** + * @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 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 array + */ + protected function buildCommand() + { + $commands = array('-y'); + if ($this->driver->getConfiguration()->has('ffmpeg.threads')) { + $commands[] = '-threads'; + $commands[] = $this->driver->getConfiguration()->get('ffmpeg.threads'); + } + + return array_merge($commands, + $this->getInitialParameters(), + $this->buildInputsPart($this->inputs), + $this->buildComplexFilterPart($this->filters), + $this->mapCommands, + $this->getAdditionalParameters() + ); + } + + /** + * 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/Functional/ComplexMediaTest.php b/tests/Functional/ComplexMediaTest.php new file mode 100644 index 0000000..78cf080 --- /dev/null +++ b/tests/Functional/ComplexMediaTest.php @@ -0,0 +1,241 @@ +getFFMpeg(); + $inputs = array(realpath(__DIR__ . '/../files/Test.ogv')); + $format = new Mp3(); + $output = __DIR__ . '/output/' . self::PATH_PREFIX . 'extracted_with_map.mp3'; + + // You can run it without -filter_complex, just using -map. + $complexMedia = $ffmpeg->openComplex($inputs); + $complexMedia + ->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__ . '/output/' . self::PATH_PREFIX . 'audio_test.mp3'; + + $complexMedia = $ffmpeg->openComplex($inputs); + $complexMedia + ->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__ . '/output/' . self::PATH_PREFIX . 'multiple_inputs_test.mp4'; + + $complexMedia = $ffmpeg->openComplex($inputs); + $complexMedia->filters() + ->custom('[0:v][1:v]', 'hstack', '[v]'); + $complexMedia + ->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\ComplexMedia::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__ . '/output/' . self::PATH_PREFIX . 'test_multiple_outputs.mp3'; + $outputVideo1 = __DIR__ . '/output/' . self::PATH_PREFIX . 'test_multiple_outputs_v1.mp4'; + $outputVideo2 = __DIR__ . '/output/' . self::PATH_PREFIX . 'test_multiple_outputs_v2.mp4'; + + $complexMedia = $ffmpeg->openComplex($inputs); + $complexMedia->filters() + ->sine('[a]', 5) + ->testSrc('[v1]', TestSrcFilter::TESTSRC, '160x120', 5) + ->testSrc('[v2]', TestSrcFilter::TESTSRC, '160x120', 5) + ->custom('[v1]', 'negate', '[v1negate]') + ->custom('[v2]', 'edgedetect', '[v2edgedetect]'); + $complexMedia + ->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\ComplexMedia\TestSrcFilter + * @covers \FFMpeg\Filters\ComplexMedia\SineFilter + */ + public function testTestSrcFilterTestSineFilter() + { + $ffmpeg = $this->getFFMpeg(); + $inputs = array(realpath(__DIR__ . '/../files/Test.ogv')); + $format = new X264('aac', 'libx264'); + $output = __DIR__ . '/output/' . self::PATH_PREFIX . 'testsrc.mp4'; + + $complexMedia = $ffmpeg->openComplex($inputs); + $complexMedia->filters() + ->sine('[a]', 10) + ->testSrc('[v]', TestSrcFilter::TESTSRC, '160x120', 10); + $complexMedia + ->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\ComplexMedia\XStackFilter + * @covers \FFMpeg\Filters\ComplexMedia\SineFilter + */ + public function testXStackFilter() + { + $ffmpeg = $this->getFFMpeg(); + $inputs = array(realpath(__DIR__ . '/../files/Test.ogv')); + $format = new X264('aac', 'libx264'); + $output = __DIR__ . '/output/' . self::PATH_PREFIX . 'xstack_test.mp4'; + + $complexMedia = $ffmpeg->openComplex($inputs); + $complexMedia->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]'); + $complexMedia + ->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__ . '/output/' . self::PATH_PREFIX . 'test_of_compatibility_with_existed_filters.mp4'; + + $complexMedia = $ffmpeg->openComplex($inputs); + $complexMedia->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]'); + $complexMedia + ->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(); + + $complexMedia1 = $ffmpeg->openComplex(array(__FILE__)); + $complexMedia1 + ->map(array('test'), $format, 'outputFile.mp4', false); + $this->assertContains('acodec', $complexMedia1->getFinalCommand()); + + $complexMedia2 = $ffmpeg->openComplex(array(__FILE__)); + $complexMedia2 + ->map(array('test'), $format, 'outputFile.mp4', true); + $this->assertNotContains('acodec', $complexMedia2->getFinalCommand()); + } + + public function testForceDisableVideo() + { + $ffmpeg = $this->getFFMpeg(); + $format = new X264(); + + $complexMedia1 = $ffmpeg->openComplex(array(__FILE__)); + $complexMedia1->map(array('test'), $format, + 'outputFile.mp4', false, false); + $this->assertContains('vcodec', $complexMedia1->getFinalCommand()); + + $complexMedia2 = $ffmpeg->openComplex(array(__FILE__)); + $complexMedia2->map(array('test'), $format, + 'outputFile.mp4', false, true); + $this->assertNotContains('vcodec', $complexMedia2->getFinalCommand()); + } +} diff --git a/tests/Unit/Media/ComplexMediaTest.php b/tests/Unit/Media/ComplexMediaTest.php new file mode 100644 index 0000000..b359a50 --- /dev/null +++ b/tests/Unit/Media/ComplexMediaTest.php @@ -0,0 +1,35 @@ +getFFMpegDriverMock(); + $ffprobe = $this->getFFProbeMock(); + + $complexMedia = new ComplexMedia(array(__FILE__, __FILE__), $driver, $ffprobe); + $this->assertSame(array(__FILE__, __FILE__), $complexMedia->getInputs()); + } + + public function testGetInputsCount() + { + $driver = $this->getFFMpegDriverMock(); + $ffprobe = $this->getFFProbeMock(); + + $complexMedia = new ComplexMedia(array(__FILE__, __FILE__), $driver, $ffprobe); + $this->assertEquals(2, $complexMedia->getInputsCount()); + } + + public function testFiltersReturnFilters() + { + $driver = $this->getFFMpegDriverMock(); + $ffprobe = $this->getFFProbeMock(); + + $complexMedia = new ComplexMedia(array(__FILE__, __FILE__), $driver, $ffprobe); + $this->assertInstanceOf('FFMpeg\Filters\ComplexMedia\ComplexFilters', $complexMedia->filters()); + } +}