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
+9 -250
View File
@@ -4,24 +4,9 @@
#include "texture_loader.h"
// stb_image — single-file image loader (public domain)
// Only compiled here; all other files just include the header.
#define STB_IMAGE_IMPLEMENTATION
// Formats stb decodes for us: PNG for app assets, plus the common user-avatar formats. WebP is handled
// separately (libwebp) below. Keep this list in sync with ImagePicker's accepted extensions.
#define STBI_ONLY_PNG
#define STBI_ONLY_JPEG
#define STBI_ONLY_BMP
#define STBI_ONLY_GIF
#define STBI_ONLY_TGA
#define STBI_ONLY_PSD
#define STBI_ONLY_PNM // .pnm/.ppm/.pgm/.pbm
#define STBI_ONLY_PIC
#define STBI_NO_STDIO // we do our own fread for portability
#include "stb_image.h"
#include <webp/decode.h>
#include <webp/demux.h> // WebPAnimDecoder — animated WebP
// Image DECODING (the stb_image implementation, libwebp, limits, streaming GIF) lives in
// image_decode_core.cpp, which is GL-free and safe on worker threads. This file only creates and destroys
// GPU textures and must be called from the UI (GL/DX) thread.
#include <algorithm>
#include <cstdint>
@@ -53,45 +38,6 @@ static ID3D11Device* GetImGuiD3D11Device()
namespace dragonx {
namespace util {
// True if the buffer is a WebP (RIFF....WEBP) container.
static bool IsWebP(const unsigned char* d, size_t n)
{
return n >= 12 && memcmp(d, "RIFF", 4) == 0 && memcmp(d + 8, "WEBP", 4) == 0;
}
// Decode any supported image to a fresh RGBA8 buffer. WebP goes through libwebp; everything else
// through stb. The returned buffer is always free()-able (stbi's default free is free(), and the WebP
// path decodes into a malloc'd buffer), so FreeRawPixels(stbi_image_free) releases either. NULL on fail.
static unsigned char* DecodeImageRGBA(const unsigned char* data, size_t len, int* outW, int* outH)
{
if (IsWebP(data, len)) {
int w = 0, h = 0;
if (!WebPGetInfo(data, len, &w, &h) || w <= 0 || h <= 0) return nullptr;
unsigned char* buf = (unsigned char*)malloc((size_t)w * h * 4);
if (!buf) return nullptr;
if (!WebPDecodeRGBAInto(data, len, buf, (size_t)w * h * 4, w * 4)) { free(buf); return nullptr; }
*outW = w; *outH = h;
return buf;
}
int channels = 0;
return stbi_load_from_memory(data, (int)len, outW, outH, &channels, 4);
}
// Read entire file into memory
static bool ReadFileToBuffer(const char* path, std::vector<unsigned char>& buf)
{
FILE* f = fopen(path, "rb");
if (!f) return false;
fseek(f, 0, SEEK_END);
long sz = ftell(f);
fseek(f, 0, SEEK_SET);
if (sz <= 0) { fclose(f); return false; }
buf.resize((size_t)sz);
size_t rd = fread(buf.data(), 1, (size_t)sz, f);
fclose(f);
return rd == (size_t)sz;
}
bool LoadTextureFromFile(const char* path, ImTextureID* outTexID, int* outW, int* outH)
{
std::vector<unsigned char> fileData;
@@ -110,7 +56,7 @@ bool LoadTextureFromFile(const char* path, ImTextureID* outTexID, int* outW, int
#ifdef DRAGONX_USE_DX11
ID3D11Device* device = GetImGuiD3D11Device();
if (!device) {
stbi_image_free(pixels);
FreeRawPixels(pixels);
DEBUG_LOGF("LoadTextureFromFile: no D3D11 device available\n");
return false;
}
@@ -131,7 +77,7 @@ bool LoadTextureFromFile(const char* path, ImTextureID* outTexID, int* outW, int
ID3D11Texture2D* texture = nullptr;
HRESULT hr = device->CreateTexture2D(&desc, &initData, &texture);
stbi_image_free(pixels);
FreeRawPixels(pixels);
if (FAILED(hr) || !texture) {
DEBUG_LOGF("LoadTextureFromFile: CreateTexture2D failed for '%s'\n", path);
return false;
@@ -169,7 +115,7 @@ bool LoadTextureFromFile(const char* path, ImTextureID* outTexID, int* outW, int
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glBindTexture(GL_TEXTURE_2D, 0);
stbi_image_free(pixels);
FreeRawPixels(pixels);
*outTexID = (ImTextureID)(intptr_t)tex;
*outW = w;
@@ -197,7 +143,7 @@ bool LoadTextureFromMemory(const unsigned char* data, size_t dataSize,
#ifdef DRAGONX_USE_DX11
ID3D11Device* device = GetImGuiD3D11Device();
if (!device) {
stbi_image_free(pixels);
FreeRawPixels(pixels);
DEBUG_LOGF("LoadTextureFromMemory: no D3D11 device available\n");
return false;
}
@@ -218,7 +164,7 @@ bool LoadTextureFromMemory(const unsigned char* data, size_t dataSize,
ID3D11Texture2D* texture = nullptr;
HRESULT hr = device->CreateTexture2D(&desc, &initData, &texture);
stbi_image_free(pixels);
FreeRawPixels(pixels);
if (FAILED(hr) || !texture) {
DEBUG_LOGF("LoadTextureFromMemory: CreateTexture2D failed\n");
return false;
@@ -255,7 +201,7 @@ bool LoadTextureFromMemory(const unsigned char* data, size_t dataSize,
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glBindTexture(GL_TEXTURE_2D, 0);
stbi_image_free(pixels);
FreeRawPixels(pixels);
*outTexID = (ImTextureID)(intptr_t)tex;
*outW = w;
@@ -326,193 +272,6 @@ bool CreateRawTexture(const unsigned char* pixels, int w, int h,
#endif
}
unsigned char* LoadRawPixelsFromFile(const char* path, int* outW, int* outH)
{
std::vector<unsigned char> fileData;
if (!ReadFileToBuffer(path, fileData)) return nullptr;
return DecodeImageRGBA(fileData.data(), fileData.size(), outW, outH);
}
unsigned char* LoadRawPixelsFromMemory(const unsigned char* data, size_t dataSize, int* outW, int* outH)
{
if (!data || dataSize == 0) return nullptr;
return DecodeImageRGBA(data, dataSize, outW, outH);
}
void FreeRawPixels(unsigned char* pixels)
{
if (pixels) stbi_image_free(pixels);
}
// Box-average downscale an RGBA image to dw*dh, appending the result as a new frame in `out`.
static void AppendDownscaledFrame(const unsigned char* src, int sw, int sh, int dw, int dh,
std::vector<std::vector<unsigned char>>& out)
{
out.emplace_back();
std::vector<unsigned char>& dst = out.back();
dst.resize((size_t)dw * dh * 4);
for (int y = 0; y < dh; y++) {
int sy0 = y * sh / dh, sy1 = std::max(sy0 + 1, (y + 1) * sh / dh);
for (int x = 0; x < dw; x++) {
int sx0 = x * sw / dw, sx1 = std::max(sx0 + 1, (x + 1) * sw / dw);
uint32_t r=0,g=0,b=0,a=0,n=0;
for (int yy = sy0; yy < sy1; yy++)
for (int xx = sx0; xx < sx1; xx++) {
const unsigned char* p = src + ((size_t)yy * sw + xx) * 4;
r+=p[0]; g+=p[1]; b+=p[2]; a+=p[3]; ++n;
}
unsigned char* d = dst.data() + ((size_t)y * dw + x) * 4;
d[0]=(unsigned char)(r/n); d[1]=(unsigned char)(g/n); d[2]=(unsigned char)(b/n); d[3]=(unsigned char)(a/n);
}
}
}
static void FitDims(int sw, int sh, int maxDim, int* dw, int* dh)
{
*dw = sw; *dh = sh;
if (sw > maxDim || sh > maxDim) {
float s = (float)maxDim / (float)std::max(sw, sh);
*dw = std::max(1, (int)(sw * s));
*dh = std::max(1, (int)(sh * s));
}
}
bool LoadAnimatedRGBA(const char* path, int maxFrames, AnimFrames& out)
{
out.frames.clear();
out.delaysSec.clear();
out.w = out.h = 0;
std::vector<unsigned char> file;
if (!ReadFileToBuffer(path, file)) return false;
const unsigned char* data = file.data();
const size_t len = file.size();
if (maxFrames < 1) maxFrames = 1;
// Static max dimension is generous (an avatar renders <=112px); animations use a smaller cap so a
// long clip can't cost tens of MB of VRAM. We only know if it animates after peeking the frame count.
const int kStillMax = 256, kAnimMax = 128;
// ---- Animated (or still) WebP via libwebp's demux decoder ----
if (IsWebP(data, len)) {
WebPData wd; wd.bytes = data; wd.size = len;
WebPAnimDecoderOptions opt;
if (!WebPAnimDecoderOptionsInit(&opt)) return false;
opt.color_mode = MODE_RGBA;
WebPAnimDecoder* dec = WebPAnimDecoderNew(&wd, &opt);
if (!dec) {
// Not an animation container — fall back to a single still decode.
int w=0,h=0; unsigned char* px = DecodeImageRGBA(data, len, &w, &h);
if (!px) return false;
int dw,dh; FitDims(w, h, kStillMax, &dw, &dh);
AppendDownscaledFrame(px, w, h, dw, dh, out.frames);
out.delaysSec.push_back(0.0f); out.w = dw; out.h = dh;
free(px);
return true;
}
WebPAnimInfo info;
if (!WebPAnimDecoderGetInfo(dec, &info) || info.canvas_width <= 0 || info.canvas_height <= 0) {
WebPAnimDecoderDelete(dec); return false;
}
const int animated = (info.frame_count > 1);
int dw, dh; FitDims(info.canvas_width, info.canvas_height, animated ? kAnimMax : kStillMax, &dw, &dh);
int prevTs = 0, produced = 0;
while (WebPAnimDecoderHasMoreFrames(dec) && produced < maxFrames) {
uint8_t* buf = nullptr; int ts = 0;
if (!WebPAnimDecoderGetNext(dec, &buf, &ts)) break; // buf valid until next call/delete
AppendDownscaledFrame(buf, info.canvas_width, info.canvas_height, dw, dh, out.frames);
float d = (ts - prevTs) / 1000.0f; prevTs = ts;
out.delaysSec.push_back(std::max(0.02f, d));
++produced;
}
WebPAnimDecoderDelete(dec);
if (out.frames.empty()) return false;
out.w = dw; out.h = dh;
return true;
}
// ---- Animated (or still) GIF via stb ----
const bool isGif = len >= 6 && memcmp(data, "GIF8", 4) == 0;
if (isGif) {
int* delays = nullptr; int w=0,h=0,z=0,comp=0;
unsigned char* all = stbi_load_gif_from_memory(data, (int)len, &delays, &w, &h, &z, &comp, 4);
if (all && w > 0 && h > 0 && z > 0) {
const int animated = (z > 1);
int dw, dh; FitDims(w, h, animated ? kAnimMax : kStillMax, &dw, &dh);
int frames = std::min(z, maxFrames);
for (int i = 0; i < frames; i++) {
AppendDownscaledFrame(all + (size_t)i * w * h * 4, w, h, dw, dh, out.frames);
float d = (delays && delays[i] > 0) ? delays[i] / 1000.0f : 0.1f;
out.delaysSec.push_back(std::max(0.02f, d));
}
out.w = dw; out.h = dh;
stbi_image_free(all);
if (delays) stbi_image_free(delays);
return true;
}
if (all) stbi_image_free(all);
if (delays) stbi_image_free(delays);
// fall through to a plain decode if the GIF path failed
}
// ---- Everything else: a single still frame (PNG/JPEG/BMP/TGA/... and APNG's default image) ----
{
int w=0,h=0; unsigned char* px = DecodeImageRGBA(data, len, &w, &h);
if (!px) return false;
int dw,dh; FitDims(w, h, kStillMax, &dw, &dh);
AppendDownscaledFrame(px, w, h, dw, dh, out.frames);
out.delaysSec.push_back(0.0f);
out.w = dw; out.h = dh;
free(px);
return true;
}
}
// Lightweight animation probe (no full decode): animated WebP via WebPGetFeatures.has_animation, or a
// GIF with >1 image-descriptor block. Walks the GIF block structure only until it finds a 2nd frame.
static bool IsAnimatedData(const unsigned char* data, size_t len)
{
if (IsWebP(data, len)) {
WebPBitstreamFeatures f;
if (WebPGetFeatures(data, len, &f) == VP8_STATUS_OK) return f.has_animation != 0;
return false;
}
if (len >= 13 && memcmp(data, "GIF8", 4) == 0) {
size_t p = 6; // after "GIF8?a"
unsigned char packed = data[p + 4]; // Logical Screen Descriptor packed byte
p += 7;
if (packed & 0x80) p += (size_t)(2 << (packed & 7)) * 3; // skip global color table
int frames = 0;
while (p < len) {
unsigned char b = data[p++];
if (b == 0x2C) { // image descriptor = one frame
if (++frames > 1) return true;
if (p + 9 > len) break;
unsigned char ip = data[p + 8];
p += 9;
if (ip & 0x80) p += (size_t)(2 << (ip & 7)) * 3; // skip local color table
if (p >= len) break;
++p; // LZW min code size
while (p < len) { unsigned char s = data[p++]; if (!s) break; p += s; } // image sub-blocks
} else if (b == 0x21) { // extension
if (p >= len) break;
++p; // label
while (p < len) { unsigned char s = data[p++]; if (!s) break; p += s; } // sub-blocks
} else { // trailer (0x3B) or unexpected
break;
}
}
return frames > 1;
}
return false;
}
bool IsAnimatedImageFile(const char* path)
{
std::vector<unsigned char> file;
if (!ReadFileToBuffer(path, file)) return false;
return IsAnimatedData(file.data(), file.size());
}
void DestroyTexture(ImTextureID texID)
{
if (!texID) return;