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:
parent
a6f6bbcb1e
commit
21c28dea25
8 changed files with 541 additions and 1 deletions
58
README.md
58
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,
|
||||
|
|
|
|||
20
src/FFMpeg/Filters/Concat/ConcatFilterInterface.php
Normal file
20
src/FFMpeg/Filters/Concat/ConcatFilterInterface.php
Normal 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);
|
||||
}
|
||||
24
src/FFMpeg/Filters/Concat/ConcatFilters.php
Normal file
24
src/FFMpeg/Filters/Concat/ConcatFilters.php
Normal 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
259
src/FFMpeg/Media/Concat.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
147
tests/Unit/Media/ConcatTest.php
Normal file
147
tests/Unit/Media/ConcatTest.php
Normal 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]'
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
3
tests/files/concat-list.txt
Normal file
3
tests/files/concat-list.txt
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
file './concat-1.mp4'
|
||||
file 'concat-2.mp4'
|
||||
#file 'concat-3.mp4'
|
||||
Loading…
Add table
Add a link
Reference in a new issue