跳转至

合宙嵌入式音视频格式 - 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 把同步下沉到底层:一个文件、一套引擎,应用层只管播放控制。

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,用于检测文件截断

HZV 文件四段式布局

图 2 | 头部 → 索引 → 交错媒体包 → 尾部

四种 magic 各司其职:HZV1(文件头)· HZIX(索引)· HZPK(媒体包)· HZTL(尾部)。

2.1 固定 128 字节文件头

MCU 上电后一次读取 128 字节,就能拿到播放所需的全部元信息。字段按功能分成五组:

Fixed Header 字段分区

图 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 播放器都建议按六层模块划分:

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 调用同一套转换入口,参数语义、输出格式、深度校验结果完全一致。

从任意视频到 .hzv

图 7 | FFmpeg 出素材,容器层封装,深度校验通过才算完成

5.1 流水线

  1. 输入常见容器:MP4 / MOV / MKV / AVI / WebM / FLV / MPEG / TS / WMV
  2. 媒体处理层:FFprobe 探测参数;按 contain / cover / stretch 做缩放、补边或裁剪
  3. 视频转成固定帧率 MJPEG(10~60 FPS);音频按目标预设转成 MP3 或 PCM
  4. 容器层扫描 JPEG 帧 / MP3 frame / PCM,计算 PTS,按 (PTS, 音频优先) 排序
  5. 写入 Header / Index / Packet / Trailer;边转换边校验,不全量载入内存
  6. 深度校验全绿后交付 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 首次验证推荐参数

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 hwsw 优先确保是 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 画面适配策略:完整显示补边 / 铺满裁剪 / 拉伸变形
搜索