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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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,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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user