Skip to main content

nika_media/tools/
optimize.rs

1//! nika:optimize — Lossless PNG optimization.
2//!
3//! Uses `oxipng` with parallel decompression.
4//! Level 2 = default (100-500ms), level 6 = zopfli (up to 15s).
5
6use 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            // Verify it's a PNG
59            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            // Valid PNG magic bytes
151            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        // JPEG-like data (starts with FF D8)
161        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        // PNG magic + garbage
176        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        // Should error, not panic
182        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            // Verify output is a real decodable PNG
199            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            // Verify savings_pct is a number (not string)
203            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}