PHP8.5怎么用ffmpeg提取视频里的字幕轨道

前言

视频站、在线教育、字幕组工具链里,「把 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 的数组调用,这套脚本就能稳定跑在各种来源的视频上。

相关推荐
zhanghaha131413 分钟前
C# 系列教程_12 字符串 string 保姆级系统教程(面向新手,大量示例 + 原理讲解)
开发语言·数据库·c#
瑞雪兆丰年兮31 分钟前
JavaWeb-day07-MySQL(DQL&多表设计)
java·开发语言·数据库·sql·mysql·javaweb·web
ShineWinsu3 小时前
对于Qt:QDial常见属性以及信号的解析
开发语言·c++·qt·gui·客户端·旋钮·qdial
Java后端的Ai之路6 小时前
RAG 还是 Agent 自主检索?Claude Code 删掉向量库之后,我把选型讲透了
开发语言·人工智能·prompt·agent·rag
starzy19906 小时前
深入理解Scala高阶函数:函数式编程核心
开发语言·scala
czq_268671948712 小时前
Python打卡第46天
开发语言·python
我叫黑大帅14 小时前
PHP 常驻 Worker 中 MySQL 连接中断问题报告
后端·面试·php
Evand J14 小时前
【MATLAB例程介绍】(17)永磁同步电机(PMSM)基于自适应无迹卡尔曼滤波(AUKF)的电流噪声在线估计。测量噪声协方差自适应与转速估计对比仿真
开发语言·matlab·卡尔曼滤波·无迹卡尔曼滤波·pmsm·电机滤波
BaiPeter15 小时前
ZPL文件预览
开发语言·c++