源代码变更: - 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
177 lines
4.9 KiB
C
177 lines
4.9 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;
|
||
}
|