
前言在内容类项目里“上传视频后自动把音频抽出来”是很常见的功能用户传 MP4后台产出 MP3 或 M4A再交给语音识别、字幕转写等下游。在 PHP 里调用 ffmpeg 看上去只是拼一条命令的事但真放到线上症状往往是这样几种exec()返回空数组日志里什么都没有只能看到脚本“没反应”文件名里带空格、中文、括号的视频全部失败英文名却正常php-fpm 的进程数被几个 ffmpeg 任务吃满接口开始 502重启才好命令退出码是 0产出的.mp3播放器打不开。这些问题的根因基本落在三处把用户可控的文件名直接拼进 shell 命令只读stdout却忘了 ffmpeg 的日志走的是stderr以及没有意识到“提取音频”并不等于“随便选个扩展名就行”。本文就按这三条线索讲清楚原理并给出一个在 PHP 7.4 上可以直接跑起来的完整示例。需要先说明一点PHP 7.4 的官方安全支持已经在 2022 年 11 月结束新项目应当直接使用 8.x。本文用 7.4 的语法类型属性、箭头函数、??等编写同时在注释里标注 8.x 的差异方便你迁移。一、先理解 ffmpeg 的“流”与容器规则ffmpeg 把输入文件解析为若干条流stream视频流、音频流、字幕流、数据流。一个 MP4 里有几条音轨是文件本身决定的可能是 0 条纯视频、无音轨也可能是 2 条国配 原声。默认情况下 ffmpeg 会自动挑一条“最佳”音轨这在多音轨场景下完全不可控所以要用-map显式指定-map 0:a:0表示输入 0 的第 1 条音频流-map 0:a表示输入 0 的全部音频流-map 0:a:0?末尾的?表示“如果不存在就不要报错”。注意这个问号是给空音轨用的如果文件根本没有音轨不带?的命令会直接失败退出。第二个概念是-c:a copy与-c:a 编码器的区别。copy是流复制stream copy不做任何解码和重编码把压缩后的音频数据原样搬进新文件速度几乎只受磁盘 IO 限制。代价是——目标容器container必须能装下这个编码格式。容器与编码的兼容关系是这块最容易踩的坑输出容器常见扩展名可直接copy的音频编码说明Matroska.mkaAAC、MP3、Opus、FLAC、AC3、EAC3兼容性最好copy 场景的兜底选择MP4.m4aAAC、ALAC、MP3源是 Opus 时通常必须转码WebM.webmOpus、Vorbis不含 AAC源是 AAC 会报错Ogg.oggOpus、Vorbis、FLAC不接受 AACMP3.mp3MP3源是 AAC 时必须用libmp3lame转码把一条 AAC 音轨用-c:a copy塞进.mp3是这类需求里出现频率最高的错误ffmpeg 要么直接报错要么写出一个扩展名和实际内容不符的文件——用file命令看是 MP4 容器播放器却按 MP3 解析于是“播放失败”。二、用 ffprobe 先探测再决定怎么转正确顺序不是“先猜扩展名”而是“先探测再决策”。ffprobe与 ffmpeg 一起安装用它可以拿到结构化的 JSONffprobe -v quiet -print_format json -show_format -show_streams input.mp4这里有一个 PHP 7.4 的注意事项str_contains()是 PHP 8.0 才加入的7.4 上调用会直接Fatal error: Call to undefined function。在 7.4 里判断字符串包含用strpos()配合! false或者用strstr()。?php declare(strict_types1); // PHP 7.4 /** return array 关联数组 */ function probe(string $file): array { $cmd [ffprobe, -v, quiet, -print_format, json, -show_format, -show_streams, $file]; $process proc_open($cmd, [1 [pipe, w], 2 [pipe, w]], $pipes); if (!is_resource($process)) { throw new RuntimeException(无法启动 ffprobe); } $stdout stream_get_contents($pipes[1]); $stderr stream_get_contents($pipes[2]); fclose($pipes[1]); fclose($pipes[2]); $code proc_close($process); if ($code ! 0) { throw new RuntimeException(ffprobe 失败: . trim((string) $stderr)); } $data json_decode((string) $stdout, true); if (!is_array($data)) { throw new RuntimeException(ffprobe 输出不是合法 JSON); } return $data; }返回的 JSON 里streams数组中codec_type为audio的那些元素就是音轨他们的codec_name字段告诉我们实际编码aac、opus、mp3等。拿到codec_name编码名之后就能按上表决定去向值在“可 copy 集合”里就copy到匹配的容器否则统一转码成 MP3libmp3lame几乎所有发行版都编进去了。三、用 proc_open 数组形式调用绕开 shell把变量拼进命令字符串是最危险也最脆弱的做法// ❌ 危险文件名带空格、分号、反引号都会出问题 exec(ffmpeg -i {$file} -vn -c:a copy out.mp3);escapeshellarg()能缓解一部分问题但它在不同平台上的行为并不一致——例如在 Windows 上它用双引号包裹并会去掉部分字符带中文的文件名可能被破坏而.mp4这种结尾在个别场景下也会被意外处理。真正稳的做法是根本不要让 shell 参与。PHP 7.4 起proc_open()的$command参数可以传数组数组的每一项作为一个独立的 argv 元素传给进程不经过 shell 解析因此空格、引号、中文名、$(...)全部原样传递注入面直接消失。如果你的运行环境低于 7.4那就只能用escapeshellarg()逐个拼接但要清楚它的边界。剩下的工程问题是如何读取输出而不死锁ffmpeg 的日志写到stderr进度写到我们指定的管道如果只读stdout而不管stderr一旦stderr管道缓冲区写满ffmpeg 就会阻塞在写日志上PHP 这边则卡在fread上两边互等——这就是“脚本卡死、fpm 进程堆积”的真相。解法是把两个管道都设为非阻塞用stream_select()同时监听并设置总超时。四、完整可运行示例下面这份代码保存为extract_audio.php在 PHP 7.4 及以上版本都可以直接运行php extract_audio.php input.mp4 ./output?php declare(strict_types1); // 运行环境PHP 7.4系统 PATH 中可用的 ffmpeg / ffprobe final class FfmpegRunner { /** var string */ private $ffmpeg; /** var string */ private $ffprobe; public function __construct(string $ffmpeg ffmpeg, string $ffprobe ffprobe) { $this-ffmpeg $ffmpeg; $this-ffprobe $ffprobe; } /** * 数组形式的命令不经过 shell * param string[] $args * return array{code:int,stdout:string,stderr:string} */ public function run(array $args, int $timeoutSeconds 600): array { $descriptors [0 [pipe, r], 1 [pipe, w], 2 [pipe, w]]; $process proc_open($args, $descriptors, $pipes); if (!is_resource($process)) { throw new RuntimeException(proc_open 失败); } fclose($pipes[0]); stream_set_blocking($pipes[1], false); stream_set_blocking($pipes[2], false); $stdout ; $stderr ; $deadline microtime(true) $timeoutSeconds; while (true) { if (microtime(true) $deadline) { proc_terminate($process, 9); throw new RuntimeException(ffmpeg 执行超时已强制结束); } $read []; if (!feof($pipes[1])) { $read[] $pipes[1]; } if (!feof($pipes[2])) { $read[] $pipes[2]; } if ($read []) { break; } $write []; $except []; $ready stream_select($read, $write, $except, 1); if ($ready false) { break; // 被信号打断等情况 } if ($ready 0) { continue; // 这一秒没有数据回到循环顶部检查超时 } foreach ($read as $stream) { $chunk fread($stream, 65536); if ($chunk false || $chunk ) { continue; } if ($stream $pipes[1]) { $stdout . $chunk; } else { $stderr . $chunk; } } } fclose($pipes[1]); fclose($pipes[2]); $code proc_close($process); return [code $code, stdout $stdout, stderr $stderr]; } /** return array 关联数组 */ public function probe(string $file): array { $r $this-run([ $this-ffprobe, -v, quiet, -print_format, json, -show_format, -show_streams, $file, ], 30); if ($r[code] ! 0) { throw new RuntimeException(ffprobe 失败: . trim($r[stderr])); } $data json_decode($r[stdout], true); if (!is_array($data)) { throw new RuntimeException(ffprobe 输出无法解析 . substr($r[stdout], 0, 200)); } return $data; } /** * 提取第 1 条音频轨能 copy 就 copy否则转码为 MP3 * return array{file:string,mode:string,codec:string,progress:array} */ public function extractAudio(string $input, string $outDir): array { if (!is_file($input)) { throw new RuntimeException(输入文件不存在: . $input); } if (!is_dir($outDir) !mkdir($outDir, 0775, true) !is_dir($outDir)) { throw new RuntimeException(无法创建输出目录: . $outDir); } $probe $this-probe($input); $audio array_values(array_filter($probe[streams] ?? [], static function ($s) { return ($s[codec_type] ?? ) audio; })); if ($audio []) { throw new RuntimeException(该文件没有音频轨道); } $codec (string) ($audio[0][codec_name] ?? unknown); $durationSec (float) ($probe[format][duration] ?? 0); // codec 可以直接 copy 的容器 $copyMap [ aac m4a, alac m4a, mp3 mp3, opus ogg, vorbis ogg, flac flac, ]; $base pathinfo($input, PATHINFO_FILENAME); $args [$this-ffmpeg, -hide_banner, -nostdin, -y, -i, $input, -vn, -map, 0:a:0]; if (isset($copyMap[$codec])) { $mode copy; $ext $copyMap[$codec]; $args array_merge($args, [-c:a, copy]); } else { $mode transcode; $ext mp3; $args array_merge($args, [-c:a, libmp3lame, -q:a, 2]); } $output rtrim($outDir, /\\) . DIRECTORY_SEPARATOR . $base . . . $ext; // -progress pipe:1 把进度写到 stdout日志仍留在 stderr两者不混淆 $args array_merge($args, [-progress, pipe:1, -nostats, $output]); $r $this-run($args, 1800); if ($r[code] ! 0) { throw new RuntimeException(ffmpeg 退出码 . $r[code] . : . substr(trim($r[stderr]), -400)); } if (!is_file($output) || filesize($output) 0) { throw new RuntimeException(输出文件为空请检查 ffmpeg 日志); } $progress []; foreach (explode(\n, $r[stdout]) as $line) { $pos strpos($line, ); // 7.4 没有 str_contains() if ($pos false) { continue; } $progress[substr($line, 0, $pos)] trim(substr($line, $pos 1)); } // out_time_us 单位是微秒out_time_ms 是 ffmpeg 的历史命名遗留值同为微秒 $doneUs (int) ($progress[out_time_us] ?? 0); if ($durationSec 0 $doneUs 0) { $progress[percent] sprintf(%.1f%%, $doneUs / ($durationSec * 1000000) * 100); } return [file $output, mode $mode, codec $codec, progress $progress]; } } // ---- 使用 ---- $input $argv[1] ?? null; $outDir $argv[2] ?? __DIR__ . /output; if ($input null) { fwrite(STDERR, 用法: php extract_audio.php 视频文件 [输出目录]\n); exit(1); } try { $runner new FfmpegRunner(); $result $runner-extractAudio($input, $outDir); printf(产出: %s\n方式: %s\n源编码: %s\n, $result[file], $result[mode], $result[codec]); } catch (Throwable $e) { fwrite(STDERR, 失败: . $e-getMessage() . \n); exit(2); }如果源是 Opus因为不在copyMap的容器兼容集合里mode会变成transcode产物是demo.mp3同时progress里会带上percent源是 AAC 时mode为copy产物直接是demo.m4a输出文件与源文件大小几乎一致。常见坑点1. 文件名直接拼进 shell❌exec(ffmpeg -i {$file} -vn -c:a copy out.m4a);✅proc_open([ffmpeg,-i,$file,-vn,-c:a,copy,out.m4a], ...)PHP 7.4 起支持数组命令2. 只读 stdout不读 stderr进程互等❌$out stream_get_contents($pipes[1]);而stderr管道不开或不开读 ✅ 两个管道都设为非阻塞用stream_select()同时读ffmpeg 的报错信息全在stderr3. 用-c:a copy写到编码不兼容的容器❌ 源是 AAC却-acodec copy out.mp3✅ 先ffprobe取codec_name按容器兼容表决定copy还是转码4. 忽略“没有音轨”的文件❌ 只用-map 0:a:0纯视频文件直接报错退出 ✅ 先判断音频流数量给出业务错误确实要容错可用-map 0:a:0?5. 不给 ffmpeg 设超时❌ 让一条坏文件无限挂着把 php-fpm 的 worker 耗光 ✅ 自己实现截止时间超时proc_terminate($process, 9)命令行里再加-nostdin防止 ffmpeg 抢终端输入6. 用输出文件大小判断成功❌if (filesize($out) 0) 成功✅ 同时校验退出码与ffprobe能否解析产物两者都过才算成功7. 在 PHP 7.4 上使用 PHP 8 的函数❌str_contains($codec, aac)、array_is_list()8.1 ✅ 用strpos() ! false升级到 8.x 后再换回来总结环节推荐做法原因命令构造proc_open()传数组不经过 shell免疫注入与特殊文件名流的选择-vn-map 0:a:0只取目标音轨多音轨可控是否转码先ffprobe再决定容器与编码必须兼容否则产出损坏文件输出读取非阻塞 stream_select()避免 stderr 管道写满导致死锁超时控制自实现 deadline proc_terminate保护 php-fpm 的 worker 池成功判定退出码 ffprobe 校验产物文件存在不代表内容可用提取音频这条链路本身不复杂出问题几乎都出在“PHP 与外部进程怎么对话”上命令怎么传、管道怎么读、超时怎么管。把这三件事按上面的方式固定下来多音轨、中文文件名、无音轨、卡死这四类线上问题就能提前挡住。