mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-31 05:28:25 -06:00
LR-EXP-HDR: HDR export (gain map JPEG, float TIFF) and develop.hdr
- `ExportOptions.hdr` (`hdr` param): photos edited in HDR export as ISO
21496-1 gain map JPEGs (the SDR rendition as the base, the gain map measured
against it as written) or 32-bit float TIFF with highlights above 1.
Other formats and SDR edits export as before.
- Output sharpening and watermarks apply to both renditions.
- `develop.hdr {enabled?, maxEv?}` turns HDR editing on/off and sets the
headroom limit.
Checked on macOS: ImageIO reports both the ISO and the Apple gain map, and
CoreImage's HDR decode of a 3 EV edit reaches 2.95 EV.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
e11db8fea0
commit
34d9c8f2d2
+81
-16
@@ -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<u8>,
|
||||
/// 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::<Resize>(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<Vec<u8>, 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<Vec<u8>, 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<Vec<u8>, 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<Vec<u8>, 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<crate::media::RenderJob>,
|
||||
meta: Option<Metadata>,
|
||||
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 } => {
|
||||
|
||||
Reference in New Issue
Block a user