From fd7643db6ac366ffa2759ae08e49dc458103ac11 Mon Sep 17 00:00:00 2001 From: cottongin Date: Mon, 3 Aug 2026 15:53:06 -0400 Subject: [PATCH] feat: blurred letterbox, portrait pairs, overlay metadata, button fixes - Letterbox: extend edge pixels outward with blur + fade to solid color - Portrait: detect and pair portraits side-by-side, fallback to solo - Metadata: add overlay mode (corner badge), time field, custom date/time format strings (strftime), font size 1.5x, region-aware for pairs - Buttons: swap G9/G10 to match physical layout, double-press random button for deep sleep, SLP indicator drawn before sleep - Boot: remove redundant 'Loading photos' screen (saves 17s refresh) Co-authored-by: Cursor --- data/app.js | 25 +++- src/button_handler.cpp | 24 +++- src/button_handler.h | 5 + src/config.h | 4 +- src/display_manager.cpp | 158 +++++++++++++++++++++++--- src/display_manager.h | 8 +- src/image_pipeline.cpp | 244 +++++++++++++++++++++++++++++++++++----- src/main.cpp | 79 +++++++++++-- src/photo_queue.cpp | 17 ++- src/photo_queue.h | 2 +- src/settings.cpp | 4 + src/settings.h | 13 ++- src/web_server.cpp | 4 + 13 files changed, 515 insertions(+), 72 deletions(-) diff --git a/data/app.js b/data/app.js index db26a45..6e4704f 100644 --- a/data/app.js +++ b/data/app.js @@ -135,16 +135,31 @@ async function renderDisplay(el) {

Metadata Overlay

- + + + strftime: %Y=year %m=month %d=day %b=Mon %B=Month +
+
+ + + strftime: %H=24h %I=12h %M=min %S=sec %p=AM/PM +
+
+
@@ -155,7 +170,9 @@ async function renderDisplay(el) { await api('/api/settings', { method: 'POST', body: JSON.stringify({ img_quality: parseInt(document.getElementById('imgQuality').value), meta_flags: flags, - meta_pos: parseInt(document.getElementById('metaPos').value) + meta_pos: parseInt(document.getElementById('metaPos').value), + date_fmt: document.getElementById('dateFmt').value, + time_fmt: document.getElementById('timeFmt').value })}); toast('Display settings saved'); }; diff --git a/src/button_handler.cpp b/src/button_handler.cpp index edf9759..8ecd600 100644 --- a/src/button_handler.cpp +++ b/src/button_handler.cpp @@ -10,7 +10,7 @@ void ButtonHandler::begin(PowerManager& power, SettingsManager& settings) { pinMode(PIN_BTN_UP, INPUT_PULLUP); pinMode(PIN_BTN_DOWN, INPUT_PULLUP); - Serial.println("[buttons] Initialized (G1=random, G9=next, G10=pause)"); + Serial.println("[buttons] Initialized (G1=random, G10=next, G9=pause)"); } ButtonEvent ButtonHandler::poll() { @@ -49,7 +49,6 @@ ButtonEvent ButtonHandler::poll() { if (holdTime >= COMBO_HOLD_MS) { Serial.println("[buttons] Combo: deep sleep"); _power->flashLED(255, 0, 0, 500); - _power->enterDeepSleep(); return ButtonEvent::DeepSleep; } } @@ -69,14 +68,31 @@ ButtonEvent ButtonHandler::poll() { return ButtonEvent::None; } - // BTN_TOP released (was short press) + // BTN_TOP released (was short press) — double-press detection if (!topNow && _btnTopPressTime > 0 && (now - _btnTopPressTime < COMBO_HOLD_MS) && (now - _btnTopPressTime > BUTTON_DEBOUNCE_MS)) { _btnTopPressTime = 0; + _lastDebounce = now; + + if (_btnTopPendingSingle && (now - _btnTopLastRelease) < DOUBLE_PRESS_WINDOW) { + // Second press within window -> deep sleep + _btnTopPendingSingle = false; + Serial.println("[buttons] BTN_TOP: double-press -> deep sleep"); + _power->flashLED(255, 0, 0, 500); + return ButtonEvent::DeepSleep; + } else { + // First press — mark as pending, wait for possible second + _btnTopLastRelease = now; + _btnTopPendingSingle = true; + } + } + + // Emit pending single press after double-press window expires + if (_btnTopPendingSingle && (now - _btnTopLastRelease) >= DOUBLE_PRESS_WINDOW) { + _btnTopPendingSingle = false; _power->flashLED(255, 255, 255); Serial.println("[buttons] BTN_TOP: random photo"); - _lastDebounce = now; return ButtonEvent::RandomPhoto; } diff --git a/src/button_handler.h b/src/button_handler.h index efbecf6..094da28 100644 --- a/src/button_handler.h +++ b/src/button_handler.h @@ -32,6 +32,11 @@ private: bool _btnUpPressed = false; bool _btnDownPressed = false; + // Double-press detection for BTN_TOP + unsigned long _btnTopLastRelease = 0; + bool _btnTopPendingSingle = false; + static constexpr unsigned long DOUBLE_PRESS_WINDOW = 400; + unsigned long _lastDebounce = 0; bool debounced(unsigned long now); diff --git a/src/config.h b/src/config.h index a242516..444193f 100644 --- a/src/config.h +++ b/src/config.h @@ -3,8 +3,8 @@ // --- Pin Definitions --- // Buttons #define PIN_BTN_TOP 1 // G1 - Random photo -#define PIN_BTN_UP 9 // G9 - Next photo -#define PIN_BTN_DOWN 10 // G10 - Play/Pause +#define PIN_BTN_UP 10 // G10 - Next photo +#define PIN_BTN_DOWN 9 // G9 - Play/Pause // RGB LEDs (NeoPixel) #define PIN_RGB_LED 21 diff --git a/src/display_manager.cpp b/src/display_manager.cpp index 8ffdc69..e319aa4 100644 --- a/src/display_manager.cpp +++ b/src/display_manager.cpp @@ -84,7 +84,6 @@ void DisplayManager::showImage(const ProcessedImage& img) { _power->enableEPDPower(); Serial.println("[display] Writing framebuffer to e-ink..."); - unsigned long start = millis(); // Write pixel by pixel using palette-mapped colors _display->startWrite(); @@ -95,13 +94,7 @@ void DisplayManager::showImage(const ProcessedImage& img) { } } _display->endWrite(); - - triggerRefresh(); - - unsigned long elapsed = millis() - start; - Serial.printf("[display] Refresh complete in %lu ms\n", elapsed); - - _power->disableEPDPower(); + // Do NOT refresh here — caller should add metadata overlay then call refresh() } void DisplayManager::showMessage(const char* title, const char* body) { @@ -121,14 +114,43 @@ void DisplayManager::showMessage(const char* title, const char* body) { _power->disableEPDPower(); } -void DisplayManager::showMetadata(const AssetInfo& info, uint8_t metaFlags, MetaPosition pos) { - if (metaFlags == 0) return; // No metadata to show +void DisplayManager::showMetadata(const AssetInfo& info, uint8_t metaFlags, MetaPosition pos, + const String& dateFmt, const String& timeFmt) { + showMetadataInRegion(info, metaFlags, pos, 0, 0, DISPLAY_WIDTH, DISPLAY_HEIGHT, dateFmt, timeFmt); +} + +void DisplayManager::showMetadataInRegion(const AssetInfo& info, uint8_t metaFlags, MetaPosition pos, + uint16_t regionX, uint16_t regionY, + uint16_t regionW, uint16_t regionH, + const String& dateFmt, const String& timeFmt) { + if (metaFlags == 0) return; // Build metadata string String metaText = ""; - if ((metaFlags & META_DATE) && info.dateTime.length() > 0) { - // Extract just the date portion (YYYY-MM-DD) + // Parse ISO datetime (YYYY-MM-DDTHH:MM:SS) into struct tm for strftime + if (((metaFlags & META_DATE) || (metaFlags & META_TIME)) && info.dateTime.length() >= 19) { + struct tm t = {}; + sscanf(info.dateTime.c_str(), "%d-%d-%dT%d:%d:%d", + &t.tm_year, &t.tm_mon, &t.tm_mday, + &t.tm_hour, &t.tm_min, &t.tm_sec); + t.tm_year -= 1900; + t.tm_mon -= 1; + + char buf[64]; + if ((metaFlags & META_DATE) && (metaFlags & META_TIME)) { + String combinedFmt = dateFmt + " " + timeFmt; + strftime(buf, sizeof(buf), combinedFmt.c_str(), &t); + metaText += buf; + } else if (metaFlags & META_DATE) { + strftime(buf, sizeof(buf), dateFmt.c_str(), &t); + metaText += buf; + } else { + strftime(buf, sizeof(buf), timeFmt.c_str(), &t); + metaText += buf; + } + } else if ((metaFlags & META_DATE) && info.dateTime.length() >= 10) { + // Fallback if datetime is too short for full parse metaText += info.dateTime.substring(0, 10); } if ((metaFlags & META_LOCATION) && info.city.length() > 0) { @@ -143,7 +165,7 @@ void DisplayManager::showMetadata(const AssetInfo& info, uint8_t metaFlags, Meta } } if ((metaFlags & META_ALBUM)) { - // Album name would need to be passed separately — skip for now + // Album name would need to be passed separately } if ((metaFlags & META_CAMERA) && info.camera.length() > 0) { if (metaText.length() > 0) metaText += " | "; @@ -152,13 +174,113 @@ void DisplayManager::showMetadata(const AssetInfo& info, uint8_t metaFlags, Meta if (metaText.length() == 0) return; - // Draw semi-transparent bar with text - uint16_t barY = (pos == MetaPosition::Top) ? 0 : (DISPLAY_HEIGHT - 30); - _display->fillRect(0, barY, DISPLAY_WIDTH, 30, TFT_BLACK); + _display->setTextSize(1.5f); _display->setTextColor(TFT_WHITE); - _display->setTextDatum(middle_center); + + switch (pos) { + case MetaPosition::CaptionBottom: + case MetaPosition::CaptionTop: { + // Bar spanning the region width, positioned at region top or bottom + uint16_t barH = 36; + uint16_t barY = (pos == MetaPosition::CaptionTop) + ? regionY + : regionY + regionH - barH; + _display->fillRect(regionX, barY, regionW, barH, TFT_BLACK); + _display->setTextDatum(middle_center); + _display->drawString(metaText.c_str(), regionX + regionW / 2, barY + barH / 2); + break; + } + case MetaPosition::OverlayTopLeft: + case MetaPosition::OverlayTopRight: + case MetaPosition::OverlayBottomLeft: + case MetaPosition::OverlayBottomRight: { + static constexpr uint16_t MARGIN = 8; + static constexpr uint16_t PAD_X = 8; + static constexpr uint16_t PAD_Y = 5; + static constexpr uint16_t TEXT_H = 20; + + uint16_t textW = _display->textWidth(metaText.c_str()); + uint16_t badgeW = min((uint16_t)(textW + PAD_X * 2), regionW); + uint16_t badgeH = TEXT_H + PAD_Y * 2; + + uint16_t bx, by; + switch (pos) { + case MetaPosition::OverlayTopLeft: + bx = regionX + MARGIN; + by = regionY + MARGIN; + break; + case MetaPosition::OverlayTopRight: + bx = regionX + regionW - badgeW - MARGIN; + by = regionY + MARGIN; + break; + case MetaPosition::OverlayBottomLeft: + bx = regionX + MARGIN; + by = regionY + regionH - badgeH - MARGIN; + break; + case MetaPosition::OverlayBottomRight: + bx = regionX + regionW - badgeW - MARGIN; + by = regionY + regionH - badgeH - MARGIN; + break; + default: + bx = regionX + MARGIN; + by = regionY + MARGIN; + break; + } + + _display->fillRect(bx, by, badgeW, badgeH, TFT_BLACK); + _display->setTextDatum(middle_left); + _display->drawString(metaText.c_str(), bx + PAD_X, by + badgeH / 2); + break; + } + } +} + +void DisplayManager::showSleepIndicator(MetaPosition metaPos) { + _power->enableEPDPower(); + + // Pick a corner opposite to metadata position + static constexpr uint16_t MARGIN = 6; + uint16_t tx, ty; + lgfx::textdatum_t datum; + + switch (metaPos) { + case MetaPosition::OverlayTopLeft: + case MetaPosition::CaptionTop: + tx = DISPLAY_WIDTH - MARGIN; + ty = DISPLAY_HEIGHT - MARGIN; + datum = bottom_right; + break; + case MetaPosition::OverlayTopRight: + tx = MARGIN; + ty = DISPLAY_HEIGHT - MARGIN; + datum = bottom_left; + break; + case MetaPosition::OverlayBottomLeft: + tx = DISPLAY_WIDTH - MARGIN; + ty = MARGIN; + datum = top_right; + break; + case MetaPosition::OverlayBottomRight: + case MetaPosition::CaptionBottom: + default: + tx = MARGIN; + ty = MARGIN; + datum = top_left; + break; + } + _display->setTextSize(1); - _display->drawString(metaText.c_str(), DISPLAY_WIDTH / 2, barY + 15); + _display->setTextColor(TFT_BLACK); + _display->setTextDatum(datum); + _display->drawString("SLP", tx, ty); +} + +void DisplayManager::refresh() { + unsigned long start = millis(); + triggerRefresh(); + unsigned long elapsed = millis() - start; + Serial.printf("[display] Refresh complete in %lu ms\n", elapsed); + _power->disableEPDPower(); } void DisplayManager::triggerRefresh() { diff --git a/src/display_manager.h b/src/display_manager.h index f7ef462..20d60b2 100644 --- a/src/display_manager.h +++ b/src/display_manager.h @@ -16,7 +16,13 @@ public: void showSetupScreen(); void showImage(const ProcessedImage& img); void showMessage(const char* title, const char* body); - void showMetadata(const AssetInfo& info, uint8_t metaFlags, MetaPosition pos); + void showMetadata(const AssetInfo& info, uint8_t metaFlags, MetaPosition pos, + const String& dateFmt = "%Y-%m-%d", const String& timeFmt = "%H:%M:%S"); + void showMetadataInRegion(const AssetInfo& info, uint8_t metaFlags, MetaPosition pos, + uint16_t regionX, uint16_t regionY, uint16_t regionW, uint16_t regionH, + const String& dateFmt = "%Y-%m-%d", const String& timeFmt = "%H:%M:%S"); + void showSleepIndicator(MetaPosition metaPos); + void refresh(); private: PowerManager* _power = nullptr; diff --git a/src/image_pipeline.cpp b/src/image_pipeline.cpp index 6a75f5e..1b1696e 100644 --- a/src/image_pipeline.cpp +++ b/src/image_pipeline.cpp @@ -123,55 +123,239 @@ static EdgeColor averageEdge(const uint8_t* rgb, uint16_t w, uint16_t h, return {(uint8_t)(sumR / count), (uint8_t)(sumG / count), (uint8_t)(sumB / count)}; } -// Fill letterbox bars in the final buffer, matching each bar to its adjacent photo edge. -// fitted image is placed at (offsetX, offsetY) with size (fitW x fitH) inside (targetW x targetH). +// In-place horizontal box blur on a rectangular sub-region of a row-major RGB buffer. +// Operates on rows from y0..y1-1, columns x0..x1-1, within a buffer of stride `stride` pixels. +static void boxBlurH(uint8_t* buf, uint16_t stride, + uint16_t x0, uint16_t y0, uint16_t x1, uint16_t y1, int radius) { + int width = x1 - x0; + if (width <= 0) return; + + // Temp row buffer for one scanline (3 bytes per pixel) + uint8_t* tmp = (uint8_t*)malloc(width * 3); + if (tmp == nullptr) return; + + int diam = radius * 2 + 1; + + for (uint16_t y = y0; y < y1; y++) { + uint8_t* row = buf + ((size_t)y * stride + x0) * 3; + + // Running sum initialization + int sumR = 0, sumG = 0, sumB = 0; + for (int i = -radius; i <= radius; i++) { + int xi = constrain(i, 0, width - 1); + sumR += row[xi * 3]; + sumG += row[xi * 3 + 1]; + sumB += row[xi * 3 + 2]; + } + tmp[0] = sumR / diam; + tmp[1] = sumG / diam; + tmp[2] = sumB / diam; + + for (int x = 1; x < width; x++) { + int addIdx = constrain(x + radius, 0, width - 1); + int remIdx = constrain(x - radius - 1, 0, width - 1); + sumR += row[addIdx * 3] - row[remIdx * 3]; + sumG += row[addIdx * 3 + 1] - row[remIdx * 3 + 1]; + sumB += row[addIdx * 3 + 2] - row[remIdx * 3 + 2]; + tmp[x * 3] = sumR / diam; + tmp[x * 3 + 1] = sumG / diam; + tmp[x * 3 + 2] = sumB / diam; + } + + memcpy(row, tmp, width * 3); + } + free(tmp); +} + +// In-place vertical box blur on a rectangular sub-region. +static void boxBlurV(uint8_t* buf, uint16_t stride, + uint16_t x0, uint16_t y0, uint16_t x1, uint16_t y1, int radius) { + int height = y1 - y0; + if (height <= 0) return; + + uint8_t* tmp = (uint8_t*)malloc(height * 3); + if (tmp == nullptr) return; + + int diam = radius * 2 + 1; + + for (uint16_t x = x0; x < x1; x++) { + // Running sum initialization + int sumR = 0, sumG = 0, sumB = 0; + for (int i = -radius; i <= radius; i++) { + int yi = y0 + constrain(i, 0, height - 1); + size_t idx = ((size_t)yi * stride + x) * 3; + sumR += buf[idx]; + sumG += buf[idx + 1]; + sumB += buf[idx + 2]; + } + tmp[0] = sumR / diam; + tmp[1] = sumG / diam; + tmp[2] = sumB / diam; + + for (int y = 1; y < height; y++) { + int addY = y0 + constrain(y + radius, 0, height - 1); + int remY = y0 + constrain(y - radius - 1, 0, height - 1); + size_t addIdx = ((size_t)addY * stride + x) * 3; + size_t remIdx = ((size_t)remY * stride + x) * 3; + sumR += buf[addIdx] - buf[remIdx]; + sumG += buf[addIdx + 1] - buf[remIdx + 1]; + sumB += buf[addIdx + 2] - buf[remIdx + 2]; + tmp[y * 3] = sumR / diam; + tmp[y * 3 + 1] = sumG / diam; + tmp[y * 3 + 2] = sumB / diam; + } + + // Write back + for (int y = 0; y < height; y++) { + size_t idx = ((size_t)(y0 + y) * stride + x) * 3; + buf[idx] = tmp[y * 3]; + buf[idx + 1] = tmp[y * 3 + 1]; + buf[idx + 2] = tmp[y * 3 + 2]; + } + } + free(tmp); +} + +// 3-pass box blur (approximates Gaussian) on a sub-region +static void gaussianBlurRegion(uint8_t* buf, uint16_t stride, + uint16_t x0, uint16_t y0, uint16_t x1, uint16_t y1, + int radius) { + for (int pass = 0; pass < 3; pass++) { + boxBlurH(buf, stride, x0, y0, x1, y1, radius); + boxBlurV(buf, stride, x0, y0, x1, y1, radius); + } +} + +// Fill letterbox bars with mirrored + blurred + faded content from the photo edge. +// The fitted image must already be placed in final_buf at (offsetX, offsetY). static void fillLetterbox(uint8_t* final_buf, uint16_t targetW, uint16_t targetH, const uint8_t* fitted, uint16_t fitW, uint16_t fitH, uint16_t offsetX, uint16_t offsetY) { + static constexpr int BLUR_RADIUS = 6; + if (offsetY > 0) { - // Horizontal letterbox (top and bottom bars) - EdgeColor top = averageEdge(fitted, fitW, fitH, 0); - EdgeColor bot = averageEdge(fitted, fitW, fitH, 1); + // Horizontal bars (top and bottom) + EdgeColor topEdge = averageEdge(fitted, fitW, fitH, 0); + EdgeColor botEdge = averageEdge(fitted, fitW, fitH, 1); - // Fill top bar - for (uint16_t y = 0; y < offsetY; y++) + // --- Extend top edge into top bar --- + // Every row in the bar copies from the photo's topmost row (row 0) + for (uint16_t y = 0; y < offsetY; y++) { for (uint16_t x = 0; x < targetW; x++) { - size_t idx = ((size_t)y * targetW + x) * 3; - final_buf[idx] = top.r; final_buf[idx+1] = top.g; final_buf[idx+2] = top.b; + int srcX = (int)x - (int)offsetX; + srcX = constrain(srcX, 0, (int)fitW - 1); + size_t srcIdx = ((size_t)0 * fitW + srcX) * 3; // always row 0 + size_t dstIdx = ((size_t)y * targetW + x) * 3; + final_buf[dstIdx] = fitted[srcIdx]; + final_buf[dstIdx + 1] = fitted[srcIdx + 1]; + final_buf[dstIdx + 2] = fitted[srcIdx + 2]; } - // Fill bottom bar + } + + // --- Extend bottom edge into bottom bar --- + // Every row copies from the photo's bottommost row (fitH - 1) uint16_t botStart = offsetY + fitH; - for (uint16_t y = botStart; y < targetH; y++) + uint16_t lastRow = fitH - 1; + for (uint16_t y = botStart; y < targetH; y++) { + for (uint16_t x = 0; x < targetW; x++) { + int srcX = (int)x - (int)offsetX; + srcX = constrain(srcX, 0, (int)fitW - 1); + size_t srcIdx = ((size_t)lastRow * fitW + srcX) * 3; + size_t dstIdx = ((size_t)y * targetW + x) * 3; + final_buf[dstIdx] = fitted[srcIdx]; + final_buf[dstIdx + 1] = fitted[srcIdx + 1]; + final_buf[dstIdx + 2] = fitted[srcIdx + 2]; + } + } + + // --- Blur both bars --- + gaussianBlurRegion(final_buf, targetW, 0, 0, targetW, offsetY, BLUR_RADIUS); + gaussianBlurRegion(final_buf, targetW, 0, botStart, targetW, targetH, BLUR_RADIUS); + + // --- Fade toward solid edge color --- + for (uint16_t y = 0; y < offsetY; y++) { + float alpha = (float)(offsetY - 1 - y) / (float)offsetY; // 0 at photo, 1 at screen edge for (uint16_t x = 0; x < targetW; x++) { size_t idx = ((size_t)y * targetW + x) * 3; - final_buf[idx] = bot.r; final_buf[idx+1] = bot.g; final_buf[idx+2] = bot.b; + final_buf[idx] = (uint8_t)(final_buf[idx] * (1.0f - alpha) + topEdge.r * alpha); + final_buf[idx + 1] = (uint8_t)(final_buf[idx + 1] * (1.0f - alpha) + topEdge.g * alpha); + final_buf[idx + 2] = (uint8_t)(final_buf[idx + 2] * (1.0f - alpha) + topEdge.b * alpha); } + } + for (uint16_t y = botStart; y < targetH; y++) { + float alpha = (float)(y - botStart) / (float)(targetH - botStart); + for (uint16_t x = 0; x < targetW; x++) { + size_t idx = ((size_t)y * targetW + x) * 3; + final_buf[idx] = (uint8_t)(final_buf[idx] * (1.0f - alpha) + botEdge.r * alpha); + final_buf[idx + 1] = (uint8_t)(final_buf[idx + 1] * (1.0f - alpha) + botEdge.g * alpha); + final_buf[idx + 2] = (uint8_t)(final_buf[idx + 2] * (1.0f - alpha) + botEdge.b * alpha); + } + } - Serial.printf("[pipeline] Letterbox TB: top=#%02X%02X%02X bot=#%02X%02X%02X\n", - top.r, top.g, top.b, bot.r, bot.g, bot.b); + Serial.printf("[pipeline] Letterbox TB: bars %dpx, top=#%02X%02X%02X bot=#%02X%02X%02X\n", + offsetY, topEdge.r, topEdge.g, topEdge.b, botEdge.r, botEdge.g, botEdge.b); } if (offsetX > 0) { - // Vertical letterbox (left and right bars) - EdgeColor left = averageEdge(fitted, fitW, fitH, 2); - EdgeColor right = averageEdge(fitted, fitW, fitH, 3); + // Vertical bars (left and right) + EdgeColor leftEdge = averageEdge(fitted, fitW, fitH, 2); + EdgeColor rightEdge = averageEdge(fitted, fitW, fitH, 3); - // Fill left bar - for (uint16_t y = 0; y < targetH; y++) + // --- Extend left edge into left bar --- + // Every column in the bar copies from the photo's leftmost column (col 0) + for (uint16_t y = 0; y < targetH; y++) { + int srcY = (int)y - (int)offsetY; + srcY = constrain(srcY, 0, (int)fitH - 1); + size_t srcIdx = ((size_t)srcY * fitW + 0) * 3; // always column 0 for (uint16_t x = 0; x < offsetX; x++) { - size_t idx = ((size_t)y * targetW + x) * 3; - final_buf[idx] = left.r; final_buf[idx+1] = left.g; final_buf[idx+2] = left.b; - } - // Fill right bar - uint16_t rightStart = offsetX + fitW; - for (uint16_t y = 0; y < targetH; y++) - for (uint16_t x = rightStart; x < targetW; x++) { - size_t idx = ((size_t)y * targetW + x) * 3; - final_buf[idx] = right.r; final_buf[idx+1] = right.g; final_buf[idx+2] = right.b; + size_t dstIdx = ((size_t)y * targetW + x) * 3; + final_buf[dstIdx] = fitted[srcIdx]; + final_buf[dstIdx + 1] = fitted[srcIdx + 1]; + final_buf[dstIdx + 2] = fitted[srcIdx + 2]; } + } - Serial.printf("[pipeline] Letterbox LR: left=#%02X%02X%02X right=#%02X%02X%02X\n", - left.r, left.g, left.b, right.r, right.g, right.b); + // --- Extend right edge into right bar --- + // Every column copies from the photo's rightmost column (fitW - 1) + uint16_t rightStart = offsetX + fitW; + uint16_t lastCol = fitW - 1; + for (uint16_t y = 0; y < targetH; y++) { + int srcY = (int)y - (int)offsetY; + srcY = constrain(srcY, 0, (int)fitH - 1); + size_t srcIdx = ((size_t)srcY * fitW + lastCol) * 3; + for (uint16_t x = rightStart; x < targetW; x++) { + size_t dstIdx = ((size_t)y * targetW + x) * 3; + final_buf[dstIdx] = fitted[srcIdx]; + final_buf[dstIdx + 1] = fitted[srcIdx + 1]; + final_buf[dstIdx + 2] = fitted[srcIdx + 2]; + } + } + + // --- Blur both bars --- + gaussianBlurRegion(final_buf, targetW, 0, 0, offsetX, targetH, BLUR_RADIUS); + gaussianBlurRegion(final_buf, targetW, rightStart, 0, targetW, targetH, BLUR_RADIUS); + + // --- Fade toward solid edge color --- + for (uint16_t y = 0; y < targetH; y++) { + for (uint16_t x = 0; x < offsetX; x++) { + float alpha = (float)(offsetX - 1 - x) / (float)offsetX; + size_t idx = ((size_t)y * targetW + x) * 3; + final_buf[idx] = (uint8_t)(final_buf[idx] * (1.0f - alpha) + leftEdge.r * alpha); + final_buf[idx + 1] = (uint8_t)(final_buf[idx + 1] * (1.0f - alpha) + leftEdge.g * alpha); + final_buf[idx + 2] = (uint8_t)(final_buf[idx + 2] * (1.0f - alpha) + leftEdge.b * alpha); + } + for (uint16_t x = rightStart; x < targetW; x++) { + float alpha = (float)(x - rightStart) / (float)(targetW - rightStart); + size_t idx = ((size_t)y * targetW + x) * 3; + final_buf[idx] = (uint8_t)(final_buf[idx] * (1.0f - alpha) + rightEdge.r * alpha); + final_buf[idx + 1] = (uint8_t)(final_buf[idx + 1] * (1.0f - alpha) + rightEdge.g * alpha); + final_buf[idx + 2] = (uint8_t)(final_buf[idx + 2] * (1.0f - alpha) + rightEdge.b * alpha); + } + } + + Serial.printf("[pipeline] Letterbox LR: bars %dpx, left=#%02X%02X%02X right=#%02X%02X%02X\n", + offsetX, leftEdge.r, leftEdge.g, leftEdge.b, + rightEdge.r, rightEdge.g, rightEdge.b); } } diff --git a/src/main.cpp b/src/main.cpp index 28342d9..e366538 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -106,7 +106,7 @@ void displayTask(void* param) { // Fetch asset info for portrait detection and metadata AssetInfo info = immichClient.fetchAssetInfo(assetId); - // Download photo + // Download primary photo uint8_t* jpegBuf = nullptr; size_t jpegSize = 0; bool downloaded = immichClient.downloadAsset( @@ -121,20 +121,77 @@ void displayTask(void* param) { (unsigned)(jpegSize / 1024), (unsigned)(heap_caps_get_free_size(MALLOC_CAP_SPIRAM) / 1024)); - ProcessedImage img = imagePipeline.process(jpegBuf, jpegSize); + ProcessedImage img = {nullptr, 0, 0, false}; + + if (info.isPortrait) { + // Try to find a second portrait for side-by-side display + Serial.println("[display_task] Portrait detected, searching for pair"); + String pairId = photoQueue.findNextPortrait(); + + if (pairId.length() > 0) { + // Fetch info for second portrait (for metadata) + AssetInfo info2 = immichClient.fetchAssetInfo(pairId); + + // Download the second portrait + uint8_t* jpeg2Buf = nullptr; + size_t jpeg2Size = 0; + bool dl2 = immichClient.downloadAsset( + pairId, s.img_quality, &jpeg2Buf, &jpeg2Size); + + if (dl2 && jpeg2Buf != nullptr) { + Serial.printf("[display_task] Portrait pair: %u KB + %u KB\n", + (unsigned)(jpegSize / 1024), + (unsigned)(jpeg2Size / 1024)); + img = imagePipeline.processPortraitPair( + jpegBuf, jpegSize, jpeg2Buf, jpeg2Size); + free(jpeg2Buf); + + if (img.valid) { + uint16_t halfW = (DISPLAY_WIDTH - PORTRAIT_GAP_PX) / 2; + uint16_t rightX = halfW + PORTRAIT_GAP_PX; + displayManager.showImage(img); + if (s.meta_flags != 0) { + displayManager.showMetadataInRegion( + info, s.meta_flags, s.meta_pos, + 0, 0, halfW, DISPLAY_HEIGHT, + s.date_fmt, s.time_fmt); + displayManager.showMetadataInRegion( + info2, s.meta_flags, s.meta_pos, + rightX, 0, halfW, DISPLAY_HEIGHT, + s.date_fmt, s.time_fmt); + } + displayManager.refresh(); + imagePipeline.freeImage(img); + displayed = true; + } + } else { + Serial.println("[display_task] Pair download failed, showing solo"); + img = imagePipeline.process(jpegBuf, jpegSize); + } + } else { + // No pair found — show solo portrait (will get L/R letterbox) + img = imagePipeline.process(jpegBuf, jpegSize); + } + } else { + // Normal landscape processing + img = imagePipeline.process(jpegBuf, jpegSize); + } + free(jpegBuf); Serial.printf("[display_task] Post-process PSRAM free: %u KB\n", (unsigned)(heap_caps_get_free_size(MALLOC_CAP_SPIRAM) / 1024)); - if (img.valid) { + if (img.valid && !displayed) { displayManager.showImage(img); if (s.meta_flags != 0) { - displayManager.showMetadata(info, s.meta_flags, s.meta_pos); + displayManager.showMetadata(info, s.meta_flags, s.meta_pos, + s.date_fmt, s.time_fmt); } + displayManager.refresh(); imagePipeline.freeImage(img); displayed = true; - } else { + } else if (!img.valid && !displayed) { Serial.println("[display_task] Processing failed, trying next"); } } @@ -206,7 +263,8 @@ void setup() { if (wifiManager.isAPMode()) { displayManager.showSetupScreen(); } else { - displayManager.showMessage("Immich Frame", "Loading photos..."); + // Skip "Loading photos" screen — the display task will show the first photo + // directly, avoiding a redundant 17s e-ink refresh cycle on boot. // Create display task on Core 1 xTaskCreatePinnedToCore(displayTask, "display", 32768, nullptr, 1, @@ -245,7 +303,14 @@ void loop() { xSemaphoreGive(stateMutex); } break; - case ButtonEvent::DeepSleep: + case ButtonEvent::DeepSleep: { + Serial.println("[main] Entering deep sleep..."); + Settings sleepS = settingsManager.get(); + displayManager.showSleepIndicator(sleepS.meta_pos); + displayManager.refresh(); + powerManager.enterDeepSleep(); + break; + } case ButtonEvent::FactoryReset: break; case ButtonEvent::None: diff --git a/src/photo_queue.cpp b/src/photo_queue.cpp index 0e76f62..ea0dd73 100644 --- a/src/photo_queue.cpp +++ b/src/photo_queue.cpp @@ -141,10 +141,21 @@ bool PhotoQueue::needsResync() { return elapsed >= (QUEUE_RESYNC_HOURS * 3600000UL); } -String PhotoQueue::findPortraitPair(size_t startIndex) { - for (size_t i = 1; i <= PORTRAIT_LOOKAHEAD && (startIndex + i) < _queue.size(); i++) { - return _queue[startIndex + i]; +String PhotoQueue::findNextPortrait() { + if (_client == nullptr) return ""; + + size_t scanEnd = min(_cursor + PORTRAIT_LOOKAHEAD, _queue.size()); + for (size_t i = _cursor; i < scanEnd; i++) { + AssetInfo info = _client->fetchAssetInfo(_queue[i]); + if (info.isPortrait) { + String id = _queue[i]; + _queue.erase(_queue.begin() + i); + Serial.printf("[queue] Found portrait pair: %s (scanned %d ahead)\n", + id.c_str(), (int)(i - _cursor + 1)); + return id; + } } + Serial.println("[queue] No portrait pair found in lookahead"); return ""; } diff --git a/src/photo_queue.h b/src/photo_queue.h index 0a2d208..d5c0201 100644 --- a/src/photo_queue.h +++ b/src/photo_queue.h @@ -15,7 +15,7 @@ public: String current(); size_t size(); bool needsResync(); - String findPortraitPair(size_t startIndex); + String findNextPortrait(); private: ImmichClient* _client = nullptr; diff --git a/src/settings.cpp b/src/settings.cpp index 2a432ca..d2b1c6a 100644 --- a/src/settings.cpp +++ b/src/settings.cpp @@ -20,6 +20,8 @@ Settings SettingsManager::get() { s.img_quality = static_cast(readU8("img_quality", DEFAULT_IMG_QUALITY)); s.meta_flags = readU8("meta_flags", DEFAULT_META_FLAGS); s.meta_pos = static_cast(readU8("meta_pos", DEFAULT_META_POS)); + s.date_fmt = readString("date_fmt", "%Y-%m-%d"); + s.time_fmt = readString("time_fmt", "%H:%M:%S"); s.led_brightness = readU8("led_bright", DEFAULT_LED_BRIGHTNESS); s.queue_cursor = readU32("queue_cursor", 0); s.albums_json = readString("albums_json", "[]"); @@ -36,6 +38,8 @@ void SettingsManager::save(const Settings& s) { writeU8("img_quality", static_cast(s.img_quality)); writeU8("meta_flags", s.meta_flags); writeU8("meta_pos", static_cast(s.meta_pos)); + writeString("date_fmt", s.date_fmt); + writeString("time_fmt", s.time_fmt); writeU8("led_bright", s.led_brightness); writeU32("queue_cursor", s.queue_cursor); writeString("albums_json", s.albums_json); diff --git a/src/settings.h b/src/settings.h index 34e9c7e..b134e61 100644 --- a/src/settings.h +++ b/src/settings.h @@ -16,12 +16,19 @@ enum class ImageQuality : uint8_t { }; enum class MetaPosition : uint8_t { - Bottom = 0, - Top = 1 + // Captioned mode (full-width bar) + CaptionBottom = 0, + CaptionTop = 1, + // Overlay mode (corner badge) + OverlayTopLeft = 2, + OverlayTopRight = 3, + OverlayBottomLeft = 4, + OverlayBottomRight = 5 }; // Metadata flags bitmask constexpr uint8_t META_DATE = 0x01; +constexpr uint8_t META_TIME = 0x20; constexpr uint8_t META_LOCATION = 0x02; constexpr uint8_t META_PEOPLE = 0x04; constexpr uint8_t META_ALBUM = 0x08; @@ -44,6 +51,8 @@ struct Settings { // Display uint8_t meta_flags; MetaPosition meta_pos; + String date_fmt; // strftime-style, default "%Y-%m-%d" + String time_fmt; // strftime-style, default "%H:%M:%S" // Device uint8_t led_brightness; diff --git a/src/web_server.cpp b/src/web_server.cpp index a787e0c..9dbf48d 100644 --- a/src/web_server.cpp +++ b/src/web_server.cpp @@ -282,6 +282,8 @@ void AppWebServer::handleGetSettings(AsyncWebServerRequest* request) { doc["img_quality"] = static_cast(s.img_quality); doc["meta_flags"] = s.meta_flags; doc["meta_pos"] = static_cast(s.meta_pos); + doc["date_fmt"] = s.date_fmt; + doc["time_fmt"] = s.time_fmt; doc["led_brightness"] = s.led_brightness; doc["immich_url"] = s.immich_url; doc["immich_key"] = s.immich_key; @@ -308,6 +310,8 @@ void AppWebServer::handlePostSettings(AsyncWebServerRequest* request, if (!doc["img_quality"].isNull()) s.img_quality = static_cast((uint8_t)doc["img_quality"]); if (!doc["meta_flags"].isNull()) s.meta_flags = doc["meta_flags"]; if (!doc["meta_pos"].isNull()) s.meta_pos = static_cast((uint8_t)doc["meta_pos"]); + if (!doc["date_fmt"].isNull()) s.date_fmt = doc["date_fmt"].as(); + if (!doc["time_fmt"].isNull()) s.time_fmt = doc["time_fmt"].as(); if (!doc["led_brightness"].isNull()) s.led_brightness = doc["led_brightness"]; if (!doc["immich_url"].isNull()) s.immich_url = doc["immich_url"].as(); if (!doc["immich_key"].isNull()) s.immich_key = doc["immich_key"].as();