feat(search): filter tag results by colour
Pass the active colour filter through tag searches and apply the existing palette match inside the tag query and count paths. Update the UI Lab mock backend so colour filtering behaves the same way when testing tag search results.
This commit is contained in:
@@ -162,6 +162,8 @@ pub struct TagSearchParams {
|
|||||||
pub media_kind: Option<String>,
|
pub media_kind: Option<String>,
|
||||||
pub favorites_only: Option<bool>,
|
pub favorites_only: Option<bool>,
|
||||||
pub rating_min: Option<i64>,
|
pub rating_min: Option<i64>,
|
||||||
|
/// Optional `[r, g, b]` color filter applied within the tag result set.
|
||||||
|
pub color: Option<[u8; 3]>,
|
||||||
pub limit: Option<usize>,
|
pub limit: Option<usize>,
|
||||||
pub offset: Option<usize>,
|
pub offset: Option<usize>,
|
||||||
}
|
}
|
||||||
@@ -957,6 +959,7 @@ pub async fn search_images_by_tag(
|
|||||||
let conn = db.get().map_err(|e| e.to_string())?;
|
let conn = db.get().map_err(|e| e.to_string())?;
|
||||||
let limit = params.limit.unwrap_or(64);
|
let limit = params.limit.unwrap_or(64);
|
||||||
let offset = params.offset.unwrap_or(0);
|
let offset = params.offset.unwrap_or(0);
|
||||||
|
let color = params.color.map(|[r, g, b]| (r, g, b));
|
||||||
let (images, total) = db::search_images_by_tag(
|
let (images, total) = db::search_images_by_tag(
|
||||||
&conn,
|
&conn,
|
||||||
¶ms.query,
|
¶ms.query,
|
||||||
@@ -964,6 +967,7 @@ pub async fn search_images_by_tag(
|
|||||||
params.media_kind.as_deref(),
|
params.media_kind.as_deref(),
|
||||||
params.favorites_only.unwrap_or(false),
|
params.favorites_only.unwrap_or(false),
|
||||||
params.rating_min.unwrap_or(0),
|
params.rating_min.unwrap_or(0),
|
||||||
|
color,
|
||||||
limit,
|
limit,
|
||||||
offset,
|
offset,
|
||||||
)
|
)
|
||||||
|
|||||||
+32
-3
@@ -2244,6 +2244,7 @@ pub fn search_images_by_tag(
|
|||||||
media_kind: Option<&str>,
|
media_kind: Option<&str>,
|
||||||
favorites_only: bool,
|
favorites_only: bool,
|
||||||
rating_min: i64,
|
rating_min: i64,
|
||||||
|
color: Option<(u8, u8, u8)>,
|
||||||
limit: usize,
|
limit: usize,
|
||||||
offset: usize,
|
offset: usize,
|
||||||
) -> Result<(Vec<ImageRecord>, usize)> {
|
) -> Result<(Vec<ImageRecord>, usize)> {
|
||||||
@@ -2252,6 +2253,10 @@ pub fn search_images_by_tag(
|
|||||||
return Ok((Vec::new(), 0));
|
return Ok((Vec::new(), 0));
|
||||||
}
|
}
|
||||||
let favorites_flag = i64::from(favorites_only);
|
let favorites_flag = i64::from(favorites_only);
|
||||||
|
let (color_flag, qr, qg, qb) = match color {
|
||||||
|
Some((r, g, b)) => (1i64, r as i64, g as i64, b as i64),
|
||||||
|
None => (0, 0, 0, 0),
|
||||||
|
};
|
||||||
|
|
||||||
// Total count (for pagination)
|
// Total count (for pagination)
|
||||||
let total: usize = conn.query_row(
|
let total: usize = conn.query_row(
|
||||||
@@ -2262,13 +2267,25 @@ pub fn search_images_by_tag(
|
|||||||
AND (?2 IS NULL OR i.media_kind = ?2)
|
AND (?2 IS NULL OR i.media_kind = ?2)
|
||||||
AND (?3 = 0 OR i.favorite = 1)
|
AND (?3 = 0 OR i.favorite = 1)
|
||||||
AND i.rating >= ?4
|
AND i.rating >= ?4
|
||||||
AND LOWER(TRIM(t.tag)) = ?5",
|
AND LOWER(TRIM(t.tag)) = ?5
|
||||||
|
AND (?6 = 0 OR EXISTS (
|
||||||
|
SELECT 1 FROM image_colors c
|
||||||
|
WHERE c.image_id = i.id
|
||||||
|
AND c.weight >= ?11
|
||||||
|
AND ((c.r - ?7)*(c.r - ?7) + (c.g - ?8)*(c.g - ?8) + (c.b - ?9)*(c.b - ?9)) <= ?10
|
||||||
|
))",
|
||||||
params![
|
params![
|
||||||
folder_id,
|
folder_id,
|
||||||
media_kind,
|
media_kind,
|
||||||
favorites_flag,
|
favorites_flag,
|
||||||
rating_min,
|
rating_min,
|
||||||
normalized_query
|
normalized_query,
|
||||||
|
color_flag,
|
||||||
|
qr,
|
||||||
|
qg,
|
||||||
|
qb,
|
||||||
|
crate::color::MATCH_DISTANCE_SQ,
|
||||||
|
crate::color::MATCH_MIN_WEIGHT
|
||||||
],
|
],
|
||||||
|row| row.get::<_, i64>(0),
|
|row| row.get::<_, i64>(0),
|
||||||
)? as usize;
|
)? as usize;
|
||||||
@@ -2282,8 +2299,14 @@ pub fn search_images_by_tag(
|
|||||||
AND (?3 = 0 OR i.favorite = 1)
|
AND (?3 = 0 OR i.favorite = 1)
|
||||||
AND i.rating >= ?4
|
AND i.rating >= ?4
|
||||||
AND LOWER(TRIM(t.tag)) = ?5
|
AND LOWER(TRIM(t.tag)) = ?5
|
||||||
|
AND (?6 = 0 OR EXISTS (
|
||||||
|
SELECT 1 FROM image_colors c
|
||||||
|
WHERE c.image_id = i.id
|
||||||
|
AND c.weight >= ?11
|
||||||
|
AND ((c.r - ?7)*(c.r - ?7) + (c.g - ?8)*(c.g - ?8) + (c.b - ?9)*(c.b - ?9)) <= ?10
|
||||||
|
))
|
||||||
ORDER BY i.rating DESC, i.modified_at DESC NULLS LAST, i.filename ASC
|
ORDER BY i.rating DESC, i.modified_at DESC NULLS LAST, i.filename ASC
|
||||||
LIMIT ?6 OFFSET ?7",
|
LIMIT ?12 OFFSET ?13",
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
let image_ids = stmt
|
let image_ids = stmt
|
||||||
@@ -2294,6 +2317,12 @@ pub fn search_images_by_tag(
|
|||||||
favorites_flag,
|
favorites_flag,
|
||||||
rating_min,
|
rating_min,
|
||||||
normalized_query,
|
normalized_query,
|
||||||
|
color_flag,
|
||||||
|
qr,
|
||||||
|
qg,
|
||||||
|
qb,
|
||||||
|
crate::color::MATCH_DISTANCE_SQ,
|
||||||
|
crate::color::MATCH_MIN_WEIGHT,
|
||||||
limit as i64,
|
limit as i64,
|
||||||
offset as i64
|
offset as i64
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -9,6 +9,36 @@ let nextAlbumId = 100;
|
|||||||
let nextTagId = 10_000;
|
let nextTagId = 10_000;
|
||||||
|
|
||||||
type AnyPayload = Record<string, any> | undefined;
|
type AnyPayload = Record<string, any> | undefined;
|
||||||
|
type Rgb = [number, number, number];
|
||||||
|
|
||||||
|
const mockPalette: Rgb[] = [
|
||||||
|
[59, 125, 216],
|
||||||
|
[31, 182, 166],
|
||||||
|
[76, 175, 80],
|
||||||
|
[242, 207, 46],
|
||||||
|
[232, 134, 46],
|
||||||
|
[226, 59, 59],
|
||||||
|
[139, 92, 246],
|
||||||
|
[236, 72, 153],
|
||||||
|
[139, 90, 43],
|
||||||
|
[26, 26, 26],
|
||||||
|
[245, 245, 245],
|
||||||
|
[154, 160, 166],
|
||||||
|
];
|
||||||
|
|
||||||
|
function isRgb(value: unknown): value is Rgb {
|
||||||
|
return Array.isArray(value) && value.length === 3 && value.every((entry) => typeof entry === "number");
|
||||||
|
}
|
||||||
|
|
||||||
|
function colorDistanceSq(left: Rgb, right: Rgb): number {
|
||||||
|
return (left[0] - right[0]) ** 2 + (left[1] - right[1]) ** 2 + (left[2] - right[2]) ** 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesMockColor(image: ImageRecord, color: Rgb): boolean {
|
||||||
|
const primary = mockPalette[(image.id - 1) % mockPalette.length];
|
||||||
|
const secondary = mockPalette[(image.folder_id + image.rating) % mockPalette.length];
|
||||||
|
return colorDistanceSq(primary, color) <= 4_900 || colorDistanceSq(secondary, color) <= 4_900;
|
||||||
|
}
|
||||||
|
|
||||||
function params(payload: unknown): Record<string, any> {
|
function params(payload: unknown): Record<string, any> {
|
||||||
if (!payload || typeof payload !== "object") return {};
|
if (!payload || typeof payload !== "object") return {};
|
||||||
@@ -28,6 +58,7 @@ function page<T>(items: T[], offset = 0, limit = 200) {
|
|||||||
function filterImages(input: ImageRecord[], payload: unknown): ImageRecord[] {
|
function filterImages(input: ImageRecord[], payload: unknown): ImageRecord[] {
|
||||||
const p = params(payload);
|
const p = params(payload);
|
||||||
const query = String(p.search ?? p.query ?? "").trim().toLowerCase();
|
const query = String(p.search ?? p.query ?? "").trim().toLowerCase();
|
||||||
|
const color = isRgb(p.color) ? p.color : null;
|
||||||
return input.filter((image) => {
|
return input.filter((image) => {
|
||||||
if (p.folder_id !== undefined && p.folder_id !== null && image.folder_id !== p.folder_id) return false;
|
if (p.folder_id !== undefined && p.folder_id !== null && image.folder_id !== p.folder_id) return false;
|
||||||
if (p.media_kind && image.media_kind !== p.media_kind) return false;
|
if (p.media_kind && image.media_kind !== p.media_kind) return false;
|
||||||
@@ -36,6 +67,7 @@ function filterImages(input: ImageRecord[], payload: unknown): ImageRecord[] {
|
|||||||
if (p.embedding_failed_only && image.embedding_status !== "failed") return false;
|
if (p.embedding_failed_only && image.embedding_status !== "failed") return false;
|
||||||
if (p.tagging_failed_only && !image.ai_tagger_error) return false;
|
if (p.tagging_failed_only && !image.ai_tagger_error) return false;
|
||||||
if (query && !image.filename.toLowerCase().includes(query)) return false;
|
if (query && !image.filename.toLowerCase().includes(query)) return false;
|
||||||
|
if (color && !matchesMockColor(image, color)) return false;
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1161,6 +1161,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
|||||||
media_kind: mediaFilter === "all" ? null : mediaFilter,
|
media_kind: mediaFilter === "all" ? null : mediaFilter,
|
||||||
favorites_only: favoritesOnly,
|
favorites_only: favoritesOnly,
|
||||||
rating_min: minimumRating > 0 ? minimumRating : null,
|
rating_min: minimumRating > 0 ? minimumRating : null,
|
||||||
|
color: colorFilter,
|
||||||
limit: PAGE_SIZE,
|
limit: PAGE_SIZE,
|
||||||
offset,
|
offset,
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user