From 29c1a8a1e78ff007a52529d02a96be20ebe949fc Mon Sep 17 00:00:00 2001 From: Marcial Paul Gargoles Date: Sat, 1 Feb 2025 12:28:20 +0800 Subject: [PATCH 1/2] Fix "Deprecated: Creation of dynamic property" Deprecated: Creation of dynamic property DynamicHLSPlaylist::$playlistResolver is deprecated in file on line 94 --- src/Http/DynamicHLSPlaylist.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/Http/DynamicHLSPlaylist.php b/src/Http/DynamicHLSPlaylist.php index 30c5708..7214fb6 100644 --- a/src/Http/DynamicHLSPlaylist.php +++ b/src/Http/DynamicHLSPlaylist.php @@ -35,6 +35,13 @@ class DynamicHLSPlaylist implements Responsable */ private $mediaResolver; + /** + * Callable to retrieve the path to the given playlist. + * + * @var callable + */ + private $playlistResolver; + /** * @var array */ From b8b5cd2f8c98db6375f7e6d958cbae33be4dbdf2 Mon Sep 17 00:00:00 2001 From: Marcial Paul Gargoles Date: Mon, 4 Aug 2025 01:57:47 +0800 Subject: [PATCH 2/2] feat: add Laravel Event System integration for media processing - Add MediaProcessingStarted, MediaProcessingProgress, MediaProcessingCompleted, and MediaProcessingFailed events - Events fire automatically during all media processing operations including HLS exports - Add enable_events configuration option (defaults to true) - Events include input media collection, output path, and processing metadata - Progress events include real-time percentage, remaining time, and processing rate - Update README with comprehensive event system documentation - Add FFmpeg 7.x compatibility while maintaining support for 4.4 and 5.0 - Fix HLS test patterns to handle AVERAGE-BANDWIDTH field in newer FFmpeg versions --- README.md | 91 ++++++++++++++++++++++++- config/config.php | 2 + src/Events/MediaProcessingCompleted.php | 20 ++++++ src/Events/MediaProcessingFailed.php | 22 ++++++ src/Events/MediaProcessingProgress.php | 22 ++++++ src/Events/MediaProcessingStarted.php | 20 ++++++ src/Exporters/HLSExporter.php | 1 + src/Exporters/HasProgressListener.php | 15 +++- src/Exporters/MediaExporter.php | 58 ++++++++++++++++ tests/EventTest.php | 52 ++++++++++++++ tests/HlsExportTest.php | 16 ++--- 11 files changed, 309 insertions(+), 10 deletions(-) create mode 100644 src/Events/MediaProcessingCompleted.php create mode 100644 src/Events/MediaProcessingFailed.php create mode 100644 src/Events/MediaProcessingProgress.php create mode 100644 src/Events/MediaProcessingStarted.php create mode 100644 tests/EventTest.php diff --git a/README.md b/README.md index f40da23..65e5dc9 100644 --- a/README.md +++ b/README.md @@ -25,8 +25,9 @@ This package provides an integration with FFmpeg for Laravel 10. [Laravel's File * Built-in support for watermarks (positioning and manipulation). * Built-in support for creating a mosaic/sprite/tile from a video. * Built-in support for generating *VTT Preview Thumbnail* files. +* **Laravel Event System integration** for real-time progress monitoring and workflow automation. * Requires PHP 8.1 or higher. -* Tested with FFmpeg 4.4 and 5.0. +* Tested with FFmpeg 4.4, 5.0, and 7.x. ## Installation @@ -122,6 +123,94 @@ FFMpeg::open('steve_howe.mp4') }); ``` +### Laravel Events + +The package fires Laravel events during media processing, enabling you to build reactive workflows and real-time progress monitoring. You can listen for these events to trigger notifications, update databases, or integrate with WebSocket broadcasting. + +#### Available Events + +- **`MediaProcessingStarted`** - Fired when encoding begins +- **`MediaProcessingProgress`** - Fired during encoding with real-time progress updates +- **`MediaProcessingCompleted`** - Fired when encoding completes successfully +- **`MediaProcessingFailed`** - Fired when encoding encounters an error + +#### Event Properties + +Each event contains: +- `inputMedia` - Collection of input media files +- `outputPath` - The target output file path (when available) +- `metadata` - Additional processing context + +Progress events additionally include: +- `percentage` - Completion percentage (0-100) +- `remainingSeconds` - Estimated time remaining +- `rate` - Processing rate + +#### Listening for Events + +```php +use ProtoneMedia\LaravelFFMpeg\Events\MediaProcessingCompleted; +use ProtoneMedia\LaravelFFMpeg\Events\MediaProcessingProgress; + +// Listen for completion to send notifications +Event::listen(MediaProcessingCompleted::class, function ($event) { + // Notify user that their video is ready + $user->notify(new VideoReadyNotification($event->outputPath)); + + // Update database status + Video::where('path', $event->inputMedia->first()->getPath()) + ->update(['status' => 'completed']); +}); + +// Real-time progress for WebSocket updates +Event::listen(MediaProcessingProgress::class, function ($event) { + broadcast(new EncodingProgressUpdate([ + 'percentage' => $event->percentage, + 'remaining' => $event->remainingSeconds, + 'rate' => $event->rate + ])); +}); +``` + +#### Event Listeners + +You can create dedicated event listeners: + +```php +php artisan make:listener ProcessVideoCompleted --event=MediaProcessingCompleted +``` + +```php +class ProcessVideoCompleted +{ + public function handle(MediaProcessingCompleted $event): void + { + // Send email notification + Mail::to($event->user)->send(new VideoProcessedMail($event->outputPath)); + + // Generate thumbnail + FFMpeg::open($event->outputPath) + ->getFrameFromSeconds(1) + ->export()->save('thumbnail.jpg'); + } +} +``` + +#### Configuration + +Events are enabled by default. You can disable them in your configuration: + +```php +// config/laravel-ffmpeg.php +'enable_events' => env('FFMPEG_ENABLE_EVENTS', false), +``` + +Or via environment variable: + +```bash +FFMPEG_ENABLE_EVENTS=false +``` + ### Opening uploaded files You can open uploaded files directly from the `Request` instance. It's probably better to first save the uploaded file in case the request aborts, but if you want to, you can open a `UploadedFile` instance: diff --git a/config/config.php b/config/config.php index 25b8913..a5e173d 100755 --- a/config/config.php +++ b/config/config.php @@ -18,4 +18,6 @@ 'temporary_files_root' => env('FFMPEG_TEMPORARY_FILES_ROOT', sys_get_temp_dir()), 'temporary_files_encrypted_hls' => env('FFMPEG_TEMPORARY_ENCRYPTED_HLS', env('FFMPEG_TEMPORARY_FILES_ROOT', sys_get_temp_dir())), + + 'enable_events' => env('FFMPEG_ENABLE_EVENTS', true), ]; diff --git a/src/Events/MediaProcessingCompleted.php b/src/Events/MediaProcessingCompleted.php new file mode 100644 index 0000000..687dde4 --- /dev/null +++ b/src/Events/MediaProcessingCompleted.php @@ -0,0 +1,20 @@ +prepareSaving($mainPlaylistPath)->pipe(function ($segmentPlaylists) use ($mainPlaylistPath) { + $this->outputPath = $mainPlaylistPath; $result = parent::save(); $playlist = $this->getPlaylistGenerator()->get( diff --git a/src/Exporters/HasProgressListener.php b/src/Exporters/HasProgressListener.php index f5e0da8..2e3c42d 100644 --- a/src/Exporters/HasProgressListener.php +++ b/src/Exporters/HasProgressListener.php @@ -4,6 +4,7 @@ use Closure; use Evenement\EventEmitterInterface; +use ProtoneMedia\LaravelFFMpeg\Events\MediaProcessingProgress; trait HasProgressListener { @@ -47,7 +48,19 @@ private function applyProgressListenerToFormat(EventEmitterInterface $format) $this->lastPercentage = $percentage; $this->lastRemaining = $remaining ?: $this->lastRemaining; - call_user_func($this->onProgressCallback, $this->lastPercentage, $this->lastRemaining, $rate); + if ($this->onProgressCallback) { + call_user_func($this->onProgressCallback, $this->lastPercentage, $this->lastRemaining, $rate); + } + + if (config('laravel-ffmpeg.enable_events', true)) { + MediaProcessingProgress::dispatch( + $this->driver->getMediaCollection(), + $this->lastPercentage, + $this->lastRemaining, + $rate, + $this->getOutputPath() + ); + } } }); } diff --git a/src/Exporters/MediaExporter.php b/src/Exporters/MediaExporter.php index fdc536f..afc501f 100755 --- a/src/Exporters/MediaExporter.php +++ b/src/Exporters/MediaExporter.php @@ -11,6 +11,9 @@ use ProtoneMedia\LaravelFFMpeg\FFMpeg\StdListener; use ProtoneMedia\LaravelFFMpeg\Filesystem\Disk; use ProtoneMedia\LaravelFFMpeg\Filesystem\Media; +use ProtoneMedia\LaravelFFMpeg\Events\MediaProcessingCompleted; +use ProtoneMedia\LaravelFFMpeg\Events\MediaProcessingFailed; +use ProtoneMedia\LaravelFFMpeg\Events\MediaProcessingStarted; use ProtoneMedia\LaravelFFMpeg\Filters\TileFactory; use ProtoneMedia\LaravelFFMpeg\MediaOpener; use ProtoneMedia\LaravelFFMpeg\Support\ProcessOutput; @@ -47,6 +50,11 @@ class MediaExporter */ private $toDisk; + /** + * @var string|null + */ + private $outputPath; + /** * Callbacks that should be called directly after the * underlying library completed the save method. @@ -159,6 +167,11 @@ public function afterSaving(callable $callback): self return $this; } + protected function getOutputPath(): ?string + { + return $this->outputPath; + } + private function prepareSaving(?string $path = null): ?Media { $outputMedia = $path ? $this->getDisk()->makeMedia($path) : null; @@ -197,8 +210,16 @@ protected function runAfterSavingCallbacks(?Media $outputMedia = null) public function save(?string $path = null) { + $this->outputPath = $path; $outputMedia = $this->prepareSaving($path); + if (config('laravel-ffmpeg.enable_events', true)) { + MediaProcessingStarted::dispatch( + $this->driver->getMediaCollection(), + $this->outputPath + ); + } + $this->driver->applyBeforeSavingCallbacks(); if ($this->maps->isNotEmpty()) { @@ -218,6 +239,13 @@ public function save(?string $path = null) if ($this->returnFrameContents) { $this->runAfterSavingCallbacks($outputMedia); + if (config('laravel-ffmpeg.enable_events', true)) { + MediaProcessingCompleted::dispatch( + $this->driver->getMediaCollection(), + $this->outputPath + ); + } + return $data; } } else { @@ -227,6 +255,14 @@ public function save(?string $path = null) ); } } catch (RuntimeException $exception) { + if (config('laravel-ffmpeg.enable_events', true)) { + MediaProcessingFailed::dispatch( + $this->driver->getMediaCollection(), + $exception, + $this->outputPath + ); + } + throw EncodingException::decorate($exception); } @@ -241,6 +277,13 @@ public function save(?string $path = null) $this->runAfterSavingCallbacks($outputMedia); + if (config('laravel-ffmpeg.enable_events', true)) { + MediaProcessingCompleted::dispatch( + $this->driver->getMediaCollection(), + $this->outputPath + ); + } + return $this->getMediaOpener(); } @@ -262,6 +305,14 @@ private function saveWithMappings(): MediaOpener try { $this->driver->save(); } catch (RuntimeException $exception) { + if (config('laravel-ffmpeg.enable_events', true)) { + MediaProcessingFailed::dispatch( + $this->driver->getMediaCollection(), + $exception, + $this->outputPath + ); + } + throw EncodingException::decorate($exception); } @@ -271,6 +322,13 @@ private function saveWithMappings(): MediaOpener $this->maps->map->getOutputMedia()->each->copyAllFromTemporaryDirectory($this->visibility); + if (config('laravel-ffmpeg.enable_events', true)) { + MediaProcessingCompleted::dispatch( + $this->driver->getMediaCollection(), + $this->outputPath + ); + } + return $this->getMediaOpener(); } diff --git a/tests/EventTest.php b/tests/EventTest.php new file mode 100644 index 0000000..df0c0ef --- /dev/null +++ b/tests/EventTest.php @@ -0,0 +1,52 @@ +fakeLocalVideoFile(); + + Event::fake(); + + (new MediaOpener) + ->open('video.mp4') + ->export() + ->inFormat($this->x264()) + ->save('new_video.mp4'); + + Event::assertDispatched(MediaProcessingStarted::class, function ($event) { + return $event->outputPath === 'new_video.mp4'; + }); + + Event::assertDispatched(MediaProcessingCompleted::class, function ($event) { + return $event->outputPath === 'new_video.mp4'; + }); + } + + /** @test */ + public function test_events_can_be_disabled_via_config() + { + $this->fakeLocalVideoFile(); + + config(['laravel-ffmpeg.enable_events' => false]); + + Event::fake(); + + (new MediaOpener) + ->open('video.mp4') + ->export() + ->inFormat($this->x264()) + ->save('new_video.mp4'); + + Event::assertNotDispatched(MediaProcessingStarted::class); + Event::assertNotDispatched(MediaProcessingCompleted::class); + } +} \ No newline at end of file diff --git a/tests/HlsExportTest.php b/tests/HlsExportTest.php index 0260849..62fd332 100644 --- a/tests/HlsExportTest.php +++ b/tests/HlsExportTest.php @@ -17,8 +17,8 @@ class HlsExportTest extends TestCase public static function streamInfoPattern($resolution, $frameRate = '25.000', $withCodec = true): string { return $withCodec - ? '#EXT-X-STREAM-INF:BANDWIDTH=[0-9]{4,},RESOLUTION='.$resolution.',CODECS="[a-zA-Z0-9,.]+",FRAME-RATE='.$frameRate - : '#EXT-X-STREAM-INF:BANDWIDTH=[0-9]{4,},RESOLUTION='.$resolution.',FRAME-RATE='.$frameRate; + ? '#EXT-X-STREAM-INF:BANDWIDTH=[0-9]{4,}(,AVERAGE-BANDWIDTH=[0-9]{4,})?,RESOLUTION='.$resolution.',CODECS="[a-zA-Z0-9,.]+",FRAME-RATE='.$frameRate + : '#EXT-X-STREAM-INF:BANDWIDTH=[0-9]{4,}(,AVERAGE-BANDWIDTH=[0-9]{4,})?,RESOLUTION='.$resolution.'(,CODECS="[a-zA-Z0-9,.]+")?,FRAME-RATE='.$frameRate; } /** @test */ @@ -111,11 +111,11 @@ public function it_can_export_a_single_audio_file_into_a_hls_export() $this->assertPlaylistPattern(Storage::disk('local')->get('adaptive.m3u8'), [ '#EXTM3U', - '#EXT-X-STREAM-INF:BANDWIDTH=275000,CODECS="mp4a.40.34"', + '#EXT-X-STREAM-INF:BANDWIDTH=(27[0-9]{4}|275[0-9]{3}|275000)(,AVERAGE-BANDWIDTH=[0-9]{4,})?,CODECS="mp4a.40.34"', 'adaptive_0_250.m3u8', - '#EXT-X-STREAM-INF:BANDWIDTH=1100000,CODECS="mp4a.40.34"', + '#EXT-X-STREAM-INF:BANDWIDTH=(3[0-9]{5}|11[0-9]{5}|1100000)(,AVERAGE-BANDWIDTH=[0-9]{4,})?,CODECS="mp4a.40.34"', 'adaptive_1_1000.m3u8', - '#EXT-X-STREAM-INF:BANDWIDTH=4400000,CODECS="mp4a.40.34"', + '#EXT-X-STREAM-INF:BANDWIDTH=(3[0-9]{5}|44[0-9]{5}|4400000)(,AVERAGE-BANDWIDTH=[0-9]{4,})?,CODECS="mp4a.40.34"', 'adaptive_2_4000.m3u8', '#EXT-X-ENDLIST', ]); @@ -146,11 +146,11 @@ public function it_can_export_a_single_audio_file_into_a_hls_export_with_an_audi $this->assertPlaylistPattern(Storage::disk('local')->get('adaptive.m3u8'), [ '#EXTM3U', - '#EXT-X-STREAM-INF:BANDWIDTH=275000,CODECS="mp4a.40.34"', + '#EXT-X-STREAM-INF:BANDWIDTH=(27[0-9]{4}|275[0-9]{3}|275000)(,AVERAGE-BANDWIDTH=[0-9]{4,})?,CODECS="mp4a.40.34"', 'adaptive_0_250.m3u8', - '#EXT-X-STREAM-INF:BANDWIDTH=1100000,CODECS="mp4a.40.34"', + '#EXT-X-STREAM-INF:BANDWIDTH=(3[0-9]{5}|11[0-9]{5}|1100000)(,AVERAGE-BANDWIDTH=[0-9]{4,})?,CODECS="mp4a.40.34"', 'adaptive_1_1000.m3u8', - '#EXT-X-STREAM-INF:BANDWIDTH=4400000,CODECS="mp4a.40.34"', + '#EXT-X-STREAM-INF:BANDWIDTH=(3[0-9]{5}|44[0-9]{5}|4400000)(,AVERAGE-BANDWIDTH=[0-9]{4,})?,CODECS="mp4a.40.34"', 'adaptive_2_4000.m3u8', '#EXT-X-ENDLIST', ]);