ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

PHP怎么调用ffmpeg合并视频和srt字幕文件

PHP怎么调用ffmpeg合并视频和srt字幕文件 前言把视频和 srt 字幕合并这件事失败的方式格外安静命令跑完了、退出码是 0、文件也生成了但播放器里就是没有字幕。换一个播放器又有了再换回上一个还是没有。另一类情况是字幕出现了但整段中文变成锟斤拷或者一串问号时间轴却是对的——说明轨道合并成功了问题在字幕文本的编码上。根本原因是合并这个词太含糊它至少对应三种技术上完全不同的做法软字幕把字幕作为独立轨道塞进容器播放器可开关、硬字幕把字幕烧进画面像素不可关闭、同名外挂根本不合并只是文件名对齐。三者的命令、代价、适用容器都不一样选错了就会出现看着成功了其实没生效。本文先讲清三种做法的差别与各自的命令再重点说两个最容易失败的环节MP4 容器的字幕编码限制以及subtitles滤镜在 Windows 路径下的转义。最后给出一个 PHP 侧的封装负责把 srt 统一成 UTF-8、构建参数、解析进度。示例需要 PHP 8.0 及以上以及一份带libass的 ffmpeg 构建硬字幕才需要。一、三种合并的区别做法命令要点是否重编码适用容器播放器可关闭字幕软字幕-c:v copy -c:s mov_text否秒级完成MP4 用mov_textMKV 用srt可以硬字幕-vf subtitlesxxx.srt视频必须重编码任意不可以同名外挂不调用 ffmpeg只重命名否任意可以选择原则很直接内部播放、要保证任何播放器都能看到就烧硬字幕要体积小、要保留原画质、要可开关就做软字幕。软字幕是绝大多数后台转码流程的正确默认值它不动视频流全部工作只是把字幕文本换个封装格式处理一部电影通常只需要几秒。先验证一下你的 ffmpeg 有没有硬字幕所需的组件这决定了后面哪些方案可行ffmpeg -hide_banner -version ffmpeg -hide_banner -filters | grep subtitles # 有 subtitles 滤镜说明带 libass ffmpeg -hide_banner -codecs | grep mov_text # 有 mov_text 说明能往 mp4 塞字幕二、软字幕容器的限制决定了编码参数MP4 容器只认一种字幕编码mov_text也叫 tx3g。这是一个纯文本字幕格式它不支持样式和复杂排版但足够显示普通对白。把 srt 直接copy进 MP4 是不行的必须让 ffmpeg 做一次字幕转码而视频和音频流可以原样复制几乎不耗时。# 需要 UTF-8 编码的 srt。MP4 必须用 mov_text ffmpeg -hide_banner -y \ -i input.mp4 \ -i subtitle.srt \ -map 0:v:0 -map 0:a? -map 1:0 \ -c:v copy -c:a copy -c:s mov_text \ -metadata:s:s:0 languagechi \ -disposition:s:0 default \ -movflags faststart \ output.mp4几个参数值得逐条解释因为它们正是字幕进去了但看不见的原因-map 0:v:0 -map 0:a? -map 1:0显式指定取第一个输入的视频、音频?表示没有音频也不报错和第二个输入的第一条流。不写-map时 ffmpeg 会用默认的自动选流规则多输入场景下很容易漏掉字幕流。-c:s mov_text字幕轨转成 MP4 支持的编码。若目标是 MKV这里改用-c:s srt或-c:s copysrt 到 srt 可以直接复制。-metadata:s:s:0 languagechi给第一条字幕轨打上语言标记。播放器据此自动选轨缺了它有些播放器默认不显示。-disposition:s:0 default把这条字幕轨标记为默认轨进一步提升打开就有字幕的概率。-movflags faststart把索引移到文件头便于边下边播。合并完务必验证别只看退出码。用ffprobe列出流信息ffprobe -hide_banner -v error -select_streams s -show_entries streamindex,codec_name:stream_tagslanguage -of json output.mp4输出里应当能看到一条codec_name为mov_text、带language: chi的流。如果-select_streams s什么都没返回说明字幕根本没进去回到-map那一步检查。三、硬字幕路径转义与字体硬字幕走的是滤镜filtersubtitles滤镜用 libass 渲染字幕再把渲染结果叠加到每一帧画面上所以视频必须重编码速度取决于分辨率和编码参数。# 需要带 libass 的 ffmpeg 构建 ffmpeg -hide_banner -y \ -i input.mp4 \ -vf subtitlessubtitle_utf8.srt:force_styleFontNameMicrosoft YaHei,FontSize24 \ -c:v libx264 -crf 23 -preset medium \ -c:a copy \ output.mp4这里有两个高频事故点。一是滤镜参数里的路径转义。滤镜图filtergraph用:分隔参数、用\做转义而 Windows 路径里恰好有一个盘符冒号。于是subtitlesC:/dir/sub.srt会被解析成参数C和参数/dir/sub.srt报出No option name near /dir/sub.srt之类莫名其妙的错误。三种处理方式从最推荐到最将就# 方案 A最稳先切到字幕所在目录只传文件名彻底绕开路径转义 cd /path/to/subtitles ffmpeg -i /path/to/input.mp4 -vf subtitlessubtitle_utf8.srt out.mp4 # 方案 B按滤镜规则显式转义盘符冒号并用单引号包住 # ffmpeg -i in.mp4 -vf subtitlesC\:/dir/subtitle.srt out.mp4 # 方案 CLinux/macOS 下路径本身没有盘符冒号正常写即可 # ffmpeg -i in.mp4 -vf subtitles/data/subs/a.srt out.mp4方案 A 在 PHP 里尤其好实现因为proc_open()支持指定工作目录cwd参数把字幕目录传进去命令里就只剩一个纯文件名不需要任何转义。这一点在下一节的代码里会用到。二是字体与编码。libass 渲染中文需要系统里真的有对应字体容器或精简镜像里往往只装了拉丁字体结果就是硬字幕渲染成方块。可以通过force_style指定FontName并把字体目录一起交给滤镜。至于字幕文本本身的编码最稳妥的做法是在 PHP 侧统一转成不带 BOM 的 UTF-8再交给 ffmpeg——把编码问题收敛到自己能控制的地方比依赖滤镜的编码探测可靠得多滤镜也提供charenc选项但不同构建表现有差异不作为首选手法。四、PHP 侧的封装下面这份代码覆盖三件事把 srt 规范化为 UTF-8顺手去 BOM、用数组参数构建 ffmpeg 命令避免 shell 转义问题、解析-progress输出拿到进度。需要 PHP 8.0 及以上。?php // srt_mux.php —— 需要 PHP 8.0 及以上ffmpeg 在 PATH 中 declare(strict_types1); const FFMPEG ffmpeg; /** 把 srt 规范化为不带 BOM 的 UTF-8返回新文件路径 */ function normalizeSrt(string $src, string $dir): string { $raw file_get_contents($src); if ($raw false) { throw new RuntimeException(读取字幕失败: {$src}); } // 去掉 UTF-8 BOM否则第一条字幕的时间轴可能被吃坏 if (str_starts_with($raw, \xEF\xBB\xBF)) { $raw substr($raw, 3); } // 不用 mb_detect_encoding 猜先判断是不是合法 UTF-8不是就按 GBK 处理 if (!mb_check_encoding($raw, UTF-8)) { $raw mb_convert_encoding($raw, UTF-8, CP936); } // 统一换行符srt 解析器对 \r\n 与 \n 都能吃但混用容易出怪问题 $raw str_replace([\r\n, \r], \n, $raw); $dst rtrim($dir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . sub_ . bin2hex(random_bytes(6)) . .utf8.srt; if (file_put_contents($dst, $raw) false) { throw new RuntimeException(写入字幕失败: {$dst}); } return $dst; } /** * 软字幕合并视频/音频原样复制只把字幕转成容器支持的编码。 * * return array{code:int, err:string} */ function muxSoftSubtitle(string $video, string $srt, string $out, string $lang chi): array { $srtUtf8 normalizeSrt($srt, dirname($out)); $args [ -hide_banner, -nostdin, -y, -i, $video, -i, $srtUtf8, -map, 0:v:0, -map, 0:a?, -map, 1:0, -c:v, copy, -c:a, copy, -c:s, mov_text, -metadata:s:s:0, language{$lang}, -disposition:s:0, default, -progress, pipe:1, -nostats, $out, ]; $errFile tempnam(sys_get_temp_dir(), fferr); $spec [ 0 [pipe, r], 1 [pipe, w], // 读进度 2 [file, $errFile, w], // 错误写文件避免管道写满死锁 ]; $proc proc_open(array_merge([FFMPEG], $args), $spec, $pipes); if (!is_resource($proc)) { throw new RuntimeException(无法启动 ffmpeg); } fclose($pipes[0]); $lastSeconds -1; while (($line fgets($pipes[1])) ! false) { if (!preg_match(/^(\w)(.*)$/, trim($line), $m)) { continue; } [, $key, $value] $m; if ($key out_time_us $value ! N/A) { // out_time_us 的单位是微秒。想换算成百分比 // 再用 ffprobe -show_entries formatduration 取一次总时长做分母即可 $seconds (int) round((int) $value / 1_000_000); if ($seconds ! $lastSeconds) { $lastSeconds $seconds; // 实际项目里这里可以写进任务表或推给前端 echo \r已处理 {$seconds} 秒; } } } fclose($pipes[1]); $code proc_close($proc); $err is_file($errFile) ? (string) file_get_contents($errFile) : ; if (is_file($errFile)) { unlink($errFile); } if ($srtUtf8 ! $srt is_file($srtUtf8)) { unlink($srtUtf8); } return [code $code, err trim($err)]; } /** 硬字幕用 cwd 参数绕开滤镜图的路径转义 */ function burnSubtitle(string $video, string $srt, string $out): array { $srtUtf8 normalizeSrt($srt, dirname($video)); $subDir dirname($srtUtf8); $subName basename($srtUtf8); $args [ -hide_banner, -nostdin, -y, -i, $video, -vf, subtitles{$subName}:force_styleFontSize24, -c:v, libx264, -crf, 23, -preset, medium, -c:a, copy, $out, ]; $errFile tempnam(sys_get_temp_dir(), fferr); $spec [ 0 [pipe, r], 1 [file, $errFile, w], // 硬字幕不需要解析进度直接落文件 2 [file, $errFile, w], ]; // 第四个参数是工作目录ffmpeg 在字幕目录里启动滤镜里就只需要文件名 $proc proc_open(array_merge([FFMPEG], $args), $spec, $pipes, $subDir); if (!is_resource($proc)) { throw new RuntimeException(无法启动 ffmpeg); } fclose($pipes[0]); $code proc_close($proc); $err is_file($errFile) ? (string) file_get_contents($errFile) : ; if (is_file($errFile)) { unlink($errFile); } if (is_file($srtUtf8)) { unlink($srtUtf8); } return [code $code, err trim($err)]; } // ---- 调用 ---- $r muxSoftSubtitle(D:/media/input.mp4, D:/media/subtitle.srt, D:/media/output.mp4); echo PHP_EOL, $r[code] 0 ? 合并完成\n : 失败: {$r[err]}\n;normalizeSrt()会把字幕写成临时文件proc_open()的数组形式让参数不经过 shell硬字幕那条路径把$subDir传给了proc_open()的第四个参数ffmpeg 的当前目录就是字幕目录滤镜里只需要一个文件名盘符冒号的转义问题自然消失。常见坑点❌ 直接-c copy把 srt 复制进 MP4命令成功但播放器没字幕✅ MP4 只认mov_text必须-c:s mov_text走一次字幕转码MKV 才可以用-c:s copy❌ 不写-map多输入时字幕轨被自动选流规则丢掉✅ 显式-map 0:v:0 -map 0:a? -map 1:0并用ffprobe -select_streams s验证字幕流真的存在❌ Windows 上写成-vf subtitlesC:/dir/a.srt报No option name之类的滤镜解析错误✅ 用proc_open()的cwd切到字幕目录只传文件名或按滤镜规则把冒号转义为\:并用单引号包裹❌ 用 GBK 编码的 srt 直接合并播放器里中文全是乱码✅ 在 PHP 侧统一转成不带 BOM 的 UTF-8 再交给 ffmpegmb_detect_encoding()不可靠用mb_check_encoding()判断再按约定转码❌ srt 开头带 UTF-8 BOM合并后第一条字幕的时间轴或文本出现异常✅ 读入后先判断并剥离\xEF\xBB\xBF❌ 硬字幕在没有中文字体的容器里渲染出方块✅ 镜像里安装中文字体或用force_style指定实际存在的FontName必要时配合字体目录选项❌ 用stream_get_contents()一次读 stderr 又在读进度长任务互相等待导致死锁✅ 进度走 stdout 逐行读stderr 重定向到文件[file, $path, w]彻底断开这条死锁路径❌ 只检查退出码就认为成功忽略了 ffmpeg 成功但没做事的情况✅ 合并后用ffprobe检查字幕流数量与语言标记硬字幕则抽查画面或对比输出体积是否远大于原文件总结需求做法关键参数MP4 内挂字幕快速无损软字幕-c:v copy -c:a copy -c:s mov_textMKV 内挂字幕软字幕-c:s srt或-c:s copy任何播放器都能看到硬字幕-vf subtitles... 重编码字幕默认显示两条元数据-metadata:s:s:0 languagechi、-disposition:s:0 default字幕编码乱码PHP 侧归一化mb_check_encoding()mb_convert_encoding()Windows 路径报错绕开滤镜转义proc_open()的cwd 纯文件名长任务进度解析 stdout-progress pipe:1 -nostats读out_time_us只要记住三种合并不是一回事再盯住两个真正的失败点——MP4 的字幕编码限制和字幕文本的编码归一化——剩下的就是拼参数。流程上建议固定成先normalizeSrt()统一编码再按目标容器选软/硬方案最后一定用ffprobe验证轨道确实存在而不是只看退出码。
返回列表