Add CYD photo album with jpg/png/gif viewer and sample assets.

Ship TF-card album UI, half-res decode canvas, prebuilt firmware, and documented flash/upload workflow.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-19 18:07:56 +08:00
commit 99618a46b0
35 changed files with 3211 additions and 0 deletions

496
src/photo_view.cpp Normal file
View File

@@ -0,0 +1,496 @@
#include "photo_view.h"
#include "pins.h"
#include <AnimatedGIF.h>
#include <Arduino.h>
#include <JPEGDEC.h>
#include <PNGdec.h>
#include <SD.h>
#include <string.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;
static constexpr size_t CANVAS_PIXELS = (size_t)CANVAS_W * (size_t)CANVAS_H;
static uint16_t g_pixels[CANVAS_PIXELS];
static bool g_ready = false;
static lv_image_dsc_t g_dsc;
static uint16_t g_img_w = 0;
static uint16_t g_img_h = 0;
static JPEGDEC *g_jpeg = nullptr;
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;
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) {
for (size_t i = 0; i < CANVAS_PIXELS; i++) {
g_pixels[i] = color;
}
}
static void canvas_sync_dsc() {
g_dsc.header.magic = LV_IMAGE_HEADER_MAGIC;
g_dsc.header.cf = LV_COLOR_FORMAT_RGB565;
g_dsc.header.flags = 0;
g_dsc.header.w = CANVAS_W;
g_dsc.header.h = CANVAS_H;
g_dsc.header.stride = CANVAS_W * 2;
g_dsc.data_size = CANVAS_PIXELS * 2;
g_dsc.data = (const uint8_t *)g_pixels;
g_dsc.reserved = nullptr;
}
static void free_jpeg() {
if (g_jpeg) {
delete g_jpeg;
g_jpeg = nullptr;
}
}
static void free_png() {
if (g_png) {
delete g_png;
g_png = nullptr;
}
}
static void gif_close_session() {
if (g_gif_open && g_gif) {
g_gif->close();
g_gif_open = false;
}
if (g_gif) {
delete g_gif;
g_gif = nullptr;
}
}
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;
}
static void *jpeg_open(const char *filename, int32_t *size) {
g_file = SD.open(filename);
if (!g_file) {
return nullptr;
}
*size = (int32_t)g_file.size();
return &g_file;
}
static void jpeg_close(void *handle) {
if (handle) {
((File *)handle)->close();
}
}
static int32_t jpeg_read(JPEGFILE *handle, uint8_t *buffer, int32_t length) {
if (!handle || !handle->fHandle) {
return 0;
}
return (int32_t)((File *)handle->fHandle)->read(buffer, length);
}
static int32_t jpeg_seek(JPEGFILE *handle, int32_t position) {
if (!handle || !handle->fHandle) {
return 0;
}
return ((File *)handle->fHandle)->seek(position) ? position : 0;
}
static int jpeg_draw(JPEGDRAW *p) {
if (!p || !p->pPixels) {
return 0;
}
for (int row = 0; row < p->iHeight; row++) {
const int y = p->y + row;
if (y < 0 || y >= (int)CANVAS_H) {
continue;
}
const uint16_t *src = p->pPixels + row * p->iWidth;
for (int col = 0; col < p->iWidth; col++) {
const int x = p->x + col;
if (x < 0 || x >= (int)CANVAS_W) {
continue;
}
g_pixels[(size_t)y * CANVAS_W + (size_t)x] = src[col];
}
}
return 1;
}
static void *png_open(const char *filename, int32_t *size) {
g_file = SD.open(filename);
if (!g_file) {
return nullptr;
}
*size = (int32_t)g_file.size();
return &g_file;
}
static void png_close(void *handle) {
if (handle) {
((File *)handle)->close();
}
}
static int32_t png_read(PNGFILE *handle, uint8_t *buffer, int32_t length) {
if (!handle || !handle->fHandle) {
return 0;
}
return (int32_t)((File *)handle->fHandle)->read(buffer, length);
}
static int32_t png_seek(PNGFILE *handle, int32_t position) {
if (!handle || !handle->fHandle) {
return 0;
}
return ((File *)handle->fHandle)->seek(position) ? position : 0;
}
static int png_draw(PNGDRAW *p) {
if (!p || !g_png || g_src_w <= 0 || g_dst_w <= 0) {
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;
}
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;
}
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];
}
}
return 1;
}
static void *gif_open(const char *fname, int32_t *pSize) {
g_file = SD.open(fname);
if (!g_file) {
return nullptr;
}
*pSize = (int32_t)g_file.size();
return &g_file;
}
static void gif_close(void *pHandle) {
if (pHandle) {
((File *)pHandle)->close();
}
}
static int32_t gif_read(GIFFILE *pFile, uint8_t *pBuf, int32_t iLen) {
if (!pFile || !pFile->fHandle) {
return 0;
}
int32_t n = (int32_t)((File *)pFile->fHandle)->read(pBuf, iLen);
pFile->iPos = (int32_t)((File *)pFile->fHandle)->position();
return n;
}
static int32_t gif_seek(GIFFILE *pFile, int32_t iPosition) {
if (!pFile || !pFile->fHandle) {
return 0;
}
((File *)pFile->fHandle)->seek(iPosition);
pFile->iPos = (int32_t)((File *)pFile->fHandle)->position();
return pFile->iPos;
}
static void gif_draw(GIFDRAW *pDraw) {
if (!pDraw || g_src_w <= 0 || g_dst_w <= 0) {
return;
}
// Map source frame lines into scaled dest canvas
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;
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;
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;
}
g_pixels[(size_t)y * CANVAS_W + (size_t)x] = pDraw->pPalette[idx];
}
}
}
static bool load_jpeg(const char *path) {
gif_close_session();
free_png();
free_jpeg();
g_jpeg = new JPEGDEC();
if (!g_jpeg) {
return false;
}
if (!g_jpeg->open(path, jpeg_open, jpeg_close, jpeg_read, jpeg_seek,
jpeg_draw)) {
Serial.printf("JPEG open fail: %s err=%d\n", path, g_jpeg->getLastError());
free_jpeg();
return false;
}
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;
}
g_img_w = (uint16_t)dw;
g_img_h = (uint16_t)dh;
const int xoff = ((int)CANVAS_W - dw) / 2;
const int yoff = ((int)CANVAS_H - dh) / 2;
canvas_clear(0x0000);
g_jpeg->setPixelType(RGB565_LITTLE_ENDIAN);
const bool ok = g_jpeg->decode(xoff, yoff, scale);
g_jpeg->close();
free_jpeg();
if (!ok) {
Serial.printf("JPEG decode fail: %s\n", path);
}
return ok;
}
static bool load_png(const char *path) {
gif_close_session();
free_jpeg();
free_png();
g_png = new PNG();
if (!g_png) {
return false;
}
const int rc =
g_png->open(path, png_open, png_close, png_read, png_seek, png_draw);
if (rc != PNG_SUCCESS) {
Serial.printf("PNG open fail: %s rc=%d\n", path, rc);
free_png();
return false;
}
g_src_w = g_png->getWidth();
g_src_h = g_png->getHeight();
if (g_src_w <= 0 || g_src_h <= 0 || g_src_w > 640) {
Serial.printf("PNG bad size %dx%d\n", g_src_w, g_src_h);
g_png->close();
free_png();
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;
canvas_clear(0x0000);
const int drc = g_png->decode(nullptr, 0);
g_png->close();
free_png();
if (drc != PNG_SUCCESS) {
Serial.printf("PNG decode fail: %s rc=%d\n", path, drc);
return false;
}
return true;
}
static bool load_gif(const char *path) {
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;
canvas_clear(0x0000);
int delay_ms = 0;
g_gif->playFrame(true, &delay_ms);
if (delay_ms < 20) {
delay_ms = 100;
}
g_gif_next_ms = millis() + (uint32_t)delay_ms;
return true;
}
bool photo_view_init() {
if (g_ready) {
return true;
}
canvas_clear(0x0000);
canvas_sync_dsc();
g_ready = true;
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());
return true;
}
void photo_view_deinit() {
gif_close_session();
free_jpeg();
free_png();
g_ready = false;
g_dsc.data = nullptr;
}
bool photo_view_show(const PhotoEntry *entry) {
if (!entry || !photo_view_init()) {
return false;
}
bool ok = false;
switch (entry->kind) {
case PHOTO_JPG:
ok = load_jpeg(entry->path);
break;
case PHOTO_PNG:
ok = load_png(entry->path);
break;
case PHOTO_GIF:
ok = load_gif(entry->path);
break;
}
canvas_sync_dsc();
return ok;
}
const lv_image_dsc_t *photo_view_dsc() {
return g_ready ? &g_dsc : nullptr;
}
uint16_t photo_view_canvas_w() { return CANVAS_W; }
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_gif_tick() {
if (!g_gif_open || !g_gif) {
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;
return true;
}