feat: add album scope for similar image search

Similar search scoping:
- Add current_album as a similar-scope option and remember the source album when similar or region searches are launched from an album.
- Route gallery and lightbox similar actions through scope-aware store helpers so Album/Folder/All choices are applied consistently.
- Keep pagination and scope toggles working for both whole-image and region-search result sets.

Backend filtering:
- Extend find_similar_images and find_similar_by_region params with album_id, giving album scope precedence over folder scope.
- Add album_membership filtering for HNSW whole-image search and brute-force crop embedding search.
This commit is contained in:
2026-06-28 01:19:53 +01:00
parent a12e81d8bd
commit e3fde46e91
8 changed files with 162 additions and 45 deletions
+5 -7
View File
@@ -31,8 +31,7 @@ export function ContextMenu({
}) {
const openImage = useGalleryStore((state) => state.openImage);
const updateImageDetails = useGalleryStore((state) => state.updateImageDetails);
const loadSimilarImages = useGalleryStore((state) => state.loadSimilarImages);
const similarScope = useGalleryStore((state) => state.similarScope);
const findSimilar = useGalleryStore((state) => state.findSimilar);
const canFindSimilar = image.embedding_status === "ready";
return (
@@ -60,9 +59,9 @@ export function ContextMenu({
? "text-gray-200 hover:bg-white/5 hover:text-white"
: "text-gray-600 cursor-not-allowed"
}`}
onClick={async () => {
onClick={() => {
if (!canFindSimilar) return;
await loadSimilarImages(image.id, similarScope === "current_folder" ? image.folder_id : null, true, image.folder_id);
findSimilar(image.id, image.folder_id);
onClose();
}}
disabled={!canFindSimilar}
@@ -117,8 +116,7 @@ export function ImageTile({
}) {
const [loaded, setLoaded] = useState(false);
const [errored, setErrored] = useState(false);
const loadSimilarImages = useGalleryStore((state) => state.loadSimilarImages);
const similarScope = useGalleryStore((state) => state.similarScope);
const findSimilar = useGalleryStore((state) => state.findSimilar);
const selected = useGalleryStore((state) => state.gallerySelectedIds.has(image.id));
const selectionActive = useGalleryStore((state) => state.gallerySelectedIds.size > 0);
const toggleGallerySelected = useGalleryStore((state) => state.toggleGallerySelected);
@@ -274,7 +272,7 @@ export function ImageTile({
onClick={(event) => {
event.stopPropagation();
if (!canFindSimilar) return;
void loadSimilarImages(image.id, similarScope === "current_folder" ? image.folder_id : null, true, image.folder_id);
findSimilar(image.id, image.folder_id);
}}
disabled={!canFindSimilar}
>
+5 -8
View File
@@ -144,9 +144,8 @@ export function Lightbox() {
const closeImage = useGalleryStore((state) => state.closeImage);
const images = useGalleryStore((state) => state.images);
const openImage = useGalleryStore((state) => state.openImage);
const loadSimilarImages = useGalleryStore((state) => state.loadSimilarImages);
const loadSimilarByRegion = useGalleryStore((state) => state.loadSimilarByRegion);
const similarScope = useGalleryStore((state) => state.similarScope);
const findSimilar = useGalleryStore((state) => state.findSimilar);
const findSimilarByRegion = useGalleryStore((state) => state.findSimilarByRegion);
const updateImageDetails = useGalleryStore((state) => state.updateImageDetails);
const getImageTags = useGalleryStore((state) => state.getImageTags);
const addUserTag = useGalleryStore((state) => state.addUserTag);
@@ -384,12 +383,10 @@ export function Lightbox() {
exitRegionMode();
setRegionSearching(true);
const folderId =
similarScope === "current_folder" ? selectedImage.folder_id : null;
void loadSimilarByRegion(selectedImage.id, crop, folderId, selectedImage.folder_id)
void findSimilarByRegion(selectedImage.id, crop, selectedImage.folder_id)
.finally(() => setRegionSearching(false));
},
[isPanning, isDragging, dragRect, selectedImage, similarScope, loadSimilarByRegion, exitRegionMode],
[isPanning, isDragging, dragRect, selectedImage, findSimilarByRegion, exitRegionMode],
);
// Build the CSS rect for the selection overlay (viewport-relative)
@@ -543,7 +540,7 @@ export function Lightbox() {
}`}
onClick={() => {
if (!canFindSimilar) return;
void loadSimilarImages(selectedImage.id, similarScope === "current_folder" ? selectedImage.folder_id : null, true, selectedImage.folder_id);
void findSimilar(selectedImage.id, selectedImage.folder_id);
}}
disabled={!canFindSimilar}
>
+11 -1
View File
@@ -162,6 +162,7 @@ export function Toolbar() {
const setFailedTaggingOnly = useGalleryStore((state) => state.setFailedTaggingOnly);
const similarScope = useGalleryStore((state) => state.similarScope);
const setSimilarScope = useGalleryStore((state) => state.setSimilarScope);
const similarSourceAlbumId = useGalleryStore((state) => state.similarSourceAlbumId);
const mediaJobProgress = useGalleryStore((state) => state.mediaJobProgress);
const zoomPreset = useGalleryStore((state) => state.zoomPreset);
const setZoomPreset = useGalleryStore((state) => state.setZoomPreset);
@@ -187,6 +188,12 @@ export function Toolbar() {
const hasActiveSearch = search.trim().length > 0;
const parsedSearch = parseSearchValue(composeSearchValue(searchCommand, searchQuery));
const isSimilarResults = collectionTitle === "Similar Images";
// Album scope is offered while browsing an album (where it's the default) and
// while viewing similar/region results that were launched from an album.
const showAlbumScope =
activeView === "album" ||
(similarSourceAlbumId !== null &&
(collectionTitle === "Similar Images" || collectionTitle === "Region Search Results"));
// If current sort is video-only but we switched away from video filter, reset to date_desc
useEffect(() => {
@@ -450,6 +457,9 @@ export function Toolbar() {
<FilterPill label="Favorites" active={favoritesOnly} onClick={() => { setFavoritesOnly(!favoritesOnly); setFailedEmbeddingsOnly(false); setFailedTaggingOnly(false); }} />
<FilterPill label="Rated" active={minimumRating === 1} onClick={() => { setMinimumRating(minimumRating === 1 ? 0 : 1); setFailedEmbeddingsOnly(false); setFailedTaggingOnly(false); }} />
<FilterPill label="4★+" active={minimumRating === 4} onClick={() => { setMinimumRating(minimumRating === 4 ? 0 : 4); setFailedEmbeddingsOnly(false); setFailedTaggingOnly(false); }} />
{showAlbumScope ? (
<FilterPill label="Similar: Album" active={similarScope === "current_album"} onClick={() => setSimilarScope("current_album")} />
) : null}
<FilterPill label="Similar: Folder" active={similarScope === "current_folder"} onClick={() => setSimilarScope("current_folder")} />
<FilterPill label="Similar: All" active={similarScope === "all_media"} onClick={() => setSimilarScope("all_media")} />
{hasAnyFailedEmbeddings ? (
@@ -468,7 +478,7 @@ export function Toolbar() {
onClick={() => setFailedTaggingOnly(!failedTaggingOnly)}
/>
) : null}
{isSimilarResults ? <span className="ml-2 text-[11px] text-gray-500">Current similar scope: {similarScope === "current_folder" ? "current folder" : "all media"}</span> : null}
{isSimilarResults ? <span className="ml-2 text-[11px] text-gray-500">Current similar scope: {similarScope === "current_album" ? "this album" : similarScope === "current_folder" ? "current folder" : "all media"}</span> : null}
</div>
</div>
);
+71 -23
View File
@@ -53,7 +53,7 @@ export type CaptionDetail = "short" | "detailed" | "paragraph";
export type TaggerAcceleration = "auto" | "cpu" | "directml";
export type AiRating = "general" | "sensitive" | "questionable" | "explicit";
export type TaggingQueueScope = "all" | "selected";
export type SimilarScope = "all_media" | "current_folder";
export type SimilarScope = "all_media" | "current_folder" | "current_album";
export type ExploreMode = "visual" | "tags";
export type AppTheme = "phokus" | "subtle-light" | "conventional-dark";
@@ -359,6 +359,7 @@ interface GalleryState {
collectionTitle: string | null;
similarSourceImageId: number | null;
similarSourceFolderId: number | null;
similarSourceAlbumId: number | null; // album a similar search was launched from (enables "Similar: Album")
similarHasMore: boolean;
similarScope: SimilarScope;
similarFolderId: number | null;
@@ -478,8 +479,11 @@ interface GalleryState {
loadExploreTags: () => Promise<void>;
showVisualCluster: (imageIds: number[]) => Promise<void>;
searchForTag: (tag: string) => void;
loadSimilarImages: (imageId: number, folderId?: number | null, reset?: boolean, sourceFolderId?: number | null) => Promise<void>;
loadSimilarByRegion: (imageId: number, crop: { x: number; y: number; w: number; h: number }, folderId?: number | null, sourceFolderId?: number | null) => Promise<void>;
loadSimilarImages: (imageId: number, folderId?: number | null, reset?: boolean, sourceFolderId?: number | null, albumId?: number | null) => Promise<void>;
loadSimilarByRegion: (imageId: number, crop: { x: number; y: number; w: number; h: number }, folderId?: number | null, sourceFolderId?: number | null, albumId?: number | null) => Promise<void>;
// Entry points that decide scope (album when launched from an album, else folder/all per similarScope).
findSimilar: (imageId: number, sourceFolderId: number | null) => Promise<void>;
findSimilarByRegion: (imageId: number, crop: { x: number; y: number; w: number; h: number }, sourceFolderId: number | null) => Promise<void>;
setSimilarScope: (scope: SimilarScope) => void;
suggestImageTags: (imageId: number) => Promise<string[]>;
loadCaptionModelStatus: () => Promise<void>;
@@ -835,6 +839,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
collectionTitle: null,
similarSourceImageId: null,
similarSourceFolderId: null,
similarSourceAlbumId: null,
similarHasMore: false,
similarScope: "all_media",
similarFolderId: null,
@@ -1021,7 +1026,10 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
},
selectFolder: (folderId) => {
set({ selectedFolderId: folderId, selectedAlbumId: null, images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarHasMore: false, activeView: "gallery", failedEmbeddingsOnly: false, failedTaggingOnly: false, imageLoadError: null });
// Leaving any album: drop the album-origin scope so the Folder/All pills
// highlight correctly again.
const similarScope = get().similarScope === "current_album" ? "all_media" : get().similarScope;
set({ selectedFolderId: folderId, selectedAlbumId: null, similarSourceAlbumId: null, similarScope, images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarHasMore: false, activeView: "gallery", failedEmbeddingsOnly: false, failedTaggingOnly: false, imageLoadError: null });
void get().loadImages(true);
},
@@ -1218,9 +1226,10 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
}
return;
}
const pageAlbumId = get().similarScope === "current_album" ? get().similarSourceAlbumId : null;
if (collectionTitle === "Similar Images" && similarSourceImageId !== null) {
if (!similarHasMore) return;
await get().loadSimilarImages(similarSourceImageId, similarFolderId, false, get().similarSourceFolderId ?? null);
await get().loadSimilarImages(similarSourceImageId, similarFolderId, false, get().similarSourceFolderId ?? null, pageAlbumId);
return;
}
if (collectionTitle === "Region Search Results" && similarSourceImageId !== null && similarCrop !== null) {
@@ -1235,7 +1244,8 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
crop_y: similarCrop.y,
crop_w: similarCrop.w,
crop_h: similarCrop.h,
folder_id: similarFolderId,
folder_id: pageAlbumId !== null ? null : similarFolderId,
album_id: pageAlbumId,
offset: loadedCount,
limit: PAGE_SIZE,
},
@@ -1336,8 +1346,10 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
closeImage: () => set({ selectedImage: null }),
setView: (activeView) => {
// Leaving an album view drops the album-origin similar scope.
const similarScopeReset = get().similarScope === "current_album" ? "all_media" : get().similarScope;
if (activeView === "timeline") {
set({ activeView, sort: "taken_asc", images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarSourceFolderId: null, similarFolderId: null, similarHasMore: false, similarCrop: null, imageLoadError: null });
set({ activeView, sort: "taken_asc", images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarSourceFolderId: null, similarSourceAlbumId: null, similarScope: similarScopeReset, similarFolderId: null, similarHasMore: false, similarCrop: null, imageLoadError: null });
void get().loadImages(true);
return;
}
@@ -1355,7 +1367,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
return;
}
}
set({ activeView });
set({ activeView, similarSourceAlbumId: null, similarScope: similarScopeReset });
},
setExploreMode: (exploreMode) => set({ exploreMode }),
@@ -1452,10 +1464,12 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
void get().loadImages(true);
},
loadSimilarImages: async (imageId, folderId = get().selectedFolderId, reset = true, sourceFolderId = folderId ?? null) => {
loadSimilarImages: async (imageId, folderId = get().selectedFolderId, reset = true, sourceFolderId = folderId ?? null, albumId = null) => {
const requestToken = ++galleryRequestToken;
const offset = reset ? 0 : get().loadedCount;
const similarScope = folderId === null ? "all_media" : "current_folder";
const similarScope: SimilarScope = albumId !== null ? "current_album" : folderId === null ? "all_media" : "current_folder";
// Album scope drives results off album membership, so the folder query is null.
const queryFolderId = albumId !== null ? null : folderId ?? null;
set((state) => ({
images: reset ? [] : state.images,
loadedCount: reset ? 0 : state.loadedCount,
@@ -1464,7 +1478,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
imageLoadError: null,
similarSourceImageId: imageId,
similarSourceFolderId: sourceFolderId,
similarFolderId: folderId ?? null,
similarFolderId: queryFolderId,
similarScope,
// Force the gallery grid so results (and the bulk bar) render regardless
// of which view the search was launched from.
@@ -1478,7 +1492,8 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
const result = await invoke<SimilarImagesPage>("find_similar_images", {
params: {
image_id: imageId,
folder_id: folderId ?? null,
folder_id: queryFolderId,
album_id: albumId,
offset,
limit: PAGE_SIZE,
threshold: SIMILAR_DISTANCE_THRESHOLD,
@@ -1500,7 +1515,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
similarSourceImageId: imageId,
similarSourceFolderId: sourceFolderId,
similarHasMore: result.has_more,
similarFolderId: folderId ?? null,
similarFolderId: queryFolderId,
similarScope,
selectedImage: reset ? null : state.selectedImage,
};
@@ -1518,16 +1533,17 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
similarSourceImageId: imageId,
similarSourceFolderId: sourceFolderId,
similarHasMore: false,
similarFolderId: folderId ?? null,
similarFolderId: queryFolderId,
similarScope,
selectedImage: null,
});
}
},
loadSimilarByRegion: async (imageId, crop, folderId = get().selectedFolderId, sourceFolderId = folderId ?? null) => {
loadSimilarByRegion: async (imageId, crop, folderId = get().selectedFolderId, sourceFolderId = folderId ?? null, albumId = null) => {
const requestToken = ++galleryRequestToken;
const similarScope = folderId === null ? "all_media" : "current_folder";
const similarScope: SimilarScope = albumId !== null ? "current_album" : folderId === null ? "all_media" : "current_folder";
const queryFolderId = albumId !== null ? null : folderId ?? null;
set((state) => ({
images: [],
loadedCount: 0,
@@ -1536,7 +1552,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
imageLoadError: null,
similarSourceImageId: imageId,
similarSourceFolderId: sourceFolderId,
similarFolderId: folderId ?? null,
similarFolderId: queryFolderId,
similarCrop: crop,
similarScope,
// Force the gallery grid so results (and the bulk bar) render regardless
@@ -1556,7 +1572,8 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
crop_y: crop.y,
crop_w: crop.w,
crop_h: crop.h,
folder_id: folderId ?? null,
folder_id: queryFolderId,
album_id: albumId,
offset: 0,
limit: PAGE_SIZE,
},
@@ -1574,7 +1591,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
similarSourceImageId: imageId,
similarSourceFolderId: sourceFolderId,
similarHasMore: result.has_more,
similarFolderId: folderId ?? null,
similarFolderId: queryFolderId,
similarCrop: crop,
similarScope,
});
@@ -1591,22 +1608,51 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
similarSourceImageId: imageId,
similarSourceFolderId: sourceFolderId,
similarHasMore: false,
similarFolderId: folderId ?? null,
similarFolderId: queryFolderId,
similarCrop: crop,
similarScope,
selectedImage: null,
});
}
},
// Decide the scope at launch: album when triggered from an album, else the
// current folder/all preference. Sets similarSourceAlbumId so the "Similar:
// Album" pill and scope toggle work afterward.
findSimilar: (imageId, sourceFolderId) => {
const { activeView, selectedAlbumId, similarScope } = get();
const albumOrigin = activeView === "album" ? selectedAlbumId : null;
set({ similarSourceAlbumId: albumOrigin });
// Respect the chosen scope; album is the default in an album view but the
// user can override to Folder/All before searching.
if (similarScope === "current_album" && albumOrigin !== null) {
return get().loadSimilarImages(imageId, null, true, sourceFolderId, albumOrigin);
}
const folderId = similarScope === "current_folder" ? sourceFolderId : null;
return get().loadSimilarImages(imageId, folderId, true, sourceFolderId, null);
},
findSimilarByRegion: (imageId, crop, sourceFolderId) => {
const { activeView, selectedAlbumId, similarScope } = get();
const albumOrigin = activeView === "album" ? selectedAlbumId : null;
set({ similarSourceAlbumId: albumOrigin });
if (similarScope === "current_album" && albumOrigin !== null) {
return get().loadSimilarByRegion(imageId, crop, null, sourceFolderId, albumOrigin);
}
const folderId = similarScope === "current_folder" ? sourceFolderId : null;
return get().loadSimilarByRegion(imageId, crop, folderId, sourceFolderId, null);
},
setSimilarScope: (similarScope) => {
set({ similarScope });
const { similarSourceImageId, similarSourceFolderId, selectedFolderId, collectionTitle, similarCrop } = get();
const { similarSourceImageId, similarSourceFolderId, similarSourceAlbumId, selectedFolderId, collectionTitle, similarCrop } = get();
if (similarSourceImageId === null) return;
const albumId = similarScope === "current_album" ? similarSourceAlbumId : null;
const folderId = similarScope === "current_folder" ? (similarSourceFolderId ?? selectedFolderId) : null;
if (collectionTitle === "Region Search Results" && similarCrop !== null) {
void get().loadSimilarByRegion(similarSourceImageId, similarCrop, folderId, similarSourceFolderId);
void get().loadSimilarByRegion(similarSourceImageId, similarCrop, folderId, similarSourceFolderId, albumId);
} else {
void get().loadSimilarImages(similarSourceImageId, folderId, true, similarSourceFolderId);
void get().loadSimilarImages(similarSourceImageId, folderId, true, similarSourceFolderId, albumId);
}
},
@@ -2470,6 +2516,8 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
imageLoadError: null,
similarSourceImageId: null,
similarSourceFolderId: null,
similarSourceAlbumId: albumId,
similarScope: "current_album",
similarHasMore: false,
similarFolderId: null,
similarCrop: null,