Skip to main content

device_envoy_core/
to_png.rs

1//! Host-only PNG/APNG preview helpers for [`Frame2d`].
2//!
3//! Prefer using the inherent [`Frame2d`] methods:
4//! - [`Frame2d::write_png`]
5//! - [`Frame2d::write_png_with_gamma`]
6//! - [`Frame2d::write_apng`]
7//! - [`Frame2d::write_apng_with_gamma`]
8
9use crate::led2d::Frame2d;
10use png::{BitDepth, ColorType, Encoder, ScaledFloat};
11use std::error::Error;
12use std::fs::File;
13use std::io::BufWriter;
14use std::path::Path;
15
16const PREVIEW_INVERSE_GAMMA: f32 = 2.2;
17
18impl<const W: usize, const H: usize> Frame2d<W, H> {
19    /// Render this frame into a PNG file sized to the requested maximum dimension.
20    pub fn write_png(
21        &self,
22        output_path: impl AsRef<Path>,
23        target_max_dimension: u32,
24    ) -> Result<(), Box<dyn Error>> {
25        write_frame_png(self, output_path, target_max_dimension)
26    }
27
28    /// Render this frame into a PNG file with a custom preview inverse gamma.
29    pub fn write_png_with_gamma(
30        &self,
31        output_path: impl AsRef<Path>,
32        target_max_dimension: u32,
33        preview_inverse_gamma: f32,
34    ) -> Result<(), Box<dyn Error>> {
35        write_frame_png_with_gamma(
36            self,
37            output_path,
38            target_max_dimension,
39            preview_inverse_gamma,
40        )
41    }
42
43    /// Render this frame into PNG bytes sized to the requested maximum dimension.
44    pub fn to_png_bytes(&self, target_max_dimension: u32) -> Result<Vec<u8>, Box<dyn Error>> {
45        self.to_png_bytes_with_gamma(target_max_dimension, PREVIEW_INVERSE_GAMMA)
46    }
47
48    /// Render this frame into PNG bytes with a custom preview inverse gamma.
49    pub fn to_png_bytes_with_gamma(
50        &self,
51        target_max_dimension: u32,
52        preview_inverse_gamma: f32,
53    ) -> Result<Vec<u8>, Box<dyn Error>> {
54        frame_png_bytes(self, target_max_dimension, preview_inverse_gamma)
55    }
56
57    /// Render multiple frames into a looping APNG file.
58    pub fn write_apng(
59        frames: &[Self],
60        output_path: impl AsRef<Path>,
61        target_max_dimension: u32,
62        frame_delay_ms: u32,
63    ) -> Result<(), Box<dyn Error>> {
64        write_frames_apng(frames, output_path, target_max_dimension, frame_delay_ms)
65    }
66
67    /// Render multiple frames into a looping APNG file with a custom preview inverse gamma.
68    pub fn write_apng_with_gamma(
69        frames: &[Self],
70        output_path: impl AsRef<Path>,
71        target_max_dimension: u32,
72        frame_delay_ms: u32,
73        preview_inverse_gamma: f32,
74    ) -> Result<(), Box<dyn Error>> {
75        write_frames_apng_with_gamma(
76            frames,
77            output_path,
78            target_max_dimension,
79            frame_delay_ms,
80            preview_inverse_gamma,
81        )
82    }
83}
84
85fn write_frame_png<const W: usize, const H: usize>(
86    frame: &Frame2d<W, H>,
87    output_path: impl AsRef<Path>,
88    target_max_dimension: u32,
89) -> Result<(), Box<dyn Error>> {
90    write_frame_png_with_gamma(
91        frame,
92        output_path,
93        target_max_dimension,
94        PREVIEW_INVERSE_GAMMA,
95    )
96}
97
98fn write_frame_png_with_gamma<const W: usize, const H: usize>(
99    frame: &Frame2d<W, H>,
100    output_path: impl AsRef<Path>,
101    target_max_dimension: u32,
102    preview_inverse_gamma: f32,
103) -> Result<(), Box<dyn Error>> {
104    assert!(
105        preview_inverse_gamma > 0.0,
106        "preview_inverse_gamma must be positive"
107    );
108    let output_path = output_path.as_ref();
109    let panel_width = W as u32;
110    let panel_height = H as u32;
111    let cell_size = select_cell_size(panel_width, panel_height, target_max_dimension);
112    let led_margin = (cell_size / 8).max(1);
113    write_panel_png(
114        frame,
115        output_path,
116        cell_size,
117        led_margin,
118        preview_inverse_gamma,
119    )?;
120    println!("wrote PNG to {}", output_path.display());
121    Ok(())
122}
123
124fn frame_png_bytes<const W: usize, const H: usize>(
125    frame: &Frame2d<W, H>,
126    target_max_dimension: u32,
127    preview_inverse_gamma: f32,
128) -> Result<Vec<u8>, Box<dyn Error>> {
129    assert!(
130        preview_inverse_gamma > 0.0,
131        "preview_inverse_gamma must be positive"
132    );
133    let panel_width = W as u32;
134    let panel_height = H as u32;
135    let cell_size = select_cell_size(panel_width, panel_height, target_max_dimension);
136    let led_margin = (cell_size / 8).max(1);
137    let (width, height, pixels) = panel_pixels(frame, cell_size, led_margin, preview_inverse_gamma);
138    let mut png_bytes = Vec::new();
139    let mut encoder = Encoder::new(&mut png_bytes, width, height);
140    encoder.set_color(ColorType::Rgb);
141    encoder.set_depth(BitDepth::Sixteen);
142    encoder.set_source_gamma(ScaledFloat::new(1.0));
143    {
144        let mut writer = encoder.write_header()?;
145        writer.write_image_data(&pixels)?;
146    }
147    Ok(png_bytes)
148}
149
150fn write_frames_apng<const W: usize, const H: usize>(
151    frames: &[Frame2d<W, H>],
152    output_path: impl AsRef<Path>,
153    target_max_dimension: u32,
154    frame_delay_ms: u32,
155) -> Result<(), Box<dyn Error>> {
156    write_frames_apng_with_gamma(
157        frames,
158        output_path,
159        target_max_dimension,
160        frame_delay_ms,
161        PREVIEW_INVERSE_GAMMA,
162    )
163}
164
165fn write_frames_apng_with_gamma<const W: usize, const H: usize>(
166    frames: &[Frame2d<W, H>],
167    output_path: impl AsRef<Path>,
168    target_max_dimension: u32,
169    frame_delay_ms: u32,
170    preview_inverse_gamma: f32,
171) -> Result<(), Box<dyn Error>> {
172    assert!(!frames.is_empty(), "frames must not be empty");
173    assert!(frame_delay_ms > 0, "frame_delay_ms must be positive");
174    assert!(
175        preview_inverse_gamma > 0.0,
176        "preview_inverse_gamma must be positive"
177    );
178    let output_path = output_path.as_ref();
179    let panel_width = W as u32;
180    let panel_height = H as u32;
181    let cell_size = select_cell_size(panel_width, panel_height, target_max_dimension);
182    let led_margin = (cell_size / 8).max(1);
183    let frame_count = u32::try_from(frames.len()).expect("frame count must fit in u32");
184    let delay_num = u16::try_from(frame_delay_ms).expect("frame_delay_ms must fit in u16");
185    let delay_den = 1000u16;
186
187    let (width, height, first_pixels) =
188        panel_pixels(&frames[0], cell_size, led_margin, preview_inverse_gamma);
189    let mut pixels = Vec::with_capacity(frames.len());
190    pixels.push(first_pixels);
191    for frame in frames.iter().skip(1) {
192        let (frame_width, frame_height, frame_pixels) =
193            panel_pixels(frame, cell_size, led_margin, preview_inverse_gamma);
194        assert!(frame_width == width, "frame width must match");
195        assert!(frame_height == height, "frame height must match");
196        pixels.push(frame_pixels);
197    }
198
199    if let Some(parent) = output_path.parent()
200        && !parent.as_os_str().is_empty()
201    {
202        std::fs::create_dir_all(parent)?;
203    }
204
205    let file = File::create(output_path)?;
206    let mut encoder = Encoder::new(BufWriter::new(file), width, height);
207    encoder.set_color(ColorType::Rgb);
208    encoder.set_depth(BitDepth::Sixteen);
209    encoder.set_source_gamma(ScaledFloat::new(1.0));
210    encoder.set_animated(frame_count, 0)?;
211    let mut writer = encoder.write_header()?;
212    for frame_pixels in pixels {
213        writer.set_frame_delay(delay_num, delay_den)?;
214        writer.write_image_data(&frame_pixels)?;
215    }
216    writer.finish()?;
217    println!("wrote APNG to {}", output_path.display());
218    Ok(())
219}
220
221fn select_cell_size(panel_width: u32, panel_height: u32, target_max_dimension: u32) -> u32 {
222    assert!(
223        target_max_dimension > 0,
224        "target_max_dimension must be positive"
225    );
226    let mut cell_size = target_max_dimension;
227    while cell_size > 1 {
228        let led_margin = (cell_size / 8).max(1);
229        let led_radius = (cell_size - (led_margin * 2)) / 2;
230        let output_width = panel_width * cell_size + led_radius * 2;
231        let output_height = panel_height * cell_size + led_radius * 2;
232        let max_dimension = output_width.max(output_height);
233        if max_dimension <= target_max_dimension {
234            break;
235        }
236        cell_size -= 1;
237    }
238    cell_size
239}
240
241fn write_panel_png<const W: usize, const H: usize>(
242    frame: &Frame2d<W, H>,
243    output_path: &Path,
244    cell_size: u32,
245    led_margin: u32,
246    preview_inverse_gamma: f32,
247) -> Result<(), Box<dyn Error>> {
248    let (width, height, pixels) = panel_pixels(frame, cell_size, led_margin, preview_inverse_gamma);
249    if let Some(parent) = output_path.parent()
250        && !parent.as_os_str().is_empty()
251    {
252        std::fs::create_dir_all(parent)?;
253    }
254
255    let file = File::create(output_path)?;
256    let mut encoder = Encoder::new(BufWriter::new(file), width, height);
257    encoder.set_color(ColorType::Rgb);
258    encoder.set_depth(BitDepth::Sixteen);
259    encoder.set_source_gamma(ScaledFloat::new(1.0));
260    let mut writer = encoder.write_header()?;
261    writer.write_image_data(&pixels)?;
262    Ok(())
263}
264
265fn panel_pixels<const W: usize, const H: usize>(
266    frame: &Frame2d<W, H>,
267    cell_size: u32,
268    led_margin: u32,
269    preview_inverse_gamma: f32,
270) -> (u32, u32, Vec<u8>) {
271    assert!(cell_size > 0, "cell_size must be positive");
272    assert!(
273        led_margin < cell_size / 2,
274        "led_margin must fit inside cell"
275    );
276    assert!(
277        preview_inverse_gamma > 0.0,
278        "preview_inverse_gamma must be positive"
279    );
280    let led_radius = (cell_size - (led_margin * 2)) / 2;
281    assert!(led_radius > 0, "led_radius must be positive");
282    let fade_width = led_radius / 3;
283    assert!(fade_width > 0, "fade_width must be positive");
284
285    let border = led_radius;
286    assert!(border > 0, "border must be positive");
287    let width = (W as u32) * cell_size + border * 2;
288    let height = (H as u32) * cell_size + border * 2;
289    let mut bytes = vec![0u8; (width * height * 3 * 2) as usize];
290    let center = (cell_size - 1) as i32 / 2;
291    let led_radius_f = led_radius as f32;
292    let inner_radius_f = (led_radius - fade_width) as f32;
293    let radius_sq = (led_radius as i32) * (led_radius as i32);
294
295    for y_index in 0..H {
296        for x_index in 0..W {
297            let pixel = frame.0[y_index][x_index];
298            let cell_origin_x = (x_index as u32) * cell_size;
299            let cell_origin_y = (y_index as u32) * cell_size;
300
301            for local_y in 0..cell_size {
302                let delta_y = local_y as i32 - center;
303                for local_x in 0..cell_size {
304                    let delta_x = local_x as i32 - center;
305                    let distance_sq = delta_x * delta_x + delta_y * delta_y;
306                    if distance_sq <= radius_sq {
307                        let distance = (distance_sq as f32).sqrt();
308                        let intensity = if distance <= inner_radius_f {
309                            1.0
310                        } else {
311                            let fade_span = led_radius_f - inner_radius_f;
312                            (1.0 - (distance - inner_radius_f) / fade_span).max(0.0)
313                        };
314                        let x = border + cell_origin_x + local_x;
315                        let y = border + cell_origin_y + local_y;
316                        let pixel_index = ((y * width + x) * 3 * 2) as usize;
317                        let red = linear_to_u16(
318                            inverse_gamma_to_linear(pixel.r, preview_inverse_gamma) * intensity,
319                        );
320                        let green = linear_to_u16(
321                            inverse_gamma_to_linear(pixel.g, preview_inverse_gamma) * intensity,
322                        );
323                        let blue = linear_to_u16(
324                            inverse_gamma_to_linear(pixel.b, preview_inverse_gamma) * intensity,
325                        );
326                        bytes[pixel_index] = (red >> 8) as u8;
327                        bytes[pixel_index + 1] = red as u8;
328                        bytes[pixel_index + 2] = (green >> 8) as u8;
329                        bytes[pixel_index + 3] = green as u8;
330                        bytes[pixel_index + 4] = (blue >> 8) as u8;
331                        bytes[pixel_index + 5] = blue as u8;
332                    }
333                }
334            }
335        }
336    }
337
338    (width, height, bytes)
339}
340
341fn inverse_gamma_to_linear(channel: u8, preview_inverse_gamma: f32) -> f32 {
342    let normalized = (channel as f32) / 255.0;
343    normalized.powf(preview_inverse_gamma)
344}
345
346fn linear_to_u16(value: f32) -> u16 {
347    let clamped = value.clamp(0.0, 1.0);
348    (clamped * 65535.0).round() as u16
349}