From 0b871e59e7a809033fa27c3086588e72c662e9ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Nov=C3=BD?= Date: Fri, 6 Jul 2018 18:51:09 +0200 Subject: [PATCH] Clip using input seek (#543) Making video clip by input seeking --- README.md | 22 +++ src/FFMpeg/Media/AbstractVideo.php | 292 +++++++++++++++++++++++++++++ src/FFMpeg/Media/Clip.php | 60 ++++++ src/FFMpeg/Media/Video.php | 280 ++------------------------- tests/Unit/Media/ClipTest.php | 67 +++++++ 5 files changed, 454 insertions(+), 267 deletions(-) create mode 100644 src/FFMpeg/Media/AbstractVideo.php create mode 100644 src/FFMpeg/Media/Clip.php create mode 100644 tests/Unit/Media/ClipTest.php diff --git a/README.md b/README.md index edbee3f..c20d0a3 100644 --- a/README.md +++ b/README.md @@ -156,6 +156,28 @@ $video ->save(new FFMpeg\Format\Video\X264(), '/path/to/new/file'); ``` +##### Clip + +Cuts the video at a desired point. Use input seeking method. It is faster option than use filter clip. + +```php +$clip = $video->clip(FFMpeg\Coordinate\TimeCode::fromSeconds(30), FFMpeg\Coordinate\TimeCode::fromSeconds(15)); +$clip->save(new FFMpeg\Format\Video\X264(), 'video.avi'); +``` + +The clip filter takes two parameters: + +- `$start`, an instance of `FFMpeg\Coordinate\TimeCode`, specifies the start point of the clip +- `$duration`, optional, an instance of `FFMpeg\Coordinate\TimeCode`, specifies the duration of the clip + +On clip you can apply same filters as on video. For example resizing filter. + +```php +$clip = $video->clip(FFMpeg\Coordinate\TimeCode::fromSeconds(30), FFMpeg\Coordinate\TimeCode::fromSeconds(15)); +$clip->filters()->resize(new FFMpeg\Coordinate\Dimension(320, 240), FFMpeg\Filters\Video\ResizeFilter::RESIZEMODE_INSET, true); +$clip->save(new FFMpeg\Format\Video\X264(), 'video.avi'); +``` + ##### Generate a waveform You can generate a waveform of an audio file using the `FFMpeg\Media\Audio::waveform` diff --git a/src/FFMpeg/Media/AbstractVideo.php b/src/FFMpeg/Media/AbstractVideo.php new file mode 100644 index 0000000..2b11bd4 --- /dev/null +++ b/src/FFMpeg/Media/AbstractVideo.php @@ -0,0 +1,292 @@ + + * + * 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 FFMpeg\Filters\Audio\SimpleFilter; +use FFMpeg\Exception\InvalidArgumentException; +use FFMpeg\Exception\RuntimeException; +use FFMpeg\Filters\Video\VideoFilters; +use FFMpeg\Filters\FilterInterface; +use FFMpeg\Format\FormatInterface; +use FFMpeg\Format\ProgressableInterface; +use FFMpeg\Format\AudioInterface; +use FFMpeg\Format\VideoInterface; +use Neutron\TemporaryFilesystem\Manager as FsManager; +use FFMpeg\Filters\Video\ClipFilter; + +abstract class AbstractVideo extends Audio +{ + + /** + * FileSystem Manager instance + * @var Manager + */ + protected $fs; + + /** + * FileSystem Manager ID + * @var int + */ + protected $fsId; + + /** + * @inheritDoc + * @return VideoFilters + */ + public function filters() + { + return new VideoFilters($this); + } + + /** + * @inheritDoc + * @return Video + */ + public function addFilter(FilterInterface $filter) + { + $this->filters->add($filter); + + return $this; + } + + /** + * Exports the video in the desired format, applies registered filters. + * + * @param FormatInterface $format + * @param string $outputPathfile + * @return Video + * @throws RuntimeException + */ + public function save(FormatInterface $format, $outputPathfile) + { + $passes = $this->buildCommand($format, $outputPathfile); + + $failure = null; + $totalPasses = $format->getPasses(); + + foreach ($passes as $pass => $passCommands) { + try { + /** add listeners here */ + $listeners = null; + + if ($format instanceof ProgressableInterface) { + $filters = clone $this->filters; + $duration = 0; + + // check the filters of the video, and if the video has the ClipFilter then + // take the new video duration and send to the + // FFMpeg\Format\ProgressListener\AbstractProgressListener class + foreach ($filters as $filter) { + if ($filter instanceof ClipFilter) { + $duration = $filter->getDuration()->toSeconds(); + break; + } + } + $listeners = $format->createProgressListener($this, $this->ffprobe, $pass + 1, $totalPasses, $duration); + } + + $this->driver->command($passCommands, false, $listeners); + } catch (ExecutionFailureException $e) { + $failure = $e; + break; + } + } + + $this->fs->clean($this->fsId); + + if (null !== $failure) { + throw new RuntimeException('Encoding failed', $failure->getCode(), $failure); + } + + return $this; + } + + /** + * NOTE: This method is different to the Audio's one, because Video is using passes. + * @inheritDoc + */ + public function getFinalCommand(FormatInterface $format, $outputPathfile) + { + $finalCommands = array(); + + foreach ($this->buildCommand($format, $outputPathfile) as $pass => $passCommands) { + $finalCommands[] = implode(' ', $passCommands); + } + + $this->fs->clean($this->fsId); + + return $finalCommands; + } + + /** + * **NOTE:** This creates passes instead of a single command! + * + * @inheritDoc + * @return string[][] + */ + protected function buildCommand(FormatInterface $format, $outputPathfile) + { + $commands = $this->basePartOfCommand(); + + $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()))); + } + } + + foreach ($filters as $filter) { + $commands = array_merge($commands, $filter->apply($this, $format)); + } + + if ($format instanceof VideoInterface) { + $commands[] = '-b:v'; + $commands[] = $format->getKiloBitrate() . 'k'; + $commands[] = '-refs'; + $commands[] = '6'; + $commands[] = '-coder'; + $commands[] = '1'; + $commands[] = '-sc_threshold'; + $commands[] = '40'; + $commands[] = '-flags'; + $commands[] = '+loop'; + $commands[] = '-me_range'; + $commands[] = '16'; + $commands[] = '-subq'; + $commands[] = '7'; + $commands[] = '-i_qfactor'; + $commands[] = '0.71'; + $commands[] = '-qcomp'; + $commands[] = '0.6'; + $commands[] = '-qdiff'; + $commands[] = '4'; + $commands[] = '-trellis'; + $commands[] = '1'; + } + + 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; + } + } + } + + // Merge Filters into one command + $videoFilterVars = $videoFilterProcesses = array(); + for ($i = 0; $i < count($commands); $i++) { + $command = $commands[$i]; + if ($command == '-vf') { + $commandSplits = explode(";", $commands[$i + 1]); + if (count($commandSplits) == 1) { + $commandSplit = $commandSplits[0]; + $command = trim($commandSplit); + if (preg_match("/^\[in\](.*?)\[out\]$/is", $command, $match)) { + $videoFilterProcesses[] = $match[1]; + } else { + $videoFilterProcesses[] = $command; + } + } else { + foreach ($commandSplits as $commandSplit) { + $command = trim($commandSplit); + if (preg_match("/^\[[^\]]+\](.*?)\[[^\]]+\]$/is", $command, $match)) { + $videoFilterProcesses[] = $match[1]; + } else { + $videoFilterVars[] = $command; + } + } + } + unset($commands[$i]); + unset($commands[$i + 1]); + $i++; + } + } + $videoFilterCommands = $videoFilterVars; + $lastInput = 'in'; + foreach ($videoFilterProcesses as $i => $process) { + $command = '[' . $lastInput . ']'; + $command .= $process; + $lastInput = 'p' . $i; + if ($i === (count($videoFilterProcesses) - 1)) { + $command .= '[out]'; + } else { + $command .= '[' . $lastInput . ']'; + } + + $videoFilterCommands[] = $command; + } + $videoFilterCommand = implode(';', $videoFilterCommands); + + if ($videoFilterCommand) { + $commands[] = '-vf'; + $commands[] = $videoFilterCommand; + } + + $this->fs = FsManager::create(); + $this->fsId = uniqid('ffmpeg-passes'); + $passPrefix = $this->fs->createTemporaryDirectory(0777, 50, $this->fsId) . '/' . uniqid('pass-'); + $passes = array(); + $totalPasses = $format->getPasses(); + + if (!$totalPasses) { + throw new InvalidArgumentException('Pass number should be a positive value.'); + } + + for ($i = 1; $i <= $totalPasses; $i++) { + $pass = $commands; + + if ($totalPasses > 1) { + $pass[] = '-pass'; + $pass[] = $i; + $pass[] = '-passlogfile'; + $pass[] = $passPrefix; + } + + $pass[] = $outputPathfile; + + $passes[] = $pass; + } + + return $passes; + } + + /** + * Return base part of command. + * + * @return array + */ + protected function basePartOfCommand() + { + return array('-y', '-i', $this->pathfile); + } +} diff --git a/src/FFMpeg/Media/Clip.php b/src/FFMpeg/Media/Clip.php new file mode 100644 index 0000000..4435e56 --- /dev/null +++ b/src/FFMpeg/Media/Clip.php @@ -0,0 +1,60 @@ +start = $start; + $this->duration = $duration; + $this->video = $video; + + parent::__construct($video->getPathfile(), $driver, $ffprobe); + } + + /** + * Returns the video related to the frame. + * + * @return Video + */ + public function getVideo() + { + return $this->video; + } + + /** + * Return base part of command. + * + * @return array + */ + protected function basePartOfCommand() + { + $arr = array('-y', '-ss', (string) $this->start, '-i', $this->pathfile); + + if (is_null($this->duration) === false) { + $arr[] = '-t'; + $arr[] = (string) $this->duration; + } + + return $arr; + } +} diff --git a/src/FFMpeg/Media/Video.php b/src/FFMpeg/Media/Video.php index dd3fca1..ef69a03 100644 --- a/src/FFMpeg/Media/Video.php +++ b/src/FFMpeg/Media/Video.php @@ -1,5 +1,4 @@ filters->add($filter); - - return $this; - } - - /** - * Exports the video in the desired format, applies registered filters. - * - * @param FormatInterface $format - * @param string $outputPathfile - * @return Video - * @throws RuntimeException - */ - public function save(FormatInterface $format, $outputPathfile) - { - $passes = $this->buildCommand($format, $outputPathfile); - - $failure = null; - $totalPasses = $format->getPasses(); - - foreach ($passes as $pass => $passCommands) { - try { - /** add listeners here */ - $listeners = null; - - if ($format instanceof ProgressableInterface) { - $filters = clone $this->filters; - $duration = 0; - - // check the filters of the video, and if the video has the ClipFilter then - // take the new video duration and send to the - // FFMpeg\Format\ProgressListener\AbstractProgressListener class - foreach ($filters as $filter) { - if($filter instanceof ClipFilter){ - $duration = $filter->getDuration()->toSeconds(); - break; - } - } - $listeners = $format->createProgressListener($this, $this->ffprobe, $pass + 1, $totalPasses, $duration); - } - - $this->driver->command($passCommands, false, $listeners); - } catch (ExecutionFailureException $e) { - $failure = $e; - break; - } - } - - $this->fs->clean($this->fsId); - - if (null !== $failure) { - throw new RuntimeException('Encoding failed', $failure->getCode(), $failure); - } - - return $this; - } - - /** - * NOTE: This method is different to the Audio's one, because Video is using passes. - * @inheritDoc - */ - public function getFinalCommand(FormatInterface $format, $outputPathfile) { - $finalCommands = array(); - - foreach($this->buildCommand($format, $outputPathfile) as $pass => $passCommands) { - $finalCommands[] = implode(' ', $passCommands); - } - - $this->fs->clean($this->fsId); - - return $finalCommands; - } - - /** - * **NOTE:** This creates passes instead of a single command! - * - * @inheritDoc - * @return string[][] - */ - protected function buildCommand(FormatInterface $format, $outputPathfile) { - $commands = array('-y', '-i', $this->pathfile); - - $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()))); - } - } - - foreach ($filters as $filter) { - $commands = array_merge($commands, $filter->apply($this, $format)); - } - - if ($format instanceof VideoInterface) { - $commands[] = '-b:v'; - $commands[] = $format->getKiloBitrate() . 'k'; - $commands[] = '-refs'; - $commands[] = '6'; - $commands[] = '-coder'; - $commands[] = '1'; - $commands[] = '-sc_threshold'; - $commands[] = '40'; - $commands[] = '-flags'; - $commands[] = '+loop'; - $commands[] = '-me_range'; - $commands[] = '16'; - $commands[] = '-subq'; - $commands[] = '7'; - $commands[] = '-i_qfactor'; - $commands[] = '0.71'; - $commands[] = '-qcomp'; - $commands[] = '0.6'; - $commands[] = '-qdiff'; - $commands[] = '4'; - $commands[] = '-trellis'; - $commands[] = '1'; - } - - 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; - } - } - } - - // Merge Filters into one command - $videoFilterVars = $videoFilterProcesses = array(); - for($i=0;$i $process) { - $command = '[' . $lastInput .']'; - $command .= $process; - $lastInput = 'p' . $i; - if($i === (count($videoFilterProcesses) - 1)) { - $command .= '[out]'; - } else { - $command .= '[' . $lastInput . ']'; - } - - $videoFilterCommands[] = $command; - } - $videoFilterCommand = implode(';', $videoFilterCommands); - - if($videoFilterCommand) { - $commands[] = '-vf'; - $commands[] = $videoFilterCommand; - } - - $this->fs = FsManager::create(); - $this->fsId = uniqid('ffmpeg-passes'); - $passPrefix = $this->fs->createTemporaryDirectory(0777, 50, $this->fsId) . '/' . uniqid('pass-'); - $passes = array(); - $totalPasses = $format->getPasses(); - - if(!$totalPasses) { - throw new InvalidArgumentException('Pass number should be a positive value.'); - } - - for($i = 1; $i <= $totalPasses; $i++) { - $pass = $commands; - - if ($totalPasses > 1) { - $pass[] = '-pass'; - $pass[] = $i; - $pass[] = '-passlogfile'; - $pass[] = $passPrefix; - } - - $pass[] = $outputPathfile; - - $passes[] = $pass; - } - - return $passes; - } /** * Gets the frame at timecode. @@ -315,4 +49,16 @@ class Video extends Audio { return new Concat($sources, $this->driver, $this->ffprobe); } + + /** + * Clips the video at the given time(s). + * + * @param TimeCode $start Start time + * @param TimeCode $duration Duration + * @return \FFMpeg\Media\Clip + */ + public function clip(TimeCode $start, TimeCode $duration = null) + { + return new Clip($this, $this->driver, $this->ffprobe, $start, $duration); + } } diff --git a/tests/Unit/Media/ClipTest.php b/tests/Unit/Media/ClipTest.php new file mode 100644 index 0000000..61e988c --- /dev/null +++ b/tests/Unit/Media/ClipTest.php @@ -0,0 +1,67 @@ +getConfigurationMock(); + + $driver = $this->getFFMpegDriverMock(); + $driver->expects($this->any()) + ->method('getConfiguration') + ->will($this->returnValue($configuration)); + + $ffprobe = $this->getFFProbeMock(); + + $start = $this->getTimeCodeMock(); + $start->expects($this->once()) + ->method('__toString') + ->will($this->returnValue($startValue)); + + $duration = null; + if (null !== $durationValue) { + $duration = $this->getTimeCodeMock(); + $duration->expects($this->once()) + ->method('__toString') + ->will($this->returnValue($durationValue)); + } + + $outputPathfile = '/target/file'; + + $format = $this->getMock('FFMpeg\Format\VideoInterface'); + $format->expects($this->any()) + ->method('getPasses') + ->will($this->returnValue(1)); + $format->expects($this->any()) + ->method('getExtraParams') + ->will($this->returnValue(array())); + + $clip = new Clip($this->getVideoMock(__FILE__), $driver, $ffprobe, $start, $duration); + $fc = $clip->getFinalCommand($format, $outputPathfile); + + $this->assertCount(1, $fc); + $this->assertStringStartsWith(implode(' ', $commands), $fc[0]); + } + + public function provideBuildOptions() + { + return array( + array('SS01', null, array( + '-y', '-ss', 'SS01', + '-i', __FILE__) + ), + array('SS02', 'D02', array( + '-y', '-ss', 'SS02', + '-i', __FILE__, + '-t', 'D02') + ) + ); + } +}