前言
批量处理视频的翻车现场通常长这样:本地拿两个 MP4 试跑一切正常,一旦把脚本指向存放几百个文件的目录就集中爆发——脚本跑了几十分钟被 PHP-FPM 的request_terminate_timeout掐死,日志里连一行错误都没有;或者十几二十个 ffmpeg 进程同时开跑,机器 load 飙到几十,转出来的文件没有音频轨,甚至源文件被写成半截。
这些问题的根源不在 php-ffmpeg 这个库上。PHP-FFMpeg 只是 ffmpeg 命令行的一层面向对象封装,它本身不提供任何并发、队列或断点续跑能力。而「批量」意味着要把几百次同步的外部进程调用串起来,同时不把 CPU、内存和磁盘 IO 压垮——这是调度问题,不是转码问题。
本文分三步讲:先把依赖装对并确认版本约束,再选批量执行模式,最后给出一个能直接保存成.php跑的完整脚本(含断点续跑、超时控制、失败隔离)。文中代码限制在PHP 7.4语法范围内:类型化属性、箭头函数可以用,命名参数、构造函数属性提升、match、?->这些 PHP 8.0 才有的语法不要写。
需要留个心眼的是:PHP 7.4 已经结束生命周期(官方安全支持到 2022 年 11 月结束),新项目不该再选它。本文按题目要求仍以 7.4 为基线,但文末会给出升级评估的提示。
一、装依赖:先确认二进制,再让 Composer 定版本
php-ffmpeg 的包名是php-ffmpeg/php-ffmpeg,命名空间是FFMpeg\。它自己不实现解码,而是通过proc_open去调外部的ffmpeg和ffprobe。所以第一步永远是确认这两个二进制存在且可执行:
ffmpeg -version ffprobe -version两行都要能打印出版本信息。如果提示 command not found,先装 ffmpeg(或把二进制目录写进PATH),否则后面所有代码都会以「无法启动进程」的形式失败。
第二步是安装。这里有个关键点:不要凭记忆写版本号。该库的高版本线已经把最低 PHP 版本抬到了 8.0,PHP 7.4 只能落在更早的 0.x 系列上。正确做法是让 Composer 自己解析,再用composer why-not复核原因:
composer require php-ffmpeg/php-ffmpeg --no-interaction composer show php-ffmpeg/php-ffmpeg composer why-not php-ffmpeg/php-ffmpeg 1.0.0composer why-not会直接回答「为什么 1.0.0 装不上」,输出里通常会有一行平台约束,类似「requires php ^8.0」。看到这行就说明 PHP 7.4 确实只能待在 0.x 上,别硬改composer.json里的platform去骗过检查。以本地 Composer 的解析结果为准,而不是以任何文章里的版本号为准。
| 判断项 | 命令 | 结论怎么看 |
|---|---|---|
| 二进制是否就绪 | ffmpeg -version | 有版本输出即就绪 |
| 实际装到了哪个版本 | composer show php-ffmpeg/php-ffmpeg | 看 versions 那一行的实际安装版本 |
| 为什么高版本装不上 | composer why-not php-ffmpeg/php-ffmpeg 1.0.0 | 输出里的requires php就是原因 |
| 扩展依赖是否齐全 | composer check-platform-reqs | 缺哪个扩展会直接列出来 |
二、批量处理的三种模式,别一上来就 for 循环
批量任务本质上是「一个生产者 + N 个执行者」的调度问题。三种常见模式的对比如下:
| 模式 | 实现方式 | 优点 | 代价 | 适用场景 |
|---|---|---|---|---|
| 纯串行 | 单进程foreach+save() | 代码最简单,内存最省 | 吞吐等于单机单核,几百个文件要跑很久 | 文件数少(几十个以内)、质量优先 |
| 受控并发 | xargs -P N或多个 worker 进程 | 吞吐接近核数上限 | 需要处理共享状态(日志、状态文件)的竞争 | 几百到几千个,最常用 |
| 队列化 | 任务表 + 常驻 worker + 重试 | 可中断、可重试、可分布式 | 组件多,初期成本高 | 上万文件、或需要跨机器扩展 |
「受控并发」是性价比最高的一档。要点有两个:
第一,并发数要乘上 ffmpeg 自己的线程数一起算。ffmpeg 默认按 CPU 核数开线程,如果 PHP 侧再开 8 个 worker,等于 8 × 核数 个线程在抢 CPU,转码速度反而下降,还会因为内存放大触发 OOM。所以要在配置里显式限制ffmpeg.threads,让「worker 数 × 单进程线程数」不超过物理核数。
第二,状态文件必须用原子追加。多个 worker 同时写「已完成列表」,用普通file_put_contents会互相覆盖,结果就是断点续跑失效、文件被重复转码。加FILE_APPEND | LOCK_EX才是安全的写法。
三、完整可运行脚本
下面这份代码可以直接保存为batch_transcode.php运行,PHP 7.4 及以上都能跑:
<?php declare(strict_types=1); /** * 批量转码(PHP 7.4+) * 用法: php batch_transcode.php /data/in /data/out * 断点续跑: 重复执行时自动跳过已完成文件 */ use FFMpeg\FFMpeg; use FFMpeg\FFProbe; use FFMpeg\Coordinate\Dimension; use FFMpeg\Coordinate\TimeCode; use FFMpeg\Format\Video\X264; final class BatchTranscoder { /** @var FFMpeg */ private $ffmpeg; /** @var FFProbe */ private $probe; /** @var string */ private $stateFile; public function __construct(string $ffmpegBin, string $ffprobeBin, string $stateFile) { $this->stateFile = $stateFile; // 关键:整个批次只 create 一次,复用同一个实例 $this->ffmpeg = FFMpeg::create([ 'ffmpeg.binaries' => $ffmpegBin, 'ffprobe.binaries' => $ffprobeBin, 'timeout' => 1800, // 单个任务 30 分钟,超时抛异常而不是挂死 'ffmpeg.threads' => 2, // 限制 ffmpeg 线程,配合 worker 数一起算 ]); $this->probe = $this->ffmpeg->getFFProbe(); } /** 读取已完成列表(断点续跑) */ private function loadDone(): array { if (!is_file($this->stateFile)) { return []; } $done = []; foreach (explode("\n", (string) file_get_contents($this->stateFile)) as $line) { $line = trim($line); if ($line !== '') { $done[$line] = true; } } return $done; } /** 多进程安全:追加写 + 文件锁 */ private function markDone(string $src): void { file_put_contents($this->stateFile, $src . "\n", FILE_APPEND | LOCK_EX); } private function dstPath(string $srcDir, string $dstDir, string $src): string { $rel = str_replace('\\', '/', substr($src, strlen($srcDir))); $rel = ltrim($rel, '/'); $rel = (string) preg_replace('/\.[^.]+$/', '.mp4', $rel); $target = rtrim(str_replace('\\', '/', $dstDir), '/') . '/' . $rel; $dir = dirname($target); if (!is_dir($dir) && !mkdir($dir, 0755, true) && !is_dir($dir)) { throw new RuntimeException('无法创建输出目录: ' . $dir); } return $target; } private function transcodeOne(string $src, string $dst): void { // 1) 先探测,拿到分辨率与音频轨信息 $streams = $this->probe->streams($src); $video = $streams->videos()->first(); if ($video === null) { throw new RuntimeException('没有视频轨'); } $width = (int) $video->get('width'); $hasAudio = $streams->audios()->count() > 0; // 2) 打开并挂滤镜 $media = $this->ffmpeg->open($src); if ($width > 1280) { $media->filters()->resize(new Dimension(1280, 720))->synchronize(); } // 3) 拼装输出格式 $format = new X264('aac', 'libx264'); $format->setKiloBitrate(1200); // 4) 导出(失败时抛 FFMpeg\Exception\RuntimeException) $media->save($format, $dst); // 5) 产物校验:不能只看文件是否存在 if (!is_file($dst) || filesize($dst) < 1024) { throw new RuntimeException('产物异常(过小或缺失): ' . $dst); } if (!$hasAudio) { echo " [提示] 源文件无音频轨,输出也不会有声音\n"; } } /** 抽帧做封面,PHP 7.4 的箭头函数在这里正好顺手 */ public function thumbnail(string $src, string $dstJpg, int $atSecond = 3): void { $this->ffmpeg->open($src) ->frame(TimeCode::fromSeconds($atSecond)) ->save($dstJpg, true, 90); } public function run(string $srcDir, string $dstDir): void { if (!is_dir($dstDir) && !mkdir($dstDir, 0755, true) && !is_dir($dstDir)) { throw new RuntimeException('无法创建输出根目录: ' . $dstDir); } $done = $this->loadDone(); $ok = 0; $skip = 0; $fail = 0; $allow = ['mp4' => true, 'mov' => true, 'mkv' => true, 'avi' => true]; $it = new RecursiveIteratorIterator( new RecursiveDirectoryIterator($srcDir, FilesystemIterator::SKIP_DOTS) ); /** @var SplFileInfo $file */ foreach ($it as $file) { if (!$file->isFile()) { continue; } $ext = strtolower($file->getExtension()); if (!isset($allow[$ext])) { continue; } $src = $file->getPathname(); if (isset($done[$src])) { $skip++; continue; } try { $dst = $this->dstPath($srcDir, $dstDir, $src); $this->transcodeOne($src, $dst); $this->markDone($src); $ok++; echo "[OK] {$src}\n"; } catch (Throwable $e) { // 单个文件失败不影响整批 $fail++; echo "[FAIL] {$src} => " . $e->getMessage() . "\n"; file_put_contents( $dstDir . '/failed.log', $src . "\t" . $e->getMessage() . "\n", FILE_APPEND | LOCK_EX ); } } echo "完成: 成功 {$ok} / 跳过 {$skip} / 失败 {$fail}\n"; } } $in = $argv[1] ?? __DIR__ . '/in'; $out = $argv[2] ?? __DIR__ . '/out'; $job = new BatchTranscoder( getenv('FFMPEG_BIN') ?: 'ffmpeg', getenv('FFPROBE_BIN') ?: 'ffprobe', $out . '/.done.state' ); $job->run($in, $out);要让多个 worker 并行跑同一批文件,用 shell 侧的受控并发即可,状态文件里的LOCK_EX保证不会互相覆盖:
# 4 个并发,每个文件一个 PHP 进程;worker 数 × ffmpeg.threads 不要超过物理核数 find /data/in -type f \( -name '*.mp4' -o -name '*.mov' \) -print0 \ | xargs -0 -n1 -P4 -I{} php batch_transcode.php /data/in /data/out需要注意的是,上面这条命令给每个文件启动了一个新的 PHP 进程,也就意味着每次都重新FFMpeg::create()一次;create()内部要做一次二进制探测,这是有小额外开销的。文件数极大时,更好的做法是让一个 PHP 进程常驻、自己从任务队列里取任务(模式三)。选择哪种取决于任务规模,不必一开始就上队列。
常见坑点
1. 在循环体里创建 FFMpeg 实例
❌foreach ($files as $f) { $ffmpeg = FFMpeg::create([...]); $ffmpeg->open($f)->save(...); }✅ 在构造函数里create()一次,整个批次复用同一个实例。
create()不只是 new 一个对象,它会去探测 ffmpeg/ffprobe 的版本信息。放在循环里,几百个文件就是几百次多余的进程启动。
2. 用exec()拼接文件路径
❌exec("ffmpeg -i $src -c:v libx264 $dst");✅ 交给库的进程封装处理,或自己拼命令时逐个escapeshellarg()。
文件名里带空格、引号、$()、中文括号都会让这条命令行为诡异——最坏的情况是命令被截断,输出一个空文件却返回成功状态码。
3. 只看文件是否存在就判定成功
❌if (file_exists($dst)) { $done[] = $src; }✅ 再加一道filesize()或 ffprobe 时长校验。
进程被 kill、磁盘写满、参数写错,都可能留下一个 0 字节或几 KB 的残缺文件。这种文件一旦被记进状态文件,断点续跑就永远不会重试它。
4. 不限制并发和线程数
❌ 直接启动 16 个 worker,ffmpeg 再用默认线程数。 ✅worker 数 × ffmpeg.threads ≈ 物理核数。
超配带来的不是加速,而是上下文切换开销和内存放大:每个 ffmpeg 进程都要为多帧分配缓冲区,核数越多、分辨率越高,常驻内存涨得越快。
5. 在 PHP-FPM 里跑批量脚本
❌ 用浏览器触发一个接口,等它把 300 个视频转完。 ✅ 用 CLI 执行,交给 systemd、supervisor 或计划任务托管。
FPM 有max_execution_time、request_terminate_timeout、pm.max_children三重限制。批量任务会长时间占满一个 worker,把线上接口的可用进程数直接挤没。
6. 输出路径覆盖源文件
❌$dst = $src;(原地替换) ✅ 输出到独立的目录树,文件名只从源路径推导。
转码过程中断会把源文件写坏,而这个源文件通常是你唯一的一份。
7. 把失败文件默默跳过
❌catch (Throwable $e) {}后面什么都不做。 ✅ 把失败路径和原因写进failed.log,批次结束后人工处理。
批量任务里最贵的成本是「不知道哪几个没成功」。空 catch 会让你在几天后才发现有一批文件根本没产出。
8. 认为把库升到 1.x 就能继续跑在 PHP 7.4
❌ 看到新版本就改composer.json里的约束,然后删掉composer.lock重装。 ✅ 先跑composer why-not看平台约束,确认 PHP 版本满足再升。
平台约束不满足时,强行安装通常以运行时错误收场(依赖用了 8.0 的语法而你的解释器不认),报错位置离真正原因很远,很难排查。
总结
| 环节 | 关键做法 | 常见错误 |
|---|---|---|
| 依赖 | 先验二进制,再让 Composer 解析版本 | 凭记忆写版本号 |
| 实例 | 每进程创建一次,长期复用 | 循环里反复create() |
| 并发 | 受控并发,线程数按核数反推 | 无限制起进程 |
| 状态 | 追加写 +LOCK_EX | 普通写入被覆盖 |
| 校验 | 文件大小 + 时长双重检查 | 只看文件存在 |
| 失败 | 独立日志,单文件失败不中断批次 | 空 catch 吞掉 |
| 运行环境 | CLI + 进程管理器 | 在 FPM 里跑长任务 |
php-ffmpeg 本身很简单,批量处理视频真正的难点是把「外部进程调度」这件事做好:单实例复用、受控并发、原子状态、产物校验、失败隔离,这五件事做齐,脚本就能在几百上千个文件上稳定跑完。
最后提醒一句:PHP 7.4 和 PHP 8.0 都已结束官方支持,长期项目应规划向仍在支持周期内的版本迁移,本文中的代码在 8.x 上同样可以运行。