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
+3
View File
@@ -35,6 +35,9 @@ aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
whole library. whole library.
- **Reorderable albums** — drag albums in the sidebar (hover the row for the - **Reorderable albums** — drag albums in the sidebar (hover the row for the
drag handle) to set their order, which persists across sessions. drag handle) to set their order, which persists across sessions.
- **Album-scoped similar search** — when finding visually similar images or
searching by a selected region from an album, you can now keep results scoped
to that album, switch back to the source folder, or search all media.
- **What's New** — after updating, Phokus now greets you with a "What's new" - **What's New** — after updating, Phokus now greets you with a "What's new"
toast that opens an in-app release-notes screen for the new version, with the toast that opens an in-app release-notes screen for the new version, with the
changes grouped into collapsible Added / Changed / Fixed sections. It's changes grouped into collapsible Added / Changed / Fixed sections. It's
+20 -2
View File
@@ -58,6 +58,9 @@ pub struct UpdateImageDetailsParams {
pub struct FindSimilarImagesParams { pub struct FindSimilarImagesParams {
pub image_id: i64, pub image_id: i64,
pub folder_id: Option<i64>, pub folder_id: Option<i64>,
/// When set, restrict results to images in this album (takes precedence
/// over `folder_id`). Used by the "Similar: Album" scope.
pub album_id: Option<i64>,
pub offset: Option<usize>, pub offset: Option<usize>,
pub limit: Option<usize>, pub limit: Option<usize>,
pub threshold: Option<f32>, pub threshold: Option<f32>,
@@ -72,6 +75,8 @@ pub struct FindSimilarByRegionParams {
pub crop_w: f32, pub crop_w: f32,
pub crop_h: f32, pub crop_h: f32,
pub folder_id: Option<i64>, pub folder_id: Option<i64>,
/// Restrict to an album (takes precedence over `folder_id`).
pub album_id: Option<i64>,
pub offset: Option<usize>, pub offset: Option<usize>,
pub limit: Option<usize>, pub limit: Option<usize>,
} }
@@ -704,6 +709,7 @@ pub async fn find_similar_images(
&conn, &conn,
params.image_id, params.image_id,
params.folder_id, params.folder_id,
params.album_id,
threshold, threshold,
offset, offset,
limit + 1, limit + 1,
@@ -749,8 +755,19 @@ pub async fn find_similar_by_region(
) )
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
// Search for similar images using the crop embedding // Search for similar images using the crop embedding. Album scope takes
let image_ids = match params.folder_id { // precedence over folder scope.
let image_ids = if let Some(album_id) = params.album_id {
vector::search_image_ids_by_embedding_in_album(
&conn,
&embedding,
album_id,
Some(params.image_id),
offset + limit + 1,
)
.map_err(|e| e.to_string())?
} else {
match params.folder_id {
Some(folder_id) => vector::search_image_ids_by_embedding_in_folder( Some(folder_id) => vector::search_image_ids_by_embedding_in_folder(
&conn, &conn,
&embedding, &embedding,
@@ -768,6 +785,7 @@ pub async fn find_similar_by_region(
ids.retain(|&id| id != params.image_id); ids.retain(|&id| id != params.image_id);
ids ids
} }
}
}; };
let has_more = image_ids.len() > offset + limit; let has_more = image_ids.len() > offset + limit;
+12 -4
View File
@@ -92,6 +92,7 @@ pub fn find_similar_image_matches(
conn: &Connection, conn: &Connection,
image_id: i64, image_id: i64,
folder_id: Option<i64>, folder_id: Option<i64>,
album_id: Option<i64>,
threshold: f32, threshold: f32,
offset: usize, offset: usize,
limit: usize, limit: usize,
@@ -103,10 +104,17 @@ pub fn find_similar_image_matches(
None => return Ok(Vec::new()), None => return Ok(Vec::new()),
}; };
// Fetch folder image IDs *before* acquiring the read lock so we don't hold // Build the allowed-id set *before* acquiring the read lock so we don't hold
// the lock across a potentially slow SQLite query, which would delay any // the lock across a potentially slow SQLite query, which would delay any
// concurrent ensure_index call waiting for a write lock. // concurrent ensure_index call waiting for a write lock. Album scope takes
let folder_image_ids: Option<Vec<i64>> = if let Some(folder_id) = folder_id { // precedence over folder scope; both reuse the HNSW filtered search.
let allowed_image_ids: Option<Vec<i64>> = if let Some(album_id) = album_id {
let mut stmt = conn.prepare("SELECT image_id FROM album_images WHERE album_id = ?1")?;
let ids = stmt
.query_map([album_id], |row| row.get::<_, i64>(0))?
.collect::<rusqlite::Result<Vec<_>>>()?;
Some(ids)
} else if let Some(folder_id) = folder_id {
let ids = vector::get_all_image_embeddings_with_ids(conn, Some(folder_id))? let ids = vector::get_all_image_embeddings_with_ids(conn, Some(folder_id))?
.into_iter() .into_iter()
.map(|(id, _)| id) .map(|(id, _)| id)
@@ -122,7 +130,7 @@ pub fn find_similar_image_matches(
}; };
let knbn = (offset + limit).max(limit).saturating_add(32); let knbn = (offset + limit).max(limit).saturating_add(32);
let neighbours: Vec<Neighbour> = if let Some(image_ids) = folder_image_ids { let neighbours: Vec<Neighbour> = if let Some(image_ids) = allowed_image_ids {
let mut allowed_ids = image_ids let mut allowed_ids = image_ids
.into_iter() .into_iter()
.filter_map(|allowed_image_id| { .filter_map(|allowed_image_id| {
+35
View File
@@ -372,6 +372,41 @@ pub fn search_image_ids_by_embedding_in_folder(
Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?) Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
} }
/// Brute-force cosine search scoped to a single album (membership via
/// `album_images`), ordered by ascending distance. Mirrors the folder-scoped
/// variant for region-based similarity search.
pub fn search_image_ids_by_embedding_in_album(
conn: &Connection,
embedding: &[f32],
album_id: i64,
exclude_image_id: Option<i64>,
limit: usize,
) -> Result<Vec<i64>> {
if embedding.len() != CLIP_VECTOR_DIM {
return Err(anyhow!(
"expected {}-dimensional embedding, got {}",
CLIP_VECTOR_DIM,
embedding.len()
));
}
let packed = pack_f32(embedding);
let exclude_id = exclude_image_id.unwrap_or(-1);
let mut stmt = conn.prepare(
"SELECT v.image_id
FROM image_vec v
JOIN album_images ai ON ai.image_id = v.image_id
WHERE ai.album_id = ?2
AND v.image_id != ?3
ORDER BY vec_distance_cosine(v.embedding, vec_f32(?1)) ASC
LIMIT ?4",
)?;
let rows = stmt.query_map((&packed, album_id, exclude_id, limit as i64), |row| {
row.get::<_, i64>(0)
})?;
Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
}
#[allow(dead_code)] #[allow(dead_code)]
pub fn search_caption_ids_by_embedding( pub fn search_caption_ids_by_embedding(
conn: &Connection, conn: &Connection,
+5 -7
View File
@@ -31,8 +31,7 @@ export function ContextMenu({
}) { }) {
const openImage = useGalleryStore((state) => state.openImage); const openImage = useGalleryStore((state) => state.openImage);
const updateImageDetails = useGalleryStore((state) => state.updateImageDetails); const updateImageDetails = useGalleryStore((state) => state.updateImageDetails);
const loadSimilarImages = useGalleryStore((state) => state.loadSimilarImages); const findSimilar = useGalleryStore((state) => state.findSimilar);
const similarScope = useGalleryStore((state) => state.similarScope);
const canFindSimilar = image.embedding_status === "ready"; const canFindSimilar = image.embedding_status === "ready";
return ( return (
@@ -60,9 +59,9 @@ export function ContextMenu({
? "text-gray-200 hover:bg-white/5 hover:text-white" ? "text-gray-200 hover:bg-white/5 hover:text-white"
: "text-gray-600 cursor-not-allowed" : "text-gray-600 cursor-not-allowed"
}`} }`}
onClick={async () => { onClick={() => {
if (!canFindSimilar) return; 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(); onClose();
}} }}
disabled={!canFindSimilar} disabled={!canFindSimilar}
@@ -117,8 +116,7 @@ export function ImageTile({
}) { }) {
const [loaded, setLoaded] = useState(false); const [loaded, setLoaded] = useState(false);
const [errored, setErrored] = useState(false); const [errored, setErrored] = useState(false);
const loadSimilarImages = useGalleryStore((state) => state.loadSimilarImages); const findSimilar = useGalleryStore((state) => state.findSimilar);
const similarScope = useGalleryStore((state) => state.similarScope);
const selected = useGalleryStore((state) => state.gallerySelectedIds.has(image.id)); const selected = useGalleryStore((state) => state.gallerySelectedIds.has(image.id));
const selectionActive = useGalleryStore((state) => state.gallerySelectedIds.size > 0); const selectionActive = useGalleryStore((state) => state.gallerySelectedIds.size > 0);
const toggleGallerySelected = useGalleryStore((state) => state.toggleGallerySelected); const toggleGallerySelected = useGalleryStore((state) => state.toggleGallerySelected);
@@ -274,7 +272,7 @@ export function ImageTile({
onClick={(event) => { onClick={(event) => {
event.stopPropagation(); event.stopPropagation();
if (!canFindSimilar) return; 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} disabled={!canFindSimilar}
> >
+5 -8
View File
@@ -144,9 +144,8 @@ export function Lightbox() {
const closeImage = useGalleryStore((state) => state.closeImage); const closeImage = useGalleryStore((state) => state.closeImage);
const images = useGalleryStore((state) => state.images); const images = useGalleryStore((state) => state.images);
const openImage = useGalleryStore((state) => state.openImage); const openImage = useGalleryStore((state) => state.openImage);
const loadSimilarImages = useGalleryStore((state) => state.loadSimilarImages); const findSimilar = useGalleryStore((state) => state.findSimilar);
const loadSimilarByRegion = useGalleryStore((state) => state.loadSimilarByRegion); const findSimilarByRegion = useGalleryStore((state) => state.findSimilarByRegion);
const similarScope = useGalleryStore((state) => state.similarScope);
const updateImageDetails = useGalleryStore((state) => state.updateImageDetails); const updateImageDetails = useGalleryStore((state) => state.updateImageDetails);
const getImageTags = useGalleryStore((state) => state.getImageTags); const getImageTags = useGalleryStore((state) => state.getImageTags);
const addUserTag = useGalleryStore((state) => state.addUserTag); const addUserTag = useGalleryStore((state) => state.addUserTag);
@@ -384,12 +383,10 @@ export function Lightbox() {
exitRegionMode(); exitRegionMode();
setRegionSearching(true); setRegionSearching(true);
const folderId = void findSimilarByRegion(selectedImage.id, crop, selectedImage.folder_id)
similarScope === "current_folder" ? selectedImage.folder_id : null;
void loadSimilarByRegion(selectedImage.id, crop, folderId, selectedImage.folder_id)
.finally(() => setRegionSearching(false)); .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) // Build the CSS rect for the selection overlay (viewport-relative)
@@ -543,7 +540,7 @@ export function Lightbox() {
}`} }`}
onClick={() => { onClick={() => {
if (!canFindSimilar) return; 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} disabled={!canFindSimilar}
> >
+11 -1
View File
@@ -162,6 +162,7 @@ export function Toolbar() {
const setFailedTaggingOnly = useGalleryStore((state) => state.setFailedTaggingOnly); const setFailedTaggingOnly = useGalleryStore((state) => state.setFailedTaggingOnly);
const similarScope = useGalleryStore((state) => state.similarScope); const similarScope = useGalleryStore((state) => state.similarScope);
const setSimilarScope = useGalleryStore((state) => state.setSimilarScope); const setSimilarScope = useGalleryStore((state) => state.setSimilarScope);
const similarSourceAlbumId = useGalleryStore((state) => state.similarSourceAlbumId);
const mediaJobProgress = useGalleryStore((state) => state.mediaJobProgress); const mediaJobProgress = useGalleryStore((state) => state.mediaJobProgress);
const zoomPreset = useGalleryStore((state) => state.zoomPreset); const zoomPreset = useGalleryStore((state) => state.zoomPreset);
const setZoomPreset = useGalleryStore((state) => state.setZoomPreset); const setZoomPreset = useGalleryStore((state) => state.setZoomPreset);
@@ -187,6 +188,12 @@ export function Toolbar() {
const hasActiveSearch = search.trim().length > 0; const hasActiveSearch = search.trim().length > 0;
const parsedSearch = parseSearchValue(composeSearchValue(searchCommand, searchQuery)); const parsedSearch = parseSearchValue(composeSearchValue(searchCommand, searchQuery));
const isSimilarResults = collectionTitle === "Similar Images"; 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 // If current sort is video-only but we switched away from video filter, reset to date_desc
useEffect(() => { useEffect(() => {
@@ -450,6 +457,9 @@ export function Toolbar() {
<FilterPill label="Favorites" active={favoritesOnly} onClick={() => { setFavoritesOnly(!favoritesOnly); setFailedEmbeddingsOnly(false); setFailedTaggingOnly(false); }} /> <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="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); }} /> <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: Folder" active={similarScope === "current_folder"} onClick={() => setSimilarScope("current_folder")} />
<FilterPill label="Similar: All" active={similarScope === "all_media"} onClick={() => setSimilarScope("all_media")} /> <FilterPill label="Similar: All" active={similarScope === "all_media"} onClick={() => setSimilarScope("all_media")} />
{hasAnyFailedEmbeddings ? ( {hasAnyFailedEmbeddings ? (
@@ -468,7 +478,7 @@ export function Toolbar() {
onClick={() => setFailedTaggingOnly(!failedTaggingOnly)} onClick={() => setFailedTaggingOnly(!failedTaggingOnly)}
/> />
) : null} ) : 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>
</div> </div>
); );
+71 -23
View File
@@ -53,7 +53,7 @@ export type CaptionDetail = "short" | "detailed" | "paragraph";
export type TaggerAcceleration = "auto" | "cpu" | "directml"; export type TaggerAcceleration = "auto" | "cpu" | "directml";
export type AiRating = "general" | "sensitive" | "questionable" | "explicit"; export type AiRating = "general" | "sensitive" | "questionable" | "explicit";
export type TaggingQueueScope = "all" | "selected"; 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 ExploreMode = "visual" | "tags";
export type AppTheme = "phokus" | "subtle-light" | "conventional-dark"; export type AppTheme = "phokus" | "subtle-light" | "conventional-dark";
@@ -359,6 +359,7 @@ interface GalleryState {
collectionTitle: string | null; collectionTitle: string | null;
similarSourceImageId: number | null; similarSourceImageId: number | null;
similarSourceFolderId: number | null; similarSourceFolderId: number | null;
similarSourceAlbumId: number | null; // album a similar search was launched from (enables "Similar: Album")
similarHasMore: boolean; similarHasMore: boolean;
similarScope: SimilarScope; similarScope: SimilarScope;
similarFolderId: number | null; similarFolderId: number | null;
@@ -478,8 +479,11 @@ interface GalleryState {
loadExploreTags: () => Promise<void>; loadExploreTags: () => Promise<void>;
showVisualCluster: (imageIds: number[]) => Promise<void>; showVisualCluster: (imageIds: number[]) => Promise<void>;
searchForTag: (tag: string) => void; searchForTag: (tag: string) => void;
loadSimilarImages: (imageId: number, folderId?: number | null, reset?: boolean, 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) => 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; setSimilarScope: (scope: SimilarScope) => void;
suggestImageTags: (imageId: number) => Promise<string[]>; suggestImageTags: (imageId: number) => Promise<string[]>;
loadCaptionModelStatus: () => Promise<void>; loadCaptionModelStatus: () => Promise<void>;
@@ -835,6 +839,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
collectionTitle: null, collectionTitle: null,
similarSourceImageId: null, similarSourceImageId: null,
similarSourceFolderId: null, similarSourceFolderId: null,
similarSourceAlbumId: null,
similarHasMore: false, similarHasMore: false,
similarScope: "all_media", similarScope: "all_media",
similarFolderId: null, similarFolderId: null,
@@ -1021,7 +1026,10 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
}, },
selectFolder: (folderId) => { 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); void get().loadImages(true);
}, },
@@ -1218,9 +1226,10 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
} }
return; return;
} }
const pageAlbumId = get().similarScope === "current_album" ? get().similarSourceAlbumId : null;
if (collectionTitle === "Similar Images" && similarSourceImageId !== null) { if (collectionTitle === "Similar Images" && similarSourceImageId !== null) {
if (!similarHasMore) return; if (!similarHasMore) return;
await get().loadSimilarImages(similarSourceImageId, similarFolderId, false, get().similarSourceFolderId ?? null); await get().loadSimilarImages(similarSourceImageId, similarFolderId, false, get().similarSourceFolderId ?? null, pageAlbumId);
return; return;
} }
if (collectionTitle === "Region Search Results" && similarSourceImageId !== null && similarCrop !== null) { 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_y: similarCrop.y,
crop_w: similarCrop.w, crop_w: similarCrop.w,
crop_h: similarCrop.h, crop_h: similarCrop.h,
folder_id: similarFolderId, folder_id: pageAlbumId !== null ? null : similarFolderId,
album_id: pageAlbumId,
offset: loadedCount, offset: loadedCount,
limit: PAGE_SIZE, limit: PAGE_SIZE,
}, },
@@ -1336,8 +1346,10 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
closeImage: () => set({ selectedImage: null }), closeImage: () => set({ selectedImage: null }),
setView: (activeView) => { 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") { 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); void get().loadImages(true);
return; return;
} }
@@ -1355,7 +1367,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
return; return;
} }
} }
set({ activeView }); set({ activeView, similarSourceAlbumId: null, similarScope: similarScopeReset });
}, },
setExploreMode: (exploreMode) => set({ exploreMode }), setExploreMode: (exploreMode) => set({ exploreMode }),
@@ -1452,10 +1464,12 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
void get().loadImages(true); 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 requestToken = ++galleryRequestToken;
const offset = reset ? 0 : get().loadedCount; 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) => ({ set((state) => ({
images: reset ? [] : state.images, images: reset ? [] : state.images,
loadedCount: reset ? 0 : state.loadedCount, loadedCount: reset ? 0 : state.loadedCount,
@@ -1464,7 +1478,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
imageLoadError: null, imageLoadError: null,
similarSourceImageId: imageId, similarSourceImageId: imageId,
similarSourceFolderId: sourceFolderId, similarSourceFolderId: sourceFolderId,
similarFolderId: folderId ?? null, similarFolderId: queryFolderId,
similarScope, similarScope,
// Force the gallery grid so results (and the bulk bar) render regardless // Force the gallery grid so results (and the bulk bar) render regardless
// of which view the search was launched from. // 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", { const result = await invoke<SimilarImagesPage>("find_similar_images", {
params: { params: {
image_id: imageId, image_id: imageId,
folder_id: folderId ?? null, folder_id: queryFolderId,
album_id: albumId,
offset, offset,
limit: PAGE_SIZE, limit: PAGE_SIZE,
threshold: SIMILAR_DISTANCE_THRESHOLD, threshold: SIMILAR_DISTANCE_THRESHOLD,
@@ -1500,7 +1515,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
similarSourceImageId: imageId, similarSourceImageId: imageId,
similarSourceFolderId: sourceFolderId, similarSourceFolderId: sourceFolderId,
similarHasMore: result.has_more, similarHasMore: result.has_more,
similarFolderId: folderId ?? null, similarFolderId: queryFolderId,
similarScope, similarScope,
selectedImage: reset ? null : state.selectedImage, selectedImage: reset ? null : state.selectedImage,
}; };
@@ -1518,16 +1533,17 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
similarSourceImageId: imageId, similarSourceImageId: imageId,
similarSourceFolderId: sourceFolderId, similarSourceFolderId: sourceFolderId,
similarHasMore: false, similarHasMore: false,
similarFolderId: folderId ?? null, similarFolderId: queryFolderId,
similarScope, similarScope,
selectedImage: null, 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 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) => ({ set((state) => ({
images: [], images: [],
loadedCount: 0, loadedCount: 0,
@@ -1536,7 +1552,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
imageLoadError: null, imageLoadError: null,
similarSourceImageId: imageId, similarSourceImageId: imageId,
similarSourceFolderId: sourceFolderId, similarSourceFolderId: sourceFolderId,
similarFolderId: folderId ?? null, similarFolderId: queryFolderId,
similarCrop: crop, similarCrop: crop,
similarScope, similarScope,
// Force the gallery grid so results (and the bulk bar) render regardless // 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_y: crop.y,
crop_w: crop.w, crop_w: crop.w,
crop_h: crop.h, crop_h: crop.h,
folder_id: folderId ?? null, folder_id: queryFolderId,
album_id: albumId,
offset: 0, offset: 0,
limit: PAGE_SIZE, limit: PAGE_SIZE,
}, },
@@ -1574,7 +1591,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
similarSourceImageId: imageId, similarSourceImageId: imageId,
similarSourceFolderId: sourceFolderId, similarSourceFolderId: sourceFolderId,
similarHasMore: result.has_more, similarHasMore: result.has_more,
similarFolderId: folderId ?? null, similarFolderId: queryFolderId,
similarCrop: crop, similarCrop: crop,
similarScope, similarScope,
}); });
@@ -1591,22 +1608,51 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
similarSourceImageId: imageId, similarSourceImageId: imageId,
similarSourceFolderId: sourceFolderId, similarSourceFolderId: sourceFolderId,
similarHasMore: false, similarHasMore: false,
similarFolderId: folderId ?? null, similarFolderId: queryFolderId,
similarCrop: crop,
similarScope, similarScope,
selectedImage: null, 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) => { setSimilarScope: (similarScope) => {
set({ similarScope }); set({ similarScope });
const { similarSourceImageId, similarSourceFolderId, selectedFolderId, collectionTitle, similarCrop } = get(); const { similarSourceImageId, similarSourceFolderId, similarSourceAlbumId, selectedFolderId, collectionTitle, similarCrop } = get();
if (similarSourceImageId === null) return; if (similarSourceImageId === null) return;
const albumId = similarScope === "current_album" ? similarSourceAlbumId : null;
const folderId = similarScope === "current_folder" ? (similarSourceFolderId ?? selectedFolderId) : null; const folderId = similarScope === "current_folder" ? (similarSourceFolderId ?? selectedFolderId) : null;
if (collectionTitle === "Region Search Results" && similarCrop !== null) { if (collectionTitle === "Region Search Results" && similarCrop !== null) {
void get().loadSimilarByRegion(similarSourceImageId, similarCrop, folderId, similarSourceFolderId); void get().loadSimilarByRegion(similarSourceImageId, similarCrop, folderId, similarSourceFolderId, albumId);
} else { } 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, imageLoadError: null,
similarSourceImageId: null, similarSourceImageId: null,
similarSourceFolderId: null, similarSourceFolderId: null,
similarSourceAlbumId: albumId,
similarScope: "current_album",
similarHasMore: false, similarHasMore: false,
similarFolderId: null, similarFolderId: null,
similarCrop: null, similarCrop: null,