mattrguerra/cortexmainPrivate on GitHub, published here in full

attachments.rs

Language Rust5.9 KB154 lines

crates/cortex-core/src/attachments.rs

  1. //! Attachments. Images are downscaled before they are stored, because a vision
  2. //! model bills by image size; text files are read into the prompt as a fenced
  3. //! block. Files are stored by content hash, so the same image twice is one file.
  4. use std::path::{Path, PathBuf};
  5. use base64::Engine;
  6. use image::GenericImageView;
  7. use sha2::{Digest, Sha256};
  8. /// Long edge, in pixels. Larger images cost more and rarely read better.
  9. pub const MAX_IMAGE_EDGE: u32 = 1568;
  10. /// Text files beyond this are refused rather than silently cut.
  11. pub const MAX_TEXT_BYTES: usize = 256 * 1024;
  12. /// Images beyond this are refused before decoding.
  13. pub const MAX_IMAGE_BYTES: usize = 40 * 1024 * 1024;
  14. #[derive(Debug, thiserror::Error, PartialEq)]
  15. pub enum AttachError {
  16. #[error("{0} is larger than {1} KB")]
  17. TooLarge(String, usize),
  18. #[error("{0} is not a readable image")]
  19. BadImage(String),
  20. #[error("{0} is not UTF-8 text")]
  21. NotText(String),
  22. #[error("could not save the attachment: {0}")]
  23. Io(String),
  24. }
  25. #[derive(Debug)]
  26. pub struct ProcessedImage {
  27. pub bytes: Vec<u8>,
  28. pub mime: &'static str,
  29. pub ext: &'static str,
  30. pub width: u32,
  31. pub height: u32,
  32. pub sha256: String,
  33. }
  34. pub fn sha256_hex(bytes: &[u8]) -> String {
  35. Sha256::digest(bytes).iter().map(|b| format!("{b:02x}")).collect()
  36. }
  37. /// The image types Cortex accepts, by extension.
  38. pub const IMAGE_EXTENSIONS: [&str; 5] = ["png", "jpg", "jpeg", "gif", "webp"];
  39. pub fn is_image_name(name: &str) -> bool {
  40. let n = name.to_lowercase();
  41. n.rsplit_once('.').is_some_and(|(_, ext)| IMAGE_EXTENSIONS.contains(&ext))
  42. }
  43. /// Decode, shrink to MAX_IMAGE_EDGE, re-encode: PNG when it has transparency, else JPEG.
  44. pub fn process_image(filename: &str, bytes: &[u8]) -> Result<ProcessedImage, AttachError> {
  45. if bytes.len() > MAX_IMAGE_BYTES {
  46. return Err(AttachError::TooLarge(filename.into(), MAX_IMAGE_BYTES / 1024));
  47. }
  48. let img = image::load_from_memory(bytes).map_err(|_| AttachError::BadImage(filename.into()))?;
  49. let (w, h) = img.dimensions();
  50. let img = if w.max(h) > MAX_IMAGE_EDGE {
  51. img.resize(MAX_IMAGE_EDGE, MAX_IMAGE_EDGE, image::imageops::FilterType::Lanczos3)
  52. } else {
  53. img
  54. };
  55. let (width, height) = img.dimensions();
  56. let mut out = std::io::Cursor::new(Vec::new());
  57. let (mime, ext) = if img.color().has_alpha() {
  58. img.write_to(&mut out, image::ImageFormat::Png).map_err(|e| AttachError::Io(e.to_string()))?;
  59. ("image/png", "png")
  60. } else {
  61. let rgb = image::DynamicImage::ImageRgb8(img.to_rgb8());
  62. let enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, 85);
  63. rgb.write_with_encoder(enc).map_err(|e| AttachError::Io(e.to_string()))?;
  64. ("image/jpeg", "jpg")
  65. };
  66. let bytes = out.into_inner();
  67. Ok(ProcessedImage { sha256: sha256_hex(&bytes), bytes, mime, ext, width, height })
  68. }
  69. /// Save under `dir/<sha256>.<ext>`; returns the path.
  70. pub fn store(dir: &Path, img: &ProcessedImage) -> Result<PathBuf, AttachError> {
  71. std::fs::create_dir_all(dir).map_err(|e| AttachError::Io(e.to_string()))?;
  72. let path = dir.join(format!("{}.{}", img.sha256, img.ext));
  73. if !path.exists() {
  74. std::fs::write(&path, &img.bytes).map_err(|e| AttachError::Io(e.to_string()))?;
  75. }
  76. Ok(path)
  77. }
  78. pub fn read_text(filename: &str, bytes: &[u8]) -> Result<String, AttachError> {
  79. if bytes.len() > MAX_TEXT_BYTES {
  80. return Err(AttachError::TooLarge(filename.into(), MAX_TEXT_BYTES / 1024));
  81. }
  82. String::from_utf8(bytes.to_vec()).map_err(|_| AttachError::NotText(filename.into()))
  83. }
  84. /// How a text file travels in the prompt.
  85. pub fn text_block(filename: &str, text: &str) -> String {
  86. let fence = if text.contains("```") { "~~~~" } else { "```" };
  87. format!("File: {filename}\n{fence}\n{}\n{fence}", text.trim_end())
  88. }
  89. pub fn data_url(mime: &str, bytes: &[u8]) -> String {
  90. format!("data:{mime};base64,{}", base64::engine::general_purpose::STANDARD.encode(bytes))
  91. }
  92. #[cfg(test)]
  93. mod tests {
  94. use super::*;
  95. fn png(w: u32, h: u32, alpha: bool) -> Vec<u8> {
  96. let img = if alpha {
  97. image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(w, h, image::Rgba([10, 20, 30, 128])))
  98. } else {
  99. image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel(w, h, image::Rgb([200, 100, 50])))
  100. };
  101. let mut out = std::io::Cursor::new(Vec::new());
  102. img.write_to(&mut out, image::ImageFormat::Png).unwrap();
  103. out.into_inner()
  104. }
  105. #[test]
  106. fn large_images_shrink_and_keep_aspect() {
  107. let p = process_image("big.png", &png(3136, 1000, false)).unwrap();
  108. assert_eq!((p.width, p.height), (1568, 500));
  109. assert_eq!(p.mime, "image/jpeg");
  110. let small = process_image("s.png", &png(300, 200, true)).unwrap();
  111. assert_eq!((small.width, small.height, small.mime), (300, 200, "image/png"));
  112. }
  113. #[test]
  114. fn stored_by_hash_once() {
  115. let dir = tempfile::tempdir().unwrap();
  116. let p = process_image("a.png", &png(10, 10, false)).unwrap();
  117. let a = store(dir.path(), &p).unwrap();
  118. let b = store(dir.path(), &p).unwrap();
  119. assert_eq!(a, b);
  120. assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 1);
  121. }
  122. #[test]
  123. fn rejects_bad_input() {
  124. assert_eq!(process_image("x.png", b"nope").unwrap_err(), AttachError::BadImage("x.png".into()));
  125. assert!(matches!(read_text("big.txt", &vec![b'a'; MAX_TEXT_BYTES + 1]), Err(AttachError::TooLarge(..))));
  126. assert_eq!(read_text("bin", &[0xff, 0xfe]).unwrap_err(), AttachError::NotText("bin".into()));
  127. }
  128. #[test]
  129. fn text_blocks_and_data_urls() {
  130. assert_eq!(text_block("a.md", "hi\n"), "File: a.md\n```\nhi\n```");
  131. assert!(text_block("b.md", "```rust\n```").starts_with("File: b.md\n~~~~\n"));
  132. assert_eq!(data_url("image/png", b"hi"), "data:image/png;base64,aGk=");
  133. assert!(is_image_name("Photo.JPEG") && !is_image_name("notes.txt"));
  134. }
  135. }