From 1bda2359366c26b917368317dd3dae04add4604b Mon Sep 17 00:00:00 2001 From: cottongin Date: Mon, 3 Aug 2026 21:45:17 -0400 Subject: [PATCH] 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 --- src/main.cpp | 335 ++++++++++++++++++++++++++++++++++----------------- 1 file changed, 225 insertions(+), 110 deletions(-) diff --git a/src/main.cpp b/src/main.cpp index 7dc10af..468cd37 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -27,16 +27,171 @@ ImagePipeline imagePipeline; DisplayManager displayManager; 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 SemaphoreHandle_t stateMutex; volatile bool slideshowPlaying = true; volatile bool refreshRequested = false; volatile bool randomRequested = false; volatile unsigned long lastRefreshTime = 0; +PendingPhoto pending = {{nullptr, 0, 0, false}, {}, {}, false, false, 0}; // Task handles 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(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) { Serial.println("[display_task] Started on Core 1"); @@ -48,7 +203,6 @@ void displayTask(void* param) { if (photoQueue.sync()) { Serial.printf("[display_task] Queue ready: %d photos\n", photoQueue.size()); - // Trigger first photo immediately if (xSemaphoreTake(stateMutex, pdMS_TO_TICKS(100)) == pdTRUE) { refreshRequested = true; xSemaphoreGive(stateMutex); @@ -61,21 +215,22 @@ void displayTask(void* param) { while (true) { unsigned long now = millis(); Settings s = settingsManager.get(); - imagePipeline.setPipelineMode(static_cast(s.pipeline_mode)); - imagePipeline.setBlueNoiseEnabled(s.dither_noise != 0); displayManager.setTranslucent(s.meta_translucent != 0); unsigned long intervalMs = s.interval_min * 60000UL; bool shouldRefresh = false; bool wantsRandom = false; + bool isManual = false; if (xSemaphoreTake(stateMutex, pdMS_TO_TICKS(100)) == pdTRUE) { if (refreshRequested) { shouldRefresh = true; + isManual = true; refreshRequested = false; } else if (randomRequested) { shouldRefresh = true; wantsRandom = true; + isManual = true; randomRequested = false; } else if (slideshowPlaying && (now - lastRefreshTime >= intervalMs)) { shouldRefresh = true; @@ -88,122 +243,38 @@ void displayTask(void* param) { photoQueue.sync(); } + // --- Phase 1: Display --- 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; + uint32_t currentHash = computeSettingsHash(s); - for (int attempt = 0; attempt < MAX_RETRIES && !displayed; attempt++) { - String assetId; - if (wantsRandom) { - assetId = photoQueue.random(); - } else { - assetId = photoQueue.next(); - } - - if (assetId.length() == 0) break; - - Serial.printf("[display_task] Loading asset: %s (attempt %d)\n", - 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); - } + if (!isManual && pending.ready && pending.settingsHash == currentHash) { + // Use pre-rendered image — instant display + Serial.println("[display_task] Using pre-rendered image"); + displayed = displayPhoto(pending.img, pending.info, pending.info2, + pending.isPair, s); + pending.ready = false; + } else { + // Manual request, stale settings, or no pre-render available — inline pipeline + if (pending.ready) { + if (isManual) { + Serial.println("[display_task] Manual request — discarding pre-render"); } else { - // No pair found — show solo portrait (will get L/R letterbox) - img = imagePipeline.process(jpegBuf, jpegSize); + Serial.printf("[display_task] Settings changed (0x%08X -> 0x%08X) — discarding pre-render\n", + pending.settingsHash, currentHash); } + discardPending(pending); } else { - // Normal landscape processing - img = imagePipeline.process(jpegBuf, jpegSize); + Serial.println("[display_task] No pre-render available — inline fallback"); } - free(jpegBuf); + imagePipeline.setPipelineMode(static_cast(s.pipeline_mode)); + imagePipeline.setBlueNoiseEnabled(s.dither_noise != 0); - Serial.printf("[display_task] Post-process PSRAM free: %u KB\n", - (unsigned)(heap_caps_get_free_size(MALLOC_CAP_SPIRAM) / 1024)); - - if (img.valid && !displayed) { - 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"); + PendingPhoto inlinePhoto = {{nullptr, 0, 0, false}, {}, {}, false, false, 0}; + if (prepareNextPhoto(inlinePhoto, s, wantsRandom)) { + displayed = displayPhoto(inlinePhoto.img, inlinePhoto.info, + inlinePhoto.info2, inlinePhoto.isPair, s); } } @@ -211,6 +282,50 @@ void displayTask(void* param) { lastRefreshTime = millis(); 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(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