Hyperf方案 视频转码任务
·
用 php-ffmpeg/php-ffmpeg1+ hyperf/async-queue,下面完整实现:
---
先搞懂视频转码流程
用户上传原始视频(可能是MOV/AVI/MKV)
↓
丢进队列(异步,不阻塞用户)
↓
Worker进程拿到任务
├─ 转 MP4(通用播放)
├─ 转 HLS(m3u8,流媒体/边下边播)
├─ 截缩略图
└─ 更新进度到Redis
↓
前端轮询进度,完成后展示视频
---
安装
# PHP库
composer require php-ffmpeg/php-ffmpeg hyperf/async-queue hyperf/redis
# 系统需要安装FFmpeg(Ubuntu)
apt install ffmpeg
---
目录结构
app/
Job/
TranscodeJob.php # 异步队列任务
Service/
VideoService.php # 上传+派发任务
TranscodeService.php # 转码核心逻辑
Controller/
VideoController.php # 上传/查进度接口
config/autoload/
async_queue.php # 队列配置
transcode.php # 转码配置
---
1. 配置
// config/autoload/transcode.php
return [
'ffmpeg_bin' => env('FFMPEG_BIN', '/usr/bin/ffmpeg'),
'ffprobe_bin' => env('FFPROBE_BIN', '/usr/bin/ffprobe'),
// 转码输出目录
'output_path' => BASE_PATH . '/storage/videos',
// 对外访问的基础URL
'base_url' => env('VIDEO_BASE_URL', 'https://yourdomain.com/videos'),
// 转码预设(分辨率 + 码率)
'presets' => [
'1080p' => ['width' => 1920, 'height' => 1080, 'bitrate' => 4000],
'720p' => ['width' => 1280, 'height' => 720, 'bitrate' => 2000],
'480p' => ['width' => 854, 'height' => 480, 'bitrate' => 1000],
],
];
// config/autoload/async_queue.php
return [
'transcode' => [ // 专门给转码用的队列
'driver' => \Hyperf\AsyncQueue\Driver\RedisDriver::class,
'redis' => ['pool' => 'default'],
'channel' => 'transcode',
'timeout' => 3600, // 转码最长1小时
'retry_seconds' => 60,
'max_attempts' => 2,
'processes' => 3, // 同时跑3个转码Worker
],
];
---
2. 转码核心服务
// app/Service/TranscodeService.php
<?php
namespace App\Service;
use FFMpeg\FFMpeg;
use FFMpeg\FFProbe;
use FFMpeg\Format\Video\X264;
use FFMpeg\Format\Video\WebM;
use FFMpeg\Coordinate\{Dimension, TimeCode};
use Hyperf\Redis\Redis;
class TranscodeService
{
private FFMpeg $ffmpeg;
private Redis $redis;
public function __construct(Redis $redis)
{
$this->redis = $redis;
$this->ffmpeg = FFMpeg::create([
'ffmpeg.binaries' => config('transcode.ffmpeg_bin'),
'ffprobe.binaries' => config('transcode.ffprobe_bin'),
'timeout' => 3600,
'ffmpeg.threads' => 4, // 用4个CPU线程转码
]);
}
/**
* 主转码入口:转MP4 + 截图 + 生成HLS
*/
public function transcode(string $taskId, string $inputPath): void
{
$outputDir = config('transcode.output_path') . '/' . $taskId;
@mkdir($outputDir, 0755, true);
$video = $this->ffmpeg->open($inputPath);
// 获取视频时长(用于计算进度)
$duration = $this->getDuration($inputPath);
$this->updateProgress($taskId, 5, '开始转码');
// 1. 转 MP4(多分辨率)
$this->toMp4($video, $taskId, $outputDir, $duration);
// 2. 截缩略图
$this->thumbnail($video, $taskId, $outputDir, $duration);
// 3. 生成 HLS(流媒体)
$this->toHls($inputPath, $taskId, $outputDir);
$this->updateProgress($taskId, 100, '转码完成', 'done');
}
// ── MP4转码 ──────────────────────────────────────────
private function toMp4($video, string $taskId, string $outputDir, float $duration): void
{
$presets = config('transcode.presets');
foreach ($presets as $name => $preset) {
$outputFile = "{$outputDir}/{$name}.mp4";
$format = new X264('aac', 'libx264');
$format->setKiloBitrate($preset['bitrate']);
$format->setAudioKiloBitrate(128);
// 进度回调:FFmpeg每秒汇报一次
$format->on('progress', function ($video, $format, $percentage) use ($taskId, $name) {
// 三个分辨率各占30%进度,从5%开始
$base = ['1080p' => 5, '720p' => 35, '480p' => 65][$name] ?? 5;
$this->updateProgress($taskId, (int)($base + $percentage * 0.3), "转码{$name}中");
});
$video->filters()
->resize(new Dimension($preset['width'], $preset['height']))
->synchronize();
$video->save($format, $outputFile);
}
}
// ── 截缩略图 ─────────────────────────────────────────
private function thumbnail($video, string $taskId, string $outputDir, float $duration): void
{
// 截视频1/4处的帧作为封面
$at = TimeCode::fromSeconds((int)($duration / 4));
$video->frame($at)->save("{$outputDir}/thumbnail.jpg");
$this->updateProgress($taskId, 92, '生成缩略图');
}
// ── HLS流媒体 ────────────────────────────────────────
private function toHls(string $inputPath, string $taskId, string $outputDir): void
{
// HLS:把视频切成N个小片段(.ts文件) + 一个索引文件(.m3u8)
// 播放器边下边播,不用等整个视频下载完
$hlsDir = "{$outputDir}/hls";
@mkdir($hlsDir, 0755, true);
// 直接调FFmpeg命令生成HLS(php-ffmpeg的HLS支持有限,直接exec更可控)
$cmd = sprintf(
'%s -i %s -c:v libx264 -c:a aac -hls_time 10 -hls_list_size 0 ' .
'-hls_segment_filename %s/segment_%%03d.ts %s/index.m3u8 2>&1',
config('transcode.ffmpeg_bin'),
escapeshellarg($inputPath),
escapeshellarg($hlsDir),
escapeshellarg($hlsDir)
);
exec($cmd, $output, $code);
if ($code !== 0) {
throw new \RuntimeException('HLS转码失败: ' . implode("\n", $output));
}
$this->updateProgress($taskId, 98, '生成HLS完成');
}
// ── 进度管理 ─────────────────────────────────────────
public function updateProgress(string $taskId, int $percent, string $msg, string $status = 'processing'): void
{
$this->redis->setex("transcode:progress:{$taskId}", 86400, json_encode([
'percent' => $percent,
'message' => $msg,
'status' => $status, // pending/processing/done/failed
'updated' => time(),
]));
}
public function getProgress(string $taskId): array
{
$data = $this->redis->get("transcode:progress:{$taskId}");
return $data ? json_decode($data, true) : ['percent' => 0, 'status' => 'pending'];
}
private function getDuration(string $path): float
{
$probe = FFProbe::create([
'ffprobe.binaries' => config('transcode.ffprobe_bin'),
]);
return (float) $probe->format($path)->get('duration', 0);
}
/**
* 返回转码完成后的文件URL列表
*/
public function getOutputUrls(string $taskId): array
{
$base = config('transcode.base_url') . '/' . $taskId;
return [
'mp4' => [
'1080p' => "{$base}/1080p.mp4",
'720p' => "{$base}/720p.mp4",
'480p' => "{$base}/480p.mp4",
],
'hls' => "{$base}/hls/index.m3u8",
'thumbnail' => "{$base}/thumbnail.jpg",
];
}
}
---
3. 异步队列任务
// app/Job/TranscodeJob.php
<?php
namespace App\Job;
use App\Service\TranscodeService;
use Hyperf\AsyncQueue\Job;
class TranscodeJob extends Job
{
// 最大重试次数
public int $maxAttempts = 2;
public function __construct(
public string $taskId,
public string $inputPath
) {}
public function handle(): void
{
$service = make(TranscodeService::class);
try {
$service->updateProgress($this->taskId, 1, '任务开始');
$service->transcode($this->taskId, $this->inputPath);
} catch (\Throwable $e) {
$service->updateProgress($this->taskId, 0, '转码失败: ' . $e->getMessage(), 'failed');
throw $e; // 抛出让队列记录失败
}
}
}
---
4. 视频服务(上传+派发)
// app/Service/VideoService.php
<?php
namespace App\Service;
use App\Job\TranscodeJob;
use Hyperf\AsyncQueue\Driver\DriverFactory;
use Ramsey\Uuid\Uuid;
class VideoService
{
public function __construct(
private DriverFactory $queueFactory,
private TranscodeService $transcode
) {}
/**
* 接收上传文件,派发转码任务
*/
public function upload(string $tmpPath, string $originalName): array
{
$taskId = Uuid::uuid4()->toString();
$ext = pathinfo($originalName, PATHINFO_EXTENSION);
$inputPath = config('transcode.output_path') . "/{$taskId}/original.{$ext}";
// 保存原始文件
@mkdir(dirname($inputPath), 0755, true);
rename($tmpPath, $inputPath);
// 初始化进度
$this->transcode->updateProgress($taskId, 0, '等待转码', 'pending');
// 丢进专用转码队列(异步,立即返回)
$this->queueFactory->get('transcode')->push(
new TranscodeJob($taskId, $inputPath)
);
return ['task_id' => $taskId];
}
}
---
5. 控制器
// app/Controller/VideoController.php
<?php
namespace App\Controller;
use App\Service\{VideoService, TranscodeService};
use Hyperf\HttpServer\Annotation\{Controller, PostMapping, GetMapping};
use Hyperf\HttpServer\Contract\RequestInterface;
#[Controller(prefix: '/api/video')]
class VideoController
{
public function __construct(
private VideoService $videoService,
private TranscodeService $transcodeService
) {}
// 上传视频,立即返回task_id
#[PostMapping(path: '/upload')]
public function upload(RequestInterface $request)
{
$file = $request->file('video');
if (!$file || !$file->isValid()) {
return ['code' => 400, 'msg' => '请上传视频文件'];
}
$result = $this->videoService->upload(
$file->getRealPath(),
$file->getClientFilename()
);
return ['code' => 200, 'data' => $result];
// 返回:{"task_id": "uuid-xxx"}
// 前端拿task_id轮询进度
}
// 查询转码进度
#[GetMapping(path: '/progress/{taskId}')]
public function progress(string $taskId)
{
$progress = $this->transcodeService->getProgress($taskId);
return ['code' => 200, 'data' => $progress];
// 返回:{"percent": 65, "status": "processing", "message": "转码480p中"}
}
// 获取转码完成后的播放地址
#[GetMapping(path: '/urls/{taskId}')]
public function urls(string $taskId)
{
$progress = $this->transcodeService->getProgress($taskId);
if ($progress['status'] !== 'done') {
return ['code' => 400, 'msg' => '转码未完成'];
}
return ['code' => 200, 'data' => $this->transcodeService->getOutputUrls($taskId)];
}
}
---
6. 启动转码Worker
# 启动转码队列消费者(单独进程)
php bin/hyperf.php async-queue:consume transcode
// config/autoload/processes.php 自动随服务启动
return [
\Hyperf\AsyncQueue\Process\ConsumerProcess::class,
];
---
整体流程
POST /api/video/upload
↓ 保存原始文件
↓ 派发 TranscodeJob 到 Redis 队列
↓ 立即返回 {task_id: "xxx"}(不等转码完成)
Worker进程(后台)
↓ 拿到任务
↓ FFmpeg 转 1080p.mp4 → 进度5%~35%
↓ FFmpeg 转 720p.mp4 → 进度35%~65%
↓ FFmpeg 转 480p.mp4 → 进度65%~92%
↓ 截缩略图 → 进度92%
↓ 生成 HLS m3u8 → 进度98%
↓ 完成 → 进度100% status=done
前端每2秒轮询 GET /api/video/progress/{taskId}
↓ status=done 时
GET /api/video/urls/{taskId}
↓ 拿到播放地址,展示视频
---
输出文件结构
storage/videos/{taskId}/
├── original.mp4 原始文件
├── 1080p.mp4 高清
├── 720p.mp4 标清
├── 480p.mp4 流畅
├── thumbnail.jpg 封面图
└── hls/
├── index.m3u8 播放列表
├── segment_000.ts
├── segment_001.ts
└── ...
前端播放HLS用 hls.js,直接传 index.m3u8 地址即可,支持自动切换清晰度。
火山引擎视频云技术社区,是面向 AI 音视频开发者的技术交流平台。这里汇聚源自抖音、豆包等亿级 DAU 产品的 RTC、直播、点播、AI 媒体处理、音视频互动技术,提供接入指南、最佳实践、性能调优、场景案例、Demo 代码、开源项目、白皮书和 API 文档。社区汇聚官方工程师与一线开发者,为 AI 视频通话、数字人、AI 视频处理等应用的开发与落地提供技术支持。
更多推荐
所有评论(0)