1use 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
17pub const STATIC_EXPORT_SCALE: u32 = 2;
19pub const STATIC_WIDTH_PIXELS: u32 = FRAME_WIDTH_PIXELS * STATIC_EXPORT_SCALE;
21pub const STATIC_HEIGHT_PIXELS: u32 = FRAME_HEIGHT_PIXELS * STATIC_EXPORT_SCALE;
23
24pub 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
46pub 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
78const 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
117pub fn encode_preview_html(caption: &str) -> String {
121 let escaped_caption = caption
122 .replace('&', "&")
123 .replace('<', "<")
124 .replace('>', ">");
125 format!("{PREVIEW_HEAD}{escaped_caption}{PREVIEW_PANELS}")
126}
127
128const GIF_COLOR_LIMIT: usize = 255;
130const GIF_TRANSPARENT_INDEX: u8 = 0;
131
132pub 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
209fn 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
231fn 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#[derive(Debug)]
240pub enum GifEncodingError {
241 NoFrames,
243 TooManyColors {
245 frame_index: usize,
247 },
248 PartialTransparency {
250 frame_index: usize,
252 },
253 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 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}