Skip to main content

bc_mur/
animate.rs

1use bc_ur::{MultipartEncoder, UR};
2
3use crate::{
4    Color, CorrectionLevel, Error, Logo, Result,
5    qr_matrix::{QrMatrix, check_qr_density},
6    render::{RenderedImage, render_from_matrix},
7};
8
9/// Parameters for multipart animated QR generation.
10pub struct AnimateParams {
11    /// Maximum fragment length for fountain coding (default
12    /// 40).
13    pub max_fragment_len: usize,
14    /// Error correction level. `None` = auto: Low without
15    /// logo, Quartile with logo.
16    pub correction: Option<CorrectionLevel>,
17    /// Target image size in pixels (default 512).
18    pub size: u32,
19    /// Foreground color (default black).
20    pub foreground: Color,
21    /// Background color (default white).
22    pub background: Color,
23    /// Quiet zone modules around the QR code (default 1).
24    pub quiet_zone: u32,
25    /// Optional logo overlay.
26    pub logo: Option<Logo>,
27    /// Frames per second (default 8.0).
28    pub fps: f64,
29    /// Number of complete cycles through all fragments
30    /// (default 3).
31    pub cycles: u32,
32    /// If set, use exactly this many frames instead of
33    /// `parts_count * cycles`. Returns `InsufficientFrames`
34    /// if fewer than the fountain-coded fragment count.
35    pub frame_count: Option<usize>,
36    /// If set, check each frame's QR module count against
37    /// this limit. Returns `QrCodeTooDense` if exceeded.
38    pub max_modules: Option<usize>,
39}
40
41impl Default for AnimateParams {
42    fn default() -> Self {
43        Self {
44            max_fragment_len: 40,
45            correction: None,
46            size: 512,
47            foreground: Color::BLACK,
48            background: Color::WHITE,
49            quiet_zone: 1,
50            logo: None,
51            fps: 8.0,
52            cycles: 3,
53            frame_count: None,
54            max_modules: None,
55        }
56    }
57}
58
59impl AnimateParams {
60    fn effective_correction(&self) -> CorrectionLevel {
61        self.correction.unwrap_or(if self.logo.is_some() {
62            CorrectionLevel::High
63        } else {
64            CorrectionLevel::Low
65        })
66    }
67}
68
69/// A single frame of a multipart QR animation.
70pub struct QrFrame {
71    /// The rendered RGBA image for this frame.
72    pub image: RenderedImage,
73    /// The part index (0-based).
74    pub index: usize,
75}
76
77/// Generate all frames for a multipart UR animation.
78///
79/// Cycles through the fountain-coded parts `params.cycles`
80/// times.
81pub fn generate_frames(
82    ur: &UR,
83    params: &AnimateParams,
84) -> Result<Vec<QrFrame>> {
85    let mut encoder = MultipartEncoder::new(ur, params.max_fragment_len)?;
86    let parts_count = encoder.parts_count();
87    let total_frames = if let Some(n) = params.frame_count {
88        n
89    } else {
90        parts_count * params.cycles as usize
91    };
92
93    // Validate frame count is sufficient for decoding.
94    if total_frames < parts_count {
95        return Err(Error::InsufficientFrames {
96            requested: total_frames,
97            fragments: parts_count,
98        });
99    }
100
101    let correction = params.effective_correction();
102    let mut frames = Vec::with_capacity(total_frames);
103
104    for i in 0..total_frames {
105        let part = encoder.next_part()?;
106        let index = encoder.current_index();
107        let upper = part.to_ascii_uppercase();
108        let matrix = QrMatrix::encode(upper.as_bytes(), correction)?;
109
110        // Check density on first frame (all frames use the
111        // same QR version for a given fragment length).
112        if i == 0
113            && let Some(limit) = params.max_modules
114        {
115            check_qr_density(matrix.width(), limit)?;
116        }
117
118        let image = render_from_matrix(
119            &matrix,
120            params.size,
121            params.foreground,
122            params.background,
123            params.quiet_zone,
124            params.logo.as_ref(),
125        )?;
126        frames.push(QrFrame { image, index });
127    }
128
129    Ok(frames)
130}
131
132/// Encode frames into an animated GIF.
133///
134/// For QR codes without logos, uses a small global palette
135/// (2–4 colors). For QR codes with logos, uses per-frame
136/// quantization.
137pub fn encode_animated_gif(frames: &[QrFrame], fps: f64) -> Result<Vec<u8>> {
138    if frames.is_empty() {
139        return Err(Error::InvalidParameter("no frames to encode".into()));
140    }
141
142    let width = frames[0].image.width as u16;
143    let height = frames[0].image.height as u16;
144    let delay_cs = (100.0 / fps).round() as u16; // GIF delay in centiseconds
145
146    let mut buf = Vec::new();
147    {
148        let mut encoder = gif::Encoder::new(&mut buf, width, height, &[])
149            .map_err(|e| Error::GifEncode(format!("GIF init: {e}")))?;
150
151        encoder
152            .set_repeat(gif::Repeat::Infinite)
153            .map_err(|e| Error::GifEncode(format!("GIF set repeat: {e}")))?;
154
155        for frame_data in frames {
156            let rgba = &frame_data.image.pixels;
157            let (palette, indexed) =
158                quantize_frame(rgba, width as u32, height as u32);
159
160            let mut frame = gif::Frame {
161                width,
162                height,
163                delay: delay_cs,
164                palette: Some(palette),
165                ..Default::default()
166            };
167            frame.buffer = std::borrow::Cow::Owned(indexed);
168
169            encoder.write_frame(&frame).map_err(|e| {
170                Error::GifEncode(format!("GIF write frame: {e}"))
171            })?;
172        }
173    }
174
175    Ok(buf)
176}
177
178/// Write frames as numbered PNG files.
179pub fn write_frame_pngs(
180    frames: &[QrFrame],
181    output_dir: &std::path::Path,
182) -> Result<()> {
183    std::fs::create_dir_all(output_dir)?;
184    for (i, frame) in frames.iter().enumerate() {
185        let path = output_dir.join(format!("{:04}.png", i));
186        let png = frame.image.to_png()?;
187        std::fs::write(&path, &png)?;
188    }
189    Ok(())
190}
191
192/// Quantize an RGBA frame to a 256-color indexed palette.
193///
194/// Uses median-cut quantization via the `image` crate's
195/// color quantization.
196fn quantize_frame(rgba: &[u8], width: u32, height: u32) -> (Vec<u8>, Vec<u8>) {
197    // Collect unique colors (up to limit)
198    let mut unique_colors: Vec<[u8; 4]> = Vec::new();
199    let mut seen = std::collections::HashSet::new();
200
201    for px in rgba.chunks_exact(4) {
202        let key = [px[0], px[1], px[2], px[3]];
203        if seen.insert(key) {
204            unique_colors.push(key);
205            if unique_colors.len() > 256 {
206                break;
207            }
208        }
209    }
210
211    if unique_colors.len() <= 256 {
212        // Simple case: few enough colors for a direct palette
213        let palette: Vec<u8> = unique_colors
214            .iter()
215            .flat_map(|c| [c[0], c[1], c[2]])
216            .collect();
217
218        let indexed: Vec<u8> = rgba
219            .chunks_exact(4)
220            .map(|px| {
221                unique_colors
222                    .iter()
223                    .position(|c| {
224                        c[0] == px[0]
225                            && c[1] == px[1]
226                            && c[2] == px[2]
227                            && c[3] == px[3]
228                    })
229                    .unwrap_or(0) as u8
230            })
231            .collect();
232
233        (palette, indexed)
234    } else {
235        // Many colors (logo present) — use NeuQuant
236        let nq = color_quant::NeuQuant::new(10, 256, rgba);
237        let palette: Vec<u8> = (0..256)
238            .flat_map(|i| {
239                if let Some(c) = nq.lookup(i) {
240                    [c[0], c[1], c[2]]
241                } else {
242                    [0, 0, 0]
243                }
244            })
245            .collect();
246
247        let indexed: Vec<u8> = rgba
248            .chunks_exact(4)
249            .map(|px| nq.index_of(px) as u8)
250            .collect();
251
252        let _ = (width, height); // used by signature
253
254        (palette, indexed)
255    }
256}