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:
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user