1use std::fs;
22use std::path::Path;
23
24use oxideav_core::{Error, PixelFormat, Result, VideoFrame, VideoPlane};
25use oxideav_pixfmt::{convert as pix_convert, ConvertOptions, FrameInfo};
26
27use crate::op::{AlphaOp, Op};
28
29#[derive(Clone, Copy, Debug, PartialEq, Eq)]
34pub enum OutputClass {
35 Scene,
38 Vector,
41 Raster(RasterFormat),
44}
45
46#[derive(Clone, Copy, Debug, PartialEq, Eq)]
47pub enum RasterFormat {
48 Png,
49 Jpeg,
50 Bmp,
51 Webp,
52}
53
54pub fn classify_output(path_or_template: &str) -> Result<OutputClass> {
60 let ext = ext_of(path_or_template)
61 .ok_or_else(|| {
62 Error::invalid(format!(
63 "convert: output '{path_or_template}' has no extension"
64 ))
65 })?
66 .to_ascii_lowercase();
67 match ext.as_str() {
68 "pdf" => Ok(OutputClass::Scene),
69 "svg" => Ok(OutputClass::Vector),
70 "png" | "apng" => Ok(OutputClass::Raster(RasterFormat::Png)),
71 "jpg" | "jpeg" => Ok(OutputClass::Raster(RasterFormat::Jpeg)),
72 "bmp" | "dib" => Ok(OutputClass::Raster(RasterFormat::Bmp)),
73 "webp" => Ok(OutputClass::Raster(RasterFormat::Webp)),
74 other => Err(Error::unsupported(format!(
75 "convert: output extension '.{other}' not yet supported (today png/jpg/bmp/webp/svg/pdf)"
76 ))),
77 }
78}
79
80fn ext_of(path: &str) -> Option<&str> {
81 let last = path.rsplit('/').next().unwrap_or(path);
82 let last = last.split('?').next().unwrap_or(last);
83 let dot = last.rfind('.')?;
84 Some(&last[dot + 1..])
85}
86
87#[derive(Debug, Clone)]
91pub struct RgbaImage {
92 pub width: u32,
93 pub height: u32,
94 pub pixels: Vec<u8>,
95 pub stride: usize,
96}
97
98impl RgbaImage {
99 pub fn is_rgb(&self) -> bool {
103 self.stride == (self.width as usize) * 3
104 }
105}
106
107pub fn encode_raster_to_path(
112 img: &RgbaImage,
113 fmt: RasterFormat,
114 path: &str,
115 ops: &[Op],
116) -> Result<()> {
117 let bytes = match fmt {
118 RasterFormat::Png => encode_png(img)?,
119 RasterFormat::Bmp => encode_bmp(img)?,
120 RasterFormat::Webp => encode_webp(img)?,
121 RasterFormat::Jpeg => encode_jpeg(img, ops)?,
122 };
123 fs::write(Path::new(path), bytes)
124 .map_err(|e| Error::invalid(format!("convert: failed to write {path}: {e}")))?;
125 Ok(())
126}
127
128fn last_quality(ops: &[Op]) -> Option<u8> {
129 ops.iter().rev().find_map(|o| match o {
130 Op::Quality(q) => Some((*q).min(100) as u8),
131 _ => None,
132 })
133}
134
135pub fn apply_alpha_ops(img: &mut RgbaImage, ops: &[Op], bg: [u8; 4]) {
140 for op in ops {
141 if let Op::Alpha(a) = op {
142 match a {
143 AlphaOp::On => {}
144 AlphaOp::Off => drop_alpha(img),
145 AlphaOp::Remove => flatten_alpha_over(img, bg),
146 AlphaOp::Set => set_alpha(img, 255),
147 AlphaOp::Transparent => set_alpha(img, 0),
148 }
149 }
150 }
151}
152
153fn flatten_alpha_over(img: &mut RgbaImage, bg: [u8; 4]) {
154 if img.is_rgb() {
155 return;
156 }
157 let bg_r = bg[0] as u32;
158 let bg_g = bg[1] as u32;
159 let bg_b = bg[2] as u32;
160 for px in img.pixels.chunks_exact_mut(4) {
161 let a = px[3] as u32;
162 if a == 255 {
163 continue;
164 }
165 let inv = 255 - a;
166 let r = (px[0] as u32 * a + bg_r * inv + 127) / 255;
167 let g = (px[1] as u32 * a + bg_g * inv + 127) / 255;
168 let b = (px[2] as u32 * a + bg_b * inv + 127) / 255;
169 px[0] = r as u8;
170 px[1] = g as u8;
171 px[2] = b as u8;
172 px[3] = 255;
173 }
174}
175
176fn drop_alpha(img: &mut RgbaImage) {
177 if img.is_rgb() {
178 return;
179 }
180 let w = img.width as usize;
181 let h = img.height as usize;
182 let mut out = Vec::with_capacity(w * h * 3);
183 for px in img.pixels.chunks_exact(4) {
184 out.extend_from_slice(&px[..3]);
185 }
186 img.pixels = out;
187 img.stride = w * 3;
188}
189
190fn set_alpha(img: &mut RgbaImage, value: u8) {
191 if img.is_rgb() {
192 return;
193 }
194 for px in img.pixels.chunks_exact_mut(4) {
195 px[3] = value;
196 }
197}
198
199fn encode_png(img: &RgbaImage) -> Result<Vec<u8>> {
200 use oxideav_png::{encode_png_image, PngImage, PngPixelFormat};
201 let pf = if img.is_rgb() {
202 PngPixelFormat::Rgb24
203 } else {
204 PngPixelFormat::Rgba
205 };
206 let png = PngImage {
207 width: img.width,
208 height: img.height,
209 pixel_format: pf,
210 stride: img.stride,
211 data: img.pixels.clone(),
212 palette: Vec::new(),
213 };
214 encode_png_image(&png).map_err(|e| Error::invalid(format!("convert: PNG encode failed: {e:?}")))
215}
216
217fn encode_bmp(img: &RgbaImage) -> Result<Vec<u8>> {
218 use oxideav_bmp::{encode_bmp as bmp_encode, BmpImage, BmpPixelFormat, BmpPlane};
219 let pf = if img.is_rgb() {
220 BmpPixelFormat::Rgb24
221 } else {
222 BmpPixelFormat::Rgba
223 };
224 let bmp = BmpImage {
225 width: img.width,
226 height: img.height,
227 pixel_format: pf,
228 planes: vec![BmpPlane {
229 stride: img.stride,
230 data: img.pixels.clone(),
231 }],
232 palette: None,
233 pts: None,
234 };
235 bmp_encode(&bmp)
236 .map(|(bytes, _format)| bytes)
237 .map_err(|e| Error::invalid(format!("convert: BMP encode failed: {e:?}")))
238}
239
240fn encode_webp(_img: &RgbaImage) -> Result<Vec<u8>> {
241 Err(Error::invalid(
247 "convert: WebP encode pending oxideav-webp clean-room VP8L encoder",
248 ))
249}
250
251fn encode_jpeg(img: &RgbaImage, ops: &[Op]) -> Result<Vec<u8>> {
252 use oxideav_mjpeg::encoder::encode_jpeg as jpeg_encode;
253 let quality = last_quality(ops).unwrap_or(85);
254
255 let src_format = if img.is_rgb() {
256 PixelFormat::Rgb24
257 } else {
258 PixelFormat::Rgba
259 };
260 let src_frame = VideoFrame {
261 pts: None,
262 planes: vec![VideoPlane {
263 stride: img.stride,
264 data: img.pixels.clone(),
265 }],
266 };
267 let info = FrameInfo::new(src_format, img.width, img.height);
268 let yuv = pix_convert(
269 &src_frame,
270 info,
271 PixelFormat::Yuv420P,
272 &ConvertOptions::default(),
273 )
274 .map_err(|e| Error::invalid(format!("convert: RGB→YUV420P conversion failed: {e:?}")))?;
275 jpeg_encode(&yuv, img.width, img.height, PixelFormat::Yuv420P, quality)
276 .map_err(|e| Error::invalid(format!("convert: JPEG encode failed: {e:?}")))
277}
278
279#[cfg(test)]
280mod tests {
281 use super::*;
282
283 #[test]
284 fn classify_output_covers_known_targets() {
285 assert!(matches!(classify_output("out.pdf"), Ok(OutputClass::Scene)));
286 assert!(matches!(
287 classify_output("out.svg"),
288 Ok(OutputClass::Vector)
289 ));
290 assert!(matches!(
291 classify_output("out.png"),
292 Ok(OutputClass::Raster(RasterFormat::Png))
293 ));
294 assert!(matches!(
295 classify_output("out.jpg"),
296 Ok(OutputClass::Raster(RasterFormat::Jpeg))
297 ));
298 assert!(matches!(
299 classify_output("out.jpeg"),
300 Ok(OutputClass::Raster(RasterFormat::Jpeg))
301 ));
302 assert!(matches!(
303 classify_output("out.bmp"),
304 Ok(OutputClass::Raster(RasterFormat::Bmp))
305 ));
306 assert!(matches!(
307 classify_output("out.webp"),
308 Ok(OutputClass::Raster(RasterFormat::Webp))
309 ));
310 assert!(classify_output("out.tiff").is_err());
311 assert!(classify_output("out.gif").is_err());
312 assert!(classify_output("noext").is_err());
313 }
314
315 #[test]
316 fn drop_alpha_shrinks_buffer_three_quarters() {
317 let mut img = RgbaImage {
318 width: 2,
319 height: 2,
320 pixels: vec![
321 10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120, 130, 140, 150, 160,
322 ],
323 stride: 8,
324 };
325 drop_alpha(&mut img);
326 assert_eq!(img.stride, 6);
327 assert_eq!(img.pixels.len(), 12);
328 assert_eq!(img.pixels[..3], [10, 20, 30]);
329 assert_eq!(img.pixels[3..6], [50, 60, 70]);
330 }
331
332 #[test]
333 fn flatten_alpha_over_white_preserves_opaque_pixels() {
334 let mut img = RgbaImage {
335 width: 2,
336 height: 1,
337 pixels: vec![255, 0, 0, 255, 0, 0, 0, 0],
338 stride: 8,
339 };
340 flatten_alpha_over(&mut img, [255, 255, 255, 255]);
341 assert_eq!(img.pixels[..4], [255, 0, 0, 255]);
342 assert_eq!(img.pixels[4..], [255, 255, 255, 255]);
343 }
344
345 #[test]
346 fn set_alpha_to_zero_leaves_colour_intact() {
347 let mut img = RgbaImage {
348 width: 1,
349 height: 1,
350 pixels: vec![10, 20, 30, 100],
351 stride: 4,
352 };
353 set_alpha(&mut img, 0);
354 assert_eq!(img.pixels, [10, 20, 30, 0]);
355 }
356}