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等)这样实现主要考虑是出于基于文本的字幕通常占用内存较少。
参考资料
----------------------------------------------------------------------------------------------------------------------------
本文作者:Tocy e-mail: zyvj@qq.com
版权所有@2015-2020,请勿用于商业用途,转载请注明原文地址。本人保留所有权利。