From a7c50ff19bd0e576bbe2c73347a84c63b0783a41 Mon Sep 17 00:00:00 2001 From: DanS Date: Wed, 15 Jul 2026 11:50:27 -0500 Subject: [PATCH] perf(image-picker): decode hover animations off-thread (no UI hang) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/ui/windows/image_picker.h | 88 +++++++++++++++++++++++++++-------- 1 file changed, 68 insertions(+), 20 deletions(-) diff --git a/src/ui/windows/image_picker.h b/src/ui/windows/image_picker.h index f635df5..503b815 100644 --- a/src/ui/windows/image_picker.h +++ b/src/ui/windows/image_picker.h @@ -13,12 +13,15 @@ #pragma once #include +#include #include #include #include #include #include +#include #include +#include #include #include @@ -232,11 +235,12 @@ public: const Thumb* t = ImGui::IsRectVisible(mn, mx) ? thumbFor(abs) : nullptr; ImTextureID tex = t ? t->tex : 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. - if (t && hov && isAnimatableName(s_images[i])) { + // Hovering an animated image loads its frames in the BACKGROUND (no UI hang) and plays + // 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 - loadThumbAnim(mt, abs); - if (mt.frames.size() > 1 && mt.totalDur > 0.0f) { + driveThumbAnim(mt, abs); + if (mt.animReady && mt.frames.size() > 1 && mt.totalDur > 0.0f) { s_animatingThisFrame = true; double phase = std::fmod(ImGui::GetTime(), (double)mt.totalDur); double acc = 0.0; @@ -327,28 +331,70 @@ public: static bool consumeAnimationActive() { bool v = s_animatingThisFrame; s_animatingThisFrame = false; return v; } 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 done{false}; + util::AnimFrames result; + }; + struct Thumb { ImTextureID tex = 0; int w = 0, h = 0; bool tried = false; // static frame-0 thumbnail bool animated = false; // multi-frame GIF/WebP (badge) - std::vector frames; // full sequence (loaded on hover) - std::vector delays; int aw = 0, ah = 0; float totalDur = 0.0f; bool animTried = false; + // Async animation load (kicked off on hover): the full frame decode runs on a worker thread, + // 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 job; + util::AnimFrames pending; // decoded frames awaiting GPU upload + size_t uploaded = 0; + std::vector frames; + std::vector 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). - static void loadThumbAnim(Thumb& th, const std::string& path) { - if (th.animTried) return; - th.animTried = true; - util::AnimFrames af; - if (util::LoadAnimatedRGBA(path.c_str(), kThumbMaxFrames, af) && af.frames.size() > 1) { - th.aw = af.w; th.ah = af.h; - for (size_t i = 0; i < af.frames.size(); ++i) { - ImTextureID t = 0; - if (util::CreateRawTexture(af.frames[i].data(), af.w, af.h, false, &t)) { - th.frames.push_back(t); - th.delays.push_back(i < af.delaysSec.size() ? af.delaysSec[i] : 0.1f); - } + // Drive the async animation load for a hovered thumbnail: (1) kick off a background decode once, + // (2) move its result to a staging buffer when done, (3) upload a few frames per UI frame. Never + // blocks — the static thumbnail keeps showing until the sequence is fully uploaded. + static void driveThumbAnim(Thumb& th, const std::string& path) { + if (th.animReady) return; + if (!th.animRequested) { + if (s_animInFlight.load() >= kMaxAnimInFlight) return; // cap workers; retry a later frame + th.animRequested = true; + th.job = std::make_shared(); + s_animInFlight.fetch_add(1); + std::shared_ptr job = th.job; + 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 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 kMaxAnimInFlight = 2; // max concurrent background decodes + static inline std::atomic s_animInFlight{0}; static inline bool s_open = false; static inline bool s_animatingThisFrame = false; // a hover-preview animation is playing static inline int s_loadedThisFrame = 0;