mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-30 21:18:11 -06:00
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:
co-authored by
Claude Opus 5.5
parent
3cc97c58d1
commit
c6954d5bd9
+100
-2
@@ -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
@@ -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),
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user