10、MediaMuxer实战:添加字幕轨道(Subtitle Track)——将外部字幕文件嵌入视频

字幕嵌入,说白了就是把外挂字幕变成内嵌字幕。很多视频编辑App都有这个功能——用户导入一个SRT文件,然后字幕就“焊死”在视频里了。今天我们就来聊聊,如何用MediaMuxer实现这个能力。

我个人习惯把字幕轨道看作“第四种轨道类型”。视频有视频轨道,音频有音频轨道,字幕嘛,在Android里其实也是通过MediaMuxer写入的。不过这里有个坑——MediaMuxer对字幕的支持并不像视频、音频那么完善。嗯,我们得绕点路。

10.1 字幕轨道的本质

先搞清楚一件事:MediaMuxer支持的字幕格式,其实是“时间戳+文本”的样本数据。它不像MP4里那种标准的字幕轨道(比如tx3g格式),而是需要我们手动把字幕数据打包成MediaCodec能识别的格式。

我在项目中遇到过这样一个场景:用户上传了一个SRT文件,要求嵌入到视频里。当时我第一反应是“直接用MediaMuxer加个字幕轨道不就行了?”结果发现,事情没那么简单。

MediaMuxer的addTrack方法确实可以添加字幕轨道,但前提是——你得提供一个MediaFormat对象,并且这个格式必须是“application/x-subrip”或者类似的MIME类型。说白了,Android原生支持的字幕格式非常有限。

核心要点:MediaMuxer添加字幕轨道,本质上是把字幕文本作为“样本数据”写入,而不是渲染成图像。字幕的渲染工作,需要播放器端支持。

10.2 准备工作:解析SRT文件

SRT文件的结构很简单:序号、时间范围、字幕文本。我们得先把它解析成程序能用的数据结构。

// 字幕条目数据结构
public class SubtitleItem {
    public long startTimeUs;  // 开始时间,微秒
    public long endTimeUs;    // 结束时间,微秒
    public String text;       // 字幕文本
}

// 解析SRT文件
public List<SubtitleItem> parseSrt(String filePath) {
    List<SubtitleItem> items = new ArrayList<>();
    try (BufferedReader reader = new BufferedReader(new FileReader(filePath))) {
        String line;
        while ((line = reader.readLine()) != null) {
            // 跳过序号行
            if (line.trim().isEmpty()) continue;
            // 读取时间行
            String timeLine = reader.readLine();
            if (timeLine == null) break;
            // 解析时间
            String[] times = timeLine.split(" --> ");
            long startUs = parseTimeToUs(times[0].trim());
            long endUs = parseTimeToUs(times[1].trim());
            // 读取字幕文本(可能有多行)
            StringBuilder text = new StringBuilder();
            while ((line = reader.readLine()) != null && !line.trim().isEmpty()) {
                text.append(line).append("\n");
            }
            SubtitleItem item = new SubtitleItem();
            item.startTimeUs = startUs;
            item.endTimeUs = endUs;
            item.text = text.toString().trim();
            items.add(item);
        }
    } catch (IOException e) {
        e.printStackTrace();
    }
    return items;
}

// 时间格式:00:00:01,234 → 微秒
private long parseTimeToUs(String timeStr) {
    String[] parts = timeStr.split("[:,]");
    long hours = Long.parseLong(parts[0]);
    long minutes = Long.parseLong(parts[1]);
    long seconds = Long.parseLong(parts[2]);
    long millis = Long.parseLong(parts[3]);
    return (hours * 3600 + minutes * 60 + seconds) * 1000000 + millis * 1000;
}

小技巧:解析时间时,注意SRT文件的时间分隔符是“-->”,前后各有一个空格。我曾经因为没处理空格,解析出来的时间全是0,排查了半天才发现是格式问题。

10.3 创建字幕轨道并写入

解析完SRT文件后,我们就可以创建字幕轨道了。这里要注意,MediaMuxer的字幕轨道格式需要手动指定。

// 创建字幕轨道的MediaFormat
MediaFormat subtitleFormat = MediaFormat.createSubtitleFormat("application/x-subrip", "en");
// 或者使用更通用的方式
// MediaFormat subtitleFormat = new MediaFormat();
// subtitleFormat.setString(MediaFormat.KEY_MIME, MediaFormat.MIMETYPE_TEXT_SUBRIP);

// 添加到MediaMuxer
int subtitleTrackIndex = muxer.addTrack(subtitleFormat);
muxer.start();

// 写入字幕样本
for (SubtitleItem item : subtitleItems) {
    byte[] data = item.text.getBytes("UTF-8");
    ByteBuffer buffer = ByteBuffer.wrap(data);
    MediaCodec.BufferInfo info = new MediaCodec.BufferInfo();
    info.offset = 0;
    info.size = data.length;
    info.presentationTimeUs = item.startTimeUs;
    info.flags = MediaCodec.BUFFER_FLAG_KEY_FRAME;  // 字幕样本通常标记为关键帧
    
    muxer.writeSampleData(subtitleTrackIndex, buffer, info);
}

你想想看,这里有个关键点——每个字幕样本的presentationTimeUs,对应的是字幕开始显示的时间。那结束时间呢?其实MediaMuxer不关心结束时间,它只负责把样本写进去。字幕什么时候消失,是播放器根据下一个字幕样本的时间戳来决定的。

注意:MediaMuxer写入字幕样本时,必须保证时间戳是递增的。如果你乱序写入,生成的视频文件在播放时可能会出现字幕错乱。我曾经犯过这个错——解析SRT时没排序,结果字幕在播放器里跳来跳去。

10.4 完整的嵌入流程

把上面这些串起来,就是一个完整的字幕嵌入流程。我画了一张图,帮你理清思路:

字幕嵌入流程 步骤1:解析SRT文件 读取序号、时间、文本 步骤2:创建字幕轨道 MediaFormat + addTrack 步骤3:写入样本 writeSampleData 关键点:时间戳处理 • 每个字幕样本的 presentationTimeUs 必须递增 • 字幕结束时间由下一个样本的开始时间决定 • 最后一个字幕的结束时间 = 视频总时长 注意:播放器兼容性 • 并非所有播放器都支持内嵌字幕轨道 • 建议使用 VLC、MX Player 等主流播放器测试 • 系统播放器可能无法显示字幕,需要额外处理

10.5 完整代码示例

下面是一个完整的字幕嵌入方法,把视频、音频和字幕合并到一个MP4文件里:

public void muxWithSubtitle(String videoPath, String audioPath, 
                            String srtPath, String outputPath) {
    try {
        MediaExtractor videoExtractor = new MediaExtractor();
        videoExtractor.setDataSource(videoPath);
        MediaExtractor audioExtractor = new MediaExtractor();
        audioExtractor.setDataSource(audioPath);
        
        MediaMuxer muxer = new MediaMuxer(outputPath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4);
        
        // 添加视频轨道
        int videoTrackIndex = -1;
        for (int i = 0; i < videoExtractor.getTrackCount(); i++) {
            MediaFormat format = videoExtractor.getTrackFormat(i);
            String mime = format.getString(MediaFormat.KEY_MIME);
            if (mime.startsWith("video/")) {
                videoExtractor.selectTrack(i);
                videoTrackIndex = muxer.addTrack(format);
                break;
            }
        }
        
        // 添加音频轨道
        int audioTrackIndex = -1;
        for (int i = 0; i < audioExtractor.getTrackCount(); i++) {
            MediaFormat format = audioExtractor.getTrackFormat(i);
            String mime = format.getString(MediaFormat.KEY_MIME);
            if (mime.startsWith("audio/")) {
                audioExtractor.selectTrack(i);
                audioTrackIndex = muxer.addTrack(format);
                break;
            }
        }
        
        // 解析字幕并添加轨道
        List<SubtitleItem> subtitles = parseSrt(srtPath);
        MediaFormat subtitleFormat = MediaFormat.createSubtitleFormat(
            "application/x-subrip", "zh");
        int subtitleTrackIndex = muxer.addTrack(subtitleFormat);
        
        muxer.start();
        
        // 写入视频样本
        // ...(省略视频写入代码,与之前章节相同)
        
        // 写入音频样本
        // ...(省略音频写入代码)
        
        // 写入字幕样本
        for (SubtitleItem item : subtitles) {
            byte[] data = item.text.getBytes("UTF-8");
            ByteBuffer buffer = ByteBuffer.wrap(data);
            MediaCodec.BufferInfo info = new MediaCodec.BufferInfo();
            info.offset = 0;
            info.size = data.length;
            info.presentationTimeUs = item.startTimeUs;
            info.flags = MediaCodec.BUFFER_FLAG_KEY_FRAME;
            muxer.writeSampleData(subtitleTrackIndex, buffer, info);
        }
        
        muxer.stop();
        muxer.release();
        videoExtractor.release();
        audioExtractor.release();
        
    } catch (IOException e) {
        e.printStackTrace();
    }
}

经验之谈:写入字幕样本时,我建议把字幕文本的编码统一为UTF-8。有些SRT文件是ANSI编码,直接读取会出现乱码。另外,字幕文本里如果包含换行符,记得保留——播放器会根据换行符自动分行显示。

10.6 避坑指南

做字幕嵌入时,有几个坑我踩过,分享给你:

  • 时间戳精度问题:SRT文件的时间单位是毫秒,但MediaMuxer要求微秒。转换时别漏了乘以1000。我曾经因为单位搞错,字幕全部提前或延迟显示。
  • 字幕轨道索引:addTrack返回的索引,在写入样本时一定要用对。如果你有视频、音频、字幕三个轨道,索引分别是0、1、2。写错索引会导致数据写入错误的轨道。
  • 播放器兼容性:不是所有播放器都支持内嵌字幕。系统自带的播放器可能直接忽略字幕轨道。建议用VLC或MX Player测试。
  • 字幕文本长度:单个字幕样本的文本不要太大。如果一行字幕有几百个字,播放器可能显示不全。我一般建议每行不超过40个字符。

重要提醒:MediaMuxer写入字幕轨道后,生成的MP4文件在PC上可能正常播放,但在手机上可能看不到字幕。这是因为Android系统播放器对字幕轨道的支持有限。如果你需要确保所有设备都能显示字幕,可以考虑把字幕渲染到视频画面上——那就是另一套方案了。

好了,关于MediaMuxer添加字幕轨道的内容就聊到这里。说白了,这个功能的核心就是“解析SRT → 创建轨道 → 写入样本”三步走。虽然Android对字幕的支持不算完美,但掌握了这套方法,至少能应对大部分需求。


公众号:蓝海资料掘金营,微信deep3321