Skip to content

feat: add Laravel Event System integration for media processing #540

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 90 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions config/config.php
Original file line number Diff line number Diff line change
Expand Up @@ -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),
];
20 changes: 20 additions & 0 deletions src/Events/MediaProcessingCompleted.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

namespace ProtoneMedia\LaravelFFMpeg\Events;

use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
use ProtoneMedia\LaravelFFMpeg\Filesystem\MediaCollection;

class MediaProcessingCompleted
{
use Dispatchable, InteractsWithSockets, SerializesModels;

public function __construct(
public MediaCollection $inputMedia,
public ?string $outputPath = null,
public array $metadata = []
) {
}
}
22 changes: 22 additions & 0 deletions src/Events/MediaProcessingFailed.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

namespace ProtoneMedia\LaravelFFMpeg\Events;

use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
use ProtoneMedia\LaravelFFMpeg\Filesystem\MediaCollection;
use Throwable;

class MediaProcessingFailed
{
use Dispatchable, InteractsWithSockets, SerializesModels;

public function __construct(
public MediaCollection $inputMedia,
public Throwable $exception,
public ?string $outputPath = null,
public array $metadata = []
) {
}
}
22 changes: 22 additions & 0 deletions src/Events/MediaProcessingProgress.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

namespace ProtoneMedia\LaravelFFMpeg\Events;

use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
use ProtoneMedia\LaravelFFMpeg\Filesystem\MediaCollection;

class MediaProcessingProgress
{
use Dispatchable, InteractsWithSockets, SerializesModels;

public function __construct(
public MediaCollection $inputMedia,
public float $percentage,
public ?int $remainingSeconds = null,
public ?float $rate = null,
public ?string $outputPath = null
) {
}
}
20 changes: 20 additions & 0 deletions src/Events/MediaProcessingStarted.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

namespace ProtoneMedia\LaravelFFMpeg\Events;

use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
use ProtoneMedia\LaravelFFMpeg\Filesystem\MediaCollection;

class MediaProcessingStarted
{
use Dispatchable, InteractsWithSockets, SerializesModels;

public function __construct(
public MediaCollection $inputMedia,
public ?string $outputPath = null,
public array $metadata = []
) {
}
}
1 change: 1 addition & 0 deletions src/Exporters/HLSExporter.php
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,7 @@ public function getCommand(?string $path = null)
public function save(?string $mainPlaylistPath = null): MediaOpener
{
return $this->prepareSaving($mainPlaylistPath)->pipe(function ($segmentPlaylists) use ($mainPlaylistPath) {
$this->outputPath = $mainPlaylistPath;
$result = parent::save();

$playlist = $this->getPlaylistGenerator()->get(
Expand Down
15 changes: 14 additions & 1 deletion src/Exporters/HasProgressListener.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use Closure;
use Evenement\EventEmitterInterface;
use ProtoneMedia\LaravelFFMpeg\Events\MediaProcessingProgress;

trait HasProgressListener
{
Expand Down Expand Up @@ -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()
);
}
}
});
}
Expand Down
58 changes: 58 additions & 0 deletions src/Exporters/MediaExporter.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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()) {
Expand All @@ -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 {
Expand All @@ -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);
}

Expand All @@ -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();
}

Expand All @@ -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);
}

Expand All @@ -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();
}

Expand Down
Loading
Loading