1use std::future::Future;
7use std::pin::Pin;
8
9use super::context::MediaToolContext;
10use super::error::MediaToolError;
11use super::error::{invalid_args, tool_error, unsupported_format};
12use super::{MediaOp, MediaOpResult};
13
14pub struct OptimizeOp;
15
16impl MediaOp for OptimizeOp {
17 fn name(&self) -> &'static str {
18 "optimize"
19 }
20
21 fn description(&self) -> &'static str {
22 "Lossless PNG optimization (reduce file size without quality loss)"
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 PNG image" },
30 "level": { "type": "integer", "description": "Optimization level (1-6, default 2)", "minimum": 1, "maximum": 6, "default": 2 },
31 "strip": { "type": "boolean", "description": "Strip non-essential metadata chunks", "default": true }
32 },
33 "required": ["hash"],
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("optimize", "missing 'hash'"))?;
49 let level = args
50 .get("level")
51 .and_then(|v| v.as_u64())
52 .unwrap_or(2)
53 .clamp(1, 6) as u8;
54 let strip = args.get("strip").and_then(|v| v.as_bool()).unwrap_or(true);
55
56 let data = ctx.read_media(hash).await?;
57
58 if data.len() < 8 || data[..4] != [137, 80, 78, 71] {
60 return Err(unsupported_format("optimize", "input is not a PNG image"));
61 }
62
63 let original_size = data.len();
64
65 let optimized = ctx
66 .compute
67 .compute(move || -> Result<Vec<u8>, MediaToolError> {
68 let mut opts = oxipng::Options::from_preset(level);
69 if strip {
70 opts.strip = oxipng::StripChunks::Safe;
71 }
72 oxipng::optimize_from_memory(&data, &opts)
73 .map_err(|e| tool_error("optimize", format!("optimization failed: {e}")))
74 })
75 .await??;
76
77 let optimized_size = optimized.len();
78 let savings_pct = if original_size > 0 {
79 ((original_size as f64 - optimized_size as f64) / original_size as f64 * 100.0)
80 .max(0.0)
81 } else {
82 0.0
83 };
84
85 Ok(MediaOpResult::Binary {
86 data: optimized,
87 mime_type: "image/png".to_string(),
88 extension: "png".to_string(),
89 metadata: serde_json::json!({
90 "original_size": original_size,
91 "optimized_size": optimized_size,
92 "savings_pct": (savings_pct * 10.0).round() / 10.0,
93 "level": level,
94 }),
95 })
96 })
97 }
98}
99
100#[cfg(test)]
101mod tests {
102 use super::*;
103 use crate::CasStore;
104 use std::sync::Arc;
105
106 async fn setup() -> (tempfile::TempDir, Arc<MediaToolContext>) {
107 let dir = tempfile::tempdir().unwrap();
108 let ctx = Arc::new(MediaToolContext::new(CasStore::new(dir.path())).unwrap());
109 (dir, ctx)
110 }
111
112 fn fixture_png() -> Vec<u8> {
113 use image::{ImageBuffer, Rgb};
114 let img = ImageBuffer::from_fn(50, 50, |x, y| {
115 Rgb([(x * 5 % 256) as u8, (y * 5 % 256) as u8, 128])
116 });
117 let mut buf = Vec::new();
118 let encoder = image::codecs::png::PngEncoder::new(&mut buf);
119 image::ImageEncoder::write_image(
120 encoder,
121 img.as_raw(),
122 50,
123 50,
124 image::ExtendedColorType::Rgb8,
125 )
126 .unwrap();
127 buf
128 }
129
130 #[tokio::test]
131 async fn optimize_png_output_valid() {
132 let (_dir, ctx) = setup().await;
133 let png = fixture_png();
134 let sr = ctx.cas.store(&png).await.unwrap();
135
136 let op = OptimizeOp;
137 let result = op
138 .execute(serde_json::json!({"hash": sr.hash}), &ctx)
139 .await
140 .unwrap();
141
142 if let MediaOpResult::Binary {
143 data,
144 mime_type,
145 metadata,
146 ..
147 } = result
148 {
149 assert_eq!(mime_type, "image/png");
150 assert_eq!(&data[..4], &[137, 80, 78, 71]);
152 assert!(metadata["original_size"].as_u64().unwrap() > 0);
153 assert!(metadata["optimized_size"].as_u64().unwrap() > 0);
154 }
155 }
156
157 #[tokio::test]
158 async fn optimize_jpeg_rejected() {
159 let (_dir, ctx) = setup().await;
160 let jpeg = vec![
162 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01,
163 ];
164 let sr = ctx.cas.store(&jpeg).await.unwrap();
165
166 let op = OptimizeOp;
167 let result = op.execute(serde_json::json!({"hash": sr.hash}), &ctx).await;
168 assert!(result.is_err());
169 assert!(result.unwrap_err().to_string().contains("NIKA-291"));
170 }
171
172 #[tokio::test]
173 async fn optimize_corrupt_png_no_panic() {
174 let (_dir, ctx) = setup().await;
175 let data = vec![137, 80, 78, 71, 13, 10, 26, 10, 0xFF, 0xFE, 0xFD];
177 let sr = ctx.cas.store(&data).await.unwrap();
178
179 let op = OptimizeOp;
180 let result = op.execute(serde_json::json!({"hash": sr.hash}), &ctx).await;
181 assert!(result.is_err());
183 }
184
185 #[tokio::test]
186 async fn optimize_output_is_decodable_png() {
187 let (_dir, ctx) = setup().await;
188 let png = fixture_png();
189 let sr = ctx.cas.store(&png).await.unwrap();
190
191 let op = OptimizeOp;
192 let result = op
193 .execute(serde_json::json!({"hash": sr.hash}), &ctx)
194 .await
195 .unwrap();
196
197 if let MediaOpResult::Binary { data, metadata, .. } = result {
198 let img = image::load_from_memory(&data).expect("optimized output must be decodable");
200 assert_eq!(img.width(), 50);
201 assert_eq!(img.height(), 50);
202 assert!(
204 metadata["savings_pct"].is_f64() || metadata["savings_pct"].is_u64(),
205 "savings_pct should be numeric, got: {:?}",
206 metadata["savings_pct"]
207 );
208 }
209 }
210
211 #[tokio::test]
212 async fn optimize_preserves_dimensions() {
213 let (_dir, ctx) = setup().await;
214 let png = fixture_png();
215 let original_size = png.len();
216 let sr = ctx.cas.store(&png).await.unwrap();
217
218 let op = OptimizeOp;
219 let result = op
220 .execute(serde_json::json!({"hash": sr.hash, "level": 2}), &ctx)
221 .await
222 .unwrap();
223
224 if let MediaOpResult::Binary { data, metadata, .. } = result {
225 assert!(
226 data.len() <= original_size,
227 "optimized should not be larger than original"
228 );
229 assert_eq!(
230 metadata["original_size"].as_u64().unwrap(),
231 original_size as u64
232 );
233 }
234 }
235}