Baji_Rtc_Toy/main/dzbj/dual_gif_demo.c
Rdzleo eb96130fc9 feat(Rtc_AIavatar): 数字人透明 GIF 显示方案 PoC 完成(背景图+透明GIF叠加)
源代码变更:
- 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
2026-05-12 17:14:49 +08:00

174 lines
5.4 KiB
C
Raw Permalink 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.

/**
* dual_gif_demo.c — 两个 GIF 循环交替播放实现
*
* 实现策略:
* 1. 启动时加载两个 GIF 整个文件到 PSRAM
* 2. 构造两个 lv_img_dsc_tCF_RAWdata 指向 PSRAM
* 3. 创建一个 lv_gif 控件set_src(gif1)
* 4. 用 lv_timer 在 GIF1 时长到达后切到 GIF2再切回 GIF1循环
* 5. 控件只创建一次,背景不重绘
*/
#include "dual_gif_demo.h"
#include "esp_log.h"
#include "esp_heap_caps.h"
#include "esp_lvgl_port.h"
#include "esp_spiffs.h"
#include "lvgl.h"
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
static const char *TAG = "DUAL_GIF";
// 运行时状态
static uint8_t *g_gif_data[2] = {NULL, NULL}; // PSRAM 中的 GIF 二进制
static size_t g_gif_size[2] = {0, 0};
static uint32_t g_gif_duration[2] = {0, 0}; // 每个 GIF 完整时长(ms)
static lv_img_dsc_t g_gif_dsc[2]; // LVGL 图像描述符
static lv_obj_t *g_gif_obj = NULL; // 单一 lv_gif 控件
static lv_timer_t *g_switch_timer = NULL;
static int g_current = 0;
static bool g_playing = false;
// 切换定时器:到达时长后切到另一个 GIF
static void switch_timer_cb(lv_timer_t *timer) {
if (!g_playing || !g_gif_obj) return;
g_current = 1 - g_current;
lv_gif_set_src(g_gif_obj, &g_gif_dsc[g_current]);
// 下次切换时间 = 当前 GIF 的完整时长
lv_timer_set_period(timer, g_gif_duration[g_current]);
}
// 加载单个 GIF 文件到 PSRAM
static esp_err_t load_gif_file(const char *path, uint8_t **out_data, size_t *out_size) {
FILE *f = fopen(path, "rb");
if (!f) {
ESP_LOGE(TAG, "打开失败: %s", path);
return ESP_FAIL;
}
fseek(f, 0, SEEK_END);
size_t sz = ftell(f);
fseek(f, 0, SEEK_SET);
uint8_t *buf = heap_caps_malloc(sz, MALLOC_CAP_SPIRAM);
if (!buf) {
ESP_LOGE(TAG, "PSRAM 分配失败: %d bytes", (int)sz);
fclose(f);
return ESP_ERR_NO_MEM;
}
size_t r = fread(buf, 1, sz, f);
fclose(f);
if (r != sz) {
heap_caps_free(buf);
return ESP_FAIL;
}
*out_data = buf;
*out_size = sz;
ESP_LOGI(TAG, "已加载 %s (%.1f KB)", path, sz / 1024.0);
return ESP_OK;
}
esp_err_t dual_gif_demo_start(const char *path1, uint32_t dur1_ms,
const char *path2, uint32_t dur2_ms) {
if (g_playing) {
ESP_LOGW(TAG, "已在播放,先 stop");
return ESP_ERR_INVALID_STATE;
}
// 检查 SPIFFS 是否已挂载,未挂载则自动挂载
size_t total = 0, used = 0;
if (esp_spiffs_info("storage", &total, &used) != 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,
};
esp_err_t err = esp_vfs_spiffs_register(&conf);
if (err != ESP_OK) {
ESP_LOGE(TAG, "SPIFFS 挂载失败: %s", esp_err_to_name(err));
return err;
}
}
// 加载两个 GIF 到 PSRAM
if (load_gif_file(path1, &g_gif_data[0], &g_gif_size[0]) != ESP_OK) {
return ESP_FAIL;
}
if (load_gif_file(path2, &g_gif_data[1], &g_gif_size[1]) != ESP_OK) {
heap_caps_free(g_gif_data[0]);
g_gif_data[0] = NULL;
return ESP_FAIL;
}
g_gif_duration[0] = dur1_ms;
g_gif_duration[1] = dur2_ms;
// 构造 lv_img_dsc_tCF_RAWLVGL 内部会用 gifdec 解码)
for (int i = 0; i < 2; i++) {
memset(&g_gif_dsc[i], 0, sizeof(lv_img_dsc_t));
g_gif_dsc[i].header.cf = LV_IMG_CF_RAW;
g_gif_dsc[i].header.always_zero = 0;
g_gif_dsc[i].header.reserved = 0;
g_gif_dsc[i].header.w = 0; // CF_RAW 时由 gifdec 解析
g_gif_dsc[i].header.h = 0;
g_gif_dsc[i].data_size = g_gif_size[i];
g_gif_dsc[i].data = g_gif_data[i];
}
// 在 LVGL 任务中创建 lv_gif 控件 + 启动切换定时器
if (!lvgl_port_lock(200)) {
ESP_LOGE(TAG, "lvgl_port_lock 失败");
heap_caps_free(g_gif_data[0]);
heap_caps_free(g_gif_data[1]);
g_gif_data[0] = g_gif_data[1] = NULL;
return ESP_FAIL;
}
g_gif_obj = lv_gif_create(lv_scr_act());
lv_gif_set_src(g_gif_obj, &g_gif_dsc[0]);
lv_obj_align(g_gif_obj, LV_ALIGN_CENTER, 0, 0);
g_current = 0;
g_playing = true;
// 第一次切换在 GIF1 时长结束后触发
g_switch_timer = lv_timer_create(switch_timer_cb, dur1_ms, NULL);
lvgl_port_unlock();
ESP_LOGI(TAG, "✓ 开始循环播放: GIF1(%lums) ↔ GIF2(%lums)",
(unsigned long)dur1_ms, (unsigned long)dur2_ms);
return ESP_OK;
}
void dual_gif_demo_stop(void) {
if (!g_playing) return;
g_playing = false;
if (lvgl_port_lock(200)) {
if (g_switch_timer) {
lv_timer_del(g_switch_timer);
g_switch_timer = NULL;
}
if (g_gif_obj) {
lv_obj_del(g_gif_obj);
g_gif_obj = NULL;
}
lvgl_port_unlock();
}
for (int i = 0; i < 2; i++) {
if (g_gif_data[i]) {
heap_caps_free(g_gif_data[i]);
g_gif_data[i] = NULL;
}
}
ESP_LOGI(TAG, "停止播放并释放 PSRAM");
}
bool dual_gif_demo_is_playing(void) {
return g_playing;
}