Webアプリケーションでユーザーから動画を受け取り、HLS形式へ変換する場合、FFmpegをHTTPリクエスト内で直接実行するのは避けたいところです。
例えば、次のような処理です。
Upload
↓
FFmpeg
↓
HLS生成
↓
Response
数秒で終わる処理なら問題にならないかもしれません。
しかし、1GBを超える動画や長時間動画では、FFmpeg処理に数分以上かかる可能性があります。
そこで今回はLaravel Queueを使って、
Upload
↓
DBへJob登録
↓
Queueへ投入
↓
すぐResponse
--- Background ---
Queue Worker
↓
FFmpeg
↓
HLS生成
↓
ready
という構成を考えてみます。
今回作る構成
全体像は次のようになります。
Client
|
| Upload
v
Laravel Application
|
+---- Save Original
|
+---- Create DB Record
|
+---- Dispatch Job
|
v
Queue Worker
|
v
FFmpeg
|
v
HLS Output
|
v
Update Status
ポイントは、動画変換をWebリクエストから分離することです。
動画Jobの状態を管理する
まず、動画変換の状態をDBで管理します。
例えば以下のような状態を用意します。
pending
processing
ready
failed
migrationの簡単な例です。
Schema::create('video_jobs', function (Blueprint $table) {
$table->id();
$table->string('source_path');
$table->string('output_path')->nullable();
$table->string('status')
->default('pending');
$table->unsignedTinyInteger('progress')
->default(0);
$table->unsignedInteger('attempts')
->default(0);
$table->text('error_message')
->nullable();
$table->timestamps();
});
これで、
どの動画を処理しているか
現在の状態
進捗
失敗理由
を確認できます。
Upload Controller
次に動画を受け取ります。
簡略化すると次のようになります。
public function store(Request $request)
{
$request->validate([
'video' => [
'required',
'file',
'mimetypes:video/mp4,video/quicktime'
],
]);
$path = $request
->file('video')
->store('videos/originals');
$videoJob = VideoJob::create([
'source_path' => $path,
'status' => 'pending',
]);
ProcessVideo::dispatch($videoJob->id);
return response()->json([
'id' => $videoJob->id,
'status' => 'pending',
], 202);
}
ここでは動画変換を実行していません。
重要なのは、
ProcessVideo::dispatch($videoJob->id);
だけ実行してHTTP Responseを返すことです。
Queue Jobを作る
LaravelでJobを作ります。
php artisan make:job ProcessVideo
Jobでは動画IDだけ受け取るようにします。
class ProcessVideo implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public function __construct(
public int $videoJobId
) {
}
public function handle(): void
{
$video = VideoJob::findOrFail(
$this->videoJobId
);
// Video processing
}
}
ModelそのものではなくIDを渡して、Worker側で最新レコードを取得する構成にしています。
processingへ変更する
FFmpeg開始前に状態を更新します。
$video->update([
'status' => 'processing',
'attempts' => $video->attempts + 1,
'error_message' => null,
]);
これで管理画面などから、
pending → processing
への変化を確認できます。
FFmpegでHLSへ変換する
非常に単純なFFmpegコマンドなら次のようになります。
ffmpeg \
-i input.mp4 \
-c:v libx264 \
-c:a aac \
-hls_time 6 \
-hls_playlist_type vod \
output.m3u8
出力は例えば、
video-101/
├── output.m3u8
├── output0.ts
├── output1.ts
├── output2.ts
└── ...
のようになります。
ただし、LaravelからShell commandを作る場合、ユーザー入力をそのまま文字列連結するのは避けるべきです。
Symfony Processを利用する
LaravelプロジェクトではSymfony Processを利用できます。
use Symfony\Component\Process\Process;
例えば、
$process = new Process([
'ffmpeg',
'-y',
'-i',
$inputPath,
'-c:v',
'libx264',
'-c:a',
'aac',
'-hls_time',
'6',
'-hls_playlist_type',
'vod',
$outputPath,
]);
$process->setTimeout(3600);
$process->run();
配列形式で引数を渡すことで、単純なShell文字列連結より安全に扱いやすくなります。
FFmpegの成功を確認する
処理終了後は必ずexit statusを確認します。
if (! $process->isSuccessful()) {
throw new RuntimeException(
$process->getErrorOutput()
);
}
成功したら、
$video->update([
'status' => 'ready',
'progress' => 100,
'output_path' => $relativeOutputPath,
]);
とします。
状態は、
pending
↓
processing
↓
ready
になります。
エラー時はfailedにする
動画変換は必ず成功するとは限りません。
例えば、
- 壊れた動画
- unsupported codec
- disk不足
- FFmpeg crash
- timeout
- memory不足
などがあります。
そのため例外処理が必要です。
try {
$process->run();
if (! $process->isSuccessful()) {
throw new RuntimeException(
$process->getErrorOutput()
);
}
$video->update([
'status' => 'ready',
'progress' => 100,
]);
} catch (Throwable $e) {
$video->update([
'status' => 'failed',
'error_message' => mb_substr(
$e->getMessage(),
0,
5000
),
]);
throw $e;
}
ログも残しておくとデバッグしやすくなります。
Laravel QueueのRetry
動画変換では一時的な失敗も考えられます。
Job側で、
public int $tries = 3;
を設定できます。
さらにbackoffも設定できます。
public function backoff(): array
{
return [
60,
300,
900,
];
}
つまり、
1回目失敗
↓
60秒
2回目失敗
↓
300秒
3回目
のようなRetry戦略を作れます。
ただし、壊れた動画のような永久エラーを何度も再処理しても意味がありません。
エラー種類によってRetry可能か判断する仕組みがあるとより良いです。
Queue Workerを分離する
WebサーバーとFFmpeg Workerを同じプロセスとして考えないことも重要です。
例えば、
Server
├── nginx
├── PHP-FPM
└── Queue Worker
└── FFmpeg
でも動作します。
しかし動画処理が増えてきたら、
Web Server
├── nginx
└── PHP-FPM
Worker Server
├── Queue Worker 1
├── Queue Worker 2
└── FFmpeg
のように分離できます。
これによりFFmpegがCPUを大量消費してもWebリクエストへの影響を減らしやすくなります。
Worker数を増やしすぎない
ここは非常に重要です。
CPUが8 coreだからといって、
8 workers
=
8 FFmpeg processes
が最適とは限りません。
FFmpeg自体が複数CPU threadを利用する可能性があります。
例えば、
Worker 1 → FFmpeg → CPU
Worker 2 → FFmpeg → CPU
Worker 3 → FFmpeg → CPU
Worker 4 → FFmpeg → CPU
と同時実行するとCPU使用率が急上昇します。
最初は少ないconcurrencyから始め、CPU、load average、変換時間を計測して調整するほうが安全です。
FFprobeで動画を事前チェックする
FFmpegを実行する前にFFprobeで動画情報を取得することもできます。
ffprobe \
-v quiet \
-print_format json \
-show_format \
-show_streams \
input.mp4
JSON形式で、
{
"streams": [],
"format": {}
}
が返ります。
ここから、
duration
codec
width
height
bitrate
audio
などを確認できます。
解像度によって処理を変える
例えば入力動画が720pしかない場合、
1080p
720p
480p
を作る必要はありません。
720p動画を1080pへupscaleしても、元の画質以上の情報が増えるわけではありません。
例えば、
Input 2160p
→ 1080p
→ 720p
→ 480p
Input 1080p
→ 1080p
→ 720p
→ 480p
Input 720p
→ 720p
→ 480p
というルールを作れます。
Adaptive Bitrate HLS
本格的な動画配信では複数画質を作ることがあります。
構成例:
video-101/
├── master.m3u8
│
├── 1080p/
│ ├── index.m3u8
│ └── segments...
│
├── 720p/
│ ├── index.m3u8
│ └── segments...
│
└── 480p/
├── index.m3u8
└── segments...
master.m3u8から各画質を参照します。
ネットワーク状態に応じてPlayerが適切なbitrateを選択できるようになります。
元動画をどうするか
HLS生成後にoriginal MP4を残すかどうかも設計上のポイントです。
残す場合:
Original
+
HLS
メリットは、将来別設定で再encodeできることです。
デメリットはstorage使用量が増えることです。
削除する場合:
Original
↓
Encode
↓
HLS
↓
Delete Original
storageは節約できますが、再変換用の高品質sourceがなくなります。
サービス要件に合わせて決める必要があります。
一時ファイルを必ずCleanupする
動画処理ではtemporary filesが大量に発生します。
例えば、
/tmp/video-101/
を作った場合、成功・失敗に関係なく削除できるようにします。
PHPならfinallyを利用できます。
try {
// encode
} catch (Throwable $e) {
throw $e;
} finally {
$this->cleanupTemporaryFiles();
}
失敗したJobがtemporary diskを埋め尽くす問題を防ぎやすくなります。
Monitoring
本番環境では最低限以下を監視したいところです。
queue_wait_time
encoding_time
failed_jobs
CPU usage
memory usage
disk usage
output_size
例えばQueue待ち時間が、
30 sec
↓
5 min
↓
30 min
と増えているなら、Worker capacityが不足している可能性があります。
一方、CPUが常に100%なら、単純にWorkerを追加するのは逆効果かもしれません。
最終的な構成
ここまでをまとめると、
Client
|
v
Laravel Upload
|
+---------+---------+
| |
v v
Original File Database
|
v
Queue Job
|
v
Worker Server
|
v
FFprobe
|
v
FFmpeg
|
v
HLS Output
|
v
ready status
という構成になります。
まとめ
Laravelで動画変換を実装するときに重要なのは、FFmpegのコマンドそのものだけではありません。
むしろ、
- HTTP Requestと動画変換を分離する
- Queueで非同期処理する
- Job statusをDBで管理する
- Retryを設計する
- FFmpeg失敗を検知する
- Worker concurrencyを制御する
- temporary filesをcleanupする
- CPUやQueueをmonitoringする
といった周辺設計のほうが、本番運用では重要になります。
最初は、
Upload
↓
Queue
↓
FFmpeg
↓
HLS
というシンプルな構成から始め、動画数やトラフィックが増えた段階でWorker分離やAdaptive Bitrate Streamingを追加していくのが扱いやすいと思います。