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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-09 21:09:48 -05:00
co-authored by Claude Opus 5.5
parent 971f2abae5
commit c6fc1ac168
7 changed files with 893 additions and 300 deletions
+257
View File
@@ -64,6 +64,7 @@
#include "data/seed_migration_resume.h"
#include "util/address_validation.h"
#include "util/seed_phrase.h"
#include "util/image_decode_core.h"
#include "daemon/daemon_startup_diagnosis.h"
#include "util/amount_format.h"
#include "util/payment_uri.h"
@@ -7203,6 +7204,259 @@ void testLiteClientBridgeUsesRuntimeOwnedStringCleanup()
// ---- image decode (C185 / C186) ----------------------------------------------------------------
unsigned char imgU8(int v) { return static_cast<unsigned char>(v); }
// GIF "literal" LZW: min code size 8, a clear code every 100 pixels so the code width stays 9 bits.
std::vector<unsigned char> imgTestLzw(const std::vector<unsigned char>& idx)
{
std::vector<unsigned char> out;
std::uint32_t acc = 0; int nb = 0;
auto put = [&](std::uint32_t code) {
acc |= code << nb; nb += 9;
while (nb >= 8) { out.push_back(imgU8((int)(acc & 0xFF))); acc >>= 8; nb -= 8; }
};
put(256); // clear
int run = 0;
for (unsigned char p : idx) {
if (run == 100) { put(256); run = 0; }
put(p); ++run;
}
put(257); // end of information
if (nb > 0) out.push_back(imgU8((int)(acc & 0xFF)));
return out;
}
// n-frame GIF89a, full-canvas frames, 256-entry global palette; disposal[f] in 0..3, delayCs[f] in 1/100 s.
std::vector<unsigned char> imgTestGif(int w, int h, const std::vector<int>& disposal, const std::vector<int>& delayCs)
{
std::vector<unsigned char> g = { 'G','I','F','8','9','a', imgU8(w & 255), imgU8(w >> 8), imgU8(h & 255), imgU8(h >> 8),
0xF7, 0, 0 };
for (int i = 0; i < 256; ++i) { g.push_back(imgU8(i)); g.push_back(imgU8(255 - i)); g.push_back(imgU8(i / 2)); }
for (size_t f = 0; f < disposal.size(); ++f) {
const int cs = delayCs[f];
const unsigned char gce[] = { 0x21, 0xF9, 0x04, imgU8(disposal[f] << 2), imgU8(cs & 255), imgU8(cs >> 8), 0, 0 };
g.insert(g.end(), std::begin(gce), std::end(gce));
const unsigned char desc[] = { 0x2C, 0, 0, 0, 0, imgU8(w & 255), imgU8(w >> 8), imgU8(h & 255), imgU8(h >> 8), 0 };
g.insert(g.end(), std::begin(desc), std::end(desc));
std::vector<unsigned char> idx((size_t)w * h);
for (size_t i = 0; i < idx.size(); ++i) idx[i] = imgU8((int)((i + f * 37) & 255));
const std::vector<unsigned char> lzw = imgTestLzw(idx);
g.push_back(8); // LZW minimum code size
for (size_t o = 0; o < lzw.size(); o += 255) {
const size_t n = std::min<size_t>(255, lzw.size() - o);
g.push_back(imgU8((int)n));
g.insert(g.end(), lzw.begin() + (std::ptrdiff_t)o, lzw.begin() + (std::ptrdiff_t)(o + n));
}
g.push_back(0); // block terminator
}
g.push_back(0x3B); // trailer
return g;
}
// 24-bit BMP; withPixels=false gives a header-only file (limit tests: nothing to allocate).
std::vector<unsigned char> imgTestBmp(int w, int h, bool withPixels)
{
const std::uint32_t rowBytes = (std::uint32_t)((w * 3 + 3) & ~3);
const std::uint32_t dataBytes = withPixels ? rowBytes * (std::uint32_t)h : 0;
std::vector<unsigned char> b;
auto le32 = [&](std::uint32_t v) { for (int i = 0; i < 4; ++i) b.push_back(imgU8((int)((v >> (8 * i)) & 255))); };
auto le16 = [&](unsigned v) { b.push_back(imgU8((int)(v & 255))); b.push_back(imgU8((int)(v >> 8))); };
b.push_back('B'); b.push_back('M');
le32(54 + dataBytes); le32(0); le32(54);
le32(40); le32((std::uint32_t)w); le32((std::uint32_t)h); le16(1); le16(24);
le32(0); le32(dataBytes); le32(2835); le32(2835); le32(0); le32(0);
b.resize(54 + dataBytes, 0x80);
return b;
}
void imgWriteFile(const fs::path& p, const std::vector<unsigned char>& bytes)
{
std::ofstream o(p, std::ios::binary);
o.write(reinterpret_cast<const char*>(bytes.data()), (std::streamsize)bytes.size());
}
void testImageDecodePolicy()
{
namespace util = dragonx::util;
int dw = 0, dh = 0;
util::FitImageDims(1000, 500, 250, &dw, &dh);
EXPECT_EQ(dw, 250); EXPECT_EQ(dh, 125);
util::FitImageDims(100, 50, 250, &dw, &dh); // already small: unchanged
EXPECT_EQ(dw, 100); EXPECT_EQ(dh, 50);
const util::ImageLimits lim;
EXPECT_TRUE(util::DimsWithinLimits(4000, 3000, false, lim)); // 12 MP photo
EXPECT_TRUE(util::DimsWithinLimits(16320, 12240, false, lim)); // 200 MP phone mode still loads
EXPECT_TRUE(util::DimsWithinLimits(30000, 4000, false, lim)); // wide panorama (side > 16384) still loads
EXPECT_FALSE(util::DimsWithinLimits(20000, 20000, false, lim)); // 400 MP: rejected
EXPECT_TRUE(util::DimsWithinLimits(3840, 2160, true, lim)); // 4K animation
EXPECT_TRUE(util::DimsWithinLimits(8192, 4096, true, lim)); // exactly 32 Mi px
EXPECT_FALSE(util::DimsWithinLimits(8193, 4096, true, lim));
EXPECT_FALSE(util::DimsWithinLimits(0, 5, false, lim));
const std::vector<unsigned char> gif = imgTestGif(8, 4, {0}, {5});
int gw = 0, gh = 0;
EXPECT_TRUE(util::ParseGifLogicalScreen(gif.data(), gif.size(), &gw, &gh));
EXPECT_EQ(gw, 8); EXPECT_EQ(gh, 4);
EXPECT_FALSE(util::ParseGifLogicalScreen(gif.data(), 12, &gw, &gh)); // too short
const unsigned char notGif[13] = { 'P','N','G',0,0,0,0,0,0,0,0,0,0 };
EXPECT_FALSE(util::ParseGifLogicalScreen(notGif, sizeof(notGif), &gw, &gh));
// Eviction: LRU first, recently used items protected, zero-byte items never chosen.
std::vector<util::CacheItem> items = { {0, 100, 1}, {1, 100, 5}, {2, 100, 9}, {3, 0, 0} };
std::vector<std::size_t> ev = util::selectEvictions(items, 150, 9);
EXPECT_EQ(ev.size(), (std::size_t)2);
if (ev.size() == 2) { EXPECT_EQ(ev[0], (std::size_t)0); EXPECT_EQ(ev[1], (std::size_t)1); }
ev = util::selectEvictions(items, 150, 5); // lastUse >= 5 protected
EXPECT_EQ(ev.size(), (std::size_t)1);
if (ev.size() == 1) EXPECT_EQ(ev[0], (std::size_t)0);
EXPECT_TRUE(util::selectEvictions(items, 400, 9).empty()); // under budget: nothing
}
void testImageDecodeCore()
{
namespace util = dragonx::util;
const util::ImageLimits lim;
util::AnimFrames af;
// 2-frame animation: frame count, per-frame delay (cs -> s), animated flag, source size.
{
auto g = imgTestGif(8, 4, {1, 1}, {5, 10});
EXPECT_TRUE(util::DecodeAnimatedFromMemory(g.data(), g.size(), 300, lim, nullptr, af) == util::ImageStatus::Ok);
EXPECT_EQ(af.frames.size(), (std::size_t)2);
EXPECT_EQ(af.delaysSec.size(), (std::size_t)2);
if (af.delaysSec.size() == 2) { EXPECT_NEAR(af.delaysSec[0], 0.05, 1e-4); EXPECT_NEAR(af.delaysSec[1], 0.10, 1e-4); }
EXPECT_TRUE(af.animated);
EXPECT_EQ(af.srcW, 8); EXPECT_EQ(af.srcH, 4);
if (!af.frames.empty()) EXPECT_EQ(af.frames[0].size(), (std::size_t)(8 * 4 * 4));
}
// maxFrames truncation: streaming stops after 3 of 5 frames; the SOURCE still counts as animated.
{
auto g = imgTestGif(8, 4, {1, 1, 1, 1, 1}, {5, 10, 15, 20, 25});
EXPECT_TRUE(util::DecodeAnimatedFromMemory(g.data(), g.size(), 3, lim, nullptr, af) == util::ImageStatus::Ok);
EXPECT_EQ(af.frames.size(), (std::size_t)3);
if (af.delaysSec.size() == 3) EXPECT_NEAR(af.delaysSec[2], 0.15, 1e-4);
EXPECT_TRUE(af.animated);
}
// Disposal 3 on every frame: stb's own loop read before the heap buffer here. Run under ASan/valgrind.
{
auto g = imgTestGif(8, 4, {3, 3, 3, 3}, {5, 5, 5, 5});
EXPECT_TRUE(util::DecodeAnimatedFromMemory(g.data(), g.size(), 300, lim, nullptr, af) == util::ImageStatus::Ok);
EXPECT_EQ(af.frames.size(), (std::size_t)4);
}
// Truncated GIF: keeps the frames decoded so far, never crashes.
{
auto g = imgTestGif(8, 4, {1, 1}, {5, 5});
g.resize(g.size() - 20);
const util::ImageStatus st = util::DecodeAnimatedFromMemory(g.data(), g.size(), 300, lim, nullptr, af);
EXPECT_TRUE(st == util::ImageStatus::Ok || st == util::ImageStatus::DecodeError);
if (st == util::ImageStatus::Ok) EXPECT_TRUE(af.frames.size() >= 1 && af.frames.size() <= 2);
}
// Single-frame GIF above the still bound is downscaled (512x256 -> 256x128) and is not "animated".
{
auto g = imgTestGif(512, 256, {0}, {0});
EXPECT_TRUE(util::DecodeAnimatedFromMemory(g.data(), g.size(), 300, lim, nullptr, af) == util::ImageStatus::Ok);
EXPECT_EQ(af.frames.size(), (std::size_t)1);
EXPECT_EQ(af.w, 256); EXPECT_EQ(af.h, 128);
EXPECT_FALSE(af.animated);
}
// Oversized GIF canvas (8000x8000 = 64 M px > 32 Mi px): rejected from the 13-byte header, nothing allocated.
{
auto g = imgTestGif(8, 4, {}, {}); // header + palette + trailer, no frames
g[6] = imgU8(8000 & 255); g[7] = imgU8(8000 >> 8); g[8] = imgU8(8000 & 255); g[9] = imgU8(8000 >> 8);
EXPECT_TRUE(util::DecodeAnimatedFromMemory(g.data(), g.size(), 300, lim, nullptr, af) == util::ImageStatus::TooLarge);
EXPECT_TRUE(af.frames.empty());
}
// Stills: header-only BMP claiming 20000x20000 (400 MP) is rejected without a pixel buffer; a real 3x2 decodes.
{
auto big = imgTestBmp(20000, 20000, false);
EXPECT_TRUE(util::DecodeAnimatedFromMemory(big.data(), big.size(), 1, lim, nullptr, af) == util::ImageStatus::TooLarge);
auto ok = imgTestBmp(3, 2, true);
EXPECT_TRUE(util::DecodeAnimatedFromMemory(ok.data(), ok.size(), 1, lim, nullptr, af) == util::ImageStatus::Ok);
EXPECT_EQ(af.frames.size(), (std::size_t)1);
EXPECT_EQ(af.w, 3); EXPECT_EQ(af.h, 2);
EXPECT_FALSE(af.animated);
}
// Garbage and cancellation.
{
const unsigned char junk[16] = { 1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16 };
EXPECT_TRUE(util::DecodeAnimatedFromMemory(junk, sizeof(junk), 1, lim, nullptr, af) == util::ImageStatus::Unsupported);
auto g = imgTestGif(8, 4, {1, 1, 1}, {5, 5, 5});
std::atomic<bool> cancelNow{true};
EXPECT_TRUE(util::DecodeAnimatedFromMemory(g.data(), g.size(), 300, lim, &cancelNow, af) == util::ImageStatus::Cancelled);
EXPECT_TRUE(af.frames.empty());
}
// File-size cap is applied BEFORE the read; back-compat wrappers still work.
{
const fs::path dir = makeTempDir();
const fs::path p = dir / "a.gif";
imgWriteFile(p, imgTestGif(8, 4, {1, 1}, {5, 5}));
util::ImageLimits tiny = lim; tiny.maxFileBytes = 10;
EXPECT_TRUE(util::LoadAnimatedRGBAEx(p.string().c_str(), 300, tiny, nullptr, af) == util::ImageStatus::TooLarge);
EXPECT_TRUE(util::LoadAnimatedRGBAEx(p.string().c_str(), 300, lim, nullptr, af) == util::ImageStatus::Ok);
EXPECT_TRUE(util::LoadAnimatedRGBA(p.string().c_str(), 300, af));
EXPECT_TRUE(util::IsAnimatedImageFile(p.string().c_str()));
EXPECT_TRUE(util::LoadAnimatedRGBAEx((dir / "missing.gif").string().c_str(), 1, lim, nullptr, af) == util::ImageStatus::ReadError);
removeTree(dir);
}
}
void testImageDecodeJobs()
{
namespace util = dragonx::util;
const fs::path dir = makeTempDir();
const fs::path p = dir / "anim.gif";
imgWriteFile(p, imgTestGif(8, 4, {1, 1, 1}, {5, 5, 5}));
auto waitFor = [](const util::ImageDecodeJobPtr& j) {
for (int i = 0; i < 500 && !j->finished(); ++i) std::this_thread::sleep_for(std::chrono::milliseconds(10));
return j->finished();
};
util::ImageDecodeLane closed(0); // a full lane never starts work
EXPECT_TRUE(util::StartImageDecode(closed, p.string(), 300, util::ImageLimits{}) == nullptr);
util::ImageDecodeLane lane(2);
auto job = util::StartImageDecode(lane, p.string(), 300, util::ImageLimits{});
EXPECT_TRUE(job != nullptr);
if (job) {
EXPECT_TRUE(waitFor(job));
EXPECT_TRUE(job->ok());
EXPECT_EQ(job->result.frames.size(), (std::size_t)3);
EXPECT_TRUE(job->result.animated);
}
EXPECT_EQ(lane.inFlight(), 0); // slot released before `done` is published
// afterDecode runs on the worker only after a successful decode; its return value is outPath.
auto j2 = util::StartImageDecode(lane, p.string(), 1, util::ImageLimits{},
[](const std::string& s) { return s + ".copy"; });
EXPECT_TRUE(j2 != nullptr && waitFor(j2));
if (j2) { EXPECT_TRUE(j2->ok()); EXPECT_EQ(j2->outPath, p.string() + ".copy"); }
auto j3 = util::StartImageDecode(lane, p.string(), 1, util::ImageLimits{},
[](const std::string&) { return std::string(); });
EXPECT_TRUE(j3 != nullptr && waitFor(j3));
if (j3) { EXPECT_TRUE(j3->status == util::ImageStatus::PostFailed); EXPECT_FALSE(j3->ok()); }
// Missing file -> ReadError (never a hang, never a crash); afterDecode is not called.
bool called = false;
auto j4 = util::StartImageDecode(lane, (dir / "nope.png").string(), 1, util::ImageLimits{},
[&called](const std::string& s) { called = true; return s; });
EXPECT_TRUE(j4 != nullptr && waitFor(j4));
if (j4) { EXPECT_FALSE(j4->ok()); EXPECT_TRUE(j4->status == util::ImageStatus::ReadError); }
EXPECT_FALSE(called);
// Cancelled right after start: finishes either way (won the race = Ok) and never leaks the slot.
auto j5 = util::StartImageDecode(lane, p.string(), 300, util::ImageLimits{});
if (j5) {
j5->cancel.store(true);
EXPECT_TRUE(waitFor(j5));
EXPECT_TRUE(j5->ok() || j5->result.frames.empty());
}
EXPECT_EQ(lane.inFlight(), 0);
removeTree(dir);
}
void testGeneratedResourceBehavior()
{
const auto* themes = dragonx::resources::getEmbeddedThemes();
@@ -14092,6 +14346,9 @@ int main()
testBlockDbOutputDiagnosis();
testLiteServerProbeLive();
testXmrigLiveInstall();
testImageDecodePolicy();
testImageDecodeCore();
testImageDecodeJobs();
testGeneratedResourceBehavior();
if (g_failures != 0) {