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 <cursoragent@cursor.com>
This commit is contained in:
25
data/app.js
25
data/app.js
@@ -135,16 +135,31 @@ async function renderDisplay(el) {
|
||||
<h2>Metadata Overlay</h2>
|
||||
<ul class="checkbox-list">
|
||||
<li><input type="checkbox" data-flag="1" ${flags&1?'checked':''}><span>Date</span></li>
|
||||
<li><input type="checkbox" data-flag="32" ${flags&32?'checked':''}><span>Time</span></li>
|
||||
<li><input type="checkbox" data-flag="2" ${flags&2?'checked':''}><span>Location</span></li>
|
||||
<li><input type="checkbox" data-flag="4" ${flags&4?'checked':''}><span>People</span></li>
|
||||
<li><input type="checkbox" data-flag="8" ${flags&8?'checked':''}><span>Album</span></li>
|
||||
<li><input type="checkbox" data-flag="16" ${flags&16?'checked':''}><span>Camera</span></li>
|
||||
</ul>
|
||||
<div class="field">
|
||||
<label>Position</label>
|
||||
<label>Date Format</label>
|
||||
<input type="text" id="dateFmt" value="${settings.date_fmt || '%Y-%m-%d'}" placeholder="%Y-%m-%d">
|
||||
<small>strftime: %Y=year %m=month %d=day %b=Mon %B=Month</small>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Time Format</label>
|
||||
<input type="text" id="timeFmt" value="${settings.time_fmt || '%H:%M:%S'}" placeholder="%H:%M:%S">
|
||||
<small>strftime: %H=24h %I=12h %M=min %S=sec %p=AM/PM</small>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Style & Position</label>
|
||||
<select id="metaPos">
|
||||
<option value="0" ${settings.meta_pos===0?'selected':''}>Bottom</option>
|
||||
<option value="1" ${settings.meta_pos===1?'selected':''}>Top</option>
|
||||
<option value="0" ${settings.meta_pos===0?'selected':''}>Caption - Bottom</option>
|
||||
<option value="1" ${settings.meta_pos===1?'selected':''}>Caption - Top</option>
|
||||
<option value="2" ${settings.meta_pos===2?'selected':''}>Overlay - Top Left</option>
|
||||
<option value="3" ${settings.meta_pos===3?'selected':''}>Overlay - Top Right</option>
|
||||
<option value="4" ${settings.meta_pos===4?'selected':''}>Overlay - Bottom Left</option>
|
||||
<option value="5" ${settings.meta_pos===5?'selected':''}>Overlay - Bottom Right</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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');
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
79
src/main.cpp
79
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:
|
||||
|
||||
@@ -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 "";
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ public:
|
||||
String current();
|
||||
size_t size();
|
||||
bool needsResync();
|
||||
String findPortraitPair(size_t startIndex);
|
||||
String findNextPortrait();
|
||||
|
||||
private:
|
||||
ImmichClient* _client = nullptr;
|
||||
|
||||
@@ -20,6 +20,8 @@ Settings SettingsManager::get() {
|
||||
s.img_quality = static_cast<ImageQuality>(readU8("img_quality", DEFAULT_IMG_QUALITY));
|
||||
s.meta_flags = readU8("meta_flags", DEFAULT_META_FLAGS);
|
||||
s.meta_pos = static_cast<MetaPosition>(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<uint8_t>(s.img_quality));
|
||||
writeU8("meta_flags", s.meta_flags);
|
||||
writeU8("meta_pos", static_cast<uint8_t>(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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -282,6 +282,8 @@ void AppWebServer::handleGetSettings(AsyncWebServerRequest* request) {
|
||||
doc["img_quality"] = static_cast<uint8_t>(s.img_quality);
|
||||
doc["meta_flags"] = s.meta_flags;
|
||||
doc["meta_pos"] = static_cast<uint8_t>(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<ImageQuality>((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<MetaPosition>((uint8_t)doc["meta_pos"]);
|
||||
if (!doc["date_fmt"].isNull()) s.date_fmt = doc["date_fmt"].as<String>();
|
||||
if (!doc["time_fmt"].isNull()) s.time_fmt = doc["time_fmt"].as<String>();
|
||||
if (!doc["led_brightness"].isNull()) s.led_brightness = doc["led_brightness"];
|
||||
if (!doc["immich_url"].isNull()) s.immich_url = doc["immich_url"].as<String>();
|
||||
if (!doc["immich_key"].isNull()) s.immich_key = doc["immich_key"].as<String>();
|
||||
|
||||
Reference in New Issue
Block a user