Skip to main content

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}