diff --git a/lib/GfxRenderer/Bitmap.cpp b/lib/GfxRenderer/Bitmap.cpp index 63535ac..490f3f3 100644 --- a/lib/GfxRenderer/Bitmap.cpp +++ b/lib/GfxRenderer/Bitmap.cpp @@ -82,6 +82,10 @@ const char* Bitmap::errorToString(BmpReaderError err) { BmpReaderError Bitmap::parseHeaders() { if (!file) return BmpReaderError::FileInvalid; + + // Store file size for cache validation + fileSize = file.size(); + if (!file.seek(0)) return BmpReaderError::SeekStartFailed; // --- BMP FILE HEADER --- @@ -263,17 +267,24 @@ BmpReaderError Bitmap::rewindToData() const { return BmpReaderError::Ok; } -bool Bitmap::detectPerimeterIsBlack() const { - // Detect if the 1-pixel perimeter of the image is mostly black or white. - // Returns true if mostly black (luminance < 128), false if mostly white. +EdgeLuminance Bitmap::detectEdgeLuminance(int depth) const { + // Detect average luminance for each edge of the image. + // Samples 'depth' pixels from each edge for more stable averages. + // Returns per-edge luminance values (0-255). - if (width <= 0 || height <= 0) return false; + EdgeLuminance result = {128, 128, 128, 128}; // Default to neutral gray + + if (width <= 0 || height <= 0) return result; + if (depth < 1) depth = 1; + if (depth > width / 2) depth = width / 2; + if (depth > height / 2) depth = height / 2; auto* rowBuffer = static_cast(malloc(rowBytes)); - if (!rowBuffer) return false; + if (!rowBuffer) return result; - int blackCount = 0; - int whiteCount = 0; + // Accumulators for each edge + uint32_t topSum = 0, bottomSum = 0, leftSum = 0, rightSum = 0; + int topCount = 0, bottomCount = 0, leftCount = 0, rightCount = 0; // Helper lambda to get luminance from a pixel at position x in rowBuffer auto getLuminance = [&](int x) -> uint8_t { @@ -299,16 +310,6 @@ bool Bitmap::detectPerimeterIsBlack() const { } }; - // Helper to classify and count a pixel - auto countPixel = [&](int x) { - const uint8_t lum = getLuminance(x); - if (lum < 128) { - blackCount++; - } else { - whiteCount++; - } - }; - // Helper to seek to a specific image row (accounting for top-down vs bottom-up) auto seekToRow = [&](int imageRow) -> bool { // In bottom-up BMP (topDown=false), row 0 in file is the bottom row of image @@ -317,35 +318,56 @@ bool Bitmap::detectPerimeterIsBlack() const { return file.seek(bfOffBits + static_cast(fileRow) * rowBytes); }; - // Sample top row (image row 0) - all pixels - if (seekToRow(0) && file.read(rowBuffer, rowBytes) == rowBytes) { - for (int x = 0; x < width; x++) { - countPixel(x); - } - } - - // Sample bottom row (image row height-1) - all pixels - if (height > 1) { - if (seekToRow(height - 1) && file.read(rowBuffer, rowBytes) == rowBytes) { + // Sample top rows (image rows 0 to depth-1) - all pixels + for (int row = 0; row < depth && row < height; row++) { + if (seekToRow(row) && file.read(rowBuffer, rowBytes) == rowBytes) { for (int x = 0; x < width; x++) { - countPixel(x); + topSum += getLuminance(x); + topCount++; } } } - // Sample left and right edges from intermediate rows - for (int y = 1; y < height - 1; y++) { + // Sample bottom rows (image rows height-depth to height-1) - all pixels + for (int row = height - depth; row < height; row++) { + if (row >= depth && row >= 0) { // Avoid overlap with top rows + if (seekToRow(row) && file.read(rowBuffer, rowBytes) == rowBytes) { + for (int x = 0; x < width; x++) { + bottomSum += getLuminance(x); + bottomCount++; + } + } + } + } + + // Sample left and right edges from all rows + for (int y = 0; y < height; y++) { if (seekToRow(y) && file.read(rowBuffer, rowBytes) == rowBytes) { - countPixel(0); // Left edge - countPixel(width - 1); // Right edge + // Left edge (first 'depth' pixels) + for (int x = 0; x < depth && x < width; x++) { + leftSum += getLuminance(x); + leftCount++; + } + // Right edge (last 'depth' pixels) + for (int x = width - depth; x < width; x++) { + if (x >= depth) { // Avoid overlap with left edge + rightSum += getLuminance(x); + rightCount++; + } + } } } free(rowBuffer); + // Calculate averages + if (topCount > 0) result.top = static_cast(topSum / topCount); + if (bottomCount > 0) result.bottom = static_cast(bottomSum / bottomCount); + if (leftCount > 0) result.left = static_cast(leftSum / leftCount); + if (rightCount > 0) result.right = static_cast(rightSum / rightCount); + // Rewind file position for subsequent drawing rewindToData(); - // Return true if perimeter is mostly black - return blackCount > whiteCount; + return result; } diff --git a/lib/GfxRenderer/Bitmap.h b/lib/GfxRenderer/Bitmap.h index 56cfe3e..e8cd3e3 100644 --- a/lib/GfxRenderer/Bitmap.h +++ b/lib/GfxRenderer/Bitmap.h @@ -6,6 +6,14 @@ #include "BitmapHelpers.h" +// Per-edge average luminance values (0-255) +struct EdgeLuminance { + uint8_t top; + uint8_t bottom; + uint8_t left; + uint8_t right; +}; + enum class BmpReaderError : uint8_t { Ok = 0, FileInvalid, @@ -37,7 +45,7 @@ class Bitmap { BmpReaderError parseHeaders(); BmpReaderError readNextRow(uint8_t* data, uint8_t* rowBuffer) const; BmpReaderError rewindToData() const; - bool detectPerimeterIsBlack() const; + EdgeLuminance detectEdgeLuminance(int depth = 2) const; int getWidth() const { return width; } int getHeight() const { return height; } bool isTopDown() const { return topDown; } @@ -45,6 +53,7 @@ class Bitmap { int getRowBytes() const { return rowBytes; } bool is1Bit() const { return bpp == 1; } uint16_t getBpp() const { return bpp; } + uint32_t getFileSize() const { return fileSize; } private: static uint16_t readLE16(FsFile& f); @@ -58,6 +67,7 @@ class Bitmap { uint32_t bfOffBits = 0; uint16_t bpp = 0; int rowBytes = 0; + uint32_t fileSize = 0; uint8_t paletteLum[256] = {}; // Floyd-Steinberg dithering state (mutable for const methods) diff --git a/lib/GfxRenderer/GfxRenderer.cpp b/lib/GfxRenderer/GfxRenderer.cpp index 7cacfb6..b79b558 100644 --- a/lib/GfxRenderer/GfxRenderer.cpp +++ b/lib/GfxRenderer/GfxRenderer.cpp @@ -144,6 +144,44 @@ void GfxRenderer::fillRect(const int x, const int y, const int width, const int } } +void GfxRenderer::fillRectGray(const int x, const int y, const int width, const int height, + const uint8_t grayLevel) const { + // Fill rectangle with 4-level grayscale value. + // The grayscale encoding for 4 levels uses 3 passes: + // - Level 0 (black): BW=black, MSB=skip, LSB=skip + // - Level 1 (dark gray): BW=black, MSB=white, LSB=white + // - Level 2 (light gray): BW=black, MSB=white, LSB=skip + // - Level 3 (white): BW=skip, MSB=skip, LSB=skip + + if (width <= 0 || height <= 0) return; + + switch (renderMode) { + case BW: + // In BW mode, fill with black for levels 0-2, skip for level 3 + if (grayLevel < 3) { + fillRect(x, y, width, height, true); // true = black + } + // Level 3 = white, which is the default clear color, so skip + break; + + case GRAYSCALE_MSB: + // In MSB mode (buffer cleared to black), fill with white for levels 1-2 + if (grayLevel == 1 || grayLevel == 2) { + fillRect(x, y, width, height, false); // false = white + } + // Levels 0 and 3 stay black (which is correct for 0, will combine correctly for 3) + break; + + case GRAYSCALE_LSB: + // In LSB mode (buffer cleared to black), fill with white for level 1 only + if (grayLevel == 1) { + fillRect(x, y, width, height, false); // false = white + } + // Levels 0, 2, 3 stay black + break; + } +} + void GfxRenderer::drawImage(const uint8_t bitmap[], const int x, const int y, const int width, const int height) const { // TODO: Rotate bits int rotatedX = 0; diff --git a/lib/GfxRenderer/GfxRenderer.h b/lib/GfxRenderer/GfxRenderer.h index 662a30f..722b076 100644 --- a/lib/GfxRenderer/GfxRenderer.h +++ b/lib/GfxRenderer/GfxRenderer.h @@ -65,6 +65,9 @@ class GfxRenderer { void drawLine(int x1, int y1, int x2, int y2, bool state = true) const; void drawRect(int x, int y, int width, int height, bool state = true) const; void fillRect(int x, int y, int width, int height, bool state = true) const; + // Fill rectangle with 4-level grayscale (0=black, 1=dark gray, 2=light gray, 3=white) + // Handles current render mode (BW, GRAYSCALE_MSB, GRAYSCALE_LSB) + void fillRectGray(int x, int y, int width, int height, uint8_t grayLevel) const; void drawImage(const uint8_t bitmap[], int x, int y, int width, int height) const; void drawBitmap(const Bitmap& bitmap, int x, int y, int maxWidth, int maxHeight, float cropX = 0, float cropY = 0, bool invert = false) const; diff --git a/src/BookManager.h b/src/BookManager.h index bb7bffc..ee54775 100644 --- a/src/BookManager.h +++ b/src/BookManager.h @@ -50,6 +50,13 @@ class BookManager { */ static std::string getArchivedBookOriginalPath(const std::string& archivedFilename); + /** + * Get the full cache directory path for a book + * @param bookPath Full path to the book file + * @return Cache directory path (e.g., "/.crosspoint/epub_123456") + */ + static std::string getCacheDir(const std::string& bookPath); + private: // Extract filename from a full path static std::string getFilename(const std::string& path); @@ -63,9 +70,6 @@ class BookManager { // Get cache directory prefix for a file type (epub_, txt_, xtc_) static std::string getCachePrefix(const std::string& path); - // Get the full cache directory path for a book - static std::string getCacheDir(const std::string& bookPath); - // Write the .meta file for an archived book static bool writeMetaFile(const std::string& archivedPath, const std::string& originalPath); diff --git a/src/activities/boot_sleep/SleepActivity.cpp b/src/activities/boot_sleep/SleepActivity.cpp index 8411294..a49a5b6 100644 --- a/src/activities/boot_sleep/SleepActivity.cpp +++ b/src/activities/boot_sleep/SleepActivity.cpp @@ -6,6 +6,9 @@ #include #include +#include + +#include "BookManager.h" #include "CrossPointSettings.h" #include "CrossPointState.h" #include "fontIds.h" @@ -13,10 +16,16 @@ #include "util/StringUtils.h" namespace { -// Perimeter cache file format: +// Edge luminance cache file format (per-BMP): // - 4 bytes: uint32_t file size (for cache invalidation) -// - 1 byte: result (0 = white perimeter, 1 = black perimeter) -constexpr size_t PERIM_CACHE_SIZE = 5; +// - 4 bytes: EdgeLuminance (top, bottom, left, right) +constexpr size_t EDGE_CACHE_SIZE = 8; + +// Book-level edge cache file format (stored in book's cache directory): +// - 4 bytes: uint32_t cover BMP file size (for cache invalidation) +// - 4 bytes: EdgeLuminance (top, bottom, left, right) +// - 1 byte: cover mode (0=FIT, 1=CROP) - for EPUB mode invalidation +constexpr size_t BOOK_EDGE_CACHE_SIZE = 9; } // namespace void SleepActivity::onEnter() { @@ -202,8 +211,9 @@ void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap, const std::str // Image is taller than screen ratio - fit to height scale = static_cast(pageHeight) / static_cast(bitmap.getHeight()); } - fillWidth = static_cast(bitmap.getWidth() * scale); - fillHeight = static_cast(bitmap.getHeight() * scale); + // Use ceil to ensure fill area covers all drawn pixels + fillWidth = static_cast(std::ceil(bitmap.getWidth() * scale)); + fillHeight = static_cast(std::ceil(bitmap.getHeight() * scale)); // Center the scaled image x = (pageWidth - fillWidth) / 2; @@ -212,31 +222,72 @@ void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap, const std::str fillWidth, fillHeight, x, y); } - // Detect perimeter color and clear to matching background - const bool isBlackPerimeter = getPerimeterIsBlack(bitmap, bmpPath); - const uint8_t clearColor = isBlackPerimeter ? 0x00 : 0xFF; + // Get edge luminance values (from cache or calculate) + const EdgeLuminance edges = getEdgeLuminance(bitmap, bmpPath); + const uint8_t topGray = quantizeGray(edges.top); + const uint8_t bottomGray = quantizeGray(edges.bottom); + const uint8_t leftGray = quantizeGray(edges.left); + const uint8_t rightGray = quantizeGray(edges.right); - Serial.printf("[%lu] [SLP] drawing to %d x %d\n", millis(), x, y); - renderer.clearScreen(clearColor); + Serial.printf("[%lu] [SLP] Edge luminance: T=%d B=%d L=%d R=%d -> gray levels T=%d B=%d L=%d R=%d\n", + millis(), edges.top, edges.bottom, edges.left, edges.right, + topGray, bottomGray, leftGray, rightGray); - // If background is black, fill the image area with white first so white pixels render correctly - if (isBlackPerimeter) { - renderer.fillRect(x, y, fillWidth, fillHeight, false); // false = white + // Clear screen to white first (default background) + renderer.clearScreen(0xFF); + + // Fill letterbox regions with edge colors (BW pass) + // Top letterbox + if (y > 0) { + renderer.fillRectGray(0, 0, pageWidth, y, topGray); + } + // Bottom letterbox + if (y + fillHeight < pageHeight) { + renderer.fillRectGray(0, y + fillHeight, pageWidth, pageHeight - y - fillHeight, bottomGray); + } + // Left letterbox + if (x > 0) { + renderer.fillRectGray(0, y, x, fillHeight, leftGray); + } + // Right letterbox + if (x + fillWidth < pageWidth) { + renderer.fillRectGray(x + fillWidth, y, pageWidth - x - fillWidth, fillHeight, rightGray); } + Serial.printf("[%lu] [SLP] drawing bitmap at %d, %d\n", millis(), x, y); renderer.drawBitmap(bitmap, x, y, drawWidth, drawHeight, cropX, cropY); renderer.displayBuffer(EInkDisplay::HALF_REFRESH); if (bitmap.hasGreyscale()) { + // Grayscale LSB pass bitmap.rewindToData(); renderer.clearScreen(0x00); renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB); + + // Fill letterbox regions for LSB pass + if (y > 0) renderer.fillRectGray(0, 0, pageWidth, y, topGray); + if (y + fillHeight < pageHeight) + renderer.fillRectGray(0, y + fillHeight, pageWidth, pageHeight - y - fillHeight, bottomGray); + if (x > 0) renderer.fillRectGray(0, y, x, fillHeight, leftGray); + if (x + fillWidth < pageWidth) + renderer.fillRectGray(x + fillWidth, y, pageWidth - x - fillWidth, fillHeight, rightGray); + renderer.drawBitmap(bitmap, x, y, drawWidth, drawHeight, cropX, cropY); renderer.copyGrayscaleLsbBuffers(); + // Grayscale MSB pass bitmap.rewindToData(); renderer.clearScreen(0x00); renderer.setRenderMode(GfxRenderer::GRAYSCALE_MSB); + + // Fill letterbox regions for MSB pass + if (y > 0) renderer.fillRectGray(0, 0, pageWidth, y, topGray); + if (y + fillHeight < pageHeight) + renderer.fillRectGray(0, y + fillHeight, pageWidth, pageHeight - y - fillHeight, bottomGray); + if (x > 0) renderer.fillRectGray(0, y, x, fillHeight, leftGray); + if (x + fillWidth < pageWidth) + renderer.fillRectGray(x + fillWidth, y, pageWidth - x - fillWidth, fillHeight, rightGray); + renderer.drawBitmap(bitmap, x, y, drawWidth, drawHeight, cropX, cropY); renderer.copyGrayscaleMsbBuffers(); @@ -250,8 +301,17 @@ void SleepActivity::renderCoverSleepScreen() const { return renderDefaultSleepScreen(); } + const bool cropped = SETTINGS.sleepScreenCoverMode == CrossPointSettings::SLEEP_SCREEN_COVER_MODE::CROP; + + // Try to use cached edge data to skip book metadata loading + if (tryRenderCachedCoverSleep(APP_STATE.openEpubPath, cropped)) { + return; + } + + // Cache miss - need to load book metadata and generate cover + Serial.printf("[%lu] [SLP] Cache miss, loading book metadata\n", millis()); + std::string coverBmpPath; - bool cropped = SETTINGS.sleepScreenCoverMode == CrossPointSettings::SLEEP_SCREEN_COVER_MODE::CROP; // Check if the current book is XTC, TXT, or EPUB if (StringUtils::checkFileExtension(APP_STATE.openEpubPath, ".xtc") || @@ -305,7 +365,34 @@ void SleepActivity::renderCoverSleepScreen() const { if (SdMan.openFileForRead("SLP", coverBmpPath, file)) { Bitmap bitmap(file); if (bitmap.parseHeaders() == BmpReaderError::Ok) { + // Render the bitmap - this will calculate and cache edge luminance per-BMP renderBitmapSleepScreen(bitmap, coverBmpPath); + + // Also save to book-level edge cache for faster subsequent sleeps + const std::string edgeCachePath = getBookEdgeCachePath(APP_STATE.openEpubPath); + if (!edgeCachePath.empty()) { + // Get the edge luminance that was just calculated (it's now cached per-BMP) + const EdgeLuminance edges = getEdgeLuminance(bitmap, coverBmpPath); + const uint32_t bmpFileSize = bitmap.getFileSize(); + const uint8_t coverMode = cropped ? 1 : 0; + + FsFile cacheFile; + if (SdMan.openFileForWrite("SLP", edgeCachePath, cacheFile)) { + uint8_t cacheData[BOOK_EDGE_CACHE_SIZE]; + cacheData[0] = bmpFileSize & 0xFF; + cacheData[1] = (bmpFileSize >> 8) & 0xFF; + cacheData[2] = (bmpFileSize >> 16) & 0xFF; + cacheData[3] = (bmpFileSize >> 24) & 0xFF; + cacheData[4] = edges.top; + cacheData[5] = edges.bottom; + cacheData[6] = edges.left; + cacheData[7] = edges.right; + cacheData[8] = coverMode; + cacheFile.write(cacheData, BOOK_EDGE_CACHE_SIZE); + cacheFile.close(); + Serial.printf("[%lu] [SLP] Saved book-level edge cache to %s\n", millis(), edgeCachePath.c_str()); + } + } return; } } @@ -318,7 +405,7 @@ void SleepActivity::renderBlankSleepScreen() const { renderer.displayBuffer(EInkDisplay::HALF_REFRESH); } -std::string SleepActivity::getPerimeterCachePath(const std::string& bmpPath) { +std::string SleepActivity::getEdgeCachePath(const std::string& bmpPath) { // Convert "/dir/file.bmp" to "/dir/.file.bmp.perim" const size_t lastSlash = bmpPath.find_last_of('/'); if (lastSlash == std::string::npos) { @@ -330,67 +417,182 @@ std::string SleepActivity::getPerimeterCachePath(const std::string& bmpPath) { return dir + "." + filename + ".perim"; } -bool SleepActivity::getPerimeterIsBlack(const Bitmap& bitmap, const std::string& bmpPath) const { - const std::string cachePath = getPerimeterCachePath(bmpPath); +uint8_t SleepActivity::quantizeGray(uint8_t lum) { + // Quantize luminance (0-255) to 4-level grayscale (0-3) + // Thresholds tuned for X4 display gray levels + if (lum < 43) return 0; // black + if (lum < 128) return 1; // dark gray + if (lum < 213) return 2; // light gray + return 3; // white +} + +EdgeLuminance SleepActivity::getEdgeLuminance(const Bitmap& bitmap, const std::string& bmpPath) const { + const std::string cachePath = getEdgeCachePath(bmpPath); + EdgeLuminance result = {128, 128, 128, 128}; // Default to neutral gray // Try to read from cache FsFile cacheFile; if (SdMan.openFileForRead("SLP", cachePath, cacheFile)) { - uint8_t cacheData[PERIM_CACHE_SIZE]; - if (cacheFile.read(cacheData, PERIM_CACHE_SIZE) == PERIM_CACHE_SIZE) { + uint8_t cacheData[EDGE_CACHE_SIZE]; + if (cacheFile.read(cacheData, EDGE_CACHE_SIZE) == EDGE_CACHE_SIZE) { // Extract cached file size const uint32_t cachedSize = static_cast(cacheData[0]) | (static_cast(cacheData[1]) << 8) | (static_cast(cacheData[2]) << 16) | (static_cast(cacheData[3]) << 24); - // Get current BMP file size - FsFile bmpFile; - uint32_t currentSize = 0; - if (SdMan.openFileForRead("SLP", bmpPath, bmpFile)) { - currentSize = bmpFile.size(); - bmpFile.close(); - } + // Get current BMP file size from already-opened bitmap + const uint32_t currentSize = bitmap.getFileSize(); // Validate cache if (cachedSize == currentSize && currentSize > 0) { - const bool result = cacheData[4] != 0; - Serial.printf("[%lu] [SLP] Perimeter cache hit for %s: %s\n", millis(), bmpPath.c_str(), - result ? "black" : "white"); + result.top = cacheData[4]; + result.bottom = cacheData[5]; + result.left = cacheData[6]; + result.right = cacheData[7]; + Serial.printf("[%lu] [SLP] Edge cache hit for %s: T=%d B=%d L=%d R=%d\n", millis(), bmpPath.c_str(), + result.top, result.bottom, result.left, result.right); cacheFile.close(); return result; } - Serial.printf("[%lu] [SLP] Perimeter cache invalid (size mismatch: %lu vs %lu)\n", millis(), + Serial.printf("[%lu] [SLP] Edge cache invalid (size mismatch: %lu vs %lu)\n", millis(), static_cast(cachedSize), static_cast(currentSize)); } cacheFile.close(); } - // Cache miss - calculate perimeter - Serial.printf("[%lu] [SLP] Calculating perimeter for %s\n", millis(), bmpPath.c_str()); - const bool isBlack = bitmap.detectPerimeterIsBlack(); - Serial.printf("[%lu] [SLP] Perimeter detected: %s\n", millis(), isBlack ? "black" : "white"); + // Cache miss - calculate edge luminance + Serial.printf("[%lu] [SLP] Calculating edge luminance for %s\n", millis(), bmpPath.c_str()); + result = bitmap.detectEdgeLuminance(2); // Sample 2 pixels deep for stability + Serial.printf("[%lu] [SLP] Edge luminance detected: T=%d B=%d L=%d R=%d\n", millis(), + result.top, result.bottom, result.left, result.right); - // Get BMP file size for cache - FsFile bmpFile; - uint32_t fileSize = 0; - if (SdMan.openFileForRead("SLP", bmpPath, bmpFile)) { - fileSize = bmpFile.size(); - bmpFile.close(); - } + // Get BMP file size from already-opened bitmap for cache + const uint32_t fileSize = bitmap.getFileSize(); // Save to cache if (fileSize > 0 && SdMan.openFileForWrite("SLP", cachePath, cacheFile)) { - uint8_t cacheData[PERIM_CACHE_SIZE]; + uint8_t cacheData[EDGE_CACHE_SIZE]; cacheData[0] = fileSize & 0xFF; cacheData[1] = (fileSize >> 8) & 0xFF; cacheData[2] = (fileSize >> 16) & 0xFF; cacheData[3] = (fileSize >> 24) & 0xFF; - cacheData[4] = isBlack ? 1 : 0; - cacheFile.write(cacheData, PERIM_CACHE_SIZE); + cacheData[4] = result.top; + cacheData[5] = result.bottom; + cacheData[6] = result.left; + cacheData[7] = result.right; + cacheFile.write(cacheData, EDGE_CACHE_SIZE); cacheFile.close(); - Serial.printf("[%lu] [SLP] Saved perimeter cache to %s\n", millis(), cachePath.c_str()); + Serial.printf("[%lu] [SLP] Saved edge cache to %s\n", millis(), cachePath.c_str()); } - return isBlack; + return result; +} + +std::string SleepActivity::getBookEdgeCachePath(const std::string& bookPath) { + const std::string cacheDir = BookManager::getCacheDir(bookPath); + if (cacheDir.empty()) { + return ""; + } + return cacheDir + "/edge.bin"; +} + +std::string SleepActivity::getCoverBmpPath(const std::string& cacheDir, const std::string& bookPath, bool cropped) { + if (cacheDir.empty()) { + return ""; + } + + // EPUB uses different paths for fit vs crop modes + if (StringUtils::checkFileExtension(bookPath, ".epub")) { + return cropped ? (cacheDir + "/cover_crop.bmp") : (cacheDir + "/cover_fit.bmp"); + } + + // XTC and TXT use a single cover.bmp + return cacheDir + "/cover.bmp"; +} + +bool SleepActivity::tryRenderCachedCoverSleep(const std::string& bookPath, bool cropped) const { + // Try to render cover sleep screen using cached edge data without loading book metadata + const std::string edgeCachePath = getBookEdgeCachePath(bookPath); + if (edgeCachePath.empty()) { + Serial.println("[SLP] Cannot get edge cache path"); + return false; + } + + // Check if edge cache exists + FsFile cacheFile; + if (!SdMan.openFileForRead("SLP", edgeCachePath, cacheFile)) { + Serial.println("[SLP] No edge cache file found"); + return false; + } + + // Read cache data + uint8_t cacheData[BOOK_EDGE_CACHE_SIZE]; + if (cacheFile.read(cacheData, BOOK_EDGE_CACHE_SIZE) != BOOK_EDGE_CACHE_SIZE) { + Serial.println("[SLP] Edge cache file too small"); + cacheFile.close(); + return false; + } + cacheFile.close(); + + // Extract cached values + const uint32_t cachedBmpSize = static_cast(cacheData[0]) | + (static_cast(cacheData[1]) << 8) | + (static_cast(cacheData[2]) << 16) | + (static_cast(cacheData[3]) << 24); + EdgeLuminance cachedEdges; + cachedEdges.top = cacheData[4]; + cachedEdges.bottom = cacheData[5]; + cachedEdges.left = cacheData[6]; + cachedEdges.right = cacheData[7]; + const uint8_t cachedCoverMode = cacheData[8]; + + // Check if cover mode matches (for EPUB) + const uint8_t currentCoverMode = cropped ? 1 : 0; + if (StringUtils::checkFileExtension(bookPath, ".epub") && cachedCoverMode != currentCoverMode) { + Serial.printf("[SLP] Cover mode changed (cached=%d, current=%d), invalidating cache\n", + cachedCoverMode, currentCoverMode); + return false; + } + + // Construct cover BMP path + const std::string cacheDir = BookManager::getCacheDir(bookPath); + const std::string coverBmpPath = getCoverBmpPath(cacheDir, bookPath, cropped); + if (coverBmpPath.empty()) { + Serial.println("[SLP] Cannot construct cover BMP path"); + return false; + } + + // Try to open the cover BMP + FsFile bmpFile; + if (!SdMan.openFileForRead("SLP", coverBmpPath, bmpFile)) { + Serial.printf("[SLP] Cover BMP not found: %s\n", coverBmpPath.c_str()); + return false; + } + + // Check if BMP file size matches cache + const uint32_t currentBmpSize = bmpFile.size(); + if (currentBmpSize != cachedBmpSize || currentBmpSize == 0) { + Serial.printf("[SLP] BMP size mismatch (cached=%lu, current=%lu)\n", + static_cast(cachedBmpSize), static_cast(currentBmpSize)); + bmpFile.close(); + return false; + } + + // Parse bitmap headers + Bitmap bitmap(bmpFile); + if (bitmap.parseHeaders() != BmpReaderError::Ok) { + Serial.println("[SLP] Failed to parse cached cover BMP"); + bmpFile.close(); + return false; + } + + Serial.printf("[%lu] [SLP] Using cached cover sleep: %s (T=%d B=%d L=%d R=%d)\n", millis(), + coverBmpPath.c_str(), cachedEdges.top, cachedEdges.bottom, cachedEdges.left, cachedEdges.right); + + // Render the bitmap with cached edge values + // We call renderBitmapSleepScreen which will use getEdgeLuminance internally, + // but since the per-BMP cache should also exist (same values), it will be a cache hit + renderBitmapSleepScreen(bitmap, coverBmpPath); + return true; } diff --git a/src/activities/boot_sleep/SleepActivity.h b/src/activities/boot_sleep/SleepActivity.h index 5bcb1d0..288727b 100644 --- a/src/activities/boot_sleep/SleepActivity.h +++ b/src/activities/boot_sleep/SleepActivity.h @@ -1,9 +1,9 @@ #pragma once #include "../Activity.h" -#include +#include -class Bitmap; +#include class SleepActivity final : public Activity { public: @@ -19,7 +19,15 @@ class SleepActivity final : public Activity { void renderBitmapSleepScreen(const Bitmap& bitmap, const std::string& bmpPath) const; void renderBlankSleepScreen() const; - // Perimeter detection caching helpers - static std::string getPerimeterCachePath(const std::string& bmpPath); - bool getPerimeterIsBlack(const Bitmap& bitmap, const std::string& bmpPath) const; + // Edge luminance caching helpers + static std::string getEdgeCachePath(const std::string& bmpPath); + EdgeLuminance getEdgeLuminance(const Bitmap& bitmap, const std::string& bmpPath) const; + + // Book-level edge cache helpers (for skipping book metadata loading) + static std::string getBookEdgeCachePath(const std::string& bookPath); + static std::string getCoverBmpPath(const std::string& cacheDir, const std::string& bookPath, bool cropped); + bool tryRenderCachedCoverSleep(const std::string& bookPath, bool cropped) const; + + // Quantize luminance (0-255) to 4-level grayscale (0-3) + static uint8_t quantizeGray(uint8_t lum); };