use crate::ContentError;
use crate::encode::{ImageSpec, MipmapLevel, bytes_to_hex};
use image::{DynamicImage, GenericImageView, codecs::jpeg::JpegEncoder, imageops::FilterType};
use sha2::{Digest, Sha256};
use std::io::Cursor;
use std::path::Path;
const JPEG_QUALITY: u8 = 82;
const MIN_LEVEL_DIMENSION: u32 = 64;
fn level_dimensions(original: u32, level: u32) -> u32 {
let scale = 2_u32.pow(level);
#[allow(
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
clippy::cast_sign_loss
)]
{
((original as f32) / (scale as f32)).round().max(1.0) as u32
}
}
fn plan_levels(width: u32, height: u32) -> Vec<(u32, u32)> {
let mut plan = Vec::new();
let mut level = 0_u32;
loop {
let (w, h) = (
level_dimensions(width, level),
level_dimensions(height, level),
);
plan.push((w, h));
if w <= MIN_LEVEL_DIMENSION || h <= MIN_LEVEL_DIMENSION {
break;
}
level += 1;
}
plan
}
pub(crate) fn encode_as_jpeg(image: &DynamicImage) -> Result<Vec<u8>, ContentError> {
let mut bytes = Vec::new();
let mut cursor = Cursor::new(&mut bytes);
JpegEncoder::new_with_quality(&mut cursor, JPEG_QUALITY)
.encode_image(image)
.map_err(|e| ContentError::Image(format!("failed to encode JPEG level: {e}")))?;
Ok(bytes)
}
fn build_levels(
image: &DynamicImage,
mut upload: impl FnMut(&[u8]) -> Result<String, ContentError>,
) -> Result<Vec<MipmapLevel>, ContentError> {
let (width, height) = image.dimensions();
let mut levels = Vec::new();
for (level, (out_width, out_height)) in plan_levels(width, height).into_iter().enumerate() {
let resized = if level == 0 {
image.clone()
} else {
image.resize_exact(out_width, out_height, FilterType::Lanczos3)
};
let jpeg_bytes = encode_as_jpeg(&resized)?;
let cid = upload(&jpeg_bytes)?;
levels.push(MipmapLevel {
filesize: jpeg_bytes.len() as u64,
cid,
});
}
Ok(levels)
}
pub fn build_image_spec(
path: &Path,
filename_override: Option<&str>,
) -> Result<ImageSpec, ContentError> {
let bytes = std::fs::read(path).map_err(|e| {
ContentError::Image(format!("failed to read image {}: {e}", path.display()))
})?;
let image = image::load_from_memory(&bytes).map_err(|e| {
ContentError::Image(format!("failed to decode image {}: {e}", path.display()))
})?;
let (width, height) = image.dimensions();
let filename = filename_override.map_or_else(|| fallback_filename(path), str::to_owned);
let upload = |jpeg_bytes: &[u8]| -> Result<String, ContentError> {
let digest_hex = crate::ipfs::add(jpeg_bytes, &filename)?;
crate::encode::digest_hex_to_cid(&digest_hex)
};
let mipmap_levels = build_levels(&image, upload)?;
let mut hasher = Sha256::new();
hasher.update(&bytes);
let digest: [u8; 32] = hasher.finalize().into();
Ok(ImageSpec {
filename,
filesize: bytes.len() as u64,
digest_hex: bytes_to_hex(&digest),
width,
height,
mipmap_levels,
})
}
fn fallback_filename(path: &Path) -> String {
path.file_name()
.map_or_else(|| "image".to_string(), |n| n.to_string_lossy().into_owned())
}
#[cfg(test)]
mod tests {
use super::*;
use image::{ImageBuffer, Rgba};
fn test_image() -> DynamicImage {
DynamicImage::ImageRgba8(ImageBuffer::from_fn(300, 200, |x, y| {
#[allow(clippy::cast_possible_truncation)]
Rgba([(x % 256) as u8, (y % 256) as u8, 128, 255])
}))
}
#[test]
fn level_dimensions_round_and_clamp_to_one() {
assert_eq!(level_dimensions(1012, 0), 1012);
assert_eq!(level_dimensions(1012, 1), 506);
assert_eq!(level_dimensions(5, 2), 1);
assert_eq!(level_dimensions(1, 10), 1);
}
#[test]
fn plan_levels_halves_until_min_dimension() {
let plan = plan_levels(1012, 1012);
assert_eq!(
plan,
vec![(1012, 1012), (506, 506), (253, 253), (127, 127), (63, 63)]
);
assert_eq!(plan_levels(64, 64), vec![(64, 64)]);
let wide = plan_levels(300, 200);
assert_eq!(wide, vec![(300, 200), (150, 100), (75, 50)]);
}
#[test]
fn encode_as_jpeg_produces_decodable_jpeg_at_dimensions() {
let jpeg = encode_as_jpeg(&test_image()).unwrap();
let decoded = image::load_from_memory(&jpeg).unwrap();
assert_eq!(
image::guess_format(&jpeg).unwrap(),
image::ImageFormat::Jpeg
);
assert_eq!(decoded.dimensions(), (300, 200));
}
#[test]
fn build_levels_uploads_every_level_and_records_sizes() {
let image = test_image();
let mut calls = 0;
let levels = build_levels(&image, |bytes| {
calls += 1;
assert_ne!(bytes, &[] as &[u8]);
Ok(format!("QmFake{calls}"))
})
.unwrap();
assert_eq!(levels.len(), 3);
assert_eq!(levels[0].cid, "QmFake1");
assert_eq!(levels[2].cid, "QmFake3");
assert!(levels[0].filesize > levels[1].filesize);
assert!(levels[1].filesize > levels[2].filesize);
assert_eq!(calls, 3);
}
#[test]
fn fallback_filename_handles_rootless_paths() {
assert_eq!(fallback_filename(Path::new("/tmp/pic.png")), "pic.png");
assert_eq!(fallback_filename(Path::new("/")), "image");
}
#[test]
fn build_image_spec_reports_missing_files() {
let err = build_image_spec(Path::new("/nonexistent/img.png"), None).unwrap_err();
assert!(err.to_string().contains("failed to read image"));
}
#[test]
fn build_image_spec_rejects_non_image_bytes() {
let dir = std::env::temp_dir().join(format!("choreo-content-image-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("not-an-image.bin");
std::fs::write(&path, b"definitely not an image").unwrap();
let err = build_image_spec(&path, None).unwrap_err();
assert!(err.to_string().contains("failed to decode image"));
std::fs::remove_dir_all(&dir).unwrap();
}
}