diff --git a/crates/engine/src/cmd/develop.rs b/crates/engine/src/cmd/develop.rs index e0aaaf95..b727b99b 100644 --- a/crates/engine/src/cmd/develop.rs +++ b/crates/engine/src/cmd/develop.rs @@ -572,6 +572,33 @@ pub fn specs() -> Vec { Ok(()) }) }), + // ---- HDR editing + cmd!( + "develop.hdr", + "HDR", + [], + None, + "{enabled?: bool (default: toggle), maxEv?: number 0..5 (headroom limit, stops above SDR white)} — HDR editing: highlights above SDR white for HDR exports and displays; every SDR render uses the SDR rendition (the hdr.sdr* controls)", + has_active, + |s, p| { + let id = active(s, "develop.hdr")?; + let cur = s.develop_of(id).unwrap_or_default().hdr; + let enabled = bool_or(p, "enabled", !cur.enabled); + let max_ev = match p.get("maxEv") { + None => None, + Some(v) => Some(v.as_f64().filter(|x| x.is_finite()).ok_or_else(|| bad("develop.hdr", "`maxEv` must be a number"))?), + }; + let label = if enabled { "HDR On" } else { "HDR Off" }; + edit(s, "develop.hdr", label, |d| { + d.hdr.enabled = enabled; + if let Some(m) = max_ev { + d.hdr.max_ev = m.clamp(0.0, lightcraft_develop::Hdr::MAX_EV_LIMIT); + } + Ok(()) + })?; + Ok(json!({"enabled": enabled, "maxEv": s.develop_of(id).map(|d| d.hdr.max_ev)})) + } + ), // ---- geometry: Upright cmd!( "geometry.upright", diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs index 33366285..0ba8164a 100644 --- a/crates/engine/src/export.rs +++ b/crates/engine/src/export.rs @@ -600,6 +600,10 @@ pub struct ExportOptions { /// Bits per channel: 8, 16 (PNG, TIFF), 32 (TIFF: float, linear) or 10 (AVIF); `None` = the /// format's default (TIFF 16, everything else 8). See [`ExportOptions::effective_depth`]. pub bit_depth: Option, + /// HDR output for photos edited in HDR: JPEG as an ISO 21496-1 gain map JPEG (the SDR + /// rendition plus a gain map, so the file looks right everywhere), 32-bit float TIFF with the + /// highlights above SDR white kept. Other formats, and photos without an HDR edit, are SDR. + pub hdr: bool, } impl Default for ExportOptions { @@ -623,6 +627,7 @@ impl Default for ExportOptions { watermark: None, color_space: OutputSpace::Srgb, bit_depth: None, + hdr: false, } } } @@ -658,6 +663,7 @@ pub const OPTION_PARAMS: &[&str] = &[ "watermark", "colorSpace", "bitDepth", + "hdr", ]; /// The keys of a `watermark` object ([`Watermark`], camelCase). @@ -789,7 +795,7 @@ impl ExportOptions { } serde_json::from_value::(v.clone()).map_err(|e| bad(format!("`{k}`: {e}")))?; } - "dontEnlarge" | "removeLocation" | "background" => { + "dontEnlarge" | "removeLocation" | "background" | "hdr" => { boolean(k, v)?; } "naming" | "subfolder" | "path" | "dir" | "preset" => { @@ -922,6 +928,7 @@ impl ExportOptions { .filter(|w: &Watermark| !w.text.trim().is_empty() || !w.image.trim().is_empty()), color_space: s("colorSpace").and_then(OutputSpace::parse).unwrap_or(d.color_space), bit_depth: u("bitDepth").filter(|b| matches!(b, 8 | 10 | 16 | 32)).map(|b| b as u8), + hdr: p.get("hdr").and_then(Value::as_bool).unwrap_or(d.hdr), } } @@ -990,6 +997,7 @@ impl ExportOptions { (ExportFormat::Png, Some(16 | 32)) => OutputDepth::U16, (ExportFormat::Png, _) => OutputDepth::U8, (ExportFormat::Tiff, Some(8)) => OutputDepth::U8, + (ExportFormat::Tiff, Some(32)) if self.hdr => OutputDepth::F32Hdr, (ExportFormat::Tiff, Some(32)) => OutputDepth::F32Linear, (ExportFormat::Tiff, _) => OutputDepth::U16, (ExportFormat::Avif, Some(10 | 16 | 32)) => OutputDepth::U16, @@ -1007,6 +1015,12 @@ impl ExportOptions { } } + /// Whether these options write HDR files (for photos edited in HDR): JPEG (gain map) or + /// 32-bit float TIFF with [`ExportOptions::hdr`]. + pub fn hdr_output(&self) -> bool { + self.hdr && matches!((self.format, self.bit_depth), (ExportFormat::Jpeg, _) | (ExportFormat::Tiff, Some(32))) + } + /// The colour space the file is actually written in (AVIF: sRGB). pub fn effective_space(&self) -> OutputSpace { if self.format == ExportFormat::Avif { OutputSpace::Srgb } else { self.color_space } @@ -1119,13 +1133,8 @@ pub fn encode_image(img: &Rgba8, o: &ExportOptions) -> Result, String> { /// 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 img = finish_8bit(img, o); let space = o.effective_space(); - if let Some(wm) = &o.watermark { - let wm = Watermark { color: srgb8_in(space, wm.color), target: Some(space), ..wm.clone() }; - draw_watermark(&mut img, &wm); - } let profile = icc::write_named(named_space(space)); let exif = meta.map(lightcraft_meta::write_exif); let xmp = meta.map(|m| lightcraft_meta::write_xmp(m, None)); @@ -1168,6 +1177,56 @@ pub fn encode_with_metadata(img: &Rgba8, o: &ExportOptions, meta: Option<&Metada r.map_err(|e| e.to_string()) } +/// Output sharpening and the watermark on an 8-bit render in `o.effective_space()`. +fn finish_8bit(img: &Rgba8, o: &ExportOptions) -> Rgba8 { + let mut img = img.clone(); + output_sharpen(&mut img, o.sharpen, o.sharpen_amount); + let space = o.effective_space(); + if let Some(wm) = &o.watermark { + let wm = Watermark { color: srgb8_in(space, wm.color), target: Some(space), ..wm.clone() }; + draw_watermark(&mut img, &wm); + } + img +} + +/// Output sharpening and the watermark on a high-bit-depth render. +fn finish_deep(img: &DeepImage, o: &ExportOptions) -> DeepImage { + let mut img = img.clone(); + output_sharpen_deep(&mut img, o.sharpen, o.sharpen_amount); + if let Some(wm) = &o.watermark { + let wm = Watermark { color: srgb8_in(img.space, wm.color), target: Some(img.space), ..wm.clone() }; + draw_watermark_deep(&mut img, &wm); + } + img +} + +/// Encode an ISO 21496-1 gain map JPEG from the SDR rendition `sdr` (8-bit, in the output space) +/// and the HDR rendition `hdr` (float linear, same space and size). The gain map is measured +/// against the base as written, so its 8-bit rounding is handed back by the map. +pub fn encode_gain_map_jpeg(sdr: &Rgba8, hdr: &DeepImage, o: &ExportOptions, meta: Option<&Metadata>) -> Result, String> { + use lightcraft_codecs::gainmap; + let base = finish_8bit(sdr, o); + let hdr = finish_deep(hdr, o); + let DeepSamples::F32(hv) = &hdr.samples else { + return Err("HDR export needs a float HDR render".into()); + }; + let (w, h) = (base.width, base.height); + if (hdr.width, hdr.height) != (w, h) || hv.len() < w * h * 3 { + return Err("HDR and SDR renders differ in size".into()); + } + let space = o.effective_space(); + let trc = space.trc(); + let sdr_lin: Vec<[f32; 3]> = base.data.iter().map(|p| [0, 1, 2].map(|c| trc.decode(p[c] as f32 / 255.0))).collect(); + let hdr_lin: &[[f32; 3]] = hv.as_chunks::<3>().0; + let (map, gm) = gainmap::compute(&sdr_lin, hdr_lin, w, h, space.luma(), &gainmap::GainMapOptions::default()).map_err(|e| e.to_string())?; + let profile = icc::write_named(named_space(space)); + let exif = meta.map(lightcraft_meta::write_exif); + let xmp = meta.map(|m| lightcraft_meta::write_xmp(m, None)); + let em = EncodeMeta { icc: Some(&profile), exif: exif.as_deref(), xmp: xmp.as_deref(), ppi: Some(o.ppi) }; + let sub = if o.limit_kb.is_some() { ChromaSubsampling::S420 } else { ChromaSubsampling::S444 }; + gainmap::encode_jpeg(&EncodeImage::rgba8(&base), &map, &gm, o.quality, sub, &em).map_err(|e| e.to_string()) +} + /// The codecs' name of an output space (for its ICC profile). pub fn named_space(s: OutputSpace) -> NamedSpace { match s { @@ -1204,12 +1263,7 @@ pub fn encode_rendered(r: &lightcraft_pipeline::Rendered, o: &ExportOptions, met /// Encode a high-bit-depth image (see [`encode_rendered`]). pub fn encode_deep(img: &DeepImage, o: &ExportOptions, meta: Option<&Metadata>) -> Result, String> { - let mut img = img.clone(); - output_sharpen_deep(&mut img, o.sharpen, o.sharpen_amount); - if let Some(wm) = &o.watermark { - let wm = Watermark { color: srgb8_in(img.space, wm.color), target: Some(img.space), ..wm.clone() }; - draw_watermark_deep(&mut img, &wm); - } + let img = finish_deep(img, o); let profile = match img.samples { DeepSamples::F32(_) => icc::write_matrix_trc(&img.space.rgb_space(), &lightcraft_codecs::Trc::Linear), DeepSamples::U16(_) => icc::write_named(named_space(img.space)), @@ -1328,6 +1382,8 @@ pub struct PreparedExport { struct RenderWork { job: crate::media::RenderJob, + /// The HDR rendition of a gain map JPEG (`job` renders its SDR base). + hdr_job: Option, meta: Option, opts: ExportOptions, } @@ -1376,8 +1432,10 @@ fn prepare_guarded( let work = if o.format.is_rendered() { let (w, h) = output_size(p, o); let meta = export_metadata(p, o); + let gain_map = o.hdr_output() && o.format == ExportFormat::Jpeg && p.develop.hdr.enabled; let job = session.export_job(id, w, h, o.effective_space(), o.effective_depth())?; - Work::Render(Box::new(RenderWork { job, meta, opts: o.clone() })) + let hdr_job = if gain_map { Some(session.export_job(id, w, h, o.effective_space(), OutputDepth::F32Hdr)?) } else { None }; + Work::Render(Box::new(RenderWork { job, hdr_job, meta, opts: o.clone() })) } else { let lightcraft_catalog::Source::File { path } = &p.source else { return Err(format!("{} is a generated demo photo: it has no original file to export", p.file_name)); @@ -1399,9 +1457,16 @@ impl PreparedExport { let file_name = self.file_name; match self.work { Work::Render(w) => { - let RenderWork { job, meta, opts } = *w; + let RenderWork { job, hdr_job, meta, opts } = *w; let r = job.run().rendered?; - let bytes = encode_rendered(&r, &opts, meta.as_ref())?; + let bytes = match hdr_job { + Some(hj) => { + let hr = hj.run().rendered?; + let hdr = hr.deep.ok_or("the HDR render has no float samples")?; + encode_gain_map_jpeg(&r.image, &hdr, &opts, meta.as_ref())? + } + None => encode_rendered(&r, &opts, meta.as_ref())?, + }; Ok(Exported { file_name, bytes, width: r.image.width, height: r.image.height, sidecars: Vec::new() }) } Work::File { path, read, packet, dng, label, size } => { diff --git a/crates/engine/src/tests_export.rs b/crates/engine/src/tests_export.rs index faba3e3c..df244a12 100644 --- a/crates/engine/src/tests_export.rs +++ b/crates/engine/src/tests_export.rs @@ -370,3 +370,55 @@ fn exports_are_atomic_without_a_sync() { assert!(syncs_on_this_thread() > before, "the durable writer syncs"); let _ = std::fs::remove_dir_all(&dir); } + +/// The demo library's ocean sunset (a sun far above SDR white), selected and active. +fn sunset(s: &mut Session) -> lightcraft_catalog::PhotoId { + let id = s.catalog.photos().find(|p| p.meta.title == "Golden horizon").map(|p| p.id).unwrap(); + s.execute("library.select", &json!({"ids": [id.0]})).unwrap(); + id +} + +#[test] +fn hdr_jpeg_export_writes_a_gain_map_and_sdr_edits_stay_plain() { + use lightcraft_codecs::gainmap; + let mut s = Session::with_demo(); + let id = sunset(&mut s); + let o = ExportOptions::from_json(&json!({"longEdge": 320, "hdr": true})); + assert!(o.hdr_output()); + // no HDR edit: an ordinary JPEG even with HDR output asked for + let plain = export_photo(&mut s, id, &o, 1).unwrap(); + assert!(!gainmap::is_gain_map_jpeg(&plain.bytes)); + + let r = s.execute("develop.hdr", &json!({"enabled": true, "maxEv": 3})).unwrap(); + assert_eq!(r["enabled"], true); + let e = export_photo(&mut s, id, &o, 1).unwrap(); + assert!(gainmap::is_gain_map_jpeg(&e.bytes)); + let gm = gainmap::read_jpeg(&e.bytes).unwrap(); + assert!(gm.meta.alternate_headroom > 0.5 && gm.meta.alternate_headroom <= 3.05, "headroom {}", gm.meta.alternate_headroom); + // the base is the SDR rendition: the same pixels as the SDR export (the decoders agree) + let a = lightcraft_codecs::decode(&e.bytes, Default::default()).unwrap(); + let b = lightcraft_codecs::decode(&plain.bytes, Default::default()).unwrap(); + assert_eq!((a.width, a.height), (b.width, b.height)); + let diff = a.image.data.iter().zip(&b.image.data).map(|(p, q)| (p[1] - q[1]).abs()).fold(0.0f32, f32::max); + assert!(diff < 0.02, "base differs from the SDR export by {diff}"); + // HDR off again: the gain map goes away + s.execute("develop.hdr", &json!({"enabled": false})).unwrap(); + assert!(!gainmap::is_gain_map_jpeg(&export_photo(&mut s, id, &o, 1).unwrap().bytes)); +} + +#[test] +fn hdr_float_tiff_keeps_highlights_above_white() { + let mut s = Session::with_demo(); + let id = sunset(&mut s); + s.execute("develop.hdr", &json!({"enabled": true, "maxEv": 2})).unwrap(); + let max_of = |s: &mut Session, hdr: bool| { + let o = ExportOptions::from_json(&json!({"longEdge": 240, "format": "tiff", "bitDepth": 32, "hdr": hdr})); + let e = export_photo(s, id, &o, 1).unwrap(); + let d = lightcraft_codecs::decode(&e.bytes, Default::default()).unwrap(); + d.image.data.iter().flat_map(|p| p.iter().copied()).fold(0.0f32, f32::max) + }; + let hdr = max_of(&mut s, true); + let sdr = max_of(&mut s, false); + assert!(sdr <= 1.0 + 1e-3, "SDR float TIFF stays in 0..1 ({sdr})"); + assert!(hdr > 1.2 && hdr <= 4.0 + 1e-3, "HDR float TIFF max {hdr}"); +} diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index 974de023..a7555398 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -131,7 +131,7 @@ impl Backend for Headless { "engine.commands" => { let mut v: Vec = self.session.commands().into_iter().map(|c| serde_json::to_value(c).unwrap_or_default()).collect(); v.push(json!({"id": "app.export", "label": "Export Now", "menu": [], "shortcut": null, - "params": "{path?: output file (.jpg/.png/.tif/.webp/.avif/.dng) | dir?, ids?, preset?, format?: jpeg|png|tiff|webp|avif|dng|original, longEdge?|shortEdge?|width?|height?|megapixels?|percent? (default longEdge 3000; longEdge 0 = full size), dontEnlarge?, ppi?, quality?: 1..100, limitKb?, colorSpace?: srgb|displayP3|adobeRgb|proPhoto|rec2020, bitDepth?: 8|10|16|32, sharpen?: none|screen|matte|glossy, sharpenAmount?: low|standard|high, metadata?: all|allExceptCamera|copyright|none, removeLocation?, naming?, startNumber?, subfolder?, conflict?: unique|overwrite|skip, tiffCompression?: none|lzw|zip, dngCompression?: lossless|deflate|uncompressed, watermark?: text | {text?, vertical? (upright columns, right to left), size? (text height, 0.005..0.5 of the short edge; default 0.035), opacity? (0..1; 0.7), anchor?: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight, inset? (margin, 0..0.4 of the short edge; 0.025), color? [r,g,b] sRGB, shadow?, image? (graphic drawn instead of the text), imageWidth? (0.01..1 of the photo's width; 0.2)}} — an unknown parameter, an out-of-range watermark size or a value of the wrong kind is an error, not a default", + "params": "{path?: output file (.jpg/.png/.tif/.webp/.avif/.dng) | dir?, ids?, preset?, format?: jpeg|png|tiff|webp|avif|dng|original, longEdge?|shortEdge?|width?|height?|megapixels?|percent? (default longEdge 3000; longEdge 0 = full size), dontEnlarge?, ppi?, quality?: 1..100, limitKb?, colorSpace?: srgb|displayP3|adobeRgb|proPhoto|rec2020, bitDepth?: 8|10|16|32, hdr? (HDR output for HDR edits: gain map JPEG, float TIFF), sharpen?: none|screen|matte|glossy, sharpenAmount?: low|standard|high, metadata?: all|allExceptCamera|copyright|none, removeLocation?, naming?, startNumber?, subfolder?, conflict?: unique|overwrite|skip, tiffCompression?: none|lzw|zip, dngCompression?: lossless|deflate|uncompressed, watermark?: text | {text?, vertical? (upright columns, right to left), size? (text height, 0.005..0.5 of the short edge; default 0.035), opacity? (0..1; 0.7), anchor?: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight, inset? (margin, 0..0.4 of the short edge; 0.025), color? [r,g,b] sRGB, shadow?, image? (graphic drawn instead of the text), imageWidth? (0.01..1 of the photo's width; 0.2)}} — an unknown parameter, an out-of-range watermark size or a value of the wrong kind is an error, not a default", "enabled": self.session.active().is_some()})); Ok(Value::Array(v)) } diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 4a114f18..01ce2647 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -258,6 +258,7 @@ pub fn helper_tools(has_ui: bool) -> Vec { "removeLocation": {"type": "boolean"}, "colorSpace": {"type": "string", "enum": ["srgb", "displayP3", "adobeRgb", "proPhoto", "rec2020"], "description": "Output colour space (default sRGB; AVIF is always sRGB). adobeRgb = Adobe RGB (1998) compatible; the embedded ICC profile is generated from the published primaries"}, "bitDepth": {"type": "integer", "enum": [8, 10, 16, 32], "description": "Bits per channel: PNG 8|16 (default 8), TIFF 8|16|32 (default 16; 32 = linear float with a linear profile), AVIF 8|10; JPEG/WebP are 8-bit"}, + "hdr": {"type": "boolean", "description": "HDR output for photos edited in HDR (develop.hdr): JPEG as an ISO 21496-1 gain map JPEG, AVIF as 10-bit Rec. 2020 PQ, 32-bit TIFF with highlights above 1; other formats and SDR edits export as usual"}, "watermark": {"description": "Text, or {text, vertical (boolean; defaults to false: upright columns right to left), size (text height as a fraction of the short edge, 0.005..0.5; default 0.035), opacity (0..1; default 0.7), anchor (topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight), inset (margin as a fraction of the short edge, 0..0.4; default 0.025), color [r,g,b] (sRGB 0..255), shadow (boolean), image (path of a graphic drawn instead of the text), imageWidth (fraction of the photo's width, 0.01..1; default 0.2)}. Unknown keys and out-of-range sizes are errors"} }), &[], @@ -729,6 +730,7 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, args: &Value) -> ToolResult { "watermark", "colorSpace", "bitDepth", + "hdr", "preset", "background", "resize",