diff --git a/CMakeLists.txt b/CMakeLists.txt index 6525f9a..f53375a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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 # , 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 diff --git a/src/util/image_decode_core.cpp b/src/util/image_decode_core.cpp new file mode 100644 index 0000000..f0f9d58 --- /dev/null +++ b/src/util/image_decode_core.cpp @@ -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 +#include // WebPAnimDecoder — animated WebP + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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* 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>& out) +{ + out.emplace_back(); + std::vector& 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& 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& buf) +{ + return ReadFileCapped(path, 0, buf) == ImageStatus::Ok; +} + +unsigned char* LoadRawPixelsFromFile(const char* path, int* outW, int* outH) +{ + std::vector 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 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* 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 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* 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 gs(new GifState()); + std::vector 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(&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* 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 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* 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* cancel, AnimFrames& out) +{ + out = AnimFrames{}; + std::vector 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(); + std::shared_ptr> 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 diff --git a/src/util/image_decode_core.h b/src/util/image_decode_core.h new file mode 100644 index 0000000..43bcb36 --- /dev/null +++ b/src/util/image_decode_core.h @@ -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 +#include +#include +#include +#include +#include +#include + +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> frames; ///< each is w*h*4 RGBA (already downscaled) + std::vector 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* 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* 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& buf); +bool ReadFileToBuffer(const char* path, std::vector& 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 done{false}; + std::atomic 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; + +/// 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; + +/// 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> counter() const { return count_; } +private: + int cap_; + std::shared_ptr> count_ = std::make_shared>(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 diff --git a/src/util/image_decode_policy.h b/src/util/image_decode_policy.h new file mode 100644 index 0000000..0f60edc --- /dev/null +++ b/src/util/image_decode_policy.h @@ -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 +#include +#include +#include +#include + +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 selectEvictions(std::vector items, std::size_t budget, + std::uint64_t protectFromUse) +{ + std::size_t total = 0; + for (const CacheItem& i : items) total += i.bytes; + std::vector 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 diff --git a/src/util/texture_loader.cpp b/src/util/texture_loader.cpp index dd9d709..c69641b 100644 --- a/src/util/texture_loader.cpp +++ b/src/util/texture_loader.cpp @@ -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 -#include // 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 #include @@ -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& 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 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 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>& out) -{ - out.emplace_back(); - std::vector& 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 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 file; - if (!ReadFileToBuffer(path, file)) return false; - return IsAnimatedData(file.data(), file.size()); -} - void DestroyTexture(ImTextureID texID) { if (!texID) return; diff --git a/src/util/texture_loader.h b/src/util/texture_loader.h index 1b0abbd..b20bd7f 100644 --- a/src/util/texture_loader.h +++ b/src/util/texture_loader.h @@ -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 #include namespace dragonx { namespace util { -/** - * @brief Downscaled RGBA frame sequence for an image (1 frame for stills, N for animations). - */ -struct AnimFrames { - std::vector> frames; ///< each is w*h*4 RGBA (already downscaled) - std::vector 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) diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index 30e8ad7..5325da7 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -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(v); } + +// GIF "literal" LZW: min code size 8, a clear code every 100 pixels so the code width stays 9 bits. +std::vector imgTestLzw(const std::vector& idx) +{ + std::vector 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 imgTestGif(int w, int h, const std::vector& disposal, const std::vector& delayCs) +{ + std::vector 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 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 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(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 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 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& bytes) +{ + std::ofstream o(p, std::ios::binary); + o.write(reinterpret_cast(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 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 items = { {0, 100, 1}, {1, 100, 5}, {2, 100, 9}, {3, 0, 0} }; + std::vector 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 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) {