Fix photo stripes and keep GIF playback off the UI thread.
Use cover/stretch display, solid sample assets, core0 GIF decode without blocking touch, and always-commit project rule. Co-authored-by: Cursor <cursoragent@cursor.com>
12
.cursor/rules/commit-after-work.mdc
Normal file
@@ -0,0 +1,12 @@
|
||||
---
|
||||
description: Always create a local git commit after finishing a task
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Git commit after work
|
||||
|
||||
After completing a user-requested change (code, docs, firmware, assets), always:
|
||||
|
||||
1. Update `CHANGELOG.md` when behavior changes
|
||||
2. `git add` relevant files and create a local commit (do not push unless asked)
|
||||
3. Keep the working tree clean before ending the turn
|
||||
14
CHANGELOG.md
@@ -1,5 +1,19 @@
|
||||
# Changelog
|
||||
|
||||
## 2026-10-19 — 去掉图后条纹
|
||||
|
||||
- 样张改为纯色底,去掉装饰横纹。
|
||||
- 画面 cover 铺满画布;LVGL 全屏 STRETCH,避免缩放留缝/条带。
|
||||
|
||||
## 2026-10-19 — GIF 不阻塞触控
|
||||
|
||||
- GIF 解码改到 core0 任务;`playFrame(false)` 去掉库内 `delay()`。
|
||||
- 切换相片先 `photo_view_stop()`,避免点按卡几秒。
|
||||
|
||||
## 2026-10-19 — 每次改完提交
|
||||
|
||||
- 项目规则:完成任务后必须本地 git commit。
|
||||
|
||||
## 2026-10-19 — Photo album
|
||||
|
||||
- CYD LVGL 壳层来自 tftest(触摸映射冻结,TF + SoftSPI)。
|
||||
|
||||
@@ -7,8 +7,8 @@ ESP32-2432S028R 电子相册:从 TF 卡读取 **JPG / PNG / GIF**,触控切
|
||||
## 功能
|
||||
|
||||
- 扫描 TF 卡 `/photos` 与根目录的 `.jpg` / `.jpeg` / `.png` / `.gif`
|
||||
- 半分辨率画布 160×120,界面 2× 放大到 320×240(适配无 PSRAM 的 DRAM)
|
||||
- JPG 过大自动 1/2、1/4、1/8 缩放;PNG / GIF 等比缩入画布
|
||||
- 半分辨率画布 160×120,全屏拉伸到 320×240(适配无 PSRAM 的 DRAM)
|
||||
- JPG / PNG / GIF 按 cover 铺满画布(可裁边)
|
||||
- **点左半屏 = 上一张,点右半屏 = 下一张**;底栏 `<` / `>` 同样切换
|
||||
- GIF 自动播帧;右上角显示 TF 状态
|
||||
- 串口 `#PUT` 传文件到卡(`tools/sd_put.py`)
|
||||
|
||||
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 8.2 KiB After Width: | Height: | Size: 8.2 KiB |
|
Before Width: | Height: | Size: 8.2 KiB After Width: | Height: | Size: 8.1 KiB |
@@ -1,5 +1,5 @@
|
||||
name: CYD Photo album (cyd)
|
||||
git: 99618a4
|
||||
git: 800e168
|
||||
date: 2026-10-19
|
||||
board: ESP32-2432S028R (ILI9341)
|
||||
features: jpg/png/gif album, SoftSPI touch, TF card
|
||||
@@ -12,5 +12,5 @@ offsets:
|
||||
sha256:
|
||||
bootloader.bin 3D234A7471F67B013686DABD4DEE7C1FA915C9928463616A94BC9297ACF1ABF8
|
||||
boot_app0.bin F94C5D786A7A8FAB06AC5D10E33BF37711A6697636DC037559EA19CC410A17F0
|
||||
firmware.bin CB21DB25D8AAC041B39C10AA2E63E00D2401D1A7EA4F956F9B4985D1EA87C8EB
|
||||
firmware.bin 8BAFA46DA3DF4BF3A15D5B67E3062B80E0D8576A2F59BE2101431747E24438AF
|
||||
partitions.bin 148B959CBFF1C38AA8E1D5C0BA9D612C54997B945E56A63F41223EEF650653A1
|
||||
|
||||
@@ -11,6 +11,9 @@ void photo_view_deinit();
|
||||
// Decode entry into half-res RGB565 canvas (160x120, shown 2x).
|
||||
bool photo_view_show(const PhotoEntry *entry);
|
||||
|
||||
// Abort GIF worker immediately so touch/switch stays responsive.
|
||||
void photo_view_stop();
|
||||
|
||||
const lv_image_dsc_t *photo_view_dsc();
|
||||
uint16_t photo_view_canvas_w();
|
||||
uint16_t photo_view_canvas_h();
|
||||
@@ -18,5 +21,5 @@ uint16_t photo_view_img_w();
|
||||
uint16_t photo_view_img_h();
|
||||
|
||||
bool photo_view_is_gif();
|
||||
// Advance GIF frame if due; returns true when pixels changed.
|
||||
// Publish a ready GIF frame to the visible canvas (non-blocking).
|
||||
bool photo_view_gif_tick();
|
||||
|
||||
11
src/main.cpp
@@ -72,14 +72,15 @@ void setup() {
|
||||
}
|
||||
|
||||
void loop() {
|
||||
if (sd_put_poll()) {
|
||||
refresh_sd_ui(true);
|
||||
}
|
||||
|
||||
// Touch/UI first — GIF decode runs on core0 and must not starve this.
|
||||
lv_timer_handler();
|
||||
lv_tick_inc(5);
|
||||
ui_tick();
|
||||
|
||||
if (sd_put_poll()) {
|
||||
refresh_sd_ui(true);
|
||||
}
|
||||
|
||||
const uint32_t now = millis();
|
||||
if (now - last_refresh_ms >= REFRESH_MS) {
|
||||
last_refresh_ms = now;
|
||||
@@ -87,5 +88,5 @@ void loop() {
|
||||
refresh_sd_ui(false);
|
||||
}
|
||||
|
||||
delay(5);
|
||||
delay(photo_view_is_gif() ? 2 : 5);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
#include <SD.h>
|
||||
#include <string.h>
|
||||
|
||||
#include <freertos/FreeRTOS.h>
|
||||
#include <freertos/task.h>
|
||||
|
||||
// Half-res canvas (fits ESP32 DRAM). UI shows it at 2x -> 320x240.
|
||||
static constexpr uint16_t CANVAS_W = UI_HOR_RES / 2;
|
||||
static constexpr uint16_t CANVAS_H = UI_VER_RES / 2;
|
||||
@@ -24,8 +27,6 @@ static PNG *g_png = nullptr;
|
||||
static AnimatedGIF *g_gif = nullptr;
|
||||
|
||||
static File g_file;
|
||||
static bool g_gif_open = false;
|
||||
static uint32_t g_gif_next_ms = 0;
|
||||
static int g_off_x = 0;
|
||||
static int g_off_y = 0;
|
||||
static int g_src_w = 0;
|
||||
@@ -33,12 +34,24 @@ static int g_src_h = 0;
|
||||
static int g_dst_w = 0;
|
||||
static int g_dst_h = 0;
|
||||
|
||||
static void canvas_clear(uint16_t color) {
|
||||
// GIF worker (core 0): decode must not block LVGL/touch on core 1.
|
||||
static TaskHandle_t g_gif_task = nullptr;
|
||||
static uint16_t *g_back = nullptr; // decode target (heap)
|
||||
static uint16_t *g_draw_dst = g_pixels; // where gif_draw writes
|
||||
static char g_gif_path[PHOTO_PATH_MAX];
|
||||
static volatile bool g_gif_active = false; // session open (UI-visible)
|
||||
static volatile bool g_gif_busy = false; // worker running
|
||||
static volatile bool g_gif_stop = false; // request abort
|
||||
static volatile bool g_frame_ready = false; // back buffer has a new frame
|
||||
|
||||
static void canvas_clear_buf(uint16_t *buf, uint16_t color) {
|
||||
for (size_t i = 0; i < CANVAS_PIXELS; i++) {
|
||||
g_pixels[i] = color;
|
||||
buf[i] = color;
|
||||
}
|
||||
}
|
||||
|
||||
static void canvas_clear(uint16_t color) { canvas_clear_buf(g_pixels, color); }
|
||||
|
||||
static void canvas_sync_dsc() {
|
||||
g_dsc.header.magic = LV_IMAGE_HEADER_MAGIC;
|
||||
g_dsc.header.cf = LV_COLOR_FORMAT_RGB565;
|
||||
@@ -65,47 +78,40 @@ static void free_png() {
|
||||
}
|
||||
}
|
||||
|
||||
static void gif_close_session() {
|
||||
if (g_gif_open && g_gif) {
|
||||
g_gif->close();
|
||||
g_gif_open = false;
|
||||
// Tightest JPEGDEC scale that still covers the canvas (center-cropped).
|
||||
static int jpeg_scale_cover(int w, int h, int *dw, int *dh) {
|
||||
const int scales[] = {0, JPEG_SCALE_HALF, JPEG_SCALE_QUARTER, JPEG_SCALE_EIGHTH};
|
||||
const int divs[] = {1, 2, 4, 8};
|
||||
int best = 0;
|
||||
int best_dw = w;
|
||||
int best_dh = h;
|
||||
int best_area = 0x7fffffff;
|
||||
bool found = false;
|
||||
for (int i = 0; i < 4; i++) {
|
||||
const int dw_i = w / divs[i];
|
||||
const int dh_i = h / divs[i];
|
||||
if (dw_i < 1 || dh_i < 1) {
|
||||
continue;
|
||||
}
|
||||
if (dw_i >= (int)CANVAS_W && dh_i >= (int)CANVAS_H) {
|
||||
const int area = dw_i * dh_i;
|
||||
if (area < best_area) {
|
||||
best_area = area;
|
||||
best = scales[i];
|
||||
best_dw = dw_i;
|
||||
best_dh = dh_i;
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (g_gif) {
|
||||
delete g_gif;
|
||||
g_gif = nullptr;
|
||||
if (!found) {
|
||||
best = 0;
|
||||
best_dw = w;
|
||||
best_dh = h;
|
||||
}
|
||||
}
|
||||
|
||||
static void fit_size(int nw, int nh, int *dw, int *dh) {
|
||||
*dw = nw;
|
||||
*dh = nh;
|
||||
if (*dw <= (int)CANVAS_W && *dh <= (int)CANVAS_H) {
|
||||
return;
|
||||
}
|
||||
const float sx = (float)CANVAS_W / (float)nw;
|
||||
const float sy = (float)CANVAS_H / (float)nh;
|
||||
const float s = sx < sy ? sx : sy;
|
||||
*dw = (int)(nw * s + 0.5f);
|
||||
*dh = (int)(nh * s + 0.5f);
|
||||
if (*dw < 1) {
|
||||
*dw = 1;
|
||||
}
|
||||
if (*dh < 1) {
|
||||
*dh = 1;
|
||||
}
|
||||
}
|
||||
|
||||
static int jpeg_scale_for(int w, int h) {
|
||||
if (w > CANVAS_W * 4 || h > CANVAS_H * 4) {
|
||||
return JPEG_SCALE_EIGHTH;
|
||||
}
|
||||
if (w > CANVAS_W * 2 || h > CANVAS_H * 2) {
|
||||
return JPEG_SCALE_QUARTER;
|
||||
}
|
||||
if (w > CANVAS_W || h > CANVAS_H) {
|
||||
return JPEG_SCALE_HALF;
|
||||
}
|
||||
return 0;
|
||||
*dw = best_dw;
|
||||
*dh = best_dh;
|
||||
return best;
|
||||
}
|
||||
|
||||
static void *jpeg_open(const char *filename, int32_t *size) {
|
||||
@@ -192,34 +198,32 @@ static int png_draw(PNGDRAW *p) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Line buffer for source width (cap to avoid stack blow)
|
||||
static uint16_t line[640];
|
||||
if (p->iWidth > 640) {
|
||||
return 0;
|
||||
}
|
||||
g_png->getLineAsRGB565(p, line, PNG_RGB565_LITTLE_ENDIAN, 0x0000);
|
||||
|
||||
// Nearest-neighbor: which dest rows map from this source row?
|
||||
for (int dy = 0; dy < g_dst_h; dy++) {
|
||||
const int sy = (dy * g_src_h) / g_dst_h;
|
||||
if (sy != p->y) {
|
||||
continue;
|
||||
}
|
||||
int dy = 0;
|
||||
while (dy < g_dst_h && (dy * g_src_h) / g_dst_h < p->y) {
|
||||
dy++;
|
||||
}
|
||||
while (dy < g_dst_h && (dy * g_src_h) / g_dst_h == p->y) {
|
||||
const int y = g_off_y + dy;
|
||||
if (y < 0 || y >= (int)CANVAS_H) {
|
||||
continue;
|
||||
}
|
||||
for (int dx = 0; dx < g_dst_w; dx++) {
|
||||
const int sx = (dx * g_src_w) / g_dst_w;
|
||||
if (sx < 0 || sx >= p->iWidth) {
|
||||
continue;
|
||||
if (y >= 0 && y < (int)CANVAS_H) {
|
||||
for (int dx = 0; dx < g_dst_w; dx++) {
|
||||
const int sx = (dx * g_src_w) / g_dst_w;
|
||||
if (sx < 0 || sx >= p->iWidth) {
|
||||
continue;
|
||||
}
|
||||
const int x = g_off_x + dx;
|
||||
if (x < 0 || x >= (int)CANVAS_W) {
|
||||
continue;
|
||||
}
|
||||
g_pixels[(size_t)y * CANVAS_W + (size_t)x] = line[sx];
|
||||
}
|
||||
const int x = g_off_x + dx;
|
||||
if (x < 0 || x >= (int)CANVAS_W) {
|
||||
continue;
|
||||
}
|
||||
g_pixels[(size_t)y * CANVAS_W + (size_t)x] = line[sx];
|
||||
}
|
||||
dy++;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
@@ -258,41 +262,182 @@ static int32_t gif_seek(GIFFILE *pFile, int32_t iPosition) {
|
||||
}
|
||||
|
||||
static void gif_draw(GIFDRAW *pDraw) {
|
||||
if (!pDraw || g_src_w <= 0 || g_dst_w <= 0) {
|
||||
if (!pDraw || !g_draw_dst || g_src_w <= 0 || g_dst_w <= 0) {
|
||||
return;
|
||||
}
|
||||
// Map source frame lines into scaled dest canvas
|
||||
if (g_gif_stop) {
|
||||
return;
|
||||
}
|
||||
|
||||
const int src_y = pDraw->iY + pDraw->y;
|
||||
for (int dy = 0; dy < g_dst_h; dy++) {
|
||||
const int sy = (dy * g_src_h) / g_dst_h;
|
||||
if (sy != src_y) {
|
||||
continue;
|
||||
}
|
||||
const int y = g_off_y + dy;
|
||||
uint16_t *dst = g_draw_dst;
|
||||
|
||||
// Fast path: 1:1 into canvas region
|
||||
if (g_src_w == g_dst_w && g_src_h == g_dst_h) {
|
||||
const int y = g_off_y + src_y;
|
||||
if (y < 0 || y >= (int)CANVAS_H) {
|
||||
continue;
|
||||
return;
|
||||
}
|
||||
for (int dx = 0; dx < g_dst_w; dx++) {
|
||||
const int sx = (dx * g_src_w) / g_dst_w;
|
||||
const int lx = sx - pDraw->iX;
|
||||
if (lx < 0 || lx >= pDraw->iWidth) {
|
||||
continue;
|
||||
}
|
||||
const uint8_t idx = pDraw->pPixels[lx];
|
||||
uint16_t *row = dst + (size_t)y * CANVAS_W + (size_t)g_off_x + pDraw->iX;
|
||||
for (int x = 0; x < pDraw->iWidth; x++) {
|
||||
const uint8_t idx = pDraw->pPixels[x];
|
||||
if (pDraw->ucHasTransparency && idx == pDraw->ucTransparent) {
|
||||
continue;
|
||||
}
|
||||
const int x = g_off_x + dx;
|
||||
if (x < 0 || x >= (int)CANVAS_W) {
|
||||
row[x] = pDraw->pPalette[idx];
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Scaled: only dest rows that sample this source line
|
||||
int dy = 0;
|
||||
while (dy < g_dst_h && (dy * g_src_h) / g_dst_h < src_y) {
|
||||
dy++;
|
||||
}
|
||||
while (dy < g_dst_h && (dy * g_src_h) / g_dst_h == src_y) {
|
||||
const int y = g_off_y + dy;
|
||||
if (y >= 0 && y < (int)CANVAS_H) {
|
||||
for (int dx = 0; dx < g_dst_w; dx++) {
|
||||
const int sx = (dx * g_src_w) / g_dst_w;
|
||||
const int lx = sx - pDraw->iX;
|
||||
if (lx < 0 || lx >= pDraw->iWidth) {
|
||||
continue;
|
||||
}
|
||||
const uint8_t idx = pDraw->pPixels[lx];
|
||||
if (pDraw->ucHasTransparency && idx == pDraw->ucTransparent) {
|
||||
continue;
|
||||
}
|
||||
const int x = g_off_x + dx;
|
||||
if (x < 0 || x >= (int)CANVAS_W) {
|
||||
continue;
|
||||
}
|
||||
dst[(size_t)y * CANVAS_W + (size_t)x] = pDraw->pPalette[idx];
|
||||
}
|
||||
}
|
||||
dy++;
|
||||
}
|
||||
}
|
||||
|
||||
static void gif_worker_cleanup() {
|
||||
if (g_gif) {
|
||||
g_gif->close();
|
||||
delete g_gif;
|
||||
g_gif = nullptr;
|
||||
}
|
||||
g_draw_dst = g_pixels;
|
||||
g_frame_ready = false;
|
||||
g_gif_busy = false;
|
||||
g_gif_active = false;
|
||||
}
|
||||
|
||||
static void gif_task(void * /*arg*/) {
|
||||
for (;;) {
|
||||
ulTaskNotifyTake(pdTRUE, portMAX_DELAY);
|
||||
g_gif_busy = true;
|
||||
g_gif_stop = false;
|
||||
g_frame_ready = false;
|
||||
|
||||
if (!g_back) {
|
||||
g_back = (uint16_t *)malloc(CANVAS_PIXELS * sizeof(uint16_t));
|
||||
if (!g_back) {
|
||||
Serial.println("GIF: back buffer alloc failed");
|
||||
g_gif_busy = false;
|
||||
g_gif_active = false;
|
||||
continue;
|
||||
}
|
||||
g_pixels[(size_t)y * CANVAS_W + (size_t)x] = pDraw->pPalette[idx];
|
||||
}
|
||||
|
||||
g_gif = new AnimatedGIF();
|
||||
if (!g_gif) {
|
||||
gif_worker_cleanup();
|
||||
continue;
|
||||
}
|
||||
|
||||
g_gif->begin(GIF_PALETTE_RGB565_LE);
|
||||
if (!g_gif->open(g_gif_path, gif_open, gif_close, gif_read, gif_seek,
|
||||
gif_draw)) {
|
||||
Serial.printf("GIF open fail: %s\n", g_gif_path);
|
||||
gif_worker_cleanup();
|
||||
continue;
|
||||
}
|
||||
|
||||
g_src_w = g_gif->getCanvasWidth();
|
||||
g_src_h = g_gif->getCanvasHeight();
|
||||
if (g_src_w <= 0 || g_src_h <= 0) {
|
||||
gif_worker_cleanup();
|
||||
continue;
|
||||
}
|
||||
|
||||
// Cover full canvas (sample source into every dest pixel).
|
||||
g_dst_w = CANVAS_W;
|
||||
g_dst_h = CANVAS_H;
|
||||
g_img_w = CANVAS_W;
|
||||
g_img_h = CANVAS_H;
|
||||
g_off_x = 0;
|
||||
g_off_y = 0;
|
||||
|
||||
canvas_clear_buf(g_back, 0x0000);
|
||||
g_draw_dst = g_back;
|
||||
g_gif_active = true;
|
||||
|
||||
while (!g_gif_stop) {
|
||||
int delay_ms = 0;
|
||||
// bSync=false: never delay() inside the library (that froze touch).
|
||||
g_gif->playFrame(false, &delay_ms);
|
||||
if (g_gif_stop) {
|
||||
break;
|
||||
}
|
||||
|
||||
g_frame_ready = true;
|
||||
// Wait until UI consumed the frame (or abort).
|
||||
while (g_frame_ready && !g_gif_stop) {
|
||||
vTaskDelay(pdMS_TO_TICKS(2));
|
||||
}
|
||||
if (g_gif_stop) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (delay_ms < 30) {
|
||||
delay_ms = 30;
|
||||
}
|
||||
if (delay_ms > 200) {
|
||||
delay_ms = 200;
|
||||
}
|
||||
int waited = 0;
|
||||
while (waited < delay_ms && !g_gif_stop) {
|
||||
vTaskDelay(pdMS_TO_TICKS(5));
|
||||
waited += 5;
|
||||
}
|
||||
}
|
||||
|
||||
gif_worker_cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
static void gif_ensure_task() {
|
||||
if (g_gif_task) {
|
||||
return;
|
||||
}
|
||||
xTaskCreatePinnedToCore(gif_task, "gif", 8192, nullptr, 1, &g_gif_task, 0);
|
||||
}
|
||||
|
||||
static void gif_stop_and_wait() {
|
||||
g_gif_stop = true;
|
||||
g_frame_ready = false;
|
||||
const uint32_t t0 = millis();
|
||||
while (g_gif_busy && (millis() - t0) < 800) {
|
||||
vTaskDelay(pdMS_TO_TICKS(5));
|
||||
}
|
||||
// If worker hung, force local cleanup of flags (file may still be open briefly)
|
||||
if (g_gif_busy) {
|
||||
Serial.println("GIF: stop timeout");
|
||||
g_gif_busy = false;
|
||||
g_gif_active = false;
|
||||
}
|
||||
}
|
||||
|
||||
static bool load_jpeg(const char *path) {
|
||||
gif_close_session();
|
||||
gif_stop_and_wait();
|
||||
free_png();
|
||||
free_jpeg();
|
||||
|
||||
@@ -310,22 +455,13 @@ static bool load_jpeg(const char *path) {
|
||||
|
||||
const int nw = g_jpeg->getWidth();
|
||||
const int nh = g_jpeg->getHeight();
|
||||
const int scale = jpeg_scale_for(nw, nh);
|
||||
int dw = nw;
|
||||
int dh = nh;
|
||||
if (scale == JPEG_SCALE_HALF) {
|
||||
dw /= 2;
|
||||
dh /= 2;
|
||||
} else if (scale == JPEG_SCALE_QUARTER) {
|
||||
dw /= 4;
|
||||
dh /= 4;
|
||||
} else if (scale == JPEG_SCALE_EIGHTH) {
|
||||
dw /= 8;
|
||||
dh /= 8;
|
||||
}
|
||||
int dw = 0;
|
||||
int dh = 0;
|
||||
const int scale = jpeg_scale_cover(nw, nh, &dw, &dh);
|
||||
|
||||
g_img_w = (uint16_t)dw;
|
||||
g_img_h = (uint16_t)dh;
|
||||
g_img_w = CANVAS_W;
|
||||
g_img_h = CANVAS_H;
|
||||
// Center; negative offsets crop overflow into the canvas (cover).
|
||||
const int xoff = ((int)CANVAS_W - dw) / 2;
|
||||
const int yoff = ((int)CANVAS_H - dh) / 2;
|
||||
|
||||
@@ -341,7 +477,7 @@ static bool load_jpeg(const char *path) {
|
||||
}
|
||||
|
||||
static bool load_png(const char *path) {
|
||||
gif_close_session();
|
||||
gif_stop_and_wait();
|
||||
free_jpeg();
|
||||
free_png();
|
||||
|
||||
@@ -367,11 +503,13 @@ static bool load_png(const char *path) {
|
||||
return false;
|
||||
}
|
||||
|
||||
fit_size(g_src_w, g_src_h, &g_dst_w, &g_dst_h);
|
||||
g_img_w = (uint16_t)g_dst_w;
|
||||
g_img_h = (uint16_t)g_dst_h;
|
||||
g_off_x = ((int)CANVAS_W - g_dst_w) / 2;
|
||||
g_off_y = ((int)CANVAS_H - g_dst_h) / 2;
|
||||
// Sample source into every canvas pixel (cover / fill).
|
||||
g_dst_w = CANVAS_W;
|
||||
g_dst_h = CANVAS_H;
|
||||
g_img_w = CANVAS_W;
|
||||
g_img_h = CANVAS_H;
|
||||
g_off_x = 0;
|
||||
g_off_y = 0;
|
||||
|
||||
canvas_clear(0x0000);
|
||||
const int drc = g_png->decode(nullptr, 0);
|
||||
@@ -385,44 +523,37 @@ static bool load_png(const char *path) {
|
||||
}
|
||||
|
||||
static bool load_gif(const char *path) {
|
||||
gif_stop_and_wait();
|
||||
free_jpeg();
|
||||
free_png();
|
||||
gif_close_session();
|
||||
|
||||
g_gif = new AnimatedGIF();
|
||||
if (!g_gif) {
|
||||
return false;
|
||||
}
|
||||
|
||||
g_gif->begin(GIF_PALETTE_RGB565_LE);
|
||||
if (!g_gif->open(path, gif_open, gif_close, gif_read, gif_seek, gif_draw)) {
|
||||
Serial.printf("GIF open fail: %s\n", path);
|
||||
gif_close_session();
|
||||
return false;
|
||||
}
|
||||
|
||||
g_src_w = g_gif->getCanvasWidth();
|
||||
g_src_h = g_gif->getCanvasHeight();
|
||||
if (g_src_w <= 0 || g_src_h <= 0) {
|
||||
gif_close_session();
|
||||
return false;
|
||||
}
|
||||
|
||||
fit_size(g_src_w, g_src_h, &g_dst_w, &g_dst_h);
|
||||
g_img_w = (uint16_t)g_dst_w;
|
||||
g_img_h = (uint16_t)g_dst_h;
|
||||
g_off_x = ((int)CANVAS_W - g_dst_w) / 2;
|
||||
g_off_y = ((int)CANVAS_H - g_dst_h) / 2;
|
||||
g_gif_open = true;
|
||||
gif_ensure_task();
|
||||
strncpy(g_gif_path, path, sizeof(g_gif_path) - 1);
|
||||
g_gif_path[sizeof(g_gif_path) - 1] = '\0';
|
||||
|
||||
// Show black immediately; worker fills frames async.
|
||||
canvas_clear(0x0000);
|
||||
int delay_ms = 0;
|
||||
g_gif->playFrame(true, &delay_ms);
|
||||
if (delay_ms < 20) {
|
||||
delay_ms = 100;
|
||||
canvas_sync_dsc();
|
||||
g_gif_stop = false;
|
||||
xTaskNotifyGive(g_gif_task);
|
||||
|
||||
// Brief wait for first frame (non-blocking beyond a short timeout).
|
||||
const uint32_t t0 = millis();
|
||||
while ((millis() - t0) < 250) {
|
||||
if (g_frame_ready) {
|
||||
break;
|
||||
}
|
||||
// Worker finished with no frame => open/decode failed
|
||||
if ((millis() - t0) > 40 && !g_gif_busy && !g_gif_active) {
|
||||
return false;
|
||||
}
|
||||
vTaskDelay(pdMS_TO_TICKS(5));
|
||||
}
|
||||
g_gif_next_ms = millis() + (uint32_t)delay_ms;
|
||||
return true;
|
||||
if (g_frame_ready && g_back) {
|
||||
memcpy(g_pixels, g_back, CANVAS_PIXELS * sizeof(uint16_t));
|
||||
g_frame_ready = false;
|
||||
}
|
||||
return g_gif_active || g_gif_busy;
|
||||
}
|
||||
|
||||
bool photo_view_init() {
|
||||
@@ -432,6 +563,7 @@ bool photo_view_init() {
|
||||
canvas_clear(0x0000);
|
||||
canvas_sync_dsc();
|
||||
g_ready = true;
|
||||
gif_ensure_task();
|
||||
Serial.printf("photo_view: canvas %ux%u (%u bytes), free heap %u\n",
|
||||
(unsigned)CANVAS_W, (unsigned)CANVAS_H,
|
||||
(unsigned)(CANVAS_PIXELS * 2), (unsigned)ESP.getFreeHeap());
|
||||
@@ -439,13 +571,19 @@ bool photo_view_init() {
|
||||
}
|
||||
|
||||
void photo_view_deinit() {
|
||||
gif_close_session();
|
||||
photo_view_stop();
|
||||
free_jpeg();
|
||||
free_png();
|
||||
if (g_back) {
|
||||
free(g_back);
|
||||
g_back = nullptr;
|
||||
}
|
||||
g_ready = false;
|
||||
g_dsc.data = nullptr;
|
||||
}
|
||||
|
||||
void photo_view_stop() { gif_stop_and_wait(); }
|
||||
|
||||
bool photo_view_show(const PhotoEntry *entry) {
|
||||
if (!entry || !photo_view_init()) {
|
||||
return false;
|
||||
@@ -476,21 +614,13 @@ uint16_t photo_view_canvas_h() { return CANVAS_H; }
|
||||
uint16_t photo_view_img_w() { return g_img_w; }
|
||||
uint16_t photo_view_img_h() { return g_img_h; }
|
||||
|
||||
bool photo_view_is_gif() { return g_gif_open; }
|
||||
bool photo_view_is_gif() { return g_gif_active || g_gif_busy; }
|
||||
|
||||
bool photo_view_gif_tick() {
|
||||
if (!g_gif_open || !g_gif) {
|
||||
if (!g_frame_ready || !g_back) {
|
||||
return false;
|
||||
}
|
||||
const uint32_t now = millis();
|
||||
if ((int32_t)(now - g_gif_next_ms) < 0) {
|
||||
return false;
|
||||
}
|
||||
int delay_ms = 0;
|
||||
g_gif->playFrame(true, &delay_ms);
|
||||
if (delay_ms < 20) {
|
||||
delay_ms = 100;
|
||||
}
|
||||
g_gif_next_ms = now + (uint32_t)delay_ms;
|
||||
memcpy(g_pixels, g_back, CANVAS_PIXELS * sizeof(uint16_t));
|
||||
g_frame_ready = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
15
src/ui.cpp
@@ -110,6 +110,9 @@ static void go_delta(int delta) {
|
||||
if (n == 0) {
|
||||
return;
|
||||
}
|
||||
// Abort GIF worker first so the click is not stuck behind decode/delay.
|
||||
photo_view_stop();
|
||||
|
||||
int16_t next = (int16_t)(g_index + delta);
|
||||
while (next < 0) {
|
||||
next = (int16_t)(next + (int16_t)n);
|
||||
@@ -117,9 +120,6 @@ static void go_delta(int delta) {
|
||||
while (next >= (int16_t)n) {
|
||||
next = (int16_t)(next - (int16_t)n);
|
||||
}
|
||||
if (next == g_index && n > 1) {
|
||||
// still reload if single? skip
|
||||
}
|
||||
g_index = next;
|
||||
show_current();
|
||||
}
|
||||
@@ -185,9 +185,12 @@ void ui_create() {
|
||||
lv_obj_add_event_cb(tap, on_tap_zone, LV_EVENT_CLICKED, nullptr);
|
||||
|
||||
img_photo = lv_image_create(scr);
|
||||
lv_obj_set_size(img_photo, photo_view_canvas_w(), photo_view_canvas_h());
|
||||
lv_image_set_scale(img_photo, 512); // 256=1x, 512=2x -> full 320x240
|
||||
lv_obj_align(img_photo, LV_ALIGN_CENTER, 0, 0);
|
||||
// Full-screen widget + stretch avoids scale-transform gaps/stripes behind the photo.
|
||||
lv_obj_set_size(img_photo, UI_HOR_RES, UI_VER_RES);
|
||||
lv_obj_set_pos(img_photo, 0, 0);
|
||||
lv_image_set_inner_align(img_photo, LV_IMAGE_ALIGN_STRETCH);
|
||||
lv_obj_set_style_bg_color(img_photo, lv_color_hex(0x000000), 0);
|
||||
lv_obj_set_style_bg_opa(img_photo, LV_OPA_COVER, 0);
|
||||
lv_obj_clear_flag(img_photo, LV_OBJ_FLAG_CLICKABLE);
|
||||
|
||||
// Top bar
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate 320x240 JPG/PNG/GIF samples into assets/photos."""
|
||||
"""Generate 320x240 JPG/PNG/GIF samples into assets/photos (solid bg, no stripes)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
@@ -27,16 +27,13 @@ def font(size: int = 28) -> ImageFont.ImageFont:
|
||||
def card(bg, accent, title: str, subtitle: str, path: Path, fmt: str) -> None:
|
||||
im = Image.new("RGB", (W, H), bg)
|
||||
d = ImageDraw.Draw(im)
|
||||
for i in range(0, H, 12):
|
||||
c = tuple(max(0, min(255, v + (i % 24) - 12)) for v in accent)
|
||||
d.rectangle((0, i, W, i + 6), fill=c)
|
||||
d.rectangle((16, 40, W - 16, H - 40), fill=bg)
|
||||
d.rectangle((16, 40, W - 16, H - 40), outline=accent, width=3)
|
||||
d.text((28, 70), title, fill=(234, 240, 246), font=font(36))
|
||||
d.text((28, 130), subtitle, fill=(122, 139, 156), font=font(20))
|
||||
d.text((28, 170), "CYD ALBUM TEST", fill=accent, font=font(16))
|
||||
# Solid panel only — no horizontal stripe decoration
|
||||
d.rounded_rectangle((20, 36, W - 20, H - 36), radius=12, fill=bg, outline=accent, width=3)
|
||||
d.text((36, 70), title, fill=(234, 240, 246), font=font(36))
|
||||
d.text((36, 130), subtitle, fill=(160, 175, 190), font=font(20))
|
||||
d.text((36, 170), "CYD ALBUM TEST", fill=accent, font=font(16))
|
||||
if fmt == "JPEG":
|
||||
im.save(path, "JPEG", quality=90)
|
||||
im.save(path, "JPEG", quality=92)
|
||||
else:
|
||||
im.save(path, "PNG")
|
||||
print(f"wrote {path} ({path.stat().st_size} bytes)")
|
||||
@@ -45,7 +42,7 @@ def card(bg, accent, title: str, subtitle: str, path: Path, fmt: str) -> None:
|
||||
def main() -> None:
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
card((10, 16, 22), (79, 195, 247), "01 JPG", "tap left / right", OUT / "01_blue.jpg", "JPEG")
|
||||
card((20, 12, 28), (232, 93, 76), "02 JPG", "red sample", OUT / "02_red.jpg", "JPEG")
|
||||
card((28, 12, 18), (232, 93, 76), "02 JPG", "red sample", OUT / "02_red.jpg", "JPEG")
|
||||
card((8, 28, 20), (61, 220, 151), "03 PNG", "green sample", OUT / "03_green.png", "PNG")
|
||||
|
||||
frames = []
|
||||
@@ -53,10 +50,9 @@ def main() -> None:
|
||||
im = Image.new("RGB", (W, H), (10, 16, 22))
|
||||
d = ImageDraw.Draw(im)
|
||||
d.text((24, 24), "04 GIF", fill=(234, 240, 246), font=font(32))
|
||||
d.text((24, 70), "animated", fill=(122, 139, 156), font=font(18))
|
||||
d.text((24, 70), "animated", fill=(160, 175, 190), font=font(18))
|
||||
x = 20 + i * 40
|
||||
d.rectangle((x, 120, x + 80, 180), fill=(255, 200, 40))
|
||||
d.rectangle((20, 200, 300, 210), fill=(36, 49, 64))
|
||||
d.rounded_rectangle((x, 120, x + 80, 180), radius=8, fill=(255, 200, 40))
|
||||
frames.append(im)
|
||||
gif_path = OUT / "04_bounce.gif"
|
||||
frames[0].save(
|
||||
|
||||