diff --git a/src/ui/windows/contacts_tab.cpp b/src/ui/windows/contacts_tab.cpp index 03f0acb..284d987 100644 --- a/src/ui/windows/contacts_tab.cpp +++ b/src/ui/windows/contacts_tab.cpp @@ -13,6 +13,7 @@ #include "../material/draw_helpers.h" #include "../material/type.h" #include "../material/project_icons.h" +#include "../../util/image_decode_core.h" #include "../../util/texture_loader.h" #include "../../util/platform.h" #include "image_picker.h" @@ -21,11 +22,14 @@ #include #include #include +#include #include +#include #include #include #include #include +#include #include namespace dragonx { @@ -71,44 +75,87 @@ static bool matchesSearch(const data::AddressBookEntry& e, const std::string& ne } // Lazily-loaded, path-keyed cache of custom avatar image textures. Each entry is a frame SEQUENCE -// (1 frame for stills, N for animated GIF/WebP), box-downscaled by texture_loader to bound VRAM. -// Decoding is budgeted per frame so opening a large library/contact list doesn't stall the UI — callers -// show a placeholder and the image fills in over the next frames. +// (1 frame for stills, N for animated GIF/WebP), box-downscaled to bound VRAM. Read + decode + downscale +// run on a background worker (util::StartImageDecode, at most 2 at once); the UI thread only uploads the +// finished frames under a per-frame byte budget, so a large contact list or image library never stalls +// the UI — callers show the Z/T badge until the image is ready. Above kAvatarCacheBudgetBytes of GPU +// memory the least-recently-drawn entries are evicted (never one drawn on-screen last frame). struct AvatarTex { - std::vector frames; // 1 = still, N = animated + std::vector frames; // uploaded so far: 1 = still, N = animated std::vector delays; // seconds per frame (parallel to frames) float totalDur = 0.0f; int w = 0, h = 0; + util::ImageDecodeJobPtr job; // decode running on a worker (null once collected) + util::AnimFrames pending; // decoded frames waiting for GPU upload + size_t uploaded = 0; // frames of `pending` processed so far + bool complete = false; // every frame uploaded (an animation only plays once complete) + bool failed = false; // decode failed: badge, no retry until invalidated (as before) + uint64_t lastUse = 0; // ImGui frame this avatar was last drawn ON-SCREEN (LRU) }; static std::unordered_map s_avatarTexCache; -static int s_avatarLoadsThisFrame = 0; +static util::ImageDecodeLane s_avatarDecodeLane(2); // at most 2 avatar decodes at once +static size_t s_avatarUploadBytesThisFrame = 0; +static uint64_t s_avatarPrevFrame = 0; // ImGui frame of the previous RenderContactsTab (eviction guard) static bool s_animateAvatars = true; // mirrors the setting; refreshed each frame in RenderContactsTab static int s_contactsShape = 0; // avatar shape (mirrors getContactsAvatarShape: 0/1/2) static float s_contactsScale = 1.0f; // card/list row scale (mirrors getContactsListScale) static bool s_avatarAnimatedThisFrame = false; // set when a live animated frame is drawn -> keep redrawing -static constexpr int kAvatarLoadsPerFrame = 2; +static constexpr size_t kAvatarUploadBytesPerFrame = 4u * 1024u * 1024u; // GPU upload budget per UI frame static constexpr int kAvatarMaxFrames = 300; // cap frames per animated avatar (bounds VRAM/decode) +static constexpr size_t kAvatarCacheBudgetBytes = 128u * 1024u * 1024u; // GPU bytes before LRU eviction -static const AvatarTex* getAvatarTexture(const std::string& path) { - auto it = s_avatarTexCache.find(path); - if (it != s_avatarTexCache.end()) return it->second.frames.empty() ? nullptr : &it->second; - if (s_avatarLoadsThisFrame >= kAvatarLoadsPerFrame) return nullptr; // defer to a later frame - s_avatarLoadsThisFrame++; - AvatarTex at; - util::AnimFrames af; - if (util::LoadAnimatedRGBA(path.c_str(), kAvatarMaxFrames, af) && !af.frames.empty()) { - at.w = af.w; at.h = 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)) { - at.frames.push_back(t); - at.delays.push_back(i < af.delaysSec.size() ? af.delaysSec[i] : 0.0f); - } +// Collect a finished decode and upload its frames within the per-frame byte budget. Never blocks. Keeps +// the idle render loop polling (RequestProgressFrame, ~10 fps) while a decode or an upload is outstanding. +static void advanceAvatarLoad(AvatarTex& at) { + if (at.job) { + if (!at.job->finished()) { material::RequestProgressFrame(); return; } + if (at.job->ok() && !at.job->result.frames.empty()) { + at.pending = std::move(at.job->result); + at.w = at.pending.w; at.h = at.pending.h; + } else { + at.failed = true; } - for (float d : at.delays) at.totalDur += d; + at.job.reset(); } - auto& slot = (s_avatarTexCache[path] = std::move(at)); - return slot.frames.empty() ? nullptr : &slot; + if (at.failed || at.complete) return; + while (at.uploaded < at.pending.frames.size() && s_avatarUploadBytesThisFrame < kAvatarUploadBytesPerFrame) { + const std::vector& px = at.pending.frames[at.uploaded]; + ImTextureID t = 0; + if (util::CreateRawTexture(px.data(), at.w, at.h, false, &t)) { + at.frames.push_back(t); + at.delays.push_back(at.uploaded < at.pending.delaysSec.size() ? at.pending.delaysSec[at.uploaded] : 0.0f); + } + s_avatarUploadBytesThisFrame += px.size(); + ++at.uploaded; + } + if (at.uploaded >= at.pending.frames.size()) { + for (float d : at.delays) at.totalDur += d; + at.pending = util::AnimFrames{}; // release the CPU copies + at.complete = true; + } else { + material::RequestProgressFrame(); // more frames to upload on the next frame + } +} + +// `visible` is false for an avatar scrolled out of its list/table viewport: a cached or in-flight image is +// still advanced and returned, but nothing new is decoded for an avatar nobody can see, and an off-screen +// draw does not count as a use for the LRU. +static const AvatarTex* getAvatarTexture(const std::string& path, bool visible = true) { + auto it = s_avatarTexCache.find(path); + if (it == s_avatarTexCache.end()) { + if (!visible) return nullptr; + // ImageLimits defaults = the old sizes: still 256 px, animation 128 px. + util::ImageDecodeJobPtr job = + util::StartImageDecode(s_avatarDecodeLane, path, kAvatarMaxFrames, util::ImageLimits{}); + if (!job) { material::RequestProgressFrame(); return nullptr; } // both slots busy: badge now, retry + AvatarTex fresh; + fresh.job = std::move(job); + it = s_avatarTexCache.emplace(path, std::move(fresh)).first; + } + AvatarTex& at = it->second; + if (visible) at.lastUse = static_cast(ImGui::GetFrameCount()); + advanceAvatarLoad(at); + return at.frames.empty() ? nullptr : &at; } // The texture to draw for `t` right now: a still's single frame, or — when the animate-avatars setting @@ -118,7 +165,7 @@ static const AvatarTex* getAvatarTexture(const std::string& path) { // culled animated avatar can't pin the app awake. static ImTextureID currentAvatarFrame(const AvatarTex* t, bool onScreen = true) { if (!t || t->frames.empty()) return 0; - if (onScreen && t->frames.size() > 1 && s_animateAvatars && t->totalDur > 0.0f) { + if (onScreen && t->complete && t->frames.size() > 1 && s_animateAvatars && t->totalDur > 0.0f) { s_avatarAnimatedThisFrame = true; double phase = std::fmod(ImGui::GetTime(), (double)t->totalDur); double acc = 0.0; @@ -130,14 +177,37 @@ static ImTextureID currentAvatarFrame(const AvatarTex* t, bool onScreen = true) return t->frames[0]; } -// Drop a cached avatar's textures (all frames) so the next getAvatarTexture reloads from disk. +// Drop a cached avatar's textures (all frames) so the next getAvatarTexture reloads from disk. A decode +// still running is orphaned: its worker stops at the next frame boundary and frees its own result. static void invalidateAvatarTexture(const std::string& path) { auto it = s_avatarTexCache.find(path); if (it == s_avatarTexCache.end()) return; + if (it->second.job) it->second.job->cancel.store(true, std::memory_order_relaxed); for (ImTextureID t : it->second.frames) if (t) util::DestroyTexture(t); s_avatarTexCache.erase(it); } +// LRU eviction, run at the TOP of RenderContactsTab (nothing drawn yet this frame, so no evicted texture is +// referenced by this frame's draw lists). Avatars drawn on-screen by the previous Contacts frame have +// lastUse >= protectFromFrame and are never evicted; evicted ones re-decode when they scroll back in. +static void evictAvatarCache(uint64_t protectFromFrame) { + size_t total = 0; + for (const auto& kv : s_avatarTexCache) + total += (size_t)kv.second.w * (size_t)kv.second.h * 4u * kv.second.frames.size(); + if (total <= kAvatarCacheBudgetBytes) return; + std::vector items; + std::vector keys; + items.reserve(s_avatarTexCache.size()); + keys.reserve(s_avatarTexCache.size()); + for (const auto& kv : s_avatarTexCache) { + const AvatarTex& a = kv.second; + items.push_back({keys.size(), (size_t)a.w * (size_t)a.h * 4u * a.frames.size(), a.lastUse}); + keys.push_back(kv.first); + } + for (size_t idx : util::selectEvictions(std::move(items), kAvatarCacheBudgetBytes, protectFromFrame)) + invalidateAvatarTexture(keys[idx]); +} + // Draw a contact's avatar: a custom image, a Material icon in a tinted shape, or the Z/T type badge. // `shape` picks the outline: 0 = circle (centered at `c`, radius `r`); 1 = rounded square (same box as // the circle, all corners rounded); 2 = a full-row-height tab spanning [tabMin, tabMax] with rounded @@ -169,7 +239,7 @@ static void drawContactAvatar(ImDrawList* dl, ImVec2 c, float r, const data::Add }; const std::string& av = e.avatar; if (av.rfind("img:", 0) == 0) { - const AvatarTex* t = getAvatarTexture(av.substr(4)); + const AvatarTex* t = getAvatarTexture(av.substr(4), onScreen); ImTextureID tex = currentAvatarFrame(t, onScreen); if (tex) { const float aspect = (sMax.x - sMin.x) / std::max(1.0f, sMax.y - sMin.y); // crop to the shape's aspect @@ -237,15 +307,20 @@ static ImU32 contactTypeColor(bool shielded, bool light) { return ImGui::ColorConvertFloat4ToU32(c); } -// Copy a chosen avatar image into /contact-avatars/, returning the destination absolute path +// Copy a chosen avatar image into /contact-avatars/, returning the destination absolute path // (empty on failure). Named by the source stem + an 8-hex FNV hash of the full source path, so // re-picking the same file is idempotent and two different files never clobber each other. -static std::string copyAvatarImage(const std::string& srcPath) { +// Runs on a decode worker: pure std::filesystem, no UI state, config dir passed in (resolved on the UI +// thread). Copies to a per-job ".part." and renames it over the final name, so a crash mid-copy never +// leaves a truncated avatar (".part." is not an image name; the library scan ignores it) and two imports of +// the same source at once never share (and steal) one temp file. +static std::string copyAvatarImage(const std::string& srcPath, const std::string& configDir) { + static std::atomic s_partSeq{0}; namespace fs = std::filesystem; std::error_code ec; fs::path src(srcPath); if (!fs::is_regular_file(src, ec)) return ""; - fs::path dir = fs::path(util::Platform::getConfigDir()) / "contact-avatars"; + fs::path dir = fs::path(configDir) / "contact-avatars"; fs::create_directories(dir, ec); uint64_t h = 1469598103934665603ull; // FNV-1a for (unsigned char c : srcPath) { h ^= c; h *= 1099511628211ull; } @@ -254,8 +329,22 @@ static std::string copyAvatarImage(const std::string& srcPath) { std::string stem = src.stem().string(); if (stem.size() > 40) stem.resize(40); fs::path dst = dir / (stem + "-" + suffix + src.extension().string()); - fs::copy_file(src, dst, fs::copy_options::overwrite_existing, ec); - return ec ? std::string() : dst.string(); + fs::path tmp = dst; + tmp += ".part." + std::to_string(s_partSeq.fetch_add(1, std::memory_order_relaxed)); + fs::copy_file(src, tmp, fs::copy_options::overwrite_existing, ec); + if (!ec) { + // Atomic replace (POSIX rename / MoveFileExW REPLACE_EXISTING): a failure is a transient sharing/access + // error (antivirus, indexer, a handle a spawned child inherited). Retry briefly (we are on a worker) and + // NEVER delete dst: the existing library image, and every contact using it, survives a failed import. + int delayMs = 20; + for (int attempt = 0; attempt < 5; ++attempt) { + fs::rename(tmp, dst, ec); + if (!ec) break; + if (attempt < 4) { std::this_thread::sleep_for(std::chrono::milliseconds(delayMs)); delayMs *= 2; } + } + } + if (ec) { std::error_code rec; fs::remove(tmp, rec); return std::string(); } + return dst.string(); } // Avatar image LIBRARY: the images the user has added live in /contact-avatars/ and persist as @@ -305,6 +394,54 @@ static void deleteAvatarLibraryFile(const std::string& path) { s_avatarLibraryDirty = true; } +// Avatar imports ("Add image" in the edit dialog). A worker validates the source (header probe + limits + +// first-frame decode: the same "does it decode" gate as before) and copies it into the library; the UI +// thread applies the result in pumpAvatarImports() at the top of the next Contacts frame. A result only +// lands in the dialog session that started it (s_editSession), so a late import can never set another +// contact's avatar, and Save/Add waits until this session's import has been applied. +struct AvatarImport { util::ImageDecodeJobPtr job; uint64_t session = 0; }; +static std::vector s_avatarImports; +static util::ImageDecodeLane s_avatarImportLane(8); +static uint64_t s_editSession = 0; // bumped whenever the add/edit dialog is (re)opened + +static void startAvatarImport(const std::string& src) { + const std::string cfg = util::Platform::getConfigDir(); // resolved here, on the UI thread + util::ImageDecodeJobPtr job = util::StartImageDecode( + s_avatarImportLane, src, /*maxFrames=*/1, util::ImageLimits{}, + [cfg](const std::string& p) { return copyAvatarImage(p, cfg); }); // worker; "" => PostFailed + if (!job) { Notifications::instance().error(TR("contact_avatar_copy_failed")); return; } + s_avatarImports.push_back({std::move(job), s_editSession}); + material::RequestProgressFrame(); +} + +// True while an import started by the CURRENT dialog session has not been applied yet (finished or not: +// a job can finish after this frame's pump, and Save must not run before its result lands). +static bool avatarImportPending() { + for (const AvatarImport& imp : s_avatarImports) + if (imp.session == s_editSession) return true; + return false; +} + +static void pumpAvatarImports() { + for (size_t i = 0; i < s_avatarImports.size();) { + if (!s_avatarImports[i].job->finished()) { material::RequestProgressFrame(); ++i; continue; } + const AvatarImport imp = s_avatarImports[i]; + s_avatarImports.erase(s_avatarImports.begin() + (std::ptrdiff_t)i); + const util::ImageDecodeJob& job = *imp.job; + if (job.ok() && !job.outPath.empty()) { + invalidateAvatarTexture(job.outPath); // re-picked file may have replaced an old copy + s_avatarLibraryDirty = true; // surface the new image in the grid + if ((s_show_edit_dialog || s_show_add_dialog) && imp.session == s_editSession) { + s_edit_avatar = "img:" + job.outPath; s_edit_avatar_mode = 2; + } + } else if (job.status == util::ImageStatus::PostFailed) { + Notifications::instance().error(TR("contact_avatar_copy_failed")); + } else { + Notifications::instance().error(TR("contact_avatar_bad_image")); + } + } +} + // UI-loop accessor: true if an animated avatar frame was drawn since the last call (clear-on-read), so // the main render loop keeps producing frames while an animation plays and idles once it stops / the // contacts view is hidden. Declared in contacts_tab.h. @@ -318,7 +455,12 @@ bool ConsumeContactsAvatarAnimation() { void RenderContactsTab(App* app) { - s_avatarLoadsThisFrame = 0; // reset the per-frame avatar-decode budget (see getAvatarTexture) + // Avatar upkeep before anything is drawn this frame: reset the GPU upload budget, apply finished + // imports, and LRU-evict while protecting what the previous Contacts frame drew on-screen. + s_avatarUploadBytesThisFrame = 0; + pumpAvatarImports(); + evictAvatarCache(s_avatarPrevFrame); + s_avatarPrevFrame = static_cast(ImGui::GetFrameCount()); s_animateAvatars = !app->settings() || app->settings()->getAnimateAvatars(); s_contactsShape = app->settings() ? app->settings()->getContactsAvatarShape() : 0; s_contactsScale = app->settings() ? app->settings()->getContactsListScale() : 1.0f; @@ -351,6 +493,7 @@ void RenderContactsTab(App* app) s_edit_avatar_mode = 0; s_edit_icon_search[0] = '\0'; s_avatarLibraryDirty = true; // rescan the image library on open (files may have changed) + ++s_editSession; // a pending avatar import from an earlier dialog must not land here }; auto loadEditFields = [](const data::AddressBookEntry& entry) { @@ -363,6 +506,7 @@ void RenderContactsTab(App* app) : (entry.avatar.rfind("img:", 0) == 0) ? 2 : 0; s_edit_icon_search[0] = '\0'; s_avatarLibraryDirty = true; // rescan the image library on open (files may have changed) + ++s_editSession; // a pending avatar import from an earlier dialog must not land here }; bool has_selection = s_selected_index >= 0 && s_selected_index < static_cast(book.size()); @@ -727,17 +871,9 @@ void RenderContactsTab(App* app) ImGui::PushID(0); if (ImGui::InvisibleButton("##avadd", ImVec2(cell, cell))) { ImagePicker::open("", [](const std::string& src) { - // Verify the source decodes before committing (guards corrupt/unsupported files). - int iw = 0, ih = 0; - unsigned char* px = util::LoadRawPixelsFromFile(src.c_str(), &iw, &ih); - if (!px) { Notifications::instance().error(TR("contact_avatar_bad_image")); return; } - util::FreeRawPixels(px); - std::string dst = copyAvatarImage(src); - if (!dst.empty()) { - invalidateAvatarTexture(dst); - s_edit_avatar = "img:" + dst; s_edit_avatar_mode = 2; - s_avatarLibraryDirty = true; // surface the new image in the grid - } else Notifications::instance().error(TR("contact_avatar_copy_failed")); + // Validate (decode) + copy on a worker; pumpAvatarImports() applies the + // result (or the same error toasts) at the top of a later frame. + startAvatarImport(src); }); } if (ImGui::IsItemHovered()) { ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); material::Tooltip("%s", TR("contact_avatar_choose")); } @@ -812,7 +948,10 @@ void RenderContactsTab(App* app) ImGui::EndChild(); // ##contactAvatarCol ImGui::Dummy(ImVec2(0, Layout::spacingSm())); - bool canSubmit = std::strlen(s_edit_label) > 0 && std::strlen(s_edit_address) > 0; + // An image picked a moment ago is still being validated/copied: wait for it (typically < 0.5 s) + // so the contact is never saved without the avatar the user just chose. + bool canSubmit = std::strlen(s_edit_label) > 0 && std::strlen(s_edit_address) > 0 + && !avatarImportPending(); float actionW = std::max(110.0f * dp, btnFont->CalcTextSizeA(btnFont->LegacySize, FLT_MAX, 0, TR("save")).x + ImGui::GetStyle().FramePadding.x * 2.0f + 28.0f * dp); @@ -1481,6 +1620,7 @@ void ContactsSweepOpenEditDialog(int avatarMode) s_edit_avatar_mode = (avatarMode < 0 || avatarMode > 2) ? 0 : avatarMode; s_edit_avatar = (s_edit_avatar_mode == 1) ? "icon:account_balance" : ""; s_edit_icon_search[0] = '\0'; + ++s_editSession; s_show_add_dialog = false; s_show_edit_dialog = true; s_focus_edit_field = false;