1 use std::collections::hash_map::Entry as HEntry;
2 use std::collections::HashMap;
3 use std::error::Error as StdError;
4 use std::ffi::OsStr;
5 use std::fs::{self, File};
6 use std::hash::{Hash, Hasher};
7 use std::path::{Path, PathBuf};
8 use std::{collections::hash_map::DefaultHasher, io::Write};
9 
10 use image::error::ImageResult;
11 use image::io::Reader as ImgReader;
12 use image::{imageops::FilterType, EncodableLayout};
13 use image::{ImageFormat, ImageOutputFormat};
14 use lazy_static::lazy_static;
15 use rayon::prelude::*;
16 use regex::Regex;
17 use serde::{Deserialize, Serialize};
18 use svg_metadata::Metadata as SvgMetadata;
19 
20 use config::Config;
21 use errors::{Error, Result};
22 use utils::fs as ufs;
23 
24 static RESIZED_SUBDIR: &str = "processed_images";
25 const DEFAULT_Q_JPG: u8 = 75;
26 
27 lazy_static! {
28     pub static ref RESIZED_FILENAME: Regex =
29         Regex::new(r#"([0-9a-f]{16})([0-9a-f]{2})[.](jpg|png|webp)"#).unwrap();
30 }
31 
32 /// Size and format read cheaply with `image`'s `Reader`.
33 #[derive(Debug)]
34 struct ImageMeta {
35     size: (u32, u32),
36     format: Option<ImageFormat>,
37 }
38 
39 impl ImageMeta {
read(path: &Path) -> ImageResult<Self>40     fn read(path: &Path) -> ImageResult<Self> {
41         let reader = ImgReader::open(path).and_then(ImgReader::with_guessed_format)?;
42         let format = reader.format();
43         let size = reader.into_dimensions()?;
44 
45         Ok(Self { size, format })
46     }
47 
is_lossy(&self) -> bool48     fn is_lossy(&self) -> bool {
49         use ImageFormat::*;
50 
51         // We assume lossy by default / if unknown format
52         let format = self.format.unwrap_or(Jpeg);
53         !matches!(format, Png | Pnm | Tiff | Tga | Bmp | Ico | Hdr | Farbfeld)
54     }
55 }
56 
57 /// De-serialized & sanitized arguments of `resize_image`
58 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
59 pub enum ResizeArgs {
60     /// A simple scale operation that doesn't take aspect ratio into account
61     Scale(u32, u32),
62     /// Scales the image to a specified width with height computed such
63     /// that aspect ratio is preserved
64     FitWidth(u32),
65     /// Scales the image to a specified height with width computed such
66     /// that aspect ratio is preserved
67     FitHeight(u32),
68     /// If the image is larger than the specified width or height, scales the image such
69     /// that it fits within the specified width and height preserving aspect ratio.
70     /// Either dimension may end up being smaller, but never larger than specified.
71     Fit(u32, u32),
72     /// Scales the image such that it fills the specified width and height.
73     /// Output will always have the exact dimensions specified.
74     /// The part of the image that doesn't fit in the thumbnail due to differing
75     /// aspect ratio will be cropped away, if any.
76     Fill(u32, u32),
77 }
78 
79 impl ResizeArgs {
from_args(op: &str, width: Option<u32>, height: Option<u32>) -> Result<Self>80     pub fn from_args(op: &str, width: Option<u32>, height: Option<u32>) -> Result<Self> {
81         use ResizeArgs::*;
82 
83         // Validate args:
84         match op {
85             "fit_width" => {
86                 if width.is_none() {
87                     return Err("op=\"fit_width\" requires a `width` argument".into());
88                 }
89             }
90             "fit_height" => {
91                 if height.is_none() {
92                     return Err("op=\"fit_height\" requires a `height` argument".into());
93                 }
94             }
95             "scale" | "fit" | "fill" => {
96                 if width.is_none() || height.is_none() {
97                     return Err(
98                         format!("op={} requires a `width` and `height` argument", op).into()
99                     );
100                 }
101             }
102             _ => return Err(format!("Invalid image resize operation: {}", op).into()),
103         };
104 
105         Ok(match op {
106             "scale" => Scale(width.unwrap(), height.unwrap()),
107             "fit_width" => FitWidth(width.unwrap()),
108             "fit_height" => FitHeight(height.unwrap()),
109             "fit" => Fit(width.unwrap(), height.unwrap()),
110             "fill" => Fill(width.unwrap(), height.unwrap()),
111             _ => unreachable!(),
112         })
113     }
114 }
115 
116 /// Contains image crop/resize instructions for use by `Processor`
117 ///
118 /// The `Processor` applies `crop` first, if any, and then `resize`, if any.
119 #[derive(Clone, PartialEq, Eq, Hash, Default, Debug)]
120 struct ResizeOp {
121     crop: Option<(u32, u32, u32, u32)>, // x, y, w, h
122     resize: Option<(u32, u32)>,         // w, h
123 }
124 
125 impl ResizeOp {
new(args: ResizeArgs, (orig_w, orig_h): (u32, u32)) -> Self126     fn new(args: ResizeArgs, (orig_w, orig_h): (u32, u32)) -> Self {
127         use ResizeArgs::*;
128 
129         let res = ResizeOp::default();
130 
131         match args {
132             Scale(w, h) => res.resize((w, h)),
133             FitWidth(w) => {
134                 let h = (orig_h as u64 * w as u64) / orig_w as u64;
135                 res.resize((w, h as u32))
136             }
137             FitHeight(h) => {
138                 let w = (orig_w as u64 * h as u64) / orig_h as u64;
139                 res.resize((w as u32, h))
140             }
141             Fit(w, h) => {
142                 if orig_w <= w && orig_h <= h {
143                     return res; // ie. no-op
144                 }
145 
146                 let orig_w_h = orig_w as u64 * h as u64;
147                 let orig_h_w = orig_h as u64 * w as u64;
148 
149                 if orig_w_h > orig_h_w {
150                     Self::new(FitWidth(w), (orig_w, orig_h))
151                 } else {
152                     Self::new(FitHeight(h), (orig_w, orig_h))
153                 }
154             }
155             Fill(w, h) => {
156                 const RATIO_EPSILLION: f32 = 0.1;
157 
158                 let factor_w = orig_w as f32 / w as f32;
159                 let factor_h = orig_h as f32 / h as f32;
160 
161                 if (factor_w - factor_h).abs() <= RATIO_EPSILLION {
162                     // If the horizontal and vertical factor is very similar,
163                     // that means the aspect is similar enough that there's not much point
164                     // in cropping, so just perform a simple scale in this case.
165                     res.resize((w, h))
166                 } else {
167                     // We perform the fill such that a crop is performed first
168                     // and then resize_exact can be used, which should be cheaper than
169                     // resizing and then cropping (smaller number of pixels to resize).
170                     let (crop_w, crop_h) = if factor_w < factor_h {
171                         (orig_w, (factor_w * h as f32).round() as u32)
172                     } else {
173                         ((factor_h * w as f32).round() as u32, orig_h)
174                     };
175 
176                     let (offset_w, offset_h) = if factor_w < factor_h {
177                         (0, (orig_h - crop_h) / 2)
178                     } else {
179                         ((orig_w - crop_w) / 2, 0)
180                     };
181 
182                     res.crop((offset_w, offset_h, crop_w, crop_h)).resize((w, h))
183                 }
184             }
185         }
186     }
187 
crop(mut self, crop: (u32, u32, u32, u32)) -> Self188     fn crop(mut self, crop: (u32, u32, u32, u32)) -> Self {
189         self.crop = Some(crop);
190         self
191     }
192 
resize(mut self, size: (u32, u32)) -> Self193     fn resize(mut self, size: (u32, u32)) -> Self {
194         self.resize = Some(size);
195         self
196     }
197 }
198 
199 /// Thumbnail image format
200 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
201 pub enum Format {
202     /// JPEG, The `u8` argument is JPEG quality (in percent).
203     Jpeg(u8),
204     /// PNG
205     Png,
206     /// WebP, The `u8` argument is WebP quality (in percent), None meaning lossless.
207     WebP(Option<u8>),
208 }
209 
210 impl Format {
from_args(meta: &ImageMeta, format: &str, quality: Option<u8>) -> Result<Format>211     fn from_args(meta: &ImageMeta, format: &str, quality: Option<u8>) -> Result<Format> {
212         use Format::*;
213         if let Some(quality) = quality {
214             assert!(quality > 0 && quality <= 100, "Quality must be within the range [1; 100]");
215         }
216         let jpg_quality = quality.unwrap_or(DEFAULT_Q_JPG);
217         match format {
218             "auto" => {
219                 if meta.is_lossy() {
220                     Ok(Jpeg(jpg_quality))
221                 } else {
222                     Ok(Png)
223                 }
224             }
225             "jpeg" | "jpg" => Ok(Jpeg(jpg_quality)),
226             "png" => Ok(Png),
227             "webp" => Ok(WebP(quality)),
228             _ => Err(format!("Invalid image format: {}", format).into()),
229         }
230     }
231 
232     /// Looks at file's extension and, if it's a supported image format, returns whether the format is lossless
is_lossy<P: AsRef<Path>>(p: P) -> Option<bool>233     pub fn is_lossy<P: AsRef<Path>>(p: P) -> Option<bool> {
234         p.as_ref()
235             .extension()
236             .and_then(std::ffi::OsStr::to_str)
237             .map(|ext| match ext.to_lowercase().as_str() {
238                 "jpg" | "jpeg" => Some(true),
239                 "png" => Some(false),
240                 "gif" => Some(false),
241                 "bmp" => Some(false),
242                 // It is assumed that webp is lossy, but it can be both
243                 "webp" => Some(true),
244                 _ => None,
245             })
246             .unwrap_or(None)
247     }
248 
extension(&self) -> &str249     fn extension(&self) -> &str {
250         // Kept in sync with RESIZED_FILENAME and op_filename
251         use Format::*;
252 
253         match *self {
254             Png => "png",
255             Jpeg(_) => "jpg",
256             WebP(_) => "webp",
257         }
258     }
259 }
260 
261 #[allow(clippy::derive_hash_xor_eq)]
262 impl Hash for Format {
hash<H: Hasher>(&self, hasher: &mut H)263     fn hash<H: Hasher>(&self, hasher: &mut H) {
264         use Format::*;
265 
266         let q = match *self {
267             Png => 0,
268             Jpeg(q) => q,
269             WebP(None) => 0,
270             WebP(Some(q)) => q,
271         };
272 
273         hasher.write_u8(q);
274         hasher.write(self.extension().as_bytes());
275     }
276 }
277 
278 /// Holds all data needed to perform a resize operation
279 #[derive(Debug, PartialEq, Eq)]
280 pub struct ImageOp {
281     /// This is the source input path string as passed in the template, we need this to compute the hash.
282     /// Hashing the resolved `input_path` would include the absolute path to the image
283     /// with all filesystem components.
284     input_src: String,
285     input_path: PathBuf,
286     op: ResizeOp,
287     format: Format,
288     /// Hash of the above parameters
289     hash: u64,
290     /// If there is a hash collision with another ImageOp, this contains a sequential ID > 1
291     /// identifying the collision in the order as encountered (which is essentially random).
292     /// Therefore, ImageOps with collisions (ie. collision_id > 0) are always considered out of date.
293     /// Note that this is very unlikely to happen in practice
294     collision_id: u32,
295 }
296 
297 impl ImageOp {
298     const RESIZE_FILTER: FilterType = FilterType::Lanczos3;
299 
new(input_src: String, input_path: PathBuf, op: ResizeOp, format: Format) -> ImageOp300     fn new(input_src: String, input_path: PathBuf, op: ResizeOp, format: Format) -> ImageOp {
301         let mut hasher = DefaultHasher::new();
302         hasher.write(input_src.as_ref());
303         op.hash(&mut hasher);
304         format.hash(&mut hasher);
305         let hash = hasher.finish();
306 
307         ImageOp { input_src, input_path, op, format, hash, collision_id: 0 }
308     }
309 
perform(&self, target_path: &Path) -> Result<()>310     fn perform(&self, target_path: &Path) -> Result<()> {
311         if !ufs::file_stale(&self.input_path, target_path) {
312             return Ok(());
313         }
314 
315         let mut img = image::open(&self.input_path)?;
316 
317         let img = match self.op.crop {
318             Some((x, y, w, h)) => img.crop(x, y, w, h),
319             None => img,
320         };
321         let img = match self.op.resize {
322             Some((w, h)) => img.resize_exact(w, h, Self::RESIZE_FILTER),
323             None => img,
324         };
325 
326         let mut f = File::create(target_path)?;
327 
328         match self.format {
329             Format::Png => {
330                 img.write_to(&mut f, ImageOutputFormat::Png)?;
331             }
332             Format::Jpeg(q) => {
333                 img.write_to(&mut f, ImageOutputFormat::Jpeg(q))?;
334             }
335             Format::WebP(q) => {
336                 let encoder = webp::Encoder::from_image(&img);
337                 let memory = match q {
338                     Some(q) => encoder.encode(q as f32),
339                     None => encoder.encode_lossless(),
340                 };
341                 f.write_all(memory.as_bytes())?;
342             }
343         }
344 
345         Ok(())
346     }
347 }
348 
349 #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
350 pub struct EnqueueResponse {
351     /// The final URL for that asset
352     pub url: String,
353     /// The path to the static asset generated
354     pub static_path: String,
355     /// New image width
356     pub width: u32,
357     /// New image height
358     pub height: u32,
359     /// Original image width
360     pub orig_width: u32,
361     /// Original image height
362     pub orig_height: u32,
363 }
364 
365 impl EnqueueResponse {
new(url: String, static_path: PathBuf, meta: &ImageMeta, op: &ResizeOp) -> Self366     fn new(url: String, static_path: PathBuf, meta: &ImageMeta, op: &ResizeOp) -> Self {
367         let static_path = static_path.to_string_lossy().into_owned();
368         let (width, height) = op.resize.unwrap_or(meta.size);
369         let (orig_width, orig_height) = meta.size;
370 
371         Self { url, static_path, width, height, orig_width, orig_height }
372     }
373 }
374 
375 /// A struct into which image operations can be enqueued and then performed.
376 /// All output is written in a subdirectory in `static_path`,
377 /// taking care of file stale status based on timestamps and possible hash collisions.
378 #[derive(Debug)]
379 pub struct Processor {
380     /// The base path of the Zola site
381     base_path: PathBuf,
382     base_url: String,
383     output_dir: PathBuf,
384     /// A map of a ImageOps by their stored hash.
385     /// Note that this cannot be a HashSet, because hashset handles collisions and we don't want that,
386     /// we need to be aware of and handle collisions ourselves.
387     img_ops: HashMap<u64, ImageOp>,
388     /// Hash collisions go here:
389     img_ops_collisions: Vec<ImageOp>,
390 }
391 
392 impl Processor {
new(base_path: PathBuf, config: &Config) -> Processor393     pub fn new(base_path: PathBuf, config: &Config) -> Processor {
394         Processor {
395             output_dir: base_path.join("static").join(RESIZED_SUBDIR),
396             base_url: config.make_permalink(RESIZED_SUBDIR),
397             base_path,
398             img_ops: HashMap::new(),
399             img_ops_collisions: Vec::new(),
400         }
401     }
402 
set_base_url(&mut self, config: &Config)403     pub fn set_base_url(&mut self, config: &Config) {
404         self.base_url = config.make_permalink(RESIZED_SUBDIR);
405     }
406 
num_img_ops(&self) -> usize407     pub fn num_img_ops(&self) -> usize {
408         self.img_ops.len() + self.img_ops_collisions.len()
409     }
410 
411     #[allow(clippy::too_many_arguments)]
enqueue( &mut self, input_src: String, input_path: PathBuf, op: &str, width: Option<u32>, height: Option<u32>, format: &str, quality: Option<u8>, ) -> Result<EnqueueResponse>412     pub fn enqueue(
413         &mut self,
414         input_src: String,
415         input_path: PathBuf,
416         op: &str,
417         width: Option<u32>,
418         height: Option<u32>,
419         format: &str,
420         quality: Option<u8>,
421     ) -> Result<EnqueueResponse> {
422         let meta = ImageMeta::read(&input_path).map_err(|e| {
423             Error::chain(format!("Failed to read image: {}", input_path.display()), e)
424         })?;
425 
426         let args = ResizeArgs::from_args(op, width, height)?;
427         let op = ResizeOp::new(args, meta.size);
428         let format = Format::from_args(&meta, format, quality)?;
429         let img_op = ImageOp::new(input_src, input_path, op.clone(), format);
430         let (static_path, url) = self.insert(img_op);
431 
432         Ok(EnqueueResponse::new(url, static_path, &meta, &op))
433     }
434 
insert_with_collisions(&mut self, mut img_op: ImageOp) -> u32435     fn insert_with_collisions(&mut self, mut img_op: ImageOp) -> u32 {
436         match self.img_ops.entry(img_op.hash) {
437             HEntry::Occupied(entry) => {
438                 if *entry.get() == img_op {
439                     return 0;
440                 }
441             }
442             HEntry::Vacant(entry) => {
443                 entry.insert(img_op);
444                 return 0;
445             }
446         }
447 
448         // If we get here, that means a hash collision.
449         // This is detected when there is an ImageOp with the same hash in the `img_ops`
450         // map but which is not equal to this one.
451         // To deal with this, all collisions get a (random) sequential ID number.
452 
453         // First try to look up this ImageOp in `img_ops_collisions`, maybe we've
454         // already seen the same ImageOp.
455         // At the same time, count IDs to figure out the next free one.
456         // Start with the ID of 2, because we'll need to use 1 for the ImageOp
457         // already present in the map:
458         let mut collision_id = 2;
459         for op in self.img_ops_collisions.iter().filter(|op| op.hash == img_op.hash) {
460             if *op == img_op {
461                 // This is a colliding ImageOp, but we've already seen an equal one
462                 // (not just by hash, but by content too), so just return its ID:
463                 return collision_id;
464             } else {
465                 collision_id += 1;
466             }
467         }
468 
469         // If we get here, that means this is a new colliding ImageOp and
470         // `collision_id` is the next free ID
471         if collision_id == 2 {
472             // This is the first collision found with this hash, update the ID
473             // of the matching ImageOp in the map.
474             self.img_ops.get_mut(&img_op.hash).unwrap().collision_id = 1;
475         }
476         img_op.collision_id = collision_id;
477         self.img_ops_collisions.push(img_op);
478         collision_id
479     }
480 
op_filename(hash: u64, collision_id: u32, format: Format) -> String481     fn op_filename(hash: u64, collision_id: u32, format: Format) -> String {
482         // Please keep this in sync with RESIZED_FILENAME
483         assert!(collision_id < 256, "Unexpectedly large number of collisions: {}", collision_id);
484         format!("{:016x}{:02x}.{}", hash, collision_id, format.extension())
485     }
486 
487     /// Adds the given operation to the queue but do not process it immediately.
488     /// Returns (path in static folder, final URL).
insert(&mut self, img_op: ImageOp) -> (PathBuf, String)489     fn insert(&mut self, img_op: ImageOp) -> (PathBuf, String) {
490         let hash = img_op.hash;
491         let format = img_op.format;
492         let collision_id = self.insert_with_collisions(img_op);
493         let filename = Self::op_filename(hash, collision_id, format);
494         let url = format!("{}{}", self.base_url, filename);
495         (Path::new("static").join(RESIZED_SUBDIR).join(filename), url)
496     }
497 
498     /// Remove stale processed images in the output directory
prune(&self) -> Result<()>499     pub fn prune(&self) -> Result<()> {
500         // Do not create folders if they don't exist
501         if !self.output_dir.exists() {
502             return Ok(());
503         }
504 
505         ufs::ensure_directory_exists(&self.output_dir)?;
506         let entries = fs::read_dir(&self.output_dir)?;
507         for entry in entries {
508             let entry_path = entry?.path();
509             if entry_path.is_file() {
510                 let filename = entry_path.file_name().unwrap().to_string_lossy();
511                 if let Some(capts) = RESIZED_FILENAME.captures(filename.as_ref()) {
512                     let hash = u64::from_str_radix(capts.get(1).unwrap().as_str(), 16).unwrap();
513                     let collision_id =
514                         u32::from_str_radix(capts.get(2).unwrap().as_str(), 16).unwrap();
515 
516                     if collision_id > 0 || !self.img_ops.contains_key(&hash) {
517                         fs::remove_file(&entry_path)?;
518                     }
519                 }
520             }
521         }
522         Ok(())
523     }
524 
525     /// Run the enqueued image operations
do_process(&mut self) -> Result<()>526     pub fn do_process(&mut self) -> Result<()> {
527         if !self.img_ops.is_empty() {
528             ufs::ensure_directory_exists(&self.output_dir)?;
529         }
530 
531         self.img_ops
532             .par_iter()
533             .map(|(hash, op)| {
534                 let target =
535                     self.output_dir.join(Self::op_filename(*hash, op.collision_id, op.format));
536                 op.perform(&target).map_err(|e| {
537                     Error::chain(format!("Failed to process image: {}", op.input_path.display()), e)
538                 })
539             })
540             .collect::<Result<()>>()
541     }
542 }
543 
544 #[derive(Debug, Serialize, Eq, PartialEq)]
545 pub struct ImageMetaResponse {
546     pub width: u32,
547     pub height: u32,
548     pub format: Option<&'static str>,
549 }
550 
551 impl ImageMetaResponse {
new_svg(width: u32, height: u32) -> Self552     pub fn new_svg(width: u32, height: u32) -> Self {
553         Self { width, height, format: Some("svg") }
554     }
555 }
556 
557 impl From<ImageMeta> for ImageMetaResponse {
from(im: ImageMeta) -> Self558     fn from(im: ImageMeta) -> Self {
559         Self {
560             width: im.size.0,
561             height: im.size.1,
562             format: im.format.and_then(|f| f.extensions_str().get(0)).copied(),
563         }
564     }
565 }
566 
567 impl From<webp::WebPImage> for ImageMetaResponse {
from(img: webp::WebPImage) -> Self568     fn from(img: webp::WebPImage) -> Self {
569         Self { width: img.width(), height: img.height(), format: Some("webp") }
570     }
571 }
572 
573 /// Read image dimensions (cheaply), used in `get_image_metadata()`, supports SVG
read_image_metadata<P: AsRef<Path>>(path: P) -> Result<ImageMetaResponse>574 pub fn read_image_metadata<P: AsRef<Path>>(path: P) -> Result<ImageMetaResponse> {
575     let path = path.as_ref();
576     let ext = path.extension().and_then(OsStr::to_str).unwrap_or("").to_lowercase();
577 
578     let error = |e: Box<dyn StdError + Send + Sync>| {
579         Error::chain(format!("Failed to read image: {}", path.display()), e)
580     };
581 
582     match ext.as_str() {
583         "svg" => {
584             let img = SvgMetadata::parse_file(&path).map_err(|e| error(e.into()))?;
585             match (img.height(), img.width(), img.view_box()) {
586                 (Some(h), Some(w), _) => Ok((h, w)),
587                 (_, _, Some(view_box)) => Ok((view_box.height, view_box.width)),
588                 _ => Err("Invalid dimensions: SVG width/height and viewbox not set.".into()),
589             }
590             .map(|(h, w)| ImageMetaResponse::new_svg(h as u32, w as u32))
591         }
592         "webp" => {
593             // Unfortunatelly we have to load the entire image here, unlike with the others :|
594             let data = fs::read(path).map_err(|e| error(e.into()))?;
595             let decoder = webp::Decoder::new(&data[..]);
596             decoder.decode().map(ImageMetaResponse::from).ok_or_else(|| {
597                 Error::msg(format!("Failed to decode WebP image: {}", path.display()))
598             })
599         }
600         _ => ImageMeta::read(path).map(ImageMetaResponse::from).map_err(|e| error(e.into())),
601     }
602 }
603 
604 /// Assert that `address` matches `prefix` + RESIZED_FILENAME regex + "." + `extension`,
605 /// this is useful in test so that we don't need to hardcode hash, which is annoying.
assert_processed_path_matches(path: &str, prefix: &str, extension: &str)606 pub fn assert_processed_path_matches(path: &str, prefix: &str, extension: &str) {
607     let filename = path
608         .strip_prefix(prefix)
609         .unwrap_or_else(|| panic!("Path `{}` doesn't start with `{}`", path, prefix));
610 
611     let suffix = format!(".{}", extension);
612     assert!(filename.ends_with(&suffix), "Path `{}` doesn't end with `{}`", path, suffix);
613 
614     assert!(
615         RESIZED_FILENAME.is_match_at(filename, 0),
616         "In path `{}`, file stem `{}` doesn't match the RESIZED_FILENAME regex",
617         path,
618         filename
619     );
620 }
621