源代码变更: - 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
174 lines
5.4 KiB
C
174 lines
5.4 KiB
C
/**
|
||
* dual_gif_demo.c — 两个 GIF 循环交替播放实现
|
||
*
|
||
* 实现策略:
|
||
* 1. 启动时加载两个 GIF 整个文件到 PSRAM
|
||
* 2. 构造两个 lv_img_dsc_t(CF_RAW),data 指向 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_t(CF_RAW,LVGL 内部会用 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;
|
||
}
|