Pixel-level Bayer dithering in the 171-254 gray range creates a high-frequency checkerboard in the BW pass that causes e-ink display crosstalk during HALF_REFRESH, washing out cover images. Replace with 2x2 hash-based block dithering for this specific gray range — each block gets a uniform level (2 or 3) via a spatial hash, avoiding single-pixel alternation while approximating the target gray. Standard Bayer dithering remains for all other gray ranges. Also removes all debug instrumentation from the investigation. Co-authored-by: Cursor <cursoragent@cursor.com>
659 lines
25 KiB
C++
659 lines
25 KiB
C++
#include "SleepActivity.h"
|
||
|
||
#include <Epub.h>
|
||
#include <GfxRenderer.h>
|
||
#include <HalStorage.h>
|
||
#include <Serialization.h>
|
||
#include <Txt.h>
|
||
#include <Xtc.h>
|
||
|
||
#include <algorithm>
|
||
#include <cmath>
|
||
|
||
#include "CrossPointSettings.h"
|
||
#include "CrossPointState.h"
|
||
#include "components/UITheme.h"
|
||
#include "fontIds.h"
|
||
#include "images/Logo120.h"
|
||
#include "util/StringUtils.h"
|
||
|
||
namespace {
|
||
|
||
// Number of source pixels along the image edge to average for the dominant color
|
||
constexpr int EDGE_SAMPLE_DEPTH = 20;
|
||
|
||
// Map a 2-bit quantized pixel value to an 8-bit grayscale value
|
||
constexpr uint8_t val2bitToGray(uint8_t val2bit) { return val2bit * 85; }
|
||
|
||
// Letterbox fill data: one average gray value per edge (top/bottom or left/right).
|
||
struct LetterboxFillData {
|
||
uint8_t avgA = 128; // average gray of edge A (top or left)
|
||
uint8_t avgB = 128; // average gray of edge B (bottom or right)
|
||
int letterboxA = 0; // pixel size of the first letterbox area (top or left)
|
||
int letterboxB = 0; // pixel size of the second letterbox area (bottom or right)
|
||
bool horizontal = false; // true = top/bottom letterbox, false = left/right
|
||
bool valid = false;
|
||
};
|
||
|
||
// Snap an 8-bit gray value to the nearest of the 4 e-ink levels: 0, 85, 170, 255.
|
||
uint8_t snapToEinkLevel(uint8_t gray) {
|
||
// Thresholds at midpoints: 42, 127, 212
|
||
if (gray < 43) return 0;
|
||
if (gray < 128) return 85;
|
||
if (gray < 213) return 170;
|
||
return 255;
|
||
}
|
||
|
||
// 4x4 Bayer ordered dithering matrix, values 0-255.
|
||
// Produces a structured halftone pattern for 4-level quantization.
|
||
// clang-format off
|
||
constexpr uint8_t BAYER_4X4[4][4] = {
|
||
{ 0, 128, 32, 160},
|
||
{192, 64, 224, 96},
|
||
{ 48, 176, 16, 144},
|
||
{240, 112, 208, 80}
|
||
};
|
||
// clang-format on
|
||
|
||
// Ordered (Bayer) dithering for 4-level e-ink display.
|
||
// Maps an 8-bit gray value to a 2-bit level (0-3) using the Bayer matrix
|
||
// to produce a structured, repeating halftone pattern.
|
||
uint8_t quantizeBayerDither(int gray, int x, int y) {
|
||
const int threshold = BAYER_4X4[y & 3][x & 3];
|
||
const int scaled = gray * 3;
|
||
if (scaled < 255) {
|
||
return (scaled + threshold >= 255) ? 1 : 0;
|
||
} else if (scaled < 510) {
|
||
return ((scaled - 255) + threshold >= 255) ? 2 : 1;
|
||
} else {
|
||
return ((scaled - 510) + threshold >= 255) ? 3 : 2;
|
||
}
|
||
}
|
||
|
||
// Check whether a gray value would produce a dithered mix that crosses the
|
||
// level-2 / level-3 boundary. This is the ONLY boundary where some dithered
|
||
// pixels are BLACK (level ≤ 2) and others are WHITE (level 3) in the BW pass,
|
||
// creating a high-frequency checkerboard that causes e-ink display crosstalk
|
||
// and washes out adjacent content during HALF_REFRESH.
|
||
// Gray values 171-254 produce a level-2/level-3 mix via Bayer dithering.
|
||
bool bayerCrossesBwBoundary(uint8_t gray) {
|
||
return gray > 170 && gray < 255;
|
||
}
|
||
|
||
// Hash-based block dithering for BW-boundary gray values (171-254).
|
||
// Each blockSize×blockSize pixel block gets a single uniform level (2 or 3),
|
||
// determined by a deterministic spatial hash. The proportion of level-3 blocks
|
||
// approximates the target gray. Unlike Bayer, the pattern is irregular
|
||
// (noise-like), making it much less visually obvious at the same block size.
|
||
// The hash is purely spatial (depends only on x, y, blockSize) so it produces
|
||
// identical levels across BW, LSB, and MSB render passes.
|
||
static constexpr int BW_DITHER_BLOCK = 2;
|
||
|
||
uint8_t hashBlockDither(uint8_t avg, int x, int y) {
|
||
const int bx = x / BW_DITHER_BLOCK;
|
||
const int by = y / BW_DITHER_BLOCK;
|
||
// Fast mixing hash (splitmix32-inspired)
|
||
uint32_t h = (uint32_t)bx * 2654435761u ^ (uint32_t)by * 2246822519u;
|
||
h ^= h >> 16;
|
||
h *= 0x45d9f3bu;
|
||
h ^= h >> 16;
|
||
// Proportion of level-3 blocks needed to approximate the target gray
|
||
const float ratio = (avg - 170.0f) / 85.0f;
|
||
const uint32_t threshold = (uint32_t)(ratio * 4294967295.0f);
|
||
return (h < threshold) ? 3 : 2;
|
||
}
|
||
|
||
// --- Edge average cache ---
|
||
// Caches the computed edge averages alongside the cover BMP so we don't rescan on every sleep.
|
||
constexpr uint8_t EDGE_CACHE_VERSION = 2;
|
||
|
||
bool loadEdgeCache(const std::string& path, int screenWidth, int screenHeight, LetterboxFillData& data) {
|
||
FsFile file;
|
||
if (!Storage.openFileForRead("SLP", path, file)) return false;
|
||
|
||
uint8_t version;
|
||
serialization::readPod(file, version);
|
||
if (version != EDGE_CACHE_VERSION) {
|
||
file.close();
|
||
return false;
|
||
}
|
||
|
||
uint16_t cachedW, cachedH;
|
||
serialization::readPod(file, cachedW);
|
||
serialization::readPod(file, cachedH);
|
||
if (cachedW != static_cast<uint16_t>(screenWidth) || cachedH != static_cast<uint16_t>(screenHeight)) {
|
||
file.close();
|
||
return false;
|
||
}
|
||
|
||
uint8_t horizontal;
|
||
serialization::readPod(file, horizontal);
|
||
data.horizontal = (horizontal != 0);
|
||
|
||
serialization::readPod(file, data.avgA);
|
||
serialization::readPod(file, data.avgB);
|
||
|
||
int16_t lbA, lbB;
|
||
serialization::readPod(file, lbA);
|
||
serialization::readPod(file, lbB);
|
||
data.letterboxA = lbA;
|
||
data.letterboxB = lbB;
|
||
|
||
file.close();
|
||
data.valid = true;
|
||
Serial.printf("[%lu] [SLP] Loaded edge cache from %s (avgA=%d, avgB=%d)\n", millis(), path.c_str(), data.avgA,
|
||
data.avgB);
|
||
return true;
|
||
}
|
||
|
||
bool saveEdgeCache(const std::string& path, int screenWidth, int screenHeight, const LetterboxFillData& data) {
|
||
if (!data.valid) return false;
|
||
|
||
FsFile file;
|
||
if (!Storage.openFileForWrite("SLP", path, file)) return false;
|
||
|
||
serialization::writePod(file, EDGE_CACHE_VERSION);
|
||
serialization::writePod(file, static_cast<uint16_t>(screenWidth));
|
||
serialization::writePod(file, static_cast<uint16_t>(screenHeight));
|
||
serialization::writePod(file, static_cast<uint8_t>(data.horizontal ? 1 : 0));
|
||
serialization::writePod(file, data.avgA);
|
||
serialization::writePod(file, data.avgB);
|
||
serialization::writePod(file, static_cast<int16_t>(data.letterboxA));
|
||
serialization::writePod(file, static_cast<int16_t>(data.letterboxB));
|
||
file.close();
|
||
|
||
Serial.printf("[%lu] [SLP] Saved edge cache to %s\n", millis(), path.c_str());
|
||
return true;
|
||
}
|
||
|
||
// Read the bitmap once to compute a single average gray value for the top/bottom or left/right edges.
|
||
// Only computes running sums -- no per-pixel arrays, no malloc beyond row buffers.
|
||
// After sampling the bitmap is rewound via rewindToData().
|
||
LetterboxFillData computeEdgeAverages(const Bitmap& bitmap, int imgX, int imgY, int pageWidth, int pageHeight,
|
||
float scale, float cropX, float cropY) {
|
||
LetterboxFillData data;
|
||
|
||
const int cropPixX = static_cast<int>(std::floor(bitmap.getWidth() * cropX / 2.0f));
|
||
const int cropPixY = static_cast<int>(std::floor(bitmap.getHeight() * cropY / 2.0f));
|
||
const int visibleWidth = bitmap.getWidth() - 2 * cropPixX;
|
||
const int visibleHeight = bitmap.getHeight() - 2 * cropPixY;
|
||
|
||
if (visibleWidth <= 0 || visibleHeight <= 0) return data;
|
||
|
||
const int outputRowSize = (bitmap.getWidth() + 3) / 4;
|
||
auto* outputRow = static_cast<uint8_t*>(malloc(outputRowSize));
|
||
auto* rowBytes = static_cast<uint8_t*>(malloc(bitmap.getRowBytes()));
|
||
if (!outputRow || !rowBytes) {
|
||
free(outputRow);
|
||
free(rowBytes);
|
||
return data;
|
||
}
|
||
|
||
if (imgY > 0) {
|
||
// Top/bottom letterboxing -- compute overall average of first/last EDGE_SAMPLE_DEPTH rows
|
||
data.horizontal = true;
|
||
const int scaledHeight = static_cast<int>(std::round(static_cast<float>(visibleHeight) * scale));
|
||
data.letterboxA = imgY;
|
||
data.letterboxB = pageHeight - imgY - scaledHeight;
|
||
if (data.letterboxB < 0) data.letterboxB = 0;
|
||
|
||
const int sampleRows = std::min(EDGE_SAMPLE_DEPTH, visibleHeight);
|
||
uint64_t sumTop = 0, sumBot = 0;
|
||
int countTop = 0, countBot = 0;
|
||
|
||
for (int bmpY = 0; bmpY < bitmap.getHeight(); bmpY++) {
|
||
if (bitmap.readNextRow(outputRow, rowBytes) != BmpReaderError::Ok) break;
|
||
const int logicalY = bitmap.isTopDown() ? bmpY : bitmap.getHeight() - 1 - bmpY;
|
||
if (logicalY < cropPixY || logicalY >= bitmap.getHeight() - cropPixY) continue;
|
||
const int outY = logicalY - cropPixY;
|
||
|
||
const bool inTop = (outY < sampleRows);
|
||
const bool inBot = (outY >= visibleHeight - sampleRows);
|
||
if (!inTop && !inBot) continue;
|
||
|
||
for (int bmpX = cropPixX; bmpX < bitmap.getWidth() - cropPixX; bmpX++) {
|
||
const uint8_t val = (outputRow[bmpX / 4] >> (6 - ((bmpX * 2) % 8))) & 0x3;
|
||
const uint8_t gray = val2bitToGray(val);
|
||
if (inTop) {
|
||
sumTop += gray;
|
||
countTop++;
|
||
}
|
||
if (inBot) {
|
||
sumBot += gray;
|
||
countBot++;
|
||
}
|
||
}
|
||
}
|
||
|
||
data.avgA = countTop > 0 ? static_cast<uint8_t>(sumTop / countTop) : 128;
|
||
data.avgB = countBot > 0 ? static_cast<uint8_t>(sumBot / countBot) : 128;
|
||
data.valid = true;
|
||
} else if (imgX > 0) {
|
||
// Left/right letterboxing -- compute overall average of first/last EDGE_SAMPLE_DEPTH columns
|
||
data.horizontal = false;
|
||
const int scaledWidth = static_cast<int>(std::round(static_cast<float>(visibleWidth) * scale));
|
||
data.letterboxA = imgX;
|
||
data.letterboxB = pageWidth - imgX - scaledWidth;
|
||
if (data.letterboxB < 0) data.letterboxB = 0;
|
||
|
||
const int sampleCols = std::min(EDGE_SAMPLE_DEPTH, visibleWidth);
|
||
uint64_t sumLeft = 0, sumRight = 0;
|
||
int countLeft = 0, countRight = 0;
|
||
|
||
for (int bmpY = 0; bmpY < bitmap.getHeight(); bmpY++) {
|
||
if (bitmap.readNextRow(outputRow, rowBytes) != BmpReaderError::Ok) break;
|
||
const int logicalY = bitmap.isTopDown() ? bmpY : bitmap.getHeight() - 1 - bmpY;
|
||
if (logicalY < cropPixY || logicalY >= bitmap.getHeight() - cropPixY) continue;
|
||
|
||
for (int bmpX = cropPixX; bmpX < cropPixX + sampleCols; bmpX++) {
|
||
const uint8_t val = (outputRow[bmpX / 4] >> (6 - ((bmpX * 2) % 8))) & 0x3;
|
||
sumLeft += val2bitToGray(val);
|
||
countLeft++;
|
||
}
|
||
for (int bmpX = bitmap.getWidth() - cropPixX - sampleCols; bmpX < bitmap.getWidth() - cropPixX; bmpX++) {
|
||
const uint8_t val = (outputRow[bmpX / 4] >> (6 - ((bmpX * 2) % 8))) & 0x3;
|
||
sumRight += val2bitToGray(val);
|
||
countRight++;
|
||
}
|
||
}
|
||
|
||
data.avgA = countLeft > 0 ? static_cast<uint8_t>(sumLeft / countLeft) : 128;
|
||
data.avgB = countRight > 0 ? static_cast<uint8_t>(sumRight / countRight) : 128;
|
||
data.valid = true;
|
||
}
|
||
|
||
bitmap.rewindToData();
|
||
free(outputRow);
|
||
free(rowBytes);
|
||
return data;
|
||
}
|
||
|
||
// Draw letterbox fill in the areas around the cover image.
|
||
// DITHERED: fills with the edge average using Bayer ordered dithering to approximate the color.
|
||
// SOLID: snaps edge average to nearest e-ink level (0/85/170/255) for a clean uniform fill.
|
||
// Must be called once per render pass (BW, GRAYSCALE_LSB, GRAYSCALE_MSB).
|
||
void drawLetterboxFill(GfxRenderer& renderer, const LetterboxFillData& data, uint8_t fillMode) {
|
||
if (!data.valid) return;
|
||
|
||
const bool isSolid = (fillMode == CrossPointSettings::SLEEP_SCREEN_LETTERBOX_FILL::LETTERBOX_SOLID);
|
||
|
||
// For DITHERED mode with gray values in 171-254 (the level-2/level-3 BW boundary):
|
||
// Pixel-level Bayer dithering creates a regular high-frequency checkerboard in
|
||
// the BW pass that causes e-ink display crosstalk during HALF_REFRESH.
|
||
//
|
||
// Solution: HASH-BASED BLOCK DITHERING. Each 2x2 pixel block gets a single
|
||
// level (2 or 3) determined by a spatial hash, with the proportion of level-3
|
||
// blocks tuned to approximate the target gray. The 2px minimum run avoids BW
|
||
// crosstalk, and the irregular hash pattern is much less visible than a regular
|
||
// Bayer grid at the same block size.
|
||
const bool hashA = !isSolid && bayerCrossesBwBoundary(data.avgA);
|
||
const bool hashB = !isSolid && bayerCrossesBwBoundary(data.avgB);
|
||
|
||
// For solid mode: snap to nearest e-ink level
|
||
const uint8_t levelA = isSolid ? snapToEinkLevel(data.avgA) / 85 : 0;
|
||
const uint8_t levelB = isSolid ? snapToEinkLevel(data.avgB) / 85 : 0;
|
||
|
||
if (data.horizontal) {
|
||
if (data.letterboxA > 0) {
|
||
for (int y = 0; y < data.letterboxA; y++)
|
||
for (int x = 0; x < renderer.getScreenWidth(); x++) {
|
||
uint8_t lv;
|
||
if (isSolid) lv = levelA;
|
||
else if (hashA) lv = hashBlockDither(data.avgA, x, y);
|
||
else lv = quantizeBayerDither(data.avgA, x, y);
|
||
renderer.drawPixelGray(x, y, lv);
|
||
}
|
||
}
|
||
if (data.letterboxB > 0) {
|
||
const int start = renderer.getScreenHeight() - data.letterboxB;
|
||
for (int y = start; y < renderer.getScreenHeight(); y++)
|
||
for (int x = 0; x < renderer.getScreenWidth(); x++) {
|
||
uint8_t lv;
|
||
if (isSolid) lv = levelB;
|
||
else if (hashB) lv = hashBlockDither(data.avgB, x, y);
|
||
else lv = quantizeBayerDither(data.avgB, x, y);
|
||
renderer.drawPixelGray(x, y, lv);
|
||
}
|
||
}
|
||
} else {
|
||
if (data.letterboxA > 0) {
|
||
for (int x = 0; x < data.letterboxA; x++)
|
||
for (int y = 0; y < renderer.getScreenHeight(); y++) {
|
||
uint8_t lv;
|
||
if (isSolid) lv = levelA;
|
||
else if (hashA) lv = hashBlockDither(data.avgA, x, y);
|
||
else lv = quantizeBayerDither(data.avgA, x, y);
|
||
renderer.drawPixelGray(x, y, lv);
|
||
}
|
||
}
|
||
if (data.letterboxB > 0) {
|
||
const int start = renderer.getScreenWidth() - data.letterboxB;
|
||
for (int x = start; x < renderer.getScreenWidth(); x++)
|
||
for (int y = 0; y < renderer.getScreenHeight(); y++) {
|
||
uint8_t lv;
|
||
if (isSolid) lv = levelB;
|
||
else if (hashB) lv = hashBlockDither(data.avgB, x, y);
|
||
else lv = quantizeBayerDither(data.avgB, x, y);
|
||
renderer.drawPixelGray(x, y, lv);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
} // namespace
|
||
|
||
void SleepActivity::onEnter() {
|
||
Activity::onEnter();
|
||
GUI.drawPopup(renderer, "Entering Sleep...");
|
||
|
||
switch (SETTINGS.sleepScreen) {
|
||
case (CrossPointSettings::SLEEP_SCREEN_MODE::BLANK):
|
||
return renderBlankSleepScreen();
|
||
case (CrossPointSettings::SLEEP_SCREEN_MODE::CUSTOM):
|
||
return renderCustomSleepScreen();
|
||
case (CrossPointSettings::SLEEP_SCREEN_MODE::COVER):
|
||
case (CrossPointSettings::SLEEP_SCREEN_MODE::COVER_CUSTOM):
|
||
return renderCoverSleepScreen();
|
||
default:
|
||
return renderDefaultSleepScreen();
|
||
}
|
||
}
|
||
|
||
void SleepActivity::renderCustomSleepScreen() const {
|
||
// Check if we have a /sleep directory
|
||
auto dir = Storage.open("/sleep");
|
||
if (dir && dir.isDirectory()) {
|
||
std::vector<std::string> files;
|
||
char name[500];
|
||
// collect all valid BMP files
|
||
for (auto file = dir.openNextFile(); file; file = dir.openNextFile()) {
|
||
if (file.isDirectory()) {
|
||
file.close();
|
||
continue;
|
||
}
|
||
file.getName(name, sizeof(name));
|
||
auto filename = std::string(name);
|
||
if (filename[0] == '.') {
|
||
file.close();
|
||
continue;
|
||
}
|
||
|
||
if (filename.substr(filename.length() - 4) != ".bmp") {
|
||
Serial.printf("[%lu] [SLP] Skipping non-.bmp file name: %s\n", millis(), name);
|
||
file.close();
|
||
continue;
|
||
}
|
||
Bitmap bitmap(file);
|
||
if (bitmap.parseHeaders() != BmpReaderError::Ok) {
|
||
Serial.printf("[%lu] [SLP] Skipping invalid BMP file: %s\n", millis(), name);
|
||
file.close();
|
||
continue;
|
||
}
|
||
files.emplace_back(filename);
|
||
file.close();
|
||
}
|
||
const auto numFiles = files.size();
|
||
if (numFiles > 0) {
|
||
// Generate a random number between 1 and numFiles
|
||
auto randomFileIndex = random(numFiles);
|
||
// If we picked the same image as last time, reroll
|
||
while (numFiles > 1 && randomFileIndex == APP_STATE.lastSleepImage) {
|
||
randomFileIndex = random(numFiles);
|
||
}
|
||
APP_STATE.lastSleepImage = randomFileIndex;
|
||
APP_STATE.saveToFile();
|
||
const auto filename = "/sleep/" + files[randomFileIndex];
|
||
FsFile file;
|
||
if (Storage.openFileForRead("SLP", filename, file)) {
|
||
Serial.printf("[%lu] [SLP] Randomly loading: /sleep/%s\n", millis(), files[randomFileIndex].c_str());
|
||
delay(100);
|
||
Bitmap bitmap(file, true);
|
||
if (bitmap.parseHeaders() == BmpReaderError::Ok) {
|
||
renderBitmapSleepScreen(bitmap);
|
||
dir.close();
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if (dir) dir.close();
|
||
|
||
// Look for sleep.bmp on the root of the sd card to determine if we should
|
||
// render a custom sleep screen instead of the default.
|
||
FsFile file;
|
||
if (Storage.openFileForRead("SLP", "/sleep.bmp", file)) {
|
||
Bitmap bitmap(file, true);
|
||
if (bitmap.parseHeaders() == BmpReaderError::Ok) {
|
||
Serial.printf("[%lu] [SLP] Loading: /sleep.bmp\n", millis());
|
||
renderBitmapSleepScreen(bitmap);
|
||
return;
|
||
}
|
||
}
|
||
|
||
renderDefaultSleepScreen();
|
||
}
|
||
|
||
void SleepActivity::renderDefaultSleepScreen() const {
|
||
const auto pageWidth = renderer.getScreenWidth();
|
||
const auto pageHeight = renderer.getScreenHeight();
|
||
|
||
renderer.clearScreen();
|
||
renderer.drawImage(Logo120, (pageWidth - 120) / 2, (pageHeight - 120) / 2, 120, 120);
|
||
renderer.drawCenteredText(UI_10_FONT_ID, pageHeight / 2 + 70, "CrossPoint", true, EpdFontFamily::BOLD);
|
||
renderer.drawCenteredText(SMALL_FONT_ID, pageHeight / 2 + 95, "SLEEPING");
|
||
|
||
// Make sleep screen dark unless light is selected in settings
|
||
if (SETTINGS.sleepScreen != CrossPointSettings::SLEEP_SCREEN_MODE::LIGHT) {
|
||
renderer.invertScreen();
|
||
}
|
||
|
||
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
|
||
}
|
||
|
||
void SleepActivity::renderBitmapSleepScreen(const Bitmap& bitmap, const std::string& edgeCachePath) const {
|
||
int x, y;
|
||
const auto pageWidth = renderer.getScreenWidth();
|
||
const auto pageHeight = renderer.getScreenHeight();
|
||
float cropX = 0, cropY = 0;
|
||
|
||
Serial.printf("[%lu] [SLP] bitmap %d x %d, screen %d x %d\n", millis(), bitmap.getWidth(), bitmap.getHeight(),
|
||
pageWidth, pageHeight);
|
||
|
||
// Always compute aspect-ratio-preserving scale and position (supports both larger and smaller images)
|
||
float ratio = static_cast<float>(bitmap.getWidth()) / static_cast<float>(bitmap.getHeight());
|
||
const float screenRatio = static_cast<float>(pageWidth) / static_cast<float>(pageHeight);
|
||
|
||
Serial.printf("[%lu] [SLP] bitmap ratio: %f, screen ratio: %f\n", millis(), ratio, screenRatio);
|
||
if (ratio > screenRatio) {
|
||
// image wider than viewport ratio, needs to be centered vertically
|
||
if (SETTINGS.sleepScreenCoverMode == CrossPointSettings::SLEEP_SCREEN_COVER_MODE::CROP) {
|
||
cropX = 1.0f - (screenRatio / ratio);
|
||
Serial.printf("[%lu] [SLP] Cropping bitmap x: %f\n", millis(), cropX);
|
||
ratio = (1.0f - cropX) * static_cast<float>(bitmap.getWidth()) / static_cast<float>(bitmap.getHeight());
|
||
}
|
||
x = 0;
|
||
y = std::round((static_cast<float>(pageHeight) - static_cast<float>(pageWidth) / ratio) / 2);
|
||
Serial.printf("[%lu] [SLP] Centering with ratio %f to y=%d\n", millis(), ratio, y);
|
||
} else {
|
||
// image taller than or equal to viewport ratio, needs to be centered horizontally
|
||
if (SETTINGS.sleepScreenCoverMode == CrossPointSettings::SLEEP_SCREEN_COVER_MODE::CROP) {
|
||
cropY = 1.0f - (ratio / screenRatio);
|
||
Serial.printf("[%lu] [SLP] Cropping bitmap y: %f\n", millis(), cropY);
|
||
ratio = static_cast<float>(bitmap.getWidth()) / ((1.0f - cropY) * static_cast<float>(bitmap.getHeight()));
|
||
}
|
||
x = std::round((static_cast<float>(pageWidth) - static_cast<float>(pageHeight) * ratio) / 2);
|
||
y = 0;
|
||
Serial.printf("[%lu] [SLP] Centering with ratio %f to x=%d\n", millis(), ratio, x);
|
||
}
|
||
|
||
Serial.printf("[%lu] [SLP] drawing to %d x %d\n", millis(), x, y);
|
||
|
||
// Compute the scale factor (same formula as drawBitmap) so we can map screen coords to source coords
|
||
const float effectiveWidth = (1.0f - cropX) * bitmap.getWidth();
|
||
const float effectiveHeight = (1.0f - cropY) * bitmap.getHeight();
|
||
const float scale =
|
||
std::min(static_cast<float>(pageWidth) / effectiveWidth, static_cast<float>(pageHeight) / effectiveHeight);
|
||
|
||
// Determine letterbox fill settings
|
||
const uint8_t fillMode = SETTINGS.sleepScreenLetterboxFill;
|
||
const bool wantFill = (fillMode != CrossPointSettings::SLEEP_SCREEN_LETTERBOX_FILL::LETTERBOX_NONE);
|
||
|
||
static const char* fillModeNames[] = {"dithered", "solid", "none"};
|
||
const char* fillModeName = (fillMode < 3) ? fillModeNames[fillMode] : "unknown";
|
||
|
||
// Compute edge averages if letterbox fill is requested (try cache first)
|
||
LetterboxFillData fillData;
|
||
const bool hasLetterbox = (x > 0 || y > 0);
|
||
if (hasLetterbox && wantFill) {
|
||
bool cacheLoaded = false;
|
||
if (!edgeCachePath.empty()) {
|
||
cacheLoaded = loadEdgeCache(edgeCachePath, pageWidth, pageHeight, fillData);
|
||
}
|
||
if (!cacheLoaded) {
|
||
Serial.printf("[%lu] [SLP] Letterbox detected (x=%d, y=%d), computing edge averages for %s fill\n", millis(), x,
|
||
y, fillModeName);
|
||
fillData = computeEdgeAverages(bitmap, x, y, pageWidth, pageHeight, scale, cropX, cropY);
|
||
if (fillData.valid && !edgeCachePath.empty()) {
|
||
saveEdgeCache(edgeCachePath, pageWidth, pageHeight, fillData);
|
||
}
|
||
}
|
||
if (fillData.valid) {
|
||
Serial.printf("[%lu] [SLP] Letterbox fill: %s, horizontal=%d, avgA=%d, avgB=%d, letterboxA=%d, letterboxB=%d\n",
|
||
millis(), fillModeName, fillData.horizontal, fillData.avgA, fillData.avgB, fillData.letterboxA,
|
||
fillData.letterboxB);
|
||
}
|
||
}
|
||
|
||
renderer.clearScreen();
|
||
|
||
const bool hasGreyscale = bitmap.hasGreyscale() &&
|
||
SETTINGS.sleepScreenCoverFilter == CrossPointSettings::SLEEP_SCREEN_COVER_FILTER::NO_FILTER;
|
||
|
||
// Draw letterbox fill (BW pass)
|
||
if (fillData.valid) {
|
||
drawLetterboxFill(renderer, fillData, fillMode);
|
||
}
|
||
|
||
renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, cropX, cropY);
|
||
|
||
if (SETTINGS.sleepScreenCoverFilter == CrossPointSettings::SLEEP_SCREEN_COVER_FILTER::INVERTED_BLACK_AND_WHITE) {
|
||
renderer.invertScreen();
|
||
}
|
||
|
||
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
|
||
|
||
if (hasGreyscale) {
|
||
bitmap.rewindToData();
|
||
renderer.clearScreen(0x00);
|
||
renderer.setRenderMode(GfxRenderer::GRAYSCALE_LSB);
|
||
if (fillData.valid) {
|
||
drawLetterboxFill(renderer, fillData, fillMode);
|
||
}
|
||
renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, cropX, cropY);
|
||
renderer.copyGrayscaleLsbBuffers();
|
||
|
||
bitmap.rewindToData();
|
||
renderer.clearScreen(0x00);
|
||
renderer.setRenderMode(GfxRenderer::GRAYSCALE_MSB);
|
||
if (fillData.valid) {
|
||
drawLetterboxFill(renderer, fillData, fillMode);
|
||
}
|
||
renderer.drawBitmap(bitmap, x, y, pageWidth, pageHeight, cropX, cropY);
|
||
renderer.copyGrayscaleMsbBuffers();
|
||
|
||
renderer.displayGrayBuffer();
|
||
renderer.setRenderMode(GfxRenderer::BW);
|
||
}
|
||
}
|
||
|
||
void SleepActivity::renderCoverSleepScreen() const {
|
||
void (SleepActivity::*renderNoCoverSleepScreen)() const;
|
||
switch (SETTINGS.sleepScreen) {
|
||
case (CrossPointSettings::SLEEP_SCREEN_MODE::COVER_CUSTOM):
|
||
renderNoCoverSleepScreen = &SleepActivity::renderCustomSleepScreen;
|
||
break;
|
||
default:
|
||
renderNoCoverSleepScreen = &SleepActivity::renderDefaultSleepScreen;
|
||
break;
|
||
}
|
||
|
||
if (APP_STATE.openEpubPath.empty()) {
|
||
return (this->*renderNoCoverSleepScreen)();
|
||
}
|
||
|
||
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") ||
|
||
StringUtils::checkFileExtension(APP_STATE.openEpubPath, ".xtch")) {
|
||
// Handle XTC file
|
||
Xtc lastXtc(APP_STATE.openEpubPath, "/.crosspoint");
|
||
if (!lastXtc.load()) {
|
||
Serial.printf("[%lu] [SLP] Failed to load last XTC\n", millis());
|
||
return (this->*renderNoCoverSleepScreen)();
|
||
}
|
||
|
||
if (!lastXtc.generateCoverBmp()) {
|
||
Serial.printf("[%lu] [SLP] Failed to generate XTC cover bmp\n", millis());
|
||
return (this->*renderNoCoverSleepScreen)();
|
||
}
|
||
|
||
coverBmpPath = lastXtc.getCoverBmpPath();
|
||
} else if (StringUtils::checkFileExtension(APP_STATE.openEpubPath, ".txt")) {
|
||
// Handle TXT file - looks for cover image in the same folder
|
||
Txt lastTxt(APP_STATE.openEpubPath, "/.crosspoint");
|
||
if (!lastTxt.load()) {
|
||
Serial.printf("[%lu] [SLP] Failed to load last TXT\n", millis());
|
||
return (this->*renderNoCoverSleepScreen)();
|
||
}
|
||
|
||
if (!lastTxt.generateCoverBmp()) {
|
||
Serial.printf("[%lu] [SLP] No cover image found for TXT file\n", millis());
|
||
return (this->*renderNoCoverSleepScreen)();
|
||
}
|
||
|
||
coverBmpPath = lastTxt.getCoverBmpPath();
|
||
} else if (StringUtils::checkFileExtension(APP_STATE.openEpubPath, ".epub")) {
|
||
// Handle EPUB file
|
||
Epub lastEpub(APP_STATE.openEpubPath, "/.crosspoint");
|
||
// Skip loading css since we only need metadata here
|
||
if (!lastEpub.load(true, true)) {
|
||
Serial.printf("[%lu] [SLP] Failed to load last epub\n", millis());
|
||
return (this->*renderNoCoverSleepScreen)();
|
||
}
|
||
|
||
if (!lastEpub.generateCoverBmp(cropped)) {
|
||
Serial.printf("[%lu] [SLP] Failed to generate cover bmp\n", millis());
|
||
return (this->*renderNoCoverSleepScreen)();
|
||
}
|
||
|
||
coverBmpPath = lastEpub.getCoverBmpPath(cropped);
|
||
} else {
|
||
return (this->*renderNoCoverSleepScreen)();
|
||
}
|
||
|
||
FsFile file;
|
||
if (Storage.openFileForRead("SLP", coverBmpPath, file)) {
|
||
Bitmap bitmap(file);
|
||
if (bitmap.parseHeaders() == BmpReaderError::Ok) {
|
||
Serial.printf("[%lu] [SLP] Rendering sleep cover: %s\n", millis(), coverBmpPath.c_str());
|
||
// Derive edge cache path from cover BMP path (e.g. cover.bmp -> cover_edges.bin)
|
||
std::string edgeCachePath;
|
||
const auto dotPos = coverBmpPath.rfind(".bmp");
|
||
if (dotPos != std::string::npos) {
|
||
edgeCachePath = coverBmpPath.substr(0, dotPos) + "_edges.bin";
|
||
}
|
||
renderBitmapSleepScreen(bitmap, edgeCachePath);
|
||
return;
|
||
}
|
||
}
|
||
|
||
return (this->*renderNoCoverSleepScreen)();
|
||
}
|
||
|
||
void SleepActivity::renderBlankSleepScreen() const {
|
||
renderer.clearScreen();
|
||
renderer.displayBuffer(HalDisplay::HALF_REFRESH);
|
||
}
|