前言先说一句可能不中听但很重要的话转码后的视频卡顿责任几乎全在 ffmpeg 的参数上PHP 只是个调用方。但 PHP 这一侧有两件事必须做对否则你连视频到底卡在哪都查不出来判断转码是否真的成功。很多卡顿其实是转码失败后留下了一个损坏或不完整的文件而 PHP 侧因为没检查退出码把它当成功处理了不要让同步转码拖垮整个站点。一次转码占用一个 PHP-FPM 子进程几分钟几个用户同时上传就能把进程池占满表现出来是整个网站都卡。下面按先分清卡在哪一环再谈参数最后讲 PHP 侧怎么调的顺序展开文中代码最低要求 PHP 8.0。一、卡顿到底卡在哪一环卡顿太笼统了先拆成具体现象才能对症下药。现象最可能的根因怎么验证点开要等很久才出画面MP4 的moov元数据在文件末尾必须下载完才能开始播加-movflags faststart前后对比首帧时间拖动进度条后卡住很久关键帧间隔太大播放器要一直解码到下一个关键帧看 GOP 长度设置建议 2 秒一个关键帧iPhone / Safari 完全不能播像素格式不是yuv420p或用了 10 位色深ffprobe看pix_fmt只有声音没有画面视频编码浏览器不支持比如 H.265/HEVCffprobe看codec_name播到一半疯狂缓冲码率超过带宽或源文件是可变帧率VFR时间戳混乱看avg_frame_rate与r_frame_rate是否一致全站都变慢不只视频页PHP-FPM 子进程被转码任务占满看 FPM 状态页的max children reached计数转码成功但文件放不了PHP 没检查 ffmpeg 退出码把失败当成功检查proc_close()的返回值二、必须交代清楚的 ffmpeg 参数关键帧间隔拖动进度条卡不卡的直接原因为了压缩体积编码器把帧分成三类I 帧关键帧存完整画面、可单独解码P 帧只存与前一帧的差异B 帧存前后两个方向的差异。后两者都依赖其他帧所以播放器要跳到第 100 秒必须先找到它之前最近的那个 I 帧再从那里一帧帧解码过来。如果 I 帧之间隔了 300 帧10 秒每次拖动进度条就要多解码 10 秒的内容低端设备上就是肉眼可见的卡顿。# 30fps 下每 60 帧一个关键帧也就是每 2 秒一个-g 60 -keyint_min 60 -sc_threshold 0-g 60设定最大间隔-keyint_min 60设定最小间隔-sc_threshold 0关掉场景切换时自动插入关键帧的行为——不关掉的话画面切换频繁的视频会插入一堆额外关键帧文件变大间隔还不可预测。代价是关键帧越多、压缩率越低、文件越大-g 60是常见折中值-g 30拖动更顺但文件更大这个取舍要结合业务自己定。像素格式iOS 能不能播的分水岭-pix_fmt yuv420p用libx264编码时如果源文件是 10 位色深或 4:2:2 采样ffmpeg 可能保留这些格式而大多数浏览器和 iOS 设备只能解码 8 位 4:2:0也就是yuv420p症状是桌面 Chrome 能播、iPhone 黑屏或只有声音。做缩放时宽高必须是偶数-vf scale1280:-1会得到奇数高度yuv420p直接报错退出-2才是安全写法-vf scale1280:-2moov 位置首帧加载速度的决定因素MP4 的结构是元数据moov 媒体数据mdat。ffmpeg 写文件时习惯把moov放在最后写之前不知道最终大小这意味着播放器必须先下载完整个文件才知道视频有多长、关键帧在哪里。-movflags faststart它让 ffmpeg 转码完成后再做一次搬移把moov移到文件开头。代价是要多读一遍文件转码时间略微变长换来的是边下边播的能力。明确指定帧率对付 VFR 源文件手机录屏、屏幕录制这类源文件往往是可变帧率VFR, Variable Frame Rate——画面不动时就少存几帧。这类文件直接转码后时间戳会不均匀播放器时间轴抖动表现就是进度条跳着走 音画不同步。用-r 30指定输出帧率可以把它规范成固定帧率CFR。注意这个选项在不同 ffmpeg 版本里改过名字旧版是-vsync cfr新版是-fps_mode cfr具体用哪个请以你本机ffmpeg -h full的输出为准别照抄网上的老教程。三、PHP 侧先能判断成功和失败不要用 shell_execshell_exec()只返回标准输出拿不到退出码也拿不到标准错误。而 ffmpeg 恰恰把错误信息写到 stderr、进度写到 stdout。结果是 ffmpeg 失败了你什么都不知道代码继续往下走把一个损坏的文件存进了数据库。正确做法是用proc_open()它能把三条管道都拿到手上?php// ✅ 传数组形式PHP 7.4 支持由 PHP 自己处理参数转义$process proc_open([ffmpeg, -nostdin, -i, $input, $output],[0 [pipe, r], 1 [pipe, w], 2 [pipe, w]],$pipes);用数组形式传命令比手写escapeshellarg()拼字符串更安全。文件名里带空格、引号、中文时手拼很容易拼出语法错误甚至命令注入数组形式由 PHP 负责转义Windows 与 Linux 的行为差异也一并绕开。-nostdin不是可选项ffmpeg 默认会尝试从标准输入读取控制指令。Web 环境里标准输入往往已关闭或指向别处ffmpeg 可能因此直接退出错误信息还相当隐晦。加-nostdin同时在 PHP 里立刻fclose($pipes[0])是双保险。proc_get_status()的退出码只能读一次这是一个非常隐蔽的坑进程退出后调用proc_get_status()它会回收reap该进程之后再调用返回的exitcode永远是-1。判断退出码应该用proc_close()的返回值它才是权威的proc_get_status()只用来判断running这个布尔值。四、代码实战带进度解析的转码封装下面这个脚本可以直接php transcode.php 输入文件.mp4运行。它做了四件事探测源文件时长、执行转码并解析进度、检查退出码、用ffprobe验证输出文件真的能被浏览器播放。最低要求 PHP 8.0。?phpdeclare(strict_types1);// 最低要求PHP 8.0用到了 str_starts_with、str_contains、match/** 用 ffprobe 读取媒体时长秒 */function probeDuration(string $file): float{$cmd sprintf(ffprobe -v error -show_entries formatduration -of defaultnoprint_wrappers1:nokey1 %s,escapeshellarg($file));$lines [];$code 0;exec($cmd, $lines, $code);if ($code ! 0 || $lines []) {throw new RuntimeException(ffprobe 无法读取该文件请确认文件完整且格式受支持);}return (float) $lines[0];}/** 校验输出文件是否浏览器友好编码、像素格式、容器三样都要看 */function assertBrowserFriendly(string $file): void{$lines [];$code 0;exec(sprintf(ffprobe -v error -print_format json -show_format -show_streams %s,escapeshellarg($file)), $lines, $code);if ($code ! 0) {throw new RuntimeException(ffprobe 执行失败输出文件可能已损坏);}$info json_decode(implode(\n, $lines), true, 512, JSON_THROW_ON_ERROR);$video null;foreach ((is_array($info) ? $info[streams] ?? [] : []) as $stream) {if (($stream[codec_type] ?? ) video) {$video $stream;break;}}if ($video null) {throw new RuntimeException(输出文件里没有视频流);}// 期望值 不匹配时的说明$rules [codec_name [h264, 视频编码不是 H.264浏览器可能无法播放],pix_fmt [yuv420p, 像素格式不对iOS 上会黑屏],];foreach ($rules as $key [$expected, $message]) {if (($video[$key] ?? ) ! $expected) {throw new RuntimeException({$message}实际{$video[$key] ?? 未知});}}if (!str_contains((string) ($info[format][format_name] ?? ), mp4)) {throw new RuntimeException(输出容器不是 MP4);}}/*** 转码成浏览器友好的 MP4* param callable(float):void|null $onProgress 接收 0.0 ~ 1.0* return array{exit_code: int, stderr: string}*/function transcodeToWebMp4(string $input,string $output,string $quality balanced,?callable $onProgress null,): array {// match 表达式PHP 8.0预设档位$preset match ($quality) {fast veryfast,balanced medium,best slow,default throw new InvalidArgumentException(未知的质量档位: . $quality),};$duration probeDuration($input);$cmd [ffmpeg,-nostdin, // 不让 ffmpeg 去读标准输入-hide_banner,-y, // 目标文件已存在则覆盖-i, $input,-c:v, libx264,-profile:v, high,-level, 4.1,-preset, $preset,-crf, 23, // 恒定质量数值越小画质越好、文件越大-pix_fmt, yuv420p, // 浏览器兼容性的关键-r, 30, // 输出固定帧率压制 VFR 源文件的时间戳抖动-g, 60, // 每 60 帧一个关键帧30fps 下约 2 秒-keyint_min, 60,-sc_threshold, 0, // 关掉场景切换关键帧让间隔严格均匀-c:a, aac,-b:a, 128k,-ar, 44100,-ac, 2,-movflags, faststart, // 把 moov 移到文件开头-progress, pipe:1, // 进度写 stdout-loglevel, error, // stderr 只保留错误-f, mp4,$output,];$process proc_open($cmd,[0 [pipe, r], 1 [pipe, w], 2 [pipe, w]],$pipes);if (!is_resource($process)) {throw new RuntimeException(无法启动 ffmpeg请确认它已安装并在 PATH 中);}fclose($pipes[0]); // 立刻关掉 stdin配合 -nostdin 双保险stream_set_blocking($pipes[1], false);stream_set_blocking($pipes[2], false);$stderrBuffer ;$progressBuf ;while (true) {// 两条管道都要持续读否则管道缓冲区写满会把 ffmpeg 卡死$progressBuf . (string) stream_get_contents($pipes[1]);$stderrBuffer . (string) stream_get_contents($pipes[2]);while (($pos strpos($progressBuf, \n)) ! false) {$line substr($progressBuf, 0, $pos);$progressBuf substr($progressBuf, $pos 1);// -progress 输出的 out_time_ms 单位其实是微秒if ($onProgress ! null str_starts_with($line, out_time_ms)) {$seconds (int) substr($line, 11) / 1_000_000;$onProgress($duration 0 ? min(1.0, $seconds / $duration) : 0.0);}}if (proc_get_status($process)[running] false) {break;}usleep(200_000); // 每 0.2 秒轮询一次}// 收尾把残留数据读完再关闭管道最后取权威退出码$stderrBuffer . (string) stream_get_contents($pipes[2]);fclose($pipes[1]);fclose($pipes[2]);return [exit_code proc_close($process), // 用 proc_close 而不是 proc_get_statusstderr $stderrBuffer,];}// ---------------- 命令行入口 ----------------$input $argv[1] ?? null;if ($input null || !is_file($input)) {fwrite(STDERR, 用法: php transcode.php 输入文件\n);exit(1);}$output __DIR__ . /output.mp4;try {$result transcodeToWebMp4($input, $output, balanced, static function (float $ratio): void {printf(\r进度: %5.1f%%, $ratio * 100);});echo PHP_EOL;if ($result[exit_code] ! 0) {fwrite(STDERR, sprintf(转码失败退出码 %d\n%s\n, $result[exit_code], $result[stderr]));exit(1);}if (!is_file($output) || filesize($output) 0) {fwrite(STDERR, 转码退出码为 0但输出文件不存在或为空\n);exit(1);}// 关键一步不要相信退出码去验证输出文件的内容assertBrowserFriendly($output);printf(转码完成并通过校验%s%.1f MB\n, $output, filesize($output) / 1048576);} catch (Throwable $e) {fwrite(STDERR, 出错 . $e-getMessage() . PHP_EOL);exit(1);}这段代码里有两处设计是刻意为之的第一while (true)循环里同时读两条管道。管道有固定大小的缓冲区如果只读 stdout 不管 stderrffmpeg 往 stderr 写满后就会阻塞在写 stderr 上永不结束而 PHP 在proc_get_status()那里无限等待——双方互相死等俗称死锁。这就是两条管道都要设成非阻塞、每轮都读的原因。第二退出码为 0 也要再验一遍输出文件。转码看似完成、输出却是半截文件的情况并不少见比如进程被外部杀掉。用ffprobe读一遍输出的编码、像素格式和容器才是验证而不是假设。常见坑点1. 用shell_exec()执行 ffmpeg❌ 错误写法shell_exec(ffmpeg -i {$input} {$output})——拿不到退出码拿不到 stderr。✅ 正确写法用proc_open()并检查proc_close()的返回值。exec()也能通过第三个引用参数拿到退出码但同样不好同时拿到 stdout 和 stderr而且仍然是字符串拼命令。2. 只读 stdout不管 stderr导致死锁❌ 错误写法while (proc_get_status($process)[running]) { echo stream_get_contents($pipes[1]); }。✅ 正确写法两条管道都设为非阻塞stream_set_blocking(..., false)循环里两条都stream_get_contents。3. 对max_execution_time的跨平台差异没有预期❌ 错误写法以为max_execution_time 30会限制住整个转码过程的耗时。✅ 正确写法PHP 手册明确说明在 Linux 上脚本花在系统调用如exec、sleep、流操作、数据库查询上的时间不计入max_execution_timeWindows 上则按真实经过时间计算。同一个脚本在 Linux 上能跑十分钟不超时在 Windows 上 30 秒就挂了轮询循环里的usleep()同样如此。要控制转码超时请自己记录开始时间并在循环里判断。4. 假设out_time_ms是毫秒❌ 错误写法(int) substr($line, 11) / 1000——当成毫秒算进度会大 1000 倍。✅ 正确写法(int) substr($line, 11) / 1_000_000。这是命名和实际含义不一致的历史遗留问题算出来的进度如果是 12000%就是这个坑。另外进度值要用min(1.0, ...)夹住因为out_time_ms偶尔会略微超过总时长。总结卡顿现象对应参数 / 动作说明首帧要等整段下载-movflags faststart把元数据移到文件开头代价是多读一遍文件拖动进度条卡-g 60 -keyint_min 60 -sc_threshold 0GOP 越短拖动越顺文件越大iOS 黑屏 / 只有声音-pix_fmt yuv420p10 位色深和 4:2:2 大多设备播不了只有声音没有画面-c:v libx264H.265/HEVC 浏览器普遍不支持音画不同步、进度条跳-r 30把 VFR 源文件规范成固定帧率进度条不动或卡死同时读 stdout 和 stderr都设非阻塞管道写满会死锁转码成功但文件坏的检查proc_close()返回值 ffprobe复验别用shell_exec别信proc_get_status的退出码全站变慢转码异步化单独 FPM 池同步转码会占满进程池解决转码后卡顿的正确姿势是把它拆成两件事视频为什么卡和PHP 有没有发现它坏。前者靠 ffmpeg 参数faststart、GOP、像素格式、帧率解决每一个都有明确原理可推导不需要背后者靠proc_open的三条管道加一次ffprobe复验解决。