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