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