feat: pre-render next photo for instant slideshow transitions
Refactor displayTask() into two phases: display and pre-render. After showing a photo, the next one is immediately downloaded, decoded, and dithered into a pending framebuffer (~240KB PSRAM). When the slideshow timer fires, the EPD refresh starts instantly without the previous 2-5s pipeline delay. - Add PendingPhoto struct with settings hash for staleness guard - Extract displayPhoto() and prepareNextPhoto() helpers - Manual next/random discards pre-render and goes inline - Falls back to inline pipeline if pre-render unavailable - No changes to ImagePipeline, DisplayManager, or other modules Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
335
src/main.cpp
335
src/main.cpp
@@ -27,16 +27,171 @@ ImagePipeline imagePipeline;
|
|||||||
DisplayManager displayManager;
|
DisplayManager displayManager;
|
||||||
AppWebServer webServer;
|
AppWebServer webServer;
|
||||||
|
|
||||||
|
// Pre-rendered photo ready for instant display on next slideshow tick
|
||||||
|
struct PendingPhoto {
|
||||||
|
ProcessedImage img;
|
||||||
|
AssetInfo info;
|
||||||
|
AssetInfo info2;
|
||||||
|
bool isPair;
|
||||||
|
bool ready;
|
||||||
|
uint32_t settingsHash;
|
||||||
|
};
|
||||||
|
|
||||||
|
static uint32_t computeSettingsHash(const Settings& s) {
|
||||||
|
return (uint32_t)s.pipeline_mode
|
||||||
|
| ((uint32_t)s.dither_noise << 8)
|
||||||
|
| ((uint32_t)s.img_quality << 16);
|
||||||
|
}
|
||||||
|
|
||||||
// Shared state
|
// Shared state
|
||||||
SemaphoreHandle_t stateMutex;
|
SemaphoreHandle_t stateMutex;
|
||||||
volatile bool slideshowPlaying = true;
|
volatile bool slideshowPlaying = true;
|
||||||
volatile bool refreshRequested = false;
|
volatile bool refreshRequested = false;
|
||||||
volatile bool randomRequested = false;
|
volatile bool randomRequested = false;
|
||||||
volatile unsigned long lastRefreshTime = 0;
|
volatile unsigned long lastRefreshTime = 0;
|
||||||
|
PendingPhoto pending = {{nullptr, 0, 0, false}, {}, {}, false, false, 0};
|
||||||
|
|
||||||
// Task handles
|
// Task handles
|
||||||
TaskHandle_t displayTaskHandle = nullptr;
|
TaskHandle_t displayTaskHandle = nullptr;
|
||||||
|
|
||||||
|
// Display a ProcessedImage with metadata overlays, trigger EPD refresh, and free the framebuffer.
|
||||||
|
// Callers provide the image, metadata info, and current settings. Returns true if displayed.
|
||||||
|
static bool displayPhoto(ProcessedImage& img, const AssetInfo& info, const AssetInfo& info2,
|
||||||
|
bool isPair, const Settings& s) {
|
||||||
|
if (!img.valid) return false;
|
||||||
|
|
||||||
|
if (isPair) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
displayManager.showImage(img);
|
||||||
|
if (s.meta_flags != 0) {
|
||||||
|
displayManager.showMetadata(info, s.meta_flags, s.meta_pos,
|
||||||
|
s.date_fmt, s.time_fmt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (s.show_battery) {
|
||||||
|
uint8_t battPct = powerManager.getBatteryPercent();
|
||||||
|
displayManager.showBatteryIndicator(battPct, s.meta_pos);
|
||||||
|
}
|
||||||
|
|
||||||
|
displayManager.refresh();
|
||||||
|
imagePipeline.freeImage(img);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch, download, decode, and dither one photo (or portrait pair) from the queue.
|
||||||
|
// On success, fills out the PendingPhoto fields and returns true.
|
||||||
|
// On failure after MAX_RETRIES attempts, returns false with pending.ready untouched.
|
||||||
|
static bool prepareNextPhoto(PendingPhoto& out, const Settings& s, bool wantsRandom) {
|
||||||
|
static constexpr int MAX_RETRIES = 10;
|
||||||
|
|
||||||
|
imagePipeline.setPipelineMode(static_cast<PipelineMode>(s.pipeline_mode));
|
||||||
|
imagePipeline.setBlueNoiseEnabled(s.dither_noise != 0);
|
||||||
|
|
||||||
|
for (int attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
||||||
|
String assetId = wantsRandom ? photoQueue.random() : photoQueue.next();
|
||||||
|
if (assetId.length() == 0) break;
|
||||||
|
|
||||||
|
Serial.printf("[prepare] Loading asset: %s (attempt %d)\n",
|
||||||
|
assetId.c_str(), attempt + 1);
|
||||||
|
|
||||||
|
AssetInfo info = immichClient.fetchAssetInfo(assetId);
|
||||||
|
|
||||||
|
uint8_t* jpegBuf = nullptr;
|
||||||
|
size_t jpegSize = 0;
|
||||||
|
if (!immichClient.downloadAsset(assetId, s.img_quality, &jpegBuf, &jpegSize)
|
||||||
|
|| jpegBuf == nullptr) {
|
||||||
|
Serial.println("[prepare] Download failed, trying next");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
Serial.printf("[prepare] JPEG: %u KB, PSRAM free: %u KB\n",
|
||||||
|
(unsigned)(jpegSize / 1024),
|
||||||
|
(unsigned)(heap_caps_get_free_size(MALLOC_CAP_SPIRAM) / 1024));
|
||||||
|
|
||||||
|
ProcessedImage img = {nullptr, 0, 0, false};
|
||||||
|
AssetInfo info2 = {};
|
||||||
|
bool isPair = false;
|
||||||
|
|
||||||
|
if (info.isPortrait) {
|
||||||
|
Serial.println("[prepare] Portrait detected, searching for pair");
|
||||||
|
String pairId = photoQueue.findNextPortrait();
|
||||||
|
|
||||||
|
if (pairId.length() > 0) {
|
||||||
|
info2 = immichClient.fetchAssetInfo(pairId);
|
||||||
|
|
||||||
|
uint8_t* jpeg2Buf = nullptr;
|
||||||
|
size_t jpeg2Size = 0;
|
||||||
|
bool dl2 = immichClient.downloadAsset(
|
||||||
|
pairId, s.img_quality, &jpeg2Buf, &jpeg2Size);
|
||||||
|
|
||||||
|
if (dl2 && jpeg2Buf != nullptr) {
|
||||||
|
Serial.printf("[prepare] Portrait pair: %u KB + %u KB\n",
|
||||||
|
(unsigned)(jpegSize / 1024),
|
||||||
|
(unsigned)(jpeg2Size / 1024));
|
||||||
|
img = imagePipeline.processPortraitPair(
|
||||||
|
jpegBuf, jpegSize, jpeg2Buf, jpeg2Size);
|
||||||
|
free(jpeg2Buf);
|
||||||
|
isPair = img.valid;
|
||||||
|
|
||||||
|
if (!img.valid) {
|
||||||
|
Serial.println("[prepare] Pair processing failed, showing solo");
|
||||||
|
img = imagePipeline.process(jpegBuf, jpegSize);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Serial.println("[prepare] Pair download failed, showing solo");
|
||||||
|
img = imagePipeline.process(jpegBuf, jpegSize);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
img = imagePipeline.process(jpegBuf, jpegSize);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
img = imagePipeline.process(jpegBuf, jpegSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
free(jpegBuf);
|
||||||
|
|
||||||
|
Serial.printf("[prepare] Post-process PSRAM free: %u KB\n",
|
||||||
|
(unsigned)(heap_caps_get_free_size(MALLOC_CAP_SPIRAM) / 1024));
|
||||||
|
|
||||||
|
if (img.valid) {
|
||||||
|
out.img = img;
|
||||||
|
out.info = info;
|
||||||
|
out.info2 = info2;
|
||||||
|
out.isPair = isPair;
|
||||||
|
out.settingsHash = computeSettingsHash(s);
|
||||||
|
out.ready = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Serial.println("[prepare] Processing failed, trying next");
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Discard a pre-rendered pending photo, freeing its framebuffer.
|
||||||
|
static void discardPending(PendingPhoto& p) {
|
||||||
|
if (p.ready && p.img.valid) {
|
||||||
|
Serial.println("[prerender] Discarding stale pending image");
|
||||||
|
imagePipeline.freeImage(p.img);
|
||||||
|
}
|
||||||
|
p.ready = false;
|
||||||
|
}
|
||||||
|
|
||||||
void displayTask(void* param) {
|
void displayTask(void* param) {
|
||||||
Serial.println("[display_task] Started on Core 1");
|
Serial.println("[display_task] Started on Core 1");
|
||||||
|
|
||||||
@@ -48,7 +203,6 @@ void displayTask(void* param) {
|
|||||||
|
|
||||||
if (photoQueue.sync()) {
|
if (photoQueue.sync()) {
|
||||||
Serial.printf("[display_task] Queue ready: %d photos\n", photoQueue.size());
|
Serial.printf("[display_task] Queue ready: %d photos\n", photoQueue.size());
|
||||||
// Trigger first photo immediately
|
|
||||||
if (xSemaphoreTake(stateMutex, pdMS_TO_TICKS(100)) == pdTRUE) {
|
if (xSemaphoreTake(stateMutex, pdMS_TO_TICKS(100)) == pdTRUE) {
|
||||||
refreshRequested = true;
|
refreshRequested = true;
|
||||||
xSemaphoreGive(stateMutex);
|
xSemaphoreGive(stateMutex);
|
||||||
@@ -61,21 +215,22 @@ void displayTask(void* param) {
|
|||||||
while (true) {
|
while (true) {
|
||||||
unsigned long now = millis();
|
unsigned long now = millis();
|
||||||
Settings s = settingsManager.get();
|
Settings s = settingsManager.get();
|
||||||
imagePipeline.setPipelineMode(static_cast<PipelineMode>(s.pipeline_mode));
|
|
||||||
imagePipeline.setBlueNoiseEnabled(s.dither_noise != 0);
|
|
||||||
displayManager.setTranslucent(s.meta_translucent != 0);
|
displayManager.setTranslucent(s.meta_translucent != 0);
|
||||||
unsigned long intervalMs = s.interval_min * 60000UL;
|
unsigned long intervalMs = s.interval_min * 60000UL;
|
||||||
|
|
||||||
bool shouldRefresh = false;
|
bool shouldRefresh = false;
|
||||||
bool wantsRandom = false;
|
bool wantsRandom = false;
|
||||||
|
bool isManual = false;
|
||||||
|
|
||||||
if (xSemaphoreTake(stateMutex, pdMS_TO_TICKS(100)) == pdTRUE) {
|
if (xSemaphoreTake(stateMutex, pdMS_TO_TICKS(100)) == pdTRUE) {
|
||||||
if (refreshRequested) {
|
if (refreshRequested) {
|
||||||
shouldRefresh = true;
|
shouldRefresh = true;
|
||||||
|
isManual = true;
|
||||||
refreshRequested = false;
|
refreshRequested = false;
|
||||||
} else if (randomRequested) {
|
} else if (randomRequested) {
|
||||||
shouldRefresh = true;
|
shouldRefresh = true;
|
||||||
wantsRandom = true;
|
wantsRandom = true;
|
||||||
|
isManual = true;
|
||||||
randomRequested = false;
|
randomRequested = false;
|
||||||
} else if (slideshowPlaying && (now - lastRefreshTime >= intervalMs)) {
|
} else if (slideshowPlaying && (now - lastRefreshTime >= intervalMs)) {
|
||||||
shouldRefresh = true;
|
shouldRefresh = true;
|
||||||
@@ -88,122 +243,38 @@ void displayTask(void* param) {
|
|||||||
photoQueue.sync();
|
photoQueue.sync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Phase 1: Display ---
|
||||||
if (shouldRefresh && photoQueue.size() > 0 && wifiManager.isConnected()) {
|
if (shouldRefresh && photoQueue.size() > 0 && wifiManager.isConnected()) {
|
||||||
// Try up to 3 assets in case some fail to decode
|
|
||||||
static constexpr int MAX_RETRIES = 10;
|
|
||||||
bool displayed = false;
|
bool displayed = false;
|
||||||
|
uint32_t currentHash = computeSettingsHash(s);
|
||||||
|
|
||||||
for (int attempt = 0; attempt < MAX_RETRIES && !displayed; attempt++) {
|
if (!isManual && pending.ready && pending.settingsHash == currentHash) {
|
||||||
String assetId;
|
// Use pre-rendered image — instant display
|
||||||
if (wantsRandom) {
|
Serial.println("[display_task] Using pre-rendered image");
|
||||||
assetId = photoQueue.random();
|
displayed = displayPhoto(pending.img, pending.info, pending.info2,
|
||||||
} else {
|
pending.isPair, s);
|
||||||
assetId = photoQueue.next();
|
pending.ready = false;
|
||||||
}
|
} else {
|
||||||
|
// Manual request, stale settings, or no pre-render available — inline pipeline
|
||||||
if (assetId.length() == 0) break;
|
if (pending.ready) {
|
||||||
|
if (isManual) {
|
||||||
Serial.printf("[display_task] Loading asset: %s (attempt %d)\n",
|
Serial.println("[display_task] Manual request — discarding pre-render");
|
||||||
assetId.c_str(), attempt + 1);
|
|
||||||
|
|
||||||
// Fetch asset info for portrait detection and metadata
|
|
||||||
AssetInfo info = immichClient.fetchAssetInfo(assetId);
|
|
||||||
|
|
||||||
// Download primary photo
|
|
||||||
uint8_t* jpegBuf = nullptr;
|
|
||||||
size_t jpegSize = 0;
|
|
||||||
bool downloaded = immichClient.downloadAsset(
|
|
||||||
assetId, s.img_quality, &jpegBuf, &jpegSize);
|
|
||||||
|
|
||||||
if (!downloaded || jpegBuf == nullptr) {
|
|
||||||
Serial.println("[display_task] Download failed, trying next");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
Serial.printf("[display_task] JPEG: %u KB, PSRAM free: %u KB\n",
|
|
||||||
(unsigned)(jpegSize / 1024),
|
|
||||||
(unsigned)(heap_caps_get_free_size(MALLOC_CAP_SPIRAM) / 1024));
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
if (s.show_battery) {
|
|
||||||
uint8_t battPct = powerManager.getBatteryPercent();
|
|
||||||
displayManager.showBatteryIndicator(battPct, s.meta_pos);
|
|
||||||
}
|
|
||||||
displayManager.refresh();
|
|
||||||
imagePipeline.freeImage(img);
|
|
||||||
displayed = true;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Serial.println("[display_task] Pair download failed, showing solo");
|
|
||||||
img = imagePipeline.process(jpegBuf, jpegSize);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// No pair found — show solo portrait (will get L/R letterbox)
|
Serial.printf("[display_task] Settings changed (0x%08X -> 0x%08X) — discarding pre-render\n",
|
||||||
img = imagePipeline.process(jpegBuf, jpegSize);
|
pending.settingsHash, currentHash);
|
||||||
}
|
}
|
||||||
|
discardPending(pending);
|
||||||
} else {
|
} else {
|
||||||
// Normal landscape processing
|
Serial.println("[display_task] No pre-render available — inline fallback");
|
||||||
img = imagePipeline.process(jpegBuf, jpegSize);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
free(jpegBuf);
|
imagePipeline.setPipelineMode(static_cast<PipelineMode>(s.pipeline_mode));
|
||||||
|
imagePipeline.setBlueNoiseEnabled(s.dither_noise != 0);
|
||||||
|
|
||||||
Serial.printf("[display_task] Post-process PSRAM free: %u KB\n",
|
PendingPhoto inlinePhoto = {{nullptr, 0, 0, false}, {}, {}, false, false, 0};
|
||||||
(unsigned)(heap_caps_get_free_size(MALLOC_CAP_SPIRAM) / 1024));
|
if (prepareNextPhoto(inlinePhoto, s, wantsRandom)) {
|
||||||
|
displayed = displayPhoto(inlinePhoto.img, inlinePhoto.info,
|
||||||
if (img.valid && !displayed) {
|
inlinePhoto.info2, inlinePhoto.isPair, s);
|
||||||
displayManager.showImage(img);
|
|
||||||
if (s.meta_flags != 0) {
|
|
||||||
displayManager.showMetadata(info, s.meta_flags, s.meta_pos,
|
|
||||||
s.date_fmt, s.time_fmt);
|
|
||||||
}
|
|
||||||
if (s.show_battery) {
|
|
||||||
uint8_t battPct = powerManager.getBatteryPercent();
|
|
||||||
displayManager.showBatteryIndicator(battPct, s.meta_pos);
|
|
||||||
}
|
|
||||||
displayManager.refresh();
|
|
||||||
imagePipeline.freeImage(img);
|
|
||||||
displayed = true;
|
|
||||||
} else if (!img.valid && !displayed) {
|
|
||||||
Serial.println("[display_task] Processing failed, trying next");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,6 +282,50 @@ void displayTask(void* param) {
|
|||||||
lastRefreshTime = millis();
|
lastRefreshTime = millis();
|
||||||
xSemaphoreGive(stateMutex);
|
xSemaphoreGive(stateMutex);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Phase 2: Pre-render next photo ---
|
||||||
|
if (displayed && photoQueue.size() > 0 && wifiManager.isConnected()) {
|
||||||
|
bool playing = false;
|
||||||
|
if (xSemaphoreTake(stateMutex, pdMS_TO_TICKS(100)) == pdTRUE) {
|
||||||
|
playing = slideshowPlaying;
|
||||||
|
xSemaphoreGive(stateMutex);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (playing) {
|
||||||
|
Serial.println("[prerender] Starting pre-render of next photo");
|
||||||
|
Settings preS = settingsManager.get();
|
||||||
|
imagePipeline.setPipelineMode(static_cast<PipelineMode>(preS.pipeline_mode));
|
||||||
|
imagePipeline.setBlueNoiseEnabled(preS.dither_noise != 0);
|
||||||
|
|
||||||
|
if (prepareNextPhoto(pending, preS, false)) {
|
||||||
|
Serial.printf("[prerender] Pre-render complete, PSRAM free: %u KB\n",
|
||||||
|
(unsigned)(heap_caps_get_free_size(MALLOC_CAP_SPIRAM) / 1024));
|
||||||
|
} else {
|
||||||
|
Serial.println("[prerender] Pre-render failed — will fall back to inline");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Phase 2 (idle): Pre-render if we don't have one yet ---
|
||||||
|
if (!pending.ready && photoQueue.size() > 0 && wifiManager.isConnected()) {
|
||||||
|
bool playing = false;
|
||||||
|
if (xSemaphoreTake(stateMutex, pdMS_TO_TICKS(100)) == pdTRUE) {
|
||||||
|
playing = slideshowPlaying;
|
||||||
|
xSemaphoreGive(stateMutex);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (playing) {
|
||||||
|
Serial.println("[prerender] No pending image — pre-rendering now");
|
||||||
|
Settings preS = settingsManager.get();
|
||||||
|
|
||||||
|
if (prepareNextPhoto(pending, preS, false)) {
|
||||||
|
Serial.printf("[prerender] Pre-render complete, PSRAM free: %u KB\n",
|
||||||
|
(unsigned)(heap_caps_get_free_size(MALLOC_CAP_SPIRAM) / 1024));
|
||||||
|
} else {
|
||||||
|
Serial.println("[prerender] Pre-render failed — will retry next loop");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Yield — check every second
|
// Yield — check every second
|
||||||
|
|||||||
Reference in New Issue
Block a user