1use std::fs;
22use std::io::Write;
23use std::path::{Path, PathBuf};
24
25use image::ImageEncoder;
26
27use crate::error::{Error, Result};
28use crate::library::Library;
29use crate::thumbnail::NO_THUMBNAIL_SIZE;
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum Format {
34 Png,
35 Jpg,
36}
37
38impl Format {
39 pub fn ext(self) -> &'static str {
40 match self {
41 Format::Png => "png",
42 Format::Jpg => "jpg",
43 }
44 }
45}
46
47#[derive(Debug, Clone, PartialEq)]
49pub enum Converted {
50 Done,
52 KeptOld(PathBuf),
55 Animated,
57 NotWebp,
59}
60
61pub fn is_animated(bytes: &[u8]) -> bool {
64 bytes.windows(4).any(|w| w == b"ANMF")
65}
66
67pub fn encode(img: &image::DynamicImage, format: Format) -> std::result::Result<Vec<u8>, String> {
70 let mut out = Vec::new();
71 match format {
72 Format::Png => {
73 img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).map_err(|e| e.to_string())?;
74 }
75 Format::Jpg => {
76 let rgba = img.to_rgba8();
77 let mut rgb = image::RgbImage::new(rgba.width(), rgba.height());
78 for (d, s) in rgb.pixels_mut().zip(rgba.pixels()) {
79 let a = s[3] as u32;
80 for c in 0..3 {
81 d[c] = ((s[c] as u32 * a + 255 * (255 - a) + 127) / 255) as u8;
82 }
83 }
84 image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, 100)
85 .write_image(rgb.as_raw(), rgb.width(), rgb.height(), image::ExtendedColorType::Rgb8)
86 .map_err(|e| e.to_string())?;
87 }
88 }
89 Ok(out)
90}
91
92impl Library {
93 pub fn convert_webp(&mut self, id: &str, format: Format, discard: impl FnOnce(&Path) -> std::io::Result<()>) -> Result<Converted> {
96 let mut item = self.item(id).cloned().ok_or_else(|| self.invalid(format!("no item {id}")))?;
97 if !item.ext.eq_ignore_ascii_case("webp") {
98 return Ok(Converted::NotWebp);
99 }
100 let old = item.original_path(self.root());
101 let bytes = fs::read(&old).map_err(|e| Error::io(&old, e))?;
102 if is_animated(&bytes) {
103 return Ok(Converted::Animated);
104 }
105 let img = image::load_from_memory_with_format(&bytes, image::ImageFormat::WebP).map_err(|e| self.invalid(format!("can't read the WebP: {e}")))?;
106 let encoded = encode(&img, format).map_err(|e| self.invalid(format!("can't write the {}: {e}", format.ext())))?;
107 let dir = item.info_dir(self.root());
108 let name = crate::disk_name::on_disk(&item.name);
109 let new = dir.join(&*crate::disk_name::on_disk(&format!("{name}.{}", format.ext())));
110 if new.exists() {
111 return Err(self.invalid(format!("{} already exists", new.display())));
112 }
113 let staged = dir.join(format!(".{name}.{}.falcon-convert", format.ext()));
114 let kept = dir.join(format!(".{name}.webp.falcon-old"));
115 let written = fs::File::create(&staged).and_then(|mut f| f.write_all(&encoded).and_then(|()| f.sync_all()));
116 if let Err(e) = written {
117 let _ = fs::remove_file(&staged);
118 return Err(Error::io(&staged, e));
119 }
120 if let Err(e) = fs::rename(&old, &kept) {
121 let _ = fs::remove_file(&staged);
122 return Err(Error::io(&old, e));
123 }
124 if let Err(e) = fs::rename(&staged, &new) {
125 let _ = fs::rename(&kept, &old);
126 let _ = fs::remove_file(&staged);
127 return Err(Error::io(&new, e));
128 }
129 let thumb = item.thumbnail_path(self.root());
130 let small = img.width() <= NO_THUMBNAIL_SIZE && img.height() <= NO_THUMBNAIL_SIZE;
131 item.ext = format.ext().to_string();
132 item.size = encoded.len() as u64;
133 if small {
134 item.no_thumbnail = Some(true);
135 item.remove_thumbnail = None;
136 }
137 if let Err(e) = self.update_item(item) {
138 let _ = fs::remove_file(&new);
139 let _ = fs::rename(&kept, &old);
140 return Err(e);
141 }
142 if let Some(j) = &mut self.journal {
143 j.irreversible = true;
144 }
145 if small && let Some(t) = thumb.filter(|t| t.file_name().is_some_and(|n| n.to_string_lossy().ends_with("_thumbnail.png"))) {
147 let _ = fs::remove_file(t);
148 }
149 Ok(match discard(&kept) {
150 Ok(()) => Converted::Done,
151 Err(_) => Converted::KeptOld(kept),
152 })
153 }
154}
155
156#[cfg(test)]
157mod tests {
158 use super::*;
159 use crate::import::ImportOptions;
160
161 fn webp(dir: &Path, name: &str, w: u32, h: u32, alpha: u8) -> PathBuf {
162 let img = image::RgbaImage::from_pixel(w, h, image::Rgba([200, 10, 10, alpha]));
163 let enc = webp::Encoder::from_rgba(img.as_raw(), w, h).encode_lossless();
164 let p = dir.join(name);
165 fs::write(&p, &*enc).unwrap();
166 p
167 }
168
169 fn setup(w: u32, h: u32, alpha: u8) -> (tempfile::TempDir, Library, String) {
170 let d = tempfile::tempdir().unwrap();
171 let mut lib = Library::create(d.path().join("L.library")).unwrap();
172 let src = webp(d.path(), "pic.webp", w, h, alpha);
173 let id = lib.import_file(&src, &ImportOptions::default()).unwrap();
174 (d, lib, id)
175 }
176
177 #[test]
178 fn converts_to_png_and_hands_over_the_old_file() {
179 let (_d, mut lib, id) = setup(40, 30, 255);
180 let mut handed = None;
181 let got = lib.convert_webp(&id, Format::Png, |p| {
182 handed = Some(p.to_path_buf());
183 fs::remove_file(p)
184 });
185 assert_eq!(got.unwrap(), Converted::Done);
186 let item = lib.item(&id).unwrap().clone();
187 assert_eq!(item.ext, "png");
188 let path = item.original_path(lib.root());
189 assert_eq!(item.size, fs::metadata(&path).unwrap().len());
190 assert_eq!(image::open(&path).unwrap().width(), 40);
191 assert_eq!(item.width, 40);
192 assert!(handed.unwrap().to_string_lossy().ends_with(".pic.webp.falcon-old"));
193 let mut names: Vec<String> = fs::read_dir(item.info_dir(lib.root())).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect();
195 names.sort();
196 assert_eq!(names, ["metadata.json", "pic.png"]);
197 assert_eq!(item.no_thumbnail, Some(true));
198 let reopened = Library::open(lib.root()).unwrap();
200 assert_eq!(reopened.item(&id).unwrap().ext, "png");
201 }
202
203 #[test]
204 fn jpg_lays_transparency_on_white() {
205 let (_d, mut lib, id) = setup(8, 8, 0);
206 lib.convert_webp(&id, Format::Jpg, |p| fs::remove_file(p)).unwrap();
207 let item = lib.item(&id).unwrap().clone();
208 assert_eq!(item.ext, "jpg");
209 let px = image::open(item.original_path(lib.root())).unwrap().to_rgb8().get_pixel(4, 4).0;
210 assert!(px.iter().all(|&c| c > 245), "{px:?}");
211 }
212
213 #[test]
214 fn a_failed_discard_keeps_the_old_file() {
215 let (_d, mut lib, id) = setup(8, 8, 255);
216 let got = lib.convert_webp(&id, Format::Png, |_| Err(std::io::Error::other("no Trash"))).unwrap();
217 let Converted::KeptOld(p) = got else { panic!("{got:?}") };
218 assert!(p.exists());
219 }
220
221 #[test]
222 fn a_large_image_keeps_its_thumbnail() {
223 let (_d, mut lib, id) = setup(1200, 20, 255);
224 let thumb = lib.item(&id).unwrap().thumbnail_path(lib.root()).unwrap();
225 assert!(thumb.exists());
226 lib.convert_webp(&id, Format::Png, |p| fs::remove_file(p)).unwrap();
227 assert!(thumb.exists());
228 assert_ne!(lib.item(&id).unwrap().no_thumbnail, Some(true));
229 }
230
231 #[test]
232 fn skips_animations_other_types_and_existing_names() {
233 let (d, mut lib, id) = setup(8, 8, 255);
234 let item = lib.item(&id).unwrap().clone();
235 fs::write(item.info_dir(lib.root()).join("pic.png"), b"x").unwrap();
236 assert!(lib.convert_webp(&id, Format::Png, |p| fs::remove_file(p)).is_err());
237 assert_eq!(lib.item(&id).unwrap().ext, "webp");
238 assert!(item.original_path(lib.root()).exists());
239 let txt = d.path().join("a.txt");
240 fs::write(&txt, b"hi").unwrap();
241 let tid = lib.import_file(&txt, &ImportOptions::default()).unwrap();
242 assert_eq!(lib.convert_webp(&tid, Format::Png, |p| fs::remove_file(p)).unwrap(), Converted::NotWebp);
243 assert!(is_animated(b"RIFF....WEBPVP8XANIM....ANMF"));
244 assert!(!is_animated(b"RIFF....WEBPVP8 "));
245 }
246
247 #[test]
248 fn the_undo_step_is_irreversible() {
249 let (_d, mut lib, id) = setup(8, 8, 255);
250 lib.begin_transaction("Convert");
251 lib.convert_webp(&id, Format::Png, |p| fs::remove_file(p)).unwrap();
252 assert!(lib.end_transaction().unwrap().irreversible);
253 }
254}