perf(explore): reduce tag cloud refresh pressure
github/actions/ci GitHub Actions CI finished: success
github/actions/ci GitHub Actions CI finished: success
Debounce Explore tag refreshes while AI tagging is active so the tag cloud catches up after worker activity settles instead of continuously re-querying. Optimize the tag cloud aggregate query by fetching representative thumbnails in one pass and adding a supporting tag/image index.
This commit is contained in:
@@ -63,6 +63,9 @@ aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
|
||||
so the info panel shows more at a glance with less scrolling.
|
||||
- **Faster Explore revisits** — returning to a folder's visual clusters should
|
||||
feel much faster now, even in big libraries.
|
||||
- **Calmer Tag Cloud during AI tagging** — Explore no longer keeps hammering the
|
||||
tag list while a folder is actively being tagged, so tagging stays smoother and
|
||||
the cloud catches up once the work settles.
|
||||
- **Faster first-time clustering** — large libraries build their first visual
|
||||
clusters much more quickly, while still keeping the groups nicely balanced.
|
||||
- **Better tag browsing** — the Tag manager now has live search, sorting
|
||||
|
||||
+19
-8
@@ -303,6 +303,7 @@ pub fn migrate(conn: &Connection) -> Result<()> {
|
||||
CREATE INDEX IF NOT EXISTS idx_image_tags_image_id ON image_tags(image_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_image_tags_source ON image_tags(source);
|
||||
CREATE INDEX IF NOT EXISTS idx_image_tags_tag ON image_tags(tag);
|
||||
CREATE INDEX IF NOT EXISTS idx_image_tags_tag_image_id ON image_tags(tag, image_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS albums (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -2446,7 +2447,10 @@ pub fn get_explore_tags(
|
||||
limit: usize,
|
||||
) -> Result<Vec<ExploreTagEntry>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT t.tag, COUNT(DISTINCT t.image_id) AS tag_count, MIN(t.image_id) AS representative_image_id,
|
||||
"WITH tag_counts AS (
|
||||
SELECT t.tag,
|
||||
COUNT(DISTINCT t.image_id) AS tag_count,
|
||||
MIN(t.image_id) AS representative_image_id,
|
||||
MAX(CASE WHEN t.source = 'ai' THEN 1 ELSE 0 END) AS has_ai_source,
|
||||
MAX(CASE WHEN t.source = 'user' THEN 1 ELSE 0 END) AS has_user_source
|
||||
FROM image_tags t
|
||||
@@ -2455,23 +2459,30 @@ pub fn get_explore_tags(
|
||||
GROUP BY t.tag
|
||||
HAVING COUNT(DISTINCT t.image_id) >= 1
|
||||
ORDER BY tag_count DESC, t.tag ASC
|
||||
LIMIT ?2",
|
||||
LIMIT ?2
|
||||
)
|
||||
SELECT c.tag,
|
||||
c.tag_count,
|
||||
c.representative_image_id,
|
||||
i.thumbnail_path,
|
||||
c.has_ai_source,
|
||||
c.has_user_source
|
||||
FROM tag_counts c
|
||||
JOIN images i ON i.id = c.representative_image_id
|
||||
ORDER BY c.tag_count DESC, c.tag ASC",
|
||||
)?;
|
||||
|
||||
let rows = stmt
|
||||
.query_map(params![folder_id, limit as i64], |row| {
|
||||
let representative_image_id = row.get::<_, i64>(2)?;
|
||||
let thumbnail_path = get_image_by_id(conn, representative_image_id)
|
||||
.ok()
|
||||
.and_then(|image| image.thumbnail_path);
|
||||
|
||||
Ok(ExploreTagEntry {
|
||||
tag: row.get(0)?,
|
||||
count: row.get(1)?,
|
||||
representative_image_id,
|
||||
thumbnail_path,
|
||||
has_ai_source: row.get::<_, i64>(3)? != 0,
|
||||
has_user_source: row.get::<_, i64>(4)? != 0,
|
||||
thumbnail_path: row.get(3)?,
|
||||
has_ai_source: row.get::<_, i64>(4)? != 0,
|
||||
has_user_source: row.get::<_, i64>(5)? != 0,
|
||||
})
|
||||
})?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
|
||||
+54
-9
@@ -651,6 +651,34 @@ let galleryRequestToken = 0;
|
||||
let visualClusterRequestToken = 0;
|
||||
let exploreTagRequestToken = 0;
|
||||
let exploreTagRefreshTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const EXPLORE_TAG_REFRESH_IDLE_MS = 900;
|
||||
const EXPLORE_TAG_REFRESH_WHILE_TAGGING_MS = 4500;
|
||||
|
||||
function scopeHasTaggingPending(
|
||||
progressByFolder: Record<number, FolderJobProgress>,
|
||||
folderId: number | null,
|
||||
): boolean {
|
||||
if (folderId === null) {
|
||||
return Object.values(progressByFolder).some((progress) => progress.tagging_pending > 0);
|
||||
}
|
||||
return (progressByFolder[folderId]?.tagging_pending ?? 0) > 0;
|
||||
}
|
||||
|
||||
function taggingProgressAffectsScope(progressFolderId: number, scopeFolderId: number | null): boolean {
|
||||
return scopeFolderId === null || scopeFolderId === progressFolderId;
|
||||
}
|
||||
|
||||
function imagesAffectScope(images: ImageRecord[], scopeFolderId: number | null): boolean {
|
||||
return scopeFolderId === null || images.some((image) => image.folder_id === scopeFolderId);
|
||||
}
|
||||
|
||||
function scheduleExploreTagRefresh(load: () => void, delayMs: number) {
|
||||
if (exploreTagRefreshTimer) clearTimeout(exploreTagRefreshTimer);
|
||||
exploreTagRefreshTimer = setTimeout(() => {
|
||||
exploreTagRefreshTimer = null;
|
||||
load();
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
function initialAiCaptionsEnabled(): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
@@ -1498,6 +1526,9 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
||||
loadExploreTags: async (options) => {
|
||||
const { selectedFolderId, exploreTagsFolderId, exploreTagLoading } = get();
|
||||
const force = options?.force ?? false;
|
||||
if (!force && exploreTagLoading) {
|
||||
return;
|
||||
}
|
||||
if (!force && !exploreTagLoading && exploreTagsFolderId !== undefined && exploreTagsFolderId === selectedFolderId) {
|
||||
return;
|
||||
}
|
||||
@@ -2984,9 +3015,18 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
||||
notificationTimers.set(taggingKey, setTimeout(() => {
|
||||
notificationTimers.delete(taggingKey);
|
||||
void notifyTaskComplete("AI tagging complete", body);
|
||||
// New tags landed — invalidate Explore tag cache.
|
||||
set({ exploreTagsFolderId: undefined });
|
||||
}, NOTIFICATION_DEBOUNCE_MS));
|
||||
const state = get();
|
||||
if (taggingProgressAffectsScope(progress.folder_id, state.selectedFolderId)) {
|
||||
// New tags landed — refresh the Explore tag cloud after the worker
|
||||
// settles instead of waiting for the user to revisit Explore.
|
||||
set({ exploreTagsFolderId: undefined });
|
||||
if (state.activeView === "explore" && state.exploreMode === "tags") {
|
||||
scheduleExploreTagRefresh(() => {
|
||||
void get().loadExploreTags({ force: true });
|
||||
}, EXPLORE_TAG_REFRESH_IDLE_MS);
|
||||
}
|
||||
}
|
||||
} else if (previous.tagging_pending === 0 && progress.tagging_pending > 0) {
|
||||
clearTimeout(notificationTimers.get(taggingKey));
|
||||
notificationTimers.delete(taggingKey);
|
||||
@@ -3067,15 +3107,16 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
||||
const unlistenThumbnails = await listen<ThumbnailBatch>("media-updated", (event) => {
|
||||
const batch = event.payload;
|
||||
const taggingUpdated = batch.images.some((image) => image.ai_tagged_at !== null || image.ai_tagger_error !== null);
|
||||
if (taggingUpdated) {
|
||||
const state = get();
|
||||
if (taggingUpdated && imagesAffectScope(batch.images, state.selectedFolderId)) {
|
||||
set({ exploreTagsFolderId: undefined });
|
||||
if (get().activeView === "explore") {
|
||||
if (!exploreTagRefreshTimer) {
|
||||
exploreTagRefreshTimer = setTimeout(() => {
|
||||
exploreTagRefreshTimer = null;
|
||||
if (state.activeView === "explore" && state.exploreMode === "tags") {
|
||||
const delay = scopeHasTaggingPending(state.mediaJobProgress, state.selectedFolderId)
|
||||
? EXPLORE_TAG_REFRESH_WHILE_TAGGING_MS
|
||||
: EXPLORE_TAG_REFRESH_IDLE_MS;
|
||||
scheduleExploreTagRefresh(() => {
|
||||
void get().loadExploreTags({ force: true });
|
||||
}, 700);
|
||||
}
|
||||
}, delay);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3179,6 +3220,10 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
||||
);
|
||||
|
||||
return () => {
|
||||
if (exploreTagRefreshTimer) {
|
||||
clearTimeout(exploreTagRefreshTimer);
|
||||
exploreTagRefreshTimer = null;
|
||||
}
|
||||
unlistenProgress();
|
||||
unlistenMediaJobs();
|
||||
unlistenCaptionModelProgress();
|
||||
|
||||
Reference in New Issue
Block a user