use anyhow::Result;
use base64::{engine::general_purpose, Engine as _};
use image::{DynamicImage, ImageFormat};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct ImageAttachment {
pub data: ImageData,
pub media_type: String,
pub source_type: SourceType,
pub dimensions: Option<(u32, u32)>,
pub size_bytes: Option<u64>,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub enum ImageData {
Base64(String),
Url(String),
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub enum SourceType {
File(PathBuf),
Clipboard,
Url,
}
pub struct ImageProcessor;
impl ImageProcessor {
const MAX_WIDTH: u32 = 1568;
const MAX_HEIGHT: u32 = 1568;
const MAX_FILE_SIZE: u64 = 5 * 1024 * 1024;
pub fn load_from_path(path: &Path) -> Result<ImageAttachment> {
let metadata = std::fs::metadata(path)?;
if metadata.len() > Self::MAX_FILE_SIZE {
return Err(anyhow::anyhow!(
"Image file too large: {}MB (max 5MB)",
metadata.len() / 1024 / 1024
));
}
let img = image::open(path)?;
let format = ImageFormat::from_path(path)
.map_err(|_| anyhow::anyhow!("Unsupported image format"))?;
let media_type = Self::format_to_media_type(format)?;
let processed_img = Self::resize_if_needed(img);
let base64_data = Self::encode_to_base64(&processed_img, format)?;
Ok(ImageAttachment {
data: ImageData::Base64(base64_data),
media_type,
source_type: SourceType::File(path.to_path_buf()),
dimensions: Some((processed_img.width(), processed_img.height())),
size_bytes: Some(metadata.len()),
})
}
pub async fn load_from_url(url: &str) -> Result<ImageAttachment> {
use reqwest::Client;
let parsed_url = url::Url::parse(url).map_err(|_| anyhow::anyhow!("Invalid URL format"))?;
if let Some(mut path) = parsed_url.path_segments() {
if let Some(filename) = path.next_back() {
if !Self::is_supported_image_by_name(filename) {
return Err(anyhow::anyhow!(
"URL does not appear to point to a supported image format: {}",
filename
));
}
}
}
let client = Client::new();
let response = client
.get(url)
.header("User-Agent", "Octomind/1.0")
.send()
.await?;
if !response.status().is_success() {
return Err(anyhow::anyhow!(
"Failed to download image: HTTP {}",
response.status()
));
}
let content_type = response
.headers()
.get("content-type")
.and_then(|h| h.to_str().ok())
.unwrap_or("")
.to_string();
if !content_type.starts_with("image/") {
return Err(anyhow::anyhow!(
"URL does not return an image (content-type: {})",
content_type
));
}
let image_bytes = response.bytes().await?;
if image_bytes.len() > Self::MAX_FILE_SIZE as usize {
return Err(anyhow::anyhow!(
"Image too large: {}MB (max 5MB)",
image_bytes.len() / 1024 / 1024
));
}
let img = image::load_from_memory(&image_bytes)?;
let media_type = if content_type.starts_with("image/") {
content_type.to_string()
} else {
Self::guess_media_type_from_url(url).unwrap_or_else(|| "image/png".to_string())
};
let processed_img = Self::resize_if_needed(img);
let format = Self::media_type_to_format(&media_type)?;
let base64_data = Self::encode_to_base64(&processed_img, format)?;
Ok(ImageAttachment {
data: ImageData::Base64(base64_data),
media_type,
source_type: SourceType::Url,
dimensions: Some((processed_img.width(), processed_img.height())),
size_bytes: Some(image_bytes.len() as u64),
})
}
pub fn load_from_clipboard() -> Result<Option<ImageAttachment>> {
use arboard::Clipboard;
let mut clipboard =
Clipboard::new().map_err(|_| anyhow::anyhow!("Failed to access clipboard"))?;
match clipboard.get_image() {
Ok(img_data) => {
let attachment = Self::convert_clipboard_image(img_data)?;
Ok(Some(attachment))
}
Err(_) => Ok(None), }
}
fn convert_clipboard_image(img_data: arboard::ImageData) -> Result<ImageAttachment> {
let width = img_data.width;
let height = img_data.height;
let bytes = img_data.bytes;
let img = image::RgbaImage::from_raw(width as u32, height as u32, bytes.into_owned())
.ok_or_else(|| anyhow::anyhow!("Failed to create image from clipboard data"))?;
let dynamic_img = DynamicImage::ImageRgba8(img);
let processed_img = Self::resize_if_needed(dynamic_img);
let base64_data = Self::encode_to_base64(&processed_img, ImageFormat::Png)?;
Ok(ImageAttachment {
data: ImageData::Base64(base64_data),
media_type: "image/png".to_string(),
source_type: SourceType::Clipboard,
dimensions: Some((processed_img.width(), processed_img.height())),
size_bytes: None, })
}
fn resize_if_needed(img: DynamicImage) -> DynamicImage {
let (width, height) = (img.width(), img.height());
if width <= Self::MAX_WIDTH && height <= Self::MAX_HEIGHT {
return img;
}
let ratio =
(Self::MAX_WIDTH as f32 / width as f32).min(Self::MAX_HEIGHT as f32 / height as f32);
let new_width = (width as f32 * ratio) as u32;
let new_height = (height as f32 * ratio) as u32;
img.resize(new_width, new_height, image::imageops::FilterType::Lanczos3)
}
fn encode_to_base64(img: &DynamicImage, format: ImageFormat) -> Result<String> {
let mut buffer = Vec::new();
img.write_to(&mut std::io::Cursor::new(&mut buffer), format)?;
Ok(general_purpose::STANDARD.encode(&buffer))
}
fn format_to_media_type(format: ImageFormat) -> Result<String> {
let media_type = match format {
ImageFormat::Png => "image/png",
ImageFormat::Jpeg => "image/jpeg",
ImageFormat::Gif => "image/gif",
ImageFormat::WebP => "image/webp",
ImageFormat::Bmp => "image/bmp",
_ => return Err(anyhow::anyhow!("Unsupported image format for vision API")),
};
Ok(media_type.to_string())
}
pub fn show_preview(attachment: &ImageAttachment) -> Result<()> {
if let ImageData::Base64(ref data) = attachment.data {
let img_bytes = general_purpose::STANDARD.decode(data)?;
let img = image::load_from_memory(&img_bytes)?;
if let Some((width, height)) = attachment.dimensions {
crate::log_info!("📸 Image: {}x{} ({})", width, height, attachment.media_type);
}
if let Some(size) = attachment.size_bytes {
crate::log_info!("📏 Size: {:.1}KB", size as f64 / 1024.0);
}
let config = viuer::Config {
width: Some(40),
height: Some(20),
absolute_offset: false,
..Default::default()
};
if let Err(e) = viuer::print(&img, &config) {
crate::log_debug!("⚠️ Preview not available: {}", e);
}
}
Ok(())
}
pub fn is_supported_image(path: &Path) -> bool {
if let Some(extension) = path.extension() {
if let Some(ext_str) = extension.to_str() {
Self::is_supported_extension(ext_str)
} else {
false
}
} else {
false
}
}
pub fn is_supported_image_by_name(filename: &str) -> bool {
if let Some(ext) = filename.split('.').next_back() {
Self::is_supported_extension(ext)
} else {
false
}
}
fn is_supported_extension(ext: &str) -> bool {
matches!(
ext.to_lowercase().as_str(),
"png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp"
)
}
fn guess_media_type_from_url(url: &str) -> Option<String> {
if let Some(ext) = url.split('.').next_back() {
match ext.to_lowercase().as_str() {
"png" => Some("image/png".to_string()),
"jpg" | "jpeg" => Some("image/jpeg".to_string()),
"gif" => Some("image/gif".to_string()),
"webp" => Some("image/webp".to_string()),
"bmp" => Some("image/bmp".to_string()),
_ => None,
}
} else {
None
}
}
fn media_type_to_format(media_type: &str) -> Result<ImageFormat> {
match media_type {
"image/png" => Ok(ImageFormat::Png),
"image/jpeg" => Ok(ImageFormat::Jpeg),
"image/gif" => Ok(ImageFormat::Gif),
"image/webp" => Ok(ImageFormat::WebP),
"image/bmp" => Ok(ImageFormat::Bmp),
_ => Ok(ImageFormat::Png), }
}
pub fn supported_extensions() -> &'static [&'static str] {
&["png", "jpg", "jpeg", "gif", "webp", "bmp"]
}
pub fn is_url(input: &str) -> bool {
input.starts_with("http://") || input.starts_with("https://")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_supported_extensions() {
let extensions = ImageProcessor::supported_extensions();
assert!(extensions.contains(&"png"));
assert!(extensions.contains(&"jpg"));
}
#[test]
fn test_is_supported_image() {
assert!(ImageProcessor::is_supported_image(Path::new("test.png")));
assert!(ImageProcessor::is_supported_image(Path::new("test.JPG")));
assert!(!ImageProcessor::is_supported_image(Path::new("test.txt")));
}
}