1use 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 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 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 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 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 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 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}