From 7f40a7a7d9f62a0d50203cd33371a51cedc0d156 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 12:49:13 -0400 Subject: [PATCH 1/3] LR-EXP-COLORSPACE: export in sRGB, Display P3, Adobe RGB (1998) compatible, ProPhoto RGB or Rec. 2020 RenderRequest gains an output space (default sRGB; previews stay sRGB). The per-pixel stage converts scene-linear Rec.2020 to the target primaries, gamut-maps into the target gamut and encodes with the target curve, on CPU and GPU (WGSL finish kernel reads the matrix, luma weights and curve from its parameter block). sRGB output is byte-identical to before on the CPU. Export embeds our own generated ICC profile for the space; AVIF stays sRGB. ExportOptions.colorSpace, export dialog and MCP export schema. Tests: P3 colour survives a P3 export (decoded through the embedded profile), every space/format round-trips its profile, GPU equivalence per space. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + apps/lightcraft-web/src/wire.rs | 2 + crates/color/src/lib.rs | 2 +- crates/engine/src/export.rs | 130 ++++++++++++++++++-- crates/engine/src/media.rs | 10 ++ crates/gpu/src/ctx.rs | 3 +- crates/gpu/src/params.rs | 9 ++ crates/gpu/src/render.rs | 2 +- crates/gpu/src/wgsl/finish.wgsl | 32 ++++- crates/gpu/tests/equivalence.rs | 29 +++++ crates/mcp/src/tools.rs | 2 + crates/pipeline/Cargo.toml | 1 + crates/pipeline/src/finish.rs | 80 +++++++++--- crates/pipeline/src/lib.rs | 10 +- crates/pipeline/src/output.rs | 175 +++++++++++++++++++++++++++ crates/ui-egui/src/panels/dialogs.rs | 19 +++ docs/mcp.md | 2 +- docs/parity.md | 48 ++++---- 18 files changed, 500 insertions(+), 57 deletions(-) create mode 100644 crates/pipeline/src/output.rs diff --git a/Cargo.lock b/Cargo.lock index e6d6f3f37..0c1362b6c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2208,6 +2208,7 @@ dependencies = [ "proptest", "rayon", "serde", + "serde_json", ] [[package]] diff --git a/apps/lightcraft-web/src/wire.rs b/apps/lightcraft-web/src/wire.rs index 6dbe81b2c..9dc5dd310 100644 --- a/apps/lightcraft-web/src/wire.rs +++ b/apps/lightcraft-web/src/wire.rs @@ -88,6 +88,8 @@ impl WireJob { quality: if self.draft { Quality::Draft } else { Quality::Full }, apply_crop: self.apply_crop, overlay: lightcraft_engine::pipeline::Overlay::from_parts(self.overlay.0, self.overlay.1), + // workers render previews (exports run in-process) + space: lightcraft_engine::pipeline::OutputSpace::Srgb, } } diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index 77d06185a..da9ea7003 100644 --- a/crates/color/src/lib.rs +++ b/crates/color/src/lib.rs @@ -118,7 +118,7 @@ pub const PROPHOTO: RgbSpace = RgbSpace { name: "ProPhoto RGB", r: Xy::new(0.734699, 0.265301), g: Xy::new(0.159597, 0.840403), b: Xy::new(0.036598, 0.000105), white: D50 }; /// The 1998 "Adobe RGB" primaries (a public colour-space definition; name used descriptively). pub const ADOBE_RGB: RgbSpace = - RgbSpace { name: "Adobe RGB (1998)", r: Xy::new(0.64, 0.33), g: Xy::new(0.21, 0.71), b: Xy::new(0.15, 0.06), white: D65 }; + RgbSpace { name: "Adobe RGB (1998) compatible", r: Xy::new(0.64, 0.33), g: Xy::new(0.21, 0.71), b: Xy::new(0.15, 0.06), white: D65 }; /// The develop pipeline's working space: linear Rec.2020 / D65. pub const WORKING: RgbSpace = REC2020; diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs index 107a501be..7c27b50a1 100644 --- a/crates/engine/src/export.rs +++ b/crates/engine/src/export.rs @@ -1,9 +1,11 @@ -//! Export: encode a rendered image to JPEG / PNG / TIFF / WebP / AVIF with an embedded sRGB profile, -//! optional output sharpening and a JPEG file-size limit. Pure (bytes in, bytes out) so the desktop +//! Export: encode a rendered image to JPEG / PNG / TIFF / WebP / AVIF in sRGB, Display P3, Adobe RGB +//! (1998) compatible, ProPhoto RGB or Rec. 2020 with an embedded ICC profile we generate from the +//! published primaries and curves, optional output sharpening and a JPEG file-size limit. Pure (bytes in, bytes out) so the desktop //! 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}; +pub use lightcraft_pipeline::OutputSpace; use lightcraft_raster::Rgba8; use serde::{Deserialize, Serialize}; @@ -198,6 +200,8 @@ pub struct ExportOptions { pub remove_location: bool, /// Text watermark (none when absent or the text is empty). pub watermark: Option, + /// Output colour space (AVIF is always sRGB: its muxer cannot embed a profile). + pub color_space: OutputSpace, } impl Default for ExportOptions { @@ -213,12 +217,14 @@ impl Default for ExportOptions { metadata: MetadataPolicy::All, remove_location: false, watermark: None, + color_space: OutputSpace::Srgb, } } } impl ExportOptions { - /// Read options from command params (`format`, `quality`, `longEdge`, `limitKb`, `sharpen`, `sharpenAmount`, `naming`). + /// Read options from command params (`format`, `quality`, `longEdge`, `limitKb`, `sharpen`, + /// `sharpenAmount`, `naming`, `metadata`, `removeLocation`, `watermark`, `colorSpace`). pub fn from_json(p: &serde_json::Value) -> Self { use serde_json::Value; let d = Self::default(); @@ -243,9 +249,15 @@ impl ExportOptions { _ => None, } .filter(|w: &Watermark| !w.text.trim().is_empty()), + color_space: s("colorSpace").and_then(OutputSpace::parse).unwrap_or(d.color_space), } } + /// 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 } + } + /// Output file name for photo `stem` at 1-based position `seq` in a batch. pub fn file_name(&self, stem: &str, seq: usize) -> String { let base = if self.naming.trim().is_empty() { "{name}" } else { self.naming.as_str() }; @@ -304,7 +316,8 @@ 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 +/// Encode a rendered display-referred image according to `o` (its pixels must already be in +/// `o.effective_space()`). 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) @@ -314,10 +327,12 @@ pub fn encode_image(img: &Rgba8, o: &ExportOptions) -> Result, String> { 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 space = o.effective_space(); if let Some(wm) = &o.watermark { - draw_watermark(&mut img, wm); + let wm = Watermark { color: srgb8_in(space, wm.color), ..wm.clone() }; + draw_watermark(&mut img, &wm); } - let profile = icc::write_named(NamedSpace::Srgb); + 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 meta = EncodeMeta { icc: Some(&profile), exif: exif.as_deref(), xmp: xmp.as_deref() }; @@ -358,6 +373,31 @@ pub fn encode_with_metadata(img: &Rgba8, o: &ExportOptions, meta: Option<&Metada r.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 { + OutputSpace::Srgb => NamedSpace::Srgb, + OutputSpace::DisplayP3 => NamedSpace::DisplayP3, + OutputSpace::AdobeRgb => NamedSpace::AdobeRgb, + OutputSpace::ProPhoto => NamedSpace::ProPhoto, + OutputSpace::Rec2020 => NamedSpace::Rec2020, + } +} + +/// An 8-bit sRGB colour expressed in `space` (encoded with its curve). +pub fn srgb8_in(space: OutputSpace, c: [u8; 3]) -> [u8; 3] { + if space == OutputSpace::Srgb { + return c; + } + let lin = c.map(lightcraft_color::transfer::decode_srgb8); + let m = lightcraft_color::SRGB.to_space(&space.rgb_space()).to_f32(); + let t = space.trc(); + std::array::from_fn(|i| { + let v = m[i][0] * lin[0] + m[i][1] * lin[1] + m[i][2] * lin[2]; + (t.encode(v) * 255.0 + 0.5) as u8 + }) +} + /// 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(); @@ -414,7 +454,7 @@ pub fn export_photo(session: &mut crate::Session, id: lightcraft_catalog::PhotoI 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 r = session.render_export(id, size, o.effective_space())?; 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 }) } @@ -516,4 +556,80 @@ mod tests { assert_eq!(o.long_edge, Some(2048)); assert_eq!(o.file_name("IMG/1", 7), "IMG_1-007.jpg"); } + + /// A flat field of a saturated green inside Display P3 but outside sRGB, rendered into `space`. + fn p3_green(space: OutputSpace) -> Rgba8 { + use lightcraft_color::{DISPLAY_P3, REC2020}; + let c = DISPLAY_P3.to_space(&REC2020).apply_f32([0.04, 0.45, 0.04]); + let src = lightcraft_raster::Rgb32f::filled(16, 16, c); + let req = lightcraft_pipeline::RenderRequest { space, ..lightcraft_pipeline::RenderRequest::fit(16, 16) }; + lightcraft_pipeline::render(&src, &Default::default(), &Default::default(), &req).image + } + + /// Decode `bytes` and return the centre pixel in linear sRGB primaries (unclamped), plus the + /// recognised space of the embedded profile. + fn decoded_in_srgb(bytes: &[u8]) -> ([f32; 3], Option) { + let d = lightcraft_codecs::decode(bytes, Default::default()).expect("decodes"); + let m = d.space.to_space(&lightcraft_color::SRGB); + (m.apply_f32(d.image.get(8, 8)), d.space.named) + } + + #[test] + fn p3_colour_survives_a_p3_export_and_is_clipped_in_srgb() { + let o = |space| ExportOptions { format: ExportFormat::Png, color_space: space, ..Default::default() }; + let (p3, named) = decoded_in_srgb(&encode_image(&p3_green(OutputSpace::DisplayP3), &o(OutputSpace::DisplayP3)).unwrap()); + assert_eq!(named, Some(NamedSpace::DisplayP3)); + assert!(p3[0] < -0.03 || p3[2] < -0.03, "outside sRGB after a P3 round trip: {p3:?}"); + let (s, named) = decoded_in_srgb(&encode_image(&p3_green(OutputSpace::Srgb), &o(OutputSpace::Srgb)).unwrap()); + assert_eq!(named, Some(NamedSpace::Srgb)); + assert!(s.iter().all(|v| *v > -0.002), "{s:?}"); + // the green itself is about the same brightness either way + assert!((p3[1] - s[1]).abs() < 0.15, "{p3:?} {s:?}"); + } + + #[test] + fn every_space_embeds_its_own_profile_and_round_trips() { + let grey = { + let src = lightcraft_raster::Rgb32f::filled(16, 16, [0.18; 3]); + let base = lightcraft_pipeline::RenderRequest::fit(16, 16); + move |space| { + lightcraft_pipeline::render(&src, &Default::default(), &Default::default(), &lightcraft_pipeline::RenderRequest { space, ..base }) + .image + } + }; + let reference = + decoded_in_srgb(&encode_image(&grey(OutputSpace::Srgb), &ExportOptions { format: ExportFormat::Tiff, ..Default::default() }).unwrap()).0; + for space in OutputSpace::ALL { + for format in [ExportFormat::Jpeg, ExportFormat::Png, ExportFormat::Tiff, ExportFormat::Webp] { + let o = ExportOptions { format, color_space: space, ..Default::default() }; + let bytes = encode_image(&grey(space), &o).unwrap(); + let (px, named) = decoded_in_srgb(&bytes); + assert_eq!(named, Some(named_space(space)), "{space:?} {format:?}"); + // a neutral grey decodes to the same linear value whatever the output space + for c in 0..3 { + assert!((px[c] - reference[c]).abs() < 0.01, "{space:?} {format:?}: {px:?} vs {reference:?}"); + } + } + let icc = icc::write_named(named_space(space)); + let info = icc::parse(&icc).expect("our profile parses"); + assert_eq!(info.named, Some(named_space(space))); + assert!(info.description.as_deref().is_some_and(|d| !d.is_empty())); + } + let o = ExportOptions { format: ExportFormat::Avif, color_space: OutputSpace::ProPhoto, ..Default::default() }; + assert_eq!(o.effective_space(), OutputSpace::Srgb); + let o = ExportOptions::from_json(&serde_json::json!({"colorSpace": "displayP3"})); + assert_eq!(o.color_space, OutputSpace::DisplayP3); + assert_eq!(ExportOptions::from_json(&serde_json::json!({"colorSpace": "Adobe RGB (1998) compatible"})).color_space, OutputSpace::AdobeRgb); + } + + #[test] + fn srgb_watermark_colour_is_converted() { + assert_eq!(srgb8_in(OutputSpace::Srgb, [10, 200, 30]), [10, 200, 30]); + for s in OutputSpace::ALL { + assert_eq!(srgb8_in(s, [255, 255, 255]), [255, 255, 255], "{s:?}"); + assert_eq!(srgb8_in(s, [0, 0, 0]), [0, 0, 0], "{s:?}"); + } + let p3 = srgb8_in(OutputSpace::DisplayP3, [255, 0, 0]); + assert!(p3[0] < 255 && p3[1] > 0, "{p3:?}"); + } } diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index e10b31a1e..98adc8391 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -518,6 +518,16 @@ impl crate::Session { r.rendered } + /// Synchronous render for export: like [`Self::render_now`], into colour space `space`. + pub fn render_export(&mut self, id: PhotoId, max: usize, space: lightcraft_pipeline::OutputSpace) -> Result { + let mut job = self.render_job(id, max, max, false, true).ok_or("no such photo")?; + job.request.space = space; + job.key ^= (space as u64 + 1).wrapping_mul(0xa076_1d64_78bd_642f); + let r = job.run(); + self.accept(&r); + r.rendered + } + /// The source proxy for pixel-statistics commands (auto tone/WB), loading synchronously. pub fn source_now(&mut self, id: PhotoId, level: SourceLevel) -> Result, String> { let p = self.catalog.photo(id).ok_or("no such photo")?.clone(); diff --git a/crates/gpu/src/ctx.rs b/crates/gpu/src/ctx.rs index 010ff7ba0..dad0a42ba 100644 --- a/crates/gpu/src/ctx.rs +++ b/crates/gpu/src/ctx.rs @@ -125,8 +125,7 @@ fn constants() -> String { use lightcraft_pipeline::tone::{LUT_MAX_EV, LUT_MIN_EV, LUT_N}; let mut s = String::new(); let [to_lms, from_lms, to_lab, from_lab] = lightcraft_color::perceptual::oklab_matrices(); - let to_srgb = lightcraft_color::REC2020.to_space(&lightcraft_color::SRGB).to_f32(); - for (name, m) in [("OK_TO_LMS", to_lms), ("OK_FROM_LMS", from_lms), ("OK_TO_LAB", to_lab), ("OK_FROM_LAB", from_lab), ("TO_SRGB", to_srgb)] { + for (name, m) in [("OK_TO_LMS", to_lms), ("OK_FROM_LMS", from_lms), ("OK_TO_LAB", to_lab), ("OK_FROM_LAB", from_lab)] { let rows: Vec = m.iter().map(|r| format!("vec3({:?}, {:?}, {:?})", r[0], r[1], r[2])).collect(); s += &format!("const {name} = array, 3>({});\n", rows.join(", ")); } diff --git a/crates/gpu/src/params.rs b/crates/gpu/src/params.rs index b18461a2e..279692c91 100644 --- a/crates/gpu/src/params.rs +++ b/crates/gpu/src/params.rs @@ -66,6 +66,10 @@ const FIELDS: &[(&str, usize)] = &[ ("CALIB", 1), ("CALIB_M", 9), ("SHADOW_TINT", 1), + ("OUT_M", 9), + ("OUT_Y", 3), + ("OUT_TRC", 1), + ("OUT_GAMMA", 1), ]; /// `(name, index)` of every field (for the WGSL constants). @@ -212,6 +216,11 @@ pub fn finish_block(fp: &FinishParams, masks: &[[f32; MASK_TERMS]], present: &Pr p.fs("CALIB_M", m.as_flattened()); } p.f("SHADOW_TINT", fp.shadow_tint); + p.fs("OUT_M", fp.to_out.as_flattened()); + p.fs("OUT_Y", &fp.out_luma); + let (trc, gamma) = fp.out_trc.code(); + p.u("OUT_TRC", trc); + p.f("OUT_GAMMA", gamma); if let Some((amt, cell, rough, seed)) = fp.grain { p.b("GRAIN", true); p.f("GRAIN_AMT", amt); diff --git a/crates/gpu/src/render.rs b/crates/gpu/src/render.rs index 0174da93e..8f0bf08fe 100644 --- a/crates/gpu/src/render.rs +++ b/crates/gpu/src/render.rs @@ -460,7 +460,7 @@ pub fn render( lap("masks", &mut t, &mut cx); // 5. per-pixel stage - let fp = FinishParams::new(s, &plan.frame, info, w, h, plan.px_per_long, prep.air); + let fp = FinishParams::new(s, &plan.frame, info, w, h, plan.px_per_long, prep.air, req.space); let present = Present { clarity: prep.clarity.is_some(), texture: prep.texture.is_some(), dark: prep.dark.is_some() }; let (p, aux) = finish_block(&fp, &terms, &present); let aux = gpu.upload(&aux); diff --git a/crates/gpu/src/wgsl/finish.wgsl b/crates/gpu/src/wgsl/finish.wgsl index d405e9495..71b20d5b7 100644 --- a/crates/gpu/src/wgsl/finish.wgsl +++ b/crates/gpu/src/wgsl/finish.wgsl @@ -218,6 +218,26 @@ fn enc8(v: f32) -> u32 { return u32(clamp(v, 0.0, 1.0) * 255.0 + 0.5); } +// sRGB-curve-encoded value → the output space's own curve (`OutputTrc`: 0 sRGB, 1 gamma, 2 Rec.709). +fn out_encode(v: f32) -> f32 { + let kind = pu(F_OUT_TRC); + if (kind == 0u) { + return v; + } + let e = clamp(v, 0.0, 1.0); + var l = e / 12.92; + if (e > 0.04045) { + l = pow((e + 0.055) / 1.055, 2.4); + } + if (kind == 1u) { + return pow(l, 1.0 / pf(F_OUT_GAMMA)); + } + if (l < 0.018) { + return l * 4.5; + } + return 1.099 * pow(l, 0.45) - 0.099; +} + @compute @workgroup_size(16, 16) fn main(@builtin(global_invocation_id) gid: vec3) { let w = pu(F_W); @@ -399,9 +419,14 @@ fn main(@builtin(global_invocation_id) gid: vec3) { } } - // --- gamut map to sRGB (desaturate towards luminance until in range) - var r = mul3(TO_SRGB, d); - let yy = clamp(0.2126 * r.x + 0.7152 * r.y + 0.0722 * r.z, 0.0, 1.0); + // --- gamut map to the output space (desaturate towards luminance until in range) + let om = array, 3>( + vec3(pf(F_OUT_M), pf(F_OUT_M + 1u), pf(F_OUT_M + 2u)), + vec3(pf(F_OUT_M + 3u), pf(F_OUT_M + 4u), pf(F_OUT_M + 5u)), + vec3(pf(F_OUT_M + 6u), pf(F_OUT_M + 7u), pf(F_OUT_M + 8u)), + ); + var r = mul3(om, d); + let yy = clamp(pf(F_OUT_Y) * r.x + pf(F_OUT_Y + 1u) * r.y + pf(F_OUT_Y + 2u) * r.z, 0.0, 1.0); var tg = 1.0; for (var k = 0u; k < 3u; k++) { let cc = r[k]; @@ -439,5 +464,6 @@ fn main(@builtin(global_invocation_id) gid: vec3) { let k = pf(F_GRAIN_AMT) * g * (0.35 + 2.6 * lum * (1.0 - lum)); e = e + k; } + e = vec3(out_encode(e.x), out_encode(e.y), out_encode(e.z)); out[i] = enc8(e.x) | (enc8(e.y) << 8u) | (enc8(e.z) << 16u) | (255u << 24u); } diff --git a/crates/gpu/tests/equivalence.rs b/crates/gpu/tests/equivalence.rs index d6fdab543..619114764 100644 --- a/crates/gpu/tests/equivalence.rs +++ b/crates/gpu/tests/equivalence.rs @@ -462,3 +462,32 @@ fn red_eye_matches() { }; check("red eye + spot (cpu stage)", &src, &info, &s, &RenderRequest::fit(600, 600)); } + +fn saturated(s: &mut DevelopSettings) { + typical(s); + s.color.saturation = 70.0; + s.color.vibrance = 40.0; + s.curve.highlights = 30.0; + s.curve.shadows = -20.0; + s.grain.amount = 30.0; +} + +#[test] +fn output_spaces_match() { + if !gpu() { + return; + } + // Export colour spaces: matrix to the target primaries, gamut mapping into the target gamut and + // its encoding curve, on both renderers. Saturated edits push colours to the gamut edges. + use lightcraft_pipeline::OutputSpace; + let src = scene(0, 900, 600); + let raw = SourceInfo { raw: true, ..Default::default() }; + for space in OutputSpace::ALL { + for (name, edit) in [("typical", typical as Edit), ("saturated + curves + grain", saturated as Edit)] { + let mut s = DevelopSettings::default(); + edit(&mut s); + let req = RenderRequest { space, ..RenderRequest::fit(640, 640) }; + check(&format!("{space:?} {name}"), &src, &raw, &s, &req); + } + } +} diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 663c8f08a..57f289c12 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -188,6 +188,7 @@ pub fn helper_tools(has_ui: bool) -> Vec { "naming": {"type": "string", "description": "e.g. {name}-{seq}"}, "metadata": {"type": "string", "enum": ["all", "allExceptCamera", "copyright", "none"]}, "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"}, "watermark": {"description": "Text, or {text, size (fraction of short edge), opacity, anchor (topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight), inset, color [r,g,b], shadow}"} }), &[], @@ -534,6 +535,7 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, args: &Value) -> ToolResult { "metadata", "removeLocation", "watermark", + "colorSpace", ], ), ) diff --git a/crates/pipeline/Cargo.toml b/crates/pipeline/Cargo.toml index ff997b5c5..27d778164 100644 --- a/crates/pipeline/Cargo.toml +++ b/crates/pipeline/Cargo.toml @@ -20,3 +20,4 @@ serde = { workspace = true } lightcraft-scenes = { workspace = true } proptest = { workspace = true } image = { workspace = true } +serde_json = { workspace = true } diff --git a/crates/pipeline/src/finish.rs b/crates/pipeline/src/finish.rs index 0ab6199e4..83f1c0330 100644 --- a/crates/pipeline/src/finish.rs +++ b/crates/pipeline/src/finish.rs @@ -1,8 +1,8 @@ //! The per-pixel stage: everything after the spatial planes are ready, in one parallel pass. +use lightcraft_color::luminance_2020; use lightcraft_color::spline::{Lut1, MonotoneCurve}; use lightcraft_color::transfer::linear_to_srgb; -use lightcraft_color::{REC2020, SRGB, luminance_2020}; use lightcraft_develop::{DevelopSettings, LocalAdjustments, ToneCurve, VignetteStyle}; use lightcraft_geom::Point; use lightcraft_raster::Rgba8; @@ -10,6 +10,7 @@ use lightcraft_raster::Rgba8; use crate::colorops::ColorOps; use crate::geometry::Frame; use crate::local::log_lum; +use crate::output::{OutputSpace, OutputTrc}; use crate::tone::ToneMap; use crate::{Prepared, SourceInfo, for_rows}; @@ -153,7 +154,11 @@ pub struct FinishParams { /// Refine Saturation as 0..1 (1 = the curves' own saturation). pub refine_sat: f32, pub vig: Option, - pub to_srgb: [[f32; 3]; 3], + /// Linear Rec.2020 → linear output RGB, the output's luminance weights (gamut mapping) and its + /// encoding curve (see [`crate::output`]). + pub to_out: [[f32; 3]; 3], + pub out_luma: [f32; 3], + pub out_trc: OutputTrc, pub hl: f32, pub sh: f32, pub clar: f32, @@ -178,8 +183,18 @@ pub struct FinishParams { } impl FinishParams { - /// Parameters for a `w × h` render; `ev`/`air_pre` as in [`crate::Prepared`]. - pub fn new(s: &DevelopSettings, frame: &Frame, info: &SourceInfo, w: usize, h: usize, px_per_long: f64, air_pre: f32) -> FinishParams { + /// Parameters for a `w × h` render into `space`; `ev`/`air_pre` as in [`crate::Prepared`]. + #[allow(clippy::too_many_arguments)] + pub fn new( + s: &DevelopSettings, + frame: &Frame, + info: &SourceInfo, + w: usize, + h: usize, + px_per_long: f64, + air_pre: f32, + space: OutputSpace, + ) -> FinishParams { let effects = s.section_enabled("effects"); let (clar, tex, dehaze) = if effects { ((s.effects.clarity / 100.0) as f32, (s.effects.texture / 100.0) as f32, (s.effects.dehaze / 100.0) as f32) @@ -205,7 +220,9 @@ impl FinishParams { curves: curve_luts(&s.curve), refine_sat: (s.curve.refine_saturation / 100.0).clamp(0.0, 1.0) as f32, vig: if effects { vignette(s) } else { None }, - to_srgb: REC2020.to_space(&SRGB).to_f32(), + to_out: space.from_working(), + out_luma: space.luma(), + out_trc: space.trc(), hl: (s.light.highlights / 100.0) as f32, sh: (s.light.shadows / 100.0) as f32, clar, @@ -228,10 +245,45 @@ impl FinishParams { } } -pub(crate) fn finish(p: &Prepared, s: &DevelopSettings, frame: &Frame, info: &SourceInfo) -> Rgba8 { +pub(crate) fn finish(p: &Prepared, s: &DevelopSettings, frame: &Frame, info: &SourceInfo, space: OutputSpace) -> Rgba8 { let (w, h) = (p.img.width, p.img.height); - let fp = FinishParams::new(s, frame, info, w, h, p.px_per_long, p.air); - let FinishParams { tone, ops, curves, vig, to_srgb, hl, sh, clar, tex, dehaze, sharpen, sharpen_mask, air, air_pre, gain, ev, grain, .. } = &fp; + let fp = FinishParams::new(s, frame, info, w, h, p.px_per_long, p.air, space); + let trc = fp.out_trc; + let data = finish_with(p, &fp, |e| match trc { + OutputTrc::Srgb => [enc(e[0]), enc(e[1]), enc(e[2]), 255], + t => { + let x = e.map(|v| enc(t.encode(lightcraft_color::transfer::srgb_to_linear(v.clamp(0.0, 1.0))))); + [x[0], x[1], x[2], 255] + } + }); + Rgba8 { width: w, height: h, data } +} + +/// The per-pixel stage: every output pixel's colour in the output primaries, encoded with the sRGB +/// curve (tone curves and grain applied; not clamped), handed to `store` for the final encoding. +pub(crate) fn finish_with(p: &Prepared, fp: &FinishParams, store: impl Fn([f32; 3]) -> T + Sync + Send) -> Vec { + let (w, h) = (p.img.width, p.img.height); + let FinishParams { + tone, + ops, + curves, + vig, + to_out, + out_luma, + hl, + sh, + clar, + tex, + dehaze, + sharpen, + sharpen_mask, + air, + air_pre, + gain, + ev, + grain, + .. + } = fp; let (hl, sh, clar, tex, dehaze, sharpen, sharpen_mask, air, air_pre, gain, ev) = (*hl, *sh, *clar, *tex, *dehaze, *sharpen, *sharpen_mask, *air, *air_pre, *gain, *ev); let terms: Vec<[f32; MASK_TERMS]> = p.masks.iter().map(|m| mask_terms(&m.adjust)).collect(); @@ -240,8 +292,8 @@ pub(crate) fn finish(p: &Prepared, s: &DevelopSettings, frame: &Frame, info: &So let aspect = w as f32 / h as f32; let srgb = srgb_lut(); - let mut out = Rgba8::new(w, h); - for_rows(&mut out.data, w, |y, row| { + let mut out = vec![T::default(); w * h]; + for_rows(&mut out, w, |y, row| { for (x, px) in row.iter_mut().enumerate() { let i = y * w + x; let raw = p.img.data[i]; @@ -388,14 +440,14 @@ pub(crate) fn finish(p: &Prepared, s: &DevelopSettings, frame: &Frame, info: &So } } - // --- gamut map to sRGB (desaturate towards luminance until in range) - let m = to_srgb; + // --- gamut map to the output space (desaturate towards luminance until in range) + let m = to_out; let mut r = [ m[0][0] * d[0] + m[0][1] * d[1] + m[0][2] * d[2], m[1][0] * d[0] + m[1][1] * d[1] + m[1][2] * d[2], m[2][0] * d[0] + m[2][1] * d[1] + m[2][2] * d[2], ]; - let yy = (0.2126 * r[0] + 0.7152 * r[1] + 0.0722 * r[2]).clamp(0.0, 1.0); + let yy = (out_luma[0] * r[0] + out_luma[1] * r[1] + out_luma[2] * r[2]).clamp(0.0, 1.0); let mut t = 1.0f32; for c in r { if c < 0.0 { @@ -427,7 +479,7 @@ pub(crate) fn finish(p: &Prepared, s: &DevelopSettings, frame: &Frame, info: &So let k = amt * g * (0.35 + 2.6 * lum * (1.0 - lum)); e = e.map(|v| v + k); } - *px = [enc(e[0]), enc(e[1]), enc(e[2]), 255]; + *px = store(e); } }); out diff --git a/crates/pipeline/src/lib.rs b/crates/pipeline/src/lib.rs index 548febbd2..0b7596540 100644 --- a/crates/pipeline/src/lib.rs +++ b/crates/pipeline/src/lib.rs @@ -11,7 +11,7 @@ //! clarity, local adjustments (masks) //! 3. tone map — contrast / whites / blacks filmic curve on luminance, highlight desaturation //! 4. colour — vibrance, saturation, colour mixer, colour grading, B&W (OkLCh) -//! 5. display — gamut map to sRGB, encode, tone curves (parametric + point), vignette, grain +//! 5. display — gamut map to the output space (sRGB unless [`RenderRequest::space`] says otherwise), encode, tone curves (parametric + point), vignette, grain //! //! Spatial parameters are specified relative to the image's long edge, so a 400 px preview and a //! 60 MP export look alike. @@ -29,6 +29,7 @@ pub mod geometry; pub mod local; pub mod masks; pub mod optics; +pub mod output; pub mod profiles; pub mod redeye; pub mod spots; @@ -37,6 +38,7 @@ pub mod transform; pub mod upright; pub mod visualize; +pub use output::{OutputSpace, OutputTrc}; pub use visualize::Overlay; use lightcraft_develop::{DevelopSettings, Treatment}; @@ -84,11 +86,13 @@ pub struct RenderRequest { pub apply_crop: bool, /// A diagnostic view drawn over the result (e.g. Point Color's "visualize range"). pub overlay: Overlay, + /// Colour space of the result (exports; previews stay sRGB). + pub space: OutputSpace, } impl RenderRequest { pub fn fit(max_w: usize, max_h: usize) -> Self { - Self { max_w, max_h, quality: Quality::Full, apply_crop: true, overlay: Overlay::None } + Self { max_w, max_h, quality: Quality::Full, apply_crop: true, overlay: Overlay::None, space: OutputSpace::Srgb } } } @@ -344,7 +348,7 @@ fn render_impl(src: Src<'_>, info: &SourceInfo, s: &DevelopSettings, req: &Rende if let Some((a, c)) = shared { c.put(CacheEntry { src: a.clone(), geo, sampled, lin: Some((lin_key, lin)), planes }); } - let image = finish::finish(&prep, s, frame, info); + let image = finish::finish(&prep, s, frame, info, req.space); lap("finish", &mut t); let histogram = Histogram::of_srgb8(&image); lap("histogram", &mut t); diff --git a/crates/pipeline/src/output.rs b/crates/pipeline/src/output.rs new file mode 100644 index 000000000..fc532bb77 --- /dev/null +++ b/crates/pipeline/src/output.rs @@ -0,0 +1,175 @@ +//! Output encoding: the colour space (primaries + transfer curve) and sample format a render is +//! delivered in. Previews are always 8-bit sRGB; exports may ask for a wide-gamut space and/or +//! 16-bit or linear float samples. +//! +//! The per-pixel stage works in scene-linear Rec.2020 and ends by converting to the target's +//! primaries, gamut mapping into the *target* gamut, and encoding. Tone curves and grain operate on +//! sRGB-curve-encoded values of the target primaries (identical to the sRGB path when the target is +//! sRGB); afterwards the values are re-encoded with the target's own curve. + +use lightcraft_color::{ADOBE_RGB, DISPLAY_P3, PROPHOTO, REC2020, RgbSpace, SRGB}; +use serde::{Deserialize, Serialize}; + +/// The RGB space an image is rendered into. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum OutputSpace { + #[default] + Srgb, + DisplayP3, + /// Primaries and gamma of the published Adobe RGB (1998) specification ("compatible": our own + /// profile, built from the published numbers). + AdobeRgb, + ProPhoto, + Rec2020, +} + +/// The encoding curve of an [`OutputSpace`]. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum OutputTrc { + /// IEC 61966-2-1 (sRGB, Display P3). + Srgb, + /// Pure power law: encoded = linear^(1/g). + Gamma(f32), + /// ITU-R BT.709 / BT.2020 OETF. + Rec709, +} + +impl OutputTrc { + /// Linear → encoded (input clamped to 0..1). + pub fn encode(self, v: f32) -> f32 { + let v = v.clamp(0.0, 1.0); + match self { + OutputTrc::Srgb => lightcraft_color::transfer::linear_to_srgb(v), + OutputTrc::Gamma(g) => v.powf(1.0 / g), + OutputTrc::Rec709 => { + if v < 0.018 { + v * 4.5 + } else { + 1.099 * v.powf(0.45) - 0.099 + } + } + } + } + + /// Encoded → linear (input clamped to 0..1). + pub fn decode(self, e: f32) -> f32 { + let e = e.clamp(0.0, 1.0); + match self { + OutputTrc::Srgb => lightcraft_color::transfer::srgb_to_linear(e), + OutputTrc::Gamma(g) => e.powf(g), + OutputTrc::Rec709 => { + if e < 0.081 { + e / 4.5 + } else { + ((e + 0.099) / 1.099).powf(1.0 / 0.45) + } + } + } + } + + /// Code for the GPU kernel: 0 sRGB, 1 gamma, 2 Rec.709. + pub fn code(self) -> (u32, f32) { + match self { + OutputTrc::Srgb => (0, 1.0), + OutputTrc::Gamma(g) => (1, g), + OutputTrc::Rec709 => (2, 1.0), + } + } +} + +impl OutputSpace { + pub const ALL: [OutputSpace; 5] = [OutputSpace::Srgb, OutputSpace::DisplayP3, OutputSpace::AdobeRgb, OutputSpace::ProPhoto, OutputSpace::Rec2020]; + + pub fn rgb_space(self) -> RgbSpace { + match self { + OutputSpace::Srgb => SRGB, + OutputSpace::DisplayP3 => DISPLAY_P3, + OutputSpace::AdobeRgb => ADOBE_RGB, + OutputSpace::ProPhoto => PROPHOTO, + OutputSpace::Rec2020 => REC2020, + } + } + + pub fn trc(self) -> OutputTrc { + match self { + OutputSpace::Srgb | OutputSpace::DisplayP3 => OutputTrc::Srgb, + OutputSpace::AdobeRgb => OutputTrc::Gamma(563.0 / 256.0), + OutputSpace::ProPhoto => OutputTrc::Gamma(1.8), + OutputSpace::Rec2020 => OutputTrc::Rec709, + } + } + + /// User-facing name. + pub fn label(self) -> &'static str { + match self { + OutputSpace::Srgb => "sRGB", + OutputSpace::DisplayP3 => "Display P3", + OutputSpace::AdobeRgb => "Adobe RGB (1998) compatible", + OutputSpace::ProPhoto => "ProPhoto RGB", + OutputSpace::Rec2020 => "Rec. 2020", + } + } + + pub fn parse(s: &str) -> Option { + let k: String = s.chars().filter(|c| c.is_ascii_alphanumeric()).collect::().to_ascii_lowercase(); + Some(match k.as_str() { + "srgb" => OutputSpace::Srgb, + "displayp3" | "p3" => OutputSpace::DisplayP3, + "adobergb" | "adobergb1998" | "adobergb1998compatible" | "widegamutrgb" => OutputSpace::AdobeRgb, + "prophoto" | "prophotorgb" | "rommrgb" => OutputSpace::ProPhoto, + "rec2020" | "bt2020" | "itur2020" => OutputSpace::Rec2020, + _ => return None, + }) + } + + /// Linear Rec.2020 → linear target RGB (Bradford-adapted for D50 targets), row-major. + pub fn from_working(self) -> [[f32; 3]; 3] { + REC2020.to_space(&self.rgb_space()).to_f32() + } + + /// Luminance weights of the target's linear RGB, as the gamut mapper uses them. sRGB keeps the + /// rounded BT.709 weights the pipeline has always used (byte-identical previews and exports). + pub fn luma(self) -> [f32; 3] { + match self { + OutputSpace::Srgb => [0.2126, 0.7152, 0.0722], + o => o.rgb_space().luma().map(|v| v as f32), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn trcs_invert() { + for o in OutputSpace::ALL { + let t = o.trc(); + for i in 0..=100 { + let x = i as f32 / 100.0; + assert!((t.decode(t.encode(x)) - x).abs() < 1e-5, "{o:?} {x}"); + } + } + } + + #[test] + fn parse_and_serde_names() { + for o in OutputSpace::ALL { + let s = serde_json::to_value(o).unwrap(); + assert_eq!(OutputSpace::parse(s.as_str().unwrap()), Some(o)); + assert_eq!(OutputSpace::parse(o.label()), Some(o), "{}", o.label()); + } + } + + #[test] + fn white_maps_to_white() { + for o in OutputSpace::ALL { + let m = o.from_working(); + for row in m { + assert!((row.iter().sum::() - 1.0).abs() < 1e-4, "{o:?}"); + } + assert!((o.luma().iter().sum::() - 1.0).abs() < 1e-4); + } + } +} diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 0ac333088..ac684fc73 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -109,6 +109,24 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { &[(F::Jpeg, "JPEG"), (F::Png, "PNG"), (F::Tiff, "TIFF"), (F::Webp, "WebP"), (F::Avif, "AVIF")], &mut opts.format, ); + if opts.format == F::Avif { + ui.label(egui::RichText::new("Color space: sRGB (AVIF)").color(t.text_dim)); + } else { + use lightcraft_engine::export::OutputSpace as C; + choices( + ui, + "Color space", + "exportColorSpace", + &[ + (C::Srgb, "sRGB"), + (C::DisplayP3, "P3"), + (C::AdobeRgb, "Adobe RGB"), + (C::ProPhoto, "ProPhoto"), + (C::Rec2020, "Rec.2020"), + ], + &mut opts.color_space, + ); + } if matches!(opts.format, F::Jpeg | F::Avif) { let mut q = opts.quality as f64; if num(ui, &QUALITY, &mut q) { @@ -292,6 +310,7 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result crate::merge::start_final(app, opts), diff --git a/docs/mcp.md b/docs/mcp.md index 02bdcb09b..8beaa8321 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -85,7 +85,7 @@ During development you can also point the client at `cargo run --release -p ligh | `apply_preset {preset, amount?, ids?}` | Apply a preset (ids from `cmd_presets_list`) | | `crop {id?, rect?, angle?, reset?}` | Normalized crop rect `[x0,y0,x1,y1]` and straighten angle | | `render_photo {id?, size?, format?, path?}` | Render with current settings → **image content** (PNG, or JPEG with `format: "jpeg"`), long edge `size` (default 1024) | -| `export {path, id?, longEdge?, quality?}` | Full-quality render to `.png` / `.jpg` / `.tif` / `.webp` (headless; the desktop app writes PNG) | +| `export {path, id?, longEdge?, quality?, colorSpace?}` | Full-quality render to `.png` / `.jpg` / `.tif` / `.webp` (headless; the desktop app writes PNG) | Tools taking `id` make that photo active first; without it they act on the active photo. diff --git a/docs/parity.md b/docs/parity.md index 19d1af2d2..f8e1321cb 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -55,41 +55,39 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table Ordered by tier, then user value, then (low) effort. Take the first one nobody is working on. -1. **LR-EXP-COLORSPACE** (P0) — export is sRGB only. Add Display P3 / Adobe RGB-compatible / ProPhoto-compatible / - Rec.2020 output (our own primaries + ICC from `crates/codecs/src/icc.rs`). High value, low–medium effort. -2. **LR-EXP-PREVIOUS + LR-EXP-DIALOG presets** (P0) — remember the last export settings (⌘E "export with previous") +1. **LR-EXP-PREVIOUS + LR-EXP-DIALOG presets** (P0) — remember the last export settings (⌘E "export with previous") and offer built-in export presets (small JPEG, full JPEG, TIFF). Medium value, low effort. -3. **LR-LIB-SHOWFINDER** (P0) — reveal the original in Finder/Explorer/file manager. Medium value, low effort. -4. **LR-EDIT-EFFECTS-VIGNETTE** (P0) — the vignette style (highlight priority / colour priority / paint overlay) is +2. **LR-LIB-SHOWFINDER** (P0) — reveal the original in Finder/Explorer/file manager. Medium value, low effort. +3. **LR-EDIT-EFFECTS-VIGNETTE** (P0) — the vignette style (highlight priority / colour priority / paint overlay) is rendered but has no UI. Low effort. -5. **LR-VIEW-BEFOREAFTER** (P0) — add top/bottom and split top/bottom layouts (⌥Y). Low effort. -6. **LR-MASK-OVERLAY + LR-MASK-PINS** (P0/P1) — show the evaluated mask alpha as a coloured overlay (all mask +4. **LR-VIEW-BEFOREAFTER** (P0) — add top/bottom and split top/bottom layouts (⌥Y). Low effort. +5. **LR-MASK-OVERLAY + LR-MASK-PINS** (P0/P1) — show the evaluated mask alpha as a coloured overlay (all mask kinds, colour/opacity choice), not just brush dabs and outlines. High value, medium effort. -7. **LR-MASK-BRUSH + LR-MASK-SLIDERS** (P0) — apply the stored Auto Mask flag (edge-aware brush) and the local +6. **LR-MASK-BRUSH + LR-MASK-SLIDERS** (P0) — apply the stored Auto Mask flag (edge-aware brush) and the local Noise / Moiré / Defringe sliders, which are stored but not rendered. High value, medium effort. -8. **LR-REM-SPOT-EDIT + LR-REM-BRUSH-PARAMS** (P0) — select a spot pin, move target/source, delete with ⌫, +7. **LR-REM-SPOT-EDIT + LR-REM-BRUSH-PARAMS** (P0) — select a spot pin, move target/source, delete with ⌫, feather/opacity sliders, `[`/`]` size keys. Medium effort. -9. **LR-VIEW-PHOTOGRID** (P0) — group the justified grid by capture date with headers. Low–medium effort. -10. **LR-CROP-STRAIGHTEN** (P0) — draw-along-the-horizon straighten tool (+ double-click auto). Low effort. -11. **LR-PRE-CREATE + LR-PRE-PANEL + LR-BEHAV-PREVIEW-HOVER** (P0/P1) — per-group checkboxes in Create Preset; +8. **LR-VIEW-PHOTOGRID** (P0) — group the justified grid by capture date with headers. Low–medium effort. +9. **LR-CROP-STRAIGHTEN** (P0) — draw-along-the-horizon straighten tool (+ double-click auto). Low effort. +10. **LR-PRE-CREATE + LR-PRE-PANEL + LR-BEHAV-PREVIEW-HOVER** (P0/P1) — per-group checkboxes in Create Preset; live preview while hovering presets/profiles/versions. Medium effort. -12. **LR-PROF-DROPDOWN** (P0) — favourites/recent in the profile menu (and later a browser, LR-PROF-BROWSER). -13. **LR-LIB-KEYWORD** (P0) — rename/delete a keyword library-wide; keyword list in the left panel for browsing. -14. **LR-PREF-GENERAL + LR-IMP-RAWDEFAULT** (P0/P1) — a Settings dialog: raw defaults (preset or camera-specific), +11. **LR-PROF-DROPDOWN** (P0) — favourites/recent in the profile menu (and later a browser, LR-PROF-BROWSER). +12. **LR-LIB-KEYWORD** (P0) — rename/delete a keyword library-wide; keyword list in the left panel for browsing. +13. **LR-PREF-GENERAL + LR-IMP-RAWDEFAULT** (P0/P1) — a Settings dialog: raw defaults (preset or camera-specific), XMP prefs, cache, GPU. Medium effort. -15. **LR-IMP-ADD-DIALOG + LR-IMP-LOCAL** (P0) — an import review grid with per-photo checkboxes and destination +14. **LR-IMP-ADD-DIALOG + LR-IMP-LOCAL** (P0) — an import review grid with per-photo checkboxes and destination album; browse folders before adding. Medium effort. -16. **LR-EXP-TYPE + LR-EXP-DIM** (P0) — Original (+XMP) and DNG export (writer exists in +15. **LR-EXP-TYPE + LR-EXP-DIM** (P0) — Original (+XMP) and DNG export (writer exists in `crates/raw/src/dngwrite.rs`), short edge / width / height / megapixels, "don't enlarge", ppi. -17. **LR-IMP-FORMATS** (P0) — CR3, compressed NEF/RAF/ORF, RW2 v4 (preview only today); HEIC/AVIF decode (no +16. **LR-IMP-FORMATS** (P0) — CR3, compressed NEF/RAF/ORF, RW2 v4 (preview only today); HEIC/AVIF decode (no permissive pure-Rust decoder yet). High value, high effort (clean sources needed). -18. **LR-VIEW-FULLSCREEN + LR-VIEW-NAVIGATOR + LR-VIEW-INFOOVERLAY** (P1) — view modes. Medium effort. -19. **LR-LIB-RENAME + LR-LIB-CAPTURETIME + LR-LIB-LABEL UI** (P1) — batch rename, capture-time edit, label menu/names. -20. **LR-EYE-RED** (P1) — red-eye correction (panel placeholder exists; no render). -21. **LR-EDIT-COLOR-POINTCOLOR, LR-EDIT-CALIB, LR-EDIT-LIGHT-CURVE-TAT, LR-EDIT-LIGHT-CURVE-REFINESAT** (P1) — +17. **LR-VIEW-FULLSCREEN + LR-VIEW-NAVIGATOR + LR-VIEW-INFOOVERLAY** (P1) — view modes. Medium effort. +18. **LR-LIB-RENAME + LR-LIB-CAPTURETIME + LR-LIB-LABEL UI** (P1) — batch rename, capture-time edit, label menu/names. +19. **LR-EYE-RED** (P1) — red-eye correction (panel placeholder exists; no render). +20. **LR-EDIT-COLOR-POINTCOLOR, LR-EDIT-CALIB, LR-EDIT-LIGHT-CURVE-TAT, LR-EDIT-LIGHT-CURVE-REFINESAT** (P1) — missing develop controls (calibration matters for XMP interop). -22. **LR-EXP-BITDEPTH + LR-EXP-COMPRESSION** (P1) — 16-bit TIFF/PNG and TIFF compression choice. -23. **LR-EDIT-OPTICS-PROFILE** (P1) — a lens-profile database of our own (embedded DNG/maker corrections work today). +21. **LR-EXP-BITDEPTH + LR-EXP-COMPRESSION** (P1) — 16-bit TIFF/PNG and TIFF compression choice. +22. **LR-EDIT-OPTICS-PROFILE** (P1) — a lens-profile database of our own (embedded DNG/maker corrections work today). ## Shortcuts: conflicts and missing bindings @@ -444,7 +442,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-EXP-QUALITY | JPEG quality | P0 | ✅ | `cmd:app.export` (`quality`, `limitKb`) | | | LR-EXP-BITDEPTH | Bit depth | P1 | ⬜ | | 8-bit only | | LR-EXP-COMPRESSION | TIFF compression | P1 | ⬜ | | always Deflate | -| LR-EXP-COLORSPACE | Output colour space | P0 | ⬜ | `crates/engine/src/export.rs` | sRGB only | +| LR-EXP-COLORSPACE | Output colour space | P0 | ✅ | `cmd:app.export` (`colorSpace`), `crates/pipeline/src/output.rs`, `crates/engine/src/export.rs` | sRGB, Display P3, Adobe RGB (1998) compatible, ProPhoto RGB, Rec. 2020: rendered from the working space with gamut mapping into the target gamut (CPU + GPU), own ICC profile embedded; AVIF stays sRGB (muxer has no ICC) | | LR-EXP-HDR | HDR output | P2 | ⬜ | | | | LR-EXP-SHARPEN | Output sharpening | P1 | ✅ | `cmd:app.export` (`sharpen`, `sharpenAmount`) | | | LR-EXP-METADATA | Metadata policy | P1 | ✅ | `cmd:app.export` (`metadata`, `removeLocation`) | | From 8f558d9c991a743f564c7158581b88f2177e377c Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 13:07:02 -0400 Subject: [PATCH 2/3] LR-EXP-COLORSPACE: align ICC tag data on 4-byte boundaries The profile encoder packs tags back to back, so the odd-length description of the "Adobe RGB (1998) compatible" profile misaligned the copyright tag and macOS ImageIO ignored the profile in PNG iCCP (sips showed none). Tags are re-laid at aligned offsets; already aligned profiles (sRGB, P3, ProPhoto, Rec. 2020) are byte-identical. Co-Authored-By: Claude Opus 5.5 --- crates/codecs/src/icc.rs | 58 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 57 insertions(+), 1 deletion(-) diff --git a/crates/codecs/src/icc.rs b/crates/codecs/src/icc.rs index c6ccff7c2..7850e61b9 100644 --- a/crates/codecs/src/icc.rs +++ b/crates/codecs/src/icc.rs @@ -146,7 +146,46 @@ pub fn write_matrix_trc(space: &RgbSpace, trc: &Trc) -> Vec { }; p.description = Some(ProfileText::Localizable(vec![LocalizableString::new("en".into(), "US".into(), desc)])); p.copyright = Some(ProfileText::Localizable(vec![LocalizableString::new("en".into(), "US".into(), "No copyright, use freely".into())])); - p.encode().unwrap_or_default() + align_tags(&p.encode().unwrap_or_default()) +} + +/// Re-lay a profile's tag data on 4-byte boundaries (ICC.1 §7.3.1). The encoder packs tags back to +/// back, so a tag of odd length (e.g. a `mluc` description with an odd number of characters) +/// misaligns every following tag — which some readers (macOS ImageIO for PNG `iCCP`) reject. +fn align_tags(b: &[u8]) -> Vec { + let be = |i: usize| b.get(i..i + 4).map(|s| u32::from_be_bytes([s[0], s[1], s[2], s[3]]) as usize); + let Some(n) = be(128) else { return b.to_vec() }; + let table_end = 132 + 12 * n; + let tags: Option> = (0..n).map(|t| Some((be(132 + 12 * t + 4)?, be(132 + 12 * t + 8)?))).collect(); + let Some(tags) = tags.filter(|t| table_end <= b.len() && t.iter().all(|&(o, l)| o >= table_end && o + l <= b.len())) else { + return b.to_vec(); + }; + let mut out = b[..table_end].to_vec(); + let mut placed: Vec<((usize, usize), usize)> = Vec::new(); + for (t, &(off, len)) in tags.iter().enumerate() { + let new_off = match placed.iter().find(|(k, _)| *k == (off, len)) { + // tags sharing data keep sharing it + Some(&(_, o)) => o, + None => { + while !out.len().is_multiple_of(4) { + out.push(0); + } + let o = out.len(); + out.extend_from_slice(&b[off..off + len]); + placed.push(((off, len), o)); + o + } + }; + out[132 + 12 * t + 4..132 + 12 * t + 8].copy_from_slice(&(new_off as u32).to_be_bytes()); + } + while !out.len().is_multiple_of(4) { + out.push(0); + } + let size = out.len() as u32; + out[0..4].copy_from_slice(&size.to_be_bytes()); + // the profile ID (MD5) would no longer match: clear it (= "not computed") + out[84..100].fill(0); + out } /// A profile for one of the well-known spaces with its standard curve. @@ -221,6 +260,23 @@ mod tests { } } + #[test] + fn tags_are_aligned() { + for n in NamedSpace::ALL { + for trc in [n.trc(), Trc::Linear] { + let b = write_matrix_trc(&n.rgb_space(), &trc); + assert_eq!(b.len() % 4, 0, "{n:?}"); + assert_eq!(u32::from_be_bytes(b[0..4].try_into().unwrap()) as usize, b.len()); + let count = u32::from_be_bytes(b[128..132].try_into().unwrap()) as usize; + for t in 0..count { + let off = u32::from_be_bytes(b[132 + 12 * t + 4..132 + 12 * t + 8].try_into().unwrap()); + assert_eq!(off % 4, 0, "{n:?} tag {t}"); + } + assert!(parse(&b).is_some_and(|i| i.named == Some(n)), "{n:?}"); + } + } + } + #[test] fn linear_profile() { let bytes = write_matrix_trc(&PROPHOTO, &Trc::Linear); From 03291d6f8306a0a571e37f20d416bb6a87d34f8e Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 13:07:02 -0400 Subject: [PATCH 3/3] LR-EXP-BITDEPTH: 16-bit PNG/TIFF, 32-bit float linear TIFF, 10-bit AVIF RenderRequest gains a sample format (OutputDepth: U8 for previews, U16, F32Linear); deep renders run the per-pixel stage with the exact sRGB curve and return Rendered::deep alongside the 8-bit image (GPU stays 8-bit: deep exports render on the CPU). Export encodes deep renders: 16-bit PNG and TIFF, 32-bit float linear TIFF with a linear ICC profile we generate, 10-bit AVIF (YCbCr planes into ravif). Output sharpening and watermarks work on deep images. ExportOptions.bitDepth with per-format defaults (TIFF 16-bit, others 8-bit), export dialog bit depth choice (remembered with the last export), MCP export schema. Tests: a smooth gradient has 1024 levels in a 16-bit TIFF vs 13 in 8-bit, float TIFF decodes to the same light as 16-bit, per-format depth rules, AVIF 10-bit. Co-Authored-By: Claude Opus 5.5 --- apps/lightcraft-web/src/wire.rs | 1 + apps/lightcraft-web/src/workers.rs | 6 +- crates/codecs/src/encode.rs | 57 ++++-- crates/engine/src/export.rs | 270 ++++++++++++++++++++++++--- crates/engine/src/media.rs | 18 +- crates/gpu/src/lib.rs | 3 +- crates/gpu/src/render.rs | 2 +- crates/mcp/src/tools.rs | 2 + crates/pipeline/src/finish.rs | 37 +++- crates/pipeline/src/lib.rs | 18 +- crates/pipeline/src/output.rs | 60 ++++++ crates/ui-egui/src/panels/dialogs.rs | 13 +- docs/mcp.md | 2 +- docs/parity.md | 4 +- 14 files changed, 434 insertions(+), 59 deletions(-) diff --git a/apps/lightcraft-web/src/wire.rs b/apps/lightcraft-web/src/wire.rs index 9dc5dd310..a84be4130 100644 --- a/apps/lightcraft-web/src/wire.rs +++ b/apps/lightcraft-web/src/wire.rs @@ -90,6 +90,7 @@ impl WireJob { overlay: lightcraft_engine::pipeline::Overlay::from_parts(self.overlay.0, self.overlay.1), // workers render previews (exports run in-process) space: lightcraft_engine::pipeline::OutputSpace::Srgb, + depth: lightcraft_engine::pipeline::OutputDepth::U8, } } diff --git a/apps/lightcraft-web/src/workers.rs b/apps/lightcraft-web/src/workers.rs index 9f282c164..50ae0f726 100644 --- a/apps/lightcraft-web/src/workers.rs +++ b/apps/lightcraft-web/src/workers.rs @@ -97,7 +97,7 @@ async fn handle(scope: &web_sys::DedicatedWorkerGlobalScope, backend: Option { let histogram = Histogram::of_srgb8(&image); - return Ok((Rendered { image, histogram }, 0)); + return Ok((Rendered { image, histogram, deep: None }, 0)); } None => cache_miss = true, } @@ -275,7 +275,7 @@ impl Workers { Some(image) if hist.len() == 1025 => { let ch = |k: usize| hist[k * 256..(k + 1) * 256].to_vec(); let histogram = Histogram { r: ch(0), g: ch(1), b: ch(2), luma: ch(3), total: hist[1024] }; - Ok(Rendered { image, histogram }) + Ok(Rendered { image, histogram, deep: None }) } _ => Err("render worker: bad image".to_string()), } @@ -344,7 +344,7 @@ impl RenderOffload for Workers { photo: job.photo, level: job.level, key: job.key, - rendered: Ok(Rendered { image, histogram }), + rendered: Ok(Rendered { image, histogram, deep: None }), loaded: None, quick: None, }; diff --git a/crates/codecs/src/encode.rs b/crates/codecs/src/encode.rs index 9bfc1f784..6dcb44ad4 100644 --- a/crates/codecs/src/encode.rs +++ b/crates/codecs/src/encode.rs @@ -290,15 +290,26 @@ pub fn encode_webp_lossless(img: &EncodeImage, meta: &EncodeMeta) -> Result Result> { img.validate()?; #[cfg(all(feature = "avif", not(target_arch = "wasm32")))] { - let Samples::U8(s) = img.samples else { - return Err(Error::Encode("AVIF encoder needs 8-bit samples".into())); + let mut enc = ravif::Encoder::new() + .with_quality(quality.clamp(1, 100) as f32) + .with_alpha_quality(quality.clamp(1, 100) as f32) + .with_speed(speed.clamp(1, 10)); + if let Some(exif) = meta.exif { + enc = enc.with_exif(exif.to_vec()); + } + let s = match img.samples { + Samples::U8(s) => s, + Samples::U16(s) => return avif_10bit(&enc, img, s), + Samples::F32(_) => return Err(Error::Encode("AVIF encoder needs 8- or 16-bit samples".into())), }; let n = img.width as usize * img.height as usize; let ch = img.channels as usize; @@ -312,13 +323,6 @@ pub fn encode_avif(img: &EncodeImage, quality: u8, speed: u8, meta: &EncodeMeta) _ => ravif::RGBA8::new(c[0], c[1], c[2], c[3]), }) .collect(); - let mut enc = ravif::Encoder::new() - .with_quality(quality.clamp(1, 100) as f32) - .with_alpha_quality(quality.clamp(1, 100) as f32) - .with_speed(speed.clamp(1, 10)); - if let Some(exif) = meta.exif { - enc = enc.with_exif(exif.to_vec()); - } let r = enc.encode_rgba(ravif::Img::new(&px[..], img.width as usize, img.height as usize)).map_err(|e| Error::Encode(e.to_string()))?; let _ = img.has_alpha(); Ok(r.avif_file) @@ -329,3 +333,32 @@ pub fn encode_avif(img: &EncodeImage, quality: u8, speed: u8, meta: &EncodeMeta) Err(Error::Encode("AVIF encoding is not available in this build".into())) } } + +/// 16-bit RGB(A) → 10-bit BT.601 full-range YCbCr planes (the matrix `ravif` uses for 8-bit input). +#[cfg(all(feature = "avif", not(target_arch = "wasm32")))] +fn avif_10bit(enc: &ravif::Encoder, img: &EncodeImage, s: &[u16]) -> Result> { + const K: [f32; 3] = [0.299, 0.587, 0.114]; + let n = img.width as usize * img.height as usize; + let ch = img.channels as usize; + let rgb = |c: &[u16]| -> [f32; 3] { + let v = |x: u16| x as f32 * (1023.0 / 65535.0); + match ch { + 1 | 2 => [v(c[0]); 3], + _ => [v(c[0]), v(c[1]), v(c[2])], + } + }; + let planes = s.chunks_exact(ch).take(n).map(|c| { + let [r, g, b] = rgb(c); + let y = K[0] * r + K[1] * g + K[2] * b; + let cb = (b - y) * (0.5 / (1.0 - K[2])) + 512.0; + let cr = (r - y) * (0.5 / (1.0 - K[0])) + 512.0; + [y, cb, cr].map(|v| v.round().clamp(0.0, 1023.0) as u16) + }); + let alpha_at = |c: &[u16]| if img.has_alpha() { c[ch - 1] } else { u16::MAX }; + let has_alpha = s.chunks_exact(ch).take(n).any(|c| alpha_at(c) != u16::MAX); + let alpha = has_alpha.then(|| s.chunks_exact(ch).take(n).map(move |c| ((alpha_at(c) as u32 * 1023 + 32767) / 65535) as u16)); + let r = enc + .encode_raw_planes_10_bit(img.width as usize, img.height as usize, planes, alpha, ravif::PixelRange::Full, ravif::MatrixCoefficients::BT601) + .map_err(|e| Error::Encode(e.to_string()))?; + Ok(r.avif_file) +} diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs index 7c27b50a1..cbb3269b1 100644 --- a/crates/engine/src/export.rs +++ b/crates/engine/src/export.rs @@ -3,9 +3,9 @@ //! published primaries and curves, optional output sharpening and a JPEG file-size limit. Pure (bytes in, bytes out) so the desktop //! 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_codecs::{ChromaSubsampling, EncodeImage, EncodeMeta, NamedSpace, Samples, TiffCompression, encode, icc}; use lightcraft_meta::{DateTime, Gps, Metadata}; -pub use lightcraft_pipeline::OutputSpace; +pub use lightcraft_pipeline::{DeepImage, DeepSamples, OutputDepth, OutputSpace}; use lightcraft_raster::Rgba8; use serde::{Deserialize, Serialize}; @@ -105,15 +105,49 @@ impl Default for Watermark { /// Inter SemiBold (OFL, see assets/ATTRIBUTION.md). static WATERMARK_FONT: &[u8] = include_bytes!("../../../assets/fonts/Inter-SemiBold.ttf"); -/// Draw `wm` onto `img` (straight sRGB alpha blending). +/// Draw `wm` onto `img` (straight alpha blending of the encoded values). pub fn draw_watermark(img: &mut Rgba8, wm: &Watermark) { + let width = img.width; + watermark_coverage(img.width, img.height, wm, |x, y, k, col| { + let p = &mut img.data[y * width + x]; + for c in 0..3 { + p[c] = (p[c] as f32 + (col[c] as f32 - p[c] as f32) * k).round() as u8; + } + }); +} + +/// Draw `wm` onto a high-bit-depth image (`wm.color` is in the image's space, 8-bit encoded; float +/// images are linear and blend in linear light). +pub fn draw_watermark_deep(img: &mut DeepImage, wm: &Watermark) { + let (width, trc) = (img.width, img.space.trc()); + match &mut img.samples { + DeepSamples::U16(v) => watermark_coverage(img.width, img.height, wm, |x, y, k, col| { + let i = (y * width + x) * 3; + for c in 0..3 { + let p = v[i + c] as f32; + v[i + c] = (p + (col[c] as f32 * 257.0 - p) * k).round().clamp(0.0, 65535.0) as u16; + } + }), + DeepSamples::F32(v) => watermark_coverage(img.width, img.height, wm, |x, y, k, col| { + let i = (y * width + x) * 3; + for c in 0..3 { + let target = trc.decode(col[c] as f32 / 255.0); + v[i + c] += (target - v[i + c]) * k; + } + }), + } +} + +/// Lay out `wm` on a `width × height` image and call `blend(x, y, coverage × opacity, colour)` for +/// every covered pixel (shadow pass first). +fn watermark_coverage(width: usize, height: usize, wm: &Watermark, mut blend_px: impl FnMut(usize, usize, f32, [u8; 3])) { use ab_glyph::{Font, FontRef, PxScale, ScaleFont, point}; let text = wm.text.trim(); - if text.is_empty() || img.width == 0 || img.height == 0 { + if text.is_empty() || width == 0 || height == 0 { return; } let Ok(font) = FontRef::try_from_slice(WATERMARK_FONT) else { return }; - let short = img.width.min(img.height) as f32; + let short = width.min(height) as f32; let px = (wm.size.clamp(0.005, 0.5) * short).max(6.0); let sf = font.as_scaled(PxScale::from(px)); // Lay out one line. @@ -131,7 +165,7 @@ pub fn draw_watermark(img: &mut Rgba8, wm: &Watermark) { } let (tw, th) = (x, sf.ascent() - sf.descent()); let inset = wm.inset.clamp(0.0, 0.4) * short; - let (w, h) = (img.width as f32, img.height as f32); + let (w, h) = (width as f32, height as f32); use Anchor::*; let ox = match wm.anchor { TopLeft | Left | BottomLeft => inset, @@ -145,14 +179,10 @@ pub fn draw_watermark(img: &mut Rgba8, wm: &Watermark) { }; let alpha = wm.opacity.clamp(0.0, 1.0); let mut blend = |gx: i32, gy: i32, cov: f32, col: [u8; 3], a: f32| { - if gx < 0 || gy < 0 || gx >= img.width as i32 || gy >= img.height as i32 { + if gx < 0 || gy < 0 || gx >= width as i32 || gy >= height as i32 { return; } - let k = (cov * a).clamp(0.0, 1.0); - let p = &mut img.data[gy as usize * img.width + gx as usize]; - for c in 0..3 { - p[c] = (p[c] as f32 + (col[c] as f32 - p[c] as f32) * k).round() as u8; - } + blend_px(gx as usize, gy as usize, (cov * a).clamp(0.0, 1.0), col); }; let passes: &[(f32, [u8; 3], f32)] = if wm.shadow { &[((px * 0.05).max(1.0), [0, 0, 0], 0.45), (0.0, wm.color, 1.0)] } else { &[(0.0, wm.color, 1.0)] }; @@ -202,6 +232,9 @@ pub struct ExportOptions { pub watermark: Option, /// Output colour space (AVIF is always sRGB: its muxer cannot embed a profile). pub color_space: OutputSpace, + /// 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, } impl Default for ExportOptions { @@ -218,13 +251,14 @@ impl Default for ExportOptions { remove_location: false, watermark: None, color_space: OutputSpace::Srgb, + bit_depth: None, } } } impl ExportOptions { /// Read options from command params (`format`, `quality`, `longEdge`, `limitKb`, `sharpen`, - /// `sharpenAmount`, `naming`, `metadata`, `removeLocation`, `watermark`, `colorSpace`). + /// `sharpenAmount`, `naming`, `metadata`, `removeLocation`, `watermark`, `colorSpace`, `bitDepth`). pub fn from_json(p: &serde_json::Value) -> Self { use serde_json::Value; let d = Self::default(); @@ -250,6 +284,31 @@ impl ExportOptions { } .filter(|w: &Watermark| !w.text.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), + } + } + + /// The sample format the file is written with (unsupported requests fall back to the closest). + pub fn effective_depth(&self) -> OutputDepth { + match (self.format, self.bit_depth) { + (ExportFormat::Jpeg | ExportFormat::Webp, _) => OutputDepth::U8, + (ExportFormat::Png, Some(16 | 32)) => OutputDepth::U16, + (ExportFormat::Png, _) => OutputDepth::U8, + (ExportFormat::Tiff, Some(8)) => OutputDepth::U8, + (ExportFormat::Tiff, Some(32)) => OutputDepth::F32Linear, + (ExportFormat::Tiff, _) => OutputDepth::U16, + (ExportFormat::Avif, Some(10 | 16 | 32)) => OutputDepth::U16, + (ExportFormat::Avif, _) => OutputDepth::U8, + } + } + + /// The bit depths `format` offers (value, label); the first is its default. + pub fn bit_depths(format: ExportFormat) -> &'static [(u8, &'static str)] { + match format { + ExportFormat::Jpeg | ExportFormat::Webp => &[(8, "8-bit")], + ExportFormat::Png => &[(8, "8-bit"), (16, "16-bit")], + ExportFormat::Tiff => &[(16, "16-bit"), (8, "8-bit"), (32, "32-bit float")], + ExportFormat::Avif => &[(8, "8-bit"), (10, "10-bit")], } } @@ -269,8 +328,43 @@ impl ExportOptions { /// Unsharp mask on an 8-bit image (separable box-blur approximation of a small Gaussian). pub fn output_sharpen(img: &mut Rgba8, target: SharpenFor, amount: SharpenAmount) { + let src: Vec<[f32; 3]> = img.data.iter().map(|p| [p[0] as f32, p[1] as f32, p[2] as f32]).collect(); + if let Some(out) = unsharp(img.width, img.height, &src, target, amount) { + for (p, v) in img.data.iter_mut().zip(out) { + for c in 0..3 { + p[c] = v[c].round().clamp(0.0, 255.0) as u8; + } + } + } +} + +/// [`output_sharpen`] on a high-bit-depth image (float images are sharpened in linear light). +pub fn output_sharpen_deep(img: &mut DeepImage, target: SharpenFor, amount: SharpenAmount) { + let (w, h) = (img.width, img.height); + match &mut img.samples { + DeepSamples::U16(v) => { + let src: Vec<[f32; 3]> = v.chunks_exact(3).map(|c| [c[0] as f32, c[1] as f32, c[2] as f32]).collect(); + if let Some(out) = unsharp(w, h, &src, target, amount) { + for (d, s) in v.iter_mut().zip(out.as_flattened()) { + *d = s.round().clamp(0.0, 65535.0) as u16; + } + } + } + DeepSamples::F32(v) => { + let src: Vec<[f32; 3]> = v.chunks_exact(3).map(|c| [c[0], c[1], c[2]]).collect(); + if let Some(out) = unsharp(w, h, &src, target, amount) { + for (d, s) in v.iter_mut().zip(out.as_flattened()) { + *d = s.max(0.0); + } + } + } + } +} + +/// The sharpened values of `src` (`w × h`), or `None` when nothing is to be done. +fn unsharp(w: usize, h: usize, src: &[[f32; 3]], target: SharpenFor, amount: SharpenAmount) -> Option> { let (radius, base) = match target { - SharpenFor::None => return, + SharpenFor::None => return None, SharpenFor::Screen => (1usize, 0.35f32), SharpenFor::Matte => (2, 0.6), SharpenFor::Glossy => (1, 0.5), @@ -281,11 +375,9 @@ pub fn output_sharpen(img: &mut Rgba8, target: SharpenFor, amount: SharpenAmount SharpenAmount::Standard => 1.0, SharpenAmount::High => 1.5, }; - let (w, h) = (img.width, img.height); if w < 3 || h < 3 { - return; + return None; } - let src: Vec<[f32; 3]> = img.data.iter().map(|p| [p[0] as f32, p[1] as f32, p[2] as f32]).collect(); let r = radius as isize; let box_pass = |inp: &[[f32; 3]], horizontal: bool| -> Vec<[f32; 3]> { let mut out = vec![[0.0; 3]; inp.len()]; @@ -307,13 +399,8 @@ pub fn output_sharpen(img: &mut Rgba8, target: SharpenFor, amount: SharpenAmount } out }; - let blur = box_pass(&box_pass(&src, true), false); - for (i, p) in img.data.iter_mut().enumerate() { - for c in 0..3 { - let v = src[i][c] + (src[i][c] - blur[i][c]) * k; - p[c] = v.round().clamp(0.0, 255.0) as u8; - } - } + let blur = box_pass(&box_pass(src, true), false); + Some(src.iter().zip(&blur).map(|(s, b)| std::array::from_fn(|c| s[c] + (s[c] - b[c]) * k)).collect()) } /// Encode a rendered display-referred image according to `o` (its pixels must already be in @@ -398,6 +485,44 @@ pub fn srgb8_in(space: OutputSpace, c: [u8; 3]) -> [u8; 3] { }) } +/// Encode a render according to `o`: its high-bit-depth samples when it has them (16-bit PNG/TIFF, +/// 10-bit AVIF, 32-bit float linear TIFF), else its 8-bit image. +pub fn encode_rendered(r: &lightcraft_pipeline::Rendered, o: &ExportOptions, meta: Option<&Metadata>) -> Result, String> { + match &r.deep { + Some(d) if o.effective_depth() != OutputDepth::U8 => encode_deep(d, o, meta), + _ => encode_with_metadata(&r.image, o, meta), + } +} + +/// 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), ..wm.clone() }; + draw_watermark_deep(&mut img, &wm); + } + 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)), + }; + 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 (w, h) = (img.width as u32, img.height as u32); + let e = match &img.samples { + DeepSamples::U16(v) => EncodeImage::new(w, h, 3, Samples::U16(v)), + DeepSamples::F32(v) => EncodeImage::new(w, h, 3, Samples::F32(v)), + }; + let r = match o.format { + ExportFormat::Png => encode::encode_png(&e, &meta), + ExportFormat::Tiff => encode::encode_tiff(&e, TiffCompression::Deflate, &meta), + ExportFormat::Avif => encode::encode_avif(&e, o.quality, 8, &meta), + f => return Err(format!("{f:?} export is 8-bit only")), + }; + 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(); @@ -454,8 +579,8 @@ pub fn export_photo(session: &mut crate::Session, id: lightcraft_catalog::PhotoI 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_export(id, size, o.effective_space())?; - let bytes = encode_with_metadata(&r.image, o, meta.as_ref())?; + let r = session.render_export(id, size, o.effective_space(), o.effective_depth())?; + let bytes = encode_rendered(&r, o, meta.as_ref())?; Ok(Exported { file_name: o.file_name(&stem, seq), bytes, width: r.image.width, height: r.image.height }) } @@ -632,4 +757,97 @@ mod tests { let p3 = srgb8_in(OutputSpace::DisplayP3, [255, 0, 0]); assert!(p3[0] < 255 && p3[1] > 0, "{p3:?}"); } + + /// A shallow horizontal grey ramp (few 8-bit levels), rendered at `depth`. + fn ramp(depth: OutputDepth, space: OutputSpace) -> lightcraft_pipeline::Rendered { + let src = lightcraft_raster::Rgb32f::from_fn(1024, 4, |x, _| [0.10 + 0.03 * x as f32 / 1023.0; 3]); + let req = lightcraft_pipeline::RenderRequest { depth, space, ..lightcraft_pipeline::RenderRequest::fit(1024, 4) }; + lightcraft_pipeline::render(&src, &Default::default(), &Default::default(), &req) + } + + fn distinct_levels(bytes: &[u8]) -> (usize, lightcraft_codecs::Decoded) { + let d = lightcraft_codecs::decode(bytes, Default::default()).expect("decodes"); + let mut v: Vec = (0..d.image.width).map(|x| d.image.get(x, 1)[1].to_bits()).collect(); + v.dedup(); + (v.len(), d) + } + + #[test] + fn sixteen_bit_tiff_and_png_have_no_banding() { + let tiff = |bit_depth| ExportOptions { format: ExportFormat::Tiff, bit_depth, ..Default::default() }; + assert_eq!(tiff(None).effective_depth(), OutputDepth::U16, "TIFF defaults to 16-bit"); + let r16 = ramp(OutputDepth::U16, OutputSpace::Srgb); + assert!(matches!(r16.deep.as_ref().unwrap().samples, DeepSamples::U16(_))); + let (levels16, d16) = distinct_levels(&encode_rendered(&r16, &tiff(None), None).unwrap()); + let r8 = ramp(OutputDepth::U8, OutputSpace::Srgb); + let (levels8, _) = distinct_levels(&encode_rendered(&r8, &tiff(Some(8)), None).unwrap()); + eprintln!("gradient levels: 16-bit TIFF {levels16}, 8-bit TIFF {levels8}"); + assert!(levels8 < 40, "{levels8}"); + assert!(levels16 > 10 * levels8, "16-bit: {levels16} levels vs 8-bit: {levels8}"); + assert_eq!(d16.space.named, Some(NamedSpace::Srgb)); + // the 8-bit preview of a deep render matches the 8-bit render + for (a, b) in r16.image.data.iter().zip(&r8.image.data) { + assert!(a[1].abs_diff(b[1]) <= 1, "{a:?} {b:?}"); + } + let png = ExportOptions { format: ExportFormat::Png, bit_depth: Some(16), color_space: OutputSpace::DisplayP3, ..Default::default() }; + let rp = ramp(OutputDepth::U16, OutputSpace::DisplayP3); + let bytes = encode_rendered(&rp, &png, None).unwrap(); + assert_eq!(bytes[24], 16, "PNG IHDR bit depth"); + let (levels, d) = distinct_levels(&bytes); + assert!(levels > 10 * levels8, "{levels}"); + assert_eq!(d.space.named, Some(NamedSpace::DisplayP3)); + // sharpening and watermarking work on deep images too + let o = ExportOptions { + sharpen: SharpenFor::Matte, + watermark: Some(Watermark { text: "LC".into(), size: 0.5, ..Default::default() }), + ..tiff(None) + }; + assert!(encode_rendered(&r16, &o, None).is_ok()); + } + + #[test] + fn float_tiff_is_linear_with_a_linear_profile() { + let o = ExportOptions { format: ExportFormat::Tiff, bit_depth: Some(32), color_space: OutputSpace::ProPhoto, ..Default::default() }; + assert_eq!(o.effective_depth(), OutputDepth::F32Linear); + let rf = ramp(OutputDepth::F32Linear, OutputSpace::ProPhoto); + let (levels, df) = distinct_levels(&encode_rendered(&rf, &o, None).unwrap()); + assert!(levels > 500, "{levels}"); + assert_eq!(df.space.named, Some(NamedSpace::ProPhoto)); + assert!(df.space.trc.as_ref().is_some_and(|t| t[0].is_linear()), "{:?}", df.space.trc); + // decodes to the same light as the 16-bit gamma-encoded export + let o16 = ExportOptions { bit_depth: Some(16), ..o.clone() }; + let (_, d16) = distinct_levels(&encode_rendered(&ramp(OutputDepth::U16, OutputSpace::ProPhoto), &o16, None).unwrap()); + for x in [0, 300, 700, 1023] { + let (a, b) = (df.image.get(x, 1)[1], d16.image.get(x, 1)[1]); + assert!((a - b).abs() < 2e-4, "{x}: {a} vs {b}"); + } + } + + #[test] + fn bit_depth_options_per_format() { + let d = |format, bit_depth| ExportOptions { format, bit_depth, ..Default::default() }.effective_depth(); + assert_eq!(d(ExportFormat::Jpeg, Some(16)), OutputDepth::U8); + assert_eq!(d(ExportFormat::Webp, None), OutputDepth::U8); + assert_eq!(d(ExportFormat::Png, None), OutputDepth::U8); + assert_eq!(d(ExportFormat::Png, Some(16)), OutputDepth::U16); + assert_eq!(d(ExportFormat::Avif, Some(10)), OutputDepth::U16); + assert_eq!(ExportOptions::from_json(&serde_json::json!({"bitDepth": 16})).bit_depth, Some(16)); + assert_eq!(ExportOptions::from_json(&serde_json::json!({"bitDepth": 12})).bit_depth, None); + for f in [ExportFormat::Jpeg, ExportFormat::Png, ExportFormat::Tiff, ExportFormat::Webp, ExportFormat::Avif] { + let first = ExportOptions::bit_depths(f)[0].0; + let def = d(f, None); + assert_eq!(d(f, Some(first)), def, "{f:?}: the first choice is the default"); + } + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn avif_ten_bit() { + let o = ExportOptions { format: ExportFormat::Avif, bit_depth: Some(10), quality: 60, ..Default::default() }; + let r = ramp(OutputDepth::U16, OutputSpace::Srgb); + match encode_rendered(&r, &o, None) { + Ok(b) => assert_eq!(&b[4..8], b"ftyp"), + Err(e) => assert!(e.contains("not available"), "{e}"), + } + } } diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 98adc8391..e04744523 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -277,7 +277,7 @@ impl RenderJob { photo: self.photo, level: self.level, key: self.key, - rendered: Ok(Rendered { image, histogram }), + rendered: Ok(Rendered { image, histogram, deep: None }), loaded: None, quick: None, }; @@ -356,7 +356,7 @@ impl QuickJob { let done = |rendered: Result, quick| RenderResult { photo, level: SourceLevel::Thumb, key, rendered, loaded: None, quick }; let rendered = |image: Rgba8| { let histogram = Histogram::of_srgb8(&image); - Ok(Rendered { image, histogram }) + Ok(Rendered { image, histogram, deep: None }) }; for (cache, k) in &self.cached { if let Some(img) = cache.get(*k) { @@ -518,11 +518,19 @@ impl crate::Session { r.rendered } - /// Synchronous render for export: like [`Self::render_now`], into colour space `space`. - pub fn render_export(&mut self, id: PhotoId, max: usize, space: lightcraft_pipeline::OutputSpace) -> Result { + /// Synchronous render for export: like [`Self::render_now`], into colour space `space` with + /// sample format `depth`. + pub fn render_export( + &mut self, + id: PhotoId, + max: usize, + space: lightcraft_pipeline::OutputSpace, + depth: lightcraft_pipeline::OutputDepth, + ) -> Result { let mut job = self.render_job(id, max, max, false, true).ok_or("no such photo")?; job.request.space = space; - job.key ^= (space as u64 + 1).wrapping_mul(0xa076_1d64_78bd_642f); + job.request.depth = depth; + job.key ^= (space as u64 + 1).wrapping_mul(0xa076_1d64_78bd_642f) ^ (depth as u64 + 1).wrapping_mul(0xe703_7ed1_a0b4_28db); let r = job.run(); self.accept(&r); r.rendered diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index ee88430b4..21bc42296 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -119,7 +119,8 @@ pub fn adapter_name() -> Option { pub fn render(src: &Arc, info: &SourceInfo, s: &DevelopSettings, req: &RenderRequest, stages: Option<&StageCache>) -> Option { #[cfg(not(target_arch = "wasm32"))] { - if !enabled() { + // the kernel writes 8-bit output: high-bit-depth exports render on the CPU + if !enabled() || req.depth != lightcraft_pipeline::OutputDepth::U8 { return None; } let gpu = device()?; diff --git a/crates/gpu/src/render.rs b/crates/gpu/src/render.rs index 8f0bf08fe..efdd1c50f 100644 --- a/crates/gpu/src/render.rs +++ b/crates/gpu/src/render.rs @@ -488,7 +488,7 @@ pub fn render( lap("histogram", &mut t, &mut cx); let mut image = image; lightcraft_pipeline::visualize::apply(&mut image, req.overlay, &plan); - Some(Rendered { image, histogram }) + Some(Rendered { image, histogram, deep: None }) } /// The white-balanced, retouched, denoised image. diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 57f289c12..2f441eb99 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -189,6 +189,7 @@ pub fn helper_tools(has_ui: bool) -> Vec { "metadata": {"type": "string", "enum": ["all", "allExceptCamera", "copyright", "none"]}, "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"}, "watermark": {"description": "Text, or {text, size (fraction of short edge), opacity, anchor (topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight), inset, color [r,g,b], shadow}"} }), &[], @@ -536,6 +537,7 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, args: &Value) -> ToolResult { "removeLocation", "watermark", "colorSpace", + "bitDepth", ], ), ) diff --git a/crates/pipeline/src/finish.rs b/crates/pipeline/src/finish.rs index 83f1c0330..688d5b892 100644 --- a/crates/pipeline/src/finish.rs +++ b/crates/pipeline/src/finish.rs @@ -10,7 +10,7 @@ use lightcraft_raster::Rgba8; use crate::colorops::ColorOps; use crate::geometry::Frame; use crate::local::log_lum; -use crate::output::{OutputSpace, OutputTrc}; +use crate::output::{DeepImage, DeepSamples, OutputDepth, OutputSpace, OutputTrc}; use crate::tone::ToneMap; use crate::{Prepared, SourceInfo, for_rows}; @@ -249,7 +249,7 @@ pub(crate) fn finish(p: &Prepared, s: &DevelopSettings, frame: &Frame, info: &So let (w, h) = (p.img.width, p.img.height); let fp = FinishParams::new(s, frame, info, w, h, p.px_per_long, p.air, space); let trc = fp.out_trc; - let data = finish_with(p, &fp, |e| match trc { + let data = finish_with(p, &fp, false, |e| match trc { OutputTrc::Srgb => [enc(e[0]), enc(e[1]), enc(e[2]), 255], t => { let x = e.map(|v| enc(t.encode(lightcraft_color::transfer::srgb_to_linear(v.clamp(0.0, 1.0))))); @@ -259,9 +259,38 @@ pub(crate) fn finish(p: &Prepared, s: &DevelopSettings, frame: &Frame, info: &So Rgba8 { width: w, height: h, data } } +/// [`finish`] into 16-bit display-encoded or 32-bit float linear samples (output primaries). +pub(crate) fn finish_deep(p: &Prepared, s: &DevelopSettings, frame: &Frame, info: &SourceInfo, space: OutputSpace, depth: OutputDepth) -> DeepImage { + use lightcraft_color::transfer::srgb_to_linear; + let (w, h) = (p.img.width, p.img.height); + let fp = FinishParams::new(s, frame, info, w, h, p.px_per_long, p.air, space); + let trc = fp.out_trc; + let samples = match depth { + OutputDepth::F32Linear => { + let v = finish_with(p, &fp, true, |e| e.map(|v| srgb_to_linear(v.clamp(0.0, 1.0)))); + DeepSamples::F32(v.into_flattened()) + } + _ => { + let q = |v: f32| (v.clamp(0.0, 1.0) * 65535.0 + 0.5) as u16; + let v = finish_with(p, &fp, true, |e| match trc { + OutputTrc::Srgb => e.map(q), + t => e.map(|v| q(t.encode(srgb_to_linear(v.clamp(0.0, 1.0))))), + }); + DeepSamples::U16(v.into_flattened()) + } + }; + DeepImage { width: w, height: h, space, samples } +} + /// The per-pixel stage: every output pixel's colour in the output primaries, encoded with the sRGB /// curve (tone curves and grain applied; not clamped), handed to `store` for the final encoding. -pub(crate) fn finish_with(p: &Prepared, fp: &FinishParams, store: impl Fn([f32; 3]) -> T + Sync + Send) -> Vec { +/// `exact`: encode with the exact sRGB curve instead of the (8/10-bit accurate) table. +pub(crate) fn finish_with( + p: &Prepared, + fp: &FinishParams, + exact: bool, + store: impl Fn([f32; 3]) -> T + Sync + Send, +) -> Vec { let (w, h) = (p.img.width, p.img.height); let FinishParams { tone, @@ -461,7 +490,7 @@ pub(crate) fn finish_with(p: &Prepared, fp: &FinishPar } // --- encode, curves, grain - let mut e = r.map(|v| encode_srgb(srgb, v)); + let mut e = if exact { r.map(|v| linear_to_srgb(v.clamp(0.0, 1.0))) } else { r.map(|v| encode_srgb(srgb, v)) }; if let Some(l) = curves { let e0 = e; e = [l[0].eval(e[0]), l[1].eval(e[1]), l[2].eval(e[2])]; diff --git a/crates/pipeline/src/lib.rs b/crates/pipeline/src/lib.rs index 0b7596540..b2f3e81df 100644 --- a/crates/pipeline/src/lib.rs +++ b/crates/pipeline/src/lib.rs @@ -38,7 +38,7 @@ pub mod transform; pub mod upright; pub mod visualize; -pub use output::{OutputSpace, OutputTrc}; +pub use output::{DeepImage, DeepSamples, OutputDepth, OutputSpace, OutputTrc}; pub use visualize::Overlay; use lightcraft_develop::{DevelopSettings, Treatment}; @@ -88,17 +88,22 @@ pub struct RenderRequest { pub overlay: Overlay, /// Colour space of the result (exports; previews stay sRGB). pub space: OutputSpace, + /// Sample format: 8-bit only, or also a 16-bit / linear float [`Rendered::deep`] (exports). + pub depth: OutputDepth, } impl RenderRequest { pub fn fit(max_w: usize, max_h: usize) -> Self { - Self { max_w, max_h, quality: Quality::Full, apply_crop: true, overlay: Overlay::None, space: OutputSpace::Srgb } + Self { max_w, max_h, quality: Quality::Full, apply_crop: true, overlay: Overlay::None, space: OutputSpace::Srgb, depth: OutputDepth::U8 } } } pub struct Rendered { + /// The result, 8-bit (for deep renders: `deep` reduced to 8 bits, display-encoded). pub image: Rgba8, pub histogram: Histogram, + /// The high-bit-depth result when [`RenderRequest::depth`] asks for one. + pub deep: Option, } /// Everything the per-pixel stage needs, precomputed at output resolution. @@ -348,13 +353,20 @@ fn render_impl(src: Src<'_>, info: &SourceInfo, s: &DevelopSettings, req: &Rende if let Some((a, c)) = shared { c.put(CacheEntry { src: a.clone(), geo, sampled, lin: Some((lin_key, lin)), planes }); } + if req.depth != OutputDepth::U8 { + let deep = finish::finish_deep(&prep, s, frame, info, req.space, req.depth); + let image = deep.to_rgba8(); + let histogram = Histogram::of_srgb8(&image); + lap("finish (deep)", &mut t); + return Rendered { image, histogram, deep: Some(deep) }; + } let image = finish::finish(&prep, s, frame, info, req.space); lap("finish", &mut t); let histogram = Histogram::of_srgb8(&image); lap("histogram", &mut t); let mut image = image; visualize::apply(&mut image, req.overlay, &plan); - Rendered { image, histogram } + Rendered { image, histogram, deep: None } } /// Convenience: render a before/after pair side by side is up to the UI; this renders "before" diff --git a/crates/pipeline/src/output.rs b/crates/pipeline/src/output.rs index fc532bb77..644fc8661 100644 --- a/crates/pipeline/src/output.rs +++ b/crates/pipeline/src/output.rs @@ -138,6 +138,66 @@ impl OutputSpace { } } +/// Sample format of a render. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum OutputDepth { + /// 8-bit display-encoded ([`crate::Rendered::image`] only). + #[default] + U8, + /// 16-bit display-encoded RGB in [`crate::Rendered::deep`]. + U16, + /// 32-bit float *linear* RGB (target primaries, 0..1) in [`crate::Rendered::deep`]. + F32Linear, +} + +/// High-bit-depth RGB samples (3 per pixel, interleaved, row-major). +#[derive(Clone, Debug, PartialEq)] +pub enum DeepSamples { + U16(Vec), + F32(Vec), +} + +/// A high-bit-depth render ([`OutputDepth::U16`] / [`OutputDepth::F32Linear`]). +#[derive(Clone, Debug, PartialEq)] +pub struct DeepImage { + pub width: usize, + pub height: usize, + pub space: OutputSpace, + pub samples: DeepSamples, +} + +impl DeepImage { + /// The same image reduced to 8 bits, display-encoded with the space's curve. + pub fn to_rgba8(&self) -> lightcraft_raster::Rgba8 { + let mut out = lightcraft_raster::Rgba8::new(self.width, self.height); + let trc = self.space.trc(); + match &self.samples { + DeepSamples::U16(v) => { + for (p, c) in out.data.iter_mut().zip(v.chunks_exact(3)) { + *p = [c[0], c[1], c[2]].map(|x| ((x as u32 * 255 + 32767) / 65535) as u8).into_rgba(); + } + } + DeepSamples::F32(v) => { + for (p, c) in out.data.iter_mut().zip(v.chunks_exact(3)) { + *p = [c[0], c[1], c[2]].map(|x| (trc.encode(x) * 255.0 + 0.5) as u8).into_rgba(); + } + } + } + out + } +} + +trait IntoRgba { + fn into_rgba(self) -> [u8; 4]; +} + +impl IntoRgba for [u8; 3] { + fn into_rgba(self) -> [u8; 4] { + [self[0], self[1], self[2], 255] + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index ac684fc73..65a30f78b 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -102,6 +102,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { let n = app.session.selection.ids.len().max(1); ui.label(egui::RichText::new(format!("{n} photo{}", if n == 1 { "" } else { "s" })).color(t.text_dim)); ui.add_space(4.0); + let before = opts.format; choices( ui, "Format", @@ -109,6 +110,16 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { &[(F::Jpeg, "JPEG"), (F::Png, "PNG"), (F::Tiff, "TIFF"), (F::Webp, "WebP"), (F::Avif, "AVIF")], &mut opts.format, ); + if opts.format != before { + // each format starts at its own default depth (TIFF 16-bit, others 8-bit) + opts.bit_depth = None; + } + let depths = lightcraft_engine::export::ExportOptions::bit_depths(opts.format); + if depths.len() > 1 { + let mut bd = opts.bit_depth.filter(|b| depths.iter().any(|d| d.0 == *b)).unwrap_or(depths[0].0); + choices(ui, "Bit depth", "exportBitDepth", depths, &mut bd); + opts.bit_depth = Some(bd); + } if opts.format == F::Avif { ui.label(egui::RichText::new("Color space: sRGB (AVIF)").color(t.text_dim)); } else { @@ -310,7 +321,7 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result crate::merge::start_final(app, opts), diff --git a/docs/mcp.md b/docs/mcp.md index 8beaa8321..f343d88af 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -85,7 +85,7 @@ During development you can also point the client at `cargo run --release -p ligh | `apply_preset {preset, amount?, ids?}` | Apply a preset (ids from `cmd_presets_list`) | | `crop {id?, rect?, angle?, reset?}` | Normalized crop rect `[x0,y0,x1,y1]` and straighten angle | | `render_photo {id?, size?, format?, path?}` | Render with current settings → **image content** (PNG, or JPEG with `format: "jpeg"`), long edge `size` (default 1024) | -| `export {path, id?, longEdge?, quality?, colorSpace?}` | Full-quality render to `.png` / `.jpg` / `.tif` / `.webp` (headless; the desktop app writes PNG) | +| `export {path, id?, longEdge?, quality?, colorSpace?, bitDepth?}` | Full-quality render to `.png` / `.jpg` / `.tif` / `.webp` (headless; the desktop app writes PNG) | Tools taking `id` make that photo active first; without it they act on the active photo. diff --git a/docs/parity.md b/docs/parity.md index f8e1321cb..a0bc6f78c 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -86,7 +86,7 @@ Ordered by tier, then user value, then (low) effort. Take the first one nobody i 19. **LR-EYE-RED** (P1) — red-eye correction (panel placeholder exists; no render). 20. **LR-EDIT-COLOR-POINTCOLOR, LR-EDIT-CALIB, LR-EDIT-LIGHT-CURVE-TAT, LR-EDIT-LIGHT-CURVE-REFINESAT** (P1) — missing develop controls (calibration matters for XMP interop). -21. **LR-EXP-BITDEPTH + LR-EXP-COMPRESSION** (P1) — 16-bit TIFF/PNG and TIFF compression choice. +21. **LR-EXP-COMPRESSION** (P1) — TIFF compression choice (None / LZW / Deflate; the encoder supports all three). 22. **LR-EDIT-OPTICS-PROFILE** (P1) — a lens-profile database of our own (embedded DNG/maker corrections work today). ## Shortcuts: conflicts and missing bindings @@ -440,7 +440,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-EXP-TYPE | File types | P0 | 🟡 | `crates/engine/src/export.rs` (`ExportFormat`) | JPEG, PNG, TIFF, WebP, AVIF; no DNG, JXL or original (+XMP) | | LR-EXP-DIM | Output size | P0 | 🟡 | `crates/ui-egui/src/panels/dialogs.rs` (long edge / full size) | no short edge / width / height / megapixels, ppi, don't-enlarge | | LR-EXP-QUALITY | JPEG quality | P0 | ✅ | `cmd:app.export` (`quality`, `limitKb`) | | -| LR-EXP-BITDEPTH | Bit depth | P1 | ⬜ | | 8-bit only | +| LR-EXP-BITDEPTH | Bit depth | P1 | ✅ | `cmd:app.export` (`bitDepth`), `crates/pipeline/src/output.rs` (`OutputDepth`), `crates/engine/src/export.rs` | 16-bit PNG/TIFF rendered at 16 bits (TIFF defaults to 16), 32-bit float linear TIFF with a linear profile, 10-bit AVIF; high-bit-depth renders run on the CPU | | LR-EXP-COMPRESSION | TIFF compression | P1 | ⬜ | | always Deflate | | LR-EXP-COLORSPACE | Output colour space | P0 | ✅ | `cmd:app.export` (`colorSpace`), `crates/pipeline/src/output.rs`, `crates/engine/src/export.rs` | sRGB, Display P3, Adobe RGB (1998) compatible, ProPhoto RGB, Rec. 2020: rendered from the working space with gamut mapping into the target gamut (CPU + GPU), own ICC profile embedded; AVIF stays sRGB (muxer has no ICC) | | LR-EXP-HDR | HDR output | P2 | ⬜ | | |