一个做PHP后端的同事跟我提过一个需求:直播录制文件要回放切片,但切片点不能按常规时间戳来,得按直播间里真实发生的"答题卡下发时刻"来切。这些业务标记不在数据库里,而是写进了视频编码层的SEI字段里。我当时第一反应是"那就用FFmpeg把SEI读出来呗",结果真正动手才发现,PHP侧怎么优雅地调用FFmpeg、怎么解析SEI、怎么在写入时不让数据被转封装丢掉,每个环节都有不少门道。
这篇文章就来系统讲一套PHP+FFmpeg处理SEI的完整方案:从SEI是什么、为什么业务里用得上,到PHP环境怎么选型、FFmpeg怎么调,再到读取SEI、写入SEI的具体实现,最后用一个真实案例把链路串起来。适合PHP后端、音视频开发、以及所有需要跟视频流打交道的同学参考。整个方案不依赖复杂扩展,PHP原生能力加FFmpeg就能落地。
1. 先搞懂SEI到底是什么:一个"视频附赠信息通道"的底层逻辑
1.1 H.264码流里的"信息牌"长什么样
视频文件看起来是一个整体,但到了编码层,其实是无数个NAL单元拼接而成的。H.264码流里,每个NAL单元前面都有一段起始码,常见的可以是00 00 01,也可以是00 00 00 01。不同的NAL单元各司其职:SPS、PPS描述编码参数,IDR帧是解码器重新同步的关键帧,普通帧承载画面数据。
SEI的全称是Supplemental Enhancement Information,翻译过来是"辅助增强信息"。它本身不参与画面解码,不承载像素数据,而是以独立NAL单元的形式插在码流里,给解码器或者后端处理系统传递一些"附赠信息"。你可以把它理解成高速公路主干道旁边的可变情报板:车流(图像数据)正常走自己的车道,情报板不载车,但路过的人都能看到上面的提示。
在H.264里,SEI的NAL单元类型是6;到了H.265,SEI分为前缀SEI(类型39)和后缀SEI(类型40)。SEI内部有很多payloadType,其中业务上最常用的是payloadType=5,也就是user_data_unregistered(用户自定义未注册数据),它允许你放一个16字节的UUID加任意字节内容。我们自己做业务标记,基本都走这个类型。
1.2 业务上什么时候值得上SEI
SEI最大的特点是"跟视频帧强绑定"。它活在编码层里,只要视频流不被重新编码,无论你把它封装成MP4、TS、FLV,还是通过HLS、RTMP、DASH分发,SEI都能跟着走。这就让它非常适合下面这几类场景:
- 直播互动对齐:弹幕、答题卡、礼物特效的触发时机,如果走网络通道单独下发,很容易跟画面不同步。写进SEI后,观众端播放器解码到那一帧,就能同步触发互动指令。
- 录制文件的业务标记:直播间录制下来的文件,可能要交给审核、剪辑、切片等下游系统。把房间号、真实时间戳、录制设备ID写进SEI,下游拿到文件直接解析即可,不需要额外匹配数据库。
- 广告插播与版权溯源:在特定帧位置打上广告位标记;或者给不同分发渠道生成不同SEI标识,一旦视频被录屏传播,可以通过SEI定位泄露源。
- 多码率转码的元数据底座:转封装、转协议时容器metadata容易丢,而SEI在编码层,copy模式下基本能完整保留,天然适合做多链路分发的中转数据。
1.3 为什么不用MP4 metadata、数据库或JSON文件?
很多人会问:我在MP4文件里写个metadata不就行了吗?或者我在数据库里存一份"这个时间段对应哪个业务事件"不就行了?这得看场景。
MP4的metadata是容器层面的,一旦转封装成TS、FLV,或者切片成HLS分片,这些信息大多保不住。数据库和JSON走的是旁路通道,依赖网络和系统间的状态同步,做不到"跟这一帧的画面严格绑定"。比如直播场景中,观众说"刚才老师讲到第三题时我这边卡了10秒",你要基于画面帧定位问题,没有SEI就得靠猜。SEI不是银弹,但凡是"信息必须随视频流走、且必须精确到帧"的场景,它都是最合适的载体。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. PHP侧FFmpeg环境选型:三种部署方式与一个安全底线
2.1 三种常见的落地方式
PHP项目里要跑FFmpeg,部署方式直接决定后面的坑多坑少。我实际接触过的项目,基本是三条路线:
| 部署方式 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 系统包安装(apt/yum) | 安装简单,路径固定 | 版本通常偏旧,编解码器可能不全 | 内网环境,功能要求不高的快速落地 |
| 静态编译二进制 | 不依赖系统动态库,下载即用 | 和系统glibc可能有不兼容,协议支持要自己确认 | 没有root权限、需要自带工具的容器或机器 |
| Docker镜像内置 | 环境可复现,版本可控 | 镜像体积大,需处理宿主机与容器映射 | 微服务、K8s部署,标准化程度高的团队 |
热词里经常看到"centos7安装ffmpeg""ffmpeg下载""ffmpeg 3.4 win64 static"这类搜索,说明很多PHP开发者在环境这步就卡过。CentOS 7自带的FFmpeg版本比较老(甚至默认仓库里没有),如果你走静态二进制路线,建议下载前先确认两件事:一是二进制的编译日期和FFmpeg版本,尽量选4.4以上;二是确认编译时带了--enable-libx264 --enable-libx265 --enable-gpl等必要选项,否则后面转H.264/H.265时直接报unknown encoder。
如果团队已经在用Docker,我强烈建议直接把FFmpeg装进PHP镜像里,或者单独起一个带FFmpeg的worker容器。这样做的好处是版本锁死、环境一致,不会出现"我本地跑得好好的,服务器上命令不认"的问题。下面是一个比较省事的PHP Dockerfile片段:
dockerfile复制FROM php:8.2-cli
RUN apt-get update && apt-get install -y ffmpeg \
&& rm -rf /var/lib/apt/lists/*
COPY . /app
WORKDIR /app
2.2 PHP调FFmpeg的安全底线
PHP里执行FFmpeg,最常见的方式是exec()、shell_exec()或proc_open()。我见过不少线上事故,根因就是直接把用户输入拼进命令行,比如:
php复制exec("ffmpeg -i " . $file . " output.mp4");
这种写法等于把服务器权限敞开给人用。文件名里带个; rm -rf /都不是开玩笑。所以安全底线是:所有参数必须经过escapeshellarg()转义,或者干脆用proc_open()的数组传参模式。
PHP 7.4以上,proc_open()支持直接传参数数组,不走shell,这是最干净的方式。我平时封装FFmpeg调用,会做一个统一的runner,把超时、日志、错误收集都收口。核心代码大概长这样:
php复制function runFfmpeg(array $args, int $timeout = 120): array
{
$cmd = array_merge([
'ffmpeg',
'-hide_banner',
'-loglevel', 'error',
'-y',
], $args);
$descriptors = [
0 => ['pipe', 'r'],
1 => ['pipe', 'w'],
2 => ['pipe', 'w'],
];
$proc = proc_open($cmd, $descriptors, $pipes);
if (!is_resource($proc)) {
throw new RuntimeException('无法启动ffmpeg进程');
}
fclose($pipes[0]);
$stdout = stream_get_contents($pipes[1]);
fclose($pipes[1]);
$stderr = stream_get_contents($pipes[2]);
fclose($pipes[2]);
$code = proc_close($proc);
return [
'code' => $code,
'stdout' => $stdout,
'stderr' => $stderr,
];
}
-y参数是自动覆盖输出文件,没有它,第二次执行时FFmpeg会交互式询问"是否覆盖",在PHP里直接卡死等输入。这个坑后面还会细说。
有一点要注意:stream_get_contents()会阻塞到进程结束,所以你给的$timeout参数在这里其实没有真正生效。在实际项目里,更稳妥的做法是把它丢进队列,由CLI worker去执行,而不是在Web请求里同步等结果。
3. 读取SEI的完整链路:从容器文件到H.264裸流再到PHP解析
3.1 第一步:把封装文件转成H.264 Annex-B裸流
SEI属于编码层数据,但MP4封装格式里,H.264数据通常以AVCC格式存储,也就是每个NAL单元前面用几个字节记录长度,而不是用起始码。这就导致你直接打开MP4文件根本找不到00 00 00 01这种起始码。所以读取SEI的第一步,是把视频流转成H.264 Annex-B裸流格式。
对于MP4输入,标准做法是:
bash复制ffmpeg -i input.mp4 -c:v copy -bsf:v h264_mp4toannexb -f h264 output.h264
这里解释几个关键点:
-c:v copy表示视频流不重新编码,只是转封装,速度极快,而且能最大程度保留原有SEI数据。一旦重新编码,x264默认不会帮你保留自定义SEI。-bsf:v h264_mp4toannexb是bitstream filter,负责把AVCC格式转成Annex-B起始码格式。-f h264表示输出裸H.264流。
如果你手里的输入是TS文件(比如HLS切片合并后的产物),TS本身就是Annex-B格式,不需要转换bitstream,直接这样提取就行:
bash复制ffmpeg -i input.ts -c:v copy -f h264 output.h264
热词里"ffmpeg怎么把m4s转换成mp4""ffmpeg合并多个ts文件"这类需求,如果你后续要解析SEI,可以参考同样的思路:先提取h264裸流,而不是直接去做完整转码。
3.2 第二步:用ffprobe或trace_headers做快速验证
在写PHP解析脚本之前,建议先手动验证一下这个视频里到底有没有SEI,避免后面白忙活。FFmpeg提供了两种快速验证方式。
第一种是trace_headers这个bitstream filter,它会把码流中每个NAL单元的头信息都打印出来,我们直接过滤SEI关键词:
bash复制ffmpeg -i input.mp4 -c:v copy -bsf:v trace_headers -f null - 2>&1 | grep -i sei
第二种是直接用ffprobe查看帧信息,但坦白说,ffprobe对自定义SEI的展示并不稳定,很多自定义user data不会出现在side_data_list里。我一般用ffprobe来看关键帧结构,确认视频编码信息,用trace_headers来确认SEI是否存在。
3.3 第三步:PHP解析H.264裸流中的SEI NAL
确认有SEI之后,就可以写PHP解析了。核心思路是:遍历H.264 Annex-B裸流,找到所有起始码,按起始码位置切出NAL单元,读取NAL header的低5位判断类型,如果是6(SEI),就进入SEI payload解析。
完整的PHP解析类我放在下面,这个代码可以直接落地用:
php复制class H264SeiParser
{
public function parseFile(string $filePath): array
{
$data = file_get_contents($filePath);
if ($data === false) {
throw new RuntimeException('无法读取文件: ' . $filePath);
}
return $this->parseStream($data);
}
public function parseStream(string $data): array
{
$startCodes = $this->findStartCodes($data);
$seiList = [];
foreach ($startCodes as $i => $sc) {
$nalStart = $sc['pos'] + $sc['prefixLen'];
$nextPos = isset($startCodes[$i + 1]) ? $startCodes[$i + 1]['pos'] : strlen($data);
$nalData = substr($data, $nalStart, $nextPos - $nalStart);
if ($nalData === '' || strlen($
