1use 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 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]); }
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 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 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]); 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 let (_dir, ctx) = setup().await;
273 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 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 let (_dir, ctx) = setup().await;
312 let png = fixture_png(); 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 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}