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
This commit is contained in:
Romain Biard 2017-02-14 13:55:07 -03:00 committed by GitHub
commit 21c28dea25
8 changed files with 541 additions and 1 deletions

View file

@ -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,

View file

@ -0,0 +1,20 @@
<?php
/*
* This file is part of PHP-FFmpeg.
*
* (c) Strime <contact@strime.io>
*
* 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);
}

View file

@ -0,0 +1,24 @@
<?php
/*
* This file is part of PHP-FFmpeg.
*
* (c) Strime <contact@strime.io>
*
* 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;
}
}

259
src/FFMpeg/Media/Concat.php Normal file
View file

@ -0,0 +1,259 @@
<?php
/*
* This file is part of PHP-FFmpeg.
*
* (c) Strime <contact@strime.io>
*
* 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;
}
}

View file

@ -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);
}
}

View file

@ -0,0 +1,147 @@
<?php
namespace Tests\FFMpeg\Unit\Media;
use FFMpeg\Media\Concat;
use Neutron\TemporaryFilesystem\Manager as FsManager;
class ConcatTest extends AbstractMediaTestCase
{
public function testGetSources()
{
$driver = $this->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]'
),
),
);
}
}

View file

@ -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;
}
}

View file

@ -0,0 +1,3 @@
file './concat-1.mp4'
file 'concat-2.mp4'
#file 'concat-3.mp4'