按 GSD 框架 .planning/milestones/digital_human_rtc/phases/phase_03_gif_resources/ 规划完成 Phase 3 数字人表情 GIF 资源处理。 ## 处理方式(与 PoC 阶段 hiyori_m05.gif 一致) ```bash gifsicle --resize _x360 -O3 input.gif -o output.gif ``` - 高度 = LCD 360px,宽度按原比例自动算 → 209px - 不裁剪(保持源 GIF 完整人物) - 不加 --lossy / --colors(保留 256 色,画质优先) - 只用 -O3 优化文件大小 ## 处理结果 | GIF | 用途 | 源 | 处理后 | 节省 | |-----|------|-----|--------|------| | m03 | 负面/严肃 | 407×700 3.3MB | 209×360 1.15MB | 66% | | m06 | 默认/积极 | 407×700 1.3MB | 209×360 0.44MB | 66% | | m07 | 思考/疲倦 | 407×700 1.2MB | 209×360 0.40MB | 66% | | 合计 | — | 5.7MB | 1.94MB | 66% | ## 决策过程(避免后续重复犯错) Phase 3 初稿曾尝试裁剪到 240×320 + PIL 全帧 bbox 居中裁剪, 用户烧录后反馈"视觉感官不好"——角色被横向压扁(240×320 纵横比 0.75 vs 源 407×700 纵横比 0.583)。回归 PoC 等比例缩放方式后效果与 PoC 一致。 PoC 处理标准已写入用户级 feedback memory(feedback_hiyori_gif_processing.md), 后续 hiyori GIF 处理一律用本方式,除非用户主动要求修改。 ## 显示效果(用户已目视确认) LCD 360×360 居中显示 209×360 GIF: - 垂直方向: 360 = 360,完全充满 - 横向: 209 < 360,左右各 75.5px 留边显示背景图 - 角色比例: 完整保留源 GIF 的 407:700 = 0.582 纵横比,人物细高自然 ## 删除项 - spiffs_image/hiyori_m05.gif (2.3MB) 已删除 - 被 m06/m07/m03 替代 文件历史保留在 git,可通过 git show eb96130:spiffs_image/hiyori_m05.gif 恢复 ## 默认表情切换 main/dzbj/ai_chat_ui.c:234: - PoC: bg_gif_demo_start(..., "/spiflash/hiyori_m05.gif") - Phase 3: bg_gif_demo_start(..., "/spiflash/hiyori_m06.gif") ## 烧录运行时验证 - 烧录后 0 次重启(连续监控 18 秒) - BG_GIF: GIF 已加载到 PSRAM: /spiflash/hiyori_m06.gif (441.8 KB) - AudioCodec: Audio codec started(首次冷启动直接成功) - 用户目视确认显示效果良好 ## GSD 文档(同时提交) - .planning/milestones/digital_human_rtc/phases/phase_03_gif_resources/PLAN.md - .planning/milestones/digital_human_rtc/phases/phase_03_gif_resources/GIF_REPORT.md ## SPIFFS 容量 新 SPIFFS 4.94MB 当前实际占用 ~2MB(40%),余量 ~2.94MB 充足。
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_m06.gif"); // Phase 3: m06 默认表情(neutral/积极),240x320 居中
|
||
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;
|
||
}
|