按 GSD 框架 .planning/milestones/digital_human_rtc/phases/phase_04_emotion_mapping/
规划完成 Phase 4 情绪映射 + 运行时切换。
## 核心变更
### bg_gif_demo 新增运行时切换接口
```c
esp_err_t bg_gif_demo_switch_gif(const char *new_gif_path);
```
实现要点:
- 先 heap_caps_free(g_gif_data) 释放旧 PSRAM,再加载新 GIF,单峰仅一份
- 内部 static last_gif_path 去重(相同路径直接返回 ESP_OK)
- 切换后立即 lv_timer_set_period(timer, 20) 防止 lv_gif_set_src 重建为默认 10ms
- LVGL 锁保护 200ms 超时
### 22 情绪 → 3 hiyori GIF 映射表
定义在 main/dzbj/ai_chat_ui.c USE_BG_GIF_POC 包裹内:
| GIF | 情绪标签 (个数) |
|-----|----------------|
| m06 (默认/积极) | neutral, happy, laughing, funny, loving, relaxed, delicious, kissy, confident, silly, blink, curious (12) |
| m07 (思考/疲倦) | sleepy, thinking, confused, embarrassed, dizzy (5) |
| m03 (负面/严肃) | sad, crying, angry, surprised, shocked (5) |
22 个标准情绪 100% 覆盖,未映射的情绪默认 fallback 到 m06。
### ai_chat_set_emotion 改造
PoC 模式下优先切换数字人大图(不再切隐藏的 emoji 200x89):
```c
#ifdef USE_BG_GIF_POC
if (bg_gif_demo_is_running()) {
const char *path = find_hiyori_gif(emotion);
bg_gif_demo_switch_gif(path);
return;
}
#endif
// 非 PoC 模式 fallback emoji 路径保留
```
## 调用链(已与现有 RTC 字幕解析对接,无需改 application.cc)
RTC 字幕 → application.cc:1419 display->SetEmotion(emo)
→ AiChatDisplay::SetEmotion (display/ai_chat_display.cc)
→ ai_chat_set_emotion (dzbj/ai_chat_ui.c)
→ bg_gif_demo_switch_gif (PoC 模式)
→ 数字人 hiyori GIF 切换
## 烧录验证(用户实测)
监控 60s 期间捕获情绪切换:
- neutral / happy → m06(已在播,去重)
- thinking → m07 切换成功 (590ms 延迟)
- confused → m07 去重跳过
- AI 回复结束 → 自动回到 neutral
- 无 abort / 重启
m06 ↔ m07 切换屏幕可视确认,m03 走相同代码路径无需重复测试。
## GSD 文档(同时提交)
- .planning/milestones/digital_human_rtc/phases/phase_04_emotion_mapping/PLAN.md
- .planning/milestones/digital_human_rtc/phases/phase_04_emotion_mapping/EMOTION_REPORT.md
228 lines
6.4 KiB
C
228 lines
6.4 KiB
C
/**
|
||
* bg_gif_demo.c — 背景图 + 透明 GIF 叠加(方案 C 实现)
|
||
*/
|
||
#include "bg_gif_demo.h"
|
||
#include "fatfs.h"
|
||
#include "esp_log.h"
|
||
#include "esp_heap_caps.h"
|
||
#include "esp_lvgl_port.h"
|
||
#include "esp_spiffs.h"
|
||
#include "lvgl.h"
|
||
#include "jpeg_decoder.h"
|
||
#include <stdio.h>
|
||
#include <string.h>
|
||
#include <stdlib.h>
|
||
|
||
static const char *TAG = "BG_GIF";
|
||
|
||
// 运行时状态
|
||
static lv_obj_t *g_bg_img = NULL;
|
||
static lv_obj_t *g_gif_obj = NULL;
|
||
static uint8_t *g_bg_data = NULL; // 背景图解码后的 RGB565 buffer (PSRAM)
|
||
static uint8_t *g_gif_data = NULL; // GIF 文件二进制 (PSRAM)
|
||
static lv_img_dsc_t g_bg_dsc;
|
||
static lv_img_dsc_t g_gif_dsc;
|
||
static bool g_running = false;
|
||
|
||
// 确保 SPIFFS 已挂载
|
||
static esp_err_t ensure_spiffs_mounted(void) {
|
||
size_t total = 0, used = 0;
|
||
if (esp_spiffs_info("storage", &total, &used) == ESP_OK) {
|
||
return ESP_OK;
|
||
}
|
||
ESP_LOGI(TAG, "SPIFFS 未挂载,自动挂载...");
|
||
esp_vfs_spiffs_conf_t conf = {
|
||
.base_path = "/spiflash",
|
||
.partition_label = "storage",
|
||
.max_files = 5,
|
||
.format_if_mount_failed = false,
|
||
};
|
||
return esp_vfs_spiffs_register(&conf);
|
||
}
|
||
|
||
// 加载 GIF 文件到 PSRAM
|
||
static esp_err_t load_gif_to_psram(const char *path) {
|
||
FILE *f = fopen(path, "rb");
|
||
if (!f) {
|
||
ESP_LOGE(TAG, "GIF 打开失败: %s", path);
|
||
return ESP_FAIL;
|
||
}
|
||
fseek(f, 0, SEEK_END);
|
||
size_t sz = ftell(f);
|
||
fseek(f, 0, SEEK_SET);
|
||
|
||
g_gif_data = heap_caps_malloc(sz, MALLOC_CAP_SPIRAM);
|
||
if (!g_gif_data) {
|
||
ESP_LOGE(TAG, "GIF PSRAM 分配失败: %d bytes", (int)sz);
|
||
fclose(f);
|
||
return ESP_ERR_NO_MEM;
|
||
}
|
||
if (fread(g_gif_data, 1, sz, f) != sz) {
|
||
heap_caps_free(g_gif_data);
|
||
g_gif_data = NULL;
|
||
fclose(f);
|
||
return ESP_FAIL;
|
||
}
|
||
fclose(f);
|
||
|
||
// 构造 GIF 的 lv_img_dsc_t(CF_RAW,由 LVGL gifdec 解码)
|
||
memset(&g_gif_dsc, 0, sizeof(g_gif_dsc));
|
||
g_gif_dsc.header.cf = LV_IMG_CF_RAW;
|
||
g_gif_dsc.data_size = sz;
|
||
g_gif_dsc.data = g_gif_data;
|
||
|
||
ESP_LOGI(TAG, "GIF 已加载到 PSRAM: %s (%.1f KB)", path, sz / 1024.0);
|
||
return ESP_OK;
|
||
}
|
||
|
||
// 加载并解码背景 JPG 到 RGB565 buffer
|
||
static esp_err_t load_bg_jpg(const char *path) {
|
||
esp_jpeg_image_output_t outdata;
|
||
esp_err_t ret = DecodeImg((char *)path, &g_bg_data, &outdata);
|
||
if (ret != ESP_OK || !g_bg_data) {
|
||
ESP_LOGE(TAG, "背景图解码失败: %s, err=%s", path, esp_err_to_name(ret));
|
||
return ESP_FAIL;
|
||
}
|
||
|
||
// 构造 lv_img_dsc_t(TRUE_COLOR,已解码 RGB565)
|
||
memset(&g_bg_dsc, 0, sizeof(g_bg_dsc));
|
||
g_bg_dsc.header.cf = LV_IMG_CF_TRUE_COLOR;
|
||
g_bg_dsc.header.w = outdata.width;
|
||
g_bg_dsc.header.h = outdata.height;
|
||
g_bg_dsc.data_size = outdata.width * outdata.height * 2;
|
||
g_bg_dsc.data = g_bg_data;
|
||
|
||
ESP_LOGI(TAG, "背景图已解码: %dx%d (%.1f KB RGB565)",
|
||
outdata.width, outdata.height, g_bg_dsc.data_size / 1024.0);
|
||
return ESP_OK;
|
||
}
|
||
|
||
esp_err_t bg_gif_demo_start(const char *bg_jpg_path, const char *gif_path) {
|
||
if (g_running) {
|
||
ESP_LOGW(TAG, "已在运行,先 stop");
|
||
return ESP_ERR_INVALID_STATE;
|
||
}
|
||
|
||
if (ensure_spiffs_mounted() != ESP_OK) {
|
||
ESP_LOGE(TAG, "SPIFFS 挂载失败");
|
||
return ESP_FAIL;
|
||
}
|
||
|
||
// 1. 加载背景图(一次性,常驻)
|
||
if (load_bg_jpg(bg_jpg_path) != ESP_OK) {
|
||
return ESP_FAIL;
|
||
}
|
||
|
||
// 2. 加载 GIF
|
||
if (load_gif_to_psram(gif_path) != ESP_OK) {
|
||
free(g_bg_data);
|
||
g_bg_data = NULL;
|
||
return ESP_FAIL;
|
||
}
|
||
|
||
// 3. 在 LVGL 任务中创建图层
|
||
if (!lvgl_port_lock(200)) {
|
||
ESP_LOGE(TAG, "lvgl_port_lock 失败");
|
||
return ESP_FAIL;
|
||
}
|
||
|
||
// 底层:背景图(lv_img)
|
||
g_bg_img = lv_img_create(lv_scr_act());
|
||
lv_img_set_src(g_bg_img, &g_bg_dsc);
|
||
lv_obj_align(g_bg_img, LV_ALIGN_CENTER, 0, 0);
|
||
|
||
// 上层:透明 GIF(lv_gif,自动叠加在 bg_img 之上)
|
||
g_gif_obj = lv_gif_create(lv_scr_act());
|
||
lv_gif_set_src(g_gif_obj, &g_gif_dsc);
|
||
// 240×360 GIF 在 360×360 屏幕中居中显示
|
||
lv_obj_align(g_gif_obj, LV_ALIGN_CENTER, 0, 0);
|
||
|
||
lvgl_port_unlock();
|
||
|
||
g_running = true;
|
||
ESP_LOGI(TAG, "✓ 背景 + GIF 叠加显示启动");
|
||
return ESP_OK;
|
||
}
|
||
|
||
void bg_gif_demo_stop(void) {
|
||
if (!g_running) return;
|
||
g_running = false;
|
||
|
||
if (lvgl_port_lock(200)) {
|
||
if (g_gif_obj) {
|
||
lv_obj_del(g_gif_obj);
|
||
g_gif_obj = NULL;
|
||
}
|
||
if (g_bg_img) {
|
||
lv_obj_del(g_bg_img);
|
||
g_bg_img = NULL;
|
||
}
|
||
lvgl_port_unlock();
|
||
}
|
||
|
||
if (g_bg_data) {
|
||
free(g_bg_data);
|
||
g_bg_data = NULL;
|
||
}
|
||
if (g_gif_data) {
|
||
heap_caps_free(g_gif_data);
|
||
g_gif_data = NULL;
|
||
}
|
||
ESP_LOGI(TAG, "已停止并释放资源");
|
||
}
|
||
|
||
bool bg_gif_demo_is_running(void) {
|
||
return g_running;
|
||
}
|
||
|
||
esp_err_t bg_gif_demo_switch_gif(const char *new_gif_path) {
|
||
if (!g_running) {
|
||
ESP_LOGW(TAG, "bg_gif_demo 未启动,无法切换");
|
||
return ESP_ERR_INVALID_STATE;
|
||
}
|
||
if (!new_gif_path) {
|
||
return ESP_FAIL;
|
||
}
|
||
|
||
// 去重:同路径重复调用无副作用
|
||
static char last_gif_path[64] = {0};
|
||
if (strcmp(new_gif_path, last_gif_path) == 0) {
|
||
return ESP_OK;
|
||
}
|
||
|
||
if (!lvgl_port_lock(200)) {
|
||
ESP_LOGE(TAG, "switch_gif: lvgl_port_lock 失败");
|
||
return ESP_FAIL;
|
||
}
|
||
|
||
// 1. 先释放旧 GIF PSRAM(确保切换期间峰值只占用一份)
|
||
if (g_gif_data) {
|
||
heap_caps_free(g_gif_data);
|
||
g_gif_data = NULL;
|
||
}
|
||
|
||
// 2. 加载新 GIF(复用 load_gif_to_psram 逻辑)
|
||
if (load_gif_to_psram(new_gif_path) != ESP_OK) {
|
||
lvgl_port_unlock();
|
||
ESP_LOGE(TAG, "switch_gif: 加载失败 %s", new_gif_path);
|
||
return ESP_FAIL;
|
||
}
|
||
|
||
// 3. 更新 LVGL(lv_gif_set_src 内部会重建解码器 + 重启定时器)
|
||
lv_gif_set_src(g_gif_obj, &g_gif_dsc);
|
||
|
||
// 4. set_src 内部会重建 10ms 定时器,重设为 20ms 降低 CPU 占用
|
||
// (CLAUDE.md "lv_gif_set_src 会重建定时器" 经验)
|
||
lv_gif_t *gifobj = (lv_gif_t *)g_gif_obj;
|
||
if (gifobj->timer) {
|
||
lv_timer_set_period(gifobj->timer, 20);
|
||
}
|
||
|
||
lvgl_port_unlock();
|
||
|
||
strncpy(last_gif_path, new_gif_path, sizeof(last_gif_path) - 1);
|
||
last_gif_path[sizeof(last_gif_path) - 1] = '\0';
|
||
ESP_LOGI(TAG, "✓ 切换 GIF: %s", new_gif_path);
|
||
return ESP_OK;
|
||
}
|