简介
最近有看到其他的玩家发布过使用ESP32S31播放AVI视频的素材。我便自己想着看看能不能自己实现一下,但是找了一圈也没有找到对应的Github开源的仓库。于是就没办法了只能自己看看想办法来实现一下。好在现在随着Ai工具的发展,可以把这个苦力活交给AI做。而我需要的则是给他提供基本驱动。然后让他基于这些基本驱动来实现复杂的功能。对于S31的BSP,乐鑫已经提供了
espressif/esp32_s31_korvo_1: ^1.0.0~1
而对于视频的解析等库则是使用
espressif/esp_video: ~2.2.0
一、思路和视频素材
不过这里有点坑的是这个视频解析的库并不能直接使用。废话少说直接进入正题。程序的主要逻辑如下:
1. 从 SPIFFS 读 `/spiffs/1.avi`
2. 解析 AVI 容器头(RIFF/AVI/hdrl/movi),拿到尺寸、帧率、音频格式
3. 顺序读 movi 区的 chunk:`00dc` 视频帧喂硬件 JPEG 解码器→RGB565→缩放→LCD;`01wb` 音频块喂 I2S
4. 播到末尾自动 seek 回 movi 起点循环
先放结论:能跑,画面是 MJPEG 320×240 双线性缩放铺到 800×480,音频能出声,实测帧率大概十几到二十几 fps(取决于 MJPEG 复杂度)。下面把过程捋一捋,给也想这么玩的人省点时间。
AVI视频的话的话必须是MJEPG的编码才行。所以对于前期的准备的话我们可以从网上下载一个小一点的对应格式的AVI视频。然后把它放到spiffs文件系统下,在烧录程序的时候将其烧录到对应的spiffs 分区下面。这样的话读取的话就可以直接从文件系统来读取。至于为什么不用SD卡的原因是因为我这边写了两次的SD卡的Demo都挂载失败。因为我并没有多余的SD卡,可能是SD卡的大小导致了无法读取(32GB)

可以从上述的视频中看到是一个人赶着一只牛在走路。

文件的大小一共是1183KB,视频时长是24S。注意这里选择视频的话不要选择太大的视频了,否则写入到Flash里会耗费大量的时间。
二、AVI解析
AVI 是个老掉牙的 RIFF 容器,结构其实很规整。顶层长这样:
RIFF xxxx AVI LIST xxxx hdrl avih xxxx ← 主头(帧率/总帧数/尺寸) LIST xxxx strl ← 视频流 strh ... ← vids/auds 区分流类型 strf ... ← BITMAPINFOHEADER 或 WAVEFORMATEX LIST xxxx strl ← 音频流(同上) LIST xxxx movi ← 媒体数据区(帧都在这里) idx1 ... ← 索引(可忽略)
我没用第三方解析库,直接按字节布局定义了三个结构体(其实并不是不想用,原本是想尝试直接使用ESP_VIDEO 做的,但是AI尝试了好多次都没有成功。最终使其实现的自定义解析的功能):
#pragma pack(push, 1)
typedef struct {
uint32_t us_per_frame; /* 每帧微秒数,由此算帧率 */
...
uint32_t width, height;
} avi_main_header_t;
typedef struct { ... uint32_t compression; ... } bitmap_info_header_t;
typedef struct { ... uint16_t bits_per_sample; } wave_format_ex_t;
#pragma pack(pop)具体的解析流程如下(这里非常麻烦,如果没有AI的辅助和除非对这一块的格式比较了解,否则这个工作基本上非常难完成):
1. 验 RIFF 头:读 4 字节看是不是 `RIFF`,再读 4 字节看是不是 `AVI `(注意有个尾空格)。
2. 顶层扫描找 LIST 'hdrl:用 `fread` 读 chunk_id,再读 chunk_size,遇到 `LIST` 就再读 4 字节 list_type 判断。不是 hdrl 的话整块跳过——这里有个坑:`fseek(SEEK_CUR, chunk_size - 4)`,因为 list_type 那 4 字节已经读出来了,跳过剩余部分要减掉。
3. 进 hdrl 内部循环:找 `avih`、`strl`(视频/音频)、`movi`。
AVI 还有个特别烦的对齐规则:块按字(2字节)对齐,奇数大小的块后面补 1 字节 padding。所以每读完一块long chunk_end = chunk_start + chunk_size + (chunk_size & 1);(chunk_size & 1)` 就是奇补 1、偶补 0,省得单独写 if。整个解析过程凡是跳块都这么算的,漏了这个-padding 在某些文件上会错位。
strl(流列表)包含 strh(流头)和 strf(流格式),因此进入 strl 后需要继续遍历其子块。strh 用于判断流类型(vids 为视频、auds 为音频),strf 则分别解析视频的 BITMAPINFOHEADER 或音频的 WAVEFORMATEX。实现中优先使用 strf 中的宽高覆盖 avih,因为其数据通常更准确;视频通过 FourCC MJPG 判断格式,音频保存声道数、采样率和采样位数。最后,只有同时找到 avih、视频 strf,且 movi 数据偏移有效时,才认为 AVI 文件解析成功。 代码如下所示
while (ftell < strl_end) {
读 sub_id;
if (strh) memcpy(stream_type, strh_hdr[0..4], 4); /* 记下 vids/auds */
if (strf) {
if (stream_type=="vids") 读 BITMAPINFOHEADER;
if (stream_type=="auds") 读 WAVEFORMATEX;
}
fseek(sub_start + sub_size + (sub_size & 1)); /* 对齐跳下一块 */
}三、硬件 JPEG 解码:esp_driver_jpeg 而不是 V4L2
这块是我花时间最多的地方。先说结论:在 S31 上别用 esp_video 那套 V2L2 封装的 JPEG 设备,它在这个芯片上只暴露了编码方向(至少我手上这版 IDF6.20 是这样)。解码请直接用原生的 esp_driver_jpeg 驱动
jpeg_new_decoder_engine(&eng_cfg, &jpeg_decoder); jpeg_decoder_process(jpeg_decoder, &dec_cfg, jpeg_in, in_size, outbuf, outbuf_size, &out_size);
这里有两个坑需要注意下:
一、
硬件 JPEG 是按 16×16 的 MCU 块解码的,所以输出缓冲的行宽(stride)会向上对齐到 16 的倍数。比如你解码 320×240,实际输出 stride 是 `(320+15)&~15 = 320`(刚好整除没事),但如果是 312×240,stride 就是 320,每行右边多 8 像素的垃圾。
所以申请输出缓冲时要按对齐后的尺寸算:
uint32_t aligned_w = (avi_width + 15) & ~15u; uint32_t aligned_h = (avi_height + 15) & ~15u; size_t want = aligned_w * aligned_h * 2; /* RGB565 */
二、解码输出是走 2D-DMA 写到 PSRAM 的,驱动内部有 _check_buffer_alignment 校验,地址和大小都得满足 cache line 对齐。第一次我图省事 malloc + heap_caps_malloc(SPIRAM) 直接分配内存,结果 jpeg_decoder_process 一调就返回 ESP_ERR_INVALID_ARG,对着 log 猜半天才发现是这个问题。
下面是正确的做法: 即使用驱动内提供的分配器来分配PSRAM这样的话,它会自己处理对其问题。
jpeg_decode_memory_alloc_cfg_t mem_cfg = {
.buffer_direction = JPEG_DEC_ALLOC_OUTPUT_BUFFER,
};
jpeg_dec_outbuf = jpeg_alloc_decoder_mem(want, &mem_cfg, &jpeg_dec_outbuf_size);四、屏幕的显示
基本上到这里的时候屏幕就已经可以正常显示了。屏幕的显示是使用LVGL的canvas直接画上去的。是直接将RGB555写入进去。但是由于屏幕的分辨率是800*480 ,而这个视频的大小只有320*240,所以这里需要对视频进行放大处理。这里我一共是使用AI生成了两个版本。第一个是最近邻算法来采样周围点的像素来进行的填充。第二个是使用的双线性插值完成的。实际上由于原本的视频的分辨率非常低和质量非常差。导致最后的效果其实看起来也没有什么区别。
从上图中依稀可以看到白色的是一只牛,后面又一个人。我用电脑播放和上图做一个对比。

五、音频处理
音频初始化采用 BSP 提供的 bsp_audio_init 和 bsp_audio_codec_speaker_init,随后根据 AVI 文件解析得到的音频参数调用 esp_codec_dev_open 打开 Codec。由于测试 AVI 使用的是 8 位无符号 PCM,而 Codec 统一采用 16 位格式,因此播放前先在软件中将 U8 PCM 转换为 S16 PCM,即先减去 128 去除直流偏置,再乘以 256 扩展到 16 位动态范围。这里音频让Ai辅助处理之后我也听不出来实际的效果。因为这一个接口的麦克风我只有一个,但是连接线被我给不小心弄断了。因此音频没办法验证。
六、主循环控制
主循环和上述的实现思路一致基本上就是:
mount SPIFFS -> stat/AVI ->fopen ->parse_avi_header (绑定文件系统、检索AVI,然后打开文件、解析AVI的头)再接着分配 disp_buf(PSRAM) -> init_jpeg_decoder -> init_audio (先在PSRM里分配足够的内存,然后调用解码器对AVI进行解码,并且初始化音频数据) 然后将数据填充到对应的LVGL的buffer中。再返回到起点重新一次渲染。
while (1) {
uint32_t type = read_next_chunk(frame_buf, MAX_FRAME_SIZE, &chunk_size);
if (type == 0) {
/* 末尾或块过大被跳过: seek 回起点循环播放 */
fseek(avi_fp, movi_data_offset, SEEK_SET);
continue;
}
if (type == 0x63643030) { /* '00dc' 视频 */
decode_jpeg_frame(...);
scale_and_display_frame(rgb565_buf);
frame_count++;
/* 帧率控制: 本帧耗时 < 目标帧间隔则延时补齐 */
if (elapsed < target_frame_time_us)
vTaskDelay(...);
if (frame_count % 100 == 0) ESP_LOGI("FPS: %.2f", ...);
} else if (type == 0x62773130) { /* '01wb' 音频 */
if (spk_codec_dev) play_audio_chunk(...);
}
}实验效果如下
总结
实际上折腾这个解码是一个非常非常耗时的一个操作。不过目前所有的流程已经跑通了,受限于Flash的限制和SD卡的无法读取。目前是没有办法来测试播放高分辨率的视频的。 目前也是刚开始接触视频的解码等。尚且不清楚是否有更好的库能够省去上述的解码和播放的过程。从而使其程序的流程更加简单,而不是放到了如何来处理数据上。上述视频的帧率看着卡顿的原因是因为源视频就是这样一顿一顿的。并不是ESP32S31的实际表现(并非是性能不行,而是视频原因)。如果有折腾过这个视频解码的大佬也欢迎多给出一点Idea,看看怎么能把这个解析的更好一点。最后附加上OpenCode Go的调用账单

附件
我要赚赏金
