diff --git a/CHANGELOG.md b/CHANGELOG.md index 8bba6c4..754f741 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,11 @@ CHANGELOG * 0.4.0 (xx-xx-xxxx) +* 0.3.3 (xx-xx-2013) + + * Add convenient Stream::getDimensions method to extract video dimension. + * Add DisplayRatioFixer Frame filter. + * 0.3.2 (08-08-2013) * Fix A/V synchronization over flash and HTML5 players. diff --git a/src/FFMpeg/Exception/LogicException.php b/src/FFMpeg/Exception/LogicException.php new file mode 100644 index 0000000..b73b567 --- /dev/null +++ b/src/FFMpeg/Exception/LogicException.php @@ -0,0 +1,16 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FFMpeg\Exception; + +class LogicException extends \LogicException implements ExceptionInterface +{ +} diff --git a/src/FFMpeg/FFProbe/DataMapping/Stream.php b/src/FFMpeg/FFProbe/DataMapping/Stream.php index 9b0d170..e74503e 100644 --- a/src/FFMpeg/FFProbe/DataMapping/Stream.php +++ b/src/FFMpeg/FFProbe/DataMapping/Stream.php @@ -11,6 +11,10 @@ namespace FFMpeg\FFProbe\DataMapping; +use FFMpeg\Exception\LogicException; +use FFMpeg\Exception\RuntimeException; +use FFMpeg\Coordinate\Dimension; + class Stream extends AbstractData { /** @@ -32,4 +36,63 @@ class Stream extends AbstractData { return $this->has('codec_type') ? 'video' === $this->get('codec_type') : false; } + + /** + * Returns the dimension of the video stream. + * + * @return Dimension + * + * @throws LogicException In case the stream is not a video stream. + * @throws RuntimeException In case the dimensions can not be extracted. + */ + public function getDimensions() + { + if (!$this->isVideo()) { + throw new LogicException('Dimensions can only be retrieved from video streams.'); + } + + $width = $height = $sampleRatio = $displayRatio = null; + + if ($this->has('width')) { + $width = $this->get('width'); + } + if ($this->has('height')) { + $height = $this->get('height'); + } + if (null !== $ratio = $this->extractRatio($this, 'sample_aspect_ratio')) { + $sampleRatio = $ratio; + } + if (null !== $ratio = $this->extractRatio($this, 'display_aspect_ratio')) { + $displayRatio = $ratio; + } + + if (null === $height || null === $width) { + throw new RuntimeException('Unable to extract dimensions.'); + } + + if (null !== $displayRatio && null !== $sampleRatio) { + $width = round($width / $sampleRatio[0] * $sampleRatio[1] * $displayRatio[0] / $displayRatio[1]); + } + + return new Dimension($width, $height); + } + + /** + * Extracts a ratio from a string in a \d+:\d+ format given a key name. + * + * @param Stream $stream The stream where to look for the ratio. + * @param string $name the name of the key. + * @return null|array An array containing the width and the height, null if not found. + */ + private function extractRatio(Stream $stream, $name) + { + if ($stream->has($name)) { + $ratio = $stream->get($name); + if (preg_match('/\d+:\d+/', $ratio)) { + return array_map(function ($int) { return (int) $int; }, explode(':', $ratio)); + } + } + + return null; + } } diff --git a/src/FFMpeg/Filters/FiltersCollection.php b/src/FFMpeg/Filters/FiltersCollection.php index d44a880..c91241a 100644 --- a/src/FFMpeg/Filters/FiltersCollection.php +++ b/src/FFMpeg/Filters/FiltersCollection.php @@ -47,8 +47,12 @@ class FiltersCollection implements \Countable, \IteratorAggregate public function getIterator() { if (null === $this->sorted) { - krsort($this->filters); - $this->sorted = call_user_func_array('array_merge', $this->filters); + if (0 === count($this->filters)) { + $this->sorted = $this->filters; + } else { + krsort($this->filters); + $this->sorted = call_user_func_array('array_merge', $this->filters); + } } return new \ArrayIterator($this->sorted); diff --git a/src/FFMpeg/Filters/Frame/DisplayRatioFixerFilter.php b/src/FFMpeg/Filters/Frame/DisplayRatioFixerFilter.php new file mode 100644 index 0000000..0cc3cea --- /dev/null +++ b/src/FFMpeg/Filters/Frame/DisplayRatioFixerFilter.php @@ -0,0 +1,58 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FFMpeg\Filters\Frame; + +use FFMpeg\Exception\RuntimeException; +use FFMpeg\Media\Frame; + +class DisplayRatioFixerFilter implements FrameFilterInterface +{ + /** @var integer */ + private $priority; + + public function __construct($priority = 0) + { + $this->priority = $priority; + } + + /** + * {@inheritdoc} + */ + public function getPriority() + { + return $this->priority; + } + + /** + * {@inheritdoc} + */ + public function apply(Frame $frame) + { + $dimensions = null; + $commands = array(); + + foreach ($frame->getVideo()->getStreams() as $stream) { + if ($stream->isVideo()) { + try { + $dimensions = $stream->getDimensions(); + $commands[] = '-s'; + $commands[] = $dimensions->getWidth() . 'x' . $dimensions->getHeight(); + break; + } catch (RuntimeException $e) { + + } + } + } + + return $commands; + } +} diff --git a/src/FFMpeg/Filters/Frame/FrameFilterInterface.php b/src/FFMpeg/Filters/Frame/FrameFilterInterface.php index db4fdac..d9df0e4 100644 --- a/src/FFMpeg/Filters/Frame/FrameFilterInterface.php +++ b/src/FFMpeg/Filters/Frame/FrameFilterInterface.php @@ -13,9 +13,8 @@ namespace FFMpeg\Filters\Frame; use FFMpeg\Filters\FilterInterface; use FFMpeg\Media\Frame; -use FFMpeg\Format\FrameInterface; interface FrameFilterInterface extends FilterInterface { - public function apply(Frame $frame, FrameInterface $format); + public function apply(Frame $frame); } diff --git a/src/FFMpeg/Filters/Frame/FrameFilters.php b/src/FFMpeg/Filters/Frame/FrameFilters.php index 85bfde9..255da0c 100644 --- a/src/FFMpeg/Filters/Frame/FrameFilters.php +++ b/src/FFMpeg/Filters/Frame/FrameFilters.php @@ -21,4 +21,19 @@ class FrameFilters { $this->frame = $frame; } + + /** + * Fixes the display ratio of the output frame. + * + * In case the sample ratio and display ratio are different, image may be + * anamorphozed. This filter fixes this by specifying the output size. + * + * @return FrameFilters + */ + public function fixDisplayRatio() + { + $this->frame->addFilter(new DisplayRatioFixerFilter()); + + return $this; + } } diff --git a/src/FFMpeg/Filters/Video/ResizeFilter.php b/src/FFMpeg/Filters/Video/ResizeFilter.php index a3e407d..c5f96cb 100644 --- a/src/FFMpeg/Filters/Video/ResizeFilter.php +++ b/src/FFMpeg/Filters/Video/ResizeFilter.php @@ -12,6 +12,7 @@ namespace FFMpeg\Filters\Video; use FFMpeg\Coordinate\Dimension; +use FFMpeg\Exception\RuntimeException; use FFMpeg\Media\Video; use FFMpeg\Format\VideoInterface; @@ -80,23 +81,22 @@ class ResizeFilter implements VideoFilterInterface */ public function apply(Video $video, VideoInterface $format) { - $originalWidth = $originalHeight = null; + $dimensions = null; + $commands = array(); foreach ($video->getStreams() as $stream) { if ($stream->isVideo()) { - if ($stream->has('width')) { - $originalWidth = $stream->get('width'); - } - if ($stream->has('height')) { - $originalHeight = $stream->get('height'); + try { + $dimensions = $stream->getDimensions(); + break; + } catch (RuntimeException $e) { + } } } - $commands = array(); - - if ($originalHeight !== null && $originalWidth !== null) { - $dimensions = $this->getComputedDimensions(new Dimension($originalWidth, $originalHeight), $format->getModulus()); + if (null !== $dimensions) { + $dimensions = $this->getComputedDimensions($dimensions, $format->getModulus()); $commands[] = '-s'; $commands[] = $dimensions->getWidth() . 'x' . $dimensions->getHeight(); diff --git a/src/FFMpeg/Media/Frame.php b/src/FFMpeg/Media/Frame.php index bfbed8f..a48c50e 100644 --- a/src/FFMpeg/Media/Frame.php +++ b/src/FFMpeg/Media/Frame.php @@ -18,16 +18,30 @@ use FFMpeg\Driver\FFMpegDriver; use FFMpeg\FFProbe; use FFMpeg\Exception\RuntimeException; use FFMpeg\Coordinate\TimeCode; +use FFMpeg\Media\Video; class Frame extends AbstractMediaType { /** @var TimeCode */ private $timecode; + /** @var Video */ + private $video; - public function __construct($pathfile, FFMpegDriver $driver, FFProbe $ffprobe, TimeCode $timecode) + public function __construct(Video $video, FFMpegDriver $driver, FFProbe $ffprobe, TimeCode $timecode) { - parent::__construct($pathfile, $driver, $ffprobe); + parent::__construct($video->getPathfile(), $driver, $ffprobe); $this->timecode = $timecode; + $this->video = $video; + } + + /** + * Returns the video related to the frame. + * + * @return Video + */ + public function getVideo() + { + return $this->video; } /** @@ -83,16 +97,22 @@ class Frame extends AbstractMediaType '-y', '-ss', (string) $this->timecode, '-i', $this->pathfile, '-vframes', '1', - '-f', 'image2', $pathfile + '-f', 'image2' ); } else { $commands = array( '-y', '-i', $this->pathfile, '-vframes', '1', '-ss', (string) $this->timecode, - '-f', 'image2', $pathfile + '-f', 'image2' ); } + foreach ($this->filters as $filter) { + $commands = array_merge($commands, $filter->apply($this)); + } + + $commands = array_merge($commands, array($pathfile)); + try { $this->driver->command($commands); } catch (ExecutionFailureException $e) { diff --git a/src/FFMpeg/Media/Video.php b/src/FFMpeg/Media/Video.php index 77da940..006e346 100644 --- a/src/FFMpeg/Media/Video.php +++ b/src/FFMpeg/Media/Video.php @@ -159,6 +159,6 @@ class Video extends Audio */ public function frame(TimeCode $at) { - return new Frame($this->pathfile, $this->driver, $this->ffprobe, $at); + return new Frame($this, $this->driver, $this->ffprobe, $at); } } diff --git a/tests/FFMpeg/Tests/FFProbe/DataMapping/StreamTest.php b/tests/FFMpeg/Tests/FFProbe/DataMapping/StreamTest.php index e1ea7b6..c9f9c19 100644 --- a/tests/FFMpeg/Tests/FFProbe/DataMapping/StreamTest.php +++ b/tests/FFMpeg/Tests/FFProbe/DataMapping/StreamTest.php @@ -2,6 +2,7 @@ namespace FFMpeg\Tests\FFProbe\DataMapping; +use FFMpeg\Coordinate\Dimension; use FFMpeg\Tests\TestCase; use FFMpeg\FFProbe\DataMapping\Stream; @@ -40,4 +41,45 @@ class StreamTest extends TestCase array(false, array('codec_type' => 'audio')), ); } + + /** + * @expectedException FFMpeg\Exception\LogicException + * @expectedExceptionMessage Dimensions can only be retrieved from video streams. + */ + public function testGetDimensionsFromAudio() + { + $stream = new Stream(array('codec_type' => 'audio')); + $stream->getDimensions(); + } + + public function testGetDimensionsFromVideo() + { + $stream = new Stream(array('codec_type' => 'video', 'width' => 960, 'height' => 720)); + $this->assertEquals(new Dimension(960, 720), $stream->getDimensions()); + } + + /** + * @dataProvider provideInvalidPropertiesForDimensionsExtraction + * @expectedException FFMpeg\Exception\RuntimeException + * @expectedExceptionMessage Unable to extract dimensions. + */ + public function testUnableToGetDimensionsFromVideo($properties) + { + $stream = new Stream(array('codec_type' => 'video', 'width' => 960)); + $stream->getDimensions(); + } + + public function provideInvalidPropertiesForDimensionsExtraction() + { + return array( + array('codec_type' => 'video', 'width' => 960), + array('codec_type' => 'video', 'height' => 960), + ); + } + + public function testGetDimensionsFromVideoWithDisplayRatio() + { + $stream = new Stream(array('codec_type' => 'video', 'width' => 960, 'height' => 720, 'sample_aspect_ratio' => '4:3', 'display_aspect_ratio' => '16:9')); + $this->assertEquals(new Dimension(1280, 720), $stream->getDimensions()); + } } diff --git a/tests/FFMpeg/Tests/Filters/FiltersCollectionTest.php b/tests/FFMpeg/Tests/Filters/FiltersCollectionTest.php index 52a1395..5a29008 100644 --- a/tests/FFMpeg/Tests/Filters/FiltersCollectionTest.php +++ b/tests/FFMpeg/Tests/Filters/FiltersCollectionTest.php @@ -30,6 +30,12 @@ class FiltersCollectionTest extends TestCase $this->assertCount(2, $coll->getIterator()); } + public function testEmptyIterator() + { + $coll = new FiltersCollection(); + $this->assertInstanceOf('\ArrayIterator', $coll->getIterator()); + } + public function testIteratorSort() { $coll = new FiltersCollection(); diff --git a/tests/FFMpeg/Tests/Filters/Frame/DisplayRatioFixerFilterTest.php b/tests/FFMpeg/Tests/Filters/Frame/DisplayRatioFixerFilterTest.php new file mode 100644 index 0000000..bb642b4 --- /dev/null +++ b/tests/FFMpeg/Tests/Filters/Frame/DisplayRatioFixerFilterTest.php @@ -0,0 +1,28 @@ + 'video', 'width' => 960, 'height' => 720)); + $streams = new StreamCollection(array($stream)); + + $video = $this->getVideoMock(__FILE__); + $video->expects($this->once()) + ->method('getStreams') + ->will($this->returnValue($streams)); + + $frame = new Frame($video, $this->getFFMpegDriverMock(), $this->getFFProbeMock(), new TimeCode(0, 0, 0, 0)); + $filter = new DisplayRatioFixerFilter(); + $this->assertEquals(array('-s', '960x720'), $filter->apply($frame)); + } +} diff --git a/tests/FFMpeg/Tests/Filters/Frame/FrameFiltersTest.php b/tests/FFMpeg/Tests/Filters/Frame/FrameFiltersTest.php new file mode 100644 index 0000000..a0e6434 --- /dev/null +++ b/tests/FFMpeg/Tests/Filters/Frame/FrameFiltersTest.php @@ -0,0 +1,21 @@ +getFrameMock(); + $filters = new FrameFilters($frame); + + $frame->expects($this->once()) + ->method('addFilter') + ->with($this->isInstanceOf('FFMpeg\Filters\Frame\DisplayRatioFixerFilter')); + + $filters->fixDisplayRatio(); + } +} diff --git a/tests/FFMpeg/Tests/Media/FrameTest.php b/tests/FFMpeg/Tests/Media/FrameTest.php index a95df2b..b64d6e9 100644 --- a/tests/FFMpeg/Tests/Media/FrameTest.php +++ b/tests/FFMpeg/Tests/Media/FrameTest.php @@ -11,7 +11,7 @@ class FrameTest extends AbstractMediaTestCase */ public function testWithInvalidFile() { - new Frame('/No/file', $this->getFFMpegDriverMock(), $this->getFFProbeMock(), $this->getTimeCodeMock()); + new Frame($this->getVideoMock('/No/file'), $this->getFFMpegDriverMock(), $this->getFFProbeMock(), $this->getTimeCodeMock()); } public function testGetTimeCode() @@ -20,7 +20,7 @@ class FrameTest extends AbstractMediaTestCase $ffprobe = $this->getFFProbeMock(); $timecode = $this->getTimeCodeMock(); - $frame = new Frame(__FILE__, $driver, $ffprobe, $timecode); + $frame = new Frame($this->getVideoMock(__FILE__), $driver, $ffprobe, $timecode); $this->assertSame($timecode, $frame->getTimeCode()); } @@ -30,7 +30,7 @@ class FrameTest extends AbstractMediaTestCase $ffprobe = $this->getFFProbeMock(); $timecode = $this->getTimeCodeMock(); - $frame = new Frame(__FILE__, $driver, $ffprobe, $timecode); + $frame = new Frame($this->getVideoMock(__FILE__), $driver, $ffprobe, $timecode); $this->assertInstanceOf('FFMpeg\Filters\Frame\FrameFilters', $frame->filters()); } @@ -50,7 +50,7 @@ class FrameTest extends AbstractMediaTestCase ->method('add') ->with($filter); - $frame = new Frame(__FILE__, $driver, $ffprobe, $timecode); + $frame = new Frame($this->getVideoMock(__FILE__), $driver, $ffprobe, $timecode); $frame->setFiltersCollection($filters); $frame->addFilter($filter); } @@ -75,7 +75,7 @@ class FrameTest extends AbstractMediaTestCase ->method('command') ->with($commands); - $frame = new Frame(__FILE__, $driver, $ffprobe, $timecode); + $frame = new Frame($this->getVideoMock(__FILE__), $driver, $ffprobe, $timecode); $this->assertSame($frame, $frame->save($pathfile, $accurate)); } diff --git a/tests/FFMpeg/Tests/TestCase.php b/tests/FFMpeg/Tests/TestCase.php index d347acd..4757f95 100644 --- a/tests/FFMpeg/Tests/TestCase.php +++ b/tests/FFMpeg/Tests/TestCase.php @@ -40,6 +40,13 @@ class TestCase extends \PHPUnit_Framework_TestCase ->getMock(); } + public function getFrameMock() + { + return $this->getMockBuilder('FFMpeg\Media\Frame') + ->disableOriginalConstructor() + ->getMock(); + } + public function getFFMpegDriverMock() { return $this->getMockBuilder('FFMpeg\Driver\FFMpegDriver') @@ -122,10 +129,16 @@ class TestCase extends \PHPUnit_Framework_TestCase ->getMock(); } - protected function getVideoMock() + protected function getVideoMock($filename = null) { - return $this->getMockBuilder('FFMpeg\Media\Video') + $video = $this->getMockBuilder('FFMpeg\Media\Video') ->disableOriginalConstructor() ->getMock(); + + $video->expects($this->any()) + ->method('getPathfile') + ->will($this->returnValue($filename)); + + return $video; } }