folklore / laravel-eclair
Laravel client for Eclair, the self-hosted media processing server
Requires
- php: ^8.3
- guzzlehttp/guzzle: ^7.8
- illuminate/cache: ^12.0|^13.0
- illuminate/console: ^12.0|^13.0
- illuminate/contracts: ^12.0|^13.0
- illuminate/filesystem: ^12.0|^13.0
- illuminate/http: ^12.0|^13.0
- illuminate/routing: ^12.0|^13.0
- illuminate/support: ^12.0|^13.0
- nesbot/carbon: ^3.8
Requires (Dev)
- folklore/laravel-mediatheque: ^2.0.0-alpha.11
- larastan/larastan: ^3.0
- laravel/pint: ^1.18
- opis/json-schema: ^2.4
- orchestra/testbench: ^10.0|^11.0
- phpunit/phpunit: ^11.5|^12.0
Suggests
- folklore/laravel-mediatheque: Run the pipelines of laravel-mediatheque 2 (2.0.0-alpha.11 or newer) on Eclair, with the eclair transcoder and prober
Provides
None
Conflicts
- folklore/laravel-mediatheque: >=2.0.0-alpha.1 <2.0.0-alpha.11
Replaces
None
This package is auto-updated.
Last update: 2026-10-30 17:44:49 UTC
README
The Laravel client of Eclair, a self-hosted server that converts media and extracts their metadata. Send jobs to Eclair, receive its webhooks as events, and let it read and write your files, on S3 or on your own disks.
- A client of Eclair's
/v1API: jobs, probes, uploads, presets and operations. - A webhook route that checks signatures, handles each delivery once and dispatches events.
- An endpoint for
httpstorages, so Eclair can read inputs from and write outputs to a local disk. - Downloads of outputs from Eclair's temporary storage to a disk.
Eclair::fake(), an in-memory Eclair for your tests.php artisan eclair:status, to check the configuration.- The
eclairtranscoder and prober oflaravel-mediatheque2.
Status: in development, before 1.0. Requires PHP 8.3+ and Laravel 12+.
Installation
composer require folklore/laravel-eclair php artisan vendor:publish --tag=eclair-config
ECLAIR_URL=https://eclair.example.com ECLAIR_KEY=ek_... ECLAIR_WEBHOOK_SECRET=whsec_...
The key and the webhook secret are the project's, created by Eclair's operator (eclair projects:create, eclair keys:create and eclair storages:set).
Then map the project's storages in Eclair to your disks, in config/eclair.php:
'storages' => [ 'media' => ['disk' => 's3'], // an s3 storage on the same bucket 'files' => ['disk' => 'local', 'serve' => true], // an http storage served by this app ],
Paths are the same on both sides. An s3 storage in Eclair has the same bucket as the disk, and its prefix is the disk's root.
php artisan eclair:status
Jobs
A job runs named tasks on one input and writes their outputs. The request is the protocol's (POST /v1/jobs):
use Folklore\Eclair\Facades\Eclair; $job = Eclair::jobs()->create([ 'input' => Eclair::input('s3', 'uploads/interview.mov'), 'output' => Eclair::output('s3', 'videos/42/'), 'preset' => 'mediatheque/video', 'tasks' => [ 'poster' => ['op' => 'video.poster', 'from' => 'h264'], ], 'metadata' => ['mediaId' => 42], ], idempotencyKey: 'media-42-v1'); $job->id; // job_01JA2Y8C6W4N5V7TQ3H9XK0B2M $job->status; // JobStatus::Pending
With an idempotency key, a retry of the same request returns the first job: retry freely after an error.
$job = Eclair::jobs()->find($id); $job->ended(); // completed, failed or canceled $job->task('h264')->status; // TaskStatus::Completed $job->task('h264')->file()->path; // videos/42/h264.mp4 $job->task('probe')->metadata; // ['kind' => 'video', 'duration' => 184.2, …] $job->task('hls')->error?->code; // ErrorCode::DependencyFailed $job->toArray(); // the whole response Eclair::jobs()->list(['status' => 'failed', 'metadata' => ['mediaId' => 42]]); Eclair::jobs()->all(['createdAfter' => now()->subDay()]); // a LazyCollection over every page Eclair::jobs()->cancel($id); Eclair::jobs()->retry($id); Eclair::jobs()->files($id); // every file, segments included
Set ECLAIR_WEBHOOK_URL to send a webhook URL with every job. Without it, Eclair calls the project's webhook.
Probe
The metadata of a file, in the response:
$result = Eclair::probe(Eclair::input('s3', 'uploads/interview.mov'), timeout: 10); $result = Eclair::probeFile($request->file('video')); if ($result->pending()) { // Longer than the timeout: the metadata comes with this job and its webhook. $result->job->id; } else { $result->metadata; // ['kind' => 'video', 'mime' => 'video/quicktime', 'width' => 1920, …] }
checksum: true adds a SHA-256 of the whole file, and thumbhash: true a ThumbHash placeholder of an image.
Uploads
For files Eclair can't read, send them to its temporary storage:
$upload = Eclair::uploads()->create($request->file('video'), probe: true); Eclair::jobs()->create(['input' => $upload->input(), 'preset' => 'web-video', 'output' => ['storage' => 'eclair']]);
Files are sent in one request, within the project's direct upload limit.
Webhooks
Eclair calls POST /eclair/webhook when a job ends. The route checks the signature with ECLAIR_WEBHOOK_SECRET, refuses old timestamps, handles each delivery once, and dispatches:
| Event | When |
|---|---|
Folklore\Eclair\Events\JobCompleted |
job.completed |
Folklore\Eclair\Events\JobFailed |
job.failed |
Folklore\Eclair\Events\JobCanceled |
job.canceled |
Folklore\Eclair\Events\TaskCompleted |
task.completed, for projects that subscribe to it |
Folklore\Eclair\Events\TaskProgressed |
task.progress, for projects that subscribe to it. Eclair attempts it once, so the route doesn't remember it, and it can arrive out of order: keep the highest progress, and ignore it for a task that has ended |
Folklore\Eclair\Events\WebhookReceived |
Every verified webhook, first, including events this version doesn't know |
use Folklore\Eclair\Events\JobCompleted; Event::listen(function (JobCompleted $event) { $media = Media::find($event->job->metadata['mediaId']); // … });
A listener that throws makes the request fail, and Eclair delivers the webhook again later, for up to 24 hours. Deliveries are remembered in the cache for 48 hours: use a store that every server shares and that supports locks (ECLAIR_WEBHOOK_CACHE). Eclair can still send an older snapshot of a job after a newer one: compare before you overwrite.
The route has no middleware by default (no session, no CSRF). Change its path or middleware in eclair.webhook, or turn it off with eclair.webhook.route.
Files on your own disks
Eclair reads inputs and writes outputs on the project's storages. Three ways, depending on where your files are:
-
S3-compatible disks: an
s3storage in Eclair on the same bucket. Nothing to serve. -
Local disks, served: turn on the storage endpoint (
eclair.storage_endpoint.route) and mark the storageserve. Register anhttpstorage in Eclair with the URL{APP_URL}/eclair/storages/{name}. Eclair reads with signedGETrequests and writes with signedPUTrequests, which the endpoint checks before touching the disk.max_sizelimits a received file. -
Pull from the temporary storage: write the outputs to Eclair's temporary storage (
'output' => ['storage' => 'eclair']), then copy them to a disk:Event::listen(function (JobCompleted $event) { Eclair::downloadFiles($event->job, 'local'); // every file, at its own path Eclair::download($event->job->task('poster')->file(), 'local', 'posters/42.jpg'); });
laravel-mediatheque 2
With folklore/laravel-mediatheque 2.0.0-alpha.11 or newer, a pipeline can run on Eclair instead of the application's own ffmpeg. Name the eclair transcoder in it:
// config/mediatheque.php 'pipelines' => [ 'video' => [ 'transcoder' => 'eclair', 'renditions' => [ 'h264' => ['op' => 'video.transcode', 'max_width' => 1920], 'poster' => ['op' => 'video.poster', 'from' => 'h264', 'role' => 'poster'], 'thumbnails' => ['op' => 'video.thumbnails', 'count' => 5, 'role' => 'thumbnail'], ], ], ],
Renditions stay those of the ffmpeg driver: same operations, same options and same defaults, so switching a pipeline is configuration. A run becomes one Eclair job, with a task per rendition, which writes its files under the run's path prefix on the run's disk. The webhooks report each rendition to the mediatheque as it completes.
What it needs:
- The disks of the originals and renditions in
eclair.storages, mapped to storages of the Eclair project. With an S3-compatible disk, the storage is on the same bucket, with the disk'srootas prefix. - The webhook route, reachable by Eclair, and the project's
taskWebhookssetting on (eclair projects:update <project> --set taskWebhooks=true), so each rendition is recorded when it completes instead of when the whole run ends. - The operations of the server: Eclair's built-in ones. Add the server's others (plugins) to
eclair.mediatheque.operations. - To show how far each rendition is while it runs, the project's
progressWebhookssetting on (eclair projects:update <project> --set progressWebhooks=true) andECLAIR_MEDIATHEQUE_PROGRESS=true. Thetask.progresswebhooks record the progress, at most everyprogressWebhookIntervalmilliseconds (5000 by default), and the transcoder tells the mediatheque that progress is pushed. Without them, a rendition only moves when it completes.
ECLAIR_MEDIATHEQUE_PROBER=true probes the media with Eclair too, so the application's servers need neither ffprobe nor ImageMagick. Images get their ThumbHash placeholder (thumbhash), as with the mediatheque's local prober, from an Eclair server that has it (urbania-media/eclair#65). A probe longer than eclair.mediatheque.probe_timeout seconds completes with its job's webhook.
How it maps (decision 0004):
- Options are renamed for Eclair (
max_width→maxWidth,crf→quality,'128k'→128), and theffmpegdriver's defaults are sent where Eclair's differ (the waveform's size, the thumbnails' times, the poster's time…). Options of Eclair's own (scenes,in_middle…) pass through, and so domax_bitrateandpassthroughofvideo.transcode, which both drivers know: withpassthrough: true, a web-ready H.264 upload is copied instead of encoded, and the file's metadata says so (videoandaudio:copiedorencoded).passthroughneeds an Eclair whosevideo.transcodeknows it. - A rendition whose
whendoesn't match the probed media is skipped at submit time, with those that read from it. - The operations that write images (
video.poster,video.thumbnails,audio.waveform,document.thumbnails,image.convert,image.thumbnail) ask Eclair for the ThumbHash placeholder of each file, which is kept in the file's metadata (thumbhash), as theffmpegdriver keeps it from its probe. It needs an Eclair whose operations know thethumbhashoption (urbania-media/eclair#74); a rendition can turn it off withthumbhash: false. - Files of a public run are made public once written: Eclair writes them private.
cancel()cancels the job, andrefresh()reads it again, for a webhook that never came.
Testing
Eclair::fake() replaces Eclair with an in-memory server. The client sends its requests through Laravel's HTTP client as usual, and the fake answers them as Eclair would, so Http::assertSent() and Http::preventStrayRequests() keep working.
use Folklore\Eclair\Facades\Eclair; use Folklore\Eclair\Testing\InteractsWithEclair; class VideoTest extends TestCase { use InteractsWithEclair; public function test_a_video_is_transcoded(): void { $eclair = Eclair::fake()->preset('mediatheque/video', ['h264' => ['op' => 'video.transcode']]); $this->post('/videos', ['file' => UploadedFile::fake()->create('a.mov')]); $eclair->assertJobCreated(fn (array $request) => $request['preset'] === 'mediatheque/video'); $job = $eclair->completeJob($eclair->lastJob(), [ 'h264' => ['files' => [['name' => 'h264', 'path' => 'videos/1/h264.mp4', 'mime' => 'video/mp4', 'size' => 1024]]], ]); $this->postEclairWebhook($job)->assertNoContent(); // …assert what your listeners did } }
- Jobs stay pending until the test calls
completeJob()orfailJob().failJob($job, 'h264', 'timeout')also fails the tasks that depend on it withdependency_failed. - A file with
contentsincompleteJob()is in the temporary storage:Eclair::download()reads it. completeTask($job, 'h264', $outputs)completes one task while the job keeps running, as withtask.completedwebhooks.progressTask($job, 'h264', 0.4)sets a task running at that progress, as withtask.progresswebhooks.probeReturns($metadata)sets what probes return, andprobeReturns([], pending: true)makes them answer with a job.- Assertions:
assertJobCreated(),assertJobNotCreated(),assertNothingCreated(),assertJobCount(),assertJobCanceled(),assertProbed(),assertUploaded(),assertPresetSaved(). postEclairWebhook($job, $event = null, $task = null)sends a signed webhook to your route.
Versions
| laravel-eclair | Eclair protocol | PHP | Laravel | laravel-mediatheque |
|---|---|---|---|---|
| main (0.x) | /v1 |
8.3+ | 12, 13 | 2.0.0-alpha.11+ (optional) |
License
MIT. See LICENSE.