Skip to main content

figif_core/encoders/
standard.rs

1//! Standard lossless GIF encoder using the gif crate.
2//!
3//! Features delta encoding for smaller file sizes by only encoding
4//! the changed pixels between consecutive frames.
5
6use crate::error::{FigifError, Result};
7use crate::traits::GifEncoder;
8use crate::types::{EncodableFrame, EncodeConfig};
9use gif::{DisposalMethod, Encoder, Frame, Repeat};
10use image::RgbaImage;
11use image::imageops::FilterType;
12use std::fs::File;
13use std::io::{BufWriter, Write};
14use std::path::Path;
15
16/// Standard lossless GIF encoder.
17///
18/// This encoder produces standard GIF files using the `gif` crate.
19/// It supports resizing and basic optimization but does not perform
20/// lossy compression.
21///
22/// # Example
23///
24/// ```ignore
25/// use figif_core::encoders::StandardEncoder;
26/// use figif_core::traits::GifEncoder;
27///
28/// let encoder = StandardEncoder::new();
29/// let bytes = encoder.encode(&frames, &EncodeConfig::default())?;
30/// ```
31#[derive(Debug, Clone, Default)]
32pub struct StandardEncoder {
33    /// Resize filter to use when resizing frames.
34    resize_filter: ResizeFilter,
35}
36
37/// Filter type for resizing operations.
38#[derive(Debug, Clone, Copy, Default)]
39pub enum ResizeFilter {
40    /// Nearest neighbor - fast, pixelated
41    Nearest,
42    /// Triangle (bilinear) - good balance
43    #[default]
44    Triangle,
45    /// Catmull-Rom - smooth, good for downscaling
46    CatmullRom,
47    /// Lanczos3 - highest quality, slowest
48    Lanczos3,
49}
50
51impl From<ResizeFilter> for FilterType {
52    fn from(filter: ResizeFilter) -> Self {
53        match filter {
54            ResizeFilter::Nearest => FilterType::Nearest,
55            ResizeFilter::Triangle => FilterType::Triangle,
56            ResizeFilter::CatmullRom => FilterType::CatmullRom,
57            ResizeFilter::Lanczos3 => FilterType::Lanczos3,
58        }
59    }
60}
61
62impl StandardEncoder {
63    /// Create a new standard encoder with default settings.
64    pub fn new() -> Self {
65        Self::default()
66    }
67
68    /// Set the resize filter.
69    pub fn with_resize_filter(mut self, filter: ResizeFilter) -> Self {
70        self.resize_filter = filter;
71        self
72    }
73
74    /// Encode frames to a writer.
75    fn encode_to<W: Write>(
76        &self,
77        frames: &[EncodableFrame],
78        mut writer: W,
79        config: &EncodeConfig,
80    ) -> Result<()> {
81        if frames.is_empty() {
82            return Err(FigifError::NoFrames);
83        }
84
85        // Determine output dimensions
86        let first_frame = &frames[0];
87        let (src_width, src_height) = first_frame.image.dimensions();
88
89        let (out_width, out_height) = match (config.width, config.height) {
90            (Some(w), Some(h)) => (w as u32, h as u32),
91            (Some(w), None) => {
92                let ratio = w as f64 / src_width as f64;
93                (w as u32, (src_height as f64 * ratio).round() as u32)
94            }
95            (None, Some(h)) => {
96                let ratio = h as f64 / src_height as f64;
97                ((src_width as f64 * ratio).round() as u32, h as u32)
98            }
99            (None, None) => (src_width, src_height),
100        };
101
102        // Create encoder
103        let mut encoder = Encoder::new(&mut writer, out_width as u16, out_height as u16, &[])
104            .map_err(|e| FigifError::EncodeError {
105                reason: e.to_string(),
106            })?;
107
108        // Set repeat/loop behavior
109        let repeat: Repeat = config.loop_count.into();
110        encoder
111            .set_repeat(repeat)
112            .map_err(|e| FigifError::EncodeError {
113                reason: e.to_string(),
114            })?;
115
116        // Encode each frame with delta optimization
117        let needs_resize = out_width != src_width || out_height != src_height;
118        let mut prev_image: Option<RgbaImage> = None;
119
120        for (idx, encodable) in frames.iter().enumerate() {
121            let image = if needs_resize {
122                image::imageops::resize(
123                    &encodable.image,
124                    out_width,
125                    out_height,
126                    self.resize_filter.into(),
127                )
128            } else {
129                encodable.image.clone()
130            };
131
132            // First frame or frames with no previous: encode full frame
133            let frame = if let Some(prev) = prev_image.as_ref().filter(|_| idx != 0) {
134                // Compute delta from previous frame
135                match compute_delta_frame(&image, prev, encodable.delay_centiseconds) {
136                    Some(delta_frame) => delta_frame,
137                    None => {
138                        // Frames are identical - still need to emit a frame for timing
139                        // Use a 1x1 transparent frame at 0,0
140                        Frame {
141                            width: 1,
142                            height: 1,
143                            left: 0,
144                            top: 0,
145                            delay: encodable.delay_centiseconds,
146                            dispose: DisposalMethod::Keep,
147                            transparent: Some(0),
148                            palette: Some(vec![0, 0, 0]), // Single transparent color
149                            buffer: std::borrow::Cow::Owned(vec![0]),
150                            ..Default::default()
151                        }
152                    }
153                }
154            } else {
155                let mut f = rgba_to_gif_frame(&image, encodable.delay_centiseconds)?;
156                f.dispose = DisposalMethod::Keep;
157                f
158            };
159
160            encoder
161                .write_frame(&frame)
162                .map_err(|e| FigifError::EncodeError {
163                    reason: e.to_string(),
164                })?;
165
166            prev_image = Some(image);
167        }
168
169        Ok(())
170    }
171}
172
173impl GifEncoder for StandardEncoder {
174    fn encode(&self, frames: &[EncodableFrame], config: &EncodeConfig) -> Result<Vec<u8>> {
175        let mut buffer = Vec::new();
176        self.encode_to(frames, &mut buffer, config)?;
177        Ok(buffer)
178    }
179
180    fn encode_to_file(
181        &self,
182        frames: &[EncodableFrame],
183        path: impl AsRef<Path>,
184        config: &EncodeConfig,
185    ) -> Result<()> {
186        let path = path.as_ref();
187        let file = File::create(path).map_err(|e| FigifError::FileWrite {
188            path: path.to_path_buf(),
189            source: e,
190        })?;
191        let writer = BufWriter::new(file);
192        self.encode_to(frames, writer, config)
193    }
194
195    fn encode_to_writer<W: Write>(
196        &self,
197        frames: &[EncodableFrame],
198        writer: W,
199        config: &EncodeConfig,
200    ) -> Result<()> {
201        self.encode_to(frames, writer, config)
202    }
203
204    fn supports_lossy(&self) -> bool {
205        false
206    }
207
208    fn name(&self) -> &'static str {
209        "standard"
210    }
211}
212
213/// Compute a delta frame that only contains changed pixels from the previous frame.
214/// Returns None if frames are identical.
215fn compute_delta_frame(
216    current: &RgbaImage,
217    prev: &RgbaImage,
218    delay: u16,
219) -> Option<Frame<'static>> {
220    let (width, height) = current.dimensions();
221
222    // Find bounding box of changed pixels
223    let mut min_x = width;
224    let mut min_y = height;
225    let mut max_x = 0u32;
226    let mut max_y = 0u32;
227
228    for y in 0..height {
229        for x in 0..width {
230            let curr_pixel = current.get_pixel(x, y);
231            let prev_pixel = prev.get_pixel(x, y);
232            if curr_pixel != prev_pixel {
233                min_x = min_x.min(x);
234                min_y = min_y.min(y);
235                max_x = max_x.max(x);
236                max_y = max_y.max(y);
237            }
238        }
239    }
240
241    // No changes - frames are identical
242    if max_x < min_x || max_y < min_y {
243        return None;
244    }
245
246    // Extract the changed region
247    let delta_width = max_x - min_x + 1;
248    let delta_height = max_y - min_y + 1;
249
250    // Build palette and indices for just the changed region
251    // Use transparent for unchanged pixels within the bounding box
252    let mut palette: Vec<[u8; 3]> = Vec::new();
253    let mut indices: Vec<u8> = Vec::with_capacity((delta_width * delta_height) as usize);
254    let mut color_map: std::collections::HashMap<[u8; 3], u8> = std::collections::HashMap::new();
255
256    // Reserve index 0 for transparent (unchanged pixels)
257    let transparent_index: u8 = 0;
258    palette.push([0, 0, 0]); // Transparent placeholder
259
260    for y in min_y..=max_y {
261        for x in min_x..=max_x {
262            let curr_pixel = current.get_pixel(x, y);
263            let prev_pixel = prev.get_pixel(x, y);
264
265            if curr_pixel == prev_pixel {
266                // Unchanged pixel - use transparent
267                indices.push(transparent_index);
268            } else {
269                let [r, g, b, a] = curr_pixel.0;
270
271                if a < 128 {
272                    // Transparent in current frame
273                    indices.push(transparent_index);
274                } else {
275                    let color = [r, g, b];
276                    let index = if let Some(&idx) = color_map.get(&color) {
277                        idx
278                    } else if palette.len() < 256 {
279                        let idx = palette.len() as u8;
280                        color_map.insert(color, idx);
281                        palette.push(color);
282                        idx
283                    } else {
284                        // Palette full, find closest
285                        find_closest_color(&palette, color)
286                    };
287                    indices.push(index);
288                }
289            }
290        }
291    }
292
293    // Ensure palette has at least 2 colors
294    while palette.len() < 2 {
295        palette.push([0, 0, 0]);
296    }
297
298    // Pad palette to power of 2
299    let palette_size = palette.len().next_power_of_two().max(2);
300    while palette.len() < palette_size {
301        palette.push([0, 0, 0]);
302    }
303
304    // Flatten palette
305    let flat_palette: Vec<u8> = palette.iter().flat_map(|c| c.iter().copied()).collect();
306
307    // Create the delta frame
308    let mut frame = Frame::from_palette_pixels(
309        delta_width as u16,
310        delta_height as u16,
311        indices,
312        flat_palette,
313        Some(transparent_index),
314    );
315
316    frame.left = min_x as u16;
317    frame.top = min_y as u16;
318    frame.delay = delay;
319    frame.dispose = DisposalMethod::Keep;
320
321    Some(frame)
322}
323
324/// Convert an RGBA image to a GIF frame with color quantization.
325fn rgba_to_gif_frame(image: &RgbaImage, delay: u16) -> Result<Frame<'static>> {
326    let (width, height) = image.dimensions();
327
328    // Simple color quantization using NeuQuant
329    // The gif crate will handle this internally, but we need to prepare the data
330
331    // For now, use a simpler approach: convert to indexed color
332    // by building a palette from the unique colors
333
334    let mut palette: Vec<[u8; 3]> = Vec::new();
335    let mut indices: Vec<u8> = Vec::with_capacity((width * height) as usize);
336    let mut color_map: std::collections::HashMap<[u8; 3], u8> = std::collections::HashMap::new();
337    let mut transparent_index: Option<u8> = None;
338
339    for pixel in image.pixels() {
340        let [r, g, b, a] = pixel.0;
341
342        if a < 128 {
343            // Transparent pixel
344            if transparent_index.is_none() && palette.len() < 256 {
345                transparent_index = Some(palette.len() as u8);
346                palette.push([0, 0, 0]); // Placeholder for transparent
347            }
348            indices.push(transparent_index.unwrap_or(0));
349        } else {
350            let color = [r, g, b];
351            let index = if let Some(&idx) = color_map.get(&color) {
352                idx
353            } else if palette.len() < 256 {
354                let idx = palette.len() as u8;
355                color_map.insert(color, idx);
356                palette.push(color);
357                idx
358            } else {
359                // Palette is full, find closest color
360                find_closest_color(&palette, color)
361            };
362            indices.push(index);
363        }
364    }
365
366    // Ensure palette has at least 2 colors (GIF requirement)
367    while palette.len() < 2 {
368        palette.push([0, 0, 0]);
369    }
370
371    // Pad palette to power of 2
372    let palette_size = palette.len().next_power_of_two().max(2);
373    while palette.len() < palette_size {
374        palette.push([0, 0, 0]);
375    }
376
377    // Flatten palette
378    let flat_palette: Vec<u8> = palette.iter().flat_map(|c| c.iter().copied()).collect();
379
380    // Create frame
381    let mut frame = Frame::from_palette_pixels(
382        width as u16,
383        height as u16,
384        indices,
385        flat_palette,
386        transparent_index,
387    );
388
389    frame.delay = delay;
390
391    Ok(frame)
392}
393
394/// Find the closest color in the palette using simple Euclidean distance.
395fn find_closest_color(palette: &[[u8; 3]], target: [u8; 3]) -> u8 {
396    let mut best_idx = 0u8;
397    let mut best_dist = u32::MAX;
398
399    for (idx, color) in palette.iter().enumerate() {
400        let dr = (color[0] as i32 - target[0] as i32).pow(2) as u32;
401        let dg = (color[1] as i32 - target[1] as i32).pow(2) as u32;
402        let db = (color[2] as i32 - target[2] as i32).pow(2) as u32;
403        let dist = dr + dg + db;
404
405        if dist < best_dist {
406            best_dist = dist;
407            best_idx = idx as u8;
408        }
409    }
410
411    best_idx
412}