1use std::io::Cursor;
14use std::time::Duration;
15
16use image::codecs::gif::GifDecoder;
17use image::codecs::png::PngDecoder;
18use image::codecs::webp::WebPDecoder;
19use image::imageops::FilterType;
20use image::{AnimationDecoder, DynamicImage, Frame, ImageDecoder, ImageError, RgbaImage};
21
22use crate::image_art::{ImageAnchor, ImageFit};
23
24#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
26pub struct CellPixels {
27 pub width: u32,
28 pub height: u32,
29}
30
31impl CellPixels {
32 pub const DEFAULT: CellPixels = CellPixels {
35 width: 8,
36 height: 16,
37 };
38
39 pub fn new(width: u32, height: u32) -> CellPixels {
41 CellPixels {
42 width: width.clamp(1, 512),
43 height: height.clamp(1, 512),
44 }
45 }
46
47 pub fn span(self, cols: usize, rows: usize) -> (u32, u32) {
49 let cols = u32::try_from(cols.clamp(1, 4096)).unwrap_or(4096);
50 let rows = u32::try_from(rows.clamp(1, 4096)).unwrap_or(4096);
51 (self.width * cols, self.height * rows)
52 }
53}
54
55impl Default for CellPixels {
56 fn default() -> Self {
57 CellPixels::DEFAULT
58 }
59}
60
61pub fn fit_to_cells(image: &DynamicImage, cols: usize, rows: usize, cell: CellPixels) -> RgbaImage {
64 let (width, height) = cell.span(cols, rows);
65 fit_to_pixels(image, width, height)
66}
67
68pub fn fit_to_pixels(image: &DynamicImage, width: u32, height: u32) -> RgbaImage {
71 let (iw, ih) = (image.width().max(1), image.height().max(1));
72 let scale = f64::min(width as f64 / iw as f64, height as f64 / ih as f64);
74 let fw = ((iw as f64 * scale).round() as u32).clamp(1, width);
75 let fh = ((ih as f64 * scale).round() as u32).clamp(1, height);
76 let filter = if scale < 1.0 {
77 FilterType::Lanczos3
78 } else {
79 FilterType::Nearest
81 };
82 let scaled = image.resize_exact(fw, fh, filter).to_rgba8();
83 let mut canvas = RgbaImage::new(width, height);
84 let (x, y) = ((width - fw) / 2, (height - fh) / 2);
85 image::imageops::overlay(&mut canvas, &scaled, x as i64, y as i64);
86 canvas
87}
88
89pub fn fill_pixels(
97 image: &DynamicImage,
98 width: u32,
99 height: u32,
100 fit: ImageFit,
101 anchor: ImageAnchor,
102) -> RgbaImage {
103 let (width, height) = (width.max(1), height.max(1));
104 let (iw, ih) = (image.width().max(1), image.height().max(1));
105 let filter = |scale: f64| {
106 if scale < 1.0 {
107 FilterType::Lanczos3
108 } else {
109 FilterType::Nearest
110 }
111 };
112 match fit {
113 ImageFit::Contain | ImageFit::Native => fit_to_pixels(image, width, height),
114 ImageFit::Stretch => {
115 let scale = f64::min(width as f64 / iw as f64, height as f64 / ih as f64);
116 image.resize_exact(width, height, filter(scale)).to_rgba8()
117 }
118 ImageFit::Cover => {
119 let scale = f64::max(width as f64 / iw as f64, height as f64 / ih as f64);
121 let rw = ((iw as f64 * scale).ceil() as u32).max(width);
122 let rh = ((ih as f64 * scale).ceil() as u32).max(height);
123 let x = match anchor {
124 ImageAnchor::Left | ImageAnchor::TopLeft | ImageAnchor::BottomLeft => 0,
125 ImageAnchor::Right | ImageAnchor::TopRight | ImageAnchor::BottomRight => rw - width,
126 _ => (rw - width) / 2,
127 };
128 let y = match anchor {
129 ImageAnchor::Top | ImageAnchor::TopLeft | ImageAnchor::TopRight => 0,
130 ImageAnchor::Bottom | ImageAnchor::BottomLeft | ImageAnchor::BottomRight => {
131 rh - height
132 }
133 _ => (rh - height) / 2,
134 };
135 image
136 .resize_exact(rw, rh, filter(scale))
137 .crop_imm(x, y, width, height)
138 .to_rgba8()
139 }
140 }
141}
142
143pub fn play_mark(image: &DynamicImage) -> DynamicImage {
148 let mut out = image.to_rgba8();
149 let (w, h) = out.dimensions();
150 let side = (w.min(h) / 5).clamp(8, 96);
151 let (x0, y0) = (0, h.saturating_sub(side));
152 for y in y0..h {
153 for x in x0..(x0 + side).min(w) {
154 let pixel = out.get_pixel_mut(x, y);
156 for c in 0..3 {
157 pixel.0[c] = (u16::from(pixel.0[c]) * 2 / 10) as u8;
158 }
159 pixel.0[3] = 255;
160 let (u, v) = (
162 f64::from(x - x0) / f64::from(side),
163 f64::from(y - y0) / f64::from(side),
164 );
165 let tip = 0.75 - (v - 0.5).abs() * 1.0;
166 if (0.3..=0.75).contains(&u) && (0.25..=0.75).contains(&v) && u <= 0.3 + tip - 0.25 {
167 pixel.0 = [255, 255, 255, 255];
168 }
169 }
170 }
171 DynamicImage::ImageRgba8(out)
172}
173
174pub fn adjust(image: &DynamicImage, transforms: crate::ImageTransforms) -> DynamicImage {
179 crate::transform::prepare(image, transforms, None).0
180}
181
182pub fn sharpen(image: &RgbaImage, sigma: f32, threshold: i32) -> RgbaImage {
185 let mut sharpened = image::imageops::unsharpen(image, sigma.max(0.1), threshold);
186 for (out, source) in sharpened.pixels_mut().zip(image.pixels()) {
187 out.0[3] = source.0[3];
188 }
189 sharpened
190}
191
192pub fn threshold_alpha(image: &mut RgbaImage, cutoff: u8) {
197 for pixel in image.pixels_mut() {
198 if pixel.0[3] < cutoff {
199 pixel.0 = [0, 0, 0, 0];
200 } else {
201 pixel.0[3] = 255;
202 }
203 }
204}
205
206pub fn reduce_colors(
212 image: &mut RgbaImage,
213 mode: crate::ImageColorMode,
214 dither: crate::Dither,
215 distance: crate::ColorDistance,
216) {
217 let clear: Vec<bool> = image.pixels().map(|pixel| pixel.0[3] < 128).collect();
218 if crate::image_color::preprocess(image, mode, dither, distance, Some(&clear)).is_none() {
219 return;
220 }
221 for (pixel, clear) in image.pixels_mut().zip(clear) {
222 if clear {
223 pixel.0 = [0, 0, 0, 0];
224 }
225 }
226}
227
228#[derive(Clone, Debug, PartialEq, Eq)]
230pub struct AnimationFrame {
231 pub image: RgbaImage,
232 pub delay: Duration,
233}
234
235#[derive(Clone, Copy, Debug, PartialEq, Eq)]
237pub struct FrameBudget {
238 pub max_bytes: usize,
240 pub max_frames: usize,
242 pub min_delay: Duration,
245}
246
247impl Default for FrameBudget {
248 fn default() -> Self {
249 FrameBudget {
250 max_bytes: 64 * 1024 * 1024,
251 max_frames: 256,
252 min_delay: Duration::from_millis(40),
254 }
255 }
256}
257
258const ZERO_DELAY: Duration = Duration::from_millis(100);
260
261fn over_budget() -> ImageError {
262 ImageError::Limits(image::error::LimitError::from_kind(
263 image::error::LimitErrorKind::InsufficientMemory,
264 ))
265}
266
267fn still_frame(
270 decoder: impl image::ImageDecoder,
271 budget: FrameBudget,
272) -> Result<Vec<AnimationFrame>, ImageError> {
273 let (width, height) = decoder.dimensions();
274 let size = (width as usize)
275 .checked_mul(height as usize)
276 .and_then(|pixels| pixels.checked_mul(4))
277 .ok_or_else(over_budget)?;
278 if size > budget.max_bytes {
279 return Err(over_budget());
280 }
281 let image = DynamicImage::from_decoder(decoder)?.to_rgba8();
282 Ok(vec![AnimationFrame {
283 image,
284 delay: ZERO_DELAY,
285 }])
286}
287
288pub(crate) fn is_webp(bytes: &[u8]) -> bool {
290 bytes.len() >= 12 && bytes.starts_with(b"RIFF") && &bytes[8..12] == b"WEBP"
291}
292
293pub fn decode_animation(
299 bytes: &[u8],
300 budget: FrameBudget,
301) -> Result<Vec<AnimationFrame>, ImageError> {
302 let frames: Box<dyn Iterator<Item = Result<Frame, ImageError>>> = if bytes.starts_with(b"GIF8")
303 {
304 let mut decoder = GifDecoder::new(Cursor::new(bytes))?;
305 decoder.set_limits(image::Limits::default())?;
306 Box::new(decoder.into_frames())
307 } else if is_webp(bytes) {
308 let mut decoder = WebPDecoder::new(Cursor::new(bytes))?;
309 decoder.set_limits(image::Limits::default())?;
310 if decoder.has_animation() {
311 Box::new(decoder.into_frames())
312 } else {
313 return still_frame(decoder, budget);
314 }
315 } else {
316 let mut decoder = PngDecoder::new(Cursor::new(bytes))?;
317 decoder.set_limits(image::Limits::default())?;
318 if decoder.is_apng()? {
319 Box::new(decoder.apng()?.into_frames())
320 } else {
321 return still_frame(decoder, budget);
322 }
323 };
324 let mut used = 0usize;
325 let mut out = Vec::new();
326 for frame in frames.take(budget.max_frames.max(1)) {
327 let frame = frame?;
328 let (numer, denom) = frame.delay().numer_denom_ms();
329 let millis = numer.checked_div(denom).unwrap_or(0);
330 let buffer = frame.into_buffer();
331 let size = (buffer.width() as usize)
332 .checked_mul(buffer.height() as usize)
333 .and_then(|pixels| pixels.checked_mul(4))
334 .ok_or_else(over_budget)?;
335 used = used.checked_add(size).ok_or_else(over_budget)?;
336 if used > budget.max_bytes {
337 return Err(over_budget());
338 }
339 out.push(AnimationFrame {
340 image: buffer,
341 delay: if millis == 0 {
342 ZERO_DELAY
343 } else {
344 Duration::from_millis(u64::from(millis))
345 },
346 });
347 }
348 Ok(out)
349}
350
351pub fn resample(
356 frames: &[AnimationFrame],
357 width: u32,
358 height: u32,
359 budget: FrameBudget,
360) -> Vec<AnimationFrame> {
361 let mut out: Vec<AnimationFrame> = Vec::new();
362 let mut carried = Duration::ZERO;
364 for (index, frame) in frames.iter().enumerate() {
365 let delay = frame.delay + carried;
366 let last = index + 1 == frames.len();
367 if delay < budget.min_delay && !last {
368 carried = delay;
369 continue;
370 }
371 carried = Duration::ZERO;
372 let image = fit_to_pixels(
373 &DynamicImage::ImageRgba8(frame.image.clone()),
374 width,
375 height,
376 );
377 match out.last_mut() {
378 Some(previous) if previous.image == image => previous.delay += delay,
379 _ => out.push(AnimationFrame { image, delay }),
380 }
381 }
382 out
383}
384
385pub fn encode_png(image: &RgbaImage) -> Option<Vec<u8>> {
387 let mut out = Vec::new();
388 DynamicImage::ImageRgba8(image.clone())
389 .write_to(&mut Cursor::new(&mut out), image::ImageFormat::Png)
390 .ok()?;
391 Some(out)
392}
393
394pub fn encode_gif(frames: &[AnimationFrame]) -> Option<Vec<u8>> {
397 use image::codecs::gif::{GifEncoder, Repeat};
398 if frames.is_empty() {
399 return None;
400 }
401 let mut out = Vec::new();
402 {
403 let mut encoder = GifEncoder::new_with_speed(&mut out, 10);
404 encoder.set_repeat(Repeat::Infinite).ok()?;
405 let frames = frames.iter().map(|frame| {
406 let millis = u32::try_from(frame.delay.as_millis()).unwrap_or(u32::MAX);
407 Frame::from_parts(
408 frame.image.clone(),
409 0,
410 0,
411 image::Delay::from_numer_denom_ms(millis, 1),
412 )
413 });
414 encoder.encode_frames(frames).ok()?;
415 }
416 Some(out)
417}
418
419const BASE64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
420
421pub fn base64(bytes: &[u8]) -> String {
423 let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
424 for chunk in bytes.chunks(3) {
425 let b = [
426 chunk[0],
427 chunk.get(1).copied().unwrap_or(0),
428 chunk.get(2).copied().unwrap_or(0),
429 ];
430 let n = (u32::from(b[0]) << 16) | (u32::from(b[1]) << 8) | u32::from(b[2]);
431 out.push(BASE64[(n >> 18) as usize & 63] as char);
432 out.push(BASE64[(n >> 12) as usize & 63] as char);
433 out.push(if chunk.len() > 1 {
434 BASE64[(n >> 6) as usize & 63] as char
435 } else {
436 '='
437 });
438 out.push(if chunk.len() > 2 {
439 BASE64[n as usize & 63] as char
440 } else {
441 '='
442 });
443 }
444 out
445}
446
447pub const SAVE_CURSOR: &str = "\x1b7";
449pub const RESTORE_CURSOR: &str = "\x1b8";
451
452pub fn overlay(image: &str, cols: usize) -> (String, String) {
459 let mut after = String::with_capacity(image.len() + 16);
460 after.push_str(RESTORE_CURSOR);
461 after.push_str(image);
462 after.push_str(RESTORE_CURSOR);
463 if cols > 0 {
464 after.push_str(&format!("\x1b[{cols}C"));
465 }
466 (SAVE_CURSOR.to_string(), after)
467}
468
469#[cfg(test)]
470mod tests {
471 use super::*;
472 use image::Rgba;
473
474 #[test]
475 fn base64_matches_the_rfc_vectors() {
476 for (input, expected) in [
477 ("", ""),
478 ("f", "Zg=="),
479 ("fo", "Zm8="),
480 ("foo", "Zm9v"),
481 ("foob", "Zm9vYg=="),
482 ("fooba", "Zm9vYmE="),
483 ("foobar", "Zm9vYmFy"),
484 ] {
485 assert_eq!(base64(input.as_bytes()), expected);
486 }
487 }
488
489 #[test]
490 fn fitting_gives_the_exact_canvas_and_centres() {
491 let image = DynamicImage::ImageRgba8(RgbaImage::from_pixel(4, 4, Rgba([255, 0, 0, 255])));
492 let fitted = fit_to_cells(&image, 2, 1, CellPixels::new(8, 16));
493 assert_eq!(fitted.dimensions(), (16, 16));
494 let wide = fit_to_pixels(&image, 32, 16);
495 assert_eq!(wide.dimensions(), (32, 16));
496 assert_eq!(wide.get_pixel(0, 8).0[3], 0);
498 assert_eq!(wide.get_pixel(16, 8).0, [255, 0, 0, 255]);
499 }
500
501 #[test]
502 fn cover_crops_to_the_anchor_and_stretch_fills() {
503 let image = DynamicImage::ImageRgba8(RgbaImage::from_fn(4, 2, |x, _| {
505 if x < 2 {
506 Rgba([255, 0, 0, 255])
507 } else {
508 Rgba([0, 0, 255, 255])
509 }
510 }));
511 let left = fill_pixels(&image, 2, 2, ImageFit::Cover, ImageAnchor::Left);
512 assert_eq!(left.dimensions(), (2, 2));
513 assert!(left.pixels().all(|p| p.0 == [255, 0, 0, 255]));
514 let right = fill_pixels(&image, 2, 2, ImageFit::Cover, ImageAnchor::Right);
515 assert!(right.pixels().all(|p| p.0 == [0, 0, 255, 255]));
516 let stretched = fill_pixels(&image, 8, 8, ImageFit::Stretch, ImageAnchor::Center);
517 assert_eq!(stretched.dimensions(), (8, 8));
518 assert!(stretched.pixels().all(|p| p.0[3] == 255));
519 let contained = fill_pixels(&image, 4, 4, ImageFit::Contain, ImageAnchor::Center);
521 assert_eq!(contained.get_pixel(0, 0).0[3], 0);
522 }
523
524 #[test]
525 fn adjustments_sharpening_and_alpha_keep_transparency() {
526 let mut image = RgbaImage::from_pixel(3, 3, Rgba([100, 100, 100, 255]));
527 image.put_pixel(1, 1, Rgba([200, 200, 200, 255]));
528 image.put_pixel(0, 0, Rgba([50, 50, 50, 10]));
529 let contrasted = adjust(
530 &DynamicImage::ImageRgba8(image.clone()),
531 crate::ImageTransforms {
532 contrast: 2.0,
533 ..Default::default()
534 },
535 )
536 .to_rgba8();
537 assert!(contrasted.get_pixel(1, 1).0[0] > 200);
538 assert_eq!(contrasted.get_pixel(0, 0).0[3], 10);
539 let sharp = sharpen(&image, 1.0, 0);
540 assert!(sharp.get_pixel(1, 1).0[0] >= 200);
541 assert_eq!(sharp.get_pixel(0, 0).0[3], 10);
542 let mut binary = image.clone();
543 threshold_alpha(&mut binary, 128);
544 assert_eq!(binary.get_pixel(0, 0).0, [0, 0, 0, 0]);
545 assert_eq!(binary.get_pixel(1, 1).0[3], 255);
546 let mut reduced = binary.clone();
547 reduce_colors(
548 &mut reduced,
549 crate::ImageColorMode::Ansi16,
550 crate::Dither::None,
551 crate::ColorDistance::Rgb,
552 );
553 assert_eq!(reduced.get_pixel(0, 0).0, [0, 0, 0, 0]);
554 assert_eq!(reduced.get_pixel(1, 1).0[3], 255);
555 }
556
557 #[test]
558 fn resampling_dedupes_and_rate_limits() {
559 let red = RgbaImage::from_pixel(2, 2, Rgba([255, 0, 0, 255]));
560 let blue = RgbaImage::from_pixel(2, 2, Rgba([0, 0, 255, 255]));
561 let frame = |image: &RgbaImage, ms| AnimationFrame {
562 image: image.clone(),
563 delay: Duration::from_millis(ms),
564 };
565 let frames = [
566 frame(&red, 100),
567 frame(&red, 100),
568 frame(&blue, 10),
569 frame(&red, 50),
570 ];
571 let out = resample(&frames, 4, 4, FrameBudget::default());
572 assert_eq!(out.len(), 1);
574 assert_eq!(out[0].delay, Duration::from_millis(260));
575 let frames = [frame(&red, 100), frame(&blue, 100)];
576 assert_eq!(resample(&frames, 4, 4, FrameBudget::default()).len(), 2);
577 }
578
579 #[test]
580 fn overlay_saves_restores_and_steps_past_the_cells() {
581 let (before, after) = overlay("IMG", 2);
582 assert_eq!(before, "\x1b7");
583 assert_eq!(after, "\x1b8IMG\x1b8\x1b[2C");
584 }
585
586 #[test]
587 fn a_play_mark_is_drawn_into_the_pixels() {
588 let poster = DynamicImage::ImageRgba8(RgbaImage::from_pixel(
589 100,
590 50,
591 image::Rgba([200, 100, 50, 255]),
592 ));
593 let marked = play_mark(&poster).to_rgba8();
594 assert_eq!(marked.get_pixel(99, 0).0, [200, 100, 50, 255]);
597 assert_eq!(marked.get_pixel(0, 49).0, [40, 20, 10, 255]);
598 assert_eq!(marked.get_pixel(4, 45).0, [255, 255, 255, 255]);
599 assert_eq!(marked.dimensions(), (100, 50));
600 }
601
602 #[test]
603 fn gif_round_trips_through_the_decoder() {
604 let red = RgbaImage::from_pixel(2, 2, Rgba([255, 0, 0, 255]));
605 let blue = RgbaImage::from_pixel(2, 2, Rgba([0, 0, 255, 255]));
606 let frames = vec![
607 AnimationFrame {
608 image: red,
609 delay: Duration::from_millis(100),
610 },
611 AnimationFrame {
612 image: blue,
613 delay: Duration::from_millis(200),
614 },
615 ];
616 let gif = encode_gif(&frames).expect("encodes");
617 let decoded = decode_animation(&gif, FrameBudget::default()).expect("decodes");
618 assert_eq!(decoded.len(), 2);
619 assert_eq!(decoded[1].delay, Duration::from_millis(200));
620 let png = encode_png(&frames[0].image).expect("png");
621 assert_eq!(
622 decode_animation(&png, FrameBudget::default())
623 .unwrap()
624 .len(),
625 1
626 );
627 }
628}