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 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-01 00:03:38 -04:00
co-authored by Claude Opus 5.5
parent 3cc97c58d1
commit c6954d5bd9
5 changed files with 235 additions and 5 deletions
+100 -2
View File
@@ -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<Vec<u8>, 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<Vec<u8>, 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<Vec<u8>, 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<f64> {
let s = s.trim().trim_end_matches(['s', '"']).trim();
match s.split_once('/') {
Some((n, d)) => Some(n.trim().parse::<f64>().ok()? / d.trim().parse::<f64>().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<Metadata> {
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}"}));
+20 -2
View File
@@ -185,7 +185,9 @@ pub fn helper_tools(has_ui: bool) -> Vec<Value> {
"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),
+98
View File
@@ -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<u8> {
use lightcraft_tiff::writer::{rational, srational};
use lightcraft_tiff::{ByteOrder, IfdBuilder, TiffWriter, Value};
let ascii = |s: &Option<String>| 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<f64>| 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:?}");
}
}
+1 -1
View File
@@ -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};
+16
View File
@@ -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<serde_jso
json!({
"format": opts.format, "quality": opts.quality, "longEdge": long_edge, "limitKb": limit_kb,
"sharpen": opts.sharpen, "sharpenAmount": opts.sharpen_amount, "naming": opts.naming, "dir": dir,
"metadata": opts.metadata, "removeLocation": opts.remove_location,
}),
),
Dialog::About | Dialog::Shortcuts => Ok(serde_json::Value::Null),