FFmpeg中subtitle demuxer实现

[时间:2019-01] [状态:Open]
[关键词:字幕,ffmpeg,subtitle,demuxer,源码]

0 引言

本文重心在于FFmpeg中subtitle demuxer的实现逻辑。
在阅读本文前,笔者希望你对FFmpeg中libavformat的实现有一定了解(可以参考我之前的博文FFmpeg框架分析,最起码知道demuxer的主要接口)。
同时笔者也希望你对主流的字幕格式有一定了解,包括LRC、SRT、ASS、WebVTT。

这是我的“浅析字幕流”系列第四篇文章,其他文章链接如下:

1 LRC demuxer的实现

本文将以其中一种字幕格式——LRC的实现作为示例,说明FFmpeg内部对subtitle的解析逻辑,其他字幕实现逻辑差不多吧。有兴趣的可以自行查看代码。
对应的demuxer实现文件是libavformat/lrcdec.c
我们首先看看lrc_demuxer的定义部分:

AVInputFormat ff_lrc_demuxer = {
    .name           = "lrc",
    .long_name      = NULL_IF_CONFIG_SMALL("LRC lyrics"),
    .priv_data_size = sizeof (LRCContext),
    .read_probe     = lrc_probe,
    .read_header    = lrc_read_header,
    .read_packet    = lrc_read_packet,
    .read_close     = lrc_read_close,
    .read_seek2     = lrc_read_seek
};

不过从实际代码逻辑来看,有两个比较大的函数(probe和read_header),其他的都很简单。先看一下LRC内部的上下文数据结构的定义:

typedef struct LRCContext {
    FFDemuxSubtitlesQueue q;
    int64_t ts_offset; // offset metadata item
} LRCContext;

只有一个添加的结构FFDemuxSubtitlesQueue,基本上空的。
我们再看一下后面三个函数的实现:

static int lrc_read_packet(AVFormatContext *s, AVPacket *pkt)
{
    LRCContext *lrc = s->priv_data;
    return ff_subtitles_queue_read_packet(&lrc->q, pkt);
}

static int lrc_read_seek(AVFormatContext *s, int stream_index,
                         int64_t min_ts, int64_t ts, int64_t max_ts, int flags)
{
    LRCContext *lrc = s->priv_data;
    return ff_subtitles_queue_seek(&lrc->q, s, stream_index,
                                   min_ts, ts, max_ts, flags);
}

static int lrc_read_close(AVFormatContext *s)
{
    LRCContext *lrc = s->priv_data;
    ff_subtitles_queue_clean(&lrc->q);
    return 0;
}

基本上都是一行代码,直接将实现逻辑转接到ff_subtitles_*函数上。(关于此系列函数第二节将会详细介绍)

我们这里要介绍的第一个函数是probe,代码如下:

static int lrc_probe(AVProbeData *p)
{
    int64_t offset = 0;
    int64_t mm;
    uint64_t ss, cs;
    const AVMetadataConv *metadata_item;

    if(!memcmp(p->buf, "\xef\xbb\xbf", 3)) { // 跳过UTF-8 BOM头
        offset += 3;
    }
    while(p->buf[offset] == '\n' || p->buf[offset] == '\r') {
        offset++;
    }
    if(p->buf[offset] != '[') {// 第一个字符必须是'['
        return 0;
    }
    offset++;
    // 不在ff_lrc_metadata_conv中的特定字段
    if(!memcmp(p->buf + offset, "offset:", 7)) {
        return 40;
    }
	// [mm:ss.xx] 这是LRC中的时间格式
    if(sscanf(p->buf + offset, "%"SCNd64":%"SCNu64".%"SCNu64"]",
              &mm, &ss, &cs) == 3) {
        return 50;
    }
    /*
	const AVMetadataConv ff_lrc_metadata_conv[] = {
	    {"ti", "title"}, {"al", "album"},
	    {"ar", "artist"}, {"au", "author"},
	    {"by", "creator"}, {"re", "encoder"},
	    {"ve", "encoder_version"}, {0, 0}
	};
	*/
    for(metadata_item = ff_lrc_metadata_conv;
        metadata_item->native; metadata_item++) {
        size_t metadata_item_len = strlen(metadata_item->native);
        if(p->buf[offset + metadata_item_len] == ':' &&
           !memcmp(p->buf + offset, metadata_item->native, metadata_item_len)) {
            return 40;
        }
    }
    return 5; // Give it 5 scores since it starts with a bracket
}

probe函数基本上是根据LRC格式的特征字段进行格式探测,由于LRC本身没有明确的格式标记,所以这里仅仅是探测,给出置信值,并没有确认。在ASS或WebVTT中是有特定的格式标记的。
下面是read_header函数的实现代码:

static int lrc_read_header(AVFormatContext *s)
{
    LRCContext *lrc = s->priv_data;
    AVBPrint line;
    AVStream *st;
	// 先创建AVStream并初始化部分信息
    st = avformat_new_stream(s, NULL);
    if(!st) {
        return AVERROR(ENOMEM);
    }
    avpriv_set_pts_info(st, 64, 1, 1000);
    lrc->ts_offset = 0;
    st->codecpar->codec_type = AVMEDIA_TYPE_SUBTITLE;
    st->codecpar->codec_id   = AV_CODEC_ID_TEXT;
    av_bprint_init(&line, 0, AV_BPRINT_SIZE_UNLIMITED);

	// 注意这个循环会把整个LRC文件全部读完
    while(!avio_feof(s->pb)) {
        int64_t pos = read_line(&line, s->pb);// 读一行数据
        int64_t header_offset = find_header(line.str); // 查找是否是ID标签
        if(header_offset >= 0) { // ID标签解析,格式为 ID:msg
            char *comma_offset = strchr(line.str, ':');
            if(comma_offset) {
                char *right_bracket_offset = strchr(line.str, ']');
                if(!right_bracket_offset) {
                    continue;
                }

                *right_bracket_offset = *comma_offset = '\0';
                if(strcmp(line.str + 1, "offset") ||
                   sscanf(comma_offset + 1, "%"SCNd64, &lrc->ts_offset) != 1) {
                    av_dict_set(&s->metadata, line.str + 1, comma_offset + 1, 0);
                }
                *comma_offset = ':';
                *right_bracket_offset = ']';
            }

        } else { // 时间标签 + 歌词
            AVPacket *sub;
            int64_t ts_start = AV_NOPTS_VALUE;
            int64_t ts_stroffset = 0;
            int64_t ts_stroffset_incr = 0;
            int64_t ts_strlength = count_ts(line.str); // 找到时间标签的起始位置
			// 读取时间戳,并偏移到给歌词起始位置
            while((ts_stroffset_incr = read_ts(line.str + ts_stroffset,
                                               &ts_start)) != 0) {
                ts_stroffset += ts_stroffset_incr;
				// 将实际歌词信息插入到队列中
                sub = ff_subtitles_queue_insert(&lrc->q, line.str + ts_strlength,
                                                line.len - ts_strlength, 0);
                if(!sub) {
                    return AVERROR(ENOMEM);
                }
                sub->pos = pos;
                sub->pts = ts_start - lrc->ts_offset; // 时间戳在此赋值
                sub->duration = -1;
            }
        }
    }
	// subtitle读取完毕,会做一些字幕重排及调整
    ff_subtitles_queue_finalize(s, &lrc->q);
    ff_metadata_conv_ctx(s, NULL, ff_lrc_metadata_conv);
    av_bprint_finalize(&line, NULL);
    return 0;
}

从上述实现来看,LRC demuxer是在read_header中直接读取了所有歌词信息,并保存到字幕队列中。后续所有处理都通过该队列完成。

总结一下,在LRC demuxer中调用了以下几个API:

  • ff_subtitles_queue_read_packet
  • ff_subtitles_queue_seek
  • ff_subtitles_queue_clean
  • ff_subtitles_queue_insert
  • ff_subtitles_queue_finalize

下一小节我们将围绕这几个函数展开。

2 ff_subtitles_queue_*接口实现

首先我们看一下FFDemuxSubtitlesQueue的定义

enum sub_sort {
    SUB_SORT_TS_POS = 0,    ///< 排序顺序为:时间戳,之后是位置
    SUB_SORT_POS_TS,        ///< 排序顺序为:位置,之后是时间戳
};
typedef struct {
    AVPacket *subs;         ///< subtitles数据包数组
    int nb_subs;            ///< 已存储数据包个数
    int allocated_size;     ///< 已分配数组长度
    int current_sub_idx;    ///< 目前正在读的数据包的索引
    enum sub_sort sort;     ///< subtitle排序算法
    int keep_duplicates;    ///< set to 1 to keep duplicated subtitle events
} FFDemuxSubtitlesQueue;

先说明下,通常ffmpeg内部的接口是不加锁的,因为从设计上来说,ff_subtitles_queue_*需要保证在同一个线程内调用,否则可能存在多线程同步的问题。

2.1 ff_subtitles_queue_read_packet

读包逻辑相对简答,基本是从队列中读取缓存数据。代码如下:

int ff_subtitles_queue_read_packet(FFDemuxSubtitlesQueue *q, AVPacket *pkt)
{
    AVPacket *sub = q->subs + q->current_sub_idx;

    if (q->current_sub_idx == q->nb_subs)
        return AVERROR_EOF;
    if (av_packet_ref(pkt, sub) < 0) {
        return AVERROR(ENOMEM);
    }

    pkt->dts = pkt->pts;
    q->current_sub_idx++; // 这里更新读取位置索引
    return 0;
}

2.2 ff_subtitles_queue_seek

seek逻辑跟read_packet类似,主要是根据时间戳,直接找到seek之后的读取位置即可。代码如下:

// 二分查找最接近时间ts的索引位置
static int search_sub_ts(const FFDemuxSubtitlesQueue *q, int64_t ts)
{
    int s1 = 0, s2 = q->nb_subs - 1;

    if (s2 < s1)
        return AVERROR(ERANGE);

    for (;;) {
        int mid;

        if (s1 == s2)
            return s1;
        if (s1 == s2 - 1)
            return q->subs[s1].pts <= q->subs[s2].pts ? s1 : s2;
        mid = (s1 + s2) / 2;
        if (q->subs[mid].pts <= ts)
            s1 = mid;
        else
            s2 = mid;
    }
}

int ff_subtitles_queue_seek(FFDemuxSubtitlesQueue *q, AVFormatContext *s, int stream_index,
                            int64_t min_ts, int64_t ts, int64_t max_ts, int flags)
{
    if (flags & AVSEEK_FLAG_BYTE) {
        return AVERROR(ENOSYS);
    } else if (flags & AVSEEK_FLAG_FRAME) { // 按照帧编号执行seek
        if (ts < 0 || ts >= q->nb_subs)
            return AVERROR(ERANGE);
        q->current_sub_idx = ts;
    } else { // 通常seek都会进入此分支
        int i, idx = search_sub_ts(q, ts);
        int64_t ts_selected;

        if (idx < 0)
            return idx;
		// 继续缩小范围,找到比min_ts大,比max_tx小的位置
        for (i = idx; i < q->nb_subs && q->subs[i].pts < min_ts; i++)
            if (stream_index == -1 || q->subs[i].stream_index == stream_index)
                idx = i;
        for (i = idx; i > 0 && q->subs[i].pts > max_ts; i--)
            if (stream_index == -1 || q->subs[i].stream_index == stream_index)
                idx = i;

        ts_selected = q->subs[idx].pts;
        if (ts_selected < min_ts || ts_selected > max_ts)
            return AVERROR(ERANGE);

        /* 处理在时间上重叠的字幕数据包 */
        for (i = idx - 1; i >= 0; i--) {
            int64_t pts = q->subs[i].pts;
            if (q->subs[i].duration <= 0 ||
                (stream_index != -1 && q->subs[i].stream_index != stream_index))
                continue;
            if (pts >= min_ts && pts > ts_selected - q->subs[i].duration)
                idx = i;
            else
                break;
        }

        q->current_sub_idx = idx;
    }
    return 0;
}

2.3 ff_subtitles_queue_clean

clean函数主要完成动态申请内存的释放。具体代码如下:

void ff_subtitles_queue_clean(FFDemuxSubtitlesQueue *q)
{
    int i;

    for (i = 0; i < q->nb_subs; i++)
        av_packet_unref(&q->subs[i]);
    av_freep(&q->subs);
    q->nb_subs = q->allocated_size = q->current_sub_idx = 0;
}

2.4 ff_subtitles_queue_finalize

finalize函数主要是完成字幕数据包的排序和后处理,调用此接口表示所有字幕已经读取完了。代码如下:

void ff_subtitles_queue_finalize(void *log_ctx, FFDemuxSubtitlesQueue *q)
{
    int i;
	// 按照给定策略对队列中的数据包排序
    qsort(q->subs, q->nb_subs, sizeof(*q->subs),
          q->sort == SUB_SORT_TS_POS ? cmp_pkt_sub_ts_pos
                                     : cmp_pkt_sub_pos_ts);
    for (i = 0; i < q->nb_subs; i++)
        if (q->subs[i].duration < 0 && i < q->nb_subs - 1)
            q->subs[i].duration = q->subs[i + 1].pts - q->subs[i].pts;

    if (!q->keep_duplicates) // 剔除重复数据包
        drop_dups(log_ctx, q);
}

2.5 ff_subtitles_queue_insert

insert函数完成字幕数据的插入,并分配相关内存。代码如下:

AVPacket *ff_subtitles_queue_insert(FFDemuxSubtitlesQueue *q,
                                    const uint8_t *event, size_t len, int merge)
{
    AVPacket *subs, *sub;

    if (merge && q->nb_subs > 0) {
        /* merge with previous event */

        int old_len;
        sub = &q->subs[q->nb_subs - 1];
        old_len = sub->size;
        if (av_grow_packet(sub, len) < 0)
            return NULL;
        memcpy(sub->data + old_len, event, len);
    } else { // 多数基于文本的字幕都会进入这个逻辑分支
        /* new event */

        if (q->nb_subs >= INT_MAX/sizeof(*q->subs) - 1)
            return NULL;
		// 这个函数将保证q->subs中有足够的可用空间,不够的话自动扩展
        subs = av_fast_realloc(q->subs, &q->allocated_size,
                               (q->nb_subs + 1) * sizeof(*q->subs));
        if (!subs)
            return NULL;
        q->subs = subs;
        sub = &subs[q->nb_subs++];
        if (av_new_packet(sub, len) < 0)
            return NULL;
        sub->flags |= AV_PKT_FLAG_KEY;
        sub->pts = sub->dts = 0;
        memcpy(sub->data, event, len);
    }
    return sub;
}

3 小结

至此,我们已经基本上了解了FFmpeg内部对subtitle的解析逻辑,并且本文也以LRC为例做了说明。从整体来看,libavformat中对字幕解析的主要逻辑都集中在ff_subtitles_queue_*一系列API中。

当然,我们可以在理解这个逻辑的基础上,将subitle的demuxer改成逐帧读取数据,类似其他demuxer的处理逻辑,仅在需要的时候读取数据包,而不是在read_header时全部读完。我认为FFmpeg中字幕相关的demuxer(LRC、ASS、SRT、WebVTT等)这样实现主要考虑是出于基于文本的字幕通常占用内存较少。

参考资料

  1. libavformat-subtitles.c
  2. libavformat-lrcdec.c

----------------------------------------------------------------------------------------------------------------------------
本文作者:Tocy e-mail: zyvj@qq.com
版权所有@2015-2020,请勿用于商业用途,转载请注明原文地址。本人保留所有权利。
posted @ 2019-02-28 22:34  Tocy  阅读(846)  评论(0编辑  收藏  举报