源代码变更: - main/dzbj/bg_gif_demo.c/h: 方案 C 最终实现 - JPG 背景图(lv_img) + 透明 GIF(lv_gif) 叠加 - main/dzbj/dual_gif_demo.c/h: 方案 B 中间产物 - 双 GIF 循环切换 - main/dzbj/sprite_demo.c/h: 方案 A 已弃用 - DMA 直写 GRAM 与 LVGL 争抢 LCD IO 失败 - main/dzbj/ai_chat_ui.c: 集成 USE_BG_GIF_POC 开关,加载背景图+透明 GIF - main/dzbj/lcd.c: panel_handle 移除 static,便于其他模块访问 - main/CMakeLists.txt: 新增 3 个 dzbj 模块编译 资源新增: - spiffs_image/Background_360x360.jpg: 设备背景图(20KB) - spiffs_image/hiyori_m05.gif: Cubism Editor 直接导出的透明 GIF(2.3MB) - docs/Rtc_AIavatar/: Live2D 模型(Hiyori/Haru) + 32 段 Haru GIF + 方案文档第18章 PoC 实战记录 - tools/sprite_poc/: Python GIF→RGB565 转换脚本 踩坑要点(详见 docs/Rtc_AIavatar 第18章): - PIL Image.quantize() 会破坏 RGBA 透明度,必须改用 gifsicle - PIL 保存动画 GIF 仅第1帧有透明,后续帧不透明 - LVGL gifdec 按帧读取 - Cubism Editor 直接导出 GIF 才能逐帧保留透明信息(FREE 版限制部分模型) - gifsicle --lossy 会严重锯齿化,去掉只保留 --colors 256 + -O3 即可 - 裁剪居中需用全帧 bbox 不能只看第1帧(Live2D 角色每帧位置有偏移) - LVGL 默认不支持 PNG,背景图用 JPG + esp_jpeg 解码到 RGB565 buffer - 透明 GIF 显示黑色背景: gifdec.c canvas 初始化 alpha 须改为 0x00
347 lines
12 KiB
C
347 lines
12 KiB
C
#include "ai_chat_ui.h"
|
||
#include "lvgl.h"
|
||
#include "esp_lvgl_port.h"
|
||
#include "esp_log.h"
|
||
#include <string.h>
|
||
|
||
// ====================================================================
|
||
// PoC 开关(验证不同显示方案)
|
||
// USE_SPRITE_POC — sprite_test.bin(RGB565 raw)方案(已弃用)
|
||
// USE_DUAL_GIF_POC — 两个 GIF 循环播放(已弃用)
|
||
// USE_BG_GIF_POC — 背景图 + 透明 GIF 叠加(方案 C,当前启用 ⭐)
|
||
// 都注释:恢复原 GIF 眨眼显示
|
||
// ====================================================================
|
||
// #define USE_SPRITE_POC
|
||
// #define USE_DUAL_GIF_POC
|
||
#define USE_BG_GIF_POC
|
||
|
||
#ifdef USE_SPRITE_POC
|
||
#include "sprite_demo.h"
|
||
#endif
|
||
#ifdef USE_DUAL_GIF_POC
|
||
#include "dual_gif_demo.h"
|
||
#endif
|
||
#ifdef USE_BG_GIF_POC
|
||
#include "bg_gif_demo.h"
|
||
#endif
|
||
|
||
// lv_gif API 通过 lvgl.h 自动包含(LV_USE_GIF=y 时)
|
||
|
||
static const char *TAG = "AI_CHAT_UI";
|
||
|
||
// 声明阿里巴巴普惠体 20px 4bpp(GB2312 简体中文+ASCII)
|
||
LV_FONT_DECLARE(font_puhui_20_4);
|
||
|
||
#if LV_USE_GIF
|
||
// 需要访问 lv_gif_t 内部结构(暂停/恢复定时器)
|
||
#include "extra/libs/gif/lv_gif.h"
|
||
|
||
// 声明 GIF 表情资源(压缩优化,200x89)
|
||
LV_IMG_DECLARE(emotion_angry_200_89);
|
||
LV_IMG_DECLARE(emotion_blink1_200_89);
|
||
LV_IMG_DECLARE(emotion_blink_fast_200_89);
|
||
LV_IMG_DECLARE(emotion_blink_slow_200_89);
|
||
LV_IMG_DECLARE(emotion_dizzy_200_89);
|
||
LV_IMG_DECLARE(emotion_happy_200_89);
|
||
LV_IMG_DECLARE(emotion_sad_200_89);
|
||
LV_IMG_DECLARE(emotion_sleep_200_89);
|
||
|
||
// 声明 GIF 图标资源(压缩优化,45x45)
|
||
LV_IMG_DECLARE(icon_emotion_confused_45);
|
||
LV_IMG_DECLARE(icon_emotion_sleep_45);
|
||
LV_IMG_DECLARE(icon_emotion_thinking_45);
|
||
#endif
|
||
|
||
// 声明旧 PNG 表情资源(LV_USE_GIF 未启用时的回退)
|
||
LV_IMG_DECLARE(ui_img_neutral_png);
|
||
LV_IMG_DECLARE(ui_img_happy_png);
|
||
LV_IMG_DECLARE(ui_img_sad_png);
|
||
LV_IMG_DECLARE(ui_img_angry_png);
|
||
LV_IMG_DECLARE(ui_img_crying_png);
|
||
LV_IMG_DECLARE(ui_img_funny_png);
|
||
LV_IMG_DECLARE(ui_img_laughing_png);
|
||
|
||
// 屏幕和控件
|
||
static lv_obj_t *ai_screen = NULL;
|
||
static lv_obj_t *status_label = NULL;
|
||
static lv_obj_t *chat_label = NULL;
|
||
|
||
#if LV_USE_GIF
|
||
static lv_obj_t *gif_emotion = NULL; // GIF 表情对象
|
||
static lv_obj_t *gif_icon = NULL; // GIF 图标对象(叠加在表情右上角)
|
||
static bool gif_animation_paused = false;
|
||
#else
|
||
static lv_obj_t *emoji_img = NULL; // 旧 PNG 表情
|
||
#endif
|
||
|
||
// 背景色(纯黑,与 GIF 背景一致避免色差)
|
||
#define BG_COLOR 0x000000
|
||
|
||
// 表情→GIF 映射表
|
||
typedef struct {
|
||
const char *name;
|
||
const lv_img_dsc_t *emotion_gif;
|
||
const lv_img_dsc_t *icon_gif; // NULL 表示无叠加图标
|
||
} emotion_gif_entry_t;
|
||
|
||
#if LV_USE_GIF
|
||
static const emotion_gif_entry_t emotion_gif_map[] = {
|
||
{"neutral", &emotion_blink_slow_200_89, NULL},
|
||
{"happy", &emotion_happy_200_89, NULL},
|
||
{"laughing", &emotion_happy_200_89, NULL},
|
||
{"funny", &emotion_happy_200_89, NULL},
|
||
{"loving", &emotion_happy_200_89, NULL},
|
||
{"relaxed", &emotion_happy_200_89, NULL},
|
||
{"delicious", &emotion_happy_200_89, NULL},
|
||
{"kissy", &emotion_happy_200_89, NULL},
|
||
{"confident", &emotion_happy_200_89, NULL},
|
||
{"sad", &emotion_sad_200_89, NULL},
|
||
{"crying", &emotion_sad_200_89, NULL},
|
||
{"angry", &emotion_angry_200_89, NULL},
|
||
{"surprised", &emotion_blink_fast_200_89, NULL},
|
||
{"shocked", &emotion_blink_fast_200_89, NULL},
|
||
{"silly", &emotion_blink_fast_200_89, NULL},
|
||
{"embarrassed", &emotion_blink_fast_200_89, &icon_emotion_thinking_45},
|
||
{"thinking", &emotion_blink_fast_200_89, &icon_emotion_thinking_45},
|
||
{"confused", &emotion_blink_fast_200_89, &icon_emotion_confused_45},
|
||
{"curious", &emotion_blink_fast_200_89, &icon_emotion_confused_45},
|
||
{"dizzy", &emotion_dizzy_200_89, NULL},
|
||
{"blink", &emotion_blink1_200_89, NULL},
|
||
{"sleepy", &emotion_sleep_200_89, &icon_emotion_sleep_45},
|
||
};
|
||
#define EMOTION_GIF_MAP_SIZE (sizeof(emotion_gif_map) / sizeof(emotion_gif_map[0]))
|
||
#endif
|
||
|
||
void ai_chat_screen_init(void) {
|
||
// 创建 AI 对话屏幕
|
||
ai_screen = lv_obj_create(NULL);
|
||
lv_obj_set_style_bg_color(ai_screen, lv_color_hex(BG_COLOR), 0);
|
||
lv_obj_set_style_bg_opa(ai_screen, 255, 0);
|
||
lv_obj_clear_flag(ai_screen, LV_OBJ_FLAG_SCROLLABLE);
|
||
|
||
#if LV_USE_GIF
|
||
// GIF 表情(屏幕正中央,200x89)
|
||
gif_emotion = lv_gif_create(ai_screen);
|
||
lv_gif_set_src(gif_emotion, &emotion_blink_slow_200_89);
|
||
lv_obj_align(gif_emotion, LV_ALIGN_CENTER, 0, 0);
|
||
|
||
// 降低 GIF 定时器频率(10ms→20ms),平衡动画流畅度与 CPU 占用
|
||
lv_gif_t *gifobj = (lv_gif_t *)gif_emotion;
|
||
lv_timer_set_period(gifobj->timer, 20);
|
||
|
||
// GIF 图标(表情上方居中,45x45)
|
||
// 表情高89,顶边y=-44.5,icon高45,中心再上移几像素避免重叠
|
||
gif_icon = lv_gif_create(ai_screen);
|
||
lv_obj_align(gif_icon, LV_ALIGN_CENTER, 0, -70);
|
||
lv_obj_add_flag(gif_icon, LV_OBJ_FLAG_HIDDEN);
|
||
#else
|
||
// 旧 PNG 表情(回退方案)
|
||
emoji_img = lv_img_create(ai_screen);
|
||
lv_img_set_src(emoji_img, &ui_img_neutral_png);
|
||
lv_obj_align(emoji_img, LV_ALIGN_CENTER, 0, 0);
|
||
#endif
|
||
|
||
// 状态文本(暂时隐藏,GIF 模式下不需要文字)
|
||
status_label = lv_label_create(ai_screen);
|
||
lv_obj_set_style_text_font(status_label, &font_puhui_20_4, 0);
|
||
lv_obj_set_style_text_color(status_label, lv_color_white(), 0);
|
||
lv_obj_set_width(status_label, 300);
|
||
lv_obj_set_style_text_align(status_label, LV_TEXT_ALIGN_CENTER, 0);
|
||
lv_label_set_text(status_label, "");
|
||
lv_obj_align(status_label, LV_ALIGN_CENTER, 0, 90);
|
||
#if LV_USE_GIF
|
||
lv_obj_add_flag(status_label, LV_OBJ_FLAG_HIDDEN);
|
||
#endif
|
||
|
||
// 聊天消息文本(暂时隐藏,不显示字幕)
|
||
chat_label = lv_label_create(ai_screen);
|
||
lv_obj_set_style_text_font(chat_label, &font_puhui_20_4, 0);
|
||
lv_obj_set_style_text_color(chat_label, lv_color_hex(0xAAAAAA), 0);
|
||
lv_obj_set_width(chat_label, 300);
|
||
lv_obj_set_style_text_align(chat_label, LV_TEXT_ALIGN_LEFT, 0);
|
||
lv_label_set_text(chat_label, "");
|
||
lv_obj_align(chat_label, LV_ALIGN_CENTER, 0, 50);
|
||
lv_label_set_long_mode(chat_label, LV_LABEL_LONG_WRAP);
|
||
lv_obj_add_flag(chat_label, LV_OBJ_FLAG_HIDDEN);
|
||
|
||
// 加载屏幕
|
||
lv_disp_load_scr(ai_screen);
|
||
|
||
#ifdef USE_SPRITE_POC
|
||
// === Sprite Sheet PoC:用 RGB565 raw 替代 GIF 显示 ===
|
||
// 隐藏原有 GIF 表情对象(避免 LVGL 与 DMA 同时刷新同一区域)
|
||
#if LV_USE_GIF
|
||
if (gif_emotion) {
|
||
lv_obj_add_flag(gif_emotion, LV_OBJ_FLAG_HIDDEN);
|
||
}
|
||
if (gif_icon) {
|
||
lv_obj_add_flag(gif_icon, LV_OBJ_FLAG_HIDDEN);
|
||
}
|
||
#endif
|
||
// 启动 sprite sheet 播放(从 SPIFFS 加载到 PSRAM,DMA 直接写 LCD)
|
||
esp_err_t ret = sprite_demo_start("/spiflash/sprite_test.bin");
|
||
if (ret == ESP_OK) {
|
||
ESP_LOGI(TAG, "Sprite PoC 启动成功");
|
||
} else {
|
||
ESP_LOGW(TAG, "Sprite PoC 启动失败: %s,回退到 LVGL", esp_err_to_name(ret));
|
||
// 失败时显示原 GIF
|
||
#if LV_USE_GIF
|
||
if (gif_emotion) lv_obj_clear_flag(gif_emotion, LV_OBJ_FLAG_HIDDEN);
|
||
#endif
|
||
}
|
||
#endif // USE_SPRITE_POC
|
||
|
||
#ifdef USE_DUAL_GIF_POC
|
||
// === 双 GIF 循环播放 PoC ===
|
||
// 隐藏原有 GIF 表情控件,由 dual_gif_demo 创建新的 lv_gif 控件接管
|
||
#if LV_USE_GIF
|
||
if (gif_emotion) {
|
||
lv_obj_add_flag(gif_emotion, LV_OBJ_FLAG_HIDDEN);
|
||
}
|
||
if (gif_icon) {
|
||
lv_obj_add_flag(gif_icon, LV_OBJ_FLAG_HIDDEN);
|
||
}
|
||
#endif
|
||
// GIF 时长(由 Python 脚本预先测得):
|
||
// dance_0_gesture.gif: 58 帧 = 2900 ms
|
||
// tapbody_0_greet.gif: 100 帧 = 5000 ms
|
||
esp_err_t dgret = dual_gif_demo_start(
|
||
"/spiflash/dance_0_gesture.gif", 2900,
|
||
"/spiflash/tapbody_0_greet.gif", 5000);
|
||
if (dgret == ESP_OK) {
|
||
ESP_LOGI(TAG, "Dual GIF PoC 启动成功");
|
||
} else {
|
||
ESP_LOGW(TAG, "Dual GIF PoC 启动失败: %s", esp_err_to_name(dgret));
|
||
#if LV_USE_GIF
|
||
if (gif_emotion) lv_obj_clear_flag(gif_emotion, LV_OBJ_FLAG_HIDDEN);
|
||
#endif
|
||
}
|
||
#endif // USE_DUAL_GIF_POC
|
||
|
||
#ifdef USE_BG_GIF_POC
|
||
// === 背景图 + 透明 Hiyori GIF 叠加(方案 C)===
|
||
// 隐藏原有 GIF 表情控件,由 bg_gif_demo 创建新的图层
|
||
#if LV_USE_GIF
|
||
if (gif_emotion) {
|
||
lv_obj_add_flag(gif_emotion, LV_OBJ_FLAG_HIDDEN);
|
||
}
|
||
if (gif_icon) {
|
||
lv_obj_add_flag(gif_icon, LV_OBJ_FLAG_HIDDEN);
|
||
}
|
||
#endif
|
||
esp_err_t bgret = bg_gif_demo_start(
|
||
"/spiflash/Background_360x360.jpg",
|
||
"/spiflash/hiyori_m05.gif");
|
||
if (bgret == ESP_OK) {
|
||
ESP_LOGI(TAG, "BG+GIF PoC 启动成功");
|
||
} else {
|
||
ESP_LOGW(TAG, "BG+GIF PoC 启动失败: %s", esp_err_to_name(bgret));
|
||
#if LV_USE_GIF
|
||
if (gif_emotion) lv_obj_clear_flag(gif_emotion, LV_OBJ_FLAG_HIDDEN);
|
||
#endif
|
||
}
|
||
#endif // USE_BG_GIF_POC
|
||
}
|
||
|
||
void ai_chat_set_status(const char* status) {
|
||
if (!status_label) return;
|
||
#if LV_USE_GIF
|
||
// GIF 模式下隐藏状态文字,仅记录日志
|
||
ESP_LOGD(TAG, "状态: %s(GIF模式不显示)", status);
|
||
(void)status;
|
||
#else
|
||
lvgl_port_lock(50);
|
||
lv_label_set_text(status_label, status);
|
||
lvgl_port_unlock();
|
||
#endif
|
||
}
|
||
|
||
void ai_chat_resume_animation(void) {
|
||
#if LV_USE_GIF
|
||
if (!gif_emotion || !gif_animation_paused) return;
|
||
if (!lvgl_port_lock(200)) return;
|
||
lv_gif_t *gifobj = (lv_gif_t *)gif_emotion;
|
||
lv_timer_resume(gifobj->timer);
|
||
gif_animation_paused = false;
|
||
ESP_LOGI(TAG, "GIF动画已恢复播放");
|
||
lvgl_port_unlock();
|
||
#endif
|
||
}
|
||
|
||
void ai_chat_set_emotion(const char* emotion) {
|
||
if (!emotion) return;
|
||
|
||
#if LV_USE_GIF
|
||
if (!gif_emotion) return;
|
||
if (!lvgl_port_lock(200)) {
|
||
ESP_LOGW(TAG, "LVGL锁超时,跳过表情切换: %s", emotion);
|
||
return;
|
||
}
|
||
|
||
// 查找映射表
|
||
const emotion_gif_entry_t *entry = NULL;
|
||
for (int i = 0; i < EMOTION_GIF_MAP_SIZE; i++) {
|
||
if (strcmp(emotion, emotion_gif_map[i].name) == 0) {
|
||
entry = &emotion_gif_map[i];
|
||
break;
|
||
}
|
||
}
|
||
|
||
// 未找到映射,默认使用 neutral
|
||
if (!entry) {
|
||
ESP_LOGW(TAG, "未映射的GIF表情: %s, 使用 neutral", emotion);
|
||
entry = &emotion_gif_map[0]; // neutral
|
||
}
|
||
|
||
// 切换表情 GIF(lv_gif_set_src 内部已自动启动播放)
|
||
lv_gif_set_src(gif_emotion, entry->emotion_gif);
|
||
// set_src 内部会重建 10ms 定时器,重新设置为 50ms 降低 CPU 占用
|
||
lv_gif_t *gifobj = (lv_gif_t *)gif_emotion;
|
||
lv_timer_set_period(gifobj->timer, 20);
|
||
gif_animation_paused = false;
|
||
|
||
// 处理叠加图标
|
||
if (entry->icon_gif) {
|
||
lv_gif_set_src(gif_icon, entry->icon_gif);
|
||
lv_gif_t *icon_gifobj = (lv_gif_t *)gif_icon;
|
||
lv_timer_set_period(icon_gifobj->timer, 20);
|
||
lv_obj_clear_flag(gif_icon, LV_OBJ_FLAG_HIDDEN);
|
||
} else {
|
||
// 隐藏图标时暂停其定时器,避免空跑浪费 CPU
|
||
lv_gif_t *icon_gifobj = (lv_gif_t *)gif_icon;
|
||
if (icon_gifobj->gif) {
|
||
lv_timer_pause(icon_gifobj->timer);
|
||
}
|
||
lv_obj_add_flag(gif_icon, LV_OBJ_FLAG_HIDDEN);
|
||
}
|
||
|
||
ESP_LOGI(TAG, "GIF表情切换: %s%s", emotion, entry->icon_gif ? " +图标" : "");
|
||
lvgl_port_unlock();
|
||
|
||
#else
|
||
// 旧 PNG 回退逻辑
|
||
if (!emoji_img) return;
|
||
lvgl_port_lock(50);
|
||
|
||
const lv_img_dsc_t *img = &ui_img_neutral_png;
|
||
if (strcmp(emotion, "neutral") == 0) img = &ui_img_neutral_png;
|
||
else if (strcmp(emotion, "happy") == 0) img = &ui_img_happy_png;
|
||
else if (strcmp(emotion, "sad") == 0) img = &ui_img_sad_png;
|
||
else if (strcmp(emotion, "angry") == 0) img = &ui_img_angry_png;
|
||
else if (strcmp(emotion, "surprised") == 0) img = &ui_img_funny_png;
|
||
else if (strcmp(emotion, "crying") == 0) img = &ui_img_crying_png;
|
||
else if (strcmp(emotion, "laughing") == 0) img = &ui_img_laughing_png;
|
||
|
||
lv_img_set_src(emoji_img, img);
|
||
lvgl_port_unlock();
|
||
#endif
|
||
}
|
||
|
||
void ai_chat_set_chat_message(const char* role, const char* content) {
|
||
if (!chat_label) return;
|
||
// 字幕暂时隐藏,不更新内容
|
||
// 后续恢复时去掉 return 和 ai_chat_screen_init 中的 LV_OBJ_FLAG_HIDDEN
|
||
(void)role;
|
||
(void)content;
|
||
}
|