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:
@@ -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