mecha_core/image.rs
1//! Turning a file on disk into an image block a provider will accept.
2//!
3//! One function does the whole job, and it is here rather than at each entry
4//! point because there are three of those already — the Slack connector, the
5//! TUI, and whatever comes next — and the caps below are the kind of number
6//! that gets copied once and then diverges.
7//!
8//! Two caps, and they are enforced for different reasons:
9//!
10//! - **[`MAX_BYTES`] is a provider limit.** Anthropic rejects any single
11//! image over 5 MB outright. llama-server does not care — measured here, a
12//! 5.6 MB PNG went through and cost ~256 prompt tokens, because the server
13//! tiles it before the model ever sees it. So the cap is not about context
14//! at all: it is the smaller of what the two backends accept, applied to
15//! both, because a conversation is one object and a `/model` switch must
16//! not turn a working transcript into a rejected request.
17//! - **[`MAX_EDGE`] is about what is worth carrying.** Above roughly this,
18//! both families downsample server-side anyway, so the extra pixels buy
19//! nothing and are paid for twice — once on the wire, and once *for the
20//! life of the session*, because the transcript is append-only and every
21//! turn resends the whole history.
22//!
23//! The second cost is the one that decides the shape here. A resized image
24//! is what gets recorded, never the original, so the bill is paid once at
25//! the door rather than on every turn afterwards.
26
27use crate::message::{image_media_type, Block};
28use anyhow::{bail, Context, Result};
29use std::path::Path;
30
31/// The largest encoded image any provider here will be handed.
32///
33/// Anthropic's documented hard limit. Deliberately applied to local servers
34/// too — see the module docs.
35pub const MAX_BYTES: usize = 5 * 1024 * 1024;
36
37/// Longest edge kept. Both provider families downsample above about this, so
38/// pixels beyond it are re-sent every turn and never looked at.
39pub const MAX_EDGE: u32 = 1568;
40
41/// What a re-encode costs in fidelity. 85 is the usual "cannot tell without
42/// looking for it" point, and the thing being carried is almost always a
43/// screenshot of text, where the artefacts that matter are the ones that
44/// close up a glyph.
45const JPEG_QUALITY: u8 = 85;
46
47/// Read `path` and produce an image block bounded by the caps above.
48///
49/// **Untouched when it already fits.** A small PNG is passed through byte for
50/// byte rather than round-tripped through a decoder — re-encoding a crisp
51/// screenshot of text as JPEG to no purpose is a real loss, and it is the
52/// exact case this is most often used for.
53///
54/// Returns `Ok(None)` when the extension is not one both backends read, so a
55/// caller can say "here is a path" for a PDF instead of failing.
56pub fn block_from_path(path: &Path) -> Result<Option<Block>> {
57 let Some(media_type) = image_media_type(path) else {
58 return Ok(None);
59 };
60 let bytes = std::fs::read(path).with_context(|| format!("reading {}", path.display()))?;
61 let name = path.file_name().map(|n| n.to_string_lossy().into_owned());
62
63 // Dimensions are read from the header alone, so the common case — an
64 // image that is already small — never pays to decode the pixels.
65 let dims = image::image_dimensions(path).ok();
66 let oversized = dims.is_some_and(|(w, h)| w.max(h) > MAX_EDGE);
67 if !oversized && bytes.len() <= MAX_BYTES {
68 return Ok(Some(Block::image(media_type, &bytes, name)));
69 }
70
71 let img = image::load_from_memory(&bytes)
72 .with_context(|| format!("{} is named as an image but did not decode", path.display()))?;
73 // `thumbnail` preserves the aspect ratio and takes the *bound* rather
74 // than a target, so an image that is oversized in only one dimension is
75 // not stretched to fill the other.
76 let img = img.thumbnail(MAX_EDGE, MAX_EDGE);
77
78 let mut out = Vec::new();
79 // JPEG regardless of what came in. The alternative — keeping PNG — makes
80 // the size of the result depend on the *content*: a photograph of a
81 // screen, which is the case that motivated all of this, is several
82 // megabytes as a PNG at any resolution worth sending, so the resize
83 // would leave it still over the cap and the failure would look like the
84 // resize not working.
85 img.to_rgb8()
86 .write_with_encoder(image::codecs::jpeg::JpegEncoder::new_with_quality(
87 &mut out,
88 JPEG_QUALITY,
89 ))
90 .context("re-encoding a resized image")?;
91
92 if out.len() > MAX_BYTES {
93 bail!(
94 "{} is {} after resizing to {MAX_EDGE}px and stays above the {} limit",
95 path.display(),
96 human(out.len()),
97 human(MAX_BYTES),
98 );
99 }
100 Ok(Some(Block::image("image/jpeg", &out, name)))
101}
102
103fn human(bytes: usize) -> String {
104 if bytes >= 1024 * 1024 {
105 format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
106 } else {
107 format!("{:.0} KB", bytes as f64 / 1024.0)
108 }
109}
110
111#[cfg(test)]
112mod tests {
113 use super::*;
114 use crate::message::Block;
115
116 fn png(w: u32, h: u32) -> Vec<u8> {
117 let img = image::RgbImage::from_fn(w, h, |x, y| {
118 image::Rgb([(x % 256) as u8, (y % 256) as u8, 128])
119 });
120 let mut out = Vec::new();
121 image::DynamicImage::ImageRgb8(img)
122 .write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png)
123 .unwrap();
124 out
125 }
126
127 fn write(dir: &std::path::Path, name: &str, bytes: &[u8]) -> std::path::PathBuf {
128 let p = dir.join(name);
129 std::fs::write(&p, bytes).unwrap();
130 p
131 }
132
133 /// The case this whole path exists for: a small screenshot must reach the
134 /// model exactly as it was taken. A re-encode here would blur the text
135 /// that is the entire reason somebody sent a screenshot.
136 #[test]
137 fn an_image_that_already_fits_is_passed_through_byte_for_byte() {
138 let dir = std::env::temp_dir().join(format!("mecha-img-{}", std::process::id()));
139 std::fs::create_dir_all(&dir).unwrap();
140 let bytes = png(64, 48);
141 let p = write(&dir, "small.png", &bytes);
142
143 let block = block_from_path(&p).unwrap().unwrap();
144 let Block::Image {
145 media_type,
146 data,
147 source,
148 } = block
149 else {
150 panic!("expected an image block")
151 };
152 assert_eq!(media_type, "image/png", "the source format is kept");
153 assert_eq!(source.as_deref(), Some("small.png"));
154
155 use base64::Engine as _;
156 let decoded = base64::engine::general_purpose::STANDARD
157 .decode(&data)
158 .unwrap();
159 assert_eq!(decoded, bytes, "the original bytes, not a re-encode");
160 std::fs::remove_dir_all(&dir).ok();
161 }
162
163 /// Verified to fail on the old behaviour by construction: without the
164 /// resize this block would carry a 4000px image, and the assertion is on
165 /// the dimensions of what came back rather than merely on its size.
166 #[test]
167 fn an_oversized_image_is_resized_and_re_encoded() {
168 let dir = std::env::temp_dir().join(format!("mecha-img-big-{}", std::process::id()));
169 std::fs::create_dir_all(&dir).unwrap();
170 let p = write(&dir, "huge.png", &png(4000, 2000));
171
172 let block = block_from_path(&p).unwrap().unwrap();
173 let Block::Image {
174 media_type, data, ..
175 } = block
176 else {
177 panic!("expected an image block")
178 };
179 assert_eq!(media_type, "image/jpeg", "a resize re-encodes");
180
181 use base64::Engine as _;
182 let decoded = base64::engine::general_purpose::STANDARD
183 .decode(&data)
184 .unwrap();
185 assert!(decoded.len() <= MAX_BYTES, "under the provider cap");
186 let (w, h) = image::load_from_memory(&decoded)
187 .map(|i| {
188 (
189 image::GenericImageView::width(&i),
190 image::GenericImageView::height(&i),
191 )
192 })
193 .unwrap();
194 assert!(
195 w.max(h) <= MAX_EDGE,
196 "long edge {w}x{h} bounded by {MAX_EDGE}"
197 );
198 assert_eq!(w * 2000, h * 4000, "aspect ratio preserved, not stretched");
199 std::fs::remove_dir_all(&dir).ok();
200 }
201
202 /// A caller must be able to tell "not an image" from "an image that
203 /// failed", because the first is a normal thing to attach and the answer
204 /// to it is to name the path.
205 #[test]
206 fn a_file_that_is_not_an_image_is_none_rather_than_an_error() {
207 let dir = std::env::temp_dir().join(format!("mecha-img-pdf-{}", std::process::id()));
208 std::fs::create_dir_all(&dir).unwrap();
209 let p = write(&dir, "report.pdf", b"%PDF-1.4");
210 assert!(block_from_path(&p).unwrap().is_none());
211 std::fs::remove_dir_all(&dir).ok();
212 }
213
214 /// The extension is a claim, not a fact — the file arrived from Slack.
215 #[test]
216 fn a_file_named_png_that_is_not_one_fails_loudly() {
217 let dir = std::env::temp_dir().join(format!("mecha-img-lie-{}", std::process::id()));
218 std::fs::create_dir_all(&dir).unwrap();
219 let p = write(&dir, "lie.png", &vec![7u8; 6 * 1024 * 1024]);
220 let err = block_from_path(&p).unwrap_err().to_string();
221 assert!(err.contains("did not decode"), "got: {err}");
222 std::fs::remove_dir_all(&dir).ok();
223 }
224}