feat(ui): 8 张 EAF 数字人完整接入 (m01~m08 全情绪映射)

数字人从原本 m06+m07 两张 EAF 扩展到 m01~m08 八张, 通过 ezgif 240×320 +
抽帧 N=3 + EAF Packer 配置, 8 张 EAF 总和压缩到 4.32 MB, SPIFFS 当前
4.94 MB 分区直接装下, 无需扩分区也不丢 OTA 升级能力.

main/dzbj/ai_chat_ui_eaf.c:
  - 新版/旧版 MMAP 自动检测 (header[8] == 0x14 → entry 32B; 0x10 → 28B),
    兼容在线 EAF Packer 两种导出格式 (FSIZE/FOFFSET 偏移自动适配).
  - find_cache_index_by_name 加 fallback: 找不到精确匹配时返回 cache[0],
    PoC 阶段单张 EAF 也能验证全部情绪触发.
  - emotion_map 22 情绪 → 8 张 EAF 重排:
    * 默认/积极组 12 → m01..m05 均分 (每张 2-3 种)
    * 思考/疲倦组 5 → m06
    * 负面/严肃组 3 → m07
    * 惊讶组 2 → m08
  - 数字人对齐 GFX_ALIGN_CENTER → GFX_ALIGN_BOTTOM_MID, 240×320 在
    360×360 圆屏贴底显示, 顶部 40px 透明露出背景图, 视觉跟之前
    360×360 全屏 EAF 一致 (脚部贴底, 字幕 z-index 上层覆盖底部 56px).

spiffs_image:
  - hiyori-assets.bin: 956 KB (m06+m07) → 4.53 MB (m01~m08 + index.json)
  - 删除原 GIF (hiyori_m{03,06,07}.gif), EAF 已替代不需要烧到设备.

实测数据 (Baji 2026-05-20):
  m01=814KB m02=516KB m03=563KB m04=559KB m05=606KB m06=423KB m07=379KB m08=566KB
  8 张 EAF 总和: 4.32 MB
  SPIFFS 占用: 4.58 MB / 4.64 MB 可用 = 1.3% 余量 (临界, 未来加资源需要规划)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Rdzleo 2026-05-20 18:17:09 +08:00
parent c6ecdb124c
commit be788be251
5 changed files with 56 additions and 28 deletions

View File

@ -96,31 +96,35 @@ typedef struct {
} eaf_emotion_map_t; } eaf_emotion_map_t;
static const eaf_emotion_map_t s_emotion_map[] = { static const eaf_emotion_map_t s_emotion_map[] = {
// 默认/积极 → m06 // 22 种情绪 → 8 张 EAF (m01~m08, 2026-05-20 全 8 张接入)
{"neutral", "hiyori_m06.eaf"}, // 注: 具体动作内容需要用户在 EAF Packer 中确认后调整, 当前是按情绪类别 +
{"happy", "hiyori_m06.eaf"}, // 编号顺序的初版分配, 烧录后看效果再精准调整
{"laughing", "hiyori_m06.eaf"}, // 默认/积极组 (12 → m01..m05 均分, 每张约 2-3 种情绪)
{"funny", "hiyori_m06.eaf"}, {"neutral", "hiyori_m01.eaf"},
{"loving", "hiyori_m06.eaf"}, {"happy", "hiyori_m01.eaf"},
{"relaxed", "hiyori_m06.eaf"}, {"blink", "hiyori_m01.eaf"},
{"delicious", "hiyori_m06.eaf"}, {"laughing", "hiyori_m02.eaf"},
{"kissy", "hiyori_m06.eaf"}, {"funny", "hiyori_m02.eaf"},
{"confident", "hiyori_m06.eaf"}, {"curious", "hiyori_m02.eaf"},
{"silly", "hiyori_m06.eaf"}, {"loving", "hiyori_m03.eaf"},
{"blink", "hiyori_m06.eaf"}, {"relaxed", "hiyori_m03.eaf"},
{"curious", "hiyori_m06.eaf"}, {"delicious", "hiyori_m04.eaf"},
// 思考/疲倦 → m07 {"kissy", "hiyori_m04.eaf"},
{"sleepy", "hiyori_m07.eaf"}, {"confident", "hiyori_m05.eaf"},
{"thinking", "hiyori_m07.eaf"}, {"silly", "hiyori_m05.eaf"},
{"confused", "hiyori_m07.eaf"}, // 思考/疲倦组 (5) → m06
{"embarrassed", "hiyori_m07.eaf"}, {"sleepy", "hiyori_m06.eaf"},
{"dizzy", "hiyori_m07.eaf"}, {"thinking", "hiyori_m06.eaf"},
// 负面/严肃 → 暂用 m07m03 未导入) {"confused", "hiyori_m06.eaf"},
{"embarrassed", "hiyori_m06.eaf"},
{"dizzy", "hiyori_m06.eaf"},
// 负面/严肃组 (3) → m07
{"sad", "hiyori_m07.eaf"}, {"sad", "hiyori_m07.eaf"},
{"crying", "hiyori_m07.eaf"}, {"crying", "hiyori_m07.eaf"},
{"angry", "hiyori_m07.eaf"}, {"angry", "hiyori_m07.eaf"},
{"surprised", "hiyori_m07.eaf"}, // 惊讶组 (2) → m08
{"shocked", "hiyori_m07.eaf"}, {"surprised", "hiyori_m08.eaf"},
{"shocked", "hiyori_m08.eaf"},
}; };
#define EMOTION_MAP_SIZE (sizeof(s_emotion_map) / sizeof(s_emotion_map[0])) #define EMOTION_MAP_SIZE (sizeof(s_emotion_map) / sizeof(s_emotion_map[0]))
@ -153,6 +157,13 @@ static int find_cache_index_by_name(const char *name) {
return i; return i;
} }
} }
// Fallback: 找不到精确匹配时 (例如 PoC 阶段 bin 里只有一张表情但代码映射到 m06/m07),
// 用 cache 里第一个可用 EAF 替代, 保证设备能显示动画 (验证阶段方便用)
// 注: 实际产品阶段所有情绪都该有对应 EAF, 这条 fallback 仅 PoC 兜底
if (s_eaf_cache_count > 0 && s_eaf_cache[0].data) {
ESP_LOGW(TAG, "Asset %s 不在 cache, fallback 到 %s", name, s_eaf_cache[0].name);
return 0;
}
return -1; return -1;
} }
@ -186,8 +197,11 @@ static esp_err_t switch_emotion_by_asset(const char *asset_name) {
}; };
gfx_anim_set_src_desc(s_anim_obj, &src); gfx_anim_set_src_desc(s_anim_obj, &src);
// 居中显示hiyori 209×360 居中放 360×360 屏 // 贴底显示, 让 240×320 数字人脚部紧贴屏底
gfx_obj_align(s_anim_obj, GFX_ALIGN_CENTER, 0, 0); // 底部 40px (= 360-320) 给数字人脚部 (跟之前 360×360 EAF 视觉一致)
// 顶部 40px 留透明空白 (chroma key 透明, 露出背景图)
// 字幕 56 高度在数字人上层 (z-index), 显示时覆盖最下方约 56px (含脚部)
gfx_obj_align(s_anim_obj, GFX_ALIGN_BOTTOM_MID, 0, 0);
// 全部帧 + EAF_DEFAULT_FPS + 永远循环 // 全部帧 + EAF_DEFAULT_FPS + 永远循环
gfx_anim_set_segment(s_anim_obj, 0, 0xFFFFFFFF, EAF_DEFAULT_FPS, true); gfx_anim_set_segment(s_anim_obj, 0, 0xFFFFFFFF, EAF_DEFAULT_FPS, true);
@ -260,14 +274,22 @@ void ai_chat_screen_init(void) {
} }
uint32_t file_count = header[12] | (header[13] << 8) | (header[14] << 16) | (header[15] << 24); uint32_t file_count = header[12] | (header[13] << 8) | (header[14] << 16) | (header[15] << 24);
ESP_LOGI(TAG, " MMAP file_count=%u", (unsigned)file_count); ESP_LOGI(TAG, " MMAP file_count=%u", (unsigned)file_count);
// 自动适配新旧两版 EAF Packer 的 MMAP 格式 (2026-05-20 新版 Packer entry 变 32B)
// 旧版: header[8] = 0x10, entry 28B = name(16) + size(4) + offset(4) + pad(4)
// 新版: header[8] = 0x14, entry 32B = name(16) + reserved(4) + size(4) + offset(4) + pad(4)
const bool is_new_mmap = (header[8] == 0x14);
const size_t ENTRY_SIZE = is_new_mmap ? 32 : 28;
const size_t FSIZE_OFFSET = is_new_mmap ? 20 : 16;
const size_t FOFFSET_OFFSET = is_new_mmap ? 24 : 20;
ESP_LOGI(TAG, " MMAP 版本=%s, ENTRY_SIZE=%zu",
is_new_mmap ? "新版(0x14)" : "旧版(0x10)", ENTRY_SIZE);
// 跳过 reserved 16B 到 entry table 起点 (0x20) // 跳过 reserved 16B 到 entry table 起点 (0x20)
fseek(f, 0x20, SEEK_SET); fseek(f, 0x20, SEEK_SET);
const size_t ENTRY_SIZE = 28; // 16 + 4 + 4 + 4
const size_t DATA_START = 0x20 + file_count * ENTRY_SIZE; const size_t DATA_START = 0x20 + file_count * ENTRY_SIZE;
s_eaf_cache_count = 0; s_eaf_cache_count = 0;
for (uint32_t i = 0; i < file_count; i++) { for (uint32_t i = 0; i < file_count; i++) {
uint8_t entry[28]; uint8_t entry[32]; // 按最大 entry 大小分配, 兼容旧/新版
fseek(f, 0x20 + i * ENTRY_SIZE, SEEK_SET); fseek(f, 0x20 + i * ENTRY_SIZE, SEEK_SET);
if (fread(entry, 1, ENTRY_SIZE, f) != ENTRY_SIZE) { if (fread(entry, 1, ENTRY_SIZE, f) != ENTRY_SIZE) {
ESP_LOGE(TAG, " entry[%u] 读取失败", (unsigned)i); ESP_LOGE(TAG, " entry[%u] 读取失败", (unsigned)i);
@ -276,8 +298,14 @@ void ai_chat_screen_init(void) {
char name[17] = {0}; char name[17] = {0};
memcpy(name, entry, 16); memcpy(name, entry, 16);
name[16] = '\0'; name[16] = '\0';
uint32_t fsize = entry[16] | (entry[17] << 8) | (entry[18] << 16) | (entry[19] << 24); uint32_t fsize = entry[FSIZE_OFFSET]
uint32_t foffset = entry[20] | (entry[21] << 8) | (entry[22] << 16) | (entry[23] << 24); | (entry[FSIZE_OFFSET + 1] << 8)
| (entry[FSIZE_OFFSET + 2] << 16)
| (entry[FSIZE_OFFSET + 3] << 24);
uint32_t foffset = entry[FOFFSET_OFFSET]
| (entry[FOFFSET_OFFSET + 1] << 8)
| (entry[FOFFSET_OFFSET + 2] << 16)
| (entry[FOFFSET_OFFSET + 3] << 24);
// 只缓存 .eaf 文件 // 只缓存 .eaf 文件
size_t nlen = strlen(name); size_t nlen = strlen(name);

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 442 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 398 KiB