From 21c28dea250c1a9f0b48bf018c6a3490bf3dbcde Mon Sep 17 00:00:00 2001 From: Romain Biard Date: Tue, 14 Feb 2017 13:55:07 -0300 Subject: [PATCH] Concatenation feature (#287) * Creation of a feature to concatenate files into a new one. * Update of the README * Creation of the tests for the concatenation * We use an array of videos instead of a path to a text files * We use the bundle Temporary File System instead of getcwd --- README.md | 58 +++- .../Filters/Concat/ConcatFilterInterface.php | 20 ++ src/FFMpeg/Filters/Concat/ConcatFilters.php | 24 ++ src/FFMpeg/Media/Concat.php | 259 ++++++++++++++++++ src/FFMpeg/Media/Video.php | 11 + tests/Unit/Media/ConcatTest.php | 147 ++++++++++ tests/Unit/TestCase.php | 20 ++ tests/files/concat-list.txt | 3 + 8 files changed, 541 insertions(+), 1 deletion(-) create mode 100644 src/FFMpeg/Filters/Concat/ConcatFilterInterface.php create mode 100644 src/FFMpeg/Filters/Concat/ConcatFilters.php create mode 100644 src/FFMpeg/Media/Concat.php create mode 100644 tests/Unit/Media/ConcatTest.php create mode 100644 tests/files/concat-list.txt diff --git a/README.md b/README.md index 1296d3e..0cfabb2 100644 --- a/README.md +++ b/README.md @@ -405,7 +405,7 @@ accurate images ; it takes more time to execute. #### Gif -A gif is an animated image extracted from a sequence of the video ;. +A gif is an animated image extracted from a sequence of the video. You can save gif files using the `FFMpeg\Media\Gif::save` method. @@ -419,6 +419,62 @@ $video This method has a third optional boolean parameter, which is the duration of the animation. If you don't set it, you will get a fixed gif image. +#### Concatenation + +This feature allows you to generate one audio or video file, based on multiple sources. + +There are two ways to concatenate videos, depending on the codecs of the sources. +If your sources have all been encoded with the same codec, you will want to use the `FFMpeg\Media\Concatenate::saveFromSameCodecs` which has way better performances. +If your sources have been encoded with different codecs, you will want to use the `FFMpeg\Media\Concatenate::saveFromDifferentCodecs`. + +The first function will use the initial codec as the one for the generated file. +With the second function, you will be able to choose which codec you want for the generated file. + +You also need to pay attention to the fact that, when using the saveFromDifferentCodecs method, +your files MUST have video and audio streams. + +In both cases, you will have to provide a list of files in a TXT file. +The TXT file will one path per line. Here is an example: + +`txt +file './concat-1.mp4' +file 'concat-2.mp4' +#file 'concat-3.mp4' +` + +In this example, the third file will be ignored. +Please refer to the [documentation](https://trac.ffmpeg.org/wiki/Concatenate) for more details. + +To concatenate videos encoded with the same codec, do as follow: + +```php +// In order to instantiate the video object, you HAVE TO pass a path to a valid video file. +// We recommand that you put there the path of any of the video you want to use in this concatenation. +$video = $ffmpeg->open( '/path/to/video' ); +$video + ->concat('/path/to/list.txt') + ->saveFromSameCodecs('/path/to/new_file', TRUE); +``` + +The boolean parameter of the save function allows you to use the copy parameter which accelerates drastically the generation of the encoded file. + +To concatenate videos encoded with the same codec, do as follow: + +```php +// In order to instantiate the video object, you HAVE TO pass a path to a valid video file. +// We recommand that you put there the path of any of the video you want to use in this concatenation. +$video = $ffmpeg->open( '/path/to/video' ); + +$format = new FFMpeg\Format\Video\X264(); +$format->setAudioCodec("libmp3lame"); + +$video + ->concat('/path/to/list.txt') + ->saveFromDifferentCodecs($format, '/path/to/new_file'); +``` + +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). + #### Formats A format implements `FFMpeg\Format\FormatInterface`. To save to a video file, diff --git a/src/FFMpeg/Filters/Concat/ConcatFilterInterface.php b/src/FFMpeg/Filters/Concat/ConcatFilterInterface.php new file mode 100644 index 0000000..33eea50 --- /dev/null +++ b/src/FFMpeg/Filters/Concat/ConcatFilterInterface.php @@ -0,0 +1,20 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FFMpeg\Filters\Concat; + +use FFMpeg\Filters\FilterInterface; +use FFMpeg\Media\Concat; + +interface ConcatFilterInterface extends FilterInterface +{ + public function apply(Concat $concat); +} diff --git a/src/FFMpeg/Filters/Concat/ConcatFilters.php b/src/FFMpeg/Filters/Concat/ConcatFilters.php new file mode 100644 index 0000000..5fe4e3e --- /dev/null +++ b/src/FFMpeg/Filters/Concat/ConcatFilters.php @@ -0,0 +1,24 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FFMpeg\Filters\Concat; + +use FFMpeg\Media\Concat; + +class ConcatFilters +{ + private $concat; + + public function __construct(Concat $concat) + { + $this->concat = $concat; + } +} diff --git a/src/FFMpeg/Media/Concat.php b/src/FFMpeg/Media/Concat.php new file mode 100644 index 0000000..0977640 --- /dev/null +++ b/src/FFMpeg/Media/Concat.php @@ -0,0 +1,259 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FFMpeg\Media; + +use Alchemy\BinaryDriver\Exception\ExecutionFailureException; +use Alchemy\BinaryDriver\Exception\InvalidArgumentException; +use FFMpeg\Filters\Concat\ConcatFilterInterface; +use FFMpeg\Filters\Concat\ConcatFilters; +use FFMpeg\Driver\FFMpegDriver; +use FFMpeg\FFProbe; +use FFMpeg\Filters\Audio\SimpleFilter; +use FFMpeg\Exception\RuntimeException; +use FFMpeg\Format\FormatInterface; +use FFMpeg\Filters\FilterInterface; +use FFMpeg\Format\ProgressableInterface; +use FFMpeg\Format\AudioInterface; +use FFMpeg\Format\VideoInterface; +use Neutron\TemporaryFilesystem\Manager as FsManager; + +class Concat extends AbstractMediaType +{ + /** @var array */ + private $sources; + + public function __construct($sources, FFMpegDriver $driver, FFProbe $ffprobe) + { + parent::__construct($sources, $driver, $ffprobe); + $this->sources = $sources; + } + + /** + * Returns the path to the sources. + * + * @return string + */ + public function getSources() + { + return $this->sources; + } + + /** + * {@inheritdoc} + * + * @return ConcatFilters + */ + public function filters() + { + return new ConcatFilters($this); + } + + /** + * {@inheritdoc} + * + * @return Concat + */ + public function addFilter(ConcatFilterInterface $filter) + { + $this->filters->add($filter); + + return $this; + } + + /** + * Saves the concatenated video in the given array, considering that the sources videos are all encoded with the same codec. + * + * @param array $outputPathfile + * @param string $streamCopy + * + * @return Concat + * + * @throws RuntimeException + */ + public function saveFromSameCodecs($outputPathfile, $streamCopy = TRUE) + { + /** + * @see https://ffmpeg.org/ffmpeg-formats.html#concat + * @see https://trac.ffmpeg.org/wiki/Concatenate + */ + + // Create the file which will contain the list of videos + $fs = FsManager::create(); + $sourcesFile = $fs->createTemporaryFile('ffmpeg-concat'); + + // Set the content of this file + $fileStream = fopen($sourcesFile, 'w') or die("Cannot open file."); + $count_videos = 0; + if(is_array($this->sources) && (count($this->sources) > 0)) { + foreach ($this->sources as $videoPath) { + $line = ""; + + if($count_videos != 0) + $line .= "\n"; + + $line .= "file ".$videoPath; + + fwrite($fileStream, $line); + + $count_videos++; + } + } + else { + throw new InvalidArgumentException('The list of videos is not a valid array.'); + } + fclose($fileStream); + + + $commands = array( + '-f', 'concat', '-safe', '0', + '-i', $sourcesFile + ); + + // Check if stream copy is activated + if($streamCopy === TRUE) { + $commands[] = '-c'; + $commands[] = 'copy'; + } + + // If not, we can apply filters + else { + foreach ($this->filters as $filter) { + $commands = array_merge($commands, $filter->apply($this)); + } + } + + // Set the output file in the command + $commands = array_merge($commands, array($outputPathfile)); + + // Execute the command + try { + $this->driver->command($commands); + } catch (ExecutionFailureException $e) { + $this->cleanupTemporaryFile($outputPathfile); + $this->cleanupTemporaryFile($sourcesFile); + throw new RuntimeException('Unable to save concatenated video', $e->getCode(), $e); + } + + $this->cleanupTemporaryFile($sourcesFile); + return $this; + } + + /** + * Saves the concatenated video in the given filename, considering that the sources videos are all encoded with the same codec. + * + * @param string $outputPathfile + * + * @return Concat + * + * @throws RuntimeException + */ + public function saveFromDifferentCodecs(FormatInterface $format, $outputPathfile) + { + /** + * @see https://ffmpeg.org/ffmpeg-formats.html#concat + * @see https://trac.ffmpeg.org/wiki/Concatenate + */ + + // Check the validity of the parameter + if(!is_array($this->sources) || (count($this->sources) == 0)) { + throw new InvalidArgumentException('The list of videos is not a valid array.'); + } + + // Create the commands variable + $commands = array(); + + // Prepare the parameters + $nbSources = 0; + $files = array(); + + // For each source, check if this is a legit file + // and prepare the parameters + foreach ($this->sources as $videoPath) { + $files[] = '-i'; + $files[] = $videoPath; + $nbSources++; + } + + $commands = array_merge($commands, $files); + + // Set the parameters of the request + $commands[] = '-filter_complex'; + + $complex_filter = ''; + for($i=0; $i<$nbSources; $i++) { + $complex_filter .= '['.$i.':v:0] ['.$i.':a:0] '; + } + $complex_filter .= 'concat=n='.$nbSources.':v=1:a=1 [v] [a]'; + + $commands[] = $complex_filter; + $commands[] = '-map'; + $commands[] = '[v]'; + $commands[] = '-map'; + $commands[] = '[a]'; + + // Prepare the filters + $filters = clone $this->filters; + $filters->add(new SimpleFilter($format->getExtraParams(), 10)); + + if ($this->driver->getConfiguration()->has('ffmpeg.threads')) { + $filters->add(new SimpleFilter(array('-threads', $this->driver->getConfiguration()->get('ffmpeg.threads')))); + } + if ($format instanceof VideoInterface) { + if (null !== $format->getVideoCodec()) { + $filters->add(new SimpleFilter(array('-vcodec', $format->getVideoCodec()))); + } + } + if ($format instanceof AudioInterface) { + if (null !== $format->getAudioCodec()) { + $filters->add(new SimpleFilter(array('-acodec', $format->getAudioCodec()))); + } + } + + // Add the filters + foreach ($this->filters as $filter) { + $commands = array_merge($commands, $filter->apply($this)); + } + + if ($format instanceof AudioInterface) { + if (null !== $format->getAudioKiloBitrate()) { + $commands[] = '-b:a'; + $commands[] = $format->getAudioKiloBitrate() . 'k'; + } + if (null !== $format->getAudioChannels()) { + $commands[] = '-ac'; + $commands[] = $format->getAudioChannels(); + } + } + + // If the user passed some additional parameters + if ($format instanceof VideoInterface) { + if (null !== $format->getAdditionalParameters()) { + foreach ($format->getAdditionalParameters() as $additionalParameter) { + $commands[] = $additionalParameter; + } + } + } + + // Set the output file in the command + $commands[] = $outputPathfile; + + $failure = null; + + try { + $this->driver->command($commands); + } catch (ExecutionFailureException $e) { + throw new RuntimeException('Encoding failed', $e->getCode(), $e); + } + + return $this; + } +} diff --git a/src/FFMpeg/Media/Video.php b/src/FFMpeg/Media/Video.php index d43c9de..1a01953 100644 --- a/src/FFMpeg/Media/Video.php +++ b/src/FFMpeg/Media/Video.php @@ -204,4 +204,15 @@ class Video extends Audio { return new Gif($this, $this->driver, $this->ffprobe, $at, $dimension, $duration); } + + /** + * Concatenates a list of videos into one unique video. + * + * @param string $sources + * @return Concat + */ + public function concat($sources) + { + return new Concat($sources, $this->driver, $this->ffprobe); + } } diff --git a/tests/Unit/Media/ConcatTest.php b/tests/Unit/Media/ConcatTest.php new file mode 100644 index 0000000..d345961 --- /dev/null +++ b/tests/Unit/Media/ConcatTest.php @@ -0,0 +1,147 @@ +getFFMpegDriverMock(); + $ffprobe = $this->getFFProbeMock(); + + $concat = new Concat(array(__FILE__, __FILE__), $driver, $ffprobe); + $this->assertSame(array(__FILE__, __FILE__), $concat->getSources()); + } + + public function testFiltersReturnFilters() + { + $driver = $this->getFFMpegDriverMock(); + $ffprobe = $this->getFFProbeMock(); + + $concat = new Concat(array(__FILE__, __FILE__), $driver, $ffprobe); + $this->assertInstanceOf('FFMpeg\Filters\Concat\ConcatFilters', $concat->filters()); + } + + public function testAddFiltersAddsAFilter() + { + $driver = $this->getFFMpegDriverMock(); + $ffprobe = $this->getFFProbeMock(); + + $filters = $this->getMockBuilder('FFMpeg\Filters\FiltersCollection') + ->disableOriginalConstructor() + ->getMock(); + + $filter = $this->getMock('FFMpeg\Filters\Concat\ConcatFilterInterface'); + + $filters->expects($this->once()) + ->method('add') + ->with($filter); + + $concat = new Concat(array(__FILE__, __FILE__), $driver, $ffprobe); + $concat->setFiltersCollection($filters); + $concat->addFilter($filter); + } + + /** + * @dataProvider provideSaveFromSameCodecsOptions + */ + public function testSaveFromSameCodecs($streamCopy, $commands) + { + $driver = $this->getFFMpegDriverMock(); + $ffprobe = $this->getFFProbeMock(); + + $pathfile = '/target/destination'; + + array_push($commands, $pathfile); + + $driver->expects($this->exactly(1)) + ->method('command') + ->with($this->isType('array'), false, $this->anything()) + ->will($this->returnCallback(function ($commands, $errors, $listeners) {})); + + $concat = new Concat(array(__FILE__, 'concat-2.mp4'), $driver, $ffprobe); + $concat->saveFromSameCodecs($pathfile, $streamCopy); + + $this->assertEquals('-f', $commands[0]); + $this->assertEquals('concat', $commands[1]); + $this->assertEquals('-safe', $commands[2]); + $this->assertEquals('0', $commands[3]); + $this->assertEquals('-i', $commands[4]); + if(isset($commands[6]) && (strcmp($commands[6], "-c") == 0)) { + $this->assertEquals('-c', $commands[6]); + $this->assertEquals('copy', $commands[7]); + } + } + + public function provideSaveFromSameCodecsOptions() + { + $fs = FsManager::create(); + $tmpFile = $fs->createTemporaryFile('ffmpeg-concat'); + + return array( + array( + TRUE, + array( + '-f', 'concat', + '-safe', '0', + '-i', $tmpFile, + '-c', 'copy' + ), + ), + array( + FALSE, + array( + '-f', 'concat', + '-safe', '0', + '-i', $tmpFile + ) + ), + ); + } + + /** + * @dataProvider provideSaveFromDifferentCodecsOptions + */ + public function testSaveFromDifferentCodecs($commands) + { + $driver = $this->getFFMpegDriverMock(); + $ffprobe = $this->getFFProbeMock(); + $format = $this->getFormatInterfaceMock(); + + $pathfile = '/target/destination'; + + array_push($commands, $pathfile); + + $configuration = $this->getMock('Alchemy\BinaryDriver\ConfigurationInterface'); + + $driver->expects($this->any()) + ->method('getConfiguration') + ->will($this->returnValue($configuration)); + + $driver->expects($this->once()) + ->method('command') + ->with($commands); + + $concat = new Concat(array(__FILE__, 'concat-2.mp4'), $driver, $ffprobe); + $this->assertSame($concat, $concat->saveFromDifferentCodecs($format, $pathfile)); + } + + public function provideSaveFromDifferentCodecsOptions() + { + return array( + array( + array( + '-i', __FILE__, + '-i', 'concat-2.mp4', + '-filter_complex', + '[0:v:0] [0:a:0] [1:v:0] [1:a:0] concat=n=2:v=1:a=1 [v] [a]', + '-map', '[v]', + '-map', '[a]' + ), + ), + ); + } +} diff --git a/tests/Unit/TestCase.php b/tests/Unit/TestCase.php index 383c25c..d2912a7 100644 --- a/tests/Unit/TestCase.php +++ b/tests/Unit/TestCase.php @@ -148,4 +148,24 @@ class TestCase extends \PHPUnit_Framework_TestCase return $video; } + + public function getConcatMock() + { + return $this->getMockBuilder('FFMpeg\Media\Concat') + ->disableOriginalConstructor() + ->getMock(); + } + + public function getFormatInterfaceMock() + { + $FormatInterface = $this->getMockBuilder('FFMpeg\Format\FormatInterface') + ->disableOriginalConstructor() + ->getMock(); + + $FormatInterface->expects($this->any()) + ->method('getExtraParams') + ->will($this->returnValue(array())); + + return $FormatInterface; + } } diff --git a/tests/files/concat-list.txt b/tests/files/concat-list.txt new file mode 100644 index 0000000..fb96780 --- /dev/null +++ b/tests/files/concat-list.txt @@ -0,0 +1,3 @@ +file './concat-1.mp4' +file 'concat-2.mp4' +#file 'concat-3.mp4' \ No newline at end of file