Skip to main content

rich_art/
graphics.rs

1//! Shared pieces of the terminal graphics protocols: images fitted to an
2//! exact number of cells, animation frames resampled for them, and the
3//! overlay that draws an image over cells a line already holds.
4//!
5//! Kitty ([`crate::kitty`]), iTerm2 ([`crate::iterm`]) and inline Sixel
6//! ([`crate::sixel::SixelArt::encode_inline`]) all start here: a protocol
7//! image that must take exactly `cols × rows` cells is drawn from a raster of
8//! exactly `cols × cell width` by `rows × cell height` pixels, so the
9//! terminal never has to scale it and never lets it spill into the next cell.
10//!
11//! Requires the `image` feature.
12
13use 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/// The size of one terminal cell in pixels.
25#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
26pub struct CellPixels {
27    pub width: u32,
28    pub height: u32,
29}
30
31impl CellPixels {
32    /// The conventional guess when the terminal did not say: 8×16, the classic
33    /// VGA cell (the same guess [`crate::sixel::DEFAULT_CELL_PX`] makes).
34    pub const DEFAULT: CellPixels = CellPixels {
35        width: 8,
36        height: 16,
37    };
38
39    /// A cell size, each side at least 1 and at most 512 pixels.
40    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    /// The pixel size of `cols × rows` cells.
48    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
61/// `image` scaled to fit `cols × rows` cells of `cell` pixels, keeping its
62/// aspect ratio, centred on a transparent canvas of exactly that size.
63pub 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
68/// `image` scaled to fit `width × height` pixels, keeping its aspect ratio,
69/// centred on a transparent canvas of exactly that size.
70pub fn fit_to_pixels(image: &DynamicImage, width: u32, height: u32) -> RgbaImage {
71    let (iw, ih) = (image.width().max(1), image.height().max(1));
72    // The largest size of the same shape that fits.
73    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        // Upscaling a tiny icon: keep its pixels crisp.
80        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
89/// `image` filling exactly `width × height` pixels the way `fit` says:
90/// [`Contain`](ImageFit::Contain) (and [`Native`](ImageFit::Native)) as
91/// [`fit_to_pixels`]; [`Cover`](ImageFit::Cover) scaled to cover the canvas
92/// and cropped to it, keeping the part `anchor` names;
93/// [`Stretch`](ImageFit::Stretch) scaled to it, ignoring the aspect ratio.
94/// Alpha is kept. Downscaling uses Lanczos3, upscaling nearest-neighbour, so
95/// a tiny source stays crisp.
96pub 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            // The smallest size of the same shape that covers the canvas.
120            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
143/// `image` with a play mark drawn into its pixels at its bottom-left: a dark
144/// square with a white triangle, as video posters show one. Drawn into the
145/// pixels, it shows through every protocol; cells drawn over a Sixel or
146/// iTerm2 picture would be covered by it.
147pub 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            // The square, darkening what is under it.
155            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            // The triangle, pointing right, in its middle half.
161            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
174/// `image` with `transforms` applied (rotation, flips, brightness,
175/// contrast, gamma, grayscale), keeping its alpha: the adjustments
176/// [`ImageArt`](crate::ImageArt) makes before fitting, for callers that fit
177/// themselves.
178pub fn adjust(image: &DynamicImage, transforms: crate::ImageTransforms) -> DynamicImage {
179    crate::transform::prepare(image, transforms, None).0
180}
181
182/// `image` sharpened with an unsharp mask of radius `sigma` pixels, leaving
183/// differences below `threshold` (0–255) alone. Alpha is not touched.
184pub 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
192/// Make every pixel fully opaque or fully transparent: alpha below `cutoff`
193/// becomes a transparent black pixel, the rest opaque. GIF and most
194/// protocols draw transparency this way, so deciding it once, here, keeps
195/// every frame's edges the same.
196pub 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
206/// Reduce `image` to `mode`'s palette with `dither`, as
207/// [`ImageArt`](crate::ImageArt) does its final raster. Transparent pixels
208/// (alpha under half) stay transparent and take no diffused error; the rest
209/// become opaque palette colours. [`ImageColorMode::TrueColor`] leaves the
210/// image as it is.
211pub 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/// One frame of an animation, as a full canvas, and how long it shows.
229#[derive(Clone, Debug, PartialEq, Eq)]
230pub struct AnimationFrame {
231    pub image: RgbaImage,
232    pub delay: Duration,
233}
234
235/// Bounds on decoding and resampling an animation.
236#[derive(Clone, Copy, Debug, PartialEq, Eq)]
237pub struct FrameBudget {
238    /// Decoded bytes all frames may take together, at canvas size.
239    pub max_bytes: usize,
240    /// Frames kept at most; later frames are dropped.
241    pub max_frames: usize,
242    /// The shortest time a frame shows: faster frames are merged into the
243    /// next, so an animation never repaints faster than this.
244    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            // 25 frames a second.
253            min_delay: Duration::from_millis(40),
254        }
255    }
256}
257
258/// A browser's substitute for a GIF's "as fast as you can" delay of zero.
259const 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
267/// A still image's one frame, decoded only if its canvas fits
268/// `budget.max_bytes` (four bytes a pixel), as each animation frame must.
269fn 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
288/// Whether `bytes` start like a WebP file: a RIFF container of form `WEBP`.
289pub(crate) fn is_webp(bytes: &[u8]) -> bool {
290    bytes.len() >= 12 && bytes.starts_with(b"RIFF") && &bytes[8..12] == b"WEBP"
291}
292
293/// Decode an animated GIF, APNG or animated WebP into full-canvas frames
294/// under `budget`. A still PNG or WebP decodes to one frame. Frames past
295/// `budget.max_frames` are dropped; decoding stops with an error when the
296/// frames so far take more than `budget.max_bytes`, and a still image whose
297/// canvas alone takes more is refused before it is decoded.
298pub 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
351/// `frames` fitted to `width × height` pixels, then deduplicated (a frame
352/// equal to the one before it only lengthens that one) and rate-limited (a
353/// frame shorter than `budget.min_delay` is merged into the next). The
354/// result always holds at least one frame when `frames` does.
355pub 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    // Time owed by frames that were too short to show on their own.
363    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
385/// `image` as PNG bytes.
386pub 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
394/// `frames` as an animated GIF that loops forever, or `None` when there is
395/// nothing to encode.
396pub 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
421/// Standard base64 with padding, as every graphics protocol wants it.
422pub 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
447/// Save the cursor (DECSC).
448pub const SAVE_CURSOR: &str = "\x1b7";
449/// Restore the cursor (DECRC).
450pub const RESTORE_CURSOR: &str = "\x1b8";
451
452/// The two escapes that draw `image` (an escape that draws at the cursor and
453/// may move it) over `cols` cells a line is about to print: write the first,
454/// then the cells, then the second. The cursor is saved before the cells,
455/// restored to draw the image over them, and restored again then moved
456/// forward past them, so wherever the protocol leaves the cursor the line
457/// continues right after the cells.
458pub 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        // A square in a 2:1 box: transparent margins left and right.
497        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        // A 4x2 image: left half red, right half blue.
504        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        // Contain leaves transparent margins.
520        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        // red 200ms; blue's 10ms is carried into the red that follows it.
573        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        // Its top right is untouched; its bottom left is darkened, with
595        // white in the middle of the mark.
596        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}