mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-31 13:38:14 -06:00
Merge branch 'worktree-agent-a7cd35d1e288340c6'
# Conflicts: # docs/parity.md
This commit is contained in:
Generated
+1
@@ -2261,6 +2261,7 @@ dependencies = [
|
||||
"proptest",
|
||||
"rayon",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -88,6 +88,9 @@ 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,
|
||||
depth: lightcraft_engine::pipeline::OutputDepth::U8,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -97,7 +97,7 @@ async fn handle(scope: &web_sys::DedicatedWorkerGlobalScope, backend: Option<Bac
|
||||
match b.read(&key).await.ok().flatten().and_then(|bytes| lightcraft_preview::decode_jpeg(&bytes)) {
|
||||
Some(image) => {
|
||||
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,
|
||||
};
|
||||
|
||||
+45
-12
@@ -290,15 +290,26 @@ pub fn encode_webp_lossless(img: &EncodeImage, meta: &EncodeMeta) -> Result<Vec<
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Encode AVIF (8-bit sRGB, 4:4:4) with `ravif` (rav1e). `quality` 1..=100, `speed` 1 (slow) ..= 10.
|
||||
/// EXIF is embedded; ICC is not supported by the muxer (the file is tagged sRGB via `nclx`).
|
||||
/// Returns [`Error::Encode`] on wasm32 or when built without the `avif` feature.
|
||||
/// Encode AVIF (sRGB, 4:4:4, 10-bit AV1) with `ravif` (rav1e). 8-bit samples are widened by the
|
||||
/// encoder; 16-bit samples keep 10 bits of precision (BT.601 full-range YCbCr computed here).
|
||||
/// `quality` 1..=100, `speed` 1 (slow) ..= 10. EXIF is embedded; ICC is not supported by the muxer
|
||||
/// (the file is tagged sRGB via `nclx`). Returns [`Error::Encode`] for float samples, on wasm32 or
|
||||
/// when built without the `avif` feature.
|
||||
pub fn encode_avif(img: &EncodeImage, quality: u8, speed: u8, meta: &EncodeMeta) -> Result<Vec<u8>> {
|
||||
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<Vec<u8>> {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -146,7 +146,46 @@ pub fn write_matrix_trc(space: &RgbSpace, trc: &Trc) -> Vec<u8> {
|
||||
};
|
||||
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<u8> {
|
||||
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<Vec<(usize, usize)>> = (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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
+364
-30
@@ -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_codecs::{ChromaSubsampling, EncodeImage, EncodeMeta, NamedSpace, Samples, TiffCompression, encode, icc};
|
||||
use lightcraft_meta::{DateTime, Gps, Metadata};
|
||||
pub use lightcraft_pipeline::{DeepImage, DeepSamples, OutputDepth, OutputSpace};
|
||||
use lightcraft_raster::Rgba8;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -103,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.
|
||||
@@ -129,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,
|
||||
@@ -143,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)] };
|
||||
@@ -198,6 +230,11 @@ pub struct ExportOptions {
|
||||
pub remove_location: bool,
|
||||
/// Text watermark (none when absent or the text is empty).
|
||||
pub watermark: Option<Watermark>,
|
||||
/// 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<u8>,
|
||||
}
|
||||
|
||||
impl Default for ExportOptions {
|
||||
@@ -213,12 +250,15 @@ impl Default for ExportOptions {
|
||||
metadata: MetadataPolicy::All,
|
||||
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`).
|
||||
/// Read options from command params (`format`, `quality`, `longEdge`, `limitKb`, `sharpen`,
|
||||
/// `sharpenAmount`, `naming`, `metadata`, `removeLocation`, `watermark`, `colorSpace`, `bitDepth`).
|
||||
pub fn from_json(p: &serde_json::Value) -> Self {
|
||||
use serde_json::Value;
|
||||
let d = Self::default();
|
||||
@@ -243,9 +283,40 @@ impl ExportOptions {
|
||||
_ => None,
|
||||
}
|
||||
.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")],
|
||||
}
|
||||
}
|
||||
|
||||
/// 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() };
|
||||
@@ -257,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<Vec<[f32; 3]>> {
|
||||
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),
|
||||
@@ -269,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()];
|
||||
@@ -295,16 +399,12 @@ 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 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<Vec<u8>, String> {
|
||||
encode_with_metadata(img, o, None)
|
||||
@@ -314,10 +414,12 @@ pub fn encode_image(img: &Rgba8, o: &ExportOptions) -> Result<Vec<u8>, String> {
|
||||
pub fn encode_with_metadata(img: &Rgba8, o: &ExportOptions, meta: Option<&Metadata>) -> Result<Vec<u8>, String> {
|
||||
let mut img = img.clone();
|
||||
output_sharpen(&mut img, o.sharpen, o.sharpen_amount);
|
||||
let 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 +460,69 @@ 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
|
||||
})
|
||||
}
|
||||
|
||||
/// 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<Vec<u8>, 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<Vec<u8>, String> {
|
||||
let mut img = img.clone();
|
||||
output_sharpen_deep(&mut img, o.sharpen, o.sharpen_amount);
|
||||
if let Some(wm) = &o.watermark {
|
||||
let wm = Watermark { color: srgb8_in(img.space, wm.color), ..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<f64> {
|
||||
let s = s.trim().trim_end_matches(['s', '"']).trim();
|
||||
@@ -414,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_now(id, size, size)?;
|
||||
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 })
|
||||
}
|
||||
|
||||
@@ -516,4 +681,173 @@ 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<NamedSpace>) {
|
||||
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:?}");
|
||||
}
|
||||
|
||||
/// 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<u32> = (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}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -355,7 +355,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,
|
||||
};
|
||||
@@ -434,7 +434,7 @@ impl QuickJob {
|
||||
let done = |rendered: Result<Rendered, String>, 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) {
|
||||
@@ -636,6 +636,24 @@ impl crate::Session {
|
||||
r.rendered
|
||||
}
|
||||
|
||||
/// 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<Rendered, String> {
|
||||
let mut job = self.render_job(id, max, max, false, true).ok_or("no such photo")?;
|
||||
job.request.space = space;
|
||||
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
|
||||
}
|
||||
|
||||
/// The source proxy for pixel-statistics commands (auto tone/WB), loading synchronously.
|
||||
pub fn source_now(&mut self, id: PhotoId, level: SourceLevel) -> Result<Arc<Rgb32f>, String> {
|
||||
let p = self.catalog.photo(id).ok_or("no such photo")?.clone();
|
||||
|
||||
@@ -218,8 +218,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<String> = m.iter().map(|r| format!("vec3<f32>({:?}, {:?}, {:?})", r[0], r[1], r[2])).collect();
|
||||
s += &format!("const {name} = array<vec3<f32>, 3>({});\n", rows.join(", "));
|
||||
}
|
||||
|
||||
@@ -184,7 +184,8 @@ pub fn stage_bytes(stages: &StageCache) -> usize {
|
||||
pub fn render(src: &Arc<Rgb32f>, info: &SourceInfo, s: &DevelopSettings, req: &RenderRequest, stages: Option<&StageCache>) -> Option<Rendered> {
|
||||
#[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()?;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -495,7 +495,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);
|
||||
@@ -523,7 +523,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.
|
||||
|
||||
@@ -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<u32>) {
|
||||
let w = pu(F_W);
|
||||
@@ -399,9 +419,14 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- 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<vec3<f32>, 3>(
|
||||
vec3<f32>(pf(F_OUT_M), pf(F_OUT_M + 1u), pf(F_OUT_M + 2u)),
|
||||
vec3<f32>(pf(F_OUT_M + 3u), pf(F_OUT_M + 4u), pf(F_OUT_M + 5u)),
|
||||
vec3<f32>(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<u32>) {
|
||||
let k = pf(F_GRAIN_AMT) * g * (0.35 + 2.6 * lum * (1.0 - lum));
|
||||
e = e + k;
|
||||
}
|
||||
e = vec3<f32>(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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,6 +188,8 @@ pub fn helper_tools(has_ui: bool) -> Vec<Value> {
|
||||
"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"},
|
||||
"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}"}
|
||||
}),
|
||||
&[],
|
||||
@@ -534,6 +536,8 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, args: &Value) -> ToolResult {
|
||||
"metadata",
|
||||
"removeLocation",
|
||||
"watermark",
|
||||
"colorSpace",
|
||||
"bitDepth",
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
@@ -20,3 +20,4 @@ serde = { workspace = true }
|
||||
lightcraft-scenes = { workspace = true }
|
||||
proptest = { workspace = true }
|
||||
image = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
@@ -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::{DeepImage, DeepSamples, OutputDepth, 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<Vig>,
|
||||
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,74 @@ 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, 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)))));
|
||||
[x[0], x[1], x[2], 255]
|
||||
}
|
||||
});
|
||||
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.
|
||||
/// `exact`: encode with the exact sRGB curve instead of the (8/10-bit accurate) table.
|
||||
pub(crate) fn finish_with<T: Copy + Default + Send>(
|
||||
p: &Prepared,
|
||||
fp: &FinishParams,
|
||||
exact: bool,
|
||||
store: impl Fn([f32; 3]) -> T + Sync + Send,
|
||||
) -> Vec<T> {
|
||||
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 +321,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 +469,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 {
|
||||
@@ -409,7 +490,7 @@ pub(crate) fn finish(p: &Prepared, s: &DevelopSettings, frame: &Frame, info: &So
|
||||
}
|
||||
|
||||
// --- 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])];
|
||||
@@ -427,7 +508,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
|
||||
|
||||
@@ -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::{DeepImage, DeepSamples, OutputDepth, OutputSpace, OutputTrc};
|
||||
pub use visualize::Overlay;
|
||||
|
||||
use lightcraft_develop::{DevelopSettings, Treatment};
|
||||
@@ -84,17 +86,24 @@ 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,
|
||||
/// 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 }
|
||||
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<DeepImage>,
|
||||
}
|
||||
|
||||
/// Everything the per-pixel stage needs, precomputed at output resolution.
|
||||
@@ -377,13 +386,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 });
|
||||
}
|
||||
let image = finish::finish(&prep, s, frame, info);
|
||||
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"
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
//! 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<OutputSpace> {
|
||||
let k: String = s.chars().filter(|c| c.is_ascii_alphanumeric()).collect::<String>().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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<u16>),
|
||||
F32(Vec<f32>),
|
||||
}
|
||||
|
||||
/// 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::*;
|
||||
|
||||
#[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::<f32>() - 1.0).abs() < 1e-4, "{o:?}");
|
||||
}
|
||||
assert!((o.luma().iter().sum::<f32>() - 1.0).abs() < 1e-4);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -89,6 +89,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",
|
||||
@@ -96,6 +97,34 @@ 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 {
|
||||
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) {
|
||||
@@ -284,6 +313,7 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result<serde_jso
|
||||
"format": opts.format, "quality": opts.quality, "longEdge": long_edge, "limitKb": limit_kb,
|
||||
"sharpen": opts.sharpen, "sharpenAmount": opts.sharpen_amount, "naming": opts.naming, "dir": dir,
|
||||
"metadata": opts.metadata, "removeLocation": opts.remove_location, "watermark": opts.watermark,
|
||||
"colorSpace": opts.color_space, "bitDepth": opts.bit_depth,
|
||||
}),
|
||||
),
|
||||
Dialog::Merge { opts } => crate::merge::start_final(app, opts),
|
||||
|
||||
+1
-1
@@ -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?, 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.
|
||||
|
||||
|
||||
+14
-20
@@ -39,7 +39,7 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table
|
||||
| P. Enhance (ENH) | 0 | 0 | 2 | 0 | — | — |
|
||||
| Q. HDR (HDR) | 0 | 0 | 5 | 0 | — | — |
|
||||
| R. Video (VID) | 0 | 0 | 5 | 1 | — | 0/2 (0%) |
|
||||
| S. Export (EXP) | 4 | 6 | 8 | 0 | 2/7 (29%) | 2/7 (29%) |
|
||||
| S. Export (EXP) | 6 | 6 | 6 | 0 | 3/7 (43%) | 3/7 (43%) |
|
||||
| T. Share (SHARE) | 0 | 0 | 0 | 4 | — | — |
|
||||
| U. Map & location (MAP) | 0 | 1 | 1 | 0 | — | 0/1 (0%) |
|
||||
| V. Preferences (PREF) | 0 | 3 | 5 | 3 | 0/1 (0%) | 0/4 (0%) |
|
||||
@@ -48,35 +48,29 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table
|
||||
| Y. Menus | 46 | 18 | 21 | 8 | 37/47 (79%) | 7/23 (30%) |
|
||||
| Z. Keyboard shortcuts (desktop) | 46 | 16 | 18 | 1 | 39/52 (75%) | 7/23 (30%) |
|
||||
| Lightroom Classic extras | 4 | 19 | 56 | 9 | — | 3/21 (14%) |
|
||||
| **Total** | 213 | 101 | 186 | 37 | 155/198 (78%) | 49/144 (34%) |
|
||||
| **Total** | 215 | 101 | 184 | 37 | 156/198 (79%) | 50/144 (35%) |
|
||||
<!-- /parity:summary -->
|
||||
|
||||
## Top gaps
|
||||
|
||||
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-MASK-OVERLAY + LR-MASK-PINS** (P0/P1) — show the evaluated mask alpha as a coloured overlay (all mask
|
||||
1. **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.
|
||||
3. **LR-MASK-BRUSH + LR-MASK-SLIDERS** (P0) — apply the stored Auto Mask flag (edge-aware brush) and the local
|
||||
2. **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.
|
||||
4. **LR-REM-SPOT-EDIT + LR-REM-BRUSH-PARAMS** (P0) — select a spot pin, move target/source, delete with ⌫,
|
||||
3. **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.
|
||||
5. **LR-VIEW-PHOTOGRID** (P0) — group the justified grid by capture date with headers. Low–medium effort.
|
||||
6. **LR-LIB-KEYWORD** (P0) — rename/delete a keyword library-wide; keyword list in the left panel for browsing.
|
||||
7. **LR-PREF-GENERAL + LR-IMP-RAWDEFAULT** (P0/P1) — a Settings dialog: raw defaults (preset or camera-specific),
|
||||
XMP prefs, cache, GPU. Medium effort.
|
||||
8. **LR-IMP-ADD-DIALOG + LR-IMP-LOCAL** (P0) — an import review grid with per-photo checkboxes and destination
|
||||
4. **LR-VIEW-PHOTOGRID** (P0) — group the justified grid by capture date with headers. Low–medium effort.
|
||||
5. **LR-LIB-KEYWORD** (P0) — rename/delete a keyword library-wide; keyword list in the left panel for browsing.
|
||||
6. **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.
|
||||
9. **LR-EXP-TYPE + LR-EXP-DIM** (P0) — Original (+XMP) and DNG export (writer exists in
|
||||
7. **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.
|
||||
10. **LR-IMP-FORMATS** (P0) — CR3, compressed NEF/RAF/ORF, RW2 v4 (preview only today); HEIC/AVIF decode (no
|
||||
8. **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).
|
||||
11. **LR-VIEW-FULLSCREEN + LR-VIEW-NAVIGATOR + LR-VIEW-INFOOVERLAY** (P1) — view modes. Medium effort.
|
||||
12. **LR-LIB-RENAME + LR-LIB-CAPTURETIME + LR-LIB-LABEL UI** (P1) — batch rename, capture-time edit, label menu/names.
|
||||
13. **LR-EXP-BITDEPTH + LR-EXP-COMPRESSION** (P1) — 16-bit TIFF/PNG and TIFF compression choice.
|
||||
14. **LR-EDIT-OPTICS-PROFILE** (P1) — a lens-profile database of our own (embedded DNG/maker corrections work today).
|
||||
9. **LR-LIB-RENAME + LR-LIB-CAPTURETIME + LR-LIB-LABEL UI** (P1) — batch rename, capture-time edit, label menu/names.
|
||||
10. **LR-EDIT-OPTICS-PROFILE** (P1) — a lens-profile database of our own (embedded DNG/maker corrections work today).
|
||||
|
||||
## Shortcuts: conflicts and missing bindings
|
||||
|
||||
@@ -429,9 +423,9 @@ 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 | ⬜ | `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`) | |
|
||||
|
||||
Reference in New Issue
Block a user