From 03291d6f8306a0a571e37f20d416bb6a87d34f8e Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 13:07:02 -0400 Subject: [PATCH] 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 | ⬜ | | |