diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 5db5b66..24822e1 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -8,7 +8,7 @@ jobs: strategy: fail-fast: true matrix: - php: [7.4, 7.3, 7.2] + php: [8.0, 7.4, 7.3] laravel: [8.*, 7.*, 6.*] dependency-version: [prefer-lowest, prefer-stable] include: @@ -18,9 +18,6 @@ jobs: testbench: 5.* - laravel: 6.* testbench: 4.* - exclude: - - laravel: 8.* - php: 7.2 name: P${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.dependency-version }} @@ -28,12 +25,6 @@ jobs: - name: Checkout code uses: actions/checkout@v2 - - name: Cache dependencies - uses: actions/cache@v2 - with: - path: ~/.composer/cache/files - key: dependencies-laravel-${{ matrix.laravel }}-php-${{ matrix.php }}-composer-${{ hashFiles('composer.json') }} - - name: Setup PHP uses: shivammathur/setup-php@v2 with: diff --git a/CHANGELOG.md b/CHANGELOG.md index be48f58..451a9b0 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,68 @@ All Notable changes to `pbmedia/laravel-ffmpeg` will be documented in this file -## 7.3.0 - 2020-10-?? +## 7.5.0 - 2020-12-22 + +### Added + +- Support for PHP 8.0. +- Encrypted HLS. +- New `getProcessOutput` method to analyze media. +- Support for dynamic HLS playlists. + +### Deprecated + +- Nothing + +### Fixed + +- Nothing + +### Removed + +- Support for PHP 7.2 + +## 7.4.1 - 2020-10-26 + +### Added + +- Better exceptions +- dd() improvements + +### Deprecated + +- Nothing + +### Fixed + +- Nothing + +### Removed + +- Nothing + +## 7.4.0 - 2020-10-25 + +### Added + +- Watermark manipulations +- Dump and die +- Resize filter shortcut +- HLS export with multiple filters per format + +### Deprecated + +- Nothing + +### Fixed + +- Nothing + +### Removed + +- Nothing + +## 7.3.0 - 2020-10-16 ### Added diff --git a/README.md b/README.md index d846b2b..a7d4617 100644 --- a/README.md +++ b/README.md @@ -13,9 +13,10 @@ This package provides an integration with FFmpeg for Laravel 6.0 and higher. [La * Integration with [Laravel's Filesystem](http://laravel.com/docs/7.0/filesystem), [configuration system](https://laravel.com/docs/7.0/configuration) and [logging handling](https://laravel.com/docs/7.0/errors). * Compatible with Laravel 6.0 and higher, support for [Package Discovery](https://laravel.com/docs/7.0/packages#package-discovery). * Built-in support for HLS. +* Built-in support for encrypted HLS (AES-128) and rotating keys (optional). * Built-in support for concatenation, multiple inputs/outputs, image sequences (timelapse), complex filters (and mapping), frame/thumbnail exports. * Built-in support for watermarks (positioning and manipulation). -* PHP 7.2 and higher. +* PHP 7.3 and higher. ## Support @@ -589,6 +590,108 @@ FFMpeg::fromDisk('videos') }); ``` +### Encrypted HLS + +As of version 7.5, you can encrypt each HLS segment using AES-128 encryption. To do this, call the `withEncryptionKey` method on the HLS exporter with a key. We provide a `generateEncryptionKey` helper method on the `HLSExporter` class to generate a key. Make sure you store the key well, as the exported result is worthless without the key. + +```php +use ProtoneMedia\LaravelFFMpeg\Exporters\HLSExporter; + +$encryptionKey = HLSExporter::generateEncryptionKey(); + +FFMpeg::open('steve_howe.mp4') + ->exportForHLS() + ->withEncryptionKey($encryptionKey) + ->addFormat($lowBitrate) + ->addFormat($midBitrate) + ->addFormat($highBitrate) + ->save('adaptive_steve.m3u8'); +``` + +To secure your HLS export even further, you can rotate the key on each exported segment. By doing so, it will generate multiple keys that you'll need to store. Use the `withRotatingEncryptionKey` method to enable this feature and provide a callback that implements the storage of the keys. + +```php +FFMpeg::open('steve_howe.mp4') + ->exportForHLS() + ->withRotatingEncryptionKey(function ($filename, $contents) { + $videoId = 1; + + // use this callback to store the encryption keys + + Storage::disk('secrets')->put($videoId . '/' . $filename, $contents); + + // or... + + DB::table('hls_secrets')->insert([ + 'video_id' => $videoId, + 'filename' => $filename, + 'contents' => $contents, + ]); + }) + ->addFormat($lowBitrate) + ->addFormat($midBitrate) + ->addFormat($highBitrate) + ->save('adaptive_steve.m3u8'); +``` + +The `withRotatingEncryptionKey` method has an optional second argument to set the number of segments that use the same key. This defaults to `1`. + +```php +FFMpeg::open('steve_howe.mp4') + ->exportForHLS() + ->withRotatingEncryptionKey($callable, 10); +``` + +### Protecting your HLS encryption keys + +To make working with encrypted HLS even better, we've added a `DynamicHLSPlaylist` class that modifies playlists on-the-fly and specifically for your application. This way, you can add your authentication and authorization logic. As we're using a plain Laravel controller, you can use features like [Gates](https://laravel.com/docs/master/authorization#gates) and [Middleware](https://laravel.com/docs/master/middleware#introduction). + +In this example, we've saved the HLS export to the `public` disk, and we've stored the encryption keys to the `secrets` disk, which isn't publicly available. As the browser can't access the encryption keys, it won't play the video. Each playlist has paths to the encryption keys, and we need to modify those paths to point to an accessible endpoint. + +This implementation consists of two routes. One that responses with an encryption key and one that responses with a modified playlist. The first route (`video.key`) is relatively simple, and this is where you should add your additional logic. + +The second route (`video.playlist`) uses the `DynamicHLSPlaylist` class. Call the `dynamicHLSPlaylist` method on the `FFMpeg` facade, and similar to opening media files, you can open a playlist utilizing the `fromDisk` and `open` methods. Then you must provide three callbacks. Each of them gives you a relative path and expects a full path in return. As the `DynamicHLSPlaylist` class implements the `Illuminate\Contracts\Support\Responsable` interface, you can return the instance. + +The first callback (KeyUrlResolver) gives you the relative path to an encryption key. The second callback (MediaUrlResolver) gives you the relative path to a media segment (.ts files). The third callback (PlaylistUrlResolver) gives you the relative path to a playlist. + +Now instead of using `Storage::disk('public')->url('adaptive_steve.m3u8')` to get the full url to your primary playlist, you can use `route('video.playlist', ['playlist' => 'adaptive_steve.m3u8'])`. The `DynamicHLSPlaylist` class takes care of all the paths and urls. + +```php +Route::get('/video/secret/{key}', function ($key) { + return Storage::disk('secrets')->download($key); +})->name('video.key'); + +Route::get('/video/{playlist}', function ($playlist) { + return FFMpeg::dynamicHLSPlaylist() + ->fromDisk('public') + ->open($playlist) + ->setKeyUrlResolver(function ($key) { + return route('video.key', ['key' => $key]); + }) + ->setMediaUrlResolver(function ($mediaFilename) { + return Storage::disk('public')->url($mediaFilename); + }) + ->setPlaylistUrlResolver(function ($playlistFilename) { + return route('video.playlist', ['playlist' => $playlistFilename]); + }); +})->name('video.playlist'); +``` + +## Process Output + +You can get the raw process output by calling the `getProcessOutput` method. Though the use-case is limited, you can use it to analyze a file (for example, with the `volumedetect` filter). It returns a `\ProtoneMedia\LaravelFFMpeg\Support\ProcessOutput` class that has three methods: `all`, `errors` and `output`. Each method returns a line with the corresponding lines. + +```php +$processOutput = FFMpeg::open('video.mp4') + ->export() + ->addFilter(['-filter:a', 'volumedetect', '-f', 'null']) + ->getProcessOutput(); + +$processOutput->all(); +$processOutput->errors(); +$processOutput->out(); +``` + ## Advanced The Media object you get when you 'open' a file, actually holds the Media object that belongs to the [underlying driver](https://github.com/PHP-FFMpeg/PHP-FFMpeg). It handles dynamic method calls as you can see [here](https://github.com/pascalbaljetmedia/laravel-ffmpeg/blob/master/src/Media.php#L114-L117). This way all methods of the underlying driver are still available to you. diff --git a/composer.json b/composer.json index 218d4ca..49e4bb7 100755 --- a/composer.json +++ b/composer.json @@ -23,20 +23,20 @@ } ], "require": { - "php": "^7.2", + "php": "^7.3|^8.0", "evenement/evenement": "^3.0", "illuminate/bus": "^6.0|^7.0|^8.0", "illuminate/config": "^6.0|^7.0|^8.0", "illuminate/filesystem": "^6.0|^7.0|^8.0", "illuminate/log": "^6.0|^7.0|^8.0", "illuminate/support": "^6.0|^7.0|^8.0", - "php-ffmpeg/php-ffmpeg": "^0.16.0" + "php-ffmpeg/php-ffmpeg": "^0.17.0" }, "require-dev": { "league/flysystem-memory": "^1.0", - "mockery/mockery": "^1.3", + "mockery/mockery": "^1.3.3", "orchestra/testbench": "^4.0|^5.0|^6.0", - "phpunit/phpunit": "^8.0", + "phpunit/phpunit": "^8.0|^9.0", "spatie/image": "^1.7", "twistor/flysystem-http": "^0.2.0" }, @@ -69,4 +69,4 @@ } } } -} +} \ No newline at end of file diff --git a/src/Drivers/PHPFFMpeg.php b/src/Drivers/PHPFFMpeg.php index 6e97665..31ada83 100644 --- a/src/Drivers/PHPFFMpeg.php +++ b/src/Drivers/PHPFFMpeg.php @@ -2,8 +2,10 @@ namespace ProtoneMedia\LaravelFFMpeg\Drivers; +use Alchemy\BinaryDriver\Listeners\ListenerInterface; use Exception; use FFMpeg\Coordinate\TimeCode; +use FFMpeg\Driver\FFMpegDriver; use FFMpeg\FFMpeg; use FFMpeg\Media\AbstractMediaType; use FFMpeg\Media\AdvancedMedia as BaseAdvancedMedia; @@ -158,6 +160,43 @@ public function openAdvanced(MediaCollection $mediaCollection): self return $this->open($mediaCollection); } + /** + * Returns the FFMpegDriver of the underlying library. + * + * @return \FFMpeg\Driver\FFMpegDriver + */ + private function getFFMpegDriver(): FFMpegDriver + { + return $this->get()->getFFMpegDriver(); + } + + /** + * Add a Listener to the underlying library. + * + * @param \Alchemy\BinaryDriver\Listeners\ListenerInterface $listener + * @return self + */ + public function addListener(ListenerInterface $listener): self + { + $this->getFFMpegDriver()->listen($listener); + + return $this; + } + + /** + * Add an event handler to the underlying library. + * + * @param string $event + * @param callable $callback + * @return self + */ + public function onEvent(string $event, callable $callback): self + { + $this->getFFMpegDriver()->on($event, $callback); + + return $this; + } + /** * Returns the underlying media object itself. */ diff --git a/src/Exporters/EncryptsHLSSegments.php b/src/Exporters/EncryptsHLSSegments.php new file mode 100755 index 0000000..760b3be --- /dev/null +++ b/src/Exporters/EncryptsHLSSegments.php @@ -0,0 +1,251 @@ +encryptionKey = $key ?: static::generateEncryptionKey(); + } + + /** + * Initialises the disk, info and IV for encryption and sets the key. + * + * @param string $key + * @return self + */ + public function withEncryptionKey($key): self + { + $this->encryptionSecretsDisk = Disk::makeTemporaryDisk(); + $this->encryptionIV = bin2hex(static::generateEncryptionKey()); + + $this->setEncryptionKey($key); + + return $this; + } + + /** + * Enables encryption with rotating keys. The callable will receive every new + * key and the integer sets the number of segments that can + * use the same key. + * + * @param Closure $callback + * @param int $segmentsPerKey + * @return self + */ + public function withRotatingEncryptionKey(Closure $callback, int $segmentsPerKey = 1): self + { + $this->rotateEncryptiongKey = true; + $this->onNewEncryptionKey = $callback; + $this->segmentsPerKey = $segmentsPerKey; + + return $this->withEncryptionKey(static::generateEncryptionKey()); + } + + /** + * Rotates the key and returns the absolute path to the info file. + * + * @return string + */ + private function rotateEncryptionKey(): string + { + // get the absolute path to the encryption key + $keyPath = $this->encryptionSecretsDisk + ->makeMedia($keyFilename = uniqid() . '.key') + ->getLocalPath(); + + // randomize the encryption key + $this->encryptionSecretsDisk->put( + $keyFilename, + $encryptionKey = $this->setEncryptionKey() + ); + + // generate an info file with a reference to the encryption key and IV + $this->encryptionSecretsDisk->put( + HLSExporter::HLS_KEY_INFO_FILENAME, + implode(PHP_EOL, [ + $keyPath, $keyPath, $this->encryptionIV, + ]) + ); + + // call the callback + if ($this->onNewEncryptionKey) { + call_user_func($this->onNewEncryptionKey, $keyFilename, $encryptionKey); + } + + // return the absolute path to the info file + return $this->encryptionSecretsDisk + ->makeMedia(HLSExporter::HLS_KEY_INFO_FILENAME) + ->getLocalPath(); + } + + /** + * Returns an array with the encryption parameters. + * + * @return array + */ + private function getEncrypedHLSParameters(): array + { + if (!$this->encryptionKey) { + return []; + } + + $keyInfoPath = $this->rotateEncryptionKey(); + $parameters = ['-hls_key_info_file', $keyInfoPath]; + + if ($this->rotateEncryptiongKey) { + $parameters[] = '-hls_flags'; + $parameters[] = 'periodic_rekey'; + } + + return $parameters; + } + + /** + * Adds a listener and handler to rotate the key on + * every new HLS segment. + * + * @return void + */ + private function addHandlerToRotateEncryptionKey() + { + if (!$this->rotateEncryptiongKey) { + return; + } + + $this->addListener(new StdListener)->onEvent('listen', function ($line) { + $opensEncryptedSegment = Str::contains($line, "Opening 'crypto:/") + && Str::contains($line, ".ts' for writing"); + + if (!$opensEncryptedSegment) { + return; + } + + $this->segmentsOpened++; + + if ($this->segmentsOpened % $this->segmentsPerKey === 0) { + $this->rotateEncryptionKey(); + } + }); + } + + /** + * While encoding, the encryption keys are saved to a temporary directory. + * With this method, we loop through all segment playlists and replace + * the absolute path to the keys to a relative ones. + * + * @param \Illuminate\Support\Collection $playlistMedia + * @return void + */ + private function replaceAbsolutePathsHLSEncryption(Collection $playlistMedia) + { + if (!$this->encryptionSecretsDisk) { + return; + } + + $playlistMedia->each(function ($playlistMedia) { + $disk = $playlistMedia->getDisk(); + $path = $playlistMedia->getPath(); + + $prefix = '#EXT-X-KEY:METHOD=AES-128,URI="'; + + $content = str_replace( + $prefix . $this->encryptionSecretsDisk->path(''), + $prefix, + $disk->get($path) + ); + + $disk->put($path, $content); + }); + } + + /** + * Removes the encryption keys from the temporary disk. + * + * @return void + */ + private function cleanupHLSEncryption() + { + if (!$this->encryptionSecretsDisk) { + return; + } + + $paths = $this->encryptionSecretsDisk->allFiles(); + + foreach ($paths as $path) { + $this->encryptionSecretsDisk->delete($path); + } + } +} diff --git a/src/Exporters/HLSExporter.php b/src/Exporters/HLSExporter.php index 9bc2e3a..5c55320 100755 --- a/src/Exporters/HLSExporter.php +++ b/src/Exporters/HLSExporter.php @@ -12,6 +12,10 @@ class HLSExporter extends MediaExporter { + use EncryptsHLSSegments; + + const HLS_KEY_INFO_FILENAME = 'hls_encryption.keyinfo'; + /** * @var integer */ @@ -101,7 +105,7 @@ function ($path) use (&$formatPlaylistPath) { private function addHLSParametersToFormat(DefaultVideo $format, string $segmentsPattern, Disk $disk) { - $format->setAdditionalParameters([ + $hlsParameters = [ '-sc_threshold', '0', '-g', @@ -112,7 +116,13 @@ private function addHLSParametersToFormat(DefaultVideo $format, string $segments $this->segmentLength, '-hls_segment_filename', $disk->makeMedia($segmentsPattern)->getLocalPath(), - ]); + ]; + + $format->setAdditionalParameters(array_merge( + $format->getAdditionalParameters() ?: [], + $hlsParameters, + $this->getEncrypedHLSParameters() + )); } private function applyFiltersCallback(callable $filtersCallback, int $formatKey): array @@ -158,6 +168,8 @@ private function prepareSaving(string $path = null): Collection $this->addFormatOutputMapping($format, $disk->makeMedia($formatPlaylistPath), $outs ?? ['0']); return $this->getDisk()->makeMedia($formatPlaylistPath); + })->tap(function () { + $this->addHandlerToRotateEncryptionKey(); }); } @@ -180,6 +192,9 @@ public function save(string $path = null): MediaOpener $this->getDisk()->put($path, $playlist); + $this->replaceAbsolutePathsHLSEncryption($playlistMedia); + $this->cleanupHLSEncryption(); + return $result; }); } diff --git a/src/Exporters/HLSPlaylistGenerator.php b/src/Exporters/HLSPlaylistGenerator.php index 922881d..415215e 100644 --- a/src/Exporters/HLSPlaylistGenerator.php +++ b/src/Exporters/HLSPlaylistGenerator.php @@ -14,17 +14,6 @@ class HLSPlaylistGenerator implements PlaylistGenerator const PLAYLIST_START = '#EXTM3U'; const PLAYLIST_END = '#EXT-X-ENDLIST'; - private function getPathOfFirstSegment(Media $playlistMedia): string - { - $playlistContent = file_get_contents($playlistMedia->getLocalPath()); - - $lines = preg_split('/\n|\r\n?/', $playlistContent); - - return Collection::make($lines)->first(function ($line) { - return !Str::startsWith($line, '#') && Str::endsWith($line, '.ts'); - }); - } - private function getBandwidth(MediaOpener $media) { return $media->getFormat()->get('bit_rate'); @@ -57,9 +46,8 @@ private function getFrameRate(MediaOpener $media) public function get(array $playlistMedia, PHPFFMpeg $driver): string { return Collection::make($playlistMedia)->map(function (Media $playlistMedia) use ($driver) { - $media = (new MediaOpener($playlistMedia->getDisk(), $driver))->open( - $playlistMedia->getDirectory() . $this->getPathOfFirstSegment($playlistMedia) - ); + $media = (new MediaOpener($playlistMedia->getDisk(), $driver)) + ->openWithInputOptions($playlistMedia->getPath(), ['-allowed_extensions', 'ALL']); $streamInfo = [ "#EXT-X-STREAM-INF:BANDWIDTH={$this->getBandwidth($media)}", diff --git a/src/Exporters/MediaExporter.php b/src/Exporters/MediaExporter.php index 17565c6..617590f 100755 --- a/src/Exporters/MediaExporter.php +++ b/src/Exporters/MediaExporter.php @@ -7,9 +7,12 @@ use Illuminate\Support\Collection; use Illuminate\Support\Traits\ForwardsCalls; use ProtoneMedia\LaravelFFMpeg\Drivers\PHPFFMpeg; +use ProtoneMedia\LaravelFFMpeg\FFMpeg\NullFormat; +use ProtoneMedia\LaravelFFMpeg\FFMpeg\StdListener; use ProtoneMedia\LaravelFFMpeg\Filesystem\Disk; use ProtoneMedia\LaravelFFMpeg\Filesystem\Media; use ProtoneMedia\LaravelFFMpeg\MediaOpener; +use ProtoneMedia\LaravelFFMpeg\Support\ProcessOutput; /** * @mixin \ProtoneMedia\LaravelFFMpeg\Drivers\PHPFFMpeg @@ -86,7 +89,10 @@ public function getCommand(string $path = null) { $media = $this->prepareSaving($path); - return $this->driver->getFinalCommand($this->format, optional($media)->getLocalPath()); + return $this->driver->getFinalCommand( + $this->format ?: new NullFormat, + optional($media)->getLocalPath() ?: '/dev/null' + ); } public function dd(string $path = null) @@ -143,14 +149,19 @@ public function save(string $path = null) return $data; } } else { - $this->driver->save($this->format, $outputMedia->getLocalPath()); + $this->driver->save( + $this->format ?: new NullFormat, + optional($outputMedia)->getLocalPath() ?: '/dev/null' + ); } } catch (RuntimeException $exception) { throw EncodingException::decorate($exception); } - $outputMedia->copyAllFromTemporaryDirectory($this->visibility); - $outputMedia->setVisibility($this->visibility); + if ($outputMedia) { + $outputMedia->copyAllFromTemporaryDirectory($this->visibility); + $outputMedia->setVisibility($this->visibility); + } if ($this->onProgressCallback) { call_user_func($this->onProgressCallback, 100, 0, 0); @@ -159,6 +170,13 @@ public function save(string $path = null) return $this->getMediaOpener(); } + public function getProcessOutput(): ProcessOutput + { + return tap(new StdListener, function (StdListener $listener) { + $this->addListener($listener)->save(); + })->get(); + } + private function saveWithMappings(): MediaOpener { if ($this->onProgressCallback) { diff --git a/src/FFMpeg/FFProbe.php b/src/FFMpeg/FFProbe.php index 206bc62..5ff0b91 100644 --- a/src/FFMpeg/FFProbe.php +++ b/src/FFMpeg/FFProbe.php @@ -5,7 +5,6 @@ use Alchemy\BinaryDriver\Exception\ExecutionFailureException; use FFMpeg\Exception\RuntimeException; use FFMpeg\FFProbe as FFMpegFFProbe; -use ProtoneMedia\LaravelFFMpeg\Filesystem\MediaOnNetwork; class FFProbe extends FFMpegFFProbe { @@ -36,6 +35,27 @@ public static function make(FFMpegFFProbe $probe): self return new static($probe->getFFProbeDriver(), $probe->getCache()); } + private function shouldUseCustomProbe($pathfile): bool + { + if (!$this->media) { + return false; + } + + if ($this->media->getLocalPath() !== $pathfile) { + return false; + } + + if (empty($this->media->getCompiledInputOptions())) { + return false; + } + + if (!$this->getOptionsTester()->has('-show_streams')) { + throw new RuntimeException('This version of ffprobe is too old and does not support `-show_streams` option, please upgrade'); + } + + return true; + } + /** * Probes the streams contained in a given file. * @@ -44,29 +64,41 @@ public static function make(FFMpegFFProbe $probe): self * @throws \FFMpeg\Exception\InvalidArgumentException * @throws \FFMpeg\Exception\RuntimeException */ - public function streams($pathfile) { - if (!$this->media instanceof MediaOnNetwork || $this->media->getLocalPath() !== $pathfile) { + if (!$this->shouldUseCustomProbe($pathfile)) { return parent::streams($pathfile); } - if (!$this->getOptionsTester()->has('-show_streams')) { - throw new RuntimeException('This version of ffprobe is too old and does not support `-show_streams` option, please upgrade'); + return $this->probeStreams($pathfile, '-show_streams', static::TYPE_STREAMS); + } + + /** + * Probes the format of a given file. + * + * @param string $pathfile + * @return \FFMpeg\FFProbe\DataMapping\Format A Format object + * @throws \FFMpeg\Exception\InvalidArgumentException + * @throws \FFMpeg\Exception\RuntimeException + */ + public function format($pathfile) + { + if (!$this->shouldUseCustomProbe($pathfile)) { + return parent::format($pathfile); } - return $this->probeStreams($pathfile); + return $this->probeStreams($pathfile, '-show_format', static::TYPE_FORMAT); } /** * This is just copy-paste from FFMpeg\FFProbe... - * It prepends the command with the headers. + * It prepends the command with the input options. */ - private function probeStreams($pathfile, $allowJson = true) + private function probeStreams($pathfile, $command, $type, $allowJson = true) { $commands = array_merge( - $this->media->getCompiledHeaders(), - [$pathfile, '-show_streams'] + $this->media->getCompiledInputOptions(), + [$pathfile, $command] ); $parseIsToDo = false; @@ -90,7 +122,7 @@ private function probeStreams($pathfile, $allowJson = true) } if ($parseIsToDo) { - $data = $this->getParser()->parse(static::TYPE_STREAMS, $output); + $data = $this->getParser()->parse($type, $output); } else { try { $data = @json_decode($output, true); @@ -99,10 +131,10 @@ private function probeStreams($pathfile, $allowJson = true) throw new RuntimeException(sprintf('Unable to parse json %s', $output)); } } catch (RuntimeException $e) { - return $this->probeStreams($pathfile, false); + return $this->probeStreams($pathfile, $command, $type, false); } } - return $this->getMapper()->map(static::TYPE_STREAMS, $data); + return $this->getMapper()->map($type, $data); } } diff --git a/src/FFMpeg/InteractsWithHttpHeaders.php b/src/FFMpeg/InteractsWithHttpHeaders.php index e405d27..d994fc5 100644 --- a/src/FFMpeg/InteractsWithHttpHeaders.php +++ b/src/FFMpeg/InteractsWithHttpHeaders.php @@ -6,6 +6,8 @@ trait InteractsWithHttpHeaders { + use InteractsWithInputPath; + /** * @var array */ @@ -23,39 +25,6 @@ public function setHeaders(array $headers = []): self return $this; } - /** - * Searches in the $input array for the key bu the $path, and then - * prepend the $values in front of that key. - * - * @param array $input - * @param string $path - * @param array $values - * @return array - */ - protected static function mergeBeforePathInput(array $input, string $path, array $values = []): array - { - $key = array_search($path, $input) - 1; - - return static::mergeBeforeKey($input, $key, $values); - } - - /** - * Prepend the given $values in front of the $key in $input. - * - * @param array $input - * @param integer $key - * @param array $values - * @return array - */ - protected static function mergeBeforeKey(array $input, int $key, array $values = []): array - { - return array_merge( - array_slice($input, 0, $key), - $values, - array_slice($input, $key) - ); - } - /** * Maps the headers into a key-value string for FFmpeg. Returns * an array of arguments to pass into the command. diff --git a/src/FFMpeg/InteractsWithInputPath.php b/src/FFMpeg/InteractsWithInputPath.php new file mode 100644 index 0000000..1813e7f --- /dev/null +++ b/src/FFMpeg/InteractsWithInputPath.php @@ -0,0 +1,39 @@ +audioKiloBitrate = null; + } + + /** + * {@inheritdoc} + */ + public function getExtraParams() + { + return []; + } + + /** + * {@inheritDoc} + */ + public function getAvailableAudioCodecs() + { + return []; + } +} diff --git a/src/FFMpeg/StdListener.php b/src/FFMpeg/StdListener.php new file mode 100644 index 0000000..1a4191b --- /dev/null +++ b/src/FFMpeg/StdListener.php @@ -0,0 +1,77 @@ + [], + Process::ERR => [], + Process::OUT => [], + ]; + + public function __construct(string $eventName = 'listen') + { + $this->eventName = $eventName; + } + + /** + * Handler for a new line of data. + * + * @param string $type + * @param string $data + * @return void + */ + public function handle($type, $data) + { + $lines = preg_split('/\n|\r\n?/', $data); + + foreach ($lines as $line) { + $line = trim($line); + + $this->data[$type][] = $line; + + $this->data[static::TYPE_ALL][] = $line; + + $this->emit($this->eventName, [$line, $type]); + } + } + + /** + * Returns the collected output lines. + * + * @return array + */ + public function get(): ProcessOutput + { + return new ProcessOutput( + $this->data[static::TYPE_ALL], + $this->data[Process::ERR], + $this->data[Process::OUT] + ); + } + + public function forwardedEvents() + { + return [$this->eventName]; + } +} diff --git a/src/Filesystem/HasInputOptions.php b/src/Filesystem/HasInputOptions.php new file mode 100755 index 0000000..96d96c3 --- /dev/null +++ b/src/Filesystem/HasInputOptions.php @@ -0,0 +1,28 @@ +inputOptions; + } + + public function setInputOptions(array $options = []): self + { + $this->inputOptions = $options; + + return $this; + } + + public function getCompiledInputOptions(): array + { + return $this->getInputOptions(); + } +} diff --git a/src/Filesystem/Media.php b/src/Filesystem/Media.php index 1b6389e..48a924f 100755 --- a/src/Filesystem/Media.php +++ b/src/Filesystem/Media.php @@ -6,6 +6,8 @@ class Media { + use HasInputOptions; + /** * @var \ProtoneMedia\LaravelFFMpeg\Filesystem\Disk */ diff --git a/src/Filesystem/MediaOnNetwork.php b/src/Filesystem/MediaOnNetwork.php index 3a3a5fe..f8db918 100755 --- a/src/Filesystem/MediaOnNetwork.php +++ b/src/Filesystem/MediaOnNetwork.php @@ -6,6 +6,7 @@ class MediaOnNetwork { + use HasInputOptions; use InteractsWithHttpHeaders; /** @@ -49,6 +50,11 @@ public function getFilename(): string return pathinfo($this->getPath())['basename']; } + public function getCompiledInputOptions(): array + { + return array_merge($this->getInputOptions(), $this->getCompiledHeaders()); + } + public function getCompiledHeaders(): array { return static::compileHeaders($this->getHeaders()); diff --git a/src/Http/DynamicHLSPlaylist.php b/src/Http/DynamicHLSPlaylist.php new file mode 100644 index 0000000..321cd52 --- /dev/null +++ b/src/Http/DynamicHLSPlaylist.php @@ -0,0 +1,252 @@ +disk = Disk::make(config('filesystems.default')); + } + + /** + * Set the disk to open files from. + */ + public function fromDisk($disk): self + { + $this->disk = Disk::make($disk); + + return $this; + } + + /** + * Instantiates a Media object for the given path and clears the cache. + */ + public function open(string $path): self + { + $this->media = Media::make($this->disk, $path); + + $this->keyCache = []; + $this->playlistCache = []; + $this->mediaCache = []; + + return $this; + } + + public function setMediaUrlResolver(callable $mediaResolver): self + { + $this->mediaResolver = $mediaResolver; + + return $this; + } + + public function setPlaylistUrlResolver(callable $playlistResolver): self + { + $this->playlistResolver = $playlistResolver; + + return $this; + } + + public function setKeyUrlResolver(callable $keyResolver): self + { + $this->keyResolver = $keyResolver; + + return $this; + } + + /** + * Returns the resolved key filename from the cache or resolves it. + * + * @param string $key + * @return string + */ + private function resolveKeyFilename(string $key): string + { + if (array_key_exists($key, $this->keyCache)) { + return $this->keyCache[$key]; + } + + return $this->keyCache[$key] = call_user_func($this->keyResolver, $key); + } + + /** + * Returns the resolved media filename from the cache or resolves it. + * + * @param string $key + * @return string + */ + private function resolveMediaFilename(string $media): string + { + if (array_key_exists($media, $this->mediaCache)) { + return $this->mediaCache[$media]; + } + + return $this->mediaCache[$media] = call_user_func($this->mediaResolver, $media); + } + + /** + * Returns the resolved playlist filename from the cache or resolves it. + * + * @param string $key + * @return string + */ + private function resolvePlaylistFilename(string $playlist): string + { + if (array_key_exists($playlist, $this->playlistCache)) { + return $this->playlistCache[$playlist]; + } + + return $this->playlistCache[$playlist] = call_user_func($this->playlistResolver, $playlist); + } + + /** + * Parses the lines into a Collection + * + * @param string $lines + * @return \Illuminate\Support\Collection + */ + private static function parseLines(string $lines): Collection + { + return Collection::make(preg_split('/\n|\r\n?/', $lines)); + } + + /** + * Returns a boolean wether the line contains a .M3U8 playlist filename + * or a .TS segment filename. + * + * @param string $line + * @return boolean + */ + private static function lineHasMediaFilename(string $line): bool + { + return !Str::startsWith($line, '#') && Str::endsWith($line, ['.m3u8', '.ts']); + } + + /** + * Returns the filename of the encryption key. + * + * @param string $line + * @return string|null + */ + private static function extractKeyFromExtLine(string $line): ?string + { + preg_match_all('/#EXT-X-KEY:METHOD=AES-128,URI="([a-zA-Z0-9-_\/]+.key)",IV=[a-z0-9]+/', $line, $matches); + + return $matches[1][0] ?? null; + } + + /** + * Returns the processed content of the playlist. + * + * @return string + */ + public function get(): string + { + return $this->getProcessedPlaylist($this->media->getPath()); + } + + /** + * Returns a collection of all processed segment playlists + * and the processed master playlist. + * + * @return \Illuminate\Support\Collection + */ + public function all(): Collection + { + return static::parseLines( + $this->disk->get($this->media->getPath()) + )->filter(function ($line) { + return static::lineHasMediaFilename($line); + })->mapWithKeys(function ($segmentPlaylist) { + return [$segmentPlaylist => $this->getProcessedPlaylist($segmentPlaylist)]; + })->prepend( + $this->getProcessedPlaylist($this->media->getPath()), + $this->media->getPath() + ); + } + + /** + * Processes the given playlist. + * + * @param string $playlistPath + * @return string + */ + public function getProcessedPlaylist(string $playlistPath): string + { + return static::parseLines($this->disk->get($playlistPath))->map(function (string $line) { + if (static::lineHasMediaFilename($line)) { + return Str::endsWith($line, '.m3u8') + ? $this->resolvePlaylistFilename($line) + : $this->resolveMediaFilename($line); + } + + $key = static::extractKeyFromExtLine($line); + + if (!$key) { + return $line; + } + + return str_replace( + '#EXT-X-KEY:METHOD=AES-128,URI="' . $key . '"', + '#EXT-X-KEY:METHOD=AES-128,URI="' . $this->resolveKeyFilename($key) . '"', + $line + ); + })->implode(PHP_EOL); + } + + public function toResponse($request) + { + return Response::make($this->get(), 200, [ + 'Content-Type' => 'application/vnd.apple.mpegurl', + ]); + } +} diff --git a/src/MediaOpener.php b/src/MediaOpener.php index 6b81f78..50f1d32 100755 --- a/src/MediaOpener.php +++ b/src/MediaOpener.php @@ -97,6 +97,22 @@ public function open($paths): self return $this; } + /** + * Instantiates a single Media object and sets the given options on the object. + * + * @param string $path + * @param array $options + * @return self + */ + public function openWithInputOptions(string $path, array $options = []): self + { + $this->collection->push( + Media::make($this->disk, $path)->setInputOptions($options) + ); + + return $this; + } + /** * Instantiates a MediaOnNetwork object for each given url. */ diff --git a/src/Support/FFMpeg.php b/src/Support/FFMpeg.php index c08347e..8653867 100755 --- a/src/Support/FFMpeg.php +++ b/src/Support/FFMpeg.php @@ -5,6 +5,7 @@ use Illuminate\Support\Facades\Facade; /** + * @method static \ProtoneMedia\LaravelFFMpeg\Http\DynamicHLSPlaylist dynamicHLSPlaylist($disk) * @method static \ProtoneMedia\LaravelFFMpeg\MediaOpener fromDisk($disk) * @method static \ProtoneMedia\LaravelFFMpeg\MediaOpener fromFilesystem(\Illuminate\Contracts\Filesystem\Filesystem $filesystem) * @method static \ProtoneMedia\LaravelFFMpeg\MediaOpener open($path) diff --git a/src/Support/MediaOpenerFactory.php b/src/Support/MediaOpenerFactory.php index fc4f006..98c7df6 100644 --- a/src/Support/MediaOpenerFactory.php +++ b/src/Support/MediaOpenerFactory.php @@ -4,6 +4,7 @@ use Illuminate\Support\Traits\ForwardsCalls; use ProtoneMedia\LaravelFFMpeg\Drivers\PHPFFMpeg; +use ProtoneMedia\LaravelFFMpeg\Http\DynamicHLSPlaylist; use ProtoneMedia\LaravelFFMpeg\MediaOpener; class MediaOpenerFactory @@ -24,6 +25,11 @@ public function new(): MediaOpener return new MediaOpener($this->defaultDisk, $this->driver); } + public function dynamicHLSPlaylist(): DynamicHLSPlaylist + { + return new DynamicHLSPlaylist($this->defaultDisk); + } + /** * Handle dynamic method calls into the MediaOpener. * diff --git a/src/Support/ProcessOutput.php b/src/Support/ProcessOutput.php new file mode 100644 index 0000000..e68d157 --- /dev/null +++ b/src/Support/ProcessOutput.php @@ -0,0 +1,32 @@ +all = $all; + $this->errors = $errors; + $this->out = $out; + } + + public function all(): array + { + return $this->all; + } + + public function errors(): array + { + return $this->errors; + } + + public function out(): array + { + return $this->out; + } +} diff --git a/tests/DynamicHLSPlaylistTest.php b/tests/DynamicHLSPlaylistTest.php new file mode 100644 index 0000000..c2aa2c9 --- /dev/null +++ b/tests/DynamicHLSPlaylistTest.php @@ -0,0 +1,51 @@ +fakeLocalVideoFile(); + + $lowBitrate = $this->x264()->setKiloBitrate(250); + $highBitrate = $this->x264()->setKiloBitrate(500); + + FFMpeg::open('video.mp4') + ->exportForHLS() + ->setKeyFrameInterval(1) + ->setSegmentLength(1) + ->withRotatingEncryptionKey(function ($filename, $contents) { + Storage::disk('local')->put("keys/{$filename}", $contents); + }) + ->addFormat($lowBitrate) + ->addFormat($highBitrate) + ->save('adaptive.m3u8'); + + $dynamicPlaylist = FFMpeg::dynamicHLSPlaylist() + ->fromDisk('local') + ->open('adaptive.m3u8') + ->setMediaUrlResolver(function ($media) { + return "https://example.com/{$media}"; + }) + ->setPlaylistUrlResolver(function ($playlist) { + return "https://example.com/{$playlist}"; + }) + ->setKeyUrlResolver(function ($key) { + return "https://example.com/{$key}?secret=1337"; + }) + ->all(); + + $this->assertArrayHasKey('adaptive.m3u8', $dynamicPlaylist); + $this->assertArrayHasKey('adaptive_0_250.m3u8', $dynamicPlaylist); + $this->assertArrayHasKey('adaptive_1_500.m3u8', $dynamicPlaylist); + + $this->assertStringContainsString('https://example.com/adaptive_0_250.m3u8', $dynamicPlaylist['adaptive.m3u8']); + $this->assertStringContainsString('#EXT-X-KEY:METHOD=AES-128,URI="https://example.com/', $dynamicPlaylist['adaptive_0_250.m3u8']); + $this->assertStringContainsString('?secret=1337",IV=', $dynamicPlaylist['adaptive_0_250.m3u8']); + } +} diff --git a/tests/EncryptedHlsExportTest.php b/tests/EncryptedHlsExportTest.php new file mode 100644 index 0000000..19dd49c --- /dev/null +++ b/tests/EncryptedHlsExportTest.php @@ -0,0 +1,158 @@ +fakeLocalVideoFile(); + + $lowBitrate = $this->x264()->setKiloBitrate(250); + + FFMpeg::open('video.mp4') + ->exportForHLS() + ->withEncryptionKey(HLSExporter::generateEncryptionKey()) + ->addFormat($lowBitrate) + ->save('adaptive.m3u8'); + + $this->assertTrue(Storage::disk('local')->has('adaptive.m3u8')); + $this->assertTrue(Storage::disk('local')->has('adaptive_0_250.m3u8')); + + $playlist = Storage::disk('local')->get('adaptive.m3u8'); + + $pattern = '/' . implode("\n", [ + '#EXTM3U', + '#EXT-X-STREAM-INF:BANDWIDTH=[0-9]+,RESOLUTION=1920x1080,FRAME-RATE=25.000', + 'adaptive_0_250.m3u8', + '#EXT-X-ENDLIST', + ]) . '/'; + + $this->assertEquals(1, preg_match($pattern, $playlist)); + + $encryptedPlaylist = Storage::disk('local')->get('adaptive_0_250.m3u8'); + + $pattern = '/' . implode("\n", [ + '#EXTM3U', + '#EXT-X-VERSION:3', + '#EXT-X-TARGETDURATION:5', + '#EXT-X-MEDIA-SEQUENCE:0', + '#EXT-X-PLAYLIST-TYPE:VOD', + '#EXT-X-KEY:METHOD=AES-128,URI="[a-zA-Z0-9-_\/]+.key",IV=[a-z0-9]+', + '#EXTINF:4.720000,', + 'adaptive_0_250_00000.ts', + '#EXT-X-ENDLIST', + ]) . '/'; + + $this->assertEquals(1, preg_match($pattern, $encryptedPlaylist), "Playlist mismatch:" . PHP_EOL . $encryptedPlaylist); + } + + /** @test */ + public function it_can_export_a_single_media_file_into_an_encryped_hls_export_with_rotating_keys() + { + $this->fakeLocalVideoFile(); + + $lowBitrate = $this->x264()->setKiloBitrate(250); + + $keys = []; + + FFMpeg::open('video.mp4') + ->exportForHLS() + ->setKeyFrameInterval(1) + ->setSegmentLength(1) + ->addFormat($lowBitrate) + ->withRotatingEncryptionKey(function ($filename, $contents) use (&$keys) { + $keys[$filename] = $contents; + }) + ->save('adaptive.m3u8'); + + $this->assertCount(6, $keys); + + $this->assertTrue(Storage::disk('local')->has('adaptive.m3u8')); + $this->assertTrue(Storage::disk('local')->has('adaptive_0_250.m3u8')); + + $encryptedPlaylist = Storage::disk('local')->get('adaptive_0_250.m3u8'); + + $pattern = "/" . implode("\n", [ + '#EXTM3U', + '#EXT-X-VERSION:3', + '#EXT-X-TARGETDURATION:[0-9]+', + '#EXT-X-MEDIA-SEQUENCE:0', + '#EXT-X-PLAYLIST-TYPE:VOD', + '#EXT-X-KEY:METHOD=AES-128,URI="[a-zA-Z0-9-_\/]+.key",IV=[a-z0-9]+', + '#EXTINF:1.[0-9]+,', + 'adaptive_0_250_00000.ts', + '#EXT-X-KEY:METHOD=AES-128,URI="[a-zA-Z0-9-_\/]+.key",IV=[a-z0-9]+', + '#EXTINF:1.[0-9]+,', + 'adaptive_0_250_00001.ts', + '#EXT-X-KEY:METHOD=AES-128,URI="[a-zA-Z0-9-_\/]+.key",IV=[a-z0-9]+', + '#EXTINF:1.[0-9]+,', + 'adaptive_0_250_00002.ts', + '#EXT-X-KEY:METHOD=AES-128,URI="[a-zA-Z0-9-_\/]+.key",IV=[a-z0-9]+', + '#EXTINF:1.[0-9]+,', + 'adaptive_0_250_00003.ts', + '#EXT-X-KEY:METHOD=AES-128,URI="[a-zA-Z0-9-_\/]+.key",IV=[a-z0-9]+', + '#EXTINF:0.720000,', + 'adaptive_0_250_00004.ts', + '#EXT-X-ENDLIST', + ]) . "/"; + + $this->assertEquals(1, preg_match($pattern, $encryptedPlaylist), "Playlist mismatch:" . PHP_EOL . $encryptedPlaylist); + } + + /** @test */ + public function it_can_set_the_numbers_of_segments_per_key() + { + $this->fakeLocalVideoFile(); + + $lowBitrate = $this->x264()->setKiloBitrate(250); + + $keys = []; + + FFMpeg::open('video.mp4') + ->exportForHLS() + ->setKeyFrameInterval(1) + ->setSegmentLength(1) + ->addFormat($lowBitrate) + ->withRotatingEncryptionKey(function ($filename, $contents) use (&$keys) { + $keys[$filename] = $contents; + }, 2) + ->save('adaptive.m3u8'); + + $this->assertCount(3, $keys); + + $this->assertTrue(Storage::disk('local')->has('adaptive.m3u8')); + $this->assertTrue(Storage::disk('local')->has('adaptive_0_250.m3u8')); + + $encryptedPlaylist = Storage::disk('local')->get('adaptive_0_250.m3u8'); + + $pattern = "/" . implode("\n", [ + '#EXTM3U', + '#EXT-X-VERSION:3', + '#EXT-X-TARGETDURATION:[0-9]+', + '#EXT-X-MEDIA-SEQUENCE:0', + '#EXT-X-PLAYLIST-TYPE:VOD', + '#EXT-X-KEY:METHOD=AES-128,URI="[a-zA-Z0-9-_\/]+.key",IV=[a-z0-9]+', + '#EXTINF:1.[0-9]+,', + 'adaptive_0_250_00000.ts', + '#EXTINF:1.[0-9]+,', + 'adaptive_0_250_00001.ts', + '#EXT-X-KEY:METHOD=AES-128,URI="[a-zA-Z0-9-_\/]+.key",IV=[a-z0-9]+', + '#EXTINF:1.[0-9]+,', + 'adaptive_0_250_00002.ts', + '#EXTINF:1.[0-9]+,', + 'adaptive_0_250_00003.ts', + '#EXT-X-KEY:METHOD=AES-128,URI="[a-zA-Z0-9-_\/]+.key",IV=[a-z0-9]+', + '#EXTINF:0.720000,', + 'adaptive_0_250_00004.ts', + '#EXT-X-ENDLIST', + ]) . "/"; + + $this->assertEquals(1, preg_match($pattern, $encryptedPlaylist), "Playlist mismatch:" . PHP_EOL . $encryptedPlaylist); + } +} diff --git a/tests/HlsExportTest.php b/tests/HlsExportTest.php index 4edb8b3..544bed2 100644 --- a/tests/HlsExportTest.php +++ b/tests/HlsExportTest.php @@ -27,27 +27,27 @@ public function it_can_export_a_single_media_file_into_a_hls_export() ->addFormat($lowBitrate) ->addFormat($midBitrate) ->addFormat($highBitrate) - ->toDisk('memory') + ->toDisk('local') ->save('adaptive.m3u8'); - $this->assertTrue(Storage::disk('memory')->has('adaptive.m3u8')); - $this->assertTrue(Storage::disk('memory')->has('adaptive_0_250.m3u8')); - $this->assertTrue(Storage::disk('memory')->has('adaptive_1_1000.m3u8')); - $this->assertTrue(Storage::disk('memory')->has('adaptive_2_4000.m3u8')); + $this->assertTrue(Storage::disk('local')->has('adaptive.m3u8')); + $this->assertTrue(Storage::disk('local')->has('adaptive_0_250.m3u8')); + $this->assertTrue(Storage::disk('local')->has('adaptive_1_1000.m3u8')); + $this->assertTrue(Storage::disk('local')->has('adaptive_2_4000.m3u8')); - $media = (new MediaOpener)->fromDisk('memory')->open('adaptive_0_250_00000.ts'); + $media = (new MediaOpener)->fromDisk('local')->open('adaptive_0_250_00000.ts'); $this->assertEquals(1920, $media->getVideoStream()->get('width')); $this->assertNotNull($media->getAudioStream()); - $media = (new MediaOpener)->fromDisk('memory')->open('adaptive_1_1000_00000.ts'); + $media = (new MediaOpener)->fromDisk('local')->open('adaptive_1_1000_00000.ts'); $this->assertEquals(1920, $media->getVideoStream()->get('width')); $this->assertNotNull($media->getAudioStream()); - $media = (new MediaOpener)->fromDisk('memory')->open('adaptive_2_4000_00000.ts'); + $media = (new MediaOpener)->fromDisk('local')->open('adaptive_2_4000_00000.ts'); $this->assertEquals(1920, $media->getVideoStream()->get('width')); $this->assertNotNull($media->getAudioStream()); - $playlist = Storage::disk('memory')->get('adaptive.m3u8'); + $playlist = Storage::disk('local')->get('adaptive.m3u8'); $pattern = '/' . implode("\n", [ '#EXTM3U', @@ -60,7 +60,7 @@ public function it_can_export_a_single_media_file_into_a_hls_export() '#EXT-X-ENDLIST', ]) . '/'; - $this->assertEquals(1, preg_match($pattern, $playlist)); + $this->assertEquals(1, preg_match($pattern, $playlist), "Playlist mismatch:" . PHP_EOL . $playlist); } /** @test */ @@ -102,15 +102,15 @@ public function it_can_export_a_single_media_file_into_a_subdirectory_on_a_remot ->exportForHLS() ->addFormat($lowBitrate) ->addFormat($midBitrate) - ->toDisk('memory') + ->toDisk('local') ->save('sub/dir/adaptive.m3u8'); - $this->assertTrue(Storage::disk('memory')->has('sub/dir/adaptive.m3u8')); - $this->assertTrue(Storage::disk('memory')->has('sub/dir/adaptive_0_250.m3u8')); - $this->assertTrue(Storage::disk('memory')->has('sub/dir/adaptive_1_1000.m3u8')); + $this->assertTrue(Storage::disk('local')->has('sub/dir/adaptive.m3u8')); + $this->assertTrue(Storage::disk('local')->has('sub/dir/adaptive_0_250.m3u8')); + $this->assertTrue(Storage::disk('local')->has('sub/dir/adaptive_1_1000.m3u8')); - $masterPlaylist = Storage::disk('memory')->get('sub/dir/adaptive.m3u8'); - $lowPlaylist = Storage::disk('memory')->get('sub/dir/adaptive_0_250.m3u8'); + $masterPlaylist = Storage::disk('local')->get('sub/dir/adaptive.m3u8'); + $lowPlaylist = Storage::disk('local')->get('sub/dir/adaptive_0_250.m3u8'); $this->assertStringNotContainsString('sub/dir', $masterPlaylist); $this->assertStringNotContainsString('sub/dir', $lowPlaylist); @@ -136,16 +136,16 @@ public function it_can_use_a_custom_format_for_the_segment_naming() $segments("N{$name}B{$format->getKiloBitrate()}K{$key}_%02d.ts"); $playlist("N{$name}B{$format->getKiloBitrate()}K{$key}.m3u8"); }) - ->toDisk('memory') + ->toDisk('local') ->save('adaptive.m3u8'); - $this->assertTrue(Storage::disk('memory')->has('adaptive.m3u8')); - $this->assertTrue(Storage::disk('memory')->has('NadaptiveB250K0.m3u8')); - $this->assertTrue(Storage::disk('memory')->has('NadaptiveB250K0_00.ts')); - $this->assertTrue(Storage::disk('memory')->has('NadaptiveB1000K1.m3u8')); - $this->assertTrue(Storage::disk('memory')->has('NadaptiveB1000K1_00.ts')); + $this->assertTrue(Storage::disk('local')->has('adaptive.m3u8')); + $this->assertTrue(Storage::disk('local')->has('NadaptiveB250K0.m3u8')); + $this->assertTrue(Storage::disk('local')->has('NadaptiveB250K0_00.ts')); + $this->assertTrue(Storage::disk('local')->has('NadaptiveB1000K1.m3u8')); + $this->assertTrue(Storage::disk('local')->has('NadaptiveB1000K1_00.ts')); - $playlist = Storage::disk('memory')->get('adaptive.m3u8'); + $playlist = Storage::disk('local')->get('adaptive.m3u8'); $pattern = '/' . implode("\n", [ '#EXTM3U', @@ -208,6 +208,11 @@ public function it_can_export_to_hls_with_legacy_filters_for_each_format() $media = (new MediaOpener)->fromDisk('local')->open('adaptive_3_1500_00000.ts'); $this->assertEquals(1920, $media->getVideoStream()->get('width')); $this->assertNotNull($media->getAudioStream()); + + $playlist = Storage::disk('local')->get('adaptive.m3u8'); + + $this->assertStringContainsString('RESOLUTION=640x360', $playlist); + $this->assertStringContainsString('RESOLUTION=1920x1080', $playlist); } /** @test */ diff --git a/tests/MediaOpenerTest.php b/tests/MediaOpenerTest.php index d188f9f..10c87c5 100755 --- a/tests/MediaOpenerTest.php +++ b/tests/MediaOpenerTest.php @@ -100,7 +100,7 @@ public function it_downloads_a_remote_file_before_opening() TemporaryDirectories::deleteAll(); - $this->assertFileNotExists($tempPath); + $this->assertFalse(file_exists($tempPath)); } /** @test */ @@ -126,7 +126,7 @@ public function it_can_transform_a_media_on_network_object_to_a_media_object() ]); $media = $mediaOnNetwerk->toMedia(function ($ch) { - $this->assertIsResource($ch); + $this->assertNotNull($ch); }); [$width] = getimagesize($media->getLocalPath()); diff --git a/tests/VolumeDetectTest.php b/tests/VolumeDetectTest.php new file mode 100644 index 0000000..cf5c481 --- /dev/null +++ b/tests/VolumeDetectTest.php @@ -0,0 +1,25 @@ +fakeLocalVideoFile(); + + $processOutput = FFMpeg::open('video.mp4') + ->export() + ->addFilter(['-filter:a', 'volumedetect', '-f', 'null']) + ->getProcessOutput(); + + $this->assertIsArray($processOutput->all()); + $this->assertIsArray($processOutput->errors()); + $this->assertIsArray($processOutput->out()); + + $this->assertStringContainsString('Parsed_volumedetect_0', implode('', $processOutput->all())); + } +}