Skip to main content

git_koi/
output.rs

1//! Image and animation encoding; callers own destinations and publication.
2//!
3//! Every export is [`STATIC_EXPORT_SCALE`] times the native canvas on each side, so the
4//! pixel art stays crisp: [`encode_png`] for the still, [`encode_svg`] for a vector
5//! document of the same pixels, [`encode_gif`] for a behavior's loop, and
6//! [`encode_preview_html`] for a page that shows the GIF and PNG beside it on GitHub's
7//! light and dark page colors.
8
9use std::borrow::Cow;
10use std::collections::BTreeMap;
11use std::error::Error;
12use std::fmt;
13
14use crate::motion::FRAMES_PER_SECOND;
15use crate::render::{FRAME_HEIGHT_PIXELS, FRAME_WIDTH_PIXELS, KoiFrame};
16
17/// Each native pixel becomes this many export pixels on each side.
18pub const STATIC_EXPORT_SCALE: u32 = 2;
19/// Width of every exported image.
20pub const STATIC_WIDTH_PIXELS: u32 = FRAME_WIDTH_PIXELS * STATIC_EXPORT_SCALE;
21/// Height of every exported image.
22pub const STATIC_HEIGHT_PIXELS: u32 = FRAME_HEIGHT_PIXELS * STATIC_EXPORT_SCALE;
23
24/// Encode a static PNG by repeating each native pixel in both dimensions.
25/// Preserve its color and alpha without interpolation, timestamps, I/O, or mutation.
26/// Encoding errors propagate before the caller publishes any resulting bytes.
27pub fn encode_png(koi_frame: &KoiFrame) -> Result<Vec<u8>, png::EncodingError> {
28    let native_pixels = koi_frame.as_rgba_bytes().as_chunks::<4>().0;
29    let mut export_pixels = vec![[0; 4]; (STATIC_WIDTH_PIXELS * STATIC_HEIGHT_PIXELS) as usize];
30    for (pixel_index, pixel_channels) in export_pixels.iter_mut().enumerate() {
31        let native_x = pixel_index as u32 % STATIC_WIDTH_PIXELS / STATIC_EXPORT_SCALE;
32        let native_y = pixel_index as u32 / STATIC_WIDTH_PIXELS / STATIC_EXPORT_SCALE;
33        *pixel_channels = native_pixels[(native_y * FRAME_WIDTH_PIXELS + native_x) as usize];
34    }
35    let mut png_bytes = Vec::new();
36    let mut png_encoder =
37        png::Encoder::new(&mut png_bytes, STATIC_WIDTH_PIXELS, STATIC_HEIGHT_PIXELS);
38    png_encoder.set_color(png::ColorType::Rgba);
39    png_encoder.set_depth(png::BitDepth::Eight);
40    let mut png_writer = png_encoder.write_header()?;
41    png_writer.write_image_data(export_pixels.as_flattened())?;
42    png_writer.finish()?;
43    Ok(png_bytes)
44}
45
46/// Encode a self-contained SVG at the static export size with native pixel coordinates.
47/// Equal neighboring pixels share a rectangle within their row. Transparent pixels
48/// are omitted; visible pixels retain their color and alpha. Identical frames produce
49/// identical documents without I/O, timestamps, external assets, or input mutation.
50pub fn encode_svg(koi_frame: &KoiFrame) -> String {
51    let mut svg_document = format!(
52        "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{STATIC_WIDTH_PIXELS}\" height=\"{STATIC_HEIGHT_PIXELS}\" viewBox=\"0 0 {FRAME_WIDTH_PIXELS} {FRAME_HEIGHT_PIXELS}\" shape-rendering=\"crispEdges\">\n"
53    );
54    let native_pixels = koi_frame.as_rgba_bytes().as_chunks::<4>().0;
55    let mut pixel_index = 0;
56    while pixel_index < native_pixels.len() {
57        let pixel_channels = native_pixels[pixel_index];
58        let pixel_x = pixel_index % FRAME_WIDTH_PIXELS as usize;
59        let pixel_y = pixel_index / FRAME_WIDTH_PIXELS as usize;
60        let row_end_index = (pixel_y + 1) * FRAME_WIDTH_PIXELS as usize;
61        let run_length_pixels = native_pixels[pixel_index..row_end_index]
62            .iter()
63            .take_while(|next_pixel| **next_pixel == pixel_channels)
64            .count();
65        if pixel_channels[3] != 0 {
66            let [red_channel, green_channel, blue_channel, alpha_channel] = pixel_channels;
67            let fill_opacity = f64::from(alpha_channel) / 255.0;
68            svg_document.push_str(&format!(
69                "  <rect x=\"{pixel_x}\" y=\"{pixel_y}\" width=\"{run_length_pixels}\" height=\"1\" fill=\"#{red_channel:02x}{green_channel:02x}{blue_channel:02x}\" fill-opacity=\"{fill_opacity}\"/>\n"
70            ));
71        }
72        pixel_index += run_length_pixels;
73    }
74    svg_document.push_str("</svg>\n");
75    svg_document
76}
77
78/// The preview page up to its caption; GitHub's light and dark page colors follow.
79const PREVIEW_HEAD: &str = r#"<!doctype html>
80<html lang="en">
81<head>
82<meta charset="utf-8">
83<meta name="viewport" content="width=device-width, initial-scale=1">
84<title>git-koi preview</title>
85<style>
86  body { margin: 0; font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; }
87  h1, h2 { margin: 0; font-size: 14px; font-weight: 600; }
88  header, section { display: grid; gap: 12px; padding: 16px; }
89  .light { background: #ffffff; color: #1f2328; }
90  .dark { background: #0d1117; color: #f0f6fc; }
91  .images { display: flex; flex-wrap: wrap; gap: 16px; }
92  figure { margin: 0; }
93  img { display: block; max-width: 100%; height: auto; image-rendering: pixelated; }
94</style>
95</head>
96<body>
97<header class="light"><h1>"#;
98const PREVIEW_PANELS: &str = r#"</h1></header>
99<section class="light">
100<h2>GitHub light</h2>
101<div class="images">
102<figure><img src="koi.gif" width="512" height="512" alt="The koi swimming"><figcaption>koi.gif</figcaption></figure>
103<figure><img src="koi.png" width="512" height="512" alt="The koi, still"><figcaption>koi.png</figcaption></figure>
104</div>
105</section>
106<section class="dark">
107<h2>GitHub dark</h2>
108<div class="images">
109<figure><img src="koi.gif" width="512" height="512" alt="The koi swimming"><figcaption>koi.gif</figcaption></figure>
110<figure><img src="koi.png" width="512" height="512" alt="The koi, still"><figcaption>koi.png</figcaption></figure>
111</div>
112</section>
113</body>
114</html>
115"#;
116
117/// Build a page that previews `koi.gif` and `koi.png`, read from beside it, on GitHub's
118/// light and dark page colors under the given caption. It draws nothing itself and loads
119/// no scripts or outside files, so it shows exactly what was generated.
120pub fn encode_preview_html(caption: &str) -> String {
121    let escaped_caption = caption
122        .replace('&', "&amp;")
123        .replace('<', "&lt;")
124        .replace('>', "&gt;");
125    format!("{PREVIEW_HEAD}{escaped_caption}{PREVIEW_PANELS}")
126}
127
128/// One GIF palette holds 256 entries, and one of them marks transparent pixels.
129const GIF_COLOR_LIMIT: usize = 255;
130const GIF_TRANSPARENT_INDEX: u8 = 0;
131
132/// Encode a seamless loop as an animated GIF at the static export size, repeating each
133/// native pixel in both dimensions. The GIF repeats forever at `FRAMES_PER_SECOND`; each
134/// delay, in hundredths of a second, is the step in a rounded running total, so a loop
135/// lasts exactly its frame count divided by the frame rate. Each frame stores only the
136/// box around its drawn pixels, with an exact palette of its colors, and that box clears
137/// before the next frame. Colors are never approximated: a frame with more than 255
138/// colors or a partly transparent pixel fails instead. Identical frames produce
139/// identical bytes without I/O, timestamps, or input mutation.
140pub fn encode_gif(koi_frames: &[KoiFrame]) -> Result<Vec<u8>, GifEncodingError> {
141    if koi_frames.is_empty() {
142        return Err(GifEncodingError::NoFrames);
143    }
144    let mut gif_encoder = gif::Encoder::new(
145        Vec::new(),
146        STATIC_WIDTH_PIXELS as u16,
147        STATIC_HEIGHT_PIXELS as u16,
148        &[],
149    )?;
150    gif_encoder.set_repeat(gif::Repeat::Infinite)?;
151    for (frame_index, koi_frame) in koi_frames.iter().enumerate() {
152        let native_pixels = koi_frame.as_rgba_bytes().as_chunks::<4>().0;
153        let [box_left, box_top, box_width, box_height] = find_drawn_box(native_pixels);
154        let mut palette_indices: BTreeMap<[u8; 3], u8> = BTreeMap::new();
155        let mut palette_channels = vec![0; 3];
156        let mut native_indices = Vec::with_capacity((box_width * box_height) as usize);
157        for native_y in box_top..box_top + box_height {
158            for native_x in box_left..box_left + box_width {
159                let [red, green, blue, alpha] =
160                    native_pixels[(native_y * FRAME_WIDTH_PIXELS + native_x) as usize];
161                let palette_index = match alpha {
162                    0 => GIF_TRANSPARENT_INDEX,
163                    255 => match palette_indices.get(&[red, green, blue]) {
164                        Some(&palette_index) => palette_index,
165                        None if palette_indices.len() == GIF_COLOR_LIMIT => {
166                            return Err(GifEncodingError::TooManyColors { frame_index });
167                        }
168                        None => {
169                            let palette_index = palette_indices.len() as u8 + 1;
170                            palette_indices.insert([red, green, blue], palette_index);
171                            palette_channels.extend([red, green, blue]);
172                            palette_index
173                        }
174                    },
175                    _ => return Err(GifEncodingError::PartialTransparency { frame_index }),
176                };
177                native_indices.push(palette_index);
178            }
179        }
180        let mut export_indices = Vec::with_capacity(
181            native_indices.len() * (STATIC_EXPORT_SCALE * STATIC_EXPORT_SCALE) as usize,
182        );
183        for native_row in native_indices.chunks(box_width as usize) {
184            let export_row: Vec<u8> = native_row
185                .iter()
186                .flat_map(|&palette_index| [palette_index; STATIC_EXPORT_SCALE as usize])
187                .collect();
188            for _ in 0..STATIC_EXPORT_SCALE {
189                export_indices.extend_from_slice(&export_row);
190            }
191        }
192        gif_encoder.write_frame(&gif::Frame {
193            delay: calculate_elapsed_hundredths(frame_index + 1)
194                - calculate_elapsed_hundredths(frame_index),
195            dispose: gif::DisposalMethod::Background,
196            transparent: Some(GIF_TRANSPARENT_INDEX),
197            left: (box_left * STATIC_EXPORT_SCALE) as u16,
198            top: (box_top * STATIC_EXPORT_SCALE) as u16,
199            width: (box_width * STATIC_EXPORT_SCALE) as u16,
200            height: (box_height * STATIC_EXPORT_SCALE) as u16,
201            palette: Some(palette_channels),
202            buffer: Cow::Owned(export_indices),
203            ..gif::Frame::default()
204        })?;
205    }
206    Ok(gif_encoder.into_inner()?)
207}
208
209/// The native box `[left, top, width, height]` around a frame's drawn pixels; a frame
210/// with none keeps one transparent pixel, since a GIF frame cannot be empty.
211fn find_drawn_box(native_pixels: &[[u8; 4]]) -> [u32; 4] {
212    let mut drawn_edges: Option<[u32; 4]> = None;
213    for (pixel_index, pixel_channels) in native_pixels.iter().enumerate() {
214        if pixel_channels[3] == 0 {
215            continue;
216        }
217        let pixel_x = pixel_index as u32 % FRAME_WIDTH_PIXELS;
218        let pixel_y = pixel_index as u32 / FRAME_WIDTH_PIXELS;
219        let [left, top, right, bottom] =
220            drawn_edges.get_or_insert([pixel_x, pixel_y, pixel_x, pixel_y]);
221        *left = (*left).min(pixel_x);
222        *top = (*top).min(pixel_y);
223        *right = (*right).max(pixel_x);
224        *bottom = (*bottom).max(pixel_y);
225    }
226    drawn_edges.map_or([0, 0, 1, 1], |[left, top, right, bottom]| {
227        [left, top, right + 1 - left, bottom + 1 - top]
228    })
229}
230
231/// Hundredths of a second from the start of the loop to the given frame, rounded.
232fn calculate_elapsed_hundredths(frame_index: usize) -> u16 {
233    let frames_per_second = FRAMES_PER_SECOND as usize;
234    u16::try_from((frame_index * 200 + frames_per_second) / (2 * frames_per_second))
235        .expect("loops last under ten minutes")
236}
237
238/// Why a GIF was not produced.
239#[derive(Debug)]
240pub enum GifEncodingError {
241    /// No frames were given.
242    NoFrames,
243    /// A frame has more opaque colors than one GIF palette holds.
244    TooManyColors {
245        /// The frame, counted from zero.
246        frame_index: usize,
247    },
248    /// A frame has a pixel that is neither opaque nor fully transparent.
249    PartialTransparency {
250        /// The frame, counted from zero.
251        frame_index: usize,
252    },
253    /// The GIF encoder failed; carries its own error.
254    Encoder(gif::EncodingError),
255}
256
257impl fmt::Display for GifEncodingError {
258    fn fmt(&self, output_formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
259        match self {
260            Self::NoFrames => write!(output_formatter, "a GIF needs at least one frame"),
261            Self::TooManyColors { frame_index } => write!(
262                output_formatter,
263                "frame {frame_index} has more than {GIF_COLOR_LIMIT} colors, more than one GIF palette holds"
264            ),
265            Self::PartialTransparency { frame_index } => write!(
266                output_formatter,
267                "frame {frame_index} has partly transparent pixels, which a GIF cannot show"
268            ),
269            Self::Encoder(encoding_error) => {
270                write!(output_formatter, "GIF encoding failed: {encoding_error}")
271            }
272        }
273    }
274}
275
276impl Error for GifEncodingError {
277    fn source(&self) -> Option<&(dyn Error + 'static)> {
278        match self {
279            Self::Encoder(encoding_error) => Some(encoding_error),
280            _ => None,
281        }
282    }
283}
284
285impl From<gif::EncodingError> for GifEncodingError {
286    fn from(encoding_error: gif::EncodingError) -> Self {
287        Self::Encoder(encoding_error)
288    }
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294
295    const FRAME_PIXEL_COUNT: usize = (FRAME_WIDTH_PIXELS * FRAME_HEIGHT_PIXELS) as usize;
296
297    /// A frame with one opaque pixel of each given color, in reading order.
298    fn frame_with_colors(colors: impl IntoIterator<Item = [u8; 4]>) -> KoiFrame {
299        let mut rgba_pixels = vec![[0; 4]; FRAME_PIXEL_COUNT];
300        for (pixel_channels, color) in rgba_pixels.iter_mut().zip(colors) {
301            *pixel_channels = color;
302        }
303        KoiFrame::from_rgba_pixels(rgba_pixels)
304    }
305
306    fn distinct_colors(color_count: u32) -> impl Iterator<Item = [u8; 4]> {
307        (0..color_count).map(|color_index| [color_index as u8, (color_index >> 8) as u8, 7, 255])
308    }
309
310    fn total_hundredths(gif_bytes: &[u8]) -> u32 {
311        let mut gif_decoder = gif::DecodeOptions::new().read_info(gif_bytes).unwrap();
312        let mut total = 0;
313        while let Some(gif_frame) = gif_decoder.read_next_frame().unwrap() {
314            total += u32::from(gif_frame.delay);
315        }
316        total
317    }
318
319    #[test]
320    fn whole_loops_last_exactly_their_frames_at_fifteen_per_second() {
321        for (loop_frame_count, loop_hundredths) in [(120, 800), (90, 600)] {
322            let koi_frames: Vec<KoiFrame> = (0..loop_frame_count)
323                .map(|_| frame_with_colors(distinct_colors(1)))
324                .collect();
325            assert_eq!(
326                total_hundredths(&encode_gif(&koi_frames).unwrap()),
327                loop_hundredths
328            );
329        }
330    }
331
332    #[test]
333    fn a_frame_may_use_every_palette_color_but_no_more() {
334        assert!(encode_gif(&[frame_with_colors(distinct_colors(255))]).is_ok());
335        let crowded_frames = [
336            frame_with_colors(distinct_colors(1)),
337            frame_with_colors(distinct_colors(256)),
338        ];
339        assert!(matches!(
340            encode_gif(&crowded_frames),
341            Err(GifEncodingError::TooManyColors { frame_index: 1 })
342        ));
343    }
344
345    #[test]
346    fn partly_transparent_pixels_fail_instead_of_rounding() {
347        assert!(matches!(
348            encode_gif(&[frame_with_colors([[200, 100, 50, 128]])]),
349            Err(GifEncodingError::PartialTransparency { frame_index: 0 })
350        ));
351    }
352
353    #[test]
354    fn an_empty_frame_keeps_one_transparent_pixel() {
355        let gif_bytes = encode_gif(&[frame_with_colors([])]).unwrap();
356        let mut gif_decoder = gif::DecodeOptions::new()
357            .read_info(gif_bytes.as_slice())
358            .unwrap();
359        let gif_frame = gif_decoder.read_next_frame().unwrap().unwrap();
360        assert_eq!((gif_frame.width, gif_frame.height), (2, 2));
361        assert!(
362            gif_frame
363                .buffer
364                .chunks(4)
365                .all(|pixel_channels| pixel_channels[3] == 0)
366        );
367    }
368}