合宙嵌入式音视频格式 - HZV
HZV 是合宙面向嵌入式设备的轻量音视频容器格式(当前版本 HZV v1)。它把 MJPEG 视频、MP3 或 PCM 音频、时间戳和索引封装在同一个 .hzv 文件里,由底层引擎完成解复用、解码和音画同步。应用层只需要打开一个文件,调用 play() / pause() / stop()。
设计重点不是压缩率,而是在资源受限的 MCU 上做到简单、稳定、可预测。
一、格式概述
1.1 为什么需要 HZV
嵌入式硬件资源有限,MP4、AVI 这类通用格式的解码性能往往达不到要求。大多数嵌入式芯片自带 JPEG 硬解码电路,于是 MJPG 成了视频播放的常客——但 MJPG 没有音频。
HZV 出现之前,想播放“有画面有声音”的内容,通常要准备三样东西:一个 .mjpg 视频文件、一个 .mp3 音频文件,再加一段 Lua 同步脚本。应用层要自己启动音频、计算已播放时间、判断目标帧,再用 step()、skip() 手动追赶音频。
这套方案验证功能可以,做产品会遇到这些问题:
- 要同时管理视频、音频两个文件
- 应用层必须理解帧率、时间戳、跳帧和音频启动时机
- 暂停、恢复、循环播放时,时钟经常忘记一起归零
- UI 引擎、JPEG 解码、音频 DMA 跑在不同调度周期,长时间播放会累积漂移
- 视频落后时,如果先解码再决定要不要显示,会浪费大量解码时间
HZV 把同步下沉到底层:一个文件、一套引擎,应用层只管播放控制。
图 1 | 从“三件套手动同步”到“单文件全自动”
1.2 设计原则
HZV 不拼压缩率,拼“可预测”:
- 每帧都是独立 JPEG,不依赖前后帧,解码前就能快速跳过过期帧
- 音视频包按 PTS 交错排列,适合从 Flash / SD 卡顺序读取
- 固定长度头部和包头,MCU 不需要实现复杂的通用容器解析器
- 音视频共享同一时间轴,应用层不再负责逐帧同步
注意:HZV 不负责突破硬件性能上限。用 HZV 不代表设备一定能流畅播放 1024×600@60FPS。实际能力取决于 JPEG 复杂度、存储读取速度、解码速度、LCD 带宽和 UI 刷新链路。
1.3 适用场景
带屏嵌入式设备需要播放“带声音的动画 / 视频”时,例如:
- 广告机、工控 HMI、仪表开机动画
- 宠物伴侣、儿童玩具等交互设备的提示动画
- 本地素材循环播放,且要求音画同步、可校验、可定位
二、文件结构
HZV v1 使用小端字节序,媒体包按 4 字节对齐。整个文件由四段组成,每段都有自己的 magic:
| 区段 | Magic | 长度 | 作用 |
|---|---|---|---|
| Fixed Header | HZV1 |
固定 128 字节 | 版本、flags、时长、偏移表、音视频编码参数 |
| Index | HZIX |
32 字节头 + 每条 24 字节 | 每视频帧一条索引,音频周期性同步点 |
| Media Packets | HZPK |
变长 | 音视频包按 PTS 交错排列 |
| Trailer | HZTL |
固定 32 字节 | 记录索引位置 + CRC,用于检测文件截断 |
图 2 | 头部 → 索引 → 交错媒体包 → 尾部
四种 magic 各司其职:HZV1(文件头)· HZIX(索引)· HZPK(媒体包)· HZTL(尾部)。
2.1 固定 128 字节文件头
MCU 上电后一次读取 128 字节,就能拿到播放所需的全部元信息。字段按功能分成五组:
图 3 | 128 字节文件头按功能分为五个区
2.1.1 全局区(offset 0–31)
| 字段 | 说明 |
|---|---|
| magic | 固定为 HZV1 |
| 版本号 | major / minor 各一组。major 不认识必须拒绝打开;minor 在向后兼容前提下可接受 |
| header_size | 头部长度,v1 为 128 |
| flags | 能力与约束位,见下表 |
| timescale | 时间基,用于把 ticks 换算成毫秒 |
| stream_count | 流数量(视频 / 音频) |
| duration_ticks | 媒体总时长(ticks) |
flags 位定义:
| 比特 | 含义 |
|---|---|
| bit0 | 含视频 |
| bit1 | 含音频 |
| bit2 | 含索引 |
| bit3 | 包 CRC |
| bit4 | 固定帧率 |
| bit5 | 音频主时钟 |
| bit6 | 循环提示 |
2.1.2 定位区(offset 32–63)
| 字段 | 说明 |
|---|---|
| packet_data_offset | 媒体包区起始偏移 |
| index_offset | 索引区起始偏移 |
| index_size | 索引区总大小 |
| packet_count | 媒体包总数 |
| video_frame_count | 视频帧总数 |
索引紧跟 Header、位于媒体包之前:先建目录,再放数据。顺序播放并不依赖索引,但它支撑快速定位、进度拖动和循环回绕。
2.1.3 视频区(offset 64–83)
| 字段 | 说明 |
|---|---|
| video_codec | 1 = MJPEG |
| width / height | 画面宽高 |
| fps_num / fps_den | 帧率分子 / 分母 |
| video_output_format | 输出格式,当前为 RGB565 |
2.1.4 音频区(offset 84–99)
| 字段 | 说明 |
|---|---|
| audio_codec | 0 无音频 · 1 MP3 · 2 PCM S16LE |
| sample_rate | 采样率 |
| channels / bits | 声道数、位宽 |
| audio_frame_samples | 音频帧采样数 |
未识别的必选 codec 必须拒绝打开。
2.1.5 保护区(offset 100–127)
| 字段 | 说明 |
|---|---|
| max_video_packet | 视频包上限,用于预分配解码缓冲 |
| max_audio_packet | 音频包上限 |
| header_crc32 | 文件头 CRC32 |
| content_id | 内容 UUID |
2.2 媒体包:32 字节包头 + payload
每个音频包或视频包都带一个 32 字节固定包头,后面紧跟 payload,最后补 0 到 4 字节对齐。
图 4 | 固定包头让解析器零猜测,规则让跳帧零成本
| 字段 | 占用 | 说明 |
|---|---|---|
| magic | 4B | 固定为 HZPK |
| size | 2B | 包总大小相关字段 |
| stream_id / flags | 1B + 1B | 0 = 视频,1 = 音频 |
| pts | 8B | 演示时间戳(64 位,防止溢出) |
| duration | 4B | 该包持续时间 |
| payload_size | 4B | payload 字节数 |
| crc32 | 4B | 包校验(由 header flags bit3 控制是否启用) |
| seq | 4B | 包序号 |
包头 flags:KEY(关键帧)· SYNC · DISCONTINUITY · END。
视频包规则:
- 一包一帧,JPEG 必须以
FFD8(SOI)开始、FFD9(EOI)结束 - MJPEG 每帧独立,可直接跳过任意帧,不影响后续解码
音频包规则:
- MP3 在完整 frame 边界分包(约 40~100ms)
- PCM 在完整 sample frame 边界分包
- PTS 按累计采样数计算,不按字节数估算
音视频包按 PTS 交错排列;PTS 相同时,音频包在前。
2.3 索引区与尾部
索引区(HZIX)紧跟 Header、位于媒体包之前:
- 32 字节索引头
- 每条 24 字节索引项(PTS、文件偏移、payload 大小等)
- 每视频帧一条;音频按周期写入同步点
末尾 32 字节 Trailer(HZTL)再记录一遍索引位置。播放器把 Header 和 Trailer 里的索引位置互相核对,文件是否被截断、索引是否损坏可以立刻发现。
深度校验通过才算合法文件,至少包括:
- 四个 magic 正确
- 三级 CRC32(头部 / 包 / 尾部)
- 偏移不越界
- PTS 单调
- JPEG SOI/EOI 完整
- 索引指向真实包头
- 数量与 Header 声明一致
损坏文件必须明确报错,绝不静默当 MJPEG 打开。
三、音视频同步
同步不靠 Lua 定时器,也不靠系统启动时间,而是用 Audio V2 在 DAC/I2S DMA 实际播完一个 block 时累计的采样数作为主时钟:
played_samples += block_bytes / (sample_bytes × channels)
audio_pts_ms = played_samples × 1000 / sample_rate
这样得到的是硬件实际消耗的音频进度——短暂解码等待或 FIFO 欠载不会被误当成“已经播完”。
视频调度器每跑一轮,拿音频时钟和下一帧的 PTS 比较,只有三种结局:
| 条件 | 动作 |
|---|---|
pts > audio_time + lead |
帧还没到点:继续等待,不解码、不占 CPU |
pts ≤ audio_time + lead |
正好到显示窗口:解码并上屏(硬解 → RGB565 → LCD) |
pts + duration < audio_time − late |
已经明显落后:只移文件指针跳过该帧 |
图 5 | 等待 / 显示 / 跳帧三分支,跳帧发生在解码之前
音频优先原则: 设备性能不足时,宁可丢视频帧,也绝不为了等视频而打断音频。用户感知是声音永远连续,画面最多偶尔跳一下。
为什么“跳帧先于解码”重要:MJPEG 每帧独立,落后帧根本不需要解码,直接把文件指针移到下一帧即可。这是 HZV “简单可预测”设计带来的性能红利。
四、播放器架构
无论设备端还是桌面端,HZV 播放器都建议按六层模块划分:
图 6 | 音频支路产出主时钟,视频支路跟着钟走
| 层级 | 模块 | 职责 |
|---|---|---|
| 应用层 | Lua | play() / pause() / stop() / get_stats() |
| ① | HZV Reader | 文件结构解析、边界检查、CRC 校验 |
| ② | Demux | 区分音/视频包,提供 peek / read / skip / seek |
| ③ | Audio Backend | MP3/PCM 解码 → DAC / I2S DMA,输出已播放采样数(主时钟) |
| ④ | Video Scheduler | 对照音频时钟与帧 PTS,决定等待 / 显示 / 跳帧 |
| ⑤ | JPEG Decoder | 硬解优先,luat_jpeg_decode_hw_fast() → RGB565 |
| ⑥ | Presenter | 完成帧提交回 UI 线程 → AirUI / LCD |
关键纪律:
- 帧按需读取解码,落后帧在解码前跳过
- 使用 64 位 PTS,防止长时间播放溢出
- 损坏文件明确报错,不回退成普通 MJPEG
五、素材转换
转换器分两层:媒体处理层(FFmpeg / FFprobe 做探测、缩放、转码)和 HZV 容器层(合宙工具负责封装与校验)。AirMaster 图形界面和 Agent 调用同一套转换入口,参数语义、输出格式、深度校验结果完全一致。
图 7 | FFmpeg 出素材,容器层封装,深度校验通过才算完成
5.1 流水线
- 输入常见容器:MP4 / MOV / MKV / AVI / WebM / FLV / MPEG / TS / WMV
- 媒体处理层:FFprobe 探测参数;按 contain / cover / stretch 做缩放、补边或裁剪
- 视频转成固定帧率 MJPEG(10~60 FPS);音频按目标预设转成 MP3 或 PCM
- 容器层扫描 JPEG 帧 / MP3 frame / PCM,计算 PTS,按
(PTS, 音频优先)排序 - 写入 Header / Index / Packet / Trailer;边转换边校验,不全量载入内存
- 深度校验全绿后交付
input.mp4 → input.hzv
结尾对齐策略:
| 策略 | 含义 |
|---|---|
| hold-video(默认) | 音频更长时保持最后一帧画面 |
| shortest | 以较短的那路为准截断 |
| pad-audio | 视频更长时给音频补静音 |
AirMaster 工具链入口:
hzv_converter.py:探测与转码hzv_format.py:二进制读写与校验hzv-converter.exe:独立转换程序- Agent 工具:
hzv_probe/hzv_convert
5.2 Air1601 首次验证推荐参数
图 8 | 先稳后快:分辨率、音频、画面适配、结尾策略一次定好
| 项 | 推荐值 | 说明 |
|---|---|---|
| 视频 | 480 × 320,10 FPS | Air1601 首选分辨率,先保证流畅 |
| 音频 | MP3 32 kHz 单声道,64 kbps | 提示音 / 语音足够用 |
| 画面 | contain + 黑色补边 | 保持宽高比,不变形 |
| 结尾 | hold-video,开启包 CRC32 | 音频更长时保持末帧 |
稳定后再按 10 → 15 → 20 → 24 → 30 FPS 进阶。60 FPS 仅用于转换能力验证或高性能平台,不要默认 Air1601 全场景都能扛住。
素材存放:
- 文件较小:和脚本一起放 LuaDB,例如
/luadb/demo.hzv - 文件较大:放 SD 卡顺序读取,例如
/sd/demo.hzv
六、设备端播放
固件需要启用 AirUI、videoplayer、HZV reader、JPEG 解码、Audio V2(对应宏 LUAT_USE_AIRUI / VIDEOPLAYER / HZV / JPEG_DECODE_HW / AUDIO_V2)。从 SD 卡播放一个 HZV 只需要一段组件配置:
local video = airui.video({
parent = airui.screen,
x = 272, y = 140, w = 480, h = 320,
src = "/sd/demo.hzv", -- 小文件可放 /luadb/demo.hzv
format = "hzv",
backend = "videoplayer",
decode_mode = "hw", -- 硬解优先
loop = true,
auto_play = true,
})
同步已经下沉到底层,Lua 侧不需要设置 interval,也不需要定时调 step() / skip()。播放控制在三个接口之内:
video:play() -- 开始或恢复播放
video:pause() -- 同时暂停视频调度和音频播放
video:stop() -- 停止播放并回到初始状态
调优时用 video:get_stats() 看运行状态:
| 字段 | 含义 | 关注点 |
|---|---|---|
fps |
实际呈现帧率(非容器声明值) | 与目标帧率的差距 |
av_delta_ms |
视频与音频的时间差 | 偶发一帧内波动属正常;持续单向增长才是失步 |
dropped_frames |
因落后而跳过的 JPEG 帧数 | 持续增长说明解码 / 带宽吃紧 |
audio_underruns |
音频 FIFO 欠载次数 | 持续增加要查存储速度、解码负载和 FIFO 配置 |
clock_mode |
时钟模式 | 正常硬件播放应为 sample-counter |
decode_mode |
hw 或 sw |
优先确保是 hw |
七、版本策略与演进
HZV 的版本策略比较克制:
- major version 不认识就拒绝打开
- minor version 在向后兼容的前提下可以接受
- 未识别的必选 codec 必须拒绝
后续会在不改变 Lua 基本播放接口的前提下演进,例如:音频环形缓冲降低长视频内存占用、基于索引的 seek、更多音频编码、播放事件回调。
核心原则不会变:
单文件、顺序读取、独立 JPEG 帧、统一 PTS、音频主时钟——Lua 永远不负责逐帧同步。
附录 术语表
| 术语 | 解释 |
|---|---|
| HZV | 合宙嵌入式音视频容器格式,文件扩展名 .hzv |
| MJPEG | Motion JPEG,每帧都是独立 JPEG |
| PTS | Presentation Time Stamp,演示时间戳 |
| Audio V2 | 合宙新音频框架,HZV 用其 DMA 已播采样数作为主时钟 |
| AirUI | 合宙图形界面框架,通过 airui.video 播放 HZV |
| AirMaster | 合宙桌面工具,内置 HZV 视频转换入口 |
| contain / cover / stretch | 画面适配策略:完整显示补边 / 铺满裁剪 / 拉伸变形 |