Krolyxon
Commit c498905

Branches

Tags

Refs →

remagesrcdecodemod.rs

165 lines · 5.3 KB · Rust

1//! Format sniffing and dispatch to the decoder backends.
2
3pub mod color;
4
5use std::fs::File;
6use std::io::Read;
7use std::path::Path;
8use std::sync::LazyLock;
9
10use anyhow::{Context, anyhow};
11use fast_image_resize as fir;
12use image::{DynamicImage, ImageDecoder, ImageFormat, ImageReader, metadata::Orientation};
13
14/// Refuse single images that would need more than this to decode.
15const MAX_ALLOC: u64 = 4 << 30;
16
17/// Straight-alpha RGBA8 in sRGB, oriented upright, ready to upload.
18pub struct DecodedImage {
19 pub size: [u32; 2],
20 pub pixels: Vec<u8>,
21 pub format: &'static str,
22 /// Size before any downscale to fit the GPU's texture limit.
23 pub source_size: [u32; 2],
24}
25
26impl DecodedImage {
27 pub fn bytes(&self) -> usize {
28 self.pixels.len()
29 }
30}
31
32impl std::fmt::Debug for DecodedImage {
33 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
34 f.debug_struct("DecodedImage")
35 .field("size", &self.size)
36 .field("format", &self.format)
37 .finish_non_exhaustive()
38 }
39}
40
41static EXTENSIONS: LazyLock<Vec<&'static str>> = LazyLock::new(|| {
42 ImageFormat::all()
43 .filter(ImageFormat::reading_enabled)
44 .flat_map(|f| f.extensions_str().iter().copied())
45 .chain(["apng", "jfif"])
46 .collect()
47});
48
49/// Whether the extension alone says we can decode this file.
50pub fn is_known_extension(path: &Path) -> bool {
51 path.extension()
52 .and_then(|e| e.to_str())
53 .is_some_and(|e| EXTENSIONS.iter().any(|k| k.eq_ignore_ascii_case(e)))
54}
55
56/// Check magic bytes for files whose extension is unknown.
57pub fn sniff(path: &Path) -> bool {
58 let mut buf = [0u8; 64];
59 let Ok(mut f) = File::open(path) else {
60 return false;
61 };
62 let Ok(n) = f.read(&mut buf) else {
63 return false;
64 };
65 image::guess_format(&buf[..n]).is_ok_and(|f| f.reading_enabled())
66}
67
68fn label(format: ImageFormat) -> &'static str {
69 match format {
70 ImageFormat::Png => "PNG",
71 ImageFormat::Jpeg => "JPEG",
72 ImageFormat::Gif => "GIF",
73 ImageFormat::WebP => "WEBP",
74 ImageFormat::Pnm => "PNM",
75 ImageFormat::Tiff => "TIFF",
76 ImageFormat::Tga => "TGA",
77 ImageFormat::Dds => "DDS",
78 ImageFormat::Bmp => "BMP",
79 ImageFormat::Ico => "ICO",
80 ImageFormat::Hdr => "HDR",
81 ImageFormat::OpenExr => "EXR",
82 ImageFormat::Farbfeld => "FARBFELD",
83 ImageFormat::Avif => "AVIF",
84 ImageFormat::Qoi => "QOI",
85 _ => "IMAGE",
86 }
87}
88
89/// Decode `path`, downscaling if either side exceeds `max_dim` (the GPU texture limit).
90pub fn decode(path: &Path, max_dim: u32) -> anyhow::Result<DecodedImage> {
91 // `open` guesses from the extension; magic bytes take precedence when recognised.
92 let mut reader = ImageReader::open(path)?.with_guessed_format()?;
93 let mut limits = image::Limits::default();
94 limits.max_alloc = Some(MAX_ALLOC);
95 reader.limits(limits);
96 let format = reader.format().ok_or_else(|| anyhow!("unknown format"))?;
97 let mut decoder = reader.into_decoder()?;
98 let orientation = decoder.orientation().unwrap_or(Orientation::NoTransforms);
99 let mut img = DynamicImage::from_decoder(decoder)?;
100 img.apply_orientation(orientation);
101 let rgba = img.into_rgba8();
102 let source_size = [rgba.width(), rgba.height()];
103 let mut out = DecodedImage {
104 size: source_size,
105 pixels: rgba.into_raw(),
106 format: label(format),
107 source_size,
108 };
109 if source_size[0] > max_dim || source_size[1] > max_dim {
110 out = downscale(out, max_dim)?;
111 }
112 Ok(out)
113}
114
115/// Shrink so neither side exceeds `max_dim`. A stopgap until M5 tiles huge images.
116pub fn downscale(img: DecodedImage, max_dim: u32) -> anyhow::Result<DecodedImage> {
117 let [w, h] = img.size;
118 let k = max_dim as f64 / w.max(h) as f64;
119 let (nw, nh) = (
120 ((w as f64 * k) as u32).max(1),
121 ((h as f64 * k) as u32).max(1),
122 );
123 let src = fir::images::Image::from_vec_u8(w, h, img.pixels, fir::PixelType::U8x4)
124 .context("resize source")?;
125 let mut dst = fir::images::Image::new(nw, nh, fir::PixelType::U8x4);
126 fir::Resizer::new()
127 .resize(&src, &mut dst, None)
128 .context("resize")?;
129 log::debug!("downscaled {w}×{h} to {nw}×{nh} for the GPU");
130 Ok(DecodedImage {
131 size: [nw, nh],
132 pixels: dst.into_vec(),
133 ..img
134 })
135}
136
137#[cfg(test)]
138mod tests {
139 use super::*;
140
141 #[test]
142 fn known_extensions() {
143 assert!(is_known_extension(Path::new("a/b.JPG")));
144 assert!(is_known_extension(Path::new("b.png")));
145 assert!(!is_known_extension(Path::new("b.txt")));
146 assert!(!is_known_extension(Path::new("png")));
147 }
148
149 #[test]
150 fn decodes_and_downscales_png() {
151 let dir = std::env::temp_dir().join(format!("remage-test-{}", std::process::id()));
152 std::fs::create_dir_all(&dir).unwrap();
153 let path = dir.join("noext");
154 image::RgbaImage::from_pixel(64, 32, image::Rgba([255, 0, 0, 128]))
155 .save_with_format(&path, ImageFormat::Png)
156 .unwrap();
157 assert!(sniff(&path));
158 let img = decode(&path, 16).unwrap();
159 assert_eq!(img.format, "PNG");
160 assert_eq!(img.source_size, [64, 32]);
161 assert_eq!(img.size, [16, 8]);
162 assert_eq!(img.pixels.len(), 16 * 8 * 4);
163 std::fs::remove_dir_all(&dir).unwrap();
164 }
165}