前言
视频站、在线教育、字幕组工具链里,「把 MKV 里的字幕扒出来」是个高频需求。很多人第一次写的时候是这么干的:shell_exec("ffmpeg -i $file -map 0:s:0 out.srt"),本地测试通过,上线后遇到三种症状:换一个文件就报 Stream map '0:s:0' matches no streams;导出成功但 .srt 打开是乱码;批量处理几十个文件后有进程卡死不动,CPU 却是 0。
根因有三个。第一,「字幕轨道」在 ffmpeg 里是分类型的 :文本字幕(SubRip、ASS)可以转成 .srt,而 DVD/PGS 这类位图字幕 根本没法直接转文本,必须 OCR。不看编码类型就无脑加 -c:s srt,结果就是报错或空文件。第二,0:s:0 里的 s 是字幕流序号 ,不是文件的绝对流序号,两者混用必错。第三,shell_exec 拼字符串既容易被文件名注入,也拿不到退出码和标准错误,出错时你什么都看不见。
本文用一段完整可运行的代码,讲清三件事:用 ffprobe 列出所有字幕轨道及其编码类型、按类型选择正确的导出方式、用 proc_open 的数组形式安全地调用外部命令并拿到退出码。示例代码需要 PHP 7.4 及以上 (proc_open 支持数组形式的命令),文中的语法按 PHP 8.1 写,PHP 8.5 同样适用------这个话题本身和 PHP 版本关系不大,真正需要对齐的是 ffmpeg 自身的版本。
一、先分清:文本字幕和位图字幕
这是整件事里最容易出错的分界线。字幕流按存储方式分两类:
| 类型 | 典型 codec_name |
本质 | 能否直接转成 .srt |
|---|
|------|------------------------------------------|-----------|--------------|
| 文本字幕 | subrip、ass、ssa、webvtt、mov_text | 存的是字符和时间轴 | 能,-c:s srt |
|------|---------------------------------------------------|----------|-----------|
| 位图字幕 | hdmv_pgs_subtitle、dvd_subtitle、dvb_subtitle | 存的是每帧的图片 | 不能,只能原样导出 |
|------|---------------------|---------|-----------|
| 隐藏字幕 | eia_608、eia_708 | 北美电视字幕流 | 视情况,通常要转码 |
位图字幕转文本需要 OCR(光学字符识别),ffmpeg 本身不做这件事。硬来的结果只有两种:直接报编码器不支持的错,或者导出一个 0 字节的文件而 ffmpeg 的退出码是 0 ------ 后者最坑,因为它看起来「成功了」。
所以流程必须是两步:先探测,再决定怎么导。
二、用 ffprobe 列出所有字幕轨道
ffprobe 是 ffmpeg 套件自带的探测工具,-of json 让它输出 JSON,PHP 解析起来最省事。探测字幕流的关键参数是 -select_streams s:
bash
ffprobe -v error -select_streams s \
-show_entries stream=index,codec_name:stream_tags=language,title \
-of json input.mkv
输出的结构大致是这样,index 是文件里的绝对流序号,后面映射时要用它:
json
{
"streams": [
{"index": 2, "codec_name": "subrip", "tags": {"language": "chi", "title": "简体中文"}},
{"index": 3, "codec_name": "ass", "tags": {"language": "eng", "title": "English"}},
{"index": 4, "codec_name": "hdmv_pgs_subtitle", "tags": {"language": "jpn"}}
]
}
注意这里的 index 可能是 2、3、4 ------ 因为 0、1 通常是视频和音频。而 -map 0:s:0 指的是「第 0 条字幕流」,也就是 index 为 2 的那条。绝对序号和字幕序号是两套编号,混用是必踩的坑。
三、用 proc_open 安全地调用命令
不要用 shell_exec("ffmpeg -i $file ...") 拼字符串。proc_open 从 PHP 7.4 起支持把命令传成数组,PHP 会自己做参数转义,完全不经过 shell ,文件名里带空格、引号、;、$() 都无所谓。
php
<?php
declare(strict_types=1);
// srt_extract.php ------ 需要 PHP 7.4+(proc_open 数组形式),示例按 PHP 8.1 写
/** 判断某个 ffmpeg 字幕编码是不是文本型(能转成 SRT) */
const TEXT_SUBTITLE_CODECS = ['subrip', 'srt', 'ass', 'ssa', 'webvtt', 'mov_text', 'text'];
/**
* 运行外部命令,返回 [退出码, 标准输出, 标准错误]。
*
* 命令以数组传入:proc_open 自行处理参数转义,不经过 shell,天然免疫命令注入。
* 注意:这里是「先把 stdout 读完再读 stderr」的简单写法,
* 只有当子进程往 stderr 写出超过管道缓冲区(通常 64KB)的内容时才可能死锁,
* ffprobe/ffmpeg 在 -v error 下不会出现这种情况。
*/
function run(array $cmd): array
{
$proc = proc_open($cmd, [
0 => ['pipe', 'r'],
1 => ['pipe', 'w'],
2 => ['pipe', 'w'],
], $pipes);
if (!is_resource($proc)) {
throw new RuntimeException('无法启动进程:' . implode(' ', $cmd));
}
fclose($pipes[0]); // 立刻关掉 stdin,子进程不会等输入
$stdout = (string) stream_get_contents($pipes[1]);
$stderr = (string) stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
return [proc_close($proc), $stdout, $stderr];
}
/**
* 列出文件里的全部字幕轨道。
*
* @return list<array{index:int, codec:string, language:string, title:string, text:bool}>
*/
function listSubtitleTracks(string $file): array
{
if (!is_file($file)) {
throw new RuntimeException("文件不存在:{$file}");
}
[$code, $out, $err] = run([
'ffprobe', '-v', 'error',
'-select_streams', 's',
'-show_entries', 'stream=index,codec_name:stream_tags=language,title',
'-of', 'json',
$file,
]);
if ($code !== 0) {
throw new RuntimeException("ffprobe 退出码 {$code}:{$err}");
}
$data = json_decode($out, true, 512, JSON_THROW_ON_ERROR);
$tracks = [];
foreach ($data['streams'] ?? [] as $stream) {
$codec = (string) ($stream['codec_name'] ?? '');
$tracks[] = [
'index' => (int) $stream['index'],
'codec' => $codec,
'language' => (string) ($stream['tags']['language'] ?? 'und'),
'title' => (string) ($stream['tags']['title'] ?? ''),
'text' => in_array($codec, TEXT_SUBTITLE_CODECS, true),
];
}
return $tracks;
}
/**
* 导出单条字幕。
*
* $absoluteIndex 是 ffprobe 给出的绝对流序号;
* $convert 为 true 时转成 SRT(仅文本字幕可用),false 时原样复制。
*/
function extractSubtitle(string $file, int $absoluteIndex, string $outFile, bool $convert): void
{
[$code, , $err] = run([
'ffmpeg', '-nostdin', '-y', '-v', 'error',
'-i', $file,
'-map', '0:' . $absoluteIndex,
'-c:s', $convert ? 'srt' : 'copy',
$outFile,
]);
if ($code !== 0) {
throw new RuntimeException("ffmpeg 退出码 {$code}:{$err}");
}
// 退出码为 0 不代表真有内容,必须再确认一次文件大小
$size = is_file($outFile) ? (int) filesize($outFile) : 0;
if ($size === 0) {
throw new RuntimeException("导出结果为空文件:{$outFile}(可能是位图字幕),请检查编码类型");
}
}
// ---------------- 主流程 ----------------
$input = $argv[1] ?? '';
if ($input === '') {
exit("用法: php srt_extract.php <视频文件>\n");
}
try {
$tracks = listSubtitleTracks($input);
} catch (Throwable $e) {
exit('探测失败:' . $e->getMessage() . "\n");
}
if ($tracks === []) {
exit("这个文件里没有字幕轨道\n");
}
$base = pathinfo($input, PATHINFO_FILENAME);
$dir = dirname($input);
printf("共发现 %d 条字幕轨道:\n\n", count($tracks));
printf("%-6s %-22s %-8s %-6s %s\n", '序号', '编码', '语言', '可转文本', '标题');
foreach ($tracks as $i => $t) {
printf(
"%-6d %-22s %-8s %-6s %s\n",
$t['index'],
$t['codec'],
$t['language'],
$t['text'] ? '是' : '否',
$t['title']
);
}
echo "\n开始导出......\n";
foreach ($tracks as $i => $t) {
$suffix = $t['language'] . '.' . $i;
try {
if ($t['text']) {
$out = "{$dir}/{$base}.{$suffix}.srt";
extractSubtitle($input, $t['index'], $out, true);
} else {
// 位图字幕不能转文本,只能原样导出,扩展名用 .sup / .sub
$out = "{$dir}/{$base}.{$suffix}." . ($t['codec'] === 'dvd_subtitle' ? 'sub' : 'sup');
extractSubtitle($input, $t['index'], $out, false);
}
printf(" 第 %d 条 -> %s(%d 字节)\n", $t['index'], basename($out), (int) filesize($out));
} catch (Throwable $e) {
printf(" 第 %d 条 导出失败:%s\n", $t['index'], $e->getMessage());
}
}
echo "完成\n";
用法:
bash
php srt_extract.php demo.mkv
输出会是先一张轨道清单,再逐条给出导出结果:
text
共发现 3 条字幕轨道:
序号 编码 语言 可转文本 标题
2 subrip chi 是 简体中文
3 ass eng 是 English
4 hdmv_pgs_subtitle jpn 否
开始导出......
第 2 条 -> demo.chi.0.srt(18342 字节)
第 3 条 -> demo.eng.1.srt(21107 字节)
第 4 条 -> demo.jpn.2.sup(4823104 字节)
第 3 条原本是 ASS,导出成 .srt 时 ffmpeg 会做格式转换,样式信息(字体、位置、特效)会丢失,这是格式本身的限制,不是参数问题。需要保留样式就改导 .ass 并用 -c:s copy。
常见坑点
1. 用 shell_exec 拼字符串调用 ffmpeg
❌ shell_exec("ffmpeg -i {$file} -map 0:s:0 out.srt") ------ 文件名里只要有一个空格或分号,命令就被截断或注入;而且失败时你只能拿到 null,退出码和错误信息全丢了。 ✅ 用 proc_open 的数组形式(PHP 7.4+),或至少用 escapeshellarg() 包住每个参数并检查 $returnVar。
2. 对位图字幕用 -c:s srt
❌ 导出 PGS 字幕时加 -c:s srt ------ ffmpeg 报 Subtitle encoding currently only possible from text to text,或者产出一个 0 字节文件却返回退出码 0。 ✅ 先看 codec_name:文本字幕才转 SRT,位图字幕一律 -c:s copy,扩展名用 .sup 或 .sub。要转文本,得另外接 OCR 工具链。
3. 把 0:s:0 当成绝对流序号
❌ 文件里字幕流在 index 2、3、4,却写 -map 0:0,结果把视频流当字幕处理。 ✅ -map 0:s:N 是「第 N 条字幕流」,-map 0:<index> 是绝对序号。稳妥做法是始终用 ffprobe 给出的绝对 index 加 -map 0:<index>。
4. 用 -c:s copy 却把文件命名成 .srt
❌ 源是 ASS,加 -c:s copy 导出成 .srt ------ 文件内容还是 ASS 文本,播放器按 SRT 解析,时间轴和样式全乱。 ✅ 容器格式要和解码器匹配:想转 SRT 就用 -c:s srt,想原样保留样式就导 .ass 并 -c:s copy。
5. 只看退出码,不看输出文件
❌ if ($code === 0) { echo "成功"; } ------ 空文件也是「成功」。 ✅ 退出码为 0 之后,再检查 filesize($outFile) 是否大于 0,两条都过才算成功。
6. 忘了 -nostdin,批量处理时子进程吃掉父进程的输入
❌ 在循环里连续调用 ffmpeg,不写 -nostdin。在某些环境下 ffmpeg 会尝试从标准输入读取键盘命令,表现为进程挂住、CPU 占用为 0。 ✅ 所有非交互调用都加上 -nostdin(已经读到 EOF 的管道同理),并加 -y 避免交互式覆盖确认。
7. 把带字体附件的 MKV 转成 ASS 时丢了字体
❌ 只想导出字幕用 -map 0:s:1,MKV 里内嵌的字体附件没跟着走;字幕里的自定义字体在别处渲染不出来。 ✅ 需要连字体一起导出时,把附件流也映射上:-map 0:s:1 -map 0:t?(? 表示没有附件也不报错),并配 -c:t copy。
8. proc_open 读管道的顺序造成死锁
❌ 先 stream_get_contents($pipes[1]) 读完 stdout,再读 stderr,而子进程往 stderr 写了超过管道缓冲区的内容,就会卡住。 ✅ -v error 把 stderr 压到最小;输出量确实可能很大的场景,改用 stream_select() 同时读两个管道,或把 stderr 重定向到临时文件。
总结
| 环节 | 做法 | 要点 |
|---|
|----|--------------------------------------|----------------------------|
| 探测 | ffprobe -select_streams s -of json | 拿绝对 index 和 codec_name |
|----|----------|------------------|
| 分类 | 文本 vs 位图 | 位图不能转 SRT,只能原样导出 |
|----|------------------|--------------|
| 调用 | proc_open 数组形式 | 不经 shell,免注入 |
|----|-----------------|------------------|
| 映射 | -map 0:<绝对序号> | 别把 s:N 和绝对序号混用 |
|----|--------------------------|----------|
| 导出 | -c:s srt 或 -c:s copy | 容器扩展名要匹配 |
|----|------------|-----------|
| 校验 | 退出码 + 文件大小 | 空文件也是「成功」 |
用 ffmpeg 处理字幕,难点从来不在命令本身,而在先探测再决策 这条纪律。把「列出轨道 → 判断编码类型 → 选择导出方式 → 双重校验」固定成流程,再配上 proc_open 的数组调用,这套脚本就能稳定跑在各种来源的视频上。