From c6954d5bd9b7fa86905a94117a30fee51337fcea Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 00:03:38 -0400 Subject: [PATCH] M10.5: export metadata policy (all / all except camera / copyright / none, remove location) with EXIF + XMP embedding New lightcraft_meta::write_exif (IFD0 + Exif + GPS IFDs; round-trip tested). Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/export.rs | 102 ++++++++++++++++++++++++++- crates/mcp/src/tools.rs | 22 +++++- crates/meta/src/exif.rs | 98 +++++++++++++++++++++++++ crates/meta/src/lib.rs | 2 +- crates/ui-egui/src/panels/dialogs.rs | 16 +++++ 5 files changed, 235 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs index 91cef977d..affcbc3d7 100644 --- a/crates/engine/src/export.rs +++ b/crates/engine/src/export.rs @@ -3,6 +3,7 @@ //! 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}; @@ -59,6 +60,21 @@ pub enum SharpenAmount { High, } +/// 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 { @@ -73,6 +89,9 @@ pub struct ExportOptions { 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, } impl Default for ExportOptions { @@ -85,6 +104,8 @@ impl Default for ExportOptions { sharpen: SharpenFor::None, sharpen_amount: SharpenAmount::Standard, naming: "{name}".into(), + metadata: MetadataPolicy::All, + remove_location: false, } } } @@ -107,6 +128,8 @@ impl ExportOptions { 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), } } @@ -171,10 +194,17 @@ pub fn output_sharpen(img: &mut Rgba8, target: SharpenFor, amount: SharpenAmount /// 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); let profile = icc::write_named(NamedSpace::Srgb); - let meta = EncodeMeta { icc: Some(&profile), ..Default::default() }; + 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 => { @@ -212,6 +242,47 @@ pub fn encode_image(img: &Rgba8, o: &ExportOptions) -> Result, String> { 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, @@ -226,8 +297,9 @@ pub fn export_photo(session: &mut crate::Session, id: lightcraft_catalog::PhotoI 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_image(&r.image, o)?; + 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 }) } @@ -275,6 +347,32 @@ mod tests { 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 naming_and_params() { let o = ExportOptions::from_json(&serde_json::json!({"format": "jpg", "quality": 150, "longEdge": 2048, "naming": "{name}-{seq}"})); diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 050ced35d..de3b78b21 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -185,7 +185,9 @@ pub fn helper_tools(has_ui: bool) -> Vec { "limitKb": {"type": "integer", "description": "JPEG: largest quality that fits this many KB"}, "sharpen": {"type": "string", "enum": ["none", "screen", "matte", "glossy"]}, "sharpenAmount": {"type": "string", "enum": ["low", "standard", "high"]}, - "naming": {"type": "string", "description": "e.g. {name}-{seq}"} + "naming": {"type": "string", "description": "e.g. {name}-{seq}"}, + "metadata": {"type": "string", "enum": ["all", "allExceptCamera", "copyright", "none"]}, + "removeLocation": {"type": "boolean"} }), &[], ), @@ -515,7 +517,23 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, args: &Value) -> ToolResult { exec( b, "app.export", - obj(args, &["ids", "path", "dir", "format", "longEdge", "quality", "limitKb", "sharpen", "sharpenAmount", "naming"]), + obj( + args, + &[ + "ids", + "path", + "dir", + "format", + "longEdge", + "quality", + "limitKb", + "sharpen", + "sharpenAmount", + "naming", + "metadata", + "removeLocation", + ], + ), ) })), "screenshot" => screenshot(b, args), diff --git a/crates/meta/src/exif.rs b/crates/meta/src/exif.rs index a480c0491..ca8d7b7aa 100644 --- a/crates/meta/src/exif.rs +++ b/crates/meta/src/exif.rs @@ -253,3 +253,101 @@ pub(crate) mod tests { assert_eq!(m.title.as_deref(), Some("From XMP")); } } + +/// Serialise the interchange subset of `meta` as a TIFF-structured Exif block (little-endian, no +/// `Exif\0\0` prefix): IFD0 (make, model, software, artist, copyright, description, orientation, +/// date), the Exif IFD (capture settings, lens) and, when present, the GPS IFD. +pub fn write_exif(meta: &Metadata) -> Vec { + use lightcraft_tiff::writer::{rational, srational}; + use lightcraft_tiff::{ByteOrder, IfdBuilder, TiffWriter, Value}; + let ascii = |s: &Option| s.as_ref().filter(|v| !v.is_empty()).map(|v| Value::Ascii(v.clone())); + let mut ifd0 = IfdBuilder::new(); + for (tag, v) in [ + (t::MAKE, ascii(&meta.make)), + (t::MODEL, ascii(&meta.model)), + (t::SOFTWARE, ascii(&meta.software)), + (t::ARTIST, ascii(&meta.artist)), + (t::COPYRIGHT, ascii(&meta.copyright)), + (t::IMAGE_DESCRIPTION, ascii(&meta.caption)), + (t::DATE_TIME, meta.capture_time.map(|d| Value::Ascii(d.to_exif()))), + (t::ORIENTATION, meta.orientation.map(|o| Value::Short(vec![o.to_exif()]))), + ] { + if let Some(v) = v { + ifd0.set(tag, v); + } + } + let mut ex = IfdBuilder::new(); + ex.set(t::EXIF_VERSION, Value::Undefined(b"0232".to_vec())); + let r = |v: Option| v.filter(|v| v.is_finite() && *v > 0.0).map(|v| Value::Rational(vec![rational(v)])); + for (tag, v) in [ + (t::DATE_TIME_ORIGINAL, meta.capture_time.map(|d| Value::Ascii(d.to_exif()))), + (t::EXPOSURE_TIME, r(meta.exposure_time)), + (t::F_NUMBER, r(meta.f_number)), + (t::FOCAL_LENGTH, r(meta.focal_length)), + (t::ISO_SPEED, meta.iso.map(|i| Value::Short(vec![i.min(65_535) as u16]))), + (t::LENS_MAKE, ascii(&meta.lens_make)), + (t::LENS_MODEL, ascii(&meta.lens_model)), + (t::EXPOSURE_BIAS, meta.exposure_bias.map(|v| Value::SRational(vec![srational(v)]))), + ] { + if let Some(v) = v { + ex.set(tag, v); + } + } + ifd0.set_child(t::EXIF_IFD, ex); + if let Some(g) = meta.gps { + let dms = |v: f64| { + let v = v.abs(); + let d = v.floor(); + let m = ((v - d) * 60.0).floor(); + let s = (v - d - m / 60.0) * 3600.0; + Value::Rational(vec![(d as u32, 1), (m as u32, 1), ((s * 1000.0).round() as u32, 1000)]) + }; + let mut gps = IfdBuilder::new(); + gps.set(t::GPS_VERSION_ID, Value::Byte(vec![2, 3, 0, 0])); + gps.set(t::GPS_LATITUDE_REF, Value::Ascii(if g.latitude < 0.0 { "S" } else { "N" }.into())); + gps.set(t::GPS_LATITUDE, dms(g.latitude)); + gps.set(t::GPS_LONGITUDE_REF, Value::Ascii(if g.longitude < 0.0 { "W" } else { "E" }.into())); + gps.set(t::GPS_LONGITUDE, dms(g.longitude)); + if let Some(a) = g.altitude { + gps.set(t::GPS_ALTITUDE_REF, Value::Byte(vec![u8::from(a < 0.0)])); + gps.set(t::GPS_ALTITUDE, Value::Rational(vec![rational(a.abs())])); + } + ifd0.set_child(t::GPS_IFD, gps); + } + TiffWriter::new(ByteOrder::Little, false).write(&[ifd0]).unwrap_or_default() +} + +#[cfg(test)] +mod write_tests { + use super::*; + + #[test] + fn exif_round_trips() { + let m = Metadata { + make: Some("Synthetic".into()), + model: Some("X2".into()), + copyright: Some("© 2026 A. Person".into()), + artist: Some("A. Person".into()), + capture_time: DateTime::parse_iso("2026-09-20T05:40:00"), + exposure_time: Some(1.0 / 250.0), + f_number: Some(2.8), + iso: Some(400), + focal_length: Some(35.0), + lens_model: Some("35mm F2".into()), + gps: Some(Gps { latitude: 43.7904, longitude: -110.6818, altitude: Some(2000.0) }), + ..Default::default() + }; + let back = read_exif(&write_exif(&m)); + assert_eq!(back.make, m.make); + assert_eq!(back.model, m.model); + assert_eq!(back.copyright, m.copyright); + assert_eq!(back.artist, m.artist); + assert_eq!(back.capture_time.map(|d| d.to_exif()), m.capture_time.map(|d| d.to_exif())); + assert_eq!(back.iso, Some(400)); + assert!((back.f_number.unwrap() - 2.8).abs() < 1e-6); + assert!((back.exposure_time.unwrap() - 0.004).abs() < 1e-9); + assert_eq!(back.lens_model, m.lens_model); + let g = back.gps.unwrap(); + assert!((g.latitude - 43.7904).abs() < 1e-5 && (g.longitude + 110.6818).abs() < 1e-5, "{g:?}"); + } +} diff --git a/crates/meta/src/lib.rs b/crates/meta/src/lib.rs index 936fb21eb..71284677d 100644 --- a/crates/meta/src/lib.rs +++ b/crates/meta/src/lib.rs @@ -17,7 +17,7 @@ mod xmp; pub use container::{Embedded, embedded, jpeg_segments, png_chunks, webp_chunks}; pub use datetime::DateTime; -pub use exif::{from_tiff, read_exif, strip_exif_header, try_read_exif}; +pub use exif::{from_tiff, read_exif, strip_exif_header, try_read_exif, write_exif}; pub use iptc::parse_iptc; pub use lightcraft_geom::Orientation; pub use xmp::{LC_NS, XmpData, XmpError, parse_xmp, write_xmp}; diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 464bdbbd1..8dd907025 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -93,6 +93,21 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { } }); } + ui.horizontal(|ui| { + use lightcraft_engine::export::MetadataPolicy as M; + ui.label("Metadata"); + for (v, l) in + [(M::All, "All"), (M::AllExceptCamera, "All except camera"), (M::Copyright, "Copyright only"), (M::None, "None")] + { + ui.selectable_value(&mut opts.metadata, v, l); + } + }); + if !matches!( + opts.metadata, + lightcraft_engine::export::MetadataPolicy::None | lightcraft_engine::export::MetadataPolicy::Copyright + ) { + ui.checkbox(&mut opts.remove_location, "Remove location info"); + } ui.horizontal(|ui| { ui.label("File name"); ui.add(egui::TextEdit::singleline(&mut opts.naming).hint_text("{name}-{seq}").desired_width(f32::INFINITY)); @@ -170,6 +185,7 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result Ok(serde_json::Value::Null),