use std::future::Future;
use std::pin::Pin;
use super::context::MediaToolContext;
use super::error::{invalid_args, tool_error};
use super::safety::{composite_on_white, decode_image_safe};
use super::{MediaOp, MediaOpResult};
use crate::error::NikaError;
pub struct StripOp;
impl MediaOp for StripOp {
fn name(&self) -> &'static str {
"strip"
}
fn description(&self) -> &'static str {
"Remove all metadata (EXIF, GPS, camera info) from an image"
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"hash": { "type": "string", "description": "CAS hash of the image" },
"format": { "type": "string", "enum": ["png", "jpeg"], "description": "Output format (default: same as input)" }
},
"required": ["hash"],
"additionalProperties": false
})
}
fn execute<'a>(
&'a self,
args: serde_json::Value,
ctx: &'a MediaToolContext,
) -> Pin<Box<dyn Future<Output = Result<MediaOpResult, NikaError>> + Send + 'a>> {
Box::pin(async move {
ctx.check_cancelled()?;
let hash = args
.get("hash")
.and_then(|v| v.as_str())
.ok_or_else(|| invalid_args("strip", "missing 'hash'"))?;
let format_override = args
.get("format")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let data = ctx.read_media(hash).await?;
let output = ctx
.compute
.compute(move || -> Result<(Vec<u8>, String, String), NikaError> {
let img = decode_image_safe(&data)?;
let (w, h) = (img.width(), img.height());
let format = format_override.as_deref().unwrap_or_else(|| {
if data.len() >= 2 && data[0] == 0xFF && data[1] == 0xD8 {
"jpeg"
} else {
"png"
}
});
let mut buf = Vec::new();
let (mime, ext) = match format {
"jpeg" | "jpg" => {
let rgb = composite_on_white(&img);
let encoder =
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut buf, 100);
image::ImageEncoder::write_image(
encoder,
rgb.as_raw(),
w,
h,
image::ExtendedColorType::Rgb8,
)
.map_err(|e| tool_error("strip", format!("encode: {e}")))?;
("image/jpeg", "jpg")
}
_ => {
let rgba = img.to_rgba8();
let encoder = image::codecs::png::PngEncoder::new(&mut buf);
image::ImageEncoder::write_image(
encoder,
rgba.as_raw(),
w,
h,
image::ExtendedColorType::Rgba8,
)
.map_err(|e| tool_error("strip", format!("encode: {e}")))?;
("image/png", "png")
}
};
Ok((buf, mime.to_string(), ext.to_string()))
})
.await??;
let (buf, mime_type, extension) = output;
Ok(MediaOpResult::Binary {
data: buf,
mime_type,
extension,
metadata: serde_json::json!({
"stripped": true,
}),
})
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::media::CasStore;
use std::sync::Arc;
async fn setup() -> (tempfile::TempDir, Arc<MediaToolContext>) {
let dir = tempfile::tempdir().unwrap();
let ctx = Arc::new(MediaToolContext::new(CasStore::new(dir.path())));
(dir, ctx)
}
fn fixture_png() -> Vec<u8> {
use image::{ImageBuffer, Rgba};
let img = ImageBuffer::from_pixel(5, 5, Rgba([100u8, 200, 50, 255]));
let mut buf = Vec::new();
let encoder = image::codecs::png::PngEncoder::new(&mut buf);
image::ImageEncoder::write_image(
encoder,
img.as_raw(),
5,
5,
image::ExtendedColorType::Rgba8,
)
.unwrap();
buf
}
#[tokio::test]
async fn strip_png_produces_output() {
let (_dir, ctx) = setup().await;
let png = fixture_png();
let sr = ctx.cas.store(&png).await.unwrap();
let op = StripOp;
let result = op
.execute(serde_json::json!({"hash": sr.hash}), &ctx)
.await
.unwrap();
if let MediaOpResult::Binary {
data,
mime_type,
metadata,
..
} = result
{
assert_eq!(mime_type, "image/png");
assert!(!data.is_empty());
assert_eq!(metadata["stripped"], true);
}
}
#[tokio::test]
async fn strip_to_jpeg() {
let (_dir, ctx) = setup().await;
let png = fixture_png();
let sr = ctx.cas.store(&png).await.unwrap();
let op = StripOp;
let result = op
.execute(
serde_json::json!({
"hash": sr.hash, "format": "jpeg"
}),
&ctx,
)
.await
.unwrap();
if let MediaOpResult::Binary {
data, mime_type, ..
} = result
{
assert_eq!(mime_type, "image/jpeg");
assert_eq!(&data[..2], &[0xFF, 0xD8]);
}
}
}