perf(image-picker): decode hover animations off-thread (no UI hang)
Hovering an animated GIF/WebP previously decoded ALL frames + uploaded every texture synchronously on the UI thread — a big/long animation froze the UI for that first hover (stb's GIF decode is monolithic). Now the decode runs on a detached background worker (bounded to 2 concurrent), and frames are uploaded to GPU textures a few per UI frame; the still thumbnail keeps showing until the sequence is ready, then it animates. A shared_ptr job keeps the worker's result alive if the thumbnail is destroyed mid-decode (e.g. navigating away), so nothing blocks. Only images the cheap badge-probe already flagged as animated ever spawn a worker. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -13,12 +13,15 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
|
#include <atomic>
|
||||||
#include <cctype>
|
#include <cctype>
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <filesystem>
|
#include <filesystem>
|
||||||
#include <functional>
|
#include <functional>
|
||||||
|
#include <memory>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <thread>
|
||||||
#include <unordered_map>
|
#include <unordered_map>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
@@ -232,11 +235,12 @@ public:
|
|||||||
const Thumb* t = ImGui::IsRectVisible(mn, mx) ? thumbFor(abs) : nullptr;
|
const Thumb* t = ImGui::IsRectVisible(mn, mx) ? thumbFor(abs) : nullptr;
|
||||||
ImTextureID tex = t ? t->tex : 0;
|
ImTextureID tex = t ? t->tex : 0;
|
||||||
int tw = t ? t->w : 0, thh = t ? t->h : 0;
|
int tw = t ? t->w : 0, thh = t ? t->h : 0;
|
||||||
// Hovering an animatable image (GIF/WebP) loads its full sequence (once) and plays it.
|
// Hovering an animated image loads its frames in the BACKGROUND (no UI hang) and plays
|
||||||
if (t && hov && isAnimatableName(s_images[i])) {
|
// them once ready; the still thumbnail shows meanwhile.
|
||||||
|
if (t && hov && t->animated) {
|
||||||
Thumb& mt = s_thumbs[abs]; // t is non-null => the entry exists
|
Thumb& mt = s_thumbs[abs]; // t is non-null => the entry exists
|
||||||
loadThumbAnim(mt, abs);
|
driveThumbAnim(mt, abs);
|
||||||
if (mt.frames.size() > 1 && mt.totalDur > 0.0f) {
|
if (mt.animReady && mt.frames.size() > 1 && mt.totalDur > 0.0f) {
|
||||||
s_animatingThisFrame = true;
|
s_animatingThisFrame = true;
|
||||||
double phase = std::fmod(ImGui::GetTime(), (double)mt.totalDur);
|
double phase = std::fmod(ImGui::GetTime(), (double)mt.totalDur);
|
||||||
double acc = 0.0;
|
double acc = 0.0;
|
||||||
@@ -327,28 +331,70 @@ public:
|
|||||||
static bool consumeAnimationActive() { bool v = s_animatingThisFrame; s_animatingThisFrame = false; return v; }
|
static bool consumeAnimationActive() { bool v = s_animatingThisFrame; s_animatingThisFrame = false; return v; }
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
// Background decode job: a detached worker fills `result` off the UI thread and sets `done`. The
|
||||||
|
// shared_ptr keeps it alive if the owning Thumb is destroyed mid-decode (no blocking on navigate).
|
||||||
|
struct AnimJob {
|
||||||
|
std::atomic<bool> done{false};
|
||||||
|
util::AnimFrames result;
|
||||||
|
};
|
||||||
|
|
||||||
struct Thumb {
|
struct Thumb {
|
||||||
ImTextureID tex = 0; int w = 0, h = 0; bool tried = false; // static frame-0 thumbnail
|
ImTextureID tex = 0; int w = 0, h = 0; bool tried = false; // static frame-0 thumbnail
|
||||||
bool animated = false; // multi-frame GIF/WebP (badge)
|
bool animated = false; // multi-frame GIF/WebP (badge)
|
||||||
std::vector<ImTextureID> frames; // full sequence (loaded on hover)
|
// Async animation load (kicked off on hover): the full frame decode runs on a worker thread,
|
||||||
std::vector<float> delays; int aw = 0, ah = 0; float totalDur = 0.0f; bool animTried = false;
|
// then frames are uploaded to textures a few per UI frame — so hovering never blocks the UI.
|
||||||
|
bool animRequested = false, staged = false, animReady = false;
|
||||||
|
std::shared_ptr<AnimJob> job;
|
||||||
|
util::AnimFrames pending; // decoded frames awaiting GPU upload
|
||||||
|
size_t uploaded = 0;
|
||||||
|
std::vector<ImTextureID> frames;
|
||||||
|
std::vector<float> delays; int aw = 0, ah = 0; float totalDur = 0.0f;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Load the full frame sequence for an animated image on first hover (still images stay single-frame).
|
// Drive the async animation load for a hovered thumbnail: (1) kick off a background decode once,
|
||||||
static void loadThumbAnim(Thumb& th, const std::string& path) {
|
// (2) move its result to a staging buffer when done, (3) upload a few frames per UI frame. Never
|
||||||
if (th.animTried) return;
|
// blocks — the static thumbnail keeps showing until the sequence is fully uploaded.
|
||||||
th.animTried = true;
|
static void driveThumbAnim(Thumb& th, const std::string& path) {
|
||||||
util::AnimFrames af;
|
if (th.animReady) return;
|
||||||
if (util::LoadAnimatedRGBA(path.c_str(), kThumbMaxFrames, af) && af.frames.size() > 1) {
|
if (!th.animRequested) {
|
||||||
th.aw = af.w; th.ah = af.h;
|
if (s_animInFlight.load() >= kMaxAnimInFlight) return; // cap workers; retry a later frame
|
||||||
for (size_t i = 0; i < af.frames.size(); ++i) {
|
th.animRequested = true;
|
||||||
ImTextureID t = 0;
|
th.job = std::make_shared<AnimJob>();
|
||||||
if (util::CreateRawTexture(af.frames[i].data(), af.w, af.h, false, &t)) {
|
s_animInFlight.fetch_add(1);
|
||||||
th.frames.push_back(t);
|
std::shared_ptr<AnimJob> job = th.job;
|
||||||
th.delays.push_back(i < af.delaysSec.size() ? af.delaysSec[i] : 0.1f);
|
std::string p = path;
|
||||||
}
|
std::thread([job, p]() {
|
||||||
|
util::LoadAnimatedRGBA(p.c_str(), kThumbMaxFrames, job->result);
|
||||||
|
job->done.store(true, std::memory_order_release);
|
||||||
|
s_animInFlight.fetch_sub(1);
|
||||||
|
}).detach();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (th.job && th.job->done.load(std::memory_order_acquire)) {
|
||||||
|
th.pending = std::move(th.job->result);
|
||||||
|
th.job.reset();
|
||||||
|
if (th.pending.frames.size() <= 1) { // turned out not to animate
|
||||||
|
th.animated = false; th.pending = util::AnimFrames{}; th.animReady = true; return;
|
||||||
|
}
|
||||||
|
th.staged = true;
|
||||||
|
}
|
||||||
|
if (th.staged) {
|
||||||
|
s_animatingThisFrame = true; // keep redrawing so the upload progresses
|
||||||
|
int budget = 4;
|
||||||
|
while (th.uploaded < th.pending.frames.size() && budget-- > 0) {
|
||||||
|
ImTextureID t = 0;
|
||||||
|
if (util::CreateRawTexture(th.pending.frames[th.uploaded].data(), th.pending.w, th.pending.h, false, &t)) {
|
||||||
|
th.frames.push_back(t);
|
||||||
|
th.delays.push_back(th.uploaded < th.pending.delaysSec.size() ? th.pending.delaysSec[th.uploaded] : 0.1f);
|
||||||
|
}
|
||||||
|
th.uploaded++;
|
||||||
|
}
|
||||||
|
if (th.uploaded >= th.pending.frames.size()) {
|
||||||
|
th.aw = th.pending.w; th.ah = th.pending.h;
|
||||||
|
for (float d : th.delays) th.totalDur += d;
|
||||||
|
th.pending = util::AnimFrames{};
|
||||||
|
th.animReady = true;
|
||||||
}
|
}
|
||||||
for (float d : th.delays) th.totalDur += d;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -462,7 +508,9 @@ private:
|
|||||||
static constexpr int kThumbPx = 128; // max thumbnail dimension (downscaled)
|
static constexpr int kThumbPx = 128; // max thumbnail dimension (downscaled)
|
||||||
static constexpr int kLoadsPerFrame = 6; // decode budget per frame (spreads a big folder over frames)
|
static constexpr int kLoadsPerFrame = 6; // decode budget per frame (spreads a big folder over frames)
|
||||||
static constexpr int kThumbMaxFrames = 300; // cap frames per hovered animation
|
static constexpr int kThumbMaxFrames = 300; // cap frames per hovered animation
|
||||||
|
static constexpr int kMaxAnimInFlight = 2; // max concurrent background decodes
|
||||||
|
|
||||||
|
static inline std::atomic<int> s_animInFlight{0};
|
||||||
static inline bool s_open = false;
|
static inline bool s_open = false;
|
||||||
static inline bool s_animatingThisFrame = false; // a hover-preview animation is playing
|
static inline bool s_animatingThisFrame = false; // a hover-preview animation is playing
|
||||||
static inline int s_loadedThisFrame = 0;
|
static inline int s_loadedThisFrame = 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user