//! Export: encode a rendered image to JPEG / PNG / TIFF / WebP / AVIF with an embedded sRGB profile, //! optional output sharpening and a JPEG file-size limit. Pure (bytes in, bytes out) so the desktop //! app, CLI, MCP and the web build share it; writing the file is the caller's job. use lightcraft_codecs::{ChromaSubsampling, EncodeImage, EncodeMeta, NamedSpace, TiffCompression, encode, icc}; use lightcraft_meta::{DateTime, Gps, Metadata}; use lightcraft_raster::Rgba8; use serde::{Deserialize, Serialize}; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum ExportFormat { #[default] Jpeg, Png, Tiff, Webp, Avif, } impl ExportFormat { pub fn parse(s: &str) -> Option { Some(match s.to_ascii_lowercase().as_str() { "jpeg" | "jpg" => Self::Jpeg, "png" => Self::Png, "tiff" | "tif" => Self::Tiff, "webp" => Self::Webp, "avif" => Self::Avif, _ => return None, }) } pub fn extension(self) -> &'static str { match self { Self::Jpeg => "jpg", Self::Png => "png", Self::Tiff => "tif", Self::Webp => "webp", Self::Avif => "avif", } } } /// Output sharpening target (applied after resizing, in display-encoded values). #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum SharpenFor { #[default] None, Screen, Matte, Glossy, } #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum SharpenAmount { Low, #[default] Standard, High, } /// Where a watermark sits. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum Anchor { TopLeft, Top, TopRight, Left, Center, Right, BottomLeft, Bottom, #[default] BottomRight, } /// A text watermark, sized relative to the image so every export size looks the same. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct Watermark { pub text: String, /// Text height as a fraction of the image's short edge. pub size: f32, /// 0..1. pub opacity: f32, pub anchor: Anchor, /// Margin as a fraction of the short edge. pub inset: f32, /// sRGB colour. pub color: [u8; 3], /// Soft dark drop shadow for legibility on bright areas. pub shadow: bool, } impl Default for Watermark { fn default() -> Self { Self { text: String::new(), size: 0.035, opacity: 0.7, anchor: Anchor::BottomRight, inset: 0.025, color: [255; 3], shadow: true } } } /// Inter SemiBold (OFL, see assets/ATTRIBUTION.md). static WATERMARK_FONT: &[u8] = include_bytes!("../../../assets/fonts/Inter-SemiBold.ttf"); /// Draw `wm` onto `img` (straight sRGB alpha blending). pub fn draw_watermark(img: &mut Rgba8, wm: &Watermark) { use ab_glyph::{Font, FontRef, PxScale, ScaleFont, point}; let text = wm.text.trim(); if text.is_empty() || img.width == 0 || img.height == 0 { return; } let Ok(font) = FontRef::try_from_slice(WATERMARK_FONT) else { return }; let short = img.width.min(img.height) as f32; let px = (wm.size.clamp(0.005, 0.5) * short).max(6.0); let sf = font.as_scaled(PxScale::from(px)); // Lay out one line. let mut glyphs = Vec::new(); let mut x = 0.0f32; let mut prev = None; for ch in text.chars() { let id = sf.glyph_id(ch); if let Some(p) = prev { x += sf.kern(p, id); } glyphs.push(id.with_scale_and_position(px, point(x, sf.ascent()))); x += sf.h_advance(id); prev = Some(id); } let (tw, th) = (x, sf.ascent() - sf.descent()); let inset = wm.inset.clamp(0.0, 0.4) * short; let (w, h) = (img.width as f32, img.height as f32); use Anchor::*; let ox = match wm.anchor { TopLeft | Left | BottomLeft => inset, Top | Center | Bottom => (w - tw) / 2.0, TopRight | Right | BottomRight => w - inset - tw, }; let oy = match wm.anchor { TopLeft | Top | TopRight => inset, Left | Center | Right => (h - th) / 2.0, BottomLeft | Bottom | BottomRight => h - inset - th, }; let alpha = wm.opacity.clamp(0.0, 1.0); let mut blend = |gx: i32, gy: i32, cov: f32, col: [u8; 3], a: f32| { if gx < 0 || gy < 0 || gx >= img.width as i32 || gy >= img.height as i32 { return; } let k = (cov * a).clamp(0.0, 1.0); let p = &mut img.data[gy as usize * img.width + gx as usize]; for c in 0..3 { p[c] = (p[c] as f32 + (col[c] as f32 - p[c] as f32) * k).round() as u8; } }; let passes: &[(f32, [u8; 3], f32)] = if wm.shadow { &[((px * 0.05).max(1.0), [0, 0, 0], 0.45), (0.0, wm.color, 1.0)] } else { &[(0.0, wm.color, 1.0)] }; for &(off, col, a) in passes { for g in &glyphs { if let Some(o) = font.outline_glyph(g.clone()) { let b = o.px_bounds(); o.draw(|gx, gy, cov| blend((ox + off + b.min.x) as i32 + gx as i32, (oy + off + b.min.y) as i32 + gy as i32, cov, col, a * alpha)); } } } } /// Which metadata is embedded in exported files. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum MetadataPolicy { /// Everything we know: camera, capture settings, lens, location, title, caption, keywords, copyright. #[default] All, /// Everything except camera/lens make, model and capture settings. AllExceptCamera, /// Copyright and creator only. Copyright, /// Nothing (the sRGB profile is still embedded). None, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct ExportOptions { pub format: ExportFormat, /// 1–100 (JPEG, AVIF). pub quality: u8, /// Resize so the long edge is at most this many pixels (`None` = full size). pub long_edge: Option, /// JPEG only: largest quality whose file fits in this many KB. pub limit_kb: Option, pub sharpen: SharpenFor, pub sharpen_amount: SharpenAmount, /// File name template: `{name}` (original stem), `{seq}` (1-based, zero-padded to 3), `{ext}` is appended. pub naming: String, pub metadata: MetadataPolicy, /// Strip GPS / location even when the policy would include it. pub remove_location: bool, /// Text watermark (none when absent or the text is empty). pub watermark: Option, } impl Default for ExportOptions { fn default() -> Self { Self { format: ExportFormat::Jpeg, quality: 90, long_edge: None, limit_kb: None, sharpen: SharpenFor::None, sharpen_amount: SharpenAmount::Standard, naming: "{name}".into(), metadata: MetadataPolicy::All, remove_location: false, watermark: None, } } } impl ExportOptions { /// Read options from command params (`format`, `quality`, `longEdge`, `limitKb`, `sharpen`, `sharpenAmount`, `naming`). pub fn from_json(p: &serde_json::Value) -> Self { use serde_json::Value; let d = Self::default(); let s = |k: &str| p.get(k).and_then(Value::as_str); let u = |k: &str| p.get(k).and_then(Value::as_u64); fn enm(p: &Value, k: &str) -> Option { p.get(k).cloned().and_then(|v| serde_json::from_value(v).ok()) } Self { format: s("format").and_then(ExportFormat::parse).unwrap_or(d.format), quality: u("quality").map_or(d.quality, |q| q.clamp(1, 100) as u8), long_edge: u("longEdge").filter(|v| *v > 0).map(|v| v.min(65_535) as u32), limit_kb: u("limitKb").filter(|v| *v > 0).map(|v| v.min(u32::MAX as u64) as u32), sharpen: enm(p, "sharpen").unwrap_or(d.sharpen), sharpen_amount: enm(p, "sharpenAmount").unwrap_or(d.sharpen_amount), naming: s("naming").map_or(d.naming, str::to_string), metadata: enm(p, "metadata").unwrap_or(d.metadata), remove_location: p.get("removeLocation").and_then(Value::as_bool).unwrap_or(d.remove_location), watermark: match p.get("watermark") { Some(Value::String(t)) => Some(Watermark { text: t.clone(), ..Default::default() }), Some(v @ Value::Object(_)) => serde_json::from_value(v.clone()).ok(), _ => None, } .filter(|w: &Watermark| !w.text.trim().is_empty()), } } /// Output file name for photo `stem` at 1-based position `seq` in a batch. pub fn file_name(&self, stem: &str, seq: usize) -> String { let base = if self.naming.trim().is_empty() { "{name}" } else { self.naming.as_str() }; let name = base.replace("{name}", stem).replace("{seq}", &format!("{seq:03}")); let name: String = name.chars().map(|c| if matches!(c, '/' | '\\' | ':' | '\0') { '_' } else { c }).collect(); format!("{name}.{}", self.format.extension()) } } /// Unsharp mask on an 8-bit image (separable box-blur approximation of a small Gaussian). pub fn output_sharpen(img: &mut Rgba8, target: SharpenFor, amount: SharpenAmount) { let (radius, base) = match target { SharpenFor::None => return, SharpenFor::Screen => (1usize, 0.35f32), SharpenFor::Matte => (2, 0.6), SharpenFor::Glossy => (1, 0.5), }; let k = base * match amount { SharpenAmount::Low => 0.6, SharpenAmount::Standard => 1.0, SharpenAmount::High => 1.5, }; let (w, h) = (img.width, img.height); if w < 3 || h < 3 { return; } let src: Vec<[f32; 3]> = img.data.iter().map(|p| [p[0] as f32, p[1] as f32, p[2] as f32]).collect(); let r = radius as isize; let box_pass = |inp: &[[f32; 3]], horizontal: bool| -> Vec<[f32; 3]> { let mut out = vec![[0.0; 3]; inp.len()]; for y in 0..h { for x in 0..w { let mut acc = [0.0f32; 3]; for d in -r..=r { let (sx, sy) = if horizontal { ((x as isize + d).clamp(0, w as isize - 1) as usize, y) } else { (x, (y as isize + d).clamp(0, h as isize - 1) as usize) }; let p = inp[sy * w + sx]; acc = [acc[0] + p[0], acc[1] + p[1], acc[2] + p[2]]; } let n = (2 * r + 1) as f32; out[y * w + x] = acc.map(|v| v / n); } } out }; let blur = box_pass(&box_pass(&src, true), false); for (i, p) in img.data.iter_mut().enumerate() { for c in 0..3 { let v = src[i][c] + (src[i][c] - blur[i][c]) * k; p[c] = v.round().clamp(0.0, 255.0) as u8; } } } /// Encode a rendered (display-referred sRGB) image according to `o`. Resizing to `long_edge` is the /// caller's job (render at that size); sharpening is applied here. pub fn encode_image(img: &Rgba8, o: &ExportOptions) -> Result, String> { encode_with_metadata(img, o, None) } /// Like [`encode_image`], embedding `meta` (already filtered by the policy) as EXIF + XMP. pub fn encode_with_metadata(img: &Rgba8, o: &ExportOptions, meta: Option<&Metadata>) -> Result, String> { let mut img = img.clone(); output_sharpen(&mut img, o.sharpen, o.sharpen_amount); if let Some(wm) = &o.watermark { draw_watermark(&mut img, wm); } let profile = icc::write_named(NamedSpace::Srgb); let exif = meta.map(lightcraft_meta::write_exif); let xmp = meta.map(|m| lightcraft_meta::write_xmp(m, None)); let meta = EncodeMeta { icc: Some(&profile), exif: exif.as_deref(), xmp: xmp.as_deref() }; let e = EncodeImage::rgba8(&img); let r = match o.format { ExportFormat::Jpeg => { // 4:4:4 normally; 4:2:0 when targeting a file size (much smaller at equal visual quality). let sub = if o.limit_kb.is_some() { ChromaSubsampling::S420 } else { ChromaSubsampling::S444 }; let jpeg = |q: u8| encode::encode_jpeg(&e, q, sub, &meta); match o.limit_kb { Some(kb) => { let limit = kb as usize * 1024; // Binary search for the highest quality that fits. let (mut lo, mut hi) = (1u8, o.quality); let mut best = None; while lo <= hi { let mid = lo + (hi - lo) / 2; let b = jpeg(mid).map_err(|e| e.to_string())?; if b.len() <= limit { best = Some(b); lo = mid + 1; } else if mid == 1 { break; } else { hi = mid - 1; } } return best.ok_or_else(|| format!("cannot fit within {kb} KB")); } None => jpeg(o.quality), } } ExportFormat::Png => encode::encode_png(&e, &meta), ExportFormat::Tiff => encode::encode_tiff(&e, TiffCompression::Deflate, &meta), ExportFormat::Webp => encode::encode_webp_lossless(&e, &meta), ExportFormat::Avif => encode::encode_avif(&e, o.quality, 8, &meta), }; r.map_err(|e| e.to_string()) } /// Parse a shutter speed such as `1/250`, `0.5` or `2"` into seconds. fn parse_shutter(s: &str) -> Option { let s = s.trim().trim_end_matches(['s', '"']).trim(); match s.split_once('/') { Some((n, d)) => Some(n.trim().parse::().ok()? / d.trim().parse::().ok()?), None => s.parse().ok(), } .filter(|v: &f64| v.is_finite() && *v > 0.0) } /// The metadata to embed for `photo` under `o.metadata` / `o.remove_location`. `None` = embed nothing. pub fn export_metadata(photo: &lightcraft_catalog::Photo, o: &ExportOptions) -> Option { let m = &photo.meta; let text = |s: &str| (!s.trim().is_empty()).then(|| s.to_string()); let mut out = Metadata { copyright: text(&m.copyright), artist: text(&m.creator), software: Some("LightCraft".into()), ..Default::default() }; match o.metadata { MetadataPolicy::None => return None, MetadataPolicy::Copyright => return Some(out), MetadataPolicy::All | MetadataPolicy::AllExceptCamera => {} } out.title = text(&m.title); out.caption = text(&m.caption); out.keywords = m.keywords.clone(); out.capture_time = photo.captured.as_deref().and_then(DateTime::parse_iso); out.rating = (photo.rating > 0).then_some(photo.rating as i8); // Pixels are exported upright: orientation is baked in. out.orientation = Some(lightcraft_meta::Orientation::Normal); if !o.remove_location { out.gps = m.gps.map(|(latitude, longitude)| Gps { latitude, longitude, altitude: None }); } if o.metadata == MetadataPolicy::All { out.model = text(&m.camera); out.lens_model = text(&m.lens); out.focal_length = m.focal_mm.map(f64::from); out.f_number = m.aperture.map(f64::from); out.exposure_time = parse_shutter(&m.shutter); out.iso = m.iso; } Some(out) } /// One exported file. pub struct Exported { pub file_name: String, pub bytes: Vec, pub width: usize, pub height: usize, } /// Render photo `id` at the requested size (full size when `long_edge` is `None`) and encode it. pub fn export_photo(session: &mut crate::Session, id: lightcraft_catalog::PhotoId, o: &ExportOptions, seq: usize) -> Result { let p = session.catalog.photo(id).ok_or("no such photo")?; let stem = p.file_name.rsplit_once('.').map_or(p.file_name.as_str(), |(a, _)| a).to_string(); let full = p.width.max(p.height).max(1) as usize; let size = o.long_edge.map_or(full, |l| l as usize); let meta = export_metadata(p, o); let r = session.render_now(id, size, size)?; let bytes = encode_with_metadata(&r.image, o, meta.as_ref())?; Ok(Exported { file_name: o.file_name(&stem, seq), bytes, width: r.image.width, height: r.image.height }) } #[cfg(test)] mod tests { use super::*; fn test_image() -> Rgba8 { let mut img = Rgba8::new(96, 64); for (i, p) in img.data.iter_mut().enumerate() { let (x, y) = (i % 96, i / 96); *p = [(x * 2) as u8, (y * 3) as u8, ((x * y) % 255) as u8, 255]; } img } #[test] fn formats_have_magic() { let img = test_image(); for (f, magic) in [(ExportFormat::Jpeg, &b"\xFF\xD8"[..]), (ExportFormat::Png, b"\x89PNG"), (ExportFormat::Tiff, b"II"), (ExportFormat::Webp, b"RIFF")] { let b = encode_image(&img, &ExportOptions { format: f, ..Default::default() }).unwrap(); assert!(b.starts_with(magic), "{f:?}"); } } #[test] fn size_limit_respected() { let img = test_image(); let full = encode_image(&img, &ExportOptions { quality: 100, ..Default::default() }).unwrap(); let kb = (full.len() / 1024 / 2).max(2) as u32; let b = encode_image(&img, &ExportOptions { quality: 100, limit_kb: Some(kb), ..Default::default() }).unwrap(); assert!(b.len() <= kb as usize * 1024); } #[test] fn sharpen_increases_edge_contrast() { let mut img = Rgba8::new(8, 8); for (i, p) in img.data.iter_mut().enumerate() { let v = if i % 8 < 4 { 80 } else { 170 }; *p = [v, v, v, 255]; } output_sharpen(&mut img, SharpenFor::Matte, SharpenAmount::High); assert!(img.data[3][0] < 80 && img.data[4][0] > 170); } #[test] fn metadata_policies() { use lightcraft_catalog::{Photo, PhotoId, Source}; let mut p = Photo::new(PhotoId(1), Source::Demo { scene: 0 }, "a.jpg", "jpeg", 10, 10, "2026-09-30T00:00:00"); p.meta.camera = "Synthetic X2".into(); p.meta.copyright = "(c) Me".into(); p.meta.shutter = "1/250".into(); p.meta.gps = Some((43.0, -110.0)); let all = export_metadata(&p, &ExportOptions::default()).unwrap(); assert_eq!(all.model.as_deref(), Some("Synthetic X2")); assert!((all.exposure_time.unwrap() - 0.004).abs() < 1e-9); assert!(all.gps.is_some()); let o = ExportOptions { metadata: MetadataPolicy::AllExceptCamera, remove_location: true, ..Default::default() }; let m = export_metadata(&p, &o).unwrap(); assert!(m.model.is_none() && m.gps.is_none() && m.copyright.is_some()); let c = export_metadata(&p, &ExportOptions { metadata: MetadataPolicy::Copyright, ..Default::default() }).unwrap(); assert!(c.model.is_none() && c.gps.is_none() && c.copyright.as_deref() == Some("(c) Me")); assert!(export_metadata(&p, &ExportOptions { metadata: MetadataPolicy::None, ..Default::default() }).is_none()); // embedded and readable back from the JPEG let jpg = encode_with_metadata(&test_image(), &ExportOptions::default(), Some(&all)).unwrap(); let back = lightcraft_meta::extract(&jpg); assert_eq!(back.model.as_deref(), Some("Synthetic X2")); assert_eq!(back.copyright.as_deref(), Some("(c) Me")); assert!(back.gps.is_some()); } #[test] fn watermark_draws_in_the_anchored_corner_only() { let mut img = Rgba8::new(400, 300); for p in img.data.iter_mut() { *p = [0, 0, 0, 255]; } let o = ExportOptions::from_json(&serde_json::json!({"watermark": {"text": "LightCraft", "size": 0.08, "opacity": 1.0, "shadow": false}})); let wm = o.watermark.clone().unwrap(); draw_watermark(&mut img, &wm); let lit = |x0: usize, x1: usize, y0: usize, y1: usize| { (y0..y1).flat_map(|y| (x0..x1).map(move |x| (x, y))).filter(|&(x, y)| img.get(x, y)[0] > 128).count() }; assert!(lit(200, 400, 225, 300) > 100, "bottom-right has text"); assert!(lit(0, 200, 0, 150) == 0, "top-left untouched"); // string shorthand assert_eq!(ExportOptions::from_json(&serde_json::json!({"watermark": "© Me"})).watermark.unwrap().text, "© Me"); assert!(ExportOptions::from_json(&serde_json::json!({"watermark": ""})).watermark.is_none()); } #[test] fn naming_and_params() { let o = ExportOptions::from_json(&serde_json::json!({"format": "jpg", "quality": 150, "longEdge": 2048, "naming": "{name}-{seq}"})); assert_eq!(o.format, ExportFormat::Jpeg); assert_eq!(o.quality, 100); assert_eq!(o.long_edge, Some(2048)); assert_eq!(o.file_name("IMG/1", 7), "IMG_1-007.jpg"); } }