教你手撕 AVI:提取其中的视频流和音频流
我想在嵌入式环境下,利用 MCU 的 JPEG 硬解码功能,以及 DAC 输出模拟信号的功能,实现一个视频播放器。这个过程中需要手搓 AVI 文件解析器。
以前尝试过使用 F1C200S 作为 MCU,从 SPIFLASH 加载启动 Buildroot。我编译的 Buildroot 使 MCU 超频到 900 MHz(我没搞定 F1C200S 的视频硬解码驱动,于是通过超频来让 MCU 达到能够流畅软解的条件)。我配置 Buildroot 集成 FFmpeg,使用 FFmpeg 直接软解播放 AVI 文件(指定其视频画面输出到 fbdev,然后音频流走 stdout
出去,再搭建管道把 FFmpeg 的 stdout
接到 alsa 音频驱动的用户态播放器 tinyalsa 库的 tinyplay 播放器的 stdin
,音视频的播放就解决了。
后来厂子卖给我的 F1C200S 开发板全是残次件,超频超不了一点,播放过程会出现 Kernel panic,于是我准备更换方案,用 STM32H750,不超频,跑 480 MHz 使用 JPEG 外设硬解来播放视频,不使用 Buildroot + FFmpeg,使用 STM32 HAL,手搓 AVI parser。
AVI 文件和 WAV 文件一样,使用 Chunk 来组织数据,每个 Chunk 都符合以下规则:
- FourCC 开头,比如
RIFF
、LIST
、data
等,每一种都对应各自的内容格式。
- 4 字节的 Chunk 内容长度(不包括 Chunk 头部)
- Chunk 内容
与 WAV 不同的是,WAV 使用 data
Chunk 来存储全部的音频数据,但是 AVI 则需要同时存储音频流和视频流。WAV 很少有 Chunk 嵌套 Chunk 的情况(基本上大多数你关心的 Chunk 都是 RIFF
Chunk 的子 Chunk),但 AVI 则不同,它有很多的 Chunk 嵌套的 Chunk。
AVI 把视频流拆成一个个的包(packet),音频流也拆成一个个的包,然后交错存储视频和音频的包。AVI 文件会在头部存储一个字段,指示它有多少个流;然后每个流都有个“流头部”数据,指示这个流是视频还是音频,它是什么格式,视频的话它有帧率计算的部分;音频的话它有采样率等。音频流头有 WAVEFORMATEX
结构。头部之后,就是一个个的 packet 了,每个 packet 都是一个 Chunk,它的 FourCC 使用两个数值字符描述它的流 ID,然后再使用两个字母来描述它是视频还是音频还是别的。到最后,AVI 会有一个 idx1
Chunk(这个 Chunk 不一定有,它是可选的),这个 Chunk 可以帮助你快速 seek 你的 AVI 文件,它存储每个 packet 的 FourCC 和文件内位置、长度。
常见的 AVI 都是具有两个流的:一个视频流,一个音频流。然而:
- 一个 AVI 既可以只有一个流(比如只有视频流),也可以有很多个流,比如视频流有两个,按用途分为成人版和儿童版;音频流有六个,按语言分为中文普通话版,粤语版,以及英文版,同时也分成人版和儿童版。像这样具有很多个流的 AVI 虽然很少见,但是有。
- 不同的流可以有不同的长度,如果音频流提前播放完了,后面就是静音的视频播放;如果视频流提前播放完了,它就停在最后一帧,然后继续播放音频。
- 有些播放器能智能匹配成人版视频流和成人版音频流;儿童版视频流和儿童版音频流;
- 它可能根据机器所在公网 IP 判断地区,根据地区判断选择普通话版的音频流还是粤语版、英文版的音频流;同时提供 UI 界面供你挑选要用的视频流和音频流等。
- 通常情况下,一般的播放器只会选择它遇到的第一个视频流和第一个音频流。
AVI 的音频部分,因为头部用的是 WAVEFORMATEX
结构存储的音频格式,这块和 WAV 文件具有一些相同的特征:音频格式按 format_tag
区分,采样率、声道数等都在 WAVEFORMATEX
里。而 AVI 的流头里面的 dwRate
和 dwScale
成员也同时给你指示音频的采样率数据。
音频也不一定是双声道,就像 WAV 一样,它可能不止具有两个声道,而是会有 2.1、2.2、3.1、4.1、5.1、6.1 等各种布局方式,每个声道都有对应的扬声器位置。而针对这种情况,对于通常只有双声道的 PC,有对应的下混器算法(Downmixer)使这种多声道布局被下混为双声道。
从微软的 AVI 文档抄来它的大致结构:
RIFF ('AVI '
LIST ('hdrl'
'avih'(<Main AVI Header>)
LIST ('strl'
'strh'(<Stream header>)
'strf'(<Stream format>)
[ 'strd'(<Additional header data>) ]
[ 'strn'(<Stream name>) ]
...
)
...
)
LIST ('movi'
{SubChunk | LIST ('rec '
SubChunk1
SubChunk2
...
)
...
}
...
)
['idx1' (<AVI Index>) ]
)
需要这样去理解它的结构:
- 所有带括弧的都是 Chunk,比如
RIFF
、LIST
这些都是 Chunk,符合 Chunk 的存储方式。
- 用单引号括起来的,则仅仅是个 flag,比如
AVI
、hdrl
,用来区分这个 Chunk 里面的数据是干啥的。
- 用单引号括起来,但是右边又有括弧,括弧里面又用尖括号告诉你头部结构的,那么它还是个 Chunk,得按读取 Chunk 的方式来读取。
需要注意:
- FFmpeg 生成的 AVI 有很多地方加了
JUNK
Chunk,正确的处理方式就是忽略之。
- 有很多 Chunk 嵌套的子 Chunk 里面也有
JUNK
Chunk,也要忽略之。
小结:
AVI 的格式分为三大部分:
- 第一个
LIST
Chunk:存储 AVI 的头部,包括 AVI 的主头部,以及每个流的流头部。每个流头部都是一个子 LIST
Chunk。
- 第二个
LIST
Chunk:开头有 movi
标识,存储的是所有流的包裹。这里又有两种情况:
- 紧随
movi
标识的,是一个个的流包裹。也就是,整个 AVI 只包含一个音视频片段。
- 紧随
movi
标识的,是子 LIST
Chunk,这个 Chunk 的开头是一个 rec
标识,然后里面是一个个的流包裹。每个子 LIST
Chunk 是一段音视频片段。这样的 AVI 包含多个音视频片段。
- 一般的播放器会无视这种多片段的结构,直接把子
LIST
Chunk 里面的流包裹拿出来连续播放。
- 一个可能有也可能没有的
idx1
Chunk:这是整个 AVI 文件的索引器,负责帮你快速定位你要的包裹。
AVI 的流包裹 Chunk 结构
每个流包裹都是一个 Chunk,而 Chunk 头部的 flag 则是两个数字 + 两个字母的形式构成,两个数字是十进制的流 ID,比如 00
,而两个字母则描述这个包裹的内容:
db
:未压缩视频帧。说白了就是 BMP 位图格式。有这种包裹的 AVI 头部会有 BITMAPINFO
。
dc
:压缩视频帧。具体压缩格式要看 AVI 流头的 fccHandler
字段,比如 MJPG
就是 JPEG 帧。除此以外还有其它各种各样的格式比如 H264 等。你如果有对应格式的解码器库,你就可以解码成 RGB 位图,没有就拉倒。
pc
:调色板。调色板是拿来干啥的呢?假设你的未压缩视频帧是使用索引颜色的 BMP 格式,那么当你遇到这种调色板包裹的时候,你就需要更新你的 BMP 调色板。基本上,遇到这种包裹的概率为零。都什么年代了还使用索引颜色?
wb
:一段音频。
举个例子,假设你的 AVI 文件有两个流,第 0
个流是 MJPEG 视频流,第 1
个流是 PCM 音频流,那么在 LIST(movi)
里面,你会遇到的 Chunk 序列的 FourCC 就像这样:
00dc
视频帧
01wb
音频片段
00dc
视频帧
01wb
音频片段
00dc
视频帧
01wb
音频片段
...
但是 AVI 没有规定说一定要均匀分布每个流的数据,它通常往往是不均匀的,比如:
00dc
00dc
00dc
00dc
01wb
01wb
01wb
01wb
01wb
01wb
而且大多数情况下,它都是不均匀的,有时候可能一下子给你好几分钟的视频流,然后才开始给你音频流。你的播放器如果采取的策略是缓存「时机未到」的视频帧或者音频帧的话,那你会遇到最坏情况:一个 AVI 文件前半段全是视频流,后半段全是音频流。
因此显然不能采取缓存策略,而是要针对你要播放的视频流和音频流,分别存储对应的当前播放位置的文件内偏移量。
但如果你只用一个文件对象/文件描述符/文件句柄用于播放 AVI 的话,你就要不停地 seek()
到视频帧的位置读取视频,再 seek()
到音频帧的位置读取音频,不断地往复地使用 seek()
。虽然确实不用缓存音视频包裹了,但是这样做依然低效,容易卡存储器 IO 带宽。这是因为如果你 seek()
的距离太远了,那么你的文件对象/文件描述符/文件句柄就需要重新缓存文件片段。
解决办法就是使用多个文件对象/文件描述符/文件句柄来打开同一个 AVI 文件,然后针对每个你要播放的流,使用它专用的文件对象/文件描述符/文件句柄。这样的话,每个文件对象/文件描述符/文件句柄的缓存都可以得到利用,能减少对存储器 IO 带宽的占用。
我的代码仓库——示例代码
GitHub
Gitee
注意以上两个仓库是同步更新的,你没有条件使用 GitHub 的话就注册一个 Gitee 账号吧。
AVI 头部 Parser 大致逻辑
以下代码摘自我的代码仓库,是用来读取 AVI 的流头的,凑合看吧。看不懂就请直接去仓库里看。
int avi_reader_init
(
avi_reader *r,
void *userdata,
read_cb f_read,
seek_cb f_seek,
tell_cb f_tell,
logprintf_cb f_logprintf,
avi_logprintf_level log_level
)
{
if (!f_logprintf) f_logprintf = default_logprintf;
#if AVI_ROBUSTINESS
if (!r)
{
avi_reader fake_r = create_only_for_printf(f_logprintf, log_level, userdata);
r = &fake_r;
FATAL_PRINTF(r, "Invalid parameter: `avi_reader *r` must not be NULL." NL);
r = NULL;
goto ErrRet;
}
#endif
memset(r, 0, sizeof *r);
r->userdata = userdata;
r->f_read = f_read;
r->f_seek = f_seek;
r->f_tell = f_tell;
r->f_logprintf = f_logprintf;
r->log_level = log_level;
#if AVI_ROBUSTINESS
if (!f_read)
{
FATAL_PRINTF(r, "Invalid parameter: must provide your `read_cb` implementation." NL);
goto ErrRet;
}
if (!f_seek)
{
FATAL_PRINTF(r, "Invalid parameter: must provide your `seek_cb` implementation." NL);
goto ErrRet;
}
if (!f_tell)
{
FATAL_PRINTF(r, "Invalid parameter: must provide your `tell_cb` implementation." NL);
goto ErrRet;
}
#endif
uint32_t riff_len;
if (!must_match(r, "RIFF")) goto ErrRet;
if (!must_read(r, &riff_len, 4)) goto ErrRet;
fsize_t avi_start;
if (!must_tell(r, &avi_start)) goto ErrRet;
if (!must_match(r, "AVI ")) goto ErrRet;
r->end_of_file = (size_t)avi_start + riff_len;
char fourcc_buf[5] = { 0 };
uint32_t chunk_size;
fsize_t end_of_chunk;
int got_all_we_need = 0;
int has_index = 0;
// https://learn.microsoft.com/en-us/windows/win32/directshow/avi-riff-file-reference
while (!got_all_we_need)
{
fsize_t cur_chunk_pos;
if (!must_read(r, fourcc_buf, 4)) goto ErrRet;
if (!must_read(r, &chunk_size, 4)) goto ErrRet;
if (!must_tell(r, &cur_chunk_pos)) goto ErrRet;
end_of_chunk = cur_chunk_pos + chunk_size;
switch (MATCH4CC(fourcc_buf))
{
default:
case FCC_JUNK:
case FCC_JUNK_:
INFO_PRINTF(r, "Skipping chunk \"%s\"" NL, fourcc_buf);
break;
case FCC_LIST:
case FCC_LIST_:
if (!must_read(r, fourcc_buf, 4)) goto ErrRet;
switch (MATCH4CC(fourcc_buf))
{
case FCC_hdrl:
case FCC_hdrl_:
INFO_PRINTF(r, "Reading toplevel LIST chunk \"hdrl\"" NL);
do
{
fsize_t end_of_hdrl = end_of_chunk;
int avih_read = 0;
int strl_read = 0;
char hdrl_fourcc_buf[5] = { 0 };
uint32_t hdrl_chunk_size;
fsize_t hdrl_chunk_pos;
fsize_t hdrl_end_of_chunk;
do
{
if (!must_read(r, hdrl_fourcc_buf, 4)) goto ErrRet;
if (!must_read(r, &hdrl_chunk_size, 4)) goto ErrRet;
if (!must_tell(r, &hdrl_chunk_pos)) goto ErrRet;
hdrl_end_of_chunk = hdrl_chunk_pos + hdrl_chunk_size;
switch (MATCH4CC(hdrl_fourcc_buf))
{
case FCC_avih:
case FCC_avih_:
if (avih_read)
{
FATAL_PRINTF(r, "AVI file format corrupted: duplicated main AVI header \"avih\"" NL);
goto ErrRet;
}
INFO_PRINTF(r, "Reading the main AVI header \"avih\"" NL);
r->avih.cb = hdrl_chunk_size;
if (!must_read(r, &(&(r->avih.cb))[1], r->avih.cb)) goto ErrRet;
if (r->avih.dwStreams > AVI_MAX_STREAMS)
{
FATAL_PRINTF(r, "The AVI file contains too many streams (%u) exceeded the limit %d" NL, r->avih.dwStreams, AVI_MAX_STREAMS);
goto ErrRet;
}
has_index = (r->avih.dwFlags & AVIF_HASINDEX) == AVIF_HASINDEX;
avih_read = 1;
break;
case FCC_LIST:
case FCC_LIST_:
INFO_PRINTF(r, "Reading the stream list" NL);
if (!must_match(r, "strl")) goto ErrRet;
fsize_t string_len = 0;
uint32_t stream_id = r->num_streams;
if (stream_id >= AVI_MAX_STREAMS)
{
FATAL_PRINTF(r, "Too many streams in the AVI file, max supported streams is %d" NL, AVI_MAX_STREAMS);
goto ErrRet;
}
avi_stream_info *stream_data = &r->avi_stream_info[r->num_streams++];
const fsize_t max_string_len = AVI_MAX_STREAM_NAME - 1;
char strl_fourcc[5] = { 0 };
uint32_t strl_chunk_size = 0;
fsize_t strl_chunk_pos;
fsize_t strl_end_of_chunk;
do
{
if (!must_read(r, strl_fourcc, 4)) goto ErrRet;
if (!must_read(r, &strl_chunk_size, 4)) goto ErrRet;
if (!must_tell(r, &strl_chunk_pos)) goto ErrRet;
strl_end_of_chunk = strl_chunk_pos + strl_chunk_size;
switch (MATCH4CC(strl_fourcc))
{
default:
case FCC_JUNK:
case FCC_JUNK_:
INFO_PRINTF(r, "Skipping chunk \"%s\"" NL, strl_fourcc);
break;
case FCC_strh:
case FCC_strh_:
INFO_PRINTF(r, "Reading the stream header for stream id %u" NL, stream_id);
if (!must_read(r, &stream_data->stream_header, strl_chunk_size)) goto ErrRet;
string_len = (sizeof stream_data->stream_name) - 1;
break;
case FCC_strf:
case FCC_strf_:
INFO_PRINTF(r, "Reading the stream format for stream id %u" NL, stream_id);
if (!must_tell(r, &stream_data->stream_format_offset)) goto ErrRet;
stream_data->stream_format_len = strl_chunk_size;
break;
case FCC_strd:
case FCC_strd_:
INFO_PRINTF(r, "Reading the stream additional header data for stream id %u" NL, stream_id);
if (!must_tell(r, &stream_data->stream_additional_header_data_offset)) goto ErrRet;
stream_data->stream_additional_header_data_len = strl_chunk_size;
break;
case FCC_strn:
case FCC_strn_:
INFO_PRINTF(r, "Reading the stream name for stream id %u" NL, stream_id);
string_len = strl_chunk_size;
if (string_len > max_string_len) string_len = max_string_len;
if (!must_read(r, &stream_data->stream_name, string_len)) goto ErrRet;
break;
}
if (!must_seek(r, strl_end_of_chunk)) goto ErrRet;
} while (strl_end_of_chunk < hdrl_end_of_chunk);
if (avi_stream_is_audio(stream_data))
{
size_t max_read = sizeof stream_data->audio_format;
size_t min_read = max_read - 2;
if (stream_data->stream_format_len >= min_read)
{
if (!must_seek(r, stream_data->stream_format_offset)) goto ErrRet;
if (!must_read(r, &stream_data->audio_format, max_read)) goto ErrRet;
switch (stream_data->audio_format.wFormatTag)
{
case 2:
break;
default:
stream_data->audio_format.cbSize = 0;
break;
}
}
}
char fourcc_type[5] = { 0 };
char fourcc_handler[5] = { 0 };
*(uint32_t *)fourcc_type = stream_data->stream_header.fccType;
*(uint32_t *)fourcc_handler = stream_data->stream_header.fccHandler;
if (!string_len)
{
INFO_PRINTF(r, "Stream %u: Type: \"%s\", Handler: \"%s\"" NL, stream_id, fourcc_type, fourcc_handler);
}
else
{
INFO_PRINTF(r, "Stream %u: Type: \"%s\", Handler: \"%s\", Name: %s" NL, stream_id, fourcc_type, fourcc_handler, stream_data->stream_name);
}
strl_read++;
break;
default:
case FCC_JUNK:
case FCC_JUNK_:
INFO_PRINTF(r, "Skipping chunk \"%s\"" NL, hdrl_fourcc_buf);
break;
}
if (!must_seek(r, hdrl_end_of_chunk)) goto ErrRet;
} while (hdrl_end_of_chunk < end_of_hdrl);
if (!must_seek(r, end_of_hdrl)) goto ErrRet;
if (!avih_read)
{
FATAL_PRINTF(r, "Missing main AVI header \"avih\"" NL);
goto ErrRet;
}
if (!strl_read)
{
FATAL_PRINTF(r, "No stream found in the AVI file." NL);
goto ErrRet;
}
} while (0);
break;
case FCC_movi:
case FCC_movi_:
INFO_PRINTF(r, "Reading toplevel LIST chunk \"movi\"" NL);
if (!must_tell(r, &r->stream_data_offset)) goto ErrRet;
// Check if the AVI file uses LIST(rec) pattern to store the packets
if (!must_read(r, fourcc_buf, 4)) goto ErrRet;
if (!memcmp(fourcc_buf, "LIST", 4))
{
if (!must_read(r, fourcc_buf, 4)) goto ErrRet;
if (!memcmp(fourcc_buf, "rec ", 4))
{
INFO_PRINTF(r, "This AVI file uses `LIST(rec)` structure to store packets." NL);
}
else
{
FATAL_PRINTF(r, "Inside LIST(movi): expected LIST(rec), got LIST(%s)." NL, fourcc_buf);
goto ErrRet;
}
}
break;
}
break;
case FCC_idx1:
case FCC_idx1_:
INFO_PRINTF(r, "Reading toplevel chunk \"idx1\"" NL);
if (!must_tell(r, &r->idx_offset)) goto ErrRet;
r->num_indices = chunk_size / sizeof(avi_index_entry);
break;
}
// Skip the current chunk
if (!must_seek(r, end_of_chunk)) goto ErrRet;
got_all_we_need = r->num_streams && r->stream_data_offset && ((has_index && r->idx_offset) || !has_index);
if (end_of_chunk == r->end_of_file) break;
}
if (!r->idx_offset)
{
WARN_PRINTF(r, "No AVI index: per-stream seeking requires per-packet file traversal." NL);
}
return 1;
ErrRet:
if (r) FATAL_PRINTF(r, "Reading AVI file failed." NL);
return 0;
}