fix(images): cap and stream image decoding in a GL-free decoder

Image decoding lived in the GL texture file and had no limits: a file of
any size was read whole, stb and libwebp allocated whatever the header
claimed (a WebP alone up to 1 GB), and a GIF was decoded at full size,
every frame, before the 300-frame cap. A 720p 150-frame GIF avatar cost
0.2-1.5 s and 140-370 MB on the UI thread. stb's GIF loader also read
before the start of its buffer on disposal-3 frames, and when a long GIF
ran out of memory it freed the frame delays that the caller then freed
again (an abort).

Decoding, and the stb implementation, now live in the GL-free
util/image_decode_core, safe on any thread:

- the header is probed first; stills above 256 MP, any GIF or animated
  WebP canvas above 32 Mi px, and files above 256 MiB are rejected
  before anything is allocated (real photos, 200 MP phone shots and
  wide panoramas still load);
- a GIF decodes one composited frame at a time, downscaled as it goes,
  and stops at the frame cap: peak memory is stb's working buffers, two
  canvases and the small frames; a truncated GIF keeps the frames
  decoded so far;
- a header stb cannot read means "unsupported", never a blind decode;
- decodes above 16 MP run one at a time; a decode can be cancelled
  between frames.

util::StartImageDecode runs one decode on a detached worker that owns its
job and its lane's in-flight counter, so nothing waits at exit; the next
commits move avatars and the image picker onto it. The limits apply now
to avatars and the picker's hover previews; thumbnails and avatar imports
get them when they move to workers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-09 21:09:48 -05:00
co-authored by Claude Opus 5.5
parent 971f2abae5
commit c6fc1ac168
7 changed files with 893 additions and 300 deletions
+5
View File
@@ -571,6 +571,7 @@ set(APP_SOURCES
src/util/platform.cpp
src/util/payment_uri.cpp
src/util/texture_loader.cpp
src/util/image_decode_core.cpp
src/util/svg_texture.cpp
src/util/noise_texture.cpp
src/daemon/embedded_daemon.cpp
@@ -1210,6 +1211,7 @@ if(BUILD_TESTING)
src/util/seed_phrase.cpp
src/util/i18n.cpp
src/util/text_format.cpp
src/util/image_decode_core.cpp
src/data/wallet_state.cpp
src/data/transaction_history_cache.cpp
src/data/transaction_history_store_policy.cpp
@@ -1243,6 +1245,7 @@ if(BUILD_TESTING)
${SODIUM_INCLUDE_DIR}
${CURL_INCLUDE_DIRS}
${MINIZ_DIR}
${libwebp_SOURCE_DIR}/src # <webp/decode.h>, <webp/demux.h> for image_decode_core.cpp
)
target_link_libraries(ObsidianDragonTests PRIVATE
@@ -1250,6 +1253,8 @@ if(BUILD_TESTING)
sqlite3_amalgamation
${SODIUM_LIBRARY}
${CURL_LIBRARIES}
webp
webpdemux
)
target_compile_definitions(ObsidianDragonTests PRIVATE
+398
View File
@@ -0,0 +1,398 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
#include "image_decode_core.h"
// stb_image — single-file image loader (public domain). This is the ONLY translation unit that compiles the
// implementation (it moved here from texture_loader.cpp). Formats stb decodes for us: PNG for app assets,
// plus the common user-avatar formats. WebP is handled separately (libwebp) below. Keep this list in sync
// with ImagePicker's accepted extensions.
#define STB_IMAGE_IMPLEMENTATION
#define STBI_ONLY_PNG
#define STBI_ONLY_JPEG
#define STBI_ONLY_BMP
#define STBI_ONLY_GIF
#define STBI_ONLY_TGA
#define STBI_ONLY_PSD
#define STBI_ONLY_PNM // .pnm/.ppm/.pgm/.pbm
#define STBI_ONLY_PIC
#define STBI_NO_STDIO // we do our own fread for portability
#include "stb_image.h"
#include <webp/decode.h>
#include <webp/demux.h> // WebPAnimDecoder — animated WebP
#include <algorithm>
#include <climits>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <mutex>
#include <new>
#include <thread>
#include <utility>
namespace dragonx {
namespace util {
namespace {
// Decodes above this many source pixels are serialised process-wide so two workers never hold two
// multi-hundred-MB working sets at once. Heap-allocated and NEVER destroyed on purpose: a detached decode
// worker may still lock it while static destructors run at process exit.
constexpr std::uint64_t kHeavyDecodePixels = 16ull * 1000 * 1000;
std::mutex& HeavyDecodeMutex()
{
static std::mutex* m = new std::mutex();
return *m;
}
bool IsCancelled(const std::atomic<bool>* c) { return c && c->load(std::memory_order_relaxed); }
// True if the buffer is a WebP (RIFF....WEBP) container.
bool IsWebP(const unsigned char* d, std::size_t n)
{
return n >= 12 && std::memcmp(d, "RIFF", 4) == 0 && std::memcmp(d + 8, "WEBP", 4) == 0;
}
// Box-average downscale an RGBA image to dw*dh, appending the result as a new frame in `out`.
void AppendDownscaledFrame(const unsigned char* src, int sw, int sh, int dw, int dh,
std::vector<std::vector<unsigned char>>& out)
{
out.emplace_back();
std::vector<unsigned char>& dst = out.back();
dst.resize((size_t)dw * dh * 4);
for (int y = 0; y < dh; y++) {
int sy0 = y * sh / dh, sy1 = std::max(sy0 + 1, (y + 1) * sh / dh);
for (int x = 0; x < dw; x++) {
int sx0 = x * sw / dw, sx1 = std::max(sx0 + 1, (x + 1) * sw / dw);
uint32_t r=0,g=0,b=0,a=0,n=0;
for (int yy = sy0; yy < sy1; yy++)
for (int xx = sx0; xx < sx1; xx++) {
const unsigned char* p = src + ((size_t)yy * sw + xx) * 4;
r+=p[0]; g+=p[1]; b+=p[2]; a+=p[3]; ++n;
}
unsigned char* d = dst.data() + ((size_t)y * dw + x) * 4;
d[0]=(unsigned char)(r/n); d[1]=(unsigned char)(g/n); d[2]=(unsigned char)(b/n); d[3]=(unsigned char)(a/n);
}
}
}
// Lightweight animation probe (no full decode): animated WebP via WebPGetFeatures.has_animation, or a
// GIF with >1 image-descriptor block. Walks the GIF block structure only until it finds a 2nd frame.
bool IsAnimatedData(const unsigned char* data, size_t len)
{
if (IsWebP(data, len)) {
WebPBitstreamFeatures f;
if (WebPGetFeatures(data, len, &f) == VP8_STATUS_OK) return f.has_animation != 0;
return false;
}
if (len >= 13 && memcmp(data, "GIF8", 4) == 0) {
size_t p = 6; // after "GIF8?a"
unsigned char packed = data[p + 4]; // Logical Screen Descriptor packed byte
p += 7;
if (packed & 0x80) p += (size_t)(2 << (packed & 7)) * 3; // skip global color table
int frames = 0;
while (p < len) {
unsigned char b = data[p++];
if (b == 0x2C) { // image descriptor = one frame
if (++frames > 1) return true;
if (p + 9 > len) break;
unsigned char ip = data[p + 8];
p += 9;
if (ip & 0x80) p += (size_t)(2 << (ip & 7)) * 3; // skip local color table
if (p >= len) break;
++p; // LZW min code size
while (p < len) { unsigned char s = data[p++]; if (!s) break; p += s; } // image sub-blocks
} else if (b == 0x21) { // extension
if (p >= len) break;
++p; // label
while (p < len) { unsigned char s = data[p++]; if (!s) break; p += s; } // sub-blocks
} else { // trailer (0x3B) or unexpected
break;
}
}
return frames > 1;
}
return false;
}
} // namespace
// Decode any supported image to a fresh RGBA8 buffer. WebP goes through libwebp; everything else through
// stb. The buffer is always free()-able (stbi's default free is free(); the WebP path mallocs), so
// FreeRawPixels releases either. NULL on failure. No limits: callers that need them use the probe below.
unsigned char* DecodeImageRGBA(const unsigned char* data, std::size_t len, int* outW, int* outH)
{
if (!data || len == 0 || len > (std::size_t)INT_MAX) return nullptr;
if (IsWebP(data, len)) {
int w = 0, h = 0;
if (!WebPGetInfo(data, len, &w, &h) || w <= 0 || h <= 0) return nullptr;
unsigned char* buf = (unsigned char*)malloc((size_t)w * h * 4);
if (!buf) return nullptr;
if (!WebPDecodeRGBAInto(data, len, buf, (size_t)w * h * 4, w * 4)) { free(buf); return nullptr; }
*outW = w; *outH = h;
return buf;
}
int channels = 0;
return stbi_load_from_memory(data, (int)len, outW, outH, &channels, 4);
}
ImageStatus ReadFileCapped(const char* path, std::uint64_t maxBytes, std::vector<unsigned char>& buf)
{
buf.clear();
FILE* f = fopen(path, "rb");
if (!f) return ImageStatus::ReadError;
if (fseek(f, 0, SEEK_END) != 0) { fclose(f); return ImageStatus::ReadError; }
long sz = ftell(f);
fseek(f, 0, SEEK_SET);
if (sz <= 0) { fclose(f); return ImageStatus::ReadError; }
if (maxBytes != 0 && (std::uint64_t)sz > maxBytes) { fclose(f); return ImageStatus::TooLarge; }
try { buf.resize((size_t)sz); } catch (const std::bad_alloc&) { fclose(f); return ImageStatus::TooLarge; }
size_t rd = fread(buf.data(), 1, (size_t)sz, f);
fclose(f);
return rd == (size_t)sz ? ImageStatus::Ok : ImageStatus::ReadError;
}
bool ReadFileToBuffer(const char* path, std::vector<unsigned char>& buf)
{
return ReadFileCapped(path, 0, buf) == ImageStatus::Ok;
}
unsigned char* LoadRawPixelsFromFile(const char* path, int* outW, int* outH)
{
std::vector<unsigned char> fileData;
if (!ReadFileToBuffer(path, fileData)) return nullptr;
return DecodeImageRGBA(fileData.data(), fileData.size(), outW, outH);
}
unsigned char* LoadRawPixelsFromMemory(const unsigned char* data, std::size_t dataSize, int* outW, int* outH)
{
if (!data || dataSize == 0) return nullptr;
return DecodeImageRGBA(data, dataSize, outW, outH);
}
void FreeRawPixels(unsigned char* pixels)
{
if (pixels) stbi_image_free(pixels);
}
bool IsAnimatedImageFile(const char* path)
{
std::vector<unsigned char> file;
if (!ReadFileToBuffer(path, file)) return false;
return IsAnimatedData(file.data(), file.size());
}
namespace {
struct ProbeInfo { int w = 0, h = 0; bool animated = false; bool gif = false; bool webp = false; };
// Header-only probe + limit check. Allocates nothing proportional to the image size.
ImageStatus ProbeMemory(const unsigned char* d, std::size_t n, const ImageLimits& lim, ProbeInfo& pi)
{
if (!d || n == 0) return ImageStatus::ReadError;
if (n > (std::size_t)INT_MAX) return ImageStatus::TooLarge;
if (IsWebP(d, n)) {
WebPBitstreamFeatures f;
if (WebPGetFeatures(d, n, &f) != VP8_STATUS_OK) return ImageStatus::Unsupported;
pi.webp = true; pi.w = f.width; pi.h = f.height; pi.animated = f.has_animation != 0;
} else if (n >= 6 && std::memcmp(d, "GIF8", 4) == 0) {
if (!ParseGifLogicalScreen(d, n, &pi.w, &pi.h)) return ImageStatus::Unsupported;
pi.gif = true; pi.animated = IsAnimatedData(d, n);
} else {
int comp = 0;
if (!stbi_info_from_memory(d, (int)n, &pi.w, &pi.h, &comp)) return ImageStatus::Unsupported;
}
if (pi.w <= 0 || pi.h <= 0) return ImageStatus::Unsupported;
return DimsWithinLimits(pi.w, pi.h, pi.gif || pi.animated, lim) ? ImageStatus::Ok : ImageStatus::TooLarge;
}
ImageStatus DecodeStill(const unsigned char* d, std::size_t n, const ImageLimits& lim, AnimFrames& out)
{
int w = 0, h = 0;
unsigned char* px = DecodeImageRGBA(d, n, &w, &h);
if (!px) return ImageStatus::DecodeError;
struct Guard { unsigned char* p; ~Guard() { FreeRawPixels(p); } } guard{px}; // freed even on bad_alloc
if (w <= 0 || h <= 0) return ImageStatus::DecodeError;
int dw = 0, dh = 0;
FitImageDims(w, h, lim.stillMax, &dw, &dh);
AppendDownscaledFrame(px, w, h, dw, dh, out.frames);
out.delaysSec.push_back(0.0f);
out.w = dw; out.h = dh;
return ImageStatus::Ok;
}
// Animated WebP via libwebp's demux decoder (it composites one frame per call).
ImageStatus DecodeAnimWebP(const unsigned char* d, std::size_t n, int maxFrames, const ImageLimits& lim,
const std::atomic<bool>* cancel, AnimFrames& out)
{
WebPData wd; wd.bytes = d; wd.size = n;
WebPAnimDecoderOptions opt;
if (!WebPAnimDecoderOptionsInit(&opt)) return ImageStatus::DecodeError;
opt.color_mode = MODE_RGBA;
WebPAnimDecoder* dec = WebPAnimDecoderNew(&wd, &opt);
if (!dec) return DecodeStill(d, n, lim, out); // not really an animation container
std::unique_ptr<WebPAnimDecoder, void (*)(WebPAnimDecoder*)> guard(dec, &WebPAnimDecoderDelete);
WebPAnimInfo info;
if (!WebPAnimDecoderGetInfo(dec, &info) || info.canvas_width <= 0 || info.canvas_height <= 0)
return ImageStatus::DecodeError;
const bool animated = (info.frame_count > 1);
int dw = 0, dh = 0;
FitImageDims((int)info.canvas_width, (int)info.canvas_height, animated ? lim.animMax : lim.stillMax, &dw, &dh);
int prevTs = 0, produced = 0;
while (WebPAnimDecoderHasMoreFrames(dec) && produced < maxFrames) {
if (IsCancelled(cancel)) return ImageStatus::Cancelled;
uint8_t* buf = nullptr; int ts = 0;
if (!WebPAnimDecoderGetNext(dec, &buf, &ts)) break; // buf valid until next call/delete
AppendDownscaledFrame(buf, (int)info.canvas_width, (int)info.canvas_height, dw, dh, out.frames);
float dly = (ts - prevTs) / 1000.0f; prevTs = ts;
out.delaysSec.push_back(std::max(0.02f, dly));
++produced;
}
if (out.frames.empty()) return ImageStatus::DecodeError;
out.w = dw; out.h = dh;
return ImageStatus::Ok;
}
// stb's three per-GIF working buffers, freed exactly once (stbi__gif_load_next leaves them to the caller).
struct GifState {
stbi__gif g{}; // value-initialised = all zero, exactly like stb's own memset; ~34 KB, so heap-allocated
GifState() = default;
GifState(const GifState&) = delete;
GifState& operator=(const GifState&) = delete;
~GifState() { STBI_FREE(g.out); STBI_FREE(g.history); STBI_FREE(g.background); }
};
// GIF decoded ONE composited frame at a time via stb's incremental loader (must live in this TU: the stb
// internals are static). Peak memory = stb's ~9 B/px + two full composites (disposal 3) + the downscaled
// frames, instead of w*h*4*frameCount; stops at maxFrames. Replaces stbi_load_gif_from_memory, whose
// `two_back = out - 2*stride` (stb_image.h:7023) reads before the heap buffer for disposal 3 and whose OOM
// path frees `delays` that the old caller freed again. Truncated/corrupt data keeps the frames decoded so far.
// NOTE: depends on stb-internal stbi__gif_load_next (vendored v2.30); re-check on an stb upgrade.
ImageStatus DecodeGifStreaming(const unsigned char* d, std::size_t n, int maxFrames, bool animated,
const ImageLimits& lim, const std::atomic<bool>* cancel, AnimFrames& out)
{
int cw = 0, ch = 0;
if (!ParseGifLogicalScreen(d, n, &cw, &ch)) return ImageStatus::Unsupported;
int dw = 0, dh = 0;
FitImageDims(cw, ch, animated ? lim.animMax : lim.stillMax, &dw, &dh);
const std::size_t canvasBytes = (std::size_t)cw * (std::size_t)ch * 4u;
stbi__context s;
stbi__start_mem(&s, d, (int)n);
std::unique_ptr<GifState> gs(new GifState());
std::vector<unsigned char> ring[2]; // composites of frames k-2 / k-1 (ring[k & 1] = frame k-2 at frame k)
int comp = 0, k = 0;
while (k < maxFrames) {
if (IsCancelled(cancel)) return ImageStatus::Cancelled;
// frames 0/1 (or a ring we did not fill): no "two back", stb degrades disposal 3 to 2 as it always did
stbi_uc* twoBack = (k >= 2 && ring[k & 1].size() == canvasBytes) ? ring[k & 1].data() : nullptr;
stbi_uc* u = stbi__gif_load_next(&s, &gs->g, &comp, 4, twoBack);
if (u == reinterpret_cast<stbi_uc*>(&s)) break; // 0x3B trailer: end of stream
if (!u) break; // corrupt / truncated: keep what we have
if (gs->g.w != cw || gs->g.h != ch) break; // same header, cannot differ; belt and braces
if (animated && k + 2 < maxFrames) ring[k & 1].assign(u, u + canvasBytes); // needed at frame k+2
AppendDownscaledFrame(u, cw, ch, dw, dh, out.frames);
const float dly = gs->g.delay > 0 ? gs->g.delay / 1000.0f : 0.1f; // stb stores ms
out.delaysSec.push_back(std::max(0.02f, dly));
++k;
}
if (out.frames.empty()) return ImageStatus::DecodeError;
out.w = dw; out.h = dh;
return ImageStatus::Ok;
}
ImageStatus DecodeImpl(const unsigned char* d, std::size_t n, int maxFrames, const ImageLimits& lim,
const std::atomic<bool>* cancel, AnimFrames& out)
{
if (maxFrames < 1) maxFrames = 1;
ProbeInfo pi;
const ImageStatus ps = ProbeMemory(d, n, lim, pi);
if (ps != ImageStatus::Ok) return ps;
if (IsCancelled(cancel)) return ImageStatus::Cancelled;
std::unique_lock<std::mutex> heavy(HeavyDecodeMutex(), std::defer_lock);
if ((std::uint64_t)pi.w * (std::uint64_t)pi.h > kHeavyDecodePixels) {
heavy.lock();
if (IsCancelled(cancel)) return ImageStatus::Cancelled;
}
ImageStatus st;
if (pi.gif) st = DecodeGifStreaming(d, n, maxFrames, pi.animated, lim, cancel, out);
else if (pi.webp && pi.animated) st = DecodeAnimWebP(d, n, maxFrames, lim, cancel, out);
else st = DecodeStill(d, n, lim, out);
if (st == ImageStatus::Ok) { out.animated = pi.animated || out.frames.size() > 1; out.srcW = pi.w; out.srcH = pi.h; }
return st;
}
} // namespace
ImageStatus DecodeAnimatedFromMemory(const unsigned char* data, std::size_t len, int maxFrames,
const ImageLimits& limits, const std::atomic<bool>* cancel,
AnimFrames& out)
{
out = AnimFrames{};
ImageStatus st;
try {
st = DecodeImpl(data, len, maxFrames, limits, cancel, out);
} catch (const std::bad_alloc&) {
st = ImageStatus::TooLarge;
} catch (...) {
st = ImageStatus::DecodeError;
}
if (st != ImageStatus::Ok) out = AnimFrames{};
return st;
}
ImageStatus LoadAnimatedRGBAEx(const char* path, int maxFrames, const ImageLimits& limits,
const std::atomic<bool>* cancel, AnimFrames& out)
{
out = AnimFrames{};
std::vector<unsigned char> file;
const ImageStatus rs = ReadFileCapped(path, limits.maxFileBytes, file);
if (rs != ImageStatus::Ok) return rs;
if (IsCancelled(cancel)) return ImageStatus::Cancelled;
return DecodeAnimatedFromMemory(file.data(), file.size(), maxFrames, limits, cancel, out);
}
bool LoadAnimatedRGBA(const char* path, int maxFrames, AnimFrames& out)
{
return LoadAnimatedRGBAEx(path, maxFrames, ImageLimits{}, nullptr, out) == ImageStatus::Ok;
}
ImageDecodeJobPtr StartImageDecode(ImageDecodeLane& lane, std::string path, int maxFrames,
const ImageLimits& limits, ImageAfterDecodeFn afterDecode)
{
if (lane.full()) return nullptr;
auto job = std::make_shared<ImageDecodeJob>();
std::shared_ptr<std::atomic<int>> inflight = lane.counter();
inflight->fetch_add(1, std::memory_order_acq_rel); // UI thread only, so check-then-add cannot race
try {
// The worker owns copies/shares of everything it touches: never a static, never App, never ImGui/GL.
std::thread([job, inflight, path = std::move(path), maxFrames, limits, after = std::move(afterDecode)]() {
ImageStatus st = ImageStatus::DecodeError;
try {
st = LoadAnimatedRGBAEx(path.c_str(), maxFrames, limits, &job->cancel, job->result);
if (st == ImageStatus::Ok && after) {
job->outPath = after(path);
if (job->outPath.empty()) st = ImageStatus::PostFailed;
}
} catch (...) {
st = ImageStatus::DecodeError;
}
if (st != ImageStatus::Ok) job->result = AnimFrames{};
job->status = st;
inflight->fetch_sub(1, std::memory_order_acq_rel); // slot is free once the UI sees `done`
job->done.store(true, std::memory_order_release);
}).detach();
} catch (...) { // std::system_error: no thread. Fail this job once; never spin retrying.
inflight->fetch_sub(1, std::memory_order_acq_rel);
job->status = ImageStatus::DecodeError;
job->done.store(true, std::memory_order_release);
}
return job;
}
} // namespace util
} // namespace dragonx
+131
View File
@@ -0,0 +1,131 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
//
// GL-free image decoding (stb_image + libwebp) and a tiny off-thread decode launcher. Everything here is
// safe to call from any thread. GPU upload lives in texture_loader.{h,cpp} and must stay on the UI thread.
#pragma once
#include "image_decode_policy.h"
#include <atomic>
#include <cstddef>
#include <cstdint>
#include <functional>
#include <memory>
#include <string>
#include <vector>
namespace dragonx {
namespace util {
enum class ImageStatus {
Ok = 0,
ReadError, // missing / unreadable / empty file
TooLarge, // file bytes or pixel area above ImageLimits (or out of memory)
Unsupported, // not a format we decode, or the header is unreadable
DecodeError, // header ok, pixel decode failed
Cancelled, // the job's cancel flag was set
PostFailed, // decoded fine, but the afterDecode hook failed (e.g. the avatar copy)
};
/**
* @brief Downscaled RGBA frame sequence for an image (1 frame for stills, N for animations).
*/
struct AnimFrames {
std::vector<std::vector<unsigned char>> frames; ///< each is w*h*4 RGBA (already downscaled)
std::vector<float> delaysSec; ///< per-frame duration in seconds (parallel to frames)
int w = 0;
int h = 0;
bool animated = false; ///< the SOURCE has >1 frame (even if fewer were decoded); set on success
int srcW = 0, srcH = 0; ///< source canvas size; set on success
};
/// Decode from memory. Probes the header first and rejects anything over `limits` before allocating
/// pixels. GIFs decode one composited frame at a time and stop at maxFrames; animated WebP via libwebp's
/// demuxer; everything else (and APNG's default image) as one still. Frames are box-downscaled to
/// limits.stillMax / limits.animMax. `cancel` (may be null) is checked before decoding and between frames.
/// On any non-Ok status `out` is empty.
ImageStatus DecodeAnimatedFromMemory(const unsigned char* data, std::size_t len, int maxFrames,
const ImageLimits& limits, const std::atomic<bool>* cancel,
AnimFrames& out);
/// As above, reading `path` first (its size is checked against limits.maxFileBytes BEFORE the read).
ImageStatus LoadAnimatedRGBAEx(const char* path, int maxFrames, const ImageLimits& limits,
const std::atomic<bool>* cancel, AnimFrames& out);
/// Back-compat: default limits, no cancel. False on any failure.
bool LoadAnimatedRGBA(const char* path, int maxFrames, AnimFrames& out);
/// Cheap animation probe (GIF block walk / WebPGetFeatures) after reading the file.
bool IsAnimatedImageFile(const char* path);
/// Whole-file read. maxBytes == 0 = unlimited; TooLarge is reported before any allocation.
ImageStatus ReadFileCapped(const char* path, std::uint64_t maxBytes, std::vector<unsigned char>& buf);
bool ReadFileToBuffer(const char* path, std::vector<unsigned char>& buf);
/// Decode to a fresh RGBA8 buffer (free with FreeRawPixels). nullptr on failure. No limits.
unsigned char* DecodeImageRGBA(const unsigned char* data, std::size_t len, int* outW, int* outH);
/**
* @brief Load raw RGBA pixel data from an image file (no GPU texture, no limits).
* @return Pointer to RGBA pixels (caller must free with FreeRawPixels), or nullptr on failure
*/
unsigned char* LoadRawPixelsFromFile(const char* path, int* outW, int* outH);
/**
* @brief Load raw RGBA pixel data from in-memory compressed image data (no GPU texture, no limits).
* @return Pointer to RGBA pixels (caller must free with FreeRawPixels), or nullptr on failure
*/
unsigned char* LoadRawPixelsFromMemory(const unsigned char* data, std::size_t dataSize, int* outW, int* outH);
/**
* @brief Free pixels returned by LoadRawPixelsFromFile / LoadRawPixelsFromMemory / DecodeImageRGBA.
*/
void FreeRawPixels(unsigned char* pixels);
// ---- Off-thread decode ------------------------------------------------------------------------------
/// One background decode. The worker writes result/status/outPath, then release-stores `done`; the UI
/// reads them only after finished() returned true, and may then move `result` out. The UI never writes
/// the payload while the worker runs; it may set `cancel` at any time.
struct ImageDecodeJob {
std::atomic<bool> done{false};
std::atomic<bool> cancel{false};
AnimFrames result; // empty unless status == Ok
ImageStatus status = ImageStatus::DecodeError;
std::string outPath; // afterDecode's return value ("" if unused / failed)
bool finished() const { return done.load(std::memory_order_acquire); }
bool ok() const { return finished() && status == ImageStatus::Ok; }
};
using ImageDecodeJobPtr = std::shared_ptr<ImageDecodeJob>;
/// Runs on the worker after a successful decode (e.g. copy the file). "" means failure (PostFailed).
/// Must not touch UI state; capture everything it needs BY VALUE.
using ImageAfterDecodeFn = std::function<std::string(const std::string& path)>;
/// Caps how many decodes of one kind run at once. Use from the UI thread only. The counter is heap-owned
/// and co-owned by every worker started on the lane, so a detached worker still running at process exit
/// never touches a destroyed static (the rule from commit 6f0f95f).
class ImageDecodeLane {
public:
explicit ImageDecodeLane(int cap) : cap_(cap) {}
int inFlight() const { return count_->load(std::memory_order_acquire); }
bool full() const { return inFlight() >= cap_; }
std::shared_ptr<std::atomic<int>> counter() const { return count_; }
private:
int cap_;
std::shared_ptr<std::atomic<int>> count_ = std::make_shared<std::atomic<int>>(0);
};
/// Start decoding `path` on a detached worker if `lane` has a free slot. Returns nullptr when the lane is
/// full (show a placeholder, request a frame, retry next frame). Never blocks. If no thread can be created
/// it returns a job that is already finished with DecodeError (shown as a failed image, never retried in a
/// loop). Results are polled; the caller keeps the render loop awake with material::RequestProgressFrame().
ImageDecodeJobPtr StartImageDecode(ImageDecodeLane& lane, std::string path, int maxFrames,
const ImageLimits& limits, ImageAfterDecodeFn afterDecode = {});
} // namespace util
} // namespace dragonx
+87
View File
@@ -0,0 +1,87 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
//
// Pure image-decode policy: limits, output sizing, GIF header parse, LRU byte-budget eviction.
// Header-only and dependency-free so tests/test_phase4.cpp can exercise it without GL/stb/libwebp.
#pragma once
#include <algorithm>
#include <cstddef>
#include <cstdint>
#include <cstring>
#include <vector>
namespace dragonx {
namespace util {
// Inputs above these are rejected from the header (before any pixel buffer is allocated). Area-only on
// purpose: wide panoramas (30000x4000) and 200 MP phone photos are legitimate; only decompression bombs and
// absurd canvases are refused. GIF always uses the canvas limit (stb keeps ~9 B/px of working buffers, plus
// two full composites for disposal-3 = ~17 B/px).
struct ImageLimits {
std::uint64_t maxFileBytes = 256ull * 1024 * 1024; // checked before the file is read
std::uint64_t maxStillPixels = 256ull * 1000 * 1000; // stills (~1 GB RGBA worst case)
std::uint64_t maxAnimPixels = 32ull * 1024 * 1024; // any GIF, and animated-WebP canvases
int stillMax = 256; // output bound (px) for a still
int animMax = 128; // output bound (px) for an animation (bounds VRAM)
};
// Same arithmetic as the old texture_loader.cpp FitDims (texture_loader.cpp:370-378).
inline void FitImageDims(int sw, int sh, int maxDim, int* dw, int* dh)
{
*dw = sw; *dh = sh;
if (sw > maxDim || sh > maxDim) {
float s = (float)maxDim / (float)std::max(sw, sh);
*dw = std::max(1, (int)(sw * s));
*dh = std::max(1, (int)(sh * s));
}
}
inline bool DimsWithinLimits(int w, int h, bool gifOrAnimated, const ImageLimits& l)
{
if (w <= 0 || h <= 0) return false;
const std::uint64_t px = (std::uint64_t)w * (std::uint64_t)h;
return px <= (gifOrAnimated ? l.maxAnimPixels : l.maxStillPixels);
}
// GIF logical screen descriptor: "GIF87a"/"GIF89a" + 16-bit LE width/height. Needs 13 bytes.
inline bool ParseGifLogicalScreen(const unsigned char* d, std::size_t n, int* w, int* h)
{
if (n < 13 || std::memcmp(d, "GIF8", 4) != 0 || (d[4] != '7' && d[4] != '9') || d[5] != 'a') return false;
*w = d[6] | (d[7] << 8);
*h = d[8] | (d[9] << 8);
return true;
}
// ---- LRU byte-budget eviction (avatar cache, picker thumbnails) -----------------------------------
struct CacheItem {
std::size_t key; // caller's own id
std::size_t bytes; // resident bytes (0 = nothing to reclaim; never selected)
std::uint64_t lastUse; // frame counter of the last use
};
// Keys to evict, least-recently-used first, until the total fits `budget`. Items with
// lastUse >= protectFromUse are never selected (the caller passes the frame whose draws must survive).
inline std::vector<std::size_t> selectEvictions(std::vector<CacheItem> items, std::size_t budget,
std::uint64_t protectFromUse)
{
std::size_t total = 0;
for (const CacheItem& i : items) total += i.bytes;
std::vector<std::size_t> out;
if (total <= budget) return out;
std::stable_sort(items.begin(), items.end(),
[](const CacheItem& a, const CacheItem& b) { return a.lastUse < b.lastUse; });
for (const CacheItem& i : items) {
if (total <= budget) break;
if (i.lastUse >= protectFromUse) break; // sorted ascending: everything after is protected too
if (i.bytes == 0) continue;
out.push_back(i.key);
total -= std::min(total, i.bytes);
}
return out;
}
} // namespace util
} // namespace dragonx
+9 -250
View File
@@ -4,24 +4,9 @@
#include "texture_loader.h"
// stb_image — single-file image loader (public domain)
// Only compiled here; all other files just include the header.
#define STB_IMAGE_IMPLEMENTATION
// Formats stb decodes for us: PNG for app assets, plus the common user-avatar formats. WebP is handled
// separately (libwebp) below. Keep this list in sync with ImagePicker's accepted extensions.
#define STBI_ONLY_PNG
#define STBI_ONLY_JPEG
#define STBI_ONLY_BMP
#define STBI_ONLY_GIF
#define STBI_ONLY_TGA
#define STBI_ONLY_PSD
#define STBI_ONLY_PNM // .pnm/.ppm/.pgm/.pbm
#define STBI_ONLY_PIC
#define STBI_NO_STDIO // we do our own fread for portability
#include "stb_image.h"
#include <webp/decode.h>
#include <webp/demux.h> // WebPAnimDecoder — animated WebP
// Image DECODING (the stb_image implementation, libwebp, limits, streaming GIF) lives in
// image_decode_core.cpp, which is GL-free and safe on worker threads. This file only creates and destroys
// GPU textures and must be called from the UI (GL/DX) thread.
#include <algorithm>
#include <cstdint>
@@ -53,45 +38,6 @@ static ID3D11Device* GetImGuiD3D11Device()
namespace dragonx {
namespace util {
// True if the buffer is a WebP (RIFF....WEBP) container.
static bool IsWebP(const unsigned char* d, size_t n)
{
return n >= 12 && memcmp(d, "RIFF", 4) == 0 && memcmp(d + 8, "WEBP", 4) == 0;
}
// Decode any supported image to a fresh RGBA8 buffer. WebP goes through libwebp; everything else
// through stb. The returned buffer is always free()-able (stbi's default free is free(), and the WebP
// path decodes into a malloc'd buffer), so FreeRawPixels(stbi_image_free) releases either. NULL on fail.
static unsigned char* DecodeImageRGBA(const unsigned char* data, size_t len, int* outW, int* outH)
{
if (IsWebP(data, len)) {
int w = 0, h = 0;
if (!WebPGetInfo(data, len, &w, &h) || w <= 0 || h <= 0) return nullptr;
unsigned char* buf = (unsigned char*)malloc((size_t)w * h * 4);
if (!buf) return nullptr;
if (!WebPDecodeRGBAInto(data, len, buf, (size_t)w * h * 4, w * 4)) { free(buf); return nullptr; }
*outW = w; *outH = h;
return buf;
}
int channels = 0;
return stbi_load_from_memory(data, (int)len, outW, outH, &channels, 4);
}
// Read entire file into memory
static bool ReadFileToBuffer(const char* path, std::vector<unsigned char>& buf)
{
FILE* f = fopen(path, "rb");
if (!f) return false;
fseek(f, 0, SEEK_END);
long sz = ftell(f);
fseek(f, 0, SEEK_SET);
if (sz <= 0) { fclose(f); return false; }
buf.resize((size_t)sz);
size_t rd = fread(buf.data(), 1, (size_t)sz, f);
fclose(f);
return rd == (size_t)sz;
}
bool LoadTextureFromFile(const char* path, ImTextureID* outTexID, int* outW, int* outH)
{
std::vector<unsigned char> fileData;
@@ -110,7 +56,7 @@ bool LoadTextureFromFile(const char* path, ImTextureID* outTexID, int* outW, int
#ifdef DRAGONX_USE_DX11
ID3D11Device* device = GetImGuiD3D11Device();
if (!device) {
stbi_image_free(pixels);
FreeRawPixels(pixels);
DEBUG_LOGF("LoadTextureFromFile: no D3D11 device available\n");
return false;
}
@@ -131,7 +77,7 @@ bool LoadTextureFromFile(const char* path, ImTextureID* outTexID, int* outW, int
ID3D11Texture2D* texture = nullptr;
HRESULT hr = device->CreateTexture2D(&desc, &initData, &texture);
stbi_image_free(pixels);
FreeRawPixels(pixels);
if (FAILED(hr) || !texture) {
DEBUG_LOGF("LoadTextureFromFile: CreateTexture2D failed for '%s'\n", path);
return false;
@@ -169,7 +115,7 @@ bool LoadTextureFromFile(const char* path, ImTextureID* outTexID, int* outW, int
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glBindTexture(GL_TEXTURE_2D, 0);
stbi_image_free(pixels);
FreeRawPixels(pixels);
*outTexID = (ImTextureID)(intptr_t)tex;
*outW = w;
@@ -197,7 +143,7 @@ bool LoadTextureFromMemory(const unsigned char* data, size_t dataSize,
#ifdef DRAGONX_USE_DX11
ID3D11Device* device = GetImGuiD3D11Device();
if (!device) {
stbi_image_free(pixels);
FreeRawPixels(pixels);
DEBUG_LOGF("LoadTextureFromMemory: no D3D11 device available\n");
return false;
}
@@ -218,7 +164,7 @@ bool LoadTextureFromMemory(const unsigned char* data, size_t dataSize,
ID3D11Texture2D* texture = nullptr;
HRESULT hr = device->CreateTexture2D(&desc, &initData, &texture);
stbi_image_free(pixels);
FreeRawPixels(pixels);
if (FAILED(hr) || !texture) {
DEBUG_LOGF("LoadTextureFromMemory: CreateTexture2D failed\n");
return false;
@@ -255,7 +201,7 @@ bool LoadTextureFromMemory(const unsigned char* data, size_t dataSize,
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glBindTexture(GL_TEXTURE_2D, 0);
stbi_image_free(pixels);
FreeRawPixels(pixels);
*outTexID = (ImTextureID)(intptr_t)tex;
*outW = w;
@@ -326,193 +272,6 @@ bool CreateRawTexture(const unsigned char* pixels, int w, int h,
#endif
}
unsigned char* LoadRawPixelsFromFile(const char* path, int* outW, int* outH)
{
std::vector<unsigned char> fileData;
if (!ReadFileToBuffer(path, fileData)) return nullptr;
return DecodeImageRGBA(fileData.data(), fileData.size(), outW, outH);
}
unsigned char* LoadRawPixelsFromMemory(const unsigned char* data, size_t dataSize, int* outW, int* outH)
{
if (!data || dataSize == 0) return nullptr;
return DecodeImageRGBA(data, dataSize, outW, outH);
}
void FreeRawPixels(unsigned char* pixels)
{
if (pixels) stbi_image_free(pixels);
}
// Box-average downscale an RGBA image to dw*dh, appending the result as a new frame in `out`.
static void AppendDownscaledFrame(const unsigned char* src, int sw, int sh, int dw, int dh,
std::vector<std::vector<unsigned char>>& out)
{
out.emplace_back();
std::vector<unsigned char>& dst = out.back();
dst.resize((size_t)dw * dh * 4);
for (int y = 0; y < dh; y++) {
int sy0 = y * sh / dh, sy1 = std::max(sy0 + 1, (y + 1) * sh / dh);
for (int x = 0; x < dw; x++) {
int sx0 = x * sw / dw, sx1 = std::max(sx0 + 1, (x + 1) * sw / dw);
uint32_t r=0,g=0,b=0,a=0,n=0;
for (int yy = sy0; yy < sy1; yy++)
for (int xx = sx0; xx < sx1; xx++) {
const unsigned char* p = src + ((size_t)yy * sw + xx) * 4;
r+=p[0]; g+=p[1]; b+=p[2]; a+=p[3]; ++n;
}
unsigned char* d = dst.data() + ((size_t)y * dw + x) * 4;
d[0]=(unsigned char)(r/n); d[1]=(unsigned char)(g/n); d[2]=(unsigned char)(b/n); d[3]=(unsigned char)(a/n);
}
}
}
static void FitDims(int sw, int sh, int maxDim, int* dw, int* dh)
{
*dw = sw; *dh = sh;
if (sw > maxDim || sh > maxDim) {
float s = (float)maxDim / (float)std::max(sw, sh);
*dw = std::max(1, (int)(sw * s));
*dh = std::max(1, (int)(sh * s));
}
}
bool LoadAnimatedRGBA(const char* path, int maxFrames, AnimFrames& out)
{
out.frames.clear();
out.delaysSec.clear();
out.w = out.h = 0;
std::vector<unsigned char> file;
if (!ReadFileToBuffer(path, file)) return false;
const unsigned char* data = file.data();
const size_t len = file.size();
if (maxFrames < 1) maxFrames = 1;
// Static max dimension is generous (an avatar renders <=112px); animations use a smaller cap so a
// long clip can't cost tens of MB of VRAM. We only know if it animates after peeking the frame count.
const int kStillMax = 256, kAnimMax = 128;
// ---- Animated (or still) WebP via libwebp's demux decoder ----
if (IsWebP(data, len)) {
WebPData wd; wd.bytes = data; wd.size = len;
WebPAnimDecoderOptions opt;
if (!WebPAnimDecoderOptionsInit(&opt)) return false;
opt.color_mode = MODE_RGBA;
WebPAnimDecoder* dec = WebPAnimDecoderNew(&wd, &opt);
if (!dec) {
// Not an animation container — fall back to a single still decode.
int w=0,h=0; unsigned char* px = DecodeImageRGBA(data, len, &w, &h);
if (!px) return false;
int dw,dh; FitDims(w, h, kStillMax, &dw, &dh);
AppendDownscaledFrame(px, w, h, dw, dh, out.frames);
out.delaysSec.push_back(0.0f); out.w = dw; out.h = dh;
free(px);
return true;
}
WebPAnimInfo info;
if (!WebPAnimDecoderGetInfo(dec, &info) || info.canvas_width <= 0 || info.canvas_height <= 0) {
WebPAnimDecoderDelete(dec); return false;
}
const int animated = (info.frame_count > 1);
int dw, dh; FitDims(info.canvas_width, info.canvas_height, animated ? kAnimMax : kStillMax, &dw, &dh);
int prevTs = 0, produced = 0;
while (WebPAnimDecoderHasMoreFrames(dec) && produced < maxFrames) {
uint8_t* buf = nullptr; int ts = 0;
if (!WebPAnimDecoderGetNext(dec, &buf, &ts)) break; // buf valid until next call/delete
AppendDownscaledFrame(buf, info.canvas_width, info.canvas_height, dw, dh, out.frames);
float d = (ts - prevTs) / 1000.0f; prevTs = ts;
out.delaysSec.push_back(std::max(0.02f, d));
++produced;
}
WebPAnimDecoderDelete(dec);
if (out.frames.empty()) return false;
out.w = dw; out.h = dh;
return true;
}
// ---- Animated (or still) GIF via stb ----
const bool isGif = len >= 6 && memcmp(data, "GIF8", 4) == 0;
if (isGif) {
int* delays = nullptr; int w=0,h=0,z=0,comp=0;
unsigned char* all = stbi_load_gif_from_memory(data, (int)len, &delays, &w, &h, &z, &comp, 4);
if (all && w > 0 && h > 0 && z > 0) {
const int animated = (z > 1);
int dw, dh; FitDims(w, h, animated ? kAnimMax : kStillMax, &dw, &dh);
int frames = std::min(z, maxFrames);
for (int i = 0; i < frames; i++) {
AppendDownscaledFrame(all + (size_t)i * w * h * 4, w, h, dw, dh, out.frames);
float d = (delays && delays[i] > 0) ? delays[i] / 1000.0f : 0.1f;
out.delaysSec.push_back(std::max(0.02f, d));
}
out.w = dw; out.h = dh;
stbi_image_free(all);
if (delays) stbi_image_free(delays);
return true;
}
if (all) stbi_image_free(all);
if (delays) stbi_image_free(delays);
// fall through to a plain decode if the GIF path failed
}
// ---- Everything else: a single still frame (PNG/JPEG/BMP/TGA/... and APNG's default image) ----
{
int w=0,h=0; unsigned char* px = DecodeImageRGBA(data, len, &w, &h);
if (!px) return false;
int dw,dh; FitDims(w, h, kStillMax, &dw, &dh);
AppendDownscaledFrame(px, w, h, dw, dh, out.frames);
out.delaysSec.push_back(0.0f);
out.w = dw; out.h = dh;
free(px);
return true;
}
}
// Lightweight animation probe (no full decode): animated WebP via WebPGetFeatures.has_animation, or a
// GIF with >1 image-descriptor block. Walks the GIF block structure only until it finds a 2nd frame.
static bool IsAnimatedData(const unsigned char* data, size_t len)
{
if (IsWebP(data, len)) {
WebPBitstreamFeatures f;
if (WebPGetFeatures(data, len, &f) == VP8_STATUS_OK) return f.has_animation != 0;
return false;
}
if (len >= 13 && memcmp(data, "GIF8", 4) == 0) {
size_t p = 6; // after "GIF8?a"
unsigned char packed = data[p + 4]; // Logical Screen Descriptor packed byte
p += 7;
if (packed & 0x80) p += (size_t)(2 << (packed & 7)) * 3; // skip global color table
int frames = 0;
while (p < len) {
unsigned char b = data[p++];
if (b == 0x2C) { // image descriptor = one frame
if (++frames > 1) return true;
if (p + 9 > len) break;
unsigned char ip = data[p + 8];
p += 9;
if (ip & 0x80) p += (size_t)(2 << (ip & 7)) * 3; // skip local color table
if (p >= len) break;
++p; // LZW min code size
while (p < len) { unsigned char s = data[p++]; if (!s) break; p += s; } // image sub-blocks
} else if (b == 0x21) { // extension
if (p >= len) break;
++p; // label
while (p < len) { unsigned char s = data[p++]; if (!s) break; p += s; } // sub-blocks
} else { // trailer (0x3B) or unexpected
break;
}
}
return frames > 1;
}
return false;
}
bool IsAnimatedImageFile(const char* path)
{
std::vector<unsigned char> file;
if (!ReadFileToBuffer(path, file)) return false;
return IsAnimatedData(file.data(), file.size());
}
void DestroyTexture(ImTextureID texID)
{
if (!texID) return;
+6 -50
View File
@@ -6,37 +6,17 @@
#include "imgui.h"
// GL-free decoding (AnimFrames, LoadAnimatedRGBA[Ex], IsAnimatedImageFile, LoadRawPixels*, FreeRawPixels,
// StartImageDecode) lives in image_decode_core.h and is safe on worker threads. Everything declared below
// creates or destroys GPU textures and must run on the UI (GL/DX) thread.
#include "image_decode_core.h"
#include <cstddef>
#include <vector>
namespace dragonx {
namespace util {
/**
* @brief Downscaled RGBA frame sequence for an image (1 frame for stills, N for animations).
*/
struct AnimFrames {
std::vector<std::vector<unsigned char>> frames; ///< each is w*h*4 RGBA (already downscaled)
std::vector<float> delaysSec; ///< per-frame duration in seconds (parallel to frames)
int w = 0;
int h = 0;
};
/**
* @brief Decode an image into 1+ downscaled RGBA frames plus per-frame durations.
*
* Animated GIF (via stb) and animated WebP (via libwebp WebPAnimDecoder) return every frame (capped at
* maxFrames); static formats — and APNG, which stb reads as a single image — return one frame. Frames
* are box-downscaled internally (smaller for animations to bound VRAM). The caller uploads each frame
* with CreateRawTexture. Returns false on decode failure.
*/
bool LoadAnimatedRGBA(const char* path, int maxFrames, AnimFrames& out);
/**
* @brief Cheaply report whether an image file is an animation (multi-frame GIF or animated WebP),
* WITHOUT a full decode — a header/structure probe. False for stills + non-animatable formats.
*/
bool IsAnimatedImageFile(const char* path);
/**
* @brief Load a PNG/JPG/BMP image from disk into an OpenGL or DX11 texture.
* @param path File path (relative to working directory or absolute)
@@ -74,30 +54,6 @@ bool LoadTextureFromMemory(const unsigned char* data, size_t dataSize,
bool CreateRawTexture(const unsigned char* pixels, int w, int h,
bool repeat, ImTextureID* outTexID);
/**
* @brief Load raw RGBA pixel data from a PNG/JPG/BMP file (no GPU texture).
* @param path File path
* @param outW Receives image width
* @param outH Receives image height
* @return Pointer to RGBA pixels (caller must free with FreeRawPixels), or nullptr on failure
*/
unsigned char* LoadRawPixelsFromFile(const char* path, int* outW, int* outH);
/**
* @brief Load raw RGBA pixel data from in-memory compressed image data (no GPU texture).
* @param data Pointer to compressed image data (e.g. PNG bytes)
* @param dataSize Size of the data in bytes
* @param outW Receives image width
* @param outH Receives image height
* @return Pointer to RGBA pixels (caller must free with FreeRawPixels), or nullptr on failure
*/
unsigned char* LoadRawPixelsFromMemory(const unsigned char* data, size_t dataSize, int* outW, int* outH);
/**
* @brief Free pixels returned by LoadRawPixelsFromFile / LoadRawPixelsFromMemory.
*/
void FreeRawPixels(unsigned char* pixels);
/**
* @brief Destroy a GPU texture previously created by Load/CreateRaw functions.
* @param texID Texture handle (GLuint on OpenGL, ID3D11ShaderResourceView* on DX11)
+257
View File
@@ -64,6 +64,7 @@
#include "data/seed_migration_resume.h"
#include "util/address_validation.h"
#include "util/seed_phrase.h"
#include "util/image_decode_core.h"
#include "daemon/daemon_startup_diagnosis.h"
#include "util/amount_format.h"
#include "util/payment_uri.h"
@@ -7203,6 +7204,259 @@ void testLiteClientBridgeUsesRuntimeOwnedStringCleanup()
// ---- image decode (C185 / C186) ----------------------------------------------------------------
unsigned char imgU8(int v) { return static_cast<unsigned char>(v); }
// GIF "literal" LZW: min code size 8, a clear code every 100 pixels so the code width stays 9 bits.
std::vector<unsigned char> imgTestLzw(const std::vector<unsigned char>& idx)
{
std::vector<unsigned char> out;
std::uint32_t acc = 0; int nb = 0;
auto put = [&](std::uint32_t code) {
acc |= code << nb; nb += 9;
while (nb >= 8) { out.push_back(imgU8((int)(acc & 0xFF))); acc >>= 8; nb -= 8; }
};
put(256); // clear
int run = 0;
for (unsigned char p : idx) {
if (run == 100) { put(256); run = 0; }
put(p); ++run;
}
put(257); // end of information
if (nb > 0) out.push_back(imgU8((int)(acc & 0xFF)));
return out;
}
// n-frame GIF89a, full-canvas frames, 256-entry global palette; disposal[f] in 0..3, delayCs[f] in 1/100 s.
std::vector<unsigned char> imgTestGif(int w, int h, const std::vector<int>& disposal, const std::vector<int>& delayCs)
{
std::vector<unsigned char> g = { 'G','I','F','8','9','a', imgU8(w & 255), imgU8(w >> 8), imgU8(h & 255), imgU8(h >> 8),
0xF7, 0, 0 };
for (int i = 0; i < 256; ++i) { g.push_back(imgU8(i)); g.push_back(imgU8(255 - i)); g.push_back(imgU8(i / 2)); }
for (size_t f = 0; f < disposal.size(); ++f) {
const int cs = delayCs[f];
const unsigned char gce[] = { 0x21, 0xF9, 0x04, imgU8(disposal[f] << 2), imgU8(cs & 255), imgU8(cs >> 8), 0, 0 };
g.insert(g.end(), std::begin(gce), std::end(gce));
const unsigned char desc[] = { 0x2C, 0, 0, 0, 0, imgU8(w & 255), imgU8(w >> 8), imgU8(h & 255), imgU8(h >> 8), 0 };
g.insert(g.end(), std::begin(desc), std::end(desc));
std::vector<unsigned char> idx((size_t)w * h);
for (size_t i = 0; i < idx.size(); ++i) idx[i] = imgU8((int)((i + f * 37) & 255));
const std::vector<unsigned char> lzw = imgTestLzw(idx);
g.push_back(8); // LZW minimum code size
for (size_t o = 0; o < lzw.size(); o += 255) {
const size_t n = std::min<size_t>(255, lzw.size() - o);
g.push_back(imgU8((int)n));
g.insert(g.end(), lzw.begin() + (std::ptrdiff_t)o, lzw.begin() + (std::ptrdiff_t)(o + n));
}
g.push_back(0); // block terminator
}
g.push_back(0x3B); // trailer
return g;
}
// 24-bit BMP; withPixels=false gives a header-only file (limit tests: nothing to allocate).
std::vector<unsigned char> imgTestBmp(int w, int h, bool withPixels)
{
const std::uint32_t rowBytes = (std::uint32_t)((w * 3 + 3) & ~3);
const std::uint32_t dataBytes = withPixels ? rowBytes * (std::uint32_t)h : 0;
std::vector<unsigned char> b;
auto le32 = [&](std::uint32_t v) { for (int i = 0; i < 4; ++i) b.push_back(imgU8((int)((v >> (8 * i)) & 255))); };
auto le16 = [&](unsigned v) { b.push_back(imgU8((int)(v & 255))); b.push_back(imgU8((int)(v >> 8))); };
b.push_back('B'); b.push_back('M');
le32(54 + dataBytes); le32(0); le32(54);
le32(40); le32((std::uint32_t)w); le32((std::uint32_t)h); le16(1); le16(24);
le32(0); le32(dataBytes); le32(2835); le32(2835); le32(0); le32(0);
b.resize(54 + dataBytes, 0x80);
return b;
}
void imgWriteFile(const fs::path& p, const std::vector<unsigned char>& bytes)
{
std::ofstream o(p, std::ios::binary);
o.write(reinterpret_cast<const char*>(bytes.data()), (std::streamsize)bytes.size());
}
void testImageDecodePolicy()
{
namespace util = dragonx::util;
int dw = 0, dh = 0;
util::FitImageDims(1000, 500, 250, &dw, &dh);
EXPECT_EQ(dw, 250); EXPECT_EQ(dh, 125);
util::FitImageDims(100, 50, 250, &dw, &dh); // already small: unchanged
EXPECT_EQ(dw, 100); EXPECT_EQ(dh, 50);
const util::ImageLimits lim;
EXPECT_TRUE(util::DimsWithinLimits(4000, 3000, false, lim)); // 12 MP photo
EXPECT_TRUE(util::DimsWithinLimits(16320, 12240, false, lim)); // 200 MP phone mode still loads
EXPECT_TRUE(util::DimsWithinLimits(30000, 4000, false, lim)); // wide panorama (side > 16384) still loads
EXPECT_FALSE(util::DimsWithinLimits(20000, 20000, false, lim)); // 400 MP: rejected
EXPECT_TRUE(util::DimsWithinLimits(3840, 2160, true, lim)); // 4K animation
EXPECT_TRUE(util::DimsWithinLimits(8192, 4096, true, lim)); // exactly 32 Mi px
EXPECT_FALSE(util::DimsWithinLimits(8193, 4096, true, lim));
EXPECT_FALSE(util::DimsWithinLimits(0, 5, false, lim));
const std::vector<unsigned char> gif = imgTestGif(8, 4, {0}, {5});
int gw = 0, gh = 0;
EXPECT_TRUE(util::ParseGifLogicalScreen(gif.data(), gif.size(), &gw, &gh));
EXPECT_EQ(gw, 8); EXPECT_EQ(gh, 4);
EXPECT_FALSE(util::ParseGifLogicalScreen(gif.data(), 12, &gw, &gh)); // too short
const unsigned char notGif[13] = { 'P','N','G',0,0,0,0,0,0,0,0,0,0 };
EXPECT_FALSE(util::ParseGifLogicalScreen(notGif, sizeof(notGif), &gw, &gh));
// Eviction: LRU first, recently used items protected, zero-byte items never chosen.
std::vector<util::CacheItem> items = { {0, 100, 1}, {1, 100, 5}, {2, 100, 9}, {3, 0, 0} };
std::vector<std::size_t> ev = util::selectEvictions(items, 150, 9);
EXPECT_EQ(ev.size(), (std::size_t)2);
if (ev.size() == 2) { EXPECT_EQ(ev[0], (std::size_t)0); EXPECT_EQ(ev[1], (std::size_t)1); }
ev = util::selectEvictions(items, 150, 5); // lastUse >= 5 protected
EXPECT_EQ(ev.size(), (std::size_t)1);
if (ev.size() == 1) EXPECT_EQ(ev[0], (std::size_t)0);
EXPECT_TRUE(util::selectEvictions(items, 400, 9).empty()); // under budget: nothing
}
void testImageDecodeCore()
{
namespace util = dragonx::util;
const util::ImageLimits lim;
util::AnimFrames af;
// 2-frame animation: frame count, per-frame delay (cs -> s), animated flag, source size.
{
auto g = imgTestGif(8, 4, {1, 1}, {5, 10});
EXPECT_TRUE(util::DecodeAnimatedFromMemory(g.data(), g.size(), 300, lim, nullptr, af) == util::ImageStatus::Ok);
EXPECT_EQ(af.frames.size(), (std::size_t)2);
EXPECT_EQ(af.delaysSec.size(), (std::size_t)2);
if (af.delaysSec.size() == 2) { EXPECT_NEAR(af.delaysSec[0], 0.05, 1e-4); EXPECT_NEAR(af.delaysSec[1], 0.10, 1e-4); }
EXPECT_TRUE(af.animated);
EXPECT_EQ(af.srcW, 8); EXPECT_EQ(af.srcH, 4);
if (!af.frames.empty()) EXPECT_EQ(af.frames[0].size(), (std::size_t)(8 * 4 * 4));
}
// maxFrames truncation: streaming stops after 3 of 5 frames; the SOURCE still counts as animated.
{
auto g = imgTestGif(8, 4, {1, 1, 1, 1, 1}, {5, 10, 15, 20, 25});
EXPECT_TRUE(util::DecodeAnimatedFromMemory(g.data(), g.size(), 3, lim, nullptr, af) == util::ImageStatus::Ok);
EXPECT_EQ(af.frames.size(), (std::size_t)3);
if (af.delaysSec.size() == 3) EXPECT_NEAR(af.delaysSec[2], 0.15, 1e-4);
EXPECT_TRUE(af.animated);
}
// Disposal 3 on every frame: stb's own loop read before the heap buffer here. Run under ASan/valgrind.
{
auto g = imgTestGif(8, 4, {3, 3, 3, 3}, {5, 5, 5, 5});
EXPECT_TRUE(util::DecodeAnimatedFromMemory(g.data(), g.size(), 300, lim, nullptr, af) == util::ImageStatus::Ok);
EXPECT_EQ(af.frames.size(), (std::size_t)4);
}
// Truncated GIF: keeps the frames decoded so far, never crashes.
{
auto g = imgTestGif(8, 4, {1, 1}, {5, 5});
g.resize(g.size() - 20);
const util::ImageStatus st = util::DecodeAnimatedFromMemory(g.data(), g.size(), 300, lim, nullptr, af);
EXPECT_TRUE(st == util::ImageStatus::Ok || st == util::ImageStatus::DecodeError);
if (st == util::ImageStatus::Ok) EXPECT_TRUE(af.frames.size() >= 1 && af.frames.size() <= 2);
}
// Single-frame GIF above the still bound is downscaled (512x256 -> 256x128) and is not "animated".
{
auto g = imgTestGif(512, 256, {0}, {0});
EXPECT_TRUE(util::DecodeAnimatedFromMemory(g.data(), g.size(), 300, lim, nullptr, af) == util::ImageStatus::Ok);
EXPECT_EQ(af.frames.size(), (std::size_t)1);
EXPECT_EQ(af.w, 256); EXPECT_EQ(af.h, 128);
EXPECT_FALSE(af.animated);
}
// Oversized GIF canvas (8000x8000 = 64 M px > 32 Mi px): rejected from the 13-byte header, nothing allocated.
{
auto g = imgTestGif(8, 4, {}, {}); // header + palette + trailer, no frames
g[6] = imgU8(8000 & 255); g[7] = imgU8(8000 >> 8); g[8] = imgU8(8000 & 255); g[9] = imgU8(8000 >> 8);
EXPECT_TRUE(util::DecodeAnimatedFromMemory(g.data(), g.size(), 300, lim, nullptr, af) == util::ImageStatus::TooLarge);
EXPECT_TRUE(af.frames.empty());
}
// Stills: header-only BMP claiming 20000x20000 (400 MP) is rejected without a pixel buffer; a real 3x2 decodes.
{
auto big = imgTestBmp(20000, 20000, false);
EXPECT_TRUE(util::DecodeAnimatedFromMemory(big.data(), big.size(), 1, lim, nullptr, af) == util::ImageStatus::TooLarge);
auto ok = imgTestBmp(3, 2, true);
EXPECT_TRUE(util::DecodeAnimatedFromMemory(ok.data(), ok.size(), 1, lim, nullptr, af) == util::ImageStatus::Ok);
EXPECT_EQ(af.frames.size(), (std::size_t)1);
EXPECT_EQ(af.w, 3); EXPECT_EQ(af.h, 2);
EXPECT_FALSE(af.animated);
}
// Garbage and cancellation.
{
const unsigned char junk[16] = { 1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16 };
EXPECT_TRUE(util::DecodeAnimatedFromMemory(junk, sizeof(junk), 1, lim, nullptr, af) == util::ImageStatus::Unsupported);
auto g = imgTestGif(8, 4, {1, 1, 1}, {5, 5, 5});
std::atomic<bool> cancelNow{true};
EXPECT_TRUE(util::DecodeAnimatedFromMemory(g.data(), g.size(), 300, lim, &cancelNow, af) == util::ImageStatus::Cancelled);
EXPECT_TRUE(af.frames.empty());
}
// File-size cap is applied BEFORE the read; back-compat wrappers still work.
{
const fs::path dir = makeTempDir();
const fs::path p = dir / "a.gif";
imgWriteFile(p, imgTestGif(8, 4, {1, 1}, {5, 5}));
util::ImageLimits tiny = lim; tiny.maxFileBytes = 10;
EXPECT_TRUE(util::LoadAnimatedRGBAEx(p.string().c_str(), 300, tiny, nullptr, af) == util::ImageStatus::TooLarge);
EXPECT_TRUE(util::LoadAnimatedRGBAEx(p.string().c_str(), 300, lim, nullptr, af) == util::ImageStatus::Ok);
EXPECT_TRUE(util::LoadAnimatedRGBA(p.string().c_str(), 300, af));
EXPECT_TRUE(util::IsAnimatedImageFile(p.string().c_str()));
EXPECT_TRUE(util::LoadAnimatedRGBAEx((dir / "missing.gif").string().c_str(), 1, lim, nullptr, af) == util::ImageStatus::ReadError);
removeTree(dir);
}
}
void testImageDecodeJobs()
{
namespace util = dragonx::util;
const fs::path dir = makeTempDir();
const fs::path p = dir / "anim.gif";
imgWriteFile(p, imgTestGif(8, 4, {1, 1, 1}, {5, 5, 5}));
auto waitFor = [](const util::ImageDecodeJobPtr& j) {
for (int i = 0; i < 500 && !j->finished(); ++i) std::this_thread::sleep_for(std::chrono::milliseconds(10));
return j->finished();
};
util::ImageDecodeLane closed(0); // a full lane never starts work
EXPECT_TRUE(util::StartImageDecode(closed, p.string(), 300, util::ImageLimits{}) == nullptr);
util::ImageDecodeLane lane(2);
auto job = util::StartImageDecode(lane, p.string(), 300, util::ImageLimits{});
EXPECT_TRUE(job != nullptr);
if (job) {
EXPECT_TRUE(waitFor(job));
EXPECT_TRUE(job->ok());
EXPECT_EQ(job->result.frames.size(), (std::size_t)3);
EXPECT_TRUE(job->result.animated);
}
EXPECT_EQ(lane.inFlight(), 0); // slot released before `done` is published
// afterDecode runs on the worker only after a successful decode; its return value is outPath.
auto j2 = util::StartImageDecode(lane, p.string(), 1, util::ImageLimits{},
[](const std::string& s) { return s + ".copy"; });
EXPECT_TRUE(j2 != nullptr && waitFor(j2));
if (j2) { EXPECT_TRUE(j2->ok()); EXPECT_EQ(j2->outPath, p.string() + ".copy"); }
auto j3 = util::StartImageDecode(lane, p.string(), 1, util::ImageLimits{},
[](const std::string&) { return std::string(); });
EXPECT_TRUE(j3 != nullptr && waitFor(j3));
if (j3) { EXPECT_TRUE(j3->status == util::ImageStatus::PostFailed); EXPECT_FALSE(j3->ok()); }
// Missing file -> ReadError (never a hang, never a crash); afterDecode is not called.
bool called = false;
auto j4 = util::StartImageDecode(lane, (dir / "nope.png").string(), 1, util::ImageLimits{},
[&called](const std::string& s) { called = true; return s; });
EXPECT_TRUE(j4 != nullptr && waitFor(j4));
if (j4) { EXPECT_FALSE(j4->ok()); EXPECT_TRUE(j4->status == util::ImageStatus::ReadError); }
EXPECT_FALSE(called);
// Cancelled right after start: finishes either way (won the race = Ok) and never leaks the slot.
auto j5 = util::StartImageDecode(lane, p.string(), 300, util::ImageLimits{});
if (j5) {
j5->cancel.store(true);
EXPECT_TRUE(waitFor(j5));
EXPECT_TRUE(j5->ok() || j5->result.frames.empty());
}
EXPECT_EQ(lane.inFlight(), 0);
removeTree(dir);
}
void testGeneratedResourceBehavior()
{
const auto* themes = dragonx::resources::getEmbeddedThemes();
@@ -14092,6 +14346,9 @@ int main()
testBlockDbOutputDiagnosis();
testLiteServerProbeLive();
testXmrigLiveInstall();
testImageDecodePolicy();
testImageDecodeCore();
testImageDecodeJobs();
testGeneratedResourceBehavior();
if (g_failures != 0) {