attachments.rs
Language Rust5.9 KB154 lines
//! Attachments. Images are downscaled before they are stored, because a vision//! model bills by image size; text files are read into the prompt as a fenced//! block. Files are stored by content hash, so the same image twice is one file.use std::path::{Path, PathBuf};use base64::Engine;use image::GenericImageView;use sha2::{Digest, Sha256};/// Long edge, in pixels. Larger images cost more and rarely read better.pub const MAX_IMAGE_EDGE: u32 = 1568;/// Text files beyond this are refused rather than silently cut.pub const MAX_TEXT_BYTES: usize = 256 * 1024;/// Images beyond this are refused before decoding.pub const MAX_IMAGE_BYTES: usize = 40 * 1024 * 1024;#[derive(Debug, thiserror::Error, PartialEq)]pub enum AttachError {#[error("{0} is larger than {1} KB")]TooLarge(String, usize),#[error("{0} is not a readable image")]BadImage(String),#[error("{0} is not UTF-8 text")]NotText(String),#[error("could not save the attachment: {0}")]Io(String),}#[derive(Debug)]pub struct ProcessedImage {pub bytes: Vec<u8>,pub mime: &'static str,pub ext: &'static str,pub width: u32,pub height: u32,pub sha256: String,}pub fn sha256_hex(bytes: &[u8]) -> String {Sha256::digest(bytes).iter().map(|b| format!("{b:02x}")).collect()}/// The image types Cortex accepts, by extension.pub const IMAGE_EXTENSIONS: [&str; 5] = ["png", "jpg", "jpeg", "gif", "webp"];pub fn is_image_name(name: &str) -> bool {let n = name.to_lowercase();n.rsplit_once('.').is_some_and(|(_, ext)| IMAGE_EXTENSIONS.contains(&ext))}/// Decode, shrink to MAX_IMAGE_EDGE, re-encode: PNG when it has transparency, else JPEG.pub fn process_image(filename: &str, bytes: &[u8]) -> Result<ProcessedImage, AttachError> {if bytes.len() > MAX_IMAGE_BYTES {return Err(AttachError::TooLarge(filename.into(), MAX_IMAGE_BYTES / 1024));}let img = image::load_from_memory(bytes).map_err(|_| AttachError::BadImage(filename.into()))?;let (w, h) = img.dimensions();let img = if w.max(h) > MAX_IMAGE_EDGE {img.resize(MAX_IMAGE_EDGE, MAX_IMAGE_EDGE, image::imageops::FilterType::Lanczos3)} else {img};let (width, height) = img.dimensions();let mut out = std::io::Cursor::new(Vec::new());let (mime, ext) = if img.color().has_alpha() {img.write_to(&mut out, image::ImageFormat::Png).map_err(|e| AttachError::Io(e.to_string()))?;("image/png", "png")} else {let rgb = image::DynamicImage::ImageRgb8(img.to_rgb8());let enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, 85);rgb.write_with_encoder(enc).map_err(|e| AttachError::Io(e.to_string()))?;("image/jpeg", "jpg")};let bytes = out.into_inner();Ok(ProcessedImage { sha256: sha256_hex(&bytes), bytes, mime, ext, width, height })}/// Save under `dir/<sha256>.<ext>`; returns the path.pub fn store(dir: &Path, img: &ProcessedImage) -> Result<PathBuf, AttachError> {std::fs::create_dir_all(dir).map_err(|e| AttachError::Io(e.to_string()))?;let path = dir.join(format!("{}.{}", img.sha256, img.ext));if !path.exists() {std::fs::write(&path, &img.bytes).map_err(|e| AttachError::Io(e.to_string()))?;}Ok(path)}pub fn read_text(filename: &str, bytes: &[u8]) -> Result<String, AttachError> {if bytes.len() > MAX_TEXT_BYTES {return Err(AttachError::TooLarge(filename.into(), MAX_TEXT_BYTES / 1024));}String::from_utf8(bytes.to_vec()).map_err(|_| AttachError::NotText(filename.into()))}/// How a text file travels in the prompt.pub fn text_block(filename: &str, text: &str) -> String {let fence = if text.contains("```") { "~~~~" } else { "```" };format!("File: {filename}\n{fence}\n{}\n{fence}", text.trim_end())}pub fn data_url(mime: &str, bytes: &[u8]) -> String {format!("data:{mime};base64,{}", base64::engine::general_purpose::STANDARD.encode(bytes))}#[cfg(test)]mod tests {use super::*;fn png(w: u32, h: u32, alpha: bool) -> Vec<u8> {let img = if alpha {image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(w, h, image::Rgba([10, 20, 30, 128])))} else {image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel(w, h, image::Rgb([200, 100, 50])))};let mut out = std::io::Cursor::new(Vec::new());img.write_to(&mut out, image::ImageFormat::Png).unwrap();out.into_inner()}#[test]fn large_images_shrink_and_keep_aspect() {let p = process_image("big.png", &png(3136, 1000, false)).unwrap();assert_eq!((p.width, p.height), (1568, 500));assert_eq!(p.mime, "image/jpeg");let small = process_image("s.png", &png(300, 200, true)).unwrap();assert_eq!((small.width, small.height, small.mime), (300, 200, "image/png"));}#[test]fn stored_by_hash_once() {let dir = tempfile::tempdir().unwrap();let p = process_image("a.png", &png(10, 10, false)).unwrap();let a = store(dir.path(), &p).unwrap();let b = store(dir.path(), &p).unwrap();assert_eq!(a, b);assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 1);}#[test]fn rejects_bad_input() {assert_eq!(process_image("x.png", b"nope").unwrap_err(), AttachError::BadImage("x.png".into()));assert!(matches!(read_text("big.txt", &vec![b'a'; MAX_TEXT_BYTES + 1]), Err(AttachError::TooLarge(..))));assert_eq!(read_text("bin", &[0xff, 0xfe]).unwrap_err(), AttachError::NotText("bin".into()));}#[test]fn text_blocks_and_data_urls() {assert_eq!(text_block("a.md", "hi\n"), "File: a.md\n```\nhi\n```");assert!(text_block("b.md", "```rust\n```").starts_with("File: b.md\n~~~~\n"));assert_eq!(data_url("image/png", b"hi"), "data:image/png;base64,aGk=");assert!(is_image_name("Photo.JPEG") && !is_image_name("notes.txt"));}}