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.
|
so the info panel shows more at a glance with less scrolling.
|
||||||
- **Faster Explore revisits** — returning to a folder's visual clusters should
|
- **Faster Explore revisits** — returning to a folder's visual clusters should
|
||||||
feel much faster now, even in big libraries.
|
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
|
- **Faster first-time clustering** — large libraries build their first visual
|
||||||
clusters much more quickly, while still keeping the groups nicely balanced.
|
clusters much more quickly, while still keeping the groups nicely balanced.
|
||||||
- **Better tag browsing** — the Tag manager now has live search, sorting
|
- **Better tag browsing** — the Tag manager now has live search, sorting
|
||||||
|
|||||||
+27
-16
@@ -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_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_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 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 (
|
CREATE TABLE IF NOT EXISTS albums (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
@@ -2446,32 +2447,42 @@ pub fn get_explore_tags(
|
|||||||
limit: usize,
|
limit: usize,
|
||||||
) -> Result<Vec<ExploreTagEntry>> {
|
) -> Result<Vec<ExploreTagEntry>> {
|
||||||
let mut stmt = conn.prepare(
|
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 (
|
||||||
MAX(CASE WHEN t.source = 'ai' THEN 1 ELSE 0 END) AS has_ai_source,
|
SELECT t.tag,
|
||||||
MAX(CASE WHEN t.source = 'user' THEN 1 ELSE 0 END) AS has_user_source
|
COUNT(DISTINCT t.image_id) AS tag_count,
|
||||||
FROM image_tags t
|
MIN(t.image_id) AS representative_image_id,
|
||||||
JOIN images i ON i.id = t.image_id
|
MAX(CASE WHEN t.source = 'ai' THEN 1 ELSE 0 END) AS has_ai_source,
|
||||||
WHERE (?1 IS NULL OR i.folder_id = ?1)
|
MAX(CASE WHEN t.source = 'user' THEN 1 ELSE 0 END) AS has_user_source
|
||||||
GROUP BY t.tag
|
FROM image_tags t
|
||||||
HAVING COUNT(DISTINCT t.image_id) >= 1
|
JOIN images i ON i.id = t.image_id
|
||||||
ORDER BY tag_count DESC, t.tag ASC
|
WHERE (?1 IS NULL OR i.folder_id = ?1)
|
||||||
LIMIT ?2",
|
GROUP BY t.tag
|
||||||
|
HAVING COUNT(DISTINCT t.image_id) >= 1
|
||||||
|
ORDER BY tag_count DESC, t.tag ASC
|
||||||
|
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
|
let rows = stmt
|
||||||
.query_map(params![folder_id, limit as i64], |row| {
|
.query_map(params![folder_id, limit as i64], |row| {
|
||||||
let representative_image_id = row.get::<_, i64>(2)?;
|
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 {
|
Ok(ExploreTagEntry {
|
||||||
tag: row.get(0)?,
|
tag: row.get(0)?,
|
||||||
count: row.get(1)?,
|
count: row.get(1)?,
|
||||||
representative_image_id,
|
representative_image_id,
|
||||||
thumbnail_path,
|
thumbnail_path: row.get(3)?,
|
||||||
has_ai_source: row.get::<_, i64>(3)? != 0,
|
has_ai_source: row.get::<_, i64>(4)? != 0,
|
||||||
has_user_source: row.get::<_, i64>(4)? != 0,
|
has_user_source: row.get::<_, i64>(5)? != 0,
|
||||||
})
|
})
|
||||||
})?
|
})?
|
||||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||||
|
|||||||
+55
-10
@@ -651,6 +651,34 @@ let galleryRequestToken = 0;
|
|||||||
let visualClusterRequestToken = 0;
|
let visualClusterRequestToken = 0;
|
||||||
let exploreTagRequestToken = 0;
|
let exploreTagRequestToken = 0;
|
||||||
let exploreTagRefreshTimer: ReturnType<typeof setTimeout> | null = null;
|
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 {
|
function initialAiCaptionsEnabled(): boolean {
|
||||||
if (typeof window === "undefined") return false;
|
if (typeof window === "undefined") return false;
|
||||||
@@ -1498,6 +1526,9 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
|||||||
loadExploreTags: async (options) => {
|
loadExploreTags: async (options) => {
|
||||||
const { selectedFolderId, exploreTagsFolderId, exploreTagLoading } = get();
|
const { selectedFolderId, exploreTagsFolderId, exploreTagLoading } = get();
|
||||||
const force = options?.force ?? false;
|
const force = options?.force ?? false;
|
||||||
|
if (!force && exploreTagLoading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!force && !exploreTagLoading && exploreTagsFolderId !== undefined && exploreTagsFolderId === selectedFolderId) {
|
if (!force && !exploreTagLoading && exploreTagsFolderId !== undefined && exploreTagsFolderId === selectedFolderId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -2984,9 +3015,18 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
|||||||
notificationTimers.set(taggingKey, setTimeout(() => {
|
notificationTimers.set(taggingKey, setTimeout(() => {
|
||||||
notificationTimers.delete(taggingKey);
|
notificationTimers.delete(taggingKey);
|
||||||
void notifyTaskComplete("AI tagging complete", body);
|
void notifyTaskComplete("AI tagging complete", body);
|
||||||
// New tags landed — invalidate Explore tag cache.
|
|
||||||
set({ exploreTagsFolderId: undefined });
|
|
||||||
}, NOTIFICATION_DEBOUNCE_MS));
|
}, 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) {
|
} else if (previous.tagging_pending === 0 && progress.tagging_pending > 0) {
|
||||||
clearTimeout(notificationTimers.get(taggingKey));
|
clearTimeout(notificationTimers.get(taggingKey));
|
||||||
notificationTimers.delete(taggingKey);
|
notificationTimers.delete(taggingKey);
|
||||||
@@ -3067,15 +3107,16 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
|||||||
const unlistenThumbnails = await listen<ThumbnailBatch>("media-updated", (event) => {
|
const unlistenThumbnails = await listen<ThumbnailBatch>("media-updated", (event) => {
|
||||||
const batch = event.payload;
|
const batch = event.payload;
|
||||||
const taggingUpdated = batch.images.some((image) => image.ai_tagged_at !== null || image.ai_tagger_error !== null);
|
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 });
|
set({ exploreTagsFolderId: undefined });
|
||||||
if (get().activeView === "explore") {
|
if (state.activeView === "explore" && state.exploreMode === "tags") {
|
||||||
if (!exploreTagRefreshTimer) {
|
const delay = scopeHasTaggingPending(state.mediaJobProgress, state.selectedFolderId)
|
||||||
exploreTagRefreshTimer = setTimeout(() => {
|
? EXPLORE_TAG_REFRESH_WHILE_TAGGING_MS
|
||||||
exploreTagRefreshTimer = null;
|
: EXPLORE_TAG_REFRESH_IDLE_MS;
|
||||||
void get().loadExploreTags({ force: true });
|
scheduleExploreTagRefresh(() => {
|
||||||
}, 700);
|
void get().loadExploreTags({ force: true });
|
||||||
}
|
}, delay);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3179,6 +3220,10 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
if (exploreTagRefreshTimer) {
|
||||||
|
clearTimeout(exploreTagRefreshTimer);
|
||||||
|
exploreTagRefreshTimer = null;
|
||||||
|
}
|
||||||
unlistenProgress();
|
unlistenProgress();
|
||||||
unlistenMediaJobs();
|
unlistenMediaJobs();
|
||||||
unlistenCaptionModelProgress();
|
unlistenCaptionModelProgress();
|
||||||
|
|||||||
Reference in New Issue
Block a user