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,
}
#[derive(Debug, Copy, Clone)]
enum InlineProtocol {
Kitty,
ITerm2,
}
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 render_inline_escape(attachment: &ImageAttachment) -> Option<String> {
let ImageData::Base64(ref data) = attachment.data else {
return None;
};
let proto = Self::detect_inline_protocol()?;
let preview_b64 = Self::shrink_for_preview(data).unwrap_or_else(|_| data.clone());
let cols = 40_u32;
Some(match proto {
InlineProtocol::Kitty => Self::build_kitty_escape(&preview_b64, cols),
InlineProtocol::ITerm2 => Self::build_iterm2_escape(&preview_b64, cols),
})
}
fn shrink_for_preview(b64: &str) -> Result<String> {
const MAX_DIM: u32 = 320;
let bytes = general_purpose::STANDARD.decode(b64)?;
let img = image::load_from_memory(&bytes)?;
let (w, h) = (img.width(), img.height());
let resized = if w > MAX_DIM || h > MAX_DIM {
let ratio = (MAX_DIM as f32 / w as f32).min(MAX_DIM as f32 / h as f32);
let nw = (w as f32 * ratio) as u32;
let nh = (h as f32 * ratio) as u32;
img.resize(nw, nh, image::imageops::FilterType::Triangle)
} else {
img
};
let mut buf = Vec::new();
resized.write_to(&mut std::io::Cursor::new(&mut buf), ImageFormat::Png)?;
Ok(general_purpose::STANDARD.encode(&buf))
}
fn detect_inline_protocol() -> Option<InlineProtocol> {
if std::env::var("KITTY_WINDOW_ID").is_ok() {
return Some(InlineProtocol::Kitty);
}
if let Ok(term) = std::env::var("TERM") {
if term.contains("kitty") {
return Some(InlineProtocol::Kitty);
}
}
match std::env::var("TERM_PROGRAM").as_deref() {
Ok("ghostty") | Ok("WezTerm") => Some(InlineProtocol::Kitty),
Ok("iTerm.app") | Ok("Tabby") | Ok("vscode") => Some(InlineProtocol::ITerm2),
_ => None,
}
}
fn build_kitty_escape(b64: &str, cols: u32) -> String {
const CHUNK: usize = 4096;
let bytes = b64.as_bytes();
let chunk_count = bytes.len().div_ceil(CHUNK);
let mut out = String::with_capacity(bytes.len() + chunk_count * 32);
for (i, chunk) in bytes.chunks(CHUNK).enumerate() {
let more = if i + 1 < chunk_count { 1 } else { 0 };
let chunk_str = std::str::from_utf8(chunk).unwrap_or("");
if i == 0 {
out.push_str(&format!(
"\x1b_Ga=T,f=100,q=2,c={},m={};{}\x1b\\",
cols, more, chunk_str
));
} else {
out.push_str(&format!("\x1b_Gm={};{}\x1b\\", more, chunk_str));
}
}
out
}
fn build_iterm2_escape(b64: &str, cols: u32) -> String {
format!(
"\x1b]1337;File=inline=1;width={};preserveAspectRatio=1:{}\x07",
cols, b64
)
}
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")));
}
}