Skip to main content

nika_media/tools/
convert.rs

1//! nika:convert — Format conversion (PNG↔JPEG↔WebP).
2//!
3//! Transparent PNG → JPEG composites on white background.
4
5use std::future::Future;
6use std::pin::Pin;
7
8use super::context::MediaToolContext;
9use super::error::MediaToolError;
10use super::error::{invalid_args, tool_error, unsupported_format};
11use super::safety::{composite_on_white, decode_image_safe};
12use super::{MediaOp, MediaOpResult};
13
14pub struct ConvertOp;
15
16impl MediaOp for ConvertOp {
17    fn name(&self) -> &'static str {
18        "convert"
19    }
20
21    fn description(&self) -> &'static str {
22        "Convert image between formats (PNG, JPEG, WebP)"
23    }
24
25    fn parameters_schema(&self) -> serde_json::Value {
26        serde_json::json!({
27          "type": "object",
28          "properties": {
29            "hash": { "type": "string", "description": "CAS hash of the source image" },
30            "format": { "type": "string", "enum": ["png", "jpeg", "webp"], "description": "Target format" },
31            "quality": { "type": "integer", "description": "JPEG quality (1-100, default 85)", "default": 85 }
32          },
33          "required": ["hash", "format"],
34          "additionalProperties": false
35        })
36    }
37
38    fn execute<'a>(
39        &'a self,
40        args: serde_json::Value,
41        ctx: &'a MediaToolContext,
42    ) -> Pin<Box<dyn Future<Output = Result<MediaOpResult, MediaToolError>> + Send + 'a>> {
43        Box::pin(async move {
44            ctx.check_cancelled()?;
45            let hash = args
46                .get("hash")
47                .and_then(|v| v.as_str())
48                .ok_or_else(|| invalid_args("convert", "missing 'hash'"))?;
49            let format = args
50                .get("format")
51                .and_then(|v| v.as_str())
52                .ok_or_else(|| invalid_args("convert", "missing 'format'"))?;
53            let quality = args
54                .get("quality")
55                .and_then(|v| v.as_u64())
56                .unwrap_or(85)
57                .clamp(1, 100) as u8;
58
59            let data = ctx.read_media(hash).await?;
60            let format_owned = format.to_string();
61
62            let output = ctx
63                .compute
64                .compute(
65                    move || -> Result<(Vec<u8>, String, String), MediaToolError> {
66                        let img = decode_image_safe(&data)?;
67                        let (w, h) = (img.width(), img.height());
68                        let mut buf = Vec::new();
69
70                        let (mime, ext) = match format_owned.as_str() {
71                            "jpeg" | "jpg" => {
72                                // SAFETY: to_rgb8() silently drops alpha — RGBA(255,0,0,0) becomes
73                                // RGB(255,0,0). We must composite on white before JPEG encoding.
74                                let rgb = composite_on_white(&img);
75                                let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(
76                                    &mut buf, quality,
77                                );
78                                image::ImageEncoder::write_image(
79                                    encoder,
80                                    rgb.as_raw(),
81                                    w,
82                                    h,
83                                    image::ExtendedColorType::Rgb8,
84                                )
85                                .map_err(|e| tool_error("convert", format!("JPEG encode: {e}")))?;
86                                ("image/jpeg", "jpg")
87                            }
88                            "webp" => {
89                                img.write_to(
90                                    &mut std::io::Cursor::new(&mut buf),
91                                    image::ImageFormat::WebP,
92                                )
93                                .map_err(|e| tool_error("convert", format!("WebP encode: {e}")))?;
94                                ("image/webp", "webp")
95                            }
96                            "png" => {
97                                let rgba = img.to_rgba8();
98                                let encoder = image::codecs::png::PngEncoder::new(&mut buf);
99                                image::ImageEncoder::write_image(
100                                    encoder,
101                                    rgba.as_raw(),
102                                    w,
103                                    h,
104                                    image::ExtendedColorType::Rgba8,
105                                )
106                                .map_err(|e| tool_error("convert", format!("PNG encode: {e}")))?;
107                                ("image/png", "png")
108                            }
109                            other => return Err(unsupported_format("convert", other)),
110                        };
111
112                        Ok((buf, mime.to_string(), ext.to_string()))
113                    },
114                )
115                .await??;
116
117            let (buf, mime_type, extension) = output;
118
119            Ok(MediaOpResult::Binary {
120                data: buf,
121                mime_type,
122                extension,
123                metadata: serde_json::json!({
124                  "converted_to": format,
125                }),
126            })
127        })
128    }
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134    use crate::CasStore;
135    use std::sync::Arc;
136
137    async fn setup() -> (tempfile::TempDir, Arc<MediaToolContext>) {
138        let dir = tempfile::tempdir().unwrap();
139        let ctx = Arc::new(MediaToolContext::new(CasStore::new(dir.path())).unwrap());
140        (dir, ctx)
141    }
142
143    fn fixture_png() -> Vec<u8> {
144        use image::{ImageBuffer, Rgba};
145        let img = ImageBuffer::from_pixel(10, 10, Rgba([255u8, 0, 0, 128]));
146        let mut buf = Vec::new();
147        let encoder = image::codecs::png::PngEncoder::new(&mut buf);
148        image::ImageEncoder::write_image(
149            encoder,
150            img.as_raw(),
151            10,
152            10,
153            image::ExtendedColorType::Rgba8,
154        )
155        .unwrap();
156        buf
157    }
158
159    #[tokio::test]
160    async fn convert_png_to_jpeg() {
161        let (_dir, ctx) = setup().await;
162        let png = fixture_png();
163        let sr = ctx.cas.store(&png).await.unwrap();
164
165        let op = ConvertOp;
166        let result = op
167            .execute(
168                serde_json::json!({
169                  "hash": sr.hash, "format": "jpeg"
170                }),
171                &ctx,
172            )
173            .await
174            .unwrap();
175
176        if let MediaOpResult::Binary {
177            data, mime_type, ..
178        } = result
179        {
180            assert_eq!(mime_type, "image/jpeg");
181            assert_eq!(&data[..2], &[0xFF, 0xD8]); // JPEG magic
182        }
183    }
184
185    #[tokio::test]
186    async fn convert_png_to_jpeg_decodable() {
187        let (_dir, ctx) = setup().await;
188        let png = fixture_png();
189        let sr = ctx.cas.store(&png).await.unwrap();
190
191        let op = ConvertOp;
192        let result = op
193            .execute(
194                serde_json::json!({
195                  "hash": sr.hash, "format": "jpeg"
196                }),
197                &ctx,
198            )
199            .await
200            .unwrap();
201
202        if let MediaOpResult::Binary { data, .. } = result {
203            let img = image::load_from_memory(&data).expect("JPEG output must be decodable");
204            assert_eq!(img.width(), 10);
205            assert_eq!(img.height(), 10);
206        }
207    }
208
209    #[tokio::test]
210    async fn convert_jpeg_to_png() {
211        let (_dir, ctx) = setup().await;
212        let png = fixture_png();
213        let sr = ctx.cas.store(&png).await.unwrap();
214
215        // First convert to JPEG
216        let op = ConvertOp;
217        let jpeg_result = op
218            .execute(
219                serde_json::json!({
220                  "hash": sr.hash, "format": "jpeg"
221                }),
222                &ctx,
223            )
224            .await
225            .unwrap();
226
227        let jpeg_hash = if let MediaOpResult::Binary { data, .. } = &jpeg_result {
228            ctx.cas.store(data).await.unwrap().hash
229        } else {
230            panic!("expected Binary");
231        };
232
233        // Then convert back to PNG
234        let png_result = op
235            .execute(
236                serde_json::json!({
237                  "hash": jpeg_hash, "format": "png"
238                }),
239                &ctx,
240            )
241            .await
242            .unwrap();
243
244        if let MediaOpResult::Binary {
245            data, mime_type, ..
246        } = png_result
247        {
248            assert_eq!(mime_type, "image/png");
249            assert_eq!(&data[..4], &[137, 80, 78, 71]); // PNG magic
250            let img = image::load_from_memory(&data).expect("PNG must be decodable");
251            assert_eq!(img.width(), 10);
252        }
253    }
254
255    #[tokio::test]
256    async fn convert_corrupt_input_no_panic() {
257        let (_dir, ctx) = setup().await;
258        for i in 1..30u8 {
259            let data: Vec<u8> = (0..=i).collect();
260            if let Ok(sr) = ctx.cas.store(&data).await {
261                let op = ConvertOp;
262                let _ = op
263                    .execute(serde_json::json!({"hash": sr.hash, "format": "png"}), &ctx)
264                    .await;
265            }
266        }
267    }
268
269    #[tokio::test]
270    async fn convert_transparent_png_to_jpeg_white_background() {
271        // CRITICAL: transparent PNG → JPEG must composite on white, not just drop alpha
272        let (_dir, ctx) = setup().await;
273        // Fully transparent red pixel: RGBA(255, 0, 0, 0)
274        let img = image::ImageBuffer::from_pixel(10, 10, image::Rgba([255u8, 0, 0, 0]));
275        let mut buf = Vec::new();
276        let enc = image::codecs::png::PngEncoder::new(&mut buf);
277        image::ImageEncoder::write_image(
278            enc,
279            img.as_raw(),
280            10,
281            10,
282            image::ExtendedColorType::Rgba8,
283        )
284        .unwrap();
285        let sr = ctx.cas.store(&buf).await.unwrap();
286
287        let op = ConvertOp;
288        let result = op
289            .execute(
290                serde_json::json!({
291                  "hash": sr.hash, "format": "jpeg"
292                }),
293                &ctx,
294            )
295            .await
296            .unwrap();
297
298        if let MediaOpResult::Binary { data, .. } = result {
299            let output = image::load_from_memory(&data).unwrap().to_rgb8();
300            let pixel = output.get_pixel(5, 5);
301            // Fully transparent → should be white (255,255,255), NOT red (255,0,0)
302            assert!(pixel[0] > 250, "R should be ~255 (white), got {}", pixel[0]);
303            assert!(pixel[1] > 250, "G should be ~255 (white), got {}", pixel[1]);
304            assert!(pixel[2] > 250, "B should be ~255 (white), got {}", pixel[2]);
305        }
306    }
307
308    #[tokio::test]
309    async fn convert_semitransparent_png_to_jpeg_blends() {
310        // Semi-transparent red RGBA(255,0,0,128) on white → ~RGB(255,128,128)
311        let (_dir, ctx) = setup().await;
312        let png = fixture_png(); // Uses RGBA(255,0,0,128)
313        let sr = ctx.cas.store(&png).await.unwrap();
314
315        let op = ConvertOp;
316        let result = op
317            .execute(
318                serde_json::json!({
319                  "hash": sr.hash, "format": "jpeg"
320                }),
321                &ctx,
322            )
323            .await
324            .unwrap();
325
326        if let MediaOpResult::Binary { data, .. } = result {
327            let output = image::load_from_memory(&data).unwrap().to_rgb8();
328            let pixel = output.get_pixel(5, 5);
329            // 50% alpha red on white → approximately (255, 128, 128) ± JPEG compression
330            assert!(pixel[0] > 200, "R should be high (~255), got {}", pixel[0]);
331            assert!(
332                pixel[1] > 90 && pixel[1] < 180,
333                "G should be ~128, got {}",
334                pixel[1]
335            );
336            assert!(
337                pixel[2] > 90 && pixel[2] < 180,
338                "B should be ~128, got {}",
339                pixel[2]
340            );
341        }
342    }
343
344    #[tokio::test]
345    async fn convert_missing_format() {
346        let (_dir, ctx) = setup().await;
347        let png = fixture_png();
348        let sr = ctx.cas.store(&png).await.unwrap();
349
350        let op = ConvertOp;
351        let result = op.execute(serde_json::json!({"hash": sr.hash}), &ctx).await;
352        assert!(result.is_err());
353        assert!(result.unwrap_err().to_string().contains("NIKA-294"));
354    }
355}