feat: add color search and reusable tooltips
Add dominant-color palette extraction, storage, filtering, and startup backfill so the gallery and Timeline can be filtered from the toolbar color picker. Introduce a reusable tooltip component and migrate the color filter, update indicator, and gallery filename hover affordances to it.
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
//! Dominant-color palette extraction for color search.
|
||||
//!
|
||||
//! Colors are sampled from the already-generated thumbnail (small, fast) rather
|
||||
//! than the full image. We coarse-quantize pixels into an RGB histogram, then
|
||||
//! return the most populated bins as representative colors with their weight
|
||||
//! (fraction of sampled pixels). Search then filters images whose palette has a
|
||||
//! color within a distance threshold of the query color.
|
||||
|
||||
use image::RgbImage;
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct PaletteColor {
|
||||
pub r: u8,
|
||||
pub g: u8,
|
||||
pub b: u8,
|
||||
/// Fraction of sampled pixels (0.0–1.0) that fell in this color's bin.
|
||||
pub weight: f32,
|
||||
}
|
||||
|
||||
/// Bits kept per channel when binning. 4 bits → 16 levels/channel → 4096 bins:
|
||||
/// coarse enough to group near-identical shades, fine enough to separate hues.
|
||||
const QUANT_BITS: u32 = 4;
|
||||
/// Cap on sampled pixels so very large frames stay cheap; thumbnails are tiny so
|
||||
/// this rarely bites, but the backfill may read arbitrary thumbnail sizes.
|
||||
const MAX_SAMPLES: usize = 50_000;
|
||||
|
||||
/// Extract up to `k` dominant colors from an RGB image, most-common first.
|
||||
pub fn extract_palette(img: &RgbImage, k: usize) -> Vec<PaletteColor> {
|
||||
let pixels = img.as_raw();
|
||||
let pixel_count = pixels.len() / 3;
|
||||
if pixel_count == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let step = (pixel_count / MAX_SAMPLES).max(1);
|
||||
let shift = 8 - QUANT_BITS;
|
||||
|
||||
// bin key → (sum_r, sum_g, sum_b, count); summing lets us return the bin's
|
||||
// average color rather than the quantized corner.
|
||||
let mut bins: HashMap<u16, (u64, u64, u64, u64)> = HashMap::new();
|
||||
let mut total: u64 = 0;
|
||||
for pixel in pixels.chunks_exact(3).step_by(step) {
|
||||
let (r, g, b) = (pixel[0], pixel[1], pixel[2]);
|
||||
let key = (((r as u16) >> shift) << (QUANT_BITS * 2))
|
||||
| (((g as u16) >> shift) << QUANT_BITS)
|
||||
| ((b as u16) >> shift);
|
||||
let entry = bins.entry(key).or_insert((0, 0, 0, 0));
|
||||
entry.0 += r as u64;
|
||||
entry.1 += g as u64;
|
||||
entry.2 += b as u64;
|
||||
entry.3 += 1;
|
||||
total += 1;
|
||||
}
|
||||
if total == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut entries: Vec<(u64, u64, u64, u64)> = bins.into_values().collect();
|
||||
entries.sort_unstable_by_key(|entry| std::cmp::Reverse(entry.3));
|
||||
entries
|
||||
.into_iter()
|
||||
.take(k)
|
||||
.map(|(sum_r, sum_g, sum_b, count)| PaletteColor {
|
||||
r: (sum_r / count) as u8,
|
||||
g: (sum_g / count) as u8,
|
||||
b: (sum_b / count) as u8,
|
||||
weight: count as f32 / total as f32,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Decode a thumbnail file and extract its palette. Used by the backfill pass.
|
||||
pub fn extract_palette_from_file(thumbnail_path: &Path, k: usize) -> Option<Vec<PaletteColor>> {
|
||||
let img = image::ImageReader::open(thumbnail_path)
|
||||
.ok()?
|
||||
.decode()
|
||||
.ok()?;
|
||||
Some(extract_palette(&img.into_rgb8(), k))
|
||||
}
|
||||
|
||||
/// Number of palette colors stored per image.
|
||||
pub const PALETTE_SIZE: usize = 5;
|
||||
|
||||
/// Max squared RGB distance for a palette color to count as matching a query
|
||||
/// color (~70 units in RGB space). Tunable feel/precision of color search.
|
||||
pub const MATCH_DISTANCE_SQ: i64 = 4900;
|
||||
|
||||
/// Minimum weight (fraction of pixels) a palette color must have to match, so
|
||||
/// trivial specks of a color don't trigger a match.
|
||||
pub const MATCH_MIN_WEIGHT: f64 = 0.05;
|
||||
+35
-20
@@ -42,6 +42,9 @@ pub struct GetImagesParams {
|
||||
pub rating_min: Option<i64>,
|
||||
pub embedding_failed_only: Option<bool>,
|
||||
pub tagging_failed_only: Option<bool>,
|
||||
/// Optional `[r, g, b]` color filter — matches images whose palette contains
|
||||
/// a prominent color near this one.
|
||||
pub color: Option<[u8; 3]>,
|
||||
pub sort: Option<String>,
|
||||
pub offset: Option<i64>,
|
||||
pub limit: Option<i64>,
|
||||
@@ -564,6 +567,7 @@ pub async fn get_images(
|
||||
let rating_min = params.rating_min.unwrap_or(0);
|
||||
let embedding_failed_only = params.embedding_failed_only.unwrap_or(false);
|
||||
let tagging_failed_only = params.tagging_failed_only.unwrap_or(false);
|
||||
let color = params.color.map(|[r, g, b]| (r, g, b));
|
||||
|
||||
let total = db::count_images(
|
||||
&conn,
|
||||
@@ -574,6 +578,7 @@ pub async fn get_images(
|
||||
rating_min,
|
||||
embedding_failed_only,
|
||||
tagging_failed_only,
|
||||
color,
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
@@ -586,6 +591,7 @@ pub async fn get_images(
|
||||
rating_min,
|
||||
embedding_failed_only,
|
||||
tagging_failed_only,
|
||||
color,
|
||||
sort,
|
||||
offset,
|
||||
limit,
|
||||
@@ -768,23 +774,23 @@ pub async fn find_similar_by_region(
|
||||
.map_err(|e| e.to_string())?
|
||||
} else {
|
||||
match params.folder_id {
|
||||
Some(folder_id) => vector::search_image_ids_by_embedding_in_folder(
|
||||
&conn,
|
||||
&embedding,
|
||||
folder_id,
|
||||
Some(params.image_id),
|
||||
offset + limit + 1,
|
||||
)
|
||||
.map_err(|e| e.to_string())?,
|
||||
None => {
|
||||
// Fetch one extra candidate to compensate for the source image that
|
||||
// will be removed, so has_more is accurate and results span multiple pages.
|
||||
let mut ids =
|
||||
vector::search_image_ids_by_embedding(&conn, &embedding, offset + limit + 2)
|
||||
.map_err(|e| e.to_string())?;
|
||||
ids.retain(|&id| id != params.image_id);
|
||||
ids
|
||||
}
|
||||
Some(folder_id) => vector::search_image_ids_by_embedding_in_folder(
|
||||
&conn,
|
||||
&embedding,
|
||||
folder_id,
|
||||
Some(params.image_id),
|
||||
offset + limit + 1,
|
||||
)
|
||||
.map_err(|e| e.to_string())?,
|
||||
None => {
|
||||
// Fetch one extra candidate to compensate for the source image that
|
||||
// will be removed, so has_more is accurate and results span multiple pages.
|
||||
let mut ids =
|
||||
vector::search_image_ids_by_embedding(&conn, &embedding, offset + limit + 2)
|
||||
.map_err(|e| e.to_string())?;
|
||||
ids.retain(|&id| id != params.image_id);
|
||||
ids
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2268,7 +2274,10 @@ pub async fn list_albums(db: State<'_, DbState>) -> Result<Vec<Album>, String> {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn create_album(db: State<'_, DbState>, params: CreateAlbumParams) -> Result<Album, String> {
|
||||
pub async fn create_album(
|
||||
db: State<'_, DbState>,
|
||||
params: CreateAlbumParams,
|
||||
) -> Result<Album, String> {
|
||||
let conn = db.get().map_err(|e| e.to_string())?;
|
||||
let name = params.name.trim();
|
||||
if name.is_empty() {
|
||||
@@ -2294,13 +2303,19 @@ pub async fn delete_album(db: State<'_, DbState>, params: DeleteAlbumParams) ->
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn reorder_albums(db: State<'_, DbState>, params: ReorderAlbumsParams) -> Result<(), String> {
|
||||
pub async fn reorder_albums(
|
||||
db: State<'_, DbState>,
|
||||
params: ReorderAlbumsParams,
|
||||
) -> Result<(), String> {
|
||||
let conn = db.get().map_err(|e| e.to_string())?;
|
||||
db::reorder_albums(&conn, ¶ms.album_ids).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_albums(db: State<'_, DbState>, params: DeleteAlbumsParams) -> Result<(), String> {
|
||||
pub async fn delete_albums(
|
||||
db: State<'_, DbState>,
|
||||
params: DeleteAlbumsParams,
|
||||
) -> Result<(), String> {
|
||||
let conn = db.get().map_err(|e| e.to_string())?;
|
||||
db::delete_albums(&conn, ¶ms.album_ids).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
+106
-8
@@ -321,6 +321,17 @@ pub fn migrate(conn: &Connection) -> Result<()> {
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_album_images_album ON album_images(album_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_album_images_image ON album_images(image_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS image_colors (
|
||||
image_id INTEGER NOT NULL REFERENCES images(id) ON DELETE CASCADE,
|
||||
idx INTEGER NOT NULL,
|
||||
r INTEGER NOT NULL,
|
||||
g INTEGER NOT NULL,
|
||||
b INTEGER NOT NULL,
|
||||
weight REAL NOT NULL,
|
||||
PRIMARY KEY (image_id, idx)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_image_colors_image ON image_colors(image_id);
|
||||
",
|
||||
)?;
|
||||
|
||||
@@ -1741,11 +1752,7 @@ pub fn add_images_to_album(conn: &Connection, album_id: i64, image_ids: &[i64])
|
||||
Ok(added)
|
||||
}
|
||||
|
||||
pub fn remove_images_from_album(
|
||||
conn: &Connection,
|
||||
album_id: i64,
|
||||
image_ids: &[i64],
|
||||
) -> Result<()> {
|
||||
pub fn remove_images_from_album(conn: &Connection, album_id: i64, image_ids: &[i64]) -> Result<()> {
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
for image_id in image_ids {
|
||||
tx.execute(
|
||||
@@ -1872,6 +1879,63 @@ pub fn bulk_remove_tag_by_name(conn: &Connection, image_ids: &[i64], tag: &str)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Color palettes (color search) ──────────────────────────────────────────────
|
||||
|
||||
/// Replace an image's stored color palette. `colors` is `(r, g, b, weight)`.
|
||||
pub fn replace_image_colors(
|
||||
conn: &Connection,
|
||||
image_id: i64,
|
||||
colors: &[(u8, u8, u8, f32)],
|
||||
) -> Result<()> {
|
||||
conn.execute("DELETE FROM image_colors WHERE image_id = ?1", [image_id])?;
|
||||
for (idx, (r, g, b, weight)) in colors.iter().enumerate() {
|
||||
conn.execute(
|
||||
"INSERT INTO image_colors (image_id, idx, r, g, b, weight)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
params![
|
||||
image_id,
|
||||
idx as i64,
|
||||
*r as i64,
|
||||
*g as i64,
|
||||
*b as i64,
|
||||
*weight as f64
|
||||
],
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Images (with thumbnails) that have no stored palette yet — the backfill set.
|
||||
/// Returns `(image_id, thumbnail_path)`.
|
||||
pub fn get_images_missing_colors(conn: &Connection, limit: i64) -> Result<Vec<(i64, String)>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT i.id, i.thumbnail_path
|
||||
FROM images i
|
||||
WHERE i.media_kind = 'image'
|
||||
AND i.thumbnail_path IS NOT NULL
|
||||
AND NOT EXISTS (SELECT 1 FROM image_colors c WHERE c.image_id = i.id)
|
||||
LIMIT ?1",
|
||||
)?;
|
||||
let rows = stmt.query_map([limit], |row| {
|
||||
Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
|
||||
})?;
|
||||
Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
|
||||
}
|
||||
|
||||
/// Count of images still awaiting palette extraction (for backfill progress).
|
||||
pub fn count_images_missing_colors(conn: &Connection) -> Result<i64> {
|
||||
let count = conn.query_row(
|
||||
"SELECT COUNT(*)
|
||||
FROM images i
|
||||
WHERE i.media_kind = 'image'
|
||||
AND i.thumbnail_path IS NOT NULL
|
||||
AND NOT EXISTS (SELECT 1 FROM image_colors c WHERE c.image_id = i.id)",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
pub fn repair_deferred_embedding_jobs(conn: &Connection) -> Result<usize> {
|
||||
let pattern = "No thumbnail available yet for%";
|
||||
let repaired = conn.execute(
|
||||
@@ -1999,6 +2063,7 @@ pub fn get_images(
|
||||
rating_min: i64,
|
||||
embedding_failed_only: bool,
|
||||
tagging_failed_only: bool,
|
||||
color: Option<(u8, u8, u8)>,
|
||||
sort: &str,
|
||||
offset: i64,
|
||||
limit: i64,
|
||||
@@ -2023,6 +2088,10 @@ pub fn get_images(
|
||||
let favorites_flag = i64::from(favorites_only);
|
||||
let embedding_failed_flag = i64::from(embedding_failed_only);
|
||||
let tagging_failed_flag = i64::from(tagging_failed_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),
|
||||
};
|
||||
let sql = format!(
|
||||
"SELECT id, folder_id, path, filename, thumbnail_path, width, height, file_size, created_at, modified_at, taken_at, mime_type,
|
||||
media_kind, duration_ms, video_codec, audio_codec, metadata_updated_at, metadata_error,
|
||||
@@ -2037,6 +2106,12 @@ pub fn get_images(
|
||||
AND rating >= ?5
|
||||
AND (?6 = 0 OR embedding_status = 'failed')
|
||||
AND (?7 = 0 OR ai_tagger_error IS NOT NULL)
|
||||
AND (?10 = 0 OR EXISTS (
|
||||
SELECT 1 FROM image_colors c
|
||||
WHERE c.image_id = images.id
|
||||
AND c.weight >= ?15
|
||||
AND ((c.r - ?11)*(c.r - ?11) + (c.g - ?12)*(c.g - ?12) + (c.b - ?13)*(c.b - ?13)) <= ?14
|
||||
))
|
||||
ORDER BY {order}
|
||||
LIMIT ?8 OFFSET ?9"
|
||||
);
|
||||
@@ -2051,7 +2126,13 @@ pub fn get_images(
|
||||
embedding_failed_flag,
|
||||
tagging_failed_flag,
|
||||
limit,
|
||||
offset
|
||||
offset,
|
||||
color_flag,
|
||||
qr,
|
||||
qg,
|
||||
qb,
|
||||
crate::color::MATCH_DISTANCE_SQ,
|
||||
crate::color::MATCH_MIN_WEIGHT
|
||||
],
|
||||
map_image_row,
|
||||
)?;
|
||||
@@ -2068,12 +2149,17 @@ pub fn count_images(
|
||||
rating_min: i64,
|
||||
embedding_failed_only: bool,
|
||||
tagging_failed_only: bool,
|
||||
color: Option<(u8, u8, u8)>,
|
||||
) -> Result<i64> {
|
||||
let search_pattern = search.map(|value| format!("%{value}%"));
|
||||
|
||||
let favorites_flag = i64::from(favorites_only);
|
||||
let embedding_failed_flag = i64::from(embedding_failed_only);
|
||||
let tagging_failed_flag = i64::from(tagging_failed_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),
|
||||
};
|
||||
let count = conn.query_row(
|
||||
"SELECT COUNT(*) FROM images
|
||||
WHERE (?1 IS NULL OR folder_id = ?1)
|
||||
@@ -2082,7 +2168,13 @@ pub fn count_images(
|
||||
AND (?4 = 0 OR favorite = 1)
|
||||
AND rating >= ?5
|
||||
AND (?6 = 0 OR embedding_status = 'failed')
|
||||
AND (?7 = 0 OR ai_tagger_error IS NOT NULL)",
|
||||
AND (?7 = 0 OR ai_tagger_error IS NOT NULL)
|
||||
AND (?8 = 0 OR EXISTS (
|
||||
SELECT 1 FROM image_colors c
|
||||
WHERE c.image_id = images.id
|
||||
AND c.weight >= ?13
|
||||
AND ((c.r - ?9)*(c.r - ?9) + (c.g - ?10)*(c.g - ?10) + (c.b - ?11)*(c.b - ?11)) <= ?12
|
||||
))",
|
||||
params![
|
||||
folder_id,
|
||||
search_pattern,
|
||||
@@ -2090,7 +2182,13 @@ pub fn count_images(
|
||||
favorites_flag,
|
||||
rating_min,
|
||||
embedding_failed_flag,
|
||||
tagging_failed_flag
|
||||
tagging_failed_flag,
|
||||
color_flag,
|
||||
qr,
|
||||
qg,
|
||||
qb,
|
||||
crate::color::MATCH_DISTANCE_SQ,
|
||||
crate::color::MATCH_MIN_WEIGHT
|
||||
],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
|
||||
@@ -190,6 +190,13 @@ pub struct MediaUpdateBatch {
|
||||
pub images: Vec<ImageRecord>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
pub struct ColorBackfillProgress {
|
||||
pub processed: i64,
|
||||
pub total: i64,
|
||||
pub done: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
pub struct MediaJobProgressEvent {
|
||||
pub progress: Vec<FolderJobProgress>,
|
||||
@@ -737,6 +744,13 @@ fn persist_thumbnail_results(
|
||||
width,
|
||||
height,
|
||||
)?);
|
||||
|
||||
// Store the dominant-color palette sampled during resizing.
|
||||
if let Some(thumb) = &generated {
|
||||
if !thumb.palette.is_empty() {
|
||||
db::replace_image_colors(&tx, image_id, &thumb.palette)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tx.commit()?;
|
||||
@@ -767,6 +781,76 @@ fn is_avif_path(path: &Path) -> bool {
|
||||
.is_some_and(|ext| ext.eq_ignore_ascii_case("avif"))
|
||||
}
|
||||
|
||||
/// One-shot background pass that samples a color palette from the (already
|
||||
/// generated) thumbnails of images indexed before color search existed. New
|
||||
/// images get their palette during thumbnail generation, so this only fills the
|
||||
/// historical gap. Emits `color-backfill-progress` so the UI can show a count.
|
||||
pub fn start_color_backfill(app: AppHandle, pool: DbPool) {
|
||||
std::thread::spawn(move || {
|
||||
let total = match pool.get() {
|
||||
Ok(conn) => db::count_images_missing_colors(&conn).unwrap_or(0),
|
||||
Err(_) => return,
|
||||
};
|
||||
if total == 0 {
|
||||
return;
|
||||
}
|
||||
log::info!("Color backfill: sampling palettes for {total} images.");
|
||||
|
||||
let mut processed: i64 = 0;
|
||||
loop {
|
||||
let batch = match pool.get() {
|
||||
Ok(conn) => db::get_images_missing_colors(&conn, 64).unwrap_or_default(),
|
||||
Err(_) => break,
|
||||
};
|
||||
if batch.is_empty() {
|
||||
break;
|
||||
}
|
||||
|
||||
for (image_id, thumbnail_path) in batch {
|
||||
let palette = crate::color::extract_palette_from_file(
|
||||
Path::new(&thumbnail_path),
|
||||
crate::color::PALETTE_SIZE,
|
||||
);
|
||||
let colors: Vec<(u8, u8, u8, f32)> = match palette {
|
||||
Some(colors) if !colors.is_empty() => colors
|
||||
.into_iter()
|
||||
.map(|c| (c.r, c.g, c.b, c.weight))
|
||||
.collect(),
|
||||
// Unreadable thumbnail: store a zero-weight sentinel so the
|
||||
// image isn't reprocessed forever (it just won't match).
|
||||
_ => vec![(0, 0, 0, 0.0)],
|
||||
};
|
||||
let _ = with_db_write_lock(|| {
|
||||
let conn = pool.get()?;
|
||||
db::replace_image_colors(&conn, image_id, &colors)
|
||||
});
|
||||
processed += 1;
|
||||
}
|
||||
|
||||
let _ = app.emit(
|
||||
"color-backfill-progress",
|
||||
ColorBackfillProgress {
|
||||
processed,
|
||||
total,
|
||||
done: false,
|
||||
},
|
||||
);
|
||||
// Yield so the backfill stays in the background under active use.
|
||||
std::thread::sleep(Duration::from_millis(15));
|
||||
}
|
||||
|
||||
log::info!("Color backfill complete: {processed} images sampled.");
|
||||
let _ = app.emit(
|
||||
"color-backfill-progress",
|
||||
ColorBackfillProgress {
|
||||
processed,
|
||||
total,
|
||||
done: true,
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Returns `Ok(true)` if a batch was claimed and processed, `Ok(false)` if
|
||||
/// the queue was empty.
|
||||
fn process_metadata_batch(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
mod captioner;
|
||||
mod color;
|
||||
mod commands;
|
||||
mod db;
|
||||
mod embedder;
|
||||
@@ -122,6 +123,8 @@ pub fn run() {
|
||||
// Caption worker disabled — UI removed; keeping backend code intact for future use.
|
||||
// indexer::start_caption_worker(app.handle().clone(), pool.clone(), app_dir.clone());
|
||||
indexer::start_tagging_worker(app.handle().clone(), pool.clone(), app_dir.clone());
|
||||
// Backfill color palettes for images indexed before color search existed.
|
||||
indexer::start_color_backfill(app.handle().clone(), pool.clone());
|
||||
|
||||
let watcher_handle = indexer::start_watcher(app.handle().clone(), pool.clone(), thumb_dir.clone());
|
||||
|
||||
|
||||
@@ -12,6 +12,9 @@ pub struct GeneratedThumbnail {
|
||||
pub path: PathBuf,
|
||||
pub width: Option<i64>,
|
||||
pub height: Option<i64>,
|
||||
/// Dominant-color palette `(r, g, b, weight)` sampled while resizing. Empty
|
||||
/// when the thumbnail already existed (the color backfill handles those).
|
||||
pub palette: Vec<(u8, u8, u8, f32)>,
|
||||
}
|
||||
|
||||
pub fn generate_image_thumbnail(image_path: &Path, cache_dir: &Path) -> Result<GeneratedThumbnail> {
|
||||
@@ -28,6 +31,7 @@ pub fn generate_image_thumbnail(image_path: &Path, cache_dir: &Path) -> Result<G
|
||||
path: out_path,
|
||||
width: original_dimensions.0,
|
||||
height: original_dimensions.1,
|
||||
palette: Vec::new(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -47,6 +51,12 @@ pub fn generate_image_thumbnail(image_path: &Path, cache_dir: &Path) -> Result<G
|
||||
let thumb = image::RgbImage::from_raw(dst_width, dst_height, dst.buffer().to_vec())
|
||||
.ok_or_else(|| anyhow!("failed to construct resized thumbnail buffer"))?;
|
||||
|
||||
// Sample the dominant-color palette from the resized buffer before encoding.
|
||||
let palette = crate::color::extract_palette(&thumb, crate::color::PALETTE_SIZE)
|
||||
.into_iter()
|
||||
.map(|color| (color.r, color.g, color.b, color.weight))
|
||||
.collect();
|
||||
|
||||
if let Some(parent) = out_path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
@@ -58,6 +68,7 @@ pub fn generate_image_thumbnail(image_path: &Path, cache_dir: &Path) -> Result<G
|
||||
path: out_path,
|
||||
width: original_dimensions.0,
|
||||
height: original_dimensions.1,
|
||||
palette,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -166,6 +177,7 @@ pub fn generate_video_thumbnail(
|
||||
path: out_path,
|
||||
width: None,
|
||||
height: None,
|
||||
palette: Vec::new(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -231,6 +243,7 @@ pub fn generate_video_thumbnail(
|
||||
path: out_path,
|
||||
width: None,
|
||||
height: None,
|
||||
palette: Vec::new(),
|
||||
});
|
||||
}
|
||||
last_error = String::from_utf8_lossy(&output.stderr).to_string();
|
||||
@@ -255,6 +268,7 @@ pub fn generate_avif_thumbnail(
|
||||
path: out_path,
|
||||
width: original_dimensions.0,
|
||||
height: original_dimensions.1,
|
||||
palette: Vec::new(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -282,10 +296,19 @@ pub fn generate_avif_thumbnail(
|
||||
.output()?;
|
||||
|
||||
if output.status.success() && out_path.exists() {
|
||||
// AVIF is decoded by FFmpeg, so there's no in-memory RGB buffer here;
|
||||
// sample the palette by reading the JPEG thumbnail we just wrote.
|
||||
let palette =
|
||||
crate::color::extract_palette_from_file(&out_path, crate::color::PALETTE_SIZE)
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|color| (color.r, color.g, color.b, color.weight))
|
||||
.collect();
|
||||
return Ok(GeneratedThumbnail {
|
||||
path: out_path,
|
||||
width: original_dimensions.0,
|
||||
height: original_dimensions.1,
|
||||
palette,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user