fix: Replace full-image cache buffer with streaming band buffer to reduce memory usage (#2230)

This commit is contained in:
Justin Mitchell
2026-06-04 10:46:22 -04:00
committed by GitHub
parent a60f31cdd4
commit d9bcef7a58
5 changed files with 304 additions and 82 deletions
+43 -7
View File
@@ -1,5 +1,6 @@
#include "ImageBlock.h"
#include <FontCacheManager.h>
#include <GfxRenderer.h>
#include <Logging.h>
#include <Serialization.h>
@@ -55,10 +56,22 @@ bool renderFromCache(GfxRenderer& renderer, const std::string& cachePath, int x,
LOG_DBG("IMG", "Loading from cache: %s (%dx%d)", cachePath.c_str(), cachedWidth, cachedHeight);
// Read and render row by row to minimize memory usage
// Read several rows per SD access. A full-page image is re-rendered on every
// grayscale strip pass (~14x per page), and a one-row-per-read loop here means
// cachedHeight (~728) tiny reads through the storage mutex + SdFat each time —
// the dominant cost of displaying an image page. Batching rows into a ~4KB
// buffer cuts that to ~20 reads per pass without holding the whole image.
const int bytesPerRow = (cachedWidth + 3) / 4; // 2 bits per pixel, 4 pixels per byte
uint8_t* rowBuffer = (uint8_t*)malloc(bytesPerRow);
if (!rowBuffer) {
int rowsPerRead = 4096 / bytesPerRow;
if (rowsPerRead < 1) rowsPerRead = 1;
if (rowsPerRead > cachedHeight) rowsPerRead = cachedHeight;
uint8_t* readBuffer = (uint8_t*)malloc((size_t)rowsPerRead * bytesPerRow);
if (!readBuffer) {
// Fall back to a single-row buffer under memory pressure.
rowsPerRead = 1;
readBuffer = (uint8_t*)malloc(bytesPerRow);
}
if (!readBuffer) {
LOG_ERR("IMG", "Failed to allocate row buffer");
return false;
}
@@ -66,16 +79,31 @@ bool renderFromCache(GfxRenderer& renderer, const std::string& cachePath, int x,
DirectPixelWriter pw;
pw.init(renderer);
int rowsInBuffer = 0;
int bufferRow = 0;
for (int row = 0; row < cachedHeight; row++) {
if (cacheFile.read(rowBuffer, bytesPerRow) != bytesPerRow) {
if (bufferRow >= rowsInBuffer) {
const int toRead = (cachedHeight - row < rowsPerRead) ? (cachedHeight - row) : rowsPerRead;
const size_t bytes = (size_t)toRead * bytesPerRow;
if (cacheFile.read(readBuffer, bytes) != static_cast<int>(bytes)) {
LOG_ERR("IMG", "Cache read error at row %d", row);
free(rowBuffer);
free(readBuffer);
return false;
}
rowsInBuffer = toRead;
bufferRow = 0;
}
const uint8_t* rowBuffer = readBuffer + (size_t)bufferRow * bytesPerRow;
bufferRow++;
const int destY = y + row;
pw.beginRow(destY);
for (int col = 0; col < cachedWidth; col++) {
// On a grayscale strip pass only a narrow column window of the image is in
// the active band; skip the rest instead of unpacking+clipping every pixel.
int colStart, colEnd;
pw.bandColRange(x, cachedWidth, colStart, colEnd);
for (int col = colStart; col < colEnd; col++) {
const int byteIdx = col >> 2; // col / 4
const int bitShift = 6 - (col & 3) * 2; // MSB first within byte
uint8_t pixelValue = (rowBuffer[byteIdx] >> bitShift) & 0x03;
@@ -84,7 +112,7 @@ bool renderFromCache(GfxRenderer& renderer, const std::string& cachePath, int x,
}
}
free(rowBuffer);
free(readBuffer);
LOG_DBG("IMG", "Cache render complete");
return true;
}
@@ -92,6 +120,14 @@ bool renderFromCache(GfxRenderer& renderer, const std::string& cachePath, int x,
} // namespace
void ImageBlock::render(GfxRenderer& renderer, const int x, const int y) {
// The font-prewarm scan pass only accumulates glyphs; an image contributes
// none, and its DirectPixelWriter output bypasses the renderer's scan-mode
// suppression, so it would otherwise do a full (discarded) cache render every
// page view. Skip it here. The image still draws in the real BW/grayscale
// passes; on first view this just moves the one-time decode to the BW pass.
FontCacheManager* fcm = renderer.getFontCacheManager();
if (fcm && fcm->isScanning()) return;
LOG_DBG("IMG", "Rendering image at %d,%d: %s (%dx%d)", x, y, imagePath.c_str(), width, height);
const int screenWidth = renderer.getScreenWidth();
+63 -6
View File
@@ -4,6 +4,8 @@
#include <HalDisplay.h>
#include <stdint.h>
#include <cassert>
// Direct framebuffer writer that eliminates per-pixel overhead from the image
// rendering hot path. Pre-computes orientation transform as linear coefficients
// and caches render-mode state so the inner loop is: one multiply, one add,
@@ -99,6 +101,46 @@ struct DirectPixelWriter {
rowPhyYBase = phyYBase + logicalY * phyYStepY;
}
// For the current row (set via beginRow), narrow [colStart, colEnd) to the
// columns whose pixels fall inside the active strip band. writePixel() would
// clip the rest anyway, but on a strip pass that is most of a full-page image
// (only ~one strip-height worth of columns survive in portrait); skipping them
// here avoids the per-pixel unpack+transform entirely. For full-frame passes
// (clipRows == panel height) the range is unchanged. xBase is the logical X of
// column 0; the band test mirrors writePixel(): 0 <= phyY - originY < clipRows.
inline void bandColRange(int xBase, int width, int& colStart, int& colEnd) const {
// init() only ever sets phyYStepX to 0, +1, or -1; the +1/-1 solve below
// relies on that.
assert(phyYStepX == 0 || phyYStepX == 1 || phyYStepX == -1);
colStart = 0;
colEnd = width;
if (phyYStepX == 0) {
// phyY is constant across the row: the whole row is in-band or out.
const int sy = rowPhyYBase - originY;
if (static_cast<unsigned>(sy) >= static_cast<unsigned>(clipRows)) colEnd = 0;
return;
}
// phyY = rowPhyYBase + logicalX * phyYStepX (phyYStepX is +1 or -1).
// Solve originY <= phyY <= originY + clipRows - 1 for logicalX.
const int loY = originY;
const int hiY = originY + clipRows - 1;
int xLo, xHi;
if (phyYStepX > 0) {
xLo = loY - rowPhyYBase;
xHi = hiY - rowPhyYBase;
} else {
xLo = rowPhyYBase - hiY;
xHi = rowPhyYBase - loY;
}
const int cs = xLo - xBase;
const int ce = xHi - xBase + 1; // exclusive
if (cs > colStart) colStart = cs;
if (ce < colEnd) colEnd = ce;
if (colStart < 0) colStart = 0;
if (colEnd > width) colEnd = width;
if (colStart > colEnd) colStart = colEnd;
}
// Write a single 2-bit dithered pixel value to the framebuffer.
// Must be called after beginRow() for the current row.
// No bounds checking — caller guarantees coordinates are valid.
@@ -147,27 +189,42 @@ struct DirectPixelWriter {
// Direct cache writer that eliminates per-pixel overhead from PixelCache::setPixel().
// Pre-computes row pointer so the inner loop is just byte index + bit manipulation.
//
// Caller guarantees coordinates are within cache bounds.
// The cache buffer is a small streaming band (e.g. 16 rows), not the full image,
// so a band-relative row/column that lands outside it would corrupt adjacent
// heap. This writer therefore bounds-checks every access: beginRow() invalidates
// the row when it falls outside the band, and writePixel() drops out-of-range
// columns. This path only runs during the single decode that populates the
// cache, never on the screen render hot path, so the checks are cheap.
struct DirectCacheWriter {
uint8_t* buffer;
int bytesPerRow;
int bandRows;
int originX;
uint8_t* rowPtr; // Pre-computed for current row
uint8_t* rowPtr; // Pre-computed for current row; nullptr if row is out of band
void init(uint8_t* cacheBuffer, int cacheBytesPerRow, int cacheOriginX) {
void init(uint8_t* cacheBuffer, int cacheBytesPerRow, int cacheBandRows, int cacheOriginX) {
buffer = cacheBuffer;
bytesPerRow = cacheBytesPerRow;
bandRows = cacheBandRows;
originX = cacheOriginX;
rowPtr = nullptr;
}
// Call once per row before the column loop.
inline void beginRow(int screenY, int cacheOriginY) { rowPtr = buffer + (screenY - cacheOriginY) * bytesPerRow; }
// Call once per row before the column loop. Drops rows outside the band.
inline void beginRow(int screenY, int cacheOriginY) {
const int localRow = screenY - cacheOriginY;
rowPtr = (static_cast<unsigned>(localRow) < static_cast<unsigned>(bandRows))
? buffer + (size_t)localRow * bytesPerRow
: nullptr;
}
// Write a 2-bit pixel value. No bounds checking.
// Write a 2-bit pixel value. Drops the write if the row is out of band or the
// column is out of range.
inline void writePixel(int screenX, uint8_t value) const {
if (!rowPtr) return;
const int localX = screenX - originX;
const int byteIdx = localX >> 2; // localX / 4
if (static_cast<unsigned>(byteIdx) >= static_cast<unsigned>(bytesPerRow)) return;
const int bitShift = 6 - (localX & 3) * 2; // MSB first: pixel 0 at bits 6-7
rowPtr[byteIdx] = (rowPtr[byteIdx] & ~(0x03 << bitShift)) | ((value & 0x03) << bitShift);
}
@@ -132,7 +132,7 @@ int jpegDrawCallback(JPEGDRAW* pDraw) {
if (stride <= 0 || blockH <= 0 || validW <= 0) return 1;
const bool useDithering = ctx->config->useDithering;
const bool caching = ctx->caching;
bool caching = ctx->caching;
const int32_t fineScaleFPX = ctx->fineScaleFPX;
const int32_t invScaleFPX = ctx->invScaleFPX;
const int32_t fineScaleFPY = ctx->fineScaleFPY;
@@ -169,9 +169,21 @@ int jpegDrawCallback(JPEGDRAW* pDraw) {
DirectPixelWriter pw;
pw.init(renderer);
// The cache streams to disk one MCU-row band at a time. Flushing rows below
// this block (raster order guarantees they are final) repositions the band;
// cacheOriginY then maps screen rows to the band-local buffer rows. If a flush
// write fails, stop caching for the rest of this decode (and let finalize drop
// the partial file) rather than writing past the band buffer.
DirectCacheWriter cw;
int cacheOriginY = 0;
if (caching) {
cw.init(ctx->cache.buffer, ctx->cache.bytesPerRow, ctx->cache.originX);
if (!ctx->cache.advanceTo(dstYStart)) {
caching = false;
ctx->caching = false;
} else {
cw.init(ctx->cache.buffer, ctx->cache.bytesPerRow, ctx->cache.bandRows, ctx->cache.originX);
cacheOriginY = ctx->config->y + ctx->cache.bandStart;
}
}
// === 1:1 fast path: no scaling math ===
@@ -179,7 +191,7 @@ int jpegDrawCallback(JPEGDRAW* pDraw) {
for (int dstY = dstYStart; dstY < dstYEnd; dstY++) {
const int outY = cfgY + dstY;
pw.beginRow(outY);
if (caching) cw.beginRow(outY, ctx->config->y);
if (caching) cw.beginRow(outY, cacheOriginY);
const uint8_t* row = &pixels[(dstY - blockY) * stride];
for (int dstX = dstXStart; dstX < dstXEnd; dstX++) {
const int outX = cfgX + dstX;
@@ -213,7 +225,7 @@ int jpegDrawCallback(JPEGDRAW* pDraw) {
for (int dstY = dstYStart; dstY < dstYEnd; dstY++) {
const int outY = cfgY + dstY;
pw.beginRow(outY);
if (caching) cw.beginRow(outY, ctx->config->y);
if (caching) cw.beginRow(outY, cacheOriginY);
const int32_t srcFyFP = dstY * invScaleFPY;
const int32_t fy = srcFyFP & FP_MASK;
const int32_t fyInv = FP_ONE - fy;
@@ -310,7 +322,7 @@ int jpegDrawCallback(JPEGDRAW* pDraw) {
for (int dstY = dstYStart; dstY < dstYEnd; dstY++) {
const int outY = cfgY + dstY;
pw.beginRow(outY);
if (caching) cw.beginRow(outY, ctx->config->y);
if (caching) cw.beginRow(outY, cacheOriginY);
const int32_t srcFyFP = dstY * invScaleFPY;
int ly = (srcFyFP >> FP_SHIFT) - blockY;
if (ly < 0) ly = 0;
@@ -469,11 +481,14 @@ bool JpegToFramebufferConverter::decodeToFramebuffer(const std::string& imagePat
jpeg->setPixelType(EIGHT_BIT_GRAYSCALE);
jpeg->setUserPointer(&ctx);
// Allocate cache buffer using final output dimensions
// Start streaming the pixel cache to disk. The band only needs to hold the
// tallest single decode block: a JPEGDEC MCU cell is at most 16 scaled-source
// rows tall, which our fine scale maps to this many output rows.
ctx.caching = !config.cachePath.empty();
if (ctx.caching) {
if (!ctx.cache.allocate(destWidth, destHeight, config.x, config.y)) {
LOG_ERR("JPG", "Failed to allocate cache buffer, continuing without caching");
const int maxBlockDstRows = (int)(((int64_t)16 * ctx.fineScaleFPY) >> FP_SHIFT) + 2;
if (!ctx.cache.begin(config.cachePath, destWidth, destHeight, config.x, config.y, maxBlockDstRows)) {
LOG_ERR("JPG", "Failed to start cache stream, continuing without caching");
ctx.caching = false;
}
}
@@ -484,14 +499,16 @@ bool JpegToFramebufferConverter::decodeToFramebuffer(const std::string& imagePat
if (rc != 1) {
LOG_ERR("JPG", "Decode failed (rc=%d, lastError=%d)", rc, jpeg->getLastError());
if (ctx.caching) ctx.cache.abort();
return false;
}
LOG_DBG("JPG", "JPEG decoding complete - render time: %lu ms", decodeTime);
// Write cache file if caching was enabled
// Finalize the streamed cache file. Note: a flush failure mid-decode clears
// ctx.caching (the partial file is dropped), so re-read the flag here.
if (ctx.caching) {
ctx.cache.writeToFile(config.cachePath);
ctx.cache.finalize();
}
return true;
+144 -39
View File
@@ -4,76 +4,181 @@
#include <Logging.h>
#include <stdint.h>
#include <cstdlib>
#include <cstring>
#include <string>
// Cache buffer for storing 2-bit pixels (4 levels) during decode.
// Packs 4 pixels per byte, MSB first.
// Streaming cache writer for 2-bit pixels (4 levels). Packs 4 pixels per byte,
// MSB first.
//
// The .pxc file is written incrementally in small row bands rather than holding
// the whole decoded image in one heap buffer. A full-page image (e.g. 482x728)
// needs ~88KB packed, which will not fit alongside the ~20KB JPEG decoder on a
// fragmented 380KB heap (free heap is routinely ~55KB on an image page). When
// the cache cannot be written, every render pass re-decodes the JPEG from
// scratch; an anti-aliased image page renders ~14 times (BW + AA restore + two
// grayscale planes x ~6 strips), so a 2s decode becomes a ~30s freeze / watchdog
// reset. Streaming keeps the working set to a single MCU-row band, so caching
// succeeds and the image is decoded exactly once.
//
// Correctness relies on JPEGDEC delivering blocks in raster MCU order (outer
// loop over y, inner over x: see jpeg.inl DecodeJPEG). Consecutive MCU rows map
// to contiguous, non-overlapping destination row ranges, so once a block whose
// top row is Y arrives, every output row < Y is final and is flushed to disk.
struct PixelCache {
uint8_t* buffer;
uint8_t* buffer; // band buffer: (bandRows + 1) rows; last row kept zeroed
uint8_t* zeroRow; // points at the spare zeroed row, for gap/clip fill
int width;
int height;
int bytesPerRow;
int originX; // config.x - to convert screen coords to cache coords
int originY; // config.y
int bandRows; // rows held in the band buffer
int bandStart; // image-local row index of band buffer row 0
int flushedRows; // image-local rows already written to file
HalFile file;
std::string cachePathStr;
bool ok;
PixelCache() : buffer(nullptr), width(0), height(0), bytesPerRow(0), originX(0), originY(0) {}
PixelCache()
: buffer(nullptr),
zeroRow(nullptr),
width(0),
height(0),
bytesPerRow(0),
originX(0),
originY(0),
bandRows(0),
bandStart(0),
flushedRows(0),
ok(false) {}
PixelCache(const PixelCache&) = delete;
PixelCache& operator=(const PixelCache&) = delete;
static constexpr size_t MAX_CACHE_BYTES = 256 * 1024; // 256KB limit for embedded targets
static constexpr int MIN_BAND_ROWS = 16;
static constexpr size_t MAX_BAND_BYTES = 24 * 1024; // band working-set ceiling
bool allocate(int w, int h, int ox, int oy) {
// Open the cache file, write the header, and allocate a band buffer big enough
// to hold the tallest single decode block (maxBlockDstRows output rows).
bool begin(const std::string& cachePath, int w, int h, int ox, int oy, int maxBlockDstRows) {
width = w;
height = h;
originX = ox;
originY = oy;
bytesPerRow = (w + 3) / 4; // 2 bits per pixel, 4 pixels per byte
size_t bufferSize = (size_t)bytesPerRow * h;
if (bufferSize > MAX_CACHE_BYTES) {
LOG_ERR("IMG", "Cache buffer too large: %d bytes for %dx%d (limit %d)", bufferSize, w, h, MAX_CACHE_BYTES);
bandStart = 0;
flushedRows = 0;
ok = false;
int wantRows = maxBlockDstRows + 2;
if (wantRows < MIN_BAND_ROWS) wantRows = MIN_BAND_ROWS;
if (wantRows > h) wantRows = h;
size_t maxRowsByMem = MAX_BAND_BYTES / (size_t)bytesPerRow;
if (maxRowsByMem < 1) maxRowsByMem = 1;
if ((size_t)wantRows > maxRowsByMem) wantRows = (int)maxRowsByMem;
// A single decode block must fit inside the band, otherwise streaming would
// drop rows. This only fails for pathological upscales that could not be
// cached at all; fall back to the no-cache path.
if (wantRows < maxBlockDstRows) {
LOG_ERR("IMG", "Cache band too small (%d < %d rows) for %dx%d", wantRows, maxBlockDstRows, w, h);
return false;
}
buffer = (uint8_t*)malloc(bufferSize);
if (buffer) {
memset(buffer, 0, bufferSize);
LOG_DBG("IMG", "Allocated cache buffer: %d bytes for %dx%d", bufferSize, w, h);
}
return buffer != nullptr;
bandRows = wantRows;
const size_t bufSize = (size_t)(bandRows + 1) * bytesPerRow; // +1 spare zero row
buffer = (uint8_t*)malloc(bufSize);
if (!buffer) {
LOG_ERR("IMG", "OOM cache band: %u bytes", (unsigned)bufSize);
return false;
}
memset(buffer, 0, bufSize);
zeroRow = buffer + (size_t)bandRows * bytesPerRow;
void setPixel(int screenX, int screenY, uint8_t value) {
if (!buffer) return;
int localX = screenX - originX;
int localY = screenY - originY;
if (localX < 0 || localX >= width || localY < 0 || localY >= height) return;
int byteIdx = localY * bytesPerRow + localX / 4;
int bitShift = 6 - (localX % 4) * 2; // MSB first: pixel 0 at bits 6-7
buffer[byteIdx] = (buffer[byteIdx] & ~(0x03 << bitShift)) | ((value & 0x03) << bitShift);
}
bool writeToFile(const std::string& cachePath) {
if (!buffer) return false;
HalFile cacheFile;
if (!Storage.openFileForWrite("IMG", cachePath, cacheFile)) {
if (!Storage.openFileForWrite("IMG", cachePath, file)) {
LOG_ERR("IMG", "Failed to open cache file for writing: %s", cachePath.c_str());
free(buffer);
buffer = nullptr;
return false;
}
cachePathStr = cachePath;
uint16_t w16 = (uint16_t)w;
uint16_t h16 = (uint16_t)h;
if (file.write(&w16, 2) != 2 || file.write(&h16, 2) != 2) {
LOG_ERR("IMG", "Failed to write cache header: %s", cachePath.c_str());
abort();
return false;
}
uint16_t w = width;
uint16_t h = height;
cacheFile.write(&w, 2);
cacheFile.write(&h, 2);
cacheFile.write(buffer, bytesPerRow * height);
cacheFile.close();
LOG_DBG("IMG", "Cache written: %s (%dx%d, %d bytes)", cachePath.c_str(), width, height, 4 + bytesPerRow * height);
LOG_DBG("IMG", "Cache stream started: %s (%dx%d, band %d rows)", cachePath.c_str(), w, h, bandRows);
ok = true;
return true;
}
// Flush every output row below newTopRow (they are final in raster order) and
// reposition the band to start at newTopRow. Returns false if a write failed,
// in which case the caller must stop caching for the rest of the decode.
bool advanceTo(int newTopRow) {
if (!ok) return false;
if (newTopRow <= bandStart) return true;
if (newTopRow > height) newTopRow = height;
for (int r = bandStart; r < newTopRow; ++r) {
const int idx = r - bandStart;
const uint8_t* rowPtr = (idx < bandRows) ? (buffer + (size_t)idx * bytesPerRow) : zeroRow;
if (file.write(rowPtr, (size_t)bytesPerRow) != (size_t)bytesPerRow) {
LOG_ERR("IMG", "Cache write error at row %d", r);
ok = false;
return false;
}
}
flushedRows = newTopRow;
bandStart = newTopRow;
memset(buffer, 0, (size_t)bandRows * bytesPerRow); // fresh band (gaps stay black)
return true;
}
// Flush the final band and zero-fill any rows never covered (image clipped by
// the screen), then close the file.
bool finalize() {
if (!ok) {
abort();
return false;
}
for (int r = flushedRows; r < height; ++r) {
const int idx = r - bandStart;
const uint8_t* rowPtr = (idx >= 0 && idx < bandRows) ? (buffer + (size_t)idx * bytesPerRow) : zeroRow;
if (file.write(rowPtr, (size_t)bytesPerRow) != (size_t)bytesPerRow) {
LOG_ERR("IMG", "Cache write error at row %d", r);
abort();
return false;
}
}
file.close();
LOG_DBG("IMG", "Cache written: %s (%dx%d, %d bytes)", cachePathStr.c_str(), width, height,
4 + bytesPerRow * height);
ok = false; // file handed off; nothing left to clean up
return true;
}
// Drop a partial/failed cache so a later decode re-creates it cleanly.
void abort() {
if (file.isOpen()) file.close();
if (!cachePathStr.empty()) {
Storage.remove(cachePathStr.c_str());
}
ok = false;
}
~PixelCache() {
if (file.isOpen()) {
// The file is still open, so neither finalize() nor abort() ran, or a
// mid-stream write failed (advanceTo() cleared ok but left the file open).
// Drop the partial cache so we leave no corrupt file behind.
abort();
}
if (buffer) {
free(buffer);
buffer = nullptr;
@@ -201,10 +201,19 @@ int pngDrawCallback(PNGDRAW* pDraw) {
pw.init(*ctx->renderer);
pw.beginRow(outY);
// The cache streams to disk one row at a time. Flushing rows below this one
// (PNGdec delivers scanlines top to bottom) repositions the single-row band.
// A flush failure stops caching for the rest of the decode so we never write
// past the band buffer; finalize() then drops the partial file.
DirectCacheWriter cw;
if (caching) {
cw.init(ctx->cache.buffer, ctx->cache.bytesPerRow, ctx->cache.originX);
cw.beginRow(outY, ctx->config->y);
if (!ctx->cache.advanceTo(dstY)) {
caching = false;
ctx->caching = false;
} else {
cw.init(ctx->cache.buffer, ctx->cache.bytesPerRow, ctx->cache.bandRows, ctx->cache.originX);
cw.beginRow(outY, ctx->config->y + ctx->cache.bandStart);
}
}
int srcX = 0;
@@ -348,19 +357,16 @@ bool PngToFramebufferConverter::decodeToFramebuffer(const std::string& imagePath
return false;
}
// Allocate cache buffer using SCALED dimensions.
// PNG decode is fast enough (~135ms for 400x600) that caching provides minimal benefit
// for larger images, while the cache buffer competes with the 44KB PNG decoder for heap.
// Skip caching when the buffer would exceed the framebuffer size (48KB).
static constexpr size_t PNG_MAX_CACHE_BYTES = 48000;
// Stream the pixel cache to disk. PNGdec delivers source scanlines top to
// bottom and we emit at most one (downscaled) output row per callback, so the
// band only needs a single row. Streaming keeps the working set tiny, so
// unlike the old full-image buffer it neither competes with the ~44KB decoder
// nor forces larger images to skip caching - which previously meant a full
// re-decode on every one of an image page's ~14 render passes.
ctx.caching = !config.cachePath.empty();
if (ctx.caching) {
size_t cacheSize = (size_t)((ctx.dstWidth + 3) / 4) * ctx.dstHeight;
if (cacheSize > PNG_MAX_CACHE_BYTES) {
LOG_DBG("PNG", "Skipping cache: %zu bytes exceeds PNG limit (%zu)", cacheSize, PNG_MAX_CACHE_BYTES);
ctx.caching = false;
} else if (!ctx.cache.allocate(ctx.dstWidth, ctx.dstHeight, config.x, config.y)) {
LOG_ERR("PNG", "Failed to allocate cache buffer, continuing without caching");
if (!ctx.cache.begin(config.cachePath, ctx.dstWidth, ctx.dstHeight, config.x, config.y, 1)) {
LOG_ERR("PNG", "Failed to start cache stream, continuing without caching");
ctx.caching = false;
}
}
@@ -374,14 +380,15 @@ bool PngToFramebufferConverter::decodeToFramebuffer(const std::string& imagePath
if (rc != PNG_SUCCESS) {
LOG_ERR("PNG", "Decode failed: %d", rc);
if (ctx.caching) ctx.cache.abort();
return false;
}
LOG_DBG("PNG", "PNG decoding complete - render time: %lu ms", decodeTime);
// Write cache file if caching was enabled and buffer was allocated
// Finalize the streamed cache (caching may have been cleared on a flush error).
if (ctx.caching) {
ctx.cache.writeToFile(config.cachePath);
ctx.cache.finalize();
}
return true;