Baji_Rtc_Toy/main/dzbj/bg_gif_demo.c
Rdzleo 497c1b4654 feat(rtc-only): Phase 4 - 情绪标签 → 数字人 hiyori GIF 映射 + 切换接口
按 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
2026-05-13 11:59:38 +08:00

228 lines
6.4 KiB
C
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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_tCF_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_tTRUE_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);
// 上层:透明 GIFlv_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. 更新 LVGLlv_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;
}