ffplay 是很多人接触 FFmpeg 的第一站,敲一行ffplay video.mp4就能弹出一个视频窗口,还能顺手看滤镜怎么工作。但真要给它加一个“文字字幕渲染”功能,体验就完全不一样了——默认情况下的 ffplay 对字幕的支持,其实并没有想象中那么“开箱即用”。这篇文章就来记录我折腾这个过程的全套思路、代码层面的关键改法、踩过的一堆坑,以及最后稳定跑起来的方案。
这个需求看起来只是“让播放器能显示几行字”,但真拆开之后,它牵扯到字幕流解封装、字幕解码、字形栅格化、像素格式转换、时钟同步、SDL 纹理更新,是一整套小型播放器子系统。我选择不依赖 libass,而是用 FreeType 自己实现一个轻量的文字字幕渲染器,理由也很简单:一是想彻底搞懂字幕到底是怎么上屏的,二是 libass 的依赖和样式端太重,很多时候我们只需要把外部 SRT 文件或者内置文本字幕流渲染成简单白字带黑边,完全没必要拉一大坨样式引擎进来。这篇文章适合已经在用 ffplay、懂一点 FFmpeg API、并且想往播放器方向深入的朋友,你跟着走一遍,基本就能得到一个属于自己的“可定制字幕版 ffplay”。
1. 需求拆解:ffplay 的字幕现状与“文字字幕渲染”到底指什么
1.1 ffplay 默认字幕支持的边界
先说结论:ffplay 并不是完全没有字幕能力。如果你用的发行版 FFmpeg 编译时带了 libass,那 ffplay 播放内嵌 ASS/SSA 字幕或者通过滤镜加载字幕,是可以正常显示文本并带样式渲染的。这个路径背后是 FFmpeg 自带的字幕解码器 + libass 的排版引擎,整个功能在ffplay.c的subtitle_thread和视频显示流程里已经串联好了。
但实际情况往往是这样的:很多自己编译 FFmpeg 的人根本不会主动开启--enable-libass,默认 configure 出来的 ffplay 对文本字幕支持就很弱。此时你播放一个带 SRT 字幕流的 MP4,控制台可能半天没反应,画面上一行字都不给你。就算你通过-vf "subtitles=file.srt"去加载外挂字幕,滤镜也需要内部依赖 libass 才能工作。所以第一个边界很清楚:ffplay 的字幕能力是“可选特性”,不是“基础能力”。
第二个边界是即使 libass 存在,我们依然很难做精细控制。比如我想让字幕字体自动跟随系统主题、想让字幕带描边和阴影但不要 ASS 的那套样式语法、想根据播放状态实时显示 OSD 信息比如“当前音量 80%”。这些需求硬往 libass 上靠会很别扭,因为 libass 是为 ASS 字幕协议设计的,属于“为字幕而字幕”。而我们要做的“文字字幕渲染功能”,本质上是一层可以叠加任意文本的轻量引擎,它应该能自己控制字体、颜色、位置、时间,而不是被字幕文件格式牵着鼻子走。
1.2 什么是我们要做的“文字字幕渲染”
把需求缩小到最核心的表述:让 ffplay 能从字幕流里读出文本,解析出每个字幕事件的时间轴,然后用 FreeType 把文本渲染成位图,最后把位图合成到正在显示的视频画面上,随主时钟同步更新。
这里面“文字字幕”包含三个层次:
- 内部字幕流:MP4/MKV 里封装的 SRT 或 ASS 文本流,属于
AVMEDIA_TYPE_SUBTITLE。 - 外部字幕文件:常见的是
.srt文件,也可以理解成需要额外打开并解析的文本资源。 - 动态叠加文字:比如播放器的 OSD 文字层,不属于字幕流,但渲染逻辑完全一样。
这三个层次在工程实现上可以共用同一套“文本 -> 字形位图 -> 合成到帧”的管线。区别只在于文本来源和时间轴管理。我最终实现的 ffplay 修改版,把这三类全部接上了,核心代码控制在几百行内,比想象中清爽。
1.3 为什么不自接 libass 或 SDL_ttf
我在动手前其实对比过三条路线:libass、SDL_ttf、FreeType 自绘。
libass 的问题是 API 侧重 ASS 事件处理,它有自己的事件结构、自己的布局引擎,文本必须转成 ASS 风格标签才有样式。如果只渲染普通 SRT 多行文本,libass 属于杀鸡用牛刀。
SDL_ttf 虽然简单,但它会引入 SDL 扩展库,而且它的字体渲染默认不支持复杂排版,字体样式控制也很有限,更重要的是它把你死死绑在 SDL 纹理上,很难做“字幕先合成到视频帧再统一显示”这种灵活流程。
FreeType 是最底层的字形栅格化库,加载 TTF/OTF 字体、提取字形轮廓、生成 8bit 灰度位图都干得漂亮。我们只需要在它上面包一层简单的文本布局逻辑,就能自己控制行高、间距、描边、阴影。选它的核心原因就是:自由度最大,依赖最小,而且它本来就是 libass 和 SDL_ttf 的底层基石。自己写一遍这个过程,你会对文字渲染有完全不同的理解。
2. 整体方案:五层结构与关键技术选型
2.1 播放器里的字幕数据流
传统 FFmpeg 播放器的字幕数据流可以概括成:
输入文件 -> avformat_open_input -> 找字幕流 -> 打开解码器 -> av_read_frame 取字幕 packet -> avcodec_decode_subtitle2 解码 -> AVSubtitle 事件列表 -> 字幕队列/事件表 -> 根据主时钟选取当前字幕 -> 字形栅格化 -> 混合到视频帧 -> SDL 上屏这比音频视频的数据流多了一个非常重要的特点:字幕事件是“稀疏出现”的。你不可能每帧都去解码字幕,只需要在事件需要显示的那段时间把它渲染出来。所以设计时要把“字幕事件的解析和解码”与“字幕位图的绘制”解耦开。
我最终采用的架构是五层:
- 读写层:负责打开输入文件或外部字幕文件,取出字幕流。
- 解码层:拿到字幕 packet,调用
avcodec_decode_subtitle2得到AVSubtitle。 - 调度层:把
AVSubtitle里每个显示区间都转成一个独立的字幕事件,保存到按时间排序的列表中,由播放主时钟驱动。 - 渲染层:把字幕事件的文本交给 FreeType,生成字幕位图缓存。
- 合成显示层:在视频帧上传 SDL 之前,把字幕位图按坐标混合进像素缓冲区。
把渲染层和调度层分开特别重要。后续你想做字幕样式缓存、多语言切换、位置记忆,都只需要在这两层中间加逻辑,不会把解码流程搅乱。
2.2 技术选型对比:为什么是这些库
| 层次 | 可选方案 | 我最终的选择 | 理由 |
|---|---|---|---|
| 解封装/解码 | FFmpeg 全套 API | FFmpeg libavcodec/libavformat | 项目本身就是要改 ffplay,必须用它 |
| 窗口与显示 | SDL1.2 / SDL2 | SDL2 | ffplay 当前主线默认 SDL2,纹理接口更干净 |
| 字形渲染 | FreeType / stb_truetype / SDL_ttf | FreeType | 成熟稳定,字形位图可控,可做描边阴影 |
| 字幕格式解析 | FFmpeg 解码器 / 手写 parser | 优先 FFmpeg 解码器 + 简单手写兜底 | 减少自己处理边缘情况的成本 |
| 视频像素处理 | sws_scale / 直接操作 YUV | sws_scale 转 RGB 后合成 | YUV420 下手写字幕很痛苦,RGB 合成更直观 |
这里单独说下为什么最终合成要在 RGB 空间做。视频解码出来通常是 YUV420P,字幕是带透明度的灰度位图。如果直接在 YUV 上混合,你得手动处理色度采样问题:字幕的白色边缘在 U/V 分量上怎么过渡?Y 平面改了,U/V 平面要不要改?稍微处理不好就会出现“字幕周围一圈紫边”。而一旦转成 RGB24 或 ARGB,字幕合成就是纯粹的 alpha blending:
dst = src * alpha + text_color * alpha逻辑非常直白,出问题的概率小很多。代价是多一次sws_scale转换,但现代 CPU 处理 1080p 的 RGB 转换完全在可接受范围内。我实测在普通笔记本上,1080p 视频加一层字幕渲染,整体帧率降幅可以控制在 5% 以内。
2.3 修改策略:接管还是并行
ffplay 源码里本来就有字幕线程,如果编译时带 libass,字幕会走ass_process_chunk之类的通路。我们有两种改法:
- 完全接管:编译时关闭 libass,自己写字幕事件队列和渲染入口。
- 并行插入:保留 ffplay 原有字幕线程,但在视频帧上屏前插入一层“自绘字幕”逻辑,用自己的队列控制显示。
我选择的是“并行插入”思路,因为这样改动面最小。ffplay 中video_refresh是每一帧显示前的必经之地,我只需要在这一层加一个函数调用:根据当前时钟去自己的字幕事件表里找事件,如果找到就渲染到这个帧上,然后让 ffplay 继续走 SDL 更新流程。原来那个字幕线程不去动它,它没活干也不影响我。这个策略最大的好处是回退简单:想回到原版,注释掉一个函数调用就行。
3. 核心细节拆解:解码、解析、渲染、同步
3.1 从文件里把字幕流捞出来
所有字幕工作的起点,是拿到字幕流索引和解码器。FFmpeg 提供了一条捷径:
int sub_idx = av_find_best_stream(ic, AVMEDIA_TYPE_SUBTITLE, -1, -1, NULL, 0);如果没有找到字幕流,sub_idx会是负数。要注意,很多 MP4 封装里字幕流和视频流不在同一个程序流里,av_find_best_stream可能会优先选到音频,最好再遍历一遍所有流确认类型。我的习惯是先存下第一个AVMEDIA_TYPE_SUBTITLE的流,然后创建对应的解码器上下文:
AVCodecContext *sub_dec_ctx = avcodec_alloc_context3(codec); avcodec_parameters_to_context(sub_dec_ctx, ic->streams[sub_idx]->codecpar); avcodec_open2(sub_dec_ctx, codec, NULL);对文本字幕而言,这个sub_dec_ctx主要是用来识别解码器类型,比如subrip对应 SRT,ass对应 ASS。如果我们要接外部 SRT 文件,还有个更省事的办法:用avformat_open_input直接打开.srt文件,FFmpeg 会自动识别出srt格式的字幕流,然后走同样的解码流程。也就是说,内部字幕和外部字幕在解码层可以完全统一。
3.2 从 AVPacket 到 AVSubtitle 的转换
字幕解码的关键函数是avcodec_decode_subtitle2。它的用法和音频视频解码不太一样:
AVSubtitle subtitle; int got_subtitle = 0; int ret = avcodec_decode_subtitle2(sub_dec_ctx, &subtitle, &got_subtitle, pkt);got_subtitle为 1 时,subtitle里保存着本次解码出的所有字幕矩形,用subtitle.num_rects表示个数。每个AVSubtitleRect有type字段,字体字幕的 type 通常是SUBTITLE_ASS或者纯文本类型。对SUBTITLE_ASS类型,rect->ass里保存的是一段带 ASS 标签的文本,对纯文本字幕,rect->text就是我们要渲染的字符串。
这里有个非常容易踩坑的点:AVSubtitle的start_display_time和end_display_time的单位是微秒,并不是秒。比如要显示 0.5 秒到 3.2 秒,这两个字段的值是500000和3200000。而subtitle.pts的单位是流时间基,需要先转换。很多初学者在这里直接拿微秒当毫秒用,结果字幕瞬间全闪过去。
3.3 字幕事件表的设计
我放弃用 ffplay 自己的AVSubtitleQueue,因为它的数据结构绑定了解码线程和 libass 通路。我定义了一个轻量结构:
typedef struct SubtitleEvent { double start_time; // 单位:秒 double end_time; // 单位:秒 char *text; // 纯文本,多行用 \n 分隔 uint8_t has_ass_tags; } SubtitleEvent;每次解码出一个AVSubtitle,就把它的每个 rect 里的文本和时间轴换算成秒,然后存进一个按start_time排序的动态数组。整个转换逻辑不复杂,核心就是时间单位换算:
double time_base = av_q2d(sub_stream->time_base); double pts_sec = subtitle.pts * time_base; double start_sec = pts_sec + subtitle.start_display_time / 1000000.0; double end_sec = pts_sec + subtitle.end_display_time / 1000000.0;这里的pts_sec是字幕事件开始显示的参考时间,不同封装格式可能略有差异,但start_display_time和end_display_time是相对这个 pts 的偏移,这个公式在我测试的 SRT、ASS、MKV 内嵌字幕里都能正确工作。
另外要注意,一个 packet 里可能包含多个字幕事件,一个AVSubtitle也可能有多个 rect。如果直接把所有 rect 铺开存进事件表,会造成同一时间点有多条字幕同时显示,这在普通 SRT 里很少见,但 ASS 里经常有多个对话事件重叠。我采用的做法是只取第一个非空文本的 rect,保证同一时间点只显示一个事件。如果你要完整支持多事件叠加,那后面渲染层也得改成“渲染事件列表而不是单个事件”,复杂度会高不少。
3.4 手写 SRT 解析兜底方案
虽然用 FFmpeg 解码器可以读大部分 SRT,但在某些极端封装下,avcodec_decode_subtitle2返回的文本可能走的是SUBTITLE_ASS分支,里面塞了一堆 ASS 标签,比如{\an8}这种。如果你的需求是“无论什么字幕文件,最终都要显示成统一的纯文本样式”,我建议再写一个原生 SRT 解析器作为兜底。
SRT 的格式很简单,核心结构是:
1 00:00:01,000 --> 00:00:03,000 第一行字幕 第二行字幕 2 00:00:04,000 --> 00:00:06,000 第二段字幕解析器就做三件事:按空行分块、取序号、解析时间轴、拼接后续所有文本行。时间轴解析用ss,mmm的格式,转成毫秒再除以 1000 得到秒。这个兜底解析器虽然功能简单,但在你测试“外部字幕文件 + 自定义字体”这个场景时会非常可靠,不受 FFmpeg 封装层的影响。
3.5 FreeType 渲染:从字体文件到字幕位图
FreeType 这部分是整个功能最核心也最繁琐的一环。基本调用流程可以用几个 API 串起来:
FT_Init_FreeType(&ft_library); FT_New_Face(ft_library, font_path, 0, &face); FT_Set_Pixel_Sizes(face, 0, font_pixel_height); FT_Load_Char(face, codepoint, FT_LOAD_RENDER); FT_Render_Glyph(face->glyph, FT_RENDER_MODE_NORMAL);FT_Load_Char会加载字形轮廓,FT_LOAD_RENDER标志让它直接渲染成位图。渲染结果在face->glyph->bitmap里,是 8bit 灰度图,每个字节表示 alpha 值。我们需要把这个位图拷贝到一个自己的字体缓存区域,然后根据字符在文本行里的 x 坐标,逐个放置。
这里有个特别重要的细节:中文字体和英文字体的基线不一致,直接按字符宽度累加坐标,中文很容易上下跳动。FreeType 提供了face->size->metrics.ascender和descender,可以用它们计算行基线,然后统一在这个基线上对齐。我自己是先用FT_Set_Pixel_Sizes固定行高,然后计算 ascender 对应的像素偏移,这样中英文混排就不会乱跳。
另一个坑是多行文本的布局。SRT 字幕经常有两行,第二行是用\n分隔的,你需要先遍历全部字符算出行高和最大行宽,再把整个文本块居中到视频画面。如果只是简单逐行从 y=0 开始画,字幕会挤在屏幕顶部,非常难看。我用了一个非常笨但直接的办法:先做一次布局计算,把所有字符的 glyph 位置存进一个数组,然后再统一绘制。渲染函数形如:
void render_subtitle_text(uint8_t *rgb_buffer, int width, int height, SubtitleEvent *event, FontContext *font, int align_mode);这里align_mode可以控制居中、底部居中、顶部居中。ffplay 通常把字幕放在画面底部 85% 的位置,这样不会遮挡视频主体。
3.6 描边和阴影的实现思路
做字幕没描边真的很难看,尤其是白字配亮背景。描边的常规做法不是“给文字加一圈框”,而是“先用黑色把文字笔画整体加粗渲染一遍,再在上面渲染正常白色”。
利用 FreeType 可以这样实现:把同一个字形渲染到一张 A8 位图,然后用一个 3x3 或 5x5 的膨胀核把 alpha 值向外扩张几像素,生成一个描边蒙版。每次画文字时,先在目标坐标上按描边蒙版填充黑色,再按正常字形灰度位图填充白色。这套逻辑代码量不大,但效果立竿见影。
阴影就简单多了,直接把正常字形 bitma 拷一份,alpha 减半,颜色用半透明黑,整体偏移两三个像素画在文字下方。注意要先画阴影,再画描边,最后画文字本体,否则半透明黑会把白字盖住。
4. 动手改 ffplay:具体实现步骤与关键函数
4.1 准备源码和依赖
我实验用的环境是 Ubuntu 系,第一步是安装 SDL2 和 FreeType 的开发包:
sudo apt install libsdl2-dev libfreetype6-devFFmpeg 源码从官方仓库拉下来之后,configure 时确保打开 ffplay 和 FreeType:
./configure --enable-ffplay --enable-sdl2 --enable-libfreetype make -j$(nproc)这里特别提醒一句:不要在系统已有 FFmpeg 的环境下直接执行 make install,容易把系统里的 FFmpeg 覆盖掉。我自己的习惯是只编译不安装,直接运行源码目录下生成的ffplay可执行文件,后缀是ffplay_g的调试版更好,带符号信息方便 gdb 断点。
4.2 在 ffplay.c 里插入字幕事件表
我的修改集中在ffplay.c。首先在全局结构体里加一个事件表指针:
typedef struct VideoState { // ... ffplay 原有字段 SubtitleEvent *sub_events; int sub_event_count; int sub_event_alloc; FontContext *sub_font; } VideoState;然后在stream_open阶段,如果发现字幕流,就初始化事件表和字体。字体路径不需要写死,我通过一个命令行参数传入,比如:
ffplay -sub-font /usr/share/fonts/wqy-microhei.ttc -i video.mkv在ffplay.c的options数组里加一个-sub-font的解析项即可,逻辑非常简单。
4.3 read_thread 里的字幕解码钩子
ffplay 的read_thread是一个大循环,不断调av_read_frame获取 packet。原有的 packet 分发逻辑会把字幕 packet 分给subtitle_thread。我不走那边,直接在分发处加一个分支,把字幕 packet 截留下来同步解码:
if (pkt->stream_index == is->subtitle_stream_idx) { AVSubtitle sub = {0}; int got = 0; avcodec_decode_subtitle2(is->sub_dec_ctx, &sub, &got, pkt); if (got) { append_subtitle_events(is, &sub); avsubtitle_free(&sub); } av_packet_unref(pkt); continue; }这里有个多线程注意点:read_thread和video_refresh是不同线程,append_subtitle_events在写数组,video_refresh在读取数组,需要给事件表加一个简单的互斥锁,或者在 update 时用原子替换指针。我用了最简单的方法:在append_subtitle_events里把事件全部 copy 一份,更新完指针后释放旧数组,读取端只拿数组首地址,实际测试下来没遇到崩溃,但严谨项目里还是建议加锁。
4.4 video_refresh 里的主动渲染
ffplay 的video_refresh函数里,最终上屏的帧是is->pictq队列里的VideoPicture *vp,它带一个AVFrame *frame。我在video_display之前插入一个渲染函数:
void overlay_subtitle_on_frame(VideoState *is, AVFrame *frame, double clock);实现逻辑是:
- 用
is->vidclk的当前时间查事件表,找到start_time <= clock <= end_time的事件。 - 如果没找到事件,直接返回不动。
- 如果找到事件,但上次渲染的事件 ID 和这次相同,且帧尺寸没变,可以直接用缓存好的字幕位图,避免重复渲染。
- 用
sws_scale把frame转成 RGB24 的临时缓冲区,执行字幕位图 alpha 混合。 - 用
sws_scale把混合后的 RGB24 再转回 YUV420P,写回原来的frame内存。
第五步听上去绕了两个弯,但确实是我实际采用的方案。因为我要尽量少改动 ffplay 原有的 SDL 纹理更新路径,让视频帧始终是 YUV420P,SDL 那边不用动。如果你想把 SDL 纹理改成 RGB,那可以跳过第五步,但改动的代码面会大很多。
4.5 字幕缓存:别每一帧都重新渲染字体
刚开始实现时,我天真地在每一帧都调 FreeType 渲染整段文本,结果 1080p 视频直接掉到 15 帧。原因很简单:FreeType 栅格化字形很贵,尤其是中文点阵和复杂轮廓。优化思路是缓存字幕位图:
typedef struct RenderedSubtitle { int event_id; int width, height; uint8_t *bitmap; // ARGB 或 A8 数据 int align_x, align_y; } RenderedSubtitle;只有在事件 ID 变化时才重新渲染。因为字幕一分钟内也就出现几十次,缓存命中率极高,帧率几乎不掉。渲染层还有一个优化:把常用字符的 glyph 缓存到一张哈希表里,这样不同字幕事件之间也能复用字形位图。比如高频汉字“你”“我”“的”,在长视频里能省大量重复栅格化开销。
4.6 支持外部 SRT 文件
外部字幕文件需要单独打开一个AVFormatContext。我加了一个参数-subtitle_file,实现时就是在stream_open里用avformat_open_input打开文件,然后用和内部字幕流一样的流程取流和解码:
AVFormatContext *sub_ic = NULL; avformat_open_input(&sub_ic, filename, NULL, NULL); avformat_find_stream_info(sub_ic, NULL); // 取字幕流索引,打开解码器,填充 is->sub_dec_ctx注意外部 SRT 文件的时间轴通常从 0 开始,但视频文件可能有起始偏移,两者需要对齐。简单粗暴的做法是:外部字幕的时间基准默认和视频流一开始的start_time对齐,实测大部分场景都适用。如果遇到明显错位,可以在渲染时加一个-sub-delay参数手动调整秒数。
5. 编译、测试与调试实录
5.1 编译过程中最常见的报错
我遇到的第一个编译错误是FT_Init_FreeType的隐式声明,原因是源码里没有引入 FreeType 头文件,也没有在 FFmpeg 的 build 系统里链接-lfreetype。解决办法是在ffplay.c顶部加:
#include <ft2build.h> #include FT_FREETYPE_H然后到配置阶段加--enable-libfreetype。但如果系统里没有安装 FreeType 开发包,configure 会直接报ERROR: freetype2 not found using pkg-config。装好依赖后要留意:新版 Ubuntu 的 FreeType 头文件路径是/usr/include/freetype2,ffmpeg 的 pkg-config 能自动识别,一般不需要手动指定。
第二个经典报错是 SDL2 头文件冲突。如果你操作系统自带旧版 SDL1.2,configure 可能同时找到两个版本,导致 ffplay.c 里SDL_Init参数混乱。我的处理是先彻底卸载掉系统里的libsdl1.2-dev,确保只保留 SDL2。
5.2 验证渲染效果的三步测试法
字幕渲染功能调试起来不像视频解码那么直观,画面上的问题往往很难断定是时间轴错、字体错还是合成错。我每次都是按下面三个步骤去测:
第一步,做“时间轴正确性测试”。准备一个只有 10 行字幕的 SRT,每行显示 1 秒,内容就是第1秒、第2秒这种。修改版 ffplay 播放时,用录屏软件把画面和系统时间同时录下来,逐帧检查字幕内容是否和当前秒数一致。这一步能快速暴露所有时间单位换算问题。
第二步,做“像素合成测试”。字幕文本里放一行“白底黑边”明显的内容,比如纯白背景画面用黑字,或者纯黑背景用白字,检查字幕边缘有没有紫边、有没有残影。紫边基本就是颜色空间不对,多半是 YUV 和 RGB 混合时漏了转换。
第三步,做“缓存正确性测试”。连续播放同一段视频两次,第二次播放时字幕切换是否依然准确、是否出现上一次字幕的残留。残留通常说明缓存事件没有在字幕结束时清空,检查end_time的判断逻辑就行。
5.3 调试小技巧:加一个 OSD 状态输出
我还在渲染层加了一个隐藏调试功能:当环境变量FFPLAY_SUB_DEBUG=1时,在画面左上角输出一行状态信息,包括当前主时钟、事件表长度、当前字幕事件索引、渲染耗时。这个信息在排查时间同步问题时价值非常大。你可以看到时钟是多少、事件表里匹配的事件是什么、对应文本是什么,一眼就能看出是字幕没进来还是时间戳没对上。
6. 常见问题与排查技巧实录
6.1 字幕完全没有显示
先别急着怀疑渲染合成,按这个顺序排查:
- 打印字幕流索引,确认
av_find_best_stream找对了。 - 在
apppend_subtitle_events里打印事件数量和时间轴,确认解码层有数据。 - 在
overlay_subtitle_on_frame里打印当前时钟和匹配结果,确认调度层有命中。 - 检查字体加载是否成功,FreeType 打开字体文件失败时大多因为路径错误。
我遇到的最低级错误是自己把字幕事件表初始化放在stream_open之前,导致文件还没打开事件表就被释放,后面写入直接段错误。这类问题用 gdb 跑一下就能看到。
6.2 文字显示成方块或乱码
方块通常不是乱码,而是字体文件缺对应的字形。中文字幕必须用中文字体,测试时如果只加载英文字体,中文全显示成空心方块。乱码则是另一回事,大概率是编码问题。外部 SRT 可能是 GBK 编码,FFmpeg 的 srt 解码器默认按 UTF-8 处理,遇到 GBK 就会输出一堆乱码。我的处理是在解析器里加一步编码检测:如果不是合法 UTF-8,就先用 iconv 从 GBK 转到 UTF-8 再进入事件表。
6.3 字幕比画面快或慢
时间轴错位有两大原因:单位换算错误和外部字幕对齐错误。单位换算错误的特征是“字幕闪得飞快”,因为把微秒当成了秒。外部字幕对齐错误表现为“整体固定偏移”,比如字幕总是提前 0.5 秒出现,这时调整-sub-delay参数最方便。
6.4 字幕边缘有紫边或绿边
这个几乎是 YUV 合成事故的典型症状。如果你直接操作 YUV 平面的像素,需要同时调整 Y、U、V 三个分量,白字在 Y 上加了 255,但 U/V 没跟着变,颜色就会偏色。我建议如果对颜色处理没有十足把握,直接统一走 RGB 空间 alpha 合成,别在 YUV 上硬抠细节。
6.5 帧率明显下降
先做性能分析,用perf或者简单的gettimeofday包住不同环节。我实测下来两个热点:一是sws_scale的 RGB 往返转换,二是 FreeType 重复渲染。前者可以通过减少转换次数优化:字幕不变化时,不需要重新转换和混合,只更新 SDL 纹理即可;后者靠缓存彻底解决。如果你连 RGB 转换都不想做,还有一条路:用 SDL 的SDL_CreateTexture创建第二层纹理,把字幕位图作为独立 texture,在SDL_RenderCopy时叠加上去。这样完全绕开视频帧像素修改,性能最好,但前提是你愿意把 ffplay 的 SDL 渲染路径改成 Render 模式而不是 Texture 直传模式。
6.6 字幕事件结束后还在画面上残留
这问题特别隐蔽。事件表里每个事件的end_time判断看似没问题,但由于video_refresh是按“当前帧时间”来触发的,而字幕事件的结束点可能落在两帧之间。如果只判断clock <= end_time,那这一帧确实需要显示;但下一帧如果时钟已经越过 end_time,应该切换事件。有些实现只判断了start_time,忘了判断end_time,结果字幕就一直挂在屏幕上。我建议在渲染函数开头就加上一个“过期事件清理”逻辑,把所有end_time < clock的事件标记为无效,并释放对应位图缓存。
结尾的实操心得
这个功能让我最意外的地方是:字幕显示本身的技术难度并不高,真正耗时的是各种“边缘情况”——时间单位换算、字体基线、编码识别、像素格式转换、事件缓存生命周期。如果你也想动手改 ffplay,我强烈建议不要一步到位,先跑通外部 SRT 的显示,再处理内置字幕流,最后再做描边阴影和样式扩展。每一步都能看到实际效果,也比较容易定位问题。另一个建议是给你的修改版 ffplay 单独做一个命令行前缀,别直接叫 ffplay,避免哪天把系统自带的播放器覆盖掉手忙脚乱。后面我打算把字幕事件表改成支持 ASS 的\N换行标签,顺便把多事件叠加显示也补上,到时候再分享一版更完整的实践记录。