用 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 地址即可,支持自动切换清晰度。
Logo

火山引擎视频云技术社区,是面向 AI 音视频开发者的技术交流平台。这里汇聚源自抖音、豆包等亿级 DAU 产品的 RTC、直播、点播、AI 媒体处理、音视频互动技术,提供接入指南、最佳实践、性能调优、场景案例、Demo 代码、开源项目、白皮书和 API 文档。社区汇聚官方工程师与一线开发者,为 AI 视频通话、数字人、AI 视频处理等应用的开发与落地提供技术支持。

更多推荐