fix: improve library import reliability, supported raster formats, duplicate detection, and browser-safe normalization
Browse files- src-tauri/src/library.rs +63 -32
src-tauri/src/library.rs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
use base64::{engine::general_purpose, Engine as _};
|
| 2 |
-
use image::GenericImageView;
|
| 3 |
use serde::{Deserialize, Serialize};
|
|
|
|
| 4 |
use std::sync::Mutex;
|
| 5 |
use tauri::{AppHandle, Manager};
|
| 6 |
use uuid::Uuid;
|
|
@@ -46,6 +47,7 @@ pub async fn library_add_item(app: AppHandle, url: String, source_url: Option<St
|
|
| 46 |
let client = reqwest::Client::builder()
|
| 47 |
.timeout(std::time::Duration::from_secs(DOWNLOAD_TIMEOUT_SECS))
|
| 48 |
.redirect(reqwest::redirect::Policy::limited(5))
|
|
|
|
| 49 |
.build()
|
| 50 |
.map_err(|e| format!("HTTP client error: {e}"))?;
|
| 51 |
|
|
@@ -68,15 +70,16 @@ pub async fn library_add_item(app: AppHandle, url: String, source_url: Option<St
|
|
| 68 |
#[tauri::command]
|
| 69 |
pub fn library_import_local(app: AppHandle, path: String, title: Option<String>) -> Result<LibraryItem, String> {
|
| 70 |
if path.trim().is_empty() { return Err("Path cannot be empty".to_string()); }
|
| 71 |
-
|
| 72 |
-
let
|
|
|
|
| 73 |
if !metadata.is_file() { return Err("Path is not a file".to_string()); }
|
| 74 |
if metadata.len() > MAX_IMAGE_SIZE as u64 {
|
| 75 |
return Err(format!("Image file is too large ({:.1} MB). Maximum allowed is {:.0} MB", metadata.len() as f64 / (1024.0 * 1024.0), MAX_IMAGE_SIZE as f64 / (1024.0 * 1024.0)));
|
| 76 |
}
|
| 77 |
-
let bytes = std::fs::read(&
|
| 78 |
-
let fallback_title =
|
| 79 |
-
import_bytes(&app, &bytes, &format!("file://{
|
| 80 |
}
|
| 81 |
|
| 82 |
#[tauri::command]
|
|
@@ -100,11 +103,50 @@ fn decode_data_url_bytes(data_url: &str) -> Result<Vec<u8>, String> {
|
|
| 100 |
fn clean_title(title: Option<String>) -> String {
|
| 101 |
let t = title.unwrap_or_else(|| "Untitled reference".to_string()).trim().to_string();
|
| 102 |
let t = if t.is_empty() { "Untitled reference".to_string() } else { t };
|
| 103 |
-
t.chars().take(MAX_TITLE_LEN).collect()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
}
|
| 105 |
|
| 106 |
fn import_bytes(app: &AppHandle, bytes: &[u8], url: &str, source_url: Option<String>, title: Option<String>) -> Result<LibraryItem, String> {
|
| 107 |
if bytes.is_empty() { return Err("Image data is empty".to_string()); }
|
|
|
|
| 108 |
let hash = blake3::hash(bytes).to_hex().to_string();
|
| 109 |
{
|
| 110 |
let state = app.state::<LibraryState>();
|
|
@@ -112,20 +154,20 @@ fn import_bytes(app: &AppHandle, bytes: &[u8], url: &str, source_url: Option<Str
|
|
| 112 |
if let Some(existing) = items.iter().find(|i| i.hash == hash) { return Ok(existing.clone()); }
|
| 113 |
if items.len() >= MAX_LIBRARY_ITEMS { return Err(format!("Library is full ({MAX_LIBRARY_ITEMS} items). Please remove some items before adding new ones.")); }
|
| 114 |
}
|
| 115 |
-
let img = image::load_from_memory(bytes).map_err(|e| format!("Unsupported image format: {e}"))?;
|
| 116 |
let (width, height) = img.dimensions();
|
| 117 |
if width == 0 || height == 0 { return Err("Image has invalid dimensions".to_string()); }
|
| 118 |
if width > MAX_IMAGE_DIMENSION || height > MAX_IMAGE_DIMENSION {
|
| 119 |
return Err(format!("Image dimensions too large ({width}x{height}). Maximum is {MAX_IMAGE_DIMENSION}px in either dimension."));
|
| 120 |
}
|
| 121 |
let colors = extract_palette(&img, 6);
|
| 122 |
-
let mime =
|
| 123 |
let item = LibraryItem {
|
| 124 |
id: Uuid::new_v4().to_string(),
|
| 125 |
url: url.to_string(),
|
| 126 |
source_url: source_url.unwrap_or_default(),
|
| 127 |
title: clean_title(title),
|
| 128 |
-
data_url: format!("data:{mime};base64,{}", general_purpose::STANDARD.encode(
|
| 129 |
hash,
|
| 130 |
width,
|
| 131 |
height,
|
|
@@ -136,27 +178,28 @@ fn import_bytes(app: &AppHandle, bytes: &[u8], url: &str, source_url: Option<Str
|
|
| 136 |
let state = app.state::<LibraryState>();
|
| 137 |
let mut items = state.items.lock().map_err(|_| "library lock poisoned".to_string())?;
|
| 138 |
items.push(item.clone());
|
|
|
|
| 139 |
crate::persistence::save_json(app, "library.json", &*items)?;
|
| 140 |
Ok(item)
|
| 141 |
}
|
| 142 |
|
| 143 |
#[tauri::command]
|
| 144 |
pub fn library_update_metadata(app: AppHandle, id: String, title: Option<String>, tags: Option<Vec<String>>) -> Result<LibraryItem, String> {
|
|
|
|
| 145 |
let state = app.state::<LibraryState>();
|
| 146 |
let mut items = state.items.lock().map_err(|_| "library lock poisoned".to_string())?;
|
| 147 |
let item = items.iter_mut().find(|i| i.id == id).ok_or("Item not found")?;
|
| 148 |
if let Some(t) = title {
|
| 149 |
let t = t.trim();
|
| 150 |
-
if !t.is_empty() { item.title = t.chars().take(MAX_TITLE_LEN).collect(); }
|
| 151 |
}
|
| 152 |
if let Some(t) = tags {
|
| 153 |
if t.len() > 50 { return Err("Too many tags (maximum 50)".to_string()); }
|
| 154 |
let mut clean = Vec::new();
|
| 155 |
for tag in t {
|
| 156 |
-
let tag = tag.trim();
|
| 157 |
if tag.is_empty() { continue; }
|
| 158 |
if tag.len() > 100 { return Err("Tag too long (maximum 100 characters)".to_string()); }
|
| 159 |
-
let tag = tag.to_string();
|
| 160 |
if !clean.contains(&tag) { clean.push(tag); }
|
| 161 |
}
|
| 162 |
item.tags = clean;
|
|
@@ -168,6 +211,7 @@ pub fn library_update_metadata(app: AppHandle, id: String, title: Option<String>
|
|
| 168 |
|
| 169 |
#[tauri::command]
|
| 170 |
pub fn library_remove_tag(app: AppHandle, id: String, tag: String) -> Result<LibraryItem, String> {
|
|
|
|
| 171 |
let state = app.state::<LibraryState>();
|
| 172 |
let mut items = state.items.lock().map_err(|_| "library lock poisoned".to_string())?;
|
| 173 |
let item = items.iter_mut().find(|i| i.id == id).ok_or("Item not found")?;
|
|
@@ -182,7 +226,8 @@ pub fn library_load(app: AppHandle) -> Result<Vec<LibraryItem>, String> {
|
|
| 182 |
let loaded: Vec<LibraryItem> = crate::persistence::load_json(&app, "library.json")?;
|
| 183 |
let state = app.state::<LibraryState>();
|
| 184 |
let mut items = state.items.lock().map_err(|_| "library lock poisoned".to_string())?;
|
| 185 |
-
if items.is_empty()
|
|
|
|
| 186 |
Ok(items.clone())
|
| 187 |
}
|
| 188 |
|
|
@@ -199,6 +244,7 @@ pub fn library_search(app: AppHandle, query: String) -> Result<Vec<LibraryItem>,
|
|
| 199 |
|
| 200 |
#[tauri::command]
|
| 201 |
pub fn library_remove_item(app: AppHandle, id: String) -> Result<(), String> {
|
|
|
|
| 202 |
let state = app.state::<LibraryState>();
|
| 203 |
let mut items = state.items.lock().map_err(|_| "library lock poisoned".to_string())?;
|
| 204 |
let before = items.len();
|
|
@@ -209,35 +255,20 @@ pub fn library_remove_item(app: AppHandle, id: String) -> Result<(), String> {
|
|
| 209 |
|
| 210 |
#[tauri::command]
|
| 211 |
pub fn library_add_tag(app: AppHandle, id: String, tag: String) -> Result<LibraryItem, String> {
|
| 212 |
-
|
|
|
|
| 213 |
if tag.is_empty() { return Err("Tag cannot be empty".to_string()); }
|
| 214 |
if tag.len() > 100 { return Err("Tag too long (maximum 100 characters)".to_string()); }
|
| 215 |
let state = app.state::<LibraryState>();
|
| 216 |
let mut items = state.items.lock().map_err(|_| "library lock poisoned".to_string())?;
|
| 217 |
let item = items.iter_mut().find(|i| i.id == id).ok_or("Item not found")?;
|
| 218 |
if item.tags.len() >= 50 && !item.tags.contains(&tag) { return Err("Too many tags (maximum 50 per item)".to_string()); }
|
| 219 |
-
if !item.tags.contains(&tag) { item.tags.push(tag); }
|
| 220 |
let result = item.clone();
|
| 221 |
crate::persistence::save_json(&app, "library.json", &*items)?;
|
| 222 |
Ok(result)
|
| 223 |
}
|
| 224 |
|
| 225 |
-
fn detect_mime(bytes: &[u8], url: &str) -> &'static str {
|
| 226 |
-
if bytes.len() >= 12 {
|
| 227 |
-
if bytes[0..4] == [0x89,0x50,0x4E,0x47] { return "image/png"; }
|
| 228 |
-
if bytes[0..3] == [0xFF,0xD8,0xFF] { return "image/jpeg"; }
|
| 229 |
-
if bytes[0..4] == [0x47,0x49,0x46,0x38] { return "image/gif"; }
|
| 230 |
-
if bytes[0..4] == [0x52,0x49,0x46,0x46] && bytes[8..12] == [0x57,0x45,0x42,0x50] { return "image/webp"; }
|
| 231 |
-
if bytes[0..2] == [0x42,0x4D] { return "image/bmp"; }
|
| 232 |
-
}
|
| 233 |
-
let lower = url.to_lowercase();
|
| 234 |
-
if lower.ends_with(".png") { "image/png" }
|
| 235 |
-
else if lower.ends_with(".webp") { "image/webp" }
|
| 236 |
-
else if lower.ends_with(".gif") { "image/gif" }
|
| 237 |
-
else if lower.ends_with(".bmp") { "image/bmp" }
|
| 238 |
-
else { "image/jpeg" }
|
| 239 |
-
}
|
| 240 |
-
|
| 241 |
fn extract_palette(img: &image::DynamicImage, count: usize) -> Vec<String> {
|
| 242 |
let thumb = img.thumbnail(120,120).to_rgb8();
|
| 243 |
let mut pixels: Vec<[u8;3]> = thumb.pixels().filter_map(|p| { let [r,g,b]=[p[0],p[1],p[2]]; if r.max(g).max(b) < 12 { None } else { Some([r,g,b]) } }).collect();
|
|
|
|
| 1 |
use base64::{engine::general_purpose, Engine as _};
|
| 2 |
+
use image::{GenericImageView, ImageFormat};
|
| 3 |
use serde::{Deserialize, Serialize};
|
| 4 |
+
use std::io::Cursor;
|
| 5 |
use std::sync::Mutex;
|
| 6 |
use tauri::{AppHandle, Manager};
|
| 7 |
use uuid::Uuid;
|
|
|
|
| 47 |
let client = reqwest::Client::builder()
|
| 48 |
.timeout(std::time::Duration::from_secs(DOWNLOAD_TIMEOUT_SECS))
|
| 49 |
.redirect(reqwest::redirect::Policy::limited(5))
|
| 50 |
+
.user_agent("LumaRef/1.0 (+https://huggingface.co/asdf98/musealpha)")
|
| 51 |
.build()
|
| 52 |
.map_err(|e| format!("HTTP client error: {e}"))?;
|
| 53 |
|
|
|
|
| 70 |
#[tauri::command]
|
| 71 |
pub fn library_import_local(app: AppHandle, path: String, title: Option<String>) -> Result<LibraryItem, String> {
|
| 72 |
if path.trim().is_empty() { return Err("Path cannot be empty".to_string()); }
|
| 73 |
+
let path_buf = std::path::PathBuf::from(&path);
|
| 74 |
+
let canonical = path_buf.canonicalize().map_err(|e| format!("Cannot access file: {e}"))?;
|
| 75 |
+
let metadata = std::fs::metadata(&canonical).map_err(|e| format!("Cannot access file: {e}"))?;
|
| 76 |
if !metadata.is_file() { return Err("Path is not a file".to_string()); }
|
| 77 |
if metadata.len() > MAX_IMAGE_SIZE as u64 {
|
| 78 |
return Err(format!("Image file is too large ({:.1} MB). Maximum allowed is {:.0} MB", metadata.len() as f64 / (1024.0 * 1024.0), MAX_IMAGE_SIZE as f64 / (1024.0 * 1024.0)));
|
| 79 |
}
|
| 80 |
+
let bytes = std::fs::read(&canonical).map_err(|e| format!("Cannot read file: {e}"))?;
|
| 81 |
+
let fallback_title = canonical.file_stem().and_then(|s| s.to_str()).map(|s| s.to_string());
|
| 82 |
+
import_bytes(&app, &bytes, &format!("file://{}", canonical.to_string_lossy()), None, title.or(fallback_title))
|
| 83 |
}
|
| 84 |
|
| 85 |
#[tauri::command]
|
|
|
|
| 103 |
fn clean_title(title: Option<String>) -> String {
|
| 104 |
let t = title.unwrap_or_else(|| "Untitled reference".to_string()).trim().to_string();
|
| 105 |
let t = if t.is_empty() { "Untitled reference".to_string() } else { t };
|
| 106 |
+
t.chars().filter(|c| !c.is_control()).take(MAX_TITLE_LEN).collect()
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
fn ensure_library_loaded(app: &AppHandle) -> Result<(), String> {
|
| 110 |
+
let loaded: Vec<LibraryItem> = crate::persistence::load_json(app, "library.json").unwrap_or_default();
|
| 111 |
+
let state = app.state::<LibraryState>();
|
| 112 |
+
let mut items = state.items.lock().map_err(|_| "library lock poisoned".to_string())?;
|
| 113 |
+
if items.is_empty() && !loaded.is_empty() { *items = loaded; }
|
| 114 |
+
Ok(())
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
fn browser_safe_data(bytes: &[u8], url: &str, img: &image::DynamicImage) -> Result<(Vec<u8>, &'static str), String> {
|
| 118 |
+
let guessed = image::guess_format(bytes).ok();
|
| 119 |
+
match guessed {
|
| 120 |
+
Some(ImageFormat::Png) => Ok((bytes.to_vec(), "image/png")),
|
| 121 |
+
Some(ImageFormat::Jpeg) => Ok((bytes.to_vec(), "image/jpeg")),
|
| 122 |
+
Some(ImageFormat::Gif) => Ok((bytes.to_vec(), "image/gif")),
|
| 123 |
+
Some(ImageFormat::WebP) => Ok((bytes.to_vec(), "image/webp")),
|
| 124 |
+
Some(ImageFormat::Bmp) => Ok((bytes.to_vec(), "image/bmp")),
|
| 125 |
+
Some(ImageFormat::Ico) => Ok((bytes.to_vec(), "image/x-icon")),
|
| 126 |
+
Some(ImageFormat::Tiff) => {
|
| 127 |
+
let mut out = Vec::new();
|
| 128 |
+
img.write_to(&mut Cursor::new(&mut out), ImageFormat::Png).map_err(|e| format!("PNG conversion failed: {e}"))?;
|
| 129 |
+
Ok((out, "image/png"))
|
| 130 |
+
}
|
| 131 |
+
_ => {
|
| 132 |
+
let lower = url.to_lowercase();
|
| 133 |
+
if lower.ends_with(".png") { Ok((bytes.to_vec(), "image/png")) }
|
| 134 |
+
else if lower.ends_with(".jpg") || lower.ends_with(".jpeg") { Ok((bytes.to_vec(), "image/jpeg")) }
|
| 135 |
+
else if lower.ends_with(".gif") { Ok((bytes.to_vec(), "image/gif")) }
|
| 136 |
+
else if lower.ends_with(".webp") { Ok((bytes.to_vec(), "image/webp")) }
|
| 137 |
+
else if lower.ends_with(".bmp") { Ok((bytes.to_vec(), "image/bmp")) }
|
| 138 |
+
else {
|
| 139 |
+
let mut out = Vec::new();
|
| 140 |
+
img.write_to(&mut Cursor::new(&mut out), ImageFormat::Png).map_err(|e| format!("PNG conversion failed: {e}"))?;
|
| 141 |
+
Ok((out, "image/png"))
|
| 142 |
+
}
|
| 143 |
+
}
|
| 144 |
+
}
|
| 145 |
}
|
| 146 |
|
| 147 |
fn import_bytes(app: &AppHandle, bytes: &[u8], url: &str, source_url: Option<String>, title: Option<String>) -> Result<LibraryItem, String> {
|
| 148 |
if bytes.is_empty() { return Err("Image data is empty".to_string()); }
|
| 149 |
+
ensure_library_loaded(app)?;
|
| 150 |
let hash = blake3::hash(bytes).to_hex().to_string();
|
| 151 |
{
|
| 152 |
let state = app.state::<LibraryState>();
|
|
|
|
| 154 |
if let Some(existing) = items.iter().find(|i| i.hash == hash) { return Ok(existing.clone()); }
|
| 155 |
if items.len() >= MAX_LIBRARY_ITEMS { return Err(format!("Library is full ({MAX_LIBRARY_ITEMS} items). Please remove some items before adding new ones.")); }
|
| 156 |
}
|
| 157 |
+
let img = image::load_from_memory(bytes).map_err(|e| format!("Unsupported raster image format: {e}. Supported: PNG, JPEG, WebP, GIF, BMP, ICO, TIFF."))?;
|
| 158 |
let (width, height) = img.dimensions();
|
| 159 |
if width == 0 || height == 0 { return Err("Image has invalid dimensions".to_string()); }
|
| 160 |
if width > MAX_IMAGE_DIMENSION || height > MAX_IMAGE_DIMENSION {
|
| 161 |
return Err(format!("Image dimensions too large ({width}x{height}). Maximum is {MAX_IMAGE_DIMENSION}px in either dimension."));
|
| 162 |
}
|
| 163 |
let colors = extract_palette(&img, 6);
|
| 164 |
+
let (stored_bytes, mime) = browser_safe_data(bytes, url, &img)?;
|
| 165 |
let item = LibraryItem {
|
| 166 |
id: Uuid::new_v4().to_string(),
|
| 167 |
url: url.to_string(),
|
| 168 |
source_url: source_url.unwrap_or_default(),
|
| 169 |
title: clean_title(title),
|
| 170 |
+
data_url: format!("data:{mime};base64,{}", general_purpose::STANDARD.encode(stored_bytes)),
|
| 171 |
hash,
|
| 172 |
width,
|
| 173 |
height,
|
|
|
|
| 178 |
let state = app.state::<LibraryState>();
|
| 179 |
let mut items = state.items.lock().map_err(|_| "library lock poisoned".to_string())?;
|
| 180 |
items.push(item.clone());
|
| 181 |
+
items.sort_by(|a, b| b.created_at.cmp(&a.created_at));
|
| 182 |
crate::persistence::save_json(app, "library.json", &*items)?;
|
| 183 |
Ok(item)
|
| 184 |
}
|
| 185 |
|
| 186 |
#[tauri::command]
|
| 187 |
pub fn library_update_metadata(app: AppHandle, id: String, title: Option<String>, tags: Option<Vec<String>>) -> Result<LibraryItem, String> {
|
| 188 |
+
ensure_library_loaded(&app)?;
|
| 189 |
let state = app.state::<LibraryState>();
|
| 190 |
let mut items = state.items.lock().map_err(|_| "library lock poisoned".to_string())?;
|
| 191 |
let item = items.iter_mut().find(|i| i.id == id).ok_or("Item not found")?;
|
| 192 |
if let Some(t) = title {
|
| 193 |
let t = t.trim();
|
| 194 |
+
if !t.is_empty() { item.title = t.chars().filter(|c| !c.is_control()).take(MAX_TITLE_LEN).collect(); }
|
| 195 |
}
|
| 196 |
if let Some(t) = tags {
|
| 197 |
if t.len() > 50 { return Err("Too many tags (maximum 50)".to_string()); }
|
| 198 |
let mut clean = Vec::new();
|
| 199 |
for tag in t {
|
| 200 |
+
let tag = tag.trim().to_lowercase();
|
| 201 |
if tag.is_empty() { continue; }
|
| 202 |
if tag.len() > 100 { return Err("Tag too long (maximum 100 characters)".to_string()); }
|
|
|
|
| 203 |
if !clean.contains(&tag) { clean.push(tag); }
|
| 204 |
}
|
| 205 |
item.tags = clean;
|
|
|
|
| 211 |
|
| 212 |
#[tauri::command]
|
| 213 |
pub fn library_remove_tag(app: AppHandle, id: String, tag: String) -> Result<LibraryItem, String> {
|
| 214 |
+
ensure_library_loaded(&app)?;
|
| 215 |
let state = app.state::<LibraryState>();
|
| 216 |
let mut items = state.items.lock().map_err(|_| "library lock poisoned".to_string())?;
|
| 217 |
let item = items.iter_mut().find(|i| i.id == id).ok_or("Item not found")?;
|
|
|
|
| 226 |
let loaded: Vec<LibraryItem> = crate::persistence::load_json(&app, "library.json")?;
|
| 227 |
let state = app.state::<LibraryState>();
|
| 228 |
let mut items = state.items.lock().map_err(|_| "library lock poisoned".to_string())?;
|
| 229 |
+
if items.is_empty() || loaded.len() != items.len() { *items = loaded; }
|
| 230 |
+
items.sort_by(|a, b| b.created_at.cmp(&a.created_at));
|
| 231 |
Ok(items.clone())
|
| 232 |
}
|
| 233 |
|
|
|
|
| 244 |
|
| 245 |
#[tauri::command]
|
| 246 |
pub fn library_remove_item(app: AppHandle, id: String) -> Result<(), String> {
|
| 247 |
+
ensure_library_loaded(&app)?;
|
| 248 |
let state = app.state::<LibraryState>();
|
| 249 |
let mut items = state.items.lock().map_err(|_| "library lock poisoned".to_string())?;
|
| 250 |
let before = items.len();
|
|
|
|
| 255 |
|
| 256 |
#[tauri::command]
|
| 257 |
pub fn library_add_tag(app: AppHandle, id: String, tag: String) -> Result<LibraryItem, String> {
|
| 258 |
+
ensure_library_loaded(&app)?;
|
| 259 |
+
let tag = tag.trim().to_lowercase();
|
| 260 |
if tag.is_empty() { return Err("Tag cannot be empty".to_string()); }
|
| 261 |
if tag.len() > 100 { return Err("Tag too long (maximum 100 characters)".to_string()); }
|
| 262 |
let state = app.state::<LibraryState>();
|
| 263 |
let mut items = state.items.lock().map_err(|_| "library lock poisoned".to_string())?;
|
| 264 |
let item = items.iter_mut().find(|i| i.id == id).ok_or("Item not found")?;
|
| 265 |
if item.tags.len() >= 50 && !item.tags.contains(&tag) { return Err("Too many tags (maximum 50 per item)".to_string()); }
|
| 266 |
+
if !item.tags.contains(&tag) { item.tags.push(tag); item.tags.sort(); }
|
| 267 |
let result = item.clone();
|
| 268 |
crate::persistence::save_json(&app, "library.json", &*items)?;
|
| 269 |
Ok(result)
|
| 270 |
}
|
| 271 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 272 |
fn extract_palette(img: &image::DynamicImage, count: usize) -> Vec<String> {
|
| 273 |
let thumb = img.thumbnail(120,120).to_rgb8();
|
| 274 |
let mut pixels: Vec<[u8;3]> = thumb.pixels().filter_map(|p| { let [r,g,b]=[p[0],p[1],p[2]]; if r.max(g).max(b) < 12 { None } else { Some([r,g,b]) } }).collect();
|