You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

349 lines
13KB

  1. #[macro_use]
  2. extern crate lazy_static;
  3. extern crate regex;
  4. extern crate image;
  5. extern crate rayon;
  6. extern crate utils;
  7. extern crate errors;
  8. use std::path::{Path, PathBuf};
  9. use std::hash::{Hash, Hasher};
  10. use std::collections::HashMap;
  11. use std::collections::hash_map::Entry as HEntry;
  12. use std::collections::hash_map::DefaultHasher;
  13. use std::fs::{self, File};
  14. use regex::Regex;
  15. use image::{GenericImage, FilterType};
  16. use image::jpeg::JPEGEncoder;
  17. use rayon::prelude::*;
  18. use utils::fs as ufs;
  19. use errors::{Result, ResultExt};
  20. static RESIZED_SUBDIR: &'static str = "_processed_images";
  21. lazy_static!{
  22. pub static ref RESIZED_FILENAME: Regex = Regex::new(r#"([0-9a-f]{16})([0-9a-f]{2})[.]jpg"#).unwrap();
  23. }
  24. /// Describes the precise kind of a resize operation
  25. #[derive(Debug, Clone, Copy, PartialEq, Eq)]
  26. pub enum ResizeOp {
  27. /// A simple scale operation that doesn't take aspect ratio into account
  28. Scale(u32, u32),
  29. /// Scales the image to a specified width with height computed such that aspect ratio is preserved
  30. FitWidth(u32),
  31. /// Scales the image to a specified height with width computed such that aspect ratio is preserved
  32. FitHeight(u32),
  33. /// Scales the image such that it fits within the specified width and height preserving aspect ratio.
  34. /// Either dimension may end up being smaller, but never larger than specified.
  35. Fit(u32, u32),
  36. /// Scales the image such that it fills the specified width and height. Output will always have the exact dimensions specified.
  37. /// The part of the image that doesn't fit in the thumbnail due to differing aspect ratio will be cropped away, if any.
  38. Fill(u32, u32),
  39. }
  40. impl ResizeOp {
  41. pub fn from_args(op: &str, width: Option<u32>, height: Option<u32>) -> Result<ResizeOp> {
  42. use ResizeOp::*;
  43. // Validate args:
  44. match op {
  45. "fit_width" => if width.is_none() { return Err(format!("op=\"fit_width\" requires a `width` argument").into()) },
  46. "fit_height" => if height.is_none() { return Err(format!("op=\"fit_height\" requires a `height` argument").into()) },
  47. "scale" | "fit" | "fill" => if width.is_none() || height.is_none() {
  48. return Err(format!("op={} requires a `width` and `height` argument", op).into())
  49. },
  50. _ => return Err(format!("Invalid image resize operation: {}", op).into())
  51. };
  52. Ok(match op {
  53. "scale" => Scale(width.unwrap(), height.unwrap()),
  54. "fit_width" => FitWidth(width.unwrap()),
  55. "fit_height" => FitHeight(height.unwrap()),
  56. "fit" => Fit(width.unwrap(), height.unwrap()),
  57. "fill" => Fill(width.unwrap(), height.unwrap()),
  58. _ => unreachable!(),
  59. })
  60. }
  61. pub fn width(self) -> Option<u32> {
  62. use ResizeOp::*;
  63. match self {
  64. Scale(w, _) => Some(w),
  65. FitWidth(w) => Some(w),
  66. FitHeight(_) => None,
  67. Fit(w, _) => Some(w),
  68. Fill(w, _) => Some(w),
  69. }
  70. }
  71. pub fn height(self) -> Option<u32> {
  72. use ResizeOp::*;
  73. match self {
  74. Scale(_, h) => Some(h),
  75. FitWidth(_) => None,
  76. FitHeight(h) => Some(h),
  77. Fit(_, h) => Some(h),
  78. Fill(_, h) => Some(h),
  79. }
  80. }
  81. }
  82. impl From<ResizeOp> for u8 {
  83. fn from(op: ResizeOp) -> u8 {
  84. use ResizeOp::*;
  85. match op {
  86. Scale(_, _) => 1,
  87. FitWidth(_) => 2,
  88. FitHeight(_) => 3,
  89. Fit(_, _) => 4,
  90. Fill(_, _) => 5,
  91. }
  92. }
  93. }
  94. impl Hash for ResizeOp {
  95. fn hash<H: Hasher>(&self, hasher: &mut H) {
  96. hasher.write_u8(u8::from(*self));
  97. if let Some(w) = self.width() { hasher.write_u32(w); }
  98. if let Some(h) = self.height() { hasher.write_u32(h); }
  99. }
  100. }
  101. /// Holds all data needed to perform a resize operation
  102. #[derive(Debug, PartialEq, Eq)]
  103. pub struct ImageOp {
  104. source: String,
  105. op: ResizeOp,
  106. quality: u8,
  107. /// Hash of the above parameters
  108. hash: u64,
  109. /// If there is a hash collision with another ImageOp, this contains a sequential ID > 1
  110. /// identifying the collision in the order as encountered (which is essentially random).
  111. /// Therefore, ImageOps with collisions (ie. collision_id > 0) are always considered out of date.
  112. collision_id: u32,
  113. }
  114. impl ImageOp {
  115. pub fn new(source: String, op: ResizeOp, quality: u8) -> ImageOp {
  116. let mut hasher = DefaultHasher::new();
  117. hasher.write(source.as_ref());
  118. op.hash(&mut hasher);
  119. hasher.write_u8(quality);
  120. let hash = hasher.finish();
  121. ImageOp { source, op, quality, hash, collision_id: 0 }
  122. }
  123. pub fn from_args(source: String, op: &str, width: Option<u32>, height: Option<u32>, quality: u8) -> Result<ImageOp> {
  124. let op = ResizeOp::from_args(op, width, height)?;
  125. Ok(Self::new(source, op, quality))
  126. }
  127. fn perform(&self, content_path: &Path, target_path: &Path) -> Result<()> {
  128. use ResizeOp::*;
  129. let src_path = content_path.join(&self.source);
  130. if !ufs::file_stale(&src_path, target_path) {
  131. return Ok(())
  132. }
  133. let mut img = image::open(&src_path)?;
  134. let (img_w, img_h) = img.dimensions();
  135. const RESIZE_FILTER: FilterType = FilterType::Gaussian;
  136. const RATIO_EPSILLION: f32 = 0.1;
  137. let img = match self.op {
  138. Scale(w, h) => img.resize_exact(w, h, RESIZE_FILTER),
  139. FitWidth(w) => img.resize(w, u32::max_value(), RESIZE_FILTER),
  140. FitHeight(h) => img.resize(u32::max_value(), h, RESIZE_FILTER),
  141. Fit(w, h) => img.resize(w, h, RESIZE_FILTER),
  142. Fill(w, h) => {
  143. let factor_w = img_w as f32 / w as f32;
  144. let factor_h = img_h as f32 / h as f32;
  145. if (factor_w - factor_h).abs() <= RATIO_EPSILLION {
  146. // If the horizontal and vertical factor is very similar, that means the aspect is similar enough
  147. // that there's not much point in cropping, so just perform a simple scale in this case.
  148. img.resize_exact(w, h, RESIZE_FILTER)
  149. } else {
  150. // We perform the fill such that a crop is performed first and then resize_exact can be used,
  151. // which should be cheaper than resizing and then cropping (smaller number of pixels to resize).
  152. let (crop_w, crop_h) = if factor_w < factor_h {
  153. (img_w, (factor_w * h as f32).round() as u32)
  154. } else {
  155. ((factor_h * w as f32).round() as u32, img_h)
  156. };
  157. let (offset_w, offset_h) = if factor_w < factor_h {
  158. (0, (img_h - crop_h) / 2)
  159. } else {
  160. ((img_w - crop_w) / 2, 0)
  161. };
  162. img.crop(offset_w, offset_h, crop_w, crop_h).resize_exact(w, h, RESIZE_FILTER)
  163. }
  164. },
  165. };
  166. let mut f = File::create(target_path)?;
  167. let mut enc = JPEGEncoder::new_with_quality(&mut f, self.quality);
  168. let (img_w, img_h) = img.dimensions();
  169. enc.encode(&img.raw_pixels(), img_w, img_h, img.color())?;
  170. Ok(())
  171. }
  172. }
  173. /// A strcture into which image operations can be enqueued and then performed.
  174. /// All output is written in a subdirectory in `static_path`,
  175. /// taking care of file stale status based on timestamps and possible hash collisions.
  176. #[derive(Debug)]
  177. pub struct Processor {
  178. content_path: PathBuf,
  179. resized_path: PathBuf,
  180. resized_url: String,
  181. /// A map of a ImageOps by their stored hash.
  182. /// Note that this cannot be a HashSet, because hashset handles collisions and we don't want that,
  183. /// we need to be aware of and handle collisions ourselves.
  184. img_ops: HashMap<u64, ImageOp>,
  185. /// Hash collisions go here:
  186. img_ops_collisions: Vec<ImageOp>,
  187. }
  188. impl Processor {
  189. pub fn new(content_path: PathBuf, static_path: &Path, base_url: &str) -> Processor {
  190. Processor {
  191. content_path,
  192. resized_path: static_path.join(RESIZED_SUBDIR),
  193. resized_url: Self::resized_url(base_url),
  194. img_ops: HashMap::new(),
  195. img_ops_collisions: Vec::new(),
  196. }
  197. }
  198. fn resized_url(base_url: &str) -> String {
  199. if base_url.ends_with('/') {
  200. format!("{}{}", base_url, RESIZED_SUBDIR)
  201. } else {
  202. format!("{}/{}", base_url, RESIZED_SUBDIR)
  203. }
  204. }
  205. pub fn set_base_url(&mut self, base_url: &str) {
  206. self.resized_url = Self::resized_url(base_url);
  207. }
  208. pub fn source_exists(&self, source: &str) -> bool {
  209. self.content_path.join(source).exists()
  210. }
  211. pub fn num_img_ops(&self) -> usize {
  212. self.img_ops.len() + self.img_ops_collisions.len()
  213. }
  214. fn insert_with_collisions(&mut self, mut img_op: ImageOp) -> u32 {
  215. match self.img_ops.entry(img_op.hash) {
  216. HEntry::Occupied(entry) => if *entry.get() == img_op { return 0; },
  217. HEntry::Vacant(entry) => {
  218. entry.insert(img_op);
  219. return 0;
  220. },
  221. }
  222. // If we get here, that means a hash collision.
  223. // This is detected when there is an ImageOp with the same hash in the `img_ops` map but which is not equal to this one.
  224. // To deal with this, all collisions get a (random) sequential ID number.
  225. // First try to look up this ImageOp in `img_ops_collisions`, maybe we've already seen the same ImageOp.
  226. // At the same time, count IDs to figure out the next free one.
  227. // Start with the ID of 2, because we'll need to use 1 for the ImageOp already present in the map:
  228. let mut collision_id = 2;
  229. for op in self.img_ops_collisions.iter().filter(|op| op.hash == img_op.hash) {
  230. if *op == img_op {
  231. // This is a colliding ImageOp, but we've already seen an equal one (not just by hash, but by content too),
  232. // so just return its ID:
  233. return collision_id;
  234. } else {
  235. collision_id += 1;
  236. }
  237. }
  238. // If we get here, that means this is a new colliding ImageOp and `collision_id` is the next free ID
  239. if collision_id == 2 {
  240. // This is the first collision found with this hash, update the ID of the matching ImageOp in the map.
  241. self.img_ops.get_mut(&img_op.hash).unwrap().collision_id = 1;
  242. }
  243. img_op.collision_id = collision_id;
  244. self.img_ops_collisions.push(img_op);
  245. collision_id
  246. }
  247. fn op_filename(hash: u64, collision_id: u32) -> String {
  248. // Please keep this in sync with RESIZED_FILENAME
  249. assert!(collision_id < 256, "Unexpectedly large number of collisions: {}", collision_id);
  250. format!("{:016x}{:02x}.jpg", hash, collision_id)
  251. }
  252. fn op_url(&self, hash: u64, collision_id: u32) -> String {
  253. format!("{}/{}", &self.resized_url, Self::op_filename(hash, collision_id))
  254. }
  255. pub fn insert(&mut self, img_op: ImageOp) -> String {
  256. let hash = img_op.hash;
  257. let collision_id = self.insert_with_collisions(img_op);
  258. self.op_url(hash, collision_id)
  259. }
  260. pub fn prune(&self) -> Result<()> {
  261. ufs::ensure_directory_exists(&self.resized_path)?;
  262. let entries = fs::read_dir(&self.resized_path)?;
  263. for entry in entries {
  264. let entry_path = entry?.path();
  265. if entry_path.is_file() {
  266. let filename = entry_path.file_name().unwrap().to_string_lossy();
  267. if let Some(capts) = RESIZED_FILENAME.captures(filename.as_ref()) {
  268. let hash = u64::from_str_radix(capts.get(1).unwrap().as_str(), 16).unwrap();
  269. let collision_id = u32::from_str_radix(capts.get(2).unwrap().as_str(), 16).unwrap();
  270. if collision_id > 0 || !self.img_ops.contains_key(&hash) {
  271. fs::remove_file(&entry_path)?;
  272. }
  273. }
  274. }
  275. }
  276. Ok(())
  277. }
  278. pub fn do_process(&mut self) -> Result<()> {
  279. self.img_ops.par_iter().map(|(hash, op)| {
  280. let target = self.resized_path.join(Self::op_filename(*hash, op.collision_id));
  281. op.perform(&self.content_path, &target)
  282. .chain_err(|| format!("Failed to process image: {}", op.source))
  283. })
  284. .fold(|| Ok(()), Result::and)
  285. .reduce(|| Ok(()), Result::and)
  286. }
  287. }
  288. /// Looks at file's extension and returns whether it's a supported image format
  289. pub fn file_is_img<P: AsRef<Path>>(p: P) -> bool {
  290. p.as_ref().extension().and_then(|s| s.to_str()).map(|ext| {
  291. match ext.to_lowercase().as_str() {
  292. "jpg" | "jpeg" => true,
  293. "png" => true,
  294. "gif" => true,
  295. "bmp" => true,
  296. _ => false,
  297. }
  298. }).unwrap_or(false)
  299. }