diff --git a/CHANGELOG.md b/CHANGELOG.md index 9dfbb60..a0242f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 2026-10-19 — GIF 动区闪 + 切回变小 + +- 显示统一全屏 STRETCH,离开 GIF 不再缩成小图。 +- GIF 1:1 合成 + disposal=2 恢复背景;样张改为 160×120。 + ## 2026-10-19 — GIF 去闪屏 - 双缓冲指针交换发布帧,避免 memcpy 半帧撕裂。 diff --git a/README.md b/README.md index 83c8ef4..9a32e50 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,8 @@ ESP32-2432S028R 电子相册:从 TF 卡读取 **JPG / PNG / GIF**,触控切 ## 功能 - 扫描 TF 卡 `/photos` 与根目录的 `.jpg` / `.jpeg` / `.png` / `.gif` -- 半分辨率画布 160×120,全屏拉伸到 320×240(适配无 PSRAM 的 DRAM) -- JPG / PNG / GIF 按 cover 铺满画布(可裁边) +- 半分辨率画布 160×120,界面始终全屏 STRETCH 到 320×240 +- JPG / PNG cover 铺满;GIF 建议 ≤160×120(1:1 解码,减少动区闪烁) - **点左半屏 = 上一张,点右半屏 = 下一张**;底栏 `<` / `>` 同样切换 - GIF 自动播帧;右上角显示 TF 状态 - 串口 `#PUT` 传文件到卡(`tools/sd_put.py`) diff --git a/assets/photos/04_bounce.gif b/assets/photos/04_bounce.gif index 82b67b2..66bf16f 100644 Binary files a/assets/photos/04_bounce.gif and b/assets/photos/04_bounce.gif differ diff --git a/firmware/cyd/VERSION.txt b/firmware/cyd/VERSION.txt index 71ef430..343e094 100644 --- a/firmware/cyd/VERSION.txt +++ b/firmware/cyd/VERSION.txt @@ -1,5 +1,5 @@ name: CYD Photo album (cyd) -git: 5475be8 +git: 083a197 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 2A437103D8933CA6ACDAA139336A68A227053E0B73D44FFA10FBB5115F6C7328 + firmware.bin A0FDF3B007C54E9B302690E5FCD96790A6610A74D39F1BDFB72A926C4114F815 partitions.bin 148B959CBFF1C38AA8E1D5C0BA9D612C54997B945E56A63F41223EEF650653A1 diff --git a/firmware/cyd/firmware.bin b/firmware/cyd/firmware.bin index dcc4f96..913370c 100644 Binary files a/firmware/cyd/firmware.bin and b/firmware/cyd/firmware.bin differ diff --git a/src/photo_view.cpp b/src/photo_view.cpp index cecbdc1..5ab9f0a 100644 --- a/src/photo_view.cpp +++ b/src/photo_view.cpp @@ -271,60 +271,76 @@ static int32_t gif_seek(GIFFILE *pFile, int32_t iPosition) { return pFile->iPos; } +// GIF disposal tracking (apply previous frame's disposal at start of next frame). +static uint8_t g_gif_pend_disposal = 0; +static int16_t g_gif_pend_x = 0; +static int16_t g_gif_pend_y = 0; +static int16_t g_gif_pend_w = 0; +static int16_t g_gif_pend_h = 0; +static uint16_t g_gif_bg565 = 0x0000; + +static void gif_fill_rect(uint16_t *dst, int x0, int y0, int w, int h, + uint16_t color) { + if (!dst || w <= 0 || h <= 0) { + return; + } + for (int y = y0; y < y0 + h; y++) { + if (y < 0 || y >= (int)CANVAS_H) { + continue; + } + for (int x = x0; x < x0 + w; x++) { + if (x < 0 || x >= (int)CANVAS_W) { + continue; + } + dst[(size_t)y * CANVAS_W + (size_t)x] = color; + } + } +} + +// 1:1 blit into canvas (centered / clipped). No per-frame downscale — that +// caused moving-region flicker with disposal + transparency. static void gif_draw(GIFDRAW *pDraw) { - if (!pDraw || !g_draw_dst || g_src_w <= 0 || g_dst_w <= 0) { + if (!pDraw || !g_draw_dst || g_src_w <= 0) { return; } if (g_gif_stop) { return; } - const int src_y = pDraw->iY + pDraw->y; 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) { - return; + if (pDraw->y == 0) { + // Restore previous frame rect to background before drawing this frame. + if (g_gif_pend_disposal == 2) { + gif_fill_rect(dst, g_gif_pend_x, g_gif_pend_y, g_gif_pend_w, g_gif_pend_h, + g_gif_bg565); } - 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; - } - row[x] = pDraw->pPalette[idx]; + g_gif_pend_disposal = pDraw->ucDisposalMethod; + g_gif_pend_x = (int16_t)(g_off_x + pDraw->iX); + g_gif_pend_y = (int16_t)(g_off_y + pDraw->iY); + g_gif_pend_w = (int16_t)pDraw->iWidth; + g_gif_pend_h = (int16_t)pDraw->iHeight; + if (pDraw->pPalette) { + g_gif_bg565 = pDraw->pPalette[pDraw->ucBackground]; } + } + + const int y = g_off_y + pDraw->iY + pDraw->y; + if (y < 0 || y >= (int)CANVAS_H) { 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]; - } + const int x0 = g_off_x + pDraw->iX; + for (int i = 0; i < pDraw->iWidth; i++) { + const int x = x0 + i; + if (x < 0 || x >= (int)CANVAS_W) { + continue; } - dy++; + const uint8_t idx = pDraw->pPixels[i]; + if (pDraw->ucHasTransparency && idx == pDraw->ucTransparent) { + continue; + } + dst[(size_t)y * CANVAS_W + (size_t)x] = pDraw->pPalette[idx]; } } @@ -381,12 +397,16 @@ static void gif_task(void * /*arg*/) { continue; } - g_dst_w = CANVAS_W; - g_dst_h = CANVAS_H; + // 1:1 into canvas (center; clip if GIF larger than 160x120). + g_off_x = ((int)CANVAS_W - g_src_w) / 2; + g_off_y = ((int)CANVAS_H - g_src_h) / 2; + g_dst_w = g_src_w; + g_dst_h = g_src_h; g_img_w = CANVAS_W; g_img_h = CANVAS_H; - g_off_x = 0; - g_off_y = 0; + g_gif_pend_disposal = 0; + g_gif_pend_w = 0; + g_gif_pend_h = 0; // Double-buffer: show A, decode into B (B starts as copy of A). g_show = g_pixels; diff --git a/src/ui.cpp b/src/ui.cpp index df92f33..b0e8a39 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -85,21 +85,12 @@ static void show_current() { const PhotoEntry *e = photo_list_get((uint16_t)g_index); const bool ok = e && photo_view_show(e); if (img_photo) { - const bool gif = ok && photo_view_is_gif(); - // GIF: integer 2x scale (cheap, less tear). Still: full-screen stretch. - if (gif) { - lv_image_set_inner_align(img_photo, LV_IMAGE_ALIGN_DEFAULT); - lv_image_set_scale(img_photo, 512); // 256=1x - lv_image_set_pivot(img_photo, photo_view_canvas_w() / 2, - photo_view_canvas_h() / 2); - lv_obj_set_size(img_photo, photo_view_canvas_w(), photo_view_canvas_h()); - lv_obj_align(img_photo, LV_ALIGN_CENTER, 0, 0); - } else { - lv_image_set_scale(img_photo, 256); - lv_image_set_inner_align(img_photo, LV_IMAGE_ALIGN_STRETCH); - lv_obj_set_size(img_photo, UI_HOR_RES, UI_VER_RES); - lv_obj_set_pos(img_photo, 0, 0); - } + // Always full-screen stretch so JPG/GIF share the same size (no shrink after GIF). + lv_image_set_scale(img_photo, LV_SCALE_NONE); + lv_image_set_pivot(img_photo, 0, 0); + lv_image_set_inner_align(img_photo, LV_IMAGE_ALIGN_STRETCH); + lv_obj_set_size(img_photo, UI_HOR_RES, UI_VER_RES); + lv_obj_align(img_photo, LV_ALIGN_TOP_LEFT, 0, 0); const lv_image_dsc_t *dsc = photo_view_dsc(); if (ok && dsc) { @@ -201,9 +192,9 @@ void ui_create() { lv_obj_add_event_cb(tap, on_tap_zone, LV_EVENT_CLICKED, nullptr); img_photo = lv_image_create(scr); - // 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_obj_align(img_photo, LV_ALIGN_TOP_LEFT, 0, 0); + lv_image_set_scale(img_photo, LV_SCALE_NONE); 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); diff --git a/tools/make_test_photos.py b/tools/make_test_photos.py index 10c2c65..ac740a7 100644 --- a/tools/make_test_photos.py +++ b/tools/make_test_photos.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Generate 320x240 JPG/PNG/GIF samples into assets/photos (solid bg, no stripes).""" +"""Generate test photos into assets/photos (solid bg; GIF is 160x120 for 1:1 decode).""" from __future__ import annotations from pathlib import Path @@ -9,6 +9,7 @@ from PIL import Image, ImageDraw, ImageFont ROOT = Path(__file__).resolve().parents[1] OUT = ROOT / "assets" / "photos" W, H = 320, 240 +GIF_W, GIF_H = 160, 120 def font(size: int = 28) -> ImageFont.ImageFont: @@ -27,7 +28,6 @@ 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) - # 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)) @@ -45,23 +45,25 @@ def main() -> None: 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") + # 160x120 GIF matches decode canvas → no downscale flicker on moving regions. frames = [] - for i in range(6): - im = Image.new("RGB", (W, H), (10, 16, 22)) + for i in range(8): + im = Image.new("RGB", (GIF_W, GIF_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=(160, 175, 190), font=font(18)) - x = 20 + i * 40 - d.rounded_rectangle((x, 120, x + 80, 180), radius=8, fill=(255, 200, 40)) + d.text((8, 8), "04 GIF", fill=(234, 240, 246), font=font(22)) + d.text((8, 36), "160x120", fill=(160, 175, 190), font=font(14)) + x = 4 + i * 14 + d.rounded_rectangle((x, 60, x + 40, 100), radius=6, fill=(255, 200, 40)) frames.append(im) gif_path = OUT / "04_bounce.gif" frames[0].save( gif_path, save_all=True, append_images=frames[1:], - duration=120, + duration=80, loop=0, optimize=False, + disposal=2, # restore to background — handled in firmware ) print(f"wrote {gif_path} ({gif_path.stat().st_size} bytes)") print("DONE")